modelstage 0.5.24 → 0.6.1

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +2 -56
  3. package/bin/modelstage.mjs +48 -12
  4. package/config/models/live2d.assetbundls.json +1 -1
  5. package/config/models/live2d.azurlane.json +1 -1
  6. package/config/models/live2d.azurlane_bundle.json +1 -1
  7. package/config/models/live2d.azurlane_bundle.jsonl +2 -0
  8. package/config/models/live2d.azurlane_spine.json +1 -1
  9. package/config/models/live2d.bd2.json +1 -1
  10. package/config/models/live2d.bd2.jsonl +2 -0
  11. package/config/models/live2d.bluearchive.json +1 -1
  12. package/config/models/live2d.nikke.json +1 -1
  13. package/config/models/live2d.nikke.jsonl +2 -0
  14. package/config/models/live2d.nikke_2.json +1 -1
  15. package/config/models/live2d.npc.json +1 -1
  16. package/config/models/spine.assetbundls.json +1 -1
  17. package/config/models/spine.azurlane.json +1 -1
  18. package/config/models/spine.azurlane_bundle.json +1 -1
  19. package/config/models/spine.azurlane_bundle.jsonl +2 -0
  20. package/config/models/spine.azurlane_spine.json +1 -1
  21. package/config/models/spine.bd2.json +1 -1
  22. package/config/models/spine.bd2.jsonl +433 -0
  23. package/config/models/spine.bluearchive.json +1 -1
  24. package/config/models/spine.nikke.json +1 -1
  25. package/config/models/spine.nikke_2.json +1 -1
  26. package/config/models/spine.npc.json +1 -1
  27. package/package.json +8 -2
  28. package/src/server/assets/probe.mjs +1 -1
  29. package/src/server/core/config.mjs +2 -2
  30. package/src/server/core/log.mjs +1 -1
  31. package/src/server/data/catalog.mjs +11 -14
  32. package/src/server/data/live2d.mjs +139 -40
  33. package/src/server/data/manifest-cache.mjs +53 -10
  34. package/src/server/data/manifest-jsonl.mjs +264 -0
  35. package/src/server/data/manifest-stamp.mjs +52 -2
  36. package/src/server/data/scan-progress.mjs +305 -0
  37. package/src/server/data/scan-queue.mjs +99 -0
  38. package/src/server/data/spine.mjs +324 -106
  39. package/src/server/http/write.mjs +8 -1
  40. package/src/server/plugins/registry.mjs +1 -1
  41. package/src/server/routes/data.mjs +76 -17
  42. package/src/server/serve.mjs +71 -4
  43. package/src/server/unpack/plugin-store.mjs +1 -1
  44. package/src/server/unpack/runtime/exec.mjs +37 -8
  45. package/src/server/unpack/worker-client.mjs +23 -8
  46. package/src/shared/scan-limits.mjs +23 -0
  47. package/src/shared/type-labels.mjs +31 -0
  48. package/src/web/app/anim-list.js +1 -1
  49. package/src/web/app/backgrounds.js +4 -8
  50. package/src/web/app/dir-picker.js +3 -5
  51. package/src/web/app/memory-budget.js +0 -1
  52. package/src/web/app/memory-guard.js +0 -1
  53. package/src/web/app/model-actions.js +18 -15
  54. package/src/web/app/root-manager.js +2 -4
  55. package/src/web/app/transport-actions.js +1 -1
  56. package/src/web/app/ui-state.js +1 -1
  57. package/src/web/components.css +149 -0
  58. package/src/web/debug/race-probe.js +36 -13
  59. package/src/web/engine/textures.js +1 -2
  60. package/src/web/index.html +2 -0
  61. package/src/web/infra/api.js +10 -19
  62. package/src/web/infra/document.js +3 -3
  63. package/src/web/infra/plugin-host-unpack.mjs +81 -9
  64. package/src/web/infra/ready.js +177 -39
  65. package/src/web/live2d/bridge.js +1 -1
  66. package/src/web/live2d/runtime-adapter.js +2 -4
  67. package/src/web/panel/panel-settings.js +88 -3
  68. package/src/web/roster/events.js +159 -0
  69. package/src/web/roster/filter.js +4 -4
  70. package/src/web/roster/list.js +3 -3
  71. package/src/web/roster/load.js +440 -0
  72. package/src/web/roster/persist.js +64 -15
  73. package/src/web/roster/render.js +284 -0
  74. package/src/web/roster/reveal.js +4 -9
  75. package/src/web/roster/rows.js +35 -13
  76. package/src/web/roster/scan-stream.js +346 -0
  77. package/src/web/roster/snapshot.js +16 -8
  78. package/src/web/roster/store.js +292 -0
  79. package/src/web/roster/type-filter.js +97 -0
  80. package/src/web/roster/view.js +148 -544
  81. package/src/web/spine/canvas-runtime.js +4 -5
  82. package/src/web/spine/runtimes.js +1 -1
  83. package/tool/lib/check-perms.mjs +0 -114
  84. package/tool/lib/contract-checks.mjs +0 -582
  85. package/tool/lib/cubism-core.mjs +0 -122
  86. package/tool/lib/export-bundle.mjs +0 -328
  87. package/tool/lib/spine-runtime.mjs +0 -131
  88. package/tool/lib/test-lock.mjs +0 -178
  89. package/tool/lib/test-report.mjs +0 -133
