Browse Source

feat: image graphics element support in next (#2984)

* feat: image graphics element support in next

* fixes
pull/2985/head
Jason Dove 1 month ago committed by GitHub
parent
commit
cd30ac4aff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 16
      ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
  3. 3
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  4. 20
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  5. 3
      ErsatzTV.Core.Tests/FFmpeg/GraphicsElementSelectorTests.cs
  6. 36
      ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs
  7. 3
      ErsatzTV.Core/Interfaces/FFmpeg/IGraphicsElementSelector.cs
  8. 5
      ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs
  9. 233
      ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs
  10. 110
      ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs
  11. 29
      ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor

1
CHANGELOG.md

@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Next engine
- Use `overlay_qsv` for hardware-accelerated watermarks and image subtitles
- Support multiple watermarks (still only `permanent` and `intermittent` modes, not `opacity expression`)
- Support image graphics elements that do not use an opacity expression
### Changed
- Upgrade Mesa driver in docker from 25.2.8 to 26.0.3 to fix issues with hevc_vaapi encoder when using radeonsi driver

16
ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs

@ -129,8 +129,13 @@ public partial class SyncNextPlayoutHandler( @@ -129,8 +129,13 @@ public partial class SyncNextPlayoutHandler(
.ThenInclude(d => d.DecoGraphicsElements)
.ThenInclude(d => d.GraphicsElement)
// get watermarks
.Include(i => i.Watermarks)
// get graphics elements
.Include(i => i.PlayoutItemGraphicsElements)
.ThenInclude(pige => pige.GraphicsElement)
// get playout templates (and deco templates/decos)
.Include(i => i.Playout)
.ThenInclude(p => p.Templates)
@ -140,6 +145,15 @@ public partial class SyncNextPlayoutHandler( @@ -140,6 +145,15 @@ public partial class SyncNextPlayoutHandler(
.ThenInclude(d => d.DecoWatermarks)
.ThenInclude(d => d.Watermark)
// get playout templates (and deco templates/decos)
.Include(i => i.Playout)
.ThenInclude(p => p.Templates)
.ThenInclude(t => t.DecoTemplate)
.ThenInclude(t => t.Items)
.ThenInclude(i => i.Deco)
.ThenInclude(d => d.DecoGraphicsElements)
.ThenInclude(d => d.GraphicsElement)
.Include(i => i.MediaItem)
.ThenInclude(mi => mi.LibraryPath)
.ThenInclude(lp => lp.Library)
@ -232,6 +246,8 @@ public partial class SyncNextPlayoutHandler( @@ -232,6 +246,8 @@ public partial class SyncNextPlayoutHandler(
.AsNoTracking()
.Include(c => c.Watermark)
.Include(c => c.Artwork)
.Include(c => c.FFmpegProfile)
.ThenInclude(ff => ff.Resolution)
.SingleOrDefaultAsync(c => c.Number == channelNumber, cancellationToken)
.Map(Optional);

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

@ -358,7 +358,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -358,7 +358,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
List<PlayoutItemGraphicsElement> graphicsElements = _graphicsElementSelector.SelectGraphicsElements(
channel,
playoutItemWithPath.PlayoutItem,
now);
now,
shouldLogMessages: true);
if (playoutItemWithPath.PlayoutItem.MediaItem is Image)
{

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

@ -259,6 +259,14 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -259,6 +259,14 @@ public class PrepareTroubleshootingPlaybackHandler(
}
}
List<GraphicsElement> graphicsElements = [];
if (request.GraphicsElementIds.Count > 0)
{
graphicsElements = await dbContext.GraphicsElements
.Where(ge => request.GraphicsElementIds.Contains(ge.Id))
.ToListAsync(cancellationToken);
}
switch (request.StreamingEngine)
{
case StreamingEngine.Next:
@ -270,6 +278,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -270,6 +278,7 @@ public class PrepareTroubleshootingPlaybackHandler(
inPoint,
outPoint,
watermarks,
graphicsElements,
cancellationToken);
default:
return await GetLegacyProcess(
@ -283,6 +292,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -283,6 +292,7 @@ public class PrepareTroubleshootingPlaybackHandler(
channel,
inPoint,
watermarks,
graphicsElements,
cancellationToken);
}
}
@ -295,6 +305,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -295,6 +305,7 @@ public class PrepareTroubleshootingPlaybackHandler(
TimeSpan inPoint,
TimeSpan outPoint,
List<WatermarkOptions> watermarks,
List<GraphicsElement> graphicsElements,
CancellationToken cancellationToken)
{
Validation<BaseError, string> channelBinaryResult = await ChannelBinaryMustExist();
@ -345,8 +356,8 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -345,8 +356,8 @@ public class PrepareTroubleshootingPlaybackHandler(
CollectionKey = null,
CollectionEtag = null,
PlayoutItemWatermarks = [],
GraphicsElements = [],
PlayoutItemGraphicsElements = []
GraphicsElements = null,
PlayoutItemGraphicsElements = [.. graphicsElements.Map(ge => new PlayoutItemGraphicsElement { GraphicsElement = ge })]
};
Option<Core.Next.PlayoutItem> maybeNextPlayoutItem =
@ -412,6 +423,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -412,6 +423,7 @@ public class PrepareTroubleshootingPlaybackHandler(
Channel channel,
TimeSpan inPoint,
List<WatermarkOptions> watermarks,
List<GraphicsElement> graphicsElements,
CancellationToken cancellationToken)
{
MediaVersion version = mediaItem.GetHeadVersion();
@ -470,10 +482,6 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -470,10 +482,6 @@ public class PrepareTroubleshootingPlaybackHandler(
// we cannot burst live input
bool hlsRealtime = mediaItem is RemoteStream { IsLive: true };
List<GraphicsElement> graphicsElements = await dbContext.GraphicsElements
.Where(ge => request.GraphicsElementIds.Contains(ge.Id))
.ToListAsync(cancellationToken);
PlayoutItemResult playoutItemResult = await ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
ffprobePath,

3
ErsatzTV.Core.Tests/FFmpeg/GraphicsElementSelectorTests.cs

@ -368,7 +368,8 @@ public class GraphicsElementSelectorTests @@ -368,7 +368,8 @@ public class GraphicsElementSelectorTests
List<PlayoutItemGraphicsElement> result = GraphicsElementSelector.SelectGraphicsElements(
testCase.channel,
testCase.playoutItem,
Now);
Now,
shouldLogMessages: true);
result.Map(pige => pige.GraphicsElement).ShouldBe(testCase.expected);
}

36
ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs

@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain.Filler; @@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.FFmpeg;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace ErsatzTV.Core.FFmpeg;
@ -12,9 +13,14 @@ public class GraphicsElementSelector(IDecoSelector decoSelector, ILogger<Graphic @@ -12,9 +13,14 @@ public class GraphicsElementSelector(IDecoSelector decoSelector, ILogger<Graphic
public List<PlayoutItemGraphicsElement> SelectGraphicsElements(
Channel channel,
PlayoutItem playoutItem,
DateTimeOffset now)
DateTimeOffset now,
bool shouldLogMessages)
{
logger.LogDebug("Checking for graphics elements at {Now}", now);
ILogger<GraphicsElementSelector> log = shouldLogMessages
? logger
: NullLogger<GraphicsElementSelector>.Instance;
log.LogDebug("Checking for graphics elements at {Now}", now);
var result = new List<PlayoutItemGraphicsElement>();
@ -25,7 +31,7 @@ public class GraphicsElementSelector(IDecoSelector decoSelector, ILogger<Graphic @@ -25,7 +31,7 @@ public class GraphicsElementSelector(IDecoSelector decoSelector, ILogger<Graphic
// if (playoutItem.DisableWatermarks)
// {
// logger.LogDebug("Graphics elements are disabled by playout item");
// log.LogDebug("Graphics elements are disabled by playout item");
// return result;
// }
@ -41,21 +47,21 @@ public class GraphicsElementSelector(IDecoSelector decoSelector, ILogger<Graphic @@ -41,21 +47,21 @@ public class GraphicsElementSelector(IDecoSelector decoSelector, ILogger<Graphic
case DecoMode.Merge:
if (playoutItem.FillerKind is FillerKind.None || templateDeco.UseGraphicsElementsDuringFiller)
{
logger.LogDebug("Graphics elements will come from template deco (merge)");
log.LogDebug("Graphics elements will come from template deco (merge)");
result.AddRange(
templateDeco.DecoGraphicsElements.Map(dge => dge.GraphicsElement).Map(ge =>
new PlayoutItemGraphicsElement { PlayoutItem = playoutItem, GraphicsElement = ge }));
break;
}
logger.LogDebug("Graphics elements are disabled by template deco during filler");
log.LogDebug("Graphics elements are disabled by template deco during filler");
result.Clear();
done = true;
break;
case DecoMode.Override:
if (playoutItem.FillerKind is FillerKind.None || templateDeco.UseGraphicsElementsDuringFiller)
{
logger.LogDebug("Graphics elements will come from template deco (replace)");
log.LogDebug("Graphics elements will come from template deco (replace)");
result.AddRange(
templateDeco.DecoGraphicsElements.Map(dge => dge.GraphicsElement).Map(ge =>
new PlayoutItemGraphicsElement { PlayoutItem = playoutItem, GraphicsElement = ge }));
@ -63,16 +69,16 @@ public class GraphicsElementSelector(IDecoSelector decoSelector, ILogger<Graphic @@ -63,16 +69,16 @@ public class GraphicsElementSelector(IDecoSelector decoSelector, ILogger<Graphic
break;
}
logger.LogDebug("Graphics elements are disabled by template deco during filler");
log.LogDebug("Graphics elements are disabled by template deco during filler");
result.Clear();
done = true;
break;
case DecoMode.Disable:
logger.LogDebug("Graphics elements are disabled by template deco");
log.LogDebug("Graphics elements are disabled by template deco");
done = true;
break;
case DecoMode.Inherit:
logger.LogDebug("Graphics elements will inherit from playout deco");
log.LogDebug("Graphics elements will inherit from playout deco");
break;
}
@ -92,21 +98,21 @@ public class GraphicsElementSelector(IDecoSelector decoSelector, ILogger<Graphic @@ -92,21 +98,21 @@ public class GraphicsElementSelector(IDecoSelector decoSelector, ILogger<Graphic
case DecoMode.Merge:
if (playoutItem.FillerKind is FillerKind.None || playoutDeco.UseGraphicsElementsDuringFiller)
{
logger.LogDebug("Graphics elements will come from playout deco (merge)");
log.LogDebug("Graphics elements will come from playout deco (merge)");
result.AddRange(
playoutDeco.DecoGraphicsElements.Map(dge => dge.GraphicsElement).Map(ge =>
new PlayoutItemGraphicsElement { PlayoutItem = playoutItem, GraphicsElement = ge }));
break;
}
logger.LogDebug("Graphics elements are disabled by playout deco during filler");
log.LogDebug("Graphics elements are disabled by playout deco during filler");
result.Clear();
done = true;
break;
case DecoMode.Override:
if (playoutItem.FillerKind is FillerKind.None || playoutDeco.UseGraphicsElementsDuringFiller)
{
logger.LogDebug("Graphics elements will come from playout deco (replace)");
log.LogDebug("Graphics elements will come from playout deco (replace)");
result.AddRange(
playoutDeco.DecoGraphicsElements.Map(dge => dge.GraphicsElement).Map(ge =>
new PlayoutItemGraphicsElement { PlayoutItem = playoutItem, GraphicsElement = ge }));
@ -114,16 +120,16 @@ public class GraphicsElementSelector(IDecoSelector decoSelector, ILogger<Graphic @@ -114,16 +120,16 @@ public class GraphicsElementSelector(IDecoSelector decoSelector, ILogger<Graphic
break;
}
logger.LogDebug("Graphics elements are disabled by playout deco during filler");
log.LogDebug("Graphics elements are disabled by playout deco during filler");
result.Clear();
done = true;
break;
case DecoMode.Disable:
logger.LogDebug("Graphics elements are disabled by playout deco");
log.LogDebug("Graphics elements are disabled by playout deco");
done = true;
break;
case DecoMode.Inherit:
logger.LogDebug("Graphics elements will inherit from channel and/or global setting");
log.LogDebug("Graphics elements will inherit from channel and/or global setting");
break;
}

3
ErsatzTV.Core/Interfaces/FFmpeg/IGraphicsElementSelector.cs

@ -7,5 +7,6 @@ public interface IGraphicsElementSelector @@ -7,5 +7,6 @@ public interface IGraphicsElementSelector
List<PlayoutItemGraphicsElement> SelectGraphicsElements(
Channel channel,
PlayoutItem playoutItem,
DateTimeOffset now);
DateTimeOffset now,
bool shouldLogMessages);
}

5
ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs

@ -34,6 +34,11 @@ public class TemplateDataRepository(IFileSystem fileSystem, IDbContextFactory<Tv @@ -34,6 +34,11 @@ public class TemplateDataRepository(IFileSystem fileSystem, IDbContextFactory<Tv
{
try
{
if (count <= 0)
{
return Option<Dictionary<string, object>>.None;
}
if (channelNumber.Equals(
FileSystemLayout.TranscodeTroubleshootingChannel,
StringComparison.OrdinalIgnoreCase))

233
ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs

@ -11,10 +11,14 @@ using ErsatzTV.Core.Interfaces.FFmpeg; @@ -11,10 +11,14 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using MediaStream = ErsatzTV.Core.Domain.MediaStream;
using PlayoutItem = ErsatzTV.Core.Domain.PlayoutItem;
namespace ErsatzTV.Infrastructure.Scheduling;
@ -26,6 +30,8 @@ public class PlayoutItemConverter( @@ -26,6 +30,8 @@ public class PlayoutItemConverter(
ICustomStreamSelector customStreamSelector,
IFFmpegStreamSelector ffmpegStreamSelector,
IWatermarkSelector watermarkSelector,
IGraphicsElementSelector graphicsElementSelector,
IGraphicsElementLoader graphicsElementLoader,
IDbContextFactory<TvContext> dbContextFactory) : IPlayoutItemConverter
{
public async Task<Option<Core.Next.PlayoutItem>> ToNext(
@ -207,11 +213,14 @@ public class PlayoutItemConverter( @@ -207,11 +213,14 @@ public class PlayoutItemConverter(
subtitles,
shouldLogMessages,
cancellationToken);
await SelectWatermark(
await SelectGraphics(
maybeGlobalWatermark,
channel,
playoutItem,
nextPlayoutItem);
nextPlayoutItem,
headVersion.RFrameRate,
shouldLogMessages,
cancellationToken);
}
}
@ -453,7 +462,7 @@ public class PlayoutItemConverter( @@ -453,7 +462,7 @@ public class PlayoutItemConverter(
}
}
}
else if (!subtitle.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
else if (!IsRemoteUri(subtitle.Path))
{
if (nextPlayoutItem.Tracks?.Subtitle?.Source is null)
{
@ -486,12 +495,18 @@ public class PlayoutItemConverter( @@ -486,12 +495,18 @@ public class PlayoutItemConverter(
}
}
private async Task SelectWatermark(
private async Task SelectGraphics(
Option<ChannelWatermark> maybeGlobalWatermark,
Channel channel,
PlayoutItem playoutItem,
Core.Next.PlayoutItem nextPlayoutItem)
Core.Next.PlayoutItem nextPlayoutItem,
string frameRate,
bool shouldLogMessages,
CancellationToken cancellationToken)
{
nextPlayoutItem.Graphics ??= [];
var result = new List<KeyValuePair<Core.Next.GraphicsLayer, int>>();
List<WatermarkOptions> watermarks = watermarkSelector.SelectWatermarks(
maybeGlobalWatermark,
channel,
@ -500,71 +515,166 @@ public class PlayoutItemConverter( @@ -500,71 +515,166 @@ public class PlayoutItemConverter(
shouldLogMessages: false);
// permanent or intermittent watermarks are supported
if (watermarks.All(wm => wm.Watermark.Mode is ChannelWatermarkMode.Permanent or ChannelWatermarkMode.Intermittent))
IEnumerable<WatermarkOptions> supportedWatermarks = watermarks.Where(wm =>
wm.Watermark.Mode is ChannelWatermarkMode.Permanent or ChannelWatermarkMode.Intermittent);
foreach (WatermarkOptions watermarkOptions in supportedWatermarks)
{
nextPlayoutItem.Graphics = [];
var layer = new Core.Next.GraphicsLayer
{
Location = ToGraphics(watermarkOptions.Watermark.Location),
HorizontalMarginPercent = watermarkOptions.Watermark.HorizontalMarginPercent,
VerticalMarginPercent = watermarkOptions.Watermark.VerticalMarginPercent,
OpacityPercent = watermarkOptions.Watermark.Opacity,
StreamIndex = await watermarkOptions.ImageStreamIndex.IfNoneAsync(0),
WithinSourceContent = watermarkOptions.Watermark.PlaceWithinSourceContent,
};
foreach (WatermarkOptions watermarkOptions in watermarks.OrderBy(wm => wm.Watermark.ZIndex))
if (watermarkOptions.Watermark.Size is WatermarkSize.Scaled)
{
Core.Next.GraphicsLocation location = watermarkOptions.Watermark.Location switch
layer.WidthPercent = watermarkOptions.Watermark.WidthPercent;
}
if (IsRemoteUri(watermarkOptions.ImagePath))
{
layer.Source = new Core.Next.PlayoutItemSource
{
WatermarkLocation.TopMiddle => Core.Next.GraphicsLocation.TopCenter,
WatermarkLocation.TopRight => Core.Next.GraphicsLocation.TopRight,
WatermarkLocation.LeftMiddle => Core.Next.GraphicsLocation.CenterLeft,
WatermarkLocation.MiddleCenter => Core.Next.GraphicsLocation.Center,
WatermarkLocation.RightMiddle => Core.Next.GraphicsLocation.CenterRight,
WatermarkLocation.BottomLeft => Core.Next.GraphicsLocation.BottomLeft,
WatermarkLocation.BottomMiddle => Core.Next.GraphicsLocation.BottomCenter,
WatermarkLocation.BottomRight => Core.Next.GraphicsLocation.BottomRight,
_ => Core.Next.GraphicsLocation.TopLeft,
SourceType = Core.Next.SourceType.Http,
Uri = watermarkOptions.ImagePath,
};
var layer = new Core.Next.GraphicsLayer
}
else
{
layer.Source = new Core.Next.PlayoutItemSource
{
Location = location,
HorizontalMarginPercent = watermarkOptions.Watermark.HorizontalMarginPercent,
VerticalMarginPercent = watermarkOptions.Watermark.VerticalMarginPercent,
OpacityPercent = watermarkOptions.Watermark.Opacity,
StreamIndex = await watermarkOptions.ImageStreamIndex.IfNoneAsync(0),
WithinSourceContent = watermarkOptions.Watermark.PlaceWithinSourceContent,
SourceType = Core.Next.SourceType.Local,
Path = watermarkOptions.ImagePath,
};
}
if (watermarkOptions.Watermark.Size is WatermarkSize.Scaled)
if (watermarkOptions.Watermark.Mode is ChannelWatermarkMode.Intermittent)
{
layer.Timing = new Core.Next.Timing
{
layer.WidthPercent = watermarkOptions.Watermark.WidthPercent;
}
TimingType = Core.Next.TimingType.Periodic,
Clock = Core.Next.PeriodicClock.Wall,
FrequencyMs = watermarkOptions.Watermark.FrequencyMinutes * 60 * 1000,
HoldMs = watermarkOptions.Watermark.DurationSeconds * 1000,
};
}
if (watermarkOptions.ImagePath.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
layer.Source = new Core.Next.PlayoutItemSource
result.Add(new KeyValuePair<Core.Next.GraphicsLayer, int>(layer, watermarkOptions.Watermark.ZIndex));
}
List<PlayoutItemGraphicsElement> graphicsElements = graphicsElementSelector.SelectGraphicsElements(
channel,
playoutItem,
playoutItem.StartOffset,
shouldLogMessages);
IEnumerable<PlayoutItemGraphicsElement> supportedGraphicsElements = graphicsElements
.Where(ge => ge.GraphicsElement.Kind is GraphicsElementKind.Image);
var outputFrameSize = new Resolution
{
Width = channel.FFmpegProfile.Resolution.Width,
Height = channel.FFmpegProfile.Resolution.Height,
};
var squarePixelFrameSize = new Resolution
{
Width = outputFrameSize.Width,
Height = outputFrameSize.Height
};
var headVersion = playoutItem.MediaItem.GetHeadVersion();
Option<VideoStream> maybeVideoStream = headVersion.Streams
.Where(s => s.MediaStreamKind is MediaStreamKind.Video)
.HeadOrNone()
.Select(v => new VideoStream(
v.Index,
v.Codec,
v.Profile,
None,
ColorParams.Unknown,
new FrameSize(headVersion.Width, headVersion.Height),
headVersion.SampleAspectRatio,
headVersion.DisplayAspectRatio,
None,
StillImage: false,
ScanKind.Progressive));
foreach (var videoStream in maybeVideoStream)
{
var frameSize =
videoStream.SquarePixelFrameSize(new FrameSize(outputFrameSize.Width, outputFrameSize.Height));
squarePixelFrameSize.Width = frameSize.Width;
squarePixelFrameSize.Height = frameSize.Height;
}
var context = new GraphicsEngineContext(
channel.Number,
playoutItem.MediaItem,
Elements: [],
TemplateVariables: [],
squarePixelFrameSize,
outputFrameSize,
new FrameRate(frameRate),
playoutItem.StartOffset,
playoutItem.StartOffset,
TimeSpan.Zero,
playoutItem.OutPoint - playoutItem.InPoint,
playoutItem.MediaItem.GetDurationForPlayout());
context = await graphicsElementLoader.LoadAll(context, [.. supportedGraphicsElements], cancellationToken);
foreach (GraphicsElementContext element in context?.Elements ?? [])
{
switch (element)
{
case ImageElementDataContext({ } image):
// opacity expressions are not supported yet
if (!string.IsNullOrWhiteSpace(image.OpacityExpression))
{
SourceType = Core.Next.SourceType.Http,
Uri = watermarkOptions.ImagePath,
};
}
else
{
layer.Source = new Core.Next.PlayoutItemSource
continue;
}
var layer = new Core.Next.GraphicsLayer
{
SourceType = Core.Next.SourceType.Local,
Path = watermarkOptions.ImagePath,
Location = ToGraphics(image.Location),
HorizontalMarginPercent = image.HorizontalMarginPercent,
VerticalMarginPercent = image.VerticalMarginPercent,
OpacityPercent = image.OpacityPercent,
StreamIndex = 0,
WithinSourceContent = image.PlaceWithinSourceContent,
WidthPercent = image.Scale ? image.ScaleWidthPercent ?? 100 : null,
};
}
if (watermarkOptions.Watermark.Mode is ChannelWatermarkMode.Intermittent)
{
layer.Timing = new Core.Next.Timing
if (IsRemoteUri(image.Image))
{
TimingType = Core.Next.TimingType.Periodic,
Clock = Core.Next.PeriodicClock.Wall,
FrequencyMs = watermarkOptions.Watermark.FrequencyMinutes * 60 * 1000,
HoldMs = watermarkOptions.Watermark.DurationSeconds * 1000,
};
}
layer.Source = new Core.Next.PlayoutItemSource
{
SourceType = Core.Next.SourceType.Http,
Uri = image.Image,
};
}
else
{
layer.Source = new Core.Next.PlayoutItemSource
{
SourceType = Core.Next.SourceType.Local,
Path = image.Image,
};
}
nextPlayoutItem.Graphics.Add(layer);
result.Add(new KeyValuePair<Core.Next.GraphicsLayer, int>(layer, image.ZIndex ?? 0));
break;
}
}
nextPlayoutItem.Graphics.Clear();
nextPlayoutItem.Graphics.AddRange(result.OrderBy(kvp => kvp.Value).Select(kvp => kvp.Key));
}
private static async Task<List<Subtitle>> GetSubtitles(
@ -642,4 +752,23 @@ public class PlayoutItemConverter( @@ -642,4 +752,23 @@ public class PlayoutItemConverter(
}
}
}
private static Core.Next.GraphicsLocation ToGraphics(WatermarkLocation watermarkLocation) =>
watermarkLocation switch
{
WatermarkLocation.TopMiddle => Core.Next.GraphicsLocation.TopCenter,
WatermarkLocation.TopRight => Core.Next.GraphicsLocation.TopRight,
WatermarkLocation.LeftMiddle => Core.Next.GraphicsLocation.CenterLeft,
WatermarkLocation.MiddleCenter => Core.Next.GraphicsLocation.Center,
WatermarkLocation.RightMiddle => Core.Next.GraphicsLocation.CenterRight,
WatermarkLocation.BottomLeft => Core.Next.GraphicsLocation.BottomLeft,
WatermarkLocation.BottomMiddle => Core.Next.GraphicsLocation.BottomCenter,
WatermarkLocation.BottomRight => Core.Next.GraphicsLocation.BottomRight,
_ => Core.Next.GraphicsLocation.TopLeft
};
private static bool IsRemoteUri(string path) =>
Uri.TryCreate(path, UriKind.Absolute, out Uri uriResult)
&& (uriResult.Scheme == Uri.UriSchemeHttp ||
uriResult.Scheme == Uri.UriSchemeHttps);
}

110
ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.IO.Abstractions;
using System.Text;
using System.Text.RegularExpressions;
@ -23,6 +24,8 @@ public partial class GraphicsElementLoader( @@ -23,6 +24,8 @@ public partial class GraphicsElementLoader(
ILogger<GraphicsElementLoader> logger)
: IGraphicsElementLoader
{
private readonly ConcurrentDictionary<string, string> _yamlCache = new();
public async Task<GraphicsEngineContext> LoadAll(
GraphicsEngineContext context,
List<PlayoutItemGraphicsElement> elements,
@ -31,7 +34,7 @@ public partial class GraphicsElementLoader( @@ -31,7 +34,7 @@ public partial class GraphicsElementLoader(
try
{
// get max epg entries
int epgEntries = await GetMaxEpgEntries(elements);
int epgEntries = await GetMaxEpgEntries(elements, cancellationToken);
// init template element variables once
Dictionary<string, object> templateVariables =
@ -49,7 +52,8 @@ public partial class GraphicsElementLoader( @@ -49,7 +52,8 @@ public partial class GraphicsElementLoader(
{
Option<TextGraphicsElement> maybeElement = await LoadText(
reference.GraphicsElement.Path,
templateVariables);
templateVariables,
cancellationToken);
if (maybeElement.IsNone)
{
logger.LogWarning(
@ -68,7 +72,8 @@ public partial class GraphicsElementLoader( @@ -68,7 +72,8 @@ public partial class GraphicsElementLoader(
{
Option<ImageGraphicsElement> maybeElement = await LoadImage(
reference.GraphicsElement.Path,
templateVariables);
templateVariables,
cancellationToken);
if (maybeElement.IsNone)
{
logger.LogWarning(
@ -87,7 +92,8 @@ public partial class GraphicsElementLoader( @@ -87,7 +92,8 @@ public partial class GraphicsElementLoader(
{
Option<MotionGraphicsElement> maybeElement = await LoadMotion(
reference.GraphicsElement.Path,
templateVariables);
templateVariables,
cancellationToken);
if (maybeElement.IsNone)
{
logger.LogWarning(
@ -106,7 +112,8 @@ public partial class GraphicsElementLoader( @@ -106,7 +112,8 @@ public partial class GraphicsElementLoader(
{
Option<SubtitleGraphicsElement> maybeElement = await LoadSubtitle(
reference.GraphicsElement.Path,
templateVariables);
templateVariables,
cancellationToken);
if (maybeElement.IsNone)
{
logger.LogWarning(
@ -132,7 +139,8 @@ public partial class GraphicsElementLoader( @@ -132,7 +139,8 @@ public partial class GraphicsElementLoader(
{
Option<ScriptGraphicsElement> maybeElement = await LoadScript(
reference.GraphicsElement.Path,
templateVariables);
templateVariables,
cancellationToken);
if (maybeElement.IsNone)
{
logger.LogWarning(
@ -169,7 +177,7 @@ public partial class GraphicsElementLoader( @@ -169,7 +177,7 @@ public partial class GraphicsElementLoader(
{
try
{
string yaml = await fileSystem.File.ReadAllTextAsync(fileName, cancellationToken);
string yaml = await ReadFile(fileName, cancellationToken);
var template = Template.Parse(yaml);
var builder = new StringBuilder();
@ -200,7 +208,7 @@ public partial class GraphicsElementLoader( @@ -200,7 +208,7 @@ public partial class GraphicsElementLoader(
return Option<string>.None;
}
private async Task<int> GetMaxEpgEntries(List<PlayoutItemGraphicsElement> elements)
private async Task<int> GetMaxEpgEntries(List<PlayoutItemGraphicsElement> elements, CancellationToken cancellationToken)
{
var epgEntries = 0;
@ -212,7 +220,8 @@ public partial class GraphicsElementLoader( @@ -212,7 +220,8 @@ public partial class GraphicsElementLoader(
{
try
{
foreach (string line in await fileSystem.File.ReadAllLinesAsync(reference.GraphicsElement.Path))
string text = await ReadFile(reference.GraphicsElement.Path, cancellationToken);
foreach (string line in text.Split("\n"))
{
Match match = EpgEntriesRegex().Match(line);
if (!match.Success || !int.TryParse(match.Groups[1].Value, out int value))
@ -235,20 +244,35 @@ public partial class GraphicsElementLoader( @@ -235,20 +244,35 @@ public partial class GraphicsElementLoader(
return epgEntries;
}
private Task<Option<ImageGraphicsElement>> LoadImage(string fileName, Dictionary<string, object> variables) =>
GetTemplatedYaml(fileName, variables).BindT(FromYaml<ImageGraphicsElement>);
private Task<Option<TextGraphicsElement>> LoadText(string fileName, Dictionary<string, object> variables) =>
GetTemplatedYaml(fileName, variables).BindT(FromYaml<TextGraphicsElement>);
private Task<Option<MotionGraphicsElement>> LoadMotion(string fileName, Dictionary<string, object> variables) =>
GetTemplatedYaml(fileName, variables).BindT(FromYaml<MotionGraphicsElement>);
private Task<Option<SubtitleGraphicsElement>> LoadSubtitle(string fileName, Dictionary<string, object> variables) =>
GetTemplatedYaml(fileName, variables).BindT(FromYaml<SubtitleGraphicsElement>);
private Task<Option<ScriptGraphicsElement>> LoadScript(string fileName, Dictionary<string, object> variables) =>
GetTemplatedYaml(fileName, variables).BindT(FromYaml<ScriptGraphicsElement>);
private Task<Option<ImageGraphicsElement>> LoadImage(
string fileName,
Dictionary<string, object> variables,
CancellationToken cancellationToken) =>
GetTemplatedYaml(fileName, variables, cancellationToken).BindT(FromYaml<ImageGraphicsElement>);
private Task<Option<TextGraphicsElement>> LoadText(
string fileName,
Dictionary<string, object> variables,
CancellationToken cancellationToken) =>
GetTemplatedYaml(fileName, variables, cancellationToken).BindT(FromYaml<TextGraphicsElement>);
private Task<Option<MotionGraphicsElement>> LoadMotion(
string fileName,
Dictionary<string, object> variables,
CancellationToken cancellationToken) =>
GetTemplatedYaml(fileName, variables, cancellationToken).BindT(FromYaml<MotionGraphicsElement>);
private Task<Option<SubtitleGraphicsElement>> LoadSubtitle(
string fileName,
Dictionary<string, object> variables,
CancellationToken cancellationToken) =>
GetTemplatedYaml(fileName, variables, cancellationToken).BindT(FromYaml<SubtitleGraphicsElement>);
private Task<Option<ScriptGraphicsElement>> LoadScript(
string fileName,
Dictionary<string, object> variables,
CancellationToken cancellationToken) =>
GetTemplatedYaml(fileName, variables, cancellationToken).BindT(FromYaml<ScriptGraphicsElement>);
private async Task<Dictionary<string, object>> InitTemplateVariables(
GraphicsEngineContext context,
@ -280,23 +304,29 @@ public partial class GraphicsElementLoader( @@ -280,23 +304,29 @@ public partial class GraphicsElementLoader(
}
// epg variables
DateTimeOffset startTime = context.ContentStartTime + context.Seek;
Option<Dictionary<string, object>> maybeEpgData =
await templateDataRepository.GetEpgTemplateData(context.ChannelNumber, startTime, epgEntries);
foreach (Dictionary<string, object> templateData in maybeEpgData)
if (epgEntries > 0)
{
foreach (KeyValuePair<string, object> variable in templateData)
DateTimeOffset startTime = context.ContentStartTime + context.Seek;
Option<Dictionary<string, object>> maybeEpgData =
await templateDataRepository.GetEpgTemplateData(context.ChannelNumber, startTime, epgEntries);
foreach (Dictionary<string, object> templateData in maybeEpgData)
{
result.Add(variable.Key, variable.Value);
foreach (KeyValuePair<string, object> variable in templateData)
{
result.Add(variable.Key, variable.Value);
}
}
}
return result;
}
private async Task<Option<TemplatedYaml>> GetTemplatedYaml(string fileName, Dictionary<string, object> variables)
private async Task<Option<TemplatedYaml>> GetTemplatedYaml(
string fileName,
Dictionary<string, object> variables,
CancellationToken cancellationToken)
{
string yaml = await fileSystem.File.ReadAllTextAsync(fileName);
string yaml = await ReadFile(fileName, cancellationToken);
try
{
var scriptObject = new ScriptObject();
@ -304,7 +334,9 @@ public partial class GraphicsElementLoader( @@ -304,7 +334,9 @@ public partial class GraphicsElementLoader(
scriptObject.Import("convert_timezone", templateFunctions.ConvertTimeZone);
scriptObject.Import("format_datetime", templateFunctions.FormatDateTime);
scriptObject.Import("get_directory_name", (string path) => Path.GetDirectoryName(path));
scriptObject.Import("get_filename_without_extension", (string path) => Path.GetFileNameWithoutExtension(path));
scriptObject.Import(
"get_filename_without_extension",
(string path) => Path.GetFileNameWithoutExtension(path));
var context = new TemplateContext { MemberRenamer = member => member.Name };
context.PushGlobal(scriptObject);
@ -361,6 +393,20 @@ public partial class GraphicsElementLoader( @@ -361,6 +393,20 @@ public partial class GraphicsElementLoader(
}
}
private async Task<string> ReadFile(string fileName, CancellationToken cancellationToken)
{
string key = fileSystem.Path.GetFullPath(fileName);
if (_yamlCache.TryGetValue(key, out string cached))
{
return cached;
}
string yaml = await fileSystem.File.ReadAllTextAsync(key, cancellationToken);
_yamlCache.TryAdd(key, yaml);
return yaml;
}
[GeneratedRegex(@"epg_entries:\s*(\d+)")]
private static partial Regex EpgEntriesRegex();

29
ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor

@ -126,22 +126,19 @@ @@ -126,22 +126,19 @@
}
</MudSelect>
</MudStack>
if (_streamingEngine is StreamingEngine.Legacy)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Graphics Elements</MudText>
</div>
<MudSelect T="string" @bind-SelectedValues="_graphicsElementNames" Clearable="true"
MultiSelection="true">
@foreach (GraphicsElementViewModel graphicsElement in _graphicsElements)
{
<MudSelectItem T="string"
Value="@graphicsElement.Name">@graphicsElement.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Graphics Elements</MudText>
</div>
<MudSelect T="string" @bind-SelectedValues="_graphicsElementNames" Clearable="true"
MultiSelection="true">
@foreach (GraphicsElementViewModel graphicsElement in _graphicsElements)
{
<MudSelectItem T="string"
Value="@graphicsElement.Name">@graphicsElement.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Start From Beginning</MudText>

Loading…
Cancel
Save