UX Overhaul Part 1 (#3047)

Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>
This commit is contained in:
Robbie Davis 2024-08-09 13:55:31 -04:00 committed by GitHub
parent 5934d516f3
commit ff79710ac6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
324 changed files with 11589 additions and 4598 deletions

View file

@ -1223,7 +1223,7 @@ public class BookService : IBookService
{
// Try to get the cover image from OPF file, if not set, try to parse it from all the files, then result to the first one.
var coverImageContent = epubBook.Content.Cover
?? epubBook.Content.Images.Local.FirstOrDefault(file => Parser.IsCoverImage(file.FilePath)) // FileName -> FilePath
?? epubBook.Content.Images.Local.FirstOrDefault(file => Parser.IsCoverImage(file.FilePath))
?? epubBook.Content.Images.Local.FirstOrDefault();
if (coverImageContent == null) return string.Empty;

View file

@ -1,10 +1,14 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Threading.Tasks;
using API.Constants;
using API.DTOs;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Extensions;
using EasyCaching.Core;
using Flurl;
@ -13,6 +17,9 @@ using HtmlAgilityPack;
using Kavita.Common;
using Microsoft.Extensions.Logging;
using NetVips;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Quantization;
using Image = NetVips.Image;
namespace API.Services;
@ -60,6 +67,7 @@ public interface IImageService
Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat);
Task<bool> IsImage(string filePath);
Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
void UpdateColorScape(IHasCoverImage entity);
}
public class ImageService : IImageService
@ -73,6 +81,9 @@ public class ImageService : IImageService
public const string CollectionTagCoverImageRegex = @"tag\d+";
public const string ReadingListCoverImageRegex = @"readinglist\d+";
private const double WhiteThreshold = 0.90; // Colors with lightness above this are considered too close to white
private const double BlackThreshold = 0.25; // Colors with lightness below this are considered too close to black
/// <summary>
/// Width of the Thumbnail generation
@ -415,13 +426,266 @@ public class ImageService : IImageService
_logger.LogDebug("Favicon.png for {Domain} downloaded and saved successfully", domain);
return filename;
}catch (Exception ex)
} catch (Exception ex)
{
_logger.LogError(ex, "Error downloading favicon.png for {Domain}", domain);
throw;
}
}
private static (Vector3?, Vector3?) GetPrimarySecondaryColors(string imagePath)
{
using var image = Image.NewFromFile(imagePath);
// Resize the image to speed up processing
var resizedImage = image.Resize(0.1);
// Convert image to RGB array
var pixels = resizedImage.WriteToMemory().ToArray();
// Convert to list of Vector3 (RGB)
var rgbPixels = new List<Vector3>();
for (var i = 0; i < pixels.Length - 2; i += 3)
{
rgbPixels.Add(new Vector3(pixels[i], pixels[i + 1], pixels[i + 2]));
}
// Perform k-means clustering
var clusters = KMeansClustering(rgbPixels, 4);
var sorted = SortByVibrancy(clusters);
if (sorted.Count >= 2)
{
return (sorted[0], sorted[1]);
}
if (sorted.Count == 1)
{
return (sorted[0], null);
}
return (null, null);
}
private static (Vector3?, Vector3?) GetPrimaryColorSharp(string imagePath)
{
using var image = SixLabors.ImageSharp.Image.Load<Rgb24>(imagePath);
image.Mutate(
x => x
// Scale the image down preserving the aspect ratio. This will speed up quantization.
// We use nearest neighbor as it will be the fastest approach.
.Resize(new ResizeOptions() { Sampler = KnownResamplers.NearestNeighbor, Size = new SixLabors.ImageSharp.Size(100, 0) })
// Reduce the color palette to 1 color without dithering.
.Quantize(new OctreeQuantizer(new QuantizerOptions { MaxColors = 4 })));
Rgb24 dominantColor = image[0, 0];
// This will give you a dominant color in HEX format i.e #5E35B1FF
return (new Vector3(dominantColor.R, dominantColor.G, dominantColor.B), new Vector3(dominantColor.R, dominantColor.G, dominantColor.B));
}
private static Image PreProcessImage(Image image)
{
// Create a mask for white and black pixels
var whiteMask = image.Colourspace(Enums.Interpretation.Lab)[0] > (WhiteThreshold * 100);
var blackMask = image.Colourspace(Enums.Interpretation.Lab)[0] < (BlackThreshold * 100);
// Create a replacement color (e.g., medium gray)
var replacementColor = new[] { 128.0, 128.0, 128.0 };
// Apply the masks to replace white and black pixels
var processedImage = image.Copy();
processedImage = processedImage.Ifthenelse(whiteMask, replacementColor);
processedImage = processedImage.Ifthenelse(blackMask, replacementColor);
return processedImage;
}
private static Dictionary<Vector3, int> GenerateColorHistogram(Image image)
{
var pixels = image.WriteToMemory().ToArray();
var histogram = new Dictionary<Vector3, int>();
for (var i = 0; i < pixels.Length; i += 3)
{
var color = new Vector3(pixels[i], pixels[i + 1], pixels[i + 2]);
if (!histogram.TryAdd(color, 1))
{
histogram[color]++;
}
}
return histogram;
}
private static bool IsColorCloseToWhiteOrBlack(Vector3 color)
{
var (_, _, lightness) = RgbToHsl(color);
return lightness is > WhiteThreshold or < BlackThreshold;
}
private static List<Vector3> KMeansClustering(List<Vector3> points, int k, int maxIterations = 100)
{
var random = new Random();
var centroids = points.OrderBy(x => random.Next()).Take(k).ToList();
for (var i = 0; i < maxIterations; i++)
{
var clusters = new List<Vector3>[k];
for (var j = 0; j < k; j++)
{
clusters[j] = [];
}
foreach (var point in points)
{
var nearestCentroidIndex = centroids
.Select((centroid, index) => new { Index = index, Distance = Vector3.DistanceSquared(centroid, point) })
.OrderBy(x => x.Distance)
.First().Index;
clusters[nearestCentroidIndex].Add(point);
}
var newCentroids = clusters.Select(cluster =>
cluster.Count != 0 ? new Vector3(
cluster.Average(p => p.X),
cluster.Average(p => p.Y),
cluster.Average(p => p.Z)
) : Vector3.Zero
).ToList();
if (centroids.SequenceEqual(newCentroids))
break;
centroids = newCentroids;
}
return centroids;
}
// public static Vector3 GetComplementaryColor(Vector3 color)
// {
// // Simple complementary color calculation
// return new Vector3(255 - color.X, 255 - color.Y, 255 - color.Z);
// }
public static List<Vector3> SortByBrightness(List<Vector3> colors)
{
return colors.OrderBy(c => 0.299 * c.X + 0.587 * c.Y + 0.114 * c.Z).ToList();
}
public static List<Vector3> SortByVibrancy(List<Vector3> colors)
{
return colors.OrderByDescending(c =>
{
float max = Math.Max(c.X, Math.Max(c.Y, c.Z));
float min = Math.Min(c.X, Math.Min(c.Y, c.Z));
return (max - min) / max;
}).ToList();
}
private static string RgbToHex(Vector3 color)
{
return $"#{(int)color.X:X2}{(int)color.Y:X2}{(int)color.Z:X2}";
}
private static Vector3 GetComplementaryColor(Vector3 color)
{
// Convert RGB to HSL
var (h, s, l) = RgbToHsl(color);
// Rotate hue by 180 degrees
h = (h + 180) % 360;
// Convert back to RGB
return HslToRgb(h, s, l);
}
private static (double H, double S, double L) RgbToHsl(Vector3 rgb)
{
double r = rgb.X / 255;
double g = rgb.Y / 255;
double b = rgb.Z / 255;
var max = Math.Max(r, Math.Max(g, b));
var min = Math.Min(r, Math.Min(g, b));
var diff = max - min;
double h = 0;
double s = 0;
var l = (max + min) / 2;
if (Math.Abs(diff) > 0.00001)
{
s = l > 0.5 ? diff / (2 - max - min) : diff / (max + min);
if (max == r)
h = (g - b) / diff + (g < b ? 6 : 0);
else if (max == g)
h = (b - r) / diff + 2;
else if (max == b)
h = (r - g) / diff + 4;
h *= 60;
}
return (h, s, l);
}
private static Vector3 HslToRgb(double h, double s, double l)
{
double r, g, b;
if (Math.Abs(s) < 0.00001)
{
r = g = b = l;
}
else
{
var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
var p = 2 * l - q;
r = HueToRgb(p, q, h + 120);
g = HueToRgb(p, q, h);
b = HueToRgb(p, q, h - 120);
}
return new Vector3((float)(r * 255), (float)(g * 255), (float)(b * 255));
}
private static double HueToRgb(double p, double q, double t)
{
if (t < 0) t += 360;
if (t > 360) t -= 360;
return t switch
{
< 60 => p + (q - p) * t / 60,
< 180 => q,
< 240 => p + (q - p) * (240 - t) / 60,
_ => p
};
}
/// <summary>
/// Generates the Primary and Secondary colors from a file
/// </summary>
/// <remarks>This may use a second most common color or a complementary color. It's up to implemenation to choose what's best</remarks>
/// <param name="sourceFile"></param>
/// <returns></returns>
public static ColorScape CalculateColorScape(string sourceFile)
{
if (!File.Exists(sourceFile)) return new ColorScape() {Primary = null, Secondary = null};
var colors = GetPrimarySecondaryColors(sourceFile);
return new ColorScape()
{
Primary = colors.Item1 == null ? null : RgbToHex(colors.Item1.Value),
Secondary = colors.Item2 == null ? null : RgbToHex(colors.Item2.Value)
};
}
private static string FallbackToKavitaReaderFavicon(string baseUrl)
{
var correctSizeLink = string.Empty;
@ -582,4 +846,39 @@ public class ImageService : IImageService
image.WriteToFile(dest);
}
public void UpdateColorScape(IHasCoverImage entity)
{
var colors = CalculateColorScape(
_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, entity.CoverImage));
entity.PrimaryColor = colors.Primary;
entity.SecondaryColor = colors.Secondary;
}
public static Color HexToRgb(string? hex)
{
if (string.IsNullOrEmpty(hex)) throw new ArgumentException("Hex cannot be null");
// Remove the leading '#' if present
hex = hex.TrimStart('#');
// Ensure the hex string is valid
if (hex.Length != 6 && hex.Length != 3)
{
throw new ArgumentException("Hex string should be 6 or 3 characters long.");
}
if (hex.Length == 3)
{
// Expand shorthand notation to full form (e.g., "abc" -> "aabbcc")
hex = string.Concat(hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]);
}
// Parse the hex string into RGB components
var r = Convert.ToInt32(hex.Substring(0, 2), 16);
var g = Convert.ToInt32(hex.Substring(2, 2), 16);
var b = Convert.ToInt32(hex.Substring(4, 2), 16);
return Color.FromArgb(r, g, b);
}
}

