ImageProcessor.cs 18 KB

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