2
0
Эх сурвалжийг харах

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

Cody Robibero 2 жил өмнө
parent
commit
515e69dcf7

+ 22 - 23
src/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs

@@ -2,35 +2,34 @@ using System;
 using MediaBrowser.Model.Drawing;
 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>
-    /// Static helper class used to draw percentage-played indicators on images.
+    /// Draw a percentage played indicator on a canvas.
     /// </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;
-
-        /// <summary>
-        /// 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;
+        using var paint = new SKPaint();
+        var endX = imageSize.Width - 1;
+        var endY = imageSize.Height - 1;
 
-            paint.Color = SKColor.Parse("#99000000");
-            paint.Style = SKPaintStyle.Fill;
-            canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, endX, endY), paint);
+        paint.Color = SKColor.Parse("#99000000");
+        paint.Style = SKPaintStyle.Fill;
+        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");
-            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);
     }
 }

+ 32 - 33
src/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs

@@ -1,48 +1,47 @@
 using MediaBrowser.Model.Drawing;
 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>
-    /// Static helper class for drawing 'played' indicators.
+    /// Draw a 'played' indicator in the top right corner of a canvas.
     /// </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;
-
-        /// <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;
+        var x = imageSize.Width - OffsetFromTopRightCorner;
 
-            using var paint = new SKPaint
-            {
-                Color = SKColor.Parse("#CC00A4DC"),
-                Style = SKPaintStyle.Fill
-            };
+        using var paint = new SKPaint
+        {
+            Color = SKColor.Parse("#CC00A4DC"),
+            Style = SKPaintStyle.Fill
+        };
 
-            canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
+        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);
+        paint.TextSize = 30;
+        paint.IsAntialias = true;
 
-            // or:
-            // var emojiChar = 0x1F680;
-            const string Text = "✔️";
-            var emojiChar = StringUtilities.GetUnicodeCharacterCode(Text, SKTextEncoding.Utf32);
+        // or:
+        // var emojiChar = 0x1F680;
+        const string Text = "✔️";
+        var emojiChar = StringUtilities.GetUnicodeCharacterCode(Text, SKTextEncoding.Utf32);
 
-            // ask the font manager for a font with that character
-            paint.Typeface = SKFontManager.Default.MatchCharacter(emojiChar);
+        // ask the font manager for a font with that character
+        paint.Typeface = SKFontManager.Default.MatchCharacter(emojiChar);
 
-            canvas.DrawText(Text, (float)x - 12, OffsetFromTopRightCorner + 12, paint);
-        }
+        canvas.DrawText(Text, (float)x - 12, OffsetFromTopRightCorner + 12, paint);
     }
 }

+ 33 - 34
src/Jellyfin.Drawing.Skia/SkiaCodecException.cs

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

+ 427 - 428
src/Jellyfin.Drawing.Skia/SkiaEncoder.cs

@@ -12,534 +12,533 @@ using Microsoft.Extensions.Logging;
 using SkiaSharp;
 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>
-    /// Image encoder that uses <see cref="SkiaSharp"/> to manipulate images.
+    /// Initializes a new instance of the <see cref="SkiaEncoder"/> class.
     /// </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;
-        private readonly IApplicationPaths _appPaths;
+    /// <inheritdoc/>
+    public string Name => "Skia";
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SkiaEncoder"/> class.
-        /// </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/>
+    public bool SupportsImageCollageCreation => true;
 
-        /// <inheritdoc/>
-        public string Name => "Skia";
+    /// <inheritdoc/>
+    public bool SupportsImageEncoding => true;
 
-        /// <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 };
 
-        /// <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
-            {
-                // test an operation that requires the native library
-                SKPMColor.PreMultiply(SKColors.Black);
-                return true;
-            }
-            catch (Exception)
-            {
-                return false;
-            }
+    /// <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;
         }
-
-        /// <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)
+        catch (Exception)
         {
-            return selectedFormat switch
-            {
-                ImageFormat.Bmp => SKEncodedImageFormat.Bmp,
-                ImageFormat.Jpg => SKEncodedImageFormat.Jpeg,
-                ImageFormat.Gif => SKEncodedImageFormat.Gif,
-                ImageFormat.Webp => SKEncodedImageFormat.Webp,
-                _ => SKEncodedImageFormat.Png
-            };
+            return false;
         }
+    }
 
-        /// <inheritdoc />
-        /// <exception cref="FileNotFoundException">The path is not valid.</exception>
-        public ImageDimensions GetImageSize(string path)
+    /// <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
         {
-            if (!File.Exists(path))
-            {
-                throw new FileNotFoundException("File not found", path);
-            }
+            ImageFormat.Bmp => SKEncodedImageFormat.Bmp,
+            ImageFormat.Jpg => SKEncodedImageFormat.Jpeg,
+            ImageFormat.Gif => SKEncodedImageFormat.Gif,
+            ImageFormat.Webp => SKEncodedImageFormat.Webp,
+            _ => SKEncodedImageFormat.Png
+        };
+    }
 
-            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));
-            }
+    /// <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);
+        }
 
-            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);
-            }
+        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));
         }
 
-        /// <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)
+        using var codec = SKCodec.Create(path, out SKCodecResult result);
+        switch (result)
         {
-            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('.');
-            if (!SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase))
-            {
-                _logger.LogDebug("Unable to compute blur hash due to unsupported format: {ImagePath}", path);
-                return string.Empty;
-            }
+    /// <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);
 
-            // Any larger than 128x128 is too slow and there's no visually discernible difference
-            return BlurHashEncoder.Encode(xComp, yComp, path, 128, 128);
+        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;
         }
 
-        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));
-            var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPath}) is not valid.");
-            Directory.CreateDirectory(directory);
-            File.Copy(path, tempPath, true);
+        return tempPath;
+    }
 
-            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)
-            {
-                return SKEncodedOrigin.TopLeft;
-            }
+            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
+        };
+    }
 
-            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);
         }
 
-        /// <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))
+        var requiresTransparencyHack = _transparentImageTypes.Contains(Path.GetExtension(path));
+
+        if (requiresTransparencyHack || forceCleanBitmap)
+        {
+            using SKCodec codec = SKCodec.Create(NormalizePath(path), out SKCodecResult res);
+            if (res != SKCodecResult.Success)
             {
-                throw new FileNotFoundException("File not found", path);
+                origin = GetSKEncodedOrigin(orientation);
+                return null;
             }
 
-            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);
 
-                // create the bitmap
-                var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack);
+            // decode
+            _ = codec.GetPixels(bitmap.Info, bitmap.GetPixels());
 
-                // decode
-                _ = codec.GetPixels(bitmap.Info, bitmap.GetPixels());
+            origin = codec.EncodedOrigin;
 
-                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);
             }
+        }
+
+        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 (resultBitmap.ColorType == SKColorType.Gray8)
+            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 resultBitmap;
+            return bitmap;
         }
 
