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,403 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// tool/lib/runtime-package.mjs —— 运行时的**取包/校验/解压/落盘**核心(零依赖)
|
|
3
|
+
//
|
|
4
|
+
// 为什么拆出这一层:CLI(tool/fetch-runtimes.mjs)只该管参数与交互;
|
|
5
|
+
// 版本表、tgz 下载、tar 解析、integrity 校验、落盘这些"确定性逻辑"放这里,
|
|
6
|
+
// 才能被单测直接调用(不必起 CLI),也避免 CLI 文件膨胀到几百行。
|
|
7
|
+
//
|
|
8
|
+
// 合规边界(硬约束,别放宽):
|
|
9
|
+
// · Spine 4.0.1+ :官方 npm。我们只是**替用户从上游取**,不托管、不再分发二进制。
|
|
10
|
+
// · Spine 3.x :npm 没有(最早 4.0.1)⇒ 只引导(spine3Guide),不自取。
|
|
11
|
+
// · Live2D Cubism Core:官方只在官网提供且下载须同意专有许可 ⇒ 只引导(live2dGuide),绝不代下。
|
|
12
|
+
//
|
|
13
|
+
// 实现约束:
|
|
14
|
+
// · 解压自己算(zlib.gunzipSync + 自研 tar 头解析),**不 shell 出 tar**
|
|
15
|
+
// —— 跨平台(Windows 未必有 tar)+ 项目踩过 noexec / tool-self-detection 的坑。
|
|
16
|
+
// · 校验用 registry 给的 dist.integrity(sha512),比自己算 sha256 权威。
|
|
17
|
+
// · 先写 .tmp-* 再 rename:校验或解压失败时磁盘上不留半截 js
|
|
18
|
+
// (半截 js 被当合法运行时加载,报错会莫名其妙)。
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import zlib from 'node:zlib';
|
|
24
|
+
import crypto from 'node:crypto';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
/** 项目根(tool/lib/ 的上两级) */
|
|
29
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
30
|
+
|
|
31
|
+
/** registry 元数据地址(scope 包必须把 / 写成 %2f,否则 404) */
|
|
32
|
+
const META_URL = 'https://registry.npmjs.org/@esotericsoftware%2fspine-webgl';
|
|
33
|
+
/** npm 上 Spine 运行时的最早版本(3.x 从来没发过,所以 3.x 只能引导) */
|
|
34
|
+
export const MIN_VERSION = '4.0.1';
|
|
35
|
+
/** 网络超时:元数据与 tarball 都只有几百 KB */
|
|
36
|
+
const META_TIMEOUT_MS = 15000;
|
|
37
|
+
const TARBALL_TIMEOUT_MS = 60000;
|
|
38
|
+
/** tar 块大小 */
|
|
39
|
+
const TAR_BLOCK = 512;
|
|
40
|
+
|
|
41
|
+
// ---------------- 错误分类 ----------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 造一个带分类码的错误(CLI 靠 code 决定退出码与人话)。
|
|
45
|
+
* @param {string} code
|
|
46
|
+
* @param {string} message
|
|
47
|
+
* @returns {Error}
|
|
48
|
+
*/
|
|
49
|
+
export function fail(code, message) {
|
|
50
|
+
const error = new Error(message);
|
|
51
|
+
error.code = code;
|
|
52
|
+
return error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------- 目录与版本 ----------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 数据根:与 web.sh / serve.mjs / 各 resolver 同一口径
|
|
59
|
+
* (MODELSTAGE_DATA_ROOT 覆盖,缺省项目根)。
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function defaultDataRoot() {
|
|
63
|
+
const env = (process.env.MODELSTAGE_DATA_ROOT || '').trim();
|
|
64
|
+
return env ? path.resolve(env) : ROOT;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 下载落点根:<dataRoot>/runtimes(内部再分 spine/<ver>/ 与 live2d/<ver>/)。
|
|
69
|
+
* 下载物**绝不能**落进 src/*-runtimes/:npx / 只读安装场景下项目目录可能只读或临时。
|
|
70
|
+
* @param {string} [dataRoot]
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function runtimesRoot(dataRoot) {
|
|
74
|
+
return path.join(dataRoot || defaultDataRoot(), 'runtimes');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 按语义版本比较(数值比较,不是字符串比较)。
|
|
79
|
+
* 为什么不能用字符串排序:4.0.10 会被排到 4.0.9 前面,用户看到的"最新版"就是错的。
|
|
80
|
+
* @param {string} a
|
|
81
|
+
* @param {string} b
|
|
82
|
+
* @returns {number}
|
|
83
|
+
*/
|
|
84
|
+
export function compareSemver(a, b) {
|
|
85
|
+
const left = String(a).split('.').map((x) => Number(x) || 0);
|
|
86
|
+
const right = String(b).split('.').map((x) => Number(x) || 0);
|
|
87
|
+
const len = Math.max(left.length, right.length);
|
|
88
|
+
for (let i = 0; i < len; i += 1) {
|
|
89
|
+
const diff = (left[i] || 0) - (right[i] || 0);
|
|
90
|
+
if (diff !== 0) return diff;
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 从 registry 版本表里挑出可下载的版本(>= 4.0.1),按语义版本升序。
|
|
97
|
+
* **不硬编码任何版本表** —— 版本清单永远以 registry 为准。
|
|
98
|
+
* @param {string[]} all registry 的 versions 键
|
|
99
|
+
* @returns {string[]}
|
|
100
|
+
*/
|
|
101
|
+
export function pickDownloadableVersions(all) {
|
|
102
|
+
return (Array.isArray(all) ? all : [])
|
|
103
|
+
.filter((v) => /^\d+\.\d+\.\d+/.test(String(v)))
|
|
104
|
+
.filter((v) => compareSemver(v, MIN_VERSION) >= 0)
|
|
105
|
+
.sort(compareSemver);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------- 网络 ----------------
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 取 registry 元数据。
|
|
112
|
+
* @param {{metaUrl?: string}} [opts]
|
|
113
|
+
* @returns {Promise<object>}
|
|
114
|
+
*/
|
|
115
|
+
export async function fetchMeta(opts = {}) {
|
|
116
|
+
const url = opts.metaUrl || META_URL;
|
|
117
|
+
let res;
|
|
118
|
+
try {
|
|
119
|
+
res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(META_TIMEOUT_MS) });
|
|
120
|
+
} catch (e) {
|
|
121
|
+
throw fail('network', `网络不可达:取版本元数据失败(${(e && e.message) || e})`);
|
|
122
|
+
}
|
|
123
|
+
if (res.status === 404) throw fail('not-found', `registry 上找不到这个包(HTTP 404):${url}`);
|
|
124
|
+
if (!res.ok) throw fail('http', `取版本元数据失败:HTTP ${res.status}`);
|
|
125
|
+
try {
|
|
126
|
+
return await res.json();
|
|
127
|
+
} catch (e) {
|
|
128
|
+
throw fail('parse', `版本元数据不是合法 JSON:${(e && e.message) || e}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 下载 tarball 到内存。
|
|
134
|
+
* @param {string} url
|
|
135
|
+
* @returns {Promise<Buffer>}
|
|
136
|
+
*/
|
|
137
|
+
async function downloadTarball(url) {
|
|
138
|
+
let res;
|
|
139
|
+
try {
|
|
140
|
+
res = await fetch(url, { signal: AbortSignal.timeout(TARBALL_TIMEOUT_MS) });
|
|
141
|
+
} catch (e) {
|
|
142
|
+
throw fail('network', `网络不可达:下载 tarball 失败(${(e && e.message) || e})`);
|
|
143
|
+
}
|
|
144
|
+
if (res.status === 404) throw fail('not-found', `tarball 地址 404(版本或地址不对):${url}`);
|
|
145
|
+
if (!res.ok) throw fail('http', `下载 tarball 失败:HTTP ${res.status}`);
|
|
146
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
147
|
+
if (!buf.length) throw fail('empty', '下载到的 tarball 是空的');
|
|
148
|
+
return buf;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------- tar 解析(零依赖) ----------------
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 读 tar 头的八进制数字字段。
|
|
155
|
+
* @param {Buffer} buf
|
|
156
|
+
* @param {number} start
|
|
157
|
+
* @param {number} len
|
|
158
|
+
* @returns {number}
|
|
159
|
+
*/
|
|
160
|
+
function readOctal(buf, start, len) {
|
|
161
|
+
const raw = buf.subarray(start, start + len).toString('latin1').replace(/\0.*$/, '').trim();
|
|
162
|
+
if (!raw) return 0;
|
|
163
|
+
// GNU base-256:最高位置 1 时是二进制大整数(少见;出现了返回 0 交给后续跳过,不瞎算)
|
|
164
|
+
if (raw.charCodeAt(0) & 0x80) return 0;
|
|
165
|
+
const n = parseInt(raw, 8);
|
|
166
|
+
return Number.isFinite(n) ? n : 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 读 C 字符串字段(以 \0 截断)。
|
|
171
|
+
* @param {Buffer} buf
|
|
172
|
+
* @param {number} start
|
|
173
|
+
* @param {number} len
|
|
174
|
+
* @returns {string}
|
|
175
|
+
*/
|
|
176
|
+
function readStr(buf, start, len) {
|
|
177
|
+
return buf.subarray(start, start + len).toString('utf8').replace(/\0.*$/s, '');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 解析 tar 字节流,产出所有**普通文件**。
|
|
182
|
+
*
|
|
183
|
+
* 只实现真正会遇到的几种 typeflag:
|
|
184
|
+
* '0' / '\0' 普通文件 · '5' 目录(跳过) · 'L' GNU 长文件名(覆盖下一个条目名)
|
|
185
|
+
* 'x' pax 扩展头(含 path= 覆盖名)· 'g' 全局 pax(跳过)
|
|
186
|
+
* **不认识的一律按 size 跳过、绝不猜** —— 跳错一个字节后面全错位,这是自研 tar 最常见的 bug。
|
|
187
|
+
*
|
|
188
|
+
* @param {Buffer} tar
|
|
189
|
+
* @param {(entry: {name: string, data: Buffer}) => void} onFile
|
|
190
|
+
*/
|
|
191
|
+
export function walkTar(tar, onFile) {
|
|
192
|
+
let off = 0;
|
|
193
|
+
let longName = '';
|
|
194
|
+
let paxPath = '';
|
|
195
|
+
while (off + TAR_BLOCK <= tar.length) {
|
|
196
|
+
const header = tar.subarray(off, off + TAR_BLOCK);
|
|
197
|
+
// 全 0 块 = 归档结束(规范是两个连续 0 块;确认下一块也空就停,避免把中间空洞当结束)
|
|
198
|
+
if (header.every((b) => b === 0)) {
|
|
199
|
+
const next = off + TAR_BLOCK;
|
|
200
|
+
const nextEmpty = next + TAR_BLOCK > tar.length
|
|
201
|
+
|| tar.subarray(next, next + TAR_BLOCK).every((b) => b === 0);
|
|
202
|
+
if (nextEmpty) return;
|
|
203
|
+
off = next;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const size = readOctal(header, 124, 12);
|
|
207
|
+
const type = String.fromCharCode(header[156] || 0x30);
|
|
208
|
+
const prefix = readStr(header, 345, 155);
|
|
209
|
+
let name = readStr(header, 0, 100);
|
|
210
|
+
if (prefix) name = `${prefix}/${name}`;
|
|
211
|
+
const dataStart = off + TAR_BLOCK;
|
|
212
|
+
const data = tar.subarray(dataStart, dataStart + size);
|
|
213
|
+
const nextOff = dataStart + Math.ceil(size / TAR_BLOCK) * TAR_BLOCK;
|
|
214
|
+
|
|
215
|
+
if (type === 'L') {
|
|
216
|
+
// GNU 长名:本块数据就是下一个条目的名字
|
|
217
|
+
longName = data.toString('utf8').replace(/\0.*$/s, '');
|
|
218
|
+
} else if (type === 'x') {
|
|
219
|
+
// pax 扩展头:形如 "26 path=package/dist/iife/x.js\n"
|
|
220
|
+
const text = data.toString('utf8');
|
|
221
|
+
const m = /(?:^|\n)\d+ path=([^\n]+)/.exec(text);
|
|
222
|
+
if (m) paxPath = m[1];
|
|
223
|
+
} else if (type === '0' || type === '\0' || type === '7') {
|
|
224
|
+
const finalName = paxPath || longName || name;
|
|
225
|
+
if (finalName) onFile({ name: finalName, data: Buffer.from(data) });
|
|
226
|
+
longName = '';
|
|
227
|
+
paxPath = '';
|
|
228
|
+
} else {
|
|
229
|
+
// 目录 / 链接 / 全局 pax:不产生数据,但要把待用的长名清掉
|
|
230
|
+
longName = '';
|
|
231
|
+
paxPath = '';
|
|
232
|
+
}
|
|
233
|
+
off = nextOff;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 从 tgz 里取出需要的文件(IIFE 构建 + LICENSE)。
|
|
239
|
+
*
|
|
240
|
+
* 为什么挑 dist/iife/ 而不是 package.json 的 main:
|
|
241
|
+
* main 指向 dist/index.js,那是 **ESM**(`export * from "./AssetManager"`),
|
|
242
|
+
* 而我们的加载器要的是**挂全局 `var spine` 的 IIFE 构建**(浏览器当普通脚本执行;
|
|
243
|
+
* node 侧把源码包成表达式取回 spine 对象,见 tool/lib/spine-runtime.mjs 的沙箱加载)。
|
|
244
|
+
* 包里恰好自带 dist/iife/spine-webgl.js,所以固定取它。
|
|
245
|
+
*
|
|
246
|
+
* @param {Buffer} tgz
|
|
247
|
+
* @returns {{js: {path: string, text: string}, license: {path: string, text: string}|null, files: string[]}}
|
|
248
|
+
*/
|
|
249
|
+
export function extractRuntime(tgz) {
|
|
250
|
+
let raw;
|
|
251
|
+
try {
|
|
252
|
+
raw = zlib.gunzipSync(tgz);
|
|
253
|
+
} catch (e) {
|
|
254
|
+
throw fail('gunzip', `解压 tarball 失败(不是合法 gzip):${(e && e.message) || e}`);
|
|
255
|
+
}
|
|
256
|
+
const wanted = [];
|
|
257
|
+
const all = [];
|
|
258
|
+
walkTar(raw, ({ name, data }) => {
|
|
259
|
+
all.push(name);
|
|
260
|
+
if (/\/dist\/iife\/spine-webgl(\.[\w-]+)?\.js$/.test(name) && !name.endsWith('.map')) {
|
|
261
|
+
wanted.push({ name, data });
|
|
262
|
+
} else if (/\/LICENSE(\.(md|txt))?$/i.test(name)) {
|
|
263
|
+
wanted.push({ name, data });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// 优先非压缩版(与项目里既有的 spine-webgl-x.y.js 同构,排查时人类可读)
|
|
268
|
+
const jsCandidates = wanted.filter((w) => /\.js$/.test(w.name));
|
|
269
|
+
const pick = jsCandidates.find((w) => /\/spine-webgl\.js$/.test(w.name)) || jsCandidates[0];
|
|
270
|
+
if (!pick) {
|
|
271
|
+
throw fail('layout', `包结构里没有 dist/iife/spine-webgl*.js(找到 ${all.length} 个文件)。`
|
|
272
|
+
+ '上游可能改了打包布局,需要更新选取规则(不要退回 main:那是 ESM)');
|
|
273
|
+
}
|
|
274
|
+
const text = pick.data.toString('utf8');
|
|
275
|
+
if (!/var\s+spine\s*=/.test(text)) {
|
|
276
|
+
throw fail('layout', `取到的 ${pick.name} 不是"挂全局 spine"的构建(首 120 字符:${text.slice(0, 120)})`);
|
|
277
|
+
}
|
|
278
|
+
const lic = wanted.find((w) => /LICENSE/i.test(w.name)) || null;
|
|
279
|
+
return {
|
|
280
|
+
js: { path: pick.name, text },
|
|
281
|
+
license: lic ? { path: lic.name, text: lic.data.toString('utf8') } : null,
|
|
282
|
+
files: all,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 剥掉 `//# sourceMappingURL=...` 行。
|
|
288
|
+
* 为什么必须剥:.map 不落地,留着这行会让 devtools 为一个不存在的文件报 404(噪音)。
|
|
289
|
+
* @param {string} text
|
|
290
|
+
* @returns {string}
|
|
291
|
+
*/
|
|
292
|
+
export function stripSourceMapRef(text) {
|
|
293
|
+
return text.replace(/^\/\/[#@]\s*sourceMappingURL=.*$/gm, '').replace(/\n{3,}/g, '\n\n');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------- 校验与落盘 ----------------
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 校验 tarball 的 sha512(npm 的 dist.integrity 形如 `sha512-<base64>`)。
|
|
300
|
+
* @param {Buffer} buf
|
|
301
|
+
* @param {string} integrity
|
|
302
|
+
* @param {(alg: string, buf: Buffer) => string} [hashFn] 测试可注入
|
|
303
|
+
*/
|
|
304
|
+
export function verifyIntegrity(buf, integrity, hashFn) {
|
|
305
|
+
const m = /^(sha512|sha256|sha1)-(.+)$/.exec(String(integrity || '').trim());
|
|
306
|
+
if (!m) throw fail('integrity-format', `integrity 字段格式不认识:${String(integrity).slice(0, 60)}`);
|
|
307
|
+
const [, alg, want] = m;
|
|
308
|
+
const got = hashFn
|
|
309
|
+
? hashFn(alg, buf)
|
|
310
|
+
: crypto.createHash(alg).update(buf).digest('base64');
|
|
311
|
+
if (got !== want) {
|
|
312
|
+
throw fail('integrity', `integrity 校验失败(${alg}):期望 ${want.slice(0, 16)}… 实际 ${got.slice(0, 16)}…`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 原子写:先写同目录 .tmp-*,成功后 rename。
|
|
318
|
+
* @param {string} file
|
|
319
|
+
* @param {string|Buffer} content
|
|
320
|
+
*/
|
|
321
|
+
function writeAtomic(file, content) {
|
|
322
|
+
const dir = path.dirname(file);
|
|
323
|
+
const tmp = path.join(dir, `.tmp-${process.pid}-${path.basename(file)}`);
|
|
324
|
+
try {
|
|
325
|
+
// 先保证**目标文件的父目录**存在(不是临时文件那个目录 —— 两者相同,但意图要写清:
|
|
326
|
+
// 我们关心的是"落点能不能写")。调用方目前恰好都建好了目录,但原子写函数不该依赖这件事:
|
|
327
|
+
// 任何人下次直接调它,不建目录就会在最前面那行 writeFileSync 收到 ENOENT。
|
|
328
|
+
// recursive 幂等、无副作用,重复调用不抛错。
|
|
329
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
330
|
+
fs.writeFileSync(tmp, content);
|
|
331
|
+
fs.renameSync(tmp, file);
|
|
332
|
+
} catch (e) {
|
|
333
|
+
try { fs.unlinkSync(tmp); } catch { /* 清理失败不掩盖原始错误 */ }
|
|
334
|
+
throw fail('write', `落点不可写:${file}(${(e && e.message) || e})`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* 下载并落地一个 Spine 版本。
|
|
340
|
+
* @param {string} version
|
|
341
|
+
* @param {{dir?: string, dataRoot?: string, metaUrl?: string, log?: (s: string) => void}} opts
|
|
342
|
+
* @returns {Promise<{version: string, dir: string, jsFile: string, licenseFile: string|null, sourceFile: string}>}
|
|
343
|
+
*/
|
|
344
|
+
export async function fetchSpine(version, opts = {}) {
|
|
345
|
+
const log = opts.log || (() => {});
|
|
346
|
+
const meta = await fetchMeta(opts);
|
|
347
|
+
const versions = meta && meta.versions ? meta.versions : {};
|
|
348
|
+
const info = versions[version];
|
|
349
|
+
if (!info) {
|
|
350
|
+
// 版本表有 200+ 条,全列出来终端就没法看 ⇒ 只报最新几个 + 总数
|
|
351
|
+
const avail = pickDownloadableVersions(Object.keys(versions));
|
|
352
|
+
const tailList = avail.slice(-6).join(' / ') || '(取不到版本表)';
|
|
353
|
+
throw fail('version', `版本 ${version} 不存在。最近的可下载版本:${tailList}`
|
|
354
|
+
+ `(共 ${avail.length} 个,用 --list 查看全部)`);
|
|
355
|
+
}
|
|
356
|
+
const tarball = info.dist && info.dist.tarball;
|
|
357
|
+
const integrity = info.dist && info.dist.integrity;
|
|
358
|
+
if (!tarball) throw fail('meta', `registry 没给 ${version} 的 dist.tarball`);
|
|
359
|
+
if (!integrity) throw fail('meta', `registry 没给 ${version} 的 dist.integrity(无法校验,拒绝落地)`);
|
|
360
|
+
|
|
361
|
+
log(`下载 ${version} …`);
|
|
362
|
+
const tgz = await downloadTarball(tarball);
|
|
363
|
+
log(`校验 integrity(${Math.round(tgz.length / 1024)}KB)…`);
|
|
364
|
+
verifyIntegrity(tgz, integrity);
|
|
365
|
+
|
|
366
|
+
const { js, license, files } = extractRuntime(tgz);
|
|
367
|
+
const clean = stripSourceMapRef(js.text);
|
|
368
|
+
|
|
369
|
+
const root = opts.dir || runtimesRoot(opts.dataRoot);
|
|
370
|
+
const targetDir = path.join(root, 'spine', version);
|
|
371
|
+
try {
|
|
372
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
373
|
+
} catch (e) {
|
|
374
|
+
throw fail('write', `落点不可写:${targetDir}(${(e && e.message) || e})`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 文件名必须归一化成 resolver 认的名字:它按 `spine-webgl-<ver>.js` 拼路径,
|
|
378
|
+
// 包里原名是 dist/iife/spine-webgl.js —— 不归一化就是"文件在、但引擎找不到"
|
|
379
|
+
const jsFile = path.join(targetDir, `spine-webgl-${version}.js`);
|
|
380
|
+
writeAtomic(jsFile, clean);
|
|
381
|
+
|
|
382
|
+
let licenseFile = null;
|
|
383
|
+
if (license) {
|
|
384
|
+
licenseFile = path.join(targetDir, 'LICENSE');
|
|
385
|
+
writeAtomic(licenseFile, license.text);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const sourceFile = path.join(targetDir, 'SOURCE.json');
|
|
389
|
+
writeAtomic(sourceFile, JSON.stringify({
|
|
390
|
+
package: '@esotericsoftware/spine-webgl',
|
|
391
|
+
version,
|
|
392
|
+
tarball,
|
|
393
|
+
integrity,
|
|
394
|
+
packageFile: js.path,
|
|
395
|
+
packageFiles: files.length,
|
|
396
|
+
fetchedAt: new Date().toISOString(),
|
|
397
|
+
tool: 'tool/fetch-runtimes.mjs',
|
|
398
|
+
note: 'Spine Runtimes 使用 Esoteric Software 自定义许可(非 MIT);使用方需自行遵守,商用需 Spine 授权。',
|
|
399
|
+
}, null, 2));
|
|
400
|
+
|
|
401
|
+
log(`已落地:${jsFile}`);
|
|
402
|
+
return { version, dir: targetDir, jsFile, licenseFile, sourceFile };
|
|
403
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// tool/lib/spine-meta.mjs —— Spine 模型的元数据提取(服务端,供 /api/model-meta)
|
|
3
|
+
//
|
|
4
|
+
// 产出与 Cubism 的 modelMeta **同字段名**,让新前端用一套代码吃两种模型:
|
|
5
|
+
// { kind:'spine', version, designSize:{width,height}, bbox:{x,y,width,height,unit,pose},
|
|
6
|
+
// bones, slots, animations, skins, runtime:{origin} }
|
|
7
|
+
//
|
|
8
|
+
// 口径与限制(与 Cubism 侧一致,写进文档):
|
|
9
|
+
// · designSize = SkeletonData 的 width/height(Spine 的设计画布尺寸)
|
|
10
|
+
// · bbox = **rest pose(setup pose)**下的可视包围盒,单位是模型单位,不含动作/物理变形
|
|
11
|
+
//
|
|
12
|
+
// 错误码(SPINE_ERR_*,同一失败原因永远同一码):
|
|
13
|
+
// SPINE_ERR_BAD_ARGS / SPINE_ERR_NO_FILE / SPINE_ERR_BAD_VERSION / SPINE_ERR_NO_RUNTIME / SPINE_ERR_PARSE
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
import { loadRuntime, listRuntimeVersions } from './spine-runtime.mjs';
|
|
17
|
+
import { classifyAll } from './spine-motions.mjs';
|
|
18
|
+
|
|
19
|
+
export const SPINE_CODES = {
|
|
20
|
+
BAD_ARGS: 'SPINE_ERR_BAD_ARGS',
|
|
21
|
+
NO_FILE: 'SPINE_ERR_NO_FILE',
|
|
22
|
+
BAD_VERSION: 'SPINE_ERR_BAD_VERSION',
|
|
23
|
+
NO_RUNTIME: 'SPINE_ERR_NO_RUNTIME',
|
|
24
|
+
PARSE: 'SPINE_ERR_PARSE',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function fail(code, message, extra) {
|
|
28
|
+
const err = new Error(message);
|
|
29
|
+
err.code = code;
|
|
30
|
+
err.ulb = true;
|
|
31
|
+
if (extra && typeof extra === 'object') Object.assign(err, extra);
|
|
32
|
+
return err;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 从 JSON 骨架里取版本(skeleton.spine,如 "3.8.99") */
|
|
36
|
+
function versionFromJson(bytes) {
|
|
37
|
+
try {
|
|
38
|
+
const j = JSON.parse(new TextDecoder('utf-8').decode(bytes));
|
|
39
|
+
const v = j && j.skeleton && j.skeleton.spine;
|
|
40
|
+
return typeof v === 'string' ? v : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 探测 .skel 二进制里的版本号。
|
|
48
|
+
* 格式:前 8 字节是 hash,紧随其后是版本——**3.8 起是字符串**(1 字节长度 + 字符),
|
|
49
|
+
* 更早(3.6/3.7)是 4 字节小端 float。两种都试,取合理值。
|
|
50
|
+
* @returns {string|null} 形如 '3.8' / '4.2'
|
|
51
|
+
*/
|
|
52
|
+
export function detectSpineVersion(bytes, fileName) {
|
|
53
|
+
if (!bytes || !bytes.length) return null;
|
|
54
|
+
const isJson = /\.json$/i.test(fileName || '') || bytes[0] === 0x7b; // '{'
|
|
55
|
+
if (isJson) {
|
|
56
|
+
const v = versionFromJson(bytes);
|
|
57
|
+
if (!v) return null;
|
|
58
|
+
const m = v.match(/^(\d+)\.(\d+)/);
|
|
59
|
+
return m ? `${m[1]}.${m[2]}` : null;
|
|
60
|
+
}
|
|
61
|
+
// 二进制:不同版本的头部布局不完全一样(实测 3.8 是「8 字节 hash + 20 字符 hash 串 + 版本串」),
|
|
62
|
+
// 所以不假设固定偏移,而是在前 64 字节里扫两种特征:
|
|
63
|
+
// ① 「长度字节 + 形如 数字.数字 的短字符串」——版本号总是这种形态
|
|
64
|
+
// ② 合理的 float32(更老的格式直接存 float 版本)
|
|
65
|
+
const limit = Math.min(bytes.length, 64);
|
|
66
|
+
for (let i = 0; i + 1 < limit; i++) {
|
|
67
|
+
const len = bytes[i];
|
|
68
|
+
if (len < 3 || len > 16) continue;
|
|
69
|
+
if (i + 1 + len > bytes.length) continue;
|
|
70
|
+
// 只要求"开头像版本号"(数字+点+数字):有的版本会把版本串按长度补满,尾部未必还是数字/点
|
|
71
|
+
let s = '';
|
|
72
|
+
for (let k = 0; k < Math.min(len, 8); k++) {
|
|
73
|
+
const c = bytes[i + 1 + k];
|
|
74
|
+
if ((c >= 0x30 && c <= 0x39) || c === 0x2e) s += String.fromCharCode(c);
|
|
75
|
+
else break;
|
|
76
|
+
}
|
|
77
|
+
const m = s.match(/^(\d+)\.(\d+)/);
|
|
78
|
+
if (m) return `${m[1]}.${m[2]}`;
|
|
79
|
+
}
|
|
80
|
+
// float 兜底(3.6 / 3.7 这类老格式)
|
|
81
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
82
|
+
for (let i = 8; i + 4 <= limit; i++) {
|
|
83
|
+
const f = dv.getFloat32(i, true);
|
|
84
|
+
if (Number.isFinite(f) && f > 2 && f < 5 && Math.abs(f - Math.round(f * 10) / 10) < 0.001) {
|
|
85
|
+
return f.toFixed(1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 选最接近的可用运行时版本(精确命中优先,否则取同 major 的最大 minor) */
|
|
92
|
+
function pickRuntimeVersion(want) {
|
|
93
|
+
const all = listRuntimeVersions();
|
|
94
|
+
if (!all.length) return null;
|
|
95
|
+
if (all.includes(want)) return want;
|
|
96
|
+
const major = String(want).split('.')[0];
|
|
97
|
+
const sameMajor = all.filter((v) => v.split('.')[0] === major).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
98
|
+
return sameMajor.length ? sameMajor[sameMajor.length - 1] : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 读 setup pose 的可视包围盒(模型单位);拿不到时返回 null,不抛错 */
|
|
102
|
+
function readSetupBbox(spine, skeleton) {
|
|
103
|
+
try {
|
|
104
|
+
if (typeof skeleton.getBounds !== 'function' || !spine.Vector2) return null;
|
|
105
|
+
const off = new spine.Vector2();
|
|
106
|
+
const size = new spine.Vector2();
|
|
107
|
+
skeleton.getBounds(off, size, []);
|
|
108
|
+
if (!(size.x > 0 && size.y > 0) || !Number.isFinite(off.x) || !Number.isFinite(off.y)) return null;
|
|
109
|
+
return { x: +off.x.toFixed(3), y: +off.y.toFixed(3), width: +size.x.toFixed(3), height: +size.y.toFixed(3), unit: 'model', pose: 'rest' };
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 解析 Spine 骨架并产出元数据。
|
|
117
|
+
* @param {object} input
|
|
118
|
+
* @param {Uint8Array} input.bytes 骨架文件字节
|
|
119
|
+
* @param {string} input.fileName 文件名(用于判断 json/skel)
|
|
120
|
+
* @param {string} [input.atlasText] 同目录 atlas 文本(提供时才能算 bbox;没有则 bbox=null)
|
|
121
|
+
* @returns {object} 元数据(字段见文件头)
|
|
122
|
+
*/
|
|
123
|
+
export function readSpineMeta(input) {
|
|
124
|
+
const bytes = input && input.bytes;
|
|
125
|
+
const fileName = (input && input.fileName) || '';
|
|
126
|
+
if (!bytes || !bytes.length) throw fail(SPINE_CODES.NO_FILE, '骨架文件为空或不存在');
|
|
127
|
+
const detected = detectSpineVersion(bytes, fileName);
|
|
128
|
+
if (!detected) throw fail(SPINE_CODES.BAD_VERSION, `无法识别 Spine 版本(文件 ${fileName || '(未命名)'}):既不是 JSON 骨架,也没有可识别的版本头`);
|
|
129
|
+
const runtimeVersion = pickRuntimeVersion(detected);
|
|
130
|
+
if (!runtimeVersion) throw fail(SPINE_CODES.NO_RUNTIME, `没有可用的 Spine ${detected} 运行时(可用:${listRuntimeVersions().join(', ') || '无'})`);
|
|
131
|
+
|
|
132
|
+
let loaded;
|
|
133
|
+
try {
|
|
134
|
+
loaded = loadRuntime(runtimeVersion);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
throw fail(SPINE_CODES.NO_RUNTIME, `加载 Spine ${runtimeVersion} 运行时失败:${e.message}`);
|
|
137
|
+
}
|
|
138
|
+
const spine = loaded.spine;
|
|
139
|
+
|
|
140
|
+
// 附件加载器:有 atlas 时按真实 region 建附件(能算 bbox);没有时用返回 null 的桩(只出计数)。
|
|
141
|
+
// 4.x 的 atlas/附件绑定与 3.8 不完全一致(实测 4.1 在解析阶段会摸 page.texture.getImage),
|
|
142
|
+
// 所以 atlas 路径失败时**降级**到桩加载器:designSize/计数照出,bbox 置空并在结果里标注原因。
|
|
143
|
+
const makeStubLoader = () => ({
|
|
144
|
+
newRegionAttachment: () => null, newMeshAttachment: () => null,
|
|
145
|
+
newBoundingBoxAttachment: () => null, newPathAttachment: () => null,
|
|
146
|
+
newPointAttachment: () => null, newClippingAttachment: () => null,
|
|
147
|
+
});
|
|
148
|
+
let loader = null;
|
|
149
|
+
let atlasNote = null;
|
|
150
|
+
try {
|
|
151
|
+
if (input.atlasText && spine.TextureAtlas && spine.AtlasAttachmentLoader) {
|
|
152
|
+
const fakePage = {
|
|
153
|
+
width: 1024, height: 1024,
|
|
154
|
+
setTexture() {}, setFilters() {}, setWraps() {}, dispose() {},
|
|
155
|
+
getImage: () => ({ width: 1024, height: 1024 }),
|
|
156
|
+
// 4.x 的附件绑定会直接读 page.texture.getImage()(3.8 不会),所以这里也垫一层
|
|
157
|
+
texture: { getImage: () => ({ width: 1024, height: 1024 }) },
|
|
158
|
+
};
|
|
159
|
+
const atlas = new spine.TextureAtlas(input.atlasText, () => fakePage);
|
|
160
|
+
// 4.x 的附件绑定会直接读 page.texture.getImage(),而 setTexture 在部分版本里是空实现
|
|
161
|
+
// ⇒ 构造完 atlas 后直接把 texture 挂到每个 page 上(3.8 无副作用,4.x 必需)
|
|
162
|
+
for (const pg of atlas.pages || []) {
|
|
163
|
+
pg.texture = { getImage: () => ({ width: 1024, height: 1024 }) };
|
|
164
|
+
}
|
|
165
|
+
loader = new spine.AtlasAttachmentLoader(atlas);
|
|
166
|
+
}
|
|
167
|
+
} catch (e) {
|
|
168
|
+
loader = null;
|
|
169
|
+
atlasNote = `atlas 不可用(${e.message}),bbox 置空`;
|
|
170
|
+
}
|
|
171
|
+
if (!loader) loader = makeStubLoader();
|
|
172
|
+
|
|
173
|
+
const stubLoader = makeStubLoader();
|
|
174
|
+
const parseWith = (ld) => {
|
|
175
|
+
const isJson = /\.json$/i.test(fileName) || bytes[0] === 0x7b;
|
|
176
|
+
let d;
|
|
177
|
+
if (isJson) {
|
|
178
|
+
const json = JSON.parse(new TextDecoder('utf-8').decode(bytes));
|
|
179
|
+
d = new spine.SkeletonJson(ld).readSkeletonData(json);
|
|
180
|
+
} else {
|
|
181
|
+
d = new spine.SkeletonBinary(ld).readSkeletonData(new Uint8Array(bytes));
|
|
182
|
+
}
|
|
183
|
+
const sk = new spine.Skeleton(d);
|
|
184
|
+
try { sk.updateWorldTransform(2); } catch { sk.updateWorldTransform(); }
|
|
185
|
+
return { data: d, skeleton: sk };
|
|
186
|
+
};
|
|
187
|
+
let parsed;
|
|
188
|
+
try {
|
|
189
|
+
parsed = parseWith(loader);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
if (loader !== stubLoader) {
|
|
192
|
+
// atlas 绑定与运行时版本不完全兼容时降级重试:只出计数与 designSize,bbox 置空并标注原因
|
|
193
|
+
try {
|
|
194
|
+
parsed = parseWith(stubLoader);
|
|
195
|
+
atlasNote = `atlas 绑定与 Spine ${runtimeVersion} 运行时不完全兼容(${e.message}),已降级为仅计数,bbox 置空`;
|
|
196
|
+
} catch (e2) {
|
|
197
|
+
throw fail(SPINE_CODES.PARSE, `骨架解析失败(Spine ${runtimeVersion}):${e2.message}`);
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
throw fail(SPINE_CODES.PARSE, `骨架解析失败(Spine ${runtimeVersion}):${e.message}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const data = parsed.data;
|
|
204
|
+
const skeleton = parsed.skeleton;
|
|
205
|
+
|
|
206
|
+
const width = Number(data.width) || 0;
|
|
207
|
+
const height = Number(data.height) || 0;
|
|
208
|
+
// 动作名 → 分组/循环/置信度:规则由调用方注入(读 tool/spine-motion-rules.json);
|
|
209
|
+
// 不注入时全部按"未知名"处理(group=动作名、loop=false、confidence=low),不猜。
|
|
210
|
+
const animNames = (data.animations || []).map((a) => a && a.name).filter((n) => typeof n === 'string' && n);
|
|
211
|
+
const motions = input.rules
|
|
212
|
+
? classifyAll(animNames, input.rules)
|
|
213
|
+
: animNames.map((n) => ({ name: n, group: n, loop: false, confidence: 'low', rule: null }));
|
|
214
|
+
return {
|
|
215
|
+
kind: 'spine',
|
|
216
|
+
version: detected,
|
|
217
|
+
designSize: width > 0 && height > 0 ? { width, height, source: 'skeleton.width/height' } : null,
|
|
218
|
+
bbox: readSetupBbox(spine, skeleton),
|
|
219
|
+
bboxNote: atlasNote || null,
|
|
220
|
+
bones: (data.bones || []).length,
|
|
221
|
+
slots: (data.slots || []).length,
|
|
222
|
+
animations: animNames.length,
|
|
223
|
+
motions,
|
|
224
|
+
skins: (data.skins || []).length,
|
|
225
|
+
runtime: { version: runtimeVersion, origin: loaded.origin },
|
|
226
|
+
};
|
|
227
|
+
}
|