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;
}
}
}