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.
244 lines
9.5 KiB
244 lines
9.5 KiB
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; |
|
|
|
namespace ErsatzTV.Infrastructure.Streaming.Graphics; |
|
|
|
public class GraphicsEngine( |
|
TemplateFunctions templateFunctions, |
|
GraphicsEngineFonts graphicsEngineFonts, |
|
ITempFilePool tempFilePool, |
|
IConfigElementRepository configElementRepository, |
|
ILocalStatisticsProvider localStatisticsProvider, |
|
ILogger<GraphicsEngine> logger) |
|
: IGraphicsEngine |
|
{ |
|
public async Task Run(GraphicsEngineContext context, PipeWriter pipeWriter, CancellationToken cancellationToken) |
|
{ |
|
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) |
|
{ |
|
switch (element) |
|
{ |
|
case WatermarkElementContext watermarkElementContext: |
|
var watermark = new WatermarkElement(watermarkElementContext.Options, logger); |
|
if (watermark.IsValid) |
|
{ |
|
elements.Add(watermark); |
|
} |
|
|
|
break; |
|
|
|
case ImageElementContext imageElementContext: |
|
elements.Add(new ImageElement(imageElementContext.ImageElement, logger)); |
|
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 ScriptElementDataContext scriptElementDataContext: |
|
elements.Add(new ScriptElement(scriptElementDataContext.ScriptElement, logger)); |
|
break; |
|
|
|
case SubtitleElementDataContext subtitleElementContext: |
|
{ |
|
var variables = context.TemplateVariables.ToDictionary(); |
|
foreach (KeyValuePair<string, string> variable in subtitleElementContext.Variables) |
|
{ |
|
variables.Add(variable.Key, variable.Value); |
|
} |
|
|
|
var subtitleElement = new SubtitleElement( |
|
templateFunctions, |
|
tempFilePool, |
|
subtitleElementContext.SubtitleElement, |
|
variables, |
|
logger); |
|
|
|
elements.Add(subtitleElement); |
|
break; |
|
} |
|
} |
|
} |
|
|
|
// initialize all elements |
|
await Task.WhenAll(elements.Select(e => e.InitializeAsync(context, cancellationToken))); |
|
|
|
long frameCount = 0; |
|
var totalFrames = (long)(context.Duration.TotalSeconds * context.FrameRate); |
|
|
|
int width = context.FrameSize.Width; |
|
int height = context.FrameSize.Height; |
|
int frameBufferSize = width * height * 4; // BGRA = 4 bytes |
|
var skImageInfo = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Unpremul); |
|
|
|
using var paint = new SKPaint(); |
|
var preparedElementImages = new List<PreparedElementImage>(elements.Count); |
|
|
|
var prepareTasks = new List<Task<Option<PreparedElementImage>>>(elements.Count); |
|
|
|
try |
|
{ |
|
// `content_total_seconds` - the total number of seconds in the content |
|
TimeSpan contentTotalTime = context.Seek + context.ContentTotalDuration; |
|
|
|
while (!cancellationToken.IsCancellationRequested && frameCount < totalFrames) |
|
{ |
|
// seconds since this specific stream started |
|
double streamTimeSeconds = (double)frameCount / context.FrameRate; |
|
var streamTime = TimeSpan.FromSeconds(streamTimeSeconds); |
|
|
|
// `content_seconds` - the total number of seconds the frame is into the content |
|
TimeSpan contentTime = context.Seek + streamTime; |
|
|
|
// `time_of_day_seconds` - the total number of seconds the frame is since midnight |
|
DateTimeOffset frameTime = context.ContentStartTime + contentTime; |
|
|
|
// `channel_seconds` - the total number of seconds the frame is from when the channel started/activated |
|
TimeSpan channelTime = frameTime - context.ChannelStartTime; |
|
|
|
// prepare images outside mutate to allow async image generation |
|
prepareTasks.Clear(); |
|
foreach (var element in elements) |
|
{ |
|
if (!element.IsFinished) |
|
{ |
|
Task<Option<PreparedElementImage>> task = SafePrepareImage( |
|
element, |
|
frameTime.TimeOfDay, |
|
contentTime, |
|
contentTotalTime, |
|
channelTime, |
|
cancellationToken); |
|
|
|
prepareTasks.Add(task); |
|
} |
|
} |
|
|
|
Option<PreparedElementImage>[] results = await Task.WhenAll(prepareTasks); |
|
|
|
preparedElementImages.Clear(); |
|
foreach (Option<PreparedElementImage> result in results) |
|
{ |
|
foreach (var preparedImage in result) |
|
{ |
|
preparedElementImages.Add(preparedImage); |
|
} |
|
} |
|
|
|
preparedElementImages.Sort((a, _) => a.ZIndex); |
|
|
|
Memory<byte> memory = pipeWriter.GetMemory(frameBufferSize); |
|
|
|
unsafe |
|
{ |
|
using (System.Buffers.MemoryHandle handle = memory.Pin()) |
|
{ |
|
using (var surface = SKSurface.Create(skImageInfo, (IntPtr)handle.Pointer, width * 4)) |
|
{ |
|
if (surface == null) |
|
{ |
|
logger.LogWarning("Failed to create SKSurface for frame"); |
|
} |
|
else |
|
{ |
|
var canvas = surface.Canvas; |
|
canvas.Clear(SKColors.Transparent); |
|
|
|
foreach (PreparedElementImage preparedImage in preparedElementImages) |
|
{ |
|
// Optimization: Skip BlendMode if opacity is full |
|
if (preparedImage.Opacity < 0.99f) |
|
{ |
|
using var colorFilter = SKColorFilter.CreateBlendMode( |
|
SKColors.White.WithAlpha((byte)(preparedImage.Opacity * 255)), |
|
SKBlendMode.Modulate); |
|
paint.ColorFilter = colorFilter; |
|
canvas.DrawBitmap(preparedImage.Image, preparedImage.Point, paint); |
|
} |
|
else |
|
{ |
|
paint.ColorFilter = null; |
|
canvas.DrawBitmap(preparedImage.Image, preparedImage.Point, paint); |
|
} |
|
|
|
if (preparedImage.Dispose) |
|
{ |
|
preparedImage.Image.Dispose(); |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
pipeWriter.Advance(frameBufferSize); |
|
await pipeWriter.FlushAsync(cancellationToken); |
|
|
|
frameCount++; |
|
} |
|
} |
|
catch (Exception) |
|
{ |
|
// do nothing; don't want to throw on a background task |
|
} |
|
finally |
|
{ |
|
await pipeWriter.CompleteAsync(); |
|
|
|
foreach (IDisposable element in elements.OfType<IDisposable>()) |
|
{ |
|
element.Dispose(); |
|
} |
|
} |
|
} |
|
|
|
private async Task<Option<PreparedElementImage>> SafePrepareImage( |
|
IGraphicsElement element, |
|
TimeSpan frameTimeOfDay, |
|
TimeSpan contentTime, |
|
TimeSpan contentTotalTime, |
|
TimeSpan channelTime, |
|
CancellationToken ct) |
|
{ |
|
try |
|
{ |
|
return await element.PrepareImage( |
|
frameTimeOfDay, |
|
contentTime, |
|
contentTotalTime, |
|
channelTime, |
|
ct); |
|
} |
|
catch (Exception ex) |
|
{ |
|
logger.LogWarning(ex, "Failed to render element {Type}. Disabling.", element.GetType().Name); |
|
|
|
element.IsFinished = true; |
|
|
|
return Option<PreparedElementImage>.None; |
|
} |
|
} |
|
}
|
|
|