using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using CliWrap; using CliWrap.Buffered; using ErsatzTV.FFmpeg.Capabilities.Nvidia; using ErsatzTV.FFmpeg.Capabilities.Qsv; using ErsatzTV.FFmpeg.Capabilities.Vaapi; using ErsatzTV.FFmpeg.Capabilities.VideoToolbox; using ErsatzTV.FFmpeg.GlobalOption.HardwareAcceleration; using ErsatzTV.FFmpeg.Runtime; using Hardware.Info; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; namespace ErsatzTV.FFmpeg.Capabilities; public partial class HardwareCapabilitiesFactory( IMemoryCache memoryCache, IRuntimeInfo runtimeInfo, ILogger logger) : IHardwareCapabilitiesFactory { private const string CudaDeviceKey = "ffmpeg.hardware.cuda.device"; private static readonly CompositeFormat VaapiCacheKeyFormat = CompositeFormat.Parse("ffmpeg.hardware.vaapi.{0}.{1}.{2}"); private static readonly CompositeFormat VaapiGenerationCacheKeyFormat = CompositeFormat.Parse("ffmpeg.hardware.vaapi.generation.{0}.{1}.{2}"); private static readonly CompositeFormat QsvCacheKeyFormat = CompositeFormat.Parse("ffmpeg.hardware.qsv.{0}"); private static readonly CompositeFormat FFmpegCapabilitiesCacheKeyFormat = CompositeFormat.Parse("ffmpeg.{0}"); private static readonly string[] QsvArguments = { "-f", "lavfi", "-i", "nullsrc", "-t", "00:00:01", "-c:v", "h264_qsv", "-f", "null", "-" }; public void ClearCache() { memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "hwaccels")); memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "decoders")); memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "filters")); memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "encoders")); memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "options")); memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "formats")); } public async Task GetFFmpegCapabilities(string ffmpegPath) { // TODO: validate videotoolbox somehow // TODO: validate amf somehow IReadOnlySet ffmpegHardwareAccelerations = await GetFFmpegCapabilities(ffmpegPath, "hwaccels", ParseFFmpegAccelLine) .Map(set => set.Intersect(FFmpegKnownHardwareAcceleration.AllAccels).ToImmutableHashSet()); IReadOnlySet ffmpegDecoders = await GetFFmpegCapabilities(ffmpegPath, "decoders", ParseFFmpegLine) .Map(set => set.Intersect(FFmpegKnownDecoder.AllDecoders).ToImmutableHashSet()); IReadOnlySet ffmpegFilters = await GetFFmpegCapabilities(ffmpegPath, "filters", ParseFFmpegLine) .Map(set => set.Intersect(FFmpegKnownFilter.AllFilters).ToImmutableHashSet()); IReadOnlySet ffmpegEncoders = await GetFFmpegCapabilities(ffmpegPath, "encoders", ParseFFmpegLine) .Map(set => set.Intersect(FFmpegKnownEncoder.AllEncoders).ToImmutableHashSet()); IReadOnlySet ffmpegOptions = await GetFFmpegOptions(ffmpegPath) .Map(set => set.Intersect(FFmpegKnownOption.AllOptions).ToImmutableHashSet()); IReadOnlySet ffmpegDemuxFormats = await GetFFmpegFormats(ffmpegPath, "D") .Map(set => set.Intersect(FFmpegKnownFormat.AllFormats).ToImmutableHashSet()); return new FFmpegCapabilities( ffmpegHardwareAccelerations, ffmpegDecoders, ffmpegFilters, ffmpegEncoders, ffmpegOptions, ffmpegDemuxFormats); } public async Task GetHardwareCapabilities( IFFmpegCapabilities ffmpegCapabilities, string ffmpegPath, HardwareAccelerationMode hardwareAccelerationMode, Option vaapiDisplay, Option vaapiDriver, Option vaapiDevice) { if (hardwareAccelerationMode is HardwareAccelerationMode.None) { return new NoHardwareCapabilities(); } if (!ffmpegCapabilities.HasHardwareAcceleration(hardwareAccelerationMode)) { logger.LogWarning( "FFmpeg does not support {HardwareAcceleration} acceleration; will use software mode", hardwareAccelerationMode); return new NoHardwareCapabilities(); } return hardwareAccelerationMode switch { HardwareAccelerationMode.Nvenc => GetNvidiaCapabilities(ffmpegCapabilities), HardwareAccelerationMode.Qsv => await GetQsvCapabilities(ffmpegPath, vaapiDevice), HardwareAccelerationMode.Vaapi => await GetVaapiCapabilities(vaapiDisplay, vaapiDriver, vaapiDevice), HardwareAccelerationMode.VideoToolbox => new VideoToolboxHardwareCapabilities(ffmpegCapabilities, logger), HardwareAccelerationMode.Amf => new AmfHardwareCapabilities(), HardwareAccelerationMode.V4l2m2m => new V4l2m2mHardwareCapabilities(ffmpegCapabilities), HardwareAccelerationMode.Rkmpp => new RkmppHardwareCapabilities(), _ => new DefaultHardwareCapabilities() }; } public async Task GetNvidiaOutput(string ffmpegPath) { if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { return string.Empty; } try { Option> maybeDevices = CudaHelper.GetDevices(); foreach (List devices in maybeDevices.Where(list => list.Count > 0)) { var sb = new StringBuilder(); foreach (CudaDevice device in devices) { sb.AppendLine( CultureInfo.InvariantCulture, $"GPU #{device.Handle} < {device.Model} > has Compute SM {device.Version.Major}.{device.Version.Minor}"); sb.AppendLine(CudaHelper.GetDeviceDetails(device)); } return sb.ToString(); } } catch (FileNotFoundException) { // do nothing } catch (TypeInitializationException) { // do nothing } // if we don't have a list of cuda devices, fall back to ffmpeg check string[] arguments = [ "-f", "lavfi", "-i", "nullsrc", "-c:v", "h264_nvenc", "-gpu", "list", "-f", "null", "-" ]; BufferedCommandResult result = await Cli.Wrap(ffmpegPath) .WithArguments(arguments) .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync(Encoding.UTF8); string output = string.IsNullOrWhiteSpace(result.StandardOutput) ? result.StandardError : result.StandardOutput; return output; } public async Task GetQsvOutput(string ffmpegPath, Option qsvDevice) { if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { return new QsvOutput(0, string.Empty); } var option = new QsvHardwareAccelerationOption(qsvDevice, FFmpegCapability.Software); var arguments = option.GlobalOptions.ToList(); arguments.AddRange(QsvArguments); BufferedCommandResult result = await Cli.Wrap(ffmpegPath) .WithArguments(arguments) .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync(Encoding.UTF8); string output = string.IsNullOrWhiteSpace(result.StandardOutput) ? result.StandardError : result.StandardOutput; return new QsvOutput(result.ExitCode, output); } public async Task> GetVaapiOutput(string display, Option vaapiDriver, string vaapiDevice) { if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { return Option.None; } BufferedCommandResult whichResult = await Cli.Wrap("which") .WithArguments("vainfo") .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync(Encoding.UTF8); if (whichResult.ExitCode != 0) { return Option.None; } var envVars = new Dictionary(); foreach (string libvaDriverName in vaapiDriver) { envVars.Add("LIBVA_DRIVER_NAME", libvaDriverName); } var lines = new List(); string arguments = display == "drm" ? $"--display drm --device {vaapiDevice} -a" : $"--display {display} -a"; await Cli.Wrap("vainfo") .WithArguments(arguments) .WithEnvironmentVariables(envVars) .WithValidation(CommandResultValidation.None) .WithStandardOutputPipe(PipeTarget.ToDelegate(lines.Add)) .WithStandardErrorPipe(PipeTarget.ToDelegate(lines.Add)) .ExecuteAsync(); return string.Join(System.Environment.NewLine, lines); } public async Task> GetVaapiDisplays() { BufferedCommandResult whichResult = await Cli.Wrap("which") .WithArguments("vainfo") .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync(Encoding.UTF8); if (whichResult.ExitCode != 0) { return ["drm"]; } BufferedCommandResult result = await Cli.Wrap("vainfo") .WithArguments("--display help") .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync(Encoding.UTF8); return string.IsNullOrWhiteSpace(result.StandardOutput) ? ["drm"] : result.StandardOutput.Trim().Split("\n").Skip(1).Map(s => s.Trim()).ToList(); } public List GetCpuList() { try { var hardwareInfo = new HardwareInfo(); hardwareInfo.RefreshCPUList(); return hardwareInfo.CpuList.Map(c => new CpuModel(c.Manufacturer, c.Name)).ToList(); } catch (Exception) { // do nothing } return []; } public List GetVideoControllerList() { try { var hardwareInfo = new HardwareInfo(); hardwareInfo.RefreshVideoControllerList(); return hardwareInfo.VideoControllerList .Map(v => new VideoControllerModel(v.Manufacturer, v.Name)) .ToList(); } catch (Exception) { // do nothing } return []; } [SuppressMessage("ReSharper", "InconsistentNaming")] public List GetVideoToolboxDecoders() { var result = new List(); foreach (string fourCC in FourCC.AllVideoToolbox) { if (VideoToolboxUtil.IsHardwareDecoderSupported(fourCC, logger)) { result.Add(fourCC); } } return result; } public List GetVideoToolboxEncoders() => VideoToolboxUtil.GetAvailableEncoders(logger); public void SetAviSynthInstalled(bool aviSynthInstalled) { var cacheKey = string.Format( CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "avisynth_installed"); memoryCache.Set(cacheKey, aviSynthInstalled); } public bool IsAviSynthInstalled() { var cacheKey = string.Format( CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "avisynth_installed"); return memoryCache.TryGetValue(cacheKey, out bool installed) && installed; } private async Task> GetFFmpegCapabilities( string ffmpegPath, string capabilities, Func> parseLine) { var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, capabilities); if (memoryCache.TryGetValue(cacheKey, out IReadOnlySet? cachedCapabilities) && cachedCapabilities is not null) { return cachedCapabilities; } string[] arguments = ["-hide_banner", $"-{capabilities}"]; BufferedCommandResult result = await Cli.Wrap(ffmpegPath) .WithArguments(arguments) .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync(Encoding.UTF8); string output = string.IsNullOrWhiteSpace(result.StandardOutput) ? result.StandardError : result.StandardOutput; var capabilitiesResult = output.Split("\n").Map(s => s.Trim()) .Bind(l => parseLine(l)) .ToImmutableHashSet(); memoryCache.Set(cacheKey, capabilitiesResult); return capabilitiesResult; } private async Task> GetFFmpegOptions(string ffmpegPath) { var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "options"); if (memoryCache.TryGetValue(cacheKey, out IReadOnlySet? cachedCapabilities) && cachedCapabilities is not null) { return cachedCapabilities; } string[] arguments = ["-hide_banner", "-h", "long"]; BufferedCommandResult result = await Cli.Wrap(ffmpegPath) .WithArguments(arguments) .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync(Encoding.UTF8); string output = string.IsNullOrWhiteSpace(result.StandardOutput) ? result.StandardError : result.StandardOutput; var capabilitiesResult = output.Split("\n").Map(s => s.Trim()) .Bind(l => ParseFFmpegOptionLine(l)) .ToImmutableHashSet(); memoryCache.Set(cacheKey, capabilitiesResult); return capabilitiesResult; } private async Task> GetFFmpegFormats(string ffmpegPath, string muxDemux) { var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "formats"); if (memoryCache.TryGetValue(cacheKey, out IReadOnlySet? cachedCapabilities) && cachedCapabilities is not null) { return cachedCapabilities; } string[] arguments = ["-hide_banner", "-formats"]; BufferedCommandResult result = await Cli.Wrap(ffmpegPath) .WithArguments(arguments) .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync(Encoding.UTF8); string output = string.IsNullOrWhiteSpace(result.StandardOutput) ? result.StandardError : result.StandardOutput; var capabilitiesResult = output.Split("\n").Map(s => s.Trim()) .Bind(l => ParseFFmpegFormatLine(l)) .Where(tuple => tuple.Item1.Contains(muxDemux)) .Map(tuple => tuple.Item2) .ToImmutableHashSet(); memoryCache.Set(cacheKey, capabilitiesResult); return capabilitiesResult; } private static Option ParseFFmpegAccelLine(string input) { Match match = AccelRegex().Match(input); return match.Success ? match.Groups[1].Value : Option.None; } private static Option ParseFFmpegLine(string input) { Match match = FFmpegRegex().Match(input); return match.Success ? match.Groups[1].Value : Option.None; } private static Option ParseFFmpegOptionLine(string input) { Match match = OptionRegex().Match(input); return match.Success ? match.Groups[1].Value : Option.None; } private static Option> ParseFFmpegFormatLine(string input) { Match match = FormatRegex().Match(input); return match.Success ? Tuple(match.Groups[1].Value, match.Groups[2].Value) : Option>.None; } private async Task GetVaapiCapabilities( Option vaapiDisplay, Option vaapiDriver, Option vaapiDevice) { try { if (vaapiDevice.IsNone) { // this shouldn't really happen logger.LogError( "Cannot detect VAAPI capabilities without device {Device}", vaapiDevice); return new NoHardwareCapabilities(); } string display = await vaapiDisplay.IfNoneAsync("drm"); string driver = await vaapiDriver.IfNoneAsync(string.Empty); string device = await vaapiDevice.IfNoneAsync(string.Empty); string generation = string.Empty; var cacheKey = string.Format(CultureInfo.InvariantCulture, VaapiCacheKeyFormat, display, driver, device); var generationCacheKey = string.Format( CultureInfo.InvariantCulture, VaapiGenerationCacheKeyFormat, display, driver, device); if (memoryCache.TryGetValue(cacheKey, out List? profileEntrypoints) && profileEntrypoints is not null) { if (memoryCache.TryGetValue(generationCacheKey, out string? cachedGeneration) && cachedGeneration is not null) { generation = cachedGeneration; } return new VaapiHardwareCapabilities(profileEntrypoints, generation, logger); } Option output = await GetVaapiOutput(display, vaapiDriver, device); if (output.IsNone) { logger.LogWarning("Unable to determine VAAPI capabilities; please install vainfo"); return new DefaultHardwareCapabilities(); } foreach (string o in output) { profileEntrypoints = VaapiCapabilityParser.ParseFull(o); generation = VaapiCapabilityParser.ParseGeneration(o); } if (profileEntrypoints is not null && profileEntrypoints.Count != 0) { if (display == "drm") { logger.LogDebug( "Detected {Count} VAAPI profile entrypoints using {Driver} {Device}", profileEntrypoints.Count, driver, device); } else { logger.LogDebug( "Detected {Count} VAAPI profile entrypoints using {Display} {Driver}", profileEntrypoints.Count, display, driver); } memoryCache.Set(cacheKey, profileEntrypoints); memoryCache.Set(generationCacheKey, generation); return new VaapiHardwareCapabilities(profileEntrypoints, generation, logger); } } catch (Exception ex) { logger.LogWarning( ex, "Error detecting VAAPI capabilities; some hardware accelerated features will be unavailable"); return new NoHardwareCapabilities(); } logger.LogWarning( "Error detecting VAAPI capabilities; some hardware accelerated features will be unavailable"); return new NoHardwareCapabilities(); } private async Task GetQsvCapabilities(string ffmpegPath, Option qsvDevice) { try { if (runtimeInfo.IsOSPlatform(OSPlatform.Linux) && qsvDevice.IsNone) { // this shouldn't really happen logger.LogError("Cannot detect QSV capabilities without device {Device}", qsvDevice); return new NoHardwareCapabilities(); } string device = await qsvDevice.IfNoneAsync(string.Empty); var cacheKey = string.Format(CultureInfo.InvariantCulture, QsvCacheKeyFormat, device); if (memoryCache.TryGetValue(cacheKey, out List? profileEntrypoints) && profileEntrypoints is not null) { return new VaapiHardwareCapabilities(profileEntrypoints, string.Empty, logger); } QsvOutput output = await GetQsvOutput(ffmpegPath, qsvDevice); if (output.ExitCode != 0) { logger.LogWarning("QSV test failed; some hardware accelerated features will be unavailable"); return new NoHardwareCapabilities(); } if (runtimeInfo.IsOSPlatform(OSPlatform.Linux)) { if (!memoryCache.TryGetValue("ffmpeg.vaapi_displays", out List? vaapiDisplays)) { vaapiDisplays = ["drm"]; } vaapiDisplays ??= []; vaapiDisplays = vaapiDisplays.OrderBy(s => s).ToList(); foreach (string vaapiDisplay in vaapiDisplays) { Option vaapiOutput = await GetVaapiOutput(vaapiDisplay, Option.None, device); if (vaapiOutput.IsNone) { logger.LogWarning("Unable to determine QSV capabilities; please install vainfo"); return new DefaultHardwareCapabilities(); } foreach (string o in vaapiOutput) { profileEntrypoints = VaapiCapabilityParser.ParseFull(o); } if (profileEntrypoints is not null && profileEntrypoints.Count != 0) { logger.LogDebug( "Detected {Count} VAAPI profile entrypoints using QSV device {Device}", profileEntrypoints.Count, device); memoryCache.Set(cacheKey, profileEntrypoints); return new VaapiHardwareCapabilities(profileEntrypoints, string.Empty, logger); } } } // not sure how to check capabilities on windows return new DefaultHardwareCapabilities(); } catch (Exception ex) { logger.LogWarning( ex, "Error detecting QSV capabilities; some hardware accelerated features will be unavailable"); return new NoHardwareCapabilities(); } } private IHardwareCapabilities GetNvidiaCapabilities(IFFmpegCapabilities ffmpegCapabilities) { if (memoryCache.TryGetValue(CudaDeviceKey, out CudaDevice? cudaDevice) && cudaDevice is not null) { return new NvidiaHardwareCapabilities(cudaDevice, ffmpegCapabilities, logger); } try { Option> maybeDevices = CudaHelper.GetDevices(); foreach (CudaDevice firstDevice in maybeDevices.Map(list => list.HeadOrNone())) { logger.LogDebug( "Detected NVIDIA GPU model {Model} architecture SM {Major}.{Minor}", firstDevice.Model, firstDevice.Version.Major, firstDevice.Version.Minor); memoryCache.Set(CudaDeviceKey, firstDevice); return new NvidiaHardwareCapabilities(firstDevice, ffmpegCapabilities, logger); } } catch (FileNotFoundException) { // do nothing } catch (TypeInitializationException) { // do nothing } logger.LogWarning( "Error detecting NVIDIA GPU capabilities; some hardware accelerated features will be unavailable"); return new NoHardwareCapabilities(); } [GeneratedRegex(@"^([\w]+)$")] private static partial Regex AccelRegex(); [GeneratedRegex(@"^\s*?[A-Z\.]+\s+(\w+).*")] private static partial Regex FFmpegRegex(); [GeneratedRegex(@"^-([a-z_]+)\s+.*")] private static partial Regex OptionRegex(); [GeneratedRegex(@"([DE]+)\s+(\w+)")] private static partial Regex FormatRegex(); }