diff --git a/AutoMask/App.axaml.cs b/AutoMask/App.axaml.cs index 64f8d02..88d0a9d 100644 --- a/AutoMask/App.axaml.cs +++ b/AutoMask/App.axaml.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Avalonia.Threading; namespace AutoSplit_AutoMask; @@ -16,6 +17,31 @@ public override void OnFrameworkInitializationCompleted() if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow = new MainWindow(); + + // Catches uncaught exceptions raised on the UI dispatcher (including async-void + // event handlers' continuations). Setting Handled = true keeps the app running + // instead of bubbling to AppDomain.UnhandledException and terminating; the user + // gets a MessageBox and can retry the action that failed. + Dispatcher.UIThread.UnhandledException += (_, e) => + { + Utils.LogCrashToDisk(e.Exception, "Dispatcher.UnhandledException"); + + try + { + if (desktop.MainWindow is { IsVisible: true } mainWindow) + { + _ = MessageBox.Show(mainWindow, "Something went wrong", e.Exception.Message); + } + } + catch + { + // Avalonia's documentation explicitly warns against allocating or + // performing resource-heavy work in this handler — secondary failures + // here can't be reported anywhere useful. + } + + e.Handled = true; + }; } base.OnFrameworkInitializationCompleted(); diff --git a/AutoMask/AppJsonContext.cs b/AutoMask/AppJsonContext.cs index 9fc8fdf..d1a4018 100644 --- a/AutoMask/AppJsonContext.cs +++ b/AutoMask/AppJsonContext.cs @@ -2,9 +2,15 @@ namespace AutoSplit_AutoMask; +// Nested generic and element types are registered explicitly so trimming/AOT can't remove +// reflection metadata the source-gen serializer relies on. Keep this in sync with any new +// model field types. [JsonSerializable(typeof(SplitPreset))] +[JsonSerializable(typeof(Split))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(PremadeSplitsFile))] [JsonSerializable(typeof(PremadeSplit))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(CapturePreferences))] [JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] internal partial class AppJsonContext : JsonSerializerContext; diff --git a/AutoMask/AutoMask.csproj b/AutoMask/AutoMask.csproj index eacc403..1d6ce2e 100644 --- a/AutoMask/AutoMask.csproj +++ b/AutoMask/AutoMask.csproj @@ -13,7 +13,7 @@ true $(NoWarn);AVLN3001 Assets/icon.ico - 0.9.2-alpha + 0.9.3-alpha false diff --git a/AutoMask/Capture/BitBltCaptureBase.cs b/AutoMask/Capture/BitBltCaptureBase.cs index 4799609..d0c9f80 100644 --- a/AutoMask/Capture/BitBltCaptureBase.cs +++ b/AutoMask/Capture/BitBltCaptureBase.cs @@ -5,8 +5,6 @@ namespace AutoSplit_AutoMask.Capture; -// Shared plumbing for window + region capture. Each BitBlt grabs the current subject rect into -// a reusable GDI compatible bitmap, then GetDIBits copies it into a SkiaSharp-backed buffer. [SupportedOSPlatform("windows")] public abstract class BitBltCaptureBase : ICaptureSource { @@ -42,22 +40,33 @@ public bool TryGrabFrame(out SKBitmap? frame) return false; } - if (w <= 0 || h <= 0) + // try/finally so any throw between acquire and release (EnsureBuffers GDI exhaustion, + // BitBlt fault, allocator OOM) doesn't strand srcDc. ReleaseDC must run for caret-DC + // and window-DC paths or the system DC pool fills up. + bool blitOk; + try { - Win32.ReleaseDC(owningHwnd, srcDc); - return false; - } - - SourceWidth = w; - SourceHeight = h; + if (w <= 0 || h <= 0) + { + return false; + } - EnsureBuffers(srcDc, w, h); + SourceWidth = w; + SourceHeight = h; - bool ok = Win32.BitBlt(_memDc, 0, 0, w, h, srcDc, ox, oy, Win32.SRCCOPY | Win32.CAPTUREBLT); + if (!EnsureBuffers(srcDc, w, h)) + { + return false; + } - Win32.ReleaseDC(owningHwnd, srcDc); + blitOk = Win32.BitBlt(_memDc, 0, 0, w, h, srcDc, ox, oy, Win32.SRCCOPY | Win32.CAPTUREBLT); + } + finally + { + Win32.ReleaseDC(owningHwnd, srcDc); + } - if (!ok) + if (!blitOk) { return false; } @@ -87,6 +96,11 @@ public bool TryGrabFrame(out SKBitmap? frame) if (scans == 0) { + // Drop the bitmap so a transient GetDIBits failure (e.g. driver glitch on + // resolution change) doesn't leave a half-populated buffer that a later + // size-match call would skip re-allocating. + _frame.Dispose(); + _frame = null; return false; } @@ -94,20 +108,42 @@ public bool TryGrabFrame(out SKBitmap? frame) return true; } - private void EnsureBuffers(IntPtr srcDc, int w, int h) + private bool EnsureBuffers(IntPtr srcDc, int w, int h) { if (_memDc != IntPtr.Zero && w == _allocatedWidth && h == _allocatedHeight) { - return; + return true; } ReleaseGdiHandles(); - _memDc = Win32.CreateCompatibleDC(srcDc); - _gdiBitmap = Win32.CreateCompatibleBitmap(srcDc, w, h); - _oldObject = Win32.SelectObject(_memDc, _gdiBitmap); + IntPtr memDc = Win32.CreateCompatibleDC(srcDc); + if (memDc == IntPtr.Zero) + { + return false; + } + + IntPtr gdiBitmap = Win32.CreateCompatibleBitmap(srcDc, w, h); + if (gdiBitmap == IntPtr.Zero) + { + Win32.DeleteDC(memDc); + return false; + } + + IntPtr oldObject = Win32.SelectObject(memDc, gdiBitmap); + if (oldObject == IntPtr.Zero) + { + Win32.DeleteObject(gdiBitmap); + Win32.DeleteDC(memDc); + return false; + } + + _memDc = memDc; + _gdiBitmap = gdiBitmap; + _oldObject = oldObject; _allocatedWidth = w; _allocatedHeight = h; + return true; } private void ReleaseGdiHandles() diff --git a/AutoMask/Capture/CaptureController.cs b/AutoMask/Capture/CaptureController.cs index fcd02f1..bfdf3c8 100644 --- a/AutoMask/Capture/CaptureController.cs +++ b/AutoMask/Capture/CaptureController.cs @@ -12,6 +12,15 @@ public sealed class CaptureController : IAsyncDisposable public readonly record struct CropRect(int X, int Y, int W, int H); + // Immutable snapshot of all fields that the UI thread writes and the capture loop reads. + // Swapped as a single reference so the loop can never observe a torn (refPixels, refMask) + // pair from different reference images. + private sealed record CaptureState( + byte[]? RefPixels, + byte[]? RefMask, + double Required, + CropRect Crop); + // Pixel buffer is BGRA, tightly packed, CompareWidth * CompareHeight * 4 bytes. public event Action? FrameReady; public event Action? ErrorReported; @@ -23,42 +32,64 @@ public sealed class CaptureController : IAsyncDisposable private ICaptureSource? _active; - private byte[]? _refPixels; - private byte[]? _refMask; - private double _required; - - private CropRect _crop; + private CaptureState _state = new(null, null, 0.0, new CropRect(0, 0, 0, 0)); private double _highest; private int _uiPostPending; + // Double-buffer the live pixels so the capture loop can refill one buffer while the + // UI handler still holds the previous one. _uiPostPending gates a single post in flight, + // so two buffers are sufficient. Allocated once at construction; no per-frame GC churn. + private readonly byte[][] _frameBuffers = + { + new byte[CompareWidth * CompareHeight * 4], + new byte[CompareWidth * CompareHeight * 4], + }; + private int _writeIndex; + public void UpdateReference(byte[]? refPixels, byte[]? refMask, double required) { - _refPixels = refPixels; - _refMask = refMask; - _required = required; - _highest = 0.0; + UpdateState(s => s with { RefPixels = refPixels, RefMask = refMask, Required = required }); + Interlocked.Exchange(ref _highest, 0.0); } - public void UpdateCrop(CropRect rect) => _crop = rect; + public void UpdateCrop(CropRect rect) + { + UpdateState(s => s with { Crop = rect }); + } - public void ResetHighest() => _highest = 0.0; + public void ResetHighest() => Interlocked.Exchange(ref _highest, 0.0); + + private void UpdateState(Func mutate) + { + while (true) + { + var current = Volatile.Read(ref _state); + var next = mutate(current); + if (Interlocked.CompareExchange(ref _state, next, current) == current) + { + return; + } + } + } + + private static readonly TimeSpan SourceShutdownTimeout = TimeSpan.FromSeconds(5); public async Task SetSourceAsync(ICaptureSource? newSource, CancellationToken ct) { await _swapLock.WaitAsync(ct); try { - if (_active is not null) + var current = Volatile.Read(ref _active); + if (current is not null) { - try { await _active.StopAsync(); } catch { /* ignore */ } - try { await _active.DisposeAsync(); } catch { /* ignore */ } - _active = null; + Volatile.Write(ref _active, null); + await ShutdownSourceAsync(current); } if (newSource is not null) { await newSource.StartAsync(ct); - _active = newSource; + Volatile.Write(ref _active, newSource); } } finally @@ -67,6 +98,45 @@ public async Task SetSourceAsync(ICaptureSource? newSource, CancellationToken ct } } + private async Task ShutdownSourceAsync(ICaptureSource source) + { + // Bounded shutdown so a hung native source (OpenCV deadlock during webcam release, + // GDI driver stall) doesn't block the swap forever and prevent the user from + // switching to a working source. Errors and timeouts surface via ErrorReported so + // the UI can show why the previous source might still be holding resources. + try + { + await source.StopAsync().WaitAsync(SourceShutdownTimeout); + } + catch (TimeoutException) + { + ReportShutdownIssue(source, "stop timed out"); + } + catch (Exception ex) + { + ReportShutdownIssue(source, $"stop failed: {ex.Message}"); + } + + try + { + await source.DisposeAsync().AsTask().WaitAsync(SourceShutdownTimeout); + } + catch (TimeoutException) + { + ReportShutdownIssue(source, "dispose timed out"); + } + catch (Exception ex) + { + ReportShutdownIssue(source, $"dispose failed: {ex.Message}"); + } + } + + private void ReportShutdownIssue(ICaptureSource source, string detail) + { + var msg = $"Capture source '{source.DisplayName}' {detail}"; + Dispatcher.UIThread.Post(() => ErrorReported?.Invoke(msg)); + } + public void Start() { if (_thread is not null) @@ -96,18 +166,19 @@ public void Stop() private void Loop(CancellationToken ct) { double frameMs = 1000.0 / TargetFps; - var sw = Stopwatch.StartNew(); + var stopwatch = Stopwatch.StartNew(); double nextDueMs = 0.0; while (!ct.IsCancellationRequested) { - ICaptureSource? src = _active; - byte[]? refPixels = _refPixels; - byte[]? refMask = _refMask; - double required = _required; - CropRect crop = _crop; - - if (src is null) + ICaptureSource? source = Volatile.Read(ref _active); + CaptureState state = Volatile.Read(ref _state); + byte[]? refPixels = state.RefPixels; + byte[]? refMask = state.RefMask; + double required = state.Required; + CropRect crop = state.Crop; + + if (source is null) { Thread.Sleep(20); continue; @@ -115,7 +186,7 @@ private void Loop(CancellationToken ct) try { - if (!src.TryGrabFrame(out var raw) || raw is null) + if (!source.TryGrabFrame(out var raw) || raw is null) { Thread.Sleep(2); continue; @@ -128,22 +199,24 @@ private void Loop(CancellationToken ct) continue; } - double cur = 0; - double high = _highest; + double currentSimilarity = 0; + double highestSimilarity = Volatile.Read(ref _highest); - byte[] livePixels = ReadBgraBytes(scaled); + byte[] livePixels = _frameBuffers[_writeIndex]; + if (!ReadBgraBytesInto(scaled, livePixels)) + { + Thread.Sleep(2); + continue; + } - if (refPixels is not null && refMask is not null) + if (refPixels is not null && refMask is not null + && refPixels.Length == livePixels.Length + && refMask.Length * 4 == livePixels.Length) { double similarity = Comparison.L2NormComparer.Compare(refPixels, refMask, livePixels); - if (similarity > _highest) - { - _highest = similarity; - } - - cur = similarity; - high = _highest; + highestSimilarity = UpdateHighest(similarity); + currentSimilarity = similarity; } if (Interlocked.CompareExchange(ref _uiPostPending, 1, 0) == 0) @@ -155,7 +228,7 @@ private void Loop(CancellationToken ct) { try { - FrameReady?.Invoke(frameBuffer, cur, high, required); + FrameReady?.Invoke(frameBuffer, currentSimilarity, highestSimilarity, required); } finally { @@ -163,6 +236,10 @@ private void Loop(CancellationToken ct) } }, DispatcherPriority.Background); + + // Flip only on a successful post so the UI handler keeps exclusive access + // to the buffer it received until it sets _uiPostPending back to 0. + _writeIndex ^= 1; } } catch (Exception ex) @@ -172,7 +249,7 @@ private void Loop(CancellationToken ct) Thread.Sleep(200); } - double elapsed = sw.Elapsed.TotalMilliseconds; + double elapsed = stopwatch.Elapsed.TotalMilliseconds; nextDueMs += frameMs; double sleep = nextDueMs - elapsed; if (sleep > 1) @@ -206,31 +283,65 @@ private void Loop(CancellationToken ct) new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None)); } - // Paint the source into a fixed w×h canvas at offset (−X, −Y) so the crop size - // defines the output rectangle and parts outside the source stay black. - using var canvasBitmap = new SKBitmap( - new SKImageInfo(w, h, SKColorType.Bgra8888, SKAlphaType.Opaque)); - using (var canvas = new SKCanvas(canvasBitmap)) + // Single-pass crop+scale: draw the crop rect of the source straight into the + // output bitmap. Skia clamps src rects that fall outside source bounds and the + // pre-cleared black background fills any uncovered area, matching the previous + // canvas-then-resize behavior without the intermediate w×h SKBitmap allocation. + var output = new SKBitmap(new SKImageInfo(outW, outH, SKColorType.Bgra8888, SKAlphaType.Opaque)); + using (var canvas = new SKCanvas(output)) + using (var sourceImage = SKImage.FromBitmap(source)) { canvas.Clear(SKColors.Black); - canvas.DrawBitmap(source, -crop.X, -crop.Y); + var srcRect = new SKRect(crop.X, crop.Y, crop.X + w, crop.Y + h); + var dstRect = new SKRect(0, 0, outW, outH); + canvas.DrawImage(sourceImage, srcRect, dstRect, + new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None)); } + return output; + } - return canvasBitmap.Resize( - new SKImageInfo(outW, outH, SKColorType.Bgra8888, SKAlphaType.Opaque), - new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None)); + private double UpdateHighest(double similarity) + { + while (true) + { + double current = Volatile.Read(ref _highest); + if (similarity <= current) + { + return current; + } + if (Interlocked.CompareExchange(ref _highest, similarity, current) == current) + { + return similarity; + } + } } - private static byte[] ReadBgraBytes(SKBitmap bitmap) + private static bool ReadBgraBytesInto(SKBitmap bitmap, byte[] destination) { int byteCount = bitmap.ByteCount; - byte[] result = new byte[byteCount]; - System.Runtime.InteropServices.Marshal.Copy(bitmap.GetPixels(), result, 0, byteCount); - return result; + if (byteCount != destination.Length) + { + // Source resized between iterations or a non-target format slipped through; + // skip this frame rather than tear the destination buffer. + return false; + } + System.Runtime.InteropServices.Marshal.Copy(bitmap.GetPixels(), destination, 0, byteCount); + return true; } + private int _disposed; + public async ValueTask DisposeAsync() { + // Idempotent: a second call would await the already-disposed _swapLock and throw + // ObjectDisposedException. The TestOutputWindow Closing handler can fire twice + // when the OS issues simultaneous close requests (Windows taskbar 'Close all + // windows'), so the controller has to tolerate it. + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + Stop(); await SetSourceAsync(null, CancellationToken.None); _swapLock.Dispose(); diff --git a/AutoMask/Capture/WebcamCapture.cs b/AutoMask/Capture/WebcamCapture.cs index 57845fd..8969ebb 100644 --- a/AutoMask/Capture/WebcamCapture.cs +++ b/AutoMask/Capture/WebcamCapture.cs @@ -14,10 +14,12 @@ public sealed class CamDeviceInfo [SupportedOSPlatform("windows")] public sealed class WebcamCapture : ICaptureSource { + public event Action? ErrorReported; + private readonly CamDeviceInfo _device; private readonly string _displayName; - private VideoCapture? _cap; + private VideoCapture? _videoCapture; private Thread? _thread; private CancellationTokenSource? _cts; @@ -75,20 +77,20 @@ public static Task> EnumerateDevicesAsync() public Task StartAsync(CancellationToken ct) { - _cap = new VideoCapture(_device.Index, VideoCaptureAPIs.DSHOW); - if (!_cap.IsOpened()) + _videoCapture = new VideoCapture(_device.Index, VideoCaptureAPIs.DSHOW); + if (!_videoCapture.IsOpened()) { - _cap.Dispose(); - _cap = null; + _videoCapture.Dispose(); + _videoCapture = null; throw new InvalidOperationException($"Could not open webcam: {_device.Name}"); } - _cap.Set(VideoCaptureProperties.FrameWidth, 1920); - _cap.Set(VideoCaptureProperties.FrameHeight, 1080); - _cap.Set(VideoCaptureProperties.Fps, 60); + _videoCapture.Set(VideoCaptureProperties.FrameWidth, 1920); + _videoCapture.Set(VideoCaptureProperties.FrameHeight, 1080); + _videoCapture.Set(VideoCaptureProperties.Fps, 60); - _widthPx = (int)_cap.Get(VideoCaptureProperties.FrameWidth); - _heightPx = (int)_cap.Get(VideoCaptureProperties.FrameHeight); + _widthPx = (int)_videoCapture.Get(VideoCaptureProperties.FrameWidth); + _heightPx = (int)_videoCapture.Get(VideoCaptureProperties.FrameHeight); if (_widthPx <= 0 || _heightPx <= 0) { _widthPx = 640; @@ -114,14 +116,19 @@ private void CaptureLoop() { try { - if (!_cap!.Read(frame) || frame.Empty()) + if (!_videoCapture!.Read(frame) || frame.Empty()) { Thread.Sleep(5); continue; } } - catch + catch (Exception ex) { + // OpenCV native errors (device unplugged, codec failure, OOM during + // frame allocation) — surface to UI so the live tester can show why + // the feed stopped instead of silently freezing. + var msg = $"Webcam read failed: {ex.Message}"; + Avalonia.Threading.Dispatcher.UIThread.Post(() => ErrorReported?.Invoke(msg)); break; } @@ -139,20 +146,20 @@ private unsafe void CopyBgrMatToLatest(Mat frame) } var target = new SKBitmap(new SKImageInfo(w, h, SKColorType.Bgra8888, SKAlphaType.Opaque)); - byte* dst = (byte*)target.GetPixels(); + byte* targetPixels = (byte*)target.GetPixels(); for (int y = 0; y < h; y++) { - byte* s = (byte*)frame.Ptr(y); - byte* d = dst + (long)y * w * 4; + byte* sourceRow = (byte*)frame.Ptr(y); + byte* targetRow = targetPixels + (long)y * w * 4; for (int x = 0; x < w; x++) { - d[0] = s[0]; - d[1] = s[1]; - d[2] = s[2]; - d[3] = 255; - s += 3; - d += 4; + targetRow[0] = sourceRow[0]; + targetRow[1] = sourceRow[1]; + targetRow[2] = sourceRow[2]; + targetRow[3] = 255; + sourceRow += 3; + targetRow += 4; } } @@ -199,17 +206,16 @@ public Task StopAsync() { _cts?.Cancel(); } - catch + catch (ObjectDisposedException) { - // ignored } _thread?.Join(1000); _thread = null; - _cap?.Release(); - _cap?.Dispose(); - _cap = null; + _videoCapture?.Release(); + _videoCapture?.Dispose(); + _videoCapture = null; return Task.CompletedTask; } diff --git a/AutoMask/Comparison/L2NormComparer.cs b/AutoMask/Comparison/L2NormComparer.cs index 91d9fe7..3152c74 100644 --- a/AutoMask/Comparison/L2NormComparer.cs +++ b/AutoMask/Comparison/L2NormComparer.cs @@ -1,3 +1,7 @@ +using System.Numerics; +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.X86; + namespace AutoSplit_AutoMask.Comparison; public static class L2NormComparer @@ -10,35 +14,132 @@ public static class L2NormComparer // Comparison is symmetric under channel permutation, so BGRA and RGBA produce identical results. public static double Compare(byte[] refPixels, byte[] refMask, byte[] livePixels) { - if (refMask.Length == 0) + int n = refMask.Length; + if (n == 0) + { + return 0.0; + } + + long sumSq; + int maskCount; + + // The AVX2 fast path consumes 8 mask bytes / 32 pixel bytes per iteration. Capture + // sizes (320×240 = 76800 mask bytes, divisible by 8) hit this exclusively in practice. + if (Avx2.IsSupported && Ssse3.IsSupported && n >= 8 + && refPixels.Length >= n * 4 && livePixels.Length >= n * 4) + { + (sumSq, maskCount) = CompareAvx2(refPixels, refMask, livePixels); + } + else + { + (sumSq, maskCount) = CompareScalar(refPixels, refMask, livePixels, 0, n); + } + + if (maskCount == 0) { return 0.0; } + double error = Math.Sqrt((double)sumSq); + double maxError = Math.Sqrt(maskCount * 3.0 * 255.0 * 255.0); + return 1.0 - error / maxError; + } + + private static (long sumSq, int maskCount) CompareScalar( + byte[] refPixels, byte[] refMask, byte[] livePixels, int start, int end) + { long sumSq = 0; int maskCount = 0; - - for (int i = 0, px = 0; i < refMask.Length; i++, px += 4) + for (int i = start, px = start * 4; i < end; i++, px += 4) { if (refMask[i] == 0) { continue; } - maskCount++; int d0 = refPixels[px] - livePixels[px]; int d1 = refPixels[px + 1] - livePixels[px + 1]; int d2 = refPixels[px + 2] - livePixels[px + 2]; sumSq += d0 * d0 + d1 * d1 + d2 * d2; } + return (sumSq, maskCount); + } - if (maskCount == 0) + private static unsafe (long sumSq, int maskCount) CompareAvx2( + byte[] refPixels, byte[] refMask, byte[] livePixels) + { + int n = refMask.Length; + int simdEnd = n - (n % 8); + + // PSHUFB index vectors: replicate each of 8 source mask bytes by 4 to cover all 4 + // BGRA bytes of its pixel. PSHUFB operates inside each 128-bit lane, so the source + // half is duplicated below into both halves of a 128-bit register. + var lowExpand = Vector128.Create( + (byte)0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3); + var highExpand = Vector128.Create( + (byte)4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6, 7, 7, 7, 7); + // Zero alpha bytes so the L2 sum stays BGR-only, matching the scalar formula even + // when ref alpha differs from live alpha in masked-out regions. + var alphaKill = Vector256.Create( + (byte)0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0x00, + 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0x00, + 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0x00, + 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0x00); + + Vector256 sumVec = Vector256.Zero; + int maskCount = 0; + + fixed (byte* refP = refPixels, mP = refMask, livP = livePixels) { - return 0.0; + for (int i = 0; i < simdEnd; i += 8) + { + ulong m8 = *(ulong*)(mP + i); + // Mask bytes are exactly 0x00 or 0xFF, so 8 active bytes contribute 64 bits. + maskCount += BitOperations.PopCount(m8) >> 3; + + // Broadcast 8 mask bytes -> 32 bytes (one Vector256 covering 8 BGRA pixels). + var maskV128 = Vector128.Create(m8, m8).AsByte(); + var lowLane = Ssse3.Shuffle(maskV128, lowExpand); + var highLane = Ssse3.Shuffle(maskV128, highExpand); + var pixelActive = Vector256.Create(lowLane, highLane); + + var laneMask = Avx2.And(pixelActive, alphaKill); + + int px = i * 4; + var refV = Avx.LoadVector256(refP + px); + var livV = Avx.LoadVector256(livP + px); + + // |refV - livV| via two saturating subtractions OR'd together. + var sub1 = Avx2.SubtractSaturate(refV, livV); + var sub2 = Avx2.SubtractSaturate(livV, refV); + var diffAbs = Avx2.Or(sub1, sub2); + + var diffMasked = Avx2.And(diffAbs, laneMask); + + // Widen byte -> ushort (per 128-bit lane), then PMADDWD to square-and-sum + // adjacent pairs into 32-bit lanes. + var lo = Avx2.UnpackLow(diffMasked, Vector256.Zero).AsInt16(); + var hi = Avx2.UnpackHigh(diffMasked, Vector256.Zero).AsInt16(); + var sqLo = Avx2.MultiplyAddAdjacent(lo, lo); + var sqHi = Avx2.MultiplyAddAdjacent(hi, hi); + var perChunk = Avx2.Add(sqLo, sqHi); + + // Promote int -> long every iteration; an int accumulator overflows around + // ~1300 chunks (76800-pixel comparisons run 9600). + sumVec = Avx2.Add(sumVec, Avx2.ConvertToVector256Int64(perChunk.GetLower())); + sumVec = Avx2.Add(sumVec, Avx2.ConvertToVector256Int64(perChunk.GetUpper())); + } } - double error = Math.Sqrt((double)sumSq); - double maxError = Math.Sqrt(maskCount * 3.0 * 255.0 * 255.0); - return 1.0 - error / maxError; + long sumSq = Vector256.Sum(sumVec); + + if (simdEnd < n) + { + var (tailSum, tailCount) = CompareScalar(refPixels, refMask, livePixels, simdEnd, n); + sumSq += tailSum; + maskCount += tailCount; + } + + return (sumSq, maskCount); } } diff --git a/AutoMask/ImageProcessor.cs b/AutoMask/ImageProcessor.cs index b6e12b2..a4f7532 100644 --- a/AutoMask/ImageProcessor.cs +++ b/AutoMask/ImageProcessor.cs @@ -9,44 +9,81 @@ public static class ImageProcessor { public static SKBitmap ApplyScaledAlphaChannel(string inputPath, string alphaPath, Dictionary maskCache) { - using var inputBitmap = SKBitmap.Decode(inputPath); - - bool ownAlpha = !maskCache.TryGetValue(alphaPath, out var alphaBitmap); - alphaBitmap ??= SKBitmap.Decode(alphaPath); + using var rawInput = SKBitmap.Decode(inputPath); + // Native blit if the decoder picked a non-BGRA layout (rare for PNG on Windows, + // possible for JPEG). Cheaper than the three SKColor[] copies the previous version + // performed and avoids managed allocations of (width*height*4) bytes per call. + using var inputDisposable = rawInput.ColorType == SKColorType.Bgra8888 + ? null + : rawInput.Copy(SKColorType.Bgra8888); + SKBitmap inputBitmap = inputDisposable ?? rawInput; + + SKBitmap alphaBitmap; + // Decode under lock so concurrent callers (multiple Task.Run consumers) can't both + // decode the same mask and leak one. Dictionary<,> is not thread-safe to read during + // a writer either, so the lookup must also be inside the lock. + lock (maskCache) + { + if (!maskCache.TryGetValue(alphaPath, out var cached)) + { + cached = SKBitmap.Decode(alphaPath); + maskCache[alphaPath] = cached; + } + alphaBitmap = cached; + } - using var scaledAlpha = alphaBitmap!.Resize( - new SKImageInfo(inputBitmap.Width, inputBitmap.Height), + // Resizing into an explicit BGRA8888 SKImageInfo guarantees the byte loop's layout. + using var scaledAlpha = alphaBitmap.Resize( + new SKImageInfo(inputBitmap.Width, inputBitmap.Height, SKColorType.Bgra8888), new SKSamplingOptions(SKFilterMode.Linear))!; int width = inputBitmap.Width; int height = inputBitmap.Height; - var outputBitmap = new SKBitmap(width, height); + var outputBitmap = new SKBitmap( + new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Unpremul)); - SKColor[] inputPixels = inputBitmap.Pixels; - SKColor[] alphaPixels = scaledAlpha.Pixels; - SKColor[] outputPixels = new SKColor[width * height]; + ApplyAlphaThreshold(inputBitmap, scaledAlpha, outputBitmap, width, height); + + return outputBitmap; + } + + private static unsafe void ApplyAlphaThreshold(SKBitmap input, SKBitmap alpha, SKBitmap output, + int width, int height) + { + IntPtr inPtr = input.GetPixels(); + IntPtr alpPtr = alpha.GetPixels(); + IntPtr outPtr = output.GetPixels(); + int rowIn = input.RowBytes; + int rowAlp = alpha.RowBytes; + int rowOut = output.RowBytes; Parallel.For(0, height, y => { - int row = y * width; - for (int x = 0; x < width; x++) + unsafe { - int i = row + x; - var src = inputPixels[i]; - outputPixels[i] = alphaPixels[i].Alpha == 255 - ? new SKColor(src.Red, src.Green, src.Blue) - : SKColors.Transparent; + byte* inRow = (byte*)inPtr + y * rowIn; + byte* alpRow = (byte*)alpPtr + y * rowAlp; + byte* outRow = (byte*)outPtr + y * rowOut; + for (int x = 0; x < width; x++) + { + int o = x * 4; + if (alpRow[o + 3] == 255) + { + // Hard threshold: keep input pixel only when scaled mask is fully opaque. + // Resize with linear sampling produces partial alphas at edges, which the + // original SKColor[] loop also rejected. + outRow[o] = inRow[o]; + outRow[o + 1] = inRow[o + 1]; + outRow[o + 2] = inRow[o + 2]; + outRow[o + 3] = 255; + } + else + { + *(uint*)(outRow + o) = 0u; + } + } } }); - - outputBitmap.Pixels = outputPixels; - - if (ownAlpha) - { - alphaBitmap.Dispose(); - } - - return outputBitmap; } public static Bitmap CreateCheckerBitmap(int width, int height) diff --git a/AutoMask/ImportSplitsDialog.axaml.cs b/AutoMask/ImportSplitsDialog.axaml.cs index 329e649..bc410d3 100644 --- a/AutoMask/ImportSplitsDialog.axaml.cs +++ b/AutoMask/ImportSplitsDialog.axaml.cs @@ -125,9 +125,9 @@ private Border BuildSplitCard(PremadeSplitsFile splitsFile, PremadeSplit split) Grid.SetColumn(imageBorder, 1); cardContent.Children.Add(imageBorder); } - catch + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) { - // Image failed to load - skip thumbnail + Utils.LogError($"ImportSplitsDialog: failed to load thumbnail '{baseImagePath}': {ex.Message}"); } } } diff --git a/AutoMask/MainWindow.axaml.cs b/AutoMask/MainWindow.axaml.cs index de7aaba..6f1658f 100644 --- a/AutoMask/MainWindow.axaml.cs +++ b/AutoMask/MainWindow.axaml.cs @@ -1,6 +1,5 @@ using System.Collections.Concurrent; using System.Collections.ObjectModel; -using System.Diagnostics; using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Input; @@ -54,12 +53,9 @@ public MainWindow() _splitPresets = []; _createdFilename = "Output preview"; - var rootDir = Path.GetDirectoryName(Process.GetCurrentProcess().MainModule!.FileName); - - if (string.IsNullOrEmpty(rootDir)) - { - throw new InvalidOperationException("Could not locate root directory"); - } + // AppContext.BaseDirectory is the canonical app root; works under NativeAOT + // self-contained where Process.MainModule may be platform-specific or null. + string rootDir = AppContext.BaseDirectory; _currentPresetsDirectory = Path.Combine(rootDir, "presets") + Path.DirectorySeparatorChar; _currentSplitsDirectory = Path.Combine(rootDir, "splits") + Path.DirectorySeparatorChar; @@ -111,48 +107,100 @@ public MainWindow() Opened += async (_, _) => await RefreshPresetsAsync(); - Closed += (_, _) => CleanupSavestateTempDirs(); + } + + protected override void OnClosing(Avalonia.Controls.WindowClosingEventArgs e) + { + // Windows taskbar 'Close all windows' fires Close on every top-level window in + // parallel. MainWindow closing ends the desktop lifetime and tears down any open + // child window, including TestOutputWindow's save-prefs dialog. Defer until the + // child window has finished its own close so the prompt isn't killed mid-flight. + if (OperatingSystem.IsWindows() && _testOutputWindow is { } child && !child.HasShutdownCompleted) + { + e.Cancel = true; + // Re-issue the close once the child has finished, so the parent's close + // proceeds without the user having to click again. + child.Closed += (_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(Close); + child.Activate(); + return; + } + + base.OnClosing(e); + } + + protected override void OnClosed(EventArgs e) + { + base.OnClosed(e); + + foreach (var b in _inputPreviewCache.Values) + { + b.Dispose(); + } + _inputPreviewCache.Clear(); + + foreach (var b in _inputThumbnailCache.Values) + { + b.Dispose(); + } + _inputThumbnailCache.Clear(); + + foreach (var b in _maskSkBitmapCache.Values) + { + b.Dispose(); + } + _maskSkBitmapCache.Clear(); + + _maskedImage?.Dispose(); + _maskedImage = null; + _previewBitmap?.Dispose(); + _previewBitmap = null; + + CleanupSavestateTempDirs(); } private static void CleanupSavestateTempDirs() { + // Best-effort cleanup. Failures here are non-actionable for the user (locked + // files / TOCTOU on the temp enumeration / permission edge cases) and would + // only spam an error dialog at shutdown. try { foreach (string dir in Directory.EnumerateDirectories(Path.GetTempPath(), "AutoMask_savestates_*")) { - try - { - Directory.Delete(dir, recursive: true); - } - catch - { - } + try { Directory.Delete(dir, recursive: true); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } } } - catch - { - } + catch (IOException) { } + catch (UnauthorizedAccessException) { } } private void CheckSavePossible() { + // All callers run on the UI thread (event handlers, post-await continuations); + // the previous Dispatcher.UIThread.Post wrap was redundant. bool hasOutputDir = !string.IsNullOrEmpty(_outputDirectoryPath); bool hasPreview = !string.IsNullOrEmpty(_selectedInputImagePath) && !string.IsNullOrEmpty(_alphaImagePath); bool saveAllAllowed = hasOutputDir && selectedPresetIndex >= 0 && selectedPresetIndex < _splitPresets.Count && _splitPresets[selectedPresetIndex].Splits?.Count == _inputImagePaths?.Count; - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - BtnSave.IsEnabled = hasOutputDir; - BtnSaveAs.IsEnabled = hasPreview; - BtnSaveAllSplits.IsEnabled = saveAllAllowed; - }); + BtnSave.IsEnabled = hasOutputDir; + BtnSaveAs.IsEnabled = hasPreview; + BtnSaveAllSplits.IsEnabled = saveAllAllowed; } private async Task RefreshPresetsAsync() { - var foundPresets = await PresetService.LoadPresetsAsync(_currentPresetsDirectory); + var (foundPresets, loadFailures) = await PresetService.LoadPresetsAsync(_currentPresetsDirectory); + + if (loadFailures.Count > 0) + { + string detail = string.Join("\n", loadFailures.Select(f => $"{f.Path} - {f.Reason}")); + await ShowMessage("Preset load errors", + $"{loadFailures.Count} preset(s) could not be loaded:\n\n{detail}"); + } DebugLog($"Found {foundPresets.Count} presets:"); foreach (var preset in foundPresets) @@ -241,7 +289,6 @@ private async void ComboBoxSelectPreset_SelectionChanged(object sender, Selectio int? dataIdx = _presetDisplayMap[displayIdx]; if (dataIdx == null) { - // Group header - not selectable, nothing to do return; } diff --git a/AutoMask/MessageBox.cs b/AutoMask/MessageBox.cs index cf7cf3c..c89daa7 100644 --- a/AutoMask/MessageBox.cs +++ b/AutoMask/MessageBox.cs @@ -87,7 +87,18 @@ void AddButton(string content, MessageBoxResult result, bool isDefault = false) var tcs = new TaskCompletionSource(); msgBox.Closed += (_, _) => tcs.TrySetResult(msgBox._result); - msgBox.ShowDialog(owner); + // ShowDialog returns a Task that completes when the dialog closes; without observing + // it, a synchronous throw (window-create failure, threading mismatch) would leave + // the caller awaiting tcs.Task forever. Forward any exception to the TCS instead. + _ = msgBox.ShowDialog(owner).ContinueWith( + t => + { + if (t.IsFaulted && t.Exception is { } ex) + { + tcs.TrySetException(ex.InnerExceptions); + } + }, + TaskScheduler.Default); return tcs.Task; } } diff --git a/AutoMask/PresetEditor.axaml.cs b/AutoMask/PresetEditor.axaml.cs index 811c028..4b3caa5 100644 --- a/AutoMask/PresetEditor.axaml.cs +++ b/AutoMask/PresetEditor.axaml.cs @@ -687,9 +687,9 @@ private async Task UpdateOutputPreview() skResult.Dispose(); OutputPreviewImage.Source = _outputPreviewBitmap; } - catch + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) { - // If preview generation fails, leave the output box empty + Utils.LogError($"PresetEditor: preview generation failed: {ex.Message}"); } } @@ -706,7 +706,7 @@ protected override void OnClosed(EventArgs e) private void ValidateSplitName(string name) { - bool isInvalid = InvalidNameCharsRegex().IsMatch(name); + bool isInvalid = string.IsNullOrWhiteSpace(name) || InvalidNameCharsRegex().IsMatch(name); if (isInvalid) { @@ -734,7 +734,8 @@ private void UpdateSaveButtonState() return; } - bool anyInvalidName = _selectedPreset.Splits.Any(s => InvalidNameCharsRegex().IsMatch(s.Name)); + bool anyInvalidName = _selectedPreset.Splits.Any(s => + string.IsNullOrWhiteSpace(s.Name) || InvalidNameCharsRegex().IsMatch(s.Name)); bool nameValid = !string.IsNullOrWhiteSpace(_selectedPreset.PresetName); BtnSave.IsEnabled = nameValid && !anyInvalidName; @@ -803,7 +804,6 @@ private async void PresetListBox_SelectionChanged(object? sender, SelectionChang } } - // Commit the switch _suppressPresetSelection = true; PresetListBox.SelectedIndex = displayIdx; _suppressPresetSelection = false; @@ -980,9 +980,10 @@ private async void BtnImportSplits_Click(object? sender, Avalonia.Interactivity. } List premadeSplits; + List loadFailures; try { - premadeSplits = await PresetService.LoadPremadeSplitsAsync(_splitsDirectory); + (premadeSplits, loadFailures) = await PresetService.LoadPremadeSplitsAsync(_splitsDirectory); } catch (Exception ex) { @@ -990,6 +991,13 @@ private async void BtnImportSplits_Click(object? sender, Avalonia.Interactivity. return; } + if (loadFailures.Count > 0) + { + string detail = string.Join("\n", loadFailures.Select(f => $"• {f.Path} - {f.Reason}")); + await MessageBox.Show(this, "Pre-made splits load errors", + $"{loadFailures.Count} file(s) could not be loaded:\n\n{detail}"); + } + if (premadeSplits.Count == 0) { await MessageBox.Show(this, "No Splits Found", @@ -1519,20 +1527,35 @@ private async Task SaveToNewFolder(EditablePreset preset) }, }; - okBtn.Click += (_, _) => { tcs.TrySetResult(textBox.Text); dialog.Close(); }; - cancelBtn.Click += (_, _) => { tcs.TrySetResult(null); dialog.Close(); }; - dialog.Closed += (_, _) => tcs.TrySetResult(null); + // Closed fires after every code path (OK/Cancel/Enter/Escape/window-close). + // Without a guard, an explicit OK/Enter result was overwritten by the Closed + // handler's null on the way out — TrySetResult silently no-ops the second call, + // but the order of subscription mattered. Setting the result inside Closed only + // when nothing else has run avoids the order coupling entirely. + string? pendingResult = null; + bool resultSet = false; + void Commit(string? value) + { + if (!resultSet) + { + resultSet = true; + pendingResult = value; + } + dialog.Close(); + } + + okBtn.Click += (_, _) => Commit(textBox.Text); + cancelBtn.Click += (_, _) => Commit(null); + dialog.Closed += (_, _) => tcs.TrySetResult(resultSet ? pendingResult : null); textBox.KeyDown += (_, e) => { if (e.Key == Avalonia.Input.Key.Enter) { - tcs.TrySetResult(textBox.Text); - dialog.Close(); + Commit(textBox.Text); } else if (e.Key == Avalonia.Input.Key.Escape) { - tcs.TrySetResult(null); - dialog.Close(); + Commit(null); } }; dialog.Opened += (_, _) => { textBox.Focus(); textBox.SelectAll(); }; diff --git a/AutoMask/PresetEditorModels.cs b/AutoMask/PresetEditorModels.cs index b9ee797..ca1f951 100644 --- a/AutoMask/PresetEditorModels.cs +++ b/AutoMask/PresetEditorModels.cs @@ -62,7 +62,6 @@ public static PresetDisplayItem ForHeader(string gameName, bool isCollapsed = fa public static PresetDisplayItem ForPreset(EditablePreset p) => new() { Preset = p }; - // Properties forwarded to DataTemplate bindings public string PresetName => Preset?.PresetName ?? ""; public string GameName => IsHeader ? GroupName : Preset?.GameName ?? ""; public int SplitCount => Preset?.SplitCount ?? 0; diff --git a/AutoMask/PresetService.cs b/AutoMask/PresetService.cs index 2a120f8..00f6c1b 100644 --- a/AutoMask/PresetService.cs +++ b/AutoMask/PresetService.cs @@ -1,61 +1,119 @@ +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; namespace AutoSplit_AutoMask; +/// +/// One JSON file that failed to load. is either an exception message or +/// "deserialized to null" when the JSON parsed but produced no model. +/// +public sealed record LoadFailure(string Path, string Reason); + public static class PresetService { - public static async Task> LoadPresetsAsync(string presetsDirectory) + public static async Task<(List Presets, List Failures)> LoadPresetsAsync(string presetsDirectory) { var presetPaths = Directory.EnumerateDirectories(presetsDirectory) .Where(dir => Directory.EnumerateFiles(dir, "preset.json", SearchOption.TopDirectoryOnly).Any()) .ToArray(); - List foundPresets = []; + // Reading and deserializing each preset.json in parallel — sequential await on a + // slow disk (e.g. networked drive, large preset library) summed into noticeable + // startup latency. + var results = await Task.WhenAll(presetPaths.Select(LoadOnePresetAsync)); - foreach (string presetPath in presetPaths) + List foundPresets = []; + List failures = []; + foreach (var (preset, failure) in results) { - SplitPreset? preset = JsonSerializer.Deserialize( - await File.ReadAllTextAsync(Path.Combine(presetPath, "preset.json")), - AppJsonContext.Default.SplitPreset); - if (preset is not null) { - preset.PresetFolder = presetPath; foundPresets.Add(preset); } + else if (failure is not null) + { + failures.Add(failure); + } } - return foundPresets; + return (foundPresets, failures); } - public static async Task> LoadPremadeSplitsAsync(string splitsDirectory) + private static async Task<(SplitPreset? Preset, LoadFailure? Failure)> LoadOnePresetAsync(string presetPath) + { + string filePath = Path.Combine(presetPath, "preset.json"); + try + { + var preset = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(filePath, Encoding.UTF8), + AppJsonContext.Default.SplitPreset); + + if (preset is null) + { + return (null, new LoadFailure(filePath, "deserialized to null")); + } + + preset.PresetFolder = presetPath; + return (preset, null); + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + return (null, new LoadFailure(filePath, ex.Message)); + } + } + + public static async Task<(List Files, List Failures)> LoadPremadeSplitsAsync(string splitsDirectory) { if (!Directory.Exists(splitsDirectory)) { - return []; + return ([], []); } var splitPaths = Directory.EnumerateDirectories(splitsDirectory) .Where(dir => Directory.EnumerateFiles(dir, "splits.json", SearchOption.TopDirectoryOnly).Any()) .ToArray(); + var results = await Task.WhenAll(splitPaths.Select(LoadOnePremadeSplitsAsync)); + List foundSplitFiles = []; + List failures = []; + foreach (var (file, failure) in results) + { + if (file is not null) + { + foundSplitFiles.Add(file); + } + else if (failure is not null) + { + failures.Add(failure); + } + } + + return (foundSplitFiles.OrderBy(f => f.GameName).ToList(), failures); + } - foreach (string splitPath in splitPaths) + private static async Task<(PremadeSplitsFile? File, LoadFailure? Failure)> LoadOnePremadeSplitsAsync(string splitPath) + { + string filePath = Path.Combine(splitPath, "splits.json"); + try { - PremadeSplitsFile? splitsFile = JsonSerializer.Deserialize( - await File.ReadAllTextAsync(Path.Combine(splitPath, "splits.json")), + var splitsFile = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(filePath, Encoding.UTF8), AppJsonContext.Default.PremadeSplitsFile); - if (splitsFile is not null) + if (splitsFile is null) { - splitsFile.FolderPath = splitPath; - foundSplitFiles.Add(splitsFile); + return (null, new LoadFailure(filePath, "deserialized to null")); } - } - return foundSplitFiles.OrderBy(f => f.GameName).ToList(); + splitsFile.FolderPath = splitPath; + return (splitsFile, null); + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + return (null, new LoadFailure(filePath, ex.Message)); + } } public static string CreateFilenameForSplit(SplitPreset preset, int splitIndex) @@ -107,6 +165,30 @@ public static string BuildFilename(string name, int splitIndex, int totalSplits, return output + ".png"; } + /// + /// Returns a filename that is not yet present in , using + /// "name (1).ext", "name (2).ext", ... if the preferred name is already taken. + /// The chosen name is added to the set so subsequent calls won't pick it. + /// + private static string ReserveUniqueName(string preferred, HashSet used) + { + if (used.Add(preferred)) + { + return preferred; + } + + string ext = Path.GetExtension(preferred); + string baseName = Path.GetFileNameWithoutExtension(preferred); + for (int i = 1; ; i++) + { + string candidate = $"{baseName} ({i}){ext}"; + if (used.Add(candidate)) + { + return candidate; + } + } + } + /// /// Replaces spaces with underscores and strips characters that are invalid in directory names. /// @@ -138,6 +220,40 @@ internal static async Task SavePresetToFolderAsync(EditablePreset preset, string var splitRelPaths = new List(); var splitSavestateRelPaths = new List(); + // Pre-pass: reserve filenames already locked in by splits whose mask/savestate is + // already inside the target folder. Two splits referencing different external files + // that share a filename would otherwise overwrite each other, and an external copy + // could overwrite an internal mask of the same name when processed first. + var usedMaskNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var usedSavestateNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var split in preset.Splits) + { + if (!string.IsNullOrEmpty(split.MaskAbsolutePath)) + { + string maskFull = Path.GetFullPath(split.MaskAbsolutePath); + if (maskFull.StartsWith(targetFolderPrefix, StringComparison.OrdinalIgnoreCase)) + { + string rel = Path.GetRelativePath(targetFolderFull, maskFull); + // Only top-level mask filenames can collide with copy destinations + // (which always land at the target folder root). + if (!rel.Contains(Path.DirectorySeparatorChar) && !rel.Contains(Path.AltDirectorySeparatorChar)) + { + usedMaskNames.Add(rel); + } + } + } + + if (!string.IsNullOrEmpty(split.SavestateAbsolutePath)) + { + string savestateFull = Path.GetFullPath(split.SavestateAbsolutePath); + if (savestateFull.StartsWith(savestatesPrefix, StringComparison.OrdinalIgnoreCase)) + { + usedSavestateNames.Add(Path.GetFileName(savestateFull)); + } + } + } + foreach (var split in preset.Splits) { if (string.IsNullOrEmpty(split.MaskAbsolutePath)) @@ -154,7 +270,7 @@ internal static async Task SavePresetToFolderAsync(EditablePreset preset, string } else { - string destFilename = Path.GetFileName(maskFull); + string destFilename = ReserveUniqueName(Path.GetFileName(maskFull), usedMaskNames); string destPath = Path.Combine(targetFolderFull, destFilename); File.Copy(maskFull, destPath, overwrite: true); // Update the model so subsequent saves treat this file as already in place @@ -169,18 +285,18 @@ internal static async Task SavePresetToFolderAsync(EditablePreset preset, string continue; } - string savestateFull = Path.GetFullPath(split.SavestateAbsolutePath); + string savestateFullPath = Path.GetFullPath(split.SavestateAbsolutePath); - if (savestateFull.StartsWith(savestatesPrefix, StringComparison.OrdinalIgnoreCase)) + if (savestateFullPath.StartsWith(savestatesPrefix, StringComparison.OrdinalIgnoreCase)) { - splitSavestateRelPaths.Add(Path.GetRelativePath(targetFolderFull, savestateFull)); + splitSavestateRelPaths.Add(Path.GetRelativePath(targetFolderFull, savestateFullPath)); } else { Directory.CreateDirectory(savestatesFolder); - string destFilename = Path.GetFileName(savestateFull); + string destFilename = ReserveUniqueName(Path.GetFileName(savestateFullPath), usedSavestateNames); string destPath = Path.Combine(savestatesFolder, destFilename); - File.Copy(savestateFull, destPath, overwrite: true); + File.Copy(savestateFullPath, destPath, overwrite: true); split.SavestateAbsolutePath = destPath; splitSavestateRelPaths.Add(Path.Combine("savestates", destFilename)); } @@ -248,7 +364,21 @@ internal static async Task SavePresetToFolderAsync(EditablePreset preset, string jsonObj["splits"] = splitsArray; string json = jsonObj.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); - await File.WriteAllTextAsync(Path.Combine(targetFolderFull, "preset.json"), json); + string finalPath = Path.Combine(targetFolderFull, "preset.json"); + // Write to a sibling temp file, then atomically replace. File.WriteAllTextAsync + // truncates the destination before writing, so a crash mid-write would leave + // preset.json empty or partial; a rename on the same volume is atomic on NTFS. + string tmpPath = finalPath + ".tmp"; + try + { + await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8); + File.Move(tmpPath, finalPath, overwrite: true); + } + catch + { + try { if (File.Exists(tmpPath)) { File.Delete(tmpPath); } } catch { /* best-effort */ } + throw; + } if (Directory.Exists(savestatesFolder)) { @@ -262,7 +392,9 @@ internal static async Task SavePresetToFolderAsync(EditablePreset preset, string { if (!referencedNames.Contains(Path.GetFileName(file))) { - File.Delete(file); + // Best-effort cleanup; preset.json is already committed and a stale + // savestate file is harmless on disk, so don't fail the whole save. + try { File.Delete(file); } catch { /* ignore */ } } } } diff --git a/AutoMask/Program.cs b/AutoMask/Program.cs index c795082..312ba73 100644 --- a/AutoMask/Program.cs +++ b/AutoMask/Program.cs @@ -5,8 +5,22 @@ namespace AutoSplit_AutoMask; class Program { [STAThread] - public static void Main(string[] args) => BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); + public static void Main(string[] args) + { + // Self-contained AOT publishes have no console attached and no debugger to observe + // a crash; without these handlers a fatal exception silently exits. Crash logs land + // in %LOCALAPPDATA%\AutoMask\crashes (XDG equivalent on Linux) for bug reports. + AppDomain.CurrentDomain.UnhandledException += (_, e) => + Utils.LogCrashToDisk(e.ExceptionObject as Exception, "AppDomain.UnhandledException"); + TaskScheduler.UnobservedTaskException += (_, e) => + { + Utils.LogCrashToDisk(e.Exception, "TaskScheduler.UnobservedTaskException"); + e.SetObserved(); + }; + + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() diff --git a/AutoMask/TestOutputWindow.axaml.cs b/AutoMask/TestOutputWindow.axaml.cs index c86198f..99b37b1 100644 --- a/AutoMask/TestOutputWindow.axaml.cs +++ b/AutoMask/TestOutputWindow.axaml.cs @@ -33,7 +33,6 @@ private sealed class FeedOption private readonly CaptureController _controller = new(); private readonly ObservableCollection _feedOptions = []; - // Reference inputs captured from MainWindow. private SplitPreset? _presetFromMain; private int _splitIndexFromMain = -1; private string? _inputPathFromMain; @@ -49,6 +48,14 @@ private sealed class FeedOption private CapturePreferences? _loadedPrefs; private bool _hasUserChanges; private bool _isClosing; + private bool _closingStarted; + + /// + /// True once the window has finished its own shutdown sequence (capture stopped, + /// resources released, save prompt resolved). MainWindow checks this so a parallel + /// 'Close all windows' from the taskbar doesn't tear this window down mid-prompt. + /// + public bool HasShutdownCompleted => _isClosing; public TestOutputWindow() { @@ -71,17 +78,25 @@ public TestOutputWindow() Opened += async (_, _) => await InitializeAsync(); Closing += async (_, e) => { + // Once shutdown completed via the explicit Close() at the end of the flow, + // _isClosing is true and the close should proceed unimpeded. if (_isClosing) { return; } - // Always cancel and run shutdown on a fresh turn - the async lambda is not - // awaited by Avalonia's close pipeline, so without Cancel the window tears - // down while ShutdownAsync is still stopping the capture thread / webcam / - // GDI handles. + // Every re-entry while the first close is still running must cancel too — + // returning without setting Cancel=true lets Avalonia tear the window down + // mid-prompt, which was killing the save dialog when Windows taskbar + // 'Close all windows' fired a second Closing event. e.Cancel = true; + if (_closingStarted) + { + return; + } + _closingStarted = true; + if (_hasUserChanges) { await PromptSaveAndClose(); @@ -111,7 +126,7 @@ public void InitializeFromMainWindow( private async Task InitializeAsync() { - _loadedPrefs = LoadPrefs(); + _loadedPrefs = await LoadPrefsAsync(); await RefreshFeedListAsync(selectAfter: _loadedPrefs?.FeedName); @@ -634,7 +649,7 @@ private void ApplyCropFromPrefs(CapturePreferences prefs) } } - private CapturePreferences? LoadPrefs() + private async Task LoadPrefsAsync() { if (string.IsNullOrEmpty(_prefsPath) || !File.Exists(_prefsPath)) { @@ -643,16 +658,16 @@ private void ApplyCropFromPrefs(CapturePreferences prefs) try { - var json = File.ReadAllText(_prefsPath); + var json = await File.ReadAllTextAsync(_prefsPath, System.Text.Encoding.UTF8); return JsonSerializer.Deserialize(json, AppJsonContext.Default.CapturePreferences); } - catch + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) { return null; } } - private void SavePrefs(CapturePreferences prefs) + private async Task SavePrefsAsync(CapturePreferences prefs) { if (string.IsNullOrEmpty(_prefsPath)) { @@ -660,7 +675,7 @@ private void SavePrefs(CapturePreferences prefs) } var json = JsonSerializer.Serialize(prefs, AppJsonContext.Default.CapturePreferences); - File.WriteAllText(_prefsPath, json); + await File.WriteAllTextAsync(_prefsPath, json, System.Text.Encoding.UTF8); } private async Task PromptSaveAndClose() @@ -670,7 +685,7 @@ private async Task PromptSaveAndClose() if (result == MessageBoxResult.Yes) { - SavePrefs(BuildCurrentPrefs()); + await SavePrefsAsync(BuildCurrentPrefs()); } _isClosing = true; diff --git a/AutoMask/Utils.cs b/AutoMask/Utils.cs index ee47fc3..469f7fb 100644 --- a/AutoMask/Utils.cs +++ b/AutoMask/Utils.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Reflection; +using System.Text; namespace AutoSplit_AutoMask; @@ -27,4 +28,38 @@ public static void OpenInFileManager(string path) [Conditional("DEBUG")] public static void LogError(string message) => Console.Error.WriteLine(message); + + /// + /// Best-effort write of an exception to %LOCALAPPDATA%\AutoMask\crashes (XDG + /// equivalent on Linux). Used by the AppDomain, TaskScheduler, and Dispatcher + /// unhandled-exception hooks so a self-contained AOT publish has something to attach + /// to a bug report when there's no console or debugger available. + /// + public static void LogCrashToDisk(Exception? ex, string source) + { + if (ex is null) + { + return; + } + + try + { + string dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "AutoMask", + "crashes"); + Directory.CreateDirectory(dir); + + string path = Path.Combine(dir, $"crash-{DateTime.Now:yyyyMMdd-HHmmss-fff}-{source}.log"); + string body = $"Timestamp: {DateTime.Now:O}\n" + + $"Source: {source}\n" + + $"Version: {AutoMaskVersion}\n\n" + + ex.ToString(); + File.WriteAllText(path, body, Encoding.UTF8); + } + catch + { + // Nothing more to do — secondary failures during crash logging are not actionable. + } + } }