diff --git a/Pointframe.Tests/Services/CaptureLibraryOcrSearchTests.cs b/Pointframe.Tests/Services/CaptureLibraryOcrSearchTests.cs index 628fe8b..0cd1018 100644 --- a/Pointframe.Tests/Services/CaptureLibraryOcrSearchTests.cs +++ b/Pointframe.Tests/Services/CaptureLibraryOcrSearchTests.cs @@ -134,6 +134,20 @@ public async Task SearchAsync_TextMatchIsCaseInsensitive() Assert.Single(results); } + [Fact] + public async Task SearchAsync_LongQuery_ReturnsMatchesSortedNewestFirst() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("older.png", Jan); + CreateFile("newer.png", Jun); + TextFor("older.png", "invoice"); + TextFor("newer.png", "invoice"); + + var results = await NewService().SearchAsync("invoice", null, null); + + Assert.Equal(["newer.png", "older.png"], results.Select(result => result.FileName).ToArray()); + } + [Fact] public async Task SearchAsync_ReportsProgressAcrossScannedCandidates() { diff --git a/Pointframe.Tests/Services/ScreenRecordingServiceTests.cs b/Pointframe.Tests/Services/ScreenRecordingServiceTests.cs index 46ac6a7..28156cf 100644 --- a/Pointframe.Tests/Services/ScreenRecordingServiceTests.cs +++ b/Pointframe.Tests/Services/ScreenRecordingServiceTests.cs @@ -1,4 +1,6 @@ +using System.Collections.Concurrent; using System.Diagnostics; +using System.Reflection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -65,6 +67,20 @@ public void Dispose() } } + private sealed class FrameCollectingVideoWriter : IVideoWriter + { + public List Frames { get; } = []; + + public void WriteFrame(byte[] frameData) + { + Frames.Add(frameData); + } + + public void Dispose() + { + } + } + private static ScreenRecordingService CreateSut() => new(NullLogger.Instance, Mock.Of(), @@ -201,6 +217,31 @@ public void Start_FactoryThrowsFileNotFound_IsRecordingRemainsFalse() Assert.False(svc.IsRecording); } + [Fact] + public void Start_WhenInitializationFailsAfterWriterCreation_ResetsRecordingStateAndClearsBufferPool() + { + var writerMock = new Mock(); + var mockFactory = new Mock(); + mockFactory + .Setup(f => f.Create(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(writerMock.Object); + var microphoneService = new Mock(); + microphoneService.Setup(service => service.GetAvailableCaptureDeviceNames()).Returns(["Studio Mic"]); + microphoneService.Setup(service => service.GetDefaultCaptureDeviceName()).Returns("Studio Mic"); + microphoneService.Setup(service => service.TryGetCaptureDeviceMuted("Studio Mic")).Throws(new InvalidOperationException("boom")); + var settings = new UserSettings { RecordMicrophone = true }; + using var svc = CreateSut(mockFactory.Object, microphoneService.Object, settings); + + Assert.Throws(() => svc.Start(0, 0, 100, 100, "test.mp4")); + + Assert.False(svc.IsRecording); + Assert.False(svc.IsPaused); + Assert.False(svc.IsRecordingMicrophoneEnabled); + Assert.False(svc.CanToggleMicrophone); + Assert.False(svc.IsMicrophoneMuted); + Assert.Empty(GetField>(svc, "_bufferPool")); + } + [Fact] public void Start_EvenDimensions_SetsIsRecordingTrue() { @@ -597,4 +638,46 @@ public void Stop_WhenWriterBackpressureOccurs_LogsZeroDroppedDuration() Assert.Contains("droppedFrames=0", sessionStats, StringComparison.Ordinal); Assert.Contains("droppedDuration=00:00:00", sessionStats, StringComparison.Ordinal); } + + [Fact] + public void PadRecordingToElapsedDuration_ClonesLatestFrameOnceForPadding() + { + var writer = new FrameCollectingVideoWriter(); + var source = new byte[] { 1, 2, 3, 4 }; + using var svc = CreateSut(); + + SetField(svc, "_writer", writer); + SetField(svc, "_latestFrameBytes", source); + SetField(svc, "_fps", 10); + SetField(svc, "_captureWidth", 1); + SetField(svc, "_captureHeight", 1); + + InvokePrivateMethod(svc, "PadRecordingToElapsedDuration", TimeSpan.FromMilliseconds(150)); + + Assert.Equal(2, writer.Frames.Count); + Assert.NotSame(source, writer.Frames[0]); + Assert.Same(writer.Frames[0], writer.Frames[1]); + Assert.Equal(source, writer.Frames[0]); + } + + private static T GetField(object target, string fieldName) + { + var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + return Assert.IsType(field?.GetValue(target)); + } + + private static void SetField(object target, string fieldName, object? value) + { + var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + field.SetValue(target, value); + } + + private static void InvokePrivateMethod(object target, string methodName, params object?[] args) + { + var method = target.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + method.Invoke(target, args); + } } diff --git a/Pointframe/Services/Annotation/AnnotationCanvasRenderer.cs b/Pointframe/Services/Annotation/AnnotationCanvasRenderer.cs index 85cc8a3..a96a586 100644 --- a/Pointframe/Services/Annotation/AnnotationCanvasRenderer.cs +++ b/Pointframe/Services/Annotation/AnnotationCanvasRenderer.cs @@ -17,6 +17,7 @@ internal sealed class AnnotationCanvasRenderer private IAnnotationShapeHandler? _activeHandler; private readonly Dictionary _brushCache = []; + private readonly byte[] _samplePixelScratch = new byte[4]; private BitmapSource? _backgroundCapture; private double _dpiX = 1.0; @@ -42,9 +43,8 @@ public void SetBackground(BitmapSource background, double dpiX, double dpiY) var px = Math.Clamp((int)Math.Floor(dipPoint.X * _dpiX), 0, _backgroundCapture.PixelWidth - 1); var py = Math.Clamp((int)Math.Floor(dipPoint.Y * _dpiY), 0, _backgroundCapture.PixelHeight - 1); - var bytes = new byte[4]; - _backgroundCapture.CopyPixels(new Int32Rect(px, py, 1, 1), bytes, 4, 0); - return Color.FromRgb(bytes[2], bytes[1], bytes[0]); // BGRA → RGB + _backgroundCapture.CopyPixels(new Int32Rect(px, py, 1, 1), _samplePixelScratch, 4, 0); + return Color.FromRgb(_samplePixelScratch[2], _samplePixelScratch[1], _samplePixelScratch[0]); // BGRA → RGB } public BitmapSource? CropLoupeRegion(Point dipCenter, int halfPixels) diff --git a/Pointframe/Services/Capture/CaptureLibraryService.cs b/Pointframe/Services/Capture/CaptureLibraryService.cs index bc5abf4..b5ce716 100644 --- a/Pointframe/Services/Capture/CaptureLibraryService.cs +++ b/Pointframe/Services/Capture/CaptureLibraryService.cs @@ -16,75 +16,12 @@ public CaptureLibraryService(IUserSettingsService settings, ICaptureTextLookupSe public IReadOnlyList GetCaptures() { - var folder = _settings.Current.ScreenshotSavePath; - if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) - { - return Array.Empty(); - } - - string[] files; - - try - { - files = Directory.GetFiles(folder); - } - catch (IOException) - { - return Array.Empty(); - } - catch (UnauthorizedAccessException) - { - return Array.Empty(); - } - catch (System.Security.SecurityException) - { - return Array.Empty(); - } - - var captures = new List(); - - foreach (var path in files) - { - if (!SupportedExtensions.Contains(Path.GetExtension(path))) - { - continue; - } - - try - { - captures.Add(new CaptureItem( - path, - Path.GetFileName(path), - File.GetLastWriteTimeUtc(path))); - } - catch (IOException) - { - // Keep capture loading best-effort if a file disappears or cannot be read. - } - catch (UnauthorizedAccessException) - { - // Keep capture loading best-effort if file access is denied. - } - catch (System.Security.SecurityException) - { - // Keep capture loading best-effort if file metadata access is blocked. - } - } - - return captures - .OrderByDescending(item => item.CapturedAtUtc) - .ToList(); + return CollectCaptures(null, null, null, useFileNameSearchPattern: false); } public IReadOnlyList Search(string? query, DateTime? fromUtc, DateTime? toUtc) { - var hasQuery = !string.IsNullOrWhiteSpace(query); - - return GetCaptures() - .Where(item => - (!hasQuery || item.FileName.Contains(query!, StringComparison.OrdinalIgnoreCase)) - && InDateRange(item, fromUtc, toUtc)) - .ToList(); + return CollectCaptures(fromUtc, toUtc, query, useFileNameSearchPattern: true); } public async Task> SearchAsync( @@ -94,11 +31,13 @@ public async Task> SearchAsync( IProgress? progress = null, CancellationToken cancellationToken = default) { - var candidates = GetCaptures() - .Where(item => InDateRange(item, fromUtc, toUtc)) - .ToList(); - var normalizedQuery = query?.Trim(); + var candidates = CollectCaptures( + fromUtc, + toUtc, + normalizedQuery is { Length: < 3 } ? normalizedQuery : null, + useFileNameSearchPattern: true, + sortByCapturedAtDescending: normalizedQuery is null or { Length: < 3 }); if (string.IsNullOrWhiteSpace(normalizedQuery)) { return candidates; @@ -108,9 +47,7 @@ public async Task> SearchAsync( // terms while the user is still composing the search phrase. if (normalizedQuery.Length < 3) { - return candidates - .Where(item => item.FileName.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase)) - .ToList(); + return candidates; } var matches = new List(); @@ -150,10 +87,104 @@ public async Task> SearchAsync( progress?.Report(new CaptureSearchProgress(scanned, candidates.Count)); } + matches.Sort((left, right) => right.CapturedAtUtc.CompareTo(left.CapturedAtUtc)); return matches; } private static bool InDateRange(CaptureItem item, DateTime? fromUtc, DateTime? toUtc) => (fromUtc is null || item.CapturedAtUtc >= fromUtc.Value) && (toUtc is null || item.CapturedAtUtc <= toUtc.Value); + + private IReadOnlyList CollectCaptures( + DateTime? fromUtc, + DateTime? toUtc, + string? fileNameContains, + bool useFileNameSearchPattern, + bool sortByCapturedAtDescending = true) + { + var folder = _settings.Current.ScreenshotSavePath; + if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) + { + return Array.Empty(); + } + + var hasFileNameFilter = !string.IsNullOrWhiteSpace(fileNameContains); + + try + { + var captures = new List(); + var filePaths = useFileNameSearchPattern && hasFileNameFilter && CanUseFileSearchPattern(fileNameContains!) + ? Directory.EnumerateFiles(folder, $"*{fileNameContains}*") + : Directory.EnumerateFiles(folder); + + foreach (var path in filePaths) + { + if (!SupportedExtensions.Contains(Path.GetExtension(path))) + { + continue; + } + + try + { + var capture = new CaptureItem( + path, + Path.GetFileName(path), + File.GetLastWriteTimeUtc(path)); + + if (!InDateRange(capture, fromUtc, toUtc)) + { + continue; + } + + if (hasFileNameFilter && !capture.FileName.Contains(fileNameContains!, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + captures.Add(capture); + } + catch (IOException) + { + // Keep capture loading best-effort if a file disappears or cannot be read. + } + catch (UnauthorizedAccessException) + { + // Keep capture loading best-effort if file access is denied. + } + catch (System.Security.SecurityException) + { + // Keep capture loading best-effort if file metadata access is blocked. + } + } + + if (sortByCapturedAtDescending) + { + captures.Sort((left, right) => right.CapturedAtUtc.CompareTo(left.CapturedAtUtc)); + } + + return captures; + } + catch (IOException) + { + return Array.Empty(); + } + catch (UnauthorizedAccessException) + { + return Array.Empty(); + } + catch (System.Security.SecurityException) + { + return Array.Empty(); + } + } + + private static bool CanUseFileSearchPattern(string fileNameContains) + { + if (fileNameContains.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + return false; + } + + return fileNameContains.IndexOfAny(['*', '?']) < 0; + } } diff --git a/Pointframe/Services/Recording/ScreenRecordingService.cs b/Pointframe/Services/Recording/ScreenRecordingService.cs index 28291c9..1c9af24 100644 --- a/Pointframe/Services/Recording/ScreenRecordingService.cs +++ b/Pointframe/Services/Recording/ScreenRecordingService.cs @@ -29,6 +29,7 @@ private static extern bool BitBlt( // Reused across every frame of a single recording session — allocated in Start, disposed in Stop. private Bitmap? _captureBitmap; private Graphics? _captureGraphics; + private ScreenDc? _screenDc; private int _captureX; private int _captureY; @@ -101,47 +102,83 @@ public void Start( _latestFrameBytes = null; _sessionStopwatch = Stopwatch.StartNew(); - // Allocate the capture surface once per session — no per-frame allocation. - _captureBitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb); - _captureGraphics = Graphics.FromImage(_captureBitmap); - - // Pre-allocate a small pool of raw frame buffers so neither the capture nor the - // encode loop ever needs to allocate on the hot path. - var bufferSize = width * height * 4; - const int PoolSize = 4; - for (var i = 0; i < PoolSize; i++) - { - _bufferPool.Enqueue(new byte[bufferSize]); - } - - var microphoneDeviceName = ResolveMicrophoneDeviceName(); - _writer = _writerFactory.Create(width, height, fps, outputPath, microphoneDeviceName); - IsRecordingMicrophoneEnabled = microphoneDeviceName is not null; - _activeMicrophoneDeviceName = microphoneDeviceName; - var initialMicrophoneMutedState = microphoneDeviceName is null - ? null - : _microphoneDeviceService.TryGetCaptureDeviceMuted(microphoneDeviceName); - _restoreMicrophoneMutedState = initialMicrophoneMutedState; - CanToggleMicrophone = initialMicrophoneMutedState.HasValue; - IsMicrophoneMuted = initialMicrophoneMutedState ?? false; - - // Bounded channel: if the encode loop falls behind, CaptureFrameToChannel will - // skip the newest frame (TryWrite returns false) rather than stalling the capture thread. - // DropWrite is used so TryWrite still returns false on a full channel, keeping the - // buffer-pool return and dropped-frame counter working correctly. - _encodeChannel = Channel.CreateBounded(new BoundedChannelOptions(PoolSize) - { - FullMode = BoundedChannelFullMode.DropWrite, - SingleReader = true, - SingleWriter = true, - AllowSynchronousContinuations = false, - }); - - _cts = new CancellationTokenSource(); - IsRecording = true; - _captureLoop = Task.Run(() => CaptureLoop(_cts.Token)); - _encodeLoop = Task.Run(EncodeLoop); - _logger.LogInformation("Recording started: {W}x{H} @ {Fps}fps (MP4) → {Path}", width, height, fps, outputPath); + try + { + // Allocate the capture surface once per session — no per-frame allocation. + _captureBitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb); + _captureGraphics = Graphics.FromImage(_captureBitmap); + + // Pre-allocate a small pool of raw frame buffers so neither the capture nor the + // encode loop ever needs to allocate on the hot path. + var bufferSize = width * height * 4; + const int PoolSize = 4; + for (var i = 0; i < PoolSize; i++) + { + _bufferPool.Enqueue(new byte[bufferSize]); + } + + var microphoneDeviceName = ResolveMicrophoneDeviceName(); + _writer = _writerFactory.Create(width, height, fps, outputPath, microphoneDeviceName); + + _screenDc = new ScreenDc(); + IsRecordingMicrophoneEnabled = microphoneDeviceName is not null; + _activeMicrophoneDeviceName = microphoneDeviceName; + var initialMicrophoneMutedState = microphoneDeviceName is null + ? null + : _microphoneDeviceService.TryGetCaptureDeviceMuted(microphoneDeviceName); + _restoreMicrophoneMutedState = initialMicrophoneMutedState; + CanToggleMicrophone = initialMicrophoneMutedState.HasValue; + IsMicrophoneMuted = initialMicrophoneMutedState ?? false; + + // Bounded channel: if the encode loop falls behind, CaptureFrameToChannel will + // skip the newest frame (TryWrite returns false) rather than stalling the capture thread. + // DropWrite is used so TryWrite still returns false on a full channel, keeping the + // buffer-pool return and dropped-frame counter working correctly. + _encodeChannel = Channel.CreateBounded(new BoundedChannelOptions(PoolSize) + { + FullMode = BoundedChannelFullMode.DropWrite, + SingleReader = true, + SingleWriter = true, + AllowSynchronousContinuations = false, + }); + + _cts = new CancellationTokenSource(); + IsRecording = true; + _captureLoop = Task.Run(() => CaptureLoop(_cts.Token)); + _encodeLoop = Task.Run(EncodeLoop); + _logger.LogInformation("Recording started: {W}x{H} @ {Fps}fps (MP4) → {Path}", width, height, fps, outputPath); + } + catch + { + IsRecording = false; + IsPaused = false; + IsRecordingMicrophoneEnabled = false; + CanToggleMicrophone = false; + IsMicrophoneMuted = false; + _activeMicrophoneDeviceName = null; + _restoreMicrophoneMutedState = null; + _latestFrameBytes = null; + _cts?.Cancel(); + WaitForStartFailureTaskShutdown(_captureLoop); + WaitForStartFailureTaskShutdown(_encodeLoop); + _screenDc?.Dispose(); + _screenDc = null; + _captureGraphics?.Dispose(); + _captureGraphics = null; + _captureBitmap?.Dispose(); + _captureBitmap = null; + _writer?.Dispose(); + _writer = null; + _captureLoop = null; + _encodeLoop = null; + _encodeChannel = null; + _cts?.Dispose(); + _cts = null; + ClearBufferPool(); + + _sessionStopwatch = null; + throw; + } } public void Stop() @@ -199,13 +236,14 @@ public void Stop() _captureGraphics = null; _captureBitmap?.Dispose(); _captureBitmap = null; + var screenDc = _screenDc; + _screenDc = null; + screenDc?.Dispose(); _latestFrameBytes = null; IsRecordingMicrophoneEnabled = false; CanToggleMicrophone = false; IsMicrophoneMuted = false; - while (_bufferPool.TryDequeue(out _)) - { - } + ClearBufferPool(); try { @@ -258,7 +296,7 @@ private async Task CaptureLoop(CancellationToken ct) private void CaptureFrameToChannel() { - if (_encodeChannel is null || _captureBitmap is null || _captureGraphics is null) + if (_encodeChannel is null || _captureBitmap is null || _captureGraphics is null || _screenDc is null) { return; } @@ -275,9 +313,8 @@ private void CaptureFrameToChannel() var bitmapDc = _captureGraphics.GetHdc(); try { - using var screenDc = new ScreenDc(); BitBlt(bitmapDc, 0, 0, _captureWidth, _captureHeight, - screenDc.Handle, _captureX, _captureY, SrcCopy); + _screenDc.Handle, _captureX, _captureY, SrcCopy); } finally { @@ -431,8 +468,9 @@ public bool TrySetMicrophoneMuted(bool isMuted) private void UpdateLatestFrame(byte[] source) { - _latestFrameBytes ??= new byte[source.Length]; - Buffer.BlockCopy(source, 0, _latestFrameBytes, 0, source.Length); + // Keep the most recent captured frame reference for stop-time padding. + // The frame content remains stable once capture stops and encode draining begins. + _latestFrameBytes = source; } private void PadRecordingToElapsedDuration(TimeSpan targetElapsed) @@ -453,6 +491,10 @@ private void PadRecordingToElapsedDuration(TimeSpan targetElapsed) paddingSource = new byte[_captureWidth * _captureHeight * 4]; _logger.LogWarning("No captured frame was available for stop-time padding; using a blank frame to preserve recording duration"); } + else + { + paddingSource = (byte[])paddingSource.Clone(); + } var elapsedFrameCount = (int)Math.Ceiling(targetElapsed.TotalSeconds * _fps); var writtenFrameCount = Volatile.Read(ref _writtenFrameCount); @@ -467,12 +509,10 @@ private void PadRecordingToElapsedDuration(TimeSpan targetElapsed) for (var index = 0; index < framesToPad; index++) { - var frameCopy = new byte[paddingSource.Length]; - Buffer.BlockCopy(paddingSource, 0, frameCopy, 0, frameCopy.Length); Interlocked.Increment(ref _attemptedFrameCount); try { - _writer?.WriteFrame(frameCopy); + _writer?.WriteFrame(paddingSource); } catch (IOException ex) { @@ -523,6 +563,29 @@ private void LogSessionSummary(TimeSpan targetElapsed) droppedDuration); } + private void ClearBufferPool() + { + while (_bufferPool.TryDequeue(out _)) + { + } + } + + private static void WaitForStartFailureTaskShutdown(Task? task) + { + if (task is null) + { + return; + } + + try + { + task.Wait(TimeSpan.FromSeconds(1)); + } + catch (AggregateException ae) when (ae.InnerExceptions.All(ex => ex is OperationCanceledException)) + { + } + } + private void RestoreMicrophoneMuteState() { if (string.IsNullOrWhiteSpace(_activeMicrophoneDeviceName) || !_restoreMicrophoneMutedState.HasValue)