mirror of https://github.com/ErsatzTV/ErsatzTV.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
250 lines
9.1 KiB
250 lines
9.1 KiB
using ErsatzTV.Core.Domain; |
|
using ErsatzTV.Core.FFmpeg; |
|
using ErsatzTV.FFmpeg.State; |
|
using Microsoft.Extensions.Logging; |
|
using NCalc; |
|
using SkiaSharp; |
|
|
|
namespace ErsatzTV.Infrastructure.Streaming.Graphics.Image; |
|
|
|
public class WatermarkElement : GraphicsElement, IDisposable |
|
{ |
|
private readonly ILogger _logger; |
|
private readonly string _imagePath; |
|
private readonly ChannelWatermark _watermark; |
|
private readonly List<SKBitmap> _scaledFrames = []; |
|
private readonly List<int> _frameDelays = []; |
|
|
|
private Option<Expression> _maybeOpacityExpression; |
|
private float _opacity; |
|
private int _animatedDurationMs; |
|
private SKCodec _sourceCodec; |
|
private SKPointI _location; |
|
|
|
public WatermarkElement(WatermarkOptions watermarkOptions, ILogger logger) |
|
{ |
|
_logger = logger; |
|
// TODO: better model coming in here? |
|
foreach (var imagePath in watermarkOptions.ImagePath) |
|
{ |
|
_imagePath = imagePath; |
|
} |
|
|
|
foreach (var watermark in watermarkOptions.Watermark) |
|
{ |
|
_watermark = watermark; |
|
ZIndex = watermark.ZIndex; |
|
} |
|
} |
|
|
|
public bool IsValid => _imagePath != null && _watermark != null; |
|
|
|
public override async Task InitializeAsync(Resolution squarePixelFrameSize, Resolution frameSize, int frameRate, CancellationToken cancellationToken) |
|
{ |
|
try |
|
{ |
|
if (_watermark.Mode is ChannelWatermarkMode.Intermittent) |
|
{ |
|
string expressionString = $@" |
|
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < 1, |
|
(time_of_day_seconds % {_watermark.FrequencyMinutes * 60}), |
|
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < {1 + _watermark.DurationSeconds}, |
|
1, |
|
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < {1 + _watermark.DurationSeconds + 1}, |
|
1 - ((time_of_day_seconds % {_watermark.FrequencyMinutes * 60} - {1 + _watermark.DurationSeconds}) / 1), |
|
0 |
|
) |
|
) |
|
)"; |
|
_maybeOpacityExpression = new Expression(expressionString); |
|
} |
|
else if (_watermark.Mode is ChannelWatermarkMode.OpacityExpression && !string.IsNullOrWhiteSpace(_watermark.OpacityExpression)) |
|
{ |
|
_maybeOpacityExpression = new Expression(_watermark.OpacityExpression); |
|
} |
|
else |
|
{ |
|
_opacity = _watermark.Opacity / 100.0f; |
|
} |
|
|
|
foreach (var expression in _maybeOpacityExpression) |
|
{ |
|
expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction; |
|
} |
|
|
|
Stream imageStream; |
|
bool isRemoteUri = Uri.TryCreate(_imagePath, UriKind.Absolute, out var uriResult) |
|
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); |
|
|
|
if (isRemoteUri) |
|
{ |
|
using var client = new HttpClient(); |
|
imageStream = new MemoryStream(await client.GetByteArrayAsync(uriResult, cancellationToken)); |
|
} |
|
else |
|
{ |
|
imageStream = new FileStream(_imagePath!, FileMode.Open, FileAccess.Read); |
|
} |
|
|
|
_sourceCodec = SKCodec.Create(imageStream); |
|
|
|
int sourceWidth = _sourceCodec.Info.Width; |
|
int sourceHeight = _sourceCodec.Info.Height; |
|
|
|
int scaledWidth = sourceWidth; |
|
int scaledHeight = sourceHeight; |
|
if (_watermark.Size == WatermarkSize.Scaled) |
|
{ |
|
scaledWidth = (int)Math.Round(_watermark.WidthPercent / 100.0 * frameSize.Width); |
|
double aspectRatio = (double)sourceHeight / sourceWidth; |
|
scaledHeight = (int)(scaledWidth * aspectRatio); |
|
} |
|
|
|
(int horizontalMargin, int verticalMargin) = _watermark.PlaceWithinSourceContent |
|
? SourceContentMargins(squarePixelFrameSize, frameSize) |
|
: NormalMargins(frameSize); |
|
|
|
var location = CalculatePosition( |
|
_watermark.Location, |
|
frameSize.Width, |
|
frameSize.Height, |
|
scaledWidth, |
|
scaledHeight, |
|
horizontalMargin, |
|
verticalMargin); |
|
_location = new SKPointI(location.X, location.Y); |
|
|
|
_animatedDurationMs = 0; |
|
|
|
var scaledImageInfo = new SKImageInfo(scaledWidth, scaledHeight, SKColorType.Bgra8888, SKAlphaType.Premul); |
|
|
|
if (_sourceCodec.FrameCount == 0) |
|
{ |
|
// static image |
|
using var frameBitmap = SKBitmap.Decode(_sourceCodec); |
|
if (frameBitmap != null) |
|
{ |
|
var scaledBitmap = new SKBitmap(scaledImageInfo); |
|
frameBitmap.ScalePixels(scaledBitmap, SKSamplingOptions.Default); |
|
_scaledFrames.Add(scaledBitmap); |
|
} |
|
} |
|
else |
|
{ |
|
// animated image |
|
for (var i = 0; i < _sourceCodec.FrameCount; i++) |
|
{ |
|
_sourceCodec.GetFrameInfo(i, out var frameInfo); |
|
int frameDuration = frameInfo.Duration; |
|
if (frameDuration == 0) |
|
{ |
|
frameDuration = 100; |
|
} |
|
|
|
using var frameBitmap = new SKBitmap(_sourceCodec.Info); |
|
var pointer = frameBitmap.GetPixels(); |
|
_sourceCodec.GetPixels(_sourceCodec.Info, pointer, new SKCodecOptions(i)); |
|
|
|
var scaledBitmap = new SKBitmap(scaledImageInfo); |
|
frameBitmap.ScalePixels(scaledBitmap, SKSamplingOptions.Default); |
|
_scaledFrames.Add(scaledBitmap); |
|
|
|
_animatedDurationMs += frameDuration; |
|
_frameDelays.Add(frameDuration); |
|
} |
|
} |
|
|
|
if (_sourceCodec.FrameCount > 0 && _animatedDurationMs == 0) |
|
{ |
|
_animatedDurationMs = int.MaxValue; |
|
} |
|
} |
|
catch (Exception ex) |
|
{ |
|
IsFailed = true; |
|
_logger.LogWarning(ex, "Failed to initialize watermark element; will disable for this content"); |
|
} |
|
} |
|
|
|
public override ValueTask<Option<PreparedElementImage>> PrepareImage( |
|
TimeSpan timeOfDay, |
|
TimeSpan contentTime, |
|
TimeSpan contentTotalTime, |
|
TimeSpan channelTime, |
|
CancellationToken cancellationToken) |
|
{ |
|
float opacity = _opacity; |
|
foreach (var expression in _maybeOpacityExpression) |
|
{ |
|
opacity = OpacityExpressionHelper.GetOpacity( |
|
expression, |
|
timeOfDay, |
|
contentTime, |
|
contentTotalTime, |
|
channelTime); |
|
} |
|
|
|
if (opacity == 0) |
|
{ |
|
return ValueTask.FromResult(Option<PreparedElementImage>.None); |
|
} |
|
|
|
SKBitmap frameForTimestamp = GetFrameForTimestamp(contentTime); |
|
return ValueTask.FromResult(Optional(new PreparedElementImage(frameForTimestamp, _location, opacity, false))); |
|
} |
|
|
|
private SKBitmap GetFrameForTimestamp(TimeSpan timestamp) |
|
{ |
|
if (_scaledFrames.Count <= 1) |
|
{ |
|
return _scaledFrames[0]; |
|
} |
|
|
|
long currentTimeMs = (long)timestamp.TotalMilliseconds % _animatedDurationMs; |
|
|
|
long frameTime = 0; |
|
for (var i = 0; i < _sourceCodec.FrameCount; i++) |
|
{ |
|
frameTime += _frameDelays[i]; |
|
if (currentTimeMs <= frameTime) |
|
{ |
|
return _scaledFrames[i]; |
|
} |
|
} |
|
|
|
return _scaledFrames.Last(); |
|
} |
|
|
|
private WatermarkMargins NormalMargins(Resolution frameSize) |
|
{ |
|
double horizontalMargin = Math.Round(_watermark.HorizontalMarginPercent / 100.0 * frameSize.Width); |
|
double verticalMargin = Math.Round(_watermark.VerticalMarginPercent / 100.0 * frameSize.Height); |
|
|
|
return new WatermarkMargins((int)Math.Round(horizontalMargin), (int)Math.Round(verticalMargin)); |
|
} |
|
|
|
private WatermarkMargins SourceContentMargins(Resolution squarePixelFrameSize, Resolution frameSize) |
|
{ |
|
int horizontalPadding = frameSize.Width - squarePixelFrameSize.Width; |
|
int verticalPadding = frameSize.Height - squarePixelFrameSize.Height; |
|
|
|
double horizontalMargin = Math.Round( |
|
_watermark.HorizontalMarginPercent / 100.0 * squarePixelFrameSize.Width |
|
+ horizontalPadding / 2.0); |
|
double verticalMargin = Math.Round( |
|
_watermark.VerticalMarginPercent / 100.0 * squarePixelFrameSize.Height |
|
+ verticalPadding / 2.0); |
|
|
|
return new WatermarkMargins((int)Math.Round(horizontalMargin), (int)Math.Round(verticalMargin)); |
|
} |
|
|
|
private sealed record WatermarkMargins(int HorizontalMargin, int VerticalMargin); |
|
|
|
public void Dispose() |
|
{ |
|
GC.SuppressFinalize(this); |
|
|
|
_sourceCodec?.Dispose(); |
|
_scaledFrames?.ForEach(f => f.Dispose()); |
|
} |
|
}
|
|
|