Editor/ModelDownloads.cs
using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;

namespace HumanoidMocapX.EditorTools;

/// <summary>Editor controls for explicit model downloads. Closing the widget cancels
/// any transfer and releases the HTTP client after its file operations have stopped.</summary>
internal sealed class ModelDownloads : Widget
{
    readonly CaptureOptions options;
    readonly Action changed;
    readonly HttpClient http = new() { Timeout = Timeout.InfiniteTimeSpan };
    readonly ModelStore store;
    readonly string cache;
    readonly (Label Name, IconButton Button, Func<CaptureModels, bool> Installed)[] rows;
    readonly Button cancel;
    readonly Label status;
    CancellationTokenSource transfer;
    string pendingMessage;
    bool destroyed;

    /// <summary><paramref name="changed"/> runs on the main thread after each download ends, so
    /// whatever waits for the models can recheck them.</summary>
    internal ModelDownloads(Widget parent, CaptureOptions options, Action changed) : base(parent)
    {
        this.options = options; this.changed = changed;
        cache = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "HumanoidMocapX", "models");
        store = new ModelStore(http, cache);
        Layout = Layout.Column(); Layout.Spacing = 6;
        rows = new[]
        {
            Model("FlowHMR · selected model", "", () => _ = DownloadAsync(null), paths => File.Exists(paths.Flow)),
            Model("SMPL-H neutral skeleton", "87.3 MB", () => _ = DownloadAsync(null, smpl: true), paths => File.Exists(paths.Smpl)),
            Model("SAM 3D Body DINOv3", "2.81 GB", () => _ = DownloadAsync(PerceptionModel.Sam3dBody), paths => File.Exists(paths.Sam) && File.Exists(paths.Mhr)),
            Model("VGGT-Omega 1B-512", "4.58 GB", () => _ = DownloadAsync(PerceptionModel.VggtOmega), paths => File.Exists(paths.Omega)),
            Model("YOLOX-L detector", "434 MB", () => _ = DownloadAsync(PerceptionModel.YoloXL), paths => File.Exists(paths.Detector))
        };
        var footer = Layout.AddRow(); footer.Spacing = 8;
        status = footer.Add(SettingsText.Note(this, "Downloads start only when clicked. Capture checks installed files without downloading."), 1);
        cancel = footer.Add(Ui.Secondary("Cancel download", "cancel", () => transfer?.Cancel())); cancel.Visible = false;
        footer.Add(Ui.Icon(this, "folder_open", () => { Directory.CreateDirectory(cache); EditorUtility.OpenFolder(cache); }, "Open the models folder: " + cache, Ui.RowHeight));
        Layout.Add(SettingsText.Note(this, "SAM, VGGT and SMPL-H come from public mirrors; their licenses are saved beside the files. SMPL-H allows noncommercial research, education and artistic use only."));
        Refresh();
    }

    /// <summary>Grays out models whose files are installed and shows a check instead of the download
    /// arrow. Clicking it only verifies the file: the download is skipped when the hash matches and
    /// happens only to repair a damaged file. Presence only here; capture verifies hashes too.</summary>
    internal void Refresh()
    {
        var paths = ModelStore.Paths(cache, options.Model);
        foreach (var (name, button, installed) in rows)
        {
            var done = installed(paths);
            button.Enabled = transfer is null;
            button.Icon = done ? "check" : "download";
            button.Foreground = done ? Theme.Green : Theme.Text;
            button.ToolTip = done ? name.Text + " is downloaded. Click to verify it; only a damaged file is downloaded again." : "Download " + name.Text;
            name.SetStyles($"color: {(done ? Theme.TextDisabled : Theme.Text).Hex};");
        }
    }

    (Label, IconButton, Func<CaptureModels, bool>) Model(string name, string size, Action download, Func<CaptureModels, bool> installed)
    {
        var row = Layout.AddRow(); row.Spacing = 8;
        var label = row.Add(new Label(name, this), 1);
        row.Add(SettingsText.Note(this, size));
        return (label, row.Add(Ui.Icon(this, "download", download, "Download " + name, Ui.RowHeight)), installed);
    }

    async Task DownloadAsync(PerceptionModel? perception, bool smpl = false)
    {
        if (destroyed || transfer is not null) return;
        var selected = options.Model;
        var name = smpl ? "SMPL-H neutral" : perception switch
        {
            PerceptionModel.Sam3dBody => "SAM 3D Body DINOv3",
            PerceptionModel.VggtOmega => "VGGT-Omega 1B-512",
            PerceptionModel.YoloXL => "YOLOX-L",
            _ => "FlowHMR " + selected
        };
        using var cancellation = new CancellationTokenSource(); transfer = cancellation;
        foreach (var row in rows) row.Button.Enabled = false;
        cancel.Visible = true; status.Text = "Preparing " + name + "…";
        string result;
        try
        {
            void Progress(string message) => Report(name + ": " + message);
            await Task.Run(() => smpl ? store.EnsureSmplAsync(Progress, cancellation.Token) : perception is { } model
                ? store.EnsurePerceptionAsync(model, Progress, cancellation.Token)
                : store.EnsureAsync(selected, Progress, cancellation.Token), cancellation.Token);
            result = name + " files saved and verified.";
        }
        catch (OperationCanceledException) { result = name + " download cancelled. Completed files are kept; the unfinished file restarts next time."; }
        catch (Exception error) { result = name + " download failed: " + error.Message; }
        await EditorPipeline.SwitchToMainThread();
        transfer = null; Interlocked.Exchange(ref pendingMessage, null);
        if (destroyed) { http.Dispose(); return; }
        Refresh();
        cancel.Visible = false; status.Text = result;
        changed();
    }

    void Report(string message) => Interlocked.Exchange(ref pendingMessage, message);

    [EditorEvent.Frame]
    void UpdateStatus()
    {
        if (!destroyed && this.IsValid() && Interlocked.Exchange(ref pendingMessage, null) is { } message)
            status.Text = message;
    }

    public override void OnDestroyed()
    {
        destroyed = true;
        if (transfer is null) http.Dispose(); else transfer.Cancel();
        base.OnDestroyed();
    }
}