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,336 @@
|
|
|
1
|
+
// spine.mjs —— Spine 模型扫描与清单缓存(config/models/spine.<rootId>.json)
|
|
2
|
+
// 本模块只认 Spine(.skel/.json + 同名 .atlas 配对);Live2D 走 live2d.mjs。
|
|
3
|
+
// 清单路径、目录创建、失效逻辑在 manifest-cache.mjs —— 那是两个扫描器共用的,不属于这里。
|
|
4
|
+
|
|
5
|
+
const log = createLogger('models');
|
|
6
|
+
//
|
|
7
|
+
// 逻辑逐字搬自 server.mjs(行为等价由 sha256 逐字节比对验证)。
|
|
8
|
+
// 与旧实现的唯一差别是**结构**:原先一个函数里套着全部步骤,这里按阶段拆成
|
|
9
|
+
// 「建索引 → 收集目录条目 → 按 base name 分组 → 组装模型」,便于阅读与单独测试;
|
|
10
|
+
// 路径依赖(清单文件位置)改成显式入参,模块内不持有全局路径。
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import { createLogger } from '../core/log.mjs';
|
|
13
|
+
import { dirSignature, signatureMatches, generatorFingerprint } from './manifest-stamp.mjs';
|
|
14
|
+
import {
|
|
15
|
+
spineManifestFile, ensureManifestDir, invalidateManifests,
|
|
16
|
+
} from './manifest-cache.mjs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { MIME, encodePath, parseAtlasPages } from '../assets/probe.mjs';
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 头像解析(三条候选路,按优先级):
|
|
24
|
+
* ① 头像仓:<avatarRoot>/<names>/<avatar>(v2 主路;avatarRoot 可配置,见 docs/设计/名表.md §4.1)
|
|
25
|
+
* ② 旧 icons.dir:/icons/<rootId>/<文件名>(v1 兼容,双轨期保留)
|
|
26
|
+
* ③ 模型目录自带的 icon.png:/files/<rootId>/<相对路径>
|
|
27
|
+
* 都没有 ⇒ null(前端用占位图,不阻塞)。
|
|
28
|
+
*
|
|
29
|
+
* 为什么头像仓要**先 stat 再返回 URL**:头像仓可能与 index 不同步(index 里写了 avatar 文件名,
|
|
30
|
+
* 但 png 还没提取)。不 stat 就给 URL,前端会拿到 404 的 img,比"没有头像"更难看。
|
|
31
|
+
*
|
|
32
|
+
* @param {Function} getNameTable 名表读取函数(由调用方注入,避免本模块依赖项目根)
|
|
33
|
+
* @param {{avatarRoot?: string}} [opts] avatarRoot = 头像仓根(可配置)
|
|
34
|
+
*/
|
|
35
|
+
export function resolveIconUrl(root, relPath, modelName, dirFiles, getNameTable, opts = {}) {
|
|
36
|
+
// 名表服务端不认识结构(names.mjs 原样透传):byAsset[key] 是角色 id(字符串),
|
|
37
|
+
// 条目里的头像文件名在 `icon` 字段。只取这两样,不碰 name/costume 等业务字段。
|
|
38
|
+
const t = getNameTable(root);
|
|
39
|
+
const key = String(modelName).toLowerCase();
|
|
40
|
+
const map = (t && t.byAsset) ? t.byAsset[key] : null;
|
|
41
|
+
const id = typeof map === 'string' ? map : (map && map.char) || null;
|
|
42
|
+
const entry = (id && t.characters) ? t.characters[id] : null;
|
|
43
|
+
const avatar = entry ? String(entry.icon || '') : '';
|
|
44
|
+
// ① 头像仓优先
|
|
45
|
+
if (avatar && opts.avatarRoot && root.names) {
|
|
46
|
+
const abs = path.join(opts.avatarRoot, String(root.names), avatar);
|
|
47
|
+
if (fs.existsSync(abs)) {
|
|
48
|
+
return `/avatars/${encodeURIComponent(root.names)}/${encodeURIComponent(avatar)}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// ② 旧路:icons.dir(v1 兼容)
|
|
52
|
+
if (avatar && root.icons && root.icons.dir) {
|
|
53
|
+
return `/icons/${encodeURIComponent(root.id)}/${encodeURIComponent(avatar)}`;
|
|
54
|
+
}
|
|
55
|
+
// ③ 模型目录自带的 icon.png(Live2D)
|
|
56
|
+
if (dirFiles && dirFiles.has('icon.png')) {
|
|
57
|
+
const rel = relPath ? `${relPath}/icon.png` : 'icon.png';
|
|
58
|
+
return `/files/${encodeURIComponent(root.id)}/${encodePath(rel)}`;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 按 atlas 声明的页名收集贴图:同目录优先,找不到再退化为全根按文件名找。 */
|
|
64
|
+
export function collectTextures(relPath, atlas, dirFiles, fileIndex) {
|
|
65
|
+
const pages = parseAtlasPages(atlas.full);
|
|
66
|
+
const textures = [];
|
|
67
|
+
const missing = [];
|
|
68
|
+
for (const p of pages) {
|
|
69
|
+
const key = p.toLowerCase();
|
|
70
|
+
let hit = dirFiles.find((f) => f.name.toLowerCase() === key);
|
|
71
|
+
if (!hit && fileIndex) hit = fileIndex.get(key);
|
|
72
|
+
if (hit) textures.push(hit);
|
|
73
|
+
else missing.push(p);
|
|
74
|
+
}
|
|
75
|
+
if (missing.length) {
|
|
76
|
+
log.warn('缺贴图页(同目录与全根均未找到)', { dir: relPath || '.', missing });
|
|
77
|
+
}
|
|
78
|
+
return { textures, missing };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 生成「文件 → 对外引用」的构造函数(每个文件用它自己的 relPath)。 */
|
|
82
|
+
export function makeFileRef(rootId, relPath) {
|
|
83
|
+
return (f) => {
|
|
84
|
+
const rel = f.relPath || (relPath ? `${relPath}/${f.name}` : f.name);
|
|
85
|
+
return {
|
|
86
|
+
name: f.name,
|
|
87
|
+
path: rel,
|
|
88
|
+
size: f.size,
|
|
89
|
+
type: MIME[f.ext] || 'application/octet-stream',
|
|
90
|
+
url: `/api/file?root=${encodeURIComponent(rootId)}&path=${encodeURIComponent(rel)}`,
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 由一个目录里的「骨架 + 同名图集」构造模型条目。
|
|
97
|
+
* 注意(职责划分):后端不做任何分组/聚合,只如实提供原始信息;
|
|
98
|
+
* 角色归一是前端显示层的职责(见 public/char-tree.js 的 charKeyOf())。
|
|
99
|
+
*/
|
|
100
|
+
export function buildModelServer(root, relPath, baseName, skel, atlas, dirFiles, fileIndex, deps) {
|
|
101
|
+
const rootId = root.id;
|
|
102
|
+
const rootName = root.name;
|
|
103
|
+
const { textures } = collectTextures(relPath, atlas, dirFiles, fileIndex);
|
|
104
|
+
// format:照抄原版实现 —— 文件名含 ".skel" 为 "skel",否则 "json"
|
|
105
|
+
const format = skel.name.toLowerCase().includes('.skel') ? 'skel' : 'json';
|
|
106
|
+
const mk = makeFileRef(rootId, relPath);
|
|
107
|
+
// 归一 key:atlas 内容首行的贴图名(去扩展名 + 小写)—— 该资源的真实标识
|
|
108
|
+
const assetKey = atlas.assetKey || baseName;
|
|
109
|
+
const dirPath = `${rootId}/${relPath}`;
|
|
110
|
+
const idPath = (relPath ? `${relPath}/` : '') + baseName;
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
id: `local-${rootId}-${idPath.replace(/\//g, '_')}`,
|
|
114
|
+
name: assetKey,
|
|
115
|
+
srcDir: relPath,
|
|
116
|
+
rootId,
|
|
117
|
+
rootName,
|
|
118
|
+
directory: `${rootName}/${relPath}`,
|
|
119
|
+
dirPath,
|
|
120
|
+
version: skel.spineVersion || '4.0',
|
|
121
|
+
format,
|
|
122
|
+
skeletonFile: mk(skel),
|
|
123
|
+
atlasFile: mk(atlas),
|
|
124
|
+
textureFiles: textures.map(mk),
|
|
125
|
+
textureCount: textures.length,
|
|
126
|
+
fileSize: skel.size + atlas.size + textures.reduce((s, t) => s + t.size, 0),
|
|
127
|
+
// atlasPma:true/false/null(未声明=未知)。不可硬编码 false —— 那等于断言"非预乘"
|
|
128
|
+
atlasPma: atlas.atlasPma ?? null,
|
|
129
|
+
// avatarRoot 来自 paths(可配置:--avatar-root > MODELSTAGE_AVATAR_ROOT > <root>/avatars)
|
|
130
|
+
iconUrl: resolveIconUrl(root, relPath, assetKey, new Set(dirFiles.map((f) => f.name)), deps.getNameTable, { avatarRoot: deps.avatarRoot }),
|
|
131
|
+
animations: [],
|
|
132
|
+
skins: [],
|
|
133
|
+
boneCount: 0,
|
|
134
|
+
slotCount: 0,
|
|
135
|
+
importedAt: Date.now(),
|
|
136
|
+
source: 'user',
|
|
137
|
+
localRoot: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 建全根文件索引(文件名小写 → 文件记录),供跨目录查找图集页。
|
|
143
|
+
* 必须先建完再建模型:某模型的第 2 页可能位于尚未遍历到的兄弟目录里;
|
|
144
|
+
* 同名文件保留先遇到的(与原版匹配顺序一致)。
|
|
145
|
+
*/
|
|
146
|
+
export function indexRootFiles(root) {
|
|
147
|
+
const fileIndex = new Map();
|
|
148
|
+
(function indexDir(absDir, relPath) {
|
|
149
|
+
let entries;
|
|
150
|
+
try {
|
|
151
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
152
|
+
} catch {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
for (const e of entries) {
|
|
156
|
+
if (e.name.startsWith('.')) continue;
|
|
157
|
+
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
|
+
if (e.isDirectory()) {
|
|
166
|
+
indexDir(full, childRel);
|
|
167
|
+
continue;
|
|
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
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
})(root.path, '');
|
|
181
|
+
return fileIndex;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 读一个目录的文件与子目录清单(文件附带版本/assetKey/PMA 探测结果)。 */
|
|
185
|
+
export function collectDirEntries(absDir, relPath, deps) {
|
|
186
|
+
let entries;
|
|
187
|
+
try {
|
|
188
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
189
|
+
} catch {
|
|
190
|
+
return { files: [], dirs: [] };
|
|
191
|
+
}
|
|
192
|
+
const files = [];
|
|
193
|
+
const dirs = [];
|
|
194
|
+
for (const e of entries) {
|
|
195
|
+
if (e.name.startsWith('.')) continue;
|
|
196
|
+
const full = path.join(absDir, e.name);
|
|
197
|
+
let st;
|
|
198
|
+
try {
|
|
199
|
+
st = fs.statSync(full);
|
|
200
|
+
} catch {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (e.isDirectory()) {
|
|
204
|
+
dirs.push({ name: e.name, full });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
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
|
+
}
|
|
221
|
+
return { files, dirs };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 由目录内文件按 base name 分组,构造该目录下的模型列表。 */
|
|
225
|
+
export function groupModelsInDir(root, relPath, files, fileIndex, deps) {
|
|
226
|
+
const models = [];
|
|
227
|
+
const groups = new Map();
|
|
228
|
+
for (const f of files) {
|
|
229
|
+
const base = f.name.replace(/\.[^.]+$/, '');
|
|
230
|
+
if (!groups.has(base)) groups.set(base, {});
|
|
231
|
+
groups.get(base)[f.ext] = f;
|
|
232
|
+
}
|
|
233
|
+
for (const [base, g] of groups) {
|
|
234
|
+
// 同名骨架优先取 .skel:二进制体积约为 .json 的一半,且是运行时标准格式
|
|
235
|
+
const skel = g['.skel'] || g['.json'];
|
|
236
|
+
const atlas = g['.atlas'];
|
|
237
|
+
// 纯 png 目录(缺骨架或图集)不构成可加载模型,跳过
|
|
238
|
+
if (!skel || !atlas) continue;
|
|
239
|
+
models.push(buildModelServer(root, relPath, base, skel, atlas, files, fileIndex, deps));
|
|
240
|
+
}
|
|
241
|
+
return models;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 递归扫描一个资源根,收集所有可加载模型。
|
|
246
|
+
* 不同资源根层级不统一(BD2 两层、Mod 三层含 char/npc 分类层),
|
|
247
|
+
* 一律递归到「含骨架+图集的目录」为止,不做针对层级的特殊分支。
|
|
248
|
+
* 全同步实现:本地磁盘操作,同步遍历比异步更简单,也避免 HTTP 处理函数需要 async。
|
|
249
|
+
*/
|
|
250
|
+
export function scanRootServer(root, deps) {
|
|
251
|
+
const models = [];
|
|
252
|
+
const fileIndex = indexRootFiles(root);
|
|
253
|
+
|
|
254
|
+
function walk(absDir, relPath) {
|
|
255
|
+
const { files, dirs } = collectDirEntries(absDir, relPath, deps);
|
|
256
|
+
models.push(...groupModelsInDir(root, relPath, files, fileIndex, deps));
|
|
257
|
+
for (const d of dirs) {
|
|
258
|
+
walk(d.full, relPath ? `${relPath}/${d.name}` : d.name);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
walk(root.path, '');
|
|
263
|
+
return models;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Spine 清单文件路径(实现在 manifest-cache 里,与 Live2D 清单同一目录)。 */
|
|
267
|
+
export function spineFile(configFile, rootId) {
|
|
268
|
+
return spineManifestFile(configFile, rootId);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 失效清单(不传 rootId 则全部)。两套清单一并处理,实现在 manifest-cache。 */
|
|
272
|
+
export function invalidateCaches(configFile, rootId) {
|
|
273
|
+
invalidateManifests(configFile, rootId);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 取模型列表(优先读落盘清单,缺失或 force 时才扫描)。同步实现。 */
|
|
277
|
+
export function getSpineModels(configFile, root, force, deps) {
|
|
278
|
+
const f = spineFile(configFile, root.id);
|
|
279
|
+
|
|
280
|
+
if (!force && fs.existsSync(f)) {
|
|
281
|
+
try {
|
|
282
|
+
const j = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
283
|
+
if (j && Array.isArray(j.models)) {
|
|
284
|
+
// 两道判据,缺一不可:
|
|
285
|
+
// ① 生成者指纹 —— 解析代码变了就该重建(dirStamps 只感知目录增删,感知不到代码改动)
|
|
286
|
+
// ② 目录签名 —— 资源文件增删后重建,不必等 ?refresh=1
|
|
287
|
+
const gen = generatorFingerprint();
|
|
288
|
+
if (j.generator !== gen) {
|
|
289
|
+
log.info('解析器已变化,清单失效重建', { root: root.id, from: j.generator || null, to: gen });
|
|
290
|
+
} else if (signatureMatches(j.dirStamps, root.path)) {
|
|
291
|
+
return { models: j.models, cached: true, ts: j.ts || Date.now() };
|
|
292
|
+
} else {
|
|
293
|
+
log.info('目录已变化,清单失效重建', { root: root.id });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch (e) {
|
|
297
|
+
log.warn('清单读取失败,将重新扫描', { file: f, err: e.message });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const models = scanRootServer(root, deps);
|
|
302
|
+
const payload = {
|
|
303
|
+
rootId: root.id,
|
|
304
|
+
rootName: root.name,
|
|
305
|
+
rootPath: root.path,
|
|
306
|
+
ts: Date.now(),
|
|
307
|
+
count: models.length,
|
|
308
|
+
// 生成时的目录签名:下次读清单先比对它,目录增删即自动重建(详见 manifest-stamp.mjs)
|
|
309
|
+
dirStamps: dirSignature(root.path),
|
|
310
|
+
// 生成时的解析器指纹:解析代码一改,这份清单自动失效(无需手工删文件)
|
|
311
|
+
generator: generatorFingerprint(),
|
|
312
|
+
models,
|
|
313
|
+
};
|
|
314
|
+
// 原子写入,避免写到一半被读到损坏内容
|
|
315
|
+
ensureManifestDir(configFile); // 清单目录(config/models/)可能还不存在:首次运行或目录被清空
|
|
316
|
+
fs.writeFileSync(`${f}.tmp`, JSON.stringify(payload), 'utf8');
|
|
317
|
+
fs.renameSync(`${f}.tmp`, f);
|
|
318
|
+
log.info('已生成清单', { file: path.basename(f), count: models.length });
|
|
319
|
+
return { models, cached: false, ts: payload.ts };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 绑定型适配器:把 configFile 与探测函数预先固定,调用方只需 (root, force)。
|
|
324
|
+
* @param {{configFile: string}} p
|
|
325
|
+
* @param {object} deps 探测与名表函数
|
|
326
|
+
*/
|
|
327
|
+
export function createSpineModels(p, deps) {
|
|
328
|
+
return {
|
|
329
|
+
getSpineModels: (root, force) => getSpineModels(p.configFile, root, force, deps),
|
|
330
|
+
spineFile: (rootId) => spineFile(p.configFile, rootId),
|
|
331
|
+
invalidateCaches: (rootId) => invalidateCaches(p.configFile, rootId),
|
|
332
|
+
buildModelServer,
|
|
333
|
+
scanRootServer: (root) => scanRootServer(root, deps),
|
|
334
|
+
resolveIconUrl,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// tree.mjs —— 目录树列举(/api/tree 的数据层)
|
|
2
|
+
//
|
|
3
|
+
// 响应对象结构与键序与旧实现保持一致(便于逐字节比对)。
|
|
4
|
+
// · **不再按资源根类型分流**:同一个根里可以既有普通模型目录、也有容器单文件;
|
|
5
|
+
// · 普通文件按扩展名识别(Spine 三件套附版本/assetKey);
|
|
6
|
+
// · 无扩展名或未知扩展名的文件才读 8 字节头判断是不是容器(UnityFS),命中挂 `container` 标记,
|
|
7
|
+
// 由发现层(roster)决定它是不是一个可装载条目。
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { SPINE_EXT } from '../assets/probe.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 已知的「普通文件」扩展名:命中这些就不必探容器魔数(省掉打开文件的代价)。
|
|
14
|
+
*
|
|
15
|
+
* 导出给 catalog 的容器列举复用 —— 这份集合若在两处各写一份必然漂移:
|
|
16
|
+
* 一处加了新扩展名、另一处没加,表现是「某个根能列出容器、另一个根列不出」,
|
|
17
|
+
* 两边都不报错,最难查。这里是唯一真相。
|
|
18
|
+
*/
|
|
19
|
+
export const KNOWN_FILE_EXT = new Set([
|
|
20
|
+
'.json', '.png', '.jpg', '.jpeg', '.webp', '.gif', '.txt', '.md', '.bin', '.skel', '.atlas', '.ogg', '.mp3', '.wav',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/** 读目录条目;失败返回 null(由调用方转 500) */
|
|
24
|
+
function readEntries(abs) {
|
|
25
|
+
try {
|
|
26
|
+
return fs.readdirSync(abs, { withFileTypes: true });
|
|
27
|
+
} catch (e) {
|
|
28
|
+
return { error: e };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 通用列举:目录项 + 文件项(**不假设根的内容类型**)。
|
|
34
|
+
*
|
|
35
|
+
* 为什么无扩展名/未知扩展名的文件才探魔数:读头是有代价的(打开文件),
|
|
36
|
+
* 而绝大多数“普通模型文件”靠扩展名就能判定;只对剩下的少数做 8 字节探测,
|
|
37
|
+
* 既有“混合根”能力,也不会把每个文件都打开一遍。
|
|
38
|
+
*
|
|
39
|
+
* @param {object} opts
|
|
40
|
+
* @param {boolean} opts.containersAvailable 是否有启用的插件能读容器(没有则只计数不列出)
|
|
41
|
+
*/
|
|
42
|
+
function collectEntries(abs, relPosix, entries, probe, plugins, containersAvailable) {
|
|
43
|
+
const dirs = [];
|
|
44
|
+
const files = [];
|
|
45
|
+
let hidden = 0;
|
|
46
|
+
for (const e of entries) {
|
|
47
|
+
if (e.name.startsWith('.')) continue;
|
|
48
|
+
const full = path.join(abs, e.name);
|
|
49
|
+
let st;
|
|
50
|
+
try {
|
|
51
|
+
st = fs.statSync(full);
|
|
52
|
+
} catch {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (e.isDirectory()) {
|
|
56
|
+
dirs.push({ name: e.name, type: 'dir' });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const ext = path.extname(e.name).toLowerCase();
|
|
60
|
+
const item = { name: e.name, type: 'file', ext, size: st.size };
|
|
61
|
+
|
|
62
|
+
if (SPINE_EXT.has(ext)) {
|
|
63
|
+
// Spine 三件套:只读必要的少量头部信息
|
|
64
|
+
item.isSpine = true;
|
|
65
|
+
item.spineVersion = ext === '.skel' ? probe.detectSkelVersion(full) : null;
|
|
66
|
+
item.assetKey = ext === '.atlas' ? probe.detectAtlasKey(full) : null;
|
|
67
|
+
} else if (!KNOWN_FILE_EXT.has(ext)) {
|
|
68
|
+
// 未知/无扩展名:可能是容器单文件(资源包本质就是打包来的模型)
|
|
69
|
+
const container = plugins.detectBundleContainer(full);
|
|
70
|
+
if (container) {
|
|
71
|
+
if (!containersAvailable) { hidden += 1; continue; }
|
|
72
|
+
item.container = container;
|
|
73
|
+
item.rel = relPosix ? `${relPosix}/${e.name}` : e.name;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
files.push(item);
|
|
77
|
+
}
|
|
78
|
+
return { dirs, files, hidden };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 列某个资源根下的目录(单一列举路径:不再按根类型分流)。
|
|
83
|
+
* @returns {{status: number, body: object}}
|
|
84
|
+
*/
|
|
85
|
+
export function listTree(deps, rootId, rel) {
|
|
86
|
+
const { config, plugins, probe } = deps;
|
|
87
|
+
const cfg = config.readConfig().find((r) => r.id === rootId);
|
|
88
|
+
if (!cfg) return { status: 400, body: { error: `未找到资源根: ${rootId}` } };
|
|
89
|
+
const abs = probe.resolveSafe(cfg.path, rel);
|
|
90
|
+
if (!abs) return { status: 403, body: { error: '路径越界' } };
|
|
91
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
|
|
92
|
+
return { status: 404, body: { error: '目录不存在', path: rel } };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const entries = readEntries(abs);
|
|
96
|
+
if (entries && entries.error) return { status: 500, body: { error: entries.error.message } };
|
|
97
|
+
|
|
98
|
+
// 有没有启用的插件能读容器:决定"容器单文件"要不要列出来(没有插件就列了也没用)
|
|
99
|
+
const containersAvailable = plugins.enabledPluginKinds().has('bundle');
|
|
100
|
+
const relPosix = rel.split(path.sep).join('/').replace(/^\/+|\/+$/g, '');
|
|
101
|
+
const collected = collectEntries(abs, relPosix, entries, probe, plugins, containersAvailable);
|
|
102
|
+
|
|
103
|
+
const dirs = collected.dirs.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
|
104
|
+
const files = collected.files.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
|
105
|
+
|
|
106
|
+
// 容器单文件被插件可用性挡掉时把原因带回(可探测,不静默)
|
|
107
|
+
if (collected.hidden) {
|
|
108
|
+
return {
|
|
109
|
+
status: 200,
|
|
110
|
+
body: {
|
|
111
|
+
root: rootId,
|
|
112
|
+
path: rel,
|
|
113
|
+
dirs,
|
|
114
|
+
files,
|
|
115
|
+
hiddenByPlugin: collected.hidden,
|
|
116
|
+
note: `本根有 ${collected.hidden} 个容器单文件被隐藏:没有启用的插件声明 fileKind 'bundle'(见 config/plugins.json)`,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return { status: 200, body: { root: rootId, path: rel, dirs, files } };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 绑定型适配器 */
|
|
124
|
+
export function createTree(deps) {
|
|
125
|
+
return { listTree: (rootId, rel) => listTree(deps, rootId, rel) };
|
|
126
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// http/atomic-json.mjs —— JSON 落盘的原子写法 + 串行队列 + 备份轮转
|
|
2
|
+
//
|
|
3
|
+
// ⚠️ 本项目还有另一处**非原子**的 JSON 写(`http/backgrounds.mjs` 的背景图索引;
|
|
4
|
+
// 以及 config 的写盘路径),尚未迁过来。**新增写盘一律用本模块**,
|
|
5
|
+
// 不要再手写 `writeFileSync` + `rename` —— 少一步 fsync 就可能出现
|
|
6
|
+
// "rename 成功但内容还是空洞"(断电/崩溃时 rename 先落盘、数据块没落盘)。
|
|
7
|
+
//
|
|
8
|
+
// 为什么需要 fsync:rename 的原子性只保证"看到的是旧文件或新文件",
|
|
9
|
+
// 不保证新文件的内容已经落盘。崩溃窗口里 rename 已生效而数据块仍在页缓存 ⇒ 得到 0 字节文件。
|
|
10
|
+
// 顺序必须是:写 tmp → fsync(tmp) → rename(tmp→target)。(目录项本身要不要 fsync,见下说明)
|
|
11
|
+
//
|
|
12
|
+
// 零依赖:只用 node:fs。
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { copyFilePortable } from '../core/fs-portable.mjs';
|
|
17
|
+
|
|
18
|
+
/** 新建文件的默认权限(其他可读;项目 check-perms 前置检查要求 go+r) */
|
|
19
|
+
const FILE_MODE = 0o644;
|
|
20
|
+
|
|
21
|
+
/** 新建目录的默认权限(其他可进出) */
|
|
22
|
+
const DIR_MODE = 0o755;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 确保目录存在(首次使用时创建,权限 0755)。
|
|
26
|
+
* @param {string} dir
|
|
27
|
+
*/
|
|
28
|
+
export function ensureDir(dir) {
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true, mode: DIR_MODE });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 原子写 JSON:写临时文件 → fsync → rename 覆盖。
|
|
34
|
+
*
|
|
35
|
+
* 目录项本身不额外 fsync:目标场景是"进程崩溃/被 kill",此时 rename 已由内核记录;
|
|
36
|
+
* 只有"整机掉电"才需要连目录一起 fsync,而本项目是家用 NAS 上的预览器,
|
|
37
|
+
* 不为此引入 `open(dir)` 平台差异(Windows 上目录 fsync 会直接失败)。
|
|
38
|
+
*
|
|
39
|
+
* @param {string} file 目标文件绝对路径
|
|
40
|
+
* @param {any} value 要写入的 JS 值(JSON 序列化,2 空格缩进)
|
|
41
|
+
* @returns {{bytes:number}} 写入字节数(便于调用方做"内容是否变化"的判断与日志)
|
|
42
|
+
*/
|
|
43
|
+
export function writeJsonAtomic(file, value) {
|
|
44
|
+
ensureDir(path.dirname(file));
|
|
45
|
+
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
46
|
+
const tmp = `${file}.tmp`;
|
|
47
|
+
const fd = fs.openSync(tmp, 'w', FILE_MODE);
|
|
48
|
+
try {
|
|
49
|
+
fs.writeFileSync(fd, text, 'utf8');
|
|
50
|
+
fs.fsyncSync(fd); // ← 关键:数据落盘后再 rename
|
|
51
|
+
} finally {
|
|
52
|
+
fs.closeSync(fd);
|
|
53
|
+
}
|
|
54
|
+
fs.renameSync(tmp, file);
|
|
55
|
+
// rename 之后补一次 chmod:某些文件系统/umask 会让 openSync 的 mode 被削弱
|
|
56
|
+
try { fs.chmodSync(file, FILE_MODE); } catch { /* 已存在且权限正确时可能失败,忽略 */ }
|
|
57
|
+
return { bytes: Buffer.byteLength(text) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 原子写**任意字节**(图片等二进制):写 tmp → fsync → rename,与 writeJsonAtomic 同一套顺序。
|
|
62
|
+
*
|
|
63
|
+
* 为什么图片也要 fsync:上传后"索引里有这条、文件却是 0 字节"的窗口确实存在,
|
|
64
|
+
* 而取文件路径只校验 `statSync(abs).isFile()` —— 0 字节文件照样算存在,
|
|
65
|
+
* 没有任何自愈会把这条剔除 ⇒ 用户长期看到破图。图片不大(上限几 MB),一次 fsync 的代价可接受。
|
|
66
|
+
* @param {string} file 目标文件绝对路径
|
|
67
|
+
* @param {Uint8Array|Buffer} bytes 内容
|
|
68
|
+
* @returns {{bytes:number}}
|
|
69
|
+
*/
|
|
70
|
+
export function writeFileAtomic(file, bytes) {
|
|
71
|
+
ensureDir(path.dirname(file));
|
|
72
|
+
const tmp = `${file}.tmp`;
|
|
73
|
+
const fd = fs.openSync(tmp, 'w', FILE_MODE);
|
|
74
|
+
try {
|
|
75
|
+
fs.writeFileSync(fd, bytes);
|
|
76
|
+
fs.fsyncSync(fd); // ← 数据落盘后再 rename(顺序与 writeJsonAtomic 一致)
|
|
77
|
+
} finally {
|
|
78
|
+
fs.closeSync(fd);
|
|
79
|
+
}
|
|
80
|
+
fs.renameSync(tmp, file);
|
|
81
|
+
try { fs.chmodSync(file, FILE_MODE); } catch { /* 已正确时可能失败,忽略 */ }
|
|
82
|
+
return { bytes: bytes.length };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 串行队列:把"读-改-写"整段排进队列,避免两次并发修改互相覆盖。
|
|
87
|
+
*
|
|
88
|
+
* 为什么不是锁:单进程单事件循环下,队列已经足够(并且没有任何"进程崩了锁不释放"的风险)。
|
|
89
|
+
* 跨进程/多实例共享同一目录**不保证**顺序 —— 见 docs/契约/数据与接口.md 的说明。
|
|
90
|
+
* @returns {(fn: () => any) => Promise<any>}
|
|
91
|
+
*/
|
|
92
|
+
export function createSerialQueue() {
|
|
93
|
+
let chain = Promise.resolve();
|
|
94
|
+
return function enqueue(fn) {
|
|
95
|
+
const next = chain.then(fn, fn);
|
|
96
|
+
// 队列自身不能被失败拖死:吞掉拒绝,只把结果(或异常)交给调用方
|
|
97
|
+
chain = next.then(() => undefined, () => undefined);
|
|
98
|
+
return next;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 备份轮转:同名前缀的备份文件只保留最近 keep 份,更早的删除。
|
|
104
|
+
*
|
|
105
|
+
* 为什么必须轮转:`reset` 与"损坏自愈"都会留备份,用户多点几次就会堆出几十个文件,
|
|
106
|
+
* 而这些都是**运行数据**(不入库),堆着只会占空间、还会让 `ls` 失去可读性。
|
|
107
|
+
*
|
|
108
|
+
* @param {string} dir 备份所在目录
|
|
109
|
+
* @param {string} prefix 文件名前缀(如 'solids.bak-')
|
|
110
|
+
* @param {number} keep 保留份数(默认 5)
|
|
111
|
+
* @returns {{kept:string[], removed:string[]}}
|
|
112
|
+
*/
|
|
113
|
+
export function rotateBackups(dir, prefix, keep = 5) {
|
|
114
|
+
let names = [];
|
|
115
|
+
try { names = fs.readdirSync(dir); } catch { return { kept: [], removed: [] }; }
|
|
116
|
+
// 文件名形如 solids.bak-1750000000000.json:按前缀筛,按名字倒序(时间戳是定长的 ⇒ 字典序=时间序)
|
|
117
|
+
const hits = names.filter((n) => n.startsWith(prefix)).sort().reverse();
|
|
118
|
+
const kept = hits.slice(0, keep);
|
|
119
|
+
const removed = [];
|
|
120
|
+
for (const n of hits.slice(keep)) {
|
|
121
|
+
try { fs.rmSync(path.join(dir, n), { force: true }); removed.push(n); } catch { /* 删不掉不影响主流程 */ }
|
|
122
|
+
}
|
|
123
|
+
return { kept, removed };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 备份一份文件(用于"损坏文件留证"与"reset 前存档")。
|
|
128
|
+
* @param {string} src 源文件
|
|
129
|
+
* @param {string} dir 备份目录
|
|
130
|
+
* @param {string} prefix 备份名前缀(含结尾的 '-',如 'solids.corrupt-')
|
|
131
|
+
* @param {number} at epoch ms(同毫秒多次备份时自动加序号,避免互相覆盖)
|
|
132
|
+
* @param {number} keep 轮转保留份数
|
|
133
|
+
* @returns {string|null} 备份文件名(失败返回 null)
|
|
134
|
+
*/
|
|
135
|
+
export function backupFile(src, dir, prefix, at = Date.now(), keep = 5) {
|
|
136
|
+
let name = `${prefix}${at}.json`;
|
|
137
|
+
try {
|
|
138
|
+
if (fs.existsSync(path.join(dir, name))) name = `${prefix}${at}-${process.pid}.json`;
|
|
139
|
+
copyFilePortable(src, path.join(dir, name));
|
|
140
|
+
rotateBackups(dir, prefix, keep);
|
|
141
|
+
return name;
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|