TextToAnimation/Animation/AnimClip.cs

Defines animation clip data types for the editor: ClipOrigin and ClipRootMotion enums, ClipEvent, ClipExportSettings, GenerationRecord, and the main AnimClip class which stores frames (arrays of XForm), events, pinned frames, locked bones, key layers, export/settings metadata, and helper methods for cloning, duplicating, evaluating frames, copying frames and sanitizing sequence names.

File Access
#nullable enable annotations

using TextToAnimation.Maths;
using TextToAnimation.Processing;

namespace TextToAnimation.Animation;

/// <summary>Where a clip in a workspace came from.</summary>
public enum ClipOrigin { Generated, Imported, ImportedFile, Duplicated, Empty }

/// <summary>How the saved sequence treats horizontal root travel.</summary>
public enum ClipRootMotion { Keep, Extract, InPlace }

/// <summary>An animation event on the clip's frame grid (footsteps and user events).</summary>
public sealed class ClipEvent
{
    public string EventClass { get; set; } = "";
    public int Frame { get; set; }
    public string? Attachment { get; set; }
    public string? Foot { get; set; }
    public double? Volume { get; set; }
    /// <summary>True when the event was generated automatically (footsteps) and may be regenerated.</summary>
    public bool Automatic { get; set; }
    public ClipEvent Clone() => new()
    {
        EventClass = EventClass, Frame = Frame, Attachment = Attachment, Foot = Foot, Volume = Volume, Automatic = Automatic,
    };
}

/// <summary>Per-clip options used when the clip is saved into a vmdl or exported.</summary>
public sealed class ClipExportSettings
{
    /// <summary>Sequence name inside the vmdl. Empty = derived from the clip name.</summary>
    public string SequenceName { get; set; } = "";
    public ClipRootMotion RootMotion { get; set; } = ClipRootMotion.Keep;
    public bool Footsteps { get; set; } = true;
    public bool AdditiveVariant { get; set; }
    public int AdditiveReferenceFrame { get; set; }
    public bool MirroredVariant { get; set; }
    public ClipExportSettings Clone() => new()
    {
        SequenceName = SequenceName, RootMotion = RootMotion, Footsteps = Footsteps, AdditiveVariant = AdditiveVariant,
        AdditiveReferenceFrame = AdditiveReferenceFrame, MirroredVariant = MirroredVariant,
    };
}

/// <summary>The settings that produced a generated clip, kept so it can be regenerated or varied.</summary>
public sealed class GenerationRecord
{
    public string Mode { get; set; } = "";
    public List<string> Prompts { get; set; } = new();
    public int Seed { get; set; }
    public float DurationSeconds { get; set; }
    public float Guidance { get; set; }
    public string Generator { get; set; } = "";
    public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
    public GenerationRecord Clone() => new()
    {
        Mode = Mode, Prompts = Prompts.ToList(), Seed = Seed, DurationSeconds = DurationSeconds,
        Guidance = Guidance, Generator = Generator, CreatedUtc = CreatedUtc,
    };
}

/// <summary>
/// One animation clip in a workspace: dense parent-local poses (one <see cref="XForm"/> per skeleton bone
/// per frame, skeleton units) plus everything the editor layers on top - events, pinned frames used as
/// in-between constraints, bone locks used by text-guided editing, bone keyframes, and export options.
/// </summary>
public sealed class AnimClip
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string Name { get; set; } = "Animation";
    public float Fps { get; set; } = 30f;
    public bool Looping { get; set; }
    public List<XForm[]> Frames { get; set; } = new();
    public List<ClipEvent> Events { get; set; } = new();

    /// <summary>Frames whose full pose is held fixed when regenerating (motion in-betweening constraints).</summary>
    public SortedSet<int> PinnedFrames { get; set; } = new();

    /// <summary>Bones (by name) preserved when regenerating with a new prompt (text-guided editing).</summary>
    public HashSet<string> LockedBones { get; set; } = new(StringComparer.Ordinal);

    /// <summary>Pose edits authored on top of <see cref="Frames"/>, per bone name.</summary>
    public KeyLayer Keys { get; set; } = new();

    public ClipOrigin Origin { get; set; } = ClipOrigin.Empty;

    /// <summary>For imported clips: the sequence name in the vmdl it came from (the one "Replace" overwrites).</summary>
    public string? SourceSequence { get; set; }

    public GenerationRecord? Generation { get; set; }
    public ClipExportSettings Export { get; set; } = new();

    /// <summary>When the clip was last written into the vmdl (null = never saved).</summary>
    public DateTime? SavedUtc { get; set; }

    /// <summary>Incremented on every change; the workspace compares it against the saved revision.</summary>
    public int Revision { get; set; }

    public int FrameCount => Frames.Count;
    public float Duration => FrameCount <= 1 ? 0f : (FrameCount - 1) / Fps;

    /// <summary>Sequence name used inside the vmdl.</summary>
    public string EffectiveSequenceName => string.IsNullOrWhiteSpace(Export.SequenceName)
        ? SanitizeSequenceName(Name)
        : SanitizeSequenceName(Export.SequenceName);

    /// <summary>Final poses: <see cref="Frames"/> with the key layer applied.</summary>
    public List<XForm[]> EvaluateFrames(Rig.Skeleton skeleton) => Keys.IsEmpty ? Frames : Keys.Apply(Frames, skeleton);

    /// <summary>Deep copy (frames, events, pins, locks, keys and settings) with a new id.</summary>
    public AnimClip Duplicate(string name)
    {
        var copy = CloneDeep();
        copy.Id = Guid.NewGuid();
        copy.Name = name;
        copy.Origin = ClipOrigin.Duplicated;
        copy.SavedUtc = null;
        copy.Export.SequenceName = "";
        copy.Revision = 0;
        return copy;
    }

    /// <summary>Deep copy that keeps the id (used for undo snapshots).</summary>
    public AnimClip CloneDeep() => new()
    {
        Id = Id,
        Name = Name,
        Fps = Fps,
        Looping = Looping,
        Frames = CopyFrames(Frames),
        Events = Events.Select(e => e.Clone()).ToList(),
        PinnedFrames = new SortedSet<int>(PinnedFrames),
        LockedBones = new HashSet<string>(LockedBones, StringComparer.Ordinal),
        Keys = Keys.Clone(),
        Origin = Origin,
        SourceSequence = SourceSequence,
        Generation = Generation?.Clone(),
        Export = Export.Clone(),
        SavedUtc = SavedUtc,
        Revision = Revision,
    };

    public static List<XForm[]> CopyFrames(IReadOnlyList<XForm[]> frames)
    {
        var copy = new List<XForm[]>(frames.Count);
        foreach (var f in frames)
        {
            var c = new XForm[f.Length];
            Array.Copy(f, c, f.Length);
            copy.Add(c);
        }
        return copy;
    }

    /// <summary>
    /// Makes a valid vmdl sequence name: letters, digits and underscores, not starting with a digit.
    /// </summary>
    public static string SanitizeSequenceName(string name)
    {
        var chars = (name ?? "").Trim().Select(c => char.IsLetterOrDigit(c) ? c : '_').ToArray();
        var s = new string(chars).Trim('_');
        while (s.Contains("__", StringComparison.Ordinal)) s = s.Replace("__", "_", StringComparison.Ordinal);
        if (s.Length == 0) s = "animation";
        if (char.IsDigit(s[0])) s = "a_" + s;
        return s;
    }
}