< 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
2%
Covered lines: 8
Uncovered lines: 363
Coverable lines: 371
Total lines: 822
Line coverage: 2.1%
Branch coverage
1%
Covered branches: 2
Total branches: 176
Branch coverage: 1.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 12/27/2025 - 12:11:51 AM Line coverage: 11% (39/354) Branch coverage: 1.2% (2/162) Total lines: 79012/29/2025 - 12:13:19 AM Line coverage: 10.5% (39/369) Branch coverage: 1.1% (2/168) Total lines: 8203/30/2026 - 12:14:34 AM Line coverage: 12.2% (46/376) Branch coverage: 3.9% (7/176) Total lines: 8373/31/2026 - 12:14:24 AM Line coverage: 2.1% (8/371) Branch coverage: 1.1% (2/176) Total lines: 822 12/27/2025 - 12:11:51 AM Line coverage: 11% (39/354) Branch coverage: 1.2% (2/162) Total lines: 79012/29/2025 - 12:13:19 AM Line coverage: 10.5% (39/369) Branch coverage: 1.1% (2/168) Total lines: 8203/30/2026 - 12:14:34 AM Line coverage: 12.2% (46/376) Branch coverage: 3.9% (7/176) Total lines: 8373/31/2026 - 12:14:24 AM Line coverage: 2.1% (8/371) Branch coverage: 1.1% (2/176) Total lines: 822

Coverage delta

Coverage delta 11 -11

Metrics

