Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
269 changes: 222 additions & 47 deletions Controllers/ShopImageController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public partial class ShopImageController : ControllerBase
private const int HORIZONTAL_PADDING = 100;
private const int BOTTOM_PADDING = 100;
private const int HEADER_HEIGHT = 450;
private const int LAYOUT_HEADER_HEIGHT = 200;
private const int COLUMN_SPACE = 100;
private const int CARDS_PER_SECTION = 5;
private const int CARD_WIDTH = 220;
Expand All @@ -36,6 +37,7 @@ public partial class ShopImageController : ControllerBase
private const float SECTION_NAME_FONT_SIZE = 43f;
private const float ENTRY_NAME_FONT_SIZE = 27f;
private const float ENTRY_PRICE_FONT_SIZE = 21f;
private const float LAYOUT_NAME_FONT_SIZE = 100f;


private static readonly MemoryCacheEntryOptions ShopImageCacheOptions = new()
Expand Down Expand Up @@ -115,60 +117,39 @@ public async Task<IActionResult> Shop([FromBody] Shop shop, [FromQuery] string?
return File(data.AsStream(true), "image/png");
}

[HttpPost("section")]
public async Task<IActionResult> ShopSection([FromBody] ShopSection section, [FromQuery] string? locale,
[HttpPost("layout")]
public async Task<IActionResult> ShopSection([FromBody] ShopLayout layout, [FromQuery] string? locale,
[FromQuery] bool? isNewShop)
{
locale ??= "en";
var _isNewShop = isNewShop ?? false;
Console.WriteLine($"Item Shop section image request | Locale = {locale} | New Shop = {section.Id}");
Console.WriteLine($"Item Shop section image request | Locale = {locale} | New Shop = {isNewShop}");

SKBitmap? templateBitmap;
ShopSectionLocationData? shopSectionLocationData;
ShopSectionLocationData[]? shopSectionLocationData;

await _namedLock.WaitAsync($"shop_section_template_{section.Id}");
await _namedLock.WaitAsync($"shop_section_template_{layout.Id}");
try
{
templateBitmap = _cache.Get<SKBitmap?>($"shop_section_template_bmp_{section.Id}");
shopSectionLocationData = _cache.Get<ShopSectionLocationData?>($"shop_section_location_data_{section.Id}");
templateBitmap = _cache.Get<SKBitmap?>($"shop_section_template_bmp_{layout.Id}");
shopSectionLocationData = _cache.Get<ShopSectionLocationData[]?>($"shop_section_location_data_{layout.Id}");
if (_isNewShop || templateBitmap is null)
{
await PrefetchImages([section]);
var templateGenerationResult = await GenerateSectionTemplate(section);
await PrefetchImages(layout.Sections);
var templateGenerationResult = await GenerateLayoutTemplate(layout);
templateBitmap = templateGenerationResult.Item2;
shopSectionLocationData = templateGenerationResult.Item1;
_cache.Set($"shop_section_template_bmp_{section.Id}", templateBitmap, ShopImageCacheOptions);
_cache.Set($"shop_section_location_data_{section.Id}", shopSectionLocationData,
_cache.Set($"shop_section_template_bmp_{layout.Id}", templateBitmap, ShopImageCacheOptions);
_cache.Set($"shop_section_location_data_{layout.Id}", shopSectionLocationData,
TimeSpan.FromMinutes(10));
}
}
finally
{
_namedLock.Release($"shop_section_template_{section.Id}");
_namedLock.Release($"shop_section_template_{layout.Id}");
}

SKBitmap? localeTemplateBitmap;

var lockName = $"shop_section_template_{locale}_{section.Id}";
await _namedLock.WaitAsync(lockName);
try
{
localeTemplateBitmap = _cache.Get<SKBitmap?>($"shop_section_template_{locale}_bmp_{section.Id}");
if (_isNewShop || localeTemplateBitmap == null)
{
localeTemplateBitmap =
await GenerateSectionLocaleTemplate(section, templateBitmap, shopSectionLocationData!);
_cache.Set($"shop_section_template_{locale}_bmp_{section.Id}", localeTemplateBitmap,
ShopImageCacheOptions);
}
}
finally
{
_namedLock.Release(lockName);
}

using var localeTemplateBitmapCopy = localeTemplateBitmap.Copy();
using var image = await GenerateShopSectionImage(section, localeTemplateBitmapCopy);
using var image = await GenerateShopLayoutImage(layout, templateBitmap, shopSectionLocationData!);
var data = image.Encode(SKEncodedImageFormat.Png, 100);
return File(data.AsStream(true), "image/png");
}
Expand Down Expand Up @@ -434,8 +415,7 @@ private async Task<SKBitmap> GenerateLocaleTemplate(Shop shop, SKBitmap template
columnCount = curColumnCount;
}

var imageInfo = new SKImageInfo(width, height);
var bitmap = new SKBitmap(imageInfo);
var bitmap = new SKBitmap(new SKImageInfo(width, height));
using var canvas = new SKCanvas(bitmap);

var shopLocationData = new ShopSectionLocationData[shop.Sections.Length];
Expand All @@ -446,8 +426,7 @@ private async Task<SKBitmap> GenerateLocaleTemplate(Shop shop, SKBitmap template
for (var j = 0; j < sections.Count; j++)
{
var section = sections[j];
var sectionImageInfo = new SKImageInfo(SECTION_WIDTH, SECTION_HEIGHT);
using var sectionBitmap = new SKBitmap(sectionImageInfo);
using var sectionBitmap = new SKBitmap(new SKImageInfo(SECTION_WIDTH, SECTION_HEIGHT));
using var sectionCanvas = new SKCanvas(sectionBitmap);

var sectionX = HORIZONTAL_PADDING + i * SECTION_WIDTH + i * COLUMN_SPACE;
Expand Down Expand Up @@ -710,19 +689,215 @@ private static string[] SplitNameText(string text, int maxWidth, SKPaint paint)
[GeneratedRegex("([a-z0-9]+|[^a-z0-9])", RegexOptions.IgnoreCase)]
private static partial Regex NameSplitRegex();

private async Task<SKBitmap> GenerateShopSectionImage(ShopSection section, SKBitmap templateBitmap)
private async Task<SKBitmap> GenerateShopLayoutImage(ShopLayout layout, SKBitmap templateBitmap,
ShopSectionLocationData[] shopSectionLocationData)
{
return null;
}
var imageInfo = new SKImageInfo(templateBitmap.Width, templateBitmap.Height);
var bitmap = new SKBitmap(imageInfo);
using var canvas = new SKCanvas(bitmap);

private async Task<SKBitmap> GenerateSectionLocaleTemplate(ShopSection section, SKBitmap templateBitmap,
ShopSectionLocationData sectionLocationData)
{
return null;
canvas.DrawBitmap(templateBitmap, SKPoint.Empty);

using var paint = new SKPaint();
paint.IsAntialias = true;
paint.Shader = SKShader.CreateLinearGradient(
new SKPoint((float)imageInfo.Width / 2, 0),
new SKPoint((float)imageInfo.Width / 2, imageInfo.Height),
[new SKColor(44, 154, 234), new SKColor(14, 53, 147)],
[0.0f, 1.0f],
SKShaderTileMode.Repeat);

canvas.DrawRoundRect(0, 0, imageInfo.Width, imageInfo.Height, 50, 50, paint);

// Drawing
using (var layoutNamePaint = new SKPaint())
{
layoutNamePaint.IsAntialias = true;
layoutNamePaint.TextSize = LAYOUT_NAME_FONT_SIZE;
layoutNamePaint.Color = SKColors.White;
layoutNamePaint.Typeface = await _assets.GetFont("Assets/Fonts/Fortnite-86Bold.otf");

canvas.DrawText(layout.Name, 50, 50 - layoutNamePaint.FontMetrics.Ascent, layoutNamePaint);
}

foreach (var sectionLocationData in shopSectionLocationData)
{
var shopSection = layout.Sections.FirstOrDefault(x => x.Id == sectionLocationData.Id);

// Draw the section name if it exists
if (sectionLocationData.Name != null)
{
using var sectionNamePaint = new SKPaint();
sectionNamePaint.IsAntialias = true;
sectionNamePaint.TextSize = SECTION_NAME_FONT_SIZE;
sectionNamePaint.Color = SKColors.White;
sectionNamePaint.Typeface = await _assets.GetFont("Assets/Fonts/Fortnite-86BoldItalic.otf");

var sectionNamePoint = new SKPoint(sectionLocationData.Name.X,
sectionLocationData.Name.Y - sectionNamePaint.FontMetrics.Ascent);
canvas.DrawText(shopSection?.Name, sectionNamePoint, sectionNamePaint);
}

foreach (var entryLocationData in sectionLocationData.Entries)
{
var shopEntry = shopSection?.Entries?.FirstOrDefault(x => x.Id == entryLocationData.Id);
if (shopEntry is null)
continue;

// Draw the shop entry name
using (var entryNamePaint = new SKPaint())
{
entryNamePaint.IsAntialias = true;
entryNamePaint.TextSize = ENTRY_NAME_FONT_SIZE;
entryNamePaint.Color = SKColors.White;
entryNamePaint.Typeface = await _assets.GetFont("Assets/Fonts/Fortnite-75Medium.otf");

var entryNameTextBounds = new SKRect();
var nameLines = SplitNameText(shopEntry.Name, entryLocationData.Name.MaxWidth ?? 0, entryNamePaint);
if (nameLines.Length > 1)
{
entryNamePaint.MeasureText(nameLines[0], ref entryNameTextBounds);
canvas.DrawText(nameLines[0], entryLocationData.Name.X,
entryLocationData.Name.Y + entryNameTextBounds.Height - 33, entryNamePaint);
}

entryNamePaint.MeasureText(nameLines.Last(), ref entryNameTextBounds);
canvas.DrawText(nameLines.Last(), entryLocationData.Name.X,
entryLocationData.Name.Y + entryNameTextBounds.Height, entryNamePaint);
}

// Draw the shop entry price
using var pricePaint = new SKPaint();
pricePaint.IsAntialias = true;
pricePaint.TextSize = ENTRY_PRICE_FONT_SIZE;
pricePaint.Color = SKColors.White;
pricePaint.Typeface = await _assets.GetFont("Assets/Fonts/Fortnite-75Medium.otf");

var priceTextWidth = pricePaint.MeasureText(shopEntry.FinalPrice);
var pricePoint = new SKPoint(entryLocationData.Price.X,
entryLocationData.Price.Y - pricePaint.FontMetrics.Descent);
canvas.DrawText(shopEntry.FinalPrice, pricePoint, pricePaint);

// Draw strikeout old price if item is discounted
if (shopEntry.FinalPrice != shopEntry.RegularPrice)
{
using var oldPricePaint = new SKPaint();
oldPricePaint.IsAntialias = true;
oldPricePaint.TextSize = ENTRY_PRICE_FONT_SIZE;
oldPricePaint.Color = SKColors.White.WithAlpha((int)(.6 * 255));
oldPricePaint.Typeface = await _assets.GetFont("Assets/Fonts/Fortnite-75Medium.otf");

var oldPriceTextWidth = oldPricePaint.MeasureText(shopEntry.RegularPrice);
var oldPricePoint = new SKPoint(entryLocationData.Price.X + priceTextWidth + 9,
entryLocationData.Price.Y - pricePaint.FontMetrics.Descent);
canvas.DrawText(shopEntry.RegularPrice, oldPricePoint, oldPricePaint);

// Draw the strikeout line
using var strikePaint = new SKPaint();
strikePaint.IsAntialias = true;
strikePaint.StrokeWidth = 2f;
strikePaint.Color = SKColors.White.WithAlpha((int)(.6 * 255));

var strikeStart = new SKPoint(oldPricePoint.X - 4, oldPricePoint.Y - 9);
var strikeEnd = new SKPoint(oldPricePoint.X + oldPriceTextWidth + 2, oldPricePoint.Y - 6);
canvas.DrawLine(strikeStart, strikeEnd, strikePaint);
}

if (shopEntry.Banner != null)
{
using var bannerBitmap = await GenerateBanner(shopEntry.Banner.Text, shopEntry.Banner.Colors,
(int)entryLocationData.Banner!.MaxWidth!);
canvas.DrawBitmap(bannerBitmap, entryLocationData.Banner!.X, entryLocationData.Banner.Y);
}
}
}

return bitmap;
}

private async Task<(ShopSectionLocationData, SKBitmap)> GenerateSectionTemplate(ShopSection section)
private async Task<(ShopSectionLocationData[], SKBitmap)> GenerateLayoutTemplate(ShopLayout layout)
{
return (null, null);
var columnCount = 1;
var bestAspectRatioDiff = float.MaxValue;
int width = 0, height = 0, sectionsPerColumn = 0;
for (var curColumnCount = columnCount; curColumnCount <= 15; curColumnCount++)
{
var curWidth = HORIZONTAL_PADDING + curColumnCount * SECTION_WIDTH + (curColumnCount - 1) * COLUMN_SPACE;
var curSectionsPerColumn = (int)Math.Ceiling((double)layout.Sections.Length / curColumnCount);
var curHeight = LAYOUT_HEADER_HEIGHT + curSectionsPerColumn * SECTION_HEIGHT +
(curSectionsPerColumn - 1) * CARD_SPACE + BOTTOM_PADDING;

// The goal is reaching a 1:1 aspect ratio
var aspectRatio = (float)curWidth / curHeight;
var aspectRatioDiff = Math.Abs(aspectRatio - 1);
if (aspectRatioDiff >= bestAspectRatioDiff) break;

width = curWidth;
height = curHeight;
sectionsPerColumn = curSectionsPerColumn;
bestAspectRatioDiff = aspectRatioDiff;
columnCount = curColumnCount;
}

var bitmap = new SKBitmap(new SKImageInfo(width, height));
using var canvas = new SKCanvas(bitmap);

var shopLocationData = new ShopSectionLocationData[layout.Sections.Length];
var iSec = 0;
for (var i = 0; i < columnCount; i++)
{
var columnSections = layout.Sections.Skip(i * sectionsPerColumn).Take(sectionsPerColumn).ToList();
for (var j = 0; j < columnSections.Count; j++)
{
var section = columnSections[j];
using var sectionBitmap = new SKBitmap(new SKImageInfo(SECTION_WIDTH, SECTION_HEIGHT));
using var sectionCanvas = new SKCanvas(sectionBitmap);

var sectionX = HORIZONTAL_PADDING / 2 + i * SECTION_WIDTH + i * COLUMN_SPACE;
var sectionY = LAYOUT_HEADER_HEIGHT + j * SECTION_HEIGHT + j * CARD_SPACE;

var position = 0f;
var shopEntryData = new List<ShopEntryLocationData>();
foreach (var entry in section.Entries)
{
// If the next card is full height, we can't fit it in the current column
if (!MathF.Floor(position).Equals(position) && entry.Size >= 1) position = MathF.Ceiling(position);
var entryX = (int)position * CARD_WIDTH + (int)position * CARD_SPACE;
var entryY = SECTION_HEIGHT - CARD_HEIGHT +
(MathF.Floor(position).Equals(position) ? 0 : (CARD_HEIGHT + CARD_SPACE) / 2);
position += entry.Size;

using var itemCardBitmap = await GenerateItemCard(entry);
using var itemCardPaint = new SKPaint();
itemCardPaint.IsAntialias = true;
itemCardPaint.Shader = SKShader.CreateBitmap(itemCardBitmap, SKShaderTileMode.Clamp,
SKShaderTileMode.Clamp, SKMatrix.CreateTranslation(entryX, entryY));
sectionCanvas.DrawRoundRect(entryX, entryY, itemCardBitmap.Width, itemCardBitmap.Height, 20, 20,
itemCardPaint);

var nameLocationData = new ShopLocationDataEntry(sectionX + entryX + 13,
sectionY + entryY + itemCardBitmap.Height - 72, itemCardBitmap.Width - 2 * CARD_PADDING);
var priceLocationData = new ShopLocationDataEntry(sectionX + entryX + 13 + 22 + 8,
sectionY + entryY + itemCardBitmap.Height - 8);
ShopLocationDataEntry? bannerLocationData = null;
if (entry.Banner != null)
bannerLocationData = new ShopLocationDataEntry(sectionX + entryX + 8, sectionY + entryY + 8,
itemCardBitmap.Width - 2 * 8);
shopEntryData.Add(new ShopEntryLocationData(entry.Id, nameLocationData, priceLocationData,
bannerLocationData));
}

ShopLocationDataEntry? sectionNameLocationData = null;
if (section.Name != null)
sectionNameLocationData = new ShopLocationDataEntry(sectionX, sectionY);
shopLocationData[iSec] =
new ShopSectionLocationData(section.Id, sectionNameLocationData, shopEntryData.ToArray());

canvas.DrawBitmap(sectionBitmap, new SKPoint(sectionX, sectionY));
iSec++;
}
}

return (shopLocationData, bitmap);
}
}
22 changes: 15 additions & 7 deletions Models/Shop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ public class Shop
public string CreatorCodeTitle { get; set; }
public string? CreatorCode { get; set; }
public string? BackgroundImagePath { get; set; }
public ShopSection[] Sections { get; set; }
public ShopSection[] Sections { get; set; }
}

public class ShopLayout
{
public string Id { get; set; }
public string Name { get; set; }
public ShopSection[] Sections { get; set; }
}

public class ShopSection
Expand Down Expand Up @@ -50,22 +57,23 @@ public ShopSectionLocationData(string id, ShopLocationDataEntry? name, ShopEntry
Name = name;
Entries = entries;
}

public string Id { get; }
public ShopLocationDataEntry? Name { get; }
public ShopEntryLocationData[] Entries { get; }
}
}

public class ShopEntryLocationData
{
public 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; }
Expand All @@ -79,14 +87,14 @@ public ShopLocationDataEntry(int x, int y)
X = x;
Y = y;
}

public ShopLocationDataEntry(int x, int y, int maxWidth)
{
X = x;
Y = y;
MaxWidth = maxWidth;
}

public int X { get; }
public int Y { get; }
public int? MaxWidth { get; }
Expand Down