using System.Buffers; using System.IO.Pipelines; using System.Runtime.InteropServices; using CliWrap; using ErsatzTV.Core; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Graphics; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Streaming; using Microsoft.Extensions.Logging; using Scriban; using Scriban.Runtime; using SkiaSharp; namespace ErsatzTV.Infrastructure.Streaming.Graphics; public class SubtitleElement( TemplateFunctions templateFunctions, ITempFilePool tempFilePool, SubtitleGraphicsElement subtitleElement, Dictionary variables, ILogger logger) : GraphicsElement, IDisposable { private CancellationTokenSource _cancellationTokenSource; private CommandTask _commandTask; private int _frameSize; private PipeReader _pipeReader; private SKPointI _point; private SKBitmap _videoFrame; private bool _isFinished; 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(); _videoFrame?.Dispose(); } public override async Task InitializeAsync(GraphicsEngineContext context, CancellationToken cancellationToken) { try { var pipe = new Pipe(); _pipeReader = pipe.Reader; // video size is the same as the main frame size _frameSize = context.FrameSize.Width * context.FrameSize.Height * 4; _videoFrame = new SKBitmap( context.FrameSize.Width, context.FrameSize.Height, SKColorType.Bgra8888, SKAlphaType.Unpremul); // subtitles contain their own positioning info _point = SKPointI.Empty; string subtitleTemplateFile = tempFilePool.GetNextTempFile(TempFileCategory.Subtitle); var scriptObject = new ScriptObject(); scriptObject.Import(variables, renamer: member => member.Name); scriptObject.Import("convert_timezone", templateFunctions.ConvertTimeZone); scriptObject.Import("format_datetime", templateFunctions.FormatDateTime); var templateContext = new TemplateContext { MemberRenamer = member => member.Name }; templateContext.PushGlobal(scriptObject); string inputText = await File.ReadAllTextAsync(subtitleElement.Template, cancellationToken); string textToRender = await Template.Parse(inputText).RenderAsync(templateContext); await File.WriteAllTextAsync(subtitleTemplateFile, textToRender, cancellationToken); string subtitleFile = Path.GetFileName(subtitleTemplateFile); string fontsDir = FileSystemLayout.FontsCacheFolder; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { fontsDir = fontsDir .Replace(@"\", @"/\") .Replace(@":/", @"\\:/"); subtitleFile = subtitleFile .Replace(@"\", @"/\") .Replace(@":/", @"\\:/"); } List arguments = [ "-nostdin", "-hide_banner", "-nostats", "-loglevel", "error", "-f", "lavfi", "-i", $"color=c=black@0.0:s={context.FrameSize.Width}x{context.FrameSize.Height}:r={context.FrameRate},format=bgra,subtitles={subtitleFile}:fontsdir={fontsDir}:alpha=1", "-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) { IsFinished = true; logger.LogWarning(ex, "Failed to initialize subtitle element; will disable for this content"); } } public override async ValueTask> PrepareImage( TimeSpan timeOfDay, TimeSpan contentTime, TimeSpan contentTotalTime, TimeSpan channelTime, CancellationToken cancellationToken) { if (_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 = _videoFrame.PeekPixels()) { sequence.CopyTo(pixmap.GetPixelSpan()); } // mark this frame as consumed consumed = sequence.End; // we are done, return the frame return new PreparedElementImage(_videoFrame, _point, 1.0f, ZIndex, 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); } } } } }