View file

@ -1,12 +1,14 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.Data;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Extensions;
using API.Helpers;
using API.SignalR;
@ -38,6 +40,9 @@ public interface IMetadataService
Task RemoveAbandonedMetadataKeys();
}
/// <summary>
/// Handles everything around Cover/ColorScape management
/// </summary>
public class MetadataService : IMetadataService
{
public const string Name = "MetadataService";
@ -47,10 +52,13 @@ public class MetadataService : IMetadataService
private readonly ICacheHelper _cacheHelper;
private readonly IReadingItemService _readingItemService;
private readonly IDirectoryService _directoryService;
private readonly IImageService _imageService;
private readonly IList<SignalRMessage> _updateEvents = new List<SignalRMessage>();
public MetadataService(IUnitOfWork unitOfWork, ILogger<MetadataService> logger,
IEventHub eventHub, ICacheHelper cacheHelper,
IReadingItemService readingItemService, IDirectoryService directoryService)
IReadingItemService readingItemService, IDirectoryService directoryService,
IImageService imageService)
{
_unitOfWork = unitOfWork;
_logger = logger;
@ -58,6 +66,7 @@ public class MetadataService : IMetadataService
_cacheHelper = cacheHelper;
_readingItemService = readingItemService;
_directoryService = directoryService;
_imageService = imageService;
}
/// <summary>
@ -71,16 +80,28 @@ public class MetadataService : IMetadataService
var firstFile = chapter.Files.MinBy(x => x.Chapter);
if (firstFile == null) return Task.FromResult(false);
if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage),
if (!_cacheHelper.ShouldUpdateCoverImage(
_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage),
firstFile, chapter.Created, forceUpdate, chapter.CoverImageLocked))
return Task.FromResult(false);
{
if (NeedsColorSpace(chapter))
{
_imageService.UpdateColorScape(chapter);
_unitOfWork.ChapterRepository.Update(chapter);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter));
}
return Task.FromResult(false);
}
_logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath);
chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath,
ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format, encodeFormat, coverImageSize);
_imageService.UpdateColorScape(chapter);
_unitOfWork.ChapterRepository.Update(chapter);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter));
@ -95,6 +116,15 @@ public class MetadataService : IMetadataService
firstFile.UpdateLastModified();
}
private static bool NeedsColorSpace(IHasCoverImage? entity)
{
if (entity == null) return false;
return !string.IsNullOrEmpty(entity.CoverImage) &&
(string.IsNullOrEmpty(entity.PrimaryColor) || string.IsNullOrEmpty(entity.SecondaryColor));
}
/// <summary>
/// Updates the cover image for a Volume
/// </summary>
@ -105,8 +135,16 @@ public class MetadataService : IMetadataService
// We need to check if Volume coverImage matches first chapters if forceUpdate is false
if (volume == null || !_cacheHelper.ShouldUpdateCoverImage(
_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, volume.CoverImage),
null, volume.Created, forceUpdate)) return Task.FromResult(false);
null, volume.Created, forceUpdate))
{
if (NeedsColorSpace(volume))
{
_imageService.UpdateColorScape(volume);
_unitOfWork.VolumeRepository.Update(volume);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume));
}
return Task.FromResult(false);
}
// For cover selection, chapters need to try for issue 1 first, then fallback to first sort order
volume.Chapters ??= new List<Chapter>();
@ -118,7 +156,10 @@ public class MetadataService : IMetadataService
if (firstChapter == null) return Task.FromResult(false);
}
volume.CoverImage = firstChapter.CoverImage;
_imageService.UpdateColorScape(volume);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume));
return Task.FromResult(true);
@ -133,13 +174,26 @@ public class MetadataService : IMetadataService
{
if (series == null) return Task.CompletedTask;
if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, series.CoverImage),
if (!_cacheHelper.ShouldUpdateCoverImage(
_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, series.CoverImage),
null, series.Created, forceUpdate, series.CoverImageLocked))
{
// Check if we don't have a primary/seconary color
if (NeedsColorSpace(series))
{
_imageService.UpdateColorScape(series);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series));
}
return Task.CompletedTask;
}
series.Volumes ??= [];
series.CoverImage = series.GetCoverImage();
_imageService.UpdateColorScape(series);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series));
return Task.CompletedTask;
}

