diff --git a/ErsatzTV.Core/Graphics/StyleDefinition.cs b/ErsatzTV.Core/Graphics/StyleDefinition.cs new file mode 100644 index 000000000..ef66b0627 --- /dev/null +++ b/ErsatzTV.Core/Graphics/StyleDefinition.cs @@ -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; } +} diff --git a/ErsatzTV.Core/Graphics/TextGraphicsElement.cs b/ErsatzTV.Core/Graphics/TextGraphicsElement.cs index d62d54bf5..168dbcb10 100644 --- a/ErsatzTV.Core/Graphics/TextGraphicsElement.cs +++ b/ErsatzTV.Core/Graphics/TextGraphicsElement.cs @@ -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 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; } diff --git a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj index 05abbee7b..15b6d39aa 100644 --- a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj +++ b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj @@ -33,8 +33,10 @@ + + diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Fonts/CustomFontMapper.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Fonts/CustomFontMapper.cs new file mode 100644 index 000000000..d1e71274a --- /dev/null +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Fonts/CustomFontMapper.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Logging; +using SkiaSharp; +using Topten.RichTextKit; + +namespace ErsatzTV.Infrastructure.Streaming.Graphics.Fonts; + +public sealed class CustomFontMapper(ILogger logger) : FontMapper +{ + private readonly Dictionary> _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 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); + } +} \ No newline at end of file diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Fonts/GraphicsEngineFonts.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Fonts/GraphicsEngineFonts.cs new file mode 100644 index 000000000..efccf822c --- /dev/null +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Fonts/GraphicsEngineFonts.cs @@ -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 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; +} diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElement.cs new file mode 100644 index 000000000..7de1b2027 --- /dev/null +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElement.cs @@ -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> 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), + }; + } +} \ No newline at end of file diff --git a/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs similarity index 76% rename from ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs rename to ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs index ce0a345a7..f4ae95bdb 100644 --- a/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs @@ -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 logger) : IGraphicsEngine +public class GraphicsEngine( + TemplateFunctions templateFunctions, + GraphicsEngineFonts graphicsEngineFonts, + ITemplateDataRepository templateDataRepository, + ILogger logger) + : IGraphicsEngine { public async Task Run(GraphicsEngineContext context, PipeWriter pipeWriter, CancellationToken cancellationToken) { - GraphicsEngineFonts.LoadFonts(FileSystemLayout.FontsCacheFolder); + graphicsEngineFonts.LoadFonts(FileSystemLayout.FontsCacheFolder); var templateVariables = new Dictionary(); @@ -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 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 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 // `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( - 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(); @@ -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 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 } } } -} +} \ No newline at end of file diff --git a/ErsatzTV.Infrastructure/Streaming/IGraphicsElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/IGraphicsElement.cs similarity index 90% rename from ErsatzTV.Infrastructure/Streaming/IGraphicsElement.cs rename to ErsatzTV.Infrastructure/Streaming/Graphics/IGraphicsElement.cs index 68ef5bda1..482b4bdce 100644 --- a/ErsatzTV.Infrastructure/Streaming/IGraphicsElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/IGraphicsElement.cs @@ -1,6 +1,6 @@ using ErsatzTV.Core.Domain; -namespace ErsatzTV.Infrastructure.Streaming; +namespace ErsatzTV.Infrastructure.Streaming.Graphics; public interface IGraphicsElement { diff --git a/ErsatzTV.Infrastructure/Streaming/ImageElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElement.cs similarity index 53% rename from ErsatzTV.Infrastructure/Streaming/ImageElement.cs rename to ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElement.cs index 197f327e2..9c91d9e15 100644 --- a/ErsatzTV.Infrastructure/Streaming/ImageElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElement.cs @@ -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 _scaledFrames = []; - private readonly List _frameDelays = []; + private readonly List _scaledFrames = []; + private readonly List _frameDelays = []; private Option _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 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 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 } } - public ValueTask> PrepareImage( + public override ValueTask> PrepareImage( TimeSpan timeOfDay, TimeSpan contentTime, TimeSpan contentTotalTime, @@ -128,24 +158,24 @@ public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger log return ValueTask.FromResult(Option.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 { GC.SuppressFinalize(this); - _sourceImage?.Dispose(); + _sourceCodec?.Dispose(); _scaledFrames?.ForEach(f => f.Dispose()); } -} \ No newline at end of file +} diff --git a/ErsatzTV.Infrastructure/Streaming/WatermarkElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs similarity index 63% rename from ErsatzTV.Infrastructure/Streaming/WatermarkElement.cs rename to ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs index cb3a23a9f..ae931089a 100644 --- a/ErsatzTV.Infrastructure/Streaming/WatermarkElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs @@ -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 _scaledFrames = []; - private readonly List _frameDelays = []; + private readonly List _scaledFrames = []; + private readonly List _frameDelays = []; private Option _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 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 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 ? 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 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 } } - public ValueTask> PrepareImage( + public override ValueTask> PrepareImage( TimeSpan timeOfDay, TimeSpan contentTime, TimeSpan contentTotalTime, @@ -158,24 +189,24 @@ public class WatermarkElement : IGraphicsElement, IDisposable return ValueTask.FromResult(Option.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 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 { GC.SuppressFinalize(this); - _sourceImage?.Dispose(); + _sourceCodec?.Dispose(); _scaledFrames?.ForEach(f => f.Dispose()); } -} \ No newline at end of file +} diff --git a/ErsatzTV.Infrastructure/Streaming/OpacityExpressionHelper.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/OpacityExpressionHelper.cs similarity index 98% rename from ErsatzTV.Infrastructure/Streaming/OpacityExpressionHelper.cs rename to ErsatzTV.Infrastructure/Streaming/Graphics/OpacityExpressionHelper.cs index 2e759bb78..6ec8d7d79 100644 --- a/ErsatzTV.Infrastructure/Streaming/OpacityExpressionHelper.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/OpacityExpressionHelper.cs @@ -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 { diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/PreparedElementImage.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/PreparedElementImage.cs new file mode 100644 index 000000000..1104d982b --- /dev/null +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/PreparedElementImage.cs @@ -0,0 +1,5 @@ +using SkiaSharp; + +namespace ErsatzTV.Infrastructure.Streaming.Graphics; + +public record PreparedElementImage(SKBitmap Image, SKPointI Point, float Opacity, bool Dispose); \ No newline at end of file diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Text/TemplateFunctions.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Text/TemplateFunctions.cs new file mode 100644 index 000000000..00ccef7d9 --- /dev/null +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Text/TemplateFunctions.cs @@ -0,0 +1,37 @@ +using System.Globalization; +using Microsoft.Extensions.Logging; +using TimeZoneConverter; + +namespace ErsatzTV.Infrastructure.Streaming.Graphics.Text; + +public class TemplateFunctions(ILogger 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); + } +} diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Text/TextElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Text/TextElement.cs new file mode 100644 index 000000000..1e752e0a9 --- /dev/null +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Text/TextElement.cs @@ -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 variables, + ILogger logger) + : GraphicsElement, IDisposable +{ + private static readonly Regex StylePattern = StyleRegex(); + + private Option _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> 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.None) + : new ValueTask>(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 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, RichTextKit.Style) BuildTextStyles() + { + var styles = new Dictionary(); + + 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(); +} diff --git a/ErsatzTV.Infrastructure/Streaming/GraphicsEngineFonts.cs b/ErsatzTV.Infrastructure/Streaming/GraphicsEngineFonts.cs deleted file mode 100644 index 81537e5a4..000000000 --- a/ErsatzTV.Infrastructure/Streaming/GraphicsEngineFonts.cs +++ /dev/null @@ -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 CustomFontFamilies - = new(StringComparer.OrdinalIgnoreCase); - private static readonly System.Collections.Generic.HashSet 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); - } -} diff --git a/ErsatzTV.Infrastructure/Streaming/PreparedElementImage.cs b/ErsatzTV.Infrastructure/Streaming/PreparedElementImage.cs deleted file mode 100644 index f277858ab..000000000 --- a/ErsatzTV.Infrastructure/Streaming/PreparedElementImage.cs +++ /dev/null @@ -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); \ No newline at end of file diff --git a/ErsatzTV.Infrastructure/Streaming/TextElement.cs b/ErsatzTV.Infrastructure/Streaming/TextElement.cs deleted file mode 100644 index 49197dc39..000000000 --- a/ErsatzTV.Infrastructure/Streaming/TextElement.cs +++ /dev/null @@ -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 variables, ILogger logger) - : IGraphicsElement, IDisposable -{ - private Option _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((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((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((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> 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.None) - : new ValueTask>(new PreparedElementImage(_image, _location, opacity, false)); - } - - public void Dispose() - { - GC.SuppressFinalize(this); - - _image?.Dispose(); - _image = null; - } -} \ No newline at end of file diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 04b000cf1..4507e4c68 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -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 services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); if (SearchHelper.IsElasticSearchEnabled) { @@ -722,6 +727,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped();