mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* 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 definitionpull/2321/head
18 changed files with 636 additions and 365 deletions
@ -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; } |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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), |
||||
}; |
||||
} |
||||
} |
||||
@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming; |
||||
namespace ErsatzTV.Infrastructure.Streaming.Graphics; |
||||
|
||||
public interface IGraphicsElement |
||||
{ |
||||
@ -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); |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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(); |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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); |
||||
@ -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; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue