Compare commits

...

12 commits
v0.2.0 ... main

Author SHA1 Message Date
cee62f55d1 Merge pull request 'add scoring infra' (#1) from scoring into main
Reviewed-on: #1
2025-11-20 07:24:58 +00:00
1b8f3a31b8 add scoring infra 2025-11-19 14:50:14 +00:00
mbsbahru
a9a4d3a631 push the readme 2025-08-01 10:19:01 +07:00
mbsbahru
dd78487b84 create main branch, add gitignore 2025-08-01 09:59:29 +07:00
mbsbahru
cbc092987d release 2025-08-01 00:59:22 +07:00
mbsbahru
a101d84e45 release 2025-08-01 00:53:20 +07:00
e868c152e0 release 2025-08-01 00:30:05 +07:00
93b41bfa94 release 2025-08-01 00:01:05 +07:00
52e830b459 release 2025-07-31 23:56:15 +07:00
49b6f3810d change using NetVips to reduce memory load 2025-07-31 23:34:36 +07:00
mbsbahru
661c4b955c normalized the crops and optimized 2025-07-31 12:57:14 +07:00
mbsbahru
ddb324bbd4 remove dsstore 2025-07-21 00:12:11 +07:00
14 changed files with 378 additions and 232 deletions

BIN
.DS_Store vendored

Binary file not shown.

2
.gitignore vendored
View file

@ -2,3 +2,5 @@ bin
obj
.vs
*.png
.idea
/.contest

18
LiloController.cs Normal file
View file

@ -0,0 +1,18 @@
using lilos_stitcher;
using Microsoft.AspNetCore.Mvc;
namespace LiloStitcher;
[ApiController]
[Route( "api/image" )]
public sealed class LiloController( lilos_stitcher.LiloStitcher stitcher ) : ControllerBase
{
[HttpPost]
[Route( "generate" )]
[Produces( "image/png" )]
public async Task<IActionResult> Generate( [FromBody] GenerateRequest payload, CancellationToken ct )
{
string path = await stitcher.CreateImageAsync( payload, ct );
return PhysicalFile(path, "image/png", enableRangeProcessing: false);
}
}

View file

@ -1,155 +1,137 @@
using Microsoft.Extensions.Caching.Memory;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Formats.Png;
using NetVips;
namespace LiloStitcher;
namespace lilos_stitcher;
public record GenerateRequest(
string CanvasRect,
double[] CropOffset,
double[] CropSize,
double OutputScale
public sealed record GenerateRequest(
string Canvas_Rect,
double[] Crop_Offset,
double[] Crop_Size,
double Output_Scale
);
public readonly record struct PlateCoordinate(int Row, int Col)
public readonly record struct PlateCoordinate( int Row, int Col )
{
public static PlateCoordinate Parse(string token)
public static PlateCoordinate Parse( string token )
{
var rowPart = new string(token.TakeWhile(char.IsLetter).ToArray()).ToUpperInvariant();
var colPart = new string(token.SkipWhile(char.IsLetter).ToArray());
if( string.IsNullOrWhiteSpace( token ) )
throw new ArgumentException( "Empty coordinate." );
var rowPart = new string( token.TakeWhile( char.IsLetter ).ToArray() ).ToUpperInvariant();
var colPart = new string( token.SkipWhile( char.IsLetter ).ToArray() );
int row = 0;
foreach (var c in rowPart)
row = row * 26 + (c - 'A' + 1);
foreach( char c in rowPart )
row = row * 26 + ( c - 'A' + 1 );
int.TryParse(colPart, out int col);
return new PlateCoordinate(row, col);
return new PlateCoordinate( row, int.Parse( colPart ) );
}
}
public class TileCache(IMemoryCache cache)
public class TileCache( IMemoryCache cache )
{
private const long TileBytes = 720L * 720 * 3;
public Image<Rgba32>? Get(string key) => cache.TryGetValue(key, out Image<Rgba32>? img) ? img : null;
public Image? Get( string key ) => cache.TryGetValue( key, out Image? img ) ? img : null;
public void Set(string key, Image<Rgba32> img) =>
cache.Set(key, img, new MemoryCacheEntryOptions
public void Set( string key, Image img ) =>
cache.Set( key, img, new MemoryCacheEntryOptions
{
Size = TileBytes,
SlidingExpiration = TimeSpan.FromMinutes(20)
});
SlidingExpiration = TimeSpan.FromMinutes( 20 )
} );
}
public class TileLoader(TileCache cache, string assetDir)
public class TileLoader( TileCache cache, string assetDir )
{
public async Task<Image<Rgba32>> LoadAsync(string name, CancellationToken ct)
public async Task<Image> LoadAsync( string name, CancellationToken ct )
{
if (cache.Get(name) is { } hit)
if( cache.Get( name ) is { } hit )
return hit;
var path = Path.Combine(assetDir, $"{name}.png");
await using var fs = File.OpenRead(path);
var image = await Image.LoadAsync<Rgba32>(fs, ct).ConfigureAwait(false);
cache.Set(name, image);
var image = await Task.Run( () =>
Image.NewFromFile( Path.Combine( assetDir, $"{name}.png" ), access: Enums.Access.Sequential ), ct );
cache.Set( name, image );
return image;
}
}
public class LiloStitcher(TileLoader loader)
public class LiloStitcher( TileLoader loader )
{
private const int TileSize = 720;
public async Task<byte[]> CreateImageAsync(GenerateRequest req, CancellationToken ct)
public async Task<string> CreateImageAsync( GenerateRequest req, CancellationToken ct )
{
var parts = req.CanvasRect.ToUpperInvariant()
.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
var part1 = PlateCoordinate.Parse(parts[0]);
var part2 = PlateCoordinate.Parse(parts[1]);
(int rowMin, int colMin, int rows, int cols) = ParseCanvas( req.Canvas_Rect );
int rowMin = Math.Min(part1.Row, part2.Row);
int rowMax = Math.Max(part1.Row, part2.Row);
int colMin = Math.Min(part1.Col, part2.Col);
int colMax = Math.Max(part1.Col, part2.Col);
Validate(req.Crop_Offset, nameof(req.Crop_Offset));
Validate(req.Crop_Size, nameof(req.Crop_Size));
double scale = req.Output_Scale;
if( scale <= 0 || scale > 1 )
throw new ArgumentOutOfRangeException( nameof( req.Output_Scale ) );
int rows = rowMax - rowMin + 1;
int cols = colMax - colMin + 1;
var names = Enumerable.Range(rowMin, rows)
.SelectMany(r => Enumerable.Range(colMin, cols).Select(c => $"{RowName(r)}{c}"))
.ToArray();
var bitmaps = await Task.WhenAll(names.Select(n => loader.LoadAsync(n, ct))).ConfigureAwait(false);
var stitched = new Image<Rgba32>(cols * TileSize, rows * TileSize);
int idx = 0;
for (int r = 0; r < rows; r++)
var tiles = new List<Image>( rows * cols );
for( int row = 0; row < rows; row++ )
{
for (int c = 0; c < cols; c++, idx++)
for( int col = 0; col < cols; col++ )
{
stitched.Mutate(ctx => ctx.DrawImage(bitmaps[idx], new Point(c * TileSize, r * TileSize), 1f));
string id = $"{RowName( rowMin + row )}{colMin + col}";
var tile = await loader.LoadAsync( id, ct );
if( scale < 1 ) tile = tile.Resize( scale );
tiles.Add( tile );
}
}
Validate(req.CropOffset, 2, nameof(req.CropOffset), inclusiveUpper: true);
Validate(req.CropSize, 2, nameof(req.CropSize), inclusiveUpper: true);
var mosaic = Image.Arrayjoin( tiles.ToArray(), across: cols );
int offsetX = (int)(req.CropOffset[0] * stitched.Width);
int offsetY = (int)(req.CropOffset[1] * stitched.Height);
int cropWidth = (int)(req.CropSize[0] * stitched.Width);
int cropHeight = (int)(req.CropSize[1] * stitched.Height);
int offsetX = (int)Math.Truncate( req.Crop_Offset[0] * mosaic.Width );
int offsetY = (int)Math.Truncate( req.Crop_Offset[1] * mosaic.Height );
if (offsetX + cropWidth > stitched.Width) cropWidth = stitched.Width - offsetX;
if (offsetY + cropHeight > stitched.Height) cropHeight = stitched.Height - offsetY;
int restWidth = mosaic.Width - offsetX;
int restHeight = mosaic.Height - offsetY;
var cropRect = new Rectangle(offsetX, offsetY, cropWidth, cropHeight);
int cropWidth = Math.Max( 1, (int)Math.Truncate( req.Crop_Size[0] * restWidth ) );
int cropHeight = Math.Max( 1, (int)Math.Truncate( req.Crop_Size[1] * restHeight ) );
using var cropped = stitched.Clone(ctx => ctx.Crop(cropRect));
int cropX = (int)Math.Truncate( offsetX / 2.0 + ( restWidth - cropWidth ) / 2.0 );
int cropY = (int)Math.Truncate( offsetY / 2.0 + ( restHeight - cropHeight ) / 2.0 );
double scale = Math.Clamp(req.OutputScale, 0.0, 1.0);
Image<Rgba32> output;
if (scale < 1.0)
{
int width = Math.Max(1, (int)Math.Truncate(cropWidth * scale));
int height = Math.Max(1, (int)Math.Truncate(cropHeight * scale));
output = cropped.Clone(ctx => ctx.Resize(width, height));
}
else
{
output = cropped.Clone();
var cropped = mosaic.Crop( cropX, cropY, cropWidth, cropHeight );
string path = Path.Combine(Path.GetTempPath(), $"mosaic-{Guid.NewGuid():N}.png");
cropped.WriteToFile(path);
return path;
}
using var memStream = new MemoryStream();
await output.SaveAsync(memStream, new PngEncoder
private static (int rowMin, int colMin, int rows, int cols) ParseCanvas( string rect )
{
}, ct).ConfigureAwait(false);
return memStream.ToArray();
static void Validate(double[] arr, int len, string name, bool inclusiveUpper)
{
if (arr is null || arr.Length < len)
throw new ArgumentException($"{name} must have length {len}");
double upper = inclusiveUpper ? 1.0 : 1.0 - double.Epsilon;
for (int i = 0; i < len; i++)
{
var v = arr[i];
if (v < 0 || v > upper)
throw new ArgumentOutOfRangeException($"{name}[{i}]= {v} outside [0,{upper}]");
}
var parts = rect.ToUpperInvariant().Split( ':', StringSplitOptions.RemoveEmptyEntries );
var part1 = PlateCoordinate.Parse( parts[0] );
var part2 = PlateCoordinate.Parse( parts[1] );
int rowMin = Math.Min( part1.Row, part2.Row );
int rowMax = Math.Max( part1.Row, part2.Row );
int colMin = Math.Min( part1.Col, part2.Col );
int colMax = Math.Max( part1.Col, part2.Col );
return (rowMin, colMin, rowMax - rowMin + 1, colMax - colMin + 1);
}
static string RowName(int row)
private static void Validate( double[] arr, string name )
{
if( arr is null || arr.Length < 2 )
throw new ArgumentException( $"{name} length" );
if( arr.Any( x => x < 0 || x > 1 ) )
throw new ArgumentOutOfRangeException( name );
}
static string RowName( int row )
{
var stringBuilder = new System.Text.StringBuilder();
while (row > 0)
while( row > 0 )
{
row--;
stringBuilder.Insert(0, (char)('A' + row % 26));
stringBuilder.Insert( 0, (char)( 'A' + row % 26 ) );
row /= 26;
}
return stringBuilder.ToString();
}
}
}

View file

@ -1,61 +1,20 @@
using System.Diagnostics;
using Microsoft.Extensions.Caching.Memory;
using lilos_stitcher;
namespace LiloStitcher;
public static class Program
{
public static async Task<int> Main(string[] args)
{
try
{
Stopwatch sw = Stopwatch.StartNew();
var begin = sw.ElapsedMilliseconds;
var opt = new Options
{
CanvasRect = "A1:AE55",
CropOffset = [0.0, 0.0],
CropSize = [1.0, 1.0],
OutputScale = 1.0,
OutputPath = "stitched.png"
};
var builder = WebApplication.CreateBuilder(args);
string tileFilePath = "../tiles1705"; // should later be directed to the read-only `ASSET_PATH_RO` environment variable
string assetDir = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), tileFilePath));
NetVips.NetVips.Init();
NetVips.NetVips.Concurrency = 3;
using var memCache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 256L * 1024 * 1024 });
var tileCache = new TileCache(memCache);
var loader = new TileLoader(tileCache, assetDir);
var stitcher = new LiloStitcher(loader);
builder.Services.AddControllers().AddJsonOptions(o => o.JsonSerializerOptions.PropertyNameCaseInsensitive = true);
var req = new GenerateRequest(
opt.CanvasRect!,
opt.CropOffset!,
opt.CropSize!,
opt.OutputScale
);
builder.Services.AddMemoryCache(o => o.SizeLimit = 128L * 1024 * 1024);
Console.WriteLine("Stitching...");
var pngBytes = await stitcher.CreateImageAsync(req, CancellationToken.None);
string assetDir = Environment.GetEnvironmentVariable("ASSET_PATH_RO") ?? throw new InvalidOperationException("dir not found");
File.WriteAllBytes(opt.OutputPath!, pngBytes);
Console.WriteLine($"Done. Wrote {opt.OutputPath} ({pngBytes.Length / 1024.0:F1} KB)");
builder.Services.AddSingleton<TileCache>();
builder.Services.AddSingleton(provider => new TileLoader(provider.GetRequiredService<TileCache>(), assetDir));
builder.Services.AddSingleton<lilos_stitcher.LiloStitcher>();
Console.WriteLine(begin - sw.ElapsedMilliseconds);
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine("ERROR: " + ex.Message);
return 1;
}
}
private struct Options
{
public string? CanvasRect { get; set; }
public double[]? CropOffset { get; set; }
public double[]? CropSize { get; set; }
public double OutputScale { get; set; }
public string? OutputPath { get; set; }
}
}
var app = builder.Build();
app.MapControllers();
app.Run();

