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";
}
}