Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions WheelWizard.Test/WheelWizard.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0-preview.3.25171.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0"/>
<PackageReference Include="NSubstitute" Version="5.3.0"/>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="Testably.Abstractions.Testing" Version="4.0.1" />
<PackageReference Include="xunit" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
Expand Down
22 changes: 0 additions & 22 deletions WheelWizard/Features/MiiImages/Domain/IMiiIMagesApi.cs

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public class MiiImageSpecifications
// All between 0 and 360, obviously
public Vector3 CharacterRotate { get; set; } = Vector3.Zero;
public Vector3 CameraRotate { get; set; } = Vector3.Zero;
public float CameraVerticalOffset { get; set; }
public float CameraZoom { get; set; } = 1f;
public float RenderScale { get; set; } = 1f;

public TimeSpan? ExpirationSeconds { get; set; } = TimeSpan.FromMinutes(30);
public CacheItemPriority CachePriority { get; set; } = CacheItemPriority.Normal;
Expand All @@ -25,7 +28,7 @@ public override string ToString()
// If we put all the things in this string, then the Key at least is unique
var parts = $"{Name}_{Size}{Expression}{Type}";
parts += $"{BackgroundColor}{InstanceCount}";
parts += $"{CharacterRotate}{CameraRotate}";
parts += $"{CharacterRotate}{CameraRotate}{CameraVerticalOffset}{CameraZoom}{RenderScale}";
parts += $"{CachePriority}";
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(parts));
}
Expand Down Expand Up @@ -58,6 +61,7 @@ public enum FaceExpression

public enum ImageSize
{
tiny = 128,
small = 270,
medium = 512,
}
Expand Down
18 changes: 14 additions & 4 deletions WheelWizard/Features/MiiImages/Domain/MiiImageVariants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ public static class MiiImageVariants
Size = MiiImageSpecifications.ImageSize.medium,
};

public static readonly MiiImageSpecifications MiiListTile = new()
{
Name = "MiiListTile",
Expression = MiiImageSpecifications.FaceExpression.normal,
Type = MiiImageSpecifications.BodyType.face,
Size = MiiImageSpecifications.ImageSize.tiny,
CachePriority = CacheItemPriority.Low,
ExpirationSeconds = TimeSpan.FromMinutes(8),
};

public static readonly MiiImageSpecifications OnlinePlayerSmall = new()
{
Name = "OnlinePlayerSmall",
Expand All @@ -36,7 +46,7 @@ public static class MiiImageVariants
Expression = MiiImageSpecifications.FaceExpression.normal,
Type = MiiImageSpecifications.BodyType.face,
Size = MiiImageSpecifications.ImageSize.medium,
ExpirationSeconds = TimeSpan.FromSeconds(30),
ExpirationSeconds = TimeSpan.Zero,
CachePriority = CacheItemPriority.Low,
};
public static readonly MiiImageSpecifications MiiEditorPreviewCarousel = new()
Expand All @@ -46,8 +56,8 @@ public static class MiiImageVariants
Type = MiiImageSpecifications.BodyType.all_body,
Size = MiiImageSpecifications.ImageSize.medium,
CachePriority = CacheItemPriority.Low,
ExpirationSeconds = TimeSpan.FromSeconds(30),
InstanceCount = 8,
ExpirationSeconds = TimeSpan.Zero,
InstanceCount = 1,
};

public static readonly MiiImageSpecifications CurrentUserSideProfile = new()
Expand Down Expand Up @@ -88,6 +98,6 @@ public static class MiiImageVariants
Type = MiiImageSpecifications.BodyType.all_body,
Size = MiiImageSpecifications.ImageSize.medium,
ExpirationSeconds = TimeSpan.FromMinutes(10),
InstanceCount = 8,
InstanceCount = 1,
};
}
8 changes: 5 additions & 3 deletions WheelWizard/Features/MiiImages/MiiImagesExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
using WheelWizard.MiiImages.Domain;
using WheelWizard.Services;
using WheelWizard.MiiRendering;

namespace WheelWizard.MiiImages;

