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
+133
View File
@@ -0,0 +1,133 @@
/**
* Assimp 可执行文件的定位、安装提示与可用性探测。
*
* 背景:PMX / FBX 模型转换与 FBX / glTF 动作预处理都依赖**外部** Assimp
* 命令行工具(BSD-3-Clause,不在 Node 依赖里)。装漏了只有在用户上传完
* 一整个压缩包之后才会暴露,而且原始报错只有 `spawn assimp ENOENT`。
* 这里把「找二进制」与「怎么装」集中到一处,供转换管线、动作预处理、
* /health 与部署自检共用。
*/
import { execFile } from 'node:child_process';
import { existsSync, statSync } from 'node:fs';
import { delimiter, join } from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
/**
* 除 PATH 之外再查找的常见目录。
* 生产上服务由系统服务托管,PATH 可能很窄(甚至只有 /usr/bin),
* 而用户把 Assimp 装在 /usr/local/bin 也很常见。
*/
export const ASSIMP_FALLBACK_DIRS = [
'/usr/bin',
'/usr/local/bin',
'/usr/sbin',
'/usr/local/sbin',
'/snap/bin',
];
export interface AssimpLocation {
/** 解析到的可执行文件路径(未找到时原样返回传入的 bin) */
path: string;
found: boolean;
/** 依次查找过的候选路径(用于错误信息) */
searched: string[];
}
/**
* 定位 Assimp 可执行文件:显式路径 -> PATH -> 常见系统目录。
* 只做文件系统检查,不执行它(探测可用性见 detectAssimpBin)。
*/
export function resolveAssimpBinary(bin: string): AssimpLocation {
const candidates: string[] = [];
if (bin.includes('/') || bin.includes('\\')) {
candidates.push(bin);
} else {
for (const dir of (process.env.PATH ?? '').split(delimiter)) {
if (dir) candidates.push(join(dir, bin));
}
for (const dir of ASSIMP_FALLBACK_DIRS) candidates.push(join(dir, bin));
}
const searched = [...new Set(candidates)];
for (const candidate of searched) {
try {
// 必须是文件:只判 existsSync 会把同名目录(例如 ASSIMP_BIN=/usr/bin)当成二进制
if (existsSync(candidate) && statSync(candidate).isFile()) {
return { path: candidate, found: true, searched };
}
} catch {
/* 权限等问题一律视为该候选不可用,继续找下一个 */
}
}
return { path: bin, found: false, searched };
}
/** 各发行版的安装命令(部署脚本与错误信息共用同一份文案) */
export const ASSIMP_INSTALL_COMMANDS: ReadonlyArray<{ distro: string; command: string }> = [
{ distro: 'Debian / Ubuntu', command: 'apt-get update && apt-get install -y assimp-utils libassimp6' },
{ distro: 'RHEL / CentOS / Rocky / Fedora', command: 'dnf install -y assimp' },
{ distro: 'Alpine', command: 'apk add --no-cache assimp' },
{ distro: 'Arch', command: 'pacman -S --noconfirm assimp' },
];
export function assimpInstallHint(indent = ' '): string {
return ASSIMP_INSTALL_COMMANDS.map((item) => `${indent}${item.distro}: ${item.command}`).join('\n');
}
/** 找不到 Assimp 时的完整可读错误(含查过的路径、安装命令与替代办法) */
export function assimpMissingMessage(bin: string, searched: string[] = []): string {
const tried =
searched.length > 0
? `\n已查找 ${searched.length} 处: ${searched.slice(0, 5).join(', ')}${searched.length > 5 ? ' ...' : ''}`
: '';
return (
`未找到 Assimp 可执行文件 "${bin}"。PMX / FBX 模型转换与 FBX / glTF 动作预处理都需要它` +
'(外部命令行工具, 不在 Node 依赖内)。' +
tried +
'\n安装方式:\n' +
assimpInstallHint() +
'\n装好后可用 `assimp version` 验证; 若它不在服务进程的 PATH 里, ' +
'请在 deploy/.env 中把 ASSIMP_BIN 设为绝对路径并重启服务。' +
'\n只上传 .glb / .vrm 模型(或 .bvh / .vmd / 关键点 JSON 动作)则不需要 Assimp。'
);
}
export interface AssimpProbe {
available: boolean;
/** 解析到的路径(未找到时为原值) */
path: string;
version?: string;
error?: string;
searched: string[];
}
/**
* 探测 Assimp 是否真的可用(执行 `assimp version`)。
* 用于 /health、部署自检,以及转换前的预检。
*/
export async function detectAssimpBin(bin: string): Promise<AssimpProbe> {
const location = resolveAssimpBinary(bin);
if (!location.found) {
return {
available: false,
path: location.path,
error: `未安装 (${bin})`,
searched: location.searched,
};
}
try {
const { stdout } = await execFileAsync(location.path, ['version'], { timeout: 10_000 });
const version =
stdout.split(/\r?\n/).find((line) => line.toLowerCase().includes('assimp')) ?? stdout.trim();
return { available: true, path: location.path, version: version.trim(), searched: location.searched };
} catch (error) {
const err = error as NodeJS.ErrnoException;
return {
available: false,
path: location.path,
error: err.code === 'ENOENT' ? `未安装 (${bin})` : (err.message ?? '未知错误'),
searched: location.searched,
};
}
}
@@ -24,6 +24,8 @@ export interface HealthReport {
};
assimp: {
available: boolean;
/** 解析到的 Assimp 可执行文件路径(未找到时为配置值) */
path?: string;
version?: string;
error?: string;
};
+8
View File
@@ -7,6 +7,7 @@ import { promisify } from 'node:util';
import { ModelFormat } from '../common/enums';
import { ZipEntry, readZipEntries } from '../common/zip-reader';
import { embedGlbImages } from './glb-textures';
import { assimpMissingMessage, resolveAssimpBinary } from '../common/assimp';
import { packGltfToGlb } from './gltf-packer';
import { VrmWrapMeta, detectGlbKind, wrapGlbAsVrm } from './vrm-writer';
@@ -250,6 +251,13 @@ async function runAssimpPipeline(
): Promise<{ buffer: Buffer; format: ModelFormat; note: string; attempts: string[] }> {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const prefer = options.preferFormat ?? 'glb';
// 预检:Assimp 缺失时直接给出「怎么装 + 查过哪些路径」,而不是两条 spawn ENOENT。
// 只做文件系统检查(不执行),避免给假二进制(测试桩)增加额外要求。
const location = resolveAssimpBinary(options.assimpBin);
if (!location.found) {
throw new Error(`模型转换失败:${assimpMissingMessage(options.assimpBin, location.searched)}`);
}
{
const workDir = dirname(modelPath);
const attempts: string[] = [];
+5 -17
View File
@@ -4,6 +4,8 @@ import { existsSync } from 'node:fs';
import { basename, extname, join } from 'node:path';
import { promisify } from 'node:util';
import { assimpMissingMessage, resolveAssimpBinary } from '../common/assimp';
const execFileAsync = promisify(execFile);
export const ASSIMP_CONVERT_TIMEOUT_MS = 120_000;
@@ -31,10 +33,8 @@ export async function convertToBvhWithAssimp(
} catch (error) {
const err = error as NodeJS.ErrnoException & { stderr?: string };
if (err.code === 'ENOENT') {
throw new BadRequestException(
`未找到 Assimp 可执行文件「${assimpBin}」。FBX / glTF 动作预处理需要 Assimp(BSD-3-Clause),` +
'请安装后设置 ASSIMP_BIN 环境变量;BVH 动作无需 Assimp。',
);
const location = resolveAssimpBinary(assimpBin);
throw new BadRequestException(assimpMissingMessage(assimpBin, location.searched));
}
const detail = (err.stderr ?? err.message ?? '').toString().trim().slice(0, 500);
throw new BadRequestException(`Assimp 转换失败:${detail || '未知错误'}`);
@@ -47,16 +47,4 @@ export async function convertToBvhWithAssimp(
}
/** 探测 Assimp 是否可用(用于 /health 与部署自检) */
export async function detectAssimp(assimpBin: string): Promise<{ available: boolean; version?: string; error?: string }> {
try {
const { stdout } = await execFileAsync(assimpBin, ['version'], { timeout: 10_000 });
const version = stdout.split(/\r?\n/).find((line) => line.toLowerCase().includes('assimp')) ?? stdout.trim();
return { available: true, version: version.trim() };
} catch (error) {
const err = error as NodeJS.ErrnoException;
return {
available: false,
error: err.code === 'ENOENT' ? `未安装(${assimpBin})` : (err.message ?? '未知错误'),
};
}
}
export { detectAssimpBin as detectAssimp } from '../common/assimp';