Editor/VideoFrames.cs
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using HumanoidMocapX.EditorTools.Video;
using HumanoidMocapX.Inference;
namespace HumanoidMocapX.EditorTools;
/// <summary>Streams timestamped decoded pixels to a temporary 30 fps timeline.
/// Disposal deletes the cache; reads preserve original resolution and RGBA bytes.</summary>
internal sealed class VideoFrames : ICaptureFrames, IDisposable
{
internal const long MaximumCacheBytes = 16L * 1024 * 1024 * 1024;
readonly FileStream stream;
readonly int frameBytes;
public int Width { get; }
public int Height { get; }
public int Count { get; }
VideoFrames(FileStream stream, int width, int height, int count)
{
this.stream = stream; Width = width; Height = height; Count = count;
frameBytes = checked(width * height * 4);
}
/// <summary>Reads sequential samples, keeping the displayed frame at each target
/// timestamp. The caller owns the decoder and runs this off the editor thread.</summary>
internal static async Task<VideoFrames> CreateAsync(Func<CancellationToken, DecodedVideoFrame> read,
double duration, double start, double? end, string directory, Action<string> progress, CancellationToken token)
{
ArgumentNullException.ThrowIfNull(read);
var timeline = new CaptureTimeline(duration, start, end); token.ThrowIfCancellationRequested();
var current = Read(read, 0, 0, double.NegativeInfinity, token)
?? throw new InvalidDataException("Video contains no decoded frames.");
if (current.Time > start + CaptureTimeline.TimestampTolerance)
throw new InvalidDataException("The decoder did not return a frame covering the start of the selected range.");
var bytes = checked(current.Width * current.Height * 4);
var required = (long)bytes * timeline.Count;
if (required > MaximumCacheBytes)
throw new InvalidDataException("Decoded video would exceed the 16 GiB temporary cache limit. Select a shorter range.");
Directory.CreateDirectory(directory);
const long reserveBytes = 256L * 1024 * 1024;
if (OperatingSystem.IsWindows() && new DriveInfo(Path.GetPathRoot(Path.GetFullPath(directory))).AvailableFreeSpace - reserveBytes < required)
throw new IOException("Not enough free disk space for decoded video. Select a shorter range or free disk space.");
var path = Path.Combine(directory, Guid.NewGuid().ToString("N") + ".rgba");
var stream = new FileStream(path, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 65536,
FileOptions.Asynchronous | FileOptions.RandomAccess | FileOptions.DeleteOnClose);
try
{
var next = Read(read, current.Width, current.Height, current.Time, token);
for (var frame = 0; frame < timeline.Count; frame++)
{
token.ThrowIfCancellationRequested();
var time = timeline.Time(frame);
while (next is not null && next.Time <= time + CaptureTimeline.TimestampTolerance)
{
current = next;
next = Read(read, current.Width, current.Height, current.Time, token);
}
await stream.WriteAsync(current.Rgba, token).ConfigureAwait(false);
if (frame % 30 == 0 || frame == timeline.Count - 1)
progress?.Invoke($"Decoding video · {frame + 1}/{timeline.Count}");
}
await stream.FlushAsync(token).ConfigureAwait(false);
return new(stream, current.Width, current.Height, timeline.Count);
}
catch { await stream.DisposeAsync(); throw; }
}
static DecodedVideoFrame Read(Func<CancellationToken, DecodedVideoFrame> read, int width, int height, double previous, CancellationToken token)
{
token.ThrowIfCancellationRequested(); var frame = read(token);
if (frame is null) return null;
if (frame.Width < 1 || frame.Height < 1 || (long)frame.Width * frame.Height > 33_177_600
|| frame.Rgba is null || frame.Rgba.Length != (long)frame.Width * frame.Height * 4
|| width != 0 && (frame.Width != width || frame.Height != height))
throw new InvalidDataException("Decoded video has invalid or changing frame dimensions.");
if (!double.IsFinite(frame.Time) || frame.Time <= previous)
throw new InvalidDataException("Decoded video timestamps must be finite and strictly increasing.");
return frame;
}
public async ValueTask ReadAsync(int frame, Memory<byte> rgba, CancellationToken cancellation)
{
if ((uint)frame >= (uint)Count || rgba.Length != frameBytes)
throw new ArgumentException("Invalid capture frame or destination size.");
cancellation.ThrowIfCancellationRequested(); stream.Position = (long)frame * frameBytes;
await stream.ReadExactlyAsync(rgba, cancellation).ConfigureAwait(false);
}
public void Dispose() => stream.Dispose();
}