mirror of https://github.com/ErsatzTV/ErsatzTV.git
4 changed files with 265 additions and 270 deletions
@ -0,0 +1,234 @@
@@ -0,0 +1,234 @@
|
||||
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 async Task<Stream> GetImageStream(string image, CancellationToken cancellationToken) |
||||
{ |
||||
Stream imageStream; |
||||
|
||||
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(); |
||||
imageStream = new MemoryStream(await client.GetByteArrayAsync(uriResult, cancellationToken)); |
||||
} |
||||
else |
||||
{ |
||||
imageStream = new FileStream(image!, FileMode.Open, FileAccess.Read); |
||||
} |
||||
|
||||
return imageStream; |
||||
} |
||||
|
||||
protected static SKBitmap ToSkiaBitmap(Image image) |
||||
{ |
||||
// Force a known pixel format
|
||||
using Image<Rgba32> rgbaImage = image.CloneAs<Rgba32>(); |
||||
|
||||
int width = rgbaImage.Width; |
||||
int height = rgbaImage.Height; |
||||
|
||||
// Allocate destination SKBitmap with RGBA8888 (straight alpha)
|
||||
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."); |
||||
} |
||||
|
||||
// Pull pixel data from ImageSharp into a managed byte[]
|
||||
// (Rgba32 is 4 bytes, so length = width * height * 4)
|
||||
var pixelArray = new Rgba32[width * height]; |
||||
rgbaImage.CopyPixelDataTo(pixelArray); |
||||
|
||||
// Convert the Rgba32[] to a byte[] without unsafe code
|
||||
var bytes = new byte[pixelArray.Length * 4]; |
||||
MemoryMarshal.AsBytes(pixelArray.AsSpan()).CopyTo(bytes); |
||||
|
||||
// Copy into Skia's pixel buffer
|
||||
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()); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue