mirror of https://github.com/ErsatzTV/ErsatzTV.git
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
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); |
|
} |
|
} |
|
} |
|
} |
|
}
|
|
|