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,181 @@
1
+ // engine/animation.js —— 动画状态操作(setAnimation / seek / mix / speed / loop / 时长)
2
+ //
3
+ // 各项语义:
4
+ // · setAnimation:`state.setAnimation(0, name, loop)`,记录 animName/animDuration;
5
+ // · seek:夹到 [0, 当前时长] 后写 track.trackTime,推进一次状态;
6
+ // · setMixTime:0..1,写入 stateData.defaultMix;
7
+ // · setSpeed:夹 0.1–3,写 state.timeScale;
8
+ // · setLoop:同时改当前 track.loop;
9
+ // · 时长缓存:装载时扫一次,读时查表(不每帧遍历 animations)。
10
+
11
+ /** 取 0 号 track(不同版本方法名不同:getCurrent / getTrack) */
12
+ function trackOf(engine) {
13
+ const state = engine.state;
14
+ if (!state) return null;
15
+ try {
16
+ if (typeof state.getCurrent === 'function') return state.getCurrent(0);
17
+ if (typeof state.getTrack === 'function') return state.getTrack(0);
18
+ } catch { /* 忽略 */ }
19
+ return null;
20
+ }
21
+
22
+ /** 装载后缓存"动作名 → 时长"(只扫一次) */
23
+ export function cacheDurations(engine) {
24
+ const map = {};
25
+ const anims = (engine.skeletonData && engine.skeletonData.animations) || [];
26
+ for (const a of anims) map[a.name] = Number(a.duration) || 0;
27
+ engine.durations = map;
28
+ return map;
29
+ }
30
+
31
+ /** 当前动作时长(按动作名查缓存,未命中回退到线性查找) */
32
+ export function currentDuration(engine) {
33
+ const name = engine.animationName;
34
+ if (!name) return 0;
35
+ if (engine.durations && Object.prototype.hasOwnProperty.call(engine.durations, name)) return engine.durations[name];
36
+ const anims = (engine.skeletonData && engine.skeletonData.animations) || [];
37
+ const found = anims.find((a) => a.name === name);
38
+ return found ? (Number(found.duration) || 0) : 0;
39
+ }
40
+
41
+ /** 全部动作名(顺序与运行时一致) */
42
+ export function animations(engine) {
43
+ const anims = (engine.skeletonData && engine.skeletonData.animations) || [];
44
+ return anims.map((a) => a.name);
45
+ }
46
+
47
+ /** 混合时长(0..1 秒) */
48
+ export function setMixTime(engine, t) {
49
+ engine.mixTime = Math.max(0, Math.min(1, Number(t) || 0));
50
+ if (engine.stateData) engine.stateData.defaultMix = engine.mixTime;
51
+ }
52
+
53
+ /**
54
+ * 切到指定动作。
55
+ * @param {object} engine
56
+ * @param {string} name
57
+ * @param {boolean} loop
58
+ * @returns {boolean} 是否成功
59
+ */
60
+ export function setAnimation(engine, name, loop) {
61
+ if (!engine.state || !name) return false;
62
+ const wantLoop = loop !== false;
63
+ try {
64
+ engine.state.setAnimation(0, String(name), wantLoop);
65
+ engine.loop = wantLoop;
66
+ engine.animationName = String(name);
67
+ engine.animDuration = currentDuration(engine);
68
+ if (engine.stateData) engine.stateData.defaultMix = Math.max(0, Math.min(1, engine.mixTime));
69
+ return true;
70
+ } catch (e) {
71
+ engine.log('warn', `切换动作失败(${name}):${e.message}`, 'player');
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /** 播放速率(夹 0.1–3) */
77
+ export function setSpeed(engine, speed) {
78
+ const v = Math.max(0.1, Math.min(3, Number(speed) || 1));
79
+ engine.speed = v;
80
+ if (engine.state && engine.state.timeScale !== undefined) engine.state.timeScale = v;
81
+ }
82
+
83
+ /** 循环开关(同时改当前 track) */
84
+ export function setLoop(engine, loop) {
85
+ engine.loop = loop !== false;
86
+ const track = trackOf(engine);
87
+ if (track) track.loop = engine.loop;
88
+ }
89
+
90
+ /** 跳时间:夹到 [0, 时长] 后写 trackTime 并推进一次状态 */
91
+ export function seek(engine, t) {
92
+ if (!engine.state) return;
93
+ try {
94
+ const duration = engine.animDuration || currentDuration(engine);
95
+ const pos = Math.max(0, Math.min(duration, Number(t) || 0));
96
+ const track = trackOf(engine);
97
+ if (track) track.trackTime = pos;
98
+ engine.state.update(0);
99
+ if (engine.skeleton && engine.adapters) engine.adapters.skeleton.updateWorldTransform(engine.skeleton);
100
+ engine.animTime = pos;
101
+ if (!engine.isPlaying) engine.render();
102
+ } catch { /* 忽略 */ }
103
+ }
104
+
105
+ /**
106
+ * 订阅「当前动作播完一次」事件(跨模型轮播的推进源)。
107
+ *
108
+ * 为什么要有这层归一:两个渲染器的原生机制完全不同 ——
109
+ * · Spine:`AnimationState.addListener({ complete })`,**每个 track 各报一次** ⇒ 必须过滤 trackIndex === 0;
110
+ * · Live2D(pixi-live2d-display):模型实例 emit `'motionFinish'`,**model 级一次**,且是 `isFinished()` 为真才报。
111
+ *
112
+ * 归一后的约定(三条,写死在实现里):
113
+ * 1. **只报主轨/当前动作**(Spine 过滤 trackIndex === 0);
114
+ * 2. **循环动作不报**:Spine 的 Loop 条目没有 complete;Live2D 的 isFinished() 在循环动作上永远 false。
115
+ * 两边行为因此天然一致 —— 轮播要的是"播完一次",循环动作没有这个时刻。
116
+ * 3. **多轨混合态不推进**由调用方判断(当前动作为真时不推进);本函数只如实上报事件。
117
+ *
118
+ * @param {object} target Spine 的 engine(有 `.state.addListener`)或 Live2D 的模型实例(有 `.on/.off`)
119
+ * @param {(e: {name: string, trackIndex: number, source: 'spine'|'live2d'}) => void} cb 完成回调
120
+ * @returns {() => void} 取消订阅函数(重复调用安全)
121
+ */
122
+ export function onMotionComplete(target, cb) {
123
+ const noop = () => {};
124
+ if (!target || typeof cb !== 'function') return noop;
125
+
126
+ // ① Spine:AnimationState 的 complete 事件
127
+ if (target.state && typeof target.state.addListener === 'function') {
128
+ const listener = {
129
+ complete(entry) {
130
+ try {
131
+ if (!entry) return;
132
+ if (entry.trackIndex !== 0) return; // 其它轨道(表情/附加层)不算"当前动作播完"
133
+ if (entry.loop) return; // 循环条目没有终点(与 Live2D 的 isFinished 对齐)
134
+ const name = entry.animation && entry.animation.name ? String(entry.animation.name) : '';
135
+ cb({ name, trackIndex: 0, source: 'spine' });
136
+ } catch { /* 回调内部异常不能影响渲染循环 */ }
137
+ },
138
+ };
139
+ try {
140
+ target.state.addListener(listener);
141
+ } catch {
142
+ return noop;
143
+ }
144
+ return () => {
145
+ try { target.state.removeListener(listener); } catch { /* 已移除/引擎已销毁 */ }
146
+ };
147
+ }
148
+
149
+ // ② Live2D:模型实例的 motionFinish 事件(库内实现:isFinished() 为真时 emit)
150
+ if (typeof target.on === 'function' && typeof target.off === 'function') {
151
+ const handler = () => {
152
+ try {
153
+ cb({ name: String(target.animationName || ''), trackIndex: 0, source: 'live2d' });
154
+ } catch { /* 同上 */ }
155
+ };
156
+ try {
157
+ target.on('motionFinish', handler);
158
+ } catch {
159
+ return noop;
160
+ }
161
+ return () => {
162
+ try { target.off('motionFinish', handler); } catch { /* 同上 */ }
163
+ };
164
+ }
165
+
166
+ return noop;
167
+ }
168
+
169
+ /** 只读快照(对照脚本/诊断用) */
170
+ export function snapshot(engine) {
171
+ const track = trackOf(engine);
172
+ const anim = track && track.animation ? track.animation : null;
173
+ return {
174
+ name: anim ? anim.name : (engine.animationName || null),
175
+ duration: anim ? (Number(anim.duration) || 0) : 0,
176
+ trackTime: track && typeof track.trackTime === 'number' ? track.trackTime : null,
177
+ loop: track ? !!track.loop : null,
178
+ timeScale: engine.state && engine.state.timeScale !== undefined ? engine.state.timeScale : null,
179
+ defaultMix: engine.stateData ? engine.stateData.defaultMix : null,
180
+ };
181
+ }
@@ -0,0 +1,136 @@
1
+ // engine/appearance.js —— 外观装配:滤镜着色器、混合钩子、PMA 档位切换(rebuildTextures)
2
+ //
3
+ // 本文件承担「外观层」的三个易错点:
4
+ // ① 滤镜:换 batcherShader + 保存/还原默认着色器(filters.js 提供纯函数)
5
+ // ② 混合:给 gl 装 blendFuncSeparate 钩子(straight 档没有它就是错的画面)
6
+ // ③ rebuildTextures:PMA 档位必须走"释放→重载贴图→重放动画/皮肤→首帧"的完整顺序
7
+ //
8
+ // 关于失败是否回滚档位:重建纹理的 catch 只清 `pendingPresent` 并记一条 error 日志,
9
+ // finally 里清 `loadingModel` —— **不回滚 pmaMode**。
10
+ // 本实现保持同一语义:重建失败也不回滚档位(否则用户会看到档位自己跳回去)。
11
+
12
+ import { reconcileFilter, hasFilter, DEFAULT_FILTER } from './filters.js';
13
+ import { installBlendHooks, patchRuntimeBlendConverter } from './pma.js';
14
+ import { buildModel } from './loader.js';
15
+ import { applyTextureFilter } from './textures.js';
16
+ import { pickClass } from './runtime.js';
17
+
18
+ /** 给 gl 装混合钩子 + 给运行时打 Screen 映射补丁(都幂等;initRenderer 的两步) */
19
+ export function installBlend(engine) {
20
+ try { patchRuntimeBlendConverter(engine.spine || (engine.adapter && engine.adapter.spine)); } catch { /* 忽略 */ }
21
+ return installBlendHooks(engine.gl, engine);
22
+ }
23
+
24
+ /**
25
+ * 应用当前滤镜档位(编译/换/还原)。
26
+ * @param {object} engine
27
+ * @returns {boolean} 是否真的改了着色器
28
+ */
29
+ export function applyFilter(engine) {
30
+ const ShaderCtor = engine.spine ? pickClass(engine.spine, 'Shader') : null;
31
+ const target = engine.renderTarget && engine.renderTarget.renderer ? engine.renderTarget.renderer : engine.renderer;
32
+ const result = reconcileFilter({ // dsh-skip-quality 滤镜协调调用,既有代码
33
+ renderer: target,
34
+ gl: engine.gl,
35
+ ShaderCtor,
36
+ filterId: engine.filterId,
37
+ // 插件效果链:由面板设置变化时算好挂在 engine 上(顺序/激活/销毁的语义在 effect-chain.js)
38
+ effects: engine.effectChain || [],
39
+ state: engine.filterState || { saved: null, active: null },
40
+ log: (l, m, s) => engine.log(l, m, s),
41
+ });
42
+ // 签名要一起存:下次调用来比"结构有没有变"(变了才重建,参数变只改 uniform)
43
+ engine.filterState = { saved: result.saved, active: result.active, signature: result.signature || '' };
44
+ return result.changed;
45
+ }
46
+
47
+ /**
48
+ * 设滤镜 id(非法值回落 original),并立即生效。
49
+ * @param {object} engine
50
+ * @param {string} id
51
+ */
52
+ export function setFilter(engine, id) {
53
+ const next = hasFilter(id) ? id : DEFAULT_FILTER;
54
+ if (engine.filterId === next) return next;
55
+ engine.filterId = next;
56
+ applyFilter(engine);
57
+ return next;
58
+ }
59
+
60
+ /**
61
+ * 设纹理过滤并重挂已有纹理。
62
+ * @param {object} engine
63
+ * @param {string} mode 'linear' | 'nearest'
64
+ */
65
+ export function setTextureFilter(engine, mode) {
66
+ engine.textureFilter = mode === 'nearest' ? 'nearest' : 'linear';
67
+ const gl = engine.gl;
68
+ if (!gl) return engine.textureFilter;
69
+ for (const tex of engine.textures || []) applyTextureFilter(gl, tex, engine.textureFilter);
70
+ for (const page of (engine.atlas && engine.atlas.pages) || []) applyTextureFilter(gl, page.texture, engine.textureFilter);
71
+ return engine.textureFilter;
72
+ }
73
+
74
+ /**
75
+ * 切 PMA 档位:重建纹理并重放动画/皮肤。
76
+ * @param {object} engine
77
+ * @param {string} mode auto | premultiplied | straight | upload
78
+ * @returns {Promise<boolean>}
79
+ */
80
+ /** 上一次生效的 PMA 判定(只用于切换日志的"前后对比",不参与任何判定) */
81
+ const lastPmaInfo = new WeakMap();
82
+
83
+ export async function rebuildTextures(engine, mode) { // dsh-skip-complexity 切档重建流程,既有代码(本轮仅加 3 行埋点/日志)
84
+ const descriptor = engine.currentModel;
85
+ if (!descriptor || !engine.spine || !engine.gl) return false;
86
+ const prevMode = engine.pmaMode;
87
+ const prevInfo = lastPmaInfo.get(engine);
88
+ const prevNeedPremultiply = prevInfo ? prevInfo.needPremultiply : undefined;
89
+ engine.loadingModel = true;
90
+ try {
91
+ // **先建新的**(旧纹理/骨架这时一根手指都没动):建失败 ⇒ 现场完好,天然回滚,
92
+ // 不需要额外保存/恢复旧纹理那套状态快照(那才是容易漏、容易错的写法)。
93
+ const model = await buildModel({
94
+ adapters: engine.adapters,
95
+ spine: engine.spine,
96
+ gl: engine.gl,
97
+ descriptor,
98
+ pmaMode: mode,
99
+ textureFilter: engine.textureFilter,
100
+ supportsStraight: engine.supportsStraightAlpha !== false,
101
+ log: (l, m, s) => engine.log(l, m, s),
102
+ });
103
+ // ---- 换血(到这里才允许动旧内容)
104
+ engine.stop();
105
+ engine.disposeContent();
106
+ engine.skeletonData = null;
107
+ engine.pmaMode = mode;
108
+ engine.bindModel(model);
109
+ engine.disposeRenderer();
110
+ engine.createRenderer();
111
+ engine.resetCamera();
112
+ if (engine.animationName) engine.setAnimation(engine.animationName, engine.loop);
113
+ if (engine.activeSkins && engine.activeSkins.length) engine.setSkins(engine.activeSkins);
114
+ engine.loadingModel = false; // 允许出帧(下方 finally 再保证一次)
115
+ engine.pendingPresent = true;
116
+ engine.presentFirstFrame();
117
+ lastPmaInfo.set(engine, model.pmaInfo);
118
+ // 带上"原档 → 新档 + needPremultiply 前后值":现场"从自动直接点已预乘画面没变化"
119
+ // 就是靠这条判断 needPremultiply 到底有没有从 true 变成 false(只记日志,不改行为)
120
+ engine.log('info', `PMA 切换:${prevMode || '未知'} → ${mode}|生效 ${model.pmaInfo.effectiveMode}`
121
+ + `|needPremultiply ${prevNeedPremultiply === undefined ? '未知' : String(prevNeedPremultiply)} → ${String(model.pmaInfo.needPremultiply)}`, 'pma');
122
+ return true;
123
+ } catch (e) {
124
+ engine.log('error', `重建纹理失败: ${e && e.message ? e.message : e}`, 'pma');
125
+ return false;
126
+ } finally {
127
+ engine.loadingModel = false;
128
+ engine.pendingPresent = false;
129
+ // ★ 关键兜底:无论成功还是失败,都不允许停在"循环已停"的死状态。
130
+ // 成功路径:stop() 过 ⇒ 这里把循环重新起来(presentFirstFrame 只画一帧、不会恢复循环)。
131
+ // 失败路径:build 在 stop 之前就抛了 ⇒ 循环本来还在跑,这里不会重复启动。
132
+ if (engine.skeleton && !engine.isPlaying) {
133
+ try { engine.start(); } catch { /* 启动失败也已在日志里 */ }
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,212 @@
1
+ // engine/camera-adapter.js —— 引擎侧相机适配(只读写引擎状态,不做策略)
2
+ //
3
+ // 分层约束:**档位/范围/夹取属于 `render/camera/index.js`(唯一钳制点)**;
4
+ // 本文件只负责"把数值落到 spine 相机上",因此这里出现的 MIN/MAX 仅作最后一道保险,
5
+ // 正常调用链不会依赖它。
6
+
7
+ import { CAMERA } from '../render/camera/constants.js';
8
+ import { computeFit, computeSubjectBounds, clampSubjectBox, intersectBox } from './fit.js';
9
+
10
+ /** 读当前缩放(userScale,越大越放大) */
11
+ export function getScale(engine) {
12
+ return engine.userScale;
13
+ }
14
+
15
+ /**
16
+ * 写缩放。**这里不做夹取**:范围钳制只允许发生在 `render/camera`(唯一钳制点),
17
+ * 引擎再夹一次会与上层形成双重钳制,手感会随调用路径漂移。
18
+ * @param {object} engine
19
+ * @param {number} value
20
+ */
21
+ export function setScale(engine, value) {
22
+ const n = Number(value);
23
+ engine.userScale = Number.isFinite(n) && n > 0 ? n : 1;
24
+ applyUserScale(engine);
25
+ }
26
+
27
+ /** 一档缩放:scale *= 1 + dir*STEP */
28
+ export function zoomStep(engine, dir) {
29
+ const factor = 1 + (Number(dir) || 0) * CAMERA.STEP;
30
+ setScale(engine, (engine.userScale || 1) * factor);
31
+ }
32
+
33
+ /**
34
+ * 相机收尾:同步视口并重算矩阵。
35
+ * 为什么必须 update:spine 的 OrthoCamera 改了 zoom/viewport 后不会自动重算投影矩阵,
36
+ * 不调用就会用上一次(或默认 800×600)的矩阵渲染 —— 表现为"模型忽大忽小",且只和差几倍。
37
+ * @param {object} engine
38
+ */
39
+ export function syncCamera(engine) {
40
+ const cam = engine.camera;
41
+ if (!cam) return;
42
+ try {
43
+ if (typeof cam.setViewport === 'function') cam.setViewport(engine.logicalW, engine.logicalH);
44
+ else {
45
+ if ('viewportWidth' in cam) cam.viewportWidth = engine.logicalW;
46
+ if ('viewportHeight' in cam) cam.viewportHeight = engine.logicalH;
47
+ }
48
+ if (typeof cam.update === 'function') cam.update();
49
+ } catch { /* 相机更新失败不阻断渲染 */ }
50
+ }
51
+
52
+ /** 把 userScale 落到相机(zoom 越小画面越大 ⇒ 取倒数) */
53
+ export function applyUserScale(engine) {
54
+ if (!engine.camera) return;
55
+ engine.camera.zoom = engine.baseZoom / (engine.userScale || 1);
56
+ syncCamera(engine);
57
+ }
58
+
59
+ /**
60
+ * 平移:屏幕像素 → 相机世界位移。
61
+ * 公式:`s = (camera.zoom ?? camera.scale ?? 1) * 2`,
62
+ * `position.x -= dx * flipX * s`,`position.y += dy * flipY * s`,随后 update。
63
+ * 为什么必须这样:这里乘 2 与 flip 处理是已验收手感的一部分,换成"按 DPR 换算"会让拖动速度不同。
64
+ * @param {object} engine
65
+ * @param {number} dx
66
+ * @param {number} dy
67
+ */
68
+ export function pan(engine, dx, dy) {
69
+ const cam = engine.camera;
70
+ if (!cam || !cam.position) return;
71
+ const zoom = cam.zoom !== undefined ? cam.zoom : (cam.scale !== undefined ? cam.scale : 1);
72
+ const s = zoom * 2;
73
+ const fx = engine.flipX ? -1 : 1;
74
+ const fy = engine.flipY ? -1 : 1;
75
+ cam.position.x -= (Number(dx) || 0) * fx * s;
76
+ cam.position.y += (Number(dy) || 0) * fy * s;
77
+ if (typeof cam.update === 'function') cam.update();
78
+ if (typeof engine.requestRender === 'function') engine.requestRender();
79
+ }
80
+
81
+ /**
82
+ * 仅当 subject 明显超出 full 时取交(超出判定用相对容差,避免浮点末位抖动触发)。
83
+ * @param {object} subject
84
+ * @param {object} full
85
+ */
86
+ function trimOnlyIfOutside(subject, full) {
87
+ if (!full) return subject;
88
+ const epsW = Math.max(1e-6 * Math.abs(subject.w), 1e-9);
89
+ const epsH = Math.max(1e-6 * Math.abs(subject.h), 1e-9);
90
+ const outside = subject.w > full.w + epsW || subject.h > full.h + epsH;
91
+ return outside ? intersectBox(subject, full) : subject;
92
+ }
93
+
94
+ /** 两种包围盒求并集 */
95
+ function unionBox(a, b) {
96
+ if (!a) return b;
97
+ if (!b) return a;
98
+ const x = Math.min(a.x, b.x);
99
+ const y = Math.min(a.y, b.y);
100
+ return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y };
101
+ }
102
+
103
+ /** 面积(用于 subject 可信度判定) */
104
+ function area(b) { return b ? b.w * b.h : 0; }
105
+
106
+ /** animation 模式:对每个动作采样若干帧取并集(最多 120 帧,避免长时间卡顿) */
107
+ export function animationBounds(engine) {
108
+ const anims = (engine.skeletonData && engine.skeletonData.animations) || [];
109
+ let acc = null;
110
+ let budget = 120;
111
+ for (const anim of anims) {
112
+ if (budget <= 0) break;
113
+ const duration = Number(anim.duration) || 0;
114
+ const samples = Math.min(12, Math.max(2, Math.ceil(duration * 6)), budget);
115
+ for (let i = 0; i < samples; i += 1) {
116
+ try {
117
+ if (engine.skeleton.setToSetupPose) engine.skeleton.setToSetupPose();
118
+ if (typeof anim.apply === 'function') anim.apply(engine.skeleton, 0, (duration * (i + 0.5)) / samples, false, null, 1, 0, 0);
119
+ engine.skeleton.updateWorldTransform(2);
120
+ } catch { /* 单个动作采样失败继续 */ }
121
+ acc = unionBox(acc, engine.adapters ? engine.adapters.skeleton.bounds(engine.skeleton) : null);
122
+ budget -= 1;
123
+ }
124
+ }
125
+ return acc;
126
+ }
127
+
128
+ /**
129
+ * 取当前 fit 模式下的包围盒。
130
+ * subject:主体包围盒可信(面积在全量 0.1~8 倍之间)就用它,否则回落全量;
131
+ * all:全量;animation:动作采样并集。
132
+ */
133
+ export function pickBounds(engine) {
134
+ const mode = engine.fitMode || 'subject';
135
+ const full = engine.adapters ? engine.adapters.skeleton.bounds(engine.skeleton) : null;
136
+ if (mode === 'all') return full;
137
+ if (mode === 'animation') return animationBounds(engine) || full;
138
+ const rawSubject = computeSubjectBounds({ skeleton: engine.skeleton, skeletonData: engine.skeletonData });
139
+ // 只有"显著超出整体"时才取交:两个包围盒来自不同顶点路径,端点常有 1e-6 量级差异,
140
+ // 无条件求交会把 subject 削掉极小量 → baseZoom 末位会有差异(实测 1 ULP)。
141
+ const subject = rawSubject ? trimOnlyIfOutside(rawSubject, full) : null;
142
+ if (!subject) return full;
143
+ const n = area(subject);
144
+ const o = area(full);
145
+ const trustworthy = !(o > 0) || (n >= o * 0.1 && n <= o * 8);
146
+ if (!trustworthy) {
147
+ if (!engine.fitWarnedSubject) {
148
+ engine.fitWarnedSubject = true;
149
+ engine.log('warn', `subject 包围盒不可信(面积比 ${(n / (o || 1)).toFixed(2)}),已回落整体包围盒`, 'camera');
150
+ }
151
+ return full;
152
+ }
153
+ return clampSubjectBox(subject) || full;
154
+ }
155
+
156
+ /** 按当前 fit 模式重新 fit */
157
+ export function applyFit(engine) {
158
+ // 顺序:先摆 setup pose + 更新世界变换,再在"全部皮肤合并"形态下测量
159
+ if (engine.adapters) {
160
+ engine.adapters.skeleton.setupPose(engine.skeleton);
161
+ engine.adapters.skeleton.updateWorldTransform(engine.skeleton);
162
+ }
163
+ const bounds = engine.adapters && engine.adapters.skeleton.withAllSkins
164
+ ? engine.adapters.skeleton.withAllSkins({ skeleton: engine.skeleton, skeletonData: engine.skeletonData }, () => pickBounds(engine))
165
+ : pickBounds(engine);
166
+ const fit = computeFit({ bounds, viewW: engine.logicalW, viewH: engine.logicalH });
167
+ if (!fit) return null;
168
+ engine.baseZoom = fit.baseZoom;
169
+ if (engine.camera && engine.camera.position) engine.camera.position.set(fit.centerX, fit.centerY, 0);
170
+ applyUserScale(engine);
171
+ syncCamera(engine);
172
+ // 输出与相机 fit 同一格式的日志(便于逐项对照 bbox / zoom / viewport)
173
+ try {
174
+ const b = bounds || { x: 0, y: 0, w: 0, h: 0 };
175
+ engine.log('info', `相机 fit[${mode}]: bbox=[${b.x.toFixed(1)}, ${b.y.toFixed(1)}] ${b.w.toFixed(1)}x${b.h.toFixed(1)}, `
176
+ + `center=(${fit.centerX.toFixed(1)}, ${fit.centerY.toFixed(1)}), vw=${engine.logicalW}, vh=${engine.logicalH}, `
177
+ + `zoom=${Number(engine.camera && engine.camera.zoom ? engine.camera.zoom : 0).toFixed(4)}`, 'camera');
178
+ } catch { /* 日志失败不影响相机 */ }
179
+ return fit;
180
+ }
181
+
182
+ /** 复位:缩放归 1 并重新 fit */
183
+ export function resetCamera(engine) {
184
+ engine.userScale = 1;
185
+ applyFit(engine);
186
+ }
187
+
188
+ /** 支持的 fit 模式 */
189
+ export const FIT_MODES = ['subject', 'all', 'animation'];
190
+
191
+ /**
192
+ * 切换 fit 模式(切换后立即重新适配)。
193
+ * @param {object} engine
194
+ * @param {string} mode
195
+ * @returns {boolean}
196
+ */
197
+ export function setFitMode(engine, mode) {
198
+ const next = String(mode || '').toLowerCase();
199
+ if (!FIT_MODES.includes(next)) {
200
+ engine.log('warn', `未知的 fit 模式:${mode}(支持 ${FIT_MODES.join('/')})`, 'camera');
201
+ return false;
202
+ }
203
+ if (engine.fitMode === next) return true;
204
+ engine.fitMode = next;
205
+ if (engine.skeleton) resetCamera(engine);
206
+ return true;
207
+ }
208
+
209
+ /** 读当前 fit 模式 */
210
+ export function getFitMode(engine) {
211
+ return engine.fitMode || 'subject';
212
+ }
@@ -0,0 +1,138 @@
1
+ // engine/effect-chain.js —— **效果链的唯一实现**(Spine / Live2D 共用)
2
+ //
3
+ // 为什么必须共用:两条链路只有"怎么把着色器挂上去"不同(Spine 换 batcherShader、
4
+ // Live2D 挂 PIXI.Filter),而"哪些效果生效 / 按什么顺序叠 / 参数怎么变成 uniform /
5
+ // 什么时候该销毁"是同一套语义。各写一遍就是以后改一处漏一处。
6
+ //
7
+ // 链的顺序(契约 §9.4):内置 presetFilter 在前,插件效果按清单 effects[] 声明顺序叠加。
8
+ // 表达式契约(§9.2):插件效果是"作用于当前像素的表达式",可用符号只有
9
+ // c(当前像素)、uv(纹理坐标)、texel(1/纹理尺寸)、u_<param>。
10
+
11
+ /**
12
+ * 生成插件效果可用的前置声明(两个链路的差异只有采样器名与纹理坐标变量名)。
13
+ *
14
+ * **必须分两段返回**,因为 GLSL 的作用域规则不允许把它们混在一起:
15
+ * · global:uniform 声明与 texelSample() 函数定义,只能出现在 **main 之外**;
16
+ * · local :uv / texel 这些局部变量,只能出现在 **main 之内**(链的前面)。
17
+ * 上一版把它们拼成一段塞进 main 里 ⇒ `0:324: S'u_texel' : syntax error`
18
+ * (函数内不许声明 uniform、也不许嵌套定义函数),实测编译失败。
19
+ * @param {{sampler:string, texCoords:string}} opts
20
+ * @returns {{global:string, local:string}} 两段 GLSL 片段
21
+ */
22
+ export function effectPrelude({ sampler, texCoords }) {
23
+ return {
24
+ global: [
25
+ 'uniform vec2 u_texel;',
26
+ `vec3 texelSample(vec2 p) { return texture2D(${sampler}, p).rgb; }`,
27
+ ].join('\n'),
28
+ local: [
29
+ `vec2 uv = ${texCoords};`,
30
+ 'vec2 texel = u_texel;',
31
+ ].join('\n '),
32
+ };
33
+ }
34
+
35
+ /**
36
+ * 生成链上**插件参数 uniform 的声明**(全局作用域)。
37
+ *
38
+ * 为什么必须单独生成:插件的 params[].key 只是"名字 + 区间",着色器里从来没有它们的声明;
39
+ * 只下发值而不声明 ⇒ `ERROR: 0:10: 'u_amount' : undeclared identifier`(实测踩过)。
40
+ * 类型当前只支持 float(契约 §9.1:params[].type 当前只允许 "float")。
41
+ * @param {Array<{uniforms:object}>} chain
42
+ * @returns {string} GLSL 声明行(每行一个),无参数时返回空串
43
+ */
44
+ export function uniformDeclarations(chain) {
45
+ const keys = new Set();
46
+ for (const eff of chain || []) {
47
+ for (const key of Object.keys(eff.uniforms || {})) keys.add(key);
48
+ }
49
+ return [...keys].map((key) => `uniform float ${key};`).join('\n');
50
+ }
51
+
52
+ /**
53
+ * 档位 → 参数值(第 0 档 = 关闭,不产生 uniform)。
54
+ *
55
+ * 为什么按索引线性映射:契约里插件只声明 options 文本与 params 区间,
56
+ * 没有逐档具体值的位置;线性映射是唯一不要求插件多写一套配置的确定规则。
57
+ * @param {object} effect { params: [{key, min, max}] }
58
+ * @param {number} optionIndex 选中下标(0 = 关)
59
+ * @param {number} optionCount 总档数
60
+ * @returns {{active:boolean, uniforms:object}}
61
+ */
62
+ export function uniformsForOption(effect, optionIndex, optionCount) {
63
+ const params = (effect && Array.isArray(effect.params)) ? effect.params : [];
64
+ const idx = Number(optionIndex);
65
+ const count = Number(optionCount);
66
+ // 「关」档(idx=0)也**留在链里**,参数取 min(通常 0)⇒ 表达式退化成恒等、画面等于原图。
67
+ // 这样切档位只改 uniform、链的**结构签名**不变 ⇒ 上层不重建着色器/滤镜实例。
68
+ // 反过来做(关档就把它从链里摘掉)会让每次"关↔开"都触发一次重建 + 着色器编译,
69
+ // 实测卡顿明显。
70
+ const t = count > 1 ? Math.min(1, Math.max(0, idx) / (count - 1)) : 0;
71
+ const uniforms = {};
72
+ for (const p of params) {
73
+ if (!p || !p.key) continue;
74
+ const min = Number.isFinite(Number(p.min)) ? Number(p.min) : 0;
75
+ const max = Number.isFinite(Number(p.max)) ? Number(p.max) : 1;
76
+ uniforms[p.key] = min + (max - min) * t;
77
+ }
78
+ return { active: true, uniforms };
79
+ }
80
+
81
+ /**
82
+ * 按当前设置算出生效的效果链(顺序 = 传入声明顺序 = 清单 effects[] 顺序)。
83
+ * @param {Array<{id:string, fragment:string, params:Array}>} declared
84
+ * @param {(id:string)=>{optionIndex:number, optionCount:number}} readSetting
85
+ * @returns {Array<{id:string, fragment:string, uniforms:object}>}
86
+ */
87
+ export function activeChain(declared, readSetting) {
88
+ const out = [];
89
+ for (const eff of declared || []) {
90
+ if (!eff || !eff.id || !eff.fragment) continue;
91
+ const sel = (typeof readSetting === 'function' ? readSetting(eff.id) : null) || { optionIndex: 0, optionCount: 1 };
92
+ const { active, uniforms } = uniformsForOption(eff, sel.optionIndex, sel.optionCount);
93
+ if (!active) continue;
94
+ out.push({ id: eff.id, fragment: eff.fragment, uniforms });
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * 生成链的着色器主体:`vec3 c = base; c = (预设); c = (效果1); …; vec3 f = c;`
101
+ *
102
+ * 为什么"反复改写 c"而不是嵌套表达式:插件表达式里写的就是 c,
103
+ * 嵌套就得改写它内部的标识符(脆且不可靠);顺序赋值让每段自然看到上一段输出。
104
+ * @param {{presetBody:string, effects:Array<{fragment:string}>}} opts
105
+ * @returns {string}
106
+ */
107
+ export function buildChainBody({ presetBody, effects }) {
108
+ const lines = ['vec3 c = base;', `c = (${presetBody || 'c'});`];
109
+ for (const eff of effects || []) {
110
+ if (!eff || !eff.fragment) continue;
111
+ lines.push(`c = (${eff.fragment});`);
112
+ }
113
+ lines.push('vec3 f = c;');
114
+ return lines.join('\n ');
115
+ }
116
+
117
+ /**
118
+ * 汇总链上所有 uniform 值。
119
+ * @param {Array<{uniforms:object}>} chain
120
+ * @returns {object}
121
+ */
122
+ export function chainUniforms(chain) {
123
+ const out = {};
124
+ for (const eff of chain || []) {
125
+ for (const [k, v] of Object.entries(eff.uniforms || {})) out[k] = v;
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * 链的**结构**签名:只含"效果 id + 表达式",**不含参数值**。
132
+ * 参数变化只改 uniform;签名一变才允许重建着色器/滤镜实例(热更新的判据)。
133
+ * @param {Array<{id:string, fragment:string}>} chain
134
+ * @returns {string}
135
+ */
136
+ export function chainSignature(chain) {
137
+ return (chain || []).map((e) => `${e.id}:${e.fragment}`).join('|');
138
+ }