-        private SKBitmap? GetBitmap(string path, bool autoOrient, ImageOrientation? orientation)
-        {
-            if (autoOrient)
-            {
-                var bitmap = Decode(path, true, orientation, out var origin);
+        return Decode(path, false, orientation, out _);
+    }
 
-                if (bitmap is not null && origin != SKEncodedOrigin.TopLeft)
-                {
-                    using (bitmap)
-                    {
-                        return OrientImage(bitmap, origin);
-                    }
-                }
+    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)
+        {
+            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
-                            || 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;
+            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();
+    }
 
-            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;
-            }
+    /// <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);
 
-            surface.DrawBitmap(bitmap, 0, 0);
-            return rotated;
+        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>
-        /// 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 skiaOutputFormat = GetImageFormat(outputFormat);
 
-            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();
-        }
+        var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor);
+        var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer);
+        var blur = options.Blur ?? 0;
+        var hasIndicator = options.AddPlayedIndicator || options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0);
 
-        /// <inheritdoc/>
-        public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat)
+        using var bitmap = GetBitmap(inputPath, autoOrient, orientation);
+        if (bitmap is null)
         {
-            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;
-            }
+            throw new InvalidDataException($"Skia unable to read image {inputPath}");
+        }
 
-            var skiaOutputFormat = GetImageFormat(outputFormat);
+        var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height);
 
-            var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor);
-            var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer);
-            var blur = options.Blur ?? 0;
-            var hasIndicator = options.AddPlayedIndicator || options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0);
+        if (options.HasDefaultOptions(inputPath, originalImageSize) && !autoOrient)
+        {
+            // Just spit out the original file if all the options are default
+            return inputPath;
+        }
 
-            using var bitmap = GetBitmap(inputPath, autoOrient, orientation);
-            if (bitmap is null)
-            {
-                throw new InvalidDataException($"Skia unable to read image {inputPath}");
-            }
+        var newImageSize = ImageHelper.GetNewImageSize(options, originalImageSize);
 
-            var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height);
+        var width = newImageSize.Width;
+        var height = newImageSize.Height;
 
-            if (options.HasDefaultOptions(inputPath, originalImageSize) && !autoOrient)
-            {
-                // Just spit out the original file if all the options are default
-                return inputPath;
-            }
+        // 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));
 
-            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;
-            var height = newImageSize.Height;
+        // 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));
+        }
 
-            // 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));
+        // 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 all we're doing is resizing then we can stop now
-            if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator)
+        // If foreground layer present then draw
+        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));
-                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;
+                opacity = .4;
             }
 
-            // 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));
-            }
+            canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver);
+        }
 
-            // 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 (hasIndicator)
+        {
+            DrawIndicator(canvas, width, height, options);
+        }
 
-            // If foreground layer present then draw
-            if (hasForegroundColor)
+        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()))
             {
-                if (!double.TryParse(options.ForegroundLayer, out double opacity))
-                {
-                    opacity = .4;
-                }
-
-                canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver);
+                pixmap.Encode(outputStream, skiaOutputFormat, quality);
             }
+        }
 
-            if (hasIndicator)
-            {
-                DrawIndicator(canvas, width, height, options);
-            }
+        return outputPath;
+    }
 
-            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);
-                }
-            }
+    /// <inheritdoc/>
+    public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
+    {
+        double ratio = (double)options.Width / options.Height;
 
-            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/>
-        public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
+    private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options)
+    {
+        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
-                new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height);
+                PercentPlayedDrawer.Process(canvas, currentImageSize, options.PercentPlayed);
             }
         }
-
-        /// <inheritdoc />
-        public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
+        catch (Exception ex)
         {
-            var splashBuilder = new SplashscreenBuilder(this);
-            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");
-            }
+            _logger.LogError(ex, "Error drawing indicator overlay");
         }
     }
 }

+ 28 - 29
src/Jellyfin.Drawing.Skia/SkiaException.cs

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

+ 29 - 30
src/Jellyfin.Drawing.Skia/SkiaHelper.cs

@@ -1,47 +1,46 @@
 using System.Collections.Generic;
 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>
-    /// Class containing helper methods for working with SkiaSharp.
+    /// Gets the next valid image as a bitmap.
     /// </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>
-        /// Gets the next valid image as a bitmap.
-        /// </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;
+        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)
-                {
-                    break;
-                }
+            if (bitmap is not null)
+            {
+                break;
             }
-
-            newIndex = currentIndex;
-            return bitmap;
         }
+
+        newIndex = currentIndex;
+        return bitmap;
     }
 }

+ 123 - 124
src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs

@@ -2,147 +2,146 @@ using System;
 using System.Collections.Generic;
 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>
-    /// Used to build the splashscreen.
+    /// Initializes a new instance of the <see cref="SplashscreenBuilder"/> class.
     /// </summary>
-    public class SplashscreenBuilder
+    /// <param name="skiaEncoder">The SkiaEncoder.</param>
+    public SplashscreenBuilder(SkiaEncoder skiaEncoder)
     {
-        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>
-        /// Initializes a new instance of the <see cref="SplashscreenBuilder"/> class.
-        /// </summary>
-        /// <param name="skiaEncoder">The SkiaEncoder.</param>
-        public SplashscreenBuilder(SkiaEncoder skiaEncoder)
-        {
-            _skiaEncoder = skiaEncoder;
-        }
+        _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>
+    /// 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);
-        }
+        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;
+    /// <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);
 
-            var bitmap = new SKBitmap(WallWidth, WallHeight);
-            using var canvas = new SKCanvas(bitmap);
-            canvas.Clear(SKColors.Black);
+        int posterHeight = WallHeight / 6;
 
-            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);
-                int currentWidthPos = i * 75;
-                int currentHeight = i * (posterHeight + Spacing);
+                SKBitmap? currentImage;
 
-                while (currentWidthPos < WallWidth)
+                switch (imageCounter)
                 {
-                    SKBitmap? currentImage;
-
-                    switch (imageCounter)
-                    {
-                        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;
-                    }
-
-                    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++;
-                    }
+                    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;
                 }
-            }
 
-            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>
-        /// Transform the collage in 3D space.
-        /// </summary>
-        /// <param name="input">The bitmap to transform.</param>
-        /// <returns>The transformed image.</returns>
-        private SKBitmap Transform3D(SKBitmap input)
+        return bitmap;
+    }
+
+    /// <summary>
+    /// Transform the collage in 3D space.
+    /// </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);
-            using var canvas = new SKCanvas(bitmap);
-            canvas.Clear(SKColors.Black);
-            var matrix = new SKMatrix
-            {
-                ScaleX = 0.324108899f,
-                ScaleY = 0.563934922f,
-                SkewX = -0.244337708f,
-                SkewY = 0.0377609022f,
-                TransX = 42.0407715f,
-                TransY = -198.104706f,
-                Persp0 = -9.08959337E-05f,
-                Persp1 = 6.85242048E-05f,
-                Persp2 = 0.988209724f
-            };
-
-            canvas.SetMatrix(matrix);
-            canvas.DrawBitmap(input, 0, 0);
-
-            return bitmap;
-        }
+            ScaleX = 0.324108899f,
+            ScaleY = 0.563934922f,
+            SkewX = -0.244337708f,
+            SkewY = 0.0377609022f,
+            TransX = 42.0407715f,
+            TransY = -198.104706f,
+            Persp0 = -9.08959337E-05f,
+            Persp1 = 6.85242048E-05f,
+            Persp2 = 0.988209724f
+        };
+
+        canvas.SetMatrix(matrix);
+        canvas.DrawBitmap(input, 0, 0);
+
+        return bitmap;
     }
 }

