ImageProcessor.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  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. ItemImageInfo originalImage = options.Image;
  107. BaseItem item = options.Item;
  108. string originalImagePath = originalImage.Path;
  109. DateTime dateModified = originalImage.DateModified;
  110. ImageDimensions? originalImageSize = null;
  111. if (originalImage.Width > 0 && originalImage.Height > 0)
  112. {
  113. originalImageSize = new ImageDimensions(originalImage.Width, originalImage.Height);
  114. }
  115. if (!_imageEncoder.SupportsImageEncoding)
  116. {
  117. return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
  118. }
  119. var supportedImageInfo = await GetSupportedImage(originalImagePath, dateModified).ConfigureAwait(false);
  120. originalImagePath = supportedImageInfo.path;
  121. if (!File.Exists(originalImagePath))
  122. {
  123. return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
  124. }
  125. dateModified = supportedImageInfo.dateModified;
  126. bool requiresTransparency = _transparentImageTypes.Contains(Path.GetExtension(originalImagePath));
  127. bool autoOrient = false;
  128. ImageOrientation? orientation = null;
  129. if (item is Photo photo)
  130. {
  131. if (photo.Orientation.HasValue)
  132. {
  133. if (photo.Orientation.Value != ImageOrientation.TopLeft)
  134. {
  135. autoOrient = true;
  136. orientation = photo.Orientation;
  137. }
  138. }
  139. else
  140. {
  141. // Orientation unknown, so do it
  142. autoOrient = true;
  143. orientation = photo.Orientation;
  144. }
  145. }
  146. if (options.HasDefaultOptions(originalImagePath, originalImageSize) && (!autoOrient || !options.RequiresAutoOrientation))
  147. {
  148. // Just spit out the original file if all the options are default
  149. return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
  150. }
  151. ImageDimensions newSize = ImageHelper.GetNewImageSize(options, null);
  152. int quality = options.Quality;
  153. ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency);
  154. string cacheFilePath = GetCacheFilePath(originalImagePath, newSize, quality, dateModified, outputFormat, options.AddPlayedIndicator, options.PercentPlayed, options.UnplayedCount, options.Blur, options.BackgroundColor, options.ForegroundLayer);
  155. try
  156. {
  157. if (!File.Exists(cacheFilePath))
  158. {
  159. if (options.CropWhiteSpace && !SupportsTransparency(originalImagePath))
  160. {
  161. options.CropWhiteSpace = false;
  162. }
  163. string resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat);
  164. if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase))
  165. {
  166. return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
  167. }
  168. }
  169. return (cacheFilePath, GetMimeType(outputFormat, cacheFilePath), _fileSystem.GetLastWriteTimeUtc(cacheFilePath));
  170. }
  171. catch (Exception ex)
  172. {
  173. // If it fails for whatever reason, return the original image
  174. _logger.LogError(ex, "Error encoding image");
  175. return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified);
  176. }
  177. }
  178. private ImageFormat GetOutputFormat(IReadOnlyCollection<ImageFormat> clientSupportedFormats, bool requiresTransparency)
  179. {
  180. var serverFormats = GetSupportedImageOutputFormats();
  181. // Client doesn't care about format, so start with webp if supported
  182. if (serverFormats.Contains(ImageFormat.Webp) && clientSupportedFormats.Contains(ImageFormat.Webp))
  183. {
  184. return ImageFormat.Webp;
  185. }
  186. // If transparency is needed and webp isn't supported, than png is the only option
  187. if (requiresTransparency && clientSupportedFormats.Contains(ImageFormat.Png))
  188. {
  189. return ImageFormat.Png;
  190. }
  191. foreach (var format in clientSupportedFormats)
  192. {
  193. if (serverFormats.Contains(format))
  194. {
  195. return format;
  196. }
  197. }
  198. // We should never actually get here
  199. return ImageFormat.Jpg;
  200. }
  201. private string GetMimeType(ImageFormat format, string path)
  202. => format switch
  203. {
  204. ImageFormat.Bmp => MimeTypes.GetMimeType("i.bmp"),
  205. ImageFormat.Gif => MimeTypes.GetMimeType("i.gif"),
  206. ImageFormat.Jpg => MimeTypes.GetMimeType("i.jpg"),
  207. ImageFormat.Png => MimeTypes.GetMimeType("i.png"),
  208. ImageFormat.Webp => MimeTypes.GetMimeType("i.webp"),
  209. _ => MimeTypes.GetMimeType(path)
  210. };
  211. /// <summary>
  212. /// Gets the cache file path based on a set of parameters.
  213. /// </summary>
  214. 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)
  215. {
  216. var filename = originalPath
  217. + "width=" + outputSize.Width
  218. + "height=" + outputSize.Height
  219. + "quality=" + quality
  220. + "datemodified=" + dateModified.Ticks
  221. + "f=" + format;
  222. if (addPlayedIndicator)
  223. {
  224. filename += "pl=true";
  225. }
  226. if (percentPlayed > 0)
  227. {
  228. filename += "p=" + percentPlayed;
  229. }
  230. if (unwatchedCount.HasValue)
  231. {
  232. filename += "p=" + unwatchedCount.Value;
  233. }
  234. if (blur.HasValue)
  235. {
  236. filename += "blur=" + blur.Value;
  237. }
  238. if (!string.IsNullOrEmpty(backgroundColor))
  239. {
  240. filename += "b=" + backgroundColor;
  241. }
  242. if (!string.IsNullOrEmpty(foregroundLayer))
  243. {
  244. filename += "fl=" + foregroundLayer;
  245. }
  246. filename += "v=" + Version;
  247. return GetCachePath(ResizedImageCachePath, filename, "." + format.ToString().ToLowerInvariant());
  248. }
  249. /// <inheritdoc />
  250. public ImageDimensions GetImageDimensions(BaseItem item, ItemImageInfo info)
  251. {
  252. int width = info.Width;
  253. int height = info.Height;
  254. if (height > 0 && width > 0)
  255. {
  256. return new ImageDimensions(width, height);
  257. }
  258. string path = info.Path;
  259. _logger.LogInformation("Getting image size for item {ItemType} {Path}", item.GetType().Name, path);
  260. ImageDimensions size = GetImageDimensions(path);
  261. info.Width = size.Width;
  262. info.Height = size.Height;
  263. return size;
  264. }
  265. /// <inheritdoc />
  266. public ImageDimensions GetImageDimensions(string path)
  267. => _imageEncoder.GetImageSize(path);
  268. /// <inheritdoc />
  269. public string GetImageCacheTag(BaseItem item, ItemImageInfo image)
  270. => (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture);
  271. /// <inheritdoc />
  272. public string GetImageCacheTag(BaseItem item, ChapterInfo chapter)
  273. {
  274. return GetImageCacheTag(item, new ItemImageInfo
  275. {
  276. Path = chapter.ImagePath,
  277. Type = ImageType.Chapter,
  278. DateModified = chapter.ImageDateModified
  279. });
  280. }
  281. private async Task<(string path, DateTime dateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified)
  282. {
  283. var inputFormat = Path.GetExtension(originalImagePath)
  284. .TrimStart('.')
  285. .Replace("jpeg", "jpg", StringComparison.OrdinalIgnoreCase);
  286. // These are just jpg files renamed as tbn
  287. if (string.Equals(inputFormat, "tbn", StringComparison.OrdinalIgnoreCase))
  288. {
  289. return (originalImagePath, dateModified);
  290. }
  291. if (!_imageEncoder.SupportedInputFormats.Contains(inputFormat))
  292. {
  293. try
  294. {
  295. string filename = (originalImagePath + dateModified.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("N", CultureInfo.InvariantCulture);
  296. string cacheExtension = _mediaEncoder.SupportsEncoder("libwebp") ? ".webp" : ".png";
  297. var outputPath = Path.Combine(_appPaths.ImageCachePath, "converted-images", filename + cacheExtension);
  298. var file = _fileSystem.GetFileInfo(outputPath);
  299. if (!file.Exists)
  300. {
  301. await _mediaEncoder.ConvertImage(originalImagePath, outputPath).ConfigureAwait(false);
  302. dateModified = _fileSystem.GetLastWriteTimeUtc(outputPath);
  303. }
  304. else
  305. {
  306. dateModified = file.LastWriteTimeUtc;
  307. }
  308. originalImagePath = outputPath;
  309. }
  310. catch (Exception ex)
  311. {
  312. _logger.LogError(ex, "Image conversion failed for {Path}", originalImagePath);
  313. }
  314. }
  315. return (originalImagePath, dateModified);
  316. }
  317. /// <summary>
  318. /// Gets the cache path.
  319. /// </summary>
  320. /// <param name="path">The path.</param>
  321. /// <param name="uniqueName">Name of the unique.</param>
  322. /// <param name="fileExtension">The file extension.</param>
  323. /// <returns>System.String.</returns>
  324. /// <exception cref="ArgumentNullException">
  325. /// path
  326. /// or
  327. /// uniqueName
  328. /// or
  329. /// fileExtension.
  330. /// </exception>
  331. public string GetCachePath(string path, string uniqueName, string fileExtension)
  332. {
  333. if (string.IsNullOrEmpty(path))
  334. {
  335. throw new ArgumentNullException(nameof(path));
  336. }
  337. if (string.IsNullOrEmpty(uniqueName))
  338. {
  339. throw new ArgumentNullException(nameof(uniqueName));
  340. }
  341. if (string.IsNullOrEmpty(fileExtension))
  342. {
  343. throw new ArgumentNullException(nameof(fileExtension));
  344. }
  345. var filename = uniqueName.GetMD5() + fileExtension;
  346. return GetCachePath(path, filename);
  347. }
  348. /// <summary>
  349. /// Gets the cache path.
  350. /// </summary>
  351. /// <param name="path">The path.</param>
  352. /// <param name="filename">The filename.</param>
  353. /// <returns>System.String.</returns>
  354. /// <exception cref="ArgumentNullException">
  355. /// path
  356. /// or
  357. /// filename.
  358. /// </exception>
  359. public string GetCachePath(string path, string filename)
  360. {
  361. if (string.IsNullOrEmpty(path))
  362. {
  363. throw new ArgumentNullException(nameof(path));
  364. }
  365. if (string.IsNullOrEmpty(filename))
  366. {
  367. throw new ArgumentNullException(nameof(filename));
  368. }
  369. var prefix = filename.Substring(0, 1);
  370. return Path.Combine(path, prefix, filename);
  371. }
  372. /// <inheritdoc />
  373. public void CreateImageCollage(ImageCollageOptions options)
  374. {
  375. _logger.LogInformation("Creating image collage and saving to {Path}", options.OutputPath);
  376. _imageEncoder.CreateImageCollage(options);
  377. _logger.LogInformation("Completed creation of image collage and saved to {Path}", options.OutputPath);
  378. }
  379. /// <inheritdoc />
  380. public void Dispose()
  381. {
  382. if (_disposed)
  383. {
  384. return;
  385. }
  386. if (_imageEncoder is IDisposable disposable)
  387. {
  388. disposable.Dispose();
  389. }
  390. _disposed = true;
  391. }
  392. }
  393. }