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/). @@ -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

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

@ -11,6 +11,8 @@ @@ -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>

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

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

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

@ -0,0 +1,53 @@ @@ -0,0 +1,53 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Graphics;
public class RefreshGraphicsElementsHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem,
ILogger<RefreshGraphicsElementsHandler> logger)
: IRequestHandler<RefreshGraphicsElements>
{
public async Task Handle(RefreshGraphicsElements request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
// cleanup existing elements
var allExisting = await dbContext.GraphicsElements
.ToListAsync(cancellationToken);
foreach (var existing in allExisting.Where(e => !localFileSystem.FileExists(e.Path)))
{
logger.LogWarning(
"Removing graphics element that references non-existing file {File}",
existing.Path);
dbContext.GraphicsElements.Remove(existing);
}
// add new elements
var newPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder)
.Where(f => allExisting.All(e => e.Path != f))
.ToList();
foreach (var path in newPaths)
{
logger.LogDebug("Adding new graphics element from file {File}", path);
var graphicsElement = new GraphicsElement
{
Path = path,
Kind = GraphicsElementKind.Text
};
await dbContext.AddAsync(graphicsElement, cancellationToken);
}
await dbContext.SaveChangesAsync(cancellationToken);
}
}

3
ErsatzTV.Application/Graphics/GraphicsElementViewModel.cs

@ -0,0 +1,3 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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< @@ -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< @@ -341,6 +344,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
effectiveNow,
playoutItemWatermarks,
maybeGlobalWatermark,
playoutItemWithPath.PlayoutItem.GraphicsElements,
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,

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

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

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

@ -7,6 +7,7 @@ public record PrepareTroubleshootingPlayback( @@ -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>>;

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

@ -113,6 +113,10 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -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( @@ -141,6 +145,7 @@ public class PrepareTroubleshootingPlaybackHandler(
now,
watermarks,
Option<ChannelWatermark>.None,
graphicsElements,
ffmpegProfile.VaapiDisplay,
ffmpegProfile.VaapiDriver,
ffmpegProfile.VaapiDevice,

10
ErsatzTV.Core/Domain/GraphicsElement.cs

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
namespace ErsatzTV.Core.Domain;
public class GraphicsElement
{
public int Id { get; set; }
public string Path { get; set; }
public GraphicsElementKind Kind { get; set; }
public List<PlayoutItem> PlayoutItems { get; set; }
public List<PlayoutItemGraphicsElement> PlayoutItemGraphicsElements { get; set; }
}

7
ErsatzTV.Core/Domain/GraphicsElementKind.cs

@ -0,0 +1,7 @@ @@ -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 @@ -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();

9
ErsatzTV.Core/Domain/PlayoutItemGraphicsElement.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain;
public class PlayoutItemGraphicsElement
{
public int PlayoutItemId { get; set; }
public PlayoutItem PlayoutItem { get; set; }
public int? GraphicsElementId { get; set; }
public GraphicsElement GraphicsElement { get; set; }
}

2
ErsatzTV.Core/Domain/PlayoutItemWatermark.cs

@ -4,6 +4,6 @@ public class PlayoutItemWatermark @@ -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; }
}

55
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -2,6 +2,7 @@ @@ -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 @@ -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 @@ -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,22 +371,50 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -368,22 +371,50 @@ 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)
{
graphicsEngineInput = new GraphicsEngineInput();
graphicsEngineContext = new GraphicsEngineContext(
watermarks.Values.OfType<GraphicsElementContext>().ToList(),
channel.FFmpegProfile.Resolution,
await playbackSettings.FrameRate.IfNoneAsync(24),
ChannelStartTime: channelStartTime,
ContentStartTime: start,
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero),
finish - now);
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(
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);
string videoFormat = GetVideoFormat(playbackSettings);

6
ErsatzTV.Core/FileSystemLayout.cs

@ -47,6 +47,9 @@ public static class FileSystemLayout @@ -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 @@ -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");

61
ErsatzTV.Core/Graphics/TextGraphicsElement.cs

