Browse Source

convert graphics engine from imagesharp to skiasharp (#2319)

* use skiasharp in graphics engine

* start to use richtextkit

* move out some template functions

* move files

* add base graphics element

* use default style in text element

* support partial styling in text element

* fix static images

* load fonts from text element definition
pull/2321/head
Jason Dove 12 months ago committed by GitHub
parent
commit
a6b01cbe28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 26
      ErsatzTV.Core/Graphics/StyleDefinition.cs
  2. 18
      ErsatzTV.Core/Graphics/TextGraphicsElement.cs
  3. 2
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  4. 48
      ErsatzTV.Infrastructure/Streaming/Graphics/Fonts/CustomFontMapper.cs
  5. 31
      ErsatzTV.Infrastructure/Streaming/Graphics/Fonts/GraphicsEngineFonts.cs
  6. 46
      ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElement.cs
  7. 68
      ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs
  8. 2
      ErsatzTV.Infrastructure/Streaming/Graphics/IGraphicsElement.cs
  9. 110
      ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElement.cs
  10. 139
      ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs
  11. 2
      ErsatzTV.Infrastructure/Streaming/Graphics/OpacityExpressionHelper.cs
  12. 5
      ErsatzTV.Infrastructure/Streaming/Graphics/PreparedElementImage.cs
  13. 37
      ErsatzTV.Infrastructure/Streaming/Graphics/Text/TemplateFunctions.cs
  14. 235
      ErsatzTV.Infrastructure/Streaming/Graphics/Text/TextElement.cs
  15. 57
      ErsatzTV.Infrastructure/Streaming/GraphicsEngineFonts.cs
  16. 6
      ErsatzTV.Infrastructure/Streaming/PreparedElementImage.cs
  17. 163
      ErsatzTV.Infrastructure/Streaming/TextElement.cs
  18. 6
      ErsatzTV/Startup.cs

26
ErsatzTV.Core/Graphics/StyleDefinition.cs

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
using YamlDotNet.Serialization;
namespace ErsatzTV.Core.Graphics;
public class StyleDefinition
{
public string Name { get; set; }
[YamlMember(Alias = "font_size", ApplyNamingConventions = false)]
public float? FontSize { get; set; }
[YamlMember(Alias = "font_weight", ApplyNamingConventions = false)]
public int? FontWeight { get; set; }
[YamlMember(Alias = "font_italic", ApplyNamingConventions = false)]
public bool? FontItalic { get; set; }
[YamlMember(Alias = "font_family", ApplyNamingConventions = false)]
public string FontFamily { get; set; }
[YamlMember(Alias = "text_color", ApplyNamingConventions = false)]
public string TextColor { get; set; }
[YamlMember(Alias = "letter_spacing", ApplyNamingConventions = false)]
public float? LetterSpacing { get; set; }
}

18
ErsatzTV.Core/Graphics/TextGraphicsElement.cs

@ -13,26 +13,32 @@ public class TextGraphicsElement @@ -13,26 +13,32 @@ public class TextGraphicsElement
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 = "horizontal_alignment", ApplyNamingConventions = false)]
public string HorizontalAlignment { 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; }
[YamlMember(Alias = "font_family", ApplyNamingConventions = false)]
public string FontFamily { get; set; }
[YamlMember(Alias = "font_size", ApplyNamingConventions = false)]
public int? FontSize { get; set; }
[YamlMember(Alias = "font_color", ApplyNamingConventions = false)]
public string FontColor { get; set; }
public List<StyleDefinition> Styles { get; set; } = [];
[YamlMember(Alias = "base_style", ApplyNamingConventions = false)]
public string BaseStyle { get; set; }
[YamlMember(Alias = "include_fonts_from", ApplyNamingConventions = false)]
public string IncludeFontsFrom { get; set; }
[YamlMember(Alias = "epg_entries", ApplyNamingConventions = false)]
public int EpgEntries { get; set; }

2
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -33,8 +33,10 @@ @@ -33,8 +33,10 @@
<PackageReference Include="Refit" Version="8.0.0" />
<PackageReference Include="Refit.Newtonsoft.Json" Version="8.0.0" />
<PackageReference Include="Refit.Xml" Version="8.0.0" />
<PackageReference Include="RichTextKit.Stbear" Version="0.4.167.3" />
<PackageReference Include="Scriban.Signed" Version="6.2.1" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
<PackageReference Include="SkiaSharp" Version="3.119.0" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />

48
ErsatzTV.Infrastructure/Streaming/Graphics/Fonts/CustomFontMapper.cs

@ -0,0 +1,48 @@ @@ -0,0 +1,48 @@
using Microsoft.Extensions.Logging;
using SkiaSharp;
using Topten.RichTextKit;
namespace ErsatzTV.Infrastructure.Streaming.Graphics.Fonts;
public sealed class CustomFontMapper(ILogger<CustomFontMapper> logger) : FontMapper
{
private readonly Dictionary<string, List<SKTypeface>> _customFonts = new();
public void LoadPrivateFont(Stream stream, string familyName)
{
var typeface = SKTypeface.FromStream(stream) ??
throw new ArgumentException("Cannot load font from stream", nameof(stream));
var qualifiedName = familyName ?? typeface.FamilyName;
if (typeface.FontSlant != SKFontStyleSlant.Upright)
{
qualifiedName += "-Italic";
}
if (!_customFonts.TryGetValue(qualifiedName, out var listFonts))
{
listFonts = [];
_customFonts[qualifiedName] = listFonts;
}
listFonts.Add(typeface);
}
public override SKTypeface TypefaceFromStyle(IStyle style, bool ignoreFontVariants)
{
var qualifiedName = style.FontFamily;
if (style.FontItalic)
{
qualifiedName += "-Italic";
}
if (_customFonts.TryGetValue(qualifiedName, out List<SKTypeface> listFonts) && listFonts.Count != 0)
{
return listFonts.MinBy(font => Math.Abs(font.FontWeight - style.FontWeight))!;
}
logger.LogWarning("Could not find font {Name}; using default", qualifiedName);
return base.TypefaceFromStyle(style, ignoreFontVariants);
}
}

