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:
2026-09-15 20:37:53 +08:00
parent ccced34dd6
commit 53bf182476
47 changed files with 5610 additions and 628 deletions
+307 -78
View File
@@ -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,
};
}