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:
@@ -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 ?? '',
|
||||
|
||||
@@ -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` 每行一条,`#` 起注释) */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/** 该种子是否指向一个真实模型 */
|
||||
|
||||
@@ -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\``);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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。
|
||||
*
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user