31
ErsatzTV.Infrastructure/Streaming/Graphics/Fonts/GraphicsEngineFonts.cs

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
using Topten.RichTextKit;
namespace ErsatzTV.Infrastructure.Streaming.Graphics.Fonts;
public class GraphicsEngineFonts(CustomFontMapper mapper)
{
private static readonly System.Collections.Generic.HashSet<string> LoadedFontFiles
= new(StringComparer.OrdinalIgnoreCase);
public void LoadFonts(string fontsFolder)
{
foreach (var file in Directory.EnumerateFiles(fontsFolder, "*.*", SearchOption.AllDirectories))
{
if (!file.EndsWith(".ttf", StringComparison.OrdinalIgnoreCase) &&
!file.EndsWith(".otf", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!LoadedFontFiles.Add(file))
{
continue;
}
using var stream = File.OpenRead(file);
mapper.LoadPrivateFont(stream, null);
}
}
public FontMapper Mapper => mapper;
}

46
ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElement.cs

@ -0,0 +1,46 @@ @@ -0,0 +1,46 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
using SkiaSharp;
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
public abstract class GraphicsElement : IGraphicsElement
{
public int ZIndex { get; protected set; }
public bool IsFailed { get; set; }
public abstract Task InitializeAsync(Resolution squarePixelFrameSize, Resolution frameSize, int frameRate,
CancellationToken cancellationToken);
public abstract ValueTask<Option<PreparedElementImage>> PrepareImage(TimeSpan timeOfDay, TimeSpan contentTime, TimeSpan contentTotalTime, TimeSpan channelTime,
CancellationToken cancellationToken);
protected static SKPointI CalculatePosition(
WatermarkLocation location,
int frameWidth,
int frameHeight,
int imageWidth,
int imageHeight,
int horizontalMargin,
int verticalMargin)
{
return location switch
{
WatermarkLocation.BottomLeft => new SKPointI(horizontalMargin, frameHeight - imageHeight - verticalMargin),
WatermarkLocation.TopLeft => new SKPointI(horizontalMargin, verticalMargin),
WatermarkLocation.TopRight => new SKPointI(frameWidth - imageWidth - horizontalMargin, verticalMargin),
WatermarkLocation.TopMiddle => new SKPointI((frameWidth - imageWidth) / 2, verticalMargin),
WatermarkLocation.RightMiddle => new SKPointI(
frameWidth - imageWidth - horizontalMargin,
(frameHeight - imageHeight) / 2),
WatermarkLocation.BottomMiddle => new SKPointI(
(frameWidth - imageWidth) / 2,
frameHeight - imageHeight - verticalMargin),
WatermarkLocation.LeftMiddle => new SKPointI(horizontalMargin, (frameHeight - imageHeight) / 2),
_ => new SKPointI(
frameWidth - imageWidth - horizontalMargin,
frameHeight - imageHeight - verticalMargin),
};
}
}

68
ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs → ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs

@ -3,18 +3,24 @@ using ErsatzTV.Core; @@ -3,18 +3,24 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Infrastructure.Streaming.Graphics.Fonts;
using ErsatzTV.Infrastructure.Streaming.Graphics.Image;
using ErsatzTV.Infrastructure.Streaming.Graphics.Text;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SkiaSharp;
namespace ErsatzTV.Infrastructure.Streaming;
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILogger<GraphicsEngine> logger) : IGraphicsEngine
public class GraphicsEngine(
TemplateFunctions templateFunctions,
GraphicsEngineFonts graphicsEngineFonts,
ITemplateDataRepository templateDataRepository,
ILogger<GraphicsEngine> logger)
: IGraphicsEngine
{
public async Task Run(GraphicsEngineContext context, PipeWriter pipeWriter, CancellationToken cancellationToken)
{
GraphicsEngineFonts.LoadFonts(FileSystemLayout.FontsCacheFolder);
graphicsEngineFonts.LoadFonts(FileSystemLayout.FontsCacheFolder);
var templateVariables = new Dictionary<string, object>();
@ -61,11 +67,12 @@ public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILog @@ -61,11 +67,12 @@ public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILog
{
elements.Add(watermark);
}
break;
case ImageElementContext imageElementContext:
elements.Add(new ImageElement(imageElementContext.ImageElement, logger));
break;
case TextElementContext textElementContext:
var variables = templateVariables.ToDictionary();
foreach (var variable in textElementContext.Variables)
@ -73,7 +80,14 @@ public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILog @@ -73,7 +80,14 @@ public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILog
variables.Add(variable.Key, variable.Value);
}
elements.Add(new TextElement(textElementContext.TextElement, variables, logger));
var textElement = new TextElement(
templateFunctions,
graphicsEngineFonts,
textElementContext.TextElement,
variables,
logger);
elements.Add(textElement);
break;
}
}
@ -85,6 +99,12 @@ public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILog @@ -85,6 +99,12 @@ public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILog
long frameCount = 0;
var totalFrames = (long)(context.Duration.TotalSeconds * context.FrameRate);
using var outputBitmap = new SKBitmap(
context.FrameSize.Width,
context.FrameSize.Height,
SKColorType.Bgra8888,
SKAlphaType.Premul);
try
{
// `content_total_seconds` - the total number of seconds in the content
@ -105,10 +125,8 @@ public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILog @@ -105,10 +125,8 @@ public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILog
// `channel_seconds` - the total number of seconds the frame is from when the channel started/activated
var channelTime = frameTime - context.ChannelStartTime;
using var outputFrame = new Image<Bgra32>(
context.FrameSize.Width,
context.FrameSize.Height,
Color.Transparent);
using var canvas = new SKCanvas(outputBitmap);
canvas.Clear(SKColors.Transparent);
// prepare images outside mutate to allow async image generation
var preparedElementImages = new List<PreparedElementImage>();
@ -135,22 +153,26 @@ public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILog @@ -135,22 +153,26 @@ public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILog
}
// draw each element
outputFrame.Mutate(ctx =>
foreach (var preparedImage in preparedElementImages)
{
foreach (var preparedImage in preparedElementImages)
using var paint = new SKPaint();
paint.Color = new SKColor(255, 255, 255, (byte)(preparedImage.Opacity * 255));
canvas.DrawBitmap(preparedImage.Image, new SKPoint(preparedImage.Point.X, preparedImage.Point.Y), paint);
if (preparedImage.Dispose)
{
ctx.DrawImage(preparedImage.Image, preparedImage.Point, preparedImage.Opacity);
if (preparedImage.Dispose)
{
preparedImage.Image.Dispose();
}
preparedImage.Image.Dispose();
}
});
}
// pipe output
int frameBufferSize = context.FrameSize.Width * context.FrameSize.Height * 4;
Memory<byte> memory = pipeWriter.GetMemory(frameBufferSize);
outputFrame.CopyPixelDataTo(memory.Span);
using (SKPixmap pixmap = outputBitmap.PeekPixels())
{
var memory = pipeWriter.GetMemory(frameBufferSize);
pixmap.GetPixelSpan().CopyTo(memory.Span);
}
pipeWriter.Advance(frameBufferSize);
await pipeWriter.FlushAsync(cancellationToken);
@ -171,4 +193,4 @@ public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILog @@ -171,4 +193,4 @@ public class GraphicsEngine(ITemplateDataRepository templateDataRepository, ILog
}
}
}
}
}

