diff --git a/Emby.Drawing/NullImageEncoder.cs b/Emby.Drawing/NullImageEncoder.cs
index 1c05aa9161..ed12f6acb2 100644
--- a/Emby.Drawing/NullImageEncoder.cs
+++ b/Emby.Drawing/NullImageEncoder.cs
@@ -43,6 +43,12 @@ namespace Emby.Drawing
throw new NotImplementedException();
}
+ ///
+ public void CreateSplashscreen(SplashscreenOptions options)
+ {
+ throw new NotImplementedException();
+ }
+
///
public string GetImageBlurHash(int xComp, int yComp, string path)
{
diff --git a/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/Jellyfin.Drawing.Skia/SkiaEncoder.cs
index 6d0a5ac2b9..16de5d7fde 100644
--- a/Jellyfin.Drawing.Skia/SkiaEncoder.cs
+++ b/Jellyfin.Drawing.Skia/SkiaEncoder.cs
@@ -492,6 +492,13 @@ namespace Jellyfin.Drawing.Skia
}
}
+ ///
+ public void CreateSplashscreen(SplashscreenOptions options)
+ {
+ var splashBuilder = new SplashscreenBuilder(this);
+ splashBuilder.GenerateSplash(options);
+ }
+
private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options)
{
try
diff --git a/Jellyfin.Drawing.Skia/SkiaHelper.cs b/Jellyfin.Drawing.Skia/SkiaHelper.cs
index f9c79c8558..35dcebdaba 100644
--- a/Jellyfin.Drawing.Skia/SkiaHelper.cs
+++ b/Jellyfin.Drawing.Skia/SkiaHelper.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using SkiaSharp;
namespace Jellyfin.Drawing.Skia
@@ -19,5 +20,41 @@ namespace Jellyfin.Drawing.Skia
throw new SkiaCodecException(result);
}
}
+
+ ///
+ /// Gets the next valid image as a bitmap.
+ ///
+ /// The current skia encoder.
+ /// The list of image paths.
+ /// The current checked indes.
+ /// The new index.
+ /// A valid bitmap, or null if no bitmap exists after currentIndex.
+ public static SKBitmap? GetNextValidImage(SkiaEncoder skiaEncoder, IReadOnlyList paths, int currentIndex, out int newIndex)
+ {
+ var imagesTested = new Dictionary();
+ SKBitmap? bitmap = null;
+
+ while (imagesTested.Count < paths.Count)
+ {
+ if (currentIndex >= paths.Count)
+ {
+ currentIndex = 0;
+ }
+
+ bitmap = skiaEncoder.Decode(paths[currentIndex], false, null, out _);
+
+ imagesTested[currentIndex] = 0;
+
+ currentIndex++;
+
+ if (bitmap != null)
+ {
+ break;
+ }
+ }
+
+ newIndex = currentIndex;
+ return bitmap;
+ }
}
}
diff --git a/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs b/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs
new file mode 100644
index 0000000000..8b6942be0c
--- /dev/null
+++ b/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs
@@ -0,0 +1,162 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Controller.Drawing;
+using SkiaSharp;
+
+namespace Jellyfin.Drawing.Skia
+{
+ ///
+ /// Used to build the splashscreen.
+ ///
+ public class SplashscreenBuilder
+ {
+ private const int Rows = 6;
+ private const int Spacing = 20;
+
+ private readonly SkiaEncoder _skiaEncoder;
+
+ private Random? _random;
+ private int _finalWidth;
+ private int _finalHeight;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The SkiaEncoder.
+ public SplashscreenBuilder(SkiaEncoder skiaEncoder)
+ {
+ _skiaEncoder = skiaEncoder;
+ }
+
+ ///
+ /// Generate a splashscreen.
+ ///
+ /// The options to generate the splashscreen.
+ public void GenerateSplash(SplashscreenOptions options)
+ {
+ _finalWidth = options.Width;
+ _finalHeight = options.Height;
+ var wall = GenerateCollage(options.PortraitInputPaths, options.LandscapeInputPaths, options.ApplyFilter);
+ var transformed = Transform3D(wall);
+
+ using var outputStream = new SKFileWStream(options.OutputPath);
+ using var pixmap = new SKPixmap(new SKImageInfo(_finalWidth, _finalHeight), transformed.GetPixels());
+ pixmap.Encode(outputStream, StripCollageBuilder.GetEncodedFormat(options.OutputPath), 90);
+ }
+
+ ///
+ /// Generates a collage of posters and landscape pictures.
+ ///
+ /// The poster paths.
+ /// The landscape paths.
+ /// Whether to apply the darkening filter.
+ /// The created collage as a bitmap.
+ private SKBitmap GenerateCollage(IReadOnlyList poster, IReadOnlyList backdrop, bool applyFilter)
+ {
+ _random = new Random();
+
+ var posterIndex = 0;
+ var backdropIndex = 0;
+
+ // use higher resolution than final image
+ var bitmap = new SKBitmap(_finalWidth * 3, _finalHeight * 2);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.Black);
+
+ int posterHeight = _finalHeight * 2 / 6;
+
+ for (int i = 0; i < Rows; i++)
+ {
+ int imageCounter = _random.Next(0, 5);
+ int currentWidthPos = i * 75;
+ int currentHeight = i * (posterHeight + Spacing);
+
+ while (currentWidthPos < _finalWidth * 3)
+ {
+ SKBitmap? currentImage;
+
+ switch (imageCounter)
+ {
+ case 0:
+ case 2:
+ case 3:
+ currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, poster, posterIndex, out int newPosterIndex);
+ posterIndex = newPosterIndex;
+ break;
+ default:
+ currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, backdrop, backdropIndex, out int newBackdropIndex);
+ backdropIndex = newBackdropIndex;
+ break;
+ }
+
+ if (currentImage == null)
+ {
+ throw new ArgumentException("Not enough valid pictures provided to create a splashscreen!");
+ }
+
+ // resize to the same aspect as the original
+ var imageWidth = Math.Abs(posterHeight * currentImage.Width / currentImage.Height);
+ using var resizedBitmap = new SKBitmap(imageWidth, posterHeight);
+ currentImage.ScalePixels(resizedBitmap, SKFilterQuality.High);
+
+ // draw on canvas
+ canvas.DrawBitmap(resizedBitmap, currentWidthPos, currentHeight);
+
+ currentWidthPos += imageWidth + Spacing;
+
+ currentImage.Dispose();
+
+ if (imageCounter >= 4)
+ {
+ imageCounter = 0;
+ }
+ else
+ {
+ imageCounter++;
+ }
+ }
+ }
+
+ if (applyFilter)
+ {
+ var paintColor = new SKPaint
+ {
+ Color = SKColors.Black.WithAlpha(0x50),
+ Style = SKPaintStyle.Fill
+ };
+ canvas.DrawRect(0, 0, _finalWidth * 3, _finalHeight * 2, paintColor);
+ }
+
+ return bitmap;
+ }
+
+ ///
+ /// Transform the collage in 3D space.
+ ///
+ /// The bitmap to transform.
+ /// The transformed image.
+ private SKBitmap Transform3D(SKBitmap input)
+ {
+ var bitmap = new SKBitmap(_finalWidth, _finalHeight);
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.Black);
+ var matrix = new SKMatrix
+ {
+ ScaleX = 0.324108899f,
+ ScaleY = 0.563934922f,
+ SkewX = -0.244337708f,
+ SkewY = 0.0377609022f,
+ TransX = 42.0407715f,
+ TransY = -198.104706f,
+ Persp0 = -9.08959337E-05f,
+ Persp1 = 6.85242048E-05f,
+ Persp2 = 0.988209724f
+ };
+
+ canvas.SetMatrix(matrix);
+ canvas.DrawBitmap(input, 0, 0);
+
+ return bitmap;
+ }
+ }
+}
diff --git a/Jellyfin.Drawing.Skia/StripCollageBuilder.cs b/Jellyfin.Drawing.Skia/StripCollageBuilder.cs
index d1cc2255d7..6bece9db6e 100644
--- a/Jellyfin.Drawing.Skia/StripCollageBuilder.cs
+++ b/Jellyfin.Drawing.Skia/StripCollageBuilder.cs
@@ -99,7 +99,7 @@ namespace Jellyfin.Drawing.Skia
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.Black);
- using var backdrop = GetNextValidImage(paths, 0, out _);
+ using var backdrop = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, 0, out _);
if (backdrop == null)
{
return bitmap;
@@ -152,34 +152,6 @@ namespace Jellyfin.Drawing.Skia
return bitmap;
}
- private SKBitmap? GetNextValidImage(IReadOnlyList paths, int currentIndex, out int newIndex)
- {
- var imagesTested = new Dictionary();
- SKBitmap? bitmap = null;
-
- while (imagesTested.Count < paths.Count)
- {
- if (currentIndex >= paths.Count)
- {
- currentIndex = 0;
- }
-
- bitmap = _skiaEncoder.Decode(paths[currentIndex], false, null, out _);
-
- imagesTested[currentIndex] = 0;
-
- currentIndex++;
-
- if (bitmap != null)
- {
- break;
- }
- }
-
- newIndex = currentIndex;
- return bitmap;
- }
-
private SKBitmap BuildSquareCollageBitmap(IReadOnlyList paths, int width, int height)
{
var bitmap = new SKBitmap(width, height);
@@ -192,7 +164,7 @@ namespace Jellyfin.Drawing.Skia
{
for (var y = 0; y < 2; y++)
{
- using var currentBitmap = GetNextValidImage(paths, imageIndex, out int newIndex);
+ using var currentBitmap = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, imageIndex, out int newIndex);
imageIndex = newIndex;
if (currentBitmap == null)
diff --git a/MediaBrowser.Controller/Drawing/IImageEncoder.cs b/MediaBrowser.Controller/Drawing/IImageEncoder.cs
index 4e67cfee4f..57d73699f6 100644
--- a/MediaBrowser.Controller/Drawing/IImageEncoder.cs
+++ b/MediaBrowser.Controller/Drawing/IImageEncoder.cs
@@ -74,5 +74,11 @@ namespace MediaBrowser.Controller.Drawing
/// The options to use when creating the collage.
/// Optional.
void CreateImageCollage(ImageCollageOptions options, string? libraryName);
+
+ ///
+ /// Creates a splashscreen image.
+ ///
+ /// The options to use when creating the splashscreen.
+ void CreateSplashscreen(SplashscreenOptions options);
}
}
diff --git a/MediaBrowser.Controller/Drawing/SplashscreenOptions.cs b/MediaBrowser.Controller/Drawing/SplashscreenOptions.cs
new file mode 100644
index 0000000000..d70773d8f8
--- /dev/null
+++ b/MediaBrowser.Controller/Drawing/SplashscreenOptions.cs
@@ -0,0 +1,59 @@
+using System.Collections.Generic;
+
+namespace MediaBrowser.Controller.Drawing
+{
+ ///
+ /// Options used to generate the splashscreen.
+ ///
+ public class SplashscreenOptions
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The portrait input paths.
+ /// The landscape input paths.
+ /// The output path.
+ /// Optional. The image width.
+ /// Optional. The image height.
+ /// Optional. Apply a darkening filter.
+ public SplashscreenOptions(IReadOnlyList portraitInputPaths, IReadOnlyList landscapeInputPaths, string outputPath, int width = 1920, int height = 1080, bool applyFilter = false)
+ {
+ PortraitInputPaths = portraitInputPaths;
+ LandscapeInputPaths = landscapeInputPaths;
+ OutputPath = outputPath;
+ Width = width;
+ Height = height;
+ ApplyFilter = applyFilter;
+ }
+
+ ///
+ /// Gets or sets the poster input paths.
+ ///
+ public IReadOnlyList PortraitInputPaths { get; set; }
+
+ ///
+ /// Gets or sets the landscape input paths.
+ ///
+ public IReadOnlyList LandscapeInputPaths { get; set; }
+
+ ///
+ /// Gets or sets the output path.
+ ///
+ public string OutputPath { get; set; }
+
+ ///
+ /// Gets or sets the width.
+ ///
+ public int Width { get; set; }
+
+ ///
+ /// Gets or sets the height.
+ ///
+ public int Height { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to apply a darkening filter at the end.
+ ///
+ public bool ApplyFilter { get; set; }
+ }
+}