diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..e12bae7
Binary files /dev/null and b/.DS_Store differ
diff --git a/.gitignore b/.gitignore
index d0a1f14..d18070c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,4 @@
bin
obj
.vs
-*.png
-.idea
-/.contest
+*.png
\ No newline at end of file
diff --git a/.idea/.idea.lilo-stitcher-console/.idea/.gitignore b/.idea/.idea.lilo-stitcher-console/.idea/.gitignore
new file mode 100644
index 0000000..4be9211
--- /dev/null
+++ b/.idea/.idea.lilo-stitcher-console/.idea/.gitignore
@@ -0,0 +1,13 @@
+# 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
new file mode 100644
index 0000000..df87cf9
--- /dev/null
+++ b/.idea/.idea.lilo-stitcher-console/.idea/encodings.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/LiloController.cs b/LiloController.cs
deleted file mode 100644
index 989b972..0000000
--- a/LiloController.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-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 afee0dd..a2ed464 100644
--- a/LiloStitcher.cs
+++ b/LiloStitcher.cs
@@ -1,137 +1,136 @@
using Microsoft.Extensions.Caching.Memory;
using NetVips;
-namespace lilos_stitcher;
+namespace lilo_stitcher_console;
-public sealed record GenerateRequest(
- string Canvas_Rect,
- double[] Crop_Offset,
- double[] Crop_Size,
- double Output_Scale
+public record GenerateRequest(
+ string CanvasRect,
+ double[] CropOffset,
+ double[] CropSize,
+ double OutputScale
);
-public readonly record struct PlateCoordinate( int Row, int Col )
+public readonly record struct PlateCoordinate(int Row, int Col)
{
- public static PlateCoordinate Parse( string token )
- {
- if( string.IsNullOrWhiteSpace( token ) )
- throw new ArgumentException( "Empty coordinate." );
+ 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());
- 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 row = 0;
- foreach( char c in rowPart )
- row = row * 26 + ( c - 'A' + 1 );
-
- return new PlateCoordinate( row, int.Parse( colPart ) );
- }
+ int.TryParse(colPart, out int col);
+ return new PlateCoordinate(row, col);
+ }
}
-
-public class TileCache( IMemoryCache cache )
+public class TileCache(IMemoryCache cache)
{
- private const long TileBytes = 720L * 720 * 3;
+ private const long TileBytes = 720L * 720 * 3;
- public Image? Get( string key ) => cache.TryGetValue( key, out Image? img ) ? img : null;
+ 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 void Set(string key, Image img) =>
+ cache.Set(key, img, new MemoryCacheEntryOptions
+ {
+ Size = TileBytes,
+ SlidingExpiration = TimeSpan.FromMinutes(20)
+ });
}
-public class TileLoader( TileCache cache, string assetDir )
+public class TileLoader(TileCache cache, string assetDir)
{
- public async Task LoadAsync( string name, CancellationToken ct )
- {
- if( cache.Get( name ) is { } hit )
- return hit;
+ 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);
- var image = await Task.Run( () =>
- Image.NewFromFile( Path.Combine( assetDir, $"{name}.png" ), access: Enums.Access.Sequential ), ct );
-
- cache.Set( name, image );
- return image;
- }
+ cache.Set(name, image);
+ return image;
+ }
}
-public class LiloStitcher( TileLoader loader )
+public class LiloStitcher(TileLoader loader)
{
- public async Task CreateImageAsync( GenerateRequest req, CancellationToken ct )
+ public async Task CreateImageAsync(GenerateRequest req, CancellationToken ct)
{
- (int rowMin, int colMin, int rows, int cols) = ParseCanvas( req.Canvas_Rect );
+ (int rowMin, int colMin, int rows, int cols) = ParseCanvas(req.CanvasRect);
- 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 ) );
+ Validate(req.CropOffset, nameof(req.CropOffset));
+ Validate(req.CropSize, nameof(req.CropSize));
- var tiles = new List( rows * cols );
+ double scale = req.OutputScale;
+ if (scale <= 0 || scale > 1)
+ throw new ArgumentOutOfRangeException(nameof(req.OutputScale));
+
+ 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.Crop_Offset[0] * mosaic.Width );
- int offsetY = (int)Math.Truncate( req.Crop_Offset[1] * mosaic.Height );
+ int offsetX = (int)Math.Truncate(req.CropOffset[0] * mosaic.Width);
+ int offsetY = (int)Math.Truncate(req.CropOffset[1] * mosaic.Height);
int restWidth = mosaic.Width - offsetX;
int restHeight = mosaic.Height - offsetY;
- 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 cropWidth = Math.Max(1, (int)Math.Truncate(req.CropSize[0] * restWidth));
+ int cropHeight = Math.Max(1, (int)Math.Truncate(req.CropSize[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 cropped = mosaic.Crop( cropX, cropY, cropWidth, cropHeight );
+ var cropRect = mosaic.Crop(cropX, cropY, cropWidth, cropHeight);
- string path = Path.Combine(Path.GetTempPath(), $"mosaic-{Guid.NewGuid():N}.png");
- cropped.WriteToFile(path);
- return path;
+ string tmpPath = Path.Combine(Path.GetTempPath(), $"mosaic-{Guid.NewGuid():N}.png");
+ cropRect.WriteToFile(tmpPath);
+
+ return tmpPath;
}
- 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 5b60d67..7019819 100644
--- a/Program.cs
+++ b/Program.cs
@@ -1,20 +1,68 @@
-using lilos_stitcher;
+using lilo_stitcher_console;
+using System.Diagnostics;
+using Microsoft.Extensions.Caching.Memory;
-var builder = WebApplication.CreateBuilder(args);
+namespace LiloStitcher;
-NetVips.NetVips.Init();
-NetVips.NetVips.Concurrency = 3;
+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"
+ };
-builder.Services.AddControllers().AddJsonOptions(o => o.JsonSerializerOptions.PropertyNameCaseInsensitive = true);
+ 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.AddMemoryCache(o => o.SizeLimit = 128L * 1024 * 1024);
+ 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);
-string assetDir = Environment.GetEnvironmentVariable("ASSET_PATH_RO") ?? throw new InvalidOperationException("dir not found");
+ var req = new GenerateRequest(
+ opt.CanvasRect!,
+ opt.CropOffset!,
+ opt.CropSize!,
+ opt.OutputScale
+ );
-builder.Services.AddSingleton();
-builder.Services.AddSingleton(provider => new TileLoader(provider.GetRequiredService(), assetDir));
-builder.Services.AddSingleton();
+ 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)");
-var app = builder.Build();
-app.MapControllers();
-app.Run();
\ No newline at end of file
+ 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
diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json
deleted file mode 100644
index 23d5fa7..0000000
--- a/Properties/launchSettings.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "$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
deleted file mode 100644
index 1980fc5..0000000
--- a/README.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# 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
deleted file mode 100644
index 0c208ae..0000000
--- a/appsettings.Development.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft.AspNetCore": "Warning"
- }
- }
-}
diff --git a/appsettings.json b/appsettings.json
deleted file mode 100644
index 10f68b8..0000000
--- a/appsettings.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft.AspNetCore": "Warning"
- }
- },
- "AllowedHosts": "*"
-}
diff --git a/lilos-stitcher.csproj b/lilo-stitcher-console.csproj
similarity index 53%
rename from lilos-stitcher.csproj
rename to lilo-stitcher-console.csproj
index 2ced848..cd56a0f 100644
--- a/lilos-stitcher.csproj
+++ b/lilo-stitcher-console.csproj
@@ -1,19 +1,18 @@
-
+
+ Exe
net9.0
- enable
+ lilo_stitcher_console
enable
- lilos_stitcher
+ enable
preview
-
+
-
-
-
+
-
\ No newline at end of file
+
diff --git a/lilos-stitcher.sln b/lilo-stitcher-console.sln
similarity index 58%
rename from lilos-stitcher.sln
rename to lilo-stitcher-console.sln
index b15f5b6..2e5c9ca 100644
--- a/lilos-stitcher.sln
+++ b/lilo-stitcher-console.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}") = "lilos-stitcher", "lilos-stitcher.csproj", "{8FDFFCBC-9C8E-94D5-A96D-606027D71B44}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "lilo-stitcher-console", "lilo-stitcher-console.csproj", "{2A1F81C9-D10F-1AE9-CA5C-0714270F48C1}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -10,15 +10,15 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {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
+ {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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
- SolutionGuid = {8DD20971-C4B3-41BF-8263-D31D5D81631F}
+ SolutionGuid = {C7CD65D7-8035-4FC2-A584-06B21F732FF7}
EndGlobalSection
EndGlobal
diff --git a/lilos-stitcher.http b/lilos-stitcher.http
deleted file mode 100644
index 8e967ed..0000000
--- a/lilos-stitcher.http
+++ /dev/null
@@ -1,6 +0,0 @@
-@lilos_stitcher_HostAddress = http://localhost:5243
-
-GET {{lilos_stitcher_HostAddress}}/weatherforecast/
-Accept: application/json
-
-###
\ No newline at end of file
diff --git a/mise.toml b/mise.toml
deleted file mode 100644
index dca4d7a..0000000
--- a/mise.toml
+++ /dev/null
@@ -1,92 +0,0 @@
-# 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
-'''