mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-03-30 23:11:53 +01:00
838 lines
30 KiB
C#
838 lines
30 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Globalization;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using BlurHashSharp.SkiaSharp;
|
||
using Jellyfin.Extensions;
|
||
using MediaBrowser.Common.Configuration;
|
||
using MediaBrowser.Common.Extensions;
|
||
using MediaBrowser.Controller.Drawing;
|
||
using MediaBrowser.Model.Drawing;
|
||
using Microsoft.Extensions.Logging;
|
||
using SkiaSharp;
|
||
using Svg.Skia;
|
||
|
||
namespace Jellyfin.Drawing.Skia;
|
||
|
||
/// <summary>
|
||
/// Image encoder that uses <see cref="SkiaSharp"/> to manipulate images.
|
||
/// </summary>
|
||
public class SkiaEncoder : IImageEncoder
|
||
{
|
||
private const string SvgFormat = "svg";
|
||
private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" };
|
||
private readonly ILogger<SkiaEncoder> _logger;
|
||
private readonly IApplicationPaths _appPaths;
|
||
private static readonly SKImageFilter _imageFilter;
|
||
private static readonly SKTypeface?[] _typefaces = InitializeTypefaces();
|
||
|
||
/// <summary>
|
||
/// The default sampling options, equivalent to old high quality filter settings when upscaling.
|
||
/// </summary>
|
||
public static readonly SKSamplingOptions UpscaleSamplingOptions;
|
||
|
||
/// <summary>
|
||
/// The sampling options, used for downscaling images, equivalent to old high quality filter settings when not upscaling.
|
||
/// </summary>
|
||
public static readonly SKSamplingOptions DefaultSamplingOptions;
|
||
|
||
static SkiaEncoder()
|
||
{
|
||
var kernel = new[]
|
||
{
|
||
0, -.1f, 0,
|
||
-.1f, 1.4f, -.1f,
|
||
0, -.1f, 0,
|
||
};
|
||
|
||
var kernelSize = new SKSizeI(3, 3);
|
||
var kernelOffset = new SKPointI(1, 1);
|
||
_imageFilter = SKImageFilter.CreateMatrixConvolution(
|
||
kernelSize,
|
||
kernel,
|
||
1f,
|
||
0f,
|
||
kernelOffset,
|
||
SKShaderTileMode.Clamp,
|
||
true);
|
||
|
||
// use cubic for upscaling
|
||
UpscaleSamplingOptions = new SKSamplingOptions(SKCubicResampler.Mitchell);
|
||
// use bilinear for everything else
|
||
DefaultSamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Initializes a new instance of the <see cref="SkiaEncoder"/> class.
|
||
/// </summary>
|
||
/// <param name="logger">The application logger.</param>
|
||
/// <param name="appPaths">The application paths.</param>
|
||
public SkiaEncoder(ILogger<SkiaEncoder> logger, IApplicationPaths appPaths)
|
||
{
|
||
_logger = logger;
|
||
_appPaths = appPaths;
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public string Name => "Skia";
|
||
|
||
/// <inheritdoc/>
|
||
public bool SupportsImageCollageCreation => true;
|
||
|
||
/// <inheritdoc/>
|
||
public bool SupportsImageEncoding => true;
|
||
|
||
/// <inheritdoc/>
|
||
public IReadOnlyCollection<string> SupportedInputFormats =>
|
||
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
"jpeg",
|
||
"jpg",
|
||
"png",
|
||
"dng",
|
||
"webp",
|
||
"gif",
|
||
"bmp",
|
||
"ico",
|
||
"astc",
|
||
"ktx",
|
||
"pkm",
|
||
"wbmp",
|
||
// TODO: check if these are supported on multiple platforms
|
||
// https://github.com/google/skia/blob/master/infra/bots/recipes/test.py#L454
|
||
// working on windows at least
|
||
"cr2",
|
||
"nef",
|
||
"arw",
|
||
SvgFormat
|
||
};
|
||
|
||
/// <inheritdoc/>
|
||
public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
|
||
=> new HashSet<ImageFormat> { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png, ImageFormat.Svg };
|
||
|
||
/// <summary>
|
||
/// Gets the default typeface to use.
|
||
/// </summary>
|
||
public static SKTypeface? DefaultTypeFace => _typefaces.Last();
|
||
|
||
/// <summary>
|
||
/// Check if the native lib is available.
|
||
/// </summary>
|
||
/// <returns>True if the native lib is available, otherwise false.</returns>
|
||
public static bool IsNativeLibAvailable()
|
||
{
|
||
try
|
||
{
|
||
// test an operation that requires the native library
|
||
SKPMColor.PreMultiply(SKColors.Black);
|
||
return true;
|
||
}
|
||
catch (Exception)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Initialize the list of typefaces
|
||
/// We have to statically build a list of typefaces because MatchCharacter only accepts a single character or code point
|
||
/// But in reality a human-readable character (grapheme cluster) could be multiple code points. For example, 🚵🏻♀️ is a single emoji but 5 code points (U+1F6B5 + U+1F3FB + U+200D + U+2640 + U+FE0F).
|
||
/// </summary>
|
||
/// <returns>The list of typefaces.</returns>
|
||
private static SKTypeface?[] InitializeTypefaces()
|
||
{
|
||
int[] chars = [
|
||
'鸡', // CJK Simplified Chinese
|
||
'雞', // CJK Traditional Chinese
|
||
'ノ', // CJK Japanese
|
||
'각', // CJK Korean
|
||
128169, // Emojis, 128169 is the Pile of Poo (💩) emoji
|
||
'ז', // Hebrew
|
||
'ي' // Arabic
|
||
];
|
||
var fonts = new List<SKTypeface>(chars.Length + 1);
|
||
foreach (var ch in chars)
|
||
{
|
||
var font = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, ch);
|
||
if (font is not null)
|
||
{
|
||
fonts.Add(font);
|
||
}
|
||
}
|
||
|
||
// Default font
|
||
fonts.Add(SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright)
|
||
?? SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'a'));
|
||
|
||
return fonts.ToArray();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Convert a <see cref="ImageFormat"/> to a <see cref="SKEncodedImageFormat"/>.
|
||
/// </summary>
|
||
/// <param name="selectedFormat">The format to convert.</param>
|
||
/// <returns>The converted format.</returns>
|
||
public static SKEncodedImageFormat GetImageFormat(ImageFormat selectedFormat)
|
||
{
|
||
return selectedFormat switch
|
||
{
|
||
ImageFormat.Bmp => SKEncodedImageFormat.Bmp,
|
||
ImageFormat.Jpg => SKEncodedImageFormat.Jpeg,
|
||
ImageFormat.Gif => SKEncodedImageFormat.Gif,
|
||
ImageFormat.Webp => SKEncodedImageFormat.Webp,
|
||
_ => SKEncodedImageFormat.Png
|
||
};
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
/// <exception cref="FileNotFoundException">The path is not valid.</exception>
|
||
public ImageDimensions GetImageSize(string path)
|
||
{
|
||
if (!File.Exists(path))
|
||
{
|
||
throw new FileNotFoundException("File not found", path);
|
||
}
|
||
|
||
var extension = Path.GetExtension(path.AsSpan());
|
||
if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
using var svg = new SKSvg();
|
||
try
|
||
{
|
||
using var picture = svg.Load(path);
|
||
if (picture is null)
|
||
{
|
||
_logger.LogError("Unable to determine image dimensions for {FilePath}", path);
|
||
return default;
|
||
}
|
||
|
||
return new ImageDimensions(Convert.ToInt32(picture.CullRect.Width), Convert.ToInt32(picture.CullRect.Height));
|
||
}
|
||
catch (FormatException skiaColorException)
|
||
{
|
||
// This exception is known to be thrown on vector images that define custom styles
|
||
// Skia SVG is not able to handle that and as the repository is quite stale and has not received updates we just catch them
|
||
_logger.LogDebug(skiaColorException, "There was a issue loading the requested svg file");
|
||
return default;
|
||
}
|
||
}
|
||
|
||
var safePath = NormalizePath(path);
|
||
if (new FileInfo(safePath).Length == 0)
|
||
{
|
||
_logger.LogDebug("Skip zero‑byte image {FilePath}", path);
|
||
return default;
|
||
}
|
||
|
||
SKCodec? codec = null;
|
||
bool isSafePathTemp = !string.Equals(Path.GetFullPath(safePath), Path.GetFullPath(path), StringComparison.OrdinalIgnoreCase);
|
||
try
|
||
{
|
||
codec = SKCodec.Create(safePath, out var result);
|
||
switch (result)
|
||
{
|
||
case SKCodecResult.Success:
|
||
// Skia/SkiaSharp edge‑case: when the image header is parsed but the actual pixel
|
||
// decode fails (truncated JPEG/PNG, exotic ICC/EXIF, CMYK without color‑transform, etc.)
|
||
// `SKCodec.Create` returns a *non‑null* codec together with
|
||
// SKCodecResult.InternalError. The header still contains valid dimensions,
|
||
// which is all we need here – so we fall back to them instead of aborting.
|
||
// See e.g. Skia bugs #4139, #6092.
|
||
case SKCodecResult.InternalError when codec is not null:
|
||
var info = codec.Info;
|
||
return new ImageDimensions(info.Width, info.Height);
|
||
|
||
case SKCodecResult.Unimplemented:
|
||
_logger.LogDebug("Image format not supported: {FilePath}", path);
|
||
return default;
|
||
|
||
default:
|
||
{
|
||
var boundsInfo = SKBitmap.DecodeBounds(safePath);
|
||
if (boundsInfo.Width > 0 && boundsInfo.Height > 0)
|
||
{
|
||
return new ImageDimensions(boundsInfo.Width, boundsInfo.Height);
|
||
}
|
||
|
||
_logger.LogWarning(
|
||
"Unable to determine image dimensions for {FilePath}: {SkCodecResult}",
|
||
path,
|
||
result);
|
||
|
||
return default;
|
||
}
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
try
|
||
{
|
||
codec?.Dispose();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogDebug(ex, "Error by closing codec for {FilePath}", safePath);
|
||
}
|
||
|
||
if (isSafePathTemp)
|
||
{
|
||
try
|
||
{
|
||
if (File.Exists(safePath))
|
||
{
|
||
File.Delete(safePath);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogDebug(ex, "Unable to remove temporary file '{TempPath}'", safePath);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
/// <exception cref="ArgumentNullException">The path is null.</exception>
|
||
/// <exception cref="FileNotFoundException">The path is not valid.</exception>
|
||
public string GetImageBlurHash(int xComp, int yComp, string path)
|
||
{
|
||
ArgumentException.ThrowIfNullOrEmpty(path);
|
||
|
||
var extension = Path.GetExtension(path.AsSpan()).TrimStart('.');
|
||
if (!SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase)
|
||
|| extension.Equals(SvgFormat, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
_logger.LogDebug("Unable to compute blur hash due to unsupported format: {ImagePath}", path);
|
||
return string.Empty;
|
||
}
|
||
|
||
// Use FileStream with FileShare.Read instead of having Skia open the file to allow concurrent read access
|
||
using var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||
// Any larger than 128x128 is too slow and there's no visually discernible difference
|
||
return BlurHashEncoder.Encode(xComp, yComp, fileStream, 128, 128);
|
||
}
|
||
|
||
private bool RequiresSpecialCharacterHack(string path)
|
||
{
|
||
for (int i = 0; i < path.Length; i++)
|
||
{
|
||
if (char.GetUnicodeCategory(path[i]) == UnicodeCategory.OtherLetter)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return path.HasDiacritics();
|
||
}
|
||
|
||
private string NormalizePath(string path)
|
||
{
|
||
if (!RequiresSpecialCharacterHack(path))
|
||
{
|
||
return path;
|
||
}
|
||
|
||
var tempPath = Path.Join(_appPaths.TempDirectory, string.Concat("skia_", Guid.NewGuid().ToString(), Path.GetExtension(path.AsSpan())));
|
||
var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPath}) is not valid.");
|
||
Directory.CreateDirectory(directory);
|
||
File.Copy(path, tempPath, true);
|
||
|
||
return tempPath;
|
||
}
|
||
|
||
private static SKEncodedOrigin GetSKEncodedOrigin(ImageOrientation? orientation)
|
||
{
|
||
if (!orientation.HasValue)
|
||
{
|
||
return SKEncodedOrigin.Default;
|
||
}
|
||
|
||
return (SKEncodedOrigin)orientation.Value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Decode an image.
|
||
/// </summary>
|
||
/// <param name="path">The filepath of the image to decode.</param>
|
||
/// <param name="forceCleanBitmap">Whether to force clean the bitmap.</param>
|
||
/// <param name="orientation">The orientation of the image.</param>
|
||
/// <param name="origin">The detected origin of the image.</param>
|
||
/// <returns>The resulting bitmap of the image.</returns>
|
||
internal SKBitmap? Decode(string path, bool forceCleanBitmap, ImageOrientation? orientation, out SKEncodedOrigin origin)
|
||
{
|
||
if (!File.Exists(path))
|
||
{
|
||
throw new FileNotFoundException("File not found", path);
|
||
}
|
||
|
||
var requiresTransparencyHack = _transparentImageTypes.Contains(Path.GetExtension(path));
|
||
|
||
if (requiresTransparencyHack || forceCleanBitmap)
|
||
{
|
||
using SKCodec codec = SKCodec.Create(NormalizePath(path), out SKCodecResult res);
|
||
if (res != SKCodecResult.Success)
|
||
{
|
||
origin = GetSKEncodedOrigin(orientation);
|
||
return null;
|
||
}
|
||
|
||
if (codec.FrameCount != 0)
|
||
{
|
||
throw new ArgumentException("Cannot decode images with multiple frames");
|
||
}
|
||
|
||
// create the bitmap
|
||
SKBitmap? bitmap = null;
|
||
try
|
||
{
|
||
bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack);
|
||
|
||
// decode
|
||
_ = codec.GetPixels(bitmap.Info, bitmap.GetPixels());
|
||
|
||
origin = codec.EncodedOrigin;
|
||
|
||
return bitmap!;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
_logger.LogError(e, "Detected intermediary error decoding image {0}", path);
|
||
bitmap?.Dispose();
|
||
throw;
|
||
}
|
||
}
|
||
|
||
var resultBitmap = SKBitmap.Decode(NormalizePath(path));
|
||
|
||
if (resultBitmap is null)
|
||
{
|
||
return Decode(path, true, orientation, out origin);
|
||
}
|
||
|
||
try
|
||
{
|
||
// If we have to resize these they often end up distorted
|
||
if (resultBitmap.ColorType == SKColorType.Gray8)
|
||
{
|
||
using (resultBitmap)
|
||
{
|
||
return Decode(path, true, orientation, out origin);
|
||
}
|
||
}
|
||
|
||
origin = SKEncodedOrigin.TopLeft;
|
||
return resultBitmap;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
_logger.LogError(e, "Detected intermediary error decoding image {0}", path);
|
||
resultBitmap?.Dispose();
|
||
throw;
|
||
}
|
||
}
|
||
|
||
private SKBitmap? GetBitmap(string path, bool autoOrient, ImageOrientation? orientation)
|
||
{
|
||
if (autoOrient)
|
||
{
|
||
var bitmap = Decode(path, true, orientation, out var origin);
|
||
|
||
if (bitmap is not null && origin != SKEncodedOrigin.TopLeft)
|
||
{
|
||
using (bitmap)
|
||
{
|
||
return OrientImage(bitmap, origin);
|
||
}
|
||
}
|
||
|
||
return bitmap;
|
||
}
|
||
|
||
return Decode(path, false, orientation, out _);
|
||
}
|
||
|
||
private SKBitmap? GetBitmapFromSvg(string path)
|
||
{
|
||
if (!File.Exists(path))
|
||
{
|
||
throw new FileNotFoundException("File not found", path);
|
||
}
|
||
|
||
using var svg = SKSvg.CreateFromFile(path);
|
||
if (svg.Drawable is null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var width = (int)Math.Round(svg.Drawable.Bounds.Width);
|
||
var height = (int)Math.Round(svg.Drawable.Bounds.Height);
|
||
|
||
SKBitmap? bitmap = null;
|
||
try
|
||
{
|
||
bitmap = new SKBitmap(width, height);
|
||
using var canvas = new SKCanvas(bitmap);
|
||
canvas.DrawPicture(svg.Picture);
|
||
canvas.Flush();
|
||
canvas.Save();
|
||
|
||
return bitmap!;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
_logger.LogError(e, "Detected intermediary error extracting image {0}", path);
|
||
bitmap?.Dispose();
|
||
throw;
|
||
}
|
||
}
|
||
|
||
private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin)
|
||
{
|
||
var needsFlip = origin is SKEncodedOrigin.LeftBottom or SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightBottom or SKEncodedOrigin.RightTop;
|
||
SKBitmap? rotated = null;
|
||
try
|
||
{
|
||
rotated = needsFlip
|
||
? new SKBitmap(bitmap.Height, bitmap.Width)
|
||
: new SKBitmap(bitmap.Width, bitmap.Height);
|
||
using var surface = new SKCanvas(rotated);
|
||
var midX = (float)rotated.Width / 2;
|
||
var midY = (float)rotated.Height / 2;
|
||
|
||
switch (origin)
|
||
{
|
||
case SKEncodedOrigin.TopRight:
|
||
surface.Scale(-1, 1, midX, midY);
|
||
break;
|
||
case SKEncodedOrigin.BottomRight:
|
||
surface.RotateDegrees(180, midX, midY);
|
||
break;
|
||
case SKEncodedOrigin.BottomLeft:
|
||
surface.Scale(1, -1, midX, midY);
|
||
break;
|
||
case SKEncodedOrigin.LeftTop:
|
||
surface.Translate(0, -rotated.Height);
|
||
surface.Scale(1, -1, midX, midY);
|
||
surface.RotateDegrees(-90);
|
||
break;
|
||
case SKEncodedOrigin.RightTop:
|
||
surface.Translate(rotated.Width, 0);
|
||
surface.RotateDegrees(90);
|
||
break;
|
||
case SKEncodedOrigin.RightBottom:
|
||
surface.Translate(rotated.Width, 0);
|
||
surface.Scale(1, -1, midX, midY);
|
||
surface.RotateDegrees(90);
|
||
break;
|
||
case SKEncodedOrigin.LeftBottom:
|
||
surface.Translate(0, rotated.Height);
|
||
surface.RotateDegrees(-90);
|
||
break;
|
||
}
|
||
|
||
surface.DrawBitmap(bitmap, 0, 0, DefaultSamplingOptions);
|
||
return rotated;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
_logger.LogError(e, "Detected intermediary error rotating image");
|
||
rotated?.Dispose();
|
||
throw;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resizes an image on the CPU, by utilizing a surface and canvas.
|
||
///
|
||
/// The convolutional matrix kernel used in this resize function gives a (light) sharpening effect.
|
||
/// This technique is similar to effect that can be created using for example the [Convolution matrix filter in GIMP](https://docs.gimp.org/2.10/en/gimp-filter-convolution-matrix.html).
|
||
/// </summary>
|
||
/// <param name="source">The source bitmap.</param>
|
||
/// <param name="targetInfo">This specifies the target size and other information required to create the surface.</param>
|
||
/// <param name="isAntialias">This enables anti-aliasing on the SKPaint instance.</param>
|
||
/// <param name="isDither">This enables dithering on the SKPaint instance.</param>
|
||
/// <returns>The resized image.</returns>
|
||
internal static SKImage ResizeImage(SKBitmap source, SKImageInfo targetInfo, bool isAntialias = false, bool isDither = false)
|
||
{
|
||
using var surface = SKSurface.Create(targetInfo);
|
||
using var canvas = surface.Canvas;
|
||
using var paint = new SKPaint();
|
||
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();
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat)
|
||
{
|
||
ArgumentException.ThrowIfNullOrEmpty(inputPath);
|
||
ArgumentException.ThrowIfNullOrEmpty(outputPath);
|
||
|
||
var inputFormat = Path.GetExtension(inputPath.AsSpan()).TrimStart('.');
|
||
if (!SupportedInputFormats.Contains(inputFormat, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
_logger.LogDebug("Unable to encode image due to unsupported format: {ImagePath}", inputPath);
|
||
return inputPath;
|
||
}
|
||
|
||
if (outputFormat == ImageFormat.Svg
|
||
&& !inputFormat.Equals(SvgFormat, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
throw new ArgumentException($"Requested svg output from {inputFormat} input");
|
||
}
|
||
|
||
var skiaOutputFormat = GetImageFormat(outputFormat);
|
||
|
||
var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor);
|
||
var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer);
|
||
var blur = options.Blur ?? 0;
|
||
var hasIndicator = options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0);
|
||
|
||
using var bitmap = inputFormat.Equals(SvgFormat, StringComparison.OrdinalIgnoreCase)
|
||
? GetBitmapFromSvg(inputPath)
|
||
: GetBitmap(inputPath, autoOrient, orientation);
|
||
|
||
if (bitmap is null)
|
||
{
|
||
throw new InvalidDataException($"Skia unable to read image {inputPath}");
|
||
}
|
||
|
||
var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height);
|
||
|
||
if (options.HasDefaultOptions(inputPath, originalImageSize) && !autoOrient)
|
||
{
|
||
// Just spit out the original file if all the options are default
|
||
return inputPath;
|
||
}
|
||
|
||
var newImageSize = ImageHelper.GetNewImageSize(options, originalImageSize);
|
||
|
||
var width = newImageSize.Width;
|
||
var height = newImageSize.Height;
|
||
|
||
// scale image (the FromImage creates a copy)
|
||
var imageInfo = new SKImageInfo(width, height, bitmap.ColorType, bitmap.AlphaType, bitmap.ColorSpace);
|
||
using var resizedImage = ResizeImage(bitmap, imageInfo);
|
||
using var resizedBitmap = SKBitmap.FromImage(resizedImage);
|
||
|
||
// If all we're doing is resizing then we can stop now
|
||
if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator)
|
||
{
|
||
var outputDirectory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
|
||
Directory.CreateDirectory(outputDirectory);
|
||
using var outputStream = new SKFileWStream(outputPath);
|
||
using var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels());
|
||
resizedBitmap.Encode(outputStream, skiaOutputFormat, quality);
|
||
return outputPath;
|
||
}
|
||
|
||
// create bitmap to use for canvas drawing used to draw into bitmap
|
||
using var saveBitmap = new SKBitmap(width, height);
|
||
using var canvas = new SKCanvas(saveBitmap);
|
||
// set background color if present
|
||
if (hasBackgroundColor)
|
||
{
|
||
canvas.Clear(SKColor.Parse(options.BackgroundColor));
|
||
}
|
||
|
||
using var paint = new SKPaint();
|
||
// Add blur if option is present
|
||
using var filter = blur > 0 ? SKImageFilter.CreateBlur(blur, blur) : null;
|
||
paint.ImageFilter = filter;
|
||
|
||
// create image from resized bitmap to apply blur
|
||
canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), DefaultSamplingOptions, paint);
|
||
|
||
// If foreground layer present then draw
|
||
if (hasForegroundColor)
|
||
{
|
||
if (!double.TryParse(options.ForegroundLayer, out double opacity))
|
||
{
|
||
opacity = .4;
|
||
}
|
||
|
||
canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver);
|
||
}
|
||
|
||
if (hasIndicator)
|
||
{
|
||
DrawIndicator(canvas, width, height, options);
|
||
}
|
||
|
||
var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
|
||
Directory.CreateDirectory(directory);
|
||
using (var outputStream = new SKFileWStream(outputPath))
|
||
{
|
||
using var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels());
|
||
pixmap.Encode(outputStream, skiaOutputFormat, quality);
|
||
}
|
||
|
||
return outputPath;
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
|
||
{
|
||
double ratio = (double)options.Width / options.Height;
|
||
|
||
if (ratio >= 1.4)
|
||
{
|
||
new StripCollageBuilder(this).BuildThumbCollage(options.InputPaths, options.OutputPath, options.Width, options.Height, libraryName);
|
||
}
|
||
else if (ratio >= .9)
|
||
{
|
||
new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height);
|
||
}
|
||
else
|
||
{
|
||
// TODO: Create Poster collage capability
|
||
new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height);
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
|
||
{
|
||
// Only generate the splash screen if we have at least one poster and at least one backdrop/thumbnail.
|
||
if (posters.Count > 0 && backdrops.Count > 0)
|
||
{
|
||
var splashBuilder = new SplashscreenBuilder(this, _logger);
|
||
var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
|
||
splashBuilder.GenerateSplash(posters, backdrops, outputPath);
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public int CreateTrickplayTile(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight)
|
||
{
|
||
var paths = options.InputPaths;
|
||
var tileWidth = options.Width;
|
||
var tileHeight = options.Height;
|
||
|
||
if (paths.Count < 1)
|
||
{
|
||
throw new ArgumentException("InputPaths cannot be empty.");
|
||
}
|
||
else if (paths.Count > tileWidth * tileHeight)
|
||
{
|
||
throw new ArgumentException($"InputPaths contains more images than would fit on {tileWidth}x{tileHeight} grid.");
|
||
}
|
||
|
||
// If no height provided, use height of first image.
|
||
if (!imgHeight.HasValue)
|
||
{
|
||
using var firstImg = Decode(paths[0], false, null, out _);
|
||
|
||
if (firstImg is null)
|
||
{
|
||
throw new InvalidDataException("Could not decode image data.");
|
||
}
|
||
|
||
if (firstImg.Width != imgWidth)
|
||
{
|
||
throw new InvalidOperationException("Image width does not match provided width.");
|
||
}
|
||
|
||
imgHeight = firstImg.Height;
|
||
}
|
||
|
||
// Make horizontal strips using every provided image.
|
||
using var tileGrid = new SKBitmap(imgWidth * tileWidth, imgHeight.Value * tileHeight);
|
||
using var canvas = new SKCanvas(tileGrid);
|
||
|
||
var imgIndex = 0;
|
||
for (var y = 0; y < tileHeight; y++)
|
||
{
|
||
for (var x = 0; x < tileWidth; x++)
|
||
{
|
||
if (imgIndex >= paths.Count)
|
||
{
|
||
break;
|
||
}
|
||
|
||
using var img = Decode(paths[imgIndex++], false, null, out _);
|
||
|
||
if (img is null)
|
||
{
|
||
throw new InvalidDataException("Could not decode image data.");
|
||
}
|
||
|
||
if (img.Width != imgWidth)
|
||
{
|
||
throw new InvalidOperationException("Image width does not match provided width.");
|
||
}
|
||
|
||
if (img.Height != imgHeight)
|
||
{
|
||
throw new InvalidOperationException("Image height does not match first image height.");
|
||
}
|
||
|
||
canvas.DrawBitmap(img, x * imgWidth, y * imgHeight.Value, DefaultSamplingOptions);
|
||
}
|
||
}
|
||
|
||
using var outputStream = new SKFileWStream(options.OutputPath);
|
||
tileGrid.Encode(outputStream, SKEncodedImageFormat.Jpeg, quality);
|
||
|
||
return imgHeight.Value;
|
||
}
|
||
|
||
private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options)
|
||
{
|
||
try
|
||
{
|
||
var currentImageSize = new ImageDimensions(imageWidth, imageHeight);
|
||
|
||
if (options.UnplayedCount.HasValue)
|
||
{
|
||
UnplayedCountIndicator.DrawUnplayedCountIndicator(canvas, currentImageSize, options.UnplayedCount.Value);
|
||
}
|
||
|
||
if (options.PercentPlayed > 0)
|
||
{
|
||
PercentPlayedDrawer.Process(canvas, currentImageSize, options.PercentPlayed);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error drawing indicator overlay");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Return the typeface that contains the glyph for the given character.
|
||
/// </summary>
|
||
/// <param name="c">The text character.</param>
|
||
/// <returns>The typeface contains the character.</returns>
|
||
public static SKTypeface? GetFontForCharacter(string c)
|
||
{
|
||
foreach (var typeface in _typefaces)
|
||
{
|
||
if (typeface is not null && typeface.ContainsGlyphs(c))
|
||
{
|
||
return typeface;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|