需求 1:系统确定以 GLB / VRM 为模型主力格式
- 服务端新增支持库:
- model-inspector.ts:解析 GLB/VRM 的面数、顶点、材质、贴图、形变目标、
**骨骼树(名称/层级/是否蒙皮/humanoid 映射/子节点数)**、humanoid 覆盖率与可驱动性
- model-thumbnail.ts:**纯 TS 软件光栅化**(z-buffer + Lambert + 完整蒙皮矩阵)
生成 512×512 PNG 缩略图,零原生依赖、不装 headless-gl
- vrm-writer.ts:GLB → VRM 1.0(写入 VRMC_vrm 扩展与 humanoid 映射)
- gltf-packer.ts:glTF(.gltf + .bin + 贴图)→ 单文件 GLB
- humanoid 候选表与 Godot 客户端 humanoid_map.gd **逐字对齐**,并有单测断言两边一致
(服务端判定的"可驱动" = 客户端实际能否驱动)
需求 2:模型管理
- .glb / .vrm / .gltf **直接导入**(不重编码,GLB 含 VRM 扩展会自动识别为 VRM)
- .pmx / .fbx 服务端转换:assimp → .glb →(失败)→ .gltf 后自行打包成 GLB
→(MODEL_CONVERT_FORMAT=vrm 时)补 VRMC_vrm 产出 VRM 1.0;每步 assimp info 回读校验
- .zip **自动搜索包内可用模型**:.vrm → .glb → .gltf(直接导入)优先,
其次 .pmx → .fbx(转换);同优先级内按"指名字 → 目录最浅 → 文件最大"
- 入库后自动分析(models.stats)与生成缩略图(models.thumbnail_asset_id),
失败均不阻断入库;管理端列表显示小缩略图、预览放开到 vrm/glb/gltf
需求 3:审核功能
- 上传的模型状态**一律 pending**(不再自动发布、不再自动设默认)
- 审核界面新增模型面板:体积、面数、顶点、材质、贴图、骨骼、形变目标等指标,
humanoid 覆盖进度、可驱动性结论(缺哪些必需骨骼)、warnings,以及
**骨骼详细预览**(层级缩进、蒙皮/Humanoid 徽章、子节点数、搜索保留祖先链、
仅看蒙皮、限高滚动 + 只渲染前 400 行)
- 审核通过可带 setPrimary=true 一并设为默认模型(此时才写源码种子);
POST /api/models/:id/primary 仅允许 published(否则 409)
- scripts/import-default-model.mjs 改为"上传 → 审核通过 + 设默认"流程
其它:
- models 新增 stats 列(迁移 AddModelStats1789460000000,已在真实库执行)
- 契约升至 rest-api/data-models/model-formats 0.4.0、database 0.3.0(另两库已同步)
- 版本号 0.1.0 → 0.2.0(package.json ×2 + lockfile + project.yaml)
- 文档:README.txt / DSH.md / project.yaml / tasks 005·007·010 全面更新;
顶层开发书更新为 v1.4
验证:168 项单测通过(10 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实端到端:MMD 压缩包 → GLB + 分析(面数 9071/骨骼 238/humanoid 0-22) + 缩略图(15KB PNG);
GLB 直接导入 sha256 与源文件一致;审核通过后 defaultModelId 由 VRM 变为该模型
526 lines
19 KiB
TypeScript
526 lines
19 KiB
TypeScript
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
|
||
if [ "\${FAKE_INFO_FAIL:-}" = "all" ]; then
|
||
echo "ERROR: Failed to load file" >&2; exit 1
|
||
fi
|
||
exit 0
|
||
;;
|
||
export)
|
||
target="\${3:-}"
|
||
case "$target" in
|
||
*.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
|
||
;;
|
||
*.gltf)
|
||
if [ "\${FAKE_GLTF:-fail}" = "fail" ]; then
|
||
echo "Failed to write file" >&2
|
||
exit 11
|
||
fi
|
||
# 写出 glTF JSON 与配套 .bin(打包器要能把它们合成 GLB)
|
||
bin="\${target%.gltf}.bin"
|
||
printf '\x01\x02\x03\x04\x05\x06\x07\x08' > "$bin"
|
||
printf '%s' "\${FAKE_GLTF_JSON:-}" > "$target"
|
||
;;
|
||
*) 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;
|
||
}
|
||
|
||
/** 构造一个节点名能映射到 16 根 humanoid 必需骨骼的最小 GLB */
|
||
function buildHumanoidGlb(): Buffer {
|
||
const names = [
|
||
'Hips', 'Spine', 'Neck', 'Head',
|
||
'LeftUpperArm', 'LeftLowerArm', 'LeftHand',
|
||
'RightUpperArm', 'RightLowerArm', 'RightHand',
|
||
'LeftUpperLeg', 'LeftLowerLeg', 'LeftFoot',
|
||
'RightUpperLeg', 'RightLowerLeg', 'RightFoot',
|
||
];
|
||
const json = {
|
||
asset: { version: '2.0' },
|
||
scene: 0,
|
||
scenes: [{ nodes: [0] }],
|
||
nodes: names.map((name, index) => ({
|
||
name,
|
||
...(index + 1 < names.length ? { children: [index + 1] } : {}),
|
||
})),
|
||
};
|
||
const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8');
|
||
const jsonPadded = Buffer.alloc(jsonBuf.length + ((4 - (jsonBuf.length % 4)) % 4), 0x20);
|
||
jsonBuf.copy(jsonPadded);
|
||
const total = 12 + 8 + jsonPadded.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);
|
||
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 做对照)', () => {
|
||
/** 一个含 PMX 的最小模型包 */
|
||
const pmxArchive = () =>
|
||
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]) },
|
||
]);
|
||
|
||
const fakeGlbB64 = buildGlbWithExternalImages().toString('base64');
|
||
|
||
const runConvert = (
|
||
archive: Buffer,
|
||
env: Record<string, string> = {},
|
||
preferFormat: 'glb' | 'vrm' = 'glb',
|
||
) => {
|
||
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('GLB 直出成功 → 交付 GLB 并内嵌贴图', async () => {
|
||
const result = await runConvert(pmxArchive(), { FAKE_GLB: 'ok' });
|
||
expect(result.format).toBe(ModelFormat.Glb);
|
||
expect(result.direct).toBe(false);
|
||
expect(result.sourceModelPath).toBe('Hatsune Miku.pmx');
|
||
expect(result.note).toContain('已内嵌 2 张贴图');
|
||
expect(result.attempts[0]).toContain('回读校验通过');
|
||
});
|
||
|
||
it('GLB 直出失败 → 退回 glTF 导出再自行打包成 GLB', async () => {
|
||
const gltfJson = JSON.stringify({
|
||
asset: { version: '2.0' },
|
||
buffers: [{ byteLength: 8, uri: 'converted.bin' }],
|
||
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 8 }],
|
||
images: [{ uri: 'textures/a.png' }],
|
||
meshes: [{ primitives: [{ attributes: {} }] }],
|
||
});
|
||
const result = await runConvert(pmxArchive(), {
|
||
FAKE_GLB: 'fail',
|
||
FAKE_GLTF: 'ok',
|
||
FAKE_GLTF_JSON: gltfJson,
|
||
});
|
||
expect(result.format).toBe(ModelFormat.Glb);
|
||
expect(result.attempts.some((line) => line.includes('GLB 直出:导出失败'))).toBe(true);
|
||
expect(result.attempts.some((line) => line.includes('GLB 打包:由 glTF 打包成功'))).toBe(true);
|
||
// 打包产物是合法 GLB(长度自洽)
|
||
expect(result.buffer.readUInt32LE(0)).toBe(0x46546c67);
|
||
expect(result.buffer.readUInt32LE(8)).toBe(result.buffer.length);
|
||
});
|
||
|
||
it('两种路径都失败 → 抛出可读错误并提示安装 Assimp', async () => {
|
||
await expect(
|
||
runConvert(pmxArchive(), { FAKE_GLB: 'fail', FAKE_GLTF: 'fail' }),
|
||
).rejects.toThrow(/模型转换失败[\s\S]*Assimp/);
|
||
});
|
||
|
||
it('preferFormat=vrm 且 humanoid 齐全 → 交付 VRM;不全 → 回退 GLB 并说明', async () => {
|
||
// 含 16 根 humanoid 骨骼的 GLB
|
||
const humanoidB64 = buildHumanoidGlb().toString('base64');
|
||
const wrapped = await runConvert(pmxArchive(), { FAKE_GLB_B64: humanoidB64 }, 'vrm');
|
||
expect(wrapped.format).toBe(ModelFormat.Vrm);
|
||
expect(wrapped.note).toContain('VRM');
|
||
|
||
// 骨骼名对不上的 GLB → 回退 GLB
|
||
const fallback = await runConvert(pmxArchive(), { FAKE_GLB_B64: fakeGlbB64 }, 'vrm');
|
||
expect(fallback.format).toBe(ModelFormat.Glb);
|
||
expect(fallback.attempts.some((line) => line.includes('VRM 封装:失败'))).toBe(true);
|
||
});
|
||
|
||
it('包内是 .vrm / .glb 时直接导入,不做重编码', async () => {
|
||
const vrmArchive = buildZip([
|
||
{ path: 'sub/model.vrm', content: buildHumanoidGlb() },
|
||
{ path: 'model.pmx', content: Buffer.from('PMX-BYTES') },
|
||
]);
|
||
const result = await runConvert(vrmArchive, { FAKE_GLB: 'fail', FAKE_GLTF: 'fail' });
|
||
// .vrm 优先级高于 .pmx:即使用 assimp 会失败也应当成功(因为根本不调用它)
|
||
expect(result.direct).toBe(true);
|
||
expect(result.format).toBe(ModelFormat.Vrm);
|
||
expect(result.sourceModelPath).toBe('sub/model.vrm');
|
||
expect(result.note).toContain('直接导入');
|
||
});
|
||
|
||
it('包内只有 .glb 时直接导入为 GLB', async () => {
|
||
const glbArchive = buildZip([{ path: 'a.glb', content: buildGlbWithExternalImages() }]);
|
||
const result = await runConvert(glbArchive);
|
||
expect(result.direct).toBe(true);
|
||
expect(result.format).toBe(ModelFormat.Glb);
|
||
});
|
||
|
||
it('压缩包里没有可用模型 → 明确报错', async () => {
|
||
const zip = buildZip([{ path: 'readme.txt', content: Buffer.from('nothing here') }]);
|
||
await expect(convertModelArchive(zip, { assimpBin: fakeAssimp })).rejects.toThrow(
|
||
/没有找到可用的模型文件/,
|
||
);
|
||
});
|
||
|
||
it('assimp 不存在时给出可读错误', async () => {
|
||
await expect(
|
||
convertModelArchive(pmxArchive(), {
|
||
assimpBin: join(workRoot, 'no-such-assimp'),
|
||
timeoutMs: 5_000,
|
||
}),
|
||
).rejects.toThrow(/模型转换失败/);
|
||
});
|
||
});
|
||
|
||
describe('默认模型写入源码', () => {
|
||
it('渲染出的源码包含种子值且结构完整', () => {
|
||
const content = renderDefaultModelSource({
|
||
id: 16,
|
||
name: '初音未来 Hatsune Miku',
|
||
format: 'glb',
|
||
sourceSha256: 'a'.repeat(64),
|
||
fileSha256: 'b'.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('sourceSha256: "' + 'a'.repeat(64) + '"');
|
||
expect(content).toContain('fileSha256: "' + 'b'.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: '',
|
||
sourceSha256: '',
|
||
fileSha256: '',
|
||
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: '',
|
||
sourceSha256: ' ',
|
||
fileSha256: '',
|
||
convertedFrom: null,
|
||
updatedAt: null,
|
||
}),
|
||
).toBe(false);
|
||
expect(hasDefaultModelSeed(DEFAULT_MODEL_SEED)).toBe(
|
||
DEFAULT_MODEL_SEED.sourceSha256.trim().length > 0 ||
|
||
DEFAULT_MODEL_SEED.fileSha256.trim().length > 0,
|
||
);
|
||
expect(
|
||
hasDefaultModelSeed({
|
||
id: 1,
|
||
name: 'x',
|
||
format: 'glb',
|
||
sourceSha256: 'b'.repeat(64),
|
||
fileSha256: '',
|
||
convertedFrom: null,
|
||
updatedAt: null,
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
|
||
it('渲染结果能被 TypeScript 语法接受(用 tsc 校验临时文件)', () => {
|
||
const content = renderDefaultModelSource({
|
||
id: 3,
|
||
name: '带"引号"与\\反斜杠的模型',
|
||
format: 'glb',
|
||
sourceSha256: 'c'.repeat(64),
|
||
fileSha256: 'd'.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('带\\"引号\\"');
|
||
});
|
||
});
|