View file

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5243",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7121;http://localhost:5243",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

44
README.md Normal file
View file

@ -0,0 +1,44 @@
# Lilo Stitcher API
An ASP.NET Core Web API for stitching, cropping, and scaling tiled images (720×720).
## Prerequisites
* .NET 9.0 SDK
* `ASSET_PATH_RO` environment variable containing the path to tiles directory (1,705 PNGs named A1.png…AE55.png)
## Build & Run
```bash
cd lilo-stitcher
dotnet clean
dotnet run
```
By default, the API listens on `http://localhost:5243` and `https://localhost:7121` (see [`launchSettings.json`](https://null.formulatrix.dev/fikribahru/lilo-stitcher/src/branch/main/Properties/launchSettings.json)).
## Usage
**Endpoint:** `POST /api/image/generate`
**Request Body (JSON):**
```json
{
"canvas_rect": "A1:H12",
"crop_offset": [0.25, 0.25],
"crop_size": [0.5, 0.5],
"output_scale": 1.0
}
```
**Example (using `curl`):**
```bash
curl -X POST http://localhost:5243/api/image/generate \
-H "Content-Type: application/json" \
-o output.png \
-d '{"canvas_rect":"A1:H12","crop_offset":[0.25,0.25],"crop_size":[0.5,0.5],"output_scale":1.0}'
```
The API will return a `image/png` containing the stitched, cropped, and scaled result.

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

9
appsettings.json Normal file
View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View file

@ -1,16 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>lilo_stitcher_console</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.7" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
</ItemGroup>
</Project>

19
lilos-stitcher.csproj Normal file
View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>lilos_stitcher</RootNamespace>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0-preview.6.25358.103" />
<PackageReference Include="NetVips" Version="3.1.0" />
<PackageReference Include="NetVips.Native.linux-arm64" Version="8.17.1" />
<!-- <PackageReference Include="NetVips.Native.linux-x64" Version="8.17.1" />-->
</ItemGroup>
</Project>

6
lilos-stitcher.http Normal file
View file

@ -0,0 +1,6 @@
@lilos_stitcher_HostAddress = http://localhost:5243
GET {{lilos_stitcher_HostAddress}}/weatherforecast/
Accept: application/json
###

View file

@ -2,7 +2,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "lilo-stitcher-console", "lilo-stitcher-console.csproj", "{2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "lilos-stitcher", "lilos-stitcher.csproj", "{8FDFFCBC-9C8E-94D5-A96D-606027D71B44}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -10,15 +10,15 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}.Release|Any CPU.Build.0 = Release|Any CPU
{8FDFFCBC-9C8E-94D5-A96D-606027D71B44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8FDFFCBC-9C8E-94D5-A96D-606027D71B44}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8FDFFCBC-9C8E-94D5-A96D-606027D71B44}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8FDFFCBC-9C8E-94D5-A96D-606027D71B44}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C7CD65D7-8035-4FC2-A584-06B21F732FF7}
SolutionGuid = {8DD20971-C4B3-41BF-8263-D31D5D81631F}
EndGlobalSection
EndGlobal

