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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"rootId":"nikke_2","
|
|
1
|
+
{"rootId":"nikke_2","ts":1794127311813,"count":0,"generator":"793f6b2e","dirStamps":{"root":1793370056514,"dirs":{"PC預覽可用 - 新":1730713861813,"[Seireiko]":1793370082759}},"models":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"rootId":"npc","
|
|
1
|
+
{"rootId":"npc","ts":1794078060417,"count":0,"generator":"07d15bbe","dirStamps":{"root":null,"dirs":{}},"models":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "modelstage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Spine / Live2D 模型查看器:三级名册、两套渲染面共享相机与面板、UnityFS 资源包解包",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"config/resources.example.json",
|
|
38
38
|
"config/diag-upload.example.json",
|
|
39
39
|
"README.md",
|
|
40
|
+
"CHANGELOG.md",
|
|
40
41
|
"LICENSE",
|
|
41
42
|
"!**/.tmp-*",
|
|
42
43
|
"!**/*.bak",
|
|
@@ -150,7 +150,7 @@ export function parseAtlasPages(atlasPath) {
|
|
|
150
150
|
try {
|
|
151
151
|
// 先剥一次 BOM 再按行匹配。原因:JS 的 \s **包含 U+FEFF**,而 AssetStudio 导出的 atlas
|
|
152
152
|
// 首行是「BOM + 页名」(EF BB BF ...)⇒ 不剥的话首行被 /^[^\s]/ 判成"有前导空白"整行跳过
|
|
153
|
-
// ⇒
|
|
153
|
+
// ⇒ 多页图集的**第一页页名丢失** ⇒ 贴图拿不到、画面空白。
|
|
154
154
|
// 为什么前端 src/web/engine/textures.js 没这个坑:它**先 trim() 再匹配**,而 trim() 会把 U+FEFF
|
|
155
155
|
// 当空白吃掉 —— 所以那边页名本来就干净。这里剥 BOM 就是与"先 trim"等价,别删。
|
|
156
156
|
// 只剥解析用的文本:**不改 atlas 落盘字节、不改页名大小写**(产物必须逐字节不变)。
|
|
@@ -126,8 +126,8 @@ function readHeader() {
|
|
|
126
126
|
* 按 name(显示名称 = 归一 key)把资源根归并成组,供前端直接渲染。
|
|
127
127
|
*
|
|
128
128
|
* 归一语义:name 相同的多个资源根合并为一个条目。
|
|
129
|
-
*
|
|
130
|
-
*
|
|
129
|
+
* 例:原版目录与 Mod 目录若填了同一个显示名,界面合并成一个条目,
|
|
130
|
+
* 其下汇总各 root 的模型(原版 + Mod 共存不覆盖)。
|
|
131
131
|
*
|
|
132
132
|
* @param {Array} roots 资源根数组
|
|
133
133
|
* @returns {Array<{name:string, rootCount:number, roots:Array}>}
|
package/src/server/core/log.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// 记录形状(与前端同一套字段名):{ at, level, tag, msg, detail }
|
|
8
8
|
// 输出格式(单行,人读 + 可解析兼顾):
|
|
9
|
-
// 14:32:07.981 INFO [models] 已生成清单
|
|
9
|
+
// 14:32:07.981 INFO [models] 已生成清单 spine.<rootId>.json {"count":0}
|
|
10
10
|
// └ 固定宽度前缀 ⇒ 正则 /^(\d{2}:\d{2}:\d{2}\.\d{3}) (\w+)\s+\[(\S+)\] (.*?)(\{.*\})?$/ 可切四段
|
|
11
11
|
// 流向:error → stderr(真正的异常),debug/info/warn → stdout。
|
|
12
12
|
// 项目启动脚本是 `nohup … >"$LOG_FILE" 2>&1`,两流合并写同一文件,所以分流不丢日志,
|
|
@@ -209,7 +209,6 @@ export function toSpineEntry(cfg, m) {
|
|
|
209
209
|
skins: m.skins || [],
|
|
210
210
|
expressions: [],
|
|
211
211
|
textureCount: m.textureCount,
|
|
212
|
-
fileSize: m.fileSize,
|
|
213
212
|
extras: {
|
|
214
213
|
versionLabel: m.version,
|
|
215
214
|
format: m.format,
|
|
@@ -316,7 +315,6 @@ function buildLive2DMeta(lm, fromDef, textureCheck) {
|
|
|
316
315
|
textureCount: (lm.textureFiles || []).length,
|
|
317
316
|
// 声明了但磁盘缺失的纹理(前端据此预判并给出明确提示,不必等渲染报错)
|
|
318
317
|
missingTextures: textureCheck.missing,
|
|
319
|
-
fileSize: lm.fileSize,
|
|
320
318
|
extras: { cubism: lm.cubism, defFile: lm.defFile, mocFile: lm.mocFile },
|
|
321
319
|
};
|
|
322
320
|
}
|
|
@@ -446,9 +444,9 @@ export function toBundleEntry(cfg, file, provider, owner = null) {
|
|
|
446
444
|
/**
|
|
447
445
|
* 容器列举的规模上限 —— 值来自**实测**,不是拍脑袋。
|
|
448
446
|
*
|
|
449
|
-
*
|
|
450
|
-
* 文件最大深度 3
|
|
451
|
-
*
|
|
447
|
+
* 实测多个真实资源根:
|
|
448
|
+
* 文件最大深度 3 | 目录数数百 | 容器命中十几个
|
|
449
|
+
* 也有根的容器在**第二层**(`<角色名>/<包>`)—— 这正是"只扫根层"会漏掉的那类。
|
|
452
450
|
* 深度取「实测 max + 1」,给未来更深的 Mod 类根留一层余量。
|
|
453
451
|
*
|
|
454
452
|
* 超限**绝不静默截断**:stopped 带回原因,由调用方写进 warnings
|
|
@@ -469,7 +467,7 @@ const BUNDLE_SCAN_MAX_HITS = 500;
|
|
|
469
467
|
* "顺手统一成 listTree"会把冷启动拖回 31 秒 —— 这条注释就是防这个的。
|
|
470
468
|
*
|
|
471
469
|
* 只对**未知扩展名**的文件读 8 字节魔数,扩展名集合复用 tree 的 `KNOWN_FILE_EXT`(同一份真相):
|
|
472
|
-
*
|
|
470
|
+
* 实测常见资源根的未知扩展名文件数为 **0**,
|
|
473
471
|
* 即这三个根一次文件打开都不需要,成本全在 readdir 本身。
|
|
474
472
|
*
|
|
475
473
|
* 返回 `byDir`(按目录聚合)是为将来「按 dirSignature 缓存」留的钩子:
|
|
@@ -535,9 +533,8 @@ export function findContainerFiles(rootPath, detectContainer, opts = {}) {
|
|
|
535
533
|
/**
|
|
536
534
|
* 列资源包根下的条目(递归,不再只看根层)。
|
|
537
535
|
*
|
|
538
|
-
* 为什么必须递归:资源包的实际摆法不统一 ——
|
|
539
|
-
*
|
|
540
|
-
* (nikke_2:3 个包都在 L1)。只看根层时后者 catalog 条目为 0,
|
|
536
|
+
* 为什么必须递归:资源包的实际摆法不统一 —— 有的根把包平铺在根层,
|
|
537
|
+
* 有的按 `<角色名>/<包>` 分目录。只看根层时后者 catalog 条目为 0,
|
|
541
538
|
* 表现是"资源包明明在盘上,模型列表里却没有"。
|
|
542
539
|
*
|
|
543
540
|
* @returns {{entries: Array, warnings: string[], skipped: number}}
|
|
@@ -764,7 +761,7 @@ export function createCatalog({ config, spine, live2d, tree, plugins, unpack = n
|
|
|
764
761
|
* @returns {{entries: Array, warnings: string[], scanErrors: Error[],
|
|
765
762
|
* cachedAll: boolean, pathProblem: string|null}}
|
|
766
763
|
*/
|
|
767
|
-
function scanRootEntries(cfg) {
|
|
764
|
+
async function scanRootEntries(cfg) {
|
|
768
765
|
const entries = [];
|
|
769
766
|
const warnings = [];
|
|
770
767
|
/** 扫描过程中抛的错(用于判断 rootWarning 该说"扫不动"还是"真的空") */
|
|
@@ -775,7 +772,7 @@ export function createCatalog({ config, spine, live2d, tree, plugins, unpack = n
|
|
|
775
772
|
const pathProblem = inspectRootPath(cfg.path);
|
|
776
773
|
|
|
777
774
|
try {
|
|
778
|
-
const scan = spine.getSpineModels(cfg, false);
|
|
775
|
+
const scan = await spine.getSpineModels(cfg, false);
|
|
779
776
|
for (const m of scan.models) entries.push(toSpineEntry(cfg, m));
|
|
780
777
|
if (scan.cached !== true) cachedAll = false;
|
|
781
778
|
} catch (e) {
|
|
@@ -783,7 +780,7 @@ export function createCatalog({ config, spine, live2d, tree, plugins, unpack = n
|
|
|
783
780
|
warnings.push(`spine 扫描失败:${e.message}`);
|
|
784
781
|
}
|
|
785
782
|
try {
|
|
786
|
-
const scan = live2d.getLive2DModels(cfg, false);
|
|
783
|
+
const scan = await live2d.getLive2DModels(cfg, false);
|
|
787
784
|
for (const lm of scan.models) entries.push(toLive2DEntry(cfg, lm));
|
|
788
785
|
if (scan.cached !== true) cachedAll = false;
|
|
789
786
|
} catch (e) {
|
|
@@ -801,14 +798,14 @@ export function createCatalog({ config, spine, live2d, tree, plugins, unpack = n
|
|
|
801
798
|
return { entries, warnings, scanErrors, cachedAll, pathProblem, skipped: bundle.skipped };
|
|
802
799
|
}
|
|
803
800
|
|
|
804
|
-
function listCatalog(rootId) {
|
|
801
|
+
async function listCatalog(rootId) {
|
|
805
802
|
const t0 = performance.now();
|
|
806
803
|
const cfg = config.readConfig().find((r) => r.id === rootId);
|
|
807
804
|
if (!cfg) {
|
|
808
805
|
// 形状与其它接口的错误体保持一致;按约定这里用 404(资源根不存在)
|
|
809
806
|
return { status: 404, body: { error: `未找到资源根: ${rootId}` } };
|
|
810
807
|
}
|
|
811
|
-
const { entries, warnings, scanErrors, cachedAll, pathProblem, skipped } = scanRootEntries(cfg);
|
|
808
|
+
const { entries, warnings, scanErrors, cachedAll, pathProblem, skipped } = await scanRootEntries(cfg);
|
|
812
809
|
const { sawStale } = assembleEntryStates(cfg, entries);
|
|
813
810
|
// §八 触发来源(三选一,别混成一条):这一根里只要有一条是"旧结论作废"排进来的,
|
|
814
811
|
// 就说明这轮探测是**能力变化**引起的(fingerprint);否则是清单重建引起的(catalog)。
|
|
@@ -10,10 +10,16 @@
|
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import { createLogger } from '../core/log.mjs';
|
|
12
12
|
import { dirSignature, signatureMatches, generatorFingerprint } from './manifest-stamp.mjs';
|
|
13
|
-
import { live2dManifestFile, ensureManifestDir } from './manifest-cache.mjs';
|
|
13
|
+
import { live2dManifestFile, ensureManifestDir, live2dManifestBase } from './manifest-cache.mjs';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { encodePath } from '../assets/probe.mjs';
|
|
16
16
|
import { resolveLive2DRuntimes } from '../../../tool/resolve-live2d-runtimes.mjs';
|
|
17
|
+
// 进度节流:与 Spine 侧共用同一份实现
|
|
18
|
+
import { createProgressThrottle } from './scan-progress.mjs';
|
|
19
|
+
// P1:清单改 JSONL(边扫边写)+ 双读兼容
|
|
20
|
+
import { openManifestWriter, readManifest } from './manifest-jsonl.mjs';
|
|
21
|
+
// 并发上限 + 让路提示(同 spine,理由见 scan-queue.mjs 顶部)
|
|
22
|
+
import { acquireScanSlot, releaseScanSlot, isInteractiveBusy } from './scan-queue.mjs';
|
|
17
23
|
|
|
18
24
|
/** 本模块日志(统一形状 {at,level,tag,msg,detail}) */
|
|
19
25
|
const log = createLogger('live2d');
|
|
@@ -21,7 +27,6 @@ const log = createLogger('live2d');
|
|
|
21
27
|
const rtLog = createLogger('live2d-runtimes');
|
|
22
28
|
|
|
23
29
|
/** Live2D 清单缓存的格式版本(结构变更时 +1,旧缓存自动失效重扫) */
|
|
24
|
-
const LIVE2D_MANIFEST_VERSION = 1;
|
|
25
30
|
/** 运行时库文件清单缓存(探测一次即复用) */
|
|
26
31
|
let live2dRuntimeFilesCache = null;
|
|
27
32
|
|
|
@@ -125,7 +130,6 @@ export function buildLive2DModel(root, relPath, defName, cubism) {
|
|
|
125
130
|
|
|
126
131
|
return {
|
|
127
132
|
id: `live2d-${root.id}-${(relPath || '.').replace(/\//g, '_')}`,
|
|
128
|
-
kind: 'live2d',
|
|
129
133
|
cubism,
|
|
130
134
|
name,
|
|
131
135
|
srcDir: relPath,
|
|
@@ -140,30 +144,26 @@ export function buildLive2DModel(root, relPath, defName, cubism) {
|
|
|
140
144
|
motions,
|
|
141
145
|
// 列表缩略图:模型目录里的 icon.png(有则用)
|
|
142
146
|
iconUrl: fileObj('icon.png') ? fileUrl('icon.png') : null,
|
|
143
|
-
fileSize: [defName, mocName, ...textures, ...expressions.map((e) => e.file),
|
|
144
|
-
...Object.values(motions).flat().map((mo) => mo.file)]
|
|
145
|
-
.map(fileObj).filter(Boolean).reduce((sum, f) => sum + f.size, 0),
|
|
146
147
|
};
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
/** 扫一个目录:识别 Cubism 4(*.model3.json)优先于 Cubism 2(model.json)。 */
|
|
150
151
|
function modelsInDir(root, absDir, relPath, entries) {
|
|
152
|
+
// 同步(回退理由同 spine.collectDirEntries:异步化在 CIFS 上更慢)
|
|
151
153
|
const dirs = [];
|
|
152
154
|
const files = new Map(); // 文件名 -> { name, size }
|
|
153
155
|
for (const e of entries) {
|
|
154
156
|
if (e.name.startsWith('.')) continue;
|
|
155
157
|
const full = path.join(absDir, e.name);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
158
|
+
// **不再逐文件 stat**:目录判定用 Dirent(`e.isDirectory()`);
|
|
159
|
+
// 这里原来的 size 唯一用途是 `model.fileSize`(前端从未读过,死数据)。
|
|
160
|
+
// 注意:`fileObj()` 里的 stat **保留** —— 它要 `st.isFile()` 判「文件真的在不在」,
|
|
161
|
+
// 去掉会让「引用不存在的贴图」从告警变成沉默(缺贴图页数会变多)。
|
|
162
162
|
if (e.isDirectory()) {
|
|
163
163
|
dirs.push({ name: e.name, full });
|
|
164
164
|
continue;
|
|
165
165
|
}
|
|
166
|
-
files.set(e.name, { name: e.name
|
|
166
|
+
files.set(e.name, { name: e.name });
|
|
167
167
|
}
|
|
168
168
|
const out = [];
|
|
169
169
|
const def4 = [...files.keys()].find((n) => /\.model3\.json$/i.test(n)) || null;
|
|
@@ -176,10 +176,33 @@ function modelsInDir(root, absDir, relPath, entries) {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
/** 扫描一个资源根里的全部 Live2D 模型。 */
|
|
179
|
-
export function scanLive2DRootServer(root) {
|
|
179
|
+
export async function scanLive2DRootServer(root, opts = {}) {
|
|
180
180
|
const models = [];
|
|
181
|
+
// 进度回调可选:不传时与改动前逐字一致(现有调用方不传)
|
|
182
|
+
const throttle = typeof opts.onProgress === 'function'
|
|
183
|
+
? createProgressThrottle({ onProgress: opts.onProgress, kind: 'live2d', rootId: root.id })
|
|
184
|
+
: null;
|
|
185
|
+
let dirsScanned = 0;
|
|
186
|
+
|
|
187
|
+
// 分片让出:与 spine 侧同一套策略(每 20 个目录或每 50ms),理由见 spine.mjs 里的注释
|
|
188
|
+
// setImmediate 而不是 setTimeout(0):后者在负载下实际延迟可达几十 ms,扫描让出上千次会累积成数倍劣化(实测 24s→169s)
|
|
189
|
+
const yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve));
|
|
190
|
+
const YIELD_DIRS = 20;
|
|
191
|
+
const YIELD_MS = 50;
|
|
192
|
+
let sinceYieldDirs = 0;
|
|
193
|
+
let lastYieldAt = Date.now();
|
|
194
|
+
async function maybeYield() {
|
|
195
|
+
sinceYieldDirs += 1;
|
|
196
|
+
// 让路:有交互请求时再多让一点,但幅度有限(理由见 spine.mjs 同处注释)
|
|
197
|
+
const busy = isInteractiveBusy();
|
|
198
|
+
if (sinceYieldDirs >= (busy ? 12 : YIELD_DIRS) || Date.now() - lastYieldAt >= (busy ? 25 : YIELD_MS)) {
|
|
199
|
+
sinceYieldDirs = 0;
|
|
200
|
+
lastYieldAt = Date.now();
|
|
201
|
+
await yieldToEventLoop();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
181
204
|
|
|
182
|
-
function walk(absDir, relPath) {
|
|
205
|
+
async function walk(absDir, relPath) {
|
|
183
206
|
let entries;
|
|
184
207
|
try {
|
|
185
208
|
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
@@ -187,11 +210,25 @@ export function scanLive2DRootServer(root) {
|
|
|
187
210
|
return;
|
|
188
211
|
}
|
|
189
212
|
const found = modelsInDir(root, absDir, relPath, entries);
|
|
190
|
-
|
|
191
|
-
|
|
213
|
+
// 边扫边写挂点(P1):不传则完全无副作用
|
|
214
|
+
if (found.models.length) {
|
|
215
|
+
models.push(...found.models);
|
|
216
|
+
if (typeof opts.onModel === 'function') {
|
|
217
|
+
for (const m of found.models) opts.onModel(m);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
dirsScanned += 1;
|
|
221
|
+
if (throttle) {
|
|
222
|
+
throttle.bump({ scanned: models.length, dirs: dirsScanned, currentPath: relPath, dirCompleted: true });
|
|
223
|
+
}
|
|
224
|
+
await maybeYield();
|
|
225
|
+
for (const d of found.dirs) {
|
|
226
|
+
await walk(d.full, relPath ? `${relPath}/${d.name}` : d.name);
|
|
227
|
+
}
|
|
192
228
|
}
|
|
193
229
|
|
|
194
|
-
walk(root.path, '');
|
|
230
|
+
await walk(root.path, '');
|
|
231
|
+
if (throttle) throttle.flush();
|
|
195
232
|
return models;
|
|
196
233
|
}
|
|
197
234
|
|
|
@@ -199,41 +236,103 @@ export function scanLive2DRootServer(root) {
|
|
|
199
236
|
* 取 Live2D 模型列表(带落盘缓存,逻辑与 Spine 的 getModels 一致)。
|
|
200
237
|
* 为什么缓存:天命之子有 2000+ 角色目录,每次刷新全量扫描会明显变慢。
|
|
201
238
|
*/
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
239
|
+
/**
|
|
240
|
+
* 在跑的扫描:key = `live2d:<rootId>` → promise(与 spine 同口径,理由见 spine.mjs 里的注释)。
|
|
241
|
+
*/
|
|
242
|
+
const live2dInflight = new Map();
|
|
243
|
+
|
|
244
|
+
export async function getLive2DModels(configFile, cfg, force, onProgress, onStart, onModel) {
|
|
245
|
+
const key = `live2d:${cfg.id}`;
|
|
246
|
+
const existing = live2dInflight.get(key);
|
|
247
|
+
if (existing) return existing;
|
|
248
|
+
// 先占位再启动(同 spine,理由见其注释)
|
|
249
|
+
let settleResolve;
|
|
250
|
+
let settleReject;
|
|
251
|
+
const gate = new Promise((resolve, reject) => { settleResolve = resolve; settleReject = reject; });
|
|
252
|
+
live2dInflight.set(key, gate);
|
|
253
|
+
doGetLive2DModels(configFile, cfg, force, onProgress, onStart, onModel)
|
|
254
|
+
.then(settleResolve, settleReject)
|
|
255
|
+
.finally(() => { if (live2dInflight.get(key) === gate) live2dInflight.delete(key); });
|
|
256
|
+
return gate;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 真正干活的实现(单飞包装见上面的 getLive2DModels) */
|
|
260
|
+
async function doGetLive2DModels(configFile, cfg, force, onProgress, onStart, onModel) {
|
|
261
|
+
const base = live2dManifestBase(configFile, cfg.id);
|
|
262
|
+
// 与 spine 同口径:默认 .json,env=jsonl 才写 JSONL(两种都能读)
|
|
263
|
+
const wantJsonl = String(process.env.MODELSTAGE_MANIFEST_FORMAT || 'json').toLowerCase() === 'jsonl';
|
|
264
|
+
|
|
265
|
+
if (!force) {
|
|
266
|
+
// 双读:.jsonl 优先,缺则回落 .json(视为 complete=true)
|
|
267
|
+
const m = readManifest(base);
|
|
268
|
+
if (m.format !== 'none') {
|
|
269
|
+
if (!m.complete) {
|
|
270
|
+
log.info('清单不完整(complete 非 true),重建', { id: cfg.id, badLines: m.badLines });
|
|
271
|
+
} else {
|
|
209
272
|
const gen = generatorFingerprint();
|
|
210
|
-
if (
|
|
211
|
-
log.info('解析器已变化,清单失效重建', { id: cfg.id, from:
|
|
212
|
-
} else if (signatureMatches(
|
|
213
|
-
return { models:
|
|
273
|
+
if (m.meta.generator !== gen) {
|
|
274
|
+
log.info('解析器已变化,清单失效重建', { id: cfg.id, from: m.meta.generator || null, to: gen });
|
|
275
|
+
} else if (signatureMatches(m.meta.dirStamps, cfg.path)) {
|
|
276
|
+
return { models: m.models, cached: true, ts: m.meta.ts };
|
|
214
277
|
} else {
|
|
215
278
|
log.info('目录已变化,清单失效重建', { id: cfg.id });
|
|
216
279
|
}
|
|
217
280
|
}
|
|
218
|
-
} catch {
|
|
219
|
-
/* 缓存损坏则重扫 */
|
|
220
281
|
}
|
|
221
282
|
}
|
|
222
|
-
|
|
283
|
+
|
|
223
284
|
const ts = Date.now();
|
|
285
|
+
const generator = generatorFingerprint();
|
|
286
|
+
const dirStamps = dirSignature(cfg.path);
|
|
287
|
+
ensureManifestDir(configFile);
|
|
288
|
+
|
|
289
|
+
const writer = wantJsonl
|
|
290
|
+
? openManifestWriter(base, { rootId: cfg.id, ts, generator, dirStamps })
|
|
291
|
+
: null;
|
|
292
|
+
|
|
293
|
+
// 并发上限(同 spine):全局只允许 1 个根在扫
|
|
294
|
+
await acquireScanSlot(`live2d:${cfg.id}`);
|
|
295
|
+
|
|
296
|
+
let models;
|
|
297
|
+
try {
|
|
298
|
+
models = await scanLive2DRootServer(cfg, {
|
|
299
|
+
...(onProgress ? { onProgress } : {}),
|
|
300
|
+
...(typeof onStart === 'function' ? { onStart } : {}),
|
|
301
|
+
...((writer || typeof onModel === 'function')
|
|
302
|
+
? {
|
|
303
|
+
onModel: (m) => {
|
|
304
|
+
if (writer) writer.append(m);
|
|
305
|
+
if (typeof onModel === 'function') onModel(m);
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
: {}),
|
|
309
|
+
});
|
|
310
|
+
if (writer) writer.finish(models.length);
|
|
311
|
+
} catch (e) {
|
|
312
|
+
if (writer) writer.abort();
|
|
313
|
+
throw e;
|
|
314
|
+
} finally {
|
|
315
|
+
releaseScanSlot();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (wantJsonl) {
|
|
319
|
+
log.info('已生成清单(jsonl)', { id: cfg.id, count: models.length, bytes: writer.bytesWritten });
|
|
320
|
+
return { models, cached: false, ts };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 回退:只写 .json(旧格式)
|
|
224
324
|
try {
|
|
225
|
-
|
|
226
|
-
//
|
|
227
|
-
|
|
325
|
+
const file = `${base}.json`;
|
|
326
|
+
// 清单头与 spine 完全一致(见 docs:v 是唯一的格式版本字段;kind 一律不写进清单,
|
|
327
|
+
// 由 catalog 读取时盖章;rootName/rootPath 无消费方已移除)
|
|
228
328
|
const body = JSON.stringify({
|
|
229
|
-
version: LIVE2D_MANIFEST_VERSION,
|
|
230
329
|
rootId: cfg.id,
|
|
231
330
|
ts,
|
|
232
|
-
|
|
233
|
-
generator
|
|
331
|
+
count: models.length,
|
|
332
|
+
generator,
|
|
333
|
+
dirStamps,
|
|
234
334
|
models,
|
|
235
335
|
});
|
|
236
|
-
// 与 spine 清单同口径:先写临时文件再 rename,避免并发读拿到半份 JSON
|
|
237
336
|
fs.writeFileSync(`${file}.tmp`, body, 'utf8');
|
|
238
337
|
fs.renameSync(`${file}.tmp`, file);
|
|
239
338
|
} catch (e) {
|
|
@@ -266,7 +365,7 @@ export function createLive2d(p) {
|
|
|
266
365
|
live2dModelsFile: (rootId) => live2dModelsFile(p.configFile, rootId),
|
|
267
366
|
buildLive2DModel,
|
|
268
367
|
scanLive2DRootServer,
|
|
269
|
-
getLive2DModels: (cfg, force) => getLive2DModels(p.configFile, cfg, force),
|
|
368
|
+
getLive2DModels: (cfg, force, onProgress, onStart, onModel) => getLive2DModels(p.configFile, cfg, force, onProgress, onStart, onModel),
|
|
270
369
|
getLive2DRuntimeFiles,
|
|
271
370
|
};
|
|
272
371
|
}
|
|
@@ -17,14 +17,28 @@ export function manifestDir(configFile) {
|
|
|
17
17
|
return path.join(path.dirname(configFile), 'models');
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* 清单的**基名**(不带扩展名):config/models/spine.<rootId>
|
|
22
|
+
* 为什么引入 base:同一份清单现在有两种落盘格式(.jsonl 当前 / .json 历史),
|
|
23
|
+
* 读写层需要"同一个名字 + 不同后缀",而不是各自拼一遍字符串(拼两处早晚拼歪)。
|
|
24
|
+
*/
|
|
25
|
+
export function spineManifestBase(configFile, rootId) {
|
|
26
|
+
return path.join(manifestDir(configFile), `spine.${rootId}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Live2D 清单基名:config/models/live2d.<rootId> */
|
|
30
|
+
export function live2dManifestBase(configFile, rootId) {
|
|
31
|
+
return path.join(manifestDir(configFile), `live2d.${rootId}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Spine 清单路径(历史 JSON 格式):config/models/spine.<rootId>.json */
|
|
21
35
|
export function spineManifestFile(configFile, rootId) {
|
|
22
|
-
return
|
|
36
|
+
return `${spineManifestBase(configFile, rootId)}.json`;
|
|
23
37
|
}
|
|
24
38
|
|
|
25
|
-
/** Live2D
|
|
39
|
+
/** Live2D 清单路径(历史 JSON 格式):config/models/live2d.<rootId>.json */
|
|
26
40
|
export function live2dManifestFile(configFile, rootId) {
|
|
27
|
-
return
|
|
41
|
+
return `${live2dManifestBase(configFile, rootId)}.json`;
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
/** 写清单前确保目录存在(首次运行或目录被清空时,不能因为缺目录写失败) */
|
|
@@ -37,21 +51,50 @@ export function ensureManifestDir(configFile) {
|
|
|
37
51
|
* 文件不存在、目录不存在都不算错(本来就没有清单可删)。
|
|
38
52
|
*/
|
|
39
53
|
export function invalidateManifests(configFile, rootId) {
|
|
54
|
+
// **只删成品**(.jsonl / .json),**不删 tmp**。
|
|
55
|
+
//
|
|
56
|
+
// 为什么 tmp 不能删:tmp 是"扫描正在写的那份"。invalidate 可能被**并发触发**
|
|
57
|
+
// (另一个根 refresh、或任何一次失效),删掉正在写的 tmp ⇒ 那个扫描 finish 时 rename ENOENT
|
|
58
|
+
// (实测日志:`rename 'spine.<rootId>.jsonl.tmp' -> 'spine.<rootId>.jsonl'`)。
|
|
59
|
+
// tmp 现在带 pid+seq 唯一名,靠"年龄"清理:只删老到不可能是"正在写"的。
|
|
60
|
+
const FINAL_SUFFIXES = ['.jsonl', '.json'];
|
|
61
|
+
const TMP_MAX_AGE_MS = 60 * 60 * 1000; // 1 小时:正常扫描远用不到,超过即视为残留
|
|
62
|
+
const TMP_RE = /\.\d+\.\d+\.jsonl\.tmp$/; // <base>.<pid>.<seq>.jsonl.tmp
|
|
63
|
+
|
|
64
|
+
function cleanupStaleTmp(dir) {
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
try {
|
|
67
|
+
for (const n of fs.readdirSync(dir)) {
|
|
68
|
+
if (!TMP_RE.test(n)) continue;
|
|
69
|
+
const full = path.join(dir, n);
|
|
70
|
+
try {
|
|
71
|
+
// 只删老的:新 tmp 可能正是另一个扫描正在写的
|
|
72
|
+
if (now - fs.statSync(full).mtimeMs > TMP_MAX_AGE_MS) fs.unlinkSync(full);
|
|
73
|
+
} catch { /* 单个文件处理失败不影响整体 */ }
|
|
74
|
+
}
|
|
75
|
+
} catch { /* 目录读不了就算了 */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
40
78
|
if (rootId) {
|
|
41
|
-
for (const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
79
|
+
for (const base of [spineManifestBase(configFile, rootId), live2dManifestBase(configFile, rootId)]) {
|
|
80
|
+
for (const suffix of FINAL_SUFFIXES) {
|
|
81
|
+
const f = `${base}${suffix}`;
|
|
82
|
+
try {
|
|
83
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
84
|
+
} catch {
|
|
85
|
+
/* 删除失败不影响调用方:清单不可用会被后续扫描覆盖 */
|
|
86
|
+
}
|
|
46
87
|
}
|
|
47
88
|
}
|
|
89
|
+
cleanupStaleTmp(manifestDir(configFile));
|
|
48
90
|
return;
|
|
49
91
|
}
|
|
50
92
|
try {
|
|
51
93
|
const dir = manifestDir(configFile);
|
|
52
94
|
for (const n of fs.readdirSync(dir)) {
|
|
53
|
-
if (/^(?:spine|live2d)\..+\.json$/.test(n)) fs.unlinkSync(path.join(dir, n));
|
|
95
|
+
if (/^(?:spine|live2d)\..+\.(?:jsonl|json)$/.test(n)) fs.unlinkSync(path.join(dir, n));
|
|
54
96
|
}
|
|
97
|
+
cleanupStaleTmp(dir);
|
|
55
98
|
} catch {
|
|
56
99
|
/* 目录不存在 = 没有清单要删 */
|
|
57
100
|
}
|