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,269 @@
|
|
|
1
|
+
// infra/document.js —— Model Document 的组装层(Assets 层,document/v1)
|
|
2
|
+
//
|
|
3
|
+
// 职责(契约 Document契约 §一/§二):把 catalog entry 变成 **ready 的 Document**——
|
|
4
|
+
// · 资源地址一律先经 VirtualFS 解析(命中用虚拟地址、未命中回落原 URL,与 M3 的两套前置同一口径)
|
|
5
|
+
// · 取字节/读定义也在这里发生(这是 Assets 层的工作);渲染器只拿"资源在哪 + 校验结果"
|
|
6
|
+
// · 失败不产半成品:state='error' + warnings 写明可读原因,绝不进入渲染层
|
|
7
|
+
//
|
|
8
|
+
// 为什么取资源必须在这一层:渲染器目录(spine/、live2d/、render/)里不允许出现模型资源的
|
|
9
|
+
// fetch —— 一旦渲染器自己取资源,就会出现"取失败分支"在渲染层里长出来(契约 D4)。
|
|
10
|
+
// 把这件事收到这里,渲染器只消费地址,取不到的情况在进入渲染层之前就已经变成 error 文档。
|
|
11
|
+
//
|
|
12
|
+
// 说明(M4 阶段的能力边界,已在交付说明里如实登记):
|
|
13
|
+
// · skeleton.bones / slots 需要 Spine 运行时解析 .skel 才能得到,属引擎职责;
|
|
14
|
+
// 本轮只保证**形状齐全**(空数组),animations/skins 能拿到的(Live2D 定义、Spine 条目元数据)照填。
|
|
15
|
+
// · Document 不注册 VFS 前缀(注册由提供者负责),因此 dispose() 只撤销"本层自己注册过的"前缀;
|
|
16
|
+
// 当前没有这种前缀 ⇒ dispose 是幂等空操作,绝不会和别处的 revoke 撞车(无双重撤销)。
|
|
17
|
+
import { getSharedVirtualFS } from './vfs.js';
|
|
18
|
+
// 内容类型是否受支持 = 查渲染面元数据表(唯一真相在 render/surfaces.js)
|
|
19
|
+
import { isKnownKind } from '../render/surfaces.js';
|
|
20
|
+
// 模型文件必须走带重试的 fetch:几十个并发裸 fetch 遇上 keep-alive race 会直接 Failed to fetch,
|
|
21
|
+
// 一个文件失败整个模型就画不出来(现场表现:Spine 空白、资源包正常——后者走 blob 不过网络)。
|
|
22
|
+
import { fetchWithRetry } from './api.js';
|
|
23
|
+
|
|
24
|
+
/** 默认取共享 VFS 实例(与渲染器、验收脚本同一个) */
|
|
25
|
+
function pickVfs(deps) {
|
|
26
|
+
if (deps && deps.vfs) return deps.vfs;
|
|
27
|
+
try {
|
|
28
|
+
return getSharedVirtualFS();
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 资源地址解析:命中用虚拟地址,未命中回落原 URL;异常一律回落 */
|
|
35
|
+
function resolveUrl(vfs, url) {
|
|
36
|
+
if (!url) return null;
|
|
37
|
+
try {
|
|
38
|
+
const hit = vfs && typeof vfs.resolve === 'function' ? vfs.resolve(url) : null;
|
|
39
|
+
return hit || url;
|
|
40
|
+
} catch {
|
|
41
|
+
return url;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 读一个资源的字节;失败只记 warning,不抛(由调用方汇总成 error 文档) */
|
|
46
|
+
async function readBytes(url, label, warnings) {
|
|
47
|
+
try {
|
|
48
|
+
// 带重试:网络错误 / 5xx 会退避重试(4xx 不重试)
|
|
49
|
+
const res = await fetchWithRetry(url, { timeoutMs: 8000, label: 'model-bytes' });
|
|
50
|
+
if (!res || !res.ok) {
|
|
51
|
+
warnings.push(`${label} 读取失败:HTTP ${(res && res.status) || '?'}(${url})`);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const buf = await res.arrayBuffer();
|
|
55
|
+
if (!buf || !buf.byteLength) {
|
|
56
|
+
warnings.push(`${label} 内容为空(${url})`);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return buf;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
warnings.push(`${label} 读取失败:${(e && e.message) || e}(${url})`);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 判定内容类型:显式 kind 优先,其次按条目形状推断 */
|
|
67
|
+
function detectKind(entry) {
|
|
68
|
+
if (!entry) return null;
|
|
69
|
+
// 显式 kind 只认**受支持的渲染面**(查表,不写死枚举)。
|
|
70
|
+
// 原先这里是 `kind === 'spine' || kind === 'live2d'` —— 加第三个面时它会把 3d 判成 null,
|
|
71
|
+
// 而 null 在 buildDocument 里意味着 state='error',于是新面永远进不了渲染层且不报"不支持"。
|
|
72
|
+
if (isKnownKind(entry.kind)) return entry.kind;
|
|
73
|
+
if (entry.skeletonFile) return 'spine';
|
|
74
|
+
if (entry.modelUrl || (entry.entry && entry.entry.url)) return 'live2d';
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 组装公共只读字段(kind/source/provider/根信息/state/warnings 等) */
|
|
79
|
+
function baseDocument(entry, kind, vfs) {
|
|
80
|
+
return {
|
|
81
|
+
// ── 只读(契约 §一)
|
|
82
|
+
id: entry.id || null,
|
|
83
|
+
kind,
|
|
84
|
+
source: entry.source || (entry.provider ? 'bundle' : 'files'),
|
|
85
|
+
provider: entry.provider || null,
|
|
86
|
+
rootId: entry.rootId || (entry.root && entry.root.id) || null,
|
|
87
|
+
rootName: entry.rootName || (entry.root && entry.root.name) || null,
|
|
88
|
+
name: entry.name || entry.id || null,
|
|
89
|
+
srcDir: entry.srcDir || entry.dir || null,
|
|
90
|
+
assets: { entry: null, textures: [], atlas: null },
|
|
91
|
+
skeleton: { bones: [], slots: [], animations: [], skins: [] },
|
|
92
|
+
meta: { version: entry.version || null, bbox: null, motions: null, expressions: null, motionDurations: {}, extras: {} },
|
|
93
|
+
state: 'ready',
|
|
94
|
+
warnings: [],
|
|
95
|
+
// ── 可写(契约 §六:只留形状,加编辑器时实现)
|
|
96
|
+
params: {},
|
|
97
|
+
dirty: false,
|
|
98
|
+
history: { undo() {}, redo() {} },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Spine:骨架 / atlas / 贴图页三件套(地址已解析,字节一并带上供引擎消费) */
|
|
103
|
+
async function buildSpineAssets(doc, entry, vfs) {
|
|
104
|
+
const warn = doc.warnings;
|
|
105
|
+
const skelUrl = resolveUrl(vfs, entry.skeletonFile && entry.skeletonFile.url);
|
|
106
|
+
const atlasUrl = resolveUrl(vfs, entry.atlasFile && entry.atlasFile.url);
|
|
107
|
+
const texEntries = Array.isArray(entry.textureFiles) ? entry.textureFiles : [];
|
|
108
|
+
if (!skelUrl) warn.push('缺少骨架地址(skeletonFile.url)');
|
|
109
|
+
if (!atlasUrl) warn.push('缺少 atlas 地址(atlasFile.url)');
|
|
110
|
+
if (!texEntries.length) warn.push('缺少贴图页(textureFiles 为空)');
|
|
111
|
+
|
|
112
|
+
const [skelBytes, atlasBytes] = await Promise.all([
|
|
113
|
+
skelUrl ? readBytes(skelUrl, '骨架', warn) : null,
|
|
114
|
+
atlasUrl ? readBytes(atlasUrl, 'atlas', warn) : null,
|
|
115
|
+
]);
|
|
116
|
+
doc.assets.entry = { role: 'skeleton', name: (entry.skeletonFile && entry.skeletonFile.name) || '', url: skelUrl, bytes: skelBytes };
|
|
117
|
+
doc.assets.atlas = { role: 'atlas', name: (entry.atlasFile && entry.atlasFile.name) || '', url: atlasUrl, bytes: atlasBytes };
|
|
118
|
+
|
|
119
|
+
for (const tex of texEntries) {
|
|
120
|
+
const url = resolveUrl(vfs, tex && tex.url);
|
|
121
|
+
if (!url) { warn.push(`贴图缺地址:${(tex && tex.name) || '?'}`); continue; }
|
|
122
|
+
const bytes = await readBytes(url, `贴图 ${tex.name || ''}`, warn);
|
|
123
|
+
doc.assets.textures.push({ role: 'texture', name: (tex && tex.name) || '', url, bytes });
|
|
124
|
+
}
|
|
125
|
+
// 动画/皮肤快照:条目里能给的先填(引擎返回的更权威,但 Document 只读部分不应该等运行时)
|
|
126
|
+
const animations = Array.isArray(entry.animations) ? entry.animations : [];
|
|
127
|
+
doc.skeleton.animations = animations.map((a) => (typeof a === 'string' ? a : (a && (a.name || a.Name)) || '')).filter(Boolean);
|
|
128
|
+
doc.skeleton.skins = Array.isArray(entry.skins) ? entry.skins.slice() : [];
|
|
129
|
+
doc.meta.extras = { atlasPma: entry.atlasPma === undefined ? null : entry.atlasPma };
|
|
130
|
+
return doc;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 动作时长缓存:定义地址 → { 动作文件 → 秒数 }(跨多次组装复用) */
|
|
134
|
+
const motionDurationCache = new Map();
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 预取 Live2D 每个动作文件的时长(Meta.Duration)。
|
|
138
|
+
*
|
|
139
|
+
* 为什么放在 Assets 层:时长要读动作文件,而"取模型资源"是 Assets 层的职责——
|
|
140
|
+
* 渲染器里不允许 fetch(契约 D4)。之前这套逻辑在 live2d/bridge 里,收到本层后
|
|
141
|
+
* 渲染器只读 document.meta.motionDurations,动作时长不会因为"没人预取"而整片消失。
|
|
142
|
+
*
|
|
143
|
+
* 键用**动作文件相对路径**(model3.json 里的 File 原值,如 `motions/idle.motion3.json`):
|
|
144
|
+
* 动作标签(`group / 文件名`)的拼法只有一处实现(live2d/model.js 的 motionLabel),
|
|
145
|
+
* 这里再写一份必然漂移;桥接层拿标签对应的文件来查即可。
|
|
146
|
+
*
|
|
147
|
+
* 取不到就**不写这个键**(不填 0 冒充),前端自然显示为"无时长"。
|
|
148
|
+
* @param {object} doc Document(就地写 doc.meta.motionDurations)
|
|
149
|
+
* @param {object} ref model3.json 的 FileReferences
|
|
150
|
+
* @param {string} baseRef **解析前**的模型定义地址(vfs:// 或 http://)——
|
|
151
|
+
* 动作文件的相对路径必须以它为基址拼接:拿解析后的 blob 当基址会拼出
|
|
152
|
+
* `blob:…/motions/x.motion3.json` 这种永不命中的地址(资源包时长整片 null 的真因)。
|
|
153
|
+
* @param {string} entryUrl 定义文件地址(用于把相对 File 拼成可读地址)
|
|
154
|
+
* @param {object} vfs VirtualFS
|
|
155
|
+
*/
|
|
156
|
+
async function fillMotionDurations(doc, ref, baseRef, vfs) {
|
|
157
|
+
// 同一模型反复组装(切模型来回点、测试反复装载)时不必重复预取:按定义地址缓存一份。
|
|
158
|
+
// 14 个动作文件就是 14 次请求,重复预取既慢又给测试增加时序抖动。
|
|
159
|
+
const cached = motionDurationCache.get(baseRef);
|
|
160
|
+
if (cached) { doc.meta.motionDurations = { ...cached }; return; }
|
|
161
|
+
const base = String(baseRef || '').replace(/[^/]*$/, '');
|
|
162
|
+
const files = [];
|
|
163
|
+
for (const group of Object.keys(ref.Motions || {})) {
|
|
164
|
+
for (const mo of (ref.Motions[group] || [])) {
|
|
165
|
+
const file = mo && (mo.File || mo.Name);
|
|
166
|
+
if (file && !files.includes(file)) files.push(file);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const out = {};
|
|
170
|
+
const jobs = files.map(async (file) => {
|
|
171
|
+
const abs = /^[a-z][a-z0-9+.-]*:/i.test(file) || file.startsWith('/') ? file : `${base}${file}`;
|
|
172
|
+
const url = resolveUrl(vfs, abs);
|
|
173
|
+
try {
|
|
174
|
+
const res = await fetchWithRetry(url, { timeoutMs: 8000, label: 'motion3' });
|
|
175
|
+
if (!res || !res.ok) return;
|
|
176
|
+
const json = await res.json();
|
|
177
|
+
const d = Number(json && json.Meta && json.Meta.Duration);
|
|
178
|
+
if (Number.isFinite(d) && d > 0) out[file] = d;
|
|
179
|
+
} catch { /* 单个动作读不到就跳过,不影响其它动作与整体装载 */ }
|
|
180
|
+
});
|
|
181
|
+
await Promise.all(jobs);
|
|
182
|
+
doc.meta.motionDurations = out;
|
|
183
|
+
motionDurationCache.set(baseRef, out);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Live2D:入口 model3.json 定义(地址已解析;内部引用由 live2d/vfs-settings 在装载时解析) */
|
|
187
|
+
async function buildLive2dAssets(doc, entry, vfs) {
|
|
188
|
+
const warn = doc.warnings;
|
|
189
|
+
const raw = (entry.entry && entry.entry.url) || entry.modelUrl || null;
|
|
190
|
+
const url = resolveUrl(vfs, raw);
|
|
191
|
+
if (!url) { warn.push('缺少模型定义地址(entry.url / modelUrl)'); return doc; }
|
|
192
|
+
// ref 保留**解析前**的原始地址:Live2D 内部相对引用要靠它推出 vfs 前缀
|
|
193
|
+
// (url 是解析后的 blob,blob 不能当基址;vfs-settings 需要 vfs:// 形态才能把
|
|
194
|
+
// motions/x.motion3.json 这类引用映射回已注册的前缀)。
|
|
195
|
+
doc.assets.entry = { role: 'definition', name: raw.split('/').pop() || '', url, ref: raw, bytes: null };
|
|
196
|
+
try {
|
|
197
|
+
const res = await fetchWithRetry(url, { timeoutMs: 8000, label: 'model-def' });
|
|
198
|
+
if (!res || !res.ok) {
|
|
199
|
+
warn.push(`模型定义读取失败:HTTP ${(res && res.status) || '?'}(${url})`);
|
|
200
|
+
return doc;
|
|
201
|
+
}
|
|
202
|
+
const json = await res.json();
|
|
203
|
+
const ref = (json && json.FileReferences) || {};
|
|
204
|
+
const motions = ref.Motions || {};
|
|
205
|
+
doc.meta.version = json && json.Version ? Number(json.Version) : doc.meta.version;
|
|
206
|
+
// meta.motions 必须是**完整映射** { 组: [{ File, ... }] },不是组名数组:
|
|
207
|
+
// 渲染层要凭 File 去查预取时长表(live2d/bridge.js 的 motionFileOf),
|
|
208
|
+
// 只留组名的话 duration 一律取不到 ⇒ 动作列表时长整片 null(冷缓存下 34/34 全空)。
|
|
209
|
+
doc.meta.motions = motions;
|
|
210
|
+
doc.meta.expressions = (ref.Expressions || []).map((e) => (e && e.Name) || '');
|
|
211
|
+
doc.skeleton.animations = Object.keys(motions);
|
|
212
|
+
doc.meta.extras = { moc: ref.Moc || null, textures: (ref.Textures || []).slice(), json };
|
|
213
|
+
// 传 raw(解析前地址)而不是 url(blob):相对路径要相对"来源"拼,不是相对 blob
|
|
214
|
+
await fillMotionDurations(doc, ref, raw, vfs);
|
|
215
|
+
} catch (e) {
|
|
216
|
+
warn.push(`模型定义解析失败:${(e && e.message) || e}(${url})`);
|
|
217
|
+
}
|
|
218
|
+
return doc;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 给 Document 装上幂等的 dispose(契约 D2) */
|
|
222
|
+
function attachDispose(doc, vfs) {
|
|
223
|
+
let disposed = false;
|
|
224
|
+
// 本层不注册前缀(注册是提供者的职责)⇒ 通常没有要撤的前缀;
|
|
225
|
+
// 若将来本层自己注册,把前缀记在 doc._ownedPrefix 上,这里会撤且只撤一次。
|
|
226
|
+
doc.dispose = function dispose() {
|
|
227
|
+
if (disposed) return false;
|
|
228
|
+
disposed = true;
|
|
229
|
+
const prefix = doc._ownedPrefix;
|
|
230
|
+
if (prefix && vfs && typeof vfs.revoke === 'function') {
|
|
231
|
+
try { vfs.revoke(prefix); } catch { /* 撤销失败不影响幂等语义 */ }
|
|
232
|
+
}
|
|
233
|
+
return true;
|
|
234
|
+
};
|
|
235
|
+
return doc;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 把 catalog entry 变成 ready/error 的 Document。
|
|
240
|
+
* @param {object} entry catalog 条目(可能是模型条目,也可能已带 kind)
|
|
241
|
+
* @param {{vfs?: object, log?: Function}} [deps]
|
|
242
|
+
* @returns {Promise<object>} Document(state 只会是 'ready' 或 'error')
|
|
243
|
+
*/
|
|
244
|
+
export async function buildDocument(entry, deps = {}) {
|
|
245
|
+
const vfs = pickVfs(deps);
|
|
246
|
+
const kind = detectKind(entry);
|
|
247
|
+
if (!entry || !kind) {
|
|
248
|
+
const bad = baseDocument(entry || {}, null, vfs);
|
|
249
|
+
bad.state = 'error';
|
|
250
|
+
bad.warnings.push(entry ? `无法判定内容类型(既没有 skeletonFile 也没有 entry.url/modelUrl)` : '缺少 catalog 条目');
|
|
251
|
+
return attachDispose(bad, vfs);
|
|
252
|
+
}
|
|
253
|
+
const doc = baseDocument(entry, kind, vfs);
|
|
254
|
+
try {
|
|
255
|
+
// 组装分支**显式列全 + 未知抛错**,不再用 `if spine else live2d` 的二分。
|
|
256
|
+
// 二分写法隐含"只有两个面":加第三个面时它会被当 live2d 组装,产出一份字段错位的 Document
|
|
257
|
+
// (不报错、只是后面渲染乱),比直接失败难排得多。
|
|
258
|
+
if (kind === 'spine') await buildSpineAssets(doc, entry, vfs);
|
|
259
|
+
else if (kind === 'live2d') await buildLive2dAssets(doc, entry, vfs);
|
|
260
|
+
else throw new Error(`不支持的渲染面「${kind}」:组装分支未在 render/surfaces.js 登记`);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
doc.warnings.push(`组装异常:${(e && e.message) || e}`);
|
|
263
|
+
}
|
|
264
|
+
// 任何 warning 都意味着"资源不全" ⇒ 不进入渲染层(契约 §二 硬规则 1)
|
|
265
|
+
if (doc.warnings.length) doc.state = 'error';
|
|
266
|
+
return attachDispose(doc, vfs);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export default buildDocument;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// infra/dom.js —— 最小 DOM 工厂(跨模块公共层)
|
|
2
|
+
//
|
|
3
|
+
// 本文件是 `el()` 的**唯一实现**。panel/dom.js 与 roster/rows.js 只是 re-export ——
|
|
4
|
+
// **不要在任何地方再新增 el 的实现**(历史上曾有三份各自漂移:panel / roster / debug)。
|
|
5
|
+
// 判据是"函数体只有一份",不是"文本只出现一次":re-export 不算第二份实现。
|
|
6
|
+
//
|
|
7
|
+
// 为什么收在 infra/:panel / roster / debug 三个功能层都要建节点,
|
|
8
|
+
// 而这个工厂与业务无关;放在任何单一功能层都会让另外两层反向依赖它。
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 建元素:tag + class + 文本。
|
|
12
|
+
*
|
|
13
|
+
* 三处判空的语义(改动前历史实现一致,逐条都是刻意的,别"简化"):
|
|
14
|
+
* · className 用 falsy 判空 ⇒ 传 '' / undefined / null / 0 都**不设 class 属性**
|
|
15
|
+
* (不是设成空字符串 —— 那会在 outerHTML 里留下 `class=""`)
|
|
16
|
+
* · text 用 `!== undefined && !== null` 判空 ⇒ **`0` 与 `''` 会被写入**
|
|
17
|
+
* (数字 0 是常见内容,用 falsy 判空会把 0 吞掉)
|
|
18
|
+
* · text 一律 `String(...)` 后再写入 ⇒ 传对象得到 "[object Object]",
|
|
19
|
+
* 但调用方实际传的通常是对象索引取值后的字符串(见 panel/view.js)
|
|
20
|
+
*
|
|
21
|
+
* @param {string} tag 标签名
|
|
22
|
+
* @param {string} [className] 类名(可空)
|
|
23
|
+
* @param {string|number} [text] 文本内容(0 与空串都会写入)
|
|
24
|
+
* @returns {HTMLElement}
|
|
25
|
+
*/
|
|
26
|
+
export function el(tag, className, text) {
|
|
27
|
+
const node = document.createElement(tag);
|
|
28
|
+
if (className) node.className = className;
|
|
29
|
+
if (text !== undefined && text !== null) node.textContent = String(text);
|
|
30
|
+
return node;
|
|
31
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// src/web/infra/events.mjs —— 宿主对外事件的**协议常量**(唯一来源)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要单独一个文件:这些事件名原先以字符串字面量散落在多个文件里
|
|
4
|
+
// (派发点与监听点各写一遍),任何批量重命名都可能顺手命中它 ——
|
|
5
|
+
// 真发生过:`spineviewer:plugins-loaded` 被批量替换成 `spineviewer:plugins-ctx.loaded`,
|
|
6
|
+
// 于是"卸载后面板不重建 / document.files 就绪后不重绘",不报错、只静默失效。
|
|
7
|
+
// 收成常量 + 一条"不许再出现字面量"的单测(test/events-const.test.mjs)后,
|
|
8
|
+
// 改坏会当场红,而不是等到用户发现功能没反应。
|
|
9
|
+
//
|
|
10
|
+
// 使用口径:
|
|
11
|
+
// · 派发:window.dispatchEvent(new CustomEvent(EVENTS.PLUGINS_LOADED, { detail }))
|
|
12
|
+
// · 监听:window.addEventListener(EVENTS.PLUGINS_LOADED, handler)
|
|
13
|
+
// · **不要**在任何业务文件里再写 `'spineviewer:...'` 字面量(单测会拦)
|
|
14
|
+
//
|
|
15
|
+
// 加新事件:在这里登记一个键,然后派发/监听两端都用常量;事件名本身是外部契约
|
|
16
|
+
// (面板/插件可能按名字监听),改动要当成破坏性变更对待。
|
|
17
|
+
|
|
18
|
+
export const EVENTS = Object.freeze({
|
|
19
|
+
/** 插件装载/卸载完成(面板侧据此重建;detail: { reason? , unloaded? }) */
|
|
20
|
+
PLUGINS_LOADED: 'spineviewer:plugins-loaded',
|
|
21
|
+
/** 单个插件注册成功(detail: { id }) */
|
|
22
|
+
PLUGIN_REGISTERED: 'spineviewer:plugin-registered',
|
|
23
|
+
/** 插件向宿主上报的通知(detail: { kind, msg }) */
|
|
24
|
+
NOTIFY: 'spineviewer:notify',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export default EVENTS;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// infra/log.js —— 统一的日志工厂(调试日志整理的第 ① 步)
|
|
2
|
+
//
|
|
3
|
+
// 解决的问题:现在页面里同时存在四条各写各的通路(diag.push / stageStatus / diagEvent / console 镜像),
|
|
4
|
+
// 同一件事可能从两条路进来,级别口径也不一致。这里只定义"一条日志记录长什么样 + 怎么写",
|
|
5
|
+
// **不接管任何现有调用点**:现有通路改由适配层转调(第 ② 步做),因此本模块可以独立上线、独立测试。
|
|
6
|
+
//
|
|
7
|
+
// 四档的分界不是"严重程度",而是"给谁看":
|
|
8
|
+
// debug 给机器:事件名 + 结构化 detail,字段名稳定可解析(断言/回归对比靠它)
|
|
9
|
+
// info 给人和 AI:一句人话,正常流程(设置回显、操作确认、进度)
|
|
10
|
+
// warn 给人和 AI:人话 + 为什么会这样(预期内的失败、可降级)
|
|
11
|
+
// error 给人和 AI:人话 + 可排查上下文(错误对象、输入)
|
|
12
|
+
//
|
|
13
|
+
// 记录形状(四档完全一致,只有 level 不同):
|
|
14
|
+
// { at, level, tag, event?, msg?, detail?, src }
|
|
15
|
+
// 可选字段:debug 必须给 event;info/warn/error 必须给 msg。detail 可选,但给了就保持结构化。
|
|
16
|
+
|
|
17
|
+
/** 允许的级别(顺序即严重度递增,面板按此排序) */
|
|
18
|
+
export const LOG_LEVELS = ['debug', 'info', 'warn', 'error'];
|
|
19
|
+
|
|
20
|
+
/** 允许的来源:app=本应用主动打的;console=包装原生 console 抓到的(可能混第三方);server=服务端转发 */
|
|
21
|
+
export const LOG_SOURCES = ['app', 'console', 'server'];
|
|
22
|
+
|
|
23
|
+
/** 内存里保留多少条(够面板展示与测试取用,又不至于无限涨) */
|
|
24
|
+
export const LOG_LIMIT = 500;
|
|
25
|
+
|
|
26
|
+
/** 环形缓冲:最近若干条记录(面板/测试/自检都从这里取,避免各自再存一份) */
|
|
27
|
+
const buffer = [];
|
|
28
|
+
/** 订阅者:每条新记录都会同步推给他们(面板渲染、测试断言) */
|
|
29
|
+
const sinks = new Set();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 把 detail 归一成"结构化但可序列化"的值。
|
|
33
|
+
*
|
|
34
|
+
* 关键点:**对象必须原样保留**,绝不能 String() —— 一旦变成 "[object Object]",机器就没法解析了
|
|
35
|
+
* (这也是 debug 级存在的唯一理由)。Error 不是普通可枚举对象,单独拆成可读字段。
|
|
36
|
+
* @param {unknown} detail
|
|
37
|
+
* @returns {object|undefined} 归一后的 detail;没给就返回 undefined
|
|
38
|
+
*/
|
|
39
|
+
function normalizeDetail(detail) {
|
|
40
|
+
if (detail === undefined || detail === null) return undefined;
|
|
41
|
+
if (detail instanceof Error) {
|
|
42
|
+
return { name: detail.name, message: detail.message, stack: detail.stack || '' };
|
|
43
|
+
}
|
|
44
|
+
if (typeof detail === 'object') return detail; // 保留结构(数组/普通对象都原样带走)
|
|
45
|
+
return { value: detail }; // 原始值包一层,保证 detail 永远是对象
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 组装一条记录(四档共用,保证形状一致)。
|
|
50
|
+
* @param {string} level 级别(必须在 LOG_LEVELS 内)
|
|
51
|
+
* @param {string} tag 模块标签
|
|
52
|
+
* @param {object} fields { event?, msg?, detail? }
|
|
53
|
+
* @param {'app'|'console'|'server'} src 来源
|
|
54
|
+
* @returns {object} 统一形状的记录
|
|
55
|
+
*/
|
|
56
|
+
function makeRecord(level, tag, fields, src) {
|
|
57
|
+
const record = { at: Date.now(), level, tag, src };
|
|
58
|
+
if (fields.event) record.event = String(fields.event);
|
|
59
|
+
if (fields.msg !== undefined && fields.msg !== null && fields.msg !== '') record.msg = String(fields.msg);
|
|
60
|
+
const detail = normalizeDetail(fields.detail);
|
|
61
|
+
if (detail !== undefined) record.detail = detail;
|
|
62
|
+
return record;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 写入一条记录:进环形缓冲 + 推给订阅者(订阅者异常不能反过来影响业务) */
|
|
66
|
+
function push(record) {
|
|
67
|
+
buffer.push(record);
|
|
68
|
+
if (buffer.length > LOG_LIMIT) buffer.splice(0, buffer.length - LOG_LIMIT);
|
|
69
|
+
for (const sink of sinks) {
|
|
70
|
+
try { sink(record); } catch { /* 消费方自己出错不该污染日志通路 */ }
|
|
71
|
+
}
|
|
72
|
+
return record;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 创建一个带模块标签的 logger。
|
|
77
|
+
*
|
|
78
|
+
* @param {string} tag 模块标签(进每条记录,便于按模块过滤;如 'unpack' / 'panel' / 'stage')
|
|
79
|
+
* @param {{src?: 'app'|'console'|'server'}} [opts] src 缺省为 'app'
|
|
80
|
+
* @returns {{
|
|
81
|
+
* debug: (event: string, detail?: object) => object,
|
|
82
|
+
* info: (msg: string, detail?: object) => object,
|
|
83
|
+
* warn: (msg: string, detail?: object) => object,
|
|
84
|
+
* error: (msg: string, detail?: object) => object,
|
|
85
|
+
* }}
|
|
86
|
+
*/
|
|
87
|
+
export function createLogger(tag, opts) {
|
|
88
|
+
if (typeof tag !== 'string' || !tag) throw new TypeError('createLogger: tag 必须是非空字符串');
|
|
89
|
+
const src = (opts && opts.src) || 'app';
|
|
90
|
+
if (!LOG_SOURCES.includes(src)) {
|
|
91
|
+
throw new TypeError(`createLogger: src 必须是 ${LOG_SOURCES.join('/')} 之一(收到 ${String(src)})`);
|
|
92
|
+
}
|
|
93
|
+
const name = String(tag);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
/**
|
|
97
|
+
* 机器友好日志:**必须给事件名**(点分命名,如 'unpack.pair'),detail 用固定键。
|
|
98
|
+
* 只给 msg 不给 event 会直接抛错——否则它就退化成了一条"人话日志",
|
|
99
|
+
* 而 debug 级存在的唯一理由就是"字段名稳定、可被机器解析"。
|
|
100
|
+
*/
|
|
101
|
+
debug(event, detail) {
|
|
102
|
+
if (typeof event !== 'string' || !event) {
|
|
103
|
+
throw new TypeError('log.debug: 必须给事件名(点分命名,如 "unpack.pair"),不能只给 msg');
|
|
104
|
+
}
|
|
105
|
+
if (!/^[a-z][\w-]*(\.[\w-]+)+$/i.test(event)) {
|
|
106
|
+
throw new TypeError(`log.debug: 事件名应点分命名(收到 "${event}")`);
|
|
107
|
+
}
|
|
108
|
+
return push(makeRecord('debug', name, { event, detail }, src));
|
|
109
|
+
},
|
|
110
|
+
/** 一句人话,正常流程(设置回显 / 操作确认 / 进度) */
|
|
111
|
+
info(msg, detail) {
|
|
112
|
+
if (typeof msg !== 'string' || !msg) throw new TypeError('log.info: 必须给一句人话 msg');
|
|
113
|
+
return push(makeRecord('info', name, { msg, detail }, src));
|
|
114
|
+
},
|
|
115
|
+
/** 人话 + 为什么会这样(预期内的失败、可降级的情况) */
|
|
116
|
+
warn(msg, detail) {
|
|
117
|
+
if (typeof msg !== 'string' || !msg) throw new TypeError('log.warn: 必须给一句人话 msg');
|
|
118
|
+
return push(makeRecord('warn', name, { msg, detail }, src));
|
|
119
|
+
},
|
|
120
|
+
/** 人话 + 可排查上下文(非预期失败:错误对象、输入、状态) */
|
|
121
|
+
error(msg, detail) {
|
|
122
|
+
if (typeof msg !== 'string' || !msg) throw new TypeError('log.error: 必须给一句人话 msg');
|
|
123
|
+
return push(makeRecord('error', name, { msg, detail }, src));
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 订阅新记录(面板渲染、测试断言用)。
|
|
130
|
+
* @param {(record: object) => void} sink
|
|
131
|
+
* @returns {() => void} 取消订阅
|
|
132
|
+
*/
|
|
133
|
+
export function subscribe(sink) {
|
|
134
|
+
if (typeof sink !== 'function') throw new TypeError('subscribe: 必须传函数');
|
|
135
|
+
sinks.add(sink);
|
|
136
|
+
return () => { sinks.delete(sink); };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 取最近的若干条记录(不传就全取,受 LOG_LIMIT 限制)。
|
|
141
|
+
* @param {number} [n]
|
|
142
|
+
* @returns {object[]} 记录的浅拷贝数组(外部改它不影响内部缓冲)
|
|
143
|
+
*/
|
|
144
|
+
export function recent(n) {
|
|
145
|
+
if (n === undefined || n === null) return buffer.slice();
|
|
146
|
+
const count = Math.max(0, Math.floor(Number(n) || 0));
|
|
147
|
+
return count ? buffer.slice(-count) : [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 清空缓冲区(只清日志,不动订阅者;测试与"清空日志"按钮都用它) */
|
|
151
|
+
export function clearLogs() {
|
|
152
|
+
buffer.length = 0;
|
|
153
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// infra/mem-stats.js —— 资源与上下文计数(**只读观测**,不改变任何清理/渲染逻辑)
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它:现场症状是"加载资源包/切 Live2D 之后画布黑 + 网络失败",
|
|
4
|
+
// 而 `stage.stats().contexts` 只是 `gl ? 1 : 0`(恒 0/1),**验证不了泄漏**。
|
|
5
|
+
// 浏览器对 WebGL 上下文有硬上限(约 16 个),超了会丢弃最旧的 ⇒ 画布黑。
|
|
6
|
+
// 所以这里自己维护"累计创建 / 已释放 / 仍存活"三档计数,把"累积"变成可读数字。
|
|
7
|
+
//
|
|
8
|
+
// 实现方式:包装 HTMLCanvasElement.prototype.getContext 与 WEBGL_lose_context.loseContext,
|
|
9
|
+
// 只做计数,不改变返回值与行为(幂等安装,重复调用安全)。
|
|
10
|
+
|
|
11
|
+
const st = {
|
|
12
|
+
installed: false,
|
|
13
|
+
created: 0, // 累计创建成功的 webgl/webgl2 上下文数(单调递增,泄漏会线性涨)
|
|
14
|
+
lost: 0, // 观测到的 loseContext 次数
|
|
15
|
+
byType: {}, // 'webgl' / 'webgl2' → 次数
|
|
16
|
+
contexts: [], // 每条 {type, at, connected}(at=创建时刻,connected=canvas 是否仍在文档里)
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** 安装计数钩子(幂等) */
|
|
20
|
+
export function installMemStats() {
|
|
21
|
+
if (st.installed || typeof HTMLCanvasElement === 'undefined') return;
|
|
22
|
+
st.installed = true;
|
|
23
|
+
const proto = HTMLCanvasElement.prototype;
|
|
24
|
+
const orig = proto.getContext;
|
|
25
|
+
proto.getContext = function patched(type, ...rest) {
|
|
26
|
+
const ctx = orig.call(this, type, ...rest);
|
|
27
|
+
try {
|
|
28
|
+
if (ctx && /webgl2?$/i.test(String(type))) {
|
|
29
|
+
// ⚠ 只统计**新创建**的上下文:同一 canvas 重复 getContext 会返回同一个上下文,
|
|
30
|
+
// 若按调用次数累加就会把"特征探测/像素读取"误记成泄漏(我第一版就错了,差点误判根因)。
|
|
31
|
+
const already = st.contexts.find((r) => r.canvas === this && r.type === String(type));
|
|
32
|
+
if (already) return ctx;
|
|
33
|
+
st.created += 1;
|
|
34
|
+
st.byType[type] = (st.byType[type] || 0) + 1;
|
|
35
|
+
const rec = { type: String(type), at: Date.now(), canvas: this,
|
|
36
|
+
// 记录身份特征,便于指认"这些 canvas 是谁建的"
|
|
37
|
+
id: this.id || '', cls: (this.className && String(this.className).slice(0, 40)) || '',
|
|
38
|
+
w: this.width, h: this.height };
|
|
39
|
+
st.contexts.push(rec);
|
|
40
|
+
// 包装 loseContext:真被释放了就记一笔(用于算"仍存活")
|
|
41
|
+
const lose = ctx.getExtension && ctx.getExtension('WEBGL_lose_context');
|
|
42
|
+
if (lose && typeof lose.loseContext === 'function' && !lose.__counted) {
|
|
43
|
+
const origLose = lose.loseContext.bind(lose);
|
|
44
|
+
try {
|
|
45
|
+
lose.loseContext = () => { st.lost += 1; rec.lost = true; return origLose(); };
|
|
46
|
+
lose.__counted = true;
|
|
47
|
+
} catch { /* 只读扩展对象:忽略 */ }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch { /* 计数失败不影响页面 */ }
|
|
51
|
+
return ctx;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 读当前计数。
|
|
57
|
+
* @returns {{glContexts: number, glContextsLive: number, glContextsLost: number, byType: object,
|
|
58
|
+
* canvases: number, canvasesWithGl: number, orphanGl: number, jsHeapMB: number|null}}
|
|
59
|
+
* glContexts 累计创建数(泄漏判据:随切换线性增长)
|
|
60
|
+
* glContextsLive 创建 − 已释放(近似"同时存活"数)
|
|
61
|
+
* orphanGl 上下文仍在、但 canvas 已脱离文档("旧 canvas 没删")
|
|
62
|
+
* canvases DOM 里的 canvas 数
|
|
63
|
+
*/
|
|
64
|
+
export function memStats() {
|
|
65
|
+
let canvases = 0;
|
|
66
|
+
let canvasesWithGl = 0;
|
|
67
|
+
try {
|
|
68
|
+
const list = [...document.querySelectorAll('canvas')];
|
|
69
|
+
canvases = list.length;
|
|
70
|
+
for (const c of list) {
|
|
71
|
+
st.contexts.forEach((r) => { if (r.canvas === c) canvasesWithGl += 1; });
|
|
72
|
+
}
|
|
73
|
+
} catch { /* 忽略 */ }
|
|
74
|
+
const orphanGl = st.contexts.filter((r) => !r.lost && r.canvas && !r.canvas.isConnected).length;
|
|
75
|
+
let jsHeapMB = null;
|
|
76
|
+
let jsHeapLimitMB = null;
|
|
77
|
+
let jsHeapPct = null;
|
|
78
|
+
try {
|
|
79
|
+
const m = performance && performance.memory;
|
|
80
|
+
if (m && m.usedJSHeapSize) {
|
|
81
|
+
jsHeapMB = Math.round((m.usedJSHeapSize / 1048576) * 10) / 10;
|
|
82
|
+
// 内存保护(T11)需要的是"占上限的比例",只给 MB 无法判断还剩多少余量。
|
|
83
|
+
// totalJSHeapSize 是"已分配额度"、jsHeapSizeLimit 才是硬上限 ⇒ 后者才是判据。
|
|
84
|
+
if (m.jsHeapSizeLimit) {
|
|
85
|
+
jsHeapLimitMB = Math.round((m.jsHeapSizeLimit / 1048576) * 10) / 10;
|
|
86
|
+
jsHeapPct = m.usedJSHeapSize / m.jsHeapSizeLimit;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch { /* 非 Chrome:保持 null,调用方按"无法判断"处理 */ }
|
|
90
|
+
return {
|
|
91
|
+
glContexts: st.created,
|
|
92
|
+
glContextsLive: Math.max(0, st.created - st.lost),
|
|
93
|
+
glContextsLost: st.lost,
|
|
94
|
+
byType: { ...st.byType },
|
|
95
|
+
canvases,
|
|
96
|
+
canvasesWithGl,
|
|
97
|
+
orphanGl,
|
|
98
|
+
// 逐条明细(排障用):哪些 canvas 拿了上下文、是否仍在文档里
|
|
99
|
+
jsHeapLimitMB,
|
|
100
|
+
jsHeapPct,
|
|
101
|
+
records: st.contexts.map((r) => ({
|
|
102
|
+
type: r.type, id: r.id, cls: r.cls, w: r.w, h: r.h,
|
|
103
|
+
lost: !!r.lost, connected: !!(r.canvas && r.canvas.isConnected),
|
|
104
|
+
})),
|
|
105
|
+
jsHeapMB,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 供测试重置(仅测试用) */
|
|
110
|
+
export function __resetMemStatsForTest() {
|
|
111
|
+
st.created = 0; st.lost = 0; st.byType = {}; st.contexts = [];
|
|
112
|
+
}
|