StripCollageBuilder.cs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. using System;
  2. using System.Collections.Generic;
  3. using System.IO;
  4. using System.Text.RegularExpressions;
  5. using SkiaSharp;
  6. using SkiaSharp.HarfBuzz;
  7. namespace Jellyfin.Drawing.Skia;
  8. /// <summary>
  9. /// Used to build collages of multiple images arranged in vertical strips.
  10. /// </summary>
  11. public partial class StripCollageBuilder
  12. {
  13. private readonly SkiaEncoder _skiaEncoder;
  14. /// <summary>
  15. /// Initializes a new instance of the <see cref="StripCollageBuilder"/> class.
  16. /// </summary>
  17. /// <param name="skiaEncoder">The encoder to use for building collages.</param>
  18. public StripCollageBuilder(SkiaEncoder skiaEncoder)
  19. {
  20. _skiaEncoder = skiaEncoder;
  21. }
  22. [GeneratedRegex(@"[^\p{IsCJKUnifiedIdeographs}\p{IsCJKUnifiedIdeographsExtensionA}\p{IsKatakana}\p{IsHiragana}\p{IsHangulSyllables}\p{IsHangulJamo}]")]
  23. private static partial Regex NonCjkPatternRegex();
  24. [GeneratedRegex(@"\p{IsArabic}|\p{IsArmenian}|\p{IsHebrew}|\p{IsSyriac}|\p{IsThaana}")]
  25. private static partial Regex IsRtlTextRegex();
  26. /// <summary>
  27. /// Check which format an image has been encoded with using its filename extension.
  28. /// </summary>
  29. /// <param name="outputPath">The path to the image to get the format for.</param>
  30. /// <returns>The image format.</returns>
  31. public static SKEncodedImageFormat GetEncodedFormat(string outputPath)
  32. {
  33. ArgumentNullException.ThrowIfNull(outputPath);
  34. var ext = Path.GetExtension(outputPath);
  35. if (string.Equals(ext, ".jpg", StringComparison.OrdinalIgnoreCase)
  36. || string.Equals(ext, ".jpeg", StringComparison.OrdinalIgnoreCase))
  37. {
  38. return SKEncodedImageFormat.Jpeg;
  39. }
  40. if (string.Equals(ext, ".webp", StringComparison.OrdinalIgnoreCase))
  41. {
  42. return SKEncodedImageFormat.Webp;
  43. }
  44. if (string.Equals(ext, ".gif", StringComparison.OrdinalIgnoreCase))
  45. {
  46. return SKEncodedImageFormat.Gif;
  47. }
  48. if (string.Equals(ext, ".bmp", StringComparison.OrdinalIgnoreCase))
  49. {
  50. return SKEncodedImageFormat.Bmp;
  51. }
  52. // default to png
  53. return SKEncodedImageFormat.Png;
  54. }
  55. /// <summary>
  56. /// Create a square collage.
  57. /// </summary>
  58. /// <param name="paths">The paths of the images to use in the collage.</param>
  59. /// <param name="outputPath">The path at which to place the resulting collage image.</param>
  60. /// <param name="width">The desired width of the collage.</param>
  61. /// <param name="height">The desired height of the collage.</param>
  62. public void BuildSquareCollage(IReadOnlyList<string> paths, string outputPath, int width, int height)
  63. {
  64. using var bitmap = BuildSquareCollageBitmap(paths, width, height);
  65. using var outputStream = new SKFileWStream(outputPath);
  66. using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels());
  67. pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
  68. }
  69. /// <summary>
  70. /// Create a thumb collage.
  71. /// </summary>
  72. /// <param name="paths">The paths of the images to use in the collage.</param>
  73. /// <param name="outputPath">The path at which to place the resulting image.</param>
  74. /// <param name="width">The desired width of the collage.</param>
  75. /// <param name="height">The desired height of the collage.</param>
  76. /// <param name="libraryName">The name of the library to draw on the collage.</param>
  77. public void BuildThumbCollage(IReadOnlyList<string> paths, string outputPath, int width, int height, string? libraryName)
  78. {
  79. using var bitmap = BuildThumbCollageBitmap(paths, width, height, libraryName);
  80. using var outputStream = new SKFileWStream(outputPath);
  81. using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels());
  82. pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
  83. }
  84. private SKBitmap BuildThumbCollageBitmap(IReadOnlyList<string> paths, int width, int height, string? libraryName)
  85. {
  86. var bitmap = new SKBitmap(width, height);
  87. using var canvas = new SKCanvas(bitmap);
  88. canvas.Clear(SKColors.Black);
  89. using var backdrop = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, 0, out _);
  90. if (backdrop is null)
  91. {
  92. return bitmap;
  93. }
  94. // resize to the same aspect as the original
  95. var backdropHeight = Math.Abs(width * backdrop.Height / backdrop.Width);
  96. using var residedBackdrop = SkiaEncoder.ResizeImage(backdrop, new SKImageInfo(width, backdropHeight, backdrop.ColorType, backdrop.AlphaType, backdrop.ColorSpace));
  97. // draw the backdrop
  98. canvas.DrawImage(residedBackdrop, 0, 0);
  99. // draw shadow rectangle
  100. using var paintColor = new SKPaint
  101. {
  102. Color = SKColors.Black.WithAlpha(0x78),
  103. Style = SKPaintStyle.Fill
  104. };
  105. canvas.DrawRect(0, 0, width, height, paintColor);
  106. var typeFace = SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright);
  107. // use the system fallback to find a typeface for the given CJK character
  108. var filteredName = NonCjkPatternRegex().Replace(libraryName ?? string.Empty, string.Empty);
  109. if (!string.IsNullOrEmpty(filteredName))
  110. {
  111. typeFace = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, filteredName[0]);
  112. }
  113. // draw library name
  114. using var textPaint = new SKPaint
  115. {
  116. Color = SKColors.White,
  117. Style = SKPaintStyle.Fill,
  118. TextSize = 112,
  119. TextAlign = SKTextAlign.Center,
  120. Typeface = typeFace,
  121. IsAntialias = true
  122. };
  123. // scale down text to 90% of the width if text is larger than 95% of the width
  124. var textWidth = textPaint.MeasureText(libraryName);
  125. if (textWidth > width * 0.95)
  126. {
  127. textPaint.TextSize = 0.9f * width * textPaint.TextSize / textWidth;
  128. }
  129. if (string.IsNullOrWhiteSpace(libraryName))
  130. {
  131. return bitmap;
  132. }
  133. if (IsRtlTextRegex().IsMatch(libraryName))
  134. {
  135. canvas.DrawShapedText(libraryName, width / 2f, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), textPaint);
  136. }
  137. else
  138. {
  139. canvas.DrawText(libraryName, width / 2f, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), textPaint);
  140. }
  141. return bitmap;
  142. }
  143. private SKBitmap BuildSquareCollageBitmap(IReadOnlyList<string> paths, int width, int height)
  144. {
  145. var bitmap = new SKBitmap(width, height);
  146. var imageIndex = 0;
  147. var cellWidth = width / 2;
  148. var cellHeight = height / 2;
  149. using var canvas = new SKCanvas(bitmap);
  150. for (var x = 0; x < 2; x++)
  151. {
  152. for (var y = 0; y < 2; y++)
  153. {
  154. using var currentBitmap = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, imageIndex, out int newIndex);
  155. imageIndex = newIndex;
  156. if (currentBitmap is null)
  157. {
  158. continue;
  159. }
  160. // Scale image. The FromBitmap creates a copy
  161. var imageInfo = new SKImageInfo(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType, currentBitmap.ColorSpace);
  162. using var resizeImage = SkiaEncoder.ResizeImage(currentBitmap, imageInfo);
  163. // draw this image into the strip at the next position
  164. var xPos = x * cellWidth;
  165. var yPos = y * cellHeight;
  166. canvas.DrawImage(resizeImage, xPos, yPos);
  167. }
  168. }
  169. return bitmap;
  170. }
  171. }