Browse Source

add motion graphics elements (#2428)

* crude motion graphics element

* fix motion element rendering

* implement motion element scaling

* implement motion start seconds

* update changelog
pull/2430/head
Jason Dove 4 months ago committed by GitHub
parent
commit
b9451a6585
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 13
      CHANGELOG.md
  2. 18
      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. 38
      ErsatzTV.Core/Graphics/MotionGraphicsElement.cs
  7. 9
      ErsatzTV.Core/Interfaces/Streaming/GraphicsEngineContext.cs
  8. 1
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj.DotSettings
  9. 32
      ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElement.cs
  10. 30
      ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs
  11. 20
      ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsEngine.cs
  12. 32
      ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElementBase.cs
  13. 267
      ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs
  14. 18
      ErsatzTV.Infrastructure/Streaming/Graphics/Subtitle/SubtitleElement.cs
  15. 1
      ErsatzTV/Startup.cs

13
CHANGELOG.md

@ -20,6 +20,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

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

@ -66,6 +66,24 @@ public class RefreshGraphicsElementsHandler( @@ -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))

1
ErsatzTV.Application/Graphics/Mapper.cs

@ -12,6 +12,7 @@ public static class Mapper @@ -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)
};
}

3
ErsatzTV.Core/Domain/GraphicsElementKind.cs

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

2
ErsatzTV.Core/FileSystemLayout.cs

@ -51,6 +51,7 @@ public static class FileSystemLayout @@ -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 @@ -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");

38
ErsatzTV.Core/Graphics/MotionGraphicsElement.cs

@ -0,0 +1,38 @@ @@ -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; }
}

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

@ -21,14 +21,19 @@ public abstract record GraphicsElementContext; @@ -21,14 +21,19 @@ public abstract record GraphicsElementContext;
public record WatermarkElementContext(WatermarkOptions Options) : GraphicsElementContext;
public record TextElementDataContext(TextGraphicsElement TextElement, Dictionary<string, string> 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<string, string> Variables)

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

@ -6,5 +6,6 @@ @@ -6,5 +6,6 @@
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=data_005Cconfigurations_005Cmetadata/@EntryIndexedValue">True</s:Boolean>
<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_005Csubtitle/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Cgraphics_005Ctext/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

32
ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElement.cs

@ -48,4 +48,36 @@ public abstract class GraphicsElement : IGraphicsElement @@ -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);
}

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

