From cd99798da23d2d12f3b177483504a3ee13f18440 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Fri, 5 Jun 2026 03:34:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20mesh-processor=20/strip-rig=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(FBX=20=EB=B3=80=ED=99=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mesh-processor/Dockerfile | 1 + mesh-processor/src/index.js | 29 +++++++++++++- mesh-processor/src/stripRig.js | 70 ++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 mesh-processor/src/stripRig.js diff --git a/mesh-processor/Dockerfile b/mesh-processor/Dockerfile index 808b09a..5fbdbda 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 de552ca..1ce46da 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 0000000..2bcfc0d --- /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); + } +} From 2a13cfd03522997bbe27fc926e359c17fd8751da Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Fri, 5 Jun 2026 03:38:45 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EB=A7=88=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=ED=8A=B8=20clean=20mesh=20=EC=9E=90=EB=8F=99=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20API=20=EC=B6=94=EA=B0=80=20(Mixamo=20FBX)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mascot/controller/MascotController.java | 16 +++++++++ .../domain/mascot/dto/CleanMeshResponse.java | 16 +++++++++ .../domain/mascot/dto/MascotResponse.java | 2 ++ .../service/MascotAnimationMergeService.java | 33 +++++++++++++++++ .../service/MascotGenerationService.java | 2 ++ .../domain/mascot/service/MascotService.java | 11 ++++++ .../mascot/service/MeshProcessorClient.java | 35 +++++++++++++++++++ .../core/domain/mascot/entity/Mascot.java | 10 ++++++ .../guideon/core/dto/mascot/MascotDto.java | 2 ++ infra/init.sql | 2 ++ 10 files changed, 129 insertions(+) create mode 100644 guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/CleanMeshResponse.java 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 509d9be..40093df 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 0000000..61063f8 --- /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 db34e40..4c449ef 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 8e31ae4..33939ff 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 4ac6d45..bd6e779 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 544703e..caaee41 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 b29001e..77156e3 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 1df8699..607f5e2 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 014ba72..55b7fdd 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 e71f6db..287feea 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 (