diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/AnimationGlbsUploadResponse.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/AnimationGlbsUploadResponse.java index 4c7242f..2a07c0c 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/AnimationGlbsUploadResponse.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/AnimationGlbsUploadResponse.java @@ -12,6 +12,9 @@ public class AnimationGlbsUploadResponse { private List uploaded; + /** 병합 완료 시 animModelUrl, 마스코트 미생성 또는 병합 실패 시 null */ + private String animModelUrl; + @Getter @Builder public static class UploadedClip { diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimConfigService.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimConfigService.java index 5a1a0b4..2938fef 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimConfigService.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimConfigService.java @@ -4,7 +4,9 @@ import com.guideon.common.exception.ErrorCode; import com.guideon.core.domain.admin.entity.AdminRole; import com.guideon.core.domain.mascot.entity.MascotAnimConfig; +import com.guideon.core.domain.mascot.entity.MascotGeneration; import com.guideon.core.domain.mascot.repository.MascotAnimConfigRepository; +import com.guideon.core.domain.mascot.repository.MascotGenerationRepository; import com.guideon.guideonbackend.domain.mascot.dto.AnimConfigRequest; import com.guideon.guideonbackend.domain.mascot.dto.AnimConfigResponse; import com.guideon.guideonbackend.domain.mascot.dto.AnimationGlbsUploadResponse; @@ -41,7 +43,9 @@ public class MascotAnimConfigService { ); private final MascotAnimConfigRepository animConfigRepository; + private final MascotGenerationRepository generationRepository; private final FileStorageService fileStorageService; + private final MascotAnimationMergeService animationMergeService; /** * state별 GLB 파일 업로드. @@ -106,7 +110,13 @@ public AnimationGlbsUploadResponse uploadAnimationGlbs( .build()); } - return AnimationGlbsUploadResponse.builder().uploaded(uploaded).build(); + // rig가 완료된 마스코트가 있으면 즉시 병합 — animModelUrl을 응답에 반영 + String animModelUrl = triggerMergeIfAvailable(siteId); + + return AnimationGlbsUploadResponse.builder() + .uploaded(uploaded) + .animModelUrl(animModelUrl) + .build(); } /** @@ -158,6 +168,23 @@ public AnimConfigResponse updateAnimConfig(Long siteId, AnimConfigRequest reques return getAnimConfig(siteId, adminDetails); } + /** + * 해당 site의 최신 rig 완료 이력이 있으면 병합을 동기 실행하고 animModelUrl 반환. + * 없으면 null 반환 (이후 rig 완료 시점에 자동 병합됨). + * + * findTop 대신 전체 목록 스트림으로 첫 번째 완료건을 선택 — + * 최신 생성이 FAILED/진행중이어도 이전 완료 이력을 정확히 찾는다. + */ + private String triggerMergeIfAvailable(Long siteId) { + return generationRepository.findBySite_SiteIdOrderByCreatedAtDesc(siteId).stream() + .filter(MascotGeneration::isFullyCompleted) + .filter(gen -> gen.getResultModelUrl() != null) + .findFirst() + .map(gen -> animationMergeService.mergeIfRiggedMascotExists( + siteId, gen.getGenerationId(), gen.getResultModelUrl())) + .orElse(null); + } + 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/MascotAnimationMergeService.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimationMergeService.java new file mode 100644 index 0000000..8e31ae4 --- /dev/null +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimationMergeService.java @@ -0,0 +1,74 @@ +package com.guideon.guideonbackend.domain.mascot.service; + +import com.guideon.core.domain.mascot.entity.MascotAnimConfig; +import com.guideon.core.domain.mascot.repository.MascotAnimConfigRepository; +import com.guideon.guideonbackend.global.storage.FileStorageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * rig 완료된 마스코트 GLB + 상태별 Mixamo 애니메이션 GLB → 통합 anim GLB 병합. + * + * rig 완료 시점(MascotGenerationService)과 + * 애니메이션 GLB 업로드 시점(MascotAnimConfigService) 양쪽에서 공통으로 호출된다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MascotAnimationMergeService { + + private final MascotAnimConfigRepository animConfigRepository; + private final FileStorageService fileStorageService; + private final MeshProcessorClient meshProcessorClient; + private final MascotGenerationPersistService persistService; + + /** + * anim_config가 설정돼 있고 rig GLB가 존재하면 mesh-processor에 병합을 요청한다. + * + * @param siteId 사이트 ID + * @param generationId MascotGeneration PK (출력 파일명 + DB 업데이트에 사용) + * @param riggedModelUrl Tripo rig 결과 GLB 서빙 URL + * @return 병합 성공 시 animModelUrl, anim_config 미설정 또는 병합 실패 시 null + */ + public String mergeIfRiggedMascotExists(Long siteId, Long generationId, String riggedModelUrl) { + List animConfigs = animConfigRepository.findBySiteId(siteId); + if (animConfigs.isEmpty()) { + log.info("anim_config 미설정 — anim 병합 skip: siteId={}", siteId); + return null; + } + + try { + // clipName이 중복일 때 IllegalStateException 방지 — 첫 번째 값 유지 (a, b) -> a + Map animGlbs = animConfigs.stream().collect(Collectors.toMap( + MascotAnimConfig::getClipName, + c -> fileStorageService.toLocalPath(c.getGlbUrl()).toString(), + (a, b) -> a + )); + + String riggedLocalPath = fileStorageService.toLocalPath(riggedModelUrl).toString(); + String animFilename = "anim_" + generationId + ".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, generationId, animModelUrl, animClips); + log.info("anim GLB 병합 완료: generationId={}, animModelUrl={}", generationId, animModelUrl); + return animModelUrl; + + } catch (Exception e) { + log.warn("mesh-processor 실패 — animModelUrl 없이 완료: generationId={}, err={}", + generationId, e.getMessage()); + return null; + } + } +} 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 4b07cc7..4ac6d45 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 @@ -4,9 +4,7 @@ import com.guideon.common.exception.ErrorCode; import com.guideon.core.domain.admin.entity.AdminRole; import com.guideon.core.domain.mascot.entity.GenerationStatus; -import com.guideon.core.domain.mascot.entity.MascotAnimConfig; import com.guideon.core.domain.mascot.entity.MascotGeneration; -import com.guideon.core.domain.mascot.repository.MascotAnimConfigRepository; import com.guideon.core.domain.mascot.repository.MascotGenerationRepository; import com.guideon.core.domain.site.entity.Site; import com.guideon.core.domain.site.repository.SiteRepository; @@ -20,9 +18,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; /** * 마스코트 3D 모델 생성 파이프라인 오케스트레이션. @@ -43,8 +38,7 @@ public class MascotGenerationService { private final MascotGenerationRepository generationRepository; private final SiteRepository siteRepository; private final FileStorageService fileStorageService; - private final MascotAnimConfigRepository animConfigRepository; - private final MeshProcessorClient meshProcessorClient; + private final MascotAnimationMergeService animationMergeService; /** * Step 1: 선업로드된 이미지 URL을 받아 Tripo 3D 생성 task 시작 @@ -136,7 +130,7 @@ public GenerationStatusResponse pollStatus(Long siteId, Long generationId, log.info("리깅 GLB 저장 완료: generationId={}", generationId); // anim_config가 설정되어 있으면 mesh-processor로 자동 병합 - triggerAnimationMerge(siteId, generationId, modelUrl); + animationMergeService.mergeIfRiggedMascotExists(siteId, generationId, modelUrl); } return GenerationStatusResponse.from(gen); @@ -158,44 +152,6 @@ public GenerationStatusResponse getLatestGeneration(Long siteId, CustomAdminDeta return GenerationStatusResponse.from(gen); } - /** - * rig 완료 후 anim_config가 있으면 mesh-processor에 병합을 요청한다. - * 실패해도 파이프라인 완료 처리는 유지 (base GLB 확보됨, animModelUrl만 null로 남음). - */ - private void triggerAnimationMerge(Long siteId, Long generationId, String riggedModelUrl) { - List animConfigs = animConfigRepository.findBySiteId(siteId); - if (animConfigs.isEmpty()) { - log.info("anim_config 미설정 — anim 병합 skip: siteId={}", siteId); - return; - } - - try { - // { "Idle": "/app/uploads/1/abc.glb", "Talking": "/app/uploads/1/def.glb", ... } - Map animGlbs = animConfigs.stream().collect(Collectors.toMap( - MascotAnimConfig::getClipName, - c -> fileStorageService.toLocalPath(c.getGlbUrl()).toString() - )); - - String riggedLocalPath = fileStorageService.toLocalPath(riggedModelUrl).toString(); - String animFilename = "anim_" + generationId + ".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, generationId, animModelUrl, animClips); - log.info("anim GLB 자동 병합 완료: generationId={}, animModelUrl={}", generationId, animModelUrl); - - } catch (Exception e) { - log.warn("mesh-processor 실패 — animModelUrl 없이 완료: generationId={}, err={}", - generationId, e.getMessage()); - } - } - private void validatePlatformAdmin(CustomAdminDetails adminDetails) { if (!AdminRole.PLATFORM_ADMIN.name().equals(adminDetails.getRole())) { throw new CustomException(ErrorCode.ACCESS_DENIED);