< Summary - Jellyfin

Information
Class: Jellyfin.Drawing.Skia.SkiaEncoder
Assembly: Jellyfin.Drawing.Skia
File(s): /srv/git/jellyfin/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
Line coverage
21%
Covered lines: 86
Uncovered lines: 320
Coverable lines: 406
Total lines: 914
Line coverage: 21.1%
Branch coverage
16%
Covered branches: 35
Total branches: 208
Branch coverage: 16.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/16/2026 - 12:15:55 AM Line coverage: 2.1% (8/371) Branch coverage: 1.1% (2/176) Total lines: 8225/20/2026 - 12:15:44 AM Line coverage: 2.1% (8/371) Branch coverage: 0.5% (1/176) Total lines: 82210/29/2026 - 12:17:11 AM Line coverage: 21.1% (86/406) Branch coverage: 16.8% (35/208) Total lines: 914 5/16/2026 - 12:15:55 AM Line coverage: 2.1% (8/371) Branch coverage: 1.1% (2/176) Total lines: 8225/20/2026 - 12:15:44 AM Line coverage: 2.1% (8/371) Branch coverage: 0.5% (1/176) Total lines: 82210/29/2026 - 12:17:11 AM Line coverage: 21.1% (86/406) Branch coverage: 16.8% (35/208) Total lines: 914

Coverage delta

Coverage delta 19 -19

Metrics

File(s)

/srv/git/jellyfin/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs

#LineLine coverage
 1using System;
 2using System.Buffers;
 3using System.Collections.Generic;
 4using System.Globalization;
 5using System.IO;
 6using System.Linq;
 7using System.Runtime.InteropServices;
 8using BlurHashSharp.SkiaSharp;
 9using Jellyfin.Extensions;
 10using MediaBrowser.Common.Configuration;
 11using MediaBrowser.Common.Extensions;
 12using MediaBrowser.Controller.Drawing;
 13using MediaBrowser.Model.Drawing;
 14using Microsoft.Extensions.Logging;
 15using SkiaSharp;
 16using Svg;
 17using Svg.Skia;
 18
 19namespace Jellyfin.Drawing.Skia;
 20
 21/// <summary>
 22/// Image encoder that uses <see cref="SkiaSharp"/> to manipulate images.
 23/// </summary>
 24public class SkiaEncoder : IImageEncoder
 25{
 26    private const string SvgFormat = "svg";
 27
 28    // The light sharpening kernel applied after resizing, see ResizeImage.
 29    private const float SharpenCenterWeight = 1.4f;
 30    private const float SharpenNeighborWeight = -0.1f;
 31
 232    private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".g
 33    private readonly ILogger<SkiaEncoder> _logger;
 34    private readonly IApplicationPaths _appPaths;
 235    private static readonly SKTypeface?[] _typefaces = InitializeTypefaces();
 36
 37    /// <summary>
 38    /// The default sampling options, equivalent to old high quality filter settings when upscaling.
 39    /// </summary>
 240    public static readonly SKSamplingOptions UpscaleSamplingOptions = new SKSamplingOptions(SKCubicResampler.Mitchell);
 41
 42    /// <summary>
 43    /// The sampling options, used for downscaling images, equivalent to old high quality filter settings when not upsca
 44    /// </summary>
 245    public static readonly SKSamplingOptions DefaultSamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipma
 46
 47    static SkiaEncoder()
 48    {
 249        SvgDocument.ResolveExternalElements = ExternalType.None;
 250        SvgDocument.ResolveExternalImages = ExternalType.None;
 251        SvgDocument.ResolveExternalXmlEntites = ExternalType.None;
 252    }
 53
 54    /// <summary>
 55    /// Initializes a new instance of the <see cref="SkiaEncoder"/> class.
 56    /// </summary>
 57    /// <param name="logger">The application logger.</param>
 58    /// <param name="appPaths">The application paths.</param>
 59    public SkiaEncoder(ILogger<SkiaEncoder> logger, IApplicationPaths appPaths)
 60    {
 2761        _logger = logger;
 2762        _appPaths = appPaths;
 2763    }
 64
 65    /// <inheritdoc/>
 066    public string Name => "Skia";
 67
 68    /// <inheritdoc/>
 069    public bool SupportsImageCollageCreation => true;
 70
 71    /// <inheritdoc/>
 072    public bool SupportsImageEncoding => true;
 73
 74    /// <inheritdoc/>
 75    public IReadOnlyCollection<string> SupportedInputFormats =>
 076        new HashSet<string>(StringComparer.OrdinalIgnoreCase)
 077        {
 078            "jpeg",
 079            "jpg",
 080            "png",
 081            "dng",
 082            "webp",
 083            "gif",
 084            "bmp",
 085            "ico",
 086            "astc",
 087            "ktx",
 088            "pkm",
 089            "wbmp",
 090            // TODO: check if these are supported on multiple platforms
 091            // https://github.com/google/skia/blob/master/infra/bots/recipes/test.py#L454
 092            // working on windows at least
 093            "cr2",
 094            "nef",
 095            "arw",
 096            SvgFormat
 097        };
 98
 99    /// <inheritdoc/>
 100    public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
 0101        => new HashSet<ImageFormat> { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png, ImageFormat.Svg };
 102
 103    /// <summary>
 104    /// Gets the default typeface to use.
 105    /// </summary>
 0106    public static SKTypeface? DefaultTypeFace => _typefaces.Last();
 107
 108    /// <summary>
 109    /// Check if the native lib is available.
 110    /// </summary>
 111    /// <returns>True if the native lib is available, otherwise false.</returns>
 112    public static bool IsNativeLibAvailable()
 113    {
 114        try
 115        {
 116            // test an operation that requires the native library
 28117            SKPMColor.PreMultiply(SKColors.Black);
 28118            return true;
 119        }
 0120        catch (Exception)
 121        {
 0122            return false;
 123        }
 28124    }
 125
 126    /// <summary>
 127    /// Initialize the list of typefaces
 128    /// We have to statically build a list of typefaces because MatchCharacter only accepts a single character or code p
 129    /// But in reality a human-readable character (grapheme cluster) could be multiple code points. For example, 🚵🏻‍♀️
 130    /// </summary>
 131    /// <returns>The list of typefaces.</returns>
 132    private static SKTypeface?[] InitializeTypefaces()
 133    {
 2134        int[] chars = [
 2135            '鸡', // CJK Simplified Chinese
 2136            '雞', // CJK Traditional Chinese
 2137            'ノ', // CJK Japanese
 2138            '각', // CJK Korean
 2139            128169, // Emojis, 128169 is the Pile of Poo (💩) emoji
 2140            'ז', // Hebrew
 2141            'ي' // Arabic
 2142        ];
 2143        var fonts = new List<SKTypeface>(chars.Length + 1);
 32144        foreach (var ch in chars)
 145        {
 14146            var font = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFon
 14147            if (font is not null)
 148            {
 4149                fonts.Add(font);
 150            }
 151        }
 152
 153        // Default font
 2154        fonts.Add(SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSl
 2155            ?? SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSl
 156
 2157        return fonts.ToArray();
 158    }
 159
 160    /// <summary>
 161    /// Convert a <see cref="ImageFormat"/> to a <see cref="SKEncodedImageFormat"/>.
 162    /// </summary>
 163    /// <param name="selectedFormat">The format to convert.</param>
 164    /// <returns>The converted format.</returns>
 165    public static SKEncodedImageFormat GetImageFormat(ImageFormat selectedFormat)
 166    {
 0167        return selectedFormat switch
 0168        {
 0169            ImageFormat.Bmp => SKEncodedImageFormat.Bmp,
 0170            ImageFormat.Jpg => SKEncodedImageFormat.Jpeg,
 0171            ImageFormat.Gif => SKEncodedImageFormat.Gif,
 0172            ImageFormat.Webp => SKEncodedImageFormat.Webp,
 0173            _ => SKEncodedImageFormat.Png
 0174        };
 175    }
 176
 177    /// <inheritdoc />
 178    /// <exception cref="FileNotFoundException">The path is not valid.</exception>
 179    public ImageDimensions GetImageSize(string path)
 180    {
 0181        if (!File.Exists(path))
 182        {
 0183            throw new FileNotFoundException("File not found", path);
 184        }
 185
 0186        var extension = Path.GetExtension(path.AsSpan());
 0187        if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase))
 188        {
 0189            if (!SvgSecurityValidator.IsSafe(path, out var reason))
 190            {
 0191                _logger.LogError("Refusing to determine dimensions for SVG {FilePath}: {Reason}", path, reason);
 0192                return default;
 193            }
 194
 0195            using var svg = new SKSvg();
 196            try
 197            {
 0198                using var picture = svg.Load(path);
 0199                if (picture is null)
 200                {
 0201                    _logger.LogError("Unable to determine image dimensions for {FilePath}", path);
 0202                    return default;
 203                }
 204
 0205                return new ImageDimensions(Convert.ToInt32(picture.CullRect.Width), Convert.ToInt32(picture.CullRect.Hei
 206            }
 0207            catch (FormatException skiaColorException)
 208            {
 209                // This exception is known to be thrown on vector images that define custom styles
 210                // Skia SVG is not able to handle that and as the repository is quite stale and has not received updates
 0211                _logger.LogDebug(skiaColorException, "There was a issue loading the requested svg file");
 0212                return default;
 213            }
 214        }
 215
 0216        var safePath = NormalizePath(path);
 0217        if (new FileInfo(safePath).Length == 0)
 218        {
 0219            _logger.LogDebug("Skip zero‑byte image {FilePath}", path);
 0220            return default;
 221        }
 222
 0223        SKCodec? codec = null;
 0224        bool isSafePathTemp = !string.Equals(Path.GetFullPath(safePath), Path.GetFullPath(path), StringComparison.Ordina
 225        try
 226        {
 0227            codec = SKCodec.Create(safePath, out var result);
 0228            switch (result)
 229            {
 230                case SKCodecResult.Success:
 231                // Skia/SkiaSharp edge‑case: when the image header is parsed but the actual pixel
 232                // decode fails (truncated JPEG/PNG, exotic ICC/EXIF, CMYK without color‑transform, etc.)
 233                // `SKCodec.Create` returns a *non‑null* codec together with
 234                // SKCodecResult.InternalError.  The header still contains valid dimensions,
 235                // which is all we need here – so we fall back to them instead of aborting.
 236                // See e.g. Skia bugs #4139, #6092.
 0237                case SKCodecResult.InternalError when codec is not null:
 0238                    var info = codec.Info;
 0239                    return new ImageDimensions(info.Width, info.Height);
 240
 241                case SKCodecResult.Unimplemented:
 0242                    _logger.LogDebug("Image format not supported: {FilePath}", path);
 0243                    return default;
 244
 245                default:
 246                    {
 0247                        var boundsInfo = SKBitmap.DecodeBounds(safePath);
 0248                        if (boundsInfo.Width > 0 && boundsInfo.Height > 0)
 249                        {
 0250                            return new ImageDimensions(boundsInfo.Width, boundsInfo.Height);
 251                        }
 252
 0253                        _logger.LogWarning(
 0254                            "Unable to determine image dimensions for {FilePath}: {SkCodecResult}",
 0255                            path,
 0256                            result);
 257
 0258                        return default;
 259                    }
 260            }
 261        }
 262        finally
 263        {
 264            try
 265            {
 0266                codec?.Dispose();
 0267            }
 0268            catch (Exception ex)
 269            {
 0270                _logger.LogDebug(ex, "Error by closing codec for {FilePath}", safePath);
 0271            }
 272
 0273            if (isSafePathTemp)
 274            {
 275                try
 276                {
 0277                    if (File.Exists(safePath))
 278                    {
 0279                        File.Delete(safePath);
 280                    }
 0281                }
 0282                catch (Exception ex)
 283                {
 0284                    _logger.LogDebug(ex, "Unable to remove temporary file '{TempPath}'", safePath);
 0285                }
 286            }
 0287        }
 0288    }
 289
 290    /// <inheritdoc />
 291    /// <exception cref="ArgumentNullException">The path is null.</exception>
 292    /// <exception cref="FileNotFoundException">The path is not valid.</exception>
 293    public string GetImageBlurHash(int xComp, int yComp, string path)
 294    {
 0295        ArgumentException.ThrowIfNullOrEmpty(path);
 296
 0297        var extension = Path.GetExtension(path.AsSpan()).TrimStart('.');
 0298        if (!SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase)
 0299            || extension.Equals(SvgFormat, StringComparison.OrdinalIgnoreCase))
 300        {
 0301            _logger.LogDebug("Unable to compute blur hash due to unsupported format: {ImagePath}", path);
 0302            return string.Empty;
 303        }
 304
 305        // Use FileStream with FileShare.Read instead of having Skia open the file to allow concurrent read access
 0306        using var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
 307        // Any larger than 128x128 is too slow and there's no visually discernible difference
 0308        return BlurHashEncoder.Encode(xComp, yComp, fileStream, 128, 128);
 0309    }
 310
 311    private bool RequiresSpecialCharacterHack(string path)
 312    {
 0313        for (int i = 0; i < path.Length; i++)
 314        {
 0315            if (char.GetUnicodeCategory(path[i]) == UnicodeCategory.OtherLetter)
 316            {
 0317                return true;
 318            }
 319        }
 320
 0321        return path.HasDiacritics();
 322    }
 323
 324    private string NormalizePath(string path)
 325    {
 0326        if (!RequiresSpecialCharacterHack(path))
 327        {
 0328            return path;
 329        }
 330
 0331        var tempPath = Path.Join(_appPaths.TempDirectory, string.Concat("skia_", Guid.NewGuid().ToString(), Path.GetExte
 0332        var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPat
 0333        Directory.CreateDirectory(directory);
 0334        File.Copy(path, tempPath, true);
 335
 0336        return tempPath;
 337    }
 338
 339    private static SKEncodedOrigin GetSKEncodedOrigin(ImageOrientation? orientation)
 340    {
 0341        if (!orientation.HasValue)
 342        {
 0343            return SKEncodedOrigin.Default;
 344        }
 345
 0346        return (SKEncodedOrigin)orientation.Value;
 347    }
 348
 349    /// <summary>
 350    /// Decode an image.
 351    /// </summary>
 352    /// <param name="path">The filepath of the image to decode.</param>
 353    /// <param name="forceCleanBitmap">Whether to force clean the bitmap.</param>
 354    /// <param name="orientation">The orientation of the image.</param>
 355    /// <param name="origin">The detected origin of the image.</param>
 356    /// <returns>The resulting bitmap of the image.</returns>
 357    internal SKBitmap? Decode(string path, bool forceCleanBitmap, ImageOrientation? orientation, out SKEncodedOrigin ori
 358    {
 0359        if (!File.Exists(path))
 360        {
 0361            throw new FileNotFoundException("File not found", path);
 362        }
 363
 0364        var requiresTransparencyHack = _transparentImageTypes.Contains(Path.GetExtension(path));
 365
 0366        if (requiresTransparencyHack || forceCleanBitmap)
 367        {
 0368            using SKCodec codec = SKCodec.Create(NormalizePath(path), out SKCodecResult res);
 0369            if (res != SKCodecResult.Success)
 370            {
 0371                origin = GetSKEncodedOrigin(orientation);
 0372                return null;
 373            }
 374
 0375            if (codec.FrameCount != 0)
 376            {
 0377                throw new ArgumentException("Cannot decode images with multiple frames");
 378            }
 379
 380            // create the bitmap
 0381            SKBitmap? bitmap = null;
 382            try
 383            {
 0384                bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack);
 385
 386                // decode
 0387                _ = codec.GetPixels(bitmap.Info, bitmap.GetPixels());
 388
 0389                origin = codec.EncodedOrigin;
 390
 0391                return bitmap!;
 392            }
 0393            catch (Exception e)
 394            {
 0395                _logger.LogError(e, "Detected intermediary error decoding image {0}", path);
 0396                bitmap?.Dispose();
 0397                throw;
 398            }
 399        }
 400
 0401        var resultBitmap = SKBitmap.Decode(NormalizePath(path));
 402
 0403        if (resultBitmap is null)
 404        {
 0405            return Decode(path, true, orientation, out origin);
 406        }
 407
 408        try
 409        {
 410            // If we have to resize these they often end up distorted
 0411            if (resultBitmap.ColorType == SKColorType.Gray8)
 412            {
 0413                using (resultBitmap)
 414                {
 0415                    return Decode(path, true, orientation, out origin);
 416                }
 417            }
 418
 0419            origin = SKEncodedOrigin.TopLeft;
 0420            return resultBitmap;
 421        }
 0422        catch (Exception e)
 423        {
 0424            _logger.LogError(e, "Detected intermediary error decoding image {0}", path);
 0425            resultBitmap?.Dispose();
 0426            throw;
 427        }
 0428    }
 429
 430    private SKBitmap? GetBitmap(string path, bool autoOrient, ImageOrientation? orientation)
 431    {
 0432        if (autoOrient)
 433        {
 0434            var bitmap = Decode(path, true, orientation, out var origin);
 435
 0436            if (bitmap is not null && origin != SKEncodedOrigin.TopLeft)
 437            {
 0438                using (bitmap)
 439                {
 0440                    return OrientImage(bitmap, origin);
 441                }
 442            }
 443
 0444            return bitmap;
 445        }
 446
 0447        return Decode(path, false, orientation, out _);
 0448    }
 449
 450    private SKBitmap? GetBitmapFromSvg(string path)
 451    {
 0452        if (!File.Exists(path))
 453        {
 0454            throw new FileNotFoundException("File not found", path);
 455        }
 456
 0457        if (!SvgSecurityValidator.IsSafe(path, out var reason))
 458        {
 0459            _logger.LogError("Refusing to render SVG {FilePath}: {Reason}", path, reason);
 0460            return null;
 461        }
 462
 0463        using var svg = SKSvg.CreateFromFile(path);
 0464        if (svg.Drawable is null)
 465        {
 0466            return null;
 467        }
 468
 0469        var width = (int)Math.Round(svg.Drawable.Bounds.Width);
 0470        var height = (int)Math.Round(svg.Drawable.Bounds.Height);
 471
 0472        SKBitmap? bitmap = null;
 473        try
 474        {
 0475            bitmap = new SKBitmap(width, height);
 0476            using var canvas = new SKCanvas(bitmap);
 0477            canvas.DrawPicture(svg.Picture);
 0478            canvas.Flush();
 0479            canvas.Save();
 480
 0481            return bitmap!;
 482        }
 0483        catch (Exception e)
 484        {
 0485            _logger.LogError(e, "Detected intermediary error extracting image {0}", path);
 0486            bitmap?.Dispose();
 0487            throw;
 488        }
 0489    }
 490
 491    private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin)
 492    {
 0493        var needsFlip = origin is SKEncodedOrigin.LeftBottom or SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightBottom o
 0494        SKBitmap? rotated = null;
 495        try
 496        {
 0497            rotated = needsFlip
 0498                ? new SKBitmap(bitmap.Height, bitmap.Width)
 0499                : new SKBitmap(bitmap.Width, bitmap.Height);
 0500            using var surface = new SKCanvas(rotated);
 0501            var midX = (float)rotated.Width / 2;
 0502            var midY = (float)rotated.Height / 2;
 503
 504            switch (origin)
 505            {
 506                case SKEncodedOrigin.TopRight:
 0507                    surface.Scale(-1, 1, midX, midY);
 0508                    break;
 509                case SKEncodedOrigin.BottomRight:
 0510                    surface.RotateDegrees(180, midX, midY);
 0511                    break;
 512                case SKEncodedOrigin.BottomLeft:
 0513                    surface.Scale(1, -1, midX, midY);
 0514                    break;
 515                case SKEncodedOrigin.LeftTop:
 0516                    surface.Translate(0, -rotated.Height);
 0517                    surface.Scale(1, -1, midX, midY);
 0518                    surface.RotateDegrees(-90);
 0519                    break;
 520                case SKEncodedOrigin.RightTop:
 0521                    surface.Translate(rotated.Width, 0);
 0522                    surface.RotateDegrees(90);
 0523                    break;
 524                case SKEncodedOrigin.RightBottom:
 0525                    surface.Translate(rotated.Width, 0);
 0526                    surface.Scale(1, -1, midX, midY);
 0527                    surface.RotateDegrees(90);
 0528                    break;
 529                case SKEncodedOrigin.LeftBottom:
 0530                    surface.Translate(0, rotated.Height);
 0531                    surface.RotateDegrees(-90);
 532                    break;
 533            }
 534
 0535            surface.DrawBitmap(bitmap, 0, 0, DefaultSamplingOptions);
 0536            return rotated;
 537        }
 0538        catch (Exception e)
 539        {
 0540            _logger.LogError(e, "Detected intermediary error rotating image");
 0541            rotated?.Dispose();
 0542            throw;
 543        }
 0544    }
 545
 546    /// <summary>
 547    /// Resizes an image on the CPU, by utilizing a surface and canvas.
 548    ///
 549    /// The convolutional matrix kernel used in this resize function gives a (light) sharpening effect.
 550    /// This technique is similar to effect that can be created using for example the [Convolution matrix filter in GIMP
 551    /// </summary>
 552    /// <param name="source">The source bitmap.</param>
 553    /// <param name="targetInfo">This specifies the target size and other information required to create the surface.</p
 554    /// <param name="isAntialias">This enables anti-aliasing on the SKPaint instance.</param>
 555    /// <param name="isDither">This enables dithering on the SKPaint instance.</param>
 556    /// <returns>The resized image.</returns>
 557    internal static SKImage ResizeImage(SKBitmap source, SKImageInfo targetInfo, bool isAntialias = false, bool isDither
 558    {
 3559        if (source.Width == targetInfo.Width && source.Height == targetInfo.Height)
 560        {
 1561            return SKImage.FromBitmap(source);
 562        }
 563
 2564        var isDownscale = source.Width > targetInfo.Width || source.Height > targetInfo.Height;
 565
 2566        using var target = new SKBitmap(targetInfo);
 2567        using var canvas = new SKCanvas(target);
 2568        using var paint = new SKPaint();
 2569        paint.IsAntialias = isAntialias;
 2570        paint.IsDither = isDither;
 571
 572        // Historically, kHigh implied cubic filtering, but only when upsampling.
 573        // If specified kHigh, and were down-sampling, Skia used to switch back to kMedium (bilinear filtering plus mipm
 574        // With current skia API, passing Mitchell cubic when down-sampling will cause serious quality degradation.
 2575        var samplingOptions = isDownscale
 2576            ? DefaultSamplingOptions
 2577            : UpscaleSamplingOptions;
 578
 2579        canvas.DrawBitmap(
 2580            source,
 2581            SKRect.Create(0, 0, source.Width, source.Height),
 2582            SKRect.Create(0, 0, targetInfo.Width, targetInfo.Height),
 2583            samplingOptions,
 2584            paint);
 585
 2586        if (isDownscale)
 587        {
 1588            SharpenInPlace(target);
 589        }
 590
 2591        return SKImage.FromBitmap(target);
 2592    }
 593
 594    /// <summary>
 595    /// Applies the light 3x3 sharpening kernel to the bitmap in place.
 596    ///
 597    /// This is equivalent to the SKImageFilter.CreateMatrixConvolution paint filter that
 598    /// was previously part of the resize draw call. Since the SkiaSharp 3 update that
 599    /// filter no longer has a fast CPU path and takes multiple seconds per image on the
 600    /// software rasterizer, so the same kernel is applied directly instead.
 601    /// </summary>
 602    /// <param name="bitmap">The bitmap to sharpen. Must use a color type with four bytes per pixel; other color types a
 603    internal static void SharpenInPlace(SKBitmap bitmap)
 604    {
 6605        if (bitmap.BytesPerPixel != 4)
 606        {
 1607            return;
 608        }
 609
 5610        var width = bitmap.Width;
 5611        var height = bitmap.Height;
 5612        var stride = bitmap.RowBytes;
 5613        var pixels = bitmap.GetPixels();
 5614        if (width == 0 || height == 0 || pixels == IntPtr.Zero)
 615        {
 0616            return;
 617        }
 618
 5619        var length = stride * height;
 5620        var source = ArrayPool<byte>.Shared.Rent(length);
 5621        var result = ArrayPool<byte>.Shared.Rent(length);
 622        try
 623        {
 5624            Marshal.Copy(pixels, source, 0, length);
 625
 106626            for (var y = 0; y < height; y++)
 627            {
 628                // The kernel clamps at the edges: out-of-bounds taps reuse the edge pixel.
 48629                var row = y * stride;
 48630                var up = y == 0 ? row : row - stride;
 48631                var down = y == height - 1 ? row : row + stride;
 632
 1316633                for (var x = 0; x < width; x++)
 634                {
 610635                    var col = x * 4;
 610636                    var left = x == 0 ? col : col - 4;
 610637                    var right = x == width - 1 ? col : col + 4;
 638
 6100639                    for (var channel = 0; channel < 4; channel++)
 640                    {
 2440641                        var value = (SharpenCenterWeight * source[row + col + channel])
 2440642                            + (SharpenNeighborWeight * (source[up + col + channel]
 2440643                                + source[down + col + channel]
 2440644                                + source[row + left + channel]
 2440645                                + source[row + right + channel]));
 2440646                        result[row + col + channel] = (byte)Math.Clamp((int)(value + 0.5f), 0, 255);
 647                    }
 648                }
 649            }
 650
 5651            Marshal.Copy(result, 0, pixels, length);
 5652        }
 653        finally
 654        {
 5655            ArrayPool<byte>.Shared.Return(source);
 5656            ArrayPool<byte>.Shared.Return(result);
 5657        }
 5658    }
 659
 660    /// <inheritdoc/>
 661    public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientat
 662    {
 0663        ArgumentException.ThrowIfNullOrEmpty(inputPath);
 0664        ArgumentException.ThrowIfNullOrEmpty(outputPath);
 665
 0666        var inputFormat = Path.GetExtension(inputPath.AsSpan()).TrimStart('.');
 0667        if (!SupportedInputFormats.Contains(inputFormat, StringComparison.OrdinalIgnoreCase))
 668        {
 0669            _logger.LogDebug("Unable to encode image due to unsupported format: {ImagePath}", inputPath);
 0670            return inputPath;
 671        }
 672
 0673        if (outputFormat == ImageFormat.Svg
 0674            && !inputFormat.Equals(SvgFormat, StringComparison.OrdinalIgnoreCase))
 675        {
 0676            throw new ArgumentException($"Requested svg output from {inputFormat} input");
 677        }
 678
 0679        var skiaOutputFormat = GetImageFormat(outputFormat);
 680
 0681        var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor);
 0682        var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer);
 0683        var blur = options.Blur ?? 0;
 0684        var hasIndicator = options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0);
 685
 0686        using var bitmap = inputFormat.Equals(SvgFormat, StringComparison.OrdinalIgnoreCase)
 0687            ? GetBitmapFromSvg(inputPath)
 0688            : GetBitmap(inputPath, autoOrient, orientation);
 689
 0690        if (bitmap is null)
 691        {
 0692            throw new InvalidDataException($"Skia unable to read image {inputPath}");
 693        }
 694
 0695        var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height);
 696
 0697        if (options.HasDefaultOptions(inputPath, originalImageSize) && !autoOrient)
 698        {
 699            // Just spit out the original file if all the options are default
 0700            return inputPath;
 701        }
 702
 0703        var newImageSize = ImageHelper.GetNewImageSize(options, originalImageSize);
 704
 0705        var width = newImageSize.Width;
 0706        var height = newImageSize.Height;
 707
 708        // scale image (the FromImage creates a copy)
 0709        var imageInfo = new SKImageInfo(width, height, bitmap.ColorType, bitmap.AlphaType, bitmap.ColorSpace);
 0710        using var resizedImage = ResizeImage(bitmap, imageInfo);
 0711        using var resizedBitmap = SKBitmap.FromImage(resizedImage);
 712
 713        // If all we're doing is resizing then we can stop now
 0714        if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator)
 715        {
 0716            var outputDirectory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({out
 0717            Directory.CreateDirectory(outputDirectory);
 0718            using var outputStream = new SKFileWStream(outputPath);
 0719            using var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels());
 0720            resizedBitmap.Encode(outputStream, skiaOutputFormat, quality);
 0721            return outputPath;
 722        }
 723
 724        // create bitmap to use for canvas drawing used to draw into bitmap
 0725        using var saveBitmap = new SKBitmap(width, height);
 0726        using var canvas = new SKCanvas(saveBitmap);
 727        // set background color if present
 0728        if (hasBackgroundColor)
 729        {
 0730            canvas.Clear(SKColor.Parse(options.BackgroundColor));
 731        }
 732
 0733        using var paint = new SKPaint();
 734        // Add blur if option is present
 0735        using var filter = blur > 0 ? SKImageFilter.CreateBlur(blur, blur) : null;
 0736        paint.ImageFilter = filter;
 737
 738        // create image from resized bitmap to apply blur
 0739        canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), DefaultSamplingOptions, paint);
 740
 741        // If foreground layer present then draw
 0742        if (hasForegroundColor)
 743        {
 0744            if (!double.TryParse(options.ForegroundLayer, out double opacity))
 745            {
 0746                opacity = .4;
 747            }
 748
 0749            canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver);
 750        }
 751
 0752        if (hasIndicator)
 753        {
 0754            DrawIndicator(canvas, width, height, options);
 755        }
 756
 0757        var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) 
 0758        Directory.CreateDirectory(directory);
 0759        using (var outputStream = new SKFileWStream(outputPath))
 760        {
 0761            using var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels());
 0762            pixmap.Encode(outputStream, skiaOutputFormat, quality);
 763        }
 764
 0765        return outputPath;
 0766    }
 767
 768    /// <inheritdoc/>
 769    public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
 770    {
 0771        double ratio = (double)options.Width / options.Height;
 772
 0773        if (ratio >= 1.4)
 774        {
 0775            new StripCollageBuilder(this).BuildThumbCollage(options.InputPaths, options.OutputPath, options.Width, optio
 776        }
 0777        else if (ratio >= .9)
 778        {
 0779            new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, opti
 780        }
 781        else
 782        {
 783            // TODO: Create Poster collage capability
 0784            new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, opti
 785        }
 0786    }
 787
 788    /// <inheritdoc />
 789    public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
 790    {
 791        // Only generate the splash screen if we have at least one poster and at least one backdrop/thumbnail.
 20792        if (posters.Count > 0 && backdrops.Count > 0)
 793        {
 0794            var splashBuilder = new SplashscreenBuilder(this, _logger);
 0795            var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
 0796            splashBuilder.GenerateSplash(posters, backdrops, outputPath);
 797        }
 20798    }
 799
 800    /// <inheritdoc />
 801    public int CreateTrickplayTile(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight)
 802    {
 0803        var paths = options.InputPaths;
 0804        var tileWidth = options.Width;
 0805        var tileHeight = options.Height;
 806
 0807        if (paths.Count < 1)
 808        {
 0809            throw new ArgumentException("InputPaths cannot be empty.");
 810        }
 0811        else if (paths.Count > tileWidth * tileHeight)
 812        {
 0813            throw new ArgumentException($"InputPaths contains more images than would fit on {tileWidth}x{tileHeight} gri
 814        }
 815
 816        // If no height provided, use height of first image.
 0817        if (!imgHeight.HasValue)
 818        {
 0819            using var firstImg = Decode(paths[0], false, null, out _);
 820
 0821            if (firstImg is null)
 822            {
 0823                throw new InvalidDataException("Could not decode image data.");
 824            }
 825
 0826            if (firstImg.Width != imgWidth)
 827            {
 0828                throw new InvalidOperationException("Image width does not match provided width.");
 829            }
 830
 0831            imgHeight = firstImg.Height;
 832        }
 833
 834        // Make horizontal strips using every provided image.
 0835        using var tileGrid = new SKBitmap(imgWidth * tileWidth, imgHeight.Value * tileHeight);
 0836        using var canvas = new SKCanvas(tileGrid);
 837
 0838        var imgIndex = 0;
 0839        for (var y = 0; y < tileHeight; y++)
 840        {
 0841            for (var x = 0; x < tileWidth; x++)
 842            {
 0843                if (imgIndex >= paths.Count)
 844                {
 845                    break;
 846                }
 847
 0848                using var img = Decode(paths[imgIndex++], false, null, out _);
 849
 0850                if (img is null)
 851                {
 0852                    throw new InvalidDataException("Could not decode image data.");
 853                }
 854
 0855                if (img.Width != imgWidth)
 856                {
 0857                    throw new InvalidOperationException("Image width does not match provided width.");
 858                }
 859
 0860                if (img.Height != imgHeight)
 861                {
 0862                    throw new InvalidOperationException("Image height does not match first image height.");
 863                }
 864
 0865                canvas.DrawBitmap(img, x * imgWidth, y * imgHeight.Value, DefaultSamplingOptions);
 866            }
 867        }
 868
 0869        using var outputStream = new SKFileWStream(options.OutputPath);
 0870        tileGrid.Encode(outputStream, SKEncodedImageFormat.Jpeg, quality);
 871
 0872        return imgHeight.Value;
 0873    }
 874
 875    private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options)
 876    {
 877        try
 878        {
 0879            var currentImageSize = new ImageDimensions(imageWidth, imageHeight);
 880
 0881            if (options.UnplayedCount.HasValue)
 882            {
 0883                UnplayedCountIndicator.DrawUnplayedCountIndicator(canvas, currentImageSize, options.UnplayedCount.Value)
 884            }
 885
 0886            if (options.PercentPlayed > 0)
 887            {
 0888                PercentPlayedDrawer.Process(canvas, currentImageSize, options.PercentPlayed);
 889            }
 0890        }
 0891        catch (Exception ex)
 892        {
 0893            _logger.LogError(ex, "Error drawing indicator overlay");
 0894        }
 0895    }
 896
 897    /// <summary>
 898    /// Return the typeface that contains the glyph for the given character.
 899    /// </summary>
 900    /// <param name="c">The text character.</param>
 901    /// <returns>The typeface contains the character.</returns>
 902    public static SKTypeface? GetFontForCharacter(string c)
 903    {
 0904        foreach (var typeface in _typefaces)
 905        {
 0906            if (typeface is not null && typeface.ContainsGlyphs(c))
 907            {
 0908                return typeface;
 909            }
 910        }
 911
 0912        return null;
 913    }
 914}

