ImageProcessor.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  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 GetImageHash(string path)
  270. => _imageEncoder.GetImageHash(path);
  271. /// <inheritdoc />
  272. public string GetImageCacheTag(BaseItem item, ItemImageInfo image)
  273. => (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture);
  274. /// <inheritdoc />
  275. public string GetImageCacheTag(BaseItem item, ChapterInfo chapter)
  276. {
  277. return GetImageCacheTag(item, new ItemImageInfo
  278. {
  279. Path = chapter.ImagePath,
  280. Type = ImageType.Chapter,
  281. DateModified = chapter.ImageDateModified
  282. });
  283. }
  284. private async Task<(string path, DateTime dateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified)
  285. {
  286. var inputFormat = Path.GetExtension(originalImagePath)
  287. .TrimStart('.')
  288. .Replace("jpeg", "jpg", StringComparison.OrdinalIgnoreCase);
  289. // These are just jpg files renamed as tbn
  290. if (string.Equals(inputFormat, "tbn", StringComparison.OrdinalIgnoreCase))
  291. {
  292. return (originalImagePath, dateModified);
  293. }
  294. if (!_imageEncoder.SupportedInputFormats.Contains(inputFormat))
  295. {
  296. try
  297. {
  298. string filename = (originalImagePath + dateModified.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("N", CultureInfo.InvariantCulture);
  299. string cacheExtension = _mediaEncoder.SupportsEncoder("libwebp") ? ".webp" : ".png";
  300. var outputPath = Path.Combine(_appPaths.ImageCachePath, "converted-images", filename + cacheExtension);
  301. var file = _fileSystem.GetFileInfo(outputPath);
  302. if (!file.Exists)
  303. {
  304. await _mediaEncoder.ConvertImage(originalImagePath, outputPath).ConfigureAwait(false);
  305. dateModified = _fileSystem.GetLastWriteTimeUtc(outputPath);
  306. }
  307. else
  308. {
  309. dateModified = file.LastWriteTimeUtc;
  310. }
  311. originalImagePath = outputPath;
  312. }
  313. catch (Exception ex)
  314. {
  315. _logger.LogError(ex, "Image conversion failed for {Path}", originalImagePath);
  316. }
  317. }
  318. return (originalImagePath, dateModified);
  319. }
  320. /// <summary>
  321. /// Gets the cache path.
  322. /// </summary>
  323. /// <param name="path">The path.</param>
  324. /// <param name="uniqueName">Name of the unique.</param>
  325. /// <param name="fileExtension">The file extension.</param>
  326. /// <returns>System.String.</returns>
  327. /// <exception cref="ArgumentNullException">
  328. /// path
  329. /// or
  330. /// uniqueName
  331. /// or
  332. /// fileExtension.
  333. /// </exception>
  334. public string GetCachePath(string path, string uniqueName, string fileExtension)
  335. {
  336. if (string.IsNullOrEmpty(path))
  337. {
  338. throw new ArgumentNullException(nameof(path));
  339. }
  340. if (string.IsNullOrEmpty(uniqueName))
  341. {
  342. throw new ArgumentNullException(nameof(uniqueName));
  343. }
  344. if (string.IsNullOrEmpty(fileExtension))
  345. {
  346. throw new ArgumentNullException(nameof(fileExtension));
  347. }
  348. var filename = uniqueName.GetMD5() + fileExtension;
  349. return GetCachePath(path, filename);
  350. }
  351. /// <summary>
  352. /// Gets the cache path.
  353. /// </summary>
  354. /// <param name="path">The path.</param>
  355. /// <param name="filename">The filename.</param>
  356. /// <returns>System.String.</returns>
  357. /// <exception cref="ArgumentNullException">
  358. /// path
  359. /// or
  360. /// filename.
  361. /// </exception>
  362. public string GetCachePath(string path, string filename)
  363. {
  364. if (string.IsNullOrEmpty(path))
  365. {
  366. throw new ArgumentNullException(nameof(path));
  367. }
  368. if (string.IsNullOrEmpty(filename))
  369. {
  370. throw new ArgumentNullException(nameof(filename));
  371. }
  372. var prefix = filename.Substring(0, 1);
  373. return Path.Combine(path, prefix, filename);
  374. }
  375. /// <inheritdoc />
  376. public void CreateImageCollage(ImageCollageOptions options)
  377. {
  378. _logger.LogInformation("Creating image collage and saving to {Path}", options.OutputPath);
  379. _imageEncoder.CreateImageCollage(options);
  380. _logger.LogInformation("Completed creation of image collage and saved to {Path}", options.OutputPath);
  381. }
  382. /// <inheritdoc />
  383. public void Dispose()
  384. {
  385. if (_disposed)
  386. {
  387. return;
  388. }
  389. if (_imageEncoder is IDisposable disposable)
  390. {
  391. disposable.Dispose();
  392. }
  393. _disposed = true;
  394. }
  395. }
  396. }