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

This commit is contained in:
Cody Robibero 2023-01-11 12:57:10 -07:00 committed by GitHub
commit 515e69dcf7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1418 additions and 1429 deletions

View File

@ -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;
var endY = imageSize.Height - 1;
/// <summary> paint.Color = SKColor.Parse("#99000000");
/// Draw a percentage played indicator on a canvas. paint.Style = SKPaintStyle.Fill;
/// </summary> canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, endX, endY), paint);
/// <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"); double foregroundWidth = (endX * percent) / 100;
paint.Style = SKPaintStyle.Fill;
canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, endX, endY), paint);
double foregroundWidth = (endX * percent) / 100; paint.Color = SKColor.Parse("#FF00A4DC");
canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, Convert.ToInt32(foregroundWidth), endY), paint);
paint.Color = SKColor.Parse("#FF00A4DC");
canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, Convert.ToInt32(foregroundWidth), endY), paint);
}
} }
} }

View File

@ -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> using var paint = new SKPaint
/// 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; Color = SKColor.Parse("#CC00A4DC"),
Style = SKPaintStyle.Fill
};
using var paint = new SKPaint canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
{
Color = SKColor.Parse("#CC00A4DC"),
Style = SKPaintStyle.Fill
};
canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint); paint.Color = new SKColor(255, 255, 255, 255);
paint.TextSize = 30;
paint.IsAntialias = true;
paint.Color = new SKColor(255, 255, 255, 255); // or:
paint.TextSize = 30; // var emojiChar = 0x1F680;
paint.IsAntialias = true; const string Text = "✔️";
var emojiChar = StringUtilities.GetUnicodeCharacterCode(Text, SKTextEncoding.Utf32);
// or: // ask the font manager for a font with that character
// var emojiChar = 0x1F680; paint.Typeface = SKFontManager.Default.MatchCharacter(emojiChar);
const string Text = "✔️";
var emojiChar = StringUtilities.GetUnicodeCharacterCode(Text, SKTextEncoding.Utf32);
// ask the font manager for a font with that character canvas.DrawText(Text, (float)x - 12, OffsetFromTopRightCorner + 12, paint);
paint.Typeface = SKFontManager.Default.MatchCharacter(emojiChar);
canvas.DrawText(Text, (float)x - 12, OffsetFromTopRightCorner + 12, paint);
}
} }
} }

View File

@ -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>
/// Initializes a new instance of the <see cref="SkiaCodecException" /> class
/// with a specified error message.
/// </summary>
/// <param name="result">The non-successful codec result returned by Skia.</param>
/// <param name="message">The message that describes the error.</param>
public SkiaCodecException(SKCodecResult result, string message)
: base(message)
{
CodecResult = result;
}
/// <summary>
/// Gets the non-successful codec result returned by Skia.
/// </summary>
public SKCodecResult CodecResult { get; }
/// <inheritdoc />
public override string ToString()
=> string.Format(
CultureInfo.InvariantCulture,
"Non-success codec result: {0}\n{1}",
CodecResult,
base.ToString());
} }
/// <summary>
/// Initializes a new instance of the <see cref="SkiaCodecException" /> class
/// with a specified error message.
/// </summary>
/// <param name="result">The non-successful codec result returned by Skia.</param>
/// <param name="message">The message that describes the error.</param>
public SkiaCodecException(SKCodecResult result, string message)
: base(message)
{
CodecResult = result;
}
/// <summary>
/// Gets the non-successful codec result returned by Skia.
/// </summary>
public SKCodecResult CodecResult { get; }
/// <inheritdoc />
public override string ToString()
=> string.Format(
CultureInfo.InvariantCulture,
"Non-success codec result: {0}\n{1}",
CodecResult,
base.ToString());
} }

View File

@ -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> /// <inheritdoc/>
/// <param name="appPaths">The application paths.</param> public bool SupportsImageEncoding => true;
public SkiaEncoder(ILogger<SkiaEncoder> logger, IApplicationPaths appPaths)
/// <inheritdoc/>
public IReadOnlyCollection<string> SupportedInputFormats =>
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ {
_logger = logger; "jpeg",
_appPaths = appPaths; "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 };
/// <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;
}
}
/// <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
{
ImageFormat.Bmp => SKEncodedImageFormat.Bmp,
ImageFormat.Jpg => SKEncodedImageFormat.Jpeg,
ImageFormat.Gif => SKEncodedImageFormat.Gif,
ImageFormat.Webp => SKEncodedImageFormat.Webp,
_ => SKEncodedImageFormat.Png
};
}
/// <inheritdoc />
/// <exception cref="FileNotFoundException">The path is not valid.</exception>
public ImageDimensions GetImageSize(string path)
{
if (!File.Exists(path))
{
throw new FileNotFoundException("File not found", path);
} }
/// <inheritdoc/> var extension = Path.GetExtension(path.AsSpan());
public string Name => "Skia"; if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase))
/// <inheritdoc/>
public bool SupportsImageCollageCreation => true;
/// <inheritdoc/>
public bool SupportsImageEncoding => true;
/// <inheritdoc/>
public IReadOnlyCollection<string> SupportedInputFormats =>
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"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 };
/// <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 var svg = new SKSvg();
svg.Load(path);
return new ImageDimensions(Convert.ToInt32(svg.Picture.CullRect.Width), Convert.ToInt32(svg.Picture.CullRect.Height));
}
using var codec = SKCodec.Create(path, out SKCodecResult result);
switch (result)
{
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);
}
}
/// <inheritdoc />
/// <exception cref="ArgumentNullException">The path is null.</exception>
/// <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);
var extension = Path.GetExtension(path.AsSpan()).TrimStart('.');
if (!SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("Unable to compute blur hash due to unsupported format: {ImagePath}", path);
return string.Empty;
}
// 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++)
{
if (char.GetUnicodeCategory(path[i]) == UnicodeCategory.OtherLetter)
{ {
// test an operation that requires the native library
SKPMColor.PreMultiply(SKColors.Black);
return true; return true;
} }
catch (Exception)
{
return false;
}
} }
/// <summary> return path.HasDiacritics();
/// Convert a <see cref="ImageFormat"/> to a <see cref="SKEncodedImageFormat"/>. }
/// </summary>
/// <param name="selectedFormat">The format to convert.</param> private string NormalizePath(string path)
/// <returns>The converted format.</returns> {
public static SKEncodedImageFormat GetImageFormat(ImageFormat selectedFormat) if (!RequiresSpecialCharacterHack(path))
{ {
return selectedFormat switch return path;
{
ImageFormat.Bmp => SKEncodedImageFormat.Bmp,
ImageFormat.Jpg => SKEncodedImageFormat.Jpeg,
ImageFormat.Gif => SKEncodedImageFormat.Gif,
ImageFormat.Webp => SKEncodedImageFormat.Webp,
_ => SKEncodedImageFormat.Png
};
} }
/// <inheritdoc /> var tempPath = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid() + Path.GetExtension(path));
/// <exception cref="FileNotFoundException">The path is not valid.</exception> var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPath}) is not valid.");
public ImageDimensions GetImageSize(string path) Directory.CreateDirectory(directory);
File.Copy(path, tempPath, true);
return tempPath;
}
private static SKEncodedOrigin GetSKEncodedOrigin(ImageOrientation? orientation)
{
if (!orientation.HasValue)
{ {
if (!File.Exists(path)) return SKEncodedOrigin.TopLeft;
{
throw new FileNotFoundException("File not found", path);
}
var extension = Path.GetExtension(path.AsSpan());
if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase))
{
var svg = new SKSvg();
svg.Load(path);
return new ImageDimensions(Convert.ToInt32(svg.Picture.CullRect.Width), Convert.ToInt32(svg.Picture.CullRect.Height));
}
using var codec = SKCodec.Create(path, out SKCodecResult result);
switch (result)
{
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);
}
} }
/// <inheritdoc /> return orientation.Value switch
/// <exception cref="ArgumentNullException">The path is null.</exception>
/// <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); ImageOrientation.TopRight => SKEncodedOrigin.TopRight,
ImageOrientation.RightTop => SKEncodedOrigin.RightTop,
ImageOrientation.RightBottom => SKEncodedOrigin.RightBottom,
ImageOrientation.LeftTop => SKEncodedOrigin.LeftTop,
ImageOrientation.LeftBottom => SKEncodedOrigin.LeftBottom,
ImageOrientation.BottomRight => SKEncodedOrigin.BottomRight,
ImageOrientation.BottomLeft => SKEncodedOrigin.BottomLeft,
_ => SKEncodedOrigin.TopLeft
};
}
var extension = Path.GetExtension(path.AsSpan()).TrimStart('.'); /// <summary>
if (!SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase)) /// Decode an image.
{ /// </summary>
_logger.LogDebug("Unable to compute blur hash due to unsupported format: {ImagePath}", path); /// <param name="path">The filepath of the image to decode.</param>
return string.Empty; /// <param name="forceCleanBitmap">Whether to force clean the bitmap.</param>
} /// <param name="orientation">The orientation of the image.</param>
/// <param name="origin">The detected origin of the image.</param>
// Any larger than 128x128 is too slow and there's no visually discernible difference /// <returns>The resulting bitmap of the image.</returns>
return BlurHashEncoder.Encode(xComp, yComp, path, 128, 128); internal SKBitmap? Decode(string path, bool forceCleanBitmap, ImageOrientation? orientation, out SKEncodedOrigin origin)
{
if (!File.Exists(path))
{
throw new FileNotFoundException("File not found", path);
} }
private bool RequiresSpecialCharacterHack(string path) var requiresTransparencyHack = _transparentImageTypes.Contains(Path.GetExtension(path));
if (requiresTransparencyHack || forceCleanBitmap)
{ {
for (int i = 0; i < path.Length; i++) using SKCodec codec = SKCodec.Create(NormalizePath(path), out SKCodecResult res);
if (res != SKCodecResult.Success)
{ {
if (char.GetUnicodeCategory(path[i]) == UnicodeCategory.OtherLetter) origin = GetSKEncodedOrigin(orientation);
{ return null;
return true;
}
} }
return path.HasDiacritics(); // create the bitmap
var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack);
// decode
_ = codec.GetPixels(bitmap.Info, bitmap.GetPixels());
origin = codec.EncodedOrigin;
return bitmap;
} }
private string NormalizePath(string path) var resultBitmap = SKBitmap.Decode(NormalizePath(path));
if (resultBitmap is null)
{ {
if (!RequiresSpecialCharacterHack(path)) return Decode(path, true, orientation, out origin);
{
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);
return tempPath;
} }
private static SKEncodedOrigin GetSKEncodedOrigin(ImageOrientation? orientation) // If we have to resize these they often end up distorted
if (resultBitmap.ColorType == SKColorType.Gray8)
{ {
if (!orientation.HasValue) using (resultBitmap)
{
return SKEncodedOrigin.TopLeft;
}
return orientation.Value switch
{
ImageOrientation.TopRight => SKEncodedOrigin.TopRight,
ImageOrientation.RightTop => SKEncodedOrigin.RightTop,
ImageOrientation.RightBottom => SKEncodedOrigin.RightBottom,
ImageOrientation.LeftTop => SKEncodedOrigin.LeftTop,
ImageOrientation.LeftBottom => SKEncodedOrigin.LeftBottom,
ImageOrientation.BottomRight => SKEncodedOrigin.BottomRight,
ImageOrientation.BottomLeft => SKEncodedOrigin.BottomLeft,
_ => SKEncodedOrigin.TopLeft
};
}
/// <summary>
/// Decode an image.
/// </summary>
/// <param name="path">The filepath of the image to decode.</param>
/// <param name="forceCleanBitmap">Whether to force clean the bitmap.</param>
/// <param name="orientation">The orientation of the image.</param>
/// <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);
}
var requiresTransparencyHack = _transparentImageTypes.Contains(Path.GetExtension(path));
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
var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack);
// decode
_ = codec.GetPixels(bitmap.Info, bitmap.GetPixels());
origin = codec.EncodedOrigin;
return bitmap;
}
var resultBitmap = SKBitmap.Decode(NormalizePath(path));
if (resultBitmap is null)
{ {
return Decode(path, true, orientation, out origin); return Decode(path, true, orientation, out origin);
} }
}
// If we have to resize these they often end up distorted origin = SKEncodedOrigin.TopLeft;
if (resultBitmap.ColorType == SKColorType.Gray8) return resultBitmap;
}
private SKBitmap? GetBitmap(string path, bool autoOrient, ImageOrientation? orientation)
{
if (autoOrient)
{
var bitmap = Decode(path, true, orientation, out var origin);
if (bitmap is not null && origin != SKEncodedOrigin.TopLeft)
{ {
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 _);
}
private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin)
{
var needsFlip = origin == SKEncodedOrigin.LeftBottom
|| origin == SKEncodedOrigin.LeftTop
|| 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)
{ {
if (autoOrient) case SKEncodedOrigin.TopRight:
{ surface.Scale(-1, 1, midX, midY);
var bitmap = Decode(path, true, orientation, out var origin); break;
case SKEncodedOrigin.BottomRight:
if (bitmap is not null && origin != SKEncodedOrigin.TopLeft) surface.RotateDegrees(180, midX, midY);
{ break;
using (bitmap) case SKEncodedOrigin.BottomLeft:
{ surface.Scale(1, -1, midX, midY);
return OrientImage(bitmap, origin); break;
} case SKEncodedOrigin.LeftTop:
} surface.Translate(0, -rotated.Height);
surface.Scale(1, -1, midX, midY);
return bitmap; surface.RotateDegrees(-90);
} break;
case SKEncodedOrigin.RightTop:
return Decode(path, false, orientation, out _); 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;
} }
private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin) surface.DrawBitmap(bitmap, 0, 0);
return rotated;
}
/// <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
{ {
var needsFlip = origin == SKEncodedOrigin.LeftBottom FilterQuality = SKFilterQuality.High,
|| origin == SKEncodedOrigin.LeftTop IsAntialias = isAntialias,
|| origin == SKEncodedOrigin.RightBottom IsDither = isDither
|| 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) var kernel = new float[9]
{ {
case SKEncodedOrigin.TopRight: 0, -.1f, 0,
surface.Scale(-1, 1, midX, midY); -.1f, 1.4f, -.1f,
break; 0, -.1f, 0,
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 kernelSize = new SKSizeI(3, 3);
return rotated; 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/>
public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat)
{
ArgumentException.ThrowIfNullOrEmpty(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;
} }
/// <summary> var skiaOutputFormat = GetImageFormat(outputFormat);
/// Resizes an image on the CPU, by utilizing a surface and canvas.
/// var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor);
/// The convolutional matrix kernel used in this resize function gives a (light) sharpening effect. var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer);
/// 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). var blur = options.Blur ?? 0;
/// </summary> var hasIndicator = options.AddPlayedIndicator || options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0);
/// <param name="source">The source bitmap.</param>
/// <param name="targetInfo">This specifies the target size and other information required to create the surface.</param> using var bitmap = GetBitmap(inputPath, autoOrient, orientation);
/// <param name="isAntialias">This enables anti-aliasing on the SKPaint instance.</param> if (bitmap is null)
/// <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); throw new InvalidDataException($"Skia unable to read image {inputPath}");
using var canvas = surface.Canvas;
using var paint = new SKPaint
{
FilterQuality = SKFilterQuality.High,
IsAntialias = isAntialias,
IsDither = isDither
};
var kernel = new float[9]
{
0, -.1f, 0,
-.1f, 1.4f, -.1f,
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/> var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height);
public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat)
if (options.HasDefaultOptions(inputPath, originalImageSize) && !autoOrient)
{ {
ArgumentException.ThrowIfNullOrEmpty(inputPath); // Just spit out the original file if all the options are default
ArgumentException.ThrowIfNullOrEmpty(outputPath); return inputPath;
}
var inputFormat = Path.GetExtension(inputPath.AsSpan()).TrimStart('.'); var newImageSize = ImageHelper.GetNewImageSize(options, originalImageSize);
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 width = newImageSize.Width;
var height = newImageSize.Height;
var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor); // scale image (the FromImage creates a copy)
var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer); var imageInfo = new SKImageInfo(width, height, bitmap.ColorType, bitmap.AlphaType, bitmap.ColorSpace);
var blur = options.Blur ?? 0; using var resizedBitmap = SKBitmap.FromImage(ResizeImage(bitmap, imageInfo));
var hasIndicator = options.AddPlayedIndicator || options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0);
using var bitmap = GetBitmap(inputPath, autoOrient, orientation);
if (bitmap is null)
{
throw new InvalidDataException($"Skia unable to read image {inputPath}");
}
var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height);
if (options.HasDefaultOptions(inputPath, originalImageSize) && !autoOrient)
{
// Just spit out the original file if all the options are default
return inputPath;
}
var newImageSize = ImageHelper.GetNewImageSize(options, originalImageSize);
var width = newImageSize.Width;
var height = newImageSize.Height;
// scale image (the FromImage creates a copy)
var imageInfo = new SKImageInfo(width, height, bitmap.ColorType, bitmap.AlphaType, bitmap.ColorSpace);
using var resizedBitmap = SKBitmap.FromImage(ResizeImage(bitmap, imageInfo));
// 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;
}
// create bitmap to use for canvas drawing used to draw into bitmap
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 (blur > 0)
{
// 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
if (hasForegroundColor)
{
if (!double.TryParse(options.ForegroundLayer, out double opacity))
{
opacity = .4;
}
canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver);
}
if (hasIndicator)
{
DrawIndicator(canvas, width, height, options);
}
var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
Directory.CreateDirectory(directory);
using (var outputStream = new SKFileWStream(outputPath))
{
using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels()))
{
pixmap.Encode(outputStream, skiaOutputFormat, quality);
}
}
// 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; return outputPath;
} }
/// <inheritdoc/> // create bitmap to use for canvas drawing used to draw into bitmap
public void CreateImageCollage(ImageCollageOptions options, string? libraryName) using var saveBitmap = new SKBitmap(width, height);
using var canvas = new SKCanvas(saveBitmap);
// set background color if present
if (hasBackgroundColor)
{ {
double ratio = (double)options.Width / options.Height; canvas.Clear(SKColor.Parse(options.BackgroundColor));
}
if (ratio >= 1.4) // Add blur if option is present
if (blur > 0)
{
// 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
if (hasForegroundColor)
{
if (!double.TryParse(options.ForegroundLayer, out double opacity))
{ {
new StripCollageBuilder(this).BuildThumbCollage(options.InputPaths, options.OutputPath, options.Width, options.Height, libraryName); opacity = .4;
} }
else if (ratio >= .9)
canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver);
}
if (hasIndicator)
{
DrawIndicator(canvas, width, height, options);
}
var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
Directory.CreateDirectory(directory);
using (var outputStream = new SKFileWStream(outputPath))
{
using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels()))
{ {
new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height); pixmap.Encode(outputStream, skiaOutputFormat, quality);
}
else
{
// TODO: Create Poster collage capability
new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height);
} }
} }
/// <inheritdoc /> return outputPath;
public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops) }
/// <inheritdoc/>
public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
{
double ratio = (double)options.Width / options.Height;
if (ratio >= 1.4)
{ {
var splashBuilder = new SplashscreenBuilder(this); new StripCollageBuilder(this).BuildThumbCollage(options.InputPaths, options.OutputPath, options.Width, options.Height, libraryName);
var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
splashBuilder.GenerateSplash(posters, backdrops, outputPath);
} }
else if (ratio >= .9)
private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options)
{ {
try new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height);
{ }
var currentImageSize = new ImageDimensions(imageWidth, imageHeight); else
{
// TODO: Create Poster collage capability
new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height);
}
}
if (options.AddPlayedIndicator) /// <inheritdoc />
{ public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
PlayedIndicatorDrawer.DrawPlayedIndicator(canvas, currentImageSize); {
} var splashBuilder = new SplashscreenBuilder(this);
else if (options.UnplayedCount.HasValue) var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
{ splashBuilder.GenerateSplash(posters, backdrops, outputPath);
UnplayedCountIndicator.DrawUnplayedCountIndicator(canvas, currentImageSize, options.UnplayedCount.Value); }
}
if (options.PercentPlayed > 0) private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options)
{ {
PercentPlayedDrawer.Process(canvas, currentImageSize, options.PercentPlayed); try
} {
} var currentImageSize = new ImageDimensions(imageWidth, imageHeight);
catch (Exception ex)
if (options.AddPlayedIndicator)
{ {
_logger.LogError(ex, "Error drawing indicator overlay"); 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");
} }
} }
} }

View File

@ -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)
{ {
}
} }
} }

View File

@ -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> while (imagesTested.Count < paths.Count)
/// <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>(); if (currentIndex >= paths.Count)
SKBitmap? bitmap = null;
while (imagesTested.Count < paths.Count)
{ {
if (currentIndex >= paths.Count) currentIndex = 0;
{
currentIndex = 0;
}
bitmap = skiaEncoder.Decode(paths[currentIndex], false, null, out _);
imagesTested[currentIndex] = 0;
currentIndex++;
if (bitmap is not null)
{
break;
}
} }
newIndex = currentIndex; bitmap = skiaEncoder.Decode(paths[currentIndex], false, null, out _);
return bitmap;
imagesTested[currentIndex] = 0;
currentIndex++;
if (bitmap is not null)
{
break;
}
} }
newIndex = currentIndex;
return bitmap;
} }
} }

View File

@ -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>
/// Generate a splashscreen.
/// </summary>
/// <param name="posters">The poster paths.</param>
/// <param name="backdrops">The landscape paths.</param>
/// <param name="outputPath">The output path.</param>
public void GenerateSplash(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops, string outputPath)
{
using var wall = GenerateCollage(posters, backdrops);
using var transformed = Transform3D(wall);
/// <summary> using var outputStream = new SKFileWStream(outputPath);
/// Initializes a new instance of the <see cref="SplashscreenBuilder"/> class. using var pixmap = new SKPixmap(new SKImageInfo(FinalWidth, FinalHeight), transformed.GetPixels());
/// </summary> pixmap.Encode(outputStream, StripCollageBuilder.GetEncodedFormat(outputPath), 90);
/// <param name="skiaEncoder">The SkiaEncoder.</param> }
public SplashscreenBuilder(SkiaEncoder skiaEncoder)
/// <summary>
/// Generates a collage of posters and landscape pictures.
/// </summary>
/// <param name="posters">The poster paths.</param>
/// <param name="backdrops">The landscape paths.</param>
/// <returns>The created collage as a bitmap.</returns>
private SKBitmap GenerateCollage(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
{
var posterIndex = 0;
var backdropIndex = 0;
var bitmap = new SKBitmap(WallWidth, WallHeight);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.Black);
int posterHeight = WallHeight / 6;
for (int i = 0; i < Rows; i++)
{ {
_skiaEncoder = skiaEncoder; int imageCounter = Random.Shared.Next(0, 5);
} int currentWidthPos = i * 75;
int currentHeight = i * (posterHeight + Spacing);
/// <summary> while (currentWidthPos < WallWidth)
/// Generate a splashscreen.
/// </summary>
/// <param name="posters">The poster paths.</param>
/// <param name="backdrops">The landscape paths.</param>
/// <param name="outputPath">The output path.</param>
public void GenerateSplash(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops, string outputPath)
{
using var wall = GenerateCollage(posters, backdrops);
using var transformed = Transform3D(wall);
using var outputStream = new SKFileWStream(outputPath);
using var pixmap = new SKPixmap(new SKImageInfo(FinalWidth, FinalHeight), transformed.GetPixels());
pixmap.Encode(outputStream, StripCollageBuilder.GetEncodedFormat(outputPath), 90);
}
/// <summary>
/// Generates a collage of posters and landscape pictures.
/// </summary>
/// <param name="posters">The poster paths.</param>
/// <param name="backdrops">The landscape paths.</param>
/// <returns>The created collage as a bitmap.</returns>
private SKBitmap GenerateCollage(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
{
var posterIndex = 0;
var backdropIndex = 0;
var bitmap = new SKBitmap(WallWidth, WallHeight);
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); SKBitmap? currentImage;
int currentWidthPos = i * 75;
int currentHeight = i * (posterHeight + Spacing);
while (currentWidthPos < WallWidth) switch (imageCounter)
{ {
SKBitmap? currentImage; case 0:
case 2:
case 3:
currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, posters, posterIndex, out int newPosterIndex);
posterIndex = newPosterIndex;
break;
default:
currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, backdrops, backdropIndex, out int newBackdropIndex);
backdropIndex = newBackdropIndex;
break;
}
switch (imageCounter) if (currentImage is null)
{ {
case 0: throw new ArgumentException("Not enough valid pictures provided to create a splashscreen!");
case 2: }
case 3:
currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, posters, posterIndex, out int newPosterIndex);
posterIndex = newPosterIndex;
break;
default:
currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, backdrops, backdropIndex, out int newBackdropIndex);
backdropIndex = newBackdropIndex;
break;
}
if (currentImage is null) // resize to the same aspect as the original
{ var imageWidth = Math.Abs(posterHeight * currentImage.Width / currentImage.Height);
throw new ArgumentException("Not enough valid pictures provided to create a splashscreen!"); using var resizedBitmap = new SKBitmap(imageWidth, posterHeight);
} currentImage.ScalePixels(resizedBitmap, SKFilterQuality.High);
// resize to the same aspect as the original // draw on canvas
var imageWidth = Math.Abs(posterHeight * currentImage.Width / currentImage.Height); canvas.DrawBitmap(resizedBitmap, currentWidthPos, currentHeight);
using var resizedBitmap = new SKBitmap(imageWidth, posterHeight);
currentImage.ScalePixels(resizedBitmap, SKFilterQuality.High);
// draw on canvas currentWidthPos += imageWidth + Spacing;
canvas.DrawBitmap(resizedBitmap, currentWidthPos, currentHeight);
currentWidthPos += imageWidth + Spacing; currentImage.Dispose();
currentImage.Dispose(); if (imageCounter >= 4)
{
if (imageCounter >= 4) imageCounter = 0;
{ }
imageCounter = 0; else
} {
else imageCounter++;
{
imageCounter++;
}
} }
} }
return bitmap;
} }
/// <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,
Persp1 = 6.85242048E-05f,
Persp2 = 0.988209724f
};
canvas.SetMatrix(matrix); canvas.SetMatrix(matrix);
canvas.DrawBitmap(input, 0, 0); canvas.DrawBitmap(input, 0, 0);
return bitmap; return bitmap;
}
} }
} }

