需求 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 变为该模型
445 lines
17 KiB
TypeScript
445 lines
17 KiB
TypeScript
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 { 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)。
|
||
*
|
||
* ## 策略(与产品约定一致)
|
||
*
|
||
* **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 {
|
||
/** Assimp 可执行文件(默认 `assimp`,可用 ASSIMP_BIN 覆盖) */
|
||
assimpBin: string;
|
||
/** 单次转换超时(毫秒) */
|
||
timeoutMs?: number;
|
||
/** 优先交付的格式:`glb`(默认)或 `vrm` */
|
||
preferFormat?: 'glb' | 'vrm';
|
||
/** 优先选用的 PMX 文件名(大小写不敏感),例如 `Hatsune Miku.pmx` */
|
||
preferredPmx?: string;
|
||
/** 生成 VRM 时写入的元信息 */
|
||
vrmMeta?: VrmWrapMeta;
|
||
}
|
||
|
||
export interface ConvertedModel {
|
||
/** 产物(或直接导入的原文件)内容 */
|
||
buffer: Buffer;
|
||
/** 入库格式 */
|
||
format: ModelFormat;
|
||
/** 归档内的源文件相对路径 */
|
||
sourceModelPath: string;
|
||
/** 人类可读说明,写入 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,
|
||
): ZipEntry | undefined {
|
||
const models = entries.filter((entry) => extname(entry.path).toLowerCase() === '.pmx');
|
||
if (models.length === 0) return undefined;
|
||
const wanted = preferred?.trim().toLowerCase();
|
||
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);
|
||
if (byBase) return byBase;
|
||
}
|
||
return [...models].sort((a, b) => {
|
||
const depthA = a.path.split('/').length;
|
||
const depthB = b.path.split('/').length;
|
||
if (depthA !== depthB) return depthA - depthB;
|
||
return b.size - a.size;
|
||
})[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[],
|
||
targetDir: string,
|
||
): Promise<Map<string, Buffer>> {
|
||
const contents = new Map<string, Buffer>();
|
||
for (const entry of entries) {
|
||
const data = entry.read();
|
||
contents.set(entry.path, data);
|
||
const destination = join(targetDir, entry.path);
|
||
await mkdir(dirname(destination), { recursive: true });
|
||
await writeFile(destination, data);
|
||
}
|
||
return contents;
|
||
}
|
||
|
||
/** 在解压结果里按 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);
|
||
return (uri: string): Buffer | null => {
|
||
if (uri.startsWith('data:')) return null;
|
||
let decoded = uri;
|
||
try {
|
||
decoded = decodeURIComponent(uri);
|
||
} catch {
|
||
/* 保持原样 */
|
||
}
|
||
const normalized = decoded.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
|
||
const direct = lowered.get(normalized);
|
||
if (direct) return direct;
|
||
const base = normalized.split('/').pop();
|
||
if (!base) return null;
|
||
for (const [path, data] of lowered) {
|
||
if (path.split('/').pop() === base) return data;
|
||
}
|
||
return null;
|
||
};
|
||
}
|
||
|
||
/** 用 Assimp 导出并回读校验(回读失败视为转换失败) */
|
||
async function exportAndValidate(
|
||
assimpBin: string,
|
||
modelPath: string,
|
||
outPath: string,
|
||
timeoutMs: number,
|
||
): 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)}`,
|
||
size: 0,
|
||
};
|
||
}
|
||
|
||
let size = 0;
|
||
try {
|
||
size = (await readFile(outPath)).length;
|
||
} catch {
|
||
return { ok: false, detail: '导出命令返回成功但没有产出文件', size: 0 };
|
||
}
|
||
if (size === 0) return { ok: false, detail: '导出产物为空文件', size: 0 };
|
||
|
||
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)}`, size };
|
||
}
|
||
return { ok: true, detail: `产物 ${size} 字节,回读校验通过`, size };
|
||
}
|
||
|
||
/** 在 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 entries = readZipEntries(archive);
|
||
if (entries.length === 0) throw new Error('压缩包是空的');
|
||
|
||
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-'));
|
||
try {
|
||
const contents = await unpack(entries, workDir);
|
||
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,
|
||
};
|
||
}
|