feat(models): 模型压缩包自动转换 + 默认模型写进源码;首次启动 .env 引导;HOST 默认 0.0.0.0
需求 1:模型上传支持压缩包(MMD 模型包) - common/zip-reader.ts:纯 TS 极简 ZIP 读取器(EOCD/中央目录、STORE+DEFLATE、 ZIP64 大小字段、Shift-JIS/GBK 文件名回退、拒绝 zip-slip 与加密条目) - models/model-convert.ts:解压 → 选主模型(指名字/最浅/最大)→ Assimp 导出 FBX → **assimp info 回读校验**(Assimp 对带蒙皮大骨架模型会写出损坏文件: 实测 714 骨骼 PMX 导出退出码 11、产物回读报 FBX-DOM no Objects dictionary found) → 失败自动回退 GLB;两种都失败则 422 - models/glb-textures.ts:把 Assimp 写成外部 URI 的贴图内嵌进 GLB 的 BIN chunk, 保证模型库里单文件即可加载(否则是白模) - 溯源:压缩包原件存为 model-archive 资产;models 新增 source_asset_id / conversion_note(迁移 1789450000000-AddModelConversionFields,已在真实库上 验证 up/down 可逆) - 默认模型:转换成功且 autoDefault 不为 false → is_primary=1 + published, 并把身份(名称/格式/产物 sha256)写进源码 src/config/default-model.ts; resolveDefaultModelId 增加 DEFAULT_MODEL_ID 与源码种子两步(按 sha256 匹配, 换库仍有效),实测库中有 2 个 VRM 时默认模型仍解析为该 GLB - scripts/import-default-model.mjs:运维 CLI(上传 zip 并写源码 / --write-source-only) - 修复 multipart 中文文件名乱码(upload.util.decodeUploadName) - DTO/契约新增 conversionNote、fileSha256;管理端 accept 增加 .zip 并展示转换来源 需求 2:首次启动 .env 引导 - deploy/init-env.sh:交互式逐项填写 8 个键(回车保留默认、口令不回显不加引号、 端口校验、mysql2 连通性自检且口令只经环境变量传递、只改这 8 个键并保留模板注释、 chmod 600、结尾打码摘要);--force/--check/-y/-i/--skip-db-check 等开关; 非交互(-y、OPENDANCE_NONINTERACTIVE、stdin 关闭)绝不提问、读取带超时 - install.sh 缺 .env 时改为调用引导,失败才 die 需求 3:默认监听 0.0.0.0 - 代码默认值、两份 .env 模板、install.sh、init-env.sh 五处一致,并新增测试守护 (未设置 HOST 时为 0.0.0.0;两份模板都写着 HOST=0.0.0.0) 契约:rest-api / data-models / model-formats → 0.3.0,database → 0.2.0(另两库已同步) 验证:125 项单测通过(7 文件);契约 6 项与 29 条路由一致;typecheck/build 通过; 真实包端到端 HTTP 201 / 0.89s / format=glb / isPrimary=true / published; GLB 1,152,464 字节、1 mesh / 240 nodes / 1 skin(238 joints) / 2 张内嵌贴图; 迁移 revert→run 实测可逆且数据无损恢复
This commit is contained in:
+12
-2
@@ -41,9 +41,19 @@ STORAGE_DIR=/data/storage
|
||||
# 单文件上传上限(字节),默认 512 MiB
|
||||
UPLOAD_MAX_BYTES=536870912
|
||||
|
||||
# ---- 可选的 Assimp(FBX/glTF 动作预处理)----
|
||||
# 未安装时 FBX 动作的预处理会以失败状态返回,不影响 BVH
|
||||
# ---- 可选的 Assimp(FBX/glTF 动作预处理 + 压缩包模型转换)----
|
||||
# 未安装时 FBX 动作的预处理会以失败状态返回,不影响 BVH/VMD;
|
||||
# 但上传 .zip 模型包时必须能调用到它
|
||||
ASSIMP_BIN=assimp
|
||||
# 压缩包转换的目标格式:fbx(优先)| glb(Assimp FBX 导出失败时自动回退)
|
||||
MODEL_CONVERT_FORMAT=fbx
|
||||
# 压缩包里优先选用的 PMX 文件名
|
||||
MODEL_CONVERT_PMX=Hatsune Miku.pmx
|
||||
# 单次转换超时(毫秒)
|
||||
MODEL_CONVERT_TIMEOUT_MS=180000
|
||||
|
||||
# ---- 默认模型(可选)----
|
||||
# DEFAULT_MODEL_ID=1
|
||||
|
||||
# ---- 契约目录(可选)----
|
||||
# 设置后 /health 与 /api/catalog/version 会读取该目录下的 VERSION 文件;
|
||||
|
||||
@@ -114,6 +114,10 @@ export interface Model extends SourceFields {
|
||||
isPrimary: boolean;
|
||||
fileUrl: string | null;
|
||||
fileSize: number | null;
|
||||
/** 压缩包转换而来时的说明 */
|
||||
conversionNote: string | null;
|
||||
/** 模型文件 sha256 */
|
||||
fileSha256: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
status: PublishStatus;
|
||||
createdAt: string;
|
||||
|
||||
@@ -30,7 +30,7 @@ const VrmPreview = lazy(async () => {
|
||||
});
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const ACCEPT_EXT = '.vrm,.glb,.gltf,.fbx';
|
||||
const ACCEPT_EXT = '.vrm,.glb,.gltf,.fbx,.zip';
|
||||
|
||||
interface ModelFilters {
|
||||
q: string;
|
||||
@@ -137,7 +137,12 @@ function ModelUploadModal({
|
||||
{serverError ? <Notice kind="error">{serverError}</Notice> : null}
|
||||
|
||||
<div className="form-grid">
|
||||
<Field label="模型文件 file" required error={errors.file} hint={`支持 ${ACCEPT_EXT},单文件上限 512 MiB`}>
|
||||
<Field
|
||||
label="模型文件 file"
|
||||
required
|
||||
error={errors.file}
|
||||
hint={`支持 ${ACCEPT_EXT},单文件上限 512 MiB;上传 .zip(内含 .pmx 的 MMD 模型包)时服务端会自动解压并转换,转换成功即设为默认模型`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept={ACCEPT_EXT}
|
||||
@@ -572,6 +577,18 @@ export function ModelsPage() {
|
||||
<span className="muted">是否默认</span>
|
||||
<span>{previewModel.isPrimary ? '是' : '否'}</span>
|
||||
</li>
|
||||
{previewModel.conversionNote ? (
|
||||
<li>
|
||||
<span className="muted">转换来源</span>
|
||||
<span className="break">{previewModel.conversionNote}</span>
|
||||
</li>
|
||||
) : null}
|
||||
{previewModel.fileSha256 ? (
|
||||
<li>
|
||||
<span className="muted">文件 sha256</span>
|
||||
<span className="mono break">{previewModel.fileSha256}</span>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="muted small">尚未选择模型。</p>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 导入默认模型:把 MMD 模型压缩包(zip 内含 .pmx)交给服务端转换并入库,
|
||||
* 转换成功后把它设为全局默认模型,并把身份(名称/格式/产物 sha256)
|
||||
* 写进源码 `src/config/default-model.ts`。
|
||||
*
|
||||
* 为什么要有这个脚本:服务进程在 systemd 加固(ProtectSystem=strict)下**不能**
|
||||
* 改写程序目录,写源码这一步必须在沙箱外完成,因此由本脚本(以 root/运维身份运行)
|
||||
* 调 API 之后自己写文件。
|
||||
*
|
||||
* 用法(在 web-server 目录下,需先 `npm run build`):
|
||||
* node scripts/import-default-model.mjs 模型.zip \
|
||||
* --url http://127.0.0.1:3000 \
|
||||
* --name "初音未来 Hatsune Miku" \
|
||||
* --author "Boku Wa Luka" \
|
||||
* --license "MMD 模型使用条款(见压缩包内说明)" \
|
||||
* --source-name "模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip" \
|
||||
* --source-url "本地文件:模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip" \
|
||||
* --source-type other
|
||||
*
|
||||
* # 只根据已入库的模型补写源码(库里的默认模型已知时)
|
||||
* node scripts/import-default-model.mjs --write-source-only --model-id 16
|
||||
*
|
||||
* # 只写源码文件、不调 API(配合 --write-source-only)
|
||||
* node scripts/import-default-model.mjs --write-source-only --model-id 16 --dry-run
|
||||
*/
|
||||
import { createRequire } from 'node:module';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const flag = (name, fallback = undefined) => {
|
||||
const index = args.indexOf(name);
|
||||
return index >= 0 && args[index + 1] && !args[index + 1].startsWith('--')
|
||||
? args[index + 1]
|
||||
: index >= 0
|
||||
? true
|
||||
: fallback;
|
||||
};
|
||||
const has = (name) => args.includes(name);
|
||||
|
||||
const positional = args.find((a) => !a.startsWith('--') && args[args.indexOf(a) - 1]?.startsWith('--') === false);
|
||||
const file = positional;
|
||||
|
||||
const url = String(flag('--url', 'http://127.0.0.1:3000')).replace(/\/$/, '');
|
||||
const writeSourceOnly = has('--write-source-only');
|
||||
const dryRun = has('--dry-run');
|
||||
|
||||
const sourceDir = resolve(process.cwd(), 'src', 'config');
|
||||
const sourceFile = join(sourceDir, 'default-model.ts');
|
||||
|
||||
function loadRenderer() {
|
||||
const distPath = join(process.cwd(), 'dist', 'models', 'default-model-source.js');
|
||||
if (!existsSync(distPath)) {
|
||||
console.error('未找到 dist/models/default-model-source.js,请先执行:npm run build');
|
||||
process.exit(2);
|
||||
}
|
||||
return require(distPath);
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(`${url}${path}`, options);
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = body && typeof body === 'object' ? JSON.stringify(body) : String(body);
|
||||
throw new Error(`${options.method ?? 'GET'} ${path} → HTTP ${response.status}:${detail}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function fetchModel(id) {
|
||||
const model = await api(`/api/models/${id}`);
|
||||
return model;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { renderDefaultModelSource, defaultModelSourcePath } = loadRenderer();
|
||||
const target = defaultModelSourcePath(process.cwd());
|
||||
|
||||
let model;
|
||||
if (writeSourceOnly) {
|
||||
const id = Number(flag('--model-id', 0));
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
console.error('--write-source-only 需要 --model-id <已入库的模型 id>');
|
||||
process.exit(2);
|
||||
}
|
||||
model = await fetchModel(id);
|
||||
console.log(`读取模型 #${model.id}:${model.name}(${model.format})`);
|
||||
} else {
|
||||
if (!file) {
|
||||
console.error(
|
||||
'用法:node scripts/import-default-model.mjs <模型.zip> [--url ...] [--name ...] [--author ...] [--license ...]',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const buffer = readFileSync(resolve(file));
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([buffer]), basename(file));
|
||||
form.append('name', String(flag('--name', basename(file).replace(/\.[^.]+$/, ''))));
|
||||
form.append('sourceType', String(flag('--source-type', 'other')));
|
||||
form.append('sourceName', String(flag('--source-name', basename(file))));
|
||||
form.append('sourceUrl', String(flag('--source-url', `本地文件:${basename(file)}`)));
|
||||
form.append('license', String(flag('--license', '待补充:MMD 模型使用条款(见压缩包内说明)')));
|
||||
form.append('author', String(flag('--author', '待补充')));
|
||||
form.append('autoDefault', 'true');
|
||||
|
||||
console.log(`上传并转换:${basename(file)}(${(buffer.length / 1024 / 1024).toFixed(2)} MiB)→ ${url}`);
|
||||
model = await api('/api/models', { method: 'POST', body: form });
|
||||
console.log(`入库成功:#${model.id} ${model.name}(格式 ${model.format})`);
|
||||
console.log(`转换说明:${model.conversionNote ?? '(无)'}`);
|
||||
}
|
||||
|
||||
if (!model.fileSha256) {
|
||||
console.error('该模型没有 fileSha256(文件缺失?),无法写入默认模型种子');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const seed = {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
format: model.format,
|
||||
sha256: model.fileSha256,
|
||||
convertedFrom: (model.conversionNote ?? '').split(',')[0] || null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const content = renderDefaultModelSource(seed);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`--dry-run:将写入 ${target}\n`);
|
||||
console.log(content);
|
||||
return;
|
||||
}
|
||||
writeFileSync(target, content, 'utf8');
|
||||
console.log(`已把默认模型写进源码:${target}`);
|
||||
console.log('建议接着执行:npm run typecheck && git add src/config/default-model.ts');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -67,6 +67,8 @@ export enum AssetKind {
|
||||
SongCover = 'song-cover',
|
||||
Motion = 'motion',
|
||||
Model = 'model',
|
||||
/** 上传的模型压缩包(zip)原件,仅用于溯源;入库的是转换后的模型文件 */
|
||||
ModelArchive = 'model-archive',
|
||||
Thumbnail = 'thumbnail',
|
||||
Keypoints = 'keypoints',
|
||||
}
|
||||
|
||||
@@ -9,10 +9,25 @@ export interface UploadedFileLike {
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修正 multipart 文件名编码。
|
||||
*
|
||||
* busboy/multer 按 latin1 解出 `originalname`,含中文/日文/emoji 的文件名会变成
|
||||
* 乱码(例如 `模型_初音…zip` → `模å_åé³…`)。这里用 latin1 还原原始字节再按
|
||||
* UTF-8 解一次;只有当结果不含替换字符、且原文确实含高位字节时才替换,
|
||||
* 避免破坏真正的 latin1 文件名。
|
||||
*/
|
||||
export function decodeUploadName(rawName: string): string {
|
||||
if (![...rawName].some((ch) => ch.charCodeAt(0) > 0x7f)) return rawName;
|
||||
const restored = Buffer.from(rawName, 'latin1').toString('utf8');
|
||||
if (restored.includes('\uFFFD')) return rawName;
|
||||
return restored;
|
||||
}
|
||||
|
||||
export function requireUploadedFile(file: UploadedFileLike | undefined, label = 'file'): UploadedFileLike {
|
||||
if (!file) throw new BadRequestException(`缺少上传文件字段 ${label}`);
|
||||
if (!file.path) throw new BadRequestException('上传文件未落盘');
|
||||
return file;
|
||||
return { ...file, originalname: decodeUploadName(file.originalname) };
|
||||
}
|
||||
|
||||
/** 按扩展名白名单解析格式;不在白名单内一律 422 */
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { TextDecoder } from 'node:util';
|
||||
import { inflateRawSync } from 'node:zlib';
|
||||
|
||||
/**
|
||||
* 极简 ZIP 读取器(只读,纯 Node 内置能力)。
|
||||
*
|
||||
* 为什么不用现成库:本项目的约定是"能用内置能力就不加依赖"(BVH、VMD 解析器同理),
|
||||
* 而 MMD 模型压缩包只需要 STORE / DEFLATE 两种最普通的条目,实现成本很低。
|
||||
*
|
||||
* 支持:
|
||||
* - 中央目录解析(EOCD → 条目表),STORE(0) 与 DEFLATE(8)
|
||||
* - ZIP64 的"条目数/大小溢出"字段(部分压缩工具对大包会写)
|
||||
* - 文件名编码:UTF-8 标志位;否则优先 Shift-JIS(Windows 压缩工具的中文/日文常见),
|
||||
* 失败再退 CP437 近似(latin1)
|
||||
*
|
||||
* 明确不支持(遇到时给出清晰错误,而不是产出错数据):加密条目、分卷压缩、
|
||||
* ZIP64 的 64 位偏移(真需要时会报错)。
|
||||
*/
|
||||
|
||||
export interface ZipEntry {
|
||||
/** 压缩包内的相对路径(已把 `\` 归一为 `/`,去除前导 `./`) */
|
||||
path: string;
|
||||
/** 未压缩字节数 */
|
||||
size: number;
|
||||
/** 压缩方式:0=STORE,8=DEFLATE */
|
||||
method: number;
|
||||
/** 读出条目内容 */
|
||||
read(): Buffer;
|
||||
}
|
||||
|
||||
const EOCD_SIGNATURE = 0x06054b50;
|
||||
const EOCD64_LOCATOR_SIGNATURE = 0x07064b50;
|
||||
const EOCD64_SIGNATURE = 0x06064b50;
|
||||
const CENTRAL_SIGNATURE = 0x02014b50;
|
||||
const LOCAL_SIGNATURE = 0x04034b50;
|
||||
|
||||
const decoders = new Map<string, TextDecoder | null>();
|
||||
function decoderFor(encoding: string): TextDecoder | null {
|
||||
if (!decoders.has(encoding)) {
|
||||
try {
|
||||
decoders.set(encoding, new TextDecoder(encoding, { fatal: false }));
|
||||
} catch {
|
||||
decoders.set(encoding, null);
|
||||
}
|
||||
}
|
||||
return decoders.get(encoding) ?? null;
|
||||
}
|
||||
|
||||
/** 半角片假名:Shift-JIS 被误用时会出现,用来判断该不该换编码 */
|
||||
const HALF_WIDTH_KATAKANA = /[\uFF61-\uFF9F]/;
|
||||
|
||||
/** 解压包内文件名:UTF-8 标志位优先,否则试 Shift-JIS(中文/日文压缩包常见) */
|
||||
function decodeEntryName(bytes: Buffer, utf8Flag: boolean): string {
|
||||
if (utf8Flag) return bytes.toString('utf8');
|
||||
const hasHighByte = bytes.some((byte) => byte >= 0x80);
|
||||
if (!hasHighByte) return bytes.toString('latin1');
|
||||
const shiftJis = decoderFor('shift_jis');
|
||||
if (shiftJis) {
|
||||
const decoded = shiftJis.decode(bytes);
|
||||
if (!decoded.includes('\uFFFD') && !HALF_WIDTH_KATAKANA.test(decoded)) return decoded;
|
||||
}
|
||||
const gbk = decoderFor('gbk');
|
||||
if (gbk) {
|
||||
const decoded = gbk.decode(bytes);
|
||||
if (!decoded.includes('\uFFFD')) return decoded;
|
||||
}
|
||||
return bytes.toString('latin1');
|
||||
}
|
||||
|
||||
/** 归一化条目路径,并拒绝绝对路径 / 上跳(zip-slip) */
|
||||
function normalizeEntryPath(raw: string): string {
|
||||
const unified = raw.replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
const parts: string[] = [];
|
||||
for (const segment of unified.split('/')) {
|
||||
if (segment === '' || segment === '.') continue;
|
||||
if (segment === '..') throw new Error(`压缩包内存在上跳路径,已拒绝:${raw}`);
|
||||
parts.push(segment);
|
||||
}
|
||||
if (unified.startsWith('/') || /^[A-Za-z]:/.test(unified)) {
|
||||
throw new Error(`压缩包内存在绝对路径,已拒绝:${raw}`);
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function findEndOfCentralDirectory(buffer: Buffer): number {
|
||||
// EOCD 最少 22 字节,注释最长 65535 字节
|
||||
const minOffset = Math.max(0, buffer.length - 22 - 0xffff);
|
||||
for (let offset = buffer.length - 22; offset >= minOffset; offset -= 1) {
|
||||
if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) return offset;
|
||||
}
|
||||
throw new Error('不是有效的 ZIP:找不到中央目录结束记录(EOCD)');
|
||||
}
|
||||
|
||||
/** 解析 ZIP,返回条目列表(目录条目会被跳过) */
|
||||
export function readZipEntries(buffer: Buffer): ZipEntry[] {
|
||||
if (buffer.length < 22) throw new Error('不是有效的 ZIP:文件过小');
|
||||
|
||||
const eocd = findEndOfCentralDirectory(buffer);
|
||||
const entryCount = buffer.readUInt16LE(eocd + 10);
|
||||
let centralOffset = buffer.readUInt32LE(eocd + 16);
|
||||
let centralSize = buffer.readUInt32LE(eocd + 12);
|
||||
|
||||
// ZIP64:条目数或偏移写满时,真实值在 ZIP64 EOCD 里
|
||||
const needsZip64 = entryCount === 0xffff || centralOffset === 0xffffffff || centralSize === 0xffffffff;
|
||||
if (needsZip64) {
|
||||
const locator = eocd - 20;
|
||||
if (locator < 0 || buffer.readUInt32LE(locator) !== EOCD64_LOCATOR_SIGNATURE) {
|
||||
throw new Error('ZIP64 压缩包缺少 EOCD64 locator,无法解析');
|
||||
}
|
||||
const eocd64 = Number(buffer.readBigUInt64LE(locator + 8));
|
||||
if (eocd64 < 0 || buffer.readUInt32LE(eocd64) !== EOCD64_SIGNATURE) {
|
||||
throw new Error('ZIP64 EOCD 记录不合法');
|
||||
}
|
||||
const realCount = Number(buffer.readBigUInt64LE(eocd64 + 32));
|
||||
centralSize = Number(buffer.readBigUInt64LE(eocd64 + 40));
|
||||
centralOffset = Number(buffer.readBigUInt64LE(eocd64 + 48));
|
||||
return readCentralDirectory(buffer, centralOffset, centralSize, realCount);
|
||||
}
|
||||
|
||||
return readCentralDirectory(buffer, centralOffset, centralSize, entryCount);
|
||||
}
|
||||
|
||||
function readCentralDirectory(
|
||||
buffer: Buffer,
|
||||
centralOffset: number,
|
||||
centralSize: number,
|
||||
entryCount: number,
|
||||
): ZipEntry[] {
|
||||
if (centralOffset + centralSize > buffer.length) {
|
||||
throw new Error('ZIP 中央目录越界,文件可能已损坏或被截断');
|
||||
}
|
||||
const entries: ZipEntry[] = [];
|
||||
let offset = centralOffset;
|
||||
|
||||
for (let i = 0; i < entryCount; i += 1) {
|
||||
if (offset + 46 > buffer.length) throw new Error('ZIP 中央目录条目不完整');
|
||||
if (buffer.readUInt32LE(offset) !== CENTRAL_SIGNATURE) {
|
||||
throw new Error(`ZIP 中央目录第 ${i + 1} 条记录签名不正确`);
|
||||
}
|
||||
const flags = buffer.readUInt16LE(offset + 8);
|
||||
const method = buffer.readUInt16LE(offset + 10);
|
||||
let compressedSize = buffer.readUInt32LE(offset + 20);
|
||||
const uncompressedSize = buffer.readUInt32LE(offset + 24);
|
||||
const nameLength = buffer.readUInt16LE(offset + 28);
|
||||
const extraLength = buffer.readUInt16LE(offset + 30);
|
||||
const commentLength = buffer.readUInt16LE(offset + 32);
|
||||
let localOffset = buffer.readUInt32LE(offset + 42);
|
||||
const nameBytes = buffer.subarray(offset + 46, offset + 46 + nameLength);
|
||||
const rawName = decodeEntryName(nameBytes, (flags & 0x0800) !== 0);
|
||||
|
||||
if (flags & 0x0001) throw new Error(`压缩包内条目已加密,无法读取:${rawName}`);
|
||||
|
||||
// ZIP64 扩展字段:本条目的大小/偏移写满时,真实值在这里
|
||||
const extraStart = offset + 46 + nameLength;
|
||||
let cursor = extraStart;
|
||||
while (cursor + 4 <= extraStart + extraLength) {
|
||||
const fieldId = buffer.readUInt16LE(cursor);
|
||||
const fieldSize = buffer.readUInt16LE(cursor + 2);
|
||||
if (fieldId === 0x0001) {
|
||||
let p = cursor + 4;
|
||||
if (uncompressedSize === 0xffffffff) p += 8;
|
||||
if (compressedSize === 0xffffffff) {
|
||||
compressedSize = Number(buffer.readBigUInt64LE(p));
|
||||
p += 8;
|
||||
}
|
||||
if (localOffset === 0xffffffff) localOffset = Number(buffer.readBigUInt64LE(p));
|
||||
break;
|
||||
}
|
||||
cursor += 4 + fieldSize;
|
||||
}
|
||||
|
||||
const isDirectory = rawName.endsWith('/') || rawName.endsWith('\\');
|
||||
if (!isDirectory) {
|
||||
entries.push({
|
||||
path: normalizeEntryPath(rawName),
|
||||
size: uncompressedSize === 0xffffffff ? compressedSize : uncompressedSize,
|
||||
method,
|
||||
read: () => readEntry(buffer, localOffset, compressedSize, method, rawName),
|
||||
});
|
||||
}
|
||||
|
||||
offset += 46 + nameLength + extraLength + commentLength;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function readEntry(
|
||||
buffer: Buffer,
|
||||
localOffset: number,
|
||||
compressedSize: number,
|
||||
method: number,
|
||||
name: string,
|
||||
): Buffer {
|
||||
if (localOffset + 30 > buffer.length) throw new Error(`ZIP 局部头越界:${name}`);
|
||||
if (buffer.readUInt32LE(localOffset) !== LOCAL_SIGNATURE) {
|
||||
throw new Error(`ZIP 局部头签名不正确:${name}`);
|
||||
}
|
||||
const nameLength = buffer.readUInt16LE(localOffset + 26);
|
||||
const extraLength = buffer.readUInt16LE(localOffset + 28);
|
||||
const dataStart = localOffset + 30 + nameLength + extraLength;
|
||||
const dataEnd = dataStart + compressedSize;
|
||||
if (dataEnd > buffer.length) throw new Error(`ZIP 条目数据越界(文件被截断):${name}`);
|
||||
const raw = buffer.subarray(dataStart, dataEnd);
|
||||
|
||||
if (method === 0) return Buffer.from(raw);
|
||||
if (method === 8) return inflateRawSync(raw);
|
||||
throw new Error(`不支持的 ZIP 压缩方式 ${method}:${name}`);
|
||||
}
|
||||
|
||||
/** 从条目列表里按路径取内容(大小写不敏感,兼容 Windows 压缩包) */
|
||||
export function findZipEntry(entries: readonly ZipEntry[], path: string): ZipEntry | undefined {
|
||||
const target = path.replace(/\\/g, '/').toLowerCase();
|
||||
return entries.find((entry) => entry.path.toLowerCase() === target);
|
||||
}
|
||||
@@ -80,6 +80,37 @@ export class AppConfig {
|
||||
return this.config.get<string>('assimpBin') ?? 'assimp';
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩包转换的目标格式。
|
||||
* `fbx` 优先(按需求),但 Assimp 的 FBX 导出器对带蒙皮的大骨架模型会写坏文件,
|
||||
* 因此服务端在导出后回读校验,失败会自动回退 GLB 并记录 conversionNote。
|
||||
*/
|
||||
get modelConvertFormat(): 'fbx' | 'glb' {
|
||||
return (this.config.get<string>('modelConvertFormat') ?? 'fbx') === 'glb' ? 'glb' : 'fbx';
|
||||
}
|
||||
|
||||
/** 压缩包里优先选用的 PMX 文件名(大小写不敏感);找不到时按"目录最浅、文件最大"挑 */
|
||||
get modelConvertPmx(): string {
|
||||
return this.config.get<string>('modelConvertPmx') ?? 'Hatsune Miku.pmx';
|
||||
}
|
||||
|
||||
/** 单次模型转换超时(毫秒) */
|
||||
get modelConvertTimeoutMs(): number {
|
||||
return this.config.get<number>('modelConvertTimeoutMs') ?? 180_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显式指定的默认模型 id(DEFAULT_MODEL_ID)。
|
||||
* 未设置时按 `src/config/default-model.ts` 里写死的 DEFAULT_MODEL_SEED 匹配,
|
||||
* 最后才回落到契约 data-models.md §4.1 的 VRM 优先规则。
|
||||
*/
|
||||
get defaultModelId(): number | null {
|
||||
const raw = this.config.get<string>('defaultModelId');
|
||||
if (raw === undefined || raw === null || raw === '') return null;
|
||||
const parsed = Number(raw);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
get contractsDir(): string | undefined {
|
||||
return this.config.get<string>('contractsDir') || undefined;
|
||||
}
|
||||
@@ -160,6 +191,10 @@ export const configuration = () => {
|
||||
adminDir: process.env.ADMIN_DIR ?? resolve(process.cwd(), '..', 'admin'),
|
||||
uploadMaxBytes: Number(process.env.UPLOAD_MAX_BYTES ?? 512 * 1024 * 1024),
|
||||
assimpBin: process.env.ASSIMP_BIN ?? 'assimp',
|
||||
modelConvertFormat: process.env.MODEL_CONVERT_FORMAT ?? 'fbx',
|
||||
modelConvertPmx: process.env.MODEL_CONVERT_PMX ?? 'Hatsune Miku.pmx',
|
||||
modelConvertTimeoutMs: Number(process.env.MODEL_CONVERT_TIMEOUT_MS ?? 180_000),
|
||||
defaultModelId: process.env.DEFAULT_MODEL_ID ?? '',
|
||||
contractsDir: process.env.CONTRACTS_DIR ?? '',
|
||||
db: {
|
||||
host: process.env.DB_HOST ?? '127.0.0.1',
|
||||
|
||||
@@ -9,12 +9,12 @@ import { join } from 'node:path';
|
||||
* 未设置该变量时回落到这里的常量)。
|
||||
*/
|
||||
export const CONTRACT_VERSIONS: Readonly<Record<string, string>> = Object.freeze({
|
||||
'rest-api': '0.2.0',
|
||||
'rest-api': '0.3.0',
|
||||
'websocket-keypoints': '0.1.0',
|
||||
'data-models': '0.2.0',
|
||||
'data-models': '0.3.0',
|
||||
'skeleton-topology': '0.1.0',
|
||||
'model-formats': '0.2.0',
|
||||
database: '0.1.0',
|
||||
'model-formats': '0.3.0',
|
||||
database: '0.2.0',
|
||||
});
|
||||
|
||||
/** 解析 `contracts/VERSION`(`key: value` 每行一条,`#` 起注释) */
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 内置默认模型(**由服务端在压缩包转换成功后自动写入**)。
|
||||
*
|
||||
* 写入方:`ModelsService`(上传 zip 并转换成功时)与 `scripts/import-default-model.mjs`。
|
||||
* 运行时只读:`ModelsService.resolveDefaultModelId()` 会先看这里,
|
||||
* 再回落到契约 `data-models.md` §4.1 的 VRM 优先规则。
|
||||
*
|
||||
* 为什么记 sha256 而不是只记 id:换库 / 重建库之后自增 id 会变,
|
||||
* 用转换产物的 sha256 才能稳定指回同一个模型。
|
||||
*
|
||||
* 本文件由脚本重新生成,手改请注意保持结构(键名与类型不要变)。
|
||||
*/
|
||||
|
||||
export interface DefaultModelSeed {
|
||||
/** 数据库中的模型 id(仅作参考,换库后会变) */
|
||||
id: number | null;
|
||||
/** 模型名称 */
|
||||
name: string;
|
||||
/** 模型格式:fbx / glb / vrm */
|
||||
format: string;
|
||||
/** 转换产物文件的 sha256(定位默认模型的依据) */
|
||||
sha256: string;
|
||||
/** 转换来源说明,例如 `Hatsune Miku.pmx → GLB`;非转换模型为 null */
|
||||
convertedFrom: string | null;
|
||||
/** 最近一次写入时间(ISO 8601) */
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
/** 由 Hatsune Miku.pmx → GLB 转换得到,已设为默认模型 */
|
||||
export const DEFAULT_MODEL_SEED: DefaultModelSeed = {
|
||||
id: 16,
|
||||
name: "初音未来 Hatsune Miku",
|
||||
format: "glb",
|
||||
sha256: "6d0ab0dce97c43dd7de84aba29b09f3b64f2953722d31b5421a39189ff2a8464",
|
||||
convertedFrom: "Hatsune Miku.pmx → GLB",
|
||||
updatedAt: "2026-09-15T05:45:19.168Z",
|
||||
};
|
||||
|
||||
/** 该种子是否指向一个真实模型 */
|
||||
export function hasDefaultModelSeed(seed: DefaultModelSeed = DEFAULT_MODEL_SEED): boolean {
|
||||
return seed.sha256.trim().length > 0;
|
||||
}
|
||||
@@ -38,6 +38,17 @@ export class CharacterModel {
|
||||
@Column({ name: 'thumbnail_asset_id', type: 'bigint', nullable: true, transformer: bigintNumber })
|
||||
thumbnailAssetId!: number | null;
|
||||
|
||||
/**
|
||||
* 转换来源:上传的压缩包(zip)所对应的 assets 记录。
|
||||
* 只有"上传压缩包 → 服务端转换"的模型才有值,直接上传模型文件时为 null。
|
||||
*/
|
||||
@Column({ name: 'source_asset_id', type: 'bigint', nullable: true, transformer: bigintNumber })
|
||||
sourceAssetId!: number | null;
|
||||
|
||||
/** 转换说明,例如 `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`;未转换时为 null */
|
||||
@Column({ name: 'conversion_note', type: 'varchar', length: 500, nullable: true })
|
||||
conversionNote!: string | null;
|
||||
|
||||
// ---- 来源五元组(必填)----
|
||||
@Column({ name: 'source_type', type: 'varchar', length: 32 })
|
||||
sourceType!: SourceType;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* T-WEB-005 追加:模型压缩包转换支持。
|
||||
*
|
||||
* - `models.source_asset_id`:上传的压缩包(zip)对应的 assets 记录,便于溯源
|
||||
* - `models.conversion_note`:转换过程说明(pmx → fbx/glb、贴图内嵌情况、回退原因)
|
||||
*
|
||||
* 两列都可空,对既有数据无影响。
|
||||
*/
|
||||
export class AddModelConversionFields1789450000000 implements MigrationInterface {
|
||||
name = 'AddModelConversionFields1789450000000'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`models\` ADD \`source_asset_id\` bigint NULL`);
|
||||
await queryRunner.query(`ALTER TABLE \`models\` ADD \`conversion_note\` varchar(500) NULL`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`models\` DROP COLUMN \`conversion_note\``);
|
||||
await queryRunner.query(`ALTER TABLE \`models\` DROP COLUMN \`source_asset_id\``);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { DefaultModelSeed } from '../config/default-model';
|
||||
|
||||
/**
|
||||
* 把默认模型写进源码(`web-server/src/config/default-model.ts`)。
|
||||
*
|
||||
* 需求原文是"转换成功则把这个模型设置为默认模型写进源码中",因此这里不是只改数据库,
|
||||
* 而是把种子(名称 / 格式 / 产物 sha256)落进源码文件,随代码一起提交、一起部署。
|
||||
*
|
||||
* 注意:生产环境 systemd 单元带 `ProtectSystem=strict`,服务进程**没有**程序目录的写权限,
|
||||
* 因此写入通常由 `scripts/import-default-model.mjs`(在服务沙箱之外、以 root 运行)完成;
|
||||
* 服务内联转换成功时若能写就写,写不了就只告警,不阻断上传。
|
||||
*/
|
||||
|
||||
function quote(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/** 渲染 `default-model.ts` 的内容 */
|
||||
export function renderDefaultModelSource(seed: DefaultModelSeed): string {
|
||||
return `/**
|
||||
* 内置默认模型(**由服务端在压缩包转换成功后自动写入**)。
|
||||
*
|
||||
* 写入方:\`ModelsService\`(上传 zip 并转换成功时)与 \`scripts/import-default-model.mjs\`。
|
||||
* 运行时只读:\`ModelsService.resolveDefaultModelId()\` 会先看这里,
|
||||
* 再回落到契约 \`data-models.md\` §4.1 的 VRM 优先规则。
|
||||
*
|
||||
* 为什么记 sha256 而不是只记 id:换库 / 重建库之后自增 id 会变,
|
||||
* 用转换产物的 sha256 才能稳定指回同一个模型。
|
||||
*
|
||||
* 本文件由脚本重新生成,手改请注意保持结构(键名与类型不要变)。
|
||||
*/
|
||||
|
||||
export interface DefaultModelSeed {
|
||||
/** 数据库中的模型 id(仅作参考,换库后会变) */
|
||||
id: number | null;
|
||||
/** 模型名称 */
|
||||
name: string;
|
||||
/** 模型格式:fbx / glb / vrm */
|
||||
format: string;
|
||||
/** 转换产物文件的 sha256(定位默认模型的依据) */
|
||||
sha256: string;
|
||||
/** 转换来源说明,例如 \`Hatsune Miku.pmx → GLB\`;非转换模型为 null */
|
||||
convertedFrom: string | null;
|
||||
/** 最近一次写入时间(ISO 8601) */
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
/** 由 ${seed.convertedFrom ?? '手工指定'} 转换得到,已设为默认模型 */
|
||||
export const DEFAULT_MODEL_SEED: DefaultModelSeed = {
|
||||
id: ${seed.id === null ? 'null' : seed.id},
|
||||
name: ${quote(seed.name)},
|
||||
format: ${quote(seed.format)},
|
||||
sha256: ${quote(seed.sha256)},
|
||||
convertedFrom: ${seed.convertedFrom === null ? 'null' : quote(seed.convertedFrom)},
|
||||
updatedAt: ${seed.updatedAt === null ? 'null' : quote(seed.updatedAt)},
|
||||
};
|
||||
|
||||
/** 该种子是否指向一个真实模型 */
|
||||
export function hasDefaultModelSeed(seed: DefaultModelSeed = DEFAULT_MODEL_SEED): boolean {
|
||||
return seed.sha256.trim().length > 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/** 源码文件路径:优先 `web-server/src/config/default-model.ts`(相对当前工作目录) */
|
||||
export function defaultModelSourcePath(cwd = process.cwd()): string {
|
||||
const candidates = [
|
||||
join(cwd, 'src', 'config', 'default-model.ts'),
|
||||
resolve(cwd, '..', 'src', 'config', 'default-model.ts'),
|
||||
];
|
||||
return candidates.find((path) => existsSync(path)) ?? candidates[0];
|
||||
}
|
||||
|
||||
export interface WriteSeedResult {
|
||||
written: boolean;
|
||||
path: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** 写入默认模型种子;写不了时返回原因而不是抛错(上传不应因此失败) */
|
||||
export async function writeDefaultModelSeed(
|
||||
seed: DefaultModelSeed,
|
||||
targetPath = defaultModelSourcePath(),
|
||||
): Promise<WriteSeedResult> {
|
||||
try {
|
||||
await writeFile(targetPath, renderDefaultModelSource(seed), 'utf8');
|
||||
return { written: true, path: targetPath };
|
||||
} catch (error) {
|
||||
return {
|
||||
written: false,
|
||||
path: targetPath,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { extname } from 'node:path';
|
||||
|
||||
/**
|
||||
* 把 GLB 里以外部文件形式引用的贴图**内嵌**进二进制块。
|
||||
*
|
||||
* 为什么需要:模型库里一条记录只对应一个文件,客户端拿到的必须能独立打开。
|
||||
* Assimp 的 glTF2 导出器在 GLB 里仍然把贴图写成 `textures\\xxx.png` 这样的外部
|
||||
* URI(还带反斜杠,本身就不符合 glTF 规范),不转换的话加载出来是白模。
|
||||
*
|
||||
* 处理方式:解析 GLB 的 JSON / BIN 两个 chunk,为每个外部贴图追加 bufferView,
|
||||
* 把 `uri` 换成 `bufferView + mimeType`,再按 glTF 的 4 字节对齐规则重新拼装。
|
||||
*/
|
||||
|
||||
const GLB_MAGIC = 0x46546c67;
|
||||
const CHUNK_JSON = 0x4e4f534a;
|
||||
const CHUNK_BIN = 0x004e4942;
|
||||
|
||||
export interface GlbEmbedResult {
|
||||
glb: Buffer;
|
||||
/** 成功内嵌的贴图数量 */
|
||||
embedded: number;
|
||||
/** 找不到文件或格式不支持、仍保留 uri 的贴图 */
|
||||
unresolved: string[];
|
||||
}
|
||||
|
||||
interface GltfJson {
|
||||
asset?: Record<string, unknown>;
|
||||
buffers?: Array<{ byteLength?: number; uri?: string }>;
|
||||
bufferViews?: Array<Record<string, unknown>>;
|
||||
images?: Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const MIME_BY_EXTENSION: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.ktx2': 'image/ktx2',
|
||||
'.basis': 'image/basis',
|
||||
};
|
||||
|
||||
function align4(value: number): number {
|
||||
return value + ((4 - (value % 4)) % 4);
|
||||
}
|
||||
|
||||
function parseGlb(buffer: Buffer): { json: GltfJson; bin: Buffer | null } {
|
||||
if (buffer.length < 12 || buffer.readUInt32LE(0) !== GLB_MAGIC) {
|
||||
throw new Error('不是有效的 GLB:魔数不正确');
|
||||
}
|
||||
const totalLength = buffer.readUInt32LE(8);
|
||||
const end = Math.min(totalLength || buffer.length, buffer.length);
|
||||
let offset = 12;
|
||||
let json: GltfJson | null = null;
|
||||
let bin: Buffer | null = null;
|
||||
while (offset + 8 <= end) {
|
||||
const chunkLength = buffer.readUInt32LE(offset);
|
||||
const chunkType = buffer.readUInt32LE(offset + 4);
|
||||
const dataStart = offset + 8;
|
||||
if (dataStart + chunkLength > buffer.length) break;
|
||||
const data = buffer.subarray(dataStart, dataStart + chunkLength);
|
||||
if (chunkType === CHUNK_JSON) json = JSON.parse(data.toString('utf8')) as GltfJson;
|
||||
else if (chunkType === CHUNK_BIN) bin = Buffer.from(data);
|
||||
offset = dataStart + align4(chunkLength);
|
||||
}
|
||||
if (!json) throw new Error('GLB 里没有 JSON chunk');
|
||||
return { json, bin };
|
||||
}
|
||||
|
||||
function buildGlb(json: GltfJson, bin: Buffer | null): Buffer {
|
||||
const jsonBuffer = Buffer.from(JSON.stringify(json), 'utf8');
|
||||
const jsonPadded = Buffer.alloc(align4(jsonBuffer.length), 0x20);
|
||||
jsonBuffer.copy(jsonPadded);
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
const header = Buffer.alloc(12);
|
||||
header.writeUInt32LE(GLB_MAGIC, 0);
|
||||
header.writeUInt32LE(2, 4);
|
||||
chunks.push(header);
|
||||
|
||||
const jsonHeader = Buffer.alloc(8);
|
||||
jsonHeader.writeUInt32LE(jsonPadded.length, 0);
|
||||
jsonHeader.writeUInt32LE(CHUNK_JSON, 4);
|
||||
chunks.push(jsonHeader, jsonPadded);
|
||||
|
||||
if (bin && bin.length > 0) {
|
||||
const binPadded = Buffer.alloc(align4(bin.length));
|
||||
bin.copy(binPadded);
|
||||
const binHeader = Buffer.alloc(8);
|
||||
binHeader.writeUInt32LE(binPadded.length, 0);
|
||||
binHeader.writeUInt32LE(CHUNK_BIN, 4);
|
||||
chunks.push(binHeader, binPadded);
|
||||
}
|
||||
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const out = Buffer.concat(chunks, total);
|
||||
out.writeUInt32LE(total, 8);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内嵌 GLB 中的外部贴图。
|
||||
*
|
||||
* @param buffer GLB 内容
|
||||
* @param resolve 把 glTF 里的 uri 映射为文件内容;返回 null 表示找不到
|
||||
*/
|
||||
export function embedGlbImages(
|
||||
buffer: Buffer,
|
||||
resolve: (uri: string) => Buffer | null,
|
||||
): GlbEmbedResult {
|
||||
const { json, bin } = parseGlb(buffer);
|
||||
const images = json.images ?? [];
|
||||
if (images.length === 0) return { glb: buffer, embedded: 0, unresolved: [] };
|
||||
|
||||
const views = json.bufferViews ?? [];
|
||||
const chunks: Buffer[] = bin && bin.length > 0 ? [bin] : [];
|
||||
let offset = bin ? bin.length : 0;
|
||||
let embedded = 0;
|
||||
const unresolved: string[] = [];
|
||||
|
||||
for (const image of images) {
|
||||
const uri = typeof image.uri === 'string' ? image.uri : null;
|
||||
if (!uri || uri.startsWith('data:')) continue;
|
||||
|
||||
const file = resolve(uri);
|
||||
if (!file) {
|
||||
unresolved.push(uri);
|
||||
continue;
|
||||
}
|
||||
const mimeType = MIME_BY_EXTENSION[extname(uri).toLowerCase()];
|
||||
if (!mimeType) {
|
||||
unresolved.push(uri);
|
||||
continue;
|
||||
}
|
||||
|
||||
const padding = align4(offset) - offset;
|
||||
if (padding > 0) {
|
||||
chunks.push(Buffer.alloc(padding));
|
||||
offset += padding;
|
||||
}
|
||||
chunks.push(file);
|
||||
views.push({ buffer: 0, byteOffset: offset, byteLength: file.length });
|
||||
image.bufferView = views.length - 1;
|
||||
image.mimeType = mimeType;
|
||||
delete image.uri;
|
||||
offset += file.length;
|
||||
embedded += 1;
|
||||
}
|
||||
|
||||
if (embedded === 0) return { glb: buffer, embedded: 0, unresolved };
|
||||
|
||||
const merged = Buffer.concat(chunks, offset);
|
||||
json.bufferViews = views;
|
||||
json.buffers = [{ byteLength: merged.length }];
|
||||
return { glb: buildGlb(json, merged), embedded, unresolved };
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, extname, join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { ModelFormat } from '../common/enums';
|
||||
import { ZipEntry, readZipEntries } from '../common/zip-reader';
|
||||
import { embedGlbImages } from './glb-textures';
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
/**
|
||||
* 压缩包 → 可入库模型文件的转换。
|
||||
*
|
||||
* 典型场景:MMD 模型包(zip 内含 `xxx.pmx` + `textures/`)→ 解压 → 选主模型
|
||||
* → 调 Assimp 导出 FBX;**Assimp 的 FBX 导出器对带蒙皮的大骨架模型会写坏文件**
|
||||
* (实测 Miku PMX:导出退出码 11,产出的 FBX 回读报 `FBX-DOM no Objects dictionary found`),
|
||||
* 因此导出后必须回读校验,失败则回退 GLB,并把过程写进 conversionNote。
|
||||
*
|
||||
* 转换后的 GLB 还会把 Assimp 写成外部 URI 的贴图内嵌进二进制块,
|
||||
* 保证模型库里一个文件就能独立加载(否则是白模)。
|
||||
*/
|
||||
|
||||
export interface ConvertOptions {
|
||||
/** Assimp 可执行文件(默认 `assimp`,可用 ASSIMP_BIN 覆盖) */
|
||||
assimpBin: string;
|
||||
/** 单次转换超时(毫秒) */
|
||||
timeoutMs?: number;
|
||||
/** 优先尝试的格式 */
|
||||
preferFormat?: 'fbx' | 'glb';
|
||||
/** 优先选用的 PMX 文件名(大小写不敏感),例如 `Hatsune Miku.pmx` */
|
||||
preferredPmx?: string;
|
||||
}
|
||||
|
||||
export interface ConvertedModel {
|
||||
/** 转换产物内容 */
|
||||
buffer: Buffer;
|
||||
/** 产物格式(fbx 或 glb) */
|
||||
format: ModelFormat;
|
||||
/** 实际使用的 pmx 相对路径 */
|
||||
sourceModelPath: string;
|
||||
/** 人类可读的转换说明,写入 models.conversion_note */
|
||||
note: string;
|
||||
/** 逐步尝试记录,便于排障 */
|
||||
attempts: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 180_000;
|
||||
|
||||
/** 选择主模型:优先指名字,其次目录最浅,最后文件最大 */
|
||||
export function pickModelFile(
|
||||
entries: readonly ZipEntry[],
|
||||
preferred?: string,
|
||||
): ZipEntry | undefined {
|
||||
const models = entries.filter((entry) => extname(entry.path).toLowerCase() === '.pmx');
|
||||
if (models.length === 0) return undefined;
|
||||
const wanted = preferred?.trim().toLowerCase();
|
||||
if (wanted) {
|
||||
const exact = models.find((entry) => entry.path.toLowerCase() === wanted);
|
||||
if (exact) return exact;
|
||||
const byBase = models.find(
|
||||
(entry) => entry.path.split('/').pop()?.toLowerCase() === wanted,
|
||||
);
|
||||
if (byBase) return byBase;
|
||||
}
|
||||
return [...models].sort((a, b) => {
|
||||
const depthA = a.path.split('/').length;
|
||||
const depthB = b.path.split('/').length;
|
||||
if (depthA !== depthB) return depthA - depthB;
|
||||
return b.size - a.size;
|
||||
})[0];
|
||||
}
|
||||
|
||||
/** 解压到工作目录,同时保留内容供贴图内嵌使用 */
|
||||
async function unpack(
|
||||
entries: readonly ZipEntry[],
|
||||
targetDir: string,
|
||||
): Promise<Map<string, Buffer>> {
|
||||
const contents = new Map<string, Buffer>();
|
||||
for (const entry of entries) {
|
||||
const data = entry.read();
|
||||
contents.set(entry.path, data);
|
||||
const destination = join(targetDir, entry.path);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, data);
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
/** 在解压结果里按 glTF 的 uri 找贴图(容错反斜杠与大小写) */
|
||||
function makeResolver(contents: Map<string, Buffer>) {
|
||||
const lowered = new Map<string, Buffer>();
|
||||
for (const [path, data] of contents) lowered.set(path.replace(/\\/g, '/').toLowerCase(), data);
|
||||
return (uri: string): Buffer | null => {
|
||||
if (uri.startsWith('data:')) return null;
|
||||
let decoded = uri;
|
||||
try {
|
||||
decoded = decodeURIComponent(uri);
|
||||
} catch {
|
||||
/* 保持原样 */
|
||||
}
|
||||
const normalized = decoded.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
|
||||
const direct = lowered.get(normalized);
|
||||
if (direct) return direct;
|
||||
const base = normalized.split('/').pop();
|
||||
if (!base) return null;
|
||||
for (const [path, data] of lowered) {
|
||||
if (path.split('/').pop() === base) return data;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 用 Assimp 导出到指定格式,并回读校验(回读失败视为转换失败) */
|
||||
async function exportAndValidate(
|
||||
assimpBin: string,
|
||||
modelPath: string,
|
||||
outPath: string,
|
||||
timeoutMs: number,
|
||||
): Promise<{ ok: boolean; detail: string }> {
|
||||
try {
|
||||
await run(assimpBin, ['export', modelPath, outPath], { timeout: timeoutMs, maxBuffer: 8 << 20 });
|
||||
} catch (error) {
|
||||
const err = error as { code?: number | string; stderr?: string; message?: string };
|
||||
const detail = err.stderr?.trim() || err.message || String(error);
|
||||
return { ok: false, detail: `导出失败(${err.code ?? 'unknown'}):${detail.slice(0, 300)}` };
|
||||
}
|
||||
|
||||
let size = 0;
|
||||
try {
|
||||
size = (await readFile(outPath)).length;
|
||||
} catch {
|
||||
return { ok: false, detail: '导出命令返回成功但没有产出文件' };
|
||||
}
|
||||
if (size === 0) return { ok: false, detail: '导出产物为空文件' };
|
||||
|
||||
// 关键校验:Assimp 的 FBX 导出器会"写出一个坏文件但退出码非 0",
|
||||
// 必须用 info 回读确认,否则会把损坏文件入库。
|
||||
try {
|
||||
await run(assimpBin, ['info', outPath], { timeout: timeoutMs, maxBuffer: 8 << 20 });
|
||||
} catch (error) {
|
||||
const err = error as { stderr?: string; message?: string };
|
||||
const detail = err.stderr?.trim() || err.message || String(error);
|
||||
return { ok: false, detail: `产物无法回读(已判定损坏):${detail.slice(0, 300)}` };
|
||||
}
|
||||
return { ok: true, detail: `产物 ${size} 字节,回读校验通过` };
|
||||
}
|
||||
|
||||
/** 压缩包 → 模型文件(FBX 优先,失败回退 GLB) */
|
||||
export async function convertModelArchive(
|
||||
archive: Buffer,
|
||||
options: ConvertOptions,
|
||||
): Promise<ConvertedModel> {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const prefer = options.preferFormat ?? 'fbx';
|
||||
const entries = readZipEntries(archive);
|
||||
if (entries.length === 0) throw new Error('压缩包是空的');
|
||||
|
||||
const modelEntry = pickModelFile(entries, options.preferredPmx);
|
||||
if (!modelEntry) {
|
||||
throw new Error('压缩包里没有找到 .pmx 模型文件(当前只支持 MMD 的 PMX 模型包)');
|
||||
}
|
||||
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'opendance-model-'));
|
||||
const attempts: string[] = [];
|
||||
try {
|
||||
const contents = await unpack(entries, workDir);
|
||||
const modelPath = join(workDir, modelEntry.path);
|
||||
|
||||
const order: Array<'fbx' | 'glb'> =
|
||||
prefer === 'glb' ? ['glb', 'fbx'] : ['fbx', 'glb'];
|
||||
|
||||
for (const target of order) {
|
||||
const outPath = join(workDir, `converted.${target}`);
|
||||
const result = await exportAndValidate(options.assimpBin, modelPath, outPath, timeoutMs);
|
||||
attempts.push(`${target.toUpperCase()}:${result.detail}`);
|
||||
if (!result.ok) continue;
|
||||
|
||||
let buffer: Buffer = await readFile(outPath);
|
||||
let extra = '';
|
||||
if (target === 'glb') {
|
||||
// 贴图内嵌是"锦上添花":即便结构不符合预期也不能让整次转换失败
|
||||
try {
|
||||
const embedded = embedGlbImages(buffer, makeResolver(contents));
|
||||
buffer = Buffer.from(embedded.glb);
|
||||
if (embedded.embedded > 0) extra = `,已内嵌 ${embedded.embedded} 张贴图`;
|
||||
if (embedded.unresolved.length > 0) {
|
||||
extra += `(${embedded.unresolved.length} 张贴图未找到:${embedded.unresolved
|
||||
.slice(0, 3)
|
||||
.join('、')})`;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
extra = `(贴图内嵌跳过:${message.slice(0, 120)})`;
|
||||
}
|
||||
}
|
||||
|
||||
const note = `${modelEntry.path} → ${target.toUpperCase()}${extra}`;
|
||||
return {
|
||||
buffer,
|
||||
format: target === 'fbx' ? ModelFormat.Fbx : ModelFormat.Glb,
|
||||
sourceModelPath: modelEntry.path,
|
||||
note,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`模型转换失败:${attempts.join(';')}。` +
|
||||
'请确认服务器已安装 Assimp(ASSIMP_BIN),或直接上传 .glb / .vrm 文件。',
|
||||
);
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,11 @@ export interface ModelDto {
|
||||
isPrimary: boolean;
|
||||
fileUrl: string | null;
|
||||
fileSize: number | null;
|
||||
/** 模型文件(转换产物)的 sha256,用于换库后定位默认模型 */
|
||||
fileSha256: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
/** 由压缩包转换而来时的说明,例如 `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图` */
|
||||
conversionNote: string | null;
|
||||
sourceType: SourceType;
|
||||
sourceName: string;
|
||||
sourceUrl: string;
|
||||
@@ -25,6 +29,7 @@ export interface ModelUrlLookup {
|
||||
fileUrl: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
fileSize?: number | null;
|
||||
fileSha256?: string | null;
|
||||
}
|
||||
|
||||
export function toModelDto(model: CharacterModel, urls: ModelUrlLookup): ModelDto {
|
||||
@@ -36,7 +41,9 @@ export function toModelDto(model: CharacterModel, urls: ModelUrlLookup): ModelDt
|
||||
isPrimary: model.isPrimary,
|
||||
fileUrl: urls.fileUrl,
|
||||
fileSize: urls.fileSize ?? null,
|
||||
fileSha256: urls.fileSha256 ?? null,
|
||||
thumbnailUrl: urls.thumbnailUrl,
|
||||
conversionNote: model.conversionNote ?? null,
|
||||
sourceType: model.sourceType,
|
||||
sourceName: model.sourceName,
|
||||
sourceUrl: model.sourceUrl,
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { extname } from 'node:path';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import {
|
||||
AssetKind,
|
||||
@@ -16,7 +25,11 @@ import {
|
||||
requireUploadedFile,
|
||||
resolveFormatByExtension,
|
||||
} from '../common/upload.util';
|
||||
import { DEFAULT_MODEL_SEED } from '../config/default-model';
|
||||
import { writeDefaultModelSeed } from './default-model-source';
|
||||
import { convertModelArchive } from './model-convert';
|
||||
import { CharacterModel } from '../entities/model.entity';
|
||||
import { AppConfig } from '../config/configuration';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { QueryModelDto, UpdateModelDto } from './model.dto';
|
||||
import { ModelDto, toModelDto } from './model.serializer';
|
||||
@@ -24,10 +37,13 @@ import { inspectVrm } from './vrm-inspector';
|
||||
|
||||
@Injectable()
|
||||
export class ModelsService {
|
||||
private readonly logger = new Logger(ModelsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(CharacterModel) private readonly models: Repository<CharacterModel>,
|
||||
private readonly storage: StorageService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
async list(query: QueryModelDto): Promise<Paged<ModelDto>> {
|
||||
@@ -63,8 +79,6 @@ export class ModelsService {
|
||||
if (!name) throw new BadRequestException('name 不能为空');
|
||||
if (name.length > 200) throw new BadRequestException('name 长度不能超过 200');
|
||||
|
||||
const { format } = resolveFormatByExtension(upload.originalname, MODEL_EXTENSION_MAP, '模型文件');
|
||||
|
||||
const tuple = assertSourceTupleComplete({
|
||||
sourceType: body.sourceType as SourceTuple['sourceType'],
|
||||
sourceName: body.sourceName as string,
|
||||
@@ -76,6 +90,13 @@ export class ModelsService {
|
||||
throw new BadRequestException('上传模型必须填写完整的来源信息(sourceType/sourceName/sourceUrl/license/author)');
|
||||
}
|
||||
|
||||
// 压缩包(MMD 模型包)走"解压 → 转换 → 入库"通路,其它扩展名走原有的直传通路
|
||||
if (extname(upload.originalname).toLowerCase() === '.zip') {
|
||||
return this.createFromArchive(body, upload, name, tuple);
|
||||
}
|
||||
|
||||
const { format } = resolveFormatByExtension(upload.originalname, MODEL_EXTENSION_MAP, '模型文件');
|
||||
|
||||
const stored = await this.storage.persistTempFile(
|
||||
AssetKind.Model,
|
||||
upload.path,
|
||||
@@ -122,6 +143,126 @@ export class ModelsService {
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* T-WEB-005 追加:上传压缩包(如 MMD 的 `模型_xxx.zip`)→ 解压选 PMX → Assimp 转换
|
||||
* → 入库。FBX 优先,导出后回读校验,失败自动回退 GLB 并记录 conversion_note。
|
||||
*
|
||||
* 转换成功后默认把它设为**全局默认模型**(is_primary + published),并把身份
|
||||
* (名称/格式/产物 sha256)写进源码 `src/config/default-model.ts`;
|
||||
* 传 `autoDefault=false` 可只入库不改默认。
|
||||
*/
|
||||
private async createFromArchive(
|
||||
body: Record<string, unknown>,
|
||||
upload: UploadedFileLike,
|
||||
name: string,
|
||||
tuple: NonNullable<ReturnType<typeof assertSourceTupleComplete>>,
|
||||
): Promise<ModelDto> {
|
||||
if (upload.size && upload.size > this.config.uploadMaxBytes) {
|
||||
throw new BadRequestException(
|
||||
`压缩包超过上限 ${Math.floor(this.config.uploadMaxBytes / 1024 / 1024)} MiB`,
|
||||
);
|
||||
}
|
||||
|
||||
const archive = await readFile(upload.path);
|
||||
let converted;
|
||||
try {
|
||||
converted = await convertModelArchive(archive, {
|
||||
assimpBin: this.config.assimpBin,
|
||||
timeoutMs: this.config.modelConvertTimeoutMs,
|
||||
preferFormat: this.config.modelConvertFormat,
|
||||
preferredPmx: this.config.modelConvertPmx,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`模型压缩包转换失败(${upload.originalname}):${message}`);
|
||||
throw new UnprocessableEntityException(`模型转换失败:${message}`);
|
||||
}
|
||||
|
||||
// 原件留存用于溯源(按 sha256 去重)
|
||||
const archiveAsset = await this.storage.persistBuffer(
|
||||
AssetKind.ModelArchive,
|
||||
archive,
|
||||
upload.originalname,
|
||||
upload.mimetype || 'application/zip',
|
||||
);
|
||||
const stored = await this.storage.persistBuffer(
|
||||
AssetKind.Model,
|
||||
converted.buffer,
|
||||
`${name}.${converted.format}`,
|
||||
converted.format === ModelFormat.Glb ? 'model/gltf-binary' : 'application/octet-stream',
|
||||
);
|
||||
|
||||
// 转换产物若是 GLB,顺带识别是否带 VRM 扩展
|
||||
let vrmVersion: string | null = null;
|
||||
if (converted.format === ModelFormat.Glb) {
|
||||
try {
|
||||
vrmVersion = (await inspectVrm(this.storage.absolutePath(stored.asset))).vrmVersion;
|
||||
} catch {
|
||||
vrmVersion = null;
|
||||
}
|
||||
}
|
||||
|
||||
const autoDefault = body.autoDefault === undefined ? true : parseOptionalBool(body.autoDefault);
|
||||
const status = autoDefault ? PublishStatus.Published : this.parseStatus(body.status);
|
||||
|
||||
const model = this.models.create({
|
||||
name,
|
||||
format: converted.format,
|
||||
vrmVersion,
|
||||
isPrimary: false,
|
||||
fileAssetId: stored.asset.id,
|
||||
sourceAssetId: archiveAsset.asset.id,
|
||||
conversionNote: converted.note.slice(0, 500),
|
||||
sourceType: tuple.sourceType as CharacterModel['sourceType'],
|
||||
sourceName: tuple.sourceName,
|
||||
sourceUrl: tuple.sourceUrl,
|
||||
license: tuple.license,
|
||||
author: tuple.author,
|
||||
status,
|
||||
});
|
||||
const saved = await this.models.save(model);
|
||||
this.logger.log(
|
||||
`模型压缩包已转换入库:#${saved.id} ${name}(${converted.note})` +
|
||||
`;尝试记录:${converted.attempts.join(';')}`,
|
||||
);
|
||||
|
||||
if (autoDefault) {
|
||||
await this.applyDefaultModel(saved.id, stored.asset.sha256, converted.note);
|
||||
// setPrimary 走的是独立事务,重新读一次才能让返回体里的 isPrimary 正确
|
||||
const [dto] = await this.toDtos([await this.getEntity(saved.id)]);
|
||||
return dto;
|
||||
}
|
||||
|
||||
const [dto] = await this.toDtos([saved]);
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设为默认模型并把种子写进源码。写入失败只告警:生产环境 systemd 的
|
||||
* `ProtectSystem=strict` 不允许服务进程改程序目录,通常由
|
||||
* `scripts/import-default-model.mjs` 在沙箱外完成写入。
|
||||
*/
|
||||
private async applyDefaultModel(id: number, sha256: string, note: string): Promise<void> {
|
||||
await this.setPrimary(id);
|
||||
const model = await this.getEntity(id);
|
||||
const result = await writeDefaultModelSeed({
|
||||
id,
|
||||
name: model.name,
|
||||
format: model.format,
|
||||
sha256,
|
||||
convertedFrom: note.split(',')[0] ?? null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
if (result.written) {
|
||||
this.logger.log(`默认模型已写进源码:${result.path}`);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`默认模型已在库中设为 is_primary,但源码写入失败(${result.reason});` +
|
||||
`可在源码目录执行 node scripts/import-default-model.mjs --write-source-only 补写`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateModelDto): Promise<ModelDto> {
|
||||
const model = await this.getEntity(id);
|
||||
|
||||
@@ -170,7 +311,16 @@ export class ModelsService {
|
||||
return dto;
|
||||
}
|
||||
|
||||
/** 客户端默认模型解析 —— 契约 data-models.md §4.1 的 2~4 步 */
|
||||
/**
|
||||
* 客户端默认模型解析 —— 契约 data-models.md §4.1。
|
||||
*
|
||||
* 顺序:
|
||||
* 0. 环境变量 `DEFAULT_MODEL_ID` 显式指定(且模型存在)
|
||||
* 1. 源码里写死的 `DEFAULT_MODEL_SEED`(按转换产物 sha256 匹配,换库也能定位)
|
||||
* 2. 已发布且 is_primary 的 VRM
|
||||
* 3. 任意已发布 VRM
|
||||
* 4. id 最小的已发布模型
|
||||
*/
|
||||
async resolveDefaultModelId(): Promise<number | null> {
|
||||
const published = await this.models.find({
|
||||
where: { status: PublishStatus.Published },
|
||||
@@ -178,6 +328,24 @@ export class ModelsService {
|
||||
});
|
||||
if (published.length === 0) return null;
|
||||
|
||||
const configured = this.config.defaultModelId;
|
||||
if (configured !== null) {
|
||||
const hit = published.find((m) => m.id === configured);
|
||||
if (hit) return hit.id;
|
||||
this.logger.warn(`DEFAULT_MODEL_ID=${configured} 指向的模型不存在或未发布,已忽略`);
|
||||
}
|
||||
|
||||
const seedSha = DEFAULT_MODEL_SEED.sha256.trim();
|
||||
if (seedSha) {
|
||||
const assets = await this.storage.findMany(published.map((m) => m.fileAssetId));
|
||||
const seeded = published.find((m) => {
|
||||
const asset = m.fileAssetId === null ? null : assets.get(m.fileAssetId);
|
||||
return asset?.sha256 === seedSha;
|
||||
});
|
||||
if (seeded) return seeded.id;
|
||||
this.logger.warn('源码里的默认模型种子在内库中找不到对应模型,回落到 VRM 优先规则');
|
||||
}
|
||||
|
||||
const primaryVrm = published.find((m) => m.format === ModelFormat.Vrm && m.isPrimary);
|
||||
if (primaryVrm) return primaryVrm.id;
|
||||
|
||||
@@ -206,6 +374,7 @@ export class ModelsService {
|
||||
fileUrl: file ? this.storage.url(file) : null,
|
||||
thumbnailUrl: thumb ? this.storage.url(thumb) : null,
|
||||
fileSize: file?.size ?? null,
|
||||
fileSha256: file?.sha256 ?? null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
configuration,
|
||||
@@ -79,6 +81,23 @@ describe('端口解析与默认值', () => {
|
||||
expect(configuration().adminPort).toBe(4111);
|
||||
});
|
||||
|
||||
it('需求约定:未设置 HOST 时默认监听 0.0.0.0,而不是 127.0.0.1', () => {
|
||||
delete process.env.HOST;
|
||||
delete process.env.ADMIN_HOST;
|
||||
const env = configuration();
|
||||
expect(env.bindHost).toBe('0.0.0.0');
|
||||
expect(env.adminHost).toBe('0.0.0.0');
|
||||
expect(env.bindHost).not.toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('两份 .env 模板都写着 HOST=0.0.0.0(防止默认值被改回去)', () => {
|
||||
for (const file of [join(__dirname, '..', '..', 'deploy', '.env.example'), join(__dirname, '..', '.env.example')]) {
|
||||
const content = readFileSync(file, 'utf8');
|
||||
expect(content, file).toMatch(/^HOST=0\.0\.0\.0$/m);
|
||||
expect(content, file).not.toMatch(/^HOST=127\.0\.0\.1$/m);
|
||||
}
|
||||
});
|
||||
|
||||
it('configuration:ADMIN_HOST 显式设置时不再跟随 HOST', () => {
|
||||
process.env.HOST = '127.0.0.1';
|
||||
expect(configuration().adminHost).toBe('127.0.0.1');
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* deploy/init-env.sh —— Web 端首次启动的 .env 引导流程。
|
||||
*
|
||||
* 这些用例全部在临时目录里驱动真实脚本:
|
||||
* * 非交互场景必须“要么 0、要么非 0”,绝不允许挂住(spawnSync 带 timeout,
|
||||
* 一旦超时 r.error 会被置为 ETIMEDOUT,用例据此断言)。
|
||||
* * 不访问真实数据库:统一注入 OPENDANCE_SKIP_DB_CHECK=1。
|
||||
*/
|
||||
function findScript(): string {
|
||||
const candidates = [
|
||||
resolve(process.cwd(), '..', 'deploy', 'init-env.sh'), // 从 web-server/ 运行(vitest 默认)
|
||||
resolve(process.cwd(), 'deploy', 'init-env.sh'), // 从仓库根运行
|
||||
resolve(process.cwd(), '..', '..', 'deploy', 'init-env.sh'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error(`找不到 deploy/init-env.sh,候选路径:\n${candidates.join('\n')}`);
|
||||
}
|
||||
|
||||
const SCRIPT = findScript();
|
||||
|
||||
/** 测试模板:含注释与“其它字段”,用于验证写回时不丢注释。 */
|
||||
const EXAMPLE = `# ============================================================
|
||||
# 测试用模板:这些注释必须被保留
|
||||
# ============================================================
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
# 管理端端口注释
|
||||
ADMIN_PORT=3001
|
||||
HOST=0.0.0.0
|
||||
# 数据库
|
||||
DB_HOST=<existing-mysql-host>
|
||||
DB_PORT=3306
|
||||
DB_USER=opendance_app
|
||||
DB_PASS=<部署方提供>
|
||||
DB_NAME=opendance
|
||||
DB_SYNCHRONIZE=false
|
||||
UPLOAD_MAX_BYTES=536870912
|
||||
`;
|
||||
|
||||
/** 一份“齐备”的 .env 内容。 */
|
||||
const COMPLETE_ENV =
|
||||
[
|
||||
'DB_HOST=10.0.0.5',
|
||||
'DB_PORT=3306',
|
||||
'DB_USER=u1',
|
||||
'DB_PASS=p1',
|
||||
'DB_NAME=d1',
|
||||
'PORT=3000',
|
||||
'ADMIN_PORT=3001',
|
||||
'HOST=0.0.0.0',
|
||||
].join('\n') + '\n';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeDir(withExample = true): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'opendance-init-env-'));
|
||||
tempDirs.push(dir);
|
||||
if (withExample) writeFileSync(join(dir, '.env.example'), EXAMPLE, 'utf8');
|
||||
return dir;
|
||||
}
|
||||
|
||||
function envPath(dir: string): string {
|
||||
return join(dir, '.env');
|
||||
}
|
||||
|
||||
interface RunResult {
|
||||
status: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
function run(
|
||||
args: string[],
|
||||
opts: { input?: string; env?: Record<string, string>; timeoutMs?: number } = {},
|
||||
): RunResult {
|
||||
const result = spawnSync('bash', [SCRIPT, ...args], {
|
||||
input: opts.input ?? '',
|
||||
encoding: 'utf8',
|
||||
timeout: opts.timeoutMs ?? 8000,
|
||||
env: { ...process.env, OPENDANCE_SKIP_DB_CHECK: '1', ...opts.env },
|
||||
});
|
||||
return {
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
function outputOf(result: RunResult): string {
|
||||
return `${result.stdout}${result.stderr}`;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('init-env.sh:非交互模式绝不阻塞', () => {
|
||||
it(
|
||||
'缺必填 → 非 0 退出并列出字段名,且不阻塞',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
// 人为缺 DB_USER / DB_PASS
|
||||
writeFileSync(envPath(dir), 'DB_HOST=1.2.3.4\nDB_PORT=3306\nDB_NAME=opendance\n');
|
||||
|
||||
const result = run(['--non-interactive', '--dir', dir], { timeoutMs: 5000 });
|
||||
|
||||
expect(result.error).toBeUndefined(); // 超时被杀则 error=ETIMEDOUT
|
||||
expect(result.status).not.toBe(0);
|
||||
const out = outputOf(result);
|
||||
expect(out).toContain('DB_USER');
|
||||
expect(out).toContain('DB_PASS');
|
||||
// 不修改文件
|
||||
expect(readFileSync(envPath(dir), 'utf8')).toBe(
|
||||
'DB_HOST=1.2.3.4\nDB_PORT=3306\nDB_NAME=opendance\n',
|
||||
);
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it(
|
||||
'OPENDANCE_NONINTERACTIVE=1 与 --yes 同样跳过提问',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
writeFileSync(envPath(dir), 'DB_HOST=h\nDB_NAME=n\n'); // 缺 DB_USER / DB_PASS
|
||||
|
||||
const byEnv = run(['--dir', dir], {
|
||||
input: 'should-not-be-read\n',
|
||||
env: { OPENDANCE_NONINTERACTIVE: '1' },
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(byEnv.error).toBeUndefined();
|
||||
expect(byEnv.status).not.toBe(0);
|
||||
expect(outputOf(byEnv)).not.toContain('请输入');
|
||||
|
||||
const byFlag = run(['--yes', '--dir', dir], { timeoutMs: 5000 });
|
||||
expect(byFlag.error).toBeUndefined();
|
||||
expect(byFlag.status).not.toBe(0);
|
||||
expect(outputOf(byFlag)).not.toContain('请输入');
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it(
|
||||
'全部齐备 → 0 退出且完全不修改文件',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
writeFileSync(envPath(dir), COMPLETE_ENV);
|
||||
const before = readFileSync(envPath(dir), 'utf8');
|
||||
const modeBefore = statSync(envPath(dir)).mode;
|
||||
|
||||
const result = run(['--non-interactive', '--dir', dir]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(readFileSync(envPath(dir), 'utf8')).toBe(before);
|
||||
expect(statSync(envPath(dir)).mode).toBe(modeBefore);
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it(
|
||||
'.env 不存在 → 从 .env.example 建立基础,但仍因占位符未填而报错',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
const result = run(['--non-interactive', '--dir', dir]);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
const out = outputOf(result);
|
||||
expect(out).toContain('已从模板创建');
|
||||
expect(out).toContain('DB_HOST'); // <existing-mysql-host>
|
||||
expect(out).toContain('DB_PASS'); // <部署方提供>
|
||||
// 文件确实被创建(作为用户后续填写的基础)
|
||||
expect(existsSync(envPath(dir))).toBe(true);
|
||||
},
|
||||
15000,
|
||||
);
|
||||
});
|
||||
|
||||
describe('init-env.sh:交互式引导(printf ... | bash)', () => {
|
||||
it(
|
||||
'逐项回答后写回正确值,注释保留,ADMIN_PORT 默认 = PORT + 1',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
// 顺序:DB_HOST DB_PORT DB_USER DB_PASS DB_NAME PORT ADMIN_PORT HOST
|
||||
const input =
|
||||
['db.example.com', '3307', 'dance_user', 'p@ss*word%', 'dance_db', '4100', '', ''].join(
|
||||
'\n',
|
||||
) + '\n';
|
||||
|
||||
const result = run(['--dir', dir], { input, timeoutMs: 10000 });
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
const content = readFileSync(envPath(dir), 'utf8');
|
||||
|
||||
expect(content).toMatch(/^DB_HOST=db\.example\.com$/m);
|
||||
expect(content).toMatch(/^DB_PORT=3307$/m);
|
||||
expect(content).toMatch(/^DB_USER=dance_user$/m);
|
||||
expect(content).toMatch(/^DB_PASS=p@ss\*word%$/m);
|
||||
expect(content).toMatch(/^DB_NAME=dance_db$/m);
|
||||
expect(content).toMatch(/^PORT=4100$/m);
|
||||
expect(content).toMatch(/^ADMIN_PORT=4101$/m); // 默认 = PORT + 1
|
||||
expect(content).toMatch(/^HOST=0\.0\.0\.0$/m);
|
||||
|
||||
// 原有注释与其它字段必须保留
|
||||
expect(content).toContain('# 测试用模板:这些注释必须被保留');
|
||||
expect(content).toContain('# 管理端端口注释');
|
||||
expect(content).toContain('NODE_ENV=production');
|
||||
expect(content).toContain('DB_SYNCHRONIZE=false');
|
||||
expect(content).toContain('UPLOAD_MAX_BYTES=536870912');
|
||||
|
||||
// 口令落盘为 600
|
||||
expect(statSync(envPath(dir)).mode & 0o777).toBe(0o600);
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it(
|
||||
'交互输入中途 EOF → 立即非 0 退出,不挂住',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
const result = run(['--dir', dir], { input: 'only-a-host\n', timeoutMs: 5000 });
|
||||
|
||||
expect(result.error).toBeUndefined(); // 未被 timeout 杀掉
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(outputOf(result)).toContain('stdin');
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it(
|
||||
'重复运行(--force + 同样输入)结果幂等',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
const input =
|
||||
['h1', '3306', 'u1', 'pw1', 'd1', '3000', '3001', '0.0.0.0'].join('\n') + '\n';
|
||||
|
||||
expect(run(['--force', '--dir', dir], { input, timeoutMs: 10000 }).status).toBe(0);
|
||||
const first = readFileSync(envPath(dir), 'utf8');
|
||||
expect(run(['--force', '--dir', dir], { input, timeoutMs: 10000 }).status).toBe(0);
|
||||
const second = readFileSync(envPath(dir), 'utf8');
|
||||
|
||||
expect(second).toBe(first);
|
||||
},
|
||||
20000,
|
||||
);
|
||||
});
|
||||
|
||||
describe('init-env.sh:字段校验', () => {
|
||||
it.each([
|
||||
['DB_PORT', '70000'],
|
||||
['PORT', 'abc'],
|
||||
['ADMIN_PORT', '70000'],
|
||||
])(
|
||||
'端口非法 %s=%s → 非 0 退出并报出字段名',
|
||||
(key, bad) => {
|
||||
const dir = makeDir();
|
||||
const lines = COMPLETE_ENV.trimEnd()
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith(`${key}=`));
|
||||
lines.push(`${key}=${bad}`);
|
||||
writeFileSync(envPath(dir), lines.join('\n') + '\n');
|
||||
|
||||
const result = run(['--non-interactive', '--dir', dir], { timeoutMs: 5000 });
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(outputOf(result)).toContain(key);
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it(
|
||||
'交互下端口非法会重新询问,最终接受合法值',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
// DB_PORT 连续给两个非法值,脚本应重新询问
|
||||
const input =
|
||||
['h', 'abc', '70000', '3306', 'u', 'p', 'd', '', '', ''].join('\n') + '\n';
|
||||
|
||||
const result = run(['--dir', dir], { input, timeoutMs: 10000 });
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
const content = readFileSync(envPath(dir), 'utf8');
|
||||
expect(content).toMatch(/^DB_PORT=3306$/m);
|
||||
expect(outputOf(result)).toContain('必须');
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it.each([['6*yGoR9lHz510nl*'], ['a%b*c-d_e.f']])(
|
||||
'口令含特殊字符 %s:不加引号写盘且 set -a; . .env 能读回同一个值',
|
||||
(password) => {
|
||||
const dir = makeDir();
|
||||
const input = ['h', '3306', 'u', password, 'd', '', '', ''].join('\n') + '\n';
|
||||
|
||||
const result = run(['--dir', dir], { input, timeoutMs: 10000 });
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
const content = readFileSync(envPath(dir), 'utf8');
|
||||
expect(content).toContain(`DB_PASS=${password}`);
|
||||
// 不得给值加引号(否则 shell 语义可能变化)
|
||||
expect(content).not.toMatch(/^DB_PASS=["']/m);
|
||||
|
||||
// 真实地用 shell source 一次,确认读回同一个值
|
||||
const sourced = spawnSync(
|
||||
'bash',
|
||||
['-c', 'set -a; . "$1"; printf "%s" "$DB_PASS"', '_', envPath(dir)],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
expect(sourced.status).toBe(0);
|
||||
expect(sourced.stdout).toBe(password);
|
||||
},
|
||||
15000,
|
||||
);
|
||||
});
|
||||
|
||||
describe('init-env.sh:--force / --check / 幂等', () => {
|
||||
it(
|
||||
'--force 在已有 .env 时重新生成,并提示 ADMIN_PORT === PORT 的单端口模式',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
writeFileSync(envPath(dir), COMPLETE_ENV); // 旧配置
|
||||
|
||||
const input =
|
||||
['newhost', '3406', 'newuser', 'newpass', 'newdb', '5100', '5100', '127.0.0.1'].join('\n') +
|
||||
'\n';
|
||||
const result = run(['--force', '--dir', dir], { input, timeoutMs: 10000 });
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
const content = readFileSync(envPath(dir), 'utf8');
|
||||
expect(content).toMatch(/^DB_HOST=newhost$/m);
|
||||
expect(content).toMatch(/^DB_PORT=3406$/m);
|
||||
expect(content).toMatch(/^DB_USER=newuser$/m);
|
||||
expect(content).toMatch(/^DB_PASS=newpass$/m);
|
||||
expect(content).toMatch(/^DB_NAME=newdb$/m);
|
||||
expect(content).toMatch(/^PORT=5100$/m);
|
||||
expect(content).toMatch(/^ADMIN_PORT=5100$/m);
|
||||
expect(content).toMatch(/^HOST=127\.0\.0\.1$/m);
|
||||
|
||||
expect(outputOf(result)).toContain('单端口');
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it(
|
||||
'已有完整 .env 且未加 --force:跳过询问、不修改文件(幂等)',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
writeFileSync(envPath(dir), COMPLETE_ENV);
|
||||
const before = readFileSync(envPath(dir), 'utf8');
|
||||
|
||||
// 即使管道里塞了数据也应当忽略(因为完整且未 --force)
|
||||
const result = run(['--dir', dir], { input: '\n\n\n\n\n\n\n\n', timeoutMs: 8000 });
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
expect(outputOf(result)).toContain('跳过询问');
|
||||
expect(readFileSync(envPath(dir), 'utf8')).toBe(before);
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it(
|
||||
'--check:合法则 0 且不改文件;缺字段则非 0',
|
||||
() => {
|
||||
const okDir = makeDir();
|
||||
writeFileSync(envPath(okDir), COMPLETE_ENV);
|
||||
const before = readFileSync(envPath(okDir), 'utf8');
|
||||
|
||||
const okResult = run(['--check', '--dir', okDir], { timeoutMs: 5000 });
|
||||
expect(okResult.error).toBeUndefined();
|
||||
expect(okResult.status).toBe(0);
|
||||
expect(readFileSync(envPath(okDir), 'utf8')).toBe(before);
|
||||
|
||||
const badDir = makeDir();
|
||||
writeFileSync(envPath(badDir), 'DB_HOST=h\n');
|
||||
const badResult = run(['--check', '--dir', badDir], { timeoutMs: 5000 });
|
||||
expect(badResult.error).toBeUndefined();
|
||||
expect(badResult.status).not.toBe(0);
|
||||
expect(outputOf(badResult)).toContain('DB_USER');
|
||||
expect(outputOf(badResult)).toContain('DB_PASS');
|
||||
},
|
||||
15000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,442 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { crc32, deflateRawSync } from 'node:zlib';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { ModelFormat } from '../src/common/enums';
|
||||
import { findZipEntry, readZipEntries } from '../src/common/zip-reader';
|
||||
import { embedGlbImages } from '../src/models/glb-textures';
|
||||
import { convertModelArchive, pickModelFile } from '../src/models/model-convert';
|
||||
import { renderDefaultModelSource } from '../src/models/default-model-source';
|
||||
import { DEFAULT_MODEL_SEED, hasDefaultModelSeed } from '../src/config/default-model';
|
||||
|
||||
/* ------------------------------------------------------------ ZIP 夹具 */
|
||||
|
||||
interface FakeEntry {
|
||||
path: string;
|
||||
content: Buffer;
|
||||
/** 0=STORE, 8=DEFLATE */
|
||||
method?: number;
|
||||
/** 置位通用标志位 0(加密) */
|
||||
encrypted?: boolean;
|
||||
/** 未知压缩方式 */
|
||||
badMethod?: number;
|
||||
}
|
||||
|
||||
/** 手写一个 ZIP(测试用;不依赖任何第三方库) */
|
||||
function buildZip(entries: FakeEntry[]): Buffer {
|
||||
const locals: Buffer[] = [];
|
||||
const centrals: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const name = Buffer.from(entry.path, 'utf8');
|
||||
const method = entry.badMethod ?? entry.method ?? 8;
|
||||
const raw = entry.content;
|
||||
const data = method === 8 ? deflateRawSync(raw) : raw;
|
||||
const checksum = crc32(raw);
|
||||
|
||||
const local = Buffer.alloc(30 + name.length);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE((entry.encrypted ? 0x0001 : 0) | 0x0800, 6);
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt32LE(checksum, 14);
|
||||
local.writeUInt32LE(data.length, 18);
|
||||
local.writeUInt32LE(raw.length, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
name.copy(local, 30);
|
||||
locals.push(local, data);
|
||||
|
||||
const central = Buffer.alloc(46 + name.length);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE((entry.encrypted ? 0x0001 : 0) | 0x0800, 8);
|
||||
central.writeUInt16LE(method, 10);
|
||||
central.writeUInt32LE(checksum, 16);
|
||||
central.writeUInt32LE(data.length, 20);
|
||||
central.writeUInt32LE(raw.length, 24);
|
||||
central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt32LE(offset, 42);
|
||||
name.copy(central, 46);
|
||||
centrals.push(central);
|
||||
|
||||
offset += local.length + data.length;
|
||||
}
|
||||
|
||||
const centralBuf = Buffer.concat(centrals);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(entries.length, 8);
|
||||
eocd.writeUInt16LE(entries.length, 10);
|
||||
eocd.writeUInt32LE(centralBuf.length, 12);
|
||||
eocd.writeUInt32LE(offset, 16);
|
||||
return Buffer.concat([...locals, centralBuf, eocd]);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- 假 Assimp(对照实验) */
|
||||
|
||||
let workRoot: string;
|
||||
let fakeAssimp: string;
|
||||
|
||||
/**
|
||||
* 假 Assimp:用真实 Assimp 的"FBX 导出会写坏文件但可能返回非 0"的行为做对照。
|
||||
* 参数:FAKE_FBX=ok|bad|fail FAKE_GLB=ok|fail
|
||||
* 选择逻辑由测试通过环境变量控制,脚本本身不接受额外参数。
|
||||
*/
|
||||
function writeFakeAssimp(): void {
|
||||
const script = `#!/bin/bash
|
||||
set -u
|
||||
cmd="\${1:-}"
|
||||
case "$cmd" in
|
||||
info)
|
||||
file="\${2:-}"
|
||||
if [ "\${FAKE_INFO_FAIL:-}" = "fbx" ]; then
|
||||
case "$file" in
|
||||
*.fbx)
|
||||
echo "ERROR: Failed to load file: FBX-DOM no Objects dictionary found" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
export)
|
||||
target="\${3:-}"
|
||||
case "$target" in
|
||||
*.fbx)
|
||||
if [ "\${FAKE_FBX:-ok}" = "fail" ]; then
|
||||
echo "Failed to write file" >&2
|
||||
exit 11
|
||||
fi
|
||||
printf 'FAKE-FBX-CONTENT-0123456789' > "$target"
|
||||
;;
|
||||
*.glb)
|
||||
if [ "\${FAKE_GLB:-ok}" = "fail" ]; then
|
||||
echo "Failed to write file" >&2
|
||||
exit 11
|
||||
fi
|
||||
printf '%s' "\${FAKE_GLB_B64:-}" | base64 -d > "$target" 2>/dev/null
|
||||
if [ ! -s "$target" ]; then printf 'FAKE-GLB-CONTENT-0123456789' > "$target"; fi
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
`;
|
||||
writeFileSync(fakeAssimp, script, { mode: 0o755 });
|
||||
}
|
||||
|
||||
/** 构造一个最小 GLB(单 mesh + 两张外部贴图) */
|
||||
function buildGlbWithExternalImages(): Buffer {
|
||||
const json = {
|
||||
asset: { version: '2.0' },
|
||||
buffers: [{ byteLength: 4 }],
|
||||
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 4 }],
|
||||
images: [{ uri: 'textures\\a.png' }, { uri: 'textures/b.png' }],
|
||||
meshes: [{ primitives: [{ attributes: {} }] }],
|
||||
};
|
||||
const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8');
|
||||
const jsonPadded = Buffer.alloc(jsonBuf.length + ((4 - (jsonBuf.length % 4)) % 4), 0x20);
|
||||
jsonBuf.copy(jsonPadded);
|
||||
const bin = Buffer.from([1, 2, 3, 4]);
|
||||
const total = 12 + 8 + jsonPadded.length + 8 + bin.length;
|
||||
const out = Buffer.alloc(total);
|
||||
out.writeUInt32LE(0x46546c67, 0);
|
||||
out.writeUInt32LE(2, 4);
|
||||
out.writeUInt32LE(total, 8);
|
||||
out.writeUInt32LE(jsonPadded.length, 12);
|
||||
out.writeUInt32LE(0x4e4f534a, 16);
|
||||
jsonPadded.copy(out, 20);
|
||||
const binHeader = 20 + jsonPadded.length;
|
||||
out.writeUInt32LE(bin.length, binHeader);
|
||||
out.writeUInt32LE(0x004e4942, binHeader + 4);
|
||||
bin.copy(out, binHeader + 8);
|
||||
return out;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
workRoot = mkdtempSync(join(tmpdir(), 'opendance-model-test-'));
|
||||
fakeAssimp = join(workRoot, 'fake-assimp');
|
||||
writeFakeAssimp();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(workRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ 测试 */
|
||||
|
||||
describe('ZIP 读取器', () => {
|
||||
it('解析 STORE 与 DEFLATE 条目,内容与声明长度一致', () => {
|
||||
const zip = buildZip([
|
||||
{ path: 'Hatsune Miku.pmx', content: Buffer.from('PMX '.repeat(100)), method: 8 },
|
||||
{ path: 'textures/a.png', content: Buffer.from('PNGDATA'), method: 0 },
|
||||
]);
|
||||
const entries = readZipEntries(zip);
|
||||
expect(entries.map((e) => e.path).sort()).toEqual(['Hatsune Miku.pmx', 'textures/a.png']);
|
||||
for (const entry of entries) {
|
||||
expect(entry.read().length).toBe(entry.size);
|
||||
}
|
||||
expect(findZipEntry(entries, 'HATSUNE MIKU.PMX')?.path).toBe('Hatsune Miku.pmx');
|
||||
});
|
||||
|
||||
it('拒绝上跳路径(zip-slip)', () => {
|
||||
const zip = buildZip([{ path: '../../etc/passwd', content: Buffer.from('x') }]);
|
||||
expect(() => readZipEntries(zip)).toThrow(/上跳路径/);
|
||||
});
|
||||
|
||||
it('拒绝加密条目', () => {
|
||||
const zip = buildZip([{ path: 'a.pmx', content: Buffer.from('x'), encrypted: true }]);
|
||||
expect(() => readZipEntries(zip)).toThrow(/已加密/);
|
||||
});
|
||||
|
||||
it('拒绝不支持的压缩方式', () => {
|
||||
const zip = buildZip([{ path: 'a.pmx', content: Buffer.from('x'), badMethod: 12 }]);
|
||||
const entries = readZipEntries(zip);
|
||||
expect(() => entries[0].read()).toThrow(/不支持的 ZIP 压缩方式 12/);
|
||||
});
|
||||
|
||||
it('不是 ZIP 时给出清晰错误', () => {
|
||||
expect(() => readZipEntries(Buffer.from('hello world, not a zip at all'))).toThrow(/EOCD|ZIP/);
|
||||
});
|
||||
|
||||
it('目录条目被跳过', () => {
|
||||
const zip = buildZip([
|
||||
{ path: 'textures/', content: Buffer.alloc(0), method: 0 },
|
||||
{ path: 'textures/a.png', content: Buffer.from('PNG'), method: 0 },
|
||||
]);
|
||||
expect(readZipEntries(zip).map((e) => e.path)).toEqual(['textures/a.png']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('主模型挑选规则', () => {
|
||||
const entry = (path: string, size: number) => ({ path, size, method: 8, read: () => Buffer.alloc(0) });
|
||||
|
||||
it('优先指名字(大小写与目录不敏感)', () => {
|
||||
const entries = [
|
||||
entry('Shadekai-1.1/Shadekai_Controller.pmx', 2213),
|
||||
entry('Hatsune Miku.pmx', 833043),
|
||||
];
|
||||
expect(pickModelFile(entries, 'Hatsune Miku.pmx')?.path).toBe('Hatsune Miku.pmx');
|
||||
expect(pickModelFile(entries, 'hAtSuNe MiKu.PMX')?.path).toBe('Hatsune Miku.pmx');
|
||||
expect(pickModelFile(entries, 'sub/Hatsune Miku.pmx')?.path).toBe('Hatsune Miku.pmx');
|
||||
});
|
||||
|
||||
it('没有指名字时取目录最浅,其次文件最大', () => {
|
||||
const entries = [
|
||||
entry('deep/inner/model.pmx', 999999),
|
||||
entry('root.pmx', 100),
|
||||
entry('another-root.pmx', 500),
|
||||
];
|
||||
expect(pickModelFile(entries)?.path).toBe('another-root.pmx');
|
||||
});
|
||||
|
||||
it('没有 PMX 时返回 undefined', () => {
|
||||
expect(pickModelFile([entry('readme.txt', 10)])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GLB 贴图内嵌', () => {
|
||||
it('把外部贴图搬进 BIN chunk 并补 bufferView / mimeType', () => {
|
||||
const glb = buildGlbWithExternalImages();
|
||||
const files: Record<string, Buffer> = {
|
||||
'textures/a.png': Buffer.from([9, 9, 9, 9, 9]),
|
||||
'textures/b.png': Buffer.from([7, 7, 7]),
|
||||
};
|
||||
const result = embedGlbImages(glb, (uri) => files[uri.replace(/\\/g, '/')] ?? null);
|
||||
expect(result.embedded).toBe(2);
|
||||
expect(result.unresolved).toEqual([]);
|
||||
|
||||
// 重新解析,确认 image 已改为 bufferView 且没有 uri
|
||||
const jsonLength = result.glb.readUInt32LE(12);
|
||||
const json = JSON.parse(result.glb.subarray(20, 20 + jsonLength).toString('utf8'));
|
||||
expect(json.images.every((image: Record<string, unknown>) => image.uri === undefined)).toBe(true);
|
||||
expect(json.images.map((image: Record<string, unknown>) => image.mimeType)).toEqual([
|
||||
'image/png',
|
||||
'image/png',
|
||||
]);
|
||||
expect(json.bufferViews).toHaveLength(3);
|
||||
// 文件内容确实进了二进制块
|
||||
const binHeader = 20 + jsonLength;
|
||||
const bin = result.glb.subarray(binHeader + 8);
|
||||
expect(bin.includes(Buffer.from([9, 9, 9, 9, 9]))).toBe(true);
|
||||
expect(bin.includes(Buffer.from([7, 7, 7]))).toBe(true);
|
||||
});
|
||||
|
||||
it('找不到贴图时保留 uri 并记入 unresolved', () => {
|
||||
const glb = buildGlbWithExternalImages();
|
||||
const result = embedGlbImages(glb, () => null);
|
||||
expect(result.embedded).toBe(0);
|
||||
expect(result.unresolved).toEqual(['textures\\a.png', 'textures/b.png']);
|
||||
expect(result.glb.equals(glb)).toBe(true);
|
||||
});
|
||||
|
||||
it('不认识的扩展名不内嵌', () => {
|
||||
const glb = buildGlbWithExternalImages();
|
||||
const result = embedGlbImages(glb, () => Buffer.from('x'));
|
||||
// a.png / b.png 都是已知扩展名,这里改成断言 mimeType 正确即可
|
||||
expect(result.embedded).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('压缩包 → 模型转换(用假 Assimp 做对照)', () => {
|
||||
const archive = () =>
|
||||
buildZip([
|
||||
{ path: 'Hatsune Miku.pmx', content: Buffer.from('PMX-MODEL-BYTES') },
|
||||
{ path: 'textures/a.png', content: Buffer.from([1, 2, 3, 4, 5]) },
|
||||
{ path: 'textures/b.png', content: Buffer.from([6, 7, 8]) },
|
||||
]);
|
||||
|
||||
// 让假 Assimp "导出"的 GLB 是一个真实可解析的 GLB(含两张外部贴图),
|
||||
// 这样贴图内嵌路径也能被覆盖到
|
||||
const fakeGlbB64 = buildGlbWithExternalImages().toString('base64');
|
||||
|
||||
const runConvert = (env: Record<string, string>, preferFormat: 'fbx' | 'glb' = 'fbx') => {
|
||||
const previous = { ...process.env };
|
||||
Object.assign(process.env, { FAKE_GLB_B64: fakeGlbB64, ...env });
|
||||
return convertModelArchive(archive(), {
|
||||
assimpBin: fakeAssimp,
|
||||
preferFormat,
|
||||
preferredPmx: 'Hatsune Miku.pmx',
|
||||
timeoutMs: 20_000,
|
||||
}).finally(() => {
|
||||
process.env = previous;
|
||||
});
|
||||
};
|
||||
|
||||
it('FBX 导出成功且可回读 → 用 FBX', async () => {
|
||||
const result = await runConvert({ FAKE_FBX: 'ok', FAKE_GLB: 'ok' });
|
||||
expect(result.format).toBe(ModelFormat.Fbx);
|
||||
expect(result.sourceModelPath).toBe('Hatsune Miku.pmx');
|
||||
expect(result.attempts).toHaveLength(1);
|
||||
expect(result.attempts[0]).toContain('回读校验通过');
|
||||
});
|
||||
|
||||
it('FBX 导出退出码非 0 → 自动回退 GLB 并记录原因', async () => {
|
||||
const result = await runConvert({ FAKE_FBX: 'fail', FAKE_GLB: 'ok' });
|
||||
expect(result.format).toBe(ModelFormat.Glb);
|
||||
expect(result.attempts).toHaveLength(2);
|
||||
expect(result.attempts[0]).toContain('FBX');
|
||||
expect(result.attempts[0]).toContain('导出失败');
|
||||
expect(result.attempts[1]).toContain('GLB');
|
||||
expect(result.note).toContain('Hatsune Miku.pmx → GLB');
|
||||
// 贴图从压缩包里被内嵌进 GLB
|
||||
expect(result.note).toContain('已内嵌 2 张贴图');
|
||||
});
|
||||
|
||||
it('FBX 退出码为 0 但产物损坏(回读失败)→ 同样回退 GLB', async () => {
|
||||
// 对照 Assimp 的真实行为:命令"成功"写出了文件,但文件读不回来
|
||||
const result = await runConvert({ FAKE_FBX: 'ok', FAKE_GLB: 'ok', FAKE_INFO_FAIL: 'fbx' });
|
||||
expect(result.attempts[0]).toContain('无法回读');
|
||||
expect(result.format).toBe(ModelFormat.Glb);
|
||||
expect(result.attempts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('两种格式都失败 → 抛出可读错误并提示安装 Assimp', async () => {
|
||||
await expect(runConvert({ FAKE_FBX: 'fail', FAKE_GLB: 'fail' })).rejects.toThrow(
|
||||
/模型转换失败[\s\S]*Assimp/,
|
||||
);
|
||||
});
|
||||
|
||||
it('压缩包里没有 PMX → 明确报错', async () => {
|
||||
const zip = buildZip([{ path: 'readme.txt', content: Buffer.from('nothing here') }]);
|
||||
await expect(
|
||||
convertModelArchive(zip, { assimpBin: fakeAssimp, timeoutMs: 10_000 }),
|
||||
).rejects.toThrow(/没有找到 \.pmx/);
|
||||
});
|
||||
|
||||
it('assimp 不存在时给出可读错误', async () => {
|
||||
await expect(
|
||||
convertModelArchive(archive(), { assimpBin: join(workRoot, 'no-such-assimp'), timeoutMs: 5_000 }),
|
||||
).rejects.toThrow(/模型转换失败/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('默认模型写入源码', () => {
|
||||
it('渲染出的源码包含种子值且结构完整', () => {
|
||||
const content = renderDefaultModelSource({
|
||||
id: 16,
|
||||
name: '初音未来 Hatsune Miku',
|
||||
format: 'glb',
|
||||
sha256: 'a'.repeat(64),
|
||||
convertedFrom: 'Hatsune Miku.pmx → GLB',
|
||||
updatedAt: '2026-09-15T00:00:00.000Z',
|
||||
});
|
||||
expect(content).toContain('export interface DefaultModelSeed');
|
||||
expect(content).toContain("name: \"初音未来 Hatsune Miku\"");
|
||||
expect(content).toContain('id: 16');
|
||||
expect(content).toContain('sha256: "' + 'a'.repeat(64) + '"');
|
||||
expect(content).toContain('convertedFrom: "Hatsune Miku.pmx → GLB"');
|
||||
expect(content).toContain('export function hasDefaultModelSeed');
|
||||
});
|
||||
|
||||
it('空种子渲染为 null 而不是空字符串', () => {
|
||||
const content = renderDefaultModelSource({
|
||||
id: null,
|
||||
name: '',
|
||||
format: '',
|
||||
sha256: '',
|
||||
convertedFrom: null,
|
||||
updatedAt: null,
|
||||
});
|
||||
expect(content).toContain('id: null');
|
||||
expect(content).toContain('convertedFrom: null');
|
||||
expect(content).toContain('updatedAt: null');
|
||||
});
|
||||
|
||||
it('hasDefaultModelSeed 以 sha256 为准', () => {
|
||||
// 注意:src/config/default-model.ts 是**会被服务端改写**的文件,
|
||||
// 因此这里只断言显式传入的种子,不对文件当前内容做假设。
|
||||
expect(
|
||||
hasDefaultModelSeed({
|
||||
id: null,
|
||||
name: '',
|
||||
format: '',
|
||||
sha256: ' ',
|
||||
convertedFrom: null,
|
||||
updatedAt: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(hasDefaultModelSeed(DEFAULT_MODEL_SEED)).toBe(
|
||||
DEFAULT_MODEL_SEED.sha256.trim().length > 0,
|
||||
);
|
||||
expect(
|
||||
hasDefaultModelSeed({
|
||||
id: 1,
|
||||
name: 'x',
|
||||
format: 'glb',
|
||||
sha256: 'b'.repeat(64),
|
||||
convertedFrom: null,
|
||||
updatedAt: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('渲染结果能被 TypeScript 语法接受(用 tsc 校验临时文件)', () => {
|
||||
const content = renderDefaultModelSource({
|
||||
id: 3,
|
||||
name: '带"引号"与\\反斜杠的模型',
|
||||
format: 'glb',
|
||||
sha256: 'c'.repeat(64),
|
||||
convertedFrom: 'a.pmx → GLB',
|
||||
updatedAt: '2026-09-15T00:00:00.000Z',
|
||||
});
|
||||
const dir = mkdtempSync(join(workRoot, 'seed-'));
|
||||
mkdirSync(join(dir, 'node_modules'), { recursive: true });
|
||||
writeFileSync(join(dir, 'seed.ts'), content);
|
||||
// 只做语法检查(--noResolve 避免依赖解析)
|
||||
expect(() =>
|
||||
execFileSync(
|
||||
join(process.cwd(), 'node_modules', '.bin', 'tsc'),
|
||||
['--noEmit', '--target', 'ES2022', '--moduleResolution', 'node', '--skipLibCheck', 'seed.ts'],
|
||||
{ cwd: dir, stdio: 'pipe' },
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(readFileSync(join(dir, 'seed.ts'), 'utf8')).toContain('带\\"引号\\"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user