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,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