现象(真实故障):导入示例文件/【奥黛塔】_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 容量约束。
737 lines
26 KiB
TypeScript
737 lines
26 KiB
TypeScript
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;
|
||
}
|