diff options
Diffstat (limited to 'Emby.Drawing')
| -rw-r--r-- | Emby.Drawing/Common/ImageHeader.cs | 242 | ||||
| -rw-r--r-- | Emby.Drawing/Emby.Drawing.csproj | 17 | ||||
| -rw-r--r-- | Emby.Drawing/ImageProcessor.cs | 34 | ||||
| -rw-r--r-- | Emby.Drawing/PercentPlayedDrawer.cs | 31 | ||||
| -rw-r--r-- | Emby.Drawing/PlayedIndicatorDrawer.cs | 58 | ||||
| -rw-r--r-- | Emby.Drawing/SkiaEncoder.cs | 663 | ||||
| -rw-r--r-- | Emby.Drawing/StripCollageBuilder.cs | 234 | ||||
| -rw-r--r-- | Emby.Drawing/UnplayedCountIndicator.cs | 65 |
8 files changed, 1076 insertions, 268 deletions
diff --git a/Emby.Drawing/Common/ImageHeader.cs b/Emby.Drawing/Common/ImageHeader.cs deleted file mode 100644 index c08cdabb2..000000000 --- a/Emby.Drawing/Common/ImageHeader.cs +++ /dev/null @@ -1,242 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using MediaBrowser.Model.Drawing; -using MediaBrowser.Model.IO; -using Microsoft.Extensions.Logging; - -namespace Emby.Drawing.Common -{ - /// <summary> - /// Taken from http://stackoverflow.com/questions/111345/getting-image-dimensions-without-reading-the-entire-file/111349 - /// http://www.codeproject.com/Articles/35978/Reading-Image-Headers-to-Get-Width-and-Height - /// Minor improvements including supporting unsigned 16-bit integers when decoding Jfif and added logic - /// to load the image using new Bitmap if reading the headers fails - /// </summary> - public static class ImageHeader - { - /// <summary> - /// The error message - /// </summary> - const string ErrorMessage = "Could not recognize image format."; - - /// <summary> - /// The image format decoders - /// </summary> - private static readonly KeyValuePair<byte[], Func<BinaryReader, ImageSize>>[] ImageFormatDecoders = new Dictionary<byte[], Func<BinaryReader, ImageSize>> - { - { new byte[] { 0x42, 0x4D }, DecodeBitmap }, - { new byte[] { 0x47, 0x49, 0x46, 0x38, 0x37, 0x61 }, DecodeGif }, - { new byte[] { 0x47, 0x49, 0x46, 0x38, 0x39, 0x61 }, DecodeGif }, - { new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }, DecodePng }, - { new byte[] { 0xff, 0xd8 }, DecodeJfif } - - }.ToArray(); - - private static readonly int MaxMagicBytesLength = ImageFormatDecoders.Select(i => i.Key.Length).OrderByDescending(i => i).First(); - - private static string[] SupportedExtensions = new string[] { ".jpg", ".jpeg", ".png", ".gif" }; - - /// <summary> - /// Gets the dimensions of an image. - /// </summary> - /// <param name="path">The path of the image to get the dimensions of.</param> - /// <param name="logger">The logger.</param> - /// <param name="fileSystem">The file system.</param> - /// <returns>The dimensions of the specified image.</returns> - /// <exception cref="ArgumentException">The image was of an unrecognised format.</exception> - public static ImageSize GetDimensions(string path, ILogger logger, IFileSystem fileSystem) - { - if (string.IsNullOrEmpty(path)) - { - throw new ArgumentNullException(nameof(path)); - } - - string extension = Path.GetExtension(path).ToLower(); - - if (!SupportedExtensions.Contains(extension)) - { - throw new ArgumentException("ImageHeader doesn't support " + extension); - } - - using (var fs = fileSystem.OpenRead(path)) - { - using (var binaryReader = new BinaryReader(fs)) - { - return GetDimensions(binaryReader); - } - } - } - - /// <summary> - /// Gets the dimensions of an image. - /// </summary> - /// <param name="binaryReader">The binary reader.</param> - /// <returns>Size.</returns> - /// <exception cref="ArgumentException">binaryReader</exception> - /// <exception cref="ArgumentException">The image was of an unrecognized format.</exception> - private static ImageSize GetDimensions(BinaryReader binaryReader) - { - var magicBytes = new byte[MaxMagicBytesLength]; - - for (var i = 0; i < MaxMagicBytesLength; i += 1) - { - magicBytes[i] = binaryReader.ReadByte(); - - foreach (var kvPair in ImageFormatDecoders) - { - if (StartsWith(magicBytes, kvPair.Key)) - { - return kvPair.Value(binaryReader); - } - } - } - - throw new ArgumentException(ErrorMessage, nameof(binaryReader)); - } - - /// <summary> - /// Startses the with. - /// </summary> - /// <param name="thisBytes">The this bytes.</param> - /// <param name="thatBytes">The that bytes.</param> - /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> - private static bool StartsWith(byte[] thisBytes, byte[] thatBytes) - { - for (int i = 0; i < thatBytes.Length; i += 1) - { - if (thisBytes[i] != thatBytes[i]) - { - return false; - } - } - - return true; - } - - /// <summary> - /// Reads the little endian int16. - /// </summary> - /// <param name="binaryReader">The binary reader.</param> - /// <returns>System.Int16.</returns> - private static short ReadLittleEndianInt16(this BinaryReader binaryReader) - { - var bytes = new byte[sizeof(short)]; - - for (int i = 0; i < sizeof(short); i += 1) - { - bytes[sizeof(short) - 1 - i] = binaryReader.ReadByte(); - } - return BitConverter.ToInt16(bytes, 0); - } - - /// <summary> - /// Reads the little endian int32. - /// </summary> - /// <param name="binaryReader">The binary reader.</param> - /// <returns>System.Int32.</returns> - private static int ReadLittleEndianInt32(this BinaryReader binaryReader) - { - var bytes = new byte[sizeof(int)]; - for (int i = 0; i < sizeof(int); i += 1) - { - bytes[sizeof(int) - 1 - i] = binaryReader.ReadByte(); - } - return BitConverter.ToInt32(bytes, 0); - } - - /// <summary> - /// Decodes the bitmap. - /// </summary> - /// <param name="binaryReader">The binary reader.</param> - /// <returns>Size.</returns> - private static ImageSize DecodeBitmap(BinaryReader binaryReader) - { - binaryReader.ReadBytes(16); - int width = binaryReader.ReadInt32(); - int height = binaryReader.ReadInt32(); - return new ImageSize - { - Width = width, - Height = height - }; - } - - /// <summary> - /// Decodes the GIF. - /// </summary> - /// <param name="binaryReader">The binary reader.</param> - /// <returns>Size.</returns> - private static ImageSize DecodeGif(BinaryReader binaryReader) - { - int width = binaryReader.ReadInt16(); - int height = binaryReader.ReadInt16(); - return new ImageSize - { - Width = width, - Height = height - }; - } - - /// <summary> - /// Decodes the PNG. - /// </summary> - /// <param name="binaryReader">The binary reader.</param> - /// <returns>Size.</returns> - private static ImageSize DecodePng(BinaryReader binaryReader) - { - binaryReader.ReadBytes(8); - int width = ReadLittleEndianInt32(binaryReader); - int height = ReadLittleEndianInt32(binaryReader); - return new ImageSize - { - Width = width, - Height = height - }; - } - - /// <summary> - /// Decodes the jfif. - /// </summary> - /// <param name="binaryReader">The binary reader.</param> - /// <returns>Size.</returns> - /// <exception cref="ArgumentException"></exception> - private static ImageSize DecodeJfif(BinaryReader binaryReader) - { - // A JPEG image consists of a sequence of segments, - // each beginning with a marker, each of which begins with a 0xFF byte - // followed by a byte indicating what kind of marker it is. - // Source: https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure - while (binaryReader.ReadByte() == 0xff) - { - byte marker = binaryReader.ReadByte(); - short chunkLength = binaryReader.ReadLittleEndianInt16(); - // SOF0: Indicates that this is a baseline DCT-based JPEG, - // and specifies the width, height, number of components, and component subsampling - // SOF2: Indicates that this is a progressive DCT-based JPEG, - // and specifies the width, height, number of components, and component subsampling - if (marker == 0xc0 || marker == 0xc2) - { - // https://help.accusoft.com/ImageGear/v18.2/Windows/ActiveX/IGAX-10-12.html - binaryReader.ReadByte(); // We don't care about the first byte - int height = binaryReader.ReadLittleEndianInt16(); - int width = binaryReader.ReadLittleEndianInt16(); - return new ImageSize(width, height); - } - - if (chunkLength < 0) - { - ushort uchunkLength = (ushort)chunkLength; - binaryReader.ReadBytes(uchunkLength - 2); - } - else - { - binaryReader.ReadBytes(chunkLength - 2); - } - } - - throw new ArgumentException(ErrorMessage); - } - } -} diff --git a/Emby.Drawing/Emby.Drawing.csproj b/Emby.Drawing/Emby.Drawing.csproj index ba29c656b..c36d42194 100644 --- a/Emby.Drawing/Emby.Drawing.csproj +++ b/Emby.Drawing/Emby.Drawing.csproj @@ -1,17 +1,24 @@ <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>netstandard2.0</TargetFramework> + <GenerateAssemblyInfo>false</GenerateAssemblyInfo> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="SkiaSharp" Version="1.68.0" /> + <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="1.68.0" /> + <PackageReference Include="Jellyfin.SkiaSharp.NativeAssets.LinuxArm" Version="1.68.0" /> + </ItemGroup> + <ItemGroup> <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> + <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> </ItemGroup> <ItemGroup> <Compile Include="..\SharedVersion.cs" /> </ItemGroup> - <PropertyGroup> - <TargetFramework>netstandard2.0</TargetFramework> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> - </PropertyGroup> - </Project> diff --git a/Emby.Drawing/ImageProcessor.cs b/Emby.Drawing/ImageProcessor.cs index 28aae9cae..f91990442 100644 --- a/Emby.Drawing/ImageProcessor.cs +++ b/Emby.Drawing/ImageProcessor.cs @@ -1,3 +1,4 @@ +using SkiaSharp; using System; using System.Collections.Generic; using System.Globalization; @@ -5,7 +6,6 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Emby.Drawing.Common; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Controller.Drawing; @@ -422,10 +422,10 @@ namespace Emby.Drawing public ImageSize GetImageSize(BaseItem item, ItemImageInfo info) { - return GetImageSize(item, info, false, true); + return GetImageSize(item, info, true); } - public ImageSize GetImageSize(BaseItem item, ItemImageInfo info, bool allowSlowMethods, bool updateItem) + public ImageSize GetImageSize(BaseItem item, ItemImageInfo info, bool updateItem) { var width = info.Width; var height = info.Height; @@ -442,7 +442,7 @@ namespace Emby.Drawing var path = info.Path; _logger.LogInformation("Getting image size for item {0} {1}", item.GetType().Name, path); - var size = GetImageSize(path, allowSlowMethods); + var size = GetImageSize(path); info.Height = Convert.ToInt32(size.Height); info.Width = Convert.ToInt32(size.Width); @@ -455,34 +455,26 @@ namespace Emby.Drawing return size; } - public ImageSize GetImageSize(string path) - { - return GetImageSize(path, true); - } - /// <summary> /// Gets the size of the image. /// </summary> - private ImageSize GetImageSize(string path, bool allowSlowMethod) + public ImageSize GetImageSize(string path) { if (string.IsNullOrEmpty(path)) { throw new ArgumentNullException(nameof(path)); } - try - { - return ImageHeader.GetDimensions(path, _logger, _fileSystem); - } - catch - { - if (!allowSlowMethod) + using (var s = new SKFileStream(path)) + using (var codec = SKCodec.Create(s)) { - throw; + var info = codec.Info; + return new ImageSize + { + Height = info.Height, + Width = info.Width + }; } - } - - return _imageEncoder.GetImageSize(path); } /// <summary> diff --git a/Emby.Drawing/PercentPlayedDrawer.cs b/Emby.Drawing/PercentPlayedDrawer.cs new file mode 100644 index 000000000..3ab5f34bc --- /dev/null +++ b/Emby.Drawing/PercentPlayedDrawer.cs @@ -0,0 +1,31 @@ +using System; +using MediaBrowser.Model.Drawing; +using SkiaSharp; + +namespace Emby.Drawing +{ + public class PercentPlayedDrawer + { + private const int IndicatorHeight = 8; + + public void Process(SKCanvas canvas, ImageSize 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(Math.Round(foregroundWidth)), (float)endY), paint); + } + } + } +} diff --git a/Emby.Drawing/PlayedIndicatorDrawer.cs b/Emby.Drawing/PlayedIndicatorDrawer.cs new file mode 100644 index 000000000..92a164a5f --- /dev/null +++ b/Emby.Drawing/PlayedIndicatorDrawer.cs @@ -0,0 +1,58 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.IO; +using SkiaSharp; + +namespace Emby.Drawing +{ + public class PlayedIndicatorDrawer + { + private const int OffsetFromTopRightCorner = 38; + + private readonly IApplicationPaths _appPaths; + private readonly IHttpClient _iHttpClient; + private readonly IFileSystem _fileSystem; + + public PlayedIndicatorDrawer(IApplicationPaths appPaths, IHttpClient iHttpClient, IFileSystem fileSystem) + { + _appPaths = appPaths; + _iHttpClient = iHttpClient; + _fileSystem = fileSystem; + } + + public void DrawPlayedIndicator(SKCanvas canvas, ImageSize 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 new file mode 100644 index 000000000..dc571f4a0 --- /dev/null +++ b/Emby.Drawing/SkiaEncoder.cs @@ -0,0 +1,663 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +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 Func<IHttpClient> _httpClientFactory; + private readonly IFileSystem _fileSystem; + private static ILocalizationManager _localizationManager; + + public SkiaEncoder(ILogger logger, IApplicationPaths appPaths, Func<IHttpClient> httpClientFactory, IFileSystem fileSystem, ILocalizationManager localizationManager) + { + _logger = logger; + _appPaths = appPaths; + _httpClientFactory = httpClientFactory; + _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 ImageSize GetImageSize(string path) + { + using (var s = new SKFileStream(path)) + using (var codec = SKCodec.Create(s)) + { + var info = codec.Info; + + return new ImageSize + { + Width = info.Width, + Height = 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); + + if (bitmap != null) + { + // decode + codec.GetPixels(bitmap.Info, bitmap.GetPixels()); + + origin = codec.EncodedOrigin; + } + else + { + origin = GetSKEncodedOrigin(orientation); + } + + 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 ImageSize(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 = Convert.ToInt32(Math.Round(newImageSize.Width)); + var height = Convert.ToInt32(Math.Round(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 ImageSize(imageWidth, imageHeight); + + if (options.AddPlayedIndicator) + { + new PlayedIndicatorDrawer(_appPaths, _httpClientFactory(), _fileSystem).DrawPlayedIndicator(canvas, currentImageSize); + } + else if (options.UnplayedCount.HasValue) + { + new UnplayedCountIndicator(_appPaths, _httpClientFactory(), _fileSystem).DrawUnplayedCountIndicator(canvas, currentImageSize, options.UnplayedCount.Value); + } + + if (options.PercentPlayed > 0) + { + new 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 new file mode 100644 index 000000000..dd342998b --- /dev/null +++ b/Emby.Drawing/StripCollageBuilder.cs @@ -0,0 +1,234 @@ +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<int, int>(); + 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 new file mode 100644 index 000000000..caa3e465b --- /dev/null +++ b/Emby.Drawing/UnplayedCountIndicator.cs @@ -0,0 +1,65 @@ +using System.Globalization; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.IO; +using SkiaSharp; + +namespace Emby.Drawing +{ + public class UnplayedCountIndicator + { + private const int OffsetFromTopRightCorner = 38; + + private readonly IApplicationPaths _appPaths; + private readonly IHttpClient _iHttpClient; + private readonly IFileSystem _fileSystem; + + public UnplayedCountIndicator(IApplicationPaths appPaths, IHttpClient iHttpClient, IFileSystem fileSystem) + { + _appPaths = appPaths; + _iHttpClient = iHttpClient; + _fileSystem = fileSystem; + } + + public void DrawUnplayedCountIndicator(SKCanvas canvas, ImageSize 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); + } + } + } +} |
