This commit is contained in:
Renjaya Raga Zenta 2025-07-27 23:22:07 +07:00
parent 8b10c3b27e
commit 6333a95b25
2 changed files with 89 additions and 21 deletions

View file

@ -1,6 +1,7 @@
using System.Buffers; using System.Buffers;
using System.IO.Pipelines; using System.IO.Pipelines;
using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.Caching.Memory;
using NetVips; using NetVips;
using Oh.My.Stitcher; using Oh.My.Stitcher;
using Validation; using Validation;
@ -8,6 +9,7 @@ using ZLogger;
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args);
builder.Logging.ClearProviders().AddZLoggerConsole(); builder.Logging.ClearProviders().AddZLoggerConsole();
builder.Services.AddMemoryCache();
builder.Services.Configure<JsonOptions>(options => builder.Services.Configure<JsonOptions>(options =>
{ {
options.SerializerOptions.TypeInfoResolver = StitchSerializerContext.Default; options.SerializerOptions.TypeInfoResolver = StitchSerializerContext.Default;
@ -18,45 +20,71 @@ ILoggerFactory loggerFactory = app.Services.GetRequiredService<ILoggerFactory>()
ILogger logger = loggerFactory.CreateLogger<Program>(); ILogger logger = loggerFactory.CreateLogger<Program>();
string? tilesDirectory = Environment.GetEnvironmentVariable("ASSET_PATH_RO"); string? tilesDirectory = Environment.GetEnvironmentVariable("ASSET_PATH_RO");
string cacheDirectory = Path.Combine(Path.GetTempPath(), "oh-my-stitch");
Directory.CreateDirectory(cacheDirectory);
// sanity check // sanity check
Assumes.NotNullOrEmpty(tilesDirectory); Assumes.NotNullOrEmpty(tilesDirectory);
Assumes.True(File.Exists(Path.Combine(tilesDirectory, "A1.png"))); Assumes.True(File.Exists(Path.Combine(tilesDirectory, "A1.png")));
Assumes.True(File.Exists(Path.Combine(tilesDirectory, "AE55.png"))); Assumes.True(File.Exists(Path.Combine(tilesDirectory, "AE55.png")));
app.UseDefaultFiles(); app.UseDefaultFiles();
app.UseStaticFiles(); app.UseStaticFiles();
app.MapPost("/api/image/generate", (Stitch request) => app.MapPost("/api/image/generate", (Stitch request, IMemoryCache cache) =>
{ {
Pipe pipe = new(); Pipe pipe = new();
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
Image? image = null;
List<Image> images = []; List<Image> images = [];
try 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(); Pipe innerPipe = new();
_ = Task.Run(async () => _ = 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 ) while( true )
{ {
ReadResult result = await innerPipe.Reader.ReadAsync(); ReadResult result = await innerPipe.Reader.ReadAsync();
ReadOnlySequence<byte> buffer = result.Buffer; ReadOnlySequence<byte> buffer = result.Buffer;
if(!buffer.IsEmpty )
{ foreach( ReadOnlyMemory<byte> segment in buffer )
foreach( ReadOnlyMemory<byte> segment in buffer ) await Task.WhenAll(pipe.Writer.WriteAsync(segment).AsTask(), cacheStream.WriteAsync(segment).AsTask());
{
await pipe.Writer.WriteAsync(segment); innerPipe.Reader.AdvanceTo(buffer.End);
} if( result.IsCompleted )
innerPipe.Reader.AdvanceTo(buffer.End); break;
if (result.IsCompleted)
{
break;
}
}
} }
}); });
image.WriteToStream(innerPipe.Writer.AsStream(), ".png"); image?.WriteToStream(innerPipe.Writer.AsStream(), ".png");
} }
catch( Exception e ) catch( Exception e )
{ {
@ -66,6 +94,7 @@ app.MapPost("/api/image/generate", (Stitch request) =>
} }
finally finally
{ {
image?.Dispose();
foreach( Image img in images ) foreach( Image img in images )
img.Dispose(); img.Dispose();
await pipe.Writer.CompleteAsync(); await pipe.Writer.CompleteAsync();

View file

@ -1,5 +1,7 @@
// ReSharper disable ReplaceSliceWithRangeIndexer // ReSharper disable ReplaceSliceWithRangeIndexer
using System.Runtime.InteropServices;
using Microsoft.Extensions.Caching.Memory;
using NetVips; using NetVips;
using ZLogger; using ZLogger;
@ -7,7 +9,11 @@ namespace Oh.My.Stitcher;
public static class Tile public static class Tile
{ {
public static Image Create(in Stitch request, string tilesDirectory, List<Image> 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<Image> 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) ) if( !TryParseRect(request.CanvasRect, out int minRow, out int maxRow, out int minCol, out int maxCol) )
throw new ArgumentException($"Invalid canvas_rect: '{request.CanvasRect}'"); throw new ArgumentException($"Invalid canvas_rect: '{request.CanvasRect}'");
@ -15,17 +21,50 @@ public static class Tile
logger.ZLogDebug( logger.ZLogDebug(
$"rect: {request.CanvasRect}, minRow: {minRow}, maxRow: {maxRow}, minCol: {minCol}, maxCol: {maxCol}"); $"rect: {request.CanvasRect}, minRow: {minRow}, maxRow: {maxRow}, minCol: {minCol}, maxCol: {maxCol}");
int width = maxCol - minCol + 1; 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<byte> 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 row = minRow; row <= maxRow; row++ )
for( int col = minCol; col <= maxCol; col++ ) for( int col = minCol; col <= maxCol; col++ )
images.Add(Image.NewFromFile(FullPath(tilesDirectory, row, col))); images.Add(Image.NewFromFile(FullPath(tilesDirectory, row, col)));
using var canvasImage = Image.Arrayjoin(images.ToArray(), width); using var canvasImage = Image.Arrayjoin(images.ToArray(), width);
int cropLeft = (int)( canvasImage.Width * request.CropOffset.X ); int cropLeft = (int)( canvasImage.Width * cropOffsetX );
int cropTop = (int)( canvasImage.Height * request.CropOffset.Y ); int cropTop = (int)( canvasImage.Height * cropOffsetY );
int cropWidth = (int)( canvasImage.Width * request.CropSize.Width ); int cropWidth = (int)( canvasImage.Width * cropSizeW );
int cropHeight = (int)( canvasImage.Height * request.CropSize.Height ); 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) public static Image CreateError(Exception e)