public static class MiiImagesExtensions
{
public static IServiceCollection AddMiiImages(this IServiceCollection services)
{
services.AddWhWzRefitApi<IMiiIMagesApi>(Endpoints.MiiImageAddress);

services.AddMiiRendering();
services.AddSingleton<IMiiImagesSingletonService, MiiImagesSingletonService>();

return services;
Expand All @@ -26,6 +25,9 @@ public static MiiImageSpecifications Clone(this MiiImageSpecifications specifica
InstanceCount = specifications.InstanceCount,
CharacterRotate = new(specifications.CharacterRotate.X, specifications.CharacterRotate.Y, specifications.CharacterRotate.Z),
CameraRotate = new(specifications.CameraRotate.X, specifications.CameraRotate.Y, specifications.CameraRotate.Z),
CameraVerticalOffset = specifications.CameraVerticalOffset,
CameraZoom = specifications.CameraZoom,
RenderScale = specifications.RenderScale,
ExpirationSeconds =
specifications.ExpirationSeconds?.TotalSeconds == null
? null
Expand Down
179 changes: 112 additions & 67 deletions WheelWizard/Features/MiiImages/MiiImagesSingletonService.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
using System.Collections.Concurrent;
using Avalonia.Media.Imaging;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using WheelWizard.MiiImages.Domain;
using WheelWizard.Shared.Services;
using WheelWizard.MiiRendering.Services;
using WheelWizard.WiiManagement.MiiManagement.Domain.Mii;

namespace WheelWizard.MiiImages;
Expand All @@ -12,95 +13,139 @@ public interface IMiiImagesSingletonService
Task<OperationResult<Bitmap>> GetImageAsync(Mii? mii, MiiImageSpecifications specifications);
}

public class MiiImagesSingletonService(IApiCaller<IMiiIMagesApi> apiCaller, IMemoryCache cache) : IMiiImagesSingletonService
public class MiiImagesSingletonService : IMiiImagesSingletonService, IDisposable
{
// Track in-flight requests to prevent duplicate API calls
private readonly ConcurrentDictionary<string, SemaphoreSlim> _inFlightRequests = new();
private const long ImageCacheSizeLimitBytes = 64L * 1024L * 1024L;
private readonly IMiiNativeRenderer _nativeRenderer;
private readonly ILogger<MiiImagesSingletonService> _logger;
private readonly MemoryCache _imageCache = new(
new MemoryCacheOptions { SizeLimit = ImageCacheSizeLimitBytes, CompactionPercentage = 0.2 }
);

// Track in-flight requests to prevent duplicate renders.
private readonly ConcurrentDictionary<string, Task<OperationResult<Bitmap>>> _inFlightRequests = new();

public MiiImagesSingletonService(IMiiNativeRenderer nativeRenderer, ILogger<MiiImagesSingletonService> logger)
{
_nativeRenderer = nativeRenderer;
_logger = logger;
}

public async Task<OperationResult<Bitmap>> GetImageAsync(Mii? mii, MiiImageSpecifications specifications)
{
if (mii == null)
return Fail("Mii cannot be null.");

var data = MiiStudioDataSerializer.Serialize(mii);
if (data.IsFailure)
return data.Error;
{
_logger.LogWarning(
"Mii studio serialization failed for image '{ImageName}' ({BodyType}/{Expression}): {Error}",
specifications.Name,
specifications.Type,
specifications.Expression,
data.Error?.Message
);
return data.Error ?? Fail("Mii studio serialization failed.");
}

var miiConfigKey = data.Value + specifications;
if (!ShouldCache(specifications))
return await RenderWithoutCacheAsync(mii, data.Value, specifications);

// Even tho we also check it in the semaphore section, we also check here if it's in the cache, just to be tad faster.
if (cache.TryGetValue(miiConfigKey, out Bitmap? cachedValue))
// Fast path: return from cache before looking up or creating in-flight render work.
if (_imageCache.TryGetValue(miiConfigKey, out Bitmap? cachedValue))
{
if (cachedValue != null)
return cachedValue;
return Fail("Cached image is null.");
}

var requestSemaphore = _inFlightRequests.GetOrAdd(miiConfigKey, _ => new(1, 1));

var renderTask = _inFlightRequests.GetOrAdd(miiConfigKey, _ => RenderAndCacheAsync(mii, data.Value, specifications, miiConfigKey));
try
{
// Wait to acquire the semaphore - only the first request will proceed immediately
await requestSemaphore.WaitAsync();

// Double-check the cache after acquiring the semaphore
// Another thread might have completed the request while we were waiting
if (cache.TryGetValue(miiConfigKey, out Bitmap? doubleCheckCached))
{
if (doubleCheckCached != null)
return doubleCheckCached;
return Fail("Cached image is null.");
}

// If we get here, we're the first request and need to call the API
var newImageResult = await apiCaller.CallApiAsync(api => GetBitmapAsync(api, data.Value, specifications));

Bitmap? newImage = null;
if (newImageResult.IsSuccess)
newImage = newImageResult.Value;

using (var entry = cache.CreateEntry(miiConfigKey))
{
entry.Value = newImage;
entry.SlidingExpiration = specifications.ExpirationSeconds;
entry.Priority = specifications.CachePriority;
}

if (newImage != null)
return newImage;
return Fail("Failed to get new image.");
return await renderTask;
}
finally
{
// We can also do it all without try catch. But we need to make sure that whatever happens, we release the semaphore
// So just to be safe, if anything happens, we release the semaphore anyway.
requestSemaphore.Release();
_inFlightRequests.TryRemove(miiConfigKey, out _);
_inFlightRequests.TryRemove(new KeyValuePair<string, Task<OperationResult<Bitmap>>>(miiConfigKey, renderTask));
}
}

private static bool ShouldCache(MiiImageSpecifications specifications) =>
specifications.ExpirationSeconds.HasValue && specifications.ExpirationSeconds.Value > TimeSpan.Zero;

private async Task<OperationResult<Bitmap>> RenderWithoutCacheAsync(Mii mii, string studioData, MiiImageSpecifications specifications)
{
var newImageResult = await _nativeRenderer.RenderAsync(mii, studioData, specifications);
if (newImageResult.IsFailure)
{
_logger.LogWarning(
"Native Mii render failed for uncached image '{ImageName}' ({BodyType}/{Expression}, size={Size}): {Error}",
specifications.Name,
specifications.Type,
specifications.Expression,
specifications.Size,
newImageResult.Error?.Message
);
return newImageResult.Error!;
}

return newImageResult.Value;
}

private async Task<OperationResult<Bitmap>> RenderAndCacheAsync(
Mii mii,
string studioData,
MiiImageSpecifications specifications,
string cacheKey
)
{
if (_imageCache.TryGetValue(cacheKey, out Bitmap? cached))
{
if (cached != null)
return cached;
return Fail("Cached image is null.");
}

var newImageResult = await _nativeRenderer.RenderAsync(mii, studioData, specifications);
if (newImageResult.IsFailure)
{
_logger.LogWarning(
"Native Mii render failed for image '{ImageName}' ({BodyType}/{Expression}, size={Size}): {Error}",
specifications.Name,
specifications.Type,
specifications.Expression,
specifications.Size,
newImageResult.Error?.Message
);
return newImageResult.Error!;
}

var newImage = newImageResult.Value;
using (var entry = _imageCache.CreateEntry(cacheKey))
{
entry.Value = newImage;
entry.SlidingExpiration = specifications.ExpirationSeconds;
entry.Priority = specifications.CachePriority;
entry.Size = EstimateBitmapSizeBytes(newImage);
}

return newImage;
}

private static long EstimateBitmapSizeBytes(Bitmap image)
{
var width = image.PixelSize.Width;
var height = image.PixelSize.Height;
if (width <= 0 || height <= 0)
return 1;

return Math.Max(1L, checked((long)width * height * 4L));
}

private static async Task<Bitmap> GetBitmapAsync(IMiiIMagesApi api, string data, MiiImageSpecifications specifications)
public void Dispose()
{
var result = await api.GetImageAsync(
data,
specifications.Type.ToString(),
specifications.Expression.ToString(),
(int)specifications.Size,
characterXRotate: (int)specifications.CharacterRotate.X,
characterYRotate: (int)specifications.CharacterRotate.Y,
characterZRotate: (int)specifications.CharacterRotate.Z,
bgColor: specifications.BackgroundColor,
instanceCount: specifications.InstanceCount,
cameraXRotate: (int)specifications.CameraRotate.X,
cameraYRotate: (int)specifications.CameraRotate.Y,
cameraZRotate: (int)specifications.CameraRotate.Z
);

using var memoryStream = new MemoryStream();
await result.CopyToAsync(memoryStream);
memoryStream.Position = 0; // Reset stream position for Bitmap constructor

if (memoryStream.Length == 0)
throw new InvalidOperationException("Received empty image stream.");

var bitmap = new Bitmap(memoryStream);
return bitmap;
_imageCache.Dispose();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using WheelWizard.Services;

namespace WheelWizard.MiiRendering.Configuration;

public sealed class MiiRenderingConfiguration
{
public const string ResourceFileName = "FFLResHigh.dat";

/// <summary>
/// The managed Wheel Wizard location for the resource file.
/// </summary>
public string ManagedResourcePath { get; init; } = PathManager.MiiRenderingResourceFilePath;

/// <summary>
/// Lower bound for sanity-checking resource integrity.
/// </summary>
public long MinimumExpectedSizeBytes { get; init; } = 1024 * 1024;

public static MiiRenderingConfiguration CreateDefault()
{
return new MiiRenderingConfiguration { ManagedResourcePath = PathManager.MiiRenderingResourceFilePath };
}
}
24 changes: 24 additions & 0 deletions WheelWizard/Features/MiiRendering/Configuration/MiiViewMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace WheelWizard.MiiRendering.Configuration;

public readonly record struct MiiLightingProfile(
float AmbientScale,
float DirectionalLightInfluence,
float DiffuseScale,
float DiffuseFloor,
float SpecularScale,
float RimScale,
float RimPower
);

public static class MiiLightingProfiles
{
public static readonly MiiLightingProfile Default = new(
AmbientScale: 0.71f,
DirectionalLightInfluence: 0.32f,
DiffuseScale: 1.32f,
DiffuseFloor: 0.23f,
SpecularScale: 0.78f,
RimScale: 0.88f,
RimPower: 2.56f
);
}
Loading
Loading