Browse Source

nvidia - decode 10-bit h264 in software (#2833)

* output progress/speed even when copying video

* nvidia - decode 10-bit h264 in software

* fixes

* fix tests
pull/2835/head
Jason Dove 2 months ago committed by GitHub
parent
commit
0c30c47ba9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs
  3. 9
      ErsatzTV.Application/FFmpegProfiles/Queries/GetSupportedHardwareAccelerationKindsHandler.cs
  4. 3
      ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs
  5. 3
      ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs
  6. 3
      ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs
  7. 12
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  8. 8
      ErsatzTV.Application/Streaming/Queries/GetSeekTextSubtitleProcessHandler.cs
  9. 3
      ErsatzTV.Application/Streaming/Queries/GetSlugProcessByChannelNumberHandler.cs
  10. 3
      ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs
  11. 44
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  12. 28
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  13. 9
      ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs
  14. 3
      ErsatzTV.FFmpeg/Capabilities/FFmpegCapabilities.cs
  15. 82
      ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs
  16. 1
      ErsatzTV.FFmpeg/Capabilities/IFFmpegCapabilities.cs
  17. 4
      ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilitiesFactory.cs
  18. 12
      ErsatzTV.FFmpeg/Capabilities/NvidiaHardwareCapabilities.cs
  19. 3
      ErsatzTV.FFmpeg/Pipeline/IPipelineBuilderFactory.cs
  20. 2
      ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs
  21. 6
      ErsatzTV.FFmpeg/Pipeline/PipelineBuilderFactory.cs
  22. 3
      ErsatzTV.Infrastructure/Health/Checks/FFmpegCapabilitiesHealthCheck.cs
  23. 37
      ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs
  24. 2
      ErsatzTV.Infrastructure/Health/Checks/VaapiDriverHealthCheck.cs
  25. 2
      ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs

1
CHANGELOG.md

@ -33,6 +33,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -33,6 +33,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fix Trakt list sync
- Fix some cases of QSV audio/video desync when *not* seeking by using software decode
- This only applies to content that *might* be problematic (using a heuristic)
- NVIDIA: force software decode of 10-bit h264 content since hardware decode is unsupported by ffmpeg until version 8
## [26.2.0] - 2026-02-02
### Added

2
ErsatzTV.Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs

@ -26,7 +26,7 @@ public class RefreshFFmpegCapabilitiesHandler( @@ -26,7 +26,7 @@ public class RefreshFFmpegCapabilitiesHandler(
foreach (string ffmpegPath in maybeFFmpegPath)
{
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
Option<string> maybeFFprobePath = await dbContext.ConfigElements
.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)

9
ErsatzTV.Application/FFmpegProfiles/Queries/GetSupportedHardwareAccelerationKindsHandler.cs

@ -31,15 +31,18 @@ public class @@ -31,15 +31,18 @@ public class
Validation<BaseError, string> validation = await Validate(dbContext, cancellationToken);
return await validation.Match(
GetHardwareAccelerationKinds,
ffmpegPath => GetHardwareAccelerationKinds(ffmpegPath, cancellationToken),
_ => Task.FromResult(new List<HardwareAccelerationKind> { HardwareAccelerationKind.None }));
}
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(string ffmpegPath)
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(
string ffmpegPath,
CancellationToken cancellationToken)
{
var result = new List<HardwareAccelerationKind> { HardwareAccelerationKind.None };
IFFmpegCapabilities ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
IFFmpegCapabilities ffmpegCapabilities =
await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Nvenc))
{

3
ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs

@ -74,7 +74,8 @@ public class @@ -74,7 +74,8 @@ public class
ffmpegPath,
originalPath,
withExtension,
request.MaxHeight.Value);
request.MaxHeight.Value,
cancellationToken);
CommandResult resize = await process.ExecuteAsync(cancellationToken);

3
ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs

@ -36,7 +36,8 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo @@ -36,7 +36,8 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
saveReports,
channel,
request.Scheme,
request.Host);
request.Host,
cancellationToken);
return new PlayoutItemProcessModel(
process,

3
ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs

@ -32,7 +32,8 @@ public class GetErrorProcessHandler( @@ -32,7 +32,8 @@ public class GetErrorProcessHandler(
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
process,

12
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -337,7 +337,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -337,7 +337,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
doesNotExistProcess,
@ -534,7 +535,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -534,7 +535,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
offlineProcess,
@ -558,7 +560,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -558,7 +560,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
doesNotExistProcess,
@ -582,7 +585,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -582,7 +585,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
errorProcess,

8
ErsatzTV.Application/Streaming/Queries/GetSeekTextSubtitleProcessHandler.cs

@ -21,19 +21,21 @@ public class GetSeekTextSubtitleProcessHandler( @@ -21,19 +21,21 @@ public class GetSeekTextSubtitleProcessHandler(
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, string> validation = await Validate(dbContext, cancellationToken);
return await validation.Match(
ffmpegPath => GetProcess(request, ffmpegPath),
ffmpegPath => GetProcess(request, ffmpegPath, cancellationToken),
error => Task.FromResult<Either<BaseError, SeekTextSubtitleProcess>>(error.Join()));
}
private async Task<Either<BaseError, SeekTextSubtitleProcess>> GetProcess(
GetSeekTextSubtitleProcess request,
string ffmpegPath)
string ffmpegPath,
CancellationToken cancellationToken)
{
Command process = await ffmpegProcessService.SeekTextSubtitle(
ffmpegPath,
request.PathAndCodec.Path,
request.PathAndCodec.Codec,
request.Seek);
request.Seek,
cancellationToken);
return new SeekTextSubtitleProcess(process);
}

3
ErsatzTV.Application/Streaming/Queries/GetSlugProcessByChannelNumberHandler.cs

@ -34,7 +34,8 @@ public class GetSlugProcessByChannelNumberHandler( @@ -34,7 +34,8 @@ public class GetSlugProcessByChannelNumberHandler(
request.Now,
duration,
request.HlsRealtime,
request.PtsOffset);
request.PtsOffset,
cancellationToken);
var result = new PlayoutItemProcessModel(
playoutItemResult,

3
ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs

@ -161,7 +161,8 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI @@ -161,7 +161,8 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
videoToolboxCapabilities.AppendLine();
}
var ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value);
var ffmpegCapabilities =
await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value, cancellationToken);
aviSynthDemuxer = ffmpegCapabilities.HasDemuxFormat(FFmpegKnownFormat.AviSynth);
aviSynthInstalled = _hardwareCapabilitiesFactory.IsAviSynthInstalled();
}

