Merge pull request #9065 from barronpm/drawing-use-file-namespaces

pull/9073/head
Cody Robibero 2 years ago committed by GitHub
commit 515e69dcf7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -2,35 +2,34 @@ using System;
using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Drawing;
using SkiaSharp; using SkiaSharp;
namespace Jellyfin.Drawing.Skia namespace Jellyfin.Drawing.Skia;
/// <summary>
/// Static helper class used to draw percentage-played indicators on images.
/// </summary>
public static class PercentPlayedDrawer
{ {
private const int IndicatorHeight = 8;
/// <summary> /// <summary>
/// Static helper class used to draw percentage-played indicators on images. /// Draw a percentage played indicator on a canvas.
/// </summary> /// </summary>
public static class PercentPlayedDrawer /// <param name="canvas">The canvas to draw the indicator on.</param>
/// <param name="imageSize">The size of the image being drawn on.</param>
/// <param name="percent">The percentage played to display with the indicator.</param>
public static void Process(SKCanvas canvas, ImageDimensions imageSize, double percent)
{ {
private const int IndicatorHeight = 8; using var paint = new SKPaint();
var endX = imageSize.Width - 1;
/// <summary> var endY = imageSize.Height - 1;
/// Draw a percentage played indicator on a canvas.
/// </summary>
/// <param name="canvas">The canvas to draw the indicator on.</param>
/// <param name="imageSize">The size of the image being drawn on.</param>
/// <param name="percent">The percentage played to display with the indicator.</param>
public static void Process(SKCanvas canvas, ImageDimensions imageSize, double percent)
{
using var paint = new SKPaint();
var endX = imageSize.Width - 1;
var endY = imageSize.Height - 1;
paint.Color = SKColor.Parse("#99000000"); paint.Color = SKColor.Parse("#99000000");
paint.Style = SKPaintStyle.Fill; paint.Style = SKPaintStyle.Fill;
canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, endX, endY), paint); canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, endX, endY), paint);
double foregroundWidth = (endX * percent) / 100; double foregroundWidth = (endX * percent) / 100;
paint.Color = SKColor.Parse("#FF00A4DC"); paint.Color = SKColor.Parse("#FF00A4DC");
canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, Convert.ToInt32(foregroundWidth), endY), paint); canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, Convert.ToInt32(foregroundWidth), endY), paint);
}
} }
} }

@ -1,48 +1,47 @@
using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Drawing;
using SkiaSharp; using SkiaSharp;
namespace Jellyfin.Drawing.Skia namespace Jellyfin.Drawing.Skia;
/// <summary>
/// Static helper class for drawing 'played' indicators.
/// </summary>
public static class PlayedIndicatorDrawer
{ {
private const int OffsetFromTopRightCorner = 38;
/// <summary> /// <summary>
/// Static helper class for drawing 'played' indicators. /// Draw a 'played' indicator in the top right corner of a canvas.
/// </summary> /// </summary>
public static class PlayedIndicatorDrawer /// <param name="canvas">The canvas to draw the indicator on.</param>
/// <param name="imageSize">
/// The dimensions of the image to draw the indicator on. The width is used to determine the x-position of the
/// indicator.
/// </param>
public static void DrawPlayedIndicator(SKCanvas canvas, ImageDimensions imageSize)
{ {
private const int OffsetFromTopRightCorner = 38; var x = imageSize.Width - OffsetFromTopRightCorner;
/// <summary>
/// Draw a 'played' indicator in the top right corner of a canvas.
/// </summary>
/// <param name="canvas">The canvas to draw the indicator on.</param>
/// <param name="imageSize">
/// The dimensions of the image to draw the indicator on. The width is used to determine the x-position of the
/// indicator.
/// </param>
public static void DrawPlayedIndicator(SKCanvas canvas, ImageDimensions imageSize)
{
var x = imageSize.Width - OffsetFromTopRightCorner;
using var paint = new SKPaint using var paint = new SKPaint
{ {
Color = SKColor.Parse("#CC00A4DC"), Color = SKColor.Parse("#CC00A4DC"),
Style = SKPaintStyle.Fill Style = SKPaintStyle.Fill
}; };
canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint); canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
paint.Color = new SKColor(255, 255, 255, 255); paint.Color = new SKColor(255, 255, 255, 255);
paint.TextSize = 30; paint.TextSize = 30;
paint.IsAntialias = true; paint.IsAntialias = true;
// or: // or:
// var emojiChar = 0x1F680; // var emojiChar = 0x1F680;
const string Text = "✔️"; const string Text = "✔️";
var emojiChar = StringUtilities.GetUnicodeCharacterCode(Text, SKTextEncoding.Utf32); var emojiChar = StringUtilities.GetUnicodeCharacterCode(Text, SKTextEncoding.Utf32);
// ask the font manager for a font with that character // ask the font manager for a font with that character
paint.Typeface = SKFontManager.Default.MatchCharacter(emojiChar); paint.Typeface = SKFontManager.Default.MatchCharacter(emojiChar);
canvas.DrawText(Text, (float)x - 12, OffsetFromTopRightCorner + 12, paint); canvas.DrawText(Text, (float)x - 12, OffsetFromTopRightCorner + 12, paint);
}
} }
} }

@ -1,45 +1,44 @@
using System.Globalization; using System.Globalization;
using SkiaSharp; using SkiaSharp;
namespace Jellyfin.Drawing.Skia namespace Jellyfin.Drawing.Skia;
/// <summary>
/// Represents errors that occur during interaction with Skia codecs.
/// </summary>
public class SkiaCodecException : SkiaException
{ {
/// <summary> /// <summary>
/// Represents errors that occur during interaction with Skia codecs. /// Initializes a new instance of the <see cref="SkiaCodecException" /> class.
/// </summary> /// </summary>
public class SkiaCodecException : SkiaException /// <param name="result">The non-successful codec result returned by Skia.</param>
public SkiaCodecException(SKCodecResult result)
{ {
/// <summary> CodecResult = result;
/// Initializes a new instance of the <see cref="SkiaCodecException" /> class. }
/// </summary>
/// <param name="result">The non-successful codec result returned by Skia.</param>
public SkiaCodecException(SKCodecResult result)
{
CodecResult = result;
}
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SkiaCodecException" /> class /// Initializes a new instance of the <see cref="SkiaCodecException" /> class
/// with a specified error message. /// with a specified error message.
/// </summary> /// </summary>
/// <param name="result">The non-successful codec result returned by Skia.</param> /// <param name="result">The non-successful codec result returned by Skia.</param>
/// <param name="message">The message that describes the error.</param> /// <param name="message">The message that describes the error.</param>
public SkiaCodecException(SKCodecResult result, string message) public SkiaCodecException(SKCodecResult result, string message)
: base(message) : base(message)
{ {
CodecResult = result; CodecResult = result;
} }
/// <summary> /// <summary>
/// Gets the non-successful codec result returned by Skia. /// Gets the non-successful codec result returned by Skia.
/// </summary> /// </summary>
public SKCodecResult CodecResult { get; } public SKCodecResult CodecResult { get; }
/// <inheritdoc /> /// <inheritdoc />
public override string ToString() public override string ToString()
=> string.Format( => string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
"Non-success codec result: {0}\n{1}", "Non-success codec result: {0}\n{1}",
CodecResult, CodecResult,
base.ToString()); base.ToString());
}
} }

