diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/controller/MascotController.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/controller/MascotController.java index 509d9be5..40093df8 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/controller/MascotController.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/controller/MascotController.java @@ -3,6 +3,7 @@ import com.guideon.common.exception.CustomException; import com.guideon.common.exception.ErrorCode; import com.guideon.common.response.ApiResponse; +import com.guideon.guideonbackend.domain.mascot.dto.CleanMeshResponse; import com.guideon.guideonbackend.domain.mascot.dto.*; import org.springframework.lang.Nullable; import com.guideon.guideonbackend.domain.mascot.service.MascotAnimConfigService; @@ -82,6 +83,21 @@ public ResponseEntity> updateMascot( return ResponseEntity.ok(ApiResponse.success(response, traceId)); } + @Operation(summary = "Mixamo 업로드용 Clean Mesh 조회", + description = "스켈레톤 제거된 T-포즈 FBX URL을 반환합니다. " + + "이 파일을 Mixamo에 업로드하면 자동 리깅 후 애니메이션을 다운로드할 수 있습니다. " + + "rig 완료 후 자동 생성됩니다. PLATFORM_ADMIN 권한 필요") + @GetMapping("/clean-mesh") + public ResponseEntity> getCleanMesh( + @PathVariable Long siteId, + @AuthenticationPrincipal CustomAdminDetails adminDetails, + HttpServletRequest httpRequest + ) { + CleanMeshResponse response = mascotService.getCleanMesh(siteId, adminDetails); + String traceId = (String) httpRequest.getAttribute(TraceIdUtil.TRACE_ID_ATTR); + return ResponseEntity.ok(ApiResponse.success(response, traceId)); + } + // ── 애니메이션 GLB 업로드 ── @Operation( diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/CleanMeshResponse.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/CleanMeshResponse.java new file mode 100644 index 00000000..61063f8c --- /dev/null +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/CleanMeshResponse.java @@ -0,0 +1,16 @@ +package com.guideon.guideonbackend.domain.mascot.dto; + +import lombok.Builder; +import lombok.Getter; + +/** GET /mascot/clean-mesh 응답 */ +@Getter +@Builder +public class CleanMeshResponse { + + /** 스켈레톤 제거된 T-포즈 FBX URL. Mixamo 업로드 후 애니메이션 다운로드에 사용. null이면 미생성. */ + private String cleanMeshUrl; + + /** "ready" | "not_available" */ + private String status; +} diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/MascotResponse.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/MascotResponse.java index db34e400..4c449ef8 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/MascotResponse.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/MascotResponse.java @@ -24,6 +24,7 @@ public class MascotResponse { private String imageUrl; private String modelUrl; private String modelFormat; + private String cleanMeshUrl; private Long generationId; private Boolean isActive; private LocalDateTime createdAt; @@ -44,6 +45,7 @@ public static MascotResponse from(MascotDto dto) { .imageUrl(dto.getImageUrl()) .modelUrl(dto.getModelUrl()) .modelFormat(dto.getModelFormat()) + .cleanMeshUrl(dto.getCleanMeshUrl()) .generationId(dto.getGenerationId()) .isActive(dto.getIsActive()) .createdAt(dto.getCreatedAt()) diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimationMergeService.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimationMergeService.java index 8e31ae4b..33939ff7 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimationMergeService.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimationMergeService.java @@ -2,6 +2,7 @@ import com.guideon.core.domain.mascot.entity.MascotAnimConfig; import com.guideon.core.domain.mascot.repository.MascotAnimConfigRepository; +import com.guideon.core.domain.mascot.repository.MascotRepository; import com.guideon.guideonbackend.global.storage.FileStorageService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -9,6 +10,7 @@ import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; /** @@ -23,6 +25,7 @@ public class MascotAnimationMergeService { private final MascotAnimConfigRepository animConfigRepository; + private final MascotRepository mascotRepository; private final FileStorageService fileStorageService; private final MeshProcessorClient meshProcessorClient; private final MascotGenerationPersistService persistService; @@ -71,4 +74,34 @@ public String mergeIfRiggedMascotExists(Long siteId, Long generationId, String r return null; } } + + /** + * rig 완료 후 스켈레톤을 제거한 Mixamo-ready FBX를 비동기 생성. + * 실패해도 예외를 전파하지 않는다 — clean mesh는 부가 기능이므로 파이프라인 완료에 영향 없음. + * + * @param siteId 사이트 ID + * @param generationId MascotGeneration PK (파일명에 사용) + * @param riggedModelUrl Tripo rig 결과 GLB 서빙 URL + */ + public void stripRigAsync(Long siteId, Long generationId, String riggedModelUrl) { + CompletableFuture.runAsync(() -> { + try { + String riggedLocalPath = fileStorageService.toLocalPath(riggedModelUrl).toString(); + String fbxFilename = "clean_mesh_" + generationId + ".fbx"; + String fbxUrl = fileStorageService.resolveUrl(siteId, fbxFilename); + String fbxLocalPath = fileStorageService.toLocalPath(fbxUrl).toString(); + + meshProcessorClient.stripRig(riggedLocalPath, fbxLocalPath); + + mascotRepository.findBySite_SiteId(siteId).ifPresent( + mascot -> mascot.updateCleanMeshUrl(fbxUrl) + ); + log.info("clean mesh 생성 완료: generationId={}, url={}", generationId, fbxUrl); + + } catch (Exception e) { + log.warn("strip-rig 실패 — cleanMeshUrl 없이 완료: generationId={}, err={}", + generationId, e.getMessage()); + } + }); + } } 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 4ac6d454..bd6e7796 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 @@ -131,6 +131,8 @@ 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/MascotService.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotService.java index 544703e9..caaee416 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotService.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotService.java @@ -11,6 +11,7 @@ import com.guideon.core.dto.mascot.UpdateMascotCommand; import com.guideon.guideonbackend.client.CoreMascotClient; import com.guideon.guideonbackend.domain.mascot.dto.AnimationUploadResponse; +import com.guideon.guideonbackend.domain.mascot.dto.CleanMeshResponse; import com.guideon.guideonbackend.domain.mascot.dto.CreateMascotRequest; import com.guideon.guideonbackend.domain.mascot.dto.MascotImageUploadResponse; import com.guideon.guideonbackend.domain.mascot.dto.MascotResponse; @@ -199,6 +200,16 @@ public VoiceCloneResponse cloneVoice(Long siteId, MultipartFile audioFile, .build(); } + public CleanMeshResponse getCleanMesh(Long siteId, CustomAdminDetails adminDetails) { + validatePlatformAdmin(adminDetails); + MascotDto dto = coreMascotClient.getMascot(siteId); + String url = dto.getCleanMeshUrl(); + return CleanMeshResponse.builder() + .cleanMeshUrl(url) + .status(url != null ? "ready" : "not_available") + .build(); + } + private void validatePlatformAdmin(CustomAdminDetails adminDetails) { if (!AdminRole.PLATFORM_ADMIN.name().equals(adminDetails.getRole())) { throw new CustomException(ErrorCode.ACCESS_DENIED); diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MeshProcessorClient.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MeshProcessorClient.java index b29001ed..77156e32 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MeshProcessorClient.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MeshProcessorClient.java @@ -46,6 +46,41 @@ public MeshProcessorClient( * KEY = GLB 내부에 임베드될 Animation 클립명 * @param outputPath 출력 GLB 로컬 경로 (예: /app/uploads/1/anim_42.glb) */ + /** + * 스켈레톤 제거 요청. + * + * @param riggedGlbPath Tripo rig 결과 GLB 로컬 경로 + * @param outputFbxPath 출력 FBX 로컬 경로 + */ + @SuppressWarnings("unchecked") + public void stripRig(String riggedGlbPath, String outputFbxPath) { + Map body = Map.of( + "riggedGlb", riggedGlbPath, + "outputFbx", outputFbxPath + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + try { + Map response = restTemplate.postForObject( + baseUrl + "/strip-rig", + new HttpEntity<>(body, headers), + Map.class + ); + log.info("mesh-processor strip-rig 완료: outputFbx={}", outputFbxPath); + if (response != null && Boolean.FALSE.equals(response.get("success"))) { + throw new CustomException(ErrorCode.UPSTREAM_TIMEOUT, + "mesh-processor strip-rig 실패: " + response.get("error")); + } + } catch (CustomException e) { + throw e; + } catch (Exception e) { + throw new CustomException(ErrorCode.UPSTREAM_TIMEOUT, + "mesh-processor strip-rig 호출 실패: " + e.getMessage()); + } + } + @SuppressWarnings("unchecked") public void combine(String baseGlbPath, Map animGlbs, String outputPath) { Map body = Map.of( diff --git a/guideon-core/src/main/java/com/guideon/core/domain/mascot/entity/Mascot.java b/guideon-core/src/main/java/com/guideon/core/domain/mascot/entity/Mascot.java index 1df86990..607f5e24 100644 --- a/guideon-core/src/main/java/com/guideon/core/domain/mascot/entity/Mascot.java +++ b/guideon-core/src/main/java/com/guideon/core/domain/mascot/entity/Mascot.java @@ -67,6 +67,10 @@ public class Mascot extends BaseEntity { @Column(name = "anim_model_url", length = 500) private String animModelUrl; + /** 스켈레톤 제거된 T-포즈 FBX URL. Mixamo 업로드용 clean mesh. */ + @Column(name = "clean_mesh_url", length = 500) + private String cleanMeshUrl; + /** 상태→클립명 매핑. Unity Animation.Play() 호출에 사용. 예: {idle:"Idle", speaking:"Talking", greeting:"Waving"} */ @JdbcTypeCode(SqlTypes.JSON) @Column(name = "anim_clips", nullable = false, columnDefinition = "jsonb") @@ -125,6 +129,11 @@ public void updateAnimation(String animModelUrl, Map animClips) this.animClips = animClips != null ? animClips : new HashMap<>(); } + /** Mixamo 업로드용 clean mesh URL 설정. */ + public void updateCleanMeshUrl(String cleanMeshUrl) { + this.cleanMeshUrl = cleanMeshUrl; + } + /** * rig 재생성 시작 시 이전 애니메이션 메타데이터 초기화. * 새 base GLB와 옛 animModelUrl이 함께 서빙되는 스켈레톤 불일치를 방지. @@ -132,5 +141,6 @@ public void updateAnimation(String animModelUrl, Map animClips) public void clearAnimation() { this.animModelUrl = null; this.animClips = new HashMap<>(); + this.cleanMeshUrl = null; } } diff --git a/guideon-core/src/main/java/com/guideon/core/dto/mascot/MascotDto.java b/guideon-core/src/main/java/com/guideon/core/dto/mascot/MascotDto.java index 014ba72f..55b7fddf 100644 --- a/guideon-core/src/main/java/com/guideon/core/dto/mascot/MascotDto.java +++ b/guideon-core/src/main/java/com/guideon/core/dto/mascot/MascotDto.java @@ -24,6 +24,7 @@ public class MascotDto { private String imageUrl; private String modelUrl; private String modelFormat; + private String cleanMeshUrl; private Long generationId; private Boolean isActive; private LocalDateTime createdAt; @@ -44,6 +45,7 @@ public static MascotDto from(Mascot mascot) { .imageUrl(mascot.getImageUrl()) .modelUrl(mascot.getModelUrl()) .modelFormat(mascot.getModelFormat()) + .cleanMeshUrl(mascot.getCleanMeshUrl()) .generationId(mascot.getGeneration() != null ? mascot.getGeneration().getGenerationId() : null) .isActive(mascot.getIsActive()) .createdAt(mascot.getCreatedAt()) diff --git a/infra/init.sql b/infra/init.sql index e71f6db4..287feea5 100644 --- a/infra/init.sql +++ b/infra/init.sql @@ -255,6 +255,8 @@ ALTER TABLE tb_mascot ADD COLUMN IF NOT EXISTS generation_id BIGINT NULL UNIQUE -- 상태별 애니메이션 (5클립 GLB 단일 URL + 상태→클립명 매핑). Unity 노출 소스 ALTER TABLE tb_mascot ADD COLUMN IF NOT EXISTS anim_model_url VARCHAR(500) NULL; ALTER TABLE tb_mascot ADD COLUMN IF NOT EXISTS anim_clips JSONB NOT NULL DEFAULT '{}'; +-- Mixamo 업로드용 clean mesh (스켈레톤 제거된 T-포즈 FBX) +ALTER TABLE tb_mascot ADD COLUMN IF NOT EXISTS clean_mesh_url VARCHAR(500) NULL; -- tb_document CREATE TABLE IF NOT EXISTS tb_document ( diff --git a/mesh-processor/Dockerfile b/mesh-processor/Dockerfile index 808b09a4..5fbdbda4 100644 --- a/mesh-processor/Dockerfile +++ b/mesh-processor/Dockerfile @@ -2,6 +2,7 @@ FROM node:20-alpine WORKDIR /app COPY package.json . RUN npm install --production +RUN apk add --no-cache assimp COPY src ./src EXPOSE 3001 CMD ["node", "src/index.js"] diff --git a/mesh-processor/src/index.js b/mesh-processor/src/index.js index de552ca8..1ce46da8 100644 --- a/mesh-processor/src/index.js +++ b/mesh-processor/src/index.js @@ -1,5 +1,6 @@ import express from 'express'; -import { combine } from './combiner.js'; +import { combine } from './combiner.js'; +import { stripRig } from './stripRig.js'; const app = express(); const PORT = process.env.PORT || 3001; @@ -41,6 +42,32 @@ app.post('/combine', async (req, res) => { } }); +/** + * POST /strip-rig + * { + * "riggedGlb": "/app/uploads/{siteId}/{hash}.glb", — Tripo rig_model 결과 GLB + * "outputFbx": "/app/uploads/{siteId}/clean_mesh_{generationId}.fbx" + * } + * + * 스켈레톤·스킨 제거 → T-포즈 clean mesh → FBX 변환 (Mixamo 업로드용) + * 응답: { "success": true, "outputFbx": "..." } + */ +app.post('/strip-rig', async (req, res) => { + const { riggedGlb, outputFbx } = req.body; + + if (!riggedGlb || !outputFbx) { + return res.status(400).json({ error: 'riggedGlb, outputFbx 필수' }); + } + + try { + await stripRig(riggedGlb, outputFbx); + res.json({ success: true, outputFbx }); + } catch (e) { + console.error('[strip-rig] 실패:', e.message); + res.status(500).json({ error: e.message }); + } +}); + app.get('/health', (_, res) => res.json({ status: 'ok' })); app.listen(PORT, () => console.log(`mesh-processor :${PORT}`)); diff --git a/mesh-processor/src/stripRig.js b/mesh-processor/src/stripRig.js new file mode 100644 index 00000000..2bcfc0d8 --- /dev/null +++ b/mesh-processor/src/stripRig.js @@ -0,0 +1,70 @@ +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); + +/** + * Tripo rigged GLB에서 스켈레톤을 제거하고 Mixamo-ready FBX를 생성. + * + * 원리: + * - Animation 제거 → 불필요한 클립 제거 + * - Skin 제거 → 메쉬가 뼈대에서 분리되고 버텍스가 bind pose(T-포즈) 위치 유지 + * - 메쉬 없는 orphan 노드(뼈대 노드) 제거 + * - prune()으로 고아 리소스 정리 + * - assimp CLI로 clean GLB → FBX 변환 + * + * @param {string} inputGlbPath Tripo rigged 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); + } +}