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.
- package/LICENSE +21 -0
- package/README.md +274 -2
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/bin/modelstage.mjs +137 -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 +63 -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 +654 -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 +396 -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/shared/type-labels.mjs +31 -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 +524 -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 +117 -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 +92 -0
- package/src/web/roster/reveal.js +77 -0
- package/src/web/roster/rows.js +248 -0
- package/src/web/roster/snapshot.js +39 -0
- package/src/web/roster/type-filter.js +97 -0
- package/src/web/roster/view.js +761 -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/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/parse-jsonc.mjs +86 -0
- package/tool/resolve-live2d-runtimes.mjs +276 -0
- package/tool/spine-motion-rules.json +101 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
// app/model-actions.js —— 模型选择 / 资源根装载 / 名册联动
|
|
2
|
+
//
|
|
3
|
+
// 这里只做"选中一个模型之后发生什么":装载 → 刷新皮肤与动作列表 → 更新面板只读行 → 舞台状态。
|
|
4
|
+
// 具体怎么画由渲染器负责;本模块不碰引擎内部。
|
|
5
|
+
|
|
6
|
+
import { dom, stage, state } from './context.js';
|
|
7
|
+
import { resetMotionState } from './anim-list.js';
|
|
8
|
+
import { refreshStage } from './memory-bars.js';
|
|
9
|
+
import { markModelFailed } from '../infra/ready.js';
|
|
10
|
+
import { buildDocument } from '../infra/document.js';
|
|
11
|
+
import { getSharedVirtualFS } from '../infra/vfs.js';
|
|
12
|
+
import { diagEvent, stageLog, stageStatus } from './status.js';
|
|
13
|
+
// "受支持的渲染面"由元数据表判定(不在这里写死 spine/live2d 枚举)
|
|
14
|
+
import { isKnownKind } from '../render/surfaces.js';
|
|
15
|
+
import { formatError, getJson } from '../infra/api.js';
|
|
16
|
+
import { createRootManager } from './root-manager.js';
|
|
17
|
+
// 装载结果上报服务端(契约《条目状态与展示归属》§三):成功/失败都报,
|
|
18
|
+
// 响应带回重算后的三字段 ⇒ 就地更新那一行(不重拉 catalog、不整表重建)。
|
|
19
|
+
// 前端不再自己存"装载失败记忆"(原 roster/bundle-failures.js 的 localStorage 层已删)。
|
|
20
|
+
import { reportEntryResult } from './entry-report.js';
|
|
21
|
+
import { createDirPicker } from './dir-picker.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 本次装载实际命中的提供者 id(容器由 resolve 给出,目录型为 builtin-*;都没有则 null)。
|
|
25
|
+
* 由 loadModel 在 mount 之前写入,mountEntry 的成功/失败回调读它去上报 loadResult.provider。
|
|
26
|
+
* 为什么不塞进 loadModel 的返回值:那个返回值是"渲染器 info",形状被多处消费,
|
|
27
|
+
* 加私货会污染契约;而这个值只有"上报"一处用得上。
|
|
28
|
+
*/
|
|
29
|
+
let lastMountProvider = null;
|
|
30
|
+
|
|
31
|
+
async function loadWebConfig() {
|
|
32
|
+
try {
|
|
33
|
+
const cfg = await getJson('/api/web-config');
|
|
34
|
+
// 服务端下发的前端准入参数(前端读不到 env):拿到就应用,缺省值保持在 memory-budget.js
|
|
35
|
+
try { applyAdmitConfig(cfg); } catch { /* 应用失败不该影响装载 */ }
|
|
36
|
+
if (cfg && typeof cfg.autoRestoreLast === 'boolean') return cfg.autoRestoreLast;
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
import { createRosterView } from '../roster/view.js';
|
|
43
|
+
import { fillTransportOptions } from './transport-actions.js';
|
|
44
|
+
import { connectPanelRefresh, connectReloadApp, rebuildPanelFor, refreshPanelAnimations } from '../panel/panel-settings.js';
|
|
45
|
+
import { applyAdmitConfig } from './memory-budget.js';
|
|
46
|
+
|
|
47
|
+
/** 加载模型后刷新皮肤列表(换模型必须重建,不能停在上一个模型的皮肤) */
|
|
48
|
+
function refreshSkinList() {
|
|
49
|
+
if (!state.panel || !stage) return;
|
|
50
|
+
const skins = stage.getSkins ? stage.getSkins() : ['default'];
|
|
51
|
+
// 当前项取**引擎实际生效的那个**,不要用"列表里有没有 default"去猜:
|
|
52
|
+
// 装载时会自动挑一个"能画出东西"的皮肤(有些资源的 default 是空壳),
|
|
53
|
+
// 猜成 default 就会出现"下拉显示 default、画面其实是 00"这种自相矛盾的显示。
|
|
54
|
+
const active = typeof stage.getActiveSkin === 'function' ? stage.getActiveSkin() : null;
|
|
55
|
+
const current = (active && skins.includes(active))
|
|
56
|
+
? active
|
|
57
|
+
: (skins.includes('default') ? 'default' : skins[0]);
|
|
58
|
+
state.panel.setOptions('ctl-skin-list', skins.map((s) => ({ text: s, value: s })), current);
|
|
59
|
+
if (state.transport) state.transport.setOptions('transport-skin', skins.map((s) => ({ text: s, value: s })), current);
|
|
60
|
+
// 注意:**不把皮肤记进值工厂**(不持久化)。皮肤名是"模型局部语义"——
|
|
61
|
+
// 当前模型的 "00" 换个模型可能根本不存在,记住它只会让下次装载按一个不存在的名字去设,
|
|
62
|
+
// 还会盖掉"装载时自动挑一个能画出东西的皮肤"这条逻辑(表现为刷新后又空白)。
|
|
63
|
+
// 与动作列表同一条思路:这类"取值域随模型变"的项,每次装载重新来。
|
|
64
|
+
try { refreshPanelAnimations(); } catch { /* 动作列表刷新失败不影响主流程 */ }
|
|
65
|
+
document.documentElement.dataset.skinCount = String(skins.length);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 把"当前动作"缓存对齐到底栏下拉的实际值。
|
|
70
|
+
*
|
|
71
|
+
* 为什么要这一步:`state.transportMotion` 是"当前动作"的**副本**(唯一真相是底栏下拉,契约 §七),
|
|
72
|
+
* 但它只在"用户手动选动作"时被写入。装载新模型时动作清单整体换掉、底栏被重置到新清单的首项,
|
|
73
|
+
* 缓存却还停在上一个模型的 id —— 右栏按这个陈旧 id 去高亮就一条都匹配不上(实测:底栏显示 idle、
|
|
74
|
+
* 缓存仍是上一模型的 "3",右栏 0 个选中)。装载完成后立刻对齐,缓存与真实播放项就不会分叉。
|
|
75
|
+
* @returns {string} 对齐后的当前动作 id(取不到则为空串)
|
|
76
|
+
*/
|
|
77
|
+
function syncTransportMotionCache() {
|
|
78
|
+
const transport = state.transport;
|
|
79
|
+
if (!transport || typeof transport.picker !== 'function') return '';
|
|
80
|
+
const pk = transport.picker('transport-motion');
|
|
81
|
+
const value = pk && typeof pk.value === 'function' ? String(pk.value() || '') : '';
|
|
82
|
+
if (value) state.transportMotion = value;
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
/** 在左栏顶部插一条错误横幅(替代抛异常到控制台) */
|
|
86
|
+
function showRootsError(message) {
|
|
87
|
+
const banner = document.createElement('p');
|
|
88
|
+
banner.className = 'error-banner';
|
|
89
|
+
banner.id = 'roots-error';
|
|
90
|
+
banner.textContent = `读取资源根失败:${message}`;
|
|
91
|
+
dom.rosterList.replaceChildren(banner);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 释放当前 Document 持有的 VFS 前缀(幂等;只撤自己注册过的那个)。
|
|
95
|
+
*
|
|
96
|
+
* 为什么不再叫 bundle 前缀:前缀是「当前 Document 的资产来源」带来的,与来源是容器还是目录无关
|
|
97
|
+
* —— 目录型模型将来若也走 VFS(或插件产出别的容器类型)同样适用。
|
|
98
|
+
* @param {string|null} keep 要保留的前缀(换到另一个容器时传新前缀;切到不再用 VFS 的模型时传 null)
|
|
99
|
+
*/
|
|
100
|
+
function releaseVfsPrefix(keep) {
|
|
101
|
+
const prev = state.vfsPrefix;
|
|
102
|
+
if (!prev || prev === keep) return;
|
|
103
|
+
try { getSharedVirtualFS().revoke(prev); } catch { /* 已撤销或不存在:忽略 */ }
|
|
104
|
+
state.vfsPrefix = null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 把条目归一成 `{ kind, container? }`。
|
|
109
|
+
* · kind = 内容类型(spine / live2d)—— 只决定渲染分支
|
|
110
|
+
* · container = 容器标记(如 'unityfs')—— 只决定"要不要先解包",由发现阶段给出
|
|
111
|
+
* 旧条目(localStorage 里存的"上次选中"可能仍是 kind:'bundle')在这里就地纠正,
|
|
112
|
+
* 避免刷新恢复时走进已废弃的分支。
|
|
113
|
+
* @param {object} model 名册条目
|
|
114
|
+
* @returns {object} 归一后的条目(新对象)
|
|
115
|
+
*/
|
|
116
|
+
function normalizeEntry(model) {
|
|
117
|
+
const legacyBundle = model && model.kind === 'bundle';
|
|
118
|
+
// 只做"已知值归一",**不再瞎兜底**:
|
|
119
|
+
// 以前非 spine/live2d 一律当 'spine' —— 而容器条目的 kind 可能为 null
|
|
120
|
+
// (多个 bundle 插件并存时,解包前无法确定内容类型)⇒ live2d 容器会被当成 spine 走错分支。
|
|
121
|
+
// 现在未知就是 null,由解包产物判(见 expandContainerEntry);判不出来就明确报错。
|
|
122
|
+
// 判据改成查渲染面元数据表(加新面时这里不用改)。
|
|
123
|
+
const known = isKnownKind(model && model.kind) ? model.kind : null;
|
|
124
|
+
// container 是"这是容器"的事实,但**不假定它一定在**:旧条目可能只有 kind:'bundle'
|
|
125
|
+
const container = (model && model.container) || (legacyBundle ? 'unityfs' : null);
|
|
126
|
+
return { ...model, kind: known, ...(container ? { container } : {}) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 解析资产来源(**全流程唯一按「是不是容器单文件」分流的地方**)。
|
|
131
|
+
*
|
|
132
|
+
* 容器单文件:插件解包 → 注册 VFS 前缀 → 入口地址用 `vfs://…`(Document 组装与 Live2D 内部相对引用
|
|
133
|
+
* 都要经 VirtualFS;blob 不能当基址,`new URL(rel, 'blob:…')` 直接非法)。
|
|
134
|
+
* 普通条目:直接用条目里的地址。
|
|
135
|
+
* 下游(Document 组装 / 渲染装载 / 动作列表 / 清理)**一律不知道"这是资源包"**。
|
|
136
|
+
*
|
|
137
|
+
* @returns {Promise<{ok:boolean, entryUrl:string|null, prefix:string|null, provider:string|null, message:string}>}
|
|
138
|
+
*/
|
|
139
|
+
async function resolveAssetSource(model, name) {
|
|
140
|
+
// 发现阶段给的是"这个条目是不是容器单文件"(container 标记),不是内容类型:
|
|
141
|
+
// 是容器 ⇒ 先解包注册 VFS 前缀;否则直接用条目自带地址。
|
|
142
|
+
if (!model.container) {
|
|
143
|
+
const url = (model.entry && model.entry.url) || model.modelUrl || null;
|
|
144
|
+
if (!url) return { ok: false, entryUrl: null, prefix: null, provider: null, message: '条目缺少资产地址' };
|
|
145
|
+
return { ok: true, entryUrl: url, prefix: null, provider: null, message: '' };
|
|
146
|
+
}
|
|
147
|
+
const host = state.pluginHost;
|
|
148
|
+
if (!host) {
|
|
149
|
+
return { ok: false, entryUrl: null, prefix: null, provider: null, message: '插件宿主未就绪(页面刚启动时可稍等再试)' };
|
|
150
|
+
}
|
|
151
|
+
// 宿主对象启动时就创建了,但插件脚本可能还在注入:装载入口自己等一次。
|
|
152
|
+
// loadAll 幂等(已装载直接返回,进行中复用同一个 Promise),热路径上就是立即 resolve。
|
|
153
|
+
await host.loadAll();
|
|
154
|
+
const providers = typeof host.providersFor === 'function' ? host.providersFor(['bundle']) : [];
|
|
155
|
+
if (!providers.length) {
|
|
156
|
+
// 与"宿主未就绪"分开报:此时是真的没有能处理容器的插件(配置/目录问题)
|
|
157
|
+
return {
|
|
158
|
+
ok: false, entryUrl: null, prefix: null, provider: null,
|
|
159
|
+
// 与 plugin-host 的 reason 同口径:no-provider 是**真实存在**的分类,可直接进文案映射表
|
|
160
|
+
reason: 'no-provider',
|
|
161
|
+
message: '没有能处理 bundle 容器的插件(检查 config/plugins.json 与 plugins/ 目录)',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
stageStatus(`正在解包 ${name}…(首次约 3~4 秒)`, 'info');
|
|
165
|
+
const mat = await host.materialize({
|
|
166
|
+
rootId: model.rootId,
|
|
167
|
+
rel: model.rel,
|
|
168
|
+
name: model.name,
|
|
169
|
+
size: model.size,
|
|
170
|
+
rootKind: 'bundle', // 容器类型(插件能力声明用它判"能不能读"),不是内容类型
|
|
171
|
+
});
|
|
172
|
+
// mat.reason 是 plugin-host 给出的结构化原因(no-provider / resolve-failed / memory-guard …),
|
|
173
|
+
// 原样透出给上层记缓存用;没有被编造的分类,缺就是 null
|
|
174
|
+
if (!mat.ok) return { ok: false, entryUrl: null, prefix: null, provider: null, reason: mat.reason || null, message: mat.message || '解包失败' };
|
|
175
|
+
const entryUrl = joinPrefix(mat.prefix, mat.entry);
|
|
176
|
+
// files 只带**路径清单**(不带 Blob):产物已经由 materialize 注册进 VFS,
|
|
177
|
+
// 上层判断"有没有 entry.json"只需要名字,不需要字节。
|
|
178
|
+
return {
|
|
179
|
+
ok: true, entryUrl, prefix: mat.prefix, provider: mat.provider, message: '',
|
|
180
|
+
files: mat.files && typeof mat.files.keys === 'function' ? [...mat.files.keys()] : [],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 拼 VFS 地址:prefix + 相对路径。
|
|
186
|
+
* 只此一处实现 —— 解包产物(entry.json 里的相对名)与其它容器文件都走它,
|
|
187
|
+
* 避免"同一个拼接规则写两遍",将来 prefix 形态变了漏改一处就会静默取不到文件。
|
|
188
|
+
* @param {string} prefix 形如 'vfs://xxx/'
|
|
189
|
+
* @param {string} rel 相对路径(允许以 ./ 或 / 开头)
|
|
190
|
+
* @returns {string}
|
|
191
|
+
*/
|
|
192
|
+
function joinPrefix(prefix, rel) {
|
|
193
|
+
return `${prefix}${String(rel || '').replace(/^\.?\//, '')}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 取路径末段(entry.json 里的文件名 → 展示用名字) */
|
|
197
|
+
function baseName(p) {
|
|
198
|
+
return String(p || '').split('/').pop() || '';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 从 skel 字节里读出版本号(如 '4.1.20')。
|
|
203
|
+
*
|
|
204
|
+
* 二进制 skel 的布局:`[8 字节 hash][1 字节长度 N][N 字节 UTF-8 版本串]`(实测 4.1.20 完全吻合)。
|
|
205
|
+
* 为什么必须读出来:引擎用 `doc.meta.version`(= entry.version)选运行时;缺它会选"最新一档",
|
|
206
|
+
* 而 spine 运行时不校验版本兼容性 ⇒ 用新版读旧格式只会读错位,报 `String in string table must not be null`。
|
|
207
|
+
* 读不到就返回 null(交给引擎的兜底分支),不编造。
|
|
208
|
+
* @param {string} url 骨架地址(vfs:// 或 http)
|
|
209
|
+
* @returns {Promise<string|null>}
|
|
210
|
+
*/
|
|
211
|
+
async function probeSkelVersion(url) {
|
|
212
|
+
try {
|
|
213
|
+
const vfs = getSharedVirtualFS();
|
|
214
|
+
const real = (vfs && typeof vfs.resolve === 'function' ? vfs.resolve(url) : null) || url;
|
|
215
|
+
// 归 **B 类(只加超时,不走 api.js)**:这里要的是二进制头,不是 JSON —— getJson 会 res.json() 解坏。
|
|
216
|
+
// 注意 `.slice(0,32)` 是在**已拿到的 blob** 上切,不是 HTTP Range ⇒ 实际仍下载整个 .skel,
|
|
217
|
+
// 所以超时按"整文件下载"给:20s(CIFS 上几 MB 骨架可能数秒,实测运行时脚本读取同量级)。
|
|
218
|
+
const res = await fetch(real, { signal: AbortSignal.timeout(20000) });
|
|
219
|
+
if (!res || !res.ok) return null;
|
|
220
|
+
// 读前 64 字节:3.8 格式是「hash 串 + 版本串」,两段加起来可能 40+ 字节(实测 1+27+1+7=36)
|
|
221
|
+
const head = new Uint8Array(await (await res.blob()).slice(0, 64).arrayBuffer());
|
|
222
|
+
|
|
223
|
+
const printable = (bytes) => new TextDecoder().decode(bytes).replace(/[^\x20-\x7e]/g, '').trim();
|
|
224
|
+
const looksLikeVersion = (s) => /^[34]\.\d{1,2}(\.\d{1,2})?/.test(s);
|
|
225
|
+
|
|
226
|
+
// ── 格式 ①:spine **4.x** —— `hashLow:int32 + hashHigh:int32 + version:readString`
|
|
227
|
+
// 版本串长度字节落在 head[8];编码仍带 1 字节偏移(见下)。
|
|
228
|
+
const len4 = head[8];
|
|
229
|
+
if (len4 > 1 && len4 <= 25) {
|
|
230
|
+
const ver4 = printable(head.slice(9, 9 + (len4 - 1)));
|
|
231
|
+
if (looksLikeVersion(ver4)) return ver4;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── 格式 ②:spine **3.8** —— `hash:readString + version:readString`(**没有** 8 字节 int32 hash)
|
|
235
|
+
// 实测 3.8.96 的包:head[0]=0x1c(28) ⇒ hash 27 字节;接着 0x08(8) ⇒ 版本串 7 字节 "3.8.96"。
|
|
236
|
+
// ⚠️ spine 的 readString 长度**含 1 字节偏移**(源码 `byteCount--`):少减 1 会整体错位,
|
|
237
|
+
// 读成 ".8.96hDE01_eyeclos" —— 那正是本轮踩到的坑,也是"页面装载这类包必然失败"的根因。
|
|
238
|
+
let i = 0;
|
|
239
|
+
const readVarint = () => {
|
|
240
|
+
let result = 0;
|
|
241
|
+
let shift = 0;
|
|
242
|
+
for (;;) {
|
|
243
|
+
const b = head[i++];
|
|
244
|
+
if (b === undefined) throw new Error('越界');
|
|
245
|
+
result |= (b & 0x7f) << shift;
|
|
246
|
+
if (!(b & 0x80)) break;
|
|
247
|
+
shift += 7;
|
|
248
|
+
if (shift > 28) throw new Error('varint 过长');
|
|
249
|
+
}
|
|
250
|
+
return result;
|
|
251
|
+
};
|
|
252
|
+
try {
|
|
253
|
+
const hashLen = readVarint();
|
|
254
|
+
if (hashLen > 1 && hashLen < 64 && i + (hashLen - 1) <= head.length) {
|
|
255
|
+
i += (hashLen - 1);
|
|
256
|
+
const verLen = readVarint();
|
|
257
|
+
if (verLen > 1 && verLen <= 25 && i + (verLen - 1) <= head.length) {
|
|
258
|
+
const ver3 = printable(head.slice(i, i + (verLen - 1)));
|
|
259
|
+
if (looksLikeVersion(ver3)) return ver3;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} catch { /* 3.8 判据不成立就返回 null,让调用方走"最新一档" */ }
|
|
263
|
+
|
|
264
|
+
return null;
|
|
265
|
+
} catch { return null; }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 容器解包产物 → 展开成 spine 三件套(entry.json 是唯一权威)。
|
|
270
|
+
*
|
|
271
|
+
* 三种输入的处置:
|
|
272
|
+
* 1. 有 entry.json 且含 skeletonFile ⇒ 补齐 {skeletonFile, atlasFile, textureFiles}
|
|
273
|
+
* 2. 是 live2d 容器(entry.json 是 model3.json,没有 skeletonFile)⇒ 原样返回,走原来的 live2d 路
|
|
274
|
+
* 3. 产物里有 .skel/.atlas 却没有 entry.json ⇒ **明确报错**,不按扩展名猜:
|
|
275
|
+
* 多页 atlas 时"页面名 ↔ png"只能靠 atlas 首行解析,猜错会静默显示错图,
|
|
276
|
+
* 那种"看着正常但是错的"比直接报错难查得多。
|
|
277
|
+
* @param {{prefix:string, entryUrl:string, files:string[]}} src
|
|
278
|
+
* @param {string} name 显示名(只用于日志)
|
|
279
|
+
* @returns {Promise<{entry?:object, error?:string}>}
|
|
280
|
+
*/
|
|
281
|
+
async function expandContainerEntry(src, name) {
|
|
282
|
+
// vfs:// 不能直接 fetch(浏览器不认这个协议,会 net::ERR_FAILED)——
|
|
283
|
+
// 必须先用 VFS 的 resolve 换成 blob: 地址。这与 document.js:33 resolveUrl 是同一套逻辑,
|
|
284
|
+
// 这里复用同一个 VFS 句柄,不另造解析器。
|
|
285
|
+
const fetchUrl = (() => {
|
|
286
|
+
try { return getSharedVirtualFS().resolve(src.entryUrl) || null; } catch { return null; }
|
|
287
|
+
})();
|
|
288
|
+
let meta = null;
|
|
289
|
+
let fetchNote = '';
|
|
290
|
+
try {
|
|
291
|
+
if (fetchUrl) {
|
|
292
|
+
// 归 **B 类(只加超时)**:这里必须保留 `cache: 'no-store'`(entry.json 是解包产物,
|
|
293
|
+
// 读到浏览器缓存会拿到上一版产物);而 getJson/fetchWithRetry 都不透传 cache 选项,
|
|
294
|
+
// 走 api.js 会静默丢掉这个语义 ⇒ 宁可只补超时。
|
|
295
|
+
// 8.5s 与 api.js 默认一致(entry.json 是小 JSON,实测同量级接口 10~58ms)。
|
|
296
|
+
const res = await fetch(fetchUrl, { cache: 'no-store', signal: AbortSignal.timeout(8500) });
|
|
297
|
+
if (res && res.ok) meta = await res.json();
|
|
298
|
+
else fetchNote = `HTTP ${res && res.status}`;
|
|
299
|
+
} else fetchNote = 'resolve 返回空';
|
|
300
|
+
} catch (e) { meta = null; fetchNote = (e && e.message) || 'fetch 抛错'; } // 不是 JSON / 取不到 ⇒ 当作"没有 entry.json"走下面的判定
|
|
301
|
+
|
|
302
|
+
// 排障:这条把"取到没取到"一次说清(meta 为 null 时整个 skeletonFile 分支根本不会进)
|
|
303
|
+
diagEvent('container.expand', {
|
|
304
|
+
plugin: src.provider || null,
|
|
305
|
+
entryUrl: src.entryUrl,
|
|
306
|
+
resolved: fetchUrl ? String(fetchUrl).slice(0, 48) : null,
|
|
307
|
+
metaKeys: meta && typeof meta === 'object' ? Object.keys(meta).join(',') : null,
|
|
308
|
+
fetchNote: fetchNote || null,
|
|
309
|
+
}, { level: 'info', src: 'app' });
|
|
310
|
+
|
|
311
|
+
if (meta && meta.skeletonFile) {
|
|
312
|
+
const join = (rel) => joinPrefix(src.prefix, rel);
|
|
313
|
+
const texFiles = Array.isArray(meta.textureFiles) ? meta.textureFiles : [];
|
|
314
|
+
const built = {
|
|
315
|
+
skeletonFile: { name: baseName(meta.skeletonFile), url: join(meta.skeletonFile) },
|
|
316
|
+
atlasFile: meta.atlasFile
|
|
317
|
+
? { name: baseName(meta.atlasFile), url: join(meta.atlasFile) }
|
|
318
|
+
: undefined,
|
|
319
|
+
textureFiles: texFiles.map((f) => ({ name: baseName(f), url: join(f) })),
|
|
320
|
+
};
|
|
321
|
+
// skel 版本必须一起带出去:引擎按 doc.meta.version(= entry.version)选运行时,
|
|
322
|
+
// 缺这个值会走 pickRuntimeVersion 的"没版本号 ⇒ 用清单里最新一档"分支。
|
|
323
|
+
// 用新版运行时读旧格式 skel **不会报版本错**(spine 运行时不校验兼容性),只会读错位,
|
|
324
|
+
// 最后在 `String in string table must not be null` 这种地方炸 —— 排查成本极高。
|
|
325
|
+
const version = await probeSkelVersion(built.skeletonFile.url);
|
|
326
|
+
// 排障:三件套的 url 各打一条(拼接用的是什么 prefix、结果长什么样)
|
|
327
|
+
diagEvent('container.assets', {
|
|
328
|
+
prefix: src.prefix || null,
|
|
329
|
+
skeletonUrl: built.skeletonFile.url,
|
|
330
|
+
atlasUrl: built.atlasFile ? built.atlasFile.url : null,
|
|
331
|
+
textureUrls: built.textureFiles.map((t) => t.url).join(' , '),
|
|
332
|
+
skelVersion: version,
|
|
333
|
+
}, { level: 'info', src: 'app' });
|
|
334
|
+
return { kind: 'spine', entry: built, version };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 没有 skeletonFile:按产物确定性地判内容类型(kind 可能本来是 null)
|
|
338
|
+
//
|
|
339
|
+
// Live2D 的判据用**入口文件名**,而不是扫描 files 清单:
|
|
340
|
+
// · 实测容器的入口就叫 `model3.json`(不是 `xxx.model3.json`),endsWith('model3.json') 两种都能覆盖;
|
|
341
|
+
// · 更关键:materialize 返回的 files 在不同路径下形状不一致(实测拿到过 Number),
|
|
342
|
+
// 拿不到清单时"靠 files 判"必然误判成"判不出"。入口文件则是**一定存在**的。
|
|
343
|
+
const entryName = baseName(src.entryUrl).toLowerCase();
|
|
344
|
+
if (entryName.endsWith('model3.json')) return { kind: 'live2d' };
|
|
345
|
+
|
|
346
|
+
const paths = (Array.isArray(src.files) ? src.files : []).map((p) => String(p).toLowerCase());
|
|
347
|
+
const hasSkel = paths.some((p) => p.endsWith('.skel'));
|
|
348
|
+
const hasAtlas = paths.some((p) => p.endsWith('.atlas'));
|
|
349
|
+
const hasModel3 = paths.some((p) => p.endsWith('model3.json'));
|
|
350
|
+
if (hasModel3) return { kind: 'live2d' };
|
|
351
|
+
|
|
352
|
+
if (hasSkel && hasAtlas) {
|
|
353
|
+
stageLog('warn', `容器 ${name} 里没有 entry.json(将拒绝装载)`, {
|
|
354
|
+
files: paths.slice(0, 12).join(' / '),
|
|
355
|
+
});
|
|
356
|
+
return {
|
|
357
|
+
reason: 'entry-missing',
|
|
358
|
+
error: '容器内缺少 entry.json:spine 三件套需要 skeletonFile / atlasFile / textureFiles 才能确定'
|
|
359
|
+
+ '骨架、atlas 与贴图页的对应关系(不按扩展名猜,多页 atlas 猜错会静默显示错图)',
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
// 既没有 spine 清单、也没有 Live2D 定义 ⇒ 无法判定,明确报错(不静默走任何分支)
|
|
363
|
+
return {
|
|
364
|
+
reason: 'kind-undetermined',
|
|
365
|
+
error: '容器内既没有 entry.json(spine 三件套清单)也没有 *.model3.json(Live2D 定义),'
|
|
366
|
+
+ '无法判定内容类型;不按扩展名猜(多页 atlas 猜错会静默显示错图)',
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* **唯一的装载通路**:解析资产来源 → 组装 Document → 交给当前渲染器。
|
|
372
|
+
*
|
|
373
|
+
* 用户定的原则:资源包解包后文件就在 VFS 里,`buildDocument + mount` 只该有一条路,
|
|
374
|
+
* 差异只存在于「资产地址是 vfs:// 还是 http://」。所以本函数不再按 kind 分支,
|
|
375
|
+
* kind 只作为 Document 的内容类型(选渲染面)。
|
|
376
|
+
*
|
|
377
|
+
* @param {object} model 名册条目(已归一)
|
|
378
|
+
* @param {string} name 显示名
|
|
379
|
+
* @returns {Promise<object|null>} 渲染器返回的 info(失败为 null)
|
|
380
|
+
*/
|
|
381
|
+
async function loadModel(model, name) {
|
|
382
|
+
const src = await resolveAssetSource(model, name);
|
|
383
|
+
if (!src.ok) {
|
|
384
|
+
stage.unload();
|
|
385
|
+
stageLog('error', `无法装载 ${name}:${src.message}`, { model: name, kind: model && model.kind, reason: src.message });
|
|
386
|
+
reportEntryResult(model, { result: 'failed', reason: src.reason || null, message: src.message });
|
|
387
|
+
markModelFailed(`无法装载 ${name}:${src.message}`);
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
// 容器解包产物:把 entry.json 展开成 spine 三件套。
|
|
391
|
+
// 为什么必须在这一层做:`buildSpineAssets`(infra/document.js:98)要的是
|
|
392
|
+
// {skeletonFile, atlasFile, textureFiles} 这个形状,而容器这条路只传了"入口文件的地址",
|
|
393
|
+
// 没人把 entry.json 读开过;同时 document.js:69 的兜底规则(有 entry.url 就当 live2d)
|
|
394
|
+
// 会把 spine 容器判成 live2d。在进 buildDocument 之前补齐 + 显式给 kind 即可,
|
|
395
|
+
// **不动 document.js**(那条路与 live2d 分支同族,动它会弄坏已经能用的 live2d)。
|
|
396
|
+
const entry = { url: src.entryUrl };
|
|
397
|
+
let kind = model.kind || null;
|
|
398
|
+
// spine 三件套必须放在**传给 buildDocument 的那个对象的顶层**:
|
|
399
|
+
// buildSpineAssets(doc, entry, vfs) 读的是 `entry.skeletonFile` / `entry.atlasFile` /
|
|
400
|
+
// `entry.textureFiles`(document.js:98-105)。塞进嵌套的 `entry.entry.*` 它一个字都读不到,
|
|
401
|
+
// 只会报"缺少骨架地址/缺少 atlas 地址/贴图页为空"—— 而 diag 里那三个 url 明明都拼对了。
|
|
402
|
+
let spineAssets = null;
|
|
403
|
+
let spineVersion = null;
|
|
404
|
+
if (src.prefix) {
|
|
405
|
+
const expanded = await expandContainerEntry(src, name);
|
|
406
|
+
if (expanded.error) {
|
|
407
|
+
stage.unload();
|
|
408
|
+
stageLog('error', `无法装载 ${name}:${expanded.error}`, { model: name, kind: model && model.kind, reason: expanded.error });
|
|
409
|
+
reportEntryResult(model, { result: 'failed', reason: expanded.reason || null, message: expanded.error });
|
|
410
|
+
markModelFailed(`无法装载 ${name}:${expanded.error}`);
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
// 产物判定优先:容器条目 kind 可能为 null(多个 bundle 插件并存时解包前无法确定内容类型)
|
|
414
|
+
if (expanded.kind) kind = expanded.kind;
|
|
415
|
+
if (expanded.entry) spineAssets = expanded.entry;
|
|
416
|
+
if (expanded.version) spineVersion = expanded.version;
|
|
417
|
+
}
|
|
418
|
+
// 到这里还没确定内容类型 ⇒ 明确报错,不猜也不静默走进某个分支
|
|
419
|
+
if (kind !== 'spine' && kind !== 'live2d') {
|
|
420
|
+
const why = `无法判定内容类型(kind=${kind === null || kind === undefined ? 'null' : kind})`
|
|
421
|
+
+ ':非容器条目没有可判定的产物,容器条目也没有 entry.json(skeletonFile) 或 *.model3.json';
|
|
422
|
+
stage.unload();
|
|
423
|
+
stageLog('error', `无法装载 ${name}:${why}`, { model: name, kind, reason: 'kind-undetermined' });
|
|
424
|
+
reportEntryResult(model, { result: 'failed', reason: 'kind-undetermined', message: why });
|
|
425
|
+
markModelFailed(`无法装载 ${name}:${why}`);
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
const doc = await buildDocument({
|
|
429
|
+
...model,
|
|
430
|
+
...(spineAssets || {}), // 三件套放顶层(buildSpineAssets 只认这里)
|
|
431
|
+
// 骨架版本同样放顶层:document.js:87 用 entry.version 填 doc.meta.version,
|
|
432
|
+
// 引擎据此选运行时档位;缺了就选最新档 ⇒ 旧格式 skel 读错位
|
|
433
|
+
version: spineVersion || model.version || null,
|
|
434
|
+
kind, // 只表达内容类型
|
|
435
|
+
provider: src.provider || model.provider || null,
|
|
436
|
+
entry, // 是 vfs:// 还是 http:// 由发现阶段决定
|
|
437
|
+
});
|
|
438
|
+
if (!doc || doc.state !== 'ready') {
|
|
439
|
+
const why = (doc && (doc.warnings || []).join(';')) || '原因未知';
|
|
440
|
+
stageLog('error', `无法装载 ${name}:${why}`, { model: name, kind, reason: why });
|
|
441
|
+
// 这条失败没有结构化 reason(是 Document 组装的 warnings 汇总)⇒ reason 传 null,走 message 兜底
|
|
442
|
+
reportEntryResult(model, { result: 'failed', reason: null, message: why });
|
|
443
|
+
markModelFailed(`无法装载 ${name}:${why}`);
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
// 统一清理:新 Document 不用旧前缀就释放(换容器/切到目录型模型都走这一句)
|
|
447
|
+
releaseVfsPrefix(src.prefix);
|
|
448
|
+
state.vfsPrefix = src.prefix || null;
|
|
449
|
+
// 形态 C 的空闲预热:装载成功后让服务端把这个**容器包**解包落盘,供下次命中。
|
|
450
|
+
// 放在装载之后(不是之前):预热要给首屏让路;warmup 内部自己找 idle + 延迟 2s,上限由服务端 /api/unpack/capacity 下发(CPU 维度与内存维度取小)。
|
|
451
|
+
// 仅容器单文件需要(目录型模型没有解包这件事)。
|
|
452
|
+
if (model && model.container && state.pluginHost && typeof state.pluginHost.warmup === 'function') {
|
|
453
|
+
try { state.pluginHost.warmup({ rootId: model.rootId, rel: model.rel, size: model.size }); } catch { /* 预热失败不影响播放 */ }
|
|
454
|
+
}
|
|
455
|
+
// 本次装载**实际命中的提供者**(容器由 resolve 给出;目录型由内置装载器给出)。
|
|
456
|
+
// 记在模块级是为了让 mountEntry 的成功回调能拿到它 —— 那里才有"装上了"的信号。
|
|
457
|
+
// 它只用于上报 loadResult.provider(服务端据此重算 display),不参与选面判定。
|
|
458
|
+
// ⚠ 目录型必须也给出 provider,不能留 null:存储层的规则是"ok 但没有 provider ⇒ 视作
|
|
459
|
+
// 无法确定归属、降格记 failed"(entry-states.mjs 的一致性约束)。实测踩过:
|
|
460
|
+
// 目录型装载全部被误记 failed ⇒ 名册每个模型都挂上 ⚠。
|
|
461
|
+
// 目录型的归属服务端早就算好了(display.provider = 唯一认领它的 builtin-*,来自插件声明),
|
|
462
|
+
// 这里读条目自带的 display.provider 兜底 —— 权威仍在服务端,前端不硬编码任何提供者 id。
|
|
463
|
+
lastMountProvider = (src && src.provider) || (model && model.display && model.display.provider) || null;
|
|
464
|
+
return state.currentRenderer ? state.currentRenderer.mount(doc) : stage.load({ document: doc });
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** 选中变体的回调:M2 只记状态(M4 用它去渲染舞台) */
|
|
468
|
+
/**
|
|
469
|
+
* 真正装载一个条目(**唯一装载实现**):onSelectModel 判完资产残缺后交给它。
|
|
470
|
+
* @param {object} entry 已 normalizeEntry 的条目
|
|
471
|
+
* @param {string} name 展示名
|
|
472
|
+
*/
|
|
473
|
+
function mountEntry(entry, name) {
|
|
474
|
+
const kind = entry.kind;
|
|
475
|
+
// kind 未知时不能一刀切拒绝:
|
|
476
|
+
// · 容器 ⇒ 放行,解包产物到手后由 expandContainerEntry 判定(kind 可能本来就是 null)
|
|
477
|
+
// · 非容器 ⇒ 没有任何产物可判,明确拒绝(宁可报错也不走错分支)
|
|
478
|
+
if (kind !== 'spine' && kind !== 'live2d' && !entry.container) {
|
|
479
|
+
stage.unload();
|
|
480
|
+
stageLog('warn', `已选中 ${name}(未知内容类型,且不是容器 ⇒ 没有可判定的产物:kind=${kind === null || kind === undefined ? 'null' : kind})`, { model: name, kind });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Live2D 与 Spine 共用同一装载入口:引擎在 loadModel 里认出 kind==="live2d" 后
|
|
485
|
+
|
|
486
|
+
stageStatus(`正在加载 ${name}…`, 'info');
|
|
487
|
+
// 走当前渲染器装载:Live2D 必须由 renderer 分流到覆盖层,
|
|
488
|
+
// 否则会落到舞台的 Spine 前置流程(取骨架/图集)并 404。
|
|
489
|
+
const loadDoc = {
|
|
490
|
+
...entry,
|
|
491
|
+
rootId: entry.rootId,
|
|
492
|
+
rootName: entry.rootName,
|
|
493
|
+
entry: entry.entry || (entry.modelUrl ? { url: entry.modelUrl } : null),
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
// 唯一的装载通路:来源解析在 loadModel 内部完成,这里不再按 kind/source 分支
|
|
497
|
+
const loadPromise = loadModel(entry, name);
|
|
498
|
+
loadPromise
|
|
499
|
+
.then((info) => {
|
|
500
|
+
// null = 本次请求被排队/被更新的选择取代(Surface 的 last-wins 语义):
|
|
501
|
+
// 不能当成功读结果字段,否则出现"旧面已释放 + 新面没装"的空白 + TypeError
|
|
502
|
+
if (!info) return; // 被排队/被取代:由真正生效的那次装载负责状态文案
|
|
503
|
+
diagEvent('model.mount', {
|
|
504
|
+
root: loadDoc.rootId || null,
|
|
505
|
+
model: loadDoc.name || null,
|
|
506
|
+
kind: loadDoc.kind || null,
|
|
507
|
+
version: info.version || null,
|
|
508
|
+
animations: Array.isArray(info.animations) ? info.animations.length : 0,
|
|
509
|
+
skins: Array.isArray(info.skins) ? info.skins.length : 0,
|
|
510
|
+
}, { level: 'info', src: 'app' });
|
|
511
|
+
const isL2D = /^Live2D/.test(String(info.version || ''));
|
|
512
|
+
const anims = Array.isArray(info.animations) ? info.animations : [];
|
|
513
|
+
const motion = info.motion || anims[0] || '(无动作)';
|
|
514
|
+
const head = isL2D ? String(info.version) : `Spine ${info.version}`;
|
|
515
|
+
// 抽成 mountEntry 后没有 model 参数:这里用 name(调用方传入的展示名)
|
|
516
|
+
stageStatus(`${name} · ${head} · ${anims.length} 动作 · 播放 ${motion}`, 'info');
|
|
517
|
+
// 运行时组只读:显示本次实际使用的运行时版本(引擎按骨架版本自动选,不可手切)
|
|
518
|
+
if (state.panel) state.panel.setReadonly('ctl-runtime', `Spine ${info.version}`);
|
|
519
|
+
// 模型加载成功 = "现在真的有资源在用内存了" ⇒ 刷新内存三色条(事件驱动,不轮询)
|
|
520
|
+
refreshStage().catch(() => { /* 取不到数据时组件内部已记 warn 并隐藏 */ });
|
|
521
|
+
refreshSkinList();
|
|
522
|
+
fillTransportOptions(info);
|
|
523
|
+
// 底栏已按新清单落到首项(或模型自报的 motion)⇒ 立刻把"当前动作"缓存对齐,
|
|
524
|
+
// 否则右栏刷新会读到上一个模型的 id(换模型后右栏无选中项的真因)
|
|
525
|
+
syncTransportMotionCache();
|
|
526
|
+
// 缓存对齐后再刷一次右栏,保证"底栏 == 右栏高亮"(两处逐项一致,契约 §一/§七)
|
|
527
|
+
try { refreshPanelAnimations(); } catch { /* 动作列表刷新失败不影响主流程 */ }
|
|
528
|
+
document.documentElement.dataset.stageReady = '1';
|
|
529
|
+
// 走到这里才是**真的装载成功**(resolve 成功但 mount 失败的会落到 .catch 里,到不了这行)。
|
|
530
|
+
// 上报服务端:result:'ok' + 实际命中的 provider(`lastMountProvider` 由 loadModel 在 mount 前写入)。
|
|
531
|
+
// 服务端的响应就是重算后的三字段 ⇒ 这一行当场变色、⚠ 消失(不需要刷新页面)。
|
|
532
|
+
// 前端不再自己维护"失败记忆"(原 localStorage 层已删)。
|
|
533
|
+
reportEntryResult(entry, { result: 'ok', provider: lastMountProvider || null });
|
|
534
|
+
})
|
|
535
|
+
.catch((err) => {
|
|
536
|
+
// 被取代属正常竞态结果(不是故障):只记一条 info,不显示红色错误
|
|
537
|
+
if (err && err.aborted) return; // 被取代是正常竞态结果,不报故障(真生效的那次会写状态)
|
|
538
|
+
// 渲染失败属业务错误:显示在舞台提示与 __WEB__ 里,不抛到控制台(验收要求零错误)
|
|
539
|
+
const msg = formatError(err);
|
|
540
|
+
state.stageError = msg;
|
|
541
|
+
stageLog('error', `渲染失败:${msg}`, { model: name, err: msg });
|
|
542
|
+
document.documentElement.dataset.stageReady = 'error';
|
|
543
|
+
// 失败也上报:reason 用渲染器给的结构化码(没有就用 null),文案与列表悬浮提示同源
|
|
544
|
+
reportEntryResult(entry, { result: 'failed', reason: (err && err.reason) || null, message: msg, provider: lastMountProvider || null });
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** 选中模型:先做资产残缺预判,再交给 mountEntry 装载 */
|
|
549
|
+
function onSelectModel(model) {
|
|
550
|
+
|
|
551
|
+
state.selectedModel = model;
|
|
552
|
+
const name = model ? (model.name || model.id) : '无';
|
|
553
|
+
if (!stage) { stageLog('error', `已选中 ${name}(渲染器不可用)`, { model: name, reason: 'no-stage' }); return; }
|
|
554
|
+
if (!model) { stage.unload(); return; }
|
|
555
|
+
|
|
556
|
+
const entry = normalizeEntry(model);
|
|
557
|
+
const kind = entry.kind;
|
|
558
|
+
|
|
559
|
+
// 资产残缺预判(catalog 的 missingTextures):声明了但磁盘缺失的纹理 ⇒ 渲染必失败。
|
|
560
|
+
// **只报明确错误、不自动换源**:同名文件不等于同一份资源(目录版与容器版可能是不同版本),
|
|
561
|
+
// 自动换源会让用户以为"点哪个都一样",也会掩盖真实的数据问题。
|
|
562
|
+
const missingTex = (entry.meta && entry.meta.missingTextures) || [];
|
|
563
|
+
if (missingTex.length) {
|
|
564
|
+
const msg = `该模型缺纹理文件 ${missingTex.join('、')}(资产残缺,无法渲染)。`;
|
|
565
|
+
state.stageError = msg;
|
|
566
|
+
stageLog('error', msg, { source: 'model-actions' });
|
|
567
|
+
document.documentElement.dataset.stageReady = 'error';
|
|
568
|
+
diagEvent('model.incomplete', { model: entry.name || entry.id, missing: missingTex }, { level: 'warn', src: 'app' });
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// 换模型 = 换内容:先把「当前动作」的副本清掉(动作是模型属性,见设计 §1.9)。
|
|
573
|
+
// 必须在这里清而不是装载成功后清:装载失败/被拒时也不该残留上一个模型的动作名,
|
|
574
|
+
// 否则新模型的清单里只要恰好有同名项(碧蓝航线 live2d 全都叫 idle/home/…)就会被继续播。
|
|
575
|
+
resetMotionState();
|
|
576
|
+
// 无残缺(绝大多数情况):直接走统一装载入口
|
|
577
|
+
return mountEntry(entry, name);
|
|
578
|
+
}
|
|
579
|
+
/** 拉资源根并把名册三级树挂到左栏(并按配置决定是否恢复上次选中) */
|
|
580
|
+
/**
|
|
581
|
+
* 装载资源根与名册。
|
|
582
|
+
* @param {{skipRestore?: boolean}} [opts] skipRestore=true 时只刷新树,不重新恢复上次模型
|
|
583
|
+
* (资源根增删改后走这条:用户正在看的内容不该因为改配置被重载一遍)
|
|
584
|
+
*/
|
|
585
|
+
async function loadRoots(opts = {}) {
|
|
586
|
+
try {
|
|
587
|
+
// 资源根与恢复开关互不依赖:并行取,任一失败都不影响另一条(开关失败按"恢复"处理)
|
|
588
|
+
const [data, autoRestore] = await Promise.all([getJson('/api/roots'), loadWebConfig()]);
|
|
589
|
+
const roots = Array.isArray(data?.roots) ? data.roots : [];
|
|
590
|
+
state.roots = roots;
|
|
591
|
+
// 语义上报:资源根即"渲染能力来源",其就绪对应 plugin.load
|
|
592
|
+
diagEvent('plugin.load', {
|
|
593
|
+
roots: roots.length,
|
|
594
|
+
kinds: [...new Set(roots.map((r) => r.kind || 'spine'))],
|
|
595
|
+
ids: roots.map((r) => r.id).slice(0, 12),
|
|
596
|
+
}, { level: 'info', src: 'app' });
|
|
597
|
+
const roster = createRosterView({
|
|
598
|
+
listEl: dom.rosterList,
|
|
599
|
+
countEl: dom.rosterCount,
|
|
600
|
+
searchEl: dom.rosterSearch,
|
|
601
|
+
collapseBtn: dom.btnCollapse,
|
|
602
|
+
onSelect: onSelectModel,
|
|
603
|
+
});
|
|
604
|
+
state.roster = roster;
|
|
605
|
+
// 关闭自动恢复时,连"上次展开的节点"也不还原:保持空舞台、空展开
|
|
606
|
+
await roster.mount(roots, { restoreExpanded: autoRestore });
|
|
607
|
+
state.autoRestore = autoRestore;
|
|
608
|
+
state.restore = (autoRestore && !opts.skipRestore)
|
|
609
|
+
? await roster.restoreLast()
|
|
610
|
+
: { restored: false, reason: opts.skipRestore ? 'roots-changed' : 'config-off' };
|
|
611
|
+
// 资源根管理(F4):首次建实例并渲染;之后每次刷新都用最新列表重画
|
|
612
|
+
if (!state.rootManager) {
|
|
613
|
+
state.rootManager = createRootManager({
|
|
614
|
+
els: {
|
|
615
|
+
list: dom.rootList, msg: dom.rootMsg, count: dom.rootCount,
|
|
616
|
+
triggerBtn: dom.btnAddRoot, toggleBtn: dom.btnRootToggle, body: dom.rootManagerBody,
|
|
617
|
+
// 目录浏览器:点「选择目录」弹出,选完把 {path,name} 交给 submitAdd
|
|
618
|
+
picker: createDirPicker({
|
|
619
|
+
formatError,
|
|
620
|
+
onPick: ({ path, name }) => state.rootManager.submitAdd(path, name),
|
|
621
|
+
// 失败文案由 root-manager 记录(它才知道服务端回了什么),对话框据此显示在自己身上
|
|
622
|
+
getLastError: () => (state.rootManager && typeof state.rootManager.getLastAddError === 'function'
|
|
623
|
+
? state.rootManager.getLastAddError()
|
|
624
|
+
: ''),
|
|
625
|
+
}),
|
|
626
|
+
},
|
|
627
|
+
formatError,
|
|
628
|
+
onChanged: () => { loadRoots({ skipRestore: true }); },
|
|
629
|
+
});
|
|
630
|
+
window.__WEB__ && (window.__WEB__.rootManager = state.rootManager);
|
|
631
|
+
}
|
|
632
|
+
// 只在首次用 GET 的列表渲染:GET 只返回启用项,之后任何刷新都不能拿它覆盖
|
|
633
|
+
// "含停用项"的完整列表(停用的根会从管理区凭空消失,用户就再也启用不回来)
|
|
634
|
+
if (!state.rootManagerRendered) {
|
|
635
|
+
state.rootManager.render(roots);
|
|
636
|
+
state.rootManagerRendered = true;
|
|
637
|
+
}
|
|
638
|
+
// 目录浏览器默认起点:最后一个已有根所在的父目录(没有就从 / 开始)
|
|
639
|
+
if (state.rootManager && roots.length && typeof state.rootManager.setPickerStart === 'function') {
|
|
640
|
+
const last = roots[roots.length - 1].path || '';
|
|
641
|
+
const idx = Math.max(last.lastIndexOf('/'), last.lastIndexOf('\\'));
|
|
642
|
+
state.rootManager.setPickerStart(idx > 0 ? last.slice(0, idx) : '');
|
|
643
|
+
}
|
|
644
|
+
// 供自动化断言:资源根 id 序列 + 恢复结果
|
|
645
|
+
document.documentElement.dataset.rootsIds = roots.map((r) => r.id).join(',');
|
|
646
|
+
document.documentElement.dataset.rootsReady = '1';
|
|
647
|
+
document.documentElement.dataset.restore = JSON.stringify(state.restore);
|
|
648
|
+
if (!state.restore.restored) {
|
|
649
|
+
}
|
|
650
|
+
} catch (err) {
|
|
651
|
+
state.rootsError = formatError(err);
|
|
652
|
+
document.documentElement.dataset.rootsReady = 'error';
|
|
653
|
+
showRootsError(state.rootsError);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* 切换模型的**统一入口**(所有"不是用户点左栏"的切换都走这里)。
|
|
659
|
+
*
|
|
660
|
+
* 一次切换要同步的东西不少:装载本身(底栏皮肤/动作、右栏列表、渲染)+ 左栏树的高亮/展开/定位。
|
|
661
|
+
* 分散在各入口手写就会漏(实测:翻页时左栏高亮停在上一个模型)。
|
|
662
|
+
* @param {object} model 模型条目(catalog entry 或补过 entry 的条目)
|
|
663
|
+
* @param {{syncTree?: boolean}} [opts] syncTree=false 时不碰左栏(左栏自己点选时用)
|
|
664
|
+
* @returns {unknown} onSelectModel 的结果
|
|
665
|
+
*/
|
|
666
|
+
function selectModel(model, opts = {}) {
|
|
667
|
+
if (!model) return null;
|
|
668
|
+
const roster = state.roster;
|
|
669
|
+
if (opts.syncTree !== false && roster && typeof roster.selectById === 'function') {
|
|
670
|
+
// 左栏同步可能要懒加载游戏(异步):失败不影响装载,只影响高亮
|
|
671
|
+
Promise.resolve(roster.selectById(model.id)).catch(() => { /* 高亮失败不影响播放 */ });
|
|
672
|
+
}
|
|
673
|
+
return onSelectModel(model);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
export { refreshSkinList, showRootsError, onSelectModel, selectModel, loadRoots };
|
|
677
|
+
|
|
678
|
+
// 面板重建后需要按当前模型重刷皮肤列表:把本模块的实现注入回去(避免循环依赖)
|
|
679
|
+
connectPanelRefresh(refreshSkinList);
|
|
680
|
+
|
|
681
|
+
// 导入项目配置后重载资源根与名册树:注入本模块的 loadRoots(不重载当前模型)
|
|
682
|
+
connectReloadApp(() => loadRoots({ skipRestore: true }));
|