File(s)

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

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Globalization;
 4using System.IO;
 5using System.Linq;
 6using BlurHashSharp.SkiaSharp;
 7using Jellyfin.Extensions;
 8using MediaBrowser.Common.Configuration;
 9using MediaBrowser.Common.Extensions;
 10using MediaBrowser.Controller.Drawing;
 11using MediaBrowser.Model.Drawing;
 12using Microsoft.Extensions.Logging;
 13using SkiaSharp;
 14using Svg.Skia;
 15
 16namespace Jellyfin.Drawing.Skia;
 17
 18/// <summary>
 19/// Image encoder that uses <see cref="SkiaSharp"/> to manipulate images.
 20/// </summary>
 21public class SkiaEncoder : IImageEncoder
 22{
 23    private const string SvgFormat = "svg";
 024    private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".g
 25    private readonly ILogger<SkiaEncoder> _logger;
 26    private readonly IApplicationPaths _appPaths;
 027    private static readonly SKTypeface?[] _typefaces = InitializeTypefaces();
 028    private static readonly SKImageFilter _imageFilter = SKImageFilter.CreateMatrixConvolution(
 029        new SKSizeI(3, 3),
 030        [
 031            0,    -.1f,    0,
 032            -.1f, 1.4f, -.1f,
 033            0,    -.1f,    0
 034        ],
 035        1f,
 036        0f,
 037        new SKPointI(1, 1),
 038        SKShaderTileMode.Clamp,
 039        true);
 40
 41    /// <summary>
 42    /// The default sampling options, equivalent to old high quality filter settings when upscaling.
 43    /// </summary>
 044    public static readonly SKSamplingOptions UpscaleSamplingOptions = new SKSamplingOptions(SKCubicResampler.Mitchell);
 45
 46    /// <summary>
 47    /// The sampling options, used for downscaling images, equivalent to old high quality filter settings when not upsca
 48    /// </summary>
 049    public static readonly SKSamplingOptions DefaultSamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipma
 50
 51    /// <summary>
 52    /// Initializes a new instance of the <see cref="SkiaEncoder"/> class.
 53    /// </summary>
 54    /// <param name="logger">The application logger.</param>
 55    /// <param name="appPaths">The application paths.</param>
 56    public SkiaEncoder(ILogger<SkiaEncoder> logger, IApplicationPaths appPaths)
 57    {
 2158        _logger = logger;
 2159        _appPaths = appPaths;
 2160    }
 61
 62    /// <inheritdoc/>
 063    public string Name => "Skia";
 64
 65    /// <inheritdoc/>
 066    public bool SupportsImageCollageCreation => true;
 67
 68    /// <inheritdoc/>
 069    public bool SupportsImageEncoding => true;
 70
 71    /// <inheritdoc/>
 72    public IReadOnlyCollection<string> SupportedInputFormats =>
 073        new HashSet<string>(StringComparer.OrdinalIgnoreCase)
 074        {
 075            "jpeg",
 076            "jpg",
 077            "png",
 078            "dng",
 079            "webp",
 080            "gif",
 081            "bmp",
 082            "ico",
 083            "astc",
 084            "ktx",
 085            "pkm",
 086            "wbmp",
 087            // TODO: check if these are supported on multiple platforms
 088            // https://github.com/google/skia/blob/master/infra/bots/recipes/test.py#L454
 089            // working on windows at least
 090            "cr2",
 091            "nef",
 092            "arw",
 093            SvgFormat
 094        };
 95
 96    /// <inheritdoc/>
 97    public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
 098        => new HashSet<ImageFormat> { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png, ImageFormat.Svg };
 99
 100    /// <summary>
 101    /// Gets the default typeface to use.
 102    /// </summary>
 0103    public static SKTypeface? DefaultTypeFace => _typefaces.Last();
 104
 105    /// <summary>
 106    /// Check if the native lib is available.
 107    /// </summary>
 108    /// <returns>True if the native lib is available, otherwise false.</returns>
 109    public static bool IsNativeLibAvailable()
 110    {
 111        try
 112        {
 113            // test an operation that requires the native library
 21114            SKPMColor.PreMultiply(SKColors.Black);
 21115            return true;
 116        }
 0117        catch (Exception)
 118        {
 0119            return false;
 120        }
 21121    }
 122
 123    /// <summary>
 124    /// Initialize the list of typefaces
 125    /// We have to statically build a list of typefaces because MatchCharacter only accepts a single character or code p
 126    /// But in reality a human-readable character (grapheme cluster) could be multiple code points. For example, 🚵🏻‍♀️
 127    /// </summary>
 128    /// <returns>The list of typefaces.</returns>
 129    private static SKTypeface?[] InitializeTypefaces()
 130    {
 0131        int[] chars = [
 0132            '鸡', // CJK Simplified Chinese
 0133            '雞', // CJK Traditional Chinese
 0134            'ノ', // CJK Japanese
 0135            '각', // CJK Korean
 0136            128169, // Emojis, 128169 is the Pile of Poo (💩) emoji
 0137            'ז', // Hebrew
 0138            'ي' // Arabic
 0139        ];
 0140        var fonts = new List<SKTypeface>(chars.Length + 1);
 0141        foreach (var ch in chars)
 142        {
 0143            var font = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFon
 0144            if (font is not null)
 145            {
 0146                fonts.Add(font);
 147            }
 148        }
 149
 150        // Default font
 0151        fonts.Add(SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSl
 0152            ?? SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSl
 153
 0154        return fonts.ToArray();
 155    }
 156
 157    /// <summary>
 158    /// Convert a <see cref="ImageFormat"/> to a <see cref="SKEncodedImageFormat"/>.
 159    /// </summary>
 160    /// <param name="selectedFormat">The format to convert.</param>
 161    /// <returns>The converted format.</returns>
 162    public static SKEncodedImageFormat GetImageFormat(ImageFormat selectedFormat)
 163    {
 0164        return selectedFormat switch
 0165        {
 0166            ImageFormat.Bmp => SKEncodedImageFormat.Bmp,
 0167            ImageFormat.Jpg => SKEncodedImageFormat.Jpeg,
 0168            ImageFormat.Gif => SKEncodedImageFormat.Gif,
 0169            ImageFormat.Webp => SKEncodedImageFormat.Webp,
 0170            _ => SKEncodedImageFormat.Png
 0171        };
 172    }
 173
 174    /// <inheritdoc />
 175    /// <exception cref="FileNotFoundException">The path is not valid.</exception>
 176    public ImageDimensions GetImageSize(string path)
 177    {
 0178        if (!File.Exists(path))
 179        {
 0180            throw new FileNotFoundException("File not found", path);
 181        }
 182
 0183        var extension = Path.GetExtension(path.AsSpan());
 0184        if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase))
 185        {
 0186            using var svg = new SKSvg();
 187            try
 188            {
 0189                using var picture = svg.Load(path);
 0190                if (picture is null)
 191                {
 0192                    _logger.LogError("Unable to determine image dimensions for {FilePath}", path);
 0193                    return default;
 194                }
 195
 0196                return new ImageDimensions(Convert.ToInt32(picture.CullRect.Width), Convert.ToInt32(picture.CullRect.Hei
 197            }
 0198            catch (FormatException skiaColorException)
 199            {
 200                // This exception is known to be thrown on vector images that define custom styles
 201                // Skia SVG is not able to handle that and as the repository is quite stale and has not received updates
 0202                _logger.LogDebug(skiaColorException, "There was a issue loading the requested svg file");
 0203                return default;
 204            }
 205        }
 206
 0207        var safePath = NormalizePath(path);
 0208        if (new FileInfo(safePath).Length == 0)
 209        {
 0210            _logger.LogDebug("Skip zero‑byte image {FilePath}", path);
 0211            return default;
 212        }
 213
 0214        SKCodec? codec = null;
 0215        bool isSafePathTemp = !string.Equals(Path.GetFullPath(safePath), Path.GetFullPath(path), StringComparison.Ordina
 216        try
 217        {
 0218            codec = SKCodec.Create(safePath, out var result);
 0219            switch (result)
 220            {
 221                case SKCodecResult.Success:
 222                // Skia/SkiaSharp edge‑case: when the image header is parsed but the actual pixel
 223                // decode fails (truncated JPEG/PNG, exotic ICC/EXIF, CMYK without color‑transform, etc.)
 224                // `SKCodec.Create` returns a *non‑null* codec together with
 225                // SKCodecResult.InternalError.  The header still contains valid dimensions,
 226                // which is all we need here – so we fall back to them instead of aborting.
 227                // See e.g. Skia bugs #4139, #6092.
 0228                case SKCodecResult.InternalError when codec is not null:
 0229                    var info = codec.Info;
 0230                    return new ImageDimensions(info.Width, info.Height);
 231
 232                case SKCodecResult.Unimplemented:
 0233                    _logger.LogDebug("Image format not supported: {FilePath}", path);
 0234                    return default;
 235
 236                default:
 237                {
 0238                    var boundsInfo = SKBitmap.DecodeBounds(safePath);
 0239                    if (boundsInfo.Width > 0 && boundsInfo.Height > 0)
 240                    {
 0241                        return new ImageDimensions(boundsInfo.Width, boundsInfo.Height);
 242                    }
 243
 0244                    _logger.LogWarning(
 0245                        "Unable to determine image dimensions for {FilePath}: {SkCodecResult}",
 0246                        path,
 0247                        result);
 248
 0249                    return default;
 250                }
 251            }
 252        }
 253        finally
 254        {
 255            try
 256            {
 0257                codec?.Dispose();
 0258            }
 0259            catch (Exception ex)
 260            {
 0261                _logger.LogDebug(ex, "Error by closing codec for {FilePath}", safePath);
 0262            }
 263
 0264            if (isSafePathTemp)
 265            {
 266                try
 267                {
 0268                    if (File.Exists(safePath))
 269                    {
 0270                        File.Delete(safePath);
 271                    }
 0272                }
 0273                catch (Exception ex)
 274                {
 0275                    _logger.LogDebug(ex, "Unable to remove temporary file '{TempPath}'", safePath);
 0276                }
 277            }
 0278        }
 0279    }
 280
 281    /// <inheritdoc />
 282    /// <exception cref="ArgumentNullException">The path is null.</exception>
 283    /// <exception cref="FileNotFoundException">The path is not valid.</exception>
 284    public string GetImageBlurHash(int xComp, int yComp, string path)
 285    {
 0286        ArgumentException.ThrowIfNullOrEmpty(path);
 287
 0288        var extension = Path.GetExtension(path.AsSpan()).TrimStart('.');
 0289        if (!SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase)
 0290            || extension.Equals(SvgFormat, StringComparison.OrdinalIgnoreCase))
 291        {
 0292            _logger.LogDebug("Unable to compute blur hash due to unsupported format: {ImagePath}", path);
 0293            return string.Empty;
 294        }
 295
 296        // Use FileStream with FileShare.Read instead of having Skia open the file to allow concurrent read access
 0297        using var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
 298        // Any larger than 128x128 is too slow and there's no visually discernible difference
 0299        return BlurHashEncoder.Encode(xComp, yComp, fileStream, 128, 128);
 0300    }
 301
 302    private bool RequiresSpecialCharacterHack(string path)
 303    {
 0304        for (int i = 0; i < path.Length; i++)
 305        {
 0306            if (char.GetUnicodeCategory(path[i]) == UnicodeCategory.OtherLetter)
 307            {
 0308                return true;
 309            }
 310        }
 311
 0312        return path.HasDiacritics();
 313    }
 314
 315    private string NormalizePath(string path)
 316    {
 0317        if (!RequiresSpecialCharacterHack(path))
 318        {
 0319            return path;
 320        }
 321
 0322        var tempPath = Path.Join(_appPaths.TempDirectory, string.Concat("skia_", Guid.NewGuid().ToString(), Path.GetExte
 0323        var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPat
 0324        Directory.CreateDirectory(directory);
 0325        File.Copy(path, tempPath, true);
 326
 0327        return tempPath;
 328    }
 329
 330    private static SKEncodedOrigin GetSKEncodedOrigin(ImageOrientation? orientation)
 331    {
 0332        if (!orientation.HasValue)
 333        {
 0334            return SKEncodedOrigin.Default;
 335        }
 336
 0337        return (SKEncodedOrigin)orientation.Value;
 338    }
 339
 340    /// <summary>
 341    /// Decode an image.
 342    /// </summary>
 343    /// <param name="path">The filepath of the image to decode.</param>
 344    /// <param name="forceCleanBitmap">Whether to force clean the bitmap.</param>
 345    /// <param name="orientation">The orientation of the image.</param>
 346    /// <param name="origin">The detected origin of the image.</param>
 347    /// <returns>The resulting bitmap of the image.</returns>
 348    internal SKBitmap? Decode(string path, bool forceCleanBitmap, ImageOrientation? orientation, out SKEncodedOrigin ori
 349    {
 0350        if (!File.Exists(path))
 351        {
 0352            throw new FileNotFoundException("File not found", path);
 353        }
 354
 0355        var requiresTransparencyHack = _transparentImageTypes.Contains(Path.GetExtension(path));
 356
 0357        if (requiresTransparencyHack || forceCleanBitmap)
 358        {
 0359            using SKCodec codec = SKCodec.Create(NormalizePath(path), out SKCodecResult res);
 0360            if (res != SKCodecResult.Success)
 361            {
 0362                origin = GetSKEncodedOrigin(orientation);
 0363                return null;
 364            }
 365
 0366            if (codec.FrameCount != 0)
 367            {
 0368                throw new ArgumentException("Cannot decode images with multiple frames");
 369            }
 370
 371            // create the bitmap
 0372            SKBitmap? bitmap = null;
 373            try
 374            {
 0375                bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack);
 376
 377                // decode
 0378                _ = codec.GetPixels(bitmap.Info, bitmap.GetPixels());
 379
 0380                origin = codec.EncodedOrigin;
 381
 0382                return bitmap!;
 383            }
 0384            catch (Exception e)
 385            {
 0386                _logger.LogError(e, "Detected intermediary error decoding image {0}", path);
 0387                bitmap?.Dispose();
 0388                throw;
 389            }
 390        }
 391
 0392        var resultBitmap = SKBitmap.Decode(NormalizePath(path));
 393
 0394        if (resultBitmap is null)
 395        {
 0396            return Decode(path, true, orientation, out origin);
 397        }
 398
 399        try
 400        {
 401             // If we have to resize these they often end up distorted
 0402            if (resultBitmap.ColorType == SKColorType.Gray8)
 403            {
 0404                using (resultBitmap)
 405                {
 0406                    return Decode(path, true, orientation, out origin);
 407                }
 408            }
 409
 0410            origin = SKEncodedOrigin.TopLeft;
 0411            return resultBitmap;
 412        }
 0413        catch (Exception e)
 414        {
 0415            _logger.LogError(e, "Detected intermediary error decoding image {0}", path);
 0416            resultBitmap?.Dispose();
 0417            throw;
 418        }
 0419    }
 420
 421    private SKBitmap? GetBitmap(string path, bool autoOrient, ImageOrientation? orientation)
 422    {
 0423        if (autoOrient)
 424        {
 0425            var bitmap = Decode(path, true, orientation, out var origin);
 426
 0427            if (bitmap is not null && origin != SKEncodedOrigin.TopLeft)
 428            {
 0429                using (bitmap)
 430                {
 0431                    return OrientImage(bitmap, origin);
 432                }
 433            }
 434
 0435            return bitmap;
 436        }
 437
 0438        return Decode(path, false, orientation, out _);
 0439    }
 440
 441    private SKBitmap? GetBitmapFromSvg(string path)
 442    {
 0443        if (!File.Exists(path))
 444        {
 0445            throw new FileNotFoundException("File not found", path);
 446        }
 447
 0448        using var svg = SKSvg.CreateFromFile(path);
 0449        if (svg.Drawable is null)
 450        {
 0451            return null;
 452        }
 453
 0454        var width = (int)Math.Round(svg.Drawable.Bounds.Width);
 0455        var height = (int)Math.Round(svg.Drawable.Bounds.Height);
 456
 0457        SKBitmap? bitmap = null;
 458        try
 459        {
 0460            bitmap = new SKBitmap(width, height);
 0461            using var canvas = new SKCanvas(bitmap);
 0462            canvas.DrawPicture(svg.Picture);
 0463            canvas.Flush();
 0464            canvas.Save();
 465
 0466            return bitmap!;
 467        }
 0468        catch (Exception e)
 469        {
 0470            _logger.LogError(e, "Detected intermediary error extracting image {0}", path);
 0471            bitmap?.Dispose();
 0472            throw;
 473        }
 0474    }
 475
 476    private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin)
 477    {
 0478        var needsFlip = origin is SKEncodedOrigin.LeftBottom or SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightBottom o
 0479        SKBitmap? rotated = null;
 480        try
 481        {
 0482            rotated = needsFlip
 0483                ? new SKBitmap(bitmap.Height, bitmap.Width)
 0484                : new SKBitmap(bitmap.Width, bitmap.Height);
 0485            using var surface = new SKCanvas(rotated);
 0486            var midX = (float)rotated.Width / 2;
 0487            var midY = (float)rotated.Height / 2;
 488
 489            switch (origin)
 490            {
 491                case SKEncodedOrigin.TopRight:
 0492                    surface.Scale(-1, 1, midX, midY);
 0493                    break;
 494                case SKEncodedOrigin.BottomRight:
 0495                    surface.RotateDegrees(180, midX, midY);
 0496                    break;
 497                case SKEncodedOrigin.BottomLeft:
 0498                    surface.Scale(1, -1, midX, midY);
 0499                    break;
 500                case SKEncodedOrigin.LeftTop:
 0501                    surface.Translate(0, -rotated.Height);
 0502                    surface.Scale(1, -1, midX, midY);
 0503                    surface.RotateDegrees(-90);
 0504                    break;
 505                case SKEncodedOrigin.RightTop:
 0506                    surface.Translate(rotated.Width, 0);
 0507                    surface.RotateDegrees(90);
 0508                    break;
 509                case SKEncodedOrigin.RightBottom:
 0510                    surface.Translate(rotated.Width, 0);
 0511                    surface.Scale(1, -1, midX, midY);
 0512                    surface.RotateDegrees(90);
 0513                    break;
 514                case SKEncodedOrigin.LeftBottom:
 0515                    surface.Translate(0, rotated.Height);
 0516                    surface.RotateDegrees(-90);
 517                    break;
 518            }
 519
 0520            surface.DrawBitmap(bitmap, 0, 0, DefaultSamplingOptions);
 0521            return rotated;
 522        }
 0523        catch (Exception e)
 524        {
 0525            _logger.LogError(e, "Detected intermediary error rotating image");
 0526            rotated?.Dispose();
 0527            throw;
 528        }
 0529    }
 530
 531    /// <summary>
 532    /// Resizes an image on the CPU, by utilizing a surface and canvas.
 533    ///
 534    /// The convolutional matrix kernel used in this resize function gives a (light) sharpening effect.
 535    /// This technique is similar to effect that can be created using for example the [Convolution matrix filter in GIMP
 536    /// </summary>
 537    /// <param name="source">The source bitmap.</param>
 538    /// <param name="targetInfo">This specifies the target size and other information required to create the surface.</p
 539    /// <param name="isAntialias">This enables anti-aliasing on the SKPaint instance.</param>
 540    /// <param name="isDither">This enables dithering on the SKPaint instance.</param>
 541    /// <returns>The resized image.</returns>
 542    internal static SKImage ResizeImage(SKBitmap source, SKImageInfo targetInfo, bool isAntialias = false, bool isDither
 543    {
 0544        using var surface = SKSurface.Create(targetInfo);
 0545        using var canvas = surface.Canvas;
 0546        using var paint = new SKPaint();
 0547        paint.IsAntialias = isAntialias;
 0548        paint.IsDither = isDither;
 549
 550        // Historically, kHigh implied cubic filtering, but only when upsampling.
 551        // If specified kHigh, and were down-sampling, Skia used to switch back to kMedium (bilinear filtering plus mipm
 552        // With current skia API, passing Mitchell cubic when down-sampling will cause serious quality degradation.
 0553        var samplingOptions = source.Width > targetInfo.Width || source.Height > targetInfo.Height
 0554            ? DefaultSamplingOptions
 0555            : UpscaleSamplingOptions;
 556
 0557        paint.ImageFilter = _imageFilter;
 0558        canvas.DrawBitmap(
 0559            source,
 0560            SKRect.Create(0, 0, source.Width, source.Height),
 0561            SKRect.Create(0, 0, targetInfo.Width, targetInfo.Height),
 0562            samplingOptions,
 0563            paint);
 564
 0565        return surface.Snapshot();
 0566    }
 567
 568    /// <inheritdoc/>
 569    public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientat
 570    {
 0571        ArgumentException.ThrowIfNullOrEmpty(inputPath);
 0572        ArgumentException.ThrowIfNullOrEmpty(outputPath);
 573
 0574        var inputFormat = Path.GetExtension(inputPath.AsSpan()).TrimStart('.');
 0575        if (!SupportedInputFormats.Contains(inputFormat, StringComparison.OrdinalIgnoreCase))
 576        {
 0577            _logger.LogDebug("Unable to encode image due to unsupported format: {ImagePath}", inputPath);
 0578            return inputPath;
 579        }
 580
 0581        if (outputFormat == ImageFormat.Svg
 0582            && !inputFormat.Equals(SvgFormat, StringComparison.OrdinalIgnoreCase))
 583        {
 0584            throw new ArgumentException($"Requested svg output from {inputFormat} input");
 585        }
 586
 0587        var skiaOutputFormat = GetImageFormat(outputFormat);
 588
 0589        var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor);
 0590        var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer);
 0591        var blur = options.Blur ?? 0;
 0592        var hasIndicator = options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0);
 593
 0594        using var bitmap = inputFormat.Equals(SvgFormat, StringComparison.OrdinalIgnoreCase)
 0595            ? GetBitmapFromSvg(inputPath)
 0596            : GetBitmap(inputPath, autoOrient, orientation);
 597
 0598        if (bitmap is null)
 599        {
 0600            throw new InvalidDataException($"Skia unable to read image {inputPath}");
 601        }
 602
 0603        var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height);
 604
 0605        if (options.HasDefaultOptions(inputPath, originalImageSize) && !autoOrient)
 606        {
 607            // Just spit out the original file if all the options are default
 0608            return inputPath;
 609        }
 610
 0611        var newImageSize = ImageHelper.GetNewImageSize(options, originalImageSize);
 612
 0613        var width = newImageSize.Width;
 0614        var height = newImageSize.Height;
 615
 616        // scale image (the FromImage creates a copy)
 0617        var imageInfo = new SKImageInfo(width, height, bitmap.ColorType, bitmap.AlphaType, bitmap.ColorSpace);
 0618        using var resizedImage = ResizeImage(bitmap, imageInfo);
 0619        using var resizedBitmap = SKBitmap.FromImage(resizedImage);
 620
 621        // If all we're doing is resizing then we can stop now
 0622        if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator)
 623        {
 0624            var outputDirectory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({out
 0625            Directory.CreateDirectory(outputDirectory);
 0626            using var outputStream = new SKFileWStream(outputPath);
 0627            using var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels());
 0628            resizedBitmap.Encode(outputStream, skiaOutputFormat, quality);
 0629            return outputPath;
 630        }
 631
 632        // create bitmap to use for canvas drawing used to draw into bitmap
 0633        using var saveBitmap = new SKBitmap(width, height);
 0634        using var canvas = new SKCanvas(saveBitmap);
 635        // set background color if present
 0636        if (hasBackgroundColor)
 637        {
 0638            canvas.Clear(SKColor.Parse(options.BackgroundColor));
 639        }
 640
 0641        using var paint = new SKPaint();
 642        // Add blur if option is present
 0643        using var filter = blur > 0 ? SKImageFilter.CreateBlur(blur, blur) : null;
 0644        paint.ImageFilter = filter;
 645
 646        // create image from resized bitmap to apply blur
 0647        canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), DefaultSamplingOptions, paint);
 648
 649        // If foreground layer present then draw
 0650        if (hasForegroundColor)
 651        {
 0652            if (!double.TryParse(options.ForegroundLayer, out double opacity))
 653            {
 0654                opacity = .4;
 655            }
 656
 0657            canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver);
 658        }
 659
 0660        if (hasIndicator)
 661        {
 0662            DrawIndicator(canvas, width, height, options);
 663        }
 664
 0665        var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) 
 0666        Directory.CreateDirectory(directory);
 0667        using (var outputStream = new SKFileWStream(outputPath))
 668        {
 0669            using var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels());
 0670            pixmap.Encode(outputStream, skiaOutputFormat, quality);
 671        }
 672
 0673        return outputPath;
 0674    }
 675
 676    /// <inheritdoc/>
 677    public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
 678    {
 0679        double ratio = (double)options.Width / options.Height;
 680
 0681        if (ratio >= 1.4)
 682        {
 0683            new StripCollageBuilder(this).BuildThumbCollage(options.InputPaths, options.OutputPath, options.Width, optio
 684        }
 0685        else if (ratio >= .9)
 686        {
 0687            new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, opti
 688        }
 689        else
 690        {
 691            // TODO: Create Poster collage capability
 0692            new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, opti
 693        }
 0694    }
 695
 696    /// <inheritdoc />
 697    public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
 698    {
 699        // Only generate the splash screen if we have at least one poster and at least one backdrop/thumbnail.
 16700        if (posters.Count > 0 && backdrops.Count > 0)
 701        {
 0702            var splashBuilder = new SplashscreenBuilder(this, _logger);
 0703            var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
 0704            splashBuilder.GenerateSplash(posters, backdrops, outputPath);
 705        }
 16706    }
 707
 708    /// <inheritdoc />
 709    public int CreateTrickplayTile(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight)
 710    {
 0711        var paths = options.InputPaths;
 0712        var tileWidth = options.Width;
 0713        var tileHeight = options.Height;
 714
 0715        if (paths.Count < 1)
 716        {
 0717            throw new ArgumentException("InputPaths cannot be empty.");
 718        }
 0719        else if (paths.Count > tileWidth * tileHeight)
 720        {
 0721            throw new ArgumentException($"InputPaths contains more images than would fit on {tileWidth}x{tileHeight} gri
 722        }
 723
 724        // If no height provided, use height of first image.
 0725        if (!imgHeight.HasValue)
 726        {
 0727            using var firstImg = Decode(paths[0], false, null, out _);
 728
 0729            if (firstImg is null)
 730            {
 0731                throw new InvalidDataException("Could not decode image data.");
 732            }
 733
 0734            if (firstImg.Width != imgWidth)
 735            {
 0736                throw new InvalidOperationException("Image width does not match provided width.");
 737            }
 738
 0739            imgHeight = firstImg.Height;
 740        }
 741
 742        // Make horizontal strips using every provided image.
 0743        using var tileGrid = new SKBitmap(imgWidth * tileWidth, imgHeight.Value * tileHeight);
 0744        using var canvas = new SKCanvas(tileGrid);
 745
 0746        var imgIndex = 0;
 0747        for (var y = 0; y < tileHeight; y++)
 748        {
 0749            for (var x = 0; x < tileWidth; x++)
 750            {
 0751                if (imgIndex >= paths.Count)
 752                {
 753                    break;
 754                }
 755
 0756                using var img = Decode(paths[imgIndex++], false, null, out _);
 757
 0758                if (img is null)
 759                {
 0760                    throw new InvalidDataException("Could not decode image data.");
 761                }
 762
 0763                if (img.Width != imgWidth)
 764                {
 0765                    throw new InvalidOperationException("Image width does not match provided width.");
 766                }
 767
 0768                if (img.Height != imgHeight)
 769                {
 0770                    throw new InvalidOperationException("Image height does not match first image height.");
 771                }
 772
 0773                canvas.DrawBitmap(img, x * imgWidth, y * imgHeight.Value, DefaultSamplingOptions);
 774            }
 775        }
 776
 0777        using var outputStream = new SKFileWStream(options.OutputPath);
 0778        tileGrid.Encode(outputStream, SKEncodedImageFormat.Jpeg, quality);
 779
 0780        return imgHeight.Value;
 0781    }
 782
 783    private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options)
 784    {
 785        try
 786        {
 0787            var currentImageSize = new ImageDimensions(imageWidth, imageHeight);
 788
 0789            if (options.UnplayedCount.HasValue)
 790            {
 0791                UnplayedCountIndicator.DrawUnplayedCountIndicator(canvas, currentImageSize, options.UnplayedCount.Value)
 792            }
 793
 0794            if (options.PercentPlayed > 0)
 795            {
 0796                PercentPlayedDrawer.Process(canvas, currentImageSize, options.PercentPlayed);
 797            }
 0798        }
 0799        catch (Exception ex)
 800        {
 0801            _logger.LogError(ex, "Error drawing indicator overlay");
 0802        }
 0803    }
 804
 805    /// <summary>
 806    /// Return the typeface that contains the glyph for the given character.
 807    /// </summary>
 808    /// <param name="c">The text character.</param>
 809    /// <returns>The typeface contains the character.</returns>
 810    public static SKTypeface? GetFontForCharacter(string c)
 811    {
 0812        foreach (var typeface in _typefaces)
 813        {
 0814            if (typeface is not null && typeface.ContainsGlyphs(c))
 815            {
 0816                return typeface;
 817            }
 818        }
 819
 0820        return null;
 821    }
 822}

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)
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)