using System; using System.Collections.Generic; using UnityEngine; using UnityEditor; /// /// AnimationClip의 시작/종료 프레임을 설정하여 해당 구간만 잘라낸 새 클립을 생성합니다. /// FBX 임포트 클립에는 적용 불가 — 우선 Extract 후 사용하세요. /// public class AnimationClipTrimmerWindow : EditorWindow { private AnimationClip sourceClip; private int startFrame; private int endFrame; private float clipFrameRate; private float clipLength; private int totalFrames; private const string MenuPath = "Tools/Animation/Trim Clip"; [MenuItem(MenuPath, false, 21)] public static void ShowWindow() { var clips = Selection.GetFiltered(SelectionMode.Assets); if (clips.Length == 0) { EditorUtility.DisplayDialog( "Trim Animation Clip", "AnimationClip을 1개 선택해 주세요.", "확인"); return; } if (clips.Length > 1) { EditorUtility.DisplayDialog( "Trim Animation Clip", "1개의 AnimationClip만 선택해 주세요.", "확인"); return; } var window = GetWindow("Clip Trimmer"); window.sourceClip = clips[0]; window.clipFrameRate = clips[0].frameRate; window.clipLength = clips[0].length; window.totalFrames = Mathf.RoundToInt(clips[0].length * clips[0].frameRate); window.startFrame = 0; window.endFrame = window.totalFrames; window.minSize = new Vector2(320, 200); window.Show(); } [MenuItem(MenuPath, true)] public static bool ValidateShowWindow() { return Selection.GetFiltered(SelectionMode.Assets).Length == 1; } private void OnGUI() { if (sourceClip == null) { EditorGUILayout.HelpBox("AnimationClip을 찾을 수 없습니다.", MessageType.Error); return; } // 소스 클립 정보 EditorGUILayout.Space(8f); EditorGUILayout.LabelField("소스 클립", EditorStyles.boldLabel); EditorGUILayout.LabelField($" 이름: {sourceClip.name}"); EditorGUILayout.LabelField($" 프레임 레이트: {clipFrameRate} FPS"); EditorGUILayout.LabelField($" 전체 길이: {clipLength:F3}초 ({totalFrames}프레임)"); EditorGUILayout.Space(12f); // 프레임 범위 설정 EditorGUILayout.LabelField("잘라내기 범위", EditorStyles.boldLabel); EditorGUILayout.Space(4f); EditorGUI.BeginChangeCheck(); int newStart = EditorGUILayout.IntField("시작 프레임", startFrame); int newEnd = EditorGUILayout.IntField("종료 프레임", endFrame); if (EditorGUI.EndChangeCheck()) { startFrame = Mathf.Clamp(newStart, 0, totalFrames); endFrame = Mathf.Clamp(newEnd, 0, totalFrames); // 시작이 종료보다 크면 교체 if (startFrame > endFrame) (startFrame, endFrame) = (endFrame, startFrame); } // 시간 표시 float startTime = startFrame / clipFrameRate; float endTime = endFrame / clipFrameRate; float duration = endTime - startTime; EditorGUILayout.Space(4f); EditorGUILayout.LabelField($" 구간: {startTime:F3}초 ~ {endTime:F3}초"); EditorGUILayout.LabelField($" 길이: {duration:F3}초 ({endFrame - startFrame}프레임)"); // 유효성 경고 if (startFrame == 0 && endFrame == totalFrames) { EditorGUILayout.HelpBox("전체 구간이 선택되었습니다. 변경 후 잘라내기를 진행하세요.", MessageType.Info); } EditorGUILayout.Space(12f); // 잘라내기 버튼 EditorGUI.BeginDisabledGroup(startFrame == 0 && endFrame == totalFrames); if (GUILayout.Button("잘라내기 (새 클립 생성)", GUILayout.Height(32f))) { PerformTrim(); } EditorGUI.EndDisabledGroup(); } /// /// 지정된 프레임 구간만 포함하는 새 AnimationClip을 생성합니다. /// private void PerformTrim() { float startTime = startFrame / clipFrameRate; float endTime = endFrame / clipFrameRate; // 결과 클립 생성 string sourcePath = AssetDatabase.GetAssetPath(sourceClip); string directory = System.IO.Path.GetDirectoryName(sourcePath); string trimmedName = $"{sourceClip.name}_Trimmed_F{startFrame}_F{endFrame}"; string savePath = System.IO.Path.Combine(directory, trimmedName + ".anim") .Replace("\\", "/"); savePath = AssetDatabase.GenerateUniqueAssetPath(savePath); AnimationClip trimmedClip = new AnimationClip { name = trimmedName, frameRate = sourceClip.frameRate, legacy = sourceClip.legacy, wrapMode = sourceClip.wrapMode }; // 커브 복사 (시간 오프셋 반영) EditorCurveBinding[] bindings = AnimationUtility.GetCurveBindings(sourceClip); foreach (EditorCurveBinding binding in bindings) { AnimationCurve sourceCurve = AnimationUtility.GetEditorCurve(sourceClip, binding); if (sourceCurve == null || sourceCurve.keys.Length == 0) continue; AnimationCurve trimmedCurve = TrimCurve(sourceCurve, startTime, endTime); if (trimmedCurve != null && trimmedCurve.keys.Length > 0) { trimmedClip.SetCurve(binding.path, binding.type, binding.propertyName, trimmedCurve); } } // AnimationEvent 복사 (시간 오프셋 반영) AnimationEvent[] sourceEvents = AnimationUtility.GetAnimationEvents(sourceClip); if (sourceEvents.Length > 0) { List trimmedEvents = new List(); foreach (AnimationEvent evt in sourceEvents) { if (evt.time >= startTime && evt.time <= endTime) { trimmedEvents.Add(new AnimationEvent { time = evt.time - startTime, functionName = evt.functionName, floatParameter = evt.floatParameter, intParameter = evt.intParameter, stringParameter = evt.stringParameter, objectReferenceParameter = evt.objectReferenceParameter, messageOptions = evt.messageOptions }); } } if (trimmedEvents.Count > 0) AnimationUtility.SetAnimationEvents(trimmedClip, trimmedEvents.ToArray()); } // Object reference 커브도 함께 복사해 VFX/소품 전환이 유지되도록 합니다. EditorCurveBinding[] objectBindings = AnimationUtility.GetObjectReferenceCurveBindings(sourceClip); foreach (EditorCurveBinding binding in objectBindings) { ObjectReferenceKeyframe[] sourceKeys = AnimationUtility.GetObjectReferenceCurve(sourceClip, binding); ObjectReferenceKeyframe[] trimmedKeys = TrimObjectReferenceCurve(sourceKeys, startTime, endTime); if (trimmedKeys != null && trimmedKeys.Length > 0) { AnimationUtility.SetObjectReferenceCurve(trimmedClip, binding, trimmedKeys); } } // 저장 AssetDatabase.CreateAsset(trimmedClip, savePath); CopyClipSettings(sourceClip, trimmedClip, endTime - startTime); AssetDatabase.SaveAssets(); EditorUtility.FocusProjectWindow(); Selection.activeObject = trimmedClip; Debug.Log( $"[AnimationClipTrimmer] 잘라내기 완료: {sourceClip.name} " + $"F{startFrame}~F{endFrame} ({endTime - startTime:F3}초) → {savePath}"); Close(); } /// /// 커브에서 startTime ~ endTime 구간의 키 프레임만 추출하고 시간을 0부터 시작하도록 오프셋합니다. /// private static AnimationCurve TrimCurve(AnimationCurve curve, float startTime, float endTime) { if (curve == null || endTime < startTime) return null; Keyframe[] sourceKeys = curve.keys; List trimmedKeys = new List(); float duration = endTime - startTime; foreach (Keyframe key in sourceKeys) { if (key.time >= startTime && key.time <= endTime) { trimmedKeys.Add(new Keyframe( key.time - startTime, key.value, key.inTangent, key.outTangent )); } } // 선택 구간에 기존 키가 없더라도 경계 값을 샘플링해 고정 구간을 유지합니다. if (trimmedKeys.Count == 0) { float startValue = curve.Evaluate(startTime); float endValue = curve.Evaluate(endTime); trimmedKeys.Add(new Keyframe(0f, startValue)); if (duration > Mathf.Epsilon) trimmedKeys.Add(new Keyframe(duration, endValue)); } else { float startValue = curve.Evaluate(startTime); float endValue = curve.Evaluate(endTime); if (!Mathf.Approximately(trimmedKeys[0].time, 0f)) { trimmedKeys.Insert(0, new Keyframe(0f, startValue)); } else { Keyframe firstKey = trimmedKeys[0]; firstKey.time = 0f; firstKey.value = startValue; trimmedKeys[0] = firstKey; } if (duration > Mathf.Epsilon) { int lastIndex = trimmedKeys.Count - 1; if (!Mathf.Approximately(trimmedKeys[lastIndex].time, duration)) { trimmedKeys.Add(new Keyframe(duration, endValue)); } else { Keyframe lastKey = trimmedKeys[lastIndex]; lastKey.time = duration; lastKey.value = endValue; trimmedKeys[lastIndex] = lastKey; } } } return new AnimationCurve(trimmedKeys.ToArray()) { preWrapMode = curve.preWrapMode, postWrapMode = curve.postWrapMode }; } /// /// Object reference 커브에서 선택 구간만 잘라내고 시간을 0부터 다시 시작합니다. /// private static ObjectReferenceKeyframe[] TrimObjectReferenceCurve( ObjectReferenceKeyframe[] sourceKeys, float startTime, float endTime) { if (sourceKeys == null || sourceKeys.Length == 0 || endTime < startTime) return null; List trimmedKeys = new List(); float duration = endTime - startTime; for (int i = 0; i < sourceKeys.Length; i++) { ObjectReferenceKeyframe key = sourceKeys[i]; if (key.time >= startTime && key.time <= endTime) { trimmedKeys.Add(new ObjectReferenceKeyframe { time = key.time - startTime, value = key.value }); } } if (trimmedKeys.Count == 0) { ObjectReferenceKeyframe sampledKey = sourceKeys[0]; for (int i = sourceKeys.Length - 1; i >= 0; i--) { if (sourceKeys[i].time <= startTime) { sampledKey = sourceKeys[i]; break; } } trimmedKeys.Add(new ObjectReferenceKeyframe { time = 0f, value = sampledKey.value }); } if (!Mathf.Approximately(trimmedKeys[0].time, 0f)) { trimmedKeys.Insert(0, new ObjectReferenceKeyframe { time = 0f, value = trimmedKeys[0].value }); } int lastIndex = trimmedKeys.Count - 1; if (duration > Mathf.Epsilon && !Mathf.Approximately(trimmedKeys[lastIndex].time, duration)) { trimmedKeys.Add(new ObjectReferenceKeyframe { time = duration, value = trimmedKeys[lastIndex].value }); } return trimmedKeys.ToArray(); } /// /// 원본 클립의 루트 모션/루프/미러 설정을 새 클립에 유지합니다. /// private static void CopyClipSettings(AnimationClip source, AnimationClip target, float duration) { if (source == null || target == null) return; SerializedObject sourceSerializedObject = new SerializedObject(source); SerializedObject targetSerializedObject = new SerializedObject(target); SerializedProperty sourceSettings = sourceSerializedObject.FindProperty("m_AnimationClipSettings"); SerializedProperty targetSettings = targetSerializedObject.FindProperty("m_AnimationClipSettings"); if (sourceSettings == null || targetSettings == null) return; string[] relativePropertyNames = { "m_AdditiveReferencePoseClip", "m_AdditiveReferencePoseTime", "m_OrientationOffsetY", "m_Level", "m_CycleOffset", "m_HasAdditiveReferencePose", "m_LoopTime", "m_LoopBlend", "m_LoopBlendOrientation", "m_LoopBlendPositionY", "m_LoopBlendPositionXZ", "m_KeepOriginalOrientation", "m_KeepOriginalPositionY", "m_KeepOriginalPositionXZ", "m_HeightFromFeet", "m_Mirror" }; for (int i = 0; i < relativePropertyNames.Length; i++) { SerializedProperty sourceProperty = sourceSettings.FindPropertyRelative(relativePropertyNames[i]); SerializedProperty targetProperty = targetSettings.FindPropertyRelative(relativePropertyNames[i]); if (sourceProperty == null || targetProperty == null) continue; CopySerializedPropertyValue(sourceProperty, targetProperty); } SerializedProperty startTimeProperty = targetSettings.FindPropertyRelative("m_StartTime"); if (startTimeProperty != null) startTimeProperty.floatValue = 0f; SerializedProperty stopTimeProperty = targetSettings.FindPropertyRelative("m_StopTime"); if (stopTimeProperty != null) stopTimeProperty.floatValue = Mathf.Max(0f, duration); targetSerializedObject.ApplyModifiedPropertiesWithoutUndo(); } private static void CopySerializedPropertyValue(SerializedProperty source, SerializedProperty target) { switch (source.propertyType) { case SerializedPropertyType.Integer: target.intValue = source.intValue; break; case SerializedPropertyType.Boolean: target.boolValue = source.boolValue; break; case SerializedPropertyType.Float: target.floatValue = source.floatValue; break; case SerializedPropertyType.ObjectReference: target.objectReferenceValue = source.objectReferenceValue; break; default: break; } } }