diff --git a/Directory.Packages.props b/Directory.Packages.props index d00cd2b794..7e9df1a22c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,15 +10,15 @@ - - + + - + @@ -67,12 +67,12 @@ - - - + + + - + @@ -89,4 +89,4 @@ - + \ No newline at end of file diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 73c8c39663..4626bc914d 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -27,6 +27,16 @@ public class SkiaEncoder : IImageEncoder private static readonly SKImageFilter _imageFilter; private static readonly SKTypeface[] _typefaces; + /// + /// The default sampling options, equivalent to old high quality filter settings when upscaling. + /// + public static readonly SKSamplingOptions UpscaleSamplingOptions; + + /// + /// The sampling options, used for downscaling images, equivalent to old high quality filter settings when not upscaling. + /// + public static readonly SKSamplingOptions DefaultSamplingOptions; + #pragma warning disable CA1810 static SkiaEncoder() #pragma warning restore CA1810 @@ -63,6 +73,11 @@ public class SkiaEncoder : IImageEncoder SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ي'), // Arabic SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright) // Default font ]; + + // use cubic for upscaling + UpscaleSamplingOptions = new SKSamplingOptions(SKCubicResampler.Mitchell); + // use bilinear for everything else + DefaultSamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear); } /// @@ -441,7 +456,7 @@ public class SkiaEncoder : IImageEncoder break; } - surface.DrawBitmap(bitmap, 0, 0); + surface.DrawBitmap(bitmap, 0, 0, DefaultSamplingOptions); return rotated; } catch (Exception e) @@ -467,18 +482,23 @@ public class SkiaEncoder : IImageEncoder { using var surface = SKSurface.Create(targetInfo); using var canvas = surface.Canvas; - using var paint = new SKPaint - { - FilterQuality = SKFilterQuality.High, - IsAntialias = isAntialias, - IsDither = isDither - }; + using var paint = new SKPaint(); + paint.IsAntialias = isAntialias; + paint.IsDither = isDither; + + // Historically, kHigh implied cubic filtering, but only when upsampling. + // If specified kHigh, and were down-sampling, Skia used to switch back to kMedium (bilinear filtering plus mipmaps). + // With current skia API, passing Mitchell cubic when down-sampling will cause serious quality degradation. + var samplingOptions = source.Width > targetInfo.Width || source.Height > targetInfo.Height + ? DefaultSamplingOptions + : UpscaleSamplingOptions; paint.ImageFilter = _imageFilter; canvas.DrawBitmap( source, SKRect.Create(0, 0, source.Width, source.Height), SKRect.Create(0, 0, targetInfo.Width, targetInfo.Height), + samplingOptions, paint); return surface.Snapshot(); @@ -560,11 +580,10 @@ public class SkiaEncoder : IImageEncoder using var paint = new SKPaint(); // Add blur if option is present using var filter = blur > 0 ? SKImageFilter.CreateBlur(blur, blur) : null; - paint.FilterQuality = SKFilterQuality.High; paint.ImageFilter = filter; // create image from resized bitmap to apply blur - canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), paint); + canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), DefaultSamplingOptions, paint); // If foreground layer present then draw if (hasForegroundColor) @@ -690,7 +709,7 @@ public class SkiaEncoder : IImageEncoder throw new InvalidOperationException("Image height does not match first image height."); } - canvas.DrawBitmap(img, x * imgWidth, y * imgHeight.Value); + canvas.DrawBitmap(img, x * imgWidth, y * imgHeight.Value, DefaultSamplingOptions); } } diff --git a/src/Jellyfin.Drawing.Skia/SkiaExtensions.cs b/src/Jellyfin.Drawing.Skia/SkiaExtensions.cs new file mode 100644 index 0000000000..f7d6842ff4 --- /dev/null +++ b/src/Jellyfin.Drawing.Skia/SkiaExtensions.cs @@ -0,0 +1,58 @@ +using SkiaSharp; + +namespace Jellyfin.Drawing.Skia; + +/// +/// The SkiaSharp extensions. +/// +public static class SkiaExtensions +{ + /// + /// Draws an SKBitmap on the canvas with specified SkSamplingOptions. + /// + /// The SKCanvas to draw on. + /// The SKBitmap to draw. + /// The destination SKRect. + /// The SKSamplingOptions to use for rendering. + /// Optional SKPaint to apply additional effects or styles. + public static void DrawBitmap(this SKCanvas canvas, SKBitmap bitmap, SKRect dest, SKSamplingOptions options, SKPaint? paint = null) + { + using var image = SKImage.FromBitmap(bitmap); + canvas.DrawImage(image, dest, options, paint); + } + + /// + /// Draws an SKBitmap on the canvas at the specified coordinates with the given SkSamplingOptions. + /// + /// The SKCanvas to draw on. + /// The SKBitmap to draw. + /// The x-coordinate where the bitmap will be drawn. + /// The y-coordinate where the bitmap will be drawn. + /// The SKSamplingOptions to use for rendering. + /// Optional SKPaint to apply additional effects or styles. + public static void DrawBitmap(this SKCanvas canvas, SKBitmap bitmap, float x, float y, SKSamplingOptions options, SKPaint? paint = null) + { + using var image = SKImage.FromBitmap(bitmap); + canvas.DrawImage(image, x, y, options, paint); + } + + /// + /// Draws an SKBitmap on the canvas using a specified source rectangle, destination rectangle, + /// and optional paint, with the given SkSamplingOptions. + /// + /// The SKCanvas to draw on. + /// The SKBitmap to draw. + /// + /// The source SKRect defining the portion of the bitmap to draw. + /// + /// + /// The destination SKRect defining the area on the canvas where the bitmap will be drawn. + /// + /// The SKSamplingOptions to use for rendering. + /// Optional SKPaint to apply additional effects or styles. + public static void DrawBitmap(this SKCanvas canvas, SKBitmap bitmap, SKRect source, SKRect dest, SKSamplingOptions options, SKPaint? paint = null) + { + using var image = SKImage.FromBitmap(bitmap); + canvas.DrawImage(image, source, dest, options, paint); + } +} diff --git a/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs b/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs index 03733d4f84..554707a3f0 100644 --- a/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs +++ b/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs @@ -101,10 +101,12 @@ public class SplashscreenBuilder { var imageWidth = Math.Abs(posterHeight * currentImage.Width / currentImage.Height); using var resizedBitmap = new SKBitmap(imageWidth, posterHeight); - currentImage.ScalePixels(resizedBitmap, SKFilterQuality.High); - + var samplingOptions = currentImage.Width > imageWidth || currentImage.Height > posterHeight + ? SkiaEncoder.DefaultSamplingOptions + : SkiaEncoder.UpscaleSamplingOptions; + currentImage.ScalePixels(resizedBitmap, samplingOptions); // draw on canvas - canvas.DrawBitmap(resizedBitmap, currentWidthPos, currentHeight); + canvas.DrawBitmap(resizedBitmap, currentWidthPos, currentHeight, samplingOptions); // resize to the same aspect as the original currentWidthPos += imageWidth + Spacing; diff --git a/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs b/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs index 03e202e5a4..64c33d5c2b 100644 --- a/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs +++ b/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs @@ -111,38 +111,31 @@ public partial class StripCollageBuilder var backdropHeight = Math.Abs(width * backdrop.Height / backdrop.Width); using var resizedBackdrop = SkiaEncoder.ResizeImage(backdrop, new SKImageInfo(width, backdropHeight, backdrop.ColorType, backdrop.AlphaType, backdrop.ColorSpace)); using var paint = new SKPaint(); - paint.FilterQuality = SKFilterQuality.High; // draw the backdrop - canvas.DrawImage(resizedBackdrop, 0, 0, paint); + canvas.DrawImage(resizedBackdrop, 0, 0, SkiaEncoder.DefaultSamplingOptions, paint); // draw shadow rectangle - using var paintColor = new SKPaint - { - Color = SKColors.Black.WithAlpha(0x78), - Style = SKPaintStyle.Fill, - FilterQuality = SKFilterQuality.High - }; + using var paintColor = new SKPaint(); + paintColor.Color = SKColors.Black.WithAlpha(0x78); + paintColor.Style = SKPaintStyle.Fill; canvas.DrawRect(0, 0, width, height, paintColor); var typeFace = SkiaEncoder.DefaultTypeFace; // draw library name - using var textPaint = new SKPaint - { - Color = SKColors.White, - Style = SKPaintStyle.Fill, - TextSize = 112, - TextAlign = SKTextAlign.Left, - Typeface = typeFace, - IsAntialias = true, - FilterQuality = SKFilterQuality.High - }; + using var textFont = new SKFont(); + textFont.Size = 112; + textFont.Typeface = typeFace; + using var textPaint = new SKPaint(); + textPaint.Color = SKColors.White; + textPaint.Style = SKPaintStyle.Fill; + textPaint.IsAntialias = true; // scale down text to 90% of the width if text is larger than 95% of the width - var textWidth = textPaint.MeasureText(libraryName); + var textWidth = textFont.MeasureText(libraryName); if (textWidth > width * 0.95) { - textPaint.TextSize = 0.9f * width * textPaint.TextSize / textWidth; + textFont.Size = 0.9f * width * textFont.Size / textWidth; } if (string.IsNullOrWhiteSpace(libraryName)) @@ -150,23 +143,22 @@ public partial class StripCollageBuilder return bitmap; } - var realWidth = DrawText(null, 0, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint); + var realWidth = DrawText(null, 0, (height / 2f) + (textFont.Metrics.XHeight / 2), libraryName, textPaint, textFont); if (realWidth > width * 0.95) { - textPaint.TextSize = 0.9f * width * textPaint.TextSize / realWidth; - realWidth = DrawText(null, 0, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint); + textFont.Size = 0.9f * width * textFont.Size / realWidth; + realWidth = DrawText(null, 0, (height / 2f) + (textFont.Metrics.XHeight / 2), libraryName, textPaint, textFont); } var padding = (width - realWidth) / 2; if (IsRtlTextRegex().IsMatch(libraryName)) { - textPaint.TextAlign = SKTextAlign.Right; - DrawText(canvas, width - padding, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint, true); + DrawText(canvas, width - padding, (height / 2f) + (textFont.Metrics.XHeight / 2), libraryName, textPaint, textFont, true); } else { - DrawText(canvas, padding, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint); + DrawText(canvas, padding, (height / 2f) + (textFont.Metrics.XHeight / 2), libraryName, textPaint, textFont); } return bitmap; @@ -196,12 +188,11 @@ public partial class StripCollageBuilder var imageInfo = new SKImageInfo(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType, currentBitmap.ColorSpace); using var resizeImage = SkiaEncoder.ResizeImage(currentBitmap, imageInfo); using var paint = new SKPaint(); - paint.FilterQuality = SKFilterQuality.High; // draw this image into the strip at the next position var xPos = x * cellWidth; var yPos = y * cellHeight; - canvas.DrawImage(resizeImage, xPos, yPos, paint); + canvas.DrawImage(resizeImage, xPos, yPos, SkiaEncoder.DefaultSamplingOptions, paint); } } @@ -216,11 +207,13 @@ public partial class StripCollageBuilder /// y position of the canvas to draw text. /// The text to draw. /// The SKPaint to style the text. + /// The SKFont to style the text. + /// The alignment of the text. Default aligns to left. /// The width of the text. - private static float MeasureAndDrawText(SKCanvas? canvas, float x, float y, string text, SKPaint textPaint) + private static float MeasureAndDrawText(SKCanvas? canvas, float x, float y, string text, SKPaint textPaint, SKFont textFont, SKTextAlign alignment = SKTextAlign.Left) { - var width = textPaint.MeasureText(text); - canvas?.DrawShapedText(text, x, y, textPaint); + var width = textFont.MeasureText(text); + canvas?.DrawShapedText(text, x, y, alignment, textFont, textPaint); return width; } @@ -232,16 +225,18 @@ public partial class StripCollageBuilder /// y position of the canvas to draw text. /// The text to draw. /// The SKPaint to style the text. + /// The SKFont to style the text. /// If true, render from right to left. /// The width of the text. - private static float DrawText(SKCanvas? canvas, float x, float y, string text, SKPaint textPaint, bool isRtl = false) + private static float DrawText(SKCanvas? canvas, float x, float y, string text, SKPaint textPaint, SKFont textFont, bool isRtl = false) { float width = 0; + var alignment = isRtl ? SKTextAlign.Right : SKTextAlign.Left; - if (textPaint.ContainsGlyphs(text)) + if (textFont.ContainsGlyphs(text)) { // Current font can render all characters in text - return MeasureAndDrawText(canvas, x, y, text, textPaint); + return MeasureAndDrawText(canvas, x, y, text, textPaint, textFont, alignment); } // Iterate over all text elements using TextElementEnumerator @@ -254,7 +249,7 @@ public partial class StripCollageBuilder { bool notAtEnd; var textElement = enumerator.GetTextElement(); - if (textPaint.ContainsGlyphs(textElement)) + if (textFont.ContainsGlyphs(textElement)) { continue; } @@ -264,12 +259,12 @@ public partial class StripCollageBuilder if (start != enumerator.ElementIndex) { var regularText = text.Substring(start, enumerator.ElementIndex - start); - width += MeasureAndDrawText(canvas, MoveX(x, width), y, regularText, textPaint); + width += MeasureAndDrawText(canvas, MoveX(x, width), y, regularText, textPaint, textFont, alignment); start = enumerator.ElementIndex; } // Search for next point where current font can render the character there - while ((notAtEnd = enumerator.MoveNext()) && !textPaint.ContainsGlyphs(enumerator.GetTextElement())) + while ((notAtEnd = enumerator.MoveNext()) && !textFont.ContainsGlyphs(enumerator.GetTextElement())) { // Do nothing, just move enumerator to the point where current font can render the character } @@ -284,21 +279,21 @@ public partial class StripCollageBuilder if (fallback is not null) { + using var fallbackTextFont = new SKFont(); + fallbackTextFont.Size = textFont.Size; + fallbackTextFont.Typeface = fallback; using var fallbackTextPaint = new SKPaint(); fallbackTextPaint.Color = textPaint.Color; fallbackTextPaint.Style = textPaint.Style; - fallbackTextPaint.TextSize = textPaint.TextSize; - fallbackTextPaint.TextAlign = textPaint.TextAlign; - fallbackTextPaint.Typeface = fallback; fallbackTextPaint.IsAntialias = textPaint.IsAntialias; // Do the search recursively to select all possible fonts - width += DrawText(canvas, MoveX(x, width), y, subtext, fallbackTextPaint, isRtl); + width += DrawText(canvas, MoveX(x, width), y, subtext, fallbackTextPaint, fallbackTextFont, isRtl); } else { // Used up all fonts and no fonts can be found, just use current font - width += MeasureAndDrawText(canvas, MoveX(x, width), y, text[start..], textPaint); + width += MeasureAndDrawText(canvas, MoveX(x, width), y, text[start..], textPaint, textFont, alignment); } start = notAtEnd ? enumerator.ElementIndex : text.Length; @@ -307,7 +302,7 @@ public partial class StripCollageBuilder // Render the remaining text that current fonts can render if (start < text.Length) { - width += MeasureAndDrawText(canvas, MoveX(x, width), y, text[start..], textPaint); + width += MeasureAndDrawText(canvas, MoveX(x, width), y, text[start..], textPaint, textFont, alignment); } return width; diff --git a/src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs b/src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs index 456b84b8c8..46c48357e6 100644 --- a/src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs +++ b/src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs @@ -34,10 +34,12 @@ public static class UnplayedCountIndicator Style = SKPaintStyle.Fill }; + using var font = new SKFont(); + canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint); paint.Color = new SKColor(255, 255, 255, 255); - paint.TextSize = 24; + font.Size = 24; paint.IsAntialias = true; var y = OffsetFromTopRightCorner + 9; @@ -55,9 +57,9 @@ public static class UnplayedCountIndicator { x -= 15; y -= 2; - paint.TextSize = 18; + font.Size = 18; } - canvas.DrawText(text, x, y, paint); + canvas.DrawText(text, x, y, font, paint); } }