using System.Diagnostics; using System.Globalization; using System.Text; using System.Text.RegularExpressions; using System.IO; namespace AmiReel.Services; public sealed partial class ProcessRunner { public sealed record ProcessOutput(string Line, TimeSpan? Time, double? FramesPerSecond, double? Speed, int? Frame); public async Task RunAsync( string executable, IEnumerable arguments, Action? log, Action? position, CancellationToken token, bool allowFailure = false, Action? outputHandler = null) { ProcessStartInfo start = new() { FileName = executable, UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true, StandardErrorEncoding = Encoding.UTF8, StandardOutputEncoding = Encoding.UTF8 }; foreach (string argument in arguments) start.ArgumentList.Add(argument); using Process process = new() { StartInfo = start, EnableRaisingEvents = true }; StringBuilder output = new(); process.Start(); Task stdout = PumpAsync(process.StandardOutput, output, log, position, token, outputHandler); Task stderr = PumpAsync(process.StandardError, output, log, position, token, outputHandler); using CancellationTokenRegistration registration = token.Register(() => { try { if (!process.HasExited) process.Kill(true); } catch { } }); await Task.WhenAll(stdout, stderr, process.WaitForExitAsync(token)); if (process.ExitCode != 0 && !allowFailure) throw new InvalidOperationException($"Process exited with code {process.ExitCode}.\n{output}"); return output.ToString(); } private static async Task PumpAsync( StreamReader reader, StringBuilder output, Action? log, Action? position, CancellationToken token, Action? outputHandler) { while (await reader.ReadLineAsync(token) is { } line) { output.AppendLine(line); ProcessOutput parsed = ParseOutput(line); if (!IsNoise(parsed)) log?.Invoke(line); if (parsed.Time is { } time) position?.Invoke(time); outputHandler?.Invoke(parsed); } } private static ProcessOutput ParseOutput(string line) { TimeSpan? time = null; double? fps = null; double? speed = null; int? frame = null; Match timeMatch = TimeRegex().Match(line); if (timeMatch.Success && TimeSpan.TryParse(timeMatch.Groups[1].Value, CultureInfo.InvariantCulture, out TimeSpan parsedTime)) time = parsedTime; Match frameMatch = FrameRegex().Match(line); if (frameMatch.Success) { if (int.TryParse(frameMatch.Groups["frame"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedFrame)) frame = parsedFrame; if (double.TryParse(frameMatch.Groups["fps"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedFps)) fps = parsedFps; if (double.TryParse(frameMatch.Groups["speed"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedSpeed)) speed = parsedSpeed; } return new ProcessOutput(line, time, fps, speed, frame); } private static bool IsNoise(ProcessOutput output) { string line = output.Line.TrimStart(); if (string.IsNullOrWhiteSpace(line)) return true; if (line.StartsWith("frame=", StringComparison.OrdinalIgnoreCase)) return true; if (output.Frame is not null || output.Time is not null) return true; if (line.StartsWith("ERROR:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("WARNING:", StringComparison.OrdinalIgnoreCase) || line.Contains("skipped", StringComparison.OrdinalIgnoreCase) || line.Contains("could not", StringComparison.OrdinalIgnoreCase) || line.Contains("failed", StringComparison.OrdinalIgnoreCase) || line.Contains("not found", StringComparison.OrdinalIgnoreCase) || line.Contains("Output file is empty", StringComparison.OrdinalIgnoreCase)) return false; return line.StartsWith("ffmpeg version ", StringComparison.OrdinalIgnoreCase) || line.StartsWith("built with ", StringComparison.OrdinalIgnoreCase) || line.StartsWith("configuration:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("libavutil", StringComparison.OrdinalIgnoreCase) || line.StartsWith("libavcodec", StringComparison.OrdinalIgnoreCase) || line.StartsWith("libavformat", StringComparison.OrdinalIgnoreCase) || line.StartsWith("libavdevice", StringComparison.OrdinalIgnoreCase) || line.StartsWith("libavfilter", StringComparison.OrdinalIgnoreCase) || line.StartsWith("libswscale", StringComparison.OrdinalIgnoreCase) || line.StartsWith("libswresample", StringComparison.OrdinalIgnoreCase) || line.StartsWith("Input #", StringComparison.OrdinalIgnoreCase) || line.StartsWith("Output #", StringComparison.OrdinalIgnoreCase) || line.StartsWith("Stream mapping:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("Stream #", StringComparison.OrdinalIgnoreCase) || line.StartsWith("Metadata:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("Side data:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("encoder :", StringComparison.OrdinalIgnoreCase) || line.StartsWith("title :", StringComparison.OrdinalIgnoreCase) || line.StartsWith("CPB properties:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("Press [q] to stop", StringComparison.OrdinalIgnoreCase) || line.StartsWith("[", StringComparison.OrdinalIgnoreCase) || line.StartsWith("Duration:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("video:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("audio:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("subtitle:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("other streams:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("global headers:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("muxing overhead:", StringComparison.OrdinalIgnoreCase); } [GeneratedRegex(@"time=(\d{2}:\d{2}:\d{2}(?:\.\d+)?)", RegexOptions.CultureInvariant)] private static partial Regex TimeRegex(); [GeneratedRegex(@"frame=\s*(?\d+).*?fps=\s*(?\d+(?:\.\d+)?).*?speed=\s*(?\d+(?:\.\d+)?)x", RegexOptions.CultureInvariant)] private static partial Regex FrameRegex(); }