Files
opendance-web/web-server/test/model-archive.spec.ts
T
opendance da03777995 fix(models): Assimp 缺失时给出可操作报错,部署脚本自动安装
现象:服务器未安装 Assimp,上传 PMX 压缩包只得到
  GLB 直出:导出失败(ENOENT):spawn assimp ENOENT
  glTF 直出:导出失败(ENOENT):spawn assimp ENOENT
用户看不出缺什么、怎么装,也不知道只传 glb/vrm 其实不需要它。

修复:
1. 新增 src/common/assimp.ts:按 ASSIMP_BIN -> PATH -> /usr/bin、/usr/local/bin
   等常见目录定位可执行文件(只认文件,不认同名目录),转换前先预检;
   找不到就一次性给出各发行版安装命令、查过的路径与替代办法,
   不再打两条 ENOENT。动作预处理(FBX/glTF -> BVH)复用同一文案。
2. 新增 deploy/assimp-lib.sh:install.sh 按发行版自动安装
   (apt-get / dnf / yum / zypper / apk / pacman),装在非常规目录时把绝对
   路径写入 deploy/.env 的 ASSIMP_BIN;装不上只告警不中止,可用
   SKIP_ASSIMP_INSTALL=1 跳过。check-access.sh 自检增加 Assimp 一行。
3. /health 增加 assimp.path;管理端「概览 -> 服务健康」显示 Assimp 状态,
   不可用时给出安装提示。
4. 新增 test/assimp-locate.spec.ts 13 项;强化「assimp 不存在」用例断言安装命令。
   测试规模 12 文件 / 193 项。
5. 版本 0.2.2,README(.md/.txt)/DSH.md 同步(前置要求、FAQ、环境变量说明)。
2026-09-16 02:55:32 +08:00

531 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 不存在时给出可读错误(含安装命令与替代办法,而不是 spawn ENOENT)', async () => {
const promise = convertModelArchive(pmxArchive(), {
assimpBin: join(workRoot, 'no-such-assimp'),
timeoutMs: 5_000,
});
await expect(promise).rejects.toThrow(/模型转换失败/);
await expect(
convertModelArchive(pmxArchive(), {
assimpBin: join(workRoot, 'no-such-assimp'),
timeoutMs: 5_000,
}),
).rejects.toThrow(/apt-get install -y assimp-utils[\s\S]*\.glb \/ \.vrm/);
});
});
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('带\\"引号\\"');
});
});