Files
opendance-web/web-server/src/models/model-convert.ts
T
zijunchiang 53bf182476 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 变为该模型
2026-09-15 20:37:53 +08:00

445 lines
17 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 { 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 { 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)。
*
* ## 策略(与产品约定一致)
*
* **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 {
/** Assimp 可执行文件(默认 `assimp`,可用 ASSIMP_BIN 覆盖) */
assimpBin: string;
/** 单次转换超时(毫秒) */
timeoutMs?: number;
/** 优先交付的格式:`glb`(默认)或 `vrm` */
preferFormat?: 'glb' | 'vrm';
/** 优先选用的 PMX 文件名(大小写不敏感),例如 `Hatsune Miku.pmx` */
preferredPmx?: string;
/** 生成 VRM 时写入的元信息 */
vrmMeta?: VrmWrapMeta;
}
export interface ConvertedModel {
/** 产物(或直接导入的原文件)内容 */
buffer: Buffer;
/** 入库格式 */
format: ModelFormat;
/** 归档内的源文件相对路径 */
sourceModelPath: string;
/** 人类可读说明,写入 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,
): ZipEntry | undefined {
const models = entries.filter((entry) => extname(entry.path).toLowerCase() === '.pmx');
if (models.length === 0) return undefined;
const wanted = preferred?.trim().toLowerCase();
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);
if (byBase) return byBase;
}
return [...models].sort((a, b) => {
const depthA = a.path.split('/').length;
const depthB = b.path.split('/').length;
if (depthA !== depthB) return depthA - depthB;
return b.size - a.size;
})[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[],
targetDir: string,
): Promise<Map<string, Buffer>> {
const contents = new Map<string, Buffer>();
for (const entry of entries) {
const data = entry.read();
contents.set(entry.path, data);
const destination = join(targetDir, entry.path);
await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, data);
}
return contents;
}
/** 在解压结果里按 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);
return (uri: string): Buffer | null => {
if (uri.startsWith('data:')) return null;
let decoded = uri;
try {
decoded = decodeURIComponent(uri);
} catch {
/* 保持原样 */
}
const normalized = decoded.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
const direct = lowered.get(normalized);
if (direct) return direct;
const base = normalized.split('/').pop();
if (!base) return null;
for (const [path, data] of lowered) {
if (path.split('/').pop() === base) return data;
}
return null;
};
}
/** 用 Assimp 导出并回读校验(回读失败视为转换失败) */
async function exportAndValidate(
assimpBin: string,
modelPath: string,
outPath: string,
timeoutMs: number,
): 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)}`,
size: 0,
};
}
let size = 0;
try {
size = (await readFile(outPath)).length;
} catch {
return { ok: false, detail: '导出命令返回成功但没有产出文件', size: 0 };
}
if (size === 0) return { ok: false, detail: '导出产物为空文件', size: 0 };
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)}`, size };
}
return { ok: true, detail: `产物 ${size} 字节,回读校验通过`, size };
}
/** 在 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 entries = readZipEntries(archive);
if (entries.length === 0) throw new Error('压缩包是空的');
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-'));
try {
const contents = await unpack(entries, workDir);
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,
};
}