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