Files
opendance-web/web-server/src/models/model-inspector.ts
T
opendance 8e8cc0475b fix(models): 修复上传 PMX 返回 500(models.stats 超 TEXT 上限)与 GBK 丢贴图
现象(真实故障):导入示例文件/【奥黛塔】_by_原神.zip 返回 500 Internal server error。

根因一(500):
  该 PMX 643 根骨骼、93,350 面,转换本身成功(GLB 23.7MB),但分析结果里的
  骨骼树按骨骼逐条展开,JSON 实测 70,980 字节,而 models.stats 是 TEXT
  (上限 65,535)。MySQL/MariaDB 严格模式直接拒绝写入(ER_DATA_TOO_LONG /
  1406),而这一步没有兜底 -> 未捕获 -> 500。

根因二(丢贴图):
  tex/髮.png 的 GBK 字节是 F3 8C。文件名解码先试 Shift-JIS,而 WHATWG 的
  shift_jis 把未定义码位映射成私用区不可见字符(不是 U+FFFD),于是被判为
  「解码成功」,永不回退 GBK;贴图名对不上,该模型 18 张贴图只内嵌了 17 张。

修复:
1. models.stats -> MEDIUMTEXT(实体 mediumtext + transformer,保留 JSON 语义;
   不用 simple-json 以免元数据与库列型不一致),新增迁移
   AddModelStatsMediumText1789550000000;
2. 新增 shrinkStatsForStorage() 兜底:即使库未迁移,也只截断骨骼树
   (父在前,保留祖先链),面数/骨骼数/humanoid 覆盖/可驱动性/warnings 全部保留;
   服务层 saveWithStatsFallback() 仅在 1406/DATA_TOO_LONG 时裁剪重试一次,
   其它数据库错误照旧抛出;
3. 压缩包文件名解码把「私用区字符」也算作解错信号,回退 GBK;实测该模型
   贴图由 17/18 变为 18/18 全部内嵌;
4. 新增 12 项测试(test/model-stats-limit.spec.ts 9 项 + 压缩包编码/目录条目 3 项);
5. 版本 0.2.3,README(.md/.txt)/DSH.md 补修订说明、500 的 FAQ 与 stats 容量约束。
2026-09-16 03:30:12 +08:00

737 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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);
}
/* --------------------------------------------------- 存储兜底:裁剪超大的 stats */
/** MySQL / MariaDB 的 TEXT 上限(未迁移 MEDIUMTEXT 的老库仍是这个值) */
export const MYSQL_TEXT_LIMIT = 65535;
/** 裁剪后至少保留的骨骼条目数:太少审核界面就看不出结构了 */
const MIN_BONE_TREE_KEPT = 24;
/**
* 把过大的 `ModelStats` 裁到能安全写进 TEXT 的大小(纯函数,永不抛异常)。
*
* 为什么需要:`models.stats` 里最占地方的是骨骼树,按骨骼逐条展开。
* 实测一个 643 骨骼的 PMX 分析结果 70,980 字节 > TEXT 上限 65,535,
* MySQL 严格模式会直接拒绝写入(1406)。迁移已把列扩到 MEDIUMTEXT,
* 这里再兜一层:万一跑在没迁移的库上,也宁可少存骨骼明细,不能让上传 500。
*
* 保留策略:面数 / 骨骼数 / humanoid 覆盖 / 可驱动性 / warnings 一律保留,
* 只裁骨骼树(父在前,从头截断即保留祖先链),并追加一条 warning 说明被裁过。
*/
export function shrinkStatsForStorage(
stats: ModelStats,
maxBytes: number = MYSQL_TEXT_LIMIT,
): ModelStats {
const fits = (candidate: ModelStats): boolean => {
try {
return JSON.stringify(candidate).length <= maxBytes;
} catch {
return false;
}
};
if (fits(stats)) return stats;
const note = `骨骼树过大(原 ${stats.boneTree.length} 条),存储前已截断`;
const withNote = (boneTree: BoneNode[]): ModelStats => ({
...stats,
boneTree,
warnings: stats.warnings.includes(note) ? stats.warnings : [...stats.warnings, note],
});
// 二分找能放下的最大前缀长度(骨骼树父在前,前缀即祖先链完整的子集)
let low = Math.min(MIN_BONE_TREE_KEPT, stats.boneTree.length);
let high = stats.boneTree.length;
let best = withNote(stats.boneTree.slice(0, low));
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const candidate = withNote(stats.boneTree.slice(0, mid));
if (fits(candidate)) {
best = candidate;
low = mid + 1;
} else {
high = mid - 1;
}
}
// 极端情况:连最小骨骼树都放不下 -> 只留汇总字段
if (!fits(best)) {
const summary = withNote([]);
if (fits(summary)) return summary;
return {
...summary,
missingRequiredBones: summary.missingRequiredBones.slice(0, 16),
warnings: [note],
};
}
return best;
}