modelstage 0.0.0-stage → 0.5.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +267 -2
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/bin/modelstage.mjs +101 -0
- package/config/diag-upload.example.json +7 -0
- package/config/models/live2d.assetbundls.json +1 -0
- package/config/models/live2d.azurlane.json +1 -0
- package/config/models/live2d.azurlane_bundle.json +1 -0
- package/config/models/live2d.azurlane_spine.json +1 -0
- package/config/models/live2d.bd2.json +1 -0
- package/config/models/live2d.bluearchive.json +1 -0
- package/config/models/live2d.live2d.json +1 -0
- package/config/models/live2d.nikke.json +1 -0
- package/config/models/live2d.nikke_2.json +1 -0
- package/config/models/live2d.npc.json +1 -0
- package/config/models/spine.assetbundls.json +1 -0
- package/config/models/spine.azurlane.json +1 -0
- package/config/models/spine.azurlane_bundle.json +1 -0
- package/config/models/spine.azurlane_spine.json +1 -0
- package/config/models/spine.bd2.json +1 -0
- package/config/models/spine.bluearchive.json +1 -0
- package/config/models/spine.live2d.json +1 -0
- package/config/models/spine.nikke.json +1 -0
- package/config/models/spine.nikke_2.json +1 -0
- package/config/models/spine.npc.json +1 -0
- package/config/names/NIKKE.json +1058 -0
- package/config/names/README.md +47 -0
- package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
- package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
- package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
- package/config/plugins.json +13 -0
- package/config/resources.example.json +40 -0
- package/package.json +58 -4
- package/plugins/unity-live2d-bundle/README.md +98 -0
- package/plugins/unity-live2d-bundle/index.js +891 -0
- package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
- package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
- package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
- package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
- package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
- package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
- package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
- package/plugins/unity-live2d-bundle/lib/png.js +424 -0
- package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
- package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
- package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-live2d-bundle/plugin.json +81 -0
- package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
- package/plugins/unity-spine-bundle/index.js +535 -0
- package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
- package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
- package/plugins/unity-spine-bundle/lib/errors.js +79 -0
- package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
- package/plugins/unity-spine-bundle/lib/extract.js +242 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-spine-bundle/lib/png.js +424 -0
- package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
- package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
- package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-spine-bundle/plugin.json +75 -0
- package/src/server/README.md +69 -0
- package/src/server/assets/browse.mjs +56 -0
- package/src/server/assets/bundle-probe.mjs +94 -0
- package/src/server/assets/probe.mjs +184 -0
- package/src/server/core/config.mjs +173 -0
- package/src/server/core/env-num.mjs +60 -0
- package/src/server/core/env.mjs +49 -0
- package/src/server/core/fs-portable.mjs +37 -0
- package/src/server/core/log.mjs +107 -0
- package/src/server/core/paths.mjs +44 -0
- package/src/server/data/catalog.mjs +1091 -0
- package/src/server/data/entry-states.mjs +499 -0
- package/src/server/data/live2d.mjs +272 -0
- package/src/server/data/manifest-cache.mjs +58 -0
- package/src/server/data/manifest-stamp.mjs +135 -0
- package/src/server/data/model-meta.mjs +114 -0
- package/src/server/data/names.mjs +106 -0
- package/src/server/data/shape-probe-worker.mjs +33 -0
- package/src/server/data/shape-probe.mjs +626 -0
- package/src/server/data/spine.mjs +336 -0
- package/src/server/data/tree.mjs +126 -0
- package/src/server/http/atomic-json.mjs +145 -0
- package/src/server/http/background-solids.mjs +270 -0
- package/src/server/http/backgrounds.mjs +388 -0
- package/src/server/http/diag-upload.mjs +264 -0
- package/src/server/http/diag.mjs +173 -0
- package/src/server/http/ready.mjs +167 -0
- package/src/server/http/static.mjs +312 -0
- package/src/server/http/write.mjs +647 -0
- package/src/server/index.mjs +114 -0
- package/src/server/plugins/builtin-providers.json +30 -0
- package/src/server/plugins/registry.mjs +650 -0
- package/src/server/plugins/slot-catalog.mjs +286 -0
- package/src/server/routes/background-solids.mjs +56 -0
- package/src/server/routes/backgrounds.mjs +77 -0
- package/src/server/routes/config.mjs +38 -0
- package/src/server/routes/data.mjs +313 -0
- package/src/server/routes/diag.mjs +37 -0
- package/src/server/routes/entries.mjs +170 -0
- package/src/server/routes/ready.mjs +122 -0
- package/src/server/serve.mjs +334 -0
- package/src/server/unpack/capacity.mjs +217 -0
- package/src/server/unpack/mem-probe.mjs +27 -0
- package/src/server/unpack/plugin-runner.mjs +529 -0
- package/src/server/unpack/plugin-store.mjs +736 -0
- package/src/server/unpack/probe-store.mjs +73 -0
- package/src/server/unpack/routes.mjs +359 -0
- package/src/server/unpack/runtime/exec.mjs +558 -0
- package/src/server/unpack/runtime/index.mjs +141 -0
- package/src/server/unpack/runtime/node-vm.mjs +111 -0
- package/src/server/unpack/unpack-worker.mjs +105 -0
- package/src/server/unpack/worker-client.mjs +184 -0
- package/src/server/unpack/write-result.mjs +84 -0
- package/src/shared/bundle-shape.mjs +83 -0
- package/src/shared/entry-reasons.mjs +119 -0
- package/src/shared/names.mjs +45 -0
- package/src/web/README.md +193 -0
- package/src/web/app/anim-list.js +222 -0
- package/src/web/app/background-solids.js +150 -0
- package/src/web/app/backgrounds.js +152 -0
- package/src/web/app/camera-log.js +28 -0
- package/src/web/app/context.js +72 -0
- package/src/web/app/dir-picker.js +242 -0
- package/src/web/app/entry-report.js +71 -0
- package/src/web/app/memory-bars.js +91 -0
- package/src/web/app/memory-budget.js +228 -0
- package/src/web/app/memory-guard.js +204 -0
- package/src/web/app/model-actions.js +682 -0
- package/src/web/app/renderer-setting.js +70 -0
- package/src/web/app/root-manager.js +254 -0
- package/src/web/app/status.js +138 -0
- package/src/web/app/topbar-actions.js +357 -0
- package/src/web/app/transport-actions.js +297 -0
- package/src/web/app/ui-state.js +559 -0
- package/src/web/app/url-params.js +171 -0
- package/src/web/app/view-settings.js +186 -0
- package/src/web/components.css +465 -0
- package/src/web/debug/diag-hud.js +95 -0
- package/src/web/debug/drag.js +369 -0
- package/src/web/debug/log.js +320 -0
- package/src/web/debug/model-cmp.js +292 -0
- package/src/web/debug/panel.css +474 -0
- package/src/web/debug/panel.js +712 -0
- package/src/web/debug/race-probe.js +82 -0
- package/src/web/debug/upload.js +130 -0
- package/src/web/engine/adapters/atlas.js +47 -0
- package/src/web/engine/adapters/index.js +22 -0
- package/src/web/engine/adapters/renderer.js +223 -0
- package/src/web/engine/adapters/skeleton.js +105 -0
- package/src/web/engine/adapters/texture.js +64 -0
- package/src/web/engine/animation.js +181 -0
- package/src/web/engine/appearance.js +136 -0
- package/src/web/engine/camera-adapter.js +212 -0
- package/src/web/engine/effect-chain.js +138 -0
- package/src/web/engine/engine-state.js +120 -0
- package/src/web/engine/filters.js +224 -0
- package/src/web/engine/fit.js +197 -0
- package/src/web/engine/frame-gate.js +33 -0
- package/src/web/engine/gl-driver.js +56 -0
- package/src/web/engine/gl.js +77 -0
- package/src/web/engine/index.js +20 -0
- package/src/web/engine/loader.js +55 -0
- package/src/web/engine/model-info.js +26 -0
- package/src/web/engine/pipeline.js +200 -0
- package/src/web/engine/player.js +427 -0
- package/src/web/engine/pma/blend.js +76 -0
- package/src/web/engine/pma/detect.js +118 -0
- package/src/web/engine/pma/index.js +8 -0
- package/src/web/engine/pma/resolve.js +112 -0
- package/src/web/engine/pma/upload.js +127 -0
- package/src/web/engine/pma.js +5 -0
- package/src/web/engine/render-frame.js +93 -0
- package/src/web/engine/runtime-capabilities.js +209 -0
- package/src/web/engine/runtime.js +37 -0
- package/src/web/engine/skins.js +165 -0
- package/src/web/engine/textures.js +174 -0
- package/src/web/index.html +115 -0
- package/src/web/infra/api.js +531 -0
- package/src/web/infra/diag.js +509 -0
- package/src/web/infra/document.js +269 -0
- package/src/web/infra/dom.js +31 -0
- package/src/web/infra/events.mjs +27 -0
- package/src/web/infra/log.js +153 -0
- package/src/web/infra/mem-stats.js +112 -0
- package/src/web/infra/plugin-host-registration.mjs +285 -0
- package/src/web/infra/plugin-host-report.mjs +32 -0
- package/src/web/infra/plugin-host-unload.mjs +79 -0
- package/src/web/infra/plugin-host-unpack.mjs +899 -0
- package/src/web/infra/plugin-host-warmup.mjs +180 -0
- package/src/web/infra/plugin-host.js +628 -0
- package/src/web/infra/ready.js +461 -0
- package/src/web/infra/schedule.js +51 -0
- package/src/web/infra/shortcuts.js +129 -0
- package/src/web/infra/slot-registry.js +402 -0
- package/src/web/infra/vfs.js +247 -0
- package/src/web/live2d/bridge.js +400 -0
- package/src/web/live2d/model.js +321 -0
- package/src/web/live2d/overlay.js +142 -0
- package/src/web/live2d/pixi-app.js +111 -0
- package/src/web/live2d/playback-probe.js +92 -0
- package/src/web/live2d/probe-geometry.js +329 -0
- package/src/web/live2d/probe.js +119 -0
- package/src/web/live2d/release.js +66 -0
- package/src/web/live2d/runtime-adapter.js +289 -0
- package/src/web/live2d/stage-ctx.js +152 -0
- package/src/web/live2d/stage.js +204 -0
- package/src/web/live2d/vfs-settings.js +174 -0
- package/src/web/live2d/view.js +89 -0
- package/src/web/main.js +629 -0
- package/src/web/panel/background-list.js +373 -0
- package/src/web/panel/bg-source.js +291 -0
- package/src/web/panel/color-picker.css +199 -0
- package/src/web/panel/color-picker.js +278 -0
- package/src/web/panel/dialog.css +116 -0
- package/src/web/panel/dialog.js +173 -0
- package/src/web/panel/dom.js +35 -0
- package/src/web/panel/icons.js +58 -0
- package/src/web/panel/memory-bar.js +195 -0
- package/src/web/panel/panel-settings.js +1263 -0
- package/src/web/panel/panel.css +1304 -0
- package/src/web/panel/render-choice.js +104 -0
- package/src/web/panel/schema.js +784 -0
- package/src/web/panel/settings-io.js +291 -0
- package/src/web/panel/shell-controls.js +274 -0
- package/src/web/panel/slider.js +210 -0
- package/src/web/panel/toolbar-schema.js +111 -0
- package/src/web/panel/toolbar.js +195 -0
- package/src/web/panel/transport-picker.js +375 -0
- package/src/web/panel/transport.js +321 -0
- package/src/web/panel/view.js +553 -0
- package/src/web/plugin/unpack-worker.js +185 -0
- package/src/web/render/camera/constants.js +60 -0
- package/src/web/render/camera/index.js +123 -0
- package/src/web/render/camera/spine.js +124 -0
- package/src/web/render/capture.js +30 -0
- package/src/web/render/interaction.js +527 -0
- package/src/web/render/live2d.js +627 -0
- package/src/web/render/playback.js +47 -0
- package/src/web/render/reset-view.js +29 -0
- package/src/web/render/spine.js +386 -0
- package/src/web/render/surface-switch.js +176 -0
- package/src/web/render/surface.js +571 -0
- package/src/web/render/surfaces.js +70 -0
- package/src/web/roster/filter.js +31 -0
- package/src/web/roster/list.js +276 -0
- package/src/web/roster/persist.js +52 -0
- package/src/web/roster/reveal.js +77 -0
- package/src/web/roster/rows.js +248 -0
- package/src/web/roster/snapshot.js +37 -0
- package/src/web/roster/view.js +670 -0
- package/src/web/spine/canvas-api-groups.js +267 -0
- package/src/web/spine/canvas-api.js +97 -0
- package/src/web/spine/canvas-document.js +80 -0
- package/src/web/spine/canvas-engine.js +193 -0
- package/src/web/spine/canvas-entry.js +28 -0
- package/src/web/spine/canvas-pixels.js +36 -0
- package/src/web/spine/canvas-runtime.js +127 -0
- package/src/web/spine/canvas-view.js +108 -0
- package/src/web/spine/canvas.js +7 -0
- package/src/web/spine/runtime-hint.js +73 -0
- package/src/web/spine/runtimes.js +212 -0
- package/src/web/style.css +493 -0
- package/tool/lib/bundle-probe-node.mjs +121 -0
- package/tool/lib/check-perms.mjs +114 -0
- package/tool/lib/contract-checks.mjs +582 -0
- package/tool/lib/cubism-core.mjs +122 -0
- package/tool/lib/export-bundle.mjs +328 -0
- package/tool/lib/paths.mjs +20 -0
- package/tool/lib/runtime-guides.mjs +65 -0
- package/tool/lib/runtime-package.mjs +403 -0
- package/tool/lib/spine-meta.mjs +227 -0
- package/tool/lib/spine-motions.mjs +104 -0
- package/tool/lib/spine-runtime.mjs +131 -0
- package/tool/lib/test-lock.mjs +178 -0
- package/tool/lib/test-report.mjs +133 -0
- package/tool/parse-jsonc.mjs +86 -0
- package/tool/resolve-live2d-runtimes.mjs +276 -0
- package/tool/spine-motion-rules.json +101 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// app/entry-report.js —— 装载结果上报(契约《条目状态与展示归属》§三 的前端侧)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有这一层:装载成功/失败都必须写进服务端的条目状态,并且**立刻**把那一行画对。
|
|
4
|
+
// 服务端的 POST /api/entries/result 是"写读一体"的:响应里就带回重算后的
|
|
5
|
+
// display/support/loadResult。所以这里一次请求同时完成两件事,不需要再拉 /api/catalog
|
|
6
|
+
// (那是秒级扫描,为一行颜色付这个代价是浪费,而且刚解包时它未必已经能看到)。
|
|
7
|
+
//
|
|
8
|
+
// 旁路原则(契约 INV-D3):上报失败**绝不能**影响装载结果 —— 所有异常在这里吞掉只记日志。
|
|
9
|
+
import { postJson } from '../infra/api.js';
|
|
10
|
+
import { stageLog } from './status.js';
|
|
11
|
+
import { state } from './context.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 条目的状态键:与 VFS 前缀同构,`${rootId}/${rel}`。
|
|
15
|
+
*
|
|
16
|
+
* 为什么不用条目 id:容器 id 是 `bundle-<rootId>-<rel 把 / 换成 _>` 派生的,**不可逆**,
|
|
17
|
+
* 不同 rel 可能撞成同一个键;而 rootId/rel 是解包与存储都天然认得的形态。
|
|
18
|
+
* @param {object} model 名册条目
|
|
19
|
+
* @returns {string} 空串 = 这个条目没有可用的键(调用方应跳过上报)
|
|
20
|
+
*/
|
|
21
|
+
export function entryStateKey(model) {
|
|
22
|
+
const rootId = String((model && model.rootId) || '');
|
|
23
|
+
const rel = String((model && (model.rel || model.srcDir || model.name)) || '');
|
|
24
|
+
if (!rootId || !rel) return '';
|
|
25
|
+
return `${rootId}/${rel}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 上报一次装载结果,并用响应就地更新名册那一行。
|
|
30
|
+
*
|
|
31
|
+
* @param {object} model 名册条目
|
|
32
|
+
* @param {{result: 'ok'|'failed', reason?: string|null, message?: string|null,
|
|
33
|
+
* provider?: string|null}} outcome 结果(reason 用契约码,message 是人话)
|
|
34
|
+
* @returns {Promise<object|null>} 服务端重算后的三字段;失败/未上报返回 null
|
|
35
|
+
*/
|
|
36
|
+
export async function reportEntryResult(model, outcome) {
|
|
37
|
+
const key = entryStateKey(model);
|
|
38
|
+
if (!key) return null;
|
|
39
|
+
const body = {
|
|
40
|
+
key,
|
|
41
|
+
result: outcome.result === 'ok' ? 'ok' : 'failed',
|
|
42
|
+
reason: outcome.reason || null,
|
|
43
|
+
message: outcome.message || null,
|
|
44
|
+
provider: outcome.provider || null,
|
|
45
|
+
size: Number.isFinite(model && model.size) ? Number(model.size) : undefined,
|
|
46
|
+
source: 'client',
|
|
47
|
+
};
|
|
48
|
+
try {
|
|
49
|
+
const res = await postJson('/api/entries/result', body, { retries: 0 });
|
|
50
|
+
const computed = res && res.entry ? res.entry : null;
|
|
51
|
+
if (computed) {
|
|
52
|
+
// 必须走名册的写回入口,不能只刷 DOM:
|
|
53
|
+
// 传进来的 model 是 normalizeEntry 的**副本**(`{ ...entry }`),
|
|
54
|
+
// 改它不会影响名册渲染用的那个对象 ⇒ 下一次 render 就读回旧值,
|
|
55
|
+
// 表现为"当场变了色、一切换/搜索就掉回中性色"(实测的老 bug)。
|
|
56
|
+
// setEntryState 按 id 找到名册持有的原对象、写回三字段,再重画那一行。
|
|
57
|
+
const roster = state.roster;
|
|
58
|
+
const id = String((model && (model.id || model.name)) || '');
|
|
59
|
+
try {
|
|
60
|
+
if (roster && typeof roster.setEntryState === 'function' && id) roster.setEntryState(id, computed);
|
|
61
|
+
} catch { /* 名册刷新失败不影响装载,也不影响已写入的服务端状态 */ }
|
|
62
|
+
}
|
|
63
|
+
return computed;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
// 旁路:上报失败只记一条 info,绝不升级成装载失败
|
|
66
|
+
try {
|
|
67
|
+
stageLog('info', `装载结果未上报(${(e && e.message) || e}):${key}`, { key, result: body.result });
|
|
68
|
+
} catch { /* 日志失败也不影响 */ }
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// app/memory-bars.js —— 内存三色条的两个挂载点(舞台顶部 / 设置面板)统一管理
|
|
2
|
+
//
|
|
3
|
+
// 为什么要这一层:面板会重建、舞台容器常在,两处各有自己的生命周期。
|
|
4
|
+
// 组件本身(panel/memory-bar.js)只管"画一条";谁该在什么时候拉数据、要不要显示,
|
|
5
|
+
// 集中在这么一个小模块里,免得散落到 model-actions / panel-settings / shell-controls 各处。
|
|
6
|
+
//
|
|
7
|
+
// 刷新时机(事件驱动,无定时器):
|
|
8
|
+
// · 模型加载成功后 → refreshStage()
|
|
9
|
+
// · 面板重建(展开设置时) → refreshPanel()
|
|
10
|
+
// · 「显示状态信息」开关 → setStageVisible()(关掉时舞台那条跟着消失,与状态行同策略)
|
|
11
|
+
|
|
12
|
+
import { mountMemoryBar } from '../panel/memory-bar.js';
|
|
13
|
+
|
|
14
|
+
/** 舞台顶部那条(容器在 index.html 里) */
|
|
15
|
+
const stageBar = mountMemoryBar('#mem-bar-stage');
|
|
16
|
+
|
|
17
|
+
/** 设置面板里那条(容器由 schema 的 ctl-memory-bar 提供) */
|
|
18
|
+
const panelBar = mountMemoryBar('#right-panel [data-ctl="ctl-memory-bar"]');
|
|
19
|
+
|
|
20
|
+
/** 舞台那条是否允许显示(跟「显示状态信息」开关走,默认开) */
|
|
21
|
+
let stageVisible = true;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 刷新舞台顶部那条(模型加载成功后调用)。
|
|
25
|
+
* @returns {Promise<boolean>} 是否显示成功
|
|
26
|
+
*/
|
|
27
|
+
export async function refreshStage() {
|
|
28
|
+
if (!stageVisible) { stageBar.hide(); return false; }
|
|
29
|
+
return stageBar.refresh();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 刷新设置面板那条(面板重建后调用)。
|
|
34
|
+
* @returns {Promise<boolean>}
|
|
35
|
+
*/
|
|
36
|
+
export async function refreshPanel() {
|
|
37
|
+
return panelBar.refresh();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 「显示状态信息」开关联动:关掉时舞台那条立即隐藏(不拉数据、不留空壳)。
|
|
42
|
+
* @param {boolean} on
|
|
43
|
+
*/
|
|
44
|
+
export function setStageVisible(on) {
|
|
45
|
+
stageVisible = on !== false;
|
|
46
|
+
if (!stageVisible) { stageBar.hide(); return; }
|
|
47
|
+
// 打开时补拉一次(此刻可能还没有数据)
|
|
48
|
+
refreshStage().catch(() => { /* 失败已由组件内部记 warn 并隐藏 */ });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 绑定「展开设置面板 → 拉一次」(文档 §五)。
|
|
53
|
+
* 面板重建时也会调 refreshPanel,但"点开设置组"不重建面板 ⇒ 单独绑点击。
|
|
54
|
+
* 幂等:只绑一次。
|
|
55
|
+
* @returns {boolean} 本次是否新绑定
|
|
56
|
+
*/
|
|
57
|
+
let bound = false;
|
|
58
|
+
export function bindPanelBarRefresh() {
|
|
59
|
+
if (bound) return false;
|
|
60
|
+
bound = true;
|
|
61
|
+
document.addEventListener('click', (ev) => {
|
|
62
|
+
const trigger = ev.target && typeof ev.target.closest === 'function'
|
|
63
|
+
? ev.target.closest('#right-panel .acc-trigger')
|
|
64
|
+
: null;
|
|
65
|
+
if (!trigger) return;
|
|
66
|
+
const label = (trigger.textContent || '').trim();
|
|
67
|
+
// 只关心「设置」组:其它组与内存无关,不必拉数据
|
|
68
|
+
if (!label.includes('设置')) return;
|
|
69
|
+
refreshPanel().catch(() => { /* 失败已由组件记 warn 并隐藏 */ });
|
|
70
|
+
}, true);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 供断言:两处当前显示的数值(无数据返回 null) */
|
|
75
|
+
export function memoryBarSnapshot() {
|
|
76
|
+
const read = (sel) => {
|
|
77
|
+
const bar = document.querySelector(sel);
|
|
78
|
+
if (!bar || bar.hidden) return null;
|
|
79
|
+
return {
|
|
80
|
+
aria: bar.getAttribute('aria-label'),
|
|
81
|
+
text: (bar.querySelector('.mem-bar-text') || {}).textContent || '',
|
|
82
|
+
pct: {
|
|
83
|
+
server: bar.dataset.pctServer, other: bar.dataset.pctOther, avail: bar.dataset.pctAvail,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
stage: read('#mem-bar-stage .mem-bar'),
|
|
89
|
+
panel: read('#right-panel [data-ctl="ctl-memory-bar"] .mem-bar'),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// app/memory-budget.js —— 内存判据的**唯一决策入口**(策略层)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有这一层:项目里曾经同时存在三处"防内存打爆"的判断,各写各的
|
|
4
|
+
// ① capacity.mjs 用「可用内存 × 0.5 vs 预估峰值」——有权威翻译器、受开关控、文案带数字
|
|
5
|
+
// ② memory-guard.js 用「JS 堆 85%」——受开关控
|
|
6
|
+
// ③ plugin-host.js 用「裸包字节 > 512MB 硬编码」——不受开关控、文案丢数字、更不看内存余量
|
|
7
|
+
// 同一个包在 8GB 机器上没事、在 2GB 机器上必炸,第③处却一视同仁地拦。
|
|
8
|
+
// 所以这里把"能不能做"收成一个函数,谁要判断都问它。
|
|
9
|
+
//
|
|
10
|
+
// 🔴 铁律:**前端不自己算倍数**。
|
|
11
|
+
// 「包字节 → 预估峰值」的权威实现只有服务端 capacity.mjs 的 estimatePeakMb(三档实测上包络),
|
|
12
|
+
// 前端自己写 bytes*20 之类的倍数 = 又造一个新口径,正是本任务要消灭的东西。
|
|
13
|
+
//
|
|
14
|
+
// 数据源分工(不许越界):
|
|
15
|
+
// · 服务端内存:只能从 GET /api/unpack/capacity 拿(浏览器里读不到系统内存)
|
|
16
|
+
// · 前端堆占用:只能从浏览器 performance.memory 拿(服务端读不到)
|
|
17
|
+
|
|
18
|
+
import { memStats } from '../infra/mem-stats.js';
|
|
19
|
+
import { stageLog } from './status.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 前端堆占用阈值(路径 B / C 与 assess 共用**同一个**值)。
|
|
23
|
+
* 为什么放在这里:判据口径只允许有一份,memory-guard 从这里 import ——
|
|
24
|
+
* 两边各写一个 0.85 就是新一轮"同一事实两处表达",迟早漂移。
|
|
25
|
+
*
|
|
26
|
+
* 缺省 0.85;服务端可用 **MODELSTAGE_ADMIT_HEAP_RATIO** 覆盖,
|
|
27
|
+
* 经 `GET /api/web-config` 的 `admitHeapRatio` 下发到前端(前端读不到 env)。
|
|
28
|
+
* 这里用 `let` 而不是 const:下发值要在启动后覆盖缺省,而 import 方拿到的是**活绑定**,
|
|
29
|
+
* memory-guard 里那些 `h.pct > HEAP_RATIO` 无需改动就会看到新值。
|
|
30
|
+
*/
|
|
31
|
+
export let HEAP_RATIO = 0.85;
|
|
32
|
+
/** 缺省值(下发值缺失/非法时回落到它;也是文档里写的那个数) */
|
|
33
|
+
export const DEFAULT_HEAP_RATIO = 0.85;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 应用服务端下发的准入配置(幂等;非法值回落缺省并 warn 一次)。
|
|
37
|
+
* @param {{admitHeapRatio?: number}} cfg /api/web-config 的响应
|
|
38
|
+
* @returns {{ratio: number, applied: boolean}}
|
|
39
|
+
*/
|
|
40
|
+
export function applyAdmitConfig(cfg) {
|
|
41
|
+
if (!cfg || cfg.admitHeapRatio === undefined || cfg.admitHeapRatio === null) {
|
|
42
|
+
return { ratio: HEAP_RATIO, applied: false };
|
|
43
|
+
}
|
|
44
|
+
const v = Number(cfg.admitHeapRatio);
|
|
45
|
+
const ok = Number.isFinite(v) && v > 0 && v <= 1;
|
|
46
|
+
if (!ok) {
|
|
47
|
+
// 不静默:下发值非法时说明白用的是哪个数,否则"为什么阈值没变"只能靠猜
|
|
48
|
+
try { stageLog('warn', `准入堆比例下发值非法(${JSON.stringify(cfg.admitHeapRatio)})—— 继续用 ${HEAP_RATIO}`); } catch { /* 日志失败不影响判据 */ }
|
|
49
|
+
return { ratio: HEAP_RATIO, applied: false };
|
|
50
|
+
}
|
|
51
|
+
HEAP_RATIO = v;
|
|
52
|
+
try { stageLog('info', `准入堆比例已按服务端下发调整:${v}`); } catch { /* 同上 */ }
|
|
53
|
+
return { ratio: HEAP_RATIO, applied: true };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 服务端判据缓存时长:同一个包在短时间内反复判断不该每次都打服务端 */
|
|
57
|
+
const CAP_TTL_MS = 5000;
|
|
58
|
+
|
|
59
|
+
/** 开关状态镜像(由 memory-guard.setMemoryGuard 同步过来,避免两层互相 import 成环) */
|
|
60
|
+
let guardEnabled = true;
|
|
61
|
+
|
|
62
|
+
/** 缓存:key = 包 MB(取整)/ 'machine' → { at, value } */
|
|
63
|
+
const capCache = new Map();
|
|
64
|
+
|
|
65
|
+
/** 实际发起过的服务端请求次数(验收"缓存生效"用) */
|
|
66
|
+
let fetchCount = 0;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 同步"内存保护开关"状态。memory-guard.setMemoryGuard 里会调它。
|
|
70
|
+
* @param {boolean} on
|
|
71
|
+
*/
|
|
72
|
+
export function setBudgetGuardEnabled(on) {
|
|
73
|
+
guardEnabled = on !== false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @returns {boolean} 当前是否受开关约束 */
|
|
77
|
+
export function isBudgetGuardEnabled() {
|
|
78
|
+
return guardEnabled;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 取服务端判据(带 5 秒缓存)。
|
|
83
|
+
* @param {number} sourceMb 包大小(MB);0/缺省 ⇒ 只问机器级信息
|
|
84
|
+
* @returns {Promise<object|null>} 拿不到返回 null(调用方按"这一路不可用"处理)
|
|
85
|
+
*/
|
|
86
|
+
async function fetchServerJudgement(sourceMb) {
|
|
87
|
+
const key = sourceMb > 0 ? String(Math.round(sourceMb)) : 'machine';
|
|
88
|
+
const hit = capCache.get(key);
|
|
89
|
+
if (hit && Date.now() - hit.at < CAP_TTL_MS) return hit.value;
|
|
90
|
+
const qs = sourceMb > 0 ? `?sourceMb=${encodeURIComponent(sourceMb)}` : '';
|
|
91
|
+
try {
|
|
92
|
+
fetchCount += 1;
|
|
93
|
+
const res = await fetch(`/api/unpack/capacity${qs}`, {
|
|
94
|
+
headers: { Accept: 'application/json' },
|
|
95
|
+
signal: AbortSignal.timeout(2000),
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
98
|
+
const j = await res.json();
|
|
99
|
+
capCache.set(key, { at: Date.now(), value: j });
|
|
100
|
+
return j;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
stageLog('warn', `内存判据取不到服务端数据:${String((err && err.message) || err)}`, { sourceMb });
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 读前端堆占用(不可用返回 null,不瞎估) */
|
|
108
|
+
function heapStats() {
|
|
109
|
+
try {
|
|
110
|
+
const s = memStats();
|
|
111
|
+
if (s.jsHeapPct === null || s.jsHeapPct === undefined) return null;
|
|
112
|
+
return { pct: s.jsHeapPct, usedMB: s.jsHeapMB, limitMB: s.jsHeapLimitMB };
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 百分比显示:不足 1% 写成 <1%(写 0% 会让人以为没数据) */
|
|
119
|
+
function pctText(pct) {
|
|
120
|
+
return pct < 0.01 ? '<1' : String(Math.round(pct * 100));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 统一决策:这次操作能不能做。
|
|
125
|
+
*
|
|
126
|
+
* 判据取**更严格的一路**(任一路不通过即不通过);两路都拿不到 ⇒ **放行 + 记日志**
|
|
127
|
+
* —— 拿不到真值时不假装保护,否则会变成"在某些浏览器上永远加载不了"。
|
|
128
|
+
*
|
|
129
|
+
* @param {object} opts
|
|
130
|
+
* @param {number} [opts.bytesHint] 已知的包字节数(能算出预估峰值)
|
|
131
|
+
* @param {'web-load'|'web-unpack'|'web-long-op'} [opts.where] 调用场景(只影响文案与默认策略)
|
|
132
|
+
* @param {string} [opts.label] 对象名(写进文案,让用户知道是哪一步被拦)
|
|
133
|
+
* @returns {Promise<{ok:boolean, reason:string, numbers:object, message:string}>}
|
|
134
|
+
* numbers: { pkgBytes, peakEstMb, budgetMb, availMb, heapPct, usedMB, limitMB }
|
|
135
|
+
*/
|
|
136
|
+
export async function assess(opts = {}) {
|
|
137
|
+
const where = opts.where || 'web-load';
|
|
138
|
+
const label = String(opts.label || '').trim();
|
|
139
|
+
const bytes = Number(opts.bytesHint) > 0 ? Number(opts.bytesHint) : 0;
|
|
140
|
+
const numbers = {
|
|
141
|
+
pkgBytes: bytes || null,
|
|
142
|
+
peakEstMb: null,
|
|
143
|
+
budgetMb: null,
|
|
144
|
+
availMb: null,
|
|
145
|
+
heapPct: null,
|
|
146
|
+
usedMB: null,
|
|
147
|
+
limitMB: null,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
if (!guardEnabled) {
|
|
151
|
+
// 开关关闭:**连绝对兜底也不拦**(用户明示要试)
|
|
152
|
+
return { ok: true, reason: 'guard-off', numbers, message: '' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 路 A|服务端:包字节 → 预估峰值 / 预算 / 可用
|
|
156
|
+
const srv = await fetchServerJudgement(bytes > 0 ? bytes / 1024 / 1024 : 0);
|
|
157
|
+
const heap = heapStats();
|
|
158
|
+
let serverJudged = false;
|
|
159
|
+
let serverFails = false;
|
|
160
|
+
let serverDetail = '';
|
|
161
|
+
if (srv && Number.isFinite(srv.peakEstMb) && Number.isFinite(srv.budgetMb)) {
|
|
162
|
+
serverJudged = true;
|
|
163
|
+
numbers.peakEstMb = srv.peakEstMb;
|
|
164
|
+
numbers.budgetMb = srv.budgetMb;
|
|
165
|
+
numbers.availMb = Number.isFinite(srv.memAvailableMb) ? srv.memAvailableMb : null;
|
|
166
|
+
if (srv.peakEstMb > srv.budgetMb) {
|
|
167
|
+
serverFails = true;
|
|
168
|
+
serverDetail = `预估峰值 ${srv.peakEstMb}MB 超过可用预算 ${srv.budgetMb}MB(当前可用 ${numbers.availMb}MB × 0.5)`;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (heap) {
|
|
172
|
+
numbers.heapPct = heap.pct;
|
|
173
|
+
numbers.usedMB = heap.usedMB;
|
|
174
|
+
numbers.limitMB = heap.limitMB;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 两路都拿不到 ⇒ 放行 + 记日志(不假装保护)
|
|
178
|
+
if (!serverJudged && !heap) {
|
|
179
|
+
stageLog('warn', `内存判据两路都不可用,放行(${where}${label ? ` · ${label}` : ''})`, { where, bytes });
|
|
180
|
+
return { ok: true, reason: 'unjudgeable', numbers, message: '' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const heapFails = !!(heap && heap.pct > HEAP_RATIO);
|
|
184
|
+
if (serverFails || heapFails) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
reason: serverFails ? 'insufficient-memory' : 'heap-high',
|
|
188
|
+
numbers,
|
|
189
|
+
message: buildMessage({ where, label, serverFails, heapFails, serverDetail, numbers }),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return { ok: true, reason: 'ok', numbers, message: '' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 统一文案模板(三处同一套格式):`无法<动作>「<对象>」:<判据含真实数字>。<能怎么办>`
|
|
197
|
+
*
|
|
198
|
+
* 为什么必须带数字:只说"内存不足/文件过大",用户无法判断该关标签页、拆包还是换机器。
|
|
199
|
+
* @returns {string}
|
|
200
|
+
*/
|
|
201
|
+
export function buildMessage({ where, label, serverFails, heapFails, serverDetail, numbers }) {
|
|
202
|
+
const obj = label ? `「${label}」` : '';
|
|
203
|
+
const verb = where === 'web-unpack' ? '解包' : (where === 'web-long-op' ? '继续' : '加载');
|
|
204
|
+
const parts = [];
|
|
205
|
+
if (serverFails && serverDetail) parts.push(serverDetail);
|
|
206
|
+
if (heapFails && numbers.heapPct !== null) {
|
|
207
|
+
parts.push(`内存占用较高(${numbers.usedMB} MB / ${numbers.limitMB} MB,${pctText(numbers.heapPct)}%)`);
|
|
208
|
+
}
|
|
209
|
+
const why = parts.join(';');
|
|
210
|
+
const advice = where === 'web-unpack'
|
|
211
|
+
? '可先关闭其他程序,或改用更小的包。'
|
|
212
|
+
: (where === 'web-long-op'
|
|
213
|
+
? '已完成的文件保留,可稍后重试剩余部分。'
|
|
214
|
+
: '请关闭其他标签页后重试。');
|
|
215
|
+
return `无法${verb}${obj}:${why}。${advice}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 验收用:已发起的服务端判据请求次数 */
|
|
219
|
+
export function budgetStats() {
|
|
220
|
+
return { fetchCount, cachedKeys: [...capCache.keys()] };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 供测试重置 */
|
|
224
|
+
export function __resetMemoryBudgetForTest() {
|
|
225
|
+
capCache.clear();
|
|
226
|
+
fetchCount = 0;
|
|
227
|
+
guardEnabled = true;
|
|
228
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// app/memory-guard.js —— 内存保护(一个开关管三条路径)
|
|
2
|
+
//
|
|
3
|
+
// 与 ctl-hang-guard 的区别(别混):
|
|
4
|
+
// memoryGuard(本文件) = **内存容量**保护 → 防 OOM / 页面崩溃
|
|
5
|
+
// ctl-hang-guard = **帧停滞**看门狗 → 画面停住时尝试自愈
|
|
6
|
+
// 两者并列、互不替代。
|
|
7
|
+
//
|
|
8
|
+
// 三条路径:
|
|
9
|
+
// A 服务端解包准入:开关开=超预算拒绝(既有行为);关=请求带 allowOverLimit 强行解包。
|
|
10
|
+
// 判断全在服务端(capacity.mjs 读系统内存),前端不自己算阈值。
|
|
11
|
+
// B 前端加载前预检:jsHeap 占上限比例 > HEAP_RATIO 就拒绝并给出真实数字。
|
|
12
|
+
// C 长操作中监控:解包/批量加载期间按帧采样 jsHeap,超限即中止(已完成的文件保留)。
|
|
13
|
+
//
|
|
14
|
+
// 为什么数值必须取真值:只说"内存不足"用户无法判断该拆包还是该关标签页 ——
|
|
15
|
+
// 所以所有提示都带「当前 / 上限 / 百分比 / MB」。
|
|
16
|
+
|
|
17
|
+
import { memStats } from '../infra/mem-stats.js';
|
|
18
|
+
import { HEAP_RATIO, assess, buildMessage, setBudgetGuardEnabled } from './memory-budget.js';
|
|
19
|
+
|
|
20
|
+
export { HEAP_RATIO };
|
|
21
|
+
|
|
22
|
+
/** 操作中监控的采样间隔(帧)—— 每 20 帧约 1/3 秒,够快且不干扰渲染 */
|
|
23
|
+
export const MONITOR_FRAME_STRIDE = 20;
|
|
24
|
+
|
|
25
|
+
/** 开关状态(默认开:保持"服务端永远准入判断"的现状,关才放行) */
|
|
26
|
+
let enabled = true;
|
|
27
|
+
|
|
28
|
+
/** 最近一次判据读数(面板显示 + 断言都用它,避免各处各取一次导致数字对不上) */
|
|
29
|
+
let readout = { availMb: null, totalMb: null, peakEstMb: null, budgetMb: null, at: 0, error: '' };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 设置开关(面板 ctl-memory-guard 变化时调用)。
|
|
33
|
+
* @param {boolean} on
|
|
34
|
+
* @returns {boolean} 生效后的值
|
|
35
|
+
*/
|
|
36
|
+
export function setMemoryGuard(on) {
|
|
37
|
+
enabled = on !== false;
|
|
38
|
+
setBudgetGuardEnabled(enabled); // 判据层与开关状态保持同步(它不 import 本文件,避免成环)
|
|
39
|
+
try { document.documentElement.dataset.memoryGuard = enabled ? 'on' : 'off'; } catch { /* 忽略 */ }
|
|
40
|
+
return enabled;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @returns {boolean} 内存保护是否开启 */
|
|
44
|
+
export function isMemoryGuardOn() {
|
|
45
|
+
return enabled;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 读前端堆占用(真值来自 performance.memory,不可用时返回 null 而不是瞎估)。
|
|
50
|
+
* @returns {{usedMB:number|null, limitMB:number|null, pct:number|null}}
|
|
51
|
+
*/
|
|
52
|
+
export function heapSnapshot() {
|
|
53
|
+
const s = memStats();
|
|
54
|
+
return { usedMB: s.jsHeapMB, limitMB: s.jsHeapLimitMB, pct: s.jsHeapPct };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 路径 B:加载大模型前的预检。
|
|
59
|
+
*
|
|
60
|
+
* 判不了(浏览器不暴露 performance.memory,如 Firefox/Safari)就**放行** ——
|
|
61
|
+
* 拿不到真值时不假装保护,否则会变成"在部分浏览器上永远加载不了"。
|
|
62
|
+
* @param {string} what 要加载的东西(写进提示,便于用户知道是哪一步被拦)
|
|
63
|
+
* @returns {{ok: boolean, message?: string, usedMB?: number|null, limitMB?: number|null, pct?: number|null}}
|
|
64
|
+
*/
|
|
65
|
+
export async function preflightLoad(what) {
|
|
66
|
+
// 判断口径全部交给 memory-budget.assess(服务端容量 + 前端堆两路取更严格),
|
|
67
|
+
// 这里只负责把结果翻成调用方要的形状,不自己算任何阈值。
|
|
68
|
+
if (!enabled) return { ok: true };
|
|
69
|
+
const r = await assess({ where: 'web-load', label: what });
|
|
70
|
+
return { ok: r.ok, message: r.message, reason: r.reason, ...r.numbers };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 解包判据(供 plugin-host 在装载容器包前调用):包字节 → 预估峰值 vs 可用预算。
|
|
75
|
+
* 内部就是 assess({ where:'web-unpack' }),前端不算任何倍数。
|
|
76
|
+
* @param {string} label 对象名
|
|
77
|
+
* @param {number} bytes 包字节数
|
|
78
|
+
* @returns {Promise<{ok:boolean, reason:string, numbers:object, message:string}>}
|
|
79
|
+
*/
|
|
80
|
+
export function assessUnpack(label, bytes) {
|
|
81
|
+
if (!enabled) return Promise.resolve({ ok: true, reason: 'guard-off', numbers: {}, message: '' });
|
|
82
|
+
return assess({ where: 'web-unpack', label, bytesHint: bytes });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 路径 C:长操作中的内存监控。
|
|
87
|
+
*
|
|
88
|
+
* 用法:
|
|
89
|
+
* const mon = startMemoryMonitor('解包');
|
|
90
|
+
* for (...) { await one(); if (mon.aborted()) break; } // 中止但保留已完成的部分
|
|
91
|
+
* mon.stop();
|
|
92
|
+
*
|
|
93
|
+
* 为什么按帧采样而不是 setInterval:跟着渲染循环走,页面不可见时自然降频,
|
|
94
|
+
* 不会在后台标签页里空转(符合《事件驱动优先》)。
|
|
95
|
+
* @param {string} label 操作名(写进日志与提示)
|
|
96
|
+
* @returns {{stop: () => void, aborted: () => boolean, reason: () => string, samples: () => number}}
|
|
97
|
+
*/
|
|
98
|
+
export function startMemoryMonitor(label) {
|
|
99
|
+
const state = { hit: false, why: '', n: 0, frames: 0 };
|
|
100
|
+
let raf = 0;
|
|
101
|
+
let cancelled = false;
|
|
102
|
+
|
|
103
|
+
/** 采一次样并判定(帧回调与同步检查共用这一处,避免两套判定漂移) */
|
|
104
|
+
const sample = () => {
|
|
105
|
+
if (state.hit || cancelled) return;
|
|
106
|
+
state.n += 1;
|
|
107
|
+
const h = heapSnapshot();
|
|
108
|
+
if (!enabled || h.pct === null || h.pct <= HEAP_RATIO) return;
|
|
109
|
+
state.hit = true;
|
|
110
|
+
// 文案走统一模板(三处同一套格式),不再各处手拼
|
|
111
|
+
state.why = buildMessage({
|
|
112
|
+
where: 'web-long-op',
|
|
113
|
+
label: String(label).trim(),
|
|
114
|
+
serverFails: false,
|
|
115
|
+
heapFails: true,
|
|
116
|
+
serverDetail: '',
|
|
117
|
+
numbers: { heapPct: h.pct, usedMB: h.usedMB, limitMB: h.limitMB },
|
|
118
|
+
});
|
|
119
|
+
try { document.documentElement.dataset.memGuardAbort = label; } catch { /* 忽略 */ }
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const tick = () => {
|
|
123
|
+
if (cancelled || state.hit) return;
|
|
124
|
+
state.frames += 1;
|
|
125
|
+
// 首帧就采一次,之后每 MONITOR_FRAME_STRIDE 帧一次。
|
|
126
|
+
// 原来只在 %STRIDE===0 时采 ⇒ "几帧内跑完"的短操作等于没有监控
|
|
127
|
+
// (实测:取 19 个小文件一帧都没跨过,监控从未触发)。
|
|
128
|
+
if (state.frames === 1 || state.frames % MONITOR_FRAME_STRIDE === 0) sample();
|
|
129
|
+
if (state.hit) return;
|
|
130
|
+
raf = requestAnimationFrame(tick);
|
|
131
|
+
};
|
|
132
|
+
if (typeof requestAnimationFrame === 'function') raf = requestAnimationFrame(tick);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
stop: () => { cancelled = true; try { cancelAnimationFrame(raf); } catch { /* 忽略 */ } },
|
|
136
|
+
aborted: () => state.hit,
|
|
137
|
+
reason: () => state.why,
|
|
138
|
+
samples: () => state.n,
|
|
139
|
+
/**
|
|
140
|
+
* 同步采样一次,返回是否已越线。
|
|
141
|
+
*
|
|
142
|
+
* 为什么需要:按帧采样对"密集但总时长短"的循环无能为力 —— 取 19 个小文件可能一帧都没跨过,
|
|
143
|
+
* 那时监控等于不存在。调用方在每步开始前调它,就能在"还没排到下一帧"时也发现越线。
|
|
144
|
+
*/
|
|
145
|
+
checkNow: () => { sample(); return state.hit; },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 刷新判据读数(面板「内存判据」那行显示的真实值)。
|
|
151
|
+
*
|
|
152
|
+
* 数值来源必须是服务端的 /api/unpack/capacity(由它读系统内存),
|
|
153
|
+
* 前端不许自己拍脑袋算"大概够不够"。
|
|
154
|
+
* @param {{rootId?: string, rel?: string}} [target] 给了就带上该包的需求估算
|
|
155
|
+
* @returns {Promise<object>} 服务端返回的判据(失败时返回 {error})
|
|
156
|
+
*/
|
|
157
|
+
export async function refreshMemoryReadout(target) {
|
|
158
|
+
const qs = target && target.rootId && target.rel
|
|
159
|
+
? `?rootId=${encodeURIComponent(target.rootId)}&rel=${encodeURIComponent(target.rel)}`
|
|
160
|
+
: '';
|
|
161
|
+
try {
|
|
162
|
+
const res = await fetch(`/api/unpack/capacity${qs}`, {
|
|
163
|
+
headers: { Accept: 'application/json' },
|
|
164
|
+
signal: AbortSignal.timeout(2000),
|
|
165
|
+
});
|
|
166
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
167
|
+
const j = await res.json();
|
|
168
|
+
readout = {
|
|
169
|
+
availMb: Number.isFinite(j.memAvailableMb) ? j.memAvailableMb : null,
|
|
170
|
+
totalMb: Number.isFinite(j.memTotalMb) ? j.memTotalMb : null,
|
|
171
|
+
peakEstMb: Number.isFinite(j.peakEstMb) ? j.peakEstMb : null,
|
|
172
|
+
budgetMb: Number.isFinite(j.budgetMb) ? j.budgetMb : null,
|
|
173
|
+
at: Date.now(),
|
|
174
|
+
error: '',
|
|
175
|
+
};
|
|
176
|
+
return { ...j, readout };
|
|
177
|
+
} catch (err) {
|
|
178
|
+
readout = { ...readout, at: Date.now(), error: String((err && err.message) || err) };
|
|
179
|
+
return { error: readout.error, readout };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 最近一次读数(断言用) */
|
|
184
|
+
export function memoryReadout() {
|
|
185
|
+
return { ...readout };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 供测试/断言的整体快照 */
|
|
189
|
+
export function memGuardSnapshot() {
|
|
190
|
+
return {
|
|
191
|
+
enabled,
|
|
192
|
+
heapRatio: HEAP_RATIO,
|
|
193
|
+
heap: heapSnapshot(),
|
|
194
|
+
readout: memoryReadout(),
|
|
195
|
+
abortedLabel: (() => { try { return document.documentElement.dataset.memGuardAbort || ''; } catch { return ''; } })(),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 供测试重置(仅测试用) */
|
|
200
|
+
export function __resetMemoryGuardForTest() {
|
|
201
|
+
enabled = true;
|
|
202
|
+
readout = { availMb: null, totalMb: null, peakEstMb: null, budgetMb: null, at: 0, error: '' };
|
|
203
|
+
try { delete document.documentElement.dataset.memGuardAbort; } catch { /* 忽略 */ }
|
|
204
|
+
}
|