TrimEdit is a small immutable-state editor for clip trimming. It stores which original frame indices are kept, supports deleting and keeping ranges, full restore, and undo/redo using stacks of int arrays.
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.Linq;
namespace HumanoidRetargeter.Core.Trim;
/// <summary>
/// The trim editor's state: which frames of a clip are kept, with undo and redo. Positions are indices
/// into the kept frames (what the timeline shows); <see cref="Kept"/> holds the original frame numbers.
/// </summary>
public sealed class TrimEdit
{
const int MinimumFrames = 2;
readonly Stack<int[]> _undo = new();
readonly Stack<int[]> _redo = new();
int[] _kept;
/// <summary>Starts from <paramref name="kept"/>, or every frame when null.</summary>
public TrimEdit(int frameCount, IReadOnlyList<int>? kept = null)
{
if (frameCount < 1)
throw new ArgumentOutOfRangeException(nameof(frameCount));
FrameCount = frameCount;
_kept = kept is { Count: > 0 } && kept.All(f => f >= 0 && f < frameCount)
? kept.ToArray()
: Enumerable.Range(0, frameCount).ToArray();
}
/// <summary>Frames in the untrimmed clip.</summary>
public int FrameCount { get; }
/// <summary>Original frame numbers still in the clip, in order.</summary>
public IReadOnlyList<int> Kept => _kept;
/// <summary>True when any frame has been cut.</summary>
public bool IsTrimmed => _kept.Length != FrameCount;
/// <summary>True when there is an edit to undo.</summary>
public bool CanUndo => _undo.Count > 0;
/// <summary>True when there is an undone edit to redo.</summary>
public bool CanRedo => _redo.Count > 0;
/// <summary>True when frames were cut out between <paramref name="position"/> and the next one.</summary>
public bool IsCutAfter(int position)
=> position >= 0 && position + 1 < _kept.Length && _kept[position + 1] != _kept[position] + 1;
/// <summary>Removes positions <paramref name="start"/>..<paramref name="end"/> (inclusive).</summary>
public bool Delete(int start, int end)
{
(start, end) = Clamp(start, end);
if (_kept.Length - (end - start + 1) < MinimumFrames)
return false;
return Set(_kept.Take(start).Concat(_kept.Skip(end + 1)).ToArray());
}
/// <summary>Keeps only positions <paramref name="start"/>..<paramref name="end"/> (inclusive).</summary>
public bool Keep(int start, int end)
{
(start, end) = Clamp(start, end);
if (end - start + 1 < MinimumFrames)
return false;
return Set(_kept[start..(end + 1)]);
}
/// <summary>Brings back every frame (undoable).</summary>
public bool Restore() => Set(Enumerable.Range(0, FrameCount).ToArray());
/// <summary>Steps back one edit.</summary>
public bool Undo() => Swap(_undo, _redo);
/// <summary>Re-applies the last undone edit.</summary>
public bool Redo() => Swap(_redo, _undo);
(int, int) Clamp(int start, int end)
{
if (start > end)
(start, end) = (end, start);
return (Math.Clamp(start, 0, _kept.Length - 1), Math.Clamp(end, 0, _kept.Length - 1));
}
bool Set(int[] next)
{
if (next.AsSpan().SequenceEqual(_kept))
return false;
_undo.Push(_kept);
_redo.Clear();
_kept = next;
return true;
}
bool Swap(Stack<int[]> from, Stack<int[]> to)
{
if (from.Count == 0)
return false;
to.Push(_kept);
_kept = from.Pop();
return true;
}
}