mirror of https://github.com/ErsatzTV/ErsatzTV.git
10 changed files with 281 additions and 5 deletions
@ -0,0 +1,56 @@ |
|||||||
|
using ErsatzTV.FFmpeg.State; |
||||||
|
using YamlDotNet.Serialization; |
||||||
|
using YamlDotNet.Serialization.NamingConventions; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Graphics; |
||||||
|
|
||||||
|
public class ImageGraphicsElement |
||||||
|
{ |
||||||
|
public string Image { get; set; } |
||||||
|
|
||||||
|
public int? Opacity { get; set; } |
||||||
|
|
||||||
|
[YamlMember(Alias = "opacity_expression", ApplyNamingConventions = false)] |
||||||
|
public string OpacityExpression { get; set; } |
||||||
|
|
||||||
|
public WatermarkLocation Location { get; set; } |
||||||
|
[YamlMember(Alias = "horizontal_margin_percent", ApplyNamingConventions = false)] |
||||||
|
public double? HorizontalMarginPercent { get; set; } |
||||||
|
[YamlMember(Alias = "vertical_margin_percent", ApplyNamingConventions = false)] |
||||||
|
public double? VerticalMarginPercent { get; set; } |
||||||
|
|
||||||
|
[YamlMember(Alias = "location_x", ApplyNamingConventions = false)] |
||||||
|
public double? LocationX { get; set; } |
||||||
|
[YamlMember(Alias = "location_y", ApplyNamingConventions = false)] |
||||||
|
public double? LocationY { get; set; } |
||||||
|
[YamlMember(Alias = "z_index", ApplyNamingConventions = false)] |
||||||
|
public int? ZIndex { get; set; } |
||||||
|
|
||||||
|
public bool Scale { get; set; } |
||||||
|
[YamlMember(Alias = "scale_width_percent", ApplyNamingConventions = false)] |
||||||
|
public double? ScaleWidthPercent { get; set; } |
||||||
|
|
||||||
|
public static async Task<Option<ImageGraphicsElement>> FromFile(string fileName) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
string yaml = await File.ReadAllTextAsync(fileName); |
||||||
|
|
||||||
|
// TODO: validate schema
|
||||||
|
// if (await yamlScheduleValidator.ValidateSchedule(yaml, isImport) == false)
|
||||||
|
// {
|
||||||
|
// return Option<YamlPlayoutDefinition>.None;
|
||||||
|
// }
|
||||||
|
|
||||||
|
IDeserializer deserializer = new DeserializerBuilder() |
||||||
|
.WithNamingConvention(CamelCaseNamingConvention.Instance) |
||||||
|
.Build(); |
||||||
|
|
||||||
|
return deserializer.Deserialize<ImageGraphicsElement>(yaml); |
||||||
|
} |
||||||
|
catch (Exception) |
||||||
|
{ |
||||||
|
return Option<ImageGraphicsElement>.None; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,160 @@ |
|||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Core.Graphics; |
||||||
|
using Microsoft.Extensions.Logging; |
||||||
|
using NCalc; |
||||||
|
using SixLabors.ImageSharp; |
||||||
|
using SixLabors.ImageSharp.Formats.Gif; |
||||||
|
using SixLabors.ImageSharp.Processing; |
||||||
|
using Image = SixLabors.ImageSharp.Image; |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Streaming; |
||||||
|
|
||||||
|
public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger logger) : IGraphicsElement, IDisposable |
||||||
|
{ |
||||||
|
private readonly List<Image> _scaledFrames = []; |
||||||
|
private readonly List<double> _frameDelays = []; |
||||||
|
|
||||||
|
private Option<Expression> _maybeOpacityExpression; |
||||||
|
private float _opacity; |
||||||
|
private double _animatedDurationSeconds; |
||||||
|
private Image _sourceImage; |
||||||
|
private Point _location; |
||||||
|
|
||||||
|
public int ZIndex { get; private set; } |
||||||
|
|
||||||
|
public bool IsFailed { get; set; } |
||||||
|
|
||||||
|
public async Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
if (!string.IsNullOrWhiteSpace(imageGraphicsElement.OpacityExpression)) |
||||||
|
{ |
||||||
|
var expression = new Expression(imageGraphicsElement.OpacityExpression); |
||||||
|
expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction; |
||||||
|
_maybeOpacityExpression = expression; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
_opacity = (imageGraphicsElement.Opacity ?? 100) / 100.0f; |
||||||
|
} |
||||||
|
|
||||||
|
ZIndex = imageGraphicsElement.ZIndex ?? 0; |
||||||
|
|
||||||
|
foreach (var expression in _maybeOpacityExpression) |
||||||
|
{ |
||||||
|
expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction; |
||||||
|
} |
||||||
|
|
||||||
|
bool isRemoteUri = Uri.TryCreate(imageGraphicsElement.Image, UriKind.Absolute, out var uriResult) |
||||||
|
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); |
||||||
|
|
||||||
|
if (isRemoteUri) |
||||||
|
{ |
||||||
|
using var client = new HttpClient(); |
||||||
|
await using Stream imageStream = await client.GetStreamAsync(uriResult, cancellationToken); |
||||||
|
_sourceImage = await Image.LoadAsync(imageStream, cancellationToken); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
_sourceImage = await Image.LoadAsync(imageGraphicsElement.Image!, cancellationToken); |
||||||
|
} |
||||||
|
|
||||||
|
int scaledWidth = _sourceImage.Width; |
||||||
|
int scaledHeight = _sourceImage.Height; |
||||||
|
if (imageGraphicsElement.Scale) |
||||||
|
{ |
||||||
|
scaledWidth = (int)Math.Round((imageGraphicsElement.ScaleWidthPercent ?? 100) / 100.0 * frameSize.Width); |
||||||
|
double aspectRatio = (double)_sourceImage.Height / _sourceImage.Width; |
||||||
|
scaledHeight = (int)(scaledWidth * aspectRatio); |
||||||
|
} |
||||||
|
|
||||||
|
int horizontalMargin = (int)Math.Round((imageGraphicsElement.HorizontalMarginPercent ?? 0) / 100.0 * frameSize.Width); |
||||||
|
int verticalMargin = (int)Math.Round((imageGraphicsElement.VerticalMarginPercent ?? 0) / 100.0 * frameSize.Height); |
||||||
|
|
||||||
|
_location = WatermarkElement.CalculatePosition( |
||||||
|
imageGraphicsElement.Location, |
||||||
|
frameSize.Width, |
||||||
|
frameSize.Height, |
||||||
|
scaledWidth, |
||||||
|
scaledHeight, |
||||||
|
horizontalMargin, |
||||||
|
verticalMargin); |
||||||
|
|
||||||
|
_animatedDurationSeconds = 0; |
||||||
|
|
||||||
|
for (int i = 0; i < _sourceImage.Frames.Count; i++) |
||||||
|
{ |
||||||
|
var frame = _sourceImage.Frames.CloneFrame(i); |
||||||
|
frame.Mutate(ctx => ctx.Resize(scaledWidth, scaledHeight)); |
||||||
|
_scaledFrames.Add(frame); |
||||||
|
|
||||||
|
var frameDelay = _sourceImage.Frames[i].Metadata.GetFormatMetadata(GifFormat.Instance).FrameDelay / 100.0; |
||||||
|
_animatedDurationSeconds += frameDelay; |
||||||
|
_frameDelays.Add(frameDelay); |
||||||
|
} |
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
IsFailed = true; |
||||||
|
logger.LogWarning(ex, "Failed to initialize image element; will disable for this content"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public 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); |
||||||
|
} |
||||||
|
|
||||||
|
Image frameForTimestamp = GetFrameForTimestamp(contentTime); |
||||||
|
return ValueTask.FromResult(Optional(new PreparedElementImage(frameForTimestamp, _location, opacity, false))); |
||||||
|
} |
||||||
|
|
||||||
|
private Image GetFrameForTimestamp(TimeSpan timestamp) |
||||||
|
{ |
||||||
|
if (_scaledFrames.Count <= 1) |
||||||
|
{ |
||||||
|
return _scaledFrames[0]; |
||||||
|
} |
||||||
|
|
||||||
|
double currentTime = timestamp.TotalSeconds % _animatedDurationSeconds; |
||||||
|
|
||||||
|
double frameTime = 0; |
||||||
|
for (int i = 0; i < _sourceImage.Frames.Count; i++) |
||||||
|
{ |
||||||
|
frameTime += _frameDelays[i]; |
||||||
|
if (currentTime <= frameTime) |
||||||
|
{ |
||||||
|
return _scaledFrames[i]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return _scaledFrames.Last(); |
||||||
|
} |
||||||
|
|
||||||
|
public void Dispose() |
||||||
|
{ |
||||||
|
GC.SuppressFinalize(this); |
||||||
|
|
||||||
|
_sourceImage?.Dispose(); |
||||||
|
_scaledFrames?.ForEach(f => f.Dispose()); |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue