ClipOps static class for editing animation clips. It implements operations like crop, trim, delete/duplicate/split/reverse sections, resampling/time-scaling, root motion adjustments (in-place, offset, reset, remove drift), loop blending, and helpers to splice frames, smooth paths, remap events/keys, and finalize (re-bake IK bones and fix quaternion continuity).
#nullable enable annotations
using System.Numerics;
using TextToAnimation.Formats;
using TextToAnimation.Maths;
using TextToAnimation.Processing;
namespace TextToAnimation.Animation;
using Vector3 = System.Numerics.Vector3; // s&box compat (see Code/TextToAnimation/Assembly.cs)
/// <summary>
/// Clip editing operations. Every operation mutates the clip in place (take an undo snapshot first),
/// keeps events / pinned frames / keys on the right frames, keeps quaternions continuous, keeps root
/// travel continuous across splices and re-bakes derived helper bones (s&box IK targets).
/// Frame ranges are inclusive.
/// </summary>
public static class ClipOps
{
// ------------------------------------------------------------------ structure
/// <summary>Keeps only frames [start, end] (crop/trim).</summary>
public static void Crop(AnimClip clip, MotionRig rig, int start, int end)
{
CheckRange(clip, start, end);
clip.Frames = clip.Frames.GetRange(start, end - start + 1);
Remap(clip, f => f >= start && f <= end ? f - start : null);
Finish(clip, rig);
}
/// <summary>Removes the first <paramref name="headFrames"/> and last <paramref name="tailFrames"/> frames.</summary>
public static void Trim(AnimClip clip, MotionRig rig, int headFrames, int tailFrames)
=> Crop(clip, rig, headFrames, clip.FrameCount - 1 - tailFrames);
/// <summary>Removes frames [start, end] and joins the rest so root travel continues smoothly.</summary>
public static void DeleteSection(AnimClip clip, MotionRig rig, int start, int end)
{
CheckRange(clip, start, end);
if (end - start + 1 >= clip.FrameCount - 1)
throw new InvalidOperationException("Can't delete the whole clip - at least two frames must remain.");
var before = clip.Frames.GetRange(0, start);
var after = clip.Frames.GetRange(end + 1, clip.FrameCount - end - 1);
var count = end - start + 1;
clip.Frames = Splice(rig, before, after);
Remap(clip, f => f < start ? f : f > end ? f - count : null);
Finish(clip, rig);
}
/// <summary>Inserts a copy of frames [start, end] right after <paramref name="end"/>; the copy continues the root path.</summary>
public static void DuplicateSection(AnimClip clip, MotionRig rig, int start, int end)
{
CheckRange(clip, start, end);
var head = clip.Frames.GetRange(0, end + 1);
var copy = AnimClip.CopyFrames(clip.Frames.GetRange(start, end - start + 1));
var tail = clip.Frames.GetRange(end + 1, clip.FrameCount - end - 1);
var count = end - start + 1;
var joined = Splice(rig, head, copy);
clip.Frames = Splice(rig, joined, tail);
// events/pins/keys on the duplicated span are copied too
var copiedEvents = clip.Events.Where(e => e.Frame >= start && e.Frame <= end)
.Select(e => { var c = e.Clone(); c.Frame = e.Frame - start + end + 1; return c; }).ToList();
Remap(clip, f => f <= end ? f : f + count);
clip.Events.AddRange(copiedEvents);
clip.Events.Sort((a, b) => a.Frame.CompareTo(b.Frame));
Finish(clip, rig);
}
/// <summary>Splits at <paramref name="frame"/>: the clip keeps [0, frame], the returned clip holds [frame, end].</summary>
public static AnimClip Split(AnimClip clip, MotionRig rig, int frame, string secondName)
{
if (frame <= 0 || frame >= clip.FrameCount - 1)
throw new ArgumentOutOfRangeException(nameof(frame), "Split point must be inside the clip.");
var second = clip.Duplicate(secondName);
Crop(second, rig, frame, second.FrameCount - 1);
Crop(clip, rig, 0, frame);
clip.Looping = false;
second.Looping = false;
return second;
}
/// <summary>Plays the clip backwards.</summary>
public static void Reverse(AnimClip clip, MotionRig rig)
{
var n = clip.FrameCount;
clip.Frames.Reverse();
Remap(clip, f => n - 1 - f);
foreach (var e in clip.Events.Where(e => e.Automatic).ToList()) clip.Events.Remove(e); // footsteps become wrong: regenerate
Finish(clip, rig);
}
/// <summary>Plays frames [start, end] backwards in place; the rest of the clip continues the root path.</summary>
public static void ReverseSection(AnimClip clip, MotionRig rig, int start, int end)
{
CheckRange(clip, start, end);
if (start == 0 && end == clip.FrameCount - 1) { Reverse(clip, rig); return; }
if (end - start < 1) return;
var head = clip.Frames.GetRange(0, start);
var middle = AnimClip.CopyFrames(clip.Frames.GetRange(start, end - start + 1));
middle.Reverse();
var tail = clip.Frames.GetRange(end + 1, clip.FrameCount - end - 1);
clip.Frames = Splice(rig, Splice(rig, head, middle), tail);
foreach (var e in clip.Events.Where(e => e.Automatic && e.Frame >= start && e.Frame <= end).ToList()) clip.Events.Remove(e);
Remap(clip, f => f >= start && f <= end ? start + end - f : f);
Finish(clip, rig);
}
/// <summary>Changes the sample rate, keeping the duration (motion is resampled).</summary>
public static void Resample(AnimClip clip, MotionRig rig, float newFps)
{
if (!(newFps > 0f) || !float.IsFinite(newFps)) throw new ArgumentOutOfRangeException(nameof(newFps));
if (MathF.Abs(newFps - clip.Fps) < 1e-4f) return;
var duration = clip.Duration;
var count = Math.Max(2, (int)MathF.Round(duration * newFps) + 1);
ResampleTo(clip, rig, count, newFps);
}
/// <summary>Speeds the motion up (factor > 1) or slows it down (factor < 1) at the same frame rate.</summary>
public static void TimeScale(AnimClip clip, MotionRig rig, float factor)
{
if (!(factor > 0f) || !float.IsFinite(factor)) throw new ArgumentOutOfRangeException(nameof(factor));
var count = Math.Max(2, (int)MathF.Round((clip.FrameCount - 1) / factor) + 1);
ResampleTo(clip, rig, count, clip.Fps);
}
/// <summary>Samples the clip at a fractional frame (linear position, slerped rotation).</summary>
public static XForm[] Sample(IReadOnlyList<XForm[]> frames, float frame)
{
if (frames.Count == 0) throw new InvalidOperationException("Clip has no frames.");
frame = Math.Clamp(frame, 0f, frames.Count - 1);
var a = (int)MathF.Floor(frame);
var b = Math.Min(a + 1, frames.Count - 1);
var t = frame - a;
var fa = frames[a];
if (t <= 1e-6f || a == b) { var copy = new XForm[fa.Length]; Array.Copy(fa, copy, fa.Length); return copy; }
var fb = frames[b];
var result = new XForm[fa.Length];
for (var i = 0; i < fa.Length; i++)
result[i] = new XForm(Vector3.Lerp(fa[i].Pos, fb[i].Pos, t), MathQ.Normalize(Quaternion.Slerp(fa[i].Rot, fb[i].Rot, t)));
return result;
}
static void ResampleTo(AnimClip clip, MotionRig rig, int count, float fps)
{
var old = clip.Frames;
var scale = (old.Count - 1) / (float)(count - 1);
var frames = new List<XForm[]>(count);
for (var f = 0; f < count; f++) frames.Add(Sample(old, f * scale));
clip.Frames = frames;
clip.Fps = fps;
Remap(clip, f => Math.Clamp((int)MathF.Round(f / scale), 0, count - 1));
Finish(clip, rig);
}
// ------------------------------------------------------------------ root motion
/// <summary>Removes horizontal travel: the character stays over its start position.</summary>
public static void MakeInPlace(AnimClip clip, MotionRig rig)
{
if (rig.HipsIndex < 0) throw new InvalidOperationException("This skeleton has no hips bone to keep in place.");
// The travel is the hips' ground path with the natural sway smoothed out (a quarter second window).
// The whole body is moved back by it via the motion root, so rigs with a separate root bone and rigs
// where the hips are the root behave the same.
var path = RootTools.HipsTrajectory(clip.Frames, rig).Select(p => RootTools.Horizontal(rig, p)).ToArray();
var travel = SmoothPath(path, Math.Max(1, (int)MathF.Round(clip.Fps * 0.25f)));
for (var f = 0; f < clip.FrameCount; f++)
{
var w = RootTools.RootWorld(clip.Frames, rig, f);
RootTools.SetRootWorld(clip.Frames, rig, f, new XForm(w.Pos - (travel[f] - travel[0]), w.Rot));
}
clip.Export.RootMotion = ClipRootMotion.InPlace;
Finish(clip, rig);
}
/// <summary>Centered moving average with point-reflected (linearly extrapolated) edges, so a straight
/// path is reproduced exactly - including at the first and last frame.</summary>
internal static Vector3[] SmoothPath(Vector3[] path, int halfWindow)
{
var n = path.Length;
var result = new Vector3[n];
if (n == 0) return result;
Vector3 At(int i) => i < 0 ? 2f * path[0] - path[Math.Min(-i, n - 1)]
: i >= n ? 2f * path[n - 1] - path[Math.Max(2 * (n - 1) - i, 0)] : path[i];
for (var i = 0; i < n; i++)
{
var sum = Vector3.Zero;
for (var k = -halfWindow; k <= halfWindow; k++) sum += At(i + k);
result[i] = sum / (2 * halfWindow + 1);
}
return result;
}
/// <summary>
/// Removes slow unwanted drift: the straight-line horizontal offset (and heading change) between the
/// first and last frame is distributed out over the clip, so it starts and ends at the same spot
/// facing the same way. Meant for idles and in-place loops.
/// </summary>
public static void RemoveRootDrift(AnimClip clip, MotionRig rig, bool position = true, bool heading = true)
{
var n = clip.FrameCount;
if (n < 2) return;
var start = RootTools.GroundFrame(clip.Frames, rig, 0);
var end = RootTools.GroundFrame(clip.Frames, rig, n - 1);
var dPos = end.Pos - start.Pos;
var dYaw = WrapAngle(RootTools.RootHeading(clip.Frames, rig, n - 1) - RootTools.RootHeading(clip.Frames, rig, 0));
for (var f = 0; f < n; f++)
{
var t = f / (float)(n - 1);
var correctionYaw = heading ? RootTools.Yaw(rig, -dYaw * t) : Quaternion.Identity;
var pivot = RootTools.GroundFrame(clip.Frames, rig, f).Pos;
var w = RootTools.RootWorld(clip.Frames, rig, f);
// rotate about the root's own ground position so heading changes don't swing it around
var p = pivot + Vector3.Transform(w.Pos - pivot, correctionYaw);
if (position) p -= dPos * t;
RootTools.SetRootWorld(clip.Frames, rig, f, new XForm(p, MathQ.Normalize(correctionYaw * w.Rot)));
}
Finish(clip, rig);
}
/// <summary>
/// Moves/turns the whole clip over the ground. With <paramref name="progressive"/> the offset grows
/// from zero at the first frame to the full value at the last (bends or lengthens the path instead).
/// </summary>
public static void OffsetRoot(AnimClip clip, MotionRig rig, Vector3 translation, float yawDegrees, bool progressive)
{
var n = clip.FrameCount;
var pivot = RootTools.GroundFrame(clip.Frames, rig, 0).Pos;
for (var f = 0; f < n; f++)
{
var t = progressive ? (n == 1 ? 1f : f / (float)(n - 1)) : 1f;
var yaw = RootTools.Yaw(rig, yawDegrees * t * MathF.PI / 180f);
var w = RootTools.RootWorld(clip.Frames, rig, f);
var p = pivot + Vector3.Transform(w.Pos - pivot, yaw) + translation * t;
RootTools.SetRootWorld(clip.Frames, rig, f, new XForm(p, MathQ.Normalize(yaw * w.Rot)));
}
Finish(clip, rig);
}
/// <summary>Moves the root so the clip starts at the origin facing the rig's forward.</summary>
public static void ResetStart(AnimClip clip, MotionRig rig)
{
var start = RootTools.GroundFrame(clip.Frames, rig, 0);
var restGround = RootTools.Horizontal(rig, rig.Skeleton.RestWorld[rig.RootIndex].Pos);
var inv = start.Inverse();
var toRest = XForm.Compose(new XForm(restGround, Quaternion.Identity), inv);
RootTools.TransformRange(clip.Frames, rig, 0, clip.FrameCount - 1, toRest);
Finish(clip, rig);
}
// ------------------------------------------------------------------ loops
/// <summary>
/// Makes the clip loop seamlessly: the pose difference between the last and the first frame is blended
/// out over the last <paramref name="blendFrames"/> frames (horizontal travel and heading are kept, so a
/// walk cycle still moves forward). The clip is marked looping.
/// </summary>
public static void MakeSeamlessLoop(AnimClip clip, MotionRig rig, int blendFrames)
{
var n = clip.FrameCount;
if (n < 4) throw new InvalidOperationException("The clip is too short to loop.");
blendFrames = Math.Clamp(blendFrames, 1, n - 2);
var first = clip.Frames[0];
var last = clip.Frames[n - 1];
var bones = rig.Skeleton.Count;
// Per-bone correction that turns the last pose into the first. For the motion root only the
// non-ground part (height, tilt) is corrected: travel and heading stay authored.
var corrections = new XForm[bones];
for (var b = 0; b < bones; b++)
{
if (b == rig.RootIndex) continue;
corrections[b] = KeyLayer.DeltaBetween(last[b], first[b]);
}
var firstRoot = RootTools.RootWorld(clip.Frames, rig, 0);
var lastRoot = RootTools.RootWorld(clip.Frames, rig, n - 1);
var firstGround = RootTools.GroundFrame(clip.Frames, rig, 0);
var lastGround = RootTools.GroundFrame(clip.Frames, rig, n - 1);
// the first root expressed relative to its ground frame, re-applied on the last ground frame
var firstRelative = XForm.Compose(firstGround.Inverse(), firstRoot);
var targetRoot = XForm.Compose(lastGround, firstRelative);
var rootCorrection = new XForm(targetRoot.Pos - lastRoot.Pos, MathQ.Normalize(targetRoot.Rot * Quaternion.Conjugate(lastRoot.Rot)));
for (var i = 0; i < blendFrames; i++)
{
var f = n - blendFrames + i;
var t = (i + 1) / (float)blendFrames;
var w = t * t * (3f - 2f * t);
var frame = clip.Frames[f];
for (var b = 0; b < bones; b++)
{
if (b == rig.RootIndex) continue;
frame[b] = KeyLayer.ApplyDelta(frame[b], Scale(corrections[b], w));
}
var root = RootTools.RootWorld(clip.Frames, rig, f);
RootTools.SetRootWorld(clip.Frames, rig, f, KeyLayer.ApplyDelta(root, Scale(rootCorrection, w)));
}
clip.Looping = true;
Finish(clip, rig);
}
// ------------------------------------------------------------------ helpers
/// <summary>Re-derives helper bones and fixes quaternion continuity after any edit.</summary>
public static void Finish(AnimClip clip, MotionRig rig)
{
if (rig.Rig.BonesOfClass(Rig.BoneClass.IkBaked).Any())
{
try { IkBoneBaker.Bake(clip.Frames, rig.Rig); }
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) { /* rig without baked IK layout */ }
}
QuaternionContinuity.AlignFrames(clip.Frames);
clip.PinnedFrames.RemoveWhere(f => f < 0 || f >= clip.FrameCount);
clip.Events.RemoveAll(e => e.Frame < 0 || e.Frame >= clip.FrameCount);
clip.Revision++;
}
/// <summary>Concatenates two frame lists, moving the second rigidly over the ground so its start
/// continues from the first one's end (one frame of the previous velocity is kept).</summary>
public static List<XForm[]> Splice(MotionRig rig, List<XForm[]> a, List<XForm[]> b)
{
var result = new List<XForm[]>(a.Count + b.Count);
result.AddRange(a);
if (b.Count == 0) return result;
var copyB = AnimClip.CopyFrames(b);
if (a.Count > 0)
{
var endA = RootTools.GroundFrame(a, rig, a.Count - 1);
// step that the motion would have taken: velocity from the last two frames of A
var step = a.Count >= 2
? RootTools.GroundFrame(a, rig, a.Count - 1).Pos - RootTools.GroundFrame(a, rig, a.Count - 2).Pos
: Vector3.Zero;
var startB = RootTools.GroundFrame(copyB, rig, 0);
var target = new XForm(endA.Pos + step, endA.Rot);
var transform = XForm.Compose(target, startB.Inverse());
RootTools.TransformRange(copyB, rig, 0, copyB.Count - 1, transform);
}
result.AddRange(copyB);
return result;
}
static XForm Scale(XForm delta, float w)
=> new(delta.Pos * w, MathQ.Normalize(Quaternion.Slerp(Quaternion.Identity, delta.Rot, w)));
static RootMotionAxes Axes(MotionRig rig) => new()
{
Up = rig.Up,
RootIndex = rig.RootIndex,
HipsIndex = rig.HipsIndex >= 0 ? rig.HipsIndex : rig.RootIndex,
HipsParentIsRoot = rig.Skeleton[rig.HipsIndex >= 0 ? rig.HipsIndex : rig.RootIndex].ParentIndex == rig.RootIndex,
};
static float WrapAngle(float a)
{
while (a > MathF.PI) a -= 2f * MathF.PI;
while (a < -MathF.PI) a += 2f * MathF.PI;
return a;
}
static void CheckRange(AnimClip clip, int start, int end)
{
if (start < 0 || end >= clip.FrameCount || start > end)
throw new ArgumentOutOfRangeException(nameof(start), $"Frame range {start}-{end} is outside the clip (0-{clip.FrameCount - 1}).");
}
/// <summary>Moves events, pinned frames and keys through a frame mapping (null = removed).</summary>
static void Remap(AnimClip clip, Func<int, int?> map)
{
foreach (var e in clip.Events.ToList())
{
if (map(e.Frame) is int f) e.Frame = f; else clip.Events.Remove(e);
}
clip.Events.Sort((a, b) => a.Frame.CompareTo(b.Frame));
clip.PinnedFrames = new SortedSet<int>(clip.PinnedFrames.Select(map).Where(f => f.HasValue).Select(f => f!.Value));
var keys = clip.Keys.Enumerate().ToList();
var layer = new KeyLayer { FalloffFrames = clip.Keys.FalloffFrames };
foreach (var (bone, frame, delta) in keys)
if (map(frame) is int f) layer.SetKey(bone, f, delta);
clip.Keys = layer;
}
}