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,899 @@
|
|
|
1
|
+
// src/web/infra/plugin-host-unpack.mjs —— 资源取用与解包(从 plugin-host.js 抽出,B4)
|
|
2
|
+
//
|
|
3
|
+
// 这是宿主里体量最大的一块(20 个函数、约 490 代码行):把"资源根里的文件"变成
|
|
4
|
+
// "可用的字节 / 解包产物 / VFS 前缀"。它**不碰 DOM、不碰面板**,对外只有四个入口:
|
|
5
|
+
// materialize(容器解包)/ fetchFile(取字节)/ providersFor、findProvider(provider 查询)。
|
|
6
|
+
//
|
|
7
|
+
// 依赖约定(3 个参数,窄视图;不传整块 ctx):
|
|
8
|
+
// · io = { say, emit } —— 日志与 diag 事件(依赖,不是状态)
|
|
9
|
+
// · state = { providers, getDiscovered } —— provider 表(读)、本次发现清单(读,惰性替换故用 getter)
|
|
10
|
+
// · limits = { base } —— 服务端基地址(拼 /api/unpack/resolve 与产物 URL)
|
|
11
|
+
//
|
|
12
|
+
// 本模块**自持**的状态(生命周期归它管):
|
|
13
|
+
// · workerPool —— providerId → { worker, ready, seq, pending }:创建、复用、清理都在这里
|
|
14
|
+
// · MIME_BY_EXT / UNPACK_FETCH_CONCURRENCY / ORDINARY_ASSET_EXT / MAX_BUNDLE_BYTES
|
|
15
|
+
// · toUint8Array / describeType —— 只为本模块服务的类型工具
|
|
16
|
+
// ⇒ 卸载时的 worker 终止由宿主把 `unpack.pool` 交给 unload 模块完成(创建=这里,清理=unload)
|
|
17
|
+
//
|
|
18
|
+
// 其余依赖一律**直接 import**(它们是模块级依赖,不是宿主状态):
|
|
19
|
+
// memoryGuard(内存保护开关/预检)、getSharedVirtualFS、fetchWithRetry/postJson/mapLimit、
|
|
20
|
+
// histogramFromObjects / isAssetDependencyHistogram / dependencyReason
|
|
21
|
+
//
|
|
22
|
+
// ⚠️ 行为契约(逐字保留):错误码与文案、诊断事件名(unpack.* / container.*)、
|
|
23
|
+
// 预筛上限与并发上限、以及"失败不抛、由调用方回落"的降级语义,搬迁时一处未改。
|
|
24
|
+
|
|
25
|
+
import * as memoryGuard from '../app/memory-guard.js';
|
|
26
|
+
import { getSharedVirtualFS } from './vfs.js';
|
|
27
|
+
import { fetchWithRetry, postJson, mapLimit } from './api.js';
|
|
28
|
+
import { histogramFromObjects, isAssetDependencyHistogram, dependencyReason } from '../../shared/bundle-shape.mjs';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {{io: object, state: object, limits: object}} deps
|
|
32
|
+
* @returns {{materialize: Function, fetchFile: Function, providersFor: Function,
|
|
33
|
+
* findProvider: Function, pool: Map}}
|
|
34
|
+
*/
|
|
35
|
+
/** 未知二进制类型的兜底 MIME(扩展名对照表、Blob 回落、fetch 兜底三处共用一个) */
|
|
36
|
+
const MIME_OCTET = 'application/octet-stream';
|
|
37
|
+
export function createUnpack({ io, state, limits }) {
|
|
38
|
+
const { say, emit } = io;
|
|
39
|
+
const { providers, getDiscovered } = state;
|
|
40
|
+
const { base } = limits;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 把各种"字节样"入参归一成 Uint8Array(契约 §1.1 要求的类型)。
|
|
44
|
+
*
|
|
45
|
+
* 为什么必须在宿主侧收口:插件 canHandle 内部对类型不符是 try/catch → 返回 false,
|
|
46
|
+
* 调用方只会看到"这个文件没有提供者",看不到"你传的类型不对"——
|
|
47
|
+
* 典型的静默失败(实测:同一份 44MB bundle 传 ArrayBuffer 判不出,传 Uint8Array 命中)。
|
|
48
|
+
* 所以 ArrayBuffer / 各种 TypedArray / DataView 都在这里转好,调用方不必记类型。
|
|
49
|
+
* @param {any} value
|
|
50
|
+
* @returns {Uint8Array|null} 无法识别返回 null
|
|
51
|
+
*/
|
|
52
|
+
function toUint8Array(value) {
|
|
53
|
+
if (value instanceof Uint8Array) return value;
|
|
54
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
55
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
/** 类型名(错误提示用) */
|
|
59
|
+
function describeType(value) {
|
|
60
|
+
if (value === null) return 'null';
|
|
61
|
+
if (typeof value !== 'object') return typeof value;
|
|
62
|
+
const ctor = value.constructor && value.constructor.name;
|
|
63
|
+
return ctor || Object.prototype.toString.call(value);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 资源根里某个文件的下载 URL(**唯一拼法**)。
|
|
67
|
+
*
|
|
68
|
+
* 为什么单独抽出来:现在有两条路要取同一个文件 —— 主线程读(fetchFile)与
|
|
69
|
+
* worker 自己按 URL 拉(unpack-worker)。两处各拼一遍就是"改一处漏一处"的漂移,
|
|
70
|
+
* 而且拼错的表现极难查(一边能解、一边 404)。
|
|
71
|
+
* @param {string} rootId
|
|
72
|
+
* @param {string} rel
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
function fileUrlOf(rootId, rel) {
|
|
76
|
+
return `${base}/api/file?root=${encodeURIComponent(rootId)}&path=${encodeURIComponent(rel)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 取资源根里的文件字节(契约 §3.6 的唯一取字节通道)。
|
|
81
|
+
* @param {string} rootId 资源根 id
|
|
82
|
+
* @param {string} rel 相对该根的路径(POSIX)
|
|
83
|
+
* @returns {Promise<{bytes: ArrayBuffer, size: number, url: string}>}
|
|
84
|
+
*/
|
|
85
|
+
async function fetchFile(rootId, rel) {
|
|
86
|
+
const url = fileUrlOf(rootId, rel);
|
|
87
|
+
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
88
|
+
const t0 = now();
|
|
89
|
+
// 容器包字节同样走重试:这是"模型文件"主路径,keep-alive race 会打中它
|
|
90
|
+
const res = await fetchWithRetry(url, { timeoutMs: 20000, label: 'plugin-file' });
|
|
91
|
+
// 契约 §1.1:file.bytes 的类型是 **Uint8Array**(不是 ArrayBuffer)。
|
|
92
|
+
// 宿主在这里就转好,调用方拿到的直接是插件期望的类型。
|
|
93
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
94
|
+
// 打点:这是"主线程整包读入"的唯一入口。方向④ 完成后它**只该在回落路径出现**,
|
|
95
|
+
// 正常装载不该再有这条 ⇒ 验收就看它在不在。
|
|
96
|
+
try {
|
|
97
|
+
emit('unpack.main-read', { rel, bytes: bytes.byteLength, ms: Math.round(now() - t0) });
|
|
98
|
+
} catch { /* 打点失败不影响取字节 */ }
|
|
99
|
+
return { bytes, size: bytes.byteLength, url };
|
|
100
|
+
}
|
|
101
|
+
const ORDINARY_ASSET_EXT = /\.(json|skel|atlas|png|jpe?g|webp|bmp|gif|moc3|motion3|physics3|cdi3|txt|md|bin)$/i;
|
|
102
|
+
/**
|
|
103
|
+
* **绝对兜底**上限(只防"手滑点了一个几 GB 的文件",不是内存判据)。
|
|
104
|
+
*
|
|
105
|
+
* 为什么从 512MB 改成 2GB 并受开关控制:原来它是"裸字节数"判断 ——
|
|
106
|
+
* 512MB 的包在 8GB 机器上没事、在 2GB 机器上必然炸,而且关掉内存保护它照样拦,
|
|
107
|
+
* 文案还只说"文件过大(536870912B > 上限 536870912B)"(用户不知道该怎么做)。
|
|
108
|
+
* 现在真正的内存判断交给 memory-budget.assess(按可用内存换算预估峰值),
|
|
109
|
+
* 这里只在"明显不可能是有意义的资源包"时兜一下,且**开关关闭时不拦**。
|
|
110
|
+
*/
|
|
111
|
+
const MAX_BUNDLE_BYTES = 2 * 1024 * 1024 * 1024;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 预筛:返回 null 表示"可能是资源包,值得读全量",否则返回跳过原因。
|
|
115
|
+
* @param {{rootId?:string, rel?:string, name?:string, size?:number, rootKind?:string}} file
|
|
116
|
+
*/
|
|
117
|
+
// ── 分区 · 资源取用与解包(本体:本文件) ────────────────────────────────────────────────────
|
|
118
|
+
// 注意:预热链(fetchCapacity / warmup / scheduleWarmup)已搬到 plugin-host-warmup.mjs(B1)。
|
|
119
|
+
// 从"文件能不能读"(prefilterReason/validateResolved)到"worker 解包→失败回落服务端",
|
|
120
|
+
// 再到"内存准入驱动的预热"(fetchCapacity/warmup/scheduleWarmup)。**不碰 DOM**。
|
|
121
|
+
// 状态耦合:discovered(读清单)、providers、say、emit、workerPool/warmupInflight/capCache。
|
|
122
|
+
function prefilterReason(file) {
|
|
123
|
+
if (!file || !file.rootId || !file.rel) return '缺少 rootId/rel';
|
|
124
|
+
if (file.rootKind && file.rootKind !== 'bundle') return `资源根类型是 ${file.rootKind},不是 bundle`;
|
|
125
|
+
const name = String(file.name || file.rel);
|
|
126
|
+
if (ORDINARY_ASSET_EXT.test(name)) return `后缀是普通模型资产(${name.split('.').pop()}),不可能是资源包`;
|
|
127
|
+
if (typeof file.size === 'number' && file.size === 0) return '文件为空';
|
|
128
|
+
// 绝对兜底:只在内存保护开着时生效(关掉开关=用户明示要试,不再拦)
|
|
129
|
+
if (memoryGuard.isMemoryGuardOn() && typeof file.size === 'number' && file.size > MAX_BUNDLE_BYTES) {
|
|
130
|
+
const gb = (n) => `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
|
131
|
+
return `包体积 ${gb(file.size)} 超过绝对上限 ${gb(MAX_BUNDLE_BYTES)}(内存保护开启时的兜底;可在面板关闭内存保护后重试)`;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 校验 provider.resolve 的返回值(契约 §一 / §5.8:不得返回缺字段半成品)。
|
|
138
|
+
* @returns {string|null} null = 合规,否则返回原因
|
|
139
|
+
*/
|
|
140
|
+
function validateResolved(res) {
|
|
141
|
+
if (!res || typeof res !== 'object') return '返回值不是对象';
|
|
142
|
+
if (typeof res.prefix !== 'string' || !/^vfs:\/\/.+\/$/.test(res.prefix)) {
|
|
143
|
+
return `prefix 不合契约 §1.2(应形如 vfs://<providerId>/<rootId>/<相对目录>/):${res.prefix}`;
|
|
144
|
+
}
|
|
145
|
+
const files = res.files;
|
|
146
|
+
const size = files instanceof Map ? files.size
|
|
147
|
+
: (files && typeof files === 'object' ? Object.keys(files).length : 0);
|
|
148
|
+
if (!size) return 'files 为空';
|
|
149
|
+
if (typeof res.entry !== 'string' || !res.entry) return 'entry 缺失';
|
|
150
|
+
const has = files instanceof Map
|
|
151
|
+
? files.has(res.entry)
|
|
152
|
+
: Object.prototype.hasOwnProperty.call(files, res.entry);
|
|
153
|
+
if (!has) return `entry 不在 files 里:${res.entry}`;
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 读全量字节(file.bytes 优先;没有就经 /api/file 取) */
|
|
158
|
+
async function bytesOf(file) {
|
|
159
|
+
const given = toUint8Array(file.bytes);
|
|
160
|
+
if (given) return { ok: true, bytes: given };
|
|
161
|
+
try {
|
|
162
|
+
const got = await fetchFile(file.rootId, file.rel);
|
|
163
|
+
return { ok: true, bytes: got.bytes };
|
|
164
|
+
} catch (e) {
|
|
165
|
+
return { ok: false, message: `读文件失败:${(e && e.message) || e}` };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 解包 Worker 的单例管理。
|
|
171
|
+
*
|
|
172
|
+
* 为什么需要:LZMA 解压实测 300~620ms/MB(解压后),一个 8.28MB 的包要 3.9 秒纯同步 CPU,
|
|
173
|
+
* 留在主线程就是 4 秒白屏。放进 worker 后主线程照常渲染。
|
|
174
|
+
* 为什么按 provider 建:不同插件的 lib 清单不同(`files[]` 是加载顺序的权威),
|
|
175
|
+
* 一个 worker 只装一个 provider 的那份 lib。
|
|
176
|
+
*/
|
|
177
|
+
const workerPool = new Map(); // providerId → { worker, ready, seq, pending }
|
|
178
|
+
|
|
179
|
+
/** 从 `discovered` 找该 provider 的 files[](按 id 动态找,不硬编码插件名) */
|
|
180
|
+
function filesOfProvider(providerId) {
|
|
181
|
+
const pl = getDiscovered().find((p) => p && p.id === providerId);
|
|
182
|
+
return pl && Array.isArray(pl.files) ? pl.files : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** worker 的进度事件 → 状态行 + 调试日志(4 秒里不能一点反馈都没有) */
|
|
186
|
+
function onWorkerProgress(m) {
|
|
187
|
+
const d = m.detail || {};
|
|
188
|
+
if (m.phase === 'probing') {
|
|
189
|
+
// 判定包类型也要解一遍(插件的 canHandle 会完整解析),所以它同样是"会等数秒"的一段
|
|
190
|
+
say.info('后台解包:正在判定包内容类型…(主线程不阻塞)');
|
|
191
|
+
emit('unpack.worker', { phase: 'probing' });
|
|
192
|
+
} else if (m.phase === 'blocks') {
|
|
193
|
+
const est = d.estimateMs ? `,预计约 ${(d.estimateMs / 1000).toFixed(1)} 秒` : '';
|
|
194
|
+
say.info(`后台解包:${d.algo} / ${d.blocks} 块 / 解压后 ${d.decompressedMB}MB${est}(主线程不阻塞)`);
|
|
195
|
+
emit('unpack.worker', { phase: 'blocks', ...d });
|
|
196
|
+
} else if (m.phase === 'unpacking') {
|
|
197
|
+
emit('unpack.worker', { phase: 'unpacking' });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 取(或建)某个 provider 的 worker;建不起来返回 null(调用方回落同步解包)。
|
|
203
|
+
* @param {string} providerId
|
|
204
|
+
* @returns {Promise<object|null>}
|
|
205
|
+
*/
|
|
206
|
+
function ensureUnpackWorker(providerId) {
|
|
207
|
+
if (workerPool.has(providerId)) return workerPool.get(providerId).ready;
|
|
208
|
+
const files = filesOfProvider(providerId);
|
|
209
|
+
const entry = { worker: null, ready: null, seq: 0, pending: new Map() };
|
|
210
|
+
workerPool.set(providerId, entry);
|
|
211
|
+
entry.ready = (async () => {
|
|
212
|
+
if (typeof Worker !== 'function' || !files || !files.length) return null;
|
|
213
|
+
let worker;
|
|
214
|
+
// 静态托管的映射是 src/web/ → 站点根(实测:/app/model-actions.js 可用),
|
|
215
|
+
// 所以 src/web/plugin/unpack-worker.js 的 URL 就是 /plugin/unpack-worker.js
|
|
216
|
+
try { worker = new Worker(new URL('/plugin/unpack-worker.js', import.meta.url)); } catch { return null; }
|
|
217
|
+
entry.worker = worker;
|
|
218
|
+
const ok = await new Promise((resolve) => {
|
|
219
|
+
const timer = setTimeout(() => resolve(false), 8000); // 载 lib 超时 ⇒ 判不可用
|
|
220
|
+
worker.addEventListener('message', (ev) => {
|
|
221
|
+
const m = ev.data || {};
|
|
222
|
+
if (m.t === 'ready') { clearTimeout(timer); resolve(!!m.hasProvider); }
|
|
223
|
+
else if (m.t === 'progress') onWorkerProgress(m);
|
|
224
|
+
else if (m.t === 'done' || m.t === 'fail') {
|
|
225
|
+
const p = entry.pending.get(m.id);
|
|
226
|
+
if (p) { entry.pending.delete(m.id); p(m); }
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
worker.addEventListener('error', () => { clearTimeout(timer); resolve(false); });
|
|
230
|
+
// files 元素已带插件目录(unity-live2d-bundle/lib/xxx.js),故 base 到 /plugins/
|
|
231
|
+
worker.postMessage({ t: 'init', base: new URL('/plugins/', import.meta.url).href, files });
|
|
232
|
+
});
|
|
233
|
+
if (!ok) { try { worker.terminate(); } catch { /* 忽略 */ } entry.worker = null; return null; }
|
|
234
|
+
return entry;
|
|
235
|
+
})();
|
|
236
|
+
return entry.ready;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 用 worker 解包。失败**不抛**,返回 {ok:false} 让调用方回落同步路径(降级不静默,由调用方报)。
|
|
241
|
+
* @param {object} prov 提供者
|
|
242
|
+
* @param {object} file 条目(rootId/rel/name)
|
|
243
|
+
* @param {Uint8Array|null} bytes 已读字节;为 null 时改用 opts.url 让 worker 自己拉
|
|
244
|
+
* @param {{url?: string, size?: number}} [opts] url 模式参数(方向④)
|
|
245
|
+
* @returns {Promise<{ok:boolean, res?:object, src?:string, error?:string}>}
|
|
246
|
+
*/
|
|
247
|
+
async function resolveViaWorker(prov, file, bytes, opts = {}) {
|
|
248
|
+
const entry = await ensureUnpackWorker(prov.id);
|
|
249
|
+
if (!entry) return { ok: false, unavailable: true, error: `worker 不可用(${prov.id}:CSP / 老浏览器 / 插件 lib 加载失败)` };
|
|
250
|
+
const id = (entry.seq += 1);
|
|
251
|
+
// 两种投喂方式(方向④):
|
|
252
|
+
// · bytes 已就绪(调用方本来就要读 / 或 file.bytes 内联给了)⇒ 结构化拷贝给 worker
|
|
253
|
+
// · 只有 URL ⇒ **worker 自己 fetch**:主线程不再把 19MB 读进来再拷一遍
|
|
254
|
+
// (实测那一段 "read arrayBuffer + postMessage" 占 3918ms/19.3MB,是首帧最大的单笔开销)
|
|
255
|
+
// 仍然刻意**不**用 transferable:同一份 bytes 可能要依次交给多个 provider 试,
|
|
256
|
+
// transfer 出去主线程的 buffer 就 detached 了;而 url 模式下这条顾虑从根本上不存在。
|
|
257
|
+
const url = opts && opts.url ? String(opts.url) : '';
|
|
258
|
+
const useUrl = !bytes && !!url;
|
|
259
|
+
const payload = { t: 'unpack', id, file: { rootId: file.rootId, rel: file.rel, name: file.name } };
|
|
260
|
+
if (useUrl) { payload.url = url; payload.size = Number.isFinite(opts.size) ? Number(opts.size) : 0; }
|
|
261
|
+
else { payload.bytes = bytes; }
|
|
262
|
+
const nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
263
|
+
const postT0 = nowMs();
|
|
264
|
+
// 注意 Promise 的执行器是**同步**跑的 ⇒ 构造返回时 postMessage 已经调用完毕,
|
|
265
|
+
// 于是 postMs 量到的就纯粹是"投喂"这一步(bytes 模式是结构化拷贝,url 模式只是发个小对象)。
|
|
266
|
+
const waitDone = new Promise((resolve) => {
|
|
267
|
+
const timer = setTimeout(() => { entry.pending.delete(id); resolve({ t: 'fail', message: 'worker 超时(60s)' }); }, 60000);
|
|
268
|
+
entry.pending.set(id, (m) => { clearTimeout(timer); resolve(m); });
|
|
269
|
+
entry.worker.postMessage(payload);
|
|
270
|
+
});
|
|
271
|
+
const postMs = Math.round(nowMs() - postT0);
|
|
272
|
+
const result = await waitDone;
|
|
273
|
+
// 打点分两个数(别混):postMs = 投喂;totalMs = 整轮(含 worker 自己拉包 + 解压)
|
|
274
|
+
try {
|
|
275
|
+
emit('unpack.worker-post', {
|
|
276
|
+
rel: file.rel, mode: useUrl ? 'url' : 'bytes', bytes: useUrl ? 0 : (bytes ? bytes.byteLength : 0),
|
|
277
|
+
postMs, totalMs: Math.round(nowMs() - postT0),
|
|
278
|
+
});
|
|
279
|
+
} catch { /* 打点失败不影响解包 */ }
|
|
280
|
+
if (result.t === 'no-handle') return { ok: false, noHandle: true };
|
|
281
|
+
if (result.t !== 'done' || !result.res) {
|
|
282
|
+
return { ok: false, error: `${result.code || 'worker-fail'}: ${result.message || '未知'}` };
|
|
283
|
+
}
|
|
284
|
+
// 产物从「[[path, Blob|Uint8Array, type]]」还原成 Map<path, **Blob**>。
|
|
285
|
+
// vfs.register 只接受 Blob 或字符串(实测传 Uint8Array 会报"内容必须是 Blob 或字符串")。
|
|
286
|
+
// worker 侧现在会把插件的 Blob 原样透传(零拷贝),只有非 Blob 产物才需要我们在这里建;
|
|
287
|
+
// 所以大部分文件走 `val` 直通,一个字节都不用再复制。
|
|
288
|
+
const files = new Map((result.res.files || []).map(([p, val, type]) => [
|
|
289
|
+
p,
|
|
290
|
+
(typeof Blob !== 'undefined' && val instanceof Blob)
|
|
291
|
+
? val
|
|
292
|
+
: new Blob([val], { type: type || MIME_OCTET }),
|
|
293
|
+
]));
|
|
294
|
+
return {
|
|
295
|
+
ok: true,
|
|
296
|
+
src: 'local-worker',
|
|
297
|
+
workerMs: result.ms,
|
|
298
|
+
res: { prefix: result.res.prefix, entry: result.res.entry, files },
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 解析一个没人认领的容器,统计包内对象类型构成。
|
|
304
|
+
*
|
|
305
|
+
* 只在「所有插件 canHandle 都说不认」的失败路径上跑一次(不进渲染热路径)。
|
|
306
|
+
* 这里只负责"在浏览器里怎么拿到对象表"(走插件注入的 window.__ULB__);
|
|
307
|
+
* **怎么统计、怎么判定、文案怎么写,全部在 src/shared/bundle-shape.mjs**,
|
|
308
|
+
* 与 node 侧 /api/unpack 共用同一份,避免两条路径给出两种答案。
|
|
309
|
+
* @param {Uint8Array} bytes 容器字节
|
|
310
|
+
* @returns {Record<string, number>|null} className → 数量;探测不了返回 null
|
|
311
|
+
*/
|
|
312
|
+
function classHistogramOf(bytes) {
|
|
313
|
+
try {
|
|
314
|
+
const NS = typeof window !== 'undefined' ? window.__ULB__ : null;
|
|
315
|
+
const ufs = NS && NS.unityfs;
|
|
316
|
+
const ser = NS && NS.serialized;
|
|
317
|
+
if (!ufs || !ser || typeof ufs.readHeader !== 'function' || !bytes || !bytes.length) return null;
|
|
318
|
+
const head = ufs.readHeader(bytes);
|
|
319
|
+
// 取 serialized file 节点:判据是**结构形式**(CAB- 后跟 32 位十六进制且无后缀),不能只看名字前缀 ——
|
|
320
|
+
// `.resS`(裸资源流)与 `.resource`(魔数 FSB5 = FMOD 音频银行)同样以 CAB- 开头,
|
|
321
|
+
// 喂给 serialized 解析器会读到 2^60 量级的垃圾 fileSize。也不做"找不到就取第一个 CAB-*"的回退。
|
|
322
|
+
const cab = (head.nodes || []).find((n) => /^CAB-[0-9a-f]{32}$/i.test(n.path));
|
|
323
|
+
if (!cab) return null;
|
|
324
|
+
const parsed = ufs.parse(bytes);
|
|
325
|
+
return histogramFromObjects(ser.parse(ufs.nodeBytes(parsed, cab.path)));
|
|
326
|
+
} catch { return null; }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 找提供者并 resolve(含返回值契约校验) */
|
|
330
|
+
async function resolveViaProvider(file, bytes) {
|
|
331
|
+
const provider = findProvider({ ...file, bytes });
|
|
332
|
+
if (!provider) {
|
|
333
|
+
// 没人认领时先判形态:美术依赖包要说清楚"为什么打不开 + 下一步怎么做",
|
|
334
|
+
// 否则用户只会看到泛化的"没有启用的提供者",误以为插件被禁用或加载失败。
|
|
335
|
+
// 判据与文案来自 src/shared/bundle-shape.mjs(与 node 侧 /api/unpack 同一份)。
|
|
336
|
+
const hist = classHistogramOf(bytes);
|
|
337
|
+
if (isAssetDependencyHistogram(hist)) {
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
reason: 'asset-dependency',
|
|
341
|
+
message: dependencyReason(hist),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
reason: 'no-provider',
|
|
347
|
+
message: `没有启用的提供者能处理 ${file.rel}`
|
|
348
|
+
+ '(检查 config/plugins.json 是否禁用了对应插件,或插件是否加载失败)',
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
if (typeof provider.resolve !== 'function') {
|
|
352
|
+
return { ok: false, reason: 'no-resolve', message: `提供者 ${provider.id} 没有 resolve()` };
|
|
353
|
+
}
|
|
354
|
+
let res = null;
|
|
355
|
+
try {
|
|
356
|
+
res = await provider.resolve({ ...file, bytes, host: hostApi });
|
|
357
|
+
} catch (e) {
|
|
358
|
+
return { ok: false, reason: 'resolve-failed', message: `提供者 ${provider.id} 解析失败:${(e && e.message) || e}` };
|
|
359
|
+
}
|
|
360
|
+
const bad = validateResolved(res);
|
|
361
|
+
if (bad) return { ok: false, reason: 'bad-result', message: `提供者 ${provider.id} 返回不合契约:${bad}` };
|
|
362
|
+
// 与 worker 候选循环同一把尺子:产物不自洽就当场判定不可用。
|
|
363
|
+
// 不判的后果是错误被推迟到装载阶段,只剩一句 "Network error.",根因完全看不出来。
|
|
364
|
+
// 注:本函数只有一个候选(findProvider 选定的那个),不在这里改试下一个 ——
|
|
365
|
+
// 多候选轮试在 worker 循环里做;这条同步路径只在 ?unpack=0 或 worker 全不可用时才走。
|
|
366
|
+
const usable = await resolvedUsable(res);
|
|
367
|
+
if (!usable.ok) {
|
|
368
|
+
return { ok: false, reason: 'bad-result', message: `提供者 ${provider.id} 的产物不可用:${usable.why}` };
|
|
369
|
+
}
|
|
370
|
+
return { ok: true, provider: provider.id, res };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 产物可用性校验:**"插件说能解"不等于"产物真能用"**。
|
|
375
|
+
*
|
|
376
|
+
* 为什么必须有:同一个 UnityFS 包可能被多个 bundle 插件都判成"我能处理" ——
|
|
377
|
+
* 它们的判定只看包结构,不看"解出来的东西是否自洽"。
|
|
378
|
+
* 实测(NIKKE 的 spine 资源包):live2d 插件抢先返回 ok,但它生成的 `model3.json`
|
|
379
|
+
* 里 Moc 指向的 `moc3.moc3` 内容只有 4 字节、字面量 `null`(包里根本没有 moc3)
|
|
380
|
+
* ⇒ 后续按 live2d 装载必然失败(控制台只有一句 Network error),
|
|
381
|
+
* 而真正能解这个包的 spine 插件永远轮不到 ⇒ 表现为"点一次不出画,
|
|
382
|
+
* 再点一次(服务端缓存选对了插件)才显示"。
|
|
383
|
+
*
|
|
384
|
+
* 判据(对所有 kind 通用,不认包名):
|
|
385
|
+
* ① 入口文件必须真实存在于产物里
|
|
386
|
+
* ② 入口是 JSON 时,它引用的每个文件也必须在产物里,且内容不是空/占位(≤8 字节)
|
|
387
|
+
* ③ 校验自身出错一律放行(宁可放过一次,不可错杀可用产物)
|
|
388
|
+
* @param {{entry: string, files: Map<string, Blob>}} res 插件产物
|
|
389
|
+
* @returns {Promise<{ok: boolean, why?: string}>}
|
|
390
|
+
*/
|
|
391
|
+
async function resolvedUsable(res) {
|
|
392
|
+
try {
|
|
393
|
+
const files = res && res.files;
|
|
394
|
+
if (!files || typeof files.keys !== 'function' || !files.size) return { ok: false, why: '产物为空' };
|
|
395
|
+
const norm = (p) => String(p || '').replace(/^\.?\//, '');
|
|
396
|
+
const keys = [...files.keys()];
|
|
397
|
+
const find = (name) => keys.find((k) => norm(k) === norm(name));
|
|
398
|
+
const entryName = norm(res.entry);
|
|
399
|
+
if (!entryName) return { ok: false, why: '没有入口文件' };
|
|
400
|
+
const entryKey = find(entryName);
|
|
401
|
+
if (entryKey === undefined) return { ok: false, why: `入口 ${entryName} 不在产物里` };
|
|
402
|
+
const blob = files.get(entryKey);
|
|
403
|
+
if (!blob || typeof blob.text !== 'function') return { ok: true };
|
|
404
|
+
let meta = null;
|
|
405
|
+
try { meta = JSON.parse(await blob.text()); } catch { return { ok: false, why: '入口不是合法 JSON' }; }
|
|
406
|
+
const fr = (meta && meta.FileReferences) || {};
|
|
407
|
+
const refs = [
|
|
408
|
+
...(meta && meta.skeletonFile ? [meta.skeletonFile] : []),
|
|
409
|
+
...(meta && meta.atlasFile ? [meta.atlasFile] : []),
|
|
410
|
+
...(Array.isArray(meta && meta.textureFiles) ? meta.textureFiles : []),
|
|
411
|
+
...(fr.Moc ? [fr.Moc] : []),
|
|
412
|
+
...(Array.isArray(fr.Textures) ? fr.Textures : []),
|
|
413
|
+
...(fr.Physics ? [fr.Physics] : []),
|
|
414
|
+
];
|
|
415
|
+
for (const r of refs) {
|
|
416
|
+
const k = find(r);
|
|
417
|
+
if (k === undefined) return { ok: false, why: `引用文件 ${r} 不在产物里` };
|
|
418
|
+
const b = files.get(k);
|
|
419
|
+
if (b && typeof b.size === 'number' && b.size <= 8) {
|
|
420
|
+
return { ok: false, why: `引用文件 ${r} 只有 ${b.size} 字节(空/占位)` };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return { ok: true };
|
|
424
|
+
} catch { return { ok: true }; }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** 注册进 VirtualFS;同前缀重复装载时先撤再注册(不泄漏旧 prefix) */
|
|
428
|
+
function registerResolved(vfs, res) {
|
|
429
|
+
try {
|
|
430
|
+
vfs.register(res.prefix, res.files);
|
|
431
|
+
return { ok: true, reRegistered: false };
|
|
432
|
+
} catch (e) {
|
|
433
|
+
const msg = String((e && e.message) || e);
|
|
434
|
+
if (!/已注册/.test(msg)) return { ok: false, message: `注册进 VirtualFS 失败:${msg}` };
|
|
435
|
+
try { vfs.revoke(res.prefix); } catch { /* 撤销失败也继续重试 */ }
|
|
436
|
+
try {
|
|
437
|
+
vfs.register(res.prefix, res.files);
|
|
438
|
+
return { ok: true, reRegistered: true };
|
|
439
|
+
} catch (e2) {
|
|
440
|
+
return { ok: false, message: `重新注册失败:${(e2 && e2.message) || e2}` };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* 把一个资源包文件"物化"进 VirtualFS:预筛 → 读全量 → findProvider → resolve → register。
|
|
447
|
+
* 本函数**不碰渲染**(接线是下一步),只保证"资源真的进了 VFS 且能解析出入口地址"。
|
|
448
|
+
* @param {object} file { rootId, rel, name?, size?, rootKind?, bytes? }
|
|
449
|
+
* @param {{vfs?: object}} [opts]
|
|
450
|
+
* @returns {Promise<object>} { ok, reason?, message?, provider?, prefix?, entry?, entryUrl?, files? }
|
|
451
|
+
*/
|
|
452
|
+
/** 解包产物并发上限:留出槽位给模型文件与就绪上报(同源只有 6 个连接槽) */
|
|
453
|
+
const UNPACK_FETCH_CONCURRENCY = 4;
|
|
454
|
+
|
|
455
|
+
/** 产物文件的 MIME(按扩展名;未知一律 octet-stream) */
|
|
456
|
+
const MIME_BY_EXT = {
|
|
457
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp',
|
|
458
|
+
'.json': 'application/json', '.moc3': MIME_OCTET, '.wav': 'audio/wav', '.mp3': 'audio/mpeg',
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
/** 产物文件数(跨 realm 鸭子类型:Map 看 size,普通对象看键数) */
|
|
462
|
+
function filesCount(files) {
|
|
463
|
+
if (!files) return 0;
|
|
464
|
+
if (typeof files.size === 'number') return files.size;
|
|
465
|
+
return Object.keys(files).length;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* 从 VFS 前缀里取出真正解出这份产物的提供者 id。
|
|
470
|
+
*
|
|
471
|
+
* 为什么必须取:服务端命中路径过去把 provider 写死成 `'server'`,而 `'server'` 不是注册表里
|
|
472
|
+
* 任何一个提供者 ⇒ 服务端按 id 反查声明色得到 null ⇒ 徽标掉回中性色。
|
|
473
|
+
* 实测的两个症状都出在这:①"颜色当场变了、切回来又变回中性"②第二次点击(命中缓存)
|
|
474
|
+
* 上报的 provider 是假 id,把存储里的正确记录覆盖坏。
|
|
475
|
+
* 前缀形态与 server/unpack/plugin-store.mjs 的 expectedPrefix 同源(同一套拼接规则):
|
|
476
|
+
* `vfs://<providerId>/<rootId>/<相对目录>/`
|
|
477
|
+
* @param {string} prefix VFS 前缀
|
|
478
|
+
* @returns {string|null} 解析不出就返回 null(调用方按"未确定归属"处理,绝不编一个 id)
|
|
479
|
+
*/
|
|
480
|
+
function providerFromPrefix(prefix) {
|
|
481
|
+
const s = String(prefix || '');
|
|
482
|
+
const m = /^vfs:\/\/([^/]+)\//.exec(s);
|
|
483
|
+
return m ? m[1] : null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* 竞速取"第一个成功"的(注意:不是第一个 settle 的——先失败的那条不该让我们提前放弃)。
|
|
488
|
+
* @param {Array<Promise<any>>} ps
|
|
489
|
+
* @returns {Promise<any>} 首个 fulfilled 的值;全失败则抛最后一个错误
|
|
490
|
+
*/
|
|
491
|
+
function firstFulfilled(ps) {
|
|
492
|
+
return new Promise((resolve, reject) => {
|
|
493
|
+
let left = ps.length;
|
|
494
|
+
let lastErr = null;
|
|
495
|
+
for (const p of ps) {
|
|
496
|
+
p.then((v) => resolve(v), (e) => {
|
|
497
|
+
lastErr = lastErr || e;
|
|
498
|
+
left -= 1;
|
|
499
|
+
if (left === 0) reject(lastErr);
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* 服务端**命中查询**(形态 C 的前台路径):`hitOnly` ⇒ 服务端只读索引,**绝不在这里解包**。
|
|
507
|
+
* miss 会抛 reason='server-miss',调用方据此走本地解包(首次绝不因此变慢)。
|
|
508
|
+
* 真正的解包由 warmup() 在页面空闲时触发。
|
|
509
|
+
* @param {{rootId: string, rel: string}} file
|
|
510
|
+
* @returns {Promise<{prefix: string, entry: string, files: Map<string, Uint8Array>, hit: boolean, provider: string, src: string}>}
|
|
511
|
+
*/
|
|
512
|
+
async function resolveFromServer(file, { hitOnly = true, mon = null } = {}) {
|
|
513
|
+
// 自定义超时已删:查询只读服务端的缓存索引,本该很快;给它 1.5s 反而会在服务偶尔忙时
|
|
514
|
+
// 超时被误判成"没缓存",直接导致前端白解一遍(19MB / 4.3s)。用统一的默认超时即可。
|
|
515
|
+
const tLook = performance.now();
|
|
516
|
+
let j;
|
|
517
|
+
try {
|
|
518
|
+
// allowOverLimit:面板「内存保护」关掉时允许服务端越过内存预算(默认开 ⇒ 不带,行为不变)
|
|
519
|
+
// 显式 retries:2 —— 这个 POST 是**幂等**的(同 rootId+rel 解出来的产物相同),失败重发安全。
|
|
520
|
+
// api.js 对非幂等方法默认不重试(防"创建成功但响应丢了 ⇒ 重发 ⇒ 建两个"),
|
|
521
|
+
// 所以幂等的 POST 必须在调用点显式声明意图,不能指望默认值。
|
|
522
|
+
j = await postJson('/api/unpack/resolve',
|
|
523
|
+
{ rootId: file.rootId, rel: file.rel, hitOnly, allowOverLimit: !memoryGuard.isMemoryGuardOn() },
|
|
524
|
+
{ retries: 2 });
|
|
525
|
+
} catch (e) {
|
|
526
|
+
// 查询**失败**(超时/网络/连接复用被打断)必须可见:以前这种异常没有 reason,
|
|
527
|
+
// 被 catch 静默当成"未命中",日志里查不出任何痕迹 —— 这是排查不出来的首因。
|
|
528
|
+
emit('unpack.lookup', { rootId: file.rootId, rel: file.rel, ok: false, error: String((e && e.message) || e).slice(0, 120), ms: Math.round(performance.now() - tLook) });
|
|
529
|
+
say.warn(`命中查询失败:${file.rel}(${(e && e.message) || e})`);
|
|
530
|
+
throw Object.assign(e instanceof Error ? e : new Error(String(e)), { reason: 'lookup-error' });
|
|
531
|
+
}
|
|
532
|
+
if (!j || !j.ok) {
|
|
533
|
+
emit('unpack.lookup', { rootId: file.rootId, rel: file.rel, ok: false, error: (j && (j.reason || j.message)) || 'bad-response', ms: Math.round(performance.now() - tLook) });
|
|
534
|
+
throw Object.assign(new Error((j && (j.message || j.reason)) || '服务端查询失败'), { reason: 'lookup-error' });
|
|
535
|
+
}
|
|
536
|
+
if (!j.hit && hitOnly) {
|
|
537
|
+
// **只有"只查缓存"模式**才把 hit:false 当作未命中。
|
|
538
|
+
// hitOnly=false 时服务端是**真解包**:解完后返回 hit:false + 完整产物,这是正常成功路径。
|
|
539
|
+
// 早前这里无条件按 !j.hit 抛 server-miss ⇒「请服务端解包」这条路永远走不通,
|
|
540
|
+
// 于是"本地插件解不了的包(如纹理格式 ASTC/50)在页面上永远打不开"。
|
|
541
|
+
emit('unpack.lookup', { rootId: file.rootId, rel: file.rel, ok: true, hit: false, missReason: j.missReason || 'no-index', ms: Math.round(performance.now() - tLook) });
|
|
542
|
+
throw Object.assign(new Error(`未命中(${j.missReason || 'no-index'})`), { reason: 'server-miss' });
|
|
543
|
+
}
|
|
544
|
+
emit('unpack.lookup', {
|
|
545
|
+
rootId: file.rootId, rel: file.rel, ok: true,
|
|
546
|
+
hit: !!j.hit,
|
|
547
|
+
...(j.hit ? {} : { unpacked: true }), // 服务端刚解完(server-unpack),验收要看这一次花了多久
|
|
548
|
+
ms: Math.round(performance.now() - tLook),
|
|
549
|
+
});
|
|
550
|
+
// **并行**取产物:实测 19 个文件顺序 145ms vs 并行 57ms(39 个文件 413ms vs 174ms)。
|
|
551
|
+
// 顺序取会让"命中路径"慢 2.5 倍,从而在竞速里输给本地解包 —— 那 17b 就白做了。
|
|
552
|
+
const list = j.files || [];
|
|
553
|
+
// **限流 4 并发**:39 个文件一次全发会吃满同源 6 个连接槽,
|
|
554
|
+
// 把 Spine 模型文件与 /api/ready 挤到队列里超时(现场:"加载资源包之后全挂")。
|
|
555
|
+
// 路径 C|长操作监控:39 个文件可能要取几秒,期间堆占用飙升就先停手。
|
|
556
|
+
// 中止只影响"还要不要再取",已经取到的文件与已落盘的服务端产物都保留。
|
|
557
|
+
const got = await mapLimit(list, UNPACK_FETCH_CONCURRENCY, async (f) => {
|
|
558
|
+
// 每步开头同步采一次(不依赖"是否已跨帧"):短操作也能被及时发现
|
|
559
|
+
// mon 由调用方(materialize)传入 —— 它是在那里启动的监控。
|
|
560
|
+
// 曾经这里直接引用 mon 但它在另一个函数作用域里 ⇒ 命中路径一取产物就抛
|
|
561
|
+
// "mon is not defined",被上层当成服务端失败,回落到本地重新解一遍(实测 16s)。
|
|
562
|
+
if (mon && mon.checkNow()) throw Object.assign(new Error(mon.reason()), { reason: 'memory-guard' });
|
|
563
|
+
// 服务端解包产物同样属于"模型文件",失败要重试(keep-alive race 会打中这里)
|
|
564
|
+
const res = await fetchWithRetry(f.url, { timeoutMs: 8000, label: 'unpack-file' });
|
|
565
|
+
// VFS 只接受 Blob/字符串(本地路径上插件给的就是 Blob)→ 这里转成带 MIME 的 Blob,
|
|
566
|
+
// MIME 影响 blob URL 的消费方(<img>/WebGL 纹理、fetch 后 JSON.parse 都看它)
|
|
567
|
+
const type = MIME_BY_EXT[(/\.[a-z0-9]+$/i.exec(f.path) || [''])[0].toLowerCase()] || MIME_OCTET;
|
|
568
|
+
return [f.path, new Blob([await res.arrayBuffer()], { type })];
|
|
569
|
+
});
|
|
570
|
+
const files = new Map(got);
|
|
571
|
+
if (!files.size) throw new Error('服务端返回的产物为空');
|
|
572
|
+
// src 区分"命中已有缓存"与"服务端刚解出来":验收要看 unpack.source 到底走没走重复劳动
|
|
573
|
+
// provider 取**真正解出这份产物的插件 id**(从 VFS 前缀解析,见 providerFromPrefix),
|
|
574
|
+
// 不再写死 'server':那是个注册表里不存在的假 id,会让服务端反查声明色得到 null
|
|
575
|
+
// ⇒ 表现为"命中缓存的第二次点击颜色反而掉回中性色"。
|
|
576
|
+
// 解析不出(前缀形态异常)时返回 null,由上层按"归属未定"处理,绝不编一个 id 上去。
|
|
577
|
+
return {
|
|
578
|
+
prefix: j.prefix, entry: j.entry, files, hit: true,
|
|
579
|
+
provider: providerFromPrefix(j.prefix),
|
|
580
|
+
src: hitOnly ? 'server-hit' : 'server-unpack',
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
async function materialize(file, opts = {}) {
|
|
584
|
+
const vfs = opts.vfs || getSharedVirtualFS();
|
|
585
|
+
const skip = prefilterReason(file);
|
|
586
|
+
if (skip) return { ok: false, reason: 'prefilter', message: `跳过:${skip}` };
|
|
587
|
+
|
|
588
|
+
// 路径 B|前端加载前预检:JS 堆占用已超阈值就先别加载(继续加载很可能直接 OOM 崩页面)。
|
|
589
|
+
// 浏览器不暴露 performance.memory 时 preflightLoad 会放行(拿不到真值就不假装保护)。
|
|
590
|
+
// 顺带把"这个包需要多少内存"取回来(面板判据行要显示真实需求,不能只显示可用量)——
|
|
591
|
+
// 不 await:判据读数只是展示,不能拖慢加载。
|
|
592
|
+
memoryGuard.refreshMemoryReadout({ rootId: file.rootId, rel: file.rel }).catch(() => { /* 取不到就维持上次 */ });
|
|
593
|
+
// 解包峰值准入**整个去掉**(曾经在这里判,也在 localPath 里判过,都不对)。
|
|
594
|
+
// 分工口径:服务端内存不够就由浏览器负责解包,而不是拒绝装载。
|
|
595
|
+
// 服务端 capacity 只负责「服务端要不要解 / 面板显示多少」,不参与前端解包准入。
|
|
596
|
+
// 浏览器侧的保护是 preflightLoad(JS 堆预检)+ 长操作监控(越线中止、已完成部分保留)。
|
|
597
|
+
// 实测背景:42MB 包服务端解包峰值 630MB 会被服务端自身拒绝,
|
|
598
|
+
// 而同一台机器的浏览器解同一个包 4.3s 且渲染正常。
|
|
599
|
+
const pre = await memoryGuard.preflightLoad(file.rel);
|
|
600
|
+
if (!pre.ok) {
|
|
601
|
+
say.warn(pre.message);
|
|
602
|
+
emit('memguard.preflight', { rel: file.rel, usedMB: pre.usedMB, limitMB: pre.limitMB, pct: pre.pct });
|
|
603
|
+
return { ok: false, reason: 'memory-guard', message: pre.message };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// 路径 C|长操作监控:**覆盖整条装载路径**(服务端取产物 / 本地解包 / 读大包字节都算)。
|
|
607
|
+
// 曾经只挂在"取服务端产物"的循环里 —— 可 cache miss 时走的是本地解包,
|
|
608
|
+
// 那个循环根本不执行,监控等于不存在(端到端实测发现)。
|
|
609
|
+
const mon = memoryGuard.startMemoryMonitor(`装载 ${file.rel}`);
|
|
610
|
+
try {
|
|
611
|
+
const tMat = performance.now();
|
|
612
|
+
// 运行时开关:?unpack=off 关掉服务端命中查询与预热(性能基线用;同一份代码只差开关)
|
|
613
|
+
const unpackOn = (() => {
|
|
614
|
+
try { return new URLSearchParams(location.search).get('unpack') !== '0'; } catch { return true; }
|
|
615
|
+
})();
|
|
616
|
+
if (!unpackOn) emit('unpack.race', { enabled: false, reason: 'URL 参数 unpack=0' });
|
|
617
|
+
|
|
618
|
+
// 本地通道:原逻辑一字未改(readFile → 插件 resolve)
|
|
619
|
+
const localPath = async () => {
|
|
620
|
+
// 这里**不做服务端预算准入**。分工口径(用户定):
|
|
621
|
+
// 服务端内存不够 ⇒ 就由浏览器负责解包,而不是拒绝装载。
|
|
622
|
+
// 服务端 capacity 只决定两件事:① 服务端要不要解(预热 / 直调接口)② 面板上显示多少
|
|
623
|
+
// 浏览器自己的保护是:preflightLoad(JS 堆预检)+ 下面这条长操作监控(越线就中止,
|
|
624
|
+
// 已完成的部分保留)。两者都在,够用。
|
|
625
|
+
// 实测背景:42MB 资源包服务端解包峰值 630MB > 可用预算时会直接拒绝;
|
|
626
|
+
// 但同一台机器的浏览器解同一个包只要 4.3s 且能正常渲染 —— 拒绝是错的。
|
|
627
|
+
// 读整包进内存是这条路径上最吃内存的一步:先查一次,越线就不读
|
|
628
|
+
if (mon.checkNow()) throw Object.assign(new Error(mon.reason()), { reason: 'memory-guard' });
|
|
629
|
+
// ── 方向④:**主线程不再预先读整包** ────────────────────────────────
|
|
630
|
+
// 以前这里先 bytesOf(file) 把 19MB 读进主线程、再 postMessage 拷给 worker,
|
|
631
|
+
// 实测那一段 3918ms/19.3MB,是首帧最大的单笔开销。现在改成:
|
|
632
|
+
// · 有内联 bytes(调用方已给)⇒ 照旧直接投喂
|
|
633
|
+
// · 否则只把 **URL + 体积** 交给 worker,由它自己 fetch(主线程只发一条小消息)
|
|
634
|
+
// 懒读兜底:只有"worker 整条路都不成立"时,才真正 bytesOf() 一次(回落到同步解包)。
|
|
635
|
+
let sizeHint = Number.isFinite(file.size) ? Number(file.size) : 0;
|
|
636
|
+
const fileUrl = fileUrlOf(file.rootId, file.rel);
|
|
637
|
+
const inlineBytes = toUint8Array(file.bytes);
|
|
638
|
+
// 调试开关 ?workerFetch=0:强制回"主线程先读整包再投喂"的旧行为。
|
|
639
|
+
// 存在的理由只有一个 —— 让"改造前/后"能在**同一台机器同一次会话**里 A/B 对比,
|
|
640
|
+
// 而不是拿两份不同时间的日志比。平时不设即走 url 模式。
|
|
641
|
+
const forceMainRead = (() => {
|
|
642
|
+
try { return new URLSearchParams(location.search).get('workerFetch') === '0'; } catch { return false; }
|
|
643
|
+
})();
|
|
644
|
+
let readCache = null;
|
|
645
|
+
const ensureBytes = async () => {
|
|
646
|
+
if (readCache) return readCache;
|
|
647
|
+
const r = await bytesOf(file);
|
|
648
|
+
if (!r.ok) throw Object.assign(new Error(r.message), { reason: 'read-failed' });
|
|
649
|
+
readCache = r;
|
|
650
|
+
return r;
|
|
651
|
+
};
|
|
652
|
+
// 条目没带体积时(少数历史条目 / 目录型),进度事件的 bytes 就没法凭空写对。
|
|
653
|
+
// 这时**先读一次**把真实体积拿到 —— 宁可多读这一次,也不要把既有事件语义
|
|
654
|
+
// 从"压缩字节数"降级成 0(下游按它显示"压缩 xMB / 估算耗时",0 会被当真)。
|
|
655
|
+
if (!inlineBytes && sizeHint <= 0) {
|
|
656
|
+
const r = await ensureBytes();
|
|
657
|
+
sizeHint = r.bytes.length;
|
|
658
|
+
}
|
|
659
|
+
// 同步解压前先把"在等什么"写出去,并**让出一帧**:
|
|
660
|
+
// 插件 resolve 是同步的,LZMA 包实测要 1.7~10 秒(≈300~620ms/MB 解压后体积),
|
|
661
|
+
// 主线程一旦进解压就再没机会渲染这条提示(DOM 已改、但浏览器不会给你帧)。
|
|
662
|
+
// 让帧代价约 16ms,换来"用户知道在解压、不是卡死"。非 LZMA 包几乎不受影响(多一帧)。
|
|
663
|
+
// 体积取自条目元信息(解包前主线程不读包,拿不到就得等读完才知道大小)
|
|
664
|
+
say.info(`本地解包中:${file.rel}(压缩约 ${(sizeHint / 1048576).toFixed(2)}MB,大包可能要数秒)`);
|
|
665
|
+
emit('unpack.local-start', { rootId: file.rootId, rel: file.rel, bytes: sizeHint });
|
|
666
|
+
// **所有压缩类型**都先试 worker:resolve 的成本只看包有多大,与压缩类型无关。
|
|
667
|
+
// 为什么从"只看 canHandle"改成"看 resolve 成本":
|
|
668
|
+
// · 旧门禁只看 canHandle 贵不贵(LZMA 的 canHandle 要 5.7s、LZ4 只要毫秒级),
|
|
669
|
+
// 就据此决定 LZ4/none 继续走同步 —— 但 canHandle 那点差异,在 resolve 面前可忽略。
|
|
670
|
+
// · resolve 要解压**全部数据块** + 抽文件 + 产物归一,实测代价:
|
|
671
|
+
// aijier_3(LZ4,解压后 44MB)同步 resolve 6.8s,主线程最长 longtask 2.8~3.9s;
|
|
672
|
+
// live2d/z46_2(LZMA,解压后 35MB)走 worker,主线程最长 longtask 仅 462ms。
|
|
673
|
+
// 两个包大小同级,主线程占用差 8 倍以上 —— 差别只在走不走 worker。
|
|
674
|
+
const bundleCands = providersFor(['bundle']);
|
|
675
|
+
// 「没有任何 bundle 插件」≠「所有候选 worker 都不可用」:
|
|
676
|
+
// 候选为空时下面的循环压根不跑,若照旧写死那句结论,就会把"没有插件能处理这个包"
|
|
677
|
+
// 误报成"worker 不可用",把排查方向带偏到 CSP / worker 加载。
|
|
678
|
+
// 候选为空就跳过整段,落到同步路径去报正确的"没有插件能处理"。
|
|
679
|
+
if (bundleCands.length) {
|
|
680
|
+
let fellBack = '';
|
|
681
|
+
let anyProbed = false; // 有没有任何一个 worker 真的判过"不认这个包"
|
|
682
|
+
for (const cand of bundleCands) {
|
|
683
|
+
// 有内联字节就直投;否则把 URL 交给 worker 自己拉(主线程零字节搬运)。
|
|
684
|
+
// ?workerFetch=0 时故意先读一遍,用来量"改造前"那一段的耗时。
|
|
685
|
+
const wBytes = forceMainRead ? (await ensureBytes()).bytes : inlineBytes;
|
|
686
|
+
const w = await resolveViaWorker(cand, file, wBytes, wBytes ? {} : { url: fileUrl, size: sizeHint });
|
|
687
|
+
if (w.ok) {
|
|
688
|
+
if (mon.checkNow()) throw Object.assign(new Error(mon.reason()), { reason: 'memory-guard' });
|
|
689
|
+
// 「说能解」≠「解出来能用」:产物不自洽就换下一个候选(判据见 resolvedUsable 注释)。
|
|
690
|
+
// 不加这一步:排在前面的插件用伪造产物(例如 moc3 写成 null)抢先成功,
|
|
691
|
+
// 真正能解它的插件永远轮不到 ⇒ 装载阶段才炸,且只留一句 Network error。
|
|
692
|
+
const usable = await resolvedUsable(w.res);
|
|
693
|
+
if (!usable.ok) {
|
|
694
|
+
anyProbed = true;
|
|
695
|
+
say.warn(`候选 ${cand.id} 的产物不可用(${usable.why})—— 换下一个候选`);
|
|
696
|
+
emit('unpack.candidate-reject', { rootId: file.rootId, rel: file.rel, provider: cand.id, why: usable.why });
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
return { ...w.res, provider: cand.id, src: 'local-worker' };
|
|
700
|
+
}
|
|
701
|
+
// 「这个 worker 建不起来」≠「这个 provider 不认这个包」:
|
|
702
|
+
// 前者应继续试下一个候选(另一个插件的 worker 可能能用),
|
|
703
|
+
// 后者才该落到同步路径去问它。写反了会让"第一个候选 worker 失败"直接把整条后台路径废掉
|
|
704
|
+
// —— 实测就是这么误降级的(spine 插件 worker 建不起来 ⇒ 明明 live2d worker 可用却回落到同步)。
|
|
705
|
+
if (w.unavailable) continue;
|
|
706
|
+
if (w.noHandle) { anyProbed = true; continue; }
|
|
707
|
+
fellBack = w.error || '解包失败';
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
// 全都没判成(worker 一个都起不来)才报降级;"有 worker 判过但不认"属正常,
|
|
711
|
+
// 交给下面的同步路径去给出正确的"没有插件能处理"错误,不该谎报成后台不可用
|
|
712
|
+
if (!fellBack && !anyProbed) fellBack = '所有候选插件的 worker 都不可用';
|
|
713
|
+
if (fellBack) {
|
|
714
|
+
// 降级**不静默**:明确告诉用户"后台解包用不了,接下来会卡数秒"
|
|
715
|
+
say.warn(`后台解包不可用(${fellBack})—— 回落主线程解包,期间界面会卡住数秒`);
|
|
716
|
+
emit('unpack.worker-fallback', { rootId: file.rootId, rel: file.rel, error: fellBack });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// 同步解压前让出一帧:DOM 改了但主线程随即冻住,浏览器不会给你渲染机会。
|
|
720
|
+
// 代价约 16ms,换来"用户知道在解压、不是卡死"。
|
|
721
|
+
await new Promise((resolve) => {
|
|
722
|
+
const next = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (cb) => setTimeout(cb, 16);
|
|
723
|
+
next(() => resolve());
|
|
724
|
+
});
|
|
725
|
+
// 走到这里说明 worker 路径没成:**这时候才**把整包读进主线程(懒读兜底)
|
|
726
|
+
const read = await ensureBytes();
|
|
727
|
+
const got = await resolveViaProvider(file, read.bytes);
|
|
728
|
+
if (mon.checkNow()) throw Object.assign(new Error(mon.reason()), { reason: 'memory-guard' });
|
|
729
|
+
if (!got.ok) throw Object.assign(new Error(got.message || '本地解包失败'), { reason: got.reason, passthrough: got });
|
|
730
|
+
return { ...got.res, provider: got.provider, src: 'local' };
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
// 前台装载:**命中就取产物;miss 就让服务端解(别自己再解一遍)**。
|
|
734
|
+
//
|
|
735
|
+
// 为什么改(原逻辑是"miss 就本地解包"):
|
|
736
|
+
// 服务端解包是原生能力、且预热本来就在解同一个包。前台在 miss 时自己再解一遍,
|
|
737
|
+
// 结果就是同一个 19MB 包被解两次(前端 4.3s + 服务端 206ms),纯重复劳动。
|
|
738
|
+
// 回落本地只在「服务端明确说不行」时发生:内存准入拒绝 / 不可解 / 文件不可读。
|
|
739
|
+
// **不按"等了几秒"判死** —— 超时只记日志并重试一次,不据此改走另一条路。
|
|
740
|
+
let won = null;
|
|
741
|
+
let serverSaidNo = '';
|
|
742
|
+
|
|
743
|
+
// 第一个**成功**的结果;全部失败才抛(抛最先失败那条的错误,形状最接近单条路径)。
|
|
744
|
+
// 为什么不用 Promise.race:race 以"第一个 settle"为准 —— 一条线失败就整体失败,
|
|
745
|
+
// 而这里任一成功就算成功(另一条线失败只是"没抢到",不是结论)。
|
|
746
|
+
// 落败的那条不取消:它的产物由它自己的调用约定处理(本地线只返回、不注册 VFS),
|
|
747
|
+
// 但必须挂上 catch,否则它稍后 reject 会变成 unhandledrejection。
|
|
748
|
+
const firstWon = (ps) => new Promise((resolve, reject) => {
|
|
749
|
+
let left = ps.length;
|
|
750
|
+
let firstErr = null;
|
|
751
|
+
const down = () => { left -= 1; if (left === 0) reject(firstErr || new Error('解包失败:两条通路都没有结果')); };
|
|
752
|
+
for (const p of ps) {
|
|
753
|
+
p.then((v) => { if (v) resolve(v); else down(); }, (e) => { if (!firstErr) firstErr = e; down(); });
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
if (unpackOn && !file.bytes && file.rootId && file.rel) {
|
|
758
|
+
// **双端解**:服务端命中查询与本地解包**同时开始**,谁先出结果用谁。
|
|
759
|
+
// 设计口径:miss 时两端都解 —— 前端先解完就先显示(首屏不接受被后端拖住),
|
|
760
|
+
// 后端那条线解完落盘做缓存,供下次点击直接命中(命中路径仍然最快)。
|
|
761
|
+
//
|
|
762
|
+
// 为什么不能串行(先 await 查服务端、miss 再本地解):
|
|
763
|
+
// 服务端是单进程 Node,它正在跑另一个解包时事件循环被占住,
|
|
764
|
+
// 连"只读缓存索引"的 peek 都得排队 ⇒ 前端本地解包被推迟到后端忙完,
|
|
765
|
+
// 表现为"日志里解包/预热都完成了、画布却不出画,再点一次(后端已落盘)才显示"。
|
|
766
|
+
// 并行之后:服务端慢只影响"能不能省一次解包",绝不影响首屏。
|
|
767
|
+
//
|
|
768
|
+
// 前台仍然**绝不请求服务端解包**(hitOnly):解包由本地线或那两条预热线负责。
|
|
769
|
+
const serverP = resolveFromServer(file, { mon }).catch((e) => {
|
|
770
|
+
// 未命中(或查询失败)都交给本地线;查询失败已由 resolveFromServer 记进 diag
|
|
771
|
+
serverSaidNo = (e && (e.reason || e.message)) || 'server-miss';
|
|
772
|
+
return null;
|
|
773
|
+
});
|
|
774
|
+
const localP = localPath();
|
|
775
|
+
try {
|
|
776
|
+
won = await firstWon([serverP, localP]);
|
|
777
|
+
} catch (e) {
|
|
778
|
+
// 内存保护的判定是**结论**,不是"服务端失败":绝不能回落服务端/本地把它绕过去
|
|
779
|
+
if (e && e.reason === 'memory-guard') {
|
|
780
|
+
return { ok: false, reason: e.reason, message: String((e && e.message) || e) };
|
|
781
|
+
}
|
|
782
|
+
// ── 最后一级兜底:让**服务端真解包**(hitOnly:false)再试一次 ──────────────
|
|
783
|
+
// 为什么需要它:本地线只能跑 JS 插件,而有些包的纹理解码格式它不支持
|
|
784
|
+
// (实测:ASTC/50、PVRTC/49)⇒ 本地必然失败;但服务端可能还有别的运行时
|
|
785
|
+
// (exec/Python 插件,能力来自 UnityPy)能解。没有这一级,这类包在页面上永远打不开,
|
|
786
|
+
// 而同一个包用 /api/unpack/resolve 却能解开 —— 那是"两条路两种答案",不可接受。
|
|
787
|
+
// 代价可控:只在**本地已失败**之后才走,不影响首屏(首屏竞速仍是 server-hit + local)。
|
|
788
|
+
//
|
|
789
|
+
// ⚠️ 这一级必须放在 `passthrough` 之前:本地解包失败时一定带 passthrough
|
|
790
|
+
// (见 localPath 里 `Object.assign(..., { passthrough: got })`),
|
|
791
|
+
// 先处理它就会直接 return,兜底永远走不到(实测就是这个原因)。
|
|
792
|
+
if (unpackOn && !file.bytes && file.rootId && file.rel) {
|
|
793
|
+
say.warn(`本地解包失败,改请服务端解包:${file.rel}(${(e && e.message) || e})`);
|
|
794
|
+
emit('unpack.fallback', { rootId: file.rootId, rel: file.rel, localReason: (e && e.reason) || 'local-failed' });
|
|
795
|
+
try {
|
|
796
|
+
won = await resolveFromServer(file, { hitOnly: false, mon });
|
|
797
|
+
} catch (e2) {
|
|
798
|
+
// 兜底也失败:本地失败保持原有返回形状(那是上层认识的形状)
|
|
799
|
+
if (e && e.passthrough) return e.passthrough;
|
|
800
|
+
return {
|
|
801
|
+
ok: false,
|
|
802
|
+
reason: (e2 && e2.reason) || 'unpack-failed',
|
|
803
|
+
message: `本地解包失败(${(e && e.message) || e}),服务端解包也失败(${(e2 && e2.message) || e2})`,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
} else {
|
|
807
|
+
if (e && e.passthrough) return e.passthrough; // 无可兜底时保持原有返回形状
|
|
808
|
+
return { ok: false, reason: (e && e.reason) || 'unpack-failed', message: String((e && e.message) || e) };
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
emit('unpack.race', {
|
|
812
|
+
rootId: file.rootId, rel: file.rel,
|
|
813
|
+
winner: won && won.src, // server-hit | local-worker | local
|
|
814
|
+
serverMiss: serverSaidNo || null,
|
|
815
|
+
});
|
|
816
|
+
} else {
|
|
817
|
+
try {
|
|
818
|
+
won = await localPath();
|
|
819
|
+
} catch (e) {
|
|
820
|
+
if (e && e.passthrough) return e.passthrough; // 本地失败保持原有返回形状
|
|
821
|
+
// 内存保护的判定是**结论**,不是"服务端失败":绝不能回落本地解包把它绕过去
|
|
822
|
+
return { ok: false, reason: (e && e.reason) || 'unpack-failed', message: String((e && e.message) || e) };
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const reg = registerResolved(vfs, won);
|
|
827
|
+
if (!reg.ok) return { ok: false, reason: 'register-failed', message: reg.message };
|
|
828
|
+
|
|
829
|
+
const entryUrl = vfs.resolve(won.prefix + String(won.entry).replace(/^\.?\//, ''));
|
|
830
|
+
// 三态日志口径:命中缓存 / 服务端解包 / 回落或本地解包
|
|
831
|
+
// 三态 + 落败原因(验收"回落可用"就靠这条能被读到)
|
|
832
|
+
// 三态(验收要求 source 能区分"重复劳动"是否发生):
|
|
833
|
+
// 解包命中缓存 = 服务端本来就有产物(最快,<500ms)
|
|
834
|
+
// 服务端解包 = miss 后由服务端解出来(前台没自己再解一遍)
|
|
835
|
+
// 回落前端解包 = 服务端明确不能解,才由前端解
|
|
836
|
+
const source = won.src === 'server-hit' ? '解包命中缓存'
|
|
837
|
+
: won.src === 'server-unpack' ? '服务端解包'
|
|
838
|
+
: won.src === 'local-worker' ? '后台解包(Worker,主线程不阻塞)'
|
|
839
|
+
: '回落前端解包';
|
|
840
|
+
const detail = { rootId: file.rootId, rel: file.rel, source, files: filesCount(won.files), ms: Math.round(performance.now() - tMat), ...(serverSaidNo ? { miss: serverSaidNo } : {}) };
|
|
841
|
+
say.info(`${source}:${file.rootId}/${file.rel}(${detail.files} 个文件,${detail.ms}ms)`);
|
|
842
|
+
emit('unpack.source', detail);
|
|
843
|
+
return {
|
|
844
|
+
ok: true,
|
|
845
|
+
provider: won.provider,
|
|
846
|
+
prefix: won.prefix,
|
|
847
|
+
entry: won.entry,
|
|
848
|
+
entryUrl,
|
|
849
|
+
files: filesCount(won.files),
|
|
850
|
+
reRegistered: reg.reRegistered,
|
|
851
|
+
source, // 供上层/诊断断言:解包命中缓存 | 服务端解包 | 回落前端解包
|
|
852
|
+
};
|
|
853
|
+
} finally {
|
|
854
|
+
// 无论正常返回、抛错还是被内存监控中止,都要停采样(否则 requestAnimationFrame 会一直排下去)
|
|
855
|
+
mon.stop();
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
/** 已注册的提供者(可按 fileKinds 过滤,如 ['bundle']) */
|
|
859
|
+
function providersFor(fileKinds) {
|
|
860
|
+
const list = [...providers.values()];
|
|
861
|
+
if (!fileKinds || !fileKinds.length) return list;
|
|
862
|
+
const want = new Set(fileKinds);
|
|
863
|
+
return list.filter((p) => Array.isArray(p.fileKinds) && p.fileKinds.some((k) => want.has(k)));
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* 找一个能处理该文件的提供者(走插件的 canHandle)。
|
|
868
|
+
* file 形状按契约 §1.1:{ rootId, rel, name?, bytes?, profile? };host 由这里注入(§3.6)。
|
|
869
|
+
* canHandle 必须是同步布尔(§3.1);插件内部抛错一律当"不认",不冒泡。
|
|
870
|
+
* @param {object} file
|
|
871
|
+
* @returns {object|null} 命中的 provider
|
|
872
|
+
*/
|
|
873
|
+
function findProvider(file) {
|
|
874
|
+
if (!file || typeof file !== 'object') return null;
|
|
875
|
+
const probe = { ...file, host: hostApi };
|
|
876
|
+
if (file.bytes !== undefined && file.bytes !== null) {
|
|
877
|
+
const normalized = toUint8Array(file.bytes);
|
|
878
|
+
if (normalized) probe.bytes = normalized;
|
|
879
|
+
else say.warn(`findProvider 收到无法识别的 bytes 类型(${describeType(file.bytes)}):`
|
|
880
|
+
+ '契约 §1.1 要求 Uint8Array;插件可能因此判不出容器(表现为"没有提供者")');
|
|
881
|
+
}
|
|
882
|
+
for (const p of providers.values()) {
|
|
883
|
+
// 只有声明了 asset-provider 角色的插件才参与"这个文件归谁解"(契约 §一/§二)。
|
|
884
|
+
// 其它角色(renderer/editor/exporter/panel)不该在这里被问到。
|
|
885
|
+
if (!Array.isArray(p.provides) || !p.provides.includes('asset-provider')) continue;
|
|
886
|
+
if (typeof p.canHandle !== 'function') continue;
|
|
887
|
+
let hit = false;
|
|
888
|
+
try {
|
|
889
|
+
hit = p.canHandle(probe) === true;
|
|
890
|
+
} catch (e) {
|
|
891
|
+
say.warn(`提供者 ${p.id} 的 canHandle 抛错(按不处理该文件处理):${(e && e.message) || e}`);
|
|
892
|
+
hit = false;
|
|
893
|
+
}
|
|
894
|
+
if (hit) return p;
|
|
895
|
+
}
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
return { materialize, fetchFile, providersFor, findProvider, prefilterReason, pool: workerPool };
|
|
899
|
+
}
|