From b9451a65851ff02d765ff69755dc45660fbd38c7 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Tue, 16 Sep 2025 16:34:08 -0500 Subject: [PATCH] add motion graphics elements (#2428) * crude motion graphics element * fix motion element rendering * implement motion element scaling * implement motion start seconds * update changelog --- CHANGELOG.md | 13 + .../RefreshGraphicsElementsHandler.cs | 18 ++ ErsatzTV.Application/Graphics/Mapper.cs | 1 + ErsatzTV.Core/Domain/GraphicsElementKind.cs | 3 +- ErsatzTV.Core/FileSystemLayout.cs | 2 + .../Graphics/MotionGraphicsElement.cs | 38 +++ .../Streaming/GraphicsEngineContext.cs | 9 +- ...ErsatzTV.Infrastructure.csproj.DotSettings | 1 + .../Streaming/Graphics/GraphicsElement.cs | 32 +++ .../Graphics/GraphicsElementLoader.cs | 30 +- .../Streaming/Graphics/GraphicsEngine.cs | 20 +- .../Graphics/Image/ImageElementBase.cs | 32 --- .../Graphics/Motion/MotionElement.cs | 267 ++++++++++++++++++ .../Graphics/Subtitle/SubtitleElement.cs | 18 +- ErsatzTV/Startup.cs | 1 + 15 files changed, 439 insertions(+), 46 deletions(-) create mode 100644 ErsatzTV.Core/Graphics/MotionGraphicsElement.cs create mode 100644 ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f443fe04..1b922a734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - This is supported using jellyfin-ffmpeg7 on devices like Orange Pi 5 Plus and NanoPi R6S - Block schedules: allow selecting multiple watermarks on block items - Block schedules: allow selecting multiple graphics elements on block items +- Add `motion` graphics element type + - Supported in playback troubleshooting and all scheduling types + - Supports video files with alpha channel (e.g. vp8/vp9 webm, apple prores 4444) + - 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 + - Template supports: + - Content (`video_path`) + - Placement (`location`, `horizontal_margin_percent`, `vertical_margin_percent`) + - Scaling (`scale`, `scale_width_percent`) + - Timing (`start_seconds`) + - Draw order (`z_index`) ### Fixed - Fix green output when libplacebo tonemapping is used with NVIDIA acceleration and 10-bit output in FFmpeg Profile diff --git a/ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElementsHandler.cs b/ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElementsHandler.cs index bba1b873f..f3cc8ae17 100644 --- a/ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElementsHandler.cs +++ b/ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElementsHandler.cs @@ -66,6 +66,24 @@ public class RefreshGraphicsElementsHandler( await dbContext.AddAsync(graphicsElement, cancellationToken); } + // add new motion elements + var newMotionPaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsMotionTemplatesFolder) + .Where(f => allExisting.All(e => e.Path != f)) + .ToList(); + + foreach (string path in newMotionPaths) + { + logger.LogDebug("Adding new graphics element from file {File}", path); + + var graphicsElement = new GraphicsElement + { + Path = path, + Kind = GraphicsElementKind.Motion + }; + + await dbContext.AddAsync(graphicsElement, cancellationToken); + } + // add new subtitle elements var newSubtitlePaths = localFileSystem.ListFiles(FileSystemLayout.GraphicsElementsSubtitleTemplatesFolder) .Where(f => allExisting.All(e => e.Path != f)) diff --git a/ErsatzTV.Application/Graphics/Mapper.cs b/ErsatzTV.Application/Graphics/Mapper.cs index 462e39cbc..4c43856a0 100644 --- a/ErsatzTV.Application/Graphics/Mapper.cs +++ b/ErsatzTV.Application/Graphics/Mapper.cs @@ -12,6 +12,7 @@ public static class Mapper GraphicsElementKind.Text => new GraphicsElementViewModel(graphicsElement.Id, $"text/{fileName}"), GraphicsElementKind.Image => new GraphicsElementViewModel(graphicsElement.Id, $"image/{fileName}"), GraphicsElementKind.Subtitle => new GraphicsElementViewModel(graphicsElement.Id, $"subtitle/{fileName}"), + GraphicsElementKind.Motion => new GraphicsElementViewModel(graphicsElement.Id, $"motion/{fileName}"), _ => new GraphicsElementViewModel(graphicsElement.Id, graphicsElement.Path) }; } diff --git a/ErsatzTV.Core/Domain/GraphicsElementKind.cs b/ErsatzTV.Core/Domain/GraphicsElementKind.cs index fb8cf70d0..c75258d41 100644 --- a/ErsatzTV.Core/Domain/GraphicsElementKind.cs +++ b/ErsatzTV.Core/Domain/GraphicsElementKind.cs @@ -4,5 +4,6 @@ public enum GraphicsElementKind { Image = 0, Text = 1, - Subtitle = 2 + Subtitle = 2, + Motion = 3 } diff --git a/ErsatzTV.Core/FileSystemLayout.cs b/ErsatzTV.Core/FileSystemLayout.cs index 22e6cded1..1d6bb2847 100644 --- a/ErsatzTV.Core/FileSystemLayout.cs +++ b/ErsatzTV.Core/FileSystemLayout.cs @@ -51,6 +51,7 @@ public static class FileSystemLayout public static readonly string GraphicsElementsTextTemplatesFolder; public static readonly string GraphicsElementsImageTemplatesFolder; public static readonly string GraphicsElementsSubtitleTemplatesFolder; + public static readonly string GraphicsElementsMotionTemplatesFolder; public static readonly string ScriptsFolder; @@ -171,6 +172,7 @@ public static class FileSystemLayout GraphicsElementsTextTemplatesFolder = Path.Combine(GraphicsElementsTemplatesFolder, "text"); GraphicsElementsImageTemplatesFolder = Path.Combine(GraphicsElementsTemplatesFolder, "image"); GraphicsElementsSubtitleTemplatesFolder = Path.Combine(GraphicsElementsTemplatesFolder, "subtitle"); + GraphicsElementsMotionTemplatesFolder = Path.Combine(GraphicsElementsTemplatesFolder, "motion"); ScriptsFolder = Path.Combine(AppDataFolder, "scripts"); diff --git a/ErsatzTV.Core/Graphics/MotionGraphicsElement.cs b/ErsatzTV.Core/Graphics/MotionGraphicsElement.cs new file mode 100644 index 000000000..8ef209d50 --- /dev/null +++ b/ErsatzTV.Core/Graphics/MotionGraphicsElement.cs @@ -0,0 +1,38 @@ +using ErsatzTV.FFmpeg.State; +using YamlDotNet.Serialization; + +namespace ErsatzTV.Core.Graphics; + +public class MotionGraphicsElement +{ + [YamlMember(Alias = "video_path", ApplyNamingConventions = false)] + public string VideoPath { get; set; } + + // [YamlMember(Alias = "opacity_percent", ApplyNamingConventions = false)] + // public int? OpacityPercent { get; set; } + // + // [YamlMember(Alias = "opacity_expression", ApplyNamingConventions = false)] + // public string OpacityExpression { get; set; } + + [YamlMember(Alias = "start_seconds", ApplyNamingConventions = false)] + public double? StartSeconds { 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 = "z_index", ApplyNamingConventions = false)] + public int? ZIndex { get; set; } + + [YamlMember(Alias = "epg_entries", ApplyNamingConventions = false)] + public int EpgEntries { get; set; } + + public bool Scale { get; set; } + + [YamlMember(Alias = "scale_width_percent", ApplyNamingConventions = false)] + public double? ScaleWidthPercent { get; set; } +} diff --git a/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs b/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs index aa7a96554..65c609c18 100644 --- a/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs +++ b/ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs @@ -21,14 +21,19 @@ public abstract record GraphicsElementContext; public record WatermarkElementContext(WatermarkOptions Options) : GraphicsElementContext; -public record TextElementDataContext(TextGraphicsElement TextElement, Dictionary Variables) - : GraphicsElementContext, ITemplateDataContext +public record TextElementDataContext(TextGraphicsElement TextElement) : GraphicsElementContext, ITemplateDataContext { public int EpgEntries => TextElement.EpgEntries; } public record ImageElementContext(ImageGraphicsElement ImageElement) : GraphicsElementContext; +public record MotionElementDataContext(MotionGraphicsElement MotionElement) + : GraphicsElementContext, ITemplateDataContext +{ + public int EpgEntries => MotionElement.EpgEntries; +} + public record SubtitleElementDataContext( SubtitleGraphicsElement SubtitleElement, Dictionary Variables) diff --git a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj.DotSettings b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj.DotSettings index 9c13eba22..de34be8be 100644 --- a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj.DotSettings +++ b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj.DotSettings @@ -6,5 +6,6 @@ True True True + True True True \ No newline at end of file diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElement.cs index d32ec1767..27c80884f 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElement.cs @@ -48,4 +48,36 @@ public abstract class GraphicsElement : IGraphicsElement frameWidth - imageWidth - horizontalMargin, frameHeight - imageHeight - verticalMargin) }; + + protected static WatermarkMargins NormalMargins( + Resolution frameSize, + double horizontalMarginPercent, + double verticalMarginPercent) + { + double horizontalMargin = Math.Round(horizontalMarginPercent / 100.0 * frameSize.Width); + double verticalMargin = Math.Round(verticalMarginPercent / 100.0 * frameSize.Height); + + return new WatermarkMargins((int)Math.Round(horizontalMargin), (int)Math.Round(verticalMargin)); + } + + protected static WatermarkMargins SourceContentMargins( + Resolution squarePixelFrameSize, + Resolution frameSize, + double horizontalMarginPercent, + double verticalMarginPercent) + { + int horizontalPadding = frameSize.Width - squarePixelFrameSize.Width; + int verticalPadding = frameSize.Height - squarePixelFrameSize.Height; + + double horizontalMargin = Math.Round( + horizontalMarginPercent / 100.0 * squarePixelFrameSize.Width + + horizontalPadding / 2.0); + double verticalMargin = Math.Round( + verticalMarginPercent / 100.0 * squarePixelFrameSize.Height + + verticalPadding / 2.0); + + return new WatermarkMargins((int)Math.Round(horizontalMargin), (int)Math.Round(verticalMargin)); + } + + protected sealed record WatermarkMargins(int HorizontalMargin, int VerticalMargin); } diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs index a030044c9..e09476e67 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs @@ -55,13 +55,7 @@ public partial class GraphicsElementLoader( foreach (TextGraphicsElement element in maybeElement) { - var variables = new Dictionary(); - if (!string.IsNullOrWhiteSpace(reference.Variables)) - { - variables = JsonConvert.DeserializeObject>(reference.Variables); - } - - context.Elements.Add(new TextElementDataContext(element, variables)); + context.Elements.Add(new TextElementDataContext(element)); } break; @@ -82,6 +76,25 @@ public partial class GraphicsElementLoader( break; } + case GraphicsElementKind.Motion: + { + Option maybeElement = await LoadMotion( + reference.GraphicsElement.Path, + templateVariables); + if (maybeElement.IsNone) + { + logger.LogWarning( + "Failed to load motion graphics element from file {Path}; ignoring", + reference.GraphicsElement.Path); + } + + foreach (MotionGraphicsElement element in maybeElement) + { + context.Elements.Add(new MotionElementDataContext(element)); + } + + break; + } case GraphicsElementKind.Subtitle: { Option maybeElement = await LoadSubtitle( @@ -148,6 +161,9 @@ public partial class GraphicsElementLoader( private Task> LoadText(string fileName, Dictionary variables) => GetTemplatedYaml(fileName, variables).BindT(FromYaml); + private Task> LoadMotion(string fileName, Dictionary variables) => + GetTemplatedYaml(fileName, variables).BindT(FromYaml); + private Task> LoadSubtitle(string fileName, Dictionary variables) => GetTemplatedYaml(fileName, variables).BindT(FromYaml); diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs index 51e56e4e3..4d66041ab 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs @@ -1,6 +1,9 @@ using System.IO.Pipelines; using ErsatzTV.Core; +using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Streaming; using Microsoft.Extensions.Logging; using SkiaSharp; @@ -11,6 +14,8 @@ public class GraphicsEngine( TemplateFunctions templateFunctions, GraphicsEngineFonts graphicsEngineFonts, ITempFilePool tempFilePool, + IConfigElementRepository configElementRepository, + ILocalStatisticsProvider localStatisticsProvider, ILogger logger) : IGraphicsEngine { @@ -18,6 +23,10 @@ public class GraphicsEngine( { graphicsEngineFonts.LoadFonts(FileSystemLayout.FontsCacheFolder); + Option ffprobePath = await configElementRepository.GetValue( + ConfigElementKey.FFprobePath, + cancellationToken); + var elements = new List(); foreach (GraphicsElementContext element in context.Elements) { @@ -37,10 +46,17 @@ public class GraphicsEngine( break; case TextElementDataContext textElementContext: - { elements.Add(new TextElement(graphicsEngineFonts, textElementContext.TextElement, logger)); break; - } + + case MotionElementDataContext motionElementDataContext: + elements.Add( + new MotionElement( + motionElementDataContext.MotionElement, + ffprobePath, + localStatisticsProvider, + logger)); + break; case SubtitleElementDataContext subtitleElementContext: { diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElementBase.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElementBase.cs index aacea1c1a..304bd2e26 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElementBase.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElementBase.cs @@ -175,36 +175,4 @@ public abstract class ImageElementBase : GraphicsElement, IDisposable return _scaledFrames.Last(); } - - private static WatermarkMargins NormalMargins( - Resolution frameSize, - double horizontalMarginPercent, - double verticalMarginPercent) - { - double horizontalMargin = Math.Round(horizontalMarginPercent / 100.0 * frameSize.Width); - double verticalMargin = Math.Round(verticalMarginPercent / 100.0 * frameSize.Height); - - return new WatermarkMargins((int)Math.Round(horizontalMargin), (int)Math.Round(verticalMargin)); - } - - private static WatermarkMargins SourceContentMargins( - Resolution squarePixelFrameSize, - Resolution frameSize, - double horizontalMarginPercent, - double verticalMarginPercent) - { - int horizontalPadding = frameSize.Width - squarePixelFrameSize.Width; - int verticalPadding = frameSize.Height - squarePixelFrameSize.Height; - - double horizontalMargin = Math.Round( - horizontalMarginPercent / 100.0 * squarePixelFrameSize.Width - + horizontalPadding / 2.0); - double verticalMargin = Math.Round( - verticalMarginPercent / 100.0 * squarePixelFrameSize.Height - + verticalPadding / 2.0); - - return new WatermarkMargins((int)Math.Round(horizontalMargin), (int)Math.Round(verticalMargin)); - } - - private sealed record WatermarkMargins(int HorizontalMargin, int VerticalMargin); } diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs new file mode 100644 index 000000000..340687e1f --- /dev/null +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs @@ -0,0 +1,267 @@ +using System.Buffers; +using System.IO.Pipelines; +using CliWrap; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Graphics; +using ErsatzTV.Core.Interfaces.Metadata; +using Microsoft.Extensions.Logging; +using SkiaSharp; + +namespace ErsatzTV.Infrastructure.Streaming.Graphics; + +public class MotionElement( + MotionGraphicsElement motionElement, + Option ffprobePath, + ILocalStatisticsProvider localStatisticsProvider, + ILogger logger) + : GraphicsElement, IDisposable +{ + private CancellationTokenSource _cancellationTokenSource; + private CommandTask _commandTask; + private int _frameSize; + private PipeReader _pipeReader; + private SKPointI _point; + private SKBitmap _canvasBitmap; + private SKBitmap _motionFrameBitmap; + private bool _isFinished; + private TimeSpan _startTime; + + 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(); + _motionFrameBitmap?.Dispose(); + } + + public override async Task InitializeAsync( + Resolution squarePixelFrameSize, + Resolution frameSize, + int frameRate, + CancellationToken cancellationToken) + { + try + { + var pipe = new Pipe(); + _pipeReader = pipe.Reader; + + _startTime = TimeSpan.FromSeconds(motionElement.StartSeconds ?? 0); + + SizeAndDecoder sizeAndDecoder = await ProbeMotionElement(frameSize); + Resolution sourceSize = sizeAndDecoder.Size; + + int scaledWidth = sourceSize.Width; + int scaledHeight = sourceSize.Height; + + if (motionElement.Scale) + { + scaledWidth = (int)Math.Round((motionElement.ScaleWidthPercent ?? 100) / 100.0 * frameSize.Width); + double aspectRatio = (double)sourceSize.Height / sourceSize.Width; + scaledHeight = (int)Math.Round(scaledWidth * aspectRatio); + } + + // ensure even dimensions + if (scaledWidth % 2 != 0) + { + scaledWidth++; + } + + if (scaledHeight % 2 != 0) + { + scaledHeight++; + } + + var targetSize = new Resolution { Width = scaledWidth, Height = scaledHeight }; + + _frameSize = targetSize.Width * targetSize.Height * 4; + + _canvasBitmap = new SKBitmap(frameSize.Width, frameSize.Height, SKColorType.Bgra8888, SKAlphaType.Unpremul); + + _motionFrameBitmap = new SKBitmap( + targetSize.Width, + targetSize.Height, + SKColorType.Bgra8888, + SKAlphaType.Unpremul); + + _point = SKPointI.Empty; + + (int horizontalMargin, int verticalMargin) = NormalMargins( + frameSize, + motionElement.HorizontalMarginPercent ?? 0, + motionElement.VerticalMarginPercent ?? 0); + + _point = CalculatePosition( + motionElement.Location, + frameSize.Width, + frameSize.Height, + targetSize.Width, + targetSize.Height, + horizontalMargin, + verticalMargin); + + List arguments = ["-nostdin", "-hide_banner", "-nostats", "-loglevel", "error"]; + + foreach (string decoder in sizeAndDecoder.Decoder) + { + arguments.AddRange(["-c:v", decoder]); + } + + arguments.AddRange( + [ + "-i", motionElement.VideoPath, + ]); + + if (motionElement.Scale) + { + arguments.AddRange(["-vf", $"scale={targetSize.Width}:{targetSize.Height}"]); + } + + arguments.AddRange( + [ + "-f", "image2pipe", + "-pix_fmt", "bgra", + "-vcodec", "rawvideo", + "-" + ]); + + Command command = Cli.Wrap("ffmpeg") + .WithArguments(arguments) + .WithWorkingDirectory(FileSystemLayout.TempFilePoolFolder) + .WithStandardOutputPipe(PipeTarget.ToStream(pipe.Writer.AsStream())); + + _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) + { + IsFailed = true; + logger.LogWarning(ex, "Failed to initialize motion element; will disable for this content"); + } + } + + public override async ValueTask> PrepareImage( + TimeSpan timeOfDay, + TimeSpan contentTime, + TimeSpan contentTotalTime, + TimeSpan channelTime, + CancellationToken cancellationToken) + { + if (contentTime < _startTime || _isFinished) + { + 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 = _motionFrameBitmap.PeekPixels()) + { + sequence.CopyTo(pixmap.GetPixelSpan()); + } + + _canvasBitmap.Erase(SKColors.Transparent); + + using (var canvas = new SKCanvas(_canvasBitmap)) + { + canvas.DrawBitmap(_motionFrameBitmap, _point); + } + + // mark this frame as consumed + consumed = sequence.End; + + // we are done, return the frame + return new PreparedElementImage(_canvasBitmap, SKPointI.Empty, 1.0f, false); + } + + if (readResult.IsCompleted) + { + _isFinished = true; + + await _pipeReader.CompleteAsync(); + return Option.None; + } + } + finally + { + if (!_isFinished) + { + // advance the reader, consuming the processed frame and examining the entire buffer + _pipeReader.AdvanceTo(consumed, examined); + } + } + } + } + + private async Task ProbeMotionElement(Resolution frameSize) + { + try + { + foreach (string ffprobe in ffprobePath) + { + Either maybeMediaVersion = + await localStatisticsProvider.GetStatistics(ffprobe, motionElement.VideoPath); + + foreach (var mediaVersion in maybeMediaVersion.RightToSeq()) + { + Option decoder = Option.None; + + foreach (var videoStream in mediaVersion.Streams.Where(s => + s.MediaStreamKind is MediaStreamKind.Video)) + { + decoder = videoStream.Codec switch + { + "vp8" => "libvpx", + "vp9" => "libvpx-vp9", + _ => Option.None + }; + } + + return new SizeAndDecoder( + new Resolution { Width = mediaVersion.Width, Height = mediaVersion.Height }, + decoder); + } + } + } + catch (Exception) + { + // do nothing + } + + return new SizeAndDecoder(frameSize, Option.None); + } + + private record SizeAndDecoder(Resolution Size, Option Decoder); +} diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Subtitle/SubtitleElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Subtitle/SubtitleElement.cs index 53ff24655..316872c4d 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/Subtitle/SubtitleElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Subtitle/SubtitleElement.cs @@ -27,6 +27,7 @@ public class SubtitleElement( private PipeReader _pipeReader; private SKPointI _point; private SKBitmap _videoFrame; + private bool _isFinished; public void Dispose() { @@ -85,6 +86,7 @@ public class SubtitleElement( string subtitleFile = Path.GetFileName(subtitleTemplateFile); List arguments = [ + "-nostdin", "-hide_banner", "-nostats", "-loglevel", "error", "-f", "lavfi", "-i", $"color=c=black@0.0:s={frameSize.Width}x{frameSize.Height}:r={frameRate},format=bgra,subtitles='{subtitleFile}':alpha=1", @@ -105,6 +107,8 @@ public class SubtitleElement( _cancellationTokenSource.Token); _commandTask = command.ExecuteAsync(linkedToken.Token); + + _ = _commandTask.Task.ContinueWith(_ => pipe.Writer.Complete(), TaskScheduler.Default); } catch (Exception ex) { @@ -120,6 +124,11 @@ public class SubtitleElement( TimeSpan channelTime, CancellationToken cancellationToken) { + if (_isFinished) + { + return Option.None; + } + while (true) { ReadResult readResult = await _pipeReader.ReadAsync(cancellationToken); @@ -147,14 +156,19 @@ public class SubtitleElement( if (readResult.IsCompleted) { + _isFinished = true; + await _pipeReader.CompleteAsync(); return Option.None; } } finally { - // advance the reader, consuming the processed frame and examining the entire buffer - _pipeReader.AdvanceTo(consumed, examined); + if (!_isFinished) + { + // advance the reader, consuming the processed frame and examining the entire buffer + _pipeReader.AdvanceTo(consumed, examined); + } } } } diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 237ea19cf..24eb5e03a 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -372,6 +372,7 @@ public class Startup FileSystemLayout.GraphicsElementsTextTemplatesFolder, FileSystemLayout.GraphicsElementsImageTemplatesFolder, FileSystemLayout.GraphicsElementsSubtitleTemplatesFolder, + FileSystemLayout.GraphicsElementsMotionTemplatesFolder, FileSystemLayout.ScriptsFolder, FileSystemLayout.MultiEpisodeShuffleTemplatesFolder, FileSystemLayout.AudioStreamSelectorScriptsFolder