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> { const contents = new Map(); 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) { const lowered = new Map(); 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; 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 { 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 { 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, }; }