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,375 @@
1
+ // panel/transport-picker.js —— 底栏「自绘下拉」的完整实现
2
+ //
3
+ // 从 transport.js 抽出来的原因:那块代码(触发器 + listbox + 键盘语义 + 背景选项源)占了
4
+ // 半个文件,混在"渲染编排 + 接线"里读不下去。这里只负责"一个下拉怎么工作",
5
+ // 不知道底栏有几个控件、也不知道它们怎么分组。
6
+ //
7
+ // 对外只暴露四件事:
8
+ // createPicker(ctl, onPick, hooks) 建一个下拉
9
+ // refreshBgOptions(picker) 重新拉背景选项(内置 4 模式 + 服务端自定义图)
10
+ // applyBgValue(value) 应用一次背景选择(mode:xxx / img:url)
11
+ // bindBgSync(picker) 任何来源改了背景都让下拉重新对齐
12
+
13
+ import { el } from './dom.js';
14
+ import { listBgSources, renderSourceGroup, selectedSourceId } from './bg-source.js';
15
+ import { listBackgrounds } from '../app/backgrounds.js';
16
+ import { applyBackground, readViewSettings, setCustomBackground } from '../app/view-settings.js';
17
+ import { stageLog, stageStatus } from '../app/status.js';
18
+
19
+ /**
20
+ * 自绘下拉的键盘语义(WAI-ARIA combobox 惯用法):
21
+ * Esc 关闭并把焦点还给触发按钮
22
+ * ↑/↓ 打开(若关闭)或移动高亮
23
+ * Enter/空格 确认当前高亮项并关闭
24
+ * 抽成模块级函数是为了让 createPicker 本体保持可读(单函数长度门禁)。
25
+ * @param {KeyboardEvent} ev
26
+ * @param {{isOpen:Function, open:Function, close:Function, count:Function,
27
+ * activeIndex:Function, setActive:Function, pickActive:Function}} api
28
+ */
29
+ /**
30
+ * 按选项清单重建列表项(纯 DOM 构建,便于测试与复用)。
31
+ * @param {Array<{text:string,value:string}>} options
32
+ * @param {(value:string) => void} onPickItem 点某项时回调
33
+ * @param {HTMLElement} list 目标 <ul>
34
+ * @returns {Array<{value:string, text:string, node:HTMLLIElement}>}
35
+ */
36
+ function buildPickerItems(options, onPickItem, list, onSubmenu) {
37
+ list.replaceChildren();
38
+ const items = [];
39
+ for (const opt of options || []) {
40
+ // 分组标题:只做视觉分隔(role=presentation),不进可选项数组,键盘不会停在它上面
41
+ if (opt.group) {
42
+ const head = el('li', 'transport-picker-group', opt.text);
43
+ head.setAttribute('role', 'presentation');
44
+ list.append(head);
45
+ continue;
46
+ }
47
+ const li = el('li', 'transport-picker-item', opt.text);
48
+ li.setAttribute('role', 'option');
49
+ li.dataset.value = String(opt.value);
50
+ li.tabIndex = -1;
51
+ if (opt.submenu) {
52
+ // 有二级:行尾加 ▸,点它不"选中"而是展开侧向二级(键盘 Enter 同样走这条)
53
+ li.dataset.submenu = String(opt.submenu);
54
+ li.append(el('span', 'transport-picker-arrow', '▸'));
55
+ li.addEventListener('click', () => {
56
+ if (typeof onSubmenu === 'function') onSubmenu(String(opt.submenu), li);
57
+ });
58
+ } else {
59
+ li.addEventListener('click', () => onPickItem(String(opt.value)));
60
+ }
61
+ items.push({ value: String(opt.value), text: opt.text, node: li, submenu: opt.submenu || null });
62
+ list.append(li);
63
+ }
64
+ return items;
65
+ }
66
+
67
+ function handlePickerKey(ev, api) {
68
+ const key = ev.key;
69
+ if (key === 'Escape') { if (api.isOpen()) { api.close(); ev.preventDefault(); } return; }
70
+ if (key === 'ArrowDown' || key === 'ArrowUp') {
71
+ if (!api.isOpen()) { api.open(); ev.preventDefault(); return; }
72
+ const step = key === 'ArrowDown' ? 1 : -1;
73
+ const next = Math.max(0, Math.min(api.count() - 1, (api.activeIndex() < 0 ? 0 : api.activeIndex()) + step));
74
+ api.setActive(next);
75
+ ev.preventDefault();
76
+ return;
77
+ }
78
+ if (key === 'Enter' || key === ' ') {
79
+ if (!api.isOpen()) return;
80
+ api.pickActive();
81
+ ev.preventDefault();
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 自绘下拉(替代原生 <select>):触发按钮 + 弹出列表。
87
+ *
88
+ * 为什么不用原生 select:视觉无法与本实现设计语言统一(动作/皮肤列表同样是自绘的,保持一致),
89
+ * 而且原生 select 的键盘行为在各浏览器不一致,无法保证"上下键移动高亮、Esc 关闭并回焦"。
90
+ *
91
+ * 结构(无障碍语义照 WAI-ARIA combobox/listbox):
92
+ * .transport-picker[data-ctl]
93
+ * button.transport-picker-btn[aria-haspopup=listbox][aria-expanded]
94
+ * span.picker-label(显示当前项文案)+ svg chevron
95
+ * ul.transport-picker-list[role=listbox] > li[role=option][aria-selected]
96
+ *
97
+ * @param {{id:string, label:string, title?:string, options:Array<{text:string,value:string}>, value:string}} ctl
98
+ * @param {(value:string) => void} onPick 选中回调(只有真实选中才触发,与原生 select 的 change 同义)
99
+ */
100
+ export function createPicker(ctl, onPick, hooks = {}) {
101
+ const root = el('div', 'transport-picker');
102
+ root.dataset.ctl = ctl.id;
103
+ const btn = el('button', 'transport-picker-btn');
104
+ btn.type = 'button';
105
+ btn.setAttribute('aria-haspopup', 'listbox');
106
+ btn.setAttribute('aria-expanded', 'false');
107
+ btn.setAttribute('aria-label', ctl.label || '选择');
108
+ if (ctl.title) btn.title = ctl.title;
109
+ // 图标由调用方传入(图标表在 transport.js,这里不重复维护一份)
110
+ const iconBody = ctl.icon;
111
+ if (iconBody) {
112
+ const wrap = el('span', 'btn-icon');
113
+ wrap.setAttribute('aria-hidden', 'true');
114
+ wrap.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${iconBody}</svg>`;
115
+ btn.append(wrap);
116
+ btn.classList.add('has-icon');
117
+ }
118
+ const labelEl = el('span', 'picker-label', '');
119
+ btn.append(labelEl);
120
+ const list = el('ul', 'transport-picker-list');
121
+ list.setAttribute('role', 'listbox');
122
+ list.hidden = true;
123
+ // 二级菜单(侧向弹出):内容由调用方的 hooks.onSubmenu 用统一渲染层填
124
+ const sub = el('div', 'transport-picker-sub');
125
+ sub.hidden = true;
126
+ root.append(btn, list, sub);
127
+
128
+ let items = [];
129
+ let activeIndex = -1;
130
+ let current = ctl.value || '';
131
+
132
+ /** 更新触发按钮上的文案 */
133
+ function paintLabel() {
134
+ const hit = items.find((it) => it.value === current);
135
+ labelEl.textContent = hit ? hit.text : (items[0] ? items[0].text : '');
136
+ }
137
+
138
+ /** 高亮某一项(-1 = 不高亮) */
139
+ function paintActive() {
140
+ items.forEach((it, i) => {
141
+ it.node.classList.toggle('is-active', i === activeIndex);
142
+ it.node.setAttribute('aria-selected', String(it.value === current));
143
+ });
144
+ }
145
+
146
+ function closeSub() {
147
+ sub.hidden = true;
148
+ sub.replaceChildren();
149
+ }
150
+
151
+ function close(returnFocus = false) {
152
+ closeSub();
153
+ if (list.hidden) return;
154
+ list.hidden = true;
155
+ btn.setAttribute('aria-expanded', 'false');
156
+ root.classList.remove('is-open');
157
+ activeIndex = -1;
158
+ paintActive();
159
+ if (returnFocus) { try { btn.focus(); } catch { /* 忽略 */ } }
160
+ }
161
+
162
+ function open() {
163
+ // 影院模式下底栏整体被收起,不自作主张弹出
164
+ if (document.documentElement.dataset.cinema === 'on') return;
165
+ // 打开前刷新(背景图列表会随上传/删除变化,不刷新就会列出过期项)
166
+ if (typeof hooks.onBeforeOpen === 'function') { try { hooks.onBeforeOpen(); } catch { /* 刷新失败就用现有选项 */ } }
167
+ list.hidden = false;
168
+ btn.setAttribute('aria-expanded', 'true');
169
+ root.classList.add('is-open');
170
+ const cur = items.findIndex((it) => it.value === current);
171
+ activeIndex = cur >= 0 ? cur : 0;
172
+ paintActive();
173
+ try { (items[activeIndex] || {}).node.focus(); } catch { /* 忽略 */ }
174
+ }
175
+
176
+ /** 选中(真实选择):更新显示 + 通知调用方(与原生 change 一致,不重复触发同值) */
177
+ function pick(value, { silent = false } = {}) {
178
+ const hit = items.find((it) => it.value === value);
179
+ if (!hit) return false;
180
+ const changed = value !== current;
181
+ current = value;
182
+ paintLabel();
183
+ paintActive();
184
+ if (!silent && changed) onPick(value);
185
+ return true;
186
+ }
187
+
188
+ /** 重建选项(换模型/换面时调用):建行细节在模块级 buildPickerItems */
189
+ function setOptions(next, value) {
190
+ items = buildPickerItems(next, (v) => { pick(v); close(true); }, list, (groupId, node) => {
191
+ // 展开二级:把容器交给调用方渲染(内容与右栏同一份 renderSourceGroup)
192
+ items.forEach((it) => it.node.classList.toggle('is-sub', it.node === node));
193
+ sub.hidden = false;
194
+ if (typeof hooks.onSubmenu === 'function') {
195
+ try { hooks.onSubmenu(String(groupId), sub); } catch { closeSub(); }
196
+ }
197
+ });
198
+ if (typeof value === 'string') current = value;
199
+ if (!items.some((it) => it.value === current)) current = items[0] ? items[0].value : '';
200
+ paintLabel();
201
+ paintActive();
202
+ return items.length;
203
+ }
204
+
205
+ // 触发按钮:点击开合
206
+ btn.addEventListener('click', () => { if (list.hidden) open(); else close(); });
207
+ // 键盘:↓/↑ 移动高亮、Enter 确认、Esc 关闭并回焦(逻辑在模块级 handlePickerKey)
208
+ root.addEventListener('keydown', (ev) => handlePickerKey(ev, {
209
+ isOpen: () => !list.hidden,
210
+ open,
211
+ close: () => close(true),
212
+ count: () => items.length,
213
+ activeIndex: () => activeIndex,
214
+ setActive: (i) => { activeIndex = i; paintActive(); try { (items[i] || {}).node.focus(); } catch { /* 忽略 */ } },
215
+ pickActive: () => { const hit = items[activeIndex]; if (hit) { pick(hit.value); close(true); } },
216
+ }));
217
+ // 点外部收起
218
+ document.addEventListener('click', (ev) => { if (!root.contains(ev.target)) close(); });
219
+
220
+ setOptions(ctl.options && ctl.options.length
221
+ ? ctl.options.map((o) => (typeof o === 'string' ? { text: o, value: o } : o))
222
+ : [], current);
223
+
224
+ return {
225
+ root,
226
+ setOptions,
227
+ setValue: (v) => { current = String(v); paintLabel(); paintActive(); },
228
+ /**
229
+ * 按钮上的"当前项预览"(小色块 / 缩略图)。
230
+ * 用 CSS 变量下发颜色与图片地址(运行时数据,不写内联 background 字面量)。
231
+ * @param {{swatch?:string, thumb?:string}|null} preview 传 null 清掉预览
232
+ */
233
+ setPreview: (preview) => {
234
+ let block = btn.querySelector('.picker-prev');
235
+ if (!preview) { if (block) block.remove(); return; }
236
+ if (!block) { block = el('span', 'picker-prev'); btn.insertBefore(block, btn.firstChild); }
237
+ if (preview.swatch) block.style.setProperty('--prev-swatch', preview.swatch);
238
+ else block.style.removeProperty('--prev-swatch');
239
+ if (preview.thumb) block.style.setProperty('--prev-thumb', `url("${preview.thumb}")`);
240
+ else block.style.removeProperty('--prev-thumb');
241
+ block.dataset.kind = preview.thumb ? 'thumb' : 'swatch';
242
+ },
243
+ closeSub,
244
+ open,
245
+ close: () => close(false),
246
+ isOpen: () => !list.hidden,
247
+ value: () => current,
248
+ };
249
+ }
250
+
251
+ /**
252
+ * 底栏「背景」的一级菜单(与右栏四个档位一一对应)。
253
+ *
254
+ * 结构是**两级**:棋盘格 / 透明 直接选;纯色 ▸ / 自定义 ▸ 展开侧向二级(色块列表 / 图片列表),
255
+ * 二级内容由 panel/bg-source.js 的统一渲染层出,与右栏那份是同一套代码。
256
+ * 这里的初值只用于"数据还没拉回来"的第一帧,真实文案(含当前项名与 ✓)由 refreshBgOptions 重算。
257
+ */
258
+ export const BG_MODES = [
259
+ { text: '棋盘格', value: 'mode:checkerboard' },
260
+ { text: '透明', value: 'mode:transparent' },
261
+ { text: '纯色', value: 'g:solid', submenu: 'solid' },
262
+ { text: '自定义', value: 'g:custom', submenu: 'custom' },
263
+ ];
264
+
265
+ /**
266
+ * 当前背景在**一级菜单**里对应的值:内置模式用 `mode:<模式>`,纯色/自定义用档位值 `g:<组>`。
267
+ * 具体是哪一条色/图不在这里体现(二级菜单里打勾),一级只标"哪一档"。
268
+ * @param {{mode?:string}} [background] 缺省读当前设置
269
+ * @returns {string}
270
+ */
271
+ export function currentBgValue(background) {
272
+ const bg = background || (readViewSettings().background || {});
273
+ const mode = String(bg.mode || 'checkerboard');
274
+ if (mode === 'solid') return 'g:solid';
275
+ if (mode === 'custom') return 'g:custom';
276
+ return `mode:${mode === 'transparent' ? 'transparent' : 'checkerboard'}`;
277
+ }
278
+
279
+ /**
280
+ * 应用一个背景选择值。
281
+ * @param {string} value `mode:<模式>` 或 `img:<url>`
282
+ * @returns {boolean} 是否落地
283
+ */
284
+ /** url → 自定义图名字(切图时状态行要显示人话,而不是一串 url) */
285
+ const bgImageNames = new Map();
286
+
287
+ /**
288
+ * 应用一个背景选择值,并写一条状态(与皮肤/动作切换保持一致:让用户看到"切成了什么")。
289
+ * @param {string} value `mode:<模式>` 或 `img:<url>`
290
+ * @returns {boolean} 是否落地
291
+ */
292
+ export function applyBgValue(value) {
293
+ const v = String(value || '');
294
+ if (v.startsWith('img:')) {
295
+ const url = v.slice(4);
296
+ const ok = setCustomBackground(url);
297
+ document.dispatchEvent(new CustomEvent('panel:bg-applied', { detail: { mode: 'custom' } }));
298
+ const bgName = bgImageNames.get(url) || '自定义图片';
299
+ if (ok) stageStatus(`背景 → ${bgName}`, 'info', { show: true });
300
+ else stageLog('warn', `背景 → ${bgName}(舞台未就绪)`, { url, reason: 'stage-not-ready' });
301
+ return ok;
302
+ }
303
+ if (v.startsWith('mode:')) {
304
+ const mode = v.slice(5);
305
+ applyBackground(mode); // 内部会广播 panel:bg-applied,底栏/右栏据此同步
306
+ const text = (BG_MODES.find((m) => m.value === `mode:${mode}`) || {}).text || mode;
307
+ stageStatus(`背景 → ${text}`, 'info', { show: true });
308
+ return true;
309
+ }
310
+ return false;
311
+ }
312
+
313
+ /**
314
+ * 重新拉取背景选项(内置 4 模式 + 服务端自定义图,自定义图带分组标题)。
315
+ * 没有自定义图时只列内置 4 项——不出现空的「自定义图片」分组。
316
+ * @param {object} picker createPicker 句柄
317
+ * @returns {Promise<number>} 选项条数
318
+ */
319
+ export async function refreshBgOptions(picker) {
320
+ if (!picker) return 0;
321
+ const sources = await listBgSources();
322
+ const sel = selectedSourceId(readViewSettings().background || {}, sources);
323
+ // 两组各自找自己的命中条目:不能共用一个 item,否则会出现「自定义 · 暖暗」这种串味
324
+ const findIn = (pool) => (sel.itemId ? (pool || []).find((x) => x.id === sel.itemId) || null : null);
325
+ const solidItem = sel.groupId === 'solid' ? findIn(sources.solid) : null;
326
+ const customItem = sel.groupId === 'custom' ? findIn(sources.custom) : null;
327
+
328
+ const tick = (on) => (on ? '✓ ' : '');
329
+ const opts = [
330
+ { text: `${tick(sel.groupId === 'builtin' && sel.id === 'checkerboard')}棋盘格`, value: 'mode:checkerboard' },
331
+ { text: `${tick(sel.groupId === 'builtin' && sel.id === 'transparent')}透明`, value: 'mode:transparent' },
332
+ // 一级只标"哪一档";有具体命中的条目就把名字带出来,按钮回显因此自然是「纯色 · 暖暗」
333
+ { text: `${tick(sel.groupId === 'solid')}${solidItem ? `纯色 · ${solidItem.name}` : '纯色'}`, value: 'g:solid', submenu: 'solid' },
334
+ { text: `${tick(sel.groupId === 'custom')}${customItem ? `自定义 · ${customItem.name}` : '自定义'}`, value: 'g:custom', submenu: 'custom' },
335
+ ];
336
+ picker.setOptions(opts, currentBgValue());
337
+ // 按钮上的小预览:纯色给色块、自定义给缩略图;内置模式清掉
338
+ if (typeof picker.setPreview === 'function') {
339
+ picker.setPreview(solidItem ? { swatch: solidItem.color }
340
+ : (customItem ? { thumb: customItem.url } : null));
341
+ }
342
+ return opts.length;
343
+ }
344
+
345
+ /**
346
+ * 渲染某一组的二级菜单内容(供底栏 picker 的 onSubmenu 调用)。
347
+ * 与右栏共用 panel/bg-source.js 的同一个渲染函数 ⇒ 两处永远一致。
348
+ * @param {HTMLElement} host 二级容器
349
+ * @param {'solid'|'custom'} groupId
350
+ * @param {(payload:{groupId:string, item:object})=>void} onPick 选中回调
351
+ * @returns {Promise<number>} 条数
352
+ */
353
+ export async function renderBgSubmenu(host, groupId, onPick) {
354
+ const sources = await listBgSources();
355
+ const sel = selectedSourceId(readViewSettings().background || {}, sources);
356
+ const items = groupId === 'custom' ? sources.custom : sources.solid;
357
+ const { count } = renderSourceGroup(host, groupId, items, {
358
+ variant: 'menu',
359
+ selectedItemId: sel.itemId || null,
360
+ onApply: (item) => { if (typeof onPick === 'function') onPick({ groupId, item }); },
361
+ });
362
+ return count;
363
+ }
364
+
365
+ /**
366
+ * 任何来源(右栏档位 / 右栏列表 / 底栏自己)改了背景,都让底栏下拉重新对齐。
367
+ * 只刷新自己、不再往回广播,避免来回触发。
368
+ */
369
+ export function bindBgSync(picker) {
370
+ if (typeof document === 'undefined') return;
371
+ document.addEventListener('panel:bg-applied', () => {
372
+ refreshBgOptions(picker).catch(() => { /* 刷新失败保持现状 */ });
373
+ });
374
+ }
375
+