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 = { 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; 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(); 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(); 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(); 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(); 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(); 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(); 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 { 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; }