modelstage 0.5.28 → 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.
- package/CHANGELOG.md +83 -0
- package/README.md +2 -63
- package/config/models/live2d.assetbundls.json +1 -1
- package/config/models/live2d.azurlane.json +1 -1
- package/config/models/live2d.azurlane_bundle.json +1 -1
- package/config/models/live2d.azurlane_bundle.jsonl +2 -0
- package/config/models/live2d.azurlane_spine.json +1 -1
- package/config/models/live2d.bd2.json +1 -1
- package/config/models/live2d.bd2.jsonl +2 -0
- package/config/models/live2d.bluearchive.json +1 -1
- package/config/models/live2d.nikke.json +1 -1
- package/config/models/live2d.nikke.jsonl +2 -0
- package/config/models/live2d.nikke_2.json +1 -1
- package/config/models/live2d.npc.json +1 -1
- package/config/models/spine.assetbundls.json +1 -1
- package/config/models/spine.azurlane.json +1 -1
- package/config/models/spine.azurlane_bundle.json +1 -1
- package/config/models/spine.azurlane_bundle.jsonl +2 -0
- package/config/models/spine.azurlane_spine.json +1 -1
- package/config/models/spine.bd2.json +1 -1
- package/config/models/spine.bd2.jsonl +433 -0
- package/config/models/spine.bluearchive.json +1 -1
- package/config/models/spine.nikke.json +1 -1
- package/config/models/spine.nikke_2.json +1 -1
- package/config/models/spine.npc.json +1 -1
- package/package.json +2 -1
- package/src/server/assets/probe.mjs +1 -1
- package/src/server/core/config.mjs +2 -2
- package/src/server/core/log.mjs +1 -1
- package/src/server/data/catalog.mjs +11 -14
- package/src/server/data/live2d.mjs +139 -40
- package/src/server/data/manifest-cache.mjs +53 -10
- package/src/server/data/manifest-jsonl.mjs +264 -0
- package/src/server/data/manifest-stamp.mjs +52 -2
- package/src/server/data/scan-progress.mjs +305 -0
- package/src/server/data/scan-queue.mjs +99 -0
- package/src/server/data/spine.mjs +324 -106
- package/src/server/plugins/registry.mjs +1 -1
- package/src/server/routes/data.mjs +76 -17
- package/src/server/serve.mjs +9 -4
- package/src/server/unpack/plugin-store.mjs +1 -1
- package/src/server/unpack/runtime/exec.mjs +37 -8
- package/src/server/unpack/worker-client.mjs +23 -8
- package/src/shared/scan-limits.mjs +23 -0
- package/src/web/app/anim-list.js +1 -1
- package/src/web/app/backgrounds.js +4 -8
- package/src/web/app/dir-picker.js +3 -5
- package/src/web/app/memory-budget.js +0 -1
- package/src/web/app/memory-guard.js +0 -1
- package/src/web/app/model-actions.js +18 -15
- package/src/web/app/root-manager.js +2 -4
- package/src/web/app/transport-actions.js +1 -1
- package/src/web/app/ui-state.js +1 -1
- package/src/web/components.css +90 -0
- package/src/web/debug/race-probe.js +36 -13
- package/src/web/engine/textures.js +1 -2
- package/src/web/infra/api.js +10 -19
- package/src/web/infra/document.js +3 -3
- package/src/web/infra/plugin-host-unpack.mjs +81 -9
- package/src/web/infra/ready.js +177 -39
- package/src/web/live2d/bridge.js +1 -1
- package/src/web/live2d/runtime-adapter.js +2 -4
- package/src/web/panel/panel-settings.js +88 -3
- package/src/web/roster/events.js +159 -0
- package/src/web/roster/filter.js +4 -4
- package/src/web/roster/list.js +3 -3
- package/src/web/roster/load.js +440 -0
- package/src/web/roster/persist.js +46 -37
- package/src/web/roster/render.js +284 -0
- package/src/web/roster/reveal.js +4 -9
- package/src/web/roster/rows.js +35 -13
- package/src/web/roster/scan-stream.js +346 -0
- package/src/web/roster/snapshot.js +14 -8
- package/src/web/roster/store.js +292 -0
- package/src/web/roster/type-filter.js +5 -5
- package/src/web/roster/view.js +147 -634
- package/src/web/spine/canvas-runtime.js +4 -5
- package/src/web/spine/runtimes.js +1 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// data/scan-queue.mjs —— 扫描的并发上限、FIFO 队列、以及"有交互请求在等"的让路提示(P0 退化修复)
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要这个模块:
|
|
4
|
+
// P0 把扫描改成 async 分片让出之后,**多个根的扫描从"自然串行"变成了并发** ——
|
|
5
|
+
// 页面一次加载会同时对 9 个根发 catalog,于是 9 份扫描一起抢 CIFS/磁盘 IO,
|
|
6
|
+
// 事件循环被切碎到连 `/plugins/*.js` 静态文件都拿不到(实测 P95 984ms、最大 6464ms,
|
|
7
|
+
// 9 个 catalog 里 6 个 120s 超时)。
|
|
8
|
+
//
|
|
9
|
+
// 三道闸门(按生效顺序):
|
|
10
|
+
// ① **并发上限**:全局同时只允许 CONCURRENCY 个根在扫,其余 FIFO 排队。
|
|
11
|
+
// 这一条就把"9 倍压力"压回"1 倍",是 ROI 最高的一步。
|
|
12
|
+
// ② **让路提示**:有 HTTP 请求在途时(`inflight > 0`),扫描把每 tick 的时间预算调小,
|
|
13
|
+
// 优先把事件循环还给 HTTP/静态文件。与既有的"后台形态探测让路"同一思路。
|
|
14
|
+
// ③ **排队可见**:排队中的 stream 请求立刻收到一帧 `{type:'queued', position}`,
|
|
15
|
+
// 而不是黑着屏等 120s 让人以为死了。
|
|
16
|
+
|
|
17
|
+
/** 同时允许几个根在扫。1 = 完全串行(最保守,也最能保证交互 P95)。 */
|
|
18
|
+
const CONCURRENCY = 1;
|
|
19
|
+
|
|
20
|
+
/** 在途 HTTP 请求数(由 serve.mjs 在请求开始/结束时同步进来) */
|
|
21
|
+
let inflightRequests = 0;
|
|
22
|
+
|
|
23
|
+
/** 排队表:{ key, resolve },先进先出 */
|
|
24
|
+
const waiting = [];
|
|
25
|
+
/** 当前在扫的数量 */
|
|
26
|
+
let activeCount = 0;
|
|
27
|
+
|
|
28
|
+
/** 供 serve.mjs 调用:请求开始 */
|
|
29
|
+
export function noteRequestStart() {
|
|
30
|
+
inflightRequests += 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 供 serve.mjs 调用:请求结束(含异常路径,必须成对) */
|
|
34
|
+
export function noteRequestEnd() {
|
|
35
|
+
inflightRequests = Math.max(0, inflightRequests - 1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 当前是否有交互请求在等 —— 扫描据此决定"每 tick 干多少活"。
|
|
40
|
+
* 为什么用"在途请求数"而不是"距上次请求多久":在途数是**现在**的事实,
|
|
41
|
+
* 时间差是**过去**的推断;长请求(解包、大文件)期间时间差会误判为"没人"。
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
export function isInteractiveBusy() {
|
|
45
|
+
// **>1 而不是 >0**:触发扫描的那个 HTTP 请求自己就让计数 >=1,用 >0 会恒真 ⇒
|
|
46
|
+
// 扫描永远走"更频繁让出"分支(实测单根从 24s 恶化到 47s)。
|
|
47
|
+
// >1 的意思才是"除了发起者,还有别的交互请求在等"。
|
|
48
|
+
return inflightRequests > 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 诊断用:当前在扫几个、排队几个、在途请求几个 */
|
|
52
|
+
export function scanQueueStats() {
|
|
53
|
+
return { active: activeCount, queued: waiting.length, inflightRequests, concurrency: CONCURRENCY };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 申请一个扫描槽位。满员时排队(FIFO),轮到时 resolve。
|
|
58
|
+
*
|
|
59
|
+
* @param {string} key 去重键(`spine:<rootId>` / `live2d:<rootId>`)。同一 key 只会排一次。
|
|
60
|
+
* @param {(position: number) => void} [onQueued] 刚被排入队列时回调(用于给 stream 发 queued 帧)
|
|
61
|
+
* @returns {Promise<void>}
|
|
62
|
+
*/
|
|
63
|
+
export function acquireScanSlot(key, onQueued) {
|
|
64
|
+
// ① 空闲:直接占位
|
|
65
|
+
if (activeCount < CONCURRENCY && waiting.length === 0) {
|
|
66
|
+
activeCount += 1;
|
|
67
|
+
return Promise.resolve();
|
|
68
|
+
}
|
|
69
|
+
// ② 已经在排队:复用同一个位置(同一 kind:rootId 不重复排队)
|
|
70
|
+
const dup = waiting.find((w) => w.key === key);
|
|
71
|
+
if (dup) {
|
|
72
|
+
if (onQueued) onQueued(waiting.indexOf(dup) + 1);
|
|
73
|
+
return dup.promise;
|
|
74
|
+
}
|
|
75
|
+
// ③ 排队
|
|
76
|
+
let resolveFn;
|
|
77
|
+
const promise = new Promise((resolve) => { resolveFn = resolve; });
|
|
78
|
+
const entry = { key, resolve: resolveFn, promise };
|
|
79
|
+
waiting.push(entry);
|
|
80
|
+
if (onQueued) onQueued(waiting.length);
|
|
81
|
+
return promise;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 释放扫描槽位并唤醒队首。
|
|
86
|
+
* 必须与 acquireScanSlot 成对(放在 finally 里 —— 扫描抛错不能把槽位永久占死)。
|
|
87
|
+
*/
|
|
88
|
+
export function releaseScanSlot() {
|
|
89
|
+
activeCount = Math.max(0, activeCount - 1);
|
|
90
|
+
if (activeCount < CONCURRENCY) {
|
|
91
|
+
const next = waiting.shift();
|
|
92
|
+
if (next) {
|
|
93
|
+
activeCount += 1;
|
|
94
|
+
next.resolve();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export { CONCURRENCY };
|
|
@@ -12,10 +12,16 @@ import fs from 'node:fs';
|
|
|
12
12
|
import { createLogger } from '../core/log.mjs';
|
|
13
13
|
import { dirSignature, signatureMatches, generatorFingerprint } from './manifest-stamp.mjs';
|
|
14
14
|
import {
|
|
15
|
-
spineManifestFile, ensureManifestDir, invalidateManifests,
|
|
15
|
+
spineManifestFile, spineManifestBase, ensureManifestDir, invalidateManifests,
|
|
16
16
|
} from './manifest-cache.mjs';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { MIME, encodePath, parseAtlasPages } from '../assets/probe.mjs';
|
|
19
|
+
// 进度节流:与单飞层共用同一份实现(P0 起扫描可见进度,节流规则见设计 §5.1)
|
|
20
|
+
import { createProgressThrottle } from './scan-progress.mjs';
|
|
21
|
+
// P1:清单改 JSONL(边扫边写)+ 双读兼容
|
|
22
|
+
import { openManifestWriter, readManifest } from './manifest-jsonl.mjs';
|
|
23
|
+
// 并发上限 + 让路提示(P0 退化修复:9 根并发扫描会把事件循环切碎)
|
|
24
|
+
import { acquireScanSlot, releaseScanSlot, isInteractiveBusy } from './scan-queue.mjs';
|
|
19
25
|
|
|
20
26
|
|
|
21
27
|
|
|
@@ -123,7 +129,6 @@ export function buildModelServer(root, relPath, baseName, skel, atlas, dirFiles,
|
|
|
123
129
|
atlasFile: mk(atlas),
|
|
124
130
|
textureFiles: textures.map(mk),
|
|
125
131
|
textureCount: textures.length,
|
|
126
|
-
fileSize: skel.size + atlas.size + textures.reduce((s, t) => s + t.size, 0),
|
|
127
132
|
// atlasPma:true/false/null(未声明=未知)。不可硬编码 false —— 那等于断言"非预乘"
|
|
128
133
|
atlasPma: atlas.atlasPma ?? null,
|
|
129
134
|
// avatarRoot 来自 paths(可配置:--avatar-root > MODELSTAGE_AVATAR_ROOT > <root>/avatars)
|
|
@@ -139,86 +144,141 @@ export function buildModelServer(root, relPath, baseName, skel, atlas, dirFiles,
|
|
|
139
144
|
}
|
|
140
145
|
|
|
141
146
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
147
|
+
* **一次遍历**把整棵目录树读进内存(当前唯一碰磁盘的 readdir 入口)。
|
|
148
|
+
*
|
|
149
|
+
* 为什么要合并:原来同一棵树被读两遍 —— `indexRootFiles`(readdir 全根)+ `scanRootServer`
|
|
150
|
+
* 的 `collectDirEntries`(walk 时再 readdir 每个目录)。合并后 walk 阶段**零 IO**。
|
|
151
|
+
* 结构(键与现有 relPath 口径一致,根用 ''):
|
|
152
|
+
* Map<relPath, { files: [{name, full, ext}], dirs: [{name, full}] }>
|
|
153
|
+
* · files 的顺序 = readdir 返回序(**不改**,因为 models 的顺序依赖它)
|
|
154
|
+
* · dirs 的顺序同理
|
|
155
|
+
* 不存 size:`model.fileSize` 已删(死数据),索引里也不需要。
|
|
156
|
+
*
|
|
157
|
+
* @param {{path: string, id?: string}} root
|
|
158
|
+
* @param {{onProgress?: (p: {dirs: number}) => void}} [opts]
|
|
159
|
+
* @returns {Promise<Map<string, {files: Array, dirs: Array}>>}
|
|
145
160
|
*/
|
|
146
|
-
export function
|
|
147
|
-
const
|
|
148
|
-
|
|
161
|
+
export async function readTreeIntoMemory(root, opts = {}) {
|
|
162
|
+
const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : null;
|
|
163
|
+
const tree = new Map();
|
|
164
|
+
let sinceYield = 0;
|
|
165
|
+
let lastYieldAt = Date.now();
|
|
166
|
+
let dirsRead = 0;
|
|
167
|
+
|
|
168
|
+
const yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve));
|
|
169
|
+
async function maybeYield() {
|
|
170
|
+
sinceYield += 1;
|
|
171
|
+
// 与现有策略一致:每 20 个目录 或 每 50ms 让出一次(别丢让出,否则进度帧与静态文件都会被卡)
|
|
172
|
+
if (sinceYield >= 20 || Date.now() - lastYieldAt >= 50) {
|
|
173
|
+
sinceYield = 0;
|
|
174
|
+
lastYieldAt = Date.now();
|
|
175
|
+
if (onProgress) onProgress({ dirs: dirsRead });
|
|
176
|
+
await yieldToEventLoop();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function visit(absDir, relPath) {
|
|
149
181
|
let entries;
|
|
150
182
|
try {
|
|
151
183
|
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
152
184
|
} catch {
|
|
185
|
+
tree.set(relPath, { files: [], dirs: [] });
|
|
153
186
|
return;
|
|
154
187
|
}
|
|
188
|
+
const files = [];
|
|
189
|
+
const dirs = [];
|
|
155
190
|
for (const e of entries) {
|
|
156
191
|
if (e.name.startsWith('.')) continue;
|
|
157
192
|
const full = path.join(absDir, e.name);
|
|
158
|
-
const childRel = relPath ? `${relPath}/${e.name}` : e.name;
|
|
159
|
-
let st;
|
|
160
|
-
try {
|
|
161
|
-
st = fs.statSync(full);
|
|
162
|
-
} catch {
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
193
|
if (e.isDirectory()) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const key = e.name.toLowerCase();
|
|
170
|
-
if (!fileIndex.has(key)) {
|
|
171
|
-
fileIndex.set(key, {
|
|
172
|
-
name: e.name,
|
|
173
|
-
full,
|
|
174
|
-
ext: path.extname(e.name).toLowerCase(),
|
|
175
|
-
size: st.size,
|
|
176
|
-
relPath: childRel,
|
|
177
|
-
});
|
|
194
|
+
dirs.push({ name: e.name, full });
|
|
195
|
+
} else {
|
|
196
|
+
files.push({ name: e.name, full, ext: path.extname(e.name).toLowerCase() });
|
|
178
197
|
}
|
|
179
198
|
}
|
|
180
|
-
|
|
181
|
-
|
|
199
|
+
tree.set(relPath, { files, dirs });
|
|
200
|
+
dirsRead += 1;
|
|
201
|
+
await maybeYield();
|
|
202
|
+
// 递归顺序:**先写本节点、再按 dirs 顺序递归**(与原 walk 的深度优先一致)
|
|
203
|
+
for (const d of dirs) {
|
|
204
|
+
await visit(d.full, relPath ? `${relPath}/${d.name}` : d.name);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
await visit(root.path, '');
|
|
209
|
+
return tree;
|
|
182
210
|
}
|
|
183
211
|
|
|
184
|
-
/**
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
continue;
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
212
|
+
/**
|
|
213
|
+
* 建全根文件索引(文件名小写 → 文件记录),供跨目录查找图集页。
|
|
214
|
+
|
|
215
|
+
* 必须先建完再建模型:某模型的第 2 页可能位于尚未遍历到的兄弟目录里;
|
|
216
|
+
* 同名文件保留先遇到的(与原版匹配顺序一致)。
|
|
217
|
+
*/
|
|
218
|
+
/**
|
|
219
|
+
* 从**已读进内存的树**建文件索引(纯内存,零 IO)。
|
|
220
|
+
* @param {Map<string, {files: Array, dirs: Array}>} tree
|
|
221
|
+
* @returns {Map<string, {name: string, full: string, ext: string, relPath: string}>}
|
|
222
|
+
*/
|
|
223
|
+
export function indexFromTree(tree) {
|
|
224
|
+
const fileIndex = new Map();
|
|
225
|
+
for (const [relPath, node] of tree) {
|
|
226
|
+
for (const f of node.files) {
|
|
227
|
+
const key = f.name.toLowerCase();
|
|
228
|
+
// 同名文件保留先遇到的(与原版匹配顺序一致:depth-first + readdir 序)
|
|
229
|
+
if (fileIndex.has(key)) continue;
|
|
230
|
+
fileIndex.set(key, {
|
|
231
|
+
name: f.name,
|
|
232
|
+
full: f.full,
|
|
233
|
+
ext: f.ext,
|
|
234
|
+
relPath: relPath ? `${relPath}/${f.name}` : f.name,
|
|
235
|
+
});
|
|
206
236
|
}
|
|
207
|
-
const ext = path.extname(e.name).toLowerCase();
|
|
208
|
-
files.push({
|
|
209
|
-
name: e.name,
|
|
210
|
-
full,
|
|
211
|
-
ext,
|
|
212
|
-
size: st.size,
|
|
213
|
-
// 该文件相对资源根的路径(贴图可能来自其他目录,故每个文件各自记录)
|
|
214
|
-
relPath: relPath ? `${relPath}/${e.name}` : e.name,
|
|
215
|
-
spineVersion: ext === '.skel' ? deps.detectSkelVersion(full)
|
|
216
|
-
: ext === '.json' ? deps.detectJsonVersion(full) : null,
|
|
217
|
-
assetKey: ext === '.atlas' ? deps.detectAtlasKey(full) : null,
|
|
218
|
-
atlasPma: ext === '.atlas' ? deps.detectAtlasPma(full) : null,
|
|
219
|
-
});
|
|
220
237
|
}
|
|
221
|
-
return
|
|
238
|
+
return fileIndex;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 兼容入口:需要索引但手上没有树时用(内部会读一遍树)。scanRootServer 走的是"读一次树、复用"。 */
|
|
242
|
+
export async function indexRootFiles(root, opts = {}) {
|
|
243
|
+
const tree = await readTreeIntoMemory(root, opts);
|
|
244
|
+
return indexFromTree(tree);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 限流地并发执行异步任务,**保持结果顺序**。
|
|
251
|
+
* @template T, R
|
|
252
|
+
* @param {T[]} items
|
|
253
|
+
* @param {number} limit 同时进行的上限
|
|
254
|
+
* @param {(item: T, index: number) => Promise<R>} fn
|
|
255
|
+
* @returns {Promise<R[]>}
|
|
256
|
+
*/
|
|
257
|
+
/** 读一个目录的文件与子目录清单(文件附带版本/assetKey/PMA 探测结果)。 */
|
|
258
|
+
/**
|
|
259
|
+
* 取某个目录的文件与子目录清单 —— **纯内存**(从 readTreeIntoMemory 的树取)。
|
|
260
|
+
*
|
|
261
|
+
* 与旧版的差异只有一处:不再 `readdirSync`。文件顺序仍是 readdir 返回序(树里保存的就是它),
|
|
262
|
+
* 所以 groupModelsInDir / buildModelServer 产出的 models **顺序与内容都不变**。
|
|
263
|
+
* detect* 仍在(同步读文件头):它只对 skel/json/atlas 调用,每目录 1~3 次,不是"每文件都做"。
|
|
264
|
+
* @param {Map<string, {files: Array, dirs: Array}>} tree
|
|
265
|
+
* @param {string} relPath 相对根的目录路径(根为 '')
|
|
266
|
+
* @param {object} deps
|
|
267
|
+
*/
|
|
268
|
+
export function collectDirEntries(tree, relPath, deps) {
|
|
269
|
+
const node = tree.get(relPath);
|
|
270
|
+
if (!node) return { files: [], dirs: [] }; // 树里没有 = 读该目录时失败过(原逻辑同样返回空)
|
|
271
|
+
const files = node.files.map((f) => ({
|
|
272
|
+
name: f.name,
|
|
273
|
+
full: f.full,
|
|
274
|
+
ext: f.ext,
|
|
275
|
+
relPath: relPath ? `${relPath}/${f.name}` : f.name,
|
|
276
|
+
spineVersion: f.ext === '.skel' ? deps.detectSkelVersion(f.full)
|
|
277
|
+
: f.ext === '.json' ? deps.detectJsonVersion(f.full) : null,
|
|
278
|
+
assetKey: f.ext === '.atlas' ? deps.detectAtlasKey(f.full) : null,
|
|
279
|
+
atlasPma: f.ext === '.atlas' ? deps.detectAtlasPma(f.full) : null,
|
|
280
|
+
}));
|
|
281
|
+
return { files, dirs: node.dirs };
|
|
222
282
|
}
|
|
223
283
|
|
|
224
284
|
/** 由目录内文件按 base name 分组,构造该目录下的模型列表。 */
|
|
@@ -245,21 +305,89 @@ export function groupModelsInDir(root, relPath, files, fileIndex, deps) {
|
|
|
245
305
|
* 递归扫描一个资源根,收集所有可加载模型。
|
|
246
306
|
* 不同资源根层级不统一(BD2 两层、Mod 三层含 char/npc 分类层),
|
|
247
307
|
* 一律递归到「含骨架+图集的目录」为止,不做针对层级的特殊分支。
|
|
248
|
-
*
|
|
308
|
+
* 分片让出的**异步**实现:`collectDirEntries` / `indexRootFiles` 用的是同步 readdir,
|
|
309
|
+
* 整棵树的遍历会占满一个事件循环切片 —— 期间 res.write 的数据发不出去(实测 433 帧全挤在末尾 2ms)。
|
|
310
|
+
* 所以每 20 个目录或每 50ms `await setImmediate` 让出一次,进度帧才真的能"边扫边到"。
|
|
311
|
+
* 注意:这里**没有**用异步 readdir(fs.promises),因为同步 readdir 单次更快、
|
|
312
|
+
* 且 CIFS 上并发 readdir 收益不明显;分片让出已足够让出 CPU 给 IO 回调。
|
|
249
313
|
*/
|
|
250
|
-
export function scanRootServer(root, deps) {
|
|
314
|
+
export async function scanRootServer(root, deps) {
|
|
251
315
|
const models = [];
|
|
252
|
-
|
|
316
|
+
// 进度回调是**可选**的:不传就与改动前逐字一致(现有调用方与测试都不传)。
|
|
317
|
+
// 节流放在这里而不是调用方,因为只有这里同时知道"已扫到第几个目录"和"累计多少模型"。
|
|
318
|
+
// **必须定义在建索引之前** —— indexRootFiles 的 onProgress 要引用它(否则 TDZ ReferenceError)。
|
|
319
|
+
const throttle = deps && typeof deps.onProgress === 'function'
|
|
320
|
+
? createProgressThrottle({ onProgress: deps.onProgress, kind: 'spine', rootId: root.id })
|
|
321
|
+
: null;
|
|
322
|
+
// **一次遍历**:整棵树读进内存(walk 阶段零 readdir)。
|
|
323
|
+
// 原来 indexRootFiles 与 collectDirEntries 各读一遍同一棵树 ⇒ 这里成为唯一的磁盘遍历点。
|
|
324
|
+
const tree = await readTreeIntoMemory(root, {
|
|
325
|
+
onProgress: throttle
|
|
326
|
+
? () => throttle.bump({ scanned: models.length, dirs: 0, currentPath: '(建索引)', dirCompleted: false })
|
|
327
|
+
: undefined,
|
|
328
|
+
});
|
|
329
|
+
const fileIndex = indexFromTree(tree);
|
|
330
|
+
let dirsScanned = 0;
|
|
253
331
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
332
|
+
/**
|
|
333
|
+
* 让出事件循环。
|
|
334
|
+
*
|
|
335
|
+
* 为什么必须有:`collectDirEntries` 用的是同步 readdir,整个 walk 会在**一个事件循环切片**里跑完
|
|
336
|
+
* ⇒ 期间 `res.write` 只进内核缓冲、一帧都发不出去(实测 433 帧全挤在扫描结束后 2ms 内涌出)。
|
|
337
|
+
* 每积累一点工作量就 `await setImmediate`,把控制权还给 IO 回调,帧才真的能"边扫边到"。
|
|
338
|
+
* 为什么用 setImmediate 而不是 setTimeout(0):setImmediate 在 check 阶段就跑,
|
|
339
|
+
* 没有 setTimeout 的最小 1ms 计时器开销;扫描一次要让出几百次时这个差别不小。
|
|
340
|
+
*/
|
|
341
|
+
// setImmediate 而不是 setTimeout(0):实测 setTimeout 在负载下实际延迟可达几十 ms,
|
|
342
|
+
// 而扫描要让出上千次 ⇒ 单根扫描从 24s 恶化到 169s(7 倍)。setImmediate 在同一 tick 的 check 阶段跑,代价小得多。
|
|
343
|
+
const yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve));
|
|
344
|
+
|
|
345
|
+
// 让出判据:每 20 个目录 **或** 每 50ms,先到就先让出一次。
|
|
346
|
+
// 只看目录数不够 —— 遇到单个巨大目录时目录数不变但耗时在涨;只看时间不够 —— 空目录树会几乎不让出。
|
|
347
|
+
const YIELD_DIRS = 20;
|
|
348
|
+
const YIELD_MS = 50;
|
|
349
|
+
let sinceYieldDirs = 0;
|
|
350
|
+
let lastYieldAt = Date.now();
|
|
351
|
+
async function maybeYield() {
|
|
352
|
+
sinceYieldDirs += 1;
|
|
353
|
+
// 让路:有交互请求时**再**多让一点,但幅度有限(实测把阈值压到 5/10 会让扫描慢 7 倍,
|
|
354
|
+
// 而 P95 只从 984ms 降到 765ms —— 得不偿失)。真正能压住最大值的是非阻塞 IO,不是更频繁让出。
|
|
355
|
+
const busy = isInteractiveBusy();
|
|
356
|
+
const dirThreshold = busy ? 12 : YIELD_DIRS;
|
|
357
|
+
const msThreshold = busy ? 25 : YIELD_MS;
|
|
358
|
+
if (sinceYieldDirs >= dirThreshold || Date.now() - lastYieldAt >= msThreshold) {
|
|
359
|
+
sinceYieldDirs = 0;
|
|
360
|
+
lastYieldAt = Date.now();
|
|
361
|
+
await yieldToEventLoop();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// 边扫边写挂点:每产出一条模型就回调一次(P1)。不传则完全无副作用。
|
|
366
|
+
const onModel = deps && typeof deps.onModel === 'function' ? deps.onModel : null;
|
|
367
|
+
function collectModels(produced) {
|
|
368
|
+
if (!produced.length) return;
|
|
369
|
+
models.push(...produced);
|
|
370
|
+
if (onModel) {
|
|
371
|
+
for (const m of produced) onModel(m);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function walk(absDir, relPath) {
|
|
376
|
+
const { files, dirs } = collectDirEntries(tree, relPath, deps);
|
|
377
|
+
collectModels(groupModelsInDir(root, relPath, files, fileIndex, deps));
|
|
378
|
+
dirsScanned += 1;
|
|
379
|
+
if (throttle) {
|
|
380
|
+
throttle.bump({ scanned: models.length, dirs: dirsScanned, currentPath: relPath, dirCompleted: true });
|
|
381
|
+
}
|
|
382
|
+
await maybeYield();
|
|
257
383
|
for (const d of dirs) {
|
|
258
|
-
walk(d.full, relPath ? `${relPath}/${d.name}` : d.name);
|
|
384
|
+
await walk(d.full, relPath ? `${relPath}/${d.name}` : d.name);
|
|
259
385
|
}
|
|
260
386
|
}
|
|
261
387
|
|
|
262
|
-
walk(root.path, '');
|
|
388
|
+
await walk(root.path, '');
|
|
389
|
+
// 补发最后一帧:保证前端拿到的最终计数与返回的 models.length 一致,而不是停在某个中间值
|
|
390
|
+
if (throttle) throttle.flush();
|
|
263
391
|
return models;
|
|
264
392
|
}
|
|
265
393
|
|
|
@@ -274,49 +402,135 @@ export function invalidateCaches(configFile, rootId) {
|
|
|
274
402
|
}
|
|
275
403
|
|
|
276
404
|
/** 取模型列表(优先读落盘清单,缺失或 force 时才扫描)。同步实现。 */
|
|
277
|
-
|
|
278
|
-
|
|
405
|
+
/**
|
|
406
|
+
* 在跑的扫描:key = `spine:<rootId>` → promise。
|
|
407
|
+
*
|
|
408
|
+
* 为什么单飞要放在**这里**(而不是只放在 stream 路由层):
|
|
409
|
+
* 非流式请求(`/api/spine/models` 不带 stream)原本各自起一次扫描 ——
|
|
410
|
+
* 实测同一根 30s 内出现 4 次"清单失效重建",一次 curl 等了 171s。
|
|
411
|
+
* 放到扫描本体这里,**所有调用方**(流式 / 非流式 / catalog / 探测)自然共用同一次扫描。
|
|
412
|
+
* force=1 时若已有扫描在跑也**复用**(用户在 UI 上狂点刷新不该变成 N 次全量扫描)。
|
|
413
|
+
*/
|
|
414
|
+
const spineInflight = new Map();
|
|
279
415
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
416
|
+
export async function getSpineModels(configFile, root, force, deps) {
|
|
417
|
+
const key = `spine:${root.id}`;
|
|
418
|
+
const existing = spineInflight.get(key);
|
|
419
|
+
if (existing) return existing;
|
|
420
|
+
|
|
421
|
+
// **先占位,再启动** —— 这一步不能省。
|
|
422
|
+
// doGetSpineModels 的第一个 await(onStart 后的 setImmediate)会**让出事件循环**;
|
|
423
|
+
// 如果先调它、后 set,那么同一 tick 里进来的并发请求都会看到"没人跑" ⇒ 各起一次扫描
|
|
424
|
+
// (实测:4 个并发 refresh=1 ⇒ 重建 4 次、总墙钟 334s)。
|
|
425
|
+
// 先塞一个 pending promise 进表,后来者立刻拿到同一个 promise。
|
|
426
|
+
let settleResolve;
|
|
427
|
+
let settleReject;
|
|
428
|
+
const gate = new Promise((resolve, reject) => { settleResolve = resolve; settleReject = reject; });
|
|
429
|
+
spineInflight.set(key, gate);
|
|
430
|
+
doGetSpineModels(configFile, root, force, deps)
|
|
431
|
+
.then(settleResolve, settleReject)
|
|
432
|
+
.finally(() => { if (spineInflight.get(key) === gate) spineInflight.delete(key); });
|
|
433
|
+
return gate;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** 真正干活的实现(单飞包装见上面的 getSpineModels) */
|
|
437
|
+
async function doGetSpineModels(configFile, root, force, deps) {
|
|
438
|
+
const base = spineManifestBase(configFile, root.id);
|
|
439
|
+
// 写入格式:**默认 .json(保持原有行为)**;MODELSTAGE_MANIFEST_FORMAT=jsonl 才写 JSONL。
|
|
440
|
+
// 两种格式都可读(readManifest 优先当前格式、缺失回落另一种),切换不需要迁移历史文件。
|
|
441
|
+
const wantJsonl = String(process.env.MODELSTAGE_MANIFEST_FORMAT || 'json').toLowerCase() === 'jsonl';
|
|
442
|
+
|
|
443
|
+
if (!force) {
|
|
444
|
+
// 双读:.jsonl 优先,缺则回落 .json(历史格式,视为 complete=true —— 兼容关键)
|
|
445
|
+
const m = readManifest(base);
|
|
446
|
+
if (m.format !== 'none') {
|
|
447
|
+
if (!m.complete) {
|
|
448
|
+
// 第三道门:半成品不当结果(进程被杀 / 写入中断)。先重建,别把半截清单当答案。
|
|
449
|
+
log.info('清单不完整(complete 非 true),重建', { root: root.id, badLines: m.badLines, err: m.error || null });
|
|
450
|
+
} else {
|
|
451
|
+
// 前两道门原样保留(现在从 meta 读):① 解析器指纹 ② 目录签名
|
|
287
452
|
const gen = generatorFingerprint();
|
|
288
|
-
if (
|
|
289
|
-
log.info('解析器已变化,清单失效重建', { root: root.id, from:
|
|
290
|
-
} else if (signatureMatches(
|
|
291
|
-
return { models:
|
|
453
|
+
if (m.meta.generator !== gen) {
|
|
454
|
+
log.info('解析器已变化,清单失效重建', { root: root.id, from: m.meta.generator || null, to: gen });
|
|
455
|
+
} else if (signatureMatches(m.meta.dirStamps, root.path)) {
|
|
456
|
+
return { models: m.models, cached: true, ts: m.meta.ts || Date.now() };
|
|
292
457
|
} else {
|
|
293
458
|
log.info('目录已变化,清单失效重建', { root: root.id });
|
|
294
459
|
}
|
|
295
460
|
}
|
|
296
|
-
} catch (e) {
|
|
297
|
-
log.warn('清单读取失败,将重新扫描', { file: f, err: e.message });
|
|
298
461
|
}
|
|
299
462
|
}
|
|
300
463
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
464
|
+
// 在真正开始扫描前通知一次 —— 让"首帧到达"不必等 indexRootFiles 那一步(同步建索引,实测 4s+)
|
|
465
|
+
if (deps && typeof deps.onStart === 'function') {
|
|
466
|
+
deps.onStart();
|
|
467
|
+
// **必须紧接着让出一次**:res.write 只是把数据放进 Node 的输出缓冲,
|
|
468
|
+
// 真正写到 socket 要等事件循环的下一个 tick。而下一步 indexRootFiles 是同步的(实测 4.6s),
|
|
469
|
+
// 不让出的话首帧照样要等到扫描启动才到达 —— 那样 onStart 就白加了。
|
|
470
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const ts = Date.now();
|
|
474
|
+
const generator = generatorFingerprint();
|
|
475
|
+
// 目录签名在扫描**前**算:它只扫根的一层子目录 mtime(ms 级),不影响首帧;
|
|
476
|
+
// 而且它必须与"这次扫描看到的内容"对应,放扫描后算反而可能记进别人刚改的结果。
|
|
477
|
+
const dirStamps = dirSignature(root.path);
|
|
478
|
+
|
|
315
479
|
ensureManifestDir(configFile); // 清单目录(config/models/)可能还不存在:首次运行或目录被清空
|
|
480
|
+
|
|
481
|
+
// 边扫边写:writer 在 run 内创建、与这次扫描同生命周期 —— 单飞保证同一 key 只有一个 writer。
|
|
482
|
+
// 同步 append 是微秒级(几十~几百字节),不会像目录遍历那样长时间占住事件循环。
|
|
483
|
+
const writer = wantJsonl
|
|
484
|
+
? openManifestWriter(base, { rootId: root.id, ts, generator, dirStamps })
|
|
485
|
+
: null;
|
|
486
|
+
// onModel 双路:一路喂 writer(边扫边写清单),一路喂 deps.onModel(→ emitter → 流式 model 帧)。
|
|
487
|
+
// 两条路互不影响:没有 writer(json 回退模式)时照样能流式吐模型。
|
|
488
|
+
const upstreamOnModel = deps && typeof deps.onModel === 'function' ? deps.onModel : null;
|
|
489
|
+
const scanDeps = (writer || upstreamOnModel)
|
|
490
|
+
? {
|
|
491
|
+
...deps,
|
|
492
|
+
onModel: (m) => {
|
|
493
|
+
if (writer) writer.append(m);
|
|
494
|
+
if (upstreamOnModel) upstreamOnModel(m);
|
|
495
|
+
},
|
|
496
|
+
}
|
|
497
|
+
: deps;
|
|
498
|
+
|
|
499
|
+
// 并发上限:全局同时只允许 1 个根在扫,其余 FIFO 排队。
|
|
500
|
+
// 为什么必须在扫描**之前**排队:9 根并发时如果都开始扫,事件循环会被切碎到
|
|
501
|
+
// 连静态文件都拿不到(实测 /plugins/*.js P95 984ms、9 个 catalog 里 6 个 120s 超时)。
|
|
502
|
+
await acquireScanSlot(`spine:${root.id}`, (position) => {
|
|
503
|
+
if (deps && typeof deps.onQueued === 'function') deps.onQueued(position);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
let models;
|
|
507
|
+
try {
|
|
508
|
+
models = await scanRootServer(root, scanDeps);
|
|
509
|
+
if (writer) writer.finish(models.length);
|
|
510
|
+
} catch (e) {
|
|
511
|
+
// 失败只 abort:正式 .jsonl 保持上一次的完整版本(tmp 留着供排查)
|
|
512
|
+
if (writer) writer.abort();
|
|
513
|
+
throw e;
|
|
514
|
+
} finally {
|
|
515
|
+
// 必须成对释放(放 finally):扫描抛错不能把槽位永久占死,否则后续请求永久排队
|
|
516
|
+
releaseScanSlot();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (wantJsonl) {
|
|
520
|
+
log.info('已生成清单(jsonl)', { file: path.basename(base) + '.jsonl', count: models.length, bytes: writer.bytesWritten });
|
|
521
|
+
return { models, cached: false, ts };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// 回退:只写 .json(旧格式,一次性序列化 —— 这个分支本来就不支持"边扫边写")
|
|
525
|
+
// 清单头**与 live2d 完全一致**:{ v, rootId, ts, count, generator, dirStamps, models }。
|
|
526
|
+
// rootName/rootPath 已移除(grep 过:只有写入方、没有消费方);
|
|
527
|
+
// kind 一律不写进清单(前端需要的类型由 catalog 读取时盖章)。
|
|
528
|
+
const payload = { rootId: root.id, ts, count: models.length, generator, dirStamps, models };
|
|
529
|
+
const f = `${base}.json`;
|
|
316
530
|
fs.writeFileSync(`${f}.tmp`, JSON.stringify(payload), 'utf8');
|
|
317
531
|
fs.renameSync(`${f}.tmp`, f);
|
|
318
|
-
log.info('
|
|
319
|
-
return { models, cached: false, ts
|
|
532
|
+
log.info('已生成清单(json 回退)', { file: path.basename(f), count: models.length });
|
|
533
|
+
return { models, cached: false, ts };
|
|
320
534
|
}
|
|
321
535
|
|
|
322
536
|
/**
|
|
@@ -326,11 +540,15 @@ export function getSpineModels(configFile, root, force, deps) {
|
|
|
326
540
|
*/
|
|
327
541
|
export function createSpineModels(p, deps) {
|
|
328
542
|
return {
|
|
329
|
-
|
|
543
|
+
// 第三个参数是**每次调用的额外 deps**(目前只有 onProgress):与既有 deps 合并即可,
|
|
544
|
+
// 不传时逐字等同于旧行为。(p.configFile/root/force/deps 都在闭包里)
|
|
545
|
+
getSpineModels: (root, force, extraDeps) => getSpineModels(
|
|
546
|
+
p.configFile, root, force, extraDeps ? { ...deps, ...extraDeps } : deps,
|
|
547
|
+
),
|
|
330
548
|
spineFile: (rootId) => spineFile(p.configFile, rootId),
|
|
331
549
|
invalidateCaches: (rootId) => invalidateCaches(p.configFile, rootId),
|
|
332
550
|
buildModelServer,
|
|
333
|
-
scanRootServer: (root) => scanRootServer(root, deps),
|
|
551
|
+
scanRootServer: (root) => scanRootServer(root, deps), // 返回 Promise(已 async 化)
|
|
334
552
|
resolveIconUrl,
|
|
335
553
|
};
|
|
336
554
|
}
|
|
@@ -463,7 +463,7 @@ export function enabledPluginKinds(pluginsDir, switchFile) {
|
|
|
463
463
|
/**
|
|
464
464
|
* 按**魔数**识别资源包容器(kind:"bundle" 的资源根用)。
|
|
465
465
|
*
|
|
466
|
-
*
|
|
466
|
+
* 为什么不能按扩展名:Live2D 资源包常是**无扩展名**的单文件,
|
|
467
467
|
* 按扩展名过滤会一个都列不出来。容器标识在文件头,读前 8 字节即可判定,成本可忽略。
|
|
468
468
|
*
|
|
469
469
|
* @param {string} abs 文件绝对路径
|