@ -0,0 +1,61 @@ @@ -0,0 +1,61 @@
using ErsatzTV.FFmpeg.State;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace ErsatzTV.Core.Graphics;
public class TextGraphicsElement
{
public int? Opacity { get; set; }
[YamlMember(Alias = "opacity_expression", ApplyNamingConventions = false)]
public string OpacityExpression { get; set; }
public WatermarkLocation Location { get; set; }
[YamlMember(Alias = "horizontal_margin_percent", ApplyNamingConventions = false)]
public double? HorizontalMarginPercent { get; set; }
[YamlMember(Alias = "vertical_margin_percent", ApplyNamingConventions = false)]
public double? VerticalMarginPercent { get; set; }
[YamlMember(Alias = "horizontal_alignment", ApplyNamingConventions = false)]
public string HorizontalAlignment { get; set; }
[YamlMember(Alias = "location_x", ApplyNamingConventions = false)]
public double? LocationX { get; set; }
[YamlMember(Alias = "location_y", ApplyNamingConventions = false)]
public double? LocationY { get; set; }
[YamlMember(Alias = "z_index", ApplyNamingConventions = false)]
public int? ZIndex { get; set; }
[YamlMember(Alias = "font_family", ApplyNamingConventions = false)]
public string FontFamily { get; set; }
[YamlMember(Alias = "font_size", ApplyNamingConventions = false)]
public int? FontSize { get; set; }
[YamlMember(Alias = "font_color", ApplyNamingConventions = false)]
public string FontColor { get; set; }
public string Text { get; set; }
public static async Task<Option<TextGraphicsElement>> FromFile(string fileName)
{
try
{
string yaml = await File.ReadAllTextAsync(fileName);
// TODO: validate schema
// if (await yamlScheduleValidator.ValidateSchedule(yaml, isImport) == false)
// {
// return Option<YamlPlayoutDefinition>.None;
// }
IDeserializer deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
return deserializer.Deserialize<TextGraphicsElement>(yaml);
}
catch (Exception)
{
return Option<TextGraphicsElement>.None;
}
}
}

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

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

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

@ -1,5 +1,6 @@ @@ -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( @@ -15,3 +16,5 @@ public record GraphicsEngineContext(
public abstract record 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 @@ @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -5962,6 +6020,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.Navigation("PlayoutItemGraphicsElements");
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 @@ @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -5799,6 +5855,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.Navigation("PlayoutItemGraphicsElements");
b.Navigation("PlayoutItemWatermarks");
});

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

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations;
public class GraphicsElementConfiguration : IEntityTypeConfiguration<GraphicsElement>
{
public void Configure(EntityTypeBuilder<GraphicsElement> builder) => builder.ToTable("GraphicsElement");
}

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

@ -28,5 +28,18 @@ public class PlayoutItemConfiguration : IEntityTypeConfiguration<PlayoutItem> @@ -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 }));
}
}

1
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -113,6 +113,7 @@ public class TvContext : DbContext @@ -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);

55
ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs

@ -1,4 +1,5 @@ @@ -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 @@ -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,30 +64,39 @@ public class GraphicsEngine(ILogger<GraphicsEngine> logger) : IGraphicsEngine @@ -58,30 +64,39 @@ public class GraphicsEngine(ILogger<GraphicsEngine> logger) : IGraphicsEngine
context.FrameSize.Height,
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
outputFrame.Mutate(ctx =>
{
foreach (var element in elements.OrderBy(e => e.ZIndex))
foreach (var preparedImage in preparedElementImages)
{
try
{
if (!element.IsFailed)
{
element.Draw(
ctx,
frameTime.TimeOfDay,
contentTime,
contentTotalTime,
channelTime,
cancellationToken);
}
}
catch (Exception ex)
ctx.DrawImage(preparedImage.Image, preparedImage.Point, preparedImage.Opacity);
if (preparedImage.Dispose)
{
element.IsFailed = true;
logger.LogWarning(ex,
"Failed to draw graphics element of type {Type}; will disable for this content",
element.GetType().Name);
preparedImage.Image.Dispose();
}
}
});

56
ErsatzTV.Infrastructure/Streaming/GraphicsEngineFonts.cs

