Editor/LocalCapture.cs
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using HumanoidMocapX.Core.Motion;
using HumanoidMocapX.EditorTools.Video;
using HumanoidMocapX.Inference;

namespace HumanoidMocapX.EditorTools;

/// <summary>Connects installed models and timestamped decoding (Windows Media Foundation, or
/// the system FFmpeg elsewhere) to in-editor GPU inference. No network, capture worker or
/// runtime model download is used.</summary>
internal sealed class LocalCapture(CaptureOptions options, string models, string cache,
    Action<string, double?> reportProgress = null) : IBodyCapture
{
    internal const string FfmpegMissing = "Local video capture on Linux needs FFmpeg: install it so ffmpeg and ffprobe are on PATH.";
    internal static string Availability => OperatingSystem.IsWindows() || FfmpegVideoDecoder.IsInstalled ? null : FfmpegMissing;
    public string UnavailableReason => Availability;
    int active;

    public async Task<string> CaptureAsync(string video, double start, double? end, Action<string> progress, CancellationToken cancellationToken)
    {
        if (UnavailableReason is { } reason) throw new PlatformNotSupportedException(reason);
        var selected = new CaptureOptions { Model = options.Model, CameraInterval = options.CameraInterval, Lanes = options.Lanes };
        selected.Validate(); new HostedCaptureOptions(selected.Model, selected.CameraInterval, start, end).Validate();
        if (Interlocked.CompareExchange(ref active, 1, 0) != 0)
            throw new InvalidOperationException("Wait for the current local capture to finish.");
        try
        {
            void Report(string message) { progress?.Invoke(message); reportProgress?.Invoke(message, null); }
            using var frames = await Task.Run(async () =>
            {
                cancellationToken.ThrowIfCancellationRequested(); Report("Opening video decoder…");
                using var decoder = IVideoDecoder.Open(video);
                var duration = decoder.Duration;
                if (start > 0) decoder.Seek(start);
                return await VideoFrames.CreateAsync(decoder.Read, duration, start, end, cache, Report, cancellationToken);
            }, cancellationToken);
            var installed = await ModelStore.InstalledAsync(models, selected.Model, Report, cancellationToken);
            var (motion, person, detail) = await new FlowVideoProcessor(installed, selected, progress, reportProgress).RunAsync(frames, cancellationToken);
            motion.Video = video; motion.Name = Path.GetFileNameWithoutExtension(video); motion.StartTime += start;
            var note = person.Note(start, frames.Count);
            var result = await Task.Run(() => Save(motion, detail, note, cancellationToken), cancellationToken);
            reportProgress?.Invoke("Motion is ready", 1);
            return result;
        }
        finally { Volatile.Write(ref active, 0); }
    }

    async Task<string> Save(FlowMotion motion, ObservedDetail detail, string note, CancellationToken token)
    {
        Directory.CreateDirectory(cache);
        var path = Path.Combine(cache, Guid.NewGuid().ToString("N") + ".hmotion"); var partial = path + ".partial";
        try
        {
            var document = motion.ToDocument(); detail.Apply(document);
            if (note is not null) document.Diagnostics.Add(note);
            await using (var output = new FileStream(partial, FileMode.CreateNew, FileAccess.Write, FileShare.None, 65536, true))
                await JsonSerializer.SerializeAsync(output, document, MotionDocument.JsonOptions, token);
            token.ThrowIfCancellationRequested(); File.Move(partial, path);
            return path;
        }
        finally { if (File.Exists(partial)) File.Delete(partial); }
    }
}