View file

@ -48,6 +48,7 @@ public interface IReadingListService
Task CreateReadingListsFromSeries(Series series, Library library);
Task CreateReadingListsFromSeries(int libraryId, int seriesId);
Task<string> GenerateReadingListCoverImage(int readingListId);
}
/// <summary>
@ -59,15 +60,20 @@ public class ReadingListService : IReadingListService
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ReadingListService> _logger;
private readonly IEventHub _eventHub;
private readonly ChapterSortComparerDefaultFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerDefaultFirst.Default;
private readonly IImageService _imageService;
private readonly IDirectoryService _directoryService;
private static readonly Regex JustNumbers = new Regex(@"^\d+$", RegexOptions.Compiled | RegexOptions.IgnoreCase,
Parser.RegexTimeout);
public ReadingListService(IUnitOfWork unitOfWork, ILogger<ReadingListService> logger, IEventHub eventHub)
public ReadingListService(IUnitOfWork unitOfWork, ILogger<ReadingListService> logger,
IEventHub eventHub, IImageService imageService, IDirectoryService directoryService)
{
_unitOfWork = unitOfWork;
_logger = logger;
_eventHub = eventHub;
_imageService = imageService;
_directoryService = directoryService;
}
public static string FormatTitle(ReadingListItemDto item)
@ -488,8 +494,12 @@ public class ReadingListService : IReadingListService
if (!_unitOfWork.HasChanges()) continue;
_imageService.UpdateColorScape(readingList);
await CalculateReadingListAgeRating(readingList);
await _unitOfWork.CommitAsync(); // TODO: See if we can avoid this extra commit by reworking bottom logic
await CalculateStartAndEndDates(await _unitOfWork.ReadingListRepository.GetReadingListByTitleAsync(arcPair.Item1,
user.Id, ReadingListIncludes.Items | ReadingListIncludes.ItemChapter));
await _unitOfWork.CommitAsync();
@ -632,6 +642,7 @@ public class ReadingListService : IReadingListService
var allSeriesLocalized = userSeries.ToDictionary(s => s.NormalizedLocalizedName);
var readingListNameNormalized = Parser.Normalize(cblReading.Name);
// Get all the user's reading lists
var allReadingLists = (user.ReadingLists).ToDictionary(s => s.NormalizedTitle);
if (!allReadingLists.TryGetValue(readingListNameNormalized, out var readingList))
@ -736,7 +747,10 @@ public class ReadingListService : IReadingListService
}
// If there are no items, don't create a blank list
if (!_unitOfWork.HasChanges() || !readingList.Items.Any()) return importSummary;
if (!_unitOfWork.HasChanges() || readingList.Items.Count == 0) return importSummary;
_imageService.UpdateColorScape(readingList);
await _unitOfWork.CommitAsync();
@ -787,4 +801,33 @@ public class ReadingListService : IReadingListService
file.Close();
return cblReadingList;
}
public async Task<string> GenerateReadingListCoverImage(int readingListId)
{
// TODO: Currently reading lists are dynamically generated at runtime. This needs to be overhauled to be generated and stored within
// the Reading List (and just expire every so often) so we can utilize ColorScapes.
// Check if a cover already exists for the reading list
// var potentialExistingCoverPath = _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory,
// ImageService.GetReadingListFormat(readingListId));
// if (_directoryService.FileSystem.File.Exists(potentialExistingCoverPath))
// {
// // Check if we need to update CoverScape
//
// }
var covers = await _unitOfWork.ReadingListRepository.GetRandomCoverImagesAsync(readingListId);
var destFile = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory,
ImageService.GetReadingListFormat(readingListId));
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
destFile += settings.EncodeMediaAs.GetExtension();
if (_directoryService.FileSystem.File.Exists(destFile)) return destFile;
ImageService.CreateMergedImage(
covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(),
settings.CoverImageSize,
destFile);
// TODO: Refactor this so that reading lists have a dedicated cover image so we can calculate primary/secondary colors
return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile;
}
}

View file

@ -97,7 +97,6 @@ public class VersionUpdaterService : IVersionUpdaterService
// isNightly can be true when we compare something like v0.8.1 vs v0.8.1.0
if (IsVersionEqualToBuildVersion(updateVersion))
{
//latestRelease.UpdateVersion = BuildInfo.Version.ToString();
isNightly = false;
}