feat: 애니메이션 클립 병합/잘라내기 에디터 툴 추가 및 메뉴 통합

- AnimationClipMerger: 여러 클립을 순서 조정 후 이어 붙이는 에디터 윈도우 추가
- AnimationClipTrimmerWindow: 시작/종료 프레임 지정으로 클립 잘라내기 에디터 윈도우 추가
- 기존 애니메이션 에디터 툴 메뉴를 Tools/Animation/ 서브메뉴로 통합
  - Merge Clips, Trim Clip, Reverse Clip, Section Speed Editor
This commit is contained in:
2026-04-02 00:11:20 +09:00
parent 7ff9332333
commit 5c2b9ccb69
6 changed files with 524 additions and 2 deletions

View File

@@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEditor;
/// <summary>
/// 여러 AnimationClip을 시간 순으로 이어 붙여 새로운 클립을 생성하는 에디터 윈도우입니다.
/// 클립 순서를 자유롭게 조정할 수 있습니다.
/// FBX 임포트 클립에는 적용 불가 — 우선 Extract 후 사용하세요.
/// </summary>
public class AnimationClipMergerWindow : EditorWindow
{
private List<AnimationClip> clips = new List<AnimationClip>();
private Vector2 scrollPosition;
private const string MenuPath = "Tools/Animation/Merge Clips";
[MenuItem(MenuPath, false, 20)]
public static void ShowWindow()
{
var selectedClips = Selection.GetFiltered<AnimationClip>(SelectionMode.Assets);
if (selectedClips.Length < 2)
{
EditorUtility.DisplayDialog(
"Merge Animation Clips",
"병합할 AnimationClip을 2개 이상 선택해 주세요.",
"확인");
return;
}
var window = GetWindow<AnimationClipMergerWindow>("Clip Merger");
// 선택 순서 유지 (Selection.GetFiltered는 역순일 수 있으므로 뒤집음)
window.clips = selectedClips.Reverse().ToList();
window.minSize = new Vector2(300, 250);
window.Show();
}
[MenuItem(MenuPath, true)]
public static bool ValidateShowWindow()
{
return Selection.GetFiltered<AnimationClip>(SelectionMode.Assets).Length >= 2;
}
private void OnGUI()
{
EditorGUILayout.Space(8f);
EditorGUILayout.LabelField($"클립 순서 ({clips.Count}개)", EditorStyles.boldLabel);
EditorGUILayout.Space(4f);
if (clips.Count == 0)
{
EditorGUILayout.HelpBox("클립이 없습니다.", MessageType.Warning);
return;
}
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
for (int i = 0; i < clips.Count; i++)
{
AnimationClip clip = clips[i];
if (clip == null)
{
EditorGUILayout.LabelField($"[{i + 1}] (삭제됨)", EditorStyles.boldLabel);
continue;
}
EditorGUILayout.BeginHorizontal();
// 인덱스
EditorGUILayout.LabelField($"[{i + 1}]", GUILayout.Width(30));
// 클립 정보
EditorGUI.BeginDisabledGroup(true);
EditorGUILayout.ObjectField(clip, typeof(AnimationClip), false);
EditorGUI.EndDisabledGroup();
// 길이 표시
EditorGUILayout.LabelField($"{clip.length:F2}s", GUILayout.Width(50));
// 위로 / 아래로 버튼
EditorGUI.BeginDisabledGroup(i == 0);
if (GUILayout.Button("▲", GUILayout.Width(28)))
{
clips.Swap(i, i - 1);
break;
}
EditorGUI.EndDisabledGroup();
EditorGUI.BeginDisabledGroup(i == clips.Count - 1);
if (GUILayout.Button("▼", GUILayout.Width(28)))
{
clips.Swap(i, i + 1);
break;
}
EditorGUI.EndDisabledGroup();
// 제거 버튼
if (GUILayout.Button("✕", GUILayout.Width(28)))
{
clips.RemoveAt(i);
break;
}
EditorGUILayout.EndHorizontal();
}
EditorGUILayout.EndScrollView();
EditorGUILayout.Space(8f);
// 총 길이
float totalLength = clips.Sum(c => c != null ? c.length : 0f);
EditorGUILayout.LabelField($"총 길이: {totalLength:F3}초", EditorStyles.miniBoldLabel);
EditorGUILayout.Space(8f);
// 병합 버튼
EditorGUI.BeginDisabledGroup(clips.Count < 2);
if (GUILayout.Button("병합", GUILayout.Height(32f)))
{
PerformMerge();
}
EditorGUI.EndDisabledGroup();
}
/// <summary>
/// 현재 순서대로 클립들을 이어 붙여 새 AnimationClip을 생성합니다.
/// </summary>
private void PerformMerge()
{
// null 클립 제거
clips.RemoveAll(c => c == null);
if (clips.Count < 2)
{
EditorUtility.DisplayDialog("Merge Animation Clips", "유효한 클립이 2개 이상 필요합니다.", "확인");
return;
}
// 첫 번째 클립이 위치한 폴더에 결과 저장
string firstClipPath = AssetDatabase.GetAssetPath(clips[0]);
string directory = System.IO.Path.GetDirectoryName(firstClipPath);
string mergedName = "Merged_" + string.Join("_", clips.Select(c => c.name));
string savePath = System.IO.Path.Combine(directory, mergedName + ".anim")
.Replace("\\", "/");
savePath = AssetDatabase.GenerateUniqueAssetPath(savePath);
AnimationClip mergedClip = new AnimationClip
{
name = mergedName,
frameRate = clips[0].frameRate,
legacy = clips[0].legacy,
wrapMode = WrapMode.Once
};
float timeOffset = 0f;
foreach (AnimationClip sourceClip in clips)
{
CopyCurvesToClip(sourceClip, mergedClip, timeOffset);
CopyEventsToClip(sourceClip, mergedClip, timeOffset);
timeOffset += sourceClip.length;
}
AssetDatabase.CreateAsset(mergedClip, savePath);
AssetDatabase.SaveAssets();
EditorUtility.FocusProjectWindow();
Selection.activeObject = mergedClip;
Debug.Log($"[AnimationClipMerger] {clips.Count}개 클립 병합 완료: {savePath} (총 {timeOffset:F3}초)");
Close();
}
/// <summary>
/// 소스 클립의 모든 커브를 타겟 클립에 오프셋 적용하여 복사합니다.
/// 동일 바인딩이 이미 존재하면 키 프레임을 병합합니다.
/// </summary>
private static void CopyCurvesToClip(AnimationClip source, AnimationClip target, float timeOffset)
{
EditorCurveBinding[] bindings = AnimationUtility.GetCurveBindings(source);
foreach (EditorCurveBinding binding in bindings)
{
AnimationCurve sourceCurve = AnimationUtility.GetEditorCurve(source, binding);
if (sourceCurve == null || sourceCurve.keys.Length == 0)
continue;
AnimationCurve existingCurve = AnimationUtility.GetEditorCurve(target, binding);
AnimationCurve newCurve = existingCurve != null
? AppendKeys(existingCurve, sourceCurve, timeOffset)
: OffsetCurve(sourceCurve, timeOffset);
target.SetCurve(binding.path, binding.type, binding.propertyName, newCurve);
}
}
/// <summary>
/// 커브의 모든 키 프레임 시간을 오프셋만큼 이동시킨 새 커브를 반환합니다.
/// </summary>
private static AnimationCurve OffsetCurve(AnimationCurve curve, float offset)
{
Keyframe[] keys = curve.keys;
for (int i = 0; i < keys.Length; i++)
{
keys[i].time += offset;
}
return new AnimationCurve(keys)
{
preWrapMode = curve.preWrapMode,
postWrapMode = curve.postWrapMode
};
}
/// <summary>
/// 기존 커브에 새 키 프레임들을 오프셋 적용하여 추가합니다.
/// </summary>
private static AnimationCurve AppendKeys(AnimationCurve existing, AnimationCurve toAppend, float offset)
{
List<Keyframe> allKeys = new List<Keyframe>(existing.keys);
foreach (Keyframe key in toAppend.keys)
{
allKeys.Add(new Keyframe(key.time + offset, key.value, key.inTangent, key.outTangent));
}
return new AnimationCurve(allKeys.ToArray())
{
preWrapMode = existing.preWrapMode,
postWrapMode = existing.postWrapMode
};
}
/// <summary>
/// 소스 클립의 AnimationEvent를 오프셋 적용하여 타겟 클립에 추가합니다.
/// </summary>
private static void CopyEventsToClip(AnimationClip source, AnimationClip target, float timeOffset)
{
AnimationEvent[] events = AnimationUtility.GetAnimationEvents(source);
if (events.Length == 0)
return;
AnimationEvent[] existingEvents = AnimationUtility.GetAnimationEvents(target);
List<AnimationEvent> allEvents = new List<AnimationEvent>(existingEvents);
foreach (AnimationEvent evt in events)
{
allEvents.Add(new AnimationEvent
{
time = evt.time + timeOffset,
functionName = evt.functionName,
floatParameter = evt.floatParameter,
intParameter = evt.intParameter,
stringParameter = evt.stringParameter,
objectReferenceParameter = evt.objectReferenceParameter,
messageOptions = evt.messageOptions
});
}
AnimationUtility.SetAnimationEvents(target, allEvents.ToArray());
}
}
/// <summary>
/// List 확장 메서드: 두 요소의 위치를 교체합니다.
/// </summary>
internal static class ListExtensions
{
public static void Swap<T>(this IList<T> list, int a, int b)
{
(list[a], list[b]) = (list[b], list[a]);
}
}