92
mise.toml Normal file
View file

@ -0,0 +1,92 @@
# Quick Guide
# - install mise
# - download the asset and extract them to ASSET_PATH_RO
# - mise trust mise.toml
# - mise run verify-asset
# - require hashdeep
# `hashdeep` package in debian
# or `md5deep` package in fedora
# or uncomment `tools."http:hashdeep"` below in windows
# - mise run serve
# - mise run arrange
# - mise run action
# - mise run assert
# - mise run bench
[env]
ASSET_PATH_RO = "{{ [xdg_cache_home, 'stitch-a-ton', 'asset'] | join_path }}"
CONTEST_HOST = "http://localhost:7007"
CONTEST_API = "/api/image/generate"
CONTEST_OUTPUT = "{{ [cwd, '.contest'] | join_path }}"
DOTNET_ENVIRONMENT = "Production"
ANSWER_COMMIT_HASH = "89a07b40bf0414212c96945671a012035d375a25"
[tools]
dotnet = "9"
xh = "latest"
uv = "latest"
k6 = "latest"
# uncomment these if you're on windows
#[tools."http:hashdeep"]
#version = "4.4"
#[tools."http:hashdeep".platforms]
#windows-x64 = {url = "https://github.com/jessek/hashdeep/releases/download/v4.4/md5deep-4.4.zip"}
[tasks.setup]
run = '''
{% if env.CONTEST_OUTPUT is not exists %}
mkdir .contest
{% endif %}
'''
[tasks.verify-asset]
dir = "{{ env.ASSET_PATH_RO }}"
run = '''
xh get https://null.formulatrix.dev/Contest/stitch-a-ton-answer/raw/commit/{{ env.ANSWER_COMMIT_HASH }}/asset.txt -o ../asset.txt
hashdeep -arbvk ../asset.txt .
'''
[tasks.arrange]
depends = ['setup']
dir = "{{ env.CONTEST_OUTPUT }}"
outputs = ['answer.json', 'action.py', 'assert.py', 'bench.js', 'fuzzy.json']
run = '''
xh get https://null.formulatrix.dev/Contest/stitch-a-ton-answer/raw/commit/{{ env.ANSWER_COMMIT_HASH }}/answer.json -o answer.json
xh get https://null.formulatrix.dev/Contest/stitch-a-ton-answer/raw/commit/{{ env.ANSWER_COMMIT_HASH }}/action.py -o action.py
xh get https://null.formulatrix.dev/Contest/stitch-a-ton-answer/raw/commit/{{ env.ANSWER_COMMIT_HASH }}/assert.py -o assert.py
xh get https://null.formulatrix.dev/Contest/stitch-a-ton-answer/raw/commit/{{ env.ANSWER_COMMIT_HASH }}/bench.js -o bench.js
xh get https://null.formulatrix.dev/Contest/stitch-a-ton-answer/raw/commit/{{ env.ANSWER_COMMIT_HASH }}/fuzzy.json -o fuzzy.json
'''
[tasks.serve]
run = "dotnet run -c Release --no-launch-profile --urls {{env.CONTEST_HOST}}"
[tasks.quick]
depends = ['arrange']
dir = "{{ env.CONTEST_OUTPUT }}"
run = '''
xh post {{env.CONTEST_HOST}}{{env.CONTEST_ENDPOINT}} canvas_rect=A1:H12 crop_offset:=[0,0] crop_size:=[1,1] output_scale:=0.25 -o quick.png
'''
[tasks.action]
depends = ['arrange']
dir = "{{ env.CONTEST_OUTPUT }}"
run = '''
uv run --no-config --script {{ [env.CONTEST_OUTPUT, 'action.py'] | join_path }}
'''
[tasks.assert]
depends = ['arrange']
dir = "{{ env.CONTEST_OUTPUT }}"
run = '''
uvx --no-config --with-requirements assert.py pytest assert.py
'''
[tasks.bench]
depends = ['arrange']
dir = "{{ env.CONTEST_OUTPUT }}"
run = '''
k6 run -e TARGET_URL="{{ env.CONTEST_HOST }}{{ env.CONTEST_API }}" bench.js
'''