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 40093df..f847e60 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 @@ -4,6 +4,7 @@ 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.ModelUploadResponse; import com.guideon.guideonbackend.domain.mascot.dto.*; import org.springframework.lang.Nullable; import com.guideon.guideonbackend.domain.mascot.service.MascotAnimConfigService; @@ -201,6 +202,25 @@ public ResponseEntity> updateAnimConfig( return ResponseEntity.ok(ApiResponse.success(response, traceId)); } + // ── 수동 GLB 업로드 ── + + @Operation( + summary = "마스코트 GLB 수동 업로드 (모델 교체)", + description = "외부에서 준비한 GLB를 업로드해 마스코트 model_url을 교체합니다. " + + "anim_config가 설정되어 있으면 anim 병합도 자동 실행됩니다. " + + "Tripo 생성 파이프라인 없이 직접 모델을 교체할 때 사용. PLATFORM_ADMIN 권한 필요") + @PostMapping(value = "/model", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> uploadMascotModel( + @PathVariable Long siteId, + @RequestPart("file") MultipartFile file, + @AuthenticationPrincipal CustomAdminDetails adminDetails, + HttpServletRequest httpRequest + ) { + ModelUploadResponse response = generationService.uploadMascotModel(siteId, file, adminDetails); + String traceId = (String) httpRequest.getAttribute(TraceIdUtil.TRACE_ID_ATTR); + return ResponseEntity.ok(ApiResponse.success(response, traceId)); + } + // ── 3D 모델 생성 (Tripo AI) ── @Operation(summary = "3D 모델 생성 시작", diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/ModelUploadResponse.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/ModelUploadResponse.java new file mode 100644 index 0000000..d63d3ff --- /dev/null +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/ModelUploadResponse.java @@ -0,0 +1,15 @@ +package com.guideon.guideonbackend.domain.mascot.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ModelUploadResponse { + + /** 저장된 GLB URL (tb_mascot.model_url) */ + private String modelUrl; + + /** anim_config가 설정된 경우 자동 병합된 anim GLB URL, 미설정이면 null */ + private String animModelUrl; +} 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 33939ff..cdf31db 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 @@ -75,6 +75,51 @@ public String mergeIfRiggedMascotExists(Long siteId, Long generationId, String r } } + /** + * 수동 GLB 업로드 시 anim 병합. + * generationId가 없으므로 타임스탬프 기반 토큰으로 출력 파일명 생성. + * + * @param siteId 사이트 ID + * @param riggedModelUrl 수동 업로드된 리깅 완료 GLB 서빙 URL + * @return 병합 성공 시 animModelUrl, anim_config 미설정 또는 병합 실패 시 null + */ + public String mergeForManualUpload(Long siteId, String riggedModelUrl) { + List animConfigs = animConfigRepository.findBySiteId(siteId); + if (animConfigs.isEmpty()) { + log.info("anim_config 미설정 — anim 병합 skip (수동 업로드): siteId={}", siteId); + return null; + } + + try { + Map animGlbs = animConfigs.stream().collect(Collectors.toMap( + MascotAnimConfig::getClipName, + c -> fileStorageService.toLocalPath(c.getGlbUrl()).toString(), + (a, b) -> a + )); + + String token = "manual_" + System.currentTimeMillis(); + String riggedLocalPath = fileStorageService.toLocalPath(riggedModelUrl).toString(); + String animFilename = "anim_" + token + ".glb"; + String animModelUrl = fileStorageService.resolveUrl(siteId, animFilename); + String animLocalPath = fileStorageService.toLocalPath(animModelUrl).toString(); + + meshProcessorClient.combine(riggedLocalPath, animGlbs, animLocalPath); + + Map animClips = animConfigs.stream().collect(Collectors.toMap( + MascotAnimConfig::getStateKey, + MascotAnimConfig::getClipName + )); + persistService.applyAnimationComplete(siteId, null, animModelUrl, animClips); + log.info("anim GLB 병합 완료 (수동 업로드): token={}, siteId={}", token, siteId); + return animModelUrl; + + } catch (Exception e) { + log.warn("mesh-processor 실패 — animModelUrl 없이 완료 (수동): siteId={}, err={}", + siteId, e.getMessage()); + return null; + } + } + /** * rig 완료 후 스켈레톤을 제거한 Mixamo-ready FBX를 비동기 생성. * 실패해도 예외를 전파하지 않는다 — clean mesh는 부가 기능이므로 파이프라인 완료에 영향 없음. diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotGenerationPersistService.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotGenerationPersistService.java index 0330c25..20ba638 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotGenerationPersistService.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotGenerationPersistService.java @@ -94,6 +94,33 @@ public MascotGeneration applyRigFailed(Long generationId, String reason) { * mesh-processor 병합 완료: tb_mascot.animModelUrl + animClips 반영. * rig 완료 후 별도 트랜잭션으로 실행 (외부 호출 결과). */ + /** + * 수동 GLB 업로드: tb_mascot의 model_url 교체 + 애니메이션 메타데이터 초기화. + * generation이 없는 수동 업로드이므로 generation 연결은 null로 설정. + */ + @Transactional + public void applyManualModelUpload(Long siteId, String modelUrl) { + mascotRepository.findBySite_SiteId(siteId).ifPresentOrElse( + mascot -> { + mascot.clearAnimation(); + mascot.updateModelUrl(modelUrl, "glb", null); + log.info("tb_mascot 수동 model_url 업데이트: siteId={}, modelUrl={}", siteId, modelUrl); + }, + () -> log.warn("tb_mascot 미존재 — 수동 model_url 업데이트 생략: siteId={}", siteId) + ); + } + + /** + * clean mesh FBX URL을 tb_mascot에 직접 저장. + * Tripo pre-rig 결과가 FBX인 경우(GLB→FBX 변환 불필요) 사용. + */ + @Transactional + public void saveCleanMeshUrl(Long siteId, String cleanMeshUrl) { + mascotRepository.findBySite_SiteId(siteId).ifPresent( + mascot -> mascot.updateCleanMeshUrl(cleanMeshUrl) + ); + } + @Transactional public void applyAnimationComplete(Long siteId, Long generationId, String animModelUrl, Map animClips) { 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 f85292f..46bcb2d 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 @@ -9,6 +9,7 @@ import com.guideon.core.domain.site.entity.Site; import com.guideon.core.domain.site.repository.SiteRepository; import com.guideon.guideonbackend.domain.mascot.dto.GenerationStatusResponse; +import com.guideon.guideonbackend.domain.mascot.dto.ModelUploadResponse; import com.guideon.guideonbackend.domain.mascot.dto.StartGenerationResponse; import com.guideon.guideonbackend.global.security.CustomAdminDetails; import com.guideon.guideonbackend.global.storage.FileStorageService; @@ -17,6 +18,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; /** @@ -39,6 +41,7 @@ public class MascotGenerationService { private final SiteRepository siteRepository; private final FileStorageService fileStorageService; private final MascotAnimationMergeService animationMergeService; + private final MeshProcessorClient meshProcessorClient; /** * Step 1: 선업로드된 이미지 URL을 받아 Tripo 3D 생성 task 시작 @@ -107,16 +110,26 @@ public GenerationStatusResponse pollStatus(Long siteId, Long generationId, String rigTaskId = tripoApiService.createAnimateRigTask(gen.getModelTaskId()); gen = persistService.applyModelComplete(generationId, rigTaskId); - // pre-rig 모델 = 뼈대 없는 clean mesh → assimp로 FBX 변환 (Mixamo 업로드용) + // pre-rig 모델 = 뼈대 없는 clean mesh → Mixamo 업로드용 FBX 확보 + // Tripo 반환 포맷(GLB/FBX) 무관하게 항상 clean_mesh_{id}.fbx가 생성되도록 처리. 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); + if (FileValidator.isGlb(preRigBytes)) { + // GLB 반환: 저장 후 assimp로 FBX 변환 (stripRigAsync) + String preRigHash = FileValidator.computeFileHash(preRigBytes); + String preRigUrl = fileStorageService.store(siteId, preRigHash, preRigBytes, "pre_rig_mascot.glb"); + animationMergeService.stripRigAsync(siteId, generationId, preRigUrl); + } else { + // FBX 직접 반환: 변환 없이 clean_mesh FBX로 저장 + log.info("pre-rig 결과 FBX 반환 — clean_mesh FBX 직접 저장: generationId={}", generationId); + String fbxFilename = "clean_mesh_" + generationId + ".fbx"; + String fbxHash = FileValidator.computeFileHash(preRigBytes); + String fbxUrl = fileStorageService.store(siteId, fbxHash, preRigBytes, fbxFilename); + persistService.saveCleanMeshUrl(siteId, fbxUrl); + } } catch (Exception e) { - log.warn("pre-rig 모델 GLB 검증/FBX 변환 실패 (무시): generationId={}, err={}", generationId, e.getMessage()); + log.warn("pre-rig 모델 처리 실패 (무시): generationId={}, err={}", generationId, e.getMessage()); } } } @@ -135,19 +148,38 @@ public GenerationStatusResponse pollStatus(Long siteId, Long generationId, } if (status.isSuccess() && status.modelUrl() != null) { - // 리깅 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); + // 리깅 결과 다운로드 → GLB/FBX 포맷 판별 + byte[] rigBytes = tripoApiService.downloadModel(status.modelUrl()); + String modelUrl; + + if (FileValidator.isGlb(rigBytes)) { + // 정상 케이스: GLB 그대로 저장 + String glbHash = FileValidator.computeFileHash(rigBytes); + modelUrl = fileStorageService.store(siteId, glbHash, rigBytes, "mascot.glb"); + log.info("리깅 GLB 저장 완료: generationId={}", generationId); + } else { + // Tripo FBX 반환 케이스: mesh-processor /convert 로 GLB 변환 + log.warn("Tripo rig 결과 FBX 반환 — /convert로 GLB 변환 시도: generationId={}", generationId); + try { + String fbxHash = FileValidator.computeFileHash(rigBytes); + String fbxUrl = fileStorageService.store(siteId, fbxHash, rigBytes, + "rig_raw_" + generationId + ".fbx"); + String fbxLocalPath = fileStorageService.toLocalPath(fbxUrl).toString(); + String glbFilename = "mascot.glb"; + String glbUrl = fileStorageService.resolveUrl(siteId, glbFilename); + String glbLocalPath = fileStorageService.toLocalPath(glbUrl).toString(); + meshProcessorClient.convert(fbxLocalPath, glbLocalPath); + modelUrl = glbUrl; + log.info("FBX→GLB 변환 완료: generationId={}", generationId); + } catch (Exception e) { + log.error("FBX→GLB 변환 실패 — rig 실패 처리: generationId={}, err={}", generationId, e.getMessage()); + gen = persistService.applyRigFailed(generationId, + "FBX→GLB 변환 실패: " + e.getMessage()); + return GenerationStatusResponse.from(gen); + } } - String glbHash = FileValidator.computeFileHash(glbBytes); - String modelUrl = fileStorageService.store(siteId, glbHash, glbBytes, "mascot.glb"); + gen = persistService.applyRigComplete(siteId, generationId, modelUrl); - log.info("리깅 GLB 저장 완료: generationId={}", generationId); // anim_config가 설정되어 있으면 mesh-processor로 자동 병합 animationMergeService.mergeIfRiggedMascotExists(siteId, generationId, modelUrl); @@ -159,6 +191,35 @@ public GenerationStatusResponse pollStatus(Long siteId, Long generationId, return GenerationStatusResponse.from(gen); } + /** + * 수동 GLB 업로드: 기존 model_url 교체 + anim_config 기준 anim 자동 병합. + * Tripo 생성 파이프라인을 거치지 않고 외부에서 만든 GLB를 직접 마스코트에 연결할 때 사용. + */ + public ModelUploadResponse uploadMascotModel(Long siteId, MultipartFile file, + CustomAdminDetails adminDetails) { + validatePlatformAdmin(adminDetails); + + FileValidator.validateGlb(file); + String fileHash = FileValidator.computeFileHash(file); + byte[] fileBytes; + try { + fileBytes = file.getBytes(); + } catch (java.io.IOException e) { + throw new CustomException(ErrorCode.VALIDATION_ERROR, "파일을 읽을 수 없습니다."); + } + String modelUrl = fileStorageService.store(siteId, fileHash, fileBytes, "mascot.glb"); + + persistService.applyManualModelUpload(siteId, modelUrl); + log.info("수동 마스코트 GLB 업로드 완료: siteId={}, modelUrl={}", siteId, modelUrl); + + String animModelUrl = animationMergeService.mergeForManualUpload(siteId, modelUrl); + + return ModelUploadResponse.builder() + .modelUrl(modelUrl) + .animModelUrl(animModelUrl) + .build(); + } + /** * 생성 이력 조회 (최신) */ 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 77156e3..4fa3bba 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 @@ -52,6 +52,42 @@ public MeshProcessorClient( * @param riggedGlbPath Tripo rig 결과 GLB 로컬 경로 * @param outputFbxPath 출력 FBX 로컬 경로 */ + /** + * 포맷 변환 요청 (assimp 확장자 추론). + * 주 용도: Tripo rig 결과 FBX → GLB 변환. + * + * @param inputPath 입력 파일 로컬 경로 (예: /app/uploads/1/rig_raw_42.fbx) + * @param outputPath 출력 파일 로컬 경로 (예: /app/uploads/1/mascot.glb) + */ + @SuppressWarnings("unchecked") + public void convert(String inputPath, String outputPath) { + Map body = Map.of( + "input", inputPath, + "output", outputPath + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + try { + Map response = restTemplate.postForObject( + baseUrl + "/convert", + new HttpEntity<>(body, headers), + Map.class + ); + log.info("mesh-processor convert 완료: output={}", outputPath); + if (response != null && Boolean.FALSE.equals(response.get("success"))) { + throw new CustomException(ErrorCode.UPSTREAM_TIMEOUT, + "mesh-processor convert 실패: " + response.get("error")); + } + } catch (CustomException e) { + throw e; + } catch (Exception e) { + throw new CustomException(ErrorCode.UPSTREAM_TIMEOUT, + "mesh-processor convert 호출 실패: " + e.getMessage()); + } + } + @SuppressWarnings("unchecked") public void stripRig(String riggedGlbPath, String outputFbxPath) { Map body = Map.of( 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 c56adfd..4d60ea0 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 @@ -163,6 +163,16 @@ public static void validateImage(MultipartFile file) { } } + /** + * GLB 여부를 boolean으로 반환 (예외 없음). + * Tripo 반환 포맷 분기 처리에 사용. + */ + public static boolean isGlb(byte[] fileBytes) { + return fileBytes != null + && fileBytes.length >= 4 + && hasGlbSignature(Arrays.copyOf(fileBytes, 4)); + } + /** * GLB(glTF Binary) 바이트 배열 검증: 매직바이트만 확인. * Tripo API 다운로드 결과처럼 MultipartFile이 아닌 byte[] 경우에 사용. diff --git a/mesh-processor/src/convert.js b/mesh-processor/src/convert.js new file mode 100644 index 0000000..8bf1e36 --- /dev/null +++ b/mesh-processor/src/convert.js @@ -0,0 +1,16 @@ +import { execSync } from 'child_process'; + +/** + * assimp export를 사용한 포맷 변환. + * assimp는 확장자로 포맷을 자동 추론하므로 FBX→GLB, GLB→FBX 모두 동일하게 호출. + * + * 주 용도: Tripo animate_rig가 FBX를 반환한 경우 GLB로 변환. + * + * @param {string} inputPath 입력 파일 경로 (예: /app/uploads/1/rig_raw_42.fbx) + * @param {string} outputPath 출력 파일 경로 (예: /app/uploads/1/mascot.glb) + */ +export async function convert(inputPath, outputPath) { + console.log(`[convert] ${inputPath} → ${outputPath}`); + execSync(`assimp export "${inputPath}" "${outputPath}"`, { timeout: 60000 }); + console.log(`[convert] 변환 완료: ${outputPath}`); +} diff --git a/mesh-processor/src/index.js b/mesh-processor/src/index.js index 1ce46da..99c611f 100644 --- a/mesh-processor/src/index.js +++ b/mesh-processor/src/index.js @@ -1,6 +1,7 @@ import express from 'express'; import { combine } from './combiner.js'; import { stripRig } from './stripRig.js'; +import { convert } from './convert.js'; const app = express(); const PORT = process.env.PORT || 3001; @@ -68,6 +69,32 @@ app.post('/strip-rig', async (req, res) => { } }); +/** + * POST /convert + * { + * "input": "/app/uploads/{siteId}/rig_raw_{generationId}.fbx", — Tripo rig_model FBX + * "output": "/app/uploads/{siteId}/mascot.glb" + * } + * + * assimp 확장자 자동 추론으로 FBX→GLB 변환. + * 응답: { "success": true, "output": "..." } + */ +app.post('/convert', async (req, res) => { + const { input, output } = req.body; + + if (!input || !output) { + return res.status(400).json({ error: 'input, output 필수' }); + } + + try { + await convert(input, output); + res.json({ success: true, output }); + } catch (e) { + console.error('[convert] 실패:', 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}`));