diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index e12bae7..0000000
Binary files a/.DS_Store and /dev/null differ
diff --git a/.gitignore b/.gitignore
index d18070c..d0a1f14 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
bin
obj
.vs
-*.png
\ No newline at end of file
+*.png
+.idea
+/.contest
diff --git a/.idea/.idea.lilo-stitcher-console/.idea/.gitignore b/.idea/.idea.lilo-stitcher-console/.idea/.gitignore
deleted file mode 100644
index 4be9211..0000000
--- a/.idea/.idea.lilo-stitcher-console/.idea/.gitignore
+++ /dev/null
@@ -1,13 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Rider ignored files
-/modules.xml
-/contentModel.xml
-/projectSettingsUpdater.xml
-/.idea.lilo-stitcher-console.iml
-# Editor-based HTTP Client requests
-/httpRequests/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
diff --git a/.idea/.idea.lilo-stitcher-console/.idea/encodings.xml b/.idea/.idea.lilo-stitcher-console/.idea/encodings.xml
deleted file mode 100644
index df87cf9..0000000
--- a/.idea/.idea.lilo-stitcher-console/.idea/encodings.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/LiloController.cs b/LiloController.cs
new file mode 100644
index 0000000..989b972
--- /dev/null
+++ b/LiloController.cs
@@ -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 Generate( [FromBody] GenerateRequest payload, CancellationToken ct )
+ {
+ string path = await stitcher.CreateImageAsync( payload, ct );
+ return PhysicalFile(path, "image/png", enableRangeProcessing: false);
+ }
+}
\ No newline at end of file
diff --git a/LiloStitcher.cs b/LiloStitcher.cs
index a2ed464..afee0dd 100644
--- a/LiloStitcher.cs
+++ b/LiloStitcher.cs
@@ -1,136 +1,137 @@
using Microsoft.Extensions.Caching.Memory;
using NetVips;
-namespace lilo_stitcher_console;
+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)
- {
- var rowPart = new string(token.TakeWhile(char.IsLetter).ToArray()).ToUpperInvariant();
- var colPart = new string(token.SkipWhile(char.IsLetter).ToArray());
-
- int row = 0;
- foreach (var character in rowPart)
- row = row * 26 + (character - 'A' + 1);
-
- int.TryParse(colPart, out int col);
- return new PlateCoordinate(row, col);
- }
-}
-
-public class TileCache(IMemoryCache cache)
-{
- private const long TileBytes = 720L * 720 * 3;
-
- public Image? Get(string key) => cache.TryGetValue(key, out Image? img) ? img : null;
-
- public void Set(string key, Image img) =>
- cache.Set(key, img, new MemoryCacheEntryOptions
+ public static PlateCoordinate Parse( string token )
{
- Size = TileBytes,
- SlidingExpiration = TimeSpan.FromMinutes(20)
- });
+ 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( char c in rowPart )
+ row = row * 26 + ( c - 'A' + 1 );
+
+ return new PlateCoordinate( row, int.Parse( colPart ) );
+ }
}
-public class TileLoader(TileCache cache, string assetDir)
-{
- public async Task LoadAsync(string name, CancellationToken ct)
- {
- if (cache.Get(name) is { } hit)
- return hit;
-
- 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 TileCache( IMemoryCache cache )
+{
+ private const long TileBytes = 720L * 720 * 3;
+
+ public Image? Get( string key ) => cache.TryGetValue( key, out Image? img ) ? img : null;
+
+ public void Set( string key, Image img ) =>
+ cache.Set( key, img, new MemoryCacheEntryOptions
+ {
+ Size = TileBytes,
+ SlidingExpiration = TimeSpan.FromMinutes( 20 )
+ } );
}
-public class LiloStitcher(TileLoader loader)
+public class TileLoader( TileCache cache, string assetDir )
{
- public async Task CreateImageAsync(GenerateRequest req, CancellationToken ct)
+ public async Task LoadAsync( string name, CancellationToken ct )
{
- (int rowMin, int colMin, int rows, int cols) = ParseCanvas(req.CanvasRect);
+ if( cache.Get( name ) is { } hit )
+ return hit;
- Validate(req.CropOffset, nameof(req.CropOffset));
- Validate(req.CropSize, nameof(req.CropSize));
+ var image = await Task.Run( () =>
+ Image.NewFromFile( Path.Combine( assetDir, $"{name}.png" ), access: Enums.Access.Sequential ), ct );
- double scale = req.OutputScale;
- if (scale <= 0 || scale > 1)
- throw new ArgumentOutOfRangeException(nameof(req.OutputScale));
+ cache.Set( name, image );
+ return image;
+ }
+}
- var tiles = new List(rows * cols);
+public class LiloStitcher( TileLoader loader )
+{
+ public async Task CreateImageAsync( GenerateRequest req, CancellationToken ct )
+ {
+ (int rowMin, int colMin, int rows, int cols) = ParseCanvas( req.Canvas_Rect );
+
+ 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 ) );
+
+ var tiles = new List( rows * cols );
for( int row = 0; row < rows; row++ )
{
- for( int col = 0; col < cols; col++ )
- {
- string id = $"{RowName( rowMin + row )}{colMin + col}";
- var tile = await loader.LoadAsync( id, ct );
- if( scale < 1 ) tile = tile.Resize( scale );
- tiles.Add( tile );
- }
+ for( int col = 0; col < cols; col++ )
+ {
+ string id = $"{RowName( rowMin + row )}{colMin + col}";
+ var tile = await loader.LoadAsync( id, ct );
+ if( scale < 1 ) tile = tile.Resize( scale );
+ tiles.Add( tile );
+ }
}
- var mosaic = Image.Arrayjoin(tiles.ToArray(), across: cols);
+ var mosaic = Image.Arrayjoin( tiles.ToArray(), across: cols );
- int offsetX = (int)Math.Truncate(req.CropOffset[0] * mosaic.Width);
- int offsetY = (int)Math.Truncate(req.CropOffset[1] * mosaic.Height);
+ int offsetX = (int)Math.Truncate( req.Crop_Offset[0] * mosaic.Width );
+ int offsetY = (int)Math.Truncate( req.Crop_Offset[1] * mosaic.Height );
int restWidth = mosaic.Width - offsetX;
int restHeight = mosaic.Height - offsetY;
- int cropWidth = Math.Max(1, (int)Math.Truncate(req.CropSize[0] * restWidth));
- int cropHeight = Math.Max(1, (int)Math.Truncate(req.CropSize[1] * restHeight));
+ 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 ) );
- int cropX = (int)Math.Truncate(offsetX / 2.0 + (restWidth - cropWidth) / 2.0);
- int cropY = (int)Math.Truncate(offsetY / 2.0 + (restHeight - cropHeight) / 2.0);
+ int cropX = (int)Math.Truncate( offsetX / 2.0 + ( restWidth - cropWidth ) / 2.0 );
+ int cropY = (int)Math.Truncate( offsetY / 2.0 + ( restHeight - cropHeight ) / 2.0 );
- var cropRect = mosaic.Crop(cropX, cropY, cropWidth, cropHeight);
+ var cropped = mosaic.Crop( cropX, cropY, cropWidth, cropHeight );
- string tmpPath = Path.Combine(Path.GetTempPath(), $"mosaic-{Guid.NewGuid():N}.png");
- cropRect.WriteToFile(tmpPath);
-
- return tmpPath;
+ string path = Path.Combine(Path.GetTempPath(), $"mosaic-{Guid.NewGuid():N}.png");
+ cropped.WriteToFile(path);
+ return path;
}
- private static (int rowMin, int colMin, int rows, int cols) ParseCanvas(string rect)
+ private static (int rowMin, int colMin, int rows, int cols) ParseCanvas( string rect )
{
- 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);
+ 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);
}
- private static void Validate(double[] arr, string name)
+ 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);
+ 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)
+ static string RowName( int row )
{
- var stringBuilder = new System.Text.StringBuilder();
- while (row > 0)
- {
- row--;
- stringBuilder.Insert(0, (char)('A' + row % 26));
- row /= 26;
- }
- return stringBuilder.ToString();
+ var stringBuilder = new System.Text.StringBuilder();
+ while( row > 0 )
+ {
+ row--;
+ stringBuilder.Insert( 0, (char)( 'A' + row % 26 ) );
+ row /= 26;
+ }
+ return stringBuilder.ToString();
}
}
diff --git a/Program.cs b/Program.cs
index 7019819..5b60d67 100644
--- a/Program.cs
+++ b/Program.cs
@@ -1,68 +1,20 @@
-using lilo_stitcher_console;
-using System.Diagnostics;
-using Microsoft.Extensions.Caching.Memory;
+using lilos_stitcher;
-namespace LiloStitcher;
+var builder = WebApplication.CreateBuilder(args);
-public static class Program
-{
- public static async Task Main( string[] args )
- {
- try
- {
- NetVips.NetVips.Init();
- NetVips.NetVips.Concurrency = 3;
-
- Stopwatch sw = Stopwatch.StartNew();
- var begin = sw.ElapsedMilliseconds;
- var opt = new Options
- {
- CanvasRect = "A1:AE55",
- CropOffset = new[] { 0.4, 0.4 },
- CropSize = new[] { 0.8, 0.8 },
- OutputScale = 0.5,
- OutputPath = "stitched.png"
- };
+NetVips.NetVips.Init();
+NetVips.NetVips.Concurrency = 3;
- 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));
+builder.Services.AddControllers().AddJsonOptions(o => o.JsonSerializerOptions.PropertyNameCaseInsensitive = true);
- using var memCache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 128L * 1024 * 1024 });
- var tileCache = new TileCache(memCache);
- var loader = new TileLoader(tileCache, assetDir);
- var stitcher = new lilo_stitcher_console.LiloStitcher(loader);
+builder.Services.AddMemoryCache(o => o.SizeLimit = 128L * 1024 * 1024);
- var req = new GenerateRequest(
- opt.CanvasRect!,
- opt.CropOffset!,
- opt.CropSize!,
- opt.OutputScale
- );
+string assetDir = Environment.GetEnvironmentVariable("ASSET_PATH_RO") ?? throw new InvalidOperationException("dir not found");
- Console.WriteLine("Stitching...");
- var png = await stitcher.CreateImageAsync(req, CancellationToken.None);
- File.Move( png, opt.OutputPath!, overwrite: true );
-
- long bytes = new FileInfo( opt.OutputPath! ).Length;
- Console.WriteLine($"Done. Wrote {opt.OutputPath} ({bytes / 1024.0:F1} KB)");
+builder.Services.AddSingleton();
+builder.Services.AddSingleton(provider => new TileLoader(provider.GetRequiredService(), assetDir));
+builder.Services.AddSingleton();
- Console.WriteLine(sw.ElapsedMilliseconds - begin);
-
- 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; }
- }
-}
\ No newline at end of file
+var app = builder.Build();
+app.MapControllers();
+app.Run();
\ No newline at end of file
diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json
new file mode 100644
index 0000000..23d5fa7
--- /dev/null
+++ b/Properties/launchSettings.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1980fc5
--- /dev/null
+++ b/README.md
@@ -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.
diff --git a/appsettings.Development.json b/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/appsettings.json b/appsettings.json
new file mode 100644
index 0000000..10f68b8
--- /dev/null
+++ b/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/lilo-stitcher-console.csproj b/lilos-stitcher.csproj
similarity index 53%
rename from lilo-stitcher-console.csproj
rename to lilos-stitcher.csproj
index cd56a0f..2ced848 100644
--- a/lilo-stitcher-console.csproj
+++ b/lilos-stitcher.csproj
@@ -1,18 +1,19 @@
-
+
- Exe
net9.0
- lilo_stitcher_console
- enable
enable
+ enable
+ lilos_stitcher
preview
-
+
-
+
+
+
-
+
\ No newline at end of file
diff --git a/lilos-stitcher.http b/lilos-stitcher.http
new file mode 100644
index 0000000..8e967ed
--- /dev/null
+++ b/lilos-stitcher.http
@@ -0,0 +1,6 @@
+@lilos_stitcher_HostAddress = http://localhost:5243
+
+GET {{lilos_stitcher_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
\ No newline at end of file
diff --git a/lilo-stitcher-console.sln b/lilos-stitcher.sln
similarity index 58%
rename from lilo-stitcher-console.sln
rename to lilos-stitcher.sln
index 2e5c9ca..b15f5b6 100644
--- a/lilo-stitcher-console.sln
+++ b/lilos-stitcher.sln
@@ -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
diff --git a/mise.toml b/mise.toml
new file mode 100644
index 0000000..dca4d7a
--- /dev/null
+++ b/mise.toml
@@ -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
+'''