Stream custom live channels using your own media
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

189 lines
6.4 KiB

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<string, object> variables,
ILogger logger)
: GraphicsElement, IDisposable
{
private CancellationTokenSource _cancellationTokenSource;
private CommandTask<CommandResult> _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<string> 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<Option<PreparedElementImage>> PrepareImage(
TimeSpan timeOfDay,
TimeSpan contentTime,
TimeSpan contentTotalTime,
TimeSpan channelTime,
CancellationToken cancellationToken)
{
if (_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 = _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<PreparedElementImage>.None;
}
}
finally
{
if (!_isFinished)
{
// advance the reader, consuming the processed frame and examining the entire buffer
_pipeReader.AdvanceTo(consumed, examined);
}
}
}
}
}