add text graphics element to playback troubleshooting (#2282)
* refactor graphics engine; async frame generation * add text graphics element to playback troubleshooting
This commit is contained in:
@@ -19,6 +19,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- `LinearFadePoints(time, start, peakStart, peakEnd, end)`
|
||||
- Add `Z-Index` to watermark editor
|
||||
- The graphics engine will order by z-index when overlaying watermarks
|
||||
- Add *experimental* `Graphics Element` template system
|
||||
- Graphics elements are defined in YAML files inside ETV config folder / templates / graphics-elements subfolder
|
||||
- Add `Text` graphics element type
|
||||
- Supported in playback troubleshooting
|
||||
- Displays multi-line text in a specified font, color, location, z-index
|
||||
- Supports constant opacity and opacity expression
|
||||
|
||||
### Fix
|
||||
- Fix database operations that were slowing down playout builds
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ffmpegprofiles_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=filler_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=filler_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=graphics_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=graphics_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=hdhr_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=hdhr_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=health_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public record RefreshGraphicsElements : IRequest, IBackgroundServiceRequest;
|
||||
@@ -0,0 +1,53 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public class RefreshGraphicsElementsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<RefreshGraphicsElementsHandler> logger)
|
||||
: IRequestHandler<RefreshGraphicsElements>
|
||||
{
|
||||
public async Task Handle(RefreshGraphicsElements request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// cleanup existing elements
|
||||
var allExisting = await dbContext.GraphicsElements
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var existing in allExisting.Where(e => !localFileSystem.FileExists(e.Path)))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Removing graphics element that references non-existing file {File}",
|
||||
existing.Path);
|
||||
|
||||
dbContext.GraphicsElements.Remove(existing);
|
||||
}
|
||||
|
||||
// add new elements
|
||||
var newPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder)
|
||||
.Where(f => allExisting.All(e => e.Path != f))
|
||||
.ToList();
|
||||
|
||||
foreach (var path in newPaths)
|
||||
{
|
||||
logger.LogDebug("Adding new graphics element from file {File}", path);
|
||||
|
||||
var graphicsElement = new GraphicsElement
|
||||
{
|
||||
Path = path,
|
||||
Kind = GraphicsElementKind.Text
|
||||
};
|
||||
|
||||
await dbContext.AddAsync(graphicsElement, cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public record GraphicsElementViewModel(int Id, string Name);
|
||||
@@ -0,0 +1,16 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public static class Mapper
|
||||
{
|
||||
public static GraphicsElementViewModel ProjectToViewModel(GraphicsElement graphicsElement)
|
||||
{
|
||||
var fileName = Path.GetFileName(graphicsElement.Path);
|
||||
return graphicsElement.Kind switch
|
||||
{
|
||||
GraphicsElementKind.Text => new GraphicsElementViewModel(graphicsElement.Id, $"text/{fileName}"),
|
||||
_ => new GraphicsElementViewModel(graphicsElement.Id, graphicsElement.Path)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public record GetAllGraphicsElements : IRequest<List<GraphicsElementViewModel>>;
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Graphics.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public class GetAllGraphicsElementsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllGraphicsElements, List<GraphicsElementViewModel>>
|
||||
{
|
||||
public async Task<List<GraphicsElementViewModel>> Handle(
|
||||
GetAllGraphicsElements request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.GraphicsElements
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
.ThenInclude(p => p.Deco)
|
||||
.ThenInclude(d => d.Watermark)
|
||||
|
||||
// get graphics elements
|
||||
.Include(i => i.GraphicsElements)
|
||||
|
||||
// get playout templates (and deco templates/decos)
|
||||
.Include(i => i.Playout)
|
||||
.ThenInclude(p => p.Templates)
|
||||
@@ -341,6 +344,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
effectiveNow,
|
||||
playoutItemWatermarks,
|
||||
maybeGlobalWatermark,
|
||||
playoutItemWithPath.PlayoutItem.GraphicsElements,
|
||||
channel.FFmpegProfile.VaapiDisplay,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice,
|
||||
|
||||
@@ -4,5 +4,6 @@ public record ArchiveTroubleshootingResults(
|
||||
int MediaItemId,
|
||||
int FFmpegProfileId,
|
||||
List<int> WatermarkIds,
|
||||
List<int> GraphicsElementIds,
|
||||
bool StartFromBeginning)
|
||||
: IRequest<Option<string>>;
|
||||
|
||||
@@ -7,6 +7,7 @@ public record PrepareTroubleshootingPlayback(
|
||||
int MediaItemId,
|
||||
int FFmpegProfileId,
|
||||
List<int> WatermarkIds,
|
||||
List<int> GraphicsElementIds,
|
||||
int? SubtitleId,
|
||||
bool StartFromBeginning)
|
||||
: IRequest<Either<BaseError, PlayoutItemResult>>;
|
||||
|
||||
@@ -113,6 +113,10 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
outPoint = inPoint + duration;
|
||||
}
|
||||
|
||||
var graphicsElements = await dbContext.GraphicsElements
|
||||
.Where(ge => request.GraphicsElementIds.Contains(ge.Id))
|
||||
.ToListAsync();
|
||||
|
||||
PlayoutItemResult playoutItemResult = await ffmpegProcessService.ForPlayoutItem(
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
@@ -141,6 +145,7 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
now,
|
||||
watermarks,
|
||||
Option<ChannelWatermark>.None,
|
||||
graphicsElements,
|
||||
ffmpegProfile.VaapiDisplay,
|
||||
ffmpegProfile.VaapiDriver,
|
||||
ffmpegProfile.VaapiDevice,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class GraphicsElement
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Path { get; set; }
|
||||
public GraphicsElementKind Kind { get; set; }
|
||||
public List<PlayoutItem> PlayoutItems { get; set; }
|
||||
public List<PlayoutItemGraphicsElement> PlayoutItemGraphicsElements { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum GraphicsElementKind
|
||||
{
|
||||
Image = 0,
|
||||
Text = 1
|
||||
}
|
||||
@@ -32,6 +32,8 @@ public class PlayoutItem
|
||||
public string CollectionKey { get; set; }
|
||||
public string CollectionEtag { get; set; }
|
||||
public List<PlayoutItemWatermark> PlayoutItemWatermarks { get; set; }
|
||||
public List<GraphicsElement> GraphicsElements { get; set; }
|
||||
public List<PlayoutItemGraphicsElement> PlayoutItemGraphicsElements { get; set; }
|
||||
public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime();
|
||||
public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime();
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class PlayoutItemGraphicsElement
|
||||
{
|
||||
public int PlayoutItemId { get; set; }
|
||||
public PlayoutItem PlayoutItem { get; set; }
|
||||
public int? GraphicsElementId { get; set; }
|
||||
public GraphicsElement GraphicsElement { get; set; }
|
||||
}
|
||||
@@ -4,6 +4,6 @@ public class PlayoutItemWatermark
|
||||
{
|
||||
public int PlayoutItemId { get; set; }
|
||||
public PlayoutItem PlayoutItem { get; set; }
|
||||
public ChannelWatermark Watermark { get; set; }
|
||||
public int? WatermarkId { get; set; }
|
||||
public ChannelWatermark Watermark { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using CliWrap;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
@@ -64,6 +65,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
DateTimeOffset now,
|
||||
List<ChannelWatermark> playoutItemWatermarks,
|
||||
Option<ChannelWatermark> globalWatermark,
|
||||
List<GraphicsElement> graphicsElements,
|
||||
string vaapiDisplay,
|
||||
VaapiDriver vaapiDriver,
|
||||
string vaapiDevice,
|
||||
@@ -324,6 +326,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
Option<WatermarkInputFile> watermarkInputFile = Option<WatermarkInputFile>.None;
|
||||
Option<GraphicsEngineInput> graphicsEngineInput = Option<GraphicsEngineInput>.None;
|
||||
Option<GraphicsEngineContext> graphicsEngineContext = Option<GraphicsEngineContext>.None;
|
||||
List<GraphicsElementContext> graphicsElementContexts = new List<GraphicsElementContext>();
|
||||
|
||||
// use graphics engine for all watermarks
|
||||
if (!disableWatermarks)
|
||||
@@ -368,13 +371,42 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
}
|
||||
}
|
||||
|
||||
// only use graphics engine when we have watermarks
|
||||
if (watermarks.Count > 0)
|
||||
graphicsElementContexts.AddRange(watermarks.Values);
|
||||
}
|
||||
|
||||
foreach (var graphicsElement in graphicsElements)
|
||||
{
|
||||
switch (graphicsElement.Kind)
|
||||
{
|
||||
case GraphicsElementKind.Text:
|
||||
var maybeElement = await TextGraphicsElement.FromFile(graphicsElement.Path);
|
||||
if (maybeElement.IsNone)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to load text graphics element from file {Path}; ignoring",
|
||||
graphicsElement.Path);
|
||||
}
|
||||
|
||||
foreach (var element in maybeElement)
|
||||
{
|
||||
graphicsElementContexts.Add(new TextElementContext(element));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
_logger.LogInformation(
|
||||
"Ignoring unsupported graphics element kind {Kind}",
|
||||
nameof(graphicsElement.Kind));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// only use graphics engine when we have elements
|
||||
if (graphicsElementContexts.Count > 0)
|
||||
{
|
||||
graphicsEngineInput = new GraphicsEngineInput();
|
||||
|
||||
graphicsEngineContext = new GraphicsEngineContext(
|
||||
watermarks.Values.OfType<GraphicsElementContext>().ToList(),
|
||||
graphicsElementContexts,
|
||||
channel.FFmpegProfile.Resolution,
|
||||
await playbackSettings.FrameRate.IfNoneAsync(24),
|
||||
ChannelStartTime: channelStartTime,
|
||||
@@ -382,7 +414,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero),
|
||||
finish - now);
|
||||
}
|
||||
}
|
||||
|
||||
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, fillerKind);
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ public static class FileSystemLayout
|
||||
|
||||
public static readonly string ChannelGuideTemplatesFolder;
|
||||
|
||||
public static readonly string GraphicsElementsTemplatesFolder;
|
||||
public static readonly string GraphicsElementsTextTemplatesFolder;
|
||||
|
||||
public static readonly string ScriptsFolder;
|
||||
|
||||
public static readonly string MultiEpisodeShuffleTemplatesFolder;
|
||||
@@ -162,6 +165,9 @@ public static class FileSystemLayout
|
||||
|
||||
ChannelGuideTemplatesFolder = Path.Combine(TemplatesFolder, "channel-guide");
|
||||
|
||||
GraphicsElementsTemplatesFolder = Path.Combine(TemplatesFolder, "graphics-elements");
|
||||
GraphicsElementsTextTemplatesFolder = Path.Combine(GraphicsElementsTemplatesFolder, "text");
|
||||
|
||||
ScriptsFolder = Path.Combine(AppDataFolder, "scripts");
|
||||
|
||||
MultiEpisodeShuffleTemplatesFolder = Path.Combine(ScriptsFolder, "multi-episode-shuffle");
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace ErsatzTV.Core.Graphics;
|
||||
|
||||
public class TextGraphicsElement
|
||||
{
|
||||
public int? Opacity { get; set; }
|
||||
|
||||
[YamlMember(Alias = "opacity_expression", ApplyNamingConventions = false)]
|
||||
public string OpacityExpression { get; set; }
|
||||
|
||||
public WatermarkLocation Location { get; set; }
|
||||
[YamlMember(Alias = "horizontal_margin_percent", ApplyNamingConventions = false)]
|
||||
public double? HorizontalMarginPercent { get; set; }
|
||||
[YamlMember(Alias = "vertical_margin_percent", ApplyNamingConventions = false)]
|
||||
public double? VerticalMarginPercent { get; set; }
|
||||
[YamlMember(Alias = "horizontal_alignment", ApplyNamingConventions = false)]
|
||||
public string HorizontalAlignment { get; set; }
|
||||
|
||||
[YamlMember(Alias = "location_x", ApplyNamingConventions = false)]
|
||||
public double? LocationX { get; set; }
|
||||
[YamlMember(Alias = "location_y", ApplyNamingConventions = false)]
|
||||
public double? LocationY { get; set; }
|
||||
[YamlMember(Alias = "z_index", ApplyNamingConventions = false)]
|
||||
public int? ZIndex { get; set; }
|
||||
|
||||
[YamlMember(Alias = "font_family", ApplyNamingConventions = false)]
|
||||
public string FontFamily { get; set; }
|
||||
[YamlMember(Alias = "font_size", ApplyNamingConventions = false)]
|
||||
public int? FontSize { get; set; }
|
||||
[YamlMember(Alias = "font_color", ApplyNamingConventions = false)]
|
||||
public string FontColor { get; set; }
|
||||
|
||||
public string Text { get; set; }
|
||||
|
||||
public static async Task<Option<TextGraphicsElement>> FromFile(string fileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
string yaml = await File.ReadAllTextAsync(fileName);
|
||||
|
||||
// TODO: validate schema
|
||||
// if (await yamlScheduleValidator.ValidateSchedule(yaml, isImport) == false)
|
||||
// {
|
||||
// return Option<YamlPlayoutDefinition>.None;
|
||||
// }
|
||||
|
||||
IDeserializer deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.Build();
|
||||
|
||||
return deserializer.Deserialize<TextGraphicsElement>(yaml);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return Option<TextGraphicsElement>.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public interface IFFmpegProcessService
|
||||
DateTimeOffset now,
|
||||
List<ChannelWatermark> playoutItemWatermarks,
|
||||
Option<ChannelWatermark> globalWatermark,
|
||||
List<GraphicsElement> graphicsElements,
|
||||
string vaapiDisplay,
|
||||
VaapiDriver vaapiDriver,
|
||||
string vaapiDevice,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Streaming;
|
||||
|
||||
@@ -15,3 +16,5 @@ public record GraphicsEngineContext(
|
||||
public abstract record GraphicsElementContext;
|
||||
|
||||
public record WatermarkElementContext(WatermarkOptions Options) : GraphicsElementContext;
|
||||
|
||||
public record TextElementContext(TextGraphicsElement TextElement) : GraphicsElementContext;
|
||||
|
||||
Generated
+6274
File diff suppressed because it is too large
Load Diff
+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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -849,6 +849,25 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.ToTable("Genre");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.GraphicsElement", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("GraphicsElement", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageFolderDuration", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1894,6 +1913,21 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.ToTable("PlayoutItem", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemGraphicsElement", b =>
|
||||
{
|
||||
b.Property<int>("PlayoutItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("GraphicsElementId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("PlayoutItemId", "GraphicsElementId");
|
||||
|
||||
b.HasIndex("GraphicsElementId");
|
||||
|
||||
b.ToTable("PlayoutItemGraphicsElement");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
|
||||
{
|
||||
b.Property<int>("PlayoutItemId")
|
||||
@@ -4635,6 +4669,25 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.Navigation("Playout");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemGraphicsElement", b =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.GraphicsElement", "GraphicsElement")
|
||||
.WithMany("PlayoutItemGraphicsElements")
|
||||
.HasForeignKey("GraphicsElementId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.PlayoutItem", "PlayoutItem")
|
||||
.WithMany("PlayoutItemGraphicsElements")
|
||||
.HasForeignKey("PlayoutItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("GraphicsElement");
|
||||
|
||||
b.Navigation("PlayoutItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.PlayoutItem", "PlayoutItem")
|
||||
@@ -5806,6 +5859,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.Navigation("Writers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.GraphicsElement", b =>
|
||||
{
|
||||
b.Navigation("PlayoutItemGraphicsElements");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
|
||||
{
|
||||
b.Navigation("Actors");
|
||||
@@ -5962,6 +6020,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
|
||||
{
|
||||
b.Navigation("PlayoutItemGraphicsElements");
|
||||
|
||||
b.Navigation("PlayoutItemWatermarks");
|
||||
});
|
||||
|
||||
|
||||
Generated
+6109
File diff suppressed because it is too large
Load Diff
+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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -814,6 +814,23 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b.ToTable("Genre");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.GraphicsElement", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("GraphicsElement", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageFolderDuration", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1805,6 +1822,21 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b.ToTable("PlayoutItem", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemGraphicsElement", b =>
|
||||
{
|
||||
b.Property<int>("PlayoutItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GraphicsElementId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("PlayoutItemId", "GraphicsElementId");
|
||||
|
||||
b.HasIndex("GraphicsElementId");
|
||||
|
||||
b.ToTable("PlayoutItemGraphicsElement");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
|
||||
{
|
||||
b.Property<int>("PlayoutItemId")
|
||||
@@ -4472,6 +4504,25 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b.Navigation("Playout");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemGraphicsElement", b =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.GraphicsElement", "GraphicsElement")
|
||||
.WithMany("PlayoutItemGraphicsElements")
|
||||
.HasForeignKey("GraphicsElementId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.PlayoutItem", "PlayoutItem")
|
||||
.WithMany("PlayoutItemGraphicsElements")
|
||||
.HasForeignKey("PlayoutItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("GraphicsElement");
|
||||
|
||||
b.Navigation("PlayoutItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItemWatermark", b =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.PlayoutItem", "PlayoutItem")
|
||||
@@ -5643,6 +5694,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b.Navigation("Writers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.GraphicsElement", b =>
|
||||
{
|
||||
b.Navigation("PlayoutItemGraphicsElements");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.ImageMetadata", b =>
|
||||
{
|
||||
b.Navigation("Actors");
|
||||
@@ -5799,6 +5855,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
|
||||
{
|
||||
b.Navigation("PlayoutItemGraphicsElements");
|
||||
|
||||
b.Navigation("PlayoutItemWatermarks");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations;
|
||||
|
||||
public class GraphicsElementConfiguration : IEntityTypeConfiguration<GraphicsElement>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GraphicsElement> builder) => builder.ToTable("GraphicsElement");
|
||||
}
|
||||
@@ -28,5 +28,18 @@ public class PlayoutItemConfiguration : IEntityTypeConfiguration<PlayoutItem>
|
||||
.HasForeignKey(ci => ci.PlayoutItemId)
|
||||
.OnDelete(DeleteBehavior.Cascade),
|
||||
j => j.HasKey(ci => new { ci.PlayoutItemId, ci.WatermarkId }));
|
||||
|
||||
builder.HasMany(c => c.GraphicsElements)
|
||||
.WithMany(m => m.PlayoutItems)
|
||||
.UsingEntity<PlayoutItemGraphicsElement>(
|
||||
j => j.HasOne(ci => ci.GraphicsElement)
|
||||
.WithMany(mi => mi.PlayoutItemGraphicsElements)
|
||||
.HasForeignKey(ci => ci.GraphicsElementId)
|
||||
.OnDelete(DeleteBehavior.Cascade),
|
||||
j => j.HasOne(ci => ci.PlayoutItem)
|
||||
.WithMany(c => c.PlayoutItemGraphicsElements)
|
||||
.HasForeignKey(ci => ci.PlayoutItemId)
|
||||
.OnDelete(DeleteBehavior.Cascade),
|
||||
j => j.HasKey(ci => new { ci.PlayoutItemId, ci.GraphicsElementId }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ public class TvContext : DbContext
|
||||
public DbSet<TraktList> TraktLists { get; set; }
|
||||
public DbSet<FillerPreset> FillerPresets { get; set; }
|
||||
public DbSet<Subtitle> Subtitles { get; set; }
|
||||
public DbSet<GraphicsElement> GraphicsElements { get; set; }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
|
||||
optionsBuilder.UseLoggerFactory(_loggerFactory);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.IO.Pipelines;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SixLabors.ImageSharp;
|
||||
@@ -11,18 +12,23 @@ public class GraphicsEngine(ILogger<GraphicsEngine> logger) : IGraphicsEngine
|
||||
{
|
||||
public async Task Run(GraphicsEngineContext context, PipeWriter pipeWriter, CancellationToken cancellationToken)
|
||||
{
|
||||
GraphicsEngineFonts.LoadFonts(FileSystemLayout.FontsCacheFolder);
|
||||
|
||||
var elements = new List<IGraphicsElement>();
|
||||
foreach (var element in context.Elements)
|
||||
{
|
||||
switch (element)
|
||||
{
|
||||
case WatermarkElementContext watermarkElementContext:
|
||||
var watermark = new WatermarkElement(watermarkElementContext.Options);
|
||||
var watermark = new WatermarkElement(watermarkElementContext.Options, logger);
|
||||
if (watermark.IsValid)
|
||||
{
|
||||
elements.Add(watermark);
|
||||
}
|
||||
|
||||
break;
|
||||
case TextElementContext textElementContext:
|
||||
elements.Add(new TextElement(textElementContext.TextElement, logger));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -58,23 +64,20 @@ public class GraphicsEngine(ILogger<GraphicsEngine> logger) : IGraphicsEngine
|
||||
context.FrameSize.Height,
|
||||
Color.Transparent);
|
||||
|
||||
// draw each element
|
||||
outputFrame.Mutate(ctx =>
|
||||
{
|
||||
foreach (var element in elements.OrderBy(e => e.ZIndex))
|
||||
// prepare images outside mutate to allow async image generation
|
||||
var preparedElementImages = new List<PreparedElementImage>();
|
||||
foreach (var element in elements.Where(e => !e.IsFailed).OrderBy(e => e.ZIndex))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!element.IsFailed)
|
||||
{
|
||||
element.Draw(
|
||||
ctx,
|
||||
var maybePreparedImage = await element.PrepareImage(
|
||||
frameTime.TimeOfDay,
|
||||
contentTime,
|
||||
contentTotalTime,
|
||||
channelTime,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
preparedElementImages.AddRange(maybePreparedImage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -84,6 +87,18 @@ public class GraphicsEngine(ILogger<GraphicsEngine> logger) : IGraphicsEngine
|
||||
element.GetType().Name);
|
||||
}
|
||||
}
|
||||
|
||||
// draw each element
|
||||
outputFrame.Mutate(ctx =>
|
||||
{
|
||||
foreach (var preparedImage in preparedElementImages)
|
||||
{
|
||||
ctx.DrawImage(preparedImage.Image, preparedImage.Point, preparedImage.Opacity);
|
||||
if (preparedImage.Dispose)
|
||||
{
|
||||
preparedImage.Image.Dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// pipe output
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using SixLabors.Fonts;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming;
|
||||
|
||||
public static class GraphicsEngineFonts
|
||||
{
|
||||
private static readonly FontCollection CustomFontCollection = new();
|
||||
private static readonly ConcurrentDictionary<string, FontFamily> CustomFontFamilies = new();
|
||||
|
||||
private static bool _fontsLoaded;
|
||||
|
||||
public static void LoadFonts(string fontsFolder)
|
||||
{
|
||||
if (_fontsLoaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var file in Directory.GetFiles(fontsFolder, "*.*", SearchOption.AllDirectories))
|
||||
{
|
||||
if (file.EndsWith(".ttf", StringComparison.OrdinalIgnoreCase) ||
|
||||
file.EndsWith(".otf", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var fontFamily = CustomFontCollection.Add(file, CultureInfo.CurrentCulture);
|
||||
CustomFontFamilies.TryAdd(fontFamily.Name, fontFamily);
|
||||
}
|
||||
}
|
||||
|
||||
_fontsLoaded = true;
|
||||
}
|
||||
|
||||
public static Font GetFont(string fontFamilyName, float fontSize, FontStyle style)
|
||||
{
|
||||
// try custom fonts
|
||||
if (CustomFontFamilies.TryGetValue(fontFamilyName, out var customFamily))
|
||||
{
|
||||
return customFamily.GetAvailableStyles().Contains(style)
|
||||
? customFamily.CreateFont(fontSize, style)
|
||||
: customFamily.CreateFont(fontSize);
|
||||
}
|
||||
|
||||
// fallback to system fonts
|
||||
if (SystemFonts.TryGet(fontFamilyName, CultureInfo.CurrentCulture, out var systemFamily))
|
||||
{
|
||||
return systemFamily.GetAvailableStyles().Contains(style)
|
||||
? systemFamily.CreateFont(fontSize, style)
|
||||
: systemFamily.CreateFont(fontSize);
|
||||
}
|
||||
|
||||
// fallback to default font
|
||||
var fallback = SystemFonts.Families.First();
|
||||
return fallback.CreateFont(fontSize, style);
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Streaming;
|
||||
namespace ErsatzTV.Infrastructure.Streaming;
|
||||
|
||||
public interface IGraphicsElement
|
||||
{
|
||||
@@ -10,8 +10,7 @@ public interface IGraphicsElement
|
||||
|
||||
Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken);
|
||||
|
||||
void Draw(
|
||||
object context,
|
||||
ValueTask<Option<PreparedElementImage>> PrepareImage(
|
||||
TimeSpan timeOfDay,
|
||||
TimeSpan contentTime,
|
||||
TimeSpan contentTotalTime,
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Globalization;
|
||||
using NCalc;
|
||||
using NCalc.Handlers;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming;
|
||||
|
||||
public static class OpacityExpressionHelper
|
||||
{
|
||||
public static void EvaluateFunction(string name, FunctionArgs args)
|
||||
{
|
||||
switch (name)
|
||||
{
|
||||
case "LinearFadePoints":
|
||||
{
|
||||
if (args.Parameters.Length != 5)
|
||||
{
|
||||
throw new ArgumentException("LinearFadePoints() requires 5 arguments.");
|
||||
}
|
||||
|
||||
double time = Convert.ToDouble(args.Parameters[0].Evaluate(), CultureInfo.CurrentCulture);
|
||||
double start = Convert.ToDouble(args.Parameters[1].Evaluate(), CultureInfo.CurrentCulture);
|
||||
double peakStart = Convert.ToDouble(args.Parameters[2].Evaluate(), CultureInfo.CurrentCulture);
|
||||
double peakEnd = Convert.ToDouble(args.Parameters[3].Evaluate(), CultureInfo.CurrentCulture);
|
||||
double end = Convert.ToDouble(args.Parameters[4].Evaluate(), CultureInfo.CurrentCulture);
|
||||
|
||||
args.Result = LinearFadePoints(time, start, peakStart, peakEnd, end);
|
||||
break;
|
||||
}
|
||||
case "LinearFadeDuration":
|
||||
{
|
||||
if (args.Parameters.Length != 4)
|
||||
{
|
||||
throw new ArgumentException("LinearFadeDuration() requires 4 arguments.");
|
||||
}
|
||||
|
||||
double time = Convert.ToDouble(args.Parameters[0].Evaluate(), CultureInfo.CurrentCulture);
|
||||
double start = Convert.ToDouble(args.Parameters[1].Evaluate(), CultureInfo.CurrentCulture);
|
||||
double fadeSeconds = Convert.ToDouble(args.Parameters[2].Evaluate(), CultureInfo.CurrentCulture);
|
||||
double peakSeconds = Convert.ToDouble(args.Parameters[3].Evaluate(), CultureInfo.CurrentCulture);
|
||||
|
||||
args.Result = LinearFadeDuration(time, start, fadeSeconds, peakSeconds);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static double LinearFadePoints(double time, double start, double peakStart, double peakEnd, double end)
|
||||
{
|
||||
if (time < start || time >= end)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// fade in
|
||||
if (time < peakStart)
|
||||
{
|
||||
return (time - start) / (peakStart - start);
|
||||
}
|
||||
|
||||
// solid
|
||||
if (time < peakEnd)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// fade out
|
||||
return (end - time) / (end - peakEnd);
|
||||
}
|
||||
|
||||
private static double LinearFadeDuration(double time, double start, double fadeSeconds, double peakSeconds)
|
||||
{
|
||||
// edge case with no fade
|
||||
if (fadeSeconds <= 0)
|
||||
{
|
||||
double noFadeEnd = start + peakSeconds;
|
||||
return (time >= start && time < noFadeEnd) ? 1.0 : 0.0;
|
||||
}
|
||||
|
||||
double peakStart = start + fadeSeconds;
|
||||
double peakEnd = peakStart + peakSeconds;
|
||||
double end = peakEnd + fadeSeconds;
|
||||
|
||||
return LinearFadePoints(time, start, peakStart, peakEnd, end);
|
||||
}
|
||||
|
||||
public static float GetOpacity(
|
||||
Expression expression,
|
||||
TimeSpan timeOfDay,
|
||||
TimeSpan contentTime,
|
||||
TimeSpan contentTotalTime,
|
||||
TimeSpan channelTime)
|
||||
{
|
||||
expression.Parameters["content_seconds"] = contentTime.TotalSeconds;
|
||||
expression.Parameters["content_total_seconds"] = contentTotalTime.TotalSeconds;
|
||||
expression.Parameters["channel_seconds"] = channelTime.TotalSeconds;
|
||||
expression.Parameters["time_of_day_seconds"] = timeOfDay.TotalSeconds;
|
||||
|
||||
object expressionResult = expression.Evaluate();
|
||||
return Convert.ToSingle(expressionResult, CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using SixLabors.ImageSharp;
|
||||
using Image=SixLabors.ImageSharp.Image;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming;
|
||||
|
||||
public record PreparedElementImage(Image Image, Point Point, float Opacity, bool Dispose);
|
||||
@@ -0,0 +1,118 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NCalc;
|
||||
using SixLabors.Fonts;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Drawing.Processing;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using Image=SixLabors.ImageSharp.Image;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming;
|
||||
|
||||
public class TextElement(TextGraphicsElement textElement, ILogger logger) : IGraphicsElement, IDisposable
|
||||
{
|
||||
private Option<Expression> _maybeOpacityExpression;
|
||||
private float _opacity;
|
||||
private Image _image;
|
||||
private Point _location;
|
||||
|
||||
public int ZIndex { get; private set; }
|
||||
|
||||
public bool IsFailed { get; set; }
|
||||
|
||||
public Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(textElement.OpacityExpression))
|
||||
{
|
||||
var expression = new Expression(textElement.OpacityExpression);
|
||||
expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction;
|
||||
_maybeOpacityExpression = expression;
|
||||
}
|
||||
else
|
||||
{
|
||||
_opacity = (textElement.Opacity ?? 100) / 100.0f;
|
||||
}
|
||||
|
||||
ZIndex = textElement.ZIndex ?? 0;
|
||||
|
||||
var font = GraphicsEngineFonts.GetFont(textElement.FontFamily, textElement.FontSize ?? 48, FontStyle.Regular);
|
||||
var fontColor = Color.White;
|
||||
if (Color.TryParse(textElement.FontColor, out Color parsedColor) ||
|
||||
Color.TryParseHex(textElement.FontColor, out parsedColor))
|
||||
{
|
||||
fontColor = parsedColor;
|
||||
}
|
||||
|
||||
var textOptions = new RichTextOptions(font)
|
||||
{
|
||||
Origin = new PointF(0, 0),
|
||||
HorizontalAlignment = HorizontalAlignment.Left
|
||||
};
|
||||
|
||||
// if (Enum.TryParse(textElement.HorizontalAlignment, out HorizontalAlignment parsedAlignment))
|
||||
// {
|
||||
// textOptions.HorizontalAlignment = parsedAlignment;
|
||||
// }
|
||||
|
||||
FontRectangle textBounds = TextMeasurer.MeasureBounds(textElement.Text, textOptions);
|
||||
textOptions.Origin = new PointF(-textBounds.X, -textBounds.Y);
|
||||
|
||||
_image = new Image<Rgba32>((int)Math.Ceiling(textBounds.Width), (int)Math.Ceiling(textBounds.Height));
|
||||
_image.Mutate(ctx => ctx.DrawText(textOptions, textElement.Text, fontColor));
|
||||
|
||||
int horizontalMargin = (int)Math.Round((textElement.HorizontalMarginPercent ?? 0) / 100.0 * frameSize.Width);
|
||||
int verticalMargin = (int)Math.Round((textElement.VerticalMarginPercent ?? 0) / 100.0 * frameSize.Height);
|
||||
|
||||
_location = WatermarkElement.CalculatePosition(
|
||||
textElement.Location,
|
||||
frameSize.Width,
|
||||
frameSize.Height,
|
||||
_image.Width,
|
||||
_image.Height,
|
||||
horizontalMargin,
|
||||
verticalMargin);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsFailed = true;
|
||||
logger.LogWarning(ex, "Failed to initialize text element; will disable for this content");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<Option<PreparedElementImage>> PrepareImage(
|
||||
TimeSpan timeOfDay,
|
||||
TimeSpan contentTime,
|
||||
TimeSpan contentTotalTime,
|
||||
TimeSpan channelTime,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
float opacity = _opacity;
|
||||
foreach (var expression in _maybeOpacityExpression)
|
||||
{
|
||||
opacity = OpacityExpressionHelper.GetOpacity(
|
||||
expression,
|
||||
timeOfDay,
|
||||
contentTime,
|
||||
contentTotalTime,
|
||||
channelTime);
|
||||
}
|
||||
|
||||
return opacity == 0
|
||||
? ValueTask.FromResult(Option<PreparedElementImage>.None)
|
||||
: new ValueTask<Option<PreparedElementImage>>(new PreparedElementImage(_image, _location, opacity, false));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
|
||||
_image?.Dispose();
|
||||
_image = null;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NCalc;
|
||||
using NCalc.Handlers;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Gif;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
@@ -14,18 +12,21 @@ namespace ErsatzTV.Infrastructure.Streaming;
|
||||
|
||||
public class WatermarkElement : IGraphicsElement, IDisposable
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _imagePath;
|
||||
private readonly ChannelWatermark _watermark;
|
||||
private readonly List<Image> _scaledFrames = [];
|
||||
private readonly List<double> _frameDelays = [];
|
||||
|
||||
private Expression _expression;
|
||||
private Option<Expression> _maybeOpacityExpression;
|
||||
private float _opacity;
|
||||
private double _animatedDurationSeconds;
|
||||
private Image _sourceImage;
|
||||
private Point _location;
|
||||
|
||||
public WatermarkElement(WatermarkOptions watermarkOptions)
|
||||
public WatermarkElement(WatermarkOptions watermarkOptions, ILogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
// TODO: better model coming in here?
|
||||
foreach (var imagePath in watermarkOptions.ImagePath)
|
||||
{
|
||||
@@ -46,6 +47,8 @@ public class WatermarkElement : IGraphicsElement, IDisposable
|
||||
public bool IsFailed { get; set; }
|
||||
|
||||
public async Task InitializeAsync(Resolution frameSize, int frameRate, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_watermark.Mode is ChannelWatermarkMode.Intermittent)
|
||||
{
|
||||
@@ -60,19 +63,21 @@ public class WatermarkElement : IGraphicsElement, IDisposable
|
||||
)
|
||||
)
|
||||
)";
|
||||
_expression = new Expression(expressionString);
|
||||
_maybeOpacityExpression = new Expression(expressionString);
|
||||
}
|
||||
else if (_watermark.Mode is ChannelWatermarkMode.OpacityExpression && !string.IsNullOrWhiteSpace(_watermark.OpacityExpression))
|
||||
{
|
||||
_expression = new Expression(_watermark.OpacityExpression);
|
||||
_maybeOpacityExpression = new Expression(_watermark.OpacityExpression);
|
||||
}
|
||||
else
|
||||
{
|
||||
float opacity = _watermark.Opacity / 100.0f;
|
||||
_expression = new Expression(opacity.ToString(CultureInfo.InvariantCulture));
|
||||
_opacity = _watermark.Opacity / 100.0f;
|
||||
}
|
||||
|
||||
_expression.EvaluateFunction += EvaluateFunction;
|
||||
foreach (var expression in _maybeOpacityExpression)
|
||||
{
|
||||
expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction;
|
||||
}
|
||||
|
||||
bool isRemoteUri = Uri.TryCreate(_imagePath, UriKind.Absolute, out var uriResult)
|
||||
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
|
||||
@@ -122,72 +127,38 @@ public class WatermarkElement : IGraphicsElement, IDisposable
|
||||
_frameDelays.Add(frameDelay);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EvaluateFunction(string name, FunctionArgs args)
|
||||
catch (Exception ex)
|
||||
{
|
||||
switch (name)
|
||||
{
|
||||
case "LinearFadePoints":
|
||||
{
|
||||
if (args.Parameters.Length != 5)
|
||||
{
|
||||
throw new ArgumentException("LinearFadePoints() requires 5 arguments.");
|
||||
}
|
||||
|
||||
double time = Convert.ToDouble(args.Parameters[0].Evaluate(), CultureInfo.CurrentCulture);
|
||||
double start = Convert.ToDouble(args.Parameters[1].Evaluate(), CultureInfo.CurrentCulture);
|
||||
double peakStart = Convert.ToDouble(args.Parameters[2].Evaluate(), CultureInfo.CurrentCulture);
|
||||
double peakEnd = Convert.ToDouble(args.Parameters[3].Evaluate(), CultureInfo.CurrentCulture);
|
||||
double end = Convert.ToDouble(args.Parameters[4].Evaluate(), CultureInfo.CurrentCulture);
|
||||
|
||||
args.Result = LinearFadePoints(time, start, peakStart, peakEnd, end);
|
||||
break;
|
||||
}
|
||||
case "LinearFadeDuration":
|
||||
{
|
||||
if (args.Parameters.Length != 4)
|
||||
{
|
||||
throw new ArgumentException("LinearFadeDuration() requires 4 arguments.");
|
||||
}
|
||||
|
||||
double time = Convert.ToDouble(args.Parameters[0].Evaluate(), CultureInfo.CurrentCulture);
|
||||
double start = Convert.ToDouble(args.Parameters[1].Evaluate(), CultureInfo.CurrentCulture);
|
||||
double fadeSeconds = Convert.ToDouble(args.Parameters[2].Evaluate(), CultureInfo.CurrentCulture);
|
||||
double peakSeconds = Convert.ToDouble(args.Parameters[3].Evaluate(), CultureInfo.CurrentCulture);
|
||||
|
||||
args.Result = LinearFadeDuration(time, start, fadeSeconds, peakSeconds);
|
||||
break;
|
||||
}
|
||||
IsFailed = true;
|
||||
_logger.LogWarning(ex, "Failed to initialize watermark element; will disable for this content");
|
||||
}
|
||||
}
|
||||
|
||||
public void Draw(
|
||||
object context,
|
||||
public ValueTask<Option<PreparedElementImage>> PrepareImage(
|
||||
TimeSpan timeOfDay,
|
||||
TimeSpan contentTime,
|
||||
TimeSpan contentTotalTime,
|
||||
TimeSpan channelTime,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (context is not IImageProcessingContext imageProcessingContext)
|
||||
float opacity = _opacity;
|
||||
foreach (var expression in _maybeOpacityExpression)
|
||||
{
|
||||
return;
|
||||
opacity = OpacityExpressionHelper.GetOpacity(
|
||||
expression,
|
||||
timeOfDay,
|
||||
contentTime,
|
||||
contentTotalTime,
|
||||
channelTime);
|
||||
}
|
||||
|
||||
_expression.Parameters["content_seconds"] = contentTime.TotalSeconds;
|
||||
_expression.Parameters["content_total_seconds"] = contentTotalTime.TotalSeconds;
|
||||
_expression.Parameters["channel_seconds"] = channelTime.TotalSeconds;
|
||||
_expression.Parameters["time_of_day_seconds"] = timeOfDay.TotalSeconds;
|
||||
|
||||
object expressionResult = _expression.Evaluate();
|
||||
float opacity = Convert.ToSingle(expressionResult, CultureInfo.InvariantCulture);
|
||||
if (opacity == 0)
|
||||
{
|
||||
return;
|
||||
return ValueTask.FromResult(Option<PreparedElementImage>.None);
|
||||
}
|
||||
|
||||
Image frameForTimestamp = GetFrameForTimestamp(contentTime);
|
||||
imageProcessingContext.DrawImage(frameForTimestamp, _location, opacity);
|
||||
return ValueTask.FromResult(Optional(new PreparedElementImage(frameForTimestamp, _location, opacity, false)));
|
||||
}
|
||||
|
||||
private Image GetFrameForTimestamp(TimeSpan timestamp)
|
||||
@@ -212,12 +183,12 @@ public class WatermarkElement : IGraphicsElement, IDisposable
|
||||
return _scaledFrames.Last();
|
||||
}
|
||||
|
||||
private static Point CalculatePosition(
|
||||
internal static Point CalculatePosition(
|
||||
WatermarkLocation location,
|
||||
int frameWidth,
|
||||
int frameHeight,
|
||||
int scaledWidth,
|
||||
int scaledHeight,
|
||||
int imageWidth,
|
||||
int imageHeight,
|
||||
int horizontalMargin,
|
||||
int verticalMargin)
|
||||
{
|
||||
@@ -225,62 +196,23 @@ public class WatermarkElement : IGraphicsElement, IDisposable
|
||||
|
||||
return location switch
|
||||
{
|
||||
WatermarkLocation.BottomLeft => new Point(horizontalMargin, frameHeight - scaledHeight - verticalMargin),
|
||||
WatermarkLocation.BottomLeft => new Point(horizontalMargin, frameHeight - imageHeight - verticalMargin),
|
||||
WatermarkLocation.TopLeft => new Point(horizontalMargin, verticalMargin),
|
||||
WatermarkLocation.TopRight => new Point(frameWidth - scaledWidth - horizontalMargin, verticalMargin),
|
||||
WatermarkLocation.TopMiddle => new Point((frameWidth - scaledWidth) / 2, verticalMargin),
|
||||
WatermarkLocation.TopRight => new Point(frameWidth - imageWidth - horizontalMargin, verticalMargin),
|
||||
WatermarkLocation.TopMiddle => new Point((frameWidth - imageWidth) / 2, verticalMargin),
|
||||
WatermarkLocation.RightMiddle => new Point(
|
||||
frameWidth - scaledWidth - horizontalMargin,
|
||||
(frameHeight - scaledHeight) / 2),
|
||||
frameWidth - imageWidth - horizontalMargin,
|
||||
(frameHeight - imageHeight) / 2),
|
||||
WatermarkLocation.BottomMiddle => new Point(
|
||||
(frameWidth - scaledWidth) / 2,
|
||||
frameHeight - scaledHeight - verticalMargin),
|
||||
WatermarkLocation.LeftMiddle => new Point(horizontalMargin, (frameHeight - scaledHeight) / 2),
|
||||
(frameWidth - imageWidth) / 2,
|
||||
frameHeight - imageHeight - verticalMargin),
|
||||
WatermarkLocation.LeftMiddle => new Point(horizontalMargin, (frameHeight - imageHeight) / 2),
|
||||
_ => new Point(
|
||||
frameWidth - scaledWidth - horizontalMargin,
|
||||
frameHeight - scaledHeight - verticalMargin),
|
||||
frameWidth - imageWidth - horizontalMargin,
|
||||
frameHeight - imageHeight - verticalMargin),
|
||||
};
|
||||
}
|
||||
|
||||
private static double LinearFadePoints(double time, double start, double peakStart, double peakEnd, double end)
|
||||
{
|
||||
if (time < start || time >= end)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// fade in
|
||||
if (time < peakStart)
|
||||
{
|
||||
return (time - start) / (peakStart - start);
|
||||
}
|
||||
|
||||
// solid
|
||||
if (time < peakEnd)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// fade out
|
||||
return (end - time) / (end - peakEnd);
|
||||
}
|
||||
|
||||
private static double LinearFadeDuration(double time, double start, double fadeSeconds, double peakSeconds)
|
||||
{
|
||||
// edge case with no fade
|
||||
if (fadeSeconds <= 0)
|
||||
{
|
||||
double noFadeEnd = start + peakSeconds;
|
||||
return (time >= start && time < noFadeEnd) ? 1.0 : 0.0;
|
||||
}
|
||||
|
||||
double peakStart = start + fadeSeconds;
|
||||
double peakEnd = peakStart + peakSeconds;
|
||||
double end = peakEnd + fadeSeconds;
|
||||
|
||||
return LinearFadePoints(time, start, peakStart, peakEnd, end);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
|
||||
@@ -369,6 +369,7 @@ public class TranscodingTests
|
||||
now,
|
||||
[],
|
||||
GetWatermark(watermark),
|
||||
[],
|
||||
"drm",
|
||||
VaapiDriver.RadeonSI,
|
||||
"/dev/dri/renderD128",
|
||||
@@ -648,6 +649,7 @@ public class TranscodingTests
|
||||
now,
|
||||
[],
|
||||
channelWatermark,
|
||||
[],
|
||||
"drm",
|
||||
VaapiDriver.RadeonSI,
|
||||
"/dev/dri/renderD128",
|
||||
|
||||
@@ -29,6 +29,8 @@ public class TroubleshootController(
|
||||
[FromQuery]
|
||||
List<int> watermark,
|
||||
[FromQuery]
|
||||
List<int> graphicsElement,
|
||||
[FromQuery]
|
||||
int? subtitleId,
|
||||
[FromQuery]
|
||||
bool startFromBeginning,
|
||||
@@ -37,7 +39,7 @@ public class TroubleshootController(
|
||||
try
|
||||
{
|
||||
Either<BaseError, PlayoutItemResult> result = await mediator.Send(
|
||||
new PrepareTroubleshootingPlayback(mediaItem, ffmpegProfile, watermark, subtitleId, startFromBeginning),
|
||||
new PrepareTroubleshootingPlayback(mediaItem, ffmpegProfile, watermark, graphicsElement, subtitleId, startFromBeginning),
|
||||
cancellationToken);
|
||||
|
||||
if (result.IsLeft)
|
||||
@@ -115,11 +117,13 @@ public class TroubleshootController(
|
||||
[FromQuery]
|
||||
List<int> watermark,
|
||||
[FromQuery]
|
||||
List<int> graphicsElement,
|
||||
[FromQuery]
|
||||
bool startFromBeginning,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> maybeArchivePath = await mediator.Send(
|
||||
new ArchiveTroubleshootingResults(mediaItem, ffmpegProfile, watermark, startFromBeginning),
|
||||
new ArchiveTroubleshootingResults(mediaItem, ffmpegProfile, watermark, graphicsElement, startFromBeginning),
|
||||
cancellationToken);
|
||||
|
||||
foreach (string archivePath in maybeArchivePath)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/system/troubleshooting/playback"
|
||||
@using ErsatzTV.Application.FFmpegProfiles
|
||||
@using ErsatzTV.Application.Graphics
|
||||
@using ErsatzTV.Application.MediaItems
|
||||
@using ErsatzTV.Application.Troubleshooting
|
||||
@using ErsatzTV.Application.Troubleshooting.Queries
|
||||
@@ -54,6 +55,18 @@
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Subtitle</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_subtitleId" For="@(() => _subtitleId)" Clearable="true">
|
||||
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
|
||||
@foreach (SubtitleViewModel subtitleStream in _subtitleStreams)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@subtitleStream.Id">@($"{subtitleStream.Id}: {subtitleStream.Language} - {subtitleStream.Title} ({subtitleStream.Codec})")</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Watermarks</MudText>
|
||||
@@ -67,13 +80,12 @@
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Subtitle</MudText>
|
||||
<MudText>Graphics Elements</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_subtitleId" For="@(() => _subtitleId)" Clearable="true">
|
||||
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
|
||||
@foreach (SubtitleViewModel subtitleStream in _subtitleStreams)
|
||||
<MudSelect T="string" @bind-SelectedValues="_graphicsElementNames" Clearable="true" MultiSelection="true">
|
||||
@foreach (GraphicsElementViewModel graphicsElement in _graphicsElements)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@subtitleStream.Id">@($"{subtitleStream.Id}: {subtitleStream.Language} - {subtitleStream.Title} ({subtitleStream.Codec})")</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@graphicsElement.Name">@graphicsElement.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
@@ -121,9 +133,11 @@
|
||||
private readonly List<FFmpegProfileViewModel> _ffmpegProfiles = [];
|
||||
private readonly List<WatermarkViewModel> _watermarks = [];
|
||||
private readonly List<SubtitleViewModel> _subtitleStreams = [];
|
||||
private readonly List<GraphicsElementViewModel> _graphicsElements = [];
|
||||
private MediaItemInfo _info;
|
||||
private int _ffmpegProfileId;
|
||||
private IEnumerable<string> _watermarkNames = new System.Collections.Generic.HashSet<string>();
|
||||
private IEnumerable<string> _graphicsElementNames = new System.Collections.Generic.HashSet<string>();
|
||||
private int? _subtitleId;
|
||||
private bool _startFromBeginning;
|
||||
private bool _hasPlayed;
|
||||
@@ -155,6 +169,9 @@
|
||||
_watermarks.Clear();
|
||||
_watermarks.AddRange(await Mediator.Send(new GetAllWatermarks(), _cts.Token));
|
||||
|
||||
_graphicsElements.Clear();
|
||||
_graphicsElements.AddRange(await Mediator.Send(new GetAllGraphicsElements(), _cts.Token));
|
||||
|
||||
if (MediaItemId is not null)
|
||||
{
|
||||
await OnMediaItemIdChanged(MediaItemId);
|
||||
@@ -175,6 +192,13 @@
|
||||
uri.Query += $"&watermark={watermark.Id}";
|
||||
}
|
||||
}
|
||||
foreach (var graphicsElementName in _graphicsElementNames)
|
||||
{
|
||||
foreach (var graphicsElement in _graphicsElements.Where(ge => ge.Name == graphicsElementName))
|
||||
{
|
||||
uri.Query += $"&graphicsElement={graphicsElement.Id}";
|
||||
}
|
||||
}
|
||||
if (_subtitleId is not null)
|
||||
{
|
||||
uri.Query += $"&subtitleId={_subtitleId.Value}";
|
||||
@@ -216,6 +240,7 @@
|
||||
private async Task DownloadResults()
|
||||
{
|
||||
var uri = $"api/troubleshoot/playback/archive?mediaItem={MediaItemId ?? 0}&ffmpegProfile={_ffmpegProfileId}&startFromBeginning={_startFromBeginning}";
|
||||
|
||||
foreach (var watermarkName in _watermarkNames)
|
||||
{
|
||||
foreach (var watermark in _watermarks.Where(wm => wm.Name == watermarkName))
|
||||
@@ -223,6 +248,15 @@
|
||||
uri += $"&watermark={watermark.Id}";
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var graphicsElementName in _graphicsElementNames)
|
||||
{
|
||||
foreach (var graphicsElement in _graphicsElements.Where(ge => ge.Name == graphicsElementName))
|
||||
{
|
||||
uri += $"&graphicsElement={graphicsElement.Id}";
|
||||
}
|
||||
}
|
||||
|
||||
await JsRuntime.InvokeVoidAsync("window.open", uri);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using Bugsnag;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Emby;
|
||||
using ErsatzTV.Application.Graphics;
|
||||
using ErsatzTV.Application.Jellyfin;
|
||||
using ErsatzTV.Application.Maintenance;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
@@ -128,6 +129,8 @@ public class SchedulerService : BackgroundService
|
||||
await RefreshTraktLists(cancellationToken);
|
||||
await MatchTraktLists(cancellationToken);
|
||||
|
||||
await RefreshGraphicsElements(cancellationToken);
|
||||
|
||||
await ReleaseMemory(cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
@@ -367,6 +370,9 @@ public class SchedulerService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private ValueTask RefreshGraphicsElements(CancellationToken cancellationToken) =>
|
||||
_workerChannel.WriteAsync(new RefreshGraphicsElements(), cancellationToken);
|
||||
|
||||
private ValueTask DeleteOrphanedArtwork(CancellationToken cancellationToken) =>
|
||||
_workerChannel.WriteAsync(new DeleteOrphanedArtwork(), cancellationToken);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Threading.Channels;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Graphics;
|
||||
using ErsatzTV.Application.Maintenance;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
@@ -103,6 +104,9 @@ public class WorkerService : BackgroundService
|
||||
case MatchTraktListItems matchTraktListItems:
|
||||
await mediator.Send(matchTraktListItems, stoppingToken);
|
||||
break;
|
||||
case RefreshGraphicsElements refreshGraphicsElements:
|
||||
await mediator.Send(refreshGraphicsElements, stoppingToken);
|
||||
break;
|
||||
#if !DEBUG_NO_SYNC
|
||||
case ExtractEmbeddedSubtitles extractEmbeddedSubtitles:
|
||||
await mediator.Send(extractEmbeddedSubtitles, stoppingToken);
|
||||
|
||||
@@ -336,6 +336,8 @@ public class Startup
|
||||
FileSystemLayout.MusicVideoCreditsTemplatesFolder,
|
||||
FileSystemLayout.ChannelStreamSelectorsFolder,
|
||||
FileSystemLayout.ChannelGuideTemplatesFolder,
|
||||
FileSystemLayout.GraphicsElementsTemplatesFolder,
|
||||
FileSystemLayout.GraphicsElementsTextTemplatesFolder,
|
||||
FileSystemLayout.ScriptsFolder,
|
||||
FileSystemLayout.MultiEpisodeShuffleTemplatesFolder,
|
||||
FileSystemLayout.AudioStreamSelectorScriptsFolder
|
||||
|
||||
Reference in New Issue
Block a user