2
ErsatzTV.Infrastructure/Streaming/IGraphicsElement.cs → ErsatzTV.Infrastructure/Streaming/Graphics/IGraphicsElement.cs

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Infrastructure.Streaming;
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
public interface IGraphicsElement
{

110
ErsatzTV.Infrastructure/Streaming/ImageElement.cs → ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElement.cs

@ -2,29 +2,22 @@ using ErsatzTV.Core.Domain; @@ -2,29 +2,22 @@ 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;
using SkiaSharp;
namespace ErsatzTV.Infrastructure.Streaming;
namespace ErsatzTV.Infrastructure.Streaming.Graphics.Image;
public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger logger) : IGraphicsElement, IDisposable
public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger logger) : GraphicsElement, IDisposable
{
private readonly List<Image> _scaledFrames = [];
private readonly List<double> _frameDelays = [];
private readonly List<SKBitmap> _scaledFrames = [];
private readonly List<int> _frameDelays = [];
private Option<Expression> _maybeOpacityExpression;
private float _opacity;
private double _animatedDurationSeconds;
private Image _sourceImage;
private Point _location;
private int _animatedDurationMs;
private SKCodec _sourceCodec;
private SKPointI _location;
public int ZIndex { get; private set; }
public bool IsFailed { get; set; }
public async Task InitializeAsync(
public override async Task InitializeAsync(
Resolution squarePixelFrameSize,
Resolution frameSize,
int frameRate,
@ -50,33 +43,38 @@ public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger log @@ -50,33 +43,38 @@ public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger log
expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction;
}
Stream imageStream;
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);
imageStream = new MemoryStream(await client.GetByteArrayAsync(uriResult, cancellationToken));
}
else
{
_sourceImage = await Image.LoadAsync(imageGraphicsElement.Image!, cancellationToken);
imageStream = new FileStream(imageGraphicsElement.Image!, FileMode.Open, FileAccess.Read);
}
int scaledWidth = _sourceImage.Width;
int scaledHeight = _sourceImage.Height;
_sourceCodec = SKCodec.Create(imageStream);
int sourceWidth = _sourceCodec.Info.Width;
int sourceHeight = _sourceCodec.Info.Height;
int scaledWidth = sourceWidth;
int scaledHeight = sourceHeight;
if (imageGraphicsElement.Scale)
{
scaledWidth = (int)Math.Round((imageGraphicsElement.ScaleWidthPercent ?? 100) / 100.0 * frameSize.Width);
double aspectRatio = (double)_sourceImage.Height / _sourceImage.Width;
double aspectRatio = (double)sourceHeight / sourceWidth;
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(
_location = CalculatePosition(
imageGraphicsElement.Location,
frameSize.Width,
frameSize.Height,
@ -85,17 +83,49 @@ public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger log @@ -85,17 +83,49 @@ public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger log
horizontalMargin,
verticalMargin);
_animatedDurationSeconds = 0;
_animatedDurationMs = 0;
var scaledImageInfo = new SKImageInfo(scaledWidth, scaledHeight, SKColorType.Bgra8888, SKAlphaType.Premul);
for (int i = 0; i < _sourceImage.Frames.Count; i++)
if (_sourceCodec.FrameCount == 0)
{
var frame = _sourceImage.Frames.CloneFrame(i);
frame.Mutate(ctx => ctx.Resize(scaledWidth, scaledHeight));
_scaledFrames.Add(frame);
// 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);
}
}
var frameDelay = _sourceImage.Frames[i].Metadata.GetFormatMetadata(GifFormat.Instance).FrameDelay / 100.0;
_animatedDurationSeconds += frameDelay;
_frameDelays.Add(frameDelay);
if (_sourceCodec.FrameCount > 0 && _animatedDurationMs == 0)
{
_animatedDurationMs = int.MaxValue;
}
}
catch (Exception ex)
@ -105,7 +135,7 @@ public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger log @@ -105,7 +135,7 @@ public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger log
}
}
public ValueTask<Option<PreparedElementImage>> PrepareImage(
public override ValueTask<Option<PreparedElementImage>> PrepareImage(
TimeSpan timeOfDay,
TimeSpan contentTime,
TimeSpan contentTotalTime,
@ -128,24 +158,24 @@ public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger log @@ -128,24 +158,24 @@ public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger log
return ValueTask.FromResult(Option<PreparedElementImage>.None);
}
Image frameForTimestamp = GetFrameForTimestamp(contentTime);
SKBitmap frameForTimestamp = GetFrameForTimestamp(contentTime);
return ValueTask.FromResult(Optional(new PreparedElementImage(frameForTimestamp, _location, opacity, false)));
}
private Image GetFrameForTimestamp(TimeSpan timestamp)
private SKBitmap GetFrameForTimestamp(TimeSpan timestamp)
{
if (_scaledFrames.Count <= 1)
{
return _scaledFrames[0];
}
double currentTime = timestamp.TotalSeconds % _animatedDurationSeconds;
long currentTimeMs = (long)timestamp.TotalMilliseconds % _animatedDurationMs;
double frameTime = 0;
for (int i = 0; i < _sourceImage.Frames.Count; i++)
long frameTime = 0;
for (var i = 0; i < _sourceCodec.FrameCount; i++)
{
frameTime += _frameDelays[i];
if (currentTime <= frameTime)
if (currentTimeMs <= frameTime)
{
return _scaledFrames[i];
}
@ -158,7 +188,7 @@ public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger log @@ -158,7 +188,7 @@ public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger log
{
GC.SuppressFinalize(this);
_sourceImage?.Dispose();
_sourceCodec?.Dispose();
_scaledFrames?.ForEach(f => f.Dispose());
}
}
}

