Browse Source

add text graphics element to playback troubleshooting (#2282)

* refactor graphics engine; async frame generation

* add text graphics element to playback troubleshooting
pull/2283/head
Jason Dove 12 months ago committed by GitHub
parent
commit
76a589b538
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings
  3. 3
      ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElements.cs
  4. 53
      ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElementsHandler.cs
  5. 3
      ErsatzTV.Application/Graphics/GraphicsElementViewModel.cs
  6. 16
      ErsatzTV.Application/Graphics/Mapper.cs
  7. 3
      ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElements.cs
  8. 19
      ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsHandler.cs
  9. 4
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  10. 1
      ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs
  11. 1
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs
  12. 5
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  13. 10
      ErsatzTV.Core/Domain/GraphicsElement.cs
  14. 7
      ErsatzTV.Core/Domain/GraphicsElementKind.cs
  15. 2
      ErsatzTV.Core/Domain/PlayoutItem.cs
  16. 9
      ErsatzTV.Core/Domain/PlayoutItemGraphicsElement.cs
  17. 2
      ErsatzTV.Core/Domain/PlayoutItemWatermark.cs
  18. 55
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  19. 6
      ErsatzTV.Core/FileSystemLayout.cs
  20. 61
      ErsatzTV.Core/Graphics/TextGraphicsElement.cs
  21. 1
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  22. 3
      ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs
  23. 6274
      ErsatzTV.Infrastructure.MySql/Migrations/20250808162048_Add_PlayoutItemGraphicsElement.Designer.cs
  24. 71
      ErsatzTV.Infrastructure.MySql/Migrations/20250808162048_Add_PlayoutItemGraphicsElement.cs
  25. 60
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  26. 6109
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250808162239_Add_PlayoutItemGraphicsElement.Designer.cs
  27. 67
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250808162239_Add_PlayoutItemGraphicsElement.cs
  28. 58
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  29. 10
      ErsatzTV.Infrastructure/Data/Configurations/GraphicsElementConfiguration.cs
  30. 13
      ErsatzTV.Infrastructure/Data/Configurations/PlayoutItemConfiguration.cs
  31. 1
      ErsatzTV.Infrastructure/Data/TvContext.cs
  32. 55
      ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs
  33. 56
      ErsatzTV.Infrastructure/Streaming/GraphicsEngineFonts.cs
  34. 5
      ErsatzTV.Infrastructure/Streaming/IGraphicsElement.cs
  35. 101
      ErsatzTV.Infrastructure/Streaming/OpacityExpressionHelper.cs
  36. 6
      ErsatzTV.Infrastructure/Streaming/PreparedElementImage.cs
  37. 118
      ErsatzTV.Infrastructure/Streaming/TextElement.cs
  38. 268
      ErsatzTV.Infrastructure/Streaming/WatermarkElement.cs
  39. 2
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  40. 8
      ErsatzTV/Controllers/Api/TroubleshootController.cs
  41. 44
      ErsatzTV/Pages/PlaybackTroubleshooting.razor
  42. 6
      ErsatzTV/Services/SchedulerService.cs
  43. 4
      ErsatzTV/Services/WorkerService.cs
  44. 2
      ErsatzTV/Startup.cs

6
CHANGELOG.md

@ -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)` - `LinearFadePoints(time, start, peakStart, peakEnd, end)`
- Add `Z-Index` to watermark editor - Add `Z-Index` to watermark editor
- The graphics engine will order by z-index when overlaying watermarks - 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
- Fix database operations that were slowing down playout builds - Fix database operations that were slowing down playout builds

2
ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings

@ -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/=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_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/=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_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/=hdhr_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=health_005Cqueries/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=health_005Cqueries/@EntryIndexedValue">True</s:Boolean>

3
ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElements.cs

@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Graphics;
public record RefreshGraphicsElements : IRequest, IBackgroundServiceRequest;

53
ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElementsHandler.cs

@ -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);
}
}

3
ErsatzTV.Application/Graphics/GraphicsElementViewModel.cs

@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Graphics;
public record GraphicsElementViewModel(int Id, string Name);

16
ErsatzTV.Application/Graphics/Mapper.cs

@ -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)
};
}
}

3
ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElements.cs

@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Graphics;
public record GetAllGraphicsElements : IRequest<List<GraphicsElementViewModel>>;

19
ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsHandler.cs

@ -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());
}
}

4
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -88,6 +88,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ThenInclude(p => p.Deco) .ThenInclude(p => p.Deco)
.ThenInclude(d => d.Watermark) .ThenInclude(d => d.Watermark)
// get graphics elements
.Include(i => i.GraphicsElements)
// get playout templates (and deco templates/decos) // get playout templates (and deco templates/decos)
.Include(i => i.Playout) .Include(i => i.Playout)
.ThenInclude(p => p.Templates) .ThenInclude(p => p.Templates)
@ -341,6 +344,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
effectiveNow, effectiveNow,
playoutItemWatermarks, playoutItemWatermarks,
maybeGlobalWatermark, maybeGlobalWatermark,
playoutItemWithPath.PlayoutItem.GraphicsElements,
channel.FFmpegProfile.VaapiDisplay, channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver, channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice, channel.FFmpegProfile.VaapiDevice,

1
ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs

@ -4,5 +4,6 @@ public record ArchiveTroubleshootingResults(
int MediaItemId, int MediaItemId,
int FFmpegProfileId, int FFmpegProfileId,
List<int> WatermarkIds, List<int> WatermarkIds,
List<int> GraphicsElementIds,
bool StartFromBeginning) bool StartFromBeginning)
: IRequest<Option<string>>; : IRequest<Option<string>>;

1
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs

@ -7,6 +7,7 @@ public record PrepareTroubleshootingPlayback(
int MediaItemId, int MediaItemId,
int FFmpegProfileId, int FFmpegProfileId,
List<int> WatermarkIds, List<int> WatermarkIds,
List<int> GraphicsElementIds,
int? SubtitleId, int? SubtitleId,
bool StartFromBeginning) bool StartFromBeginning)
: IRequest<Either<BaseError, PlayoutItemResult>>; : IRequest<Either<BaseError, PlayoutItemResult>>;

5
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs

@ -113,6 +113,10 @@ public class PrepareTroubleshootingPlaybackHandler(
outPoint = inPoint + duration; outPoint = inPoint + duration;
} }
var graphicsElements = await dbContext.GraphicsElements
.Where(ge => request.GraphicsElementIds.Contains(ge.Id))
.ToListAsync();
PlayoutItemResult playoutItemResult = await ffmpegProcessService.ForPlayoutItem( PlayoutItemResult playoutItemResult = await ffmpegProcessService.ForPlayoutItem(
ffmpegPath, ffmpegPath,
ffprobePath, ffprobePath,
@ -141,6 +145,7 @@ public class PrepareTroubleshootingPlaybackHandler(
now, now,
watermarks, watermarks,
Option<ChannelWatermark>.None, Option<ChannelWatermark>.None,
graphicsElements,
ffmpegProfile.VaapiDisplay, ffmpegProfile.VaapiDisplay,
ffmpegProfile.VaapiDriver, ffmpegProfile.VaapiDriver,
ffmpegProfile.VaapiDevice, ffmpegProfile.VaapiDevice,

10
ErsatzTV.Core/Domain/GraphicsElement.cs

@ -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; }
}

7
ErsatzTV.Core/Domain/GraphicsElementKind.cs

@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum GraphicsElementKind
{
Image = 0,
Text = 1
}

2
ErsatzTV.Core/Domain/PlayoutItem.cs

@ -32,6 +32,8 @@ public class PlayoutItem
public string CollectionKey { get; set; } public string CollectionKey { get; set; }
public string CollectionEtag { get; set; } public string CollectionEtag { get; set; }
public List<PlayoutItemWatermark> PlayoutItemWatermarks { 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 StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime();
public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime(); public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime();

9
ErsatzTV.Core/Domain/PlayoutItemGraphicsElement.cs

@ -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; }
}

2
ErsatzTV.Core/Domain/PlayoutItemWatermark.cs

@ -4,6 +4,6 @@ public class PlayoutItemWatermark
{ {
public int PlayoutItemId { get; set; } public int PlayoutItemId { get; set; }
public PlayoutItem PlayoutItem { get; set; } public PlayoutItem PlayoutItem { get; set; }
public ChannelWatermark Watermark { get; set; }
public int? WatermarkId { get; set; } public int? WatermarkId { get; set; }
public ChannelWatermark Watermark { get; set; }
} }

55
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -2,6 +2,7 @@
using CliWrap; using CliWrap;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Streaming;
@ -64,6 +65,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
DateTimeOffset now, DateTimeOffset now,
List<ChannelWatermark> playoutItemWatermarks, List<ChannelWatermark> playoutItemWatermarks,
Option<ChannelWatermark> globalWatermark, Option<ChannelWatermark> globalWatermark,
List<GraphicsElement> graphicsElements,
string vaapiDisplay, string vaapiDisplay,
VaapiDriver vaapiDriver, VaapiDriver vaapiDriver,
string vaapiDevice, string vaapiDevice,
@ -324,6 +326,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<WatermarkInputFile> watermarkInputFile = Option<WatermarkInputFile>.None; Option<WatermarkInputFile> watermarkInputFile = Option<WatermarkInputFile>.None;
Option<GraphicsEngineInput> graphicsEngineInput = Option<GraphicsEngineInput>.None; Option<GraphicsEngineInput> graphicsEngineInput = Option<GraphicsEngineInput>.None;
Option<GraphicsEngineContext> graphicsEngineContext = Option<GraphicsEngineContext>.None; Option<GraphicsEngineContext> graphicsEngineContext = Option<GraphicsEngineContext>.None;
List<GraphicsElementContext> graphicsElementContexts = new List<GraphicsElementContext>();
// use graphics engine for all watermarks // use graphics engine for all watermarks
if (!disableWatermarks) if (!disableWatermarks)
@ -368,22 +371,50 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
} }
} }
// only use graphics engine when we have watermarks graphicsElementContexts.AddRange(watermarks.Values);
if (watermarks.Count > 0) }
foreach (var graphicsElement in graphicsElements)
{
switch (graphicsElement.Kind)
{ {
graphicsEngineInput = new GraphicsEngineInput(); case GraphicsElementKind.Text:
var maybeElement = await TextGraphicsElement.FromFile(graphicsElement.Path);
graphicsEngineContext = new GraphicsEngineContext( if (maybeElement.IsNone)
watermarks.Values.OfType<GraphicsElementContext>().ToList(), {
channel.FFmpegProfile.Resolution, _logger.LogWarning(
await playbackSettings.FrameRate.IfNoneAsync(24), "Failed to load text graphics element from file {Path}; ignoring",
ChannelStartTime: channelStartTime, graphicsElement.Path);
ContentStartTime: start, }
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero),
finish - now); 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(
graphicsElementContexts,
channel.FFmpegProfile.Resolution,
await playbackSettings.FrameRate.IfNoneAsync(24),
ChannelStartTime: channelStartTime,
ContentStartTime: start,
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero),
finish - now);
}
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, fillerKind); HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, fillerKind);
string videoFormat = GetVideoFormat(playbackSettings); string videoFormat = GetVideoFormat(playbackSettings);

6
ErsatzTV.Core/FileSystemLayout.cs

@ -47,6 +47,9 @@ public static class FileSystemLayout
public static readonly string ChannelGuideTemplatesFolder; public static readonly string ChannelGuideTemplatesFolder;
public static readonly string GraphicsElementsTemplatesFolder;
public static readonly string GraphicsElementsTextTemplatesFolder;
public static readonly string ScriptsFolder; public static readonly string ScriptsFolder;
public static readonly string MultiEpisodeShuffleTemplatesFolder; public static readonly string MultiEpisodeShuffleTemplatesFolder;
@ -162,6 +165,9 @@ public static class FileSystemLayout
ChannelGuideTemplatesFolder = Path.Combine(TemplatesFolder, "channel-guide"); ChannelGuideTemplatesFolder = Path.Combine(TemplatesFolder, "channel-guide");
GraphicsElementsTemplatesFolder = Path.Combine(TemplatesFolder, "graphics-elements");
GraphicsElementsTextTemplatesFolder = Path.Combine(GraphicsElementsTemplatesFolder, "text");
ScriptsFolder = Path.Combine(AppDataFolder, "scripts"); ScriptsFolder = Path.Combine(AppDataFolder, "scripts");
MultiEpisodeShuffleTemplatesFolder = Path.Combine(ScriptsFolder, "multi-episode-shuffle"); MultiEpisodeShuffleTemplatesFolder = Path.Combine(ScriptsFolder, "multi-episode-shuffle");

61
ErsatzTV.Core/Graphics/TextGraphicsElement.cs

@ -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;
}
}
}

1
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs

@ -28,6 +28,7 @@ public interface IFFmpegProcessService
DateTimeOffset now, DateTimeOffset now,
List<ChannelWatermark> playoutItemWatermarks, List<ChannelWatermark> playoutItemWatermarks,
Option<ChannelWatermark> globalWatermark, Option<ChannelWatermark> globalWatermark,
List<GraphicsElement> graphicsElements,
string vaapiDisplay, string vaapiDisplay,
VaapiDriver vaapiDriver, VaapiDriver vaapiDriver,
string vaapiDevice, string vaapiDevice,

3
ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs

@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Graphics;
namespace ErsatzTV.Core.Interfaces.Streaming; namespace ErsatzTV.Core.Interfaces.Streaming;
@ -15,3 +16,5 @@ public record GraphicsEngineContext(
public abstract record GraphicsElementContext; public abstract record GraphicsElementContext;
public record WatermarkElementContext(WatermarkOptions Options) : GraphicsElementContext; public record WatermarkElementContext(WatermarkOptions Options) : GraphicsElementContext;
public record TextElementContext(TextGraphicsElement TextElement) : GraphicsElementContext;

6274
ErsatzTV.Infrastructure.MySql/Migrations/20250808162048_Add_PlayoutItemGraphicsElement.Designer.cs generated

File diff suppressed because it is too large Load Diff

71
ErsatzTV.Infrastructure.MySql/Migrations/20250808162048_Add_PlayoutItemGraphicsElement.cs

@ -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");
}
}
}

60
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -849,6 +849,25 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.ToTable("Genre"); 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 => modelBuilder.Entity("ErsatzTV.Core.Domain.ImageFolderDuration", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -1894,6 +1913,21 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.ToTable("PlayoutItem", (string)null); 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 => modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
{ {
b.Property<int>("PlayoutItemId") b.Property<int>("PlayoutItemId")
@ -4635,6 +4669,25 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Playout"); 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 => modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
{ {
b.HasOne("ErsatzTV.Core.Domain.PlayoutItem", "PlayoutItem") b.HasOne("ErsatzTV.Core.Domain.PlayoutItem", "PlayoutItem")
@ -5806,6 +5859,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Writers"); b.Navigation("Writers");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.GraphicsElement", b =>
{
b.Navigation("PlayoutItemGraphicsElements");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b => modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
{ {
b.Navigation("Actors"); b.Navigation("Actors");
@ -5962,6 +6020,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{ {
b.Navigation("PlayoutItemGraphicsElements");
b.Navigation("PlayoutItemWatermarks"); b.Navigation("PlayoutItemWatermarks");
}); });

6109
ErsatzTV.Infrastructure.Sqlite/Migrations/20250808162239_Add_PlayoutItemGraphicsElement.Designer.cs generated

File diff suppressed because it is too large Load Diff

67
ErsatzTV.Infrastructure.Sqlite/Migrations/20250808162239_Add_PlayoutItemGraphicsElement.cs

@ -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");
}
}
}

58
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -814,6 +814,23 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.ToTable("Genre"); 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 => modelBuilder.Entity("ErsatzTV.Core.Domain.ImageFolderDuration", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -1805,6 +1822,21 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.ToTable("PlayoutItem", (string)null); 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 => modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
{ {
b.Property<int>("PlayoutItemId") b.Property<int>("PlayoutItemId")
@ -4472,6 +4504,25 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Playout"); 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 => modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
{ {
b.HasOne("ErsatzTV.Core.Domain.PlayoutItem", "PlayoutItem") b.HasOne("ErsatzTV.Core.Domain.PlayoutItem", "PlayoutItem")
@ -5643,6 +5694,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Writers"); b.Navigation("Writers");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.GraphicsElement", b =>
{
b.Navigation("PlayoutItemGraphicsElements");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b => modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
{ {
b.Navigation("Actors"); b.Navigation("Actors");
@ -5799,6 +5855,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{ {
b.Navigation("PlayoutItemGraphicsElements");
b.Navigation("PlayoutItemWatermarks"); b.Navigation("PlayoutItemWatermarks");
}); });

10
ErsatzTV.Infrastructure/Data/Configurations/GraphicsElementConfiguration.cs

@ -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");
}

13
ErsatzTV.Infrastructure/Data/Configurations/PlayoutItemConfiguration.cs

@ -28,5 +28,18 @@ public class PlayoutItemConfiguration : IEntityTypeConfiguration<PlayoutItem>
.HasForeignKey(ci => ci.PlayoutItemId) .HasForeignKey(ci => ci.PlayoutItemId)
.OnDelete(DeleteBehavior.Cascade), .OnDelete(DeleteBehavior.Cascade),
j => j.HasKey(ci => new { ci.PlayoutItemId, ci.WatermarkId })); 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 }));
} }
} }

1
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -113,6 +113,7 @@ public class TvContext : DbContext
public DbSet<TraktList> TraktLists { get; set; } public DbSet<TraktList> TraktLists { get; set; }
public DbSet<FillerPreset> FillerPresets { get; set; } public DbSet<FillerPreset> FillerPresets { get; set; }
public DbSet<Subtitle> Subtitles { get; set; } public DbSet<Subtitle> Subtitles { get; set; }
public DbSet<GraphicsElement> GraphicsElements { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
optionsBuilder.UseLoggerFactory(_loggerFactory); optionsBuilder.UseLoggerFactory(_loggerFactory);

55
ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs

@ -1,4 +1,5 @@
using System.IO.Pipelines; using System.IO.Pipelines;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Streaming;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
@ -11,18 +12,23 @@ public class GraphicsEngine(ILogger<GraphicsEngine> logger) : IGraphicsEngine
{ {
public async Task Run(GraphicsEngineContext context, PipeWriter pipeWriter, CancellationToken cancellationToken) public async Task Run(GraphicsEngineContext context, PipeWriter pipeWriter, CancellationToken cancellationToken)
{ {
GraphicsEngineFonts.LoadFonts(FileSystemLayout.FontsCacheFolder);
var elements = new List<IGraphicsElement>(); var elements = new List<IGraphicsElement>();
foreach (var element in context.Elements) foreach (var element in context.Elements)
{ {
switch (element) switch (element)
{ {
case WatermarkElementContext watermarkElementContext: case WatermarkElementContext watermarkElementContext:
var watermark = new WatermarkElement(watermarkElementContext.Options); var watermark = new WatermarkElement(watermarkElementContext.Options, logger);
if (watermark.IsValid) if (watermark.IsValid)
{ {
elements.Add(watermark); elements.Add(watermark);
} }
break;
case TextElementContext textElementContext:
elements.Add(new TextElement(textElementContext.TextElement, logger));
break; break;
} }
} }
@ -58,30 +64,39 @@ public class GraphicsEngine(ILogger<GraphicsEngine> logger) : IGraphicsEngine
context.FrameSize.Height, context.FrameSize.Height,
Color.Transparent); Color.Transparent);
// 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
{
var maybePreparedImage = await element.PrepareImage(
frameTime.TimeOfDay,
contentTime,
contentTotalTime,
channelTime,
cancellationToken);
preparedElementImages.AddRange(maybePreparedImage);
}
catch (Exception ex)
{
element.IsFailed = true;
logger.LogWarning(ex,
"Failed to draw graphics element of type {Type}; will disable for this content",
element.GetType().Name);
}
}
// draw each element // draw each element
outputFrame.Mutate(ctx => outputFrame.Mutate(ctx =>
{ {
foreach (var element in elements.OrderBy(e => e.ZIndex)) foreach (var preparedImage in preparedElementImages)
{ {
try ctx.DrawImage(preparedImage.Image, preparedImage.Point, preparedImage.Opacity);
{ if (preparedImage.Dispose)
if (!element.IsFailed)
{
element.Draw(
ctx,
frameTime.TimeOfDay,
contentTime,
contentTotalTime,
channelTime,
cancellationToken);
}
}
catch (Exception ex)
{ {
element.IsFailed = true; preparedImage.Image.Dispose();
logger.LogWarning(ex,
"Failed to draw graphics element of type {Type}; will disable for this content",
element.GetType().Name);
} }
} }
}); });

56
ErsatzTV.Infrastructure/Streaming/GraphicsEngineFonts.cs

@ -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);
}
}

5
ErsatzTV.Core/Interfaces/Streaming/IGraphicsElement.cs → ErsatzTV.Infrastructure/Streaming/IGraphicsElement.cs

@ -1,6 +1,6 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Streaming; namespace ErsatzTV.Infrastructure.Streaming;
public interface IGraphicsElement public interface IGraphicsElement
{ {
@ -10,8 +10,7 @@ public interface IGraphicsElement
Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken); Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken);
void Draw( ValueTask<Option<PreparedElementImage>> PrepareImage(
object context,
TimeSpan timeOfDay, TimeSpan timeOfDay,
TimeSpan contentTime, TimeSpan contentTime,
TimeSpan contentTotalTime, TimeSpan contentTotalTime,

101
ErsatzTV.Infrastructure/Streaming/OpacityExpressionHelper.cs

@ -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);
}
}

6
ErsatzTV.Infrastructure/Streaming/PreparedElementImage.cs

@ -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);

118
ErsatzTV.Infrastructure/Streaming/TextElement.cs

@ -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;
}
}

268
ErsatzTV.Infrastructure/Streaming/WatermarkElement.cs

@ -1,10 +1,8 @@
using System.Globalization;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg.State; using ErsatzTV.FFmpeg.State;
using Microsoft.Extensions.Logging;
using NCalc; using NCalc;
using NCalc.Handlers;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Gif; using SixLabors.ImageSharp.Formats.Gif;
using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing;
@ -14,18 +12,21 @@ namespace ErsatzTV.Infrastructure.Streaming;
public class WatermarkElement : IGraphicsElement, IDisposable public class WatermarkElement : IGraphicsElement, IDisposable
{ {
private readonly ILogger _logger;
private readonly string _imagePath; private readonly string _imagePath;
private readonly ChannelWatermark _watermark; private readonly ChannelWatermark _watermark;
private readonly List<Image> _scaledFrames = []; private readonly List<Image> _scaledFrames = [];
private readonly List<double> _frameDelays = []; private readonly List<double> _frameDelays = [];
private Expression _expression; private Option<Expression> _maybeOpacityExpression;
private float _opacity;
private double _animatedDurationSeconds; private double _animatedDurationSeconds;
private Image _sourceImage; private Image _sourceImage;
private Point _location; private Point _location;
public WatermarkElement(WatermarkOptions watermarkOptions) public WatermarkElement(WatermarkOptions watermarkOptions, ILogger logger)
{ {
_logger = logger;
// TODO: better model coming in here? // TODO: better model coming in here?
foreach (var imagePath in watermarkOptions.ImagePath) foreach (var imagePath in watermarkOptions.ImagePath)
{ {
@ -47,147 +48,117 @@ public class WatermarkElement : IGraphicsElement, IDisposable
public async Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken) public async Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken)
{ {
if (_watermark.Mode is ChannelWatermarkMode.Intermittent) try
{ {
string expressionString = $@" if (_watermark.Mode is ChannelWatermarkMode.Intermittent)
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < 1, {
(time_of_day_seconds % {_watermark.FrequencyMinutes * 60}), string expressionString = $@"
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < {1 + _watermark.DurationSeconds}, if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < 1,
1, (time_of_day_seconds % {_watermark.FrequencyMinutes * 60}),
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < {1 + _watermark.DurationSeconds + 1}, if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < {1 + _watermark.DurationSeconds},
1 - ((time_of_day_seconds % {_watermark.FrequencyMinutes * 60} - {1 + _watermark.DurationSeconds}) / 1), 1,
0 if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < {1 + _watermark.DurationSeconds + 1},
1 - ((time_of_day_seconds % {_watermark.FrequencyMinutes * 60} - {1 + _watermark.DurationSeconds}) / 1),
0
)
) )
) )";
)"; _maybeOpacityExpression = new Expression(expressionString);
_expression = new Expression(expressionString); }
} else if (_watermark.Mode is ChannelWatermarkMode.OpacityExpression && !string.IsNullOrWhiteSpace(_watermark.OpacityExpression))
else if (_watermark.Mode is ChannelWatermarkMode.OpacityExpression && !string.IsNullOrWhiteSpace(_watermark.OpacityExpression)) {
{ _maybeOpacityExpression = new Expression(_watermark.OpacityExpression);
_expression = new Expression(_watermark.OpacityExpression); }
} else
else {
{ _opacity = _watermark.Opacity / 100.0f;
float opacity = _watermark.Opacity / 100.0f; }
_expression = new Expression(opacity.ToString(CultureInfo.InvariantCulture));
}
_expression.EvaluateFunction += EvaluateFunction;
bool isRemoteUri = Uri.TryCreate(_imagePath, UriKind.Absolute, out var uriResult)
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
if (isRemoteUri)
{
using var client = new HttpClient();
await using Stream imageStream = await client.GetStreamAsync(uriResult, cancellationToken);
_sourceImage = await Image.LoadAsync(imageStream, cancellationToken);
}
else
{
_sourceImage = await Image.LoadAsync(_imagePath!, cancellationToken);
}
int scaledWidth = _sourceImage.Width;
int scaledHeight = _sourceImage.Height;
if (_watermark.Size == WatermarkSize.Scaled)
{
scaledWidth = (int)Math.Round(_watermark.WidthPercent / 100.0 * frameSize.Width);
double aspectRatio = (double)_sourceImage.Height / _sourceImage.Width;
scaledHeight = (int)(scaledWidth * aspectRatio);
}
int horizontalMargin = (int)Math.Round(_watermark.HorizontalMarginPercent / 100.0 * frameSize.Width); foreach (var expression in _maybeOpacityExpression)
int verticalMargin = (int)Math.Round(_watermark.VerticalMarginPercent / 100.0 * frameSize.Height); {
expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction;
}
_location = CalculatePosition( bool isRemoteUri = Uri.TryCreate(_imagePath, UriKind.Absolute, out var uriResult)
_watermark.Location, && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
frameSize.Width,
frameSize.Height,
scaledWidth,
scaledHeight,
horizontalMargin,
verticalMargin);
_animatedDurationSeconds = 0; if (isRemoteUri)
{
using var client = new HttpClient();
await using Stream imageStream = await client.GetStreamAsync(uriResult, cancellationToken);
_sourceImage = await Image.LoadAsync(imageStream, cancellationToken);
}
else
{
_sourceImage = await Image.LoadAsync(_imagePath!, cancellationToken);
}
for (int i = 0; i < _sourceImage.Frames.Count; i++) int scaledWidth = _sourceImage.Width;
{ int scaledHeight = _sourceImage.Height;
var frame = _sourceImage.Frames.CloneFrame(i); if (_watermark.Size == WatermarkSize.Scaled)
frame.Mutate(ctx => ctx.Resize(scaledWidth, scaledHeight)); {
_scaledFrames.Add(frame); scaledWidth = (int)Math.Round(_watermark.WidthPercent / 100.0 * frameSize.Width);
double aspectRatio = (double)_sourceImage.Height / _sourceImage.Width;
scaledHeight = (int)(scaledWidth * aspectRatio);
}
var frameDelay = _sourceImage.Frames[i].Metadata.GetFormatMetadata(GifFormat.Instance).FrameDelay / 100.0; int horizontalMargin = (int)Math.Round(_watermark.HorizontalMarginPercent / 100.0 * frameSize.Width);
_animatedDurationSeconds += frameDelay; int verticalMargin = (int)Math.Round(_watermark.VerticalMarginPercent / 100.0 * frameSize.Height);
_frameDelays.Add(frameDelay);
}
}
private static void EvaluateFunction(string name, FunctionArgs args) _location = CalculatePosition(
{ _watermark.Location,
switch (name) frameSize.Width,
{ frameSize.Height,
case "LinearFadePoints": scaledWidth,
{ scaledHeight,
if (args.Parameters.Length != 5) horizontalMargin,
{ verticalMargin);
throw new ArgumentException("LinearFadePoints() requires 5 arguments.");
}
double time = Convert.ToDouble(args.Parameters[0].Evaluate(), CultureInfo.CurrentCulture); _animatedDurationSeconds = 0;
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); for (int i = 0; i < _sourceImage.Frames.Count; i++)
break;
}
case "LinearFadeDuration":
{ {
if (args.Parameters.Length != 4) var frame = _sourceImage.Frames.CloneFrame(i);
{ frame.Mutate(ctx => ctx.Resize(scaledWidth, scaledHeight));
throw new ArgumentException("LinearFadeDuration() requires 4 arguments."); _scaledFrames.Add(frame);
}
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); var frameDelay = _sourceImage.Frames[i].Metadata.GetFormatMetadata(GifFormat.Instance).FrameDelay / 100.0;
break; _animatedDurationSeconds += frameDelay;
_frameDelays.Add(frameDelay);
} }
} }
catch (Exception ex)
{
IsFailed = true;
_logger.LogWarning(ex, "Failed to initialize watermark element; will disable for this content");
}
} }
public void Draw( public ValueTask<Option<PreparedElementImage>> PrepareImage(
object context,
TimeSpan timeOfDay, TimeSpan timeOfDay,
TimeSpan contentTime, TimeSpan contentTime,
TimeSpan contentTotalTime, TimeSpan contentTotalTime,
TimeSpan channelTime, TimeSpan channelTime,
CancellationToken cancellationToken) 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) if (opacity == 0)
{ {
return; return ValueTask.FromResult(Option<PreparedElementImage>.None);
} }
Image frameForTimestamp = GetFrameForTimestamp(contentTime); Image frameForTimestamp = GetFrameForTimestamp(contentTime);
imageProcessingContext.DrawImage(frameForTimestamp, _location, opacity); return ValueTask.FromResult(Optional(new PreparedElementImage(frameForTimestamp, _location, opacity, false)));
} }
private Image GetFrameForTimestamp(TimeSpan timestamp) private Image GetFrameForTimestamp(TimeSpan timestamp)
@ -212,12 +183,12 @@ public class WatermarkElement : IGraphicsElement, IDisposable
return _scaledFrames.Last(); return _scaledFrames.Last();
} }
private static Point CalculatePosition( internal static Point CalculatePosition(
WatermarkLocation location, WatermarkLocation location,
int frameWidth, int frameWidth,
int frameHeight, int frameHeight,
int scaledWidth, int imageWidth,
int scaledHeight, int imageHeight,
int horizontalMargin, int horizontalMargin,
int verticalMargin) int verticalMargin)
{ {
@ -225,62 +196,23 @@ public class WatermarkElement : IGraphicsElement, IDisposable
return location switch 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.TopLeft => new Point(horizontalMargin, verticalMargin),
WatermarkLocation.TopRight => new Point(frameWidth - scaledWidth - horizontalMargin, verticalMargin), WatermarkLocation.TopRight => new Point(frameWidth - imageWidth - horizontalMargin, verticalMargin),
WatermarkLocation.TopMiddle => new Point((frameWidth - scaledWidth) / 2, verticalMargin), WatermarkLocation.TopMiddle => new Point((frameWidth - imageWidth) / 2, verticalMargin),
WatermarkLocation.RightMiddle => new Point( WatermarkLocation.RightMiddle => new Point(
frameWidth - scaledWidth - horizontalMargin, frameWidth - imageWidth - horizontalMargin,
(frameHeight - scaledHeight) / 2), (frameHeight - imageHeight) / 2),
WatermarkLocation.BottomMiddle => new Point( WatermarkLocation.BottomMiddle => new Point(
(frameWidth - scaledWidth) / 2, (frameWidth - imageWidth) / 2,
frameHeight - scaledHeight - verticalMargin), frameHeight - imageHeight - verticalMargin),
WatermarkLocation.LeftMiddle => new Point(horizontalMargin, (frameHeight - scaledHeight) / 2), WatermarkLocation.LeftMiddle => new Point(horizontalMargin, (frameHeight - imageHeight) / 2),
_ => new Point( _ => new Point(
frameWidth - scaledWidth - horizontalMargin, frameWidth - imageWidth - horizontalMargin,
frameHeight - scaledHeight - verticalMargin), 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() public void Dispose()
{ {
GC.SuppressFinalize(this); GC.SuppressFinalize(this);

2
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -369,6 +369,7 @@ public class TranscodingTests
now, now,
[], [],
GetWatermark(watermark), GetWatermark(watermark),
[],
"drm", "drm",
VaapiDriver.RadeonSI, VaapiDriver.RadeonSI,
"/dev/dri/renderD128", "/dev/dri/renderD128",
@ -648,6 +649,7 @@ public class TranscodingTests
now, now,
[], [],
channelWatermark, channelWatermark,
[],
"drm", "drm",
VaapiDriver.RadeonSI, VaapiDriver.RadeonSI,
"/dev/dri/renderD128", "/dev/dri/renderD128",

8
ErsatzTV/Controllers/Api/TroubleshootController.cs

@ -29,6 +29,8 @@ public class TroubleshootController(
[FromQuery] [FromQuery]
List<int> watermark, List<int> watermark,
[FromQuery] [FromQuery]
List<int> graphicsElement,
[FromQuery]
int? subtitleId, int? subtitleId,
[FromQuery] [FromQuery]
bool startFromBeginning, bool startFromBeginning,
@ -37,7 +39,7 @@ public class TroubleshootController(
try try
{ {
Either<BaseError, PlayoutItemResult> result = await mediator.Send( Either<BaseError, PlayoutItemResult> result = await mediator.Send(
new PrepareTroubleshootingPlayback(mediaItem, ffmpegProfile, watermark, subtitleId, startFromBeginning), new PrepareTroubleshootingPlayback(mediaItem, ffmpegProfile, watermark, graphicsElement, subtitleId, startFromBeginning),
cancellationToken); cancellationToken);
if (result.IsLeft) if (result.IsLeft)
@ -115,11 +117,13 @@ public class TroubleshootController(
[FromQuery] [FromQuery]
List<int> watermark, List<int> watermark,
[FromQuery] [FromQuery]
List<int> graphicsElement,
[FromQuery]
bool startFromBeginning, bool startFromBeginning,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Option<string> maybeArchivePath = await mediator.Send( Option<string> maybeArchivePath = await mediator.Send(
new ArchiveTroubleshootingResults(mediaItem, ffmpegProfile, watermark, startFromBeginning), new ArchiveTroubleshootingResults(mediaItem, ffmpegProfile, watermark, graphicsElement, startFromBeginning),
cancellationToken); cancellationToken);
foreach (string archivePath in maybeArchivePath) foreach (string archivePath in maybeArchivePath)

44
ErsatzTV/Pages/PlaybackTroubleshooting.razor

@ -1,5 +1,6 @@
@page "/system/troubleshooting/playback" @page "/system/troubleshooting/playback"
@using ErsatzTV.Application.FFmpegProfiles @using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.Graphics
@using ErsatzTV.Application.MediaItems @using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Troubleshooting @using ErsatzTV.Application.Troubleshooting
@using ErsatzTV.Application.Troubleshooting.Queries @using ErsatzTV.Application.Troubleshooting.Queries
@ -54,6 +55,18 @@
} }
</MudSelect> </MudSelect>
</MudStack> </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"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"> <div class="d-flex">
<MudText>Watermarks</MudText> <MudText>Watermarks</MudText>
@ -67,13 +80,12 @@
</MudStack> </MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"> <div class="d-flex">
<MudText>Subtitle</MudText> <MudText>Graphics Elements</MudText>
</div> </div>
<MudSelect @bind-Value="_subtitleId" For="@(() => _subtitleId)" Clearable="true"> <MudSelect T="string" @bind-SelectedValues="_graphicsElementNames" Clearable="true" MultiSelection="true">
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem> @foreach (GraphicsElementViewModel graphicsElement in _graphicsElements)
@foreach (SubtitleViewModel subtitleStream in _subtitleStreams)
{ {
<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> </MudSelect>
</MudStack> </MudStack>
@ -121,9 +133,11 @@
private readonly List<FFmpegProfileViewModel> _ffmpegProfiles = []; private readonly List<FFmpegProfileViewModel> _ffmpegProfiles = [];
private readonly List<WatermarkViewModel> _watermarks = []; private readonly List<WatermarkViewModel> _watermarks = [];
private readonly List<SubtitleViewModel> _subtitleStreams = []; private readonly List<SubtitleViewModel> _subtitleStreams = [];
private readonly List<GraphicsElementViewModel> _graphicsElements = [];
private MediaItemInfo _info; private MediaItemInfo _info;
private int _ffmpegProfileId; private int _ffmpegProfileId;
private IEnumerable<string> _watermarkNames = new System.Collections.Generic.HashSet<string>(); private IEnumerable<string> _watermarkNames = new System.Collections.Generic.HashSet<string>();
private IEnumerable<string> _graphicsElementNames = new System.Collections.Generic.HashSet<string>();
private int? _subtitleId; private int? _subtitleId;
private bool _startFromBeginning; private bool _startFromBeginning;
private bool _hasPlayed; private bool _hasPlayed;
@ -155,6 +169,9 @@
_watermarks.Clear(); _watermarks.Clear();
_watermarks.AddRange(await Mediator.Send(new GetAllWatermarks(), _cts.Token)); _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) if (MediaItemId is not null)
{ {
await OnMediaItemIdChanged(MediaItemId); await OnMediaItemIdChanged(MediaItemId);
@ -175,6 +192,13 @@
uri.Query += $"&watermark={watermark.Id}"; 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) if (_subtitleId is not null)
{ {
uri.Query += $"&subtitleId={_subtitleId.Value}"; uri.Query += $"&subtitleId={_subtitleId.Value}";
@ -216,6 +240,7 @@
private async Task DownloadResults() private async Task DownloadResults()
{ {
var uri = $"api/troubleshoot/playback/archive?mediaItem={MediaItemId ?? 0}&ffmpegProfile={_ffmpegProfileId}&startFromBeginning={_startFromBeginning}"; var uri = $"api/troubleshoot/playback/archive?mediaItem={MediaItemId ?? 0}&ffmpegProfile={_ffmpegProfileId}&startFromBeginning={_startFromBeginning}";
foreach (var watermarkName in _watermarkNames) foreach (var watermarkName in _watermarkNames)
{ {
foreach (var watermark in _watermarks.Where(wm => wm.Name == watermarkName)) foreach (var watermark in _watermarks.Where(wm => wm.Name == watermarkName))
@ -223,6 +248,15 @@
uri += $"&watermark={watermark.Id}"; 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); await JsRuntime.InvokeVoidAsync("window.open", uri);
} }

6
ErsatzTV/Services/SchedulerService.cs

@ -4,6 +4,7 @@ using Bugsnag;
using ErsatzTV.Application; using ErsatzTV.Application;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Emby; using ErsatzTV.Application.Emby;
using ErsatzTV.Application.Graphics;
using ErsatzTV.Application.Jellyfin; using ErsatzTV.Application.Jellyfin;
using ErsatzTV.Application.Maintenance; using ErsatzTV.Application.Maintenance;
using ErsatzTV.Application.MediaCollections; using ErsatzTV.Application.MediaCollections;
@ -128,6 +129,8 @@ public class SchedulerService : BackgroundService
await RefreshTraktLists(cancellationToken); await RefreshTraktLists(cancellationToken);
await MatchTraktLists(cancellationToken); await MatchTraktLists(cancellationToken);
await RefreshGraphicsElements(cancellationToken);
await ReleaseMemory(cancellationToken); await ReleaseMemory(cancellationToken);
} }
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) 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) => private ValueTask DeleteOrphanedArtwork(CancellationToken cancellationToken) =>
_workerChannel.WriteAsync(new DeleteOrphanedArtwork(), cancellationToken); _workerChannel.WriteAsync(new DeleteOrphanedArtwork(), cancellationToken);

4
ErsatzTV/Services/WorkerService.cs

@ -3,6 +3,7 @@ using System.Threading.Channels;
using Bugsnag; using Bugsnag;
using ErsatzTV.Application; using ErsatzTV.Application;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Graphics;
using ErsatzTV.Application.Maintenance; using ErsatzTV.Application.Maintenance;
using ErsatzTV.Application.MediaCollections; using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.Playouts; using ErsatzTV.Application.Playouts;
@ -103,6 +104,9 @@ public class WorkerService : BackgroundService
case MatchTraktListItems matchTraktListItems: case MatchTraktListItems matchTraktListItems:
await mediator.Send(matchTraktListItems, stoppingToken); await mediator.Send(matchTraktListItems, stoppingToken);
break; break;
case RefreshGraphicsElements refreshGraphicsElements:
await mediator.Send(refreshGraphicsElements, stoppingToken);
break;
#if !DEBUG_NO_SYNC #if !DEBUG_NO_SYNC
case ExtractEmbeddedSubtitles extractEmbeddedSubtitles: case ExtractEmbeddedSubtitles extractEmbeddedSubtitles:
await mediator.Send(extractEmbeddedSubtitles, stoppingToken); await mediator.Send(extractEmbeddedSubtitles, stoppingToken);

2
ErsatzTV/Startup.cs

@ -336,6 +336,8 @@ public class Startup
FileSystemLayout.MusicVideoCreditsTemplatesFolder, FileSystemLayout.MusicVideoCreditsTemplatesFolder,
FileSystemLayout.ChannelStreamSelectorsFolder, FileSystemLayout.ChannelStreamSelectorsFolder,
FileSystemLayout.ChannelGuideTemplatesFolder, FileSystemLayout.ChannelGuideTemplatesFolder,
FileSystemLayout.GraphicsElementsTemplatesFolder,
FileSystemLayout.GraphicsElementsTextTemplatesFolder,
FileSystemLayout.ScriptsFolder, FileSystemLayout.ScriptsFolder,
FileSystemLayout.MultiEpisodeShuffleTemplatesFolder, FileSystemLayout.MultiEpisodeShuffleTemplatesFolder,
FileSystemLayout.AudioStreamSelectorScriptsFolder FileSystemLayout.AudioStreamSelectorScriptsFolder

Loading…
Cancel
Save