From 0750a0712f5f649f0a27aa103377bad6fa6927ec Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Sat, 12 Jun 2021 06:16:52 -0500 Subject: [PATCH] allow animated channel watermarks (#255) --- ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs | 12 ++++-- ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs | 14 +++++-- .../Interfaces/Images/IImageCache.cs | 1 + ErsatzTV.Infrastructure/Images/ImageCache.cs | 34 ++++++++++++++- ErsatzTV/Pages/ChannelEditor.razor | 41 ++++++++++++------- 5 files changed, 80 insertions(+), 22 deletions(-) diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs index 9f866276d..199f37d7b 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs @@ -155,15 +155,19 @@ namespace ErsatzTV.Core.FFmpeg public FFmpegProcessBuilder WithWatermark( Option watermark, Option maybePath, - IDisplaySize resolution) + IDisplaySize resolution, + bool isAnimated) { foreach (string path in maybePath) { - string subfolder = path[..2]; - string fullPath = Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder, path); + if (isAnimated) + { + _arguments.Add("-ignore_loop"); + _arguments.Add("0"); + } _arguments.Add("-i"); - _arguments.Add(fullPath); + _arguments.Add(path); _complexFilterBuilder = _complexFilterBuilder.WithWatermark(watermark, resolution); } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs index 768e71a85..57dac8a5d 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs @@ -4,6 +4,7 @@ using System.IO; using System.Threading.Tasks; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.Core.Interfaces.Images; using LanguageExt; namespace ErsatzTV.Core.FFmpeg @@ -11,14 +12,17 @@ namespace ErsatzTV.Core.FFmpeg public class FFmpegProcessService { private readonly IFFmpegStreamSelector _ffmpegStreamSelector; + private readonly IImageCache _imageCache; private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator; public FFmpegProcessService( FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService, - IFFmpegStreamSelector ffmpegStreamSelector) + IFFmpegStreamSelector ffmpegStreamSelector, + IImageCache imageCache) { _playbackSettingsCalculator = ffmpegPlaybackSettingsService; _ffmpegStreamSelector = ffmpegStreamSelector; + _imageCache = imageCache; } public async Task ForPlayoutItem( @@ -46,7 +50,11 @@ namespace ErsatzTV.Core.FFmpeg .Filter(_ => channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect) .Filter(a => a.ArtworkKind == ArtworkKind.Logo) .HeadOrNone() - .Map(a => a.Path); + .Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option.None)); + + bool isAnimated = await maybeWatermarkPath.Match( + p => _imageCache.IsAnimated(p), + () => Task.FromResult(false)); Option maybeWatermark = channel.Watermark; @@ -58,7 +66,7 @@ namespace ErsatzTV.Core.FFmpeg .WithRealtimeOutput(playbackSettings.RealtimeOutput) .WithSeek(playbackSettings.StreamSeek) .WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec) - .WithWatermark(maybeWatermark, maybeWatermarkPath, channel.FFmpegProfile.Resolution) + .WithWatermark(maybeWatermark, maybeWatermarkPath, channel.FFmpegProfile.Resolution, isAnimated) .WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale) .WithAlignedAudio(playbackSettings.AudioDuration) .WithNormalizeLoudness(playbackSettings.NormalizeLoudness); diff --git a/ErsatzTV.Core/Interfaces/Images/IImageCache.cs b/ErsatzTV.Core/Interfaces/Images/IImageCache.cs index b2ebb1bf4..3f25ad4b0 100644 --- a/ErsatzTV.Core/Interfaces/Images/IImageCache.cs +++ b/ErsatzTV.Core/Interfaces/Images/IImageCache.cs @@ -10,5 +10,6 @@ namespace ErsatzTV.Core.Interfaces.Images Task> SaveArtworkToCache(byte[] imageBuffer, ArtworkKind artworkKind); Task> CopyArtworkToCache(string path, ArtworkKind artworkKind); string GetPathForImage(string fileName, ArtworkKind artworkKind, Option maybeMaxHeight); + Task IsAnimated(string fileName); } } diff --git a/ErsatzTV.Infrastructure/Images/ImageCache.cs b/ErsatzTV.Infrastructure/Images/ImageCache.cs index 6a80dcdc7..be9541c7a 100644 --- a/ErsatzTV.Infrastructure/Images/ImageCache.cs +++ b/ErsatzTV.Infrastructure/Images/ImageCache.cs @@ -8,6 +8,8 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Metadata; using LanguageExt; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.Processing; @@ -18,10 +20,17 @@ namespace ErsatzTV.Infrastructure.Images { private static readonly SHA1CryptoServiceProvider Crypto; private readonly ILocalFileSystem _localFileSystem; + private readonly ILogger _logger; + private readonly IMemoryCache _memoryCache; static ImageCache() => Crypto = new SHA1CryptoServiceProvider(); - public ImageCache(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem; + public ImageCache(ILocalFileSystem localFileSystem, IMemoryCache memoryCache, ILogger logger) + { + _localFileSystem = localFileSystem; + _memoryCache = memoryCache; + _logger = logger; + } public async Task> ResizeImage(byte[] imageBuffer, int height) { @@ -120,5 +129,28 @@ namespace ErsatzTV.Infrastructure.Images return Path.Combine(baseFolder, fileName); } + + public async Task IsAnimated(string fileName) + { + 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) + { + _logger.LogError(ex, "Unable to check image for animation"); + return false; + } + } } } diff --git a/ErsatzTV/Pages/ChannelEditor.razor b/ErsatzTV/Pages/ChannelEditor.razor index a57e4b981..212593504 100644 --- a/ErsatzTV/Pages/ChannelEditor.razor +++ b/ErsatzTV/Pages/ChannelEditor.razor @@ -248,20 +248,33 @@ private async Task UploadLogo(InputFileChangeEventArgs e) { - var buffer = new byte[e.File.Size]; - await e.File.OpenReadStream().ReadAsync(buffer); - Either maybeCacheFileName = await _mediator.Send(new SaveArtworkToDisk(buffer, ArtworkKind.Logo)); - maybeCacheFileName.Match( - relativeFileName => - { - _model.Logo = relativeFileName; - StateHasChanged(); - }, - error => - { - _snackbar.Add($"Unexpected error saving channel logo: {error.Value}", Severity.Error); - _logger.LogError("Unexpected error saving channel logo: {Error}", error.Value); - }); + try + { + var buffer = new byte[e.File.Size]; + await e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024).ReadAsync(buffer); + Either maybeCacheFileName = await _mediator.Send(new SaveArtworkToDisk(buffer, ArtworkKind.Logo)); + maybeCacheFileName.Match( + relativeFileName => + { + _model.Logo = relativeFileName; + StateHasChanged(); + }, + error => + { + _snackbar.Add($"Unexpected error saving channel logo: {error.Value}", Severity.Error); + _logger.LogError("Unexpected error saving channel logo: {Error}", error.Value); + }); + } + catch (IOException) + { + _snackbar.Add("Channel logo exceeds maximum allowed file size of 10 MB", Severity.Error); + _logger.LogError("Channel logo exceeds maximum allowed file size of 10 MB"); + } + catch (Exception ex) + { + _snackbar.Add($"Unexpected error saving channel logo: {ex.Message}", Severity.Error); + _logger.LogError("Unexpected error saving channel logo: {Error}", ex.Message); + } } } \ No newline at end of file