From 5788392abdc3de9c458739207d75cd996c6a213f Mon Sep 17 00:00:00 2001 From: Jakob Lomander Boman Date: Sun, 22 Feb 2026 19:47:18 +0100 Subject: [PATCH] Add async invariants --- FFMpegCore/FFMpeg/FFMpeg.cs | 145 +++++++++++++++++++ FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs | 25 +++- FFMpegCore/FFProbe/FFProbe.cs | 46 +++++- FFMpegCore/GlobalFFOptions.cs | 77 ++++++++-- FFMpegCore/Helpers/FFMpegHelper.cs | 29 +++- FFMpegCore/Helpers/FFProbeHelper.cs | 33 ++++- 6 files changed, 328 insertions(+), 27 deletions(-) diff --git a/FFMpegCore/FFMpeg/FFMpeg.cs b/FFMpegCore/FFMpeg/FFMpeg.cs index 6a6d8c8b..8f45e95e 100644 --- a/FFMpegCore/FFMpeg/FFMpeg.cs +++ b/FFMpegCore/FFMpeg/FFMpeg.cs @@ -459,6 +459,31 @@ internal static IReadOnlyList GetPixelFormatsInternal() return list.AsReadOnly(); } + internal static async Task> GetPixelFormatsInternalAsync(CancellationToken cancellationToken = default) + { + FFMpegHelper.RootExceptionCheck(); + + var apa = await GlobalFFOptions.GetFFMpegBinaryPathAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + + var list = new List(); + var processArguments = new ProcessArguments(apa, "-pix_fmts"); + processArguments.OutputDataReceived += (e, data) => + { + if (PixelFormat.TryParse(data, out var format)) + { + list.Add(format); + } + }; + + var result = await processArguments.StartAndWaitForExitAsync(cancellationToken).ConfigureAwait(false); + if (result.ExitCode != 0) + { + throw new FFMpegException(FFMpegExceptionType.Process, string.Join("\r\n", result.OutputData)); + } + + return list.AsReadOnly(); + } + public static IReadOnlyList GetPixelFormats() { if (!GlobalFFOptions.Current.UseCache) @@ -469,6 +494,16 @@ public static IReadOnlyList GetPixelFormats() return FFMpegCache.PixelFormats.Values.ToList().AsReadOnly(); } + public static async Task> GetPixelFormatsAsync(CancellationToken cancellationToken = default) + { + if (!GlobalFFOptions.Current.UseCache) + { + return await GetPixelFormatsInternalAsync(cancellationToken).ConfigureAwait(false); + } + + return FFMpegCache.PixelFormats.Values.ToList().AsReadOnly(); + } + public static bool TryGetPixelFormat(string name, out PixelFormat format) { if (!GlobalFFOptions.Current.UseCache) @@ -522,6 +557,36 @@ private static void ParsePartOfCodecs(Dictionary codecs, string a } } + private static async Task ParsePartOfCodecsAsync(Dictionary codecs, string arguments, Func parser, CancellationToken cancellationToken = default) + { + FFMpegHelper.RootExceptionCheck(); + + var ffmpegPath = await GlobalFFOptions.GetFFMpegBinaryPathAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + + var processArguments = new ProcessArguments(ffmpegPath, arguments); + processArguments.OutputDataReceived += (e, data) => + { + var codec = parser(data); + if (codec != null) + { + if (codecs.TryGetValue(codec.Name, out var parentCodec)) + { + parentCodec.Merge(codec); + } + else + { + codecs.Add(codec.Name, codec); + } + } + }; + + var result = await processArguments.StartAndWaitForExitAsync(cancellationToken).ConfigureAwait(false); + if (result.ExitCode != 0) + { + throw new FFMpegException(FFMpegExceptionType.Process, string.Join("\r\n", result.OutputData)); + } + } + internal static Dictionary GetCodecsInternal() { var res = new Dictionary(); @@ -556,6 +621,40 @@ internal static Dictionary GetCodecsInternal() return res; } + internal static async Task> GetCodecsInternalAsync(CancellationToken cancellationToken = default) + { + var res = new Dictionary(); + await ParsePartOfCodecsAsync(res, "-codecs", s => + { + if (Codec.TryParseFromCodecs(s, out var codec)) + { + return codec; + } + + return null; + }, cancellationToken).ConfigureAwait(false); + await ParsePartOfCodecsAsync(res, "-encoders", s => + { + if (Codec.TryParseFromEncodersDecoders(s, out var codec, true)) + { + return codec; + } + + return null; + }, cancellationToken).ConfigureAwait(false); + await ParsePartOfCodecsAsync(res, "-decoders", s => + { + if (Codec.TryParseFromEncodersDecoders(s, out var codec, false)) + { + return codec; + } + + return null; + }, cancellationToken).ConfigureAwait(false); + + return res; + } + public static IReadOnlyList GetCodecs() { if (!GlobalFFOptions.Current.UseCache) @@ -566,6 +665,17 @@ public static IReadOnlyList GetCodecs() return FFMpegCache.Codecs.Values.ToList().AsReadOnly(); } + public static async Task> GetCodecsAsync(CancellationToken cancellationToken = default) + { + if (!GlobalFFOptions.Current.UseCache) + { + var codecs = await GetCodecsInternalAsync(cancellationToken).ConfigureAwait(false); + return codecs.Values.ToList().AsReadOnly(); + } + + return FFMpegCache.Codecs.Values.ToList().AsReadOnly(); + } + public static IReadOnlyList GetCodecs(CodecType type) { if (!GlobalFFOptions.Current.UseCache) @@ -644,6 +754,31 @@ internal static IReadOnlyList GetContainersFormatsInternal() return list.AsReadOnly(); } + internal static async Task> GetContainersFormatsInternalAsync(CancellationToken cancellationToken = default) + { + FFMpegHelper.RootExceptionCheck(); + + var ffmpegPath = await GlobalFFOptions.GetFFMpegBinaryPathAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + + var list = new List(); + var instance = new ProcessArguments(ffmpegPath, "-formats"); + instance.OutputDataReceived += (e, data) => + { + if (ContainerFormat.TryParse(data, out var fmt)) + { + list.Add(fmt); + } + }; + + var result = await instance.StartAndWaitForExitAsync(cancellationToken).ConfigureAwait(false); + if (result.ExitCode != 0) + { + throw new FFMpegException(FFMpegExceptionType.Process, string.Join("\r\n", result.OutputData)); + } + + return list.AsReadOnly(); + } + public static IReadOnlyList GetContainerFormats() { if (!GlobalFFOptions.Current.UseCache) @@ -654,6 +789,16 @@ public static IReadOnlyList GetContainerFormats() return FFMpegCache.ContainerFormats.Values.ToList().AsReadOnly(); } + public static async Task> GetContainerFormatsAsync(CancellationToken cancellationToken = default) + { + if (!GlobalFFOptions.Current.UseCache) + { + return await GetContainersFormatsInternalAsync(cancellationToken).ConfigureAwait(false); + } + + return FFMpegCache.ContainerFormats.Values.ToList().AsReadOnly(); + } + public static bool TryGetContainerFormat(string name, out ContainerFormat fmt) { if (!GlobalFFOptions.Current.UseCache) diff --git a/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs b/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs index b1bd3dab..111a11a4 100644 --- a/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs +++ b/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs @@ -130,10 +130,10 @@ public bool ProcessSynchronously(bool throwOnError = true, FFOptions? ffMpegOpti return HandleCompletion(throwOnError, processResult?.ExitCode ?? -1, processResult?.ErrorData ?? Array.Empty()); } - public async Task ProcessAsynchronously(bool throwOnError = true, FFOptions? ffMpegOptions = null) + public async Task ProcessAsynchronously(bool throwOnError = true, FFOptions? ffMpegOptions = null, CancellationToken cancellationToken = default) { var options = GetConfiguredOptions(ffMpegOptions); - var processArguments = PrepareProcessArguments(options); + var processArguments = await PrepareProcessArgumentsAsync(options, cancellationToken).ConfigureAwait(false); using var cancellationTokenSource = new CancellationTokenSource(); IProcessResult? processResult = null; @@ -141,7 +141,7 @@ public async Task ProcessAsynchronously(bool throwOnError = true, FFOption { processResult = await Process(processArguments, cancellationTokenSource).ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (throwOnError) { if (throwOnError) { @@ -252,6 +252,23 @@ private ProcessArguments PrepareProcessArguments(FFOptions ffOptions) FFMpegHelper.RootExceptionCheck(); FFMpegHelper.VerifyFFMpegExists(ffOptions); + var fileName = GlobalFFOptions.GetFFMpegBinaryPath(ffOptions); + + return GetProcessArguments(ffOptions, fileName); + } + + public async Task PrepareProcessArgumentsAsync(FFOptions ffOptions, CancellationToken cancellationToken = default) + { + FFMpegHelper.RootExceptionCheck(); + await FFMpegHelper.VerifyFFMpegExistsAsync(ffOptions, cancellationToken).ConfigureAwait(false); + + var fileName = await GlobalFFOptions.GetFFMpegBinaryPathAsync(ffOptions, cancellationToken).ConfigureAwait(false); + + return GetProcessArguments(ffOptions, fileName); + } + + private ProcessArguments GetProcessArguments(FFOptions ffOptions, string fileName) + { var arguments = _ffMpegArguments.Text; //If local loglevel is null, set the global. @@ -270,7 +287,7 @@ private ProcessArguments PrepareProcessArguments(FFOptions ffOptions) var startInfo = new ProcessStartInfo { - FileName = GlobalFFOptions.GetFFMpegBinaryPath(ffOptions), + FileName = fileName, Arguments = arguments, StandardOutputEncoding = ffOptions.Encoding, StandardErrorEncoding = ffOptions.Encoding, diff --git a/FFMpegCore/FFProbe/FFProbe.cs b/FFMpegCore/FFProbe/FFProbe.cs index 164ea72f..5175b3fc 100644 --- a/FFMpegCore/FFProbe/FFProbe.cs +++ b/FFMpegCore/FFProbe/FFProbe.cs @@ -82,7 +82,7 @@ public static async Task AnalyseAsync(string filePath, FFOptions { ThrowIfInputFileDoesNotExist(filePath); - var instance = PrepareStreamAnalysisInstance(filePath, ffOptions ?? GlobalFFOptions.Current, customArguments); + var instance = await PrepareStreamAnalysisInstanceAsync(filePath, ffOptions ?? GlobalFFOptions.Current, customArguments, cancellationToken).ConfigureAwait(false); var result = await instance.StartAndWaitForExitAsync(cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); ThrowIfExitCodeNotZero(result); @@ -114,7 +114,7 @@ public static async Task GetPacketsAsync(string filePath, FFOpti { ThrowIfInputFileDoesNotExist(filePath); - var instance = PreparePacketAnalysisInstance(filePath, ffOptions ?? GlobalFFOptions.Current, customArguments); + var instance = await PreparePacketAnalysisInstanceAsync(filePath, ffOptions ?? GlobalFFOptions.Current, customArguments, cancellationToken).ConfigureAwait(false); var result = await instance.StartAndWaitForExitAsync(cancellationToken).ConfigureAwait(false); return ParsePacketsOutput(result); } @@ -122,7 +122,7 @@ public static async Task GetPacketsAsync(string filePath, FFOpti public static async Task AnalyseAsync(Uri uri, FFOptions? ffOptions = null, CancellationToken cancellationToken = default, string? customArguments = null) { - var instance = PrepareStreamAnalysisInstance(uri.AbsoluteUri, ffOptions ?? GlobalFFOptions.Current, customArguments); + var instance = await PrepareStreamAnalysisInstanceAsync(uri.AbsoluteUri, ffOptions ?? GlobalFFOptions.Current, customArguments, cancellationToken).ConfigureAwait(false); var result = await instance.StartAndWaitForExitAsync(cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); ThrowIfExitCodeNotZero(result); @@ -135,7 +135,7 @@ public static async Task AnalyseAsync(Stream stream, FFOptions? { var streamPipeSource = new StreamPipeSource(stream); var pipeArgument = new InputPipeArgument(streamPipeSource); - var instance = PrepareStreamAnalysisInstance(pipeArgument.PipePath, ffOptions ?? GlobalFFOptions.Current, customArguments); + var instance = await PrepareStreamAnalysisInstanceAsync(pipeArgument.PipePath, ffOptions ?? GlobalFFOptions.Current, customArguments, cancellationToken).ConfigureAwait(false); pipeArgument.Pre(); var task = instance.StartAndWaitForExitAsync(cancellationToken); @@ -162,7 +162,7 @@ public static async Task AnalyseAsync(Stream stream, FFOptions? public static async Task GetFramesAsync(Uri uri, FFOptions? ffOptions = null, CancellationToken cancellationToken = default, string? customArguments = null) { - var instance = PrepareFrameAnalysisInstance(uri.AbsoluteUri, ffOptions ?? GlobalFFOptions.Current, customArguments); + var instance = await PrepareFrameAnalysisInstanceAsync(uri.AbsoluteUri, ffOptions ?? GlobalFFOptions.Current, customArguments, cancellationToken).ConfigureAwait(false); var result = await instance.StartAndWaitForExitAsync(cancellationToken).ConfigureAwait(false); return ParseFramesOutput(result); } @@ -240,11 +240,45 @@ private static ProcessArguments PreparePacketAnalysisInstance(string filePath, F return PrepareInstance($"-loglevel error -print_format json -show_packets -v quiet -sexagesimal \"{filePath}\"", ffOptions, customArguments); } + private static async Task PrepareStreamAnalysisInstanceAsync(string filePath, FFOptions ffOptions, string? customArguments, CancellationToken cancellationToken = default) + { + return await PrepareInstanceAsync($"-loglevel error -print_format json -show_format -sexagesimal -show_streams -show_chapters \"{filePath}\"", ffOptions, + customArguments, cancellationToken).ConfigureAwait(false); + } + + private static async Task PrepareFrameAnalysisInstanceAsync(string filePath, FFOptions ffOptions, string? customArguments, CancellationToken cancellationToken = default) + { + return await PrepareInstanceAsync($"-loglevel error -print_format json -show_frames -v quiet -sexagesimal \"{filePath}\"", ffOptions, customArguments, cancellationToken).ConfigureAwait(false); + } + + private static async Task PreparePacketAnalysisInstanceAsync(string filePath, FFOptions ffOptions, string? customArguments, CancellationToken cancellationToken = default) + { + return await PrepareInstanceAsync($"-loglevel error -print_format json -show_packets -v quiet -sexagesimal \"{filePath}\"", ffOptions, customArguments, cancellationToken).ConfigureAwait(false); + } + private static ProcessArguments PrepareInstance(string arguments, FFOptions ffOptions, string? customArguments) { FFProbeHelper.RootExceptionCheck(); FFProbeHelper.VerifyFFProbeExists(ffOptions); - var startInfo = new ProcessStartInfo(GlobalFFOptions.GetFFProbeBinaryPath(ffOptions), $"{arguments} {customArguments}") + + var filePath = GlobalFFOptions.GetFFProbeBinaryPath(ffOptions); + + return GetProcessArguments(arguments, ffOptions, customArguments, filePath); + } + + private static async Task PrepareInstanceAsync(string arguments, FFOptions ffOptions, string? customArguments, CancellationToken cancellationToken = default) + { + FFProbeHelper.RootExceptionCheck(); + await FFProbeHelper.VerifyFFProbeExistsAsync(ffOptions, cancellationToken).ConfigureAwait(false); + + var filePath = await GlobalFFOptions.GetFFProbeBinaryPathAsync(ffOptions, cancellationToken).ConfigureAwait(false); + + return GetProcessArguments(arguments, ffOptions, customArguments, filePath); + } + + private static ProcessArguments GetProcessArguments(string arguments, FFOptions ffOptions, string? customArguments, string filePath) + { + var startInfo = new ProcessStartInfo(filePath, $"{arguments} {customArguments}") { StandardOutputEncoding = ffOptions.Encoding, StandardErrorEncoding = ffOptions.Encoding, diff --git a/FFMpegCore/GlobalFFOptions.cs b/FFMpegCore/GlobalFFOptions.cs index a4c48aec..eedc6aa9 100644 --- a/FFMpegCore/GlobalFFOptions.cs +++ b/FFMpegCore/GlobalFFOptions.cs @@ -30,30 +30,85 @@ public static string GetFFProbeBinaryPath(FFOptions? ffOptions = null) return GetFFBinaryPath("FFProbe", ffOptions ?? Current); } + public static Task GetFFMpegBinaryPathAsync(FFOptions? ffOptions = null, CancellationToken cancellationToken = default) + { + return GetFFBinaryPathAsync("FFMpeg", ffOptions ?? Current, cancellationToken); + } + + public static Task GetFFProbeBinaryPathAsync(FFOptions? ffOptions = null, CancellationToken cancellationToken = default) + { + return GetFFBinaryPathAsync("FFProbe", ffOptions ?? Current, cancellationToken); + } + private static string GetFFBinaryPath(string name, FFOptions ffOptions) { - var ffName = name.ToLowerInvariant(); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + var ffName = GetFFName(name); + + foreach (var possiblePath in GetPossiblePaths(ffName, ffOptions)) { - ffName += ".exe"; + if (File.Exists(possiblePath)) + { + return possiblePath; + } } - var target = Environment.Is64BitProcess ? "x64" : "x86"; - var possiblePaths = new List { Path.Combine(ffOptions.BinaryFolder, target), ffOptions.BinaryFolder }; + //Fall back to the assumption this tool exists in the PATH + return ffName; + } + + private static async Task GetFFBinaryPathAsync(string name, FFOptions ffOptions, CancellationToken cancellationToken = default) + { + var ffName = GetFFName(name); - foreach (var possiblePath in possiblePaths) + var results = await + Task.WhenAll( + GetPossiblePaths(ffName, ffOptions) + .Select(async possiblePath => await CheckPathAsync(possiblePath, cancellationToken).ConfigureAwait(false))) + .ConfigureAwait(false); + + var foundPath = results.FirstOrDefault(path => path is not null); + + if (foundPath is not null) { - var possibleFFMpegPath = Path.Combine(possiblePath, ffName); - if (File.Exists(possibleFFMpegPath)) - { - return possibleFFMpegPath; - } + return foundPath; } //Fall back to the assumption this tool exists in the PATH return ffName; } + private static async Task CheckPathAsync(string possiblePath, CancellationToken cancellationToken) + { + var exists = await Task.Run(() => File.Exists(possiblePath), cancellationToken).ConfigureAwait(false); + + if (exists) + { + return possiblePath; + } + + return null; + } + + private static string GetFFName(string name) + { + var ffName = name.ToLowerInvariant(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + ffName += ".exe"; + } + + return ffName; + } + + private static HashSet GetPossiblePaths(string ffName, FFOptions ffOptions) + { + var target = Environment.Is64BitProcess ? "x64" : "x86"; + + var paths = new HashSet { Path.Combine(ffOptions.BinaryFolder, target), ffOptions.BinaryFolder }; + + return [.. paths.Select(possible => Path.Combine(possible, ffName))]; + } + private static FFOptions LoadFFOptions() { return File.Exists(ConfigFile) diff --git a/FFMpegCore/Helpers/FFMpegHelper.cs b/FFMpegCore/Helpers/FFMpegHelper.cs index 11540c9a..eb1abf27 100644 --- a/FFMpegCore/Helpers/FFMpegHelper.cs +++ b/FFMpegCore/Helpers/FFMpegHelper.cs @@ -6,6 +6,7 @@ namespace FFMpegCore.Helpers; public static class FFMpegHelper { private static bool _ffmpegVerified; + private static readonly object _syncObject = new(); public static void ConversionSizeExceptionCheck(IMediaAnalysis info) { @@ -31,7 +32,7 @@ public static void ExtensionExceptionCheck(string filename, string extension) public static void RootExceptionCheck() { - if (GlobalFFOptions.Current.BinaryFolder == null) + if (string.IsNullOrWhiteSpace(GlobalFFOptions.Current.BinaryFolder)) { throw new FFOptionsException("FFMpeg root is not configured in app config. Missing key 'BinaryFolder'."); } @@ -45,7 +46,31 @@ public static void VerifyFFMpegExists(FFOptions ffMpegOptions) } var result = Instance.Finish(GlobalFFOptions.GetFFMpegBinaryPath(ffMpegOptions), "-version"); - _ffmpegVerified = result.ExitCode == 0; + + VerifyResult(result); + } + + public static async Task VerifyFFMpegExistsAsync(FFOptions ffMpegOptions, CancellationToken cancellationToken = default) + { + if (_ffmpegVerified) + { + return; + } + + var ffmpegPath = await GlobalFFOptions.GetFFMpegBinaryPathAsync(ffMpegOptions, cancellationToken).ConfigureAwait(false); + + var result = await Instance.FinishAsync(ffmpegPath, "-version", cancellationToken).ConfigureAwait(false); + + VerifyResult(result); + } + + private static void VerifyResult(IProcessResult result) + { + lock (_syncObject) + { + _ffmpegVerified = result.ExitCode is 0; + } + if (!_ffmpegVerified) { throw new FFMpegException(FFMpegExceptionType.Operation, "ffmpeg was not found on your system"); diff --git a/FFMpegCore/Helpers/FFProbeHelper.cs b/FFMpegCore/Helpers/FFProbeHelper.cs index 307290ee..b4e01006 100644 --- a/FFMpegCore/Helpers/FFProbeHelper.cs +++ b/FFMpegCore/Helpers/FFProbeHelper.cs @@ -6,24 +6,49 @@ namespace FFMpegCore.Helpers; public static class FFProbeHelper { private static bool _ffprobeVerified; + private static readonly object _syncObject = new(); public static void RootExceptionCheck() { - if (GlobalFFOptions.Current.BinaryFolder == null) + if (string.IsNullOrWhiteSpace(GlobalFFOptions.Current.BinaryFolder)) { throw new FFOptionsException("FFProbe root is not configured in app config. Missing key 'BinaryFolder'."); } } - public static void VerifyFFProbeExists(FFOptions ffMpegOptions) + public static void VerifyFFProbeExists(FFOptions ffOptions) { if (_ffprobeVerified) { return; } - var result = Instance.Finish(GlobalFFOptions.GetFFProbeBinaryPath(ffMpegOptions), "-version"); - _ffprobeVerified = result.ExitCode == 0; + var result = Instance.Finish(GlobalFFOptions.GetFFProbeBinaryPath(ffOptions), "-version"); + + VerifyResult(result); + } + + public static async Task VerifyFFProbeExistsAsync(FFOptions ffOptions, CancellationToken cancellationToken = default) + { + if (_ffprobeVerified) + { + return; + } + + var ffProbePath = await GlobalFFOptions.GetFFProbeBinaryPathAsync(ffOptions, cancellationToken).ConfigureAwait(false); + + var result = await Instance.FinishAsync(ffProbePath, "-version", cancellationToken).ConfigureAwait(false); + + VerifyResult(result); + } + + private static void VerifyResult(IProcessResult result) + { + lock (_syncObject) + { + _ffprobeVerified = result.ExitCode is 0; + } + if (!_ffprobeVerified) { throw new FFProbeException("ffprobe was not found on your system");