Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2ee7bab
Add Capture Library: browse, search, and open captures
dimitar-radenkov Jul 12, 2026
4e8f6a1
Add persistent OCR cache with EF Core and Sqlite
dimitar-radenkov Jul 12, 2026
678f27d
Debounced search and date presets for Library window
dimitar-radenkov Jul 12, 2026
91de01d
Handle ListBox double-click in code-behind
dimitar-radenkov Jul 12, 2026
a835e2a
Replace CaptureTextIndex with CaptureTextLookupService
dimitar-radenkov Jul 12, 2026
eb4f413
Add telemetry tracking to LibraryViewModel searches
dimitar-radenkov Jul 12, 2026
61ffb80
Refactor file retrieval in CaptureLibraryService to handle exceptions…
dimitar-radenkov Jul 12, 2026
d00ca4f
Bump version to 6.6 in version.json
dimitar-radenkov Jul 12, 2026
bb192d3
Optimize capture library search paths
dimitar-radenkov Jul 14, 2026
ab112b5
Optimize recording stop padding
dimitar-radenkov Jul 14, 2026
1c1109f
Reduce annotation color sampling allocations
dimitar-radenkov Jul 14, 2026
6abb81d
Optimize latest-frame tracking in capture loop
dimitar-radenkov Jul 14, 2026
4af14d4
Reuse screen DC across capture frames
dimitar-radenkov Jul 14, 2026
3853ef4
Merge branch 'master' into feature/perf-capture-recording-optimizations
dimitar-radenkov Jul 14, 2026
a4a2a14
Improve error handling in ScreenRecordingService initialization and v…
dimitar-radenkov Jul 14, 2026
2a43f15
Fix recording review thread issues
Copilot Jul 14, 2026
12d7dfe
Tidy recording fix follow-ups
Copilot Jul 14, 2026
e4449d7
Address validation follow-ups
Copilot Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Pointframe.Tests/Services/CaptureLibraryOcrSearchTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
83 changes: 83 additions & 0 deletions Pointframe.Tests/Services/ScreenRecordingServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -65,6 +67,20 @@ public void Dispose()
}
}

private sealed class FrameCollectingVideoWriter : IVideoWriter
{
public List<byte[]> Frames { get; } = [];

public void WriteFrame(byte[] frameData)
{
Frames.Add(frameData);
}

public void Dispose()
{
}
}

private static ScreenRecordingService CreateSut() =>
new(NullLogger<ScreenRecordingService>.Instance,
Mock.Of<IMicrophoneDeviceService>(),
Expand Down Expand Up @@ -201,6 +217,31 @@ public void Start_FactoryThrowsFileNotFound_IsRecordingRemainsFalse()
Assert.False(svc.IsRecording);
}

[Fact]
public void Start_WhenInitializationFailsAfterWriterCreation_ResetsRecordingStateAndClearsBufferPool()
{
var writerMock = new Mock<IVideoWriter>();
var mockFactory = new Mock<IVideoWriterFactory>();
mockFactory
.Setup(f => f.Create(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), It.IsAny<string?>()))
.Returns(writerMock.Object);
var microphoneService = new Mock<IMicrophoneDeviceService>();
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<InvalidOperationException>(() => 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<ConcurrentQueue<byte[]>>(svc, "_bufferPool"));
}

[Fact]
public void Start_EvenDimensions_SetsIsRecordingTrue()
{
Expand Down Expand Up @@ -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<T>(object target, string fieldName)
{
var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(field);
return Assert.IsType<T>(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);
}
}
6 changes: 3 additions & 3 deletions Pointframe/Services/Annotation/AnnotationCanvasRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ internal sealed class AnnotationCanvasRenderer
private IAnnotationShapeHandler? _activeHandler;

private readonly Dictionary<Color, SolidColorBrush> _brushCache = [];
private readonly byte[] _samplePixelScratch = new byte[4];

private BitmapSource? _backgroundCapture;
private double _dpiX = 1.0;
Expand All @@ -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)
Expand Down
175 changes: 103 additions & 72 deletions Pointframe/Services/Capture/CaptureLibraryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,75 +16,12 @@ public CaptureLibraryService(IUserSettingsService settings, ICaptureTextLookupSe

public IReadOnlyList<CaptureItem> GetCaptures()
{
var folder = _settings.Current.ScreenshotSavePath;
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
{
return Array.Empty<CaptureItem>();
}

string[] files;

try
{
files = Directory.GetFiles(folder);
}
catch (IOException)
{
return Array.Empty<CaptureItem>();
}
catch (UnauthorizedAccessException)
{
return Array.Empty<CaptureItem>();
}
catch (System.Security.SecurityException)
{
return Array.Empty<CaptureItem>();
}

var captures = new List<CaptureItem>();

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<CaptureItem> 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<IReadOnlyList<CaptureItem>> SearchAsync(
Expand All @@ -94,11 +31,13 @@ public async Task<IReadOnlyList<CaptureItem>> SearchAsync(
IProgress<CaptureSearchProgress>? 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;
Expand All @@ -108,9 +47,7 @@ public async Task<IReadOnlyList<CaptureItem>> 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<CaptureItem>();
Expand Down Expand Up @@ -150,10 +87,104 @@ public async Task<IReadOnlyList<CaptureItem>> 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<CaptureItem> 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<CaptureItem>();
}

var hasFileNameFilter = !string.IsNullOrWhiteSpace(fileNameContains);

try
{
var captures = new List<CaptureItem>();
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<CaptureItem>();
}
catch (UnauthorizedAccessException)
{
return Array.Empty<CaptureItem>();
}
catch (System.Security.SecurityException)
{
return Array.Empty<CaptureItem>();
}
}

private static bool CanUseFileSearchPattern(string fileNameContains)
{
if (fileNameContains.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
{
return false;
}

return fileNameContains.IndexOfAny(['*', '?']) < 0;
}
Comment thread
Copilot marked this conversation as resolved.
}
Loading
Loading