139
ErsatzTV.Infrastructure/Streaming/WatermarkElement.cs → ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs

@ -3,26 +3,23 @@ using ErsatzTV.Core.FFmpeg; @@ -3,26 +3,23 @@ using ErsatzTV.Core.FFmpeg;
using ErsatzTV.FFmpeg.State;
using Microsoft.Extensions.Logging;
using NCalc;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Gif;
using SixLabors.ImageSharp.Processing;
using Image = SixLabors.ImageSharp.Image;
using SkiaSharp;
namespace ErsatzTV.Infrastructure.Streaming;
namespace ErsatzTV.Infrastructure.Streaming.Graphics.Image;
public class WatermarkElement : IGraphicsElement, IDisposable
public class WatermarkElement : GraphicsElement, IDisposable
{
private readonly ILogger _logger;
private readonly string _imagePath;
private readonly ChannelWatermark _watermark;
private readonly List<Image> _scaledFrames = [];
private readonly List<double> _frameDelays = [];
private readonly List<SKBitmap> _scaledFrames = [];
private readonly List<int> _frameDelays = [];
private Option<Expression> _maybeOpacityExpression;
private float _opacity;
private double _animatedDurationSeconds;
private Image _sourceImage;
private Point _location;
private int _animatedDurationMs;
private SKCodec _sourceCodec;
private SKPointI _location;
public WatermarkElement(WatermarkOptions watermarkOptions, ILogger logger)
{
@ -42,11 +39,7 @@ public class WatermarkElement : IGraphicsElement, IDisposable @@ -42,11 +39,7 @@ public class WatermarkElement : IGraphicsElement, IDisposable
public bool IsValid => _imagePath != null && _watermark != null;
public int ZIndex { get; }
public bool IsFailed { get; set; }
public async Task InitializeAsync(Resolution squarePixelFrameSize, Resolution frameSize, int frameRate, CancellationToken cancellationToken)
public override async Task InitializeAsync(Resolution squarePixelFrameSize, Resolution frameSize, int frameRate, CancellationToken cancellationToken)
{
try
{
@ -79,26 +72,31 @@ public class WatermarkElement : IGraphicsElement, IDisposable @@ -79,26 +72,31 @@ public class WatermarkElement : IGraphicsElement, IDisposable
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();
await using Stream imageStream = await client.GetStreamAsync(uriResult, cancellationToken);
_sourceImage = await Image.LoadAsync(imageStream, cancellationToken);
imageStream = new MemoryStream(await client.GetByteArrayAsync(uriResult, cancellationToken));
}
else
{
_sourceImage = await Image.LoadAsync(_imagePath!, cancellationToken);
imageStream = new FileStream(_imagePath!, FileMode.Open, FileAccess.Read);
}
int scaledWidth = _sourceImage.Width;
int scaledHeight = _sourceImage.Height;
_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)_sourceImage.Height / _sourceImage.Width;
double aspectRatio = (double)sourceHeight / sourceWidth;
scaledHeight = (int)(scaledWidth * aspectRatio);
}
@ -106,7 +104,7 @@ public class WatermarkElement : IGraphicsElement, IDisposable @@ -106,7 +104,7 @@ public class WatermarkElement : IGraphicsElement, IDisposable
? SourceContentMargins(squarePixelFrameSize, frameSize)
: NormalMargins(frameSize);
_location = CalculatePosition(
var location = CalculatePosition(
_watermark.Location,
frameSize.Width,
frameSize.Height,
@ -114,18 +112,51 @@ public class WatermarkElement : IGraphicsElement, IDisposable @@ -114,18 +112,51 @@ public class WatermarkElement : IGraphicsElement, IDisposable
scaledHeight,
horizontalMargin,
verticalMargin);
_location = new SKPointI(location.X, location.Y);
_animatedDurationSeconds = 0;
_animatedDurationMs = 0;
for (int i = 0; i < _sourceImage.Frames.Count; i++)
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
{
var frame = _sourceImage.Frames.CloneFrame(i);
frame.Mutate(ctx => ctx.Resize(scaledWidth, scaledHeight));
_scaledFrames.Add(frame);
// 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);
}
}
var frameDelay = _sourceImage.Frames[i].Metadata.GetFormatMetadata(GifFormat.Instance).FrameDelay / 100.0;
_animatedDurationSeconds += frameDelay;
_frameDelays.Add(frameDelay);
if (_sourceCodec.FrameCount > 0 && _animatedDurationMs == 0)
{
_animatedDurationMs = int.MaxValue;
}
}
catch (Exception ex)
@ -135,7 +166,7 @@ public class WatermarkElement : IGraphicsElement, IDisposable @@ -135,7 +166,7 @@ public class WatermarkElement : IGraphicsElement, IDisposable
}
}
public ValueTask<Option<PreparedElementImage>> PrepareImage(
public override ValueTask<Option<PreparedElementImage>> PrepareImage(
TimeSpan timeOfDay,
TimeSpan contentTime,
TimeSpan contentTotalTime,
@ -158,24 +189,24 @@ public class WatermarkElement : IGraphicsElement, IDisposable @@ -158,24 +189,24 @@ public class WatermarkElement : IGraphicsElement, IDisposable
return ValueTask.FromResult(Option<PreparedElementImage>.None);
}
Image frameForTimestamp = GetFrameForTimestamp(contentTime);
SKBitmap frameForTimestamp = GetFrameForTimestamp(contentTime);
return ValueTask.FromResult(Optional(new PreparedElementImage(frameForTimestamp, _location, opacity, false)));
}
private Image GetFrameForTimestamp(TimeSpan timestamp)
private SKBitmap GetFrameForTimestamp(TimeSpan timestamp)
{
if (_scaledFrames.Count <= 1)
{
return _scaledFrames[0];
}
double currentTime = timestamp.TotalSeconds % _animatedDurationSeconds;
long currentTimeMs = (long)timestamp.TotalMilliseconds % _animatedDurationMs;
double frameTime = 0;
for (int i = 0; i < _sourceImage.Frames.Count; i++)
long frameTime = 0;
for (var i = 0; i < _sourceCodec.FrameCount; i++)
{
frameTime += _frameDelays[i];
if (currentTime <= frameTime)
if (currentTimeMs <= frameTime)
{
return _scaledFrames[i];
}
@ -184,34 +215,6 @@ public class WatermarkElement : IGraphicsElement, IDisposable @@ -184,34 +215,6 @@ public class WatermarkElement : IGraphicsElement, IDisposable
return _scaledFrames.Last();
}
internal static Point CalculatePosition(
WatermarkLocation location,
int frameWidth,
int frameHeight,
int imageWidth,
int imageHeight,
int horizontalMargin,
int verticalMargin)
{
return location switch
{
WatermarkLocation.BottomLeft => new Point(horizontalMargin, frameHeight - imageHeight - verticalMargin),
WatermarkLocation.TopLeft => new Point(horizontalMargin, verticalMargin),
WatermarkLocation.TopRight => new Point(frameWidth - imageWidth - horizontalMargin, verticalMargin),
WatermarkLocation.TopMiddle => new Point((frameWidth - imageWidth) / 2, verticalMargin),
WatermarkLocation.RightMiddle => new Point(
frameWidth - imageWidth - horizontalMargin,
(frameHeight - imageHeight) / 2),
WatermarkLocation.BottomMiddle => new Point(
(frameWidth - imageWidth) / 2,
frameHeight - imageHeight - verticalMargin),
WatermarkLocation.LeftMiddle => new Point(horizontalMargin, (frameHeight - imageHeight) / 2),
_ => new Point(
frameWidth - imageWidth - horizontalMargin,
frameHeight - imageHeight - verticalMargin),
};
}
private WatermarkMargins NormalMargins(Resolution frameSize)
{
double horizontalMargin = Math.Round(_watermark.HorizontalMarginPercent / 100.0 * frameSize.Width);
@ -241,7 +244,7 @@ public class WatermarkElement : IGraphicsElement, IDisposable @@ -241,7 +244,7 @@ public class WatermarkElement : IGraphicsElement, IDisposable
{
GC.SuppressFinalize(this);
_sourceImage?.Dispose();
_sourceCodec?.Dispose();
_scaledFrames?.ForEach(f => f.Dispose());
}
}
}

