Browse Source

add script graphics element (#2681)

* add script graphics element

* pass template data as json to stdin

* update changelog
pull/2683/head
Jason Dove 1 month ago committed by GitHub
parent
commit
c524bc0d7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 17
      CHANGELOG.md
  2. 20
      ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElementsHandler.cs
  3. 1
      ErsatzTV.Application/Graphics/Mapper.cs
  4. 3
      ErsatzTV.Core/Domain/GraphicsElementKind.cs
  5. 2
      ErsatzTV.Core/FileSystemLayout.cs
  6. 27
      ErsatzTV.Core/Graphics/ScriptGraphicsElement.cs
  7. 7
      ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs
  8. 1
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  9. 1
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj.DotSettings
  10. 22
      ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs
  11. 137
      ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs
  12. 3
      ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElement.cs
  13. 3
      ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs
  14. 6
      ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs
  15. 2
      ErsatzTV.Infrastructure/Streaming/Graphics/PreparedElementImage.cs
  16. 173
      ErsatzTV.Infrastructure/Streaming/Graphics/Script/ScriptElement.cs
  17. 2
      ErsatzTV.Infrastructure/Streaming/Graphics/Subtitle/SubtitleElement.cs
  18. 2
      ErsatzTV.Infrastructure/Streaming/Graphics/Text/TextElement.cs
  19. 1
      ErsatzTV/Startup.cs

17
CHANGELOG.md

@ -4,12 +4,27 @@ All notable changes to this project will be documented in this file. @@ -4,12 +4,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Add `script` graphics element type
- Supported in playback troubleshooting and all scheduling types
- Supports arbitrary scripts or executables that output BGRA data to stdout
- Supports EPG and Media Item replacement in entire template
- EPG data is sourced from XMLTV for the current time
- EPG data can also load a configurable number of subsequent (up next) entries
- Media Item data is sourced from the currently playing media item
- All template data will also be passed as JSON to the stdin stream of the command
- Template supports:
- Script and arguments (`command` and `args`)
- Draw order (`z_index`)
- Timing (`start_seconds` and `duration_seconds`)
### Fixed
- Fix startup on systems unsupported by NvEncSharp
- Fix detection of Plex Other Video libraries using `Plex Personal Media` agent
- If the library is already detected as a Movies library in ETV, synchronization must be disabled for the library to change it to an Other Videos library
- A warning will be logged when this scenario is detected
- VAAPI: work around buggy ffmpeg behavior where hevc_vaapi encoder with RadeonSI driver incorrectly outputs height of 1088 instead of 1080
- AMD VAAPI: work around buggy ffmpeg behavior where hevc_vaapi encoder with RadeonSI driver incorrectly outputs height of 1088 instead of 1080
- Optimize graphics engine to generate element frames in parallel and to eliminate redundant frame copies
## [25.9.0] - 2025-11-29
### Added

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

@ -123,6 +123,26 @@ public class RefreshGraphicsElementsHandler( @@ -123,6 +123,26 @@ public class RefreshGraphicsElementsHandler(
await dbContext.AddAsync(graphicsElement, cancellationToken);
}
// add new script elements
var newScriptPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsScriptTemplatesFolder, "*.yml", "*.yaml")
.Where(f => allExisting.All(e => e.Path != f))
.ToList();
foreach (string path in newScriptPaths)
{
logger.LogDebug("Adding new graphics element from file {File}", path);
var graphicsElement = new GraphicsElement
{
Path = path,
Kind = GraphicsElementKind.Script
};
await TryRefreshName(graphicsElement, cancellationToken);
await dbContext.AddAsync(graphicsElement, cancellationToken);
}
await dbContext.SaveChangesAsync(cancellationToken);
}

1
ErsatzTV.Application/Graphics/Mapper.cs

@ -13,6 +13,7 @@ public static class Mapper @@ -13,6 +13,7 @@ public static class Mapper
GraphicsElementKind.Image => $"image/{fileName}",
GraphicsElementKind.Subtitle => $"subtitle/{fileName}",
GraphicsElementKind.Motion => $"motion/{fileName}",
GraphicsElementKind.Script => $"script/{fileName}",
_ => graphicsElement.Path
};

3
ErsatzTV.Core/Domain/GraphicsElementKind.cs

@ -5,5 +5,6 @@ public enum GraphicsElementKind @@ -5,5 +5,6 @@ public enum GraphicsElementKind
Image = 0,
Text = 1,
Subtitle = 2,
Motion = 3
Motion = 3,
Script = 4
}

2
ErsatzTV.Core/FileSystemLayout.cs

@ -50,6 +50,7 @@ public static class FileSystemLayout @@ -50,6 +50,7 @@ public static class FileSystemLayout
public static readonly string GraphicsElementsTemplatesFolder;
public static readonly string GraphicsElementsTextTemplatesFolder;
public static readonly string GraphicsElementsImageTemplatesFolder;
public static readonly string GraphicsElementsScriptTemplatesFolder;
public static readonly string GraphicsElementsSubtitleTemplatesFolder;
public static readonly string GraphicsElementsMotionTemplatesFolder;
@ -175,6 +176,7 @@ public static class FileSystemLayout @@ -175,6 +176,7 @@ public static class FileSystemLayout
GraphicsElementsTemplatesFolder = Path.Combine(TemplatesFolder, "graphics-elements");
GraphicsElementsTextTemplatesFolder = Path.Combine(GraphicsElementsTemplatesFolder, "text");
GraphicsElementsImageTemplatesFolder = Path.Combine(GraphicsElementsTemplatesFolder, "image");
GraphicsElementsScriptTemplatesFolder = Path.Combine(GraphicsElementsTemplatesFolder, "script");
GraphicsElementsSubtitleTemplatesFolder = Path.Combine(GraphicsElementsTemplatesFolder, "subtitle");
GraphicsElementsMotionTemplatesFolder = Path.Combine(GraphicsElementsTemplatesFolder, "motion");

27
ErsatzTV.Core/Graphics/ScriptGraphicsElement.cs

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
using YamlDotNet.Serialization;
namespace ErsatzTV.Core.Graphics;
public class ScriptGraphicsElement : BaseGraphicsElement
{
[YamlMember(Alias = "command", ApplyNamingConventions = false)]
public string Command { get; set; }
[YamlMember(Alias = "args", ApplyNamingConventions = false)]
public List<string> Arguments { get; set; }
[YamlMember(Alias = "z_index", ApplyNamingConventions = false)]
public int? ZIndex { get; set; }
[YamlMember(Alias = "epg_entries", ApplyNamingConventions = false)]
public int EpgEntries { get; set; }
[YamlMember(Alias = "start_seconds", ApplyNamingConventions = false)]
public double? StartSeconds { get; set; }
[YamlMember(Alias = "duration_seconds", ApplyNamingConventions = false)]
public double? DurationSeconds { get; set; }
[YamlMember(Alias = "pixel_format", ApplyNamingConventions = false)]
public string PixelFormat { get; set; }
}

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

