mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* refactor graphics engine; async frame generation * add text graphics element to playback troubleshootingpull/2283/head
44 changed files with 13399 additions and 211 deletions
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Graphics; |
||||
|
||||
public record RefreshGraphicsElements : IRequest, IBackgroundServiceRequest; |
||||
@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
|
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.Metadata; |
||||
using ErsatzTV.Infrastructure.Data; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.Extensions.Logging; |
||||
|
||||
namespace ErsatzTV.Application.Graphics; |
||||
|
||||
public class RefreshGraphicsElementsHandler( |
||||
IDbContextFactory<TvContext> dbContextFactory, |
||||
ILocalFileSystem localFileSystem, |
||||
ILogger<RefreshGraphicsElementsHandler> logger) |
||||
: IRequestHandler<RefreshGraphicsElements> |
||||
{ |
||||
public async Task Handle(RefreshGraphicsElements request, CancellationToken cancellationToken) |
||||
{ |
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); |
||||
|
||||
// cleanup existing elements
|
||||
var allExisting = await dbContext.GraphicsElements |
||||
.ToListAsync(cancellationToken); |
||||
|
||||
foreach (var existing in allExisting.Where(e => !localFileSystem.FileExists(e.Path))) |
||||
{ |
||||
logger.LogWarning( |
||||
"Removing graphics element that references non-existing file {File}", |
||||
existing.Path); |
||||
|
||||
dbContext.GraphicsElements.Remove(existing); |
||||
} |
||||
|
||||
// add new elements
|
||||
var newPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder) |
||||
.Where(f => allExisting.All(e => e.Path != f)) |
||||
.ToList(); |
||||
|
||||
foreach (var path in newPaths) |
||||
{ |
||||
logger.LogDebug("Adding new graphics element from file {File}", path); |
||||
|
||||
var graphicsElement = new GraphicsElement |
||||
{ |
||||
Path = path, |
||||
Kind = GraphicsElementKind.Text |
||||
}; |
||||
|
||||
await dbContext.AddAsync(graphicsElement, cancellationToken); |
||||
} |
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken); |
||||
} |
||||
} |
||||
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Graphics; |
||||
|
||||
public record GraphicsElementViewModel(int Id, string Name); |
||||
@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
|
||||
namespace ErsatzTV.Application.Graphics; |
||||
|
||||
public static class Mapper |
||||
{ |
||||
public static GraphicsElementViewModel ProjectToViewModel(GraphicsElement graphicsElement) |
||||
{ |
||||
var fileName = Path.GetFileName(graphicsElement.Path); |
||||
return graphicsElement.Kind switch |
||||
{ |
||||
GraphicsElementKind.Text => new GraphicsElementViewModel(graphicsElement.Id, $"text/{fileName}"), |
||||
_ => new GraphicsElementViewModel(graphicsElement.Id, graphicsElement.Path) |
||||
}; |
||||
} |
||||
} |
||||
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Graphics; |
||||
|
||||
public record GetAllGraphicsElements : IRequest<List<GraphicsElementViewModel>>; |
||||
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Infrastructure.Data; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using static ErsatzTV.Application.Graphics.Mapper; |
||||
|
||||
namespace ErsatzTV.Application.Graphics; |
||||
|
||||
public class GetAllGraphicsElementsHandler(IDbContextFactory<TvContext> dbContextFactory) |
||||
: IRequestHandler<GetAllGraphicsElements, List<GraphicsElementViewModel>> |
||||
{ |
||||
public async Task<List<GraphicsElementViewModel>> Handle( |
||||
GetAllGraphicsElements request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); |
||||
return await dbContext.GraphicsElements |
||||
.ToListAsync(cancellationToken) |
||||
.Map(list => list.Map(ProjectToViewModel).ToList()); |
||||
} |
||||
} |
||||
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.Domain; |
||||
|
||||
public class GraphicsElement |
||||
{ |
||||
public int Id { get; set; } |
||||
public string Path { get; set; } |
||||
public GraphicsElementKind Kind { get; set; } |
||||
public List<PlayoutItem> PlayoutItems { get; set; } |
||||
public List<PlayoutItemGraphicsElement> PlayoutItemGraphicsElements { get; set; } |
||||
} |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain; |
||||
|
||||
public enum GraphicsElementKind |
||||
{ |
||||
Image = 0, |
||||
Text = 1 |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Domain; |
||||
|
||||
public class PlayoutItemGraphicsElement |
||||
{ |
||||
public int PlayoutItemId { get; set; } |
||||
public PlayoutItem PlayoutItem { get; set; } |
||||
public int? GraphicsElementId { get; set; } |
||||
public GraphicsElement GraphicsElement { get; set; } |
||||
} |
||||
@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
|
||||
using ErsatzTV.FFmpeg.State; |
||||
using YamlDotNet.Serialization; |
||||
using YamlDotNet.Serialization.NamingConventions; |
||||
|
||||
namespace ErsatzTV.Core.Graphics; |
||||
|
||||
public class TextGraphicsElement |
||||
{ |
||||
public int? Opacity { get; set; } |
||||
|
||||
[YamlMember(Alias = "opacity_expression", ApplyNamingConventions = false)] |
||||
public string OpacityExpression { get; set; } |
||||
|
||||
public WatermarkLocation Location { get; set; } |
||||
[YamlMember(Alias = "horizontal_margin_percent", ApplyNamingConventions = false)] |
||||
public double? HorizontalMarginPercent { get; set; } |
||||
[YamlMember(Alias = "vertical_margin_percent", ApplyNamingConventions = false)] |
||||
public double? VerticalMarginPercent { get; set; } |
||||
[YamlMember(Alias = "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 string Text { get; set; } |
||||
|
||||
public static async Task<Option<TextGraphicsElement>> FromFile(string fileName) |
||||
{ |
||||
try |
||||
{ |
||||
string yaml = await File.ReadAllTextAsync(fileName); |
||||
|
||||
// TODO: validate schema
|
||||
// if (await yamlScheduleValidator.ValidateSchedule(yaml, isImport) == false)
|
||||
// {
|
||||
// return Option<YamlPlayoutDefinition>.None;
|
||||
// }
|
||||
|
||||
IDeserializer deserializer = new DeserializerBuilder() |
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance) |
||||
.Build(); |
||||
|
||||
return deserializer.Deserialize<TextGraphicsElement>(yaml); |
||||
} |
||||
catch (Exception) |
||||
{ |
||||
return Option<TextGraphicsElement>.None; |
||||
} |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
|
||||
using Microsoft.EntityFrameworkCore.Metadata; |
||||
using Microsoft.EntityFrameworkCore.Migrations; |
||||
|
||||
#nullable disable |
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations |
||||
{ |
||||
/// <inheritdoc />
|
||||
public partial class Add_PlayoutItemGraphicsElement : Migration |
||||
{ |
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.CreateTable( |
||||
name: "GraphicsElement", |
||||
columns: table => new |
||||
{ |
||||
Id = table.Column<int>(type: "int", nullable: false) |
||||
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), |
||||
Path = table.Column<string>(type: "longtext", nullable: true) |
||||
.Annotation("MySql:CharSet", "utf8mb4"), |
||||
Kind = table.Column<int>(type: "int", nullable: false) |
||||
}, |
||||
constraints: table => |
||||
{ |
||||
table.PrimaryKey("PK_GraphicsElement", x => x.Id); |
||||
}) |
||||
.Annotation("MySql:CharSet", "utf8mb4"); |
||||
|
||||
migrationBuilder.CreateTable( |
||||
name: "PlayoutItemGraphicsElement", |
||||
columns: table => new |
||||
{ |
||||
PlayoutItemId = table.Column<int>(type: "int", nullable: false), |
||||
GraphicsElementId = table.Column<int>(type: "int", nullable: false) |
||||
}, |
||||
constraints: table => |
||||
{ |
||||
table.PrimaryKey("PK_PlayoutItemGraphicsElement", x => new { x.PlayoutItemId, x.GraphicsElementId }); |
||||
table.ForeignKey( |
||||
name: "FK_PlayoutItemGraphicsElement_GraphicsElement_GraphicsElementId", |
||||
column: x => x.GraphicsElementId, |
||||
principalTable: "GraphicsElement", |
||||
principalColumn: "Id", |
||||
onDelete: ReferentialAction.Cascade); |
||||
table.ForeignKey( |
||||
name: "FK_PlayoutItemGraphicsElement_PlayoutItem_PlayoutItemId", |
||||
column: x => x.PlayoutItemId, |
||||
principalTable: "PlayoutItem", |
||||
principalColumn: "Id", |
||||
onDelete: ReferentialAction.Cascade); |
||||
}) |
||||
.Annotation("MySql:CharSet", "utf8mb4"); |
||||
|
||||
migrationBuilder.CreateIndex( |
||||
name: "IX_PlayoutItemGraphicsElement_GraphicsElementId", |
||||
table: "PlayoutItemGraphicsElement", |
||||
column: "GraphicsElementId"); |
||||
} |
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.DropTable( |
||||
name: "PlayoutItemGraphicsElement"); |
||||
|
||||
migrationBuilder.DropTable( |
||||
name: "GraphicsElement"); |
||||
} |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations; |
||||
|
||||
#nullable disable |
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations |
||||
{ |
||||
/// <inheritdoc />
|
||||
public partial class Add_PlayoutItemGraphicsElement : Migration |
||||
{ |
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.CreateTable( |
||||
name: "GraphicsElement", |
||||
columns: table => new |
||||
{ |
||||
Id = table.Column<int>(type: "INTEGER", nullable: false) |
||||
.Annotation("Sqlite:Autoincrement", true), |
||||
Path = table.Column<string>(type: "TEXT", nullable: true), |
||||
Kind = table.Column<int>(type: "INTEGER", nullable: false) |
||||
}, |
||||
constraints: table => |
||||
{ |
||||
table.PrimaryKey("PK_GraphicsElement", x => x.Id); |
||||
}); |
||||
|
||||
migrationBuilder.CreateTable( |
||||
name: "PlayoutItemGraphicsElement", |
||||
columns: table => new |
||||
{ |
||||
PlayoutItemId = table.Column<int>(type: "INTEGER", nullable: false), |
||||
GraphicsElementId = table.Column<int>(type: "INTEGER", nullable: false) |
||||
}, |
||||
constraints: table => |
||||
{ |
||||
table.PrimaryKey("PK_PlayoutItemGraphicsElement", x => new { x.PlayoutItemId, x.GraphicsElementId }); |
||||
table.ForeignKey( |
||||
name: "FK_PlayoutItemGraphicsElement_GraphicsElement_GraphicsElementId", |
||||
column: x => x.GraphicsElementId, |
||||
principalTable: "GraphicsElement", |
||||
principalColumn: "Id", |
||||
onDelete: ReferentialAction.Cascade); |
||||
table.ForeignKey( |
||||
name: "FK_PlayoutItemGraphicsElement_PlayoutItem_PlayoutItemId", |
||||
column: x => x.PlayoutItemId, |
||||
principalTable: "PlayoutItem", |
||||
principalColumn: "Id", |
||||
onDelete: ReferentialAction.Cascade); |
||||
}); |
||||
|
||||
migrationBuilder.CreateIndex( |
||||
name: "IX_PlayoutItemGraphicsElement_GraphicsElementId", |
||||
table: "PlayoutItemGraphicsElement", |
||||
column: "GraphicsElementId"); |
||||
} |
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.DropTable( |
||||
name: "PlayoutItemGraphicsElement"); |
||||
|
||||
migrationBuilder.DropTable( |
||||
name: "GraphicsElement"); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations; |
||||
|
||||
public class GraphicsElementConfiguration : IEntityTypeConfiguration<GraphicsElement> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<GraphicsElement> builder) => builder.ToTable("GraphicsElement"); |
||||
} |
||||
@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
|
||||
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(); |
||||
|
||||
private static bool _fontsLoaded; |
||||
|
||||
public static void LoadFonts(string fontsFolder) |
||||
{ |
||||
if (_fontsLoaded) |
||||
{ |
||||
return; |
||||
} |
||||
|
||||
foreach (var file in Directory.GetFiles(fontsFolder, "*.*", SearchOption.AllDirectories)) |
||||
{ |
||||
if (file.EndsWith(".ttf", StringComparison.OrdinalIgnoreCase) || |
||||
file.EndsWith(".otf", StringComparison.OrdinalIgnoreCase)) |
||||
{ |
||||
var fontFamily = CustomFontCollection.Add(file, CultureInfo.CurrentCulture); |
||||
CustomFontFamilies.TryAdd(fontFamily.Name, fontFamily); |
||||
} |
||||
} |
||||
|
||||
_fontsLoaded = true; |
||||
} |
||||
|
||||
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); |
||||
} |
||||
} |
||||
@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
|
||||
using System.Globalization; |
||||
using NCalc; |
||||
using NCalc.Handlers; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming; |
||||
|
||||
public static class OpacityExpressionHelper |
||||
{ |
||||
public static void EvaluateFunction(string name, FunctionArgs args) |
||||
{ |
||||
switch (name) |
||||
{ |
||||
case "LinearFadePoints": |
||||
{ |
||||
if (args.Parameters.Length != 5) |
||||
{ |
||||
throw new ArgumentException("LinearFadePoints() requires 5 arguments."); |
||||
} |
||||
|
||||
double time = Convert.ToDouble(args.Parameters[0].Evaluate(), CultureInfo.CurrentCulture); |
||||
double start = Convert.ToDouble(args.Parameters[1].Evaluate(), CultureInfo.CurrentCulture); |
||||
double peakStart = Convert.ToDouble(args.Parameters[2].Evaluate(), CultureInfo.CurrentCulture); |
||||
double peakEnd = Convert.ToDouble(args.Parameters[3].Evaluate(), CultureInfo.CurrentCulture); |
||||
double end = Convert.ToDouble(args.Parameters[4].Evaluate(), CultureInfo.CurrentCulture); |
||||
|
||||
args.Result = LinearFadePoints(time, start, peakStart, peakEnd, end); |
||||
break; |
||||
} |
||||
case "LinearFadeDuration": |
||||
{ |
||||
if (args.Parameters.Length != 4) |
||||
{ |
||||
throw new ArgumentException("LinearFadeDuration() requires 4 arguments."); |
||||
} |
||||
|
||||
double time = Convert.ToDouble(args.Parameters[0].Evaluate(), CultureInfo.CurrentCulture); |
||||
double start = Convert.ToDouble(args.Parameters[1].Evaluate(), CultureInfo.CurrentCulture); |
||||
double fadeSeconds = Convert.ToDouble(args.Parameters[2].Evaluate(), CultureInfo.CurrentCulture); |
||||
double peakSeconds = Convert.ToDouble(args.Parameters[3].Evaluate(), CultureInfo.CurrentCulture); |
||||
|
||||
args.Result = LinearFadeDuration(time, start, fadeSeconds, peakSeconds); |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
|
||||
private static double LinearFadePoints(double time, double start, double peakStart, double peakEnd, double end) |
||||
{ |
||||
if (time < start || time >= end) |
||||
{ |
||||
return 0; |
||||
} |
||||
|
||||
// fade in
|
||||
if (time < peakStart) |
||||
{ |
||||
return (time - start) / (peakStart - start); |
||||
} |
||||
|
||||
// solid
|
||||
if (time < peakEnd) |
||||
{ |
||||
return 1.0; |
||||
} |
||||
|
||||
// fade out
|
||||
return (end - time) / (end - peakEnd); |
||||
} |
||||
|
||||
private static double LinearFadeDuration(double time, double start, double fadeSeconds, double peakSeconds) |
||||
{ |
||||
// edge case with no fade
|
||||
if (fadeSeconds <= 0) |
||||
{ |
||||
double noFadeEnd = start + peakSeconds; |
||||
return (time >= start && time < noFadeEnd) ? 1.0 : 0.0; |
||||
} |
||||
|
||||
double peakStart = start + fadeSeconds; |
||||
double peakEnd = peakStart + peakSeconds; |
||||
double end = peakEnd + fadeSeconds; |
||||
|
||||
return LinearFadePoints(time, start, peakStart, peakEnd, end); |
||||
} |
||||
|
||||
public static float GetOpacity( |
||||
Expression expression, |
||||
TimeSpan timeOfDay, |
||||
TimeSpan contentTime, |
||||
TimeSpan contentTotalTime, |
||||
TimeSpan channelTime) |
||||
{ |
||||
expression.Parameters["content_seconds"] = contentTime.TotalSeconds; |
||||
expression.Parameters["content_total_seconds"] = contentTotalTime.TotalSeconds; |
||||
expression.Parameters["channel_seconds"] = channelTime.TotalSeconds; |
||||
expression.Parameters["time_of_day_seconds"] = timeOfDay.TotalSeconds; |
||||
|
||||
object expressionResult = expression.Evaluate(); |
||||
return Convert.ToSingle(expressionResult, CultureInfo.InvariantCulture); |
||||
} |
||||
} |
||||
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
using SixLabors.ImageSharp; |
||||
using Image=SixLabors.ImageSharp.Image; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming; |
||||
|
||||
public record PreparedElementImage(Image Image, Point Point, float Opacity, bool Dispose); |
||||
@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Graphics; |
||||
using Microsoft.Extensions.Logging; |
||||
using NCalc; |
||||
using SixLabors.Fonts; |
||||
using SixLabors.ImageSharp; |
||||
using SixLabors.ImageSharp.Drawing.Processing; |
||||
using SixLabors.ImageSharp.PixelFormats; |
||||
using SixLabors.ImageSharp.Processing; |
||||
using Image=SixLabors.ImageSharp.Image; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming; |
||||
|
||||
public class TextElement(TextGraphicsElement textElement, 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 Task InitializeAsync(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.Opacity ?? 100) / 100.0f; |
||||
} |
||||
|
||||
ZIndex = textElement.ZIndex ?? 0; |
||||
|
||||
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(textElement.Text, 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, textElement.Text, 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"); |
||||
} |
||||
|
||||
return Task.CompletedTask; |
||||
} |
||||
|
||||
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