View File

@ -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> /// <summary>
/// Used to build collages of multiple images arranged in vertical strips. /// Initializes a new instance of the <see cref="StripCollageBuilder"/> class.
/// </summary> /// </summary>
public class StripCollageBuilder /// <param name="skiaEncoder">The encoder to use for building collages.</param>
public StripCollageBuilder(SkiaEncoder skiaEncoder)
{ {
private readonly SkiaEncoder _skiaEncoder; _skiaEncoder = skiaEncoder;
}
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="StripCollageBuilder"/> class. /// Check which format an image has been encoded with using its filename extension.
/// </summary> /// </summary>
/// <param name="skiaEncoder">The encoder to use for building collages.</param> /// <param name="outputPath">The path to the image to get the format for.</param>
public StripCollageBuilder(SkiaEncoder skiaEncoder) /// <returns>The image format.</returns>
public static SKEncodedImageFormat GetEncodedFormat(string outputPath)
{
ArgumentNullException.ThrowIfNull(outputPath);
var ext = Path.GetExtension(outputPath);
if (string.Equals(ext, ".jpg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(ext, ".jpeg", StringComparison.OrdinalIgnoreCase))
{ {
_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;
}
/// <summary>
/// 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);
}
/// <summary>
/// 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);
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)
{
var bitmap = new SKBitmap(width, height);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.Black);
using var backdrop = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, 0, out _);
if (backdrop is null)
{ {
var bitmap = new SKBitmap(width, height);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.Black);
using var backdrop = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, 0, out _);
if (backdrop is null)
{
return bitmap;
}
// resize to the same aspect as the original
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));
// draw the backdrop
canvas.DrawImage(residedBackdrop, 0, 0);
// draw shadow rectangle
using var paintColor = new SKPaint
{
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);
// 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]);
}
// 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)
{
textPaint.TextSize = 0.9f * width * textPaint.TextSize / textWidth;
}
canvas.DrawText(libraryName, width / 2f, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), textPaint);
return bitmap; return bitmap;
} }
private SKBitmap BuildSquareCollageBitmap(IReadOnlyList<string> paths, int width, int height) // resize to the same aspect as the original
{ var backdropHeight = Math.Abs(width * backdrop.Height / backdrop.Width);
var bitmap = new SKBitmap(width, height); using var residedBackdrop = SkiaEncoder.ResizeImage(backdrop, new SKImageInfo(width, backdropHeight, backdrop.ColorType, backdrop.AlphaType, backdrop.ColorSpace));
var imageIndex = 0; // draw the backdrop
var cellWidth = width / 2; canvas.DrawImage(residedBackdrop, 0, 0);
var cellHeight = height / 2;
using var canvas = new SKCanvas(bitmap); // draw shadow rectangle
for (var x = 0; x < 2; x++) using var paintColor = new SKPaint
{
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);
// 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]);
}
// 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)
{
textPaint.TextSize = 0.9f * width * textPaint.TextSize / textWidth;
}
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);
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;
} }
} }

