Browse Source

optimize image manipulation (#722)

* update dependencies

* use ffmpeg to resize images

* use ffprobe to check for animated logos and watermarks

* remove last use of imagesharp
pull/723/head
Jason Dove 3 years ago committed by GitHub
parent
commit
d54766866e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/ErsatzTV.Application.csproj
  3. 72
      ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs
  4. 17
      ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs
  5. 1
      ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs
  6. 1
      ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs
  7. 3
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  8. 1
      ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs
  9. 6
      ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
  10. 3
      ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs
  11. 3
      ErsatzTV.Core/ErsatzTV.Core.csproj
  12. 48
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  13. 106
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  14. 6
      ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs
  15. 4
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  16. 1
      ErsatzTV.Core/Interfaces/FFmpeg/ISongVideoGenerator.cs
  17. 6
      ErsatzTV.Core/Interfaces/Images/IImageCache.cs
  18. 36
      ErsatzTV.Core/Metadata/LocalFolderScanner.cs
  19. 4
      ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj
  20. 27
      ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs
  21. 22
      ErsatzTV.FFmpeg/Filter/ScaleImageFilter.cs
  22. 27
      ErsatzTV.FFmpeg/Filter/VideoFilter.cs
  23. 5
      ErsatzTV.FFmpeg/FrameSize.cs
  24. 13
      ErsatzTV.FFmpeg/Option/FileNameOutputOption.cs
  25. 23
      ErsatzTV.FFmpeg/PipelineBuilder.cs
  26. 3
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  27. 86
      ErsatzTV.Infrastructure/Images/ImageCache.cs
  28. 6
      ErsatzTV/ErsatzTV.csproj

2
CHANGELOG.md

@ -25,6 +25,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -25,6 +25,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- This mode is used when a schedule is updated, or when collection modifications trigger a playout rebuild
- `Reset` - this mode will rebuild the entire playout and will NOT maintain progress
- This mode is only used when the `Reset Playout` button is clicked on the Playouts page
- Use ffmpeg to resize images; this should help reduce ErsatzTV's memory use
- Use ffprobe to check for animated logos and watermarks; this should help reduce ErsatzTV's memory use
## [0.4.5-alpha] - 2022-03-29
### Fixed

2
ErsatzTV.Application/ErsatzTV.Application.csproj

@ -7,7 +7,7 @@ @@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.0" />
<PackageReference Include="Bugsnag" Version="3.0.1" />
<PackageReference Include="CliWrap" Version="3.4.2" />
<PackageReference Include="MediatR" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />

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

@ -1,5 +1,10 @@ @@ -1,5 +1,10 @@
using ErsatzTV.Core;
using System.Diagnostics;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Repositories;
using Winista.Mime;
namespace ErsatzTV.Application.Images;
@ -9,12 +14,32 @@ public class @@ -9,12 +14,32 @@ public class
{
private static readonly MimeTypes MimeTypes = new();
private readonly IImageCache _imageCache;
private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IConfigElementRepository _configElementRepository;
public GetCachedImagePathHandler(IImageCache imageCache) => _imageCache = imageCache;
public GetCachedImagePathHandler(
IImageCache imageCache,
IFFmpegProcessService ffmpegProcessService,
IConfigElementRepository configElementRepository)
{
_imageCache = imageCache;
_ffmpegProcessService = ffmpegProcessService;
_configElementRepository = configElementRepository;
}
public async Task<Either<BaseError, CachedImagePathViewModel>> Handle(
GetCachedImagePath request,
CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate();
return await validation.Match(
ffmpegPath => Handle(ffmpegPath, request),
error => Task.FromResult<Either<BaseError, CachedImagePathViewModel>>(error.Join()));
}
private async Task<Either<BaseError, CachedImagePathViewModel>> Handle(
string ffmpegPath,
GetCachedImagePath request)
{
try
{
@ -24,23 +49,44 @@ public class @@ -24,23 +49,44 @@ public class
request.FileName,
request.ArtworkKind,
Optional(request.MaxHeight));
if (cachePath == null)
{
return BaseError.New("Failed to generate cache path for image");
}
if (!File.Exists(cachePath))
{
if (request.MaxHeight.HasValue)
{
string originalPath = _imageCache.GetPathForImage(request.FileName, request.ArtworkKind, None);
byte[] contents = await File.ReadAllBytesAsync(originalPath, cancellationToken);
Either<BaseError, byte[]> resizeResult =
await _imageCache.ResizeImage(contents, request.MaxHeight.Value);
resizeResult.IfRight(result => contents = result);
string baseFolder = Path.GetDirectoryName(cachePath);
if (baseFolder != null && !Directory.Exists(baseFolder))
{
Directory.CreateDirectory(baseFolder);
}
await File.WriteAllBytesAsync(cachePath, contents, cancellationToken);
// ffmpeg needs the extension to determine the output codec
string withExtension = cachePath + ".jpg";
string originalPath = _imageCache.GetPathForImage(request.FileName, request.ArtworkKind, None);
Process process = _ffmpegProcessService.ResizeImage(
ffmpegPath,
originalPath,
withExtension,
request.MaxHeight.Value);
CommandResult resize = await Cli.Wrap(process.StartInfo.FileName)
.WithArguments(process.StartInfo.ArgumentList)
.WithValidation(CommandResultValidation.None)
.ExecuteAsync();
if (resize.ExitCode != 0)
{
return BaseError.New($"Failed to resize image; exit code {resize.ExitCode}");
}
File.Move(withExtension, cachePath);
mimeType = new MimeType("image/jpeg");
}
@ -61,4 +107,12 @@ public class @@ -61,4 +107,12 @@ public class
return BaseError.New(ex.Message);
}
}
private async Task<Validation<BaseError, string>> Validate() =>
await ValidateFFmpegPath();
private Task<Validation<BaseError, string>> ValidateFFmpegPath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath)
.FilterT(File.Exists)
.Map(ffmpegPath => ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
}

17
ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs

@ -17,9 +17,9 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr @@ -17,9 +17,9 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
public async Task<Either<BaseError, PlayoutItemProcessModel>> Handle(T request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Tuple<Channel, string>> validation = await Validate(dbContext, request);
Validation<BaseError, Tuple<Channel, string, string>> validation = await Validate(dbContext, request);
return await validation.Match(
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2, cancellationToken),
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2, tuple.Item3, cancellationToken),
error => Task.FromResult<Either<BaseError, PlayoutItemProcessModel>>(error.Join()));
}
@ -28,13 +28,15 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr @@ -28,13 +28,15 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
T request,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken);
private static async Task<Validation<BaseError, Tuple<Channel, string>>> Validate(
private static async Task<Validation<BaseError, Tuple<Channel, string, string>>> Validate(
TvContext dbContext,
T request) =>
(await ChannelMustExist(dbContext, request), await FFmpegPathMustExist(dbContext))
.Apply((channel, ffmpegPath) => Tuple(channel, ffmpegPath));
(await ChannelMustExist(dbContext, request), await FFmpegPathMustExist(dbContext),
await FFprobePathMustExist(dbContext))
.Apply((channel, ffmpegPath, ffprobePath) => Tuple(channel, ffmpegPath, ffprobePath));
private static Task<Validation<BaseError, Channel>> ChannelMustExist(TvContext dbContext, T request) =>
dbContext.Channels
@ -63,4 +65,9 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr @@ -63,4 +65,9 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath)
.FilterT(File.Exists)
.Map(maybePath => maybePath.ToValidation<BaseError>("FFmpeg path does not exist on filesystem"));
private static Task<Validation<BaseError, string>> FFprobePathMustExist(TvContext dbContext) =>
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(maybePath => maybePath.ToValidation<BaseError>("FFprobe path does not exist on filesystem"));
}

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

@ -25,6 +25,7 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo @@ -25,6 +25,7 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
GetConcatProcessByChannelNumber request,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
{
bool saveReports = await dbContext.ConfigElements

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

@ -24,6 +24,7 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess> @@ -24,6 +24,7 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess>
GetErrorProcess request,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
{
Process process = await _ffmpegProcessService.ForError(

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

@ -58,6 +58,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -58,6 +58,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
GetPlayoutItemProcessByChannelNumber request,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
{
DateTimeOffset now = request.Now;
@ -128,6 +129,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -128,6 +129,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel,
maybeGlobalWatermark,
ffmpegPath,
ffprobePath,
cancellationToken);
}
@ -137,6 +139,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -137,6 +139,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Process process = await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
ffprobePath,
saveReports,
channel,
videoVersion,

1
ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs

@ -25,6 +25,7 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetW @@ -25,6 +25,7 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetW
GetWrappedProcessByChannelNumber request,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
{
bool saveReports = await dbContext.ConfigElements

6
ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj

@ -7,9 +7,9 @@ @@ -7,9 +7,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.0" />
<PackageReference Include="Bugsnag" Version="3.0.1" />
<PackageReference Include="CliWrap" Version="3.4.2" />
<PackageReference Include="FluentAssertions" Version="6.5.1" />
<PackageReference Include="FluentAssertions" Version="6.6.0" />
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
@ -21,7 +21,7 @@ @@ -21,7 +21,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.17.2" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />

3
ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs

@ -11,6 +11,7 @@ using ErsatzTV.Core.Interfaces.Images; @@ -11,6 +11,7 @@ using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
@ -220,6 +221,7 @@ public class TranscodingTests @@ -220,6 +221,7 @@ public class TranscodingTests
imageCache.Object,
new Mock<ITempFilePool>().Object,
new Mock<IClient>().Object,
new Mock<IMemoryCache>().Object,
LoggerFactory.CreateLogger<FFmpegProcessService>());
var service = new FFmpegLibraryProcessService(
@ -318,6 +320,7 @@ public class TranscodingTests @@ -318,6 +320,7 @@ public class TranscodingTests
using Process process = await service.ForPlayoutItem(
ExecutableName("ffmpeg"),
ExecutableName("ffprobe"),
false,
new Channel(Guid.NewGuid())
{

3
ErsatzTV.Core/ErsatzTV.Core.csproj

@ -7,11 +7,12 @@ @@ -7,11 +7,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.0.0" />
<PackageReference Include="Bugsnag" Version="3.0.1" />
<PackageReference Include="Destructurama.Attributed" Version="3.0.0" />
<PackageReference Include="Flurl" Version="3.0.4" />
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
<PackageReference Include="MediatR" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />

48
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -34,6 +34,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -34,6 +34,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
public async Task<Process> ForPlayoutItem(
string ffmpegPath,
string ffprobePath,
bool saveReports,
Channel channel,
MediaVersion videoVersion,
@ -72,7 +73,13 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -72,7 +73,13 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
targetFramerate);
Option<WatermarkOptions> watermarkOptions =
await _ffmpegProcessService.GetWatermarkOptions(channel, globalWatermark, videoVersion, None, None);
await _ffmpegProcessService.GetWatermarkOptions(
ffprobePath,
channel,
globalWatermark,
videoVersion,
None,
None);
Option<List<FadePoint>> maybeFadePoints = watermarkOptions
.Map(o => o.Watermark)
@ -316,6 +323,25 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -316,6 +323,25 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
public Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host) =>
_ffmpegProcessService.WrapSegmenter(ffmpegPath, saveReports, channel, scheme, host);
public Process ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
{
var videoInputFile = new VideoInputFile(
inputFile,
new List<VideoStream> { new(0, string.Empty, None, FrameSize.Unknown, None, true) });
var pipelineBuilder = new PipelineBuilder(
videoInputFile,
None,
None,
None,
FileSystemLayout.FFmpegReportsFolder,
_logger);
FFmpegPipeline pipeline = pipelineBuilder.Resize(outputFile, new FrameSize(-1, height));
return GetProcess(ffmpegPath, videoInputFile, None, None, None, pipeline, false);
}
public Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile) =>
_ffmpegProcessService.ConvertToPng(ffmpegPath, inputFile, outputFile);
@ -324,6 +350,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -324,6 +350,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
public Task<Either<BaseError, string>> GenerateSongImage(
string ffmpegPath,
string ffprobePath,
Option<string> subtitleFile,
Channel channel,
Option<ChannelWatermark> globalWatermark,
@ -338,6 +365,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -338,6 +365,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
CancellationToken cancellationToken) =>
_ffmpegProcessService.GenerateSongImage(
ffmpegPath,
ffprobePath,
subtitleFile,
channel,
globalWatermark,
@ -357,7 +385,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -357,7 +385,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<ConcatInputFile> concatInputFile,
FFmpegPipeline pipeline)
FFmpegPipeline pipeline,
bool log = true)
{
IEnumerable<string> loggedSteps = pipeline.PipelineSteps.Map(ps => ps.GetType().Name);
IEnumerable<string> loggedVideoFilters =
@ -365,12 +394,15 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -365,12 +394,15 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
IEnumerable<string> loggedAudioFilters =
audioInputFile.Map(f => f.FilterSteps.Map(af => af.GetType().Name)).Flatten();
_logger.LogDebug(
"FFmpeg pipeline {PipelineSteps}, {AudioFilters}, {VideoFilters}",
loggedSteps,
loggedAudioFilters,
loggedVideoFilters
);
if (log)
{
_logger.LogDebug(
"FFmpeg pipeline {PipelineSteps}, {AudioFilters}, {VideoFilters}",
loggedSteps,
loggedAudioFilters,
loggedVideoFilters
);
}
IList<EnvironmentVariable> environmentVariables =
CommandGenerator.GenerateEnvironmentVariables(pipeline.PipelineSteps);

106
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -1,21 +1,23 @@ @@ -1,21 +1,23 @@
using System.Diagnostics;
using Bugsnag;
using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.FFmpeg.State;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg;
public class FFmpegProcessService : IFFmpegProcessService
public class FFmpegProcessService
{
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly IImageCache _imageCache;
private readonly ITempFilePool _tempFilePool;
private readonly IClient _client;
private readonly IMemoryCache _memoryCache;
private readonly ILogger<FFmpegProcessService> _logger;
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
@ -25,6 +27,7 @@ public class FFmpegProcessService : IFFmpegProcessService @@ -25,6 +27,7 @@ public class FFmpegProcessService : IFFmpegProcessService
IImageCache imageCache,
ITempFilePool tempFilePool,
IClient client,
IMemoryCache memoryCache,
ILogger<FFmpegProcessService> logger)
{
_playbackSettingsCalculator = ffmpegPlaybackSettingsService;
@ -32,33 +35,10 @@ public class FFmpegProcessService : IFFmpegProcessService @@ -32,33 +35,10 @@ public class FFmpegProcessService : IFFmpegProcessService
_imageCache = imageCache;
_tempFilePool = tempFilePool;
_client = client;
_memoryCache = memoryCache;
_logger = logger;
}
public Task<Process> ForPlayoutItem(
string ffmpegPath,
bool saveReports,
Channel channel,
MediaVersion videoVersion,
MediaVersion audioVersion,
string videoPath,
string audioPath,
DateTimeOffset start,
DateTimeOffset finish,
DateTimeOffset now,
Option<ChannelWatermark> globalWatermark,
VaapiDriver vaapiDriver,
string vaapiDevice,
bool hlsRealtime,
FillerKind fillerKind,
TimeSpan inPoint,
TimeSpan outPoint,
long ptsOffset,
Option<int> targetFramerate)
{
throw new NotSupportedException();
}
public async Task<Process> ForError(
string ffmpegPath,
Channel channel,
@ -140,11 +120,6 @@ public class FFmpegProcessService : IFFmpegProcessService @@ -140,11 +120,6 @@ public class FFmpegProcessService : IFFmpegProcessService
}
}
public Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
{
throw new NotSupportedException();
}
public Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
@ -186,6 +161,7 @@ public class FFmpegProcessService : IFFmpegProcessService @@ -186,6 +161,7 @@ public class FFmpegProcessService : IFFmpegProcessService
public async Task<Either<BaseError, string>> GenerateSongImage(
string ffmpegPath,
string ffprobePath,
Option<string> subtitleFile,
Channel channel,
Option<ChannelWatermark> globalWatermark,
@ -220,7 +196,13 @@ public class FFmpegProcessService : IFFmpegProcessService @@ -220,7 +196,13 @@ public class FFmpegProcessService : IFFmpegProcessService
: None;
Option<WatermarkOptions> watermarkOptions =
await GetWatermarkOptions(channel, globalWatermark, videoVersion, watermarkOverride, watermarkPath);
await GetWatermarkOptions(
ffprobePath,
channel,
globalWatermark,
videoVersion,
watermarkOverride,
watermarkPath);
FFmpegPlaybackSettings playbackSettings =
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
@ -289,6 +271,7 @@ public class FFmpegProcessService : IFFmpegProcessService @@ -289,6 +271,7 @@ public class FFmpegProcessService : IFFmpegProcessService
displaySize.Width != target.Width || displaySize.Height != target.Height;
internal async Task<WatermarkOptions> GetWatermarkOptions(
string ffprobePath,
Channel channel,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
@ -320,7 +303,7 @@ public class FFmpegProcessService : IFFmpegProcessService @@ -320,7 +303,7 @@ public class FFmpegProcessService : IFFmpegProcessService
await watermarkOverride.IfNoneAsync(channel.Watermark),
customPath,
None,
await _imageCache.IsAnimated(customPath));
await IsAnimated(ffprobePath, customPath));
case ChannelWatermarkImageSource.ChannelLogo:
Option<string> maybeChannelPath = channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
@ -331,7 +314,7 @@ public class FFmpegProcessService : IFFmpegProcessService @@ -331,7 +314,7 @@ public class FFmpegProcessService : IFFmpegProcessService
maybeChannelPath,
None,
await maybeChannelPath.Match(
p => _imageCache.IsAnimated(p),
p => IsAnimated(ffprobePath, p),
() => Task.FromResult(false)));
default:
throw new NotSupportedException("Unsupported watermark image source");
@ -352,7 +335,7 @@ public class FFmpegProcessService : IFFmpegProcessService @@ -352,7 +335,7 @@ public class FFmpegProcessService : IFFmpegProcessService
await watermarkOverride.IfNoneAsync(watermark),
customPath,
None,
await _imageCache.IsAnimated(customPath));
await IsAnimated(ffprobePath, customPath));
case ChannelWatermarkImageSource.ChannelLogo:
Option<string> maybeChannelPath = channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
@ -363,7 +346,7 @@ public class FFmpegProcessService : IFFmpegProcessService @@ -363,7 +346,7 @@ public class FFmpegProcessService : IFFmpegProcessService
maybeChannelPath,
None,
await maybeChannelPath.Match(
p => _imageCache.IsAnimated(p),
p => IsAnimated(ffprobePath, p),
() => Task.FromResult(false)));
default:
throw new NotSupportedException("Unsupported watermark image source");
@ -373,4 +356,55 @@ public class FFmpegProcessService : IFFmpegProcessService @@ -373,4 +356,55 @@ public class FFmpegProcessService : IFFmpegProcessService
return new WatermarkOptions(None, None, None, false);
}
private async Task<bool> IsAnimated(string ffprobePath, string path)
{
try
{
var cacheKey = $"image.animated.{Path.GetFileName(path)}";
if (_memoryCache.TryGetValue(cacheKey, out bool animated))
{
return animated;
}
BufferedCommandResult result = await Cli.Wrap(ffprobePath)
.WithArguments(
new[]
{
"-loglevel", "error",
"-select_streams", "v:0",
"-count_frames",
"-show_entries", "stream=nb_read_frames",
"-print_format", "csv",
path
})
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync();
if (result.ExitCode == 0)
{
string output = result.StandardOutput;
output = output.Replace("stream,", string.Empty);
if (int.TryParse(output, out int frameCount))
{
bool isAnimated = frameCount > 1;
_memoryCache.Set(cacheKey, isAnimated, TimeSpan.FromDays(1));
return isAnimated;
}
}
else
{
_logger.LogWarning(
"Error checking frame count for file {File}l exit code {ExitCode}",
path,
result.ExitCode);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error checking frame count for file {File}", path);
}
return false;
}
}