@ -12,534 +12,533 @@ using Microsoft.Extensions.Logging;
using SkiaSharp; using SkiaSharp;
using SKSvg = SkiaSharp.Extended.Svg.SKSvg; using SKSvg = SkiaSharp.Extended.Svg.SKSvg;
namespace Jellyfin.Drawing.Skia namespace Jellyfin.Drawing.Skia;
/// <summary>
/// Image encoder that uses <see cref="SkiaSharp"/> to manipulate images.
/// </summary>
public class SkiaEncoder : IImageEncoder
{ {
private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" };
private readonly ILogger<SkiaEncoder> _logger;
private readonly IApplicationPaths _appPaths;
/// <summary> /// <summary>
/// Image encoder that uses <see cref="SkiaSharp"/> to manipulate images. /// Initializes a new instance of the <see cref="SkiaEncoder"/> class.
/// </summary> /// </summary>
public class SkiaEncoder : IImageEncoder /// <param name="logger">The application logger.</param>
/// <param name="appPaths">The application paths.</param>
public SkiaEncoder(ILogger<SkiaEncoder> logger, IApplicationPaths appPaths)
{ {
private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" }; _logger = logger;
_appPaths = appPaths;
}
private readonly ILogger<SkiaEncoder> _logger; /// <inheritdoc/>
private readonly IApplicationPaths _appPaths; public string Name => "Skia";
/// <summary> /// <inheritdoc/>
/// Initializes a new instance of the <see cref="SkiaEncoder"/> class. public bool SupportsImageCollageCreation => true;
/// </summary>
/// <param name="logger">The application logger.</param>
/// <param name="appPaths">The application paths.</param>
public SkiaEncoder(ILogger<SkiaEncoder> logger, IApplicationPaths appPaths)
{
_logger = logger;
_appPaths = appPaths;
}
/// <inheritdoc/> /// <inheritdoc/>
public string Name => "Skia"; public bool SupportsImageEncoding => true;
/// <inheritdoc/> /// <inheritdoc/>
public bool SupportsImageCollageCreation => true; public IReadOnlyCollection<string> SupportedInputFormats =>
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
/// <inheritdoc/> {
public bool SupportsImageEncoding => true; "jpeg",
"jpg",
"png",
"dng",
"webp",
"gif",
"bmp",
"ico",
"astc",
"ktx",
"pkm",
"wbmp",
// TODO: check if these are supported on multiple platforms
// https://github.com/google/skia/blob/master/infra/bots/recipes/test.py#L454
// working on windows at least
"cr2",
"nef",
"arw"
};
/// <inheritdoc/>
public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
=> new HashSet<ImageFormat> { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png };
/// <inheritdoc/> /// <summary>
public IReadOnlyCollection<string> SupportedInputFormats => /// Check if the native lib is available.
new HashSet<string>(StringComparer.OrdinalIgnoreCase) /// </summary>
{ /// <returns>True if the native lib is available, otherwise false.</returns>
"jpeg", public static bool IsNativeLibAvailable()
"jpg", {
"png", try
"dng", {
"webp", // test an operation that requires the native library
"gif", SKPMColor.PreMultiply(SKColors.Black);
"bmp", return true;
"ico",
"astc",
"ktx",
"pkm",
"wbmp",
// TODO: check if these are supported on multiple platforms
// https://github.com/google/skia/blob/master/infra/bots/recipes/test.py#L454
// working on windows at least
"cr2",
"nef",
"arw"
};
/// <inheritdoc/>
public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
=> new HashSet<ImageFormat> { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png };
/// <summary>
/// Check if the native lib is available.
/// </summary>
/// <returns>True if the native lib is available, otherwise false.</returns>
public static bool IsNativeLibAvailable()
{
try
{
// test an operation that requires the native library
SKPMColor.PreMultiply(SKColors.Black);
return true;
}
catch (Exception)
{
return false;
}
} }
catch (Exception)
/// <summary>
/// Convert a <see cref="ImageFormat"/> to a <see cref="SKEncodedImageFormat"/>.
/// </summary>
/// <param name="selectedFormat">The format to convert.</param>
/// <returns>The converted format.</returns>
public static SKEncodedImageFormat GetImageFormat(ImageFormat selectedFormat)
{ {
return selectedFormat switch return false;
{
ImageFormat.Bmp => SKEncodedImageFormat.Bmp,
ImageFormat.Jpg => SKEncodedImageFormat.Jpeg,
ImageFormat.Gif => SKEncodedImageFormat.Gif,
ImageFormat.Webp => SKEncodedImageFormat.Webp,
_ => SKEncodedImageFormat.Png
};
} }
}
/// <inheritdoc /> /// <summary>
/// <exception cref="FileNotFoundException">The path is not valid.</exception> /// Convert a <see cref="ImageFormat"/> to a <see cref="SKEncodedImageFormat"/>.
public ImageDimensions GetImageSize(string path) /// </summary>
/// <param name="selectedFormat">The format to convert.</param>
/// <returns>The converted format.</returns>
public static SKEncodedImageFormat GetImageFormat(ImageFormat selectedFormat)
{
return selectedFormat switch
{ {
if (!File.Exists(path)) ImageFormat.Bmp => SKEncodedImageFormat.Bmp,
{ ImageFormat.Jpg => SKEncodedImageFormat.Jpeg,
throw new FileNotFoundException("File not found", path); ImageFormat.Gif => SKEncodedImageFormat.Gif,
} ImageFormat.Webp => SKEncodedImageFormat.Webp,
_ => SKEncodedImageFormat.Png
};
}
var extension = Path.GetExtension(path.AsSpan()); /// <inheritdoc />
if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase)) /// <exception cref="FileNotFoundException">The path is not valid.</exception>
{ public ImageDimensions GetImageSize(string path)
var svg = new SKSvg(); {
svg.Load(path); if (!File.Exists(path))
return new ImageDimensions(Convert.ToInt32(svg.Picture.CullRect.Width), Convert.ToInt32(svg.Picture.CullRect.Height)); {
} throw new FileNotFoundException("File not found", path);
}
using var codec = SKCodec.Create(path, out SKCodecResult result); var extension = Path.GetExtension(path.AsSpan());
switch (result) if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase))
{ {
case SKCodecResult.Success: var svg = new SKSvg();
var info = codec.Info; svg.Load(path);
return new ImageDimensions(info.Width, info.Height); return new ImageDimensions(Convert.ToInt32(svg.Picture.CullRect.Width), Convert.ToInt32(svg.Picture.CullRect.Height));
case SKCodecResult.Unimplemented:
_logger.LogDebug("Image format not supported: {FilePath}", path);
return new ImageDimensions(0, 0);
default:
_logger.LogError("Unable to determine image dimensions for {FilePath}: {SkCodecResult}", path, result);
return new ImageDimensions(0, 0);
}
} }
/// <inheritdoc /> using var codec = SKCodec.Create(path, out SKCodecResult result);
/// <exception cref="ArgumentNullException">The path is null.</exception> switch (result)
/// <exception cref="FileNotFoundException">The path is not valid.</exception>
/// <exception cref="SkiaCodecException">The file at the specified path could not be used to generate a codec.</exception>
public string GetImageBlurHash(int xComp, int yComp, string path)
{ {
ArgumentException.ThrowIfNullOrEmpty(path); case SKCodecResult.Success:
var info = codec.Info;
return new ImageDimensions(info.Width, info.Height);
case SKCodecResult.Unimplemented:
_logger.LogDebug("Image format not supported: {FilePath}", path);
return new ImageDimensions(0, 0);
default:
_logger.LogError("Unable to determine image dimensions for {FilePath}: {SkCodecResult}", path, result);
return new ImageDimensions(0, 0);
}
}
var extension = Path.GetExtension(path.AsSpan()).TrimStart('.'); /// <inheritdoc />
if (!SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase)) /// <exception cref="ArgumentNullException">The path is null.</exception>
{ /// <exception cref="FileNotFoundException">The path is not valid.</exception>
_logger.LogDebug("Unable to compute blur hash due to unsupported format: {ImagePath}", path); /// <exception cref="SkiaCodecException">The file at the specified path could not be used to generate a codec.</exception>
return string.Empty; public string GetImageBlurHash(int xComp, int yComp, string path)
} {
ArgumentException.ThrowIfNullOrEmpty(path);
// Any larger than 128x128 is too slow and there's no visually discernible difference var extension = Path.GetExtension(path.AsSpan()).TrimStart('.');
return BlurHashEncoder.Encode(xComp, yComp, path, 128, 128); if (!SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("Unable to compute blur hash due to unsupported format: {ImagePath}", path);
return string.Empty;
} }
private bool RequiresSpecialCharacterHack(string path) // Any larger than 128x128 is too slow and there's no visually discernible difference
return BlurHashEncoder.Encode(xComp, yComp, path, 128, 128);
}
private bool RequiresSpecialCharacterHack(string path)
{
for (int i = 0; i < path.Length; i++)
{ {
for (int i = 0; i < path.Length; i++) if (char.GetUnicodeCategory(path[i]) == UnicodeCategory.OtherLetter)
{ {
if (char.GetUnicodeCategory(path[i]) == UnicodeCategory.OtherLetter) return true;
{
return true;
}
} }
return path.HasDiacritics();
} }
private string NormalizePath(string path) return path.HasDiacritics();
}
private string NormalizePath(string path)
{
if (!RequiresSpecialCharacterHack(path))
{ {
if (!RequiresSpecialCharacterHack(path)) return path;
{ }
return path;
} var tempPath = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid() + Path.GetExtension(path));
var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPath}) is not valid.");
Directory.CreateDirectory(directory);
File.Copy(path, tempPath, true);
var tempPath = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid() + Path.GetExtension(path)); return tempPath;
var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPath}) is not valid."); }
Directory.CreateDirectory(directory);
File.Copy(path, tempPath, true);
return tempPath; private static SKEncodedOrigin GetSKEncodedOrigin(ImageOrientation? orientation)
{
if (!orientation.HasValue)
{
return SKEncodedOrigin.TopLeft;
} }
private static SKEncodedOrigin GetSKEncodedOrigin(ImageOrientation? orientation) return orientation.Value switch
{ {
if (!orientation.HasValue) ImageOrientation.TopRight => SKEncodedOrigin.TopRight,
{ ImageOrientation.RightTop => SKEncodedOrigin.RightTop,
return SKEncodedOrigin.TopLeft; ImageOrientation.RightBottom => SKEncodedOrigin.RightBottom,
} ImageOrientation.LeftTop => SKEncodedOrigin.LeftTop,
ImageOrientation.LeftBottom => SKEncodedOrigin.LeftBottom,
ImageOrientation.BottomRight => SKEncodedOrigin.BottomRight,
ImageOrientation.BottomLeft => SKEncodedOrigin.BottomLeft,
_ => SKEncodedOrigin.TopLeft
};
}
return orientation.Value switch /// <summary>
{ /// Decode an image.
ImageOrientation.TopRight => SKEncodedOrigin.TopRight, /// </summary>
ImageOrientation.RightTop => SKEncodedOrigin.RightTop, /// <param name="path">The filepath of the image to decode.</param>
ImageOrientation.RightBottom => SKEncodedOrigin.RightBottom, /// <param name="forceCleanBitmap">Whether to force clean the bitmap.</param>
ImageOrientation.LeftTop => SKEncodedOrigin.LeftTop, /// <param name="orientation">The orientation of the image.</param>
ImageOrientation.LeftBottom => SKEncodedOrigin.LeftBottom, /// <param name="origin">The detected origin of the image.</param>
ImageOrientation.BottomRight => SKEncodedOrigin.BottomRight, /// <returns>The resulting bitmap of the image.</returns>
ImageOrientation.BottomLeft => SKEncodedOrigin.BottomLeft, internal SKBitmap? Decode(string path, bool forceCleanBitmap, ImageOrientation? orientation, out SKEncodedOrigin origin)
_ => SKEncodedOrigin.TopLeft {
}; if (!File.Exists(path))
{
throw new FileNotFoundException("File not found", path);
} }
/// <summary> var requiresTransparencyHack = _transparentImageTypes.Contains(Path.GetExtension(path));
/// Decode an image.
/// </summary> if (requiresTransparencyHack || forceCleanBitmap)
/// <param name="path">The filepath of the image to decode.</param> {
/// <param name="forceCleanBitmap">Whether to force clean the bitmap.</param> using SKCodec codec = SKCodec.Create(NormalizePath(path), out SKCodecResult res);
/// <param name="orientation">The orientation of the image.</param> if (res != SKCodecResult.Success)
/// <param name="origin">The detected origin of the image.</param>
/// <returns>The resulting bitmap of the image.</returns>
internal SKBitmap? Decode(string path, bool forceCleanBitmap, ImageOrientation? orientation, out SKEncodedOrigin origin)
{
if (!File.Exists(path))
{ {
throw new FileNotFoundException("File not found", path); origin = GetSKEncodedOrigin(orientation);
return null;
} }
var requiresTransparencyHack = _transparentImageTypes.Contains(Path.GetExtension(path)); // create the bitmap
var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack);
if (requiresTransparencyHack || forceCleanBitmap)
{
using SKCodec codec = SKCodec.Create(NormalizePath(path), out SKCodecResult res);
if (res != SKCodecResult.Success)
{
origin = GetSKEncodedOrigin(orientation);
return null;
}
// create the bitmap // decode
var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack); _ = codec.GetPixels(bitmap.Info, bitmap.GetPixels());
// decode origin = codec.EncodedOrigin;
_ = codec.GetPixels(bitmap.Info, bitmap.GetPixels());
origin = codec.EncodedOrigin; return bitmap;
}
return bitmap; var resultBitmap = SKBitmap.Decode(NormalizePath(path));
}
var resultBitmap = SKBitmap.Decode(NormalizePath(path)); if (resultBitmap is null)
{
return Decode(path, true, orientation, out origin);
}
if (resultBitmap is null) // If we have to resize these they often end up distorted
if (resultBitmap.ColorType == SKColorType.Gray8)
{
using (resultBitmap)
{ {
return Decode(path, true, orientation, out origin); return Decode(path, true, orientation, out origin);
} }
}
origin = SKEncodedOrigin.TopLeft;
return resultBitmap;
}
private SKBitmap? GetBitmap(string path, bool autoOrient, ImageOrientation? orientation)
{
if (autoOrient)
{
var bitmap = Decode(path, true, orientation, out var origin);
// If we have to resize these they often end up distorted if (bitmap is not null && origin != SKEncodedOrigin.TopLeft)
if (resultBitmap.ColorType == SKColorType.Gray8)
{ {
using (resultBitmap) using (bitmap)
{ {
return Decode(path, true, orientation, out origin); return OrientImage(bitmap, origin);
} }
} }
origin = SKEncodedOrigin.TopLeft; return bitmap;
return resultBitmap;
} }
private SKBitmap? GetBitmap(string path, bool autoOrient, ImageOrientation? orientation) return Decode(path, false, orientation, out _);
{ }
if (autoOrient)
{
var bitmap = Decode(path, true, orientation, out var origin);
if (bitmap is not null && origin != SKEncodedOrigin.TopLeft) private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin)
{ {
using (bitmap) var needsFlip = origin == SKEncodedOrigin.LeftBottom
{ || origin == SKEncodedOrigin.LeftTop
return OrientImage(bitmap, origin); || origin == SKEncodedOrigin.RightBottom
} || origin == SKEncodedOrigin.RightTop;
} var rotated = needsFlip
? new SKBitmap(bitmap.Height, bitmap.Width)
: new SKBitmap(bitmap.Width, bitmap.Height);
using var surface = new SKCanvas(rotated);
var midX = (float)rotated.Width / 2;
var midY = (float)rotated.Height / 2;
switch (origin)
{
case SKEncodedOrigin.TopRight:
surface.Scale(-1, 1, midX, midY);
break;
case SKEncodedOrigin.BottomRight:
surface.RotateDegrees(180, midX, midY);
break;
case SKEncodedOrigin.BottomLeft:
surface.Scale(1, -1, midX, midY);
break;
case SKEncodedOrigin.LeftTop:
surface.Translate(0, -rotated.Height);
surface.Scale(1, -1, midX, midY);
surface.RotateDegrees(-90);
break;
case SKEncodedOrigin.RightTop:
surface.Translate(rotated.Width, 0);
surface.RotateDegrees(90);
break;
case SKEncodedOrigin.RightBottom:
surface.Translate(rotated.Width, 0);
surface.Scale(1, -1, midX, midY);
surface.RotateDegrees(90);
break;
case SKEncodedOrigin.LeftBottom:
surface.Translate(0, rotated.Height);
surface.RotateDegrees(-90);
break;
}
return bitmap; surface.DrawBitmap(bitmap, 0, 0);
} return rotated;
}
return Decode(path, false, orientation, out _); /// <summary>
} /// Resizes an image on the CPU, by utilizing a surface and canvas.
///
/// The convolutional matrix kernel used in this resize function gives a (light) sharpening effect.
/// This technique is similar to effect that can be created using for example the [Convolution matrix filter in GIMP](https://docs.gimp.org/2.10/en/gimp-filter-convolution-matrix.html).
/// </summary>
/// <param name="source">The source bitmap.</param>
/// <param name="targetInfo">This specifies the target size and other information required to create the surface.</param>
/// <param name="isAntialias">This enables anti-aliasing on the SKPaint instance.</param>
/// <param name="isDither">This enables dithering on the SKPaint instance.</param>
/// <returns>The resized image.</returns>
internal static SKImage ResizeImage(SKBitmap source, SKImageInfo targetInfo, bool isAntialias = false, bool isDither = false)
{
using var surface = SKSurface.Create(targetInfo);
using var canvas = surface.Canvas;
using var paint = new SKPaint
{
FilterQuality = SKFilterQuality.High,
IsAntialias = isAntialias,
IsDither = isDither
};
private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin) var kernel = new float[9]
{ {
var needsFlip = origin == SKEncodedOrigin.LeftBottom 0, -.1f, 0,
|| origin == SKEncodedOrigin.LeftTop -.1f, 1.4f, -.1f,
|| origin == SKEncodedOrigin.RightBottom 0, -.1f, 0,
|| origin == SKEncodedOrigin.RightTop; };
var rotated = needsFlip
? new SKBitmap(bitmap.Height, bitmap.Width) var kernelSize = new SKSizeI(3, 3);
: new SKBitmap(bitmap.Width, bitmap.Height); var kernelOffset = new SKPointI(1, 1);
using var surface = new SKCanvas(rotated);
var midX = (float)rotated.Width / 2; paint.ImageFilter = SKImageFilter.CreateMatrixConvolution(
var midY = (float)rotated.Height / 2; kernelSize,
kernel,
1f,
0f,
kernelOffset,
SKShaderTileMode.Clamp,
true);
canvas.DrawBitmap(
source,
SKRect.Create(0, 0, source.Width, source.Height),
SKRect.Create(0, 0, targetInfo.Width, targetInfo.Height),
paint);
return surface.Snapshot();
}
switch (origin) /// <inheritdoc/>
{ public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat)
case SKEncodedOrigin.TopRight: {
surface.Scale(-1, 1, midX, midY); ArgumentException.ThrowIfNullOrEmpty(inputPath);
break; ArgumentException.ThrowIfNullOrEmpty(outputPath);
case SKEncodedOrigin.BottomRight:
surface.RotateDegrees(180, midX, midY);
break;
case SKEncodedOrigin.BottomLeft:
surface.Scale(1, -1, midX, midY);
break;
case SKEncodedOrigin.LeftTop:
surface.Translate(0, -rotated.Height);
surface.Scale(1, -1, midX, midY);
surface.RotateDegrees(-90);
break;
case SKEncodedOrigin.RightTop:
surface.Translate(rotated.Width, 0);
surface.RotateDegrees(90);
break;
case SKEncodedOrigin.RightBottom:
surface.Translate(rotated.Width, 0);
surface.Scale(1, -1, midX, midY);
surface.RotateDegrees(90);
break;
case SKEncodedOrigin.LeftBottom:
surface.Translate(0, rotated.Height);
surface.RotateDegrees(-90);
break;
}
surface.DrawBitmap(bitmap, 0, 0); var inputFormat = Path.GetExtension(inputPath.AsSpan()).TrimStart('.');
return rotated; if (!SupportedInputFormats.Contains(inputFormat, StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("Unable to encode image due to unsupported format: {ImagePath}", inputPath);
return inputPath;
} }
/// <summary> var skiaOutputFormat = GetImageFormat(outputFormat);
/// Resizes an image on the CPU, by utilizing a surface and canvas.
///
/// The convolutional matrix kernel used in this resize function gives a (light) sharpening effect.
/// This technique is similar to effect that can be created using for example the [Convolution matrix filter in GIMP](https://docs.gimp.org/2.10/en/gimp-filter-convolution-matrix.html).
/// </summary>
/// <param name="source">The source bitmap.</param>
/// <param name="targetInfo">This specifies the target size and other information required to create the surface.</param>
/// <param name="isAntialias">This enables anti-aliasing on the SKPaint instance.</param>
/// <param name="isDither">This enables dithering on the SKPaint instance.</param>
/// <returns>The resized image.</returns>
internal static SKImage ResizeImage(SKBitmap source, SKImageInfo targetInfo, bool isAntialias = false, bool isDither = false)
{
using var surface = SKSurface.Create(targetInfo);
using var canvas = surface.Canvas;
using var paint = new SKPaint
{
FilterQuality = SKFilterQuality.High,
IsAntialias = isAntialias,
IsDither = isDither
};
var kernel = new float[9] var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor);
{ var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer);
0, -.1f, 0, var blur = options.Blur ?? 0;
-.1f, 1.4f, -.1f, var hasIndicator = options.AddPlayedIndicator || options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0);
0, -.1f, 0,
};
var kernelSize = new SKSizeI(3, 3);
var kernelOffset = new SKPointI(1, 1);
paint.ImageFilter = SKImageFilter.CreateMatrixConvolution(
kernelSize,
kernel,
1f,
0f,
kernelOffset,
SKShaderTileMode.Clamp,
true);
canvas.DrawBitmap(
source,
SKRect.Create(0, 0, source.Width, source.Height),
SKRect.Create(0, 0, targetInfo.Width, targetInfo.Height),
paint);
return surface.Snapshot();
}
/// <inheritdoc/> using var bitmap = GetBitmap(inputPath, autoOrient, orientation);
public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat) if (bitmap is null)
{ {
ArgumentException.ThrowIfNullOrEmpty(inputPath); throw new InvalidDataException($"Skia unable to read image {inputPath}");
ArgumentException.ThrowIfNullOrEmpty(outputPath); }
var inputFormat = Path.GetExtension(inputPath.AsSpan()).TrimStart('.');
if (!SupportedInputFormats.Contains(inputFormat, StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("Unable to encode image due to unsupported format: {ImagePath}", inputPath);
return inputPath;
}
var skiaOutputFormat = GetImageFormat(outputFormat); var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height);
var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor); if (options.HasDefaultOptions(inputPath, originalImageSize) && !autoOrient)
var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer); {
var blur = options.Blur ?? 0; // Just spit out the original file if all the options are default
var hasIndicator = options.AddPlayedIndicator || options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0); return inputPath;
}
using var bitmap = GetBitmap(inputPath, autoOrient, orientation); var newImageSize = ImageHelper.GetNewImageSize(options, originalImageSize);
if (bitmap is null)
{
throw new InvalidDataException($"Skia unable to read image {inputPath}");
}
var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height); var width = newImageSize.Width;
var height = newImageSize.Height;
if (options.HasDefaultOptions(inputPath, originalImageSize) && !autoOrient) // scale image (the FromImage creates a copy)
{ var imageInfo = new SKImageInfo(width, height, bitmap.ColorType, bitmap.AlphaType, bitmap.ColorSpace);
// Just spit out the original file if all the options are default using var resizedBitmap = SKBitmap.FromImage(ResizeImage(bitmap, imageInfo));
return inputPath;
}
var newImageSize = ImageHelper.GetNewImageSize(options, originalImageSize); // If all we're doing is resizing then we can stop now
if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator)
{
var outputDirectory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
Directory.CreateDirectory(outputDirectory);
using var outputStream = new SKFileWStream(outputPath);
using var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels());
resizedBitmap.Encode(outputStream, skiaOutputFormat, quality);
return outputPath;
}
var width = newImageSize.Width; // create bitmap to use for canvas drawing used to draw into bitmap
var height = newImageSize.Height; using var saveBitmap = new SKBitmap(width, height);
using var canvas = new SKCanvas(saveBitmap);
// set background color if present
if (hasBackgroundColor)
{
canvas.Clear(SKColor.Parse(options.BackgroundColor));
}
// scale image (the FromImage creates a copy) // Add blur if option is present
var imageInfo = new SKImageInfo(width, height, bitmap.ColorType, bitmap.AlphaType, bitmap.ColorSpace); if (blur > 0)
using var resizedBitmap = SKBitmap.FromImage(ResizeImage(bitmap, imageInfo)); {
// create image from resized bitmap to apply blur
using var paint = new SKPaint();
using var filter = SKImageFilter.CreateBlur(blur, blur);
paint.ImageFilter = filter;
canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), paint);
}
else
{
// draw resized bitmap onto canvas
canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height));
}
// If all we're doing is resizing then we can stop now // If foreground layer present then draw
if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator) if (hasForegroundColor)
{
if (!double.TryParse(options.ForegroundLayer, out double opacity))
{ {
var outputDirectory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); opacity = .4;
Directory.CreateDirectory(outputDirectory);
using var outputStream = new SKFileWStream(outputPath);
using var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels());
resizedBitmap.Encode(outputStream, skiaOutputFormat, quality);
return outputPath;
} }
// create bitmap to use for canvas drawing used to draw into bitmap canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver);
using var saveBitmap = new SKBitmap(width, height); }
using var canvas = new SKCanvas(saveBitmap);
// set background color if present
if (hasBackgroundColor)
{
canvas.Clear(SKColor.Parse(options.BackgroundColor));
}
// Add blur if option is present if (hasIndicator)
if (blur > 0) {
{ DrawIndicator(canvas, width, height, options);
// create image from resized bitmap to apply blur }
using var paint = new SKPaint();
using var filter = SKImageFilter.CreateBlur(blur, blur);
paint.ImageFilter = filter;
canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), paint);
}
else
{
// draw resized bitmap onto canvas
canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height));
}
// If foreground layer present then draw var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
if (hasForegroundColor) Directory.CreateDirectory(directory);
using (var outputStream = new SKFileWStream(outputPath))
{
using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels()))
{ {
if (!double.TryParse(options.ForegroundLayer, out double opacity)) pixmap.Encode(outputStream, skiaOutputFormat, quality);
{
opacity = .4;
}
canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver);
} }
}
if (hasIndicator) return outputPath;
{ }
DrawIndicator(canvas, width, height, options);
}
var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); /// <inheritdoc/>
Directory.CreateDirectory(directory); public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
using (var outputStream = new SKFileWStream(outputPath)) {
{ double ratio = (double)options.Width / options.Height;
using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels()))
{
pixmap.Encode(outputStream, skiaOutputFormat, quality);
}
}
return outputPath; if (ratio >= 1.4)
{
new StripCollageBuilder(this).BuildThumbCollage(options.InputPaths, options.OutputPath, options.Width, options.Height, libraryName);
}
else if (ratio >= .9)
{
new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height);
}
else
{
// TODO: Create Poster collage capability
new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height);
} }
}
/// <inheritdoc />
public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
{
var splashBuilder = new SplashscreenBuilder(this);
var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
splashBuilder.GenerateSplash(posters, backdrops, outputPath);
}
/// <inheritdoc/> private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options)
public void CreateImageCollage(ImageCollageOptions options, string? libraryName) {
try
{ {
double ratio = (double)options.Width / options.Height; var currentImageSize = new ImageDimensions(imageWidth, imageHeight);
if (ratio >= 1.4) if (options.AddPlayedIndicator)
{ {
new StripCollageBuilder(this).BuildThumbCollage(options.InputPaths, options.OutputPath, options.Width, options.Height, libraryName); PlayedIndicatorDrawer.DrawPlayedIndicator(canvas, currentImageSize);
} }
else if (ratio >= .9) else if (options.UnplayedCount.HasValue)
{ {
new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height); UnplayedCountIndicator.DrawUnplayedCountIndicator(canvas, currentImageSize, options.UnplayedCount.Value);
} }
else
if (options.PercentPlayed > 0)
{ {
// TODO: Create Poster collage capability PercentPlayedDrawer.Process(canvas, currentImageSize, options.PercentPlayed);
new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height);
} }
} }
catch (Exception ex)
/// <inheritdoc />
public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
{ {
var splashBuilder = new SplashscreenBuilder(this); _logger.LogError(ex, "Error drawing indicator overlay");
var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
splashBuilder.GenerateSplash(posters, backdrops, outputPath);
}
private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options)
{
try
{
var currentImageSize = new ImageDimensions(imageWidth, imageHeight);
if (options.AddPlayedIndicator)
{
PlayedIndicatorDrawer.DrawPlayedIndicator(canvas, currentImageSize);
}
else if (options.UnplayedCount.HasValue)
{
UnplayedCountIndicator.DrawUnplayedCountIndicator(canvas, currentImageSize, options.UnplayedCount.Value);
}
if (options.PercentPlayed > 0)
{
PercentPlayedDrawer.Process(canvas, currentImageSize, options.PercentPlayed);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error drawing indicator overlay");
}
} }
} }
} }