+ 142 - 143
src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs

@@ -4,183 +4,182 @@ using System.IO;
 using System.Text.RegularExpressions;
 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>
-    /// 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>
-    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>
-        /// 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)
+        if (string.Equals(ext, ".jpg", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(ext, ".jpeg", StringComparison.OrdinalIgnoreCase))
         {
-            _skiaEncoder = skiaEncoder;
+            return SKEncodedImageFormat.Jpeg;
         }
 
-        /// <summary>
-        /// 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)
+        if (string.Equals(ext, ".webp", StringComparison.OrdinalIgnoreCase))
         {
-            ArgumentNullException.ThrowIfNull(outputPath);
-
-            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;
+            return SKEncodedImageFormat.Webp;
         }
 
-        /// <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)
+        if (string.Equals(ext, ".gif", StringComparison.OrdinalIgnoreCase))
         {
-            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);
+            return SKEncodedImageFormat.Gif;
         }
 
-        /// <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)
+        if (string.Equals(ext, ".bmp", StringComparison.OrdinalIgnoreCase))
         {
-            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);
+            return SKEncodedImageFormat.Bmp;
         }
 
-        private SKBitmap BuildThumbCollageBitmap(IReadOnlyList<string> paths, int width, int height, string? libraryName)
-        {
-            var bitmap = new SKBitmap(width, height);
+        // default to png
+        return SKEncodedImageFormat.Png;
+    }
 
-            using var canvas = new SKCanvas(bitmap);
-            canvas.Clear(SKColors.Black);
+    /// <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);
+    }
 
-            using var backdrop = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, 0, out _);
-            if (backdrop is null)
-            {
-                return bitmap;
-            }
+    /// <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);
+    }
 
-            // 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);
+    private SKBitmap BuildThumbCollageBitmap(IReadOnlyList<string> paths, int width, int height, string? libraryName)
+    {
+        var bitmap = new SKBitmap(width, height);
 
-            // draw shadow rectangle
-            using var paintColor = new SKPaint
-            {
-                Color = SKColors.Black.WithAlpha(0x78),
-                Style = SKPaintStyle.Fill
-            };
-            canvas.DrawRect(0, 0, width, height, paintColor);
+        using var canvas = new SKCanvas(bitmap);
+        canvas.Clear(SKColors.Black);
 
-            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
-            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]);
-            }
+        // 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 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;
-            }
+        // draw shadow rectangle
+        using var paintColor = new SKPaint
+        {
+            Color = SKColors.Black.WithAlpha(0x78),
+            Style = SKPaintStyle.Fill
+        };
+        canvas.DrawRect(0, 0, width, height, paintColor);
 
-            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);
-            var imageIndex = 0;
-            var cellWidth = width / 2;
-            var cellHeight = height / 2;
+            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++)
+        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);
-                    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);
+                    continue;
                 }
-            }
 
-            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;
     }
 }

+ 45 - 46
src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs

@@ -2,63 +2,62 @@ using System.Globalization;
 using MediaBrowser.Model.Drawing;
 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>
-    /// 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>
-    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>
-        /// The x-offset used when drawing an unplayed count indicator.
-        /// </summary>
-        private const int OffsetFromTopRightCorner = 38;
+        var x = imageSize.Width - OffsetFromTopRightCorner;
+        var text = count.ToString(CultureInfo.InvariantCulture);
 
-        /// <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)
+        using var paint = new SKPaint
         {
-            var x = imageSize.Width - OffsetFromTopRightCorner;
-            var text = count.ToString(CultureInfo.InvariantCulture);
-
-            using var paint = new SKPaint
-            {
-                Color = SKColor.Parse("#CC00A4DC"),
-                Style = SKPaintStyle.Fill
-            };
-
-            canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
+            Color = SKColor.Parse("#CC00A4DC"),
+            Style = SKPaintStyle.Fill
+        };
 
-            paint.Color = new SKColor(255, 255, 255, 255);
-            paint.TextSize = 24;
-            paint.IsAntialias = true;
+        canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
 
-            var y = OffsetFromTopRightCorner + 9;
+        paint.Color = new SKColor(255, 255, 255, 255);
+        paint.TextSize = 24;
+        paint.IsAntialias = true;
 
-            if (text.Length == 1)
-            {
-                x -= 7;
-            }
+        var y = OffsetFromTopRightCorner + 9;
 
-            if (text.Length == 2)
-            {
-                x -= 13;
-            }
-            else if (text.Length >= 3)
-            {
-                x -= 15;
-                y -= 2;
-                paint.TextSize = 18;
-            }
+        if (text.Length == 1)
+        {
+            x -= 7;
+        }
 
-            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);
     }
 }

+ 449 - 450
src/Jellyfin.Drawing/ImageProcessor.cs

@@ -19,551 +19,550 @@ using MediaBrowser.Model.Net;
 using Microsoft.Extensions.Logging;
 using Photo = MediaBrowser.Controller.Entities.Photo;
 