6
ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs

@ -31,6 +31,7 @@ public class SongVideoGenerator : ISongVideoGenerator @@ -31,6 +31,7 @@ public class SongVideoGenerator : ISongVideoGenerator
Channel channel,
Option<ChannelWatermark> maybeGlobalWatermark,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
{
Option<string> subtitleFile = None;
@ -190,9 +191,7 @@ public class SongVideoGenerator : ISongVideoGenerator @@ -190,9 +191,7 @@ public class SongVideoGenerator : ISongVideoGenerator
{
string hash = hashes[NextRandom(hashes.Count)];
backgroundPath = await _imageCache.WriteBlurHash(
hash,
channel.FFmpegProfile.Resolution);
backgroundPath = _imageCache.WriteBlurHash(hash, channel.FFmpegProfile.Resolution);
videoVersion.Height = channel.FFmpegProfile.Resolution.Height;
videoVersion.Width = channel.FFmpegProfile.Resolution.Width;
@ -214,6 +213,7 @@ public class SongVideoGenerator : ISongVideoGenerator @@ -214,6 +213,7 @@ public class SongVideoGenerator : ISongVideoGenerator
Either<BaseError, string> maybeSongImage = await _ffmpegProcessService.GenerateSongImage(
ffmpegPath,
ffprobePath,
subtitleFile,
channel,
maybeGlobalWatermark,

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

@ -10,6 +10,7 @@ public interface IFFmpegProcessService @@ -10,6 +10,7 @@ public interface IFFmpegProcessService
{
Task<Process> ForPlayoutItem(
string ffmpegPath,
string ffprobePath,
bool saveReports,
Channel channel,
MediaVersion videoVersion,
@ -41,12 +42,15 @@ public interface IFFmpegProcessService @@ -41,12 +42,15 @@ public interface IFFmpegProcessService
Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Process ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);
Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile);
Process ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile);
Task<Either<BaseError, string>> GenerateSongImage(
string ffmpegPath,
string ffprobePath,
Option<string> subtitleFile,
Channel channel,
Option<ChannelWatermark> globalWatermark,

1
ErsatzTV.Core/Interfaces/FFmpeg/ISongVideoGenerator.cs

@ -9,5 +9,6 @@ public interface ISongVideoGenerator @@ -9,5 +9,6 @@ public interface ISongVideoGenerator
Channel channel,
Option<ChannelWatermark> maybeGlobalWatermark,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken);
}

6
ErsatzTV.Core/Interfaces/Images/IImageCache.cs

@ -5,11 +5,9 @@ namespace ErsatzTV.Core.Interfaces.Images; @@ -5,11 +5,9 @@ namespace ErsatzTV.Core.Interfaces.Images;
public interface IImageCache
{
Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height);
Task<Either<BaseError, string>> SaveArtworkToCache(Stream stream, ArtworkKind artworkKind);
Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind);
string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight);
Task<bool> IsAnimated(string fileName);
Task<string> CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y);
Task<string> WriteBlurHash(string blurHash, IDisplaySize targetSize);
string CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y);
string WriteBlurHash(string blurHash, IDisplaySize targetSize);
}