44
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -604,7 +604,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -604,7 +604,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
VaapiDeviceName(hwAccel, vaapiDevice),
await customReportsFolder.IfNoneAsync(FileSystemLayout.FFmpegReportsFolder),
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
ffmpegPath,
cancellationToken);
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
@ -687,7 +688,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -687,7 +688,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
string vaapiDisplay,
VaapiDriver vaapiDriver,
string vaapiDevice,
Option<int> qsvExtraHardwareFrames)
Option<int> qsvExtraHardwareFrames,
CancellationToken cancellationToken)
{
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
channel.StreamingMode,
@ -830,7 +832,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -830,7 +832,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
? FileSystemLayout.TranscodeTroubleshootingFolder
: FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
ffmpegPath,
cancellationToken);
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
@ -843,7 +846,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -843,7 +846,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
DateTimeOffset now,
TimeSpan duration,
bool hlsRealtime,
TimeSpan ptsOffset)
TimeSpan ptsOffset,
CancellationToken cancellationToken)
{
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
channel.StreamingMode,
@ -960,7 +964,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -960,7 +964,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
? FileSystemLayout.TranscodeTroubleshootingFolder
: FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
ffmpegPath,
cancellationToken);
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
@ -972,7 +977,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -972,7 +977,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
bool saveReports,
Channel channel,
string scheme,
string host)
string host,
CancellationToken cancellationToken)
{
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
@ -993,7 +999,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -993,7 +999,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<string>.None,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
ffmpegPath,
cancellationToken);
FFmpegPipeline pipeline = pipelineBuilder.Concat(
concatInputFile,
@ -1063,7 +1070,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -1063,7 +1070,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<string>.None,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
ffmpegPath,
cancellationToken);
FFmpegPipeline pipeline = pipelineBuilder.WrapSegmenter(
concatInputFile,
@ -1072,7 +1080,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -1072,7 +1080,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return GetCommand(ffmpegPath, None, None, None, concatInputFile, None, pipeline);
}
public async Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
public async Task<Command> ResizeImage(
string ffmpegPath,
string inputFile,
string outputFile,
int height,
CancellationToken cancellationToken)
{
var videoInputFile = new VideoInputFile(
inputFile,
@ -1105,7 +1118,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -1105,7 +1118,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<string>.None,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
ffmpegPath,
cancellationToken);
FFmpegPipeline pipeline = pipelineBuilder.Resize(outputFile, new FrameSize(-1, height));
@ -1141,7 +1155,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -1141,7 +1155,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
watermarkWidthPercent,
cancellationToken);
public async Task<Command> SeekTextSubtitle(string ffmpegPath, string inputFile, string codec, TimeSpan seek)
public async Task<Command> SeekTextSubtitle(
string ffmpegPath,
string inputFile,
string codec,
TimeSpan seek,
CancellationToken cancellationToken)
{
var videoInputFile = new VideoInputFile(
inputFile,
@ -1174,7 +1193,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -1174,7 +1193,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<string>.None,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
ffmpegPath,
cancellationToken);
FFmpegPipeline pipeline = pipelineBuilder.Seek(inputFile, codec, seek);

28
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs

@ -56,7 +56,8 @@ public interface IFFmpegProcessService @@ -56,7 +56,8 @@ public interface IFFmpegProcessService
string vaapiDisplay,
VaapiDriver vaapiDriver,
string vaapiDevice,
Option<int> qsvExtraHardwareFrames);
Option<int> qsvExtraHardwareFrames,
CancellationToken cancellationToken);
Task<Command> Slug(
string ffmpegPath,
@ -64,9 +65,16 @@ public interface IFFmpegProcessService @@ -64,9 +65,16 @@ public interface IFFmpegProcessService
DateTimeOffset now,
TimeSpan duration,
bool hlsRealtime,
TimeSpan ptsOffset);
TimeSpan ptsOffset,
CancellationToken cancellationToken);
Task<Command> ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Task<Command> ConcatChannel(
string ffmpegPath,
bool saveReports,
Channel channel,
string scheme,
string host,
CancellationToken cancellationToken);
Task<Command> WrapSegmenter(
string ffmpegPath,
@ -77,7 +85,12 @@ public interface IFFmpegProcessService @@ -77,7 +85,12 @@ public interface IFFmpegProcessService
string accessToken,
CancellationToken cancellationToken);
Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);
Task<Command> ResizeImage(
string ffmpegPath,
string inputFile,
string outputFile,
int height,
CancellationToken cancellationToken);
Task<Either<BaseError, string>> GenerateSongImage(
string ffmpegPath,
@ -94,5 +107,10 @@ public interface IFFmpegProcessService @@ -94,5 +107,10 @@ public interface IFFmpegProcessService
int watermarkWidthPercent,
CancellationToken cancellationToken);
Task<Command> SeekTextSubtitle(string ffmpegPath, string inputFile, string codec, TimeSpan seek);
Task<Command> SeekTextSubtitle(
string ffmpegPath,
string inputFile,
string codec,
TimeSpan seek,
CancellationToken cancellationToken);
}

9
ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs

@ -122,7 +122,7 @@ public class PipelineBuilderBaseTests @@ -122,7 +122,7 @@ public class PipelineBuilderBaseTests
string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
command.ShouldBe(
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -progress - -ss 00:00:01 -c:v h264 -readrate 1.05 -i /tmp/whatever.mkv -filter_complex [0:1]aresample=async=1[a] -map 0:0 -map [a] -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -bf 0 -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
"-nostdin -hide_banner -nostats -progress - -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -readrate 1.05 -i /tmp/whatever.mkv -filter_complex [0:1]aresample=async=1[a] -map 0:0 -map [a] -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -bf 0 -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
}
[Test]
@ -225,7 +225,7 @@ public class PipelineBuilderBaseTests @@ -225,7 +225,7 @@ public class PipelineBuilderBaseTests
string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
command.ShouldBe(
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -progress - -ss 00:00:01 -c:v h264 -readrate 1.05 -i /tmp/whatever.mkv -filter_complex [0:1]aresample=async=1[a] -map 0:0 -map [a] -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -bf 0 -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -ac 6 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
"-nostdin -hide_banner -nostats -progress - -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -readrate 1.05 -i /tmp/whatever.mkv -filter_complex [0:1]aresample=async=1[a] -map 0:0 -map [a] -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -bf 0 -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -c:a aac -ac 6 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
}
[Test]
@ -390,7 +390,7 @@ public class PipelineBuilderBaseTests @@ -390,7 +390,7 @@ public class PipelineBuilderBaseTests
// 0.4.0 reference: "-nostdin -threads 1 -hide_banner -loglevel error -nostats -fflags +genpts+discardcorrupt+igndts -re -ss 00:14:33.6195516 -i /tmp/whatever.mkv -map 0:0 -map 0:a -c:v copy -flags cgop -sc_threshold 0 -c:a copy -movflags +faststart -metadata service_provider="ErsatzTV" -metadata service_name="ErsatzTV" -t 00:06:39.6934484 -f mpegts -mpegts_flags +initial_discontinuity pipe:1"
command.ShouldBe(
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:1 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -c:v copy -c:a copy -f mp4 pipe:1");
"-nostdin -hide_banner -nostats -progress - -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:1 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -c:v copy -c:a copy -f mp4 pipe:1");
}
[Test]
@ -484,7 +484,7 @@ public class PipelineBuilderBaseTests @@ -484,7 +484,7 @@ public class PipelineBuilderBaseTests
string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
command.ShouldBe(
"-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:a -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -c:v copy -c:a copy -f mp4 pipe:1");
"-nostdin -hide_banner -nostats -progress - -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:a -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -c:v copy -c:a copy -f mp4 pipe:1");
}
[Test]
@ -556,6 +556,7 @@ public class PipelineBuilderBaseTests @@ -556,6 +556,7 @@ public class PipelineBuilderBaseTests
}
public class DefaultFFmpegCapabilities() : FFmpegCapabilities(
string.Empty,
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>(),
new System.Collections.Generic.HashSet<string>(),

3
ErsatzTV.FFmpeg/Capabilities/FFmpegCapabilities.cs

@ -4,6 +4,7 @@ using ErsatzTV.FFmpeg.Format; @@ -4,6 +4,7 @@ using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.Capabilities;
public class FFmpegCapabilities(
string ffmpegVersion,
IReadOnlySet<string> ffmpegHardwareAccelerations,
IReadOnlySet<string> ffmpegDecoders,
IReadOnlySet<string> ffmpegFilters,
@ -12,6 +13,8 @@ public class FFmpegCapabilities( @@ -12,6 +13,8 @@ public class FFmpegCapabilities(
IReadOnlySet<string> ffmpegDemuxFormats)
: IFFmpegCapabilities
{
public string Version => ffmpegVersion;
public bool HasHardwareAcceleration(HardwareAccelerationMode hardwareAccelerationMode)
{
// AMF isn't a "hwaccel" in ffmpeg, so check for presence of encoders

82
ErsatzTV.FFmpeg/Capabilities/HardwareCapabilitiesFactory.cs

@ -25,6 +25,7 @@ public partial class HardwareCapabilitiesFactory( @@ -25,6 +25,7 @@ public partial class HardwareCapabilitiesFactory(
: IHardwareCapabilitiesFactory
{
private const string CudaDeviceKey = "ffmpeg.hardware.cuda.device";
private const string FFmpegVersionKey = "ffmpeg.version";
private static readonly CompositeFormat VaapiCacheKeyFormat =
CompositeFormat.Parse("ffmpeg.hardware.vaapi.{0}.{1}.{2}");
@ -46,6 +47,7 @@ public partial class HardwareCapabilitiesFactory( @@ -46,6 +47,7 @@ public partial class HardwareCapabilitiesFactory(
public void ClearCache()
{
memoryCache.Remove(FFmpegVersionKey);
memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "hwaccels"));
memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "decoders"));
memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "filters"));
@ -54,33 +56,39 @@ public partial class HardwareCapabilitiesFactory( @@ -54,33 +56,39 @@ public partial class HardwareCapabilitiesFactory(
memoryCache.Remove(string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "formats"));
}
public async Task<IFFmpegCapabilities> GetFFmpegCapabilities(string ffmpegPath)
public async Task<IFFmpegCapabilities> GetFFmpegCapabilities(string ffmpegPath, CancellationToken cancellationToken)
{
// TODO: validate videotoolbox somehow
// TODO: validate amf somehow
string ffmpegVersion = await GetFFmpegVersion(ffmpegPath, cancellationToken);
IReadOnlySet<string> ffmpegHardwareAccelerations =
await GetFFmpegCapabilities(ffmpegPath, "hwaccels", ParseFFmpegAccelLine)
await GetFFmpegCapabilities(ffmpegPath, "hwaccels", ParseFFmpegAccelLine, cancellationToken)
.Map(set => set.Intersect(FFmpegKnownHardwareAcceleration.AllAccels).ToImmutableHashSet());
IReadOnlySet<string> ffmpegDecoders = await GetFFmpegCapabilities(ffmpegPath, "decoders", ParseFFmpegLine)
.Map(set => set.Intersect(FFmpegKnownDecoder.AllDecoders).ToImmutableHashSet());
IReadOnlySet<string> ffmpegDecoders =
await GetFFmpegCapabilities(ffmpegPath, "decoders", ParseFFmpegLine, cancellationToken)
.Map(set => set.Intersect(FFmpegKnownDecoder.AllDecoders).ToImmutableHashSet());
IEnumerable<string> allFilterNames =
FFmpegKnownFilter.AllFilters.Union(FFmpegKnownFilter.RequiredFilters.Select(f => f.Name));
IReadOnlySet<string> ffmpegFilters = await GetFFmpegCapabilities(ffmpegPath, "filters", ParseFFmpegLine)
.Map(set => set.Intersect(allFilterNames).ToImmutableHashSet());
IReadOnlySet<string> ffmpegFilters =
await GetFFmpegCapabilities(ffmpegPath, "filters", ParseFFmpegLine, cancellationToken)
.Map(set => set.Intersect(allFilterNames).ToImmutableHashSet());
IReadOnlySet<string> ffmpegEncoders = await GetFFmpegCapabilities(ffmpegPath, "encoders", ParseFFmpegLine)
.Map(set => set.Intersect(FFmpegKnownEncoder.AllEncoders).ToImmutableHashSet());
IReadOnlySet<string> ffmpegEncoders =
await GetFFmpegCapabilities(ffmpegPath, "encoders", ParseFFmpegLine, cancellationToken)
.Map(set => set.Intersect(FFmpegKnownEncoder.AllEncoders).ToImmutableHashSet());
IReadOnlySet<string> ffmpegOptions = await GetFFmpegOptions(ffmpegPath)
IReadOnlySet<string> ffmpegOptions = await GetFFmpegOptions(ffmpegPath, cancellationToken)
.Map(set => set.Intersect(FFmpegKnownOption.AllOptions).ToImmutableHashSet());
IReadOnlySet<string> ffmpegDemuxFormats = await GetFFmpegFormats(ffmpegPath, "D")
IReadOnlySet<string> ffmpegDemuxFormats = await GetFFmpegFormats(ffmpegPath, "D", cancellationToken)
.Map(set => set.Intersect(FFmpegKnownFormat.AllFormats).ToImmutableHashSet());
return new FFmpegCapabilities(
ffmpegVersion,
ffmpegHardwareAccelerations,
ffmpegDecoders,
ffmpegFilters,
@ -339,10 +347,40 @@ public partial class HardwareCapabilitiesFactory( @@ -339,10 +347,40 @@ public partial class HardwareCapabilitiesFactory(
return memoryCache.TryGetValue(cacheKey, out bool installed) && installed;
}
public async Task<string> GetFFmpegVersion(string ffmpegPath, CancellationToken cancellationToken)
{
if (memoryCache.TryGetValue(FFmpegVersionKey, out string? ffmpegVersion) &&
ffmpegVersion is not null)
{
return ffmpegVersion;
}
string[] arguments = ["-version"];
BufferedCommandResult result = await Cli.Wrap(ffmpegPath)
.WithArguments(arguments)
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(Encoding.UTF8, cancellationToken);
string output = string.IsNullOrWhiteSpace(result.StandardOutput)
? result.StandardError
: result.StandardOutput;
string versionResult = await output.Split("\n").Map(s => s.Trim())
.Bind(l => ParseFFmpegVersionLine(l))
.HeadOrNone()
.IfNoneAsync(string.Empty);
memoryCache.Set(FFmpegVersionKey, versionResult);
return versionResult;
}
private async Task<IReadOnlySet<string>> GetFFmpegCapabilities(
string ffmpegPath,
string capabilities,
Func<string, Option<string>> parseLine)
Func<string, Option<string>> parseLine,
CancellationToken cancellationToken)
{
var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, capabilities);
if (memoryCache.TryGetValue(cacheKey, out IReadOnlySet<string>? cachedCapabilities) &&
@ -356,7 +394,7 @@ public partial class HardwareCapabilitiesFactory( @@ -356,7 +394,7 @@ public partial class HardwareCapabilitiesFactory(
BufferedCommandResult result = await Cli.Wrap(ffmpegPath)
.WithArguments(arguments)
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(Encoding.UTF8);
.ExecuteBufferedAsync(Encoding.UTF8, cancellationToken);
string output = string.IsNullOrWhiteSpace(result.StandardOutput)
? result.StandardError
@ -371,7 +409,7 @@ public partial class HardwareCapabilitiesFactory( @@ -371,7 +409,7 @@ public partial class HardwareCapabilitiesFactory(
return capabilitiesResult;
}
private async Task<IReadOnlySet<string>> GetFFmpegOptions(string ffmpegPath)
private async Task<IReadOnlySet<string>> GetFFmpegOptions(string ffmpegPath, CancellationToken cancellationToken)
{
var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "options");
if (memoryCache.TryGetValue(cacheKey, out IReadOnlySet<string>? cachedCapabilities) &&
@ -385,7 +423,7 @@ public partial class HardwareCapabilitiesFactory( @@ -385,7 +423,7 @@ public partial class HardwareCapabilitiesFactory(
BufferedCommandResult result = await Cli.Wrap(ffmpegPath)
.WithArguments(arguments)
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(Encoding.UTF8);
.ExecuteBufferedAsync(Encoding.UTF8, cancellationToken);
string output = string.IsNullOrWhiteSpace(result.StandardOutput)
? result.StandardError
@ -400,7 +438,10 @@ public partial class HardwareCapabilitiesFactory( @@ -400,7 +438,10 @@ public partial class HardwareCapabilitiesFactory(
return capabilitiesResult;
}
private async Task<IReadOnlySet<string>> GetFFmpegFormats(string ffmpegPath, string muxDemux)
private async Task<IReadOnlySet<string>> GetFFmpegFormats(
string ffmpegPath,
string muxDemux,
CancellationToken cancellationToken)
{
var cacheKey = string.Format(CultureInfo.InvariantCulture, FFmpegCapabilitiesCacheKeyFormat, "formats");
if (memoryCache.TryGetValue(cacheKey, out IReadOnlySet<string>? cachedCapabilities) &&
@ -414,7 +455,7 @@ public partial class HardwareCapabilitiesFactory( @@ -414,7 +455,7 @@ public partial class HardwareCapabilitiesFactory(
BufferedCommandResult result = await Cli.Wrap(ffmpegPath)
.WithArguments(arguments)
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(Encoding.UTF8);
.ExecuteBufferedAsync(Encoding.UTF8, cancellationToken);
string output = string.IsNullOrWhiteSpace(result.StandardOutput)
? result.StandardError
@ -431,6 +472,12 @@ public partial class HardwareCapabilitiesFactory( @@ -431,6 +472,12 @@ public partial class HardwareCapabilitiesFactory(
return capabilitiesResult;
}
private static Option<string> ParseFFmpegVersionLine(string input)
{
Match match = VersionRegex().Match(input);
return match.Success ? match.Groups[1].Value : Option<string>.None;
}
private static Option<string> ParseFFmpegAccelLine(string input)
{
Match match = AccelRegex().Match(input);
@ -661,6 +708,9 @@ public partial class HardwareCapabilitiesFactory( @@ -661,6 +708,9 @@ public partial class HardwareCapabilitiesFactory(
return new NoHardwareCapabilities();
}
[GeneratedRegex(@"version\s+([^\s]+)")]
private static partial Regex VersionRegex();
[GeneratedRegex(@"^([\w]+)$")]
private static partial Regex AccelRegex();

1
ErsatzTV.FFmpeg/Capabilities/IFFmpegCapabilities.cs

@ -4,6 +4,7 @@ namespace ErsatzTV.FFmpeg.Capabilities; @@ -4,6 +4,7 @@ namespace ErsatzTV.FFmpeg.Capabilities;
public interface IFFmpegCapabilities
{
string Version { get; }
bool HasHardwareAcceleration(HardwareAccelerationMode hardwareAccelerationMode);
bool HasDecoder(FFmpegKnownDecoder decoder);
bool HasEncoder(FFmpegKnownEncoder encoder);

4
ErsatzTV.FFmpeg/Capabilities/IHardwareCapabilitiesFactory.cs

@ -6,7 +6,9 @@ public interface IHardwareCapabilitiesFactory @@ -6,7 +6,9 @@ public interface IHardwareCapabilitiesFactory
{
void ClearCache();
Task<IFFmpegCapabilities> GetFFmpegCapabilities(string ffmpegPath);
Task<IFFmpegCapabilities> GetFFmpegCapabilities(string ffmpegPath, CancellationToken cancellationToken);
Task<string> GetFFmpegVersion(string ffmpegPath, CancellationToken cancellationToken);
Task<IHardwareCapabilities> GetHardwareCapabilities(
IFFmpegCapabilities ffmpegCapabilities,

12
ErsatzTV.FFmpeg/Capabilities/NvidiaHardwareCapabilities.cs

@ -57,6 +57,18 @@ public class NvidiaHardwareCapabilities(CudaDevice cudaDevice, IFFmpegCapabiliti @@ -57,6 +57,18 @@ public class NvidiaHardwareCapabilities(CudaDevice cudaDevice, IFFmpegCapabiliti
isHardware = false;
}
// 10-bit h264 hardware decode is not supported until ffmpeg 8
if (isHardware && videoFormat is VideoFormat.H264 && bitDepth == 10)
{
bool isVersion8OrNewer = int.TryParse(
ffmpegCapabilities.Version.Split('.')[0].TrimStart('n', 'N'),
out int major) && major >= 8;
if (!isVersion8OrNewer)
{
isHardware = false;
}
}
if (isHardware)
{
return videoFormat switch

3
ErsatzTV.FFmpeg/Pipeline/IPipelineBuilderFactory.cs

@ -15,5 +15,6 @@ public interface IPipelineBuilderFactory @@ -15,5 +15,6 @@ public interface IPipelineBuilderFactory
Option<string> vaapiDevice,
string reportsFolder,
string fontsFolder,
string ffmpegPath);
string ffmpegPath,
CancellationToken cancellationToken);
}

2
ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs

@ -211,6 +211,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -211,6 +211,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
new NoStandardInputOption(),
new HideBannerOption(),
new NoStatsOption(),
new ProgressOption(),
new LoglevelErrorOption(),
new StandardFormatFlags(),
new NoDemuxDecodeDelayOutputOption(),
@ -220,7 +221,6 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -220,7 +221,6 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
if (desiredState.VideoFormat != VideoFormat.Copy)
{
pipelineSteps.Add(new ClosedGopOutputOption());
pipelineSteps.Add(new ProgressOption());
}
if (desiredState.VideoFormat != VideoFormat.Copy && !desiredState.AllowBFrames)

6
ErsatzTV.FFmpeg/Pipeline/PipelineBuilderFactory.cs

@ -29,9 +29,11 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory @@ -29,9 +29,11 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
Option<string> vaapiDevice,
string reportsFolder,
string fontsFolder,
string ffmpegPath)
string ffmpegPath,
CancellationToken cancellationToken)
{
IFFmpegCapabilities ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
IFFmpegCapabilities ffmpegCapabilities =
await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
IHardwareCapabilities capabilities = await _hardwareCapabilitiesFactory.GetHardwareCapabilities(
ffmpegCapabilities,

3
ErsatzTV.Infrastructure/Health/Checks/FFmpegCapabilitiesHealthCheck.cs

@ -23,7 +23,8 @@ public class FFmpegCapabilitiesHealthCheck(IConfigElementRepository configElemen @@ -23,7 +23,8 @@ public class FFmpegCapabilitiesHealthCheck(IConfigElementRepository configElemen
foreach (ConfigElement ffmpegPath in maybeFFmpegPath)
{
var ffmpegCapabilities = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value);
var ffmpegCapabilities =
await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value, cancellationToken);
List<string> missingFilters = [];

37
ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs

@ -1,20 +1,20 @@ @@ -1,20 +1,20 @@
using System.Text.RegularExpressions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Capabilities;
namespace ErsatzTV.Infrastructure.Health.Checks;
public class FFmpegVersionHealthCheck(IConfigElementRepository configElementRepository)
public class FFmpegVersionHealthCheck(
IConfigElementRepository configElementRepository,
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory)
: BaseHealthCheck, IFFmpegVersionHealthCheck
{
private const string BundledVersion = "7.1.1";
private const string BundledVersionVaapi = "7.1.1";
private const string WindowsVersionPrefix = "n7.1.1";
private static readonly string[] FFmpegVersionArguments = { "-version" };
public override string Title => "FFmpeg Version";
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
@ -37,8 +37,9 @@ public class FFmpegVersionHealthCheck(IConfigElementRepository configElementRepo @@ -37,8 +37,9 @@ public class FFmpegVersionHealthCheck(IConfigElementRepository configElementRepo
foreach (ConfigElement ffmpegPath in maybeFFmpegPath)
{
Option<string> maybeVersion = await GetVersion(ffmpegPath.Value, cancellationToken);
if (maybeVersion.IsNone)
Option<string> maybeVersion =
await hardwareCapabilitiesFactory.GetFFmpegVersion(ffmpegPath.Value, cancellationToken);
if (maybeVersion.IsNone || maybeVersion.Exists(string.IsNullOrWhiteSpace))
{
return WarningResult("Unable to determine ffmpeg version", "Unable to determine ffmpeg version", link);
}
@ -54,8 +55,9 @@ public class FFmpegVersionHealthCheck(IConfigElementRepository configElementRepo @@ -54,8 +55,9 @@ public class FFmpegVersionHealthCheck(IConfigElementRepository configElementRepo
foreach (ConfigElement ffprobePath in maybeFFprobePath)
{
Option<string> maybeVersion = await GetVersion(ffprobePath.Value, cancellationToken);
if (maybeVersion.IsNone)
Option<string> maybeVersion =
await hardwareCapabilitiesFactory.GetFFmpegVersion(ffprobePath.Value, cancellationToken);
if (maybeVersion.IsNone || maybeVersion.Exists(string.IsNullOrWhiteSpace))
{
return WarningResult(
"Unable to determine ffprobe version",
@ -101,21 +103,4 @@ public class FFmpegVersionHealthCheck(IConfigElementRepository configElementRepo @@ -101,21 +103,4 @@ public class FFmpegVersionHealthCheck(IConfigElementRepository configElementRepo
return None;
}
private static async Task<Option<string>> GetVersion(string path, CancellationToken cancellationToken)
{
Option<string> maybeLine = await GetProcessOutput(path, FFmpegVersionArguments, cancellationToken)
.Map(s => s.Split("\n").HeadOrNone().Map(h => h.Trim()));
foreach (string line in maybeLine)
{
const string PATTERN = @"version\s+([^\s]+)";
Match match = Regex.Match(line, PATTERN);
if (match.Success)
{
return match.Groups[1].Value;
}
}
return None;
}
}

2
ErsatzTV.Infrastructure/Health/Checks/VaapiDriverHealthCheck.cs

@ -55,7 +55,7 @@ public class VaapiDriverHealthCheck( @@ -55,7 +55,7 @@ public class VaapiDriverHealthCheck(
foreach (string ffmpegPath in maybeFFmpegPath)
{
IFFmpegCapabilities ffmpegCapabilities =
await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
foreach (FFmpegProfile profile in activeFFmpegProfiles)
{
Option<string> vaapiDriver = VaapiDriverName(profile.VaapiDriver);

2
ErsatzTV.Scanner/Application/FFmpeg/Commands/RefreshFFmpegCapabilitiesHandler.cs

@ -26,7 +26,7 @@ public class RefreshFFmpegCapabilitiesHandler( @@ -26,7 +26,7 @@ public class RefreshFFmpegCapabilitiesHandler(
foreach (string ffmpegPath in maybeFFmpegPath)
{
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
Option<string> maybeFFprobePath = await dbContext.ConfigElements
.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)

Loading…
Cancel
Save