-namespace Jellyfin.Drawing
+namespace Jellyfin.Drawing;
+
+/// <summary>
+/// Class ImageProcessor.
+/// </summary>
+public sealed class ImageProcessor : IImageProcessor, IDisposable
 {
+    // Increment this when there's a change requiring caches to be invalidated
+    private const char Version = '3';
+
+    private static readonly HashSet<string> _transparentImageTypes
+        = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" };
+
+    private readonly ILogger<ImageProcessor> _logger;
+    private readonly IFileSystem _fileSystem;
+    private readonly IServerApplicationPaths _appPaths;
+    private readonly IImageEncoder _imageEncoder;
+    private readonly IMediaEncoder _mediaEncoder;
+
+    private bool _disposed;
+
     /// <summary>
-    /// Class ImageProcessor.
+    /// Initializes a new instance of the <see cref="ImageProcessor"/> class.
     /// </summary>
-    public sealed class ImageProcessor : IImageProcessor, IDisposable
+    /// <param name="logger">The logger.</param>
+    /// <param name="appPaths">The server application paths.</param>
+    /// <param name="fileSystem">The filesystem.</param>
+    /// <param name="imageEncoder">The image encoder.</param>
+    /// <param name="mediaEncoder">The media encoder.</param>
+    public ImageProcessor(
+        ILogger<ImageProcessor> logger,
+        IServerApplicationPaths appPaths,
+        IFileSystem fileSystem,
+        IImageEncoder imageEncoder,
+        IMediaEncoder mediaEncoder)
     {
-        // Increment this when there's a change requiring caches to be invalidated
-        private const char Version = '3';
-
-        private static readonly HashSet<string> _transparentImageTypes
-            = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" };
-
-        private readonly ILogger<ImageProcessor> _logger;
-        private readonly IFileSystem _fileSystem;
-        private readonly IServerApplicationPaths _appPaths;
-        private readonly IImageEncoder _imageEncoder;
-        private readonly IMediaEncoder _mediaEncoder;
-
-        private bool _disposed;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="ImageProcessor"/> class.
-        /// </summary>
-        /// <param name="logger">The logger.</param>
-        /// <param name="appPaths">The server application paths.</param>
-        /// <param name="fileSystem">The filesystem.</param>
-        /// <param name="imageEncoder">The image encoder.</param>
-        /// <param name="mediaEncoder">The media encoder.</param>
-        public ImageProcessor(
-            ILogger<ImageProcessor> logger,
-            IServerApplicationPaths appPaths,
-            IFileSystem fileSystem,
-            IImageEncoder imageEncoder,
-            IMediaEncoder mediaEncoder)
-        {
-            _logger = logger;
-            _fileSystem = fileSystem;
-            _imageEncoder = imageEncoder;
-            _mediaEncoder = mediaEncoder;
-            _appPaths = appPaths;
-        }
+        _logger = logger;
+        _fileSystem = fileSystem;
+        _imageEncoder = imageEncoder;
+        _mediaEncoder = mediaEncoder;
+        _appPaths = appPaths;
+    }
 
-        private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images");
+    private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images");
 
-        /// <inheritdoc />
-        public IReadOnlyCollection<string> SupportedInputFormats =>
-            new HashSet<string>(StringComparer.OrdinalIgnoreCase)
-            {
-                "tiff",
-                "tif",
-                "jpeg",
-                "jpg",
-                "png",
-                "aiff",
-                "cr2",
-                "crw",
-                "nef",
-                "orf",
-                "pef",
-                "arw",
-                "webp",
-                "gif",
-                "bmp",
-                "erf",
-                "raf",
-                "rw2",
-                "nrw",
-                "dng",
-                "ico",
-                "astc",
-                "ktx",
-                "pkm",
-                "wbmp"
-            };
-
-        /// <inheritdoc />
-        public bool SupportsImageCollageCreation => _imageEncoder.SupportsImageCollageCreation;
-
-        /// <inheritdoc />
-        public async Task ProcessImage(ImageProcessingOptions options, Stream toStream)
-        {
-            var file = await ProcessImage(options).ConfigureAwait(false);
-            using (var fileStream = AsyncFile.OpenRead(file.Path))
-            {
-                await fileStream.CopyToAsync(toStream).ConfigureAwait(false);
-            }
+    /// <inheritdoc />
+    public IReadOnlyCollection<string> SupportedInputFormats =>
+        new HashSet<string>(StringComparer.OrdinalIgnoreCase)
+        {
+            "tiff",
+            "tif",
+            "jpeg",
+            "jpg",
+            "png",
+            "aiff",
+            "cr2",
+            "crw",
+            "nef",
+            "orf",
+            "pef",
+            "arw",
+            "webp",
+            "gif",
+            "bmp",
+            "erf",
+            "raf",
+            "rw2",
+            "nrw",
+            "dng",
+            "ico",
+            "astc",
+            "ktx",
+            "pkm",
+            "wbmp"
+        };
+
+    /// <inheritdoc />
+    public bool SupportsImageCollageCreation => _imageEncoder.SupportsImageCollageCreation;
+
+    /// <inheritdoc />
+    public async Task ProcessImage(ImageProcessingOptions options, Stream toStream)
+    {
+        var file = await ProcessImage(options).ConfigureAwait(false);
+        using (var fileStream = AsyncFile.OpenRead(file.Path))
+        {
+            await fileStream.CopyToAsync(toStream).ConfigureAwait(false);
         }
+    }
 
-        /// <inheritdoc />
-        public IReadOnlyCollection<ImageFormat> GetSupportedImageOutputFormats()
-            => _imageEncoder.SupportedOutputFormats;
+    /// <inheritdoc />
+    public IReadOnlyCollection<ImageFormat> GetSupportedImageOutputFormats()
+        => _imageEncoder.SupportedOutputFormats;
 
-        /// <inheritdoc />
-        public bool SupportsTransparency(string path)
-            => _transparentImageTypes.Contains(Path.GetExtension(path));
+    /// <inheritdoc />
+    public bool SupportsTransparency(string path)
+        => _transparentImageTypes.Contains(Path.GetExtension(path));
 
-        /// <inheritdoc />
-        public async Task<(string Path, string? MimeType, DateTime DateModified)> ProcessImage(ImageProcessingOptions options)
-        {
-            ItemImageInfo originalImage = options.Image;
-            BaseItem item = options.Item;
+    /// <inheritdoc />
+    public async Task<(string Path, string? MimeType, DateTime DateModified)> ProcessImage(ImageProcessingOptions options)
+    {
+        ItemImageInfo originalImage = options.Image;
+        BaseItem item = options.Item;
 
-            string originalImagePath = originalImage.Path;
-            DateTime dateModified = originalImage.DateModified;
-            ImageDimensions? originalImageSize = null;
-            if (originalImage.Width > 0 && originalImage.Height > 0)
-            {
-                originalImageSize = new ImageDimensions(originalImage.Width, originalImage.Height);
-            }
+        string originalImagePath = originalImage.Path;
+        DateTime dateModified = originalImage.DateModified;
+        ImageDimensions? originalImageSize = null;
+        if (originalImage.Width > 0 && originalImage.Height > 0)
+        {
+            originalImageSize = new ImageDimensions(originalImage.Width, originalImage.Height);
+        }
 
-            var mimeType = MimeTypes.GetMimeType(originalImagePath);
-            if (!_imageEncoder.SupportsImageEncoding)
-            {
-                return (originalImagePath, mimeType, dateModified);
-            }
+        var mimeType = MimeTypes.GetMimeType(originalImagePath);
+        if (!_imageEncoder.SupportsImageEncoding)
+        {
+            return (originalImagePath, mimeType, dateModified);
+        }
 
-            var supportedImageInfo = await GetSupportedImage(originalImagePath, dateModified).ConfigureAwait(false);
-            originalImagePath = supportedImageInfo.Path;
+        var supportedImageInfo = await GetSupportedImage(originalImagePath, dateModified).ConfigureAwait(false);
+        originalImagePath = supportedImageInfo.Path;
 
-            // Original file doesn't exist, or original file is gif.
-            if (!File.Exists(originalImagePath) || string.Equals(mimeType, MediaTypeNames.Image.Gif, StringComparison.OrdinalIgnoreCase))
-            {
-                return (originalImagePath, mimeType, dateModified);
-            }
+        // Original file doesn't exist, or original file is gif.
+        if (!File.Exists(originalImagePath) || string.Equals(mimeType, MediaTypeNames.Image.Gif, StringComparison.OrdinalIgnoreCase))
+        {
+            return (originalImagePath, mimeType, dateModified);
+        }
 
-            dateModified = supportedImageInfo.DateModified;
-            bool requiresTransparency = _transparentImageTypes.Contains(Path.GetExtension(originalImagePath));
+        dateModified = supportedImageInfo.DateModified;
+        bool requiresTransparency = _transparentImageTypes.Contains(Path.GetExtension(originalImagePath));
 
-            bool autoOrient = false;
-            ImageOrientation? orientation = null;
-            if (item is Photo photo)
+        bool autoOrient = false;
+        ImageOrientation? orientation = null;
+        if (item is Photo photo)
+        {
+            if (photo.Orientation.HasValue)
             {
-                if (photo.Orientation.HasValue)
-                {
-                    if (photo.Orientation.Value != ImageOrientation.TopLeft)
-                    {
-                        autoOrient = true;
-                        orientation = photo.Orientation;
-                    }
-                }
-                else
+                if (photo.Orientation.Value != ImageOrientation.TopLeft)
                 {
-                    // Orientation unknown, so do it
                     autoOrient = true;
                     orientation = photo.Orientation;
                 }
             }
-
-            if (options.HasDefaultOptions(originalImagePath, originalImageSize) && (!autoOrient || !options.RequiresAutoOrientation))
+            else
             {
-                // Just spit out the original file if all the options are default
-                return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
-            }
-
-            int quality = options.Quality;
-
-            ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency);
-            string cacheFilePath = GetCacheFilePath(
-                originalImagePath,
-                options.Width,
-                options.Height,
-                options.MaxWidth,
-                options.MaxHeight,
-                options.FillWidth,
-                options.FillHeight,
-                quality,
-                dateModified,
-                outputFormat,
-                options.AddPlayedIndicator,
-                options.PercentPlayed,
-                options.UnplayedCount,
-                options.Blur,
-                options.BackgroundColor,
-                options.ForegroundLayer);
-
-            try
-            {
-                if (!File.Exists(cacheFilePath))
-                {
-                    string resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat);
-
-                    if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase))
-                    {
-                        return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
-                    }
-                }
-
-                return (cacheFilePath, GetMimeType(outputFormat, cacheFilePath), _fileSystem.GetLastWriteTimeUtc(cacheFilePath));
-            }
-            catch (Exception ex)
-            {
-                // If it fails for whatever reason, return the original image
-                _logger.LogError(ex, "Error encoding image");
-                return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
+                // Orientation unknown, so do it
+                autoOrient = true;
+                orientation = photo.Orientation;
             }
         }
 
-        private ImageFormat GetOutputFormat(IReadOnlyCollection<ImageFormat> clientSupportedFormats, bool requiresTransparency)
+        if (options.HasDefaultOptions(originalImagePath, originalImageSize) && (!autoOrient || !options.RequiresAutoOrientation))
         {
-            var serverFormats = GetSupportedImageOutputFormats();
-
-            // Client doesn't care about format, so start with webp if supported
-            if (serverFormats.Contains(ImageFormat.Webp) && clientSupportedFormats.Contains(ImageFormat.Webp))
-            {
-                return ImageFormat.Webp;
-            }
+            // Just spit out the original file if all the options are default
+            return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
+        }
 
-            // If transparency is needed and webp isn't supported, than png is the only option
-            if (requiresTransparency && clientSupportedFormats.Contains(ImageFormat.Png))
+        int quality = options.Quality;
+
+        ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency);
+        string cacheFilePath = GetCacheFilePath(
+            originalImagePath,
+            options.Width,
+            options.Height,
+            options.MaxWidth,
+            options.MaxHeight,
+            options.FillWidth,
+            options.FillHeight,
+            quality,
+            dateModified,
+            outputFormat,
+            options.AddPlayedIndicator,
+            options.PercentPlayed,
+            options.UnplayedCount,
+            options.Blur,
+            options.BackgroundColor,
+            options.ForegroundLayer);
+
+        try
+        {
+            if (!File.Exists(cacheFilePath))
             {
-                return ImageFormat.Png;
-            }
+                string resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat);
 
-            foreach (var format in clientSupportedFormats)
-            {
-                if (serverFormats.Contains(format))
+                if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase))
                 {
-                    return format;
+                    return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
                 }
             }
 
-            // We should never actually get here
-            return ImageFormat.Jpg;
+            return (cacheFilePath, GetMimeType(outputFormat, cacheFilePath), _fileSystem.GetLastWriteTimeUtc(cacheFilePath));
         }
+        catch (Exception ex)
+        {
+            // If it fails for whatever reason, return the original image
+            _logger.LogError(ex, "Error encoding image");
+            return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
+        }
+    }
 
-        private string GetMimeType(ImageFormat format, string path)
-            => format switch
-            {
-                ImageFormat.Bmp => MimeTypes.GetMimeType("i.bmp"),
-                ImageFormat.Gif => MimeTypes.GetMimeType("i.gif"),
-                ImageFormat.Jpg => MimeTypes.GetMimeType("i.jpg"),
-                ImageFormat.Png => MimeTypes.GetMimeType("i.png"),
-                ImageFormat.Webp => MimeTypes.GetMimeType("i.webp"),
-                _ => MimeTypes.GetMimeType(path)
-            };
-
-        /// <summary>
-        /// Gets the cache file path based on a set of parameters.
-        /// </summary>
-        private string GetCacheFilePath(
-            string originalPath,
-            int? width,
-            int? height,
-            int? maxWidth,
-            int? maxHeight,
-            int? fillWidth,
-            int? fillHeight,
-            int quality,
-            DateTime dateModified,
-            ImageFormat format,
-            bool addPlayedIndicator,
-            double percentPlayed,
-            int? unwatchedCount,
-            int? blur,
-            string backgroundColor,
-            string foregroundLayer)
-        {
-            var filename = new StringBuilder(256);
-            filename.Append(originalPath);
-
-            filename.Append(",quality=");
-            filename.Append(quality);
-
-            filename.Append(",datemodified=");
-            filename.Append(dateModified.Ticks);
-
-            filename.Append(",f=");
-            filename.Append(format);
-
-            if (width.HasValue)
-            {
-                filename.Append(",width=");
-                filename.Append(width.Value);
-            }
+    private ImageFormat GetOutputFormat(IReadOnlyCollection<ImageFormat> clientSupportedFormats, bool requiresTransparency)
+    {
+        var serverFormats = GetSupportedImageOutputFormats();
 
-            if (height.HasValue)
-            {
-                filename.Append(",height=");
-                filename.Append(height.Value);
-            }
+        // Client doesn't care about format, so start with webp if supported
+        if (serverFormats.Contains(ImageFormat.Webp) && clientSupportedFormats.Contains(ImageFormat.Webp))
+        {
+            return ImageFormat.Webp;
+        }
 
-            if (maxWidth.HasValue)
-            {
-                filename.Append(",maxwidth=");
-                filename.Append(maxWidth.Value);
-            }
+        // If transparency is needed and webp isn't supported, than png is the only option
+        if (requiresTransparency && clientSupportedFormats.Contains(ImageFormat.Png))
+        {
+            return ImageFormat.Png;
+        }
 
-            if (maxHeight.HasValue)
+        foreach (var format in clientSupportedFormats)
+        {
+            if (serverFormats.Contains(format))
             {
-                filename.Append(",maxheight=");
-                filename.Append(maxHeight.Value);
+                return format;
             }
+        }
 
-            if (fillWidth.HasValue)
-            {
-                filename.Append(",fillwidth=");
-                filename.Append(fillWidth.Value);
-            }
+        // We should never actually get here
+        return ImageFormat.Jpg;
+    }
 
-            if (fillHeight.HasValue)
-            {
-                filename.Append(",fillheight=");
-                filename.Append(fillHeight.Value);
-            }
+    private string GetMimeType(ImageFormat format, string path)
+        => format switch
+        {
+            ImageFormat.Bmp => MimeTypes.GetMimeType("i.bmp"),
+            ImageFormat.Gif => MimeTypes.GetMimeType("i.gif"),
+            ImageFormat.Jpg => MimeTypes.GetMimeType("i.jpg"),
+            ImageFormat.Png => MimeTypes.GetMimeType("i.png"),
+            ImageFormat.Webp => MimeTypes.GetMimeType("i.webp"),
+            _ => MimeTypes.GetMimeType(path)
+        };
 
-            if (addPlayedIndicator)
-            {
-                filename.Append(",pl=true");
-            }
+    /// <summary>
+    /// Gets the cache file path based on a set of parameters.
+    /// </summary>
+    private string GetCacheFilePath(
+        string originalPath,
+        int? width,
+        int? height,
+        int? maxWidth,
+        int? maxHeight,
+        int? fillWidth,
+        int? fillHeight,
+        int quality,
+        DateTime dateModified,
+        ImageFormat format,
+        bool addPlayedIndicator,
+        double percentPlayed,
+        int? unwatchedCount,
+        int? blur,
+        string backgroundColor,
+        string foregroundLayer)
+    {
+        var filename = new StringBuilder(256);
+        filename.Append(originalPath);
 
-            if (percentPlayed > 0)
-            {
-                filename.Append(",p=");
-                filename.Append(percentPlayed);
-            }
+        filename.Append(",quality=");
+        filename.Append(quality);
 
-            if (unwatchedCount.HasValue)
-            {
-                filename.Append(",p=");
-                filename.Append(unwatchedCount.Value);
-            }
+        filename.Append(",datemodified=");
+        filename.Append(dateModified.Ticks);
 
-            if (blur.HasValue)
-            {
-                filename.Append(",blur=");
-                filename.Append(blur.Value);
-            }
+        filename.Append(",f=");
+        filename.Append(format);
 
-            if (!string.IsNullOrEmpty(backgroundColor))
-            {
-                filename.Append(",b=");
-                filename.Append(backgroundColor);
-            }
+        if (width.HasValue)
+        {
+            filename.Append(",width=");
+            filename.Append(width.Value);
+        }
 
-            if (!string.IsNullOrEmpty(foregroundLayer))
-            {
-                filename.Append(",fl=");
-                filename.Append(foregroundLayer);
-            }
+        if (height.HasValue)
+        {
+            filename.Append(",height=");
+            filename.Append(height.Value);
+        }
 
-            filename.Append(",v=");
-            filename.Append(Version);
+        if (maxWidth.HasValue)
+        {
+            filename.Append(",maxwidth=");
+            filename.Append(maxWidth.Value);
+        }
 
-            return GetCachePath(ResizedImageCachePath, filename.ToString(), "." + format.ToString().ToLowerInvariant());
+        if (maxHeight.HasValue)
+        {
+            filename.Append(",maxheight=");
+            filename.Append(maxHeight.Value);
         }
 
-        /// <inheritdoc />
-        public ImageDimensions GetImageDimensions(BaseItem item, ItemImageInfo info)
+        if (fillWidth.HasValue)
         {
-            int width = info.Width;
-            int height = info.Height;
+            filename.Append(",fillwidth=");
+            filename.Append(fillWidth.Value);
+        }
 
-            if (height > 0 && width > 0)
-            {
-                return new ImageDimensions(width, height);
-            }
+        if (fillHeight.HasValue)
+        {
+            filename.Append(",fillheight=");
+            filename.Append(fillHeight.Value);
+        }
 
-            string path = info.Path;
-            _logger.LogDebug("Getting image size for item {ItemType} {Path}", item.GetType().Name, path);
+        if (addPlayedIndicator)
+        {
+            filename.Append(",pl=true");
+        }
 
-            ImageDimensions size = GetImageDimensions(path);
-            info.Width = size.Width;
-            info.Height = size.Height;
+        if (percentPlayed > 0)
+        {
+            filename.Append(",p=");
+            filename.Append(percentPlayed);
+        }
 
-            return size;
+        if (unwatchedCount.HasValue)
+        {
+            filename.Append(",p=");
+            filename.Append(unwatchedCount.Value);
         }
 
-        /// <inheritdoc />
-        public ImageDimensions GetImageDimensions(string path)
-            => _imageEncoder.GetImageSize(path);
+        if (blur.HasValue)
+        {
+            filename.Append(",blur=");
+            filename.Append(blur.Value);
+        }
 
-        /// <inheritdoc />
-        public string GetImageBlurHash(string path)
+        if (!string.IsNullOrEmpty(backgroundColor))
         {
-            var size = GetImageDimensions(path);
-            return GetImageBlurHash(path, size);
+            filename.Append(",b=");
+            filename.Append(backgroundColor);
         }
 
-        /// <inheritdoc />
-        public string GetImageBlurHash(string path, ImageDimensions imageDimensions)
+        if (!string.IsNullOrEmpty(foregroundLayer))
         {
-            if (imageDimensions.Width <= 0 || imageDimensions.Height <= 0)
-            {
-                return string.Empty;
-            }
+            filename.Append(",fl=");
+            filename.Append(foregroundLayer);
+        }
+
+        filename.Append(",v=");
+        filename.Append(Version);
 
-            // We want tiles to be as close to square as possible, and to *mostly* keep under 16 tiles for performance.
-            // One tile is (width / xComp) x (height / yComp) pixels, which means that ideally yComp = xComp * height / width.
-            // See more at https://github.com/woltapp/blurhash/#how-do-i-pick-the-number-of-x-and-y-components
-            float xCompF = MathF.Sqrt(16.0f * imageDimensions.Width / imageDimensions.Height);
-            float yCompF = xCompF * imageDimensions.Height / imageDimensions.Width;
+        return GetCachePath(ResizedImageCachePath, filename.ToString(), "." + format.ToString().ToLowerInvariant());
+    }
 
-            int xComp = Math.Min((int)xCompF + 1, 9);
-            int yComp = Math.Min((int)yCompF + 1, 9);
+    /// <inheritdoc />
+    public ImageDimensions GetImageDimensions(BaseItem item, ItemImageInfo info)
+    {
+        int width = info.Width;
+        int height = info.Height;
 
-            return _imageEncoder.GetImageBlurHash(xComp, yComp, path);
+        if (height > 0 && width > 0)
+        {
+            return new ImageDimensions(width, height);
         }
 
-        /// <inheritdoc />
-        public string GetImageCacheTag(BaseItem item, ItemImageInfo image)
-            => (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture);
+        string path = info.Path;
+        _logger.LogDebug("Getting image size for item {ItemType} {Path}", item.GetType().Name, path);
+
+        ImageDimensions size = GetImageDimensions(path);
+        info.Width = size.Width;
+        info.Height = size.Height;
 
-        /// <inheritdoc />
-        public string GetImageCacheTag(BaseItem item, ChapterInfo chapter)
+        return size;
+    }
+
+    /// <inheritdoc />
+    public ImageDimensions GetImageDimensions(string path)
+        => _imageEncoder.GetImageSize(path);
+
+    /// <inheritdoc />
+    public string GetImageBlurHash(string path)
+    {
+        var size = GetImageDimensions(path);
+        return GetImageBlurHash(path, size);
+    }
+
+    /// <inheritdoc />
+    public string GetImageBlurHash(string path, ImageDimensions imageDimensions)
+    {
+        if (imageDimensions.Width <= 0 || imageDimensions.Height <= 0)
         {
-            return GetImageCacheTag(item, new ItemImageInfo
-            {
-                Path = chapter.ImagePath,
-                Type = ImageType.Chapter,
-                DateModified = chapter.ImageDateModified
-            });
+            return string.Empty;
         }
 
-        /// <inheritdoc />
-        public string? GetImageCacheTag(User user)
-        {
-            if (user.ProfileImage is null)
-            {
-                return null;
-            }
+        // We want tiles to be as close to square as possible, and to *mostly* keep under 16 tiles for performance.
+        // One tile is (width / xComp) x (height / yComp) pixels, which means that ideally yComp = xComp * height / width.
+        // See more at https://github.com/woltapp/blurhash/#how-do-i-pick-the-number-of-x-and-y-components
+        float xCompF = MathF.Sqrt(16.0f * imageDimensions.Width / imageDimensions.Height);
+        float yCompF = xCompF * imageDimensions.Height / imageDimensions.Width;
 
-            return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5()
-                .ToString("N", CultureInfo.InvariantCulture);
-        }
+        int xComp = Math.Min((int)xCompF + 1, 9);
+        int yComp = Math.Min((int)yCompF + 1, 9);
+
+        return _imageEncoder.GetImageBlurHash(xComp, yComp, path);
+    }
+
+    /// <inheritdoc />
+    public string GetImageCacheTag(BaseItem item, ItemImageInfo image)
+        => (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture);
 
-        private Task<(string Path, DateTime DateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified)
+    /// <inheritdoc />
+    public string GetImageCacheTag(BaseItem item, ChapterInfo chapter)
+    {
+        return GetImageCacheTag(item, new ItemImageInfo
         {
-            var inputFormat = Path.GetExtension(originalImagePath.AsSpan()).TrimStart('.').ToString();
+            Path = chapter.ImagePath,
+            Type = ImageType.Chapter,
+            DateModified = chapter.ImageDateModified
+        });
+    }
 
-            // These are just jpg files renamed as tbn
-            if (string.Equals(inputFormat, "tbn", StringComparison.OrdinalIgnoreCase))
-            {
-                return Task.FromResult((originalImagePath, dateModified));
-            }
+    /// <inheritdoc />
+    public string? GetImageCacheTag(User user)
+    {
+        if (user.ProfileImage is null)
+        {
+            return null;
+        }
 
-            // TODO _mediaEncoder.ConvertImage is not implemented
-            // if (!_imageEncoder.SupportedInputFormats.Contains(inputFormat))
-            // {
-            //     try
-            //     {
-            //         string filename = (originalImagePath + dateModified.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("N", CultureInfo.InvariantCulture);
-            //
-            //         string cacheExtension = _mediaEncoder.SupportsEncoder("libwebp") ? ".webp" : ".png";
-            //         var outputPath = Path.Combine(_appPaths.ImageCachePath, "converted-images", filename + cacheExtension);
-            //
-            //         var file = _fileSystem.GetFileInfo(outputPath);
-            //         if (!file.Exists)
-            //         {
-            //             await _mediaEncoder.ConvertImage(originalImagePath, outputPath).ConfigureAwait(false);
-            //             dateModified = _fileSystem.GetLastWriteTimeUtc(outputPath);
-            //         }
-            //         else
-            //         {
-            //             dateModified = file.LastWriteTimeUtc;
-            //         }
-            //
-            //         originalImagePath = outputPath;
-            //     }
-            //     catch (Exception ex)
-            //     {
-            //         _logger.LogError(ex, "Image conversion failed for {Path}", originalImagePath);
-            //     }
-            // }
+        return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5()
+            .ToString("N", CultureInfo.InvariantCulture);
+    }
 
+    private Task<(string Path, DateTime DateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified)
+    {
+        var inputFormat = Path.GetExtension(originalImagePath.AsSpan()).TrimStart('.').ToString();
+
+        // These are just jpg files renamed as tbn
+        if (string.Equals(inputFormat, "tbn", StringComparison.OrdinalIgnoreCase))
+        {
             return Task.FromResult((originalImagePath, dateModified));
         }
 
-        /// <summary>
-        /// Gets the cache path.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <param name="uniqueName">Name of the unique.</param>
-        /// <param name="fileExtension">The file extension.</param>
-        /// <returns>System.String.</returns>
-        /// <exception cref="ArgumentNullException">
-        /// path
-        /// or
-        /// uniqueName
-        /// or
-        /// fileExtension.
-        /// </exception>
-        public string GetCachePath(string path, string uniqueName, string fileExtension)
-        {
-            ArgumentException.ThrowIfNullOrEmpty(path);
-            ArgumentException.ThrowIfNullOrEmpty(uniqueName);
-            ArgumentException.ThrowIfNullOrEmpty(fileExtension);
-
-            var filename = uniqueName.GetMD5() + fileExtension;
-
-            return GetCachePath(path, filename);
-        }
+        // TODO _mediaEncoder.ConvertImage is not implemented
+        // if (!_imageEncoder.SupportedInputFormats.Contains(inputFormat))
+        // {
+        //     try
+        //     {
+        //         string filename = (originalImagePath + dateModified.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("N", CultureInfo.InvariantCulture);
+        //
+        //         string cacheExtension = _mediaEncoder.SupportsEncoder("libwebp") ? ".webp" : ".png";
+        //         var outputPath = Path.Combine(_appPaths.ImageCachePath, "converted-images", filename + cacheExtension);
+        //
+        //         var file = _fileSystem.GetFileInfo(outputPath);
+        //         if (!file.Exists)
+        //         {
+        //             await _mediaEncoder.ConvertImage(originalImagePath, outputPath).ConfigureAwait(false);
+        //             dateModified = _fileSystem.GetLastWriteTimeUtc(outputPath);
+        //         }
+        //         else
+        //         {
+        //             dateModified = file.LastWriteTimeUtc;
+        //         }
+        //
+        //         originalImagePath = outputPath;
+        //     }
+        //     catch (Exception ex)
+        //     {
+        //         _logger.LogError(ex, "Image conversion failed for {Path}", originalImagePath);
+        //     }
+        // }
+
+        return Task.FromResult((originalImagePath, dateModified));
+    }
 
-        /// <summary>
-        /// Gets the cache path.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <param name="filename">The filename.</param>
-        /// <returns>System.String.</returns>
-        /// <exception cref="ArgumentNullException">
-        /// path
-        /// or
-        /// filename.
-        /// </exception>
-        public string GetCachePath(ReadOnlySpan<char> path, ReadOnlySpan<char> filename)
-        {
-            if (path.IsEmpty)
-            {
-                throw new ArgumentException("Path can't be empty.", nameof(path));
-            }
+    /// <summary>
+    /// Gets the cache path.
+    /// </summary>
+    /// <param name="path">The path.</param>
+    /// <param name="uniqueName">Name of the unique.</param>
+    /// <param name="fileExtension">The file extension.</param>
+    /// <returns>System.String.</returns>
+    /// <exception cref="ArgumentNullException">
+    /// path
+    /// or
+    /// uniqueName
+    /// or
+    /// fileExtension.
+    /// </exception>
+    public string GetCachePath(string path, string uniqueName, string fileExtension)
+    {
+        ArgumentException.ThrowIfNullOrEmpty(path);
+        ArgumentException.ThrowIfNullOrEmpty(uniqueName);
+        ArgumentException.ThrowIfNullOrEmpty(fileExtension);
 
-            if (filename.IsEmpty)
-            {
-                throw new ArgumentException("Filename can't be empty.", nameof(filename));
-            }
+        var filename = uniqueName.GetMD5() + fileExtension;
 
-            var prefix = filename.Slice(0, 1);
+        return GetCachePath(path, filename);
+    }
 
-            return Path.Join(path, prefix, filename);
+    /// <summary>
+    /// Gets the cache path.
+    /// </summary>
+    /// <param name="path">The path.</param>
+    /// <param name="filename">The filename.</param>
+    /// <returns>System.String.</returns>
+    /// <exception cref="ArgumentNullException">
+    /// path
+    /// or
+    /// filename.
+    /// </exception>
+    public string GetCachePath(ReadOnlySpan<char> path, ReadOnlySpan<char> filename)
+    {
+        if (path.IsEmpty)
+        {
+            throw new ArgumentException("Path can't be empty.", nameof(path));
         }
 
-        /// <inheritdoc />
-        public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
+        if (filename.IsEmpty)
         {
-            _logger.LogInformation("Creating image collage and saving to {Path}", options.OutputPath);
+            throw new ArgumentException("Filename can't be empty.", nameof(filename));
+        }
 
-            _imageEncoder.CreateImageCollage(options, libraryName);
+        var prefix = filename.Slice(0, 1);
 
-            _logger.LogInformation("Completed creation of image collage and saved to {Path}", options.OutputPath);
-        }
+        return Path.Join(path, prefix, filename);
+    }
 
-        /// <inheritdoc />
-        public void Dispose()
-        {
-            if (_disposed)
-            {
-                return;
-            }
+    /// <inheritdoc />
+    public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
+    {
+        _logger.LogInformation("Creating image collage and saving to {Path}", options.OutputPath);
 
-            if (_imageEncoder is IDisposable disposable)
-            {
-                disposable.Dispose();
-            }
+        _imageEncoder.CreateImageCollage(options, libraryName);
+
+        _logger.LogInformation("Completed creation of image collage and saved to {Path}", options.OutputPath);
+    }
+
+    /// <inheritdoc />
+    public void Dispose()
+    {
+        if (_disposed)
+        {
+            return;
+        }
 
-            _disposed = true;
+        if (_imageEncoder is IDisposable disposable)
+        {
+            disposable.Dispose();
         }
+
+        _disposed = true;
     }
 }

+ 46 - 47
src/Jellyfin.Drawing/NullImageEncoder.cs

@@ -3,56 +3,55 @@ using System.Collections.Generic;
 using MediaBrowser.Controller.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>
-    /// 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<string> SupportedInputFormats
+        => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "png", "jpeg", "jpg" };
 