@@ -1,122 +0,0 @@
1
- // ============================================================================
2
- // tool/lib/cubism-core.mjs —— 在 Node 的 vm 沙箱里跑 Cubism Core(浏览器构建)
3
- //
4
- // 为什么需要:生成 motion3 需要 moc3 的**真实参数 Id**(Core 的 parameters.ids)。
5
- // 纯字符串扫描(moc3NameCandidates)得到的候选名不等于参数 Id:实测用它建 hashLookup
6
- // 会让绑定哈希全部认不出。Core 是浏览器包(Emscripten),所以要垫一个最小浏览器环境。
7
- //
8
- // 沙箱契约(与 spine-runtime.mjs 同思路):
9
- // · 提供 document.currentScript / location / atob / 可应答 data: URI 的 XMLHttpRequest
10
- // —— Core 的 Emscripten 外壳用它们取内嵌 base64 的 WASM 初始化数据
11
- // · 不提供 process/require:避免它走 Node 分支去 readFileSync 一个 data: URI
12
- // · 初始化时机:脚本 eval 完即完成(Emscripten 的 run() 在 eval 末尾),随后自检版本
13
- // · 释放:用完必须 model.release() → moc._release()(顺序固定),本模块在 finally 里保证
14
- //
15
- // 内存:Core 是 WASM 堆,泄漏会累积。getParameterIds 每次调用都成对释放;
16
- // 调用方连续处理多个包时 RSS 应保持平稳(导出 CLI 的实测值见文档)。
17
- // ============================================================================
18
-
19
- import fs from 'node:fs';
20
- import path from 'node:path';
21
- import vm from 'node:vm';
22
- import { fileURLToPath } from 'node:url';
23
-
24
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
- const ROOT = path.resolve(__dirname, '..', '..');
26
-
27
- // Core 的候选路径(项目自带优先;也可用 CUBISM_CORE 指到别处)
28
- const CORE_CANDIDATES = [
29
- path.join(ROOT, 'tool', 'vendor', 'live2dcubismcore.min.js'),
30
- process.env.CUBISM_CORE || '',
31
- ].filter(Boolean);
32
-
33
- let sandboxCache = null;
34
-
35
- /** 最小浏览器垫片 + 加载 Core。只做一次(Core 初始化 ~26ms)。 */
36
- function getCore() {
37
- if (sandboxCache) return sandboxCache;
38
- const file = CORE_CANDIDATES.find((p) => fs.existsSync(p));
39
- if (!file) throw new Error(`找不到 Cubism Core(试过:${CORE_CANDIDATES.join('、')})`);
40
-
41
- // Core 的 WASM 初始化数据内嵌为 data: URI,Emscripten 外壳用 XHR 取;这里给个只认 data: 的桩
42
- class DataUriXHR {
43
- open(_method, url) { this.url = url; }
44
- send() {
45
- const m = /^data:([^;]*);base64,(.*)$/s.exec(String(this.url));
46
- if (!m) throw new Error(`Cubism Core 请求了非 data: URI(${String(this.url).slice(0, 60)}),沙箱不提供网络`);
47
- const buf = Buffer.from(m[2], 'base64');
48
- this.response = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
49
- this.responseText = buf.toString('latin1');
50
- this.status = 200;
51
- this.readyState = 4;
52
- if (this.onload) this.onload();
53
- }
54
- addEventListener() {}
55
- }
56
-
57
- const ctx = {
58
- console: { log() {}, info() {}, warn() {}, error() {}, debug() {} },
59
- WebAssembly, TextDecoder, TextEncoder, ArrayBuffer, Math, Date,
60
- setTimeout, clearTimeout,
61
- atob: (s) => Buffer.from(s, 'base64').toString('binary'),
62
- document: { currentScript: { src: 'http://localhost/live2dcubismcore.min.js' }, addEventListener() {} },
63
- XMLHttpRequest: DataUriXHR,
64
- location: { href: 'http://localhost/live2dcubismcore.min.js' },
65
- };
66
- ctx.window = ctx;
67
- ctx.self = ctx;
68
- ctx.globalThis = ctx;
69
- vm.createContext(ctx);
70
- vm.runInContext(fs.readFileSync(file, 'utf8'), ctx, { filename: 'live2dcubismcore.min.js' });
71
-
72
- const L = ctx.Live2DCubismCore;
73
- if (!L || !L.Moc || !L.Model) throw new Error('Cubism Core 加载后没有 Moc/Model 导出');
74
- // 自检:csmGetVersion 返回编码版本(如 67239938 = 4.2.0 的编码),拿不到就说明沙箱里没跑起来
75
- const version = L.Version && L.Version.csmGetVersion ? L.Version.csmGetVersion() : null;
76
- if (!version) throw new Error('Cubism Core 自检失败:Version.csmGetVersion() 无返回');
77
- sandboxCache = { L, version, file, coreEvalMs: 0 };
78
- return sandboxCache;
79
- }
80
-
81
- /** Core 版本信息(自检/报告用) */
82
- export function coreInfo() {
83
- const { version, file } = getCore();
84
- return { version, file, latestMocVersion: sandboxCache.L.Version.csmGetLatestMocVersion() };
85
- }
86
-
87
- /**
88
- * 从 moc3 字节取**真实参数 Id**(用完立即释放 model 与 moc)。
89
- * @param {Uint8Array} moc3
90
- * @returns {{ids:string[], canvas:{width:number,height:number,ppu:number}, count:number}}
91
- */
92
- export function getParameterIds(moc3) {
93
- const { L } = getCore();
94
- if (!moc3 || !moc3.length) throw new Error('moc3 为空');
95
- // Core 需要 ArrayBuffer(不是 Node Buffer 切片)
96
- const ab = moc3.buffer.slice(moc3.byteOffset, moc3.byteOffset + moc3.byteLength);
97
- let moc = null;
98
- let model = null;
99
- try {
100
- moc = L.Moc.fromArrayBuffer(ab);
101
- if (!moc) throw new Error('Moc.fromArrayBuffer 返回 null(moc3 损坏或版本过新)');
102
- model = L.Model.fromMoc(moc);
103
- if (!model) throw new Error('Model.fromMoc 返回 null');
104
- const ids = Array.from(model.parameters.ids);
105
- const info = model.canvasinfo;
106
- return {
107
- ids,
108
- count: model.parameters.count,
109
- canvas: {
110
- width: info.CanvasWidth / info.PixelsPerUnit,
111
- height: info.CanvasHeight / info.PixelsPerUnit,
112
- ppu: info.PixelsPerUnit,
113
- },
114
- };
115
- } finally {
116
- // 释放顺序固定:先 model 再 moc;任一为 null 就跳过,绝不让异常吃掉释放
117
- try { if (model) model.release(); } catch { /* 释放失败不掩盖原错误 */ }
118
- try { if (moc) moc._release(); } catch { /* 同上 */ }
119
- }
120
- }
121
-
122
- export const CORE_PATHS = { ROOT, CORE_CANDIDATES };
@@ -1,328 +0,0 @@
1
- // ============================================================================
2
- // tool/lib/export-bundle.mjs —— 把 UnityFS 资源包导出成「标准 Live2D 模型目录」
3
- //
4
- // 产出目录(与 AzurLane-Live2D-Viewer 等第三方查看器认可的形态一致):
5
- // <out>/<资源名>/
6
- // <资源名>.moc3
7
- // <资源名>.physics3.json (包里没有就跳过)
8
- // <资源名>.model3.json (FileReferences 指向同目录文件)
9
- // textures/texture_XX.png (ETC2/DXT 解码 → RGBA → PNG 真压缩)
10
- // export-report.json (耗时/字节/跳过/警告,便于批量核对)
11
- //
12
- // 为什么这样组织:
13
- // · 解析/解码/编码全部复用插件里的 lib(同一份实现,避免第二套解析)
14
- // · PNG 必须用 encodePngAsync(真 deflate);encodePng 是 stored 不压缩,4096² 会写 67MB
15
- // · 先写 .tmp-<name>-<pid>/ 再 rename,失败/断电不留半成品
16
- // · 阶段 1 **不导出 motion3**:生成 motion3 需要 moc3 的真实参数 Id(Cubism Core),
17
- // 纯 Node 侧拿不到(实测用名字候选建立的 hashLookup 会让 466 条曲线全部 skippedUnknownBinding)
18
- // ============================================================================
19
-
20
- import fs from 'node:fs';
21
- import path from 'node:path';
22
- import vm from 'node:vm';
23
- import zlib from 'node:zlib';
24
- import { fileURLToPath } from 'node:url';
25
- import { getParameterIds, coreInfo } from './cubism-core.mjs';
26
-
27
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
- const ROOT = path.resolve(__dirname, '..', '..');
29
- const LIB_DIR = path.join(ROOT, 'plugins', 'unity-live2d-bundle', 'lib');
30
-
31
- // 依赖顺序:errors 最先,model3 最后(组装 model3.json)
32
- const LIB_ORDER = [
33
- 'errors.js', 'bytes.js', 'common-strings.js', 'unityfs.js', 'serialized.js', 'typetree.js',
34
- 'extract.js', 'etc2.js', 'dxt.js', 'texdecode.js', 'png.js', 'anim-to-motion.js', 'model3.js',
35
- ];
36
-
37
- let sandbox = null;
38
- /** 建(或取缓存的)插件库沙箱;PNG 的 deflate/inflate 用 Node 的 zlib 注入 */
39
- function getLibs() {
40
- if (sandbox) return sandbox;
41
- const ctx = { console: { log() {}, info() {}, warn() {}, error() {} }, TextDecoder, TextEncoder, setTimeout, clearTimeout };
42
- ctx.globalThis = ctx; ctx.window = ctx; ctx.self = ctx;
43
- vm.createContext(ctx);
44
- for (const rel of LIB_ORDER) {
45
- const abs = path.join(LIB_DIR, rel);
46
- if (!fs.existsSync(abs)) throw new Error(`导出依赖缺失:${abs}`);
47
- vm.runInContext(fs.readFileSync(abs, 'utf8'), ctx, { filename: `ulb-lib/${rel}` });
48
- }
49
- const NS = ctx.__ULB__;
50
- if (NS.png && NS.png.setDeflate) NS.png.setDeflate((u8) => new Uint8Array(zlib.deflateSync(Buffer.from(u8))));
51
- if (NS.png && NS.png.setInflate) NS.png.setInflate((u8) => new Uint8Array(zlib.inflateSync(Buffer.from(u8))));
52
- sandbox = NS;
53
- return sandbox;
54
- }
55
-
56
- /** 递归汇总目录字节数(报告用) */
57
- function dirBytes(dir) {
58
- let total = 0;
59
- const walk = (d) => {
60
- let es = [];
61
- try { es = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
62
- for (const e of es) {
63
- const p = path.join(d, e.name);
64
- if (e.isDirectory()) walk(p);
65
- else { try { total += fs.statSync(p).size; } catch { /* 忽略单个文件 */ } }
66
- }
67
- };
68
- walk(dir);
69
- return total;
70
- }
71
-
72
- /**
73
- * 解析资源包并规划导出(不写盘)。dryRun 也走这一步,保证"将要做什么"与真做一致。
74
- * @returns {{name, textures:[{name,w,h,format,formatName,supported}], moc3Bytes, physicsBytes, warnings, skippedTextures}}
75
- */
76
- export function planExport(bundleBytes) {
77
- const NS = getLibs();
78
- const E = NS.errors;
79
- if (!bundleBytes || !bundleBytes.length) throw E.fail(E.CODES.NO_FILE, '资源包为空或不存在');
80
- let sig = '';
81
- for (let i = 0; i < Math.min(7, bundleBytes.length); i++) sig += String.fromCharCode(bundleBytes[i]);
82
- if (sig !== 'UnityFS') throw E.fail(E.CODES.NOT_UNITYFS, `不是 UnityFS 资源包(signature="${sig.replace(/[^\x20-\x7e]/g, '?')}")`);
83
-
84
- let parsed, sf, got;
85
- try {
86
- parsed = NS.unityfs.parse(bundleBytes);
87
- const cab = parsed.nodes.find((n) => /^CAB-/.test(n.path));
88
- if (!cab) throw E.fail(E.CODES.NO_CAB, '资源包里没有 CAB-* 节点(不是资产包)');
89
- const resS = parsed.nodes.find((n) => /\.resS$/.test(n.path));
90
- const cabBytes = NS.unityfs.nodeBytes(parsed, cab.path);
91
- const resSBytes = resS ? NS.unityfs.nodeBytes(parsed, resS.path) : null;
92
- sf = NS.serialized.parse(cabBytes);
93
- got = NS.extract.extractAll({ cabBytes, resSBytes, sf, resSBytesForTextures: resSBytes });
94
- } catch (e) {
95
- if (e && e.code) throw e;
96
- throw E.fail(E.CODES.CONTAINER_PARSE, `容器解析失败:${e.message}`);
97
- }
98
- if (!got.moc3) throw E.fail(E.CODES.MODEL_BUILD, '包里没有 moc3(不是 Live2D 资源包?)');
99
-
100
- const textures = (got.textures || []).map((t) => {
101
- const f = NS.texdecode.FORMATS[t.format];
102
- return {
103
- name: t.name, width: t.width, height: t.height, format: t.format,
104
- formatName: f ? f.name : `未知格式 ${t.format}`,
105
- supported: !!f,
106
- };
107
- });
108
- return {
109
- parsed, sf, got, textures,
110
- moc3Bytes: got.moc3,
111
- physicsBytes: got.physics3 || null,
112
- warnings: [],
113
- skippedTextures: textures.filter((t) => !t.supported).map((t) => t.formatName),
114
- };
115
- }
116
-
117
- /**
118
- * 执行导出。
119
- * @param {object} opts
120
- * bundleBytes 资源包字节 | outDir 输出根 | name 资源名 | force 覆盖 | dryRun 只规划
121
- * profile 可选(传给 buildModel3,用于 HitAreas 等)
122
- * onProgress({stage, ...})
123
- */
124
-
125
- /** 最坏情况产物估算:PNG 压缩后体积 ≤ 原始 RGBA,用它做磁盘检查上界 */
126
- function estimateBytes(plan) {
127
- let raw = 0;
128
- for (const t of plan.textures) if (t.supported) raw += t.width * t.height * 4;
129
- return raw + plan.moc3Bytes.length + (plan.physicsBytes ? plan.physicsBytes.length : 0);
130
- }
131
-
132
- /**
133
- * 磁盘余量检查:剩余 < 预估×1.5 直接抛 ULB_ERR_EXPORT_NO_SPACE;
134
- * 查不到剩余空间(如跨设备/权限)只记 warning,不阻断导出。
135
- */
136
- function checkDisk(NS, outRoot, estimate, warnings) {
137
- try {
138
- let probeDir = outRoot;
139
- while (!fs.existsSync(probeDir) && path.dirname(probeDir) !== probeDir) probeDir = path.dirname(probeDir);
140
- const st = fs.statfsSync(probeDir);
141
- const free = st.bavail * st.bsize;
142
- if (free < estimate * 1.5) {
143
- throw NS.errors.fail(NS.errors.CODES.EXPORT_NO_SPACE, `磁盘空间不足:需要约 ${(estimate * 1.5 / 1048576).toFixed(0)} MB,剩余 ${(free / 1048576).toFixed(0)} MB`);
144
- }
145
- } catch (e) {
146
- if (e && e.code === NS.errors.CODES.EXPORT_NO_SPACE) throw e;
147
- warnings.push(`磁盘剩余空间检查失败(继续导出):${e.message}`);
148
- }
149
- }
150
-
151
- /** 纹理:解码 → PNG(真压缩)→ 写盘;单张失败只记 warning,不中断整包 */
152
- async function writeTextures(NS, plan, tmpDir, progress, warnings, written) {
153
- const rels = [];
154
- fs.mkdirSync(path.join(tmpDir, 'textures'), { recursive: true });
155
- const resSNode = plan.parsed.nodes.find((n) => /\.resS$/.test(n.path));
156
- const resSBytes = resSNode ? NS.unityfs.nodeBytes(plan.parsed, resSNode.path) : null;
157
- for (const t of plan.textures) {
158
- if (!t.supported) continue;
159
- progress({ stage: 'texture', name: t.name, width: t.width, height: t.height });
160
- let rgba;
161
- try {
162
- const texObj = plan.got.textures.find((x) => x.name === t.name);
163
- rgba = NS.texdecode.decode(texObj, NS.extract.textureCompressedBytes(texObj, resSBytes), { flipY: true });
164
- } catch (e) {
165
- warnings.push(`纹理 ${t.name} 解码失败,已跳过:${e.message}`);
166
- continue;
167
- }
168
- const png = await NS.png.encodePngAsync(rgba, t.width, t.height);
169
- const rel = `textures/${t.name}.png`;
170
- fs.writeFileSync(path.join(tmpDir, rel), png);
171
- written.push({ rel, bytes: png.length });
172
- rels.push(rel);
173
- }
174
- return rels;
175
- }
176
-
177
- /**
178
- * motion3:优先直接落包内原生 motion3;否则用 Cubism Core 的真实参数 Id 建白名单后转换。
179
- * 转换逻辑全部复用 lib/anim-to-motion.js(含单点曲线补两点),不另写一套。
180
- * @returns {{defs:Array<{name,url,group}>, curves:{total,emitted,skipped}|null, dropped:number, source:'native'|'converted'}}
181
- */
182
- function writeMotions(NS, arg) {
183
- const { plan, profile, tmpDir, written, progress } = arg;
184
- const native = (plan.got.nativeFiles || []).filter((f) => /motion3\.json$/i.test(f.name));
185
- if (native.length) {
186
- fs.mkdirSync(path.join(tmpDir, 'motions'), { recursive: true });
187
- const defs = [];
188
- for (const f of native) {
189
- const rel = `motions/${f.name}`;
190
- fs.writeFileSync(path.join(tmpDir, rel), f.bytes);
191
- written.push({ rel, bytes: f.bytes.length });
192
- const base = f.name.replace(/\.motion3\.json$/i, '');
193
- defs.push({ name: base, url: rel, group: NS.model3.motionGroupOf(base, profile) });
194
- }
195
- return { defs, curves: null, dropped: 0, source: 'native' };
196
- }
197
-
198
- progress({ stage: 'motions' });
199
- const { ids } = getParameterIds(plan.moc3Bytes);
200
- const candidates = [...new Set([...NS.extract.moc3NameCandidates(plan.moc3Bytes), ...ids])];
201
- const opts = { paramWhitelist: new Set(ids), isLoop: (n) => NS.model3.motionLoops(n, profile) };
202
- if (profile && profile.motion) {
203
- opts.fadeIn = profile.motion.fadeInTime;
204
- opts.fadeOut = profile.motion.fadeOutTime;
205
- }
206
- const conv = NS.animToMotion.clipsToMotions(plan.sf, candidates, opts);
207
- fs.mkdirSync(path.join(tmpDir, 'motions'), { recursive: true });
208
- const defs = [];
209
- const curves = { total: 0, emitted: 0, skipped: 0 };
210
- for (const m of conv.motions) {
211
- const text = JSON.stringify(m.json);
212
- const rel = `motions/${m.name}.motion3.json`;
213
- fs.writeFileSync(path.join(tmpDir, rel), text);
214
- written.push({ rel, bytes: Buffer.byteLength(text) });
215
- defs.push({ name: m.name, url: rel, group: NS.model3.motionGroupOf(m.name, profile) });
216
- curves.total += (m.stats.curvesTotal || 0);
217
- curves.emitted += (m.stats.emitted || 0);
218
- curves.skipped += (m.stats.skippedUnknownBinding || 0);
219
- }
220
- return { defs, curves, dropped: (conv.dropped || []).length, source: 'converted' };
221
- }
222
-
223
- /** model3.json:Motions 由 writeMotions 的结果填充;空 Motions 会被删掉(避免部分运行时报错) */
224
- function writeModel3(NS, arg) {
225
- const { name, textureRels, plan, profile, tmpDir, written, motions } = arg;
226
- const model3 = NS.model3.buildModel3({
227
- mocUrl: `${name}.moc3`,
228
- textureUrls: textureRels,
229
- physicsUrl: plan.physicsBytes ? `${name}.physics3.json` : null,
230
- motions: motions || [],
231
- paramNames: [],
232
- profile: profile || null,
233
- });
234
- const refs = model3.FileReferences;
235
- if (refs && refs.Motions && !Object.keys(refs.Motions).length) delete refs.Motions;
236
- const text = JSON.stringify(model3, null, 2);
237
- fs.writeFileSync(path.join(tmpDir, `${name}.model3.json`), text);
238
- written.push({ rel: `${name}.model3.json`, bytes: Buffer.byteLength(text) });
239
- }
240
-
241
- /** 报告落盘 + 原子落位(先 rename 到最终目录,保证读者永远看不到半成品) */
242
- function finalize(arg) {
243
- const { name, bundleBytes, t0, written, plan, warnings, tmpDir, finalDir, motions } = arg;
244
- const report = {
245
- name, bundleBytes, generatedAt: new Date().toISOString(), tookMs: Date.now() - t0,
246
- files: written, totalBytes: written.reduce((a, f) => a + f.bytes, 0),
247
- textures: plan.textures,
248
- motions: {
249
- count: (motions.defs || []).length,
250
- source: motions.source,
251
- dropped: motions.dropped || 0,
252
- curves: motions.curves,
253
- groups: [...new Set((motions.defs || []).map((d) => d.group))],
254
- },
255
- cubismCore: coreInfo(),
256
- warnings,
257
- };
258
- const text = JSON.stringify(report, null, 2);
259
- fs.writeFileSync(path.join(tmpDir, 'export-report.json'), text);
260
- written.push({ rel: 'export-report.json', bytes: Buffer.byteLength(text) });
261
- if (fs.existsSync(finalDir)) fs.rmSync(finalDir, { recursive: true, force: true }); // 仅 --force 会走到这
262
- fs.renameSync(tmpDir, finalDir);
263
- }
264
-
265
- /**
266
- * 执行导出(编排:规划 → 磁盘 → dry-run → 已存在 → 写临时目录 → 原子落位)。
267
- * 具体写盘工作交给 writeTextures / writeMotions / writeModel3 / finalize。
268
- */
269
- export async function exportBundle(opts) {
270
- const NS = getLibs();
271
- const o = opts || {};
272
- const t0 = Date.now();
273
- const progress = typeof o.onProgress === 'function' ? o.onProgress : () => {};
274
- const name = String(o.name || '').trim();
275
- if (!name) throw new Error('缺少资源名(name)');
276
- const outRoot = path.resolve(o.outDir || path.join(ROOT, 'data', 'export'));
277
- const finalDir = path.join(outRoot, name);
278
-
279
- progress({ stage: 'plan' });
280
- const plan = planExport(o.bundleBytes);
281
- const warnings = [...plan.warnings];
282
- if (plan.skippedTextures.length) {
283
- warnings.push(`以下纹理格式暂不支持,已跳过(不整体失败):${[...new Set(plan.skippedTextures)].join('、')}`);
284
- }
285
- const estimate = estimateBytes(plan);
286
- checkDisk(NS, outRoot, estimate, warnings);
287
- const textures = plan.textures.map((t) => ({ name: t.name, width: t.width, height: t.height, formatName: t.formatName, supported: t.supported }));
288
-
289
- if (o.dryRun) {
290
- return {
291
- ok: true, dryRun: true, name, outDir: finalDir, existed: fs.existsSync(finalDir),
292
- wouldWrite: [`${name}.moc3`, ...(plan.physicsBytes ? [`${name}.physics3.json`] : []),
293
- ...plan.textures.filter((t) => t.supported).map((t) => `textures/${t.name}.png`),
294
- 'motions/<动作名>.motion3.json', `${name}.model3.json`, 'export-report.json'],
295
- textures, estimateBytes: estimate, warnings, ms: Date.now() - t0,
296
- };
297
- }
298
- if (fs.existsSync(finalDir) && !o.force) {
299
- return { ok: true, skipped: true, reason: '目标目录已存在(加 --force 覆盖)', name, outDir: finalDir, textures, warnings, ms: Date.now() - t0 };
300
- }
301
-
302
- fs.mkdirSync(outRoot, { recursive: true });
303
- const tmpDir = path.join(outRoot, `.tmp-${name}-${process.pid}`);
304
- fs.rmSync(tmpDir, { recursive: true, force: true });
305
- fs.mkdirSync(tmpDir, { recursive: true });
306
- const written = [];
307
- try {
308
- fs.writeFileSync(path.join(tmpDir, `${name}.moc3`), plan.moc3Bytes);
309
- written.push({ rel: `${name}.moc3`, bytes: plan.moc3Bytes.length });
310
- if (plan.physicsBytes) {
311
- fs.writeFileSync(path.join(tmpDir, `${name}.physics3.json`), plan.physicsBytes);
312
- written.push({ rel: `${name}.physics3.json`, bytes: plan.physicsBytes.length });
313
- }
314
- const rels = await writeTextures(NS, plan, tmpDir, progress, warnings, written);
315
- const motions = writeMotions(NS, { plan, profile: o.profile, tmpDir, written, progress });
316
- writeModel3(NS, { name, textureRels: rels, plan, profile: o.profile, tmpDir, written, motions: motions.defs });
317
- finalize({ name, bundleBytes: o.bundleBytes.length, t0, written, plan, warnings, tmpDir, finalDir, motions });
318
- return {
319
- ok: true, name, outDir: finalDir, files: written.length, totalBytes: dirBytes(finalDir),
320
- tookMs: Date.now() - t0, textures, motions: motions.defs.length, curves: motions.curves, warnings,
321
- };
322
- } catch (e) {
323
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* 清理失败不掩盖原错误 */ }
324
- throw e;
325
- }
326
- }
327
-
328
- export const EXPORT_PATHS = { ROOT, LIB_DIR, LIB_ORDER };
@@ -1,131 +0,0 @@
1
- // ============================================================================
2
- // tool/lib/spine-runtime.mjs —— Spine 运行时源码定位 + 在 Node 里受控加载
3
- //
4
- // 为什么要单独一层:Spine 的 .skel/.json 只能由**对应版本**的运行时解析,
5
- // 而运行时代码是浏览器风格的打包体(会摸 document/window)。这里在 Node 的 vm 沙箱里
6
- // 垫一个最小 DOM 垫片把它跑起来,只用来做**数据层解析**(不渲染、不碰 WebGL)。
7
- //
8
- // 源码来源(按优先级,逐条探测,命中即用):
9
- // ① src/spine-runtimes/spine-webgl-<ver>.js —— 项目内(该目录不入库)
10
- // ② ../spine-runtimes/spine-webgl-<ver>.js —— 工作区共享
11
- // ③ 环境变量 SPINE_RUNTIME_DIR 指定的目录
12
- //
13
- // 没有内联兜底:运行时源码必须由外部提供,找不到时显式报错并给修复指引。
14
- //
15
- // 懒加载:首次真正要解析某版本时才读源码 + 建沙箱,之后按版本缓存;不在服务启动时加载。
16
- // ============================================================================
17
-
18
- import fs from 'node:fs';
19
- import path from 'node:path';
20
- import vm from 'node:vm';
21
- import { fileURLToPath } from 'node:url';
22
-
23
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
- const ROOT = path.resolve(__dirname, '..', '..');
25
- const WORKSPACE = path.resolve(ROOT, '..');
26
-
27
- /**
28
- * 数据根:与 web.sh / serve.mjs 同一口径(MODELSTAGE_DATA_ROOT 覆盖,缺省项目根)。
29
- * 下载物落在 <dataRoot>/runtimes/spine/<ver>/spine-webgl-<ver>.js。
30
- * @returns {string}
31
- */
32
- function dataRootDir() {
33
- const env = (process.env.MODELSTAGE_DATA_ROOT || '').trim();
34
- return env ? path.resolve(env) : ROOT;
35
- }
36
-
37
- /** 运行时源码候选目录(按优先级) */
38
- const RUNTIME_DIRS = [
39
- path.join(ROOT, 'src', 'spine-runtimes'),
40
- path.join(WORKSPACE, 'spine-runtimes'),
41
- // **末尾追加**(上面两条的既有优先次序不动):tool/fetch-runtimes.mjs 的下载落点。
42
- // 必须放最后 —— "项目自带优先"不能因为引入下载功能而静默失效。
43
- path.join(dataRootDir(), 'runtimes', 'spine'),
44
- ];
45
-
46
-
47
-
48
- const sourceCache = new Map(); // version → { code, origin }
49
- /**
50
- * 取某版本的运行时源码。
51
- * @param {string} version 如 '3.8' / '4.2'
52
- * @returns {{code:string, origin:string}|null}
53
- */
54
- export function getRuntimeSource(version) {
55
- if (sourceCache.has(version)) return sourceCache.get(version);
56
- let hit = null;
57
- for (const dir of RUNTIME_DIRS) {
58
- // 两种布局都要认:
59
- // 平铺 <dir>/spine-webgl-<ver>.js —— 项目自带 / 环境变量目录的既有布局
60
- // 分版本 <dir>/<ver>/spine-webgl-<ver>.js —— tool/fetch-runtimes.mjs 的下载布局
61
- // (分版本目录是为了让每个版本各自带 LICENSE 与 SOURCE.json)
62
- for (const p of [path.join(dir, `spine-webgl-${version}.js`), path.join(dir, version, `spine-webgl-${version}.js`)]) {
63
- if (fs.existsSync(p)) { hit = { code: fs.readFileSync(p, 'utf8'), origin: p }; break; }
64
- }
65
- if (hit) break;
66
- }
67
- if (!hit && process.env.SPINE_RUNTIME_DIR) {
68
- const p = path.join(process.env.SPINE_RUNTIME_DIR, `spine-webgl-${version}.js`);
69
- if (fs.existsSync(p)) hit = { code: fs.readFileSync(p, 'utf8'), origin: p };
70
- }
71
- sourceCache.set(version, hit);
72
- return hit;
73
- }
74
-
75
- /** 可用版本列表(扫描候选目录;平铺与分版本两种布局都算) */
76
- export function listRuntimeVersions() {
77
- const out = new Set();
78
- const addFrom = (dir) => {
79
- for (const f of fs.readdirSync(dir)) {
80
- const m = f.match(/^spine-webgl-(.+)\.js$/);
81
- if (m) out.add(m[1]);
82
- }
83
- };
84
- for (const dir of RUNTIME_DIRS) {
85
- if (!fs.existsSync(dir)) continue;
86
- addFrom(dir);
87
- // 分版本子目录(下载布局)也要列出来
88
- let subs = [];
89
- try { subs = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); } catch { /* 跳过 */ }
90
- for (const sub of subs) {
91
- try { addFrom(path.join(dir, sub)); } catch { /* 跳过读不了的子目录 */ }
92
- }
93
- }
94
- return [...out].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
95
- }
96
-
97
- const sandboxCache = new Map(); // version → { spine, origin }
98
- /**
99
- * 在 vm 沙箱里加载某版本运行时,返回其导出的 spine 对象(懒加载 + 缓存)。
100
- * 沙箱只垫数据层需要的最小 DOM 能力:createElement 返回带 getContext()=null 的假 canvas,
101
- * 足以让运行时完成模块初始化,但任何真正的渲染调用都会失败 —— 这正是我们要的边界。
102
- */
103
- export function loadRuntime(version) {
104
- if (sandboxCache.has(version)) return sandboxCache.get(version);
105
- const src = getRuntimeSource(version);
106
- if (!src) throw new Error(`没有可用的 Spine ${version} 运行时源码。请任选一种方式提供:SPINE_RUNTIME_DIR 环境变量 / src/spine-runtimes/ / 工作区共享 ../spine-runtimes/`);
107
- const sandbox = {
108
- console: { log() {}, info() {}, warn() {}, error() {}, debug() {} },
109
- performance: { now: () => Date.now() },
110
- navigator: { userAgent: 'node-spine-meta' },
111
- setTimeout, clearTimeout, setInterval, clearInterval,
112
- TextDecoder, TextEncoder,
113
- URL, Blob,
114
- document: {
115
- createElement: () => ({ getContext: () => null, style: {}, addEventListener() {}, width: 0, height: 0 }),
116
- addEventListener() {}, removeEventListener() {},
117
- },
118
- addEventListener() {}, removeEventListener() {},
119
- };
120
- sandbox.window = sandbox;
121
- sandbox.self = sandbox;
122
- sandbox.globalThis = sandbox;
123
- vm.createContext(sandbox);
124
- vm.runInContext(src.code, sandbox, { filename: `spine-${version}.js` });
125
- if (!sandbox.spine) throw new Error(`Spine ${version} 运行时加载后没有导出 spine 对象(源码来源:${src.origin})`);
126
- const hit = { spine: sandbox.spine, origin: src.origin };
127
- sandboxCache.set(version, hit);
128
- return hit;
129
- }
130
-
131
- export const PATHS = { ROOT, WORKSPACE, RUNTIME_DIRS };