allow animated channel watermarks (#255)
This commit is contained in:
@@ -155,15 +155,19 @@ namespace ErsatzTV.Core.FFmpeg
|
|||||||
public FFmpegProcessBuilder WithWatermark(
|
public FFmpegProcessBuilder WithWatermark(
|
||||||
Option<ChannelWatermark> watermark,
|
Option<ChannelWatermark> watermark,
|
||||||
Option<string> maybePath,
|
Option<string> maybePath,
|
||||||
IDisplaySize resolution)
|
IDisplaySize resolution,
|
||||||
|
bool isAnimated)
|
||||||
{
|
{
|
||||||
foreach (string path in maybePath)
|
foreach (string path in maybePath)
|
||||||
{
|
{
|
||||||
string subfolder = path[..2];
|
if (isAnimated)
|
||||||
string fullPath = Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder, path);
|
{
|
||||||
|
_arguments.Add("-ignore_loop");
|
||||||
|
_arguments.Add("0");
|
||||||
|
}
|
||||||
|
|
||||||
_arguments.Add("-i");
|
_arguments.Add("-i");
|
||||||
_arguments.Add(fullPath);
|
_arguments.Add(path);
|
||||||
|
|
||||||
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(watermark, resolution);
|
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(watermark, resolution);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.IO;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||||
|
using ErsatzTV.Core.Interfaces.Images;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
|
|
||||||
namespace ErsatzTV.Core.FFmpeg
|
namespace ErsatzTV.Core.FFmpeg
|
||||||
@@ -11,14 +12,17 @@ namespace ErsatzTV.Core.FFmpeg
|
|||||||
public class FFmpegProcessService
|
public class FFmpegProcessService
|
||||||
{
|
{
|
||||||
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
|
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
|
||||||
|
private readonly IImageCache _imageCache;
|
||||||
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
|
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
|
||||||
|
|
||||||
public FFmpegProcessService(
|
public FFmpegProcessService(
|
||||||
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
|
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
|
||||||
IFFmpegStreamSelector ffmpegStreamSelector)
|
IFFmpegStreamSelector ffmpegStreamSelector,
|
||||||
|
IImageCache imageCache)
|
||||||
{
|
{
|
||||||
_playbackSettingsCalculator = ffmpegPlaybackSettingsService;
|
_playbackSettingsCalculator = ffmpegPlaybackSettingsService;
|
||||||
_ffmpegStreamSelector = ffmpegStreamSelector;
|
_ffmpegStreamSelector = ffmpegStreamSelector;
|
||||||
|
_imageCache = imageCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Process> ForPlayoutItem(
|
public async Task<Process> ForPlayoutItem(
|
||||||
@@ -46,7 +50,11 @@ namespace ErsatzTV.Core.FFmpeg
|
|||||||
.Filter(_ => channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
|
.Filter(_ => channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
|
||||||
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
|
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
|
||||||
.HeadOrNone()
|
.HeadOrNone()
|
||||||
.Map(a => a.Path);
|
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
|
||||||
|
|
||||||
|
bool isAnimated = await maybeWatermarkPath.Match(
|
||||||
|
p => _imageCache.IsAnimated(p),
|
||||||
|
() => Task.FromResult(false));
|
||||||
|
|
||||||
Option<ChannelWatermark> maybeWatermark = channel.Watermark;
|
Option<ChannelWatermark> maybeWatermark = channel.Watermark;
|
||||||
|
|
||||||
@@ -58,7 +66,7 @@ namespace ErsatzTV.Core.FFmpeg
|
|||||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||||
.WithSeek(playbackSettings.StreamSeek)
|
.WithSeek(playbackSettings.StreamSeek)
|
||||||
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec)
|
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec)
|
||||||
.WithWatermark(maybeWatermark, maybeWatermarkPath, channel.FFmpegProfile.Resolution)
|
.WithWatermark(maybeWatermark, maybeWatermarkPath, channel.FFmpegProfile.Resolution, isAnimated)
|
||||||
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
|
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
|
||||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||||
.WithNormalizeLoudness(playbackSettings.NormalizeLoudness);
|
.WithNormalizeLoudness(playbackSettings.NormalizeLoudness);
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ namespace ErsatzTV.Core.Interfaces.Images
|
|||||||
Task<Either<BaseError, string>> SaveArtworkToCache(byte[] imageBuffer, ArtworkKind artworkKind);
|
Task<Either<BaseError, string>> SaveArtworkToCache(byte[] imageBuffer, ArtworkKind artworkKind);
|
||||||
Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind);
|
Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind);
|
||||||
string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight);
|
string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight);
|
||||||
|
Task<bool> IsAnimated(string fileName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ using ErsatzTV.Core.Domain;
|
|||||||
using ErsatzTV.Core.Interfaces.Images;
|
using ErsatzTV.Core.Interfaces.Images;
|
||||||
using ErsatzTV.Core.Interfaces.Metadata;
|
using ErsatzTV.Core.Interfaces.Metadata;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using SixLabors.ImageSharp;
|
using SixLabors.ImageSharp;
|
||||||
using SixLabors.ImageSharp.Formats.Jpeg;
|
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||||
using SixLabors.ImageSharp.Processing;
|
using SixLabors.ImageSharp.Processing;
|
||||||
@@ -18,10 +20,17 @@ namespace ErsatzTV.Infrastructure.Images
|
|||||||
{
|
{
|
||||||
private static readonly SHA1CryptoServiceProvider Crypto;
|
private static readonly SHA1CryptoServiceProvider Crypto;
|
||||||
private readonly ILocalFileSystem _localFileSystem;
|
private readonly ILocalFileSystem _localFileSystem;
|
||||||
|
private readonly ILogger<ImageCache> _logger;
|
||||||
|
private readonly IMemoryCache _memoryCache;
|
||||||
|
|
||||||
static ImageCache() => Crypto = new SHA1CryptoServiceProvider();
|
static ImageCache() => Crypto = new SHA1CryptoServiceProvider();
|
||||||
|
|
||||||
public ImageCache(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem;
|
public ImageCache(ILocalFileSystem localFileSystem, IMemoryCache memoryCache, ILogger<ImageCache> logger)
|
||||||
|
{
|
||||||
|
_localFileSystem = localFileSystem;
|
||||||
|
_memoryCache = memoryCache;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height)
|
public async Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height)
|
||||||
{
|
{
|
||||||
@@ -120,5 +129,28 @@ namespace ErsatzTV.Infrastructure.Images
|
|||||||
|
|
||||||
return Path.Combine(baseFolder, fileName);
|
return Path.Combine(baseFolder, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,20 +248,33 @@
|
|||||||
|
|
||||||
private async Task UploadLogo(InputFileChangeEventArgs e)
|
private async Task UploadLogo(InputFileChangeEventArgs e)
|
||||||
{
|
{
|
||||||
var buffer = new byte[e.File.Size];
|
try
|
||||||
await e.File.OpenReadStream().ReadAsync(buffer);
|
{
|
||||||
Either<BaseError, string> maybeCacheFileName = await _mediator.Send(new SaveArtworkToDisk(buffer, ArtworkKind.Logo));
|
var buffer = new byte[e.File.Size];
|
||||||
maybeCacheFileName.Match(
|
await e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024).ReadAsync(buffer);
|
||||||
relativeFileName =>
|
Either<BaseError, string> maybeCacheFileName = await _mediator.Send(new SaveArtworkToDisk(buffer, ArtworkKind.Logo));
|
||||||
{
|
maybeCacheFileName.Match(
|
||||||
_model.Logo = relativeFileName;
|
relativeFileName =>
|
||||||
StateHasChanged();
|
{
|
||||||
},
|
_model.Logo = relativeFileName;
|
||||||
error =>
|
StateHasChanged();
|
||||||
{
|
},
|
||||||
_snackbar.Add($"Unexpected error saving channel logo: {error.Value}", Severity.Error);
|
error =>
|
||||||
_logger.LogError("Unexpected error saving channel logo: {Error}", error.Value);
|
{
|
||||||
});
|
_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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user