@ -43,6 +43,13 @@ public record SubtitleElementDataContext( @@ -43,6 +43,13 @@ public record SubtitleElementDataContext(
public int EpgEntries => SubtitleElement.EpgEntries;
}
public record ScriptElementDataContext(ScriptGraphicsElement ScriptElement)
: GraphicsElementContext, ITemplateDataContext
{
public int EpgEntries => ScriptElement.EpgEntries;
}
public interface ITemplateDataContext
{
int EpgEntries { get; }

1
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -8,6 +8,7 @@ @@ -8,6 +8,7 @@
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>

1
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj.DotSettings

@ -7,5 +7,6 @@ @@ -7,5 +7,6 @@
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Cgraphics_005Cfonts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Cgraphics_005Cimage/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Cgraphics_005Cmotion/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Cgraphics_005Cscript/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Cgraphics_005Csubtitle/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Cgraphics_005Ctext/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

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

@ -125,6 +125,25 @@ public partial class GraphicsElementLoader( @@ -125,6 +125,25 @@ public partial class GraphicsElementLoader(
break;
}
case GraphicsElementKind.Script:
{
Option<ScriptGraphicsElement> maybeElement = await LoadScript(
reference.GraphicsElement.Path,
templateVariables);
if (maybeElement.IsNone)
{
logger.LogWarning(
"Failed to load script graphics element from file {Path}; ignoring",
reference.GraphicsElement.Path);
}
foreach (ScriptGraphicsElement element in maybeElement)
{
context.Elements.Add(new ScriptElementDataContext(element));
}
break;
}
default:
logger.LogInformation(
"Ignoring unsupported graphics element kind {Kind}",
@ -214,6 +233,9 @@ public partial class GraphicsElementLoader( @@ -214,6 +233,9 @@ public partial class GraphicsElementLoader(
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 async Task<Dictionary<string, object>> InitTemplateVariables(
GraphicsEngineContext context,
int epgEntries,

137
ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs

@ -58,6 +58,10 @@ public class GraphicsEngine( @@ -58,6 +58,10 @@ public class GraphicsEngine(
logger));
break;
case ScriptElementDataContext scriptElementDataContext:
elements.Add(new ScriptElement(scriptElementDataContext.ScriptElement, logger));
break;
case SubtitleElementDataContext subtitleElementContext:
{
var variables = context.TemplateVariables.ToDictionary();
@ -85,11 +89,15 @@ public class GraphicsEngine( @@ -85,11 +89,15 @@ public class GraphicsEngine(
long frameCount = 0;
var totalFrames = (long)(context.Duration.TotalSeconds * context.FrameRate);
using var outputBitmap = new SKBitmap(
context.FrameSize.Width,
context.FrameSize.Height,
SKColorType.Bgra8888,
SKAlphaType.Unpremul);
int width = context.FrameSize.Width;
int height = context.FrameSize.Height;
int frameBufferSize = width * height * 4; // BGRA = 4 bytes
var skImageInfo = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Unpremul);
using var paint = new SKPaint();
var preparedElementImages = new List<PreparedElementImage>(elements.Count);
var prepareTasks = new List<Task<Option<PreparedElementImage>>>(elements.Count);
try
{
@ -111,65 +119,81 @@ public class GraphicsEngine( @@ -111,65 +119,81 @@ public class GraphicsEngine(
// `channel_seconds` - the total number of seconds the frame is from when the channel started/activated
TimeSpan channelTime = frameTime - context.ChannelStartTime;
using var canvas = new SKCanvas(outputBitmap);
canvas.Clear(SKColors.Transparent);
// prepare images outside mutate to allow async image generation
var preparedElementImages = new List<PreparedElementImage>();
foreach (IGraphicsElement element in elements.Where(e => !e.IsFinished).OrderBy(e => e.ZIndex))
prepareTasks.Clear();
foreach (var element in elements)
{
try
if (!element.IsFinished)
{
Option<PreparedElementImage> maybePreparedImage = await element.PrepareImage(
Task<Option<PreparedElementImage>> task = SafePrepareImage(
element,
frameTime.TimeOfDay,
contentTime,
contentTotalTime,
channelTime,
cancellationToken);
preparedElementImages.AddRange(maybePreparedImage);
prepareTasks.Add(task);
}
catch (Exception ex)
}
Option<PreparedElementImage>[] results = await Task.WhenAll(prepareTasks);
preparedElementImages.Clear();
foreach (Option<PreparedElementImage> result in results)
{
foreach (var preparedImage in result)
{
element.IsFinished = true;
logger.LogWarning(
ex,
"Failed to draw graphics element of type {Type}; will disable for this content",
element.GetType().Name);
preparedElementImages.Add(preparedImage);
}
}
// draw each element
using (var paint = new SKPaint())
preparedElementImages.Sort((a, _) => a.ZIndex);
Memory<byte> memory = pipeWriter.GetMemory(frameBufferSize);
unsafe
{
foreach (PreparedElementImage preparedImage in preparedElementImages)
using (System.Buffers.MemoryHandle handle = memory.Pin())
{
using (var colorFilter = SKColorFilter.CreateBlendMode(
SKColors.White.WithAlpha((byte)(preparedImage.Opacity * 255)),
SKBlendMode.Modulate))
{
paint.ColorFilter = colorFilter;
canvas.DrawBitmap(
preparedImage.Image,
new SKPoint(preparedImage.Point.X, preparedImage.Point.Y),
paint);
}
if (preparedImage.Dispose)
using (var surface = SKSurface.Create(skImageInfo, (IntPtr)handle.Pointer, width * 4))
{
preparedImage.Image.Dispose();
if (surface == null)
{
logger.LogWarning("Failed to create SKSurface for frame");
}
else
{
var canvas = surface.Canvas;
canvas.Clear(SKColors.Transparent);
foreach (PreparedElementImage preparedImage in preparedElementImages)
{
// Optimization: Skip BlendMode if opacity is full
if (preparedImage.Opacity < 0.99f)
{
using var colorFilter = SKColorFilter.CreateBlendMode(
SKColors.White.WithAlpha((byte)(preparedImage.Opacity * 255)),
SKBlendMode.Modulate);
paint.ColorFilter = colorFilter;
canvas.DrawBitmap(preparedImage.Image, preparedImage.Point, paint);
}
else
{
paint.ColorFilter = null;
canvas.DrawBitmap(preparedImage.Image, preparedImage.Point, paint);
}
if (preparedImage.Dispose)
{
preparedImage.Image.Dispose();
}
}
}
}
}
}
// pipe output
int frameBufferSize = context.FrameSize.Width * context.FrameSize.Height * 4;
using (SKPixmap pixmap = outputBitmap.PeekPixels())
{
Memory<byte> memory = pipeWriter.GetMemory(frameBufferSize);
pixmap.GetPixelSpan().CopyTo(memory.Span);
}
pipeWriter.Advance(frameBufferSize);
await pipeWriter.FlushAsync(cancellationToken);
@ -190,4 +214,31 @@ public class GraphicsEngine( @@ -190,4 +214,31 @@ public class GraphicsEngine(
}
}
}
private async Task<Option<PreparedElementImage>> SafePrepareImage(
IGraphicsElement element,
TimeSpan frameTimeOfDay,
TimeSpan contentTime,
TimeSpan contentTotalTime,
TimeSpan channelTime,
CancellationToken ct)
{
try
{
return await element.PrepareImage(
frameTimeOfDay,
contentTime,
contentTotalTime,
channelTime,
ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to render element {Type}. Disabling.", element.GetType().Name);
element.IsFinished = true;
return Option<PreparedElementImage>.None;
}
}
}

3
ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElement.cs

@ -76,6 +76,7 @@ public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger log @@ -76,6 +76,7 @@ public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger log
}
SKBitmap frameForTimestamp = GetFrameForTimestamp(contentTime);
return ValueTask.FromResult(Optional(new PreparedElementImage(frameForTimestamp, Location, opacity, false)));
return ValueTask.FromResult(
Optional(new PreparedElementImage(frameForTimestamp, Location, opacity, ZIndex, false)));
}
}

3
ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs

@ -106,6 +106,7 @@ public class WatermarkElement : ImageElementBase @@ -106,6 +106,7 @@ public class WatermarkElement : ImageElementBase
}
SKBitmap frameForTimestamp = GetFrameForTimestamp(contentTime);
return ValueTask.FromResult(Optional(new PreparedElementImage(frameForTimestamp, Location, opacity, false)));
return ValueTask.FromResult(
Optional(new PreparedElementImage(frameForTimestamp, Location, opacity, ZIndex, false)));
}
}

6
ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs

@ -230,7 +230,7 @@ public class MotionElement( @@ -230,7 +230,7 @@ public class MotionElement(
{
if (contentTime <= _endTime)
{
return new PreparedElementImage(_canvasBitmap, SKPointI.Empty, 1.0f, false);
return new PreparedElementImage(_canvasBitmap, SKPointI.Empty, 1.0f, ZIndex, false);
}
_state = MotionElementState.Finished;
@ -266,7 +266,7 @@ public class MotionElement( @@ -266,7 +266,7 @@ public class MotionElement(
consumed = sequence.End;
// we are done, return the frame
return new PreparedElementImage(_canvasBitmap, SKPointI.Empty, 1.0f, false);
return new PreparedElementImage(_canvasBitmap, SKPointI.Empty, 1.0f, ZIndex, false);
}
if (readResult.IsCompleted)
@ -276,7 +276,7 @@ public class MotionElement( @@ -276,7 +276,7 @@ public class MotionElement(
if (motionElement.EndBehavior is MotionEndBehavior.Hold)
{
_state = MotionElementState.Holding;
return new PreparedElementImage(_canvasBitmap, SKPointI.Empty, 1.0f, false);
return new PreparedElementImage(_canvasBitmap, SKPointI.Empty, 1.0f, ZIndex, false);
}
else
{

2
ErsatzTV.Infrastructure/Streaming/Graphics/PreparedElementImage.cs

@ -2,4 +2,4 @@ using SkiaSharp; @@ -2,4 +2,4 @@ using SkiaSharp;
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
public record PreparedElementImage(SKBitmap Image, SKPointI Point, float Opacity, bool Dispose);
public record PreparedElementImage(SKBitmap Image, SKPointI Point, float Opacity, int ZIndex, bool Dispose);

173
ErsatzTV.Infrastructure/Streaming/Graphics/Script/ScriptElement.cs

@ -0,0 +1,173 @@ @@ -0,0 +1,173 @@
using System.Buffers;
using System.IO.Pipelines;
using System.Text.Json;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Core.Interfaces.Streaming;
using Microsoft.Extensions.Logging;
using SkiaSharp;
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
public class ScriptElement(ScriptGraphicsElement scriptElement, ILogger logger)
: GraphicsElement, IDisposable
{
private CancellationTokenSource _cancellationTokenSource;
private CommandTask<CommandResult> _commandTask;
private int _frameSize;
private PipeReader _pipeReader;
private SKBitmap _canvasBitmap;
private TimeSpan _startTime;
private TimeSpan _endTime;
public void Dispose()
{
GC.SuppressFinalize(this);
_pipeReader?.Complete();
_cancellationTokenSource?.Cancel();
try
{
#pragma warning disable VSTHRD002
_commandTask?.Task.Wait();
#pragma warning restore VSTHRD002
}
catch (Exception)
{
// do nothing
}
_cancellationTokenSource?.Dispose();
_canvasBitmap?.Dispose();
}
public override Task InitializeAsync(GraphicsEngineContext context, CancellationToken cancellationToken)
{
try
{
_startTime = TimeSpan.FromSeconds(scriptElement.StartSeconds ?? 0);
_endTime = _startTime + TimeSpan.FromSeconds(scriptElement.DurationSeconds ?? 0);
// already past the time when this is supposed to play; don't do any more work
if (_endTime < context.Seek)
{
IsFinished = true;
return Task.CompletedTask;
}
var options = new PipeOptions(
minimumSegmentSize: 1024 * 1024,
pauseWriterThreshold: 64 * 1024 * 1024,
resumeWriterThreshold: 32 * 1024 * 1024
);
var pipe = new Pipe(options);
_pipeReader = pipe.Reader;
_frameSize = context.FrameSize.Width * context.FrameSize.Height * 4;
// default to bgra, but allow rgba when configured
SKColorType pixelFormat = SKColorType.Bgra8888;
if (string.Equals(scriptElement.PixelFormat, "rgba", StringComparison.OrdinalIgnoreCase))
{
pixelFormat = SKColorType.Rgba8888;
}
_canvasBitmap = new SKBitmap(
context.FrameSize.Width,
context.FrameSize.Height,
pixelFormat,
SKAlphaType.Unpremul);
string json = JsonSerializer.Serialize(context.TemplateVariables);
Command command = Cli.Wrap(scriptElement.Command)
.WithArguments(scriptElement.Arguments)
.WithWorkingDirectory(FileSystemLayout.TempFilePoolFolder)
.WithStandardInputPipe(PipeSource.FromString(json))
.WithStandardOutputPipe(PipeTarget.ToStream(pipe.Writer.AsStream()));
logger.LogDebug(
"script element command {Command} arguments {Arguments}",
command.TargetFilePath,
command.Arguments);
_cancellationTokenSource = new CancellationTokenSource();
var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
_cancellationTokenSource.Token);
_commandTask = command.ExecuteAsync(linkedToken.Token);
_ = _commandTask.Task.ContinueWith(_ => pipe.Writer.Complete(), TaskScheduler.Default);
}
catch (Exception ex)
{
IsFinished = true;
logger.LogWarning(ex, "Failed to initialize motion element; will disable for this content");
}
return Task.CompletedTask;
}
public override async ValueTask<Option<PreparedElementImage>> PrepareImage(
TimeSpan timeOfDay,
TimeSpan contentTime,
TimeSpan contentTotalTime,
TimeSpan channelTime,
CancellationToken cancellationToken)
{
try
{
if (contentTime < _startTime || contentTime > _endTime)
{
return Option<PreparedElementImage>.None;
}
while (true)
{
ReadResult readResult = await _pipeReader.ReadAsync(cancellationToken);
ReadOnlySequence<byte> buffer = readResult.Buffer;
SequencePosition consumed = buffer.Start;
SequencePosition examined = buffer.End;
try
{
if (buffer.Length >= _frameSize)
{
ReadOnlySequence<byte> sequence = buffer.Slice(0, _frameSize);
using (SKPixmap pixmap = _canvasBitmap.PeekPixels())
{
sequence.CopyTo(pixmap.GetPixelSpan());
}
// mark this frame as consumed
consumed = sequence.End;
// we are done, return the frame
return new PreparedElementImage(_canvasBitmap, SKPointI.Empty, 1.0f, ZIndex, false);
}
if (readResult.IsCompleted)
{
await _pipeReader.CompleteAsync();
return Option<PreparedElementImage>.None;
}
}
finally
{
// advance the reader, consuming the processed frame and examining the entire buffer
_pipeReader.AdvanceTo(consumed, examined);
}
}
}
catch (TaskCanceledException)
{
return Option<PreparedElementImage>.None;
}
}
}

2
ErsatzTV.Infrastructure/Streaming/Graphics/Subtitle/SubtitleElement.cs

@ -165,7 +165,7 @@ public class SubtitleElement( @@ -165,7 +165,7 @@ public class SubtitleElement(
consumed = sequence.End;
// we are done, return the frame
return new PreparedElementImage(_videoFrame, _point, 1.0f, false);
return new PreparedElementImage(_videoFrame, _point, 1.0f, ZIndex, false);
}
if (readResult.IsCompleted)

2
ErsatzTV.Infrastructure/Streaming/Graphics/Text/TextElement.cs

@ -129,7 +129,7 @@ public partial class TextElement( @@ -129,7 +129,7 @@ public partial class TextElement(
return opacity == 0
? ValueTask.FromResult(Option<PreparedElementImage>.None)
: new ValueTask<Option<PreparedElementImage>>(new PreparedElementImage(_image, _location, opacity, false));
: new ValueTask<Option<PreparedElementImage>>(new PreparedElementImage(_image, _location, opacity, ZIndex, false));
}
private RichTextKit.TextBlock BuildTextBlock(string textToRender)

1
ErsatzTV/Startup.cs

@ -380,6 +380,7 @@ public class Startup @@ -380,6 +380,7 @@ public class Startup
FileSystemLayout.GraphicsElementsTemplatesFolder,
FileSystemLayout.GraphicsElementsTextTemplatesFolder,
FileSystemLayout.GraphicsElementsImageTemplatesFolder,
FileSystemLayout.GraphicsElementsScriptTemplatesFolder,
FileSystemLayout.GraphicsElementsSubtitleTemplatesFolder,
FileSystemLayout.GraphicsElementsMotionTemplatesFolder,
FileSystemLayout.ScriptsFolder,

Loading…
Cancel
Save