From 401672f40c171111831cb002554c457518a44029 Mon Sep 17 00:00:00 2001 From: Freesia Date: Tue, 11 Aug 2026 18:01:01 +0900 Subject: [PATCH 1/5] =?UTF-8?q?OneShot=E3=83=A2=E3=83=BC=E3=83=89=E3=82=92?= =?UTF-8?q?=E3=83=9B=E3=83=83=E3=83=88=E3=82=AD=E3=83=BC=E5=AE=9F=E8=A1=8C?= =?UTF-8?q?=E3=81=A8=E3=81=97=E3=81=A6=E5=BE=A9=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WindowTranslator.Abstractions/UserSettings.cs | 5 ++ .../OcrObservationSelectorTests.cs | 58 ++++++++++++++++ .../OcrTextTrackerAccuracyTests.cs | 5 +- WindowTranslator/AssemblyInfo.cs | 5 +- .../ErrorReport/ErrorReportViewModel.cs | 1 + .../Modules/Main/CaptureMainWindow.xaml.cs | 31 ++++++++- .../Modules/Main/MainViewModelBase.cs | 67 +++++++++++++++++-- .../Modules/Main/OverlayMainWindow.xaml.cs | 4 ++ .../Modules/Ocr/OcrObservationSelector.cs | 16 +++++ .../Modules/Settings/AllSettingsViewModel.cs | 8 ++- .../Properties/Resources.Designer.cs | 5 ++ WindowTranslator/Properties/Resources.en.resx | 3 + WindowTranslator/Properties/Resources.resx | 3 + 13 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 WindowTranslator.Tests/OcrObservationSelectorTests.cs create mode 100644 WindowTranslator/Modules/Ocr/OcrObservationSelector.cs diff --git a/WindowTranslator.Abstractions/UserSettings.cs b/WindowTranslator.Abstractions/UserSettings.cs index 244405b1..f24bc43a 100644 --- a/WindowTranslator.Abstractions/UserSettings.cs +++ b/WindowTranslator.Abstractions/UserSettings.cs @@ -82,6 +82,11 @@ public class TargetSettings /// public bool DisplayBusy { get; set; } = true; + /// + /// ホットキーが押されたときだけOCRと翻訳を行うか + /// + public bool IsOneShotMode { get; set; } + /// /// マウスポインター判定の余白(WPF上のピクセル値) /// diff --git a/WindowTranslator.Tests/OcrObservationSelectorTests.cs b/WindowTranslator.Tests/OcrObservationSelectorTests.cs new file mode 100644 index 00000000..b0d51f5d --- /dev/null +++ b/WindowTranslator.Tests/OcrObservationSelectorTests.cs @@ -0,0 +1,58 @@ +using System.Drawing; +using WindowTranslator.Modules.Ocr; + +namespace WindowTranslator.Tests; + +public class OcrObservationSelectorTests +{ + [Fact] + public void OneShotReturnsCurrentObservationsWithoutCallingTracker() + { + TextRect[] observations = [new("current", 10, 20, 100, 30, 18, false)]; + var tracker = new RecordingTracker + { + Result = [new("previous", 1, 2, 3, 4, 5, false)], + }; + + IReadOnlyList result = OcrObservationSelector.Select( + observations, + new Size(1920, 1080), + tracker, + true); + + Assert.False(tracker.WasCalled); + Assert.Equal(observations, result); + } + + [Fact] + public void ContinuousModeReturnsTrackerResult() + { + TextRect[] tracked = [new("tracked", 11, 22, 101, 31, 19, false)]; + var tracker = new RecordingTracker { Result = tracked }; + + IReadOnlyList result = OcrObservationSelector.Select( + [new("current", 10, 20, 100, 30, 18, false)], + new Size(1920, 1080), + tracker, + false); + + Assert.True(tracker.WasCalled); + Assert.Same(tracked, result); + } + + private sealed class RecordingTracker : IOcrTextTracker + { + public bool WasCalled { get; private set; } + public required IReadOnlyList Result { get; init; } + + public IReadOnlyList Update(IEnumerable observations, Size imageSize) + { + this.WasCalled = true; + return this.Result; + } + + public void Reset() + { + } + } +} diff --git a/WindowTranslator.Tests/OcrTextTrackerAccuracyTests.cs b/WindowTranslator.Tests/OcrTextTrackerAccuracyTests.cs index 95c10a9c..d57c0220 100644 --- a/WindowTranslator.Tests/OcrTextTrackerAccuracyTests.cs +++ b/WindowTranslator.Tests/OcrTextTrackerAccuracyTests.cs @@ -1705,7 +1705,7 @@ public void DormantChildrenDoNotConsumeAnActiveTracksObservation() } [Fact] - public void RemovedFeaturesAreNotExposed() + public void RemovedBufferFeaturesAreNotExposedAndOneShotIsAvailable() { const System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public @@ -1713,7 +1713,8 @@ public void RemovedFeaturesAreNotExposed() Type appResources = typeof(OcrTextTracker).Assembly.GetType("WindowTranslator.Properties.Resources", throwOnError: true)!; Type abstractionResources = typeof(TextRect).Assembly.GetType("WindowTranslator.Properties.Resources", throwOnError: true)!; - Assert.Null(appResources.GetProperty("IsOneShotMode", flags)); + Assert.NotNull(appResources.GetProperty("IsOneShotMode", flags)); + Assert.NotNull(typeof(TargetSettings).GetProperty(nameof(TargetSettings.IsOneShotMode))); Assert.Null(abstractionResources.GetProperty("Buffer", flags)); Assert.Null(abstractionResources.GetProperty("BufferSize", flags)); Assert.Null(abstractionResources.GetProperty("IsSuppressVibe", flags)); diff --git a/WindowTranslator/AssemblyInfo.cs b/WindowTranslator/AssemblyInfo.cs index 48ce7811..117828e0 100644 --- a/WindowTranslator/AssemblyInfo.cs +++ b/WindowTranslator/AssemblyInfo.cs @@ -1,6 +1,9 @@ +using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Windows; +[assembly: InternalsVisibleTo("WindowTranslator.Tests")] + [assembly: ThemeInfo( ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located //(used if a resource is not found in the page, @@ -10,4 +13,4 @@ // app, or any theme specific resource dictionaries) )] -[assembly: SupportedOSPlatform("windows10.0.19041")] \ No newline at end of file +[assembly: SupportedOSPlatform("windows10.0.19041")] diff --git a/WindowTranslator/Modules/ErrorReport/ErrorReportViewModel.cs b/WindowTranslator/Modules/ErrorReport/ErrorReportViewModel.cs index 9a97c2a7..08ca8d61 100644 --- a/WindowTranslator/Modules/ErrorReport/ErrorReportViewModel.cs +++ b/WindowTranslator/Modules/ErrorReport/ErrorReportViewModel.cs @@ -148,6 +148,7 @@ private static string GetInfo(Exception ex, UserSettings? settings, string targe sb.AppendLine(CultureInfo.CurrentCulture, $"OverlayShortcut: {value.OverlayShortcut ?? "N/A"}"); sb.AppendLine(CultureInfo.CurrentCulture, $"OverlayOpacity: {value.OverlayOpacity}"); sb.AppendLine(CultureInfo.CurrentCulture, $"DisplayBusy: {value.DisplayBusy}"); + sb.AppendLine(CultureInfo.CurrentCulture, $"IsOneShotMode: {value.IsOneShotMode}"); if (value.SelectedPlugins?.Count > 0) { diff --git a/WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs b/WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs index 56f30811..9b199aeb 100644 --- a/WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs +++ b/WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs @@ -1,9 +1,13 @@ using System.Runtime.InteropServices; using System.Windows; +using System.Windows.Interop; using System.Windows.Threading; using CommunityToolkit.Mvvm.Messaging; +using Microsoft.Extensions.Options; using Windows.Win32.Foundation; +using Windows.Win32.UI.Input.KeyboardAndMouse; using Windows.Win32.UI.WindowsAndMessaging; +using WindowTranslator.Extensions; using WindowTranslator.Stores; using static Windows.Win32.PInvoke; @@ -16,11 +20,17 @@ public partial class CaptureMainWindow { private readonly IProcessInfoStore processInfo; private readonly DispatcherTimer timer = new(); + private readonly bool isOneShotMode; + private readonly HOT_KEY_MODIFIERS shortcutModifiers; + private readonly int shortcutKey; + private IntPtr windowHandle; - public CaptureMainWindow(IProcessInfoStore processInfo) + public CaptureMainWindow(IProcessInfoStore processInfo, IOptionsSnapshot targetSettings) { InitializeComponent(); this.processInfo = processInfo; + this.isOneShotMode = targetSettings.Value.IsOneShotMode; + (this.shortcutModifiers, this.shortcutKey) = targetSettings.Value.OverlayShortcut.ToHotKey(); this.timer.Interval = TimeSpan.FromMilliseconds(10); this.timer.Tick += (s, e) => CheckTargetWindow(); } @@ -28,6 +38,12 @@ public CaptureMainWindow(IProcessInfoStore processInfo) private void Window_Loaded(object sender, RoutedEventArgs e) { this.timer.Start(); + if (this.isOneShotMode) + { + this.windowHandle = new WindowInteropHelper(this).Handle; + RegisterHotKey(new(this.windowHandle), 0, this.shortcutModifiers, (uint)this.shortcutKey); + HwndSource.FromHwnd(this.windowHandle).AddHook(WndProc); + } StrongReferenceMessenger.Default.Register(this, CloseIfViewModel); } @@ -45,9 +61,22 @@ protected override void OnClosed(EventArgs e) { base.OnClosed(e); this.timer.Stop(); + if (this.isOneShotMode) + { + UnregisterHotKey(new(this.windowHandle), 0); + } StrongReferenceMessenger.Default.Unregister(this); } + private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool handled) + { + if (msg == WM_HOTKEY && this.DataContext is CaptureMainViewModel viewModel) + { + viewModel.RequestOneShot(); + } + return 0; + } + private static void CloseIfViewModel(CaptureMainWindow w, CloseMessage m) { if (w.DataContext == m.ViewModel) diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index f6cdfb2c..cb8c8853 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -39,6 +39,7 @@ public abstract partial class MainViewModelBase : IDisposable private readonly double fontScale; private readonly double overlayOpacity; private readonly double mousePointerHitTestPadding; + private readonly bool isOneShotMode; private TextRect[]? lastRequested; [ObservableProperty] @@ -64,6 +65,7 @@ public abstract partial class MainViewModelBase : IDisposable public ObservableCollection OcrTexts { get; } = []; public string Font { get; } public double MousePointerHitTestPadding => this.mousePointerHitTestPadding; + public bool IsOneShotMode => this.isOneShotMode; public MainViewModelBase( IPresentationService presentationService, @@ -85,6 +87,7 @@ public MainViewModelBase( this.fontScale = options.Value.FontScale; this.overlayOpacity = options.Value.OverlayOpacity; this.mousePointerHitTestPadding = options.Value.MousePointerHitTestPadding; + this.isOneShotMode = options.Value.IsOneShotMode; this.DisplayBusy = options.Value.DisplayBusy; this.capture = capture ?? throw new ArgumentNullException(nameof(capture)); this.capture.Captured += Capture_CapturedAsync; @@ -96,13 +99,24 @@ public MainViewModelBase( this.filters = filters.ToArray(); this.logger = logger; this.capture.StartCapture(processInfoStore.MainWindowHandle); - this.timer = new(_ => Application.Current.Dispatcher.Invoke(() => CreateTextOverlayAsync().Forget()), null, 0, 500); + this.timer = new( + _ => Application.Current.Dispatcher.Invoke(() => CreateTextOverlayAsync().Forget()), + null, + this.isOneShotMode ? Timeout.Infinite : 0, + 500); var transAsm = this.translator.GetType().Assembly; this.title = $"{this.name} - {this.translator.Name} ({transAsm.GetName().Version})"; } partial void OnOverlayVisibleChanged(bool value) { + // OneShotではホットキー押下時点の最新フレームが必要なため、表示状態にかかわらず + // キャプチャーを継続する。表示の切り替えも前回の結果を破棄する契機にはしない。 + if (this.isOneShotMode) + { + return; + } + if (value) { this.OcrTexts.Clear(); @@ -128,12 +142,30 @@ private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args) var sbmp = Interlocked.Exchange(ref this.capturedBmp, newBmp); this.Width = newBmp.PixelWidth; this.Height = newBmp.PixelHeight; - CreateTextOverlayAsync().Forget(); + if (!this.isOneShotMode) + { + CreateTextOverlayAsync().Forget(); + } sbmp?.Dispose(); } - private async Task CreateTextOverlayAsync() + public void RequestOneShot() + { + if (!this.isOneShotMode) + { + return; + } + + this.logger.LogDebug("OneShot OCR requested"); + CreateTextOverlayAsync(true).Forget(); + } + + private async Task CreateTextOverlayAsync(bool oneShotRequested = false) { + if (this.isOneShotMode && !oneShotRequested) + { + return; + } if (!await this.analyzing.WaitAsync(0)) { return; @@ -163,8 +195,12 @@ private async Task CreateTextOverlayAsync() { try { - texts = await this.ocr.RecognizeAsync(sbmp); - texts = this.ocrTextTracker.Update(texts, new(sbmp.PixelWidth, sbmp.PixelHeight)); + var observations = await this.ocr.RecognizeAsync(sbmp); + texts = OcrObservationSelector.Select( + observations, + new(sbmp.PixelWidth, sbmp.PixelHeight), + this.ocrTextTracker, + this.isOneShotMode); } catch (ObjectDisposedException) { @@ -212,7 +248,15 @@ private async Task CreateTextOverlayAsync() using var t = this.logger.LogDebugTime("PreTranslate"); texts = await tmp.ToArrayAsync(); } - TranslateAsync(texts).Forget(); + if (this.isOneShotMode) + { + // 周期処理がないため、この1回の処理内で翻訳完了まで待って表示へ反映する。 + await TranslateAsync(texts); + } + else + { + TranslateAsync(texts).Forget(); + } texts = texts.Select(t => t switch { { TranslatedText: null } when this.cache.Contains(t.SourceText) => t with { TranslatedText = this.cache.Get(t.SourceText) }, @@ -232,6 +276,17 @@ private async Task CreateTextOverlayAsync() texts = texts.Select(t => t with { Background = Color.FromArgb((int)(255 * this.overlayOpacity), t.Background) }).ToArray(); } + if (this.isOneShotMode) + { + // 前回の矩形との同一性や位置関係を一切引き継がない。 + this.OcrTexts.Clear(); + foreach (var text in texts) + { + this.OcrTexts.Add(text); + } + return; + } + var hash = texts.ToHashSet(); foreach (var text in this.OcrTexts.Where(t => !hash.Contains(t)).ToArray()) { diff --git a/WindowTranslator/Modules/Main/OverlayMainWindow.xaml.cs b/WindowTranslator/Modules/Main/OverlayMainWindow.xaml.cs index 4f3cc39c..748652a0 100644 --- a/WindowTranslator/Modules/Main/OverlayMainWindow.xaml.cs +++ b/WindowTranslator/Modules/Main/OverlayMainWindow.xaml.cs @@ -219,6 +219,10 @@ private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool hand { return 0; } + if (this.DataContext is OverlayMainViewModel viewModel) + { + viewModel.RequestOneShot(); + } if (this.overlaySwitch == OverlaySwitch.Hold) { HoldHideOverlay(); diff --git a/WindowTranslator/Modules/Ocr/OcrObservationSelector.cs b/WindowTranslator/Modules/Ocr/OcrObservationSelector.cs new file mode 100644 index 00000000..b3ce3cf3 --- /dev/null +++ b/WindowTranslator/Modules/Ocr/OcrObservationSelector.cs @@ -0,0 +1,16 @@ +using System.Drawing; + +namespace WindowTranslator.Modules.Ocr; + +internal static class OcrObservationSelector +{ + public static IReadOnlyList Select( + IEnumerable observations, + Size imageSize, + IOcrTextTracker tracker, + bool isOneShotMode) + { + var current = observations.ToArray(); + return isOneShotMode ? current : tracker.Update(current, imageSize); + } +} diff --git a/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs b/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs index e034de78..4c17bfb6 100644 --- a/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs +++ b/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs @@ -262,6 +262,7 @@ public async Task SaveAsync(object window) }, PluginParams = t.Params.ToDictionary(p => p.GetType().Name), DisplayBusy = t.DisplayBusy, + IsOneShotMode = t.IsOneShotMode, OverlayOpacity = t.OverlayOpacity, MousePointerHitTestPadding = t.MousePointerHitTestPadding, }), @@ -467,10 +468,15 @@ public partial class TargetSettingsViewModel( [ObservableProperty] private bool displayBusy = settings.DisplayBusy; + [property: Category("SettingsViewModel|Misc")] + [property: SortIndex(9)] + [ObservableProperty] + private bool isOneShotMode = settings.IsOneShotMode; + [property: Category("SettingsViewModel|Misc")] [property: LocalizedDescription(typeof(Resources), $"{nameof(MousePointerHitTestPadding)}_Desc")] [property: Slidable(0, 100, 1, 10, true, 1)] - [property: SortIndex(9)] + [property: SortIndex(10)] [ObservableProperty] private double mousePointerHitTestPadding = settings.MousePointerHitTestPadding; diff --git a/WindowTranslator/Properties/Resources.Designer.cs b/WindowTranslator/Properties/Resources.Designer.cs index b74596b4..cddb06ab 100644 --- a/WindowTranslator/Properties/Resources.Designer.cs +++ b/WindowTranslator/Properties/Resources.Designer.cs @@ -307,6 +307,11 @@ internal Resources() { /// public static string IsEnableCaptureOverlay => ResourceManager.GetString("IsEnableCaptureOverlay", resourceCulture) ?? string.Empty; + /// + /// "ホットキーを押したときだけOCR・翻訳する" に類似しているローカライズされた文字列を検索します。 + /// + public static string IsOneShotMode => ResourceManager.GetString("IsOneShotMode", resourceCulture) ?? string.Empty; + /// /// "最新バージョンをご利用中です。" に類似しているローカライズされた文字列を検索します。 /// diff --git a/WindowTranslator/Properties/Resources.en.resx b/WindowTranslator/Properties/Resources.en.resx index ce3a7abc..080dd3a9 100644 --- a/WindowTranslator/Properties/Resources.en.resx +++ b/WindowTranslator/Properties/Resources.en.resx @@ -306,6 +306,9 @@ Show busy icon + + Run OCR and translation only when the hotkey is pressed + Display overlay translation only for text at mouse pointer position diff --git a/WindowTranslator/Properties/Resources.resx b/WindowTranslator/Properties/Resources.resx index 9990ab8c..a8780878 100644 --- a/WindowTranslator/Properties/Resources.resx +++ b/WindowTranslator/Properties/Resources.resx @@ -306,6 +306,9 @@ 処理中アイコンを表示する + + ホットキーを押したときだけOCR・翻訳する + マウスポインター位置のテキストのみオーバレイ翻訳を表示する From fc58d50be21112b31e95854aba3cbf4ef6ec5b84 Mon Sep 17 00:00:00 2001 From: Freesia Date: Tue, 11 Aug 2026 23:17:34 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E6=8C=87=E6=91=98=E3=82=92=E5=8F=8D=E6=98=A0=E3=81=97=E3=81=A6?= =?UTF-8?q?OneShot=E5=87=A6=E7=90=86=E3=82=92=E7=B0=A1=E7=B4=A0=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OcrObservationSelectorTests.cs | 58 ------- WindowTranslator/AssemblyInfo.cs | 5 +- .../Modules/Main/CaptureMainWindow.xaml.cs | 6 +- .../Modules/Main/MainViewModelBase.cs | 143 +++++++++--------- .../Modules/Ocr/OcrObservationSelector.cs | 16 -- 5 files changed, 78 insertions(+), 150 deletions(-) delete mode 100644 WindowTranslator.Tests/OcrObservationSelectorTests.cs delete mode 100644 WindowTranslator/Modules/Ocr/OcrObservationSelector.cs diff --git a/WindowTranslator.Tests/OcrObservationSelectorTests.cs b/WindowTranslator.Tests/OcrObservationSelectorTests.cs deleted file mode 100644 index b0d51f5d..00000000 --- a/WindowTranslator.Tests/OcrObservationSelectorTests.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Drawing; -using WindowTranslator.Modules.Ocr; - -namespace WindowTranslator.Tests; - -public class OcrObservationSelectorTests -{ - [Fact] - public void OneShotReturnsCurrentObservationsWithoutCallingTracker() - { - TextRect[] observations = [new("current", 10, 20, 100, 30, 18, false)]; - var tracker = new RecordingTracker - { - Result = [new("previous", 1, 2, 3, 4, 5, false)], - }; - - IReadOnlyList result = OcrObservationSelector.Select( - observations, - new Size(1920, 1080), - tracker, - true); - - Assert.False(tracker.WasCalled); - Assert.Equal(observations, result); - } - - [Fact] - public void ContinuousModeReturnsTrackerResult() - { - TextRect[] tracked = [new("tracked", 11, 22, 101, 31, 19, false)]; - var tracker = new RecordingTracker { Result = tracked }; - - IReadOnlyList result = OcrObservationSelector.Select( - [new("current", 10, 20, 100, 30, 18, false)], - new Size(1920, 1080), - tracker, - false); - - Assert.True(tracker.WasCalled); - Assert.Same(tracked, result); - } - - private sealed class RecordingTracker : IOcrTextTracker - { - public bool WasCalled { get; private set; } - public required IReadOnlyList Result { get; init; } - - public IReadOnlyList Update(IEnumerable observations, Size imageSize) - { - this.WasCalled = true; - return this.Result; - } - - public void Reset() - { - } - } -} diff --git a/WindowTranslator/AssemblyInfo.cs b/WindowTranslator/AssemblyInfo.cs index 117828e0..48ce7811 100644 --- a/WindowTranslator/AssemblyInfo.cs +++ b/WindowTranslator/AssemblyInfo.cs @@ -1,9 +1,6 @@ -using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Windows; -[assembly: InternalsVisibleTo("WindowTranslator.Tests")] - [assembly: ThemeInfo( ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located //(used if a resource is not found in the page, @@ -13,4 +10,4 @@ // app, or any theme specific resource dictionaries) )] -[assembly: SupportedOSPlatform("windows10.0.19041")] +[assembly: SupportedOSPlatform("windows10.0.19041")] \ No newline at end of file diff --git a/WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs b/WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs index 9b199aeb..955eb878 100644 --- a/WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs +++ b/WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs @@ -70,7 +70,11 @@ protected override void OnClosed(EventArgs e) private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool handled) { - if (msg == WM_HOTKEY && this.DataContext is CaptureMainViewModel viewModel) + if (msg != WM_HOTKEY) + { + return 0; + } + if (this.DataContext is CaptureMainViewModel viewModel) { viewModel.RequestOneShot(); } diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index cb8c8853..597463d5 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -22,7 +22,7 @@ namespace WindowTranslator.Modules.Main; [ObservableObject] public abstract partial class MainViewModelBase : IDisposable { - private readonly Timer timer; + private readonly Timer? timer; private readonly IOcrModule ocr; private readonly IOcrTextTracker ocrTextTracker; private readonly ITranslateModule translator; @@ -60,12 +60,12 @@ public abstract partial class MainViewModelBase : IDisposable private SoftwareBitmap? capturedBmp; private SoftwareBitmap? analyzingBmp; + private bool isFirstCapture; private bool disposedValue; public ObservableCollection OcrTexts { get; } = []; public string Font { get; } public double MousePointerHitTestPadding => this.mousePointerHitTestPadding; - public bool IsOneShotMode => this.isOneShotMode; public MainViewModelBase( IPresentationService presentationService, @@ -98,20 +98,18 @@ public MainViewModelBase( this.color = color ?? throw new ArgumentNullException(nameof(color)); this.filters = filters.ToArray(); this.logger = logger; - this.capture.StartCapture(processInfoStore.MainWindowHandle); - this.timer = new( - _ => Application.Current.Dispatcher.Invoke(() => CreateTextOverlayAsync().Forget()), - null, - this.isOneShotMode ? Timeout.Infinite : 0, - 500); + if (!this.isOneShotMode) + { + this.capture.StartCapture(processInfoStore.MainWindowHandle); + this.timer = new(_ => Application.Current.Dispatcher.Invoke(() => CreateTextOverlayAsync().Forget()), null, 0, 500); + } var transAsm = this.translator.GetType().Assembly; this.title = $"{this.name} - {this.translator.Name} ({transAsm.GetName().Version})"; } partial void OnOverlayVisibleChanged(bool value) { - // OneShotではホットキー押下時点の最新フレームが必要なため、表示状態にかかわらず - // キャプチャーを継続する。表示の切り替えも前回の結果を破棄する契機にはしない。 + // OneShotのキャプチャーはRequestOneShotで開始し、翻訳完了後に停止する。 if (this.isOneShotMode) { return; @@ -142,10 +140,7 @@ private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args) var sbmp = Interlocked.Exchange(ref this.capturedBmp, newBmp); this.Width = newBmp.PixelWidth; this.Height = newBmp.PixelHeight; - if (!this.isOneShotMode) - { - CreateTextOverlayAsync().Forget(); - } + CreateTextOverlayAsync().Forget(); sbmp?.Dispose(); } @@ -157,15 +152,14 @@ public void RequestOneShot() } this.logger.LogDebug("OneShot OCR requested"); - CreateTextOverlayAsync(true).Forget(); + this.OcrTexts.Clear(); + this.isFirstCapture = true; + this.capture.StopCapture(); + this.capture.StartCapture(this.processInfoStore.MainWindowHandle); } - private async Task CreateTextOverlayAsync(bool oneShotRequested = false) + private async Task CreateTextOverlayAsync() { - if (this.isOneShotMode && !oneShotRequested) - { - return; - } if (!await this.analyzing.WaitAsync(0)) { return; @@ -190,44 +184,51 @@ private async Task CreateTextOverlayAsync(bool oneShotRequested = false) return; } + var shouldRecognize = !this.isOneShotMode || this.isFirstCapture; IEnumerable texts; - using (this.Recognizing.EnterBusy()) + if (shouldRecognize) { - try - { - var observations = await this.ocr.RecognizeAsync(sbmp); - texts = OcrObservationSelector.Select( - observations, - new(sbmp.PixelWidth, sbmp.PixelHeight), - this.ocrTextTracker, - this.isOneShotMode); - } - catch (ObjectDisposedException) - { - // すでに破棄されている場合は何もしない - this.timer.DisposeAsync().Forget(); - this.capture.StopCapture(); - return; - } - catch (OperationCanceledException) - { - // キャンセルされた場合は何もしない - this.timer.DisposeAsync().Forget(); - this.capture.StopCapture(); - return; - } - catch (Exception e) + using (this.Recognizing.EnterBusy()) { - this.timer.DisposeAsync().Forget(); - this.capture.StopCapture(); - var path = Path.Combine(PathUtility.UserDir, $"ocr_error", $"{DateTime.UtcNow:yyyyMMdd'T'HHmmss'Z'}.png"); - await sbmp.TrySaveImage(path); - await this.presentationService.OpenErrorDialogAsync(Resources.FaildOcr, e, this.name, path); - StrongReferenceMessenger.Default.Send(new(this)); - return; + try + { + var observations = await this.ocr.RecognizeAsync(sbmp); + texts = this.isOneShotMode + ? observations.ToArray() + : this.ocrTextTracker.Update(observations, new(sbmp.PixelWidth, sbmp.PixelHeight)); + this.isFirstCapture = false; + } + catch (ObjectDisposedException) + { + // すでに破棄されている場合は何もしない + await DisposeTimerAsync(); + this.capture.StopCapture(); + return; + } + catch (OperationCanceledException) + { + // キャンセルされた場合は何もしない + await DisposeTimerAsync(); + this.capture.StopCapture(); + return; + } + catch (Exception e) + { + await DisposeTimerAsync(); + this.capture.StopCapture(); + var path = Path.Combine(PathUtility.UserDir, $"ocr_error", $"{DateTime.UtcNow:yyyyMMdd'T'HHmmss'Z'}.png"); + await sbmp.TrySaveImage(path); + await this.presentationService.OpenErrorDialogAsync(Resources.FaildOcr, e, this.name, path); + StrongReferenceMessenger.Default.Send(new(this)); + return; + } } + texts = texts.Select(t => t with { FontSize = t.FontSize * this.fontScale }); + } + else + { + texts = this.OcrTexts.ToArray(); } - texts = texts.Select(t => t with { FontSize = t.FontSize * this.fontScale }); // フィルター&翻訳処理は必ず通す using (this.Filtering.EnterBusy()) @@ -248,12 +249,7 @@ private async Task CreateTextOverlayAsync(bool oneShotRequested = false) using var t = this.logger.LogDebugTime("PreTranslate"); texts = await tmp.ToArrayAsync(); } - if (this.isOneShotMode) - { - // 周期処理がないため、この1回の処理内で翻訳完了まで待って表示へ反映する。 - await TranslateAsync(texts); - } - else + if (shouldRecognize) { TranslateAsync(texts).Forget(); } @@ -276,17 +272,6 @@ private async Task CreateTextOverlayAsync(bool oneShotRequested = false) texts = texts.Select(t => t with { Background = Color.FromArgb((int)(255 * this.overlayOpacity), t.Background) }).ToArray(); } - if (this.isOneShotMode) - { - // 前回の矩形との同一性や位置関係を一切引き継がない。 - this.OcrTexts.Clear(); - foreach (var text in texts) - { - this.OcrTexts.Add(text); - } - return; - } - var hash = texts.ToHashSet(); foreach (var text in this.OcrTexts.Where(t => !hash.Contains(t)).ToArray()) { @@ -297,6 +282,10 @@ private async Task CreateTextOverlayAsync(bool oneShotRequested = false) { this.OcrTexts.Add(text); } + if (this.isOneShotMode && this.OcrTexts.All(t => t.TranslatedText is not null)) + { + this.capture.StopCapture(); + } } private async Task TranslateAsync(IEnumerable texts) @@ -331,11 +320,15 @@ private async Task TranslateAsync(IEnumerable texts) this.logger.LogDebug("Translate"); var translated = await this.translator.TranslateAsync(requests).ConfigureAwait(false); this.cache.AddRange(requests.Select(t => t.SourceText).Zip(translated)); + if (this.isOneShotMode) + { + _ = Application.Current.Dispatcher.BeginInvoke(() => CreateTextOverlayAsync().Forget()); + } } catch (Exception e) when (e is not OperationCanceledException) { this.logger.LogError(e, "翻訳中にエラーが発生"); - this.timer.DisposeAsync().Forget(); + await DisposeTimerAsync(); this.capture.StopCapture(); // 翻訳失敗してエラーで閉じる場合はキューをクリア Interlocked.Exchange(ref this.lastRequested, null); @@ -348,6 +341,14 @@ private async Task TranslateAsync(IEnumerable texts) } } + private async ValueTask DisposeTimerAsync() + { + if (this.timer is { } timer) + { + await timer.DisposeAsync(); + } + } + protected virtual void Dispose(bool disposing) { if (disposedValue) diff --git a/WindowTranslator/Modules/Ocr/OcrObservationSelector.cs b/WindowTranslator/Modules/Ocr/OcrObservationSelector.cs deleted file mode 100644 index b3ce3cf3..00000000 --- a/WindowTranslator/Modules/Ocr/OcrObservationSelector.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Drawing; - -namespace WindowTranslator.Modules.Ocr; - -internal static class OcrObservationSelector -{ - public static IReadOnlyList Select( - IEnumerable observations, - Size imageSize, - IOcrTextTracker tracker, - bool isOneShotMode) - { - var current = observations.ToArray(); - return isOneShotMode ? current : tracker.Update(current, imageSize); - } -} From 025b37d31f6e73c624df5a70cc692ce982f4ec78 Mon Sep 17 00:00:00 2001 From: Freesia Date: Fri, 14 Aug 2026 22:09:51 +0900 Subject: [PATCH 3/5] =?UTF-8?q?OneShot=E3=82=92=E6=9C=80=E6=96=B0=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E3=81=AE=E5=8D=98=E7=99=BA=E5=87=A6=E7=90=86=E3=81=AB?= =?UTF-8?q?=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/Main/MainViewModelBase.cs | 255 ++++++++++++------ 1 file changed, 180 insertions(+), 75 deletions(-) diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index 597463d5..6f41219a 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -60,7 +60,8 @@ public abstract partial class MainViewModelBase : IDisposable private SoftwareBitmap? capturedBmp; private SoftwareBitmap? analyzingBmp; - private bool isFirstCapture; + private int oneShotRequestId; + private int oneShotCapturedRequestId; private bool disposedValue; public ObservableCollection OcrTexts { get; } = []; @@ -132,6 +133,29 @@ partial void OnOverlayVisibleChanged(bool value) private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args) { + if (this.isOneShotMode) + { + int requestId = Volatile.Read(ref this.oneShotRequestId); + if (requestId == 0 || requestId == Volatile.Read(ref this.oneShotCapturedRequestId)) + { + return; + } + + var bitmap = await SoftwareBitmap.CreateCopyFromSurfaceAsync(args.Frame.Surface); + if (!this.IsOneShotRequestCurrent(requestId) + || Interlocked.Exchange(ref this.oneShotCapturedRequestId, requestId) == requestId) + { + bitmap.Dispose(); + return; + } + + this.Width = bitmap.PixelWidth; + this.Height = bitmap.PixelHeight; + this.capture.StopCapture(); + CreateTextOverlayAsync(bitmap, requestId).Forget(); + return; + } + if (this.analyzing.CurrentCount == 0) { return; @@ -152,126 +176,170 @@ public void RequestOneShot() } this.logger.LogDebug("OneShot OCR requested"); + Interlocked.Increment(ref this.oneShotRequestId); this.OcrTexts.Clear(); - this.isFirstCapture = true; this.capture.StopCapture(); this.capture.StartCapture(this.processInfoStore.MainWindowHandle); } - private async Task CreateTextOverlayAsync() + private async Task CreateTextOverlayAsync(SoftwareBitmap? oneShotBitmap = null, int requestId = 0) { - if (!await this.analyzing.WaitAsync(0)) + bool isOneShotRequest = oneShotBitmap is not null; + if (isOneShotRequest) { - return; + await this.analyzing.WaitAsync(); } - using var to = this.logger.LogDebugTime("TextOverlay"); - using var rel = new DisposeAction(() => - { - this.analyzing.Release(); - }); - var sbmp = Interlocked.Exchange(ref this.capturedBmp, null); - if (sbmp is null) + else if (!await this.analyzing.WaitAsync(0)) { - sbmp = this.analyzingBmp; + return; } - else + + using var to = this.logger.LogDebugTime("TextOverlay"); + using var rel = new DisposeAction(() => this.analyzing.Release()); + using var ownedOneShotBitmap = oneShotBitmap; + var sbmp = oneShotBitmap ?? Interlocked.Exchange(ref this.capturedBmp, null); + if (!isOneShotRequest) { - this.analyzingBmp?.Dispose(); - this.analyzingBmp = sbmp; + if (sbmp is null) + { + sbmp = this.analyzingBmp; + } + else + { + this.analyzingBmp?.Dispose(); + this.analyzingBmp = sbmp; + } } if (sbmp is null) { return; } - var shouldRecognize = !this.isOneShotMode || this.isFirstCapture; IEnumerable texts; - if (shouldRecognize) + using (this.Recognizing.EnterBusy()) { - using (this.Recognizing.EnterBusy()) + try { - try - { - var observations = await this.ocr.RecognizeAsync(sbmp); - texts = this.isOneShotMode - ? observations.ToArray() - : this.ocrTextTracker.Update(observations, new(sbmp.PixelWidth, sbmp.PixelHeight)); - this.isFirstCapture = false; - } - catch (ObjectDisposedException) + var observations = await this.ocr.RecognizeAsync(sbmp); + texts = isOneShotRequest + ? observations.ToArray() + : this.ocrTextTracker.Update(observations, new(sbmp.PixelWidth, sbmp.PixelHeight)); + } + catch (ObjectDisposedException) + { + if (!isOneShotRequest) { - // すでに破棄されている場合は何もしない await DisposeTimerAsync(); this.capture.StopCapture(); - return; } - catch (OperationCanceledException) + return; + } + catch (OperationCanceledException) + { + if (!isOneShotRequest) { - // キャンセルされた場合は何もしない await DisposeTimerAsync(); this.capture.StopCapture(); + } + return; + } + catch (Exception e) + { + if (isOneShotRequest && !this.IsOneShotRequestCurrent(requestId)) + { + this.logger.LogDebug(e, "置き換えられたOneShot要求のOCRエラーを無視"); return; } - catch (Exception e) + if (!isOneShotRequest) { await DisposeTimerAsync(); this.capture.StopCapture(); - var path = Path.Combine(PathUtility.UserDir, $"ocr_error", $"{DateTime.UtcNow:yyyyMMdd'T'HHmmss'Z'}.png"); - await sbmp.TrySaveImage(path); - await this.presentationService.OpenErrorDialogAsync(Resources.FaildOcr, e, this.name, path); - StrongReferenceMessenger.Default.Send(new(this)); - return; } + var path = Path.Combine(PathUtility.UserDir, $"ocr_error", $"{DateTime.UtcNow:yyyyMMdd'T'HHmmss'Z'}.png"); + await sbmp.TrySaveImage(path); + await this.presentationService.OpenErrorDialogAsync(Resources.FaildOcr, e, this.name, path); + StrongReferenceMessenger.Default.Send(new(this)); + return; } texts = texts.Select(t => t with { FontSize = t.FontSize * this.fontScale }); } - else + + if (isOneShotRequest && !this.IsOneShotRequestCurrent(requestId)) { - texts = this.OcrTexts.ToArray(); + return; } - // フィルター&翻訳処理は必ず通す + FilterContext context; + TextRect[] displayedTexts; using (this.Filtering.EnterBusy()) { texts = await this.color.ConvertColorAsync(sbmp, texts); - - var context = new FilterContext() + context = new() { SoftwareBitmap = sbmp, ImageSize = new(sbmp.PixelWidth, sbmp.PixelHeight), }; + var tmp = texts.ToAsyncEnumerable(); + foreach (var filter in this.filters.OrderByDescending(f => f.Priority)) { - var tmp = texts.ToAsyncEnumerable(); - foreach (var filter in this.filters.OrderByDescending(f => f.Priority)) - { - tmp = filter.ExecutePreTranslate(tmp, context); - } - using var t = this.logger.LogDebugTime("PreTranslate"); - texts = await tmp.ToArrayAsync(); + tmp = filter.ExecutePreTranslate(tmp, context); } - if (shouldRecognize) + using var t = this.logger.LogDebugTime("PreTranslate"); + texts = await tmp.ToArrayAsync(); + if (!isOneShotRequest) { TranslateAsync(texts).Forget(); } - texts = texts.Select(t => t switch - { - { TranslatedText: null } when this.cache.Contains(t.SourceText) => t with { TranslatedText = this.cache.Get(t.SourceText) }, - _ => t, - }).ToArray(); - { - var tmp = texts.ToAsyncEnumerable(); - foreach (var filter in this.filters.OrderBy(f => f.Priority)) - { - tmp = filter.ExecutePostTranslate(tmp, context); - } - using var t = this.logger.LogDebugTime("PostTranslate"); - texts = await tmp.ToArrayAsync(); - } + displayedTexts = await CreateDisplayedTextsAsync(texts, context); + } + + if (isOneShotRequest && !this.IsOneShotRequestCurrent(requestId)) + { + return; + } - // 背景色に不透明度を設定 - texts = texts.Select(t => t with { Background = Color.FromArgb((int)(255 * this.overlayOpacity), t.Background) }).ToArray(); + UpdateOcrTexts(displayedTexts, requestId); + if (!isOneShotRequest) + { + return; + } + + if (!await TranslateOneShotAsync(texts, requestId) + || !this.IsOneShotRequestCurrent(requestId)) + { + return; } + using (this.Filtering.EnterBusy()) + { + displayedTexts = await CreateDisplayedTextsAsync(texts, context); + } + UpdateOcrTexts(displayedTexts, requestId); + } + + private async Task CreateDisplayedTextsAsync(IEnumerable texts, FilterContext context) + { + texts = texts.Select(t => t switch + { + { TranslatedText: null } when this.cache.Contains(t.SourceText) => t with { TranslatedText = this.cache.Get(t.SourceText) }, + _ => t, + }).ToArray(); + var tmp = texts.ToAsyncEnumerable(); + foreach (var filter in this.filters.OrderBy(f => f.Priority)) + { + tmp = filter.ExecutePostTranslate(tmp, context); + } + using var t = this.logger.LogDebugTime("PostTranslate"); + texts = await tmp.ToArrayAsync(); + return texts.Select(t => t with { Background = Color.FromArgb((int)(255 * this.overlayOpacity), t.Background) }).ToArray(); + } + + private void UpdateOcrTexts(IEnumerable texts, int requestId = 0) + { + if (requestId != 0 && !this.IsOneShotRequestCurrent(requestId)) + { + return; + } var hash = texts.ToHashSet(); foreach (var text in this.OcrTexts.Where(t => !hash.Contains(t)).ToArray()) { @@ -282,10 +350,6 @@ private async Task CreateTextOverlayAsync() { this.OcrTexts.Add(text); } - if (this.isOneShotMode && this.OcrTexts.All(t => t.TranslatedText is not null)) - { - this.capture.StopCapture(); - } } private async Task TranslateAsync(IEnumerable texts) @@ -320,10 +384,6 @@ private async Task TranslateAsync(IEnumerable texts) this.logger.LogDebug("Translate"); var translated = await this.translator.TranslateAsync(requests).ConfigureAwait(false); this.cache.AddRange(requests.Select(t => t.SourceText).Zip(translated)); - if (this.isOneShotMode) - { - _ = Application.Current.Dispatcher.BeginInvoke(() => CreateTextOverlayAsync().Forget()); - } } catch (Exception e) when (e is not OperationCanceledException) { @@ -341,6 +401,51 @@ private async Task TranslateAsync(IEnumerable texts) } } + private async Task TranslateOneShotAsync(IEnumerable texts, int requestId) + { + await this.translating.WaitAsync().ConfigureAwait(false); + try + { + if (!this.IsOneShotRequestCurrent(requestId) || this.disposedValue) + { + return false; + } + + var requests = texts + .Where(t => t.TranslatedText is null) + .Where(t => !this.cache.Contains(t.SourceText)) + .ToArray(); + if (!requests.Any()) + { + return true; + } + + this.logger.LogDebug("Translate OneShot"); + var translated = await this.translator.TranslateAsync(requests).ConfigureAwait(false); + this.cache.AddRange(requests.Select(t => t.SourceText).Zip(translated)); + return true; + } + catch (Exception e) when (e is not OperationCanceledException) + { + this.logger.LogError(e, "OneShot翻訳中にエラーが発生"); + if (!this.IsOneShotRequestCurrent(requestId)) + { + return false; + } + + await this.presentationService.OpenErrorDialogAsync(Resources.FaildOverlay, e, this.name, string.Empty); + StrongReferenceMessenger.Default.Send(new(this)); + return false; + } + finally + { + this.translating.Release(); + } + } + + private bool IsOneShotRequestCurrent(int requestId) + => requestId == Volatile.Read(ref this.oneShotRequestId); + private async ValueTask DisposeTimerAsync() { if (this.timer is { } timer) From c2738938266e55b10d004917435d33c76b9733d9 Mon Sep 17 00:00:00 2001 From: Freesia Date: Mon, 24 Aug 2026 23:01:21 +0900 Subject: [PATCH 4/5] =?UTF-8?q?OneShot=E3=82=92=E5=88=9D=E5=9B=9E=E3=83=95?= =?UTF-8?q?=E3=83=AC=E3=83=BC=E3=83=A0=E5=87=A6=E7=90=86=E3=81=AB=E7=B0=A1?= =?UTF-8?q?=E7=B4=A0=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/Main/MainViewModelBase.cs | 159 ++++-------------- 1 file changed, 29 insertions(+), 130 deletions(-) diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index 6f41219a..90fdd47c 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -60,8 +60,7 @@ public abstract partial class MainViewModelBase : IDisposable private SoftwareBitmap? capturedBmp; private SoftwareBitmap? analyzingBmp; - private int oneShotRequestId; - private int oneShotCapturedRequestId; + private bool isFirstCapture; private bool disposedValue; public ObservableCollection OcrTexts { get; } = []; @@ -110,7 +109,7 @@ public MainViewModelBase( partial void OnOverlayVisibleChanged(bool value) { - // OneShotのキャプチャーはRequestOneShotで開始し、翻訳完了後に停止する。 + // OneShotのキャプチャーはRequestOneShotで開始する。 if (this.isOneShotMode) { return; @@ -135,25 +134,12 @@ private async Task Capture_CapturedAsync(object? sender, CapturedEventArgs args) { if (this.isOneShotMode) { - int requestId = Volatile.Read(ref this.oneShotRequestId); - if (requestId == 0 || requestId == Volatile.Read(ref this.oneShotCapturedRequestId)) + if (!this.isFirstCapture) { return; } - - var bitmap = await SoftwareBitmap.CreateCopyFromSurfaceAsync(args.Frame.Surface); - if (!this.IsOneShotRequestCurrent(requestId) - || Interlocked.Exchange(ref this.oneShotCapturedRequestId, requestId) == requestId) - { - bitmap.Dispose(); - return; - } - - this.Width = bitmap.PixelWidth; - this.Height = bitmap.PixelHeight; + this.isFirstCapture = false; this.capture.StopCapture(); - CreateTextOverlayAsync(bitmap, requestId).Forget(); - return; } if (this.analyzing.CurrentCount == 0) @@ -176,39 +162,29 @@ public void RequestOneShot() } this.logger.LogDebug("OneShot OCR requested"); - Interlocked.Increment(ref this.oneShotRequestId); this.OcrTexts.Clear(); - this.capture.StopCapture(); + this.isFirstCapture = true; this.capture.StartCapture(this.processInfoStore.MainWindowHandle); } - private async Task CreateTextOverlayAsync(SoftwareBitmap? oneShotBitmap = null, int requestId = 0) + private async Task CreateTextOverlayAsync() { - bool isOneShotRequest = oneShotBitmap is not null; - if (isOneShotRequest) - { - await this.analyzing.WaitAsync(); - } - else if (!await this.analyzing.WaitAsync(0)) + if (!await this.analyzing.WaitAsync(0)) { return; } using var to = this.logger.LogDebugTime("TextOverlay"); using var rel = new DisposeAction(() => this.analyzing.Release()); - using var ownedOneShotBitmap = oneShotBitmap; - var sbmp = oneShotBitmap ?? Interlocked.Exchange(ref this.capturedBmp, null); - if (!isOneShotRequest) + var sbmp = Interlocked.Exchange(ref this.capturedBmp, null); + if (sbmp is null) { - if (sbmp is null) - { - sbmp = this.analyzingBmp; - } - else - { - this.analyzingBmp?.Dispose(); - this.analyzingBmp = sbmp; - } + sbmp = this.analyzingBmp; + } + else + { + this.analyzingBmp?.Dispose(); + this.analyzingBmp = sbmp; } if (sbmp is null) { @@ -221,40 +197,26 @@ private async Task CreateTextOverlayAsync(SoftwareBitmap? oneShotBitmap = null, try { var observations = await this.ocr.RecognizeAsync(sbmp); - texts = isOneShotRequest - ? observations.ToArray() + texts = this.isOneShotMode + ? observations : this.ocrTextTracker.Update(observations, new(sbmp.PixelWidth, sbmp.PixelHeight)); } catch (ObjectDisposedException) { - if (!isOneShotRequest) - { - await DisposeTimerAsync(); - this.capture.StopCapture(); - } + await DisposeTimerAsync(); + this.capture.StopCapture(); return; } catch (OperationCanceledException) { - if (!isOneShotRequest) - { - await DisposeTimerAsync(); - this.capture.StopCapture(); - } + await DisposeTimerAsync(); + this.capture.StopCapture(); return; } catch (Exception e) { - if (isOneShotRequest && !this.IsOneShotRequestCurrent(requestId)) - { - this.logger.LogDebug(e, "置き換えられたOneShot要求のOCRエラーを無視"); - return; - } - if (!isOneShotRequest) - { - await DisposeTimerAsync(); - this.capture.StopCapture(); - } + await DisposeTimerAsync(); + this.capture.StopCapture(); var path = Path.Combine(PathUtility.UserDir, $"ocr_error", $"{DateTime.UtcNow:yyyyMMdd'T'HHmmss'Z'}.png"); await sbmp.TrySaveImage(path); await this.presentationService.OpenErrorDialogAsync(Resources.FaildOcr, e, this.name, path); @@ -264,11 +226,6 @@ private async Task CreateTextOverlayAsync(SoftwareBitmap? oneShotBitmap = null, texts = texts.Select(t => t with { FontSize = t.FontSize * this.fontScale }); } - if (isOneShotRequest && !this.IsOneShotRequestCurrent(requestId)) - { - return; - } - FilterContext context; TextRect[] displayedTexts; using (this.Filtering.EnterBusy()) @@ -286,35 +243,26 @@ private async Task CreateTextOverlayAsync(SoftwareBitmap? oneShotBitmap = null, } using var t = this.logger.LogDebugTime("PreTranslate"); texts = await tmp.ToArrayAsync(); - if (!isOneShotRequest) + if (!this.isOneShotMode) { TranslateAsync(texts).Forget(); } displayedTexts = await CreateDisplayedTextsAsync(texts, context); } - if (isOneShotRequest && !this.IsOneShotRequestCurrent(requestId)) - { - return; - } - - UpdateOcrTexts(displayedTexts, requestId); - if (!isOneShotRequest) + UpdateOcrTexts(displayedTexts); + if (!this.isOneShotMode) { return; } - if (!await TranslateOneShotAsync(texts, requestId) - || !this.IsOneShotRequestCurrent(requestId)) - { - return; - } + await TranslateAsync(texts); using (this.Filtering.EnterBusy()) { displayedTexts = await CreateDisplayedTextsAsync(texts, context); } - UpdateOcrTexts(displayedTexts, requestId); + UpdateOcrTexts(displayedTexts); } private async Task CreateDisplayedTextsAsync(IEnumerable texts, FilterContext context) @@ -334,12 +282,8 @@ private async Task CreateDisplayedTextsAsync(IEnumerable t return texts.Select(t => t with { Background = Color.FromArgb((int)(255 * this.overlayOpacity), t.Background) }).ToArray(); } - private void UpdateOcrTexts(IEnumerable texts, int requestId = 0) + private void UpdateOcrTexts(IEnumerable texts) { - if (requestId != 0 && !this.IsOneShotRequestCurrent(requestId)) - { - return; - } var hash = texts.ToHashSet(); foreach (var text in this.OcrTexts.Where(t => !hash.Contains(t)).ToArray()) { @@ -401,51 +345,6 @@ private async Task TranslateAsync(IEnumerable texts) } } - private async Task TranslateOneShotAsync(IEnumerable texts, int requestId) - { - await this.translating.WaitAsync().ConfigureAwait(false); - try - { - if (!this.IsOneShotRequestCurrent(requestId) || this.disposedValue) - { - return false; - } - - var requests = texts - .Where(t => t.TranslatedText is null) - .Where(t => !this.cache.Contains(t.SourceText)) - .ToArray(); - if (!requests.Any()) - { - return true; - } - - this.logger.LogDebug("Translate OneShot"); - var translated = await this.translator.TranslateAsync(requests).ConfigureAwait(false); - this.cache.AddRange(requests.Select(t => t.SourceText).Zip(translated)); - return true; - } - catch (Exception e) when (e is not OperationCanceledException) - { - this.logger.LogError(e, "OneShot翻訳中にエラーが発生"); - if (!this.IsOneShotRequestCurrent(requestId)) - { - return false; - } - - await this.presentationService.OpenErrorDialogAsync(Resources.FaildOverlay, e, this.name, string.Empty); - StrongReferenceMessenger.Default.Send(new(this)); - return false; - } - finally - { - this.translating.Release(); - } - } - - private bool IsOneShotRequestCurrent(int requestId) - => requestId == Volatile.Read(ref this.oneShotRequestId); - private async ValueTask DisposeTimerAsync() { if (this.timer is { } timer) From fea2da214e73ee0f71c04c6edc2bcfc2054578f8 Mon Sep 17 00:00:00 2001 From: Freesia Date: Mon, 24 Aug 2026 23:15:33 +0900 Subject: [PATCH 5/5] =?UTF-8?q?OneShot=E3=81=A8=E7=84=A1=E9=96=A2=E4=BF=82?= =?UTF-8?q?=E3=81=AA=E5=B7=AE=E5=88=86=E3=82=92=E9=99=A4=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OcrTextTrackerAccuracyTests.cs | 5 +-- .../Modules/Main/CaptureMainWindow.xaml.cs | 35 +------------------ .../Modules/Main/MainViewModelBase.cs | 23 +++++++----- 3 files changed, 17 insertions(+), 46 deletions(-) diff --git a/WindowTranslator.Tests/OcrTextTrackerAccuracyTests.cs b/WindowTranslator.Tests/OcrTextTrackerAccuracyTests.cs index d57c0220..179cb845 100644 --- a/WindowTranslator.Tests/OcrTextTrackerAccuracyTests.cs +++ b/WindowTranslator.Tests/OcrTextTrackerAccuracyTests.cs @@ -1705,16 +1705,13 @@ public void DormantChildrenDoNotConsumeAnActiveTracksObservation() } [Fact] - public void RemovedBufferFeaturesAreNotExposedAndOneShotIsAvailable() + public void RemovedFeaturesAreNotExposed() { const System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic; - Type appResources = typeof(OcrTextTracker).Assembly.GetType("WindowTranslator.Properties.Resources", throwOnError: true)!; Type abstractionResources = typeof(TextRect).Assembly.GetType("WindowTranslator.Properties.Resources", throwOnError: true)!; - Assert.NotNull(appResources.GetProperty("IsOneShotMode", flags)); - Assert.NotNull(typeof(TargetSettings).GetProperty(nameof(TargetSettings.IsOneShotMode))); Assert.Null(abstractionResources.GetProperty("Buffer", flags)); Assert.Null(abstractionResources.GetProperty("BufferSize", flags)); Assert.Null(abstractionResources.GetProperty("IsSuppressVibe", flags)); diff --git a/WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs b/WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs index 955eb878..56f30811 100644 --- a/WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs +++ b/WindowTranslator/Modules/Main/CaptureMainWindow.xaml.cs @@ -1,13 +1,9 @@ using System.Runtime.InteropServices; using System.Windows; -using System.Windows.Interop; using System.Windows.Threading; using CommunityToolkit.Mvvm.Messaging; -using Microsoft.Extensions.Options; using Windows.Win32.Foundation; -using Windows.Win32.UI.Input.KeyboardAndMouse; using Windows.Win32.UI.WindowsAndMessaging; -using WindowTranslator.Extensions; using WindowTranslator.Stores; using static Windows.Win32.PInvoke; @@ -20,17 +16,11 @@ public partial class CaptureMainWindow { private readonly IProcessInfoStore processInfo; private readonly DispatcherTimer timer = new(); - private readonly bool isOneShotMode; - private readonly HOT_KEY_MODIFIERS shortcutModifiers; - private readonly int shortcutKey; - private IntPtr windowHandle; - public CaptureMainWindow(IProcessInfoStore processInfo, IOptionsSnapshot targetSettings) + public CaptureMainWindow(IProcessInfoStore processInfo) { InitializeComponent(); this.processInfo = processInfo; - this.isOneShotMode = targetSettings.Value.IsOneShotMode; - (this.shortcutModifiers, this.shortcutKey) = targetSettings.Value.OverlayShortcut.ToHotKey(); this.timer.Interval = TimeSpan.FromMilliseconds(10); this.timer.Tick += (s, e) => CheckTargetWindow(); } @@ -38,12 +28,6 @@ public CaptureMainWindow(IProcessInfoStore processInfo, IOptionsSnapshot(this, CloseIfViewModel); } @@ -61,26 +45,9 @@ protected override void OnClosed(EventArgs e) { base.OnClosed(e); this.timer.Stop(); - if (this.isOneShotMode) - { - UnregisterHotKey(new(this.windowHandle), 0); - } StrongReferenceMessenger.Default.Unregister(this); } - private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool handled) - { - if (msg != WM_HOTKEY) - { - return 0; - } - if (this.DataContext is CaptureMainViewModel viewModel) - { - viewModel.RequestOneShot(); - } - return 0; - } - private static void CloseIfViewModel(CaptureMainWindow w, CloseMessage m) { if (w.DataContext == m.ViewModel) diff --git a/WindowTranslator/Modules/Main/MainViewModelBase.cs b/WindowTranslator/Modules/Main/MainViewModelBase.cs index 90fdd47c..213b7b8a 100644 --- a/WindowTranslator/Modules/Main/MainViewModelBase.cs +++ b/WindowTranslator/Modules/Main/MainViewModelBase.cs @@ -173,9 +173,11 @@ private async Task CreateTextOverlayAsync() { return; } - using var to = this.logger.LogDebugTime("TextOverlay"); - using var rel = new DisposeAction(() => this.analyzing.Release()); + using var rel = new DisposeAction(() => + { + this.analyzing.Release(); + }); var sbmp = Interlocked.Exchange(ref this.capturedBmp, null); if (sbmp is null) { @@ -203,12 +205,14 @@ private async Task CreateTextOverlayAsync() } catch (ObjectDisposedException) { + // すでに破棄されている場合は何もしない await DisposeTimerAsync(); this.capture.StopCapture(); return; } catch (OperationCanceledException) { + // キャンセルされた場合は何もしない await DisposeTimerAsync(); this.capture.StopCapture(); return; @@ -223,9 +227,10 @@ private async Task CreateTextOverlayAsync() StrongReferenceMessenger.Default.Send(new(this)); return; } - texts = texts.Select(t => t with { FontSize = t.FontSize * this.fontScale }); } + texts = texts.Select(t => t with { FontSize = t.FontSize * this.fontScale }); + // フィルター&翻訳処理は必ず通す FilterContext context; TextRect[] displayedTexts; using (this.Filtering.EnterBusy()) @@ -236,13 +241,15 @@ private async Task CreateTextOverlayAsync() SoftwareBitmap = sbmp, ImageSize = new(sbmp.PixelWidth, sbmp.PixelHeight), }; - var tmp = texts.ToAsyncEnumerable(); - foreach (var filter in this.filters.OrderByDescending(f => f.Priority)) { - tmp = filter.ExecutePreTranslate(tmp, context); + var tmp = texts.ToAsyncEnumerable(); + foreach (var filter in this.filters.OrderByDescending(f => f.Priority)) + { + tmp = filter.ExecutePreTranslate(tmp, context); + } + using var t = this.logger.LogDebugTime("PreTranslate"); + texts = await tmp.ToArrayAsync(); } - using var t = this.logger.LogDebugTime("PreTranslate"); - texts = await tmp.ToArrayAsync(); if (!this.isOneShotMode) { TranslateAsync(texts).Forget();