XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEWorkflow

【WinUI】XFE基于windows的工作流

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using XFEWorkflow.Abstractions;

namespace XFEWorkflow.Plugins.Vision;

public sealed class VisionPlugin : IWorkflowPlugin
{
    public static PluginManifest Manifest
    {
        get { using var stream = typeof(VisionPlugin).Assembly.GetManifestResourceStream("XFEWorkflow.Plugins.Vision.plugin.json")!; return JsonSerializer.Deserialize<PluginManifest>(stream, WorkflowJson.Options)!; }
    }
    public void Register(IPluginRegistry registry)
    { foreach (var descriptor in Manifest.Capabilities) registry.RegisterAction(descriptor, () => new VisionAction(descriptor.Id)); }
}

public sealed class VisionAction(string capabilityId) : IWorkflowAction
{
    public async ValueTask<JsonObject> ExecuteAsync(JsonObject input, IWorkflowContext context, CancellationToken ct)
    {
        ct.ThrowIfCancellationRequested();
        var operation = capabilityId["xfe.vision.".Length..];
        if (operation == "selectRegion")
        {
            await context.InvokeHostAsync("desktop.acquire", [], ct);
            return await context.InvokeHostAsync("screen.selectRegion", input, ct);
        }
        if (operation is "clipboardRead" or "clipboardWrite")
        {
            await context.InvokeHostAsync("desktop.acquire", [], ct);
            return await context.InvokeHostAsync("screen." + operation, input, ct);
        }
        if (operation is "click" or "move" or "drag" or "scroll")
        {
            await context.InvokeHostAsync("desktop.acquire", [], ct);
            return await context.InvokeHostAsync("desktop.input", VisionParameters.Input(operation, input), ct);
        }
        if (input.ContainsKey("imagePath") && input["imagePath"] is null) throw new ArgumentException("图像路径绑定为空,未执行屏幕识别。");
        _ = Region(input);
        if (input.String("imagePath").Length == 0) await context.InvokeHostAsync("desktop.acquire", [], ct);
        if (operation == "capture")
        {
            var path = input.String("path"); if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("请选择截图保存路径。");
            if (!Path.GetExtension(path).Equals(".png", StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("截图保存路径必须使用 .png 扩展名。");
            return await Task.Run(() =>
            {
                ct.ThrowIfCancellationRequested(); using var frame = ScreenCapture.Capture(Region(input));
                var destination = Path.GetFullPath(path); Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
                frame.Bitmap.Save(destination, ImageFormat.Png);
                return new JsonObject { ["path"] = destination, ["region"] = frame.Region.ToJson() };
            }, ct);
        }
        if (operation is "pixel" or "waitColor")
        {
            var (x, y) = VisionParameters.ReadPoint(input, "point");
            var color = operation == "waitColor" ? VisionParameters.Color(input.String("color", "#FFFFFF")) : default;
            var tolerance = VisionParameters.Integer(input, "tolerance", 12, 0, 255);
            async Task<JsonObject> Probe()
            {
                var value = await Task.Run(() => ScreenCapture.Pixel(x, y), ct);
                return new() { ["color"] = $"#{value.R:X2}{value.G:X2}{value.B:X2}", ["rgb"] = new JsonObject { ["r"] = value.R, ["g"] = value.G, ["b"] = value.B },
                    ["found"] = Math.Abs(value.R - color.R) <= tolerance && Math.Abs(value.G - color.G) <= tolerance && Math.Abs(value.B - color.B) <= tolerance };
            }
            return operation == "pixel" ? await Probe() : await VisionPolling.WaitAsync(Probe, input, ct);
        }
        var isText = operation is "ocr" or "findText" or "waitText" or "clickText";
        if (!isText && operation is not ("findImage" or "waitImage" or "clickImage")) throw new NotSupportedException(capabilityId);
        using var recognizer = isText ? new OcrRecognizer(input.String("language", "chi_sim+eng")) : null;
        using var template = !isText ? TemplateImage.Load(input.String("templatePath")) : null;
        Task<JsonObject> Recognize() => Task.Run(() =>
        {
            ct.ThrowIfCancellationRequested(); using var frame = LoadFrame(input);
            if (recognizer is not null)
            {
                var result = recognizer.Recognize(frame, input, ct);
                return operation == "ocr" ? result : OcrTextSearch.Find(result, input.String("text"), input.String("matchMode", "contains"), input.Boolean("ignoreCase", true));
            }
            var maxMatches = VisionParameters.Integer(input, "maxMatches", 5, 1, 100);
            var matches = input["minimumSimilarity"] is not null
                ? ImageMatcher.FindSimilar(frame.Bitmap, template!.Bitmap, frame.Region, VisionParameters.Number(input, "minimumSimilarity", .9, .01, 1), maxMatches, ct)
                : ImageMatcher.Find(frame.Bitmap, template!.Bitmap, frame.Region, VisionParameters.Integer(input, "tolerance", 12, 0, 255), maxMatches, ct);
            return VisionParameters.Matches(matches, frame.CoordinateSpace);
        }, ct);
        var output = operation is "waitText" or "waitImage" ? await VisionPolling.WaitAsync(Recognize, input, ct) : await Recognize();
        if (operation is "clickText" or "clickImage")
        {
            if (!output.Boolean("found")) throw new InvalidOperationException("未找到要点击的目标,请检查识别区域、文字或模板。");
            if (output.String("coordinateSpace") != "screen") throw new InvalidOperationException("只允许点击屏幕识别结果。");
            var click = WorkflowJson.Copy(input); click["point"] = output["point"]!.DeepClone();
            await context.InvokeHostAsync("desktop.input", VisionParameters.Input("click", click), ct);
        }
        return output;
    }
    private static ScreenRegion? Region(JsonObject input) => !input.ContainsKey("region") ? null : input["region"] is { } value ? ScreenRegion.Parse(value) : throw new ArgumentException("区域绑定为空。请提供有效区域,或清除区域参数后识别整个桌面。");
    private static ScreenFrame LoadFrame(JsonObject input)
    {
        if (input.ContainsKey("imagePath") && input["imagePath"] is null) throw new ArgumentException("图像路径绑定为空,未执行屏幕识别。");
        return input.String("imagePath") is { Length: > 0 } path ? ScreenCapture.Load(path, Region(input)) : ScreenCapture.Capture(Region(input));
    }
}

public static class VisionParameters
{
    public static double Number(JsonObject input, string name, double fallback, double minimum, double maximum)
    {
        if (input[name] is null) return fallback;
        if (input[name] is not JsonValue scalar) throw new ArgumentException(name + " 必须是数值。");
        var value = scalar.TryGetValue<double>(out var number) ? number : scalar.TryGetValue<int>(out var integer) ? integer : double.NaN;
        if (!double.IsFinite(value) || value < minimum || value > maximum) throw new ArgumentException($"{name} 范围为 {minimum}~{maximum}。");
        return value;
    }
    public static int Integer(JsonObject input, string name, int fallback, int minimum, int maximum)
    {
        if (input[name] is null) return fallback;
        var value = ScreenRegion.ReadInteger(input, name);
        if (value < minimum || value > maximum) throw new ArgumentException($"{name} 范围为 {minimum}~{maximum}。");
        return value;
    }
    public static JsonObject Point(int x, int y) => new() { ["x"] = x, ["y"] = y };
    public static (int X, int Y) ReadPoint(JsonObject input, string name)
    {
        var obj = input[name] as JsonObject ?? throw new ArgumentException("请选择" + name + "屏幕坐标。");
        return (ScreenRegion.ReadInteger(obj, "x"), ScreenRegion.ReadInteger(obj, "y"));
    }
    public static Color Color(string value)
    {
        if (value.Length != 7 || value[0] != '#' || !int.TryParse(value.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var rgb)) throw new ArgumentException("颜色应为 #RRGGBB。");
        return System.Drawing.Color.FromArgb((rgb >> 16) & 255, (rgb >> 8) & 255, rgb & 255);
    }
    public static JsonObject Matches(JsonArray matches, string coordinateSpace, string text = "") => new()
    {
        ["found"] = matches.Count > 0, ["matches"] = matches, ["region"] = matches.FirstOrDefault()?.DeepClone(),
        ["point"] = matches.FirstOrDefault()?["point"]?.DeepClone(), ["coordinateSpace"] = coordinateSpace, ["text"] = text
    };
    public static JsonObject Input(string operation, JsonObject input)
    {
        var result = WorkflowJson.Copy(input); result["operation"] = operation == "move" ? "locate" : operation;
        if (operation is "click" or "move") { var (x, y) = ReadPoint(input, "point"); result["x"] = x; result["y"] = y; }
        if (operation is "click" or "drag")
        {
            var button = input.String("button", "left"); if (button is not ("left" or "right" or "middle")) throw new ArgumentException("鼠标按键无效。"); result["button"] = button;
        }
        if (operation == "click") result["clicks"] = Integer(input, "clicks", 1, 1, 2);
        if (operation == "drag") { var from = ReadPoint(input, "from"); var to = ReadPoint(input, "to"); result["fromX"] = from.X; result["fromY"] = from.Y; result["toX"] = to.X; result["toY"] = to.Y; result["durationMilliseconds"] = Integer(input, "durationMilliseconds", 500, 0, 60000); }
        if (operation == "scroll") result["delta"] = Integer(input, "delta", -120, -120000, 120000);
        return result;
    }
}

public static class VisionPolling
{
    public static async Task<JsonObject> WaitAsync(Func<Task<JsonObject>> probe, JsonObject input, CancellationToken ct)
    {
        var timeout = VisionParameters.Integer(input, "timeoutMilliseconds", 30000, 0, 3_600_000);
        var interval = VisionParameters.Integer(input, "intervalMilliseconds", 500, 100, 60_000);
        var watch = Stopwatch.StartNew();
        while (true)
        {
            ct.ThrowIfCancellationRequested(); var result = await probe(); ct.ThrowIfCancellationRequested();
            if (result.Boolean("found")) { result["timedOut"] = false; return result; }
            if (watch.ElapsedMilliseconds >= timeout) { result["timedOut"] = true; return result; }
            await Task.Delay((int)Math.Max(1, Math.Min(interval, timeout - watch.ElapsedMilliseconds)), ct);
        }
    }
}