36
ErsatzTV.Core/Metadata/LocalFolderScanner.cs

@ -206,21 +206,9 @@ public abstract class LocalFolderScanner @@ -206,21 +206,9 @@ public abstract class LocalFolderScanner
if (metadata is SongMetadata)
{
artwork.BlurHash43 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
4,
3);
artwork.BlurHash54 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
5,
4);
artwork.BlurHash64 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
6,
4);
artwork.BlurHash43 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 4, 3);
artwork.BlurHash54 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 5, 4);
artwork.BlurHash64 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 6, 4);
}
await _metadataRepository.UpdateArtworkPath(artwork);
@ -238,21 +226,9 @@ public abstract class LocalFolderScanner @@ -238,21 +226,9 @@ public abstract class LocalFolderScanner
if (metadata is SongMetadata)
{
artwork.BlurHash43 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
4,
3);
artwork.BlurHash54 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
5,
4);
artwork.BlurHash64 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
6,
4);
artwork.BlurHash43 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 4, 3);
artwork.BlurHash54 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 5, 4);
artwork.BlurHash64 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 6, 4);
}
metadata.Artwork.Add(artwork);

4
ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj

@ -8,10 +8,10 @@ @@ -8,10 +8,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.5.1" />
<PackageReference Include="FluentAssertions" Version="6.6.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="Moq" Version="4.17.2" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="coverlet.collector" Version="3.1.2">
<PrivateAssets>all</PrivateAssets>

