Editor/ProcessingSection.cs
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;
namespace HumanoidMocapX.EditorTools;
/// <summary>Settings › Processing › Setup › Processing: where captures run. This computer is saved at
/// once. A provider needs its address, then a browser sign-in with an option its site offers. A
/// processing machine needs its SSH address and worker path, then a successful Check. Until then the
/// current choice and addresses stay in use. <c>applied</c> runs after a choice is saved, so the
/// window can rebuild around it.</summary>
sealed class ProcessingSection : Widget
{
static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(30);
readonly HostingSettings settings;
readonly SshSettings machine;
readonly Action applied;
readonly float labelWidth;
readonly Widget provider, remote;
readonly LineEdit address, machineAddress, worker;
readonly Button connect, check;
readonly Widget signIns;
readonly Label status, problem;
CancellationTokenSource pending;
bool building = true;
internal ProcessingSection(Widget parent, HostingSettings settings, SshSettings machine, Action applied, float labelWidth) : base(parent)
{
this.settings = settings; this.machine = machine; this.applied = applied; this.labelWidth = labelWidth;
Layout = Layout.Column(); Layout.Spacing = 6;
var configured = ProcessingSetup.Configured; var hosted = configured && settings.Enabled; var remoteChosen = configured && !hosted && machine.Enabled;
var row = Layout.AddRow(); row.Spacing = 8;
row.Add(new Label("Runs on", this) { FixedWidth = labelWidth });
var mode = row.Add(Ui.Framed(new ComboBox(this)), 1);
if (!configured) mode.AddItem("Not set up · choose one", "help_outline", () => Show(null), selected: true);
mode.AddItem("This computer", "memory", UseThisComputer,
LocalCapture.Availability ?? "Your GPU processes videos; nothing leaves this computer.", configured && !hosted && !remoteChosen);
mode.AddItem("A provider", "cloud", () => Show(provider),
"A provider processes videos for you after you sign in.", hosted);
mode.AddItem("Processing machine (SSH)", "dns", () => Show(remote),
"Your own Linux machine processes videos with humanoid-mocap-worker over SSH.", remoteChosen);
provider = Layout.Add(new Widget(this) { Layout = Layout.Column(), Visible = hosted });
provider.Layout.Spacing = 6;
var line = provider.Layout.AddRow(); line.Spacing = 8;
line.Add(new Label("Address", provider) { FixedWidth = labelWidth });
address = line.Add(Ui.Framed(new LineEdit(provider) { Text = settings.ServiceUrl, PlaceholderText = "mocap.example.com", MaxLength = 2048 }), 1);
connect = line.Add(Ui.Secondary(hosted ? "Reconnect" : "Connect", "link", () => _ = ConnectAsync()));
address.ReturnPressed += () => _ = ConnectAsync();
signIns = provider.Layout.Add(new Widget(provider) { Layout = Layout.Row(), Visible = false });
signIns.Layout.Spacing = 8;
remote = Layout.Add(new Widget(this) { Layout = Layout.Column(), Visible = remoteChosen });
remote.Layout.Spacing = 6;
var host = remote.Layout.AddRow(); host.Spacing = 8;
host.Add(new Label("Address", remote) { FixedWidth = labelWidth });
machineAddress = host.Add(Ui.Framed(new LineEdit(remote) { Text = machine.Address, PlaceholderText = "[email protected]", MaxLength = 512 }), 1);
check = host.Add(Ui.Secondary("Check", "network_check", () => _ = CheckAsync()));
var path = remote.Layout.AddRow(); path.Spacing = 8;
path.Add(new Label("Worker", remote) { FixedWidth = labelWidth });
worker = path.Add(Ui.Framed(new LineEdit(remote) { Text = machine.Worker, PlaceholderText = SshSettings.DefaultWorker, MaxLength = 1024 }), 1);
machineAddress.ReturnPressed += () => _ = CheckAsync(); worker.ReturnPressed += () => _ = CheckAsync();
status = Layout.Add(Ui.Muted(new Label("", this) { WordWrap = true, Visible = false }));
problem = Layout.Add(Ui.Error(this));
Layout.Add(SettingsText.Note(this, !configured ? "Choose this computer, a provider or a processing machine before capturing videos."
: hosted ? "Your provider receives the videos you capture and your account ID. Results are deleted 24 hours after processing."
: remoteChosen ? $"Videos are copied to {machine.Address} over SSH and processed there; each job's files are removed afterwards. Sign-in must work without a password prompt (an SSH key)."
: LocalCapture.Availability ?? "Videos are processed on this computer with the installed models (Local models below)."));
building = false;
}
// The address rows belong to the provider and processing machine choices; null shows neither.
void Show(Widget panel)
{
if (building) return;
provider.Visible = panel == provider; remote.Visible = panel == remote;
signIns.Visible = false; Ui.Show(problem, null); Ui.Show(status, null);
}
void UseThisComputer()
{
Show(null);
if (building || ProcessingSetup.Configured && !settings.Enabled && !machine.Enabled) return;
pending?.Cancel();
ProcessingSetup.Choose(settings, machine, false);
applied();
}
// Checks the typed machine without touching the one in use; a ready machine becomes the choice.
async Task CheckAsync()
{
if (pending is not null) return;
Ui.Show(problem, null);
var typed = new SshSettings { Address = machineAddress.Text.Trim(), Worker = worker.Text.Trim() };
if (typed.Problem is { } invalid) { Ui.Show(problem, invalid); return; }
using var cancellation = new CancellationTokenSource(CheckTimeout); pending = cancellation; var token = cancellation.Token;
check.Enabled = false; Ui.Show(status, "Checking " + typed.Address + "…");
try
{
await SshCapture.CheckAsync(typed, token);
await EditorPipeline.SwitchToMainThread();
if (!this.IsValid()) return;
Ui.Show(status, typed.Address + " is ready: the worker starts and FFmpeg is installed.");
if (ProcessingSetup.Configured && machine.Enabled && machine.Address == typed.Address && machine.Worker == typed.Worker) return;
machine.Address = typed.Address; machine.Worker = typed.Worker;
ProcessingSetup.Choose(settings, machine, false, remote: true);
applied();
}
catch (Exception failure) when (failure is InvalidOperationException or ArgumentException or OperationCanceledException or System.IO.IOException)
{
await EditorPipeline.SwitchToMainThread();
Ui.Show(status, null);
Ui.Show(problem, failure is OperationCanceledException ? "The processing machine did not answer in time." : failure.Message);
}
finally
{
pending = null;
if (check.IsValid()) check.Enabled = true;
}
}
// Reaches the typed site without touching the address in use, then offers its sign-in options.
async Task ConnectAsync()
{
Ui.Show(problem, null); signIns.Visible = false;
var typed = address.Text.Trim();
if (typed.Length == 0) { Ui.Show(problem, "Enter your provider's address first."); return; }
connect.Enabled = false;
try
{
using var client = new HostSignIn();
var offered = await client.ProvidersAsync(new HostingSettings { ServiceUrl = typed }.Endpoint(), default);
await EditorPipeline.SwitchToMainThread();
if (this.IsValid()) ShowSignIns(typed, offered);
}
catch (Exception failure) when (failure is ArgumentException or System.Net.Http.HttpRequestException or System.IO.InvalidDataException
or System.Text.Json.JsonException or OperationCanceledException)
{
await EditorPipeline.SwitchToMainThread();
if (problem.IsValid()) Ui.Show(problem, failure is OperationCanceledException ? "The provider did not answer in time." : failure.Message);
}
finally { if (connect.IsValid()) connect.Enabled = true; }
}
void ShowSignIns(string typed, IReadOnlyList<HostProvider> offered)
{
signIns.Layout.Clear(true);
signIns.Layout.AddSpacingCell(labelWidth + 8);
foreach (var option in offered)
signIns.Layout.Add(new MocapButton("Sign in with " + option.Label, "login") { FixedHeight = Ui.RowHeight, Clicked = () => _ = SignInAsync(typed, option) });
signIns.Layout.AddStretchCell();
signIns.Visible = true;
}
// One sign-in at a time: the buttons stay disabled until it ends, so two attempts never
// swap the address under each other.
async Task SignInAsync(string typed, HostProvider option)
{
if (pending is not null) return;
Ui.Show(problem, null); Ui.Show(status, "Finish signing in in your browser. This window continues by itself.");
using var cancellation = new CancellationTokenSource(); pending = cancellation; var token = cancellation.Token;
signIns.Enabled = connect.Enabled = false;
var previous = settings.ServiceUrl;
settings.ServiceUrl = typed;
try
{
await ProcessingSetup.SignInAsync(settings, option, token);
await EditorPipeline.SwitchToMainThread();
if (token.IsCancellationRequested) { settings.ServiceUrl = previous; return; }
ProcessingSetup.Choose(settings, machine, true);
applied();
}
catch (Exception failure) when (ProcessingSetup.IsSignInFailure(failure))
{
await EditorPipeline.SwitchToMainThread();
settings.ServiceUrl = previous;
if (!token.IsCancellationRequested && problem.IsValid())
Ui.Show(problem, failure is OperationCanceledException ? "Sign-in timed out. Try again." : failure.Message);
}
finally
{
pending = null;
if (status.IsValid()) { Ui.Show(status, null); signIns.Enabled = connect.Enabled = true; }
}
}
public override void OnDestroyed()
{
pending?.Cancel();
base.OnDestroyed();
}
}