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 @@ -106,6 +106,19 @@ public GenerationStatusResponse pollStatus(Long siteId, Long generationId,
log.info("마스코트 model 완료, rigging 시작: generationId={}", generationId);
String rigTaskId = tripoApiService.createAnimateRigTask(gen.getModelTaskId());
gen = persistService.applyModelComplete(generationId, rigTaskId);

// pre-rig 모델 = 뼈대 없는 clean mesh → assimp로 FBX 변환 (Mixamo 업로드용)
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);
} catch (Exception e) {
log.warn("pre-rig 모델 GLB 검증/FBX 변환 실패 (무시): generationId={}, err={}", generationId, e.getMessage());
}
}
}

return GenerationStatusResponse.from(gen);
Expand All @@ -122,17 +135,22 @@ public GenerationStatusResponse pollStatus(Long siteId, Long generationId,
}

if (status.isSuccess() && status.modelUrl() != null) {
// 리깅 GLB 다운로드 → 저장
// 리깅 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);
}
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);
// 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 @@ -103,6 +103,7 @@ public String createImageToModelTask(String imageToken) {
requestBody.put("pbr", true);
requestBody.put("quad", true);
requestBody.put("texture_quality", "detailed");
requestBody.put("out_format", "glb");

Map<String, Object> response = postJson(url, requestBody);
Map<String, Object> data = extractData(response);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.Set;

Expand Down Expand Up @@ -162,6 +163,21 @@ public static void validateImage(MultipartFile file) {
}
}

/**
* GLB(glTF Binary) 바이트 배열 검증: 매직바이트만 확인.
* Tripo API 다운로드 결과처럼 MultipartFile이 아닌 byte[] 경우에 사용.
* FBX(Kaydara) 등 잘못된 포맷이 반환됐을 때 빠르게 실패시킨다.
*/
public static void validateGlb(byte[] fileBytes) {
if (fileBytes == null || fileBytes.length < 4) {
throw new CustomException(ErrorCode.VALIDATION_ERROR, "GLB 파일이 비어있거나 너무 작습니다.");
}
if (!hasGlbSignature(Arrays.copyOf(fileBytes, 4))) {
throw new CustomException(ErrorCode.VALIDATION_ERROR,
"유효하지 않은 GLB 파일입니다. Tripo가 FBX를 반환했을 수 있습니다.");
}
}

/**
* GLB(glTF Binary) 파일 검증: 매직바이트 + 크기 상한(50MB).
* GLB 매직: 0x67 0x6C 0x54 0x46 ("glTF")
Expand Down
75 changes: 12 additions & 63 deletions mesh-processor/src/stripRig.js
Original file line number Diff line number Diff line change
@@ -1,70 +1,19 @@
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);
import { execSync } from 'child_process';

/**
* Tripo rigged GLB에서 스켈레톤을 제거하고 Mixamo-ready FBX를 생성.
* GLB → FBX 변환 (Mixamo 업로드용).
*
* 입력: Tripo image_to_model 결과 GLB (뼈대 없는 clean mesh)
* 출력: FBX (Mixamo 자동 리깅 전용)
*
* 원리:
* - Animation 제거 → 불필요한 클립 제거
* - Skin 제거 → 메쉬가 뼈대에서 분리되고 버텍스가 bind pose(T-포즈) 위치 유지
* - 메쉬 없는 orphan 노드(뼈대 노드) 제거
* - prune()으로 고아 리소스 정리
* - assimp CLI로 clean GLB → FBX 변환
* image_to_model 단계의 GLB는 이미 뼈대가 없으므로
* gltf-transform 처리 없이 assimp 변환만 수행한다.
*
* @param {string} inputGlbPath Tripo rigged GLB 경로
* @param {string} outputFbxPath 출력 FBX 경로
* @param {string} inputGlbPath Tripo pre-rig 모델 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);
}
console.log(`[stripRig] ${inputGlbPath} → ${outputFbxPath}`);
execSync(`assimp export "${inputGlbPath}" "${outputFbxPath}"`, { timeout: 30000 });
console.log(`[stripRig] FBX 변환 완료: ${outputFbxPath}`);
}
Loading