@ -55,13 +55,7 @@ public partial class GraphicsElementLoader( @@ -55,13 +55,7 @@ public partial class GraphicsElementLoader(
foreach (TextGraphicsElement element in maybeElement)
{
var variables = new Dictionary<string, string>();
if (!string.IsNullOrWhiteSpace(reference.Variables))
{
variables = JsonConvert.DeserializeObject<Dictionary<string, string>>(reference.Variables);
}
context.Elements.Add(new TextElementDataContext(element, variables));
context.Elements.Add(new TextElementDataContext(element));
}
break;
@ -82,6 +76,25 @@ public partial class GraphicsElementLoader( @@ -82,6 +76,25 @@ public partial class GraphicsElementLoader(
break;
}
case GraphicsElementKind.Motion:
{
Option<MotionGraphicsElement> 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<SubtitleGraphicsElement> maybeElement = await LoadSubtitle(
@ -148,6 +161,9 @@ public partial class GraphicsElementLoader( @@ -148,6 +161,9 @@ public partial class GraphicsElementLoader(
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>);

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

@ -1,6 +1,9 @@ @@ -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( @@ -11,6 +14,8 @@ public class GraphicsEngine(
TemplateFunctions templateFunctions,
GraphicsEngineFonts graphicsEngineFonts,
ITempFilePool tempFilePool,
IConfigElementRepository configElementRepository,
ILocalStatisticsProvider localStatisticsProvider,
ILogger<GraphicsEngine> logger)
: IGraphicsEngine
{
@ -18,6 +23,10 @@ public class GraphicsEngine( @@ -18,6 +23,10 @@ public class GraphicsEngine(
{
graphicsEngineFonts.LoadFonts(FileSystemLayout.FontsCacheFolder);
Option<string> ffprobePath = await configElementRepository.GetValue<string>(
ConfigElementKey.FFprobePath,
cancellationToken);
var elements = new List<IGraphicsElement>();
foreach (GraphicsElementContext element in context.Elements)
{
@ -37,10 +46,17 @@ public class GraphicsEngine( @@ -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:
{

32
ErsatzTV.Infrastructure/Streaming/Graphics/Image/ImageElementBase.cs

@ -175,36 +175,4 @@ public abstract class ImageElementBase : GraphicsElement, IDisposable @@ -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);
}

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

@ -0,0 +1,267 @@ @@ -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<string> ffprobePath,
ILocalStatisticsProvider localStatisticsProvider,
ILogger logger)
: GraphicsElement, IDisposable
{
private CancellationTokenSource _cancellationTokenSource;
private CommandTask<CommandResult> _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<string> 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<Option<PreparedElementImage>> PrepareImage(
TimeSpan timeOfDay,
TimeSpan contentTime,
TimeSpan contentTotalTime,
TimeSpan channelTime,
CancellationToken cancellationToken)
{
if (contentTime < _startTime || _isFinished)
{
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 = _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<PreparedElementImage>.None;
}
}
finally
{
if (!_isFinished)
{
// advance the reader, consuming the processed frame and examining the entire buffer
_pipeReader.AdvanceTo(consumed, examined);
}
}
}
}
private async Task<SizeAndDecoder> ProbeMotionElement(Resolution frameSize)
{
try
{
foreach (string ffprobe in ffprobePath)
{
Either<BaseError, MediaVersion> maybeMediaVersion =
await localStatisticsProvider.GetStatistics(ffprobe, motionElement.VideoPath);
foreach (var mediaVersion in maybeMediaVersion.RightToSeq())
{
Option<string> decoder = Option<string>.None;
foreach (var videoStream in mediaVersion.Streams.Where(s =>
s.MediaStreamKind is MediaStreamKind.Video))
{
decoder = videoStream.Codec switch
{
"vp8" => "libvpx",
"vp9" => "libvpx-vp9",
_ => Option<string>.None
};
}
return new SizeAndDecoder(
new Resolution { Width = mediaVersion.Width, Height = mediaVersion.Height },
decoder);
}
}
}
catch (Exception)
{
// do nothing
}
return new SizeAndDecoder(frameSize, Option<string>.None);
}
private record SizeAndDecoder(Resolution Size, Option<string> Decoder);
}

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

@ -27,6 +27,7 @@ public class SubtitleElement( @@ -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( @@ -85,6 +86,7 @@ public class SubtitleElement(
string subtitleFile = Path.GetFileName(subtitleTemplateFile);
List<string> 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( @@ -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( @@ -120,6 +124,11 @@ public class SubtitleElement(
TimeSpan channelTime,
CancellationToken cancellationToken)
{
if (_isFinished)
{
return Option<PreparedElementImage>.None;
}
while (true)
{
ReadResult readResult = await _pipeReader.ReadAsync(cancellationToken);
@ -147,14 +156,19 @@ public class SubtitleElement( @@ -147,14 +156,19 @@ public class SubtitleElement(
if (readResult.IsCompleted)
{
_isFinished = true;
await _pipeReader.CompleteAsync();
return Option<PreparedElementImage>.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);
}
}
}
}

1
ErsatzTV/Startup.cs

@ -372,6 +372,7 @@ public class Startup @@ -372,6 +372,7 @@ public class Startup
FileSystemLayout.GraphicsElementsTextTemplatesFolder,
FileSystemLayout.GraphicsElementsImageTemplatesFolder,
FileSystemLayout.GraphicsElementsSubtitleTemplatesFolder,
FileSystemLayout.GraphicsElementsMotionTemplatesFolder,
FileSystemLayout.ScriptsFolder,
FileSystemLayout.MultiEpisodeShuffleTemplatesFolder,
FileSystemLayout.AudioStreamSelectorScriptsFolder

Loading…
Cancel
Save