diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotGenerationService.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotGenerationService.java index bd6e779..f85292f 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotGenerationService.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotGenerationService.java @@ -106,6 +106,19 @@ public GenerationStatusResponse pollStatus(Long siteId, Long generationId, log.info("마스코트 model 완료, rigging 시작: generationId={}", generationId); String rigTaskId = tripoApiService.createAnimateRigTask(gen.getModelTaskId()); gen = persistService.applyModelComplete(generationId, rigTaskId); + + // pre-rig 모델 = 뼈대 없는 clean mesh → assimp로 FBX 변환 (Mixamo 업로드용) + if (status.modelUrl() != null) { + try { + byte[] preRigBytes = tripoApiService.downloadModel(status.modelUrl()); + FileValidator.validateGlb(preRigBytes); // FBX 반환 시 skip + String preRigHash = FileValidator.computeFileHash(preRigBytes); + String preRigUrl = fileStorageService.store(siteId, preRigHash, preRigBytes, "pre_rig_mascot.glb"); + animationMergeService.stripRigAsync(siteId, generationId, preRigUrl); + } catch (Exception e) { + log.warn("pre-rig 모델 GLB 검증/FBX 변환 실패 (무시): generationId={}, err={}", generationId, e.getMessage()); + } + } } return GenerationStatusResponse.from(gen); @@ -122,8 +135,15 @@ public GenerationStatusResponse pollStatus(Long siteId, Long generationId, } if (status.isSuccess() && status.modelUrl() != null) { - // 리깅 GLB 다운로드 → 저장 + // 리깅 GLB 다운로드 → GLB 포맷 검증 → 저장 byte[] glbBytes = tripoApiService.downloadModel(status.modelUrl()); + try { + FileValidator.validateGlb(glbBytes); + } catch (com.guideon.common.exception.CustomException e) { + log.error("Tripo rig 결과가 GLB 아님 (FBX 반환 의심) — rig 실패 처리: generationId={}", generationId); + gen = persistService.applyRigFailed(generationId, "Tripo rig 결과 GLB 검증 실패 (FBX 반환 의심)"); + return GenerationStatusResponse.from(gen); + } String glbHash = FileValidator.computeFileHash(glbBytes); String modelUrl = fileStorageService.store(siteId, glbHash, glbBytes, "mascot.glb"); gen = persistService.applyRigComplete(siteId, generationId, modelUrl); @@ -131,8 +151,6 @@ public GenerationStatusResponse pollStatus(Long siteId, Long generationId, // anim_config가 설정되어 있으면 mesh-processor로 자동 병합 animationMergeService.mergeIfRiggedMascotExists(siteId, generationId, modelUrl); - // Mixamo 업로드용 clean mesh(FBX) 비동기 생성 - animationMergeService.stripRigAsync(siteId, generationId, modelUrl); } return GenerationStatusResponse.from(gen); diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/TripoApiService.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/TripoApiService.java index 5e43e37..f0604e9 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/TripoApiService.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/TripoApiService.java @@ -103,6 +103,7 @@ public String createImageToModelTask(String imageToken) { requestBody.put("pbr", true); requestBody.put("quad", true); requestBody.put("texture_quality", "detailed"); + requestBody.put("out_format", "glb"); Map response = postJson(url, requestBody); Map data = extractData(response); diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/global/storage/FileValidator.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/global/storage/FileValidator.java index 6f8739b..c56adfd 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/global/storage/FileValidator.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/global/storage/FileValidator.java @@ -12,6 +12,7 @@ import java.io.InputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Arrays; import java.util.HexFormat; import java.util.Set; @@ -162,6 +163,21 @@ public static void validateImage(MultipartFile file) { } } + /** + * GLB(glTF Binary) 바이트 배열 검증: 매직바이트만 확인. + * Tripo API 다운로드 결과처럼 MultipartFile이 아닌 byte[] 경우에 사용. + * FBX(Kaydara) 등 잘못된 포맷이 반환됐을 때 빠르게 실패시킨다. + */ + public static void validateGlb(byte[] fileBytes) { + if (fileBytes == null || fileBytes.length < 4) { + throw new CustomException(ErrorCode.VALIDATION_ERROR, "GLB 파일이 비어있거나 너무 작습니다."); + } + if (!hasGlbSignature(Arrays.copyOf(fileBytes, 4))) { + throw new CustomException(ErrorCode.VALIDATION_ERROR, + "유효하지 않은 GLB 파일입니다. Tripo가 FBX를 반환했을 수 있습니다."); + } + } + /** * GLB(glTF Binary) 파일 검증: 매직바이트 + 크기 상한(50MB). * GLB 매직: 0x67 0x6C 0x54 0x46 ("glTF") diff --git a/mesh-processor/src/stripRig.js b/mesh-processor/src/stripRig.js index 2bcfc0d..6336139 100644 --- a/mesh-processor/src/stripRig.js +++ b/mesh-processor/src/stripRig.js @@ -1,70 +1,19 @@ -import { NodeIO } from '@gltf-transform/core'; -import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; -import { prune } from '@gltf-transform/functions'; -import { execSync } from 'child_process'; -import { writeFileSync, unlinkSync, existsSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; - -const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); +import { execSync } from 'child_process'; /** - * Tripo rigged GLB에서 스켈레톤을 제거하고 Mixamo-ready FBX를 생성. + * GLB → FBX 변환 (Mixamo 업로드용). + * + * 입력: Tripo image_to_model 결과 GLB (뼈대 없는 clean mesh) + * 출력: FBX (Mixamo 자동 리깅 전용) * - * 원리: - * - Animation 제거 → 불필요한 클립 제거 - * - Skin 제거 → 메쉬가 뼈대에서 분리되고 버텍스가 bind pose(T-포즈) 위치 유지 - * - 메쉬 없는 orphan 노드(뼈대 노드) 제거 - * - prune()으로 고아 리소스 정리 - * - assimp CLI로 clean GLB → FBX 변환 + * image_to_model 단계의 GLB는 이미 뼈대가 없으므로 + * gltf-transform 처리 없이 assimp 변환만 수행한다. * - * @param {string} inputGlbPath Tripo rigged GLB 경로 - * @param {string} outputFbxPath 출력 FBX 경로 + * @param {string} inputGlbPath Tripo pre-rig 모델 GLB 경로 + * @param {string} outputFbxPath 출력 FBX 경로 */ export async function stripRig(inputGlbPath, outputFbxPath) { - console.log(`[stripRig] input: ${inputGlbPath}`); - - const doc = await io.read(inputGlbPath); - const root = doc.getRoot(); - - // 1. 모든 애니메이션 제거 - const animCount = root.listAnimations().length; - root.listAnimations().forEach(anim => anim.dispose()); - console.log(`[stripRig] 애니메이션 ${animCount}개 제거`); - - // 2. 모든 Skin 제거 (메쉬→뼈대 바인딩 해제, 버텍스는 bind pose 유지) - const skinCount = root.listSkins().length; - root.listSkins().forEach(skin => skin.dispose()); - console.log(`[stripRig] Skin ${skinCount}개 제거`); - - // 3. 메쉬가 없는 노드(뼈대 노드) 제거 - // 씬 루트에 직접 연결된 노드는 유지, 내부 orphan 뼈대 노드만 제거 - const sceneRootNodes = new Set( - root.listScenes().flatMap(s => s.listChildren()) - ); - let removedBones = 0; - for (const node of root.listNodes()) { - if (sceneRootNodes.has(node)) continue; - if (node.getMesh() === null && node.getCamera() === null && node.getLight() === null) { - node.dispose(); - removedBones++; - } - } - console.log(`[stripRig] 뼈대 노드 ${removedBones}개 제거`); - - // 4. 고아 accessor/bufferView 등 정리 - await doc.transform(prune()); - - // 5. 임시 GLB 파일로 저장 - const tmpGlb = join(tmpdir(), `clean_${Date.now()}.glb`); - await io.write(tmpGlb, doc); - console.log(`[stripRig] 임시 GLB 저장: ${tmpGlb}`); - - // 6. assimp CLI로 GLB → FBX 변환 - try { - execSync(`assimp export "${tmpGlb}" "${outputFbxPath}"`, { timeout: 30000 }); - console.log(`[stripRig] FBX 변환 완료: ${outputFbxPath}`); - } finally { - if (existsSync(tmpGlb)) unlinkSync(tmpGlb); - } + console.log(`[stripRig] ${inputGlbPath} → ${outputFbxPath}`); + execSync(`assimp export "${inputGlbPath}" "${outputFbxPath}"`, { timeout: 30000 }); + console.log(`[stripRig] FBX 변환 완료: ${outputFbxPath}`); }