ImageProcessor.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Globalization;
  4. using System.IO;
  5. using System.Linq;
  6. using System.Threading.Tasks;
  7. using Jellyfin.Data.Entities;
  8. using MediaBrowser.Common.Extensions;
  9. using MediaBrowser.Controller;
  10. using MediaBrowser.Controller.Drawing;
  11. using MediaBrowser.Controller.Entities;
  12. using MediaBrowser.Controller.MediaEncoding;
  13. using MediaBrowser.Model.Drawing;
  14. using MediaBrowser.Model.Entities;
  15. using MediaBrowser.Model.IO;
  16. using MediaBrowser.Model.Net;
  17. using Microsoft.Extensions.Logging;
  18. using Photo = MediaBrowser.Controller.Entities.Photo;
  19. namespace Emby.Drawing
  20. {
  21. /// <summary>
  22. /// Class ImageProcessor.
  23. /// </summary>
  24. public sealed class ImageProcessor : IImageProcessor, IDisposable
  25. {
  26. // Increment this when there's a change requiring caches to be invalidated
  27. private const string Version = "3";
  28. private static readonly HashSet<string> _transparentImageTypes
  29. = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" };
  30. private readonly ILogger<ImageProcessor> _logger;
  31. private readonly IFileSystem _fileSystem;
  32. private readonly IServerApplicationPaths _appPaths;
  33. private readonly IImageEncoder _imageEncoder;
  34. private readonly IMediaEncoder _mediaEncoder;
  35. private bool _disposed = false;
  36. /// <summary>
  37. /// Initializes a new instance of the <see cref="ImageProcessor"/> class.
  38. /// </summary>
  39. /// <param name="logger">The logger.</param>
  40. /// <param name="appPaths">The server application paths.</param>
  41. /// <param name="fileSystem">The filesystem.</param>
  42. /// <param name="imageEncoder">The image encoder.</param>
  43. /// <param name="mediaEncoder">The media encoder.</param>
  44. public ImageProcessor(
  45. ILogger<ImageProcessor> logger,
  46. IServerApplicationPaths appPaths,
  47. IFileSystem fileSystem,
  48. IImageEncoder imageEncoder,
  49. IMediaEncoder mediaEncoder)
  50. {
  51. _logger = logger;
  52. _fileSystem = fileSystem;
  53. _imageEncoder = imageEncoder;
  54. _mediaEncoder = mediaEncoder;
  55. _appPaths = appPaths;
  56. }
  57. private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images");
  58. /// <inheritdoc />
  59. public IReadOnlyCollection<string> SupportedInputFormats =>
  60. new HashSet<string>(StringComparer.OrdinalIgnoreCase)
  61. {
  62. "tiff",
  63. "tif",
  64. "jpeg",
  65. "jpg",
  66. "png",
  67. "aiff",
  68. "cr2",
  69. "crw",
  70. "nef",
  71. "orf",
  72. "pef",
  73. "arw",
  74. "webp",
  75. "gif",
  76. "bmp",
  77. "erf",
  78. "raf",
  79. "rw2",
  80. "nrw",
  81. "dng",
  82. "ico",
  83. "astc",
  84. "ktx",
  85. "pkm",
  86. "wbmp"
  87. };
  88. /// <inheritdoc />
  89. public bool SupportsImageCollageCreation => _imageEncoder.SupportsImageCollageCreation;
  90. /// <inheritdoc />
  91. public async Task ProcessImage(ImageProcessingOptions options, Stream toStream)
  92. {
  93. var file = await ProcessImage(options).ConfigureAwait(false);
  94. using (var fileStream = new FileStream(file.Item1, FileMode.Open, FileAccess.Read, FileShare.Read, IODefaults.FileStreamBufferSize, true))
  95. {
  96. await fileStream.CopyToAsync(toStream).ConfigureAwait(false);
  97. }
  98. }
  99. /// <inheritdoc />
  100. public IReadOnlyCollection<ImageFormat> GetSupportedImageOutputFormats()
  101. => _imageEncoder.SupportedOutputFormats;
  102. /// <inheritdoc />
  103. public bool SupportsTransparency(string path)
  104. => _transparentImageTypes.Contains(Path.GetExtension(path));
  105. /// <inheritdoc />
  106. public async Task<(string path, string? mimeType, DateTime dateModified)> ProcessImage(ImageProcessingOptions options)
  107. {
  108. ItemImageInfo originalImage = options.Image;
  109. BaseItem item = options.Item;
  110. string originalImagePath = originalImage.Path;
  111. DateTime dateModified = originalImage.DateModified;
  112. ImageDimensions? originalImageSize = null;
  113. if (originalImage.Width > 0 && originalImage.Height > 0)
  114. {
  115. originalImageSize = new ImageDimensions(originalImage.Width, originalImage.Height);
  116. }
  117. if (!_imageEncoder.SupportsImageEncoding)
  118. {
  119. return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
  120. }
  121. var supportedImageInfo = await GetSupportedImage(originalImagePath, dateModified).ConfigureAwait(false);
  122. originalImagePath = supportedImageInfo.path;
  123. if (!File.Exists(originalImagePath))
  124. {
  125. return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
  126. }
  127. dateModified = supportedImageInfo.dateModified;
  128. bool requiresTransparency = _transparentImageTypes.Contains(Path.GetExtension(originalImagePath));
  129. bool autoOrient = false;
  130. ImageOrientation? orientation = null;
  131. if (item is Photo photo)
  132. {
  133. if (photo.Orientation.HasValue)
  134. {
  135. if (photo.Orientation.Value != ImageOrientation.TopLeft)
  136. {
  137. autoOrient = true;
  138. orientation = photo.Orientation;
  139. }
  140. }
  141. else
  142. {
  143. // Orientation unknown, so do it
  144. autoOrient = true;
  145. orientation = photo.Orientation;
  146. }
  147. }
  148. if (options.HasDefaultOptions(originalImagePath, originalImageSize) && (!autoOrient || !options.RequiresAutoOrientation))
  149. {
  150. // Just spit out the original file if all the options are default
  151. return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
  152. }
  153. ImageDimensions newSize = ImageHelper.GetNewImageSize(options, null);
  154. int quality = options.Quality;
  155. ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency);
  156. string cacheFilePath = GetCacheFilePath(originalImagePath, newSize, quality, dateModified, outputFormat, options.AddPlayedIndicator, options.PercentPlayed, options.UnplayedCount, options.Blur, options.BackgroundColor, options.ForegroundLayer);
  157. try
  158. {
  159. if (!File.Exists(cacheFilePath))
  160. {
  161. if (options.CropWhiteSpace && !SupportsTransparency(originalImagePath))
  162. {
  163. options.CropWhiteSpace = false;
  164. }
  165. string resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat);
  166. if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase))
  167. {
  168. return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
  169. }
  170. }
  171. return (cacheFilePath, GetMimeType(outputFormat, cacheFilePath), _fileSystem.GetLastWriteTimeUtc(cacheFilePath));
  172. }
  173. catch (Exception ex)
  174. {
  175. // If it fails for whatever reason, return the original image
  176. _logger.LogError(ex, "Error encoding image");
  177. return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
  178. }
  179. }
  180. private ImageFormat GetOutputFormat(IReadOnlyCollection<ImageFormat> clientSupportedFormats, bool requiresTransparency)
  181. {
  182. var serverFormats = GetSupportedImageOutputFormats();
  183. // Client doesn't care about format, so start with webp if supported
  184. if (serverFormats.Contains(ImageFormat.Webp) && clientSupportedFormats.Contains(ImageFormat.Webp))
  185. {
  186. return ImageFormat.Webp;
  187. }
  188. // If transparency is needed and webp isn't supported, than png is the only option
  189. if (requiresTransparency && clientSupportedFormats.Contains(ImageFormat.Png))
  190. {
  191. return ImageFormat.Png;
  192. }
  193. foreach (var format in clientSupportedFormats)
  194. {
  195. if (serverFormats.Contains(format))
  196. {
  197. return format;
  198. }
  199. }
  200. // We should never actually get here
  201. return ImageFormat.Jpg;
  202. }
  203. private string? GetMimeType(ImageFormat format, string path)
  204. => format switch
  205. {
  206. ImageFormat.Bmp => MimeTypes.GetMimeType("i.bmp"),
  207. ImageFormat.Gif => MimeTypes.GetMimeType("i.gif"),
  208. ImageFormat.Jpg => MimeTypes.GetMimeType("i.jpg"),
  209. ImageFormat.Png => MimeTypes.GetMimeType("i.png"),
  210. ImageFormat.Webp => MimeTypes.GetMimeType("i.webp"),
  211. _ => MimeTypes.GetMimeType(path)
  212. };
  213. /// <summary>
  214. /// Gets the cache file path based on a set of parameters.
  215. /// </summary>
  216. private string GetCacheFilePath(string originalPath, ImageDimensions outputSize, int quality, DateTime dateModified, ImageFormat format, bool addPlayedIndicator, double percentPlayed, int? unwatchedCount, int? blur, string backgroundColor, string foregroundLayer)
  217. {
  218. var filename = originalPath
  219. + "width=" + outputSize.Width
  220. + "height=" + outputSize.Height
  221. + "quality=" + quality
  222. + "datemodified=" + dateModified.Ticks
  223. + "f=" + format;
  224. if (addPlayedIndicator)
  225. {
  226. filename += "pl=true";
  227. }
  228. if (percentPlayed > 0)
  229. {
  230. filename += "p=" + percentPlayed;
  231. }
  232. if (unwatchedCount.HasValue)
  233. {
  234. filename += "p=" + unwatchedCount.Value;
  235. }
  236. if (blur.HasValue)
  237. {
  238. filename += "blur=" + blur.Value;
  239. }
  240. if (!string.IsNullOrEmpty(backgroundColor))
  241. {
  242. filename += "b=" + backgroundColor;
  243. }
  244. if (!string.IsNullOrEmpty(foregroundLayer))
  245. {
  246. filename += "fl=" + foregroundLayer;
  247. }
  248. filename += "v=" + Version;
  249. return GetCachePath(ResizedImageCachePath, filename, "." + format.ToString().ToLowerInvariant());
  250. }
  251. /// <inheritdoc />
  252. public ImageDimensions GetImageDimensions(BaseItem item, ItemImageInfo info)
  253. {
  254. int width = info.Width;
  255. int height = info.Height;
  256. if (height > 0 && width > 0)
  257. {
  258. return new ImageDimensions(width, height);
  259. }
  260. string path = info.Path;
  261. _logger.LogDebug("Getting image size for item {ItemType} {Path}", item.GetType().Name, path);
  262. ImageDimensions size = GetImageDimensions(path);
  263. info.Width = size.Width;
  264. info.Height = size.Height;
  265. return size;
  266. }
  267. /// <inheritdoc />
  268. public ImageDimensions GetImageDimensions(string path)
  269. => _imageEncoder.GetImageSize(path);
  270. /// <inheritdoc />
  271. public string GetImageBlurHash(string path)
  272. {
  273. var size = GetImageDimensions(path);
  274. if (size.Width <= 0 || size.Height <= 0)
  275. {
  276. return string.Empty;
  277. }
  278. // We want tiles to be as close to square as possible, and to *mostly* keep under 16 tiles for performance.
  279. // One tile is (width / xComp) x (height / yComp) pixels, which means that ideally yComp = xComp * height / width.
  280. // See more at https://github.com/woltapp/blurhash/#how-do-i-pick-the-number-of-x-and-y-components
  281. float xCompF = MathF.Sqrt(16.0f * size.Width / size.Height);
  282. float yCompF = xCompF * size.Height / size.Width;
  283. int xComp = Math.Min((int)xCompF + 1, 9);
  284. int yComp = Math.Min((int)yCompF + 1, 9);
  285. return _imageEncoder.GetImageBlurHash(xComp, yComp, path);
  286. }
  287. /// <inheritdoc />
  288. public string GetImageCacheTag(BaseItem item, ItemImageInfo image)
  289. => (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture);
  290. /// <inheritdoc />
  291. public string GetImageCacheTag(BaseItem item, ChapterInfo chapter)
  292. {
  293. return GetImageCacheTag(item, new ItemImageInfo
  294. {
  295. Path = chapter.ImagePath,
  296. Type = ImageType.Chapter,
  297. DateModified = chapter.ImageDateModified
  298. });
  299. }
  300. /// <inheritdoc />
  301. public string GetImageCacheTag(User user)
  302. {
  303. return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5()
  304. .ToString("N", CultureInfo.InvariantCulture);
  305. }
  306. private async Task<(string path, DateTime dateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified)
  307. {
  308. var inputFormat = Path.GetExtension(originalImagePath)
  309. .TrimStart('.')
  310. .Replace("jpeg", "jpg", StringComparison.OrdinalIgnoreCase);
  311. // These are just jpg files renamed as tbn
  312. if (string.Equals(inputFormat, "tbn", StringComparison.OrdinalIgnoreCase))
  313. {
  314. return (originalImagePath, dateModified);
  315. }
  316. if (!_imageEncoder.SupportedInputFormats.Contains(inputFormat))
  317. {
  318. try
  319. {
  320. string filename = (originalImagePath + dateModified.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("N", CultureInfo.InvariantCulture);
  321. string cacheExtension = _mediaEncoder.SupportsEncoder("libwebp") ? ".webp" : ".png";
  322. var outputPath = Path.Combine(_appPaths.ImageCachePath, "converted-images", filename + cacheExtension);
  323. var file = _fileSystem.GetFileInfo(outputPath);
  324. if (!file.Exists)
  325. {
  326. await _mediaEncoder.ConvertImage(originalImagePath, outputPath).ConfigureAwait(false);
  327. dateModified = _fileSystem.GetLastWriteTimeUtc(outputPath);
  328. }
  329. else
  330. {
  331. dateModified = file.LastWriteTimeUtc;
  332. }
  333. originalImagePath = outputPath;
  334. }
  335. catch (Exception ex)
  336. {
  337. _logger.LogError(ex, "Image conversion failed for {Path}", originalImagePath);
  338. }
  339. }
  340. return (originalImagePath, dateModified);
  341. }
  342. /// <summary>
  343. /// Gets the cache path.
  344. /// </summary>
  345. /// <param name="path">The path.</param>
  346. /// <param name="uniqueName">Name of the unique.</param>
  347. /// <param name="fileExtension">The file extension.</param>
  348. /// <returns>System.String.</returns>
  349. /// <exception cref="ArgumentNullException">
  350. /// path
  351. /// or
  352. /// uniqueName
  353. /// or
  354. /// fileExtension.
  355. /// </exception>
  356. public string GetCachePath(string path, string uniqueName, string fileExtension)
  357. {
  358. if (string.IsNullOrEmpty(path))
  359. {
  360. throw new ArgumentNullException(nameof(path));
  361. }
  362. if (string.IsNullOrEmpty(uniqueName))
  363. {
  364. throw new ArgumentNullException(nameof(uniqueName));
  365. }
  366. if (string.IsNullOrEmpty(fileExtension))
  367. {
  368. throw new ArgumentNullException(nameof(fileExtension));
  369. }
  370. var filename = uniqueName.GetMD5() + fileExtension;
  371. return GetCachePath(path, filename);
  372. }
  373. /// <summary>
  374. /// Gets the cache path.
  375. /// </summary>
  376. /// <param name="path">The path.</param>
  377. /// <param name="filename">The filename.</param>
  378. /// <returns>System.String.</returns>
  379. /// <exception cref="ArgumentNullException">
  380. /// path
  381. /// or
  382. /// filename.
  383. /// </exception>
  384. public string GetCachePath(string path, string filename)
  385. {
  386. if (string.IsNullOrEmpty(path))
  387. {
  388. throw new ArgumentNullException(nameof(path));
  389. }
  390. if (string.IsNullOrEmpty(filename))
  391. {
  392. throw new ArgumentNullException(nameof(filename));
  393. }
  394. var prefix = filename.Substring(0, 1);
  395. return Path.Combine(path, prefix, filename);
  396. }
  397. /// <inheritdoc />
  398. public void CreateImageCollage(ImageCollageOptions options)
  399. {
  400. _logger.LogInformation("Creating image collage and saving to {Path}", options.OutputPath);
  401. _imageEncoder.CreateImageCollage(options);
  402. _logger.LogInformation("Completed creation of image collage and saved to {Path}", options.OutputPath);
  403. }
  404. /// <inheritdoc />
  405. public void Dispose()
  406. {
  407. if (_disposed)
  408. {
  409. return;
  410. }
  411. if (_imageEncoder is IDisposable disposable)
  412. {
  413. disposable.Dispose();
  414. }
  415. _disposed = true;
  416. }
  417. }
  418. }