From 7333c3f56e79c7ea70097272c49fb97bb356d63a Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Fri, 5 Jun 2026 04:29:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20clean=20mesh=20FBX=EB=A5=BC=20pre-ri?= =?UTF-8?q?g=20=EB=AA=A8=EB=8D=B8=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MascotGenerationService.java | 14 +++- mesh-processor/src/stripRig.js | 75 +++---------------- 2 files changed, 24 insertions(+), 65 deletions(-) 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..68d8e04 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,18 @@ 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()); + 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 모델 다운로드/FBX 변환 실패 (무시): generationId={}, err={}", generationId, e.getMessage()); + } + } } return GenerationStatusResponse.from(gen); @@ -131,8 +143,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/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}`); } From 54bbf32d2a89678f4123ef486d399e637d821271 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Fri, 5 Jun 2026 04:39:05 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20Tripo=20FBX=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4=20=EC=B2=98=EB=A6=AC=20(GLB=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20+=20out=5Fformat=20=EB=AA=85=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mascot/service/MascotGenerationService.java | 12 ++++++++++-- .../domain/mascot/service/TripoApiService.java | 1 + .../global/storage/FileValidator.java | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) 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 68d8e04..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 @@ -111,11 +111,12 @@ public GenerationStatusResponse pollStatus(Long siteId, Long generationId, 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 모델 다운로드/FBX 변환 실패 (무시): generationId={}, err={}", generationId, e.getMessage()); + log.warn("pre-rig 모델 GLB 검증/FBX 변환 실패 (무시): generationId={}, err={}", generationId, e.getMessage()); } } } @@ -134,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); 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")