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.
 
 
 

208 lines
7.0 KiB

using System.Runtime.InteropServices;
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Gif;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SkiaSharp;
using Image = SixLabors.ImageSharp.Image;
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
public abstract class ImageElementBase : GraphicsElement, IDisposable
{
private readonly List<SKBitmap> _scaledFrames = [];
private readonly List<double> _frameDelays = [];
private Image _sourceImage;
private double _animatedDurationSeconds;
protected SKPointI Location { get; private set; }
protected async Task LoadImage(
Resolution squarePixelFrameSize,
Resolution frameSize,
string image,
WatermarkLocation location,
bool scale,
double? scaleWidthPercent,
double? horizontalMarginPercent,
double? verticalMarginPercent,
bool placeWithinSourceContent,
CancellationToken cancellationToken)
{
bool isRemoteUri = Uri.TryCreate(image, UriKind.Absolute, out var uriResult)
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
if (isRemoteUri)
{
using var client = new HttpClient();
await using Stream imageStream = await client.GetStreamAsync(uriResult, cancellationToken);
_sourceImage = await Image.LoadAsync(imageStream, cancellationToken);
}
else
{
_sourceImage = await Image.LoadAsync(image!, cancellationToken);
}
int scaledWidth = _sourceImage.Width;
int scaledHeight = _sourceImage.Height;
if (scale)
{
scaledWidth = (int)Math.Round((scaleWidthPercent ?? 100) / 100.0 * frameSize.Width);
double aspectRatio = (double)_sourceImage.Height / _sourceImage.Width;
scaledHeight = (int)(scaledWidth * aspectRatio);
}
(int horizontalMargin, int verticalMargin) = placeWithinSourceContent
? SourceContentMargins(
squarePixelFrameSize,
frameSize,
horizontalMarginPercent ?? 0,
verticalMarginPercent ?? 0)
: NormalMargins(frameSize, horizontalMarginPercent ?? 0, verticalMarginPercent ?? 0);
Location = CalculatePosition(
location,
frameSize.Width,
frameSize.Height,
scaledWidth,
scaledHeight,
horizontalMargin,
verticalMargin);
_animatedDurationSeconds = 0;
for (int i = 0; i < _sourceImage.Frames.Count; i++)
{
Image frame = _sourceImage.Frames.CloneFrame(i);
frame.Mutate(ctx => ctx.Resize(scaledWidth, scaledHeight));
_scaledFrames.Add(ToSkiaBitmap(frame));
var frameDelay = GetFrameDelaySeconds(_sourceImage, i);
_animatedDurationSeconds += frameDelay;
_frameDelays.Add(frameDelay);
}
}
protected static SKBitmap ToSkiaBitmap(Image image)
{
using Image<Rgba32> rgbaImage = image.CloneAs<Rgba32>();
int width = rgbaImage.Width;
int height = rgbaImage.Height;
var info = new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Unpremul);
var skBitmap = new SKBitmap(info);
if (!skBitmap.TryAllocPixels(info))
{
skBitmap.Dispose();
throw new InvalidOperationException("Failed to allocate pixels for SKBitmap.");
}
var pixelArray = new Rgba32[width * height];
rgbaImage.CopyPixelDataTo(pixelArray);
var bytes = new byte[pixelArray.Length * 4];
MemoryMarshal.AsBytes(pixelArray.AsSpan()).CopyTo(bytes);
IntPtr dstPtr = skBitmap.GetPixels(out _);
Marshal.Copy(bytes, 0, dstPtr, bytes.Length);
return skBitmap;
}
protected static double GetFrameDelaySeconds(Image image, int frameIndex)
{
var format = image.Metadata.DecodedImageFormat;
var frameMeta = image.Frames[frameIndex].Metadata;
if (format == GifFormat.Instance)
{
// GIF frame delay is in hundredths of a second
var gifMeta = frameMeta.GetFormatMetadata(GifFormat.Instance);
return gifMeta.FrameDelay / 100.0;
}
if (format == PngFormat.Instance)
{
// PNG animated frame delay is in seconds (as double)
var pngMeta = frameMeta.GetFormatMetadata(PngFormat.Instance);
return pngMeta.FrameDelay.ToDouble();
}
if (format == WebpFormat.Instance)
{
// WEBP animated frame delay is in milliseconds
var webpMeta = frameMeta.GetFormatMetadata(WebpFormat.Instance);
return webpMeta.FrameDelay / 1000.0;
}
// Default: assume 1/60th second (~16.67 ms) if unknown
return 1.0 / 60.0;
}
protected SKBitmap GetFrameForTimestamp(TimeSpan timestamp)
{
if (_scaledFrames.Count <= 1)
{
return _scaledFrames[0];
}
double currentTime = timestamp.TotalSeconds % _animatedDurationSeconds;
double frameTime = 0;
for (int i = 0; i < _sourceImage.Frames.Count; i++)
{
frameTime += _frameDelays[i];
if (currentTime <= frameTime)
{
return _scaledFrames[i];
}
}
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);
public virtual void Dispose()
{
GC.SuppressFinalize(this);
_sourceImage?.Dispose();
_scaledFrames?.ForEach(f => f.Dispose());
}
}