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 @@ -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;
Expand Down Expand Up @@ -201,6 +202,25 @@ public ResponseEntity<ApiResponse<AnimConfigResponse>> 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<ApiResponse<ModelUploadResponse>> 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 모델 생성 시작",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<MascotAnimConfig> animConfigs = animConfigRepository.findBySiteId(siteId);
if (animConfigs.isEmpty()) {
log.info("anim_config 미설정 — anim 병합 skip (수동 업로드): siteId={}", siteId);
return null;
}

try {
Map<String, String> 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<String, String> 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는 부가 기능이므로 파이프라인 완료에 영향 없음.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> animClips) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;


/**
Expand All @@ -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 시작
Expand Down Expand Up @@ -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());
}
}
}
Expand All @@ -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);
Expand All @@ -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();
}

/**
* 생성 이력 조회 (최신)
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> body = Map.of(
"input", inputPath,
"output", outputPath
);

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);

try {
Map<String, Object> 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<String, Object> body = Map.of(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[] 경우에 사용.
Expand Down
16 changes: 16 additions & 0 deletions mesh-processor/src/convert.js
Original file line number Diff line number Diff line change
@@ -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}`);
}
27 changes: 27 additions & 0 deletions mesh-processor/src/index.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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}`));
Loading