From ce11869a1acf8b31316b41c28f4bf7acb1a08959 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Sat, 26 Jan 2019 20:43:13 +0100 Subject: Move Skia back into it's own project --- Emby.Drawing/Emby.Drawing.csproj | 6 - Emby.Drawing/PercentPlayedDrawer.cs | 31 - Emby.Drawing/PlayedIndicatorDrawer.cs | 44 -- Emby.Drawing/SkiaEncoder.cs | 653 --------------------- Emby.Drawing/StripCollageBuilder.cs | 234 -------- Emby.Drawing/UnplayedCountIndicator.cs | 51 -- Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj | 24 + Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs | 31 + Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs | 44 ++ Jellyfin.Drawing.Skia/SkiaEncoder.cs | 653 +++++++++++++++++++++ Jellyfin.Drawing.Skia/StripCollageBuilder.cs | 227 +++++++ Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs | 51 ++ Jellyfin.Server/Jellyfin.Server.csproj | 1 + Jellyfin.Server/Program.cs | 1 + MediaBrowser.sln | 8 +- 15 files changed, 1039 insertions(+), 1020 deletions(-) delete mode 100644 Emby.Drawing/PercentPlayedDrawer.cs delete mode 100644 Emby.Drawing/PlayedIndicatorDrawer.cs delete mode 100644 Emby.Drawing/SkiaEncoder.cs delete mode 100644 Emby.Drawing/StripCollageBuilder.cs delete mode 100644 Emby.Drawing/UnplayedCountIndicator.cs create mode 100644 Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj create mode 100644 Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs create mode 100644 Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs create mode 100644 Jellyfin.Drawing.Skia/SkiaEncoder.cs create mode 100644 Jellyfin.Drawing.Skia/StripCollageBuilder.cs create mode 100644 Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs diff --git a/Emby.Drawing/Emby.Drawing.csproj b/Emby.Drawing/Emby.Drawing.csproj index c36d42194f..9f97baf772 100644 --- a/Emby.Drawing/Emby.Drawing.csproj +++ b/Emby.Drawing/Emby.Drawing.csproj @@ -5,12 +5,6 @@ false - - - - - - diff --git a/Emby.Drawing/PercentPlayedDrawer.cs b/Emby.Drawing/PercentPlayedDrawer.cs deleted file mode 100644 index 3ce46bc128..0000000000 --- a/Emby.Drawing/PercentPlayedDrawer.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using MediaBrowser.Model.Drawing; -using SkiaSharp; - -namespace Emby.Drawing -{ - public static class PercentPlayedDrawer - { - private const int IndicatorHeight = 8; - - public static void Process(SKCanvas canvas, ImageDimensions imageSize, double percent) - { - using (var paint = new SKPaint()) - { - var endX = imageSize.Width - 1; - var endY = imageSize.Height - 1; - - paint.Color = SKColor.Parse("#99000000"); - paint.Style = SKPaintStyle.Fill; - canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, (float)endX, (float)endY), paint); - - double foregroundWidth = endX; - foregroundWidth *= percent; - foregroundWidth /= 100; - - paint.Color = SKColor.Parse("#FF52B54B"); - canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, Convert.ToInt32(foregroundWidth), (float)endY), paint); - } - } - } -} diff --git a/Emby.Drawing/PlayedIndicatorDrawer.cs b/Emby.Drawing/PlayedIndicatorDrawer.cs deleted file mode 100644 index 38b5edc928..0000000000 --- a/Emby.Drawing/PlayedIndicatorDrawer.cs +++ /dev/null @@ -1,44 +0,0 @@ -using MediaBrowser.Model.Drawing; -using SkiaSharp; - -namespace Emby.Drawing -{ - public static class PlayedIndicatorDrawer - { - private const int OffsetFromTopRightCorner = 38; - - public static void DrawPlayedIndicator(SKCanvas canvas, ImageDimensions imageSize) - { - var x = imageSize.Width - OffsetFromTopRightCorner; - - using (var paint = new SKPaint()) - { - paint.Color = SKColor.Parse("#CC52B54B"); - paint.Style = SKPaintStyle.Fill; - canvas.DrawCircle((float)x, OffsetFromTopRightCorner, 20, paint); - } - - using (var paint = new SKPaint()) - { - paint.Color = new SKColor(255, 255, 255, 255); - paint.Style = SKPaintStyle.Fill; - - paint.TextSize = 30; - paint.IsAntialias = true; - - var text = "✔️"; - var emojiChar = StringUtilities.GetUnicodeCharacterCode(text, SKTextEncoding.Utf32); - // or: - //var emojiChar = 0x1F680; - - // ask the font manager for a font with that character - var fontManager = SKFontManager.Default; - var emojiTypeface = fontManager.MatchCharacter(emojiChar); - - paint.Typeface = emojiTypeface; - - canvas.DrawText(text, (float)x - 20, OffsetFromTopRightCorner + 12, paint); - } - } - } -} diff --git a/Emby.Drawing/SkiaEncoder.cs b/Emby.Drawing/SkiaEncoder.cs deleted file mode 100644 index aae10f6cc8..0000000000 --- a/Emby.Drawing/SkiaEncoder.cs +++ /dev/null @@ -1,653 +0,0 @@ -using System; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Reflection; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.Drawing; -using MediaBrowser.Controller.Extensions; -using MediaBrowser.Model.Drawing; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.IO; -using Microsoft.Extensions.Logging; -using SkiaSharp; - -namespace Emby.Drawing -{ - public class SkiaEncoder : IImageEncoder - { - private readonly ILogger _logger; - private static IApplicationPaths _appPaths; - private readonly IFileSystem _fileSystem; - private static ILocalizationManager _localizationManager; - - public SkiaEncoder( - ILoggerFactory loggerFactory, - IApplicationPaths appPaths, - IFileSystem fileSystem, - ILocalizationManager localizationManager) - { - _logger = loggerFactory.CreateLogger("ImageEncoder"); - _appPaths = appPaths; - _fileSystem = fileSystem; - _localizationManager = localizationManager; - - LogVersion(); - } - - public string[] SupportedInputFormats => - new[] - { - "jpeg", - "jpg", - "png", - - "dng", - - "webp", - "gif", - "bmp", - "ico", - "astc", - "ktx", - "pkm", - "wbmp", - - // TODO - // Are all of these supported? https://github.com/google/skia/blob/master/infra/bots/recipes/test.py#L454 - - // working on windows at least - "cr2", - "nef", - "arw" - }; - - public ImageFormat[] SupportedOutputFormats => new[] { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png }; - - private void LogVersion() - { - // test an operation that requires the native library - SKPMColor.PreMultiply(SKColors.Black); - - _logger.LogInformation("SkiaSharp version: " + GetVersion()); - } - - public static string GetVersion() - { - return typeof(SKBitmap).GetTypeInfo().Assembly.GetName().Version.ToString(); - } - - private static bool IsTransparent(SKColor color) - { - - return (color.Red == 255 && color.Green == 255 && color.Blue == 255) || color.Alpha == 0; - } - - public static SKEncodedImageFormat GetImageFormat(ImageFormat selectedFormat) - { - switch (selectedFormat) - { - case ImageFormat.Bmp: - return SKEncodedImageFormat.Bmp; - case ImageFormat.Jpg: - return SKEncodedImageFormat.Jpeg; - case ImageFormat.Gif: - return SKEncodedImageFormat.Gif; - case ImageFormat.Webp: - return SKEncodedImageFormat.Webp; - default: - return SKEncodedImageFormat.Png; - } - } - - private static bool IsTransparentRow(SKBitmap bmp, int row) - { - for (var i = 0; i < bmp.Width; ++i) - { - if (!IsTransparent(bmp.GetPixel(i, row))) - { - return false; - } - } - return true; - } - - private static bool IsTransparentColumn(SKBitmap bmp, int col) - { - for (var i = 0; i < bmp.Height; ++i) - { - if (!IsTransparent(bmp.GetPixel(col, i))) - { - return false; - } - } - return true; - } - - private SKBitmap CropWhiteSpace(SKBitmap bitmap) - { - var topmost = 0; - for (int row = 0; row < bitmap.Height; ++row) - { - if (IsTransparentRow(bitmap, row)) - topmost = row + 1; - else break; - } - - int bottommost = bitmap.Height; - for (int row = bitmap.Height - 1; row >= 0; --row) - { - if (IsTransparentRow(bitmap, row)) - bottommost = row; - else break; - } - - int leftmost = 0, rightmost = bitmap.Width; - for (int col = 0; col < bitmap.Width; ++col) - { - if (IsTransparentColumn(bitmap, col)) - leftmost = col + 1; - else - break; - } - - for (int col = bitmap.Width - 1; col >= 0; --col) - { - if (IsTransparentColumn(bitmap, col)) - rightmost = col; - else - break; - } - - var newRect = SKRectI.Create(leftmost, topmost, rightmost - leftmost, bottommost - topmost); - - using (var image = SKImage.FromBitmap(bitmap)) - using (var subset = image.Subset(newRect)) - { - return SKBitmap.FromImage(subset); - } - } - - public ImageDimensions GetImageSize(string path) - { - using (var s = new SKFileStream(path)) - using (var codec = SKCodec.Create(s)) - { - var info = codec.Info; - - return new ImageDimensions(info.Width, info.Height); - } - } - - private static bool HasDiacritics(string text) - { - return !string.Equals(text, text.RemoveDiacritics(), StringComparison.Ordinal); - } - - private static bool RequiresSpecialCharacterHack(string path) - { - if (_localizationManager.HasUnicodeCategory(path, UnicodeCategory.OtherLetter)) - { - return true; - } - - if (HasDiacritics(path)) - { - return true; - } - - return false; - } - - private static string NormalizePath(string path, IFileSystem fileSystem) - { - if (!RequiresSpecialCharacterHack(path)) - { - return path; - } - - var tempPath = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid() + Path.GetExtension(path) ?? string.Empty); - - fileSystem.CreateDirectory(fileSystem.GetDirectoryName(tempPath)); - fileSystem.CopyFile(path, tempPath, true); - - return tempPath; - } - - private static SKEncodedOrigin GetSKEncodedOrigin(ImageOrientation? orientation) - { - if (!orientation.HasValue) - { - return SKEncodedOrigin.TopLeft; - } - - switch (orientation.Value) - { - case ImageOrientation.TopRight: - return SKEncodedOrigin.TopRight; - case ImageOrientation.RightTop: - return SKEncodedOrigin.RightTop; - case ImageOrientation.RightBottom: - return SKEncodedOrigin.RightBottom; - case ImageOrientation.LeftTop: - return SKEncodedOrigin.LeftTop; - case ImageOrientation.LeftBottom: - return SKEncodedOrigin.LeftBottom; - case ImageOrientation.BottomRight: - return SKEncodedOrigin.BottomRight; - case ImageOrientation.BottomLeft: - return SKEncodedOrigin.BottomLeft; - default: - return SKEncodedOrigin.TopLeft; - } - } - - private static string[] TransparentImageTypes = new string[] { ".png", ".gif", ".webp" }; - internal static SKBitmap Decode(string path, bool forceCleanBitmap, IFileSystem fileSystem, ImageOrientation? orientation, out SKEncodedOrigin origin) - { - if (!fileSystem.FileExists(path)) - { - throw new FileNotFoundException("File not found", path); - } - - var requiresTransparencyHack = TransparentImageTypes.Contains(Path.GetExtension(path) ?? string.Empty); - - if (requiresTransparencyHack || forceCleanBitmap) - { - using (var stream = new SKFileStream(NormalizePath(path, fileSystem))) - using (var codec = SKCodec.Create(stream)) - { - if (codec == null) - { - origin = GetSKEncodedOrigin(orientation); - return null; - } - - // create the bitmap - var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack); - - // decode - codec.GetPixels(bitmap.Info, bitmap.GetPixels()); - - origin = codec.EncodedOrigin; - - return bitmap; - } - } - - var resultBitmap = SKBitmap.Decode(NormalizePath(path, fileSystem)); - - if (resultBitmap == null) - { - return Decode(path, true, fileSystem, orientation, out origin); - } - - // If we have to resize these they often end up distorted - if (resultBitmap.ColorType == SKColorType.Gray8) - { - using (resultBitmap) - { - return Decode(path, true, fileSystem, orientation, out origin); - } - } - - origin = SKEncodedOrigin.TopLeft; - return resultBitmap; - } - - private SKBitmap GetBitmap(string path, bool cropWhitespace, bool forceAnalyzeBitmap, ImageOrientation? orientation, out SKEncodedOrigin origin) - { - if (cropWhitespace) - { - using (var bitmap = Decode(path, forceAnalyzeBitmap, _fileSystem, orientation, out origin)) - { - return CropWhiteSpace(bitmap); - } - } - - return Decode(path, forceAnalyzeBitmap, _fileSystem, orientation, out origin); - } - - private SKBitmap GetBitmap(string path, bool cropWhitespace, bool autoOrient, ImageOrientation? orientation) - { - SKEncodedOrigin origin; - - if (autoOrient) - { - var bitmap = GetBitmap(path, cropWhitespace, true, orientation, out origin); - - if (bitmap != null) - { - if (origin != SKEncodedOrigin.TopLeft) - { - using (bitmap) - { - return OrientImage(bitmap, origin); - } - } - } - - return bitmap; - } - - return GetBitmap(path, cropWhitespace, false, orientation, out origin); - } - - private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin) - { - //var transformations = { - // 2: { rotate: 0, flip: true}, - // 3: { rotate: 180, flip: false}, - // 4: { rotate: 180, flip: true}, - // 5: { rotate: 90, flip: true}, - // 6: { rotate: 90, flip: false}, - // 7: { rotate: 270, flip: true}, - // 8: { rotate: 270, flip: false}, - //} - - switch (origin) - { - - case SKEncodedOrigin.TopRight: - { - var rotated = new SKBitmap(bitmap.Width, bitmap.Height); - using (var surface = new SKCanvas(rotated)) - { - surface.Translate(rotated.Width, 0); - surface.Scale(-1, 1); - surface.DrawBitmap(bitmap, 0, 0); - } - - return rotated; - } - - case SKEncodedOrigin.BottomRight: - { - var rotated = new SKBitmap(bitmap.Width, bitmap.Height); - using (var surface = new SKCanvas(rotated)) - { - float px = bitmap.Width; - px /= 2; - - float py = bitmap.Height; - py /= 2; - - surface.RotateDegrees(180, px, py); - surface.DrawBitmap(bitmap, 0, 0); - } - - return rotated; - } - - case SKEncodedOrigin.BottomLeft: - { - var rotated = new SKBitmap(bitmap.Width, bitmap.Height); - using (var surface = new SKCanvas(rotated)) - { - float px = bitmap.Width; - px /= 2; - - float py = bitmap.Height; - py /= 2; - - surface.Translate(rotated.Width, 0); - surface.Scale(-1, 1); - - surface.RotateDegrees(180, px, py); - surface.DrawBitmap(bitmap, 0, 0); - } - - return rotated; - } - - case SKEncodedOrigin.LeftTop: - { - // TODO: Remove dual canvases, had trouble with flipping - using (var rotated = new SKBitmap(bitmap.Height, bitmap.Width)) - { - using (var surface = new SKCanvas(rotated)) - { - surface.Translate(rotated.Width, 0); - - surface.RotateDegrees(90); - - surface.DrawBitmap(bitmap, 0, 0); - - } - - var flippedBitmap = new SKBitmap(rotated.Width, rotated.Height); - using (var flippedCanvas = new SKCanvas(flippedBitmap)) - { - flippedCanvas.Translate(flippedBitmap.Width, 0); - flippedCanvas.Scale(-1, 1); - flippedCanvas.DrawBitmap(rotated, 0, 0); - } - - return flippedBitmap; - } - } - - case SKEncodedOrigin.RightTop: - { - var rotated = new SKBitmap(bitmap.Height, bitmap.Width); - using (var surface = new SKCanvas(rotated)) - { - surface.Translate(rotated.Width, 0); - surface.RotateDegrees(90); - surface.DrawBitmap(bitmap, 0, 0); - } - - return rotated; - } - - case SKEncodedOrigin.RightBottom: - { - // TODO: Remove dual canvases, had trouble with flipping - using (var rotated = new SKBitmap(bitmap.Height, bitmap.Width)) - { - using (var surface = new SKCanvas(rotated)) - { - surface.Translate(0, rotated.Height); - surface.RotateDegrees(270); - surface.DrawBitmap(bitmap, 0, 0); - } - - var flippedBitmap = new SKBitmap(rotated.Width, rotated.Height); - using (var flippedCanvas = new SKCanvas(flippedBitmap)) - { - flippedCanvas.Translate(flippedBitmap.Width, 0); - flippedCanvas.Scale(-1, 1); - flippedCanvas.DrawBitmap(rotated, 0, 0); - } - - return flippedBitmap; - } - } - - case SKEncodedOrigin.LeftBottom: - { - var rotated = new SKBitmap(bitmap.Height, bitmap.Width); - using (var surface = new SKCanvas(rotated)) - { - surface.Translate(0, rotated.Height); - surface.RotateDegrees(270); - surface.DrawBitmap(bitmap, 0, 0); - } - - return rotated; - } - - default: - return bitmap; - } - } - - public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat selectedOutputFormat) - { - if (string.IsNullOrWhiteSpace(inputPath)) - { - throw new ArgumentNullException(nameof(inputPath)); - } - if (string.IsNullOrWhiteSpace(inputPath)) - { - throw new ArgumentNullException(nameof(outputPath)); - } - - var skiaOutputFormat = GetImageFormat(selectedOutputFormat); - - var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor); - var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer); - var blur = options.Blur ?? 0; - var hasIndicator = options.AddPlayedIndicator || options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0); - - using (var bitmap = GetBitmap(inputPath, options.CropWhiteSpace, autoOrient, orientation)) - { - if (bitmap == null) - { - throw new ArgumentOutOfRangeException(string.Format("Skia unable to read image {0}", inputPath)); - } - - //_logger.LogInformation("Color type {0}", bitmap.Info.ColorType); - - var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height); - - if (!options.CropWhiteSpace && 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; - - using (var resizedBitmap = new SKBitmap(width, height))//, bitmap.ColorType, bitmap.AlphaType)) - { - // scale image - bitmap.ScalePixels(resizedBitmap, SKFilterQuality.High); - - // If all we're doing is resizing then we can stop now - if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator) - { - _fileSystem.CreateDirectory(_fileSystem.GetDirectoryName(outputPath)); - using (var outputStream = new SKFileWStream(outputPath)) - { - using (var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels())) - { - pixmap.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))//, bitmap.ColorType, bitmap.AlphaType)) - using (var canvas = new SKCanvas(saveBitmap)) - { - // set background color if present - if (hasBackgroundColor) - { - canvas.Clear(SKColor.Parse(options.BackgroundColor)); - } - - // Add blur if option is present - if (blur > 0) - { - // create image from resized bitmap to apply blur - using (var paint = new SKPaint()) - using (var filter = SKImageFilter.CreateBlur(blur, blur)) - { - paint.ImageFilter = filter; - canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), paint); - } - } - else - { - // draw resized bitmap onto canvas - canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height)); - } - - // If 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); - } - - _fileSystem.CreateDirectory(_fileSystem.GetDirectoryName(outputPath)); - using (var outputStream = new SKFileWStream(outputPath)) - { - using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels())) - { - pixmap.Encode(outputStream, skiaOutputFormat, quality); - } - } - } - } - } - return outputPath; - } - - public void CreateImageCollage(ImageCollageOptions options) - { - double ratio = options.Width; - ratio /= options.Height; - - if (ratio >= 1.4) - { - new StripCollageBuilder(_appPaths, _fileSystem).BuildThumbCollage(options.InputPaths, options.OutputPath, options.Width, options.Height); - } - else if (ratio >= .9) - { - new StripCollageBuilder(_appPaths, _fileSystem).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height); - } - else - { - // @todo create Poster collage capability - new StripCollageBuilder(_appPaths, _fileSystem).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height); - } - } - - private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options) - { - try - { - var currentImageSize = new ImageDimensions(imageWidth, imageHeight); - - if (options.AddPlayedIndicator) - { - PlayedIndicatorDrawer.DrawPlayedIndicator(canvas, currentImageSize); - } - else if (options.UnplayedCount.HasValue) - { - UnplayedCountIndicator.DrawUnplayedCountIndicator(canvas, currentImageSize, options.UnplayedCount.Value); - } - - if (options.PercentPlayed > 0) - { - PercentPlayedDrawer.Process(canvas, currentImageSize, options.PercentPlayed); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error drawing indicator overlay"); - } - } - - public string Name => "Skia"; - - public bool SupportsImageCollageCreation => true; - - public bool SupportsImageEncoding => true; - } -} diff --git a/Emby.Drawing/StripCollageBuilder.cs b/Emby.Drawing/StripCollageBuilder.cs deleted file mode 100644 index dd342998b4..0000000000 --- a/Emby.Drawing/StripCollageBuilder.cs +++ /dev/null @@ -1,234 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.IO; -using SkiaSharp; - -namespace Emby.Drawing -{ - public class StripCollageBuilder - { - private readonly IApplicationPaths _appPaths; - private readonly IFileSystem _fileSystem; - - public StripCollageBuilder(IApplicationPaths appPaths, IFileSystem fileSystem) - { - _appPaths = appPaths; - _fileSystem = fileSystem; - } - - public static SKEncodedImageFormat GetEncodedFormat(string outputPath) - { - if (outputPath == null) - { - throw new ArgumentNullException(nameof(outputPath)); - } - - var ext = Path.GetExtension(outputPath).ToLower(); - - if (ext == ".jpg" || ext == ".jpeg") - return SKEncodedImageFormat.Jpeg; - - if (ext == ".webp") - return SKEncodedImageFormat.Webp; - - if (ext == ".gif") - return SKEncodedImageFormat.Gif; - - if (ext == ".bmp") - return SKEncodedImageFormat.Bmp; - - // default to png - return SKEncodedImageFormat.Png; - } - - public void BuildPosterCollage(string[] paths, string outputPath, int width, int height) - { - // @todo - } - - public void BuildSquareCollage(string[] paths, string outputPath, int width, int height) - { - using (var bitmap = BuildSquareCollageBitmap(paths, width, height)) - { - using (var outputStream = new SKFileWStream(outputPath)) - { - using (var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels())) - { - pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90); - } - } - } - } - - public void BuildThumbCollage(string[] paths, string outputPath, int width, int height) - { - using (var bitmap = BuildThumbCollageBitmap(paths, width, height)) - { - using (var outputStream = new SKFileWStream(outputPath)) - { - using (var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels())) - { - pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90); - } - } - } - } - - private SKBitmap BuildThumbCollageBitmap(string[] paths, int width, int height) - { - var bitmap = new SKBitmap(width, height); - - using (var canvas = new SKCanvas(bitmap)) - { - canvas.Clear(SKColors.Black); - - // determine sizes for each image that will composited into the final image - var iSlice = Convert.ToInt32(width * 0.23475); - int iTrans = Convert.ToInt32(height * .25); - int iHeight = Convert.ToInt32(height * .70); - var horizontalImagePadding = Convert.ToInt32(width * 0.0125); - var verticalSpacing = Convert.ToInt32(height * 0.01111111111111111111111111111111); - int imageIndex = 0; - - for (int i = 0; i < 4; i++) - { - - using (var currentBitmap = GetNextValidImage(paths, imageIndex, out int newIndex)) - { - imageIndex = newIndex; - - if (currentBitmap == null) - { - continue; - } - - // resize to the same aspect as the original - int iWidth = (int)Math.Abs(iHeight * currentBitmap.Width / currentBitmap.Height); - using (var resizeBitmap = new SKBitmap(iWidth, iHeight, currentBitmap.ColorType, currentBitmap.AlphaType)) - { - currentBitmap.ScalePixels(resizeBitmap, SKFilterQuality.High); - // crop image - int ix = (int)Math.Abs((iWidth - iSlice) / 2); - using (var image = SKImage.FromBitmap(resizeBitmap)) - using (var subset = image.Subset(SKRectI.Create(ix, 0, iSlice, iHeight))) - { - // draw image onto canvas - canvas.DrawImage(subset ?? image, (horizontalImagePadding * (i + 1)) + (iSlice * i), verticalSpacing); - - if (subset == null) - { - continue; - } - // create reflection of image below the drawn image - using (var croppedBitmap = SKBitmap.FromImage(subset)) - using (var reflectionBitmap = new SKBitmap(croppedBitmap.Width, croppedBitmap.Height / 2, croppedBitmap.ColorType, croppedBitmap.AlphaType)) - { - // resize to half height - currentBitmap.ScalePixels(reflectionBitmap, SKFilterQuality.High); - - using (var flippedBitmap = new SKBitmap(reflectionBitmap.Width, reflectionBitmap.Height, reflectionBitmap.ColorType, reflectionBitmap.AlphaType)) - using (var flippedCanvas = new SKCanvas(flippedBitmap)) - { - // flip image vertically - var matrix = SKMatrix.MakeScale(1, -1); - matrix.SetScaleTranslate(1, -1, 0, flippedBitmap.Height); - flippedCanvas.SetMatrix(matrix); - flippedCanvas.DrawBitmap(reflectionBitmap, 0, 0); - flippedCanvas.ResetMatrix(); - - // create gradient to make image appear as a reflection - var remainingHeight = height - (iHeight + (2 * verticalSpacing)); - flippedCanvas.ClipRect(SKRect.Create(reflectionBitmap.Width, remainingHeight)); - using (var gradient = new SKPaint()) - { - gradient.IsAntialias = true; - gradient.BlendMode = SKBlendMode.SrcOver; - gradient.Shader = SKShader.CreateLinearGradient(new SKPoint(0, 0), new SKPoint(0, remainingHeight), new[] { new SKColor(0, 0, 0, 128), new SKColor(0, 0, 0, 208), new SKColor(0, 0, 0, 240), new SKColor(0, 0, 0, 255) }, null, SKShaderTileMode.Clamp); - flippedCanvas.DrawPaint(gradient); - } - - // finally draw reflection onto canvas - canvas.DrawBitmap(flippedBitmap, (horizontalImagePadding * (i + 1)) + (iSlice * i), iHeight + (2 * verticalSpacing)); - } - } - } - } - } - } - } - - return bitmap; - } - - private SKBitmap GetNextValidImage(string[] paths, int currentIndex, out int newIndex) - { - var imagesTested = new Dictionary(); - SKBitmap bitmap = null; - - while (imagesTested.Count < paths.Length) - { - if (currentIndex >= paths.Length) - { - currentIndex = 0; - } - - bitmap = SkiaEncoder.Decode(paths[currentIndex], false, _fileSystem, null, out var origin); - - imagesTested[currentIndex] = 0; - - currentIndex++; - - if (bitmap != null) - { - break; - } - } - - newIndex = currentIndex; - return bitmap; - } - - private SKBitmap BuildSquareCollageBitmap(string[] paths, int width, int height) - { - var bitmap = new SKBitmap(width, height); - var imageIndex = 0; - var cellWidth = width / 2; - var cellHeight = height / 2; - - using (var canvas = new SKCanvas(bitmap)) - { - for (var x = 0; x < 2; x++) - { - for (var y = 0; y < 2; y++) - { - - using (var currentBitmap = GetNextValidImage(paths, imageIndex, out int newIndex)) - { - imageIndex = newIndex; - - if (currentBitmap == null) - { - continue; - } - - using (var resizedBitmap = new SKBitmap(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType)) - { - // scale image - currentBitmap.ScalePixels(resizedBitmap, SKFilterQuality.High); - - // draw this image into the strip at the next position - var xPos = x * cellWidth; - var yPos = y * cellHeight; - canvas.DrawBitmap(resizedBitmap, xPos, yPos); - } - } - } - } - } - - return bitmap; - } - } -} diff --git a/Emby.Drawing/UnplayedCountIndicator.cs b/Emby.Drawing/UnplayedCountIndicator.cs deleted file mode 100644 index 4d0cc9d40f..0000000000 --- a/Emby.Drawing/UnplayedCountIndicator.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Globalization; -using MediaBrowser.Model.Drawing; -using SkiaSharp; - -namespace Emby.Drawing -{ - public static class UnplayedCountIndicator - { - private const int OffsetFromTopRightCorner = 38; - - public static void DrawUnplayedCountIndicator(SKCanvas canvas, ImageDimensions imageSize, int count) - { - var x = imageSize.Width - OffsetFromTopRightCorner; - var text = count.ToString(CultureInfo.InvariantCulture); - - using (var paint = new SKPaint()) - { - paint.Color = SKColor.Parse("#CC52B54B"); - paint.Style = SKPaintStyle.Fill; - canvas.DrawCircle((float)x, OffsetFromTopRightCorner, 20, paint); - } - using (var paint = new SKPaint()) - { - paint.Color = new SKColor(255, 255, 255, 255); - paint.Style = SKPaintStyle.Fill; - - paint.TextSize = 24; - paint.IsAntialias = true; - - var y = OffsetFromTopRightCorner + 9; - - if (text.Length == 1) - { - x -= 7; - } - if (text.Length == 2) - { - x -= 13; - } - else if (text.Length >= 3) - { - x -= 15; - y -= 2; - paint.TextSize = 18; - } - - canvas.DrawText(text, (float)x, y, paint); - } - } - } -} diff --git a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj new file mode 100644 index 0000000000..f023bc55dc --- /dev/null +++ b/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj @@ -0,0 +1,24 @@ + + + + netstandard2.0 + false + + + + + + + + + + + + + + + + + + + diff --git a/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs b/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs new file mode 100644 index 0000000000..0d5a1d3c01 --- /dev/null +++ b/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs @@ -0,0 +1,31 @@ +using System; +using MediaBrowser.Model.Drawing; +using SkiaSharp; + +namespace Jellyfin.Drawing.Skia +{ + public static class PercentPlayedDrawer + { + private const int IndicatorHeight = 8; + + public static void Process(SKCanvas canvas, ImageDimensions imageSize, double percent) + { + using (var paint = new SKPaint()) + { + var endX = imageSize.Width - 1; + var endY = imageSize.Height - 1; + + paint.Color = SKColor.Parse("#99000000"); + paint.Style = SKPaintStyle.Fill; + canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, (float)endX, (float)endY), paint); + + double foregroundWidth = endX; + foregroundWidth *= percent; + foregroundWidth /= 100; + + paint.Color = SKColor.Parse("#FF52B54B"); + canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, Convert.ToInt32(foregroundWidth), (float)endY), paint); + } + } + } +} diff --git a/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs b/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs new file mode 100644 index 0000000000..62497da272 --- /dev/null +++ b/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs @@ -0,0 +1,44 @@ +using MediaBrowser.Model.Drawing; +using SkiaSharp; + +namespace Jellyfin.Drawing.Skia +{ + public static class PlayedIndicatorDrawer + { + private const int OffsetFromTopRightCorner = 38; + + public static void DrawPlayedIndicator(SKCanvas canvas, ImageDimensions imageSize) + { + var x = imageSize.Width - OffsetFromTopRightCorner; + + using (var paint = new SKPaint()) + { + paint.Color = SKColor.Parse("#CC52B54B"); + paint.Style = SKPaintStyle.Fill; + canvas.DrawCircle((float)x, OffsetFromTopRightCorner, 20, paint); + } + + using (var paint = new SKPaint()) + { + paint.Color = new SKColor(255, 255, 255, 255); + paint.Style = SKPaintStyle.Fill; + + paint.TextSize = 30; + paint.IsAntialias = true; + + var text = "✔️"; + var emojiChar = StringUtilities.GetUnicodeCharacterCode(text, SKTextEncoding.Utf32); + // or: + //var emojiChar = 0x1F680; + + // ask the font manager for a font with that character + var fontManager = SKFontManager.Default; + var emojiTypeface = fontManager.MatchCharacter(emojiChar); + + paint.Typeface = emojiTypeface; + + canvas.DrawText(text, (float)x - 20, OffsetFromTopRightCorner + 12, paint); + } + } + } +} diff --git a/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/Jellyfin.Drawing.Skia/SkiaEncoder.cs new file mode 100644 index 0000000000..7de3106544 --- /dev/null +++ b/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -0,0 +1,653 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Extensions; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using SkiaSharp; + +namespace Jellyfin.Drawing.Skia +{ + public class SkiaEncoder : IImageEncoder + { + private readonly ILogger _logger; + private static IApplicationPaths _appPaths; + private readonly IFileSystem _fileSystem; + private static ILocalizationManager _localizationManager; + + public SkiaEncoder( + ILoggerFactory loggerFactory, + IApplicationPaths appPaths, + IFileSystem fileSystem, + ILocalizationManager localizationManager) + { + _logger = loggerFactory.CreateLogger("ImageEncoder"); + _appPaths = appPaths; + _fileSystem = fileSystem; + _localizationManager = localizationManager; + + LogVersion(); + } + + public string[] SupportedInputFormats => + new[] + { + "jpeg", + "jpg", + "png", + + "dng", + + "webp", + "gif", + "bmp", + "ico", + "astc", + "ktx", + "pkm", + "wbmp", + + // TODO + // Are all of these supported? https://github.com/google/skia/blob/master/infra/bots/recipes/test.py#L454 + + // working on windows at least + "cr2", + "nef", + "arw" + }; + + public ImageFormat[] SupportedOutputFormats => new[] { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png }; + + private void LogVersion() + { + // test an operation that requires the native library + SKPMColor.PreMultiply(SKColors.Black); + + _logger.LogInformation("SkiaSharp version: " + GetVersion()); + } + + public static string GetVersion() + { + return typeof(SKBitmap).GetTypeInfo().Assembly.GetName().Version.ToString(); + } + + private static bool IsTransparent(SKColor color) + { + + return (color.Red == 255 && color.Green == 255 && color.Blue == 255) || color.Alpha == 0; + } + + public static SKEncodedImageFormat GetImageFormat(ImageFormat selectedFormat) + { + switch (selectedFormat) + { + case ImageFormat.Bmp: + return SKEncodedImageFormat.Bmp; + case ImageFormat.Jpg: + return SKEncodedImageFormat.Jpeg; + case ImageFormat.Gif: + return SKEncodedImageFormat.Gif; + case ImageFormat.Webp: + return SKEncodedImageFormat.Webp; + default: + return SKEncodedImageFormat.Png; + } + } + + private static bool IsTransparentRow(SKBitmap bmp, int row) + { + for (var i = 0; i < bmp.Width; ++i) + { + if (!IsTransparent(bmp.GetPixel(i, row))) + { + return false; + } + } + return true; + } + + private static bool IsTransparentColumn(SKBitmap bmp, int col) + { + for (var i = 0; i < bmp.Height; ++i) + { + if (!IsTransparent(bmp.GetPixel(col, i))) + { + return false; + } + } + return true; + } + + private SKBitmap CropWhiteSpace(SKBitmap bitmap) + { + var topmost = 0; + for (int row = 0; row < bitmap.Height; ++row) + { + if (IsTransparentRow(bitmap, row)) + topmost = row + 1; + else break; + } + + int bottommost = bitmap.Height; + for (int row = bitmap.Height - 1; row >= 0; --row) + { + if (IsTransparentRow(bitmap, row)) + bottommost = row; + else break; + } + + int leftmost = 0, rightmost = bitmap.Width; + for (int col = 0; col < bitmap.Width; ++col) + { + if (IsTransparentColumn(bitmap, col)) + leftmost = col + 1; + else + break; + } + + for (int col = bitmap.Width - 1; col >= 0; --col) + { + if (IsTransparentColumn(bitmap, col)) + rightmost = col; + else + break; + } + + var newRect = SKRectI.Create(leftmost, topmost, rightmost - leftmost, bottommost - topmost); + + using (var image = SKImage.FromBitmap(bitmap)) + using (var subset = image.Subset(newRect)) + { + return SKBitmap.FromImage(subset); + } + } + + public ImageDimensions GetImageSize(string path) + { + using (var s = new SKFileStream(path)) + using (var codec = SKCodec.Create(s)) + { + var info = codec.Info; + + return new ImageDimensions(info.Width, info.Height); + } + } + + private static bool HasDiacritics(string text) + { + return !string.Equals(text, text.RemoveDiacritics(), StringComparison.Ordinal); + } + + private static bool RequiresSpecialCharacterHack(string path) + { + if (_localizationManager.HasUnicodeCategory(path, UnicodeCategory.OtherLetter)) + { + return true; + } + + if (HasDiacritics(path)) + { + return true; + } + + return false; + } + + private static string NormalizePath(string path, IFileSystem fileSystem) + { + if (!RequiresSpecialCharacterHack(path)) + { + return path; + } + + var tempPath = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid() + Path.GetExtension(path) ?? string.Empty); + + fileSystem.CreateDirectory(fileSystem.GetDirectoryName(tempPath)); + fileSystem.CopyFile(path, tempPath, true); + + return tempPath; + } + + private static SKEncodedOrigin GetSKEncodedOrigin(ImageOrientation? orientation) + { + if (!orientation.HasValue) + { + return SKEncodedOrigin.TopLeft; + } + + switch (orientation.Value) + { + case ImageOrientation.TopRight: + return SKEncodedOrigin.TopRight; + case ImageOrientation.RightTop: + return SKEncodedOrigin.RightTop; + case ImageOrientation.RightBottom: + return SKEncodedOrigin.RightBottom; + case ImageOrientation.LeftTop: + return SKEncodedOrigin.LeftTop; + case ImageOrientation.LeftBottom: + return SKEncodedOrigin.LeftBottom; + case ImageOrientation.BottomRight: + return SKEncodedOrigin.BottomRight; + case ImageOrientation.BottomLeft: + return SKEncodedOrigin.BottomLeft; + default: + return SKEncodedOrigin.TopLeft; + } + } + + private static string[] TransparentImageTypes = new string[] { ".png", ".gif", ".webp" }; + internal static SKBitmap Decode(string path, bool forceCleanBitmap, IFileSystem fileSystem, ImageOrientation? orientation, out SKEncodedOrigin origin) + { + if (!fileSystem.FileExists(path)) + { + throw new FileNotFoundException("File not found", path); + } + + var requiresTransparencyHack = TransparentImageTypes.Contains(Path.GetExtension(path) ?? string.Empty); + + if (requiresTransparencyHack || forceCleanBitmap) + { + using (var stream = new SKFileStream(NormalizePath(path, fileSystem))) + using (var codec = SKCodec.Create(stream)) + { + if (codec == null) + { + origin = GetSKEncodedOrigin(orientation); + return null; + } + + // create the bitmap + var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack); + + // decode + codec.GetPixels(bitmap.Info, bitmap.GetPixels()); + + origin = codec.EncodedOrigin; + + return bitmap; + } + } + + var resultBitmap = SKBitmap.Decode(NormalizePath(path, fileSystem)); + + if (resultBitmap == null) + { + return Decode(path, true, fileSystem, orientation, out origin); + } + + // If we have to resize these they often end up distorted + if (resultBitmap.ColorType == SKColorType.Gray8) + { + using (resultBitmap) + { + return Decode(path, true, fileSystem, orientation, out origin); + } + } + + origin = SKEncodedOrigin.TopLeft; + return resultBitmap; + } + + private SKBitmap GetBitmap(string path, bool cropWhitespace, bool forceAnalyzeBitmap, ImageOrientation? orientation, out SKEncodedOrigin origin) + { + if (cropWhitespace) + { + using (var bitmap = Decode(path, forceAnalyzeBitmap, _fileSystem, orientation, out origin)) + { + return CropWhiteSpace(bitmap); + } + } + + return Decode(path, forceAnalyzeBitmap, _fileSystem, orientation, out origin); + } + + private SKBitmap GetBitmap(string path, bool cropWhitespace, bool autoOrient, ImageOrientation? orientation) + { + SKEncodedOrigin origin; + + if (autoOrient) + { + var bitmap = GetBitmap(path, cropWhitespace, true, orientation, out origin); + + if (bitmap != null) + { + if (origin != SKEncodedOrigin.TopLeft) + { + using (bitmap) + { + return OrientImage(bitmap, origin); + } + } + } + + return bitmap; + } + + return GetBitmap(path, cropWhitespace, false, orientation, out origin); + } + + private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin) + { + //var transformations = { + // 2: { rotate: 0, flip: true}, + // 3: { rotate: 180, flip: false}, + // 4: { rotate: 180, flip: true}, + // 5: { rotate: 90, flip: true}, + // 6: { rotate: 90, flip: false}, + // 7: { rotate: 270, flip: true}, + // 8: { rotate: 270, flip: false}, + //} + + switch (origin) + { + + case SKEncodedOrigin.TopRight: + { + var rotated = new SKBitmap(bitmap.Width, bitmap.Height); + using (var surface = new SKCanvas(rotated)) + { + surface.Translate(rotated.Width, 0); + surface.Scale(-1, 1); + surface.DrawBitmap(bitmap, 0, 0); + } + + return rotated; + } + + case SKEncodedOrigin.BottomRight: + { + var rotated = new SKBitmap(bitmap.Width, bitmap.Height); + using (var surface = new SKCanvas(rotated)) + { + float px = bitmap.Width; + px /= 2; + + float py = bitmap.Height; + py /= 2; + + surface.RotateDegrees(180, px, py); + surface.DrawBitmap(bitmap, 0, 0); + } + + return rotated; + } + + case SKEncodedOrigin.BottomLeft: + { + var rotated = new SKBitmap(bitmap.Width, bitmap.Height); + using (var surface = new SKCanvas(rotated)) + { + float px = bitmap.Width; + px /= 2; + + float py = bitmap.Height; + py /= 2; + + surface.Translate(rotated.Width, 0); + surface.Scale(-1, 1); + + surface.RotateDegrees(180, px, py); + surface.DrawBitmap(bitmap, 0, 0); + } + + return rotated; + } + + case SKEncodedOrigin.LeftTop: + { + // TODO: Remove dual canvases, had trouble with flipping + using (var rotated = new SKBitmap(bitmap.Height, bitmap.Width)) + { + using (var surface = new SKCanvas(rotated)) + { + surface.Translate(rotated.Width, 0); + + surface.RotateDegrees(90); + + surface.DrawBitmap(bitmap, 0, 0); + + } + + var flippedBitmap = new SKBitmap(rotated.Width, rotated.Height); + using (var flippedCanvas = new SKCanvas(flippedBitmap)) + { + flippedCanvas.Translate(flippedBitmap.Width, 0); + flippedCanvas.Scale(-1, 1); + flippedCanvas.DrawBitmap(rotated, 0, 0); + } + + return flippedBitmap; + } + } + + case SKEncodedOrigin.RightTop: + { + var rotated = new SKBitmap(bitmap.Height, bitmap.Width); + using (var surface = new SKCanvas(rotated)) + { + surface.Translate(rotated.Width, 0); + surface.RotateDegrees(90); + surface.DrawBitmap(bitmap, 0, 0); + } + + return rotated; + } + + case SKEncodedOrigin.RightBottom: + { + // TODO: Remove dual canvases, had trouble with flipping + using (var rotated = new SKBitmap(bitmap.Height, bitmap.Width)) + { + using (var surface = new SKCanvas(rotated)) + { + surface.Translate(0, rotated.Height); + surface.RotateDegrees(270); + surface.DrawBitmap(bitmap, 0, 0); + } + + var flippedBitmap = new SKBitmap(rotated.Width, rotated.Height); + using (var flippedCanvas = new SKCanvas(flippedBitmap)) + { + flippedCanvas.Translate(flippedBitmap.Width, 0); + flippedCanvas.Scale(-1, 1); + flippedCanvas.DrawBitmap(rotated, 0, 0); + } + + return flippedBitmap; + } + } + + case SKEncodedOrigin.LeftBottom: + { + var rotated = new SKBitmap(bitmap.Height, bitmap.Width); + using (var surface = new SKCanvas(rotated)) + { + surface.Translate(0, rotated.Height); + surface.RotateDegrees(270); + surface.DrawBitmap(bitmap, 0, 0); + } + + return rotated; + } + + default: + return bitmap; + } + } + + public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat selectedOutputFormat) + { + if (string.IsNullOrWhiteSpace(inputPath)) + { + throw new ArgumentNullException(nameof(inputPath)); + } + if (string.IsNullOrWhiteSpace(inputPath)) + { + throw new ArgumentNullException(nameof(outputPath)); + } + + var skiaOutputFormat = GetImageFormat(selectedOutputFormat); + + var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor); + var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer); + var blur = options.Blur ?? 0; + var hasIndicator = options.AddPlayedIndicator || options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0); + + using (var bitmap = GetBitmap(inputPath, options.CropWhiteSpace, autoOrient, orientation)) + { + if (bitmap == null) + { + throw new ArgumentOutOfRangeException(string.Format("Skia unable to read image {0}", inputPath)); + } + + //_logger.LogInformation("Color type {0}", bitmap.Info.ColorType); + + var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height); + + if (!options.CropWhiteSpace && 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; + + using (var resizedBitmap = new SKBitmap(width, height))//, bitmap.ColorType, bitmap.AlphaType)) + { + // scale image + bitmap.ScalePixels(resizedBitmap, SKFilterQuality.High); + + // If all we're doing is resizing then we can stop now + if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator) + { + _fileSystem.CreateDirectory(_fileSystem.GetDirectoryName(outputPath)); + using (var outputStream = new SKFileWStream(outputPath)) + { + using (var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels())) + { + pixmap.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))//, bitmap.ColorType, bitmap.AlphaType)) + using (var canvas = new SKCanvas(saveBitmap)) + { + // set background color if present + if (hasBackgroundColor) + { + canvas.Clear(SKColor.Parse(options.BackgroundColor)); + } + + // Add blur if option is present + if (blur > 0) + { + // create image from resized bitmap to apply blur + using (var paint = new SKPaint()) + using (var filter = SKImageFilter.CreateBlur(blur, blur)) + { + paint.ImageFilter = filter; + canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), paint); + } + } + else + { + // draw resized bitmap onto canvas + canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height)); + } + + // If 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); + } + + _fileSystem.CreateDirectory(_fileSystem.GetDirectoryName(outputPath)); + using (var outputStream = new SKFileWStream(outputPath)) + { + using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels())) + { + pixmap.Encode(outputStream, skiaOutputFormat, quality); + } + } + } + } + } + return outputPath; + } + + public void CreateImageCollage(ImageCollageOptions options) + { + double ratio = options.Width; + ratio /= options.Height; + + if (ratio >= 1.4) + { + new StripCollageBuilder(_appPaths, _fileSystem).BuildThumbCollage(options.InputPaths, options.OutputPath, options.Width, options.Height); + } + else if (ratio >= .9) + { + new StripCollageBuilder(_appPaths, _fileSystem).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height); + } + else + { + // @todo create Poster collage capability + new StripCollageBuilder(_appPaths, _fileSystem).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height); + } + } + + private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options) + { + try + { + var currentImageSize = new ImageDimensions(imageWidth, imageHeight); + + if (options.AddPlayedIndicator) + { + PlayedIndicatorDrawer.DrawPlayedIndicator(canvas, currentImageSize); + } + else if (options.UnplayedCount.HasValue) + { + UnplayedCountIndicator.DrawUnplayedCountIndicator(canvas, currentImageSize, options.UnplayedCount.Value); + } + + if (options.PercentPlayed > 0) + { + PercentPlayedDrawer.Process(canvas, currentImageSize, options.PercentPlayed); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error drawing indicator overlay"); + } + } + + public string Name => "Skia"; + + public bool SupportsImageCollageCreation => true; + + public bool SupportsImageEncoding => true; + } +} diff --git a/Jellyfin.Drawing.Skia/StripCollageBuilder.cs b/Jellyfin.Drawing.Skia/StripCollageBuilder.cs new file mode 100644 index 0000000000..92115047c1 --- /dev/null +++ b/Jellyfin.Drawing.Skia/StripCollageBuilder.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.IO; +using SkiaSharp; + +namespace Jellyfin.Drawing.Skia +{ + public class StripCollageBuilder + { + private readonly IApplicationPaths _appPaths; + private readonly IFileSystem _fileSystem; + + public StripCollageBuilder(IApplicationPaths appPaths, IFileSystem fileSystem) + { + _appPaths = appPaths; + _fileSystem = fileSystem; + } + + public static SKEncodedImageFormat GetEncodedFormat(string outputPath) + { + if (outputPath == null) + { + throw new ArgumentNullException(nameof(outputPath)); + } + + var ext = Path.GetExtension(outputPath).ToLower(); + + if (ext == ".jpg" || ext == ".jpeg") + return SKEncodedImageFormat.Jpeg; + + if (ext == ".webp") + return SKEncodedImageFormat.Webp; + + if (ext == ".gif") + return SKEncodedImageFormat.Gif; + + if (ext == ".bmp") + return SKEncodedImageFormat.Bmp; + + // default to png + return SKEncodedImageFormat.Png; + } + + public void BuildSquareCollage(string[] paths, string outputPath, int width, int height) + { + using (var bitmap = BuildSquareCollageBitmap(paths, width, height)) + using (var outputStream = new SKFileWStream(outputPath)) + { + using (var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels())) + { + pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90); + } + } + } + + public void BuildThumbCollage(string[] paths, string outputPath, int width, int height) + { + using (var bitmap = BuildThumbCollageBitmap(paths, width, height)) + { + using (var outputStream = new SKFileWStream(outputPath)) + { + using (var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels())) + { + pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90); + } + } + } + } + + private SKBitmap BuildThumbCollageBitmap(string[] paths, int width, int height) + { + var bitmap = new SKBitmap(width, height); + + using (var canvas = new SKCanvas(bitmap)) + { + canvas.Clear(SKColors.Black); + + // determine sizes for each image that will composited into the final image + var iSlice = Convert.ToInt32(width * 0.23475); + int iTrans = Convert.ToInt32(height * .25); + int iHeight = Convert.ToInt32(height * .70); + var horizontalImagePadding = Convert.ToInt32(width * 0.0125); + var verticalSpacing = Convert.ToInt32(height * 0.01111111111111111111111111111111); + int imageIndex = 0; + + for (int i = 0; i < 4; i++) + { + + using (var currentBitmap = GetNextValidImage(paths, imageIndex, out int newIndex)) + { + imageIndex = newIndex; + + if (currentBitmap == null) + { + continue; + } + + // resize to the same aspect as the original + int iWidth = (int)Math.Abs(iHeight * currentBitmap.Width / currentBitmap.Height); + using (var resizeBitmap = new SKBitmap(iWidth, iHeight, currentBitmap.ColorType, currentBitmap.AlphaType)) + { + currentBitmap.ScalePixels(resizeBitmap, SKFilterQuality.High); + // crop image + int ix = (int)Math.Abs((iWidth - iSlice) / 2); + using (var image = SKImage.FromBitmap(resizeBitmap)) + using (var subset = image.Subset(SKRectI.Create(ix, 0, iSlice, iHeight))) + { + // draw image onto canvas + canvas.DrawImage(subset ?? image, (horizontalImagePadding * (i + 1)) + (iSlice * i), verticalSpacing); + + if (subset == null) + { + continue; + } + // create reflection of image below the drawn image + using (var croppedBitmap = SKBitmap.FromImage(subset)) + using (var reflectionBitmap = new SKBitmap(croppedBitmap.Width, croppedBitmap.Height / 2, croppedBitmap.ColorType, croppedBitmap.AlphaType)) + { + // resize to half height + currentBitmap.ScalePixels(reflectionBitmap, SKFilterQuality.High); + + using (var flippedBitmap = new SKBitmap(reflectionBitmap.Width, reflectionBitmap.Height, reflectionBitmap.ColorType, reflectionBitmap.AlphaType)) + using (var flippedCanvas = new SKCanvas(flippedBitmap)) + { + // flip image vertically + var matrix = SKMatrix.MakeScale(1, -1); + matrix.SetScaleTranslate(1, -1, 0, flippedBitmap.Height); + flippedCanvas.SetMatrix(matrix); + flippedCanvas.DrawBitmap(reflectionBitmap, 0, 0); + flippedCanvas.ResetMatrix(); + + // create gradient to make image appear as a reflection + var remainingHeight = height - (iHeight + (2 * verticalSpacing)); + flippedCanvas.ClipRect(SKRect.Create(reflectionBitmap.Width, remainingHeight)); + using (var gradient = new SKPaint()) + { + gradient.IsAntialias = true; + gradient.BlendMode = SKBlendMode.SrcOver; + gradient.Shader = SKShader.CreateLinearGradient(new SKPoint(0, 0), new SKPoint(0, remainingHeight), new[] { new SKColor(0, 0, 0, 128), new SKColor(0, 0, 0, 208), new SKColor(0, 0, 0, 240), new SKColor(0, 0, 0, 255) }, null, SKShaderTileMode.Clamp); + flippedCanvas.DrawPaint(gradient); + } + + // finally draw reflection onto canvas + canvas.DrawBitmap(flippedBitmap, (horizontalImagePadding * (i + 1)) + (iSlice * i), iHeight + (2 * verticalSpacing)); + } + } + } + } + } + } + } + + return bitmap; + } + + private SKBitmap GetNextValidImage(string[] paths, int currentIndex, out int newIndex) + { + var imagesTested = new Dictionary(); + SKBitmap bitmap = null; + + while (imagesTested.Count < paths.Length) + { + if (currentIndex >= paths.Length) + { + currentIndex = 0; + } + + bitmap = SkiaEncoder.Decode(paths[currentIndex], false, _fileSystem, null, out var origin); + + imagesTested[currentIndex] = 0; + + currentIndex++; + + if (bitmap != null) + { + break; + } + } + + newIndex = currentIndex; + return bitmap; + } + + private SKBitmap BuildSquareCollageBitmap(string[] paths, int width, int height) + { + var bitmap = new SKBitmap(width, height); + var imageIndex = 0; + var cellWidth = width / 2; + var cellHeight = height / 2; + + using (var canvas = new SKCanvas(bitmap)) + { + for (var x = 0; x < 2; x++) + { + for (var y = 0; y < 2; y++) + { + + using (var currentBitmap = GetNextValidImage(paths, imageIndex, out int newIndex)) + { + imageIndex = newIndex; + + if (currentBitmap == null) + { + continue; + } + + using (var resizedBitmap = new SKBitmap(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType)) + { + // scale image + currentBitmap.ScalePixels(resizedBitmap, SKFilterQuality.High); + + // draw this image into the strip at the next position + var xPos = x * cellWidth; + var yPos = y * cellHeight; + canvas.DrawBitmap(resizedBitmap, xPos, yPos); + } + } + } + } + } + + return bitmap; + } + } +} diff --git a/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs b/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs new file mode 100644 index 0000000000..ba712bff77 --- /dev/null +++ b/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs @@ -0,0 +1,51 @@ +using System.Globalization; +using MediaBrowser.Model.Drawing; +using SkiaSharp; + +namespace Jellyfin.Drawing.Skia +{ + public static class UnplayedCountIndicator + { + private const int OffsetFromTopRightCorner = 38; + + public static void DrawUnplayedCountIndicator(SKCanvas canvas, ImageDimensions imageSize, int count) + { + var x = imageSize.Width - OffsetFromTopRightCorner; + var text = count.ToString(CultureInfo.InvariantCulture); + + using (var paint = new SKPaint()) + { + paint.Color = SKColor.Parse("#CC52B54B"); + paint.Style = SKPaintStyle.Fill; + canvas.DrawCircle((float)x, OffsetFromTopRightCorner, 20, paint); + } + using (var paint = new SKPaint()) + { + paint.Color = new SKColor(255, 255, 255, 255); + paint.Style = SKPaintStyle.Fill; + + paint.TextSize = 24; + paint.IsAntialias = true; + + var y = OffsetFromTopRightCorner + 9; + + if (text.Length == 1) + { + x -= 7; + } + if (text.Length == 2) + { + x -= 13; + } + else if (text.Length >= 3) + { + x -= 15; + y -= 2; + paint.TextSize = 18; + } + + canvas.DrawText(text, (float)x, y, paint); + } + } + } +} diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 9b698628e0..5a4bf5149d 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -49,6 +49,7 @@ + diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index f64f50cd7c..66586d4e4f 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -13,6 +13,7 @@ using Emby.Server.Implementations; using Emby.Server.Implementations.EnvironmentInfo; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Networking; +using Jellyfin.Drawing.Skia; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Model.Globalization; diff --git a/MediaBrowser.sln b/MediaBrowser.sln index dfaa2601fa..62ae58d732 100644 --- a/MediaBrowser.sln +++ b/MediaBrowser.sln @@ -1,4 +1,4 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 VisualStudioVersion = 15.0.26730.3 MinimumVisualStudioVersion = 10.0.40219.1 @@ -56,6 +56,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution SharedVersion.cs = SharedVersion.cs EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Drawing.Skia", "Jellyfin.Drawing.Skia\Jellyfin.Drawing.Skia.csproj", "{154872D9-6C12-4007-96E3-8F70A58386CE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -158,6 +160,10 @@ Global {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Debug|Any CPU.Build.0 = Debug|Any CPU {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Release|Any CPU.ActiveCfg = Release|Any CPU {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Release|Any CPU.Build.0 = Release|Any CPU + {154872D9-6C12-4007-96E3-8F70A58386CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {154872D9-6C12-4007-96E3-8F70A58386CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {154872D9-6C12-4007-96E3-8F70A58386CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {154872D9-6C12-4007-96E3-8F70A58386CE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE -- cgit v1.2.3