ImageProcessor.cs 17 KB

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