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/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/docs/features/edit.md b/docs/features/edit.md index 325e1ccbf69..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 @@ -105,7 +106,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 +138,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 +147,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 +174,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) | diff --git a/docs/screenshots/multiple-replace.png b/docs/screenshots/multiple-replace.png index 97a66ed7c7d..a69801a7710 100644 Binary files a/docs/screenshots/multiple-replace.png and b/docs/screenshots/multiple-replace.png differ 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/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/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/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/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", 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" }, diff --git a/src/ui/Assets/Languages/Japanese.json b/src/ui/Assets/Languages/Japanese.json index 6599cac5475..182aa9444e2 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-beta10", + "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": "変換済み", @@ -1068,6 +1071,7 @@ "assaDrawHint": "ASS形式の図形描画", "ssaStylesHint": "SSA スタイル", "webVttStylesHint": "WebVTTスタイルマネージャー", + "yttStylesHint": "YouTube timed text のスタイル管理", "ssaPropertiesHint": "SSA プロパティ", "ssaAttachmentsHint": "SSA 添付ファイル", "getFrameRateFromVideoFileHint": "動画ファイルからフレームレートを取得" @@ -1632,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": "次の差分", 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": "다음 차이", 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/Controls/SyntaxTextEditorControl/SyntaxTextEditor.cs b/src/ui/Controls/SyntaxTextEditorControl/SyntaxTextEditor.cs index 7e44834722f..0575cf9987b 100644 --- a/src/ui/Controls/SyntaxTextEditorControl/SyntaxTextEditor.cs +++ b/src/ui/Controls/SyntaxTextEditorControl/SyntaxTextEditor.cs @@ -18,14 +18,8 @@ namespace Nikse.SubtitleEdit.Controls.SyntaxTextEditorControl; /// Only the visible lines are tokenized, laid out and drawn, so a 25 MB source opens and scrolls /// like a small one. Word wrap is not supported - long lines scroll sideways instead. /// -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/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/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/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/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/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 09e4520aff6..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() { @@ -9964,9 +9991,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; } } } @@ -16969,6 +16999,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 +17276,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 +17320,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 +17384,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 +17395,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 +17435,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 +17478,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; } @@ -24574,6 +24666,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(); @@ -24595,12 +24697,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/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/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/src/ui/Features/Shared/SourceView/SourceViewViewModel.cs b/src/ui/Features/Shared/SourceView/SourceViewViewModel.cs index f4e1191770b..4cdaa07301e 100644 --- a/src/ui/Features/Shared/SourceView/SourceViewViewModel.cs +++ b/src/ui/Features/Shared/SourceView/SourceViewViewModel.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; @@ -190,7 +189,6 @@ private SyntaxTextEditorWrapper CreateAdvancedTextBoxWrapper(string text, Subtit { var editor = new SyntaxTextEditor { - Margin = new Thickness(0, 0, 10, 0), VerticalAlignment = VerticalAlignment.Stretch, HorizontalAlignment = HorizontalAlignment.Stretch, ShowLineNumbers = true, @@ -205,16 +203,7 @@ private SyntaxTextEditorWrapper CreateAdvancedTextBoxWrapper(string text, Subtit // The view model's Text is not kept in sync while typing on purpose: materializing the // whole source on every keystroke is what made a large file crawl. Ok reads the editor. - 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); } // ---------------------------------------------------------------------------------------- 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/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/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/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) 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/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..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(); } } @@ -310,6 +311,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 @@ -348,13 +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. - private void ShowStep2() - { - EnterStep2(); - RefreshFixes(); - } - private async Task ShowStep2Async() { EnterStep2(); @@ -709,6 +704,7 @@ private async Task RunScanWithFeedbackAsync(Action scan) AnalysingIsVisible = true; await Task.Delay(AnalysingPaintDelayMilliseconds); + await LoadNamesListIfNeededAsync(); scan(); await Task.Delay(AnalysingMinimumVisibleMilliseconds); @@ -796,8 +792,6 @@ private void ApplyFixes() return; } - LoadNamesListIfNeeded(); - _totalErrors = 0; if (_previewMode) { @@ -1321,20 +1315,46 @@ 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. A caller that comes in while a + /// load for the same language is running awaits that load instead of starting a second one. /// - private void LoadNamesListIfNeeded() + private Task LoadNamesListIfNeededAsync() { var dictionaryFolder = Se.DictionariesFolder; if (string.Equals(_namesListFolder, dictionaryFolder, StringComparison.Ordinal) && string.Equals(_namesListLanguage, Language, StringComparison.Ordinal)) { - return; + return _namesListLoad; } - _namesList.Load(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/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/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/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..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; @@ -1068,7 +1070,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/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/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/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/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(); 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/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..5258b09d5a4 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,54 +24,85 @@ 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) + // 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); - 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")); + 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() @@ -337,7 +369,7 @@ private string GetLocalCultureNamesFileName() return Path.Combine(_dictionaryFolder, LanguageName + "_names.xml"); } - private void LoadNamesList(string fileNameOrUrl) + private static void LoadNamesList(string fileNameOrUrl, HashSet names, HashSet multiNames, HashSet blackList) { if (string.IsNullOrEmpty(fileNameOrUrl) || !File.Exists(fileNameOrUrl) && @@ -365,11 +397,11 @@ private void LoadNamesList(string fileNameOrUrl) { if (name.Contains(' ')) { - _namesMultiList.Add(name); + multiNames.Add(name); } else { - _namesList.Add(name); + names.Add(name); } } } @@ -387,7 +419,7 @@ private void LoadNamesList(string fileNameOrUrl) var name = reader.ReadElementContentAsString().Trim(); if (name.Length > 0) { - _blackList.Add(name); + blackList.Add(name); } } } 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..03f17e78796 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 @@ -4243,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/src/ui/Logic/EditorChromeParts.cs b/src/ui/Logic/EditorChromeParts.cs index 12e60110461..4b858ad8951 100644 --- a/src/ui/Logic/EditorChromeParts.cs +++ b/src/ui/Logic/EditorChromeParts.cs @@ -358,6 +358,10 @@ private static void AddTextFieldStyles(Styles styles, EditorWindowStyles extra) styles.Add(style); if (extra.HasFlag(EditorWindowStyles.AutoComplete)) { + styles.Add(new Style(x => x.OfType()) + { + Setters = { new Setter(TemplatedControl.CornerRadiusProperty, new CornerRadius(6)) }, + }); styles.Add(autoCompleteStyle); } } 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(); 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, 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 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/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPreviewSubtitle.cs b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPreviewSubtitle.cs index 9caf87c2038..3ec25bf637f 100644 --- a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPreviewSubtitle.cs +++ b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPreviewSubtitle.cs @@ -1,8 +1,16 @@ +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; namespace Nikse.SubtitleEdit.Logic.VideoPlayers.Ffmpeg; @@ -10,22 +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, whole-line italic, 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 { - public sealed record Line(double StartSeconds, double EndSeconds, string Text, bool Italic, bool Secondary); + /// 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 static readonly FfmpegPreviewSubtitle Empty = new(Array.Empty()); + /// 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 | 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(), 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; @@ -39,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 @@ -53,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) @@ -61,31 +163,161 @@ 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, 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, italic, secondary)); + lines.Add(new Line(p.StartTime.TotalSeconds, p.EndTime.TotalSeconds, text, runs, secondary, alignment)); + } + } + + /// + /// 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. + /// + /// + /// 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 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; + state.Apply(tag.Value); + } + + 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(); + alignment = source.Contains("\\pos(", StringComparison.Ordinal) || source.Contains("\\move(", StringComparison.Ordinal) + ? null + : state.Alignment; + 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 (state.Italic || state.Bold || state.Color.HasValue) + { + var last = styled.Count - 1; + 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, state.Italic, state.Bold, state.Color)); + } + } + + 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 }; + } + + /// What the tags so far have switched on. + private sealed class TagState + { + 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) { - italic = trimmed.StartsWith("{\\i1}", StringComparison.Ordinal) && !trimmed.Contains("{\\i0}", StringComparison.Ordinal); + if (tag[0] == '{') + { + 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 = tag[1] != '/'; + } } - var plain = HtmlUtil.RemoveHtmlTags(trimmed, true); - plain = plain.Replace("\\N", Environment.NewLine).Replace("\\n", Environment.NewLine); - return plain.Trim(); + private void ApplyAssTags(string block) + { + foreach (Match match in AssStyleOverrideRegex.Matches(block)) + { + 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; + } + } + } } /// 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..4e491aa6790 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; @@ -185,145 +186,256 @@ 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 = 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 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.Length > 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, primaryItalic, alignment)); + blocks.Add(MakeBlock(group.ToList(), group.Key.Secondary ? secondaryPaint : paint, group.Key.Alignment)); } - if (secondary.Length > 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, secondaryItalic, 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(string text, bool italic, int numpadAlignment) + OverlayBlock MakeBlock(List lines, OverlayPaint blockPaint, 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 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) + { + 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); + } + + 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/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); + } +} 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; 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() { 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(); + } + } } 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/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/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