Editor/Server/CaptureProcessor.cs
#nullable enable annotations
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using HumanoidMocapX.Core.Motion;
using HumanoidMocapX;
namespace HumanoidMocapHost;
/// <summary>Adapts the library's capture pipeline to streamed hosted jobs. Each job owns
/// a temporary directory; the caller owns both streams. Capture and cleanup failures propagate.</summary>
internal sealed class CaptureProcessor : IClipProcessor, IClipInspector
{
readonly string directory;
readonly long maximumUploadBytes;
readonly Func<HostedCaptureOptions, string, Action<string, double?>, IBodyCapture> create;
readonly Func<string, CancellationToken, Task<double>>? duration;
internal CaptureProcessor(string directory, long maximumUploadBytes,
Func<HostedCaptureOptions, string, Action<string, double?>, IBodyCapture> create,
Func<string, CancellationToken, Task<double>>? duration = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
ArgumentNullException.ThrowIfNull(create);
if (maximumUploadBytes < 1) throw new ArgumentOutOfRangeException(nameof(maximumUploadBytes));
this.directory = Path.GetFullPath(directory); this.maximumUploadBytes = maximumUploadBytes; this.create = create;
this.duration = duration;
}
// The editor constructs this only after checking the decoder and installed models.
public bool IsAvailable => true;
public async Task<HostedCaptureOptions> InspectAsync(HostedCaptureOptions options, string video, CancellationToken cancellation)
{
options.Validate(); cancellation.ThrowIfCancellationRequested();
if (duration is null) return options;
var seconds = await duration(video, cancellation);
if (!double.IsFinite(seconds) || seconds <= options.Start)
throw new InvalidDataException("The uploaded video has no frames in the selected range.");
return options with { End = Math.Min(options.End ?? seconds, seconds) };
}
public async Task ProcessAsync(HostedCaptureOptions options, string extension, Stream video, Stream motion,
Action<string, double?> progress, CancellationToken cancellation)
{
ArgumentNullException.ThrowIfNull(options); options.Validate();
ArgumentNullException.ThrowIfNull(video); ArgumentNullException.ThrowIfNull(motion);
if (!video.CanRead || !motion.CanWrite) throw new ArgumentException("Capture needs a readable video and writable motion stream.");
if (extension is not (".mp4" or ".mov" or ".m4v" or ".avi" or ".mkv" or ".webm"))
throw new ArgumentException("Unsupported video extension.", nameof(extension));
cancellation.ThrowIfCancellationRequested();
var workspace = Path.Combine(directory, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workspace);
try
{
var path = Path.Combine(workspace, "video" + extension);
await using (var output = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 65536, true))
await CaptureQueue.CopyBounded(video, output, maximumUploadBytes, cancellation);
var capture = create(options, workspace, progress);
if (capture.UnavailableReason is { } reason) throw new InvalidOperationException(reason);
var result = await capture.CaptureAsync(path, options.Start, options.End, static _ => { }, cancellation);
await ExportAsync(workspace, result, motion, cancellation);
}
finally { Directory.Delete(workspace, true); }
}
static async Task ExportAsync(string workspace, string result, Stream destination, CancellationToken cancellation)
{
ArgumentException.ThrowIfNullOrWhiteSpace(result);
var relative = Path.GetRelativePath(workspace, Path.GetFullPath(result));
if (relative == ".." || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || Path.IsPathRooted(relative))
throw new InvalidDataException("The capture result is outside its job workspace.");
await using var input = new FileStream(result, FileMode.Open, FileAccess.Read, FileShare.Read, 65536, true);
if (input.Length is < 1 or > CaptureQueue.MaximumResultBytes)
throw new InvalidDataException("The capture result is empty or exceeds 128 MiB.");
var document = await JsonSerializer.DeserializeAsync<MotionDocument>(input, MotionDocument.JsonOptions, cancellation)
?? throw new InvalidDataException("The capture returned no motion document.");
document.Validate();
// Local paths belong to the provider; only the reconstructed motion is downloadable.
document.Name = "capture"; document.SourceVideo = ""; document.OriginalReconstruction = null;
await JsonSerializer.SerializeAsync(destination, document, MotionDocument.JsonOptions, cancellation);
}
}