@ -1,39 +1,38 @@
using System; using System;
namespace Jellyfin.Drawing.Skia namespace Jellyfin.Drawing.Skia;
/// <summary>
/// Represents errors that occur during interaction with Skia.
/// </summary>
public class SkiaException : Exception
{ {
/// <summary> /// <summary>
/// Represents errors that occur during interaction with Skia. /// Initializes a new instance of the <see cref="SkiaException"/> class.
/// </summary> /// </summary>
public class SkiaException : Exception public SkiaException()
{ {
/// <summary> }
/// Initializes a new instance of the <see cref="SkiaException"/> class.
/// </summary>
public SkiaException()
{
}
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message. /// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message.
/// </summary> /// </summary>
/// <param name="message">The message that describes the error.</param> /// <param name="message">The message that describes the error.</param>
public SkiaException(string message) : base(message) public SkiaException(string message) : base(message)
{ {
} }
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message and a /// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message and a
/// reference to the inner exception that is the cause of this exception. /// reference to the inner exception that is the cause of this exception.
/// </summary> /// </summary>
/// <param name="message">The error message that explains the reason for the exception.</param> /// <param name="message">The error message that explains the reason for the exception.</param>
/// <param name="innerException"> /// <param name="innerException">
/// The exception that is the cause of the current exception, or a null reference (Nothing in Visual Basic) if /// The exception that is the cause of the current exception, or a null reference (Nothing in Visual Basic) if
/// no inner exception is specified. /// no inner exception is specified.
/// </param> /// </param>
public SkiaException(string message, Exception innerException) public SkiaException(string message, Exception innerException)
: base(message, innerException) : base(message, innerException)
{ {
}
} }
} }

@ -1,47 +1,46 @@
using System.Collections.Generic; using System.Collections.Generic;
using SkiaSharp; using SkiaSharp;
namespace Jellyfin.Drawing.Skia namespace Jellyfin.Drawing.Skia;
/// <summary>
/// Class containing helper methods for working with SkiaSharp.
/// </summary>
public static class SkiaHelper
{ {
/// <summary> /// <summary>
/// Class containing helper methods for working with SkiaSharp. /// Gets the next valid image as a bitmap.
/// </summary> /// </summary>
public static class SkiaHelper /// <param name="skiaEncoder">The current skia encoder.</param>
/// <param name="paths">The list of image paths.</param>
/// <param name="currentIndex">The current checked index.</param>
/// <param name="newIndex">The new index.</param>
/// <returns>A valid bitmap, or null if no bitmap exists after <c>currentIndex</c>.</returns>
public static SKBitmap? GetNextValidImage(SkiaEncoder skiaEncoder, IReadOnlyList<string> paths, int currentIndex, out int newIndex)
{ {
/// <summary> var imagesTested = new Dictionary<int, int>();
/// Gets the next valid image as a bitmap. SKBitmap? bitmap = null;
/// </summary>
/// <param name="skiaEncoder">The current skia encoder.</param>
/// <param name="paths">The list of image paths.</param>
/// <param name="currentIndex">The current checked index.</param>
/// <param name="newIndex">The new index.</param>
/// <returns>A valid bitmap, or null if no bitmap exists after <c>currentIndex</c>.</returns>
public static SKBitmap? GetNextValidImage(SkiaEncoder skiaEncoder, IReadOnlyList<string> paths, int currentIndex, out int newIndex)
{
var imagesTested = new Dictionary<int, int>();
SKBitmap? bitmap = null;
while (imagesTested.Count < paths.Count) while (imagesTested.Count < paths.Count)
{
if (currentIndex >= paths.Count)
{ {
if (currentIndex >= paths.Count) currentIndex = 0;
{ }
currentIndex = 0;
}
bitmap = skiaEncoder.Decode(paths[currentIndex], false, null, out _); bitmap = skiaEncoder.Decode(paths[currentIndex], false, null, out _);
imagesTested[currentIndex] = 0; imagesTested[currentIndex] = 0;
currentIndex++; currentIndex++;
if (bitmap is not null) if (bitmap is not null)
{ {
break; break;
}
} }
newIndex = currentIndex;
return bitmap;
} }
newIndex = currentIndex;
return bitmap;
} }
} }

@ -2,147 +2,146 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using SkiaSharp; using SkiaSharp;
namespace Jellyfin.Drawing.Skia namespace Jellyfin.Drawing.Skia;
/// <summary>
/// Used to build the splashscreen.
/// </summary>
public class SplashscreenBuilder
{ {
private const int FinalWidth = 1920;
private const int FinalHeight = 1080;
// generated collage resolution should be higher than the final resolution
private const int WallWidth = FinalWidth * 3;
private const int WallHeight = FinalHeight * 2;
private const int Rows = 6;
private const int Spacing = 20;
private readonly SkiaEncoder _skiaEncoder;
/// <summary> /// <summary>
/// Used to build the splashscreen. /// Initializes a new instance of the <see cref="SplashscreenBuilder"/> class.
/// </summary> /// </summary>
public class SplashscreenBuilder /// <param name="skiaEncoder">The SkiaEncoder.</param>
public SplashscreenBuilder(SkiaEncoder skiaEncoder)
{ {
private const int FinalWidth = 1920; _skiaEncoder = skiaEncoder;
private const int FinalHeight = 1080; }
// generated collage resolution should be higher than the final resolution
private const int WallWidth = FinalWidth * 3;
private const int WallHeight = FinalHeight * 2;
private const int Rows = 6;
private const int Spacing = 20;
private readonly SkiaEncoder _skiaEncoder;
/// <summary>
/// Initializes a new instance of the <see cref="SplashscreenBuilder"/> class.
/// </summary>
/// <param name="skiaEncoder">The SkiaEncoder.</param>
public SplashscreenBuilder(SkiaEncoder skiaEncoder)
{
_skiaEncoder = skiaEncoder;
}
/// <summary> /// <summary>
/// Generate a splashscreen. /// Generate a splashscreen.
/// </summary> /// </summary>
/// <param name="posters">The poster paths.</param> /// <param name="posters">The poster paths.</param>
/// <param name="backdrops">The landscape paths.</param> /// <param name="backdrops">The landscape paths.</param>
/// <param name="outputPath">The output path.</param> /// <param name="outputPath">The output path.</param>
public void GenerateSplash(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops, string outputPath) public void GenerateSplash(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops, string outputPath)
{ {
using var wall = GenerateCollage(posters, backdrops); using var wall = GenerateCollage(posters, backdrops);
using var transformed = Transform3D(wall); using var transformed = Transform3D(wall);
using var outputStream = new SKFileWStream(outputPath); using var outputStream = new SKFileWStream(outputPath);
using var pixmap = new SKPixmap(new SKImageInfo(FinalWidth, FinalHeight), transformed.GetPixels()); using var pixmap = new SKPixmap(new SKImageInfo(FinalWidth, FinalHeight), transformed.GetPixels());
pixmap.Encode(outputStream, StripCollageBuilder.GetEncodedFormat(outputPath), 90); pixmap.Encode(outputStream, StripCollageBuilder.GetEncodedFormat(outputPath), 90);
} }
/// <summary> /// <summary>
/// Generates a collage of posters and landscape pictures. /// Generates a collage of posters and landscape pictures.
/// </summary> /// </summary>
/// <param name="posters">The poster paths.</param> /// <param name="posters">The poster paths.</param>
/// <param name="backdrops">The landscape paths.</param> /// <param name="backdrops">The landscape paths.</param>
/// <returns>The created collage as a bitmap.</returns> /// <returns>The created collage as a bitmap.</returns>
private SKBitmap GenerateCollage(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops) private SKBitmap GenerateCollage(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
{ {
var posterIndex = 0; var posterIndex = 0;
var backdropIndex = 0; var backdropIndex = 0;
var bitmap = new SKBitmap(WallWidth, WallHeight);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.Black);
var bitmap = new SKBitmap(WallWidth, WallHeight); int posterHeight = WallHeight / 6;
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.Black);
int posterHeight = WallHeight / 6; for (int i = 0; i < Rows; i++)
{
int imageCounter = Random.Shared.Next(0, 5);
int currentWidthPos = i * 75;
int currentHeight = i * (posterHeight + Spacing);
for (int i = 0; i < Rows; i++) while (currentWidthPos < WallWidth)
{ {
int imageCounter = Random.Shared.Next(0, 5); SKBitmap? currentImage;
int currentWidthPos = i * 75;
int currentHeight = i * (posterHeight + Spacing);
while (currentWidthPos < WallWidth) switch (imageCounter)
{ {
SKBitmap? currentImage; case 0:
case 2:
switch (imageCounter) case 3:
{ currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, posters, posterIndex, out int newPosterIndex);
case 0: posterIndex = newPosterIndex;
case 2: break;
case 3: default:
currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, posters, posterIndex, out int newPosterIndex); currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, backdrops, backdropIndex, out int newBackdropIndex);
posterIndex = newPosterIndex; backdropIndex = newBackdropIndex;
break; break;
default:
currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, backdrops, backdropIndex, out int newBackdropIndex);
backdropIndex = newBackdropIndex;
break;
}
if (currentImage is null)
{
throw new ArgumentException("Not enough valid pictures provided to create a splashscreen!");
}
// resize to the same aspect as the original
var imageWidth = Math.Abs(posterHeight * currentImage.Width / currentImage.Height);
using var resizedBitmap = new SKBitmap(imageWidth, posterHeight);
currentImage.ScalePixels(resizedBitmap, SKFilterQuality.High);
// draw on canvas
canvas.DrawBitmap(resizedBitmap, currentWidthPos, currentHeight);
currentWidthPos += imageWidth + Spacing;
currentImage.Dispose();
if (imageCounter >= 4)
{
imageCounter = 0;
}
else
{
imageCounter++;
}
} }
}
return bitmap; if (currentImage is null)
{
throw new ArgumentException("Not enough valid pictures provided to create a splashscreen!");
}
// resize to the same aspect as the original
var imageWidth = Math.Abs(posterHeight * currentImage.Width / currentImage.Height);
using var resizedBitmap = new SKBitmap(imageWidth, posterHeight);
currentImage.ScalePixels(resizedBitmap, SKFilterQuality.High);
// draw on canvas
canvas.DrawBitmap(resizedBitmap, currentWidthPos, currentHeight);
currentWidthPos += imageWidth + Spacing;
currentImage.Dispose();
if (imageCounter >= 4)
{
imageCounter = 0;
}
else
{
imageCounter++;
}
}
} }
/// <summary> return bitmap;
/// Transform the collage in 3D space. }
/// </summary>
/// <param name="input">The bitmap to transform.</param> /// <summary>
/// <returns>The transformed image.</returns> /// Transform the collage in 3D space.
private SKBitmap Transform3D(SKBitmap input) /// </summary>
/// <param name="input">The bitmap to transform.</param>
/// <returns>The transformed image.</returns>
private SKBitmap Transform3D(SKBitmap input)
{
var bitmap = new SKBitmap(FinalWidth, FinalHeight);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.Black);
var matrix = new SKMatrix
{ {
var bitmap = new SKBitmap(FinalWidth, FinalHeight); ScaleX = 0.324108899f,
using var canvas = new SKCanvas(bitmap); ScaleY = 0.563934922f,
canvas.Clear(SKColors.Black); SkewX = -0.244337708f,
var matrix = new SKMatrix SkewY = 0.0377609022f,
{ TransX = 42.0407715f,
ScaleX = 0.324108899f, TransY = -198.104706f,
ScaleY = 0.563934922f, Persp0 = -9.08959337E-05f,
SkewX = -0.244337708f, Persp1 = 6.85242048E-05f,
SkewY = 0.0377609022f, Persp2 = 0.988209724f
TransX = 42.0407715f, };
TransY = -198.104706f,
Persp0 = -9.08959337E-05f, canvas.SetMatrix(matrix);
Persp1 = 6.85242048E-05f, canvas.DrawBitmap(input, 0, 0);
Persp2 = 0.988209724f
}; return bitmap;
canvas.SetMatrix(matrix);
canvas.DrawBitmap(input, 0, 0);
return bitmap;
}
} }
} }

