using System; using System.Collections.Generic; using System.IO; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Unity.Pipeline.Commands; using Unity.Pipeline.Editor.Authoring; using Unity.Pipeline.Editor.Commands.GameObjects; using Unity.Pipeline.Models; using UnityEditor; using UnityEngine; namespace Unity.Pipeline.Editor.Commands.Animation { /// /// Group A of CLI-214: author assets and their float curves. /// /// These sit on the CLI-190 authoring foundation, so: /// - every agent-supplied asset path is funnelled through /// (sandboxed to the authoring root), /// - an existing clip is addressed with an resolved by /// and re-confined to the authoring root, /// - destructive/overwriting operations require an explicit confirm argument and every /// command supports dry_run. /// /// NOTE: AnimationClip sub-object edits (curves, clip settings) are NOT covered by Unity's Undo, /// so changes are persisted with + /// rather than wrapped in an . Only float curves are supported in /// v1 (object-reference / PPtr curves are out of scope). /// public static class AnimationClipCommands { [CliCommand("create_animation_clip", "Create an empty .anim AnimationClip asset under the authoring root, with an optional frame rate and loop flag.", MainThreadRequired = true)] public static AuthoringResult CreateAnimationClip( [CliArg("path", "Asset path ending in .anim, relative to the authoring root. The Assets/ prefix is optional.", Required = true)] string path, [CliArg("frameRate", "Sampling frame rate of the clip (default 60).")] float frameRate = 60f, [CliArg("loop", "If true, set the clip's loop-time flag in its AnimationClipSettings (default false).")] bool loop = false, [CliArg("confirm", "Required (true) only when overwriting an existing asset at the path.")] bool confirm = false, [CliArg("dry_run", "If true, validate inputs and report what would be created without writing anything.")] bool dryRun = false) { var normalized = ProjectPaths.Resolve(path, out var error); if (normalized == null) throw new ArgumentException(error); if (!string.Equals(Path.GetExtension(normalized), ".anim", StringComparison.OrdinalIgnoreCase)) throw new ArgumentException($"Animation clip path '{normalized}' must end in .anim."); if (frameRate <= 0f) throw new ArgumentException($"frameRate must be positive (got {frameRate})."); var exists = AssetDatabase.LoadMainAssetAtPath(normalized) != null; if (exists && !confirm) throw new ArgumentException($"An asset already exists at '{normalized}'. Pass confirm=true to overwrite it."); if (dryRun) return new AuthoringResult { AssetPath = normalized, Type = nameof(AnimationClip) }; EnsureParentFolder(normalized); var clip = new AnimationClip { frameRate = frameRate }; var settings = AnimationUtility.GetAnimationClipSettings(clip); settings.loopTime = loop; AnimationUtility.SetAnimationClipSettings(clip, settings); // CreateAsset does not reliably overwrite, so remove an existing asset first (the confirm // guard above gates that one already exists). if (exists) AssetDatabase.DeleteAsset(normalized); AssetDatabase.CreateAsset(clip, normalized); EditorUtility.SetDirty(clip); AssetDatabase.SaveAssets(); AssetDatabase.ImportAsset(normalized); var loaded = AssetDatabase.LoadMainAssetAtPath(normalized); var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = nameof(AnimationClip) }; result.AssetPath = normalized; return result; } [CliCommand("set_animation_curve", "Add or replace a single float curve binding on an AnimationClip (via AnimationUtility.SetEditorCurve). " + "Replacing an existing binding overwrites it rather than duplicating.", MainThreadRequired = true)] public static SetAnimationCurveResult SetAnimationCurve( [CliArg("clip", "Reference to the AnimationClip to edit (path / guid / globalId).", Required = true)] ObjectRef clip, [CliArg("path", "GameObject path relative to the animated root the property lives on. Empty string (default) targets the root.")] string path = "", [CliArg("type", "Component type the property lives on, e.g. \"Transform\", \"UnityEngine.Light\". Resolved via the component TypeResolver.", Required = true)] string type = null, [CliArg("property", "Curve property name, e.g. \"m_LocalPosition.x\", \"m_LocalScale.y\", \"localEulerAnglesRaw.z\".", Required = true)] string property = null, [CliArg("keys", "Keyframes: [{ time, value, inTangent?, outTangent?, weightedMode?: \"None\"|\"In\"|\"Out\"|\"Both\" }]. Omitted tangents default to 0 (flat); this is NOT Unity's Auto tangent mode.", Required = true)] JArray keys = null, [CliArg("dry_run", "If true, validate type/property/keys without writing the curve.")] bool dryRun = false) { var (clipAsset, clipPath) = ResolveClip(clip); var componentType = ResolveCurveType(type); if (string.IsNullOrWhiteSpace(property)) throw new ArgumentException("property is required."); var bindingPath = path ?? string.Empty; var curve = BuildCurve(keys, out var keyCount); var result = new SetAnimationCurveResult { AssetPath = clipPath, Type = nameof(AnimationClip), Binding = new CurveBinding { Path = bindingPath, Type = componentType.Name, Property = property }, KeyCount = keyCount }; if (dryRun) return result; var binding = EditorCurveBinding.FloatCurve(bindingPath, componentType, property); AnimationUtility.SetEditorCurve(clipAsset, binding, curve); EditorUtility.SetDirty(clipAsset); AssetDatabase.SaveAssets(); var described = ObjectResolver.Describe(clipAsset); if (described != null) { result.Guid = described.Guid; result.FileId = described.FileId; result.GlobalId = described.GlobalId; } return result; } [CliCommand("get_animation_clip", "Read an AnimationClip's metadata and all float curve bindings (optionally with keyframes).", MainThreadRequired = true)] public static AnimationClipInfo GetAnimationClip( [CliArg("clip", "Reference to the AnimationClip to read (path / guid / globalId).", Required = true)] ObjectRef clip, [CliArg("includeKeys", "If true, include each binding's keyframes (default false).")] bool includeKeys = false) { var (clipAsset, clipPath) = ResolveClip(clip); var settings = AnimationUtility.GetAnimationClipSettings(clipAsset); var info = new AnimationClipInfo { AssetPath = clipPath, FrameRate = clipAsset.frameRate, Length = clipAsset.length, Loop = settings.loopTime }; foreach (var binding in AnimationUtility.GetCurveBindings(clipAsset)) { var curve = AnimationUtility.GetEditorCurve(clipAsset, binding); var keyCount = curve?.length ?? 0; var entry = new CurveBindingInfo { Path = binding.path, Type = binding.type != null ? binding.type.Name : null, Property = binding.propertyName, KeyCount = keyCount }; if (includeKeys && curve != null) { entry.Keys = new List(keyCount); foreach (var key in curve.keys) { entry.Keys.Add(new KeyframeInfo { Time = key.time, Value = key.value, InTangent = key.inTangent, OutTangent = key.outTangent }); } } info.Bindings.Add(entry); } return info; } [CliCommand("remove_animation_curve", "Remove a float curve binding from an AnimationClip (SetEditorCurve(clip, binding, null)). Destructive: requires confirm=true.", MainThreadRequired = true)] public static SetAnimationCurveResult RemoveAnimationCurve( [CliArg("clip", "Reference to the AnimationClip to edit (path / guid / globalId).", Required = true)] ObjectRef clip, [CliArg("path", "GameObject path relative to the animated root the binding lives on. Empty string (default) targets the root.")] string path = "", [CliArg("type", "Component type of the binding to remove, e.g. \"Transform\". Resolved via the component TypeResolver.", Required = true)] string type = null, [CliArg("property", "Curve property name to remove, e.g. \"m_LocalPosition.x\".", Required = true)] string property = null, [CliArg("confirm", "Must be true to actually remove the binding (destructive guard).")] bool confirm = false, [CliArg("dry_run", "If true, report the binding that would be removed without removing it.")] bool dryRun = false) { var (clipAsset, clipPath) = ResolveClip(clip); var componentType = ResolveCurveType(type); if (string.IsNullOrWhiteSpace(property)) throw new ArgumentException("property is required."); var bindingPath = path ?? string.Empty; var binding = EditorCurveBinding.FloatCurve(bindingPath, componentType, property); var existing = AnimationUtility.GetEditorCurve(clipAsset, binding); if (existing == null) throw new ArgumentException( $"No curve binding for path='{bindingPath}', type='{componentType.Name}', property='{property}' on '{clipPath}'."); var keyCount = existing.length; var result = new SetAnimationCurveResult { AssetPath = clipPath, Type = nameof(AnimationClip), Binding = new CurveBinding { Path = bindingPath, Type = componentType.Name, Property = property }, KeyCount = keyCount }; if (dryRun) return result; if (!confirm) throw new ArgumentException( $"Refusing to remove curve '{property}' from '{clipPath}'. Pass confirm=true (destructive, not undoable via Unity's Undo)."); AnimationUtility.SetEditorCurve(clipAsset, binding, null); EditorUtility.SetDirty(clipAsset); AssetDatabase.SaveAssets(); return result; } /// /// Resolve a clip handle to a loaded and its sandbox-confined asset /// path, rejecting handles that don't point at an on-disk AnimationClip or that escape the root. /// private static (AnimationClip clip, string path) ResolveClip(ObjectRef clip) { if (clip == null || clip.IsEmpty) throw new ArgumentException("clip is required."); if (!ObjectResolver.TryResolve(clip, out var obj, out var error)) throw new ArgumentException(error); if (!(obj is AnimationClip clipAsset)) throw new ArgumentException($"Reference '{clip}' resolved to a {obj.GetType().Name}, not an AnimationClip."); var assetPath = AssetDatabase.GetAssetPath(clipAsset); if (string.IsNullOrEmpty(assetPath)) throw new ArgumentException($"Reference '{clip}' does not point at an on-disk AnimationClip."); var confined = ProjectPaths.Resolve(assetPath, out var confineError); if (confined == null) throw new ArgumentException( $"Clip '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}"); return (clipAsset, confined); } /// /// Resolve a curve-binding component type. Reuses the component , /// which only accepts subclasses (curves bind to component properties). /// private static Type ResolveCurveType(string type) { if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("type is required."); var resolved = TypeResolver.ResolveComponentType(type); if (resolved == null) throw new ArgumentException( $"Could not resolve component type '{type}'. Use a short name (e.g. Transform) or a fully-qualified name (e.g. UnityEngine.Light)."); return resolved; } /// Build an from the agent-supplied keyframe array. private static AnimationCurve BuildCurve(JArray keys, out int keyCount) { if (keys == null || keys.Count == 0) throw new ArgumentException("keys must be a non-empty array of { time, value, ... }."); var keyframes = new List(keys.Count); foreach (var token in keys) { if (!(token is JObject keyObj)) throw new ArgumentException("Each key must be an object: { time, value, inTangent?, outTangent?, weightedMode? }."); if (keyObj["time"] == null || keyObj["value"] == null) throw new ArgumentException("Each key requires a 'time' and a 'value'."); var time = keyObj["time"].ToObject(); var value = keyObj["value"].ToObject(); var inTangent = keyObj["inTangent"]?.ToObject() ?? 0f; var outTangent = keyObj["outTangent"]?.ToObject() ?? 0f; var keyframe = new Keyframe(time, value, inTangent, outTangent); var weightedToken = keyObj["weightedMode"]; if (weightedToken != null && weightedToken.Type != JTokenType.Null) { var name = weightedToken.ToObject(); if (!Enum.TryParse(name, ignoreCase: true, out var weightedMode)) throw new ArgumentException($"Unknown weightedMode '{name}'. Use None | In | Out | Both."); keyframe.weightedMode = weightedMode; } keyframes.Add(keyframe); } keyCount = keyframes.Count; return new AnimationCurve(keyframes.ToArray()); } /// Create the asset's parent folder chain if it does not yet exist. private static void EnsureParentFolder(string assetPath) { var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/'); if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent)) return; CreateFolderRecursive(parent); } private static void CreateFolderRecursive(string assetsPath) { if (AssetDatabase.IsValidFolder(assetsPath)) return; var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/'); var name = Path.GetFileName(assetsPath); if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name)) throw new ArgumentException($"Invalid folder path '{assetsPath}'."); if (!AssetDatabase.IsValidFolder(parent)) CreateFolderRecursive(parent); AssetDatabase.CreateFolder(parent, name); } } /// /// Result of set_animation_curve / remove_animation_curve: the clip identity extended /// with the affected binding and its key count. /// [Serializable] public class SetAnimationCurveResult : AuthoringResult { [JsonProperty("binding")] public CurveBinding Binding { get; set; } [JsonProperty("keyCount")] public int KeyCount { get; set; } } /// A curve binding's address: GameObject path, component type, and property name. [Serializable] public class CurveBinding { [JsonProperty("path")] public string Path { get; set; } [JsonProperty("type")] public string Type { get; set; } [JsonProperty("property")] public string Property { get; set; } } /// Result of get_animation_clip: clip metadata and its curve bindings. [Serializable] public class AnimationClipInfo { [JsonProperty("assetPath")] public string AssetPath { get; set; } [JsonProperty("frameRate")] public float FrameRate { get; set; } [JsonProperty("length")] public float Length { get; set; } [JsonProperty("loop")] public bool Loop { get; set; } [JsonProperty("bindings")] public List Bindings { get; set; } = new List(); } /// A single curve binding in , optionally with its keys. [Serializable] public class CurveBindingInfo { [JsonProperty("path")] public string Path { get; set; } [JsonProperty("type")] public string Type { get; set; } [JsonProperty("property")] public string Property { get; set; } [JsonProperty("keyCount")] public int KeyCount { get; set; } [JsonProperty("keys", NullValueHandling = NullValueHandling.Ignore)] public List Keys { get; set; } } /// A single keyframe in a curve binding. [Serializable] public class KeyframeInfo { [JsonProperty("time")] public float Time { get; set; } [JsonProperty("value")] public float Value { get; set; } [JsonProperty("inTangent")] public float InTangent { get; set; } [JsonProperty("outTangent")] public float OutTangent { get; set; } } }