Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ public class AnimationGlbsUploadResponse {

private List<UploadedClip> uploaded;

/** 병합 완료 시 animModelUrl, 마스코트 미생성 또는 병합 실패 시 null */
private String animModelUrl;

@Getter
@Builder
public static class UploadedClip {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 파일 업로드.
Expand Down Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MascotAnimConfig> animConfigs = animConfigRepository.findBySiteId(siteId);
if (animConfigs.isEmpty()) {
log.info("anim_config 미설정 — anim 병합 skip: siteId={}", siteId);
return null;
}

try {
// clipName이 중복일 때 IllegalStateException 방지 — 첫 번째 값 유지 (a, b) -> a
Map<String, String> animGlbs = animConfigs.stream().collect(Collectors.toMap(
MascotAnimConfig::getClipName,
c -> fileStorageService.toLocalPath(c.getGlbUrl()).toString(),
(a, b) -> a
));
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<String, String> 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 모델 생성 파이프라인 오케스트레이션.
Expand All @@ -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 시작
Expand Down Expand Up @@ -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);
Expand All @@ -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<MascotAnimConfig> 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<String, String> 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<String, String> 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);
Expand Down
Loading