Methods/Properties

.cctor()
.ctor(Microsoft.Extensions.Logging.ILogger`1<Jellyfin.Drawing.Skia.SkiaEncoder>,MediaBrowser.Common.Configuration.IApplicationPaths)
get_Name()
get_SupportsImageCollageCreation()
get_SupportsImageEncoding()
get_SupportedInputFormats()
get_SupportedOutputFormats()
get_DefaultTypeFace()
IsNativeLibAvailable()
InitializeTypefaces()
GetImageFormat(MediaBrowser.Model.Drawing.ImageFormat)
GetImageSize(System.String)
GetImageBlurHash(System.Int32,System.Int32,System.String)
RequiresSpecialCharacterHack(System.String)
NormalizePath(System.String)
GetSKEncodedOrigin(System.Nullable`1<MediaBrowser.Model.Drawing.ImageOrientation>)
Decode(System.String,System.Boolean,System.Nullable`1<MediaBrowser.Model.Drawing.ImageOrientation>,SkiaSharp.SKEncodedOrigin&)
GetBitmap(System.String,System.Boolean,System.Nullable`1<MediaBrowser.Model.Drawing.ImageOrientation>)
GetBitmapFromSvg(System.String)
OrientImage(SkiaSharp.SKBitmap,SkiaSharp.SKEncodedOrigin)
ResizeImage(SkiaSharp.SKBitmap,SkiaSharp.SKImageInfo,System.Boolean,System.Boolean)
SharpenInPlace(SkiaSharp.SKBitmap)
EncodeImage(System.String,System.DateTime,System.String,System.Boolean,System.Nullable`1<MediaBrowser.Model.Drawing.ImageOrientation>,System.Int32,MediaBrowser.Controller.Drawing.ImageProcessingOptions,MediaBrowser.Model.Drawing.ImageFormat)
CreateImageCollage(MediaBrowser.Controller.Drawing.ImageCollageOptions,System.String)
CreateSplashscreen(System.Collections.Generic.IReadOnlyList`1<System.String>,System.Collections.Generic.IReadOnlyList`1<System.String>)
CreateTrickplayTile(MediaBrowser.Controller.Drawing.ImageCollageOptions,System.Int32,System.Int32,System.Nullable`1<System.Int32>)
DrawIndicator(SkiaSharp.SKCanvas,System.Int32,System.Int32,MediaBrowser.Controller.Drawing.ImageProcessingOptions)
GetFontForCharacter(System.String)