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/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..7ac174f 100644 --- a/LiloStitcher.cs +++ b/LiloStitcher.cs @@ -1,137 +1,172 @@ -using Microsoft.Extensions.Caching.Memory; -using NetVips; - -namespace lilos_stitcher; - -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 static PlateCoordinate Parse( string token ) - { - 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 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 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 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 ); - } - } - - 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 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 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 ); - - 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 ) - { - 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 ) - { - 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 ) - { - row--; - stringBuilder.Insert( 0, (char)( 'A' + row % 26 ) ); - row /= 26; - } - return stringBuilder.ToString(); - } -} +using Microsoft.Extensions.Caching.Memory; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Formats.Png; + +namespace LiloStitcher; + +public record GenerateRequest( + string CanvasRect, + double[] CropOffset, + double[] CropSize, + double OutputScale +); + +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 + { + Size = TileBytes, + SlidingExpiration = TimeSpan.FromMinutes(20) + }); +} + +public class TileLoader(TileCache cache, string assetDir) +{ + public async Task> LoadAsync(string name, CancellationToken ct) + { + 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(fs, ct).ConfigureAwait(false); + cache.Set(name, image); + return image; + } +} + +public class LiloStitcher(TileLoader loader) +{ + private const int TileSize = 720; + + private static readonly GraphicsOptions _copy = new() + { + Antialias = false, + AlphaCompositionMode = PixelAlphaCompositionMode.Src, + BlendPercentage = 1f + }; + + public async Task 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 = 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); + + 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(cols * TileSize, rows * TileSize); + + stitched.Mutate(context => + { + int idx = 0; + for (int row = 0; row < rows; row++) + { + for (int col = 0; col < cols; col++, idx++) + { + context.DrawImage(bitmaps[idx], new Point(col * TileSize, row * TileSize), 1f); + } + } + }); + + Validate(req.CropOffset, 2, nameof(req.CropOffset), inclusiveUpper: true); + Validate(req.CropSize, 2, nameof(req.CropSize), inclusiveUpper: true); + + int offsetX = (int)Math.Truncate(req.CropOffset[0] * stitched.Width); + int offsetY = (int)Math.Truncate(req.CropOffset[1] * stitched.Height); + + int restWidth = stitched.Width - offsetX; + int restHeight = stitched.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 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 = new Rectangle(cropX, cropY, cropWidth, cropHeight); + using var cropped = stitched.Clone(context => context.Crop(cropRect)); + + double scale = Math.Clamp(req.OutputScale, 0.0, 1.0); + if (scale <= 0 || scale > 1) + throw new ArgumentOutOfRangeException(nameof(req.OutputScale), "OutputScale must be > 0 and ≤ 1.0"); + + Image 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(); + } + + using var memStream = new MemoryStream(); + await output.SaveAsync(memStream, new PngEncoder + { + CompressionLevel = PngCompressionLevel.Level1 + }, 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}]"); + } + } + + 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(); + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs index 5b60d67..253020f 100644 --- a/Program.cs +++ b/Program.cs @@ -1,20 +1,61 @@ -using lilos_stitcher; +using System.Diagnostics; +using Microsoft.Extensions.Caching.Memory; -var builder = WebApplication.CreateBuilder(args); +namespace LiloStitcher; +public static class Program +{ + public static async Task Main(string[] args) + { + try + { + Stopwatch sw = Stopwatch.StartNew(); + var begin = sw.ElapsedMilliseconds; + var opt = new Options + { + CanvasRect = "A1:AE55", + CropOffset = [0.4, 0.4], + CropSize = [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 = 256L * 1024 * 1024 }); + var tileCache = new TileCache(memCache); + var loader = new TileLoader(tileCache, assetDir); + var stitcher = new 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 pngBytes = await stitcher.CreateImageAsync(req, CancellationToken.None); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(provider => new TileLoader(provider.GetRequiredService(), assetDir)); -builder.Services.AddSingleton(); + File.WriteAllBytes(opt.OutputPath!, pngBytes); + Console.WriteLine($"Done. Wrote {opt.OutputPath} ({pngBytes.Length / 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/lilo-stitcher-console.csproj b/lilo-stitcher-console.csproj new file mode 100644 index 0000000..05566b9 --- /dev/null +++ b/lilo-stitcher-console.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + lilo_stitcher_console + enable + enable + + + + + + + + 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.csproj b/lilos-stitcher.csproj deleted file mode 100644 index 2ced848..0000000 --- a/lilos-stitcher.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - net9.0 - enable - enable - lilos_stitcher - preview - - - - - - - - - - - \ No newline at end of file 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 -'''