diff --git a/Controllers/LockerImageController.cs b/Controllers/LockerImageController.cs index 4c25e99..bae08a8 100644 --- a/Controllers/LockerImageController.cs +++ b/Controllers/LockerImageController.cs @@ -35,22 +35,28 @@ public class AccountImageController( }; [HttpPost] - public async Task Post(Locker locker, [FromQuery] bool? lossless) + public async Task Post(Locker locker, [FromQuery] bool? lossless, CancellationToken cancellationToken) { logger.LogInformation( "Locker image request received | Name = {PlayerName} | Locale = {Locale} | Items = {Items}", locker.PlayerName, locker.Locale, locker.Items.Length); - var lockKey = $"locker_{locker.RequestId}"; - using (await namedLock.LockAsync(lockKey).ConfigureAwait(false)) + + using (await namedLock.LockAsync($"locker_{locker.RequestId}", cancellationToken).ConfigureAwait(false)) { - await GenerateItemCards(locker); + await GenerateItemCards(locker, cancellationToken); } - using var lockerBitmap = await GenerateImage(locker); - - // Determine the quality of the image based on quality mapping and locker.Items.Length - var quality = lossless == true ? 100 : QualityMapping.FirstOrDefault(x => locker.Items.Length <= x.Count).Quality; - return File(lockerBitmap.Encode(SKEncodedImageFormat.Jpeg, quality).AsStream(true), "image/jpeg"); + using (locker) + { + using var lockerBitmap = await GenerateImage(locker); + + // Determine the quality of the image based on quality mapping and locker.Items.Length + var quality = lossless == true + ? 100 + : QualityMapping.FirstOrDefault(x => locker.Items.Length <= x.Count).Quality; + var data = lockerBitmap.Encode(SKEncodedImageFormat.Jpeg, quality); + return File(data.AsStream(true), "image/jpeg"); + } } private async Task GenerateImage(Locker locker) @@ -70,7 +76,7 @@ private async Task GenerateImage(Locker locker) var bitmap = new SKBitmap(imageInfo); using var canvas = new SKCanvas(bitmap); - using var backgroundPaint = new SKPaint(); + using var backgroundPaint = new SKPaintSafe(); backgroundPaint.IsAntialias = true; backgroundPaint.Shader = SKShader.CreateLinearGradient( new SKPoint((float)imageInfo.Width / 2, 0), @@ -84,10 +90,12 @@ private async Task GenerateImage(Locker locker) var textBounds = new SKRect(); var segoeFont = await assets.GetFont("Assets/Fonts/Segoe.ttf"); // don't dispose - var icon = await assets.GetBitmap("Assets/Images/Locker/Icon.png"); // don't dispose + var iconBitmap = await assets.GetBitmap("Assets/Images/Locker/Icon.png"); // don't dispose var resize = (int)(50 * uiResizingFactor); - using var resizeIcon = icon!.Resize(new SKImageInfo(resize, resize), SKFilterQuality.High); - canvas.DrawBitmap(resizeIcon, 50, 50); + using var drawIconPaint = new SKPaint(); + drawIconPaint.IsAntialias = true; + drawIconPaint.FilterQuality = SKFilterQuality.High; + canvas.DrawBitmap(iconBitmap, SKRect.Create(50, 50, resize, resize), drawIconPaint); using var splitPaint = new SKPaint(); splitPaint.IsAntialias = true; @@ -95,7 +103,7 @@ private async Task GenerateImage(Locker locker) var splitWidth = 5 * uiResizingFactor; var splitR = 3 * uiResizingFactor; - canvas.DrawRoundRect(50 + resizeIcon.Width + splitWidth, 57, splitWidth, 50 * uiResizingFactor, splitR, splitR, + canvas.DrawRoundRect(50 + resize + splitWidth, 57, splitWidth, 50 * uiResizingFactor, splitR, splitR, splitPaint); using var namePaint = new SKPaint(); @@ -106,7 +114,7 @@ private async Task GenerateImage(Locker locker) namePaint.FilterQuality = SKFilterQuality.Medium; namePaint.MeasureText(locker.PlayerName, ref textBounds); - canvas.DrawText(locker.PlayerName, 50 + resizeIcon.Width + splitWidth * 3, 58 - textBounds.Top, namePaint); + canvas.DrawText(locker.PlayerName, 50 + resize + splitWidth * 3, 58 - textBounds.Top, namePaint); using var discordBoxBitmap = await ImageUtils.GenerateDiscordBox(assets, locker.UserName, uiResizingFactor); canvas.DrawBitmap(discordBoxBitmap, imageInfo.Width - 50 - discordBoxBitmap.Width, 39); @@ -119,7 +127,6 @@ private async Task GenerateImage(Locker locker) item.Image, 50 + 256 * column + 25 * column, 50 + nameFontSize + 50 + row * 313 + row * 25); - item.Image?.Dispose(); column++; if (column != columns) continue; column = 0; @@ -134,20 +141,26 @@ private async Task GenerateImage(Locker locker) return bitmap; } - private async Task GenerateItemCards(Locker locker) + private async Task GenerateItemCards(Locker locker, CancellationToken cancellationToken) { var options = new ParallelOptions { - MaxDegreeOfParallelism = Environment.ProcessorCount / 2 + MaxDegreeOfParallelism = Environment.ProcessorCount / 2, + CancellationToken = cancellationToken }; + using var client = clientFactory.CreateClient(); await Parallel.ForEachAsync(locker.Items, options, async (item, token) => { var filePath = Path.Combine(BASE_ITEM_IMAGE_PATH, $"{item.Id}.png"); - SKBitmap? itemImage = null; - if (!System.IO.File.Exists(filePath) && item.ImageUrl is not null) + var fileExists = System.IO.File.Exists(filePath); + + byte[]? itemImageBytes = null; + if (fileExists) + { + itemImageBytes = await System.IO.File.ReadAllBytesAsync(filePath, token); + } + else if (item.ImageUrl is not null) { - using var client = clientFactory.CreateClient(); - byte[]? itemImageBytes; try { var imageUrl = changeUrlImageSize(item.ImageUrl, 256); @@ -162,45 +175,51 @@ await Parallel.ForEachAsync(locker.Items, options, async (item, token) => catch (HttpRequestException e2) { logger.LogWarning( - "Failed to download image with status {StatusCode} for {Name} ({ImageUrl}) ", + "Failed to download image with status {HttpStatusCode} for {ItemName} ({ItemImageUrl}) ", e2.StatusCode, item.Name, item.ImageUrl); - itemImageBytes = null; } } catch (HttpRequestException e) { logger.LogWarning( - "Failed to download image with status {StatusCode} for {Name} ({ImageUrl}) ", + "Failed to download image with status {HttpStatusCode} for {ItemName} ({ItemImageUrl}) ", e.StatusCode, item.Name, item.ImageUrl); - itemImageBytes = null; } + } + + SKBitmap? itemImage = null; - if (itemImageBytes is not null) + if (itemImageBytes is not null) + { + var itemImageRaw = SKBitmap.Decode(itemImageBytes); + if (itemImageRaw.Width != 256 || itemImageRaw.Height != 256) { - var itemImageRaw = SKBitmap.Decode(itemImageBytes); - if (itemImageRaw.Width != 256 || itemImageRaw.Height != 256) - { - itemImage = itemImageRaw.Resize(new SKImageInfo(256, 256), SKFilterQuality.Medium); - } - else - { - itemImage = itemImageRaw; - } + fileExists = false; + var resized = itemImageRaw.Resize(new SKImageInfo(256, 256), SKFilterQuality.Medium); + itemImageRaw.Dispose(); + itemImage = resized; + } + else + { + itemImage = itemImageRaw; + } + if (!fileExists) + { Directory.CreateDirectory(BASE_ITEM_IMAGE_PATH); + using var data = itemImage.Encode(SKEncodedImageFormat.Png, 100); + var dataBytes = data.AsSpan().ToArray(); await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true); - using var data = itemImage.Encode(SKEncodedImageFormat.Png, 100); - data.SaveTo(fileStream); + fileStream.SetLength(dataBytes.LongLength); + await fileStream.WriteAsync(dataBytes, token); } } - else if (System.IO.File.Exists(filePath)) + + using (itemImage) { - itemImage = SKBitmap.Decode(filePath); + item.Image = await GenerateItemCard(item, itemImage); } - - item.Image = await GenerateItemCard(item, itemImage); - itemImage?.Dispose(); }); } @@ -309,9 +328,11 @@ private async Task GenerateFooter(float resizeFactor) using var canvas = new SKCanvas(bitmap); var logoBitmap = await assets.GetBitmap("Assets/Images/Logo.png"); // don't dispose - var logoBitmapResize = - logoBitmap!.Resize(new SKImageInfo(imageInfo.Height, imageInfo.Height), SKFilterQuality.High); - canvas.DrawBitmap(logoBitmapResize, new SKPoint(0, 0)); + + using var drawLogoPaint = new SKPaint(); + drawLogoPaint.IsAntialias = true; + drawLogoPaint.FilterQuality = SKFilterQuality.High; + canvas.DrawBitmap(logoBitmap, SKRect.Create(0, 0, imageInfo.Height, imageInfo.Height), drawLogoPaint); var splitR = 3 * resizeFactor; diff --git a/Controllers/ShopImageController.cs b/Controllers/ShopImageController.cs index 5ba41ba..9b75d05 100644 --- a/Controllers/ShopImageController.cs +++ b/Controllers/ShopImageController.cs @@ -52,26 +52,24 @@ public partial class ShopImageController( }; [HttpPost] - public async Task Shop([FromBody] Shop shop, [FromQuery] string? locale, [FromQuery] bool? isNewShop) + public async Task Shop([FromBody] Shop shop, [FromQuery] bool? forceNew, CancellationToken cancellationToken) { - locale ??= "en"; - var _isNewShop = isNewShop ?? false; - logger.LogInformation("Item Shop image request received | Locale = {Locale} | New Shop = {IsNewShop}", locale, _isNewShop); - // Hash the section ids - var templateHash = string.Join('-', shop.Sections.Select(x => x.Id)).GetHashCode().ToString(); + var _forceNew = forceNew ?? false; + logger.LogInformation("Item Shop image request received"); + var templateHash = shop.GetTemplateHash(); + var localeTemplateHash = shop.GetLocaleTemplateHash(); SKBitmap? templateBitmap; ShopSectionLocationData[]? locationData; - - using (await namedLock.LockAsync("shop_template").ConfigureAwait(false)) + using (await namedLock.LockAsync($"shop_template_{templateHash}", cancellationToken).ConfigureAwait(false)) { logger.LogDebug("Acquired shop template lock"); templateBitmap = cache.Get($"shop_template_bmp_{templateHash}"); locationData = cache.Get($"shop_location_data_{templateHash}"); - if (_isNewShop || templateBitmap is null) + if (_forceNew || templateBitmap is null) { logger.LogDebug("Generating new shop template"); - await PrefetchImages(shop); + await PrefetchImages(shop, cancellationToken); var templateGenerationResult = await GenerateTemplate(shop); templateBitmap = templateGenerationResult.Item2; locationData = templateGenerationResult.Item1; @@ -82,30 +80,29 @@ public async Task Shop([FromBody] Shop shop, [FromQuery] string? } SKBitmap? localeTemplateBitmap; - - var lockName = $"shop_template_{locale}"; - using (await namedLock.LockAsync(lockName).ConfigureAwait(false)) + using (await namedLock.LockAsync($"shop_template_{localeTemplateHash}", cancellationToken).ConfigureAwait(false)) { - logger.LogDebug("Acquired locale shop template lock for locale {Locale}", locale); - localeTemplateBitmap = cache.Get($"shop_template_{locale}_bmp"); - if (_isNewShop || localeTemplateBitmap == null) + logger.LogDebug("Acquired locale shop template lock"); + localeTemplateBitmap = cache.Get($"shop_template_{localeTemplateHash}_bmp"); + if (_forceNew || localeTemplateBitmap == null) { - logger.LogDebug("Generating new locale shop template for locale {Locale}", locale); + logger.LogDebug("Generating new locale shop template"); localeTemplateBitmap = await GenerateLocaleTemplate(shop, templateBitmap, locationData!); - cache.Set($"shop_template_{locale}_bmp", localeTemplateBitmap, ShopImageCacheOptions); + cache.Set($"shop_template_{localeTemplateHash}_bmp", localeTemplateBitmap, ShopImageCacheOptions); } - logger.LogDebug("Releasing locale shop template lock for locale {Locale}", locale); + logger.LogDebug("Releasing locale shop template lock"); } - using var localeTemplateBitmapCopy = localeTemplateBitmap.Copy(); - using var shopImage = await GenerateShopImage(shop, localeTemplateBitmapCopy); + logger.LogDebug("Generating final shop image"); + using var shopImage = await GenerateShopImage(shop, localeTemplateBitmap); var data = shopImage.Encode(SKEncodedImageFormat.Png, 100); return File(data.AsStream(true), "image/png"); } [HttpPost("section")] - public async Task ShopSection([FromBody] ShopSection section, [FromQuery] string? locale, - [FromQuery] bool? isNewShop) + public async Task ShopSection( + [FromBody] ShopSection section, [FromQuery] string? locale, [FromQuery] bool? isNewShop, + CancellationToken cancellationToken) { locale ??= "en"; var _isNewShop = isNewShop ?? false; @@ -114,13 +111,13 @@ public async Task ShopSection([FromBody] ShopSection section, [Fr SKBitmap? templateBitmap; ShopSectionLocationData? shopSectionLocationData; - using (await namedLock.LockAsync($"shop_section_template_{section.Id}").ConfigureAwait(false)) + using (await namedLock.LockAsync($"shop_section_template_{section.Id}", cancellationToken).ConfigureAwait(false)) { templateBitmap = cache.Get($"shop_section_template_bmp_{section.Id}"); shopSectionLocationData = cache.Get($"shop_section_location_data_{section.Id}"); if (_isNewShop || templateBitmap is null) { - await PrefetchImages([section]); + await PrefetchImages([section], cancellationToken); var templateGenerationResult = await GenerateSectionTemplate(section); templateBitmap = templateGenerationResult.Item2; shopSectionLocationData = templateGenerationResult.Item1; @@ -133,7 +130,7 @@ public async Task ShopSection([FromBody] ShopSection section, [Fr SKBitmap? localeTemplateBitmap; var lockName = $"shop_section_template_{locale}_{section.Id}"; - using (await namedLock.LockAsync(lockName).ConfigureAwait(false)) + using (await namedLock.LockAsync(lockName, cancellationToken).ConfigureAwait(false)) { localeTemplateBitmap = cache.Get($"shop_section_template_{locale}_bmp_{section.Id}"); if (_isNewShop || localeTemplateBitmap == null) @@ -151,18 +148,20 @@ public async Task ShopSection([FromBody] ShopSection section, [Fr return File(data.AsStream(true), "image/png"); } - private async Task PrefetchImages(Shop shop) + private Task PrefetchImages(Shop shop, CancellationToken cancellationToken) { - await PrefetchImages(shop.Sections); + return PrefetchImages(shop.Sections, cancellationToken); } - private async Task PrefetchImages(IEnumerable sections) + private async Task PrefetchImages(IReadOnlyList sections, CancellationToken cancellationToken) { var entries = sections.SelectMany(x => x.Entries); var options = new ParallelOptions { - MaxDegreeOfParallelism = Environment.ProcessorCount / 2 + MaxDegreeOfParallelism = Environment.ProcessorCount / 2, + CancellationToken = cancellationToken }; + using var client = clientFactory.CreateClient(); await Parallel.ForEachAsync(entries, options, async (entry, token) => { var cacheKey = $"shop_image_{entry.Id}"; @@ -175,7 +174,6 @@ await Parallel.ForEachAsync(entries, options, async (entry, token) => return; } - using var client = clientFactory.CreateClient(); var url = entry.ImageUrl ?? entry.FallbackImageUrl; SKBitmap bitmap; @@ -205,7 +203,7 @@ private async Task GenerateShopImage(Shop shop, SKBitmap templateBitma var backgroundBitmap = await assets.GetBitmap("data/images/{0}", shop.BackgroundImagePath); // don't dispose if (backgroundBitmap is null) { - using var paint = new SKPaint(); + using var paint = new SKPaintSafe(); paint.IsAntialias = true; paint.IsDither = true; paint.Shader = SKShader.CreateLinearGradient( @@ -219,7 +217,7 @@ private async Task GenerateShopImage(Shop shop, SKBitmap templateBitma } else { - using var backgroundImagePaint = new SKPaint(); + using var backgroundImagePaint = new SKPaintSafe(); backgroundImagePaint.IsAntialias = true; backgroundImagePaint.FilterQuality = SKFilterQuality.Medium; @@ -438,7 +436,7 @@ private async Task GenerateLocaleTemplate(Shop shop, SKBitmap template position += entry.Size; using var itemCardBitmap = await GenerateItemCard(entry); - using var itemCardPaint = new SKPaint(); + using var itemCardPaint = new SKPaintSafe(); itemCardPaint.IsAntialias = true; itemCardPaint.Shader = SKShader.CreateBitmap(itemCardBitmap, SKShaderTileMode.Clamp, SKShaderTileMode.Clamp, SKMatrix.CreateTranslation(entryX, entryY)); @@ -573,7 +571,7 @@ private async Task GenerateItemCard(ShopEntry shopEntry) if (shopEntry.BackgroundColors != null) { - using var backgroundGradientPaint = new SKPaint(); + using var backgroundGradientPaint = new SKPaintSafe(); backgroundGradientPaint.IsAntialias = true; backgroundGradientPaint.IsDither = true; switch (shopEntry.BackgroundColors.Length) @@ -616,7 +614,7 @@ private async Task GenerateItemCard(ShopEntry shopEntry) else if (shopEntry.ImageUrl == null) { // Draw radial gradient and paste resizedImageBitmap on it - using var gradientPaint = new SKPaint(); + using var gradientPaint = new SKPaintSafe(); gradientPaint.IsAntialias = true; gradientPaint.Shader = SKShader.CreateRadialGradient( new SKPoint(imageInfo.Rect.MidX, imageInfo.Rect.MidY), @@ -643,22 +641,20 @@ private async Task GenerateItemCard(ShopEntry shopEntry) else { int resizeWidth, resizeHeight; - var aspectRatio = shopEntry.Image.Width / shopEntry.Image.Height; + var aspectRatio = (float)shopEntry.Image.Width / shopEntry.Image.Height; if (imageInfo.Width > imageInfo.Height) { resizeWidth = imageInfo.Width; - resizeHeight = imageInfo.Width / aspectRatio; + resizeHeight = (int)(imageInfo.Width / aspectRatio); } else { - resizeWidth = imageInfo.Height * aspectRatio; + resizeWidth = (int)(imageInfo.Height / aspectRatio); resizeHeight = imageInfo.Height; } - using var resizedImageBitmap = - shopEntry.Image.Resize(new SKImageInfo(resizeWidth, resizeHeight), SKFilterQuality.Medium) ?? - shopEntry.Image.Copy(); + using var resizedImageBitmap = shopEntry.Image.Resize(new SKImageInfo(resizeWidth, resizeHeight), SKFilterQuality.Medium); // Car bundles get centered in the middle of the card vertically if (shopEntry.ImageType == "car-bundle") @@ -687,7 +683,7 @@ private async Task GenerateItemCard(ShopEntry shopEntry) if (shopEntry.TextBackgroundColor != null) { var textBackgroundColor = ImageUtils.ParseColor(shopEntry.TextBackgroundColor); - using var shadowPaint = new SKPaint(); + using var shadowPaint = new SKPaintSafe(); shadowPaint.IsAntialias = true; shadowPaint.IsDither = true; shadowPaint.Shader = SKShader.CreateLinearGradient( @@ -703,7 +699,7 @@ private async Task GenerateItemCard(ShopEntry shopEntry) } else if (shopEntry.ImageType == "track") { - using var shadowPaint = new SKPaint(); + using var shadowPaint = new SKPaintSafe(); shadowPaint.IsAntialias = true; shadowPaint.IsDither = true; shadowPaint.Shader = SKShader.CreateLinearGradient( diff --git a/Controllers/StatsImageController.cs b/Controllers/StatsImageController.cs index 0c0f55f..997a4f8 100644 --- a/Controllers/StatsImageController.cs +++ b/Controllers/StatsImageController.cs @@ -16,19 +16,21 @@ public class StatsImageController(IMemoryCache cache, AsyncKeyedLocker n public async Task Post(Stats stats, StatsType type = StatsType.Normal) { logger.LogInformation("Stats image request received | Name = {PlayerName} | Type = {Type}", stats.PlayerName, type); - if (type == StatsType.Normal && stats.Teams == null) + if (type == StatsType.Normal && stats.Teams is null) return BadRequest("Normal stats type requested but no team stats were provided."); - if (type == StatsType.Competitive && stats.Competitive == null) + if (type == StatsType.Competitive && stats.Competitive is null) return BadRequest("Competitive stats type requested but no competitive stats were provided."); - var backgroundHash = stats.BackgroundImagePath is not null ? $"_{stats.BackgroundImagePath.GetHashCode()}" : ""; + var backgroundHash = stats.BackgroundImagePath is not null + ? $"_{stats.BackgroundImagePath.GetHashCode()}" + : ""; var lockName = $"stats_{type}{backgroundHash}_template_mutex"; SKBitmap? templateBitmap; using (await namedLock.LockAsync(lockName).ConfigureAwait(false)) { cache.TryGetValue($"stats_{type}{backgroundHash}_template_image", out templateBitmap); - if (templateBitmap == null) + if (templateBitmap is null) { templateBitmap = await GenerateTemplate(stats, type); cache.Set($"stats_{type}{backgroundHash}_template_image", templateBitmap); @@ -43,7 +45,9 @@ public async Task Post(Stats stats, StatsType type = StatsType.No private async Task GenerateTemplate(Stats stats, StatsType type) { - var imageInfo = type == StatsType.Competitive ? new SKImageInfo(1505, 624) : new SKImageInfo(1505, 777); + var imageInfo = type == StatsType.Competitive + ? new SKImageInfo(1505, 624) + : new SKImageInfo(1505, 777); var bitmap = new SKBitmap(imageInfo); using var canvas = new SKCanvas(bitmap); @@ -53,7 +57,7 @@ await assets.GetBitmap("data/images/{0}", stats.BackgroundImagePath); // don't dispose TODO: Clear caching on bg change if (customBackgroundBitmap is null) { - using var backgroundPaint = new SKPaint(); + using var backgroundPaint = new SKPaintSafe(); backgroundPaint.IsAntialias = true; backgroundPaint.Shader = SKShader.CreateRadialGradient( new SKPoint(imageInfo.Rect.MidX, imageInfo.Rect.MidY), @@ -65,7 +69,7 @@ await assets.GetBitmap("data/images/{0}", } else { - using var backgroundImagePaint = new SKPaint(); + using var backgroundImagePaint = new SKPaintSafe(); backgroundImagePaint.IsAntialias = true; backgroundImagePaint.FilterQuality = SKFilterQuality.Medium; @@ -453,7 +457,7 @@ await assets.GetBitmap( canvas.DrawText(rankedStatsEntry.CurrentDivisionName, x - (int)(textBounds.Width / 2), 206 - textBounds.Top, divisionPaint); - if (rankedStatsEntry.Ranking == null) + if (rankedStatsEntry.Ranking is null) { const int maxBarWidth = 130, barHeight = 6; var progressText = $"{(int)(rankedStatsEntry.Progress * 100)}%"; @@ -469,7 +473,7 @@ await assets.GetBitmap( if (rankProgressBarWidth > 0) { rankProgressBarWidth = Math.Max(rankProgressBarWidth, barHeight); - using var battlePassBarPaint = new SKPaint(); + using var battlePassBarPaint = new SKPaintSafe(); battlePassBarPaint.IsAntialias = true; battlePassBarPaint.Shader = SKShader.CreateLinearGradient( new SKPoint(barX, 0), @@ -550,7 +554,7 @@ await assets.GetBitmap( if (battlePassBarWidth > 0) { battlePassBarWidth = Math.Max(battlePassBarWidth, barHeight); - using var battlePassBarPaint = new SKPaint(); + using var battlePassBarPaint = new SKPaintSafe(); battlePassBarPaint.IsAntialias = true; battlePassBarPaint.Shader = SKShader.CreateLinearGradient( new SKPoint(158, 0), @@ -641,7 +645,7 @@ await assets.GetBitmap( valuePaint.MeasureText(stats.Squads.Top6, ref textBounds); canvas.DrawText(stats.Squads.Top6, 1316, 518 - textBounds.Top, valuePaint); - if (type == StatsType.Normal && stats.Teams != null) + if (type == StatsType.Normal && stats.Teams is not null) { valuePaint.MeasureText(stats.Teams.MatchesPlayed, ref textBounds); canvas.DrawText(stats.Teams.MatchesPlayed, 537, 671 - textBounds.Top, valuePaint); @@ -662,6 +666,8 @@ await assets.GetBitmap( return bitmap; } + private static readonly SKImageFilter _blurredFilter = SKImageFilter.CreateBlur(5, 5); + private static void DrawBlurredRoundRect(SKBitmap bitmap, SKRoundRect rect) { using var canvas = new SKCanvas(bitmap); @@ -670,7 +676,7 @@ private static void DrawBlurredRoundRect(SKBitmap bitmap, SKRoundRect rect) using var paint = new SKPaint(); paint.IsAntialias = true; - paint.ImageFilter = SKImageFilter.CreateBlur(5, 5); + paint.ImageFilter = _blurredFilter; canvas.DrawBitmap(bitmap, 0, 0, paint); } diff --git a/Controllers/UtilsImageController.cs b/Controllers/UtilsImageController.cs index e7bfd5b..d675e8f 100644 --- a/Controllers/UtilsImageController.cs +++ b/Controllers/UtilsImageController.cs @@ -34,7 +34,7 @@ public async Task GenerateProgressBar(ProgressBar progressBar) if (barWidth > 0) { barWidth = barWidth < 20 ? 20 : barWidth; - using var barPaint = new SKPaint(); + using var barPaint = new SKPaintSafe(); barPaint.IsAntialias = true; barPaint.Shader = SKShader.CreateLinearGradient( new SKPoint(0, 0), @@ -56,7 +56,7 @@ public async Task GenerateProgressBar(ProgressBar progressBar) textPaint.Typeface = segoeFont; textPaint.MeasureText(progressBar.Text, ref textBounds); - canvas.DrawText(progressBar.Text, 500 + 5, (float)bitmap.Height / 2 - textBounds.MidY, textPaint); + canvas.DrawText(progressBar.Text, 500 + 5, bitmap.Height / 2f - textBounds.MidY, textPaint); if (progressBar.BarText != null) { @@ -67,7 +67,7 @@ public async Task GenerateProgressBar(ProgressBar progressBar) barTextPaint.Typeface = segoeFont; barTextPaint.MeasureText(progressBar.BarText, ref textBounds); - canvas.DrawText(progressBar.BarText, (500 - textBounds.Width) / 2, + canvas.DrawText(progressBar.BarText, (500 - textBounds.Width) / 2f, bitmap.Height / 2f - textBounds.MidY, barTextPaint); } @@ -78,19 +78,16 @@ public async Task GenerateProgressBar(ProgressBar progressBar) [HttpPost("drop")] public async Task GenerateDropImage(Drop drop) { - logger.LogInformation("Drop Image request received"); - var mapBitmap = - await assets.GetBitmap( - $"data/images/map/{drop.Locale}.png"); // don't dispose TODO: Clear caching on bg change + logger.LogInformation("Drop Image request received | Locale = {DropLocale}", drop.Locale); - if (mapBitmap == null) + var filePath = $"data/images/map/{drop.Locale}.png"; + if (!System.IO.File.Exists(filePath)) return BadRequest("Map file doesn't exist."); - var bitmap = new SKBitmap(mapBitmap.Width, mapBitmap.Height); + var mapBytes = await System.IO.File.ReadAllBytesAsync(filePath); + using var bitmap = SKBitmap.Decode(mapBytes); using var canvas = new SKCanvas(bitmap); - canvas.DrawBitmap(mapBitmap, 0, 0); - var markerAmount = Directory.EnumerateFiles("Assets/Images/Map/Markers", "*.png").Count(); var markerBitmap = await assets.GetBitmap( @@ -103,7 +100,7 @@ await assets.GetBitmap( var mx = (drop.X + worldRadius) / (worldRadius * 2f) * bitmap.Width + xOffset; var my = (drop.Y + worldRadius) / (worldRadius * 2f) * bitmap.Height + yOffset; - canvas.DrawBitmap(markerBitmap, mx - (float)markerBitmap!.Width / 2, my - markerBitmap.Height); + canvas.DrawBitmap(markerBitmap, mx - markerBitmap!.Width / 2f, my - markerBitmap.Height); var data = bitmap.Encode(SKEncodedImageFormat.Jpeg, 100); return File(data.AsStream(true), "image/jpeg"); diff --git a/EasyFortniteStats-ImageApi.csproj b/EasyFortniteStats-ImageApi.csproj index d6316f4..9fe2df8 100644 --- a/EasyFortniteStats-ImageApi.csproj +++ b/EasyFortniteStats-ImageApi.csproj @@ -11,8 +11,8 @@ - - + + diff --git a/ImageUtils.cs b/ImageUtils.cs index 3ca31ff..04a8dff 100644 --- a/ImageUtils.cs +++ b/ImageUtils.cs @@ -46,10 +46,13 @@ public static async Task GenerateDiscordBox(SharedAssets assets, strin var discordLogoBitmap = await assets.GetBitmap("Assets/Images/DiscordLogo.png"); // don't dispose // get height with the same aspect ratio var logoResizeHeight = (int)(discordLogoBitmap!.Height * (logoResizeWidth / (float)discordLogoBitmap.Width)); - var discordLogoBitmapResized = - discordLogoBitmap.Resize(new SKImageInfo(logoResizeWidth, logoResizeHeight), SKFilterQuality.High); - canvas.DrawBitmap(discordLogoBitmapResized, 10 * resizeFactor, - (float)(imageInfo.Height - discordLogoBitmapResized.Height) / 2); + var logoX = (int)(10f * resizeFactor); + var logoY = (int)((imageInfo.Height - logoResizeHeight) / 2f); + + using var drawdiscordLogoPaint = new SKPaint(); + drawdiscordLogoPaint.IsAntialias = true; + drawdiscordLogoPaint.FilterQuality = SKFilterQuality.High; + canvas.DrawBitmap(discordLogoBitmap, SKRect.Create(logoX, logoY, logoResizeWidth, logoResizeHeight), drawdiscordLogoPaint); while (discordTagTextBounds.Width + (10 + 2 * 15 + 50) * resizeFactor > imageInfo.Width) { @@ -57,7 +60,7 @@ public static async Task GenerateDiscordBox(SharedAssets assets, strin discordTagTextPaint.MeasureText(username, ref discordTagTextBounds); } - canvas.DrawText(username, (10 + 15) * resizeFactor + discordLogoBitmapResized.Width, + canvas.DrawText(username, (10 + 15) * resizeFactor + logoResizeWidth, (float)imageInfo.Height / 2 - discordTagTextBounds.MidY, discordTagTextPaint); return bitmap; diff --git a/Models/Locker.cs b/Models/Locker.cs index b942704..62cd151 100644 --- a/Models/Locker.cs +++ b/Models/Locker.cs @@ -4,16 +4,27 @@ namespace EasyFortniteStats_ImageApi.Models; -public class Locker +public sealed class Locker : IDisposable { public string RequestId { get; set; } public string Locale { get; set; } public string PlayerName { get; set; } public string UserName { get; set; } public LockerItem[] Items { get; set; } + + public void Dispose() + { + if (Items is not { Length: not 0 }) + return; + + foreach (var item in Items) + { + item.Dispose(); + } + } } -public class LockerItem +public sealed class LockerItem : IDisposable { public string Id { get; set; } public string Name { get; set; } @@ -25,6 +36,11 @@ public class LockerItem public string Source { get; set; } [JsonIgnore] public SKBitmap? Image { get; set; } + + public void Dispose() + { + Image?.Dispose(); + } } public enum SourceType diff --git a/Models/Shop.cs b/Models/Shop.cs index 29017f7..085527e 100644 --- a/Models/Shop.cs +++ b/Models/Shop.cs @@ -11,6 +11,23 @@ public class Shop public string? CreatorCode { get; set; } public string? BackgroundImagePath { get; set; } public ShopSection[] Sections { get; set; } + + public string GetTemplateHash() + { + var hash = new HashCode(); + foreach (var section in Sections) + hash.Add(section.GetTemplateHash()); + return hash.ToHashCode().ToString(); + } + + public string GetLocaleTemplateHash() + { + var hash = new HashCode(); + hash.Add(Date); + foreach (var section in Sections) + hash.Add(section.GetLocaleTemplateHash()); + return hash.ToHashCode().ToString(); + } } public class ShopSection @@ -18,6 +35,25 @@ public class ShopSection public string Id { get; set; } public string? Name { get; set; } public ShopEntry[] Entries { get; set; } + + public int GetTemplateHash() + { + var hash = new HashCode(); + hash.Add(Id); + foreach (var entry in Entries) + hash.Add(entry.GetTemplateHash()); + return hash.ToHashCode(); + } + + public int GetLocaleTemplateHash() + { + var hash = new HashCode(); + hash.Add(Id); + hash.Add(Name); + foreach (var entry in Entries) + hash.Add(entry.GetLocaleTemplateHash()); + return hash.ToHashCode(); + } } public class ShopEntry @@ -36,6 +72,34 @@ public class ShopEntry public bool IsSpecial { get; set; } [JsonIgnore] public SKBitmap? Image { get; set; } + + public int GetTemplateHash() + { + var hash = new HashCode(); + hash.Add(Id); + hash.Add(RegularPrice); + hash.Add(FinalPrice); + hash.Add(Size); + if (BackgroundColors != null) + { + foreach (var color in BackgroundColors) + hash.Add(color); + } + hash.Add(TextBackgroundColor); + hash.Add(ImageType); + hash.Add(ImageUrl); + hash.Add(FallbackImageUrl); + hash.Add(IsSpecial); + return hash.ToHashCode(); + } + + public int GetLocaleTemplateHash() + { + var hash = new HashCode(); + hash.Add(GetTemplateHash()); + hash.Add(Name); + return hash.ToHashCode(); + } } public class ShopEntryBanner @@ -58,20 +122,16 @@ public ShopSectionLocationData(string id, ShopLocationDataEntry? name, ShopEntry public ShopEntryLocationData[] Entries { get; } } -public class ShopEntryLocationData +public class ShopEntryLocationData( + string id, + ShopLocationDataEntry name, + ShopLocationDataEntry price, + ShopLocationDataEntry? banner) { - public ShopEntryLocationData(string id, ShopLocationDataEntry name, ShopLocationDataEntry price, ShopLocationDataEntry? banner) - { - Id = id; - Name = name; - Price = price; - Banner = banner; - } - - public string Id { get; } - public ShopLocationDataEntry Name { get; } - public ShopLocationDataEntry Price { get; } - public ShopLocationDataEntry? Banner { get; } + public string Id { get; } = id; + public ShopLocationDataEntry Name { get; } = name; + public ShopLocationDataEntry Price { get; } = price; + public ShopLocationDataEntry? Banner { get; } = banner; } public class ShopLocationDataEntry diff --git a/Program.cs b/Program.cs index 2395262..f51846b 100644 --- a/Program.cs +++ b/Program.cs @@ -4,7 +4,6 @@ var builder = WebApplication.CreateBuilder(args); - builder.Services.AddControllers(); builder.Services.AddMemoryCache(); builder.Services.AddEndpointsApiExplorer(); @@ -13,8 +12,8 @@ builder.Services.AddHttpClient(); builder.Services.AddSingleton(new AsyncKeyedLocker(o => { - o.PoolSize = 20; - o.PoolInitialFill = 1; + o.PoolSize = 64; + o.PoolInitialFill = -1; })); var app = builder.Build(); diff --git a/SKPaintSafe.cs b/SKPaintSafe.cs new file mode 100644 index 0000000..c3efc14 --- /dev/null +++ b/SKPaintSafe.cs @@ -0,0 +1,33 @@ +// ReSharper disable InconsistentNaming +using SkiaSharp; + +namespace EasyFortniteStats_ImageApi; + +public sealed class SKPaintSafe : SKPaint +{ + private SKShader? _shader; + + public new SKShader? Shader + { + get => base.Shader; + set + { + // Dispose old shader to prevent leaks if changed + _shader?.Dispose(); + _shader = value; // Hold reference to prevent early GC + + base.Shader = value; + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _shader?.Dispose(); + _shader = null; + } + + base.Dispose(disposing); + } +} \ No newline at end of file diff --git a/SharedAssets.cs b/SharedAssets.cs index 8f827d3..796ebb1 100644 --- a/SharedAssets.cs +++ b/SharedAssets.cs @@ -40,7 +40,7 @@ public class SharedAssets(IMemoryCache memoryCache) return null; } - using var data = await ReadToSkData(path); // TODO: test if should dispose + using var data = await ReadToSkData(path); var bitmap = SKBitmap.Decode(data); memoryCache.Set(key, bitmap, CacheOptions); Semaphore.Release(); @@ -62,7 +62,7 @@ public async ValueTask GetFont(string path) return cached; } - using var data = await ReadToSkData(path); // TODO: test if should dispose + using var data = await ReadToSkData(path); var typeface = SKTypeface.FromData(data); memoryCache.Set(key, typeface, CacheOptions); Semaphore.Release();