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
+8 -6
View File
@@ -81,12 +81,14 @@ export class AppConfig {
}
/**
* 压缩包转换的目标格式。
* `fbx` 优先(按需求),但 Assimp 的 FBX 导出器对带蒙皮的大骨架模型会写坏文件,
* 因此服务端在导出后回读校验,失败会自动回退 GLB 并记录 conversionNote。
* 需要转换的模型(PMX / FBX)交付成什么格式。
*
* 系统以 **GLB / VRM 为主力**:GLB 是两个客户端都能原生加载的通用格式,
* VRM 在其上多了 humanoid / 表情 / springBone 语义。默认 `glb`;
* 设为 `vrm` 时会在 GLB 之上补 `VRMC_vrm` 扩展,humanoid 骨骼不全则回退 GLB。
*/
get modelConvertFormat(): 'fbx' | 'glb' {
return (this.config.get<string>('modelConvertFormat') ?? 'fbx') === 'glb' ? 'glb' : 'fbx';
get modelConvertFormat(): 'glb' | 'vrm' {
return (this.config.get<string>('modelConvertFormat') ?? 'glb') === 'vrm' ? 'vrm' : 'glb';
}
/** 压缩包里优先选用的 PMX 文件名(大小写不敏感);找不到时按"目录最浅、文件最大"挑 */
@@ -191,7 +193,7 @@ export const configuration = () => {
adminDir: process.env.ADMIN_DIR ?? resolve(process.cwd(), '..', 'admin'),
uploadMaxBytes: Number(process.env.UPLOAD_MAX_BYTES ?? 512 * 1024 * 1024),
assimpBin: process.env.ASSIMP_BIN ?? 'assimp',
modelConvertFormat: process.env.MODEL_CONVERT_FORMAT ?? 'fbx',
modelConvertFormat: process.env.MODEL_CONVERT_FORMAT ?? 'glb',
modelConvertPmx: process.env.MODEL_CONVERT_PMX ?? 'Hatsune Miku.pmx',
modelConvertTimeoutMs: Number(process.env.MODEL_CONVERT_TIMEOUT_MS ?? 180_000),
defaultModelId: process.env.DEFAULT_MODEL_ID ?? '',
+4 -4
View File
@@ -9,12 +9,12 @@ import { join } from 'node:path';
* 未设置该变量时回落到这里的常量)。
*/
export const CONTRACT_VERSIONS: Readonly<Record<string, string>> = Object.freeze({
'rest-api': '0.3.0',
'rest-api': '0.4.0',
'websocket-keypoints': '0.1.0',
'data-models': '0.3.0',
'data-models': '0.4.0',
'skeleton-topology': '0.1.0',
'model-formats': '0.3.0',
database: '0.2.0',
'model-formats': '0.4.0',
database: '0.3.0',
});
/** 解析 `contracts/VERSION`(`key: value` 每行一条,`#` 起注释) */
+8 -8
View File
@@ -36,15 +36,15 @@ export interface DefaultModelSeed {
updatedAt: string | null;
}
/** 由 Hatsune Miku.pmx → GLB 转换得到,已设为默认模型 */
/** 由 手工指定 转换得到,已设为默认模型 */
export const DEFAULT_MODEL_SEED: DefaultModelSeed = {
id: 20,
name: "初音未来 Hatsune Miku",
format: "glb",
sourceSha256: "d1113cf074e19d3a5b4e7025973604c615d6e038ad222070e4748a805ac3c73f",
fileSha256: "696c90c7e8514b759f764312d41f9b9069027e62f3aaa2d83ce2c3e475945028",
convertedFrom: "Hatsune Miku.pmx → GLB",
updatedAt: "2026-09-15T11:34:23.976Z",
id: null,
name: "",
format: "",
sourceSha256: "",
fileSha256: "",
convertedFrom: null,
updatedAt: null,
};
/** 该种子是否指向一个真实模型 */
+7
View File
@@ -49,6 +49,13 @@ export class CharacterModel {
@Column({ name: 'conversion_note', type: 'varchar', length: 500, nullable: true })
conversionNote!: string | null;
/**
* 模型分析结果(面数 / 骨骼树 / humanoid 覆盖 / 可驱动性…),见 model-inspector.ts。
* 存 JSON 文本:审核界面要展示,且骨骼树可能上千行,不适合拆列。
*/
@Column({ name: 'stats', type: 'simple-json', nullable: true })
stats!: Record<string, unknown> | null;
// ---- 来源五元组(必填)----
@Column({ name: 'source_type', type: 'varchar', length: 32 })
sourceType!: SourceType;
@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* 模型审核(T-WEB-005 追加):保存模型分析结果。
*
* `models.stats` 存 `ModelStats` 的 JSON 文本(面数、顶点数、骨骼树、
* humanoid 覆盖、可驱动性、warnings),供审核界面展示——骨骼是后续游玩评分的基础,
* 审核时必须能看清楚。用 TEXT 而非 JSON 列,兼容各版本 MySQL/MariaDB。
*/
export class AddModelStats1789460000000 implements MigrationInterface {
name = 'AddModelStats1789460000000'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`models\` ADD \`stats\` text NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`models\` DROP COLUMN \`stats\``);
}
}
+134
View File
@@ -0,0 +1,134 @@
import { embedGlbImages } from './glb-textures';
/**
* glTF(.gltf + .bin + 外部贴图)→ GLB 打包。
*
* 作为"优先转 GLB"的**第二条路**:Assimp 的 GLB 导出器对部分模型会失败,
* 但它导出 glTF(分离文件)往往成功(实测 PMX 就是这种情况)。
* 这里把分离产物自己拼回单文件 GLB:
* 1. `buffers[0].uri` 指向的 .bin 读进来当 BIN chunk
* 2. 多余 buffer 合并进同一个 BIN chunk(记录偏移重映射)
* 3. 外部贴图交给 `embedGlbImages` 内嵌
* 全程只重排 JSON 与二进制块,不改几何数据。
*/
const GLB_MAGIC = 0x46546c67;
const CHUNK_JSON = 0x4e4f534a;
const CHUNK_BIN = 0x004e4942;
interface GltfLike {
buffers?: Array<{ byteLength?: number; uri?: string }>;
bufferViews?: Array<Record<string, unknown>>;
images?: Array<Record<string, unknown>>;
[key: string]: unknown;
}
function align4(value: number): number {
return value + ((4 - (value % 4)) % 4);
}
function buildGlb(json: GltfLike, bin: Buffer | null): Buffer {
const jsonBuffer = Buffer.from(JSON.stringify(json), 'utf8');
const jsonPadded = Buffer.alloc(align4(jsonBuffer.length), 0x20);
jsonBuffer.copy(jsonPadded);
const chunks: Buffer[] = [];
const header = Buffer.alloc(12);
header.writeUInt32LE(GLB_MAGIC, 0);
header.writeUInt32LE(2, 4);
chunks.push(header);
const jsonHeader = Buffer.alloc(8);
jsonHeader.writeUInt32LE(jsonPadded.length, 0);
jsonHeader.writeUInt32LE(CHUNK_JSON, 4);
chunks.push(jsonHeader, jsonPadded);
if (bin && bin.length > 0) {
const binPadded = Buffer.alloc(align4(bin.length));
bin.copy(binPadded);
const binHeader = Buffer.alloc(8);
binHeader.writeUInt32LE(binPadded.length, 0);
binHeader.writeUInt32LE(CHUNK_BIN, 4);
chunks.push(binHeader, binPadded);
}
const out = Buffer.concat(chunks);
out.writeUInt32LE(out.length, 8);
return out;
}
export interface PackGltfResult {
glb: Buffer;
/** 合并进 BIN chunk 的 buffer 数量 */
buffersMerged: number;
/** 内嵌的贴图数量 */
imagesEmbedded: number;
/** 找不到的贴图 uri */
unresolvedImages: string[];
}
/**
* 打包;`resolve` 把 uri 映射为文件内容(返回 null 表示找不到该文件)。
* 缺失的 buffer 会导致该 buffer 被跳过(几何可能不完整),并在结果里体现。
*/
export function packGltfToGlb(
gltf: GltfLike,
resolve: (uri: string) => Buffer | null,
): PackGltfResult {
const buffers = gltf.buffers ?? [];
if (buffers.length === 0) {
throw new Error('glTF 没有任何 buffer,无法打包成 GLB');
}
/** 旧 buffer 索引 + 旧偏移 → 新偏移 */
const baseOffsets: number[] = [];
const parts: Buffer[] = [];
let cursor = 0;
let merged = 0;
for (let i = 0; i < buffers.length; i += 1) {
const uri = buffers[i]?.uri;
baseOffsets[i] = cursor;
if (!uri || uri.startsWith('data:')) {
// 内嵌 base64 或本来就缺 uri(GLB 才有意义):这里保守跳过
continue;
}
const data = resolve(uri);
if (!data) continue;
const padding = align4(cursor) - cursor;
if (padding > 0) {
parts.push(Buffer.alloc(padding));
cursor += padding;
baseOffsets[i] = cursor;
}
parts.push(data);
cursor += data.length;
merged += 1;
}
if (merged === 0) {
throw new Error('glTF 引用的 .bin 文件都找不到,无法打包成 GLB');
}
// bufferView 的 byteOffset 是相对各自 buffer 的,合并后要加上该 buffer 的基址
const views = (gltf.bufferViews ?? []).map((view) => {
const source = typeof view.buffer === 'number' ? view.buffer : 0;
const offset = typeof view.byteOffset === 'number' ? view.byteOffset : 0;
return { ...view, buffer: 0, byteOffset: (baseOffsets[source] ?? 0) + offset };
});
const merged0 = Buffer.concat(parts, cursor);
const packed: GltfLike = {
...gltf,
buffers: [{ byteLength: merged0.length }],
bufferViews: views,
};
const embedded = embedGlbImages(buildGlb(packed, merged0), resolve);
return {
glb: embedded.glb,
buffersMerged: merged,
imagesEmbedded: embedded.embedded,
unresolvedImages: embedded.unresolved,
};
}
+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,
};
}
+669
View File
@@ -0,0 +1,669 @@
import { readFile } from 'node:fs/promises';
/**
* 模型检查器 —— 审核界面「体积 / 面数 / 骨骼」数据的来源。
*
* 为什么骨骼要单独建模:客户端(Godot)是**按骨骼名**把模型骨架映射到 humanoid
* 规范(`opendance-windows/src/model/humanoid_map.gd`),再驱动 33 个点。
* 「模型能加载」不等于「能被驱动」;服务端必须在审核阶段就算出覆盖情况,
* 否则要等玩家进游戏才发现模型加载了却驱动不了。
*
* 本文件只解析 glTF 系容器(`.glb` 二进制 / `.gltf` JSON;VRM 就是带扩展的 glTF)。
* fbx / pmx 等非 glTF 容器返回 `container='unknown'` 的降级结果,**绝不抛错** ——
* 审核接口需要的是「信息」,不是 500。
*
* 统计口径(与 ModelStats 字段注释一一对应):
* - vertices = 所有 mesh primitive 的 POSITION accessor 的 count 之和(缺 accessor 记 0)
* - triangles = 索引 accessor 的 count / 3;该 primitive 没有 indices 时用 POSITION count / 3
* - materials / textures / images / animations 直接取 glTF 顶层数组长度
* - meshes = meshes 数组长度;primitives = 所有 mesh 的 primitives 数量之和
* - morphTargets = 所有 primitive 的 `targets` 数量之和
* - nodes = nodes 数组长度;skins = skins 数组长度
* - bones = 所有 skin 的 joints 去重后的节点数(= 真正参与蒙皮、可被驱动的骨骼)
* - humanoidTotal 固定为 22(客户端 HUMANOID_BONES 的长度);humanoidMapped 为命中数
* - 一切数字都经过 toCount(),保证是**有限非负整数**,绝不出现 NaN / Infinity
*
* 与客户端的映射一致性由 `test/model-inspector.spec.ts` 直接抽 `humanoid_map.gd`
* 的 CANDIDATES / STRIPPABLE_PREFIXES / HUMANOID_BONES 逐键比对来兜底:
* 客户端一旦改了名字映射,这条测试会立刻失败,而不是让审核数据悄悄失真。
*/
/* ------------------------------------------------------------------ 对外接口 */
export interface BoneNode {
index: number;
name: string;
/** 相对根的层级深度,根为 0 */
depth: number;
parentName: string | null;
childCount: number;
/** 是否被 skin.joints 引用(= 真正参与蒙皮、可被驱动) */
inSkin: boolean;
/** 映射到的 humanoid 规范名(如 leftUpperArm);映射不到为 null */
humanoid: string | null;
}
export interface ModelStats {
container: 'glb' | 'gltf' | 'vrm' | 'unknown';
vrmVersion: string | null;
fileSize: number;
meshes: number;
primitives: number;
vertices: number;
triangles: number;
materials: number;
textures: number;
images: number;
animations: number;
morphTargets: number;
nodes: number;
/** skin.joints 的数量(真正参与蒙皮的骨骼) */
bones: number;
/** skin 数量 */
skins: number;
/** humanoid 规范骨里能映射上的数量 / 总数(22) */
humanoidMapped: number;
humanoidTotal: number;
/** 驱动客户端(33 点)所必需的 16 根里缺哪些(humanoid 规范名) */
missingRequiredBones: string[];
/** 16 根齐全 → true */
drivable: boolean;
maxBoneDepth: number;
/** 扁平化的骨骼树(父在前、子在后),前端按 depth 缩进渲染 */
boneTree: BoneNode[];
/** 无法解析或需要注意的点,中文短语 */
warnings: string[];
}
/* ----------------------------------------------------- humanoid 规范骨(与客户端一致) */
/**
* humanoid 规范骨(22 根),顺序即层级、父在前。
* 与客户端 `humanoid_map.gd` 的 HUMANOID_BONES 逐字一致(测试会比对)。
*/
export const HUMANOID_BONES: readonly string[] = [
'hips',
'spine',
'chest',
'upperChest',
'neck',
'head',
'leftShoulder',
'leftUpperArm',
'leftLowerArm',
'leftHand',
'rightShoulder',
'rightUpperArm',
'rightLowerArm',
'rightHand',
'leftUpperLeg',
'leftLowerLeg',
'leftFoot',
'leftToes',
'rightUpperLeg',
'rightLowerLeg',
'rightFoot',
'rightToes',
];
/**
* 驱动客户端(33 点)所必需的 16 根 humanoid 骨。
* 缺任何一根就意味着「模型能加载但驱动不了」,审核界面据此直接给出结论。
*/
export const HUMANOID_REQUIRED: readonly string[] = [
'hips',
'spine',
'neck',
'head',
'leftUpperArm',
'leftLowerArm',
'leftHand',
'rightUpperArm',
'rightLowerArm',
'rightHand',
'leftUpperLeg',
'leftLowerLeg',
'leftFoot',
'rightUpperLeg',
'rightLowerLeg',
'rightFoot',
];
/**
* 归一化时剥离的前缀(**归一化之后**的形态:小写、无下划线/冒号)。
* 只剥离 J_Bip / mixamorig / Bip01 —— 故意不剥离 J_Aim_ / J_Roll_ / J_Sec_,
* 因为 VRoid 用这些前缀标记扭转辅助骨(如 J_Aim_L_UpperLeg),
* 若一并剥离会与真正的 J_Bip_L_UpperLeg 撞名,导致驱动到错误的骨骼上。
* 与客户端 `humanoid_map.gd` 的 STRIPPABLE_PREFIXES 一致。
*/
export const STRIPPABLE_PREFIXES: readonly string[] = ['jbip', 'mixamorig', 'bip001', 'bip01'];
/**
* 每个 canonical 骨骼的候选名(全部为归一化后的形态,含 compact 与 expanded 写法)。
* 与客户端 `humanoid_map.gd` 的 CANDIDATES 逐字一致(测试会逐键比对)。
*/
export const HUMANOID_CANDIDATES: Record<string, string[]> = {
hips: ['chips', 'hips', 'hip', 'pelvis', 'root'],
spine: ['cspine', 'spine', 'spine1', 'spinemid'],
chest: ['cchest', 'chest', 'spine2', 'spineupper'],
upperChest: ['cupperchest', 'upperchest', 'chest2', 'spine3'],
neck: ['cneck', 'neck', 'neck1'],
head: ['chead', 'head'],
leftShoulder: ['lshoulder', 'leftshoulder', 'shoulderl', 'shoulderleft', 'claviclel', 'lclavicle'],
leftUpperArm: ['lupperarm', 'leftupperarm', 'larm', 'leftarm', 'luparm'],
leftLowerArm: ['llowerarm', 'leftlowerarm', 'lforearm', 'leftforearm', 'lelbow'],
leftHand: ['lhand', 'lefthand', 'lwrist', 'leftwrist'],
rightShoulder: ['rshoulder', 'rightshoulder', 'shoulderr', 'shoulderright', 'clavicler', 'rclavicle'],
rightUpperArm: ['rupperarm', 'rightupperarm', 'rarm', 'rightarm', 'ruparm'],
rightLowerArm: ['rlowerarm', 'rightlowerarm', 'rforearm', 'rightforearm', 'relbow'],
rightHand: ['rhand', 'righthand', 'rwrist', 'rightwrist'],
leftUpperLeg: ['lupperleg', 'leftupperleg', 'lthigh', 'leftthigh', 'lupleg', 'lhip', 'lefthip'],
leftLowerLeg: [
'llowerleg',
'leftlowerleg',
'lleg',
'leftleg',
'lshin',
'leftshin',
'lknee',
'leftknee',
],
leftFoot: ['lfoot', 'leftfoot', 'lankle', 'leftankle'],
leftToes: ['ltoes', 'lefttoes', 'ltoe', 'lefttoe', 'ltoebase', 'lefttoebase'],
rightUpperLeg: ['rupperleg', 'rightupperleg', 'rthigh', 'rightthigh', 'rupleg', 'rhip', 'righthip'],
rightLowerLeg: [
'rlowerleg',
'rightlowerleg',
'rleg',
'rightleg',
'rshin',
'rightshin',
'rknee',
'rightknee',
],
rightFoot: ['rfoot', 'rightfoot', 'rankle', 'rightankle'],
rightToes: ['rtoes', 'righttoes', 'rtoe', 'righttoe', 'rtoebase', 'righttoebase'],
};
/* -------------------------------------------------------------- 客户端逻辑移植 */
/**
* 归一化骨骼名:小写 + 只保留 a-z0-9(分隔符、冒号、空格全部丢弃)。
* 移植自客户端 `humanoid_map.gd` 的 normalize()。
* 例如 "J_Bip_L_UpperArm" → "jbiplupperarm"。
*/
export function normalizeBoneName(nodeName: string): string {
const lower = nodeName.toLowerCase();
let out = '';
for (const ch of lower) {
if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) out += ch;
}
return out;
}
/** 去掉可剥离前缀后的形态。移植自客户端 strip_prefix()。 */
function stripPrefix(normalized: string): string {
for (const prefix of STRIPPABLE_PREFIXES) {
if (normalized.startsWith(prefix)) return normalized.slice(prefix.length);
}
return normalized;
}
/**
* 生成用于比较的形态集合。移植自客户端 variants():
* compact —— left/right 缩写为 l/r:"leftupperarm" → "lupperarm"
* expanded —— 首字母 l/r 展开为 left/right:"lupperarm" → "leftupperarm"
* 返回值与客户端一致地保留重复项(base / compact / expanded 共 3 项)。
*/
export function boneNameVariants(nodeName: string): string[] {
const base = stripPrefix(normalizeBoneName(nodeName));
const out = [base];
let compact = base;
if (base.startsWith('left')) compact = 'l' + base.slice(4);
else if (base.startsWith('right')) compact = 'r' + base.slice(5);
out.push(compact);
let expanded = base;
if (base.startsWith('l') && !base.startsWith('left')) expanded = 'left' + base.slice(1);
else if (base.startsWith('r') && !base.startsWith('right')) expanded = 'right' + base.slice(1);
out.push(expanded);
return out;
}
/* ------------------------------------------------------------------ glTF 解析工具 */
/** glTF JSON 的宽松视图:外部文件不可信,一律按 unknown 逐字段守卫 */
type Json = Record<string, unknown>;
const GLB_MAGIC = 0x46546c67; // 'glTF'
const GLB_VERSION = 2;
const CHUNK_JSON = 0x4e4f534a; // 'JSON';BIN chunk 是 0x004E4942,本检查器用不到
function isRecord(value: unknown): value is Json {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
/** 把任意值收敛为有限非负整数;NaN / Infinity / 负数 / 非数字一律记 0 */
function toCount(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
return Math.max(0, Math.trunc(value));
}
/** 合法的数组下标(用于节点索引、accessor 索引);不合法返回 null */
function toIndex(value: unknown, length: number): number | null {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value >= length) return null;
return value;
}
/** 非空字符串(用于 VRM 版本号等);其余返回 null */
function asText(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
function align4(value: number): number {
return value + ((4 - (value % 4)) % 4);
}
function looksLikeGlb(buffer: Buffer): boolean {
return buffer.length >= 4 && buffer.readUInt32LE(0) === GLB_MAGIC;
}
function looksLikeGltf(json: Json): boolean {
return (
isRecord(json.asset) ||
Array.isArray(json.meshes) ||
Array.isArray(json.nodes) ||
Array.isArray(json.scenes)
);
}
/**
* 解析 GLB 的 JSON chunk。
* 12 字节头(magic / version / 总长)+ chunk(JSON 0x4E4F534A / BIN 0x004E4942),
* chunk 按 4 字节对齐 —— 与 `glb-textures.ts` 同一套布局(此处不 import,避免耦合)。
* 任何异常都转成 warnings,返回 null 由调用方降级,不抛错。
*/
function parseGlbJson(buffer: Buffer, warnings: string[]): Json | null {
if (buffer.length < 12) {
warnings.push('GLB 文件过小(不足 12 字节头),无法解析');
return null;
}
const version = buffer.readUInt32LE(4);
if (version !== GLB_VERSION) {
warnings.push(`GLB 版本为 ${version},只支持版本 2`);
return null;
}
const declared = buffer.readUInt32LE(8);
if (declared > buffer.length) {
warnings.push(`GLB 头声明总长 ${declared} 字节,实际只有 ${buffer.length} 字节(文件可能被截断)`);
}
const end = declared > 0 ? Math.min(declared, buffer.length) : buffer.length;
let offset = 12;
let json: Json | null = null;
while (offset + 8 <= end) {
const chunkLength = buffer.readUInt32LE(offset);
const chunkType = buffer.readUInt32LE(offset + 4);
const dataStart = offset + 8;
if (dataStart + chunkLength > buffer.length) {
warnings.push('GLB chunk 长度越界,文件可能被截断');
break;
}
if (chunkType === CHUNK_JSON) {
try {
const parsed: unknown = JSON.parse(
buffer.subarray(dataStart, dataStart + chunkLength).toString('utf8'),
);
if (isRecord(parsed)) json = parsed;
else warnings.push('GLB 的 JSON chunk 不是对象');
} catch {
warnings.push('GLB 的 JSON chunk 解析失败(文件可能被截断)');
}
}
// BIN chunk 本检查器用不到(面数/骨骼只需要 accessor 的 count),跳过即可
offset = dataStart + align4(chunkLength);
}
if (!json) warnings.push('GLB 里没有可用的 JSON chunk');
return json;
}
/** 解析纯文本 `.gltf`;非 glTF JSON 一律返回 null 并写 warnings */
function parseGltfText(buffer: Buffer, warnings: string[]): Json | null {
const text = buffer.toString('utf8');
if (!/^\s*\{/.test(text)) {
warnings.push('不是 GLB,也不是 JSON 文本(疑似 fbx / pmx 等非 glTF 容器)');
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
warnings.push('JSON 解析失败(文件可能被截断或不完整)');
return null;
}
if (!isRecord(parsed)) {
warnings.push('JSON 顶层不是对象,不是 glTF 文档');
return null;
}
if (!looksLikeGltf(parsed)) {
warnings.push('JSON 里没有 asset / meshes / nodes / scenes,不是 glTF 文档');
return null;
}
return parsed;
}
/* ------------------------------------------------------------------ 统计与骨骼树 */
/** 非 glTF(或彻底解析失败)时的降级结果:全部记 0,缺全部必需骨骼 */
function emptyStats(container: ModelStats['container'], fileSize: number, warnings: string[]): ModelStats {
return {
container,
vrmVersion: null,
fileSize,
meshes: 0,
primitives: 0,
vertices: 0,
triangles: 0,
materials: 0,
textures: 0,
images: 0,
animations: 0,
morphTargets: 0,
nodes: 0,
bones: 0,
skins: 0,
humanoidMapped: 0,
humanoidTotal: HUMANOID_BONES.length,
missingRequiredBones: [...HUMANOID_REQUIRED],
drivable: false,
maxBoneDepth: 0,
boneTree: [],
warnings,
};
}
/** 读 accessor 的 count;索引缺失/越界/不是对象都记 0 */
function accessorCount(accessors: unknown[], ref: unknown): number {
const index = toIndex(ref, accessors.length);
if (index === null) return 0;
const accessor = accessors[index];
return isRecord(accessor) ? toCount(accessor.count) : 0;
}
/** VRM 识别:VRMC_vrm → 1.0;VRM → 0.x。返回版本号(非 VRM 为 null) */
function detectVrmVersion(gltf: Json, warnings: string[]): string | null {
const extensions = isRecord(gltf.extensions) ? gltf.extensions : {};
const vrmc = extensions['VRMC_vrm'];
if (isRecord(vrmc)) {
// VRM 1.0:meta 缺失时审核拿不到授权/来源信息,值得提示
if (!isRecord(vrmc.meta)) warnings.push('VRM 1.0 扩展缺少 meta,模型来源与授权信息未知');
return asText(vrmc.specVersion) ?? '1.0';
}
const vrm0 = extensions['VRM'];
if (isRecord(vrm0)) {
if (!isRecord(vrm0.meta)) warnings.push('VRM 0.x 扩展缺少 meta,模型来源与授权信息未知');
return asText(vrm0.specVersion) ?? asText(vrm0.exporterVersion) ?? '0.x';
}
return null;
}
interface FlatNode {
index: number;
name: string;
depth: number;
parentName: string | null;
childCount: number;
}
/** 分析一份已解析的 glTF 文档(glb / gltf / vrm 共用) */
function analyzeGltfDocument(
gltf: Json,
baseContainer: 'glb' | 'gltf',
fileSize: number,
warnings: string[],
): ModelStats {
const vrmVersion = detectVrmVersion(gltf, warnings);
// VRM 就是带扩展的 glTF:识别到扩展就把容器标成 vrm
const container: ModelStats['container'] = vrmVersion !== null ? 'vrm' : baseContainer;
const meshList = asArray(gltf.meshes);
const accessors = asArray(gltf.accessors);
const nodeList = asArray(gltf.nodes);
const skinList = asArray(gltf.skins).filter(isRecord);
/* ---------------------------------------------------------- 网格统计 */
let primitives = 0;
let vertices = 0;
let triangles = 0;
let morphTargets = 0;
for (let mi = 0; mi < meshList.length; mi += 1) {
const mesh = meshList[mi];
if (!isRecord(mesh)) continue;
const primitiveList = asArray(mesh.primitives);
for (let pi = 0; pi < primitiveList.length; pi += 1) {
const primitive = primitiveList[pi];
if (!isRecord(primitive)) continue;
primitives += 1;
const attributes = isRecord(primitive.attributes) ? primitive.attributes : {};
const positionCount = accessorCount(accessors, attributes['POSITION']);
if (positionCount === 0) {
warnings.push(`mesh[${mi}].primitive[${pi}] 缺少可用的 POSITION accessor,顶点数按 0 计`);
}
vertices += positionCount;
// 有索引按索引数/3;无索引按顶点数/3(口径见文件头注释)
const indexCount = accessorCount(accessors, primitive.indices);
triangles += Math.floor((indexCount > 0 ? indexCount : positionCount) / 3);
morphTargets += asArray(primitive.targets).length;
const mode = primitive.mode;
if (typeof mode === 'number' && mode !== 4) {
warnings.push(`mesh[${mi}].primitive[${pi}] 的 mode=${mode} 不是 TRIANGLES,面数按索引数/3 估算`);
}
}
}
/* ---------------------------------------------------------- 蒙皮统计 */
// bones = 所有 skin.joints 去重;越界的 joint 索引视为脏数据丢弃(否则会凭空多出骨骼)
const joints = new Set<number>();
for (const skin of skinList) {
for (const joint of asArray(skin.joints)) {
const index = toIndex(joint, nodeList.length);
if (index !== null) joints.add(index);
}
}
/* ------------------------------------------------------ 骨骼树(DFS) */
const nodeAt = (index: number): Json => {
const node = nodeList[index];
return isRecord(node) ? node : {};
};
const nodeName = (index: number): string => {
const name = nodeAt(index).name;
return typeof name === 'string' && name.trim().length > 0 ? name : `node_${index}`;
};
const childrenOf = (index: number): number[] => {
const out: number[] = [];
for (const child of asArray(nodeAt(index).children)) {
const valid = toIndex(child, nodeList.length);
if (valid !== null && !out.includes(valid)) out.push(valid);
}
return out;
};
const childIndices = new Set<number>();
for (let i = 0; i < nodeList.length; i += 1) {
for (const child of childrenOf(i)) childIndices.add(child);
}
// 根:优先 scenes[scene].nodes;没有场景信息时用「不作为任何节点子节点」的节点
const sceneList = asArray(gltf.scenes).filter(isRecord);
const sceneRef = gltf.scene;
const sceneIndex =
typeof sceneRef === 'number' && Number.isInteger(sceneRef) && sceneRef >= 0 && sceneRef < sceneList.length
? sceneRef
: 0;
const roots: number[] = [];
const sceneNodes = sceneList.length > 0 ? asArray(sceneList[sceneIndex]?.nodes) : [];
for (const node of sceneNodes) {
const index = toIndex(node, nodeList.length);
if (index !== null && !roots.includes(index)) roots.push(index);
}
if (roots.length === 0) {
for (let i = 0; i < nodeList.length; i += 1) {
if (!childIndices.has(i)) roots.push(i);
}
}
const flat: FlatNode[] = [];
const visited = new Set<number>();
const visit = (index: number, depth: number, parentName: string | null): void => {
if (visited.has(index)) return; // 环形 children 保护
visited.add(index);
const children = childrenOf(index);
flat.push({ index, name: nodeName(index), depth, parentName, childCount: children.length });
// 多根时每个根 depth=0;父在前、子在后(前序)
for (const child of children) visit(child, depth + 1, nodeName(index));
};
for (const root of roots) visit(root, 0, null);
// 不在场景图里的孤立节点也要能看到(按根处理),否则审核会漏骨骼
const orphans: number[] = [];
for (let i = 0; i < nodeList.length; i += 1) {
if (!visited.has(i)) orphans.push(i);
}
if (orphans.length > 0) {
warnings.push(`有 ${orphans.length} 个节点不在场景图中,已按根节点(depth=0)列出`);
for (const orphan of orphans) visit(orphan, 0, null);
}
/* -------------------------------------------------- humanoid 映射(与客户端一致) */
// variants → 节点索引;同名冲突取**先出现**的节点(骨骼树父在前,真正的 humanoid 骨通常更靠前)
const variantNode = new Map<string, number>();
for (const node of flat) {
for (const variant of boneNameVariants(node.name)) {
if (!variantNode.has(variant)) variantNode.set(variant, node.index);
}
}
const matched = new Map<string, number>();
for (const canonical of HUMANOID_BONES) {
for (const candidate of HUMANOID_CANDIDATES[canonical] ?? []) {
const hit = variantNode.get(candidate);
if (hit !== undefined) {
matched.set(canonical, hit);
break;
}
}
}
// 一个节点理论上可能被多个 canonical 命中(如 spine / spine1),
// 这里按 HUMANOID_BONES 顺序取第一次命中作为该节点的 humanoid 名
const humanoidByNode = new Map<number, string>();
for (const canonical of HUMANOID_BONES) {
const nodeIndex = matched.get(canonical);
if (nodeIndex !== undefined && !humanoidByNode.has(nodeIndex)) {
humanoidByNode.set(nodeIndex, canonical);
}
}
const boneTree: BoneNode[] = flat.map((node) => ({
index: node.index,
name: node.name,
depth: node.depth,
parentName: node.parentName,
childCount: node.childCount,
inSkin: joints.has(node.index),
humanoid: humanoidByNode.get(node.index) ?? null,
}));
let maxBoneDepth = 0;
for (const node of boneTree) {
if (node.depth > maxBoneDepth) maxBoneDepth = node.depth;
}
const missingRequiredBones = HUMANOID_REQUIRED.filter((bone) => !matched.has(bone));
if (nodeList.length === 0) warnings.push('文件里没有 nodes,无法分析骨骼树');
return {
container,
vrmVersion,
fileSize,
meshes: meshList.length,
primitives,
vertices,
triangles,
materials: asArray(gltf.materials).length,
textures: asArray(gltf.textures).length,
images: asArray(gltf.images).length,
animations: asArray(gltf.animations).length,
morphTargets,
nodes: nodeList.length,
bones: joints.size,
skins: asArray(gltf.skins).length,
humanoidMapped: matched.size,
humanoidTotal: HUMANOID_BONES.length,
missingRequiredBones,
drivable: missingRequiredBones.length === 0,
maxBoneDepth,
boneTree,
warnings,
};
}
/* ------------------------------------------------------------------ 入口 */
/**
* 读取并分析模型文件。
*
* @param absPath 模型文件绝对路径
* @param fileSize 可选的已知文件大小(如数据库里记录的值);不传则用实际读到的字节数
*
* 非 glTF 容器(fbx / pmx 等)、文件不存在、magic 不对、JSON 截断、缺 accessor……
* 一律返回 `container='unknown'`(或已解析部分)+ `warnings`,**不抛异常**。
*/
export async function inspectModelFile(absPath: string, fileSize?: number): Promise<ModelStats> {
const warnings: string[] = [];
const hasSize = typeof fileSize === 'number' && Number.isFinite(fileSize);
let buffer: Buffer;
try {
buffer = await readFile(absPath);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return emptyStats('unknown', hasSize ? toCount(fileSize) : 0, [`读取文件失败:${reason}`]);
}
const size = hasSize ? toCount(fileSize) : buffer.length;
let gltf: Json | null;
const glb = looksLikeGlb(buffer);
if (glb) {
gltf = parseGlbJson(buffer, warnings);
} else {
gltf = parseGltfText(buffer, warnings);
}
if (!gltf) return emptyStats('unknown', size, warnings);
return analyzeGltfDocument(gltf, glb ? 'glb' : 'gltf', size, warnings);
}
File diff suppressed because it is too large Load Diff
@@ -14,6 +14,8 @@ export interface ModelDto {
fileSha256: string | null;
/** 上传压缩包的 sha256(稳定);非压缩包上传为 null,此时以 fileSha256 为准 */
sourceSha256: string | null;
/** 模型分析结果(面数 / 骨骼树 / humanoid 覆盖 / 可驱动性),供审核界面展示 */
stats: Record<string, unknown> | null;
thumbnailUrl: string | null;
/** 由压缩包转换而来时的说明,例如 `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图` */
conversionNote: string | null;
@@ -46,6 +48,7 @@ export function toModelDto(model: CharacterModel, urls: ModelUrlLookup): ModelDt
fileSize: urls.fileSize ?? null,
fileSha256: urls.fileSha256 ?? null,
sourceSha256: urls.sourceSha256 ?? null,
stats: model.stats ?? null,
thumbnailUrl: urls.thumbnailUrl,
conversionNote: model.conversionNote ?? null,
sourceType: model.sourceType,
+198 -156
View File
@@ -19,15 +19,17 @@ import {
} from '../common/enums';
import { Paged, toPaged } from '../common/pagination';
import { SourceTuple, assertSourceTupleComplete } from '../common/source.dto';
import {
UploadedFileLike,
parseOptionalBool,
requireUploadedFile,
resolveFormatByExtension,
} from '../common/upload.util';
import { UploadedFileLike, parseOptionalBool, requireUploadedFile } from '../common/upload.util';
import { DEFAULT_MODEL_SEED } from '../config/default-model';
import { writeDefaultModelSeed } from './default-model-source';
import { convertModelArchive } from './model-convert';
import {
ConvertedModel,
convertModelArchive,
convertModelFile,
importModelFile,
} from './model-convert';
import { ModelStats, inspectModelFile } from './model-inspector';
import { renderModelThumbnail } from './model-thumbnail';
import { CharacterModel } from '../entities/model.entity';
import { AppConfig } from '../config/configuration';
import { StorageService } from '../storage/storage.service';
@@ -71,7 +73,20 @@ export class ModelsService {
return model;
}
/** T-WEB-005:上传模型 + 来源字段,VRM 优先;自动识别 VRM 版本 */
/**
* T-WEB-005:上传模型(T-WEB-005 追加:GLB/VRM 直接导入,PMX/FBX 优先转 GLB、其次 VRM)。
*
* 统一入口,按扩展名分流:
* - `.glb` / `.vrm` / `.gltf`:**直接导入**,不做任何重编码
* - `.pmx` / `.fbx`:服务端转换(优先 GLB,其次 VRM;见 model-convert.ts)
* - `.zip`:自动搜索包内可用模型(`.vrm` > `.glb` > `.gltf` > `.pmx` > `.fbx`),
* 能直接导入就直接导入,否则解压后转换;压缩包原件留存溯源
*
* 入库后立即做两件事:**模型分析**(面数 / 骨骼树 / humanoid 覆盖,供审核展示)
* 与**缩略图生成**(纯 TS 光栅化,失败不影响入库)。
*
* 状态一律 `pending`:模型必须经审核后才可能被客户端消费(契约 database.md 状态机)。
*/
async create(body: Record<string, unknown>, file: UploadedFileLike | undefined): Promise<ModelDto> {
const upload = requireUploadedFile(file);
@@ -90,184 +105,175 @@ export class ModelsService {
throw new BadRequestException('上传模型必须填写完整的来源信息(sourceType/sourceName/sourceUrl/license/author)');
}
// 压缩包(MMD 模型包)走"解压 → 转换 → 入库"通路,其它扩展名走原有的直传通路
if (extname(upload.originalname).toLowerCase() === '.zip') {
return this.createFromArchive(body, upload, name, tuple);
if (upload.size && upload.size > this.config.uploadMaxBytes) {
throw new BadRequestException(
`上传文件超过上限 ${Math.floor(this.config.uploadMaxBytes / 1024 / 1024)} MiB`,
);
}
const { format } = resolveFormatByExtension(upload.originalname, MODEL_EXTENSION_MAP, '模型文件');
const ext = extname(upload.originalname).toLowerCase();
const convertOptions = {
assimpBin: this.config.assimpBin,
timeoutMs: this.config.modelConvertTimeoutMs,
preferFormat: this.config.modelConvertFormat,
preferredPmx: this.config.modelConvertPmx,
vrmMeta: { name, author: tuple.author ?? '', license: tuple.license },
};
const stored = await this.storage.persistTempFile(
AssetKind.Model,
upload.path,
upload.originalname,
upload.mimetype,
);
let imported: ConvertedModel;
let sourceAssetId: number | null = null;
// VRM 版本识别(GLB 容器内检查 VRM / VRMC_vrm 扩展)
let vrmVersion: string | null = null;
if (format === ModelFormat.Vrm || format === ModelFormat.Glb) {
if (ext === '.zip') {
const archive = await readFile(upload.path);
// 原件先留存(按 sha256 去重),再解压转换
const archiveAsset = await this.storage.persistBuffer(
AssetKind.ModelArchive,
archive,
upload.originalname,
upload.mimetype || 'application/zip',
);
sourceAssetId = archiveAsset.asset.id;
try {
const inspection = await inspectVrm(this.storage.absolutePath(stored.asset));
vrmVersion = inspection.vrmVersion;
} catch {
vrmVersion = null;
imported = await convertModelArchive(archive, convertOptions);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`模型压缩包导入失败(${upload.originalname}):${message}`);
throw new UnprocessableEntityException(`模型压缩包导入失败:${message}`);
}
} else if (ext === '.pmx' || ext === '.fbx') {
const buffer = await readFile(upload.path);
try {
imported = await convertModelFile(buffer, upload.originalname, convertOptions);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`模型转换失败(${upload.originalname}):${message}`);
throw new UnprocessableEntityException(`模型转换失败:${message}`);
}
} else {
const buffer = await readFile(upload.path);
try {
imported = importModelFile(buffer, upload.originalname);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const allowed = Object.keys(MODEL_EXTENSION_MAP).join('、');
throw new UnprocessableEntityException(
`${message};允许直接导入:${allowed},或上传含 .pmx/.fbx 的 .zip 由服务端转换`,
);
}
}
if (format === ModelFormat.Vrm && !vrmVersion) {
const stored = await this.storage.persistBuffer(
AssetKind.Model,
imported.buffer,
`${name}.${imported.format}`,
imported.format === ModelFormat.Vrm || imported.format === ModelFormat.Glb
? 'model/gltf-binary'
: 'application/octet-stream',
);
// 分析 + 缩略图:都属于"尽力而为",失败不能挡住入库
const absolutePath = this.storage.absolutePath(stored.asset);
const stats = await this.analyzeSafely(absolutePath, stored.asset.size, name);
const thumbnailAssetId = await this.thumbnailSafely(absolutePath, name);
let vrmVersion: string | null = null;
if (stats.vrmVersion) {
vrmVersion = stats.vrmVersion;
} else if (imported.format === ModelFormat.Vrm) {
vrmVersion = 'unknown';
}
const status = this.parseStatus(body.status);
const model = this.models.create({
name,
format: format as ModelFormat,
format: imported.format,
vrmVersion,
isPrimary: false,
fileAssetId: stored.asset.id,
sourceAssetId,
thumbnailAssetId,
conversionNote: imported.note.slice(0, 500),
stats: stats as unknown as Record<string, unknown>,
sourceType: tuple.sourceType as CharacterModel['sourceType'],
sourceName: tuple.sourceName,
sourceUrl: tuple.sourceUrl,
license: tuple.license,
author: tuple.author,
status,
});
const saved = await this.models.save(model);
if (parseOptionalBool(body.isPrimary)) {
return this.setPrimary(saved.id);
}
const [dto] = await this.toDtos([saved]);
return dto;
}
/**
* T-WEB-005 追加:上传压缩包(如 MMD 的 `模型_xxx.zip`)→ 解压选 PMX → Assimp 转换
* → 入库。FBX 优先,导出后回读校验,失败自动回退 GLB 并记录 conversion_note。
*
* 转换成功后默认把它设为**全局默认模型**(is_primary + published),并把身份
* (名称/格式/产物 sha256)写进源码 `src/config/default-model.ts`;
* 传 `autoDefault=false` 可只入库不改默认。
*/
private async createFromArchive(
body: Record<string, unknown>,
upload: UploadedFileLike,
name: string,
tuple: NonNullable<ReturnType<typeof assertSourceTupleComplete>>,
): Promise<ModelDto> {
if (upload.size && upload.size > this.config.uploadMaxBytes) {
throw new BadRequestException(
`压缩包超过上限 ${Math.floor(this.config.uploadMaxBytes / 1024 / 1024)} MiB`,
);
}
const archive = await readFile(upload.path);
let converted;
try {
converted = await convertModelArchive(archive, {
assimpBin: this.config.assimpBin,
timeoutMs: this.config.modelConvertTimeoutMs,
preferFormat: this.config.modelConvertFormat,
preferredPmx: this.config.modelConvertPmx,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`模型压缩包转换失败(${upload.originalname}):${message}`);
throw new UnprocessableEntityException(`模型转换失败:${message}`);
}
// 原件留存用于溯源(按 sha256 去重)
const archiveAsset = await this.storage.persistBuffer(
AssetKind.ModelArchive,
archive,
upload.originalname,
upload.mimetype || 'application/zip',
);
const stored = await this.storage.persistBuffer(
AssetKind.Model,
converted.buffer,
`${name}.${converted.format}`,
converted.format === ModelFormat.Glb ? 'model/gltf-binary' : 'application/octet-stream',
);
// 转换产物若是 GLB,顺带识别是否带 VRM 扩展
let vrmVersion: string | null = null;
if (converted.format === ModelFormat.Glb) {
try {
vrmVersion = (await inspectVrm(this.storage.absolutePath(stored.asset))).vrmVersion;
} catch {
vrmVersion = null;
}
}
const autoDefault = body.autoDefault === undefined ? true : parseOptionalBool(body.autoDefault);
const status = autoDefault ? PublishStatus.Published : this.parseStatus(body.status);
const model = this.models.create({
name,
format: converted.format,
vrmVersion,
isPrimary: false,
fileAssetId: stored.asset.id,
sourceAssetId: archiveAsset.asset.id,
conversionNote: converted.note.slice(0, 500),
sourceType: tuple.sourceType as CharacterModel['sourceType'],
sourceName: tuple.sourceName,
sourceUrl: tuple.sourceUrl,
license: tuple.license,
author: tuple.author,
status,
// 上传一律待审核:模型必须过审核才可能被客户端消费
status: PublishStatus.Pending,
});
const saved = await this.models.save(model);
this.logger.log(
`模型压缩包已转换入库:#${saved.id} ${name}(${converted.note})` +
`;尝试记录:${converted.attempts.join(';')}`,
`模型已入库待审:#${saved.id} ${name}(${imported.format},${imported.direct ? '直接导入' : '转换'})` +
` 面数=${stats.triangles} 骨骼=${stats.bones} humanoid=${stats.humanoidMapped}/${stats.humanoidTotal}` +
` 可驱动=${stats.drivable};说明:${imported.note};尝试:${imported.attempts.join(';')}`,
);
if (autoDefault) {
await this.applyDefaultModel(saved.id, {
sourceSha256: archiveAsset.asset.sha256,
fileSha256: stored.asset.sha256,
note: converted.note,
});
// setPrimary 走的是独立事务,重新读一次才能让返回体里的 isPrimary 正确
const [dto] = await this.toDtos([await this.getEntity(saved.id)]);
return dto;
}
const [dto] = await this.toDtos([saved]);
return dto;
}
/**
* 设为默认模型并把种子写进源码。写入失败只告警:生产环境 systemd 的
* `ProtectSystem=strict` 不允许服务进程改程序目录,通常由
* `scripts/import-default-model.mjs` 在沙箱外完成写入。
*/
private async applyDefaultModel(
id: number,
payload: { sourceSha256: string; fileSha256: string; note: string },
): Promise<void> {
await this.setPrimary(id);
const model = await this.getEntity(id);
const result = await writeDefaultModelSeed({
id,
name: model.name,
format: model.format,
sourceSha256: payload.sourceSha256,
fileSha256: payload.fileSha256,
convertedFrom: payload.note.split(',')[0] ?? null,
updatedAt: new Date().toISOString(),
});
if (result.written) {
this.logger.log(`默认模型已写进源码:${result.path}`);
} else {
this.logger.warn(
`默认模型已在库中设为 is_primary,但源码写入失败(${result.reason});` +
`可在源码目录执行 node scripts/import-default-model.mjs --write-source-only 补写`,
/** 分析模型;任何异常都降级为一条 warning,不阻断上传 */
private async analyzeSafely(
absolutePath: string,
fileSize: number,
name: string,
): Promise<ModelStats> {
try {
const stats = await inspectModelFile(absolutePath, fileSize);
if (stats.warnings.length > 0) {
this.logger.warn(`模型「${name}」分析提示:${stats.warnings.join(';')}`);
}
return stats;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`模型「${name}」分析失败:${message}`);
return {
container: 'unknown',
vrmVersion: null,
fileSize,
meshes: 0,
primitives: 0,
vertices: 0,
triangles: 0,
materials: 0,
textures: 0,
images: 0,
animations: 0,
morphTargets: 0,
nodes: 0,
bones: 0,
skins: 0,
humanoidMapped: 0,
humanoidTotal: 22,
missingRequiredBones: [],
drivable: false,
maxBoneDepth: 0,
boneTree: [],
warnings: [`分析失败:${message}`],
};
}
}
/** 生成缩略图;失败只告警(列表页会退化为格式占位图) */
private async thumbnailSafely(absolutePath: string, name: string): Promise<number | null> {
try {
const thumbnail = await renderModelThumbnail(absolutePath, { size: 512 });
if (!thumbnail) {
this.logger.warn(`模型「${name}」无法生成缩略图(非 glTF 容器或结构不完整)`);
return null;
}
const asset = await this.storage.persistBuffer(
AssetKind.Thumbnail,
thumbnail.png,
`${name}.png`,
'image/png',
);
return asset.asset.id;
} catch (error) {
this.logger.warn(
`模型「${name}」缩略图生成失败:${error instanceof Error ? error.message : String(error)}`,
);
return null;
}
}
@@ -309,16 +315,52 @@ export class ModelsService {
async setPrimary(id: number): Promise<ModelDto> {
const model = await this.getEntity(id);
if (model.status !== PublishStatus.Published) {
throw new ConflictException(
`模型 #${id} 当前状态为 ${model.status},只有已发布(published)的模型才能设为默认模型`,
);
}
await this.dataSource.transaction(async (manager) => {
await manager.update(CharacterModel, { isPrimary: true }, { isPrimary: false });
await manager.update(CharacterModel, { id }, { isPrimary: true });
});
// 把"默认模型是谁"写进源码(种子以压缩包 sha256 为主键,见 default-model.ts)
await this.writeDefaultModelSeed(model);
model.isPrimary = true;
const [dto] = await this.toDtos([model]);
return dto;
}
/** 取源压缩包(若有)或模型文件本身的 sha256 作为稳定匹配键 */
private async writeDefaultModelSeed(model: CharacterModel): Promise<void> {
const assets = await this.storage.findMany([model.sourceAssetId, model.fileAssetId]);
const source = model.sourceAssetId ? assets.get(model.sourceAssetId) : undefined;
const file = model.fileAssetId ? assets.get(model.fileAssetId) : undefined;
const seedSha = source?.sha256 ?? file?.sha256 ?? '';
if (!seedSha) {
this.logger.warn(`模型 #${model.id} 缺少文件 sha256,无法写入默认模型种子`);
return;
}
const result = await writeDefaultModelSeed({
id: model.id,
name: model.name,
format: model.format,
sourceSha256: source?.sha256 ?? file?.sha256 ?? '',
fileSha256: file?.sha256 ?? '',
convertedFrom: (model.conversionNote ?? '').split(',')[0] || null,
updatedAt: new Date().toISOString(),
});
if (result.written) this.logger.log(`默认模型已写进源码:${result.path}`);
else {
this.logger.warn(
`默认模型已在库中设为 is_primary,但源码写入失败(${result.reason});` +
'可在源码目录执行 node scripts/import-default-model.mjs --write-source-only --model-id ' + model.id,
);
}
}
/**
* 客户端默认模型解析 —— 契约 data-models.md §4.1。
*
+210
View File
@@ -0,0 +1,210 @@
import { ModelFormat } from '../common/enums';
import { HUMANOID_CANDIDATES, HUMANOID_REQUIRED, normalizeBoneName, boneNameVariants } from './model-inspector';
/**
* GLB → VRM 封装(服务端"其次转 VRM"这一步)。
*
* 为什么要自己写:Assimp 不会写 VRM 扩展(它不认识 VRM),所以 `GLB → VRM`
* 在通用转换器里是做不到的。但 VRM 本质就是"glTF + 一个 JSON 扩展",
* 只要能把 humanoid 骨骼映射写进 `extensions.VRMC_vrm`,就能得到生态
* (three-vrm、UniVRM、Godot 侧的本项目加载器)认得的 VRM 1.0 模型。
*
* 本模块只改 JSON chunk,不碰二进制数据:因此**不会有任何几何/材质损失**,
* 只是在 GLB 之上补一层语义。骨骼名映射不到 humanoid 规范名时会抛错,
* 由调用方决定回退(此时应当交付 GLB)。
*/
const GLB_MAGIC = 0x46546c67;
const CHUNK_JSON = 0x4e4f534a;
const CHUNK_BIN = 0x004e4942;
export interface VrmWrapMeta {
name: string;
author: string;
/** 许可说明,写进 meta.otherLicenseUrl / copyrightInformation */
license?: string;
/** 原压缩包/来源说明 */
sourceNote?: string;
}
export interface VrmWrapResult {
buffer: Buffer;
/** 成功写入 humanoid 映射的骨骼数量 */
humanoidBones: number;
/** 映射不到的必需骨骼(为空才允许产出 VRM) */
missingRequired: string[];
}
interface GltfJson {
asset?: Record<string, unknown>;
nodes?: Array<{ name?: string; children?: number[] }>;
scenes?: Array<{ nodes?: number[] }>;
scene?: number;
extensionsUsed?: string[];
extensions?: Record<string, unknown>;
[key: string]: unknown;
}
function align4(value: number): number {
return value + ((4 - (value % 4)) % 4);
}
/** 解析 GLB 的 JSON 与 BIN chunk */
function parseGlb(buffer: Buffer): { json: GltfJson; bin: Buffer | null } {
if (buffer.length < 12 || buffer.readUInt32LE(0) !== GLB_MAGIC) {
throw new Error('不是有效的 GLB(魔数不正确)');
}
const total = buffer.readUInt32LE(8) || buffer.length;
const end = Math.min(total, buffer.length);
let offset = 12;
let json: GltfJson | null = null;
let bin: Buffer | null = null;
while (offset + 8 <= end) {
const length = buffer.readUInt32LE(offset);
const type = buffer.readUInt32LE(offset + 4);
const start = offset + 8;
if (start + length > buffer.length) break;
const data = buffer.subarray(start, start + length);
if (type === CHUNK_JSON) json = JSON.parse(data.toString('utf8')) as GltfJson;
else if (type === CHUNK_BIN) bin = Buffer.from(data);
offset = start + align4(length);
}
if (!json) throw new Error('GLB 里没有 JSON chunk');
return { json, bin };
}
function buildGlb(json: GltfJson, bin: Buffer | null): Buffer {
const jsonBuffer = Buffer.from(JSON.stringify(json), 'utf8');
const jsonPadded = Buffer.alloc(align4(jsonBuffer.length), 0x20);
jsonBuffer.copy(jsonPadded);
const chunks: Buffer[] = [];
const header = Buffer.alloc(12);
header.writeUInt32LE(GLB_MAGIC, 0);
header.writeUInt32LE(2, 4);
chunks.push(header);
const jsonHeader = Buffer.alloc(8);
jsonHeader.writeUInt32LE(jsonPadded.length, 0);
jsonHeader.writeUInt32LE(CHUNK_JSON, 4);
chunks.push(jsonHeader, jsonPadded);
if (bin && bin.length > 0) {
const binPadded = Buffer.alloc(align4(bin.length));
bin.copy(binPadded);
const binHeader = Buffer.alloc(8);
binHeader.writeUInt32LE(binPadded.length, 0);
binHeader.writeUInt32LE(CHUNK_BIN, 4);
chunks.push(binHeader, binPadded);
}
const out = Buffer.concat(chunks);
out.writeUInt32LE(out.length, 8);
return out;
}
/**
* 按骨骼名把 glTF 节点映射到 VRM humanoid 规范骨。
* 复用 `model-inspector.ts` 的候选表(与 Godot 客户端 `humanoid_map.gd` 一致),
* 保证"服务端判定的可驱动性 = 客户端实际能不能驱动"。
*/
export function mapHumanoidBones(
nodes: ReadonlyArray<{ name?: string }>,
): { bones: Array<{ bone: string; node: number }>; missing: string[] } {
const used = new Set<number>();
const bones: Array<{ bone: string; node: number }> = [];
for (const canonical of HUMANOID_REQUIRED.concat(
Object.keys(HUMANOID_CANDIDATES).filter((name) => !HUMANOID_REQUIRED.includes(name)),
)) {
const candidates = HUMANOID_CANDIDATES[canonical] ?? [];
let hit = -1;
for (let index = 0; index < nodes.length; index += 1) {
if (used.has(index)) continue;
const name = nodes[index]?.name;
if (!name) continue;
const variants = boneNameVariants(name);
if (variants.some((variant) => candidates.includes(variant))) {
hit = index;
break;
}
}
if (hit >= 0) {
used.add(hit);
bones.push({ bone: canonical, node: hit });
}
}
const mapped = new Set(bones.map((entry) => entry.bone));
const missing = HUMANOID_REQUIRED.filter((name) => !mapped.has(name));
return { bones, missing };
}
/**
* 把 GLB 封装成 VRM 1.0(`VRMC_vrm`)。
*
* @throws 当必需 humanoid 骨骼映射不全时抛出(调用方应回退到 GLB 交付)
*/
export function wrapGlbAsVrm(buffer: Buffer, meta: VrmWrapMeta): VrmWrapResult {
const { json, bin } = parseGlb(buffer);
const nodes = json.nodes ?? [];
const { bones, missing } = mapHumanoidBones(nodes);
if (missing.length > 0) {
throw new Error(
`无法生成 VRM:humanoid 必需骨骼缺失 ${missing.join('、')}(共 ${HUMANOID_REQUIRED.length} 根)。` +
'骨骼名需要能映射到 VRM humanoid 规范(如 J_Bip_C_Hips / Hips 等)。',
);
}
const extensions = { ...(json.extensions ?? {}) };
extensions.VRMC_vrm = {
specVersion: '1.0',
meta: {
name: meta.name,
version: '1.0',
authors: [meta.author || 'unknown'],
copyrightInformation: meta.license ?? 'see source archive',
contactInformation: '',
references: meta.sourceNote ? [meta.sourceNote] : [],
thirdPartyLicenses: '',
// 未内置缩略图:审核通过后由服务端生成,这里留空是允许的
thumbnailImage: -1,
licenseUrl: 'https://vrm.dev/licenses/1.0/',
avatarPermission: 'onlyAuthor',
allowExcessivelyViolentExpressions: false,
allowExcessivelySexualExpressions: false,
allowPoliticalOrReligiousExpressions: false,
allowRedistribution: false,
allowModification: false,
modificationRedistribution: false,
creditNotation: 'required',
allowCreditRedistribution: false,
},
humanoid: {
humanBones: Object.fromEntries(
bones.map((entry) => [entry.bone, { node: entry.node }]),
),
},
};
json.extensions = extensions;
const used = new Set([...(json.extensionsUsed ?? []), 'VRMC_vrm']);
json.extensionsUsed = [...used];
return { buffer: buildGlb(json, bin), humanoidBones: bones.length, missingRequired: missing };
}
/** 产物是否是 VRM 容器(检查扩展名,供调用方判定交付格式) */
export function detectGlbKind(buffer: Buffer): ModelFormat {
try {
const { json } = parseGlb(buffer);
const ext = json.extensions ?? {};
if (ext['VRMC_vrm'] || ext.VRM) return ModelFormat.Vrm;
} catch {
/* 解析失败按普通 glb 处理 */
}
return ModelFormat.Glb;
}
export { normalizeBoneName };
+9 -1
View File
@@ -1,4 +1,4 @@
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
import { IsBoolean, IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
import { PublishStatus, TargetType } from '../common/enums';
import { PageQueryDto } from '../common/pagination';
@@ -12,6 +12,14 @@ export class ReviewActionDto {
@IsString()
@MaxLength(2000)
comment?: string;
/**
* 仅对模型生效:通过审核的同时把它设为全局默认模型
* (并把它写进源码 `src/config/default-model.ts`)。
*/
@IsOptional()
@IsBoolean()
setPrimary?: boolean;
}
export class QueryReviewDto extends PageQueryDto {
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ModelsModule } from '../models/models.module';
import { CharacterModel } from '../entities/model.entity';
import { Motion } from '../entities/motion.entity';
import { ReviewRecord } from '../entities/review-record.entity';
@@ -8,7 +9,7 @@ import { ReviewsController } from './reviews.controller';
import { ReviewsService } from './reviews.service';
@Module({
imports: [TypeOrmModule.forFeature([ReviewRecord, Song, Motion, CharacterModel])],
imports: [TypeOrmModule.forFeature([ReviewRecord, Song, Motion, CharacterModel]), ModelsModule],
controllers: [ReviewsController],
providers: [ReviewsService],
exports: [ReviewsService],
+10 -1
View File
@@ -5,6 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ModelsService } from '../models/models.service';
import { Repository } from 'typeorm';
import {
ACTION_TARGET_STATUS,
@@ -67,6 +68,7 @@ export class ReviewsService {
@InjectRepository(Song) private readonly songs: Repository<Song>,
@InjectRepository(Motion) private readonly motions: Repository<Motion>,
@InjectRepository(CharacterModel) private readonly models: Repository<CharacterModel>,
private readonly modelsService: ModelsService,
) {}
async listRecords(query: QueryReviewDto): Promise<Paged<ReviewRecordDto>> {
@@ -136,7 +138,14 @@ export class ReviewsService {
if (!model) throw new NotFoundException(`模型 #${id} 不存在`);
entity = model;
name = model.name;
save = () => this.models.save(model);
save = async () => {
const saved = await this.models.save(model);
// 审核通过时可选顺带设为默认模型(写库 + 写源码种子)
if (dto.setPrimary && action === ReviewAction.Approve) {
await this.modelsService.setPrimary(id);
}
return saved;
};
break;
}
default: