Editor/Hosting/HostedCapture.cs
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using HumanoidMocapX.Core.Mapping;
using HumanoidMocapX.Core.Motion;
namespace HumanoidMocapX.EditorTools;
/// <summary>Talks to the signed-in provider: streams captures, lists and cancels the account's
/// queued jobs, and validates returned motion before import.</summary>
internal sealed class HostedCapture : IBodyCapture, IDisposable
{
const long MaximumResultBytes = 128L * 1024 * 1024;
// Editors trim by frame times, so allow the same rounding the provider does.
const double ClipTolerance = 0.01;
readonly HostingSettings settings;
readonly CaptureOptions options;
readonly string cache;
readonly HttpClient http;
readonly Func<TimeSpan, CancellationToken, Task> delay;
readonly JsonSerializerOptions json = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } };
readonly CancellationTokenSource lifetime = new();
readonly object gate = new();
bool active, disposed;
internal HostedCapture(HostingSettings settings, CaptureOptions options, string cache, HttpMessageHandler handler = null,
Func<TimeSpan, CancellationToken, Task> delay = null)
{
this.settings = settings; this.options = options; this.cache = cache;
http = new(handler ?? new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromMinutes(30) };
this.delay = delay ?? Task.Delay;
}
public string UnavailableReason
{
get
{
try { settings.Endpoint(); settings.RequireSignedIn(); }
catch (ArgumentException error) { return error.Message; }
return settings.Profile is { CanProcess: false } profile ? NotAllowed(profile) : null;
}
}
internal static string NotAllowed(HostProfile profile) => profile.InGuild == false
? "Join the provider's Discord server to process videos." : "This account is not whitelisted on this provider yet.";
/// <summary>The provider's limits and queue size.</summary>
internal Task<HostedCapabilities> CapabilitiesAsync(CancellationToken token)
=> Read<HostedCapabilities>(Endpoint(), settings.Token, "v1/capabilities", token);
/// <summary>The account's jobs on the provider, oldest first, with queue positions and estimates.</summary>
internal async Task<HostedJob[]> ListAsync(CancellationToken token)
{
using var request = Message(Endpoint(), settings.Token, HttpMethod.Get, "v1/jobs");
using var response = await Send(request, token);
await response.Content.LoadIntoBufferAsync(262144, token);
return JsonSerializer.Deserialize<HostedJob[]>(await response.Content.ReadAsStringAsync(token), json)
?? throw new InvalidDataException("The host returned an invalid job list.");
}
/// <summary>Creates a job and uploads the video; processing continues on the provider. Fails
/// with the provider's message when the account's job limit is reached.</summary>
internal async Task<HostedJob> SubmitAsync(string video, double start, double? end, CancellationToken token)
{
var endpoint = Endpoint(); var key = settings.Token;
await using var input = new FileStream(video, FileMode.Open, FileAccess.Read, FileShare.Read, 65536, true);
var job = await Create(endpoint, key, video, input.Length, start, end, token);
await Upload(endpoint, key, job.Id, video, input, token);
return job;
}
/// <summary>Cancels a waiting job or removes a finished one. The provider refuses to cancel a job
/// that is already processing.</summary>
internal async Task CancelAsync(Guid job, CancellationToken token)
{
using var request = Message(Endpoint(), settings.Token, HttpMethod.Delete, "v1/jobs/" + job.ToString("D"));
using var response = await Send(request, token);
}
/// <summary>Downloads and validates a completed job's motion; returns the saved .hmotion path.</summary>
internal Task<string> DownloadAsync(Guid job, string video, CancellationToken token)
=> Task.Run(() => Download(Endpoint(), settings.Token, "v1/jobs/" + job.ToString("D") + "/result", video, token), token);
Uri Endpoint() { var endpoint = settings.Endpoint(); settings.RequireSignedIn(); return endpoint; }
public async Task<string> CaptureAsync(string video, double start, double? end,
Action<string> progress, CancellationToken cancellationToken)
{
var endpoint = Endpoint(); var key = settings.Token;
lock (gate)
{
ObjectDisposedException.ThrowIf(disposed, this);
if (active) throw new InvalidOperationException("Wait for the current hosted capture to finish.");
active = true;
}
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lifetime.Token);
linked.CancelAfter(TimeSpan.FromHours(2));
var token = linked.Token; Guid? job = null;
try
{
await using var input = new FileStream(video, FileMode.Open, FileAccess.Read, FileShare.Read, 65536, true);
job = (await Create(endpoint, key, video, input.Length, start, end, token)).Id;
var path = "v1/jobs/" + job.Value.ToString("D");
progress?.Invoke("Uploading video to " + endpoint.Host + "…");
await Upload(endpoint, key, job.Value, video, input, token);
while (true)
{
var (status, interval) = await ReadStatus(endpoint, key, path, progress, token);
if (status.Id != job || status.Message is null || status.Message.Length > 200
|| status.QueuePosition is < 1 or > 1_000_000 || status.QueuePosition.HasValue && status.State != "queued")
throw new InvalidDataException("The host returned an invalid job status.");
progress?.Invoke(Describe(status));
if (status.State == "completed") break;
if (status.State is "failed" or "cancelled" or "cancelling")
throw new InvalidOperationException("Hosted capture " + status.State + ". Job: " + job);
if (status.State is not ("queued" or "processing")) throw new InvalidDataException("Unexpected hosted capture state: " + status.State);
await delay(interval, token);
}
return await Task.Run(() => Download(endpoint, key, path + "/result", video, token), token);
}
finally
{
try { if (job.HasValue) await RemoveJob(endpoint, key, job.Value, progress); }
finally { lock (gate) { active = false; if (disposed) Release(); } }
}
}
/// <summary>One status line: message, place in line, progress and the provider's time estimates.</summary>
internal static string Describe(HostedJob job)
{
var text = job.Message;
if (job.QueuePosition is { } position) text += $" · {position}" + (job.QueueLength >= position ? $" of {job.QueueLength} in line" : " in line")
+ (job.Priority ? " (priority)" : "");
if (job.State == "processing" && job.Progress is >= 0 and <= 1) text += $" · {Math.Round(job.Progress.Value * 100)}%";
if (job.State == "queued" && job.SecondsUntilStart is { } start) text += " · starts in " + Duration(start);
if (job.State is "queued" or "processing" && job.SecondsRemaining is { } left) text += " · about " + Duration(left) + " left";
return text;
}
/// <summary>Human-friendly durations for estimates: "45 s", "12 min", "1 h 5 min".</summary>
internal static string Duration(int seconds)
{
seconds = Math.Max(0, seconds);
if (seconds < 60) return seconds + " s";
var minutes = (int)Math.Round(seconds / 60.0);
return minutes < 60 ? minutes + " min" : $"{minutes / 60} h {minutes % 60} min";
}
async Task<HostedJob> Create(Uri endpoint, string key, string video, long bytes, double start, double? end, CancellationToken token)
{
var request = new HostedCaptureOptions(options.Model, options.CameraInterval, start, end); request.Validate();
var extension = Path.GetExtension(video).ToLowerInvariant();
if (extension is not (".mp4" or ".mov" or ".m4v" or ".avi" or ".mkv" or ".webm"))
throw new ArgumentException("Select a supported video file.", nameof(video));
var capabilities = await Read<HostedCapabilities>(endpoint, key, "v1/capabilities", token);
if (capabilities.Protocol != 1 || !capabilities.ProcessingAvailable)
throw new InvalidOperationException("The provider's video processor is not ready or uses an unsupported protocol.");
var maximum = capabilities.MaximumUploadBytes;
if (maximum < 1 || bytes < 1 || bytes > maximum)
throw new InvalidDataException($"The video is empty or larger than the {maximum / (1024 * 1024)} MiB this account may upload.");
if (capabilities.MaximumClipSeconds is { } clip && end - start > clip + ClipTolerance)
throw new InvalidDataException($"Clips are limited to {clip:0.##} seconds for this account. Shorten the range and try again.");
using var create = Message(endpoint, key, HttpMethod.Post, "v1/jobs");
var name = Path.GetFileName(video); if (name.Length > 120) name = name[..120];
create.Content = new StringContent(JsonSerializer.Serialize(new { request.Model, request.CameraInterval, request.Start, request.End, name }, json),
Encoding.UTF8, "application/json");
using var response = await Send(create, token);
var created = await Json<HostedJob>(response, token);
if (created.Id == Guid.Empty || created.State != "awaiting-upload") throw new InvalidDataException("The host returned an invalid capture job.");
return created;
}
async Task Upload(Uri endpoint, string key, Guid job, string video, Stream input, CancellationToken token)
{
using var upload = Message(endpoint, key, HttpMethod.Put, "v1/jobs/" + job.ToString("D") + "/video");
upload.Headers.Add("X-Video-Extension", Path.GetExtension(video).ToLowerInvariant());
upload.Content = new StreamContent(input, 65536);
upload.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
using var uploaded = await Send(upload, token);
}
async Task RemoveJob(Uri endpoint, string key, Guid job, Action<string> progress)
{
using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(10));
try
{
using var delete = Message(endpoint, key, HttpMethod.Delete, "v1/jobs/" + job.ToString("D"));
using var response = await http.SendAsync(delete, cleanup.Token);
if (response.StatusCode != HttpStatusCode.NotFound) response.EnsureSuccessStatusCode();
}
catch (Exception error) when (error is HttpRequestException or OperationCanceledException or IOException)
{ progress?.Invoke("Could not remove remote job " + job + ". The host may retain it until expiry."); }
}
async Task<string> Download(Uri endpoint, string key, string relative, string video, CancellationToken token)
{
Directory.CreateDirectory(cache);
var final = Path.Combine(cache, Guid.NewGuid().ToString("N") + ".hmotion"); var partial = final + ".partial";
try
{
using var request = Message(endpoint, key, HttpMethod.Get, relative);
using var response = await Send(request, token);
if (response.Content.Headers.ContentLength is > MaximumResultBytes) throw new InvalidDataException("The motion download exceeds the size limit.");
await using (var input = await response.Content.ReadAsStreamAsync(token))
await using (var output = new FileStream(partial, FileMode.CreateNew, FileAccess.Write, FileShare.None, 65536, true))
{
var buffer = new byte[65536]; long total = 0;
while (await input.ReadAsync(buffer, token) is var read && read > 0)
{
total += read;
if (total > MaximumResultBytes) throw new InvalidDataException("The motion download exceeds the size limit.");
await output.WriteAsync(buffer.AsMemory(0, read), token);
}
}
MotionDocument motion;
await using (var stream = new FileStream(partial, FileMode.Open, FileAccess.Read, FileShare.Read, 65536, true))
motion = await JsonSerializer.DeserializeAsync<MotionDocument>(stream, MotionDocument.JsonOptions, token)
?? throw new InvalidDataException("The host returned an empty motion document.");
motion.Validate();
if (!motion.Bones.Any(bone => bone.Role == BoneRole.Hips)) throw new InvalidDataException("Hosted capture must return full-body motion.");
// Remote metadata cannot select a local sidecar file or preview source.
motion.OriginalReconstruction = null; motion.SourceVideo = video;
await using (var stream = new FileStream(partial, FileMode.Create, FileAccess.Write, FileShare.None, 65536, true))
await JsonSerializer.SerializeAsync(stream, motion, MotionDocument.JsonOptions, token);
token.ThrowIfCancellationRequested(); File.Move(partial, final);
return final;
}
finally { if (File.Exists(partial)) File.Delete(partial); }
}
async Task<T> Read<T>(Uri endpoint, string key, string path, CancellationToken token)
{
using var request = Message(endpoint, key, HttpMethod.Get, path);
using var response = await Send(request, token);
return await Json<T>(response, token);
}
async Task<(HostedJob Job, TimeSpan Interval)> ReadStatus(Uri endpoint, string key, string path,
Action<string> progress, CancellationToken token)
{
const int maximumRetries = 6;
for (var retry = 0; ; retry++)
{
using var request = Message(endpoint, key, HttpMethod.Get, path);
HttpResponseMessage response;
try { response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token); }
catch (HttpRequestException) when (retry < maximumRetries)
{
progress?.Invoke("Waiting for the processing host to respond…");
await delay(TimeSpan.FromSeconds(2), token); continue;
}
using (response)
{
var interval = PollDelay(response.Headers.RetryAfter, DateTimeOffset.UtcNow);
if (response.IsSuccessStatusCode) return (await Json<HostedJob>(response, token), interval);
if (retry >= maximumRetries || response.StatusCode is not (HttpStatusCode.TooManyRequests
or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout))
throw Failure(request, response.StatusCode);
progress?.Invoke("The processing host is busy; waiting to check this job again…");
await delay(interval, token);
}
}
}
internal static TimeSpan PollDelay(RetryConditionHeaderValue retry, DateTimeOffset now)
{
var seconds = retry?.Delta?.TotalSeconds ?? (retry?.Date is { } date ? (date - now).TotalSeconds : 1);
return TimeSpan.FromSeconds(Math.Clamp(seconds, 1, 30));
}
async Task<T> Json<T>(HttpResponseMessage response, CancellationToken token)
{
await response.Content.LoadIntoBufferAsync(16384, token);
return JsonSerializer.Deserialize<T>(await response.Content.ReadAsStringAsync(token), json)
?? throw new InvalidDataException("The host returned an empty API response.");
}
async Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token)
{
var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
if (response.IsSuccessStatusCode) return response;
using (response)
{
var detail = await ErrorMessage(response, token);
if (response.StatusCode == HttpStatusCode.Unauthorized && request.Headers.Authorization?.Parameter is { } rejected) settings.Expire(rejected);
throw Failure(request, response.StatusCode, detail);
}
}
// The provider explains refusals (not whitelisted, job limit, already processing); show its words.
static async Task<string> ErrorMessage(HttpResponseMessage response, CancellationToken token)
{
try
{
await response.Content.LoadIntoBufferAsync(4096, token);
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(token));
var root = document.RootElement;
var message = root.TryGetProperty("error", out var error) && error.TryGetProperty("message", out var text) ? text.GetString()
: root.TryGetProperty("detail", out var detail) ? detail.GetString() : null;
return message is { Length: > 0 and <= 300 } ? message : null;
}
catch (Exception error) when (error is JsonException or HttpRequestException or InvalidOperationException) { return null; }
}
static HttpRequestException Failure(HttpRequestMessage request, HttpStatusCode status, string detail = null)
=> new(status == HttpStatusCode.Unauthorized ? "Your sign-in expired. Sign in to your provider again."
: detail ?? "The provider returned HTTP " + (int)status + " for " + request.Method + " " + request.RequestUri.AbsolutePath, null, status);
static HttpRequestMessage Message(Uri endpoint, string key, HttpMethod method, string path)
{
var request = new HttpRequestMessage(method, new Uri(endpoint, path));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", key);
return request;
}
public void Dispose()
{
lock (gate)
{
if (disposed) return;
disposed = true; lifetime.Cancel();
if (!active) Release();
}
}
void Release() { http.Dispose(); lifetime.Dispose(); }
}