ImageProcessor.cs 17 KB

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