add text graphics element to playback troubleshooting (#2282)

* refactor graphics engine; async frame generation

* add text graphics element to playback troubleshooting
This commit is contained in:
Jason Dove
2025-08-08 19:18:15 +00:00
committed by GitHub
parent 9f3db05c17
commit 76a589b538
44 changed files with 13408 additions and 220 deletions
+6
View File
@@ -19,6 +19,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `LinearFadePoints(time, start, peakStart, peakEnd, end)`
- Add `Z-Index` to watermark editor
- The graphics engine will order by z-index when overlaying watermarks
- Add *experimental* `Graphics Element` template system
- Graphics elements are defined in YAML files inside ETV config folder / templates / graphics-elements subfolder
- Add `Text` graphics element type
- Supported in playback troubleshooting
- Displays multi-line text in a specified font, color, location, z-index
- Supports constant opacity and opacity expression
### Fix
- Fix database operations that were slowing down playout builds
@@ -11,6 +11,8 @@
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpegprofiles_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=filler_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=filler_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=graphics_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=graphics_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=hdhr_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=hdhr_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=health_005Cqueries/@EntryIndexedValue">True</s:Boolean>
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Graphics;
public record RefreshGraphicsElements : IRequest, IBackgroundServiceRequest;
@@ -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 @@
namespace ErsatzTV.Application.Graphics;
public record GraphicsElementViewModel(int Id, string Name);
+16
View File
@@ -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 @@
namespace ErsatzTV.Application.Graphics;
public record GetAllGraphicsElements : IRequest<List<GraphicsElementViewModel>>;
@@ -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());
}
}
@@ -88,6 +88,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ThenInclude(p => p.Deco)
.ThenInclude(d => d.Watermark)
// get graphics elements
.Include(i => i.GraphicsElements)
// get playout templates (and deco templates/decos)
.Include(i => i.Playout)
.ThenInclude(p => p.Templates)
@@ -341,6 +344,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
effectiveNow,
playoutItemWatermarks,
maybeGlobalWatermark,
playoutItemWithPath.PlayoutItem.GraphicsElements,
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
@@ -4,5 +4,6 @@ public record ArchiveTroubleshootingResults(
int MediaItemId,
int FFmpegProfileId,
List<int> WatermarkIds,
List<int> GraphicsElementIds,
bool StartFromBeginning)
: IRequest<Option<string>>;
@@ -7,6 +7,7 @@ public record PrepareTroubleshootingPlayback(
int MediaItemId,
int FFmpegProfileId,
List<int> WatermarkIds,
List<int> GraphicsElementIds,
int? SubtitleId,
bool StartFromBeginning)
: IRequest<Either<BaseError, PlayoutItemResult>>;
@@ -113,6 +113,10 @@ public class PrepareTroubleshootingPlaybackHandler(
outPoint = inPoint + duration;
}
var graphicsElements = await dbContext.GraphicsElements
.Where(ge => request.GraphicsElementIds.Contains(ge.Id))
.ToListAsync();
PlayoutItemResult playoutItemResult = await ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
ffprobePath,
@@ -141,6 +145,7 @@ public class PrepareTroubleshootingPlaybackHandler(
now,
watermarks,
Option<ChannelWatermark>.None,
graphicsElements,
ffmpegProfile.VaapiDisplay,
ffmpegProfile.VaapiDriver,
ffmpegProfile.VaapiDevice,
+10
View File
@@ -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 @@
namespace ErsatzTV.Core.Domain;
public enum GraphicsElementKind
{
Image = 0,
Text = 1
}
+2
View File
@@ -32,6 +32,8 @@ public class PlayoutItem
public string CollectionKey { get; set; }
public string CollectionEtag { get; set; }
public List<PlayoutItemWatermark> PlayoutItemWatermarks { get; set; }
public List<GraphicsElement> GraphicsElements { get; set; }
public List<PlayoutItemGraphicsElement> PlayoutItemGraphicsElements { get; set; }
public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime();
public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime();
@@ -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; }
}
+1 -1
View File
@@ -4,6 +4,6 @@ public class PlayoutItemWatermark
{
public int PlayoutItemId { get; set; }
public PlayoutItem PlayoutItem { get; set; }
public ChannelWatermark Watermark { get; set; }
public int? WatermarkId { get; set; }
public ChannelWatermark Watermark { get; set; }
}
@@ -2,6 +2,7 @@
using CliWrap;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming;
@@ -64,6 +65,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
DateTimeOffset now,
List<ChannelWatermark> playoutItemWatermarks,
Option<ChannelWatermark> globalWatermark,
List<GraphicsElement> graphicsElements,
string vaapiDisplay,
VaapiDriver vaapiDriver,
string vaapiDevice,
@@ -324,6 +326,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<WatermarkInputFile> watermarkInputFile = Option<WatermarkInputFile>.None;
Option<GraphicsEngineInput> graphicsEngineInput = Option<GraphicsEngineInput>.None;
Option<GraphicsEngineContext> graphicsEngineContext = Option<GraphicsEngineContext>.None;
List<GraphicsElementContext> graphicsElementContexts = new List<GraphicsElementContext>();
// use graphics engine for all watermarks
if (!disableWatermarks)
@@ -368,13 +371,42 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
}
}
// only use graphics engine when we have watermarks
if (watermarks.Count > 0)
graphicsElementContexts.AddRange(watermarks.Values);
}
foreach (var graphicsElement in graphicsElements)
{
switch (graphicsElement.Kind)
{
case GraphicsElementKind.Text:
var maybeElement = await TextGraphicsElement.FromFile(graphicsElement.Path);
if (maybeElement.IsNone)
{
_logger.LogWarning(
"Failed to load text graphics element from file {Path}; ignoring",
graphicsElement.Path);
}
foreach (var element in maybeElement)
{
graphicsElementContexts.Add(new TextElementContext(element));
}
break;
default:
_logger.LogInformation(
"Ignoring unsupported graphics element kind {Kind}",
nameof(graphicsElement.Kind));
break;
}
}
// only use graphics engine when we have elements
if (graphicsElementContexts.Count > 0)
{
graphicsEngineInput = new GraphicsEngineInput();
graphicsEngineContext = new GraphicsEngineContext(
watermarks.Values.OfType<GraphicsElementContext>().ToList(),
graphicsElementContexts,
channel.FFmpegProfile.Resolution,
await playbackSettings.FrameRate.IfNoneAsync(24),
ChannelStartTime: channelStartTime,
@@ -382,7 +414,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero),
finish - now);
}
}
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, fillerKind);
+6
View File
@@ -47,6 +47,9 @@ public static class FileSystemLayout
public static readonly string ChannelGuideTemplatesFolder;
public static readonly string GraphicsElementsTemplatesFolder;
public static readonly string GraphicsElementsTextTemplatesFolder;
public static readonly string ScriptsFolder;
public static readonly string MultiEpisodeShuffleTemplatesFolder;
@@ -162,6 +165,9 @@ public static class FileSystemLayout
ChannelGuideTemplatesFolder = Path.Combine(TemplatesFolder, "channel-guide");
GraphicsElementsTemplatesFolder = Path.Combine(TemplatesFolder, "graphics-elements");
GraphicsElementsTextTemplatesFolder = Path.Combine(GraphicsElementsTemplatesFolder, "text");
ScriptsFolder = Path.Combine(AppDataFolder, "scripts");
MultiEpisodeShuffleTemplatesFolder = Path.Combine(ScriptsFolder, "multi-episode-shuffle");
@@ -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;
}
}
}
@@ -28,6 +28,7 @@ public interface IFFmpegProcessService
DateTimeOffset now,
List<ChannelWatermark> playoutItemWatermarks,
Option<ChannelWatermark> globalWatermark,
List<GraphicsElement> graphicsElements,
string vaapiDisplay,
VaapiDriver vaapiDriver,
string vaapiDevice,
@@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Graphics;
namespace ErsatzTV.Core.Interfaces.Streaming;
@@ -15,3 +16,5 @@ public record GraphicsEngineContext(
public abstract record GraphicsElementContext;
public record WatermarkElementContext(WatermarkOptions Options) : GraphicsElementContext;
public record TextElementContext(TextGraphicsElement TextElement) : GraphicsElementContext;
@@ -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");
}
}
}
@@ -849,6 +849,25 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.ToTable("Genre");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.GraphicsElement", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<int>("Kind")
.HasColumnType("int");
b.Property<string>("Path")
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("GraphicsElement", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageFolderDuration", b =>
{
b.Property<int>("Id")
@@ -1894,6 +1913,21 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.ToTable("PlayoutItem", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemGraphicsElement", b =>
{
b.Property<int>("PlayoutItemId")
.HasColumnType("int");
b.Property<int>("GraphicsElementId")
.HasColumnType("int");
b.HasKey("PlayoutItemId", "GraphicsElementId");
b.HasIndex("GraphicsElementId");
b.ToTable("PlayoutItemGraphicsElement");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
{
b.Property<int>("PlayoutItemId")
@@ -4635,6 +4669,25 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Playout");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemGraphicsElement", b =>
{
b.HasOne("ErsatzTV.Core.Domain.GraphicsElement", "GraphicsElement")
.WithMany("PlayoutItemGraphicsElements")
.HasForeignKey("GraphicsElementId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.PlayoutItem", "PlayoutItem")
.WithMany("PlayoutItemGraphicsElements")
.HasForeignKey("PlayoutItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("GraphicsElement");
b.Navigation("PlayoutItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlayoutItem", "PlayoutItem")
@@ -5806,6 +5859,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Writers");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.GraphicsElement", b =>
{
b.Navigation("PlayoutItemGraphicsElements");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
{
b.Navigation("Actors");
@@ -5962,6 +6020,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.Navigation("PlayoutItemGraphicsElements");
b.Navigation("PlayoutItemWatermarks");
});
@@ -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");
}
}
}
@@ -814,6 +814,23 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.ToTable("Genre");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.GraphicsElement", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Kind")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("GraphicsElement", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageFolderDuration", b =>
{
b.Property<int>("Id")
@@ -1805,6 +1822,21 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.ToTable("PlayoutItem", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemGraphicsElement", b =>
{
b.Property<int>("PlayoutItemId")
.HasColumnType("INTEGER");
b.Property<int>("GraphicsElementId")
.HasColumnType("INTEGER");
b.HasKey("PlayoutItemId", "GraphicsElementId");
b.HasIndex("GraphicsElementId");
b.ToTable("PlayoutItemGraphicsElement");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
{
b.Property<int>("PlayoutItemId")
@@ -4472,6 +4504,25 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Playout");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemGraphicsElement", b =>
{
b.HasOne("ErsatzTV.Core.Domain.GraphicsElement", "GraphicsElement")
.WithMany("PlayoutItemGraphicsElements")
.HasForeignKey("GraphicsElementId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.PlayoutItem", "PlayoutItem")
.WithMany("PlayoutItemGraphicsElements")
.HasForeignKey("PlayoutItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("GraphicsElement");
b.Navigation("PlayoutItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlayoutItem", "PlayoutItem")
@@ -5643,6 +5694,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Writers");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.GraphicsElement", b =>
{
b.Navigation("PlayoutItemGraphicsElements");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
{
b.Navigation("Actors");
@@ -5799,6 +5855,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.Navigation("PlayoutItemGraphicsElements");
b.Navigation("PlayoutItemWatermarks");
});
@@ -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");
}
@@ -28,5 +28,18 @@ public class PlayoutItemConfiguration : IEntityTypeConfiguration<PlayoutItem>
.HasForeignKey(ci => ci.PlayoutItemId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasKey(ci => new { ci.PlayoutItemId, ci.WatermarkId }));
builder.HasMany(c => c.GraphicsElements)
.WithMany(m => m.PlayoutItems)
.UsingEntity<PlayoutItemGraphicsElement>(
j => j.HasOne(ci => ci.GraphicsElement)
.WithMany(mi => mi.PlayoutItemGraphicsElements)
.HasForeignKey(ci => ci.GraphicsElementId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasOne(ci => ci.PlayoutItem)
.WithMany(c => c.PlayoutItemGraphicsElements)
.HasForeignKey(ci => ci.PlayoutItemId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasKey(ci => new { ci.PlayoutItemId, ci.GraphicsElementId }));
}
}
@@ -113,6 +113,7 @@ public class TvContext : DbContext
public DbSet<TraktList> TraktLists { get; set; }
public DbSet<FillerPreset> FillerPresets { get; set; }
public DbSet<Subtitle> Subtitles { get; set; }
public DbSet<GraphicsElement> GraphicsElements { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
optionsBuilder.UseLoggerFactory(_loggerFactory);
@@ -1,4 +1,5 @@
using System.IO.Pipelines;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Streaming;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
@@ -11,18 +12,23 @@ public class GraphicsEngine(ILogger<GraphicsEngine> logger) : IGraphicsEngine
{
public async Task Run(GraphicsEngineContext context, PipeWriter pipeWriter, CancellationToken cancellationToken)
{
GraphicsEngineFonts.LoadFonts(FileSystemLayout.FontsCacheFolder);
var elements = new List<IGraphicsElement>();
foreach (var element in context.Elements)
{
switch (element)
{
case WatermarkElementContext watermarkElementContext:
var watermark = new WatermarkElement(watermarkElementContext.Options);
var watermark = new WatermarkElement(watermarkElementContext.Options, logger);
if (watermark.IsValid)
{
elements.Add(watermark);
}
break;
case TextElementContext textElementContext:
elements.Add(new TextElement(textElementContext.TextElement, logger));
break;
}
}
@@ -58,23 +64,20 @@ public class GraphicsEngine(ILogger<GraphicsEngine> logger) : IGraphicsEngine
context.FrameSize.Height,
Color.Transparent);
// draw each element
outputFrame.Mutate(ctx =>
{
foreach (var element in elements.OrderBy(e => e.ZIndex))
// prepare images outside mutate to allow async image generation
var preparedElementImages = new List<PreparedElementImage>();
foreach (var element in elements.Where(e => !e.IsFailed).OrderBy(e => e.ZIndex))
{
try
{
if (!element.IsFailed)
{
element.Draw(
ctx,
var maybePreparedImage = await element.PrepareImage(
frameTime.TimeOfDay,
contentTime,
contentTotalTime,
channelTime,
cancellationToken);
}
preparedElementImages.AddRange(maybePreparedImage);
}
catch (Exception ex)
{
@@ -84,6 +87,18 @@ public class GraphicsEngine(ILogger<GraphicsEngine> logger) : IGraphicsEngine
element.GetType().Name);
}
}
// draw each element
outputFrame.Mutate(ctx =>
{
foreach (var preparedImage in preparedElementImages)
{
ctx.DrawImage(preparedImage.Image, preparedImage.Point, preparedImage.Opacity);
if (preparedImage.Dispose)
{
preparedImage.Image.Dispose();
}
}
});
// pipe output
@@ -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);
}
}
@@ -1,6 +1,6 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Streaming;
namespace ErsatzTV.Infrastructure.Streaming;
public interface IGraphicsElement
{
@@ -10,8 +10,7 @@ public interface IGraphicsElement
Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken);
void Draw(
object context,
ValueTask<Option<PreparedElementImage>> PrepareImage(
TimeSpan timeOfDay,
TimeSpan contentTime,
TimeSpan contentTotalTime,
@@ -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 @@
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 @@
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;
}
}
@@ -1,10 +1,8 @@
using System.Globalization;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg.State;
using Microsoft.Extensions.Logging;
using NCalc;
using NCalc.Handlers;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Gif;
using SixLabors.ImageSharp.Processing;
@@ -14,18 +12,21 @@ namespace ErsatzTV.Infrastructure.Streaming;
public class WatermarkElement : IGraphicsElement, IDisposable
{
private readonly ILogger _logger;
private readonly string _imagePath;
private readonly ChannelWatermark _watermark;
private readonly List<Image> _scaledFrames = [];
private readonly List<double> _frameDelays = [];
private Expression _expression;
private Option<Expression> _maybeOpacityExpression;
private float _opacity;
private double _animatedDurationSeconds;
private Image _sourceImage;
private Point _location;
public WatermarkElement(WatermarkOptions watermarkOptions)
public WatermarkElement(WatermarkOptions watermarkOptions, ILogger logger)
{
_logger = logger;
// TODO: better model coming in here?
foreach (var imagePath in watermarkOptions.ImagePath)
{
@@ -46,6 +47,8 @@ public class WatermarkElement : IGraphicsElement, IDisposable
public bool IsFailed { get; set; }
public async Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken)
{
try
{
if (_watermark.Mode is ChannelWatermarkMode.Intermittent)
{
@@ -60,19 +63,21 @@ public class WatermarkElement : IGraphicsElement, IDisposable
)
)
)";
_expression = new Expression(expressionString);
_maybeOpacityExpression = new Expression(expressionString);
}
else if (_watermark.Mode is ChannelWatermarkMode.OpacityExpression && !string.IsNullOrWhiteSpace(_watermark.OpacityExpression))
{
_expression = new Expression(_watermark.OpacityExpression);
_maybeOpacityExpression = new Expression(_watermark.OpacityExpression);
}
else
{
float opacity = _watermark.Opacity / 100.0f;
_expression = new Expression(opacity.ToString(CultureInfo.InvariantCulture));
_opacity = _watermark.Opacity / 100.0f;
}
_expression.EvaluateFunction += EvaluateFunction;
foreach (var expression in _maybeOpacityExpression)
{
expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction;
}
bool isRemoteUri = Uri.TryCreate(_imagePath, UriKind.Absolute, out var uriResult)
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
@@ -122,72 +127,38 @@ public class WatermarkElement : IGraphicsElement, IDisposable
_frameDelays.Add(frameDelay);
}
}
private static void EvaluateFunction(string name, FunctionArgs args)
catch (Exception ex)
{
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;
}
IsFailed = true;
_logger.LogWarning(ex, "Failed to initialize watermark element; will disable for this content");
}
}
public void Draw(
object context,
public ValueTask<Option<PreparedElementImage>> PrepareImage(
TimeSpan timeOfDay,
TimeSpan contentTime,
TimeSpan contentTotalTime,
TimeSpan channelTime,
CancellationToken cancellationToken)
{
if (context is not IImageProcessingContext imageProcessingContext)
float opacity = _opacity;
foreach (var expression in _maybeOpacityExpression)
{
return;
opacity = OpacityExpressionHelper.GetOpacity(
expression,
timeOfDay,
contentTime,
contentTotalTime,
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();
float opacity = Convert.ToSingle(expressionResult, CultureInfo.InvariantCulture);
if (opacity == 0)
{
return;
return ValueTask.FromResult(Option<PreparedElementImage>.None);
}
Image frameForTimestamp = GetFrameForTimestamp(contentTime);
imageProcessingContext.DrawImage(frameForTimestamp, _location, opacity);
return ValueTask.FromResult(Optional(new PreparedElementImage(frameForTimestamp, _location, opacity, false)));
}
private Image GetFrameForTimestamp(TimeSpan timestamp)
@@ -212,12 +183,12 @@ public class WatermarkElement : IGraphicsElement, IDisposable
return _scaledFrames.Last();
}
private static Point CalculatePosition(
internal static Point CalculatePosition(
WatermarkLocation location,
int frameWidth,
int frameHeight,
int scaledWidth,
int scaledHeight,
int imageWidth,
int imageHeight,
int horizontalMargin,
int verticalMargin)
{
@@ -225,62 +196,23 @@ public class WatermarkElement : IGraphicsElement, IDisposable
return location switch
{
WatermarkLocation.BottomLeft => new Point(horizontalMargin, frameHeight - scaledHeight - verticalMargin),
WatermarkLocation.BottomLeft => new Point(horizontalMargin, frameHeight - imageHeight - verticalMargin),
WatermarkLocation.TopLeft => new Point(horizontalMargin, verticalMargin),
WatermarkLocation.TopRight => new Point(frameWidth - scaledWidth - horizontalMargin, verticalMargin),
WatermarkLocation.TopMiddle => new Point((frameWidth - scaledWidth) / 2, verticalMargin),
WatermarkLocation.TopRight => new Point(frameWidth - imageWidth - horizontalMargin, verticalMargin),
WatermarkLocation.TopMiddle => new Point((frameWidth - imageWidth) / 2, verticalMargin),
WatermarkLocation.RightMiddle => new Point(
frameWidth - scaledWidth - horizontalMargin,
(frameHeight - scaledHeight) / 2),
frameWidth - imageWidth - horizontalMargin,
(frameHeight - imageHeight) / 2),
WatermarkLocation.BottomMiddle => new Point(
(frameWidth - scaledWidth) / 2,
frameHeight - scaledHeight - verticalMargin),
WatermarkLocation.LeftMiddle => new Point(horizontalMargin, (frameHeight - scaledHeight) / 2),
(frameWidth - imageWidth) / 2,
frameHeight - imageHeight - verticalMargin),
WatermarkLocation.LeftMiddle => new Point(horizontalMargin, (frameHeight - imageHeight) / 2),
_ => new Point(
frameWidth - scaledWidth - horizontalMargin,
frameHeight - scaledHeight - verticalMargin),
frameWidth - imageWidth - horizontalMargin,
frameHeight - imageHeight - verticalMargin),
};
}
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 void Dispose()
{
GC.SuppressFinalize(this);
@@ -369,6 +369,7 @@ public class TranscodingTests
now,
[],
GetWatermark(watermark),
[],
"drm",
VaapiDriver.RadeonSI,
"/dev/dri/renderD128",
@@ -648,6 +649,7 @@ public class TranscodingTests
now,
[],
channelWatermark,
[],
"drm",
VaapiDriver.RadeonSI,
"/dev/dri/renderD128",
@@ -29,6 +29,8 @@ public class TroubleshootController(
[FromQuery]
List<int> watermark,
[FromQuery]
List<int> graphicsElement,
[FromQuery]
int? subtitleId,
[FromQuery]
bool startFromBeginning,
@@ -37,7 +39,7 @@ public class TroubleshootController(
try
{
Either<BaseError, PlayoutItemResult> result = await mediator.Send(
new PrepareTroubleshootingPlayback(mediaItem, ffmpegProfile, watermark, subtitleId, startFromBeginning),
new PrepareTroubleshootingPlayback(mediaItem, ffmpegProfile, watermark, graphicsElement, subtitleId, startFromBeginning),
cancellationToken);
if (result.IsLeft)
@@ -115,11 +117,13 @@ public class TroubleshootController(
[FromQuery]
List<int> watermark,
[FromQuery]
List<int> graphicsElement,
[FromQuery]
bool startFromBeginning,
CancellationToken cancellationToken)
{
Option<string> maybeArchivePath = await mediator.Send(
new ArchiveTroubleshootingResults(mediaItem, ffmpegProfile, watermark, startFromBeginning),
new ArchiveTroubleshootingResults(mediaItem, ffmpegProfile, watermark, graphicsElement, startFromBeginning),
cancellationToken);
foreach (string archivePath in maybeArchivePath)
+39 -5
View File
@@ -1,5 +1,6 @@
@page "/system/troubleshooting/playback"
@using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.Graphics
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Troubleshooting
@using ErsatzTV.Application.Troubleshooting.Queries
@@ -54,6 +55,18 @@
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Subtitle</MudText>
</div>
<MudSelect @bind-Value="_subtitleId" For="@(() => _subtitleId)" Clearable="true">
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
@foreach (SubtitleViewModel subtitleStream in _subtitleStreams)
{
<MudSelectItem T="int?" Value="@subtitleStream.Id">@($"{subtitleStream.Id}: {subtitleStream.Language} - {subtitleStream.Title} ({subtitleStream.Codec})")</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Watermarks</MudText>
@@ -67,13 +80,12 @@
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Subtitle</MudText>
<MudText>Graphics Elements</MudText>
</div>
<MudSelect @bind-Value="_subtitleId" For="@(() => _subtitleId)" Clearable="true">
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
@foreach (SubtitleViewModel subtitleStream in _subtitleStreams)
<MudSelect T="string" @bind-SelectedValues="_graphicsElementNames" Clearable="true" MultiSelection="true">
@foreach (GraphicsElementViewModel graphicsElement in _graphicsElements)
{
<MudSelectItem T="int?" Value="@subtitleStream.Id">@($"{subtitleStream.Id}: {subtitleStream.Language} - {subtitleStream.Title} ({subtitleStream.Codec})")</MudSelectItem>
<MudSelectItem T="string" Value="@graphicsElement.Name">@graphicsElement.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
@@ -121,9 +133,11 @@
private readonly List<FFmpegProfileViewModel> _ffmpegProfiles = [];
private readonly List<WatermarkViewModel> _watermarks = [];
private readonly List<SubtitleViewModel> _subtitleStreams = [];
private readonly List<GraphicsElementViewModel> _graphicsElements = [];
private MediaItemInfo _info;
private int _ffmpegProfileId;
private IEnumerable<string> _watermarkNames = new System.Collections.Generic.HashSet<string>();
private IEnumerable<string> _graphicsElementNames = new System.Collections.Generic.HashSet<string>();
private int? _subtitleId;
private bool _startFromBeginning;
private bool _hasPlayed;
@@ -155,6 +169,9 @@
_watermarks.Clear();
_watermarks.AddRange(await Mediator.Send(new GetAllWatermarks(), _cts.Token));
_graphicsElements.Clear();
_graphicsElements.AddRange(await Mediator.Send(new GetAllGraphicsElements(), _cts.Token));
if (MediaItemId is not null)
{
await OnMediaItemIdChanged(MediaItemId);
@@ -175,6 +192,13 @@
uri.Query += $"&watermark={watermark.Id}";
}
}
foreach (var graphicsElementName in _graphicsElementNames)
{
foreach (var graphicsElement in _graphicsElements.Where(ge => ge.Name == graphicsElementName))
{
uri.Query += $"&graphicsElement={graphicsElement.Id}";
}
}
if (_subtitleId is not null)
{
uri.Query += $"&subtitleId={_subtitleId.Value}";
@@ -216,6 +240,7 @@
private async Task DownloadResults()
{
var uri = $"api/troubleshoot/playback/archive?mediaItem={MediaItemId ?? 0}&ffmpegProfile={_ffmpegProfileId}&startFromBeginning={_startFromBeginning}";
foreach (var watermarkName in _watermarkNames)
{
foreach (var watermark in _watermarks.Where(wm => wm.Name == watermarkName))
@@ -223,6 +248,15 @@
uri += $"&watermark={watermark.Id}";
}
}
foreach (var graphicsElementName in _graphicsElementNames)
{
foreach (var graphicsElement in _graphicsElements.Where(ge => ge.Name == graphicsElementName))
{
uri += $"&graphicsElement={graphicsElement.Id}";
}
}
await JsRuntime.InvokeVoidAsync("window.open", uri);
}
+6
View File
@@ -4,6 +4,7 @@ using Bugsnag;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Emby;
using ErsatzTV.Application.Graphics;
using ErsatzTV.Application.Jellyfin;
using ErsatzTV.Application.Maintenance;
using ErsatzTV.Application.MediaCollections;
@@ -128,6 +129,8 @@ public class SchedulerService : BackgroundService
await RefreshTraktLists(cancellationToken);
await MatchTraktLists(cancellationToken);
await RefreshGraphicsElements(cancellationToken);
await ReleaseMemory(cancellationToken);
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
@@ -367,6 +370,9 @@ public class SchedulerService : BackgroundService
}
}
private ValueTask RefreshGraphicsElements(CancellationToken cancellationToken) =>
_workerChannel.WriteAsync(new RefreshGraphicsElements(), cancellationToken);
private ValueTask DeleteOrphanedArtwork(CancellationToken cancellationToken) =>
_workerChannel.WriteAsync(new DeleteOrphanedArtwork(), cancellationToken);
+4
View File
@@ -3,6 +3,7 @@ using System.Threading.Channels;
using Bugsnag;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Graphics;
using ErsatzTV.Application.Maintenance;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.Playouts;
@@ -103,6 +104,9 @@ public class WorkerService : BackgroundService
case MatchTraktListItems matchTraktListItems:
await mediator.Send(matchTraktListItems, stoppingToken);
break;
case RefreshGraphicsElements refreshGraphicsElements:
await mediator.Send(refreshGraphicsElements, stoppingToken);
break;
#if !DEBUG_NO_SYNC
case ExtractEmbeddedSubtitles extractEmbeddedSubtitles:
await mediator.Send(extractEmbeddedSubtitles, stoppingToken);
+2
View File
@@ -336,6 +336,8 @@ public class Startup
FileSystemLayout.MusicVideoCreditsTemplatesFolder,
FileSystemLayout.ChannelStreamSelectorsFolder,
FileSystemLayout.ChannelGuideTemplatesFolder,
FileSystemLayout.GraphicsElementsTemplatesFolder,
FileSystemLayout.GraphicsElementsTextTemplatesFolder,
FileSystemLayout.ScriptsFolder,
FileSystemLayout.MultiEpisodeShuffleTemplatesFolder,
FileSystemLayout.AudioStreamSelectorScriptsFolder