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,291 @@
|
|
|
1
|
+
// panel/settings-io.js —— 设置导出 / 导入(右栏「设置」组底部那三个按钮的后两个)
|
|
2
|
+
//
|
|
3
|
+
// 导出:一个 JSON 同时带走「服务端配置原文」(GET /api/config/export)与「前端偏好」(localStorage)。
|
|
4
|
+
// 导入:项目配置交给 POST /api/config/import(校验→备份→写→回读自检),偏好写回 localStorage 并立即生效。
|
|
5
|
+
//
|
|
6
|
+
// 依赖注入的原因:面板桥(重载名册/应用偏好)与状态行都在装配层,这里不反向 import,避免环。
|
|
7
|
+
import { formatError, getJson } from '../infra/api.js';
|
|
8
|
+
import { readViewSettings, VIEW_SETTINGS_KEY } from '../app/view-settings.js';
|
|
9
|
+
import { stageLog, stageStatus } from '../app/status.js';
|
|
10
|
+
import { state } from '../app/context.js';
|
|
11
|
+
|
|
12
|
+
/** 装配层注入:面板设置回放、导入后重载、偏好应用 */
|
|
13
|
+
let ioBridge = {
|
|
14
|
+
applyPanelSetting: null,
|
|
15
|
+
applyViewerPrefs: null,
|
|
16
|
+
reloadApp: null,
|
|
17
|
+
panelSettings: () => ({}),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function connectSettingsIo(bridge) {
|
|
21
|
+
ioBridge = { ...ioBridge, ...(bridge || {}) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 取服务端项目配置原文(失败不抛出,返回 error 供状态行提示) */
|
|
25
|
+
async function fetchProjectFiles() {
|
|
26
|
+
try {
|
|
27
|
+
// 幂等 GET 的小 JSON(实测 58ms)⇒ 走统一层拿重试与统一超时/错误归类。
|
|
28
|
+
// 非 2xx 现在会被 api.js 抛成带 status 的错误(原来在这里读 data.error),
|
|
29
|
+
// 两种来源都会落进下面的 catch 变成可读文案,状态行展示口径不变。
|
|
30
|
+
const data = await getJson('/api/config/export');
|
|
31
|
+
if (data && data.files) return { files: data.files, error: null };
|
|
32
|
+
return { files: null, error: (data && data.error) || '服务端未返回 files' };
|
|
33
|
+
} catch (err) {
|
|
34
|
+
return { files: null, error: (err && err.message) || String(err) };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 触发浏览器下载(内容同时留在 dataset 供断言) */
|
|
39
|
+
function downloadJson(text, fileName) {
|
|
40
|
+
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
|
41
|
+
const link = document.createElement('a');
|
|
42
|
+
link.href = url;
|
|
43
|
+
link.download = fileName;
|
|
44
|
+
document.body.append(link);
|
|
45
|
+
link.click();
|
|
46
|
+
link.remove();
|
|
47
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 导出:一个 JSON 文件同时带走「服务端配置原文」+「前端偏好」。
|
|
52
|
+
* 结构:{ _meta, schema, project:{files}, panel, view }
|
|
53
|
+
* 项目配置取不到时仍导出偏好,并在状态行注明(一半失败不牵连整体)。
|
|
54
|
+
*/
|
|
55
|
+
export async function exportSettingsFile() {
|
|
56
|
+
const project = await fetchProjectFiles();
|
|
57
|
+
const payload = {
|
|
58
|
+
_meta: {
|
|
59
|
+
app: 'modelstage',
|
|
60
|
+
formatVersion: 1,
|
|
61
|
+
exportedAt: new Date().toISOString(),
|
|
62
|
+
note: 'project=服务端配置原文(资源根+名表);panel/view=前端偏好',
|
|
63
|
+
},
|
|
64
|
+
schema: 'modelstage-settings/v2',
|
|
65
|
+
project: project.files ? { files: project.files } : null,
|
|
66
|
+
panel: { ...(ioBridge.panelSettings() || {}) },
|
|
67
|
+
view: readViewSettings(),
|
|
68
|
+
};
|
|
69
|
+
const text = JSON.stringify(payload, null, 2);
|
|
70
|
+
const panelCount = Object.keys(payload.panel).length;
|
|
71
|
+
const projectCount = project.files ? Object.keys(project.files).length : 0;
|
|
72
|
+
window.__WEB__ && (window.__WEB__.settingsExport = {
|
|
73
|
+
text, panelCount, projectFiles: projectCount, projectError: project.error,
|
|
74
|
+
});
|
|
75
|
+
document.documentElement.dataset.settingsExport = String(text.length);
|
|
76
|
+
try {
|
|
77
|
+
downloadJson(text, `modelstage-settings-${payload._meta.exportedAt.slice(0, 10)}.json`);
|
|
78
|
+
stageStatus(
|
|
79
|
+
`已导出设置:前端偏好 ${panelCount} 项`
|
|
80
|
+
+ (projectCount ? ` + 项目配置 ${projectCount} 个文件` : `(项目配置未取到:${project.error})`)
|
|
81
|
+
+ `,${text.length} 字节`, 'info', { show: true });
|
|
82
|
+
return true;
|
|
83
|
+
} catch (err) {
|
|
84
|
+
stageLog('error', `导出设置失败:${formatError(err)}`, { err: formatError(err) });
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 打开导入文件选择器。
|
|
91
|
+
* 说明:浏览器不允许脚本直接读本地文件,只能由用户选文件;所以这里造一个隐藏 input,
|
|
92
|
+
* 选完立刻移除,文本交给 importSettingsFromText 处理(该函数也可被自动化直接调用)。
|
|
93
|
+
*/
|
|
94
|
+
export function openImportPicker() {
|
|
95
|
+
// <label> 包 <input type="file">:点 label 即触发选择框(原生行为,不靠 JS 绑 click)
|
|
96
|
+
const label = document.createElement('label');
|
|
97
|
+
label.classList.add('file-picker-hidden');
|
|
98
|
+
const input = document.createElement('input');
|
|
99
|
+
input.type = 'file';
|
|
100
|
+
input.accept = '.json,application/json';
|
|
101
|
+
label.append(input);
|
|
102
|
+
document.body.append(label);
|
|
103
|
+
input.addEventListener('change', async () => {
|
|
104
|
+
const file = input.files && input.files[0];
|
|
105
|
+
label.remove();
|
|
106
|
+
if (!file) return;
|
|
107
|
+
let text = '';
|
|
108
|
+
try { text = await file.text(); } catch (err) {
|
|
109
|
+
stageLog('error', `读取文件失败:${formatError(err)}`, { err: formatError(err) });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
await importSettingsFromText(text);
|
|
113
|
+
}, { once: true });
|
|
114
|
+
label.click(); // 点 label 等价于点 input(原生行为)
|
|
115
|
+
stageStatus('已打开导入文件选择器:选一个之前导出的 JSON', 'info', { show: true });
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 把导入文本解析成对象(不做语义判断,只回答"能不能读成对象")。
|
|
121
|
+
* @param {string} text
|
|
122
|
+
* @returns {{ok: boolean, value?: object, error?: string}}
|
|
123
|
+
*/
|
|
124
|
+
function parseImportText(text) {
|
|
125
|
+
let value = null;
|
|
126
|
+
try { value = JSON.parse(String(text || '')); } catch (err) {
|
|
127
|
+
return { ok: false, error: `不是合法 JSON(${(err && err.message) || err})` };
|
|
128
|
+
}
|
|
129
|
+
if (!value || typeof value !== 'object') return { ok: false, error: '内容为空或不是对象' };
|
|
130
|
+
return { ok: true, value };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 从解析结果里认出三类内容。
|
|
135
|
+
* 兼容三种来源:本项目导出(view/panel)、旧版实现导出(viewer)、最早期的"顶层直接是偏好"。
|
|
136
|
+
* @param {object} parsed
|
|
137
|
+
* @returns {{projectFiles: object|null, viewPrefs: object|null, panelPrefs: object|null}}
|
|
138
|
+
*/
|
|
139
|
+
/** 取"对象型字段"的值;不是对象就当年没有 */
|
|
140
|
+
function objectField(value) {
|
|
141
|
+
return value && typeof value === 'object' ? value : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 这份对象看起来是不是"视图偏好本体"(最早期导出没有包装层) */
|
|
145
|
+
function looksLikePrefs(obj) {
|
|
146
|
+
if (!obj) return false;
|
|
147
|
+
// 只认"确实还在用"的偏好字段;已下线的字段不再参与识别
|
|
148
|
+
// (否则一份只剩 partFilter* 的旧导出会被误当成偏好本体)
|
|
149
|
+
return !!(obj.uiTheme || obj.background);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 已下线的设置字段:导入时**忽略并记一条 warn**,绝不因此中断整份导入 */
|
|
153
|
+
const DEPRECATED_PREF_FIELDS = ['partFilterEnabled', 'partFilterKeywords'];
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
function classifyImportPayload(parsed) {
|
|
157
|
+
const obj = objectField(parsed) || {};
|
|
158
|
+
// 向后兼容:老导出文件里可能带已下线的字段(如 partFilter*)。
|
|
159
|
+
// 这里只提示一句、照常继续导入 —— 不能因为一个废弃字段让整份配置导入失败。
|
|
160
|
+
const deprecated = DEPRECATED_PREF_FIELDS.filter((k) => obj[k] !== undefined
|
|
161
|
+
|| (obj.view && obj.view[k] !== undefined) || (obj.viewer && obj.viewer[k] !== undefined));
|
|
162
|
+
if (deprecated.length) {
|
|
163
|
+
try {
|
|
164
|
+
stageLog('warn', `导入配置里含已下线字段(${deprecated.join('、')}),已忽略,其余设置照常导入`, { deprecated });
|
|
165
|
+
} catch { /* 状态行不可用不影响导入 */ }
|
|
166
|
+
document.documentElement.dataset.settingsImportDeprecated = deprecated.join(',');
|
|
167
|
+
}
|
|
168
|
+
const projectFiles = obj.project && obj.project.files ? obj.project.files : null;
|
|
169
|
+
const viewer = objectField(obj.viewer);
|
|
170
|
+
const viewPrefs = objectField(obj.view) || viewer || (looksLikePrefs(obj) ? obj : null);
|
|
171
|
+
return { projectFiles, viewPrefs, panelPrefs: objectField(obj.panel) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 后端拒绝时拼一句可读原因(成功返回 null) */
|
|
175
|
+
function rejectReason(res, data) {
|
|
176
|
+
if (res && res.ok && data && data.ok === true) return null;
|
|
177
|
+
const details = Array.isArray(data && data.details) ? data.details : [];
|
|
178
|
+
const why = (data && data.error) || `HTTP ${res ? res.status : 'unknown'}`;
|
|
179
|
+
return `项目配置被拒绝(${why})${details.length ? `:${details.join(';')}` : ''}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 统一的失败出口:状态行 + dataset 标记 + 返回值,三处口径一致 */
|
|
183
|
+
function importFail(stage, error) {
|
|
184
|
+
const msg = `导入失败:${error}`;
|
|
185
|
+
stageLog('error', msg, { source: 'import-config' });
|
|
186
|
+
document.documentElement.dataset.settingsImport = `fail|${msg}`;
|
|
187
|
+
return { ok: false, mode: stage, error: msg };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 项目配置落地:交给后端自己校验/备份/写/回读。
|
|
192
|
+
* @param {object} files 相对 config/ 的路径 → 文件文本
|
|
193
|
+
* @returns {Promise<{ok: boolean, written?: string[], backup?: string|null, mode?: string, error?: string}>}
|
|
194
|
+
*/
|
|
195
|
+
async function importProjectFiles(files) {
|
|
196
|
+
let res = null;
|
|
197
|
+
let data = null;
|
|
198
|
+
try {
|
|
199
|
+
res = await fetch('/api/config/import', {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: { 'content-type': 'application/json' },
|
|
202
|
+
body: JSON.stringify({ files }),
|
|
203
|
+
// 30s:一次要写多个配置文件**并且**先备份现有配置(backupConfigFile 落盘到 CIFS,
|
|
204
|
+
// 每个文件是"写临时文件 + rename"两次往返,几 MB 配置在 CIFS 上可能数秒)。
|
|
205
|
+
// 导出侧实测 58ms 但那只是读;给 30s 是留足"多文件写入 + 备份"的余量,
|
|
206
|
+
// 而不是把慢但正常的情况判成失败。
|
|
207
|
+
signal: AbortSignal.timeout(30000),
|
|
208
|
+
});
|
|
209
|
+
data = await res.json();
|
|
210
|
+
} catch (err) {
|
|
211
|
+
// 说清是"超时/中断"而不是笼统的"请求失败":30s 到点就掐断(见上面的 signal),
|
|
212
|
+
// 用户需要知道是服务端慢/卡住了,而不是自己填错了什么。
|
|
213
|
+
return importFail('network', `项目配置导入超时或中断(上限 30s):${formatError(err)}`);
|
|
214
|
+
}
|
|
215
|
+
const rejected = rejectReason(res, data);
|
|
216
|
+
if (rejected) return importFail('project', rejected);
|
|
217
|
+
if (typeof ioBridge.reloadApp === 'function') {
|
|
218
|
+
try { await ioBridge.reloadApp(); } catch { /* 重载失败不影响导入结果展示 */ }
|
|
219
|
+
}
|
|
220
|
+
return { ok: true, written: data.written || [], backup: data.backup || null };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 前端偏好落地:视图偏好写 localStorage 后立刻生效,面板设置逐项回放。
|
|
225
|
+
* @param {object|null} viewPrefs
|
|
226
|
+
* @param {object|null} panelPrefs
|
|
227
|
+
* @returns {number} 实际应用项数
|
|
228
|
+
*/
|
|
229
|
+
function importPrefs(viewPrefs, panelPrefs) {
|
|
230
|
+
let applied = 0;
|
|
231
|
+
if (viewPrefs) {
|
|
232
|
+
try {
|
|
233
|
+
const merged = { ...readViewSettings(), ...viewPrefs };
|
|
234
|
+
localStorage.setItem(VIEW_SETTINGS_KEY, JSON.stringify(merged));
|
|
235
|
+
if (typeof ioBridge.applyViewerPrefs === 'function') ioBridge.applyViewerPrefs(merged);
|
|
236
|
+
else applyTheme(merged.uiTheme || 'dark-cyan');
|
|
237
|
+
applied += Object.keys(viewPrefs).length;
|
|
238
|
+
} catch (err) {
|
|
239
|
+
stageLog('warn', `前端偏好写入失败:${formatError(err)}`, { err: formatError(err) });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (panelPrefs && typeof ioBridge.applyPanelSetting === 'function') {
|
|
243
|
+
for (const [id, value] of Object.entries(panelPrefs)) {
|
|
244
|
+
try {
|
|
245
|
+
ioBridge.applyPanelSetting(id, value);
|
|
246
|
+
// 面板控件同步:导入只写引擎会出现"设置生效了、读数还是旧值",
|
|
247
|
+
// 而读数是用户唯一可见的生效出口 —— 必须跟着走到新值(契约:显示值=真实值)
|
|
248
|
+
if (state.panel && typeof state.panel.setValue === 'function') state.panel.setValue(id, value);
|
|
249
|
+
applied += 1;
|
|
250
|
+
} catch { /* 单项失败继续 */ }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return applied;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 导入一个导出文件:识别 → 校验 → 项目配置落地 → 偏好落地 → 汇总提示。
|
|
258
|
+
* @param {string} text JSON 文本
|
|
259
|
+
* @returns {Promise<{ok:boolean, mode:string, written?:string[], backup?:string, error?:string}>}
|
|
260
|
+
*/
|
|
261
|
+
export async function importSettingsFromText(text) {
|
|
262
|
+
const parsed = parseImportText(text);
|
|
263
|
+
if (!parsed.ok) return importFail('parse', parsed.error);
|
|
264
|
+
const { projectFiles, viewPrefs, panelPrefs } = classifyImportPayload(parsed.value);
|
|
265
|
+
if (!projectFiles && !viewPrefs && !panelPrefs) {
|
|
266
|
+
return importFail('shape', '既没有项目配置(project.files)也没有可识别的偏好字段');
|
|
267
|
+
}
|
|
268
|
+
let written = null;
|
|
269
|
+
let backup = null;
|
|
270
|
+
if (projectFiles) {
|
|
271
|
+
const res = await importProjectFiles(projectFiles);
|
|
272
|
+
if (!res.ok) return res;
|
|
273
|
+
written = res.written;
|
|
274
|
+
backup = res.backup;
|
|
275
|
+
}
|
|
276
|
+
const applied = importPrefs(viewPrefs, panelPrefs);
|
|
277
|
+
const parts = [];
|
|
278
|
+
if (projectFiles) parts.push(`项目配置 ${written.length} 个文件${backup ? `(备份 ${backup})` : ''}`);
|
|
279
|
+
if (applied) parts.push(`前端偏好 ${applied} 项`);
|
|
280
|
+
const msg = `已导入:${parts.join(' + ') || '无变更'}`;
|
|
281
|
+
stageStatus(msg, 'info', { show: true });
|
|
282
|
+
document.documentElement.dataset.settingsImport = `ok|${msg}`;
|
|
283
|
+
return { ok: true, mode: projectFiles ? 'project' : 'prefs', written, backup };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 自动化入口:文件选择器在无头环境里点不了,测试直接喂 JSON 文本。
|
|
287
|
+
// 注意 __WEB__ 可能比本模块晚初始化,所以这里"没有就建"(与 app/context.js 同一写法)。
|
|
288
|
+
if (typeof window !== 'undefined') {
|
|
289
|
+
window.__WEB__ = window.__WEB__ || {};
|
|
290
|
+
window.__WEB__.settingsIo = { importText: importSettingsFromText, exportNow: exportSettingsFile };
|
|
291
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// panel/shell-controls.js —— 四个「外壳级」控件的真实实现
|
|
2
|
+
//
|
|
3
|
+
// 这四项与渲染器能力无关(主题/背景那类才走 panel 的能力过滤),所以归外壳自己管:
|
|
4
|
+
// ctl-show-status 显示状态信息 → 控制舞台顶部状态行的显隐(日志照旧写)
|
|
5
|
+
// ctl-debug-log 调试日志 → 控制 debug/panel.js 浮层的显隐(采集与展示分离)
|
|
6
|
+
// ctl-hang-guard 防卡死保护 → 帧停滞看门狗(检出 + 自愈 + 计数)
|
|
7
|
+
// ctl-settings-io 导出/导入/恢复默认 → 导出走前端生成 JSON;导入依赖后端(未实现即如实禁用)
|
|
8
|
+
//
|
|
9
|
+
// 与 main.js 的分工:main.js 只做装配(把 SHELL_APPLIERS 指向这里导出的函数),
|
|
10
|
+
// 跨模块需要的"面板重放"通过 connectShellControls 注入,避免 main ↔ 本模块循环依赖。
|
|
11
|
+
|
|
12
|
+
import { createDebugLog } from '../debug/log.js';
|
|
13
|
+
import { setStageVisible } from '../app/memory-bars.js';
|
|
14
|
+
import { mountDebugPanel } from '../debug/panel.js';
|
|
15
|
+
import { formatError } from '../infra/api.js';
|
|
16
|
+
import { dom, state, stage } from '../app/context.js';
|
|
17
|
+
import { resetViewViaRenderer } from '../render/reset-view.js';
|
|
18
|
+
import { stageLog, stageStatus } from '../app/status.js';
|
|
19
|
+
import * as memoryGuard from '../app/memory-guard.js';
|
|
20
|
+
import { VIEW_SETTINGS_KEY, applyBackground, applyTheme, readViewSettings, saveViewSettings } from '../app/view-settings.js';
|
|
21
|
+
import {
|
|
22
|
+
connectSettingsIo, exportSettingsFile, importSettingsFromText, openImportPicker,
|
|
23
|
+
} from './settings-io.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 防卡死保护的参数:单点定义,测试与排障都读这里(不许在逻辑里散落裸数字)。
|
|
27
|
+
* tickMs 采样间隔
|
|
28
|
+
* stallLimit 连续多少次"帧数不增长"判定为卡死(3 次 ≈ 3 秒)
|
|
29
|
+
*/
|
|
30
|
+
export const hangGuardConfig = { tickMs: 1000, stallLimit: 3 };
|
|
31
|
+
|
|
32
|
+
/** 面板桥:恢复默认要"按面板声明的默认值重放一遍",由 main.js 在装配完成后注入 */
|
|
33
|
+
let panelBridge = {
|
|
34
|
+
applyPanelSetting: null,
|
|
35
|
+
getPanelDefaults: () => ({}),
|
|
36
|
+
/** 导入项目配置后需要重载资源根与名册树(由 main.js 注入) */
|
|
37
|
+
reloadApp: null,
|
|
38
|
+
/** 应用导入的视图偏好(主题/背景/部件筛除) */
|
|
39
|
+
applyViewerPrefs: null,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 注入面板桥(在 main.js 里装配好面板后调用一次)。
|
|
44
|
+
* @param {{applyPanelSetting?: Function, getPanelDefaults?: Function}} bridge
|
|
45
|
+
*/
|
|
46
|
+
export function connectShellControls(bridge) {
|
|
47
|
+
panelBridge = { ...panelBridge, ...(bridge || {}) };
|
|
48
|
+
// 设置导入/导出模块用同一套桥(重载名册、应用偏好、面板回放)
|
|
49
|
+
connectSettingsIo({
|
|
50
|
+
applyPanelSetting: (id, value) => panelBridge.applyPanelSetting?.(id, value),
|
|
51
|
+
applyViewerPrefs: (prefs) => panelBridge.applyViewerPrefs?.(prefs),
|
|
52
|
+
reloadApp: () => panelBridge.reloadApp?.(),
|
|
53
|
+
panelSettings: () => state.panelSettings || {},
|
|
54
|
+
});
|
|
55
|
+
window.__WEB__ && (window.__WEB__.hangGuardConfig = hangGuardConfig);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 显示状态信息(ctl-show-status):控制舞台顶部那条状态行的显隐。
|
|
60
|
+
* 关掉只是不显示,日志与控制台照旧(排障不能因为藏了提示就断线)。
|
|
61
|
+
*/
|
|
62
|
+
export function applyShowStatus(on) {
|
|
63
|
+
// 舞台顶部的内存条与状态行同策略:关掉「显示状态信息」时它也不显示
|
|
64
|
+
setStageVisible(on !== false);
|
|
65
|
+
state.showStatus = on !== false;
|
|
66
|
+
document.documentElement.dataset.showStatus = state.showStatus ? 'on' : 'off';
|
|
67
|
+
if (dom.stageHint && !state.showStatus) {
|
|
68
|
+
dom.stageHint.textContent = '';
|
|
69
|
+
dom.stageHint.hidden = true;
|
|
70
|
+
}
|
|
71
|
+
stageStatus(`状态信息显示:${state.showStatus ? '开' : '关'}`, 'info', { show: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 调试日志(ctl-debug-log):控制 debug/panel.js 浮层的显隐。
|
|
76
|
+
* 采集与展示分开——关掉只隐藏面板,已采内容保留,重新打开能接着看。
|
|
77
|
+
*/
|
|
78
|
+
export function applyDebugLog(on) {
|
|
79
|
+
state.debugLog = on !== false;
|
|
80
|
+
document.documentElement.dataset.debugLog = state.debugLog ? 'on' : 'off';
|
|
81
|
+
const host = ensureDebugPanelHost();
|
|
82
|
+
if (host) host.hidden = !state.debugLog;
|
|
83
|
+
if (state.debugLogHandle && typeof state.debugLogHandle.setEnabled === 'function') {
|
|
84
|
+
try { state.debugLogHandle.setEnabled(state.debugLog); } catch { /* 开关失败不影响渲染 */ }
|
|
85
|
+
}
|
|
86
|
+
// S4:把持久化的"收起/展开"补上(面板可能比启动读回更早挂载,挂载那一刻还读不到持久值)
|
|
87
|
+
if (state.debugPanel && typeof state.debugPanel.restoreCollapsed === 'function') {
|
|
88
|
+
try { state.debugPanel.restoreCollapsed(); } catch { /* 回放失败保持当前形态 */ }
|
|
89
|
+
}
|
|
90
|
+
stageStatus(`调试日志:${state.debugLog ? '开' : '关'}`, 'info', { show: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 调试日志浮层的容器与实例(懒建;样式由 debug/panel.js 自己注入) */
|
|
94
|
+
export function ensureDebugPanelHost() {
|
|
95
|
+
if (state.debugPanelHost) return state.debugPanelHost;
|
|
96
|
+
const host = document.createElement('div');
|
|
97
|
+
host.id = 'debug-panel-host';
|
|
98
|
+
// 位置/尺寸在 style.css 的 #debug-panel-host(契约:设计值不进 JS)
|
|
99
|
+
document.body.append(host);
|
|
100
|
+
state.debugPanelHost = host;
|
|
101
|
+
try {
|
|
102
|
+
state.debugLogHandle = createDebugLog({ capacity: 200 });
|
|
103
|
+
state.debugPanel = mountDebugPanel({ log: state.debugLogHandle, container: host });
|
|
104
|
+
window.__WEB__ && (window.__WEB__.debugLog = state.debugLogHandle);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
stageLog('warn', `调试日志面板不可用:${formatError(err)}`, { err: formatError(err) });
|
|
107
|
+
state.debugLogHandle = null;
|
|
108
|
+
}
|
|
109
|
+
return state.debugPanelHost;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 防卡死保护(ctl-hang-guard):防的是「画面停住但没人知道」。
|
|
114
|
+
* 做法:每 tickMs 采一次舞台累计帧数,连续 stallLimit 次不增长(且已有模型在渲染)判为停滞 →
|
|
115
|
+
* 写一条 warn + 尝试 pause→play 自愈一次;计数写进 __WEB__.hangGuard 供断言。
|
|
116
|
+
* 只看"有没有新帧",不看帧率高低(慢 ≠ 卡死)。关掉时定时器直接停,不留后台轮询。
|
|
117
|
+
*/
|
|
118
|
+
export function applyHangGuard(on) {
|
|
119
|
+
state.hangGuard = on !== false;
|
|
120
|
+
document.documentElement.dataset.hangGuard = state.hangGuard ? 'on' : 'off';
|
|
121
|
+
if (state.hangGuardTimer) {
|
|
122
|
+
clearInterval(state.hangGuardTimer);
|
|
123
|
+
state.hangGuardTimer = null;
|
|
124
|
+
}
|
|
125
|
+
if (!state.hangGuard) {
|
|
126
|
+
stageStatus('防卡死保护:关', 'info', { show: true });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
state.hangGuardStats = { ticks: 0, stalls: 0, recoveries: 0, lastFrames: null, skippedHidden: 0 };
|
|
130
|
+
window.__WEB__ && (window.__WEB__.hangGuard = state.hangGuardStats);
|
|
131
|
+
state.hangGuardTimer = setInterval(hangGuardTick, hangGuardConfig.tickMs);
|
|
132
|
+
stageStatus(`防卡死保护:开(连续 ${hangGuardConfig.stallLimit}s 无新帧则尝试恢复)`, 'info', { show: true });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 内存保护(ctl-memory-guard):与防卡死保护并列、互不替代。
|
|
137
|
+
* · 开关开(默认)= 服务端解包前按可用内存准入 + 前端加载前查 JS 堆占用;
|
|
138
|
+
* · 开关关 = 服务端放行超预算解包(用户自担风险),前端预检也不再拦。
|
|
139
|
+
* 这里只翻转状态并刷新判据读数,真正的判断在 app/memory-guard.js 与服务端 capacity.mjs。
|
|
140
|
+
* @param {boolean} on
|
|
141
|
+
*/
|
|
142
|
+
export function applyMemoryGuard(on) {
|
|
143
|
+
const value = memoryGuard.setMemoryGuard(on);
|
|
144
|
+
stageStatus(value ? '内存保护:开(解包按可用内存准入)' : '内存保护:关(允许超预算解包,请自行确认内存充足)', 'info', { show: true });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 把看门狗计数发布到 dataset(纯可观测性,失败不影响任何行为) */
|
|
148
|
+
function publishHangGuardStats(stats) {
|
|
149
|
+
try {
|
|
150
|
+
document.documentElement.dataset.hangGuardStats =
|
|
151
|
+
`ticks=${stats.ticks} stalls=${stats.stalls} rec=${stats.recoveries}`
|
|
152
|
+
+ ` skip=${stats.skippedHidden || 0} skipPaused=${stats.skippedPaused || 0}`;
|
|
153
|
+
} catch { /* dataset 只是可观测性 */ }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 看门狗单次采样 */
|
|
157
|
+
function hangGuardTick() {
|
|
158
|
+
const stats = state.hangGuardStats;
|
|
159
|
+
if (!stats || !stage || typeof stage.stats !== 'function') return;
|
|
160
|
+
// 标签页在后台时**直接跳过,连统计都不做**:
|
|
161
|
+
// 浏览器按规范会暂停后台页的 requestAnimationFrame(省电),frames 自然不增长 ——
|
|
162
|
+
// 那是浏览器的正常行为,不是我们的渲染卡死。在这儿统计只会得出"后台=卡死"的假结论,
|
|
163
|
+
// 而且下一段的 stallLimit 一到就会真的去 pause()/play() 自愈一次(多余动作)。
|
|
164
|
+
// 计数单独记 skippedHidden(不是把 stalls 归零),让它可观测:将来要看"到底跳过了多少次"有据可查。
|
|
165
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
|
166
|
+
stats.skippedHidden = (stats.skippedHidden || 0) + 1;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
let snap = null;
|
|
170
|
+
try { snap = stage.stats(); } catch { snap = null; }
|
|
171
|
+
if (!snap) return;
|
|
172
|
+
|
|
173
|
+
// **暂停时完全跳过**(不碰 ticks / stalls / lastFrames),与"后台标签页"同型:
|
|
174
|
+
// 暂停本来就不该有帧(canvas-api 的 pause() 会 stopFrameTick),拿它去判"渲染停滞"必然误判。
|
|
175
|
+
// 实测代价(8899,spine char000101):暂停后每 tick frames 不涨 ⇒ stalls 累加到 3
|
|
176
|
+
// ⇒ 看门狗执行 pause();play() 自愈 ⇒ rec 0→1、paused 被改回 false、
|
|
177
|
+
// 引擎 animationId 从"字段不存在"变成 522 并持续递增。
|
|
178
|
+
// ⇒ 用户点了暂停,**画面 3~4 秒后自己继续动** —— 这是用户可见的功能缺陷,不是计数虚涨。
|
|
179
|
+
// 计 skippedPaused 保持可观测(与 skippedHidden 一致),但**不参与停滞判定**;
|
|
180
|
+
// 注意不要改成"暂停时把 stalls 归零"(那是另一种语义:仍在拿不该涨的东西当判据)。
|
|
181
|
+
if (snap.paused === true) {
|
|
182
|
+
stats.skippedPaused = (stats.skippedPaused || 0) + 1;
|
|
183
|
+
stats.wasPaused = true;
|
|
184
|
+
publishHangGuardStats(stats);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 从暂停回到播放的第一个 tick **只重置基线、不比较**:
|
|
189
|
+
// frames 在暂停期间冻住,若直接拿 lastFrames(暂停前的旧值)去比,
|
|
190
|
+
// 恢复播放后可能立刻算成一次停滞(同一个误报的镜像)。
|
|
191
|
+
if (stats.wasPaused) {
|
|
192
|
+
stats.wasPaused = false;
|
|
193
|
+
stats.lastFrames = null;
|
|
194
|
+
stats.stalls = 0;
|
|
195
|
+
stats.ticks += 1;
|
|
196
|
+
publishHangGuardStats(stats);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
stats.ticks += 1;
|
|
201
|
+
const frames = typeof snap.frames === 'number' ? snap.frames : null;
|
|
202
|
+
// 没模型在渲染时不计停滞:没东西可卡,报了就是误报。
|
|
203
|
+
// 只看 model:`snap.phase` 是**恒假**的死条件 —— statsOf() 不返回 phase
|
|
204
|
+
// (`phase: 'ready'` 只出现在 canvas-api 的 onStatus 回调里,不是 stats() 快照的一部分)。
|
|
205
|
+
const loaded = !!snap.model;
|
|
206
|
+
if (!loaded || frames === null) { stats.lastFrames = frames; return; }
|
|
207
|
+
stats.stalls = stats.lastFrames !== null && frames <= stats.lastFrames ? stats.stalls + 1 : 0;
|
|
208
|
+
stats.lastFrames = frames;
|
|
209
|
+
if (stats.stalls >= hangGuardConfig.stallLimit) {
|
|
210
|
+
stats.stalls = 0;
|
|
211
|
+
stats.recoveries += 1;
|
|
212
|
+
stageLog('warn', `检测到渲染停滞(${hangGuardConfig.stallLimit}s 无新帧),已尝试恢复`, { stallLimit: hangGuardConfig.stallLimit });
|
|
213
|
+
try { stage.pause(); stage.play(); } catch { /* 自愈失败不影响主流程 */ }
|
|
214
|
+
}
|
|
215
|
+
publishHangGuardStats(stats);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 从后台切回可见时**重置基线**:
|
|
219
|
+
// 后台期间 rAF 停着、frames 冻住,stats.lastFrames 是"很久以前的旧值"。
|
|
220
|
+
// 不重置就直接比,会把整个后台时长一次性算成停滞 ⇒ 切回来第一个 tick 就报"渲染停滞",
|
|
221
|
+
// 和后台误报是同一个病,只是晚了一步。
|
|
222
|
+
// 这里只清基线(lastFrames=null 让下一个 tick 只记录不比较;stalls 归零),
|
|
223
|
+
// 不动 recoveries/skippedHidden(那是累计事实,不该被一次切页抹掉)。
|
|
224
|
+
// 幂等注册:模块只加载一次,监听只挂一次。
|
|
225
|
+
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
|
|
226
|
+
document.addEventListener('visibilitychange', () => {
|
|
227
|
+
const stats = state.hangGuardStats;
|
|
228
|
+
if (!stats) return;
|
|
229
|
+
if (document.visibilityState === 'visible') {
|
|
230
|
+
stats.lastFrames = null;
|
|
231
|
+
stats.stalls = 0;
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 设置导入导出(ctl-settings-io):三个动作共用一条通路,按按钮文字分派。
|
|
238
|
+
* 导入依赖后端写接口,接口没实现就不假装能做(按钮在 schema 里已带禁用原因)。
|
|
239
|
+
*/
|
|
240
|
+
// 自动化入口:文件选择器在无头环境里没法点,测试直接喂 JSON 文本
|
|
241
|
+
window.__WEB__ && (window.__WEB__.settingsIo = { importText: importSettingsFromText, exportNow: exportSettingsFile });
|
|
242
|
+
|
|
243
|
+
export function runSettingsAction(action) {
|
|
244
|
+
if (action === '导出设置') return exportSettingsFile();
|
|
245
|
+
if (action === '导入设置') return openImportPicker();
|
|
246
|
+
if (action === '恢复默认') return restoreDefaultSettings();
|
|
247
|
+
stageLog('warn', `设置动作不可用:${action}`, { action });
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 恢复默认:清本地存储 → 按面板声明的默认值重放一遍 → 视图复位 */
|
|
252
|
+
export function restoreDefaultSettings() {
|
|
253
|
+
try { localStorage.removeItem(VIEW_SETTINGS_KEY); } catch { /* 存储不可用不算失败 */ }
|
|
254
|
+
state.panelSettings = {};
|
|
255
|
+
try { saveViewSettings({ panel: {} }); } catch { /* 存储不可用忽略 */ }
|
|
256
|
+
const defaults = panelBridge.getPanelDefaults() || {};
|
|
257
|
+
for (const [id, value] of Object.entries(defaults)) {
|
|
258
|
+
// MSAA 默认值已由建上下文时生效,重放会白等一次上下文重建
|
|
259
|
+
if (id === 'ctl-msaa') continue;
|
|
260
|
+
if (typeof panelBridge.applyPanelSetting === 'function') panelBridge.applyPanelSetting(id, value);
|
|
261
|
+
}
|
|
262
|
+
applyTheme('dark-cyan');
|
|
263
|
+
applyBackground('checkerboard');
|
|
264
|
+
const panel = state.panel;
|
|
265
|
+
if (panel && panel.setValue) {
|
|
266
|
+
panel.setValue('ctl-theme', state.viewTheme);
|
|
267
|
+
panel.setValue('ctl-bg-mode', state.viewBg);
|
|
268
|
+
}
|
|
269
|
+
// 视图复位走统一入口(只认当前渲染器,避免 Live2D 面打到隐藏的 Spine 舞台)
|
|
270
|
+
try { resetViewViaRenderer(state.currentRenderer); } catch { /* 视图复位失败不影响设置 */ }
|
|
271
|
+
document.documentElement.dataset.settingsRestored = String(Date.now());
|
|
272
|
+
stageStatus('已恢复默认设置(本地存储已清空、视图已复位)', 'info', { show: true });
|
|
273
|
+
return true;
|
|
274
|
+
}
|