2
ErsatzTV.Infrastructure/Streaming/OpacityExpressionHelper.cs → ErsatzTV.Infrastructure/Streaming/Graphics/OpacityExpressionHelper.cs

@ -2,7 +2,7 @@ using System.Globalization; @@ -2,7 +2,7 @@ using System.Globalization;
using NCalc;
using NCalc.Handlers;
namespace ErsatzTV.Infrastructure.Streaming;
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
public static class OpacityExpressionHelper
{

5
ErsatzTV.Infrastructure/Streaming/Graphics/PreparedElementImage.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using SkiaSharp;
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
public record PreparedElementImage(SKBitmap Image, SKPointI Point, float Opacity, bool Dispose);

37
ErsatzTV.Infrastructure/Streaming/Graphics/Text/TemplateFunctions.cs

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using TimeZoneConverter;
namespace ErsatzTV.Infrastructure.Streaming.Graphics.Text;
public class TemplateFunctions(ILogger<TemplateFunctions> logger)
{
public DateTimeOffset ConvertTimeZone(DateTimeOffset dateTimeOffset, string timeZoneId)
{
try
{
var tz = TZConvert.GetTimeZoneInfo(timeZoneId);
return TimeZoneInfo.ConvertTime(dateTimeOffset, tz);
}
catch (TimeZoneNotFoundException ex)
{
logger.LogWarning(ex, "Exception finding specified time zone; resulting time will be unchanged");
return dateTimeOffset;
}
}
public string FormatDateTime(DateTimeOffset dateTimeOffset, string timeZoneId, string format)
{
try
{
var tz = TZConvert.GetTimeZoneInfo(timeZoneId);
dateTimeOffset = TimeZoneInfo.ConvertTime(dateTimeOffset, tz);
}
catch (TimeZoneNotFoundException ex)
{
logger.LogWarning(ex, "Exception finding specified time zone; resulting time will be unchanged");
}
return dateTimeOffset.ToString(format, CultureInfo.CurrentCulture);
}
}

235
ErsatzTV.Infrastructure/Streaming/Graphics/Text/TextElement.cs

@ -0,0 +1,235 @@ @@ -0,0 +1,235 @@
using System.Text.RegularExpressions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Infrastructure.Streaming.Graphics.Fonts;
using Microsoft.Extensions.Logging;
using NCalc;
using Topten.RichTextKit;
using Scriban;
using Scriban.Runtime;
using SkiaSharp;
using RichTextKit=Topten.RichTextKit;
namespace ErsatzTV.Infrastructure.Streaming.Graphics.Text;
public partial class TextElement(
TemplateFunctions templateFunctions,
GraphicsEngineFonts graphicsEngineFonts,
TextGraphicsElement textElement,
Dictionary<string, object> variables,
ILogger logger)
: GraphicsElement, IDisposable
{
private static readonly Regex StylePattern = StyleRegex();
private Option<Expression> _maybeOpacityExpression;
private float _opacity;
private SKBitmap _image;
private SKPointI _location;
public override async Task InitializeAsync(
Resolution squarePixelFrameSize,
Resolution frameSize,
int frameRate,
CancellationToken cancellationToken)
{
try
{
if (!string.IsNullOrWhiteSpace(textElement.OpacityExpression))
{
var expression = new Expression(textElement.OpacityExpression);
expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction;
_maybeOpacityExpression = expression;
}
else
{
_opacity = (textElement.OpacityPercent ?? 100) / 100.0f;
}
ZIndex = textElement.ZIndex ?? 0;
if (!string.IsNullOrWhiteSpace(textElement.IncludeFontsFrom))
{
if (Directory.Exists(textElement.IncludeFontsFrom))
{
graphicsEngineFonts.LoadFonts(textElement.IncludeFontsFrom);
}
else
{
logger.LogWarning(
"include_fonts_from path {Directory} does not exist",
textElement.IncludeFontsFrom);
}
}
var scriptObject = new ScriptObject();
scriptObject.Import(variables, renamer: member => member.Name);
scriptObject.Import("convert_timezone", templateFunctions.ConvertTimeZone);
scriptObject.Import("format_datetime", templateFunctions.FormatDateTime);
var context = new TemplateContext { MemberRenamer = member => member.Name };
context.PushGlobal(scriptObject);
string textToRender = await Template.Parse(textElement.Text).RenderAsync(context);
var textBlock = BuildTextBlock(textToRender);
_image = new SKBitmap((int)Math.Ceiling(textBlock.MeasuredWidth), (int)Math.Ceiling(textBlock.MeasuredHeight));
using (var canvas = new SKCanvas(_image))
{
canvas.Clear(SKColors.Transparent);
textBlock.Paint(canvas, new SKPoint(0, 0));
}
int horizontalMargin =
(int)Math.Round((textElement.HorizontalMarginPercent ?? 0) / 100.0 * frameSize.Width);
int verticalMargin = (int)Math.Round((textElement.VerticalMarginPercent ?? 0) / 100.0 * frameSize.Height);
_location = CalculatePosition(
textElement.Location,
frameSize.Width,
frameSize.Height,
_image.Width,
_image.Height,
horizontalMargin,
verticalMargin);
}
catch (Exception ex)
{
IsFailed = true;
logger.LogWarning(ex, "Failed to initialize text 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);
}
return opacity == 0
? ValueTask.FromResult(Option<PreparedElementImage>.None)
: new ValueTask<Option<PreparedElementImage>>(new PreparedElementImage(_image, _location, opacity, false));
}
public void Dispose()
{
GC.SuppressFinalize(this);
_image?.Dispose();
_image = null;
}
private TextBlock BuildTextBlock(string textToRender)
{
var textBlock = new TextBlock { FontMapper = graphicsEngineFonts.Mapper };
(Dictionary<string, RichTextKit.Style> styles, RichTextKit.Style baseStyle) = BuildTextStyles();
int lastIndex = 0;
foreach (Match match in StylePattern.Matches(textToRender))
{
// unstyled text before match
if (match.Index > lastIndex)
{
textBlock.AddText(textToRender.AsSpan(lastIndex, match.Index - lastIndex), baseStyle);
}
var styleName = match.Groups[1].Value;
var innerText = match.Groups[2].Value;
if (styles.TryGetValue(styleName, out var style))
{
textBlock.AddText(innerText, style);
}
else
{
textBlock.AddText(match.Value, baseStyle);
}
lastIndex = match.Index + match.Length;
}
// unstyled text after match
if (lastIndex < textToRender.Length)
{
textBlock.AddText(textToRender.AsSpan(lastIndex), baseStyle);
}
return textBlock;
}
private (Dictionary<string, RichTextKit.Style>, RichTextKit.Style) BuildTextStyles()
{
var styles = new Dictionary<string, RichTextKit.Style>();
var baseStyleDef = textElement.Styles.Find(s => s.Name == textElement.BaseStyle);
if (baseStyleDef == null)
{
throw new InvalidOperationException(
$"The specified base_style '{textElement.BaseStyle}' was not found in the styles list.");
}
foreach (var s in textElement.Styles)
{
// start with base and merge in additional settings
var finalStyle = RichTextStyleFromDef(baseStyleDef);
finalStyle.FontFamily = s.FontFamily ?? finalStyle.FontFamily;
finalStyle.FontItalic = s.FontItalic ?? finalStyle.FontItalic;
finalStyle.FontSize = s.FontSize ?? finalStyle.FontSize;
finalStyle.FontWeight = s.FontWeight ?? finalStyle.FontWeight;
finalStyle.LetterSpacing = s.LetterSpacing ?? finalStyle.LetterSpacing;
if (s.TextColor != null && SKColor.TryParse(s.TextColor, out var parsedColor))
{
finalStyle.TextColor = parsedColor;
}
styles[s.Name] = finalStyle;
}
return (styles, RichTextStyleFromDef(baseStyleDef));
RichTextKit.Style RichTextStyleFromDef(StyleDefinition def)
{
var style = new RichTextKit.Style
{
FontFamily = def.FontFamily,
FontItalic = def.FontItalic ?? false,
TextColor = SKColor.TryParse(def.TextColor, out var color) ? color : SKColors.White
};
foreach (var fontSize in Optional(def.FontSize))
{
style.FontSize = fontSize;
}
foreach (var fontWeight in Optional(def.FontWeight))
{
style.FontWeight = fontWeight;
}
foreach (var letterSpacing in Optional(def.LetterSpacing))
{
style.LetterSpacing = letterSpacing;
}
return style;
}
}
[GeneratedRegex(@"\[(\w+)\](.*?)\[/\1\]")]
private static partial Regex StyleRegex();
}

57
ErsatzTV.Infrastructure/Streaming/GraphicsEngineFonts.cs

@ -1,57 +0,0 @@ @@ -1,57 +0,0 @@
using System.Collections.Concurrent;
using System.Globalization;
using SixLabors.Fonts;
namespace ErsatzTV.Infrastructure.Streaming;
public static class GraphicsEngineFonts
{
private static readonly FontCollection CustomFontCollection = new();
private static readonly ConcurrentDictionary<string, FontFamily> CustomFontFamilies
= new(StringComparer.OrdinalIgnoreCase);
private static readonly System.Collections.Generic.HashSet<string> LoadedFontFiles
= new(StringComparer.OrdinalIgnoreCase);
public static void LoadFonts(string fontsFolder)
{
foreach (var file in Directory.EnumerateFiles(fontsFolder, "*.*", SearchOption.AllDirectories))
{
if (!file.EndsWith(".ttf", StringComparison.OrdinalIgnoreCase) &&
!file.EndsWith(".otf", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!LoadedFontFiles.Add(file))
{
continue;
}
var fontFamily = CustomFontCollection.Add(file, CultureInfo.CurrentCulture);
CustomFontFamilies.TryAdd(fontFamily.Name, fontFamily);
}
}
public static Font GetFont(string fontFamilyName, float fontSize, FontStyle style)
{
// try custom fonts
if (CustomFontFamilies.TryGetValue(fontFamilyName, out var customFamily))
{
return customFamily.GetAvailableStyles().Contains(style)
? customFamily.CreateFont(fontSize, style)
: customFamily.CreateFont(fontSize);
}
// fallback to system fonts
if (SystemFonts.TryGet(fontFamilyName, CultureInfo.CurrentCulture, out var systemFamily))
{
return systemFamily.GetAvailableStyles().Contains(style)
? systemFamily.CreateFont(fontSize, style)
: systemFamily.CreateFont(fontSize);
}
// fallback to default font
var fallback = SystemFonts.Families.First();
return fallback.CreateFont(fontSize, style);
}
}

6
ErsatzTV.Infrastructure/Streaming/PreparedElementImage.cs

@ -1,6 +0,0 @@ @@ -1,6 +0,0 @@
using SixLabors.ImageSharp;
using Image=SixLabors.ImageSharp.Image;
namespace ErsatzTV.Infrastructure.Streaming;
public record PreparedElementImage(Image Image, Point Point, float Opacity, bool Dispose);

163
ErsatzTV.Infrastructure/Streaming/TextElement.cs

@ -1,163 +0,0 @@ @@ -1,163 +0,0 @@
using System.Globalization;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using Microsoft.Extensions.Logging;
using NCalc;
using Scriban;
using Scriban.Runtime;
using SixLabors.Fonts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using TimeZoneConverter;
using Image=SixLabors.ImageSharp.Image;
namespace ErsatzTV.Infrastructure.Streaming;
public class TextElement(TextGraphicsElement textElement, Dictionary<string, object> variables, ILogger logger)
: IGraphicsElement, IDisposable
{
private Option<Expression> _maybeOpacityExpression;
private float _opacity;
private Image _image;
private Point _location;
public int ZIndex { get; private set; }
public bool IsFailed { get; set; }
public async Task InitializeAsync(
Resolution squarePixelFrameSize,
Resolution frameSize,
int frameRate,
CancellationToken cancellationToken)
{
try
{
if (!string.IsNullOrWhiteSpace(textElement.OpacityExpression))
{
var expression = new Expression(textElement.OpacityExpression);
expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction;
_maybeOpacityExpression = expression;
}
else
{
_opacity = (textElement.OpacityPercent ?? 100) / 100.0f;
}
ZIndex = textElement.ZIndex ?? 0;
var scriptObject = new ScriptObject();
scriptObject.Import(variables, renamer: member => member.Name);
scriptObject.Import("convert_timezone", new Func<DateTimeOffset, string, DateTimeOffset>((dt, tzId) =>
{
try
{
var tz = TZConvert.GetTimeZoneInfo(tzId);
return TimeZoneInfo.ConvertTime(dt, tz);
}
catch (TimeZoneNotFoundException ex)
{
logger.LogWarning(ex, "Exception finding specified time zone; resulting time will be unchanged");
return dt;
}
}));
scriptObject.Import("format_datetime", new Func<DateTimeOffset, string, string, string>((dt, tzId, format) =>
{
try
{
var tz = TZConvert.GetTimeZoneInfo(tzId);
dt = TimeZoneInfo.ConvertTime(dt, tz);
}
catch (TimeZoneNotFoundException ex)
{
logger.LogWarning(ex, "Exception finding specified time zone; resulting time will be unchanged");
}
return dt.ToString(format, CultureInfo.CurrentCulture);
}));
var context = new TemplateContext { MemberRenamer = member => member.Name };
context.PushGlobal(scriptObject);
string textToRender = await Template.Parse(textElement.Text).RenderAsync(context);
var font = GraphicsEngineFonts.GetFont(textElement.FontFamily, textElement.FontSize ?? 48,
FontStyle.Regular);
var fontColor = Color.White;
if (Color.TryParse(textElement.FontColor, out Color parsedColor) ||
Color.TryParseHex(textElement.FontColor, out parsedColor))
{
fontColor = parsedColor;
}
var textOptions = new RichTextOptions(font)
{
Origin = new PointF(0, 0),
HorizontalAlignment = HorizontalAlignment.Left
};
// if (Enum.TryParse(textElement.HorizontalAlignment, out HorizontalAlignment parsedAlignment))
// {
// textOptions.HorizontalAlignment = parsedAlignment;
// }
FontRectangle textBounds = TextMeasurer.MeasureBounds(textToRender, textOptions);
textOptions.Origin = new PointF(-textBounds.X, -textBounds.Y);
_image = new Image<Rgba32>((int)Math.Ceiling(textBounds.Width), (int)Math.Ceiling(textBounds.Height));
_image.Mutate(ctx => ctx.DrawText(textOptions, textToRender, fontColor));
int horizontalMargin =
(int)Math.Round((textElement.HorizontalMarginPercent ?? 0) / 100.0 * frameSize.Width);
int verticalMargin = (int)Math.Round((textElement.VerticalMarginPercent ?? 0) / 100.0 * frameSize.Height);
_location = WatermarkElement.CalculatePosition(
textElement.Location,
frameSize.Width,
frameSize.Height,
_image.Width,
_image.Height,
horizontalMargin,
verticalMargin);
}
catch (Exception ex)
{
IsFailed = true;
logger.LogWarning(ex, "Failed to initialize text 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);
}
return opacity == 0
? ValueTask.FromResult(Option<PreparedElementImage>.None)
: new ValueTask<Option<PreparedElementImage>>(new PreparedElementImage(_image, _location, opacity, false));
}
public void Dispose()
{
GC.SuppressFinalize(this);
_image?.Dispose();
_image = null;
}
}

6
ErsatzTV/Startup.cs

@ -65,6 +65,9 @@ using ErsatzTV.Infrastructure.Scripting; @@ -65,6 +65,9 @@ using ErsatzTV.Infrastructure.Scripting;
using ErsatzTV.Infrastructure.Search;
using ErsatzTV.Infrastructure.Sqlite.Data;
using ErsatzTV.Infrastructure.Streaming;
using ErsatzTV.Infrastructure.Streaming.Graphics;
using ErsatzTV.Infrastructure.Streaming.Graphics.Fonts;
using ErsatzTV.Infrastructure.Streaming.Graphics.Text;
using ErsatzTV.Infrastructure.Trakt;
using ErsatzTV.Serialization;
using ErsatzTV.Services;
@ -620,6 +623,8 @@ public class Startup @@ -620,6 +623,8 @@ public class Startup
services.AddSingleton<ISmartCollectionCache, SmartCollectionCache>();
services.AddSingleton<SearchQueryParser>();
services.AddSingleton<ITroubleshootingNotifier, TroubleshootingNotifier>();
services.AddSingleton<CustomFontMapper>();
services.AddSingleton<GraphicsEngineFonts>();
if (SearchHelper.IsElasticSearchEnabled)
{
@ -722,6 +727,7 @@ public class Startup @@ -722,6 +727,7 @@ public class Startup
services.AddScoped<IGraphicsEngine, GraphicsEngine>();
services.AddScoped<IGraphicsElementRepository, GraphicsElementRepository>();
services.AddScoped<ITemplateDataRepository, TemplateDataRepository>();
services.AddScoped<TemplateFunctions>();
services.AddScoped<IFFmpegProcessService, FFmpegLibraryProcessService>();
services.AddScoped<IPipelineBuilderFactory, PipelineBuilderFactory>();

Loading…
Cancel
Save