diff --git a/Directory.Packages.props b/Directory.Packages.props index 2707f90c4..2951d87c9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,7 +27,7 @@ - + @@ -39,6 +39,8 @@ + + @@ -75,7 +77,7 @@ - + diff --git a/NexusMods.App.sln b/NexusMods.App.sln index b4605a168..da34ba818 100644 --- a/NexusMods.App.sln +++ b/NexusMods.App.sln @@ -254,12 +254,22 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Collections", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Collections.Tests", "tests\NexusMods.Collections.Tests\NexusMods.Collections.Tests.csproj", "{8C817874-7A88-450E-B216-851A1B03684C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Media", "src\Abstractions\NexusMods.Abstractions.Media\NexusMods.Abstractions.Media.csproj", "{5CB6D02C-07D0-4C0D-BF5C-4E2E958A0612}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Games.Larian", "src\Games\NexusMods.Games.Larian\NexusMods.Games.Larian.csproj", "{2A35EBB5-1CA6-4F5D-8CE8-352146C82C28}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Games.Larian.Tests", "tests\Games\NexusMods.Games.Larian.Tests\NexusMods.Games.Larian.Tests.csproj", "{425F7A13-99A2-4231-B0C1-C56EB819C174}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Media", "src\NexusMods.Media\NexusMods.Media.csproj", "{CEC177AB-4FF0-4F8A-81B8-1E756D892416}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Resources", "src\Abstractions\NexusMods.Abstractions.Resources\NexusMods.Abstractions.Resources.csproj", "{8744F914-BF51-4276-AFDA-9CBD750B8187}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Resources.DB", "src\Abstractions\NexusMods.Abstractions.Resources.DB\NexusMods.Abstractions.Resources.DB.csproj", "{856B58BA-8B98-42C5-9129-273A679697D0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Resources.Caching", "src\Abstractions\NexusMods.Abstractions.Resources.Caching\NexusMods.Abstractions.Resources.Caching.csproj", "{BE8C17C4-E3B0-4D07-8CD0-0D15C3CCA9D5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Resources.IO", "src\Abstractions\NexusMods.Abstractions.Resources.IO\NexusMods.Abstractions.Resources.IO.csproj", "{D3BA5B5A-668A-443B-872C-3116CBB0BC0D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Resources.Resilience", "src\Abstractions\NexusMods.Abstractions.Resources.Resilience\NexusMods.Abstractions.Resources.Resilience.csproj", "{04219A58-C99C-4C3B-A477-5E4B29D1F275}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -662,10 +672,6 @@ Global {8C817874-7A88-450E-B216-851A1B03684C}.Debug|Any CPU.Build.0 = Debug|Any CPU {8C817874-7A88-450E-B216-851A1B03684C}.Release|Any CPU.ActiveCfg = Release|Any CPU {8C817874-7A88-450E-B216-851A1B03684C}.Release|Any CPU.Build.0 = Release|Any CPU - {5CB6D02C-07D0-4C0D-BF5C-4E2E958A0612}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5CB6D02C-07D0-4C0D-BF5C-4E2E958A0612}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5CB6D02C-07D0-4C0D-BF5C-4E2E958A0612}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5CB6D02C-07D0-4C0D-BF5C-4E2E958A0612}.Release|Any CPU.Build.0 = Release|Any CPU {2A35EBB5-1CA6-4F5D-8CE8-352146C82C28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2A35EBB5-1CA6-4F5D-8CE8-352146C82C28}.Debug|Any CPU.Build.0 = Debug|Any CPU {2A35EBB5-1CA6-4F5D-8CE8-352146C82C28}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -674,6 +680,30 @@ Global {425F7A13-99A2-4231-B0C1-C56EB819C174}.Debug|Any CPU.Build.0 = Debug|Any CPU {425F7A13-99A2-4231-B0C1-C56EB819C174}.Release|Any CPU.ActiveCfg = Release|Any CPU {425F7A13-99A2-4231-B0C1-C56EB819C174}.Release|Any CPU.Build.0 = Release|Any CPU + {CEC177AB-4FF0-4F8A-81B8-1E756D892416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CEC177AB-4FF0-4F8A-81B8-1E756D892416}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CEC177AB-4FF0-4F8A-81B8-1E756D892416}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CEC177AB-4FF0-4F8A-81B8-1E756D892416}.Release|Any CPU.Build.0 = Release|Any CPU + {8744F914-BF51-4276-AFDA-9CBD750B8187}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8744F914-BF51-4276-AFDA-9CBD750B8187}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8744F914-BF51-4276-AFDA-9CBD750B8187}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8744F914-BF51-4276-AFDA-9CBD750B8187}.Release|Any CPU.Build.0 = Release|Any CPU + {856B58BA-8B98-42C5-9129-273A679697D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {856B58BA-8B98-42C5-9129-273A679697D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {856B58BA-8B98-42C5-9129-273A679697D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {856B58BA-8B98-42C5-9129-273A679697D0}.Release|Any CPU.Build.0 = Release|Any CPU + {BE8C17C4-E3B0-4D07-8CD0-0D15C3CCA9D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BE8C17C4-E3B0-4D07-8CD0-0D15C3CCA9D5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE8C17C4-E3B0-4D07-8CD0-0D15C3CCA9D5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BE8C17C4-E3B0-4D07-8CD0-0D15C3CCA9D5}.Release|Any CPU.Build.0 = Release|Any CPU + {D3BA5B5A-668A-443B-872C-3116CBB0BC0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3BA5B5A-668A-443B-872C-3116CBB0BC0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3BA5B5A-668A-443B-872C-3116CBB0BC0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3BA5B5A-668A-443B-872C-3116CBB0BC0D}.Release|Any CPU.Build.0 = Release|Any CPU + {04219A58-C99C-4C3B-A477-5E4B29D1F275}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {04219A58-C99C-4C3B-A477-5E4B29D1F275}.Debug|Any CPU.Build.0 = Debug|Any CPU + {04219A58-C99C-4C3B-A477-5E4B29D1F275}.Release|Any CPU.ActiveCfg = Release|Any CPU + {04219A58-C99C-4C3B-A477-5E4B29D1F275}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -791,9 +821,14 @@ Global {BF6EEEA3-9C9C-404E-9B2D-6926EF503384} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C} {A9FD538A-E101-4AEA-A98E-35DCED950AEE} = {E7BAE287-D505-4D6D-A090-665A64309B2D} {8C817874-7A88-450E-B216-851A1B03684C} = {52AF9D62-7D5B-4AD0-BA12-86F2AA67428B} - {5CB6D02C-07D0-4C0D-BF5C-4E2E958A0612} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C} {2A35EBB5-1CA6-4F5D-8CE8-352146C82C28} = {70D38D24-79AE-4600-8E83-17F3C11BA81F} {425F7A13-99A2-4231-B0C1-C56EB819C174} = {05B06AC1-7F2B-492F-983E-5BC63CDBF20D} + {CEC177AB-4FF0-4F8A-81B8-1E756D892416} = {E7BAE287-D505-4D6D-A090-665A64309B2D} + {8744F914-BF51-4276-AFDA-9CBD750B8187} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C} + {856B58BA-8B98-42C5-9129-273A679697D0} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C} + {BE8C17C4-E3B0-4D07-8CD0-0D15C3CCA9D5} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C} + {D3BA5B5A-668A-443B-872C-3116CBB0BC0D} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C} + {04219A58-C99C-4C3B-A477-5E4B29D1F275} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {9F9F8352-34DD-42C0-8564-EE9AF34A3501} diff --git a/src/Abstractions/NexusMods.Abstractions.Media/IImageStore.cs b/src/Abstractions/NexusMods.Abstractions.Media/IImageStore.cs deleted file mode 100644 index 393a4905c..000000000 --- a/src/Abstractions/NexusMods.Abstractions.Media/IImageStore.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Avalonia.Media.Imaging; -using BitFaster.Caching; -using JetBrains.Annotations; -using NexusMods.MnemonicDB.Abstractions; -using OneOf; - -namespace NexusMods.Abstractions.Media; - -/// -/// Optimized storage for images. -/// -[PublicAPI] -public interface IImageStore -{ - ValueTask PutAsync(Bitmap bitmap); - - [MustDisposeResource] Lifetime? Get(OneOf input); - - StoredImage.New CreateStoredImage(ITransaction transaction, Bitmap bitmap); -} diff --git a/src/Abstractions/NexusMods.Abstractions.Media/ImageData.cs b/src/Abstractions/NexusMods.Abstractions.Media/ImageData.cs deleted file mode 100644 index 6f19fcd35..000000000 --- a/src/Abstractions/NexusMods.Abstractions.Media/ImageData.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace NexusMods.Abstractions.Media; - -/// -/// Image data. -/// -public readonly struct ImageData -{ - /// - /// Compression type. - /// - public readonly ImageDataCompression Compression; - - /// - /// Binary data. - /// - public readonly byte[] Data; - - /// - /// Constructor. - /// - public ImageData(ImageDataCompression compression, byte[] data) - { - Compression = compression; - Data = data; - } -} - -/// -/// Compression types. -/// -public enum ImageDataCompression : byte -{ - /// - /// No compression. - /// - None = 0, -} diff --git a/src/Abstractions/NexusMods.Abstractions.Media/ImageDataAttribute.cs b/src/Abstractions/NexusMods.Abstractions.Media/ImageDataAttribute.cs deleted file mode 100644 index 3eb07d654..000000000 --- a/src/Abstractions/NexusMods.Abstractions.Media/ImageDataAttribute.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Diagnostics; -using NexusMods.MnemonicDB.Abstractions; -using NexusMods.MnemonicDB.Abstractions.Attributes; -using NexusMods.MnemonicDB.Abstractions.ElementComparers; - -namespace NexusMods.Abstractions.Media; - -/// -/// Binary blob containing image data. -/// -public class ImageDataAttribute(string ns, string name) : BlobAttribute(ns, name) -{ - /// - protected override ImageData FromLowLevel(ReadOnlySpan value, ValueTags tags, AttributeResolver resolver) - { - Debug.Assert(sizeof(ImageDataCompression) == 1); - var compression = (ImageDataCompression)value[0]; - - var data = Decompress(compression, value[1..]); - return new ImageData(compression, data); - } - - /// - protected override void WriteValue(ImageData value, TWriter writer) - { - Debug.Assert(sizeof(ImageDataCompression) == 1); - var count = value.Data.Length + sizeof(ImageDataCompression); - - var span = writer.GetSpan(sizeHint: count); - span[0] = (byte)value.Compression; - - var bytesWritten = Compress(value.Compression, value.Data, span[1..]); - writer.Advance(bytesWritten + sizeof(ImageDataCompression)); - } - - private static int Compress(ImageDataCompression compression, byte[] data, Span outputSpan) - { - switch (compression) - { - case ImageDataCompression.None: - data.CopyTo(outputSpan); - return data.Length; - default: - throw new ArgumentOutOfRangeException(); - } - } - - private static byte[] Decompress(ImageDataCompression compression, ReadOnlySpan data) - { - switch (compression) - { - case ImageDataCompression.None: - return data.ToArray(); - default: - throw new ArgumentOutOfRangeException(); - } - } -} diff --git a/src/Abstractions/NexusMods.Abstractions.Media/ImageMetadata.cs b/src/Abstractions/NexusMods.Abstractions.Media/ImageMetadata.cs deleted file mode 100644 index d5a15803f..000000000 --- a/src/Abstractions/NexusMods.Abstractions.Media/ImageMetadata.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using Avalonia; -using Avalonia.Platform; -using Avalonia.Skia; -using SkiaSharp; - -namespace NexusMods.Abstractions.Media; - -/// -/// Metadata of an image. -/// -[StructLayout(LayoutKind.Sequential)] -public readonly struct ImageMetadata -{ - /// - /// Width. - /// - public readonly uint ImageWidth; - - /// - /// Height. - /// - public readonly uint ImageHeight; - - /// - /// Color type. - /// - public readonly SKColorType SkColorType; - - /// - /// Alpha format. - /// - public readonly AlphaFormat AlphaFormat; - - /// - /// DPI. - /// - public readonly uint Dpi; - - /// - /// Pixel size. - /// - public PixelSize PixelSize => new((int)ImageWidth, (int)ImageHeight); - - /// - /// Pixel format. - /// - public PixelFormat PixelFormat => SkColorType.ToPixelFormat(); - - // NOTE(erri120): Going from bits to bytes requires dividing by 8, aka bit shift by 3 - - /// - /// Stride is the number of bytes from one row pixels in memory to the next row. - /// - public int Stride => ((int)ImageWidth * PixelFormat.BitsPerPixel) >> 3; - - /// - /// Total length of the raw data. - /// - public ulong DataLength => (ImageWidth * ImageHeight * (uint)PixelFormat.BitsPerPixel) >> 3; - - /// - /// Constructor. - /// - public ImageMetadata(uint imageWidth, uint imageHeight, SKColorType skColorType, AlphaFormat alphaFormat, uint dpi) - { - ImageWidth = imageWidth; - ImageHeight = imageHeight; - SkColorType = skColorType; - AlphaFormat = alphaFormat; - Dpi = dpi; - } - - /// - /// Reads the binary data as metadata. - /// - public static ImageMetadata Read(ReadOnlySpan bytes) - { - Debug.Assert(bytes.Length == Marshal.SizeOf()); - - unsafe - { - fixed (byte* b = bytes) - { - return Unsafe.Read(b); - } - } - } - - /// - /// Writes the metadata as binary data. - /// - public void Write(Span bytes) - { - Debug.Assert(bytes.Length == Marshal.SizeOf()); - - unsafe - { - fixed (void* b = bytes) - { - Unsafe.Write(b, this); - } - } - } -} diff --git a/src/Abstractions/NexusMods.Abstractions.Media/ImageMetadataAttribute.cs b/src/Abstractions/NexusMods.Abstractions.Media/ImageMetadataAttribute.cs deleted file mode 100644 index 7287ea5b5..000000000 --- a/src/Abstractions/NexusMods.Abstractions.Media/ImageMetadataAttribute.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Runtime.InteropServices; -using NexusMods.MnemonicDB.Abstractions; -using NexusMods.MnemonicDB.Abstractions.Attributes; -using NexusMods.MnemonicDB.Abstractions.ElementComparers; - -namespace NexusMods.Abstractions.Media; - -/// -/// Binary blob representation of . -/// -public class ImageMetadataAttribute(string ns, string name) : BlobAttribute(ns, name) -{ - private static readonly int ImageMetadataSize = Marshal.SizeOf(); - - /// - protected override ImageMetadata FromLowLevel(ReadOnlySpan value, ValueTags tags, AttributeResolver resolver) - { - return ImageMetadata.Read(value); - } - - /// - protected override void WriteValue(ImageMetadata value, TWriter writer) - { - var span = writer.GetSpan(sizeHint: ImageMetadataSize); - value.Write(span); - writer.Advance(ImageMetadataSize); - } -} diff --git a/src/Abstractions/NexusMods.Abstractions.Media/ServiceExtensions.cs b/src/Abstractions/NexusMods.Abstractions.Media/ServiceExtensions.cs deleted file mode 100644 index fc8be43ac..000000000 --- a/src/Abstractions/NexusMods.Abstractions.Media/ServiceExtensions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace NexusMods.Abstractions.Media; - -/// -/// Extension methods. -/// -public static class ServiceExtensions -{ - /// - /// Adds media. - /// - public static IServiceCollection AddMedia(this IServiceCollection serviceCollection) - { - return serviceCollection.AddStoredImageModel(); - } -} diff --git a/src/Abstractions/NexusMods.Abstractions.Media/StoredImage.cs b/src/Abstractions/NexusMods.Abstractions.Media/StoredImage.cs deleted file mode 100644 index 3891c3db8..000000000 --- a/src/Abstractions/NexusMods.Abstractions.Media/StoredImage.cs +++ /dev/null @@ -1,23 +0,0 @@ -using JetBrains.Annotations; -using NexusMods.MnemonicDB.Abstractions.Models; - -namespace NexusMods.Abstractions.Media; - -/// -/// Represent an image. -/// -[UsedImplicitly] -public partial class StoredImage : IModelDefinition -{ - private const string Namespace = "NexusMods.ImageStore.StoredImage"; - - /// - /// Image data. - /// - public static readonly ImageDataAttribute ImageData = new(Namespace, nameof(ImageData)) { NoHistory = true }; - - /// - /// Image metadata. - /// - public static readonly ImageMetadataAttribute Metadata = new(Namespace, nameof(Metadata)); -} diff --git a/src/Abstractions/NexusMods.Abstractions.MnemonicDB.Attributes/BytesAttribute.cs b/src/Abstractions/NexusMods.Abstractions.MnemonicDB.Attributes/BytesAttribute.cs new file mode 100644 index 000000000..922720486 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.MnemonicDB.Attributes/BytesAttribute.cs @@ -0,0 +1,23 @@ +using NexusMods.MnemonicDB.Abstractions; +using NexusMods.MnemonicDB.Abstractions.Attributes; +using NexusMods.MnemonicDB.Abstractions.ElementComparers; + +namespace NexusMods.Abstractions.MnemonicDB.Attributes; + +/// +/// Bytes. +/// +public class BytesAttribute(string ns, string name) : BlobAttribute(ns, name) +{ + /// + protected override byte[] FromLowLevel(ReadOnlySpan value, ValueTags tags, AttributeResolver resolver) => value.ToArray(); + + /// + protected override void WriteValue(byte[] value, TWriter writer) + { + var span = writer.GetSpan(sizeHint: value.Length); + + value.CopyTo(span); + writer.Advance(value.Length); + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusMods.Abstractions.NexusModsLibrary.csproj b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusMods.Abstractions.NexusModsLibrary.csproj index 65669311d..a65ed011a 100644 --- a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusMods.Abstractions.NexusModsLibrary.csproj +++ b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusMods.Abstractions.NexusModsLibrary.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusModsModPageMetadata.cs b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusModsModPageMetadata.cs index 73f293adb..6d4cd1a75 100644 --- a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusModsModPageMetadata.cs +++ b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusModsModPageMetadata.cs @@ -1,6 +1,7 @@ using JetBrains.Annotations; using NexusMods.Abstractions.MnemonicDB.Attributes; using NexusMods.Abstractions.NexusWebApi.Types; +using NexusMods.Abstractions.Resources.DB; using NexusMods.Abstractions.Telemetry; using NexusMods.MnemonicDB.Abstractions.Attributes; using NexusMods.MnemonicDB.Abstractions.Models; @@ -35,6 +36,8 @@ public partial class NexusModsModPageMetadata : IModelDefinition /// public static readonly UriAttribute FullSizedPictureUri = new(Namespace, nameof(FullSizedPictureUri)) { IsOptional = true }; + public static readonly ReferenceAttribute ThumbnailResource = new(Namespace, nameof(ThumbnailResource)) { IsOptional = true }; + /// /// Uri for the thumbnail of the full sized picture. /// diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.Caching/NexusMods.Abstractions.Resources.Caching.csproj b/src/Abstractions/NexusMods.Abstractions.Resources.Caching/NexusMods.Abstractions.Resources.Caching.csproj new file mode 100644 index 000000000..c3fbd3909 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources.Caching/NexusMods.Abstractions.Resources.Caching.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.Caching/ResourceCache.cs b/src/Abstractions/NexusMods.Abstractions.Resources.Caching/ResourceCache.cs new file mode 100644 index 000000000..7b9ee6cf3 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources.Caching/ResourceCache.cs @@ -0,0 +1,79 @@ +using BitFaster.Caching; +using BitFaster.Caching.Lru; +using JetBrains.Annotations; + +namespace NexusMods.Abstractions.Resources.Caching; + +[PublicAPI] +public sealed class ResourceCache : IResourceLoader + where TResourceIdentifier : notnull + where TData : notnull + where TKey : notnull +{ + private readonly Func _keyGenerator; + private readonly IResourceLoader _inner; + private readonly IAsyncCache _cache; + + /// + /// Constructor. + /// + public ResourceCache( + Func keyGenerator, + IEqualityComparer keyComparer, + ICapacityPartition capacityPartition, + IResourceLoader inner) + { + _keyGenerator = keyGenerator; + _inner = inner; + + _cache = new ConcurrentLruBuilder() + .WithKeyComparer(keyComparer) + .WithCapacity(capacityPartition) + .AsAsyncCache() + .Build(); + } + + /// + public async ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken) + { + var key = _keyGenerator(resourceIdentifier); + var lifetime = await _cache.GetOrAddAsync(key, static (key, state) => AddAsync(state.Item1, state.Item2, state.Item3), (this, resourceIdentifier, cancellationToken)); + + return new Resource + { + Data = lifetime, + }; + } + + private static async Task AddAsync( + ResourceCache self, + TResourceIdentifier resourceIdentifier, + CancellationToken cancellationToken) + { + var resource = await self._inner.LoadResourceAsync(resourceIdentifier, cancellationToken); + return resource.Data; + } +} + +public static partial class ExtensionsMethods +{ + public static IResourceLoader UseCache( + this IResourceLoader inner, + Func keyGenerator, + IEqualityComparer keyComparer, + ICapacityPartition capacityPartition) + where TResourceIdentifier : notnull + where TData : notnull + where TKey : notnull + { + return inner.Then( + state: (keyGenerator, keyComparer, capacityPartition), + factory: static (input, inner) => new ResourceCache( + keyGenerator: input.keyGenerator, + keyComparer: input.keyComparer, + capacityPartition: input.capacityPartition, + inner: inner + ) + ); + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.Caching/ScopedResourceCache.cs b/src/Abstractions/NexusMods.Abstractions.Resources.Caching/ScopedResourceCache.cs new file mode 100644 index 000000000..04a15baae --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources.Caching/ScopedResourceCache.cs @@ -0,0 +1,80 @@ +using BitFaster.Caching; +using BitFaster.Caching.Lru; +using JetBrains.Annotations; + +namespace NexusMods.Abstractions.Resources.Caching; + +[PublicAPI] +public sealed class ScopedResourceCache : IResourceLoader> + where TResourceIdentifier : notnull + where TData : IDisposable + where TKey : notnull +{ + private readonly Func _keyGenerator; + private readonly IResourceLoader _inner; + private readonly IScopedAsyncCache _cache; + + /// + /// Constructor. + /// + public ScopedResourceCache( + Func keyGenerator, + IEqualityComparer keyComparer, + ICapacityPartition capacityPartition, + IResourceLoader inner) + { + _keyGenerator = keyGenerator; + _inner = inner; + + _cache = new ConcurrentLruBuilder() + .WithKeyComparer(keyComparer) + .WithCapacity(capacityPartition) + .AsAsyncCache() + .AsScopedCache() + .Build(); + } + + public async ValueTask>> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken) + { + var key = _keyGenerator(resourceIdentifier); + var lifetime = await _cache.ScopedGetOrAddAsync(key, static (key, state) => AddAsync(state.Item1, state.Item2, state.Item3), (this, resourceIdentifier, cancellationToken)); + + return new Resource> + { + Data = lifetime, + }; + } + + private static async Task> AddAsync( + ScopedResourceCache self, + TResourceIdentifier resourceIdentifier, + CancellationToken cancellationToken) + { + var resource = await self._inner.LoadResourceAsync(resourceIdentifier, cancellationToken); + return new Scoped(resource.Data); + } +} + +[PublicAPI] +public static partial class ExtensionsMethods +{ + public static IResourceLoader> UseScopedCache( + this IResourceLoader inner, + Func keyGenerator, + IEqualityComparer keyComparer, + ICapacityPartition capacityPartition) + where TResourceIdentifier : notnull + where TData : IDisposable + where TKey : notnull + { + return inner.Then( + state: (keyGenerator, keyComparer, capacityPartition), + factory: static (input, inner) => new ScopedResourceCache( + keyGenerator: input.keyGenerator, + keyComparer: input.keyComparer, + capacityPartition: input.capacityPartition, + inner: inner + ) + ); + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.DB/IdentifierLoader.cs b/src/Abstractions/NexusMods.Abstractions.Resources.DB/IdentifierLoader.cs new file mode 100644 index 000000000..af42a9795 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources.DB/IdentifierLoader.cs @@ -0,0 +1,73 @@ +using JetBrains.Annotations; +using NexusMods.MnemonicDB.Abstractions; + +namespace NexusMods.Abstractions.Resources.DB; + +[PublicAPI] +public sealed class IdentifierLoader : IResourceLoader + where TResourceIdentifier : notnull + where TData : notnull + where TLowerLevel : notnull +{ + private readonly IConnection _connection; + private readonly Attribute _attribute; + private readonly AttributeId _attributeId; + + private readonly IResourceLoader, TData> _innerLoader; + + /// + /// Constructor. + /// + public IdentifierLoader( + IConnection connection, + Attribute attribute, + IResourceLoader, TData> innerLoader) + { + _connection = connection; + _innerLoader = innerLoader; + + _attribute = attribute; + _attributeId = _connection.AttributeCache.GetAttributeId(attribute.Id); + } + + /// + public ValueTask> LoadResourceAsync(EntityId entityId, CancellationToken cancellationToken) + { + var resourceIdentifier = GetIdentifier(entityId); + return _innerLoader.LoadResourceAsync((entityId, resourceIdentifier), cancellationToken); + } + + private TResourceIdentifier GetIdentifier(EntityId entityId) + { + var indexSegment = _connection.Db.Get(entityId); + foreach (var datom in indexSegment) + { + if (!datom.A.Equals(_attributeId)) continue; + var value = _attribute.ReadValue(datom.ValueSpan, datom.Prefix.ValueTag, _connection.AttributeResolver); + return value; + } + + throw new KeyNotFoundException($"Unable to find a value in Entity `{entityId}` with attribute `{_attribute}`"); + } +} + +public static partial class ExtensionsMethods +{ + public static IResourceLoader EntityIdToIdentifier( + this IResourceLoader, TData> inner, + IConnection connection, + Attribute attribute) + where TResourceIdentifier : notnull + where TData : notnull + where TLowerLevel : notnull + { + return inner.Then( + state: (connection, attribute), + factory: static (input, inner) => new IdentifierLoader( + connection: input.connection, + attribute: input.attribute, + innerLoader: inner + ) + ); + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Media/NexusMods.Abstractions.Media.csproj b/src/Abstractions/NexusMods.Abstractions.Resources.DB/NexusMods.Abstractions.Resources.DB.csproj similarity index 61% rename from src/Abstractions/NexusMods.Abstractions.Media/NexusMods.Abstractions.Media.csproj rename to src/Abstractions/NexusMods.Abstractions.Resources.DB/NexusMods.Abstractions.Resources.DB.csproj index 98ae0ab4d..4b857d84c 100644 --- a/src/Abstractions/NexusMods.Abstractions.Media/NexusMods.Abstractions.Media.csproj +++ b/src/Abstractions/NexusMods.Abstractions.Resources.DB/NexusMods.Abstractions.Resources.DB.csproj @@ -4,11 +4,13 @@ - - + + + + + - - + diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResource.cs b/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResource.cs new file mode 100644 index 000000000..85f043385 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResource.cs @@ -0,0 +1,22 @@ +using JetBrains.Annotations; +using NexusMods.Abstractions.MnemonicDB.Attributes; +using NexusMods.MnemonicDB.Abstractions.Models; + +namespace NexusMods.Abstractions.Resources.DB; + +[PublicAPI] +public partial class PersistedResource : IModelDefinition +{ + private const string Namespace = "NexusMods.Resources.PersistedResource"; + + public static readonly BytesAttribute Data = new(Namespace, nameof(Data)); + + public static readonly DateTimeAttribute ExpiresAt = new(Namespace, nameof(ExpiresAt)); + + public static readonly HashAttribute ResourceIdentifierHash = new(Namespace, nameof(ResourceIdentifierHash)); + + public partial struct ReadOnly + { + public bool IsExpired => DateTime.UtcNow > ExpiresAt; + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResourceLoader.cs b/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResourceLoader.cs new file mode 100644 index 000000000..8116ae9c2 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResourceLoader.cs @@ -0,0 +1,171 @@ +using DynamicData.Kernel; +using JetBrains.Annotations; +using NexusMods.Hashing.xxHash64; +using NexusMods.MnemonicDB.Abstractions; +using NexusMods.MnemonicDB.Abstractions.Attributes; + +namespace NexusMods.Abstractions.Resources.DB; + +[PublicAPI] +public sealed class PersistedResourceLoader : IResourceLoader + where TResourceIdentifier : notnull + where TData : notnull +{ + public delegate byte[] DataToBytes(TData data); + public delegate TData BytesToData(byte[] bytes); + public delegate Hash IdentifierToHash(TResourceIdentifier resourceIdentifier); + + public delegate EntityId IdentifierToEntityId(TResourceIdentifier resourceIdentifier); + + private readonly IConnection _connection; + private readonly IResourceLoader _innerLoader; + private readonly ReferenceAttribute _referenceAttribute; + private readonly DataToBytes _dataToBytes; + private readonly BytesToData _bytesToData; + private readonly IdentifierToHash _identifierToHash; + private readonly IdentifierToEntityId _identifierToEntityId; + private readonly AttributeId _referenceAttributeId; + private readonly Optional _partitionId; + + public PersistedResourceLoader( + IConnection connection, + ReferenceAttribute referenceAttribute, + IdentifierToHash identifierToHash, + DataToBytes dataToBytes, + BytesToData bytesToData, + IdentifierToEntityId identifierToEntityId, + Optional partitionId, + IResourceLoader innerLoader) + { + _connection = connection; + _innerLoader = innerLoader; + + _dataToBytes = dataToBytes; + _bytesToData = bytesToData; + + _identifierToHash = identifierToHash; + _identifierToEntityId = identifierToEntityId; + + _referenceAttribute = referenceAttribute; + _referenceAttributeId = _connection.AttributeCache.GetAttributeId(_referenceAttribute.Id); + _partitionId = partitionId; + } + + /// + public ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken) + { + var entityId = _identifierToEntityId(resourceIdentifier); + var tuple = (entityId, resourceIdentifier); + + var resource = LoadResource(tuple); + if (resource is not null) return ValueTask.FromResult(resource); + return SaveResource(tuple, cancellationToken); + } + + private Resource? LoadResource(ValueTuple resourceIdentifier) + { + var db = _connection.Db; + var (entityId, innerResourceIdentifier) = resourceIdentifier; + + var persistedResourceId = Optional.None; + var indexSegment = db.Datoms(entityId); + foreach (var datom in indexSegment) + { + if (!datom.A.Equals(_referenceAttributeId)) continue; + persistedResourceId = _referenceAttribute.ReadValue(datom.ValueSpan, datom.Prefix.ValueTag, _connection.AttributeResolver); + } + + if (!persistedResourceId.HasValue) return null; + var persistedResource = PersistedResource.Load(db, persistedResourceId.Value); + + if (!persistedResource.IsValid()) return null; + if (persistedResource.IsExpired) return null; + + var hash = _identifierToHash(innerResourceIdentifier); + if (!persistedResource.ResourceIdentifierHash.Equals(hash)) return null; + + var bytes = persistedResource.Data; + var data = _bytesToData(bytes); + + return new Resource + { + Data = data, + ExpiresAt = persistedResource.ExpiresAt, + }; + } + + private async ValueTask> SaveResource(ValueTuple resourceIdentifier, CancellationToken cancellationToken) + { + var resource = await _innerLoader.LoadResourceAsync(resourceIdentifier.Item2, cancellationToken); + var bytes = _dataToBytes(resource.Data); + + using var tx = _connection.BeginTransaction(); + var tmpId = _partitionId.HasValue ? tx.TempId(_partitionId.Value) : tx.TempId(); + + var persisted = new PersistedResource.New(tx, tmpId) + { + Data = bytes, + ExpiresAt = resource.ExpiresAt, + ResourceIdentifierHash = _identifierToHash(resourceIdentifier.Item2), + }; + + _referenceAttribute.Add(tx, resourceIdentifier.Item1, persisted); + await tx.Commit(); + + return resource; + } +} + +/// +/// Extension methods. +/// +[PublicAPI] +public static partial class ExtensionsMethods +{ + public static IResourceLoader Persist( + this IResourceLoader inner, + IConnection connection, + ReferenceAttribute referenceAttribute, + PersistedResourceLoader.IdentifierToHash identifierToHash, + PersistedResourceLoader.IdentifierToEntityId identifierToEntityId, + Optional partitionId) + where TResourceIdentifier : notnull + { + return inner.Persist( + connection: connection, + referenceAttribute: referenceAttribute, + identifierToHash: identifierToHash, + identifierToEntityId: identifierToEntityId, + dataToBytes: static bytes => bytes, + bytesToData: static bytes => bytes, + partitionId: partitionId + ); + } + + public static IResourceLoader Persist( + this IResourceLoader inner, + IConnection connection, + ReferenceAttribute referenceAttribute, + PersistedResourceLoader.IdentifierToHash identifierToHash, + PersistedResourceLoader.IdentifierToEntityId identifierToEntityId, + PersistedResourceLoader.DataToBytes dataToBytes, + PersistedResourceLoader.BytesToData bytesToData, + Optional partitionId) + where TResourceIdentifier : notnull + where TData : notnull + { + return inner.Then( + state: (connection, referenceAttribute, identifierToHash, identifierToEntityId, dataToBytes, bytesToData, partitionId), + factory: static (input, inner) => new PersistedResourceLoader( + connection: input.connection, + referenceAttribute: input.referenceAttribute, + identifierToHash: input.identifierToHash, + identifierToEntityId: input.identifierToEntityId, + dataToBytes: input.dataToBytes, + bytesToData: input.bytesToData, + partitionId: input.partitionId, + innerLoader: inner + ) + ); + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.IO/FileStoreLoader.cs b/src/Abstractions/NexusMods.Abstractions.Resources.IO/FileStoreLoader.cs new file mode 100644 index 000000000..88a078afe --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources.IO/FileStoreLoader.cs @@ -0,0 +1,34 @@ +using JetBrains.Annotations; +using NexusMods.Abstractions.IO; +using NexusMods.Hashing.xxHash64; + +namespace NexusMods.Abstractions.Resources.IO; + +[PublicAPI] +public sealed class FileStoreLoader : IResourceLoader +{ + private readonly IFileStore _fileStore; + + /// + /// Constructor. + /// + public FileStoreLoader(IFileStore fileStore) + { + _fileStore = fileStore; + } + + /// + public async ValueTask> LoadResourceAsync(Hash resourceIdentifier, CancellationToken cancellationToken) + { + await using var stream = await _fileStore.GetFileStream(resourceIdentifier, cancellationToken); + + var bytes = GC.AllocateUninitializedArray(length: (int)stream.Length); + var count = await stream.ReadAsync(bytes, cancellationToken: cancellationToken); + + ArgumentOutOfRangeException.ThrowIfNotEqual(count, bytes.Length); + return new Resource + { + Data = bytes, + }; + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.IO/HttpLoader.cs b/src/Abstractions/NexusMods.Abstractions.Resources.IO/HttpLoader.cs new file mode 100644 index 000000000..9d4473b8c --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources.IO/HttpLoader.cs @@ -0,0 +1,53 @@ +using System.Diagnostics; +using JetBrains.Annotations; + +namespace NexusMods.Abstractions.Resources.IO; + +[PublicAPI] +public sealed class HttpLoader : IResourceLoader +{ + private readonly HttpClient _httpClient; + private const string SupportedScheme = "https"; + + /// + /// Constructor. + /// + public HttpLoader(HttpClient httpClient) + { + _httpClient = httpClient; + } + + /// + public async ValueTask> LoadResourceAsync(Uri resourceIdentifier, CancellationToken cancellationToken) + { + Debug.Assert(resourceIdentifier.Scheme.Equals(SupportedScheme, StringComparison.OrdinalIgnoreCase)); + + using var responseMessage = await _httpClient.GetAsync(resourceIdentifier, HttpCompletionOption.ResponseContentRead, cancellationToken); + responseMessage.EnsureSuccessStatusCode(); + + using var content = responseMessage.Content; + var bytes = await content.ReadAsByteArrayAsync(cancellationToken: cancellationToken); + + return new Resource + { + Data = bytes, + ExpiresAt = GetExpiresAt(responseMessage), + }; + } + + private static DateTime GetExpiresAt(HttpResponseMessage responseMessage) + { + var cacheControl = responseMessage.Headers.CacheControl; + if (cacheControl is null) return DateTime.MaxValue; + + var maxAge = cacheControl.MaxAge; + if (!maxAge.HasValue) return DateTime.MaxValue; + + var age = responseMessage.Headers.Age; + + var diff = maxAge.Value; + if (age.HasValue) diff -= age.Value; + + return DateTime.UtcNow + diff; + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.IO/NexusMods.Abstractions.Resources.IO.csproj b/src/Abstractions/NexusMods.Abstractions.Resources.IO/NexusMods.Abstractions.Resources.IO.csproj new file mode 100644 index 000000000..01b5630fd --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources.IO/NexusMods.Abstractions.Resources.IO.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/Extensions.cs b/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/Extensions.cs new file mode 100644 index 000000000..ecc0ed8c3 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/Extensions.cs @@ -0,0 +1,52 @@ +using JetBrains.Annotations; +using Polly; +using Polly.Fallback; + +namespace NexusMods.Abstractions.Resources.Resilience; + +/// +/// Extension methods. +/// +[PublicAPI] +public static class Extensions +{ + /// + /// Adds resilience using Polly. + /// + public static IResourceLoader AddResilience( + this IResourceLoader inner, + ResiliencePipeline> resiliencePipeline) + where TResourceIdentifier : notnull + where TData : notnull + { + return inner.Then( + state: resiliencePipeline, + factory: static (input, inner) => new PollyWrapper( + resiliencePipeline: input, + innerLoader: inner + ) + ); + } + + /// + /// Use a fallback value. + /// + public static IResourceLoader UseFallbackValue( + this IResourceLoader inner, + TData fallbackValue) + where TResourceIdentifier : notnull + where TData : notnull + { + var resiliencePipeline = new ResiliencePipelineBuilder>() + .AddFallback(new FallbackStrategyOptions> + { + FallbackAction = _ => Outcome.FromResultAsValueTask(new Resource + { + Data = fallbackValue, + }), + }) + .Build(); + + return inner.AddResilience(resiliencePipeline); + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/NexusMods.Abstractions.Resources.Resilience.csproj b/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/NexusMods.Abstractions.Resources.Resilience.csproj new file mode 100644 index 000000000..5deb95b17 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/NexusMods.Abstractions.Resources.Resilience.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/PollyWrapper.cs b/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/PollyWrapper.cs new file mode 100644 index 000000000..4f245a93d --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/PollyWrapper.cs @@ -0,0 +1,37 @@ +using JetBrains.Annotations; +using Polly; + +namespace NexusMods.Abstractions.Resources.Resilience; + +/// +/// Uses a resilience pipeline. +/// +[PublicAPI] +public sealed class PollyWrapper : IResourceLoader + where TResourceIdentifier : notnull + where TData : notnull +{ + private readonly ResiliencePipeline> _resiliencePipeline; + private readonly IResourceLoader _innerLoader; + + /// + /// Constructor. + /// + public PollyWrapper( + ResiliencePipeline> resiliencePipeline, + IResourceLoader innerLoader) + { + _resiliencePipeline = resiliencePipeline; + _innerLoader = innerLoader; + } + + /// + public ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken) + { + return _resiliencePipeline.ExecuteAsync(callback: static (state, cancellationToken) => + { + var (innerLoader, resourceIdentifier) = state; + return innerLoader.LoadResourceAsync(resourceIdentifier, cancellationToken); + }, state: (_innerLoader, resourceIdentifier), cancellationToken: cancellationToken); + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Resources/ANestedResourceLoader.cs b/src/Abstractions/NexusMods.Abstractions.Resources/ANestedResourceLoader.cs new file mode 100644 index 000000000..3a6da3970 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources/ANestedResourceLoader.cs @@ -0,0 +1,35 @@ +using JetBrains.Annotations; + +namespace NexusMods.Abstractions.Resources; + +/// +/// Nested resource loader. +/// +[PublicAPI] +public abstract class ANestedResourceLoader : IResourceLoader + where TResourceIdentifier : notnull + where TData : notnull + where TInnerData : notnull +{ + private readonly IResourceLoader _innerLoader; + + /// + /// Constructor. + /// + protected ANestedResourceLoader(IResourceLoader innerLoader) + { + _innerLoader = innerLoader; + } + + /// + public async ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken) + { + var resource = await _innerLoader.LoadResourceAsync(resourceIdentifier, cancellationToken); + return await ProcessResourceAsync(resource, resourceIdentifier, cancellationToken); + } + + /// + /// Process the resource from the inner loader. + /// + protected abstract ValueTask> ProcessResourceAsync(Resource resource, TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken); +} diff --git a/src/Abstractions/NexusMods.Abstractions.Resources/ChangeIdentifier.cs b/src/Abstractions/NexusMods.Abstractions.Resources/ChangeIdentifier.cs new file mode 100644 index 000000000..b7a70db77 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources/ChangeIdentifier.cs @@ -0,0 +1,56 @@ +using JetBrains.Annotations; + +namespace NexusMods.Abstractions.Resources; + +/// +/// Changes the identifier. +/// +[PublicAPI] +public sealed class ChangeIdentifier : IResourceLoader + where TData : notnull + where TOldResourceIdentifier : notnull + where TNewResourceIdentifier : notnull +{ + private readonly Func _transform; + private readonly IResourceLoader _inner; + + /// + /// Constructor. + /// + public ChangeIdentifier( + Func transform, + IResourceLoader inner) + { + _transform = transform; + _inner = inner; + } + + /// + public ValueTask> LoadResourceAsync(TOldResourceIdentifier resourceIdentifier, CancellationToken cancellationToken) + { + var newIdentifier = _transform(resourceIdentifier); + return _inner.LoadResourceAsync(newIdentifier, cancellationToken); + } +} + +public static partial class Extensions +{ + /// + /// Change the identifier. + /// + public static IResourceLoader ChangeIdentifier( + this IResourceLoader inner, + Func transform) + where TData : notnull + where TOldResourceIdentifier : notnull + where TNewResourceIdentifier : notnull + { + return inner.Then( + state: transform, + factory: static (input, inner) => new ChangeIdentifier( + transform: input, + inner: inner + ) + ); + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Resources/Extensions.cs b/src/Abstractions/NexusMods.Abstractions.Resources/Extensions.cs new file mode 100644 index 000000000..27ae74f76 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources/Extensions.cs @@ -0,0 +1,61 @@ +using JetBrains.Annotations; + +namespace NexusMods.Abstractions.Resources; + +/// +/// Extension methods. +/// +[PublicAPI] +public static partial class Extensions +{ + /// + /// Chain loaders. + /// + public static IResourceLoader Then( + this IResourceLoader innerLoader, + Func, IResourceLoader> factory) + where TResourceIdentifier : notnull + where TInnerResourceIdentifier : notnull + where TData : notnull + where TInnerData : notnull + { + return factory(innerLoader); + } + + /// + /// Chain loaders. + /// + public static IResourceLoader Then( + this IResourceLoader innerLoader, + TState state, + Func, IResourceLoader> factory) + where TResourceIdentifier : notnull + where TInnerResourceIdentifier : notnull + where TData : notnull + where TInnerData : notnull + { + return factory(state, innerLoader); + } + + public static IResourceLoader ThenDo( + this IResourceLoader innerLoader, + TState state, + Func, CancellationToken, ValueTask>> func) + where TResourceIdentifier : notnull + where TData : notnull + where TInnerData : notnull + { + return ResourceLoader.Create, + TState, + Func, CancellationToken, ValueTask>> + >> + ((innerLoader, state, func), static async (outerState, resourceIdentifier, cancellationToken) => + { + var (innerLoader, innerState, func) = outerState; + + var resource = await innerLoader.LoadResourceAsync(resourceIdentifier, cancellationToken); + return await func(innerState, resourceIdentifier, resource, cancellationToken); + }); + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Resources/IResourceLoader.cs b/src/Abstractions/NexusMods.Abstractions.Resources/IResourceLoader.cs new file mode 100644 index 000000000..48b5e9027 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources/IResourceLoader.cs @@ -0,0 +1,17 @@ +using JetBrains.Annotations; + +namespace NexusMods.Abstractions.Resources; + +/// +/// Represents a resource loader. +/// +[PublicAPI] +public interface IResourceLoader + where TResourceIdentifier : notnull + where TData : notnull +{ + /// + /// Loads the resource. + /// + ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken); +} diff --git a/src/Abstractions/NexusMods.Abstractions.Resources/NexusMods.Abstractions.Resources.csproj b/src/Abstractions/NexusMods.Abstractions.Resources/NexusMods.Abstractions.Resources.csproj new file mode 100644 index 000000000..4300f8663 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources/NexusMods.Abstractions.Resources.csproj @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Abstractions/NexusMods.Abstractions.Resources/Resource.cs b/src/Abstractions/NexusMods.Abstractions.Resources/Resource.cs new file mode 100644 index 000000000..e96d17186 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources/Resource.cs @@ -0,0 +1,32 @@ +using JetBrains.Annotations; + +namespace NexusMods.Abstractions.Resources; + +/// +/// Represents a resource. +/// +[PublicAPI] +public record Resource where TData : notnull +{ + /// + /// Gets the data of the resource. + /// + public required TData Data { get; init; } + + /// + /// Gets the expiration date. + /// + public DateTime ExpiresAt { get; init; } = DateTime.MaxValue; + + /// + /// Creates a new resource. + /// + public Resource WithData(TOther data) where TOther : notnull + { + return new Resource + { + Data = data, + ExpiresAt = ExpiresAt, + }; + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Resources/ResourceLoader.cs b/src/Abstractions/NexusMods.Abstractions.Resources/ResourceLoader.cs new file mode 100644 index 000000000..cfbc4dc7e --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources/ResourceLoader.cs @@ -0,0 +1,71 @@ +using JetBrains.Annotations; + +namespace NexusMods.Abstractions.Resources; + +/// +/// Factory methods. +/// +[PublicAPI] +public static class ResourceLoader +{ + /// + /// Create a resource loader. + /// + public static IResourceLoader Create( + Func>> func) + where TResourceIdentifier : notnull + where TData : notnull + { + return new Impl(func); + } + + /// + /// Create a resource loader. + /// + public static IResourceLoader Create( + TState state, + Func>> func) + where TResourceIdentifier : notnull + where TData : notnull + where TState : notnull + { + return new Impl(func, state); + } + + private sealed class Impl : IResourceLoader + where TResourceIdentifier : notnull + where TData : notnull + { + private readonly Func>> _func; + + public Impl(Func>> func) + { + _func = func; + } + + public ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken) + { + return _func(resourceIdentifier, cancellationToken); + } + } + + private sealed class Impl : IResourceLoader + where TResourceIdentifier : notnull + where TData : notnull + where TState : notnull + { + private readonly Func>> _func; + private readonly TState _state; + + public Impl(Func>> func, TState state) + { + _func = func; + _state = state; + } + + public ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken) + { + return _func(_state, resourceIdentifier, cancellationToken); + } + } +} diff --git a/src/Games/NexusMods.Games.StardewValley/Emitters/DependencyDiagnosticEmitter.cs b/src/Games/NexusMods.Games.StardewValley/Emitters/DependencyDiagnosticEmitter.cs index bf9e6a749..3128bc8bc 100644 --- a/src/Games/NexusMods.Games.StardewValley/Emitters/DependencyDiagnosticEmitter.cs +++ b/src/Games/NexusMods.Games.StardewValley/Emitters/DependencyDiagnosticEmitter.cs @@ -1,5 +1,8 @@ using System.Collections.Immutable; +using System.Reactive; using System.Runtime.CompilerServices; +using System.Text; +using BitFaster.Caching.Lru; using DynamicData.Kernel; using Microsoft.Extensions.Logging; using NexusMods.Abstractions.Diagnostics; @@ -8,8 +11,14 @@ using NexusMods.Abstractions.IO; using NexusMods.Abstractions.Loadouts; using NexusMods.Abstractions.Loadouts.Extensions; +using NexusMods.Abstractions.Resources; +using NexusMods.Abstractions.Resources.Caching; +using NexusMods.Abstractions.Resources.DB; +using NexusMods.Abstractions.Resources.IO; using NexusMods.Games.StardewValley.Models; using NexusMods.Games.StardewValley.WebAPI; +using NexusMods.Hashing.xxHash64; +using NexusMods.MnemonicDB.Abstractions; using NexusMods.Paths; using StardewModdingAPI; using StardewModdingAPI.Toolkit; @@ -36,6 +45,31 @@ public DependencyDiagnosticEmitter( _os = os; } + private static IResourceLoader CreatePipeline(IFileStore fileStore) + { + var pipeline = new FileStoreLoader(fileStore) + .ThenDo(Unit.Default, static (_, _, resource, _) => + { + var bytes = resource.Data; + var json = Encoding.UTF8.GetString(bytes); + + var manifest = Interop.SMAPIJsonHelper.Deserialize(json); + ArgumentNullException.ThrowIfNull(manifest); + + return ValueTask.FromResult(resource.WithData(manifest)); + }) + .UseCache( + keyGenerator: static hash => hash, + keyComparer: EqualityComparer.Default, + capacityPartition: new FavorWarmPartition(totalCapacity: 100) + ) + .ChangeIdentifier( + static mod => mod.Manifest.AsLoadoutFile().Hash + ); + + return pipeline; + } + public async IAsyncEnumerable Diagnose(Loadout.ReadOnly loadout, [EnumeratorCancellation] CancellationToken cancellationToken) { var gameVersion = new SemanticVersion(loadout.InstallationInstance.Version); diff --git a/src/Games/NexusMods.Games.StardewValley/NexusMods.Games.StardewValley.csproj b/src/Games/NexusMods.Games.StardewValley/NexusMods.Games.StardewValley.csproj index e49624073..f17a9acc8 100644 --- a/src/Games/NexusMods.Games.StardewValley/NexusMods.Games.StardewValley.csproj +++ b/src/Games/NexusMods.Games.StardewValley/NexusMods.Games.StardewValley.csproj @@ -19,6 +19,10 @@ + + + + diff --git a/src/NexusMods.App.UI/IImageCache.cs b/src/NexusMods.App.UI/IImageCache.cs index ab8b29f52..68651232d 100644 --- a/src/NexusMods.App.UI/IImageCache.cs +++ b/src/NexusMods.App.UI/IImageCache.cs @@ -9,6 +9,7 @@ namespace NexusMods.App.UI; /// Represents an image cache. /// [PublicAPI] +[Obsolete("To be replaced with resource pipelines")] public interface IImageCache : IDisposable { /// diff --git a/src/NexusMods.App.UI/ImageStore.cs b/src/NexusMods.App.UI/ImageStore.cs deleted file mode 100644 index b39816410..000000000 --- a/src/NexusMods.App.UI/ImageStore.cs +++ /dev/null @@ -1,169 +0,0 @@ -using System.Diagnostics; -using Avalonia; -using Avalonia.Media.Imaging; -using Avalonia.Skia; -using BitFaster.Caching; -using JetBrains.Annotations; -using NexusMods.Abstractions.Media; -using NexusMods.MnemonicDB.Abstractions; -using OneOf; -using Size = NexusMods.Paths.Size; - -namespace NexusMods.App.UI; - -public sealed class ImageStore : IImageStore, IDisposable -{ - private readonly IConnection _connection; - private SingletonCache _cache; - - public ImageStore(IConnection connection) - { - _connection = connection; - _cache = new SingletonCache(); - } - - /// - public async ValueTask PutAsync(Bitmap bitmap) - { - using var tx = _connection.BeginTransaction(); - var storedImage = CreateStoredImage(tx, bitmap); - - var result = await tx.Commit(); - return result.Remap(storedImage); - } - - /// - [MustDisposeResource] public Lifetime? Get(OneOf input) - { - if (input.TryPickT0(out var id, out var storedImage)) - { - storedImage = StoredImage.Load(_connection.Db, id); - } - - if (!storedImage.IsValid()) return null; - var metadata = storedImage.Metadata; - var bytes = storedImage.ImageData.Data; - - Debug.Assert((ulong)bytes.Length == metadata.DataLength); - var lifetime = _cache.Acquire(id, _ => ToBitmap(metadata, bytes)); - return lifetime; - } - - /// - StoredImage.New IImageStore.CreateStoredImage(ITransaction transaction, Bitmap bitmap) => CreateStoredImage(transaction, bitmap); - - public static StoredImage.New CreateStoredImage(ITransaction transaction, Bitmap bitmap) - { - var metadata = ToMetadata(bitmap); - var bytes = GC.AllocateUninitializedArray(length: (int)metadata.DataLength); - GetBitmapBytes(metadata, bitmap, bytes); - - var imageData = CompressData(metadata, bytes); - - var storedImage = new StoredImage.New(transaction) - { - Metadata = metadata, - ImageData = imageData, - }; - - return storedImage; - } - - private static ImageData CompressData(ImageMetadata metadata, byte[] uncompressedData) - { - // TODO: optional compression for larger images - return new ImageData(ImageDataCompression.None, uncompressedData); - } - - private static void GetBitmapBytes(ImageMetadata metadata, Bitmap bitmap, byte[] bytes) - { - unsafe - { - fixed (byte* b = bytes) - { - var ptr = new IntPtr(b); - bitmap.CopyPixels( - sourceRect: new PixelRect(metadata.PixelSize), - buffer: ptr, - bufferSize: (int)metadata.DataLength, - stride: metadata.Stride - ); - } - } - } - - private static Bitmap ToBitmap(ImageMetadata metadata, byte[] bytes) - { - unsafe - { - fixed (byte* b = bytes) - { - var ptr = new IntPtr(b); - var bitmap = new Bitmap( - format: metadata.PixelFormat, - alphaFormat: metadata.AlphaFormat, - data: ptr, - size: metadata.PixelSize, - dpi: new Vector(metadata.Dpi, metadata.Dpi), - stride: metadata.Stride - ); - - return bitmap; - } - } - } - - private static ImageMetadata ToMetadata(Bitmap bitmap) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bitmap.PixelSize.Width); - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bitmap.PixelSize.Height); - - var width = (uint)bitmap.PixelSize.Width; - var height = (uint)bitmap.PixelSize.Height; - - if (!bitmap.Format.HasValue) throw new NotSupportedException("Bitmap doesn't have a PixelFormat"); - var format = bitmap.Format.Value; - - if (format.BitsPerPixel % 8 != 0) throw new NotSupportedException($"Format `{format}` isn't supported"); - - if (!bitmap.AlphaFormat.HasValue) throw new NotSupportedException("Bitmap doesn't have an AlphaFormat"); - var alphaFormat = bitmap.AlphaFormat.Value; - - var dpi = bitmap.Dpi; - var x = (int)Math.Floor(dpi.X); - var y = (int)Math.Floor(dpi.Y); - if (x != y) throw new NotSupportedException($"Uneven DPI isn't supported: `{dpi.ToString()}`"); - - // NOTE(erri120): small hack, Avalonia PixelFormat struct is sealed, and we can't really do anything with it. - // Instead, we'll just convert to SkColorType using the method provided by Avalonia. - var skColorType = format.ToSkColorType(); - skColorType.ToPixelFormat(); - - var metadata = new ImageMetadata( - imageWidth: width, - imageHeight: height, - skColorType: skColorType, - alphaFormat: alphaFormat, - dpi: (uint)x - ); - - // 16MB is enough to store a RAW image of 2000x2000 with 4 bytes per pixel - // 8.3MB is enough to store a RAW image of 1920x1080 with 4 bytes per pixel - var maxSize = Size.MB * 16; - - if (Size.From(metadata.DataLength) > maxSize) - throw new NotSupportedException($"Large images above `{maxSize}` aren't supported!"); - - return metadata; - } - - private bool _isDisposed; - public void Dispose() - { - ObjectDisposedException.ThrowIf(_isDisposed, this); - - _cache = null!; - _isDisposed = true; - } -} - diff --git a/src/NexusMods.App.UI/NexusMods.App.UI.csproj b/src/NexusMods.App.UI/NexusMods.App.UI.csproj index b036424fb..a37c5b47d 100644 --- a/src/NexusMods.App.UI/NexusMods.App.UI.csproj +++ b/src/NexusMods.App.UI/NexusMods.App.UI.csproj @@ -85,7 +85,6 @@ - diff --git a/src/NexusMods.App.UI/Services.cs b/src/NexusMods.App.UI/Services.cs index f39139163..ec1c5a405 100644 --- a/src/NexusMods.App.UI/Services.cs +++ b/src/NexusMods.App.UI/Services.cs @@ -1,7 +1,6 @@ using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using NexusMods.Abstractions.Diagnostics; -using NexusMods.Abstractions.Media; using NexusMods.Abstractions.Serialization.ExpressionGenerator; using NexusMods.Abstractions.Serialization.Json; using NexusMods.App.UI.Controls.DataGrid; @@ -56,7 +55,6 @@ using NexusMods.App.UI.Windows; using NexusMods.App.UI.WorkspaceAttachments; using NexusMods.App.UI.WorkspaceSystem; -using NexusMods.MnemonicDB.Abstractions; using NexusMods.Paths; using ReactiveUI; using DownloadGameNameView = NexusMods.App.UI.Controls.DownloadGrid.Columns.DownloadGameName.DownloadGameNameView; @@ -88,8 +86,6 @@ public static IServiceCollection AddUI(this IServiceCollection c) // Services .AddSingleton() - .AddSingleton() - .AddMedia() .AddTransient() // View Models @@ -211,7 +207,7 @@ public static IServiceCollection AddUI(this IServiceCollection c) // workspace system .AddSingleton() - .AddAttributeCollection(typeof(WindowDataAttributes)) + .AddWindowDataAttributesModel() .AddViewModel() .AddViewModel() .AddViewModel() diff --git a/src/NexusMods.DataModel/NexusMods.DataModel.csproj b/src/NexusMods.DataModel/NexusMods.DataModel.csproj index e8e6b5c71..6d6934f0c 100644 --- a/src/NexusMods.DataModel/NexusMods.DataModel.csproj +++ b/src/NexusMods.DataModel/NexusMods.DataModel.csproj @@ -8,6 +8,8 @@ + + @@ -16,7 +18,6 @@ - @@ -34,7 +35,10 @@ + + + @@ -42,8 +46,4 @@ - - - - diff --git a/src/NexusMods.DataModel/Services.cs b/src/NexusMods.DataModel/Services.cs index 0589bdf25..3a4a1ddf5 100644 --- a/src/NexusMods.DataModel/Services.cs +++ b/src/NexusMods.DataModel/Services.cs @@ -14,6 +14,7 @@ using NexusMods.Abstractions.Jobs; using NexusMods.Abstractions.Loadouts; using NexusMods.Abstractions.MnemonicDB.Analyzers; +using NexusMods.Abstractions.Resources.DB; using NexusMods.Abstractions.Serialization.ExpressionGenerator; using NexusMods.DataModel.CommandLine.Verbs; using NexusMods.DataModel.Diagnostics; @@ -117,7 +118,9 @@ public static IServiceCollection AddDataModel(this IServiceCollection coll) // GC coll.AddAllSingleton(); - + + coll.AddPersistedResourceModel(); + // Verbs coll.AddLoadoutManagementVerbs() .AddToolVerbs(); diff --git a/src/NexusMods.Media/AvaloniaImageLoader.cs b/src/NexusMods.Media/AvaloniaImageLoader.cs new file mode 100644 index 000000000..7640972ef --- /dev/null +++ b/src/NexusMods.Media/AvaloniaImageLoader.cs @@ -0,0 +1,59 @@ +using Avalonia; +using Avalonia.Media.Imaging; +using Avalonia.Skia; +using NexusMods.Abstractions.Resources; +using SkiaSharp; + +namespace NexusMods.Media; + +public sealed class AvaloniaImageLoader : ANestedResourceLoader + where TResourceIdentifier : notnull +{ + public AvaloniaImageLoader(IResourceLoader innerLoader) : base(innerLoader) { } + + protected override ValueTask> ProcessResourceAsync( + Resource resource, + TResourceIdentifier resourceIdentifier, + CancellationToken cancellationToken) + { + var skBitmap = resource.Data; + var bitmap = ToBitmap(skBitmap); + + return ValueTask.FromResult(new Resource + { + Data = bitmap, + ExpiresAt = resource.ExpiresAt, + }); + } + + private static Bitmap ToBitmap(SKBitmap skBitmap) + { + var data = skBitmap.GetPixels(); + ArgumentOutOfRangeException.ThrowIfZero(data); + + var bitmap = new Bitmap( + format: skBitmap.ColorType.ToPixelFormat(), + alphaFormat: skBitmap.AlphaType.ToAlphaFormat(), + data: data, + size: new PixelSize(skBitmap.Width, skBitmap.Height), + dpi: new Vector(96.0, 96.0), + stride: skBitmap.RowBytes + ); + + return bitmap; + } +} + +public static partial class Extensions +{ + public static IResourceLoader ToAvaloniaBitmap( + this IResourceLoader inner) + where TResourceIdentifier : notnull + { + return inner.Then( + factory: static inner => new AvaloniaImageLoader( + innerLoader: inner + ) + ); + } +} diff --git a/src/NexusMods.Media/ImageDecoder.cs b/src/NexusMods.Media/ImageDecoder.cs new file mode 100644 index 000000000..a5defbd9c --- /dev/null +++ b/src/NexusMods.Media/ImageDecoder.cs @@ -0,0 +1,86 @@ +using NexusMods.Abstractions.Resources; +using SkiaSharp; + +namespace NexusMods.Media; + +public sealed class ImageDecoder : ANestedResourceLoader + where TResourceIdentifier : notnull +{ + private readonly DecoderType _decoderType; + + public ImageDecoder( + DecoderType decoderType, + IResourceLoader innerLoader + ) : base(innerLoader) + { + _decoderType = decoderType; + } + + protected override ValueTask> ProcessResourceAsync( + Resource resource, + TResourceIdentifier resourceIdentifier, + CancellationToken cancellationToken) + { + var decoded = Decode(resource.Data, _decoderType); + return ValueTask.FromResult(new Resource + { + Data = decoded, + ExpiresAt = resource.ExpiresAt, + }); + } + + private static SKBitmap Decode(byte[] data, DecoderType format) + { + return format switch + { + DecoderType.Qoi => DecodeQoi(), + DecoderType.Skia => SKBitmap.Decode(data), + }; + + unsafe SKBitmap DecodeQoi() + { + var qoiImage = QoiSharp.QoiDecoder.Decode(data); + + var skImageInfo = new SKImageInfo( + width: qoiImage.Width, + height: qoiImage.Height, + colorType: SKColorType.Rgba8888, + alphaType: SKAlphaType.Unpremul, + colorspace: SKColorSpace.CreateSrgbLinear() + ); + + var skBitmap = new SKBitmap(skImageInfo); + + var pixels = skBitmap.GetPixels(out var length); + ArgumentOutOfRangeException.ThrowIfZero(pixels); + + var span = new Span((void*) pixels, (int) length); + qoiImage.Data.CopyTo(span); + + return skBitmap; + } + } +} + +public static partial class Extensions +{ + public static IResourceLoader Decode( + this IResourceLoader inner, + DecoderType decoderType) + where TResourceIdentifier : notnull + { + return inner.Then( + state: decoderType, + factory: static (decoderType, inner) => new ImageDecoder( + decoderType: decoderType, + innerLoader: inner + ) + ); + } +} + +public enum DecoderType +{ + Qoi = 0, + Skia = 1, +} diff --git a/src/NexusMods.Media/ImageEncoder.cs b/src/NexusMods.Media/ImageEncoder.cs new file mode 100644 index 000000000..2430b0ec4 --- /dev/null +++ b/src/NexusMods.Media/ImageEncoder.cs @@ -0,0 +1,102 @@ +using NexusMods.Abstractions.Resources; +using SkiaSharp; + +namespace NexusMods.Media; + +public sealed class ImageEncoder : ANestedResourceLoader + where TResourceIdentifier : notnull +{ + private readonly EncoderType _encoderType; + + public ImageEncoder( + EncoderType encoderType, + IResourceLoader innerLoader) : base(innerLoader) + { + _encoderType = encoderType; + } + + protected override ValueTask> ProcessResourceAsync( + Resource resource, + TResourceIdentifier resourceIdentifier, + CancellationToken cancellationToken) + { + var skBitmap = resource.Data; + + var encoded = _encoderType switch + { + EncoderType.Qoi => ToQoi(), + EncoderType.SkiaWebp => WithSkia(), + }; + + return ValueTask.FromResult(new Resource + { + Data = encoded, + ExpiresAt = resource.ExpiresAt, + }); + + byte[] WithSkia() + { + using var ms = new MemoryStream(); + + var skiaFormat = _encoderType switch + { + EncoderType.SkiaWebp => SKEncodedImageFormat.Webp, + EncoderType.Qoi => throw new NotSupportedException(), + }; + + resource.Data.Encode(ms, skiaFormat, quality: 80); + return ms.ToArray(); + } + + byte[] ToQoi() + { + var pixels = PreparePixels(skBitmap); + var qoiImage = new QoiSharp.QoiImage( + data: pixels, + width: skBitmap.Width, + height: skBitmap.Height, + channels: QoiSharp.Codec.Channels.RgbWithAlpha, + colorSpace: QoiSharp.Codec.ColorSpace.SRgb + ); + + return QoiSharp.QoiEncoder.Encode(qoiImage); + } + } + + private static byte[] PreparePixels(SKBitmap skBitmap) + { + // TODO: check if input is already in the correct layout, then we can skip this transposing step + var skImageInfo = skBitmap.Info + .WithAlphaType(SKAlphaType.Unpremul) + .WithColorSpace(SKColorSpace.CreateSrgbLinear()) + .WithColorType(SKColorType.Rgba8888); + + using var outputSkBitmap = new SKBitmap(skImageInfo); + skBitmap.CopyTo(outputSkBitmap); + + return skBitmap.GetPixelSpan().ToArray(); + } +} + +public static partial class Extensions +{ + public static IResourceLoader Encode( + this IResourceLoader inner, + EncoderType encoderType) + where TResourceIdentifier : notnull + { + return inner.Then( + state: encoderType, + factory: static (encoderType, inner) => new ImageEncoder( + encoderType: encoderType, + innerLoader: inner + ) + ); + } +} + +public enum EncoderType : byte +{ + Qoi = 0, + SkiaWebp = 1, +} diff --git a/src/NexusMods.Media/ImageResizer.cs b/src/NexusMods.Media/ImageResizer.cs new file mode 100644 index 000000000..cf1b2c07f --- /dev/null +++ b/src/NexusMods.Media/ImageResizer.cs @@ -0,0 +1,45 @@ +using NexusMods.Abstractions.Resources; +using SkiaSharp; + +namespace NexusMods.Media; + +public sealed class ImageResizer : ANestedResourceLoader + where TResourceIdentifier : notnull +{ + private readonly SKSizeI _newSize; + + public ImageResizer( + SKSizeI newSize, + IResourceLoader innerLoader) : base(innerLoader) + { + _newSize = newSize; + } + + protected override ValueTask> ProcessResourceAsync( + Resource resource, + TResourceIdentifier resourceIdentifier, + CancellationToken cancellationToken) + { + return ValueTask.FromResult(resource with + { + Data = resource.Data.Resize(_newSize, quality: SKFilterQuality.Low), + }); + } +} + +public static partial class Extensions +{ + public static IResourceLoader Resize( + this IResourceLoader inner, + SKSizeI newSize) + where TResourceIdentifier : notnull + { + return inner.Then( + state: newSize, + factory: static (newSize, inner) => new ImageResizer( + newSize: newSize, + innerLoader: inner + ) + ); + } +} diff --git a/src/NexusMods.Media/NexusMods.Media.csproj b/src/NexusMods.Media/NexusMods.Media.csproj new file mode 100644 index 000000000..b3ca3095f --- /dev/null +++ b/src/NexusMods.Media/NexusMods.Media.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/NexusMods.StandardGameLocators/SteamLocator.cs b/src/NexusMods.StandardGameLocators/SteamLocator.cs index cf8d35ac7..eb029c491 100644 --- a/src/NexusMods.StandardGameLocators/SteamLocator.cs +++ b/src/NexusMods.StandardGameLocators/SteamLocator.cs @@ -23,7 +23,10 @@ public SteamLocator(IServiceProvider provider) : base(provider) { } protected override IEnumerable Ids(ISteamGame game) => game.SteamIds.Select(AppId.From); /// - protected override AbsolutePath Path(SteamGame record) => record.Path; + protected override AbsolutePath Path(SteamGame game) + { + return game.Path; + } /// protected override IFileSystem GetMappedFileSystem(SteamGame game) diff --git a/tests/NexusMods.UI.Tests/ImageLoaderTests.cs b/tests/NexusMods.UI.Tests/ImageLoaderTests.cs new file mode 100644 index 000000000..b41ecd204 --- /dev/null +++ b/tests/NexusMods.UI.Tests/ImageLoaderTests.cs @@ -0,0 +1,85 @@ +using Avalonia.Media.Imaging; +using BitFaster.Caching; +using BitFaster.Caching.Lru; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using NexusMods.Abstractions.NexusModsLibrary; +using NexusMods.Abstractions.NexusWebApi.Types; +using NexusMods.Abstractions.Resources; +using NexusMods.Abstractions.Resources.Caching; +using NexusMods.Abstractions.Resources.DB; +using NexusMods.Abstractions.Resources.IO; +using NexusMods.Games.RedEngine.Cyberpunk2077; +using NexusMods.Hashing.xxHash64; +using NexusMods.Media; +using NexusMods.MnemonicDB.Abstractions; +using NexusMods.Networking.NexusWebApi; +using SkiaSharp; + +namespace NexusMods.UI.Tests; + +public class ImageLoaderTests : AUiTest +{ + public ImageLoaderTests(IServiceProvider provider) : base(provider) { } + + [Fact] + [Trait("RequiresNetworking", "True")] + public async Task Test() + { + var library = Provider.GetRequiredService(); + var modPage = await library.GetOrAddModPage(ModId.From(6072), Cyberpunk2077Game.StaticDomain); + + { + var pipeline = CreatePipeline(); + + using var lifetime1 = (await pipeline.LoadResourceAsync(modPage, CancellationToken.None)).Data; + using var lifetime2 = (await pipeline.LoadResourceAsync(modPage, CancellationToken.None)).Data; + lifetime1.Value.Should().BeSameAs(lifetime2.Value); + lifetime1.ReferenceCount.Should().Be(1); + lifetime2.ReferenceCount.Should().Be(2); + + lifetime1.Value.Size.Should().Be(new Avalonia.Size(120, 80)); + } + + { + var pipeline = CreatePipeline(); + + using var lifetime = (await pipeline.LoadResourceAsync(modPage, CancellationToken.None)).Data; + lifetime.Value.Size.Should().Be(new Avalonia.Size(120, 80)); + } + } + + private IResourceLoader> CreatePipeline() + { + const byte partitionId = 123; + + var pipeline = new HttpLoader(new HttpClient()) + .ChangeIdentifier, Uri, byte[]>(static tuple => tuple.Item2) + .Decode(decoderType: DecoderType.Skia) + .Resize(newSize: new SKSizeI( + width: 120, + height: 80 + )) + .Encode(encoderType: EncoderType.Qoi) + .Persist( + connection: Connection, + referenceAttribute: NexusModsModPageMetadata.ThumbnailResource, + identifierToHash: static tuple => tuple.Item2.ToString().XxHash64AsUtf8(), + identifierToEntityId: static tuple => tuple.Item1, + partitionId: PartitionId.User(partitionId) + ) + .Decode(decoderType: DecoderType.Qoi) + .ToAvaloniaBitmap() + .UseScopedCache( + keyGenerator: static tuple => tuple.Item1, + keyComparer: EqualityComparer.Default, + capacityPartition: new FavorWarmPartition(totalCapacity: 10, warmRatio: FavorWarmPartition.DefaultWarmRatio) + ) + .EntityIdToIdentifier( + connection: Connection, + attribute: NexusModsModPageMetadata.FullSizedPictureUri + ); + + return pipeline; + } +} diff --git a/tests/NexusMods.UI.Tests/ImageStoreTests.cs b/tests/NexusMods.UI.Tests/ImageStoreTests.cs deleted file mode 100644 index f418f04a6..000000000 --- a/tests/NexusMods.UI.Tests/ImageStoreTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Avalonia.Media.Imaging; -using Avalonia.Platform; -using Avalonia.Skia; -using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; -using NexusMods.Abstractions.Media; - -namespace NexusMods.UI.Tests; - -public class ImageStoreTests : AUiTest -{ - private readonly IImageStore _imageStore; - - public ImageStoreTests(IServiceProvider serviceProvider) : base(serviceProvider) - { - _imageStore = serviceProvider.GetRequiredService(); - } - - [Fact] - public async Task SimpleTest() - { - var bitmap = new Bitmap(AssetLoader.Open(new Uri("avares://NexusMods.App.UI/Assets/DesignTime/cyberpunk_game.png"))); - var storedImage = await _imageStore.PutAsync(bitmap); - using var lifetime = _imageStore.Get(storedImage); - lifetime.Should().NotBeNull(); - - var result = lifetime!.Value; - result.PixelSize.Equals(bitmap.PixelSize).Should().BeTrue(); - result.Dpi.NearlyEquals(bitmap.Dpi).Should().BeTrue(); - result.AlphaFormat.Should().Be(bitmap.AlphaFormat); - result.Format.Should().NotBeNull(); - result.Format!.Value.ToSkColorType().Should().Be(bitmap.Format!.Value.ToSkColorType()); - } -} diff --git a/tests/NexusMods.UI.Tests/NexusMods.UI.Tests.csproj b/tests/NexusMods.UI.Tests/NexusMods.UI.Tests.csproj index f67ba82d6..1373d5d28 100644 --- a/tests/NexusMods.UI.Tests/NexusMods.UI.Tests.csproj +++ b/tests/NexusMods.UI.Tests/NexusMods.UI.Tests.csproj @@ -1,10 +1,14 @@ + + + + diff --git a/tests/NexusMods.UI.Tests/Startup.cs b/tests/NexusMods.UI.Tests/Startup.cs index 097792716..33de079a5 100644 --- a/tests/NexusMods.UI.Tests/Startup.cs +++ b/tests/NexusMods.UI.Tests/Startup.cs @@ -3,9 +3,7 @@ using NexusMods.Abstractions.Settings; using NexusMods.App; using NexusMods.App.BuildInfo; -using NexusMods.App.UI.Settings; using NexusMods.DataModel; -using NexusMods.Games.RedEngine; using NexusMods.Games.RedEngine.Cyberpunk2077; using NexusMods.Paths; using NexusMods.StandardGameLocators.TestHelpers;