@ -4,183 +4,182 @@ using System.IO;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using SkiaSharp; using SkiaSharp;
namespace Jellyfin.Drawing.Skia namespace Jellyfin.Drawing.Skia;
/// <summary>
/// Used to build collages of multiple images arranged in vertical strips.
/// </summary>
public class StripCollageBuilder
{ {
private readonly SkiaEncoder _skiaEncoder;
/// <summary>
/// Initializes a new instance of the <see cref="StripCollageBuilder"/> class.
/// </summary>
/// <param name="skiaEncoder">The encoder to use for building collages.</param>
public StripCollageBuilder(SkiaEncoder skiaEncoder)
{
_skiaEncoder = skiaEncoder;
}
/// <summary> /// <summary>
/// Used to build collages of multiple images arranged in vertical strips. /// Check which format an image has been encoded with using its filename extension.
/// </summary> /// </summary>
public class StripCollageBuilder /// <param name="outputPath">The path to the image to get the format for.</param>
/// <returns>The image format.</returns>
public static SKEncodedImageFormat GetEncodedFormat(string outputPath)
{ {
private readonly SkiaEncoder _skiaEncoder; ArgumentNullException.ThrowIfNull(outputPath);
var ext = Path.GetExtension(outputPath);
/// <summary> if (string.Equals(ext, ".jpg", StringComparison.OrdinalIgnoreCase)
/// Initializes a new instance of the <see cref="StripCollageBuilder"/> class. || string.Equals(ext, ".jpeg", StringComparison.OrdinalIgnoreCase))
/// </summary>
/// <param name="skiaEncoder">The encoder to use for building collages.</param>
public StripCollageBuilder(SkiaEncoder skiaEncoder)
{ {
_skiaEncoder = skiaEncoder; return SKEncodedImageFormat.Jpeg;
} }
/// <summary> if (string.Equals(ext, ".webp", StringComparison.OrdinalIgnoreCase))
/// Check which format an image has been encoded with using its filename extension.
/// </summary>
/// <param name="outputPath">The path to the image to get the format for.</param>
/// <returns>The image format.</returns>
public static SKEncodedImageFormat GetEncodedFormat(string outputPath)
{ {
ArgumentNullException.ThrowIfNull(outputPath); return SKEncodedImageFormat.Webp;
var ext = Path.GetExtension(outputPath);
if (string.Equals(ext, ".jpg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(ext, ".jpeg", StringComparison.OrdinalIgnoreCase))
{
return SKEncodedImageFormat.Jpeg;
}
if (string.Equals(ext, ".webp", StringComparison.OrdinalIgnoreCase))
{
return SKEncodedImageFormat.Webp;
}
if (string.Equals(ext, ".gif", StringComparison.OrdinalIgnoreCase))
{
return SKEncodedImageFormat.Gif;
}
if (string.Equals(ext, ".bmp", StringComparison.OrdinalIgnoreCase))
{
return SKEncodedImageFormat.Bmp;
}
// default to png
return SKEncodedImageFormat.Png;
} }
/// <summary> if (string.Equals(ext, ".gif", StringComparison.OrdinalIgnoreCase))
/// Create a square collage.
/// </summary>
/// <param name="paths">The paths of the images to use in the collage.</param>
/// <param name="outputPath">The path at which to place the resulting collage image.</param>
/// <param name="width">The desired width of the collage.</param>
/// <param name="height">The desired height of the collage.</param>
public void BuildSquareCollage(IReadOnlyList<string> paths, string outputPath, int width, int height)
{ {
using var bitmap = BuildSquareCollageBitmap(paths, width, height); return SKEncodedImageFormat.Gif;
using var outputStream = new SKFileWStream(outputPath);
using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels());
pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
} }
/// <summary> if (string.Equals(ext, ".bmp", StringComparison.OrdinalIgnoreCase))
/// Create a thumb collage.
/// </summary>
/// <param name="paths">The paths of the images to use in the collage.</param>
/// <param name="outputPath">The path at which to place the resulting image.</param>
/// <param name="width">The desired width of the collage.</param>
/// <param name="height">The desired height of the collage.</param>
/// <param name="libraryName">The name of the library to draw on the collage.</param>
public void BuildThumbCollage(IReadOnlyList<string> paths, string outputPath, int width, int height, string? libraryName)
{ {
using var bitmap = BuildThumbCollageBitmap(paths, width, height, libraryName); return SKEncodedImageFormat.Bmp;
using var outputStream = new SKFileWStream(outputPath);
using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels());
pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
} }
private SKBitmap BuildThumbCollageBitmap(IReadOnlyList<string> paths, int width, int height, string? libraryName) // default to png
{ return SKEncodedImageFormat.Png;
var bitmap = new SKBitmap(width, height); }
using var canvas = new SKCanvas(bitmap); /// <summary>
canvas.Clear(SKColors.Black); /// Create a square collage.
/// </summary>
/// <param name="paths">The paths of the images to use in the collage.</param>
/// <param name="outputPath">The path at which to place the resulting collage image.</param>
/// <param name="width">The desired width of the collage.</param>
/// <param name="height">The desired height of the collage.</param>
public void BuildSquareCollage(IReadOnlyList<string> paths, string outputPath, int width, int height)
{
using var bitmap = BuildSquareCollageBitmap(paths, width, height);
using var outputStream = new SKFileWStream(outputPath);
using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels());
pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
}
using var backdrop = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, 0, out _); /// <summary>
if (backdrop is null) /// Create a thumb collage.
{ /// </summary>
return bitmap; /// <param name="paths">The paths of the images to use in the collage.</param>
} /// <param name="outputPath">The path at which to place the resulting image.</param>
/// <param name="width">The desired width of the collage.</param>
/// <param name="height">The desired height of the collage.</param>
/// <param name="libraryName">The name of the library to draw on the collage.</param>
public void BuildThumbCollage(IReadOnlyList<string> paths, string outputPath, int width, int height, string? libraryName)
{
using var bitmap = BuildThumbCollageBitmap(paths, width, height, libraryName);
using var outputStream = new SKFileWStream(outputPath);
using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels());
pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
}
// resize to the same aspect as the original private SKBitmap BuildThumbCollageBitmap(IReadOnlyList<string> paths, int width, int height, string? libraryName)
var backdropHeight = Math.Abs(width * backdrop.Height / backdrop.Width); {
using var residedBackdrop = SkiaEncoder.ResizeImage(backdrop, new SKImageInfo(width, backdropHeight, backdrop.ColorType, backdrop.AlphaType, backdrop.ColorSpace)); var bitmap = new SKBitmap(width, height);
// draw the backdrop
canvas.DrawImage(residedBackdrop, 0, 0);
// draw shadow rectangle using var canvas = new SKCanvas(bitmap);
using var paintColor = new SKPaint canvas.Clear(SKColors.Black);
{
Color = SKColors.Black.WithAlpha(0x78),
Style = SKPaintStyle.Fill
};
canvas.DrawRect(0, 0, width, height, paintColor);
var typeFace = SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright); using var backdrop = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, 0, out _);
if (backdrop is null)
{
return bitmap;
}
// use the system fallback to find a typeface for the given CJK character // resize to the same aspect as the original
var nonCjkPattern = @"[^\p{IsCJKUnifiedIdeographs}\p{IsCJKUnifiedIdeographsExtensionA}\p{IsKatakana}\p{IsHiragana}\p{IsHangulSyllables}\p{IsHangulJamo}]"; var backdropHeight = Math.Abs(width * backdrop.Height / backdrop.Width);
var filteredName = Regex.Replace(libraryName ?? string.Empty, nonCjkPattern, string.Empty); using var residedBackdrop = SkiaEncoder.ResizeImage(backdrop, new SKImageInfo(width, backdropHeight, backdrop.ColorType, backdrop.AlphaType, backdrop.ColorSpace));
if (!string.IsNullOrEmpty(filteredName)) // draw the backdrop
{ canvas.DrawImage(residedBackdrop, 0, 0);
typeFace = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, filteredName[0]);
}
// draw library name // draw shadow rectangle
using var textPaint = new SKPaint using var paintColor = new SKPaint
{ {
Color = SKColors.White, Color = SKColors.Black.WithAlpha(0x78),
Style = SKPaintStyle.Fill, Style = SKPaintStyle.Fill
TextSize = 112, };
TextAlign = SKTextAlign.Center, canvas.DrawRect(0, 0, width, height, paintColor);
Typeface = typeFace,
IsAntialias = true
};
// scale down text to 90% of the width if text is larger than 95% of the width
var textWidth = textPaint.MeasureText(libraryName);
if (textWidth > width * 0.95)
{
textPaint.TextSize = 0.9f * width * textPaint.TextSize / textWidth;
}
canvas.DrawText(libraryName, width / 2f, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), textPaint); var typeFace = SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright);
return bitmap; // use the system fallback to find a typeface for the given CJK character
var nonCjkPattern = @"[^\p{IsCJKUnifiedIdeographs}\p{IsCJKUnifiedIdeographsExtensionA}\p{IsKatakana}\p{IsHiragana}\p{IsHangulSyllables}\p{IsHangulJamo}]";
var filteredName = Regex.Replace(libraryName ?? string.Empty, nonCjkPattern, string.Empty);
if (!string.IsNullOrEmpty(filteredName))
{
typeFace = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, filteredName[0]);
} }
private SKBitmap BuildSquareCollageBitmap(IReadOnlyList<string> paths, int width, int height) // draw library name
using var textPaint = new SKPaint
{
Color = SKColors.White,
Style = SKPaintStyle.Fill,
TextSize = 112,
TextAlign = SKTextAlign.Center,
Typeface = typeFace,
IsAntialias = true
};
// scale down text to 90% of the width if text is larger than 95% of the width
var textWidth = textPaint.MeasureText(libraryName);
if (textWidth > width * 0.95)
{ {
var bitmap = new SKBitmap(width, height); textPaint.TextSize = 0.9f * width * textPaint.TextSize / textWidth;
var imageIndex = 0; }
var cellWidth = width / 2;
var cellHeight = height / 2; canvas.DrawText(libraryName, width / 2f, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), textPaint);
return bitmap;
}
private SKBitmap BuildSquareCollageBitmap(IReadOnlyList<string> paths, int width, int height)
{
var bitmap = new SKBitmap(width, height);
var imageIndex = 0;
var cellWidth = width / 2;
var cellHeight = height / 2;
using var canvas = new SKCanvas(bitmap); using var canvas = new SKCanvas(bitmap);
for (var x = 0; x < 2; x++) for (var x = 0; x < 2; x++)
{
for (var y = 0; y < 2; y++)
{ {
for (var y = 0; y < 2; y++) using var currentBitmap = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, imageIndex, out int newIndex);
imageIndex = newIndex;
if (currentBitmap is null)
{ {
using var currentBitmap = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, imageIndex, out int newIndex); continue;
imageIndex = newIndex;
if (currentBitmap is null)
{
continue;
}
// Scale image. The FromBitmap creates a copy
var imageInfo = new SKImageInfo(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType, currentBitmap.ColorSpace);
using var resizedBitmap = SKBitmap.FromImage(SkiaEncoder.ResizeImage(currentBitmap, imageInfo));
// draw this image into the strip at the next position
var xPos = x * cellWidth;
var yPos = y * cellHeight;
canvas.DrawBitmap(resizedBitmap, xPos, yPos);
} }
}
return bitmap; // Scale image. The FromBitmap creates a copy
var imageInfo = new SKImageInfo(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType, currentBitmap.ColorSpace);
using var resizedBitmap = SKBitmap.FromImage(SkiaEncoder.ResizeImage(currentBitmap, imageInfo));
// draw this image into the strip at the next position
var xPos = x * cellWidth;
var yPos = y * cellHeight;
canvas.DrawBitmap(resizedBitmap, xPos, yPos);
}
} }
return bitmap;
} }
} }

