diff --git a/src/Oh.My.Stitcher/Program.cs b/src/Oh.My.Stitcher/Program.cs index 86b929a..cc76777 100644 --- a/src/Oh.My.Stitcher/Program.cs +++ b/src/Oh.My.Stitcher/Program.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.IO.Pipelines; using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.Caching.Memory; using NetVips; using Oh.My.Stitcher; using Validation; @@ -8,6 +9,7 @@ using ZLogger; WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); builder.Logging.ClearProviders().AddZLoggerConsole(); +builder.Services.AddMemoryCache(); builder.Services.Configure(options => { options.SerializerOptions.TypeInfoResolver = StitchSerializerContext.Default; @@ -18,45 +20,71 @@ ILoggerFactory loggerFactory = app.Services.GetRequiredService() ILogger logger = loggerFactory.CreateLogger(); string? tilesDirectory = Environment.GetEnvironmentVariable("ASSET_PATH_RO"); +string cacheDirectory = Path.Combine(Path.GetTempPath(), "oh-my-stitch"); +Directory.CreateDirectory(cacheDirectory); // sanity check Assumes.NotNullOrEmpty(tilesDirectory); Assumes.True(File.Exists(Path.Combine(tilesDirectory, "A1.png"))); Assumes.True(File.Exists(Path.Combine(tilesDirectory, "AE55.png"))); + app.UseDefaultFiles(); app.UseStaticFiles(); -app.MapPost("/api/image/generate", (Stitch request) => +app.MapPost("/api/image/generate", (Stitch request, IMemoryCache cache) => { Pipe pipe = new(); _ = Task.Run(async () => { + Image? image = null; List images = []; try { - using Image image = Tile.Create(in request, tilesDirectory, images, logger); + bool created = Tile.TryCreate(in request, tilesDirectory, images, logger, cache, out image, + out string? cacheKey, out string? cacheFile); + if( !created && cacheFile != null ) + { + logger.ZLogDebug($"cache hit key: {cacheKey}, file: {cacheFile}"); + await using FileStream cacheStream = new(cacheFile, FileMode.Open, FileAccess.Read, FileShare.Read); + await cacheStream.CopyToAsync(pipe.Writer.AsStream()); + return; + } + + if( cacheKey == null ) + { + image?.WriteToStream(pipe.Writer.AsStream(), ".png"); + return; + } + Pipe innerPipe = new(); _ = Task.Run(async () => { + string newCacheFile = Path.Combine(cacheDirectory, $"{cacheKey}.png"); + MemoryCacheEntryOptions cacheEntryOptions = new MemoryCacheEntryOptions() + .SetSlidingExpiration(TimeSpan.FromMinutes(10)) + .RegisterPostEvictionCallback((_, value, _, _) => + { + if( value is string path ) + File.Delete(path); + }); + logger.ZLogDebug($"save cache key: {cacheKey}, file: {newCacheFile}"); + cache.Set(cacheKey!, newCacheFile, cacheEntryOptions); + + await using FileStream cacheStream = new(newCacheFile, FileMode.Create, FileAccess.Write, FileShare.Read); while( true ) { ReadResult result = await innerPipe.Reader.ReadAsync(); ReadOnlySequence buffer = result.Buffer; - if(!buffer.IsEmpty ) - { - foreach( ReadOnlyMemory segment in buffer ) - { - await pipe.Writer.WriteAsync(segment); - } - innerPipe.Reader.AdvanceTo(buffer.End); - if (result.IsCompleted) - { - break; - } - } + + foreach( ReadOnlyMemory segment in buffer ) + await Task.WhenAll(pipe.Writer.WriteAsync(segment).AsTask(), cacheStream.WriteAsync(segment).AsTask()); + + innerPipe.Reader.AdvanceTo(buffer.End); + if( result.IsCompleted ) + break; } }); - image.WriteToStream(innerPipe.Writer.AsStream(), ".png"); + image?.WriteToStream(innerPipe.Writer.AsStream(), ".png"); } catch( Exception e ) { @@ -66,6 +94,7 @@ app.MapPost("/api/image/generate", (Stitch request) => } finally { + image?.Dispose(); foreach( Image img in images ) img.Dispose(); await pipe.Writer.CompleteAsync(); diff --git a/src/Oh.My.Stitcher/Tile.cs b/src/Oh.My.Stitcher/Tile.cs index 3400122..bba9651 100644 --- a/src/Oh.My.Stitcher/Tile.cs +++ b/src/Oh.My.Stitcher/Tile.cs @@ -1,5 +1,7 @@ // ReSharper disable ReplaceSliceWithRangeIndexer +using System.Runtime.InteropServices; +using Microsoft.Extensions.Caching.Memory; using NetVips; using ZLogger; @@ -7,7 +9,11 @@ namespace Oh.My.Stitcher; public static class Tile { - public static Image Create(in Stitch request, string tilesDirectory, List images, ILogger logger) + private const int TILE_SIZE = 720; + private const int MAX_CACHED_TILE_SIZE = TILE_SIZE * TILE_SIZE * 128; + + public static bool TryCreate(in Stitch request, string tilesDirectory, List images, ILogger logger, + IMemoryCache cache, out Image? image, out string? cacheKey, out string? cacheFile) { if( !TryParseRect(request.CanvasRect, out int minRow, out int maxRow, out int minCol, out int maxCol) ) throw new ArgumentException($"Invalid canvas_rect: '{request.CanvasRect}'"); @@ -15,17 +21,50 @@ public static class Tile logger.ZLogDebug( $"rect: {request.CanvasRect}, minRow: {minRow}, maxRow: {maxRow}, minCol: {minCol}, maxCol: {maxCol}"); int width = maxCol - minCol + 1; + int height = maxRow - minRow + 1; + + float cropOffsetX = request.CropOffset.X; + float cropOffsetY = request.CropOffset.Y; + float cropSizeW = request.CropSize.Width; + float cropSizeH = request.CropSize.Height; + float outputScale = request.OutputScale; + + image = null; + cacheKey = cacheFile = null; + + // predicted size + int predictedW = (int)( width * TILE_SIZE * cropSizeW * outputScale ); + int predictedH = (int)( height * TILE_SIZE * cropSizeH * outputScale ); + bool canBeCached = ( predictedW * predictedH ) <= MAX_CACHED_TILE_SIZE; + if( canBeCached ) + { + Span buffer = stackalloc byte[36]; + MemoryMarshal.Write(buffer, in minRow); + MemoryMarshal.Write(buffer[4..], in maxRow); + MemoryMarshal.Write(buffer[8..], in minCol); + MemoryMarshal.Write(buffer[12..], in maxCol); + MemoryMarshal.Write(buffer[16..], in cropOffsetX); + MemoryMarshal.Write(buffer[20..], in cropOffsetY); + MemoryMarshal.Write(buffer[24..], in cropSizeW); + MemoryMarshal.Write(buffer[28..], in cropSizeH); + MemoryMarshal.Write(buffer[32..], in outputScale); + cacheKey = Convert.ToHexString(buffer).ToLowerInvariant(); + if( cache.TryGetValue(cacheKey, out cacheFile) && File.Exists(cacheFile) ) + return false; + } + for( int row = minRow; row <= maxRow; row++ ) for( int col = minCol; col <= maxCol; col++ ) images.Add(Image.NewFromFile(FullPath(tilesDirectory, row, col))); using var canvasImage = Image.Arrayjoin(images.ToArray(), width); - int cropLeft = (int)( canvasImage.Width * request.CropOffset.X ); - int cropTop = (int)( canvasImage.Height * request.CropOffset.Y ); - int cropWidth = (int)( canvasImage.Width * request.CropSize.Width ); - int cropHeight = (int)( canvasImage.Height * request.CropSize.Height ); + int cropLeft = (int)( canvasImage.Width * cropOffsetX ); + int cropTop = (int)( canvasImage.Height * cropOffsetY ); + int cropWidth = (int)( canvasImage.Width * cropSizeW ); + int cropHeight = (int)( canvasImage.Height * cropSizeH ); - return canvasImage.Crop(cropLeft, cropTop, cropWidth, cropHeight).Resize(request.OutputScale); + image = canvasImage.Crop(cropLeft, cropTop, cropWidth, cropHeight).Resize(outputScale); + return true; } public static Image CreateError(Exception e)