feat: ActorCore 모션 배치 import 자동화 추가

- MotionBatch_20260424에 Motion* 후보 FBX 10개를 import하고 Humanoid 리그를 정리
- Blender 기반 ActorCore 101본 정규화 스크립트와 Unity manifest 적용 메뉴 추가
- orc-stomp 단일 애셋의 extra root 리그를 정규화하고 사용 문서와 manifest를 보강
- Unity 컴파일, ApplyManifest 실제 실행, 콘솔 경고/오류 없음으로 검증
This commit is contained in:
2026-04-24 14:50:21 +09:00
parent ed92a1bc37
commit b4648672f6
31 changed files with 13102 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9479e994fffecfc14973a2bb6331f1e2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,100 @@
{
"schema": "colosseum.actorcoreMotionBatchImport@1",
"createdAtUtc": "2026-04-24T05:33:29.240646+00:00",
"projectRoot": "/home/dal4segno/Colosseum",
"referenceFbx": "Assets/External/Animations/ActorCore/orc-jog.fbx",
"actorCoreAvatar": "Assets/External/Animations/ActorCore/ActorCoreAvatar.asset",
"targetFolder": "Assets/External/Animations/ActorCore/MotionBatch_20260424",
"entries": [
{
"source": "/home/dal4segno/다운로드/Motion/forward-straight-punch-r.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/forward-straight-punch-r.fbx",
"sha256": "9e49fe2cfc6e316b45d124ab77c4468e8af2b85c50c10d3438be0d1c07122a86",
"size": 2007568,
"processing": "direct",
"unityImportMode": "copy",
"clipName": "forward-straight-punch-r"
},
{
"source": "/home/dal4segno/다운로드/Motion (1)/Motion/forward-straight-punch-r-mirror.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/forward-straight-punch-r-mirror.fbx",
"sha256": "664e82f0f5e541de18d75ae99c29edb277fb08e18e24c34edae8a6b37be95ee9",
"size": 2007568,
"processing": "direct",
"unityImportMode": "copy",
"clipName": "forward-straight-punch-r-mirror"
},
{
"source": "/home/dal4segno/다운로드/Motion (2)/Motion/uppercut-l.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l.fbx",
"sha256": "f9e1cf0dc3b9ab0d90799ffd67402a82d17e75a5ec6e5162203580b9f814bf65",
"size": 1838800,
"processing": "direct",
"unityImportMode": "copy",
"clipName": "uppercut-l"
},
{
"source": "/home/dal4segno/다운로드/Motion (3)/Motion/uppercut-l-mirror.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l-mirror.fbx",
"sha256": "5072e63abdecbd1c30f57d86909199bf4d9c0c06333c81931015f037df796945",
"size": 1838800,
"processing": "direct",
"unityImportMode": "copy",
"clipName": "uppercut-l-mirror"
},
{
"source": "/home/dal4segno/다운로드/Motion (4)/Motion/orc-stomp.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp.fbx",
"sha256": "4ef22bbaf3792ebd4d8236c52427be48ab9808521364da33c1892e83077e717b",
"size": 2511648,
"processing": "normalized",
"unityImportMode": "create",
"clipName": "orc-stomp"
},
{
"source": "/home/dal4segno/다운로드/Motion (5)/Motion/orc-stomp-mirror.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp-mirror.fbx",
"sha256": "ab6a578b3f9d0ecabf01a6d648e5b6315f129a86a63fa96bd305ec963bb6d048",
"size": 2511648,
"processing": "normalized",
"unityImportMode": "create",
"clipName": "orc-stomp-mirror"
},
{
"source": "/home/dal4segno/다운로드/Motion (6)/Motion/orc-chop.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-chop.fbx",
"sha256": "ba8045d2075e7ac867cdd2b701b06ad3f661c41ca64cd078227b7316f77c1b82",
"size": 2329712,
"processing": "normalized",
"unityImportMode": "create",
"clipName": "orc-chop"
},
{
"source": "/home/dal4segno/다운로드/Motion (7)/Motion/orc-chop-mirror.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-chop-mirror.fbx",
"sha256": "9151fad6fddd44c5c7f7cbb3ebf529616532db0376bf1cdb2f279c4afb23f69a",
"size": 2329712,
"processing": "normalized",
"unityImportMode": "create",
"clipName": "orc-chop-mirror"
},
{
"source": "/home/dal4segno/다운로드/Motion (8)/Motion/uppercut-l.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l_motion8.fbx",
"sha256": "f9e1cf0dc3b9ab0d90799ffd67402a82d17e75a5ec6e5162203580b9f814bf65",
"size": 1838800,
"processing": "direct",
"unityImportMode": "copy",
"clipName": "uppercut-l_motion8"
},
{
"source": "/home/dal4segno/다운로드/Motion (9)/Motion/orc-stomp.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp_motion9.fbx",
"sha256": "1d5cb6051038b9c9ac61198a1cc355a9117788b0eec44390974aa48e4e26a260",
"size": 1979136,
"processing": "normalized",
"unityImportMode": "create",
"clipName": "orc-stomp_motion9"
}
]
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 01ff949f13e1241f78f77ebf2b04f8b1
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,62 @@
[
{
"source": "/home/dal4segno/다운로드/Motion/forward-straight-punch-r.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/forward-straight-punch-r.fbx",
"sha256": "9e49fe2cfc6e316b45d124ab77c4468e8af2b85c50c10d3438be0d1c07122a86",
"size": 2007568
},
{
"source": "/home/dal4segno/다운로드/Motion (1)/Motion/forward-straight-punch-r-mirror.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/forward-straight-punch-r-mirror.fbx",
"sha256": "664e82f0f5e541de18d75ae99c29edb277fb08e18e24c34edae8a6b37be95ee9",
"size": 2007568
},
{
"source": "/home/dal4segno/다운로드/Motion (2)/Motion/uppercut-l.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l.fbx",
"sha256": "f9e1cf0dc3b9ab0d90799ffd67402a82d17e75a5ec6e5162203580b9f814bf65",
"size": 1838800
},
{
"source": "/home/dal4segno/다운로드/Motion (3)/Motion/uppercut-l-mirror.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l-mirror.fbx",
"sha256": "5072e63abdecbd1c30f57d86909199bf4d9c0c06333c81931015f037df796945",
"size": 1838800
},
{
"source": "/home/dal4segno/다운로드/Motion (4)/Motion/orc-stomp.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp.fbx",
"sha256": "4ef22bbaf3792ebd4d8236c52427be48ab9808521364da33c1892e83077e717b",
"size": 2511648
},
{
"source": "/home/dal4segno/다운로드/Motion (5)/Motion/orc-stomp-mirror.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp-mirror.fbx",
"sha256": "ab6a578b3f9d0ecabf01a6d648e5b6315f129a86a63fa96bd305ec963bb6d048",
"size": 2511648
},
{
"source": "/home/dal4segno/다운로드/Motion (6)/Motion/orc-chop.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-chop.fbx",
"sha256": "ba8045d2075e7ac867cdd2b701b06ad3f661c41ca64cd078227b7316f77c1b82",
"size": 2329712
},
{
"source": "/home/dal4segno/다운로드/Motion (7)/Motion/orc-chop-mirror.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-chop-mirror.fbx",
"sha256": "9151fad6fddd44c5c7f7cbb3ebf529616532db0376bf1cdb2f279c4afb23f69a",
"size": 2329712
},
{
"source": "/home/dal4segno/다운로드/Motion (8)/Motion/uppercut-l.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l_motion8.fbx",
"sha256": "f9e1cf0dc3b9ab0d90799ffd67402a82d17e75a5ec6e5162203580b9f814bf65",
"size": 1838800
},
{
"source": "/home/dal4segno/다운로드/Motion (9)/Motion/orc-stomp.fbx",
"asset": "Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp_motion9.fbx",
"sha256": "1d5cb6051038b9c9ac61198a1cc355a9117788b0eec44390974aa48e4e26a260",
"size": 1979136
}
]

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 36de6839bfb6ef867a3668282a656d45
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,340 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEngine;
using UnityEditor;
/// <summary>
/// ActorCore 모션 배치 manifest를 읽어 FBX ModelImporter 휴머노이드 설정을 적용합니다.
/// Blender 정규화 도구가 생성한 `_actorcore_motion_import_manifest.json`을 대상으로 사용합니다.
/// </summary>
public static class ActorCoreMotionBatchImportSettings
{
private const string MenuPath = "Tools/Animation/Apply ActorCore Motion Batch Import Settings";
private const string ExpectedSchema = "colosseum.actorcoreMotionBatchImport@1";
private const string DefaultActorCoreAvatarPath = "Assets/External/Animations/ActorCore/ActorCoreAvatar.asset";
[Serializable]
private sealed class ActorCoreMotionBatchManifest
{
public string schema;
public string createdAtUtc;
public string referenceFbx;
public string actorCoreAvatar;
public string targetFolder;
public List<ActorCoreMotionBatchEntry> entries;
}
[Serializable]
private sealed class ActorCoreMotionBatchEntry
{
public string source;
public string asset;
public string sha256;
public long size;
public string processing;
public string unityImportMode;
public string clipName;
public string[] removedBones;
public string[] sourceActions;
public string error;
}
private struct ImportValidation
{
public bool success;
public int transformCount;
public bool hasArmatureExtraRoot;
public string rootParent;
public string[] humanClips;
public string[] avatars;
public string message;
}
/// <summary>
/// 선택된 manifest 또는 파일 선택창의 manifest를 적용합니다.
/// </summary>
[MenuItem(MenuPath, false, 26)]
public static void ApplyFromManifestMenu()
{
if (EditorApplication.isPlayingOrWillChangePlaymode)
{
EditorUtility.DisplayDialog("ActorCore Motion Batch", "플레이 모드에서는 import 설정을 변경하지 않습니다.", "확인");
return;
}
string manifestPath = GetManifestPathFromSelectionOrDialog();
if (string.IsNullOrEmpty(manifestPath))
return;
ApplyManifest(manifestPath, true);
}
/// <summary>
/// manifest 경로를 받아 배치 import 설정을 적용합니다.
/// </summary>
public static bool ApplyManifest(string manifestPath, bool showDialog)
{
if (!TryLoadManifest(manifestPath, out ActorCoreMotionBatchManifest manifest))
return false;
string avatarPath = string.IsNullOrEmpty(manifest.actorCoreAvatar)
? DefaultActorCoreAvatarPath
: manifest.actorCoreAvatar;
Avatar actorCoreAvatar = AssetDatabase.LoadAssetAtPath<Avatar>(avatarPath);
if (actorCoreAvatar == null && HasCopyEntries(manifest))
{
Debug.LogError($"[ActorCoreMotionBatchImport] ActorCoreAvatar를 찾지 못했습니다: {avatarPath}");
return false;
}
int appliedCount = 0;
int fallbackCount = 0;
int failedCount = 0;
List<string> reportLines = new List<string>();
foreach (ActorCoreMotionBatchEntry entry in manifest.entries ?? new List<ActorCoreMotionBatchEntry>())
{
if (entry == null || string.IsNullOrEmpty(entry.asset))
continue;
if (string.Equals(entry.unityImportMode, "skip", StringComparison.OrdinalIgnoreCase))
{
failedCount++;
reportLines.Add($"SKIP {entry.asset}: {entry.error}");
continue;
}
ModelImporter importer = AssetImporter.GetAtPath(entry.asset) as ModelImporter;
if (importer == null)
{
failedCount++;
reportLines.Add($"FAIL {entry.asset}: ModelImporter 없음");
continue;
}
string requestedMode = NormalizeImportMode(entry.unityImportMode);
ApplyImporterSettings(entry, importer, requestedMode, actorCoreAvatar);
ImportValidation validation = ValidateImportedAsset(entry.asset, requestedMode);
if (!validation.success && requestedMode == "copy")
{
ModelImporter fallbackImporter = AssetImporter.GetAtPath(entry.asset) as ModelImporter;
ApplyImporterSettings(entry, fallbackImporter, "create", actorCoreAvatar);
validation = ValidateImportedAsset(entry.asset, "create");
fallbackCount++;
requestedMode = "create";
}
if (validation.success)
{
appliedCount++;
reportLines.Add($"OK {requestedMode.ToUpperInvariant()} {Path.GetFileName(entry.asset)} clips=[{string.Join(", ", validation.humanClips)}] rootParent={validation.rootParent}");
}
else
{
failedCount++;
reportLines.Add($"FAIL {Path.GetFileName(entry.asset)}: {validation.message}");
}
}
string summary = $"적용 {appliedCount}개, fallback {fallbackCount}개, 실패 {failedCount}개";
Debug.Log($"[ActorCoreMotionBatchImport] {summary}\n{string.Join("\n", reportLines)}");
if (showDialog)
EditorUtility.DisplayDialog("ActorCore Motion Batch", summary, "확인");
return failedCount == 0;
}
private static bool HasCopyEntries(ActorCoreMotionBatchManifest manifest)
{
return manifest.entries != null
&& manifest.entries.Any(entry => string.Equals(entry.unityImportMode, "copy", StringComparison.OrdinalIgnoreCase));
}
private static void ApplyImporterSettings(
ActorCoreMotionBatchEntry entry,
ModelImporter importer,
string mode,
Avatar actorCoreAvatar)
{
importer.importAnimation = true;
importer.animationType = ModelImporterAnimationType.Human;
importer.avatarSetup = mode == "copy"
? ModelImporterAvatarSetup.CopyFromOther
: ModelImporterAvatarSetup.CreateFromThisModel;
importer.sourceAvatar = mode == "copy" ? actorCoreAvatar : null;
importer.animationCompression = ModelImporterAnimationCompression.Optimal;
importer.optimizeGameObjects = false;
importer.optimizeBones = true;
importer.preserveHierarchy = false;
TakeInfo take = SelectPrimaryTake(importer.importedTakeInfos);
if (string.IsNullOrEmpty(take.name))
{
importer.clipAnimations = Array.Empty<ModelImporterClipAnimation>();
importer.SaveAndReimport();
ModelImporter refreshedImporter = AssetImporter.GetAtPath(entry.asset) as ModelImporter;
take = SelectPrimaryTake(refreshedImporter.importedTakeInfos);
}
if (string.IsNullOrEmpty(take.name))
return;
ModelImporter targetImporter = AssetImporter.GetAtPath(entry.asset) as ModelImporter;
targetImporter.clipAnimations = new[]
{
CreateClipAnimation(entry, take),
};
targetImporter.SaveAndReimport();
}
private static ModelImporterClipAnimation CreateClipAnimation(ActorCoreMotionBatchEntry entry, TakeInfo take)
{
float sampleRate = take.sampleRate > 0f ? take.sampleRate : 30f;
float firstFrame = take.startTime * sampleRate;
float lastFrame = take.stopTime * sampleRate;
return new ModelImporterClipAnimation
{
takeName = take.name,
name = GetClipName(entry),
firstFrame = firstFrame,
lastFrame = Mathf.Max(firstFrame + 1f, lastFrame),
loopTime = false,
loopPose = false,
lockRootRotation = false,
lockRootHeightY = false,
lockRootPositionXZ = false,
};
}
private static TakeInfo SelectPrimaryTake(TakeInfo[] takes)
{
if (takes == null || takes.Length == 0)
return default;
return takes
.Where(take => !string.IsNullOrEmpty(take.name))
.Where(take => take.name.IndexOf("T-Pose", StringComparison.OrdinalIgnoreCase) < 0)
.OrderByDescending(take => take.stopTime - take.startTime)
.FirstOrDefault();
}
private static ImportValidation ValidateImportedAsset(string assetPath, string mode)
{
UnityEngine.Object[] assets = AssetDatabase.LoadAllAssetsAtPath(assetPath);
Transform[] transforms = assets.OfType<Transform>().ToArray();
Transform armatureTransform = transforms.FirstOrDefault(transform => transform.name == "Armature");
Transform rootTransform = transforms.FirstOrDefault(transform => transform.name == "root");
AnimationClip[] humanClips = assets
.OfType<AnimationClip>()
.Where(clip => !clip.name.StartsWith("__preview__", StringComparison.Ordinal))
.Where(clip => clip.humanMotion)
.ToArray();
Avatar[] avatars = assets.OfType<Avatar>().ToArray();
string[] humanClipNames = humanClips
.Select(clip => $"{clip.name}:{clip.length:0.###}")
.ToArray();
string[] avatarNames = avatars
.Select(avatar => $"{avatar.name}:valid={avatar.isValid}:human={avatar.isHuman}")
.ToArray();
bool hasValidOwnAvatar = mode == "copy" || avatars.Any(avatar => avatar.isValid && avatar.isHuman);
bool success = armatureTransform == null
&& rootTransform != null
&& humanClips.Length > 0
&& hasValidOwnAvatar;
string message = success
? "OK"
: $"Armature={armatureTransform != null}, root={rootTransform != null}, humanClips={humanClips.Length}, avatars=[{string.Join(", ", avatarNames)}]";
return new ImportValidation
{
success = success,
transformCount = transforms.Length,
hasArmatureExtraRoot = armatureTransform != null,
rootParent = rootTransform != null && rootTransform.parent != null ? rootTransform.parent.name : "(none)",
humanClips = humanClipNames,
avatars = avatarNames,
message = message,
};
}
private static string GetClipName(ActorCoreMotionBatchEntry entry)
{
if (!string.IsNullOrEmpty(entry.clipName))
return entry.clipName;
return Path.GetFileNameWithoutExtension(entry.asset);
}
private static string NormalizeImportMode(string mode)
{
return string.Equals(mode, "copy", StringComparison.OrdinalIgnoreCase)
? "copy"
: "create";
}
private static bool TryLoadManifest(string manifestPath, out ActorCoreMotionBatchManifest manifest)
{
manifest = null;
if (string.IsNullOrEmpty(manifestPath) || !File.Exists(manifestPath))
{
Debug.LogError($"[ActorCoreMotionBatchImport] manifest를 찾지 못했습니다: {manifestPath}");
return false;
}
manifest = JsonUtility.FromJson<ActorCoreMotionBatchManifest>(File.ReadAllText(manifestPath));
if (manifest == null || manifest.entries == null)
{
Debug.LogError($"[ActorCoreMotionBatchImport] manifest 형식이 올바르지 않습니다: {manifestPath}");
return false;
}
if (!string.Equals(manifest.schema, ExpectedSchema, StringComparison.Ordinal))
Debug.LogWarning($"[ActorCoreMotionBatchImport] 예상 schema와 다릅니다: {manifest.schema}");
return true;
}
private static string GetManifestPathFromSelectionOrDialog()
{
string selectedPath = AssetDatabase.GetAssetPath(Selection.activeObject);
if (IsJsonAssetPath(selectedPath))
return selectedPath;
string absolutePath = EditorUtility.OpenFilePanel(
"ActorCore Motion Batch Manifest 선택",
Application.dataPath,
"json");
if (string.IsNullOrEmpty(absolutePath))
return null;
string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")).Replace('\\', '/');
string normalizedPath = Path.GetFullPath(absolutePath).Replace('\\', '/');
if (!normalizedPath.StartsWith(projectRoot + "/", StringComparison.Ordinal))
{
EditorUtility.DisplayDialog("ActorCore Motion Batch", "프로젝트 내부 manifest만 사용할 수 있습니다.", "확인");
return null;
}
string projectRelativePath = normalizedPath.Substring(projectRoot.Length + 1);
return IsJsonAssetPath(projectRelativePath) ? projectRelativePath : null;
}
private static bool IsJsonAssetPath(string assetPath)
{
return !string.IsNullOrEmpty(assetPath)
&& assetPath.StartsWith("Assets/", StringComparison.Ordinal)
&& assetPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2c79ec38acf2200d9a465f565d978ea8

View File

@@ -0,0 +1,69 @@
# ActorCore Motion Batch Import
`/home/dal4segno/다운로드/Motion*`처럼 외부에서 받은 ActorCore 계열 FBX를 프로젝트에 일괄 import하기 위한 절차입니다.
## 목적
- 정상 ActorCore 101본 구조는 기존 `ActorCoreAvatar``Copy From Other Avatar`로 적용합니다.
- `RootNode_0`, `CC_Base_BoneRoot`, `RL_BoneRoot` 같은 extra root/보조 본이 섞인 FBX는 Blender에서 정상 ActorCore 101본 구조로 정규화합니다.
- Unity에서는 정규화된 FBX를 `Create From This Model`로 import해 유효한 Humanoid Avatar와 `humanMotion` 클립을 생성합니다.
- 0프레임 `T-Pose` take는 제외하고 실제 모션 클립만 남깁니다.
## 1. Blender 정규화와 manifest 생성
Unity 프로젝트 루트에서 실행합니다.
```bash
python3 Tools/Blender/actorcore_motion_batch_import.py \
--source-glob "/home/dal4segno/다운로드/Motion*/**/*.fbx"
```
기본 출력 위치는 `Assets/External/Animations/ActorCore/MotionBatch_YYYYMMDD`입니다. 같은 이름의 폴더가 이미 있으면 `_1`, `_2` suffix가 붙습니다.
필요하면 batch 이름을 직접 지정할 수 있습니다.
```bash
python3 Tools/Blender/actorcore_motion_batch_import.py \
--source-glob "/home/dal4segno/다운로드/Motion*/**/*.fbx" \
--batch-name MotionBatch_DrogCandidates
```
변환 없이 계획만 확인하려면 다음처럼 실행합니다.
```bash
python3 Tools/Blender/actorcore_motion_batch_import.py \
--source-glob "/home/dal4segno/다운로드/Motion*/**/*.fbx" \
--dry-run
```
스크립트가 생성하는 manifest는 `_actorcore_motion_import_manifest.json`입니다. 이 파일에는 원본 경로, 복사된 asset 경로, sha256, 처리 방식(`copy` 또는 `create`), 제거된 본 목록이 기록됩니다.
## 2. Unity import 설정 적용
1. Unity Editor에서 play mode가 꺼져 있는지 확인합니다.
2. Project 창에서 생성된 `_actorcore_motion_import_manifest.json`을 선택합니다.
3. 메뉴 `Tools/Animation/Apply ActorCore Motion Batch Import Settings`를 실행합니다.
4. 완료 후 Unity Console에 오류/경고가 없는지 확인합니다.
메뉴는 manifest의 `unityImportMode`를 기준으로 다음을 적용합니다.
- `copy`: `Humanoid / Copy From Other Avatar / ActorCoreAvatar`
- `create`: `Humanoid / Create From This Model`
`copy` 적용 후 Unity가 유효한 `humanMotion` 클립을 만들지 못하면 자동으로 `Create From This Model`로 fallback합니다.
## 검증 기준
성공 조건은 다음과 같습니다.
- `Armature` extra transform이 없습니다.
- `root`가 FBX 루트 오브젝트 바로 아래에 있습니다.
- `AnimationClip.humanMotion == true`인 실제 모션 클립이 1개 이상 있습니다.
- `create` 모드 파일은 valid + human Avatar를 자체 sub-asset으로 가집니다.
- Unity Console에 import warning/error가 없습니다.
## 주의
- 이 도구는 원본 다운로드 폴더의 FBX를 수정하지 않고, 프로젝트 하위 새 batch 폴더에 복사한 파일만 정규화합니다.
- 기준 rest skeleton은 기본적으로 `Assets/External/Animations/ActorCore/orc-jog.fbx`를 사용합니다.
- ActorCore와 다른 본 이름 체계를 가진 FBX는 자동 정규화가 실패할 수 있습니다. manifest에서 `unityImportMode: skip`과 오류 메시지를 확인하세요.

View File

@@ -0,0 +1,495 @@
#!/usr/bin/env python3
"""ActorCore 모션 FBX 배치 import 준비 도구.
이 스크립트는 Unity 프로젝트 밖의 FBX를 ActorCore 하위 폴더로 복사하고,
Blender를 사용해 extra root/보조 본이 섞인 FBX를 정상 ActorCore 101본 구조로 정규화합니다.
Unity ModelImporter 설정은 생성된 manifest를 Unity 메뉴에서 적용합니다.
"""
from __future__ import annotations
import argparse
import datetime as dt
import glob
import hashlib
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
DEFAULT_SOURCE_GLOB = "/home/dal4segno/다운로드/Motion*/**/*.fbx"
DEFAULT_TARGET_PARENT = "Assets/External/Animations/ActorCore"
DEFAULT_REFERENCE_FBX = "Assets/External/Animations/ActorCore/orc-jog.fbx"
DEFAULT_ACTOR_CORE_AVATAR = "Assets/External/Animations/ActorCore/ActorCoreAvatar.asset"
MANIFEST_NAME = "_actorcore_motion_import_manifest.json"
BLENDER_HELPER = r'''
import bpy
import json
import re
import sys
from pathlib import Path
request_path = Path(sys.argv[sys.argv.index('--') + 1])
result_path = Path(sys.argv[sys.argv.index('--') + 2])
request = json.loads(request_path.read_text(encoding='utf-8'))
project_root = Path(request['projectRoot'])
reference_path = project_root / request['referenceFbx']
entries = request['entries']
extra_bone_names = {
'RootNode_0',
'CC_Base_BoneRoot',
'CC_Base_TearLine',
'CC_Base_Tongue',
'CC_Base_Eye',
'FOR_SHOUDER_fix_0',
'CC_Base_EyeOcclusion',
'CC_Base_Teeth',
}
core_bones = [
'root',
'CC_Base_Hip',
'CC_Base_Pelvis',
'CC_Base_L_Thigh',
'CC_Base_R_Thigh',
'CC_Base_Spine02',
'CC_Base_L_Hand',
'CC_Base_R_Hand',
]
def import_fbx(path):
bpy.ops.import_scene.fbx(filepath=str(path), automatic_bone_orientation=False, use_custom_normals=True)
armatures = [obj for obj in bpy.context.scene.objects if obj.type == 'ARMATURE']
if not armatures:
raise RuntimeError(f'Armature not found: {path}')
return armatures[0]
def action_fcurves(action):
curves = []
if hasattr(action, 'fcurves'):
try:
curves.extend(action.fcurves)
except Exception:
pass
for layer in getattr(action, 'layers', []) or []:
for strip in getattr(layer, 'strips', []) or []:
for bag in getattr(strip, 'channelbags', []) or []:
curves.extend(list(getattr(bag, 'fcurves', []) or []))
return curves
def remove_fcurve(action, fcurve):
if hasattr(action, 'fcurves'):
try:
action.fcurves.remove(fcurve)
return True
except Exception:
pass
for layer in getattr(action, 'layers', []) or []:
for strip in getattr(layer, 'strips', []) or []:
for bag in getattr(strip, 'channelbags', []) or []:
try:
bag.fcurves.remove(fcurve)
return True
except Exception:
pass
return False
def get_classification(path):
bpy.ops.wm.read_factory_settings(use_empty=True)
armature = import_fbx(path)
names = [bone.name for bone in armature.data.bones]
parents = {bone.name: bone.parent.name if bone.parent else None for bone in armature.data.bones}
actions = [action.name for action in bpy.data.actions if 'T-Pose' not in action.name]
extras = [name for name in names if name in extra_bone_names]
missing_core = [name for name in core_bones if name not in names]
return {
'boneCount': len(names),
'rootParent': parents.get('root'),
'hipParent': parents.get('CC_Base_Hip'),
'extras': extras,
'missingCore': missing_core,
'actions': actions,
'hasRlBoneRoot': 'RL_BoneRoot' in names,
}
def is_direct_candidate(classification):
return (
classification['boneCount'] == 101
and not classification['extras']
and not classification['missingCore']
and classification['hipParent'] == 'root'
)
# 정상 ActorCore rest skeleton을 캡처한다.
bpy.ops.wm.read_factory_settings(use_empty=True)
reference_armature = import_fbx(reference_path)
bpy.context.view_layer.objects.active = reference_armature
reference_armature.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
reference = {}
reference_order = []
for edit_bone in reference_armature.data.edit_bones:
reference_order.append(edit_bone.name)
reference[edit_bone.name] = {
'head': edit_bone.head.copy(),
'tail': edit_bone.tail.copy(),
'roll': float(edit_bone.roll),
'parent': edit_bone.parent.name if edit_bone.parent else None,
'useConnect': bool(edit_bone.use_connect),
}
bpy.ops.object.mode_set(mode='OBJECT')
valid_bones = set(reference)
bone_pattern = re.compile(r'pose\.bones\["(.+?)"\]')
results = []
for entry in entries:
asset_path = project_root / entry['asset']
before = get_classification(asset_path)
if is_direct_candidate(before):
entry.update({
'processing': 'direct',
'unityImportMode': 'copy',
'removedBones': [],
'sourceActions': before['actions'],
'classificationBefore': before,
'classificationAfter': before,
})
results.append(entry)
continue
bpy.ops.wm.read_factory_settings(use_empty=True)
armature = import_fbx(asset_path)
armature.name = 'Armature'
armature.data.name = 'Armature'
bpy.context.view_layer.objects.active = armature
armature.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
edit_bones = armature.data.edit_bones
if 'root' not in edit_bones and 'RL_BoneRoot' in edit_bones:
edit_bones['RL_BoneRoot'].name = 'root'
source_names = {bone.name for bone in edit_bones}
missing_reference = [name for name in reference_order if name not in source_names]
if missing_reference:
bpy.ops.object.mode_set(mode='OBJECT')
entry.update({
'processing': 'failed',
'unityImportMode': 'skip',
'removedBones': [],
'sourceActions': before['actions'],
'classificationBefore': before,
'classificationAfter': before,
'error': 'Missing reference bones: ' + ', '.join(missing_reference),
})
results.append(entry)
continue
removed_bones = []
for edit_bone in list(edit_bones):
if edit_bone.name not in reference:
removed_bones.append(edit_bone.name)
edit_bones.remove(edit_bone)
for edit_bone in edit_bones:
edit_bone.use_connect = False
for name in reference_order:
edit_bone = edit_bones[name]
info = reference[name]
edit_bone.head = info['head']
edit_bone.tail = info['tail']
edit_bone.roll = info['roll']
for name in reference_order:
edit_bone = edit_bones[name]
parent_name = reference[name]['parent']
edit_bone.parent = edit_bones[parent_name] if parent_name else None
edit_bone.use_connect = reference[name]['useConnect']
bpy.ops.object.mode_set(mode='OBJECT')
source_actions = []
for action in list(bpy.data.actions):
if 'T-Pose' in action.name:
action.name = '0_T-Pose'
else:
clean_name = action.name.split('|')[-1]
action.name = clean_name
source_actions.append(clean_name)
for action in list(bpy.data.actions):
for fcurve in list(action_fcurves(action)):
if 'RL_BoneRoot' in fcurve.data_path:
fcurve.data_path = fcurve.data_path.replace('RL_BoneRoot', 'root')
match = bone_pattern.search(fcurve.data_path)
if match and match.group(1) not in valid_bones:
remove_fcurve(action, fcurve)
armature.animation_data_create()
armature.animation_data.action = None
bpy.ops.object.mode_set(mode='POSE')
bpy.ops.pose.select_all(action='SELECT')
bpy.ops.pose.transforms_clear()
bpy.ops.object.mode_set(mode='OBJECT')
bpy.context.scene.name = asset_path.stem
bpy.context.scene.frame_start = 1
bpy.context.scene.frame_end = max([int(action.frame_range[1]) for action in bpy.data.actions if 'T-Pose' not in action.name] or [1])
bpy.context.scene.frame_set(1)
bpy.ops.object.select_all(action='DESELECT')
armature.select_set(True)
bpy.context.view_layer.objects.active = armature
temp_path = asset_path.with_suffix('.normalized_tmp.fbx')
bpy.ops.export_scene.fbx(
filepath=str(temp_path),
use_selection=True,
object_types={'ARMATURE'},
add_leaf_bones=False,
bake_anim=True,
bake_anim_use_all_actions=True,
bake_anim_use_nla_strips=False,
bake_anim_simplify_factor=0.0,
armature_nodetype='NULL',
use_armature_deform_only=True,
)
asset_path.write_bytes(temp_path.read_bytes())
temp_path.unlink()
after = get_classification(asset_path)
normalized_ok = is_direct_candidate(after)
entry.update({
'processing': 'normalized' if normalized_ok else 'failed',
'unityImportMode': 'create' if normalized_ok else 'skip',
'removedBones': removed_bones,
'sourceActions': source_actions,
'classificationBefore': before,
'classificationAfter': after,
})
if not normalized_ok:
entry['error'] = 'Normalized FBX did not match ActorCore 101-bone structure.'
results.append(entry)
result_path.write_text(json.dumps({'entries': results}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
'''
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="ActorCore 모션 FBX 배치 import 준비")
parser.add_argument(
"--source-glob",
action="append",
default=None,
help="가져올 FBX glob. 여러 번 지정 가능. 기본값: /home/dal4segno/다운로드/Motion*/**/*.fbx",
)
parser.add_argument(
"--project-root",
default=".",
help="Unity 프로젝트 루트. 기본값: 현재 디렉터리",
)
parser.add_argument(
"--target-parent",
default=DEFAULT_TARGET_PARENT,
help="새 batch 폴더를 만들 상위 Assets 경로",
)
parser.add_argument(
"--batch-name",
default=None,
help="생성할 batch 폴더 이름. 기본값: MotionBatch_YYYYMMDD",
)
parser.add_argument(
"--reference-fbx",
default=DEFAULT_REFERENCE_FBX,
help="정상 ActorCore rest skeleton 기준 FBX",
)
parser.add_argument(
"--actor-core-avatar",
default=DEFAULT_ACTOR_CORE_AVATAR,
help="Unity CopyFromOther에 사용할 ActorCore Avatar asset 경로",
)
parser.add_argument(
"--blender",
default="blender",
help="Blender 실행 파일 경로. 기본값: blender",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="복사/변환 없이 소스와 대상 경로만 출력",
)
return parser.parse_args()
def project_relative(path: Path, project_root: Path) -> str:
return path.relative_to(project_root).as_posix()
def unique_target_folder(project_root: Path, target_parent: str, batch_name: str | None) -> Path:
parent = project_root / target_parent
name = batch_name or f"MotionBatch_{dt.datetime.now().strftime('%Y%m%d')}"
target = parent / name
if not target.exists():
return target
index = 1
while True:
candidate = parent / f"{name}_{index}"
if not candidate.exists():
return candidate
index += 1
def find_sources(source_globs: list[str] | None) -> list[Path]:
patterns = source_globs or [DEFAULT_SOURCE_GLOB]
sources: list[Path] = []
for pattern in patterns:
sources.extend(Path(path).resolve() for path in glob.glob(pattern, recursive=True))
return sorted(path for path in set(sources) if path.is_file() and path.suffix.lower() == ".fbx")
def make_unique_asset_name(source: Path, used_names: dict[str, int]) -> str:
name = source.name
if name not in used_names:
used_names[name] = 1
return name
marker = ""
for part in source.parts:
match = re_match_motion_folder(part)
if match is not None:
marker = match
break
if not marker:
marker = f"dup{used_names[name]}"
used_names[name] += 1
return f"{source.stem}_{marker}{source.suffix.lower()}"
def re_match_motion_folder(folder_name: str) -> str | None:
import re
match = re.fullmatch(r"Motion(?: \((\d+)\))?", folder_name)
if not match:
return None
return "motion" + (match.group(1) or "0")
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_blender_request(project_root: Path, reference_fbx: str, entries: list[dict]) -> tuple[Path, Path, Path]:
temp_dir = Path(tempfile.mkdtemp(prefix="actorcore_batch_"))
helper_path = temp_dir / "actorcore_blender_helper.py"
request_path = temp_dir / "request.json"
result_path = temp_dir / "result.json"
helper_path.write_text(BLENDER_HELPER, encoding="utf-8")
request_path.write_text(
json.dumps(
{
"projectRoot": str(project_root),
"referenceFbx": reference_fbx,
"entries": entries,
},
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
)
return helper_path, request_path, result_path
def main() -> int:
args = parse_args()
project_root = Path(args.project_root).resolve()
if not (project_root / "Assets").is_dir():
print(f"Unity 프로젝트 루트를 찾지 못했습니다: {project_root}", file=sys.stderr)
return 2
sources = find_sources(args.source_glob)
if not sources:
print("가져올 FBX가 없습니다.", file=sys.stderr)
return 1
target_folder = unique_target_folder(project_root, args.target_parent, args.batch_name)
used_names: dict[str, int] = {}
planned_entries = []
for source in sources:
asset_name = make_unique_asset_name(source, used_names)
asset_path = target_folder / asset_name
planned_entries.append(
{
"source": str(source),
"asset": project_relative(asset_path, project_root),
"clipName": asset_path.stem,
"sha256": sha256(source),
"size": source.stat().st_size,
}
)
print(f"대상 폴더: {project_relative(target_folder, project_root)}")
for entry in planned_entries:
print(f"- {entry['asset']} <- {entry['source']}")
if args.dry_run:
return 0
target_folder.mkdir(parents=True, exist_ok=False)
for entry in planned_entries:
shutil.copy2(entry["source"], project_root / entry["asset"])
helper_path, request_path, result_path = write_blender_request(project_root, args.reference_fbx, planned_entries)
command = [args.blender, "-b", "--python", str(helper_path), "--", str(request_path), str(result_path)]
subprocess.run(command, check=True)
result = json.loads(result_path.read_text(encoding="utf-8"))
entries = result["entries"]
manifest = {
"schema": "colosseum.actorcoreMotionBatchImport@1",
"createdAtUtc": dt.datetime.now(dt.timezone.utc).isoformat(),
"projectRoot": str(project_root),
"referenceFbx": args.reference_fbx,
"actorCoreAvatar": args.actor_core_avatar,
"targetFolder": project_relative(target_folder, project_root),
"entries": entries,
}
manifest_path = target_folder / MANIFEST_NAME
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
failed = [entry for entry in entries if entry.get("unityImportMode") == "skip"]
print(f"\nmanifest: {project_relative(manifest_path, project_root)}")
print(f"직접 ActorCoreAvatar 후보: {sum(1 for entry in entries if entry.get('unityImportMode') == 'copy')}")
print(f"정규화 후 자체 Avatar 후보: {sum(1 for entry in entries if entry.get('unityImportMode') == 'create')}")
if failed:
print(f"실패: {len(failed)}", file=sys.stderr)
for entry in failed:
print(f"- {entry['asset']}: {entry.get('error', 'unknown error')}", file=sys.stderr)
return 1
print("Unity에서 Tools/Animation/Apply ActorCore Motion Batch Import Settings 메뉴로 manifest를 적용하세요.")
return 0
if __name__ == "__main__":
raise SystemExit(main())