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:
8
Assets/External/Animations/ActorCore/MotionBatch_20260424.meta
vendored
Normal file
8
Assets/External/Animations/ActorCore/MotionBatch_20260424.meta
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9479e994fffecfc14973a2bb6331f1e2
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
100
Assets/External/Animations/ActorCore/MotionBatch_20260424/_actorcore_motion_import_manifest.json
vendored
Normal file
100
Assets/External/Animations/ActorCore/MotionBatch_20260424/_actorcore_motion_import_manifest.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 01ff949f13e1241f78f77ebf2b04f8b1
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
62
Assets/External/Animations/ActorCore/MotionBatch_20260424/_motion_import_manifest.json
vendored
Normal file
62
Assets/External/Animations/ActorCore/MotionBatch_20260424/_motion_import_manifest.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
7
Assets/External/Animations/ActorCore/MotionBatch_20260424/_motion_import_manifest.json.meta
vendored
Normal file
7
Assets/External/Animations/ActorCore/MotionBatch_20260424/_motion_import_manifest.json.meta
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 36de6839bfb6ef867a3668282a656d45
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/forward-straight-punch-r-mirror.fbx
vendored
Normal file
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/forward-straight-punch-r-mirror.fbx
vendored
Normal file
Binary file not shown.
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/forward-straight-punch-r-mirror.fbx.meta
vendored
Normal file
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/forward-straight-punch-r-mirror.fbx.meta
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/forward-straight-punch-r.fbx
vendored
Normal file
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/forward-straight-punch-r.fbx
vendored
Normal file
Binary file not shown.
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/forward-straight-punch-r.fbx.meta
vendored
Normal file
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/forward-straight-punch-r.fbx.meta
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-chop-mirror.fbx
vendored
Normal file
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-chop-mirror.fbx
vendored
Normal file
Binary file not shown.
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-chop-mirror.fbx.meta
vendored
Normal file
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-chop-mirror.fbx.meta
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-chop.fbx
vendored
Normal file
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-chop.fbx
vendored
Normal file
Binary file not shown.
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-chop.fbx.meta
vendored
Normal file
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-chop.fbx.meta
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp-mirror.fbx
vendored
Normal file
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp-mirror.fbx
vendored
Normal file
Binary file not shown.
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp-mirror.fbx.meta
vendored
Normal file
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp-mirror.fbx.meta
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp.fbx
vendored
Normal file
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp.fbx
vendored
Normal file
Binary file not shown.
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp.fbx.meta
vendored
Normal file
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp.fbx.meta
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp_motion9.fbx
vendored
Normal file
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp_motion9.fbx
vendored
Normal file
Binary file not shown.
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp_motion9.fbx.meta
vendored
Normal file
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/orc-stomp_motion9.fbx.meta
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l-mirror.fbx
vendored
Normal file
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l-mirror.fbx
vendored
Normal file
Binary file not shown.
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l-mirror.fbx.meta
vendored
Normal file
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l-mirror.fbx.meta
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l.fbx
vendored
Normal file
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l.fbx
vendored
Normal file
Binary file not shown.
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l.fbx.meta
vendored
Normal file
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l.fbx.meta
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l_motion8.fbx
vendored
Normal file
BIN
Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l_motion8.fbx
vendored
Normal file
Binary file not shown.
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l_motion8.fbx.meta
vendored
Normal file
1092
Assets/External/Animations/ActorCore/MotionBatch_20260424/uppercut-l_motion8.fbx.meta
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
Assets/External/Animations/ActorCore/orc-stomp.fbx
vendored
Normal file
BIN
Assets/External/Animations/ActorCore/orc-stomp.fbx
vendored
Normal file
Binary file not shown.
1092
Assets/External/Animations/ActorCore/orc-stomp.fbx.meta
vendored
Normal file
1092
Assets/External/Animations/ActorCore/orc-stomp.fbx.meta
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2c79ec38acf2200d9a465f565d978ea8
|
||||
69
Docs/ActorCoreMotionBatchImport.md
Normal file
69
Docs/ActorCoreMotionBatchImport.md
Normal 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`과 오류 메시지를 확인하세요.
|
||||
495
Tools/Blender/actorcore_motion_batch_import.py
Executable file
495
Tools/Blender/actorcore_motion_batch_import.py
Executable 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())
|
||||
Reference in New Issue
Block a user