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.
- package/LICENSE +21 -0
- package/README.md +267 -2
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/bin/modelstage.mjs +101 -0
- package/config/diag-upload.example.json +7 -0
- package/config/models/live2d.assetbundls.json +1 -0
- package/config/models/live2d.azurlane.json +1 -0
- package/config/models/live2d.azurlane_bundle.json +1 -0
- package/config/models/live2d.azurlane_spine.json +1 -0
- package/config/models/live2d.bd2.json +1 -0
- package/config/models/live2d.bluearchive.json +1 -0
- package/config/models/live2d.live2d.json +1 -0
- package/config/models/live2d.nikke.json +1 -0
- package/config/models/live2d.nikke_2.json +1 -0
- package/config/models/live2d.npc.json +1 -0
- package/config/models/spine.assetbundls.json +1 -0
- package/config/models/spine.azurlane.json +1 -0
- package/config/models/spine.azurlane_bundle.json +1 -0
- package/config/models/spine.azurlane_spine.json +1 -0
- package/config/models/spine.bd2.json +1 -0
- package/config/models/spine.bluearchive.json +1 -0
- package/config/models/spine.live2d.json +1 -0
- package/config/models/spine.nikke.json +1 -0
- package/config/models/spine.nikke_2.json +1 -0
- package/config/models/spine.npc.json +1 -0
- package/config/names/NIKKE.json +1058 -0
- package/config/names/README.md +47 -0
- package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
- package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
- package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
- package/config/plugins.json +13 -0
- package/config/resources.example.json +40 -0
- package/package.json +58 -4
- package/plugins/unity-live2d-bundle/README.md +98 -0
- package/plugins/unity-live2d-bundle/index.js +891 -0
- package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
- package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
- package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
- package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
- package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
- package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
- package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
- package/plugins/unity-live2d-bundle/lib/png.js +424 -0
- package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
- package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
- package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-live2d-bundle/plugin.json +81 -0
- package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
- package/plugins/unity-spine-bundle/index.js +535 -0
- package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
- package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
- package/plugins/unity-spine-bundle/lib/errors.js +79 -0
- package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
- package/plugins/unity-spine-bundle/lib/extract.js +242 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-spine-bundle/lib/png.js +424 -0
- package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
- package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
- package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-spine-bundle/plugin.json +75 -0
- package/src/server/README.md +69 -0
- package/src/server/assets/browse.mjs +56 -0
- package/src/server/assets/bundle-probe.mjs +94 -0
- package/src/server/assets/probe.mjs +184 -0
- package/src/server/core/config.mjs +173 -0
- package/src/server/core/env-num.mjs +60 -0
- package/src/server/core/env.mjs +49 -0
- package/src/server/core/fs-portable.mjs +37 -0
- package/src/server/core/log.mjs +107 -0
- package/src/server/core/paths.mjs +44 -0
- package/src/server/data/catalog.mjs +1091 -0
- package/src/server/data/entry-states.mjs +499 -0
- package/src/server/data/live2d.mjs +272 -0
- package/src/server/data/manifest-cache.mjs +58 -0
- package/src/server/data/manifest-stamp.mjs +135 -0
- package/src/server/data/model-meta.mjs +114 -0
- package/src/server/data/names.mjs +106 -0
- package/src/server/data/shape-probe-worker.mjs +33 -0
- package/src/server/data/shape-probe.mjs +626 -0
- package/src/server/data/spine.mjs +336 -0
- package/src/server/data/tree.mjs +126 -0
- package/src/server/http/atomic-json.mjs +145 -0
- package/src/server/http/background-solids.mjs +270 -0
- package/src/server/http/backgrounds.mjs +388 -0
- package/src/server/http/diag-upload.mjs +264 -0
- package/src/server/http/diag.mjs +173 -0
- package/src/server/http/ready.mjs +167 -0
- package/src/server/http/static.mjs +312 -0
- package/src/server/http/write.mjs +647 -0
- package/src/server/index.mjs +114 -0
- package/src/server/plugins/builtin-providers.json +30 -0
- package/src/server/plugins/registry.mjs +650 -0
- package/src/server/plugins/slot-catalog.mjs +286 -0
- package/src/server/routes/background-solids.mjs +56 -0
- package/src/server/routes/backgrounds.mjs +77 -0
- package/src/server/routes/config.mjs +38 -0
- package/src/server/routes/data.mjs +313 -0
- package/src/server/routes/diag.mjs +37 -0
- package/src/server/routes/entries.mjs +170 -0
- package/src/server/routes/ready.mjs +122 -0
- package/src/server/serve.mjs +334 -0
- package/src/server/unpack/capacity.mjs +217 -0
- package/src/server/unpack/mem-probe.mjs +27 -0
- package/src/server/unpack/plugin-runner.mjs +529 -0
- package/src/server/unpack/plugin-store.mjs +736 -0
- package/src/server/unpack/probe-store.mjs +73 -0
- package/src/server/unpack/routes.mjs +359 -0
- package/src/server/unpack/runtime/exec.mjs +558 -0
- package/src/server/unpack/runtime/index.mjs +141 -0
- package/src/server/unpack/runtime/node-vm.mjs +111 -0
- package/src/server/unpack/unpack-worker.mjs +105 -0
- package/src/server/unpack/worker-client.mjs +184 -0
- package/src/server/unpack/write-result.mjs +84 -0
- package/src/shared/bundle-shape.mjs +83 -0
- package/src/shared/entry-reasons.mjs +119 -0
- package/src/shared/names.mjs +45 -0
- package/src/web/README.md +193 -0
- package/src/web/app/anim-list.js +222 -0
- package/src/web/app/background-solids.js +150 -0
- package/src/web/app/backgrounds.js +152 -0
- package/src/web/app/camera-log.js +28 -0
- package/src/web/app/context.js +72 -0
- package/src/web/app/dir-picker.js +242 -0
- package/src/web/app/entry-report.js +71 -0
- package/src/web/app/memory-bars.js +91 -0
- package/src/web/app/memory-budget.js +228 -0
- package/src/web/app/memory-guard.js +204 -0
- package/src/web/app/model-actions.js +682 -0
- package/src/web/app/renderer-setting.js +70 -0
- package/src/web/app/root-manager.js +254 -0
- package/src/web/app/status.js +138 -0
- package/src/web/app/topbar-actions.js +357 -0
- package/src/web/app/transport-actions.js +297 -0
- package/src/web/app/ui-state.js +559 -0
- package/src/web/app/url-params.js +171 -0
- package/src/web/app/view-settings.js +186 -0
- package/src/web/components.css +465 -0
- package/src/web/debug/diag-hud.js +95 -0
- package/src/web/debug/drag.js +369 -0
- package/src/web/debug/log.js +320 -0
- package/src/web/debug/model-cmp.js +292 -0
- package/src/web/debug/panel.css +474 -0
- package/src/web/debug/panel.js +712 -0
- package/src/web/debug/race-probe.js +82 -0
- package/src/web/debug/upload.js +130 -0
- package/src/web/engine/adapters/atlas.js +47 -0
- package/src/web/engine/adapters/index.js +22 -0
- package/src/web/engine/adapters/renderer.js +223 -0
- package/src/web/engine/adapters/skeleton.js +105 -0
- package/src/web/engine/adapters/texture.js +64 -0
- package/src/web/engine/animation.js +181 -0
- package/src/web/engine/appearance.js +136 -0
- package/src/web/engine/camera-adapter.js +212 -0
- package/src/web/engine/effect-chain.js +138 -0
- package/src/web/engine/engine-state.js +120 -0
- package/src/web/engine/filters.js +224 -0
- package/src/web/engine/fit.js +197 -0
- package/src/web/engine/frame-gate.js +33 -0
- package/src/web/engine/gl-driver.js +56 -0
- package/src/web/engine/gl.js +77 -0
- package/src/web/engine/index.js +20 -0
- package/src/web/engine/loader.js +55 -0
- package/src/web/engine/model-info.js +26 -0
- package/src/web/engine/pipeline.js +200 -0
- package/src/web/engine/player.js +427 -0
- package/src/web/engine/pma/blend.js +76 -0
- package/src/web/engine/pma/detect.js +118 -0
- package/src/web/engine/pma/index.js +8 -0
- package/src/web/engine/pma/resolve.js +112 -0
- package/src/web/engine/pma/upload.js +127 -0
- package/src/web/engine/pma.js +5 -0
- package/src/web/engine/render-frame.js +93 -0
- package/src/web/engine/runtime-capabilities.js +209 -0
- package/src/web/engine/runtime.js +37 -0
- package/src/web/engine/skins.js +165 -0
- package/src/web/engine/textures.js +174 -0
- package/src/web/index.html +115 -0
- package/src/web/infra/api.js +531 -0
- package/src/web/infra/diag.js +509 -0
- package/src/web/infra/document.js +269 -0
- package/src/web/infra/dom.js +31 -0
- package/src/web/infra/events.mjs +27 -0
- package/src/web/infra/log.js +153 -0
- package/src/web/infra/mem-stats.js +112 -0
- package/src/web/infra/plugin-host-registration.mjs +285 -0
- package/src/web/infra/plugin-host-report.mjs +32 -0
- package/src/web/infra/plugin-host-unload.mjs +79 -0
- package/src/web/infra/plugin-host-unpack.mjs +899 -0
- package/src/web/infra/plugin-host-warmup.mjs +180 -0
- package/src/web/infra/plugin-host.js +628 -0
- package/src/web/infra/ready.js +461 -0
- package/src/web/infra/schedule.js +51 -0
- package/src/web/infra/shortcuts.js +129 -0
- package/src/web/infra/slot-registry.js +402 -0
- package/src/web/infra/vfs.js +247 -0
- package/src/web/live2d/bridge.js +400 -0
- package/src/web/live2d/model.js +321 -0
- package/src/web/live2d/overlay.js +142 -0
- package/src/web/live2d/pixi-app.js +111 -0
- package/src/web/live2d/playback-probe.js +92 -0
- package/src/web/live2d/probe-geometry.js +329 -0
- package/src/web/live2d/probe.js +119 -0
- package/src/web/live2d/release.js +66 -0
- package/src/web/live2d/runtime-adapter.js +289 -0
- package/src/web/live2d/stage-ctx.js +152 -0
- package/src/web/live2d/stage.js +204 -0
- package/src/web/live2d/vfs-settings.js +174 -0
- package/src/web/live2d/view.js +89 -0
- package/src/web/main.js +629 -0
- package/src/web/panel/background-list.js +373 -0
- package/src/web/panel/bg-source.js +291 -0
- package/src/web/panel/color-picker.css +199 -0
- package/src/web/panel/color-picker.js +278 -0
- package/src/web/panel/dialog.css +116 -0
- package/src/web/panel/dialog.js +173 -0
- package/src/web/panel/dom.js +35 -0
- package/src/web/panel/icons.js +58 -0
- package/src/web/panel/memory-bar.js +195 -0
- package/src/web/panel/panel-settings.js +1263 -0
- package/src/web/panel/panel.css +1304 -0
- package/src/web/panel/render-choice.js +104 -0
- package/src/web/panel/schema.js +784 -0
- package/src/web/panel/settings-io.js +291 -0
- package/src/web/panel/shell-controls.js +274 -0
- package/src/web/panel/slider.js +210 -0
- package/src/web/panel/toolbar-schema.js +111 -0
- package/src/web/panel/toolbar.js +195 -0
- package/src/web/panel/transport-picker.js +375 -0
- package/src/web/panel/transport.js +321 -0
- package/src/web/panel/view.js +553 -0
- package/src/web/plugin/unpack-worker.js +185 -0
- package/src/web/render/camera/constants.js +60 -0
- package/src/web/render/camera/index.js +123 -0
- package/src/web/render/camera/spine.js +124 -0
- package/src/web/render/capture.js +30 -0
- package/src/web/render/interaction.js +527 -0
- package/src/web/render/live2d.js +627 -0
- package/src/web/render/playback.js +47 -0
- package/src/web/render/reset-view.js +29 -0
- package/src/web/render/spine.js +386 -0
- package/src/web/render/surface-switch.js +176 -0
- package/src/web/render/surface.js +571 -0
- package/src/web/render/surfaces.js +70 -0
- package/src/web/roster/filter.js +31 -0
- package/src/web/roster/list.js +276 -0
- package/src/web/roster/persist.js +52 -0
- package/src/web/roster/reveal.js +77 -0
- package/src/web/roster/rows.js +248 -0
- package/src/web/roster/snapshot.js +37 -0
- package/src/web/roster/view.js +670 -0
- package/src/web/spine/canvas-api-groups.js +267 -0
- package/src/web/spine/canvas-api.js +97 -0
- package/src/web/spine/canvas-document.js +80 -0
- package/src/web/spine/canvas-engine.js +193 -0
- package/src/web/spine/canvas-entry.js +28 -0
- package/src/web/spine/canvas-pixels.js +36 -0
- package/src/web/spine/canvas-runtime.js +127 -0
- package/src/web/spine/canvas-view.js +108 -0
- package/src/web/spine/canvas.js +7 -0
- package/src/web/spine/runtime-hint.js +73 -0
- package/src/web/spine/runtimes.js +212 -0
- package/src/web/style.css +493 -0
- package/tool/lib/bundle-probe-node.mjs +121 -0
- package/tool/lib/check-perms.mjs +114 -0
- package/tool/lib/contract-checks.mjs +582 -0
- package/tool/lib/cubism-core.mjs +122 -0
- package/tool/lib/export-bundle.mjs +328 -0
- package/tool/lib/paths.mjs +20 -0
- package/tool/lib/runtime-guides.mjs +65 -0
- package/tool/lib/runtime-package.mjs +403 -0
- package/tool/lib/spine-meta.mjs +227 -0
- package/tool/lib/spine-motions.mjs +104 -0
- package/tool/lib/spine-runtime.mjs +131 -0
- package/tool/lib/test-lock.mjs +178 -0
- package/tool/lib/test-report.mjs +133 -0
- package/tool/parse-jsonc.mjs +86 -0
- package/tool/resolve-live2d-runtimes.mjs +276 -0
- package/tool/spine-motion-rules.json +101 -0
package/src/web/main.js
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
// main.js —— ModelStage 本实现入口(M4)
|
|
2
|
+
//
|
|
3
|
+
// 做什么:
|
|
4
|
+
// 1. 装配各区域 DOM 引用并向 window.__WEB__ 暴露(自动化断言与手工排查都读它);
|
|
5
|
+
// 2. 拉 GET /api/brand 应用品牌(标题 + 图标),未配置时回落到默认品牌名;
|
|
6
|
+
// 3. 接上顶栏「模型库」按钮 = 左栏显示/隐藏切换;
|
|
7
|
+
// 4. 拉 /api/roots 后把名册三级树挂到左栏(roster-view.js,首次展开某游戏才请求该游戏模型);
|
|
8
|
+
// 失败时在左栏显示红色横幅,不抛到控制台;
|
|
9
|
+
// 5. 挂右侧控制面板与底部播放条的骨架(panel.js / transport.js)——
|
|
10
|
+
// M3 只做控件齐全、位置就位,组内控件一律 disabled + data-pending(M6 接线)。
|
|
11
|
+
// 为什么把状态放在 window.__WEB__.state:M7 要做持久化,需要一个显式 store;
|
|
12
|
+
// 现在先立最小形态(不引框架),后续里程碑往里加字段,避免各处散落全局变量。
|
|
13
|
+
|
|
14
|
+
// 诊断探针必须最先装:后面的模块/渲染出问题时才有取证(API 名称保持稳定,测试脚本依赖它)
|
|
15
|
+
import { installDiag } from './infra/diag.js';
|
|
16
|
+
import { markStage, installReadyApi, trackElementStage, trackElementWhenReady } from './infra/ready.js';
|
|
17
|
+
import { installMemStats, memStats } from './infra/mem-stats.js';
|
|
18
|
+
import { registerShortcuts } from './infra/shortcuts.js';
|
|
19
|
+
import { getJson, formatError } from './infra/api.js';
|
|
20
|
+
import { getSharedVirtualFS } from './infra/vfs.js';
|
|
21
|
+
import { createPluginHost } from './infra/plugin-host.js';
|
|
22
|
+
import { buildAnimationList } from './app/anim-list.js';
|
|
23
|
+
import { mountTransport } from './panel/transport.js';
|
|
24
|
+
import { createSpineStage } from './spine/canvas.js';
|
|
25
|
+
import { createStageSurface } from './render/surface.js';
|
|
26
|
+
import { createSpineRenderer } from './render/spine.js';
|
|
27
|
+
import { createLive2DRenderer } from './render/live2d.js';
|
|
28
|
+
import { createLive2DStage } from './live2d/stage.js';
|
|
29
|
+
import { attachStageInteraction } from './render/interaction.js';
|
|
30
|
+
import { dom, setStage, stage, state } from './app/context.js';
|
|
31
|
+
import { diagEvent } from './app/status.js';
|
|
32
|
+
import { stageLog, stageStatus } from './app/status.js';
|
|
33
|
+
import * as uiState from './app/ui-state.js';
|
|
34
|
+
import { VIEW_SETTINGS_KEY, applyBackground, applyTheme } from './app/view-settings.js';
|
|
35
|
+
import {
|
|
36
|
+
WIRED_IDS, panelSnapshot, rebuildPanelFor, restorePanelSettings,
|
|
37
|
+
runPanelSelfCheck,
|
|
38
|
+
} from './panel/panel-settings.js';
|
|
39
|
+
import { loadRoots } from './app/model-actions.js';
|
|
40
|
+
// URL 参数(?model= / ?runtime=):只作用于"这一次装载",不落任何持久化位置
|
|
41
|
+
import { applyUrlParams } from './app/url-params.js';
|
|
42
|
+
import { installCameraLogger } from './app/camera-log.js';
|
|
43
|
+
import { recent as recentLogs } from './infra/log.js';
|
|
44
|
+
import { resetViewViaRenderer } from './render/reset-view.js';
|
|
45
|
+
import { applyTopbarIcons } from './panel/icons.js';
|
|
46
|
+
import {
|
|
47
|
+
bindCinemaKeys, bindSidebarStateSync, exitCinemaIfActive, exportScreenshot, retrackStage, syncSidebarButtons, toggleCinema, toggleRightPanel,
|
|
48
|
+
} from './app/topbar-actions.js';
|
|
49
|
+
import {
|
|
50
|
+
applyTransportMotion, stepUnit, toggleTransportPlay,
|
|
51
|
+
} from './app/transport-actions.js';
|
|
52
|
+
|
|
53
|
+
// 页面级第一件事:装诊断探针(幂等,重复调用不会重复包装 console)
|
|
54
|
+
const diag = installDiag();
|
|
55
|
+
|
|
56
|
+
/** 服务端没配置品牌时的默认品牌名(唯一默认值,别处不重复写) */
|
|
57
|
+
const DEFAULT_BRAND = 'ModelStage';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 应用品牌:标题与图标一律来自 GET /api/brand(服务端读项目配置下发),不在前端写死。
|
|
61
|
+
* 服务端未配置时返回 null,此时回落到页面内置的默认品牌名。
|
|
62
|
+
* @param {{title: string|null, icon: string|null, iconKind: 'text'|'image'|null}} brand
|
|
63
|
+
*/
|
|
64
|
+
function applyBrand(brand) {
|
|
65
|
+
const title = brand && brand.title ? brand.title : DEFAULT_BRAND;
|
|
66
|
+
dom.brandTitle.textContent = title;
|
|
67
|
+
document.title = title;
|
|
68
|
+
|
|
69
|
+
const icon = brand && brand.icon ? brand.icon : '';
|
|
70
|
+
if (icon && brand.iconKind === 'image') {
|
|
71
|
+
// 图片形态(站内绝对路径 / http(s) / data:):换成 <img>,替代内置 SVG。
|
|
72
|
+
// 只替换容器内容、不动容器本身:id/类名保持稳定,后续换品牌不会把 DOM 引用弄丢。
|
|
73
|
+
const img = document.createElement('img');
|
|
74
|
+
img.alt = '';
|
|
75
|
+
img.src = icon;
|
|
76
|
+
dom.brandIcon.replaceChildren(img);
|
|
77
|
+
dom.brandIcon.classList.add('is-image');
|
|
78
|
+
} else if (icon) {
|
|
79
|
+
// 文字形态(emoji 等):直接当文本渲染
|
|
80
|
+
dom.brandIcon.classList.remove('is-image');
|
|
81
|
+
dom.brandIcon.replaceChildren(document.createTextNode(icon));
|
|
82
|
+
}
|
|
83
|
+
// 两者都没有:保留 index.html 里的内置 SVG 图标与默认标题
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 拉品牌配置;失败只回落到默认值,不打断首屏(品牌不是关键路径) */
|
|
87
|
+
async function loadBrand() {
|
|
88
|
+
try {
|
|
89
|
+
const brand = await getJson('/api/brand');
|
|
90
|
+
applyBrand(brand);
|
|
91
|
+
} catch {
|
|
92
|
+
applyBrand(null);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 读"启动自动恢复上次模型"开关:GET /api/web-config。
|
|
98
|
+
* 语义:显式 false 才关闭;true 与 null(未配置)都按恢复处理。
|
|
99
|
+
* 请求失败也按"恢复"处理——配置不是关键路径,不能因为它读不到就白屏或改变既有行为。
|
|
100
|
+
* @returns {Promise<boolean>}
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 面板已接线控件的生效表:key = 控件 id,value = 把该控件的机器值应用到舞台。
|
|
105
|
+
* 未列出的控件在面板里仍是 disabled + data-pending,不会被回调到这里。
|
|
106
|
+
*/
|
|
107
|
+
/**
|
|
108
|
+
* 切换当前渲染器(预埋):先让旧渲染器 unmount,再换新。
|
|
109
|
+
* 现阶段只有 2D 一个实现,这里不会被外部调用;将来 3D 渲染器接入时复用同一入口。
|
|
110
|
+
* @param {object} next 新渲染器(需带 capabilities / camera / mount / unmount / dispose)
|
|
111
|
+
*/
|
|
112
|
+
function setRenderer(next) {
|
|
113
|
+
if (state.currentRenderer && state.currentRenderer !== next) {
|
|
114
|
+
try { state.currentRenderer.unmount(); } catch { /* 旧渲染器卸载失败不影响切换 */ }
|
|
115
|
+
}
|
|
116
|
+
state.currentRenderer = next;
|
|
117
|
+
document.documentElement.dataset.renderer = next && next.id ? next.id : 'none';
|
|
118
|
+
return next;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 用当前舞台实例建立 2D 渲染器并设为当前。
|
|
123
|
+
* 舞台不可用时保持 null,调用方靠 `state.currentRenderer?.camera` 做空值保护。
|
|
124
|
+
*/
|
|
125
|
+
let live2dStage = null;
|
|
126
|
+
|
|
127
|
+
/** 建 Live2D 舞台(幂等):覆盖层挂到 #stage 内,日志统一走 stageStatus */
|
|
128
|
+
function ensureLive2DStage() {
|
|
129
|
+
if (live2dStage) return live2dStage;
|
|
130
|
+
live2dStage = createLive2DStage({
|
|
131
|
+
// 资源位置解析的唯一入口(VirtualFS 契约 §四):先问 vfs,命中用虚拟地址,未命中回落原 URL。
|
|
132
|
+
// 必须是**同一个共享实例** —— 注册方(插件/验收脚本经 window.__VFS__)与消费方各建一个的话,
|
|
133
|
+
// register 完也解析不到,表现为"接了但没生效"(这一行之前漏了,等于 Live2D 从未消费过 VFS)。
|
|
134
|
+
vfs: getSharedVirtualFS(),
|
|
135
|
+
getOverlayHost: () => document.getElementById('stage'),
|
|
136
|
+
// 引擎是懒创建的:每次加载时动态取当前实例(MSAA 重建后实例会换)
|
|
137
|
+
getEngine: () => { try { return stage && stage.debug() ? stage.debug().engine : null; } catch { return null; } },
|
|
138
|
+
log: (message, level) => stageStatus(message, level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info'),
|
|
139
|
+
});
|
|
140
|
+
window.__WEB__ && (window.__WEB__.live2d = live2dStage);
|
|
141
|
+
return live2dStage;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function initRenderer() {
|
|
145
|
+
// 渲染面:画布/尺寸/DPR/可见性/帧调度都由 StageSurface 拥有;渲染器只注册进来
|
|
146
|
+
if (state.surface) return state.currentRenderer;
|
|
147
|
+
const surface = createStageSurface({ log: (message, level) => stageStatus(message, level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info') });
|
|
148
|
+
surface.register(createSpineRenderer({ stage, onMsaa: applyMsaa }));
|
|
149
|
+
surface.register(createLive2DRenderer({ live2d: ensureLive2DStage() }));
|
|
150
|
+
// 就绪信号 renderer 阶段(契约 §三):两面都注册完 = 已实例化;
|
|
151
|
+
// 能力声明可读 = settled;能拿到 activeRenderer 且可下发设置 = interactive
|
|
152
|
+
markStage('renderer', 'mounted', { faces: 2 });
|
|
153
|
+
const twoFrames = (fn) => requestAnimationFrame(() => requestAnimationFrame(fn));
|
|
154
|
+
twoFrames(() => {
|
|
155
|
+
const caps = (() => { try { return surface.capabilities ? surface.capabilities() : null; } catch { return null; } })();
|
|
156
|
+
if (caps) markStage('renderer', 'settled', { settings: (caps.settings || []).length, camera: caps.camera || null });
|
|
157
|
+
const active = (() => { try { return surface.activeRenderer ? surface.activeRenderer() : null; } catch { return null; } })();
|
|
158
|
+
if (active && typeof active.setSetting === 'function') {
|
|
159
|
+
markStage('renderer', 'interactive', { activeId: active.id || null });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
state.surface = surface;
|
|
163
|
+
// 就绪信号:三个 DOM 阶段(契约 §2.1 三级;settled = 动画/布局稳定后才报)
|
|
164
|
+
trackElementWhenReady({ name: 'topbar', selector: '#app-header' });
|
|
165
|
+
trackElementWhenReady({ name: 'sidebarLeft', selector: '#left-panel' });
|
|
166
|
+
// 调试浮层由面板模块异步挂载(.dbg-panel),出现后再开始三级跟踪
|
|
167
|
+
trackElementWhenReady({ name: 'debugPanel', selector: '.dbg-panel', timeoutMs: 15000 });
|
|
168
|
+
trackElementWhenReady({ name: 'sidebarRight', selector: '#right-panel' });
|
|
169
|
+
// 底栏:现在是**自绘 picker**(button[aria-expanded] + ul[role=listbox]),不是原生 select。
|
|
170
|
+
// interactive 额外要求:触发按钮存在、aria-expanded=false(收起态=可用且没被展开卡住)、
|
|
171
|
+
// 且**不存在** select.transport-select(存在即说明退回旧实现)。
|
|
172
|
+
if (dom.footer) {
|
|
173
|
+
trackElementStage({
|
|
174
|
+
name: 'bottombar',
|
|
175
|
+
el: dom.footer,
|
|
176
|
+
interactiveProbe: () => {
|
|
177
|
+
const btn = dom.footer.querySelector('.transport-picker .transport-picker-btn');
|
|
178
|
+
const nativeSelect = dom.footer.querySelector('select.transport-select');
|
|
179
|
+
const expanded = btn ? btn.getAttribute('aria-expanded') : null;
|
|
180
|
+
return {
|
|
181
|
+
ok: !!btn && expanded === 'false' && !nativeSelect,
|
|
182
|
+
detail: { pickerBtn: !!btn, expanded, nativeSelect: !!nativeSelect },
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
window.__WEB__ && (window.__WEB__.surface = surface);
|
|
188
|
+
// 就绪信号(契约 §5.1):__WEB__.ready() 全量、__WEB__.ready('model') 单阶段
|
|
189
|
+
installReadyApi(window.__WEB__);
|
|
190
|
+
// 资源/上下文计数(只读观测,给"泄漏是否累积"提供数字;必须在渲染器创建上下文前安装)
|
|
191
|
+
installMemStats();
|
|
192
|
+
window.__WEB__.memStats = () => memStats();
|
|
193
|
+
// PMA 诊断读取口(只读):把"当前档位 / 生效判定 / 最近 pma.* 日志"一次取回,
|
|
194
|
+
// 便于在浏览器控制台或自动化探针里核对"auto 到底判了什么",不必翻面板。
|
|
195
|
+
window.__WEB__.pmaDebug = () => {
|
|
196
|
+
try {
|
|
197
|
+
// 引擎实例从 stage 侧取(surface.debug() 不一定有);两条路都试,取到即用
|
|
198
|
+
let eng = null;
|
|
199
|
+
try { eng = state.surface && state.surface.debug ? (state.surface.debug().engine || null) : null; } catch { /* 忽略 */ }
|
|
200
|
+
if (!eng) { try { eng = window.__WEB__.stage.debug().engine; } catch { /* 忽略 */ } }
|
|
201
|
+
const logs = recentLogs().filter((r) => {
|
|
202
|
+
const d = r && r.detail;
|
|
203
|
+
return (d && d.event && String(d.event).startsWith('pma.')) || String(r.tag || '') === 'pma';
|
|
204
|
+
}).slice(-10);
|
|
205
|
+
return {
|
|
206
|
+
mode: eng ? eng.pmaMode : null,
|
|
207
|
+
pmaInfo: eng ? eng.pmaInfo : null,
|
|
208
|
+
logs,
|
|
209
|
+
};
|
|
210
|
+
} catch (e) { return { error: String(e && e.message) }; }
|
|
211
|
+
};
|
|
212
|
+
// 只读断言入口(summary/results/disable/enable/run)
|
|
213
|
+
// 兼容层:对外仍是一个 renderer(camera 按当前面取,mount 走 Surface 的唯一入口)
|
|
214
|
+
const facade = {
|
|
215
|
+
id: 'renderer-2d',
|
|
216
|
+
// 能力一律取当前激活渲染器的声明(不再硬编码兜底,否则"默认全支持"的缺陷会静默存在),
|
|
217
|
+
// 并追加**已注册的插件效果 id**(契约 §9.3 的能力键白名单)。
|
|
218
|
+
// 为什么挂在这一处:白名单有两个消费方 —— ①面板 applyCapabilities(控件显不显示)
|
|
219
|
+
// ②app/renderer-setting.js:rendererSupports(这次设置能不能下发)。
|
|
220
|
+
// 只在①处追加会让②判"不支持"⇒ 值存了却推不到渲染器,就是"选了没反应"。
|
|
221
|
+
get capabilities() {
|
|
222
|
+
const caps = surface.capabilities ? surface.capabilities() : null;
|
|
223
|
+
if (!caps || !Array.isArray(caps.settings)) return caps;
|
|
224
|
+
const host = state.pluginHost;
|
|
225
|
+
const extra = host && typeof host.registeredEffectIds === 'function' ? host.registeredEffectIds() : [];
|
|
226
|
+
if (!extra.length) return caps;
|
|
227
|
+
// 只在返回值里追加:渲染器自己声明的原生能力保持原样,不被插件污染
|
|
228
|
+
const merged = caps.settings.slice();
|
|
229
|
+
for (const id of extra) if (!merged.includes(id)) merged.push(id);
|
|
230
|
+
return { ...caps, settings: merged };
|
|
231
|
+
},
|
|
232
|
+
setSetting: (key, value) => {
|
|
233
|
+
const r = surface.activeRenderer ? surface.activeRenderer() : null;
|
|
234
|
+
if (r && typeof r.setSetting === 'function') {
|
|
235
|
+
try { if (r.setSetting(key, value) === true) return true; } catch { /* 落到下面判断 */ }
|
|
236
|
+
}
|
|
237
|
+
// 插件效果键:渲染器不实现这个键(真正作用于像素是效果链的事),
|
|
238
|
+
// 但值已由调用方存进设置工厂 ⇒ 对自检/调用方算"已接住"。
|
|
239
|
+
// 不这么做的话,自检按 capabilities 逐键试 setSetting 会一直报"声明支持但未实现"。
|
|
240
|
+
const host = state.pluginHost;
|
|
241
|
+
return !!(host && typeof host.registeredEffectIds === 'function'
|
|
242
|
+
&& host.registeredEffectIds().includes(String(key)));
|
|
243
|
+
},
|
|
244
|
+
getSetting: (key) => {
|
|
245
|
+
const r = surface.activeRenderer ? surface.activeRenderer() : null;
|
|
246
|
+
let got = null;
|
|
247
|
+
try { got = r && r.getSetting ? r.getSetting(key) : null; } catch { got = null; }
|
|
248
|
+
if (got !== null && got !== undefined) return got;
|
|
249
|
+
// 插件效果键:渲染器只认 uniform、不持有"档位文本"的语义 ⇒ 回读走设置工厂
|
|
250
|
+
// (与面板同一个来源:uiState 才是这档值的真相,别在渲染器里另存一份)
|
|
251
|
+
const host = state.pluginHost;
|
|
252
|
+
const isEffect = host && typeof host.registeredEffectIds === 'function'
|
|
253
|
+
&& host.registeredEffectIds().includes(String(key));
|
|
254
|
+
if (!isEffect) return got;
|
|
255
|
+
try { return uiState.getValue(key); } catch { return got; }
|
|
256
|
+
},
|
|
257
|
+
/**
|
|
258
|
+
* 插件效果链:门面必须转发,否则效果永远到不了真实渲染器
|
|
259
|
+
* (与 setSetting 同一种静默失效 —— 方法存在但打不到实现上)。
|
|
260
|
+
*/
|
|
261
|
+
setEffectChain: (chain) => {
|
|
262
|
+
const r = surface.activeRenderer ? surface.activeRenderer() : null;
|
|
263
|
+
if (!r || typeof r.setEffectChain !== 'function') return false;
|
|
264
|
+
try { return r.setEffectChain(chain) === true; } catch { return false; }
|
|
265
|
+
},
|
|
266
|
+
/** 当前生效链的结构签名:空串 = 没有链(用于"切档位不该改签名"的断言) */
|
|
267
|
+
effectChainSignature: () => {
|
|
268
|
+
const r = surface.activeRenderer ? surface.activeRenderer() : null;
|
|
269
|
+
try { return r && typeof r.effectChainSignature === 'function' ? r.effectChainSignature() : ''; } catch { return ''; }
|
|
270
|
+
},
|
|
271
|
+
get camera() { return surface.activeCamera() || stage.camera; },
|
|
272
|
+
mount(document) { return surface.activate(document); },
|
|
273
|
+
unmount() { return surface.disposeActive(); },
|
|
274
|
+
dispose() { return surface.dispose(); },
|
|
275
|
+
stageMode() { return surface.stageMode(); },
|
|
276
|
+
};
|
|
277
|
+
return setRenderer(facade);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 当前交互层解绑函数(重建画布或测试清理时调用;null 表示未挂) */
|
|
281
|
+
let detachInteraction = null;
|
|
282
|
+
|
|
283
|
+
function cameraProxy() {
|
|
284
|
+
const pick = () => (state.currentRenderer && state.currentRenderer.camera)
|
|
285
|
+
|| (stage && stage.camera) || null;
|
|
286
|
+
const forward = (name) => (...args) => {
|
|
287
|
+
const cam = pick();
|
|
288
|
+
return cam && typeof cam[name] === 'function' ? cam[name](...args) : null;
|
|
289
|
+
};
|
|
290
|
+
return {
|
|
291
|
+
zoomStep: forward('zoomStep'),
|
|
292
|
+
zoomTo: forward('zoomTo'),
|
|
293
|
+
panBy: forward('panBy'),
|
|
294
|
+
reset: forward('reset'),
|
|
295
|
+
scale: forward('scale'),
|
|
296
|
+
note: forward('note'),
|
|
297
|
+
setLogger: forward('setLogger'),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** 交互层用的视图门面:camera 动态取,其余转发舞台(getView/setView/resetView 语义不变) */
|
|
302
|
+
function interactionStage() {
|
|
303
|
+
return {
|
|
304
|
+
camera: cameraProxy(),
|
|
305
|
+
getView: (...args) => (stage && stage.getView ? stage.getView(...args) : null),
|
|
306
|
+
setView: (...args) => (stage && stage.setView ? stage.setView(...args) : null),
|
|
307
|
+
resetView: (...args) => (stage && stage.resetView ? stage.resetView(...args) : null),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function attachInteraction() {
|
|
312
|
+
if (detachInteraction) {
|
|
313
|
+
try { detachInteraction(); } catch { /* 解绑失败继续挂新的 */ }
|
|
314
|
+
detachInteraction = null;
|
|
315
|
+
}
|
|
316
|
+
// 交互目标是「舞台区域」而不是某块画布:由 StageSurface 给出(画布可见性随渲染面切换,
|
|
317
|
+
// 绑定到具体画布会在切面后失去命中;#stage 与画布同尺寸同位置,坐标换算不受影响)
|
|
318
|
+
const target = (state.surface && state.surface.interactionTarget) || null;
|
|
319
|
+
if (!target || !stage) return null;
|
|
320
|
+
try {
|
|
321
|
+
detachInteraction = attachStageInteraction({ canvas: target, stage: interactionStage() });
|
|
322
|
+
} catch (err) {
|
|
323
|
+
state.stageError = formatError(err);
|
|
324
|
+
}
|
|
325
|
+
return detachInteraction;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
/** 切换抗锯齿:要换画布重建上下文,先提示"正在重建"避免用户以为卡住 */
|
|
330
|
+
async function applyMsaa(mode) {
|
|
331
|
+
if (!stage || typeof stage.rebuildWithAntialias !== 'function') return;
|
|
332
|
+
const on = mode === 'auto';
|
|
333
|
+
stageStatus('正在重建渲染上下文(切换抗锯齿)…', 'info');
|
|
334
|
+
try {
|
|
335
|
+
const r = await stage.rebuildWithAntialias(on);
|
|
336
|
+
stageStatus(`抗锯齿已${r.antialias ? '开启' : '关闭'} · 重建耗时 ${r.restoreMs} ms`, 'info');
|
|
337
|
+
document.documentElement.dataset.msaa = r.antialias ? 'on' : 'off';
|
|
338
|
+
// 顶栏里仍 disabled 的按钮(导入/截图/菜单/影院)是待接线项:运行期补标记,
|
|
339
|
+
// 让未接线=disabled+pending 的口径在顶栏也成立(index.html 不在本模块范围)
|
|
340
|
+
|
|
341
|
+
} catch (err) {
|
|
342
|
+
stageLog('error', `切换抗锯齿失败:${formatError(err)}`, { err: formatError(err) });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
/** 左栏显示/隐藏:位移 + 淡出(与顶栏「模型库」按钮共用同一套过渡) */
|
|
367
|
+
function toggleLeftPanel() {
|
|
368
|
+
state.leftPanelVisible = !state.leftPanelVisible;
|
|
369
|
+
dom.leftPanel.classList.toggle('is-hidden', !state.leftPanelVisible);
|
|
370
|
+
// S4:记下这次手动选择(右栏沿用当前值,互不覆盖)
|
|
371
|
+
uiState.setLayout({ leftVisible: state.leftPanelVisible });
|
|
372
|
+
// 左栏位移是一次过渡 ⇒ 它的就绪状态重新起算(只重置左栏;右栏/顶栏不受影响)
|
|
373
|
+
retrackStage('sidebarLeft');
|
|
374
|
+
syncSidebarButtons(); // 高亮只认"实际可见",不在这里单独写 aria
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* 应用左右栏显隐(S4 启动恢复用)。
|
|
379
|
+
*
|
|
380
|
+
* 只改"显隐真相"(class / data-right),高亮由 syncSidebarButtons 统一跟随 ——
|
|
381
|
+
* 这样收起左栏后刷新,顶栏「模型库」按钮的 aria-pressed 仍是 false(表示"点它能展开"),
|
|
382
|
+
* 用户不会因为"模型列表不见了、按钮还亮着"而困惑。
|
|
383
|
+
* @param {{leftVisible:boolean, rightVisible:boolean}} layout
|
|
384
|
+
* @returns {{leftVisible:boolean, rightVisible:boolean}} 实际应用的布局
|
|
385
|
+
*/
|
|
386
|
+
function applyLayout(layout) {
|
|
387
|
+
const l = layout || { leftVisible: true, rightVisible: true };
|
|
388
|
+
state.leftPanelVisible = l.leftVisible !== false;
|
|
389
|
+
if (dom.leftPanel) dom.leftPanel.classList.toggle('is-hidden', !state.leftPanelVisible);
|
|
390
|
+
document.documentElement.dataset.right = l.rightVisible === false ? 'off' : 'on';
|
|
391
|
+
return { leftVisible: state.leftPanelVisible, rightVisible: l.rightVisible !== false };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** 全屏切换(原生 API,无副作用;不需要等后续里程碑) */
|
|
395
|
+
async function toggleFullscreen() {
|
|
396
|
+
try {
|
|
397
|
+
if (document.fullscreenElement) {
|
|
398
|
+
await document.exitFullscreen();
|
|
399
|
+
} else {
|
|
400
|
+
await document.documentElement.requestFullscreen();
|
|
401
|
+
}
|
|
402
|
+
} catch {
|
|
403
|
+
// 浏览器策略拒绝(非用户手势、iframe 限制等):静默忽略,不污染控制台
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* 动作级翻页(快捷键 Z / X):在当前模型的动作清单里前后走一格。
|
|
412
|
+
*
|
|
413
|
+
* 与"变体/模型翻页"(A/S)是**两条互不干扰**的路径:这条只换动作,不换模型。
|
|
414
|
+
* @param {number} dir -1 上一个 / +1 下一个
|
|
415
|
+
*/
|
|
416
|
+
function stepMotion(dir) {
|
|
417
|
+
const list = buildAnimationList();
|
|
418
|
+
if (!list.length) return;
|
|
419
|
+
if (list.length < 2) { stageStatus('当前模型只有 1 个动作', 'info'); return; }
|
|
420
|
+
const curId = String(state.transportMotion || '');
|
|
421
|
+
const at = list.findIndex((a) => a.id === curId || a.label === curId);
|
|
422
|
+
const next = list[(Math.max(at, 0) + dir + list.length) % list.length];
|
|
423
|
+
if (!next) return;
|
|
424
|
+
applyTransportMotion(next.id);
|
|
425
|
+
// 底栏下拉是"当前动作"的唯一真相(契约 §七):动作换了必须同步它,否则两处显示分叉
|
|
426
|
+
const pk = state.transport && typeof state.transport.picker === 'function' ? state.transport.picker('transport-motion') : null;
|
|
427
|
+
if (pk && typeof pk.setValue === 'function') { try { pk.setValue(next.id); } catch { /* 下拉同步失败不影响播放 */ } }
|
|
428
|
+
stageStatus(`切换动作 → ${next.label || next.id}`, 'info');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* 注册全部快捷键(键位表在 infra/shortcuts.js,这里是"动作 → 实现"的唯一映射)。
|
|
433
|
+
*
|
|
434
|
+
* 影院模式语义照旧:H 是进/出切换,Esc 只负责退出(不会因为误按 Esc 反而进入影院)。
|
|
435
|
+
*/
|
|
436
|
+
function bindShortcuts() {
|
|
437
|
+
registerShortcuts({
|
|
438
|
+
togglePlay: () => toggleTransportPlay(),
|
|
439
|
+
prev: () => stepUnit(-1),
|
|
440
|
+
next: () => stepUnit(1),
|
|
441
|
+
prevMotion: () => stepMotion(-1),
|
|
442
|
+
nextMotion: () => stepMotion(1),
|
|
443
|
+
resetView: () => resetViewViaRenderer(state.currentRenderer),
|
|
444
|
+
screenshot: () => exportScreenshot(),
|
|
445
|
+
toggleCinema: () => toggleCinema(),
|
|
446
|
+
exitCinema: () => exitCinemaIfActive(),
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** 绑定事件(只给"有真实行为"的控件挂处理器;其余控件在 HTML 里 disabled) */
|
|
451
|
+
function bindEvents() {
|
|
452
|
+
applyTopbarIcons(); // 顶栏图标唯一来源:panel/icons.js
|
|
453
|
+
bindSidebarStateSync(); // 高亮与边栏真实显隐同步(含影院/收起/过渡)
|
|
454
|
+
bindShortcuts(); // 全局快捷键(唯一监听在 infra/shortcuts.js)
|
|
455
|
+
dom.btnLibrary.addEventListener('click', toggleLeftPanel);
|
|
456
|
+
dom.btnFullscreen.addEventListener('click', toggleFullscreen);
|
|
457
|
+
// 顶栏两个真动作:截图导出(canvas→blob→下载)与影院模式(切 data-cinema,显隐在 CSS)
|
|
458
|
+
if (dom.btnShot) dom.btnShot.addEventListener('click', () => exportScreenshot());
|
|
459
|
+
if (dom.btnCinema) dom.btnCinema.addEventListener('click', () => toggleCinema());
|
|
460
|
+
if (dom.btnMenu) dom.btnMenu.addEventListener('click', () => toggleRightPanel());
|
|
461
|
+
bindCinemaKeys();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** 插件宿主实例(boot 时创建;未就绪为 null) */
|
|
465
|
+
let pluginHost = null;
|
|
466
|
+
|
|
467
|
+
function boot() {
|
|
468
|
+
// 面板与播放条是纯静态骨架(不依赖网络):先挂上,保证首屏五区域都有内容
|
|
469
|
+
// 舞台渲染器:WebGL 不可用(无头精简环境等)时只提示,不让整页崩
|
|
470
|
+
try {
|
|
471
|
+
setStage(createSpineStage({ canvas: dom.stageCanvas }));
|
|
472
|
+
document.documentElement.dataset.stageContext = 'webgl';
|
|
473
|
+
} catch (err) {
|
|
474
|
+
state.stageError = formatError(err);
|
|
475
|
+
stageLog('error', `渲染器不可用:${state.stageError}`, { reason: String(state.stageError || '') });
|
|
476
|
+
document.documentElement.dataset.stageContext = 'none';
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// topbar-disabled-mark
|
|
480
|
+
for (const b of document.querySelectorAll('#app-header button:disabled')) b.dataset.pending = 'true';
|
|
481
|
+
|
|
482
|
+
initRenderer();
|
|
483
|
+
installCameraLogger(state.currentRenderer && state.currentRenderer.camera);
|
|
484
|
+
|
|
485
|
+
attachInteraction();
|
|
486
|
+
|
|
487
|
+
// 契约 R2:**读回早于首次渲染**。loadAll 是纯内存操作(不碰 DOM),一次性把工厂填满
|
|
488
|
+
// (有存值用存值、没存过用 schema 默认)⇒ S2 起不再需要"先回放默认值再读回"那套时序。
|
|
489
|
+
// MSAA 例外:重建渲染上下文要 2~5 秒,启动不重建;主题/背景各有专门落地通道。
|
|
490
|
+
const STARTUP_SKIP = new Set(['ctl-msaa', 'ctl-theme', 'ctl-bg-mode']);
|
|
491
|
+
uiState.setApplySkip(STARTUP_SKIP);
|
|
492
|
+
try {
|
|
493
|
+
const loaded = uiState.loadAll();
|
|
494
|
+
if (loaded.rejected.length) stageLog('warn', '面板设置里有非法值,已回落默认', { rejected: loaded.rejected });
|
|
495
|
+
} catch { /* 读回失败就按默认继续,不能挡住启动 */ }
|
|
496
|
+
// S4:左右栏显隐是**布局偏好**(不是模型属性,换面/换模型都不影响),按上次选择恢复。
|
|
497
|
+
// 首次访问(存储里没有 layout)⇒ 工厂返回全展开,绝不把"没有值"当成"用户选了收起"。
|
|
498
|
+
// 布局读回 + 落地;无论成败都要放行首帧可见性(否则 CSS 的"读回前隐藏"会把左右栏永久遮住)
|
|
499
|
+
// 启动期先关掉过渡(见 style.css 的 html.is-booting):
|
|
500
|
+
// 否则 applyLayout() 写下的隐藏终态会在"放行可见"的那次重算里被 0.2s 过渡真实画出来
|
|
501
|
+
// ⇒ 用户看到「先出现、再滑走」(这是修过一次又回归的闪烁)。
|
|
502
|
+
document.documentElement.classList.add('is-booting');
|
|
503
|
+
try {
|
|
504
|
+
applyLayout(uiState.getLayout());
|
|
505
|
+
} finally {
|
|
506
|
+
// 双 rAF:第一帧让上面的隐藏终态真正落地(样式计算 + 布局),第二帧才放开可见性并恢复过渡。
|
|
507
|
+
// 单 rAF 不够 —— rAF 回调在样式计算**之前**执行,会和 applyLayout 的改动合并成同一次重算,等于没分开。
|
|
508
|
+
// 放行只晚一帧,而这一帧面板仍被 visibility:hidden 遮住,用户看不到任何中间态。
|
|
509
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
510
|
+
document.documentElement.dataset.layoutReady = '1';
|
|
511
|
+
document.documentElement.classList.remove('is-booting');
|
|
512
|
+
}));
|
|
513
|
+
}
|
|
514
|
+
// 面板三态由当前渲染器能力决定;切换渲染器时必须重算 + 重跑自检(契约 INV-05)
|
|
515
|
+
rebuildPanelFor(state.currentRenderer);
|
|
516
|
+
if (state.surface && typeof state.surface.onRendererChange === 'function') {
|
|
517
|
+
state.surface.onRendererChange(() => {
|
|
518
|
+
rebuildPanelFor(state.currentRenderer);
|
|
519
|
+
installCameraLogger(state.currentRenderer && state.currentRenderer.camera); // 换面后相机入口换了
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const transportInfo = mountTransport(dom.footer, {
|
|
524
|
+
'transport-reset-view': () => resetViewViaRenderer(state.currentRenderer),
|
|
525
|
+
'transport-play': () => toggleTransportPlay(),
|
|
526
|
+
'transport-prev': () => stepUnit(-1),
|
|
527
|
+
'transport-next': () => stepUnit(1),
|
|
528
|
+
'transport-motion': (name) => applyTransportMotion(name),
|
|
529
|
+
'transport-skin': (name) => { if (stage && stage.setSkin) stage.setSkin(name); },
|
|
530
|
+
});
|
|
531
|
+
state.transport = transportInfo;
|
|
532
|
+
// 主题/背景走各自的落地通道(改 CSS 变量 / 舞台底色):值来自工厂(工厂按顶层字段读回)。
|
|
533
|
+
// 显示态不用在这里补 —— 面板渲染时的 initialValue 已经用了同一个值(契约 R2)。
|
|
534
|
+
applyTheme(uiState.getValue('ctl-theme') || 'dark-cyan');
|
|
535
|
+
applyBackground(uiState.getValue('ctl-bg-mode') || 'checkerboard');
|
|
536
|
+
// S2:原来这里有一段"手工回放 schema 默认值"(外面裹 suspendPanelPersist 防覆盖用户值)。
|
|
537
|
+
// 现在默认值已由工厂 loadAll 一次性补齐、并由 rebuildPanelFor 的 applyAll 生效,
|
|
538
|
+
// 这段时序连同 suspendPanelPersist 旁路一并删除(写盘只有工厂一条路径)。
|
|
539
|
+
state.panelDefaults = { ...((state.panel && state.panel.defaults) || {}) };
|
|
540
|
+
// 刷新后保持:把上次保存的面板设置重新应用一遍(主题/背景/MSAA 有各自恢复通道,跳过)
|
|
541
|
+
try { restorePanelSettings(STARTUP_SKIP); } catch (err) { stageLog('warn', `恢复上次面板设置失败:${formatError(err)}`, { err: formatError(err) }); }
|
|
542
|
+
document.documentElement.dataset.panelReady = '1';
|
|
543
|
+
document.documentElement.dataset.panelControls = String((state.panel && state.panel.controls) || 0);
|
|
544
|
+
document.documentElement.dataset.transportControls = String(transportInfo.controls);
|
|
545
|
+
|
|
546
|
+
// 品牌与资源根互不依赖,并行拉取;任一失败都不影响另一条链路
|
|
547
|
+
loadBrand();
|
|
548
|
+
bindEvents();
|
|
549
|
+
// 暴露给调试与自动化断言(M2 验收用)
|
|
550
|
+
// 就绪信号:`__WEB__` 一建好就算"骨架可用"(契约 §三 shell 阶段)
|
|
551
|
+
markStage('shell', 'interactive', { version: 'M4' });
|
|
552
|
+
window.__WEB__ = Object.assign(window.__WEB__ || {}, {
|
|
553
|
+
/** 当前渲染器(只读,便于排查:id / capabilities / camera 是否就绪) */
|
|
554
|
+
get renderer() { return state.currentRenderer; },
|
|
555
|
+
/** 能力自检(契约 §四):随时可重跑,violations 非空即缺陷 */
|
|
556
|
+
panelSelfCheck: () => runPanelSelfCheck(),
|
|
557
|
+
/** 面板三态快照(禁用/可用/隐藏),切换渲染器前后各取一次做对比 */
|
|
558
|
+
panelSnapshot: () => panelSnapshot(),
|
|
559
|
+
/**
|
|
560
|
+
* 名册加载计时快照:{ at, ms, roots, entries, chars, perRoot[] }。
|
|
561
|
+
* 名册还没建(启动早期)时返回 null,不编一个空对象冒充。
|
|
562
|
+
*/
|
|
563
|
+
rosterStats: () => (state.roster && typeof state.roster.rosterStats === 'function' ? state.roster.rosterStats() : null),
|
|
564
|
+
state,
|
|
565
|
+
version: 'M4',
|
|
566
|
+
/** 渲染舞台(测试脚本用它取像素、看统计) */
|
|
567
|
+
get stage() { return stage; },
|
|
568
|
+
/** 启动恢复结果:{restored, reason?, modelId?}(自动化断言用) */
|
|
569
|
+
get restore() { return state.restore || null; },
|
|
570
|
+
/** 自动恢复开关的生效值(true=恢复,false=配置关闭) */
|
|
571
|
+
get autoRestore() { return state.autoRestore; },
|
|
572
|
+
/** 诊断探针实例(等价于 window.__DIAG__,便于同一套检查代码复用) */
|
|
573
|
+
get diag() { return diag; },
|
|
574
|
+
/** 解绑交互层(测试清理用;正常运行时不必调用) */
|
|
575
|
+
detachInteraction: () => {
|
|
576
|
+
if (detachInteraction) { try { detachInteraction(); } catch { /* 忽略 */ } detachInteraction = null; }
|
|
577
|
+
},
|
|
578
|
+
/** 已接线的面板控件 id(断言用:可点集合必须与它一致) */
|
|
579
|
+
panelWired: WIRED_IDS,
|
|
580
|
+
/** 当前视图设置(主题/背景),断言用 */
|
|
581
|
+
viewSettings: () => ({ uiTheme: state.viewTheme, background: { mode: state.viewBg } }),
|
|
582
|
+
/** 已接线控件的声明默认值(断言:引擎实际生效值必须与之一致) */
|
|
583
|
+
get panelDefaults() { return state.panelDefaults || null; },
|
|
584
|
+
/** 面板/播放条骨架规模(自动化断言用) */
|
|
585
|
+
chrome: {
|
|
586
|
+
groups: (state.panel && state.panel.groups) || 0,
|
|
587
|
+
panelControls: (state.panel && state.panel.controls) || 0,
|
|
588
|
+
transportControls: transportInfo.controls,
|
|
589
|
+
},
|
|
590
|
+
defaultBrand: DEFAULT_BRAND,
|
|
591
|
+
/** 名册状态快照(未挂载时返回 null) */
|
|
592
|
+
rosterSnapshot: () => (state.roster ? state.roster.snapshot() : null),
|
|
593
|
+
/**
|
|
594
|
+
* 插件宿主(资产提供者)。
|
|
595
|
+
* 本阶段只做"加载 + 查询面",不接渲染链路(接线在后续步骤);
|
|
596
|
+
* 但宿主必须在页面起来时就绪,否则插件脚本会注册到空宿主上白跑。
|
|
597
|
+
*/
|
|
598
|
+
pluginHost: () => pluginHost,
|
|
599
|
+
});
|
|
600
|
+
// 资源根加载完成后应用 URL 参数(?model= / ?runtime=)。
|
|
601
|
+
// 无参数时 applyUrlParams 直接返回 null,不动任何状态 ⇒ 默认行为零变化。
|
|
602
|
+
// 放在 loadRoots 之后:要拿全量 catalog 做精确匹配。
|
|
603
|
+
loadRoots()
|
|
604
|
+
.then(() => applyUrlParams())
|
|
605
|
+
.catch((e) => {
|
|
606
|
+
try { stageLog('warn', `URL 参数处理失败:${(e && e.message) || e}`, { source: 'url-params' }); } catch { /* 忽略 */ }
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// 插件宿主:非阻塞启动(失败只警告,绝不影响页面其它功能)。
|
|
610
|
+
// 顺序关键:createPluginHost 内部先挂 window.SpineViewerPlugins 再注入 <script>,
|
|
611
|
+
// 插件顶层读宿主时才拿得到。
|
|
612
|
+
try {
|
|
613
|
+
pluginHost = createPluginHost({
|
|
614
|
+
log: (level, msg) => stageStatus(msg, level),
|
|
615
|
+
// 解包来源(命中缓存/服务端解包/回落前端)必须进 diag:验收与排障都靠它取证
|
|
616
|
+
diagEvent: (name, data) => diagEvent(name, data),
|
|
617
|
+
});
|
|
618
|
+
state.pluginHost = pluginHost; // app 层(选模型分流)经 state 取用,不反向 import
|
|
619
|
+
|
|
620
|
+
// 这里只是**启动预热**:不 await,不阻塞首屏。真正需要插件的能力入口
|
|
621
|
+
// (如资源包装载 loadBundleModel)会自己 await 一次 loadAll(幂等,已装载则立即返回),
|
|
622
|
+
// 所以插件注册晚于首屏也安全。
|
|
623
|
+
pluginHost.loadAll().catch((e) => stageLog('warn', `插件加载异常:${(e && e.message) || e}`, { err: String((e && e.message) || e) }));
|
|
624
|
+
} catch (e) {
|
|
625
|
+
stageLog('warn', `插件宿主初始化失败(页面其余功能不受影响):${(e && e.message) || e}`, { err: String((e && e.message) || e) });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
boot();
|