View File

@ -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> /// </summary>
public static class UnplayedCountIndicator private const int OffsetFromTopRightCorner = 38;
/// <summary>
/// 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)
{ {
/// <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 canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
{
Color = SKColor.Parse("#CC00A4DC"),
Style = SKPaintStyle.Fill
};
canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint); paint.Color = new SKColor(255, 255, 255, 255);
paint.TextSize = 24;
paint.IsAntialias = true;
paint.Color = new SKColor(255, 255, 255, 255); var y = OffsetFromTopRightCorner + 9;
paint.TextSize = 24;
paint.IsAntialias = true;
var y = OffsetFromTopRightCorner + 9; if (text.Length == 1)
{
if (text.Length == 1) x -= 7;
{
x -= 7;
}
if (text.Length == 2)
{
x -= 13;
}
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

View File

@ -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
{
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedInputFormats
=> new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "png", "jpeg", "jpg" };
/// <inheritdoc /> /// <summary>
public IReadOnlyCollection<ImageFormat> SupportedOutputFormats /// A fallback implementation of <see cref="IImageEncoder" />.
/// </summary>
public class NullImageEncoder : IImageEncoder
{
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedInputFormats
=> new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "png", "jpeg", "jpg" };
/// <inheritdoc />
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();
}
} }
} }