From a90fe26d50332bfcce172de1366e792575d43686 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Thu, 4 Dec 2025 15:40:18 -0600 Subject: [PATCH] script element packet spike (#2703) * script element packet spike * fixes --- CHANGELOG.md | 5 +- .../Graphics/ScriptGraphicsElement.cs | 3 + .../Graphics/ScriptGraphicsFormat.cs | 7 + .../Graphics/Script/ScriptElement.cs | 162 ++++++++++++++---- .../Graphics/Script/ScriptPayloadType.cs | 9 + 5 files changed, 155 insertions(+), 31 deletions(-) create mode 100644 ErsatzTV.Core/Graphics/ScriptGraphicsFormat.cs create mode 100644 ErsatzTV.Infrastructure/Streaming/Graphics/Script/ScriptPayloadType.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 414dcfbfd..efcde1434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Graphics Engine: - 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 arbitrary scripts or executables that output graphics to ETV via 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 @@ -18,6 +18,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Script and arguments (`command` and `args`) - Draw order (`z_index`) - Timing (`start_seconds` and `duration_seconds`) + - Data format (`format`) + - `raw` format means full frames of BGRA data to stdout + - `packet` format means ETV graphics packets to stdout - Add framerate template data - `RFrameRate` - the real content framerate (or channel normalized framerate) as reported by ffmpeg, e.g. `30000/1001` - `FrameRate` - the decimal representation of `RFrameRate`, e.g. `29.97002997` diff --git a/ErsatzTV.Core/Graphics/ScriptGraphicsElement.cs b/ErsatzTV.Core/Graphics/ScriptGraphicsElement.cs index f38734db5..fd2caacea 100644 --- a/ErsatzTV.Core/Graphics/ScriptGraphicsElement.cs +++ b/ErsatzTV.Core/Graphics/ScriptGraphicsElement.cs @@ -24,4 +24,7 @@ public class ScriptGraphicsElement : BaseGraphicsElement [YamlMember(Alias = "pixel_format", ApplyNamingConventions = false)] public string PixelFormat { get; set; } + + [YamlMember(Alias = "format", ApplyNamingConventions = false)] + public ScriptGraphicsFormat Format { get; set; } } diff --git a/ErsatzTV.Core/Graphics/ScriptGraphicsFormat.cs b/ErsatzTV.Core/Graphics/ScriptGraphicsFormat.cs new file mode 100644 index 000000000..44a9b32b8 --- /dev/null +++ b/ErsatzTV.Core/Graphics/ScriptGraphicsFormat.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Core.Graphics; + +public enum ScriptGraphicsFormat +{ + Raw = 0, + Packet = 1 +} diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Script/ScriptElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Script/ScriptElement.cs index fc70bcec0..a93009d71 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/Script/ScriptElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Script/ScriptElement.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Buffers.Binary; using System.IO.Pipelines; using System.Text.Json; using CliWrap; @@ -13,6 +14,8 @@ namespace ErsatzTV.Infrastructure.Streaming.Graphics; public class ScriptElement(ScriptGraphicsElement scriptElement, ILogger logger) : GraphicsElement, IDisposable { + private const uint EtvGraphicsMagic = 0x47565445; + private CancellationTokenSource _cancellationTokenSource; private CommandTask _commandTask; private int _frameSize; @@ -20,6 +23,7 @@ public class ScriptElement(ScriptGraphicsElement scriptElement, ILogger logger) private SKBitmap _canvasBitmap; private TimeSpan _startTime; private TimeSpan _endTime; + private int _repeatCount; public void Dispose() { @@ -126,48 +130,146 @@ public class ScriptElement(ScriptGraphicsElement scriptElement, ILogger logger) 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); + return scriptElement.Format is ScriptGraphicsFormat.Raw + ? await PrepareFromRaw(cancellationToken) + : await PrepareFromPacket(cancellationToken); + } + catch (TaskCanceledException) + { + return Option.None; + } + } - using (SKPixmap pixmap = _canvasBitmap.PeekPixels()) - { - sequence.CopyTo(pixmap.GetPixelSpan()); - } + private async Task> PrepareFromRaw(CancellationToken cancellationToken) + { + while (true) + { + ReadResult readResult = await _pipeReader.ReadAsync(cancellationToken); + ReadOnlySequence buffer = readResult.Buffer; + SequencePosition consumed = buffer.Start; + SequencePosition examined = buffer.End; - // mark this frame as consumed - consumed = sequence.End; + try + { + if (buffer.Length >= _frameSize) + { + ReadOnlySequence sequence = buffer.Slice(0, _frameSize); - // we are done, return the frame - return new PreparedElementImage(_canvasBitmap, SKPointI.Empty, 1.0f, ZIndex, false); + using (SKPixmap pixmap = _canvasBitmap.PeekPixels()) + { + sequence.CopyTo(pixmap.GetPixelSpan()); } - if (readResult.IsCompleted) - { - await _pipeReader.CompleteAsync(); + // mark this frame as consumed + consumed = sequence.End; - return Option.None; - } + // we are done, return the frame + return new PreparedElementImage(_canvasBitmap, SKPointI.Empty, 1.0f, ZIndex, false); } - finally + + if (readResult.IsCompleted) { - // advance the reader, consuming the processed frame and examining the entire buffer - _pipeReader.AdvanceTo(consumed, examined); + 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) + } + + private async Task> PrepareFromPacket(CancellationToken cancellationToken) + { + if (_repeatCount > 0 || await ReadPacket(cancellationToken)) { - return Option.None; + if (_repeatCount > 0) + { + _repeatCount--; + } + + return new PreparedElementImage(_canvasBitmap, SKPointI.Empty, 1.0f, ZIndex, false); } + + IsFinished = true; + await _pipeReader.CompleteAsync(); + return Option.None; + } + + private async Task ReadPacket(CancellationToken cancellationToken) + { + // need 11 bytes - 4 magic, 2 version, 1 packet type, 4 payload len + var result = await _pipeReader.ReadAtLeastAsync(11, cancellationToken); + ReadOnlySequence buffer = result.Buffer; + + if (buffer.Length < 11) + { + return false; + } + + Span headerBytes = stackalloc byte[11]; + buffer.Slice(0, 11).CopyTo(headerBytes); + + uint magic = BinaryPrimitives.ReadUInt32BigEndian(headerBytes[..4]); + ushort version = BinaryPrimitives.ReadUInt16BigEndian(headerBytes.Slice(4, 2)); + byte type = headerBytes[6]; + uint payloadLen = BinaryPrimitives.ReadUInt32BigEndian(headerBytes.Slice(7, 4)); + + if (magic != EtvGraphicsMagic || version != 1) + { + logger.LogWarning("Invalid graphics packet received: magic {Magic}, version {Version}", magic, version); + return false; + } + + // consume header + _pipeReader.AdvanceTo(buffer.GetPosition(11)); + + var success = true; + + if (payloadLen > 0) + { + result = await _pipeReader.ReadAtLeastAsync((int)payloadLen, cancellationToken); + buffer = result.Buffer; + + switch (type) + { + case (byte)ScriptPayloadType.Full: + using (SKPixmap pixmap = _canvasBitmap.PeekPixels()) + { + buffer.Slice(0, payloadLen).CopyTo(pixmap.GetPixelSpan()); + } + break; + case (byte)ScriptPayloadType.Repeat: + Span repeatBytes = stackalloc byte[4]; + buffer.Slice(0, 4).CopyTo(repeatBytes); + _repeatCount = (int)BinaryPrimitives.ReadUInt32BigEndian(repeatBytes); + break; + case (byte)ScriptPayloadType.Rectangles: + // TODO: support rectangles + logger.LogWarning("Unsupported graphics packet type: {Type}", type); + success = false; + break; + } + + // consume payload + _pipeReader.AdvanceTo(buffer.GetPosition(payloadLen)); + } + else + { + if (type == (byte)ScriptPayloadType.Clear) + { + _canvasBitmap.Erase(SKColors.Transparent); + } + else + { + logger.LogWarning("Unexpected zero-length payload for type {Type}", type); + success = false; + } + } + + return success; } } diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Script/ScriptPayloadType.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Script/ScriptPayloadType.cs new file mode 100644 index 000000000..44f205ee2 --- /dev/null +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Script/ScriptPayloadType.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Infrastructure.Streaming.Graphics; + +public enum ScriptPayloadType +{ + Full = 0, + Repeat = 1, + Clear = 2, + Rectangles = 3 +}