feat(models): 发布 v0.2.0 —— GLB/VRM 主力格式、自动缩略图与分析、模型审核与骨骼预览
需求 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 变为该模型
This commit is contained in:
@@ -1,24 +1,35 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, extname, join } from 'node:path';
|
||||
import { basename, 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';
|
||||
import { packGltfToGlb } from './gltf-packer';
|
||||
import { VrmWrapMeta, detectGlbKind, wrapGlbAsVrm } from './vrm-writer';
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
/**
|
||||
* 压缩包 → 可入库模型文件的转换。
|
||||
* 压缩包 → 可入库模型文件的导入/转换(T-WEB-005)。
|
||||
*
|
||||
* 典型场景:MMD 模型包(zip 内含 `xxx.pmx` + `textures/`)→ 解压 → 选主模型
|
||||
* → 调 Assimp 导出 FBX;**Assimp 的 FBX 导出器对带蒙皮的大骨架模型会写坏文件**
|
||||
* (实测 Miku PMX:导出退出码 11,产出的 FBX 回读报 `FBX-DOM no Objects dictionary found`),
|
||||
* 因此导出后必须回读校验,失败则回退 GLB,并把过程写进 conversionNote。
|
||||
* ## 策略(与产品约定一致)
|
||||
*
|
||||
* 转换后的 GLB 还会把 Assimp 写成外部 URI 的贴图内嵌进二进制块,
|
||||
* 保证模型库里一个文件就能独立加载(否则是白模)。
|
||||
* **GLB / VRM 是主力格式**:包内若有 `.vrm` / `.glb` / `.gltf`,**直接导入**,
|
||||
* 不做任何重编码(避免无意义的损失)。
|
||||
* 只有 `.pmx`(MMD)与 `.fbx` 这类客户端不能直接加载的格式才转换,且**优先转 GLB**:
|
||||
*
|
||||
* 1. `assimp export → .glb`(首选;本仓库实测 PMX 走这条)
|
||||
* 2. 失败则 `assimp export → .gltf`,再由 `gltf-packer.ts` 自行打包回 GLB
|
||||
* (Assimp 的 GLB 导出器对部分模型会失败,而 glTF 导出常能成功)
|
||||
* 3. 若配置要求 VRM(`MODEL_CONVERT_FORMAT=vrm`)且 humanoid 骨骼齐全,
|
||||
* 在 GLB 之上补 `VRMC_vrm` 扩展产出 VRM 1.0(见 `vrm-writer.ts`);
|
||||
* humanoid 不全则**回退交付 GLB**,并把缺失骨骼写进说明
|
||||
*
|
||||
* 每一步都做**回读校验**:Assimp 会"写出损坏文件"(实测 FBX 导出退出码 11、
|
||||
* 产物回读报 `FBX-DOM no Objects dictionary found`),只看退出码会把坏文件入库。
|
||||
*/
|
||||
|
||||
export interface ConvertOptions {
|
||||
@@ -26,28 +37,55 @@ export interface ConvertOptions {
|
||||
assimpBin: string;
|
||||
/** 单次转换超时(毫秒) */
|
||||
timeoutMs?: number;
|
||||
/** 优先尝试的格式 */
|
||||
preferFormat?: 'fbx' | 'glb';
|
||||
/** 优先交付的格式:`glb`(默认)或 `vrm` */
|
||||
preferFormat?: 'glb' | 'vrm';
|
||||
/** 优先选用的 PMX 文件名(大小写不敏感),例如 `Hatsune Miku.pmx` */
|
||||
preferredPmx?: string;
|
||||
/** 生成 VRM 时写入的元信息 */
|
||||
vrmMeta?: VrmWrapMeta;
|
||||
}
|
||||
|
||||
export interface ConvertedModel {
|
||||
/** 转换产物内容 */
|
||||
/** 产物(或直接导入的原文件)内容 */
|
||||
buffer: Buffer;
|
||||
/** 产物格式(fbx 或 glb) */
|
||||
/** 入库格式 */
|
||||
format: ModelFormat;
|
||||
/** 实际使用的 pmx 相对路径 */
|
||||
/** 归档内的源文件相对路径 */
|
||||
sourceModelPath: string;
|
||||
/** 人类可读的转换说明,写入 models.conversion_note */
|
||||
/** 人类可读说明,写入 models.conversion_note */
|
||||
note: string;
|
||||
/** 逐步尝试记录,便于排障 */
|
||||
attempts: string[];
|
||||
/** 是否为直接导入(未做任何转换) */
|
||||
direct: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 180_000;
|
||||
|
||||
/** 选择主模型:优先指名字,其次目录最浅,最后文件最大 */
|
||||
/** 归档内的候选文件:能直接导入的格式优先于需要转换的格式 */
|
||||
export type ArchiveCandidateKind = 'direct' | 'convert';
|
||||
|
||||
/** 源文件格式(PMX 只作为输入存在,永远不会成为入库格式,因此不进 ModelFormat 枚举) */
|
||||
export type SourceFormat = 'vrm' | 'glb' | 'gltf' | 'pmx' | 'fbx';
|
||||
|
||||
export interface ArchiveCandidate {
|
||||
entry: ZipEntry;
|
||||
kind: ArchiveCandidateKind;
|
||||
sourceFormat: SourceFormat;
|
||||
/** 数字越小越优先 */
|
||||
priority: number;
|
||||
}
|
||||
|
||||
/** 格式优先级:GLB / VRM 为主力,其次 glTF,最后才是需要转换的 PMX / FBX */
|
||||
const FORMAT_PRIORITY: ReadonlyArray<{ ext: string; sourceFormat: SourceFormat; assetFormat: ModelFormat; kind: ArchiveCandidateKind }> = [
|
||||
{ ext: '.vrm', sourceFormat: 'vrm', assetFormat: ModelFormat.Vrm, kind: 'direct' },
|
||||
{ ext: '.glb', sourceFormat: 'glb', assetFormat: ModelFormat.Glb, kind: 'direct' },
|
||||
{ ext: '.gltf', sourceFormat: 'gltf', assetFormat: ModelFormat.Gltf, kind: 'direct' },
|
||||
{ ext: '.pmx', sourceFormat: 'pmx', assetFormat: ModelFormat.Glb, kind: 'convert' },
|
||||
{ ext: '.fbx', sourceFormat: 'fbx', assetFormat: ModelFormat.Fbx, kind: 'convert' },
|
||||
];
|
||||
|
||||
/** 只挑 PMX(保留给旧调用方与测试);新代码请用 `pickArchiveCandidate` */
|
||||
export function pickModelFile(
|
||||
entries: readonly ZipEntry[],
|
||||
preferred?: string,
|
||||
@@ -58,9 +96,7 @@ export function pickModelFile(
|
||||
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,
|
||||
);
|
||||
const byBase = models.find((entry) => entry.path.split('/').pop()?.toLowerCase() === wanted);
|
||||
if (byBase) return byBase;
|
||||
}
|
||||
return [...models].sort((a, b) => {
|
||||
@@ -71,6 +107,41 @@ export function pickModelFile(
|
||||
})[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 在压缩包里自动搜索可用的模型文件。
|
||||
* 顺序:`.vrm` → `.glb` → `.gltf`(直接导入)→ `.pmx` → `.fbx`(需要转换);
|
||||
* 同一格式内优先"指名字",其次目录最浅,最后文件最大。
|
||||
*/
|
||||
export function pickArchiveCandidate(
|
||||
entries: readonly ZipEntry[],
|
||||
preferredPmx?: string,
|
||||
): ArchiveCandidate | undefined {
|
||||
const preferred = preferredPmx?.trim().toLowerCase();
|
||||
const candidates: ArchiveCandidate[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const ext = extname(entry.path).toLowerCase();
|
||||
const index = FORMAT_PRIORITY.findIndex((item) => item.ext === ext);
|
||||
if (index < 0) continue;
|
||||
const spec = FORMAT_PRIORITY[index];
|
||||
candidates.push({ entry, kind: spec.kind, sourceFormat: spec.sourceFormat, priority: index });
|
||||
}
|
||||
if (candidates.length === 0) return undefined;
|
||||
|
||||
return candidates.sort((a, b) => {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
if (preferred) {
|
||||
const aNamed = a.entry.path.toLowerCase() === preferred ? 0 : 1;
|
||||
const bNamed = b.entry.path.toLowerCase() === preferred ? 0 : 1;
|
||||
if (aNamed !== bNamed) return aNamed - bNamed;
|
||||
}
|
||||
const depthA = a.entry.path.split('/').length;
|
||||
const depthB = b.entry.path.split('/').length;
|
||||
if (depthA !== depthB) return depthA - depthB;
|
||||
return b.entry.size - a.entry.size;
|
||||
})[0];
|
||||
}
|
||||
|
||||
/** 解压到工作目录,同时保留内容供贴图内嵌使用 */
|
||||
async function unpack(
|
||||
entries: readonly ZipEntry[],
|
||||
@@ -87,7 +158,7 @@ async function unpack(
|
||||
return contents;
|
||||
}
|
||||
|
||||
/** 在解压结果里按 glTF 的 uri 找贴图(容错反斜杠与大小写) */
|
||||
/** 在解压结果里按 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);
|
||||
@@ -111,105 +182,263 @@ function makeResolver(contents: Map<string, Buffer>) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 用 Assimp 导出到指定格式,并回读校验(回读失败视为转换失败) */
|
||||
/** 用 Assimp 导出并回读校验(回读失败视为转换失败) */
|
||||
async function exportAndValidate(
|
||||
assimpBin: string,
|
||||
modelPath: string,
|
||||
outPath: string,
|
||||
timeoutMs: number,
|
||||
): Promise<{ ok: boolean; detail: string }> {
|
||||
): Promise<{ ok: boolean; detail: string; size: number }> {
|
||||
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)}` };
|
||||
return {
|
||||
ok: false,
|
||||
detail: `导出失败(${err.code ?? 'unknown'}):${detail.slice(0, 300)}`,
|
||||
size: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let size = 0;
|
||||
try {
|
||||
size = (await readFile(outPath)).length;
|
||||
} catch {
|
||||
return { ok: false, detail: '导出命令返回成功但没有产出文件' };
|
||||
return { ok: false, detail: '导出命令返回成功但没有产出文件', size: 0 };
|
||||
}
|
||||
if (size === 0) return { ok: false, detail: '导出产物为空文件' };
|
||||
if (size === 0) return { ok: false, detail: '导出产物为空文件', size: 0 };
|
||||
|
||||
// 关键校验: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: false, detail: `产物无法回读(已判定损坏):${detail.slice(0, 300)}`, size };
|
||||
}
|
||||
return { ok: true, detail: `产物 ${size} 字节,回读校验通过` };
|
||||
return { ok: true, detail: `产物 ${size} 字节,回读校验通过`, size };
|
||||
}
|
||||
|
||||
/** 压缩包 → 模型文件(FBX 优先,失败回退 GLB) */
|
||||
/** 在 GLB 之上补 VRM 扩展;失败时返回原因而不是抛出 */
|
||||
function tryWrapVrm(
|
||||
glb: Buffer,
|
||||
meta: VrmWrapMeta | undefined,
|
||||
modelName: string,
|
||||
): { buffer: Buffer; detail: string } | { error: string } {
|
||||
try {
|
||||
const wrapped = wrapGlbAsVrm(glb, {
|
||||
name: meta?.name ?? modelName,
|
||||
author: meta?.author ?? 'unknown',
|
||||
license: meta?.license,
|
||||
sourceNote: meta?.sourceNote,
|
||||
});
|
||||
return { buffer: wrapped.buffer, detail: `已写入 ${wrapped.humanoidBones} 根 humanoid 骨骼` };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assimp 导出管线(第 1 步 GLB 直出 → 第 2 步 glTF 再自行打包 → 第 3 步按需封装 VRM)。
|
||||
* zip 与单文件两条入口共用。
|
||||
*/
|
||||
async function runAssimpPipeline(
|
||||
modelPath: string,
|
||||
archiveResolver: (uri: string) => Buffer | null,
|
||||
modelLabel: string,
|
||||
options: ConvertOptions,
|
||||
): Promise<{ buffer: Buffer; format: ModelFormat; note: string; attempts: string[] }> {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const prefer = options.preferFormat ?? 'glb';
|
||||
{
|
||||
const workDir = dirname(modelPath);
|
||||
const attempts: string[] = [];
|
||||
|
||||
/**
|
||||
* 解析 glTF/GLB 里引用的外部文件:**先看工作目录**(Assimp 新导出的 .bin 在这里),
|
||||
* 再回落到压缩包内容(原始贴图)。少了这一步,glTF→GLB 打包会找不到 .bin。
|
||||
*/
|
||||
const resolver = (uri: string): Buffer | null => {
|
||||
if (uri.startsWith('data:')) return null;
|
||||
for (const candidate of [uri, uri.replace(/\\/g, '/'), basename(uri)]) {
|
||||
try {
|
||||
return readFileSync(join(workDir, candidate));
|
||||
} catch {
|
||||
/* 继续 */
|
||||
}
|
||||
}
|
||||
return archiveResolver(uri);
|
||||
};
|
||||
|
||||
// ---- 第 1 步:直接导出 GLB ----
|
||||
let glb: Buffer | null = null;
|
||||
let fromDirectGlb = false;
|
||||
const glbPath = join(workDir, 'converted.glb');
|
||||
const glbResult = await exportAndValidate(options.assimpBin, modelPath, glbPath, timeoutMs);
|
||||
attempts.push(`GLB 直出:${glbResult.detail}`);
|
||||
if (glbResult.ok) {
|
||||
glb = Buffer.from(await readFile(glbPath));
|
||||
fromDirectGlb = true;
|
||||
} else {
|
||||
// ---- 第 2 步:导出 glTF,再自行打包成 GLB ----
|
||||
const gltfPath = join(workDir, 'converted.gltf');
|
||||
const gltfResult = await exportAndValidate(options.assimpBin, modelPath, gltfPath, timeoutMs);
|
||||
if (gltfResult.ok) {
|
||||
try {
|
||||
const gltfJson = JSON.parse(await readFile(gltfPath, 'utf8')) as Record<string, unknown>;
|
||||
const packed = packGltfToGlb(gltfJson, resolver);
|
||||
glb = packed.glb;
|
||||
attempts.push(
|
||||
`GLB 打包:由 glTF 打包成功(合并 ${packed.buffersMerged} 个 buffer,内嵌 ${packed.imagesEmbedded} 张贴图` +
|
||||
`${packed.unresolvedImages.length > 0 ? `,${packed.unresolvedImages.length} 张贴图未找到` : ''})`,
|
||||
);
|
||||
} catch (error) {
|
||||
attempts.push(
|
||||
`GLB 打包:失败(${error instanceof Error ? error.message : String(error)})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
attempts.push(`glTF 直出:${gltfResult.detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!glb) {
|
||||
throw new Error(
|
||||
`模型转换失败:${attempts.join(';')}。` +
|
||||
'请确认服务器已安装 Assimp(ASSIMP_BIN),或直接把模型导出成 .glb / .vrm 再上传。',
|
||||
);
|
||||
}
|
||||
|
||||
// GLB 直出时 Assimp 仍会把贴图写成外部 URI,统一内嵌一次(打包路径已在 packGltfToGlb 里做过)
|
||||
let glbBuffer = glb;
|
||||
let embedNote = '';
|
||||
if (fromDirectGlb) {
|
||||
try {
|
||||
const embedded = embedGlbImages(glbBuffer, resolver);
|
||||
glbBuffer = Buffer.from(embedded.glb);
|
||||
if (embedded.embedded > 0) embedNote = `,已内嵌 ${embedded.embedded} 张贴图`;
|
||||
if (embedded.unresolved.length > 0) {
|
||||
embedNote += `(${embedded.unresolved.length} 张贴图未找到)`;
|
||||
}
|
||||
} catch (error) {
|
||||
embedNote = `(贴图内嵌跳过:${error instanceof Error ? error.message : String(error)})`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 第 3 步:按需封装成 VRM(优先 GLB,其次 VRM) ----
|
||||
if (prefer === 'vrm') {
|
||||
const wrapped = tryWrapVrm(glbBuffer, options.vrmMeta, modelLabel.replace(/\.[^.]+$/, ''));
|
||||
if ('buffer' in wrapped) {
|
||||
return {
|
||||
buffer: wrapped.buffer,
|
||||
format: ModelFormat.Vrm,
|
||||
note: `${modelLabel} → VRM(基于 GLB 封装${embedNote};${wrapped.detail})`,
|
||||
attempts: [...attempts, `VRM 封装:${wrapped.detail}`],
|
||||
};
|
||||
}
|
||||
attempts.push(`VRM 封装:失败(${wrapped.error})→ 回退交付 GLB`);
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: glbBuffer,
|
||||
format: ModelFormat.Glb,
|
||||
note: `${modelLabel} → GLB${embedNote}`,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩包 → 模型文件。
|
||||
* 包内含 GLB/VRM/glTF 时直接导入;含 PMX/FBX 时按"优先 GLB、其次 VRM"转换。
|
||||
*/
|
||||
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 candidate = pickArchiveCandidate(entries, options.preferredPmx);
|
||||
if (!candidate) {
|
||||
const supported = FORMAT_PRIORITY.map((item) => item.ext).join('、');
|
||||
throw new Error(`压缩包里没有找到可用的模型文件(支持 ${supported})`);
|
||||
}
|
||||
|
||||
// ---- 直接导入:GLB / VRM / glTF 原样入库 ----
|
||||
if (candidate.kind === 'direct') {
|
||||
const raw = candidate.entry.read();
|
||||
const assetFormat =
|
||||
FORMAT_PRIORITY.find((item) => item.sourceFormat === candidate.sourceFormat)?.assetFormat ??
|
||||
ModelFormat.Glb;
|
||||
const format =
|
||||
assetFormat === ModelFormat.Glb && detectGlbKind(raw) === ModelFormat.Vrm
|
||||
? ModelFormat.Vrm
|
||||
: assetFormat;
|
||||
return {
|
||||
buffer: raw,
|
||||
format,
|
||||
sourceModelPath: candidate.entry.path,
|
||||
note: `${candidate.entry.path} → 直接导入(已是 ${format.toUpperCase()},未做重编码)`,
|
||||
attempts: [`直接导入:${candidate.entry.path}(${raw.length} 字节)`],
|
||||
direct: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 需要转换:PMX / FBX ----
|
||||
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 文件。',
|
||||
const modelPath = join(workDir, candidate.entry.path);
|
||||
const result = await runAssimpPipeline(
|
||||
modelPath,
|
||||
makeResolver(contents),
|
||||
candidate.entry.path.split('/').pop() ?? candidate.entry.path,
|
||||
options,
|
||||
);
|
||||
return { ...result, sourceModelPath: candidate.entry.path, direct: false };
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个模型文件(`.pmx` / `.fbx`)→ 转换产物。
|
||||
* 单文件没有配套贴图,转换说明里会写明贴图缺失情况。
|
||||
*/
|
||||
export async function convertModelFile(
|
||||
buffer: Buffer,
|
||||
fileName: string,
|
||||
options: ConvertOptions,
|
||||
): Promise<ConvertedModel> {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'opendance-model-file-'));
|
||||
try {
|
||||
const modelPath = join(workDir, basename(fileName));
|
||||
await writeFile(modelPath, buffer);
|
||||
const result = await runAssimpPipeline(modelPath, () => null, basename(fileName), options);
|
||||
return { ...result, sourceModelPath: basename(fileName), direct: false };
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** 直接导入单个模型文件(上传 .glb / .vrm / .gltf 时用,不做任何转换) */
|
||||
export function importModelFile(buffer: Buffer, fileName: string): ConvertedModel {
|
||||
const ext = extname(fileName).toLowerCase();
|
||||
const spec = FORMAT_PRIORITY.find((item) => item.ext === ext && item.kind === 'direct');
|
||||
if (!spec) {
|
||||
throw new Error(`不支持直接导入的扩展名:${ext || '(无)'};需要转换的只有 .pmx / .fbx`);
|
||||
}
|
||||
const format =
|
||||
spec.assetFormat === ModelFormat.Glb && detectGlbKind(buffer) === ModelFormat.Vrm
|
||||
? ModelFormat.Vrm
|
||||
: spec.assetFormat;
|
||||
return {
|
||||
buffer,
|
||||
format,
|
||||
sourceModelPath: basename(fileName),
|
||||
note: `${fileName} → 直接导入(${format.toUpperCase()},未做重编码)`,
|
||||
attempts: [`直接导入:${fileName}(${buffer.length} 字节)`],
|
||||
direct: true,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user