Add Unity Pipeline package support
This commit is contained in:
+423
@@ -0,0 +1,423 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Group A of CLI-214: author <see cref="AnimationClip"/> assets and their float curves.
|
||||
///
|
||||
/// These sit on the CLI-190 authoring foundation, so:
|
||||
/// - every agent-supplied asset path is funnelled through <see cref="ProjectPaths.Resolve"/>
|
||||
/// (sandboxed to the authoring root),
|
||||
/// - an existing clip is addressed with an <see cref="ObjectRef"/> resolved by
|
||||
/// <see cref="ObjectResolver"/> and re-confined to the authoring root,
|
||||
/// - destructive/overwriting operations require an explicit <c>confirm</c> argument and every
|
||||
/// command supports <c>dry_run</c>.
|
||||
///
|
||||
/// NOTE: AnimationClip sub-object edits (curves, clip settings) are NOT covered by Unity's Undo,
|
||||
/// so changes are persisted with <see cref="EditorUtility.SetDirty"/> + <see cref="AssetDatabase.SaveAssets"/>
|
||||
/// rather than wrapped in an <see cref="AuthoringUndoScope"/>. Only float curves are supported in
|
||||
/// v1 (object-reference / PPtr curves are out of scope).
|
||||
/// </summary>
|
||||
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<KeyframeInfo>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a clip handle to a loaded <see cref="AnimationClip"/> and its sandbox-confined asset
|
||||
/// path, rejecting handles that don't point at an on-disk AnimationClip or that escape the root.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a curve-binding component type. Reuses the component <see cref="TypeResolver"/>,
|
||||
/// which only accepts <see cref="Component"/> subclasses (curves bind to component properties).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Build an <see cref="AnimationCurve"/> from the agent-supplied keyframe array.</summary>
|
||||
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<Keyframe>(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<float>();
|
||||
var value = keyObj["value"].ToObject<float>();
|
||||
var inTangent = keyObj["inTangent"]?.ToObject<float>() ?? 0f;
|
||||
var outTangent = keyObj["outTangent"]?.ToObject<float>() ?? 0f;
|
||||
|
||||
var keyframe = new Keyframe(time, value, inTangent, outTangent);
|
||||
|
||||
var weightedToken = keyObj["weightedMode"];
|
||||
if (weightedToken != null && weightedToken.Type != JTokenType.Null)
|
||||
{
|
||||
var name = weightedToken.ToObject<string>();
|
||||
if (!Enum.TryParse<WeightedMode>(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());
|
||||
}
|
||||
|
||||
/// <summary>Create the asset's parent folder chain if it does not yet exist.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <c>set_animation_curve</c> / <c>remove_animation_curve</c>: the clip identity extended
|
||||
/// with the affected binding and its key count.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class SetAnimationCurveResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("binding")]
|
||||
public CurveBinding Binding { get; set; }
|
||||
|
||||
[JsonProperty("keyCount")]
|
||||
public int KeyCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A curve binding's address: GameObject path, component type, and property name.</summary>
|
||||
[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; }
|
||||
}
|
||||
|
||||
/// <summary>Result of <c>get_animation_clip</c>: clip metadata and its curve bindings.</summary>
|
||||
[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<CurveBindingInfo> Bindings { get; set; } = new List<CurveBindingInfo>();
|
||||
}
|
||||
|
||||
/// <summary>A single curve binding in <see cref="AnimationClipInfo"/>, optionally with its keys.</summary>
|
||||
[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<KeyframeInfo> Keys { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A single keyframe in a curve binding.</summary>
|
||||
[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; }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 764503eaf79a41668748014af111eaf2
|
||||
+885
@@ -0,0 +1,885 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// Group B of CLI-214: author <see cref="AnimatorController"/> assets — layers, parameters, states
|
||||
/// and transitions. Built on <c>UnityEditor.Animations</c>, which is part of the Editor (no extra
|
||||
/// package), so the types are referenced directly.
|
||||
///
|
||||
/// Like the animation-clip commands, paths/handles are sandbox-confined via
|
||||
/// <see cref="ProjectPaths"/> / <see cref="ObjectResolver"/>, and every command supports
|
||||
/// <c>dry_run</c>. Some AnimatorController API calls register Undo internally, but sub-object edits
|
||||
/// are not reliably undoable, so changes are persisted with <see cref="EditorUtility.SetDirty"/> +
|
||||
/// <see cref="AssetDatabase.SaveAssets"/>.
|
||||
///
|
||||
/// Out of scope (v1): BlendTree authoring (assigning an EXISTING BlendTree as a state motion is
|
||||
/// allowed), StateMachineBehaviours, sub-state machines, and humanoid/avatar configuration.
|
||||
/// </summary>
|
||||
public static class AnimatorControllerCommands
|
||||
{
|
||||
[CliCommand("create_animator_controller",
|
||||
"Create an .controller AnimatorController asset (with a default Base Layer) under the authoring root.",
|
||||
MainThreadRequired = true)]
|
||||
public static AuthoringResult CreateAnimatorController(
|
||||
[CliArg("path", "Asset path ending in .controller, relative to the authoring root. The Assets/ prefix is optional.", Required = true)] string path,
|
||||
[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), ".controller", StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException($"Animator controller path '{normalized}' must end in .controller.");
|
||||
|
||||
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(AnimatorController) };
|
||||
|
||||
EnsureParentFolder(normalized);
|
||||
|
||||
if (exists)
|
||||
AssetDatabase.DeleteAsset(normalized);
|
||||
|
||||
// CreateAnimatorControllerAtPath creates the asset on disk WITH a default "Base Layer".
|
||||
var controller = AnimatorController.CreateAnimatorControllerAtPath(normalized);
|
||||
EditorUtility.SetDirty(controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = nameof(AnimatorController) };
|
||||
result.AssetPath = normalized;
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("add_animator_parameter",
|
||||
"Add a parameter (Float | Int | Bool | Trigger) to an AnimatorController. A duplicate name returns code 'duplicate_parameter'.",
|
||||
MainThreadRequired = true)]
|
||||
public static object AddAnimatorParameter(
|
||||
[CliArg("controller", "Reference to the AnimatorController to edit (path / guid / globalId).", Required = true)] ObjectRef controller,
|
||||
[CliArg("name", "Parameter name.", Required = true)] string name,
|
||||
[CliArg("type", "Parameter type: Float | Int | Bool | Trigger.", Required = true)] string type = null,
|
||||
[CliArg("defaultValue", "Default value for Float/Int/Bool (ignored for Trigger).")] JToken defaultValue = null,
|
||||
[CliArg("dry_run", "If true, validate inputs without writing the parameter.")] bool dryRun = false)
|
||||
{
|
||||
var (ctrl, ctrlPath) = ResolveController(controller);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("name is required.");
|
||||
|
||||
var paramType = ParseParameterType(type);
|
||||
|
||||
if (ctrl.parameters.Any(p => p.name == name))
|
||||
return ErrorResult.PackageStyle("duplicate_parameter", $"A parameter named '{name}' already exists on '{ctrlPath}'.");
|
||||
|
||||
// Resolve (and validate) the default value up front so dry_run also fails on a bad value.
|
||||
float defFloat = 0f;
|
||||
int defInt = 0;
|
||||
bool defBool = false;
|
||||
switch (paramType)
|
||||
{
|
||||
case AnimatorControllerParameterType.Float:
|
||||
if (defaultValue != null && defaultValue.Type != JTokenType.Null) defFloat = defaultValue.ToObject<float>();
|
||||
break;
|
||||
case AnimatorControllerParameterType.Int:
|
||||
if (defaultValue != null && defaultValue.Type != JTokenType.Null) defInt = defaultValue.ToObject<int>();
|
||||
break;
|
||||
case AnimatorControllerParameterType.Bool:
|
||||
if (defaultValue != null && defaultValue.Type != JTokenType.Null) defBool = defaultValue.ToObject<bool>();
|
||||
break;
|
||||
}
|
||||
|
||||
var result = new AddParameterResult
|
||||
{
|
||||
AssetPath = ctrlPath,
|
||||
Type = nameof(AnimatorController),
|
||||
Parameter = new ParameterInfo
|
||||
{
|
||||
Name = name,
|
||||
ParamType = paramType.ToString(),
|
||||
DefaultValue = DefaultValueToken(paramType, defFloat, defInt, defBool)
|
||||
}
|
||||
};
|
||||
|
||||
if (dryRun)
|
||||
return result;
|
||||
|
||||
var parameter = new AnimatorControllerParameter
|
||||
{
|
||||
name = name,
|
||||
type = paramType,
|
||||
defaultFloat = defFloat,
|
||||
defaultInt = defInt,
|
||||
defaultBool = defBool
|
||||
};
|
||||
ctrl.AddParameter(parameter);
|
||||
|
||||
Persist(ctrl);
|
||||
FillIdentity(result, ctrl);
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("add_animator_layer",
|
||||
"Add a layer to an AnimatorController.",
|
||||
MainThreadRequired = true)]
|
||||
public static object AddAnimatorLayer(
|
||||
[CliArg("controller", "Reference to the AnimatorController to edit (path / guid / globalId).", Required = true)] ObjectRef controller,
|
||||
[CliArg("name", "Layer name.", Required = true)] string name,
|
||||
[CliArg("weight", "Layer weight (default 1).")] float weight = 1f,
|
||||
[CliArg("blendingMode", "Blending mode: Override | Additive (default Override).")] string blendingMode = "Override",
|
||||
[CliArg("dry_run", "If true, validate inputs without writing the layer.")] bool dryRun = false)
|
||||
{
|
||||
var (ctrl, ctrlPath) = ResolveController(controller);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("name is required.");
|
||||
|
||||
if (!Enum.TryParse<AnimatorLayerBlendingMode>(blendingMode, ignoreCase: true, out var mode))
|
||||
throw new ArgumentException($"Unknown blendingMode '{blendingMode}'. Use Override | Additive.");
|
||||
|
||||
var index = ctrl.layers.Length;
|
||||
var result = new AddLayerResult
|
||||
{
|
||||
AssetPath = ctrlPath,
|
||||
Type = nameof(AnimatorController),
|
||||
Layer = new LayerSummary { Name = name, Index = index, Weight = weight }
|
||||
};
|
||||
|
||||
if (dryRun)
|
||||
return result;
|
||||
|
||||
// AddLayer(name) appends a layer with a fresh state machine. We then set its weight/blending
|
||||
// by writing the layer array back (AnimatorControllerLayer is a value-like wrapper, so the
|
||||
// edited copy must be reassigned).
|
||||
ctrl.AddLayer(name);
|
||||
var layers = ctrl.layers;
|
||||
layers[index].defaultWeight = weight;
|
||||
layers[index].blendingMode = mode;
|
||||
ctrl.layers = layers;
|
||||
|
||||
Persist(ctrl);
|
||||
FillIdentity(result, ctrl);
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("add_animator_state",
|
||||
"Add a state to a layer, optionally with a motion (AnimationClip or BlendTree) and as the layer default. " +
|
||||
"A layer name with no match returns code 'layer_not_found'.",
|
||||
MainThreadRequired = true)]
|
||||
public static object AddAnimatorState(
|
||||
[CliArg("controller", "Reference to the AnimatorController to edit (path / guid / globalId).", Required = true)] ObjectRef controller,
|
||||
[CliArg("layer", "Layer index (int) or name (string). Default 0 (Base Layer).")] JToken layer = null,
|
||||
[CliArg("name", "State name.", Required = true)] string name = null,
|
||||
[CliArg("motion", "Optional AnimationClip or BlendTree asset to assign as the state's motion.")] ObjectRef motion = null,
|
||||
[CliArg("isDefault", "If true, set this state as the layer's default state.")] bool isDefault = false,
|
||||
[CliArg("position", "Optional [x, y] node position in the graph (cosmetic).")] JArray position = null,
|
||||
[CliArg("dry_run", "If true, validate inputs without writing the state.")] bool dryRun = false)
|
||||
{
|
||||
var (ctrl, ctrlPath) = ResolveController(controller);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("name is required.");
|
||||
|
||||
if (!TryResolveLayer(ctrl, layer, out var layerIndex, out var layerError))
|
||||
return ErrorResult.PackageStyle("layer_not_found", layerError);
|
||||
|
||||
// Resolve an optional motion. Only AnimationClip / Motion (BlendTree derives from Motion) are
|
||||
// valid. A handle that doesn't resolve to a Motion is a clear error.
|
||||
UnityEngine.Motion resolvedMotion = null;
|
||||
if (motion != null && !motion.IsEmpty)
|
||||
{
|
||||
if (!ObjectResolver.TryResolve(motion, out var motionObj, out var motionError))
|
||||
throw new ArgumentException($"Could not resolve motion: {motionError}");
|
||||
resolvedMotion = motionObj as UnityEngine.Motion;
|
||||
if (resolvedMotion == null)
|
||||
throw new ArgumentException($"Motion reference '{motion}' resolved to a {motionObj.GetType().Name}, not an AnimationClip or BlendTree.");
|
||||
|
||||
// Re-confine the motion to the authoring root: a restricted root must not be bypassed by
|
||||
// referencing an AnimationClip/BlendTree that lives outside the sandbox.
|
||||
ConfineToAuthoringRoot(resolvedMotion, "Motion", motion);
|
||||
}
|
||||
|
||||
Vector3? nodePosition = null;
|
||||
if (position != null && position.Count > 0)
|
||||
{
|
||||
if (position.Count < 2)
|
||||
throw new ArgumentException("position must be [x, y].");
|
||||
nodePosition = new Vector3(position[0].ToObject<float>(), position[1].ToObject<float>(), 0f);
|
||||
}
|
||||
|
||||
var result = new AddStateResult
|
||||
{
|
||||
AssetPath = ctrlPath,
|
||||
Type = nameof(AnimatorController),
|
||||
State = new StateSummary
|
||||
{
|
||||
Name = name,
|
||||
Layer = layerIndex,
|
||||
HasMotion = resolvedMotion != null,
|
||||
IsDefault = isDefault
|
||||
}
|
||||
};
|
||||
|
||||
if (dryRun)
|
||||
return result;
|
||||
|
||||
var stateMachine = ctrl.layers[layerIndex].stateMachine;
|
||||
var state = nodePosition.HasValue
|
||||
? stateMachine.AddState(name, nodePosition.Value)
|
||||
: stateMachine.AddState(name);
|
||||
|
||||
if (resolvedMotion != null)
|
||||
state.motion = resolvedMotion;
|
||||
|
||||
if (isDefault)
|
||||
stateMachine.defaultState = state;
|
||||
|
||||
Persist(ctrl);
|
||||
FillIdentity(result, ctrl);
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("add_animator_transition",
|
||||
"Add a transition between two states (or from AnyState/Entry, to Exit) on a layer, with optional conditions. " +
|
||||
"Validates that the states exist and each condition's parameter exists and its mode matches the parameter type.",
|
||||
MainThreadRequired = true)]
|
||||
public static object AddAnimatorTransition(
|
||||
[CliArg("controller", "Reference to the AnimatorController to edit (path / guid / globalId).", Required = true)] ObjectRef controller,
|
||||
[CliArg("layer", "Layer index (int) or name (string). Default 0 (Base Layer).")] JToken layer = null,
|
||||
[CliArg("fromState", "Source state name, or the special \"AnyState\" / \"Entry\".", Required = true)] string fromState = null,
|
||||
[CliArg("toState", "Destination state name, or the special \"Exit\".", Required = true)] string toState = null,
|
||||
[CliArg("conditions", "Optional conditions: [{ parameter, mode: \"If\"|\"IfNot\"|\"Greater\"|\"Less\"|\"Equals\"|\"NotEqual\", threshold? }].")] JArray conditions = null,
|
||||
[CliArg("hasExitTime", "If true, the transition uses exit time (default false).")] bool hasExitTime = false,
|
||||
[CliArg("exitTime", "Normalized exit time (0..1) when hasExitTime is set.")] float exitTime = 0f,
|
||||
[CliArg("duration", "Transition duration in seconds (default 0.25).")] float duration = 0.25f,
|
||||
[CliArg("hasFixedDuration", "If true, duration is in seconds; otherwise normalized (default true).")] bool hasFixedDuration = true,
|
||||
[CliArg("dry_run", "If true, validate everything (states, parameters, mode/type) without writing the transition.")] bool dryRun = false)
|
||||
{
|
||||
var (ctrl, ctrlPath) = ResolveController(controller);
|
||||
if (string.IsNullOrWhiteSpace(fromState))
|
||||
throw new ArgumentException("fromState is required.");
|
||||
if (string.IsNullOrWhiteSpace(toState))
|
||||
throw new ArgumentException("toState is required.");
|
||||
|
||||
// exitTime is normalized (0..1) and only meaningful with hasExitTime; duration must be
|
||||
// non-negative. Reject out-of-range values up front (writing nothing) rather than letting
|
||||
// Unity clamp them unpredictably.
|
||||
if (hasExitTime && (exitTime < 0f || exitTime > 1f))
|
||||
throw new ArgumentException($"exitTime must be in [0, 1] when hasExitTime is set (got {exitTime}).");
|
||||
if (duration < 0f)
|
||||
throw new ArgumentException($"duration must be >= 0 (got {duration}).");
|
||||
|
||||
if (!TryResolveLayer(ctrl, layer, out var layerIndex, out var layerError))
|
||||
return ErrorResult.PackageStyle("layer_not_found", layerError);
|
||||
|
||||
var stateMachine = ctrl.layers[layerIndex].stateMachine;
|
||||
|
||||
const string AnyState = "AnyState";
|
||||
const string Entry = "Entry";
|
||||
const string Exit = "Exit";
|
||||
|
||||
var fromIsAny = string.Equals(fromState, AnyState, StringComparison.OrdinalIgnoreCase);
|
||||
var fromIsEntry = string.Equals(fromState, Entry, StringComparison.OrdinalIgnoreCase);
|
||||
var toIsExit = string.Equals(toState, Exit, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Resolve the source state (unless it's AnyState/Entry, which are nodes on the machine).
|
||||
AnimatorState fromAnimState = null;
|
||||
if (!fromIsAny && !fromIsEntry)
|
||||
{
|
||||
fromAnimState = FindState(stateMachine, fromState);
|
||||
if (fromAnimState == null)
|
||||
throw new ArgumentException($"Source state '{fromState}' not found in layer {layerIndex}.");
|
||||
}
|
||||
|
||||
// Resolve the destination state (unless it's Exit).
|
||||
AnimatorState toAnimState = null;
|
||||
if (!toIsExit)
|
||||
{
|
||||
toAnimState = FindState(stateMachine, toState);
|
||||
if (toAnimState == null)
|
||||
throw new ArgumentException($"Destination state '{toState}' not found in layer {layerIndex}.");
|
||||
}
|
||||
|
||||
// Entry transitions cannot carry conditions/exit-time the same way; restrict combinations the
|
||||
// engine forbids so an agent gets a clear error rather than a malformed transition.
|
||||
if (fromIsEntry && toIsExit)
|
||||
throw new ArgumentException("A transition from Entry directly to Exit is not supported.");
|
||||
|
||||
// Validate conditions BEFORE creating anything (so a bad condition writes nothing).
|
||||
var parsedConditions = ParseConditions(conditions, ctrl);
|
||||
|
||||
var result = new AddTransitionResult
|
||||
{
|
||||
AssetPath = ctrlPath,
|
||||
Type = nameof(AnimatorController),
|
||||
Transition = new TransitionSummary
|
||||
{
|
||||
From = fromState,
|
||||
To = toState,
|
||||
ConditionCount = parsedConditions.Count
|
||||
}
|
||||
};
|
||||
|
||||
if (dryRun)
|
||||
return result;
|
||||
|
||||
// Create the transition node matching the from/to kinds.
|
||||
AnimatorStateTransition stateTransition = null;
|
||||
AnimatorTransition entryTransition = null;
|
||||
|
||||
if (fromIsEntry)
|
||||
{
|
||||
// Entry -> state. Entry transitions are plain AnimatorTransition (no exit time/duration).
|
||||
entryTransition = stateMachine.AddEntryTransition(toAnimState);
|
||||
}
|
||||
else if (fromIsAny)
|
||||
{
|
||||
stateTransition = toIsExit ? null : stateMachine.AddAnyStateTransition(toAnimState);
|
||||
if (stateTransition == null)
|
||||
throw new ArgumentException("An AnyState transition to Exit is not supported.");
|
||||
}
|
||||
else
|
||||
{
|
||||
stateTransition = toIsExit
|
||||
? fromAnimState.AddExitTransition()
|
||||
: fromAnimState.AddTransition(toAnimState);
|
||||
}
|
||||
|
||||
if (stateTransition != null)
|
||||
{
|
||||
stateTransition.hasExitTime = hasExitTime;
|
||||
stateTransition.exitTime = exitTime;
|
||||
stateTransition.hasFixedDuration = hasFixedDuration;
|
||||
stateTransition.duration = duration;
|
||||
foreach (var c in parsedConditions)
|
||||
stateTransition.AddCondition(c.Mode, c.Threshold, c.Parameter);
|
||||
}
|
||||
else if (entryTransition != null)
|
||||
{
|
||||
foreach (var c in parsedConditions)
|
||||
entryTransition.AddCondition(c.Mode, c.Threshold, c.Parameter);
|
||||
}
|
||||
|
||||
Persist(ctrl);
|
||||
FillIdentity(result, ctrl);
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("get_animator_controller",
|
||||
"Read an AnimatorController's full structure: parameters, layers, states (with motion / default), and transitions (with conditions).",
|
||||
MainThreadRequired = true)]
|
||||
public static AnimatorControllerInfo GetAnimatorController(
|
||||
[CliArg("controller", "Reference to the AnimatorController to read (path / guid / globalId).", Required = true)] ObjectRef controller)
|
||||
{
|
||||
var (ctrl, ctrlPath) = ResolveController(controller);
|
||||
|
||||
var info = new AnimatorControllerInfo { AssetPath = ctrlPath };
|
||||
|
||||
foreach (var p in ctrl.parameters)
|
||||
{
|
||||
info.Parameters.Add(new ParameterInfo
|
||||
{
|
||||
Name = p.name,
|
||||
ParamType = p.type.ToString(),
|
||||
DefaultValue = DefaultValueToken(p.type, p.defaultFloat, p.defaultInt, p.defaultBool)
|
||||
});
|
||||
}
|
||||
|
||||
for (int i = 0; i < ctrl.layers.Length; i++)
|
||||
{
|
||||
var layer = ctrl.layers[i];
|
||||
var sm = layer.stateMachine;
|
||||
|
||||
var layerInfo = new LayerInfo
|
||||
{
|
||||
Name = layer.name,
|
||||
Index = i,
|
||||
DefaultState = sm != null && sm.defaultState != null ? sm.defaultState.name : null
|
||||
};
|
||||
|
||||
if (sm != null)
|
||||
{
|
||||
foreach (var childState in sm.states)
|
||||
{
|
||||
var state = childState.state;
|
||||
layerInfo.States.Add(new StateInfo
|
||||
{
|
||||
Name = state.name,
|
||||
Motion = state.motion != null ? state.motion.name : null,
|
||||
IsDefault = sm.defaultState == state
|
||||
});
|
||||
|
||||
foreach (var t in state.transitions)
|
||||
layerInfo.Transitions.Add(DescribeTransition(state.name, t));
|
||||
}
|
||||
|
||||
foreach (var t in sm.anyStateTransitions)
|
||||
layerInfo.Transitions.Add(DescribeTransition("AnyState", t));
|
||||
}
|
||||
|
||||
info.Layers.Add(layerInfo);
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static TransitionInfo DescribeTransition(string from, AnimatorStateTransition t)
|
||||
{
|
||||
var to = t.isExit ? "Exit" : (t.destinationState != null ? t.destinationState.name : null);
|
||||
var ti = new TransitionInfo
|
||||
{
|
||||
From = from,
|
||||
To = to,
|
||||
HasExitTime = t.hasExitTime,
|
||||
Duration = t.duration
|
||||
};
|
||||
foreach (var c in t.conditions)
|
||||
{
|
||||
ti.Conditions.Add(new ConditionInfo
|
||||
{
|
||||
Parameter = c.parameter,
|
||||
Mode = c.mode.ToString(),
|
||||
Threshold = c.threshold
|
||||
});
|
||||
}
|
||||
return ti;
|
||||
}
|
||||
|
||||
private static AnimatorState FindState(AnimatorStateMachine sm, string name)
|
||||
{
|
||||
if (sm == null)
|
||||
return null;
|
||||
foreach (var childState in sm.states)
|
||||
{
|
||||
if (childState.state != null && childState.state.name == name)
|
||||
return childState.state;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private struct ParsedCondition
|
||||
{
|
||||
public string Parameter;
|
||||
public AnimatorConditionMode Mode;
|
||||
public float Threshold;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse and validate the conditions array against the controller's parameters: each parameter
|
||||
/// must exist and its type must be compatible with the condition mode (If/IfNot for Bool/Trigger;
|
||||
/// numeric Greater/Less/Equals/NotEqual for Float/Int). Throws (writing nothing) on any mismatch.
|
||||
/// </summary>
|
||||
private static List<ParsedCondition> ParseConditions(JArray conditions, AnimatorController ctrl)
|
||||
{
|
||||
var parsed = new List<ParsedCondition>();
|
||||
if (conditions == null)
|
||||
return parsed;
|
||||
|
||||
foreach (var token in conditions)
|
||||
{
|
||||
if (!(token is JObject obj))
|
||||
throw new ArgumentException("Each condition must be an object: { parameter, mode, threshold? }.");
|
||||
|
||||
var parameterName = obj["parameter"]?.ToObject<string>();
|
||||
var modeName = obj["mode"]?.ToObject<string>();
|
||||
if (string.IsNullOrWhiteSpace(parameterName))
|
||||
throw new ArgumentException("Each condition requires a 'parameter'.");
|
||||
if (string.IsNullOrWhiteSpace(modeName))
|
||||
throw new ArgumentException($"Condition for parameter '{parameterName}' requires a 'mode'.");
|
||||
|
||||
var param = ctrl.parameters.FirstOrDefault(p => p.name == parameterName);
|
||||
if (param == null)
|
||||
throw new ArgumentException($"Condition references parameter '{parameterName}', which does not exist on the controller.");
|
||||
|
||||
if (!Enum.TryParse<AnimatorConditionMode>(modeName, ignoreCase: true, out var mode))
|
||||
throw new ArgumentException($"Unknown condition mode '{modeName}' for parameter '{parameterName}'. Use If | IfNot | Greater | Less | Equals | NotEqual.");
|
||||
|
||||
ValidateModeForType(parameterName, param.type, mode);
|
||||
|
||||
var threshold = obj["threshold"]?.ToObject<float>() ?? 0f;
|
||||
|
||||
parsed.Add(new ParsedCondition { Parameter = parameterName, Mode = mode, Threshold = threshold });
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enforce that a condition mode matches its parameter type: Bool/Trigger only accept If/IfNot;
|
||||
/// Float/Int only accept the numeric modes (Greater/Less/Equals/NotEqual). Unity's editor follows
|
||||
/// the same rule; we reject mismatches up front with an actionable message.
|
||||
/// </summary>
|
||||
private static void ValidateModeForType(string parameterName, AnimatorControllerParameterType type, AnimatorConditionMode mode)
|
||||
{
|
||||
var isBoolLike = type == AnimatorControllerParameterType.Bool || type == AnimatorControllerParameterType.Trigger;
|
||||
var isNumeric = type == AnimatorControllerParameterType.Float || type == AnimatorControllerParameterType.Int;
|
||||
|
||||
var boolMode = mode == AnimatorConditionMode.If || mode == AnimatorConditionMode.IfNot;
|
||||
|
||||
if (isBoolLike && !boolMode)
|
||||
throw new ArgumentException(
|
||||
$"Condition mode '{mode}' is not valid for {type} parameter '{parameterName}'. Bool/Trigger parameters use If or IfNot.");
|
||||
|
||||
if (isNumeric && boolMode)
|
||||
throw new ArgumentException(
|
||||
$"Condition mode '{mode}' is not valid for {type} parameter '{parameterName}'. Float/Int parameters use Greater, Less, Equals or NotEqual.");
|
||||
}
|
||||
|
||||
private static AnimatorControllerParameterType ParseParameterType(string type)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(type))
|
||||
throw new ArgumentException("type is required.");
|
||||
if (!Enum.TryParse<AnimatorControllerParameterType>(type, ignoreCase: true, out var parsed))
|
||||
throw new ArgumentException($"Unknown parameter type '{type}'. Use Float | Int | Bool | Trigger.");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static JToken DefaultValueToken(AnimatorControllerParameterType type, float f, int i, bool b)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AnimatorControllerParameterType.Float: return new JValue(f);
|
||||
case AnimatorControllerParameterType.Int: return new JValue(i);
|
||||
case AnimatorControllerParameterType.Bool: return new JValue(b);
|
||||
default: return JValue.CreateNull(); // Trigger has no default value
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a layer selector (int index or string name; null/absent => 0). Returns false with an
|
||||
/// error when a name doesn't match any layer (the caller surfaces 'layer_not_found').
|
||||
/// </summary>
|
||||
private static bool TryResolveLayer(AnimatorController ctrl, JToken layer, out int index, out string error)
|
||||
{
|
||||
index = 0;
|
||||
error = null;
|
||||
|
||||
if (layer == null || layer.Type == JTokenType.Null)
|
||||
return true; // default Base Layer
|
||||
|
||||
if (layer.Type == JTokenType.Integer || layer.Type == JTokenType.Float)
|
||||
{
|
||||
// The layer index is an int. A float is accepted only when it is integer-valued
|
||||
// (e.g. 1.0); a fractional value like 0.9 is rejected rather than silently rounded.
|
||||
if (layer.Type == JTokenType.Float)
|
||||
{
|
||||
var asDouble = layer.ToObject<double>();
|
||||
if (asDouble != Math.Floor(asDouble))
|
||||
{
|
||||
error = $"Layer index must be an integer; got {asDouble}.";
|
||||
return false;
|
||||
}
|
||||
index = (int)asDouble;
|
||||
}
|
||||
else
|
||||
{
|
||||
index = layer.ToObject<int>();
|
||||
}
|
||||
|
||||
if (index < 0 || index >= ctrl.layers.Length)
|
||||
{
|
||||
error = $"Layer index {index} is out of range (controller has {ctrl.layers.Length} layer(s)).";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
var name = layer.ToObject<string>();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return true;
|
||||
|
||||
// A numeric string is treated as an index.
|
||||
if (int.TryParse(name, out var asIndex))
|
||||
{
|
||||
if (asIndex < 0 || asIndex >= ctrl.layers.Length)
|
||||
{
|
||||
error = $"Layer index {asIndex} is out of range (controller has {ctrl.layers.Length} layer(s)).";
|
||||
return false;
|
||||
}
|
||||
index = asIndex;
|
||||
return true;
|
||||
}
|
||||
|
||||
for (int i = 0; i < ctrl.layers.Length; i++)
|
||||
{
|
||||
if (ctrl.layers[i].name == name)
|
||||
{
|
||||
index = i;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
error = $"No layer named '{name}' on the controller. Add it first with add_animator_layer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reject an on-disk asset reference that resolves outside the authoring root. Mirrors the
|
||||
/// confinement applied to the controller itself so a restricted root can't be sidestepped by
|
||||
/// pointing at (e.g.) an AnimationClip/BlendTree elsewhere in the project.
|
||||
/// </summary>
|
||||
private static void ConfineToAuthoringRoot(UnityEngine.Object asset, string label, ObjectRef reference)
|
||||
{
|
||||
var assetPath = AssetDatabase.GetAssetPath(asset);
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
throw new ArgumentException($"{label} reference '{reference}' does not point at an on-disk asset.");
|
||||
|
||||
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
|
||||
if (confined == null)
|
||||
throw new ArgumentException(
|
||||
$"{label} '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
|
||||
}
|
||||
|
||||
private static (AnimatorController controller, string path) ResolveController(ObjectRef controller)
|
||||
{
|
||||
if (controller == null || controller.IsEmpty)
|
||||
throw new ArgumentException("controller is required.");
|
||||
|
||||
if (!ObjectResolver.TryResolve(controller, out var obj, out var error))
|
||||
throw new ArgumentException(error);
|
||||
|
||||
if (!(obj is AnimatorController ctrl))
|
||||
throw new ArgumentException($"Reference '{controller}' resolved to a {obj.GetType().Name}, not an AnimatorController.");
|
||||
|
||||
var assetPath = AssetDatabase.GetAssetPath(ctrl);
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
throw new ArgumentException($"Reference '{controller}' does not point at an on-disk AnimatorController.");
|
||||
|
||||
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
|
||||
if (confined == null)
|
||||
throw new ArgumentException(
|
||||
$"Controller '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
|
||||
|
||||
return (ctrl, confined);
|
||||
}
|
||||
|
||||
private static void Persist(AnimatorController ctrl)
|
||||
{
|
||||
EditorUtility.SetDirty(ctrl);
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
|
||||
private static void FillIdentity(AuthoringResult result, AnimatorController ctrl)
|
||||
{
|
||||
var described = ObjectResolver.Describe(ctrl);
|
||||
if (described == null)
|
||||
return;
|
||||
result.Guid = described.Guid;
|
||||
result.FileId = described.FileId;
|
||||
result.GlobalId = described.GlobalId;
|
||||
}
|
||||
|
||||
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 models ----
|
||||
|
||||
/// <summary>A structured, non-throwing error envelope ({ error, code }) returned at HTTP 200.</summary>
|
||||
[Serializable]
|
||||
public class ErrorResult
|
||||
{
|
||||
[JsonProperty("error")]
|
||||
public string Error { get; set; }
|
||||
|
||||
[JsonProperty("code")]
|
||||
public string Code { get; set; }
|
||||
|
||||
public static ErrorResult PackageStyle(string code, string error) => new ErrorResult { Code = code, Error = error };
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ParameterInfo
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("type")]
|
||||
public string ParamType { get; set; }
|
||||
|
||||
[JsonProperty("defaultValue")]
|
||||
public JToken DefaultValue { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AddParameterResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("parameter")]
|
||||
public ParameterInfo Parameter { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class LayerSummary
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("index")]
|
||||
public int Index { get; set; }
|
||||
|
||||
[JsonProperty("weight")]
|
||||
public float Weight { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AddLayerResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("layer")]
|
||||
public LayerSummary Layer { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class StateSummary
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("layer")]
|
||||
public int Layer { get; set; }
|
||||
|
||||
[JsonProperty("hasMotion")]
|
||||
public bool HasMotion { get; set; }
|
||||
|
||||
[JsonProperty("isDefault")]
|
||||
public bool IsDefault { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AddStateResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("state")]
|
||||
public StateSummary State { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class TransitionSummary
|
||||
{
|
||||
[JsonProperty("from")]
|
||||
public string From { get; set; }
|
||||
|
||||
[JsonProperty("to")]
|
||||
public string To { get; set; }
|
||||
|
||||
[JsonProperty("conditionCount")]
|
||||
public int ConditionCount { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AddTransitionResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("transition")]
|
||||
public TransitionSummary Transition { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AnimatorControllerInfo
|
||||
{
|
||||
[JsonProperty("assetPath")]
|
||||
public string AssetPath { get; set; }
|
||||
|
||||
[JsonProperty("parameters")]
|
||||
public List<ParameterInfo> Parameters { get; set; } = new List<ParameterInfo>();
|
||||
|
||||
[JsonProperty("layers")]
|
||||
public List<LayerInfo> Layers { get; set; } = new List<LayerInfo>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class LayerInfo
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("index")]
|
||||
public int Index { get; set; }
|
||||
|
||||
[JsonProperty("defaultState")]
|
||||
public string DefaultState { get; set; }
|
||||
|
||||
[JsonProperty("states")]
|
||||
public List<StateInfo> States { get; set; } = new List<StateInfo>();
|
||||
|
||||
[JsonProperty("transitions")]
|
||||
public List<TransitionInfo> Transitions { get; set; } = new List<TransitionInfo>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class StateInfo
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("motion")]
|
||||
public string Motion { get; set; }
|
||||
|
||||
[JsonProperty("isDefault")]
|
||||
public bool IsDefault { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class TransitionInfo
|
||||
{
|
||||
[JsonProperty("from")]
|
||||
public string From { get; set; }
|
||||
|
||||
[JsonProperty("to")]
|
||||
public string To { get; set; }
|
||||
|
||||
[JsonProperty("conditions")]
|
||||
public List<ConditionInfo> Conditions { get; set; } = new List<ConditionInfo>();
|
||||
|
||||
[JsonProperty("hasExitTime")]
|
||||
public bool HasExitTime { get; set; }
|
||||
|
||||
[JsonProperty("duration")]
|
||||
public float Duration { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ConditionInfo
|
||||
{
|
||||
[JsonProperty("parameter")]
|
||||
public string Parameter { get; set; }
|
||||
|
||||
[JsonProperty("mode")]
|
||||
public string Mode { get; set; }
|
||||
|
||||
[JsonProperty("threshold")]
|
||||
public float Threshold { get; set; }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 426c006ffb43461e94ab3dde2caed7be
|
||||
+637
@@ -0,0 +1,637 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// Group C of CLI-214: author <c>TimelineAsset</c>s (tracks + clips). Timeline ships in the OPTIONAL
|
||||
/// <c>com.unity.timeline</c> package, so the Editor assembly does NOT reference <c>Unity.Timeline</c>
|
||||
/// (that would break compilation when the package is absent). Every command:
|
||||
///
|
||||
/// 1. runs <see cref="TimelineGuard.IsInstalled"/>; if Timeline is absent it returns a structured
|
||||
/// <c>{ error, code: "package_not_found" }</c> at HTTP 200 (no exception thrown), and
|
||||
/// 2. reaches the Timeline API entirely through reflection.
|
||||
///
|
||||
/// Type names used (all in <c>Unity.Timeline</c>): <c>UnityEngine.Timeline.TimelineAsset</c>,
|
||||
/// <c>TrackAsset</c>, <c>TimelineClip</c>, and the concrete track types
|
||||
/// (<c>AnimationTrack</c>, <c>AudioTrack</c>, <c>ActivationTrack</c>, <c>ControlTrack</c>,
|
||||
/// <c>PlayableTrack</c>, <c>SignalTrack</c>, <c>MarkerTrack</c>).
|
||||
///
|
||||
/// Out of scope (v1): markers, signal emitters/receivers, custom playable tracks, and track bindings
|
||||
/// to scene objects.
|
||||
/// </summary>
|
||||
public static class TimelineCommands
|
||||
{
|
||||
private const string Asm = "Unity.Timeline";
|
||||
|
||||
// Friendly track-type name => concrete TrackAsset subclass full name.
|
||||
private static readonly Dictionary<string, string> TrackTypeNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "Animation", "UnityEngine.Timeline.AnimationTrack" },
|
||||
{ "Audio", "UnityEngine.Timeline.AudioTrack" },
|
||||
{ "Activation", "UnityEngine.Timeline.ActivationTrack" },
|
||||
{ "Control", "UnityEngine.Timeline.ControlTrack" },
|
||||
{ "Playable", "UnityEngine.Timeline.PlayableTrack" },
|
||||
{ "Signal", "UnityEngine.Timeline.SignalTrack" },
|
||||
{ "Marker", "UnityEngine.Timeline.MarkerTrack" },
|
||||
};
|
||||
|
||||
[CliCommand("create_timeline",
|
||||
"Create a .playable TimelineAsset under the authoring root (optional frame rate). Requires the com.unity.timeline package.",
|
||||
MainThreadRequired = true)]
|
||||
public static object CreateTimeline(
|
||||
[CliArg("path", "Asset path ending in .playable, relative to the authoring root. The Assets/ prefix is optional.", Required = true)] string path,
|
||||
[CliArg("frameRate", "Timeline frame rate (default 60).")] float frameRate = 60f,
|
||||
[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)
|
||||
{
|
||||
if (!TimelineGuard.IsInstalled())
|
||||
return TimelineGuard.NotInstalledError();
|
||||
|
||||
var normalized = ProjectPaths.Resolve(path, out var error);
|
||||
if (normalized == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
if (!string.Equals(Path.GetExtension(normalized), ".playable", StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException($"Timeline path '{normalized}' must end in .playable.");
|
||||
|
||||
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 = "TimelineAsset" };
|
||||
|
||||
EnsureParentFolder(normalized);
|
||||
|
||||
var timelineType = TimelineGuard.ResolveTimelineAssetType();
|
||||
var timeline = ScriptableObject.CreateInstance(timelineType);
|
||||
SetFrameRate(timeline, timelineType, frameRate);
|
||||
|
||||
if (exists)
|
||||
AssetDatabase.DeleteAsset(normalized);
|
||||
|
||||
AssetDatabase.CreateAsset(timeline, normalized);
|
||||
EditorUtility.SetDirty(timeline);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.ImportAsset(normalized);
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = "TimelineAsset" };
|
||||
result.AssetPath = normalized;
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("add_timeline_track",
|
||||
"Add a track (Animation | Audio | Activation | Control | Playable | Signal | Marker) to a TimelineAsset, optionally nested under a parent group/track. Requires the com.unity.timeline package.",
|
||||
MainThreadRequired = true)]
|
||||
public static object AddTimelineTrack(
|
||||
[CliArg("timeline", "Reference to the TimelineAsset to edit (path / guid / globalId).", Required = true)] ObjectRef timeline,
|
||||
[CliArg("trackType", "Track type: Animation | Audio | Activation | Control | Playable | Signal | Marker.", Required = true)] string trackType = null,
|
||||
[CliArg("name", "Optional track display name.")] string name = null,
|
||||
[CliArg("parentTrack", "Optional name of an existing group/track to nest the new track under.")] string parentTrack = null,
|
||||
[CliArg("dry_run", "If true, validate inputs without writing the track.")] bool dryRun = false)
|
||||
{
|
||||
if (!TimelineGuard.IsInstalled())
|
||||
return TimelineGuard.NotInstalledError();
|
||||
|
||||
var (asset, assetPath, timelineType) = ResolveTimeline(timeline);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trackType) || !TrackTypeNames.TryGetValue(trackType, out var trackTypeFullName))
|
||||
throw new ArgumentException($"Unknown trackType '{trackType}'. Use Animation | Audio | Activation | Control | Playable | Signal | Marker.");
|
||||
|
||||
var concreteTrackType = Type.GetType($"{trackTypeFullName}, {Asm}", throwOnError: false);
|
||||
if (concreteTrackType == null)
|
||||
throw new ArgumentException($"Track type '{trackTypeFullName}' is not available in this version of {TimelineGuard.PackageId}.");
|
||||
|
||||
var trackName = string.IsNullOrEmpty(name) ? trackType : name;
|
||||
|
||||
// Resolve an optional parent track by name BEFORE any write so a bad parent fails clean.
|
||||
object parent = null;
|
||||
if (!string.IsNullOrEmpty(parentTrack))
|
||||
{
|
||||
parent = FindTrackByName(asset, timelineType, parentTrack);
|
||||
if (parent == null)
|
||||
throw new ArgumentException($"Parent track '{parentTrack}' not found on the timeline.");
|
||||
}
|
||||
|
||||
var result = new AddTimelineTrackResult
|
||||
{
|
||||
AssetPath = assetPath,
|
||||
Type = "TimelineAsset",
|
||||
Track = new TimelineTrackSummary { Name = trackName, TrackType = trackType }
|
||||
};
|
||||
|
||||
if (dryRun)
|
||||
return result;
|
||||
|
||||
// TrackAsset CreateTrack(Type type, TrackAsset parent, string name)
|
||||
var createTrack = timelineType.GetMethod("CreateTrack", new[] { typeof(Type), GetTrackAssetType(), typeof(string) });
|
||||
if (createTrack == null)
|
||||
throw new InvalidOperationException("TimelineAsset.CreateTrack(Type, TrackAsset, string) not found via reflection.");
|
||||
|
||||
createTrack.Invoke(asset, new object[] { concreteTrackType, parent, trackName });
|
||||
|
||||
EditorUtility.SetDirty(asset);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
FillIdentity(result, asset);
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("add_timeline_clip",
|
||||
"Add a clip to a named track on a TimelineAsset. For Animation tracks pass an AnimationClip asset; for Audio tracks an AudioClip. Requires the com.unity.timeline package.",
|
||||
MainThreadRequired = true)]
|
||||
public static object AddTimelineClip(
|
||||
[CliArg("timeline", "Reference to the TimelineAsset to edit (path / guid / globalId).", Required = true)] ObjectRef timeline,
|
||||
[CliArg("track", "Target track name.", Required = true)] string track = null,
|
||||
[CliArg("start", "Clip start time in seconds.", Required = true)] float start = 0f,
|
||||
[CliArg("duration", "Clip duration in seconds.", Required = true)] float duration = 0f,
|
||||
[CliArg("asset", "Source asset: for Animation tracks an AnimationClip; for Audio tracks an AudioClip. Required for those track types.")] ObjectRef asset = null,
|
||||
[CliArg("dry_run", "If true, validate inputs without writing the clip.")] bool dryRun = false)
|
||||
{
|
||||
if (!TimelineGuard.IsInstalled())
|
||||
return TimelineGuard.NotInstalledError();
|
||||
|
||||
var (timelineAsset, assetPath, timelineType) = ResolveTimeline(timeline);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(track))
|
||||
throw new ArgumentException("track is required.");
|
||||
if (duration <= 0f)
|
||||
throw new ArgumentException($"duration must be positive (got {duration}).");
|
||||
if (start < 0f)
|
||||
throw new ArgumentException($"start must be >= 0 (got {start}).");
|
||||
|
||||
var trackObj = FindTrackByName(timelineAsset, timelineType, track);
|
||||
if (trackObj == null)
|
||||
throw new ArgumentException($"Track '{track}' not found on the timeline.");
|
||||
|
||||
// Resolve the optional clip-source asset BEFORE any write.
|
||||
Object sourceAsset = null;
|
||||
if (asset != null && !asset.IsEmpty)
|
||||
{
|
||||
if (!ObjectResolver.TryResolve(asset, out var srcObj, out var srcError))
|
||||
throw new ArgumentException($"Could not resolve clip asset: {srcError}");
|
||||
sourceAsset = srcObj;
|
||||
|
||||
// Re-confine the clip source to the authoring root: a restricted root must not be
|
||||
// bypassed by referencing an AnimationClip/AudioClip that lives outside the sandbox.
|
||||
var sourcePath = AssetDatabase.GetAssetPath(sourceAsset);
|
||||
if (string.IsNullOrEmpty(sourcePath))
|
||||
throw new ArgumentException($"Clip asset reference '{asset}' does not point at an on-disk asset.");
|
||||
|
||||
var confinedSource = ProjectPaths.Resolve(sourcePath, out var sourceConfineError);
|
||||
if (confinedSource == null)
|
||||
throw new ArgumentException(
|
||||
$"Clip asset '{sourcePath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {sourceConfineError}");
|
||||
}
|
||||
|
||||
var result = new AddTimelineClipResult
|
||||
{
|
||||
AssetPath = assetPath,
|
||||
Type = "TimelineAsset",
|
||||
Clip = new TimelineClipSummary { Track = track, Start = start, Duration = duration }
|
||||
};
|
||||
|
||||
if (dryRun)
|
||||
{
|
||||
// Even in dry_run, validate that a clip can be created for this track/asset combination so
|
||||
// a missing-required-asset case fails fast and writes nothing.
|
||||
ValidateClipCreatable(trackObj, sourceAsset);
|
||||
return result;
|
||||
}
|
||||
|
||||
var clip = CreateClip(trackObj, sourceAsset);
|
||||
if (clip == null)
|
||||
throw new InvalidOperationException($"Failed to create a clip on track '{track}'.");
|
||||
|
||||
SetClipTiming(clip, start, duration);
|
||||
|
||||
EditorUtility.SetDirty(timelineAsset);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
FillIdentity(result, timelineAsset);
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("get_timeline",
|
||||
"Read a TimelineAsset's structure: frame rate, duration, and its tracks with their clips. Requires the com.unity.timeline package.",
|
||||
MainThreadRequired = true)]
|
||||
public static object GetTimeline(
|
||||
[CliArg("timeline", "Reference to the TimelineAsset to read (path / guid / globalId).", Required = true)] ObjectRef timeline)
|
||||
{
|
||||
if (!TimelineGuard.IsInstalled())
|
||||
return TimelineGuard.NotInstalledError();
|
||||
|
||||
var (asset, assetPath, timelineType) = ResolveTimeline(timeline);
|
||||
|
||||
var info = new TimelineInfo
|
||||
{
|
||||
AssetPath = assetPath,
|
||||
FrameRate = GetFrameRate(asset, timelineType),
|
||||
Duration = GetDouble(asset, timelineType, "duration")
|
||||
};
|
||||
|
||||
foreach (var trackObj in GetOutputTracks(asset, timelineType))
|
||||
{
|
||||
if (trackObj == null)
|
||||
continue;
|
||||
|
||||
var trackType = trackObj.GetType();
|
||||
var trackInfo = new TimelineTrackInfo
|
||||
{
|
||||
Name = GetName(trackObj),
|
||||
TrackType = FriendlyTrackType(trackType)
|
||||
};
|
||||
|
||||
foreach (var clipObj in GetClips(trackObj))
|
||||
{
|
||||
if (clipObj == null)
|
||||
continue;
|
||||
var clipType = clipObj.GetType();
|
||||
var clipAsset = GetMember(clipObj, clipType, "asset") as Object;
|
||||
trackInfo.Clips.Add(new TimelineClipInfo
|
||||
{
|
||||
Start = GetDoubleMember(clipObj, clipType, "start"),
|
||||
Duration = GetDoubleMember(clipObj, clipType, "duration"),
|
||||
Asset = clipAsset != null ? AssetDatabase.GetAssetPath(clipAsset) : null
|
||||
});
|
||||
}
|
||||
|
||||
info.Tracks.Add(trackInfo);
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
// ---- reflection helpers ----
|
||||
|
||||
private static Type GetTrackAssetType()
|
||||
{
|
||||
var t = Type.GetType($"UnityEngine.Timeline.TrackAsset, {Asm}", throwOnError: false);
|
||||
if (t == null)
|
||||
throw new InvalidOperationException("UnityEngine.Timeline.TrackAsset type not found.");
|
||||
return t;
|
||||
}
|
||||
|
||||
private static (Object asset, string path, Type timelineType) ResolveTimeline(ObjectRef timeline)
|
||||
{
|
||||
if (timeline == null || timeline.IsEmpty)
|
||||
throw new ArgumentException("timeline is required.");
|
||||
|
||||
if (!ObjectResolver.TryResolve(timeline, out var obj, out var error))
|
||||
throw new ArgumentException(error);
|
||||
|
||||
var timelineType = TimelineGuard.ResolveTimelineAssetType();
|
||||
if (timelineType == null || !timelineType.IsInstanceOfType(obj))
|
||||
throw new ArgumentException($"Reference '{timeline}' resolved to a {obj.GetType().Name}, not a TimelineAsset.");
|
||||
|
||||
var assetPath = AssetDatabase.GetAssetPath(obj);
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
throw new ArgumentException($"Reference '{timeline}' does not point at an on-disk TimelineAsset.");
|
||||
|
||||
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
|
||||
if (confined == null)
|
||||
throw new ArgumentException(
|
||||
$"Timeline '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
|
||||
|
||||
return (obj, confined, timelineType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the timeline frame rate, preferring the current <c>editorSettings.frameRate</c> (double)
|
||||
/// and falling back to the obsolete <c>editorSettings.fps</c> (float) on older package versions.
|
||||
/// </summary>
|
||||
private static void SetFrameRate(object timeline, Type timelineType, float frameRate)
|
||||
{
|
||||
var editorSettings = GetMember(timeline, timelineType, "editorSettings");
|
||||
if (editorSettings == null)
|
||||
return;
|
||||
|
||||
var settingsType = editorSettings.GetType();
|
||||
|
||||
var frameRateProp = settingsType.GetProperty("frameRate", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (frameRateProp != null && frameRateProp.CanWrite)
|
||||
{
|
||||
frameRateProp.SetValue(editorSettings, (double)frameRate);
|
||||
return;
|
||||
}
|
||||
|
||||
var fpsProp = settingsType.GetProperty("fps", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (fpsProp != null && fpsProp.CanWrite)
|
||||
fpsProp.SetValue(editorSettings, frameRate);
|
||||
}
|
||||
|
||||
private static double GetFrameRate(object timeline, Type timelineType)
|
||||
{
|
||||
var editorSettings = GetMember(timeline, timelineType, "editorSettings");
|
||||
if (editorSettings == null)
|
||||
return 0d;
|
||||
|
||||
var settingsType = editorSettings.GetType();
|
||||
|
||||
var frameRateProp = settingsType.GetProperty("frameRate", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (frameRateProp != null && frameRateProp.CanRead)
|
||||
return Convert.ToDouble(frameRateProp.GetValue(editorSettings));
|
||||
|
||||
var fpsProp = settingsType.GetProperty("fps", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (fpsProp != null && fpsProp.CanRead)
|
||||
return Convert.ToDouble(fpsProp.GetValue(editorSettings));
|
||||
|
||||
return 0d;
|
||||
}
|
||||
|
||||
private static IEnumerable<object> GetOutputTracks(object timeline, Type timelineType)
|
||||
{
|
||||
var method = timelineType.GetMethod("GetOutputTracks", Type.EmptyTypes);
|
||||
if (method == null)
|
||||
yield break;
|
||||
|
||||
var enumerable = method.Invoke(timeline, null) as IEnumerable;
|
||||
if (enumerable == null)
|
||||
yield break;
|
||||
|
||||
foreach (var item in enumerable)
|
||||
yield return item;
|
||||
}
|
||||
|
||||
private static object FindTrackByName(object timeline, Type timelineType, string name)
|
||||
{
|
||||
foreach (var track in GetOutputTracks(timeline, timelineType))
|
||||
{
|
||||
if (track != null && GetName(track) == name)
|
||||
return track;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<object> GetClips(object track)
|
||||
{
|
||||
var method = track.GetType().GetMethod("GetClips", Type.EmptyTypes);
|
||||
if (method == null)
|
||||
yield break;
|
||||
|
||||
var enumerable = method.Invoke(track, null) as IEnumerable;
|
||||
if (enumerable == null)
|
||||
yield break;
|
||||
|
||||
foreach (var item in enumerable)
|
||||
yield return item;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find a <c>CreateClip</c> overload on the track that accepts the given source asset's type
|
||||
/// (e.g. AnimationTrack.CreateClip(AnimationClip), AudioTrack.CreateClip(AudioClip)). Returns
|
||||
/// null when no asset is given (caller then uses CreateDefaultClip).
|
||||
/// </summary>
|
||||
private static MethodInfo FindCreateClipForAsset(Type trackType, Object sourceAsset)
|
||||
{
|
||||
if (sourceAsset == null)
|
||||
return null;
|
||||
|
||||
var assetType = sourceAsset.GetType();
|
||||
MethodInfo best = null;
|
||||
foreach (var m in trackType.GetMethods(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (m.Name != "CreateClip")
|
||||
continue;
|
||||
if (m.IsGenericMethodDefinition)
|
||||
continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 1)
|
||||
continue;
|
||||
if (ps[0].ParameterType.IsAssignableFrom(assetType))
|
||||
{
|
||||
// Prefer an exact match over a UnityEngine.Object-typed overload.
|
||||
if (ps[0].ParameterType == assetType)
|
||||
return m;
|
||||
best = best ?? m;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static void ValidateClipCreatable(object track, Object sourceAsset)
|
||||
{
|
||||
var trackType = track.GetType();
|
||||
var isAnimation = trackType.FullName == "UnityEngine.Timeline.AnimationTrack";
|
||||
var isAudio = trackType.FullName == "UnityEngine.Timeline.AudioTrack";
|
||||
|
||||
if ((isAnimation || isAudio) && sourceAsset == null)
|
||||
throw new ArgumentException($"Track type '{FriendlyTrackType(trackType)}' requires an 'asset' (AnimationClip / AudioClip) to create a clip.");
|
||||
|
||||
if (sourceAsset != null && FindCreateClipForAsset(trackType, sourceAsset) == null)
|
||||
throw new ArgumentException(
|
||||
$"Track type '{FriendlyTrackType(trackType)}' has no CreateClip overload accepting a {sourceAsset.GetType().Name}.");
|
||||
}
|
||||
|
||||
private static object CreateClip(object track, Object sourceAsset)
|
||||
{
|
||||
var trackType = track.GetType();
|
||||
|
||||
if (sourceAsset != null)
|
||||
{
|
||||
var createClip = FindCreateClipForAsset(trackType, sourceAsset);
|
||||
if (createClip == null)
|
||||
throw new ArgumentException(
|
||||
$"Track type '{FriendlyTrackType(trackType)}' has no CreateClip overload accepting a {sourceAsset.GetType().Name}.");
|
||||
return createClip.Invoke(track, new object[] { sourceAsset });
|
||||
}
|
||||
|
||||
var isAnimation = trackType.FullName == "UnityEngine.Timeline.AnimationTrack";
|
||||
var isAudio = trackType.FullName == "UnityEngine.Timeline.AudioTrack";
|
||||
if (isAnimation || isAudio)
|
||||
throw new ArgumentException($"Track type '{FriendlyTrackType(trackType)}' requires an 'asset' (AnimationClip / AudioClip) to create a clip.");
|
||||
|
||||
// For tracks that don't take a source asset, create a default (empty) clip.
|
||||
var createDefault = trackType.GetMethod("CreateDefaultClip", Type.EmptyTypes);
|
||||
if (createDefault != null)
|
||||
return createDefault.Invoke(track, null);
|
||||
|
||||
throw new InvalidOperationException($"No CreateClip / CreateDefaultClip available on '{trackType.Name}'.");
|
||||
}
|
||||
|
||||
private static void SetClipTiming(object clip, double start, double duration)
|
||||
{
|
||||
var clipType = clip.GetType();
|
||||
SetDoubleMember(clip, clipType, "start", start);
|
||||
SetDoubleMember(clip, clipType, "duration", duration);
|
||||
}
|
||||
|
||||
private static string FriendlyTrackType(Type trackType)
|
||||
{
|
||||
foreach (var pair in TrackTypeNames)
|
||||
{
|
||||
if (pair.Value == trackType.FullName)
|
||||
return pair.Key;
|
||||
}
|
||||
// Strip the trailing "Track" suffix for any unmapped subclass.
|
||||
var n = trackType.Name;
|
||||
return n.EndsWith("Track", StringComparison.Ordinal) ? n.Substring(0, n.Length - "Track".Length) : n;
|
||||
}
|
||||
|
||||
private static string GetName(object obj)
|
||||
{
|
||||
var prop = obj.GetType().GetProperty("name", BindingFlags.Public | BindingFlags.Instance);
|
||||
return prop?.GetValue(obj) as string;
|
||||
}
|
||||
|
||||
private static object GetMember(object obj, Type type, string name)
|
||||
{
|
||||
var prop = type.GetProperty(name, BindingFlags.Public | BindingFlags.Instance);
|
||||
if (prop != null)
|
||||
return prop.GetValue(obj);
|
||||
var field = type.GetField(name, BindingFlags.Public | BindingFlags.Instance);
|
||||
return field?.GetValue(obj);
|
||||
}
|
||||
|
||||
private static double GetDouble(object obj, Type type, string name)
|
||||
{
|
||||
var value = GetMember(obj, type, name);
|
||||
return value == null ? 0d : Convert.ToDouble(value);
|
||||
}
|
||||
|
||||
private static double GetDoubleMember(object obj, Type type, string name) => GetDouble(obj, type, name);
|
||||
|
||||
private static void SetDoubleMember(object obj, Type type, string name, double value)
|
||||
{
|
||||
var prop = type.GetProperty(name, BindingFlags.Public | BindingFlags.Instance);
|
||||
if (prop != null && prop.CanWrite)
|
||||
{
|
||||
prop.SetValue(obj, value);
|
||||
return;
|
||||
}
|
||||
var field = type.GetField(name, BindingFlags.Public | BindingFlags.Instance);
|
||||
field?.SetValue(obj, value);
|
||||
}
|
||||
|
||||
private static void FillIdentity(AuthoringResult result, Object asset)
|
||||
{
|
||||
var described = ObjectResolver.Describe(asset);
|
||||
if (described == null)
|
||||
return;
|
||||
result.Guid = described.Guid;
|
||||
result.FileId = described.FileId;
|
||||
result.GlobalId = described.GlobalId;
|
||||
}
|
||||
|
||||
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 models ----
|
||||
|
||||
[Serializable]
|
||||
public class TimelineTrackSummary
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("type")]
|
||||
public string TrackType { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AddTimelineTrackResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("track")]
|
||||
public TimelineTrackSummary Track { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class TimelineClipSummary
|
||||
{
|
||||
[JsonProperty("track")]
|
||||
public string Track { get; set; }
|
||||
|
||||
[JsonProperty("start")]
|
||||
public float Start { get; set; }
|
||||
|
||||
[JsonProperty("duration")]
|
||||
public float Duration { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AddTimelineClipResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("clip")]
|
||||
public TimelineClipSummary Clip { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class TimelineInfo
|
||||
{
|
||||
[JsonProperty("assetPath")]
|
||||
public string AssetPath { get; set; }
|
||||
|
||||
[JsonProperty("frameRate")]
|
||||
public double FrameRate { get; set; }
|
||||
|
||||
[JsonProperty("duration")]
|
||||
public double Duration { get; set; }
|
||||
|
||||
[JsonProperty("tracks")]
|
||||
public List<TimelineTrackInfo> Tracks { get; set; } = new List<TimelineTrackInfo>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class TimelineTrackInfo
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("type")]
|
||||
public string TrackType { get; set; }
|
||||
|
||||
[JsonProperty("clips")]
|
||||
public List<TimelineClipInfo> Clips { get; set; } = new List<TimelineClipInfo>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class TimelineClipInfo
|
||||
{
|
||||
[JsonProperty("start")]
|
||||
public double Start { get; set; }
|
||||
|
||||
[JsonProperty("duration")]
|
||||
public double Duration { get; set; }
|
||||
|
||||
[JsonProperty("asset")]
|
||||
public string Asset { get; set; }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dcf5399fb1544ee3a5b069c535bcd002
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// Presence guard for the optional <c>com.unity.timeline</c> package (CLI-214, Group C).
|
||||
///
|
||||
/// The Editor assembly deliberately does NOT reference <c>Unity.Timeline</c> (an asmdef reference
|
||||
/// would break compilation in any project that doesn't have the package installed). Instead, the
|
||||
/// Timeline commands reach the package via reflection, and this guard reports whether the package's
|
||||
/// core type is loadable — the optional-package guard pattern for commands backed by a package that
|
||||
/// may not be present.
|
||||
///
|
||||
/// The guard caches its result for the lifetime of the domain; a package add/remove triggers a
|
||||
/// domain reload, which resets the cache.
|
||||
/// </summary>
|
||||
public static class TimelineGuard
|
||||
{
|
||||
/// <summary>The package id, surfaced in the not-installed error message.</summary>
|
||||
public const string PackageId = "com.unity.timeline";
|
||||
|
||||
/// <summary>Assembly-qualified name of the package's root asset type.</summary>
|
||||
public const string TimelineAssetTypeName = "UnityEngine.Timeline.TimelineAsset, Unity.Timeline";
|
||||
|
||||
private static bool? s_Installed;
|
||||
|
||||
/// <summary>
|
||||
/// True when the Timeline package is installed (its <c>TimelineAsset</c> type resolves).
|
||||
/// </summary>
|
||||
public static bool IsInstalled()
|
||||
{
|
||||
if (s_Installed.HasValue)
|
||||
return s_Installed.Value;
|
||||
|
||||
s_Installed = ResolveTimelineAssetType() != null;
|
||||
return s_Installed.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>UnityEngine.Timeline.TimelineAsset</c> <see cref="Type"/>, or null if the package is
|
||||
/// absent. Callers use this as the entry point for further reflection.
|
||||
/// </summary>
|
||||
public static Type ResolveTimelineAssetType() => Type.GetType(TimelineAssetTypeName, throwOnError: false);
|
||||
|
||||
/// <summary>
|
||||
/// The structured "package not installed" error (HTTP 200, no exception) that every Timeline
|
||||
/// command returns when <see cref="IsInstalled"/> is false.
|
||||
/// </summary>
|
||||
public static ErrorResult NotInstalledError() =>
|
||||
ErrorResult.PackageStyle("package_not_found", $"{PackageId} is not installed");
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f3f8120d06814b2c86d46503ac132022
|
||||
Reference in New Issue
Block a user