@ -2,63 +2,62 @@ using System.Globalization;
using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Drawing;
using SkiaSharp; using SkiaSharp;
namespace Jellyfin.Drawing.Skia namespace Jellyfin.Drawing.Skia;
/// <summary>
/// Static helper class for drawing unplayed count indicators.
/// </summary>
public static class UnplayedCountIndicator
{ {
/// <summary> /// <summary>
/// Static helper class for drawing unplayed count indicators. /// The x-offset used when drawing an unplayed count indicator.
/// </summary>
private const int OffsetFromTopRightCorner = 38;
/// <summary>
/// Draw an unplayed count indicator in the top right corner of a canvas.
/// </summary> /// </summary>
public static class UnplayedCountIndicator /// <param name="canvas">The canvas to draw the indicator on.</param>
/// <param name="imageSize">
/// The dimensions of the image to draw the indicator on. The width is used to determine the x-position of the
/// indicator.
/// </param>
/// <param name="count">The number to draw in the indicator.</param>
public static void DrawUnplayedCountIndicator(SKCanvas canvas, ImageDimensions imageSize, int count)
{ {
/// <summary> var x = imageSize.Width - OffsetFromTopRightCorner;
/// The x-offset used when drawing an unplayed count indicator. var text = count.ToString(CultureInfo.InvariantCulture);
/// </summary>
private const int OffsetFromTopRightCorner = 38;
/// <summary> using var paint = new SKPaint
/// Draw an unplayed count indicator in the top right corner of a canvas.
/// </summary>
/// <param name="canvas">The canvas to draw the indicator on.</param>
/// <param name="imageSize">
/// The dimensions of the image to draw the indicator on. The width is used to determine the x-position of the
/// indicator.
/// </param>
/// <param name="count">The number to draw in the indicator.</param>
public static void DrawUnplayedCountIndicator(SKCanvas canvas, ImageDimensions imageSize, int count)
{ {
var x = imageSize.Width - OffsetFromTopRightCorner; Color = SKColor.Parse("#CC00A4DC"),
var text = count.ToString(CultureInfo.InvariantCulture); Style = SKPaintStyle.Fill
};
using var paint = new SKPaint
{
Color = SKColor.Parse("#CC00A4DC"),
Style = SKPaintStyle.Fill
};
canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
paint.Color = new SKColor(255, 255, 255, 255); canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
paint.TextSize = 24;
paint.IsAntialias = true;
var y = OffsetFromTopRightCorner + 9; paint.Color = new SKColor(255, 255, 255, 255);
paint.TextSize = 24;
paint.IsAntialias = true;
if (text.Length == 1) var y = OffsetFromTopRightCorner + 9;
{
x -= 7;
}
if (text.Length == 2) if (text.Length == 1)
{ {
x -= 13; x -= 7;
} }
else if (text.Length >= 3)
{
x -= 15;
y -= 2;
paint.TextSize = 18;
}
canvas.DrawText(text, x, y, paint); if (text.Length == 2)
{
x -= 13;
} }
else if (text.Length >= 3)
{
x -= 15;
y -= 2;
paint.TextSize = 18;
}
canvas.DrawText(text, x, y, paint);
} }
} }