@ -0,0 +1,56 @@ @@ -0,0 +1,56 @@
using System.Collections.Concurrent;
using System.Globalization;
using SixLabors.Fonts;
namespace ErsatzTV.Infrastructure.Streaming;
public static class GraphicsEngineFonts
{
private static readonly FontCollection CustomFontCollection = new();
private static readonly ConcurrentDictionary<string, FontFamily> CustomFontFamilies = new();
private static bool _fontsLoaded;
public static void LoadFonts(string fontsFolder)
{
if (_fontsLoaded)
{
return;
}
foreach (var file in Directory.GetFiles(fontsFolder, "*.*", SearchOption.AllDirectories))
{
if (file.EndsWith(".ttf", StringComparison.OrdinalIgnoreCase) ||
file.EndsWith(".otf", StringComparison.OrdinalIgnoreCase))
{
var fontFamily = CustomFontCollection.Add(file, CultureInfo.CurrentCulture);
CustomFontFamilies.TryAdd(fontFamily.Name, fontFamily);
}
}
_fontsLoaded = true;
}
public static Font GetFont(string fontFamilyName, float fontSize, FontStyle style)
{
// try custom fonts
if (CustomFontFamilies.TryGetValue(fontFamilyName, out var customFamily))
{
return customFamily.GetAvailableStyles().Contains(style)
? customFamily.CreateFont(fontSize, style)
: customFamily.CreateFont(fontSize);
}
// fallback to system fonts
if (SystemFonts.TryGet(fontFamilyName, CultureInfo.CurrentCulture, out var systemFamily))
{
return systemFamily.GetAvailableStyles().Contains(style)
? systemFamily.CreateFont(fontSize, style)
: systemFamily.CreateFont(fontSize);
}
// fallback to default font
var fallback = SystemFonts.Families.First();
return fallback.CreateFont(fontSize, style);
}
}

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

@ -1,6 +1,6 @@ @@ -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 @@ -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,

101
ErsatzTV.Infrastructure/Streaming/OpacityExpressionHelper.cs

@ -0,0 +1,101 @@ @@ -0,0 +1,101 @@
using System.Globalization;
using NCalc;
using NCalc.Handlers;
namespace ErsatzTV.Infrastructure.Streaming;
public static class OpacityExpressionHelper
{
public static void EvaluateFunction(string name, FunctionArgs args)
{
switch (name)
{
case "LinearFadePoints":
{
if (args.Parameters.Length != 5)
{
throw new ArgumentException("LinearFadePoints() requires 5 arguments.");
}
double time = Convert.ToDouble(args.Parameters[0].Evaluate(), CultureInfo.CurrentCulture);
double start = Convert.ToDouble(args.Parameters[1].Evaluate(), CultureInfo.CurrentCulture);
double peakStart = Convert.ToDouble(args.Parameters[2].Evaluate(), CultureInfo.CurrentCulture);
double peakEnd = Convert.ToDouble(args.Parameters[3].Evaluate(), CultureInfo.CurrentCulture);
double end = Convert.ToDouble(args.Parameters[4].Evaluate(), CultureInfo.CurrentCulture);
args.Result = LinearFadePoints(time, start, peakStart, peakEnd, end);
break;
}
case "LinearFadeDuration":
{
if (args.Parameters.Length != 4)
{
throw new ArgumentException("LinearFadeDuration() requires 4 arguments.");
}
double time = Convert.ToDouble(args.Parameters[0].Evaluate(), CultureInfo.CurrentCulture);
double start = Convert.ToDouble(args.Parameters[1].Evaluate(), CultureInfo.CurrentCulture);
double fadeSeconds = Convert.ToDouble(args.Parameters[2].Evaluate(), CultureInfo.CurrentCulture);
double peakSeconds = Convert.ToDouble(args.Parameters[3].Evaluate(), CultureInfo.CurrentCulture);
args.Result = LinearFadeDuration(time, start, fadeSeconds, peakSeconds);
break;
}
}
}
private static double LinearFadePoints(double time, double start, double peakStart, double peakEnd, double end)
{
if (time < start || time >= end)
{
return 0;
}
// fade in
if (time < peakStart)
{
return (time - start) / (peakStart - start);
}
// solid
if (time < peakEnd)
{
return 1.0;
}
// fade out
return (end - time) / (end - peakEnd);
}
private static double LinearFadeDuration(double time, double start, double fadeSeconds, double peakSeconds)
{
// edge case with no fade
if (fadeSeconds <= 0)
{
double noFadeEnd = start + peakSeconds;
return (time >= start && time < noFadeEnd) ? 1.0 : 0.0;
}
double peakStart = start + fadeSeconds;
double peakEnd = peakStart + peakSeconds;
double end = peakEnd + fadeSeconds;
return LinearFadePoints(time, start, peakStart, peakEnd, end);
}
public static float GetOpacity(
Expression expression,
TimeSpan timeOfDay,
TimeSpan contentTime,
TimeSpan contentTotalTime,
TimeSpan channelTime)
{
expression.Parameters["content_seconds"] = contentTime.TotalSeconds;
expression.Parameters["content_total_seconds"] = contentTotalTime.TotalSeconds;
expression.Parameters["channel_seconds"] = channelTime.TotalSeconds;
expression.Parameters["time_of_day_seconds"] = timeOfDay.TotalSeconds;
object expressionResult = expression.Evaluate();
return Convert.ToSingle(expressionResult, CultureInfo.InvariantCulture);
}
}

