From ec59ebc1793ff1bf0ea9852df69439809f2a8ac0 Mon Sep 17 00:00:00 2001 From: Ivandro Jao Date: Wed, 28 Oct 2026 21:03:46 +0000 Subject: [PATCH 01/29] Make SeNamesList.Load async Replace INamesList.Load with Task LoadAsync. SeNamesList now reads the name XML files with XmlReader's async API (Async = true settings, ReadAsync / ReadElementContentAsStringAsync) instead of blocking. FixCommonErrorsViewModel awaits the load in RunScanWithFeedbackAsync, the delete handler and the skip-step-1 startup path rather than inside the synchronous ApplyFixes. BatchConverter.FixCommonErrors becomes FixCommonErrorsAsync. Test fakes, SeNamesList tests and the benchmark setup follow the new signature. Co-Authored-By: Claude Fable 5.1 --- .../Tools/BatchConvert/BatchConverter.cs | 6 ++-- .../FixCommonErrorsViewModel.cs | 18 +++++++----- src/ui/Logic/Dictionaries/INamesList.cs | 3 +- src/ui/Logic/Dictionaries/SeNamesList.cs | 28 ++++++++++--------- .../BatchConverterRunBinaryOcrTests.cs | 4 +-- .../FixCommonErrorsParagraphIdTests.cs | 4 +-- .../FixCommonErrorsUnfixableErrorsTests.cs | 4 +-- .../SeNamesListAbbreviationsTests.cs | 14 +++++----- .../Dictionaries/SeNamesListMultiWordTests.cs | 16 +++++------ tests/benchmarks/HotPathRound5Benchmarks.cs | 4 +-- 10 files changed, 51 insertions(+), 50 deletions(-) diff --git a/src/ui/Features/Tools/BatchConvert/BatchConverter.cs b/src/ui/Features/Tools/BatchConvert/BatchConverter.cs index 133078ad446..96db5fea7ed 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConverter.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConverter.cs @@ -2433,7 +2433,7 @@ private async Task RunConvertFunctions(BatchConvertItem item, bool ima s = ChangeSpeed(s); s = BridgeGaps(s); s = ApplyMinGap(s); - s = FixCommonErrors(s); + s = await FixCommonErrorsAsync(s); s = MergeLinesWithSameText(s); s = MergeLinesWithSameTimeCodes(s, Language); s = ConvertColorsToDialog(s, Language); @@ -3359,7 +3359,7 @@ private void FixNames(Subtitle subtitle, string language) } } - private Subtitle FixCommonErrors(Subtitle subtitle) + private async Task FixCommonErrorsAsync(Subtitle subtitle) { if (!_config.FixCommonErrors.IsActive || _config.FixCommonErrors.Profile == null) { @@ -3370,7 +3370,7 @@ private Subtitle FixCommonErrors(Subtitle subtitle) if (!string.Equals(_namesListFolder, dictionaryFolder, StringComparison.Ordinal) || !string.Equals(_namesListLanguage, Language, StringComparison.Ordinal)) { - _namesList.Load(dictionaryFolder, Language); + await _namesList.LoadAsync(dictionaryFolder, Language); _namesListFolder = dictionaryFolder; _namesListLanguage = Language; } diff --git a/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsViewModel.cs b/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsViewModel.cs index 6125a233da8..10a8ed9ccc4 100644 --- a/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsViewModel.cs +++ b/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsViewModel.cs @@ -164,7 +164,7 @@ public void Initialize(Subtitle subtitle, SubtitleFormat subtitleFormat) if (Se.Settings.Tools.FixCommonErrors.SkipStep1 && SelectedProfile != null) { - ShowStep2(); + _ = ShowStep2(); } } @@ -310,6 +310,7 @@ private async Task DeleteSelectedItems() // Fixes and the grid find lines by number, so close the gap the deleted line left FixedSubtitle.Renumber(); + await LoadNamesListIfNeededAsync(); RefreshFixes(); // Keep the cursor where the deleted line was, so Delete can be pressed again @@ -349,9 +350,12 @@ private List GetSelectedEditableSubtitles() } // Used when step 1 is skipped at startup - the window is not up yet, so there is nothing to flash on. - private void ShowStep2() + // Initialize runs inside the synchronous dialog init callback, so this is fire-and-forget: the + // step switch happens at once and the fixes grid fills when the names list has loaded. + private async Task ShowStep2() { EnterStep2(); + await LoadNamesListIfNeededAsync(); RefreshFixes(); } @@ -709,6 +713,7 @@ private async Task RunScanWithFeedbackAsync(Action scan) AnalysingIsVisible = true; await Task.Delay(AnalysingPaintDelayMilliseconds); + await LoadNamesListIfNeededAsync(); scan(); await Task.Delay(AnalysingMinimumVisibleMilliseconds); @@ -796,8 +801,6 @@ private void ApplyFixes() return; } - LoadNamesListIfNeeded(); - _totalErrors = 0; if (_previewMode) { @@ -1321,9 +1324,10 @@ public void UpdateFixStatus(int fixes, string message) /// The names list backs and , so without /// this the rules saw no names and no abbreviations at all - e.g. Dutch "dhr. de vries" was /// capitalized to "dhr. De vries" (#13082). Reloaded only when the language changes, like - /// the batch converter does. + /// the batch converter does. Awaited by the async callers of ApplyFixes, which itself stays + /// synchronous because it mutates the grid-bound collections. /// - private void LoadNamesListIfNeeded() + private async Task LoadNamesListIfNeededAsync() { var dictionaryFolder = Se.DictionariesFolder; if (string.Equals(_namesListFolder, dictionaryFolder, StringComparison.Ordinal) && @@ -1332,7 +1336,7 @@ private void LoadNamesListIfNeeded() return; } - _namesList.Load(dictionaryFolder, Language); + await _namesList.LoadAsync(dictionaryFolder, Language); _namesListFolder = dictionaryFolder; _namesListLanguage = Language; } diff --git a/src/ui/Logic/Dictionaries/INamesList.cs b/src/ui/Logic/Dictionaries/INamesList.cs index 43d6d8f00be..844cfa2d8c7 100644 --- a/src/ui/Logic/Dictionaries/INamesList.cs +++ b/src/ui/Logic/Dictionaries/INamesList.cs @@ -1,10 +1,11 @@ using System.Collections.Generic; +using System.Threading.Tasks; namespace Nikse.SubtitleEdit.Logic.Dictionaries; public interface INamesList { - void Load(string dictionaryFolder, string languageCode); + Task LoadAsync(string dictionaryFolder, string languageCode); bool IsName(string candidate); HashSet GetAbbreviations(); } \ No newline at end of file diff --git a/src/ui/Logic/Dictionaries/SeNamesList.cs b/src/ui/Logic/Dictionaries/SeNamesList.cs index 0c747424d9c..93f2cb83d9a 100644 --- a/src/ui/Logic/Dictionaries/SeNamesList.cs +++ b/src/ui/Logic/Dictionaries/SeNamesList.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading.Tasks; using System.Xml; using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.Core.Dictionaries; @@ -23,7 +24,7 @@ public class SeNamesList : INamesList private readonly HashSet _abbreviationList = new(StringComparer.OrdinalIgnoreCase); public string LanguageName { get; private set; } = "en"; - public void Load(string dictionaryFolder, string languageCode) + public async Task LoadAsync(string dictionaryFolder, string languageCode) { _dictionaryFolder = dictionaryFolder; LanguageName = languageCode; @@ -34,10 +35,10 @@ public void Load(string dictionaryFolder, string languageCode) _blackList = new HashSet(); _namesMultiListParts = null; - LoadNamesList(GetLocalNamesUserFileName()); // e.g: en_names_user.xml (culture sensitive) - LoadNamesList(GetLocalNamesFileName()); // e.g: en_names.xml (culture sensitive) - LoadNamesList(GetLocalCultureNamesFileName()); // e.g: pt_BR_names.xml (region specific) - LoadNamesList(Path.Combine(_dictionaryFolder, "names.xml")); + await LoadNamesListAsync(GetLocalNamesUserFileName()); // e.g: en_names_user.xml (culture sensitive) + await LoadNamesListAsync(GetLocalNamesFileName()); // e.g: en_names.xml (culture sensitive) + await LoadNamesListAsync(GetLocalCultureNamesFileName()); // e.g: pt_BR_names.xml (region specific) + await LoadNamesListAsync(Path.Combine(_dictionaryFolder, "names.xml")); foreach (var name in _blackList) { @@ -325,7 +326,7 @@ private string GetLocalNamesFileName() /// /// Region specific names file, e.g. pt_BR_names.xml. Portuguese ships one list per region /// (pt_PT / pt_BR) and no neutral pt_names.xml, so without this they were never loaded. - /// Returns an empty string for a neutral language name; LoadNamesList ignores a missing file. + /// Returns an empty string for a neutral language name; LoadNamesListAsync ignores a missing file. /// private string GetLocalCultureNamesFileName() { @@ -337,7 +338,7 @@ private string GetLocalCultureNamesFileName() return Path.Combine(_dictionaryFolder, LanguageName + "_names.xml"); } - private void LoadNamesList(string fileNameOrUrl) + private async Task LoadNamesListAsync(string fileNameOrUrl) { if (string.IsNullOrEmpty(fileNameOrUrl) || !File.Exists(fileNameOrUrl) && @@ -349,9 +350,10 @@ private void LoadNamesList(string fileNameOrUrl) try { - using var reader = XmlReader.Create(fileNameOrUrl); - reader.MoveToContent(); - while (reader.Read()) + // The reader only accepts the *Async calls when created with Async = true. + using var reader = XmlReader.Create(fileNameOrUrl, new XmlReaderSettings { Async = true }); + await reader.MoveToContentAsync(); + while (await reader.ReadAsync()) { if (reader.NodeType != XmlNodeType.Element || reader.IsEmptyElement) { @@ -360,7 +362,7 @@ private void LoadNamesList(string fileNameOrUrl) if (reader.Name == "name") { - var name = reader.ReadElementContentAsString().Trim(); + var name = (await reader.ReadElementContentAsStringAsync()).Trim(); if (name.Length > 0) { if (name.Contains(' ')) @@ -375,7 +377,7 @@ private void LoadNamesList(string fileNameOrUrl) } else if (reader.Name == "blacklist") { - while (reader.Read() && reader.NodeType != XmlNodeType.EndElement) + while (await reader.ReadAsync() && reader.NodeType != XmlNodeType.EndElement) { if (reader.NodeType != XmlNodeType.Element || reader.IsEmptyElement) { @@ -384,7 +386,7 @@ private void LoadNamesList(string fileNameOrUrl) if (reader.Name == "name") { - var name = reader.ReadElementContentAsString().Trim(); + var name = (await reader.ReadElementContentAsStringAsync()).Trim(); if (name.Length > 0) { _blackList.Add(name); diff --git a/tests/UI/Features/Tools/BatchConvert/BatchConverterRunBinaryOcrTests.cs b/tests/UI/Features/Tools/BatchConvert/BatchConverterRunBinaryOcrTests.cs index cc58a8feec7..0f34a7d5aad 100644 --- a/tests/UI/Features/Tools/BatchConvert/BatchConverterRunBinaryOcrTests.cs +++ b/tests/UI/Features/Tools/BatchConvert/BatchConverterRunBinaryOcrTests.cs @@ -139,9 +139,7 @@ private sealed class FakeNOcrCaseFixer : INOcrCaseFixer private sealed class FakeNamesList : INamesList { - public void Load(string dictionaryFolder, string languageCode) - { - } + public Task LoadAsync(string dictionaryFolder, string languageCode) => Task.CompletedTask; public bool IsName(string candidate) => false; public HashSet GetAbbreviations() => new(); diff --git a/tests/UI/Features/Tools/FixCommonErrors/FixCommonErrorsParagraphIdTests.cs b/tests/UI/Features/Tools/FixCommonErrors/FixCommonErrorsParagraphIdTests.cs index ccd769edb6d..3d41e669086 100644 --- a/tests/UI/Features/Tools/FixCommonErrors/FixCommonErrorsParagraphIdTests.cs +++ b/tests/UI/Features/Tools/FixCommonErrors/FixCommonErrorsParagraphIdTests.cs @@ -61,9 +61,7 @@ public async Task RemovedEmptyLine_KeepsTheIdsOfTheLinesThatStay() private sealed class FakeNamesList : INamesList { - public void Load(string dictionaryFolder, string languageCode) - { - } + public Task LoadAsync(string dictionaryFolder, string languageCode) => Task.CompletedTask; public bool IsName(string candidate) => false; diff --git a/tests/UI/Features/Tools/FixCommonErrors/FixCommonErrorsUnfixableErrorsTests.cs b/tests/UI/Features/Tools/FixCommonErrors/FixCommonErrorsUnfixableErrorsTests.cs index b87eb0aa771..0e4e574a5ad 100644 --- a/tests/UI/Features/Tools/FixCommonErrors/FixCommonErrorsUnfixableErrorsTests.cs +++ b/tests/UI/Features/Tools/FixCommonErrors/FixCommonErrorsUnfixableErrorsTests.cs @@ -171,9 +171,7 @@ public async Task Apply_LogsWhatWasFixed() private sealed class FakeNamesList : INamesList { - public void Load(string dictionaryFolder, string languageCode) - { - } + public Task LoadAsync(string dictionaryFolder, string languageCode) => Task.CompletedTask; public bool IsName(string candidate) => false; diff --git a/tests/UI/Logic/Dictionaries/SeNamesListAbbreviationsTests.cs b/tests/UI/Logic/Dictionaries/SeNamesListAbbreviationsTests.cs index 6dbf587134c..a625bdd1c02 100644 --- a/tests/UI/Logic/Dictionaries/SeNamesListAbbreviationsTests.cs +++ b/tests/UI/Logic/Dictionaries/SeNamesListAbbreviationsTests.cs @@ -37,12 +37,12 @@ private void WriteAbbreviations(string fileName, params string[] items) // Names ending with a period stay a source of abbreviations, but "Dr." must also match "dr.". [Fact] - public void NameAbbreviationsMatchCaseInsensitively() + public async Task NameAbbreviationsMatchCaseInsensitively() { WriteNames("names.xml", "Dr.", "Mr.", "Robert"); var namesList = new SeNamesList(); - namesList.Load(_folder, "nl"); + await namesList.LoadAsync(_folder, "nl"); var abbreviations = namesList.GetAbbreviations(); Assert.Contains("dr.", abbreviations); @@ -52,13 +52,13 @@ public void NameAbbreviationsMatchCaseInsensitively() // Abbreviations that are not names come from _abbreviations.xml (#13082). [Fact] - public void LoadsLanguageAbbreviationFile() + public async Task LoadsLanguageAbbreviationFile() { WriteNames("names.xml", "Dr."); WriteAbbreviations("nl_abbreviations.xml", "dhr.", "enz."); var namesList = new SeNamesList(); - namesList.Load(_folder, "nl"); + await namesList.LoadAsync(_folder, "nl"); var abbreviations = namesList.GetAbbreviations(); Assert.Contains("dhr.", abbreviations); @@ -68,14 +68,14 @@ public void LoadsLanguageAbbreviationFile() // A second Load must not keep the previous language's abbreviations. [Fact] - public void ReloadReplacesPreviousLanguageAbbreviations() + public async Task ReloadReplacesPreviousLanguageAbbreviations() { WriteAbbreviations("nl_abbreviations.xml", "dhr."); WriteAbbreviations("es_abbreviations.xml", "sr."); var namesList = new SeNamesList(); - namesList.Load(_folder, "nl"); - namesList.Load(_folder, "es"); + await namesList.LoadAsync(_folder, "nl"); + await namesList.LoadAsync(_folder, "es"); var abbreviations = namesList.GetAbbreviations(); Assert.Contains("sr.", abbreviations); diff --git a/tests/UI/Logic/Dictionaries/SeNamesListMultiWordTests.cs b/tests/UI/Logic/Dictionaries/SeNamesListMultiWordTests.cs index 2f91c212a84..e7a4690ef7b 100644 --- a/tests/UI/Logic/Dictionaries/SeNamesListMultiWordTests.cs +++ b/tests/UI/Logic/Dictionaries/SeNamesListMultiWordTests.cs @@ -43,17 +43,17 @@ public void Dispose() } } - private SeNamesList MakeNamesList() + private async Task MakeNamesListAsync() { var list = new SeNamesList(); - list.Load(_folder + Path.DirectorySeparatorChar, "en_US"); + await list.LoadAsync(_folder + Path.DirectorySeparatorChar, "en_US"); return list; } [Fact] - public void MatchesTheFullScanForEveryPartOfEveryMultiWordName() + public async Task MatchesTheFullScanForEveryPartOfEveryMultiWordName() { - var namesList = MakeNamesList(); + var namesList = await MakeNamesListAsync(); var multiNames = namesList.GetMultiNames(); foreach (var multiName in MultiWordNames) @@ -78,16 +78,16 @@ public void MatchesTheFullScanForEveryPartOfEveryMultiWordName() [InlineData("MARY ANN was here.", "MARY ANN")] [InlineData("Los Angeles is far.", "Angeles")] [InlineData("Angeles is far.", "Angeles")] - public void MatchesTheFullScanForWord(string text, string word) + public async Task MatchesTheFullScanForWord(string text, string word) { - var namesList = MakeNamesList(); + var namesList = await MakeNamesListAsync(); Assert.Equal(Reference(namesList.GetMultiNames(), text, word), namesList.IsInNamesMultiWordList(text, word)); } [Fact] - public void AddedMultiWordNameIsSeenImmediately() + public async Task AddedMultiWordNameIsSeenImmediately() { - var namesList = MakeNamesList(); + var namesList = await MakeNamesListAsync(); const string word = "Zzyzx"; const string line = "I met Zzyzx Quorbal today."; diff --git a/tests/benchmarks/HotPathRound5Benchmarks.cs b/tests/benchmarks/HotPathRound5Benchmarks.cs index 26d8a09bf38..aff14a2ee03 100644 --- a/tests/benchmarks/HotPathRound5Benchmarks.cs +++ b/tests/benchmarks/HotPathRound5Benchmarks.cs @@ -62,12 +62,12 @@ public class NameListMultiWordBenchmarks private SeNamesList _seNamesList = null!; [GlobalSetup] - public void Setup() + public async Task Setup() { var folder = WordSamples.DictionaryFolder(); _nameList = new NameList(folder, "en_US", false, string.Empty); _seNamesList = new SeNamesList(); - _seNamesList.Load(folder, "en_US"); + await _seNamesList.LoadAsync(folder, "en_US"); } [Benchmark] From b2f208aab4aafea1b632e7025ed1f9315ca9d495 Mon Sep 17 00:00:00 2001 From: niksedk Date: Thu, 29 Oct 2026 10:23:55 +0100 Subject: [PATCH 02/29] Italian: YTT pen strings as formatting options, fix Right Applies the review from @GitGianluc on #16447: 'pen' in YouTube Timed Text means a set of formatting options, not a physical pen. Also rewrites the karaoke hint and fixes 'Right' translated as 'Giusto' (correct) instead of 'Destra'. Co-Authored-By: Claude Opus 5.5 --- src/ui/Assets/Languages/Italian.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ui/Assets/Languages/Italian.json b/src/ui/Assets/Languages/Italian.json index 61332be2e18..340bf08d318 100644 --- a/src/ui/Assets/Languages/Italian.json +++ b/src/ui/Assets/Languages/Italian.json @@ -526,7 +526,7 @@ "resolution": "Risoluzione", "retry": "Riprova", "reverseRightToLeftStartEnd": "Inverti inizio/fine RTL", - "right": "Giusto", + "right": "Destra", "rightToLeft": "Da destra a sinistra", "rules": "Regole", "saveChangesMessage": "Vuoi salvare le modifiche del file sottotitoli attuale?", @@ -1638,8 +1638,8 @@ }, "ytt": { "stylesTitleX": "Stili testo temporizzati YouTube - {0}", - "pens": "Penne", - "penX": "Penna {0}", + "pens": "Opzioni di formattazione", + "penX": "Opzione di formattazione {0}", "windowPositions": "Posizioni", "windowPosition": "Posizione", "windowPositionX": "Posizione {0}", @@ -1670,7 +1670,7 @@ "justify": "Giustifica", "left": "Sinistra", "center": "Centro", - "right": "Giusto", + "right": "Destra", "textDirection": "Direzione testo", "leftToRight": "Da sinistra a destra", "rightToLeft": "Da destra a sinistra", @@ -1678,12 +1678,12 @@ "verticalRotated": "Verticale, ruotato", "columnsRightToLeft": "Ordine colonne invertito", "windowOpacity": "Opacità riquadro finestra", - "linePen": "Penna riga", - "setPenForSelection": "Imposta penna per la selezione", + "linePen": "Opzioni di formattazione riga", + "setPenForSelection": "Imposta opzioni di formattazione per la selezione", "setPosition": "Imposta posizione", "insertKaraokeTime": "Il karaoke inizia dalla posizione video", - "insertKaraokeTimeHint": "A capo selezione in un tag s visualizzato nella posizione video", - "removePenTags": "Rimuovi tag penna", + "insertKaraokeTimeHint": "Racchiude la selezione in un tag “s” che appare alla posizione del video", + "removePenTags": "Rimuovi tag di formattazione", "stylesDotDotDot": "Stili testo temporizzati YouTube...", "youTube": "YouTube" }, From 5a5ebebaed0051b366cd598a002d85599e699119 Mon Sep 17 00:00:00 2001 From: niksedk Date: Thu, 29 Oct 2026 12:38:09 +0100 Subject: [PATCH 03/29] Load names off the UI thread and share in-flight loads - SeNamesList.LoadAsync parses the name files with the sync XmlReader on a worker thread into fresh sets and swaps them in when done, so lists in use are never seen half-filled; a generation counter keeps a slower earlier load from overwriting a later one. - Fix common errors: concurrent callers await the running load instead of starting another, a load failure is logged and shown instead of being lost in the fire-and-forget startup path, and skip-step-1 startup goes through the guarded "Analyzing..." scan. Co-Authored-By: Claude Opus 5.5 --- .../FixCommonErrorsViewModel.cs | 46 +++++--- src/ui/Logic/Dictionaries/SeNamesList.cs | 106 +++++++++++------- .../SeNamesListAbbreviationsTests.cs | 20 +++- 3 files changed, 118 insertions(+), 54 deletions(-) diff --git a/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsViewModel.cs b/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsViewModel.cs index 10a8ed9ccc4..50723ded727 100644 --- a/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsViewModel.cs +++ b/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsViewModel.cs @@ -102,6 +102,7 @@ public partial class FixCommonErrorsViewModel : ObservableObject, IFixCallbacks private readonly INamesList _namesList; private string? _namesListFolder; private string? _namesListLanguage; + private Task _namesListLoad = Task.CompletedTask; private readonly IWindowService _windowService; private readonly IOcrFixEngine _ocrFixEngine; @@ -164,7 +165,7 @@ public void Initialize(Subtitle subtitle, SubtitleFormat subtitleFormat) if (Se.Settings.Tools.FixCommonErrors.SkipStep1 && SelectedProfile != null) { - _ = ShowStep2(); + _ = ShowStep2Async(); } } @@ -349,16 +350,6 @@ private List GetSelectedEditableSubtitles() return new List() { selected }; } - // Used when step 1 is skipped at startup - the window is not up yet, so there is nothing to flash on. - // Initialize runs inside the synchronous dialog init callback, so this is fire-and-forget: the - // step switch happens at once and the fixes grid fills when the names list has loaded. - private async Task ShowStep2() - { - EnterStep2(); - await LoadNamesListIfNeededAsync(); - RefreshFixes(); - } - private async Task ShowStep2Async() { EnterStep2(); @@ -1325,20 +1316,45 @@ public void UpdateFixStatus(int fixes, string message) /// this the rules saw no names and no abbreviations at all - e.g. Dutch "dhr. de vries" was /// capitalized to "dhr. De vries" (#13082). Reloaded only when the language changes, like /// the batch converter does. Awaited by the async callers of ApplyFixes, which itself stays - /// synchronous because it mutates the grid-bound collections. + /// synchronous because it mutates the grid-bound collections. A caller that comes in while a + /// load for the same language is running awaits that load instead of starting a second one. /// - private async Task LoadNamesListIfNeededAsync() + private Task LoadNamesListIfNeededAsync() { var dictionaryFolder = Se.DictionariesFolder; if (string.Equals(_namesListFolder, dictionaryFolder, StringComparison.Ordinal) && string.Equals(_namesListLanguage, Language, StringComparison.Ordinal)) { - return; + return _namesListLoad; } - await _namesList.LoadAsync(dictionaryFolder, Language); _namesListFolder = dictionaryFolder; _namesListLanguage = Language; + _namesListLoad = LoadNamesListAsync(dictionaryFolder, Language); + return _namesListLoad; + } + + // A broken names file must not leave the fixes grid empty without a word, so report it and + // let the scan run without names. It is not retried until the language changes. + private async Task LoadNamesListAsync(string dictionaryFolder, string language) + { + try + { + await _namesList.LoadAsync(dictionaryFolder, language); + } + catch (Exception exception) + { + Se.LogError(exception, "Fix common errors: could not load names list"); + if (Window != null) + { + await MessageBox.Show( + Window, + Se.Language.General.Error, + exception.Message, + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + } } public bool IsName(string candidate) diff --git a/src/ui/Logic/Dictionaries/SeNamesList.cs b/src/ui/Logic/Dictionaries/SeNamesList.cs index 93f2cb83d9a..5258b09d5a4 100644 --- a/src/ui/Logic/Dictionaries/SeNamesList.cs +++ b/src/ui/Logic/Dictionaries/SeNamesList.cs @@ -24,54 +24,85 @@ public class SeNamesList : INamesList private readonly HashSet _abbreviationList = new(StringComparer.OrdinalIgnoreCase); public string LanguageName { get; private set; } = "en"; + // Bumped by every LoadAsync, so a slower earlier load cannot overwrite a later one. + private int _loadGeneration; + + /// + /// Reads the name files on a worker thread into fresh sets and swaps them in when done, so the + /// UI thread is not blocked and a list that is in use is never seen half-filled. + /// public async Task LoadAsync(string dictionaryFolder, string languageCode) { + var generation = ++_loadGeneration; _dictionaryFolder = dictionaryFolder; LanguageName = languageCode; - _namesList = new HashSet(); - _namesMultiList = new HashSet(); - _namesMultiListUppercase = new HashSet(); - _blackList = new HashSet(); + string[] fileNames = + [ + GetLocalNamesUserFileName(), // e.g: en_names_user.xml (culture sensitive) + GetLocalNamesFileName(), // e.g: en_names.xml (culture sensitive) + GetLocalCultureNamesFileName(), // e.g: pt_BR_names.xml (region specific) + Path.Combine(dictionaryFolder, "names.xml"), + ]; + + var loaded = await Task.Run(() => LoadNames(fileNames, dictionaryFolder, languageCode)); + if (generation != _loadGeneration) + { + return; + } + + _namesList = loaded.Names; + _namesMultiList = loaded.MultiNames; + _namesMultiListUppercase = loaded.MultiNamesUppercase; + _blackList = loaded.BlackList; _namesMultiListParts = null; + _abbreviationList.Clear(); + _abbreviationList.UnionWith(loaded.Abbreviations); + } + + private sealed record LoadedNames( + HashSet Names, + HashSet MultiNames, + HashSet MultiNamesUppercase, + HashSet BlackList, + List Abbreviations); - await LoadNamesListAsync(GetLocalNamesUserFileName()); // e.g: en_names_user.xml (culture sensitive) - await LoadNamesListAsync(GetLocalNamesFileName()); // e.g: en_names.xml (culture sensitive) - await LoadNamesListAsync(GetLocalCultureNamesFileName()); // e.g: pt_BR_names.xml (region specific) - await LoadNamesListAsync(Path.Combine(_dictionaryFolder, "names.xml")); + private static LoadedNames LoadNames(string[] fileNames, string dictionaryFolder, string languageCode) + { + var names = new HashSet(); + var multiNames = new HashSet(); + var multiNamesUppercase = new HashSet(); + var blackList = new HashSet(); - foreach (var name in _blackList) + foreach (var fileName in fileNames) { - if (_namesList.Contains(name)) - { - _namesList.Remove(name); - } + LoadNamesList(fileName, names, multiNames, blackList); + } - if (_namesMultiList.Contains(name)) - { - _namesMultiList.Remove(name); - } + foreach (var name in blackList) + { + names.Remove(name); + multiNames.Remove(name); } - foreach (var name in _namesMultiList) + foreach (var name in multiNames) { - _namesMultiListUppercase.Add(name.ToUpperInvariant()); + multiNamesUppercase.Add(name.ToUpperInvariant()); } - _abbreviationList.Clear(); - foreach (var name in _namesList) + var abbreviations = new List(); + foreach (var name in names) { if (name.EndsWith('.') && !name.Contains(' ')) { - _abbreviationList.Add(name); + abbreviations.Add(name); } } // e.g. nl_abbreviations.xml - abbreviations that are not names ("enz.", "ca.", "nr."). - foreach (var abbreviation in AbbreviationList.Load(_dictionaryFolder, languageCode)) - { - _abbreviationList.Add(abbreviation); - } + abbreviations.AddRange(AbbreviationList.Load(dictionaryFolder, languageCode)); + + return new LoadedNames(names, multiNames, multiNamesUppercase, blackList, abbreviations); } public List GetAllNames() @@ -326,7 +357,7 @@ private string GetLocalNamesFileName() /// /// Region specific names file, e.g. pt_BR_names.xml. Portuguese ships one list per region /// (pt_PT / pt_BR) and no neutral pt_names.xml, so without this they were never loaded. - /// Returns an empty string for a neutral language name; LoadNamesListAsync ignores a missing file. + /// Returns an empty string for a neutral language name; LoadNamesList ignores a missing file. /// private string GetLocalCultureNamesFileName() { @@ -338,7 +369,7 @@ private string GetLocalCultureNamesFileName() return Path.Combine(_dictionaryFolder, LanguageName + "_names.xml"); } - private async Task LoadNamesListAsync(string fileNameOrUrl) + private static void LoadNamesList(string fileNameOrUrl, HashSet names, HashSet multiNames, HashSet blackList) { if (string.IsNullOrEmpty(fileNameOrUrl) || !File.Exists(fileNameOrUrl) && @@ -350,10 +381,9 @@ private async Task LoadNamesListAsync(string fileNameOrUrl) try { - // The reader only accepts the *Async calls when created with Async = true. - using var reader = XmlReader.Create(fileNameOrUrl, new XmlReaderSettings { Async = true }); - await reader.MoveToContentAsync(); - while (await reader.ReadAsync()) + using var reader = XmlReader.Create(fileNameOrUrl); + reader.MoveToContent(); + while (reader.Read()) { if (reader.NodeType != XmlNodeType.Element || reader.IsEmptyElement) { @@ -362,22 +392,22 @@ private async Task LoadNamesListAsync(string fileNameOrUrl) if (reader.Name == "name") { - var name = (await reader.ReadElementContentAsStringAsync()).Trim(); + var name = reader.ReadElementContentAsString().Trim(); if (name.Length > 0) { if (name.Contains(' ')) { - _namesMultiList.Add(name); + multiNames.Add(name); } else { - _namesList.Add(name); + names.Add(name); } } } else if (reader.Name == "blacklist") { - while (await reader.ReadAsync() && reader.NodeType != XmlNodeType.EndElement) + while (reader.Read() && reader.NodeType != XmlNodeType.EndElement) { if (reader.NodeType != XmlNodeType.Element || reader.IsEmptyElement) { @@ -386,10 +416,10 @@ private async Task LoadNamesListAsync(string fileNameOrUrl) if (reader.Name == "name") { - var name = (await reader.ReadElementContentAsStringAsync()).Trim(); + var name = reader.ReadElementContentAsString().Trim(); if (name.Length > 0) { - _blackList.Add(name); + blackList.Add(name); } } } diff --git a/tests/UI/Logic/Dictionaries/SeNamesListAbbreviationsTests.cs b/tests/UI/Logic/Dictionaries/SeNamesListAbbreviationsTests.cs index a625bdd1c02..2aa08e873f7 100644 --- a/tests/UI/Logic/Dictionaries/SeNamesListAbbreviationsTests.cs +++ b/tests/UI/Logic/Dictionaries/SeNamesListAbbreviationsTests.cs @@ -1,4 +1,4 @@ -using Nikse.SubtitleEdit.Logic.Dictionaries; +using Nikse.SubtitleEdit.Logic.Dictionaries; namespace UITests.Logic.Dictionaries; @@ -81,4 +81,22 @@ public async Task ReloadReplacesPreviousLanguageAbbreviations() Assert.Contains("sr.", abbreviations); Assert.DoesNotContain("dhr.", abbreviations); } + + // Overlapping loads (e.g. the language changed while a load was running) end with the + // lists of the last load started, whichever finishes first. + [Fact] + public async Task OverlappingLoadsKeepTheLastStarted() + { + WriteAbbreviations("nl_abbreviations.xml", "dhr."); + WriteAbbreviations("es_abbreviations.xml", "sr."); + + var namesList = new SeNamesList(); + var first = namesList.LoadAsync(_folder, "nl"); + var second = namesList.LoadAsync(_folder, "es"); + await Task.WhenAll(first, second); + + var abbreviations = namesList.GetAbbreviations(); + Assert.Contains("sr.", abbreviations); + Assert.DoesNotContain("dhr.", abbreviations); + } } From 5aa46ac95101605d6dfd29328b7544be96808088 Mon Sep 17 00:00:00 2001 From: niksedk Date: Thu, 29 Oct 2026 14:18:37 +0100 Subject: [PATCH 04/29] Update CrispASR to v0.8.48 All 14 download URLs and 28 hash keys (archives + unpacked executables) move from v0.8.46 to v0.8.48. Archive hashes match the release API digests; inner folder names and bundled CUDA DLLs are unchanged, and the CLI flags and backend list are identical to v0.8.46. v0.8.48 keeps word boundaries when Parakeet emits standalone boundary markers (CrispStrobe/CrispASR#558). Co-Authored-By: Claude Opus 5.5 --- .../Engines/CrispAsrEngineBase.cs | 2 +- .../SpeechToText/SpeechToTextViewModel.cs | 2 +- .../Video/TextToSpeech/TtsVoiceInstaller.cs | 4 +- .../Logic/Download/CrispAsrDownloadService.cs | 28 +++---- src/ui/Logic/Download/DownloadHashManager.cs | 84 ++++++++++++------- 5 files changed, 74 insertions(+), 46 deletions(-) diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrEngineBase.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrEngineBase.cs index f62e455a8ff..77c1fac2a6f 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrEngineBase.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrEngineBase.cs @@ -28,7 +28,7 @@ public abstract class CrispAsrEngineBase : ICrispAsrEngine /// CrispASR ships several builds per platform whose sizes differ by two orders of magnitude, /// and the variant is picked at download time rather than now — so Windows and Linux get a /// range rather than a single number that would either understate the GPU bundles or scare - /// users off the CPU one. Figures are the v0.8.46 release assets — + /// users off the CPU one. Figures are the v0.8.48 release assets — /// /// holds the pin; they drift with every release, so treat them as indicative. /// diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs index 6af273d7d80..f95556634d1 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs @@ -1068,7 +1068,7 @@ internal static bool ShouldForceCrispAsrVad(ISpeechToTextEngine engine, string? /// was the crispasr v0.8.29 GPU packages, built with AVX-512 against a CI runner that had it /// (CrispASR #374) - every CPU without AVX-512 got this on the CUDA/Vulkan build while the CPU /// build ran fine, so naming the installed package is most of the answer. That build flaw is - /// fixed from v0.8.30 (SE now pins v0.8.46), but the message still earns its keep: a pre-AVX2 CPU + /// fixed from v0.8.30 (SE now pins v0.8.48), but the message still earns its keep: a pre-AVX2 CPU /// hits the same silent death on the AVX2 CPU package, and an install predating the pin bump /// keeps the broken GPU binary until the user downloads the engine again. /// diff --git a/src/ui/Features/Video/TextToSpeech/TtsVoiceInstaller.cs b/src/ui/Features/Video/TextToSpeech/TtsVoiceInstaller.cs index f81fc012b3d..d7640ceb513 100644 --- a/src/ui/Features/Video/TextToSpeech/TtsVoiceInstaller.cs +++ b/src/ui/Features/Video/TextToSpeech/TtsVoiceInstaller.cs @@ -188,7 +188,7 @@ public static Task EnsureCrispAsrForOmniVoice(Window? window, IWindowServi /// /// Ensures the CrispASR runtime that MOSS-TTS (CrispASR) runs on is installed. - /// The moss-tts backend ships in CrispASR v0.8.13 and newer (SE pins v0.8.46). + /// The moss-tts backend ships in CrispASR v0.8.13 and newer (SE pins v0.8.48). /// public static Task EnsureCrispAsrForMossTts(Window? window, IWindowService windowService, bool forceRedownload) => EnsureCrispAsrAsync(window, windowService, forceRedownload, @@ -198,7 +198,7 @@ public static Task EnsureCrispAsrForMossTts(Window? window, IWindowService /// /// Ensures the CrispASR runtime that dots.tts (CrispASR) runs on is installed. - /// The dots-tts backend ships in CrispASR v0.8.25 and newer (SE pins v0.8.46); the version + /// The dots-tts backend ships in CrispASR v0.8.25 and newer (SE pins v0.8.48); the version /// note names that floor because older builds have no dots-tts backend at all and abort on /// the unknown --backend value. /// diff --git a/src/ui/Logic/Download/CrispAsrDownloadService.cs b/src/ui/Logic/Download/CrispAsrDownloadService.cs index 95a7ba4472c..1b38321f830 100644 --- a/src/ui/Logic/Download/CrispAsrDownloadService.cs +++ b/src/ui/Logic/Download/CrispAsrDownloadService.cs @@ -26,18 +26,18 @@ public class CrispAsrDownloadService : ICrispAsrDownloadService { private readonly HttpClient _httpClient; - private const string WindowsCudaUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.46/crispasr-windows-x86_64-cuda.zip"; + private const string WindowsCudaUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.48/crispasr-windows-x86_64-cuda.zip"; /// /// The CUDA 13 build, added upstream in v0.8.31 next to the CUDA 12 one rather than /// replacing it. Offered as its own option because CUDA 13 needs a newer NVIDIA driver than /// CUDA 12 - repointing at it would have broken everyone still /// on an older driver. Mirrors the Linux pair, which has had both since v0.8.30. /// - private const string WindowsCuda13Url = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.46/crispasr-windows-x86_64-cuda13.zip"; - private const string WindowsVulkanUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.46/crispasr-windows-x86_64-vulkan.zip"; - private const string WindowsCpuUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.46/crispasr-windows-x86_64-cpu.zip"; - private const string WindowsCpuLegacyUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.46/crispasr-windows-x86_64-cpu-legacy.zip"; - private const string MacUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.46/crispasr-macos.tar.gz"; + private const string WindowsCuda13Url = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.48/crispasr-windows-x86_64-cuda13.zip"; + private const string WindowsVulkanUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.48/crispasr-windows-x86_64-vulkan.zip"; + private const string WindowsCpuUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.48/crispasr-windows-x86_64-cpu.zip"; + private const string WindowsCpuLegacyUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.48/crispasr-windows-x86_64-cpu-legacy.zip"; + private const string MacUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.48/crispasr-macos.tar.gz"; /// /// Intel Macs. Upstream's crispasr-macos.tar.gz is arm64-only (issue #13559: "Bad CPU type @@ -49,14 +49,14 @@ public class CrispAsrDownloadService : ICrispAsrDownloadService /// . Its inner folder is crispasr-macos-x86_64, not /// crispasr-macos - see . /// - private const string MacIntelUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.46/crispasr-macos-x86_64.tar.gz"; + private const string MacIntelUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.48/crispasr-macos-x86_64.tar.gz"; /// /// Intel Macs without AVX2/FMA (pre-Haswell, i.e. 2012 and older models) and the x64 build /// under Rosetta, where /// would die with an illegal instruction. /// - private const string MacIntelLegacyUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.46/crispasr-macos-x86_64-cpu-legacy.tar.gz"; + private const string MacIntelLegacyUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.48/crispasr-macos-x86_64-cpu-legacy.tar.gz"; /// /// True when an x64 process on macOS should get the portable legacy Intel build. Upstream's @@ -79,12 +79,12 @@ public class CrispAsrDownloadService : ICrispAsrDownloadService RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "crispasr-macos" : MacIntelUseLegacy ? "crispasr-macos-x86_64-cpu-legacy" : "crispasr-macos-x86_64"; - private const string LinuxUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.46/crispasr-linux-x86_64.tar.gz"; - private const string LinuxCudaUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.46/crispasr-linux-x86_64-cuda.tar.gz"; - private const string LinuxCuda13Url = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.46/crispasr-linux-x86_64-cuda13.tar.gz"; - private const string LinuxVulkanUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.46/crispasr-linux-x86_64-vulkan.tar.gz"; - private const string LinuxHipUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.46/crispasr-linux-x86_64-hip.tar.gz"; - private const string LinuxArmUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.46/crispasr-linux-arm64.tar.gz"; + private const string LinuxUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.48/crispasr-linux-x86_64.tar.gz"; + private const string LinuxCudaUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.48/crispasr-linux-x86_64-cuda.tar.gz"; + private const string LinuxCuda13Url = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.48/crispasr-linux-x86_64-cuda13.tar.gz"; + private const string LinuxVulkanUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.48/crispasr-linux-x86_64-vulkan.tar.gz"; + private const string LinuxHipUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.48/crispasr-linux-x86_64-hip.tar.gz"; + private const string LinuxArmUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.48/crispasr-linux-arm64.tar.gz"; public CrispAsrDownloadService(HttpClient httpClient) { diff --git a/src/ui/Logic/Download/DownloadHashManager.cs b/src/ui/Logic/Download/DownloadHashManager.cs index a3f4181d9fc..736256f509b 100644 --- a/src/ui/Logic/Download/DownloadHashManager.cs +++ b/src/ui/Logic/Download/DownloadHashManager.cs @@ -570,7 +570,8 @@ public static class WhisperX // (CrispStrobe/CrispASR#339, repaired in v0.8.28). [CrispAsr.WindowsCuda] = new[] { - "6d95b120da2646fe789df5cc8b2be320fecd7d722281f500b2129969a92a2c40", // v0.8.46 (current download URL) + "79560b267696c40eadfc6084736b416a2b1cb33b15ffde47fdb38ab5f17e3ef2", // v0.8.48 (current download URL) + "6d95b120da2646fe789df5cc8b2be320fecd7d722281f500b2129969a92a2c40", // v0.8.46 "94c292069a339e76109e17cc7218b17ceeb70eabf15eb8e1b1afdf7b9ee2b1b0", // v0.8.43 "62b0806f15f744d231e62b52371dc5c67dc80d7c35c196f0d1c99aa14a12dfb2", // v0.8.42 "78b2989d832ae2d540636bade42feaf865d3d0a398018c09777bad0448e1d921", // v0.8.41 @@ -628,7 +629,8 @@ public static class WhisperX // rather than replacing it, so this list starts at v0.8.31. Archive hashes. [CrispAsr.WindowsCuda13] = new[] { - "2dcb8064e9500ee4a0f097312d874ad76e45f4b1f9c6f97d23df659494fefc04", // v0.8.46 (current download URL) + "4bb8bc7aa85b4264fca3af9fcf2586fd95800e17b795bfa0be907b659e5524b4", // v0.8.48 (current download URL) + "2dcb8064e9500ee4a0f097312d874ad76e45f4b1f9c6f97d23df659494fefc04", // v0.8.46 "3cc113e6bf5efd4481ed666dc7d46946f06c9ef31a9340c6abb8d3d747bdf812", // v0.8.43 "0c6851dd954e0a83deba861fb2ef59dbf5a480d4dae310f4f86230448926db3a", // v0.8.42 "a7a6791a179084782f2e9630f33db7f90b6a45364c32784ac411ae1e3d4d1e39", // v0.8.41 @@ -644,7 +646,8 @@ public static class WhisperX }, [CrispAsr.WindowsVulkan] = new[] { - "eef2c2edcb85506f6669762c966f8609e1a84f86f8979bc52607a0dab0b09493", // v0.8.46 (current download URL) + "3a88ea0e6f97d12986c181706ee5e5547ffb4669b549012a44e3d9bd83c6b217", // v0.8.48 (current download URL) + "eef2c2edcb85506f6669762c966f8609e1a84f86f8979bc52607a0dab0b09493", // v0.8.46 "9294c7c6cca319714da2ae913a8839d81d53e2e9583020162ff9573c47654fc8", // v0.8.43 "9239e36be77daad03e02a4d6479445a06657e831daf9ca254a5c776937e541fa", // v0.8.42 "146a38edb97a83fbc1b526e7767d3e25ab03bf6f4118c41342eda09abe9af6a0", // v0.8.41 @@ -700,7 +703,8 @@ public static class WhisperX }, [CrispAsr.WindowsCpu] = new[] { - "59d81d7d279b8857674a1c5c860898880632459966079710755629efa3dfec74", // v0.8.46 (current download URL) + "f7e7a50328477f77ff87962b4e41505266fffb5aa4bf72b9e7f1f5c8f06fa1ab", // v0.8.48 (current download URL) + "59d81d7d279b8857674a1c5c860898880632459966079710755629efa3dfec74", // v0.8.46 "997bc39f541e5d5d0bd14144159c038bd95d6ab8b523718c34a54e03ab404f47", // v0.8.43 "b8d9102f695238c04d5c676098c8af60d40010396ff25ac5f9aadcc3d00ea7c5", // v0.8.42 "c803e79d1c0dc1b7160bd4b402268673803727029946c33687b33929584f7b83", // v0.8.41 @@ -749,7 +753,8 @@ public static class WhisperX }, [CrispAsr.WindowsCpuLegacy] = new[] { - "8c49ed382818569d370442bce51f855173fe611f8dfe9dbc5f6b2464da033b27", // v0.8.46 (current download URL) + "163eea99dba236e8a8f7925b205e51448e19226d0402cda82cfa4b482b5b5a17", // v0.8.48 (current download URL) + "8c49ed382818569d370442bce51f855173fe611f8dfe9dbc5f6b2464da033b27", // v0.8.46 "be0f4978d0613999954215090c2345e613c055d94ab59de0f436487933eae9ba", // v0.8.43 "b27923bbd836656facf2d3dd6ec46fd26802f3172a7c3a2c6c768e3f3c493072", // v0.8.42 "738c634360d9f7e1d13bc6d9e05def5e221a24858752536c69c9617b54efb87a", // v0.8.41 @@ -804,7 +809,8 @@ public static class WhisperX }, [CrispAsr.MacOsX64] = new[] { - "b8dccf84f85b4a5d82d7f54e9621eeb773ccfb97e303a8e783e93bcd16e5d846", // v0.8.46 (current download URL) + "b254cb88082d2f3dfba38de988e50d92f2f53df1123bdc399f537281be9b81ff", // v0.8.48 (current download URL) + "b8dccf84f85b4a5d82d7f54e9621eeb773ccfb97e303a8e783e93bcd16e5d846", // v0.8.46 "26c7ed772472330a354f08122a59107fff6830f9e7a784df8ab9c1d7dfc4b023", // v0.8.43 "cdbcdc60af6c5579965bdf6463eb6789f1b008e5ab8c83b878b696920e33ee1d", // v0.8.42 "f8ab9490345d1c7880e64282386ee3c63218790e5101158e4c8176b9dbdda4d6", // v0.8.41 @@ -823,7 +829,8 @@ public static class WhisperX // Intel Macs without AVX2/FMA and x64 under Rosetta; upstream split this archive off in v0.8.40. [CrispAsr.MacOsX64Legacy] = new[] { - "c2a5ed5afc06c63e4d1bd613133e53f365f5edbe6cc56ed6dfd626d8d9764333", // v0.8.46 (current download URL) + "74dfc5e44a34e66e688605bdb911c795c27f22f89575399f2d69cb99fceb0b57", // v0.8.48 (current download URL) + "c2a5ed5afc06c63e4d1bd613133e53f365f5edbe6cc56ed6dfd626d8d9764333", // v0.8.46 "f632510b55552545ff0c18d6fe716f5c890c948fe96c5ef25cec49aeba9d275a", // v0.8.43 "a828d2f85b67a2e2d5e638023a880bc9439e8fb00f3ed9ed43f83963e1499121", // v0.8.42 "a6ec60d94f86ed315cbfde61a7b37c1b0c1344f077562fc75ff5cf72571ed3eb", // v0.8.41 @@ -831,7 +838,8 @@ public static class WhisperX }, [CrispAsr.MacOs] = new[] { - "9138cecabb96c8a9fe48d0e2aa25f5e26ce7b5129d16cb1c6ac3388027863021", // v0.8.46 (current download URL) + "9bbd917694aba21dbffed7e5e442e3dda7e039d5122f911d7cabf01f4b1439b9", // v0.8.48 (current download URL) + "9138cecabb96c8a9fe48d0e2aa25f5e26ce7b5129d16cb1c6ac3388027863021", // v0.8.46 "354b49ba525f1f3b29620add2506d738e7a5b5a1a33225ac613f95a69237c78b", // v0.8.43 "ad0d520ba9c899f44c2f838d076de9c1289ff5600fa4262b6bdcd5cb86ab8668", // v0.8.42 "e3c1f1be6493f6247dfc11d9209b8287e3955f5a4012c3c2c56465dad6282823", // v0.8.41 @@ -887,7 +895,8 @@ public static class WhisperX }, [CrispAsr.Linux] = new[] { - "37a11962fd3f01c8299d15dc766aa2b233528dd8d60f367f72c6934f36d2ee4f", // v0.8.46 (current download URL) + "92138ebc0fa9a7911b4a3f78a9d691d650364cbf6990e22810a9db439acacf3a", // v0.8.48 (current download URL) + "37a11962fd3f01c8299d15dc766aa2b233528dd8d60f367f72c6934f36d2ee4f", // v0.8.46 "e81b597cb80e49d8687a4bceb1eb24bf55928c55ff1292a9cde12e9b4fe0eb57", // v0.8.43 "1b942bb9bd85f9e732bb3c222dbc97a644d50a8f3a4378b1507d121fae39e91f", // v0.8.42 "d6c33300836c7747450b7156e8b4b06a5079e5c1e02fd063e41ef553c13344cc", // v0.8.41 @@ -943,7 +952,8 @@ public static class WhisperX }, [CrispAsr.LinuxCuda] = new[] { - "1877bd6f6216a06a1d94c870e1db57493035f6555ba8903008609a18b163b868", // v0.8.46 (current download URL) + "772160483fe5fb43024237fac43563f270bed7877d72b3333282024d070bced4", // v0.8.48 (current download URL) + "1877bd6f6216a06a1d94c870e1db57493035f6555ba8903008609a18b163b868", // v0.8.46 "abeb01ea8c5183685ef05d5f167ee2b51dfcf83a67aedfbdd2e0802683f0a178", // v0.8.43 "40e90cd1fdcbf815c8def1e0db0a3dc6becf512d23411aa1fd6766369b9dd90c", // v0.8.42 "fb7e615478d15853959a155fbb8f25296ad5e41a5eeb85bd614d8f19ed7fae88", // v0.8.41 @@ -989,7 +999,8 @@ public static class WhisperX }, [CrispAsr.LinuxCuda13] = new[] { - "9aed20069e6ea34f9938abb8b0f38c1d85ec597e249775781c79735feb8b43e5", // v0.8.46 (current download URL) + "f7cf2ce9417957e69f11c6d570764202d092b94ecd163361a2f052020f542147", // v0.8.48 (current download URL) + "9aed20069e6ea34f9938abb8b0f38c1d85ec597e249775781c79735feb8b43e5", // v0.8.46 "fea397a3d7eb13741e3c65027fb1f51d0b362f9098257fbfe2d0e97e6c4fdf54", // v0.8.43 "8bfb828151fd3c9c15e49bbd5a7946ccb1c332be68eb9b3b029fcc4e3e4ce4d2", // v0.8.42 "7b663795bba48f267f451b8c5cad8ee3fbc4277fb69cf92db7845f1bae2cc020", // v0.8.41 @@ -1012,7 +1023,8 @@ public static class WhisperX }, [CrispAsr.LinuxVulkan] = new[] { - "a7036399fe8f33584addf5a58e62be1c9aa7fbf8033220f5b704ef261463ee8a", // v0.8.46 (current download URL) + "965fd6945459d07325b3bdb1c9e0227e3d07c2e2a9cb84be7d4cc9b5c2393ed3", // v0.8.48 (current download URL) + "a7036399fe8f33584addf5a58e62be1c9aa7fbf8033220f5b704ef261463ee8a", // v0.8.46 "d5c676e023df6fac61971dcef06fb7cce22ff3de95367f8e01efb8743c11ca46", // v0.8.43 "355f6af387e4073aecbda95465da8f605c1621a74671ede7935f95e2aad42fe0", // v0.8.42 "4bed13dc20c018d7cca978dd92e78d8ded25d4defe8ea66c2986ba6d8cd76d1c", // v0.8.41 @@ -1036,7 +1048,8 @@ public static class WhisperX }, [CrispAsr.LinuxHip] = new[] { - "f8c7347b6d9eece6132882e4b31630fd6778c9b3633ce94bbcfa6f8ab5bee7c1", // v0.8.46 (current download URL) + "0777c7e13326e146d95a34104f72cd6ee381d66f98faaddfc638117250d2ee46", // v0.8.48 (current download URL) + "f8c7347b6d9eece6132882e4b31630fd6778c9b3633ce94bbcfa6f8ab5bee7c1", // v0.8.46 "09a5dfb68b9d2d6d0b135eed4f42b9aefed66a39424ed583154d1336f9d753a8", // v0.8.43 "672b1a9483681ea8d772159391cfa85f2642a6d4ce123ddd391b068e190f1267", // v0.8.42 "ed2491969d09ee0e4ff54da3ae45931bcf43b4e52baaf29f5b91b5966c72fec0", // v0.8.41 @@ -1059,7 +1072,8 @@ public static class WhisperX }, [CrispAsr.LinuxArm] = new[] { - "ac670d76c2282b86e6782f617abc997ed56a9fc9a88a9d4c5f6706574ca9b2ba", // v0.8.46 (current download URL) + "07f7e43d17f5db9c27d4e8fe6e9d15c8d56583a30fe8117f56301fde4a2ca10a", // v0.8.48 (current download URL) + "ac670d76c2282b86e6782f617abc997ed56a9fc9a88a9d4c5f6706574ca9b2ba", // v0.8.46 "ae6e3f13e795fa6dec55d20c8bf1c96fca0bd581eab1495f344cb9f9195ec2d2", // v0.8.43 "2d75644e943dc2d02f519b3c24fd44d190af85c32eb89ae7c560ca11a7408ce1", // v0.8.42 "a0c66f1108735b68569a02b6f245799749cc9968613241733fdf83e706ba77b9", // v0.8.41 @@ -1393,7 +1407,8 @@ public static class WhisperX // SHA-256 of crispasr.exe / crispasr extracted from each archive above. [CrispAsr.WindowsCudaExecutable] = new[] { - "1047b383fcc87c7d0193c172703fa05e7a67fa7808f60e36eea5d7f6bb7e3681", // v0.8.46 (current download URL) + "7e900db3b859072205fcf7a34ce323f8c189e5593bad0e1e347d44799c9d8892", // v0.8.48 (current download URL) + "1047b383fcc87c7d0193c172703fa05e7a67fa7808f60e36eea5d7f6bb7e3681", // v0.8.46 "77e3175d8235c9e8f6e18913f6a5e8b3d01436ac3e30d9c0097546a122dae450", // v0.8.43 "6d77e667f142079476dbbff157b069fa2f30285d4c12244c988f9ac4893d2355", // v0.8.42 "de9008a6f7a15967589d382b7d3b9af0ff63d33d1a6cf6a93016eac2017b6bda", // v0.8.41 @@ -1436,7 +1451,8 @@ public static class WhisperX // rather than replacing it, so this list starts at v0.8.31. Executable hashes. [CrispAsr.WindowsCuda13Executable] = new[] { - "55b04cf3dcc87b7817ae7e56e6fd2110e35b04eff81baf1616d49d69dded7faf", // v0.8.46 (current download URL) + "7d726456937bbe8964c6e608d34a256359d3b2bad4f22f3ecdcba6a00e3bbfbc", // v0.8.48 (current download URL) + "55b04cf3dcc87b7817ae7e56e6fd2110e35b04eff81baf1616d49d69dded7faf", // v0.8.46 "7ad5f078b417b871c58e445c32d71f001518a8d91d30db40eaaf9b082de352b1", // v0.8.43 "e87f1600b7eeb91976add4de3b81acddcc241e6a4e4b55d9b3dae8a77d7492b9", // v0.8.42 "4d3f06e3d79936212dc3b3f1092609b9ce27db48ab26d7973f5199a988b0d4bf", // v0.8.41 @@ -1452,7 +1468,8 @@ public static class WhisperX }, [CrispAsr.WindowsVulkanExecutable] = new[] { - "0bf4ad9e26790268f4efa644cacd934afb93b29b314ab51b231d94a584cc4402", // v0.8.46 (current download URL) + "d06ed5a930d3ba7576f922b0851f12d4421a9032e9bbf84420975694e99f2696", // v0.8.48 (current download URL) + "0bf4ad9e26790268f4efa644cacd934afb93b29b314ab51b231d94a584cc4402", // v0.8.46 "2251c0d8e938aedbca5a79c233caefd22b193f8203a921bc43e4528abc36b0f0", // v0.8.43 "ccef0b980c350f36a67067d5e1e6b0f216b1a475252a00eb0983c48ac9d68a92", // v0.8.42 "07dbe65c4adbea6e977bc0253d0991175cbd92ee871e5a5cf33ac4eda5303658", // v0.8.41 @@ -1508,7 +1525,8 @@ public static class WhisperX }, [CrispAsr.WindowsCpuExecutable] = new[] { - "11065972c400933da7f42b0a9f46b15ef3e9bcc2d652de00425fbaafde3b5b64", // v0.8.46 (current download URL) + "0ef4018b1aca16e5abae09c52fe4b8a3bf0b3915218a9d817d02024c08afecdb", // v0.8.48 (current download URL) + "11065972c400933da7f42b0a9f46b15ef3e9bcc2d652de00425fbaafde3b5b64", // v0.8.46 "c9e6c7e51276165e2a63ce383f35652139466a359671e0af8b037f15cfb66d8a", // v0.8.43 "9ea7f63ae35adceaa19d76e5be9dff34eb5c970375905103d1488c15af1469ca", // v0.8.42 "1e4301169b6167baa23fbcc2b02e1802c3a2b2d5d5c9e756e4c99afbbd57b53a", // v0.8.41 @@ -1557,7 +1575,8 @@ public static class WhisperX }, [CrispAsr.WindowsCpuLegacyExecutable] = new[] { - "38de9d14e05e9310f57b40f917f2a88b3965dda405257d2d0b8aa934b0ec1f07", // v0.8.46 (current download URL) + "5cc178a6603aa18622d29811ae7d10be436f4ba92148af3be29969b1215fb8c1", // v0.8.48 (current download URL) + "38de9d14e05e9310f57b40f917f2a88b3965dda405257d2d0b8aa934b0ec1f07", // v0.8.46 "489f946035ed70e86ca4be16de4adc22a8f4a598619177c97f38bb51d7de3693", // v0.8.43 "2f3c0038b5b90eb5f198eaa06837896c1788d67e96fd16abd7965b126f493680", // v0.8.42 "098595f1b1b90b5469c1d539f972a5040281c60cb161f1598945148d4c5f60cb", // v0.8.41 @@ -1612,7 +1631,8 @@ public static class WhisperX }, [CrispAsr.LinuxExecutable] = new[] { - "ece0a2508f42415902e347e14ebbab6201c6bfb0dc329c4a1639827432bf46f1", // v0.8.46 (current download URL) + "1ca6034b84fcbde5b9c4b355e8eff8ec4b270b201f5e937b5982be72dfc1695a", // v0.8.48 (current download URL) + "ece0a2508f42415902e347e14ebbab6201c6bfb0dc329c4a1639827432bf46f1", // v0.8.46 "7081edc80d7948007ae4bb8fa5572c4f8ef24d347cc6620f4963c8010ffc6691", // v0.8.43 "7fee41d3399787277ce420d61907e7bf673778394254c3cbf4bae37863ff9953", // v0.8.42 "52d6ba9e9cb23b4f3baa6835b29910807108077455ba7ee67a93200b88b256ae", // v0.8.41 @@ -1668,7 +1688,8 @@ public static class WhisperX }, [CrispAsr.MacOsX64Executable] = new[] { - "5f99af7c1ad05379cdc6278c5fffa82833ccf818c0f73fe79f70f04f636efeec", // v0.8.46 (current download URL) + "8d20161dab766cbf8f82dda1405c4c10790f02633c5091860e47682b56b0d357", // v0.8.48 (current download URL) + "5f99af7c1ad05379cdc6278c5fffa82833ccf818c0f73fe79f70f04f636efeec", // v0.8.46 "499bd7ff0b06db88e55c3626b4ed9e7167f12d403910a0a145a85c9a660f63e0", // v0.8.43 "9bb13b784a14d4dfd6e154e945d6040a1b75b5bb127d58436c30d3f10109c282", // v0.8.42 "8cc5c25827a7a07b969ea45035bc22674547789d59c09f9c8ffaded8637aa898", // v0.8.41 @@ -1688,7 +1709,8 @@ public static class WhisperX // resolves to the legacy key still reads as "update available" rather than unknown. [CrispAsr.MacOsX64LegacyExecutable] = new[] { - "2296b1ae90bd946088f7f52fc03cc48089489fc5760ac1479072afa71f4ec1f2", // v0.8.46 (current download URL) + "b96f4b0394d57cbcb8aac8d4824086edaf9234d87e414fdbd8d1d85a9ec3e380", // v0.8.48 (current download URL) + "2296b1ae90bd946088f7f52fc03cc48089489fc5760ac1479072afa71f4ec1f2", // v0.8.46 "862b3b059a8c77a278362354748a43dc6779255cf17197bf036cf8a3e6670b56", // v0.8.43 "2c6251ec39e0d6eba1f786effe9d66a3c59a9a4a5e848f3a53ae7bb35ea62dc2", // v0.8.42 "484011c22bdfa8494cd6e075210aad495cf20acb8d6e52dd58ad4849cd86b917", // v0.8.41 @@ -1706,7 +1728,8 @@ public static class WhisperX }, [CrispAsr.MacOsExecutable] = new[] { - "eea2bf1f71fade7b8ae425e35df28b025b0dc80da55674271d469da53928cdb1", // v0.8.46 (current download URL) + "3a058a333ba24636f3dfe01c4a38bacdf3d4719cff805b90256fc0f52bda5361", // v0.8.48 (current download URL) + "eea2bf1f71fade7b8ae425e35df28b025b0dc80da55674271d469da53928cdb1", // v0.8.46 "3e3babb8b1b88602713a2574156b66339d6b0a1988aff164e830d5edadb9d4bd", // v0.8.43 "cadf8b5603128651d5828ab86206390377c4b703b6a26d35b6af97c5942c62ec", // v0.8.42 "be4b837c3534eae81e84482666045b3d7778689ae86da3fc7df5f20d9ed79cbc", // v0.8.41 @@ -1762,7 +1785,8 @@ public static class WhisperX }, [CrispAsr.LinuxCudaExecutable] = new[] { - "9a29536b3ce4de412950c245f4faaedeb2277e9a7ddf6c5d0df3f3222ea7d035", // v0.8.46 (current download URL) + "d629f1c5875a5b7a30e637ddb37f718907b5c40d22452e471587add0751aa957", // v0.8.48 (current download URL) + "9a29536b3ce4de412950c245f4faaedeb2277e9a7ddf6c5d0df3f3222ea7d035", // v0.8.46 "b543ccb012bfbd2689d1cc253dccc0d971109e79c75c2520f867652c41d9761b", // v0.8.43 "30418d8d6388a8d10acc83c67becdabb54ed18db4315746ecdbf19a0edf2d2a9", // v0.8.42 "5701c0722d6e82de68fbeece519f40567b8c09fc058f85a0752f584d79a0bbf2", // v0.8.41 @@ -1808,7 +1832,8 @@ public static class WhisperX }, [CrispAsr.LinuxCuda13Executable] = new[] { - "019a9e075e221472c53b56bce2f14dd3ad3f29708517bceed35c8a3623a263b4", // v0.8.46 (current download URL) + "3f4fb44a0292f036ec8eb2ed04905b377f93f62185841924e142b5e849a2d7d9", // v0.8.48 (current download URL) + "019a9e075e221472c53b56bce2f14dd3ad3f29708517bceed35c8a3623a263b4", // v0.8.46 "df6d8fcaba0871b9d3dca1d650a4011d21efd2e4ba5012ac26b78fc61f61a813", // v0.8.43 "5cb8ea4ef6a2b2c5c306118e222dffafce4358ca682374ae7fe79b80d0c2db4b", // v0.8.42 "c7ce5998d96948be463a5b9cef74a273ca21af6c02078530f1a7d5204133a954", // v0.8.41 @@ -1831,7 +1856,8 @@ public static class WhisperX }, [CrispAsr.LinuxVulkanExecutable] = new[] { - "9204933c2f25b55b1d8fb68458a59dc15bd1699a555294b7d9d08d05e24033b3", // v0.8.46 (current download URL) + "1801e8dd2e253a19c41ba9b25e464498b9fc97b5e4de46c52a3e0c2fed5ee79b", // v0.8.48 (current download URL) + "9204933c2f25b55b1d8fb68458a59dc15bd1699a555294b7d9d08d05e24033b3", // v0.8.46 "00d9393f999f83b500e5f6e9d35b33e8e54326ee2420a179c8a29c8ce52851d3", // v0.8.43 "c3072ac68c64ba1eacb36f922b272cd5254af036fb2b605c0b4bf370d46e02b1", // v0.8.42 "83557a13547933a67d96b6b3b137d6d391b6d85924384647e77eed28a3c58ca6", // v0.8.41 @@ -1855,7 +1881,8 @@ public static class WhisperX }, [CrispAsr.LinuxHipExecutable] = new[] { - "35d0131e053c1126df589a8d2fab3660a9e0bc36df7dc01c4548754155d08aa5", // v0.8.46 (current download URL) + "8d0d195c557ef06084b416f49f1bc629c4803b922ead5132442fa554d820dfa2", // v0.8.48 (current download URL) + "35d0131e053c1126df589a8d2fab3660a9e0bc36df7dc01c4548754155d08aa5", // v0.8.46 "1d46b7b631e52a6acf13efc3a6b8d7d9daed8ea05302984713c97ee353080f0d", // v0.8.43 "b30e13cf7202b1ba5c11d49d72ccd5c0b2a7c73c8887fdfaa91e5017bb5dc46a", // v0.8.42 "f03b0fc63758076f3db377846026c79a68aba965d2fbab1306e88d2524df3015", // v0.8.41 @@ -1878,7 +1905,8 @@ public static class WhisperX }, [CrispAsr.LinuxArmExecutable] = new[] { - "1b92507e69d265672c99206bce15dd3a3243d4ae1c11a7b6a8f04ed0dfde993e", // v0.8.46 (current download URL) + "9fdd852e1d346709dfdb36941f58c5e99a67cea4c427890bda936a6984e0088b", // v0.8.48 (current download URL) + "1b92507e69d265672c99206bce15dd3a3243d4ae1c11a7b6a8f04ed0dfde993e", // v0.8.46 "ea0297684bb5a8a5bdf4f57520654015d5fc828b27547dd761388dd2997aeca4", // v0.8.43 "d93d17a2a92e7f3079ea32570e752905dc967f4ea0a56b58e2abc7756961a3cc", // v0.8.42 "2e70e488f8f1ba06fffb28d622bafa70971171d72f31e5e1f49e0c101259b9ad", // v0.8.41 From 0a1ea0dbf58d41fd0fb2be035dcb2dbfd09d0ab6 Mon Sep 17 00:00:00 2001 From: niksedk Date: Thu, 29 Oct 2026 16:44:27 +0100 Subject: [PATCH 05/29] Use Silero VAD for Crisp ASR Granite by default Without VAD, crispasr runs Granite in fixed 30 s auto-chunks that drop words at the cuts. On FLEURS dev (30 en / 25 de clips, peak-normalized, crispasr 0.8.48, SE's default --max-len 50 --split-on-punct), Silero VAD took Granite 4.1 from 11.8% to 9.2% WER in English and from 12.7% to 10.7% in German, and was slightly faster. Granite 5.0 improved the same way. Granite now joins Cohere, Mega and Index-Echo in the "Auto" VAD set. --vad, --vad-model or --chunk-seconds in the advanced parameters still take precedence, and the empty-result retry still runs without VAD. Co-Authored-By: Claude Opus 5.5 --- .../SpeechToText/SpeechToTextViewModel.cs | 6 +++-- .../Language/Video/LanguageAudioToText.cs | 2 +- .../SpeechToText/CrispAsrVadGateTests.cs | 25 +++++++++++++++---- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs index f95556634d1..0e59b094f72 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs @@ -1019,7 +1019,9 @@ await MessageBox.Show(Window!, $"CUDA failed", /// the bundled Silero model keeps it from downloading its own copy into ~/.cache/crispasr /// mid-transcription. Index-Echo windows its audio on Silero speech boundaries when it has the /// model and falls back to fixed 60 s windows otherwise, which cut sentences in half and repeated - /// a cue at the seam. + /// a cue at the seam. Granite otherwise runs in crispasr's fixed 30 s auto-chunks, which drop + /// words at the cuts: on FLEURS (30 en / 25 de clips, crispasr 0.8.48) Silero took Granite 4.1 + /// from 11.8% to 9.2% WER in English and from 12.7% to 10.7% in German. /// /// --chunk-seconds/-ck in the user's parameters means "no VAD, use fixed chunks" - that is /// crispasr's own documented way to switch its auto-VAD back off, and it is the only way to @@ -1039,7 +1041,7 @@ internal static bool ShouldForceCrispAsrVad(ISpeechToTextEngine engine, string? return false; } - var needsVad = engine is CrispAsrCohere or CrispAsrMega or CrispAsrIndexEcho; + var needsVad = engine is CrispAsrCohere or CrispAsrMega or CrispAsrIndexEcho or CrispAsrGranite; if (!needsVad && CrispAsrVadModel.Get(vadChoice).Choice == CrispAsrVadModel.Automatic) { return false; diff --git a/src/ui/Logic/Config/Language/Video/LanguageAudioToText.cs b/src/ui/Logic/Config/Language/Video/LanguageAudioToText.cs index 52fedb8e30b..2c83d1deb53 100644 --- a/src/ui/Logic/Config/Language/Video/LanguageAudioToText.cs +++ b/src/ui/Logic/Config/Language/Video/LanguageAudioToText.cs @@ -99,7 +99,7 @@ public LanguageAudioToText() DetectSpeakersNeedsNewerCrispAsr = "\"Detect speakers\" needs Crisp ASR v{0} or newer, but v{1} is installed.\n\nUpdate Crisp ASR (the gear button next to the engine) or turn off \"Detect speakers\"."; XNeedsNewerCrispAsr = "{0} needs Crisp ASR v{1} or newer, but v{2} is installed.\n\nDownload and install the update now?"; Vad = "VAD"; - VadHint = "Voice activity detection: only the speech is transcribed, cut where the speaker pauses.\nAuto: Silero, and only for the engines that need it (Cohere, Mega, Index-Echo).\nSilero, FireRedVAD, Whisper-VAD, WebRTC: always on, for every Crisp ASR engine. FireRedVAD is the most accurate, Whisper-VAD gives the fewest, cleanest cuts but is the slowest.\nIndex-Echo always uses Silero. --vad, --vad-model or --chunk-seconds in the advanced settings take precedence."; + VadHint = "Voice activity detection: only the speech is transcribed, cut where the speaker pauses.\nAuto: Silero, and only for the engines that need it (Cohere, Mega, Index-Echo, Granite).\nSilero, FireRedVAD, Whisper-VAD, WebRTC: always on, for every Crisp ASR engine. FireRedVAD is the most accurate, Whisper-VAD gives the fewest, cleanest cuts but is the slowest.\nIndex-Echo always uses Silero. --vad, --vad-model or --chunk-seconds in the advanced settings take precedence."; InputLanguage = "Input language"; AdvancedWhisperSettings = "Advanced speech-to-text parameters"; DownloadingSpeechToTextEngine = "Downloading speech-to-text engine"; diff --git a/tests/UI/Features/Video/SpeechToText/CrispAsrVadGateTests.cs b/tests/UI/Features/Video/SpeechToText/CrispAsrVadGateTests.cs index d56f784cd4f..dd379838b3c 100644 --- a/tests/UI/Features/Video/SpeechToText/CrispAsrVadGateTests.cs +++ b/tests/UI/Features/Video/SpeechToText/CrispAsrVadGateTests.cs @@ -4,11 +4,11 @@ namespace UITests.Features.Video.SpeechToText; /// -/// SE adds its own Silero VAD to the Cohere and Mega Crisp ASR backends, which otherwise emit a -/// zero-byte SRT on long audio. Two things have to stay true about that: the user can still turn -/// it off with crispasr's own --chunk-seconds (#13849), and a job that came back empty can be -/// re-run with VAD off (#13911) - on a per-line clip Silero can reject the whole clip as -/// non-speech, which left clips silently unconverted. +/// SE adds its own Silero VAD to the Cohere, Mega, Index-Echo and Granite Crisp ASR backends +/// (Cohere and Mega otherwise emit a zero-byte SRT on long audio). Two things have to stay true +/// about that: the user can still turn it off with crispasr's own --chunk-seconds (#13849), and +/// a job that came back empty can be re-run with VAD off (#13911) - on a per-line clip Silero +/// can reject the whole clip as non-speech, which left clips silently unconverted. /// public class CrispAsrVadGateTests { @@ -31,6 +31,21 @@ public void IndexEchoGetsVadByDefault() Assert.True(SpeechToTextViewModel.ShouldForceCrispAsrVad(new CrispAsrIndexEcho(), string.Empty, vadSuppressed: false)); } + /// Without VAD, Granite runs in fixed 30 s auto-chunks that drop words at the cuts. + [Fact] + public void GraniteGetsVadByDefault() + { + Assert.True(SpeechToTextViewModel.ShouldForceCrispAsrVad(new CrispAsrGranite(), string.Empty, vadSuppressed: false)); + } + + /// Granite's own default parameters must not count as the user opting out. + [Fact] + public void GraniteDefaultParametersKeepVad() + { + var defaultArgs = new Nikse.SubtitleEdit.Logic.Config.SeAudioToText().CommandLineParameterCrispAsrGranite; + Assert.True(SpeechToTextViewModel.ShouldForceCrispAsrVad(new CrispAsrGranite(), defaultArgs, vadSuppressed: false)); + } + [Fact] public void OtherBackendsAreLeftAlone() { From aed87bcd1d86c70328e21b09f93d665af4208a66 Mon Sep 17 00:00:00 2001 From: niksedk Date: Thu, 29 Oct 2026 17:09:23 +0100 Subject: [PATCH 06/29] Add "move start/end X ms" shortcuts that push the neighbour Two new shortcuts (no default key): "Move start X ms back, push previous end" and "Move end X ms forward, push next start". They move by the same "Move start/end shortcut step (ms)" setting as the plain X ms shortcuts, but on reaching the minimum gap they push the neighbour's border along instead of stopping. Unlike the KeepGap frame variants, the neighbour is never pulled back when moving away again. Touching lines stay touching, and the pushed line is not squeezed below the minimum duration. Requested on doom9 for keyboard-only timing (Alt+Left/Right). Co-Authored-By: Claude Opus 5.5 --- src/ui/Features/Main/MainViewModel.cs | 70 ++++++++++++++- .../Options/LanguageSettingsShortcuts.cs | 4 + src/ui/Logic/ShortcutsMain.cs | 4 + tests/UI/Features/Main/FrameNudgeGapTests.cs | 86 +++++++++++++++++++ 4 files changed, 160 insertions(+), 4 deletions(-) diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index f9730b691e6..03bd5b29748 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -16969,6 +16969,15 @@ private static string StripLeadingDashes(string s) [RelayCommand] private void MoveEndXMsForward() => MoveEndByMs(GetMoveStartEndStepMs(), keepGapNextIfClose: false); + // Push variants (doom9 request): the step is the same, but running into the neighbour pushes + // its border along at the minimum gap instead of stopping. Only the directions that can + // collide get one - moving away from the neighbour is the plain shortcut. + [RelayCommand] + private void MoveStartXMsBackPushPrevious() => MoveStartByMs(-GetMoveStartEndStepMs(), keepGapPrevIfClose: false, pushPrevOnCollision: true); + + [RelayCommand] + private void MoveEndXMsForwardPushNext() => MoveEndByMs(GetMoveStartEndStepMs(), keepGapNextIfClose: false, pushNextOnCollision: true); + private static int GetMoveStartEndStepMs() => Math.Max(1, Se.Settings.General.MoveStartEndStepMs); // #14789: repeatable fixed-ms nudges for fixing progressive drift section by section. @@ -17237,7 +17246,7 @@ private static bool IsKeepGapClose(double gapToNeighbourMs, double minGapMs) => private void MoveStartByFrames(int frames, bool keepGapPrevIfClose) => MoveStartByMs(FramesToMilliseconds(frames), keepGapPrevIfClose); - private void MoveStartByMs(int deltaMs, bool keepGapPrevIfClose) + private void MoveStartByMs(int deltaMs, bool keepGapPrevIfClose, bool pushPrevOnCollision = false) { var s = SelectedSubtitle; if (s == null || AreTimeCodesLocked) @@ -17281,13 +17290,37 @@ private void MoveStartByMs(int deltaMs, bool keepGapPrevIfClose) } } + // Push variant: unlike KeepGapPrev the previous end only moves when this start would get + // closer to it than the minimum gap (or the current gap, if that is already tighter - + // touching lines stay touching), and it never follows a start that moves forward again. + var pushPrev = false; + var newPrevEndMs = 0.0; + if (pushPrevOnCollision && deltaMs < 0 && prev != null + && prev.EndTime.TotalMilliseconds <= s.StartTime.TotalMilliseconds) + { + var pushGapMs = Math.Min(gapMs, s.StartTime.TotalMilliseconds - prev.EndTime.TotalMilliseconds); + if (newStartMs - pushGapMs < prev.EndTime.TotalMilliseconds) + { + // Push no further than the previous line's minimum duration. + newPrevEndMs = Math.Max(newStartMs - pushGapMs, + prev.StartTime.TotalMilliseconds + Se.Settings.General.SubtitleMinimumDisplayMilliseconds); + newStartMs = newPrevEndMs + pushGapMs; + if (newStartMs >= s.StartTime.TotalMilliseconds) + { + return; + } + + pushPrev = true; + } + } + // Moving the start back must not run into the previous line (issue #13999). SE 4 clamped // to "previous end + minimum gap" here; SE 5 checked only this line's own end, so repeated // presses walked straight through the neighbour. The KeepGapPrev variant is exempt: it // carries the previous line along, so there is nothing to run into. "Allow overlap (when // moving/resizing)" is the user saying they want to - the waveform drag reads it the same // way. - if (deltaMs < 0 && !prevIsClose && prev != null && !Se.Settings.Waveform.AllowOverlap) + if (deltaMs < 0 && !prevIsClose && !pushPrev && prev != null && !Se.Settings.Waveform.AllowOverlap) { var floorMs = prev.EndTime.TotalMilliseconds + gapMs; if (newStartMs < floorMs) @@ -17321,6 +17354,10 @@ private void MoveStartByMs(int deltaMs, bool keepGapPrevIfClose) { prev.EndTime = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newStartMs - prevGapMs); } + else if (pushPrev && prev != null) + { + prev.EndTime = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newPrevEndMs); + } _updateAudioVisualizer = true; } @@ -17328,7 +17365,7 @@ private void MoveStartByMs(int deltaMs, bool keepGapPrevIfClose) private void MoveEndByFrames(int frames, bool keepGapNextIfClose) => MoveEndByMs(FramesToMilliseconds(frames), keepGapNextIfClose); - private void MoveEndByMs(int deltaMs, bool keepGapNextIfClose) + private void MoveEndByMs(int deltaMs, bool keepGapNextIfClose, bool pushNextOnCollision = false) { var s = SelectedSubtitle; if (s == null || AreTimeCodesLocked) @@ -17368,9 +17405,30 @@ private void MoveEndByMs(int deltaMs, bool keepGapNextIfClose) } } + // Push variant: symmetric counterpart of the one in MoveStartByMs. + var pushNext = false; + var newNextStartMs = 0.0; + if (pushNextOnCollision && deltaMs > 0 && next != null + && s.EndTime.TotalMilliseconds <= next.StartTime.TotalMilliseconds) + { + var pushGapMs = Math.Min(gapMs, next.StartTime.TotalMilliseconds - s.EndTime.TotalMilliseconds); + if (newEndMs + pushGapMs > next.StartTime.TotalMilliseconds) + { + newNextStartMs = Math.Min(newEndMs + pushGapMs, + next.EndTime.TotalMilliseconds - Se.Settings.General.SubtitleMinimumDisplayMilliseconds); + newEndMs = newNextStartMs - pushGapMs; + if (newEndMs <= s.EndTime.TotalMilliseconds) + { + return; + } + + pushNext = true; + } + } + // Symmetric counterpart of the clamp in MoveStartByFrames (issue #13999): moving the end // forward must not run into the next line. - if (deltaMs > 0 && !nextIsClose && next != null && !Se.Settings.Waveform.AllowOverlap) + if (deltaMs > 0 && !nextIsClose && !pushNext && next != null && !Se.Settings.Waveform.AllowOverlap) { var ceilingMs = next.StartTime.TotalMilliseconds - gapMs; if (newEndMs > ceilingMs) @@ -17390,6 +17448,10 @@ private void MoveEndByMs(int deltaMs, bool keepGapNextIfClose) { next.SetStartTimeOnly(TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newEndMs + nextGapMs)); } + else if (pushNext && next != null) + { + next.SetStartTimeOnly(TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newNextStartMs)); + } _updateAudioVisualizer = true; } diff --git a/src/ui/Logic/Config/Language/Options/LanguageSettingsShortcuts.cs b/src/ui/Logic/Config/Language/Options/LanguageSettingsShortcuts.cs index 34729bb609d..a9387a65f83 100644 --- a/src/ui/Logic/Config/Language/Options/LanguageSettingsShortcuts.cs +++ b/src/ui/Logic/Config/Language/Options/LanguageSettingsShortcuts.cs @@ -336,6 +336,8 @@ public class LanguageSettingsShortcuts public string MoveStartXMsForward { get; set; } public string MoveEndXMsBack { get; set; } public string MoveEndXMsForward { get; set; } + public string MoveStartXMsBackPushPrevious { get; set; } + public string MoveEndXMsForwardPushNext { get; set; } public string MoveSelectedLinesXMsBack { get; set; } public string MoveSelectedLinesXMsForward { get; set; } public string MoveSelectedLinesAndForwardXMsBack { get; set; } @@ -696,6 +698,8 @@ public LanguageSettingsShortcuts() MoveStartXMsForward = "Move start X ms forward (X set in Settings, also in Visual Sync)"; MoveEndXMsBack = "Move end X ms back (X set in Settings, also in Visual Sync)"; MoveEndXMsForward = "Move end X ms forward (X set in Settings, also in Visual Sync)"; + MoveStartXMsBackPushPrevious = "Move start X ms back, push previous end (X set in Settings)"; + MoveEndXMsForwardPushNext = "Move end X ms forward, push next start (X set in Settings)"; MoveSelectedLinesXMsBack = "Move selected lines X ms back (X set in Settings)"; MoveSelectedLinesXMsForward = "Move selected lines X ms forward (X set in Settings)"; MoveSelectedLinesAndForwardXMsBack = "Move selected lines and all following X ms back (X set in Settings)"; diff --git a/src/ui/Logic/ShortcutsMain.cs b/src/ui/Logic/ShortcutsMain.cs index a49f548c62e..81f2370813d 100644 --- a/src/ui/Logic/ShortcutsMain.cs +++ b/src/ui/Logic/ShortcutsMain.cs @@ -548,6 +548,8 @@ private static Dictionary BuildCommandTranslations() { nameof(MainViewModel.MoveStartXMsForwardCommand), Se.Language.Options.Shortcuts.MoveStartXMsForward }, { nameof(MainViewModel.MoveEndXMsBackCommand), Se.Language.Options.Shortcuts.MoveEndXMsBack }, { nameof(MainViewModel.MoveEndXMsForwardCommand), Se.Language.Options.Shortcuts.MoveEndXMsForward }, + { nameof(MainViewModel.MoveStartXMsBackPushPreviousCommand), Se.Language.Options.Shortcuts.MoveStartXMsBackPushPrevious }, + { nameof(MainViewModel.MoveEndXMsForwardPushNextCommand), Se.Language.Options.Shortcuts.MoveEndXMsForwardPushNext }, { nameof(MainViewModel.MoveSelectedLinesXMsBackCommand), Se.Language.Options.Shortcuts.MoveSelectedLinesXMsBack }, { nameof(MainViewModel.MoveSelectedLinesXMsForwardCommand), Se.Language.Options.Shortcuts.MoveSelectedLinesXMsForward }, { nameof(MainViewModel.MoveSelectedLinesAndForwardXMsBackCommand), Se.Language.Options.Shortcuts.MoveSelectedLinesAndForwardXMsBack }, @@ -1132,6 +1134,8 @@ private static List GetAllAvailableShortcuts(MainViewModel vm AddShortcut(shortcuts, vm.MoveStartXMsForwardCommand, nameof(vm.MoveStartXMsForwardCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.MoveEndXMsBackCommand, nameof(vm.MoveEndXMsBackCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.MoveEndXMsForwardCommand, nameof(vm.MoveEndXMsForwardCommand), ShortcutCategory.General); + AddShortcut(shortcuts, vm.MoveStartXMsBackPushPreviousCommand, nameof(vm.MoveStartXMsBackPushPreviousCommand), ShortcutCategory.General); + AddShortcut(shortcuts, vm.MoveEndXMsForwardPushNextCommand, nameof(vm.MoveEndXMsForwardPushNextCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.MoveSelectedLinesXMsBackCommand, nameof(vm.MoveSelectedLinesXMsBackCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.MoveSelectedLinesXMsForwardCommand, nameof(vm.MoveSelectedLinesXMsForwardCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.MoveSelectedLinesAndForwardXMsBackCommand, nameof(vm.MoveSelectedLinesAndForwardXMsBackCommand), ShortcutCategory.General); diff --git a/tests/UI/Features/Main/FrameNudgeGapTests.cs b/tests/UI/Features/Main/FrameNudgeGapTests.cs index 1da0a7acced..c9d7f26adf5 100644 --- a/tests/UI/Features/Main/FrameNudgeGapTests.cs +++ b/tests/UI/Features/Main/FrameNudgeGapTests.cs @@ -33,6 +33,7 @@ public class FrameNudgeGapTests : IDisposable private readonly int _minBetweenMs = Se.Settings.General.MinimumBetweenLines.Milliseconds; private readonly int _minBetweenFrames = Se.Settings.General.MinimumBetweenLines.Frames; private readonly int _moveStartEndStepMs = Se.Settings.General.MoveStartEndStepMs; + private readonly int _minDisplayMs = Se.Settings.General.SubtitleMinimumDisplayMilliseconds; public void Dispose() { @@ -43,6 +44,7 @@ public void Dispose() Se.Settings.General.MinimumBetweenLines.Milliseconds = _minBetweenMs; Se.Settings.General.MinimumBetweenLines.Frames = _minBetweenFrames; Se.Settings.General.MoveStartEndStepMs = _moveStartEndStepMs; + Se.Settings.General.SubtitleMinimumDisplayMilliseconds = _minDisplayMs; foreach (var w in _windows) { w.Close(); @@ -341,6 +343,90 @@ public void MoveStartBackKeepGapPrev_SmallestOnFrameGapAboveMsMinimum_CarriesThe window.Close(); } + // Push variants (doom9): the step moves freely until the minimum gap, then pushes the + // neighbour's border along; moving back again leaves the neighbour where it was pushed to. + [AvaloniaFact] + public void MoveEndXMsForwardPushNext_PushesAtTheMinimumGap_AndDoesNotPullBack() + { + var (window, vm) = TwoLines(gapMs: 200, minGapMs: 100); + Se.Settings.General.MoveStartEndStepMs = 50; + vm.SelectedSubtitle = vm.Subtitles[0]; + Dispatcher.UIThread.RunJobs(); + + vm.MoveEndXMsForwardPushNextCommand.Execute(null); + vm.MoveEndXMsForwardPushNextCommand.Execute(null); + Assert.Equal(3100, vm.Subtitles[0].EndTime.TotalMilliseconds, 3); + Assert.Equal(3200, vm.Subtitles[1].StartTime.TotalMilliseconds, 3); + + vm.MoveEndXMsForwardPushNextCommand.Execute(null); + Assert.Equal(3150, vm.Subtitles[0].EndTime.TotalMilliseconds, 3); + Assert.Equal(3250, vm.Subtitles[1].StartTime.TotalMilliseconds, 3); + + vm.MoveEndXMsBackCommand.Execute(null); + Assert.Equal(3100, vm.Subtitles[0].EndTime.TotalMilliseconds, 3); + Assert.Equal(3250, vm.Subtitles[1].StartTime.TotalMilliseconds, 3); + window.Close(); + } + + [AvaloniaFact] + public void MoveStartXMsBackPushPrevious_PushesAtTheMinimumGap_AndDoesNotPullBack() + { + var (window, vm) = TwoLines(gapMs: 200, minGapMs: 100); + Se.Settings.General.MoveStartEndStepMs = 50; + vm.SelectedSubtitle = vm.Subtitles[1]; + Dispatcher.UIThread.RunJobs(); + + vm.MoveStartXMsBackPushPreviousCommand.Execute(null); + vm.MoveStartXMsBackPushPreviousCommand.Execute(null); + Assert.Equal(3100, vm.Subtitles[1].StartTime.TotalMilliseconds, 3); + Assert.Equal(3000, vm.Subtitles[0].EndTime.TotalMilliseconds, 3); + + vm.MoveStartXMsBackPushPreviousCommand.Execute(null); + Assert.Equal(3050, vm.Subtitles[1].StartTime.TotalMilliseconds, 3); + Assert.Equal(2950, vm.Subtitles[0].EndTime.TotalMilliseconds, 3); + + vm.MoveStartXMsForwardCommand.Execute(null); + Assert.Equal(3100, vm.Subtitles[1].StartTime.TotalMilliseconds, 3); + Assert.Equal(2950, vm.Subtitles[0].EndTime.TotalMilliseconds, 3); + window.Close(); + } + + // The pushed line is never squeezed below the minimum duration. + [AvaloniaFact] + public void MoveStartXMsBackPushPrevious_StopsAtThePreviousMinimumDuration() + { + var (window, vm) = TwoLines(gapMs: 100, minGapMs: 100); + Se.Settings.General.MoveStartEndStepMs = 70; + Se.Settings.General.SubtitleMinimumDisplayMilliseconds = 1000; + vm.SelectedSubtitle = vm.Subtitles[1]; + Dispatcher.UIThread.RunJobs(); + + for (var i = 0; i < 50; i++) + { + vm.MoveStartXMsBackPushPreviousCommand.Execute(null); + } + + Assert.Equal(2000, vm.Subtitles[0].EndTime.TotalMilliseconds, 3); + Assert.Equal(2100, vm.Subtitles[1].StartTime.TotalMilliseconds, 3); + window.Close(); + } + + // Touching lines stay touching - the push does not open them up to the minimum gap. + [AvaloniaFact] + public void MoveEndXMsForwardPushNext_TouchingLines_StayTouching() + { + var (window, vm) = TwoLines(gapMs: 0, minGapMs: 100); + Se.Settings.General.MoveStartEndStepMs = 50; + vm.SelectedSubtitle = vm.Subtitles[0]; + Dispatcher.UIThread.RunJobs(); + + vm.MoveEndXMsForwardPushNextCommand.Execute(null); + + Assert.Equal(3050, vm.Subtitles[0].EndTime.TotalMilliseconds, 3); + Assert.Equal(3050, vm.Subtitles[1].StartTime.TotalMilliseconds, 3); + window.Close(); + } + private (Window Window, MainViewModel Vm) TwoLinesInFrameMode(int gapMs, int minGapFrames) { Se.Settings.General.MinimumBetweenLines.Frames = minGapFrames; From df169a77ac85ed0ac1d6b4652dfd429b74f4819e Mon Sep 17 00:00:00 2001 From: FunkyKnilch Date: Thu, 29 Oct 2026 17:40:38 +0100 Subject: [PATCH 07/29] Update German Translation --- src/ui/Assets/Languages/German.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ui/Assets/Languages/German.json b/src/ui/Assets/Languages/German.json index 88384f5a265..97132cffcf8 100644 --- a/src/ui/Assets/Languages/German.json +++ b/src/ui/Assets/Languages/German.json @@ -190,6 +190,9 @@ "continueFindTitle": "Suche fortsetzen?", "searchItemNotFoundContinueFromTop": "Der Suchbegriff wurde nicht gefunden.\nMöchten Sie vom Anfang des Dokuments aus erneut suchen?", "searchItemNotFoundContinueFromBottom": "Der Suchbegriff wurde nicht gefunden.\nMöchten Sie vom Ende des Dokuments aus erneut suchen?", + "continueReplaceTitle": "Ersetzen fortsetzen?", + "replaceNotFoundContinueFromTop": "Der Suchbegriff wurde nicht gefunden.\nMöchten Sie vom Anfang des Dokuments Suchen und Ersetzen fortsetzen?", + "replacedXContinueFromTop": "Der Suchbegriff wurde {0}-mal ersetzt.\nMöchten Sie vom Anfang des Dokuments Suchen und Ersetzen fortsetzen?", "conversionCancelledByUser": "Konvertierung vom Benutzer abgebrochen", "convert": "Umwandeln", "converted": "Umgewandelt", @@ -927,7 +930,7 @@ "showHistory": "Verlauf für Rückgängig anzeigen", "find": "Suchen...", "findNext": "Weitersuchen", - "replace": "Ersetzen", + "replace": "Ersetzen...", "multipleReplace": "Mehrfach ersetzen...", "fillSelectedLinesWithClipboard": "Ausgewählte Zeilen mit Text aus Zwischenablage füllen", "goToLineNumber": "Gehe zu Untertitel Nummer...", @@ -4164,7 +4167,7 @@ "unableToRemoveItem": "Element konnte nicht entfernt werden!", "regexRulesDotDotDot": "Regex-Regeln...", "regexRulesTitle": "OCR-Regex-Regeln – {0}", - "regexRulesDescription": "Reguläre Ausdrücke, die von \"Häufige OCR-Fehler korrigieren\" in \"Häufige Fehler korrigieren\" verwendet werden. Deaktivieren Sie eine Regel, um sie zu stoppen, oder fügen Sie eigene hinzu.", + "regexRulesDescription": "Reguläre Ausdrücke, die von \"Häufige OCR-Fehler korrigieren\" in \"Häufige Fehler korrigieren\" verwendet werden.\nDeaktivieren Sie eine Regel, um sie zu stoppen, oder fügen Sie eigene hinzu.", "regexRules": "Regeln", "showAllRules": "Alle Regeln", "showEnabledRules": "Aktiviert", From 64dd8b1c7452cea048e8c6d81caa2e2ca2ba019b Mon Sep 17 00:00:00 2001 From: 12si27 <88251502+12si27@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:58:24 +0900 Subject: [PATCH 08/29] Update Korean.json --- src/ui/Assets/Languages/Korean.json | 57 ++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/ui/Assets/Languages/Korean.json b/src/ui/Assets/Languages/Korean.json index eefbb10ba0a..ee7a69ecf5d 100644 --- a/src/ui/Assets/Languages/Korean.json +++ b/src/ui/Assets/Languages/Korean.json @@ -1,6 +1,6 @@ { "title": "Subtitle Edit", - "version": "v5.4.0-beta9", + "version": "v5.4.0-beta10", "translatedBy": "Hack茶ん, 12시27분(@12si27)", "cultureName": "ko-KR", "general": { @@ -152,6 +152,9 @@ "continueFindTitle": "계속 검색하시겠습니까?", "searchItemNotFoundContinueFromTop": "검색항목을 찾을 수 없습니다.\n자막의 처음부터 다시 검색하시겠습니까?", "searchItemNotFoundContinueFromBottom": "검색항목을 찾을 수 없습니다.\n자막의 끝부터 다시 검색하시겠습니까?", + "continueReplaceTitle": "계속 바꾸시겠습니까?", + "replaceNotFoundContinueFromTop": "검색 항목을 찾을 수 없습니다.\n자막의 처음부터 계속 검색하고 바꾸시겠습니까?", + "replacedXContinueFromTop": "검색 항목을 {0}개 바꿨습니다.\n자막의 처음부터 계속 검색하고 바꾸시겠습니까?", "conversionCancelledByUser": "사용자가 변환을 취소했습니다.", "convert": "변환", "converted": "변환됨", @@ -1068,6 +1071,7 @@ "assaDrawHint": "Advanced SubStation Alpha 도형 그리기", "ssaStylesHint": "Sub Station Alpha 스타일", "webVttStylesHint": "WebVTT 스타일 관리자", + "yttStylesHint": "YouTube 자막(YTT) 스타일 관리자", "ssaPropertiesHint": "Sub Station Alpha 속성", "ssaAttachmentsHint": "Sub Station Alpha 첨부 파일", "getFrameRateFromVideoFileHint": "비디오 파일에서 프레임 속도 가져오기" @@ -1632,6 +1636,57 @@ "mergeLines": "자막을 불러올 때 동일한 텍스트를 가진 줄 병합", "mergeStyleTags": "스타일 태그 병합" }, + "ytt": { + "stylesTitleX": "YouTube 자막(YTT) 스타일 - {0}", + "pens": "펜", + "penX": "펜 {0}", + "windowPositions": "위치", + "windowPosition": "위치", + "windowPositionX": "위치 {0}", + "windowStyles": "창 스타일", + "windowStyle": "창 스타일", + "windowStyleX": "창 스타일 {0}", + "default": "플레이어 기본값", + "textColor": "텍스트 색상", + "backgroundColor": "배경", + "opacity": "불투명도", + "edge": "윤곽 효과", + "edgeNone": "없음", + "edgeHardShadow": "선명한 그림자", + "edgeBevel": "경사 효과", + "edgeGlow": "광선/윤곽선", + "edgeSoftShadow": "부드러운 그림자", + "fontStyle": "글꼴 스타일", + "size": "크기", + "realSizeX": "YouTube에서 {0}% 크기로 표시", + "offset": "오프셋", + "subscript": "아래 첨자", + "normal": "보통", + "superscript": "위 첨자", + "anchorPoint": "기준점", + "horizontal": "가로 (%)", + "vertical": "세로 (%)", + "realPositionXY": "YouTube에서 {0:0.#}% / {1:0.#}% 위치에 표시", + "justify": "정렬", + "left": "왼쪽", + "center": "가운데", + "right": "오른쪽", + "textDirection": "텍스트 방향", + "leftToRight": "왼쪽에서 오른쪽", + "rightToLeft": "오른쪽에서 왼쪽", + "verticalUpright": "세로 (글자 정방향)", + "verticalRotated": "세로 (글자 회전)", + "columnsRightToLeft": "열 순서 반대로", + "windowOpacity": "창 상자 불투명도", + "linePen": "줄 펜", + "setPenForSelection": "선택 영역에 펜 적용", + "setPosition": "위치 설정", + "insertKaraokeTime": "현재 비디오 위치에서 노래방 효과 시작", + "insertKaraokeTimeHint": "선택 영역을 현재 비디오 위치에서 표시되는 s 태그로 감쌉니다", + "removePenTags": "펜 태그 제거", + "stylesDotDotDot": "YouTube 자막(YTT) 스타일...", + "youTube": "YouTube" + }, "compare": "비교", "previousDifference": "이전 차이", "nextDifference": "다음 차이", From 4ea119d7c7241d889786ff62c206e05f9fc17e2b Mon Sep 17 00:00:00 2001 From: niksedk Date: Thu, 29 Oct 2026 18:12:17 +0100 Subject: [PATCH 09/29] Keep read-only original blocks still in the timeline while retiming The timeline's original row drew each row's effective original time codes, which follow the translation while it is retimed (lock on, or a line that shares its row's times). A read-only original never moves, so draw its lines' own time codes instead - the same fix #16465 made for the waveform. Co-Authored-By: Claude Opus 5.5 --- .../AudioVisualizerControl/TimelineTracks.cs | 17 +++++++++-- src/ui/Features/Main/Layout/InitWaveform.cs | 1 + .../Main/MainViewModel.OriginalTimeCodes.cs | 28 +++++++++++++++++ .../Main/MainOriginalTimeCodesTests.cs | 30 +++++++++++++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/ui/Controls/AudioVisualizerControl/TimelineTracks.cs b/src/ui/Controls/AudioVisualizerControl/TimelineTracks.cs index 8ba9bb6bb61..578023caa01 100644 --- a/src/ui/Controls/AudioVisualizerControl/TimelineTracks.cs +++ b/src/ui/Controls/AudioVisualizerControl/TimelineTracks.cs @@ -117,6 +117,13 @@ private sealed record Track(string Key, string Label, bool IsOriginal); /// Whether a row stands for a line of the original, which gives it a block in the original row. public Func? HasOriginalLine { get; set; } + /// + /// The read-only original's own time codes by line id, or null when the original is editable. + /// A read-only original never moves, but the rows' original time codes follow the translation + /// while it is retimed. + /// + public Func?>? GetReadOnlyOriginalTimeCodes { get; set; } + /// Writes new time codes to the original line a row stands for. public Action? SetOriginalTimeCodes { get; set; } @@ -720,6 +727,7 @@ private void DrawSubtitles(DrawingContext context, AudioVisualizer source, doubl return; } + var readOnlyTimeCodes = GetReadOnlyOriginalTimeCodes?.Invoke(); foreach (var row in rows) { if (!RowHasOriginalLine(row)) @@ -727,8 +735,13 @@ private void DrawSubtitles(DrawingContext context, AudioVisualizer source, doubl continue; } - var x1 = (row.EffectiveOriginalStartTime.TotalSeconds - startSeconds) * pixelsPerSecond; - var x2 = (row.EffectiveOriginalEndTime.TotalSeconds - startSeconds) * pixelsPerSecond; + var (start, end) = readOnlyTimeCodes != null && + row.ReferenceParagraphId is { } id && + readOnlyTimeCodes.TryGetValue(id, out var timeCodes) + ? timeCodes + : (row.EffectiveOriginalStartTime, row.EffectiveOriginalEndTime); + var x1 = (start.TotalSeconds - startSeconds) * pixelsPerSecond; + var x2 = (end.TotalSeconds - startSeconds) * pixelsPerSecond; if (!(x2 < 0 || x1 > width)) { DrawBlock(context, row, GetTrackRect(originalTrack, width), x1, x2, _selectedIds.Contains(row.Id), true); diff --git a/src/ui/Features/Main/Layout/InitWaveform.cs b/src/ui/Features/Main/Layout/InitWaveform.cs index e12a3ae56f0..cec521c3ad1 100644 --- a/src/ui/Features/Main/Layout/InitWaveform.cs +++ b/src/ui/Features/Main/Layout/InitWaveform.cs @@ -437,6 +437,7 @@ void MakeWaveformContextMenu() GetHasOriginal = () => vm.ShowColumnOriginalText, GetCanEditOriginalTimeCodes = () => vm.CanEditOriginalTimeCodes && !vm.IsOriginalLockedToTranslation, HasOriginalLine = vm.HasOriginalLine, + GetReadOnlyOriginalTimeCodes = vm.GetReadOnlyOriginalTimeCodes, SetOriginalTimeCodes = vm.SetRowOriginalTimeCodes, SelectParagraph = vm.SelectAndScrollToSubtitle, TextLabel = Se.Language.General.Text, diff --git a/src/ui/Features/Main/MainViewModel.OriginalTimeCodes.cs b/src/ui/Features/Main/MainViewModel.OriginalTimeCodes.cs index 73762eb5b71..b858b579280 100644 --- a/src/ui/Features/Main/MainViewModel.OriginalTimeCodes.cs +++ b/src/ui/Features/Main/MainViewModel.OriginalTimeCodes.cs @@ -91,6 +91,34 @@ row.ReferenceParagraphId is not { } id || } } + private readonly Dictionary _readOnlyOriginalTimeCodes = new(); + + /// + /// The read-only original's own time codes by line id, or null when the original is editable. + /// The rows' original time codes follow the translation as it is retimed (with "Lock original + /// to translation" on, or when a line shares its row's times), but a read-only original never + /// moves. Rebuilt into one reused dictionary on each call: the original's lines can be replaced, + /// and one pass over them is cheap. + /// + internal IReadOnlyDictionary? GetReadOnlyOriginalTimeCodes() + { + if (!IsOriginalReadOnly || _subtitleOriginal == null) + { + return null; + } + + _readOnlyOriginalTimeCodes.Clear(); + foreach (var p in _subtitleOriginal.Paragraphs) + { + if (p.Id is { } id) + { + _readOnlyOriginalTimeCodes[id] = (TimeSpan.FromMilliseconds(p.StartTime.TotalMilliseconds), TimeSpan.FromMilliseconds(p.EndTime.TotalMilliseconds)); + } + } + + return _readOnlyOriginalTimeCodes; + } + private void ClearOriginalTimeCodes() { foreach (var row in Subtitles) diff --git a/tests/UI/Features/Main/MainOriginalTimeCodesTests.cs b/tests/UI/Features/Main/MainOriginalTimeCodesTests.cs index cd16eeb62d4..3e7de168d8b 100644 --- a/tests/UI/Features/Main/MainOriginalTimeCodesTests.cs +++ b/tests/UI/Features/Main/MainOriginalTimeCodesTests.cs @@ -159,6 +159,36 @@ public void DraggingAReferenceOnlyLine_RetimesItsOwnRow() } } + [AvaloniaFact] + public void ReadOnly_RetimingTheTranslation_LeavesTheOriginalsTimeCodesForDrawing() + { + using var _ = new SettingsScope("General.LockOriginalToTranslation"); + Se.Settings.General.LockOriginalToTranslation = true; + var (window, vm) = CreateMainViewModel(); + try + { + AddLine(vm, "Translated one", 0, 2000); + AddLine(vm, "Translated two", 4000, 6000); + var original = Original(("Original one", 100, 2100), ("Original two", 4000, 6000)); + ImportOriginal(vm, original, isReadOnly: true); + + vm.Subtitles[0].SetStartTimeKeepDuration(TimeSpan.FromMilliseconds(1000)); + vm.Subtitles[1].SetStartTimeKeepDuration(TimeSpan.FromMilliseconds(5000)); + + var timeCodes = vm.GetReadOnlyOriginalTimeCodes(); + Assert.NotNull(timeCodes); + Assert.Equal((TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(2100)), timeCodes[original.Paragraphs[0].Id!.Value]); + Assert.Equal((TimeSpan.FromMilliseconds(4000), TimeSpan.FromMilliseconds(6000)), timeCodes[original.Paragraphs[1].Id!.Value]); + + vm.IsOriginalReadOnly = false; + Assert.Null(vm.GetReadOnlyOriginalTimeCodes()); + } + finally + { + CloseWindow(window, vm); + } + } + [AvaloniaFact] public void Toggle_FlipsTheLock() { From f046b2f90c20389d3c570a5bec2c01852952c77e Mon Sep 17 00:00:00 2001 From: niksedk Date: Thu, 29 Oct 2026 18:34:07 +0100 Subject: [PATCH 10/29] Fix uneven 500 ms / custom video steps on quick presses (#16469) While a relative step's seek is still in flight, the player reports the old position, exactly one step behind the tracked target. The flat 0.5 s resync guard then dropped the previous step: at the limit for 500 ms steps (float rounding) and always for longer steps. Widen the guard to 0.5 s + step size; every other position change already clears the tracker. Co-Authored-By: Claude Opus 5.5 --- src/ui/Features/Main/MainViewModel.cs | 17 +++++++--- tests/UI/Logic/RelativeSeekResyncTests.cs | 39 +++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 tests/UI/Logic/RelativeSeekResyncTests.cs diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 4b46ac55cde..0aa22ddc4a4 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -24636,6 +24636,16 @@ private void RevealRowPosted(SubtitleLineViewModel row) // (#12027). null means "resync to the player on the next move". private double? _relativeSeekTargetSeconds; + /// + /// How far the player may lag the tracked target before a relative step resyncs to it. + /// While the previous step's seek is still in flight (slow exact seeks in long-GOP video), + /// the player still reports the old position, one full step behind the target. A flat 0.5 s + /// guard dropped that step for 500 ms steps (float rounding at the limit) and for every + /// longer step, so quick presses came out uneven (#16469). Every other position change + /// clears the tracker, so the wider guard only applies to chained relative steps. + /// + internal static double RelativeSeekResyncSeconds(int ms) => 0.5 + Math.Abs(ms) / 1000.0; + private void MoveVideoPositionMs(int ms, bool pause = false) { var vp = GetVideoPlayerControl(); @@ -24657,12 +24667,11 @@ private void MoveVideoPositionMs(int ms, bool pause = false) var actual = vp.Position; // Keep accumulating from the tracked target only while it still matches where - // the player actually is (within half a second) and playback is paused. If the - // user played, clicked the waveform, or jumped to a cue, the player diverges and - // we resync to its real position. + // the player actually is and playback is paused. If the user played, clicked the + // waveform, or jumped to a cue, the player diverges and we resync to its real position. var baseSeconds = !wasPlaying && _relativeSeekTargetSeconds is double tracked - && Math.Abs(tracked - actual) < 0.5 + && Math.Abs(tracked - actual) < RelativeSeekResyncSeconds(ms) ? tracked : actual; diff --git a/tests/UI/Logic/RelativeSeekResyncTests.cs b/tests/UI/Logic/RelativeSeekResyncTests.cs new file mode 100644 index 00000000000..365ea39e2de --- /dev/null +++ b/tests/UI/Logic/RelativeSeekResyncTests.cs @@ -0,0 +1,39 @@ +using Nikse.SubtitleEdit.Features.Main; + +namespace UITests.Logic; + +public class RelativeSeekResyncTests +{ + [Theory] + [InlineData(500)] + [InlineData(-500)] + [InlineData(1000)] + [InlineData(2000)] + [InlineData(-5000)] + public void InFlightSeek_OneStepBehind_KeepsTrackedTarget(int ms) + { + // The player still reports the previous position: exactly one step behind the target (#16469). + var lag = Math.Abs(ms) / 1000.0; + Assert.True(lag < MainViewModel.RelativeSeekResyncSeconds(ms)); + } + + [Theory] + [InlineData(10)] + [InlineData(40)] + [InlineData(100)] + public void SmallSteps_KeepAboutHalfSecondGuard(int ms) + { + var resync = MainViewModel.RelativeSeekResyncSeconds(ms); + Assert.True(resync > 0.5); + Assert.True(resync <= 0.6); + } + + [Fact] + public void FloatRoundedHalfSecondLag_StillContinues() + { + // 12.345 + 0.5 - 12.345 is not exactly 0.5 in doubles; the old "< 0.5" guard flipped on this. + var tracked = 12.345 + 0.5; + var actual = 12.345; + Assert.True(Math.Abs(tracked - actual) < MainViewModel.RelativeSeekResyncSeconds(500)); + } +} From fed4ab3d334de870e55bd409b040f8a6a8da636a Mon Sep 17 00:00:00 2001 From: niksedk Date: Thu, 29 Oct 2026 19:15:45 +0100 Subject: [PATCH 11/29] Word lists: move focus with type-ahead so arrow keys continue from the match (#16471) Co-Authored-By: Claude Opus 5.5 --- .../Options/WordLists/WordListsWindow.cs | 14 +++++ .../WordLists/WordListsTypeAheadTests.cs | 58 ++++++++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/ui/Features/Options/WordLists/WordListsWindow.cs b/src/ui/Features/Options/WordLists/WordListsWindow.cs index 6fd87c48805..a461706eac3 100644 --- a/src/ui/Features/Options/WordLists/WordListsWindow.cs +++ b/src/ui/Features/Options/WordLists/WordListsWindow.cs @@ -163,6 +163,20 @@ private static ListBox MakeListBox(string accessibleName, string itemsPath, stri IsTextSearchEnabled = true, // type a letter (or a few quickly) to jump to the first matching entry (#16078) }; + // Text search only moves the selection - focus stays on the row clicked before, so the arrow keys + // went on from there, or out of the list when that row had scrolled away (#16471). Move focus too. + listBox.AddHandler(InputElement.TextInputEvent, (_, _) => + { + var index = listBox.SelectedIndex; + if (index < 0) + { + return; + } + + listBox.ScrollIntoView(index); + listBox.ContainerFromIndex(index)?.Focus(NavigationMethod.Directional); + }, handledEventsToo: true); + // Right-click selects the item under the pointer, so Remove acts on the clicked entry. var flyout = new MenuFlyout(); flyout.Items.Add(new MenuItem { Header = Se.Language.General.Remove, Command = removeCommand }); diff --git a/tests/UI/Features/Options/WordLists/WordListsTypeAheadTests.cs b/tests/UI/Features/Options/WordLists/WordListsTypeAheadTests.cs index e4afdc9acc3..82c458c1557 100644 --- a/tests/UI/Features/Options/WordLists/WordListsTypeAheadTests.cs +++ b/tests/UI/Features/Options/WordLists/WordListsTypeAheadTests.cs @@ -1,8 +1,11 @@ -using Avalonia.Controls; +using Avalonia; +using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Headless.XUnit; +using Avalonia.Input; using Avalonia.LogicalTree; using Avalonia.Threading; +using Avalonia.VisualTree; using Nikse.SubtitleEdit.Features.Options.WordLists; using Nikse.SubtitleEdit.Logic.Config; using Nikse.SubtitleEdit.Logic.Media; @@ -129,4 +132,57 @@ public void UserWordsRemovedWordsAndOcrFixes_JumpToFirstMatch() window.Close(); } } + + // Arrow keys after a jump move on from the matched entry, not from the entry clicked before the jump (#16471). + [AvaloniaFact] + public void Names_ArrowAfterJump_MovesFromMatchedEntry() + { + var (window, vm, listBoxes) = ShowWindow(); + try + { + FocusItem(listBoxes[0]); + window.KeyTextInput("m"); + Dispatcher.UIThread.RunJobs(); + window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal("Mette", vm.SelectedName); + } + finally + { + window.Close(); + } + } + + // Jumping to an entry far outside the visible rows keeps keyboard focus in the list, so the arrow + // keys walk the list instead of moving focus to the language combo box (#16471). + [AvaloniaFact] + public void Names_ArrowAfterJumpOutOfView_StaysInList() + { + var (window, vm, listBoxes) = ShowWindow(); + try + { + var names = Enumerable.Range(0, 300).Select(i => "a" + i.ToString("D3")).ToList(); + names.AddRange(["c000", "c001", "c002"]); + vm.Names = new ObservableCollection(names); + Dispatcher.UIThread.RunJobs(); + + FocusItem(listBoxes[0]); + window.KeyTextInput("c"); + Dispatcher.UIThread.RunJobs(); + Assert.Equal("c000", vm.SelectedName); + + window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal("c001", vm.SelectedName); + var focused = window.FocusManager?.GetFocusedElement() as Visual; + Assert.NotNull(focused); + Assert.True(focused == listBoxes[0] || listBoxes[0].IsVisualAncestorOf(focused)); + } + finally + { + window.Close(); + } + } } From f52e6029f7848b7981004c729713bcae79465929 Mon Sep 17 00:00:00 2001 From: niksedk Date: Thu, 29 Oct 2026 19:43:44 +0100 Subject: [PATCH 12/29] Spell check: Undo of "Add to names list" shows the word again (#16472) AddName indexed every name - plain ones too - in the dash/period part lookup, and RemoveName never took it out, so HasNameExtended kept the word as a name and Undo skipped straight past it. Only names with '.' or '-' are indexed now (as at load), and RemoveName drops them. NameList.Remove also blacklisted every removed name, and Add refused blacklisted names, so a name added again after Undo was never saved. Only shipped names are blacklisted now, and an explicit Add clears the blacklist entry. Co-Authored-By: Claude Opus 5.5 --- src/libse/Dictionaries/NameList.cs | 59 ++++++++++--- .../SpellCheck/SpellCheckWordLists.cs | 35 +++++++- .../HyphenatedNameSpellCheckTests.cs | 43 ++++++++++ .../libse/Dictionaries/NameListRemoveTest.cs | 84 +++++++++++++++++++ 4 files changed, 209 insertions(+), 12 deletions(-) create mode 100644 tests/libse/Dictionaries/NameListRemoveTest.cs diff --git a/src/libse/Dictionaries/NameList.cs b/src/libse/Dictionaries/NameList.cs index 7440c3b0c76..10ab3944731 100644 --- a/src/libse/Dictionaries/NameList.cs +++ b/src/libse/Dictionaries/NameList.cs @@ -15,6 +15,10 @@ public class NameList private readonly HashSet _namesMultiListUppercase; private readonly HashSet _blackList; + // Names from the shipped lists (not the user file) - only these need a blacklist entry to + // stay removed; a name that only lives in the user file is gone once its node is deleted. + private readonly HashSet _shippedNames; + // Lazy reverse index for IsInNamesMultiWordList, see GetMultiNamePartIndex. private Dictionary> _namesMultiListParts; public string LanguageName { get; private set; } @@ -28,10 +32,11 @@ public NameList(string dictionaryFolder, string languageName, bool useOnlineName _namesMultiList = new HashSet(); _namesMultiListUppercase = new HashSet(); _blackList = new HashSet(); + _shippedNames = new HashSet(); - LoadNamesList(GetLocalNamesUserFileName()); // e.g: en_names_user.xml (culture sensitive) - LoadNamesList(GetLocalNamesFileName()); // e.g: en_names.xml (culture sensitive) - LoadNamesList(GetLocalCultureNamesFileName()); // e.g: pt_BR_names.xml (region specific) + LoadNamesList(GetLocalNamesUserFileName(), null); // e.g: en_names_user.xml (culture sensitive) + LoadNamesList(GetLocalNamesFileName(), _shippedNames); // e.g: en_names.xml (culture sensitive) + LoadNamesList(GetLocalCultureNamesFileName(), _shippedNames); // e.g: pt_BR_names.xml (region specific) if (useOnlineNameList && !string.IsNullOrEmpty(namesUrl)) { try @@ -45,7 +50,7 @@ public NameList(string dictionaryFolder, string languageName, bool useOnlineName } else { - LoadNamesList(Path.Combine(_dictionaryFolder, "names.xml")); + LoadNamesList(Path.Combine(_dictionaryFolder, "names.xml"), _shippedNames); } foreach (var name in _blackList) @@ -121,7 +126,7 @@ private string GetLocalCultureNamesFileName() return Path.Combine(_dictionaryFolder, LanguageName + "_names.xml"); } - private void LoadNamesList(string fileNameOrUrl) + private void LoadNamesList(string fileNameOrUrl, HashSet loadedNames) { if (string.IsNullOrEmpty(fileNameOrUrl) || !File.Exists(fileNameOrUrl) && @@ -145,6 +150,7 @@ private void LoadNamesList(string fileNameOrUrl) var name = reader.ReadElementContentAsString().Trim(); if (name.Length > 0) { + loadedNames?.Add(name); if (name.Contains(' ')) { _namesMultiList.Add(name); @@ -197,12 +203,18 @@ public bool Remove(string name) var fileName = GetLocalNamesUserFileName(); var nameListXml = CreateDocument(fileName); - // Add removed name to blacklist - var nameNode = nameListXml.CreateElement("name"); - nameNode.InnerText = name; if (nameListXml.DocumentElement != null) { - nameListXml.DocumentElement.SelectSingleNode("blacklist")?.AppendChild(nameNode); + // Blacklist only a shipped name: one the user added (e.g. spell-check "Add to + // names list" followed by Undo) just loses its node - a blacklist entry would + // make adding it again later silently fail to save. (#16472) + if (_shippedNames.Contains(name)) + { + var nameNode = nameListXml.CreateElement("name"); + nameNode.InnerText = name; + nameListXml.DocumentElement.SelectSingleNode("blacklist")?.AppendChild(nameNode); + } + var nodeToRemove = default(XmlNode); // Remove remove-name from name-list @@ -242,23 +254,48 @@ public bool Remove(string name) public bool Add(string name) { name = name.RemoveControlCharacters().Trim(); - if (name.Length == 0 || _blackList.Contains(name) || !name.ContainsLetter()) + if (name.Length == 0 || !name.ContainsLetter()) { return false; } + // An explicit add overrides an earlier removal: blacklisted names are left out of the + // lists at load, so TryAdd succeeds and the blacklist entry is dropped below. Refusing + // here left the name in the caller's session but never saved it. (#16472) + var wasBlacklisted = _blackList.Remove(name); if (!TryAdd(name)) { return false; } - // _names.xml, e.g "en_names.xml" + // _names_user.xml, e.g "en_names_user.xml" var fileName = GetLocalNamesUserFileName(); var nameListXml = CreateDocument(fileName); var de = nameListXml.DocumentElement; if (de != null) { + if (wasBlacklisted) + { + var blacklistNodes = de.SelectNodes("blacklist/name"); + var nodesToRemove = new List(); + if (blacklistNodes != null) + { + foreach (XmlNode blacklistNode in blacklistNodes) + { + if (blacklistNode.InnerText.Trim().Equals(name, StringComparison.Ordinal)) + { + nodesToRemove.Add(blacklistNode); + } + } + } + + foreach (var blacklistNode in nodesToRemove) + { + blacklistNode.ParentNode?.RemoveChild(blacklistNode); + } + } + var node = nameListXml.CreateElement("name"); node.InnerText = name; de.AppendChild(node); diff --git a/src/libuilogic/SpellCheck/SpellCheckWordLists.cs b/src/libuilogic/SpellCheck/SpellCheckWordLists.cs index 001a2d77392..46028b1c5af 100644 --- a/src/libuilogic/SpellCheck/SpellCheckWordLists.cs +++ b/src/libuilogic/SpellCheck/SpellCheckWordLists.cs @@ -161,6 +161,26 @@ private void AddWordWithDashesOrPeriods(string word) } } + private void RemoveWordWithDashesOrPeriods(string word) + { + if (!_wordsWithDashesOrPeriods.Remove(word)) + { + return; + } + + foreach (var part in word.Split(PeriodAndDash, StringSplitOptions.RemoveEmptyEntries)) + { + if (_wordsWithDashesOrPeriodsByPart.TryGetValue(part, out var words)) + { + words.Remove(word); + if (words.Count == 0) + { + _wordsWithDashesOrPeriodsByPart.Remove(part); + } + } + } + } + private IEnumerable GetSingleUnifiedCollection(IEnumerable namesMultiWordList) { return namesMultiWordList @@ -277,6 +297,15 @@ public void RemoveName(string word) { _namesListWithApostrophe.Remove(word + "'"); } + + // AddName used to index every name for the dash/period part lookup - a plain name like + // "Boris" as its own single part - and nothing took it out again, so HasNameExtended stayed + // true and spell-check Undo of "Add to names list" never showed the word again. (#16472) + // The lookup is shared with the user dictionary, so keep a word that is still in there. + if (!_userWordList.Contains(word) && !_userPhraseList.Contains(word)) + { + RemoveWordWithDashesOrPeriods(word); + } } /// @@ -369,7 +398,11 @@ public bool AddName(string word) _namesListWithApostrophe.Add(word + "'"); } - AddWordWithDashesOrPeriods(word); + // Same rule as the start-up load: only names with '.' or '-' get split into parts. + if (word.Contains(PeriodAndDash)) + { + AddWordWithDashesOrPeriods(word); + } var namesList = new NameList(_dictionaryFolder, _languageName, false, string.Empty); namesList.Add(word); diff --git a/tests/UI/Features/SpellCheck/HyphenatedNameSpellCheckTests.cs b/tests/UI/Features/SpellCheck/HyphenatedNameSpellCheckTests.cs index 48df6f6ce03..19645733f35 100644 --- a/tests/UI/Features/SpellCheck/HyphenatedNameSpellCheckTests.cs +++ b/tests/UI/Features/SpellCheck/HyphenatedNameSpellCheckTests.cs @@ -47,6 +47,49 @@ public void DashNamePart_NotMatched_WhenCombinedNameAbsentFromLine() Assert.False(wordLists.HasNameExtended("Soo", "I met Soo today.")); } + // #16472: AddName indexed plain names for the dash/period part lookup and RemoveName never took + // them out, so after spell-check Undo of "Add to names list" the word still counted as a name. + [Fact] + public void RemoveName_PlainName_NoLongerRecognized() + { + var wordLists = new SpellCheckWordLists(LanguageName); + wordLists.AddName("Zorbulon"); + + const string line = "Zorbulon came home."; + Assert.True(wordLists.HasName("Zorbulon")); + + wordLists.RemoveName("Zorbulon"); + + Assert.False(wordLists.HasName("Zorbulon")); + Assert.False(wordLists.HasNameExtended("Zorbulon", line)); + } + + [Fact] + public void RemoveName_HyphenatedName_PartsNoLongerRecognized() + { + var wordLists = new SpellCheckWordLists(LanguageName); + wordLists.AddName("Soo-bin"); + wordLists.RemoveName("Soo-bin"); + + const string line = "I met Soo-bin today."; + Assert.False(wordLists.HasNameExtended("Soo", line)); + Assert.False(wordLists.HasNameExtended("bin", line)); + } + + // #16472: Undo wrote the removed name to the blacklist, so adding it again later was refused + // on disk and the name was lost on the next start. + [Fact] + public void AddName_AfterRemove_IsSavedAgain() + { + var wordLists = new SpellCheckWordLists(LanguageName); + wordLists.AddName("Zorbulon"); + wordLists.RemoveName("Zorbulon"); + wordLists.AddName("Zorbulon"); + + var reloaded = new SpellCheckWordLists(LanguageName); + Assert.True(reloaded.HasName("Zorbulon")); + } + public void Dispose() { Se.DictionariesFolder = _originalDictionariesFolder; diff --git a/tests/libse/Dictionaries/NameListRemoveTest.cs b/tests/libse/Dictionaries/NameListRemoveTest.cs new file mode 100644 index 00000000000..1ad8a366d82 --- /dev/null +++ b/tests/libse/Dictionaries/NameListRemoveTest.cs @@ -0,0 +1,84 @@ +using Nikse.SubtitleEdit.Core.Dictionaries; +using System.Xml.Linq; + +namespace LibSETests.Dictionaries; + +// #16472: removing a name always added it to the user file's blacklist, and Add refused any +// blacklisted name - so a name the user added, removed (spell-check Undo) and added again was +// never saved. Only shipped names need a blacklist entry to stay removed. +public class NameListRemoveTest : IDisposable +{ + private readonly string _folder; + + public NameListRemoveTest() + { + _folder = Path.Combine(Path.GetTempPath(), "SeNameListRemoveTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_folder); + File.WriteAllText( + Path.Combine(_folder, "en_names.xml"), + "Shipped"); + } + + public void Dispose() + { + try + { + Directory.Delete(_folder, recursive: true); + } + catch + { + // Best-effort cleanup. + } + } + + private NameList Load() => new NameList(_folder, "en_US", false, string.Empty); + + private string[] UserFileBlacklist() => XDocument.Load(Path.Combine(_folder, "en_names_user.xml")) + .Root!.Elements("blacklist").Elements("name").Select(e => e.Value).ToArray(); + + private string[] UserFileNames() => XDocument.Load(Path.Combine(_folder, "en_names_user.xml")) + .Root!.Elements("name").Select(e => e.Value).ToArray(); + + [Fact] + public void Remove_UserAddedName_IsNotBlacklisted() + { + Assert.True(Load().Add("Zorbulon")); + + Assert.True(Load().Remove("Zorbulon")); + + Assert.DoesNotContain("Zorbulon", UserFileBlacklist()); + Assert.DoesNotContain("Zorbulon", UserFileNames()); + Assert.DoesNotContain("Zorbulon", Load().GetNames()); + } + + [Fact] + public void Add_AfterRemove_IsSaved() + { + Load().Add("Zorbulon"); + Load().Remove("Zorbulon"); + + Assert.True(Load().Add("Zorbulon")); + + Assert.Contains("Zorbulon", Load().GetNames()); + } + + [Fact] + public void Remove_ShippedName_IsBlacklisted() + { + Assert.True(Load().Remove("Shipped")); + + Assert.Equal(new[] { "Shipped" }, UserFileBlacklist()); + Assert.DoesNotContain("Shipped", Load().GetNames()); + } + + [Fact] + public void Add_BlacklistedShippedName_ClearsBlacklistEntry() + { + Load().Remove("Shipped"); + + Assert.True(Load().Add("Shipped")); + + Assert.Empty(UserFileBlacklist()); + Assert.Contains("Shipped", Load().GetNames()); + } +} From f92171b5c3b6866f1892758ccd15f079e5cf8d01 Mon Sep 17 00:00:00 2001 From: TOKUZO Date: Fri, 30 Oct 2026 03:49:51 +0900 Subject: [PATCH 13/29] Japanese for 5.4.0 Beta11 Updated the Japanese translation for SE5.4.0 Beta10 --- src/ui/Assets/Languages/Japanese.json | 130 +++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 5 deletions(-) diff --git a/src/ui/Assets/Languages/Japanese.json b/src/ui/Assets/Languages/Japanese.json index 0d244e53695..7a70639d425 100644 --- a/src/ui/Assets/Languages/Japanese.json +++ b/src/ui/Assets/Languages/Japanese.json @@ -1,6 +1,6 @@ { "title": "Subtitle Edit", - "version": "v5.4.0-beta7", + "version": "v5.4.0-beta11", "translatedBy": "hisui3393", "cultureName": "jp-JP", "general": { @@ -152,6 +152,9 @@ "continueFindTitle": "検索を続行しますか?", "searchItemNotFoundContinueFromTop": "検索文字列は見つかりません。\n文書の先頭から再度検索しますか?", "searchItemNotFoundContinueFromBottom": "検索文字列は見つかりません。\n文書の末尾から再度検索しますか?", + "continueReplaceTitle": "置換を続けますか?", + "replaceNotFoundContinueFromTop": "検索項目が見つかりませんでした。\n文書の先頭から検索と置換を続けますか?", + "replacedXContinueFromTop": "検索項目を {0} 回置換しました。\n文書の先頭から検索と置換を続けますか?", "conversionCancelledByUser": "ユーザーによって変換がキャンセルされました", "convert": "変換", "converted": "変換済み", @@ -899,7 +902,8 @@ "detail": "詳細", "fixOcrErrors": "OCRエラーを修正", "runningOcrXY": "OCR処理中... {0}/{1}", - "llamaCppDownloadEnginePrompt": "llama.cpp を使用するには、llama-server エンジンのダウンロードが必要です。今すぐダウンロードしますか?" + "llamaCppDownloadEnginePrompt": "llama.cpp を使用するには、llama-server エンジンのダウンロードが必要です。今すぐダウンロードしますか?", + "doYouWantToDeleteX": "\"{0}\" を削除しますか?" }, "main": { "menu": { @@ -1067,6 +1071,7 @@ "assaDrawHint": "ASS形式の図形描画", "ssaStylesHint": "SSA スタイル", "webVttStylesHint": "WebVTTスタイルマネージャー", + "yttStylesHint": "YouTube timed text のスタイル管理", "ssaPropertiesHint": "SSA プロパティ", "ssaAttachmentsHint": "SSA 添付ファイル", "getFrameRateFromVideoFileHint": "動画ファイルからフレームレートを取得" @@ -1631,6 +1636,57 @@ "mergeLines": "読み込み時に同じテキストの行を結合", "mergeStyleTags": "スタイルタグを結合" }, + "ytt": { + "stylesTitleX": "YouTube timed text のスタイル - {0}", + "pens": "ペン", + "penX": "ペン {0}", + "windowPositions": "位置", + "windowPosition": "位置", + "windowPositionX": "位置 {0}", + "windowStyles": "ウィンドウのスタイル", + "windowStyle": "ウィンドウのスタイル", + "windowStyleX": "ウィンドウのスタイル {0}", + "default": "プレーヤーの既定値", + "textColor": "文字色", + "backgroundColor": "背景色", + "opacity": "不透明度", + "edge": "縁取り", + "edgeNone": "なし", + "edgeHardShadow": "くっきりした影", + "edgeBevel": "ベベル", + "edgeGlow": "光彩/輪郭線", + "edgeSoftShadow": "ぼかした影", + "fontStyle": "フォントスタイル", + "size": "サイズ", + "realSizeX": "YouTube では {0}% で表示", + "offset": "オフセット", + "subscript": "下付き", + "normal": "標準", + "superscript": "上付き", + "anchorPoint": "基準点", + "horizontal": "水平方向(%)", + "vertical": "垂直方向(%)", + "realPositionXY": "YouTube では {0:0.#}% / {1:0.#}% の位置に表示", + "justify": "配置", + "left": "左", + "center": "中央", + "right": "右", + "textDirection": "文字の方向", + "leftToRight": "左から右", + "rightToLeft": "右から左", + "verticalUpright": "縦書き(正立)", + "verticalRotated": "縦書き(回転)", + "columnsRightToLeft": "列の順序を逆にする", + "windowOpacity": "ウィンドウ背景の不透明度", + "linePen": "行のペン", + "setPenForSelection": "選択範囲にペンを設定", + "setPosition": "位置を設定", + "insertKaraokeTime": "動画の位置にカラオケ開始時刻を挿入", + "insertKaraokeTimeHint": "選択範囲を、動画の現在位置で表示される s タグで囲みます", + "removePenTags": "ペンタグを削除", + "stylesDotDotDot": "YouTube timed text のスタイル...", + "youTube": "YouTube" + }, "compare": "比較", "previousDifference": "前の差分", "nextDifference": "次の差分", @@ -2999,6 +3055,7 @@ "timeRanges": "時間範囲", "timeRangesWatermark": "動画全体", "timeRangesHint": "動画のスキャン範囲を指定します。例:\"16:00-32:00; 36:00-41:00\"。時刻は m:ss または h:mm:ss 形式です。開始時刻または終了時刻を省略すると、動画の先頭または末尾を指定します(\"36:00-\")。空欄の場合は動画全体をスキャンします。", + "keepExistingLines": "既存の字幕行を保持する", "insertPreviewTime": "プレビュー位置を範囲の開始/終了に挿入", "invalidTimeRangeX": "無効な時間範囲: \"{0}\"{1}{1}{2}", "textBrightnessMinimum": "文字の最小輝度(0=無効)", @@ -3212,6 +3269,9 @@ "cutVideoSegments": "セグメント(区間)", "cutVideoTransitions": "トランジション(場面転換)", "cutVideoCutType": "カットの種類", + "cutNoSegmentsAdded": "セグメントが追加されていません", + "cutNoSegmentsAddedDetails": "波形などから1つ以上のセグメントを追加してください", + "cutDiscardSegments": "セグメントを破棄してウィンドウを閉じますか? すべてのセグメントが失われます。", "importCurrentSubtitle": "現在の字幕をインポートします", "addRemoveEmbeddedSubtitlesTitle": "埋め込み字幕を追加/削除します", "addRemoveEmbeddedSubtitlesMp4Title": "埋め込み字幕の追加/削除 (MP4)", @@ -3314,6 +3374,7 @@ "toggleShotChange": "カット変更を切り替え", "resetWaveformZoomAndSpeed": "波形のズームと速度をリセット", "showOriginalSubtitle": "波形に別字幕を表示", + "lockOriginalToTranslation": "原文を翻訳に固定する", "timelineGroupTracksBy": "字幕トラックのグループ化", "timelineShowThumbnails": "動画サムネイルを表示", "showSpeechOnly": "音声のみを表示(生成に時間がかかります)", @@ -4161,7 +4222,52 @@ "addRemovedWord": "削除した単語を追加", "ocrFixList": "OCR修正リスト", "unableToAddItem": "項目を追加できません。おそらく既に存在します", - "unableToRemoveItem": "項目を削除できません" + "unableToRemoveItem": "項目を削除できません", + "regexRulesDotDotDot": "正規表現ルール...", + "regexRulesTitle": "OCR正規表現ルール - {0}", + "regexRulesDescription": "「よくあるエラーを修正」の「OCRエラーの修正」で使用する正規表現です。ルールを無効にすると適用されなくなります。独自のルールを追加することもできます。", + "regexRules": "ルール", + "showAllRules": "すべてのルール", + "showEnabledRules": "有効", + "showDisabledRules": "無効", + "showMyRules": "自分のルール", + "enableShown": "表示中のルールを有効化", + "disableShown": "表示中のルールを無効化", + "xOfYEnabled": "{1:#,##0} 件中 {0:#,##0} 件が有効", + "builtInRule": "組み込みルール", + "myRule": "自分のルール", + "rule": "ルール", + "pattern": "検索パターン(正規表現)", + "validPattern": "有効なパターン", + "invalidPatternX": "無効なパターン: {0}", + "addAsNewRule": "新しいルールとして追加", + "updateRule": "ルールを更新", + "deleteRule": "ルールを削除", + "newRule": "新しいルール", + "builtInRulesCanOnlyBeDisabled": "組み込みルールは無効化のみ可能です。パターンを変更し、独自のルールとして追加してください。", + "ruleAlreadyExists": "このパターンのルールはすでに存在します。", + "insert": "挿入", + "testText": "テスト", + "testTextPlaceholder": "ルールを試すテキストを入力または貼り付けてください...", + "matches": "一致箇所", + "result": "結果", + "applyAllEnabledRules": "有効なすべてのルールを実行", + "xMatches": "{0:#,##0} 件一致", + "oneMatch": "1 件一致", + "noMatches": "一致なし", + "xRulesChangedText": "{0:#,##0} 個のルールがテキストを変更", + "enableRule": "ルールを有効化", + "disableRule": "ルールを無効化", + "copyPattern": "パターンをコピー", + "startOfLine": "行頭(^)", + "endOfLine": "行末($)", + "wordCharacter": "単語文字(\\w)", + "optional": "任意(?)", + "either": "いずれか(|)", + "captureGroup": "キャプチャグループ((...))", + "straightQuote": "半角ダブルクォーテーション(\\x22)", + "captureGroupReference": "キャプチャグループ1の文字列($1)", + "wholeMatch": "一致した文字列全体($&)" }, "chooseLanguage": { "title": "UI言語を選択" @@ -4260,6 +4366,7 @@ "fillSelectedLinesWithClipboard": "選択行をクリップボードのテキストで埋める", "showImage": "画像を表示", "promptForUknownWords": "不明な単語の入力を促す", + "videoPreview": "動画プレビュー", "tryToGuessUnknownWords": "不明な単語を推測する", "autoBreakIfMoreThanXLines": "{0}行を超える場合は自動的に改行", "unknownWords": "不明な単語", @@ -4390,7 +4497,18 @@ "nOcrZoomIn": "拡大", "nOcrZoomOut": "縮小", "nOcrLineAlgorithm": "線の生成方法", - "nOcrLineAlgorithmHint": "自動描画で前景線と背景線を生成するために使用する方法" + "nOcrLineAlgorithmHint": "自動描画で前景線と背景線を生成するために使用する方法", + "nOcrSelectAction": "nOCRデータベース「{0}」に対する操作を選択", + "nOcrDeleteDatabase": "nOCRデータベースを削除しますか?", + "nOcrDeleteDatabaseDetails": "現在のnOCRデータベース「{0}」を削除しますか? 登録項目数:{1:#,###,##0}件", + "nOcrDeleteItem": "nOCR項目を削除しますか?", + "nOcrDeleteItemDetails": "現在のnOCR項目を削除しますか?", + "nOcrCharacterSaved": "nOCR文字を保存しました", + "generatedForegroundXBackgroundY": "生成済み:前景 {0}、背景 {1}", + "discardOcrResultQuestion": "OCR結果を破棄しますか?", + "discardOcrResultDetaulsQuestion": "OCRテキストが入力されている項目があります。ウィンドウを閉じてOCR結果を破棄しますか?", + "updateCrispEmbedQuestion": "CrispEmbedを更新しますか?", + "updateCrispEmbedDetailsQuestion": "新しいバージョンのCrispEmbedが利用可能です。\n\n今すぐダウンロードしますか?" }, "assa": { "assaDraw": "ass描画", @@ -4535,6 +4653,7 @@ "drawAnimationBlink": "点滅", "drawAnimationBounce": "バウンド", "drawAnimationShake": "揺れ", + "drawAnimationShakeHorizontal": "横方向に揺らす", "drawAnimationSwing": "振り子のように揺れる", "drawAnimationFlip": "反転", "drawAnimationLoopBlocked": "このループは開始・終了時の効果とは異なる点を中心に動くため、適用されません。", @@ -4929,7 +5048,8 @@ "advancedEffectRollingCaptionsDescription": "各字幕行が上にスライドして表示され、前の行を押し上げます。ライブチャットやテレプロンプターのような表示です。", "advancedEffectFillColor": "塗りつぶし色", "advancedEffectRise": "ベースラインから浮き上がる", - "overrideTagsHistory": "タグ履歴を上書き" + "overrideTagsHistory": "タグ履歴を上書き", + "deleteAttachmentQuestion": "添付ファイルを削除しますか?" }, "about": { "title": "About Subtitle Edit", From 94b5931829190cf6dc36f6091e3ce718a8f2e442 Mon Sep 17 00:00:00 2001 From: niksedk Date: Thu, 29 Oct 2026 20:14:26 +0100 Subject: [PATCH 14/29] ASSA styles: preview attached [Fonts] fonts ffmpeg's subtitles filter ignores the [Fonts] attachment section, and the styles preview did not carry the footer anyway, so a style using an attached font previewed in a fallback font. The libass screenshot now decodes attached fonts to a temp folder passed as fontsdir, and the styles preview passes the subtitle footer (re-rendering when attachments change). Co-Authored-By: Claude Opus 5.5 --- src/ui/Features/Assa/AssaStylesViewModel.cs | 5 ++- src/ui/Logic/Media/FfmpegGenerator.cs | 49 ++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/ui/Features/Assa/AssaStylesViewModel.cs b/src/ui/Features/Assa/AssaStylesViewModel.cs index 464778d7cd7..df2e83ab00a 100644 --- a/src/ui/Features/Assa/AssaStylesViewModel.cs +++ b/src/ui/Features/Assa/AssaStylesViewModel.cs @@ -1521,7 +1521,7 @@ private void UpdatePreview() // actually changed. Renders via libass so the preview matches playback/burn-in exactly. var ssaStyle = style.ToSsaStyle(); var previewText = EffectivePreviewText; - var signature = ssaStyle.ToRawAss() + "\n" + previewText; + var signature = ssaStyle.ToRawAss() + "\n" + previewText + "\n" + (_subtitle.Footer?.Length ?? 0); if (signature == _lastPreviewSignature) { return; @@ -1530,12 +1530,15 @@ private void UpdatePreview() _lastPreviewSignature = signature; var requestId = System.Threading.Interlocked.Increment(ref _previewRequestId); var header = _subtitle.Header; + var footer = _subtitle.Footer; Task.Run(() => { + // The footer carries the [Fonts] attachments, so a style using an attached font previews in it var previewSubtitle = new Subtitle { Header = string.IsNullOrEmpty(header) ? AdvancedSubStationAlpha.DefaultHeader : header, + Footer = footer, }; previewSubtitle.Header = AdvancedSubStationAlpha.GetHeaderAndStylesFromAdvancedSubStationAlpha( previewSubtitle.Header, new List { ssaStyle }); diff --git a/src/ui/Logic/Media/FfmpegGenerator.cs b/src/ui/Logic/Media/FfmpegGenerator.cs index 3ce8f07278b..26a77326bac 100644 --- a/src/ui/Logic/Media/FfmpegGenerator.cs +++ b/src/ui/Logic/Media/FfmpegGenerator.cs @@ -769,12 +769,17 @@ internal static string RemoveFadeTags(string text) height++; } + // ffmpeg's subtitles filter does not load fonts from the [Fonts] attachment section, so a + // style using an attached font previewed in a fallback font. Hand them to libass as a fontsdir. + var fontsDirName = WriteEmbeddedFontsToTempFolder(previewSubtitle.Footer); + var fontsDirArgument = fontsDirName == null ? string.Empty : $":fontsdir={Path.GetFileName(fontsDirName)}"; + var process = new Process { StartInfo = { FileName = GetFfmpegLocation(), - Arguments = $"-f lavfi -i \"color=c=black@0.0:s={width}x{height}:d=0.1,format=rgba,subtitles=f={Path.GetFileName(tempAssFileName)}:alpha=1\" -frames:v 1 -c:v png \"{outputFileName}\"", + Arguments = $"-f lavfi -i \"color=c=black@0.0:s={width}x{height}:d=0.1,format=rgba,subtitles=f={Path.GetFileName(tempAssFileName)}:alpha=1{fontsDirArgument}\" -frames:v 1 -c:v png \"{outputFileName}\"", UseShellExecute = false, CreateNoWindow = true, WorkingDirectory = Path.GetDirectoryName(tempAssFileName) ?? string.Empty @@ -799,9 +804,51 @@ internal static string RemoveFadeTags(string text) // Ignore cleanup errors } + if (fontsDirName != null) + { + try + { + Directory.Delete(fontsDirName, true); + } + catch + { + // Ignore cleanup errors + } + } + return File.Exists(outputFileName) ? outputFileName : null; } + /// + /// Decodes the [Fonts] attachments of an ASSA footer into a new folder in the temp folder. + /// Returns null when there are no attached fonts. + /// + private static string? WriteEmbeddedFontsToTempFolder(string? footer) + { + var fonts = AssaFontEmbedder.GetEmbeddedFonts(footer); + if (fonts.Count == 0) + { + return null; + } + + var folder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + try + { + Directory.CreateDirectory(folder); + foreach (var (fileName, bytes) in fonts) + { + File.WriteAllBytes(Path.Combine(folder, fileName), bytes); + } + + return folder; + } + catch (Exception exception) + { + Se.LogError(exception, "Could not write attached fonts for libass preview"); + return Directory.Exists(folder) ? folder : null; + } + } + private static string GetFfmpegLocation() { return FfmpegHelper.GetFfmpegLocation(); From d793c0d66b72b4212d579f70c4f40ba0e7d94e8a Mon Sep 17 00:00:00 2001 From: niksedk Date: Thu, 29 Oct 2026 20:24:48 +0100 Subject: [PATCH 15/29] SSA styles: preview attached [Fonts] fonts The SSA styles preview draws with Skia, which only finds installed fonts by name, so a style using an attached font previewed in a fallback font. The attachments are now decoded to Skia typefaces (cached until the footer changes) and the face matching the style's font name is passed to TextToImageGenerator via a new optional typeface parameter. Co-Authored-By: Claude Opus 5.5 --- src/ui/Features/Ssa/SsaStylesViewModel.cs | 76 +++++++++++++++++++++- src/ui/Logic/Media/TextToImageGenerator.cs | 14 +++- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/ui/Features/Ssa/SsaStylesViewModel.cs b/src/ui/Features/Ssa/SsaStylesViewModel.cs index 4d2d3e2db64..597be6a2dda 100644 --- a/src/ui/Features/Ssa/SsaStylesViewModel.cs +++ b/src/ui/Features/Ssa/SsaStylesViewModel.cs @@ -65,6 +65,13 @@ public partial class SsaStylesViewModel : ObservableObject, IClosingCleanup private volatile bool _isClosing; private readonly System.Timers.Timer _timerUpdatePreview; + // Typefaces decoded from the [Fonts] attachments, so a style using an attached font that is + // not installed previews in it. Rebuilt when the footer changes; the lock keeps the timer's + // render off them while they are rebuilt or disposed on close. + private readonly object _attachedTypefacesLock = new(); + private string? _attachedTypefacesFooter; + private readonly List<(string FontName, SKTypeface Typeface)> _attachedTypefaces = new(); + public SsaStylesViewModel(IFileHelper fileHelper, IWindowService windowService) { _fileHelper = fileHelper; @@ -845,6 +852,10 @@ public void OnClosingCleanup() { _isClosing = true; _timerUpdatePreview.StopAndDispose(TimerUpdatePreviewElapsed); + lock (_attachedTypefacesLock) + { + DisposeAttachedTypefaces(); + } } public void Initialize( @@ -986,6 +997,17 @@ internal static Color WithoutAlpha(Color c) } private void UpdatePreview() + { + lock (_attachedTypefacesLock) + { + if (!_isClosing) + { + UpdatePreviewLocked(); + } + } + } + + private void UpdatePreviewLocked() { var style = CurrentStyle; if (style == null) @@ -994,6 +1016,8 @@ private void UpdatePreview() return; } + var attachedTypeface = GetAttachedTypeface(style); + var text = "This is a test"; // Scale the rendered font size to the preview canvas height (~360px) the same way @@ -1020,7 +1044,8 @@ private void UpdatePreview() (float)style.ShadowWidth, isItalic: style.Italic, isUnderline: style.Underline, - isStrikeout: style.Strikeout); + isStrikeout: style.Strikeout, + typeface: attachedTypeface); if (style.ShadowWidth > 0) { @@ -1047,7 +1072,8 @@ private void UpdatePreview() (int)Math.Round(style.ShadowWidth), isItalic: style.Italic, isUnderline: style.Underline, - isStrikeout: style.Strikeout); + isStrikeout: style.Strikeout, + typeface: attachedTypeface); } else // FontBoxType.None { @@ -1064,7 +1090,8 @@ private void UpdatePreview() (float)style.ShadowWidth, isItalic: style.Italic, isUnderline: style.Underline, - isStrikeout: style.Strikeout); + isStrikeout: style.Strikeout, + typeface: attachedTypeface); } // A 500 ms timer re-renders this for as long as the dialog is open, and ToAvaloniaBitmap @@ -1074,6 +1101,49 @@ private void UpdatePreview() bitmap.Dispose(); } + /// + /// The attached font face matching the style's font name (preferring the style's bold/italic), + /// or null to use an installed font. Call with _attachedTypefacesLock held. + /// + private SKTypeface? GetAttachedTypeface(StyleDisplay style) + { + var footer = _subtitle.Footer; + if (!ReferenceEquals(footer, _attachedTypefacesFooter)) + { + DisposeAttachedTypefaces(); + _attachedTypefacesFooter = footer; + foreach (var (_, bytes) in AssaFontEmbedder.GetEmbeddedFonts(footer)) + { + var typeface = SKTypeface.FromData(SKData.CreateCopy(bytes)); + if (typeface != null) + { + _attachedTypefaces.Add((FontHelper.GetLibAssaFontName(typeface), typeface)); + } + } + } + + var matches = _attachedTypefaces + .Where(p => p.FontName.Equals(style.FontName, StringComparison.OrdinalIgnoreCase) || + p.Typeface.FamilyName.Equals(style.FontName, StringComparison.OrdinalIgnoreCase)) + .Select(p => p.Typeface) + .ToList(); + + return matches.FirstOrDefault(p => p.IsBold == style.Bold && p.IsItalic == style.Italic) ?? + matches.FirstOrDefault(p => p.IsBold == style.Bold) ?? + matches.FirstOrDefault(); + } + + private void DisposeAttachedTypefaces() + { + foreach (var (_, typeface) in _attachedTypefaces) + { + typeface.Dispose(); + } + + _attachedTypefaces.Clear(); + _attachedTypefacesFooter = null; + } + private static int GetPlayResY(string? header) { if (string.IsNullOrEmpty(header)) diff --git a/src/ui/Logic/Media/TextToImageGenerator.cs b/src/ui/Logic/Media/TextToImageGenerator.cs index 221cfcd88d2..ebb1a464804 100644 --- a/src/ui/Logic/Media/TextToImageGenerator.cs +++ b/src/ui/Logic/Media/TextToImageGenerator.cs @@ -109,18 +109,26 @@ public static SKBitmap GenerateImageWithPadding( int padding = 20, bool isItalic = false, bool isUnderline = false, - bool isStrikeout = false + bool isStrikeout = false, + SKTypeface? typeface = null // e.g. an attached font that is not installed; owned by the caller ) { outlineWidth *= 1.7f; // factor to match ASSA - using var typeface = SKTypeface.FromFamilyName(fontName, isBold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal, SKFontStyleWidth.Normal, isItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright); + using var installedTypeface = typeface == null + ? SKTypeface.FromFamilyName(fontName, isBold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal, SKFontStyleWidth.Normal, isItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright) + : null; using var paint = new SKPaint { IsAntialias = true }; - using var font = new SKFont(typeface, fontSize); + using var font = new SKFont(typeface ?? installedTypeface, fontSize); + if (typeface != null && isBold && !typeface.IsBold) + { + font.Embolden = true; // the given face has no bold weight, so fake it like libass does + } + if (isItalic) { font.SkewX = -0.25f; // synthetic slant so italic shows even when the font has no italic face From f1ba31d65638b6e56975b827b0a2dd72d9b73a1a Mon Sep 17 00:00:00 2001 From: niksedk Date: Thu, 29 Oct 2026 20:52:41 +0100 Subject: [PATCH 16/29] Format picker: treat the pick as a user format change Picking a format in the format picker (right-click the format combo, or the shortcut) went through SetSubtitleFormat, which marks the change as programmatic. That skipped the format conversion and left Save as on the default Save as format, so picking Sub Station Alpha suggested SubRip and .srt instead of Sub Station Alpha and .ssa. Co-Authored-By: Claude Opus 5.5 --- src/ui/Features/Main/MainViewModel.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 0aa22ddc4a4..ca4d1199d47 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -9964,9 +9964,12 @@ private async Task ShowSubtitleFormatPicker() if (viewModel.OkPressed) { var selectedFormat = viewModel.GetSelectedFormat(); - if (selectedFormat != null && selectedFormat != SelectedSubtitleFormat) + if (selectedFormat != null && selectedFormat.Name != SelectedSubtitleFormat.Name) { - SetSubtitleFormat(selectedFormat); + // A pick here is the user's format change, the same as one in the toolbar combo: + // not SetSubtitleFormat, which skipped the conversion and left "Save as" on the + // default "Save as" format (.srt) instead of the picked one (.ssa). + SelectedSubtitleFormat = SubtitleFormats.FirstOrDefault(p => p.Name == selectedFormat.Name) ?? selectedFormat; } } } From 4b4c3b5a59b274df3c602384d763da1fcc4dd283 Mon Sep 17 00:00:00 2001 From: niksedk Date: Thu, 29 Oct 2026 20:56:26 +0100 Subject: [PATCH 17/29] macOS: suggest the save name with the format's extension The macOS save panel appends the preferred extension of the format's file type when the suggested name has none, and .ssa shares a type with .ass (io.mpv.ass), so Save as Sub Station Alpha suggested movie.ass. Co-Authored-By: Claude Opus 5.5 --- src/ui/Logic/Media/FileHelper.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ui/Logic/Media/FileHelper.cs b/src/ui/Logic/Media/FileHelper.cs index 27c2d0dc166..108af0a339f 100644 --- a/src/ui/Logic/Media/FileHelper.cs +++ b/src/ui/Logic/Media/FileHelper.cs @@ -310,7 +310,10 @@ public async Task PickSaveSubtitleFile( var options = new FilePickerSaveOptions { Title = title, - SuggestedFileName = MakePortalSafeSuggestedFileName(suggestedFileName, currentFormat.Extension), + // See the macOS note in PickSaveSubtitleFileAs. + SuggestedFileName = OperatingSystem.IsMacOS() + ? AddMissingExtension(Path.GetFileName(suggestedFileName), currentFormat.Extension) + : MakePortalSafeSuggestedFileName(suggestedFileName, currentFormat.Extension), FileTypeChoices = MakeSaveFilePickerFileTypes(currentFormat), DefaultExtension = currentFormat.Extension.TrimStart('.') }; @@ -350,7 +353,10 @@ public async Task PickSaveSubtitleFile( // Pre-fill with the same extension rule the result goes through below // (AddMissingExtension), so under the document portal the granted name is // final-safe when the user keeps the suggestion (see NeedsPortalGrantedName). - SuggestedFileName = DocumentPortal.IsSandboxed + // macOS: without an extension in the name, the save panel appends the preferred + // extension of the format's file type, and ".ssa" shares a type with ".ass" (e.g. + // io.mpv.ass) - so Sub Station Alpha was suggested as "movie.ass". + SuggestedFileName = DocumentPortal.IsSandboxed || OperatingSystem.IsMacOS() ? AddMissingExtension(Path.GetFileName(suggestedFileName), currentFormat.Extension) : Path.GetFileName(suggestedFileName), FileTypeChoices = filePickerFileTypes, From c5db6f47aa5fc8c08a85ab8d3e234c02b6c617d7 Mon Sep 17 00:00:00 2001 From: niksedk Date: Fri, 30 Oct 2026 06:06:33 +0100 Subject: [PATCH 18/29] Second subtitle: add a font name picker The "Second subtitle file" dialog always used the video preview font. Add a Font name row (drop-down + "..." font picker). It is saved with "Remember these settings"; stored empty while it is the preview font so it keeps following that font (#13492). Co-Authored-By: Claude Opus 5.5 --- .../OpenSecondarySubtitleViewModel.cs | 49 +++++++++++++++++- .../OpenSecondarySubtitleWindow.cs | 15 ++++++ .../SecondarySubtitleStyler.cs | 30 +++++++++-- src/ui/Logic/Config/SeVideo.cs | 6 +++ .../SecondarySubtitleStylerFontNameTests.cs | 51 +++++++++++++++++++ 5 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 tests/UI/Features/Video/SecondarySubtitleStylerFontNameTests.cs diff --git a/src/ui/Features/Video/OpenSecondarySubtitle/OpenSecondarySubtitleViewModel.cs b/src/ui/Features/Video/OpenSecondarySubtitle/OpenSecondarySubtitleViewModel.cs index 678cd0ee7d6..9dfe10bc334 100644 --- a/src/ui/Features/Video/OpenSecondarySubtitle/OpenSecondarySubtitleViewModel.cs +++ b/src/ui/Features/Video/OpenSecondarySubtitle/OpenSecondarySubtitleViewModel.cs @@ -10,6 +10,7 @@ using Nikse.SubtitleEdit.Features.Main; using Nikse.SubtitleEdit.Features.Options.Settings; using Nikse.SubtitleEdit.Features.Shared.ColorPicker; +using Nikse.SubtitleEdit.Features.Shared.PickFontName; using Nikse.SubtitleEdit.Features.Sync.VisualSync; using Nikse.SubtitleEdit.Features.Video.BurnIn; using Nikse.SubtitleEdit.Logic; @@ -27,6 +28,7 @@ namespace Nikse.SubtitleEdit.Features.Video.OpenFromUrl; public partial class OpenSecondarySubtitleViewModel : ObservableObject { [ObservableProperty] private Color _subtitleColor; + [ObservableProperty] private string _selectedFontName; [ObservableProperty] private FontBoxItem _selectedFontBoxType; [ObservableProperty] private int _fontSize; [ObservableProperty] private bool _fontBold; @@ -37,6 +39,7 @@ public partial class OpenSecondarySubtitleViewModel : ObservableObject [ObservableProperty] private bool _overrideStyle; [ObservableProperty] private bool _doNotShowAgain; + public ObservableCollection FontNames { get; } public ObservableCollection FontBoxTypes { get; } public ObservableCollection FontAlignments { get; } public ObservableCollection JustifyItems { get; } @@ -76,6 +79,7 @@ public OpenSecondarySubtitleViewModel(IWindowService windowService) _windowService = windowService; SubtitleColor = Colors.White; + FontNames = new ObservableCollection(FontHelper.GetSystemFonts()); FontBold = Se.Settings.Video.MpvPreviewFontBold; FontBoxTypes = new ObservableCollection { @@ -94,6 +98,10 @@ public OpenSecondarySubtitleViewModel(IWindowService windowService) // so it's set in Initialize. var video = Se.Settings.Video; OverrideStyle = video.SecondarySubtitleOverrideStyle; + SelectedFontName = OverrideStyle + ? SecondarySubtitleStyler.GetFontNameFromSettings() + : SecondarySubtitleStyler.GetDefaultFontName(); + AddFontName(SelectedFontName); if (OverrideStyle) { SubtitleColor = video.SecondarySubtitleColor.FromHexToColor(); @@ -131,6 +139,43 @@ private async Task ChooseColor() } } + [RelayCommand] + private async Task PickFont() + { + if (Window == null) + { + return; + } + + var currentFontName = SelectedFontName; + var result = await _windowService.ShowDialogAsync(Window, vm => + { + vm.Initialize(); + vm.SelectedFontName = currentFontName; + }); + + if (!result.OkPressed || string.IsNullOrEmpty(result.SelectedFontName)) + { + return; + } + + // A "Collected fonts" pick need not be installed, so it need not be in the drop-down. + AddFontName(result.SelectedFontName); + SelectedFontName = result.SelectedFontName; + } + + /// + /// Lists a font name the drop-down lacks (a saved family name, or a collected font), so the + /// combo box does not show blank. + /// + private void AddFontName(string fontName) + { + if (!string.IsNullOrEmpty(fontName) && !FontNames.Contains(fontName)) + { + FontNames.Insert(0, fontName); + } + } + [RelayCommand] private void Ok() { @@ -145,7 +190,7 @@ private void Ok() if (OverrideStyle) { - SecondarySubtitleStyler.SaveToSettings(FontSize, GetVideoHeight(), FontBold, SubtitleColor, SelectedFontBoxType.BoxType, SelectedFontAlignment.Code, SelectedJustify.Code); + SecondarySubtitleStyler.SaveToSettings(SelectedFontName, FontSize, GetVideoHeight(), FontBold, SubtitleColor, SelectedFontBoxType.BoxType, SelectedFontAlignment.Code, SelectedJustify.Code); } Se.SaveSettings(); @@ -317,7 +362,7 @@ private int GetVideoHeight() private Subtitle BuildAssaSubtitle(bool mergeWithSubtitle) { - var style = SecondarySubtitleStyler.MakeStyle(_styleName, FontSize, FontBold, SubtitleColor, SelectedFontBoxType.BoxType, SelectedFontAlignment.Code); + var style = SecondarySubtitleStyler.MakeStyle(_styleName, SelectedFontName, FontSize, FontBold, SubtitleColor, SelectedFontBoxType.BoxType, SelectedFontAlignment.Code); var (width, height) = SecondarySubtitleStyler.GetVideoSize(_mediaInfo); var secondaryParagraphs = SecondarySubtitleJustifier.Apply(_secondarySubtitle.Paragraphs, style, SelectedJustify.Code, width, height); diff --git a/src/ui/Features/Video/OpenSecondarySubtitle/OpenSecondarySubtitleWindow.cs b/src/ui/Features/Video/OpenSecondarySubtitle/OpenSecondarySubtitleWindow.cs index bea6bfa9fa7..84b2289b24c 100644 --- a/src/ui/Features/Video/OpenSecondarySubtitle/OpenSecondarySubtitleWindow.cs +++ b/src/ui/Features/Video/OpenSecondarySubtitle/OpenSecondarySubtitleWindow.cs @@ -51,6 +51,20 @@ public OpenSecondarySubtitleWindow(OpenSecondarySubtitleViewModel vm) }); var panelColor = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { colorPreview, buttonColor } }; + // Font name row + var comboBoxFontName = UiUtil.MakeComboBox(vm.FontNames, vm, nameof(vm.SelectedFontName)); + comboBoxFontName.Width = double.NaN; + comboBoxFontName.HorizontalAlignment = HorizontalAlignment.Stretch; + AutomationProperties.SetName(comboBoxFontName, Se.Language.General.FontName); + var buttonPickFont = EditorChrome.CreateToolButton("fa-solid fa-ellipsis", Se.Language.Tools.PickFontNameTitle, vm.PickFontCommand, 28); + var panelFontName = new Grid + { + ColumnSpacing = 6, + ColumnDefinitions = { new ColumnDefinition(new GridLength(1, GridUnitType.Star)), new ColumnDefinition(GridLength.Auto) }, + }; + panelFontName.Add(comboBoxFontName, 0, 0); + panelFontName.Add(buttonPickFont, 0, 1); + // Font size row var numericFontSize = UiUtil.MakeNumericUpDownInt(6, 200, 46, 90, vm, nameof(vm.FontSize)); numericFontSize.Classes.Add("field"); @@ -72,6 +86,7 @@ public OpenSecondarySubtitleWindow(OpenSecondarySubtitleViewModel vm) Children = { EditorChrome.MakeLabeledRow(Se.Language.General.Color, panelColor, labelWidth), + EditorChrome.MakeLabeledRow(Se.Language.General.FontName, panelFontName, labelWidth), EditorChrome.MakeLabeledRow(Se.Language.General.FontSize, numericFontSize, labelWidth), EditorChrome.MakeSwitchRow(nameof(vm.FontBold), Se.Language.General.Bold), EditorChrome.MakeLabeledRow(Se.Language.General.BorderStyle, comboBoxBorderStyle, labelWidth), diff --git a/src/ui/Features/Video/OpenSecondarySubtitle/SecondarySubtitleStyler.cs b/src/ui/Features/Video/OpenSecondarySubtitle/SecondarySubtitleStyler.cs index 7bb110a705d..be0117b5844 100644 --- a/src/ui/Features/Video/OpenSecondarySubtitle/SecondarySubtitleStyler.cs +++ b/src/ui/Features/Video/OpenSecondarySubtitle/SecondarySubtitleStyler.cs @@ -18,14 +18,12 @@ namespace Nikse.SubtitleEdit.Features.Video.OpenFromUrl; /// public static class SecondarySubtitleStyler { - public static SsaStyle MakeStyle(string styleName, int fontSize, bool bold, Color color, FontBoxType boxType, string alignment) + public static SsaStyle MakeStyle(string styleName, string fontName, int fontSize, bool bold, Color color, FontBoxType boxType, string alignment) { var style = new SsaStyle { Name = styleName, - // Follow the main preview's font (issue #13492): a hardcoded Arial showed a - // sans-serif secondary under a serif main whenever the preview font was changed. - FontName = Se.Settings.Video.MpvPreviewFontName, + FontName = string.IsNullOrWhiteSpace(fontName) ? GetDefaultFontName() : fontName, FontSize = fontSize, Bold = bold, Primary = color.ToSKColor(), @@ -84,6 +82,7 @@ public static Subtitle BuildFromSettings(Subtitle secondarySubtitle, FfmpegMedia var (width, height) = GetVideoSize(mediaInfo); var style = MakeStyle( "Style" + Guid.NewGuid().ToString().Replace("-", string.Empty), + video.SecondarySubtitleFontName, GetFontSizeFromSettings(height), video.SecondarySubtitleFontBold, video.SecondarySubtitleColor.FromHexToColor(), @@ -106,6 +105,7 @@ public static Subtitle BuildRemembered(Subtitle secondarySubtitle, FfmpegMediaIn var (width, height) = GetVideoSize(mediaInfo); var style = MakeStyle( "Style" + Guid.NewGuid().ToString().Replace("-", string.Empty), + GetDefaultFontName(), AssaResampler.Resample(AdvancedSubStationAlpha.DefaultHeight, height, Se.Settings.Video.MpvPreviewFontSize), Se.Settings.Video.MpvPreviewFontBold, Colors.White, @@ -128,18 +128,38 @@ public static (int Width, int Height) GetVideoSize(FfmpegMediaInfo2? mediaInfo) : (1920, 1080); } + /// + /// The main preview's font (issue #13492): a hardcoded Arial showed a sans-serif secondary + /// under a serif main whenever the preview font was changed. + /// + public static string GetDefaultFontName() + { + return Se.Settings.Video.MpvPreviewFontName; + } + + /// + /// The saved font name, or the main preview's font when none was saved. + /// + public static string GetFontNameFromSettings() + { + var fontName = Se.Settings.Video.SecondarySubtitleFontName; + return string.IsNullOrWhiteSpace(fontName) ? GetDefaultFontName() : fontName; + } + public static int GetFontSizeFromSettings(int videoHeight) { var fontSize = Se.Settings.Video.SecondarySubtitleFontSize * videoHeight / AdvancedSubStationAlpha.DefaultHeight; return Math.Max(1, (int)Math.Round(fontSize, MidpointRounding.AwayFromZero)); } - public static void SaveToSettings(int fontSize, int videoHeight, bool bold, Color color, FontBoxType boxType, string alignment, string justify) + public static void SaveToSettings(string fontName, int fontSize, int videoHeight, bool bold, Color color, FontBoxType boxType, string alignment, string justify) { var video = Se.Settings.Video; // Not AssaResampler: it rounds to one decimal, which can drift the size by a pixel on a // 4K video when it is scaled back. video.SecondarySubtitleFontSize = Math.Round((decimal)fontSize * AdvancedSubStationAlpha.DefaultHeight / videoHeight, 4); + // Stored empty while it is the preview font, so it keeps following that font. + video.SecondarySubtitleFontName = fontName == GetDefaultFontName() ? string.Empty : fontName; video.SecondarySubtitleFontBold = bold; video.SecondarySubtitleColor = color.FromColorToHex(); video.SecondarySubtitleBoxType = boxType; diff --git a/src/ui/Logic/Config/SeVideo.cs b/src/ui/Logic/Config/SeVideo.cs index 37a2a3824d8..7ebd35caeab 100644 --- a/src/ui/Logic/Config/SeVideo.cs +++ b/src/ui/Logic/Config/SeVideo.cs @@ -149,6 +149,11 @@ public class SeVideo /// round trip exactly. /// public decimal SecondarySubtitleFontSize { get; set; } + + /// + /// Empty follows (#13492). + /// + public string SecondarySubtitleFontName { get; set; } public bool SecondarySubtitleFontBold { get; set; } public string SecondarySubtitleColor { get; set; } public FontBoxType SecondarySubtitleBoxType { get; set; } @@ -208,6 +213,7 @@ public SeVideo() SecondarySubtitleShowDialog = true; SecondarySubtitleRememberFile = true; SecondarySubtitleFontSize = 20; + SecondarySubtitleFontName = string.Empty; SecondarySubtitleFontBold = true; SecondarySubtitleColor = Colors.White.FromColorToHex(); SecondarySubtitleBoxType = FontBoxType.None; diff --git a/tests/UI/Features/Video/SecondarySubtitleStylerFontNameTests.cs b/tests/UI/Features/Video/SecondarySubtitleStylerFontNameTests.cs new file mode 100644 index 00000000000..de3d0542f02 --- /dev/null +++ b/tests/UI/Features/Video/SecondarySubtitleStylerFontNameTests.cs @@ -0,0 +1,51 @@ +using Avalonia.Media; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Features.Video.BurnIn; +using Nikse.SubtitleEdit.Features.Video.OpenFromUrl; +using Nikse.SubtitleEdit.Logic.Config; + +namespace UITests.Features.Video; + +/// +/// The second subtitle's own font name: stored empty while it is the preview font, so it keeps +/// following that font (#13492), and used as saved otherwise. +/// +public class SecondarySubtitleStylerFontNameTests +{ + [Fact] + public void FontName_SavedAndUsedWhenRemembered() + { + var video = Se.Settings.Video; + var oldFontName = video.SecondarySubtitleFontName; + var oldPreviewFontName = video.MpvPreviewFontName; + try + { + video.MpvPreviewFontName = "Arial"; + + SecondarySubtitleStyler.SaveToSettings("Arial", 40, 1080, true, Colors.White, FontBoxType.None, "8", "auto"); + Assert.Equal(string.Empty, video.SecondarySubtitleFontName); + + SecondarySubtitleStyler.SaveToSettings("Georgia", 40, 1080, true, Colors.White, FontBoxType.None, "8", "auto"); + Assert.Equal("Georgia", video.SecondarySubtitleFontName); + + var subtitle = new Subtitle(); + subtitle.Paragraphs.Add(new Paragraph("Hello", 0, 2000)); + var result = SecondarySubtitleStyler.BuildFromSettings(subtitle, null); + var styles = AdvancedSubStationAlpha.GetSsaStylesFromHeader(result.Header); + Assert.Equal("Georgia", Assert.Single(styles).FontName); + } + finally + { + video.SecondarySubtitleFontName = oldFontName; + video.MpvPreviewFontName = oldPreviewFontName; + } + } + + [Fact] + public void MakeStyle_EmptyFontName_FollowsPreviewFont() + { + var style = SecondarySubtitleStyler.MakeStyle("s", string.Empty, 40, false, Colors.White, FontBoxType.None, "8"); + Assert.Equal(Se.Settings.Video.MpvPreviewFontName, style.FontName); + } +} From b95d8c8c2d7fe1aa4be56c3c49b267a3801f9a51 Mon Sep 17 00:00:00 2001 From: niksedk Date: Fri, 30 Oct 2026 06:16:36 +0100 Subject: [PATCH 19/29] Second subtitle: auto-detect if the video offset applies A second file already timed from zero is no longer shifted by the video offset; one with the same offset times as the main file still is. Each reading is scored by how many lines start inside the video (or the main subtitle span), tie keeps the offset. Work on #16434 Co-Authored-By: Claude Opus 5.5 --- .../Media/SecondarySubtitleOffset.cs | 50 +++++++++++++++ src/ui/Features/Main/MainViewModel.cs | 33 +++++++++- .../Media/SecondarySubtitleOffsetTests.cs | 63 +++++++++++++++++++ 3 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 src/libuilogic/Media/SecondarySubtitleOffset.cs create mode 100644 tests/libuilogic/Media/SecondarySubtitleOffsetTests.cs diff --git a/src/libuilogic/Media/SecondarySubtitleOffset.cs b/src/libuilogic/Media/SecondarySubtitleOffset.cs new file mode 100644 index 00000000000..b853be5f2d5 --- /dev/null +++ b/src/libuilogic/Media/SecondarySubtitleOffset.cs @@ -0,0 +1,50 @@ +using Nikse.SubtitleEdit.Core.Common; + +namespace Nikse.SubtitleEdit.UiLogic.Media; + +/// +/// Decides whether the video offset applies to a second subtitle on the video (#16434). With an +/// offset like 10:00:00:00 the main file is shown minus the offset, but a second file may be +/// either a sibling written with the same 10-hour times or one already timed from zero. Each way +/// of reading it is scored by how many of its lines start inside the reference window (the video, +/// or else the main subtitle on the video timeline), and the better fit wins. +/// +public static class SecondarySubtitleOffset +{ + private const double ToleranceMs = 1000; + + /// The second subtitle with its file time codes. + /// The video offset, subtracted from file times to get video times. + /// Start of the reference window on the video timeline. + /// End of the reference window; when unknown. + /// True when the offset should be subtracted; also on a tie, so a file that fits + /// neither way keeps the main file's split. + public static bool ShouldApply(Subtitle secondary, double offsetMs, double windowStartMs, double windowEndMs) + { + if (offsetMs == 0 || secondary.Paragraphs.Count == 0) + { + return offsetMs != 0; + } + + var low = windowStartMs - ToleranceMs; + var high = windowEndMs == double.MaxValue ? double.MaxValue : windowEndMs + ToleranceMs; + var asIs = 0; + var shifted = 0; + foreach (var p in secondary.Paragraphs) + { + var start = p.StartTime.TotalMilliseconds; + if (start >= low && start <= high) + { + asIs++; + } + + var shiftedStart = start - offsetMs; + if (shiftedStart >= low && shiftedStart <= high) + { + shifted++; + } + } + + return shifted >= asIs; + } +} diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index ca4d1199d47..e3171204692 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -4656,20 +4656,47 @@ private void PushFfmpegPreview(FfmpegPlayer ffmpeg, Subtitle subtitle) /// /// The second subtitle on the video timeline: its file times minus the video offset, the same /// split the main file gets on open (). Applied per push rather than - /// baked into , so an offset change re-aligns it. + /// baked into , so an offset change re-aligns it. A second file + /// already timed from zero is left as is (). /// private Subtitle? GetSecondaryPreviewSubtitle() { - if (_subtitleSecondary == null || Se.Settings.General.CurrentVideoOffsetInMs == 0) + var offsetMs = Se.Settings.General.CurrentVideoOffsetInMs; + if (_subtitleSecondary == null || offsetMs == 0) + { + return _subtitleSecondary; + } + + var (windowStartMs, windowEndMs) = GetSecondaryOffsetReferenceWindow(); + if (!SecondarySubtitleOffset.ShouldApply(_subtitleSecondary, offsetMs, windowStartMs, windowEndMs)) { return _subtitleSecondary; } var shifted = new Subtitle(_subtitleSecondary); - shifted.AddTimeToAllParagraphs(TimeSpan.FromMilliseconds(-Se.Settings.General.CurrentVideoOffsetInMs)); + shifted.AddTimeToAllParagraphs(TimeSpan.FromMilliseconds(-offsetMs)); return shifted; } + /// + /// The video timeline span a second subtitle should land in: the video when its duration is + /// known, else the main subtitle's span (already on the video timeline), else from zero on. + /// + private (double StartMs, double EndMs) GetSecondaryOffsetReferenceWindow() + { + if (_mediaInfo?.Duration is { } duration && duration.TotalMilliseconds > 0) + { + return (0, duration.TotalMilliseconds); + } + + if (Subtitles.Count > 0) + { + return (Subtitles.Min(p => p.StartTime.TotalMilliseconds), Subtitles.Max(p => p.EndTime.TotalMilliseconds)); + } + + return (0, double.MaxValue); + } + [RelayCommand] private void ClearSecondarySubtitle() { diff --git a/tests/libuilogic/Media/SecondarySubtitleOffsetTests.cs b/tests/libuilogic/Media/SecondarySubtitleOffsetTests.cs new file mode 100644 index 00000000000..0b84517c0ab --- /dev/null +++ b/tests/libuilogic/Media/SecondarySubtitleOffsetTests.cs @@ -0,0 +1,63 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.UiLogic.Media; + +namespace LibUiLogicTests.Media; + +/// +/// The video offset on a second subtitle (#16434): a sibling file with the same 10-hour times +/// takes it, a file already timed from zero does not. +/// +public class SecondarySubtitleOffsetTests +{ + private const double TenHoursMs = 10 * 60 * 60 * 1000; + private const double VideoMs = 45 * 60 * 1000; + + private static Subtitle Make(double firstStartMs, int count = 20) + { + var subtitle = new Subtitle(); + for (var i = 0; i < count; i++) + { + var start = firstStartMs + i * 60_000; + subtitle.Paragraphs.Add(new Paragraph("Line " + i, start, start + 2000)); + } + + return subtitle; + } + + [Fact] + public void FileTimedWithOffset_AppliesOffset() + { + Assert.True(SecondarySubtitleOffset.ShouldApply(Make(TenHoursMs + 5000), TenHoursMs, 0, VideoMs)); + } + + [Fact] + public void FileTimedFromZero_KeepsTimes() + { + Assert.False(SecondarySubtitleOffset.ShouldApply(Make(5000), TenHoursMs, 0, VideoMs)); + } + + [Fact] + public void FileTimedFromZero_NoWindow_KeepsTimes() + { + Assert.False(SecondarySubtitleOffset.ShouldApply(Make(5000), TenHoursMs, 0, double.MaxValue)); + } + + [Fact] + public void NegativeOffset_FileTimedWithOffset_AppliesOffset() + { + // Offset -2 s: file times are 2 s earlier than the video, the first line at -2 s + 5 s. + Assert.True(SecondarySubtitleOffset.ShouldApply(Make(-2000 + 5000), -2000, 0, VideoMs)); + } + + [Fact] + public void FitsNeitherWay_AppliesOffset() + { + Assert.True(SecondarySubtitleOffset.ShouldApply(Make(5 * TenHoursMs), TenHoursMs, 0, VideoMs)); + } + + [Fact] + public void NoOffset_NeverApplies() + { + Assert.False(SecondarySubtitleOffset.ShouldApply(Make(5000), 0, 0, VideoMs)); + } +} From 346ff7628793c32da19fb881bfeb19c759420eb4 Mon Sep 17 00:00:00 2001 From: niksedk Date: Fri, 30 Oct 2026 06:47:21 +0100 Subject: [PATCH 20/29] Multiple replace: edit rules in their row, drop the Edit rule panel The "Edit rule" section under the rules list is gone. Enter, F2 or a double-click now edit the selected rule in its own row (find, replace with, description - the caret goes in the clicked cell); Enter keeps the change, Escape puts the texts back, leaving the row keeps it, and the preview follows while typing. The type icon in front of "Find what" is now a button that picks the rule type. "Edit..." still opens the full dialog. The rules and preview panels lose a little padding. Co-Authored-By: Claude Opus 5.5 --- .../MultipleReplaceViewModel.cs | 185 ++++++++++++--- .../MultipleReplace/MultipleReplaceWindow.cs | 221 +++++++++--------- .../Edit/MultipleReplace/RuleTreeNode.cs | 25 ++ .../Edit/MultipleReplaceInlineEditTests.cs | 170 ++++++++++++++ 4 files changed, 450 insertions(+), 151 deletions(-) create mode 100644 tests/UI/Features/Edit/MultipleReplaceInlineEditTests.cs diff --git a/src/ui/Features/Edit/MultipleReplace/MultipleReplaceViewModel.cs b/src/ui/Features/Edit/MultipleReplace/MultipleReplaceViewModel.cs index a56e1163afe..ead7f1462df 100644 --- a/src/ui/Features/Edit/MultipleReplace/MultipleReplaceViewModel.cs +++ b/src/ui/Features/Edit/MultipleReplace/MultipleReplaceViewModel.cs @@ -35,7 +35,11 @@ public partial class MultipleReplaceViewModel : ObservableObject [ObservableProperty] private string _fixesInfo; [ObservableProperty] private ObservableCollection _selectedFixHits; public ObservableCollection RuleTypes { get; } - [ObservableProperty] private MultipleReplaceTypeItem? _selectedRuleType; + + // The rule whose row shows text boxes in the rules list, and its texts from before the edit + // so Escape can put them back. + private RuleTreeNode? _inlineEditNode; + private (string Find, string ReplaceWith, string Description) _inlineEditOriginal; public ObservableCollection Nodes { get; } @@ -319,13 +323,16 @@ internal void NewRuleTextBoxKeyDown(object? sender, KeyEventArgs e) } } - // Keep SelectedRuleType in sync with SelectedNode partial void OnSelectedNodeChanged(RuleTreeNode? value) { IsEditPanelVisible = value is { IsCategory: false }; + if (_inlineEditNode != null && _inlineEditNode != value) + { + EndInlineEdit(commit: true, focusRow: false); + } + if (value == null || value.IsCategory) { - SelectedRuleType = null; if (value == null && !_refreshingRules && RulesTable.SelectedItem != null) { RulesTable.SelectedItem = null; @@ -348,29 +355,6 @@ partial void OnSelectedNodeChanged(RuleTreeNode? value) { RulesTable.SelectedItem = value; } - - var item = ReplaceTypes.FirstOrDefault(t => t.Type == value.Type); - SelectedRuleType = item; - } - - partial void OnSelectedRuleTypeChanged(MultipleReplaceTypeItem? value) - { - if (value == null) - { - return; - } - - var node = SelectedNode; - if (node == null || node.IsCategory) - { - return; - } - - if (node.Type != value.Type) - { - node.Type = value.Type; - _dirty = true; - } } partial void OnSelectedFixChanged(MultipleReplaceFix? value) @@ -1485,12 +1469,19 @@ internal void OnKeyDown(object? sender, KeyEventArgs e) } else if (e.Key == Key.F2 && e.KeyModifiers == KeyModifiers.None) { - // Edit the selected rule, or rename the selected category - the usual "rename" key. + // Edit the selected rule in its row, or rename the selected category - the usual "rename" key. var node = CategoriesListBox.IsKeyboardFocusWithin ? SelectedCategory : SelectedNode ?? SelectedCategory; if (node != null) { e.Handled = true; - _ = node.IsCategory ? CategoryEdit(node) : NodeEdit(node); + if (node.IsCategory) + { + _ = CategoryEdit(node); + } + else + { + BeginInlineEdit(node, nameof(RuleTreeNode.Find)); + } } } else if (e.Key == Key.D && IsCommand(e)) @@ -1559,7 +1550,13 @@ internal void NavigateToRule(RuleTreeNode? rule, bool focus = true) /// the list box inside the tree view handles Ctrl+Arrow itself (move focus, keep selection), /// so a bubbling handler never sees it. /// - internal void RulesTable_PreviewKeyDown(object? sender, KeyEventArgs e) => MoveKeyDown(e, SelectedNode); + internal void RulesTable_PreviewKeyDown(object? sender, KeyEventArgs e) + { + if (e.Source is not TextBox) // Ctrl+Home/End in an inline edit box moves the caret + { + MoveKeyDown(e, SelectedNode); + } + } internal void CategoriesListBox_PreviewKeyDown(object? sender, KeyEventArgs e) => MoveKeyDown(e, SelectedCategory); @@ -1597,6 +1594,11 @@ private void MoveKeyDown(KeyEventArgs e, RuleTreeNode? node) internal void RulesTable_KeyDown(object? sender, KeyEventArgs e) { + if (e.Source is TextBox) + { + return; // typing in an inline edit box + } + if (e.Key == Key.Delete && e.KeyModifiers == KeyModifiers.None) { e.Handled = true; @@ -1615,8 +1617,124 @@ internal void RulesTable_KeyDown(object? sender, KeyEventArgs e) else if (e.Key == Key.Enter && e.KeyModifiers == KeyModifiers.None && SelectedNode != null) { e.Handled = true; - _ = NodeEdit(SelectedNode); + BeginInlineEdit(SelectedNode, nameof(RuleTreeNode.Find)); + } + } + + /// + /// Shows text boxes for find, replace with and description in the rule's own row, the caret + /// in . The preview follows while typing; Enter keeps the change, + /// Escape puts the texts back, and leaving the row keeps it. + /// + internal void BeginInlineEdit(RuleTreeNode? node, string field) + { + if (node == null || node.IsCategory) + { + return; + } + + if (_inlineEditNode != null && _inlineEditNode != node) + { + EndInlineEdit(commit: true, focusRow: false); + } + + node.EditField = field; + if (_inlineEditNode == node) + { + node.RequestEditFocus(); + return; + } + + _inlineEditOriginal = (node.Find, node.ReplaceWith, node.Description); + _inlineEditNode = node; + node.IsEditing = true; + } + + internal void EndInlineEdit(bool commit, bool focusRow = true) + { + var node = _inlineEditNode; + if (node == null) + { + return; + } + + _inlineEditNode = null; + if (!commit) + { + node.Find = _inlineEditOriginal.Find; + node.ReplaceWith = _inlineEditOriginal.ReplaceWith; + node.Description = _inlineEditOriginal.Description; + } + + node.IsEditing = false; + _dirty = true; + if (focusRow) + { + Dispatcher.UIThread.Post(() => FocusNode(node), DispatcherPriority.Input); + } + } + + internal void InlineEditTextBoxKeyDown(object? sender, KeyEventArgs e) + { + // Handled here, so neither reaches the window - where Enter means OK and Escape closes. + if (e.Key == Key.Enter && e.KeyModifiers == KeyModifiers.None) + { + e.Handled = true; + EndInlineEdit(commit: true); + } + else if (e.Key == Key.Escape && e.KeyModifiers == KeyModifiers.None) + { + e.Handled = true; + EndInlineEdit(commit: false); + } + } + + /// + /// Focus left the rules list while a row was being edited - keep the change. + /// + internal void RulesTableLostFocus() + { + Dispatcher.UIThread.Post(() => + { + if (_inlineEditNode != null && !RulesTable.IsKeyboardFocusWithin) + { + EndInlineEdit(commit: true, focusRow: false); + } + }, DispatcherPriority.Input); + } + + /// + /// The rule type picker behind the type icon in front of "Find what". + /// + internal void OpenRuleTypeMenu(Control button, RuleTreeNode? node) + { + if (node == null || node.IsCategory) + { + return; } + + var contextMenu = new ContextMenu(); + foreach (var typeItem in ReplaceTypes) + { + var menuItem = new MenuItem + { + Header = typeItem.Name, + Icon = new Optris.Icons.Avalonia.Icon { Value = RuleTreeNode.GetIconName(typeItem.Type) }, + ToggleType = MenuItemToggleType.Radio, + IsChecked = node.Type == typeItem.Type, + }; + menuItem.Click += (_, _) => + { + if (node.Type != typeItem.Type) + { + node.Type = typeItem.Type; + _dirty = true; + } + }; + contextMenu.Items.Add(menuItem); + } + + OpenContextMenu(button, contextMenu); } internal void CategoriesListBox_KeyDown(object? sender, KeyEventArgs e) @@ -2010,12 +2128,9 @@ internal void OnLoaded() UiUtil.RestoreWindowPosition(Window); } - internal void RulesTableDoubleTapped() + internal void RulesTableDoubleTapped(string field) { - if (SelectedNode is { } node) - { - _ = NodeEdit(node); - } + BeginInlineEdit(SelectedNode, field); } internal void CategoriesListBoxDoubleTapped() diff --git a/src/ui/Features/Edit/MultipleReplace/MultipleReplaceWindow.cs b/src/ui/Features/Edit/MultipleReplace/MultipleReplaceWindow.cs index d13109e8038..e89968e93cd 100644 --- a/src/ui/Features/Edit/MultipleReplace/MultipleReplaceWindow.cs +++ b/src/ui/Features/Edit/MultipleReplace/MultipleReplaceWindow.cs @@ -4,15 +4,20 @@ using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; +using Avalonia.Data.Converters; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.Features.Files.Compare; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.ValueConverters; using Optris.Icons.Avalonia; +using System.Linq; namespace Nikse.SubtitleEdit.Features.Edit.MultipleReplace; @@ -21,6 +26,7 @@ public class MultipleReplaceWindow : Window // Follow the app theme, also when it changes while the window is open private static readonly IBrush ErrorBrush = EditorChrome.Themed(Color.FromRgb(255, 100, 100), Color.FromRgb(183, 28, 28)); private static readonly IBrush ReplacedBrush = EditorChrome.Themed(Color.FromRgb(100, 220, 100), Color.FromRgb(27, 94, 32)); + private static readonly TextToFlowDirectionConverter TextToFlowDirection = new(); public MultipleReplaceWindow(MultipleReplaceViewModel vm) { @@ -276,7 +282,7 @@ private static Border MakeRulesView(MultipleReplaceViewModel vm, out TextBox tex VerticalAlignment = VerticalAlignment.Center, Children = { checkBoxNewestFirst, textBoxFilter }, }; - var header = new DockPanel { Margin = new Thickness(0, 0, 0, 8) }; + var header = new DockPanel { Margin = new Thickness(0, 0, 0, 6) }; DockPanel.SetDock(headerRight, Dock.Right); header.Children.Add(headerRight); header.Children.Add(title); @@ -346,7 +352,7 @@ private static Border MakeRulesView(MultipleReplaceViewModel vm, out TextBox tex new ColumnDefinition { Width = GridLength.Auto }, }, ColumnSpacing = 8, - Margin = new Thickness(0, 0, 0, 8), + Margin = new Thickness(0, 0, 0, 6), }; entryRow.Add(newFind, 0, 0); entryRow.Add(arrow, 0, 1); @@ -410,7 +416,7 @@ private static Border MakeRulesView(MultipleReplaceViewModel vm, out TextBox tex CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, NameBinding = new Binding(nameof(RuleTreeNode.Find)), - CellTemplate = new FuncDataTemplate((_, _) => MakeFindCell()), + CellTemplate = new FuncDataTemplate((_, _) => MakeFindCell(vm)), Width = new GridLength(1, GridUnitType.Star), }, new SeTableViewColumn @@ -419,7 +425,8 @@ private static Border MakeRulesView(MultipleReplaceViewModel vm, out TextBox tex CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, Binding = new Binding(nameof(RuleTreeNode.ReplaceWith)), - CellTemplate = TableViewExtras.MakeTextCellTemplate(nameof(RuleTreeNode.ReplaceWith)), + CellTemplate = new FuncDataTemplate((_, _) => + MakeEditableTextCell(vm, nameof(RuleTreeNode.ReplaceWith), Se.Language.General.ReplaceWith, MakeCellTextBlock(nameof(RuleTreeNode.ReplaceWith)))), Width = new GridLength(1, GridUnitType.Star), }, new SeTableViewColumn @@ -441,14 +448,14 @@ private static Border MakeRulesView(MultipleReplaceViewModel vm, out TextBox tex CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, Binding = new Binding(nameof(RuleTreeNode.Description)), - CellTemplate = new FuncDataTemplate((_, _) => new TextBlock + CellTemplate = new FuncDataTemplate((_, _) => MakeEditableTextCell(vm, nameof(RuleTreeNode.Description), Se.Language.General.Description, new TextBlock { VerticalAlignment = VerticalAlignment.Center, Foreground = EditorChrome.FaintTextBrush, FontStyle = FontStyle.Italic, TextTrimming = TextTrimming.CharacterEllipsis, [!TextBlock.TextProperty] = new Binding(nameof(RuleTreeNode.Description)), - }), + })), Width = new GridLength(0.8, GridUnitType.Star), }, }); @@ -459,11 +466,18 @@ private static Border MakeRulesView(MultipleReplaceViewModel vm, out TextBox tex table.AddHandler(InputElement.KeyDownEvent, vm.RulesTable_PreviewKeyDown, RoutingStrategies.Tunnel); table.DoubleTapped += (_, e) => { - if (!TableViewExtras.IsInColumnHeader(e.Source as Visual)) + // A double-click inside an edit box selects a word; elsewhere it edits the row, + // the caret in the cell that was clicked. + if (e.Source is not Visual source || TableViewExtras.IsInColumnHeader(source) || + source.FindAncestorOfType(includeSelf: true) != null) { - vm.RulesTableDoubleTapped(); + return; } + + var field = source.GetSelfAndVisualAncestors().OfType().Select(p => p.Tag).OfType().FirstOrDefault(); + vm.RulesTableDoubleTapped(field ?? nameof(RuleTreeNode.Find)); }; + table.AddHandler(InputElement.LostFocusEvent, (_, _) => vm.RulesTableLostFocus(), RoutingStrategies.Bubble); table.AddHandler(InputElement.PointerReleasedEvent, (_, e) => { if (IsContextClick(e) && vm.SelectedNode != null) @@ -495,7 +509,7 @@ private static Border MakeRulesView(MultipleReplaceViewModel vm, out TextBox tex Spacing = 4, Children = { buttonRemove, buttonEdit, buttonMoveTo, buttonUp, buttonDown }, }; - var actions = new DockPanel { Margin = new Thickness(0, 8, 0, 0), MinHeight = EditorChrome.FieldHeight }; + var actions = new DockPanel { Margin = new Thickness(0, 6, 0, 0), MinHeight = EditorChrome.FieldHeight }; DockPanel.SetDock(buttonAddRules, Dock.Right); actions.Children.Add(buttonAddRules); actions.Children.Add(ruleActions); @@ -509,7 +523,7 @@ private static Border MakeRulesView(MultipleReplaceViewModel vm, out TextBox tex new RowDefinition(new GridLength(1, GridUnitType.Star)), new RowDefinition(GridLength.Auto), }, - Margin = new Thickness(14, 12, 14, 12), + Margin = new Thickness(12, 8, 12, 8), }; panel.Add(header, 0); panel.Add(entryRow, 1); @@ -520,11 +534,11 @@ private static Border MakeRulesView(MultipleReplaceViewModel vm, out TextBox tex } /// - /// "Find what" with the rule type's icon in front, and a warning marker for a rule that + /// "Find what" with the rule type's button in front, and a warning marker for a rule that /// cannot run - today only a regular expression that will not compile; it is skipped /// silently otherwise, which reads as a rule that just does nothing (#13534). /// - private static Control MakeFindCell() + private static Control MakeFindCell(MultipleReplaceViewModel vm) { var labelError = new Label { @@ -540,14 +554,26 @@ private static Control MakeFindCell() labelError[!ToolTip.TipProperty] = new Binding(nameof(RuleTreeNode.ErrorMessage)); } - var labelIcon = new Label + // The type icon picks the type, so the row editor covers every field but whole word, + // which has its own check box column. Not focusable: Tab goes between the edit boxes. + var buttonType = new Button { VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 6, 0), + Margin = new Thickness(0, 0, 4, 0), Padding = new Thickness(0), + Width = 22, + Height = 22, + Focusable = false, Foreground = EditorChrome.DimTextBrush, }; - labelIcon.Bind(Attached.IconProperty, new Binding(nameof(RuleTreeNode.IconName))); + buttonType.Classes.Add("tool"); + buttonType.Bind(Attached.IconProperty, new Binding(nameof(RuleTreeNode.IconName))); + buttonType.Bind(AutomationProperties.NameProperty, new Binding(nameof(RuleTreeNode.SearchType))); + if (Se.Settings.Appearance.ShowHints) + { + ToolTip.SetTip(buttonType, Se.Language.General.Type); + } + buttonType.Click += (_, _) => vm.OpenRuleTypeMenu(buttonType, buttonType.DataContext as RuleTreeNode); var text = new TextBlock { @@ -556,115 +582,89 @@ private static Control MakeFindCell() [!TextBlock.TextProperty] = new Binding(nameof(RuleTreeNode.Find)), }; - return new StackPanel + var row = new DockPanel { VerticalAlignment = VerticalAlignment.Center }; + DockPanel.SetDock(labelError, Dock.Left); + DockPanel.SetDock(buttonType, Dock.Left); + row.Children.Add(labelError); + row.Children.Add(buttonType); + row.Children.Add(MakeEditableTextCell(vm, nameof(RuleTreeNode.Find), Se.Language.Edit.MultipleReplace.FindWhat, text)); + return row; + } + + private static TextBlock MakeCellTextBlock(string propertyPath) + { + return new TextBlock { - Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, - Children = { labelError, labelIcon, text }, + TextTrimming = TextTrimming.CharacterEllipsis, + [!TextBlock.TextProperty] = new Binding(propertyPath) { Mode = BindingMode.OneWay }, + [!TextBlock.FlowDirectionProperty] = new Binding(propertyPath) { Converter = TextToFlowDirection, Mode = BindingMode.OneWay }, }; } /// - /// The selected rule's fields on top (only while a rule is selected), the preview of the - /// fixes under them and the rules that made the selected fix at the bottom. + /// A rule cell that shows , and a text box instead while the rule is + /// edited in its row. The Tag names the field, so a double-click puts the caret in the cell + /// that was clicked. /// - private static Grid MakeFixesView(MultipleReplaceViewModel vm) + private static Control MakeEditableTextCell(MultipleReplaceViewModel vm, string field, string accessibleName, Control display) { - var textBoxFind = new TextBox - { - MinWidth = 100, - Width = double.NaN, - HorizontalAlignment = HorizontalAlignment.Stretch, - VerticalAlignment = VerticalAlignment.Center, - [!TextBox.TextProperty] = new Binding(nameof(vm.SelectedNode) + "." + nameof(RuleTreeNode.Find)) { Source = vm } - }; - textBoxFind.Classes.Add("text"); - AutomationProperties.SetName(textBoxFind, Se.Language.General.Find); - textBoxFind.TextChanged += vm.RuleTextChanged; + display[!Visual.IsVisibleProperty] = new Binding(nameof(RuleTreeNode.IsEditing)) { Converter = BoolConverters.Not }; - var textBoxReplaceWith = new TextBox + var textBox = new TextBox { - MinWidth = 100, - Width = double.NaN, - HorizontalAlignment = HorizontalAlignment.Stretch, VerticalAlignment = VerticalAlignment.Center, - [!TextBox.TextProperty] = new Binding(nameof(vm.SelectedNode) + "." + nameof(RuleTreeNode.ReplaceWith)) { Source = vm } + HorizontalAlignment = HorizontalAlignment.Stretch, + MinHeight = 0, + Padding = new Thickness(4, 1, 4, 1), + [!TextBox.TextProperty] = new Binding(field) { Mode = BindingMode.TwoWay }, + [!Visual.IsVisibleProperty] = new Binding(nameof(RuleTreeNode.IsEditing)), }; - textBoxReplaceWith.Classes.Add("text"); - AutomationProperties.SetName(textBoxReplaceWith, Se.Language.General.ReplaceWith); - textBoxReplaceWith.TextChanged += vm.RuleTextChanged; + textBox.Classes.Add("text"); + AutomationProperties.SetName(textBox, accessibleName); + textBox.TextChanged += vm.RuleTextChanged; + textBox.KeyDown += vm.InlineEditTextBoxKeyDown; - var comboBoxType = new ComboBox - { - Width = 170, - HorizontalAlignment = HorizontalAlignment.Left, - VerticalAlignment = VerticalAlignment.Center, - [!ItemsControl.ItemsSourceProperty] = new Binding(nameof(vm.RuleTypes)) { Source = vm }, - [!ComboBox.SelectedItemProperty] = new Binding(nameof(vm.SelectedRuleType)) { Source = vm, Mode = BindingMode.TwoWay }, - }; - AutomationProperties.SetName(comboBoxType, Se.Language.General.Type); - // no event handler needed; binding to SelectedRuleType updates RuleTreeNode.Type + var panel = new Panel { Tag = field, Children = { display, textBox } }; - var switchWholeWord = new ToggleSwitch + // Cells are recycled between rows, so follow the row the cell shows now. + RuleTreeNode? node = null; + void OnEditFocusRequested() { - OnContent = null, - OffContent = null, - MinWidth = 0, - VerticalAlignment = VerticalAlignment.Center, - [!ToggleButton.IsCheckedProperty] = new Binding(nameof(vm.SelectedNode) + "." + nameof(RuleTreeNode.WholeWord)) { Source = vm, Mode = BindingMode.TwoWay }, - [!InputElement.IsEnabledProperty] = new Binding(nameof(vm.SelectedNode) + "." + nameof(RuleTreeNode.CanUseWholeWord)) { Source = vm }, - }; - AutomationProperties.SetName(switchWholeWord, Se.Language.Edit.Find.WholeWord); - switchWholeWord.IsCheckedChanged += vm.OnActiveChanged; - var panelWholeWord = new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 8, - VerticalAlignment = VerticalAlignment.Center, - Children = + if (node?.EditField != field) { - switchWholeWord, - new TextBlock { Text = Se.Language.Edit.Find.WholeWord, FontSize = EditorChrome.FieldFontSize, VerticalAlignment = VerticalAlignment.Center }, - }, - }; + return; + } - // Labels above the fields: find and replace share the width, type and whole word take what they need. - var editGrid = new Grid + Dispatcher.UIThread.Post(() => + { + textBox.Focus(); + textBox.CaretIndex = textBox.Text?.Length ?? 0; + }, DispatcherPriority.Background); + } + + panel.DataContextChanged += (_, _) => { - RowDefinitions = + if (node != null) { - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - }, - ColumnDefinitions = + node.EditFocusRequested -= OnEditFocusRequested; + } + + node = panel.DataContext as RuleTreeNode; + if (node != null) { - new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, - new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, - new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, - new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, - }, - HorizontalAlignment = HorizontalAlignment.Stretch, - ColumnSpacing = 8, - RowSpacing = 4, + node.EditFocusRequested += OnEditFocusRequested; + } }; - editGrid.Add(EditorChrome.MakeDimLabel(Se.Language.General.Find, new Thickness(0)), 0, 0); - editGrid.Add(textBoxFind, 1, 0); - - editGrid.Add(EditorChrome.MakeDimLabel(Se.Language.General.ReplaceWith, new Thickness(0)), 0, 1); - editGrid.Add(textBoxReplaceWith, 1, 1); - - editGrid.Add(EditorChrome.MakeDimLabel(Se.Language.General.Type, new Thickness(0)), 0, 2); - editGrid.Add(comboBoxType, 1, 2); - - editGrid.Add(panelWholeWord, 1, 3); - - var editSection = EditorChrome.MakeEditorSection(Se.Language.Edit.MultipleReplace.EditRule, editGrid, first: true); - editSection.Bind(Visual.IsVisibleProperty, new Binding(nameof(vm.IsEditPanelVisible)) { Source = vm }); - editSection.Background = EditorChrome.PanelBrush; - editSection.BorderBrush = EditorChrome.LineBrush; - editSection.BorderThickness = new Thickness(0, 0, 0, 1); + return panel; + } + /// + /// The preview of the fixes, and the rules that made the selected fix under it. + /// + private static Grid MakeFixesView(MultipleReplaceViewModel vm) + { // No header sorting (the DataGrid's CanUserSortColumns is not carried over): // this is a fix preview in subtitle order, and the replace rules themselves run // in list order - reordering the backing collection would scramble the preview. @@ -827,7 +827,7 @@ private static Grid MakeFixesView(MultipleReplaceViewModel vm) var hitsPanel = new StackPanel { - Margin = new Thickness(0, 10, 0, 0), + Margin = new Thickness(0, 8, 0, 0), Spacing = 6, Children = { @@ -853,7 +853,7 @@ private static Grid MakeFixesView(MultipleReplaceViewModel vm) VerticalAlignment = VerticalAlignment.Center, Children = { buttonSelectAll, buttonSelectNone, buttonInvert }, }; - var previewHeader = new DockPanel { Margin = new Thickness(0, 0, 0, 8) }; + var previewHeader = new DockPanel { Margin = new Thickness(0, 0, 0, 6) }; DockPanel.SetDock(previewActions, Dock.Right); previewHeader.Children.Add(previewActions); previewHeader.Children.Add(EditorChrome.MakeSectionHeader(Se.Language.General.Preview)); @@ -866,24 +866,13 @@ private static Grid MakeFixesView(MultipleReplaceViewModel vm) new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, }, - Margin = new Thickness(14, 12, 14, 14), + Margin = new Thickness(12, 8, 12, 10), }; gridPreview.Add(previewHeader, 0, 0); gridPreview.Add(EditorChrome.MakeListFrame(dataGrid), 1, 0); gridPreview.Add(hitsPanel, 2, 0); - var gridFixes = new Grid - { - RowDefinitions = - { - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, - }, - }; - gridFixes.Add(editSection, 0, 0); - gridFixes.Add(gridPreview, 1, 0); - - return gridFixes; + return gridPreview; } // A preview with one row per changed line is far too long to untick by hand, so offer diff --git a/src/ui/Features/Edit/MultipleReplace/RuleTreeNode.cs b/src/ui/Features/Edit/MultipleReplace/RuleTreeNode.cs index b516e0a48cf..a16e24cf449 100644 --- a/src/ui/Features/Edit/MultipleReplace/RuleTreeNode.cs +++ b/src/ui/Features/Edit/MultipleReplace/RuleTreeNode.cs @@ -18,6 +18,31 @@ public partial class RuleTreeNode : ObservableObject [ObservableProperty] private bool _isExpanded; [ObservableProperty] private bool _wholeWord; + /// + /// True while the rule's row in the rules list shows text boxes instead of text. + /// + [ObservableProperty] private bool _isEditing; + + /// + /// The text box the row editor puts the caret in: Find, ReplaceWith or Description. + /// + public string EditField { get; set; } = nameof(Find); + + /// + /// Asks the row editor's text box for to take focus. + /// + public event System.Action? EditFocusRequested; + + public void RequestEditFocus() => EditFocusRequested?.Invoke(); + + partial void OnIsEditingChanged(bool value) + { + if (value) + { + RequestEditFocus(); + } + } + /// /// The rule's place in its category, 1-based - the order the rules run in. /// diff --git a/tests/UI/Features/Edit/MultipleReplaceInlineEditTests.cs b/tests/UI/Features/Edit/MultipleReplaceInlineEditTests.cs new file mode 100644 index 00000000000..00652759bb4 --- /dev/null +++ b/tests/UI/Features/Edit/MultipleReplaceInlineEditTests.cs @@ -0,0 +1,170 @@ +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Edit.MultipleReplace; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Media; + +namespace UITests.Features.Edit; + +/// +/// A rule is edited in its own row of the rules list - the separate "Edit rule" panel under the +/// list is gone. Enter / F2 / double-click open the row editor, Enter keeps the change, Escape +/// puts the texts back, and the preview follows while typing. +/// +public class MultipleReplaceInlineEditTests : IDisposable +{ + private readonly List _windows = new(); + + public void Dispose() + { + foreach (var window in _windows) + { + window.Close(); + } + + _windows.Clear(); + } + + private sealed class NullServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + private (MultipleReplaceViewModel Vm, MultipleReplaceWindow Window, RuleTreeNode Rule) Open() + { + var vm = new MultipleReplaceViewModel(new WindowService(new NullServiceProvider()), new FileHelper()); + vm.PreviewIntervalMs = 25; + vm.Nodes.Clear(); + + var category = new RuleTreeNode(true) { CategoryName = "c1", IsActive = true }; + vm.Nodes.Add(category); + var rule = new RuleTreeNode(false) + { + Find = "colour", + ReplaceWith = "color", + Description = "US spelling", + IsActive = true, + Parent = category, + Type = MultipleReplaceType.CaseInsensitive, + }; + category.SubNodes!.Add(rule); + + var subtitle = new Subtitle(); + subtitle.Paragraphs.Add(new Paragraph("The colour is red.", 0, 2000)); + subtitle.Paragraphs.Add(new Paragraph("The flavour is sweet.", 2000, 4000)); + vm.Initialize(subtitle); + + var window = new MultipleReplaceWindow(vm); + _windows.Add(window); + window.Show(); + Dispatcher.UIThread.RunJobs(); + vm.SelectedCategory = category; + Dispatcher.UIThread.RunJobs(); + return (vm, window, rule); + } + + private static void WaitFor(Func condition) + { + var end = Environment.TickCount64 + 3000; + while (Environment.TickCount64 < end && !condition()) + { + Dispatcher.UIThread.RunJobs(); + Thread.Sleep(20); + } + + Dispatcher.UIThread.RunJobs(); + } + + private static TextBox? FocusedTextBox(Window window) => window.FocusManager?.GetFocusedElement() as TextBox; + + private static void BeginEdit(Window window, MultipleReplaceViewModel vm, RuleTreeNode rule, string field) + { + vm.SelectedNode = rule; + Dispatcher.UIThread.RunJobs(); + vm.RulesTableDoubleTapped(field); + WaitFor(() => FocusedTextBox(window) != null); + } + + [AvaloniaFact] + public void EditRulePanel_IsGone() + { + var (_, window, _) = Open(); + + var titles = window.GetVisualDescendants().OfType().Select(t => t.Text).ToList(); + Assert.DoesNotContain(Nikse.SubtitleEdit.Logic.Config.Se.Language.Edit.MultipleReplace.EditRule, titles); + } + + [AvaloniaFact] + public void DoubleClickedCell_GetsTheCaret_AndEnterKeepsTheChange() + { + var (vm, window, rule) = Open(); + BeginEdit(window, vm, rule, nameof(RuleTreeNode.ReplaceWith)); + + Assert.True(rule.IsEditing); + var box = FocusedTextBox(window); + Assert.NotNull(box); + Assert.Equal("color", box!.Text); + + window.KeyTextInput(" x"); + window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + + Assert.False(rule.IsEditing); + Assert.Equal("color x", rule.ReplaceWith); // the space was typed, not "toggle active" + Assert.True(rule.IsActive); + Assert.True(window.IsVisible); // Enter did not reach the window's OK + } + + [AvaloniaFact] + public void Escape_PutsTheTextsBack_AndKeepsTheWindowOpen() + { + var (vm, window, rule) = Open(); + BeginEdit(window, vm, rule, nameof(RuleTreeNode.Find)); + + window.KeyTextInput("zzz"); + Assert.Equal("colourzzz", rule.Find); + window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + + Assert.False(rule.IsEditing); + Assert.Equal("colour", rule.Find); + Assert.True(window.IsVisible); + } + + [AvaloniaFact] + public void Preview_FollowsTheRowEditor() + { + var (vm, window, rule) = Open(); + WaitFor(() => vm.Fixes.Count == 1); + Assert.Single(vm.Fixes); + + BeginEdit(window, vm, rule, nameof(RuleTreeNode.Find)); + var box = FocusedTextBox(window)!; + box.Text = "our"; + WaitFor(() => vm.Fixes.Count == 2); + + Assert.Equal(2, vm.Fixes.Count); + } + + [AvaloniaFact] + public void SelectingAnotherRule_KeepsTheChange() + { + var (vm, window, rule) = Open(); + var other = new RuleTreeNode(false) { Find = "a", ReplaceWith = "b", IsActive = true, Parent = rule.Parent }; + rule.Parent!.SubNodes!.Add(other); + Dispatcher.UIThread.RunJobs(); + + BeginEdit(window, vm, rule, nameof(RuleTreeNode.Description)); + window.KeyTextInput("!"); + vm.SelectedNode = other; + Dispatcher.UIThread.RunJobs(); + + Assert.False(rule.IsEditing); + Assert.Equal("US spelling!", rule.Description); + } +} From e9340c66922c75d90b979200cc1de8c6accd69d7 Mon Sep 17 00:00:00 2001 From: niksedk Date: Fri, 30 Oct 2026 06:51:48 +0100 Subject: [PATCH 21/29] Docs: Multiple replace double-click edits the rule in its row Co-Authored-By: Claude Opus 5.5 --- docs/features/edit.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/features/edit.md b/docs/features/edit.md index 325e1ccbf69..f4b8912b5c7 100644 --- a/docs/features/edit.md +++ b/docs/features/edit.md @@ -105,7 +105,7 @@ Editing a rule regenerates the preview, which ticks every row again. ### Rule types -Each rule has one of three match types, shown as an icon in the tree: +Each rule has one of three match types, shown as an icon in front of **Find what**. Click the icon to pick another type: | Type | Description | |---|---| @@ -137,7 +137,7 @@ Right-click a category node to open its context menu: Right-click a rule node to open its context menu: -- **Edit rule** — change find/replace text, type, and description +- **Edit rule** — open the **Edit rule** dialog for find/replace text, type, whole word, and description - **Duplicate** — insert a copy of the rule above the current one - **Insert before / Insert after** — add a new rule relative to this one - **Add rules...** — add a list of rules right after this one (see [Adding many rules at once](#adding-many-rules-at-once)) @@ -146,7 +146,15 @@ Right-click a rule node to open its context menu: - **Move to category** — move the rule to the end of another category - **Delete** — remove the rule -Double-clicking a rule also opens the **Edit rule** dialog. A new rule is selected and scrolled into view, also at the end of a long category. +Double-click a rule (or press `Enter` or `F2` on it) to edit it in its own row: **Find what**, **Replace with** and **Description** turn into text boxes, with the cursor in the cell you double-clicked. `Tab` moves between the boxes and the preview follows while you type. + +- `Enter` keeps the change +- `Escape` puts the texts back +- Selecting another rule or clicking outside the list also keeps the change + +**Whole word** is the checkbox in its own column, and the type is the icon in front of **Find what**. The **Edit...** button below the list and **Edit rule** in the context menu still open the full dialog. + +A new rule is selected and scrolled into view, also at the end of a long category. ### Adding many rules at once @@ -165,7 +173,8 @@ The rules are added in the order they were entered, which is the order they run |---|---| | `Ctrl+N` | Add a new rule to the selected category, or insert after the selected rule | | `Ctrl+Shift+N` | Open **Add rules** for the selected category or rule | -| `F2` | Edit the selected rule, or rename the selected category | +| `F2` / `Enter` | Edit the selected rule in its row (`F2` renames the selected category) | +| `Enter` / `Escape` while editing a row | Keep the change / put the texts back | | `Ctrl+D` | Duplicate the selected rule | | `Ctrl+F` | Find a rule by name / text | | `Ctrl+Up` / `Ctrl+Down` | Move the selected category or rule up / down (`Cmd` on macOS) | From b21ff173680acf1805d85458e3a782957f573457 Mon Sep 17 00:00:00 2001 From: niksedk Date: Fri, 30 Oct 2026 06:54:10 +0100 Subject: [PATCH 22/29] Docs: new Multiple replace screenshot and window layout Co-Authored-By: Claude Opus 5.5 --- docs/features/edit.md | 7 ++++--- docs/screenshots/multiple-replace.png | Bin 14132 -> 38821 bytes 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/features/edit.md b/docs/features/edit.md index f4b8912b5c7..7f5226cd10a 100644 --- a/docs/features/edit.md +++ b/docs/features/edit.md @@ -82,12 +82,13 @@ Apply multiple find-and-replace rules at once, organized into named categories. ### Window layout -The window is split into two resizable panels: +The window is split into resizable panels: | Panel | Description | |---|---| -| **Left — Rules** | Tree of categories and their rules. Each rule shows its type icon, find pattern, replacement, and an optional description. The buttons above the tree add a category, add a rule, open **Add rules** (a whole list in one go), import rules, and collapse or expand all categories. | -| **Right — Fixes** | Preview of all lines that will be changed. The **Before** column highlights removed characters in red and the **After** column highlights added characters in green. Selecting a row reveals an **Applied rules** detail panel at the bottom listing every rule that matched that line. | +| **Left — Categories** | The categories, each with an on/off checkbox and its number of rules. The buttons below the list add, rename, delete and import categories. | +| **Top right — Rules** | The rules of the selected category: on/off, type icon, **Find what**, **Replace with**, **Whole word** and description. The row above the list adds a rule as you type it (`Enter` adds it); **Newest first** and the filter box change what the list shows. The buttons below remove, edit, move and reorder the selected rule, and open **Add rules** (a whole list in one go). | +| **Bottom right — Preview** | All lines that will be changed. The **Before** column highlights removed characters in red and the **After** column highlights added characters in green. Selecting a row shows **Applied rules** under it, listing every rule that matched that line. | ### Choosing which fixes to apply diff --git a/docs/screenshots/multiple-replace.png b/docs/screenshots/multiple-replace.png index 97a66ed7c7d4b02080a28639f4ab4136f8e068cf..a69801a771070fa0beb2e2df2c77089e25f044ce 100644 GIT binary patch literal 38821 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 TD+O170v>x?=QCw6-`IZv@Q;z( literal 14132 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 Date: Fri, 30 Oct 2026 07:01:47 +0100 Subject: [PATCH 23/29] v5.4.0-beta11: version bump + change log Co-Authored-By: Claude Opus 5.5 --- change-log.txt | 21 +++++++++++++++++++++ src/ui/Assets/Languages/English.json | 6 ++++-- src/ui/Logic/Config/Se.cs | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/change-log.txt b/change-log.txt index 578b6ff8514..a634715cef7 100644 --- a/change-log.txt +++ b/change-log.txt @@ -3,6 +3,27 @@ Subtitle Edit Changelog ----------------------------------------------------------------------------------------------------- +v5.4.0-beta11 (30th of October 2026) + +* Add "Move start/end X ms" shortcuts that push the neighbour line (no default key) - thx doom9 forum +* Second subtitle: add font name picker +* Second subtitle: auto-detect if the video offset applies - thx Jamestankw +* Multiple replace: edit rules directly in the list (Enter/F2/double-click), more compact window +* ASSA/SSA styles: preview fonts attached in [Fonts] +* Use Silero VAD for CrispASR Granite by default (better accuracy) +* Fix spell check undo of "Add to names list" - thx bptrsn +* Fix arrow keys after type-ahead in Options > Word lists - thx FunkyKnilch +* Fix uneven 500 ms / custom video steps on quick presses - thx vitonique +* Fix read-only original moving in waveform/timeline while retiming translation - thx pdjdev +* Fix "Save as" ignoring format picked via format picker (e.g. Sub Station Alpha/.ssa) +* Update Japanese translation - thx hisui3393 +* Update German translation - thx FunkyKnilch +* Update Italian translation - thx bovirus +* Update Korean translation - thx 12si27 +* Update CrispASR to v0.8.48 + +----------------------------------------------------------------------------------------------------- + v5.4.0-beta10 (29th of October 2026) * Add full support for YouTube timed text (.ytt/srv3): pens, positions, preview + styles window diff --git a/src/ui/Assets/Languages/English.json b/src/ui/Assets/Languages/English.json index b38848b64ce..e3dfd9e5a65 100644 --- a/src/ui/Assets/Languages/English.json +++ b/src/ui/Assets/Languages/English.json @@ -1,6 +1,6 @@ { "title": "Subtitle Edit", - "version": "v5.4.0-beta10", + "version": "v5.4.0-beta11", "translatedBy": "", "cultureName": "en-US", "general": { @@ -2709,7 +2709,7 @@ "detectSpeakersNeedsNewerCrispAsr": "\"Detect speakers\" needs Crisp ASR v{0} or newer, but v{1} is installed.\n\nUpdate Crisp ASR (the gear button next to the engine) or turn off \"Detect speakers\".", "xNeedsNewerCrispAsr": "{0} needs Crisp ASR v{1} or newer, but v{2} is installed.\n\nDownload and install the update now?", "vad": "VAD", - "vadHint": "Voice activity detection: only the speech is transcribed, cut where the speaker pauses.\nAuto: Silero, and only for the engines that need it (Cohere, Mega, Index-Echo).\nSilero, FireRedVAD, Whisper-VAD, WebRTC: always on, for every Crisp ASR engine. FireRedVAD is the most accurate, Whisper-VAD gives the fewest, cleanest cuts but is the slowest.\nIndex-Echo always uses Silero. --vad, --vad-model or --chunk-seconds in the advanced settings take precedence.", + "vadHint": "Voice activity detection: only the speech is transcribed, cut where the speaker pauses.\nAuto: Silero, and only for the engines that need it (Cohere, Mega, Index-Echo, Granite).\nSilero, FireRedVAD, Whisper-VAD, WebRTC: always on, for every Crisp ASR engine. FireRedVAD is the most accurate, Whisper-VAD gives the fewest, cleanest cuts but is the slowest.\nIndex-Echo always uses Silero. --vad, --vad-model or --chunk-seconds in the advanced settings take precedence.", "inputLanguage": "Input language", "advancedWhisperSettings": "Advanced speech-to-text parameters", "downloadingSpeechToTextEngine": "Downloading speech-to-text engine", @@ -4194,6 +4194,8 @@ "moveStartXMsForward": "Move start X ms forward (X set in Settings, also in Visual Sync)", "moveEndXMsBack": "Move end X ms back (X set in Settings, also in Visual Sync)", "moveEndXMsForward": "Move end X ms forward (X set in Settings, also in Visual Sync)", + "moveStartXMsBackPushPrevious": "Move start X ms back, push previous end (X set in Settings)", + "moveEndXMsForwardPushNext": "Move end X ms forward, push next start (X set in Settings)", "moveSelectedLinesXMsBack": "Move selected lines X ms back (X set in Settings)", "moveSelectedLinesXMsForward": "Move selected lines X ms forward (X set in Settings)", "moveSelectedLinesAndForwardXMsBack": "Move selected lines and all following X ms back (X set in Settings)", diff --git a/src/ui/Logic/Config/Se.cs b/src/ui/Logic/Config/Se.cs index 257cd6a1379..e6112878fb9 100644 --- a/src/ui/Logic/Config/Se.cs +++ b/src/ui/Logic/Config/Se.cs @@ -22,7 +22,7 @@ public class Se internal const int CurrentShortcutsMigrationVersion = 6; internal const int CurrentLayoutMigrationVersion = 2; - public static string Version { get; set; } = "v5.4.0-beta10"; + public static string Version { get; set; } = "v5.4.0-beta11"; public SeGeneral General { get; set; } = new(); public List Shortcuts { get; set; } = new(); From 9714ed69aae307419b59bc4d0343d6de71d184bf Mon Sep 17 00:00:00 2001 From: niksedk Date: Fri, 30 Oct 2026 10:21:06 +0100 Subject: [PATCH 24/29] FFmpeg player overlay: italic/bold runs, right-to-left text, no drawings Co-Authored-By: Claude Opus 5.5 --- .../Ffmpeg/FfmpegPreviewSubtitle.cs | 122 +++++++++++++++--- .../Ffmpeg/FfmpegSoftwareControl.cs | 42 ++++-- tests/UI/Logic/FfmpegPreviewSubtitleTests.cs | 100 ++++++++++++-- .../FfmpegSoftwareControlOverlayTests.cs | 2 +- 4 files changed, 230 insertions(+), 36 deletions(-) diff --git a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPreviewSubtitle.cs b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPreviewSubtitle.cs index 9caf87c2038..677a7e5aa38 100644 --- a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPreviewSubtitle.cs +++ b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPreviewSubtitle.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; +using System.Text.RegularExpressions; namespace Nikse.SubtitleEdit.Logic.VideoPlayers.Ffmpeg; @@ -10,12 +12,20 @@ namespace Nikse.SubtitleEdit.Logic.VideoPlayers.Ffmpeg; /// The subtitle preview for the ffmpeg player, as plain lines with times. mpv and VLC render /// the preview themselves from an ASS file; this player draws it in /// , so the snapshot only keeps what that overlay can show: -/// text with line breaks, whole-line italic, and whether a line belongs to the secondary +/// text with line breaks, italic/bold runs, and whether a line belongs to the secondary /// subtitle (drawn at the top, the primary at the configured alignment). /// public sealed class FfmpegPreviewSubtitle { - public sealed record Line(double StartSeconds, double EndSeconds, string Text, bool Italic, bool Secondary); + /// A part of shown italic and/or bold. + public readonly record struct StyleRun(int Start, int Length, bool Italic, bool Bold); + + public sealed record Line(double StartSeconds, double EndSeconds, string Text, StyleRun[] Runs, bool Secondary); + + // From a \p1 (or higher) block to the \p0 block that ends the drawing, or else the end of the text. + private static readonly Regex DrawingRegex = new(@"\{[^{}]*\\p[1-9][^{}]*\}.*?(?=\{[^{}]*\\p0[^{}]*\}|\z)", RegexOptions.Compiled | RegexOptions.Singleline); + private static readonly Regex StyleTagRegex = new(@"|\{\\[^{}]*\}", RegexOptions.Compiled); + private static readonly Regex AssStyleOverrideRegex = new(@"\\([ib])(\d*)(?=[\\}])", RegexOptions.Compiled); public static readonly FfmpegPreviewSubtitle Empty = new(Array.Empty()); @@ -61,31 +71,113 @@ private static void Add(List lines, IEnumerable paragraphs, boo foreach (var paragraph in paragraphs) { var p = smpteMode ? SmptePreviewStretch.Stretched(paragraph) : paragraph; - var text = ToPlainText(p.Text, out var italic); + var text = ToPlainText(p.Text, out var runs); if (string.IsNullOrWhiteSpace(text) || p.EndTime.TotalSeconds <= p.StartTime.TotalSeconds) { continue; } - lines.Add(new Line(p.StartTime.TotalSeconds, p.EndTime.TotalSeconds, text, italic, secondary)); + lines.Add(new Line(p.StartTime.TotalSeconds, p.EndTime.TotalSeconds, text, runs, secondary)); + } + } + + /// + /// Strips HTML and ASS tags and ASS drawings. Italic and bold survive as runs, because a + /// tag rarely wraps the whole text: "<i>one</i> two", or one line of two in italic. + /// + internal static string ToPlainText(string text, out StyleRun[] runs) + { + var source = text.Contains("\\p", StringComparison.Ordinal) ? DrawingRegex.Replace(text, string.Empty) : text; + + var plain = new StringBuilder(source.Length); + var styled = new List(); + var italic = false; + var bold = false; + var position = 0; + foreach (Match tag in StyleTagRegex.Matches(source)) + { + AppendPlain(source.Substring(position, tag.Index - position)); + position = tag.Index + tag.Length; + ApplyStyleTag(tag.Value, ref italic, ref bold); + } + + AppendPlain(source.Substring(position)); + + var result = plain.ToString(); + var trimmed = result.Trim(); + var leading = result.Length - result.TrimStart().Length; + runs = styled + .Select(r => ClipRun(r, leading, trimmed.Length)) + .Where(r => r.Length > 0) + .ToArray(); + return trimmed; + + void AppendPlain(string part) + { + var value = HtmlUtil.RemoveHtmlTags(part, true) + .Replace("\\N", Environment.NewLine) + .Replace("\\n", Environment.NewLine) + .Replace("\\h", " "); + if (value.Length == 0) + { + return; + } + + if (italic || bold) + { + var last = styled.Count - 1; + if (last >= 0 && styled[last].Italic == italic && styled[last].Bold == bold && styled[last].Start + styled[last].Length == plain.Length) + { + styled[last] = styled[last] with { Length = styled[last].Length + value.Length }; + } + else + { + styled.Add(new StyleRun(plain.Length, value.Length, italic, bold)); + } + } + + plain.Append(value); } } - /// Strips HTML and ASS tags; italic survives only when it wraps the whole text. - internal static string ToPlainText(string text, out bool italic) + private static StyleRun ClipRun(StyleRun run, int leading, int textLength) { - var trimmed = text.Trim(); - italic = trimmed.StartsWith("", StringComparison.OrdinalIgnoreCase) && - trimmed.EndsWith("", StringComparison.OrdinalIgnoreCase) && - trimmed.IndexOf("", StringComparison.OrdinalIgnoreCase) == trimmed.Length - 4; - if (!italic) + var start = Math.Max(0, run.Start - leading); + var end = Math.Min(textLength, run.Start + run.Length - leading); + return run with { Start = start, Length = end - start }; + } + + private static void ApplyStyleTag(string tag, ref bool italic, ref bool bold) + { + if (tag[0] == '<') { - italic = trimmed.StartsWith("{\\i1}", StringComparison.Ordinal) && !trimmed.Contains("{\\i0}", StringComparison.Ordinal); + var on = tag[1] != '/'; + if (char.ToLowerInvariant(tag[^2]) == 'i') + { + italic = on; + } + else + { + bold = on; + } + + return; } - var plain = HtmlUtil.RemoveHtmlTags(trimmed, true); - plain = plain.Replace("\\N", Environment.NewLine).Replace("\\n", Environment.NewLine); - return plain.Trim(); + foreach (Match match in AssStyleOverrideRegex.Matches(tag)) + { + // \b700 is a weight; \i and \b without a value go back to the style, which is regular here. + var value = match.Groups[2].Value; + var on = value.Length > 0 && value != "0"; + if (match.Groups[1].Value == "i") + { + italic = on; + } + else + { + bold = on; + } + } } /// Lines showing at , in start order. diff --git a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegSoftwareControl.cs b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegSoftwareControl.cs index 5451dae02e4..37e9c03fa02 100644 --- a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegSoftwareControl.cs +++ b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegSoftwareControl.cs @@ -6,6 +6,7 @@ using Avalonia.Platform; using Avalonia.Threading; using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.ValueConverters; using Nikse.SubtitleEdit.UiLogic.Export; using System; using System.Collections.Generic; @@ -237,18 +238,16 @@ private static OverlayCache BuildOverlay(List active var weight = (FontWeight)(style.Bold ? Math.Max(faceWeight, (int)FontWeight.Bold) : faceWeight); var alignment = int.TryParse(style.Alignment, NumberStyles.Integer, CultureInfo.InvariantCulture, out var a) && a is >= 1 and <= 9 ? a : 2; - var primary = string.Join(Environment.NewLine, active.Where(l => !l.Secondary).Select(l => l.Text)); - var secondary = string.Join(Environment.NewLine, active.Where(l => l.Secondary).Select(l => l.Text)); - var primaryItalic = active.Any(l => !l.Secondary) && active.Where(l => !l.Secondary).All(l => l.Italic); - var secondaryItalic = active.Any(l => l.Secondary) && active.Where(l => l.Secondary).All(l => l.Italic); + var primary = active.Where(l => !l.Secondary).ToList(); + var secondary = active.Where(l => l.Secondary).ToList(); var blocks = new List(2); - if (primary.Length > 0) + if (primary.Count > 0) { - blocks.Add(MakeBlock(primary, primaryItalic, alignment)); + blocks.Add(MakeBlock(primary, alignment)); } - if (secondary.Length > 0) + if (secondary.Count > 0) { // The secondary subtitle goes to the top; opposite the primary when that is at the top. var secondaryAlignment = alignment is 7 or 8 or 9 ? alignment - 6 : alignment + 6; @@ -257,20 +256,41 @@ private static OverlayCache BuildOverlay(List active secondaryAlignment = 8; } - blocks.Add(MakeBlock(secondary, secondaryItalic, secondaryAlignment)); + blocks.Add(MakeBlock(secondary, secondaryAlignment)); } return new OverlayCache(active, videoRect, style, blocks, fill, outlinePen, shadow, shadowPen, shadowWidth); - OverlayBlock MakeBlock(string text, bool italic, int numpadAlignment) + OverlayBlock MakeBlock(List lines, int numpadAlignment) { - var typeface = new Typeface(fontFamily, italic ? FontStyle.Italic : FontStyle.Normal, weight); - var formatted = new FormattedText(text, CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, typeface, fontSize, fill) + var text = string.Join(Environment.NewLine, lines.Select(l => l.Text)); + var typeface = new Typeface(fontFamily, FontStyle.Normal, weight); + var flowDirection = TextToFlowDirectionConverter.GetFlowDirection(text); + var formatted = new FormattedText(text, CultureInfo.CurrentUICulture, flowDirection, typeface, fontSize, fill) { TextAlignment = numpadAlignment is 1 or 4 or 7 ? TextAlignment.Left : numpadAlignment is 3 or 6 or 9 ? TextAlignment.Right : TextAlignment.Center, MaxTextWidth = Math.Max(1, videoRect.Width - 2 * margin), }; + var lineStart = 0; + foreach (var line in lines) + { + foreach (var run in line.Runs) + { + if (run.Italic) + { + formatted.SetFontStyle(FontStyle.Italic, lineStart + run.Start, run.Length); + } + + if (run.Bold) + { + formatted.SetFontWeight(FontWeight.Bold, lineStart + run.Start, run.Length); + } + } + + lineStart += line.Text.Length + Environment.NewLine.Length; + } + // Horizontal placement is TextAlignment's job: the text box spans the video width // minus margins, so the origin is always the left margin. var x = videoRect.X + margin; diff --git a/tests/UI/Logic/FfmpegPreviewSubtitleTests.cs b/tests/UI/Logic/FfmpegPreviewSubtitleTests.cs index 6341d22f8c6..ce547dc38ba 100644 --- a/tests/UI/Logic/FfmpegPreviewSubtitleTests.cs +++ b/tests/UI/Logic/FfmpegPreviewSubtitleTests.cs @@ -4,7 +4,7 @@ namespace UITests.Logic; /// -/// The subtitle snapshot the ffmpeg player's control draws: tag stripping, whole-line italic, +/// The subtitle snapshot the ffmpeg player's control draws: tag stripping, italic and bold runs, /// active-line lookup, secondary marking and the SMPTE stretch. /// public class FfmpegPreviewSubtitleTests @@ -23,27 +23,109 @@ private static Subtitle MakeSubtitle(params (double start, double end, string te [Fact] public void ToPlainText_StripsTagsAndKeepsLineBreaks() { - var text = FfmpegPreviewSubtitle.ToPlainText("Hello" + Environment.NewLine + "{\\an8}world", out var italic); + var text = FfmpegPreviewSubtitle.ToPlainText("Hello" + Environment.NewLine + "{\\an8}world", out var runs); Assert.Equal("Hello" + Environment.NewLine + "world", text); - Assert.False(italic); + Assert.Empty(runs); } [Fact] - public void ToPlainText_WholeLineItalic_IsDetected() + public void ToPlainText_WholeLineItalic_IsOneRun() { - var text = FfmpegPreviewSubtitle.ToPlainText("Hello world", out var italic); + var text = FfmpegPreviewSubtitle.ToPlainText("Hello world", out var runs); Assert.Equal("Hello world", text); - Assert.True(italic); + Assert.Equal([new FfmpegPreviewSubtitle.StyleRun(0, 11, true, false)], runs); } [Fact] - public void ToPlainText_PartialItalic_IsNotWholeLineItalic() + public void ToPlainText_PartialItalic_CoversOnlyTheTaggedWords() { - FfmpegPreviewSubtitle.ToPlainText("Hello world", out var italic); + var text = FfmpegPreviewSubtitle.ToPlainText("Hello world", out var runs); - Assert.False(italic); + Assert.Equal("Hello world", text); + Assert.Equal([new FfmpegPreviewSubtitle.StyleRun(0, 5, true, false)], runs); + } + + [Fact] + public void ToPlainText_ItalicOnEachLine_KeepsOneRunPerLine() + { + var text = FfmpegPreviewSubtitle.ToPlainText("one\\Ntwo", out var runs); + + Assert.Equal("one" + Environment.NewLine + "two", text); + Assert.Equal( + [ + new FfmpegPreviewSubtitle.StyleRun(0, 3, true, false), + new FfmpegPreviewSubtitle.StyleRun(3 + Environment.NewLine.Length, 3, true, false), + ], + runs); + } + + [Fact] + public void ToPlainText_AssTags_FollowOnOffAndWeight() + { + var text = FfmpegPreviewSubtitle.ToPlainText("{\\an8\\i1}one {\\i0\\b700}two{\\b0} three", out var runs); + + Assert.Equal("one two three", text); + Assert.Equal( + [ + new FfmpegPreviewSubtitle.StyleRun(0, 4, true, false), + new FfmpegPreviewSubtitle.StyleRun(4, 3, false, true), + ], + runs); + } + + [Fact] + public void ToPlainText_TagsThatOnlyLookLikeStyle_AreIgnored() + { + FfmpegPreviewSubtitle.ToPlainText("{\\blur2\\bord3\\be1\\iclip(0,0,1,1)}Hello", out var runs); + + Assert.Empty(runs); + } + + [Fact] + public void ToPlainText_LeadingSpace_ShiftsRuns() + { + var text = FfmpegPreviewSubtitle.ToPlainText(" Hello ", out var runs); + + Assert.Equal("Hello", text); + Assert.Equal([new FfmpegPreviewSubtitle.StyleRun(0, 5, false, true)], runs); + } + + [Fact] + public void ToPlainText_HardSpace_BecomesSpace() + { + Assert.Equal("a b", FfmpegPreviewSubtitle.ToPlainText("a\\hb", out _)); + } + + [Theory] + [InlineData("{\\p1}m 0 0 l 100 0 100 100 0 100{\\p0}")] + [InlineData("{\\an7\\pos(10,10)\\p2}m 0 0 b 10 0 20 10 20 20 s 30 30 40 40 c")] + public void ToPlainText_Drawing_LeavesNothing(string text) + { + Assert.Equal(string.Empty, FfmpegPreviewSubtitle.ToPlainText(text, out _)); + } + + [Fact] + public void ToPlainText_TextAfterDrawing_IsKept() + { + var text = FfmpegPreviewSubtitle.ToPlainText("{\\p1}m 0 0 l 10 0 10 10{\\p0} Hello", out _); + + Assert.Equal("Hello", text); + } + + [Fact] + public void ToPlainText_PositionTag_IsNotADrawing() + { + Assert.Equal("Hello", FfmpegPreviewSubtitle.ToPlainText("{\\pos(10,10)}Hello", out _)); + } + + [Fact] + public void Build_DrawingOnlyLine_IsSkipped() + { + var preview = FfmpegPreviewSubtitle.Build(MakeSubtitle((1, 3, "{\\p1}m 0 0 b 1 1 2 2 3 3{\\p0}"), (1, 3, "text")), null, false); + + Assert.Equal(["text"], preview.GetActive(2).Select(l => l.Text)); } [Fact] diff --git a/tests/UI/Logic/FfmpegSoftwareControlOverlayTests.cs b/tests/UI/Logic/FfmpegSoftwareControlOverlayTests.cs index 676e6d5cfd1..0ccf45092c9 100644 --- a/tests/UI/Logic/FfmpegSoftwareControlOverlayTests.cs +++ b/tests/UI/Logic/FfmpegSoftwareControlOverlayTests.cs @@ -28,7 +28,7 @@ public void Render_WithoutFile_ClearsLastDrawnLines() { OverlayLinesField.SetValue(control, new List { - new(0, 2, "Still on screen", false, false), + new(0, 2, "Still on screen", [], false), }); control.InvalidateVisual(); From a9da2308221a34f5ed919141546b97872833553b Mon Sep 17 00:00:00 2001 From: niksedk Date: Fri, 30 Oct 2026 10:33:53 +0100 Subject: [PATCH 25/29] FFmpeg player overlay: per-line alignment, secondary style, box, justify, margin area, colors Co-Authored-By: Claude Opus 5.5 --- .../Ffmpeg/FfmpegPreviewSubtitle.cs | 218 ++++++++++++--- .../Ffmpeg/FfmpegSoftwareControl.cs | 252 ++++++++++++------ tests/UI/Logic/FfmpegPreviewSubtitleTests.cs | 101 ++++++- .../FfmpegSoftwareControlOverlayTests.cs | 101 +++++++ 4 files changed, 542 insertions(+), 130 deletions(-) diff --git a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPreviewSubtitle.cs b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPreviewSubtitle.cs index 677a7e5aa38..3ec25bf637f 100644 --- a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPreviewSubtitle.cs +++ b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPreviewSubtitle.cs @@ -1,7 +1,13 @@ +using Avalonia.Media; using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; using Nikse.SubtitleEdit.Logic.Media; +using SkiaSharp; using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text; using System.Text.RegularExpressions; @@ -12,30 +18,112 @@ namespace Nikse.SubtitleEdit.Logic.VideoPlayers.Ffmpeg; /// The subtitle preview for the ffmpeg player, as plain lines with times. mpv and VLC render /// the preview themselves from an ASS file; this player draws it in /// , so the snapshot only keeps what that overlay can show: -/// text with line breaks, italic/bold runs, and whether a line belongs to the secondary -/// subtitle (drawn at the top, the primary at the configured alignment). +/// text with line breaks, italic/bold/color runs, a line's own "\an" alignment, and whether a +/// line belongs to the secondary subtitle (drawn with that subtitle's own style). /// public sealed class FfmpegPreviewSubtitle { - /// A part of shown italic and/or bold. - public readonly record struct StyleRun(int Start, int Length, bool Italic, bool Bold); + /// A part of shown italic, bold and/or in a color of its own. + public readonly record struct StyleRun(int Start, int Length, bool Italic, bool Bold, Color? Color = null); - public sealed record Line(double StartSeconds, double EndSeconds, string Text, StyleRun[] Runs, bool Secondary); + /// The line's own "\an" (1-9), null for the style's alignment. + public sealed record Line(double StartSeconds, double EndSeconds, string Text, StyleRun[] Runs, bool Secondary, int? Alignment = null); + + /// + /// How a block of text is drawn. Sizes are in the units of + /// ( tall). is the + /// ASSA value: 1 outline, 3 a box per line, 4 one box. + /// + public sealed record TextStyle( + string FontName, + decimal FontSize, + bool Bold, + decimal Margin, + Color Primary, + Color Outline, + Color Shadow, + decimal OutlineWidth, + decimal ShadowWidth, + int Alignment, + int BorderStyle, + string Justify) + { + public static TextStyle FromSettings(SeVideo settings) => new( + settings.MpvPreviewFontName, + settings.MpvPreviewFontSize, + settings.MpvPreviewFontBold, + settings.MpvPreviewMargin, + settings.MpvPreviewColorPrimary.FromHexToColor(), + settings.MpvPreviewColorOutline.FromHexToColor(), + settings.MpvPreviewColorShadow.FromHexToColor(), + settings.MpvPreviewOutlineWidth, + settings.MpvPreviewShadowWidth, + ParseAlignment(settings.MpvPreviewAlignment), + settings.MpvPreviewBorderType, + settings.MpvPreviewJustify); + + /// + /// The style the second subtitle was built with, which sits in its own header at its own + /// PlayRes - see . Null when it has no such style. + /// + /// + /// Justify is not in the header: mpv gets it as lines with a baked-in "\pos" (see + /// ), so only the saved setting is known here. + /// + internal static TextStyle? FromSecondary(Subtitle secondary, SeVideo settings) + { + var styleName = secondary.Paragraphs.FirstOrDefault()?.Extra; + if (string.IsNullOrEmpty(styleName) || string.IsNullOrEmpty(secondary.Header) || + !secondary.Header.Contains(styleName, StringComparison.Ordinal)) + { + return null; + } + + var playResY = AdvancedSubStationAlpha.GetTagValueFromHeader("PlayResY", "[Script Info]", secondary.Header); + if (!int.TryParse(playResY, NumberStyles.Integer, CultureInfo.InvariantCulture, out var height) || height <= 0) + { + height = AdvancedSubStationAlpha.DefaultHeight; + } + + var unit = (decimal)AdvancedSubStationAlpha.DefaultHeight / height; + var style = AdvancedSubStationAlpha.GetSsaStyle(styleName, secondary.Header); + return new TextStyle( + style.FontName, + style.FontSize * unit, + style.Bold, + style.MarginVertical * unit, + style.Primary.ToAvaloniaColor(), + style.Outline.ToAvaloniaColor(), + style.Background.ToAvaloniaColor(), + style.OutlineWidth * unit, + style.ShadowWidth * unit, + ParseAlignment(style.Alignment), + int.TryParse(style.BorderStyle, NumberStyles.Integer, CultureInfo.InvariantCulture, out var borderStyle) ? borderStyle : 1, + settings.SecondarySubtitleOverrideStyle ? settings.SecondarySubtitleJustify : "auto"); + } + + private static int ParseAlignment(string? alignment) + { + return int.TryParse(alignment, NumberStyles.Integer, CultureInfo.InvariantCulture, out var a) && a is >= 1 and <= 9 ? a : 2; + } + } // From a \p1 (or higher) block to the \p0 block that ends the drawing, or else the end of the text. private static readonly Regex DrawingRegex = new(@"\{[^{}]*\\p[1-9][^{}]*\}.*?(?=\{[^{}]*\\p0[^{}]*\}|\z)", RegexOptions.Compiled | RegexOptions.Singleline); - private static readonly Regex StyleTagRegex = new(@"|\{\\[^{}]*\}", RegexOptions.Compiled); - private static readonly Regex AssStyleOverrideRegex = new(@"\\([ib])(\d*)(?=[\\}])", RegexOptions.Compiled); + private static readonly Regex StyleTagRegex = new(@"|]*>||\{\\[^{}]*\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex AssStyleOverrideRegex = new(@"\\(?:([ib])(\d*)|1?c(&H[0-9a-fA-F]+&?)?|an([1-9]))(?=[\\}])", RegexOptions.Compiled); + private static readonly Regex FontColorRegex = new(@"color\s*=\s*[""']?([^""'\s>]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); - public static readonly FfmpegPreviewSubtitle Empty = new(Array.Empty()); + public static readonly FfmpegPreviewSubtitle Empty = new(Array.Empty(), null); private readonly Line[] _lines; // sorted by start private readonly double[] _starts; private readonly double[] _maxEndSoFar; // _maxEndSoFar[i] = latest end among _lines[0..i] - private FfmpegPreviewSubtitle(Line[] lines) + private FfmpegPreviewSubtitle(Line[] lines, TextStyle? secondaryStyle) { _lines = lines; + SecondaryStyle = secondaryStyle; _starts = new double[lines.Length]; _maxEndSoFar = new double[lines.Length]; var maxEnd = double.MinValue; @@ -49,6 +137,9 @@ private FfmpegPreviewSubtitle(Line[] lines) public int Count => _lines.Length; + /// The second subtitle's own style, null to draw it like the primary at the opposite edge. + public TextStyle? SecondaryStyle { get; } + /// /// Snapshot of (plus the secondary subtitle when given). The /// subtitle is copied, so the caller can keep editing it; in SMPTE mode the times get the @@ -63,7 +154,8 @@ public static FfmpegPreviewSubtitle Build(Subtitle subtitle, Subtitle? secondary Add(lines, secondary.Paragraphs, true, smpteMode); } - return new FfmpegPreviewSubtitle(lines.OrderBy(l => l.StartSeconds).ToArray()); + var secondaryStyle = secondary != null ? TextStyle.FromSecondary(secondary, Se.Settings.Video) : null; + return new FfmpegPreviewSubtitle(lines.OrderBy(l => l.StartSeconds).ToArray(), secondaryStyle); } private static void Add(List lines, IEnumerable paragraphs, bool secondary, bool smpteMode) @@ -71,34 +163,38 @@ private static void Add(List lines, IEnumerable paragraphs, boo foreach (var paragraph in paragraphs) { var p = smpteMode ? SmptePreviewStretch.Stretched(paragraph) : paragraph; - var text = ToPlainText(p.Text, out var runs); + var text = ToPlainText(p.Text, out var runs, out var alignment); if (string.IsNullOrWhiteSpace(text) || p.EndTime.TotalSeconds <= p.StartTime.TotalSeconds) { continue; } - lines.Add(new Line(p.StartTime.TotalSeconds, p.EndTime.TotalSeconds, text, runs, secondary)); + lines.Add(new Line(p.StartTime.TotalSeconds, p.EndTime.TotalSeconds, text, runs, secondary, alignment)); } } /// - /// Strips HTML and ASS tags and ASS drawings. Italic and bold survive as runs, because a - /// tag rarely wraps the whole text: "<i>one</i> two", or one line of two in italic. + /// Strips HTML and ASS tags and ASS drawings. Italic, bold and color survive as runs, because + /// a tag rarely wraps the whole text: "<i>one</i> two", or one line of two in italic. /// - internal static string ToPlainText(string text, out StyleRun[] runs) + /// + /// The "\an" that places the line. Null when it has none, and for a line with "\pos" or + /// "\move": that "\an" only says how the line hangs from its point, which isn't drawn here + /// (a justified second subtitle is "\an7" lines, which would end up in the top left corner). + /// + internal static string ToPlainText(string text, out StyleRun[] runs, out int? alignment) { var source = text.Contains("\\p", StringComparison.Ordinal) ? DrawingRegex.Replace(text, string.Empty) : text; var plain = new StringBuilder(source.Length); var styled = new List(); - var italic = false; - var bold = false; + var state = new TagState(); var position = 0; foreach (Match tag in StyleTagRegex.Matches(source)) { AppendPlain(source.Substring(position, tag.Index - position)); position = tag.Index + tag.Length; - ApplyStyleTag(tag.Value, ref italic, ref bold); + state.Apply(tag.Value); } AppendPlain(source.Substring(position)); @@ -110,6 +206,9 @@ internal static string ToPlainText(string text, out StyleRun[] runs) .Select(r => ClipRun(r, leading, trimmed.Length)) .Where(r => r.Length > 0) .ToArray(); + alignment = source.Contains("\\pos(", StringComparison.Ordinal) || source.Contains("\\move(", StringComparison.Ordinal) + ? null + : state.Alignment; return trimmed; void AppendPlain(string part) @@ -123,16 +222,17 @@ void AppendPlain(string part) return; } - if (italic || bold) + if (state.Italic || state.Bold || state.Color.HasValue) { var last = styled.Count - 1; - if (last >= 0 && styled[last].Italic == italic && styled[last].Bold == bold && styled[last].Start + styled[last].Length == plain.Length) + if (last >= 0 && styled[last].Italic == state.Italic && styled[last].Bold == state.Bold && + styled[last].Color == state.Color && styled[last].Start + styled[last].Length == plain.Length) { styled[last] = styled[last] with { Length = styled[last].Length + value.Length }; } else { - styled.Add(new StyleRun(plain.Length, value.Length, italic, bold)); + styled.Add(new StyleRun(plain.Length, value.Length, state.Italic, state.Bold, state.Color)); } } @@ -147,35 +247,75 @@ private static StyleRun ClipRun(StyleRun run, int leading, int textLength) return run with { Start = start, Length = end - start }; } - private static void ApplyStyleTag(string tag, ref bool italic, ref bool bold) + /// What the tags so far have switched on. + private sealed class TagState { - if (tag[0] == '<') + private readonly Stack _fontColors = new(); + + public bool Italic { get; private set; } + public bool Bold { get; private set; } + public Color? Color { get; private set; } + public int? Alignment { get; private set; } + + public void Apply(string tag) { - var on = tag[1] != '/'; - if (char.ToLowerInvariant(tag[^2]) == 'i') + if (tag[0] == '{') { - italic = on; + ApplyAssTags(tag); + } + else if (tag.StartsWith("", StringComparison.OrdinalIgnoreCase)) + { + Color = _fontColors.Count > 0 ? _fontColors.Pop() : null; + } + else if (char.ToLowerInvariant(tag[^2]) == 'i') + { + Italic = tag[1] != '/'; } else { - bold = on; + Bold = tag[1] != '/'; } - - return; } - foreach (Match match in AssStyleOverrideRegex.Matches(tag)) + private void ApplyAssTags(string block) { - // \b700 is a weight; \i and \b without a value go back to the style, which is regular here. - var value = match.Groups[2].Value; - var on = value.Length > 0 && value != "0"; - if (match.Groups[1].Value == "i") + foreach (Match match in AssStyleOverrideRegex.Matches(block)) { - italic = on; - } - else - { - bold = on; + if (match.Groups[1].Success) + { + // \b700 is a weight; \i and \b without a value go back to the style, which is regular here. + var value = match.Groups[2].Value; + var on = value.Length > 0 && value != "0"; + if (match.Groups[1].Value == "i") + { + Italic = on; + } + else + { + Bold = on; + } + } + else if (match.Groups[4].Success) + { + // Like libass: the first one counts. + Alignment ??= match.Groups[4].Value[0] - '0'; + } + else + { + // \c without a value goes back to the style's color. + Color = match.Groups[3].Success + ? AdvancedSubStationAlpha.GetSsaColor(match.Groups[3].Value, SKColors.White).ToAvaloniaColor() + : null; + } } } } diff --git a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegSoftwareControl.cs b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegSoftwareControl.cs index 37e9c03fa02..4e491aa6790 100644 --- a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegSoftwareControl.cs +++ b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegSoftwareControl.cs @@ -186,92 +186,118 @@ private void RenderOverlay(DrawingContext context, FfmpegPlayer player, Rect vid return; } - var style = OverlayStyle.FromSettings(Se.Settings.Video); + var settings = Se.Settings.Video; + var style = FfmpegPreviewSubtitle.TextStyle.FromSettings(settings); + var secondaryStyle = player.PreviewSubtitle.SecondaryStyle; + // As mpv's sub-use-margins: the black bars are part of the area the text may use (#13934). + var area = settings.MpvPreviewMarginIsPartOfSubtitleArea ? new Rect(Bounds.Size) : videoRect; var cache = _overlayCache; - if (cache == null || cache.VideoRect != videoRect || cache.Style != style || !SameLines(cache.Lines, active)) + if (cache == null || cache.VideoRect != videoRect || cache.Area != area || cache.Style != style || + cache.SecondaryStyle != secondaryStyle || !SameLines(cache.Lines, active)) { - cache = BuildOverlay(active, videoRect, style); + cache = BuildOverlay(active, videoRect, area, style, secondaryStyle); _overlayCache = cache; } + DrawBlocks(context, cache); + } + + internal static void DrawBlocks(DrawingContext context, OverlayCache cache) + { foreach (var block in cache.Blocks) { + var paint = block.Paint; + if (block.Box != null) + { + context.DrawGeometry(paint.BoxBrush, block.BoxPen, block.Box); + } + if (block.Geometry == null) { context.DrawText(block.Text, block.Origin); continue; } - if (cache.ShadowWidth > 0) + if (paint.ShadowWidth > 0 && paint.BoxBrush == null) { - using (context.PushTransform(Matrix.CreateTranslation(cache.ShadowWidth, cache.ShadowWidth))) + using (context.PushTransform(Matrix.CreateTranslation(paint.ShadowWidth, paint.ShadowWidth))) { - context.DrawGeometry(cache.Shadow, cache.ShadowPen, block.Geometry); + context.DrawGeometry(paint.Shadow, paint.ShadowPen, block.Geometry); } } - if (cache.OutlinePen != null) + if (paint.OutlinePen != null) { - context.DrawGeometry(null, cache.OutlinePen, block.Geometry); + context.DrawGeometry(null, paint.OutlinePen, block.Geometry); } - context.DrawGeometry(cache.Fill, null, block.Geometry); + // One geometry takes one brush, so text with colors of its own is drawn as text. + if (block.HasColors) + { + context.DrawText(block.Text, block.Origin); + } + else + { + context.DrawGeometry(paint.Fill, null, block.Geometry); + } } } - private static OverlayCache BuildOverlay(List active, Rect videoRect, OverlayStyle style) + internal static OverlayCache BuildOverlay( + List active, + Rect videoRect, + Rect area, + FfmpegPreviewSubtitle.TextStyle style, + FfmpegPreviewSubtitle.TextStyle? secondaryStyle) { var scale = videoRect.Height / 720.0; - var fontSize = Math.Max(6, style.FontSize * 1.6 * scale); - var margin = style.Margin * scale; - var outlineWidth = (double)style.OutlineWidth * scale; - var shadowWidth = (double)style.ShadowWidth * scale; - var fill = new SolidColorBrush(style.ColorPrimary.FromHexToColor()); - var outlinePen = outlineWidth > 0 - ? new Pen(new SolidColorBrush(style.ColorOutline.FromHexToColor()), outlineWidth * 2, lineJoin: PenLineJoin.Round) - : null; - var shadow = new SolidColorBrush(style.ColorShadow.FromHexToColor()); - var shadowPen = outlineWidth > 0 ? new Pen(shadow, outlineWidth * 2, lineJoin: PenLineJoin.Round) : null; - // The font name is a libass face name ("Arial Light"); Avalonia matches families, so - // draw the face's family at the face's own weight (issue #15682). - FontFaces.TryGetFamilyAndWeight(style.FontName, out var fontFamily, out var faceWeight); - var weight = (FontWeight)(style.Bold ? Math.Max(faceWeight, (int)FontWeight.Bold) : faceWeight); - var alignment = int.TryParse(style.Alignment, NumberStyles.Integer, CultureInfo.InvariantCulture, out var a) && a is >= 1 and <= 9 ? a : 2; - - var primary = active.Where(l => !l.Secondary).ToList(); - var secondary = active.Where(l => l.Secondary).ToList(); + var paint = MakePaint(style, scale); + var secondaryPaint = secondaryStyle != null ? MakePaint(secondaryStyle, scale) : paint; + // Text placed at the same spot stacks: the primary at the edge, the rest further in. + var stacked = new Dictionary(); var blocks = new List(2); - if (primary.Count > 0) + var groups = active + .GroupBy(l => (l.Secondary, Alignment: l.Alignment ?? DefaultAlignment(l.Secondary))) + .OrderBy(g => g.Key.Secondary); + foreach (var group in groups) { - blocks.Add(MakeBlock(primary, alignment)); + blocks.Add(MakeBlock(group.ToList(), group.Key.Secondary ? secondaryPaint : paint, group.Key.Alignment)); } - if (secondary.Count > 0) + return new OverlayCache(active, videoRect, area, style, secondaryStyle, blocks); + + int DefaultAlignment(bool secondary) { - // The secondary subtitle goes to the top; opposite the primary when that is at the top. - var secondaryAlignment = alignment is 7 or 8 or 9 ? alignment - 6 : alignment + 6; - if (secondaryAlignment is < 1 or > 9) + if (!secondary) { - secondaryAlignment = 8; + return style.Alignment; } - blocks.Add(MakeBlock(secondary, secondaryAlignment)); - } + if (secondaryStyle != null) + { + return secondaryStyle.Alignment; + } - return new OverlayCache(active, videoRect, style, blocks, fill, outlinePen, shadow, shadowPen, shadowWidth); + // No style of its own: the top, or opposite the primary when that is at the top. + var opposite = style.Alignment is 7 or 8 or 9 ? style.Alignment - 6 : style.Alignment + 6; + return opposite is < 1 or > 9 ? 8 : opposite; + } - OverlayBlock MakeBlock(List lines, int numpadAlignment) + OverlayBlock MakeBlock(List lines, OverlayPaint blockPaint, int numpadAlignment) { var text = string.Join(Environment.NewLine, lines.Select(l => l.Text)); - var typeface = new Typeface(fontFamily, FontStyle.Normal, weight); - var flowDirection = TextToFlowDirectionConverter.GetFlowDirection(text); - var formatted = new FormattedText(text, CultureInfo.CurrentUICulture, flowDirection, typeface, fontSize, fill) + var margin = blockPaint.Margin; + var left = area.X + margin; + var available = Math.Max(1, area.Width - 2 * margin); + var horizontal = numpadAlignment is 1 or 4 or 7 ? TextAlignment.Left : numpadAlignment is 3 or 6 or 9 ? TextAlignment.Right : TextAlignment.Center; + var formatted = new FormattedText(text, CultureInfo.CurrentUICulture, TextToFlowDirectionConverter.GetFlowDirection(text), blockPaint.Typeface, blockPaint.FontSize, blockPaint.Fill) { - TextAlignment = numpadAlignment is 1 or 4 or 7 ? TextAlignment.Left : numpadAlignment is 3 or 6 or 9 ? TextAlignment.Right : TextAlignment.Center, - MaxTextWidth = Math.Max(1, videoRect.Width - 2 * margin), + TextAlignment = horizontal, + MaxTextWidth = available, }; + var hasColors = false; var lineStart = 0; foreach (var line in lines) { @@ -286,64 +312,130 @@ OverlayBlock MakeBlock(List lines, int numpadAlignme { formatted.SetFontWeight(FontWeight.Bold, lineStart + run.Start, run.Length); } + + if (run.Color.HasValue) + { + formatted.SetForegroundBrush(new SolidColorBrush(run.Color.Value), lineStart + run.Start, run.Length); + hasColors = true; + } } lineStart += line.Text.Length + Environment.NewLine.Length; } // Horizontal placement is TextAlignment's job: the text box spans the video width - // minus margins, so the origin is always the left margin. - var x = videoRect.X + margin; + // minus margins, so the origin is always the left margin. With justify the box is + // as wide as the text and goes where the alignment says; the justify then only + // moves the shorter lines inside it. + var x = left; + var justify = GetJustify(blockPaint.Justify); + if (justify.HasValue) + { + var width = Math.Min(available, Math.Ceiling(formatted.WidthIncludingTrailingWhitespace) + 1); + formatted.MaxTextWidth = width; + formatted.TextAlignment = justify.Value; + x = horizontal switch + { + TextAlignment.Left => left, + TextAlignment.Right => left + available - width, + _ => left + (available - width) / 2, + }; + } + + stacked.TryGetValue(numpadAlignment, out var stack); + stacked[numpadAlignment] = stack + formatted.Height; var y = numpadAlignment switch { - 7 or 8 or 9 => videoRect.Y + margin, - 4 or 5 or 6 => videoRect.Y + (videoRect.Height - formatted.Height) / 2, - _ => videoRect.Bottom - margin - formatted.Height, + 7 or 8 or 9 => area.Y + margin + stack, + 4 or 5 or 6 => area.Y + (area.Height - formatted.Height) / 2 + stack, + _ => area.Bottom - margin - formatted.Height - stack, }; var origin = new Point(x, y); - return new OverlayBlock(formatted, origin, formatted.BuildGeometry(origin)); + Geometry? box = null; + IPen? boxPen = null; + if (blockPaint.BoxBrush != null) + { + var highlight = formatted.BuildHighlightGeometry(origin, 0, text.Length); + if (blockPaint.OneBox) + { + box = new RectangleGeometry(highlight.Bounds.Inflate(blockPaint.BoxPadding)); + } + else + { + box = highlight; + boxPen = new Pen(blockPaint.BoxBrush, blockPaint.BoxPadding * 2); + } + } + + return new OverlayBlock(formatted, origin, formatted.BuildGeometry(origin), blockPaint, hasColors, box, boxPen); } } - /// The preview settings the overlay is drawn with - a change means a rebuild. - private readonly record struct OverlayStyle( - string FontName, - int FontSize, - bool Bold, - int Margin, - string ColorPrimary, - string ColorOutline, - string ColorShadow, - decimal OutlineWidth, - decimal ShadowWidth, - string Alignment) + private static TextAlignment? GetJustify(string? justify) { - public static OverlayStyle FromSettings(SeVideo settings) => new( - settings.MpvPreviewFontName, - settings.MpvPreviewFontSize, - settings.MpvPreviewFontBold, - settings.MpvPreviewMargin, - settings.MpvPreviewColorPrimary, - settings.MpvPreviewColorOutline, - settings.MpvPreviewColorShadow, - settings.MpvPreviewOutlineWidth, - settings.MpvPreviewShadowWidth, - settings.MpvPreviewAlignment); + return justify switch + { + "left" => TextAlignment.Left, + "center" => TextAlignment.Center, + "right" => TextAlignment.Right, + _ => null, + }; } - private sealed record OverlayBlock(FormattedText Text, Point Origin, Geometry? Geometry); + private static OverlayPaint MakePaint(FfmpegPreviewSubtitle.TextStyle style, double scale) + { + var outlineWidth = (double)style.OutlineWidth * scale; + var shadowWidth = (double)style.ShadowWidth * scale; + var fill = new SolidColorBrush(style.Primary); + var shadow = new SolidColorBrush(style.Shadow); + var isBox = style.BorderStyle is 3 or 4; - private sealed record OverlayCache( - List Lines, - Rect VideoRect, - OverlayStyle Style, - List Blocks, + // The font name is a libass face name ("Arial Light"); Avalonia matches families, so + // draw the face's family at the face's own weight (issue #15682). + FontFaces.TryGetFamilyAndWeight(style.FontName, out var fontFamily, out var faceWeight); + var weight = (FontWeight)(style.Bold ? Math.Max(faceWeight, (int)FontWeight.Bold) : faceWeight); + + return new OverlayPaint( + new Typeface(fontFamily, FontStyle.Normal, weight), + Math.Max(6, (double)style.FontSize * 1.6 * scale), + (double)style.Margin * scale, + style.Justify, + fill, + !isBox && outlineWidth > 0 ? new Pen(new SolidColorBrush(style.Outline), outlineWidth * 2, lineJoin: PenLineJoin.Round) : null, + shadow, + outlineWidth > 0 ? new Pen(shadow, outlineWidth * 2, lineJoin: PenLineJoin.Round) : null, + shadowWidth, + // As libass: a box per line is in the outline color, one box in the shadow color. + isBox ? style.BorderStyle == 4 ? shadow : new SolidColorBrush(style.Outline) : null, + style.BorderStyle == 4, + Math.Max(1, Math.Max(outlineWidth, shadowWidth))); + } + + /// What a style turns into at the current video size. + internal sealed record OverlayPaint( + Typeface Typeface, + double FontSize, + double Margin, + string? Justify, IBrush Fill, IPen? OutlinePen, IBrush Shadow, IPen? ShadowPen, - double ShadowWidth); + double ShadowWidth, + IBrush? BoxBrush, + bool OneBox, + double BoxPadding); + + internal sealed record OverlayBlock(FormattedText Text, Point Origin, Geometry? Geometry, OverlayPaint Paint, bool HasColors, Geometry? Box, IPen? BoxPen); + + internal sealed record OverlayCache( + List Lines, + Rect VideoRect, + Rect Area, + FfmpegPreviewSubtitle.TextStyle Style, + FfmpegPreviewSubtitle.TextStyle? SecondaryStyle, + List Blocks); /// The largest rectangle of the given aspect ratio centered in . internal static Rect FitRect(Rect bounds, double aspectRatio) diff --git a/tests/UI/Logic/FfmpegPreviewSubtitleTests.cs b/tests/UI/Logic/FfmpegPreviewSubtitleTests.cs index ce547dc38ba..a485422670c 100644 --- a/tests/UI/Logic/FfmpegPreviewSubtitleTests.cs +++ b/tests/UI/Logic/FfmpegPreviewSubtitleTests.cs @@ -1,4 +1,7 @@ +using Avalonia.Media; using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Features.Video.OpenFromUrl; using Nikse.SubtitleEdit.Logic.VideoPlayers.Ffmpeg; namespace UITests.Logic; @@ -23,7 +26,7 @@ private static Subtitle MakeSubtitle(params (double start, double end, string te [Fact] public void ToPlainText_StripsTagsAndKeepsLineBreaks() { - var text = FfmpegPreviewSubtitle.ToPlainText("Hello" + Environment.NewLine + "{\\an8}world", out var runs); + var text = FfmpegPreviewSubtitle.ToPlainText("Hello" + Environment.NewLine + "{\\an8}world", out var runs, out _); Assert.Equal("Hello" + Environment.NewLine + "world", text); Assert.Empty(runs); @@ -32,7 +35,7 @@ public void ToPlainText_StripsTagsAndKeepsLineBreaks() [Fact] public void ToPlainText_WholeLineItalic_IsOneRun() { - var text = FfmpegPreviewSubtitle.ToPlainText("Hello world", out var runs); + var text = FfmpegPreviewSubtitle.ToPlainText("Hello world", out var runs, out _); Assert.Equal("Hello world", text); Assert.Equal([new FfmpegPreviewSubtitle.StyleRun(0, 11, true, false)], runs); @@ -41,7 +44,7 @@ public void ToPlainText_WholeLineItalic_IsOneRun() [Fact] public void ToPlainText_PartialItalic_CoversOnlyTheTaggedWords() { - var text = FfmpegPreviewSubtitle.ToPlainText("Hello world", out var runs); + var text = FfmpegPreviewSubtitle.ToPlainText("Hello world", out var runs, out _); Assert.Equal("Hello world", text); Assert.Equal([new FfmpegPreviewSubtitle.StyleRun(0, 5, true, false)], runs); @@ -50,7 +53,7 @@ public void ToPlainText_PartialItalic_CoversOnlyTheTaggedWords() [Fact] public void ToPlainText_ItalicOnEachLine_KeepsOneRunPerLine() { - var text = FfmpegPreviewSubtitle.ToPlainText("one\\Ntwo", out var runs); + var text = FfmpegPreviewSubtitle.ToPlainText("one\\Ntwo", out var runs, out _); Assert.Equal("one" + Environment.NewLine + "two", text); Assert.Equal( @@ -64,7 +67,7 @@ public void ToPlainText_ItalicOnEachLine_KeepsOneRunPerLine() [Fact] public void ToPlainText_AssTags_FollowOnOffAndWeight() { - var text = FfmpegPreviewSubtitle.ToPlainText("{\\an8\\i1}one {\\i0\\b700}two{\\b0} three", out var runs); + var text = FfmpegPreviewSubtitle.ToPlainText("{\\an8\\i1}one {\\i0\\b700}two{\\b0} three", out var runs, out _); Assert.Equal("one two three", text); Assert.Equal( @@ -78,7 +81,7 @@ public void ToPlainText_AssTags_FollowOnOffAndWeight() [Fact] public void ToPlainText_TagsThatOnlyLookLikeStyle_AreIgnored() { - FfmpegPreviewSubtitle.ToPlainText("{\\blur2\\bord3\\be1\\iclip(0,0,1,1)}Hello", out var runs); + FfmpegPreviewSubtitle.ToPlainText("{\\blur2\\bord3\\be1\\iclip(0,0,1,1)}Hello", out var runs, out _); Assert.Empty(runs); } @@ -86,7 +89,7 @@ public void ToPlainText_TagsThatOnlyLookLikeStyle_AreIgnored() [Fact] public void ToPlainText_LeadingSpace_ShiftsRuns() { - var text = FfmpegPreviewSubtitle.ToPlainText(" Hello ", out var runs); + var text = FfmpegPreviewSubtitle.ToPlainText(" Hello ", out var runs, out _); Assert.Equal("Hello", text); Assert.Equal([new FfmpegPreviewSubtitle.StyleRun(0, 5, false, true)], runs); @@ -95,7 +98,7 @@ public void ToPlainText_LeadingSpace_ShiftsRuns() [Fact] public void ToPlainText_HardSpace_BecomesSpace() { - Assert.Equal("a b", FfmpegPreviewSubtitle.ToPlainText("a\\hb", out _)); + Assert.Equal("a b", FfmpegPreviewSubtitle.ToPlainText("a\\hb", out _, out _)); } [Theory] @@ -103,13 +106,13 @@ public void ToPlainText_HardSpace_BecomesSpace() [InlineData("{\\an7\\pos(10,10)\\p2}m 0 0 b 10 0 20 10 20 20 s 30 30 40 40 c")] public void ToPlainText_Drawing_LeavesNothing(string text) { - Assert.Equal(string.Empty, FfmpegPreviewSubtitle.ToPlainText(text, out _)); + Assert.Equal(string.Empty, FfmpegPreviewSubtitle.ToPlainText(text, out _, out _)); } [Fact] public void ToPlainText_TextAfterDrawing_IsKept() { - var text = FfmpegPreviewSubtitle.ToPlainText("{\\p1}m 0 0 l 10 0 10 10{\\p0} Hello", out _); + var text = FfmpegPreviewSubtitle.ToPlainText("{\\p1}m 0 0 l 10 0 10 10{\\p0} Hello", out _, out _); Assert.Equal("Hello", text); } @@ -117,7 +120,83 @@ public void ToPlainText_TextAfterDrawing_IsKept() [Fact] public void ToPlainText_PositionTag_IsNotADrawing() { - Assert.Equal("Hello", FfmpegPreviewSubtitle.ToPlainText("{\\pos(10,10)}Hello", out _)); + Assert.Equal("Hello", FfmpegPreviewSubtitle.ToPlainText("{\\pos(10,10)}Hello", out _, out _)); + } + + [Theory] + [InlineData("{\\an8}Hello", 8)] + [InlineData("{\\an8}{\\an2}Hello", 8)] + [InlineData("Hello", null)] + [InlineData("{\\an7\\pos(10,10)}Hello", null)] + [InlineData("{\\an7\\move(1,2,3,4)}Hello", null)] + public void ToPlainText_Alignment(string text, int? expected) + { + FfmpegPreviewSubtitle.ToPlainText(text, out _, out var alignment); + + Assert.Equal(expected, alignment); + } + + [Fact] + public void ToPlainText_FontColor_IsARun_AndClosesToTheOuterColor() + { + var text = FfmpegPreviewSubtitle.ToPlainText("abcd", out var runs, out _); + + Assert.Equal("abcd", text); + Assert.Equal( + [ + new FfmpegPreviewSubtitle.StyleRun(0, 1, false, false, Colors.Red), + new FfmpegPreviewSubtitle.StyleRun(1, 1, false, false, Color.Parse("#00ff00")), + new FfmpegPreviewSubtitle.StyleRun(2, 1, false, false, Colors.Red), + ], + runs); + } + + [Fact] + public void ToPlainText_AssColor_IsBlueGreenRed_AndCResets() + { + var text = FfmpegPreviewSubtitle.ToPlainText("{\\c&H0000FF&}red{\\c} plain {\\1c&HFF0000&}blue", out var runs, out _); + + Assert.Equal("red plain blue", text); + Assert.Equal( + [ + new FfmpegPreviewSubtitle.StyleRun(0, 3, false, false, Colors.Red), + new FfmpegPreviewSubtitle.StyleRun(10, 4, false, false, Color.Parse("#0000ff")), + ], + runs); + } + + [Fact] + public void ToPlainText_ClipIsNotAColor() + { + FfmpegPreviewSubtitle.ToPlainText("{\\clip(0,0,1,1)\\3c&H0000FF&}Hello", out var runs, out _); + + Assert.Empty(runs); + } + + [Fact] + public void Build_SecondaryHeaderStyle_IsScaledToPreviewUnits() + { + var style = new SsaStyle { Name = "Second", FontName = "Georgia", FontSize = 150, Alignment = "4", MarginVertical = 75, OutlineWidth = 15, ShadowWidth = 0, Bold = true, BorderStyle = "3" }; + var secondary = MakeSubtitle((1, 3, "second")); + SecondarySubtitleStyler.SetHeader(secondary, style, 1920, 1080); + secondary.Paragraphs[0].Extra = "Second"; + + var result = FfmpegPreviewSubtitle.Build(MakeSubtitle((1, 3, "main")), secondary, false).SecondaryStyle; + + Assert.NotNull(result); + Assert.Equal("Georgia", result.FontName); + Assert.Equal(150m * 288 / 1080, result.FontSize, 3); + Assert.Equal(4, result.Alignment); + Assert.Equal(3, result.BorderStyle); + Assert.True(result.Bold); + } + + [Fact] + public void Build_SecondaryWithoutStyle_HasNoSecondaryStyle() + { + var preview = FfmpegPreviewSubtitle.Build(MakeSubtitle((1, 3, "main")), MakeSubtitle((1, 3, "second")), false); + + Assert.Null(preview.SecondaryStyle); } [Fact] diff --git a/tests/UI/Logic/FfmpegSoftwareControlOverlayTests.cs b/tests/UI/Logic/FfmpegSoftwareControlOverlayTests.cs index 0ccf45092c9..c765db49170 100644 --- a/tests/UI/Logic/FfmpegSoftwareControlOverlayTests.cs +++ b/tests/UI/Logic/FfmpegSoftwareControlOverlayTests.cs @@ -1,6 +1,8 @@ +using Avalonia; using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Headless.XUnit; +using Avalonia.Media; using Avalonia.Threading; using Nikse.SubtitleEdit.Logic.VideoPlayers.Ffmpeg; using System.Reflection; @@ -43,4 +45,103 @@ public void Render_WithoutFile_ClearsLastDrawnLines() window.Close(); } } + + private static readonly Rect VideoRect = new(0, 100, 1280, 720); + private static readonly Rect Window = new(0, 0, 1280, 920); + + private static FfmpegPreviewSubtitle.TextStyle Style(int alignment = 2, int borderStyle = 1, string justify = "auto") => + new("Arial", 20, false, 10, Colors.White, Colors.Black, Colors.Gray, 2, 1, alignment, borderStyle, justify); + + private static FfmpegPreviewSubtitle.Line Line(string text, bool secondary = false, int? alignment = null, FfmpegPreviewSubtitle.StyleRun[]? runs = null) => + new(0, 2, text, runs ?? [], secondary, alignment); + + [AvaloniaFact] + public void BuildOverlay_LineWithOwnAlignment_GetsItsOwnBlock() + { + var blocks = FfmpegSoftwareControl.BuildOverlay([Line("bottom"), Line("top", alignment: 8)], VideoRect, VideoRect, Style(), null).Blocks; + + Assert.Equal(2, blocks.Count); + Assert.True(blocks[1].Origin.Y < blocks[0].Origin.Y); + } + + [AvaloniaFact] + public void BuildOverlay_SameSpot_Stacks() + { + var secondary = Style() with { FontSize = 40 }; + + var blocks = FfmpegSoftwareControl.BuildOverlay([Line("second", secondary: true), Line("main")], VideoRect, VideoRect, Style(), secondary).Blocks; + + Assert.True(blocks[0].Paint.FontSize < blocks[1].Paint.FontSize); // the primary comes first + Assert.Equal(blocks[0].Origin.Y - blocks[1].Text.Height, blocks[1].Origin.Y, 3); + } + + [AvaloniaFact] + public void BuildOverlay_SecondaryStyle_SetsItsPlaceAndSize() + { + var secondary = Style(alignment: 7) with { FontSize = 40 }; + + var blocks = FfmpegSoftwareControl.BuildOverlay([Line("main"), Line("second", secondary: true)], VideoRect, VideoRect, Style(), secondary).Blocks; + + Assert.True(blocks[1].Origin.Y < blocks[0].Origin.Y); + Assert.Equal(blocks[0].Paint.FontSize * 2, blocks[1].Paint.FontSize, 3); + } + + [AvaloniaFact] + public void BuildOverlay_SecondaryWithoutStyle_GoesOppositeThePrimary() + { + var blocks = FfmpegSoftwareControl.BuildOverlay([Line("main"), Line("second", secondary: true)], VideoRect, VideoRect, Style(), null).Blocks; + + Assert.True(blocks[1].Origin.Y < blocks[0].Origin.Y); + } + + [AvaloniaFact] + public void BuildOverlay_MarginInBars_MeasuresFromTheWindow() + { + var inside = FfmpegSoftwareControl.BuildOverlay([Line("main")], VideoRect, VideoRect, Style(), null).Blocks[0]; + var bars = FfmpegSoftwareControl.BuildOverlay([Line("main")], VideoRect, Window, Style(), null).Blocks[0]; + + Assert.Equal(inside.Origin.Y + 100, bars.Origin.Y, 3); + } + + [AvaloniaFact] + public void BuildOverlay_Justify_KeepsBlockAtAlignment_AndMovesShortLine() + { + var text = "a much longer first line" + Environment.NewLine + "short"; + var block = FfmpegSoftwareControl.BuildOverlay([Line(text)], VideoRect, VideoRect, Style(justify: "left"), null).Blocks[0]; + + var width = block.Text.MaxTextWidth; + Assert.Equal(TextAlignment.Left, block.Text.TextAlignment); + Assert.Equal((VideoRect.Width - width) / 2, block.Origin.X, 3); + } + + [AvaloniaFact] + public void BuildOverlay_Box_ReplacesOutline() + { + var perLine = FfmpegSoftwareControl.BuildOverlay([Line("main")], VideoRect, VideoRect, Style(borderStyle: 3), null).Blocks[0]; + var oneBox = FfmpegSoftwareControl.BuildOverlay([Line("main")], VideoRect, VideoRect, Style(borderStyle: 4), null).Blocks[0]; + var outline = FfmpegSoftwareControl.BuildOverlay([Line("main")], VideoRect, VideoRect, Style(), null).Blocks[0]; + + Assert.NotNull(perLine.Box); + Assert.Null(perLine.Paint.OutlinePen); + Assert.NotNull(oneBox.Box); + Assert.Null(outline.Box); + Assert.NotNull(outline.Paint.OutlinePen); + } + + [AvaloniaFact] + public void BuildOverlay_RightToLeftText_FlowsRightToLeft() + { + var block = FfmpegSoftwareControl.BuildOverlay([Line("مرحبا!")], VideoRect, VideoRect, Style(), null).Blocks[0]; + + Assert.Equal(FlowDirection.RightToLeft, block.Text.FlowDirection); + } + + [AvaloniaFact] + public void BuildOverlay_ColorRun_IsDrawnAsText() + { + var run = new FfmpegPreviewSubtitle.StyleRun(0, 2, false, false, Colors.Red); + + Assert.True(FfmpegSoftwareControl.BuildOverlay([Line("main", runs: [run])], VideoRect, VideoRect, Style(), null).Blocks[0].HasColors); + Assert.False(FfmpegSoftwareControl.BuildOverlay([Line("main")], VideoRect, VideoRect, Style(), null).Blocks[0].HasColors); + } } From ed4ba5b4d56ff8f4248e291b0f8c3bbe2cbb9fe8 Mon Sep 17 00:00:00 2001 From: niksedk Date: Fri, 30 Oct 2026 13:52:58 +0100 Subject: [PATCH 26/29] llama.cpp: pick CPU or Vulkan on Linux, switch builds from engine settings Linux always installed the CPU build, so -ngl had no effect. The build picker now also shows on Linux, and on every re-download from the engine settings dialogs on Windows and Linux, so an installed build can be changed. Automatic updates keep the installed build. A switch now removes the previous build's GPU backend files, which unpacking would otherwise leave behind. The Linux build is detected from libggml-vulkan.so for the Backend label and updates. Picker idea, Linux test and doc section from SMAP-git in #16488. Co-Authored-By: Claude Opus 5.5 --- docs/features/auto-translate-advanced.md | 10 ++- .../Main/AiAssistant/AiAssistantViewModel.cs | 8 +-- src/ui/Features/Ocr/OcrViewModel.cs | 10 +-- .../Tools/AiReview/AiReviewViewModel.cs | 8 +-- .../BatchConvert/BatchConvertViewModel.cs | 8 +-- .../Translate/AutoTranslateViewModel.cs | 23 +++--- .../Translate/DownloadLlamaCppViewModel.cs | 3 +- .../Translate/LlamaCppDownloadHelper.cs | 10 +-- .../LlamaCppEngineSettingsViewModel.cs | 14 ++-- .../Video/VideoOcr/VideoOcrViewModel.cs | 10 +-- src/ui/Logic/Download/DownloadHashManager.cs | 15 ++++ .../Logic/Download/LlamaCppDownloadService.cs | 27 +++++++ .../Translate/LlamaCppDownloadHelperTests.cs | 68 ++++++++++++++++++ .../Download/LlamaCppCudaVariantTests.cs | 70 +++++++++++++++++++ 14 files changed, 227 insertions(+), 57 deletions(-) create mode 100644 tests/UI/Features/Translate/LlamaCppDownloadHelperTests.cs diff --git a/docs/features/auto-translate-advanced.md b/docs/features/auto-translate-advanced.md index 2106c8085e7..89717b6897b 100644 --- a/docs/features/auto-translate-advanced.md +++ b/docs/features/auto-translate-advanced.md @@ -42,4 +42,12 @@ For the local llama.cpp engines, the gear button opens an info dialog about the - **Release** — The llama.cpp release Subtitle Edit is pinned to - **Install folder** — Where the server binaries live -Use **Download** / **Update** / **Re-download...** to (re)install the server build, for example after a graphics driver change. The same dialog is used by [AI Review](ai-review.md) and the [AI Assistant](ai-assistant.md). +Use **Download** / **Update** / **Re-download...** to (re)install the server build, for example after a graphics driver change. On Windows and Linux you pick the build each time, so this is also how to switch between CPU and GPU. The same dialog is used by [AI Review](ai-review.md) and the [AI Assistant](ai-assistant.md). + +### GPU acceleration + +Pick **Vulkan (GPU)** instead of **CPU** to run llama.cpp on the GPU. Vulkan works with NVIDIA, AMD and Intel GPUs that have Vulkan drivers installed. On Windows, **CUDA (NVIDIA GPU)** is also available. + +If llama.cpp is already installed as a CPU build, click the gear next to the engine, click **Re-download...** in the engine settings, and pick the GPU build. **Backend** shows which build is installed. The **Re-download...** button next to the model only downloads the model again. + +Subtitle Edit offloads all model layers to the GPU, so the model should fit in the GPU memory. For a model that doesn't fit, add `-ngl N` under **Advanced...** to offload only N layers. If **Use only these parameters** is on, your parameters decide this. diff --git a/src/ui/Features/Main/AiAssistant/AiAssistantViewModel.cs b/src/ui/Features/Main/AiAssistant/AiAssistantViewModel.cs index ecf9447bdcd..467564065c7 100644 --- a/src/ui/Features/Main/AiAssistant/AiAssistantViewModel.cs +++ b/src/ui/Features/Main/AiAssistant/AiAssistantViewModel.cs @@ -204,17 +204,11 @@ private async Task RedownloadLlamaCppEngineAsync() LlamaCppServerManager.StopServer(); - // Reuse the installed backend so the user is not re-asked CPU/Vulkan/CUDA on a re-download; - // null on a fresh install (or off Windows), which lets DownloadAsync prompt. - var variant = OperatingSystem.IsWindows() - ? DownloadHashManager.DetectLlamaCppWindowsVariant(LlamaCppServerManager.GetAndCreateFolder()) - : null; - + // No forced variant: the user picks CPU/Vulkan/CUDA, which is how to change the installed build. await LlamaCppDownloadHelper.DownloadAsync( Window, _windowService, SelectedLlamaCppModel?.Model, - variant, forceEngineDownload: true); RefreshLlamaCppModels(); diff --git a/src/ui/Features/Ocr/OcrViewModel.cs b/src/ui/Features/Ocr/OcrViewModel.cs index 597f6de7adc..16898bb849e 100644 --- a/src/ui/Features/Ocr/OcrViewModel.cs +++ b/src/ui/Features/Ocr/OcrViewModel.cs @@ -1545,15 +1545,9 @@ private async Task UpdateLlamaCppOcrEngineAsync() LlamaCppServerManager.StopServer(); UpdateLlamaCppOcrServerButtonText(); - // Re-download the same backend that is installed (CPU/Vulkan/CUDA all unpack into one - // folder); when nothing is installed yet, DownloadAsync falls back to asking the user. - var folder = LlamaCppServerManager.GetAndCreateFolder(); - var variant = LlamaCppServerManager.IsEngineInstalled() && OperatingSystem.IsWindows() - ? DownloadHashManager.DetectLlamaCppWindowsVariant(folder) - : null; - + // No forced variant: the user picks CPU/Vulkan/CUDA, which is how to change the installed build. var model = SelectedLlamaCppOcrModel?.Model; - var downloaded = await LlamaCppDownloadHelper.DownloadAsync(Window, _windowService, model, variant, forceEngineDownload: true); + var downloaded = await LlamaCppDownloadHelper.DownloadAsync(Window, _windowService, model, forceEngineDownload: true); if (downloaded != null) { var selectName = string.IsNullOrEmpty(downloaded) ? model?.FileName : downloaded; diff --git a/src/ui/Features/Tools/AiReview/AiReviewViewModel.cs b/src/ui/Features/Tools/AiReview/AiReviewViewModel.cs index 64b4ad2341f..e04bff7df24 100644 --- a/src/ui/Features/Tools/AiReview/AiReviewViewModel.cs +++ b/src/ui/Features/Tools/AiReview/AiReviewViewModel.cs @@ -320,17 +320,11 @@ private async Task RedownloadLlamaCppEngineAsync() LlamaCppServerManager.StopServer(); UpdateLlamaCppServerButtonText(); - // Reuse the installed backend so the user is not re-asked CPU/Vulkan/CUDA on a re-download; - // null on a fresh install (or off Windows), which lets DownloadAsync prompt. - var variant = OperatingSystem.IsWindows() - ? DownloadHashManager.DetectLlamaCppWindowsVariant(LlamaCppServerManager.GetAndCreateFolder()) - : null; - + // No forced variant: the user picks CPU/Vulkan/CUDA, which is how to change the installed build. await LlamaCppDownloadHelper.DownloadAsync( Window, _windowService, SelectedLlamaCppModel?.Model, - variant, forceEngineDownload: true); RefreshLlamaCppModels(); diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs index 6696c489b69..ff24ead6107 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs @@ -3143,17 +3143,11 @@ private async Task RedownloadLlamaCppEngineAsync() LlamaCppServerManager.StopServer(); - // Reuse the installed backend so the user is not re-asked CPU/Vulkan/CUDA on a re-download; - // null on a fresh install (or off Windows), which lets DownloadAsync prompt. - var variant = OperatingSystem.IsWindows() - ? DownloadHashManager.DetectLlamaCppWindowsVariant(LlamaCppServerManager.GetAndCreateFolder()) - : null; - + // No forced variant: the user picks CPU/Vulkan/CUDA, which is how to change the installed build. await LlamaCppDownloadHelper.DownloadAsync( Window, _windowService, SelectedLlamaCppModel?.Model, - variant, forceEngineDownload: true); PopulateLlamaCppModels(); diff --git a/src/ui/Features/Translate/AutoTranslateViewModel.cs b/src/ui/Features/Translate/AutoTranslateViewModel.cs index 77f2bd09c71..777b0907088 100644 --- a/src/ui/Features/Translate/AutoTranslateViewModel.cs +++ b/src/ui/Features/Translate/AutoTranslateViewModel.cs @@ -1107,14 +1107,9 @@ private async Task ShowLlamaCppEngineSettings() return; } - // Pass the key of the outdated install when there is one, so the re-download picks the same - // backend. Otherwise pass empty: UpdateLlamaCppEngineAsync then falls back to detecting the - // variant from the install folder, and to asking the user when nothing is installed yet. - var installedKey = GetLlamaCppEngineUpdate()?.key ?? string.Empty; - await _windowService.ShowDialogAsync( Window!, - vm => vm.Initialize(() => UpdateLlamaCppEngineAsync(installedKey))); + vm => vm.Initialize(() => UpdateLlamaCppEngineAsync(string.Empty, askForBuild: true))); RefreshDownloadDots?.Invoke(); RefreshEngineUpdateButton(); @@ -1289,8 +1284,9 @@ private async Task CheckLlamaCppForUpdateAsync() /// /// Stops the running llama-server (it holds the binary open and would keep serving a stale build), /// re-downloads the matching llama.cpp build, and refreshes the model list and status indicators. + /// With the user picks the build instead, so it can be changed. /// - private async Task UpdateLlamaCppEngineAsync(string installedKey) + private async Task UpdateLlamaCppEngineAsync(string installedKey, bool askForBuild = false) { if (Window == null) { @@ -1301,8 +1297,7 @@ private async Task UpdateLlamaCppEngineAsync(string installedKey) UpdateLlamaCppServerButtonText(); var folder = LlamaCppServerManager.GetAndCreateFolder(); - var variant = DownloadHashManager.GetLlamaCppWindowsVariant(installedKey) - ?? (OperatingSystem.IsWindows() ? DownloadHashManager.DetectLlamaCppWindowsVariant(folder) : null); + var variant = askForBuild ? null : DownloadHashManager.GetLlamaCppWindowsVariant(installedKey) ?? DetectInstalledLlamaCppVariant(folder); var model = SelectedLlamaCppModel?.Model; var downloaded = await LlamaCppDownloadHelper.DownloadAsync(Window, _windowService, model, variant, forceEngineDownload: true); @@ -1317,6 +1312,16 @@ private async Task UpdateLlamaCppEngineAsync(string installedKey) RefreshEngineUpdateButton(); } + private static string? DetectInstalledLlamaCppVariant(string folder) + { + if (OperatingSystem.IsWindows()) + { + return DownloadHashManager.DetectLlamaCppWindowsVariant(folder); + } + + return OperatingSystem.IsLinux() ? DownloadHashManager.DetectLlamaCppLinuxVariant(folder) : null; + } + private static (string key, string hash)? TryReadLlamaCppSidecarHash(string folder) { try diff --git a/src/ui/Features/Translate/DownloadLlamaCppViewModel.cs b/src/ui/Features/Translate/DownloadLlamaCppViewModel.cs index ada71b4a1cb..c3cc5fcfcba 100644 --- a/src/ui/Features/Translate/DownloadLlamaCppViewModel.cs +++ b/src/ui/Features/Translate/DownloadLlamaCppViewModel.cs @@ -27,7 +27,7 @@ public partial class DownloadLlamaCppViewModel : ObservableObject public Window? Window { get; set; } public bool OkPressed { get; private set; } - /// "cpu", "vulkan", "cuda" or "cuda13" - which llama.cpp build to download (Windows only). + /// "cpu" or "vulkan" on Windows and Linux, also "cuda" or "cuda13" on Windows - which llama.cpp build to download. public string Variant { get; set; } = LlamaCppDownloadService.VariantCpu; /// The model to download, or null to only install the engine. @@ -108,6 +108,7 @@ public async void StartDownload() } TitleText = string.Format(Se.Language.General.UnpackingX, "llama.cpp"); + LlamaCppDownloadService.DeleteOtherBuildFiles(folder, Variant); engineStream.Position = 0; _zipUnpacker.UnpackZipStream(engineStream, folder, string.Empty, true, new List(), null); diff --git a/src/ui/Features/Translate/LlamaCppDownloadHelper.cs b/src/ui/Features/Translate/LlamaCppDownloadHelper.cs index 7f977e1587c..8c1310e211d 100644 --- a/src/ui/Features/Translate/LlamaCppDownloadHelper.cs +++ b/src/ui/Features/Translate/LlamaCppDownloadHelper.cs @@ -182,8 +182,9 @@ public static bool IsReady(string? modelFileName = null) /// /// Opens the llama.cpp download window (engine binary + selected model) and persists the result. /// When is given (e.g. re-downloading an update), that build is - /// used and the Windows build picker is skipped. When is - /// set, the engine binary is re-downloaded even though it is already installed. When + /// used and the build picker is skipped. When is set, the + /// engine binary is re-downloaded even though it is already installed, and without a + /// the user picks the build, so this is how to switch. When /// is set, the model file is re-downloaded even though it /// is already installed. /// @@ -191,7 +192,8 @@ public static bool IsReady(string? modelFileName = null) public static async Task DownloadAsync(Window owner, IWindowService windowService, LlamaCppModel? model, string? forceVariant = null, bool forceEngineDownload = false, bool forceModelDownload = false) { var variant = forceVariant ?? Logic.Download.LlamaCppDownloadService.VariantCpu; - if (forceVariant == null && !LlamaCppServerManager.IsEngineInstalled() && OperatingSystem.IsWindows()) + var engineWillDownload = forceEngineDownload || !LlamaCppServerManager.IsEngineInstalled(); + if (forceVariant == null && engineWillDownload && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux())) { var buildAnswer = await MessageBox.Show( owner, @@ -201,7 +203,7 @@ public static bool IsReady(string? modelFileName = null) MessageBoxIcon.Question, "CPU", "Vulkan (GPU)", - "CUDA (NVIDIA GPU)"); + OperatingSystem.IsWindows() ? "CUDA (NVIDIA GPU)" : null); variant = buildAnswer switch { diff --git a/src/ui/Features/Translate/LlamaCppEngineSettings/LlamaCppEngineSettingsViewModel.cs b/src/ui/Features/Translate/LlamaCppEngineSettings/LlamaCppEngineSettingsViewModel.cs index 1f7a3a42b74..eee1e3c0fec 100644 --- a/src/ui/Features/Translate/LlamaCppEngineSettings/LlamaCppEngineSettingsViewModel.cs +++ b/src/ui/Features/Translate/LlamaCppEngineSettings/LlamaCppEngineSettingsViewModel.cs @@ -119,8 +119,8 @@ private void ApplyStatus(DownloadHashManager.UpdateStatus status, bool installed /// /// llama.cpp ships CPU/Vulkan/CUDA builds into one folder, so the backend cannot be inferred from - /// the folder alone. Only Windows has a proven detector (, - /// which reads the shipped ggml backend DLLs); elsewhere we report OS+arch only rather than guess. + /// the folder alone. Windows and Linux have a detector (, + /// ) which reads the shipped ggml backend libraries. /// private static string DetectInstalledBackend(string folder) { @@ -133,9 +133,13 @@ private static string DetectInstalledBackend(string folder) if (Configuration.IsRunningOnLinux) { - return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 - ? "Linux ARM64" - : "Linux x64"; + var linux = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "Linux ARM64" : "Linux x64"; + return DownloadHashManager.DetectLlamaCppLinuxVariant(folder) switch + { + "vulkan" => $"{linux} (Vulkan)", + "cpu" => $"{linux} (CPU)", + _ => linux, + }; } return DownloadHashManager.DetectLlamaCppWindowsVariant(folder) switch diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs b/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs index 8c3e2a82471..d06541346b9 100644 --- a/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs +++ b/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs @@ -421,15 +421,9 @@ private async Task UpdateLlamaCppEngineAsync() LlamaCppServerManager.StopServer(); UpdateLlamaCppServerButtonText(); - // Re-download the same backend that is installed (CPU/Vulkan/CUDA all unpack into one - // folder); when nothing is installed yet, DownloadAsync falls back to asking the user. - var folder = LlamaCppServerManager.GetAndCreateFolder(); - var variant = LlamaCppServerManager.IsEngineInstalled() && OperatingSystem.IsWindows() - ? DownloadHashManager.DetectLlamaCppWindowsVariant(folder) - : null; - + // No forced variant: the user picks CPU/Vulkan/CUDA, which is how to change the installed build. var model = SelectedLlamaCppModel?.Model; - var downloaded = await LlamaCppDownloadHelper.DownloadAsync(Window, _windowService, model, variant, forceEngineDownload: true); + var downloaded = await LlamaCppDownloadHelper.DownloadAsync(Window, _windowService, model, forceEngineDownload: true); if (downloaded != null) { var selectName = string.IsNullOrEmpty(downloaded) ? model?.FileName : downloaded; diff --git a/src/ui/Logic/Download/DownloadHashManager.cs b/src/ui/Logic/Download/DownloadHashManager.cs index 736256f509b..03f17e78796 100644 --- a/src/ui/Logic/Download/DownloadHashManager.cs +++ b/src/ui/Logic/Download/DownloadHashManager.cs @@ -4271,4 +4271,19 @@ public static bool IsQwen3AsrCppVulkanInstall(string installFolder) return "cpu"; } + + /// + /// Detects which Linux llama.cpp variant is installed: the Vulkan archive only adds + /// libggml-vulkan.so (llama-server itself is identical in both builds). + /// Returns "vulkan" / "cpu", or null if the folder doesn't look like a llama.cpp install. + /// + public static string? DetectLlamaCppLinuxVariant(string installFolder) + { + if (string.IsNullOrEmpty(installFolder) || !File.Exists(Path.Combine(installFolder, "llama-server"))) + { + return null; + } + + return File.Exists(Path.Combine(installFolder, "libggml-vulkan.so")) ? "vulkan" : "cpu"; + } } diff --git a/src/ui/Logic/Download/LlamaCppDownloadService.cs b/src/ui/Logic/Download/LlamaCppDownloadService.cs index 5db8858c6fd..b02e97685be 100644 --- a/src/ui/Logic/Download/LlamaCppDownloadService.cs +++ b/src/ui/Logic/Download/LlamaCppDownloadService.cs @@ -84,6 +84,33 @@ public async Task DownloadModel(string url, string destinationFileName, IProgres await DownloadHelper.DownloadFileAsync(httpClient, url, destinationFileName, progress, cancellationToken); } + /// + /// All builds unpack into one folder and unpacking only overwrites, so the GPU backend of a + /// previous build would survive a switch - ggml would keep loading it, and the variant detection + /// would keep reporting it. Removes the files that does not ship. + /// + public static void DeleteOtherBuildFiles(string folder, string variant) + { + var isCuda = variant == VariantCuda || variant == VariantCuda13; + var staleFiles = new (string FileName, bool IsStale)[] + { + ("ggml-vulkan.dll", variant != VariantVulkan), + ("libggml-vulkan.so", variant != VariantVulkan), + ("ggml-cuda.dll", !isCuda), + ("cudart64_12.dll", variant != VariantCuda), + ("cudart64_13.dll", variant != VariantCuda13), + }; + + foreach (var (fileName, isStale) in staleFiles) + { + var path = Path.Combine(folder, fileName); + if (isStale && File.Exists(path)) + { + File.Delete(path); + } + } + } + /// /// True when the given variant ships its GPU runtime DLLs in a separate archive (Windows CUDA build). /// diff --git a/tests/UI/Features/Translate/LlamaCppDownloadHelperTests.cs b/tests/UI/Features/Translate/LlamaCppDownloadHelperTests.cs new file mode 100644 index 00000000000..464baa1f40c --- /dev/null +++ b/tests/UI/Features/Translate/LlamaCppDownloadHelperTests.cs @@ -0,0 +1,68 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.LogicalTree; +using Avalonia.Threading; +using Nikse.SubtitleEdit.Features.Shared; +using Nikse.SubtitleEdit.Features.Translate; +using Nikse.SubtitleEdit.UiLogic.LlamaCpp; + +namespace UITests.Features.Translate; + +/// +/// The Linux build picker has to appear both on a first install and on a re-download of an installed +/// engine: otherwise Linux can only ever install the CPU build and -ngl has no effect (#16488). +/// +public class LlamaCppDownloadHelperTests +{ + [AvaloniaTheory] + [InlineData(false)] + [InlineData(true)] + public async Task Linux_EngineDownloadOffersCpuAndVulkan_EvenWhenAlreadyInstalled(bool installed) + { + if (!OperatingSystem.IsLinux()) + { + return; + } + + var folder = Path.Combine(Path.GetTempPath(), "se-llamacpp-" + Path.GetRandomFileName()); + var originalFolder = LlamaCppServerManager.FolderOverride; + var originalExecutable = LlamaCppServerManager.ExecutableOverride; + Directory.CreateDirectory(folder); + LlamaCppServerManager.FolderOverride = folder; + LlamaCppServerManager.ExecutableOverride = null; + var owner = new Window(); + try + { + if (installed) + { + File.WriteAllText(LlamaCppServerManager.GetExecutable(), string.Empty); + } + + owner.Show(); + var download = LlamaCppDownloadHelper.DownloadAsync( + owner, new StubWindowService(), null, forceEngineDownload: installed); + Dispatcher.UIThread.RunJobs(); + + var picker = Assert.Single(owner.OwnedWindows.OfType()); + var buttons = picker.GetLogicalDescendants().OfType -public class SyntaxTextEditor : Decorator +public class SyntaxTextEditor : Border { - // White like every other text input; the dark and pastel themes set their own through a style - // (see UiTheme), and a caller can still set it directly. - public static readonly StyledProperty BackgroundProperty = - Border.BackgroundProperty.AddOwner( - new StyledPropertyMetadata(defaultValue: new Optional(Brushes.White))); - public static readonly StyledProperty ForegroundProperty = TextElement.ForegroundProperty.AddOwner(); @@ -49,6 +43,11 @@ public class SyntaxTextEditor : Decorator private bool _syncingScrollBars; + static SyntaxTextEditor() + { + BackgroundProperty.OverrideDefaultValue(Brushes.White); + } + public SyntaxTextEditor() { _view = new SyntaxTextView @@ -95,7 +94,6 @@ public SyntaxTextEditor() new RowDefinition(GridLength.Auto), }, Children = { _gutter, _view, _verticalScrollBar, _horizontalScrollBar }, - Background = Background, }; Child = _grid; @@ -129,12 +127,6 @@ public SyntaxTextEditor() /// The text surface - exposed for hit testing and focus. public SyntaxTextView View => _view; - public IBrush? Background - { - get => GetValue(BackgroundProperty); - set => SetValue(BackgroundProperty, value); - } - public IBrush? Foreground { get => GetValue(ForegroundProperty); @@ -242,10 +234,6 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { _gutter.IsVisible = ShowLineNumbers; } - else if (change.Property == BackgroundProperty) - { - _grid.Background = Background; - } else if (change.Property == IsReadOnlyProperty) { _view.IsReadOnly = IsReadOnly; diff --git a/src/ui/Features/Shared/SourceView/SourceViewWindow.cs b/src/ui/Features/Shared/SourceView/SourceViewWindow.cs index c3bbcd8cf11..f0d49d03b7a 100644 --- a/src/ui/Features/Shared/SourceView/SourceViewWindow.cs +++ b/src/ui/Features/Shared/SourceView/SourceViewWindow.cs @@ -68,20 +68,15 @@ public SourceViewWindow(SourceViewViewModel vm) Avalonia.Automation.AutomationProperties.SetName(vm.SourceViewTextBox.TextControl, Se.Language.General.Text); - // The editor in a field frame; its syntax colors stay as they are (theme-stable pastels) - var contentBorder = new Border + var editorContent = vm.SourceViewTextBox.ContentControl; + editorContent.Margin = new Thickness(14, 14, 14, 12); + if (editorContent is SyntaxTextEditor editor) { - VerticalAlignment = VerticalAlignment.Stretch, - HorizontalAlignment = HorizontalAlignment.Stretch, - Width = double.NaN, - Height = double.NaN, - Child = vm.SourceViewTextBox.ContentControl, - BorderBrush = EditorChrome.FieldBorderBrush, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - ClipToBounds = true, - Margin = new Thickness(14, 14, 14, 12), - }; + editor.BorderBrush = EditorChrome.FieldBorderBrush; + editor.BorderThickness = new Thickness(1); + editor.CornerRadius = new CornerRadius(8); + editor.ClipToBounds = true; + } var findBar = MakeFindBar(vm); @@ -94,7 +89,7 @@ public SourceViewWindow(SourceViewViewModel vm) }, }; - grid.Add(contentBorder, 0); + grid.Add(editorContent, 0); grid.Add(findBar, 1); Content = EditorChrome.MakeRoot(grid, MakeBottomPanel(vm)); diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertAssaViewModel.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertAssaViewModel.cs index 02c93da5271..f7ec9f10159 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConvertAssaViewModel.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertAssaViewModel.cs @@ -2,7 +2,6 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Layout; -using Avalonia.Media; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -218,10 +217,11 @@ private SyntaxTextEditorWrapper CreateAdvancedTextBoxWrapper(string text) { var editor = new SyntaxTextEditor { - Margin = new Thickness(0, 0, 10, 0), VerticalAlignment = VerticalAlignment.Stretch, HorizontalAlignment = HorizontalAlignment.Stretch, ShowLineNumbers = true, + CornerRadius = new CornerRadius(7), + ClipToBounds = true, SourceHighlighter = new AssaSourceSyntaxHighlighting(), Text = text, }; @@ -266,16 +266,7 @@ private SyntaxTextEditorWrapper CreateAdvancedTextBoxWrapper(string text) } }; - var textBoxBorder = new Border - { - BorderBrush = Brushes.Gray, - BorderThickness = new Thickness(1), - Child = editor, - VerticalAlignment = VerticalAlignment.Stretch, - HorizontalAlignment = HorizontalAlignment.Stretch, - }; - - return new SyntaxTextEditorWrapper(editor, textBoxBorder); + return new SyntaxTextEditorWrapper(editor); } internal void OnKeyDown(KeyEventArgs e)