File diff suppressed because it is too large Load Diff

@ -3,56 +3,55 @@ using System.Collections.Generic;
using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Drawing;
using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Drawing;
namespace Jellyfin.Drawing namespace Jellyfin.Drawing;
/// <summary>
/// A fallback implementation of <see cref="IImageEncoder" />.
/// </summary>
public class NullImageEncoder : IImageEncoder
{ {
/// <summary> /// <inheritdoc />
/// A fallback implementation of <see cref="IImageEncoder" />. public IReadOnlyCollection<string> SupportedInputFormats
/// </summary> => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "png", "jpeg", "jpg" };
public class NullImageEncoder : IImageEncoder
{
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedInputFormats
=> new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "png", "jpeg", "jpg" };
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyCollection<ImageFormat> SupportedOutputFormats public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
=> new HashSet<ImageFormat>() { ImageFormat.Jpg, ImageFormat.Png }; => new HashSet<ImageFormat>() { ImageFormat.Jpg, ImageFormat.Png };
/// <inheritdoc /> /// <inheritdoc />
public string Name => "Null Image Encoder"; public string Name => "Null Image Encoder";
/// <inheritdoc /> /// <inheritdoc />
public bool SupportsImageCollageCreation => false; public bool SupportsImageCollageCreation => false;
/// <inheritdoc /> /// <inheritdoc />
public bool SupportsImageEncoding => false; public bool SupportsImageEncoding => false;
/// <inheritdoc /> /// <inheritdoc />
public ImageDimensions GetImageSize(string path) public ImageDimensions GetImageSize(string path)
=> throw new NotImplementedException(); => throw new NotImplementedException();
/// <inheritdoc /> /// <inheritdoc />
public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat) public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
} }
/// <inheritdoc /> /// <inheritdoc />
public void CreateImageCollage(ImageCollageOptions options, string? libraryName) public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
} }
/// <inheritdoc /> /// <inheritdoc />
public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops) public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
} }
/// <inheritdoc /> /// <inheritdoc />
public string GetImageBlurHash(int xComp, int yComp, string path) public string GetImageBlurHash(int xComp, int yComp, string path)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
}
} }
} }

Loading…
Cancel
Save