6
ErsatzTV.Infrastructure/Streaming/PreparedElementImage.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using SixLabors.ImageSharp;
using Image=SixLabors.ImageSharp.Image;
namespace ErsatzTV.Infrastructure.Streaming;
public record PreparedElementImage(Image Image, Point Point, float Opacity, bool Dispose);

118
ErsatzTV.Infrastructure/Streaming/TextElement.cs

@ -0,0 +1,118 @@ @@ -0,0 +1,118 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using Microsoft.Extensions.Logging;
using NCalc;
using SixLabors.Fonts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Image=SixLabors.ImageSharp.Image;
namespace ErsatzTV.Infrastructure.Streaming;
public class TextElement(TextGraphicsElement textElement, ILogger logger) : IGraphicsElement, IDisposable
{
private Option<Expression> _maybeOpacityExpression;
private float _opacity;
private Image _image;
private Point _location;
public int ZIndex { get; private set; }
public bool IsFailed { get; set; }
public Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken)
{
try
{
if (!string.IsNullOrWhiteSpace(textElement.OpacityExpression))
{
var expression = new Expression(textElement.OpacityExpression);
expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction;
_maybeOpacityExpression = expression;
}
else
{
_opacity = (textElement.Opacity ?? 100) / 100.0f;
}
ZIndex = textElement.ZIndex ?? 0;
var font = GraphicsEngineFonts.GetFont(textElement.FontFamily, textElement.FontSize ?? 48, FontStyle.Regular);
var fontColor = Color.White;
if (Color.TryParse(textElement.FontColor, out Color parsedColor) ||
Color.TryParseHex(textElement.FontColor, out parsedColor))
{
fontColor = parsedColor;
}
var textOptions = new RichTextOptions(font)
{
Origin = new PointF(0, 0),
HorizontalAlignment = HorizontalAlignment.Left
};
// if (Enum.TryParse(textElement.HorizontalAlignment, out HorizontalAlignment parsedAlignment))
// {
// textOptions.HorizontalAlignment = parsedAlignment;
// }
FontRectangle textBounds = TextMeasurer.MeasureBounds(textElement.Text, textOptions);
textOptions.Origin = new PointF(-textBounds.X, -textBounds.Y);
_image = new Image<Rgba32>((int)Math.Ceiling(textBounds.Width), (int)Math.Ceiling(textBounds.Height));
_image.Mutate(ctx => ctx.DrawText(textOptions, textElement.Text, fontColor));
int horizontalMargin = (int)Math.Round((textElement.HorizontalMarginPercent ?? 0) / 100.0 * frameSize.Width);
int verticalMargin = (int)Math.Round((textElement.VerticalMarginPercent ?? 0) / 100.0 * frameSize.Height);
_location = WatermarkElement.CalculatePosition(
textElement.Location,
frameSize.Width,
frameSize.Height,
_image.Width,
_image.Height,
horizontalMargin,
verticalMargin);
}
catch (Exception ex)
{
IsFailed = true;
logger.LogWarning(ex, "Failed to initialize text element; will disable for this content");
}
return Task.CompletedTask;
}
public ValueTask<Option<PreparedElementImage>> PrepareImage(
TimeSpan timeOfDay,
TimeSpan contentTime,
TimeSpan contentTotalTime,
TimeSpan channelTime,
CancellationToken cancellationToken)
{
float opacity = _opacity;
foreach (var expression in _maybeOpacityExpression)
{
opacity = OpacityExpressionHelper.GetOpacity(
expression,
timeOfDay,
contentTime,
contentTotalTime,
channelTime);
}
return opacity == 0
? ValueTask.FromResult(Option<PreparedElementImage>.None)
: new ValueTask<Option<PreparedElementImage>>(new PreparedElementImage(_image, _location, opacity, false));
}
public void Dispose()
{
GC.SuppressFinalize(this);
_image?.Dispose();
_image = null;
}
}

268
ErsatzTV.Infrastructure/Streaming/WatermarkElement.cs