-        /// <inheritdoc />
-        public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
+    /// <inheritdoc />
+    public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
         => new HashSet<ImageFormat>() { ImageFormat.Jpg, ImageFormat.Png };
 
-        /// <inheritdoc />
-        public string Name => "Null Image Encoder";
-
-        /// <inheritdoc />
-        public bool SupportsImageCollageCreation => false;
-
-        /// <inheritdoc />
-        public bool SupportsImageEncoding => false;
-
-        /// <inheritdoc />
-        public ImageDimensions GetImageSize(string path)
-            => throw new NotImplementedException();
-
-        /// <inheritdoc />
-        public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat)
-        {
-            throw new NotImplementedException();
-        }
-
-        /// <inheritdoc />
-        public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
-        {
-            throw new NotImplementedException();
-        }
-
-        /// <inheritdoc />
-        public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
-        {
-            throw new NotImplementedException();
-        }
-
-        /// <inheritdoc />
-        public string GetImageBlurHash(int xComp, int yComp, string path)
-        {
-            throw new NotImplementedException();
-        }
+    /// <inheritdoc />
+    public string Name => "Null Image Encoder";
+
+    /// <inheritdoc />
+    public bool SupportsImageCollageCreation => false;
+
+    /// <inheritdoc />
+    public bool SupportsImageEncoding => false;
+
+    /// <inheritdoc />
+    public ImageDimensions GetImageSize(string path)
+        => throw new NotImplementedException();
+
+    /// <inheritdoc />
+    public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat)
+    {
+        throw new NotImplementedException();
+    }
+
+    /// <inheritdoc />
+    public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
+    {
+        throw new NotImplementedException();
+    }
+
+    /// <inheritdoc />
+    public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
+    {
+        throw new NotImplementedException();
+    }
+
+    /// <inheritdoc />
+    public string GetImageBlurHash(int xComp, int yComp, string path)
+    {
+        throw new NotImplementedException();
     }
 }