27
ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs

@ -155,6 +155,33 @@ public class PipelineGeneratorTests @@ -155,6 +155,33 @@ public class PipelineGeneratorTests
"-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -i /tmp/whatever.mkv -map 0:1 -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
}
[Test]
public void Resize_Image_Test()
{
var height = 200;
var videoInputFile = new VideoInputFile(
"/test/input/file.png",
new List<VideoStream>
{
new(0, string.Empty, Option<IPixelFormat>.None, FrameSize.Unknown, Option<string>.None, true)
});
var pipelineBuilder = new PipelineBuilder(
videoInputFile,
Option<AudioInputFile>.None,
Option<WatermarkInputFile>.None,
Option<SubtitleInputFile>.None,
"",
_logger);
FFmpegPipeline result = pipelineBuilder.Resize("/test/output/file.jpg", new FrameSize(-1, height));
string command = PrintCommand(videoInputFile, None, None, None, result);
command.Should().Be("-nostdin -hide_banner -nostats -loglevel error -i /test/input/file.png -vf scale=-1:200 /test/output/file.jpg");
}
private static string PrintCommand(
Option<VideoInputFile> videoInputFile,
Option<AudioInputFile> audioInputFile,

22
ErsatzTV.FFmpeg/Filter/ScaleImageFilter.cs

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
namespace ErsatzTV.FFmpeg.Filter;
public class ScaleImageFilter : BaseFilter
{
private readonly FrameSize _scaledSize;
public ScaleImageFilter(FrameSize scaledSize)
{
_scaledSize = scaledSize;
}
public override string Filter => $"scale={_scaledSize.Width}:{_scaledSize.Height}";
// public override IList<string> OutputOptions => new List<string> { "-q:v", "2" };
public override FrameState NextState(FrameState currentState) => currentState with
{
ScaledSize = _scaledSize,
PaddedSize = _scaledSize,
FrameDataLocation = FrameDataLocation.Software
};
}

27
ErsatzTV.FFmpeg/Filter/VideoFilter.cs

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
using ErsatzTV.FFmpeg.Environment;
namespace ErsatzTV.FFmpeg.Filter;
public class VideoFilter : IPipelineStep
{
private readonly IEnumerable<IPipelineFilterStep> _filterSteps;
public VideoFilter(IEnumerable<IPipelineFilterStep> filterSteps)
{
_filterSteps = filterSteps;
}
private IList<string> Arguments() =>
new List<string>
{
"-vf",
string.Join(",", _filterSteps.Map(fs => fs.Filter))
};
public IList<EnvironmentVariable> EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public IList<string> GlobalOptions => Array.Empty<string>();
public IList<string> InputOptions(InputFile inputFile) => Array.Empty<string>();
public IList<string> FilterOptions => Arguments();
public IList<string> OutputOptions => Array.Empty<string>();
public FrameState NextState(FrameState currentState) => currentState;
}

5
ErsatzTV.FFmpeg/FrameSize.cs

@ -1,3 +1,6 @@ @@ -1,3 +1,6 @@
namespace ErsatzTV.FFmpeg;
public record FrameSize(int Width, int Height);
public record FrameSize(int Width, int Height)
{
public static FrameSize Unknown = new(-1, -1);
}

13
ErsatzTV.FFmpeg/Option/FileNameOutputOption.cs

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
namespace ErsatzTV.FFmpeg.Option;
public class FileNameOutputOption : OutputOption
{
private readonly string _outputFile;
public FileNameOutputOption(string outputFile)
{
_outputFile = outputFile;
}
public override IList<string> OutputOptions => new List<string> { _outputFile };
}

23
ErsatzTV.FFmpeg/PipelineBuilder.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using ErsatzTV.FFmpeg.Decoder;
using System.Numerics;
using ErsatzTV.FFmpeg.Decoder;
using ErsatzTV.FFmpeg.Encoder;
using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Filter;
@ -53,6 +54,24 @@ public class PipelineBuilder @@ -53,6 +54,24 @@ public class PipelineBuilder
_logger = logger;
}
public FFmpegPipeline Resize(string outputFile, FrameSize scaledSize)
{
_pipelineSteps.Clear();
_pipelineSteps.Add(new NoStandardInputOption());
_pipelineSteps.Add(new HideBannerOption());
_pipelineSteps.Add(new NoStatsOption());
_pipelineSteps.Add(new LoglevelErrorOption());
IPipelineFilterStep scaleStep = new ScaleImageFilter(scaledSize);
_videoInputFile.Iter(f => f.FilterSteps.Add(scaleStep));
_pipelineSteps.Add(new VideoFilter(new[] { scaleStep }));
_pipelineSteps.Add(scaleStep);
_pipelineSteps.Add(new FileNameOutputOption(outputFile));
return new FFmpegPipeline(_pipelineSteps);
}
public FFmpegPipeline Concat(ConcatInputFile concatInputFile, FFmpegState ffmpegState)
{
concatInputFile.AddOption(new ConcatInputFormat());
@ -87,7 +106,7 @@ public class PipelineBuilder @@ -87,7 +106,7 @@ public class PipelineBuilder
return new FFmpegPipeline(_pipelineSteps);
}
public FFmpegPipeline Build(FFmpegState ffmpegState, FrameState desiredState)
{
var allVideoStreams = _videoInputFile.SelectMany(f => f.VideoStreams).ToList();

3
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -8,7 +8,7 @@ @@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Blurhash.ImageSharp" Version="1.1.1" />
<PackageReference Include="Blurhash.System.Drawing.Common" Version="2.1.1" />
<PackageReference Include="CliWrap" Version="3.4.2" />
<PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00016" />
@ -27,7 +27,6 @@ @@ -27,7 +27,6 @@
<PackageReference Include="Refit" Version="6.3.2" />
<PackageReference Include="Refit.Newtonsoft.Json" Version="6.3.2" />
<PackageReference Include="Refit.Xml" Version="6.3.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.4" />
</ItemGroup>
<ItemGroup>

86
ErsatzTV.Infrastructure/Images/ImageCache.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System.Security.Cryptography;
using System.Drawing.Imaging;
using System.Security.Cryptography;
using System.Text;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@ -6,12 +7,8 @@ using ErsatzTV.Core.FFmpeg; @@ -6,12 +7,8 @@ using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Decoder = System.Drawing.Common.Blurhash.Decoder;
using Encoder = System.Drawing.Common.Blurhash.Encoder;
namespace ErsatzTV.Infrastructure.Images;
@ -19,43 +16,16 @@ public class ImageCache : IImageCache @@ -19,43 +16,16 @@ public class ImageCache : IImageCache
{
private static readonly SHA1 Crypto;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<ImageCache> _logger;
private readonly IMemoryCache _memoryCache;
private readonly ITempFilePool _tempFilePool;
static ImageCache() => Crypto = SHA1.Create();
public ImageCache(
ILocalFileSystem localFileSystem,
IMemoryCache memoryCache,
ITempFilePool tempFilePool,
ILogger<ImageCache> logger)
ITempFilePool tempFilePool)
{
_localFileSystem = localFileSystem;
_memoryCache = memoryCache;
_tempFilePool = tempFilePool;
_logger = logger;
}
public async Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height)
{
await using var inStream = new MemoryStream(imageBuffer);
using Image image = await Image.LoadAsync(inStream);
var size = new Size { Height = height };
image.Mutate(
i => i.Resize(
new ResizeOptions
{
Mode = ResizeMode.Max,
Size = size
}));
await using var outStream = new MemoryStream();
await image.SaveAsync(outStream, new JpegEncoder { Quality = 90 });
return outStream.ToArray();
}
public async Task<Either<BaseError, string>> SaveArtworkToCache(Stream stream, ArtworkKind artworkKind)
@ -156,39 +126,18 @@ public class ImageCache : IImageCache @@ -156,39 +126,18 @@ public class ImageCache : IImageCache
return Path.Combine(baseFolder, fileName);
}
public async Task<bool> IsAnimated(string fileName)
public string CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y)
{
try
{
var cacheKey = $"image.animated.{Path.GetFileName(fileName)}";
if (_memoryCache.TryGetValue(cacheKey, out bool animated))
{
return animated;
}
using Image image = await Image.LoadAsync(fileName);
animated = image.Frames.Count > 1;
_memoryCache.Set(cacheKey, animated, TimeSpan.FromDays(1));
return animated;
}
catch (Exception ex)
var encoder = new Encoder();
string targetFile = GetPathForImage(fileName, artworkKind, Option<int>.None);
// ReSharper disable once ConvertToUsingDeclaration
using (var image = System.Drawing.Image.FromFile(targetFile))
{
_logger.LogError(ex, "Unable to check image for animation");
return false;
return encoder.Encode(image, x, y);
}
}
public async Task<string> CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y)
{
var encoder = new Blurhash.ImageSharp.Encoder();
string targetFile = GetPathForImage(fileName, artworkKind, Option<int>.None);
await using var fs = new FileStream(targetFile, FileMode.Open, FileAccess.Read);
using var image = await Image.LoadAsync<Rgb24>(fs);
return encoder.Encode(image, x, y);
}
public async Task<string> WriteBlurHash(string blurHash, IDisplaySize targetSize)
public string WriteBlurHash(string blurHash, IDisplaySize targetSize)
{
byte[] bytes = Encoding.UTF8.GetBytes(blurHash);
string base64 = Convert.ToBase64String(bytes).Replace("+", "_").Replace("/", "-").Replace("=", "");
@ -197,10 +146,13 @@ public class ImageCache : IImageCache @@ -197,10 +146,13 @@ public class ImageCache : IImageCache
{
string folder = Path.GetDirectoryName(targetFile);
_localFileSystem.EnsureFolderExists(folder);
var decoder = new Blurhash.ImageSharp.Decoder();
using Image<Rgb24> image = decoder.Decode(blurHash, targetSize.Width, targetSize.Height);
await image.SaveAsPngAsync(targetFile);
var decoder = new Decoder();
// ReSharper disable once ConvertToUsingDeclaration
using (System.Drawing.Image image = decoder.Decode(blurHash, targetSize.Width, targetSize.Height))
{
image.Save(targetFile, ImageFormat.Png);
}
}
return targetFile;

6
ErsatzTV/ErsatzTV.csproj

@ -54,12 +54,12 @@ @@ -54,12 +54,12 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Bugsnag.AspNet.Core" Version="3.0.0" />
<PackageReference Include="Bugsnag.AspNet.Core" Version="3.0.1" />
<PackageReference Include="FluentValidation" Version="10.4.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.4.0" />
<PackageReference Include="HtmlSanitizer" Version="7.1.488" />
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
<PackageReference Include="Markdig" Version="0.28.0" />
<PackageReference Include="Markdig" Version="0.28.1" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.3" />
@ -72,7 +72,7 @@ @@ -72,7 +72,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MudBlazor" Version="6.0.7" />
<PackageReference Include="MudBlazor" Version="6.0.9" />
<PackageReference Include="NaturalSort.Extension" Version="3.2.0" />
<PackageReference Include="PPioli.FluentValidation.Blazor" Version="5.0.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="6.3.2" />

Loading…
Cancel
Save