@ -1,10 +1,8 @@ @@ -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; @@ -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)
{
@ -47,147 +48,117 @@ public class WatermarkElement : IGraphicsElement, IDisposable @@ -47,147 +48,117 @@ public class WatermarkElement : IGraphicsElement, IDisposable
public async Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken)
{
if (_watermark.Mode is ChannelWatermarkMode.Intermittent)
try
{
string expressionString = $@"
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < 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},
1 - ((time_of_day_seconds % {_watermark.FrequencyMinutes * 60} - {1 + _watermark.DurationSeconds}) / 1),
0
if (_watermark.Mode is ChannelWatermarkMode.Intermittent)
{
string expressionString = $@"
if(time_of_day_seconds % {_watermark.FrequencyMinutes * 60} < 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},
1 - ((time_of_day_seconds % {_watermark.FrequencyMinutes * 60} - {1 + _watermark.DurationSeconds}) / 1),
0
)
)
)
)";
_expression = new Expression(expressionString);
}
else if (_watermark.Mode is ChannelWatermarkMode.OpacityExpression && !string.IsNullOrWhiteSpace(_watermark.OpacityExpression))
{
_expression = new Expression(_watermark.OpacityExpression);
}
else
{
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);
}
)";
_maybeOpacityExpression = new Expression(expressionString);
}
else if (_watermark.Mode is ChannelWatermarkMode.OpacityExpression && !string.IsNullOrWhiteSpace(_watermark.OpacityExpression))
{
_maybeOpacityExpression = new Expression(_watermark.OpacityExpression);
}
else
{
_opacity = _watermark.Opacity / 100.0f;
}
int horizontalMargin = (int)Math.Round(_watermark.HorizontalMarginPercent / 100.0 * frameSize.Width);
int verticalMargin = (int)Math.Round(_watermark.VerticalMarginPercent / 100.0 * frameSize.Height);
foreach (var expression in _maybeOpacityExpression)
{
expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction;
}
_location = CalculatePosition(
_watermark.Location,
frameSize.Width,
frameSize.Height,
scaledWidth,
scaledHeight,
horizontalMargin,
verticalMargin);
bool isRemoteUri = Uri.TryCreate(_imagePath, UriKind.Absolute, out var uriResult)
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
_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++)
{
var frame = _sourceImage.Frames.CloneFrame(i);
frame.Mutate(ctx => ctx.Resize(scaledWidth, scaledHeight));
_scaledFrames.Add(frame);
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);
}
var frameDelay = _sourceImage.Frames[i].Metadata.GetFormatMetadata(GifFormat.Instance).FrameDelay / 100.0;
_animatedDurationSeconds += frameDelay;
_frameDelays.Add(frameDelay);
}
}
int horizontalMargin = (int)Math.Round(_watermark.HorizontalMarginPercent / 100.0 * frameSize.Width);
int verticalMargin = (int)Math.Round(_watermark.VerticalMarginPercent / 100.0 * frameSize.Height);
private static void EvaluateFunction(string name, FunctionArgs args)
{
switch (name)
{
case "LinearFadePoints":
{
if (args.Parameters.Length != 5)
{
throw new ArgumentException("LinearFadePoints() requires 5 arguments.");
}
_location = CalculatePosition(
_watermark.Location,
frameSize.Width,
frameSize.Height,
scaledWidth,
scaledHeight,
horizontalMargin,
verticalMargin);
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);
_animatedDurationSeconds = 0;
args.Result = LinearFadePoints(time, start, peakStart, peakEnd, end);
break;
}
case "LinearFadeDuration":
for (int i = 0; i < _sourceImage.Frames.Count; i++)
{
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);
var frame = _sourceImage.Frames.CloneFrame(i);
frame.Mutate(ctx => ctx.Resize(scaledWidth, scaledHeight));
_scaledFrames.Add(frame);
args.Result = LinearFadeDuration(time, start, fadeSeconds, peakSeconds);
break;
var frameDelay = _sourceImage.Frames[i].Metadata.GetFormatMetadata(GifFormat.Instance).FrameDelay / 100.0;
_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(
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 @@ -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 @@ -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);

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

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

8
ErsatzTV/Controllers/Api/TroubleshootController.cs

@ -29,6 +29,8 @@ public class TroubleshootController( @@ -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( @@ -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( @@ -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)

44
ErsatzTV/Pages/PlaybackTroubleshooting.razor

@ -1,5 +1,6 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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
ErsatzTV/Services/SchedulerService.cs

@ -4,6 +4,7 @@ using Bugsnag; @@ -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 @@ -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 @@ -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
ErsatzTV/Services/WorkerService.cs

@ -3,6 +3,7 @@ using System.Threading.Channels; @@ -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 @@ -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
ErsatzTV/Startup.cs

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

Loading…
Cancel
Save