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; } + } +}