diff options
| author | LogicalPhallacy <44458166+LogicalPhallacy@users.noreply.github.com> | 2019-01-23 00:31:35 -0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2019-01-23 00:31:35 -0800 |
| commit | 404bd04cbc17dc8c8bf4a5c9aa3ca9c5cd85aa68 (patch) | |
| tree | 3d267c6ceef9439a034c113095e10e4d619e7c70 /Emby.Drawing | |
| parent | 8ff89fdc0c30f595a171ffc550f907ef22b6212a (diff) | |
| parent | e05e002b8bb4d13eb2b80b56a0aad8903ddb701e (diff) | |
Merge pull request #8 from jellyfin/master
rebase to latest master
Diffstat (limited to 'Emby.Drawing')
| -rw-r--r-- | Emby.Drawing/Common/ImageHeader.cs | 238 | ||||
| -rw-r--r-- | Emby.Drawing/Emby.Drawing.csproj | 19 | ||||
| -rw-r--r-- | Emby.Drawing/ImageProcessor.cs | 397 | ||||
| -rw-r--r-- | Emby.Drawing/NullImageEncoder.cs | 42 | ||||
| -rw-r--r-- | Emby.Drawing/PercentPlayedDrawer.cs | 31 | ||||
| -rw-r--r-- | Emby.Drawing/PlayedIndicatorDrawer.cs | 44 | ||||
| -rw-r--r-- | Emby.Drawing/Properties/AssemblyInfo.cs | 18 | ||||
| -rw-r--r-- | Emby.Drawing/SkiaEncoder.cs | 664 | ||||
| -rw-r--r-- | Emby.Drawing/StripCollageBuilder.cs | 234 | ||||
| -rw-r--r-- | Emby.Drawing/UnplayedCountIndicator.cs | 51 |
10 files changed, 1213 insertions, 525 deletions
diff --git a/Emby.Drawing/Common/ImageHeader.cs b/Emby.Drawing/Common/ImageHeader.cs deleted file mode 100644 index 7b819c2fd..000000000 --- a/Emby.Drawing/Common/ImageHeader.cs +++ /dev/null @@ -1,238 +0,0 @@ -using MediaBrowser.Model.Drawing; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; - -using MediaBrowser.Controller.IO; -using MediaBrowser.Model.IO; - -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) - { - var extension = Path.GetExtension(path); - - if (string.IsNullOrEmpty(extension)) - { - throw new ArgumentException("ImageHeader doesn't support image file"); - } - 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="System.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, "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(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(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="System.ArgumentException"></exception> - private static ImageSize DecodeJfif(BinaryReader binaryReader) - { - while (binaryReader.ReadByte() == 0xff) - { - byte marker = binaryReader.ReadByte(); - short chunkLength = ReadLittleEndianInt16(binaryReader); - if (marker == 0xc0) - { - binaryReader.ReadByte(); - int height = ReadLittleEndianInt16(binaryReader); - int width = ReadLittleEndianInt16(binaryReader); - return new ImageSize - { - Width = width, - Height = height - }; - } - - if (chunkLength < 0) - { - var 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 5ffaaed27..c36d42194 100644 --- a/Emby.Drawing/Emby.Drawing.csproj +++ b/Emby.Drawing/Emby.Drawing.csproj @@ -1,17 +1,24 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<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 6a67be56d..c750b60e2 100644 --- a/Emby.Drawing/ImageProcessor.cs +++ b/Emby.Drawing/ImageProcessor.cs @@ -1,27 +1,24 @@ -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Drawing; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Drawing; -using MediaBrowser.Model.Entities; -using Microsoft.Extensions.Logging; -using MediaBrowser.Model.Serialization; using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Model.IO; -using Emby.Drawing.Common; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.Threading; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Extensions; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; +using Microsoft.Extensions.Logging; +using SkiaSharp; namespace Emby.Drawing { @@ -48,106 +45,83 @@ namespace Emby.Drawing private readonly ILogger _logger; private readonly IFileSystem _fileSystem; - private readonly IJsonSerializer _jsonSerializer; private readonly IServerApplicationPaths _appPaths; private IImageEncoder _imageEncoder; private readonly Func<ILibraryManager> _libraryManager; private readonly Func<IMediaEncoder> _mediaEncoder; - public ImageProcessor(ILogger logger, + public ImageProcessor( + ILoggerFactory loggerFactory, IServerApplicationPaths appPaths, IFileSystem fileSystem, - IJsonSerializer jsonSerializer, IImageEncoder imageEncoder, - Func<ILibraryManager> libraryManager, ITimerFactory timerFactory, Func<IMediaEncoder> mediaEncoder) + Func<ILibraryManager> libraryManager, + Func<IMediaEncoder> mediaEncoder) { - _logger = logger; + _logger = loggerFactory.CreateLogger(nameof(ImageProcessor)); _fileSystem = fileSystem; - _jsonSerializer = jsonSerializer; _imageEncoder = imageEncoder; _libraryManager = libraryManager; _mediaEncoder = mediaEncoder; _appPaths = appPaths; - ImageEnhancers = new IImageEnhancer[] { }; + ImageEnhancers = Array.Empty<IImageEnhancer>(); + ImageHelper.ImageProcessor = this; } public IImageEncoder ImageEncoder { - get { return _imageEncoder; } + get => _imageEncoder; set { if (value == null) { - throw new ArgumentNullException("value"); + throw new ArgumentNullException(nameof(value)); } _imageEncoder = value; } } - public string[] SupportedInputFormats - { - get + public string[] SupportedInputFormats => + new string[] { - return new string[] - { - "tiff", - "tif", - "jpeg", - "jpg", - "png", - "aiff", - "cr2", - "crw", - - // Remove until supported - //"nef", - "orf", - "pef", - "arw", - "webp", - "gif", - "bmp", - "erf", - "raf", - "rw2", - "nrw", - "dng", - "ico", - "astc", - "ktx", - "pkm", - "wbmp" - }; - } - } + "tiff", + "tif", + "jpeg", + "jpg", + "png", + "aiff", + "cr2", + "crw", + // Remove until supported + //"nef", + "orf", + "pef", + "arw", + "webp", + "gif", + "bmp", + "erf", + "raf", + "rw2", + "nrw", + "dng", + "ico", + "astc", + "ktx", + "pkm", + "wbmp" + }; - public bool SupportsImageCollageCreation - { - get - { - return _imageEncoder.SupportsImageCollageCreation; - } - } - private string ResizedImageCachePath - { - get - { - return Path.Combine(_appPaths.ImageCachePath, "resized-images"); - } - } + public bool SupportsImageCollageCreation => _imageEncoder.SupportsImageCollageCreation; - private string EnhancedImageCachePath - { - get - { - return Path.Combine(_appPaths.ImageCachePath, "enhanced-images"); - } - } + private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images"); + + private string EnhancedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "enhanced-images"); public void AddParts(IEnumerable<IImageEnhancer> enhancers) { @@ -169,21 +143,19 @@ namespace Emby.Drawing return _imageEncoder.SupportedOutputFormats; } - private readonly string[] TransparentImageTypes = new string[] { ".png", ".webp", ".gif" }; + private static readonly string[] TransparentImageTypes = new string[] { ".png", ".webp", ".gif" }; public bool SupportsTransparency(string path) - { - return TransparentImageTypes.Contains(Path.GetExtension(path) ?? string.Empty); - } + => TransparentImageTypes.Contains(Path.GetExtension(path).ToLower()); - public async Task<Tuple<string, string, DateTime>> ProcessImage(ImageProcessingOptions options) + public async Task<(string path, string mimeType, DateTime dateModified)> ProcessImage(ImageProcessingOptions options) { if (options == null) { - throw new ArgumentNullException("options"); + throw new ArgumentNullException(nameof(options)); } - var originalImage = options.Image; - var item = options.Item; + ItemImageInfo originalImage = options.Image; + BaseItem item = options.Item; if (!originalImage.IsLocalFile) { @@ -194,19 +166,23 @@ namespace Emby.Drawing originalImage = await _libraryManager().ConvertImageToLocal(item, originalImage, options.ImageIndex).ConfigureAwait(false); } - var originalImagePath = originalImage.Path; - var dateModified = originalImage.DateModified; - var originalImageSize = originalImage.Width > 0 && originalImage.Height > 0 ? new ImageSize(originalImage.Width, originalImage.Height) : (ImageSize?)null; + string originalImagePath = originalImage.Path; + DateTime dateModified = originalImage.DateModified; + ImageSize? originalImageSize = null; + if (originalImage.Width > 0 && originalImage.Height > 0) + { + originalImageSize = new ImageSize(originalImage.Width, originalImage.Height); + } if (!_imageEncoder.SupportsImageEncoding) { - return new Tuple<string, string, DateTime>(originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); + return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); } var supportedImageInfo = await GetSupportedImage(originalImagePath, dateModified).ConfigureAwait(false); - originalImagePath = supportedImageInfo.Item1; - dateModified = supportedImageInfo.Item2; - var requiresTransparency = TransparentImageTypes.Contains(Path.GetExtension(originalImagePath) ?? string.Empty); + originalImagePath = supportedImageInfo.path; + dateModified = supportedImageInfo.dateModified; + bool requiresTransparency = TransparentImageTypes.Contains(Path.GetExtension(originalImagePath)); if (options.Enhancers.Length > 0) { @@ -220,20 +196,18 @@ namespace Emby.Drawing DateModified = dateModified, Type = originalImage.Type, Path = originalImagePath - }, requiresTransparency, item, options.ImageIndex, options.Enhancers, CancellationToken.None).ConfigureAwait(false); - originalImagePath = tuple.Item1; - dateModified = tuple.Item2; - requiresTransparency = tuple.Item3; + originalImagePath = tuple.path; + dateModified = tuple.dateModified; + requiresTransparency = tuple.transparent; // TODO: Get this info originalImageSize = null; } - var photo = item as Photo; - var autoOrient = false; + bool autoOrient = false; ImageOrientation? orientation = null; - if (photo != null) + if (item is Photo photo) { if (photo.Orientation.HasValue) { @@ -254,26 +228,18 @@ namespace Emby.Drawing if (options.HasDefaultOptions(originalImagePath, originalImageSize) && (!autoOrient || !options.RequiresAutoOrientation)) { // Just spit out the original file if all the options are default - return new Tuple<string, string, DateTime>(originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); + return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); } - //ImageSize? originalImageSize = GetSavedImageSize(originalImagePath, dateModified); - //if (originalImageSize.HasValue && options.HasDefaultOptions(originalImagePath, originalImageSize.Value) && !autoOrient) - //{ - // // Just spit out the original file if all the options are default - // _logger.LogInformation("Returning original image {0}", originalImagePath); - // return new ValueTuple<string, string, DateTime>(originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); - //} - - var newSize = ImageHelper.GetNewImageSize(options, null); - var quality = options.Quality; + ImageSize newSize = ImageHelper.GetNewImageSize(options, null); + int quality = options.Quality; - var outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency); - var cacheFilePath = GetCacheFilePath(originalImagePath, newSize, quality, dateModified, outputFormat, options.AddPlayedIndicator, options.PercentPlayed, options.UnplayedCount, options.Blur, options.BackgroundColor, options.ForegroundLayer); + ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency); + string cacheFilePath = GetCacheFilePath(originalImagePath, newSize, quality, dateModified, outputFormat, options.AddPlayedIndicator, options.PercentPlayed, options.UnplayedCount, options.Blur, options.BackgroundColor, options.ForegroundLayer); CheckDisposed(); - var lockInfo = GetLock(cacheFilePath); + LockInfo lockInfo = GetLock(cacheFilePath); await lockInfo.Lock.WaitAsync().ConfigureAwait(false); @@ -286,17 +252,15 @@ namespace Emby.Drawing options.CropWhiteSpace = false; } - var resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat); + string resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat); if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase)) { - return new Tuple<string, string, DateTime>(originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); + return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); } - - return new Tuple<string, string, DateTime>(cacheFilePath, GetMimeType(outputFormat, cacheFilePath), _fileSystem.GetLastWriteTimeUtc(cacheFilePath)); } - return new Tuple<string, string, DateTime>(cacheFilePath, GetMimeType(outputFormat, cacheFilePath), _fileSystem.GetLastWriteTimeUtc(cacheFilePath)); + return (cacheFilePath, GetMimeType(outputFormat, cacheFilePath), _fileSystem.GetLastWriteTimeUtc(cacheFilePath)); } catch (ArgumentOutOfRangeException ex) { @@ -305,7 +269,7 @@ namespace Emby.Drawing _logger.LogError(ex, "Error encoding image"); #endif // Just spit out the original file if all the options are default - return new Tuple<string, string, DateTime>(originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); + return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); } catch (Exception ex) { @@ -313,7 +277,7 @@ namespace Emby.Drawing _logger.LogError(ex, "Error encoding image"); // Just spit out the original file if all the options are default - return new Tuple<string, string, DateTime>(originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); + return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); } finally { @@ -349,42 +313,17 @@ namespace Emby.Drawing return ImageFormat.Jpg; } - private void CopyFile(string src, string destination) - { - try - { - _fileSystem.CopyFile(src, destination, true); - } - catch - { - - } - } - private string GetMimeType(ImageFormat format, string path) { - if (format == ImageFormat.Bmp) - { - return MimeTypes.GetMimeType("i.bmp"); - } - if (format == ImageFormat.Gif) - { - return MimeTypes.GetMimeType("i.gif"); - } - if (format == ImageFormat.Jpg) - { - return MimeTypes.GetMimeType("i.jpg"); - } - if (format == ImageFormat.Png) + switch(format) { - return MimeTypes.GetMimeType("i.png"); + case ImageFormat.Bmp: return MimeTypes.GetMimeType("i.bmp"); + case ImageFormat.Gif: return MimeTypes.GetMimeType("i.gif"); + case ImageFormat.Jpg: return MimeTypes.GetMimeType("i.jpg"); + case ImageFormat.Png: return MimeTypes.GetMimeType("i.png"); + case ImageFormat.Webp: return MimeTypes.GetMimeType("i.webp"); + default: return MimeTypes.GetMimeType(path); } - if (format == ImageFormat.Webp) - { - return MimeTypes.GetMimeType("i.webp"); - } - - return MimeTypes.GetMimeType(path); } /// <summary> @@ -397,17 +336,12 @@ namespace Emby.Drawing /// </summary> private string GetCacheFilePath(string originalPath, ImageSize outputSize, int quality, DateTime dateModified, ImageFormat format, bool addPlayedIndicator, double percentPlayed, int? unwatchedCount, int? blur, string backgroundColor, string foregroundLayer) { - var filename = originalPath; - - filename += "width=" + outputSize.Width; - - filename += "height=" + outputSize.Height; - - filename += "quality=" + quality; - - filename += "datemodified=" + dateModified.Ticks; - - filename += "f=" + format; + var filename = originalPath + + "width=" + outputSize.Width + + "height=" + outputSize.Height + + "quality=" + quality + + "datemodified=" + dateModified.Ticks + + "f=" + format; if (addPlayedIndicator) { @@ -445,28 +379,22 @@ namespace Emby.Drawing } public ImageSize GetImageSize(BaseItem item, ItemImageInfo info) - { - return GetImageSize(item, info, false, true); - } + => 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; + int width = info.Width; + int height = info.Height; if (height > 0 && width > 0) { - return new ImageSize - { - Width = width, - Height = height - }; + return new ImageSize(width, height); } - var path = info.Path; - _logger.LogInformation("Getting image size for item {0} {1}", item.GetType().Name, path); + string path = info.Path; + _logger.LogInformation("Getting image size for item {ItemType} {Path}", 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); @@ -479,34 +407,22 @@ 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("path"); + throw new ArgumentNullException(nameof(path)); } - try + using (var s = new SKFileStream(path)) + using (var codec = SKCodec.Create(s)) { - return ImageHeader.GetDimensions(path, _logger, _fileSystem); + var info = codec.Info; + return new ImageSize(info.Width, info.Height); } - catch - { - if (!allowSlowMethod) - { - throw; - } - } - - return _imageEncoder.GetImageSize(path); } /// <summary> @@ -515,7 +431,7 @@ namespace Emby.Drawing /// <param name="item">The item.</param> /// <param name="image">The image.</param> /// <returns>Guid.</returns> - /// <exception cref="System.ArgumentNullException">item</exception> + /// <exception cref="ArgumentNullException">item</exception> public string GetImageCacheTag(BaseItem item, ItemImageInfo image) { var supportedEnhancers = GetSupportedEnhancers(item, image.Type); @@ -547,12 +463,12 @@ namespace Emby.Drawing /// <param name="image">The image.</param> /// <param name="imageEnhancers">The image enhancers.</param> /// <returns>Guid.</returns> - /// <exception cref="System.ArgumentNullException">item</exception> + /// <exception cref="ArgumentNullException">item</exception> public string GetImageCacheTag(BaseItem item, ItemImageInfo image, IImageEnhancer[] imageEnhancers) { - var originalImagePath = image.Path; - var dateModified = image.DateModified; - var imageType = image.Type; + string originalImagePath = image.Path; + DateTime dateModified = image.DateModified; + ImageType imageType = image.Type; // Optimization if (imageEnhancers.Length == 0) @@ -564,28 +480,28 @@ namespace Emby.Drawing var cacheKeys = imageEnhancers.Select(i => i.GetConfigurationCacheKey(item, imageType)).ToList(); cacheKeys.Add(originalImagePath + dateModified.Ticks); - return string.Join("|", cacheKeys.ToArray()).GetMD5().ToString("N"); + return string.Join("|", cacheKeys).GetMD5().ToString("N"); } - private async Task<ValueTuple<string, DateTime>> GetSupportedImage(string originalImagePath, DateTime dateModified) + private async Task<(string path, DateTime dateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified) { - var inputFormat = (Path.GetExtension(originalImagePath) ?? string.Empty) + var inputFormat = Path.GetExtension(originalImagePath) .TrimStart('.') .Replace("jpeg", "jpg", StringComparison.OrdinalIgnoreCase); // These are just jpg files renamed as tbn if (string.Equals(inputFormat, "tbn", StringComparison.OrdinalIgnoreCase)) { - return new ValueTuple<string, DateTime>(originalImagePath, dateModified); + return (originalImagePath, dateModified); } if (!_imageEncoder.SupportedInputFormats.Contains(inputFormat, StringComparer.OrdinalIgnoreCase)) { try { - var filename = (originalImagePath + dateModified.Ticks.ToString(UsCulture)).GetMD5().ToString("N"); + string filename = (originalImagePath + dateModified.Ticks.ToString(UsCulture)).GetMD5().ToString("N"); - var cacheExtension = _mediaEncoder().SupportsEncoder("libwebp") ? ".webp" : ".png"; + string cacheExtension = _mediaEncoder().SupportsEncoder("libwebp") ? ".webp" : ".png"; var outputPath = Path.Combine(_appPaths.ImageCachePath, "converted-images", filename + cacheExtension); var file = _fileSystem.GetFileInfo(outputPath); @@ -603,11 +519,11 @@ namespace Emby.Drawing } catch (Exception ex) { - _logger.LogError(ex, "Image conversion failed for {originalImagePath}", originalImagePath); + _logger.LogError(ex, "Image conversion failed for {Path}", originalImagePath); } } - return new ValueTuple<string, DateTime>(originalImagePath, dateModified); + return (originalImagePath, dateModified); } /// <summary> @@ -621,16 +537,17 @@ namespace Emby.Drawing { var enhancers = GetSupportedEnhancers(item, imageType); - var imageInfo = item.GetImageInfo(imageType, imageIndex); + ItemImageInfo imageInfo = item.GetImageInfo(imageType, imageIndex); - var inputImageSupportsTransparency = SupportsTransparency(imageInfo.Path); + bool inputImageSupportsTransparency = SupportsTransparency(imageInfo.Path); var result = await GetEnhancedImage(imageInfo, inputImageSupportsTransparency, item, imageIndex, enhancers, CancellationToken.None); - return result.Item1; + return result.path; } - private async Task<ValueTuple<string, DateTime, bool>> GetEnhancedImage(ItemImageInfo image, + private async Task<(string path, DateTime dateModified, bool transparent)> GetEnhancedImage( + ItemImageInfo image, bool inputImageSupportsTransparency, BaseItem item, int imageIndex, @@ -648,14 +565,14 @@ namespace Emby.Drawing // Enhance if we have enhancers var enhancedImageInfo = await GetEnhancedImageInternal(originalImagePath, item, imageType, imageIndex, enhancers, cacheGuid, cancellationToken).ConfigureAwait(false); - var enhancedImagePath = enhancedImageInfo.Item1; + string enhancedImagePath = enhancedImageInfo.path; // If the path changed update dateModified if (!string.Equals(enhancedImagePath, originalImagePath, StringComparison.OrdinalIgnoreCase)) { - var treatmentRequiresTransparency = enhancedImageInfo.Item2; + var treatmentRequiresTransparency = enhancedImageInfo.transparent; - return new ValueTuple<string, DateTime, bool>(enhancedImagePath, _fileSystem.GetLastWriteTimeUtc(enhancedImagePath), treatmentRequiresTransparency); + return (enhancedImagePath, _fileSystem.GetLastWriteTimeUtc(enhancedImagePath), treatmentRequiresTransparency); } } catch (Exception ex) @@ -663,7 +580,7 @@ namespace Emby.Drawing _logger.LogError(ex, "Error enhancing image"); } - return new ValueTuple<string, DateTime, bool>(originalImagePath, dateModified, inputImageSupportsTransparency); + return (originalImagePath, dateModified, inputImageSupportsTransparency); } /// <summary> @@ -681,7 +598,8 @@ namespace Emby.Drawing /// or /// item /// </exception> - private async Task<ValueTuple<string, bool>> GetEnhancedImageInternal(string originalImagePath, + private async Task<(string path, bool transparent)> GetEnhancedImageInternal( + string originalImagePath, BaseItem item, ImageType imageType, int imageIndex, @@ -691,12 +609,12 @@ namespace Emby.Drawing { if (string.IsNullOrEmpty(originalImagePath)) { - throw new ArgumentNullException("originalImagePath"); + throw new ArgumentNullException(nameof(originalImagePath)); } if (item == null) { - throw new ArgumentNullException("item"); + throw new ArgumentNullException(nameof(item)); } var treatmentRequiresTransparency = false; @@ -709,13 +627,13 @@ namespace Emby.Drawing } // All enhanced images are saved as png to allow transparency - var cacheExtension = _imageEncoder.SupportedOutputFormats.Contains(ImageFormat.Webp) ? + string cacheExtension = _imageEncoder.SupportedOutputFormats.Contains(ImageFormat.Webp) ? ".webp" : (treatmentRequiresTransparency ? ".png" : ".jpg"); - var enhancedImagePath = GetCachePath(EnhancedImageCachePath, cacheGuid + cacheExtension); + string enhancedImagePath = GetCachePath(EnhancedImageCachePath, cacheGuid + cacheExtension); - var lockInfo = GetLock(enhancedImagePath); + LockInfo lockInfo = GetLock(enhancedImagePath); await lockInfo.Lock.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -724,14 +642,14 @@ namespace Emby.Drawing // Check again in case of contention if (_fileSystem.FileExists(enhancedImagePath)) { - return new ValueTuple<string, bool>(enhancedImagePath, treatmentRequiresTransparency); + return (enhancedImagePath, treatmentRequiresTransparency); } _fileSystem.CreateDirectory(_fileSystem.GetDirectoryName(enhancedImagePath)); await ExecuteImageEnhancers(supportedEnhancers, originalImagePath, enhancedImagePath, item, imageType, imageIndex).ConfigureAwait(false); - return new ValueTuple<string, bool>(enhancedImagePath, treatmentRequiresTransparency); + return (enhancedImagePath, treatmentRequiresTransparency); } finally { @@ -749,7 +667,7 @@ namespace Emby.Drawing /// <param name="imageType">Type of the image.</param> /// <param name="imageIndex">Index of the image.</param> /// <returns>Task{EnhancedImage}.</returns> - private async Task ExecuteImageEnhancers(IEnumerable<IImageEnhancer> imageEnhancers, string inputPath, string outputPath, BaseItem item, ImageType imageType, int imageIndex) + private static async Task ExecuteImageEnhancers(IEnumerable<IImageEnhancer> imageEnhancers, string inputPath, string outputPath, BaseItem item, ImageType imageType, int imageIndex) { // Run the enhancers sequentially in order of priority foreach (var enhancer in imageEnhancers) @@ -768,7 +686,7 @@ namespace Emby.Drawing /// <param name="uniqueName">Name of the unique.</param> /// <param name="fileExtension">The file extension.</param> /// <returns>System.String.</returns> - /// <exception cref="System.ArgumentNullException"> + /// <exception cref="ArgumentNullException"> /// path /// or /// uniqueName @@ -779,16 +697,16 @@ namespace Emby.Drawing { if (string.IsNullOrEmpty(path)) { - throw new ArgumentNullException("path"); + throw new ArgumentNullException(nameof(path)); } if (string.IsNullOrEmpty(uniqueName)) { - throw new ArgumentNullException("uniqueName"); + throw new ArgumentNullException(nameof(uniqueName)); } if (string.IsNullOrEmpty(fileExtension)) { - throw new ArgumentNullException("fileExtension"); + throw new ArgumentNullException(nameof(fileExtension)); } var filename = uniqueName.GetMD5() + fileExtension; @@ -802,7 +720,7 @@ namespace Emby.Drawing /// <param name="path">The path.</param> /// <param name="filename">The filename.</param> /// <returns>System.String.</returns> - /// <exception cref="System.ArgumentNullException"> + /// <exception cref="ArgumentNullException"> /// path /// or /// filename @@ -811,27 +729,25 @@ namespace Emby.Drawing { if (string.IsNullOrEmpty(path)) { - throw new ArgumentNullException("path"); + throw new ArgumentNullException(nameof(path)); } if (string.IsNullOrEmpty(filename)) { - throw new ArgumentNullException("filename"); + throw new ArgumentNullException(nameof(filename)); } var prefix = filename.Substring(0, 1); - path = Path.Combine(path, prefix); - - return Path.Combine(path, filename); + return Path.Combine(path, prefix, filename); } public void CreateImageCollage(ImageCollageOptions options) { - _logger.LogInformation("Creating image collage and saving to {0}", options.OutputPath); + _logger.LogInformation("Creating image collage and saving to {Path}", options.OutputPath); _imageEncoder.CreateImageCollage(options); - _logger.LogInformation("Completed creation of image collage and saved to {0}", options.OutputPath); + _logger.LogInformation("Completed creation of image collage and saved to {Path}", options.OutputPath); } public IImageEnhancer[] GetSupportedEnhancers(BaseItem item, ImageType imageType) @@ -870,8 +786,7 @@ namespace Emby.Drawing { lock (_locks) { - LockInfo info; - if (_locks.TryGetValue(key, out info)) + if (_locks.TryGetValue(key, out LockInfo info)) { info.Count++; } diff --git a/Emby.Drawing/NullImageEncoder.cs b/Emby.Drawing/NullImageEncoder.cs index 95ea42ecf..e6f205a1f 100644 --- a/Emby.Drawing/NullImageEncoder.cs +++ b/Emby.Drawing/NullImageEncoder.cs @@ -1,4 +1,4 @@ -using System; +using System; using MediaBrowser.Controller.Drawing; using MediaBrowser.Model.Drawing; @@ -6,26 +6,15 @@ namespace Emby.Drawing { public class NullImageEncoder : IImageEncoder { - public string[] SupportedInputFormats - { - get + public string[] SupportedInputFormats => + new[] { - return new[] - { - "png", - "jpeg", - "jpg" - }; - } - } + "png", + "jpeg", + "jpg" + }; - public ImageFormat[] SupportedOutputFormats - { - get - { - return new[] { ImageFormat.Jpg, ImageFormat.Png }; - } - } + public ImageFormat[] SupportedOutputFormats => new[] { ImageFormat.Jpg, ImageFormat.Png }; public void CropWhiteSpace(string inputPath, string outputPath) { @@ -42,20 +31,11 @@ namespace Emby.Drawing throw new NotImplementedException(); } - public string Name - { - get { return "Null Image Encoder"; } - } + public string Name => "Null Image Encoder"; - public bool SupportsImageCollageCreation - { - get { return false; } - } + public bool SupportsImageCollageCreation => false; - public bool SupportsImageEncoding - { - get { return false; } - } + public bool SupportsImageEncoding => false; public ImageSize GetImageSize(string path) { diff --git a/Emby.Drawing/PercentPlayedDrawer.cs b/Emby.Drawing/PercentPlayedDrawer.cs new file mode 100644 index 000000000..52b4329e1 --- /dev/null +++ b/Emby.Drawing/PercentPlayedDrawer.cs @@ -0,0 +1,31 @@ +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, 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..a82398fa5 --- /dev/null +++ b/Emby.Drawing/PlayedIndicatorDrawer.cs @@ -0,0 +1,44 @@ +using MediaBrowser.Model.Drawing; +using SkiaSharp; + +namespace Emby.Drawing +{ + public static class PlayedIndicatorDrawer + { + private const int OffsetFromTopRightCorner = 38; + + public static 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/Properties/AssemblyInfo.cs b/Emby.Drawing/Properties/AssemblyInfo.cs index b9e9c2ff7..8dfefe0af 100644 --- a/Emby.Drawing/Properties/AssemblyInfo.cs +++ b/Emby.Drawing/Properties/AssemblyInfo.cs @@ -1,20 +1,20 @@ -using System.Reflection; +using System.Reflection; using System.Runtime.InteropServices; -// General Information about an assembly is controlled through the following +// General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information // associated with an assembly. [assembly: AssemblyTitle("Emby.Drawing")] [assembly: AssemblyDescription("")] [assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Emby.Drawing")] -[assembly: AssemblyCopyright("Copyright © 2015")] +[assembly: AssemblyCompany("Jellyfin Project")] +[assembly: AssemblyProduct("Jellyfin: The Free Software Media System")] +[assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License Version 2")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from // COM, set the ComVisible attribute to true on that type. [assembly: ComVisible(false)] @@ -24,7 +24,7 @@ using System.Runtime.InteropServices; // Version information for an assembly consists of the following four values: // // Major Version -// Minor Version +// Minor Version // Build Number // Revision -//
\ No newline at end of file +// diff --git a/Emby.Drawing/SkiaEncoder.cs b/Emby.Drawing/SkiaEncoder.cs new file mode 100644 index 000000000..87e0eca21 --- /dev/null +++ b/Emby.Drawing/SkiaEncoder.cs @@ -0,0 +1,664 @@ +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 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) + { + 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 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..16c084a21 --- /dev/null +++ b/Emby.Drawing/UnplayedCountIndicator.cs @@ -0,0 +1,51 @@ +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, 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); + } + } + } +} |
