mirror of https://github.com/ErsatzTV/ErsatzTV.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
460 lines
17 KiB
460 lines
17 KiB
using System.Collections.Immutable; |
|
using System.Globalization; |
|
using System.Runtime.InteropServices; |
|
using System.Text; |
|
using System.Text.RegularExpressions; |
|
using CliWrap; |
|
using CliWrap.Buffered; |
|
using ErsatzTV.FFmpeg.Capabilities.Qsv; |
|
using ErsatzTV.FFmpeg.Capabilities.Vaapi; |
|
using ErsatzTV.FFmpeg.GlobalOption.HardwareAcceleration; |
|
using ErsatzTV.FFmpeg.Runtime; |
|
using Microsoft.Extensions.Caching.Memory; |
|
using Microsoft.Extensions.Logging; |
|
|
|
namespace ErsatzTV.FFmpeg.Capabilities; |
|
|
|
public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory |
|
{ |
|
private const string ArchitectureCacheKey = "ffmpeg.hardware.nvidia.architecture"; |
|
private const string ModelCacheKey = "ffmpeg.hardware.nvidia.model"; |
|
|
|
private static readonly CompositeFormat |
|
VaapiCacheKeyFormat = CompositeFormat.Parse("ffmpeg.hardware.vaapi.{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", "-" |
|
}; |
|
|
|
private readonly ILogger<HardwareCapabilitiesFactory> _logger; |
|
|
|
private readonly IMemoryCache _memoryCache; |
|
private readonly IRuntimeInfo _runtimeInfo; |
|
|
|
public HardwareCapabilitiesFactory( |
|
IMemoryCache memoryCache, |
|
IRuntimeInfo runtimeInfo, |
|
ILogger<HardwareCapabilitiesFactory> logger) |
|
{ |
|
_memoryCache = memoryCache; |
|
_runtimeInfo = runtimeInfo; |
|
_logger = logger; |
|
} |
|
|
|
public async Task<IFFmpegCapabilities> GetFFmpegCapabilities(string ffmpegPath) |
|
{ |
|
// TODO: validate videotoolbox somehow |
|
// TODO: validate amf somehow |
|
|
|
IReadOnlySet<string> ffmpegHardwareAccelerations = |
|
await GetFFmpegCapabilities(ffmpegPath, "hwaccels", ParseFFmpegAccelLine) |
|
.Map(set => set.Intersect(FFmpegKnownHardwareAcceleration.AllAccels).ToImmutableHashSet()); |
|
|
|
IReadOnlySet<string> ffmpegDecoders = await GetFFmpegCapabilities(ffmpegPath, "decoders", ParseFFmpegLine) |
|
.Map(set => set.Intersect(FFmpegKnownDecoder.AllDecoders).ToImmutableHashSet()); |
|
|
|
IReadOnlySet<string> ffmpegFilters = await GetFFmpegCapabilities(ffmpegPath, "filters", ParseFFmpegLine) |
|
.Map(set => set.Intersect(FFmpegKnownFilter.AllFilters).ToImmutableHashSet()); |
|
|
|
IReadOnlySet<string> ffmpegEncoders = await GetFFmpegCapabilities(ffmpegPath, "encoders", ParseFFmpegLine) |
|
.Map(set => set.Intersect(FFmpegKnownEncoder.AllEncoders).ToImmutableHashSet()); |
|
|
|
IReadOnlySet<string> ffmpegOptions = await GetFFmpegOptions(ffmpegPath) |
|
.Map(set => set.Intersect(FFmpegKnownOption.AllOptions).ToImmutableHashSet()); |
|
|
|
return new FFmpegCapabilities( |
|
ffmpegHardwareAccelerations, |
|
ffmpegDecoders, |
|
ffmpegFilters, |
|
ffmpegEncoders, |
|
ffmpegOptions); |
|
} |
|
|
|
public async Task<IHardwareCapabilities> GetHardwareCapabilities( |
|
IFFmpegCapabilities ffmpegCapabilities, |
|
string ffmpegPath, |
|
HardwareAccelerationMode hardwareAccelerationMode, |
|
Option<string> vaapiDisplay, |
|
Option<string> vaapiDriver, |
|
Option<string> 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 => await GetNvidiaCapabilities(ffmpegPath, ffmpegCapabilities), |
|
HardwareAccelerationMode.Qsv => await GetQsvCapabilities(ffmpegPath, vaapiDevice), |
|
HardwareAccelerationMode.Vaapi => await GetVaapiCapabilities(vaapiDisplay, vaapiDriver, vaapiDevice), |
|
HardwareAccelerationMode.Amf => new AmfHardwareCapabilities(), |
|
_ => new DefaultHardwareCapabilities() |
|
}; |
|
} |
|
|
|
public async Task<string> GetNvidiaOutput(string ffmpegPath) |
|
{ |
|
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<QsvOutput> GetQsvOutput(string ffmpegPath, Option<string> qsvDevice) |
|
{ |
|
var option = new QsvHardwareAccelerationOption(qsvDevice); |
|
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<Option<string>> GetVaapiOutput(string display, Option<string> vaapiDriver, string vaapiDevice) |
|
{ |
|
BufferedCommandResult whichResult = await Cli.Wrap("which") |
|
.WithArguments("vainfo") |
|
.WithValidation(CommandResultValidation.None) |
|
.ExecuteBufferedAsync(Encoding.UTF8); |
|
|
|
if (whichResult.ExitCode != 0) |
|
{ |
|
return Option<string>.None; |
|
} |
|
|
|
var envVars = new Dictionary<string, string?>(); |
|
foreach (string libvaDriverName in vaapiDriver) |
|
{ |
|
envVars.Add("LIBVA_DRIVER_NAME", libvaDriverName); |
|
} |
|
|
|
var lines = new List<string>(); |
|
|
|
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<List<string>> 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(); |
|
} |
|
|
|
private async Task<IReadOnlySet<string>> GetFFmpegCapabilities( |
|
string ffmpegPath, |
|
string capabilities, |
|
Func<string, Option<string>> parseLine) |
|
{ |
|
var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, capabilities); |
|
if (_memoryCache.TryGetValue(cacheKey, out IReadOnlySet<string>? 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; |
|
|
|
return output.Split("\n").Map(s => s.Trim()) |
|
.Bind(l => parseLine(l)) |
|
.ToImmutableHashSet(); |
|
} |
|
|
|
private async Task<IReadOnlySet<string>> GetFFmpegOptions(string ffmpegPath) |
|
{ |
|
var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "options"); |
|
if (_memoryCache.TryGetValue(cacheKey, out IReadOnlySet<string>? 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; |
|
|
|
return output.Split("\n").Map(s => s.Trim()) |
|
.Bind(l => ParseFFmpegOptionLine(l)) |
|
.ToImmutableHashSet(); |
|
} |
|
|
|
private static Option<string> ParseFFmpegAccelLine(string input) |
|
{ |
|
const string PATTERN = @"^([\w]+)$"; |
|
Match match = Regex.Match(input, PATTERN); |
|
return match.Success ? match.Groups[1].Value : Option<string>.None; |
|
} |
|
|
|
private static Option<string> ParseFFmpegLine(string input) |
|
{ |
|
const string PATTERN = @"^\s*?[A-Z\.]+\s+(\w+).*"; |
|
Match match = Regex.Match(input, PATTERN); |
|
return match.Success ? match.Groups[1].Value : Option<string>.None; |
|
} |
|
|
|
private static Option<string> ParseFFmpegOptionLine(string input) |
|
{ |
|
const string PATTERN = @"^-([a-z_]+)\s+.*"; |
|
Match match = Regex.Match(input, PATTERN); |
|
return match.Success ? match.Groups[1].Value : Option<string>.None; |
|
} |
|
|
|
private async Task<IHardwareCapabilities> GetVaapiCapabilities( |
|
Option<string> vaapiDisplay, |
|
Option<string> vaapiDriver, |
|
Option<string> 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 = vaapiDisplay.IfNone("drm"); |
|
string driver = vaapiDriver.IfNone(string.Empty); |
|
string device = vaapiDevice.IfNone(string.Empty); |
|
var cacheKey = string.Format(CultureInfo.InvariantCulture, VaapiCacheKeyFormat, display, driver, device); |
|
|
|
if (_memoryCache.TryGetValue(cacheKey, out List<VaapiProfileEntrypoint>? profileEntrypoints) && |
|
profileEntrypoints is not null) |
|
{ |
|
return new VaapiHardwareCapabilities(profileEntrypoints, _logger); |
|
} |
|
|
|
Option<string> 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); |
|
} |
|
|
|
if (profileEntrypoints is not null && profileEntrypoints.Count != 0) |
|
{ |
|
_logger.LogDebug( |
|
"Detected {Count} VAAPI profile entrypoints for using {Display} {Driver} {Device}", |
|
profileEntrypoints.Count, |
|
display, |
|
driver, |
|
device); |
|
_memoryCache.Set(cacheKey, profileEntrypoints); |
|
return new VaapiHardwareCapabilities(profileEntrypoints, _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<IHardwareCapabilities> GetQsvCapabilities(string ffmpegPath, Option<string> 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 = qsvDevice.IfNone(string.Empty); |
|
var cacheKey = string.Format(CultureInfo.InvariantCulture, QsvCacheKeyFormat, device); |
|
|
|
if (_memoryCache.TryGetValue(cacheKey, out List<VaapiProfileEntrypoint>? profileEntrypoints) && |
|
profileEntrypoints is not null) |
|
{ |
|
return new VaapiHardwareCapabilities(profileEntrypoints, _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)) |
|
{ |
|
Option<string> vaapiOutput = await GetVaapiOutput("drm", Option<string>.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 for using QSV device {Device}", |
|
profileEntrypoints.Count, |
|
device); |
|
|
|
_memoryCache.Set(cacheKey, profileEntrypoints); |
|
return new VaapiHardwareCapabilities(profileEntrypoints, _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 async Task<IHardwareCapabilities> GetNvidiaCapabilities( |
|
string ffmpegPath, |
|
IFFmpegCapabilities ffmpegCapabilities) |
|
{ |
|
if (_memoryCache.TryGetValue(ArchitectureCacheKey, out int cachedArchitecture) |
|
&& _memoryCache.TryGetValue(ModelCacheKey, out string? cachedModel) |
|
&& cachedModel is not null) |
|
{ |
|
return new NvidiaHardwareCapabilities( |
|
cachedArchitecture, |
|
cachedModel, |
|
ffmpegCapabilities, |
|
_logger); |
|
} |
|
|
|
string output = await GetNvidiaOutput(ffmpegPath); |
|
|
|
Option<string> maybeLine = Optional(output.Split("\n").FirstOrDefault(x => x.Contains("GPU"))); |
|
foreach (string line in maybeLine) |
|
{ |
|
const string ARCHITECTURE_PATTERN = @"SM\s+(\d\.\d)"; |
|
Match match = Regex.Match(line, ARCHITECTURE_PATTERN); |
|
if (match.Success && int.TryParse(match.Groups[1].Value.Replace(".", string.Empty), out int architecture)) |
|
{ |
|
const string MODEL_PATTERN = @"(GTX\s+[0-9a-zA-Z]+[\sTtIi]+)"; |
|
Match modelMatch = Regex.Match(line, MODEL_PATTERN); |
|
string model = modelMatch.Success ? modelMatch.Groups[1].Value.Trim() : "unknown"; |
|
_logger.LogDebug( |
|
"Detected NVIDIA GPU model {Model} architecture SM {Architecture}", |
|
model, |
|
architecture); |
|
_memoryCache.Set(ArchitectureCacheKey, architecture); |
|
_memoryCache.Set(ModelCacheKey, model); |
|
return new NvidiaHardwareCapabilities(architecture, model, ffmpegCapabilities, _logger); |
|
} |
|
} |
|
|
|
_logger.LogWarning( |
|
"Error detecting NVIDIA GPU capabilities; some hardware accelerated features will be unavailable"); |
|
|
|
return new NoHardwareCapabilities(); |
|
} |
|
}
|
|
|