diff --git a/src/ui/Assets/Languages/English.json b/src/ui/Assets/Languages/English.json index 3d21e4d417..7ec8084d00 100644 --- a/src/ui/Assets/Languages/English.json +++ b/src/ui/Assets/Languages/English.json @@ -2170,7 +2170,11 @@ "unableToReadVideoTitle": "Unable to read video", "unableToReadVideoMessage": "Could not read video info from: {0}", "abortOcrTitle": "Abort OCR?", - "abortOcrMessage": "Do you want to abort the running OCR?" + "abortOcrMessage": "Do you want to abort the running OCR?", + "testOcr": "Test current frame", + "testOcrRunning": "Testing OCR on current frame...", + "testOcrResultX": "Test result: {0}", + "testOcrNoTextFound": "Test: no text found in the scan area" }, "goToVideoPosition": "Go to video position", "generateBlankVideoDotDotDot": "Generate blank video...", diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs b/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs index 3facc8e48e..0f39ac986c 100644 --- a/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs +++ b/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs @@ -428,6 +428,126 @@ private void SetScanAreaFullFrame() CropSelector?.SetSelectionVideoRect(0, 0, VideoWidth, VideoHeight); } + /// + /// OCRs only the frame at the current preview position so the user can validate the scan + /// area, engine, and settings without scanning the whole video. The result is shown in the + /// status text. + /// + [RelayCommand] + private async Task TestOcr() + { + if (IsRunning || string.IsNullOrEmpty(_videoFileName) || VideoWidth <= 0 || VideoHeight <= 0) + { + return; + } + + var engineOk = await EnsureEngineIsAvailable(); + if (!engineOk) + { + return; + } + + ClampSelection(); + + _cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = _cancellationTokenSource.Token; + + IsRunning = true; + ProgressText = Se.Language.Video.VideoOcr.TestOcrRunning; + + var frameFileName = Path.Combine(Path.GetTempPath(), "se_video_ocr_test_" + Guid.NewGuid() + ".jpg"); + try + { + await ExtractSingleFrame(frameFileName, PreviewPositionSeconds, cancellationToken); + if (!File.Exists(frameFileName) || new FileInfo(frameFileName).Length == 0) + { + throw new Exception("Could not extract the current frame - see log for the ffmpeg command line."); + } + + var group = new VideoOcrFrameGroup { RepresentativeFileName = frameFileName }; + await OcrGroups(new List { group }, () => { }, _ => { }, cancellationToken); + + ProgressText = string.IsNullOrWhiteSpace(group.Text) + ? Se.Language.Video.VideoOcr.TestOcrNoTextFound + : string.Format(Se.Language.Video.VideoOcr.TestOcrResultX, group.Text.ReplaceLineEndings(" | ")); + } + catch (OperationCanceledException) + { + ProgressText = string.Empty; + } + catch (Exception exception) + { + Se.LogError(exception, "Video OCR: test on current frame failed"); + ProgressText = string.Empty; + + await MessageBox.Show( + Window!, + Se.Language.General.Error, + exception.Message, + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + finally + { + IsRunning = false; + + try + { + File.Delete(frameFileName); + } + catch + { + // ignore + } + } + } + + private async Task ExtractSingleFrame(string outputFileName, double positionSeconds, CancellationToken cancellationToken) + { + var scale = string.Empty; + var maxImageWidth = Se.Settings.Video.VideoOcr.MaxImageWidth; + if (maxImageWidth > 0 && SelectionWidth > maxImageWidth) + { + scale = $",scale={maxImageWidth}:-2"; + } + + // -ss before -i: seek in the demuxer, so a test frame late in a long video is still fast. + var arguments = $"-nostdin -y -ss {positionSeconds.ToString("0.###", CultureInfo.InvariantCulture)} " + + $"-i \"{_videoFileName}\" " + + $"-vf \"crop={SelectionWidth}:{SelectionHeight}:{SelectionX}:{SelectionY}{scale}\" " + + $"-frames:v 1 -q:v 2 \"{outputFileName}\""; + + Se.WriteToolsLog("Video OCR: extracting test frame - ffmpeg " + arguments); + var process = FfmpegGenerator.GetProcess(arguments, (_, _) => { }); + +#pragma warning disable CA1416 // Validate platform compatibility + process.Start(); +#pragma warning restore CA1416 + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + try + { + if (!process.HasExited) + { + process.Kill(true); + } + } + catch + { + // ignore + } + + throw; + } + } + [RelayCommand] private async Task StartOcr() { @@ -692,6 +812,15 @@ void AddPreviewLine(VideoOcrFrameGroup group) ProgressText = string.Format(Se.Language.Video.VideoOcr.RunningOcrXY, 0, ocrGroups.Count); }); + await OcrGroups(ocrGroups, ReportOcrProgress, AddPreviewLine, cancellationToken); + } + + /// + /// Runs the selected OCR engine over the given frame groups, storing each result in + /// . Shared by the full scan and the single-frame test. + /// + private async Task OcrGroups(List ocrGroups, Action reportProgress, Action addPreviewLine, CancellationToken cancellationToken) + { var engineType = SelectedEngine.EngineType; if (engineType is OcrEngineType.PaddleOcrStandalone or OcrEngineType.PaddleOcrPython) { @@ -708,8 +837,8 @@ void AddPreviewLine(VideoOcrFrameGroup group) if (group != null) { group.Text = VideoOcrLineBuilder.CleanOcrResult(p.Text); - ReportOcrProgress(); - AddPreviewLine(group); + reportProgress(); + addPreviewLine(group); } }); @@ -731,14 +860,14 @@ void AddPreviewLine(VideoOcrFrameGroup group) var ollamaOcr = new OllamaOcr(Se.Settings.Ocr.OllamaOcrTimeoutMinutes); await RunLlmOcr(ocrGroups, group => OcrWithBitmap(group, bitmap => ollamaOcr.Ocr(bitmap, OllamaUrl, OllamaModel, OllamaLanguage, cancellationToken)), - () => ollamaOcr.Error, ReportOcrProgress, AddPreviewLine, cancellationToken); + () => ollamaOcr.Error, reportProgress, addPreviewLine, cancellationToken); } else if (engineType == OcrEngineType.Glm) { var glmOcr = new GlmOcr(GlmApiKey); await RunLlmOcr(ocrGroups, group => glmOcr.Ocr(group.RepresentativeFileName, GlmUrl, GlmModel, GlmLanguage, cancellationToken), - () => glmOcr.Error, ReportOcrProgress, AddPreviewLine, cancellationToken); + () => glmOcr.Error, reportProgress, addPreviewLine, cancellationToken); } else if (engineType == OcrEngineType.LlamaCpp) { @@ -750,7 +879,7 @@ await RunLlmOcr(ocrGroups, group => var prompt = Se.Settings.Ocr.LlamaCppOcrPrompt; await RunLlmOcr(ocrGroups, group => OcrWithBitmap(group, bitmap => llamaCppOcr.Ocr(bitmap, url, modelName, LlamaCppLanguage, prompt, cancellationToken)), - () => llamaCppOcr.Error, ReportOcrProgress, AddPreviewLine, cancellationToken); + () => llamaCppOcr.Error, reportProgress, addPreviewLine, cancellationToken); } } @@ -915,6 +1044,34 @@ private void Ok() Window?.Close(); } + /// Removes the given result lines and renumbers the rest (Delete key in the grid). + internal void DeleteLines(List items) + { + if (IsRunning || items.Count == 0) + { + return; + } + + foreach (var item in items) + { + Lines.Remove(item); + } + + var number = 1; + foreach (var line in Lines) + { + line.Number = number++; + } + + IsOkEnabled = Lines.Count > 0; + } + + /// Moves the preview to the given line's start time (double-click in the grid). + internal void SeekPreview(VideoOcrLineItem item) + { + PreviewPositionSeconds = Math.Clamp(item.StartTime.TotalSeconds, 0, DurationSeconds); + } + [RelayCommand] private async Task Cancel() { diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrWindow.cs b/src/ui/Features/Video/VideoOcr/VideoOcrWindow.cs index b23d25ffb8..6ff118e009 100644 --- a/src/ui/Features/Video/VideoOcr/VideoOcrWindow.cs +++ b/src/ui/Features/Video/VideoOcr/VideoOcrWindow.cs @@ -11,6 +11,7 @@ using Nikse.SubtitleEdit.Logic.Config; using Nikse.SubtitleEdit.Logic.LlamaCpp; using Nikse.SubtitleEdit.Logic.ValueConverters; +using System.Linq; namespace Nikse.SubtitleEdit.Features.Video.VideoOcr; @@ -157,6 +158,9 @@ private static Border MakePreviewView(VideoOcrViewModel vm) UiUtil.MakeButton(Se.Language.Video.VideoOcr.BottomThird, vm.SetScanAreaBottomThirdCommand), UiUtil.MakeButton(Se.Language.Video.VideoOcr.BottomHalf, vm.SetScanAreaBottomHalfCommand), UiUtil.MakeButton(Se.Language.Video.VideoOcr.FullFrame, vm.SetScanAreaFullFrameCommand), + UiUtil.MakeButton(Se.Language.Video.VideoOcr.TestOcr, vm.TestOcrCommand) + .WithBindEnabled(nameof(vm.IsRunning), new InverseBooleanConverter()) + .WithMarginLeft(10), scanAreaText, }, }; @@ -366,7 +370,7 @@ private static Border MakeLinesView(VideoOcrViewModel vm) CanUserResizeColumns = true, HorizontalAlignment = HorizontalAlignment.Stretch, VerticalAlignment = VerticalAlignment.Stretch, - IsReadOnly = true, + IsReadOnly = false, // the text column is editable so OCR mistakes can be fixed in place DataContext = vm, ItemsSource = vm.Lines, Columns = @@ -404,12 +408,31 @@ private static Border MakeLinesView(VideoOcrViewModel vm) Header = Se.Language.General.Text, CellTheme = UiUtil.DataGridNoBorderNoPaddingCellTheme, Binding = new Binding(nameof(VideoOcrLineItem.Text)), - IsReadOnly = true, + IsReadOnly = false, Width = new DataGridLength(1, DataGridLengthUnitType.Star), }, }, }; + // Double-click a line to see the frame it came from in the preview. + dataGrid.DoubleTapped += (s, e) => + { + if (dataGrid.SelectedItem is VideoOcrLineItem item) + { + vm.SeekPreview(item); + } + }; + + // Delete removes the selected lines (unless a cell edit is in progress). + dataGrid.KeyDown += (s, e) => + { + if (e.Key == Avalonia.Input.Key.Delete && e.Source is not TextBox) + { + vm.DeleteLines(dataGrid.SelectedItems.OfType().ToList()); + e.Handled = true; + } + }; + return UiUtil.MakeBorderForControl(dataGrid); } diff --git a/src/ui/Logic/Config/Language/Video/LanguageVideoOcr.cs b/src/ui/Logic/Config/Language/Video/LanguageVideoOcr.cs index e69a693b01..397a96423b 100644 --- a/src/ui/Logic/Config/Language/Video/LanguageVideoOcr.cs +++ b/src/ui/Logic/Config/Language/Video/LanguageVideoOcr.cs @@ -33,6 +33,10 @@ public class LanguageVideoOcr public string UnableToReadVideoMessage { get; set; } public string AbortOcrTitle { get; set; } public string AbortOcrMessage { get; set; } + public string TestOcr { get; set; } + public string TestOcrRunning { get; set; } + public string TestOcrResultX { get; set; } + public string TestOcrNoTextFound { get; set; } public LanguageVideoOcr() { @@ -67,5 +71,9 @@ public LanguageVideoOcr() UnableToReadVideoMessage = "Could not read video info from: {0}"; AbortOcrTitle = "Abort OCR?"; AbortOcrMessage = "Do you want to abort the running OCR?"; + TestOcr = "Test current frame"; + TestOcrRunning = "Testing OCR on current frame..."; + TestOcrResultX = "Test result: {0}"; + TestOcrNoTextFound = "Test: no text found in the scan area"; } }