diff options
| author | Patrick Barron <18354464+barronpm@users.noreply.github.com> | 2023-01-10 07:51:46 -0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-01-10 05:51:46 -0700 |
| commit | b5da0d1b1775bbbf7acb29a5ebebca7ccd9e8f2e (patch) | |
| tree | 18973aefad6b37b1d0180ca353d37c4f14c829ad /src | |
| parent | 8af854315ef2ad78d37c25fd3def4efe6392a3ca (diff) | |
Rename Emby.Drawing and move to src (#9054)
* Move Emby.Drawing to src
* Rename Emby.Drawing -> Jellyfin.Drawing
Diffstat (limited to 'src')
| -rw-r--r-- | src/Jellyfin.Drawing/ImageProcessor.cs | 569 | ||||
| -rw-r--r-- | src/Jellyfin.Drawing/Jellyfin.Drawing.csproj | 35 | ||||
| -rw-r--r-- | src/Jellyfin.Drawing/NullImageEncoder.cs | 58 | ||||
| -rw-r--r-- | src/Jellyfin.Drawing/Properties/AssemblyInfo.cs | 30 |
4 files changed, 692 insertions, 0 deletions
diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs new file mode 100644 index 000000000..3c7bc394f --- /dev/null +++ b/src/Jellyfin.Drawing/ImageProcessor.cs @@ -0,0 +1,569 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; +using Microsoft.Extensions.Logging; +using Photo = MediaBrowser.Controller.Entities.Photo; + +namespace Jellyfin.Drawing +{ + /// <summary> + /// Class ImageProcessor. + /// </summary> + public sealed class ImageProcessor : IImageProcessor, IDisposable + { + // Increment this when there's a change requiring caches to be invalidated + private const char Version = '3'; + + private static readonly HashSet<string> _transparentImageTypes + = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" }; + + private readonly ILogger<ImageProcessor> _logger; + private readonly IFileSystem _fileSystem; + private readonly IServerApplicationPaths _appPaths; + private readonly IImageEncoder _imageEncoder; + private readonly IMediaEncoder _mediaEncoder; + + private bool _disposed; + + /// <summary> + /// Initializes a new instance of the <see cref="ImageProcessor"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="appPaths">The server application paths.</param> + /// <param name="fileSystem">The filesystem.</param> + /// <param name="imageEncoder">The image encoder.</param> + /// <param name="mediaEncoder">The media encoder.</param> + public ImageProcessor( + ILogger<ImageProcessor> logger, + IServerApplicationPaths appPaths, + IFileSystem fileSystem, + IImageEncoder imageEncoder, + IMediaEncoder mediaEncoder) + { + _logger = logger; + _fileSystem = fileSystem; + _imageEncoder = imageEncoder; + _mediaEncoder = mediaEncoder; + _appPaths = appPaths; + } + + private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images"); + + /// <inheritdoc /> + public IReadOnlyCollection<string> SupportedInputFormats => + new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + "tiff", + "tif", + "jpeg", + "jpg", + "png", + "aiff", + "cr2", + "crw", + "nef", + "orf", + "pef", + "arw", + "webp", + "gif", + "bmp", + "erf", + "raf", + "rw2", + "nrw", + "dng", + "ico", + "astc", + "ktx", + "pkm", + "wbmp" + }; + + /// <inheritdoc /> + public bool SupportsImageCollageCreation => _imageEncoder.SupportsImageCollageCreation; + + /// <inheritdoc /> + public async Task ProcessImage(ImageProcessingOptions options, Stream toStream) + { + var file = await ProcessImage(options).ConfigureAwait(false); + using (var fileStream = AsyncFile.OpenRead(file.Path)) + { + await fileStream.CopyToAsync(toStream).ConfigureAwait(false); + } + } + + /// <inheritdoc /> + public IReadOnlyCollection<ImageFormat> GetSupportedImageOutputFormats() + => _imageEncoder.SupportedOutputFormats; + + /// <inheritdoc /> + public bool SupportsTransparency(string path) + => _transparentImageTypes.Contains(Path.GetExtension(path)); + + /// <inheritdoc /> + public async Task<(string Path, string? MimeType, DateTime DateModified)> ProcessImage(ImageProcessingOptions options) + { + ItemImageInfo originalImage = options.Image; + BaseItem item = options.Item; + + string originalImagePath = originalImage.Path; + DateTime dateModified = originalImage.DateModified; + ImageDimensions? originalImageSize = null; + if (originalImage.Width > 0 && originalImage.Height > 0) + { + originalImageSize = new ImageDimensions(originalImage.Width, originalImage.Height); + } + + var mimeType = MimeTypes.GetMimeType(originalImagePath); + if (!_imageEncoder.SupportsImageEncoding) + { + return (originalImagePath, mimeType, dateModified); + } + + var supportedImageInfo = await GetSupportedImage(originalImagePath, dateModified).ConfigureAwait(false); + originalImagePath = supportedImageInfo.Path; + + // Original file doesn't exist, or original file is gif. + if (!File.Exists(originalImagePath) || string.Equals(mimeType, MediaTypeNames.Image.Gif, StringComparison.OrdinalIgnoreCase)) + { + return (originalImagePath, mimeType, dateModified); + } + + dateModified = supportedImageInfo.DateModified; + bool requiresTransparency = _transparentImageTypes.Contains(Path.GetExtension(originalImagePath)); + + bool autoOrient = false; + ImageOrientation? orientation = null; + if (item is Photo photo) + { + if (photo.Orientation.HasValue) + { + if (photo.Orientation.Value != ImageOrientation.TopLeft) + { + autoOrient = true; + orientation = photo.Orientation; + } + } + else + { + // Orientation unknown, so do it + autoOrient = true; + orientation = photo.Orientation; + } + } + + if (options.HasDefaultOptions(originalImagePath, originalImageSize) && (!autoOrient || !options.RequiresAutoOrientation)) + { + // Just spit out the original file if all the options are default + return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); + } + + int quality = options.Quality; + + ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency); + string cacheFilePath = GetCacheFilePath( + originalImagePath, + options.Width, + options.Height, + options.MaxWidth, + options.MaxHeight, + options.FillWidth, + options.FillHeight, + quality, + dateModified, + outputFormat, + options.AddPlayedIndicator, + options.PercentPlayed, + options.UnplayedCount, + options.Blur, + options.BackgroundColor, + options.ForegroundLayer); + + try + { + if (!File.Exists(cacheFilePath)) + { + string resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat); + + if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase)) + { + return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); + } + } + + return (cacheFilePath, GetMimeType(outputFormat, cacheFilePath), _fileSystem.GetLastWriteTimeUtc(cacheFilePath)); + } + catch (Exception ex) + { + // If it fails for whatever reason, return the original image + _logger.LogError(ex, "Error encoding image"); + return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); + } + } + + private ImageFormat GetOutputFormat(IReadOnlyCollection<ImageFormat> clientSupportedFormats, bool requiresTransparency) + { + var serverFormats = GetSupportedImageOutputFormats(); + + // Client doesn't care about format, so start with webp if supported + if (serverFormats.Contains(ImageFormat.Webp) && clientSupportedFormats.Contains(ImageFormat.Webp)) + { + return ImageFormat.Webp; + } + + // If transparency is needed and webp isn't supported, than png is the only option + if (requiresTransparency && clientSupportedFormats.Contains(ImageFormat.Png)) + { + return ImageFormat.Png; + } + + foreach (var format in clientSupportedFormats) + { + if (serverFormats.Contains(format)) + { + return format; + } + } + + // We should never actually get here + return ImageFormat.Jpg; + } + + private string GetMimeType(ImageFormat format, string path) + => format switch + { + ImageFormat.Bmp => MimeTypes.GetMimeType("i.bmp"), + ImageFormat.Gif => MimeTypes.GetMimeType("i.gif"), + ImageFormat.Jpg => MimeTypes.GetMimeType("i.jpg"), + ImageFormat.Png => MimeTypes.GetMimeType("i.png"), + ImageFormat.Webp => MimeTypes.GetMimeType("i.webp"), + _ => MimeTypes.GetMimeType(path) + }; + + /// <summary> + /// Gets the cache file path based on a set of parameters. + /// </summary> + private string GetCacheFilePath( + string originalPath, + int? width, + int? height, + int? maxWidth, + int? maxHeight, + int? fillWidth, + int? fillHeight, + int quality, + DateTime dateModified, + ImageFormat format, + bool addPlayedIndicator, + double percentPlayed, + int? unwatchedCount, + int? blur, + string backgroundColor, + string foregroundLayer) + { + var filename = new StringBuilder(256); + filename.Append(originalPath); + + filename.Append(",quality="); + filename.Append(quality); + + filename.Append(",datemodified="); + filename.Append(dateModified.Ticks); + + filename.Append(",f="); + filename.Append(format); + + if (width.HasValue) + { + filename.Append(",width="); + filename.Append(width.Value); + } + + if (height.HasValue) + { + filename.Append(",height="); + filename.Append(height.Value); + } + + if (maxWidth.HasValue) + { + filename.Append(",maxwidth="); + filename.Append(maxWidth.Value); + } + + if (maxHeight.HasValue) + { + filename.Append(",maxheight="); + filename.Append(maxHeight.Value); + } + + if (fillWidth.HasValue) + { + filename.Append(",fillwidth="); + filename.Append(fillWidth.Value); + } + + if (fillHeight.HasValue) + { + filename.Append(",fillheight="); + filename.Append(fillHeight.Value); + } + + if (addPlayedIndicator) + { + filename.Append(",pl=true"); + } + + if (percentPlayed > 0) + { + filename.Append(",p="); + filename.Append(percentPlayed); + } + + if (unwatchedCount.HasValue) + { + filename.Append(",p="); + filename.Append(unwatchedCount.Value); + } + + if (blur.HasValue) + { + filename.Append(",blur="); + filename.Append(blur.Value); + } + + if (!string.IsNullOrEmpty(backgroundColor)) + { + filename.Append(",b="); + filename.Append(backgroundColor); + } + + if (!string.IsNullOrEmpty(foregroundLayer)) + { + filename.Append(",fl="); + filename.Append(foregroundLayer); + } + + filename.Append(",v="); + filename.Append(Version); + + return GetCachePath(ResizedImageCachePath, filename.ToString(), "." + format.ToString().ToLowerInvariant()); + } + + /// <inheritdoc /> + public ImageDimensions GetImageDimensions(BaseItem item, ItemImageInfo info) + { + int width = info.Width; + int height = info.Height; + + if (height > 0 && width > 0) + { + return new ImageDimensions(width, height); + } + + string path = info.Path; + _logger.LogDebug("Getting image size for item {ItemType} {Path}", item.GetType().Name, path); + + ImageDimensions size = GetImageDimensions(path); + info.Width = size.Width; + info.Height = size.Height; + + return size; + } + + /// <inheritdoc /> + public ImageDimensions GetImageDimensions(string path) + => _imageEncoder.GetImageSize(path); + + /// <inheritdoc /> + public string GetImageBlurHash(string path) + { + var size = GetImageDimensions(path); + return GetImageBlurHash(path, size); + } + + /// <inheritdoc /> + public string GetImageBlurHash(string path, ImageDimensions imageDimensions) + { + if (imageDimensions.Width <= 0 || imageDimensions.Height <= 0) + { + return string.Empty; + } + + // We want tiles to be as close to square as possible, and to *mostly* keep under 16 tiles for performance. + // One tile is (width / xComp) x (height / yComp) pixels, which means that ideally yComp = xComp * height / width. + // See more at https://github.com/woltapp/blurhash/#how-do-i-pick-the-number-of-x-and-y-components + float xCompF = MathF.Sqrt(16.0f * imageDimensions.Width / imageDimensions.Height); + float yCompF = xCompF * imageDimensions.Height / imageDimensions.Width; + + int xComp = Math.Min((int)xCompF + 1, 9); + int yComp = Math.Min((int)yCompF + 1, 9); + + return _imageEncoder.GetImageBlurHash(xComp, yComp, path); + } + + /// <inheritdoc /> + public string GetImageCacheTag(BaseItem item, ItemImageInfo image) + => (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture); + + /// <inheritdoc /> + public string GetImageCacheTag(BaseItem item, ChapterInfo chapter) + { + return GetImageCacheTag(item, new ItemImageInfo + { + Path = chapter.ImagePath, + Type = ImageType.Chapter, + DateModified = chapter.ImageDateModified + }); + } + + /// <inheritdoc /> + public string? GetImageCacheTag(User user) + { + if (user.ProfileImage is null) + { + return null; + } + + return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5() + .ToString("N", CultureInfo.InvariantCulture); + } + + private Task<(string Path, DateTime DateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified) + { + var inputFormat = Path.GetExtension(originalImagePath.AsSpan()).TrimStart('.').ToString(); + + // These are just jpg files renamed as tbn + if (string.Equals(inputFormat, "tbn", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult((originalImagePath, dateModified)); + } + + // TODO _mediaEncoder.ConvertImage is not implemented + // if (!_imageEncoder.SupportedInputFormats.Contains(inputFormat)) + // { + // try + // { + // string filename = (originalImagePath + dateModified.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("N", CultureInfo.InvariantCulture); + // + // string cacheExtension = _mediaEncoder.SupportsEncoder("libwebp") ? ".webp" : ".png"; + // var outputPath = Path.Combine(_appPaths.ImageCachePath, "converted-images", filename + cacheExtension); + // + // var file = _fileSystem.GetFileInfo(outputPath); + // if (!file.Exists) + // { + // await _mediaEncoder.ConvertImage(originalImagePath, outputPath).ConfigureAwait(false); + // dateModified = _fileSystem.GetLastWriteTimeUtc(outputPath); + // } + // else + // { + // dateModified = file.LastWriteTimeUtc; + // } + // + // originalImagePath = outputPath; + // } + // catch (Exception ex) + // { + // _logger.LogError(ex, "Image conversion failed for {Path}", originalImagePath); + // } + // } + + return Task.FromResult((originalImagePath, dateModified)); + } + + /// <summary> + /// Gets the cache path. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="uniqueName">Name of the unique.</param> + /// <param name="fileExtension">The file extension.</param> + /// <returns>System.String.</returns> + /// <exception cref="ArgumentNullException"> + /// path + /// or + /// uniqueName + /// or + /// fileExtension. + /// </exception> + public string GetCachePath(string path, string uniqueName, string fileExtension) + { + ArgumentException.ThrowIfNullOrEmpty(path); + ArgumentException.ThrowIfNullOrEmpty(uniqueName); + ArgumentException.ThrowIfNullOrEmpty(fileExtension); + + var filename = uniqueName.GetMD5() + fileExtension; + + return GetCachePath(path, filename); + } + + /// <summary> + /// Gets the cache path. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="filename">The filename.</param> + /// <returns>System.String.</returns> + /// <exception cref="ArgumentNullException"> + /// path + /// or + /// filename. + /// </exception> + public string GetCachePath(ReadOnlySpan<char> path, ReadOnlySpan<char> filename) + { + if (path.IsEmpty) + { + throw new ArgumentException("Path can't be empty.", nameof(path)); + } + + if (filename.IsEmpty) + { + throw new ArgumentException("Filename can't be empty.", nameof(filename)); + } + + var prefix = filename.Slice(0, 1); + + return Path.Join(path, prefix, filename); + } + + /// <inheritdoc /> + public void CreateImageCollage(ImageCollageOptions options, string? libraryName) + { + _logger.LogInformation("Creating image collage and saving to {Path}", options.OutputPath); + + _imageEncoder.CreateImageCollage(options, libraryName); + + _logger.LogInformation("Completed creation of image collage and saved to {Path}", options.OutputPath); + } + + /// <inheritdoc /> + public void Dispose() + { + if (_disposed) + { + return; + } + + if (_imageEncoder is IDisposable disposable) + { + disposable.Dispose(); + } + + _disposed = true; + } + } +} diff --git a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj new file mode 100644 index 000000000..a5bc8eaa7 --- /dev/null +++ b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj @@ -0,0 +1,35 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <!-- ProjectGuid is only included as a requirement for SonarQube analysis --> + <PropertyGroup> + <ProjectGuid>{08FFF49B-F175-4807-A2B5-73B0EBD9F716}</ProjectGuid> + </PropertyGroup> + + <PropertyGroup> + <TargetFramework>net7.0</TargetFramework> + <GenerateAssemblyInfo>false</GenerateAssemblyInfo> + <GenerateDocumentationFile>true</GenerateDocumentationFile> + </PropertyGroup> + + <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> + + <!-- Code analysers--> + <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> + </PackageReference> + <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + </ItemGroup> + +</Project> diff --git a/src/Jellyfin.Drawing/NullImageEncoder.cs b/src/Jellyfin.Drawing/NullImageEncoder.cs new file mode 100644 index 000000000..24dda108e --- /dev/null +++ b/src/Jellyfin.Drawing/NullImageEncoder.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Model.Drawing; + +namespace Jellyfin.Drawing +{ + /// <summary> + /// A fallback implementation of <see cref="IImageEncoder" />. + /// </summary> + public class NullImageEncoder : IImageEncoder + { + /// <inheritdoc /> + public IReadOnlyCollection<string> SupportedInputFormats + => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "png", "jpeg", "jpg" }; + + /// <inheritdoc /> + public IReadOnlyCollection<ImageFormat> SupportedOutputFormats + => new HashSet<ImageFormat>() { ImageFormat.Jpg, ImageFormat.Png }; + + /// <inheritdoc /> + public string Name => "Null Image Encoder"; + + /// <inheritdoc /> + public bool SupportsImageCollageCreation => false; + + /// <inheritdoc /> + public bool SupportsImageEncoding => false; + + /// <inheritdoc /> + public ImageDimensions GetImageSize(string path) + => throw new NotImplementedException(); + + /// <inheritdoc /> + public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat) + { + throw new NotImplementedException(); + } + + /// <inheritdoc /> + public void CreateImageCollage(ImageCollageOptions options, string? libraryName) + { + throw new NotImplementedException(); + } + + /// <inheritdoc /> + public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops) + { + throw new NotImplementedException(); + } + + /// <inheritdoc /> + public string GetImageBlurHash(int xComp, int yComp, string path) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Jellyfin.Drawing/Properties/AssemblyInfo.cs b/src/Jellyfin.Drawing/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..3851bf924 --- /dev/null +++ b/src/Jellyfin.Drawing/Properties/AssemblyInfo.cs @@ -0,0 +1,30 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +// 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("Jellyfin.Drawing")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("Jellyfin Project")] +[assembly: AssemblyProduct("Jellyfin Server")] +[assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License")] +[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 +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("87b6f14e-16d8-4a58-a553-fd9945e47458")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// |
