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、环境变量说明)。
This commit is contained in:
opendance
2026-09-16 02:55:32 +08:00
parent e9de740d66
commit da03777995
20 changed files with 579 additions and 53 deletions
+200
View File
@@ -0,0 +1,200 @@
import { spawnSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import {
ASSIMP_FALLBACK_DIRS,
assimpInstallHint,
assimpMissingMessage,
detectAssimpBin,
resolveAssimpBinary,
} from '../src/common/assimp';
import { convertModelFile } from '../src/models/model-convert';
/**
* Assimp 缺失时的可诊断性。
*
* 背景(真实故障):服务器没装 Assimp,上传 PMX 压缩包只得到
* GLB 直出:导出失败(ENOENT):spawn assimp ENOENT
* glTF 直出:导出失败(ENOENT):spawn assimp ENOENT
* 用户看不出「缺什么、怎么装」。现在:
* * 转换前先定位二进制,找不到就一次性给出安装命令与查过的路径;
* * 定位逻辑覆盖 PATH 之外的常见目录(服务由 systemd 托管时 PATH 可能很窄)。
*/
let workRoot = '';
let fakeBin = '';
function findDeployLib(): string {
const candidates = [
resolve(process.cwd(), '..', 'deploy', 'assimp-lib.sh'),
resolve(process.cwd(), 'deploy', 'assimp-lib.sh'),
resolve(process.cwd(), '..', '..', 'deploy', 'assimp-lib.sh'),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
throw new Error(`找不到 deploy/assimp-lib.sh,候选路径:\n${candidates.join('\n')}`);
}
const ASSIMP_LIB = findDeployLib();
/** 写一个最小的假 assimp:只实现 `version` */
function writeFakeAssimp(path: string, versionLine = 'Assimp version 6.0.4'): string {
mkdirSync(join(path, '..'), { recursive: true });
writeFileSync(path, `#!/bin/bash\n[ "\${1:-}" = "version" ] && echo "${versionLine}"\nexit 0\n`);
chmodSync(path, 0o755);
return path;
}
beforeAll(() => {
workRoot = mkdtempSync(join(tmpdir(), 'opendance-assimp-test-'));
fakeBin = writeFakeAssimp(join(workRoot, 'bin', 'assimp'));
});
afterAll(() => {
rmSync(workRoot, { recursive: true, force: true });
});
const savedPath = process.env.PATH;
afterEach(() => {
process.env.PATH = savedPath;
});
describe('Assimp 定位(resolveAssimpBinary)', () => {
it('能在 PATH 中找到可执行文件', () => {
process.env.PATH = `${join(workRoot, 'bin')}:/usr/bin:/bin`;
const result = resolveAssimpBinary('assimp');
expect(result.found).toBe(true);
expect(result.path).toBe(fakeBin);
});
it('显式绝对路径优先,不存在时 found=false 并给出查找记录', () => {
const missing = join(workRoot, 'no-such-assimp');
const result = resolveAssimpBinary(missing);
expect(result.found).toBe(false);
expect(result.searched).toContain(missing);
const present = resolveAssimpBinary(fakeBin);
expect(present.found).toBe(true);
expect(present.path).toBe(fakeBin);
});
it('PATH 里没有时仍会查常见系统目录(systemd 服务 PATH 可能很窄)', () => {
process.env.PATH = '/nonexistent-dir-only';
const result = resolveAssimpBinary('assimp');
// 结果取决于本机是否装了真实 assimp:两种都必须把常见目录查过
for (const dir of ASSIMP_FALLBACK_DIRS) {
expect(result.searched).toContain(join(dir, 'assimp'));
}
if (!result.found) expect(result.path).toBe('assimp');
});
});
describe('Assimp 缺失时的错误信息', () => {
it('包含二进制名、各发行版安装命令、替代办法与查过的路径', () => {
const message = assimpMissingMessage('assimp', ['/usr/bin/assimp', '/usr/local/bin/assimp']);
expect(message).toContain('assimp');
expect(message).toContain('apt-get install -y assimp-utils');
expect(message).toContain('dnf install -y assimp');
expect(message).toContain('/usr/bin/assimp');
expect(message).toContain('ASSIMP_BIN');
expect(message).toContain('.glb / .vrm');
});
it('安装提示各发行版一条命令', () => {
const hint = assimpInstallHint();
for (const distro of ['Debian / Ubuntu', 'Alpine', 'Arch']) {
expect(hint).toContain(distro);
}
});
});
describe('Assimp 可用性探测(detectAssimpBin)', () => {
it('能执行时返回版本与路径', async () => {
const probe = await detectAssimpBin(fakeBin);
expect(probe.available).toBe(true);
expect(probe.path).toBe(fakeBin);
expect(probe.version).toContain('6.0.4');
});
it('找不到时 available=false 且 error 说明未安装', async () => {
const probe = await detectAssimpBin(join(workRoot, 'no-such-assimp'));
expect(probe.available).toBe(false);
expect(probe.error).toContain('未安装');
expect(probe.searched.length).toBeGreaterThan(0);
});
});
describe('转换管线:Assimp 缺失时快速失败并给出可操作信息', () => {
it('convertModelFile 抛出的错误包含安装命令,且不再出现两次 spawn ENOENT', async () => {
let message = '';
try {
await convertModelFile(Buffer.from('PMX dummy'), 'Hatsune Miku.pmx', {
assimpBin: join(workRoot, 'no-such-assimp'),
timeoutMs: 5_000,
});
throw new Error('应当抛出异常');
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
expect(message).toContain('模型转换失败');
expect(message).toContain('apt-get install -y assimp-utils');
expect(message).toContain('ASSIMP_BIN');
expect(message).not.toContain('ENOENT');
});
});
describe('deploy/assimp-lib.sh:部署脚本共用的安装逻辑', () => {
function runLib(script: string, path?: string) {
// 覆盖 PATH 的用例里必须用绝对路径调 bash,否则连 bash 都找不到
return spawnSync('/bin/bash', ['-c', `. "$1"; ${script}`, '_', ASSIMP_LIB], {
encoding: 'utf8',
timeout: 15000,
env: { ...process.env, ...(path ? { PATH: path } : {}) },
});
}
it('opendance_assimp_resolve 能在 PATH 中找到二进制', () => {
const result = runLib('opendance_assimp_resolve', `${join(workRoot, 'bin')}:/usr/bin:/bin`);
expect(result.stdout.trim()).toBe(fakeBin);
expect(result.status).toBe(0);
});
it('opendance_assimp_resolve 在找不到时返回非 0 且无输出', () => {
const result = runLib('opendance_assimp_resolve || echo MISSING', '/nonexistent-dir-only');
// 本机若真的装了 assimp(/usr/bin),则跳过这条断言的严格性
if (!existsSync('/usr/bin/assimp') && !existsSync('/usr/local/bin/assimp')) {
expect(result.stdout.trim()).toBe('MISSING');
}
expect(result.status).toBe(0);
});
it('opendance_assimp_install_cmd 按包管理器给出对应命令', () => {
const aptDir = join(workRoot, 'apt');
mkdirSync(aptDir, { recursive: true });
writeFileSync(join(aptDir, 'apt-get'), '#!/bin/bash\nexit 0\n');
chmodSync(join(aptDir, 'apt-get'), 0o755);
const result = runLib('opendance_assimp_install_cmd', `${aptDir}:/usr/bin:/bin`);
expect(result.stdout).toContain('apt-get install -y assimp-utils libassimp6');
});
it('opendance_assimp_install 调用包管理器安装 assimp-utils(离线失败也不抛)', () => {
const aptDir = join(workRoot, 'apt');
const log = join(aptDir, 'calls.log');
rmSync(log, { force: true });
writeFileSync(join(aptDir, 'apt-get'), `#!/bin/bash\necho "apt-get $*" >> "${log}"\nexit 0\n`);
chmodSync(join(aptDir, 'apt-get'), 0o755);
const result = runLib('opendance_assimp_install', `${aptDir}:/usr/bin:/bin`);
expect(result.status).toBe(0);
const calls = existsSync(log) ? readFileSync(log, 'utf8') : '';
expect(calls).toContain('apt-get install -y -qq assimp-utils libassimp6');
});
it('非常规目录需要写 ASSIMP_BIN,标准目录不需要', () => {
expect(runLib(`opendance_assimp_needs_env_path ${join(workRoot, 'bin', 'assimp')} && echo YES`).stdout).toContain('YES');
expect(runLib('opendance_assimp_needs_env_path /usr/bin/assimp || echo NO').stdout).toContain('NO');
});
});
+7 -2
View File
@@ -423,13 +423,18 @@ describe('压缩包导入与转换(用假 Assimp 做对照)', () => {
);
});
it('assimp 不存在时给出可读错误', async () => {
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(/模型转换失败/);
).rejects.toThrow(/apt-get install -y assimp-utils[\s\S]*\.glb \/ \.vrm/);
});
});