From c524bc0d7de534fa4f7d663ab6dcdf429d1895a0 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:20:18 -0600 Subject: [PATCH] add script graphics element (#2681) * add script graphics element * pass template data as json to stdin * update changelog --- CHANGELOG.md | 17 +- .../RefreshGraphicsElementsHandler.cs | 20 ++ ErsatzTV.Application/Graphics/Mapper.cs | 1 + ErsatzTV.Core/Domain/GraphicsElementKind.cs | 3 +- ErsatzTV.Core/FileSystemLayout.cs | 2 + .../Graphics/ScriptGraphicsElement.cs | 27 +++ .../Streaming/GraphicsEngineContext.cs | 7 + .../ErsatzTV.Infrastructure.csproj | 1 + ...ErsatzTV.Infrastructure.csproj.DotSettings | 1 + .../Graphics/GraphicsElementLoader.cs | 22 +++ .../Streaming/Graphics/GraphicsEngine.cs | 137 +++++++++----- .../Streaming/Graphics/Image/ImageElement.cs | 3 +- .../Graphics/Image/WatermarkElement.cs | 3 +- .../Graphics/Motion/MotionElement.cs | 6 +- .../Graphics/PreparedElementImage.cs | 2 +- .../Graphics/Script/ScriptElement.cs | 173 ++++++++++++++++++ .../Graphics/Subtitle/SubtitleElement.cs | 2 +- .../Streaming/Graphics/Text/TextElement.cs | 2 +- ErsatzTV/Startup.cs | 1 + 19 files changed, 377 insertions(+), 53 deletions(-) create mode 100644 ErsatzTV.Core/Graphics/ScriptGraphicsElement.cs create mode 100644 ErsatzTV.Infrastructure/Streaming/Graphics/Script/ScriptElement.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 01eba14f4..41b38f4e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElementsHandler.cs b/ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElementsHandler.cs index ed5a8f4d9..0ba244295 100644 --- a/ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElementsHandler.cs +++ b/ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElementsHandler.cs @@ -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); } diff --git a/ErsatzTV.Application/Graphics/Mapper.cs b/ErsatzTV.Application/Graphics/Mapper.cs index 5dc94071d..d46520705 100644 --- a/ErsatzTV.Application/Graphics/Mapper.cs +++ b/ErsatzTV.Application/Graphics/Mapper.cs @@ -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 }; diff --git a/ErsatzTV.Core/Domain/GraphicsElementKind.cs b/ErsatzTV.Core/Domain/GraphicsElementKind.cs index c75258d41..c9e4c7cc2 100644 --- a/ErsatzTV.Core/Domain/GraphicsElementKind.cs +++ b/ErsatzTV.Core/Domain/GraphicsElementKind.cs @@ -5,5 +5,6 @@ public enum GraphicsElementKind Image = 0, Text = 1, Subtitle = 2, - Motion = 3 + Motion = 3, + Script = 4 } diff --git a/ErsatzTV.Core/FileSystemLayout.cs b/ErsatzTV.Core/FileSystemLayout.cs index 749256edb..37cb5e31c 100644 --- a/ErsatzTV.Core/FileSystemLayout.cs +++ b/ErsatzTV.Core/FileSystemLayout.cs @@ -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 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"); diff --git a/ErsatzTV.Core/Graphics/ScriptGraphicsElement.cs b/ErsatzTV.Core/Graphics/ScriptGraphicsElement.cs new file mode 100644 index 000000000..f38734db5 --- /dev/null +++ b/ErsatzTV.Core/Graphics/ScriptGraphicsElement.cs @@ -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 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; } +} diff --git a/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs b/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs index 708389fad..5af9612d0 100644 --- a/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs +++ b/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs @@ -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; } diff --git a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj index 53b20e284..bf5237e99 100644 --- a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj +++ b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj @@ -8,6 +8,7 @@ latest-Recommended true Linux + true diff --git a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj.DotSettings b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj.DotSettings index de34be8be..69f40468a 100644 --- a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj.DotSettings +++ b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj.DotSettings @@ -7,5 +7,6 @@ True True True + True True True \ No newline at end of file diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs index d9572f9b3..26ecec964 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs @@ -125,6 +125,25 @@ public partial class GraphicsElementLoader( break; } + case GraphicsElementKind.Script: + { + Option 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( private Task> LoadSubtitle(string fileName, Dictionary variables) => GetTemplatedYaml(fileName, variables).BindT(FromYaml); + private Task> LoadScript(string fileName, Dictionary variables) => + GetTemplatedYaml(fileName, variables).BindT(FromYaml); + private async Task> InitTemplateVariables( GraphicsEngineContext context, int epgEntries, diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs index 82f7f2e2b..b470a6f30 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs @@ -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( 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(elements.Count); + + var prepareTasks = new List>>(elements.Count); try { @@ -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(); - 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 maybePreparedImage = await element.PrepareImage( + Task> task = SafePrepareImage( + element, frameTime.TimeOfDay, contentTime, contentTotalTime, channelTime, cancellationToken); - preparedElementImages.AddRange(maybePreparedImage); + prepareTasks.Add(task); } - catch (Exception ex) + } + + Option[] results = await Task.WhenAll(prepareTasks); + + preparedElementImages.Clear(); + foreach (Option 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 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 memory = pipeWriter.GetMemory(frameBufferSize); - pixmap.GetPixelSpan().CopyTo(memory.Span); - } - pipeWriter.Advance(frameBufferSize); await pipeWriter.FlushAsync(cancellationToken); @@ -190,4 +214,31 @@ public class GraphicsEngine( } } } + + private async Task> 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.None; + } + } } diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElement.cs index 235d79054..c69fffd57 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElement.cs @@ -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))); } } diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs index 5097cde69..112da270b 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Image/WatermarkElement.cs @@ -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))); } } diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs index 29b738bc1..265573b3b 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs @@ -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( 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( 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 { diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/PreparedElementImage.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/PreparedElementImage.cs index 01eb64f33..6fd43fcbf 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/PreparedElementImage.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/PreparedElementImage.cs @@ -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); diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Script/ScriptElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Script/ScriptElement.cs new file mode 100644 index 000000000..fc70bcec0 --- /dev/null +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Script/ScriptElement.cs @@ -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 _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> PrepareImage( + TimeSpan timeOfDay, + TimeSpan contentTime, + TimeSpan contentTotalTime, + TimeSpan channelTime, + CancellationToken cancellationToken) + { + try + { + if (contentTime < _startTime || contentTime > _endTime) + { + return Option.None; + } + + while (true) + { + ReadResult readResult = await _pipeReader.ReadAsync(cancellationToken); + ReadOnlySequence buffer = readResult.Buffer; + SequencePosition consumed = buffer.Start; + SequencePosition examined = buffer.End; + + try + { + if (buffer.Length >= _frameSize) + { + ReadOnlySequence 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.None; + } + } + finally + { + // advance the reader, consuming the processed frame and examining the entire buffer + _pipeReader.AdvanceTo(consumed, examined); + } + } + } + catch (TaskCanceledException) + { + return Option.None; + } + } +} diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Subtitle/SubtitleElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Subtitle/SubtitleElement.cs index fd60cf328..861ba4e46 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/Subtitle/SubtitleElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Subtitle/SubtitleElement.cs @@ -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) diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Text/TextElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Text/TextElement.cs index b6c83c0e5..57f4988e0 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/Text/TextElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Text/TextElement.cs @@ -129,7 +129,7 @@ public partial class TextElement( return opacity == 0 ? ValueTask.FromResult(Option.None) - : new ValueTask>(new PreparedElementImage(_image, _location, opacity, false)); + : new ValueTask>(new PreparedElementImage(_image, _location, opacity, ZIndex, false)); } private RichTextKit.TextBlock BuildTextBlock(string textToRender) diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 39ced9500..a6823eb1c 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -380,6 +380,7 @@ public class Startup FileSystemLayout.GraphicsElementsTemplatesFolder, FileSystemLayout.GraphicsElementsTextTemplatesFolder, FileSystemLayout.GraphicsElementsImageTemplatesFolder, + FileSystemLayout.GraphicsElementsScriptTemplatesFolder, FileSystemLayout.GraphicsElementsSubtitleTemplatesFolder, FileSystemLayout.GraphicsElementsMotionTemplatesFolder, FileSystemLayout.ScriptsFolder,