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,1263 @@
1
+ // panel/panel-settings.js —— 面板的"生效表 + 三态装配"
2
+ //
3
+ // 三件事集中在这里,避免 main.js 继续膨胀:
4
+ // ① SHELL_APPLIERS:与渲染器无关的控件怎么落地;其余设置一律经 renderer.setSetting 下发
5
+ // ② applyPanelSetting:控件回调的唯一入口(含"回显生效值"与"不支持则回退")
6
+ // ③ rebuildPanelFor:按当前渲染器能力重算三态 + 重跑能力自检(契约 INV-05)
7
+
8
+ import { dom, stage, state } from '../app/context.js';
9
+ import { refreshPanel } from '../app/memory-bars.js';
10
+ import { bindPanelBarRefresh } from '../app/memory-bars.js';
11
+ import { resetStage, trackElementStage } from '../infra/ready.js';
12
+ import { rendererSupports, setRendererSetting } from '../app/renderer-setting.js';
13
+ import * as uiState from '../app/ui-state.js';
14
+ import { stageLog, stageStatus } from '../app/status.js';
15
+ import { PANEL_GROUPS, applyCapabilities, applyKeyedControlReplacements, mergeSlotControls, selfCheck, settingKeyOf } from './schema.js';
16
+ import { activeChain } from '../engine/effect-chain.js';
17
+ import { getSharedVirtualFS } from '../infra/vfs.js';
18
+ import { mountPanel } from './view.js';
19
+ import { syncBrand, syncPluginToolbar } from './toolbar.js';
20
+ import { EVENTS } from '../infra/events.mjs';
21
+ import { applyDebugLog, applyHangGuard, applyMemoryGuard, applyShowStatus, connectShellControls, runSettingsAction } from './shell-controls.js';
22
+ import { applyBackground, applyTheme, readViewSettings, saveViewSettings } from '../app/view-settings.js';
23
+ import { bindBgGroupToggle, refreshBgSources } from './background-list.js';
24
+ import { applyAnimLoop, applyTransportMotion } from '../app/transport-actions.js';
25
+ import { repaintActive } from '../render/playback.js';
26
+ import {
27
+ buildAnimationList, currentAnimationId, durationText, labelWithDuration, onCycleAdvance,
28
+ primeAnimationDurations, syncTransportOptions,
29
+ } from '../app/anim-list.js';
30
+
31
+ /**
32
+ * 皮肤列表刷新钩子:它依赖"当前模型",实现在 model-actions 里(那边反过来要用 rebuildPanelFor),
33
+ * 用钩子注入把这条双向依赖拆成单向:model-actions → panel-settings。
34
+ */
35
+ let refreshSkinHook = () => {};
36
+ export function connectPanelRefresh(fn) {
37
+ if (typeof fn === 'function') refreshSkinHook = fn;
38
+ }
39
+
40
+ /**
41
+ * 外壳自己处理的控件:主题/背景走 CSS 与 Surface(与渲染器无关);
42
+ * 皮肤虽然也由外壳记住偏好,但**落地一律经 renderer.setSetting**(契约 INV-01)。
43
+ * 其它设置项同理——面板层不得直接调用舞台的 set 方法。
44
+ *
45
+ * 注:`ctl-mix-mode` / `ctl-skin-mix` / `ctl-part-filter` / `ctl-part-filter-reset`
46
+ * 已从面板移除(混合过渡统一由「播放 → 混合时长」一个滑块控制,部件筛除整项下线),
47
+ * 对应的映射表条目一并删除;底层 setMixMode / setMixTime 保留(部件筛除整项已下线,底层也删)。
48
+ */
49
+ const SHELL_APPLIERS = {
50
+ 'ctl-theme': (v) => applyTheme(v),
51
+ 'ctl-bg-mode': (v) => applyBackground(v),
52
+ 'ctl-anim-loop': (v) => applyAnimLoop(v),
53
+ // 皮肤切换同样经渲染器(INV-01):Live2D 面没声明 skin ⇒ 不会打到隐藏的 spine 舞台
54
+ 'ctl-skin-list': (v) => setRendererSetting('skin', v),
55
+ // 内存保护与防卡死保护是两件事:前者管内存容量(防 OOM),后者管帧停滞(防假死)
56
+ 'ctl-memory-guard': (v) => applyMemoryGuard(v),
57
+ 'ctl-hang-guard': (v) => applyHangGuard(v),
58
+ 'ctl-show-status': (v) => applyShowStatus(v),
59
+ 'ctl-debug-log': (v) => applyDebugLog(v),
60
+ 'ctl-settings-io': (v) => runSettingsAction(v),
61
+ };
62
+
63
+ /**
64
+ * **一次性动作控件**:点击即执行、本身没有"状态"的那类按钮组(schema 里 kind==='actions')。
65
+ *
66
+ * 为什么必须排除在持久化之外(真 bug 的成因):这类控件如果被 rememberPanelSetting 存下来,
67
+ * 启动回放 restorePanelSettings 会把它**再执行一次**——
68
+ * 导出设置 ⇒ 每次刷新都自动下载一份设置;
69
+ * 导入设置 ⇒ 每次刷新弹一次文件选择器;
70
+ * 恢复默认 ⇒ 每次刷新把用户设置清掉。
71
+ * 判据按 schema 的数据形状推导(kind==='actions'),不手写 id 名单:以后再加动作组自动被排除。
72
+ */
73
+ const ONE_SHOT_IDS = (() => {
74
+ const ids = new Set();
75
+ for (const group of PANEL_GROUPS) {
76
+ for (const ctl of (group && group.controls) || []) {
77
+ if (ctl && ctl.kind === 'actions' && ctl.id) ids.add(String(ctl.id));
78
+ }
79
+ }
80
+ return ids;
81
+ })();
82
+
83
+ /** 可点控件全集 = 外壳项 + 声明了能力键的项(能力过滤决定其中哪些真的可点) */
84
+ const WIRED_IDS = (() => {
85
+ const ids = new Set(Object.keys(SHELL_APPLIERS));
86
+ for (const group of PANEL_GROUPS) {
87
+ for (const ctl of group.controls || []) {
88
+ if (ctl && ctl.id && settingKeyOf(ctl.id)) ids.add(ctl.id);
89
+ }
90
+ }
91
+ return [...ids];
92
+ })();
93
+
94
+ /**
95
+ * 记住一项面板设置并落盘(刷新后保持)。
96
+ * 与视图偏好共用同一存储键的 panel 字段:一处读写,避免两套持久化各说一套。
97
+ */
98
+ // S2 起:启动不再"先回放默认值再读回",默认值由工厂 loadAll 一次性补齐,
99
+ // 因此原来那套 suspendPanelPersist 旁路开关已删除(写盘只有工厂一条路径,契约 R1)。
100
+
101
+ /**
102
+ * 记住一项面板设置(**唯一写入口**,契约 R1)。
103
+ * 去抖落盘、校验回落默认、写 dataset 都在工厂里,这里不重复实现。
104
+ * @param {string} id
105
+ * @param {unknown} value
106
+ */
107
+ function rememberPanelSetting(id, value) {
108
+ // 一次性动作不记(见 ONE_SHOT_IDS:记了就会在下次启动回放时重放)
109
+ if (ONE_SHOT_IDS.has(String(id))) return;
110
+ uiState.set(id, value);
111
+ }
112
+
113
+ /**
114
+ * 启动时把上次保存的面板设置读回并应用。
115
+ *
116
+ * 契约 R2:读回必须是**纯内存**的一步(工厂 loadAll),应用与显示重放交给 applyAll。
117
+ * @param {Set<string>} [skipIds] 有专门恢复通道的项(STARTUP_SKIP:主题/背景/MSAA)
118
+ * @returns {number} 读回的项数
119
+ */
120
+ export function restorePanelSettings(skipIds) {
121
+ const skip = skipIds instanceof Set ? skipIds : new Set(skipIds || []);
122
+ // 历史脏数据迁移:旧版本把一次性动作控件也存进了 panel("导出过设置后每次刷新又导出一次")。
123
+ // 就地清掉并写回存储,否则每次启动都要绕一遍。
124
+ try {
125
+ const saved = (readViewSettings().panel) || {};
126
+ const stale = Object.keys(saved).filter((id) => ONE_SHOT_IDS.has(id));
127
+ if (stale.length) {
128
+ for (const id of stale) delete saved[id];
129
+ try { saveViewSettings({ panel: { ...saved } }); } catch { /* 存储不可用就只跳过 */ }
130
+ stageLog('warn', '已清理面板设置里的一次性动作记录(旧版本遗留,会导致每次启动重放)', { removed: stale });
131
+ }
132
+ } catch { /* 迁移失败不影响读回 */ }
133
+
134
+ uiState.setApplySkip(skip);
135
+ const info = uiState.loadAll(); // 纯内存读回(在首次面板渲染之后调用时,值仍会覆盖显示态)
136
+ uiState.applyAll({ render: true }); // 生效 + 重放显示态(幂等)
137
+ if (info.rejected.length) {
138
+ stageLog('warn', '面板设置里有非法值,已回落默认', { rejected: info.rejected });
139
+ }
140
+ return info.loaded;
141
+ }
142
+
143
+ /** 面板控件回调:外壳项直接处理,其余一律经 renderer.setSetting(回显用生效值) */
144
+ /**
145
+ * 插槽控件的动作回调表:控件 id → { pluginId, fn }。
146
+ * 每次重建面板时刷新(控件随插件装载/卸载出现或消失)。
147
+ */
148
+ const slotActions = new Map();
149
+
150
+ /**
151
+ * 插件声明设置项的**当前快照**(当前值 ?? 清单里的 default)。
152
+ * 为什么由面板侧构造:值存在 uiState 工厂里,插件宿主读不到;
153
+ * render(props) 与 onAction(props) 必须看到同一份真相,所以两边共用这个函数。
154
+ * @param {string} pluginId
155
+ * @returns {Record<string, unknown>}
156
+ */
157
+ function pluginSettingsSnapshot(pluginId) {
158
+ const host = state.pluginHost;
159
+ const decls = host && typeof host.pluginSettings === 'function' ? host.pluginSettings(pluginId) : [];
160
+ const out = {};
161
+ for (const d of decls) {
162
+ if (!d || !d.key) continue;
163
+ const cur = uiState.getValue(d.key);
164
+ out[d.key] = (cur === undefined || cur === null) ? d.default : cur;
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /**
170
+ * 构造插槽回调的 owner props(设计 §4.1)。
171
+ * 现阶段:settings 真实值 + setSetting;geometry/activeSurface/document 先给 null,
172
+ * S4 换成 getter(resize / 换模型 / 换面后值会变,快照会陈旧)。
173
+ * @param {{pluginId?: string}} entry
174
+ * @returns {object}
175
+ */
176
+ /**
177
+ * 控件 id → **存值键**(`spec.setting` 给定时是清单设置键,否则就是控件 id)。
178
+ * 每次重建刷新:控件(连同它的 setting)随插件装载/卸载出现或消失。
179
+ */
180
+ const slotStoreKeyById = new Map();
181
+
182
+ /**
183
+ * **反向表**:设置键 → 绑定它的控件 id 数组。
184
+ *
185
+ * 为什么需要:一个设置键可能被**多个控件**绑定(同一个值用两种形态展示),
186
+ * 也可能被程序化写入(另一个控件、setSetting、恢复预设)。
187
+ * 只维护 ctl→key 的话,"外部改了这个键"没有任何控件会重绘,界面停在旧值。
188
+ * 为什么是数组:不做一对一假设(实测夹具里就挂了两个控件)。
189
+ */
190
+ const slotCtlIdsByKey = new Map();
191
+
192
+ /** 本 tick 待重绘的控件 id(同一控件被 ctl id 与 key 两条路径同时命中时只重绘一次) */
193
+ const repaintPending = new Set();
194
+ let repaintScheduled = false;
195
+ /** 重绘计数(验收可读:每个控件实际重绘了几次) */
196
+ const repaintStats = { total: 0, byId: new Map() };
197
+
198
+ /** 把重绘计数挂到 window,供验收/排障读取(不写 dataset,避免污染 outerHTML 快照) */
199
+ function publishRepaintStats() {
200
+ try {
201
+ if (typeof window === 'undefined') return;
202
+ window.__PANEL_REPAINT__ = {
203
+ total: repaintStats.total,
204
+ byId: Object.fromEntries(repaintStats.byId),
205
+ };
206
+ } catch { /* 无 window 忽略 */ }
207
+ }
208
+
209
+ /**
210
+ * 安排一次控件重绘(**按 tick 合并**)。
211
+ *
212
+ * 去重方式:同一 microtask 内对同一个控件只重绘一次 —— 这样
213
+ * "外部改设置键" 与 "控件自己的 id 也被写" 两条路径同时命中时不会闪两下、计数也是 1。
214
+ * @param {string} id 控件 id
215
+ */
216
+ function scheduleControlRepaint(id) {
217
+ const key = String(id || '');
218
+ if (!key) return;
219
+ repaintPending.add(key);
220
+ if (repaintScheduled) return;
221
+ repaintScheduled = true;
222
+ const flush = () => {
223
+ repaintScheduled = false;
224
+ const ids = [...repaintPending];
225
+ repaintPending.clear();
226
+ for (const ctlId of ids) {
227
+ try {
228
+ const value = uiState.getValue(storeKeyOf(ctlId));
229
+ const row = typeof document !== 'undefined'
230
+ ? document.querySelector(`#right-panel [data-ctl="${ctlId}"]`) : null;
231
+ // 节点已经不在了(插件卸载后重建过面板)⇒ 跳过:
232
+ // applier 注册在 uiState 里撤不掉,靠这条守卫让"已消失的控件"不产生悬空重绘与计数。
233
+ if (!row) continue;
234
+ // 开关按 aria-checked 回填,其余按"选中项"回填(renderControl 用静态表判 kind,插件控件不在表里)
235
+ if (row.querySelector('[role="switch"]')) state.panel.setChecked(ctlId, value === true);
236
+ else state.panel.setValue(ctlId, value);
237
+ repaintStats.total += 1;
238
+ repaintStats.byId.set(ctlId, (repaintStats.byId.get(ctlId) || 0) + 1);
239
+ } catch { /* 单个控件重绘失败不影响其它控件 */ }
240
+ }
241
+ publishRepaintStats();
242
+ };
243
+ if (typeof queueMicrotask === 'function') queueMicrotask(flush);
244
+ else setTimeout(flush, 0);
245
+ }
246
+
247
+ /**
248
+ * 当前文档的**文件清单**缓存(document.files 的唯一来源)。
249
+ *
250
+ * 就绪语义(明确选定):**未就绪返回 null**,不是 []。
251
+ * 理由:[] 会被插件读成"这个文档没有文件",而真实情况往往是"还没列举完/还没解包"——
252
+ * 把两者混为一谈会让插件写出错误的降级逻辑。空目录照实返回 [](真的是空)。
253
+ *
254
+ * 刷新策略:
255
+ * · 键 = rootId|rel|kind|id,**只在"当前文档变了"或"缓存缺失"时刷新**
256
+ * · 同一键在途时只发一次(pending 集合),连续 render 不会重复列举
257
+ * · 列举完成 ⇒ 缓存写入 + 面板重建一次(让插件的 render 拿到真实清单)
258
+ */
259
+ /** 控件 id → 清单里声明的默认值(插件设置键的兜底;替换型控件也走它) */
260
+ const slotDeclaredDefaultById = new Map();
261
+
262
+ const docFilesCache = new Map();
263
+ const docFilesPending = new Set();
264
+ const docFilesStats = { lists: 0, repaints: 0 };
265
+
266
+ /**
267
+ * 目录清单请求(/api/catalog)的超时上限(ms)。
268
+ *
269
+ * 取值理由:catalog 只列目录树、不读字节,实测正常在 300ms 内返回;6s 已留 20 倍余量,
270
+ * 又远小于"用户会以为面板卡住"的量级。**必须有上限**:该请求一旦挂起,
271
+ * docFilesPending 里的键会一直占位 ⇒ 该条目的文件清单永远是 null,且**再也不会重试**。
272
+ * 与插件宿主里的其它超时保持同一量级(fetchCapacity 1.5s / worker 载 lib 8s / 解包看门狗 60s)。
273
+ */
274
+ const DOC_FILES_TIMEOUT_MS = 6000;
275
+
276
+ /** 把列举计数挂到 window(验收/排障可读;不写 dataset 以免污染快照) */
277
+ function publishDocFilesStats() {
278
+ try {
279
+ if (typeof window === 'undefined') return;
280
+ window.__DOC_FILES__ = { lists: docFilesStats.lists, repaints: docFilesStats.repaints, cached: docFilesCache.size };
281
+ } catch { /* 无 window 忽略 */ }
282
+ }
283
+
284
+ /**
285
+ * 清单缓存键的 **prefix 段**。
286
+ *
287
+ * 为什么必须把 prefix 放进键里:VFS 前缀是按"内容指纹"派生的(换插件、重新解包都会变),
288
+ * 而 prefix 一变,同一份 rootId/rel 下的**文件清单就完全不同**。
289
+ * 键里不含 prefix 时,重新解包后仍会命中旧列表 —— 连"重新列举"都不会发生。
290
+ *
291
+ * 三种情况用三个互不相同的标记("本来就没有" 与 "还没就绪" 绝不能混成一种):
292
+ * · 容器条目、前缀已就绪 ⇒ `vfs:<prefix>`
293
+ * · 容器条目、前缀未就绪 ⇒ `vfs:`(空前缀仍带前导标记,与 `none` 区分)
294
+ * · 非容器条目(目录/单文件)⇒ `none`(这类条目天生没有 VFS 前缀)
295
+ * @param {object} model 当前模型条目
296
+ * @returns {string}
297
+ */
298
+ function docFilesPrefixOf(model) {
299
+ const isContainer = (model && (model.container === 'unityfs' || model.kind === 'bundle'));
300
+ if (!isContainer) return 'none';
301
+ return `vfs:${state.vfsPrefix || ''}`;
302
+ }
303
+
304
+ /** @param {object} model 当前模型条目 @returns {string} 缓存键 = rootId|rel|kind|id|prefix 段 */
305
+ function docFilesKeyOf(model) {
306
+ return [model && model.rootId, model && model.rel, model && model.kind, model && (model.id || model.name), docFilesPrefixOf(model)]
307
+ .map((v) => String(v == null ? '' : v)).join('|');
308
+ }
309
+
310
+ /**
311
+ * 取当前文档的文件名清单。
312
+ *
313
+ * 快路径(同步、不耗网络):
314
+ * · 容器条目且 VFS 前缀已注册 ⇒ vfs.list(prefix)(解包产物名,升序)
315
+ * · 条目自带 files 数组 ⇒ 直接用它
316
+ * 慢路径(异步):其它条目走 /api/catalog 找到本条目,取 entry.name + assets[].name
317
+ * @param {object} model 当前模型条目
318
+ * @returns {string[]|null} 未就绪返回 null
319
+ */
320
+ function docFilesOf(model) {
321
+ if (!model) return null;
322
+ const key = docFilesKeyOf(model);
323
+ if (docFilesCache.has(key)) return docFilesCache.get(key).slice();
324
+ // 快路径 1:容器条目 → VFS 前缀(同步;前缀未注册时返回 null 表示"还没解包完")
325
+ if (model.container === 'unityfs' || model.kind === 'bundle') {
326
+ const prefix = state.vfsPrefix;
327
+ const listed = prefix ? safeVfsList(prefix) : null;
328
+ if (listed) {
329
+ docFilesStats.lists += 1;
330
+ docFilesCache.set(key, listed);
331
+ publishDocFilesStats();
332
+ return listed.slice();
333
+ }
334
+ requestDocFiles(model, key);
335
+ return null;
336
+ }
337
+ // 快路径 2:条目自己带清单
338
+ if (Array.isArray(model.files)) {
339
+ docFilesCache.set(key, model.files.map(String));
340
+ return model.files.map(String);
341
+ }
342
+ requestDocFiles(model, key);
343
+ return null;
344
+ }
345
+
346
+ /** 调 VFS 列举(模块级细节隔离,便于测试替换/失败兜底) */
347
+ function safeVfsList(prefix) {
348
+ try {
349
+ const vfs = getSharedVirtualFS();
350
+ return typeof vfs.list === 'function' ? vfs.list(prefix) : null;
351
+ } catch { return null; }
352
+ }
353
+
354
+ /**
355
+ * 异步补清单(同一键在途时只发一次)。
356
+ *
357
+ * 为什么要异步:目录型条目的文件列表**只有服务端知道**(catalog 的 entry + assets),
358
+ * 而 document 是 getter、必须同步出值 —— 所以这里"先记 pending、拿到再回填 + 重建一次"。
359
+ * @param {object} model 条目 @param {string} key 缓存键
360
+ */
361
+ function requestDocFiles(model, key) {
362
+ if (docFilesPending.has(key)) return;
363
+ docFilesPending.add(key);
364
+ // 原始 rootId 与编码后的 URL 都留:日志展示原文,请求用编码版,排障时不必反推
365
+ const rawRootId = String(model.rootId || '');
366
+ const url = `/api/catalog?root=${encodeURIComponent(rawRootId)}`;
367
+ // 用 async/await 而不是 then 链:**只有 finally 能保证"成功 / 失败 / 超时"三条路径都清 pending**
368
+ // (原来的写法把 delete 塞在 then 与 catch 里,超时/挂起时根本不落地 ⇒ 永久占位、不再重试)
369
+ (async () => {
370
+ try {
371
+ const res = await fetch(url, {
372
+ headers: { accept: 'application/json' },
373
+ // 服务端挂起时浏览器可能几分钟不给响应;没有上限就等于"这个条目的清单永远没有"
374
+ signal: AbortSignal.timeout(DOC_FILES_TIMEOUT_MS),
375
+ });
376
+ if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { code: 'catalog-http' });
377
+ const data = await res.json();
378
+ const list = Array.isArray(data && data.entries) ? data.entries : [];
379
+ const hit = list.find((e) => String(e && (e.id || e.name)) === String(model.id || model.name));
380
+ // 没找到不算失败:目录树可能还没包含该条目(生成中),下次触发会再查一次
381
+ if (!hit) return;
382
+ const names = [];
383
+ if (hit.entry && hit.entry.name) names.push(String(hit.entry.name));
384
+ for (const a of Array.isArray(hit.assets) ? hit.assets : []) if (a && a.name) names.push(String(a.name));
385
+ if (!names.length) return;
386
+ docFilesStats.lists += 1;
387
+ docFilesCache.set(key, names);
388
+ publishDocFilesStats();
389
+ // 清单就绪 ⇒ 面板重建一次,让插件的 render 拿到真实清单(与插件装载走同一条重建链)
390
+ if (state.panel) {
391
+ docFilesStats.repaints += 1;
392
+ publishDocFilesStats();
393
+ // 必须派发到 **window**:重建立监听器挂在 window 上(bindPluginPanelRebuild),
394
+ // 派发到 document 收不到(DOM 事件路径上它到不了 window 监听)⇒ 重绘静默不生效。
395
+ window.dispatchEvent(new CustomEvent(EVENTS.PLUGINS_LOADED, { detail: { reason: 'document-files' } }));
396
+ }
397
+ } catch (e) {
398
+ // 不静默:留一条可诊断记录(含 rootId 与原因)。文件清单保持 null,
399
+ // pending 随后在 finally 释放 ⇒ 再次触发会**重新请求**,而不是永久禁用该条目。
400
+ const timedOut = !!e && (e.name === 'TimeoutError' || e.name === 'AbortError');
401
+ const reason = timedOut ? `超时(>${DOC_FILES_TIMEOUT_MS}ms)` : String((e && e.message) || e);
402
+ stageLog('warn', `目录清单获取失败:${String(model.name || model.id || '')}(${reason})——该条目文件列表保持为空,可再次触发重试`, {
403
+ rootId: rawRootId, url, reason, timeoutMs: DOC_FILES_TIMEOUT_MS,
404
+ });
405
+ } finally {
406
+ docFilesPending.delete(key);
407
+ }
408
+ })();
409
+ }
410
+
411
+ /** 控件 id → 存值键(没有映射就是它自己) */
412
+ function storeKeyOf(id) {
413
+ const k = String(id || '');
414
+ return slotStoreKeyById.get(k) || k;
415
+ }
416
+
417
+ /**
418
+ * 构造插槽 owner props(设计 §4.1)—— **全部字段是 getter**,不是快照。
419
+ *
420
+ * 为什么必须是 getter:面板只在"插件装载完成 / 换渲染面"时重建,
421
+ * resize 与换模型**不会**重建面板(surface.js 有 onResize 但面板没订阅、换模型只刷动作列表)。
422
+ * 快照会让插件读到过期尺寸与过期文档;getter 每次读都取当前值,且不会把 surface/state 的可变引用交出去。
423
+ * @param {{pluginId?: string}} entry
424
+ * @returns {object}
425
+ */
426
+ function slotPropsFor(entry) {
427
+ const pluginId = String((entry && entry.pluginId) || '');
428
+ return {
429
+ /** 插件声明设置项的当前值(当前值 ?? 声明默认值),每次读都重新求值 */
430
+ get settings() { return pluginSettingsSnapshot(pluginId); },
431
+ /** 舞台几何:{ cssW, cssH, dpr, res } —— 唯一来源是 surface.geometry() */
432
+ get geometry() {
433
+ try {
434
+ const g = state.surface && typeof state.surface.geometry === 'function' ? state.surface.geometry() : null;
435
+ return g ? { cssW: g.cssW, cssH: g.cssH, dpr: g.dpr } : null;
436
+ } catch { return null; }
437
+ },
438
+ /** 当前渲染面:'spine' | 'live2d' | null */
439
+ get activeSurface() {
440
+ try {
441
+ const r = state.surface && typeof state.surface.activeRenderer === 'function' ? state.surface.activeRenderer() : null;
442
+ return r && r.id ? String(r.id) : null;
443
+ } catch { return null; }
444
+ },
445
+ /** 当前文档的**只读摘要**(不给插件 model 引用:它持有的是可变对象) */
446
+ get document() {
447
+ const m = state.selectedModel;
448
+ if (!m) return null;
449
+ return {
450
+ rootId: m.rootId ? String(m.rootId) : null,
451
+ rel: m.rel ? String(m.rel) : null,
452
+ kind: m.kind ? String(m.kind) : (m.container ? 'bundle' : null),
453
+ files: docFilesOf(m),
454
+ };
455
+ },
456
+ /** 插件写设置也走面板同一条通道(校验 → 生效 → 落盘) */
457
+ setSetting(key, value) {
458
+ try { applyPanelSetting(key, value); } catch { /* 插件侧写入失败不影响面板 */ }
459
+ },
460
+ };
461
+ }
462
+
463
+ function applyPanelSetting(id, value) {
464
+ // 一次性动作(导出/导入/恢复默认):执行,不进持久化(ONE_SHOT_IDS 已在工厂侧双保险)
465
+ if (ONE_SHOT_IDS.has(String(id))) { runSettingsAction(value); return; }
466
+ // 皮肤:**不进值工厂**(它的取值域随模型变,持久化没有意义 —— 见 ui-state 的 SPEC 过滤),
467
+ // 直接下发渲染器。与动作列表同一条思路:面板只负责"把点到的值送出去"。
468
+ if (String(id) === 'ctl-skin-list') {
469
+ const fn = SHELL_APPLIERS[id];
470
+ if (typeof fn === 'function') { try { fn(value); } catch { /* 下发失败保持原皮肤 */ } }
471
+ return;
472
+ }
473
+ // 其余一律经工厂:校验 → apply(生效)→ render(显示)→ 去抖落盘。
474
+ // 这样"谁是真相"只有一处(契约 R1),也就不会出现"值对但显示错"。
475
+ // 插槽控件若声明了 spec.setting,值要落到**清单设置键**(插件 render/onAction 读的才是它)
476
+ uiState.set(storeKeyOf(id), value);
477
+ // 插槽控件:值落地后再派发插件的 onAction(先 onSetting 语义、后回调 —— 顺序固定)
478
+ const act = slotActions.get(String(id));
479
+ if (act) {
480
+ try {
481
+ act.fn({ value, id: String(id) }, slotPropsFor({ pluginId: act.pluginId }));
482
+ } catch (e) {
483
+ // 插件回调抛错**必须隔离**:面板值已经存好,不能因为插件写错就把界面带崩
484
+ stageLog('warn', `插件动作回调抛错(已隔离,面板不受影响):${(e && e.message) || e}`, { source: 'plugin-slots', id: String(id) });
485
+ }
486
+ }
487
+ }
488
+
489
+ /**
490
+ * 只负责**生效**(下发给外壳实现或渲染器),不落盘、不碰显示态。
491
+ * 由工厂的 apply() 调用 ⇒ 契约 R3 的 apply/render 分离。
492
+ * @param {string} id
493
+ * @param {unknown} value
494
+ */
495
+ function applyCore(id, value) {
496
+ // 插件声明了但效果没注册成功 ⇒ 明确拒绝并告知原因(契约 §9.3:绝不静默存值)
497
+ if (PLUGIN_DISABLED_IDS.has(String(id))) {
498
+ stageStatus(`「${id}」暂不可用:对应插件效果未注册成功`, 'warn', { show: true });
499
+ return;
500
+ }
501
+ // 插件效果:①让渲染器记住档位(它是 getSetting 的唯一回读口)②重算整条链下发
502
+ // 顺序/激活/参数换算都在 effect-chain.js,两条链路共用同一套语义。
503
+ if (PLUGIN_EFFECTS.has(String(id))) {
504
+ const r = state.currentRenderer;
505
+ if (r && typeof r.setSetting === 'function') {
506
+ try { r.setSetting(String(id), value); } catch { /* 记档位失败不影响链下发 */ }
507
+ }
508
+ applyPluginEffectChain();
509
+ return;
510
+ }
511
+ const shell = SHELL_APPLIERS[id];
512
+ if (shell) {
513
+ // 外壳项里也有"依赖渲染面能力"的(皮肤/混合模式/部件筛除):
514
+ // 渲染器没声明该键 ⇒ 直接拒绝并留痕,不静默无效(面板本身已把它置灰)
515
+ const cap = settingKeyOf(id);
516
+ const needKey = cap ? (cap.kind === 'debug' ? `debug:${cap.capabilityKey}` : cap.settingKey) : null;
517
+ if (needKey && !rendererSupports(needKey)) {
518
+ // 同上:能力不支持时该控件不会出现在面板里,这条告警没有可对应的用户操作 ⇒ 静默拒绝
519
+ return;
520
+ }
521
+ shell(value);
522
+ return;
523
+ }
524
+ const cap = settingKeyOf(id);
525
+ const renderer = state.currentRenderer;
526
+ const key = cap ? (cap.kind === 'debug' ? `debug:${cap.capabilityKey}` : cap.settingKey) : null;
527
+ if (!key || !renderer || typeof renderer.setSetting !== 'function') {
528
+ stageLog('warn', `当前渲染器不支持该项设置:${id}`, { id, renderer: state.currentRenderer && state.currentRenderer.id, reason: !key ? 'no-setting-key' : 'renderer-cannot-set' });
529
+ return;
530
+ }
531
+ let ok = false;
532
+ try { ok = renderer.setSetting(key, value) === true; } catch { ok = false; }
533
+ // 生效值回显(不是请求值):契约 INV-08
534
+ let applied = null;
535
+ try { applied = renderer.getSetting ? renderer.getSetting(key) : null; } catch { applied = null; }
536
+ if (!ok) {
537
+ stageLog('warn', `渲染器不支持设置 ${key}(已回退到生效值 ${String(applied)})`, { id, key, requested: value, applied });
538
+ if (applied !== null) uiState.commit(id, applied, { render: true });
539
+ return;
540
+ }
541
+ // 生效值可能被渲染器钳制(INV-08):把**生效值**记进工厂(commit=只记录不重发,防自递归)
542
+ const effective = applied === null ? value : applied;
543
+ if (effective !== value) uiState.commit(id, effective, { render: true });
544
+ }
545
+ // ---------------------------------------------------------------- 工厂装配(契约 R3)
546
+ /** id → 控件声明(render 需要知道形态:开关用 setChecked,其余用 setValue) */
547
+ const CTL_BY_ID = new Map();
548
+ for (const group of PANEL_GROUPS) {
549
+ for (const ctl of (group && group.controls) || []) {
550
+ if (ctl && ctl.id) CTL_BY_ID.set(String(ctl.id), ctl);
551
+ }
552
+ }
553
+
554
+ /**
555
+ * 把值重放到**当前已存在的 DOM**(契约 R3 的 render)。
556
+ * 幂等:可以在每次面板重建后反复调用;`state.panel` 还没建好时静默跳过。
557
+ * @param {string} id
558
+ * @param {unknown} value
559
+ */
560
+ function renderControl(id, value) {
561
+ if (!state.panel) return;
562
+ const ctl = CTL_BY_ID.get(String(id));
563
+ if (ctl && ctl.kind === 'switch' && typeof state.panel.setChecked === 'function') {
564
+ state.panel.setChecked(id, value === true);
565
+ return;
566
+ }
567
+ if (typeof state.panel.setValue === 'function') state.panel.setValue(id, value);
568
+ }
569
+
570
+ // 注册全部"有状态"控件:apply 只负责生效,render 只负责显示(两者失败模式不同,见契约 R3)
571
+ uiState.registerAppliers(Object.fromEntries(
572
+ uiState.SPEC.map((it) => [it.id, {
573
+ apply: (v) => applyCore(it.id, v),
574
+ render: (v) => renderControl(it.id, v),
575
+ }]),
576
+ ));
577
+
578
+ /** 面板三态快照(断言用:可用项 / 禁用项 / 隐藏项) */
579
+ function panelSnapshot() {
580
+ const on = [];
581
+ const off = [];
582
+ for (const node of document.querySelectorAll('#right-panel [data-ctl]')) {
583
+ const id = node.dataset.ctl;
584
+ const control = node.matches('select') ? node : node.querySelector('button, select');
585
+ if (!control || control.classList.contains('acc-trigger')) continue;
586
+ (control.disabled ? off : on).push(id);
587
+ }
588
+ const present = [...document.querySelectorAll('#right-panel [data-ctl]')].map((n) => n.dataset.ctl);
589
+ const hidden = [...WIRED_IDS, ...PANEL_GROUPS.flatMap((g) => (g.controls || []).map((c) => c.id)).filter(Boolean)]
590
+ .filter((id) => !present.includes(id));
591
+ return { enabled: [...new Set(on)], disabled: [...new Set(off)], hidden: [...new Set(hidden)] };
592
+ }
593
+ /** 按当前渲染器能力重建面板三态,并跑一次能力自检(契约 §四、§五) */
594
+ /**
595
+ * 把「实建的已接线集合」挂给自动化断言。
596
+ * 用 getter 覆盖而不是直接赋值:`__WEB__` 由 main.js 在 boot 后段才建好、并且会用全量
597
+ * WIRED_IDS 再赋一次;直接赋值会被那次覆盖(断言因此拿到已隐藏的 ctl-mix-mode)。
598
+ * @param {number} [tries] 剩余重试次数(等 __WEB__ 出现)
599
+ */
600
+ function exposePanelWired(tries = 20) {
601
+ if (typeof window === 'undefined') return;
602
+ const web = window.__WEB__;
603
+ if (!web) {
604
+ if (tries > 0) setTimeout(() => exposePanelWired(tries - 1), 0);
605
+ return;
606
+ }
607
+ try {
608
+ Object.defineProperty(web, 'panelWired', {
609
+ configurable: true,
610
+ get: () => state.panelWired || [],
611
+ set: () => { /* 外部整体赋值忽略:真相在 state.panelWired */ },
612
+ });
613
+ } catch { /* 无法覆盖就保持原样 */ }
614
+ }
615
+
616
+ /** 就绪信号:panel 阶段的跟踪句柄(面板每次重建都会重新起算,旧句柄要先停) */
617
+ let panelStageStop = null;
618
+
619
+ /**
620
+ * 就绪信号 panel 阶段(埋点,不改面板逻辑)。
621
+ * interactive 的附加判据(契约要求):
622
+ * · 当前渲染器**声明内**的控件都已构建:state.panelWired 里的每个 id 都能在 DOM 里找到
623
+ * (panelWired 本身就是"实际构建出来的已接线控件",两者互证,避免"报绿但少控件")
624
+ * · ctl-anim-list 已有内容或明确空态(空态是 .ctl-list-empty 提示,不是空白容器)
625
+ */
626
+ function trackPanelStage() {
627
+ if (panelStageStop) { try { panelStageStop(); } catch { /* 忽略 */ } panelStageStop = null; }
628
+ resetStage('panel'); // 换渲染器/重建面板 ⇒ 控件集合变了,必须从 mounted 重新走三级
629
+ panelStageStop = trackElementStage({
630
+ name: 'panel',
631
+ el: dom.panelBody,
632
+ timeoutMs: 12000,
633
+ interactiveProbe: () => {
634
+ const built = [...document.querySelectorAll('#right-panel [data-ctl]')].map((n) => n.dataset.ctl);
635
+ const wired = Array.isArray(state.panelWired) ? state.panelWired : [];
636
+ const missing = wired.filter((id) => !built.includes(id));
637
+ const box = document.querySelector('#right-panel [data-ctl="ctl-anim-list"]');
638
+ const rows = box ? [...box.children].filter((n) => !n.classList.contains('ctl-list-empty')).length : 0;
639
+ const emptyHint = !!(box && box.querySelector('.ctl-list-empty'));
640
+ const animOk = !!box && (rows > 0 || emptyHint);
641
+ return {
642
+ ok: wired.length > 0 && missing.length === 0 && animOk,
643
+ detail: { wired: wired.length, built: built.length, missing: missing.length, animRows: rows, animEmptyHint: emptyHint },
644
+ };
645
+ },
646
+ });
647
+ }
648
+
649
+ /**
650
+ * 把插件**注册成功**的效果 id 追加进渲染器的能力键白名单(契约 §9.3)。
651
+ *
652
+ * 为什么要拷贝:capabilities 是渲染器自己的对象,直接 push 会把插件效果永久写进它的声明里,
653
+ * 插件被拔掉后白名单还留着 ⇒ 面板能选、渲染层不认(正是"选了没反应")。
654
+ * @param {object} capabilities 渲染器声明的能力
655
+ * @param {string[]} effectIds 注册成功的插件效果 id
656
+ * @returns {object} 追加后的能力(新对象)
657
+ */
658
+ function withPluginCapabilities(capabilities, effectIds) {
659
+ if (!capabilities || !Array.isArray(capabilities.settings) || !effectIds.length) return capabilities;
660
+ return { ...capabilities, settings: [...capabilities.settings, ...effectIds] };
661
+ }
662
+
663
+ /**
664
+ * 已声明但没注册成功的插件控件 id(每次重建时刷新)。
665
+ * 用途:这类控件在面板上是置灰的,正常点不到;但若被程序化调用 setSetting,
666
+ * 契约 §9.3 要求"明确返回不支持"而不是静默存一个永远不会生效的值。
667
+ */
668
+ const PLUGIN_DISABLED_IDS = new Set();
669
+
670
+ /**
671
+ * 插件交完声明后重建一次面板 —— 插件是异步注入的,晚于首次建面板。
672
+ * 幂等:只绑一次。
673
+ */
674
+ /**
675
+ * 插件效果的运行期元数据:id → { fragment, params, options }。
676
+ * 顺序即 Map 插入顺序 = 控件节提交顺序 = 清单 effects[] 声明顺序(效果链的叠加顺序)。
677
+ */
678
+ const PLUGIN_EFFECTS = new Map();
679
+
680
+ /**
681
+ * 按当前设置算出生效链并下发给当前渲染器(**Spine / Live2D 同一入口**)。
682
+ *
683
+ * 这里只做"算 + 下发":顺序/激活/参数换算在 effect-chain.js,挂载方式在各自渲染器。
684
+ * 渲染器内部按结构签名决定"只改 uniform"还是"重建着色器",所以切档位不会重挂/重新解析。
685
+ * @returns {boolean} 是否成功下发
686
+ */
687
+ function applyPluginEffectChain() {
688
+ const renderer = state.currentRenderer;
689
+ if (!renderer || typeof renderer.setEffectChain !== 'function') return false;
690
+ const declared = [...PLUGIN_EFFECTS.entries()].map(([id, meta]) => ({ id, fragment: meta.fragment, params: meta.params }));
691
+ const chain = activeChain(declared, (effectId) => {
692
+ const meta = PLUGIN_EFFECTS.get(effectId);
693
+ const options = meta && Array.isArray(meta.options) ? meta.options : [];
694
+ const chosen = String(uiState.getValue(effectId) === undefined ? '' : uiState.getValue(effectId));
695
+ const idx = options.length ? Math.max(0, options.indexOf(chosen)) : 0;
696
+ return { optionIndex: idx, optionCount: options.length || 1 };
697
+ });
698
+ return renderer.setEffectChain(chain);
699
+ }
700
+
701
+ let pluginRebuildBound = false;
702
+ /**
703
+ * 记录当前面板各组的展开态(重建前调用)。
704
+ *
705
+ * 为什么要记:`mountPanel` 会 `bodyEl.replaceChildren()` 整块重建(view.js:389/421),
706
+ * 而展开态只写在 DOM 上(view.js:341 的 aria-expanded / :373 的 body.hidden / :375 的 is-open),
707
+ * 没有持久化 ⇒ 插件装载完成、切换渲染面触发的重建都会把用户手动展开的组打回 defaultOpen。
708
+ * 只存内存、不落盘:这是**会话内视图状态**,不是用户设置(面板设置由 uiState 工厂负责,二者别混)。
709
+ * @returns {Map<string, boolean>} groupId → 是否展开
710
+ */
711
+ /**
712
+ * 模块级累积表:groupId → 是否展开(仅内存、不落盘)。
713
+ *
714
+ * 为什么不能"每次重建只快照当前 DOM":切换渲染面时有些组会**临时不存在**
715
+ * (例如 live2d 面没有「皮肤」组,实测切过去再切回来,「皮肤」的展开态就丢了)。
716
+ * 累积表把"最近一次见到它时的状态"留着,组重新出现时就能还原;
717
+ * 组的永久消失(插件卸载)只会留下几个无用 key,代价可忽略,所以不做主动清理。
718
+ */
719
+ const expandedState = new Map();
720
+
721
+ function snapshotExpandedGroups() {
722
+ if (typeof document === 'undefined') return;
723
+ for (const sec of document.querySelectorAll('#right-panel .acc[data-group]')) {
724
+ const t = sec.querySelector('.acc-trigger');
725
+ if (t) expandedState.set(sec.dataset.group, t.getAttribute('aria-expanded') === 'true');
726
+ }
727
+ }
728
+
729
+ /**
730
+ * 重建后回填展开态,并给每个手风琴节点写 `data-group-id`。
731
+ *
732
+ * 为什么不改 view.js:渲染层已经用 `section.dataset.group = group.id`(view.js:340)把 id 放在
733
+ * `data-group` 上;测试要断言的"插件组必须 plugin. 前缀"读 `data-group-id` 更直白,
734
+ * 在装配期补写一行即可,不必为命名偏好去动渲染层(渲染层改动面越小越好)。
735
+ */
736
+ function restoreExpandedGroups() {
737
+ if (typeof document === 'undefined') return;
738
+ for (const sec of document.querySelectorAll('#right-panel .acc')) {
739
+ const id = sec.dataset.group || '';
740
+ if (id) sec.dataset.groupId = id; // → 属性 data-group-id
741
+ if (!id || !expandedState.has(id)) continue; // 从没见过的组(插件新建)走自身 defaultOpen
742
+ const open = !!expandedState.get(id);
743
+ const t = sec.querySelector('.acc-trigger');
744
+ const b = sec.querySelector('.acc-body');
745
+ if (t) t.setAttribute('aria-expanded', String(open));
746
+ if (b) b.hidden = !open;
747
+ sec.classList.toggle('is-open', open);
748
+ }
749
+ }
750
+
751
+ export function bindPluginPanelRebuild() {
752
+ if (pluginRebuildBound) return false;
753
+ pluginRebuildBound = true;
754
+ window.addEventListener(EVENTS.PLUGINS_LOADED, () => {
755
+ try {
756
+ // 先重建面板(这一步把当前插件控件登记进 SPEC),**再**清孤儿键:
757
+ // 顺序反了会把"还没来得及登记"的键当成残留删掉。
758
+ rebuildPanelFor(state.currentRenderer);
759
+ const orphans = uiState.pruneUnregistered();
760
+ if (orphans.length) stageLog('info', `已清理失效的面板设置键:${orphans.join(' / ')}`, { source: 'plugin-controls' });
761
+ } catch (e) {
762
+ stageLog('warn', `插件控件合并失败:${(e && e.message) || e}`, { source: 'plugin-controls' });
763
+ }
764
+ });
765
+ return true;
766
+ }
767
+
768
+ /**
769
+ * 装配期聚合:内置表 + 旧控件节(已 deprecated,恒空)+ 插槽登记 → 同一份 groups。
770
+ *
771
+ * 抽出来的理由:rebuildPanelFor 还要管挂载、dataset、展开态、applier 等一堆事,
772
+ * 聚合单独成函数才读得动(也才过得了"单函数 50 行"的口径)。
773
+ * @returns {{host: object|null, merged: object, slotMerged: object, registeredEffects: string[], pluginControls: Array}}
774
+ */
775
+ function aggregatePanelGroups() {
776
+ const host = state.pluginHost;
777
+ const registeredEffects = host && typeof host.registeredEffectIds === 'function' ? host.registeredEffectIds() : [];
778
+ // 内置表 → 插槽登记聚合(唯一通路:老接口 registerControls 已在其内部转成插槽注册)
779
+ const slotEntries = host && typeof host.slotSections === 'function' ? host.slotSections() : [];
780
+ // 只把分组插槽交给面板聚合:toolbar.actions 是顶栏的,走到这里会被判"不是面板控件"并刷一条拒绝日志
781
+ // (实测每个顶栏按钮刷一条,纯噪音)。顶栏那条路由 syncToolbarPlugins 负责。
782
+ const panelEntries = slotEntries.filter((e) => String((e && e.name) || '').startsWith('panel.group.'));
783
+ const slotMerged = mergeSlotControls(PANEL_GROUPS, panelEntries, {
784
+ buildProps: (entry) => slotPropsFor(entry),
785
+ // 老接口迁移来的控件靠它判"效果是否注册成功"(置灰 + 原因,契约 §9.3)
786
+ registeredEffects,
787
+ onReject: (rej) => {
788
+ // title-collision 是**提示**不是拒绝(设计 §七·一 明确允许与内置组同名)
789
+ const advisory = rej.code === 'title-collision';
790
+ stageLog(advisory ? 'info' : 'warn',
791
+ `插件插槽${advisory ? '提示' : '控件被拒'}(${rej.pluginId || '未知插件'} · ${rej.slot || '未知插槽'}):${rej.reason}`,
792
+ { source: 'plugin-slots', code: rej.code || null });
793
+ },
794
+ });
795
+ const pluginControls = slotMerged.groups.flatMap((g) => (g.controls || []).filter((c) => c && c.pluginId));
796
+ // 旧控件节通道已删除:merged 只保留"零"计数以维持 dataset 字段口径(pluginControls 全来自插槽)
797
+ const merged = { added: 0, rejected: 0, rejectedControls: 0 };
798
+ return { host, merged, slotMerged, registeredEffects, pluginControls, slotEntries };
799
+ }
800
+
801
+ /**
802
+ * 把插件控件登记进设置工厂(SPEC)+ 补 applier + 建"控件 id → 存值键"映射。
803
+ *
804
+ * 两件事必须一起做:
805
+ * · 声明了 spec.setting 的控件,值要落在**清单设置键**上 ⇒ SPEC 里要同时有控件 id 与存值键
806
+ * (只登记控件 id 的话,uiState.set(设置键) 会被工厂判成"未登记的键"直接丢弃)
807
+ * · appliers 是模块加载时按 SPEC 快照注册的,插件晚于那一刻 ⇒ 不补就"只存值不 render"
808
+ * @param {Array} pluginControls 合并后的插件控件
809
+ */
810
+ /**
811
+ * 同步顶栏的插件按钮(toolbar.actions 插槽)。
812
+ *
813
+ * 为什么不在这里处理排序/上限:那是渲染器的规则(toolbar.js),装配层只负责
814
+ * "把插槽登记项与 onAction 通道接起来",规则集中在一处才不会被两处表达。
815
+ * @param {Array} slotEntries 插槽登记项(全部插槽,渲染器只挑 toolbar.actions)
816
+ */
817
+ function syncToolbarPlugins(slotEntries) {
818
+ const nav = document.getElementById('header-actions');
819
+ if (!nav) return;
820
+ // 单值插槽:品牌名(后注册胜出,卸载回退到上一个)
821
+ syncBrand(document.getElementById('brand-title'), slotEntries);
822
+ // 被替换控件的 onAction:登记到同一个动作表(键是被替换后的**控件 id**)
823
+ for (const entry of slotEntries || []) {
824
+ if (!/^panel\.control\./.test(String(entry && entry.name || ''))) continue;
825
+ const target = String(entry.target || '');
826
+ const fn = entry.spec && entry.spec.onAction;
827
+ if (target && typeof fn === 'function') slotActions.set(target, { pluginId: entry.pluginId, fn });
828
+ }
829
+ syncPluginToolbar(nav, slotEntries, {
830
+ // 日志能力由这里注入(渲染器不能 import app 层,否则 context.js 会提前快照顶栏节点)
831
+ log: (level, message, detail) => stageLog(level, message, detail),
832
+ // pressed 是 getter 时用它求值:插件拿到的 owner props 与面板控件完全同一份
833
+ buildProps: (entry) => slotPropsFor(entry),
834
+ onAction: (entry) => {
835
+ const spec = (entry && entry.spec) || {};
836
+ if (typeof spec.onAction !== 'function') return;
837
+ try {
838
+ // 与面板控件同一套动作口径:{ id, value } + owner props
839
+ spec.onAction({ id: String(entry.id || ''), value: String(entry.id || '') }, slotPropsFor(entry));
840
+ } catch (e) {
841
+ stageLog('warn', `插件顶栏按钮回调抛错(已隔离,顶栏不受影响):${(e && e.message) || e}`, { source: 'plugin-toolbar' });
842
+ }
843
+ },
844
+ });
845
+ }
846
+
847
+ function registerSlotStorage(pluginControls) {
848
+ // **先清再判空**:早期版本把这两句写在 `if (!controls.length) return;` 之后,
849
+ // 于是"插件全部卸载后"这一轮重建不会清表 ⇒ 残留的 控件id→插件键 映射让内置控件
850
+ // 继续读插件的旧值(表现:卸载后内置控件没有任何选中项)。清表必须无条件执行。
851
+ slotStoreKeyById.clear();
852
+ slotDeclaredDefaultById.clear();
853
+ if (!pluginControls.length) return;
854
+ for (const ctl of pluginControls) {
855
+ const id = String((ctl && ctl.id) || '');
856
+ const setting = String((ctl && ctl.setting) || '');
857
+ if (!id || !setting || setting === id) continue;
858
+ slotStoreKeyById.set(id, setting);
859
+ // 清单里声明的默认值:插件自己换值域导致旧值失效时,显示它(不碰内置键)
860
+ const decls = state.pluginHost && typeof state.pluginHost.pluginSettings === 'function'
861
+ ? (state.pluginHost.pluginSettings(ctl.pluginId) || []) : [];
862
+ const decl = decls.find((d) => String((d && d.key) || '') === setting);
863
+ if (decl && decl.default !== undefined) slotDeclaredDefaultById.set(id, decl.default);
864
+ }
865
+ // 反向表:设置键 → 绑定它的控件 id(一个键可能被多个控件绑定,所以是数组)
866
+ slotCtlIdsByKey.clear();
867
+ for (const ctl of pluginControls) {
868
+ const id = String((ctl && ctl.id) || '');
869
+ const setting = String((ctl && ctl.setting) || '');
870
+ if (!id || !setting || setting === id) continue;
871
+ const list = slotCtlIdsByKey.get(setting) || [];
872
+ list.push(id);
873
+ slotCtlIdsByKey.set(setting, list);
874
+ }
875
+ const specItems = [...pluginControls];
876
+ for (const ctl of pluginControls) {
877
+ const setting = String((ctl && ctl.setting) || '');
878
+ if (setting && setting !== String(ctl.id || '')) specItems.push({ ...ctl, id: setting });
879
+ }
880
+ const reg = uiState.registerSpecItems(specItems);
881
+ // 拒绝登记的要当场说清楚:否则表现为"控件在面板上、却不被工厂接管(恢复默认跳过它)"
882
+ if (reg && Array.isArray(reg.skipped) && reg.skipped.length) {
883
+ for (const s of reg.skipped) {
884
+ stageLog('warn', `插件控件未进入设置工厂:${s.id}(${s.reason})`, { source: 'plugin-controls' });
885
+ }
886
+ }
887
+ // applier 两类:
888
+ // ① 按控件 id:老路径(外部写 control id)
889
+ // ② 按**设置键**:外部写清单设置键时,把它绑定的控件全部重绘(含被多个控件共享的情况)
890
+ // 两条路径都走 scheduleControlRepaint ⇒ 同一 tick 内只重绘一次(不闪、计数也是 1)
891
+ const appliers = {};
892
+ for (const ctl of pluginControls) {
893
+ const id = String((ctl && ctl.id) || '');
894
+ if (!id) continue;
895
+ appliers[id] = {
896
+ // 替换型控件的值域是插件自定义的,**不往内置效果上套**(applyCore 会拿它当内置参数用,
897
+ // 必然被判非法并刷一条无意义警告);它的语义由插件的 onAction 自己解释
898
+ apply: (v) => { if (!ctl.replacedBy) applyCore(id, v); },
899
+ render: () => scheduleControlRepaint(id),
900
+ };
901
+ }
902
+ for (const [key, ids] of slotCtlIdsByKey) {
903
+ appliers[key] = {
904
+ apply: (v) => { for (const ctlId of ids) { try { applyCore(ctlId, v); } catch { /* 单个失败不影响其余 */ } } },
905
+ render: () => { for (const ctlId of ids) scheduleControlRepaint(ctlId); },
906
+ };
907
+ }
908
+ uiState.registerAppliers(appliers);
909
+ }
910
+
911
+ /**
912
+ * 把插件效果元数据填进 PLUGIN_EFFECTS 并下发一次效果链(插件晚于首帧加载,不补这一次画面就不对)。
913
+ * @param {object|null} host 插件宿主
914
+ * @param {Array} pluginControls 合并后的插件控件(用来取每个效果的选项集)
915
+ */
916
+ function syncPluginEffects(host, pluginControls) {
917
+ PLUGIN_EFFECTS.clear();
918
+ if (!host || typeof host.effectDeclarations !== 'function') return;
919
+ for (const decl of host.effectDeclarations()) {
920
+ const ctl = pluginControls.find((c) => String(c.id) === decl.id);
921
+ PLUGIN_EFFECTS.set(decl.id, {
922
+ fragment: decl.fragment,
923
+ params: decl.params,
924
+ options: ctl && Array.isArray(ctl.options) ? ctl.options.map(String) : [],
925
+ });
926
+ }
927
+ if (PLUGIN_EFFECTS.size) applyPluginEffectChain();
928
+ }
929
+
930
+ /**
931
+ * 能力过滤:把"已声明但注册失败"的控件置灰保留(契约 §9.3),其余按渲染器能力裁决。
932
+ * 白名单 = 渲染器自身声明 + 注册成功的效果 id + 插槽控件的能力键(显式等于控件 id)。
933
+ * @param {object} renderer 当前渲染器
934
+ * @param {object} slotMerged mergeSlotControls 的结果
935
+ * @param {Array} pluginControls 插件控件
936
+ * @param {string[]} registeredEffects 注册成功的效果 id
937
+ * @returns {Array} 可渲染的 groups
938
+ */
939
+ function filterByCapabilities(renderer, slotMerged, pluginControls, registeredEffects) {
940
+ // 已声明但注册失败的那些:置灰而不是消失(契约 §9.3)
941
+ const keepDisabled = new Set(pluginControls.filter((c) => c.disabled === true && c.id).map((c) => String(c.id)));
942
+ PLUGIN_DISABLED_IDS.clear();
943
+ for (const ctlId of keepDisabled) PLUGIN_DISABLED_IDS.add(ctlId);
944
+ const pluginCapabilityKeys = [...registeredEffects, ...slotMerged.capabilityKeys];
945
+ return applyCapabilities(slotMerged.groups, withPluginCapabilities(renderer && renderer.capabilities, pluginCapabilityKeys), {
946
+ onWarn: (message) => stageLog('warn', message, { source: 'panel-selfcheck' }),
947
+ keepDisabled,
948
+ });
949
+ }
950
+
951
+ /**
952
+ * 挂载重建后的面板,并回填展开态。
953
+ *
954
+ * 顺序有讲究:**先**把当前展开态并入累积表(mountPanel 会整块换 DOM),
955
+ * 再 mountPanel,最后回填 —— 中间不许再插入别的重建。
956
+ * @param {Array} groups 已按能力过滤、可渲染的分组
957
+ * @param {Array} pluginControls 插件控件(用于接线清单)
958
+ * @returns {object} mountPanel 的句柄
959
+ */
960
+ function mountRebuiltPanel(groups, pluginControls) {
961
+ snapshotExpandedGroups();
962
+ const info = mountPanel({
963
+ groups,
964
+ headEl: dom.panelHead,
965
+ bodyEl: dom.panelBody,
966
+ // 动态选项 → 值工厂的取值域。皮肤列表这类"选项由模型决定"的控件,schema 里只有占位值;
967
+ // 不同步域的话,工厂会把运行时补进来的值判成非法并回落默认(表现为"点了没反应")。
968
+ onDomain: (id, list) => { try { uiState.setDomain(id, list); } catch { /* 域同步失败不影响面板 */ } },
969
+ // 面板控件回调统一入口 + 暂停补帧:面板里的设置是**直调 renderer.setSetting**(不经
970
+ // app/renderer-setting.js),所以补帧必须挂在这一层,否则暂停中改设置"改了看不见"。
971
+ onSetting: (id, value) => {
972
+ applyPanelSetting(id, value);
973
+ if (state.playing === false) { try { repaintActive(state.surface); } catch { /* 忽略 */ } }
974
+ },
975
+ // 插件控件的"接线"由宿主负责(插件只交声明式数据)⇒ 已注册成功的要进接线清单,
976
+ // 否则渲染层会当成"未接线":按钮不绑 click,点了没反应(实测踩过)。
977
+ wiredIds: [...WIRED_IDS, ...pluginControls.filter((c) => c.disabled !== true && c.id).map((c) => String(c.id))],
978
+ // 首次渲染就显示用户值(契约 R2):工厂里已经有 loadAll 读回的值
979
+ // 读存值键:声明了 spec.setting 的控件,值存在清单设置键上(不是 control id);
980
+ // **读不到时用清单声明的 default 兜底**(插件换值域后旧值失效的正解,不碰内置键)
981
+ initialValue: (id) => {
982
+ const v = uiState.getValue(storeKeyOf(id));
983
+ if (v !== undefined) return v;
984
+ return slotDeclaredDefaultById.has(String(id)) ? slotDeclaredDefaultById.get(String(id)) : undefined;
985
+ },
986
+ });
987
+ restoreExpandedGroups();
988
+ return info;
989
+ }
990
+
991
+ function rebuildPanelFor(renderer) {
992
+ bindPluginPanelRebuild(); // 幂等:确保"插件交完声明后重建面板"这条链只绑一次
993
+ const { host, merged, slotMerged, registeredEffects, pluginControls, slotEntries } = aggregatePanelGroups();
994
+ // 动作表随重建刷新:控件消失(插件卸载)后不许还留着能点的回调
995
+ slotActions.clear();
996
+ for (const ctl of pluginControls) {
997
+ if (ctl && ctl.id && typeof ctl.onAction === 'function') {
998
+ slotActions.set(String(ctl.id), { pluginId: ctl.pluginId, fn: ctl.onAction });
999
+ }
1000
+ }
1001
+ syncPluginEffects(host, pluginControls);
1002
+ // 键值插槽:替换内置控件(按 target 找既有控件,替换后仍用原 id)
1003
+ const replaced = applyKeyedControlReplacements(slotMerged.groups, slotEntries);
1004
+ // 被替换的控件也要登记:它们的**存值键是插件的 setting**,SPEC 里必须有这个键,
1005
+ // 否则 uiState.set(插件键) 会被工厂判成"未登记的键"直接丢弃(表现:点了没反应)
1006
+ registerSlotStorage([...pluginControls, ...replaced.controls]);
1007
+ if (replaced.replaced) {
1008
+ stageLog('info', `插件替换了 ${replaced.replaced} 个内置控件`, { source: 'plugin-slots' });
1009
+ }
1010
+ // 第一个参数必须是 **renderer**(filterByCapabilities 从它读 capabilities);
1011
+ // 替换后的分组通过 slotMerged 的副本带进去(曾经把两者传反,结果所有能力门控控件整片消失)
1012
+ const groups = filterByCapabilities(renderer, { ...slotMerged, groups: replaced.groups }, pluginControls, registeredEffects);
1013
+ // 顶栏插件按钮:与面板同一次重建里同步(卸载后旧按钮随重建消失)
1014
+ syncToolbarPlugins(slotEntries);
1015
+ document.documentElement.dataset.pluginControls = String(merged.added + slotMerged.added);
1016
+ document.documentElement.dataset.pluginControlsRejected = String(merged.rejected + merged.rejectedControls + slotMerged.rejected);
1017
+ document.documentElement.dataset.panelGroups = String(groups.length);
1018
+ // 重建前记住用户手动展开/收起的组:mountPanel 会整块换 DOM,不记就回缺省(实测过的现状缺陷)
1019
+ const info = mountRebuiltPanel(groups, pluginControls);
1020
+ // 先把句柄补完,再暴露 state.panel。
1021
+ // 为什么顺序重要:设置下拉的同步会**触发面板重建**,重建期间若 state.panel 指向
1022
+ // 一个"还没挂 refreshAnimations"的半成品,调用方一检查 typeof 就跳过,表现为
1023
+ // "点了底栏、右栏高亮不动,且没有任何报错"(实测踩过)。
1024
+ info.refreshAnimations = () => refreshPanelAnimations();
1025
+ state.panel = info;
1026
+ // 暴露"实际构建出来的已接线控件":能力不支持的项根本不构建(契约 §3.1),
1027
+ // 所以 panelWired 不能直接给全量 WIRED_IDS,否则断言会拿面板里不存在的 id 去比。
1028
+ // 但也不能直接给 builtIds —— 有些控件建出来了却处于 pending/disabled(如 ctl-anim-list),
1029
+ // 它们不算"已接线",给进去会让 panel-actions 的严格集合相等断言反向失败(实测踩过)。
1030
+ // 正确口径 = 静态白名单里真正建出来的 + **插件控件**(插件控件 id 是裸 effect id,
1031
+ // 不带 ctl- 前缀、不在 WIRED_IDS 里,按契约 §9.2 由宿主负责接线)。
1032
+ const builtIds = [...document.querySelectorAll('#right-panel [data-ctl]')].map((n) => n.dataset.ctl);
1033
+ // 已接线的插件控件:必须与"合并进来的那份"同源。
1034
+ // 接线清单必须与"合并进来的那份"同源:曾经只查旧通道,导致插槽控件渲染出来了却不在这份清单里,
1035
+ // 测试的「可点集合 === 已接线集合」立刻变红(插件控件本就不该免检)。
1036
+ const pluginCtlIds = pluginControls
1037
+ .map((ctl) => ctl && ctl.id)
1038
+ .filter((id) => id && builtIds.includes(id));
1039
+ state.panelWired = [...new Set([
1040
+ ...WIRED_IDS.filter((id) => builtIds.includes(id)),
1041
+ ...pluginCtlIds,
1042
+ ])];
1043
+ exposePanelWired();
1044
+ startFpsReadout(info);
1045
+ // 契约 §三/§3.1(**唯一汇聚点**):面板 DOM 刚被重建 ⇒ 当前 DOM 的显示态全是 schema 默认值,
1046
+ // 必须立刻按工厂里的生效值重放一次,否则「引擎在循环、开关显示关」会再次出现。
1047
+ try { document.dispatchEvent(new CustomEvent('panel:rebuilt')); } catch { /* 无 DOM 环境忽略 */ }
1048
+ try { uiState.applyAll({ render: true }); } catch { /* 回填失败不影响面板可用 */ }
1049
+ refreshSkinHook();
1050
+ // 自定义背景图列表:面板重建后重新拉一次(切面/重开面板都会走到这里)
1051
+ bindBgGroupToggle(); // 点「纯色▾ / 自定义▾」切换二级列表展开(幂等,只绑一次)
1052
+ refreshBgSources().catch(() => { /* 列表拉取失败只是少一块内容,不影响面板其余部分 */ });
1053
+ refreshPanel().catch(() => { /* 内存条取不到就隐藏,不阻塞面板 */ });
1054
+ // 文档 §五「展开设置面板 → 拉一次」:面板重建与"点开设置组"是两件事,
1055
+ // 后者不重建面板,所以单独绑一次点击(幂等)。
1056
+ bindPanelBarRefresh();
1057
+ state.panelSnapshot = panelSnapshot();
1058
+ const check = selfCheck(renderer || {});
1059
+ state.panelSelfCheck = check;
1060
+ document.documentElement.dataset.selfCheck = check.passed ? 'pass' : 'fail';
1061
+ if (!check.passed) {
1062
+ stageLog('error', `能力自检未通过:${check.violations.map((v) => `${v.key}(${v.actual})`).join('、')}`, { violations: check.violations });
1063
+ }
1064
+ trackPanelStage(); // 就绪信号:面板构建完毕 → 三级跟踪(mount/稳定/可交互)
1065
+ return check;
1066
+ }
1067
+ // 应用层请求"重绘动作列表"(如底栏自绘下拉选中后)——用事件解耦,避免 app⇄panel 循环依赖。
1068
+ if (typeof document !== 'undefined') {
1069
+ document.addEventListener('panel:refresh-animations', () => {
1070
+ try { refreshPanelAnimations(); } catch { /* 重绘失败不影响播放 */ }
1071
+ });
1072
+ }
1073
+
1074
+ // 列表里点了某张图设为背景 → 「背景模式」档位同步到「自定义」(视觉一致,不是另存一套状态)
1075
+ if (typeof document !== 'undefined') {
1076
+ document.addEventListener('panel:bg-applied', (ev) => {
1077
+ const mode = (ev && ev.detail && ev.detail.mode) || 'custom';
1078
+ try { if (state.panel && state.panel.setValue) state.panel.setValue('ctl-bg-mode', mode); } catch { /* 忽略 */ }
1079
+ });
1080
+ }
1081
+
1082
+ /**
1083
+ * 底栏下拉变化 → 右栏高亮跟随(契约 §七:当前动作的唯一真相是下拉的值)。
1084
+ * 为什么不只在应用层回调里刷:change 事件派发时下拉的值可能尚未落定,
1085
+ * 当场重绘会读到旧值;这里挂独立监听并在下一拍重绘,两条路径互不依赖。
1086
+ */
1087
+ let transportWatcherBound = false;
1088
+ function bindTransportMotionWatcher() {
1089
+ if (transportWatcherBound) return;
1090
+ const sel = document.querySelector('#transport-motion');
1091
+ if (!sel) return;
1092
+ sel.addEventListener('change', () => {
1093
+ setTimeout(() => { try { refreshPanelAnimations(); } catch { /* 忽略 */ } }, 0);
1094
+ });
1095
+ transportWatcherBound = true;
1096
+ }
1097
+
1098
+ /**
1099
+ * 刷新右栏「动作列表」:与底栏同一份清单(同顺序、同文案、同时长),点一条即播。
1100
+ * 高亮跟随底栏下拉的值(契约 §七:当前动作只有一处真相)。
1101
+ * @returns {number} 列出的条数
1102
+ */
1103
+ /**
1104
+ * 造一行动作(样式左名称溢出省略 + 右时长等宽小字,选中主色描边)。
1105
+ * 单独成函数:列表重绘逻辑本身要保持在可读范围,行构造细节拆出来。
1106
+ * @param {{id: string, label: string, duration: number|null}} anim
1107
+ * @param {string} currentId 当前播放中的动作 id
1108
+ * @returns {HTMLButtonElement}
1109
+ */
1110
+ function buildAnimRow(anim, currentId) {
1111
+ const row = document.createElement('button');
1112
+ row.type = 'button';
1113
+ row.className = 'ctl-list-row ctl-anim';
1114
+ row.dataset.anim = anim.id;
1115
+ row.setAttribute('aria-pressed', String(anim.id === currentId));
1116
+ const name = document.createElement('span');
1117
+ name.className = 'ctl-list-name';
1118
+ name.textContent = anim.label;
1119
+ row.append(name);
1120
+ const timeText = durationText(anim.duration);
1121
+ if (timeText) { // 时长取不到就不显示(契约 §二禁止 0s / — 冒充)
1122
+ const time = document.createElement('span');
1123
+ time.className = 'ctl-list-time';
1124
+ time.textContent = timeText;
1125
+ row.append(time);
1126
+ }
1127
+ row.title = anim.id + (timeText ? `(${timeText})` : '(时长未知)');
1128
+ row.addEventListener('click', () => { applyTransportMotion(anim.id); refreshPanelAnimations(); });
1129
+ return row;
1130
+ }
1131
+
1132
+ export function refreshPanelAnimations() {
1133
+ const box = document.querySelector('#right-panel [data-ctl="ctl-anim-list"]');
1134
+ // 诊断:记录"进来了/box 是否存在/清单条数",出现"调了没反应"时一眼看出卡在哪一步
1135
+ document.documentElement.dataset.refreshEnter = box ? '1' : '0';
1136
+ if (!box) return 0;
1137
+ const list = buildAnimationList();
1138
+ document.documentElement.dataset.refreshListLen = String(list.length);
1139
+ const currentId = currentAnimationId();
1140
+ box.replaceChildren();
1141
+ box.classList.add('ctl-list'); // 容器行样式(见 panel.css 的 .ctl-list)
1142
+ if (!list.length) {
1143
+ const hint = document.createElement('p');
1144
+ hint.className = 'ctl-list-empty'; // 空态:居中弱化小字
1145
+ hint.textContent = state.selectedModel ? '该模型没有动作' : '选中模型后显示';
1146
+ box.append(hint);
1147
+ return 0;
1148
+ }
1149
+ for (const anim of list) box.append(buildAnimRow(anim, currentId));
1150
+ // 同一份清单同步底栏下拉(契约 §一/§三:两处必须逐项一致)
1151
+ syncTransportOptions();
1152
+ bindTransportMotionWatcher();
1153
+ // 下拉可能刚被填好(首次打开时为空)——按最终值补一次高亮,避免"首屏没有当前项"。
1154
+ //
1155
+ // 取值的优先级为什么不是 currentAnimationId():它**优先读 state.transportMotion 这个缓存**
1156
+ // (见 anim-list.js 的实现)。而缓存只有"用户手动选动作"时才会被写;换模型时没人同步它,
1157
+ // 于是它仍停在上一个模型的 id。实测:换模型后底栏已显示 idle,缓存却还是上一模型的 "3",
1158
+ // 新清单里没有 "3" ⇒ 一圈都匹配不上 ⇒ 右栏一个 aria-pressed="true" 都没有(底栏正常、刷新页面正常)。
1159
+ // 正确口径:**底栏下拉才是"当前动作"的唯一真相(契约 §七)**,缓存只是它的副本。
1160
+ // 所以顺序改为「底栏值 → 缓存值 → 清单首项」,且候选必须真的落在本清单里(否则宁可不选)。
1161
+ const listIds = new Set(list.map((x) => String(x.id)));
1162
+ const pickerValue = (() => {
1163
+ const transport = state && state.transport;
1164
+ if (!transport || typeof transport.picker !== 'function') return '';
1165
+ const pk = transport.picker('transport-motion');
1166
+ return pk && typeof pk.value === 'function' ? String(pk.value() || '') : '';
1167
+ })();
1168
+ const finalId = [pickerValue, String(currentAnimationId() || ''), String(list[0] ? list[0].id : '')]
1169
+ .find((v) => v && listIds.has(v)) || '';
1170
+ // 缓存与真相对齐(下次任何读"当前动作"的地方都不会再拿到上一个模型的 id)
1171
+ if (finalId && state) state.transportMotion = finalId;
1172
+ // 记录"这次重绘用的 id":出现"高亮被刷回去"时,看一眼最后一次用的值就能定性
1173
+ document.documentElement.dataset.lastRefreshId = String(finalId || '');
1174
+ if (finalId) {
1175
+ // 选中态统一用 aria-pressed(样式在 panel.css 的 .ctl-list-row[aria-pressed='true'])
1176
+ for (const btn of box.querySelectorAll('[data-anim]')) {
1177
+ btn.setAttribute('aria-pressed', String(btn.dataset.anim === finalId));
1178
+ }
1179
+ }
1180
+ // 循环推进后由应用层回调重绘(不轮询 DOM)
1181
+ onCycleAdvance(() => refreshPanelAnimations());
1182
+ // 有缺时长的条目就预热一次(Live2D:读动作文件 Meta.Duration;取到后自动再刷一遍)
1183
+ if (list.some((x) => !durationText(x.duration))) {
1184
+ primeAnimationDurations().then((n) => { if (n > 0) refreshPanelAnimations(); }).catch(() => {});
1185
+ }
1186
+ return list.length;
1187
+ }
1188
+
1189
+ /** 帧率读数:把舞台实测帧率刷到面板上(500ms 一次;无舞台或未就绪时显示 —) */
1190
+ function startFpsReadout(panel) {
1191
+ if (!panel || typeof panel.setFps !== 'function') return;
1192
+ /**
1193
+ * Live2D 面的实测帧率取自**渲染器统计的实际出画帧数**(getMeasuredFps)。
1194
+ *
1195
+ * 为什么不再用 `app.ticker.FPS`:那是 rAF/ticker 的触发频率,不是渲染次数。渲染被门控跳过时
1196
+ * tick 照样发生,用它显示会恒等于屏幕刷新率——"上限 60 却显示 89"里就有一部分来自这个读数失真。
1197
+ * (Spine 面本来就是引擎按实际渲染帧统计,读数口径正确。)
1198
+ */
1199
+ const live2dFps = () => {
1200
+ try {
1201
+ // 取**当前激活的真实渲染器**(state.currentRenderer 是门面,只转发固定几个方法,
1202
+ // 不含 getMeasuredFps;activeRenderer() 拿到的才是带门控计数的那个实例)
1203
+ const r = (state.surface && typeof state.surface.activeRenderer === 'function')
1204
+ ? state.surface.activeRenderer()
1205
+ : state.currentRenderer;
1206
+ if (r && typeof r.getMeasuredFps === 'function') {
1207
+ const v = r.getMeasuredFps();
1208
+ if (typeof v === 'number' && isFinite(v) && v > 0) return v;
1209
+ }
1210
+ // 兜底:渲染器没提供时回退 ticker.FPS(会偏高,仅供观察,不能用于验收)
1211
+ const l2d = window.__WEB__ && window.__WEB__.live2d;
1212
+ if (!l2d || typeof l2d.isActive !== 'function' || !l2d.isActive()) return null;
1213
+ const app = typeof l2d.getApp === 'function' ? l2d.getApp() : null;
1214
+ const v = app && app.ticker ? app.ticker.FPS : null;
1215
+ return typeof v === 'number' && isFinite(v) ? v : null;
1216
+ } catch { return null; }
1217
+ };
1218
+ const tick = () => {
1219
+ const l2d = live2dFps();
1220
+ if (l2d !== null) { panel.setFps(l2d); return; }
1221
+ const st = stage && stage.stats ? stage.stats() : null;
1222
+ panel.setFps(st && typeof st.fps === 'number' ? st.fps : null);
1223
+ };
1224
+ tick();
1225
+ setInterval(tick, 500);
1226
+ }
1227
+ /** 装配完成后注入面板桥:恢复默认需要"按面板声明的默认值重放一遍" */
1228
+ // 导入项目配置后要重载资源根与名册树:实现在 app/model-actions.js,
1229
+ // 用钩子注入(那边反过来要用 rebuildPanelFor),保持单向依赖
1230
+ let reloadAppHook = null;
1231
+ export function connectReloadApp(fn) {
1232
+ if (typeof fn === 'function') reloadAppHook = fn;
1233
+ }
1234
+
1235
+ connectShellControls({
1236
+ applyPanelSetting: (id, value) => applyPanelSetting(id, value),
1237
+ getPanelDefaults: () => (state.panel && state.panel.defaults) || {},
1238
+ reloadApp: () => (typeof reloadAppHook === 'function' ? reloadAppHook() : null),
1239
+ applyViewerPrefs: (prefs) => {
1240
+ applyTheme(prefs.uiTheme || 'dark-cyan');
1241
+ applyBackground((prefs.background && prefs.background.mode) || 'checkerboard');
1242
+ if (state.panel && state.panel.setValue) {
1243
+ state.panel.setValue('ctl-theme', state.viewTheme);
1244
+ state.panel.setValue('ctl-bg-mode', state.viewBg);
1245
+ }
1246
+ },
1247
+ });
1248
+
1249
+
1250
+ /**
1251
+ * 重跑能力自检(契约 §四):随时可调,violations 非空即缺陷。
1252
+ * 放在这里而不是 main.js,是因为它依赖 schema 的 selfCheck 与当前渲染器能力。
1253
+ */
1254
+ export function runPanelSelfCheck() {
1255
+ const check = selfCheck(state.currentRenderer || {});
1256
+ state.panelSelfCheck = check;
1257
+ document.documentElement.dataset.selfCheck = check.passed ? 'pass' : 'fail';
1258
+ return check;
1259
+ }
1260
+
1261
+ export {
1262
+ SHELL_APPLIERS, WIRED_IDS, applyPanelSetting, panelSnapshot, rebuildPanelFor, startFpsReadout,
1263
+ };