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 @@ -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;
Expand Down Expand Up @@ -82,6 +83,21 @@ public ResponseEntity<ApiResponse<MascotResponse>> 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<ApiResponse<CleanMeshResponse>> 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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

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;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;

/**
Expand All @@ -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;
Expand Down Expand Up @@ -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());
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> body = Map.of(
"riggedGlb", riggedGlbPath,
"outputFbx", outputFbxPath
);

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

try {
Map<String, Object> 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<String, String> animGlbs, String outputPath) {
Map<String, Object> body = Map.of(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -125,12 +129,18 @@ public void updateAnimation(String animModelUrl, Map<String, String> animClips)
this.animClips = animClips != null ? animClips : new HashMap<>();
}

/** Mixamo 업로드용 clean mesh URL 설정. */
public void updateCleanMeshUrl(String cleanMeshUrl) {
this.cleanMeshUrl = cleanMeshUrl;
}

/**
* rig 재생성 시작 시 이전 애니메이션 메타데이터 초기화.
* 새 base GLB와 옛 animModelUrl이 함께 서빙되는 스켈레톤 불일치를 방지.
*/
public void clearAnimation() {
this.animModelUrl = null;
this.animClips = new HashMap<>();
this.cleanMeshUrl = null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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())
Expand Down
2 changes: 2 additions & 0 deletions infra/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
1 change: 1 addition & 0 deletions mesh-processor/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
29 changes: 28 additions & 1 deletion mesh-processor/src/index.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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}`));
70 changes: 70 additions & 0 deletions mesh-processor/src/stripRig.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading