diff --git a/Dictionaries/dan_OCRFixReplaceList.xml b/Dictionaries/dan_OCRFixReplaceList.xml index 621c84a6ab6..3f0c785c30a 100644 --- a/Dictionaries/dan_OCRFixReplaceList.xml +++ b/Dictionaries/dan_OCRFixReplaceList.xml @@ -49,7 +49,7 @@ - + @@ -671,7 +671,6 @@ - @@ -719,8 +718,6 @@ - - diff --git a/Dictionaries/deu_OCRFixReplaceList.xml b/Dictionaries/deu_OCRFixReplaceList.xml index d61365b95a9..b71b89b2348 100644 --- a/Dictionaries/deu_OCRFixReplaceList.xml +++ b/Dictionaries/deu_OCRFixReplaceList.xml @@ -37,7 +37,6 @@ - @@ -804,7 +803,6 @@ - @@ -1313,8 +1311,6 @@ - - @@ -1485,8 +1481,6 @@ - - @@ -1693,7 +1687,7 @@ - + @@ -2226,8 +2220,6 @@ - - @@ -2914,7 +2906,6 @@ - @@ -3213,15 +3204,12 @@ - - - @@ -3247,7 +3235,6 @@ - @@ -4487,7 +4474,6 @@ - @@ -4677,8 +4663,6 @@ - - @@ -4870,8 +4854,6 @@ - - @@ -5315,7 +5297,6 @@ - @@ -5351,9 +5332,9 @@ - + - + @@ -5365,7 +5346,6 @@ - @@ -6493,7 +6473,7 @@ - + @@ -6575,8 +6555,6 @@ - - @@ -6839,7 +6817,6 @@ - diff --git a/Dictionaries/eng_OCRFixReplaceList.xml b/Dictionaries/eng_OCRFixReplaceList.xml index 41536cbdaba..cffc826efb2 100644 --- a/Dictionaries/eng_OCRFixReplaceList.xml +++ b/Dictionaries/eng_OCRFixReplaceList.xml @@ -1593,7 +1593,6 @@ - @@ -1863,7 +1862,6 @@ - @@ -2103,7 +2101,6 @@ - @@ -2802,7 +2799,6 @@ - @@ -3412,9 +3408,8 @@ - + - diff --git a/Dictionaries/fra_OCRFixReplaceList.xml b/Dictionaries/fra_OCRFixReplaceList.xml index ba065ea4a55..6904d9f4725 100644 --- a/Dictionaries/fra_OCRFixReplaceList.xml +++ b/Dictionaries/fra_OCRFixReplaceList.xml @@ -312,24 +312,14 @@ - - - - - - - - - - diff --git a/Dictionaries/hrb_OCRFixReplaceList.xml b/Dictionaries/hrb_OCRFixReplaceList.xml index 6a5c692293c..dd9897b9d17 100644 --- a/Dictionaries/hrb_OCRFixReplaceList.xml +++ b/Dictionaries/hrb_OCRFixReplaceList.xml @@ -50,7 +50,7 @@ - + @@ -61,7 +61,6 @@ - @@ -206,8 +205,6 @@ - - @@ -281,7 +278,6 @@ - @@ -294,8 +290,6 @@ - - @@ -478,7 +472,6 @@ - @@ -1059,7 +1052,6 @@ - @@ -1216,7 +1208,6 @@ - diff --git a/Dictionaries/hrv_OCRFixReplaceList.xml b/Dictionaries/hrv_OCRFixReplaceList.xml index e21a8b8cd8b..c3ba5da08d4 100644 --- a/Dictionaries/hrv_OCRFixReplaceList.xml +++ b/Dictionaries/hrv_OCRFixReplaceList.xml @@ -103,8 +103,6 @@ - - @@ -360,7 +358,6 @@ - @@ -471,7 +468,7 @@ - + @@ -823,7 +820,7 @@ - + @@ -1398,7 +1395,6 @@ - @@ -1412,13 +1408,11 @@ - - @@ -1843,7 +1837,6 @@ - @@ -2005,7 +1998,6 @@ - @@ -2037,10 +2029,8 @@ - - @@ -2195,7 +2185,6 @@ - @@ -2321,9 +2310,7 @@ - - @@ -2571,7 +2558,6 @@ - @@ -2857,7 +2843,6 @@ - @@ -3379,7 +3364,6 @@ - @@ -3416,7 +3400,6 @@ - @@ -3472,7 +3455,6 @@ - @@ -3875,7 +3857,6 @@ - @@ -3979,7 +3960,7 @@ - + @@ -4000,7 +3981,6 @@ - @@ -4010,7 +3990,7 @@ - + @@ -4298,7 +4278,6 @@ - @@ -4587,7 +4566,6 @@ - @@ -4784,7 +4762,7 @@ - + @@ -5366,7 +5344,6 @@ - @@ -5439,7 +5416,6 @@ - @@ -5524,7 +5500,6 @@ - @@ -5562,7 +5537,7 @@ - + @@ -5590,7 +5565,6 @@ - @@ -5638,7 +5612,6 @@ - @@ -5752,7 +5725,7 @@ - + @@ -5831,7 +5804,6 @@ - @@ -5899,7 +5871,6 @@ - @@ -6195,7 +6166,6 @@ - diff --git a/Dictionaries/ita_OCRFixReplaceList.xml b/Dictionaries/ita_OCRFixReplaceList.xml index 6db756845ee..83331a2b006 100644 --- a/Dictionaries/ita_OCRFixReplaceList.xml +++ b/Dictionaries/ita_OCRFixReplaceList.xml @@ -64,7 +64,12 @@ - + + + + @@ -83,5 +88,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Dictionaries/mkd_OCRFixReplaceList.xml b/Dictionaries/mkd_OCRFixReplaceList.xml index 5cf84a493ea..9ae5838a1f2 100644 --- a/Dictionaries/mkd_OCRFixReplaceList.xml +++ b/Dictionaries/mkd_OCRFixReplaceList.xml @@ -51,5 +51,37 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Dictionaries/nld_OCRFixReplaceList.xml b/Dictionaries/nld_OCRFixReplaceList.xml index efdf028a89c..8b346192a41 100644 --- a/Dictionaries/nld_OCRFixReplaceList.xml +++ b/Dictionaries/nld_OCRFixReplaceList.xml @@ -213,14 +213,8 @@ - - - - - - @@ -241,8 +235,6 @@ - - diff --git a/Dictionaries/nob_OCRFixReplaceList.xml b/Dictionaries/nob_OCRFixReplaceList.xml index 0b3b9a6ac3d..1113fef504b 100644 --- a/Dictionaries/nob_OCRFixReplaceList.xml +++ b/Dictionaries/nob_OCRFixReplaceList.xml @@ -101,8 +101,6 @@ - - diff --git a/Dictionaries/nor_OCRFixReplaceList.xml b/Dictionaries/nor_OCRFixReplaceList.xml index fab379d392f..fb63b79edbf 100644 --- a/Dictionaries/nor_OCRFixReplaceList.xml +++ b/Dictionaries/nor_OCRFixReplaceList.xml @@ -99,8 +99,6 @@ - - diff --git a/Dictionaries/por_OCRFixReplaceList.xml b/Dictionaries/por_OCRFixReplaceList.xml index e7659bcebee..e270ba60857 100644 --- a/Dictionaries/por_OCRFixReplaceList.xml +++ b/Dictionaries/por_OCRFixReplaceList.xml @@ -549,5 +549,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Dictionaries/rus_OCRFixReplaceList.xml b/Dictionaries/rus_OCRFixReplaceList.xml index b894da07326..8568bb5d79a 100644 --- a/Dictionaries/rus_OCRFixReplaceList.xml +++ b/Dictionaries/rus_OCRFixReplaceList.xml @@ -254,5 +254,33 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Dictionaries/spa_OCRFixReplaceList.xml b/Dictionaries/spa_OCRFixReplaceList.xml index 7abe2471b71..b63440d74da 100644 --- a/Dictionaries/spa_OCRFixReplaceList.xml +++ b/Dictionaries/spa_OCRFixReplaceList.xml @@ -422,7 +422,7 @@ - + @@ -513,7 +513,7 @@ - + @@ -617,7 +617,6 @@ - @@ -632,7 +631,7 @@ - + @@ -664,7 +663,6 @@ - @@ -980,5 +978,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Dictionaries/swe_OCRFixReplaceList.xml b/Dictionaries/swe_OCRFixReplaceList.xml index 311ece623a3..6f8be81882d 100644 --- a/Dictionaries/swe_OCRFixReplaceList.xml +++ b/Dictionaries/swe_OCRFixReplaceList.xml @@ -470,7 +470,6 @@ - @@ -518,8 +517,6 @@ - - diff --git a/README.md b/README.md index 1ee75106595..84aa2ef6b36 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ You can find the latest cross-platform builds here: ### macOS - **Minimum macOS version**: 12 (Monterey) or newer +- **Recommended**: macOS 14 (Sonoma) or newer - the oldest macOS that .NET 10 is supported on. Subtitle Edit still starts on 12 and 13, but that combination is not one the .NET runtime is tested against. - The `.dmg` is self-contained: `libmpv` and `ffmpeg` are bundled inside `Subtitle Edit.app`, so no MacPorts or Homebrew install is required. #### Installing Subtitle Edit on macOS diff --git a/change-log.txt b/change-log.txt index 0d17de5f3ba..36ca4e2eb09 100644 --- a/change-log.txt +++ b/change-log.txt @@ -1,8 +1,303 @@ - + Subtitle Edit Changelog ----------------------------------------------------------------------------------------------------- +v5.2.0-beta32 (2nd of September 2026) + +* Add a "Columns..." dialog to reorder and hide subtitle grid columns - thx m0ck69 +* Export the "List errors" report to clipboard, text file, Excel or web page - thx AuroraMartell +* Binary edit: open DVD sup, XSUB (avi), MP4 VobSub, image based WebVTT/TTML and more transport streams, and export DVD sup and D-Cinema +* ASSA: offer to resample the subtitle when the video resolution differs from the script resolution - thx hisui3393 +* Add Confucius4-TTS (CrispASR), Higgs Audio v3 and Fish Audio S2 Pro (audio.cpp) voice cloning TTS engines +* "Point sync via other subtitle": a gap column that highlights likely sync points - thx alexchexes +* Move "Open second subtitle file..." to SE 4's spot in the Video menu - thx m0ck69 +* macOS: open Finder files in a new window, and start the video picker in the subtitle's own folder - thx muaz978 +* seconv: add the full frame image export option - thx smt-joen +* Faster waveform scrubbing on long-GOP video - thx GrampaWildWilly +* Faster Sami and IMSC 1.1 reading, PAC/EBU STL/D-Cinema saving and Blu-ray image export +* Fix audio dropouts when pausing or seeking - the beta 31 mpv option did nothing - thx GrampaWildWilly +* Fix Alt+Tab still not bringing undocked windows to the foreground - thx GrampaWildWilly +* Fix the playhead jumping when repeat playback wraps to the next line - thx marcpbailey +* Fix the "Guess time codes" window losing its buttons at a high UI scale +* Update Italian translation - thx bovirus + +----------------------------------------------------------------------------------------------------- + +v5.2.0-beta31 (1st of September 2026) + +* New subtitle format: Csv Excel - thx matmaggi +* Mark lines as forced narrative, with a "Forced" column and "Save forced lines as..." - thx matmaggi +* New subtitle format: EBU-TT (Tech 3350), carrying teletext colors, boxing, rows and the GSI metadata +* EBU-TT-D and IMSC Rosetta: read and write font colors - thx Scryper +* DVB teletext (.dvbttx): write Level 2.5 colors, and make it a toolbar format with alignment picker, video preview and batch convert - thx René +* Add Pocket TTS (CrispASR) voice cloning, and offer VibeVoice again +* CrispASR: update to v0.8.31, and offer Windows CUDA 13 +* Subtitle image previews: a configurable background color, and a Blu-ray sup overlay that scales with the video - thx Lukewarm1141 +* Advanced TTS settings: hint icons instead of a wall of text, so the window fits the screen again - thx nielka98 +* Fix audio dropouts by keeping the mpv audio device open while paused - thx GrampaWildWilly +* Fix Alt+Tab not bringing undocked windows to the foreground - thx GrampaWildWilly +* Fix playing selected lines in repeat mode replaying a line at the cue boundary - thx marcpbailey +* Fix the subtitle grid jumping to the end of the list after cut + auto-break - thx St0rmXtr00per +* Fix the Matroska track picker opening twice on macOS - thx marcpbailey +* Update Italian translation - thx bovirus +* Update Japanese translation - thx hisui3393 +* Update Korean translation - thx 12si27 +* Update Turkish translation - thx bilimiyorum + +----------------------------------------------------------------------------------------------------- + +v5.2.0-beta30 (31st of August 2026) + +* Read Wistia json, DVD Junior SPC, Sonic DVD Producer and YouTube srv3 (.ytt) subtitles +* Add "Center text in subtitle grid" (SE 4 parity) - thx matmaggi +* Settings import: accept an SE 4 Settings.xml - thx JDario16 +* Teletext: write G2 characters like the music note, and read the national option sub-set and the Level 2.5 colours - thx René +* Make dialogs keyboard-navigable: initial focus and Tab, across the UI - thx GrampaWildWilly +* ASSA style window: scale the preview with the window and give it the whole panel +* Auto-translate: make Enter press the default button +* Keep ASSA override blocks out of auto-translate too - thx MrGoraj +* Sort the OCR fix list alphabetically in Options - Word lists - thx Anth70 +* seconv: give Ollama OCR the same generation limits as the GUI - thx gmariani +* Fix seconv reporting its version as 5.0.0 - thx gmariani +* Fix the mouse wheel setting the video position with the option turned off - thx rookes +* Fix Compare's ignore options, and remember its settings - thx FunkyKnilch +* Fix the video preview showing EBU STL (and TTML positions) after the format was changed +* Fix 20 bugs found in a code sweep +* Update French translation - thx Need74 +* Update Italian translation - thx bovirus +* Update Japanese translation - thx hisui3393 + +----------------------------------------------------------------------------------------------------- + +v5.2.0-beta29 (30th of August 2026) + +* Add configurable "Search via X" shortcuts, with a "Search via" submenu in the text box context menu - thx kadrimarzouki +* Add "toggle translation and original in video/audio preview" shortcut - thx Surlime +* Add shortcut to copy the text of the selected lines to the original - thx hisui3393 +* Add macOS "Look up" to the subtitle text box context menu - thx routineCode +* Video offset: remember recently used offsets, and an "Apply" that keeps the window open - thx kadrimarzouki +* EBU STL: a font picker with a preview sample in the save options, color shortcuts snapping to the nearest teletext color, and clearer labels in the teletext alignment dialog +* Waveform: give right-to-left text the direction its letters ask for - thx kadrimarzouki +* seconv: per-image OCR progress, and percentages on OCR and translate - thx gmariani +* Fix the waveform cursor drifting off the frame when stepping forward - thx rookes +* Fix "one frame forward/back (with play)" not stepping smoothly - thx rookes +* Fix waveform seek drift at SMPTE frame rates, unpinned nudges and a mixed-up blip clock - thx Davanix +* Fix the mpv seek target being dropped after a click in the waveform - thx Davanix +* Fix Compare no longer scrolling the two grids together - thx Davanix +* Fix the subtitle grid not centering on the last prev/next-line paths - thx St0rmXtr00per +* Fix the blank waveform and the rest of the rewinding after leaving Settings - thx GrampaWildWilly +* Fix .dvbttx files with a preamble over 200 KB reading back as empty +* Fix a translation being re-split inside a number - thx fraternl +* Fix WebVTT cues placed in different vertical bands being merged - thx MrAnter +* Fix six more bugs found in a code sweep +* Update Bulgarian translation - thx jekovcar +* Update Italian translation - thx bovirus +* Update Japanese translation - thx hisui3393 +* Update Persian translation - thx rmtjokar +* Update Russian translation - thx jekovcar + +----------------------------------------------------------------------------------------------------- + +v5.2.0-beta28 (29th of August 2026) + +* Read and write Manzanita DVB teletext files (.dvbttx) - thx René +* EBU STL video preview: draw the box, the justification, double height rows and an optional custom font +* Surround with: more slots, and fix the slots getting lost in settings export/import - thx kadrimarzouki +* SCC: write colors as CEA-608 mid-row codes instead of putting the color tags on screen - thx Jessecar96 +* Video OCR: a better default prompt for two-line subtitles, and "--ocr-prompt" for seconv - thx tekk42 +* Update CrispASR to v0.8.30 +* Fix the subtitle grid not centering on the selected row during prev/next navigation - thx St0rmXtr00per +* Fix "inverse selection" selecting a single row and jumping to the top of the file +* Fix the video rewinding after leaving Settings - thx GrampaWildWilly +* Fix the duration field in frame mode not accepting a value typed without the colon - thx René +* Blu-ray sup: fix the export of bitmaps over 64 KB +* Update Italian translation - thx bovirus +* Update Japanese translation - thx hisui3393 + +----------------------------------------------------------------------------------------------------- + +v5.2.0-beta27 (28th of August 2026) + +* Add "Assisted split" and "Assisted move" windows, with ranked one-click suggestions and full previews - thx GitGianluc +* Add "Justify lines" for the mpv video preview in Options - Settings - Video - thx kadrimarzouki +* Image based export: let the line justification follow the alignment tag - thx smt-joen +* Much better import of unknown subtitle formats - a new generic XML importer plus a dozen JSON fixes +* Netflix check: measure gaps, bridges and shot changes at the video's frame rate, and fix two "spell out numbers" defects +* Fix "generate video with burned-in subtitles" silently doing nothing when the output file already existed - thx rRobis +* Fix the waveform cursor jumping away a moment after a click - thx Davanix +* Fix the undocked waveform window taking the foreground when another program is closed - thx GrampaWildWilly +* Fix around 290 more bugs found in 13 code sweeps - subtitle formats, batch convert, seconv, dialogs, settings, binary edit and core helpers +* Update Chinese (simplified) translation - thx emsb8888-prog +* Update Italian translation - thx bovirus +* Update Japanese translation - thx hisui3393 +* Update Korean translation - thx 12si27 +* Update Polish translation - thx potplayer-fanpack +* Update Portuguese (Brazil) translation - thx igorruckert + +----------------------------------------------------------------------------------------------------- + +v5.2.0-beta26 (27th of August 2026) + +* Video OCR: read far more subtitles, with much better text and start times - thx caiweihan +* Video OCR: run the OCR fix engine and spell check coloring on the lines, plus an edit dialog and a context menu +* Video OCR: order the engines best-measured first, add setting hints, a dictionary download button and window layout polish +* Add "Change ASSA style properties" (spacing and alignment) to batch convert - thx adeltalon +* Add "Play current" to spell check, so you can hear the line a flagged word is in - thx fraternl +* Add "Google it" to the subtitle text box context menu +* Add the SE 4 "go to next/prev subtitle (play translate)" shortcuts - thx kadrimarzouki +* Add Central Kurdish translation - thx dkakaie +* Spreadsheets (csv/xlsx/ods) can now be opened from File - open, and the import is documented - thx GrampaWildWilly +* Lambda Cap: read and write ruby, emphasis dots and positioning control codes - thx magsmike +* EBU STL: keep the colors when the header claims open subtitling +* Split long lines: keep a sentence-ending line break as an event boundary - thx AuroraMartell, Triathlon-rally +* "Fix RTL via Unicode" now wraps the sentence inside the tags, and closes the embedding +* Netflix check and fix: the timing fixes (min/max duration, gaps, shot changes) are now actually applied +* Faster XML format detection, Timed Text saving and DVB/VobSub image decoding +* WSB is now import only - Subtitle Edit could not read back the files it wrote +* Fix "apply minimum gap", "change formatting" and "sort by" clearing the subtitle when OK was pressed quickly +* Fix the first save after a translation overwriting the source file's ASSA styles +* Fix cancelling "save as" saving anyway, and "save original as" using the wrong format +* Fix a crash when dropping an mkv file on the subtitle grid, and the doubled "parsing Matroska file" wait +* Fix Arabic subtitle text showing up as boxes in the subtitle grid - thx adeltalon +* Fix auto-translate blanking every untranslated line when pressing OK after a partial translation +* Fix "play current and pause" running past the end of the line - thx kadrimarzouki +* Fix Multiple replace losing the selection when moving a rule up or down - thx madsoft-ha +* Fix the undocked waveform window coming to the front instead of the main window - thx GrampaWildWilly +* Fix export/import of settings resetting video settings and rules, and wiping shortcuts +* Fix "whole word" find and replace matching inside words +* Fix spell check losing user words, names and "use always" entries +* Fix speech to text losing word level highlighting, and cancel not stopping a batch +* Fix text to speech review edits dropping italics and line breaks +* Fix several burn-in and transparent video bugs (cut length, target file size, output properties) +* Fix image based export duplicating text after a "<", and ASSA fade-outs exporting fully transparent +* Fix five bugs in the csv/xlsx/ods importers (pipe separator, merged cells, sheet order) +* Fix shot changes import losing precision, decimal commas and "frame_time" files +* Fix CEA-608 roll-up captions scrolling the wrong rows +* Fix around 200 more bugs found in six code sweeps - subtitle formats, dialogs, settings, OCR, downloads and core helpers +* Update German translation - thx FunkyKnilch +* Update Italian translation - thx bovirus +* Update Polish translation - thx potplayer-fanpack +* Update Turkish translation - thx bilimiyorum + +----------------------------------------------------------------------------------------------------- + +v5.2.0-beta25 (26th of August 2026) + +* Add advanced text effects to image based export - gradient, neon glow, 3D extrude and more +* Add speaker-name detection and a "leave sound/music lines silent" prompt to text to speech - thx subof +* Add "Clone from video" to Qwen3 TTS, so each line can be dubbed in the voice heard in the video - thx invio-a11y +* Add an editable video position box to the waveform toolbar - thx 95Midnight +* Add custom llama.cpp vision models to the OCR model list +* Add the "Ollama advanced" translate engine to batch convert +* Add "--translate-prompt" to seconv for the local LLM translate engines - thx Makar8000 +* Image based export now honors per-line ASSA colors and outline/shadow widths +* EBU STL: show frame-based time codes while the format is active - thx Triathlon-rally +* "Normalize strings" now folds curly quotes, dashes and ellipsis to plain ASCII - thx fraternl +* DeepL: offer all the languages the API supports, and use the host that matches the key +* Google Translate: use the right language list for the free and the paid engine, and drop two broken entries +* Auto-translate: take the CrispASR MADLAD language list from the model - thx ecotycoon +* OCR: list the most accurate llama.cpp models first +* Italian OCR: fix "E'" to "È" - thx GitGianluc +* Speech to text: say when Crisp ASR was killed instead of reporting no speech - thx marwanlhabti5-coder +* Update llama.cpp to b10625 +* Fix the original text being erased after translating - thx fraternl +* Fix EBU STL export doing nothing when a font color tag has no quotes, and remember the save options +* Fix regenerating a cloned line after importing a text to speech session - thx invio-a11y +* Fix "find voices in video and clone" not setting actors when a subtitle is open - thx subof +* Fix "convert actors" missing a speaker tag split across a line break - thx subof +* Fix OCR removing the French space before "?" and "!" +* Fix Microsoft Translator throwing an error when the language list cannot be fetched +* Update Italian translation - thx bovirus +* Update Japanese translation - thx hisui3393 +* Update Polish translation - thx potplayer-fanpack + +----------------------------------------------------------------------------------------------------- + +v5.2.0-beta24 (25th of August 2026) + +* Add dots.tts text to speech engine with voice cloning +* Add WhisperX speech to text engine, standalone - no Python needed - thx muaz978 +* Add Apple Vision OCR on macOS +* Add a format properties button next to the subtitle format picker +* Add "List errors..." to the Tools menu +* Add the SE 4 bookmark, underline and "guess start" shortcuts +* macOS: add the standard Window menu, close menu-bar gaps and fix the file types the app registers +* macOS: let Ctrl+click open the shortcuts list's Import/Export menu +* Blu-ray sup export can now fade lines in/out via ASSA "{\fad}" tags +* Update Paddle OCR to 3.7 (PP-OCRv6) +* Google Translate: show a clear message when Google blocks the free service, and retry via a fallback endpoint - thx kadrimarzouki +* Speech to text advanced: the parameter buttons now toggle their parameter on and off - thx thealainpaul +* Fix EBU STL save options being silently lost or corrupted +* Fix subtitle times drifting by one millisecond while editing - thx Davanix +* Fix the original text disappearing after applying a tool dialog's result - thx madsoft-ha +* Fix "Open with" on an mkv sometimes opening only the subtitle, not the video - thx wixfigur +* Fix "convert actors" not writing the actor column for inline "Name: text" lines - thx rotj +* Fix Faster Whisper XXL, CTranslate2 and Const-me asking to install again every time - thx senaplz +* Fix Qwen 3.5 leaking "thinking" text into llama.cpp translate and AI review +* Fix Whisper suggesting int8 again after cuBLAS rejected the compute type +* Update Russian translation - thx jekovcar +* Update Bulgarian translation - thx jekovcar +* Update Chinese Traditional translation - thx love80312 +* Update Italian translation - thx bovirus + +----------------------------------------------------------------------------------------------------- + +v5.2.0-beta23 (24th of August 2026) + +* OCR: add accent, l/I and preposition-split rules for Spanish, Portuguese and Italian +* OCR: fix Latin letters and digits misread inside Cyrillic words (Russian, Macedonian) +* OCR: repair about 115 broken, dead or duplicated entries in the fix replace lists +* Faster Scenarist Closed Captions export (5x), Netflix quality checks and "remove text for hearing impaired" +* Faster Blu-ray palette decoding, transport stream scanning and Cavena 890 reading +* Fix ASSA attachments being written with the last two bytes zeroed +* Fix DVD Studio Pro "with space" files failing to load +* Fix csv lines containing a semicolon being dropped +* Fix ASSA resampling giving up on tags with a space, like "\pos (10,11)" +* Fix words per minute being reported as infinity for zero duration lines +* Fix "convert actors" losing the name and mangling lines with leading spaces +* Fix crashes and wrong results when parsing malformed MP4, transport stream and RIFF files +* Fix WebVTT thumbnail sprite sheets in .jpeg being rejected +* Fix video OCR ending a subtitle too early after an unreadable frame +* Fix teletext line length errors missing from the error lists in the main window and batch convert +* Fix beautify time codes using a cancelled or failed frame time extraction when the video duration is unknown +* Fix the speech to text quality report naming the wrong line for removed repeats +* Fix Netflix "spell out leading number" doing nothing outside Windows +* Fix Unknown 33/34/59 writing 00:00:60 instead of 00:01:00 +* Fix Matroska SSA lines with fewer commas than expected throwing an error +* Fix plain text import hanging when the max line length setting is zero +* Fix OpenRouter speech to text with GPT transcription models - thx muaz978 +* Update German translation - thx FunkyKnilch +* Update Japanese translation - thx hisui3393 +* Update Korean translation - thx 12si27 + +----------------------------------------------------------------------------------------------------- + +v5.2.0-beta22 (23rd of August 2026) + +* Beautify time codes can now use the video's real frame times instead of assuming a constant frame rate - thx warrentc3 +* Add a transcription quality report to speech to text, plus non-speech and repeated-line removal - thx l0uev4-lab +* Add an error list with summary cards and per-error rows, in the main window and in batch convert +* Add shot-change snap distances to Settings +* Snap to the nearest shot change in the waveform when a cue is close - thx m0ck69 +* Warn before saving SCC when lines exceed 32 characters or 4 lines +* Add a "Use external server" toggle for llama.cpp in batch convert, independent of the main setting - thx emsb8888-prog +* Remember the batch convert "Remove formatting" check boxes - thx matake31 +* Use the selected audio track in the sync windows - thx dmist +* Use display dimensions for rotated video when burning in subtitles - thx thealainpaul +* Speed up tag counting in the subtitle grid - thx ivandrofly +* Fix split long lines producing overlong lines, gaps between split events and duplicate numbers - thx Triathlon-rally +* Fix selecting a text to speech review line by clicking or grabbing its waveform block - thx cvrle77 +* Fix Google Translate failing on transient server errors instead of retrying - thx JoaoDuarte-code +* Fix the subtitle grid scroll position jumping while the reference projection rebuilds - thx torvchen +* Fix the minimum gap not being kept when nudging a cue one frame - thx m0ck69 +* Fix the AI review sending ASSA override blocks, and send actor/style as context - thx MrGoraj +* Fix continuation style fixes being applied twice to each line +* Update Italian translation - thx bovirus +* Update Polish translation - thx potplayer-fanpack + +----------------------------------------------------------------------------------------------------- + v5.2.0-beta21 (22nd of August 2026) * Add an option to keep the source file's date/time on converted files (batch convert + seconv) - thx TucocoTucoco diff --git a/docs/faq.md b/docs/faq.md index 48b8e7d04cd..fbd89329b71 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -12,7 +12,7 @@ Yes. Subtitle Edit is released under the MIT license. It is completely free with Subtitle Edit 5 is built with Avalonia UI and runs on: - Windows 10 (version 22H2 / build 19045) and later - Linux (native packages or Flatpak) -- macOS 12 (Monterey) or later, on both Apple Silicon (arm64) and Intel (x64) +- macOS 12 (Monterey) or later, on both Apple Silicon (arm64) and Intel (x64) - macOS 14 (Sonoma) or later recommended, as that is the oldest macOS .NET 10 is supported on ### Where is my data stored? Subtitle Edit stores settings and data in a platform-specific data folder. You can open it with the keyboard shortcut `Ctrl+Alt+Shift+D` (Windows/Linux) or `Cmd+Alt+Shift+D` (macOS). @@ -35,9 +35,9 @@ Subtitle Edit supports over 380 subtitle formats including SubRip (.srt), WebVTT For batch conversion, use **Tools → Batch convert...**. ### How do I import image-based subtitles? -Use **File → Import image subtitle for OCR...** to import Blu-ray SUP, VobSub, or other image-based subtitles and convert them to text using OCR. +Use **File → Import → Image-based subtitle for OCR...** to import Blu-ray SUP, VobSub, or other image-based subtitles and convert them to text using OCR. -You can use **File → Import image subtitle for edit...** to work/export the images. +You can use **File → Import → Image-based subtitle for edit...** to work/export the images. --- @@ -76,10 +76,11 @@ Subtitle Edit supports several local and downloadable speech-to-text engines: - **Purfview's Faster-Whisper-XXL** — Fast GPU/CPU-based (Windows, Linux) - **CTranslate2** — Fast CPU/GPU-based - **Const-me's Whisper** — DirectX-based (Windows) +- **WhisperX** — Faster-Whisper with wav2vec2 word-level alignment, standalone build (Windows x64, Linux x64, macOS Apple Silicon) - **OpenAI Whisper** — Original Python implementation - **OpenAI-compatible STT** — Send audio to any OpenAI-compatible speech endpoint -- **Qwen3 ASR CPP** — Local GGUF-based engine (Windows, Linux) -- **Crisp ASR** — Local engine with multiple backends (Parakeet, Canary, Cohere, Fire Red, GLM, Granite, Qwen3, Mega, Omni, Kyutai) and a Forced-aligner picker (built-in / Canary CTC / Qwen3 / 12 wav2vec2 language aligners) for word-level timestamps +- **Qwen3 ASR CPP** — Local GGUF-based engine (Windows, Linux, macOS) +- **Crisp ASR** — Local engine with multiple backends (Parakeet, Canary, Cohere, Fire Red, Fun-ASR Nano, GigaAM, GLM, Granite, Qwen3, Mega, MOSS Diarize, Omni, Kyutai, SenseVoice, ARK, Voxtral) and a Forced-aligner picker (built-in / Canary CTC / Qwen3 / 12 wav2vec2 language aligners) for word-level timestamps ### How do I use Speech to Text? 1. Open a video file @@ -111,11 +112,14 @@ Use **Synchronization → Change frame rate...** and select the source and targe - **Tesseract** — Open-source OCR engine with language packs - **nOCR** — Nikse's built-in OCR with trainable databases - **Binary OCR** — For binary image comparison +- **PaddleOCR** — Local OCR (standalone build for Windows/Linux, or the pip-installed Python engine) +- **CrispEmbed** — Local OCR with downloadable model backends (PP-OCRv6, GLM-OCR, GOT-OCR2, Qwen3-VL, DeepSeek-OCR-2) +- **llama.cpp** — Local vision-LLM OCR with a managed server and curated models +- **Ollama OCR** — Local LLM-based +- **Apple Vision** — Built-in OCR on macOS - **Google Lens OCR** — Cloud-based - **Google Vision OCR** — Cloud-based -- **Ollama OCR** — Local LLM-based - **Mistral OCR** — Cloud-based -- **PaddleOCR** — Local OCR ### How do I OCR Blu-ray subtitles? 1. Open the `.sup` file via **File → Open** diff --git a/docs/features/ai-assistant.md b/docs/features/ai-assistant.md index 745c88e2690..184d20851db 100644 --- a/docs/features/ai-assistant.md +++ b/docs/features/ai-assistant.md @@ -4,7 +4,8 @@ Get AI help with the current subtitle line — fix errors, shorten it to fit the - **Open:** The robot button in the text editor's button row, or right-click the text box → **AI assistant** - + +![AI Assistant](../screenshots/ai-assistant.png) The assistant works on the **currently selected line only**. The three lines before and after are sent along as read-only context so the model understands the dialogue, but they are never changed. diff --git a/docs/features/ai-review.md b/docs/features/ai-review.md index f9c9f8b3819..8b7e63d4f21 100644 --- a/docs/features/ai-review.md +++ b/docs/features/ai-review.md @@ -10,7 +10,7 @@ Proofread the subtitle text with a local (or remote) large language model - typo ## Engines -- **llama.cpp** — a managed local server. Pick a model from the curated list (Qwen 3.5, Gemma 3, Llama 3.1, EuroLLM, Phi-4 mini); Subtitle Edit downloads the engine and model on first use. A green dot marks models that are already downloaded. Custom `*.gguf` files placed in the llama.cpp models folder also appear. +- **llama.cpp** — a managed local server. Pick a model from the curated list (Qwen 3.5, Qwen 3.6, Gemma 3, Gemma 4, Llama 3.1, EuroLLM, Phi-4 mini, Granite 4.1); Subtitle Edit downloads the engine and model on first use. A green dot marks models that are already downloaded. Custom `*.gguf` files placed in the llama.cpp models folder also appear. - **Ollama** — uses a running [Ollama](https://ollama.com) instance; type a model name or pick one from the server. - **OpenAI-compatible** — any endpoint that speaks the OpenAI chat API: LM Studio, KoboldCpp, vLLM, a llama.cpp server on another machine, or cloud APIs (OpenAI, Groq, OpenRouter, DeepSeek, Mistral, Gemini). Enter the URL, model name, and an API key if the service needs one. @@ -78,6 +78,7 @@ The **Edit prompt...** button opens the review instructions sent to the model. ` ## Safety rails - Nothing is applied automatically - you decide per suggestion. -- Suggestions that would add or remove formatting tags are discarded. +- ASSA override blocks at the start or end of a line (`{\pos(...)\fs54...}`) are never sent to the model - it only sees the text, and the blocks are restored unchanged around its correction. Lines that are pure drawings or tags are skipped. The ASSA actor and style name are sent as read-only context so the model knows who is speaking and whether a line is dialogue or a sign. +- Suggestions that would add or remove the remaining formatting tags (``, inline blocks) are discarded. - Suggestions that change a line's length a lot are flagged with a warning and start unchecked, since they are usually rewrites rather than corrections. - Replies from the model that do not follow the expected format are retried once and then skipped. diff --git a/docs/features/apply-duration-limits.md b/docs/features/apply-duration-limits.md index 64b0077ed49..ce364bcd654 100644 --- a/docs/features/apply-duration-limits.md +++ b/docs/features/apply-duration-limits.md @@ -9,7 +9,8 @@ Enforce minimum and maximum duration limits on subtitle lines. ## Options -- **Minimum duration (ms)** — Set the minimum display time in milliseconds -- **Maximum duration (ms)** — Set the maximum display time in milliseconds +- **Fix min duration** — Enable the minimum limit and set the minimum display time in milliseconds +- **Do not go past shot change** — Shown when shot changes are loaded: a line extended to reach the minimum duration stops at the next shot change +- **Fix max duration** — Enable the maximum limit and set the maximum display time in milliseconds -The preview list updates live as you change values. Lines that cannot be fully fixed (because there is not enough room before the next subtitle) are shown as partial fixes or skipped, and the counts are reported under the list. +The window shows two lists that update live as you change values: the proposed fixes, each with an **Apply** checkbox, and the lines that cannot be fixed (because there is not enough room before the next subtitle). The counts are reported under the lists. diff --git a/docs/features/apply-min-gap.md b/docs/features/apply-min-gap.md index b0ea577f156..05b4ac267e6 100644 --- a/docs/features/apply-min-gap.md +++ b/docs/features/apply-min-gap.md @@ -9,6 +9,6 @@ Set a minimum time gap between consecutive subtitles. ## Options -- **Min. ms between lines / Min. frames between lines** — The minimum time between subtitles. The unit (milliseconds or frames) follows the global *Use time format HH:MM:SS:FF* setting. +- **Min. ms between lines / Min. frames between lines** — The minimum time between subtitles. The unit (milliseconds or frames) follows the global *Use frame mode (hh.mm.ss.ff)* setting. The preview list updates live and the status text shows the number of gaps that were fixed. diff --git a/docs/features/assa-apply-advanced-effects.md b/docs/features/assa-apply-advanced-effects.md index 5ec028338b8..c24b9d42386 100644 --- a/docs/features/assa-apply-advanced-effects.md +++ b/docs/features/assa-apply-advanced-effects.md @@ -27,6 +27,17 @@ Effects range from text animations (typewriter, karaoke, bounce-in) to visual en > Note: Audio-reactive effects (like **Audio text pulse**) are only shown when waveform/audio peak data is available. +## Effect Settings + +Some effects show their own settings next to the effect description in the list: + +- **Infinite snow** — **Flake count** +- **Infinite starfield** — **Star count**, **Speed** +- **Karaoke** — **Right-to-left** +- **Karaoke - active word pop** — **Auto-detect active word**, **Right-to-left**, **Active word glow** (with a glow color), **Active color**, **Inactive color** +- **Lower third** — **Accent color** +- **Word spacing** — **Spacing (pixels)** + ## Available Effects ### Text Animation Effects diff --git a/docs/features/assa-attachments.md b/docs/features/assa-attachments.md index 15d7b729b10..059a981ba21 100644 --- a/docs/features/assa-attachments.md +++ b/docs/features/assa-attachments.md @@ -11,15 +11,15 @@ Manage fonts and images embedded as attachments in an Advanced SubStation Alpha 1. Open a subtitle file in ASS/SSA format. 2. Go to **ASSA tools** → **Attachments...** to open the attachments manager. 3. View the list of currently embedded fonts and images. -4. Use **Add** to attach new font or image files. -5. Select an attachment and use **Delete** or **Delete All** to remove attachments. +4. Use **Attach...** to attach new font or image files, or **Import...** to take the attachments from another .ass file. +5. Right-click an attachment and use **Delete** or **Clear** to remove attachments. 6. Select an image attachment to see a preview. 7. Click **OK** to save changes. ## Features ### Font Attachments -- Embed TrueType (.ttf) and OpenType (.otf) font files directly in the subtitle file. +- Embed TrueType (.ttf) font files directly in the subtitle file (the file picker offers *.ttf). - Ensures the correct fonts are available regardless of the system fonts installed. ### Image Attachments @@ -30,6 +30,9 @@ Manage fonts and images embedded as attachments in an Advanced SubStation Alpha - Add one or more attachments at a time. - Delete individual attachments or clear all attachments. - View attachment names and sizes. +- **Export...** saves the selected attachment to a file. +- With a font selected, **Copy to clipboard** copies its font name and **Copy to font collector** copies the font file to Subtitle Edit's `Fonts` folder. +- Reorder via the right-click menu: **Move up** (`Ctrl+Up`), **Move down** (`Ctrl+Down`), **Move to top**, **Move to bottom**. ### Trim Fonts to Used Characters - **Trim fonts to used characters...** rewrites each embedded TrueType font so that only the glyphs used by the subtitle's text keep their outlines - a full CJK font of many megabytes typically shrinks to a few hundred kilobytes. diff --git a/docs/features/assa-draw.md b/docs/features/assa-draw.md index e0cc0e1b759..f41bd253e23 100644 --- a/docs/features/assa-draw.md +++ b/docs/features/assa-draw.md @@ -24,7 +24,11 @@ A vector drawing tool for creating ASSA drawing commands (\p1 ... \p0) used in A - **Bézier Curve:** Draw smooth curves with control points. - **Rectangle:** Draw rectangles. - **Circle:** Draw circles/ellipses. -- **Color Picker:** Pick a color from the canvas. + +### Toolbar Actions +- **Save**, **Load**, **Copy to Clipboard** (`Ctrl+C`). +- **Close Shape** (`F8`/`Enter`), **Delete Shape** (`Del`), **Clear All** (`Ctrl+N`). +- **Zoom in**, **Zoom out**, **Reset View** (`Ctrl+0`), **Toggle Grid** (`Ctrl+G`). ### Canvas - Configurable canvas size (default 1920×1080). @@ -35,7 +39,8 @@ A vector drawing tool for creating ASSA drawing commands (\p1 ... \p0) used in A ### Shape Management - Organize shapes in a tree view with layers. - Select, move, and delete shapes or individual points. -- Set layer colors. +- Set the layer color with the color swatch in the layer panel. +- **Change Layer** moves the selected shape to another layer; **Use shape for erase (iclip)** turns it into an \iclip cut-out. - Edit exact X/Y coordinates for selected points. ### Import / Export diff --git a/docs/features/assa-override-tags.md b/docs/features/assa-override-tags.md index bfd97b8b2a4..43b516153bb 100644 --- a/docs/features/assa-override-tags.md +++ b/docs/features/assa-override-tags.md @@ -21,6 +21,8 @@ Apply ASSA override tags to subtitle lines with a live video preview. ### Override Tag Selection - Browse a list of common ASSA override tags. - Select from predefined tags or build custom tag combinations. +- **Use** replaces the tag field with the chosen tag, **Append** adds it to what is there, and **Clear** empties the field. +- **History** — Shown once tags have been applied before; opens the list of previously used tags. - Remembers the last used override tag. ### Application Scope diff --git a/docs/features/assa-properties.md b/docs/features/assa-properties.md index 8c120b285ff..724592366d4 100644 --- a/docs/features/assa-properties.md +++ b/docs/features/assa-properties.md @@ -11,7 +11,7 @@ Edit the script info header properties of an Advanced SubStation Alpha (ASS/SSA) 1. Open a subtitle file in ASS/SSA format. 2. Go to **ASSA tools** → **Properties...** to open the properties dialog. 3. Edit the script info fields as needed. -4. Set the video resolution (PlayResX / PlayResY) — use **Get resolution from current video** if a video is loaded. +4. Set the video resolution (PlayResX / PlayResY) — the **...** button picks a standard resolution, and **Pick resolution from current video** is shown when a video is loaded. 5. Choose a wrap style and border/shadow scaling mode. 6. Click **OK** to save changes. @@ -29,13 +29,15 @@ Edit the script info header properties of an Advanced SubStation Alpha (ASS/SSA) ### Video Resolution - **Video Width / Height:** Sets the PlayResX and PlayResY values used for style positioning. -- **Get resolution from current video:** Automatically fills width and height from the currently loaded video. +- **...** button: Pick from a list of standard resolutions (4K DCI, 4K UHD, Full HD 1080p, HD 720p, SD PAL, ...). +- **Pick resolution from current video:** Fills width and height from the currently loaded video; only shown when a video is loaded. ### Wrap Style - Controls how long lines are word-wrapped (smart wrapping, end-of-line wrapping, no wrapping, or smart wrapping with wider lower line). ### Border and Shadow Scaling - Choose whether border and shadow sizes scale with the script resolution or the video resolution. +- The dropdown offers **Yes**, **No** and **N/A** (not set). ## Keyboard Shortcuts diff --git a/docs/features/assa-resolution-resampler.md b/docs/features/assa-resolution-resampler.md index 623812c8496..79165dc7569 100644 --- a/docs/features/assa-resolution-resampler.md +++ b/docs/features/assa-resolution-resampler.md @@ -38,8 +38,13 @@ Resample ASSA subtitle styles and positions from one video resolution to another | F1 | Show help | | Escape | Close dialog | -## Resolution set automatically from the video +## Resolution set from the video -With *Options → Settings → ASSA* set to update the resolution automatically, loading a video (or converting to ASS/SSA) writes the video's dimensions into PlayResX / PlayResY, and the styles are resampled to match. +When a video is opened and the subtitle's PlayResX / PlayResY differ from the video's picture size, this dialog opens by itself, pre-filled with the subtitle resolution as source and the video resolution as target - the same check Aegisub makes when loading a video. Press **OK** to resample the subtitle to the video, or **Cancel** to leave it as it is. Untick *Ask when a video with a different resolution is opened* to stop the prompt; the subtitle is then resampled to the video automatically. -That resampling only applies to a subtitle whose header already named a resolution - a file authored for another picture size. A header without PlayResX / PlayResY is the built-in one, or your default style storage written into it (an OCR result takes that route): there, only the small built-in font sizes (25 and below) are lifted to the video height, and your font size, margins, outline and shadow are kept exactly as configured. +Both behaviours can be changed under *Options → Settings → Subtitle formats*: + +- **ASSA: set resolution (PlayResX/PlayResY) from the video when a video is opened** - turn off to never touch the resolution on video load. +- **ASSA: ask before changing the resolution to match the video** - turn off to resample automatically instead of showing the dialog. + +The resampling only applies to a subtitle whose header already names a resolution - a file authored for another picture size. A header without PlayResX / PlayResY is the built-in one, or your default style storage written into it (an OCR result takes that route): there the video's dimensions are written into the header silently, only the small built-in font sizes (25 and below) are lifted to the video height, and your font size, margins, outline and shadow are kept exactly as configured. diff --git a/docs/features/assa-set-background.md b/docs/features/assa-set-background.md index 00d727c9020..a58d6305010 100644 --- a/docs/features/assa-set-background.md +++ b/docs/features/assa-set-background.md @@ -30,9 +30,8 @@ Add styled background boxes behind subtitle text using ASSA drawing commands. - **Outline color:** Border/outline color around the box. - **Shadow color:** Shadow color. -### Outline and Shadow +### Outline - Adjustable outline width. -- Adjustable shadow distance. ### Fill Width - Option to fill the full width of the video with configurable left and right margins. diff --git a/docs/features/assa-set-position.md b/docs/features/assa-set-position.md index 8db8e6f3e98..cac505f0f33 100644 --- a/docs/features/assa-set-position.md +++ b/docs/features/assa-set-position.md @@ -23,9 +23,9 @@ Visually position subtitles on screen by dragging them over a video screenshot, - Supports left, center, and right horizontal alignment. - Supports top, center, and bottom vertical alignment. -### Resolution Handling -- Source and target resolution settings. -- Automatically scales positions between different video resolutions. +### Rotation and Centering +- **Center horizontally** / **Center vertically** buttons. +- **Rotation** — Rotates the text with a \frz tag. ### Result - Generates ASSA \pos(x,y) override tags. diff --git a/docs/features/assa-styles.md b/docs/features/assa-styles.md index 0e349269298..85e40850813 100644 --- a/docs/features/assa-styles.md +++ b/docs/features/assa-styles.md @@ -23,6 +23,7 @@ Manage Advanced SubStation Alpha (ASS/SSA) subtitle styles, including file style - View all styles defined in the current subtitle file. - Add, remove, copy, or rename styles. - Import styles from other subtitle files. +- The **Usages** column shows how many lines use each style. Right-click a style for **Take usages from...** (pick which other styles' lines should use it instead) and **Replace style with...**. - Reorder styles via the right-click menu — **Move up** (`Ctrl+Up`), **Move down** (`Ctrl+Down`), **Move to top**, and **Move to bottom**. Select several styles first to move them as a block. This is the order the styles are written to the file with, so it is also the order other tools will list them in. ### Storage Styles @@ -30,11 +31,13 @@ Manage Advanced SubStation Alpha (ASS/SSA) subtitle styles, including file style - Storage styles are **templates only** — they are saved with your settings and reused across subtitles, but they do not belong to the current subtitle and do not change its appearance on their own. - **To apply a storage style to the current subtitle:** select it in the right panel, choose **Copy to file styles**, then assign that style to the relevant subtitle lines. - Copy styles between file and storage. +- Storage styles are grouped in **categories**: pick one from the **Category** dropdown, use the **New category...**, **Rename category...** and **Delete category** buttons next to it, and right-click a style for **Move to category...**. - Set a style as the default for new subtitles (this only affects newly created/converted subtitles, not the current one). - **Default template:** when a new subtitle is created as ASSA, or an existing subtitle is converted to ASSA (via the format dropdown or *Save as*), the whole category of the default style is copied into the file — so a multi-style template (e.g. dialogue + signs) survives the conversion — and the lines start out on the default style. If no style is marked as default, the styles in the *Default* category are used. SubStation Alpha has its own separate storage styles (**SSA tools** → **Styles...**) that work the same way for conversions to SSA. ### Style Properties - **Font:** Name, size, bold, italic, underline, strikeout. The `...` button opens the font picker with two tabs — **Installed fonts** and **Collected fonts** (fonts gathered in Subtitle Edit's own `Fonts` folder inside the data folder) — the paperclip button picks a font from the subtitle's attachments, and the font button opens the **Font collector**, which shows the fonts the subtitle uses, whether they are available, and can copy them to a folder — including SE's `Fonts` folder. The font combo also lists the collected fonts. +- **Scale X**, **Scale Y**, **Spacing**, **Angle**. - **Colors:** Primary, secondary, outline, and back (shadow) colors. - **Border:** Outline width and border style (outline + shadow or opaque box). - **Shadow:** Shadow depth. diff --git a/docs/features/audio-visualizer.md b/docs/features/audio-visualizer.md index c3c9b558a7d..830e8da9db3 100644 --- a/docs/features/audio-visualizer.md +++ b/docs/features/audio-visualizer.md @@ -22,6 +22,8 @@ The spectrogram can be generated and toggled independently of the waveform. If y | **Alt+Scroll wheel** | Horizontal zoom in/out | | **Shift+Scroll wheel** | Vertical zoom in/out | +With **Mouse-wheel sets video position** on (**Options → Settings → Waveform**), the plain scroll wheel steps the video position instead of scrolling the view; **Mouse-wheel video position step** sets the step size (a frame, or a number of milliseconds). Ctrl (Cmd on macOS) + wheel keeps its scroll behavior. + ### Subtitle Timing | Mouse Action | Effect | @@ -32,6 +34,8 @@ The spectrogram can be generated and toggled independently of the waveform. If y | **Click** on empty area | Set video position | | **Click** on subtitle | Select the subtitle | | **Double-click** on subtitle | Set video position and select subtitle | + +What a single click and a double-click do is configurable: **Waveform single-click action** (set video position, pause, select subtitle, center, or combinations) and **Waveform double-click action** (none, select subtitle, center, pause, play) in **Options → Settings → Waveform**. | **Drag** subtitle left/right edge | Adjust start/end time | | **Drag** subtitle body | Move entire subtitle | | **Right-click** | Context menu | @@ -60,12 +64,13 @@ The spectrogram can be generated and toggled independently of the waveform. If y ## Toolbar -The waveform toolbar (when visible) provides buttons for: -- Zoom in/out (horizontal and vertical) -- Toggle waveform/spectrogram mode -- Toggle grid lines -- Navigate to previous/next shot change -- Apply common subtitle timing actions +The waveform toolbar (when visible) provides: +- Play/pause, play selection (once or repeated), play next, and SE 4 style previous / play current / pause / next text buttons +- New, set start, set end, set start and offset the rest, remove blank lines +- Horizontal and vertical zoom sliders, video position slider and editable position box +- Playback speed, auto-select on play, center, video seek, audio track picker, and a **More** menu + +Switching between waveform and spectrogram is done from the right-click menu, and grid lines are turned on in **Options → Settings → Waveform**; neither is a toolbar button. See [Waveform Toolbar](main-window.md#waveform-toolbar) for the full list. The toolbar can be toggled from **Video → More → Toggle waveform toolbar**. Subtitle Edit 5 also supports waveform toolbar customization, including button visibility/order and import/export of toolbar settings. diff --git a/docs/features/auto-translate-advanced.md b/docs/features/auto-translate-advanced.md index 8d33213d166..6b3dcb007eb 100644 --- a/docs/features/auto-translate-advanced.md +++ b/docs/features/auto-translate-advanced.md @@ -28,6 +28,8 @@ With an advanced engine selected, click **Advanced...** in the engine settings r - **Top-p**, **Top-k**, **Repeat penalty** — Standard sampling options, passed through when set - **Max tokens per reply** — Response length cap; unset means no cap is sent - **Server context size (tokens)** — The context window for the locally started llama.cpp server (default 16384). Bigger batches, more history, and a long synopsis/glossary need more context. Only affects the local llama.cpp server; Ollama manages its own context +- **Extra server parameters** — Additional `llama-server` command-line arguments, e.g. `-ngl 30 --no-mmap`, applied when the local server starts. They are appended after Subtitle Edit's own flags, so a repeated flag overrides Subtitle Edit's value +- **Use only these parameters** — Start `llama-server` with the parameters above instead of Subtitle Edit's own tuning; only the model, host and port are still set. Ignored while the field is empty ## llama.cpp Engine Settings diff --git a/docs/features/auto-translate.md b/docs/features/auto-translate.md index f6c6233dcf7..40f5aa076b3 100644 --- a/docs/features/auto-translate.md +++ b/docs/features/auto-translate.md @@ -12,7 +12,7 @@ Automatically translate subtitles using various translation engines and AI servi 1. Open **Translate → Auto-translate...** 2. Select a translation engine -3. Select the source and target languages +3. Select the source and target languages (the button between them swaps the two) 4. Click **Translate** to start 5. Review the translations in the grid 6. Click **OK** to apply @@ -22,14 +22,14 @@ Automatically translate subtitles using various translation engines and AI servi - **Google Translate V1 API** — Free Google Translate - **Google Translate V2 API** — Google Cloud Translation (requires API key) - **Bing Microsoft Translator** — Azure Cognitive Services (requires API key) -- **DeepL V2 translate** — DeepL translation (requires API key) +- **DeepL V2 translate** — DeepL translation (requires API key). A **Formality** dropdown — Default, More formal, Less formal, More formal (fall back to default), Less formal (fall back to default) — appears for target languages that support it - **LibreTranslate** — Open-source, self-hosted translation - **MyMemory Translate** — Free translation memory - **ChatGPT** — OpenAI AI translation (requires API key) - **LM Studio (local LLM)** — Local LLM translation - **Ollama (local LLM)** — Local LLM-based translation - **Ollama advanced (local LLM)** — Ollama translated in batches with surrounding context, a synopsis and a glossary; see [Advanced local engines](auto-translate-advanced.md) -- **llama.cpp (local LLM)** — Server-managed local LLM translation; Subtitle Edit downloads llama.cpp and a curated model (TranslateGemma, Qwen or Aya Expanse) and runs a local `llama-server` for you. See [Using your own model](#llamacpp-using-your-own-model) to run a model we don't ship, such as TranslateGemma 27B +- **llama.cpp (local LLM)** — Server-managed local LLM translation; Subtitle Edit downloads llama.cpp and a curated model (TranslateGemma, Gemma 4, Qwen, Aya Expanse, MiLMMT or Hy-MT2) and runs a local `llama-server` for you. See [Using your own model](#llamacpp-using-your-own-model) to run a model we don't ship, such as TranslateGemma 27B - **llama.cpp advanced (local LLM)** — The managed llama.cpp server translated in batches with surrounding context, a synopsis and a glossary; see [Advanced local engines](auto-translate-advanced.md) - **OpenAI Compatible API** — Generic engine for any service exposing an OpenAI-compatible `chat/completions` endpoint (vLLM, KoboldCpp, a llama.cpp server on another machine, cloud providers, ...); configure URL, model, prompt, and an optional API key - **Anthropic Claude** — AI translation (requires API key) @@ -45,12 +45,12 @@ Automatically translate subtitles using various translation engines and AI servi - **thammegowda-nllb-serve** — Self-hosted NLLB (No Language Left Behind) server - **winstxnhdw-nllb-api** — NLLB (No Language Left Behind) API - **Baidu Translate** — Baidu translation (requires App ID and secret) -- **CrispASR MADLAD** — Local MADLAD-based translation with downloadable models (shown with size and install status); also available in Batch Convert +- **CrispASR MADLAD** — Local MADLAD-based translation with downloadable models (shown with size and install status); also available in Batch Convert. The language list is the 419 languages the MADLAD-400 model itself knows, so it is both longer and shorter than the usual one: it adds languages such as Iloko, Waray, Papiamento, Moroccan Arabic and Latin, and leaves out ones MADLAD cannot produce — including Cantonese, for which Google Translate, NLLB or any of the LLM engines are the alternatives. Quality varies a lot across that long tail — Google evaluated only about half of the languages the model claims ## llama.cpp: using your own model The models offered in the download list are deliberately kept small enough to run on an ordinary -machine (around 8 GB or less). You are not limited to them — larger models such as TranslateGemma +machine (mostly under 8 GB, the largest around 12 GB). You are not limited to them — larger models such as TranslateGemma 27B work fine if your hardware can handle them. Two ways to use one: @@ -70,8 +70,8 @@ choice and the jump to 27B buys less than the size difference suggests. ## Prompts: chat models and completion models -Every local-LLM engine (LM Studio, Ollama, KoboldCpp, llama.cpp, OpenAI Compatible API) has a -**prompt** you can edit. `{0}` is replaced with the source language and `{1}` with the target +Every local-LLM engine (LM Studio, Ollama, llama.cpp, OpenAI Compatible API) has a +**prompt** you can edit in the engine's Settings dialog (gear button). `{0}` is replaced with the source language and `{1}` with the target language, both as English names. By default the prompt is an instruction and Subtitle Edit appends the subtitle text after it — what @@ -91,8 +91,11 @@ Translate this from {0} to {1}: The trailing `{1}:` cue is what makes such a model translate at all — without it, it tends to echo the source. Set the model's temperature to 0 where the engine offers it. Curated MiLMMT models in -the llama.cpp engine's download list carry this prompt already; for LM Studio, KoboldCpp, Ollama or -your own OpenAI-compatible server, paste it into the engine's prompt field. +the llama.cpp engine's download list carry this prompt already; for LM Studio, Ollama or +your own OpenAI-compatible server (vLLM, KoboldCpp, ...), paste it into the engine's prompt field. + +Headless runs take the same prompt via `seconv --translate-prompt:` — see +[Auto-translate (command line)](../reference/command-line.md#custom-prompt). ## Engine Configuration @@ -102,6 +105,8 @@ Depending on the selected engine, you may need to provide: - **API URL** — Custom endpoint URL (for self-hosted services) - **Model** — Specific model to use (for AI engines) +The gear button opens the engine's **Settings** dialog with **Line merge**, **Delay in seconds between requests**, **Max bytes per request** and, for engines that have one, the **Prompt text** with a reset-to-default button. + The **llama.cpp advanced** and **Ollama advanced** engines translate in batches with context, synopsis, and glossary support — see [Advanced Local Engines](auto-translate-advanced.md). ## Translation Review diff --git a/docs/features/batch-convert.md b/docs/features/batch-convert.md index cb5d95c1595..03f345307cd 100644 --- a/docs/features/batch-convert.md +++ b/docs/features/batch-convert.md @@ -43,6 +43,7 @@ You can chain multiple conversion functions: - Sort by - Change resolution — the [ASSA resolution resampler](assa-resolution-resampler.md) applied to every file - Change style — swap one ASSA style for another, or import styles from a file, optionally trimming unused ones +- Change style properties — set font, size, colors, outline and other properties of the ASSA styles in every file - Embed fonts — embed the fonts an ASSA file uses as attachments, optionally [trimmed](assa-attachments.md) to the characters actually used - Adjust image brightness/alpha/color — for image-based subtitles - Auto translate @@ -53,6 +54,7 @@ You can chain multiple conversion functions: Batch Convert can machine-translate files as part of the conversion. Supported engines: - Ollama +- Ollama advanced (local LLM) — the batch/context engine described in [Advanced local engines](auto-translate-advanced.md), talking to Ollama's OpenAI-compatible endpoint - LibreTranslate - LM Studio - llama.cpp — fully managed: Batch Convert reuses an already-running local `llama-server`, or downloads llama.cpp plus a curated translation model (e.g. TranslateGemma) and starts the server for you. Point it at your own server via the remote-server option in [Auto-translate](auto-translate.md) settings. @@ -73,9 +75,10 @@ Supported OCR engines in Batch Convert: - BinaryOcr - Tesseract - Ollama -- llama.cpp (curated OCR vision models — GLM-OCR, LightOnOCR, PaddleOCR-VL, HunyuanOCR 1.5; a local `llama-server` is started automatically) +- llama.cpp (curated OCR vision models, best-first — GLM-OCR, PaddleOCR-VL, HunyuanOCR 1.5, LightOnOCR; a local `llama-server` is started automatically) - CrispEmbed (local, multiple model backends — see [OCR](ocr.md#crispembed)) - PaddleOCR (Windows and Linux only) +- Apple Vision (macOS only — pick a language, nothing to download) Subtitle Edit 5 can auto-detect language and pixels-are-space settings for nOcr/BinaryOcr in many batch workflows. This reduces the amount of manual setup needed when converting many image-based subtitle files with similar fonts. @@ -89,5 +92,7 @@ Speech-to-text batch mode can transcribe multiple media files and save the resul - **Output folder** — Where converted files are saved - **Overwrite existing** — Whether to overwrite files - **Encoding** — Text encoding for output files +- **Keep source file timestamp** — Give the output file the modification time of the source file +- **Language post fix** — Append the detected language code (2- or 3-letter) to the output file name, e.g. `movie.en.srt` For headless batch conversion, see [Command Line (seconv)](../reference/command-line.md). diff --git a/docs/features/beautify-time-codes.md b/docs/features/beautify-time-codes.md index 7651986a162..46da05831dd 100644 --- a/docs/features/beautify-time-codes.md +++ b/docs/features/beautify-time-codes.md @@ -3,7 +3,7 @@ Snap subtitle in- and out-cues to shot changes, frame boundaries, and minimum-gap / duration rules in one pass, using a fully configurable profile. - **Menu:** Tools → Beautify time codes… -- **Profile editor:** Options → Settings → Waveform → gear icon next to *Snap to shot changes*, or *Edit beautify time codes profile…* button inside the tool window. +- **Profile editor:** Options → Settings → Waveform → gear icon next to *Snap to shot changes*, or the **Edit profile...** button inside the tool window. ![Beautify time codes](../screenshots/beautify-time-codes.png) @@ -19,6 +19,8 @@ Above the visualizers a stats line summarises the run: > Subtitles: N · Changed: M · Frame rate: 25 · Shot changes: K +By default the frame boundaries are calculated from the video frame rate. Tick **Use exact time codes** and click **Extract time codes** to read the real frame time codes from the video file instead (a progress bar shows the extraction); the stats line then says *use exact time codes*. This matters for variable-frame-rate video, where calculated frames drift from the actual ones. + Below the visualizers, the **change navigator** lets you step through every cue the beautify pass moved: - **▲ / ▼** — previous / next change. Both visualizers center on the change. diff --git a/docs/features/binary-edit.md b/docs/features/binary-edit.md index 0691340ed90..d2b44b3fa1e 100644 --- a/docs/features/binary-edit.md +++ b/docs/features/binary-edit.md @@ -34,19 +34,23 @@ From the Tools menu (all lines) or the right-click menu (selected lines): - **Crop images** — Trim transparent borders - **Adjust brightness...** / **Adjust alpha (transparency)...** / **Adjust color...** — Image corrections with live preview - **Adjust durations...** / **Apply duration limits...** — Same duration tools as for text subtitles -- **Set text** — Replace a line's image with newly rendered text (font, colors, outline, shadow, box) - **Append subtitle...** — Append another image-based file, keeping its time codes or offsetting them -- **Sort by start time**, **Toggle forced**, **Select forced/non-forced lines**, **Import time codes...** +- **Sort by start time** -The Synchronization menu offers **Adjust all times**, **Change frame rate**, and **Change speed**. +The right-click menu additionally offers **Insert before** / **Insert after** (a new line from an image file), **Toggle forced**, **Select forced/non-forced lines**, and **Show earlier/later...**. + +Under the image, **Set text** replaces the selected line's image with newly rendered text (font, colors, outline, shadow, box), **Import** replaces it with an image file, and **Export** saves it as a PNG. + +The File menu has **Import time codes...** (apply the timing of a text subtitle to the lines) next to Export, and the Synchronization menu offers **Adjust all times**, **Change frame rate**, and **Change speed**. ## Export There is no in-place save — use File → Export: -- **Blu-ray (sup)**, **VobSub (sub/idx)** +- **Blu-ray (sup)**, **DVD sup**, **VobSub (sub/idx)** - **BDN/xml**, **BDN/xml 8-bit**, **DOST/png**, **Final Cut Pro + image** +- **D-Cinema interop png**, **D-Cinema SMPTE 2014 png** - **IMSC 1.1 image profile**, **WebVTT png** - **Images with HTML index**, **Images with time code** -The **Export** button under the list saves the selected line's image as a PNG. Closing with unexported changes asks for confirmation. +Closing with unexported changes asks for confirmation. diff --git a/docs/features/bridge-gaps.md b/docs/features/bridge-gaps.md index 3c2997d9b82..36c20c9f649 100644 --- a/docs/features/bridge-gaps.md +++ b/docs/features/bridge-gaps.md @@ -13,4 +13,4 @@ Extend subtitle durations to fill gaps between consecutive subtitles. - **Min. gap** — Keep at least this much gap between the bridged subtitles - **Percent for previous** — How much of the gap is given to the previous subtitle (the rest goes to the next subtitle) -Values are entered in milliseconds, or in frames when the global *Use time format HH:MM:SS:FF* setting is enabled. The preview updates live and the status text shows the number of bridged gaps. +Both values are always entered in milliseconds; only the gap column in the preview switches to frames when the global *Use frame mode (hh.mm.ss.ff)* setting is enabled. The preview updates live and the status text shows the number of bridged gaps. diff --git a/docs/features/burn-in.md b/docs/features/burn-in.md index a48819d4eea..35adf077789 100644 --- a/docs/features/burn-in.md +++ b/docs/features/burn-in.md @@ -16,7 +16,7 @@ Hardcode (burn-in) subtitles permanently into a video file using FFmpeg. 4. Configure video encoding settings (encoder, preset, CRF, pixel format) 5. Configure audio settings (encoder, sample rate, bit rate, stereo) 6. Select an output folder -7. Click **Generate** to start encoding +7. Click **Generate** to start encoding (or use the **Generate** split-button item **Prompt for ffmpeg parameters and generate** to edit the FFmpeg command line first) ## Font Settings @@ -31,7 +31,11 @@ Hardcode (burn-in) subtitles permanently into a video file using FFmpeg. - **Spacing** — Letter spacing, from -20 to 100. The preview is rendered by libass, so it shows the real spacing - **Alignment** — Subtitle alignment position - **Margins** — Horizontal and vertical margin offsets -- **Fix RTL** — Fix right-to-left text rendering + +## Effect and Logo + +- **Effect** — Browse button opening a picker with one entry to apply to every line: **Fix right-to-left**, **Fade in/out**, **Slow font size change**, **Increase font kerning**, **Scroll up**, **Scroll down**, **Rotate in**, **Tilt bounce** and **Font size bounce in**. The selected effect is shown next to the button +- **Logo** — Browse button opening a dialog to pick a PNG watermark and place it by dragging it over the video; the file name and position are shown next to the button ## Video Settings @@ -102,9 +106,14 @@ builds all include VideoToolbox. - **Bit rate** — Audio bit rate - **Stereo** — Stereo or mono output +## Cut and Target File Size + +- **Cut** — Encode only the part between **From time** and **To time** (the browse buttons pick the times from the video) +- **Target file size (requires 2 pass encoding)** — Two-pass encode aimed at **File size in MB**; **Match source video size** takes the target from each input file's own size instead (also per file in batch mode) + ## Batch Mode -Multiple video/subtitle pairs can be queued as jobs for batch processing. +Click **Batch mode** to queue several video/subtitle pairs (**Single mode** switches back). The list shows file name, size, subtitle file and status, with **Add...**, **Remove**, **Clear** and **Pick subtitle file...** buttons. **Output properties...** sets the output folder and naming; the chosen folder (or **Use source folder**) is shown next to it. ## Keyboard Shortcuts diff --git a/docs/features/change-formatting.md b/docs/features/change-formatting.md index 0a99b8eb759..49d89c357d2 100644 --- a/docs/features/change-formatting.md +++ b/docs/features/change-formatting.md @@ -1,6 +1,6 @@ # Change Formatting -Add or remove formatting (bold, italic, underline, etc.) from subtitle text. +Convert one kind of formatting (bold, italic, underline, color) in the subtitle text into another. - **Menu:** Tools → Change formatting... diff --git a/docs/features/change-frame-rate.md b/docs/features/change-frame-rate.md index 96cb8267db0..667812e061f 100644 --- a/docs/features/change-frame-rate.md +++ b/docs/features/change-frame-rate.md @@ -16,5 +16,12 @@ Convert subtitle timings from one frame rate to another. - 30 - 50 (PAL interlaced) - 59.94 (NTSC interlaced) +- 60 + +## Options + +- **From frame rate** / **To frame rate** — Pick from the list, or click the browse button next to either to choose a video file; its frame rate is read and selected +- The swap button exchanges the from and to values +- **OK** applies the conversion; **Cancel** closes without changes The window remembers its size and position between sessions. diff --git a/docs/features/change-speed.md b/docs/features/change-speed.md index 1086a610445..3de18489a1b 100644 --- a/docs/features/change-speed.md +++ b/docs/features/change-speed.md @@ -12,4 +12,12 @@ Adjust subtitle timings for videos with different playback speeds. - Converting between PAL (25fps) and NTSC (23.976fps) video speeds - Adjusting for speed-up or slow-down transfers +## Options + +- **Speed in %** — 1 to 1000 (default 100) +- **From drop-frame value** — Sets the speed to 100.1001 +- **To drop-frame value** — Sets the speed to 99.9001 +- **Adjust all** / **Adjust selected lines** / **Adjust selected lines and forward** — Which lines are changed (the selection options need lines selected in the main window) +- **Apply** applies the change to the subtitle; **Done** closes the window + The window remembers its size and position between sessions. diff --git a/docs/features/chapters.md b/docs/features/chapters.md index cbbb2202c6e..f7047fdd507 100644 --- a/docs/features/chapters.md +++ b/docs/features/chapters.md @@ -6,6 +6,7 @@ Edit the chapter marks of the loaded video: add them at the video position, impo - **Shortcut:** Configurable +![Chapters](../screenshots/chapters.png) Chapters belong to the video, not to the subtitle, so a video must be loaded. Chapters are drawn on the [audio visualizer](audio-visualizer.md) as labelled marks, which makes them useful as landmarks while subtitling even if you never write them back to the video. diff --git a/docs/features/convert-actors.md b/docs/features/convert-actors.md index c2235514d0c..ed13d24d9c5 100644 --- a/docs/features/convert-actors.md +++ b/docs/features/convert-actors.md @@ -52,4 +52,6 @@ Click **OK** to apply all checked conversions, or **Cancel** to discard all chan ## Notes - When converting from a bracketed inline format (e.g. `[]` or `()`) to the **Actor** property, a subtitle line that contains two differently labeled speakers on separate lines will be automatically split into two separate subtitle entries, each with its own Actor value. +- A bracketed tag that a line splitter broke in half — `(Speaker` at the end of one line, `2)` at the start of the next — is recognized and read as one tag. - The selected settings (from/to type, color, casing, only names) are saved and restored between sessions. +- For text-to-speech you do not have to convert the file first: the [Text to Speech](text-to-speech.md) window detects speaker names in the text when you generate, and offers to cast them on the fly without changing the subtitle itself. Convert actors is the tool for making the actors part of the file. diff --git a/docs/features/cut-video.md b/docs/features/cut-video.md index d2a3c2260db..0a8d56680ce 100644 --- a/docs/features/cut-video.md +++ b/docs/features/cut-video.md @@ -2,7 +2,7 @@ Cut segments from a video file using an audio visualizer and video player. -- **Menu:** Video → Cut video... +- **Menu:** Video → More → Cut video... - **Shortcut:** Configurable @@ -10,7 +10,7 @@ Cut segments from a video file using an audio visualizer and video player. ## How to Use -1. Open **Video → Cut video...** +1. Open **Video → More → Cut video...** 2. The video is loaded with the audio visualizer 3. Define segments by adding entries and setting their start/end points 4. Choose the cut type (cut or merge segments) @@ -28,12 +28,13 @@ Cut segments from a video file using an audio visualizer and video player. ## Cut Types -- **Cut segments** — Output only the selected segments -- **Merge segments** — Concatenate the selected segments into a single output file +- **Cut segments** — Remove the listed segments from the video and keep the rest +- **Merge segments** — Keep only the listed segments and concatenate them into a single output file ## Video Settings -- **Video extension** — Output container format (`.mkv`, `.mp4`, `.webm`, `.mp3`, `.wav`) +- **Video extension** — Output container format (`.mkv`, `.mp4`, `.mp3`, `.wav`) +- **Also cut subtitle** — Shown when a subtitle is loaded. Writes the subtitle re-timed to the cut video's timeline next to the output video (same base name; ASSA stays ASSA, other formats are written as SubRip) ## Audio Visualizer diff --git a/docs/features/edit.md b/docs/features/edit.md index 74e223a9489..31cc8b674f7 100644 --- a/docs/features/edit.md +++ b/docs/features/edit.md @@ -32,6 +32,8 @@ Options: - Whole word (checkbox) - Search type (radio buttons): Case sensitive, Case insensitive, or Regular expression +With **Regular expression** selected, right-click the search box for a helper flyout that inserts common regex building blocks. + > **Matching a line break with a regular expression:** use `\n` between the words on the two lines (for example `ear\ntwice`). `\r\n` and `\r` are accepted too and are treated the same as `\n`, so a rule works regardless of how it was written or which platform created it. > **`^` and `$` in Find and Replace** match the start and the end of the whole subtitle text, so in a two-line subtitle `^-` only finds the dash on the first line. Write `(?m)` in front of the pattern - `(?m)^- ` - to make them match at every line break instead. (Multiple replace is the other way around, see below.) @@ -115,6 +117,7 @@ Right-click a category node to open its context menu: - **New category** — add a sibling category - **New rule** — add a rule to this category - **Move up / Move down** — reorder categories +- **Move to top / Move to bottom** — move the category to the first or last position - **Delete** — remove the category and all its rules - **Import** — load rules from a `.template` file (JSON or legacy SE4 XML), a `.csv` file, or a Subtitle Edit 4 `Settings.xml` (its multiple replace groups are imported directly) - **Export** — save selected categories to a `.template` (JSON) or `.csv` file @@ -127,6 +130,7 @@ Right-click a rule node to open its context menu: - **Duplicate** — insert a copy of the rule above the current one - **Insert before / Insert after** — add a new rule relative to this one - **Move up / Move down** — reorder within the category +- **Move to top / Move to bottom** — move the rule to the first or last position in the category - **Delete** — remove the rule Double-clicking a rule also opens the **Edit rule** dialog. @@ -138,6 +142,8 @@ Double-clicking a rule also opens the **Edit rule** dialog. | `Ctrl+N` | Add a new rule to the selected category, or insert after the selected rule | | `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) | +| `Ctrl+Home` / `Ctrl+End` | Move the selected category or rule to the top / bottom (`Cmd` on macOS) | | `Ctrl+Shift+-` | Collapse all categories | | `Ctrl+Shift++` | Expand all categories | | `Delete` | Delete the selected rule (focus must be in the rules tree) | @@ -180,6 +186,13 @@ General,"hello, world","say ""hi""",greeting,true,CaseInsensitive Regex,\d+,#,strip numbers,true,RegularExpression ``` +## Go to Line Number + +Jump to a subtitle by its line number. + +- **Menu:** Edit → Go to line number... +- **Shortcut:** `Ctrl+G` + ## Modify Selection Select or deselect subtitle lines based on rules (e.g., text contains, duration, etc.). @@ -201,3 +214,9 @@ Invert the current selection (select unselected lines, deselect selected ones). ## Toggle Right-to-Left Toggle right-to-left text direction for languages like Arabic and Hebrew. + +Three more right-to-left tools act on the selected lines: + +- **Fix RTL via Unicode control chars** — add Unicode right-to-left control characters to the text, for players that ignore the text direction +- **Remove Unicode control chars** — strip those control characters again +- **Reverse RTL start/end** — reverse the start and end of right-to-left lines (for punctuation that ended up on the wrong side) diff --git a/docs/features/embedded-subtitles.md b/docs/features/embedded-subtitles.md index 0da4a96047c..945297f753c 100644 --- a/docs/features/embedded-subtitles.md +++ b/docs/features/embedded-subtitles.md @@ -14,7 +14,7 @@ The embedded subtitle editor edits subtitle tracks in Matroska containers: - `.mkv` - `.webm` -Subtitle tracks can also be inspected from MP4-style containers (`.mp4`, `.m4v`, `.mov`), but the editor warns when the loaded file cannot be edited as a Matroska container. +MP4-style containers (`.mp4`, `.m4v`, `.mov`) open a separate MP4 editor instead — see [MP4 Files](#mp4-files) below. Embedded subtitle reading supports a range of sources elsewhere in Subtitle Edit, including PGS/VobSub/DVB image tracks in Matroska, MP4-embedded VobSub/WebVTT, and CEA-708 (DTVCC) captions carried in H.264 SEI messages inside MP4. @@ -61,6 +61,16 @@ Common text formats such as SRT, WebVTT, ASSA, and SSA can be embedded. Unsuppor Changes are applied only when you click **Generate** and create the new video file. +## MP4 Files + +When the loaded video is `.mp4`, `.m4v` or `.mov`, **Video → Add/remove embedded subtitles...** opens the *Add/remove embedded subtitles (MP4)* window instead. It copies the video and audio streams and rewrites the subtitle track set as `mov_text` (MP4 timed text). + +- Existing subtitle tracks are listed with name, language, default/forced flags and codec. +- **Add** picks an external subtitle file; its split-button menu has **Add current subtitle**. Both add a `mov_text` track with the auto-detected language. +- `mov_text` is plain text, so the current subtitle and any non-SRT file (ASSA, SSA, WebVTT, ...) are converted to SubRip before muxing — styling tags are not kept. +- **Edit**, **Delete** (toggles the deletion mark) and **Preview** work as in the Matroska editor. Preview extracts the track to SRT with FFmpeg, so it is not available for bitmap-based tracks. +- **Generate** writes a new video file with the input file's extension. + ## Keyboard Shortcuts | Key | Action | @@ -73,5 +83,5 @@ Changes are applied only when you click **Generate** and create the new video fi ## Notes - The original video is not overwritten unless you explicitly choose the same output file name. -- Track editing is intended for Matroska/WebM files. Other containers may expose embedded subtitles elsewhere in Subtitle Edit, but this editor warns when the loaded file cannot be edited as a Matroska container. +- Track editing is intended for Matroska/WebM files; MP4-family files use the MP4 editor described above. - Output naming follows the same output-folder and suffix preferences used by the video generation tools. diff --git a/docs/features/file.md b/docs/features/file.md index 2d7e93b77a8..17e80bcf221 100644 --- a/docs/features/file.md +++ b/docs/features/file.md @@ -16,6 +16,10 @@ Create a new empty subtitle. Create a new subtitle while keeping the currently loaded video. +### New window + +Open another Subtitle Edit main window. + ## Open Open an existing subtitle file. @@ -49,10 +53,22 @@ Save the current subtitle to a new file or format. - **Menu:** File → Save as... - **Shortcut:** `Ctrl+Shift+S` +## Save Forced Lines As + +Save only the lines marked as forced (see **Toggle forced** in the [subtitle grid](subtitle-grid.md#context-menu)) to a file. + ## Close Original Close the secondary (original) subtitle file in translation mode. +## Close Translation + +Shown while an editable original is open: discards the translation and makes the original the working subtitle (you are asked to save unsaved changes first). Not offered when the original is a read-only reference. + +## Format Properties + +Formats with their own settings (ASSA, EBU STL, PAC, ...) get a ** properties...** item here, opening the same dialog as the gear button next to the format combo box. + ## Recent Files Quick access to recently opened subtitle files. @@ -77,6 +93,24 @@ See [Import Plain Text](import-plain-text.md) for details. Import image files and create subtitle entries from them. +### Import image-based subtitle for OCR + +Read the subtitles out of an image-based file (Blu-ray `.sup`, VobSub `.sub`, `.ts`, BDN xml) and run [OCR](ocr.md) on them to get editable text. + +### Import image-based subtitle for edit + +Open an image-based subtitle in the [image-based subtitle editor](binary-edit.md) — moving, resizing and re-colouring the bitmaps — without converting them to text. + +### Import CSV/XLSX/ODS with custom columns + +Import a spreadsheet or delimited text file and choose which column holds the start time, end time, text, and so on. + +See [Import Spreadsheets](import-csv-xlsx.md) for details, including the column names that are recognised automatically when a spreadsheet is opened directly. + +### Import formatting + +Copy the formatting — italic/bold/underline, font tags and ASSA override tags — from another subtitle file onto the currently loaded lines, matched line by line. A warning is shown first when the two files do not have the same number of lines. + ## Export ### Export as plain text @@ -87,7 +121,9 @@ Export subtitle text without time codes. Export using a customizable text template. A template has a header, a per-subtitle text part, and a footer. -Placeholders for the text part include `{start}`, `{end}`, `{text}`, `{number}`, `{number-1}`, `{duration}`, `{gap}`, `{actor}`, `{text-line-1}`, `{text-line-2}`, `{text-length}`, `{cps-period}`, `{bookmark}`, `{media-file-name}`, `{text-csv}`, and `{tab}`. +Placeholders for the text part include `{start}`, `{end}`, `{text}`, `{number}`, `{number-1}`, `{duration}`, `{gap}`, `{actor}`, `{text-line-1}`, `{text-line-2}`, `{text-length}`, `{cps-period}`, `{bookmark}`, `{text-csv}`, and `{tab}`. + +The header and footer take `{title}`, `{#lines}`, `{tab}`, `{media-file-name}`, `{media-file-name-full}` and `{media-file-name-with-ext}`. The time code format is built from these letters (anything else is kept as-is): @@ -121,12 +157,30 @@ Export subtitles in Cavena 890 format. ### Export image-based -Export subtitles as images (BDN XML, VobSub, Blu-ray SUP, Final Cut Pro + image, IMSC 1.1 image profile, etc.). +Export subtitles as images. The Export submenu lists: Blu-ray (sup), BDN/xml, BDN/xml 8-bit, IMSC 1.1 image profile, CapMaker Plus, Cheetah Caption, Cheetah Caption Old, Cavena 890, DVB teletext (Manzanita), D-Cinema interop/png, D-Cinema SMPTE 2014/png, EBU STL, DOST/png, DVD sup (MuxMan/Scenarist), Final Cut Pro + image, Images with time code, PAC (Screen Electronics), PAC Unicode (UniPac), VobSub (sub/idx) and WebVTT png — followed by **Custom text formats...** and **Plain text...**. **BDN/xml** writes 32-bit PNGs; **BDN/xml 8-bit** writes the same index.xml with 8-bit palette-indexed PNGs, which is what most Blu-ray authoring tools expect. The **IMSC 1.1 image profile** export writes a single self-contained TTML file with each subtitle embedded as a base64 PNG (`smpte:image` / `smpte:backgroundImage`), media timebase, and percentage-positioned regions — the standardized image-subtitle carriage for streaming and broadcast delivery. +#### ASSA override tags + +Tags in the text are read rather than drawn as literal characters: + +| Tag | Effect on the exported image | +|-----|------------------------------| +| `{\an1}` - `{\an9}` | Places the subtitle, overriding the alignment chosen in the window | +| `{\pos(x,y)}` | Positions the subtitle (coordinates are in the script's own resolution) | +| `{\i1}`, `{\b1}`, `{\c&H..&}`, `{\fn..}`, `{\fs..}` | Italic, bold, colour, font and size | +| `{\alpha&H80&}`, `{\1a}`, `{\3a}`, `{\4a}` | Transparency — all parts at once, or text, outline and shadow separately | +| `{\3c&H..&}`, `{\4c&H..&}` | Outline and shadow colour, overriding the colours chosen in the window | +| `{\bord2}`, `{\shad0}` | Outline and shadow width (in the script's own resolution) — `{\bord0}` turns the outline off | +| `{\fad(in,out)}`, `{\fade(..)}` | Fade in/out — **Blu-ray SUP only** (see below) | + +Anything else is removed before rendering. + +**Fading (Blu-ray SUP).** A subtitle with `{\fad(400,400)}` is written the way a Blu-ray disc does it: the image is encoded once and the fade follows as palette updates, one per video frame, which cost about a kilobyte each instead of a whole new image. Long fades are sampled coarser so a single subtitle never adds more than 60 of them. The other image formats have no way to animate a subtitle and ignore the tag - the image is written fully opaque. + ![Export Image Based](../screenshots/export-image-based.png) diff --git a/docs/features/fix-common-errors.md b/docs/features/fix-common-errors.md index 6232e77d9ce..f7dee55cfce 100644 --- a/docs/features/fix-common-errors.md +++ b/docs/features/fix-common-errors.md @@ -21,10 +21,18 @@ Automatically detect and fix common subtitle errors. 1. Open **Tools → Fix common errors...** 2. Select the language for language-specific rules -3. Check/uncheck the rules you want to apply -4. Click **Go to apply fixes** to see the proposed fixes -5. Review each fix and check/uncheck individual items -6. Click **Apply selected fixes** +3. Check/uncheck the rules you want to apply. The search box filters the rule list, and **Select all** / **Invert selection** flip many rules at once +4. Optionally tick **Try to guess unknown words** to let the OCR fix engine correct words it does not know +5. Click **Go to apply fixes** to see the proposed fixes +6. Review each fix and check/uncheck individual items +7. Click **Apply selected fixes**, then **Done** to close the window + +### Step 2 controls + +- **Refresh available fixes** re-scans the subtitle after you have applied some fixes or edited text in the preview +- The **filter chips** above the list show one chip per rule that found something; click a chip to show only that rule's fixes +- Right-click a fix for **Rule details...** (what the rule does and an example) or **Show only this rule** +- The **Log** link opens a window with everything the run changed, plus errors that were found but could not be fixed automatically ## Available Fixes @@ -58,6 +66,7 @@ Common fixes include: - Remove space between numbers - Remove dialog dash on first line of non-dialog - Normalize strings (Unicode normalization) +- Remove leading `...` (only offered when the continuation style in Settings → General is *None*) Additional language-specific rules are added when applicable (e.g. lowercase `i` → uppercase `I` for English, Turkish ANSI → Unicode, Danish letter `i`, inverted `¿`/`¡` for Spanish). @@ -66,5 +75,5 @@ Additional language-specific rules are added when applicable (e.g. lowercase `i` You can save different sets of fix rules as profiles for different workflows (e.g., broadcast, streaming, fansubbing). - Pick the active profile from the **Profile** combo box at the top of the window -- Click the **...** button next to the combo box to add, rename, delete, import, or export profiles +- Click the **...** button next to the combo box to add, rename, or delete profiles diff --git a/docs/features/import-csv-xlsx.md b/docs/features/import-csv-xlsx.md new file mode 100644 index 00000000000..137db7d38db --- /dev/null +++ b/docs/features/import-csv-xlsx.md @@ -0,0 +1,73 @@ +# Import Spreadsheets (CSV / XLSX / ODS) + +Turn a spreadsheet or delimited text file into subtitle lines. Excel workbooks (`.xlsx`), OpenDocument spreadsheets (`.ods`) and delimited text (`.csv`, `.tsv`, `.txt`) are read directly — no conversion step, and no add-ins required. + +There are two ways in: + +- **Just open the file** — Subtitle Edit recognises a spreadsheet with a subtitle-like header row and imports it automatically +- **File → Import → CSV/XLSX/ODS with custom columns...** — a window where you say which column is which, with a live preview + +- **Menu:** File → Import → CSV/XLSX/ODS with custom columns... +- **Shortcut:** Configurable (no default) + +## Opening a Spreadsheet Directly + +Works for `.csv`, `.xlsx` and `.ods`. Drag the file onto Subtitle Edit, or use **File → Open** and pick the **Spreadsheet files** type in the file dialog (the default *Subtitle files* filter only lists subtitle formats). + +For this to work the file has to be self-describing: + +- **Row 1 must be a header row**, and at least **two** of its cells must be column names Subtitle Edit recognises (see below). If fewer match, the automatic import is skipped and the file is parsed as an ordinary unknown text file — use *Import with custom columns* instead +- One row per subtitle, below the header +- For `.csv` the separator is detected automatically: comma, semicolon or tab. Values may be quoted per RFC 4180, so text can contain the separator, `""` for a literal quote, and line breaks +- Only the **first worksheet** of an `.xlsx`/`.ods` file is read. Cell formatting, colours and merged cells are ignored — only the text matters + +### Recognised Column Names + +Names are matched case-insensitively. + +| Role | Header names | +|------|--------------| +| Start | `start`, `start time`, `starttime`, `start_time`, `in`, `begin`, `from`, `fromtime`, `show`, `timecode`, `tc-in`, `tc in`, `start tc`, `tc start`, plus millisecond variants like `startms`, `start_ms`, `startmillis`, `startmilliseconds` | +| End | `end`, `end time`, `endtime`, `end_time`, `out`, `stop`, `to`, `totime`, `hide`, `tc-out`, `tc out`, `end tc`, `tc end`, plus `endms`, `end_ms`, `endmillis`, `endmilliseconds` | +| Duration | `duration`, `dur`, `durationms` | +| Text | `text`, `content`, `value`, `caption`, `sentence`, `dialog`, `dialogue` | +| Actor / speaker | `speaker`, `voice`, `character`, `character name`, `role`, `name`, `actor`, `rolle`, `sprecher` | + +A minimal file that imports cleanly: + +```csv +Start,End,Text +00:00:01.000,00:00:03.500,"Hello there." +00:00:04.000,00:00:06.000,"General Kenobi!" +``` + +### Time Code Formats + +Each time column is examined as a whole and interpreted as one of: + +- **Time codes** — `hh:mm:ss.ms` / `hh:mm:ss,ms` (also `mm:ss.ms`) +- **Frames** — `hh:mm:ss:ff`, used when *every* value in the column has four parts with a two-digit last part. The current frame rate is used to convert +- **Milliseconds** — a plain number, when the column name ends in `ms`, `millis` or `milliseconds` + +If there is a duration column but no end column, the end time is calculated as start + duration. + +## Import with Custom Columns + +Use this window when the file has extra columns, an unusual column order, header names Subtitle Edit does not know, or no header row at all. + +1. Click **Open file** and pick a `.csv`, `.tsv`, `.txt`, `.xlsx` or `.ods` file. The detected separator is shown next to the file name +2. The upper grid shows the file as it was read. Each column header has a drop-down where you pick its role: **Show** (start time), **Hide** (end time), **Duration**, **Text**, **Character** (actor) or **None**. Columns whose header name is recognised are pre-selected for you +3. The lower grid previews the resulting subtitle, updating as you change the roles +4. **OK** loads the preview as the current subtitle + +Notes: + +- Every role can be used by at most one column — assigning a role that is already taken clears it from the other column +- Only **Text** is required; **OK** stays disabled until a text column is picked. Rows with an empty text cell are skipped +- When the first row does not look like a header it is kept as data and the columns are named *Column 1*, *Column 2*, ... +- **OK replaces the currently loaded subtitle**, so save your work first if you need it + +## See Also + +- [File Menu](file.md) — the rest of the Import menu +- [Import Plain Text](import-plain-text.md) — for text without time codes diff --git a/docs/features/main-window.md b/docs/features/main-window.md index d6987798881..a660ec797cc 100644 --- a/docs/features/main-window.md +++ b/docs/features/main-window.md @@ -18,10 +18,10 @@ The menu bar provides access to all features organized into categories: | Menu | Description | |------|-------------| -| **File** | New, Open, Save, Import, Export, Compare, Statistics | -| **Edit** | Undo, Redo, Find, Replace, Multiple Replace, Modify Selection | +| **File** | New, New window, Open, Save, Save as, Save forced lines as, Close translation, format properties, Import, Export, Compare, Statistics | +| **Edit** | Undo, Redo, Find, Replace, Multiple Replace, Go to line number, right-to-left tools (fix RTL via Unicode control chars, remove Unicode control chars, reverse RTL start/end), Modify Selection | | **Tools** | Fix Common Errors, Batch Convert, Change Casing, Merge/Split, etc. | -| **Plugins** | Run installed plugins; manage installed plugins | +| **Plugins** | Run installed plugins; manage installed plugins (only shown when **Options → Settings → Appearance → Show Plugins menu** is on) | | **Spell check** | Spell checking, dictionaries, find double words / lines | | **Video** | Open/close video, Speech to Text, Text to Speech, Burn-In, etc. | | **Sync** | Adjust All Times, Visual Sync, Point Sync, Change Frame Rate/Speed | @@ -45,13 +45,27 @@ Available toolbar buttons include: |--------|--------| | **New** | Create a new subtitle | | **Open** | Open a subtitle file | +| **Open video** | Open a video file | | **Save** | Save the current subtitle | +| **Save as** | Save the subtitle to a new file or format | | **Find** | Open the Find dialog | | **Replace** | Open the Replace dialog | -| **Fix Common Errors** | Open Fix Common Errors | +| **Multiple replace** | Open Multiple Replace | | **Spell Check** | Run spell check | -| **Help** | Show help / about | -| *...and more* | Configurable in Settings | +| **Fix Common Errors** | Open Fix Common Errors | +| **Remove text for HI** | Open Remove text for hearing impaired | +| **Visual sync** / **Point sync** | Open the sync tools | +| **Beautify time codes** | Open Beautify time codes | +| **Burn-in** | Open Generate video with burned-in subtitle | +| **Auto-translate** | Open Auto-translate | +| **Speech to text** | Open Speech to text | +| **Settings** | Open Settings | +| **Layout** | Choose a main window layout | +| **Source view** | Open the [Source view](source-view.md) | +| **Help** | Show help | +| **Encoding** / **Frame rate** combos | Optional combo boxes next to the format combo (off by default) | + +Every button and combo can be shown or hidden in **Options → Settings → Toolbar**. Some buttons only appear for certain formats — the style manager, properties, attachments and ASSA draw buttons show up when an ASSA, SSA or WebVTT file is loaded. They can be hidden in **Options → Settings → Toolbar** like any other button. @@ -98,7 +112,7 @@ Below (or beside) the subtitle grid is the text editor, where you edit the text **Formatting shortcuts:** - **Ctrl+I** — Toggle italic -- **Ctrl+B** — Toggle bold +- Bold has no default key; assign one in **Options → Shortcuts** **Other shortcuts:** - **Ctrl+Z** — Undo @@ -168,10 +182,10 @@ Two draw styles are available for the waveform: **Classic** (simple lines) and * | **Click** | Empty area | Set video position | | **Click** | Subtitle bar | Select subtitle and set video position | | **Double-click** | Subtitle bar | Select subtitle and set video position | -| **Ctrl+Click** | Subtitle bar | Toggle subtitle in multi-selection | -| **Shift+Click** | Subtitle bar | Set start time of selected subtitle | -| **Ctrl+Click** | Empty area | Set end time of selected subtitle (macOS: Shift+Click) | -| **Alt+Click** | Empty area | Set start time of selected subtitle | +| **Shift+Click** | Anywhere | Set start time of selected subtitle | +| **Ctrl+Click** | Anywhere | Set end time of selected subtitle | +| **Ctrl+Shift+Click** | Anywhere | Set start time of selected subtitle and offset all following subtitles | +| **Alt+Click** | Anywhere | Move selected subtitle so it starts here (duration kept) | | **Drag** | Left edge of subtitle | Resize start time | | **Drag** | Right edge of subtitle | Resize end time | | **Alt+Drag** | Left edge | Resize start and adjust previous subtitle's end simultaneously | @@ -207,20 +221,25 @@ The context menu changes dynamically based on context (e.g., whether a new selec |-----------|-------------| | **Insert new selection** | Insert the drawn selection as a new subtitle | | **Paste new selection** | Paste subtitle from clipboard at current position | +| **Speech to text for new selection...** | Transcribe the audio in the drawn selection | | **Insert at position** | Insert a new empty subtitle at the video position | | **Paste from clipboard** | Paste subtitle text from clipboard | | **Insert subtitle file at position** | Import an entire subtitle file at the current video position | | **Delete at position** | Delete the subtitle at the current position | | **Delete** | Delete the selected subtitle(s) | | **Insert before / Insert after** | Insert a new line before or after the selected subtitle | -| **Split** | Split the selected subtitle at the video position | +| **Copy subtitle / Copy (text only)** | Copy the selected subtitle, with or without time codes | +| **Split line** | Split the selected subtitle | +| **Split line at waveform head** | Split the selected subtitle at the waveform position | | **Merge before / Merge after** | Merge with the previous or next subtitle | | **Filter by layer** | Filter visible subtitles by ASSA layer (ASSA format only) | | **Guess time codes** | Auto-detect subtitle timing from audio silence | | **Toggle shot change** | Add or remove a shot change marker at the video position | +| **Toggle chapter at video position** | Add or remove a [chapter](chapters.md) at the video position | | **Seek silence** | Find the next silent section in the audio | | **Extract audio** | Save the selected subtitle's audio to a file | | **Clone voice to** | Clone the voice heard in the selected subtitle into a text-to-speech engine that supports voice cloning | +| **Speech to text selected lines...** | Transcribe the audio of the selected subtitle(s) | | **Show only waveform** | Switch to waveform-only display | | **Show only spectrogram** | Switch to spectrogram-only display | | **Show waveform and spectrogram** | Switch to combined display | @@ -235,14 +254,22 @@ When enabled (toggle via shortcut or **Options → Shortcuts → Toggle waveform | **Play selection** | Play only the selected subtitle | | **Play selection (repeat)** | Play selected subtitle in a loop | | **Play next** | Play the next subtitle | +| **Previous / Play current / Pause / Next** | SE 4 "translate tab" style text buttons: play a single line and stop at its end | | **New** | Insert a subtitle at the current position | | **Set start + offset rest** | Set start time and shift all following subtitles | | **Set start** | Set the start time of the selected subtitle | | **Set end** | Set the end time of the selected subtitle | | **Remove blank lines** | Remove empty subtitle lines | -| **Horizontal zoom slider** | Adjust horizontal zoom (0.1× – 20×) | -| **Vertical zoom slider** | Adjust vertical zoom (0.1× – 20×) | +| **Horizontal zoom slider** | Adjust horizontal zoom (0.1× – 5×; Alt+Scroll zooms up to 20×) | +| **Vertical zoom slider** | Adjust vertical zoom (0.1× – 5×; Shift+Scroll zooms up to 20×) | | **Position slider** | Scrub through the video | +| **Video position** | Editable time code box: type, step or copy the current position | +| **Audio track** | Pick the audio track the waveform is extracted from (only shown when the video has more than one) | +| **Playback speed** | Speed slider | +| **Auto-select on play** | Toggle selecting the current subtitle while playing | +| **Center** | Toggle keeping the video position centered in the waveform | +| **Video seek** | Seek back/forward buttons | +| **More** | Menu: reset zoom and speed, configure toolbar items, hide the toolbar | ![Waveform Toolbar](../screenshots/main-waveform-toolbar.png) @@ -278,9 +305,9 @@ See [Audio Visualizer / Waveform](audio-visualizer.md) for even more details. The status bar at the bottom shows: - Waveform generation progress (when generating) -- Current subtitle format -- Line/character count information -- Other status messages +- Status messages (left) +- Line counter (right): `current/total`, or `X lines selected of Y` when several lines are selected +- Indicators, shown when relevant: update available, time codes locked, layer filter on, SMPTE timing, video offset. Click one to open the related dialog (the padlock is display only) ![Status Bar](../screenshots/main-status-bar.png) @@ -291,18 +318,18 @@ Subtitle Edit offers **12 predefined layouts** for arranging the main window are | Layout | Description | |--------|-------------| -| 1 | Video right, grid left, waveform bottom | -| 2 | Video right, grid and text left | -| 3 | Video top-right, grid bottom | -| 4 | Video left, grid right | -| 5 | Grid only (no video) | -| 6 | Video top, grid bottom | -| 7 | Video bottom-right, grid left | -| 8 | Video bottom, grid top | -| 9 | Grid left, video and waveform right | -| 10 | Video right, grid left (wide) | -| 11 | Video top-right, stacked panels | -| 12 | Minimal layout without video | +| 1 | Grid and text editor left, video right, waveform bottom | +| 2 | Video left, grid and text editor right, waveform bottom | +| 3 | Grid and text editor top-left with the waveform below them, video right | +| 4 | Video left; grid and text editor top-right with the waveform below them | +| 5 | Video top, grid and text editor middle, waveform bottom | +| 6 | Grid and text editor top, waveform bottom; the video player is hidden (the video stays loaded) | +| 7 | Grid and text editor left, video right; no waveform | +| 8 | Grid and text editor top, video bottom; no waveform | +| 9 | Video top, grid and text editor bottom; no waveform | +| 10 | Video top-left, waveform top-right with the text editor under it, grid bottom (SE 4 / Aegisub style) | +| 11 | Video top, waveform middle, grid and text editor bottom | +| 12 | Grid and text editor only; no video, no waveform | ![Layout Chooser](../screenshots/layout-chooser.png) @@ -355,10 +382,10 @@ Subtitle Edit accepts a few command-line arguments at startup, useful for deskto | Argument | Description | |----------|-------------| | *(positional)* | Subtitle file to open. The first existing path on the command line is treated as the subtitle file. | -| `/video:` | Open the given video file alongside the subtitle. | -| `/batchconvertui` | Launch directly into the standalone **Batch Convert** window instead of the full editor. | +| `/video:` | Open the given video file alongside the subtitle. `--video:` and `--video ` are accepted too. | +| `/batchconvertui` | Launch directly into the standalone **Batch Convert** window instead of the full editor. `--batchconvertui` is accepted too. | -The video file is always loaded when supplied via `/video:`, even if **Options → Settings → Video → Auto-open** is disabled — a flag on the command line is treated as an explicit instruction. +The video file is always loaded when supplied via `/video:` (or `--video`), even if **Options → Settings → Video → Auto-open** is disabled — a flag on the command line is treated as an explicit instruction. Examples (Windows shell): diff --git a/docs/features/merge-short-lines.md b/docs/features/merge-short-lines.md index 41202991f00..f760f45e499 100644 --- a/docs/features/merge-short-lines.md +++ b/docs/features/merge-short-lines.md @@ -10,7 +10,7 @@ Merge consecutive short subtitle lines into single lines. ## Options - **Single line max length** — Maximum character count allowed for a single merged line -- **Max number of lines** — Maximum number of lines (1-10) the merged subtitle may span +- **Max number of lines** — Maximum number of lines (2-10) the merged subtitle may span - **Highlight parts** — Highlight which portion of the merged text came from which source line The preview list updates live and shows all proposed merges. diff --git a/docs/features/merge-two-subtitles.md b/docs/features/merge-two-subtitles.md index 1ef9f246d36..27170036db4 100644 --- a/docs/features/merge-two-subtitles.md +++ b/docs/features/merge-two-subtitles.md @@ -10,7 +10,7 @@ Combine two subtitles into one bilingual subtitle — for example an original an ## How to Use 1. Open **Tools → Merge two subtitles...** -2. Load **Subtitle 1** and **Subtitle 2** — from a file, from the current subtitle's text, or (in translation mode) from the current translation +2. **Subtitle 1** is pre-filled with the current subtitle and, in translation mode, **Subtitle 2** with the original/translation. Use **Load from file** on either side to pick a different file 3. Pick the **output format** 4. Review the live preview 5. Click **Merge** @@ -18,9 +18,9 @@ Combine two subtitles into one bilingual subtitle — for example an original an ## Output Formats - **SubRip (.srt)** — overlapping pairs are stacked into one subtitle, subtitle 1's text as the first line(s) and subtitle 2's text below -- **Advanced Sub Station Alpha (.ass)** — each source keeps its own configurable style (**Style 1** / **Style 2**): font, color, outline width, shadow width, and top/bottom alignment, so e.g. the original can sit at the top and the translation at the bottom +- **Advanced Sub Station Alpha (.ass)** — each source keeps its own configurable style (**Style 1** / **Style 2**): font, font size, bold, italic, primary color, outline color, outline width, shadow width, and top/bottom alignment, so e.g. the original can sit at the top and the translation at the bottom. A rendered preview of each style is shown next to its settings ## Tips -- To create a bilingual subtitle from a translation you are working on, use **Load from current (text)** for subtitle 1 and **Load from current (translation)** for subtitle 2 +- To create a bilingual subtitle from a translation you are working on, open the tool while in translation mode — the current text and the translation are loaded as subtitle 1 and 2 automatically - For two independent files, timing does not need to match exactly — lines are paired by overlap diff --git a/docs/features/modify-selection.md b/docs/features/modify-selection.md index d4a258c23f3..98e34552775 100644 --- a/docs/features/modify-selection.md +++ b/docs/features/modify-selection.md @@ -46,6 +46,7 @@ Metadata rules: - **Bookmarked** — Line has a bookmark - **Bookmark contains** — Bookmark text contains the entered string +- **Forced** — Line is marked as forced narrative - **Style** — Line uses one of the checked styles (list built from the styles present in the file; ASSA subtitles) - **Actor** — Line has one of the checked actors diff --git a/docs/features/netflix-errors.md b/docs/features/netflix-errors.md index be9806a5cb2..318ce10afb9 100644 --- a/docs/features/netflix-errors.md +++ b/docs/features/netflix-errors.md @@ -32,10 +32,11 @@ The checker can report common Netflix delivery issues, including: 1. Open **Tools → Check and fix Netflix errors...**. 2. Select the subtitle language. -3. Enable the checks you want to run. -4. Review the proposed fixes in the preview grid. -5. Edit a proposed replacement if needed. -6. Click **OK** to apply the selected text fixes. +3. Tick **Children's program** and/or **SDH** if they apply — both change the limits Netflix allows (reading speed, line length). +4. Enable the checks you want to run (**Select all** / **Invert selection** flip many at once). +5. Review the proposed fixes in the preview grid. Each row has an **Apply** checkbox and a **Reason** column explaining which rule flagged it. +6. Edit a proposed replacement if needed. +7. Click **OK** to apply the selected text fixes. The tool does not blindly rewrite the subtitle. It shows the original and proposed text so you can review each change first. diff --git a/docs/features/ocr.md b/docs/features/ocr.md index 37edc03516a..a7eda5d9745 100644 --- a/docs/features/ocr.md +++ b/docs/features/ocr.md @@ -32,11 +32,16 @@ Built-in trainable OCR engine. - Best for consistent fonts (like DVD/Blu-ray subtitles) ### Binary OCR -Binary image comparison engine. +Binary image comparison engine, listed as **Binary image compare** in the engine dropdown. - Compares against a database of known character images - Fast and accurate for known fonts - Supports database editing, character history, max error percentage, and pixels-are-space tuning +### Apple Vision +Built-in OCR on macOS using Apple's Vision framework. +- macOS only; nothing to download or configure +- Good accuracy on clean subtitle images + ### Google Lens Sharp / Google Lens Standalone Cloud-based OCR using Google Lens (free, but capped). - Requires internet connection. @@ -53,7 +58,7 @@ Local LLM-based OCR via an Ollama server (e.g. with a vision model). ### llama.cpp Local LLM-based OCR using a llama.cpp-compatible server. Subtitle Edit can download llama.cpp and the model for you, or talk to a server you run yourself. - Default endpoint: `http://127.0.0.1:8080/v1/chat/completions` -- Curated vision models: **GLM-OCR 0.9B** (Q8_0, about 1.4 GB), **LightOnOCR 1B** (Q8_0, about 1.2 GB), **PaddleOCR-VL 1.6** (about 1.8 GB, 109 languages) and **HunyuanOCR 1.5** (Q8_0, about 1.3 GB — the fastest of the four). Custom `*.gguf` files placed in the llama.cpp models folder also appear in the list +- Curated vision models, listed best-first for subtitles: **GLM-OCR 0.9B** (Q8_0, about 1.4 GB — the default, and the most accurate on subtitle images), **PaddleOCR-VL 1.6** (about 1.8 GB, 109 languages), **HunyuanOCR 1.5** (Q8_0, about 1.3 GB) and **LightOnOCR 1B** (Q8_0, about 1.2 GB — the least accurate and much the slowest). Custom vision models also appear in the list: put the `*.gguf` in the llama.cpp models folder together with its `mmproj` vision projector, named either `mmproj-.gguf` or `-mmproj.gguf` (the two naming schemes used on Hugging Face). A `*.gguf` without a projector next to it is not offered — it cannot see the image - The settings dialog has a download/update button for the llama.cpp engine itself; the dot next to it turns amber when a newer build than the one installed is available ### CrispEmbed @@ -92,6 +97,33 @@ Local OCR engine. 6. Review and correct any errors 7. Click **OK** to import the text subtitles +## Options + +Checkboxes below the engine settings: + +- **Fix OCR errors**, **Prompt for unknown words**, **Try to guess unknown words** — Shown once a dictionary is loaded +- **Auto break if more than X lines** — X is the *Max number of lines* rule from Settings + +Toggle buttons above the image: + +- **Auto-detect ASSA alignment** — Capture top alignment +- **Image pre-processing** — Opens the [pre-processing](#pre-processing) settings +- **VobSub/DVD colors...** — VobSub/DVD input only +- **Fallback OCR database** — nOCR and Binary image compare only +- **Show only forced subtitles** — Only when the file has forced subtitles + +## Subtitle List Menu + +Right-click the subtitle list for: + +- **OCR selected lines** +- **Inspect line...** — nOCR and Binary image compare only +- **Show image**, **Save image as...**, **Copy image to clipboard** +- **Delete**, and **Fill selected lines with clipboard text** when several lines are selected +- **Italic**, **Bold** +- **Edit/export...**, **Import text from subtitle...**, **Export text as subtitle...** +- **Save all images with HTML index...** — See [Saving the Images](#saving-the-images) + ## Saving the Images The subtitle list's right-click menu has **Save all images with HTML index...**. Pick a folder and @@ -163,6 +195,8 @@ When the OCR engine encounters uncertain characters, you can: - Type the correct text - Add to the OCR fix dictionary for automatic correction +The unknown-words list in the OCR window has buttons for the selected word: **Add to names list (case sensitive)**, **Add to user dictionary**, **Add to OCR replace pairs** and **Google it**. + ### What is remembered Some of the choices in the unknown-word prompt are saved to disk and reused on later diff --git a/docs/features/point-sync-via-other.md b/docs/features/point-sync-via-other.md index 9fc1d284310..60eb33a27ca 100644 --- a/docs/features/point-sync-via-other.md +++ b/docs/features/point-sync-via-other.md @@ -11,8 +11,11 @@ Synchronize a subtitle file using another subtitle file as reference. 1. Open the subtitle you want to sync 2. Open **Sync → Point sync via other subtitle...** -3. Select the reference subtitle file -4. Match corresponding lines between the two files -5. Click **OK** to apply +3. Click the browse button in the right pane to load the reference subtitle +4. Select a line in your subtitle and the matching line in the other subtitle, then click **Set sync point** — or **Set sync point via video...** to pick the time from the video for a line the other subtitle does not cover +5. Repeat for more sync points; they are listed in the middle and can be removed with right-click **Delete** or the Delete/Backspace key +6. Click **Apply** to apply the sync points and keep working, or **OK** to apply and close + +Both grids have a **Gap** column with the silence before each line; lines starting after 3+ seconds of silence are highlighted, as they often make reliable sync points. **Find text** above each grid searches that subtitle. The window remembers its size and position between sessions. diff --git a/docs/features/point-sync.md b/docs/features/point-sync.md index 9dc1df65e2c..5e8e8f7b4ca 100644 --- a/docs/features/point-sync.md +++ b/docs/features/point-sync.md @@ -11,7 +11,7 @@ Synchronize subtitles using multiple reference points. 1. Open **Sync → Point sync...** 2. Select a subtitle line in the list and click **Set sync point** to set its correct video position -3. Repeat for additional sync points as needed for accuracy +3. Repeat for additional sync points as needed for accuracy; a sync point can be removed with right-click **Delete** on the list or the Delete/Backspace key 4. Click **OK** to apply More sync points provide better accuracy for subtitles with non-linear drift. The window remembers its size and position between sessions. diff --git a/docs/features/re-encode-video.md b/docs/features/re-encode-video.md index a417cee8866..e4dc43680dd 100644 --- a/docs/features/re-encode-video.md +++ b/docs/features/re-encode-video.md @@ -2,7 +2,7 @@ Re-encode a video file with a different frame rate or container format using FFmpeg. -- **Menu:** Video → Re-encode video... +- **Menu:** Video → More → Re-encode video for better subtitling... - **Shortcut:** Configurable @@ -10,9 +10,9 @@ Re-encode a video file with a different frame rate or container format using FFm ## How to Use -1. Open **Video → Re-encode video...** +1. Open **Video → More → Re-encode video for better subtitling...** 2. Select the output frame rate -3. Select the output container format (`.mkv`, `.mp4`, `.webm`) +3. Select the output container format (`.mkv`, `.mp4`) 4. Click **Generate** to start re-encoding (or use the **Generate** split-button menu to prompt for FFmpeg parameters before encoding) ## Options diff --git a/docs/features/remove-text-hi.md b/docs/features/remove-text-hi.md index ecd56ceceb0..b7cfb550145 100644 --- a/docs/features/remove-text-hi.md +++ b/docs/features/remove-text-hi.md @@ -25,10 +25,10 @@ Remove hearing-impaired annotations such as speaker names, sound descriptions, a ### Other -- **If line is uppercase** — Remove the whole line when it is entirely uppercase +- **If line is uppercase** — Remove the whole line when it is entirely uppercase. The text box next to it is a whitelist: uppercase words listed there (e.g. `NASA FBI`) do not count as hearing-impaired text - **If line contains** — Remove the line when it contains a given substring - **If line only contains music symbols** — Remove lines that consist only of music symbols (e.g. `♪ Music ♪`) -- **Remove interjections** — Remove common interjections like "hmm", "uh"; the dictionary used follows the selected **Language** +- **Remove interjections** — Remove common interjections like "hmm", "uh"; the dictionary used follows the selected **Language**. **Only on separate lines** limits this to interjections that sit on a line of their own ## Interjections @@ -39,7 +39,7 @@ Click **Edit** next to *Remove interjections* to modify the list of interjection ## Preview -All proposed removals are shown in a preview list with **Before** and **After** columns. Uncheck individual items to exclude them before clicking **OK**. +All proposed removals are shown in a preview list with **Before** and **After** columns. Uncheck individual items to exclude them before clicking **OK**. When the tool is opened from the main window, **Apply** applies the ticked rows without closing so you can run another pass with different options, and **Done** closes the window. Right-click the list to tick or untick many rows at once: diff --git a/docs/features/settings.md b/docs/features/settings.md index ea91e5c4124..f11b049e024 100644 --- a/docs/features/settings.md +++ b/docs/features/settings.md @@ -49,28 +49,39 @@ The subtitle rules that drive error checking, the grid's warning colors, and too - **Default format** and **default save-as format** - **Favorite subtitle formats** and **favorite languages** — These float to the top of the pickers - **WebVTT: use X-TIMESTAMP-MAP** — Offset time codes on load +- **ASSA: set resolution (PlayResX/PlayResY) from the video when a video is opened**, and **ASSA: ask before changing the resolution to match the video (otherwise resample automatically)** ## Syntax Coloring -- **Color text if too wide (pixels)** — With its own settings for how the width is measured +- **Color duration if too short**, **Color duration if too long** +- **Color text if too long**, **Color text if too wide (pixels)** — the latter with its own settings for how the width is measured, **Color text if more than X lines** +- **Color characters/sec if too high**, **Color words/min if too high** +- **Color time code overlap** +- **Color if gap is too short** - **Error background color** ## Video Player - **Video player** — Which player to use, plus **Download mpv** / **Download VLC** when the library is missing +- **Show stop button**, **Show full-screen button**, **Hide video controls in full-screen**, **Auto-open video file when opening subtitle** - **Subtitle preview properties** — Font name, size and bold, primary/outline/shadow colors, border style and outline/shadow width for the subtitle drawn on the video ## Waveform / Spectrogram -- **Waveform draw style** and **spectrogram mode** -- **Toolbar items** — Which timing buttons the waveform toolbar shows, and in which order +- **Waveform draw style**, **Auto-generate waveform when opening a video**, **Generate spectrogram**, **spectrogram mode** and **Waveform/spectrogram combined, waveform height %** +- **Show toolbar** and **Toolbar items** — Which timing buttons the waveform toolbar shows, and in which order - **Waveform single-click / double-click action** - **Extract audio format, sample rate and bitrate** — What the audio Subtitle Edit extracts for the waveform looks like +- **Select subtitle on right click**, **Allow overlap (when moving/resizing)**, **Set video position when moving start/end** - **Snap to shot changes (hold Shift to override)** and **Snap to frames** - **Snap distance when dragging (pixels)** — how close a dragged cue has to come to a shot change before it snaps; in pixels, so it feels the same at every zoom - **Snap to nearest shot change: max start / end distance (seconds)**, and the tighter **max end distance when start and end share a cut** — how far the *Snap selected lines to nearest shot change* shortcut looks for a cut. Where a snapped cue lands is set by the beautify profile's in/out cues gap (gear icon next to the snap toggle) -- **Mouse-wheel video position step** -- **Waveform text font size** and the full color set — text, waveform, subtitle background, background, selected subtitle background, selected, cursor/head, shot change, left/right border, fancy high color. Color themes can be imported and exported +- **Shot changes auto-generate** +- **Focus on mouse over**, **Focus text box after insert** +- **Invert mouse-wheel**, **Mouse-wheel sets video position**, **Mouse-wheel video position step** +- **Center video position also while paused**, **Draw grid lines** +- **Waveform text font size**, **Waveform text font bold** and the full color set — text, waveform, subtitle background, background, selected subtitle background, selected, cursor/head, shot change, left/right border, fancy high color. Color themes can be imported and exported +- **Auto-transcribe new waveform selection via speech-to-text** - **Download ffmpeg** and a **disk space** readout for the extracted audio ## Tools @@ -86,6 +97,7 @@ The subtitle rules that drive error checking, the grid's warning colors, and too - **Multiple replace: show context menu buttons** - **Grid: focus text box after insert new subtitle** - **Text to speech: prompt to merge continuation lines** +- **Text to speech: prompt to skip sound/music lines**, **Text to speech: prompt to detect speaker names in the text** - **Fix common errors: skip step 1 (choose fixes)** - **Music symbol** and **music symbols to replace** @@ -105,7 +117,7 @@ One checkbox per toolbar button, so the main toolbar can be trimmed to what you ## Network -Proxy settings for every download and online engine: **address**, **username**, **password**, **domain**, **bypass proxy for**, and what to **notify about**. +Proxy settings for every download and online engine: **address**, **username**, **password**, **domain**, **bypass proxy for**, and **Use system credentials**. ## Updates diff --git a/docs/features/shortcuts.md b/docs/features/shortcuts.md index fccf81fb79f..1307f115da0 100644 --- a/docs/features/shortcuts.md +++ b/docs/features/shortcuts.md @@ -47,9 +47,11 @@ Independent of the thematic category, each command has a scope shown in the **Ac Some commands have additional configuration beyond the shortcut key: - **Set color 1–8** — Choose a color for each color shortcut -- **Surround with 1–3** — Define the left/right text to surround selected text with (this replaces the *Shortcut toggle custom start/end* setting from Subtitle Edit 4) +- **Surround with 1–8** — Define the left/right text to surround selected text with (this replaces the *Shortcut toggle custom start/end* setting from Subtitle Edit 4) - **Video move custom 1–4 back/forward** — Set the number of milliseconds to skip - **Set actor 1–10** — Define the actor name assigned by each actor shortcut +- **Custom search 1–5** — Set the name and URL for each search slot +- **Go to first line** / **Go to last line** — Whether the video position follows Select a configurable command and click the **gear icon** to adjust its settings. The gear sits in the shortcut assignment row below the list, between the key detection button and **Reset** — it is only shown while a configurable command is selected, so if you cannot see it, the selected command has no extra settings. diff --git a/docs/features/shot-changes.md b/docs/features/shot-changes.md index e2fce10eedd..9637760265c 100644 --- a/docs/features/shot-changes.md +++ b/docs/features/shot-changes.md @@ -5,7 +5,7 @@ Detect scene/shot changes in a video using FFmpeg, or import them from a file. - **Menu:** Video → Generate/import shot changes - **Shortcut:** Configurable -A second menu item, **Show shot changes list**, appears under the Video menu once the loaded video has any shot changes. +A second menu item, **Show shot changes list**, appears under the Video menu once the loaded video has any shot changes. It lists the current shot changes with **Go to** (also double-click), **Clear**, and a right-click **Delete** for the selected entry. ![Shot Changes](../screenshots/shot-changes.png) @@ -20,17 +20,18 @@ A second menu item, **Show shot changes list**, appears under the Video menu onc ## Time Code Format -Choose how shot change times are displayed and exported: +Choose how imported shot change times are interpreted: - **Seconds** — Decimal seconds - **Frames** — Frame numbers - **Milliseconds** — Millisecond values - **HH:MM:SS.FFF** — Full time code format -## Import / Export +## Import -- **Import** — Load shot changes from a text file (one time code per line) -- **Export** — Save detected shot changes to a file +The window has two tabs: **Generate shot changes** and **Import shot changes**. + +- **Import shot changes from file** — Load shot changes from a text file (one time code per line); the text box on the tab can also be edited or pasted into directly Shot changes are displayed as vertical lines on the audio visualizer waveform. diff --git a/docs/features/sort-by.md b/docs/features/sort-by.md index 7d0b8fab5b5..f2ce224028f 100644 --- a/docs/features/sort-by.md +++ b/docs/features/sort-by.md @@ -16,6 +16,8 @@ Add one or more sort criteria from the available properties: - Hide (end time) - Duration - Text +- Text total length +- Text single line max length - Original text - Style - Actor @@ -25,4 +27,4 @@ Add one or more sort criteria from the available properties: - Words/min - Pixel width -Each criterion can be set to ascending or descending, and multiple criteria are applied in order (drag or use the up/down buttons to reorder). +Each criterion can be set to ascending or descending (**Toggle direction**), and multiple criteria are applied in order — use **Move up** / **Move down** to reorder them, or **Clear** to start over. diff --git a/docs/features/source-view.md b/docs/features/source-view.md index 5d67ccc1988..59bc5d3e6e8 100644 --- a/docs/features/source-view.md +++ b/docs/features/source-view.md @@ -2,8 +2,8 @@ Edit the subtitle as raw text in the current subtitle format — time codes, tags, headers and all. Useful for fixing something the grid cannot express, for pasting a whole file in at once, or just for seeing exactly what will be written to disk. -- **Menu:** Edit → Source view -- **Shortcut:** F2 +- **Shortcut:** F2 (default; there is no menu entry) +- **Toolbar:** optional **Source view** button, enabled in **Options → Settings → Toolbar** The editor is virtualizing: only the lines on screen are colored and laid out, so even a very large file opens and scrolls instantly. @@ -27,8 +27,9 @@ If you have edited the text, **Cancel**, **Escape** and the window's close butto ## Find and Replace -Press **Ctrl+F** (find) or **Ctrl+H** (replace) to open the search bar below the editor. It searches the source text — including time codes and tags — not the subtitle lines. +Press **Ctrl+F** (find) or **Ctrl+H** (replace) to open the search bar below the editor. On macOS replace is **Cmd+Alt+F**, since Cmd+H hides the application. It searches the source text — including time codes and tags — not the subtitle lines. +- **F3** / **Shift+F3** find the next / previous match (F3 opens the search bar when nothing has been searched yet) - **Case sensitive**, **Whole word** and **Regular expression** can be combined - Searching wraps around at the end (and at the top, going backwards) - With **Regular expression** on, the replacement supports group references (`$1`, `$2`, …). Without it, `$` is literal diff --git a/docs/features/speech-to-text.md b/docs/features/speech-to-text.md index ad02373be2d..8ed59377424 100644 --- a/docs/features/speech-to-text.md +++ b/docs/features/speech-to-text.md @@ -14,13 +14,13 @@ Subtitle Edit can automatically transcribe audio to text using Whisper-based and | Whisper CPP | Windows, Linux, macOS | Local CPU engine. On Windows the cuBLAS (NVIDIA CUDA) and Vulkan GPU backends can also be selected from the Whisper CPP backend dropdown. | | Purfview Faster Whisper XXL | Windows, Linux | Fast local engine, often used with NVIDIA CUDA | | Whisper CTranslate2 | Windows, Linux (x64), macOS (Apple Silicon) | CPU / NVIDIA CUDA depending on installation; CUDA requires [CUDA 12.x](https://developer.nvidia.com/cuda-12-0-0-download-archive) | -| MLX Whisper | macOS (Apple Silicon) | Runs Whisper on the Apple GPU / Neural Engine via Apple's MLX. Not downloaded by Subtitle Edit — install the `mlx-whisper` Python package yourself (see notes below) | | Whisper Const-me | Windows | DirectX-based engine | +| WhisperX | Windows (x64), Linux (x64), macOS (Apple Silicon) | Faster-Whisper with wav2vec2 word-level alignment, packaged as a standalone build (no Python install needed) | | Whisper OpenAI | All | Python-based OpenAI Whisper workflow | | OpenAI Compatible Server | All | Connect to any OpenAI-compatible speech-to-text endpoint | | OpenRouter | All | Online. One API key routes to Whisper, gpt-4o-transcribe, Groq and Google Chirp | | Alibaba Qwen3-ASR | All | Online Qwen3-ASR via Alibaba Model Studio (DashScope) | -| Qwen3 ASR CPP | Windows, Linux | Local Qwen3 ASR engine with downloadable GGUF models | +| Qwen3 ASR CPP | Windows, Linux, macOS | Local Qwen3 ASR engine with downloadable GGUF models | | Crisp ASR | Windows, Linux, macOS | Single engine with selectable backends: Parakeet, Canary, Cohere, Fire Red, Fun-ASR Nano, GigaAM, GLM, Granite, Qwen3, Mega, MOSS Diarize, Omni, Kyutai, SenseVoice, ARK, Voxtral | Engines and models are downloaded automatically on first use. @@ -29,12 +29,49 @@ Engines and models are downloaded automatically on first use. - **Whisper CPP** is shown as a single entry; the CPU / cuBLAS / Vulkan backends are selected from a secondary dropdown when Whisper CPP is selected. - **Qwen3 ASR CPP** includes 0.6B and 1.7B model options, plus a forced-aligner model used for timing workflows. -- **Crisp ASR** is exposed as one engine that wraps multiple backends (Parakeet, Canary, Cohere, Fire Red, Fun-ASR Nano, GigaAM, GLM, Granite, Qwen3, Mega, MOSS Diarize, Omni, Kyutai, SenseVoice, ARK, Voxtral). Pick the backend from the Crisp ASR backend dropdown. -- **MLX Whisper** (Apple Silicon Macs) is not bundled or auto-downloaded — it drives Apple's `mlx-whisper` Python package. Install it once with `pip3 install mlx-whisper` (or `pipx install mlx-whisper`); models download from Hugging Face on first use. Subtitle Edit detects the install by finding a Python that can `import mlx_whisper` — it probes Homebrew, python.org, pyenv and system interpreters, and (for pipx / virtual-env / conda installs, which isolate the package) reads the `mlx_whisper` command found on your PATH or at `~/.local/bin/mlx_whisper` to locate the matching interpreter. If it reports "not found" after a pipx/venv install, make sure `which mlx_whisper` resolves. +- **Crisp ASR** is exposed as one engine that wraps multiple backends (Parakeet, Canary, Cohere, Fire Red, Fun-ASR Nano, GigaAM, GLM, Granite, Qwen3, Mega, MOSS Diarize, Omni, Kyutai, SenseVoice, ARK, Voxtral). Pick the backend from the Crisp ASR backend dropdown - see [Crisp ASR backends](#crisp-asr-backends) for what each one is good at. - A **Forced aligner** option is shown for Crisp ASR backends and exposes the built-in aligner, Canary CTC, Qwen3, and the wav2vec2 zoo (12 language-specific CTC aligners that run on top of any Crisp ASR backend). - Several newer engines support automatic language selection. - Each engine can have separate advanced command-line parameters. +## Crisp ASR backends + +**Crisp ASR** is one engine wrapping many backends. Set **Engine** to *Crisp ASR*, then pick the backend from the Crisp ASR backend dropdown - the model list updates to that backend's models. + +The languages column counts what the backend dropdown offers (an *auto* entry is not counted as a language). + +**Output** is what you actually get back. *Native timings* means the backend times its own lines and can use the built-in aligner; *needs aligner* means the text arrives untimed and a forced aligner (Canary CTC, Qwen3, or a wav2vec2 aligner) places it in time. Anything else a backend adds - speaker labels, punctuation and casing - is called out there too. + +| Backend | Languages | Output | Model size range | Good for | +|---------|-----------|------------|------------------|----------| +| **Parakeet** | 13 (European + zh, ja, ko) | Native timings | 75 MB - 2.14 GB | The fast default. NVIDIA Parakeet TDT/RNN-T in 0.6B, 1.1B and a 110M tdt_ctc model - the smallest Crisp ASR model of all. Has a Japanese fine-tune | +| **Canary** | 25 (European) | Native timings | 705 MB - 1.97 GB | NVIDIA Canary 1B v2. Broad European coverage with its own timings; also usable as a CTC forced aligner for other backends | +| **Cohere** | 14 | Native timings | 1.51 - 4.14 GB | Cohere Transcribe. Separate Arabic and Japanese fine-tunes. VAD is on by default (see the VAD section below) | +| **GigaAM** | 1 (Russian) | Native timings; punctuation + casing on `e2e` only | 151 - 452 MB | Russian only, and very small. Use an `e2e` revision - those emit punctuation and capitalisation, the plain `ctc` / `rnnt` heads return bare lowercase text | +| **MOSS Diarize** | 2 (en, zh) | Native timings + speaker labels | 1.41 - 1.82 GB | Labels who is speaking. This is the backend preselected by the text-to-speech speaker-separation workflow | +| **Qwen3** | 30 | Needs aligner | 631 MB - 4.7 GB | Strong all-rounder in 0.6B and 1.7B, with an anime / visual-novel Japanese fine-tune. Also supplies the Qwen3 forced-aligner model | +| **Omni** | 149 | Needs aligner | 1.08 - 3.26 GB | By far the widest language coverage - the place to start for a language no other backend lists. Languages use NLLB-style codes (`eng_Latn`, `cmn_Hans`) | +| **Fire Red** | 49 | Needs aligner | 1 - 2.4 GB | FireRedASR2. Chinese-first, and the only backend covering Chinese regional languages: Cantonese, Shanghainese, Minnan, Gan, Hakka and Xiang | +| **GLM** | 17 | Needs aligner | 1.3 - 4.5 GB | GLM-ASR Nano, Chinese-first with a wide second tier of languages | +| **SenseVoice** | 5 (zh, yue, en, ja, ko) | Needs aligner | 136 - 469 MB | Tiny and quick for CJK audio - useful on machines where the larger backends are too slow | +| **Fun-ASR Nano** | 5 (en, zh, yue, ja, ko) | Needs aligner | 0.90 - 1.98 GB | CJK-focused Fun-ASR | +| **Mega** | 2 (en, zh) | Needs aligner | 1.3 - 4.4 GB | Mega-ASR 1.7B. VAD is on by default (see the VAD section below) | +| **Granite** | 6 (en, fr, de, es, pt, ja) | Needs aligner | 1.54 - 5.58 GB | IBM Granite Speech 4.1 2B. The `plus` models are the newer revision; `mini` and `f16enc` trade encoder precision for size | +| **ARK** | 19 (European + zh, ja, ko) | Needs aligner | 3.52 - 7.51 GB | A 3B model - the heaviest backend here, so only worth it when the smaller ones fall short | +| **Kyutai** | 2 (en, fr) | Needs aligner | 0.67 - 5.01 GB | Kyutai STT in 1B and 2.6B | +| **Voxtral** | 8 | Needs aligner | 2.65 - 4.99 GB | Mistral Voxtral Mini 3B. The backend has no built-in aligner entry at all, so a CTC aligner is always used | + +### Picking a quantization + +Most backends list the same model several times with a quantization suffix. The suffix only changes file size, memory use and speed - the model is the same: + +- `q4_k` - smallest and fastest, with the most accuracy lost. A good first download +- `q5_0` / `q5_1` / `q6_k` - middle ground where offered +- `q8_0` - close to full precision at roughly half the size. The best default when disk space allows +- no suffix / `f16` / `unquantized` - full precision, largest and slowest, and rarely worth it over `q8_0` + +A model name with a language in it (`-ja`, `-arabic`) is a fine-tune for that language and usually beats the general model on it - see below. + ## Language-specific models Some backends offer models fine-tuned for one language, which usually beat the general model on that language. @@ -53,13 +90,12 @@ The general `qwen3-asr-1.7b` and Whisper `large-v3-turbo` models are also strong 1. Open a video file in Subtitle Edit 2. Go to **Video → Speech to text...** -3. Select an **Engine** from the dropdown +3. Select an **Engine** from the dropdown. Next to it are buttons for the engine website, engine download (when it is not installed yet) and engine settings (backend and update status, for installed local engines). A **Backend** dropdown appears below for Whisper CPP and Crisp ASR 4. Select a **Model** (larger models usually improve accuracy but take more time and disk space) 5. Select the **Language** of the audio, or use auto-language when the selected engine supports it 6. Optionally enable: - **Translate to English** — Translate non-English audio to English - - **Adjust timings** — Post-process timing using waveform data - - **Post-processing** — Fix casing, merge lines, add periods, etc. + - **Post-processing** — Adjust timings, fix casing, merge lines, add periods, etc. (the settings button next to it opens the options) 7. Click **Transcribe** ## Models @@ -81,6 +117,8 @@ Transcribe multiple video files at once: 3. Click **Transcribe** 4. Results are saved as `.srt` files next to the video files +**Add language code to file name** names the output `video.en.srt` instead of `video.srt`. + ## Advanced Settings Click the **Advanced** button to configure custom command-line arguments for the Whisper engine: @@ -107,6 +145,9 @@ Click the **Post-processing** button to configure: - Add periods - Merge short lines - Split long lines +- Remove non-speech lines (lines that only describe sound, like "[Music]") +- Remove repeated lines (lines that repeat the previous line word for word) +- Show quality report after transcription - Change underline to color (useful for highlight spoken words) ## Console Log @@ -118,6 +159,6 @@ The console log at the bottom shows real-time output from the Whisper process, u - For NVIDIA GPU users, use the **Whisper CPP** cuBLAS backend or **Purfview Faster Whisper XXL** for fastest transcription - If you get "CUDA out of memory" errors, try a smaller model - The `--standard` parameter is automatically added for Purfview Faster Whisper XXL -- You can re-download an engine by right-clicking the engine area +- Right-click the window for **View tools log file** and, for downloadable engines, a re-download item - If an engine executable has gone missing (typically quarantined by antivirus software), Subtitle Edit detects it when transcription starts, names the missing file, and offers to re-download the engine - If a new engine has no model installed yet, let Subtitle Edit download both the engine and the selected model before starting transcription diff --git a/docs/features/spell-check.md b/docs/features/spell-check.md index 5c74f0fb7f8..579c5667752 100644 --- a/docs/features/spell-check.md +++ b/docs/features/spell-check.md @@ -21,7 +21,7 @@ Check spelling of subtitle text and correct misspelled words. - **Add to names list (case sensitive)** — Add the word to the names/proper nouns list (matches the exact casing) - **Add to user dictionary** — Add the word to your personal dictionary 5. The spell checker advances to the next unknown word automatically -6. When all words have been checked, the window closes +6. When all words have been checked, a **Spell check completed** summary shows how many words were changed, skipped and added — tick **Do not show this message again** to turn it off **Skip all is not saved.** It lasts until the window closes and is not carried over to the next run, so a mis-click never has lasting consequences. To accept a word permanently use @@ -49,6 +49,9 @@ Spell check requires a dictionary to be installed. If no dictionary is found, yo - **Edit whole text** — Edit the full subtitle text for the current line - **Google it** — Search Google for the current word +- **Undo** — Reverts the last change; the button only appears once there is something to undo +- **Play current** — Plays the line in the main window's video player and pauses at its end; hidden when no video is loaded +- **Show source images too...** — The image button next to **Done** attaches an image-based subtitle (sup, VobSub, BDN XML, TS, MKV, ...) so the original bitmap is shown for each line; after an OCR run the images are attached automatically - The current subtitle line is highlighted in the subtitle grid as you check ## Keyboard Shortcuts diff --git a/docs/features/split-break-long-lines.md b/docs/features/split-break-long-lines.md index 4ac50870437..95cfdd4c269 100644 --- a/docs/features/split-break-long-lines.md +++ b/docs/features/split-break-long-lines.md @@ -11,7 +11,9 @@ Split or rebalance subtitle lines that exceed maximum character limits. - **Split long lines** — Split subtitles whose total length exceeds the maximum - **Rebalance long lines** — Re-balance line breaks so lines have a more even length +- **Rebalance only lines that are too long** — Leave lines alone unless one of them exceeds the single-line maximum - **Single line max length** — Maximum characters allowed on a single line - **Max number of lines** — Maximum number of lines a subtitle may span (1-10) +- **Unbreak subtitles shorter than** — Subtitles with fewer characters than this are joined onto one line -The preview list updates live and shows all proposed fixes. +The preview list updates live and shows all proposed fixes, each with an **Apply** checkbox. **Select all** / **Select none** tick or untick every row. diff --git a/docs/features/statistics.md b/docs/features/statistics.md index 5fdbf415ebb..ad4e7c1538e 100644 --- a/docs/features/statistics.md +++ b/docs/features/statistics.md @@ -10,24 +10,27 @@ View detailed statistics about the currently loaded subtitle file, including lin 1. Open a subtitle file. 2. Go to **File** → **Statistics...** to open the statistics dialog. -3. View general statistics, most used words, and most used lines. +3. Read the dashboard: totals at the top, timing/pacing ranges and checks in the middle, and the word/line lists at the bottom. 4. Use **Export** to save the statistics to a text file. ## Features -### General Statistics -- Total number of lines. -- Total number of words and characters. -- Duration information (total duration, average duration per line). -- Subtitle format information. +### Totals +- Number of subtitles, words, and characters. +- Total duration. -### Most Used Words -- List of the most frequently used words in the subtitle. -- Word count for each word. +### Timing and Pacing +- Minimum, average, and maximum for duration, characters per line, characters per second, words per minute, and gap between lines. -### Most Used Lines -- List of the most frequently repeated lines. -- Helps identify duplicate or repeated content. +### Checks +- How many lines break the current profile's limits (line length, reading speed, duration, number of lines), so problems can be spotted before running the fix tools. + +### Characters/sec Histogram +- A bar chart of how the reading speed is distributed across the subtitle. + +### Most Used Words / Most Used Lines +- The most frequently used words and the most frequently repeated lines, with counts. +- Each list has a **Copy to clipboard** button. ### Export - Save the full statistics report to a text file. diff --git a/docs/features/subtitle-grid.md b/docs/features/subtitle-grid.md index 44409e6f04f..1bbe679b08a 100644 --- a/docs/features/subtitle-grid.md +++ b/docs/features/subtitle-grid.md @@ -12,7 +12,7 @@ The grid has a fixed set of columns. Some are always visible; others can be togg | Column | Always visible | Description | |--------|---------------|-------------| | **#** | ✓ | Line number (also shows a bookmark icon when the line is bookmarked) | -| **Show** | ✓ | Start time — when the subtitle appears | +| **Show** | | Start time — when the subtitle appears | | **Hide** | | End time — when the subtitle disappears | | **Duration** | | How long the subtitle is displayed | | **Text** | ✓ | The subtitle text | @@ -23,6 +23,7 @@ The grid has a fixed set of columns. Some are always visible; others can be togg | **CPS** | | Characters per second (reading speed indicator) | | **WPM** | | Words per minute | | **Pixel width** | | Rendered pixel width of the text | +| **Forced** | | Check mark on lines marked as forced narrative (set with **Toggle forced** in the context menu) | | **Layer** | | Layer number (only available for formats that support layers, e.g. ASS/SSA) | ## Customizing Visible Columns @@ -31,6 +32,8 @@ Right-click anywhere in the **column header row** to open the column visibility > **Note:** The **Style** and **Layer** columns only appear in the menu when the loaded subtitle format supports them (e.g. ASS/SSA). +**Columns...** at the bottom of the same menu opens a dialog where columns can be shown/hidden and reordered. + ![Subtitle Grid Column Menu](../screenshots/subtitle-grid-column-menu.png) @@ -46,14 +49,17 @@ Right-click anywhere in the **column header row** to open the column visibility Right-click a line to access: - Delete - Insert before / Insert after +- Insert subtitle after current line... (insert a whole subtitle file) - Column (delete text, insert text, paste from clipboard, shift cells up/down, text up/down) -- Split line +- Split line / Assisted split... / Assisted move... - Merge before / Merge after / Merge selected / Merge selected as dialog - Extend to line before / Extend to line after - Remove formatting (all, bold, italic, underline, color, font name, alignment) - Italic / Bold / Color... / Font name... / Alignment... - Bookmark... -- Selected lines... (Speech to text, Auto translate, Change casing, Set layer, Fix common errors, etc.) +- Toggle forced +- Selected lines... (Speech to text, Auto translate, Change casing, Set layer, Fix common errors, Save as..., etc.) +- Save forced lines as... ## Keyboard Shortcuts (Grid) @@ -64,7 +70,7 @@ Right-click a line to access: | `Ctrl+C` | Copy | | `Ctrl+V` | Paste | | `Ctrl+X` | Cut | -| `Enter` | Go to next line | +| `Enter` | Go to subtitle and set video position (default; **Options → Settings** can change it to *Go to next line*) | | `Up/Down` | Navigate lines | ### Pasting over several lines diff --git a/docs/features/text-editor.md b/docs/features/text-editor.md index d4f6ba08c55..d78b7da17c9 100644 --- a/docs/features/text-editor.md +++ b/docs/features/text-editor.md @@ -47,6 +47,15 @@ Additional formatting options: - **Fetch first word from next subtitle** — Move the first word of the next line to the current line - **Move last word to next subtitle** — Move the last word to the next subtitle line +## Context Menu + +Besides cut/copy/paste, split and the formatting items above, the text box right-click menu has: + +- **Casing** — Toggle casing, Selection to UPPERCASE, Selection to lowercase, Selection to Sentence case, and Change casing... for the selected lines +- **Insert Unicode symbol** — insert one of the configured symbols +- **Google it** (with a selection) and **Search via** — the custom search slots set up in Options → Shortcuts +- **AI assistant** — see below + ## AI Assistant Ask a local AI model about the current line, or ask it for a change — available from the text box context menu (**AI assistant**) or the edit-box toolbar robot button. diff --git a/docs/features/text-to-speech.md b/docs/features/text-to-speech.md index a4d0c9c6c7a..611491f6730 100644 --- a/docs/features/text-to-speech.md +++ b/docs/features/text-to-speech.md @@ -13,11 +13,39 @@ Generate speech audio from subtitle text using various TTS engines. 1. Open **Video → Text to speech...** 2. Select a TTS engine from the dropdown 3. Select a language and voice -4. Optionally enable **Review audio clips** to review each generated clip -5. Optionally enable **Generate video file** to create a video with the audio -6. Click **Generate** to start +4. Optionally enable **Review audio segments** to check each line before the final mix +5. Optionally enable **Add audio to video file** to mux the result into a new video file (the settings button next to it opens the encoding settings); **Advanced...** below opens further TTS settings +6. Click **Generate speech from text** to start 7. Close the window with **OK** to apply the session's subtitle changes (lines merged before generation, text edits made in the review window) to the subtitle in the main window — or **Cancel** to discard them +The bottom bar holds **Set up cast** (when speakers are present), **Import...**, **OK**, **Cancel** and **Generate speech from text**. OK is hidden while generating, and Cancel then stops the generation. + +## Set Up Cast: One Voice per Speaker + +When the subtitle carries speaker names — the **Actor** field in ASSA/SSA, or `` voices in WebVTT — a **Set up cast** button appears (with the speaker count). It opens a dialog where each actor is assigned an engine, voice, and optionally a model and voice instruction of their own. Lines without an actor use the globally selected voice. The cast is remembered between sessions, so the same actors open already assigned next time. + +If the speaker names are written into the text instead (an SDH subtitle), see the speaker prompt below — and for a video where nobody labeled the speakers at all, [Find Voices in Video and Clone](#find-voices-in-video-and-clone) works out the cast by listening. + +## Prompts Before Generation + +Clicking **Generate speech from text** runs up to three quick checks on the subtitle before any audio is made. Each one only appears when it has something to show, each opens a review dialog where every proposed change is a checkbox, and each can be turned off in **Options → Settings** (search for "Text to speech: prompt"). + +### Speaker names in the text + +An SDH subtitle writes its speakers into the text — `MIKE: text`, `[NARRATOR] text`, `(Speaker 1) text`, or a name alone on its line with the speech below. Sent to a TTS engine as-is, the names are read aloud. When no cast exists yet and at least two speakers are found, Subtitle Edit offers to move the names into the actor field: the tags leave the spoken text, the **Set up cast** dialog opens so each speaker gets a voice, and generation continues with the cast. + +- Names written the SDH way (ALL CAPS, `Speaker 1`) are checked by default; mixed-case candidates like `Warning:` are listed for you to judge, but unchecked. +- **Lines without a name continue the previous speaker** (on by default) handles the SDH convention of naming only the speaker changes — the lines between two tags belong to the name above them. +- Only the speech generation is affected: the subtitle in the main window keeps its text. To move the names into the actor field of the file itself, use **Tools → Convert actors...**. + +### Merge continuation lines + +Sentences split across several subtitles are offered for merging, so the engine speaks each thought as one breath group. These merges are applied to the subtitle in the main window when the window is closed with **OK**. + +### Skip sound and music lines + +Lines that contain only sounds or music — `♪`, `[door slams]`, `(sighs)`, or nothing once formatting tags are stripped — get read aloud or hallucinated into made-up words by TTS engines. Subtitle Edit offers to leave the checked lines silent: no audio is generated for them, and they are not counted as failures. A sound annotation followed by real speech (`[gunshot] Get down!`) is kept. + ## Supported Engines - **Piper** — Local, open-source TTS (Windows and Linux) @@ -32,16 +60,54 @@ Generate speech audio from subtitle text using various TTS engines. - **OmniVoice TTS** — Local CPU TTS with voice cloning and many languages - **Qwen3 TTS (CrispASR)** — Local Qwen3 TTS running through the CrispASR runtime (VoiceDesign, CustomVoice, and Voice clone 1.7B models) - **Chatterbox TTS (CrispASR)** — Chatterbox TTS via the CrispASR runtime, with voice cloning (multilingual Base or English-only Turbo model) -- **IndexTTS (CrispASR)** — IndexTTS-1.5 via the CrispASR runtime; the smallest voice-cloning engine here (about 870 MB) +- **IndexTTS (CrispASR)** — IndexTTS-1.5 via the CrispASR runtime; a small voice-cloning engine (about 870 MB) - **CosyVoice3 (CrispASR)** — Alibaba CosyVoice3 with 9 languages and 18 Mandarin dialects, baked-in voice presets and zero-shot cloning - **IndexTTS 2.5 (audio.cpp)** — IndexTTS-2.5 on the audio.cpp runtime: cloning in Chinese, English, Japanese, Spanish and Arabic, with emotion and speaking-rate control. The reference voice is sent per request, so switching voice does not restart the server - **VoxCPM2 (CrispASR)** — Tokenizer-free diffusion engine at 48 kHz, about 30 languages, with zero-shot cloning - **MOSS-TTS (CrispASR)** — MOSS-TTS v1.5 (Qwen3-8B backbone, 24 kHz) with zero-shot cloning - **Zonos TTS (CrispASR)** — Zonos-v0.1 at 44.1 kHz with cloning from a reference recording - **OmniVoice TTS (CrispASR)** — The OmniVoice model on the shared CrispASR runtime, run as a persistent server so the model loads once instead of once per line +- **dots.tts (CrispASR)** — dots.tts SOAR 2B rendered at 48 kHz by a BigVGAN vocoder, with zero-shot cloning +- **VibeVoice (CrispASR)** — Microsoft VibeVoice 1.5B via the CrispASR runtime, with voice cloning; a single GGUF with no separate codec file +- **Confucius4-TTS (CrispASR)** — NetEase Youdao Confucius4-TTS at 22.05 kHz, 14 languages; cloning only — a reference voice is required, there is no default voice +- **Pocket TTS (CrispASR)** — Kyutai Pocket TTS 100M; the smallest and fastest cloning engine (one 124-365 MB GGUF per language, 6 languages), and the reference is sent per request +- **Higgs Audio v3 (audio.cpp)** — Boson AI Higgs Audio v3 4B on the audio.cpp runtime; zero-shot cloning in 100+ languages, per-request reference so switching voice does not restart the server +- **Fish Audio S2 Pro (audio.cpp)** — Fish Audio S2 Pro on the audio.cpp runtime; zero-shot cloning in 80+ languages at 44.1 kHz, per-request reference Local downloadable engines are installed into the Subtitle Edit data folder when you accept the download prompt. +## CrispASR voice engines + +Several of the local engines above are different models on the same CrispASR runtime, sharing one `CrispASR/models` folder with the speech-to-text backends. What separates them is voice output quality rather than features, so they are collected here. + +**Output rate** is the engine's native render rate - 48 kHz carries noticeably more high end than 24 kHz on headphones, though for speech mixed under a video the difference is small. **Reference WAV** is the format a cloning reference is converted to on import; Subtitle Edit resamples with ffmpeg for you, but a clean 3-10 second recording at or above that rate clones best. + +| Engine | Output rate | Languages | Voice cloning | Reference WAV | Download | +|--------|-------------|-----------|---------------|---------------|----------| +| **OmniVoice TTS (CrispASR)** | 24 kHz | 646 | Built-in voice + zero-shot | 24 kHz mono | ~1 - 1.6 GB | +| **Qwen3 TTS (CrispASR)** | 24 kHz | 10 | VoiceDesign, CustomVoice or Voice clone | 24 kHz mono (strictly enforced) | ~2.3 GB | +| **Chatterbox TTS (CrispASR)** | 24 kHz | 23 on Base; Turbo is English-only | Zero-shot | 24 kHz mono | ~700 MB - 1.8 GB Base, ~1 GB Turbo | +| **IndexTTS (CrispASR)** | 24 kHz | Follows the text | Zero-shot | 24 kHz mono | ~600 MB - 2.4 GB | +| **CosyVoice3 (CrispASR)** | 24 kHz | 9, plus 18 Mandarin dialects as voices | 8 baked-in presets + zero-shot | 16 kHz mono + a transcript sidecar | ~1.6 - 2.5 GB | +| **MOSS-TTS (CrispASR)** | 24 kHz | 20 | Zero-shot | 24 kHz mono | ~10.5 - 20.5 GB incl. codec | +| **Zonos TTS (CrispASR)** | 44.1 kHz | 100+ via the language picker (trained on English, Japanese, Chinese, French and German; the rest rely on eSpeak pronunciation) | From a reference recording | 24 kHz mono | ~1.8 GB | +| **VoxCPM2 (CrispASR)** | 48 kHz | ~30 | Zero-shot | 24 kHz mono (upsampled internally) | ~1.7 - 5 GB | +| **dots.tts (CrispASR)** | 48 kHz | Follows the text | Zero-shot | 24 kHz mono | ~2.4 - 5 GB | +| **VibeVoice (CrispASR)** | 24 kHz | Follows the text | Zero-shot | 24 kHz mono | ~1.6 - 5 GB | +| **Confucius4-TTS (CrispASR)** | 22.05 kHz | 14 | Zero-shot (required - no default voice) | 22.05 kHz mono | ~1.9 - 2.6 GB | +| **Pocket TTS (CrispASR)** | 24 kHz | 6 (one model per language) | Zero-shot, per request | 24 kHz mono | ~124 - 365 MB per language | + +"Follows the text" means the engine has no language picker - it speaks whatever script it is given, taking its accent from the reference voice. + +Notes on picking one: + +- **Smallest download that still clones:** Pocket TTS at 124-365 MB per language; IndexTTS (about 600 MB - 870 MB) is the smallest that covers many languages with one model. +- **Most languages:** OmniVoice, at 646. +- **Highest output rate:** VoxCPM2 and dots.tts at 48 kHz, then Zonos at 44.1 kHz. +- **MOSS-TTS is by far the largest** because its Qwen3-8B backbone needs a ~3.5 GB codec companion on top of the backbone quant. Check free disk space before selecting it. +- Quantized engines follow the same rule as the speech-to-text models: `Q4_K` is the small fast default, `Q8_0` is close to full precision, and `F16` is rarely worth the extra gigabytes. +- **Most of the CrispASR engines load their reference voice at server start**, so switching voice reloads the model. The exceptions are **Pocket TTS** (per-request reference) and **Qwen3 TTS** with the Voice clone model — those, the three audio.cpp engines (**IndexTTS 2.5**, **Higgs Audio v3**, **Fish Audio S2 Pro**) and the standalone OmniVoice TTS engine are what [Clone From Video (Voice of Each Line)](#clone-from-video-voice-of-each-line) can use. + ## Engine Settings Some engines require additional configuration: @@ -97,7 +163,7 @@ For dubbing a video with several speakers there is a faster way than cloning eac Before generating, Subtitle Edit cuts one short reference clip per line out of the video's audio. Lines shorter than about three seconds are grown into the silence around them, but never into the neighbouring line — a reference with two speakers in it would clone the wrong person. -- **Supported engines:** OmniVoice TTS. The entry only appears for engines that accept a reference for each line; engines that load their reference when their server starts would have to reload the model for every line. +- **Supported engines:** OmniVoice TTS, Pocket TTS (CrispASR), Qwen3 TTS (CrispASR) with the Voice clone model, and the audio.cpp engines IndexTTS 2.5, Higgs Audio v3 and Fish Audio S2 Pro. The entry only appears for engines that accept a reference for each line; engines that load their reference when their server starts would have to reload the model for every line. - **Reference text:** if you have the original subtitle loaded next to the translation, its lines are used as the transcript of the clips — that is what the video actually says, and it makes cloning noticeably better. Without an original loaded, the line's own text is used. - **Test voice** previews the clone taken from the longest line of the subtitle. - Quality depends on the source audio. Loud music or two people talking over each other in a line makes that line's clone worse. Longer subtitle lines clone better than very short ones, so a subtitle segmented into full sentences gives the best result. @@ -154,7 +220,7 @@ Behavior depends on the selected model: ## Review Audio Clips -When **Review audio clips** is enabled, a dedicated review window opens after generation. This window lets you inspect, play, and regenerate audio for every subtitle line before the result is used. A 120px waveform of the original video audio is shown above the grid as a reference. The session (clips, per-line voice and engine, includes, text edits) can be written to `SubtitleEditTts.json` and imported again later — see [Continuing a Session Later](#continuing-a-session-later). Regeneration history is kept for the current session only and is not part of the export. +When **Review audio segments** is enabled, a dedicated review window opens after generation. This window lets you inspect, play, and regenerate audio for every subtitle line before the result is used. A 120px waveform of the original video audio is shown above the grid as a reference. The session (clips, per-line voice and engine, includes, text edits) can be written to `SubtitleEditTts.json` and imported again later — see [Continuing a Session Later](#continuing-a-session-later). Regeneration history is kept for the current session only and is not part of the export. ### The Review Grid @@ -162,7 +228,7 @@ Each subtitle line is shown as a row with the following columns: | Column | Description | |--------|-------------| -| **Include** | Checkbox to include or exclude the line from the final output | +| **Enabled** | Checkbox to include or exclude the line from the final output | | **#** | Subtitle line number | | **Text** | The subtitle text (editable — double-click to modify before regenerating). Text edits are applied to the subtitle in the main window when the Text to speech window is closed with **OK** | | **Voice** | The voice used for that line | @@ -175,13 +241,15 @@ Each subtitle line is shown as a row with the following columns: - **Stop** — Stops playback - **Auto-continue** — When enabled, playback automatically advances to the next line as soon as the current clip finishes +Right-click a line in the waveform for **Play line**, **Regenerate audio**, **Show history** and two timing fixes: **Fit duration to generated audio** ends the line where the generated speech ends, and **Reset timing** restores the times the line had when the window opened. + ### Regenerating a Clip You can regenerate the audio for any individual line: 1. Select the line in the grid 2. Choose the desired engine, voice, language, model, or style from the dropdowns -3. Click **Regenerate** or press **Ctrl+R** +3. Click **Regenerate** or press **R** (or **Ctrl+R**) The new clip is trimmed for silence and automatically speed-adjusted to fit the subtitle timing. After regeneration the new clip plays back immediately for review. @@ -193,7 +261,7 @@ Every time a clip is regenerated, the previous version is saved. To review the h ### Including / Excluding Lines -Uncheck the **Include** checkbox on any row to exclude that line's audio from the final output. Excluded lines are skipped when the video file is assembled. +Uncheck the **Enabled** checkbox on any row to exclude that line's audio from the final output. Excluded lines are skipped when the video file is assembled. ### Exporting Clips @@ -211,7 +279,7 @@ What comes back with the session: - Every line's generated clip, text and timing - The engine, model, voice, instruction and speed factor each line was generated with -- The **Include** checkboxes +- The **Enabled** checkboxes - The actor/voice **cast** mapping, so regenerating uses the same voices - The video the session was made from — you do not have to open it first @@ -221,6 +289,7 @@ From there you can play, edit text, regenerate single lines, and finish the sess | Key | Action | |-----|--------| -| Ctrl+R | Regenerate selected line | +| R / Ctrl+R | Regenerate selected line | +| Space | Play / pause the selected line | | Escape | Close / Cancel | | F1 | Open help | diff --git a/docs/features/transparent-subtitles.md b/docs/features/transparent-subtitles.md index e69b354d0a0..0c0b38f5f77 100644 --- a/docs/features/transparent-subtitles.md +++ b/docs/features/transparent-subtitles.md @@ -13,7 +13,7 @@ Generate a transparent video overlay with rendered subtitles that can be composi 1. Open **Video → Generate transparent video with subtitles...** 2. Configure font settings (name, size, colors, outline, shadow, alignment) 3. Set the video resolution and frame rate -4. Select the output video extension (e.g. `.mov`, `.mkv`, `.mp4`, `.webm`) +4. Select the output video extension (`.mov` or `.mkv`) 5. Select an output folder 6. Click **Generate** to create the transparent video @@ -29,22 +29,28 @@ Generate a transparent video overlay with rendered subtitles that can be composi - **Spacing** — Letter spacing, from -20 to 100 - **Alignment** — Subtitle alignment position - **Margins** — Horizontal and vertical margins -- **Fix RTL** — Fix right-to-left text rendering + +## Effect + +The **Effect** browse button opens a picker with one entry to apply to every line: **Fix right-to-left**, **Fade in/out**, **Slow font size change**, **Increase font kerning**, **Scroll up**, **Scroll down**, **Rotate in**, **Tilt bounce** and **Font size bounce in**. The selected effect is shown next to the button. + +## Preview + +In single mode the loaded video plays in an embedded player with the current style and effect rendered on top. In batch mode a static preview image rendered from the current settings is shown instead. ## Video Settings - **Resolution** — Output video width and height (or use source resolution) - **Frame rate** — Output frame rate -- **Extension** — Output container format (`.mov`, `.mkv`, `.mp4`, `.webm`) +- **Extension** — Output container format (`.mov`, `.mkv`). The overlay is encoded as ProRes 4444, which `.mp4` and `.webm` cannot carry ## Cut Options - **Cut from/to** — Generate only a portion of the subtitle timeline -- **Target file size** — Limit output to a specific file size ## Batch Mode -Multiple jobs can be queued for batch processing. +Click **Batch mode** to queue several subtitle files (**Single mode** switches back). The list shows subtitle file name, size, video file and status, with **Add...**, **Remove**, **Clear** and **Pick video file...** buttons. **Output properties...** sets the output folder and naming; the chosen folder (or **Use source folder**) is shown next to it. ## Keyboard Shortcuts diff --git a/docs/features/video-ocr.md b/docs/features/video-ocr.md index 79d514644d1..983dc9c3155 100644 --- a/docs/features/video-ocr.md +++ b/docs/features/video-ocr.md @@ -4,46 +4,67 @@ Extract hardcoded (burned-in) subtitles from a video into editable text lines us - **Menu:** Video → OCR burned-in subtitle... + +![Video OCR](../screenshots/video-ocr.png) + ## How to Use 1. Open **Video → OCR burned-in subtitle...** (a video file is required) 2. Use the preview slider to find a frame that shows a subtitle 3. Adjust the scan area rectangle so it covers where the subtitles appear (default: bottom third) — drag to move/resize, or use the preset buttons 4. Pick an OCR engine and language (for Ollama and llama.cpp, also pick a vision model) -5. Optionally click **Test OCR** to try the current frame before running the full pass -6. Click **Start OCR** — lines appear in the list as they are recognized +5. Optionally click **Test current frame** to try the current frame before running the full pass +6. Click **Start OCR** — lines appear in the list as they are recognized, already run through the OCR fix engine and spell check when a dictionary is selected 7. Click **OK** to load the result into the main window -The recognized text is editable directly in the list, so OCR mistakes can be fixed in place. Double-click a line to show the frame it came from in the preview, and press Delete to remove selected lines. +To fix an OCR mistake, press **Enter** (or **F2**) on a line — or right-click and choose +**Edit...** — to open the text in a small edit window. The right-click menu also offers +**Italic** and **Delete** (the Delete key works too). Double-click a line to show the frame +it came from in the preview. ## OCR Engines -- **Paddle OCR** — local, fast and accurate; downloaded automatically (Windows/Linux). Recommended. -- **Paddle OCR Python** — local, via a Python `paddleocr` installation (also works on macOS) -- **Ollama vision** — local vision model via [Ollama](https://ollama.com), e.g. `glm-ocr` -- **llama.cpp** — local vision model via a managed llama.cpp server; the engine and models are downloaded automatically. Available models: GLM-OCR 0.9B, LightOnOCR 1B, PaddleOCR-VL 1.6 (109 languages) and HunyuanOCR 1.5. A green dot marks models that are already downloaded; custom `*.gguf` files in the llama.cpp models folder also appear. -- **CrispEmbed** — local OCR engine with several model backends, downloaded automatically; pick the backend and model from the dropdowns (see [OCR](ocr.md#crispembed) for the backend list) -- **GLM API** — GLM vision model via the Z.ai / bigmodel.cn API (requires an API key) +Engines are listed best-first for burned-in subtitles: + +- **Apple Vision** (macOS) — the OCR built into macOS: local, fast, and nothing to download. Recommended on macOS. +- **Paddle OCR** (Windows/Linux) — local, fast and accurate; downloaded automatically. Recommended on Windows and Linux. +- **CrispEmbed** — local OCR engine with several model backends, downloaded automatically. For video the offered backends are GLM-OCR (default), DeepSeek-OCR-2 and PP-OCRv6 (see [OCR](ocr.md#crispembed)). +- **llama.cpp** — local vision model via a managed llama.cpp server; the engine and models are downloaded automatically. Available models, listed best-first for subtitles: GLM-OCR 0.9B (the default), PaddleOCR-VL 1.6 (109 languages), HunyuanOCR 1.5 and LightOnOCR 1B. A green dot marks models that are already downloaded; custom vision models in the llama.cpp models folder also appear, as long as the `mmproj` vision projector sits next to the `*.gguf` (see [OCR](ocr.md#llamacpp)). +- **Ollama vision** — local vision model via a self-managed [Ollama](https://ollama.com) installation, e.g. `glm-ocr` - **GLM API** — GLM vision model via the Z.ai / bigmodel.cn cloud API (requires an API key) ## How It Works Frames are sampled from the scan area at a few frames per second with ffmpeg. Consecutive near-identical frames are collapsed so each on-screen subtitle is OCR'ed only once, then -consecutive OCR results with near-identical text are merged into one line with correct start/end -times (the text variant shown the longest wins). +consecutive OCR results with near-identical text are merged into one line (the text variant +shown the longest wins, weighted by the engine's recognition confidence where available). +Finally each line's start and end are refined against the video's own frames, so the times +are precise to a few hundredths of a second even though the scan itself samples much coarser. + +## Fix OCR errors and spell check + +With **Fix OCR errors** enabled and a **Dictionary** selected (the list shows the +[spell check dictionaries](spell-check.md) already downloaded; the language is pre-picked +from the OCR language), every line runs through the OCR fix engine as it is recognized: +common OCR mistakes are corrected from the language's replace list, and the words are +spell checked. In the list, words the dictionary knows are shown green and unknown words +red — so the lines that need a human eye stand out while the OCR is still running. ## Settings -- **Frames per second** — how many frames per second to sample (higher = more precise timing, slower) +- **Frames per second** — how many frames per second to sample (higher = more precise grouping, slower) - **Text brightness minimum** — pixels darker than this are ignored when comparing frames, so the comparison follows the (bright) subtitle text instead of the moving video behind it; frames with - no bright pixels are skipped entirely. Set to 0 to disable (e.g. for dark subtitle text). + no bright pixels are skipped entirely. With the Paddle engine, everything below this brightness is + also blacked out in the image the engine reads, so darker scene text (shirt prints, credits) does + not get mixed into the subtitles. Set to 0 to disable (e.g. for dark subtitle text). - **Merge lines with similarity (%)** — how similar the text of two consecutive OCR results must be to merge into one line - **Max gap between lines (ms)** — maximum time gap allowed when merging - **Minimum duration (ms)** — lines shorter than this are dropped (removes OCR blips/false positives) - **Add ASSA position tag** — prepend an alignment tag (e.g. `{\an8}` for a top scan area) based on the scan area position, for Advanced Sub Station Alpha output +- **Fix OCR errors** / **Dictionary** — see above ## Tips diff --git a/docs/features/video-player.md b/docs/features/video-player.md index d6dc2b2ddeb..c35f3fda351 100644 --- a/docs/features/video-player.md +++ b/docs/features/video-player.md @@ -11,6 +11,7 @@ Subtitle Edit includes an integrated video player for previewing subtitles with - **Shortcut:** Configurable via Options → Shortcuts - **Drag and drop** a video file onto the Subtitle Edit window - You can also open video from a URL: **Video → Open video from URL...** +- Recently opened videos are listed under **Video → Open recent video**; the submenu's **Clear recent videos** item empties the list ## Playback Controls @@ -75,12 +76,21 @@ Use [Embedded Subtitles](embedded-subtitles.md) to add, remove, preview, and edi ## Supported Video Players Configure the video player backend in **Options → Settings → Video player**: -- **libmpv** — Recommended for best format support (default). The render mode can be set to **Auto**, **Native**, **OpenGL**, or **Software (slow)**. -- **libVLC** — Alternative backend (Windows and Linux only). +- **libmpv - OpenGL** — default on Linux and macOS +- **libmpv - Native Window ID rendering** — default on Windows (not available on macOS) +- **libmpv - Software rendering (slow)** +- **libVLC - Native Window ID rendering** — alternative backend (Windows and Linux only) + +The same settings page also has: +- **Show stop button** / **Show full-screen button** +- **Hide video controls in full-screen** +- **Auto-open video file when opening subtitle** +- **Download mpv** / **Download VLC** — fetch the player library when it is not installed +- **Subtitle preview properties** — how the subtitle is drawn on the video ## Video Info -You can view detailed information about the video file by right-clicking the video player (or via the "Show media information" shortcut). +You can view detailed information about the video file via the **Show media information** shortcut (assign a key in **Options → Shortcuts**). This displays: - Video codec, resolution, frame rate, and bitrate @@ -100,5 +110,8 @@ If the video has multiple audio tracks, you can toggle between them via the vide The Video menu also includes: - **Toggle select subtitle while playing** - automatically select the current subtitle during playback. -- **Set video offset** - shift video playback relative to the subtitle timing. -- **SMPTE timing** - toggle SMPTE-style timing display when available. + +Under **Video → More** (shown while a video is loaded): + +- **Set video offset...** - shift video playback relative to the subtitle timing. +- **SMPTE timing (non-integer frame rate)** - toggle SMPTE-style timing display when available. diff --git a/docs/features/visual-sync.md b/docs/features/visual-sync.md index ac853f38d45..a616a33255c 100644 --- a/docs/features/visual-sync.md +++ b/docs/features/visual-sync.md @@ -17,4 +17,6 @@ The Visual sync window shows two video player panes ("Start scene" and "End scen 4. Click **Sync** to apply (or use **Manual sync...** from the Sync split-button for a manual offset/speed adjustment) 5. Click **OK** to keep the result +An **Open video file...** button at the top loads a video if none is open. Each pane has **One second back** / **One second forward** arrow buttons, **Play 2 secs & back**, **Go to sub pos** (jump to the selected line's position) and **Find text** to search for a line. + The timing of all subtitles is linearly adjusted to match the two sync points. The window remembers its size and position between sessions. diff --git a/docs/index.md b/docs/index.md index 60bb159e8d4..af054e74adf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,6 +14,7 @@ Subtitle Edit is a free, open-source editor for video subtitles. This is the doc ### File Operations - [File Menu](features/file.md) — New, Open, Save, Import, Export - [Import Plain Text](features/import-plain-text.md) — Create subtitles from text, with forced-aligner timing +- [Import Spreadsheets](features/import-csv-xlsx.md) — Import CSV, XLSX and ODS files, with custom column mapping - [Edit Image-based Subtitle](features/binary-edit.md) — Edit Blu-ray SUP/VobSub/DVB subtitles without OCR ### Editing @@ -84,7 +85,7 @@ Subtitle Edit is a free, open-source editor for video subtitles. This is the doc - [Find Double Lines](features/find-double-lines.md) — Find consecutive duplicate subtitle lines ### OCR (Optical Character Recognition) -- [OCR](features/ocr.md) — Convert image-based subtitles to text (nOCR, Binary OCR, Tesseract) +- [OCR](features/ocr.md) — Convert image-based subtitles to text (nOCR, Binary OCR, Tesseract, PaddleOCR, CrispEmbed, LLM-based and cloud engines) ### ASSA (Advanced SubStation Alpha) - [ASSA Styles](features/assa-styles.md) — Manage ASS/SSA styles diff --git a/docs/overview.md b/docs/overview.md index 343c685ec37..847cba1da3b 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -25,7 +25,7 @@ Subtitle Edit is a free, open-source editor for video subtitles. It allows you t ## System Requirements -- Windows 10 (version 22H2 / build 19045) or newer, Linux, or macOS 12 (Monterey) or newer +- Windows 10 (version 22H2 / build 19045) or newer, Linux, or macOS 12 (Monterey) or newer (macOS 14 / Sonoma or newer recommended - that is the oldest macOS .NET 10 is supported on) - [FFmpeg](https://ffmpeg.org/) (for audio/video processing) - [libmpv](https://mpv.io/) for video playback @@ -37,7 +37,7 @@ Follow these simple steps to begin working with subtitles in Subtitle Edit: Download and install the latest version from the official website. 2. **Open a video file** - Go to **Video → Open video file...** and select your video. + Go to **Video → Open video...** and select your video. This allows you to preview and sync subtitles accurately. 3. **Open, create, or generate a subtitle file** @@ -71,7 +71,7 @@ The main window consists of several key areas: - **Video Player** — Preview video with subtitles - **Audio Visualizer** — Waveform/spectrogram for precise timing adjustments -Subtitle Edit offers **12 different layouts** for arranging these areas. You can choose and customize the layout via **Options → Choose layout**. +Subtitle Edit offers **12 different layouts** for arranging these areas. Choose one with the **Layout** button on the toolbar or its configurable shortcut. For a detailed guide to each area, mouse interactions, and keyboard shortcuts, see the **[Main Window](features/main-window.md)** documentation. diff --git a/docs/plugin.md b/docs/plugin.md index 800c8772995..0da2f35563b 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -16,7 +16,7 @@ compatible with SE5 and must be ported. > and removing plugins update the **Plugins** menu live (no restart needed). A startup > update notification is still to come. > -> The **Plugins** menu is shown by default but can be hidden via +> The **Plugins** menu is hidden by default; turn it on via > **Options → Settings → Appearance → Show Plugins menu**. The menu must be enabled > there to run or manage plugins from the main window. diff --git a/docs/reference/assa-override-tags.md b/docs/reference/assa-override-tags.md index 6e207014427..f3b5b827174 100644 --- a/docs/reference/assa-override-tags.md +++ b/docs/reference/assa-override-tags.md @@ -215,6 +215,8 @@ This is {\1c&HFF0000&}blue text **Important:** Alpha values range from `00` (fully opaque/visible) to `FF` (fully transparent/invisible). This is opposite to many other programs! +**Image export:** `\alpha`, `\1a`, `\3a` and `\4a` are honoured by the image-based exports (Blu-ray SUP, VobSub, BDN-XML, PNG, ...) - the subtitle is drawn at the transparency asked for. + **Alpha value examples:** - `&H00&` — Fully opaque (100% visible) - `&H80&` — 50% transparent @@ -406,6 +408,8 @@ This is {\1c&HFF0000&}blue text **Example:** `{\fad(500,500)}Fade test` (500ms fade-in, 500ms fade-out) +**Image export:** `\fad` and `\fade` survive an export to **Blu-ray SUP** (File → Export image-based, batch convert, and `seconv`), where the fade is written as palette updates. The other image formats have no way to animate a subtitle and write it fully opaque. + ![Fade 1 example](../screenshots/assa/fade-1.gif) **Complex fade (`\fade`) explanation:** diff --git a/docs/reference/command-line.md b/docs/reference/command-line.md index 0418d216315..fcfb68484b9 100644 --- a/docs/reference/command-line.md +++ b/docs/reference/command-line.md @@ -75,13 +75,13 @@ seconv subs.srt subrip --offset:-2000 --renumber:1 --overwrite # offset 2s b ## Subcommands ```bash -seconv formats # list all supported formats -seconv list-encodings # list text encodings +seconv formats # list all supported formats (also /formats, --formats) +seconv list-encodings # list text encodings (for --encoding and --input-encoding-fallback) seconv list-pac-codepages # list PAC code pages seconv list-ocr-engines # list OCR engines + installation status seconv list-fce-rules # list FixCommonErrors rule IDs -seconv list-rf-rules # list remove-formatting rule IDs -seconv dump-settings # print a full --settings JSON with libse defaults +seconv list-rf-rules # list remove-formatting rule IDs (alias: list-remove-formatting-rules) +seconv dump-settings # print a full --settings JSON with libse defaults (alias: default-settings) seconv info # print format/encoding/duration/language for a file seconv lint # validate subtitle(s); exit 1 if issues found seconv --help # show help (same text as -h, /? and /help) @@ -134,8 +134,9 @@ seconv lint *.srt --json # CI-friendly: exit 1 on any issue | `--output-folder:` | Output folder (default: input file's directory) | | `--output-filename:` | Output file name (single input only) | | `--overwrite` | Overwrite existing files (default: rotate to `name_2.ext`, `_3.ext`, ...) | -| `--keep-timestamp` | Give output files the source file's modified/created date instead of the conversion time | +| `--keep-timestamp` (also `--keep-timestamps`) | Give output files the source file's modified/created date instead of the conversion time | | `--encoding:` | Encoding name or codepage. Special values: `utf-8`, `utf-8-no-bom` (also `utf-8-nobom`, `utf8-nobom`), a code page number, or `source` to keep the input file's detected encoding. Defaults: auto-detect on input, UTF-8 BOM on output | +| `--input-encoding-fallback:` | Encoding to assume when the input is not UTF-8 / has no BOM, instead of the ANSI auto-detection (names as in `seconv list-encodings`). Ignored when `--encoding` is set | ### Time / frame @@ -180,18 +181,25 @@ When rendering a text subtitle to an image-based target (Blu-Ray `sup`, VobSub, | `--box-padding:` | Box padding: one value for all sides, or `left,right,top,bottom` (default: `5,5,3,3`) | | `--line-spacing:` | Extra gap between lines as percent of line height (default: `0`) | | `--alignment:` | Screen position: `bottom-center` (default), `top-left`, `middle-right`, ... | -| `--content-alignment:` | Multi-line text justification: `left` \| `center` (default) \| `right` | +| `--content-alignment:` | Multi-line text justification: `left` \| `center` (default) \| `right` \| `from-alignment` (follow the `{\anX}` tag) | | `--bottom-top-margin:` | Vertical screen-edge margin (default: 5% of height) | | `--left-right-margin:` | Horizontal screen-edge margin (default: 5% of width) | +| `--full-frame` | Draw each subtitle onto a frame-sized image instead of one cropped to the text. Only `fcpimage` and `bluraysup` use it; other image targets warn and ignore it | +| `--full-frame-background-color:` | Background of the full frame image (default: `transparent`) | Colours accept hex (`#AARRGGBB`, `#RRGGBB`, with or without `#`) or a colour name (`white`, `black`, `yellow`, ...). +**Full frame** (`--full-frame`) draws the subtitle onto a canvas the size of the video frame, using the alignment and margins to place it there, so every image can be dropped on an editing timeline at 0,0 instead of being positioned one by one. It matches the "Full frame image" checkbox in the export dialog, and applies to `fcpimage` and `bluraysup` only. The background is transparent unless `--full-frame-background-color` says otherwise, so the images sit on a track above the video. + ```bash # SRT → UHD Blu-Ray sup with a semi-transparent black background box (SE4-style) seconv movie.srt bluraysup --resolution:3840x2160 --background-color:"#B4000000" # Custom font, bold, box per line seconv movie.srt bluraysup --font-name:Verdana --font-size:60 --font-bold --box-type:box-per-line + +# Final Cut Pro + image, one frame-sized png per subtitle +seconv movie.srt fcpimage --full-frame ``` ### Containers / tracks @@ -200,12 +208,14 @@ seconv movie.srt bluraysup --font-name:Verdana --font-size:60 --font-bold --box- | Extension | Sources | |---|---| -| `.mkv`, `.mks` | Matroska text tracks (S_TEXT/UTF8, SSA, ASS, HDMV/TEXTST) and image tracks (S_HDMV/PGS via OCR) | -| `.mp4`, `.m4v`, `.m4s`, `.3gp` | MP4 text tracks and WebVTT VTTC | +| `.mkv`, `.mks`, `.webm` | Matroska text tracks (S_TEXT/UTF8, SSA, ASS, HDMV/TEXTST) and image tracks (S_HDMV/PGS via OCR) | +| `.mp4`, `.m4v`, `.m4s`, `.3gp`, `.mov`, `.m4a`, `.m4b`, `.cmaf` | MP4 text tracks and WebVTT VTTC | | `.mcc` | MacCaption 1.0 | | `.ts`, `.m2ts`, `.mts` | Transport stream — teletext (no OCR) and DVB-sub (via OCR) | | `.sup` | Blu-Ray sup (via OCR) | +| `.sub`, `.idx` | VobSub (via OCR) — pass either file of the pair; the companion is found automatically. A `.sub` with no `.idx` is read with its stream timing and a default palette | | `.avi`, `.divx` | XSUB / DivX subtitles (via OCR) | +| `.mxf` | MXF timed-text essences (TTML, SRT, …; auto-detected per essence). Image essences are not extracted | When a container has multiple usable tracks, one output file is written per track with the track's language code as a suffix: @@ -234,8 +244,8 @@ An AVI stream header carries no language, so a multi-stream `.avi` names its out | `nocr` | In-process | Built-in nOCR matcher. Required: `--ocr-db:`. | | `binaryocr` *(alias: `binary`)* | In-process | Built-in BinaryOCR matcher (different accuracy profile, similar speed). Required: `--ocr-db:`. | | `ollama` | HTTP | Local Ollama server with a vision-capable model (e.g. `llama3.2-vision`, `qwen2.5vl`). Configure via `--ollama-url` (default `http://localhost:11434/api/chat`) and `--ollama-model` (default `llama3.2-vision`). Pass `--ocr-language` as a human name like `English`. | -| `llamacpp` *(aliases: `llama.cpp`, `llama`)* | HTTP | llama.cpp with a curated OCR vision model (GLM-OCR, LightOnOCR, PaddleOCR-VL). With no `--ocr-url`, seconv finds `llama-server` (SE data folder next to seconv, installed SE data folder, then `PATH`) and an installed OCR model, starts the server on a free loopback port, and stops it at exit. seconv never downloads engines/models — install them via the SE UI's OCR window (engine "llama.cpp") or point `--ocr-url` at a running server. Pass `--ocr-language` as a human name like `English`. | -| `paddle` *(alias: `paddleocr`)* | Subprocess | Install via `pip install paddleocr`; ensure the `paddleocr` binary is on `PATH`. Pass `--ocr-language` as a short code (`en`, `de`, …). | +| `llamacpp` *(aliases: `llama.cpp`, `llama`)* | HTTP | llama.cpp with a curated OCR vision model (best-first: GLM-OCR, PaddleOCR-VL, HunyuanOCR 1.5, LightOnOCR). With no `--ocr-url`, seconv finds `llama-server` (SE data folder next to seconv, installed SE data folder, then `PATH`) and an OCR model — the first model in that order that is installed, unless `--ocr-model` names one — starts the server on a free loopback port, and stops it at exit. seconv never downloads engines/models — install them via the SE UI's OCR window (engine "llama.cpp") or point `--ocr-url` at a running server. Pass `--ocr-language` as a human name like `English`. | +| `paddle` *(alias: `paddleocr`)* | Subprocess | Install via `pip install paddleocr` (3.7 or newer, for the PP-OCRv6 models); ensure the `paddleocr` binary is on `PATH`. Pass `--ocr-language` as a short code (`en`, `de`, …). | | Option | Description | |---|---| @@ -245,10 +255,12 @@ An AVI stream header carries no language, so a multi-stream `.avi` names its out | `--dictionary-folder:` | Folder with Hunspell dictionaries + `*_OCRFixReplaceList.xml`; enables the "Fix common OCR errors" pass of `--fix-common-errors` (English is bundled, so this is only needed for other languages) | | `--ollama-url:` | Default `http://localhost:11434/api/chat` | | `--ollama-model:` | Default `llama3.2-vision` | -| `--ocr-model:` | llama.cpp OCR model: curated `.gguf` file name (e.g. `GLM-OCR-Q8_0.gguf`) or a full path to a `.gguf` with its `mmproj` sidecar next to it. Default: the first downloaded OCR model. | +| `--ocr-model:` | llama.cpp OCR model: the file name of a model in the llama.cpp models folder - curated (e.g. `GLM-OCR-Q8_0.gguf`) or your own vision model with its `mmproj` sidecar next to it - or a full path to a `.gguf` with its `mmproj` sidecar next to it. Default: the first downloaded OCR model. | | `--ocr-url:` | llama.cpp: endpoint of an already-running `llama-server` (a bare `host:port` is completed to `/v1/chat/completions`); skips the local auto-start. | +| `--ocr-prompt:` | Prompt for the prompt-driven OCR engines (`llamacpp`, `ollama`); rejected for the others. `{language}` is replaced with `--ocr-language`. A value that names an existing file, or ends in `.txt`/`.prompt`/`.md`, is read from that file; inline text gets `\n`/`\r`/`\t` unescaped. Default: the same prompt as the SE OCR window. | | `--time-codes-only` | Image sources (`.sup`, VobSub `.sub`/`.idx`, MKV PGS/VobSub, MP4 VobSub, TS DVB-sub, AVI XSUB) → text format with time codes only and empty text. **Skips OCR entirely** — no OCR engine required. Ignored for text inputs and image output targets. | | `--no-vobsub-isolate-colors` | Disable VobSub OCR colour isolation, which is **on by default**. Isolation rebuilds each subpicture as a crisp black-on-white bitmap via histogram-based colour analysis — the most frequent opaque colour (the glyph fill) becomes black and the gray outline / anti-alias colours collapse into the white background, which helps on discs whose outlines otherwise melt adjacent characters together (`Yuri` → `Yurl`). Pass this flag to OCR the raw palette instead. Ignored for non-VobSub sources and with `--time-codes-only`. | +| `--no-pgs-isolate-colors` | Disable PGS / DVB-sub OCR colour isolation, which is likewise **on by default**. | > **OCR database files are not bundled with `seconv`.** The `nocr` and `binaryocr` engines need a `.nocr` or `.db` file passed via `--ocr-db`. Sources: > @@ -258,6 +270,12 @@ An AVI stream header carries no language, so a multi-stream `.avi` names its out Run `seconv list-ocr-engines` for the per-engine installation-status table. +Long OCR runs report progress as images *finished* of the current source (per PID for TS +DVB-sub, per track for MKV). On a terminal this rewrites a single line - ` OCR 42/345 (12%)...`; +when stdout is a pipe or a file it prints one plain line per 10% instead, so a log of a +5000-image run holds ten lines rather than one endless one. Auto-translate reports the same way +(` Translated 42/345 (12%)...`). Both are suppressed by `--quiet` and `--json`. + ```bash # Tesseract seconv movie.sup subrip --ocr-engine:tesseract --ocr-language:eng @@ -273,6 +291,11 @@ seconv movie.sup subrip --ocr-engine:llamacpp seconv movie.sup subrip --ocr-engine:llamacpp --ocr-model:GLM-OCR-Q8_0.gguf seconv movie.sup subrip --ocr-engine:llamacpp --ocr-url:http://127.0.0.1:8080 +# Override the OCR prompt (inline or from a file); {language} = --ocr-language +seconv movie.sup subrip --ocr-engine:llamacpp --ocr-language:German \ + --ocr-prompt:"Identify the number of lines, then extract the text of each line exactly as written. The language is {language}." +seconv movie.sup subrip --ocr-engine:llamacpp --ocr-prompt:my-ocr-prompt.txt + # MKV with image (PGS or VobSub) tracks — OCR runs automatically seconv movie.mkv subrip --ocr-engine:tesseract --ocr-language:eng @@ -303,6 +326,7 @@ Translated output is named with the target language code — `way.srt --translat | `--translate-engine:` | `llamacpp` (default) \| `ollama` \| `lmstudio` \| `libretranslate` \| `nllb-serve` \| `nllb-api` | | `--translate-url:` | Endpoint of an already-running translate server. For `llamacpp` this skips the local server auto-start; a bare `host:port` is completed to `/v1/chat/completions`. | | `--translate-model:` | `ollama`/`lmstudio`: model name. `llamacpp`: a `.gguf` file name from the models folder or a full path (default: the first installed translate model). | +| `--translate-prompt:` | Prompt for `llamacpp` / `ollama` / `lmstudio` — inline text or a path to a text file. See [Custom prompt](#custom-prompt) below. | **llama.cpp (default engine).** With no `--translate-url`, seconv runs a local `llama-server` for you: it looks for the binary in Subtitle Edit's data folder (`llama.cpp` next to `seconv`, then `%AppData%\Subtitle Edit\llama.cpp` / `~/Library/Application Support/Subtitle Edit/llama.cpp` / `~/.config/Subtitle Edit/llama.cpp`) and falls back to `llama-server` on `PATH`. The server is started on a free localhost port with the model's correct chat-template flags and stopped again when seconv exits. Models resolve against the data folder's `models` subfolder. @@ -329,13 +353,60 @@ seconv movie.srt subrip --translate-to:da --translate-engine:ollama --translate- seconv movie.sup subrip --ocr-engine:tesseract --ocr-language:eng --translate-to:de ``` +#### Custom prompt + +`--translate-prompt` is the command-line equivalent of the prompt field the GUI offers for the +local-LLM engines (see [Prompts: chat models and completion models](../features/auto-translate.md#prompts-chat-models-and-completion-models)). +It applies to `llamacpp`, `ollama` and `lmstudio`; the translation services (`libretranslate`, +`nllb-serve`, `nllb-api`) have no prompt, and passing it to them is an error rather than a silent +no-op. + +The same placeholders as in the GUI are substituted: `{0}` = source language, `{1}` = target +language (both as English names, e.g. `English` / `German`). A prompt that also contains `{2}` is a +*completion template*: the subtitle text is placed at `{2}` and the filled-in block is sent as-is, +which is what raw-completion translation models such as MiLMMT-46 are trained on. + +The value is either inline text or the path to a text file. Reading from a file is the practical way +to pass a multi-line completion template; in inline text `\n` (also `\r`, `\t`, `\\`) is unescaped, so +a short template still fits on one command line. + +The two are told apart by shape: an existing file is always read, and so is a value that *looks* +like a path — no spaces, or a `.txt` / `.prompt` / `.md` extension. A path-shaped value that does not +exist is an error, so a typo (`--translate-prompt:prompts/mine.tmpl`) fails immediately instead of +being handed to the model as the prompt and quietly mistranslating the whole batch. Prompt text is +recognised by containing a space, a line break, or a `{0}`/`{1}`/`{2}` placeholder — which every +real prompt does. + +```bash +# Inline instruction prompt +seconv movie.srt subrip --translate-to:de \ + --translate-prompt:"Translate from {0} to {1}. Keep the line breaks. Use informal address. Output only the translation:" + +# Completion template, inline +seconv movie.srt subrip --translate-to:da --translate-prompt:"Translate this from {0} to {1}:\n{0}: {2}\n{1}:" + +# The same, from a file (recommended for anything multi-line) +seconv movie.srt subrip --translate-to:da --translate-prompt:milmmt.prompt + +# Ollama / LM Studio take the same option +seconv movie.srt subrip --translate-to:da --translate-engine:ollama --translate-model:gemma2 \ + --translate-prompt:my-prompt.txt +``` + +Precedence for `llamacpp`, highest first: `--translate-prompt`, then a curated model's own trained +prompt (MiLMMT-46, Hy-MT2 — applied automatically when that model is selected), then +`tools.llamaCppPrompt` from a `--settings` file, then the built-in default. `--translate-prompt` +deliberately overrides the curated template too: an option given on the command line must never be +a no-op. `ollama` and `lmstudio` have no per-model template, so it is simply +`--translate-prompt` > `--settings` > built-in default. + ### Templates / replacements | Option | Description | |---|---| | `--multiple-replace:` | Multiple-replace rules applied per paragraph after operations. Accepts the legacy SE *MultipleSearchAndReplaceGroups* XML **and** the file the SE5 GUI exports from *Tools → Multiple replace → export* — either `.template` (JSON) or `.csv`. Supports case-insensitive, `CaseSensitive`, and `RegularExpression` rules; only active rules are applied. The format is chosen by extension, then by content | | `--custom-format:` | SE *CustomFormatItem* XML (use with `--format customtext`) | -| `--settings:` | JSON file overlaying `Configuration.Settings` (general / tools / removeTextForHearingImpaired) plus image-output styling (exportImages). Optional `profiles` map for named overlays | +| `--settings:` | JSON file overlaying `Configuration.Settings` (general / tools / removeTextForHearingImpaired) plus image-output styling (exportImages). Optional `profiles` map for named overlays. The `tools` section also carries the auto-translate prompts (`llamaCppPrompt`, `ollamaPrompt`, `lmStudioPrompt`), so a `profiles` entry can hold a per-target-language prompt | | `--profile:` | Selects a named overlay from the settings file's `profiles` map. Requires `--settings` | #### Multiple-replace rule files @@ -425,38 +496,71 @@ The keys and defaults below are exactly what `dump-settings` emits: "minimumMillisecondsBetweenLines": 24, "maxNumberOfLines": 2, "mergeLinesShorterThan": 33, - "subtitleMaximumCharactersPerSeconds": 25.0, - "subtitleOptimalCharactersPerSeconds": 15.0, - "subtitleMaximumWordsPerMinute": 400.0, + "subtitleMaximumCharactersPerSeconds": 25, + "subtitleOptimalCharactersPerSeconds": 15, + "subtitleMaximumWordsPerMinute": 400, "dialogStyle": "DashBothLinesWithSpace", "continuationStyle": "None" }, + "tools": { + "mergeShortLinesMaxGap": 250, + "mergeShortLinesOnlyContinuous": true, + "llamaCppPrompt": "Translate from {0} to {1}, keep punctuation as input, keep line breaks exactly the same, do not censor the translation, give only the output without comments:", + "ollamaPrompt": "Translate from {0} to {1}, keep punctuation as input, keep line breaks exactly the same, do not censor the translation, give only the output without comments or notes:", + "lmStudioPrompt": "Translate from {0} to {1}, keep punctuation as input, keep line breaks exactly the same, do not censor the translation, give only the output without comments:" + }, "removeTextForHearingImpaired": { + "removeTextBetweenBrackets": true, + "removeTextBetweenParentheses": true, + "removeTextBetweenCurlyBrackets": true, + "removeTextBetweenQuestionMarks": true, + "removeTextBetweenCustom": false, + "removeTextBetweenCustomBefore": "\u00B6", + "removeTextBetweenCustomAfter": "\u00B6", + "removeTextBetweenOnlySeparateLines": false, "removeTextBeforeColon": true, - "removeInterjections": false + "removeTextBeforeColonOnlyIfUppercase": true, + "removeTextBeforeColonOnlyOnSeparateLine": false, + "removeInterjections": false, + "removeInterjectionsOnlyOnSeparateLine": false, + "removeIfContains": false, + "removeIfAllUppercase": false, + "removeIfContainsText": "\u00B6", + "removeIfOnlyMusicSymbols": true }, "exportImages": { "fontName": "Arial", "fontSize": 50, - "fontColor": "#FFFFFF", + "fontColor": "#FFFFFFFF", "isBold": false, - "outlineColor": "black", + "outlineColor": "#FF000000", "outlineWidth": 2.5, - "shadowColor": "black", + "shadowColor": "#FF000000", "shadowWidth": 0, - "backgroundColor": "#B4000000", + "backgroundColor": "#00FFFFFF", "backgroundCornerRadius": 0, - "boxType": "one-box", + "boxType": "None", "boxPaddingLeft": 5, "boxPaddingRight": 5, "boxPaddingTop": 3, "boxPaddingBottom": 3, "lineSpacingPercent": 0, - "alignment": "bottom-center", - "contentAlignment": "center", - "bottomTopMargin": 54, - "leftRightMargin": 96 - }, + "isFullFrame": false, + "fullFrameBackgroundColor": "#00FFFFFF", + "alignment": "BottomCenter", + "contentAlignment": "Center" + } +} +``` + +The `exportImages` section styles text → image rendering (see [Image output styling](#image-output-styling) for the semantics); the equivalent CLI flags override it. Colours are emitted as `#AARRGGBB` (so `backgroundColor` / `fullFrameBackgroundColor` default to fully transparent, `#00FFFFFF`, and `boxType` to `None`); `boxType`, `alignment`, and `contentAlignment` are emitted as enum names but also accept the CLI spellings (`one-box`, `bottom-center`, …). Two optional `exportImages` keys are read but not emitted: `bottomTopMargin` and `leftRightMargin` (pixels; default 5% of the frame height / width). The `tools` section holds the merge-short-lines settings and the auto-translate prompts. The `general` section mirrors `Configuration.Settings.General`; any key left out keeps the libse default. The profile-shaping values (`minimumMillisecondsBetweenLines`, `maxNumberOfLines`, `mergeLinesShorterThan`, `subtitleMaximumCharactersPerSeconds`, `subtitleOptimalCharactersPerSeconds`, `subtitleMaximumWordsPerMinute`, `dialogStyle`, `continuationStyle`) feed Fix common errors and the split/merge operations, so set them to reproduce an SE4 profile. `dialogStyle` and `continuationStyle` take the enum names (case-insensitive): `dialogStyle` ∈ `DashBothLinesWithSpace`, `DashBothLinesWithoutSpace`, `DashSecondLineWithSpace`, `DashSecondLineWithoutSpace`; `continuationStyle` ∈ `None`, `NoneTrailingDots`, `NoneTrailingEllipsis`, `OnlyTrailingDots`, `LeadingTrailingDots`, `LeadingTrailingEllipsis`, `LeadingTrailingDash`, … (see the Fix common errors continuation styles). + +Keys that seconv does not recognize are ignored, so a settings file written for a newer version still applies everything this one understands — but they are listed in a warning, so a typo (or a key your seconv is too old to know) does not silently give you default output. + +A `profiles` map (not emitted by `dump-settings`) adds named overlays with the same sections, selected with `--profile`: + +```json +{ "profiles": { "broadcast": { "general": { "subtitleMaximumDisplayMilliseconds": 6000 }, @@ -469,10 +573,6 @@ The keys and defaults below are exactly what `dump-settings` emits: } ``` -The `exportImages` section styles text → image rendering (see [Image output styling](#image-output-styling) for the semantics); the equivalent CLI flags override it. The `general` section mirrors `Configuration.Settings.General`; any key left out keeps the libse default. The profile-shaping values (`minimumMillisecondsBetweenLines`, `maxNumberOfLines`, `mergeLinesShorterThan`, `subtitleMaximumCharactersPerSeconds`, `subtitleOptimalCharactersPerSeconds`, `subtitleMaximumWordsPerMinute`, `dialogStyle`, `continuationStyle`) feed Fix common errors and the split/merge operations, so set them to reproduce an SE4 profile. `dialogStyle` and `continuationStyle` take the enum names (case-insensitive): `dialogStyle` ∈ `DashBothLinesWithSpace`, `DashBothLinesWithoutSpace`, `DashSecondLineWithSpace`, `DashSecondLineWithoutSpace`; `continuationStyle` ∈ `None`, `NoneTrailingDots`, `NoneTrailingEllipsis`, `OnlyTrailingDots`, `LeadingTrailingDots`, `LeadingTrailingEllipsis`, `LeadingTrailingDash`, … (see the Fix common errors continuation styles). - -Keys that seconv does not recognize are ignored, so a settings file written for a newer version still applies everything this one understands — but they are listed in a warning, so a typo (or a key your seconv is too old to know) does not silently give you default output. - ```bash seconv *.srt subrip --settings:my.json --profile:broadcast --remove-text-for-hi ``` @@ -481,7 +581,7 @@ seconv *.srt subrip --settings:my.json --profile:broadcast --remove-text-for-hi | Option | Description | |---|---| -| `--quiet` / `-q` | Suppress per-file progress and the parameters table; only print the final summary | +| `--quiet` / `-q` | Suppress per-file progress, the parameters table, and the OCR/translate progress lines; only print the final summary | | `--verbose` / `-v` | Print extra diagnostic information, including full exception details (stack traces) on errors | | `--json` | Emit per-file results as JSON to stdout (suppresses Spectre output). Also accepted by every subcommand. Failures use the same envelope, so stdout is always one JSON document | @@ -500,7 +600,7 @@ Operations run after the structural transforms (offset, fps, renumber, adjust-du | `--delete-first:` | Delete first N entries | | `--delete-last:` | Delete last N entries | | `--delete-contains:` | Delete entries containing the given word | -| `--fix-common-errors` | Fix common subtitle errors (all 39 rules) | +| `--fix-common-errors` | Fix common subtitle errors (all 40 rules) | | `--fix-common-errors-rules:` | Run a subset of FCE rules (CSV; supports `all,-RuleId`) | | `--fce-language:` | Force the language for FCE language-gated rules (code or English name, e.g. `es` / `Spanish`); default: auto-detect from content | | `--fix-rtl-via-unicode-chars` | Fix RTL via Unicode characters | @@ -532,7 +632,7 @@ In most batch pipelines `--apply-min-gap` is the better choice; reach for the FC ### FixCommonErrors rule selection -`--fix-common-errors` (no value) runs all 39 rules. Pass `--fix-common-errors-rules:` to pick a subset — supplying that option implies `--fix-common-errors`. +`--fix-common-errors` (no value) runs all 40 rules (39 fixes plus the `FixCommonOcrErrors` pass). Pass `--fix-common-errors-rules:` to pick a subset — supplying that option implies `--fix-common-errors`. ```bash seconv movie.srt subrip --fix-common-errors # all rules @@ -645,18 +745,19 @@ This mirrors the desktop app, where batch convert's *Remove formatting* function | `unipac`, `pacunicode` | PAC Unicode | | `ebu`, `ebustl`, `stl` | EBU STL — binary | | `cavena`, `cavena890` | Cavena 890 — binary | -| `cheetahcaption` | CheetahCaption — binary | -| `capmakerplus` | CapMakerPlus — binary | +| `cheetah`, `cheetahcaption` | CheetahCaption — binary | +| `capmaker`, `capmakerplus` | CapMakerPlus — binary | | `ayato` | Ayato — binary | -| `bluraysup`, `sup` | Blu-Ray sup — image | +| `bluraysup`, `blurayup`, `sup` | Blu-Ray sup — image | | `vobsub` | VobSub — image | | `bdnxml`, `bdn-xml` | BDN-XML — image (folder of PNGs + index.xml) | | `bdnxml8bit`, `bdn-xml8-bit` | BDN-XML with 8-bit palette-indexed PNGs — image | | `dost`, `dostimage` | DOST/image | | `fcpimage`, `fcp` | FCP/image | -| `dcinemainterop` | D-Cinema interop/png | -| `dcinemasmpte2014` | D-Cinema SMPTE 2014/png | -| `imageswithtimecode` | Images with time codes in file name | +| `dcinemainterop`, `dcinema-interop` | D-Cinema interop/png | +| `dcinemasmpte2014`, `dcinema-smpte` | D-Cinema SMPTE 2014/png | +| `imageswithtimecode`, `imagesintc` | Images with time codes in file name | +| `webvttthumbnail`, `webvtt-thumbnail`, `vttthumb` | WebVTT Thumbnail — image (sprite sheet + `.vtt`) | | `plaintext`, `text`, `txt` | Plain text (HTML stripped) | | `customtext`, `customtextformat` | Custom-templated text (requires `--custom-format`) | diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 9452061b34d..e8345faaf89 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -80,6 +80,8 @@ Inside the [source view](../features/source-view.md) window (F2), which edits th | Shortcut | Action | |----------|--------| +| Ctrl+Z | Undo | +| Ctrl+Shift+Z / Ctrl+Y | Redo | | Ctrl+F | Find in the source | | Ctrl+H | Replace in the source (Cmd+Alt+F also works on macOS) | | F3 / Shift+F3 | Find next / previous | diff --git a/docs/reference/mouse-controls.md b/docs/reference/mouse-controls.md index bc2a2c131f6..5aac4e09155 100644 --- a/docs/reference/mouse-controls.md +++ b/docs/reference/mouse-controls.md @@ -9,7 +9,9 @@ See also: [Audio Visualizer](../features/audio-visualizer.md) | [Video Player](. | Action | Effect | |--------|--------| | Click on video | Play/Pause toggle | -| Double-click on video | Toggle fullscreen | +| Scroll wheel over video | Seek ±0.5 s per notch (direction follows the waveform's "Invert mouse-wheel" setting) | + +Full screen is toggled with Alt+Enter — see [Keyboard Shortcuts](keyboard-shortcuts.md). ## Audio Visualizer / Waveform @@ -18,19 +20,23 @@ See also: [Audio Visualizer](../features/audio-visualizer.md) | [Video Player](. | Left-click | Set current position | | Left-click + drag on subtitle edge | Adjust start/end time of subtitle | | Left-click + drag in empty area | Create a new selection | -| Right-click | Open context menu | -| Scroll wheel | Scroll waveform horizontally | +| Shift + Click | Set the selected subtitle's start time to the clicked position | +| Ctrl (or Cmd) + Click | Set the selected subtitle's end time to the clicked position | +| Ctrl + Shift + Click | Set the selected subtitle's start time and offset all following subtitles by the same amount | +| Alt + Click | Move the selected subtitle's start to the clicked position, keeping its duration | +| Right-click | Select the subtitle under the cursor ("Select subtitle on right click", on by default), move the video position there, and open the context menu. On macOS, Ctrl + left-click opens the same menu | +| Scroll wheel | Scroll waveform horizontally — or seek the video by one step per notch when "Mouse-wheel sets video position" is on; "Invert mouse-wheel" flips the direction (Options → Settings → Waveform/spectrogram) | | Alt + Scroll wheel | Horizontal zoom in/out | | Shift + Scroll wheel | Vertical zoom in/out | | Ctrl (or Cmd) + Scroll wheel | Scroll waveform and seek video to cursor | -| Double-click on subtitle | Select the subtitle in the grid | +| Double-click | Nothing by default — set "Waveform double-click action" in Options → Settings → Waveform/spectrogram to select the subtitle, center on it, pause, or play | ## Subtitle Grid | Action | Effect | |--------|--------| | Click on row | Select subtitle line | -| Double-click on row | Edit subtitle line in text editor | +| Double-click on row | Go to the subtitle in the video and pause (default; "Subtitle grid double-click action" in Options → Settings → General offers other actions, e.g. play, or also focus the text box) | | Ctrl + Click | Toggle selection of individual rows | | Shift + Click | Select range of rows | | Right-click | Open context menu | diff --git a/docs/reference/supported-formats.md b/docs/reference/supported-formats.md index 36756692360..e852529d90d 100644 --- a/docs/reference/supported-formats.md +++ b/docs/reference/supported-formats.md @@ -17,11 +17,12 @@ Subtitle Edit supports a wide range of subtitle formats for reading and writing. | EBU STL | .stl | | | Spruce STL | .stl | | | Scenarist Closed Captions (SCC) | .scc | | -| DVD Studio Pro | .txt | | +| DVD Studio Pro | .stl | | | Cavena 890 | .890 | | | PAC | .pac | | | Cheetah | .cap | | -| Avid DS | .txt | | +| Avid DVD, Avid Caption, Avid Caption Drop Frame, Avid Loc Markers | .txt | | +| Avid STL | .stl | | | JSON (various) | .json | | | LRC (Lyrics) | .lrc | | | and many more... | | | @@ -47,6 +48,16 @@ Formats used to move captions and markers between Subtitle Edit and video editor | Adobe Premiere PrProj Xml | .xml | | Adobe Encore (tabs) | .txt | +## Spreadsheets + +Import only — a spreadsheet with a header row naming start/end/text columns is read directly, and any layout can be mapped by hand in *File → Import → CSV/XLSX/ODS with custom columns*. See [Import Spreadsheets](../features/import-csv-xlsx.md). + +| Format | Extension(s) | +|--------|--------------| +| Excel workbook (Office Open XML) | .xlsx | +| OpenDocument spreadsheet | .ods | +| Delimited text (comma, semicolon, tab, pipe) | .csv, .tsv, .txt | + ## Image-Based Formats | Format | Extension(s) | @@ -55,16 +66,25 @@ Formats used to move captions and markers between Subtitle Edit and video editor | VobSub (DVD) | .sub/.idx | | BDN XML | .xml | | Timed Text Base64 Image (SMPTE-TT bitmap) | .xml | +| Timed Text Image | .xml | +| BDN XML 8-bit (palette-indexed PNGs, export) | .xml | +| DOST | .dost | +| Final Cut Pro Image (FCP/image) | .xml | +| SPU Image | .xml | +| D-Cinema interop/png, D-Cinema SMPTE 2014/png (export) | .xml | +| Images with time codes in file name (export) | image files | +| WebVTT Thumbnail (sprite sheet) | .vtt | ## Container Formats (with embedded subtitles) | Format | Extension(s) | |--------|--------------| -| Matroska (MKV/MKS) | .mkv, .mks | -| MP4 / MOV (text tracks, including fragmented MP4/DASH — wvtt, stpp, tx3g) | .mp4, .m4v, .m4s, .3gp | +| Matroska (MKV/MKS/WebM) | .mkv, .mks, .webm | +| MP4 / MOV (text tracks, including fragmented MP4/DASH — wvtt, stpp, tx3g) | .mp4, .m4v, .m4s, .3gp, .mov | | Transport Stream (teletext, DVB-sub) | .ts, .m2ts, .mts | -| AVI (XSUB) | .avi | +| AVI (XSUB) | .avi, .divx | | MacCaption | .mcc | +| MXF (timed-text essences) | .mxf | ## Video Formats (for loading video) diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index b016846a4e1..0a3a2d47bae 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -37,6 +37,7 @@ All listed files exist on disk. Filenames are stable and may be linked from `../ - `multiple-replace.png` — Multiple replace window - `modify-selection.png` — Modify selection window - `show-history.png` — Show history window +- `ai-assistant.png` — AI assistant window ## Tools @@ -87,6 +88,8 @@ All listed files exist on disk. Filenames are stable and may be linked from `../ - `blank-video.png` — Blank video window - `cut-video.png` — Cut video window - `re-encode-video.png` — Re-encode video window +- `video-ocr.png` — Video OCR window +- `chapters.png` — Chapters window ## Translation @@ -157,6 +160,10 @@ Used by `../reference/assa-override-tags.md` to illustrate individual override t ## Counts -- Top-level screenshots: **92** +- Top-level screenshots: **95** - ASSA override-tag examples (`assa/`): **35** -- Total: **127** +- Total: **130** + +## How the dialog screenshots are made + +Most dialog screenshots are headless renders of the real windows (dark theme, English, sample subtitle loaded), captured with Avalonia's headless platform and real Skia drawing — the same setup the UI tests use (`UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }).UseSkia()`, see `tests/UI/Logic/Accessibility/EditBoxAccessibilityNameTests.cs`). A temporary `[AvaloniaFact]` resolves the view model from `AddSubtitleEditServices()`, calls its `Initialize(...)` with sample lines, constructs the window, runs `Dispatcher.UIThread.RunJobs()` and saves `window.CaptureRenderedFrame()`. Such renders have no OS title bar, and video/waveform areas stay black because libmpv does not run headlessly, so windows that are mostly video (main window, video player) are still real screenshots. PNGs are quantized to a 256-color palette before committing. diff --git a/docs/screenshots/ai-assistant.png b/docs/screenshots/ai-assistant.png new file mode 100644 index 00000000000..b9acd45e219 Binary files /dev/null and b/docs/screenshots/ai-assistant.png differ diff --git a/docs/screenshots/ai-review.png b/docs/screenshots/ai-review.png index f5d126baa74..a9f19a58f8d 100644 Binary files a/docs/screenshots/ai-review.png and b/docs/screenshots/ai-review.png differ diff --git a/docs/screenshots/apply-duration-limits.png b/docs/screenshots/apply-duration-limits.png index dc448aca621..567011795b3 100644 Binary files a/docs/screenshots/apply-duration-limits.png and b/docs/screenshots/apply-duration-limits.png differ diff --git a/docs/screenshots/assa-attachments.png b/docs/screenshots/assa-attachments.png index fbc65d9cce8..d0411b15a61 100644 Binary files a/docs/screenshots/assa-attachments.png and b/docs/screenshots/assa-attachments.png differ diff --git a/docs/screenshots/assa-set-position.png b/docs/screenshots/assa-set-position.png index 43db8d2d039..98e40406a37 100644 Binary files a/docs/screenshots/assa-set-position.png and b/docs/screenshots/assa-set-position.png differ diff --git a/docs/screenshots/assa-styles.png b/docs/screenshots/assa-styles.png index a7fb4b9d2ea..e26295eaa93 100644 Binary files a/docs/screenshots/assa-styles.png and b/docs/screenshots/assa-styles.png differ diff --git a/docs/screenshots/auto-translate.png b/docs/screenshots/auto-translate.png index f6fe3b32841..d1aff685676 100644 Binary files a/docs/screenshots/auto-translate.png and b/docs/screenshots/auto-translate.png differ diff --git a/docs/screenshots/batch-convert.png b/docs/screenshots/batch-convert.png index 48a7c466578..d5fab211087 100644 Binary files a/docs/screenshots/batch-convert.png and b/docs/screenshots/batch-convert.png differ diff --git a/docs/screenshots/beautify-time-codes.png b/docs/screenshots/beautify-time-codes.png index 1fd6717eaf0..19e9fa50de0 100644 Binary files a/docs/screenshots/beautify-time-codes.png and b/docs/screenshots/beautify-time-codes.png differ diff --git a/docs/screenshots/burn-in.png b/docs/screenshots/burn-in.png index 1fc9171ffa9..0ebe4fc9f7d 100644 Binary files a/docs/screenshots/burn-in.png and b/docs/screenshots/burn-in.png differ diff --git a/docs/screenshots/change-speed.png b/docs/screenshots/change-speed.png index 46ed6845ae8..bf4a9fb47c4 100644 Binary files a/docs/screenshots/change-speed.png and b/docs/screenshots/change-speed.png differ diff --git a/docs/screenshots/chapters.png b/docs/screenshots/chapters.png new file mode 100644 index 00000000000..86a162bd279 Binary files /dev/null and b/docs/screenshots/chapters.png differ diff --git a/docs/screenshots/compare.png b/docs/screenshots/compare.png index bb4409cb799..2e76701d64b 100644 Binary files a/docs/screenshots/compare.png and b/docs/screenshots/compare.png differ diff --git a/docs/screenshots/convert-actors.png b/docs/screenshots/convert-actors.png index 519f776ebe2..665bb3a580c 100644 Binary files a/docs/screenshots/convert-actors.png and b/docs/screenshots/convert-actors.png differ diff --git a/docs/screenshots/export-image-based.png b/docs/screenshots/export-image-based.png index f8cbd94301b..fe3c1d45126 100644 Binary files a/docs/screenshots/export-image-based.png and b/docs/screenshots/export-image-based.png differ diff --git a/docs/screenshots/find.png b/docs/screenshots/find.png index f0c229fc8e6..6ba737dfee6 100644 Binary files a/docs/screenshots/find.png and b/docs/screenshots/find.png differ diff --git a/docs/screenshots/fix-names.png b/docs/screenshots/fix-names.png index a592999727b..46044600b7c 100644 Binary files a/docs/screenshots/fix-names.png and b/docs/screenshots/fix-names.png differ diff --git a/docs/screenshots/import-plain-text.png b/docs/screenshots/import-plain-text.png index 150b6cfff41..ac4ac847728 100644 Binary files a/docs/screenshots/import-plain-text.png and b/docs/screenshots/import-plain-text.png differ diff --git a/docs/screenshots/modify-selection.png b/docs/screenshots/modify-selection.png index 84822cc4e3d..9720b558b29 100644 Binary files a/docs/screenshots/modify-selection.png and b/docs/screenshots/modify-selection.png differ diff --git a/docs/screenshots/netflix-errors.png b/docs/screenshots/netflix-errors.png index e742b48a92a..a2e6b76953e 100644 Binary files a/docs/screenshots/netflix-errors.png and b/docs/screenshots/netflix-errors.png differ diff --git a/docs/screenshots/point-sync-via-other.png b/docs/screenshots/point-sync-via-other.png index 5e69ee9ec1c..1676429e289 100644 Binary files a/docs/screenshots/point-sync-via-other.png and b/docs/screenshots/point-sync-via-other.png differ diff --git a/docs/screenshots/point-sync.png b/docs/screenshots/point-sync.png index 4b0f48496cf..e785c62fdc3 100644 Binary files a/docs/screenshots/point-sync.png and b/docs/screenshots/point-sync.png differ diff --git a/docs/screenshots/remove-text-hi.png b/docs/screenshots/remove-text-hi.png index 2bcb11230b4..9b88cafb781 100644 Binary files a/docs/screenshots/remove-text-hi.png and b/docs/screenshots/remove-text-hi.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png index 174484b27b8..ab5f842e90f 100644 Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/speech-to-text.png b/docs/screenshots/speech-to-text.png index d804a7185eb..692fe42ed11 100644 Binary files a/docs/screenshots/speech-to-text.png and b/docs/screenshots/speech-to-text.png differ diff --git a/docs/screenshots/spell-check.png b/docs/screenshots/spell-check.png index 411339325bc..88102532a84 100644 Binary files a/docs/screenshots/spell-check.png and b/docs/screenshots/spell-check.png differ diff --git a/docs/screenshots/split-break-long-lines.png b/docs/screenshots/split-break-long-lines.png index a2bb918b0ee..31dbe667710 100644 Binary files a/docs/screenshots/split-break-long-lines.png and b/docs/screenshots/split-break-long-lines.png differ diff --git a/docs/screenshots/statistics.png b/docs/screenshots/statistics.png index 9040f8b1ce1..c069e9db424 100644 Binary files a/docs/screenshots/statistics.png and b/docs/screenshots/statistics.png differ diff --git a/docs/screenshots/text-to-speech.png b/docs/screenshots/text-to-speech.png index 235c4ebae66..fec652a383c 100644 Binary files a/docs/screenshots/text-to-speech.png and b/docs/screenshots/text-to-speech.png differ diff --git a/docs/screenshots/transparent-subtitles.png b/docs/screenshots/transparent-subtitles.png index fb87bc76bda..e1d85b6c575 100644 Binary files a/docs/screenshots/transparent-subtitles.png and b/docs/screenshots/transparent-subtitles.png differ diff --git a/docs/screenshots/video-ocr.png b/docs/screenshots/video-ocr.png new file mode 100644 index 00000000000..bd9dd6d2f5d Binary files /dev/null and b/docs/screenshots/video-ocr.png differ diff --git a/docs/screenshots/visual-sync.png b/docs/screenshots/visual-sync.png index c4c06c8ea8c..aef6d1395d9 100644 Binary files a/docs/screenshots/visual-sync.png and b/docs/screenshots/visual-sync.png differ diff --git a/docs/screenshots/word-lists.png b/docs/screenshots/word-lists.png index 02feda4e648..15e844367b3 100644 Binary files a/docs/screenshots/word-lists.png and b/docs/screenshots/word-lists.png differ diff --git a/docs/third-party-components.md b/docs/third-party-components.md index ce0d8d5b564..4bf6c739421 100644 --- a/docs/third-party-components.md +++ b/docs/third-party-components.md @@ -36,8 +36,7 @@ Subtitle Edit stores these components in its **Data Folder**. | **Purfview Faster-Whisper XXL** | `faster-whisper-xxl.exe`, `_models/` folder | `[Data Folder]/SpeechToText/Purfview-Faster-Whisper-XXL` | | **Crisp ASR** | `crispasr.exe`, `models/` folder | `[Data Folder]/CrispASR` | | **Qwen3 ASR CPP** | `qwen3-asr-cli.exe`, `models/` folder | `[Data Folder]/Qwen3ASR` | -| **Parakeet.cpp** | `parakeet.exe`, model folders | `[Data Folder]/parakeet.cpp` | -| **PaddleOCR** | `paddleocr.exe`, `models/` folder | `[Data Folder]/OCR/PaddleOCR3-4` | +| **PaddleOCR** | `paddleocr.exe`, `models/` folder | `[Data Folder]/OCR/PaddleOCR3-7` | | **Qwen3 TTS (CrispASR)** | shares `crispasr.exe` + `models/` from `[Data Folder]/CrispASR`; reference voices in `voices/` | `[Data Folder]/TextToSpeech/Qwen3TtsCrispAsr` (voices only) | | **Chatterbox TTS (CrispASR)** | shares `crispasr.exe` + `models/` from `[Data Folder]/CrispASR`; reference voices in `voices/` | `[Data Folder]/TextToSpeech/Chatterbox` (voices only) | | **OmniVoice TTS** | `omnivoice-tts.exe`, `omnivoice-codec.exe`, `models/`, `voices/` | `[Data Folder]/TextToSpeech/OmniVoice` | @@ -57,7 +56,9 @@ Used for reading media info, extracting audio, and generating waveforms. * **Alternative: Custom Path** * You can point to an existing FFmpeg installation in `Settings.json` (located in the Data Folder): ```json - "FfmpegPath": "C:\\path\\to\\your\\ffmpeg.exe" + "General": { + "FfmpegPath": "C:\\path\\to\\your\\ffmpeg.exe" + } ``` * Use double backslashes (`\\`) for Windows paths in JSON. @@ -110,30 +111,33 @@ Used for GPU-accelerated AI-based speech recognition. ### SE5 Speech-to-Text Engines Subtitle Edit 5 can download additional ASR engines directly from the **Speech to text** window. -* **Crisp ASR:** Stored in `[Data Folder]/CrispASR`. Models go into its `models` folder. Crisp ASR backends include Parakeet, Canary, Cohere, Fire Red, GLM, Granite, Qwen3, Mega, Omni, and Kyutai. +* **Crisp ASR:** Stored in `[Data Folder]/CrispASR`. Models go into its `models` folder. Crisp ASR backends include Parakeet, Canary, Cohere, Fire Red, Fun-ASR Nano, GigaAM, GLM, Granite, Qwen3, Mega, MOSS Diarize, Omni, Kyutai, SenseVoice, ARK, and Voxtral. * The speech-to-text dialog also offers a **Forced aligner** combo for word-level timestamps. Built-in (where the backend supports it), Canary CTC, Qwen3, and 12 language-specific wav2vec2 aligners (the WhisperX aligner zoo): `en`, `de`, `fr`, `es`, `it`, `ja`, `zh`, `nl`, `pt`, `ar`, `uk`, `cs`. The default is the built-in aligner when the backend supports it, otherwise Qwen3 or Canary CTC depending on the backend; pick a wav2vec2 entry manually to use one of those. -* **Qwen3 ASR CPP:** Stored in `[Data Folder]/Qwen3ASR`. Models go into `[Data Folder]/Qwen3ASR/models`. -* **Parakeet.cpp:** Stored in `[Data Folder]/parakeet.cpp`. Each model has its own folder because the model weights and `vocab.txt` must stay together. +* **Qwen3 ASR CPP:** Stored in `[Data Folder]/Qwen3ASR`. Models go into `[Data Folder]/Qwen3ASR/models`. (Parakeet is no longer a standalone engine; it is a Crisp ASR backend.) Use [Speech to Text](features/speech-to-text.md) for the current engine list and workflow. ### PaddleOCR Used for OCR of image-based subtitles. -* **Destination:** `[Data Folder]/OCR/PaddleOCR3-4` -* **Models:** `[Data Folder]/OCR/PaddleOCR3-4/models` +* **Destination:** `[Data Folder]/OCR/PaddleOCR3-7` +* **Models:** `[Data Folder]/OCR/PaddleOCR3-7/models` * **Builds:** Subtitle Edit can download CPU, CUDA 11.8, or CUDA 12.9 builds, on both Windows and Linux (Linux x64 only; the Linux builds need glibc 2.35 or newer). -* **Version:** The folder name follows the PaddleOCR release the standalone engine is built from, so upgrading installs into a new folder instead of mixing files. The old `[Data Folder]/OCR/PaddleOCR3-1` folder is deleted automatically after the new one is installed. +* **Recognition models:** PP-OCRv6 (PaddleOCR 3.7) recognizes Chinese, English, Japanese and the Latin languages with one unified model; Arabic, Cyrillic, E-Slavic, Devanagari, Korean, Greek, Tamil, Telugu, Thai and Pali still use their PP-OCRv5 models, and Georgian its PP-OCRv3 one. All of them ship in the same bundle. +* **Version:** The folder name follows the PaddleOCR release the standalone engine is built from, so upgrading installs into a new folder instead of mixing files. The old `[Data Folder]/OCR/PaddleOCR3-1` and `[Data Folder]/OCR/PaddleOCR3-4` folders are deleted automatically after the new one is installed. +* **Paddle OCR Python:** The pip-installed engine uses the same downloaded models, so it needs `paddleocr` 3.7 or newer - older versions do not know the PP-OCRv6 model names. ### Local Text-to-Speech Engines Subtitle Edit 5 can download local TTS servers and models from the **Text to speech** window. -* **Qwen3 TTS (CrispASR):** Reference voices are stored in `[Data Folder]/TextToSpeech/Qwen3TtsCrispAsr/voices`. The talker GGUFs (VoiceDesign 1.7B or CustomVoice 1.7B) and the 12 Hz codec are downloaded into the shared `[Data Folder]/CrispASR/models` cache alongside the Crisp ASR speech-to-text models, not under `TextToSpeech/Qwen3TtsCrispAsr/models` — installing Crisp ASR first is therefore recommended. Older installs that still have model files under the legacy `TextToSpeech/Qwen3TtsCrispAsr/models` folder are migrated automatically the first time the engine is used. +* **Qwen3 TTS (CrispASR):** Reference voices are stored in `[Data Folder]/TextToSpeech/Qwen3TtsCrispAsr/voices`. The talker GGUFs (VoiceDesign 1.7B, CustomVoice 1.7B or Voice clone Base 1.7B) and the 12 Hz codec are downloaded into the shared `[Data Folder]/CrispASR/models` cache alongside the Crisp ASR speech-to-text models, not under `TextToSpeech/Qwen3TtsCrispAsr/models` — installing Crisp ASR first is therefore recommended. Older installs that still have model files under the legacy `TextToSpeech/Qwen3TtsCrispAsr/models` folder are migrated automatically the first time the engine is used. * **Chatterbox TTS (CrispASR):** Reference voices are stored in `[Data Folder]/TextToSpeech/Chatterbox/voices`. The Base / Turbo model GGUFs (T3 + S3Gen) are downloaded into the shared `[Data Folder]/CrispASR/models` cache alongside the Crisp ASR speech-to-text models, not under `TextToSpeech/Chatterbox/models` — installing Crisp ASR first is therefore recommended. Older installs that still have model files under the legacy `TextToSpeech/Chatterbox/models` folder are migrated automatically the first time the engine is used. * **OmniVoice TTS:** Stored in `[Data Folder]/TextToSpeech/OmniVoice`. Brings its own `omnivoice-tts` and `omnivoice-codec` binaries. Supports 646 languages and voice cloning on CPU. `models/` and `voices/` subfolders. * **Kokoro TTS:** Stored in `[Data Folder]/TextToSpeech/KokoroTtsCpp`. Models go into the `models` folder. -Use [Text to Speech](features/text-to-speech.md) for engine-specific options. +These are examples, not the full set — many more local engines are downloadable from the Text to speech window (IndexTTS, CosyVoice3, dots.tts, VoxCPM2, MOSS-TTS, Zonos, VibeVoice, Confucius4-TTS, Pocket TTS, Higgs Audio, Fish Audio, and more), following the same layout: CrispASR-based engines share the `[Data Folder]/CrispASR` cache, and the rest live under `[Data Folder]/TextToSpeech/`. + +Use [Text to Speech](features/text-to-speech.md) for the full engine list and engine-specific options. --- @@ -147,7 +151,9 @@ Used for reading media info, extracting audio, and generating waveforms. * **Alternative:** Place the `ffmpeg` binary in `[Data Folder]/ffmpeg`. * **Custom Path:** You can specify a custom path in `Settings.json` (located in the Data Folder): ```json - "FfmpegPath": "/path/to/your/ffmpeg" + "General": { + "FfmpegPath": "/path/to/your/ffmpeg" + } ``` ### MPV Media Player (libmpv) @@ -190,7 +196,7 @@ Used for GPU-accelerated AI-based speech recognition. ### SE5 Speech-to-Text, OCR, and TTS Engines -The same data-folder layout is used on Linux. Prefer the in-app downloaders for Crisp ASR, Qwen3 ASR, Parakeet.cpp, PaddleOCR, Qwen3 TTS (CrispASR), Chatterbox TTS (CrispASR), OmniVoice TTS, and Kokoro TTS because the required files differ by build and model. +The same data-folder layout is used on Linux. Prefer the in-app downloaders for Crisp ASR, Qwen3 ASR, PaddleOCR, and the local TTS engines because the required files differ by build and model. --- @@ -204,7 +210,9 @@ Used for reading media info, extracting audio, and generating waveforms. * **Alternative:** Place the `ffmpeg` binary in `[Data Folder]/ffmpeg`. * **Custom Path:** You can specify a custom path in `Settings.json` (located in the Data Folder): ```json - "FfmpegPath": "/path/to/your/ffmpeg" + "General": { + "FfmpegPath": "/path/to/your/ffmpeg" + } ``` ### MPV Media Player (libmpv) @@ -237,15 +245,6 @@ Used for AI-based speech recognition. * **Files:** Download or build the binary and ensure it is named `whisper-cli`. * **Models:** Models (`.bin` files) go into a `Models` subfolder: `[Data Folder]/SpeechToText/Cpp/Models`. -### MLX Whisper (Speech-to-Text, Apple Silicon) -Runs Apple's `mlx-whisper` Python package on the GPU / Neural Engine. **Subtitle Edit does not download this engine** — you install the Python package yourself, and there is no file in the Data Folder. - -* **Requirements:** An Apple Silicon Mac (M1 or newer) and Python 3. -* **Install:** `pip3 install mlx-whisper` — or, for an isolated install, `pipx install mlx-whisper`. -* **Models:** MLX-format Whisper weights (`tiny` … `large-v3-turbo`) are downloaded from Hugging Face (the `mlx-community` org) into the Hugging Face cache on first use. -* **How detection works:** Subtitle Edit does not look for a binary; it looks for a Python interpreter that can `import mlx_whisper`. It probes Homebrew (`/opt/homebrew/bin`, `/usr/local/bin`), python.org framework builds, pyenv, and the system Python. Because **pipx, virtual environments, and conda install the package into an isolated environment** that those shared interpreters cannot import, Subtitle Edit also reads the shebang of the installed `mlx_whisper` command — found on your `PATH` or at `~/.local/bin/mlx_whisper` (where pipx places it) — to locate the exact matching interpreter. -* **If it reports "not found" after installing:** confirm the command resolves with `which mlx_whisper`. If it does not, add its directory to your `PATH`, or symlink the command into `~/.local/bin`. (Symlinking or copying only the *model files* does nothing — detection is by the Python package, not a binary.) - ### SE5 Speech-to-Text, OCR, and TTS Engines Some newer local engines are platform-specific or model-specific. Use the in-app downloaders where available, and check [Speech to Text](features/speech-to-text.md), [Text to Speech](features/text-to-speech.md), and [OCR](features/ocr.md) for current engine notes. diff --git a/docs/translating.md b/docs/translating.md index d82ecda0e1d..9d9a47cdcbc 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -58,7 +58,7 @@ Near the top of the JSON file, update these fields: ```json { "title": "Subtitle Edit", - "version": null, + "version": "v5.2.0-beta32", "translatedBy": "Your Name (or email / homepage)", "cultureName": "de-DE", ... @@ -76,7 +76,7 @@ The `cultureName` value **must** be a valid .NET culture identifier. ## Step 4 — Save and Install the File -Save your translated file as **`{CultureName}.json`** (e.g., `de-DE.json`) and place it in the **`Languages`** subfolder inside Subtitle Edit's data folder: +Save your translated file as **`{English language name}.json`** (e.g., `German.json` — the file name is what the language list shows, and the shipped translations are named the same way) and place it in the **`Languages`** subfolder inside Subtitle Edit's data folder: | Platform | Languages folder path | |----------|-----------------------| diff --git a/installer/WindowsInno/Subtitle_Edit_Localization.iss b/installer/WindowsInno/Subtitle_Edit_Localization.iss index 55be25bca4d..74d5da390a0 100644 --- a/installer/WindowsInno/Subtitle_Edit_Localization.iss +++ b/installer/WindowsInno/Subtitle_Edit_Localization.iss @@ -1,4 +1,4 @@ -;* Subtitle Edit - installer localization script +;* Subtitle Edit - installer localization script ; English [CustomMessages] @@ -242,7 +242,7 @@ hu.types_default=Default installation ; Italian [CustomMessages] it.sm_com_Changelog=Elenco novità di Subtitle Edit -it.run_ViewChangelog=Visualizza le novità +it.run_ViewChangelog=Visualizza elenco novità di Subtitle Edit it.msg_DeleteSettings=Vuoi eliminare le impostazioni personali di Subtitle Edit? it.tsk_AllUsers=Per tutti gli utenti it.tsk_CurrentUser=Solo per l'utente attuale @@ -254,6 +254,9 @@ it.types_custom=Installazione personalizzata it.types_default=Installazione predefinita it.NameAndVersion=%1 %2 it.LaunchProgram=Esegui %1 +it.AdditionalIcons=Collegamenti: +it.CreateDesktopIcon=Crea collegamento programma sul &desktop +it.CreateQuickLaunchIcon=Crea collegamento programma nella &barra 'Avvio veloce' ; Japanese [CustomMessages] diff --git a/installer/macBundle/SubtitleEdit.app/Contents/Info.plist b/installer/macBundle/SubtitleEdit.app/Contents/Info.plist index 120286a6157..5152ef06876 100644 --- a/installer/macBundle/SubtitleEdit.app/Contents/Info.plist +++ b/installer/macBundle/SubtitleEdit.app/Contents/Info.plist @@ -20,8 +20,19 @@ SBED CFBundleIconFile SE.icns + LSMinimumSystemVersion - 10.15 + 12.0 NSHighResolutionCapable NSSupportsAutomaticGraphicsSwitching @@ -41,7 +52,16 @@ vtt sbv stl - xml + ttml + dfxp + itt + smi + scc + mcc + cap + pac + lrc + idx CFBundleTypeName Subtitle Files @@ -50,6 +70,22 @@ LSHandlerRank Owner + + + CFBundleTypeExtensions + + xml + + CFBundleTypeName + XML Subtitle Files + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions diff --git a/src/libse/BluRaySup/BluRaySupFadeStep.cs b/src/libse/BluRaySup/BluRaySupFadeStep.cs new file mode 100644 index 00000000000..aa4eb60a0d5 --- /dev/null +++ b/src/libse/BluRaySup/BluRaySupFadeStep.cs @@ -0,0 +1,38 @@ +using System; + +namespace Nikse.SubtitleEdit.Core.BluRaySup +{ + /// + /// One alpha level of a Blu-ray fade. The object is sent once, at the start of the epoch, and + /// each step after that is written as a "palette update display set" - a PCS with + /// palette_update_flag set plus a PDS holding the same palette with every entry's alpha + /// scaled by . That is how retail discs fade: re-sending the whole + /// object per step instead would cost kilobytes each and run into the decoder's pixel + /// transfer budget, while a palette is ~1.3 KB and needs no decoding at all. + /// + public class BluRaySupFadeStep + { + /// + /// When the alpha level takes effect, in milliseconds - the same time base as + /// . + /// + public long TimeMs { get; set; } + + /// + /// Alpha of the palette at this step, 0 (invisible) to 100 (fully opaque). + /// + public int AlphaPercent { get; set; } + + public long TimeForWrite => (long)Math.Round(TimeMs * 90.0, MidpointRounding.AwayFromZero); + + public BluRaySupFadeStep() + { + } + + public BluRaySupFadeStep(long timeMs, int alphaPercent) + { + TimeMs = timeMs; + AlphaPercent = alphaPercent; + } + } +} diff --git a/src/libse/BluRaySup/BluRaySupPalette.cs b/src/libse/BluRaySup/BluRaySupPalette.cs index 3fcadd61bb5..1b5b00b92ff 100644 --- a/src/libse/BluRaySup/BluRaySupPalette.cs +++ b/src/libse/BluRaySup/BluRaySupPalette.cs @@ -50,6 +50,17 @@ public class BluRaySupPalette * @return Integer array with red, blue, green component (in this order) */ public static int[] YCbCr2Rgb(int y, int cb, int cr, bool useBt601) + { + YCbCr2Rgb(y, cb, cr, useBt601, out var ir, out var ig, out var ib); + return new[] { ir, ig, ib }; + } + + /// + /// Same conversion as without the int[3] + /// allocation - callers that decode a whole palette (up to 256 entries) call this once + /// per entry, so the array was pure per-call garbage. + /// + public static void YCbCr2Rgb(int y, int cb, int cr, bool useBt601, out int r8, out int g8, out int b8) { // Studio range → center/offset removal y -= 16; @@ -82,7 +93,7 @@ public static int[] YCbCr2Rgb(int y, int cb, int cr, bool useBt601) if (r > 254.0) r += 0.35; if (g > 254.0) g += 0.35; if (b > 254.0) b += 0.35; - + int ir = (int)Math.Round(r, MidpointRounding.AwayFromZero); int ig = (int)Math.Round(g, MidpointRounding.AwayFromZero); int ib = (int)Math.Round(b, MidpointRounding.AwayFromZero); @@ -91,7 +102,9 @@ public static int[] YCbCr2Rgb(int y, int cb, int cr, bool useBt601) if (ig < 0) ig = 0; else if (ig > 255) ig = 255; if (ib < 0) ib = 0; else if (ib > 255) ib = 255; - return new[] { ir, ig, ib }; + r8 = ir; + g8 = ig; + b8 = ib; } /** @@ -300,10 +313,10 @@ public void SetYCbCr(int index, int yn, int cbn, int crn) _cb[index] = (byte)cbn; _cr[index] = (byte)crn; // create RGB - var rgb = YCbCr2Rgb(yn, cbn, crn, _useBt601); - _r[index] = (byte)rgb[0]; - _g[index] = (byte)rgb[1]; - _b[index] = (byte)rgb[2]; + YCbCr2Rgb(yn, cbn, crn, _useBt601, out var r8, out var g8, out var b8); + _r[index] = (byte)r8; + _g[index] = (byte)g8; + _b[index] = (byte)b8; } /** diff --git a/src/libse/BluRaySup/BluRaySupParser.cs b/src/libse/BluRaySup/BluRaySupParser.cs index 7ca7677c1c4..8258e5e87e0 100644 --- a/src/libse/BluRaySup/BluRaySupParser.cs +++ b/src/libse/BluRaySup/BluRaySupParser.cs @@ -557,7 +557,10 @@ public static List ParseBluRaySupFromMatroska(MatroskaTrackInfo matrosk subtitles.Add(sup); // fix overlapping - if (subtitles.Count > 1 && sub[subtitles.Count - 2].End > sub[subtitles.Count - 1].Start) + // Index "subtitles", not "sub": the two lists are not parallel (one + // container block can yield several display sets), so this compared + // unrelated blocks and threw once subtitles outgrew sub. + if (subtitles.Count > 1 && subtitles[subtitles.Count - 2].EndTime > subtitles[subtitles.Count - 1].StartTime) { subtitles[subtitles.Count - 2].EndTime = subtitles[subtitles.Count - 1].StartTime - 1; } diff --git a/src/libse/BluRaySup/BluRaySupPicture.cs b/src/libse/BluRaySup/BluRaySupPicture.cs index 00ddd320639..498f3d90c0d 100644 --- a/src/libse/BluRaySup/BluRaySupPicture.cs +++ b/src/libse/BluRaySup/BluRaySupPicture.cs @@ -103,6 +103,13 @@ public class BluRaySupPicture /// public List> Palettes { get; set; } = new List>(); + /// + /// Alpha levels for a fade in/out, in presentation order. A step at + /// sets the alpha the caption appears with; every later step becomes a palette update + /// display set. Empty (the default) writes the caption fully opaque, as before. + /// + public List FadeSteps { get; set; } = new List(); + /// /// Create RLE buffer from bitmap /// @@ -121,6 +128,11 @@ private static byte[] EncodeImage(SKBitmap bm, List palette) } } + // Cap on the approximate-match memo below. A caption repeats its anti-aliasing + // colours on every row, but a photographic source can hold millions of distinct + // ones - past the cap the linear scan simply runs again, as it always did. + const int maxCachedColors = 1 << 16; + var transparentColor = (byte)palette[palette.Count - 1]; var bytes = new List(bm.Width * 2); var reader = new BitmapRowReader(bm); @@ -145,7 +157,15 @@ private static byte[] EncodeImage(SKBitmap bm, List palette) } else { + // FindBestMatch is a linear scan over the (up to 255 entry) palette and + // the palette does not change while encoding, so the answer for a color + // is fixed. Anti-aliased edges hit this for the same handful of colors + // on every row of the caption; remember them in the same lookup. color = FindBestMatch(c, palette); + if (lookup.Count < maxCachedColors) + { + lookup[c] = color; + } } for (len = 1; x + len < bm.Width; len++) @@ -361,6 +381,15 @@ private static List GetBitmapPalette(SKBitmap bitmap, SKColor fontColor lookup = new HashSet(); pal.Add(fontColor); lookup.Add(fontColor); + + // Colors already known to have a close palette entry. HasCloseColor is a linear scan + // over the palette, and this pass reached it for every pixel of every anti-aliased + // edge - a 1920x200 caption ran it hundreds of thousands of times over a palette + // growing towards 254 entries. A rejection can never be undone: the palette only + // grows, and the tolerance only widens as it does (1 -> 5 -> 25), so a color that + // had a close entry once still has one later. Rejections take no palette slot, so + // caching one can never skip an insertion. + var rejected = new HashSet(); for (var y = 0; y < bitmap.Height; y++) { reader.ReadRow(y, row); @@ -373,6 +402,10 @@ private static List GetBitmapPalette(SKBitmap bitmap, SKColor fontColor { // exact color already exists } + else if (rejected.Contains(c)) + { + // a close enough color already exists + } else if (pal.Count < 100) { if (!HasCloseColor(c, pal, 1)) @@ -380,6 +413,10 @@ private static List GetBitmapPalette(SKBitmap bitmap, SKColor fontColor pal.Add(c); lookup.Add(c); } + else + { + rejected.Add(c); + } } else if (pal.Count < 240) { @@ -388,14 +425,32 @@ private static List GetBitmapPalette(SKBitmap bitmap, SKColor fontColor pal.Add(c); lookup.Add(c); } + else + { + rejected.Add(c); + } } - else if (pal.Count < 254 && !HasCloseColor(c, pal, 25)) + else if (pal.Count < 254) { - pal.Add(c); - lookup.Add(c); + if (!HasCloseColor(c, pal, 25)) + { + pal.Add(c); + lookup.Add(c); + } + else + { + rejected.Add(c); + } } } } + + // Every branch above requires pal.Count < 254, so a full palette freezes the + // result - the rest of the image only cost scans that could not change it. + if (pal.Count >= 254) + { + break; + } } pal.Add(SKColors.Transparent); // last entry must be transparent @@ -444,6 +499,78 @@ private static int GetFpsId(double fps) private static long _lastEndTimeForWrite = -1000; + /// + /// Splits into the alpha the caption appears with (part of the + /// epoch's own palette) and the steps that follow it as palette update display sets. + /// Steps outside the caption, and steps that do not change the alpha, are dropped - each + /// one would cost a display set for nothing. + /// + private static List GetFadeSteps(BluRaySupPicture pic, out int startAlphaPercent) + { + startAlphaPercent = 100; + var updates = new List(); + if (pic.FadeSteps == null || pic.FadeSteps.Count == 0) + { + return updates; + } + + var sorted = new List(pic.FadeSteps); + sorted.Sort((a, b) => a.TimeMs.CompareTo(b.TimeMs)); + + var lastAlpha = 100; + var lastTime = long.MinValue; + foreach (var step in sorted) + { + var alpha = Math.Min(100, Math.Max(0, step.AlphaPercent)); + if (step.TimeMs <= pic.StartTime) + { + startAlphaPercent = alpha; + lastAlpha = alpha; + continue; + } + + if (step.TimeMs >= pic.EndTime || alpha == lastAlpha || step.TimeMs == lastTime) + { + continue; + } + + updates.Add(new BluRaySupFadeStep(step.TimeMs, alpha)); + lastAlpha = alpha; + lastTime = step.TimeMs; + } + + return updates; + } + + /// + /// Writes a Palette Definition Segment with every alpha scaled by + /// - the whole of a Blu-ray fade is this one number + /// changing between display sets. + /// + private static int WritePds(byte[] buf, int index, byte[] packetHeader, BluRaySupPalette pal, int palSize, int paletteVersion, int alphaPercent) + { + packetHeader[10] = 0x14; // ID (keep PTS & DTS) + ToolBox.SetWord(packetHeader, 11, 2 + palSize * 5); // size + for (var i = 0; i < packetHeader.Length; i++) + { + buf[index++] = packetHeader[i]; + } + + buf[index++] = 0; // palette_id + buf[index++] = (byte)paletteVersion; // palette_version_number + var alpha = pal.GetAlpha(); + for (var i = 0; i < palSize; i++) + { + buf[index++] = (byte)i; // index + buf[index++] = pal.GetY()[i]; // Y + buf[index++] = pal.GetCr()[i]; // Cr + buf[index++] = pal.GetCb()[i]; // Cb + buf[index++] = (byte)(alpha[i] * alphaPercent / 100); // Alpha + } + + return index; + } + /// /// Create the binary stream representation of one caption /// @@ -482,7 +609,8 @@ public static byte[] CreateSupFrame(BluRaySupPicture pic, SKBitmap bmp, SKColor } else { - numAddPackets = 1 + (rleBuf.Length - 0xffe4) / 0xffeb; + // round up, but without an extra empty packet when the rest divides evenly + numAddPackets = (rleBuf.Length - 0xffe4 + 0xffeb - 1) / 0xffeb; } // a typical frame consists of 8 packets. It can be elongated by additional object frames @@ -542,14 +670,20 @@ public static byte[] CreateSupFrame(BluRaySupPicture pic, SKBitmap bmp, SKColor { 0x00, 0x00, // 0: object_id 0x00, // 2: object_version_number - 0x40 // 3: first_in_sequence (0x80), last_in_sequence (0x40), 6bits reserved + 0x00 // 3: first_in_sequence (0x80), last_in_sequence (0x40), 6bits reserved + // set per packet below - only the final one is last_in_sequence }; - var size = packetHeader.Length * (8 + numAddPackets); + // Fade steps ride along as palette update display sets (PCS + PDS + END) between the + // caption and the screen clear - same object, only the palette alpha changes. + var fadeSteps = GetFadeSteps(pic, out var startAlphaPercent); + + var size = packetHeader.Length * (8 + numAddPackets + fadeSteps.Count * 3); size += headerPcsStart.Length + headerPcsEnd.Length; size += 2 * headerWds.Length + headerOdsFirst.Length; size += numAddPackets * headerOdsNext.Length; size += (2 + palSize * 5) /* PDS */; + size += fadeSteps.Count * (headerPcsStart.Length + 2 + palSize * 5); size += rleBuf.Length; switch (alignment) @@ -679,23 +813,7 @@ public static byte[] CreateSupFrame(BluRaySupPicture pic, SKBitmap bmp, SKColor } // write PDS - Palette Definition Segment - packetHeader[10] = 0x14; // ID (keep PTS & DTS) - ToolBox.SetWord(packetHeader, 11, 2 + palSize * 5); // size - for (var i = 0; i < packetHeader.Length; i++) - { - buf[index++] = packetHeader[i]; - } - - buf[index++] = 0; - buf[index++] = 0; - for (var i = 0; i < palSize; i++) - { - buf[index++] = (byte)i; // index - buf[index++] = pal.GetY()[i]; // Y - buf[index++] = pal.GetCr()[i]; // Cr - buf[index++] = pal.GetCb()[i]; // Cb - buf[index++] = pal.GetAlpha()[i]; // Alpha - } + index = WritePds(buf, index, packetHeader, pal, palSize, 0, startAlphaPercent); // write first OBJ var bufSize = rleBuf.Length; @@ -742,6 +860,8 @@ public static byte[] CreateSupFrame(BluRaySupPicture pic, SKBitmap bmp, SKColor buf[index++] = packetHeader[i]; } + // only the final fragment carries last_in_sequence - middle ones must be 0x00 + headerOdsNext[3] = (byte)(p == numAddPackets - 1 ? 0x40 : 0x00); for (var i = 0; i < headerOdsNext.Length; i++) { buf[index++] = headerOdsNext[i]; @@ -763,6 +883,47 @@ public static byte[] CreateSupFrame(BluRaySupPicture pic, SKBitmap bmp, SKColor buf[index++] = packetHeader[i]; } + // write the fade steps - one palette update display set each (PCS + PDS + END). The + // PCS repeats the composition of the epoch start with palette_update_flag set, which + // tells the decoder to keep the object it already has and only take the new palette. + var endPts = pic.EndTimeForWrite; + var compositionNumber = pic.CompositionNumber; + headerPcsStart[7] = 0x00; // composition_state: normal case + headerPcsStart[8] = 0x80; // palette_update_flag + for (var step = 0; step < fadeSteps.Count; step++) + { + // A step may only be scheduled after the caption is up and before it is taken + // down; the start PTS can have been nudged by the small gap removal above. + var stepPts = Math.Min(Math.Max(fadeSteps[step].TimeForWrite, pts + 1), endPts - 1); + + compositionNumber++; + packetHeader[10] = 0x16; // ID + ToolBox.SetDWord(packetHeader, 2, (uint)stepPts); // PTS + ToolBox.SetDWord(packetHeader, 6, 0); // DTS (0 = unset) + ToolBox.SetWord(packetHeader, 11, headerPcsStart.Length); // size + for (var i = 0; i < packetHeader.Length; i++) + { + buf[index++] = packetHeader[i]; + } + + ToolBox.SetWord(headerPcsStart, 5, compositionNumber); + for (var i = 0; i < headerPcsStart.Length; i++) + { + buf[index++] = headerPcsStart[i]; + } + + // The palette version has to move for the decoder to take the update; it is a + // byte, so it wraps on captions with more than 255 steps. + index = WritePds(buf, index, packetHeader, pal, palSize, (step + 1) & 0xff, fadeSteps[step].AlphaPercent); + + packetHeader[10] = 0x80; // END (keep PTS & DTS) + ToolBox.SetWord(packetHeader, 11, 0); // size + for (var i = 0; i < packetHeader.Length; i++) + { + buf[index++] = packetHeader[i]; + } + } + // write PCS end packetHeader[10] = 0x16; // ID ToolBox.SetDWord(packetHeader, 2, (uint)pic.EndTimeForWrite); // PTS @@ -776,7 +937,7 @@ public static byte[] CreateSupFrame(BluRaySupPicture pic, SKBitmap bmp, SKColor ToolBox.SetWord(headerPcsEnd, 0, pic.Width); ToolBox.SetWord(headerPcsEnd, 2, h); // cropped height ToolBox.SetByte(headerPcsEnd, 4, fpsId); - ToolBox.SetWord(headerPcsEnd, 5, pic.CompositionNumber + 1); + ToolBox.SetWord(headerPcsEnd, 5, compositionNumber + 1); for (var i = 0; i < headerPcsEnd.Length; i++) { buf[index++] = headerPcsEnd[i]; diff --git a/src/libse/BluRaySup/SkiaExt.cs b/src/libse/BluRaySup/SkiaExt.cs index 9825c38f9ce..504965c1e95 100644 --- a/src/libse/BluRaySup/SkiaExt.cs +++ b/src/libse/BluRaySup/SkiaExt.cs @@ -111,13 +111,23 @@ private static bool ByteArraysEqual(ReadOnlySpan a1, ReadOnlySpan a2 return a1.SequenceEqual(a2); } - public class TrimResult + /// + /// is always a new bitmap (a crop or a copy), never the source, + /// so callers own it - hence . + /// + public class TrimResult : IDisposable { public SKBitmap TrimmedBitmap { get; set; } public int Top { get; set; } public int Left { get; set; } public int Right { get; set; } public int Bottom { get; set; } + + public void Dispose() + { + TrimmedBitmap?.Dispose(); + TrimmedBitmap = null; + } } /// diff --git a/src/libse/BluRaySup/ToolBox.cs b/src/libse/BluRaySup/ToolBox.cs index 1ffa4a1bea8..9d3970d70a1 100644 --- a/src/libse/BluRaySup/ToolBox.cs +++ b/src/libse/BluRaySup/ToolBox.cs @@ -29,15 +29,17 @@ public static class ToolBox */ public static long[] MillisecondsToTime(double ms) { + // Each component must be truncated, not rounded: rounding one up makes the remainder + // negative and cascades, so 3500 ms came out as 0:0:4:-500 and 40 minutes as 1:-20:0:0. var time = new long[4]; // time[0] = hours - time[0] = (long)Math.Round(ms / (60 * 60 * 1000), MidpointRounding.AwayFromZero); + time[0] = (long)(ms / (60.0 * 60.0 * 1000.0)); ms -= time[0] * 60.0 * 60.0 * 1000.0; // time[1] = minutes - time[1] = (long)Math.Round(ms / (60.0 * 1000.0), MidpointRounding.AwayFromZero); + time[1] = (long)(ms / (60.0 * 1000.0)); ms -= time[1] * 60 * 1000; // time[2] = seconds - time[2] = (long)Math.Round(ms / 1000.0, MidpointRounding.AwayFromZero); + time[2] = (long)(ms / 1000.0); ms -= time[2] * 1000.0; time[3] = (long)Math.Round(ms, MidpointRounding.AwayFromZero); return time; diff --git a/src/libse/CDG/CdgGraphics.cs b/src/libse/CDG/CdgGraphics.cs index c60cde3c67d..ebe438b87cc 100644 --- a/src/libse/CDG/CdgGraphics.cs +++ b/src/libse/CDG/CdgGraphics.cs @@ -79,7 +79,10 @@ public SKBitmap ToBitmap(int packetNumber) // long timeInMilliseconds) } var graphicData = GetGraphicData(); - var bitmap = new SKBitmap(FullWidth, FullHeight, SKColorType.Rgba8888, SKAlphaType.Premul); + // Bgra8888, not Rgba8888: the loop below packs (a<<24)|(r<<16)|(g<<8)|b, which on + // a little-endian machine lands in memory as B,G,R,A. Declaring Rgba8888 made + // Skia read that back as R,G,B,A - i.e. red and blue swapped in every frame. + var bitmap = new SKBitmap(FullWidth, FullHeight, SKColorType.Bgra8888, SKAlphaType.Premul); // Copy pixel data into the bitmap var pixelData = bitmap.GetPixels(); diff --git a/src/libse/Cea608/CaptionScreen.cs b/src/libse/Cea608/CaptionScreen.cs index ce0a7514e04..a4a0550358c 100644 --- a/src/libse/Cea608/CaptionScreen.cs +++ b/src/libse/Cea608/CaptionScreen.cs @@ -184,8 +184,19 @@ public void RollUp() return; } + // Reuse the row that scrolled off the top, cleared - a plain `new CcRow()` is NOT + // empty: its chars start with a null foreground/background pen state, so IsEmpty() + // (and with it CaptionScreen.IsEmpty and Serialize) reported a blank row as content + // for the rest of the file once a roll-up had happened. + var topRow = rows[removeIndex]; rows.RemoveAt(removeIndex); - rows.Add(new CcRow()); + topRow.Clear(); + + // It goes back at the roll-up base row, not at the bottom of the screen. Appending + // only happens to be right when the base row IS the bottom row - with a roll-up + // window placed higher up (a PAC can put it anywhere) appending scrolled every row + // below the window along with it. + rows.Insert(Math.Min(CurrentRow, rows.Count), topRow); Rows = rows.ToArray(); } } diff --git a/src/libse/Cea708/VancDataWriter.cs b/src/libse/Cea708/VancDataWriter.cs index 24bddaed78f..b31cbdcaf9b 100644 --- a/src/libse/Cea708/VancDataWriter.cs +++ b/src/libse/Cea708/VancDataWriter.cs @@ -76,12 +76,49 @@ public static string[] GenerateLinesFromText(string input, int counter) } bytes.AddRange(c1.GetBytes()); - var c2 = new SetText(line); - if (c2.GetBytes().Length + bytes.Count > 32) + // Split the line across packets instead of appending it whole: a cc_data + // section holds at most 16 byte-pairs, so a single SetText longer than the + // remaining room made CcDataSection throw "Too many bytes for CCData!" and the + // whole save failed on any line of ~34 characters or more. + var remaining = line; + while (remaining.Length > 0) { - counter = FlushCommands(counter, bytes, results); + var room = 32 - bytes.Count; + if (room <= 0) + { + counter = FlushCommands(counter, bytes, results); + room = 32 - bytes.Count; + } + + // Count whole characters: a G2 character encodes as EXT1 + code, and + // splitting that pair apart would emit a stray escape. + var take = 0; + var used = 0; + while (take < remaining.Length) + { + var charLength = new SetText(remaining[take].ToString()).GetBytes().Length; + if (used + charLength > room) + { + break; + } + + used += charLength; + take++; + } + + if (take == 0) + { + counter = FlushCommands(counter, bytes, results); + continue; + } + + bytes.AddRange(new SetText(remaining.Substring(0, take)).GetBytes()); + remaining = remaining.Substring(take); + if (remaining.Length > 0) + { + counter = FlushCommands(counter, bytes, results); + } } - bytes.AddRange(c2.GetBytes()); } FlushCommands(counter, bytes, results); diff --git a/src/libse/Common/ActorConverter.cs b/src/libse/Common/ActorConverter.cs index 1fb76b36c7f..861bbe7c888 100644 --- a/src/libse/Common/ActorConverter.cs +++ b/src/libse/Common/ActorConverter.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; namespace Nikse.SubtitleEdit.Core.Common { @@ -35,8 +34,19 @@ public ActorConverter(SubtitleFormat subtitleFormat, string languageCode) _nameListInclMulti = _namesList.GetAllNames(); } - public string FixActorsFromActor(Paragraph p, int? changeCasing, SKColor? color) + /// + /// Moves the actor column into the text. The converted paragraph is returned in the result - + /// the actor column is cleared, as the name now lives in the text and would otherwise be + /// written twice (#14077). + /// + public ActorConverterResult FixActorsFromActor(Paragraph paragraph, int? changeCasing, SKColor? color) { + var p = new Paragraph(paragraph, false); + if (ToActor) + { + return new ActorConverterResult { Paragraph = p, Selected = true }; + } + var actor = p.Actor; if (changeCasing.HasValue) { @@ -55,85 +65,161 @@ public string FixActorsFromActor(Paragraph p, int? changeCasing, SKColor? color) { actor = actor + ":"; } - else if (ToActor) - { - return p.Text; - } - if (color.HasValue && !ToActor) + if (color.HasValue) { actor = SetColor(_subtitleFormat, color.Value, actor); } p.Text = actor + " " + p.Text.TrimStart(' '); + p.Actor = string.Empty; - return p.Text; + return new ActorConverterResult { Paragraph = p, Selected = true }; } - public string FixActorsFromBeforeColon(Paragraph p, char ch, int? changeCasing, SKColor? color) + /// + /// Converts "Actor: text" lines. The converted paragraph is returned in the result: converting + /// to the actor column writes from whichever line carries the + /// name, and a second speaker in the same paragraph becomes + /// - the same shape + /// returns for the bracket formats (#14077). + /// + public ActorConverterResult FixActorsFromBeforeColon(Paragraph paragraph, char ch, int? changeCasing, SKColor? color) { - var sb = new StringBuilder(); - foreach (var line in p.Text.SplitToLines()) + var p = new Paragraph(paragraph, false); + var lines = p.Text.SplitToLines(); + + // Only one extra paragraph can be split off, so a third speaker would be lost. + if (ToActor && lines.Count(line => HasActor(line, ch)) > 2) { + return new ActorConverterResult { Paragraph = paragraph, Skip = true }; + } + + Paragraph nextParagraph = null; + var selectFix = true; + var actorAssigned = false; + var textLines = new List(); + foreach (var line in lines) + { + // index into the trimmed line - leading whitespace goes with the actor it precedes var s = line.Trim(); - var startIdx = line.IndexOf(ch); - if (startIdx > 0) + var startIdx = s.IndexOf(ch); + if (startIdx <= 0) { - var actor = s.Substring(0, startIdx).Trim(' ', '-', '"'); - if (changeCasing.HasValue) - { - actor = SetCasing(_subtitleFormat, changeCasing, actor); - } - - if (ToSquare) - { - actor = "[" + actor + "]"; - } - else if (ToParentheses) - { - actor = "(" + actor + ")"; - } - else if (ToColon) + // A line without an actor belongs to the paragraph the previous line went to. + if (nextParagraph != null) { - actor = actor + ":"; + nextParagraph.Text = (nextParagraph.Text + Environment.NewLine + s).Trim(); } - else if (ToActor) + else { + textLines.Add(s); } - if (color.HasValue && !ToActor) - { - SetColor(_subtitleFormat, color.Value, actor); - } + continue; + } - if (ToSquare) - { - s = actor + " " + s.Substring(startIdx + 1).TrimStart(' '); - } - else if (ToParentheses) - { - s = actor + " " + s.Substring(startIdx + 1).TrimStart(' '); - } - else if (ToColon) - { - s = actor + " " + s.Substring(startIdx + 1).TrimStart(' '); - } - else if (ToActor) - { - s = s.Substring(startIdx + 1); - } + var actor = s.Substring(0, startIdx).Trim(' ', '-', '"'); + // AND, not assignment: selectFix is declared outside the per-line loop, so a later line's valid speaker used to mask an earlier line whose "actor" was really a clock ("It's 12:30 now.") - pre-checking a fix that destroys text. + selectFix &= IsActor(actor); + if (changeCasing.HasValue) + { + actor = SetCasing(_subtitleFormat, changeCasing, actor); } - sb.AppendLine(s); + if (ToSquare) + { + actor = "[" + actor + "]"; + } + else if (ToParentheses) + { + actor = "(" + actor + ")"; + } + else if (ToColon) + { + actor = actor + ":"; + } + + if (color.HasValue && !ToActor) + { + actor = SetColor(_subtitleFormat, color.Value, actor); + } + + var text = s.Substring(startIdx + 1).TrimStart(' '); + if (!ToActor) + { + textLines.Add(actor + " " + text); + } + else if (!actorAssigned) + { + // The first name found goes in the actor column... + p.Actor = actor; + actorAssigned = true; + textLines.Add(text); + } + else + { + // ...a second one needs a paragraph of its own, as the column holds one name. + nextParagraph = new Paragraph(p) { Text = text, Actor = actor }; + } } - return sb.ToString().Trim(); + p.Text = string.Join(Environment.NewLine, textLines).Trim(); + + return new ActorConverterResult + { + Paragraph = p, + NextParagraph = nextParagraph, + Selected = selectFix, + }; + } + + private static bool HasActor(string line, char ch) + { + return line.Trim().IndexOf(ch) > 0; + } + + /// + /// A line splitter can break a tag in half - "Princess Peach on (Speaker\n2) Super Smash + /// Bros." - and the per-line scan in then finds no complete pair on + /// either line, so the paragraph was silently left alone. Line breaks inside a bracket pair + /// are folded to a space, pulling the two half-lines together. Capped at name length: a + /// bracket pair spanning that much text is a parenthetical remark, not a speaker tag. + /// + private static string JoinTagBrokenOverLineBreak(string text, char start, char end) + { + const int maxTagLength = 50; + + var startIdx = text.IndexOf(start); + while (startIdx >= 0) + { + var endIdx = text.IndexOf(end, startIdx + 1); + if (endIdx < 0) + { + break; + } + + var inner = text.Substring(startIdx + 1, endIdx - startIdx - 1); + if (inner.Length <= maxTagLength && + inner.IndexOf(start) < 0 && + (inner.Contains('\n') || inner.Contains('\r'))) + { + var joined = string.Join(" ", inner.Split((char[])null, StringSplitOptions.RemoveEmptyEntries)); + text = text.Substring(0, startIdx + 1) + joined + text.Substring(endIdx); + endIdx = startIdx + 1 + joined.Length; + } + + startIdx = text.IndexOf(start, endIdx + 1); + } + + return text; } public ActorConverterResult FixActors(Paragraph paragraph, char start, char end, int? changeCasing, SKColor? color) { var p = new Paragraph(paragraph, false); Paragraph nextParagraph = null; + p.Text = JoinTagBrokenOverLineBreak(p.Text, start, end); var lines = p.Text.SplitToLines(); if (lines.Count > 2) { @@ -148,15 +234,15 @@ public ActorConverterResult FixActors(Paragraph paragraph, char start, char end, var s = line; var startIdx = s.IndexOf(start); var endIdx = s.IndexOf(end); - if (startIdx != -1 && endIdx != -1) - { - if (endIdx < startIdx) - { - break; - } + // A closing bracket before the opening one is not an actor - the line is kept as it + // is. Giving up on the whole paragraph here dropped this line and every line after + // it from the text. + if (startIdx != -1 && endIdx > startIdx) + { var actor = s.Substring(startIdx + 1, endIdx - startIdx - 1).Trim(' ', '-', '"'); - selectFix = IsActor(actor); + // AND, not assignment: selectFix is declared outside the per-line loop, so a later line's valid speaker used to mask an earlier line whose "actor" was really a clock ("It's 12:30 now.") - pre-checking a fix that destroys text. + selectFix &= IsActor(actor); if (changeCasing.HasValue) { actor = SetCasing(_subtitleFormat, changeCasing, actor); @@ -208,9 +294,20 @@ public ActorConverterResult FixActors(Paragraph paragraph, char start, char end, } else if (lineIdx == 1 && ToActor) { - nextParagraph = new Paragraph(p); - nextParagraph.Text = s.Trim(); - nextParagraph.Actor = actor; + if (string.IsNullOrEmpty(p.Actor)) + { + // Only the second line names a speaker, so it belongs to this paragraph - + // splitting off a paragraph with no actor at all would leave the name + // nowhere (#14077). + p.Actor = actor; + p.Text = (p.Text + Environment.NewLine + s.Trim()).Trim(); + } + else + { + nextParagraph = new Paragraph(p); + nextParagraph.Text = s.Trim(); + nextParagraph.Actor = actor; + } } else if (lineIdx == 1) { @@ -269,6 +366,8 @@ private static string SetColor(SubtitleFormat format, SKColor color, string acto return actor; } + private static readonly string[] CommonTitles = { "Mr.", "Mrs.", "Dr." }; + private bool IsActor(string s) { if (string.IsNullOrWhiteSpace(s)) @@ -294,15 +393,14 @@ private bool IsActor(string s) return false; } - if (word.Any(c => char.IsDigit(c) || (!char.IsLetter(c) && c != '-' && c != '\''))) + if (CommonTitles.Contains(word)) { - return false; + continue; } - var commonTitles = new[] { "Mr.", "Mrs.", "Dr.", }; - if (commonTitles.Contains(word)) + if (word.Any(c => char.IsDigit(c) || (!char.IsLetter(c) && c != '-' && c != '\''))) { - continue; + return false; } if (!_nameListInclMulti.Contains(word, StringComparer.OrdinalIgnoreCase)) diff --git a/src/libse/Common/AssaResampler.cs b/src/libse/Common/AssaResampler.cs index 92b5c869930..84c3d969f02 100644 --- a/src/libse/Common/AssaResampler.cs +++ b/src/libse/Common/AssaResampler.cs @@ -183,6 +183,20 @@ private static string FixDrawing(decimal sourceWidth, decimal targetWidth, decim return sb.ToString().TrimEnd() + s; } + // The tag regexes below allow whitespace ("\pos (10,11)"), so the parameters cannot be cut + // at a fixed offset from the tag name - take everything between the parentheses instead. + private static string GetTagParameters(string matchValue) + { + var open = matchValue.IndexOf('('); + var close = matchValue.LastIndexOf(')'); + if (open < 0 || close <= open) + { + return string.Empty; + } + + return matchValue.Substring(open + 1, close - open - 1).RemoveChar(' '); + } + private static string FixMethodFourParameters(decimal sourceWidth, decimal targetWidth, decimal sourceHeight, decimal targetHeight, string input, string tag) { var regex = GetCachedRegex("\\\\" + tag + "\\s*\\(\\s*[-+]?\\d+[\\.\\d+]*\\s*,\\s*[-+]?\\d+[\\.\\d+]*\\s*,\\s*[-+]?\\d+[\\.\\d+]*\\s*,\\s*[-+]?\\d+[\\.\\d+]*\\s*\\)"); @@ -190,7 +204,7 @@ private static string FixMethodFourParameters(decimal sourceWidth, decimal targe var match = regex.Match(s); while (match.Success) { - var value = match.Value.Substring(tag.Length + 2, match.Value.Length - tag.Length - 3).RemoveChar(' '); + var value = GetTagParameters(match.Value); var arr = value.Split(','); if (arr.Length == 4 && decimal.TryParse(arr[0], NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var x1) && @@ -212,7 +226,8 @@ private static string FixMethodFourParameters(decimal sourceWidth, decimal targe } else { - break; + // Skip this tag rather than abandoning every remaining tag on the line. + match = regex.Match(s, match.Index + match.Value.Length); } } @@ -226,7 +241,7 @@ private static string FixMethodSixParametersFourActive(decimal sourceWidth, deci var match = regex.Match(s); while (match.Success) { - var value = match.Value.Substring(tag.Length + 2, match.Value.Length - tag.Length - 3).RemoveChar(' '); + var value = GetTagParameters(match.Value); var arr = value.Split(','); if (arr.Length == 6 && decimal.TryParse(arr[0], NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var x1) && @@ -252,7 +267,8 @@ private static string FixMethodSixParametersFourActive(decimal sourceWidth, deci } else { - break; + // Skip this tag rather than abandoning every remaining tag on the line. + match = regex.Match(s, match.Index + match.Value.Length); } } @@ -266,7 +282,7 @@ private static string FixMethodTwoParameters(decimal sourceWidth, decimal target var match = regex.Match(s); while (match.Success) { - var value = match.Value.Substring(tag.Length + 2, match.Value.Length - tag.Length - 3).RemoveChar(' '); + var value = GetTagParameters(match.Value); var arr = value.Split(','); if (arr.Length == 2 && decimal.TryParse(arr[0], NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var x) && @@ -282,7 +298,8 @@ private static string FixMethodTwoParameters(decimal sourceWidth, decimal target } else { - break; + // Skip this tag rather than abandoning every remaining tag on the line. + match = regex.Match(s, match.Index + match.Value.Length); } } @@ -306,7 +323,8 @@ private static string FixTagWithNumber(decimal sourceHeight, decimal targetHeigh } else { - break; + // Skip this tag rather than abandoning every remaining tag on the line. + match = regex.Match(s, match.Index + match.Value.Length); } } diff --git a/src/libse/Common/CharUtils.cs b/src/libse/Common/CharUtils.cs index 6ea30179028..a9b071e4035 100644 --- a/src/libse/Common/CharUtils.cs +++ b/src/libse/Common/CharUtils.cs @@ -6,7 +6,7 @@ public static class CharUtils /// Checks if character matches [0-9] /// /// - public static bool IsDigit(char ch) => ch >= '0' && ch <= '9'; + public static bool IsAsciiDigit(char ch) => ch >= '0' && ch <= '9'; /// /// Checks if given character is hexadecimal @@ -17,6 +17,7 @@ public static class CharUtils /// /// Checks if character is between A-Z or a-z /// - public static bool IsEnglishAlphabet(char ch) => ch >= 'A' && ch <= 'z' && (ch <= 'Z' || ch >= 'a'); + /// char.IsAsciiLetter (.NET 7+) is not available in netstandard2.1, which this library also targets. + public static bool IsAsciiLetter(char ch) => ch >= 'A' && ch <= 'z' && (ch <= 'Z' || ch >= 'a'); } } \ No newline at end of file diff --git a/src/libse/Common/ContinuationUtilities.cs b/src/libse/Common/ContinuationUtilities.cs index d2321434bb5..ccc26e51cd5 100644 --- a/src/libse/Common/ContinuationUtilities.cs +++ b/src/libse/Common/ContinuationUtilities.cs @@ -708,7 +708,9 @@ public static string RemovePrefix(string originalText, ContinuationProfile profi foreach (var prefix in Prefixes) { - if (newFirstWord.StartsWith(prefix, StringComparison.Ordinal) && !newFirstWord.EndsWith(prefix + Environment.NewLine, StringComparison.Ordinal)) + // EndsWith cannot express "the prefix is followed by a line break" - the prefix was + // just matched at the START. HasPrefix below is the correct twin of this test. + if (newFirstWord.StartsWith(prefix, StringComparison.Ordinal) && !newFirstWord.StartsWith(prefix + Environment.NewLine, StringComparison.Ordinal)) { newFirstWord = newFirstWord.Substring(prefix.Length); } @@ -911,7 +913,9 @@ public static bool IsBold(string input) while (input.IndexOf("", StringComparison.Ordinal) >= 0) { var startIndex = input.IndexOf("", StringComparison.Ordinal); - var endIndex = input.IndexOf("", StringComparison.Ordinal); + // From startIndex, as IsItalic does: searching from 0 found a stray leading + // "" and produced a negative Substring length, throwing on ordinary text. + var endIndex = input.IndexOf("", startIndex, StringComparison.Ordinal); var textToRemove = endIndex >= 0 ? input.Substring(startIndex, (endIndex + 4) - startIndex) : input.Substring(startIndex); input = input.Replace(textToRemove, string.Empty); } @@ -960,15 +964,15 @@ public static bool IsFullLineTag(string input, int position) var endIndex = input.LastIndexOf("= 0) { + // A truncated closing tag ("') made IndexOf return -1, and the +1 + // turned that into 0 - a negative Substring length below, which threw. + var closeIndex = input.IndexOf('>', endIndex); if (startIndex == endIndex) { startIndex = 0; - endIndex = input.IndexOf('>', endIndex) + 1; - } - else - { - endIndex = input.IndexOf('>', endIndex) + 1; } + + endIndex = closeIndex >= 0 ? closeIndex + 1 : input.Length; } else { @@ -1002,8 +1006,16 @@ public static bool IsFullLineQuote(string originalInput, int position, char quot return false; } - // Shift index if needed after deleting { } tags + // Shift index if needed after deleting { } tags. IndexOf only finds the shift when + // the removed block sat at the very start - with a tag in the middle, input is not a + // contiguous substring of originalInput and no shift happened at all, leaving + // position past the end of input. position -= Math.Max(0, originalInput.IndexOf(input, StringComparison.Ordinal)); + position = Math.Min(position, input.Length - 1); + if (position < 0) + { + return false; + } var startIndex = position; var endIndex = position; @@ -1152,7 +1164,7 @@ public static bool HasSuffix(string input, ContinuationProfile profile) return true; } - if (profile.UseDifferentStyleGap && profile.GapSuffix.Length > 0 && input.EndsWith(profile.GapSuffix) && !input.EndsWith(Environment.NewLine + profile.GapSuffix, StringComparison.Ordinal)) + if (profile.UseDifferentStyleGap && profile.GapSuffix.Length > 0 && input.EndsWith(profile.GapSuffix, StringComparison.Ordinal) && !input.EndsWith(Environment.NewLine + profile.GapSuffix, StringComparison.Ordinal)) { return true; } diff --git a/src/libse/Common/ConvertColorsToDialogUtils.cs b/src/libse/Common/ConvertColorsToDialogUtils.cs index 9c9a3c5e2d5..45d7abb6889 100644 --- a/src/libse/Common/ConvertColorsToDialogUtils.cs +++ b/src/libse/Common/ConvertColorsToDialogUtils.cs @@ -93,7 +93,16 @@ public static void ConvertColorsToDialogInSubtitle(Subtitle subtitle, bool remov currentColor = newColor; } - index = p.Text.IndexOf(">", index) + 1; + // An unterminated tag has no '>': IndexOf returns -1 and "+ 1" put the + // scan back at 0, so the same tag matched again forever and the whole + // app froze. Nothing after an unterminated tag is parseable - stop. + var tagEnd = p.Text.IndexOf(">", index, StringComparison.Ordinal); + if (tagEnd < 0) + { + break; + } + + index = tagEnd + 1; endOfColor = false; } @@ -113,8 +122,15 @@ public static void ConvertColorsToDialogInSubtitle(Subtitle subtitle, bool remov } else if (index + "{".Length <= p.Text.Length && p.Text.SafeSubstring(index, "{".Length) == "{") { - // ASS tag, jump over - index = p.Text.IndexOf("}", index) + 1; + // ASS tag, jump over. Same trap as the '>' search above: an unclosed + // '{' has no '}', and "-1 + 1" restarted the scan at 0 forever. + var assaTagEnd = p.Text.IndexOf("}", index, StringComparison.Ordinal); + if (assaTagEnd < 0) + { + break; + } + + index = assaTagEnd + 1; } else if (index + 1 <= p.Text.Length && p.Text.SafeSubstring(index, 1) == " " || p.Text.SafeSubstring(index, 1) == "\r" || p.Text.SafeSubstring(index, 1) == "\n") { diff --git a/src/libse/Common/CsvUtil.cs b/src/libse/Common/CsvUtil.cs index cb0495d5839..988b8b206b7 100644 --- a/src/libse/Common/CsvUtil.cs +++ b/src/libse/Common/CsvUtil.cs @@ -66,8 +66,15 @@ public static List> CsvSplitLines(List lines, char separato public static string[] CsvSplit(string line, bool quoteOn, out bool continuation, char separator = ',') { var lines = new List(); - var stringOn = false; + // A continuation line resumes inside the field the previous line left open, so it + // starts "in a string" too. Starting at false handed the first character of such a + // line to the not-in-a-string branches below: a line beginning with the closing + // quote appended it as literal text and never closed the field (swallowing the rest + // of the file), and a line beginning with the separator split a field that is + // inside quotes. + var stringOn = quoteOn; var item = new StringBuilder(); + var sawSeparator = false; var index = 0; while (index < line.Length) { @@ -82,21 +89,13 @@ public static string[] CsvSplit(string line, bool quoteOn, out bool continuation { switch (ch) { - case ',' when !quoteOn && separator == ',': - lines.Add(item.ToString()); - item.Clear(); - index++; - stringOn = false; - continue; - case ';' when !quoteOn && separator == ';': - lines.Add(item.ToString()); - item.Clear(); - index++; - stringOn = false; - continue; - case '\t' when !quoteOn && separator == '\t': + // Any separator, not just comma/semicolon/tab: the import window's + // detection also picks '|', and an unhandled separator left the whole + // row in the first field with every other column empty. + case var _ when !quoteOn && ch == separator: lines.Add(item.ToString()); item.Clear(); + sawSeparator = true; index++; stringOn = false; continue; @@ -135,6 +134,7 @@ public static string[] CsvSplit(string line, bool quoteOn, out bool continuation { lines.Add(item.ToString()); item.Clear(); + sawSeparator = true; index++; continue; } @@ -149,7 +149,10 @@ public static string[] CsvSplit(string line, bool quoteOn, out bool continuation index++; } - if (item.Length > 0) + // "sawSeparator" keeps a trailing empty field: "a," is two columns, the second one + // empty, and dropping it made every row that ends on an empty column one field short. + // A line with no separator and no content stays zero fields, as before. + if (item.Length > 0 || sawSeparator) { lines.Add(item.ToString()); } diff --git a/src/libse/Common/FastBitmap.cs b/src/libse/Common/FastBitmap.cs index 244eb1e733a..008d70bc2f0 100644 --- a/src/libse/Common/FastBitmap.cs +++ b/src/libse/Common/FastBitmap.cs @@ -103,15 +103,53 @@ public void SetPixel(int x, int y, PixelData color) public void SetPixel(int x, int y, SKColor color, int length) { - var data = (PixelData*)(_pBase + y * _width + x * sizeof(PixelData)); - for (int i = 0; i < length; i++) + if (length <= 0) + { + return; + } + + // Bgra8888 in memory is B,G,R,A at increasing addresses; as a little-endian + // uint that is B | G<<8 | R<<16 | A<<24. A span Fill vectorizes the run. + var value = color.Blue | ((uint)color.Green << 8) | ((uint)color.Red << 16) | ((uint)color.Alpha << 24); + var data = (uint*)(_pBase + y * _width + x * sizeof(PixelData)); + new Span(data, length).Fill(value); + } + + /// + /// True when every pixel in row has alpha below + /// . Reads only the alpha byte of each pixel - + /// used by crop scans that would otherwise build an SKColor per pixel. + /// + public bool IsRowTransparent(int y, byte alphaLimit) + { + var p = _pBase + y * _width + 3; + for (var x = 0; x < Width; x++, p += sizeof(PixelData)) { - data->Alpha = color.Alpha; - data->Red = color.Red; - data->Green = color.Green; - data->Blue = color.Blue; - data++; + if (*p >= alphaLimit) + { + return false; + } } + + return true; + } + + /// + /// True when every pixel in column from row + /// down has alpha below . + /// + public bool IsColumnTransparent(int x, int startY, byte alphaLimit) + { + var p = _pBase + startY * _width + x * sizeof(PixelData) + 3; + for (var y = startY; y < Height; y++, p += _width) + { + if (*p >= alphaLimit) + { + return false; + } + } + + return true; } public SKBitmap GetBitmap() diff --git a/src/libse/Common/FileUtil.cs b/src/libse/Common/FileUtil.cs index 46cbca06c61..a23d3e51bdf 100644 --- a/src/libse/Common/FileUtil.cs +++ b/src/libse/Common/FileUtil.cs @@ -722,7 +722,7 @@ public static bool IsPlainText(string fileName) { return false; } - else if (CharUtils.IsDigit(ch)) + else if (CharUtils.IsAsciiDigit(ch)) { numberCount++; } diff --git a/src/libse/Common/FixCasing.cs b/src/libse/Common/FixCasing.cs index 7ba5b75c677..f2d7943db3b 100644 --- a/src/libse/Common/FixCasing.cs +++ b/src/libse/Common/FixCasing.cs @@ -95,7 +95,7 @@ private static string FixEnglishAloneILowerToUpper(string input) if (text.StartsWith("I-if ", StringComparison.Ordinal)) { - text = text.Remove(0, 4).Insert(0, "I-If "); + text = text.Remove(0, 4).Insert(0, "I-If"); } for (var indexOfI = text.IndexOf('i'); indexOfI >= 0; indexOfI = text.IndexOf('i', indexOfI + 1)) @@ -175,7 +175,10 @@ private string FixCasingAfterTitles(string input) if (start.StartsWith(title.AsSpan(), StringComparison.OrdinalIgnoreCase)) { var idx = i + title.Length; - if (idx < text.Length - 2 && text[idx] == ' ') + // The body reads text[idx] after the increment below, so the bound only + // needs to leave one character - "- 2" skipped a one-letter word at the + // end of the line ("Mr. t" was left alone while "Mr. to" was fixed). + if (idx < text.Length - 1 && text[idx] == ' ') { idx++; var words = text.Substring(idx).Split(' ', '\r', '\n', ',', '"', '?', '!', '.', '\''); @@ -220,7 +223,7 @@ public static string FixStutter(string text) } else if (!tagOn && char.IsLetter(ch)) { - if (firstLetter && index < text.Length - 6 && char.IsUpper(text[index]) && + if (firstLetter && index < text.Length - 5 && char.IsUpper(text[index]) && text[index + 1] == '-' && char.IsLower(text[index + 2]) && text[index] == char.ToUpperInvariant(text[index + 2]) && text[index + 3] == '-' && char.IsLower(text[index + 4]) && text[index] == char.ToUpperInvariant(text[index + 4]) && text[index + 5] != '-') @@ -231,7 +234,7 @@ public static string FixStutter(string text) sb.Append('-'); index += 4; } - else if (firstLetter && index < text.Length - 4 && char.IsUpper(text[index]) && + else if (firstLetter && index < text.Length - 3 && char.IsUpper(text[index]) && text[index + 1] == '-' && char.IsLower(text[index + 2]) && text[index] == char.ToUpperInvariant(text[index + 2]) && text[index + 3] != '-') { @@ -281,12 +284,12 @@ private string Fix(string original, string lastLine, List nameList, Cult else if (FixMakeUppercase) { var st = new StrippableText(text); - text = st.Pre + MakeUpperCaseExceptTags(st.StrippedText) + st.Post; + text = st.Pre + MakeUpperCaseExceptTags(st.StrippedText, subtitleCulture) + st.Post; text = HtmlUtil.FixUpperTags(text); // tags inside text } else if (FixMakeLowercase) { - text = MakeLowerCaseExceptTags(text); + text = MakeLowerCaseExceptTags(text, subtitleCulture); } else if (FixMakeProperCase) { @@ -312,15 +315,19 @@ private string Fix(string original, string lastLine, List nameList, Cult return text; } - private string MakeUpperCaseExceptTags(string text) + // The case conversion follows the SUBTITLE's language, not the machine's: char.ToUpper / + // char.ToLower without a culture use CurrentCulture, so on a Turkish system every "i" in + // an English subtitle became "I-with-dot" and every "I" became a dotless "i". + private string MakeUpperCaseExceptTags(string text, CultureInfo culture) { if (string.IsNullOrEmpty(text)) { return text; } - return string.Create(text.Length, text, (span, src) => + return string.Create(text.Length, (text, culture), (span, state) => { + var (src, ci) = state; var insideAngle = false; var insideCurly = false; @@ -344,20 +351,21 @@ private string MakeUpperCaseExceptTags(string text) insideCurly = false; } - span[i] = insideAngle || insideCurly ? c : char.ToUpper(c); + span[i] = insideAngle || insideCurly ? c : char.ToUpper(c, ci); } }); } - private string MakeLowerCaseExceptTags(string text) + private string MakeLowerCaseExceptTags(string text, CultureInfo culture) { if (string.IsNullOrEmpty(text)) { return text; } - return string.Create(text.Length, text, (span, src) => + return string.Create(text.Length, (text, culture), (span, state) => { + var (src, ci) = state; var insideAngle = false; var insideCurly = false; @@ -381,7 +389,7 @@ private string MakeLowerCaseExceptTags(string text) insideCurly = false; } - span[i] = insideAngle || insideCurly ? c : char.ToLower(c); + span[i] = insideAngle || insideCurly ? c : char.ToLower(c, ci); } }); } diff --git a/src/libse/Common/HtmlUtil.cs b/src/libse/Common/HtmlUtil.cs index a251c2e7a03..2bcd8280420 100644 --- a/src/libse/Common/HtmlUtil.cs +++ b/src/libse/Common/HtmlUtil.cs @@ -879,6 +879,14 @@ public static bool IsTextFormattable(in string text) /// A string with corrected italic tags. public static string FixInvalidItalicTags(string input) { + // Every transformation below requires a '<' somewhere in the text (the "{\...}" + // prefix is only sliced off to be re-prepended verbatim), and most subtitle lines + // carry no tags at all - so skip the ~50 full-string Replace scans for those. + if (string.IsNullOrEmpty(input) || input.IndexOf('<') < 0) + { + return input; + } + var text = input; var preTags = string.Empty; @@ -1099,7 +1107,9 @@ int CalculateEarlyInsertIndex(string s) text = beginTag + text + endTag; } - if (italicBeginTagCount == 0 && italicEndTagCount == 2) + // "else if": the counts above are stale after the block just ran, so this used to undo + // it - rewriting the closing tag it had just added into a second opening tag. + else if (italicBeginTagCount == 0 && italicEndTagCount == 2) { var firstIndex = text.IndexOf(endTag, StringComparison.Ordinal); text = text.Remove(firstIndex, endTag.Length).Insert(firstIndex, beginTag); @@ -1176,7 +1186,11 @@ int CalculateEarlyInsertIndex(string s) //- You think they're they gone? //- That can't be. - if (italicBeginTagCount == 3 && italicEndTagCount == 1 && noOfLines == 2) + // GetNumberOfLines counts '\n', so noOfLines is 2 for a text broken with a bare "\n" + // too - and on Windows IndexOf(Environment.NewLine) then returns -1 and Substring + // threw. The sibling branches in this method all guard the index the same way. + if (italicBeginTagCount == 3 && italicEndTagCount == 1 && noOfLines == 2 && + text.IndexOf(Environment.NewLine, StringComparison.Ordinal) > 0) { var newLineIdx = text.IndexOf(Environment.NewLine, StringComparison.Ordinal); var firstLine = text.Substring(0, newLineIdx).Trim(); @@ -1592,14 +1606,14 @@ public static string RemoveColorTags(string input) endIndex = s.IndexOf("< /font>", match.Index - 5, StringComparison.OrdinalIgnoreCase); if (endIndex >= 0) { - s = s.Remove(endIndex, 7); + s = s.Remove(endIndex, 8); } else { endIndex = s.IndexOf("", match.Index - 5, StringComparison.OrdinalIgnoreCase); if (endIndex >= 0) { - s = s.Remove(endIndex, 7); + s = s.Remove(endIndex, 8); } } } diff --git a/src/libse/Common/ListExtensions.cs b/src/libse/Common/ListExtensions.cs index 9eb5f54ea7e..508bd3526a3 100644 --- a/src/libse/Common/ListExtensions.cs +++ b/src/libse/Common/ListExtensions.cs @@ -45,11 +45,17 @@ public static T FirstOnOrBefore(this List orderedList, T target, T default public static double FirstOnOrAfter(this List orderedList, double target, double maxDifference, double defaultValue) { int index = orderedList.BinarySearch(target); - if (index < 0) + if (index >= 0) { - index = ~index; // Get the bitwise complement to find the index of the first element greater than the target + // An exact hit is always the answer. The tolerance probe below used to run for it + // too and could return the PREVIOUS element - so with shot changes [9.98, 10.0] + // and a cue already snapped to 10.0, "the next shot change" came back as 9.98, + // 20 ms earlier. The generic overloads above only rewrite index when it is < 0. + return orderedList[index]; } + index = ~index; // Get the bitwise complement to find the index of the first element greater than the target + if ((index - 1) >= 0 && target - orderedList[index - 1] <= maxDifference) { return orderedList[index - 1]; @@ -66,11 +72,15 @@ public static double FirstOnOrAfter(this List orderedList, double target public static double FirstOnOrBefore(this List orderedList, double target, double maxDifference, double defaultValue) { int index = orderedList.BinarySearch(target); - if (index < 0) + if (index >= 0) { - index = ~index - 1; // Get the bitwise complement to find the index of the last element smaller than the target + // Exact hit wins - see the note in FirstOnOrAfter. Here the probe returned the + // element AFTER the target instead. + return orderedList[index]; } + index = ~index - 1; // Get the bitwise complement to find the index of the last element smaller than the target + if (index + 1 < orderedList.Count && orderedList[index + 1] - target <= maxDifference) { return orderedList[index + 1]; diff --git a/src/libse/Common/NetflixImsc11JapaneseToAss.cs b/src/libse/Common/NetflixImsc11JapaneseToAss.cs index be29f4f9413..ace31eadd3a 100644 --- a/src/libse/Common/NetflixImsc11JapaneseToAss.cs +++ b/src/libse/Common/NetflixImsc11JapaneseToAss.cs @@ -97,6 +97,34 @@ private static bool IsFullWidth(char ch) ch >= 0xFFE0 && ch <= 0xFFE6); } + /// + /// True when the subtitle carries the Japanese profile markup libass cannot draw by itself. + /// The format alone does not say: "Lambda Cap" decodes furigana, emphasis marks and + /// tate-chu-yoko to the same markup, and its control codes would otherwise end up on screen + /// as literal text (issue #14165). + /// + public static bool HasJapaneseMarkup(Subtitle subtitle) + { + if (subtitle == null) + { + return false; + } + + foreach (var p in subtitle.Paragraphs) + { + var text = p.Text; + if (!string.IsNullOrEmpty(text) && text.Contains('<') && + (text.Contains("", StringComparison.Ordinal))) + { + return true; + } + } + + return false; + } + public static List SplitToAssRenderLines(Paragraph p, int width, int height) { var metrics = new Metrics(height); @@ -183,6 +211,16 @@ private static List MakeHorizontalParagraphs(Paragraph p, int width, i += 4; italicOn = false; } + else if (line.Substring(i).StartsWith("", StringComparison.Ordinal)) + { + // Tate-chu-yoko only means something in a vertical column - horizontal digits + // are already horizontal, so just drop the tags (issue #14165). + i += "".Length; + } + else if (line.Substring(i).StartsWith("", StringComparison.Ordinal)) + { + i += "".Length; + } else if (line.Substring(i).StartsWith("', i); diff --git a/src/libse/Common/NikseBitmap.cs b/src/libse/Common/NikseBitmap.cs index 15ac4246a4e..6868be6fcc5 100644 --- a/src/libse/Common/NikseBitmap.cs +++ b/src/libse/Common/NikseBitmap.cs @@ -304,28 +304,51 @@ public SKColor ConvertToFourColors(SKColor background, SKColor pattern, SKColor emphasis2Buffer[3] = emphasis2.Alpha; } - for (int i = 0; i < _bitmapData.Length; i += 4) + // One 32-bit read and one 32-bit write per pixel. The loop used to index the byte + // array eight times per candidate colour and then call Buffer.BlockCopy to store the + // four result bytes - a full call per pixel of every exported VobSub/DVD image. + var packedBackground = PackBgra(backgroundBuffer[0], backgroundBuffer[1], backgroundBuffer[2], backgroundBuffer[3]); + var packedPattern = PackBgra(patternBuffer[0], patternBuffer[1], patternBuffer[2], patternBuffer[3]); + var packedEmphasis1 = PackBgra(emphasis1Buffer[0], emphasis1Buffer[1], emphasis1Buffer[2], emphasis1Buffer[3]); + var packedEmphasis2 = PackBgra(emphasis2Buffer[0], emphasis2Buffer[1], emphasis2Buffer[2], emphasis2Buffer[3]); + var backgroundIsTransparent = backgroundBuffer[3] == 0; + + // The four reference colours as plain locals: read out of the byte arrays they were + // indexed out of twelve times per pixel, each with its own bounds check. + int patternB = patternBuffer[0], patternG = patternBuffer[1], patternR = patternBuffer[2], patternA = patternBuffer[3]; + int e1B = emphasis1Buffer[0], e1G = emphasis1Buffer[1], e1R = emphasis1Buffer[2], e1A = emphasis1Buffer[3]; + int e2B = emphasis2Buffer[0], e2G = emphasis2Buffer[1], e2R = emphasis2Buffer[2], e2A = emphasis2Buffer[3]; + + var pixels = MemoryMarshal.Cast(_bitmapData.AsSpan()); + + for (var p = 0; p < pixels.Length; p++) { - int smallestDiff = 10000; - byte[] buffer = backgroundBuffer; - if (backgroundBuffer[3] == 0 && _bitmapData[i + 3] < 10) // transparent + var pixel = pixels[p]; + int blue = (byte)pixel; + int green = (byte)(pixel >> 8); + int red = (byte)(pixel >> 16); + int alpha = (byte)(pixel >> 24); + + var smallestDiff = 10000; + var result = packedBackground; + if (backgroundIsTransparent && alpha < 10) // transparent { } else { - int patternDiff = Math.Abs(patternBuffer[0] - _bitmapData[i]) + Math.Abs(patternBuffer[1] - _bitmapData[i + 1]) + Math.Abs(patternBuffer[2] - _bitmapData[i + 2]) + Math.Abs(patternBuffer[3] - _bitmapData[i + 3]); + var patternDiff = Math.Abs(patternB - blue) + Math.Abs(patternG - green) + Math.Abs(patternR - red) + Math.Abs(patternA - alpha); if (patternDiff < smallestDiff) { smallestDiff = patternDiff; - buffer = patternBuffer; + result = packedPattern; } - int emphasis1Diff = Math.Abs(emphasis1Buffer[0] - _bitmapData[i]) + Math.Abs(emphasis1Buffer[1] - _bitmapData[i + 1]) + Math.Abs(emphasis1Buffer[2] - _bitmapData[i + 2]) + Math.Abs(emphasis1Buffer[3] - _bitmapData[i + 3]); + var emphasis1Diff = Math.Abs(e1B - blue) + Math.Abs(e1G - green) + Math.Abs(e1R - red) + Math.Abs(e1A - alpha); if (useInnerAntialize) { if (emphasis1Diff - 20 < smallestDiff) { - buffer = emphasis1Buffer; + result = packedEmphasis1; } } else @@ -333,21 +356,22 @@ public SKColor ConvertToFourColors(SKColor background, SKColor pattern, SKColor if (emphasis1Diff < smallestDiff) { smallestDiff = emphasis1Diff; - buffer = emphasis1Buffer; + result = packedEmphasis1; } - int emphasis2Diff = Math.Abs(emphasis2Buffer[0] - _bitmapData[i]) + Math.Abs(emphasis2Buffer[1] - _bitmapData[i + 1]) + Math.Abs(emphasis2Buffer[2] - _bitmapData[i + 2]) + Math.Abs(emphasis2Buffer[3] - _bitmapData[i + 3]); + var emphasis2Diff = Math.Abs(e2B - blue) + Math.Abs(e2G - green) + Math.Abs(e2R - red) + Math.Abs(e2A - alpha); if (emphasis2Diff < smallestDiff) { - buffer = emphasis2Buffer; + result = packedEmphasis2; } - else if (_bitmapData[i + 3] >= 10 && _bitmapData[i + 3] < 90) // anti-alias + else if (alpha >= 10 && alpha < 90) // anti-alias { - buffer = emphasis2Buffer; + result = packedEmphasis2; } } } - Buffer.BlockCopy(buffer, 0, _bitmapData, i, 4); + + pixels[p] = result; } if (useInnerAntialize) diff --git a/src/libse/Common/Paragraph.cs b/src/libse/Common/Paragraph.cs index d251bd8740b..4823d101e98 100644 --- a/src/libse/Common/Paragraph.cs +++ b/src/libse/Common/Paragraph.cs @@ -131,6 +131,14 @@ public double WordsPerMinute return 0; } + // Same guard as GetCharactersPerSecond: a zero or negative duration would give + // infinity/a negative rate, which poisons the min/max/average of every statistics + // report that sums this up. + if (DurationTotalMilliseconds < 1) + { + return 999; + } + return 60.0 / DurationTotalSeconds * Text.CountWords(); } } diff --git a/src/libse/Common/RegexUtils.cs b/src/libse/Common/RegexUtils.cs index 26ab1f537b9..09d6cd7f2c7 100644 --- a/src/libse/Common/RegexUtils.cs +++ b/src/libse/Common/RegexUtils.cs @@ -12,7 +12,13 @@ public static partial class RegexUtils /// matches on the UI thread that means a frozen program with no way out. SE 4 used the same five /// seconds in its find/replace helper; callers treat the timeout as "no match". /// - public static readonly TimeSpan UserPatternMatchTimeout = TimeSpan.FromSeconds(5); + /// + /// Settable only so the tests that prove the timeout works do not have to wait it out - fourteen + /// of them sat on the full five seconds, close to half the whole UI suite. Nothing in the program + /// writes it; callers read it when they build the Regex, so a test sets it before the pattern is + /// compiled and puts it back afterwards. + /// + public static TimeSpan UserPatternMatchTimeout { get; set; } = TimeSpan.FromSeconds(5); // Others classes may want to use this regex. #if NET7_0_OR_GREATER @@ -56,7 +62,7 @@ static DanishLetterI() RegexFactory(@"\bburde i (?:gøre|ikke|købe|løbe|se|sig|tage)\b"), RegexFactory(@"\bfanden vil i\?"), RegexFactory(@"\b[Ff]or ser i\b"), - RegexFactory(@"\b[Db]a i (?:ankom|forlod|fik|gik|kom|dræbte|ikke|gjorde|har|havde|så)\b"), + RegexFactory(@"\b[Dd]a i (?:ankom|forlod|fik|gik|kom|dræbte|ikke|gjorde|har|havde|så)\b"), RegexFactory(@"\b[Dd]et (?:får i|har i|må i|gør i|gjorde i)\b"), RegexFactory(@"\b[Dd]et må i (?:fandme|ikke|sgu|gerne|selv|da|faktisk)\b"), RegexFactory(@"\b[Dd]et Det kan i sgu"), @@ -144,7 +150,7 @@ static DanishLetterI() RegexFactory(@"\b[Tt]og i (?:bilen|liften|toget)\b"), RegexFactory(@"\b[Tt]ræder i frem\b"), RegexFactory(@"\b[Tt]ror i (?:at|det|jeg|på|virkelig)\b"), - RegexFactory(@"\b[Tr]ror i(?:, | på\b)"), + RegexFactory(@"\b[Tt]ror i(?:, | på\b)"), RegexFactory(@"\b[Vv]ar i blevet\b"), RegexFactory(@"\b[Vv]ed i (?:alle|allesammen|er|ikke|hvad|hvem|hvor|hvorfor|hvordan|var|ville|har|havde|hvem|hvad|hvor|mente|tror)\b"), RegexFactory(@"\b[Vv]enter i på\b"), @@ -291,7 +297,7 @@ public static string ReplaceNewLineSafe(Regex regularExpression, string text, st /// \n left behind by the match normalization above was written as a physical newline /// inside the Dialogue line, corrupting the saved file on Windows (#12620). /// - private static string RestorePlatformNewLines(string text) + public static string RestorePlatformNewLines(string text) { if (Environment.NewLine == "\n" || text.IndexOf('\n') < 0) { diff --git a/src/libse/Common/RichTextToPlainText.cs b/src/libse/Common/RichTextToPlainText.cs index 481def99c2e..876568a465b 100644 --- a/src/libse/Common/RichTextToPlainText.cs +++ b/src/libse/Common/RichTextToPlainText.cs @@ -146,13 +146,18 @@ public static string ConvertToText(string inputRtf) // Push state stack.Push(new StackEntry(ucskip, ignorable)); } - else + else if (stack.Count > 0) { // Pop state StackEntry entry = stack.Pop(); ucskip = entry.NumberOfCharactersToSkip; ignorable = entry.Ignorable; } + + // else: a '}' with no matching '{'. Popping an empty stack threw + // InvalidOperationException, and because ~20 readers run their input through + // FromRtf(), a single unbalanced brace anywhere aborted format detection for + // the whole file instead of reporting an unknown format. Ignore the brace. } else if (character.Length > 0) // \x (not a letter) { diff --git a/src/libse/Common/SeJsonParser.cs b/src/libse/Common/SeJsonParser.cs index 269997dc0af..11cf94b99c1 100644 --- a/src/libse/Common/SeJsonParser.cs +++ b/src/libse/Common/SeJsonParser.cs @@ -86,6 +86,15 @@ public List GetAllTagsByNameAsStrings(string content, string name) { i++; var end = content.IndexOf('"', i); + if (end < 0) + { + // Unterminated name - the file is truncated or damaged. Bail out + // instead of letting Substring throw out of the reader (and out of + // IsMine, which format detection calls unguarded). + Errors.Add($"Fatal - unterminated string at position {i}"); + return list; + } + objectName = content.Substring(i, end - i).Trim(); var colon = content.IndexOf(':', end); if (colon < 0) @@ -151,7 +160,9 @@ public List GetAllTagsByNameAsStrings(string content, string name) Errors.Add($"Fatal - expected char \" after position {endSeek}"); return list; } - skip = content[end - 1] == '\\'; + // end can be 0 when the value's opening quote is the very first + // character (a truncated file), and content[-1] threw. + skip = end > 0 && content[end - 1] == '\\'; if (skip) { endSeek = end + 1; @@ -247,7 +258,10 @@ public List GetAllTagsByNameAsStrings(string content, string name) else if ("+-0123456789".IndexOf(ch) >= 0) { sb.Clear(); - while (IsNumberChar(content[i]) && i < max) + // Bounds check FIRST: this indexed content[i] before testing i < max, + // so a file whose last characters are a number (a truncated one) + // threw IndexOutOfRangeException straight out of the reader. + while (i < max && IsNumberChar(content[i])) { sb.Append(content[i]); i++; @@ -378,6 +392,15 @@ public List GetArrayElementsByName(string content, string name) { i++; var end = content.IndexOf('"', i); + if (end < 0) + { + // Unterminated name - the file is truncated or damaged. Bail out + // instead of letting Substring throw out of the reader (and out of + // IsMine, which format detection calls unguarded). + Errors.Add($"Fatal - unterminated string at position {i}"); + return list; + } + objectName = content.Substring(i, end - i).Trim(); var colon = content.IndexOf(':', end); if (colon < 0) @@ -443,7 +466,9 @@ public List GetArrayElementsByName(string content, string name) Errors.Add($"Fatal - expected char \" after position {endSeek}"); return list; } - skip = content[end - 1] == '\\'; + // end can be 0 when the value's opening quote is the very first + // character (a truncated file), and content[-1] threw. + skip = end > 0 && content[end - 1] == '\\'; if (skip) { endSeek = end + 1; @@ -684,6 +709,15 @@ public List GetArrayElements(string content) { i++; var end = content.IndexOf('"', i); + if (end < 0) + { + // Unterminated name - the file is truncated or damaged. Bail out + // instead of letting Substring throw out of the reader (and out of + // IsMine, which format detection calls unguarded). + Errors.Add($"Fatal - unterminated string at position {i}"); + return list; + } + objectName = content.Substring(i, end - i).Trim(); var colon = content.IndexOf(':', end); if (colon < 0) @@ -749,7 +783,9 @@ public List GetArrayElements(string content) Errors.Add($"Fatal - expected char \" after position {endSeek}"); return list; } - skip = content[end - 1] == '\\'; + // end can be 0 when the value's opening quote is the very first + // character (a truncated file), and content[-1] threw. + skip = end > 0 && content[end - 1] == '\\'; if (skip) { endSeek = end + 1; @@ -826,7 +862,10 @@ public List GetArrayElements(string content) } else if ("+-0123456789".IndexOf(ch) >= 0) { - while (IsNumberChar(content[i]) && i < max) + // Bounds check FIRST: this indexed content[i] before testing i < max, + // so a file whose last characters are a number (a truncated one) + // threw IndexOutOfRangeException straight out of the reader. + while (i < max && IsNumberChar(content[i])) { i++; } @@ -978,6 +1017,13 @@ public string GetFirstObject(string content, string name) { i++; var end = content.IndexOf('"', i); + if (end < 0) + { + // Unterminated name - see the list-returning overloads above. + Errors.Add($"Fatal - unterminated string at position {i}"); + return string.Empty; + } + objectName = content.Substring(i, end - i).Trim(); var colon = content.IndexOf(':', end); if (colon < 0) @@ -1052,7 +1098,9 @@ public string GetFirstObject(string content, string name) Errors.Add($"Fatal - expected char \" after position {endSeek}"); return string.Empty; } - skip = content[end - 1] == '\\'; + // end can be 0 when the value's opening quote is the very first + // character (a truncated file), and content[-1] threw. + skip = end > 0 && content[end - 1] == '\\'; if (skip) { endSeek = end + 1; @@ -1137,7 +1185,10 @@ public string GetFirstObject(string content, string name) else if ("+-0123456789".IndexOf(ch) >= 0) { sb.Clear(); - while (IsNumberChar(content[i]) && i < max) + // Bounds check FIRST: this indexed content[i] before testing i < max, + // so a file whose last characters are a number (a truncated one) + // threw IndexOutOfRangeException straight out of the reader. + while (i < max && IsNumberChar(content[i])) { sb.Append(content[i]); i++; @@ -1280,6 +1331,15 @@ public List GetRootElements(string content) { i++; var end = content.IndexOf('"', i); + if (end < 0) + { + // Unterminated name - the file is truncated or damaged. Bail out + // instead of letting Substring throw out of the reader (and out of + // IsMine, which format detection calls unguarded). + Errors.Add($"Fatal - unterminated string at position {i}"); + return list; + } + objectName = content.Substring(i, end - i).Trim(); var colon = content.IndexOf(':', end); if (colon < 0) @@ -1364,7 +1424,9 @@ public List GetRootElements(string content) Errors.Add($"Fatal - expected char \" after position {endSeek}"); return list; } - skip = content[end - 1] == '\\'; + // end can be 0 when the value's opening quote is the very first + // character (a truncated file), and content[-1] threw. + skip = end > 0 && content[end - 1] == '\\'; if (skip) { endSeek = end + 1; diff --git a/src/libse/Common/ShotChangeHelper.cs b/src/libse/Common/ShotChangeHelper.cs index 2fb452af3b1..2037d907cfc 100644 --- a/src/libse/Common/ShotChangeHelper.cs +++ b/src/libse/Common/ShotChangeHelper.cs @@ -123,11 +123,29 @@ public static void SaveShotChanges(string videoFileName, string shotChangeDirect /// Directory where shot change files are stored public static void DeleteShotChanges(string videoFileName, string shotChangeDirectory) { + // Delete every file FromDisk could load, not just the canonical name: the hash is + // computed from the video's CONTENT, so a renamed video still finds its old + // "{hash}_{oldName}.shotchanges" through the wildcard in FindShotChangesFileName - + // while this method used to build "{hash}_{newName}.shotchanges", find nothing, and + // leave the shot changes to come straight back. var shotChangesFileName = GetShotChangesFileName(videoFileName, shotChangeDirectory); if (File.Exists(shotChangesFileName)) { File.Delete(shotChangesFileName); } + + var hash = MovieHasher.GenerateHash(videoFileName); + foreach (var fileName in Directory.GetFiles(shotChangeDirectory, $"{hash}*.shotchanges")) + { + try + { + File.Delete(fileName); + } + catch + { + // a file we cannot delete must not abort the rest + } + } } diff --git a/src/libse/Common/SsaStyle.cs b/src/libse/Common/SsaStyle.cs index 77eecc7ff28..836d67f9c60 100644 --- a/src/libse/Common/SsaStyle.cs +++ b/src/libse/Common/SsaStyle.cs @@ -291,10 +291,6 @@ public string ToRawAss(string styleFormat = DefaultAssStyleFormat) { sb.Append('1'); } - else if (f == "strikeout") - { - sb.Append('0'); - } else if (f == "scalex") { sb.Append(ScaleX.ToString(CultureInfo.InvariantCulture)); diff --git a/src/libse/Common/StringExtensions.cs b/src/libse/Common/StringExtensions.cs index 0cb2324386d..eea2d9adcf9 100644 --- a/src/libse/Common/StringExtensions.cs +++ b/src/libse/Common/StringExtensions.cs @@ -35,7 +35,7 @@ public static bool LineEndsWithHtmlTag(this string text, bool threeLengthTag, bo } var len = text.Length; - if (len < 6 || text[len - 1] != '>') + if (len < 5 || text[len - 1] != '>') { return false; } @@ -46,7 +46,7 @@ public static bool LineEndsWithHtmlTag(this string text, bool threeLengthTag, bo return true; } - if (includeFont && len > 8 && text[len - 7] == '<' && text[len - 6] == '/') + if (includeFont && len >= 8 && text[len - 7] == '<' && text[len - 6] == '/') { return true; } @@ -492,7 +492,7 @@ public static bool ContainsNumber(this string s) var len = s.Length; for (var i = 0; i < len; i++) { - if (CharUtils.IsDigit(s[i])) + if (CharUtils.IsAsciiDigit(s[i])) { return true; } @@ -795,7 +795,11 @@ private static List> RemoveAndSaveTags(string input, S && index + 1 < input.Length && (input[index + 1] == 'N' || input[index + 1] == 'n' || input[index + 1] == 'h')) { - tags.Add(new KeyValuePair(index, input.Substring(index, 2))); + // sb.Length, not index: every other tag is recorded in tag-stripped + // coordinates, which is what RestoreSavedAndRemovedTags re-inserts into. Using + // the input offset put "\N" back in the wrong place whenever a tag preceded it + // ("{\an8}One\Ntwo" toggled to "{\an8}ONETWO\N"). + tags.Add(new KeyValuePair(sb.Length, input.Substring(index, 2))); skipNext = true; continue; } diff --git a/src/libse/Common/StrippableText.cs b/src/libse/Common/StrippableText.cs index add2bd48af9..6d41ad61a25 100644 --- a/src/libse/Common/StrippableText.cs +++ b/src/libse/Common/StrippableText.cs @@ -357,7 +357,12 @@ public void FixCasing(IEnumerable nameList, bool changeNameCases, bool m var idx = sb.ToString().IndexOf('['); if (s == ']' && idx > 1) { // I [Motor roaring] love you! - var temp = sb.ToString(0, idx - 1).Trim(); + // The text before '[' is [0, idx), so the length is idx - the + // "- 1" dropped the last character before the bracket, which is + // exactly the one this test looks at. "Yes.[Motor roaring] hello." + // saw "Yes" (a letter) instead of "Yes." and left "hello" + // lowercase. The documented "I [Motor roaring]" case is unchanged. + var temp = sb.ToString(0, idx).Trim(); if (temp.Length > 0 && !char.IsLetterOrDigit(temp[temp.Length - 1])) { lastWasBreak = true; diff --git a/src/libse/Common/Subtitle.cs b/src/libse/Common/Subtitle.cs index 30311410bb2..c25a18a7ce3 100644 --- a/src/libse/Common/Subtitle.cs +++ b/src/libse/Common/Subtitle.cs @@ -291,7 +291,7 @@ public SubtitleFormat LoadSubtitle(string fileName, out Encoding encoding, Encod var ext = Path.GetExtension(fileName).ToLowerInvariant(); foreach (var subtitleFormat in SubtitleFormat.AllSubtitleFormats.Where(p => p.Extension.Equals(ext, StringComparison.OrdinalIgnoreCase) && !p.Name.StartsWith("Unknown", StringComparison.Ordinal))) { - if (subtitleFormat.IsMine(lines, fileName)) + if (IsFormatMine(subtitleFormat, lines, fileName)) { return FinalizeFormat(fileName, batchMode, sourceFrameRate, lines, subtitleFormat, loadSubtitle); } @@ -299,7 +299,7 @@ public SubtitleFormat LoadSubtitle(string fileName, out Encoding encoding, Encod foreach (var subtitleFormat in SubtitleFormat.AllSubtitleFormats.Where(p => !p.Extension.Equals(ext, StringComparison.OrdinalIgnoreCase) || p.Name.StartsWith("Unknown", StringComparison.Ordinal))) { - if (subtitleFormat.IsMine(lines, fileName)) + if (IsFormatMine(subtitleFormat, lines, fileName)) { return FinalizeFormat(fileName, batchMode, sourceFrameRate, lines, subtitleFormat, loadSubtitle); } @@ -313,6 +313,25 @@ public SubtitleFormat LoadSubtitle(string fileName, out Encoding encoding, Encod return null; } + /// + /// Asks one format whether the lines are its own. A damaged or truncated file can make a + /// reader throw (an unclosed xml element, a json string with no end quote, ...), and this + /// runs for EVERY format when a file is opened - so a throw here used to take down the + /// whole open instead of moving on to the next format. + /// + private static bool IsFormatMine(SubtitleFormat subtitleFormat, List lines, string fileName) + { + try + { + return subtitleFormat.IsMine(lines, fileName); + } + catch (Exception exception) + { + System.Diagnostics.Debug.WriteLine($"{subtitleFormat.Name}.IsMine failed: {exception.Message}"); + return false; + } + } + private static List ReadLinesFromFile(string fileName, Encoding useThisEncoding, out Encoding encoding) { StreamReader sr; @@ -383,6 +402,43 @@ public void ChangeFrameRate(double oldFrameRate, double newFrameRate) } } + /// + /// Frame-rate conversion that lands on whole milliseconds. Scales the start and the + /// duration rather than the start and the end independently, so two cues of equal length + /// keep equal lengths afterwards - scaling both ends separately rounds them apart (#14056). + /// keeps the fractional result for callers + /// that want it. + /// + public void ChangeFrameRateWholeMilliseconds(double oldFrameRate, double newFrameRate) + { + var factor = SubtitleFormat.GetFrameForCalculation(oldFrameRate) / SubtitleFormat.GetFrameForCalculation(newFrameRate); + Paragraph previous = null; + var previousOriginalEndMs = 0d; + foreach (var p in Paragraphs) + { + var originalStartMs = p.StartTime.TotalMilliseconds; + var originalEndMs = p.EndTime.TotalMilliseconds; + + var newStartMs = Math.Round(originalStartMs * factor, MidpointRounding.AwayFromZero); + var newDurationMs = Math.Round((originalEndMs - originalStartMs) * factor, MidpointRounding.AwayFromZero); + p.StartTime.TotalMilliseconds = newStartMs; + p.EndTime.TotalMilliseconds = newStartMs + newDurationMs; + + // The two roundings can push the previous end one millisecond past this start, + // turning a clean join into an overlap the source never had. Overlaps that were + // already in the source are left as they were. + if (previous != null && + previousOriginalEndMs <= originalStartMs && + previous.EndTime.TotalMilliseconds > p.StartTime.TotalMilliseconds) + { + previous.EndTime.TotalMilliseconds = p.StartTime.TotalMilliseconds; + } + + previous = p; + previousOriginalEndMs = originalEndMs; + } + } + public void AdjustDisplayTimeUsingPercent(double percent, List selectedIndexes, List shotChanges = null, bool enforceDurationLimits = true) { // List.Contains per paragraph made this O(paragraphs * selection) - quadratic with @@ -412,8 +468,10 @@ public void AdjustDisplayTimeUsingPercent(double percent, List selectedInde } } - // handle overlap with next - if (newEndMilliseconds > nextStartMilliseconds) + // handle overlap with next - measured against the minimum gap, the way + // AdjustDisplayTimeUsingMilliseconds does. Testing only for a hard overlap + // let a new end land inside the gap (or exactly on the next start). + if (newEndMilliseconds > nextStartMilliseconds - Configuration.Settings.General.MinimumMillisecondsBetweenLines) { newEndMilliseconds = nextStartMilliseconds - Configuration.Settings.General.MinimumMillisecondsBetweenLines; } @@ -554,7 +612,16 @@ public void RecalculateDisplayTime(double maxCharactersPerSecond, int index, dou if (p.GetCharactersPerSecond() > maxCharactersPerSecond) { var numberOfCharacters = (double)p.Text.CountCharacters(true); - var maxDurationMilliseconds = (numberOfCharacters / maxCharactersPerSecond) * 1000.0; + // Whole milliseconds, rounded up: the fractional value truncates on save to one ms + // short, which is just over the maximum this branch exists to enforce (#14418). + // Verified with the same division GetCharactersPerSecond performs rather than a + // plain Math.Ceiling, so binary-fraction noise does not add a needless millisecond. + var maxDurationMilliseconds = Math.Floor(numberOfCharacters / maxCharactersPerSecond * 1000.0); + if (numberOfCharacters / (maxDurationMilliseconds / 1000.0) > maxCharactersPerSecond) + { + maxDurationMilliseconds += 1; + } + p.EndTime.TotalMilliseconds = p.StartTime.TotalMilliseconds + maxDurationMilliseconds; } diff --git a/src/libse/Common/SubtitlePositionToAssa.cs b/src/libse/Common/SubtitlePositionToAssa.cs index efded0c47ba..f7460e1ff02 100644 --- a/src/libse/Common/SubtitlePositionToAssa.cs +++ b/src/libse/Common/SubtitlePositionToAssa.cs @@ -1,4 +1,4 @@ -using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Core.SubtitleFormats; using System; using System.Collections.Generic; using System.Globalization; @@ -69,6 +69,11 @@ public static bool ApplyPositions(Subtitle subtitle, string sourceHeader, int pl return ApplyEbuTeletextRows(subtitle, sourceHeader, playResY, usePositions); } + if (DvbTeletext.IsDvbTeletextHeader(sourceHeader)) + { + return ApplyDvbTeletextRows(subtitle, playResY, usePositions); + } + var regions = GetTtmlRegions(sourceHeader); if (regions != null) { @@ -121,28 +126,59 @@ private static bool IsEbuStlHeader(string header) /// private static bool ApplyEbuTeletextRows(Subtitle subtitle, string header, int playResY, bool usePositions) { - var rows = GetEbuRowCount(header); - var newLineRows = Math.Max(1, Configuration.Settings.SubtitleSettings.EbuStlNewLineRows); + return ApplyTeletextRows( + subtitle, + GetEbuRowCount(header), + Math.Max(1, Configuration.Settings.SubtitleSettings.EbuStlNewLineRows), + Configuration.Settings.SubtitleSettings.EbuStlMarginBottom, + GetEbuJustificationOffset(), + playResY, + usePositions); + } + + /// + /// DVB teletext places subtitles the same way, but with the writer's fixed geometry + /// instead of the EBU save options: 23 rows, the default bottom row 22 (a double height + /// row covers 22 and 23), two rows per extra line, and the alignment coming off the + /// {\an} tags alone - see ManzanitaTeletextWriter.GetRowNumbers. + /// + private static bool ApplyDvbTeletextRows(Subtitle subtitle, int playResY, bool usePositions) + { + return ApplyTeletextRows(subtitle, rows: 23, newLineRows: 2, marginBottom: 1, + justificationOffset: 0, playResY, usePositions); + } + + private static bool ApplyTeletextRows(Subtitle subtitle, int rows, int newLineRows, int marginBottom, int justificationOffset, int playResY, bool usePositions) + { var applied = false; foreach (var p in subtitle.Paragraphs) { - if (string.IsNullOrEmpty(p.MarginV)) + var hasRow = int.TryParse(p.MarginV, NumberStyles.Integer, CultureInfo.InvariantCulture, out var row) && + row >= 1 && + row <= rows; + + // A row number is not a pixel margin - leaving one behind would nudge the line by + // a near random amount. + p.MarginV = null; + + if (!usePositions) { continue; } - if (!usePositions || - !int.TryParse(p.MarginV, NumberStyles.Integer, CultureInfo.InvariantCulture, out var row) || - row < 1 || row > rows) + var lineCount = Math.Max(1, Utilities.GetNumberOfLines(p.Text)); + if (!hasRow) { - // A row number is not a pixel margin - leaving it would nudge every line - // by a near random amount. - p.MarginV = null; - continue; + // Only a subtitle that was read from an STL file carries teletext rows. Anything + // typed in or converted from another format has none, and used to be skipped + // outright - so for the far more common case the justification and the vertical + // margins of the EBU options dialog changed nothing on screen at all. Put the + // line where Ebu.Save would put it: counted up from the bottom margin. + row = Math.Max(1, rows - marginBottom - (lineCount - 1) * newLineRows); } - var lastRow = row + (Math.Max(1, Utilities.GetNumberOfLines(p.Text)) - 1) * newLineRows; + var lastRow = row + (lineCount - 1) * newLineRows; var alignment = GetLeadingAlignment(p.Text); if (alignment == 0) { @@ -159,6 +195,11 @@ private static bool ApplyEbuTeletextRows(Subtitle subtitle, string header, int p alignment = 5; } + // The row says how far down the line sits, the justification says which way it + // is aligned - without this every line stays centered no matter what the EBU + // options dialog is set to. + alignment += justificationOffset; + p.Text = "{\\an" + alignment.ToString(CultureInfo.InvariantCulture) + "}" + p.Text; } @@ -181,6 +222,28 @@ private static bool ApplyEbuTeletextRows(Subtitle subtitle, string header, int p return applied; } + /// + /// Turns the EBU justification code into a shift of the ASSA alignment column: the codes + /// are 0 = unchanged, 1 = left, 2 = centered and 3 = right, and each centered alignment + /// (2, 5 and 8) has its left neighbour one below it and its right one above. + /// + /// + /// Read from the EBU STL settings, next to the margins and the teletext flags this method's + /// caller uses. It used to come off , which is the carrier that + /// takes the code to the writer, not a setting: it is null until a save or the save options + /// dialog creates one - so a preview before either showed everything centered - and batch + /// convert overwrites it with its own job's code. + /// + private static int GetEbuJustificationOffset() + { + switch (Configuration.Settings.SubtitleSettings.EbuStlJustificationCode) + { + case 1: return -1; + case 3: return 1; + default: return 0; + } + } + private static int GetEbuRowCount(string header) { try diff --git a/src/libse/Common/TextEffect/KaraokeWordTransform.cs b/src/libse/Common/TextEffect/KaraokeWordTransform.cs index 08281744b65..6e2d2de8b65 100644 --- a/src/libse/Common/TextEffect/KaraokeWordTransform.cs +++ b/src/libse/Common/TextEffect/KaraokeWordTransform.cs @@ -26,8 +26,9 @@ public override string[] Transform(string text) var karaoke = text.Substring(0, i) + fontClose + text.Substring(i); lastFontCloseIdx = i; result.Add(karaoke); - // skip only whitespace - while (i < len && char.IsWhiteSpace(text[i])) + // skip only whitespace - land on the last whitespace char so the loop's own + // i++ moves onto the next word (or tag) instead of past it + while (i + 1 < len && char.IsWhiteSpace(text[i + 1])) { i++; } diff --git a/src/libse/Common/TextSplit.cs b/src/libse/Common/TextSplit.cs index 085cd038a21..8fe8bdd733c 100644 --- a/src/libse/Common/TextSplit.cs +++ b/src/libse/Common/TextSplit.cs @@ -195,7 +195,7 @@ public static List SplitText(string text, int numberOfParts) for (var i = 0; i < numberOfParts - 1; i++) { - var endIndex = FindNearestSpaceIndex(text, startIndex + partLength); + var endIndex = FindNearestSpaceIndex(text, startIndex + partLength, startIndex); if (endIndex == -1) { @@ -227,10 +227,62 @@ public static List SplitText(string text, int numberOfParts) return parts; } - static int FindNearestSpaceIndex(string text, int startIndex) + /// + /// A space to break this chunk on: the last one at or before , + /// but never one belonging to an already-consumed chunk. Returning such a space made the + /// computed length non-positive, which appended an empty part and swallowed the rest of + /// the text into the following one. + /// + static int FindNearestSpaceIndex(string text, int targetIndex, int minIndex) { - var spaceIndex = text.LastIndexOf(' ', Math.Min(startIndex, text.Length - 1)); - return spaceIndex; + var clamped = Math.Min(targetIndex, text.Length - 1); + var spaceIndex = text.LastIndexOf(' ', clamped); + if (spaceIndex > minIndex) + { + return spaceIndex; + } + + // No space inside this chunk - take the next one after it rather than break early. + return text.IndexOf(' ', clamped); + } + + /// + /// A period between two digits ("04.00 uur", "1.500 euro") is a decimal/time separator, not a + /// sentence ending - treating it as one broke a subtitle mid-number (issue #14230). + /// + private static bool IsSentenceEndingAt(string text, int index) + { + return text[index] != '.' || + index <= 0 || + index >= text.Length - 1 || + !char.IsDigit(text[index - 1]) || + !char.IsDigit(text[index + 1]); + } + + private static int LastSentenceEndingIndex(string text) + { + for (var i = text.Length - 1; i >= 0; i--) + { + if (Array.IndexOf(PeriodQuestionExclamation, text[i]) >= 0 && IsSentenceEndingAt(text, i)) + { + return i; + } + } + + return -1; + } + + private static int FirstSentenceEndingIndex(string text) + { + for (var i = 0; i < text.Length; i++) + { + if (Array.IndexOf(PeriodQuestionExclamation, text[i]) >= 0 && IsSentenceEndingAt(text, i)) + { + return i; + } + } + + return -1; } public static Subtitle TryForWholeSentences(Subtitle inputSubtitle, string language, int lineMaxLength) @@ -246,7 +298,10 @@ public static Subtitle TryForWholeSentences(Subtitle inputSubtitle, string langu if (p == null || next == null || - p.EndTime.TotalMilliseconds - next.StartTime.TotalMilliseconds > 100 || + // The gap is next.Start - p.End; written the other way round it is positive + // only when the two overlap, so the "don't move a word across a long pause" + // guard never fired and a word could jump a minute of silence. + next.StartTime.TotalMilliseconds - p.EndTime.TotalMilliseconds > 100 || p.Text.Contains('<') || next.Text.Contains('<') || !(p.Text.Contains('.') || p.Text.Contains('?') || p.Text.Contains('!') || next.Text.Contains('.') || next.Text.Contains('?') || next.Text.Contains('!')) || @@ -263,7 +318,7 @@ public static Subtitle TryForWholeSentences(Subtitle inputSubtitle, string langu } // check for period in last part of current - var lastPeriodIdx = p.Text.LastIndexOfAny(PeriodQuestionExclamation); + var lastPeriodIdx = LastSentenceEndingIndex(p.Text); if (lastPeriodIdx > 3 && lastPeriodIdx > p.Text.Length - maxMoveChunkSize) { var newCurrentText = p.Text.Substring(0, lastPeriodIdx + 1).Trim(); @@ -304,7 +359,7 @@ public static Subtitle TryForWholeSentences(Subtitle inputSubtitle, string langu } // check for period in beginning of next - var firstPeriodIdx = next.Text.IndexOfAny(PeriodQuestionExclamation); + var firstPeriodIdx = FirstSentenceEndingIndex(next.Text); if (firstPeriodIdx >= 3 && firstPeriodIdx < maxMoveChunkSize) { var newCurrentText = next.Text.Substring(0, firstPeriodIdx + 1).Trim(); diff --git a/src/libse/Common/TextSplitResult.cs b/src/libse/Common/TextSplitResult.cs index f54839ea100..af6aae899a1 100644 --- a/src/libse/Common/TextSplitResult.cs +++ b/src/libse/Common/TextSplitResult.cs @@ -184,6 +184,14 @@ public double DiffFromAverage() public double DiffFromAveragePixel() { var pixels = LengthPixels; + // With "auto break by pixel width" off no pixel lengths are measured, so this used + // to score every candidate 0 and the "best" selectors just kept the first one. + // Fall back to the character-based diff so the most balanced split still wins. + if (pixels.Count == 0) + { + return DiffFromAverage(); + } + var avg = (double)(SumPixels(pixels) - SpaceLengthPixels) / Lines.Count; double diff = 0; for (var i = 0; i < pixels.Count; i++) diff --git a/src/libse/Common/TimeCodesBeautifier.cs b/src/libse/Common/TimeCodesBeautifier.cs deleted file mode 100644 index 932e237730a..00000000000 --- a/src/libse/Common/TimeCodesBeautifier.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace Nikse.SubtitleEdit.Core.Common -{ - public class TimeCodesBeautifier - { - private readonly List _paragraphs; - private readonly double _frameRate; - private readonly List _shotChanges; - private readonly BeautifySettings _settings; - - public TimeCodesBeautifier(List paragraphs, double frameRate, List shotChanges, BeautifySettings settings) - { - _paragraphs = paragraphs; - _frameRate = frameRate; - _shotChanges = shotChanges ?? new List(); - _settings = settings; - } - - public List Beautify() - { - var result = new List(); - - for (int i = 0; i < _paragraphs.Count; i++) - { - var p = new Paragraph(_paragraphs[i]); - - // Frame alignment - if (_settings.SnapToFrames) - { - p.StartTime = AlignToFrame(p.StartTime, _frameRate); - p.EndTime = AlignToFrame(p.EndTime, _frameRate); - } - - // Shot change snapping - if (_shotChanges.Count > 0) - { - p.StartTime = SnapStartToShotChange(p.StartTime, _settings.ShotChangeThresholdMs, _settings.ShotChangeOffsetFrames, _frameRate); - p.EndTime = SnapEndToShotChange(p.EndTime, _settings.ShotChangeThresholdMs, _settings.ShotChangeOffsetFrames, _frameRate); - } - - // Gap management with previous paragraph - if (i > 0) - { - var previousEnd = result[i - 1].EndTime.TotalMilliseconds; - var frameGapMs = FramesToMilliseconds(_settings.FrameGap, _frameRate); - var minStart = previousEnd + frameGapMs; - - if (p.StartTime.TotalMilliseconds < minStart) - { - p.StartTime = new TimeCode(minStart); - } - } - - // Validation: Ensure End > Start - if (p.EndTime.TotalMilliseconds <= p.StartTime.TotalMilliseconds) - { - p.EndTime = new TimeCode(p.StartTime.TotalMilliseconds + _settings.MinDurationMs); - } - - // Validation: Ensure minimum duration - var duration = p.EndTime.TotalMilliseconds - p.StartTime.TotalMilliseconds; - if (duration < _settings.MinDurationMs) - { - p.EndTime = new TimeCode(p.StartTime.TotalMilliseconds + _settings.MinDurationMs); - } - - result.Add(p); - } - - return result; - } - - private TimeCode AlignToFrame(TimeCode timeCode, double fps) - { - var totalMilliseconds = timeCode.TotalMilliseconds; - var frameNumber = Math.Round(totalMilliseconds * fps / 1000.0); - var alignedMilliseconds = frameNumber * 1000.0 / fps; - return new TimeCode(alignedMilliseconds); - } - - private TimeCode SnapStartToShotChange(TimeCode timeCode, int thresholdMs, int offsetFrames, double fps) - { - var timeSeconds = timeCode.TotalMilliseconds / 1000.0; - var thresholdSeconds = thresholdMs / 1000.0; - - foreach (var shotChange in _shotChanges) - { - var diff = Math.Abs(timeSeconds - shotChange); - if (diff < thresholdSeconds) - { - // Snap to offsetFrames AFTER the shot change - var offsetSeconds = offsetFrames / fps; - var snappedSeconds = shotChange + offsetSeconds; - return new TimeCode(snappedSeconds * 1000.0); - } - } - - return timeCode; - } - - private TimeCode SnapEndToShotChange(TimeCode timeCode, int thresholdMs, int offsetFrames, double fps) - { - var timeSeconds = timeCode.TotalMilliseconds / 1000.0; - var thresholdSeconds = thresholdMs / 1000.0; - - foreach (var shotChange in _shotChanges) - { - var diff = Math.Abs(timeSeconds - shotChange); - if (diff < thresholdSeconds) - { - // Snap to offsetFrames BEFORE the shot change - var offsetSeconds = offsetFrames / fps; - var snappedSeconds = shotChange - offsetSeconds; - return new TimeCode(Math.Max(0, snappedSeconds * 1000.0)); - } - } - - return timeCode; - } - - private double FramesToMilliseconds(int frames, double fps) - { - return frames * 1000.0 / fps; - } - } - - public class BeautifySettings - { - public bool SnapToFrames { get; set; } = true; - public int FrameGap { get; set; } = 2; - public int ShotChangeThresholdMs { get; set; } = 250; - public int ShotChangeOffsetFrames { get; set; } = 2; - public double MinDurationMs { get; set; } = 800; - } -} diff --git a/src/libse/Common/UUEncoding.cs b/src/libse/Common/UUEncoding.cs index cf7b3071e01..f8a79eb796c 100644 --- a/src/libse/Common/UUEncoding.cs +++ b/src/libse/Common/UUEncoding.cs @@ -28,8 +28,12 @@ public static string UUEncode(byte[] bytes) { if (i + 3 > length) { + // Copy the remaining 1 or 2 bytes and zero-pad the rest. The count used to be + // "i + 2 - length", which is the remainder only by accident when one byte is + // left and zero when two are - silently dropping the last two bytes of every + // attachment whose length % 3 == 2. src = new byte[3]; - Array.Copy(bytes, i, src, 0, i + 2 - length); + Array.Copy(bytes, i, src, 0, length - i); } else { @@ -46,10 +50,20 @@ public static string UUEncode(byte[] bytes) dest[2] += 33; dest[3] += 33; - sb.Append(Encoding.ASCII.GetString(dest)); + // The final group emits only as many characters as it carries data for - + // min(remaining + 1, 4), per the ASS/Aegisub convention that UUDecode below + // already implements. Always writing 4 made every embedded font/attachment + // 1-2 bytes longer than the original, decoding back with padding zeros. + var charsToWrite = 4; + if (i + 3 > length) + { + charsToWrite = Math.Min(length - i + 1, 4); + } + + sb.Append(Encoding.ASCII.GetString(dest, 0, charsToWrite)); - lineElements += 4; - if (lineElements == 80) + lineElements += charsToWrite; + if (lineElements >= 80) { sb.AppendLine(); lineElements = 0; @@ -78,7 +92,11 @@ public static byte[] UUDecode(string text) for (var i = 0; i < 4 && pos < len; ++pos) { var c = text[pos]; - if (c != '\n' && c != '\r') + // Encoded data is always ASCII (a 6-bit value plus 33, so 33..96). Anything + // above U+00FF cannot be part of it, and Convert.ToByte threw OverflowException + // on it - a single stray non-ASCII character in an [Fonts]/[Graphics] block + // took down the attachments dialog. Skip it like a line break. + if (c != '\n' && c != '\r' && c <= byte.MaxValue) { src[i++] = (byte)(Convert.ToByte(c) - 33); bytes++; diff --git a/src/libse/Common/UnknownFormatImporter.cs b/src/libse/Common/UnknownFormatImporter.cs index a3e560bd11c..fe52a6e21d5 100644 --- a/src/libse/Common/UnknownFormatImporter.cs +++ b/src/libse/Common/UnknownFormatImporter.cs @@ -55,6 +55,16 @@ public Subtitle AutoGuessImport(List lines, string fileName) } } + var firstNonEmptyLine = lines.FirstOrDefault(l => !string.IsNullOrWhiteSpace(l)); + if (firstNonEmptyLine != null && firstNonEmptyLine.TrimStart('', ' ', '\t').StartsWith('<')) + { + var xmlSubtitle = new UnknownFormatImporterXml().AutoGuessImport(lines); + if (xmlSubtitle.Paragraphs.Count >= 2) + { + return xmlSubtitle; + } + } + var subtitle = ImportTimeCodesOnSameSeparateLine(lines); if (subtitle.Paragraphs.Count < 2) { diff --git a/src/libse/Common/UnknownFormatImporterCsv.cs b/src/libse/Common/UnknownFormatImporterCsv.cs index e386577e5b5..19bd41ab4ce 100644 --- a/src/libse/Common/UnknownFormatImporterCsv.cs +++ b/src/libse/Common/UnknownFormatImporterCsv.cs @@ -31,6 +31,10 @@ public Subtitle AutoGuessImport(List lines) if (string.IsNullOrWhiteSpace(lines[0]) || lines[0].Trim() == "Dialogue List,,") { + // On a copy: the caller (UnknownFormatImporter) keeps using the list it passed in + // for the other parsers when this one finds nothing, and dropping a line from + // under it lost a subtitle. + lines = new List(lines); lines.RemoveAt(0); } @@ -41,7 +45,13 @@ public Subtitle AutoGuessImport(List lines) var separator = DetectSeparator(lines); - var headers = lines[0].RemoveChar('"').ToLowerInvariant().Split(separator).ToList(); + // Split the header exactly like the data rows (quote-aware) and trim each cell: + // a naive Split broke on a quoted separator inside a header name - skewing every + // column index - and an unpadded "Start, End, Text" failed to match at all. + var headers = CsvUtil.CsvSplitLines(new List { lines[0] }, separator) + .FirstOrDefault()? + .Select(p => p.Trim().Trim('"').ToLowerInvariant()) + .ToList() ?? new List(); if (!HasValidHeader(headers)) { return new Subtitle(); @@ -127,6 +137,14 @@ private Subtitle MakeSubtitle(List csvLines) private void FixStartTimeWithoutMs(List csvLines, bool isStartTimeFrames) { + if (csvLines.Count == 0) + { + // No data rows at all - e.g. an unterminated quote in the header line makes the + // header swallow the rest of the file. The averages below divide by this count, + // and the file-open path has no catch for the exception (DivideByZeroException). + return; + } + var isEndTimeNull = csvLines.All(p => string.IsNullOrEmpty(p.End)); if (isStartTimeFrames || !isEndTimeNull) { @@ -147,7 +165,10 @@ private void FixStartTimeWithoutMs(List csvLines, bool isStartTimeFrame var startGaps = GetGaps(csvLines.Select(p => p.Start).ToArray()); var avarageStartGap = startGaps.Sum() / startGaps.Length; - var avarageTextLength = csvLines.Select(p => p.Text.Length).Sum() / csvLines.Count; + // Text stays null when no text column was recognised (HasValidHeader only needs two + // hits across all five name lists, so a text column is not required) - dereferencing + // it threw straight out of the file-open path, which has no catch. + var avarageTextLength = csvLines.Select(p => p.Text?.Length ?? 0).Sum() / csvLines.Count; if (avarageStartGap >= 500 || avarageTextLength <= 90) { return; @@ -179,13 +200,19 @@ private double[] GetGaps(string[] strings) private bool DetectIsFrames(List toList) { + // Blank values carry no information either way - a single empty cell, or the blank row + // a trailing newline produces, used to veto the whole column and silently reinterpret + // HH:MM:SS:FF as HH:MM:SS.ms (12 frames became 12 ms). Decide on the values that are + // actually present. Same rule as the import window's own detection. + var considered = 0; foreach (var s in toList) { - if (s == null) + if (string.IsNullOrWhiteSpace(s)) { - return false; + continue; } + considered++; var parts = s.Split(TimeCode.TimeSplitChars, StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 4 || parts[3].Trim().Length != 2) { @@ -193,7 +220,7 @@ private bool DetectIsFrames(List toList) } } - return true; + return considered > 0; } private List ReadCsvLines(List headers, List lines, char separator) diff --git a/src/libse/Common/UnknownFormatImporterJson.cs b/src/libse/Common/UnknownFormatImporterJson.cs index ebe2e9fd267..cbe319f0dd5 100644 --- a/src/libse/Common/UnknownFormatImporterJson.cs +++ b/src/libse/Common/UnknownFormatImporterJson.cs @@ -1,4 +1,4 @@ -using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Core.SubtitleFormats; using System; using System.Collections.Generic; using System.Globalization; @@ -9,6 +9,56 @@ namespace Nikse.SubtitleEdit.Core.Common { public class UnknownFormatImporterJson { + private static readonly string[] StartTags = + { + "start", "in", "begin", + "startTime", "start_time", "starttime", + "startMillis", "start_Millis", "startmillis", + "startMs", "start_ms", "startms", + "startMilliseconds", "start_millisesonds", "startmilliseconds", + "from", "fromTime", "from_ms","fromms", "fromMilliseconds", "from_milliseconds", "show", + "tStartMs", "displayTimeOffset", "milliseconds", "timestamp", "timestamp_begin", "time", + "t1", "st", "s" + }; + + private static readonly string[] EndTags = + { + "end", "out", "stop", + "endTime", "end_time", "endtime", + "endMillis", "end_Millis", "endmillis", + "endMs", "end_ms", "endms", + "endMilliseconds", "end_millisesonds", "endmilliseconds", + "to", "toTime", "to_ms", "toms", "toMilliseconds", "to_milliseconds", "hide", + "tEndMs", "timestamp_end", + "t2", "et", "e" + }; + + private static readonly string[] DurationTags = + { + "duration", + "durationMs", + "dDurationMs", + "dur", + "d", + }; + + private static readonly string[] TextTags = + { + "text", "content", "value", "caption", "sentence", "dialog", "dialogue", + "line", "utf8", "tt", "t", "n" + }; + + private class Fields + { + public string Start { get; set; } + public string End { get; set; } + public string Duration { get; set; } + public string Text { get; set; } + public string Source { get; set; } + + public bool HasTimes => Start != null && (End != null || Duration != null); + } + public Subtitle AutoGuessImport(List lines) { var sb = new StringBuilder(); @@ -23,15 +73,18 @@ public Subtitle AutoGuessImport(List lines) return new Subtitle(); } - var subtitle1 = new Subtitle(); + var subtitle1 = ImportFromSegments(allText.Split('{', '}', '[', ']')); + var subtitle2 = ImportFromSegments(allText.Split('{', '}')); + + var subtitle3 = new Subtitle(); try { var count = 0; - foreach (var line in allText.Split('{', '}', '[', ']')) + foreach (var line in Json.ReadObjectArray(allText)) { count++; - ReadParagraph(line, subtitle1); - if (count > 20 && subtitle1.Paragraphs.Count == 0) + ReadParagraph(line, subtitle3); + if (count > 20 && subtitle3.Paragraphs.Count == 0) { break; } @@ -42,34 +95,72 @@ public Subtitle AutoGuessImport(List lines) // ignored } - var subtitle2 = new Subtitle(); - try + if (subtitle1.Paragraphs.Count >= subtitle2.Paragraphs.Count && subtitle1.Paragraphs.Count >= subtitle3.Paragraphs.Count) { - var count = 0; - foreach (var line in allText.Split('{', '}')) - { - count++; - ReadParagraph(line, subtitle2); - if (count > 20 && subtitle2.Paragraphs.Count == 0) - { - break; - } - } + subtitle1.Renumber(); + return FixTimeCodeMsOrSeconds(FixMissingEndTimes(subtitle1)); } - catch + + if (subtitle2.Paragraphs.Count >= subtitle1.Paragraphs.Count && subtitle2.Paragraphs.Count >= subtitle3.Paragraphs.Count) { - // ignored + subtitle2.Renumber(); + return FixTimeCodeMsOrSeconds(FixMissingEndTimes(subtitle2)); } - var subtitle3 = new Subtitle(); + subtitle3.Renumber(); + return FixTimeCodeMsOrSeconds(FixMissingEndTimes(subtitle3)); + } + + private static Subtitle ImportFromSegments(string[] segments) + { + var subtitle = new Subtitle(); try { var count = 0; - foreach (var line in Json.ReadObjectArray(allText)) + Fields pendingTimes = null; + var pendingAge = 0; + foreach (var segment in segments) { count++; - ReadParagraph(line, subtitle3); - if (count > 20 && subtitle3.Paragraphs.Count == 0) + var fields = ReadFields(segment); + if (fields == null) + { + if (count > 20 && subtitle.Paragraphs.Count == 0) + { + break; + } + + continue; + } + + if (fields.Start != null && fields.Text != null) + { + AddParagraph(subtitle, fields, fields.Text); + pendingTimes = null; + } + else if (fields.HasTimes && fields.Text == null) + { + // nested object layout: the time codes and the text live in different + // segments after splitting (e.g. {"start":..,"end":..,"metadata":{"text":..}}) + pendingTimes = fields; + pendingAge = 0; + } + else if (fields.Text != null && fields.Start == null && pendingTimes != null) + { + AddParagraph(subtitle, pendingTimes, fields.Text); + pendingTimes = null; + } + + if (pendingTimes != null) + { + pendingAge++; + if (pendingAge > 3) + { + pendingTimes = null; + } + } + + if (count > 20 && subtitle.Paragraphs.Count == 0) { break; } @@ -80,54 +171,62 @@ public Subtitle AutoGuessImport(List lines) // ignored } - if (subtitle1.Paragraphs.Count >= subtitle2.Paragraphs.Count && subtitle1.Paragraphs.Count >= subtitle3.Paragraphs.Count) + return subtitle; + } + + private static Subtitle FixMissingEndTimes(Subtitle subtitle) + { + if (subtitle.Paragraphs.Count < 2 || + !subtitle.Paragraphs.All(p => Math.Abs(p.EndTime.TotalMilliseconds) < 0.001)) { - subtitle1.Renumber(); - return FixTimeCodeMsOrSeconds(subtitle1); + return subtitle; } - if (subtitle2.Paragraphs.Count >= subtitle1.Paragraphs.Count && subtitle2.Paragraphs.Count >= subtitle3.Paragraphs.Count) + // start-time-only format (e.g. {"milliseconds":1500,"line":"..."}) + for (var index = 0; index < subtitle.Paragraphs.Count; index++) { - subtitle2.Renumber(); - return FixTimeCodeMsOrSeconds(subtitle2); + var paragraph = subtitle.Paragraphs[index]; + var next = subtitle.GetParagraphOrDefault(index + 1); + if (next != null) + { + paragraph.EndTime.TotalMilliseconds = next.StartTime.TotalMilliseconds - Configuration.Settings.General.MinimumMillisecondsBetweenLines; + } + else + { + paragraph.EndTime.TotalMilliseconds = paragraph.StartTime.TotalMilliseconds + Utilities.GetOptimalDisplayMilliseconds(paragraph.Text); + } } - subtitle3.Renumber(); - return FixTimeCodeMsOrSeconds(subtitle3); + return subtitle; } private static Subtitle FixTimeCodeMsOrSeconds(Subtitle subtitle) { - if (subtitle == null || subtitle.Paragraphs.Count < 5) + if (subtitle == null || subtitle.Paragraphs.Count < 2) { return subtitle; } + var msKeys = new[] + { + "start", "startMs", "start_ms", "startMillis", "start_millis", + "startMilliseconds", "start_millisecondsMs", "tStartMs", "milliseconds", + "fromMs", "from_ms", "fromms", "fromMillis", "fromMilliseconds", "from_milliseconds", "show" + }; + double totalDuration = 0; var msFound = 0; foreach (var p in subtitle.Paragraphs) { totalDuration += p.DurationTotalMilliseconds; - if (p.Style.Contains("\"start\"") || - p.Style.Contains("\"startMs\"") || - p.Style.Contains("\"start_ms\"") || - p.Style.Contains("\"startMillis\"") || - p.Style.Contains("\"start_millis\"") || - p.Style.Contains("\"startMilliseconds\"") || - p.Style.Contains("\"start_millisecondsMs\"") || - p.Style.Contains("\"fromMs\"") || - p.Style.Contains("\"from_ms\"") || - p.Style.Contains("\"fromms\"") || - p.Style.Contains("\"fromMillis\"") || - p.Style.Contains("\"fromMilliseconds\"") || - p.Style.Contains("\"from_milliseconds\"") || - p.Style.Contains("\"show\"")) + if (msKeys.Any(key => ReadKeyValue(p.Style, key, out _) != null)) { msFound++; } } - if (totalDuration / subtitle.Paragraphs.Count > 1000000 || msFound == subtitle.Paragraphs.Count) + var averageDuration = totalDuration / subtitle.Paragraphs.Count; + if (averageDuration > 1000000 || msFound == subtitle.Paragraphs.Count && averageDuration > 100000) { // Time codes were read as seconds, but they are actually milliseconds, // so all time codes are divided by 1000. @@ -143,63 +242,95 @@ private static Subtitle FixTimeCodeMsOrSeconds(Subtitle subtitle) private static void ReadParagraph(string s, Subtitle subtitle) { - s = s.Trim(); - if (s.Length < 7) + var fields = ReadFields(s); + if (fields?.Start == null || fields.Text == null) { return; } + AddParagraph(subtitle, fields, fields.Text); + } + + private static Fields ReadFields(string s) + { + s = s.Trim(); + if (s.Length < 5) + { + return null; + } + if (!s.EndsWith('}')) { s += '}'; } - var start = ReadStartTag(s); - var end = ReadEndTag(s); - var duration = ReadDurationTag(s); + var start = NormalizeTime(ReadFirstTimeTag(s, StartTags)); + var end = NormalizeTime(ReadFirstTimeTag(s, EndTags)); + var duration = ReadFirstTimeTag(s, DurationTags); var text = ReadTextTag(s); - var originalStart = start; + if (start == null && end == null && duration == null && text == null) + { + return null; + } + + return new Fields { Start = start, End = end, Duration = duration, Text = text, Source = s }; + } - if (start != null && start.Contains(":") && start.Length >= 11 && start.Length <= 12 && start.Split(new[] { ':', ',', '.' }, StringSplitOptions.RemoveEmptyEntries).Length == 4) + private static void AddParagraph(Subtitle subtitle, Fields times, string text) + { + var start = times.Start?.TrimEnd('s'); + var end = times.End?.TrimEnd('s'); + var duration = times.Duration?.TrimEnd('s'); + + if (start == null) { - start = DecodeFormatToSeconds(start); + return; } - else if (start != null && start.Contains(":") && start.Length == 8 && start.Split(new[] { ':', ',', '.' }, StringSplitOptions.RemoveEmptyEntries).Length == 3) + + if (!double.TryParse(start, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var startSeconds)) { - start = DecodeFormatHourMinuteSecondsToSeconds(start); + return; } - if (end != null && end.Contains(":") && end.Length >= 11 && end.Length <= 12 && end.Split(new[] { ':', ',', '.' }, StringSplitOptions.RemoveEmptyEntries).Length == 4) + if (end != null && + double.TryParse(end, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var endSeconds)) { - end = DecodeFormatToSeconds(end); + subtitle.Paragraphs.Add(new Paragraph(Json.DecodeJsonText(text), startSeconds * TimeCode.BaseUnit, endSeconds * TimeCode.BaseUnit) { Extra = times.Start, Style = times.Source }); + return; } - else if (end != null && end.Contains(":") && end.Length == 8 && end.Split(new[] { ':', ',', '.' }, StringSplitOptions.RemoveEmptyEntries).Length == 3) + + if (duration != null && + double.TryParse(duration, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var durationSeconds)) { - end = DecodeFormatHourMinuteSecondsToSeconds(end); + subtitle.Paragraphs.Add(new Paragraph(Json.DecodeJsonText(text), startSeconds * TimeCode.BaseUnit, (startSeconds + durationSeconds) * TimeCode.BaseUnit) { Extra = times.Start, Style = times.Source }); + return; } - if (start != null && end != null && text != null) + if (end == null && duration == null) { - start = start.TrimEnd('s'); - end = end.TrimEnd('s'); - if (double.TryParse(start, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var startSeconds) && - double.TryParse(end, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var endSeconds)) - { - var p = new Paragraph(Json.DecodeJsonText(text), startSeconds * TimeCode.BaseUnit, endSeconds * TimeCode.BaseUnit) { Extra = originalStart, Style = s }; - subtitle.Paragraphs.Add(p); - } + // start-time-only - end times are computed at the end of the import + subtitle.Paragraphs.Add(new Paragraph(Json.DecodeJsonText(text), startSeconds * TimeCode.BaseUnit, 0) { Extra = times.Start, Style = times.Source }); } - else if (start != null && duration != null && text != null) + } + + private static string NormalizeTime(string value) + { + if (value == null) { - start = start.TrimEnd('s'); - duration = duration.TrimEnd('s'); - if (double.TryParse(start, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out double startSeconds) && - double.TryParse(duration, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out double durationSeconds)) - { - var p = new Paragraph(Json.DecodeJsonText(text), startSeconds * TimeCode.BaseUnit, (startSeconds + durationSeconds) * TimeCode.BaseUnit) { Extra = originalStart, Style = s }; - subtitle.Paragraphs.Add(p); - } + return null; } + + if (value.Contains(":") && value.Length >= 11 && value.Length <= 12 && value.Split(new[] { ':', ',', '.' }, StringSplitOptions.RemoveEmptyEntries).Length == 4) + { + return DecodeFormatToSeconds(value); + } + + if (value.Contains(":") && value.Length == 8 && value.Split(new[] { ':', ',', '.' }, StringSplitOptions.RemoveEmptyEntries).Length == 3) + { + return DecodeFormatHourMinuteSecondsToSeconds(value); + } + + return value; } private static string DecodeFormatToSeconds(string s) @@ -213,140 +344,245 @@ private static string DecodeFormatHourMinuteSecondsToSeconds(string s) return DecodeFormatToSeconds(s + ":00"); } - private static string ReadStartTag(string s) + private static string ReadFirstTimeTag(string s, string[] tags) { - return ReadFirstMultiTag(s, new[] - { - "start", "in", "begin", - "startTime", "start_time", "starttime", - "startMillis", "start_Millis", "startmillis", - "startMs", "start_ms", "startms", - "startMilliseconds", "start_Millisesonds", "startmilliseconds", - "from", "fromTime", "from_ms","fromms", "fromMilliseconds", "from_milliseconds", "show" - }); - } - - private static string ReadEndTag(string s) - { - return ReadFirstMultiTag(s, new[] - { - "end", "out", "stop", - "endTime", "end_time", "endtime", - "endMillis", "end_Millis", "endmillis", - "endMs", "end_ms", "endms", - "endMilliseconds", "end_Millisesonds", "endmilliseconds", - "to", "toTime", "to_ms", "toms", "toMilliseconds", "to_milliseconds", "hide" - }); - } - - private static string ReadDurationTag(string s) - { - return ReadFirstMultiTag(s, new[] + foreach (var tag in tags) { - "duration", - "durationMs", - "dur", - }); + var res = ReadKeyValue(s, tag, out var isStringValue); + if (!string.IsNullOrEmpty(res) && IsTimeLikeValue(res)) + { + return res; + } + } + + return null; } - private static string ReadTextTag(string s) + private static bool IsTimeLikeValue(string value) { - var idx = s.IndexOf("\"text", StringComparison.OrdinalIgnoreCase); - if (idx < 0) + var v = value.Trim().TrimEnd('s'); + if (v.Length == 0 || v.Length > 20) { - idx = s.IndexOf("\"content", StringComparison.OrdinalIgnoreCase); + return false; } - if (idx < 0) - { - idx = s.IndexOf("\"value", StringComparison.OrdinalIgnoreCase); - } - if (idx < 0) - { - idx = s.IndexOf("\"caption", StringComparison.OrdinalIgnoreCase); - } - if (idx < 0) + + var hasDigit = false; + foreach (var ch in v) { - idx = s.IndexOf("\"sentence", StringComparison.OrdinalIgnoreCase); + if (char.IsDigit(ch)) + { + hasDigit = true; + } + else if (ch != ':' && ch != '.' && ch != ',' && ch != ';' && ch != '-') + { + return false; + } } - if (idx < 0) + return hasDigit; + } + + /// + /// Reads a key whose value is an array of strings; null when the key is missing, + /// the value is not an array, or the array holds anything but strings. + /// + private static List ReadStringArrayValue(string s, string tag) + { + var from = 0; + while (from < s.Length) { + var idx = s.IndexOf("\"" + tag + "\"", from, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + { + return null; + } + + var i = idx + tag.Length + 2; + while (i < s.Length && char.IsWhiteSpace(s[i])) + { + i++; + } + + if (i >= s.Length || s[i] != ':') + { + from = idx + 1; + continue; + } + + i++; + while (i < s.Length && char.IsWhiteSpace(s[i])) + { + i++; + } + + if (i >= s.Length || s[i] != '[') + { + return null; + } + + i++; + var list = new List(); + while (i < s.Length) + { + while (i < s.Length && (char.IsWhiteSpace(s[i]) || s[i] == ',')) + { + i++; + } + + if (i >= s.Length) + { + return null; // unterminated + } + + if (s[i] == ']') + { + return list; + } + + if (s[i] != '"') + { + return null; // not an array of strings + } + + i++; + var sb = new StringBuilder(); + while (i < s.Length && s[i] != '"') + { + if (s[i] == '\\' && i + 1 < s.Length) + { + sb.Append(s[i]); + sb.Append(s[i + 1]); + i += 2; + continue; + } + + sb.Append(s[i]); + i++; + } + + if (i >= s.Length) + { + return null; + } + + i++; // closing quote + list.Add(sb.ToString()); + } + return null; } - s = s.Substring(idx); - idx = s.IndexOf(']'); - if (idx > 0) - { - s = s.Substring(0, idx + 1); - } + return null; + } - var text = Json.ReadTag(s, "text"); - if (text == null) - { - text = Json.ReadTag(s, "content"); - } - if (text == null) - { - text = Json.ReadTag(s, "value"); - } - if (text == null) - { - text = Json.ReadTag(s, "caption"); - } - if (text == null) - { - text = Json.ReadTag(s, "sentence"); - } - if (text == null) + /// + /// Reads the value of a json key. Unlike a plain IndexOf, the tag must actually be in + /// key position (followed by a colon) - otherwise string VALUES that happen to match a + /// tag name hijack the lookup (e.g. "lineAlign":"start" matching the tag "start"). + /// + private static string ReadKeyValue(string s, string tag, out bool isStringValue) + { + isStringValue = false; + var from = 0; + while (from < s.Length) { - text = Json.ReadTag(s, "dialog"); - } + var idx = s.IndexOf("\"" + tag + "\"", from, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + { + return null; + } - if (text != null) - { - text = Json.DecodeJsonText(text); - } + var i = idx + tag.Length + 2; + while (i < s.Length && char.IsWhiteSpace(s[i])) + { + i++; + } - var textLines = Json.ReadArray(s, "text"); - if (textLines == null || textLines.Count == 0) - { - textLines = Json.ReadArray(s, "content"); - } + if (i >= s.Length || s[i] != ':') + { + from = idx + 1; + continue; + } - var isArray = s.Contains("["); - if (isArray && textLines.Any(p => p == "end_time" || p == "endTime" || p == "end" || p == "endMs" || p == "endMilliseconds" || p == "end_ms" || p == "endms" || p == "to" || p == "to_ms" || p == "toms" || p == "from" || p == "from_ms" | p == "hide")) - { - isArray = false; - } + i++; + while (i < s.Length && char.IsWhiteSpace(s[i])) + { + i++; + } - if (!isArray && !string.IsNullOrEmpty(text)) - { - return text - .Replace("'", "'") - .Replace("
", Environment.NewLine) - .Replace("
", Environment.NewLine) - ; - } + if (i >= s.Length) + { + return null; + } - if (textLines != null && textLines.Count > 0) - { - return string.Join(Environment.NewLine, textLines); + if (s[i] == '"') + { + isStringValue = true; + var sb = new StringBuilder(); + i++; + while (i < s.Length) + { + var ch = s[i]; + if (ch == '\\' && i + 1 < s.Length) + { + sb.Append(ch); + sb.Append(s[i + 1]); + i += 2; + continue; + } + + if (ch == '"') + { + break; + } + + sb.Append(ch); + i++; + } + + return sb.ToString(); + } + + var end = i; + while (end < s.Length && s[end] != ',' && s[end] != '}' && s[end] != ']') + { + end++; + } + + return s.Substring(i, end - i).Trim(); } - return ReadFirstMultiTag(s, new[] { "text", "content" }); + return null; } - private static string ReadFirstMultiTag(string s, string[] tags) + private static string ReadTextTag(string s) { - foreach (var tag in tags) + // text as an array of lines, e.g. "text": ["line 1", "line 2"] + foreach (var arrayTag in new[] { "text", "content", "lines" }) { - var res = Json.ReadTag(s, tag); - if (!string.IsNullOrEmpty(res)) + var textLines = ReadStringArrayValue(s, arrayTag); + if (textLines != null && textLines.Count > 0) { - return res; + var joined = string.Join(Environment.NewLine, textLines.Where(p => !string.IsNullOrEmpty(p))); + if (!string.IsNullOrWhiteSpace(joined)) + { + return joined; + } } } + + foreach (var tag in TextTags) + { + var res = ReadKeyValue(s, tag, out var isStringValue); + if (isStringValue && !string.IsNullOrEmpty(res)) + { + return res + .Replace("'", "'") + .Replace("
", Environment.NewLine) + .Replace("
", Environment.NewLine); + } + } + return null; } } diff --git a/src/libse/Common/UnknownFormatImporterOds.cs b/src/libse/Common/UnknownFormatImporterOds.cs index eb1b78ee899..ef1836e0568 100644 --- a/src/libse/Common/UnknownFormatImporterOds.cs +++ b/src/libse/Common/UnknownFormatImporterOds.cs @@ -70,22 +70,24 @@ private static List ReadTable(XElement table) foreach (var row in table.Elements(TableNs + "table-row")) { var cells = new List(); - foreach (var cell in row.Elements(TableNs + "table-cell")) + + // covered-table-cell too, not just table-cell: a merged cell is written as one + // table-cell plus one covered-table-cell per column it swallows, and skipping the + // placeholders pulled every later column one to the left - so the text column of + // a row with a merge was read as the end time. + foreach (var cell in row.Elements().Where(e => e.Name == TableNs + "table-cell" || e.Name == TableNs + "covered-table-cell")) { - var value = GetCellValue(cell); + var value = cell.Name == TableNs + "covered-table-cell" ? string.Empty : GetCellValue(cell); var repeat = ParseRepeat(cell.Attribute(TableNs + "number-columns-repeated")?.Value); - if (string.IsNullOrEmpty(value)) - { - // Trailing empties with huge repeat counts are common — only emit a single empty cell - cells.Add(string.Empty); - } - else + // Honour the repeat count for empty cells too: LibreOffice writes an + // *interior* run of blanks that way, and collapsing it to one cell shifted + // every following column left, so a row with a gap lost its text column. + // Trailing blank runs are still handled by the trim below, and ParseRepeat + // already caps the count, so this cannot blow up on a "blank to column 1024" row. + for (var i = 0; i < repeat; i++) { - for (var i = 0; i < repeat; i++) - { - cells.Add(value); - } + cells.Add(value ?? string.Empty); } } diff --git a/src/libse/Common/UnknownFormatImporterXlsx.cs b/src/libse/Common/UnknownFormatImporterXlsx.cs index 2df7242209d..ce4e373862a 100644 --- a/src/libse/Common/UnknownFormatImporterXlsx.cs +++ b/src/libse/Common/UnknownFormatImporterXlsx.cs @@ -11,6 +11,8 @@ namespace Nikse.SubtitleEdit.Core.Common public class UnknownFormatImporterXlsx { private static readonly XNamespace Ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + private static readonly XNamespace RelationshipsNs = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + private static readonly XNamespace PackageRelationshipsNs = "http://schemas.openxmlformats.org/package/2006/relationships"; public Subtitle AutoGuessImport(string fileName) { @@ -36,11 +38,7 @@ public List ReadLinesFromFile(string fileName) using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) { var sharedStrings = ReadSharedStrings(archive); - var sheetEntry = archive.Entries - .Where(e => e.FullName.StartsWith("xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase) && - e.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) - .OrderBy(e => e.FullName, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(); + var sheetEntry = FindFirstSheet(archive); if (sheetEntry == null) { return null; @@ -55,6 +53,70 @@ public List ReadLinesFromFile(string fileName) } } + /// + /// The worksheet the user sees first: the first <sheet> in xl/workbook.xml, resolved + /// through the workbook relationships. That is not necessarily sheet1.xml - moving a sheet + /// in Excel reorders the workbook entries but never renames the parts - so picking the + /// first worksheet file imported the wrong sheet. Falls back to the first worksheet part + /// when the workbook or its relationships cannot be read. + /// + private static ZipArchiveEntry FindFirstSheet(ZipArchive archive) + { + var worksheets = archive.Entries + .Where(e => e.FullName.StartsWith("xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase) && + e.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) + .OrderBy(e => e.FullName, StringComparer.OrdinalIgnoreCase) + .ToList(); + if (worksheets.Count <= 1) + { + return worksheets.FirstOrDefault(); + } + + try + { + var workbookEntry = archive.GetEntry("xl/workbook.xml"); + var relsEntry = archive.GetEntry("xl/_rels/workbook.xml.rels"); + if (workbookEntry != null && relsEntry != null) + { + string relationshipId; + using (var stream = workbookEntry.Open()) + { + relationshipId = (string)XDocument.Load(stream).Root? + .Element(Ns + "sheets")? + .Elements(Ns + "sheet") + .FirstOrDefault()? + .Attribute(RelationshipsNs + "id"); + } + + if (!string.IsNullOrEmpty(relationshipId)) + { + using (var stream = relsEntry.Open()) + { + var target = XDocument.Load(stream).Root? + .Elements(PackageRelationshipsNs + "Relationship") + .FirstOrDefault(r => (string)r.Attribute("Id") == relationshipId)? + .Attribute("Target")?.Value; + if (!string.IsNullOrEmpty(target)) + { + var fullName = "xl/" + target.TrimStart('/').Replace("../", string.Empty); + var match = worksheets.FirstOrDefault(e => e.FullName.Equals(fullName, StringComparison.OrdinalIgnoreCase)); + if (match != null) + { + return match; + } + } + } + } + } + } + catch + { + // fall through to the first worksheet part + } + + return worksheets[0]; + } + private static List ReadSharedStrings(ZipArchive archive) { var result = new List(); diff --git a/src/libse/Common/UnknownFormatImporterXml.cs b/src/libse/Common/UnknownFormatImporterXml.cs new file mode 100644 index 00000000000..3969e53cf6e --- /dev/null +++ b/src/libse/Common/UnknownFormatImporterXml.cs @@ -0,0 +1,594 @@ +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Xml; + +namespace Nikse.SubtitleEdit.Core.Common +{ + /// + /// Generic importer for unknown XML subtitle dialects: finds the repeated element that + /// carries time codes (as attributes or child elements) plus text, without knowing the + /// schema. Handles TTML-like ("begin"/"end" attributes with inline text), wrapper + /// elements with a text child (D-Cinema like), child-element time codes (Matroska + /// chapters like), time codes on an empty child element, text in an attribute, and + /// start-only samples where an empty element closes the previous one (GPAC TTXT like). + /// + public class UnknownFormatImporterXml + { + private class Candidate + { + public double StartMs { get; set; } + public double? EndMs { get; set; } + public double? DurationMs { get; set; } + public string Text { get; set; } + } + + private bool _use250TickTimeCodes; + + public Subtitle AutoGuessImport(List lines) + { + var allText = string.Join(Environment.NewLine, lines).Trim().TrimStart('').TrimStart(); + if (!allText.StartsWith('<') || !allText.EndsWith('>')) + { + return new Subtitle(); + } + + var doc = new XmlDocument { XmlResolver = null }; + try + { + var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, XmlResolver = null }; + using (var stringReader = new System.IO.StringReader(allText)) + using (var reader = XmlReader.Create(stringReader, settings)) + { + doc.Load(reader); + } + } + catch + { + return new Subtitle(); + } + + if (doc.DocumentElement == null) + { + return new Subtitle(); + } + + // D-Cinema interop writes the last time code part in 250ths of a second ("editable units") + _use250TickTimeCodes = doc.DocumentElement.Name == "DCSubtitle"; + + // group candidates per element name - the subtitle element is the name that + // repeats with time codes, everything else (styles, regions, metadata) won't + var candidatesByName = new Dictionary>(); + Walk(doc.DocumentElement, candidatesByName); + + List best = null; + var bestScore = 0; + var bestTextCount = 0; + foreach (var kvp in candidatesByName) + { + var textCount = kvp.Value.Count(c => !string.IsNullOrWhiteSpace(c.Text)); + // a group that also knows when subtitles end beats one with bare start times + // (e.g. a layout/config element with an index that parses as a time) + var score = textCount * 2 + kvp.Value.Count(c => c.EndMs.HasValue || c.DurationMs.HasValue); + if (score > bestScore) + { + bestScore = score; + bestTextCount = textCount; + best = kvp.Value; + } + } + + if (best == null || bestTextCount < 2) + { + return new Subtitle(); + } + + return MakeSubtitle(best); + } + + private static Subtitle MakeSubtitle(List candidates) + { + var subtitle = new Subtitle(); + Paragraph last = null; + foreach (var c in candidates) + { + var end = c.EndMs ?? (c.DurationMs.HasValue ? c.StartMs + c.DurationMs.Value : 0); + if (string.IsNullOrWhiteSpace(c.Text)) + { + // start-only sample with no text: it closes the previous paragraph + if (last != null && !c.EndMs.HasValue && !c.DurationMs.HasValue && + Math.Abs(last.EndTime.TotalMilliseconds) < 0.01 && c.StartMs > last.StartTime.TotalMilliseconds) + { + last.EndTime.TotalMilliseconds = c.StartMs; + } + + continue; + } + + var p = new Paragraph(c.Text.Trim(), c.StartMs, end); + subtitle.Paragraphs.Add(p); + last = p; + } + + // fill in missing end times + for (var i = 0; i < subtitle.Paragraphs.Count; i++) + { + var p = subtitle.Paragraphs[i]; + if (Math.Abs(p.EndTime.TotalMilliseconds) > 0.01) + { + continue; + } + + var optimalDurationMs = Utilities.GetOptimalDisplayMilliseconds(p.Text); + var next = subtitle.GetParagraphOrDefault(i + 1); + if (next != null && next.StartTime.TotalMilliseconds < p.StartTime.TotalMilliseconds + optimalDurationMs + 2000) + { + p.EndTime.TotalMilliseconds = next.StartTime.TotalMilliseconds - Configuration.Settings.General.MinimumMillisecondsBetweenLines; + } + else + { + p.EndTime.TotalMilliseconds = p.StartTime.TotalMilliseconds + optimalDurationMs; + } + } + + subtitle.Renumber(); + return subtitle; + } + + private void Walk(XmlElement element, Dictionary> candidatesByName) + { + var candidate = TryGetCandidate(element); + if (candidate != null) + { + if (!candidatesByName.TryGetValue(element.LocalName, out var list)) + { + list = new List(); + candidatesByName.Add(element.LocalName, list); + } + + list.Add(candidate); + } + + foreach (XmlNode child in element.ChildNodes) + { + if (child is XmlElement childElement) + { + Walk(childElement, candidatesByName); + } + } + } + + private Candidate TryGetCandidate(XmlElement element) + { + double? start = null; + double? end = null; + double? duration = null; + var timeChildNames = new List(); + + foreach (XmlAttribute attribute in element.Attributes) + { + ReadTimeField(attribute.LocalName, attribute.Value, ref start, ref end, ref duration); + } + + foreach (XmlNode child in element.ChildNodes) + { + if (!(child is XmlElement el)) + { + continue; + } + + var before = (start, end, duration); + + // simple child element whose value is a time code, e.g. 00:00:01.500 + if (!el.HasChildNodes || el.ChildNodes.Count == 1 && el.FirstChild is XmlText) + { + ReadTimeField(el.LocalName, el.InnerText, ref start, ref end, ref duration); + } + + // empty child element carrying the time codes as attributes, e.g.
@@ -2244,9 +2256,12 @@ public static SKColor GetColorFromFontString(string text, SKColor defaultColor) } // f starts at 'start' within s, so offset f-relative index back into s var colorStart = start + f.IndexOf(" color=", StringComparison.OrdinalIgnoreCase); - if (s.IndexOf('"', colorStart + " color=".Length + 1) > 0) + var quoteIndex = s.IndexOf('"', colorStart + " color=".Length + 1); + if (quoteIndex > 0 && quoteIndex < end) { - end = s.IndexOf('"', colorStart + " color=".Length + 1); + // only a quote inside the font tag itself ends the value - a quotation mark + // in the dialogue text must not hijack an unquoted color attribute + end = quoteIndex; } s = s.Substring(colorStart, end - colorStart); s = s.Replace(" color=", string.Empty); @@ -3094,7 +3109,7 @@ public static string RemoveSpaceBeforeAfterTag(string input, string openTag) // e.g.: \r\n if (text.EndsWith(close5, StringComparison.Ordinal)) { - text = text.Remove(text.Length - openTag.Length - Environment.NewLine.Length - 1, Environment.NewLine.Length); + text = text.Remove(text.Length - close5.Length, Environment.NewLine.Length); } if (text.Contains(open2, StringComparison.Ordinal)) @@ -3420,8 +3435,11 @@ public static Subtitle LoadMatroskaSSA(MatroskaTrackInfo matroskaSubtitleInfo, s { text = text.Remove(0, idx); // remove ReadOrder idx = text.IndexOf(','); - text = text.Insert(idx, "," + start + "," + end); - lines.Add("Dialogue: " + text); + if (idx >= 0) + { + text = text.Insert(idx, "," + start + "," + end); + lines.Add("Dialogue: " + text); + } } } for (int commentIndex = 0; commentIndex < comments.Paragraphs.Count; commentIndex++) @@ -3644,12 +3662,134 @@ public static string ReSplit(string text, int selectionStart) return sb.ToString().Replace(" ", " ").Replace(Environment.NewLine + " ", Environment.NewLine); } + private const char RightToLeftEmbedding = '\u202B'; + private const char PopDirectionalFormatting = '\u202C'; + + /// + /// Puts each line in a right-to-left embedding, so what the line mixes in - numbers, a Latin + /// name, the full stop that ends the sentence - reads in the right order. + /// + /// The embedding starts after the markup a line opens with and ends before the markup it + /// closes with. An ASSA override block or an HTML tag is not part of the sentence, and an + /// embedding started in front of one only moves the tag itself to the other end - which is + /// what "{\i1}text" looked like when reported (issue #14150). + /// + /// + /// Every embedding is closed with U+202C. An unterminated one is left for whatever the + /// renderer draws next to inherit, and re-running the fix used to stack another opening + /// character on top of the last one. + /// + /// public static string FixRtlViaUnicodeChars(string input) { - string rtl = "\u202B"; - var text = input.Replace(rtl, string.Empty); - text = rtl + text.Replace(Environment.NewLine, Environment.NewLine + rtl); - return text; + if (string.IsNullOrEmpty(input)) + { + return input; + } + + var text = input + .Replace(RightToLeftEmbedding.ToString(), string.Empty) + .Replace(PopDirectionalFormatting.ToString(), string.Empty); + + var lines = text.SplitToLines(); + var sb = new StringBuilder(text.Length + lines.Count * 2); + for (var i = 0; i < lines.Count; i++) + { + if (i > 0) + { + sb.Append(Environment.NewLine); + } + + var line = lines[i]; + if (HasAssaDrawing(line)) + { + // A "{\p1}" line is coordinates, not a sentence - a directional mark dropped in + // among the drawing commands is something libass has to parse as one of them. + sb.Append(line); + continue; + } + + var start = GetTextStartAfterMarkup(line); + var end = GetTextEndBeforeMarkup(line, start); + if (end <= start) + { + // Nothing but markup (an "{\an8}" on its own) - there is no sentence to embed, + // and wrapping the tags would only give the renderer something to trip on. + sb.Append(line); + continue; + } + + sb.Append(line, 0, start); + sb.Append(RightToLeftEmbedding); + sb.Append(line, start, end - start); + sb.Append(PopDirectionalFormatting); + sb.Append(line, end, line.Length - end); + } + + return sb.ToString(); + } + + /// Whether a line turns on vector drawing anywhere - "\p1".."\p9" inside an ASSA block. + private static bool HasAssaDrawing(string line) + { + var index = line.IndexOf('{'); + while (index >= 0) + { + var close = line.IndexOf('}', index); + if (close < 0) + { + return false; + } + + for (var i = index; i < close - 2; i++) + { + if (line[i] == '\\' && (line[i + 1] == 'p' || line[i + 1] == 'P') && line[i + 2] >= '1' && line[i + 2] <= '9') + { + return true; + } + } + + index = line.IndexOf('{', close); + } + + return false; + } + + /// Index of the first character after the ASSA blocks / HTML tags a line opens with. + private static int GetTextStartAfterMarkup(string line) + { + var index = 0; + while (index < line.Length) + { + var close = line[index] == '{' ? line.IndexOf('}', index) : line[index] == '<' ? line.IndexOf('>', index) : -1; + if (close < 0) + { + break; + } + + index = close + 1; + } + + return index; + } + + /// Index of the first character of the ASSA blocks / HTML tags a line closes with. + private static int GetTextEndBeforeMarkup(string line, int start) + { + var index = line.Length; + while (index > start) + { + var last = line[index - 1]; + var open = last == '}' ? line.LastIndexOf('{', index - 1) : last == '>' ? line.LastIndexOf('<', index - 1) : -1; + if (open < start) + { + break; + } + + index = open; + } + + return index; } private static readonly char[] UnicodeControlCharsAndNoBreakSpace = { '\u200E', '\u200F', '\u202A', '\u202B', '\u202C', '\u202D', '\u202E', '\u00A0' }; diff --git a/src/libse/ContainerFormats/MaterialExchangeFormat/MxfParser.cs b/src/libse/ContainerFormats/MaterialExchangeFormat/MxfParser.cs index a54b2b94d61..46b91e16ac5 100644 --- a/src/libse/ContainerFormats/MaterialExchangeFormat/MxfParser.cs +++ b/src/libse/ContainerFormats/MaterialExchangeFormat/MxfParser.cs @@ -52,6 +52,13 @@ private void ParseMxf(Stream stream) var buffer = new byte[klv.DataSize]; var bytesRead = stream.Read(buffer, 0, buffer.Length); + // A zero- or short-length value is legal MXF (an empty fill/local set), + // so the magic test needs four bytes to actually be there. + if (bytesRead < 4) + { + continue; + } + if (buffer[0] == 0x89 && buffer[1] == 0x50 && buffer[2] == 0x4E && buffer[3] == 0x47) // PNG header { _images.Add(buffer); diff --git a/src/libse/ContainerFormats/Matroska/MatroskaFile.cs b/src/libse/ContainerFormats/Matroska/MatroskaFile.cs index 1c5cf6829b2..418be0fa650 100644 --- a/src/libse/ContainerFormats/Matroska/MatroskaFile.cs +++ b/src/libse/ContainerFormats/Matroska/MatroskaFile.cs @@ -405,7 +405,11 @@ private void ReadContentEncodingElement(Element contentEncodingElement, ref int System.Diagnostics.Debug.WriteLine("ContentCompSettings: " + contentCompSettings); break; default: - _stream.Seek(element.DataSize, SeekOrigin.Current); + // compElement, not element: seeking by the parent + // ContentCompression's size on an unknown child (CRC-32, + // Void) jumped past the whole payload and the loop then + // read garbage as EBML ids. + _stream.Seek(compElement.DataSize, SeekOrigin.Current); break; } } @@ -778,11 +782,10 @@ private MatroskaSubtitleBlock ReadSubtitleBlock(Element blockElement, long clust System.Diagnostics.Debug.Print("Xiph lacing ({0} frames)", frames); break; case 4: // 00000100 = Fixed-size lacing + // Only the "frames - 1" count byte: fixed-size lacing has no per-lace + // size table (that is Xiph/EBML lacing), so reading one byte per frame + // here ate the first bytes of the actual subtitle payload. frames = _stream.ReadByte() + 1; - for (var i = 0; i < frames; i++) - { - _stream.ReadByte(); // frames - } System.Diagnostics.Debug.Print("Fixed-size lacing ({0} frames)", frames); break; case 6: // 00000110 = EMBL lacing @@ -801,6 +804,12 @@ private MatroskaSubtitleBlock ReadSubtitleBlock(Element blockElement, long clust } } + /// + /// True once has read the clusters; further calls are served from + /// memory. Callers use this to skip a progress window that would just flash by. + /// + public bool IsSubtitleDataLoaded => _subtitleRipLoaded; + public List GetSubtitle(int trackNumber, LoadMatroskaCallback progressCallback) { if (!_subtitleRipLoaded) diff --git a/src/libse/ContainerFormats/Mp4/Boxes/Mdhd.cs b/src/libse/ContainerFormats/Mp4/Boxes/Mdhd.cs index 0e95dd48818..b843687c65b 100644 --- a/src/libse/ContainerFormats/Mp4/Boxes/Mdhd.cs +++ b/src/libse/ContainerFormats/Mp4/Boxes/Mdhd.cs @@ -16,8 +16,18 @@ public class Mdhd : Box public readonly ulong Duration; public readonly string Iso639ThreeLetterCode; + // A media header is well under this; anything larger means the size field was misread. + private const ulong MaxSize = 1024 * 1024; + public Mdhd(Stream fs, ulong size) { + // "size" comes straight from the file - unsigned arithmetic on a too-small value used + // to underflow into a ~18 exabyte allocation. + if (size < 26 || size > MaxSize) + { + return; + } + Buffer = new byte[size - 4]; var bytesRead = fs.Read(Buffer, 0, Buffer.Length); if (bytesRead < Buffer.Length) @@ -27,6 +37,11 @@ public Mdhd(Stream fs, ulong size) var languageIndex = 20; int version = Buffer[0]; + if (version != 0 && Buffer.Length < 34) + { + return; // the 64-bit layout does not fit in what the size field declared + } + if (version == 0) { CreationTime = GetUInt(4); diff --git a/src/libse/ContainerFormats/Mp4/Boxes/Stbl.cs b/src/libse/ContainerFormats/Mp4/Boxes/Stbl.cs index e1b57adbf70..900733d3c72 100644 --- a/src/libse/ContainerFormats/Mp4/Boxes/Stbl.cs +++ b/src/libse/ContainerFormats/Mp4/Boxes/Stbl.cs @@ -75,7 +75,10 @@ public Stbl(Stream fs, ulong maximumLength, ulong timeScale, string handlerType, int version = Buffer[0]; var totalEntries = GetUInt(4); - var entries = ClampEntries(totalEntries, 8, 4); + // Entry 0 is GetUInt(8), i.e. bytes 8..11 - the helper wants the LAST byte + // of the first entry (the other call sites pass it correctly). With 8 the + // clamp allowed one entry too many and a short/truncated stco threw. + var entries = ClampEntries(totalEntries, 11, 4); ChunkOffsets.Capacity = ChunkOffsets.Count + entries; for (var i = 0; i < entries; i++) { @@ -93,7 +96,8 @@ public Stbl(Stream fs, ulong maximumLength, ulong timeScale, string handlerType, int version = Buffer[0]; var totalEntries = GetUInt(4); - var entries = ClampEntries(totalEntries, 8, 8); + // Entry 0 is GetUInt64(8), i.e. bytes 8..15 - see the stco note above. + var entries = ClampEntries(totalEntries, 15, 8); ChunkOffsets.Capacity = ChunkOffsets.Count + entries; for (var i = 0; i < entries; i++) { diff --git a/src/libse/ContainerFormats/Mp4/Boxes/Tfdt.cs b/src/libse/ContainerFormats/Mp4/Boxes/Tfdt.cs index 80b641be26c..38670556f7c 100644 --- a/src/libse/ContainerFormats/Mp4/Boxes/Tfdt.cs +++ b/src/libse/ContainerFormats/Mp4/Boxes/Tfdt.cs @@ -10,14 +10,21 @@ public class Tfdt : Box { public ulong BaseMediaDecodeTime { get; set; } + // A track fragment header is well under this; anything larger means the size was misread. + private const ulong MaxSize = 1024 * 1024; + public Tfdt(Stream fs, ulong size) { - var bufferSize = size - 8; - if (bufferSize <= 0) + // "size" comes straight from the file. The old "size - 8 <= 0" guard could never fire + // on an unsigned value, so a too-small size underflowed into a ~18 exabyte allocation. + // version/flags + a 32 or 64 bit decode time. + if (size < 16 || size > MaxSize) { return; } + var bufferSize = size - 8; + Buffer = new byte[bufferSize]; int bytesRead = fs.Read(Buffer, 0, Buffer.Length); if (bytesRead < Buffer.Length) @@ -30,6 +37,11 @@ public Tfdt(Stream fs, ulong size) if (version == 1) { + if (Buffer.Length < 12) + { + return; // the 64-bit layout does not fit in what the size field declared + } + BaseMediaDecodeTime = GetUInt64(4); } else diff --git a/src/libse/ContainerFormats/Mp4/Boxes/Tfhd.cs b/src/libse/ContainerFormats/Mp4/Boxes/Tfhd.cs index 1f9ea922edb..cd6bba0f765 100644 --- a/src/libse/ContainerFormats/Mp4/Boxes/Tfhd.cs +++ b/src/libse/ContainerFormats/Mp4/Boxes/Tfhd.cs @@ -31,14 +31,21 @@ public class Tfhd : Box //defaultSampleSize // 32 unsigned bit //defaultSampleFlags // 32 unsigned bit + // A track fragment header is well under this; anything larger means the size was misread. + private const ulong MaxSize = 1024 * 1024; + public Tfhd(Stream fs, ulong size) { - var bufferSize = size - 8; - if (bufferSize <= 0) + // "size" comes straight from the file. The old "size - 8 <= 0" guard could never fire + // on an unsigned value, so a too-small size underflowed into a ~18 exabyte allocation. + // version/flags + track id. + if (size < 16 || size > MaxSize) { return; } + var bufferSize = size - 8; + Buffer = new byte[bufferSize]; var bytesRead = fs.Read(Buffer, 0, Buffer.Length); if (bytesRead < Buffer.Length) @@ -57,6 +64,19 @@ public Tfhd(Stream fs, ulong size) TrackId = GetUInt(4); + // The flags claim which optional fields follow; a crafted size can declare fewer + // bytes than they need, and the readers below index the buffer unchecked. + var required = 8 + + (BaseDataOffsetPresent ? 8 : 0) + + (SampleDescriptionIndexPresent ? 4 : 0) + + (DefaultSampleDurationPresent ? 4 : 0) + + (DefaultSampleSizePresent ? 4 : 0) + + (DefaultSampleFlagsPresent ? 4 : 0); + if (Buffer.Length < required) + { + return; + } + var idx = 8; if (BaseDataOffsetPresent) { diff --git a/src/libse/ContainerFormats/Mp4/Boxes/Trun.cs b/src/libse/ContainerFormats/Mp4/Boxes/Trun.cs index d967bb94435..f91712dd95b 100644 --- a/src/libse/ContainerFormats/Mp4/Boxes/Trun.cs +++ b/src/libse/ContainerFormats/Mp4/Boxes/Trun.cs @@ -49,8 +49,15 @@ public Trun(Stream fs, ulong maximumLength) DataOffset = GetUInt(0); } - var sampleLength = Math.Min(sampleCount * 16 + 24, maximumLength); - Buffer = new byte[sampleLength]; + // maximumLength is the box's END POSITION in the file (Box.Position), not a byte count, + // so capping the sample table against it was inert - the size came entirely from the + // file's own sample_count, and a corrupt trun declaring 100M samples asked for 1.6 GB. + // Cap by the bytes actually left in the stream (never smaller than the real table, so + // valid files are unaffected) and do the multiply in 64-bit so it cannot wrap. + var bytesLeftInFile = fs.Length > fs.Position ? (ulong)(fs.Length - fs.Position) : 0UL; + var sampleLength = Math.Min((ulong)sampleCount * 16UL + 24UL, bytesLeftInFile); + sampleLength = Math.Min(sampleLength, int.MaxValue); + Buffer = new byte[(int)sampleLength]; readCount = fs.Read(Buffer, 0, Buffer.Length); if (readCount < 4) { diff --git a/src/libse/ContainerFormats/Mp4/Mp4VobSubPalette.cs b/src/libse/ContainerFormats/Mp4/Mp4VobSubPalette.cs index 104bb04e6af..2ee2a0ca1f0 100644 --- a/src/libse/ContainerFormats/Mp4/Mp4VobSubPalette.cs +++ b/src/libse/ContainerFormats/Mp4/Mp4VobSubPalette.cs @@ -132,8 +132,8 @@ private static List FromDecoderSpecificInfo(byte[] data, int offset, in for (var i = 0; i < PaletteEntries; i++) { var entry = offset + i * 4; - var rgb = BluRaySupPalette.YCbCr2Rgb(data[entry + 1], data[entry + 3], data[entry + 2], true); - palette.Add(new SKColor((byte)rgb[0], (byte)rgb[1], (byte)rgb[2])); + BluRaySupPalette.YCbCr2Rgb(data[entry + 1], data[entry + 3], data[entry + 2], true, out var r8, out var g8, out var b8); + palette.Add(new SKColor((byte)r8, (byte)g8, (byte)b8)); } return palette; diff --git a/src/libse/ContainerFormats/RiffParser.cs b/src/libse/ContainerFormats/RiffParser.cs index 521396ad2e5..1ca38befdca 100644 --- a/src/libse/ContainerFormats/RiffParser.cs +++ b/src/libse/ContainerFormats/RiffParser.cs @@ -231,14 +231,17 @@ public bool ReadElement(ref int bytesleft, ProcessChunkElement chunk, ProcessLis // Reduce bytes left bytesleft -= TwoDWordSize; - // Do we have enough bytes? - if (bytesleft < size) + // Do we have enough bytes? "size" is read as a signed int straight from the file, so a + // chunk declaring a size with the high bit set is negative here - that used to slip + // past this check and then *grow* bytesleft when subtracted below. + if (size < 0 || bytesleft < size) { - // Skip the bad data and throw an exception + // Skip the bad data and throw an exception (report the count before zeroing it) + var available = bytesleft; SkipData(bytesleft); bytesleft = 0; throw new RiffParserException("Element size mismatch for element " + FromFourCc(fourCc) - + " need " + size + " but have only " + bytesleft); + + " need " + size + " but have only " + available); } // Examine the element, is it a list or a chunk diff --git a/src/libse/ContainerFormats/TransportStream/ClutDefinitionSegment.cs b/src/libse/ContainerFormats/TransportStream/ClutDefinitionSegment.cs index 7d7c78a602b..7b71d3a079c 100644 --- a/src/libse/ContainerFormats/TransportStream/ClutDefinitionSegment.cs +++ b/src/libse/ContainerFormats/TransportStream/ClutDefinitionSegment.cs @@ -15,7 +15,10 @@ public ClutDefinitionSegment(byte[] buffer, int index, int segmentLength) ClutVersionNumber = buffer[index + 1] >> 4; int k = index + 2; - while (k + 6 <= index + segmentLength) + // A reduced-range entry is 4 bytes (id + flags + one packed word); only a + // full-range entry needs 6. Demanding 6 for every entry dropped the last + // reduced-range entry, and BuildClutLookup then paints its colour id red. + while (k + 4 <= index + segmentLength) { var rcse = new RegionClutSegmentEntry(); rcse.ClutEntryId = buffer[k]; @@ -28,6 +31,11 @@ public ClutDefinitionSegment(byte[] buffer, int index, int segmentLength) if (rcse.FullRangeFlag) { + if (k + 6 > index + segmentLength) + { + break; // truncated full-range entry + } + rcse.ClutEntryY = buffer[k + 2]; rcse.ClutEntryCr = buffer[k + 3]; rcse.ClutEntryCb = buffer[k + 4]; diff --git a/src/libse/ContainerFormats/TransportStream/DvbSubPes.cs b/src/libse/ContainerFormats/TransportStream/DvbSubPes.cs index 9228abd8493..c77a4583f80 100644 --- a/src/libse/ContainerFormats/TransportStream/DvbSubPes.cs +++ b/src/libse/ContainerFormats/TransportStream/DvbSubPes.cs @@ -35,6 +35,37 @@ public class DvbSubPes private readonly byte[] _dataBuffer; + private DvbSubPes(byte[] dataBuffer, ulong presentationTimestamp) + { + _dataBuffer = dataBuffer; + Length = dataBuffer.Length; + PresentationTimestamp = presentationTimestamp; + } + + /// + /// Wraps a teletext PES payload (data_identifier followed by data units) that arrives + /// without a PES header of its own - e.g. from a Manzanita "private_stream_1" dump, + /// where the packet boundaries and time stamps come from the XML data index instead. + /// + public static DvbSubPes FromTeletextPayload(byte[] payload, ulong presentationTimestamp) + { + return new DvbSubPes(payload, presentationTimestamp); + } + + /// + /// True if the buffer starts with an EBU teletext data_identifier (ETSI EN 300 472) + /// followed by a teletext or stuffing data unit of the mandatory 44-byte size. + /// + public static bool IsTeletextPayload(byte[] buffer) + { + return buffer.Length >= 46 && + buffer[0] >= 0x10 && buffer[0] <= 0x1f && + (buffer[1] == (int)Teletext.DataUnitT.DataUnitEbuTeletextNonSubtitle || + buffer[1] == (int)Teletext.DataUnitT.DataUnitEbuTeletextSubtitle || + buffer[1] == (int)Teletext.DataUnitT.DataUnitStuffing) && + buffer[2] == 44; + } + public DvbSubPes(byte[] buffer, int index) : this(buffer, index, buffer.Length) { diff --git a/src/libse/ContainerFormats/TransportStream/ManzanitaTeletextWriter.cs b/src/libse/ContainerFormats/TransportStream/ManzanitaTeletextWriter.cs new file mode 100644 index 00000000000..8a536808d6a --- /dev/null +++ b/src/libse/ContainerFormats/TransportStream/ManzanitaTeletextWriter.cs @@ -0,0 +1,938 @@ +using Nikse.SubtitleEdit.Core.Common; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +namespace Nikse.SubtitleEdit.Core.ContainerFormats.TransportStream +{ + /// + /// Writes subtitles as a Manzanita "private_stream_1" DVB teletext file (.dvbttx): an XML + /// preamble holding a packet index, followed by the raw teletext PES payloads that index + /// points into. reads the result back. + /// + /// The packet layout follows ETSI EN 300 472 / ETS 300 706 and mirrors a real broadcast dump: + /// one page header per subtitle (erase + subtitle flags set), one boxed double height row per + /// text line, and a filler page header closing the transmission. + /// + /// + public class ManzanitaTeletextWriter + { + /// + /// Teletext page carrying the subtitles, e.g. 888. + /// + public int PageNumber { get; set; } = 888; + + /// + /// Three letter ISO 639-2 language code for the teletext descriptor. + /// + public string LanguageCode { get; set; } = "eng"; + + public double FrameRate { get; set; } = 25.0; + + public string Creator { get; set; } = "Subtitle Edit"; + + /// + /// Written to the XML preamble - settable so tests can produce a stable file. + /// + public DateTime Date { get; set; } = DateTime.Now; + + /// + /// Teletext transmits the payload least significant bit first, so 0x27 is stored as 0xE4. + /// + private const byte FramingCode = 0xE4; + + private const byte DataIdentifier = 0x10; + private const byte DataUnitLength = 44; + private const int DataUnitSize = DataUnitLength + 2; // data_unit_id + data_unit_length + private const byte StuffingDataUnitId = (byte)Teletext.DataUnitT.DataUnitStuffing; + private const byte SubtitleDataUnitId = (byte)Teletext.DataUnitT.DataUnitEbuTeletextSubtitle; + + // Page FE in the same magazine is the usual "nothing to see here" filler that terminates + // a page transmission in serial mode. + private const int FillerPageBcd = 0xfe; + + // A teletext page is hidden one frame before the header that replaces it (Teletext.cs + // subtracts a fixed 40 ms), so the erase must be sent that much after the wanted end. + private const ulong HideOffsetMs = 40; + + private const int ColumnCount = 40; + private const int LastRow = 23; + private const int DefaultBottomRow = 22; // a double height row covers 22 and 23 + + // ETS 300 706, chapter 12.3: the enhancement packet and the triplets it holds. + private const int EnhancementPacketNumber = 26; + private const int TripletsPerPacket = 13; + private const byte SetActivePositionMode = 0x04; + + // ETS 300 706, chapter 12.3.1, table 27: an X/26 foreground colour triplet names one of + // the 32 colour map entries at a character position - the only road to the Level 2.5 + // colours beyond the eight a spacing attribute reaches. The map itself travels in a + // packet X/28/0. + private const byte ForegroundColorMode = 0x00; + private const int ColorMapPacketNumber = 28; + private const int RowAddressGroupStart = 40; + private const int TerminationAddress = 63; + private const byte TerminationMode = 0x1f; + private const byte TerminationData = 0x7f; + + // The designation code of an X/26 packet is four bits wide, so a page cannot carry more + // than sixteen of them - far past what even a full screen of accented text needs. + private const int MaxEnhancementPackets = 16; + + // Spacing attributes, ETS 300 706 chapter 12.2. + private const byte DoubleHeight = 0x0d; + private const byte StartBox = 0x0b; + private const byte EndBox = 0x0a; + private const byte AlphaWhite = 0x07; + private const byte Space = 0x20; + + // The first data unit of a packet claims this VBI line, the next one the line below, ... + private const int FirstLineOffset = 7; + + private enum Alignment + { + Left, + Center, + Right + } + + private class TeletextPacket + { + public ulong Milliseconds { get; set; } + public List DataUnits { get; } = new List(); + } + + /// + /// One cell of a teletext row: the seven bit code the row itself carries, plus the X/26 + /// triplet that overwrites it when the character needs the G2 set or a diacritical mark. + /// + private readonly struct Cell + { + public Cell(byte value) : this(value, 0, 0) + { + } + + public Cell(byte value, byte mode, byte data) : this(value, mode, data, -1) + { + } + + public Cell(byte value, byte mode, byte data, int colorEntry) + { + Value = value; + Mode = mode; + Data = data; + ColorEntry = colorEntry; + } + + public byte Value { get; } + + /// X/26 mode, or zero when the cell needs no enhancement. + public byte Mode { get; } + + public byte Data { get; } + + /// + /// The colour map entry an X/26 foreground colour triplet paints this cell (and the + /// rest of the run) with, or -1 when the Level 1 attributes already have it right. + /// + public int ColorEntry { get; } + } + + /// + /// An X/26 triplet waiting for the row layout to settle, so it knows its column. + /// + private class Enhancement + { + public int Row { get; set; } + public int Column { get; set; } + public byte Mode { get; set; } + public byte Data { get; set; } + } + + public void Write(Subtitle subtitle, string fileName) + { + File.WriteAllBytes(fileName, GetBytes(subtitle)); + } + + public byte[] GetBytes(Subtitle subtitle) + { + var packets = BuildPackets(subtitle); + + // EN 300 472 wants a constant PES payload size - pad every packet to the widest one. + var dataUnitsPerPacket = Math.Max(3, packets.Count == 0 ? 3 : packets.Max(p => p.DataUnits.Count)); + var payloadLength = 1 + dataUnitsPerPacket * DataUnitSize; + + var binary = new List(payloadLength * Math.Max(1, packets.Count)); + var index = new StringBuilder(); + var offset = 0; + foreach (var packet in packets) + { + binary.Add(DataIdentifier); + foreach (var dataUnit in packet.DataUnits) + { + binary.AddRange(dataUnit); + } + + for (var i = packet.DataUnits.Count; i < dataUnitsPerPacket; i++) + { + binary.AddRange(GetStuffingDataUnit()); + } + + index.Append(" \n"); + offset += payloadLength; + } + + var result = new List(offset + 2000); + result.AddRange(Encoding.ASCII.GetBytes(GetXml(index.ToString(), payloadLength))); + result.AddRange(binary); + return result.ToArray(); + } + + private string GetXml(string dataIndex, int payloadLength) + { + // A muxer hint only: how much bandwidth the stream needs if every frame carries a + // packet of this size, counting the PES header and the 188 byte transport packets. + var transportPackets = (int)Math.Ceiling((payloadLength + 14) / 184.0); + var rate = transportPackets * 188 * 8 * FrameRate; + + var magazine = PageNumber / 100 % 8; // magazine 8 is transmitted as 0 + var pageBcd = Teletext.DecToBec(PageNumber) & 0xff; + + var sb = new StringBuilder(); + sb.Append("\n\n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n"); + sb.Append(" \n\n"); + sb.Append(" \n"); + sb.Append(dataIndex); + sb.Append(" \n\n"); + + // The reader finds the binary section by the end tag plus a single line feed - keep it. + sb.Append("\n"); + return sb.ToString(); + } + + private string GetLanguageCode() + { + var code = (LanguageCode ?? string.Empty).Trim(); + return code.Length == 3 ? code.ToLowerInvariant() : "eng"; + } + + private static string XmlEscape(string text) + { + return (text ?? string.Empty) + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """); + } + + private List BuildPackets(Subtitle subtitle) + { + var packets = new List(); + if (subtitle?.Paragraphs == null) + { + return packets; + } + + var magazine = PageNumber / 100 % 8; + var pageBcd = Teletext.DecToBec(PageNumber) & 0xff; + var paragraphs = subtitle.Paragraphs; + var colorMap = new TeletextColorMap(); + + for (var i = 0; i < paragraphs.Count; i++) + { + var p = paragraphs[i]; + var show = new TeletextPacket { Milliseconds = ToMilliseconds(p.StartTime.TotalMilliseconds) }; + show.DataUnits.Add(GetPageHeaderDataUnit(magazine, pageBcd, show.DataUnits.Count, erasePage: true)); + show.DataUnits.AddRange(GetTextDataUnits(p, magazine, show.DataUnits.Count, colorMap)); + show.DataUnits.Add(GetPageHeaderDataUnit(magazine, FillerPageBcd, show.DataUnits.Count, erasePage: false)); + packets.Add(show); + + // Clearing the page is only needed when no other subtitle takes over: the header + // of the next subtitle erases this one anyway. + var eraseMs = ToMilliseconds(p.EndTime.TotalMilliseconds) + HideOffsetMs; + var next = i + 1 < paragraphs.Count ? paragraphs[i + 1] : null; + if (next != null && ToMilliseconds(next.StartTime.TotalMilliseconds) <= eraseMs) + { + continue; + } + + var erase = new TeletextPacket { Milliseconds = eraseMs }; + erase.DataUnits.Add(GetPageHeaderDataUnit(magazine, pageBcd, 0, erasePage: true)); + erase.DataUnits.Add(GetPageHeaderDataUnit(magazine, FillerPageBcd, 1, erasePage: false)); + packets.Add(erase); + } + + // Overlapping or unsorted subtitles would otherwise put the index out of order, and + // both the muxer and the reader expect the time stamps to grow. OrderBy is stable, so + // a page and the erase that belongs to it keep their order. + return packets.OrderBy(p => p.Milliseconds).ToList(); + } + + private static ulong ToMilliseconds(double milliseconds) + { + return milliseconds <= 0 ? 0 : (ulong)Math.Round(milliseconds, MidpointRounding.AwayFromZero); + } + + /// + /// Packet 0 of a page - page number, flags and the (suppressed) header row. + /// + private static byte[] GetPageHeaderDataUnit(int magazine, int pageBcd, int unitIndex, bool erasePage) + { + var data = new byte[ColumnCount]; + data[0] = TeletextHamming.Hamming84Encode(pageBcd & 0x0f); // page units + data[1] = TeletextHamming.Hamming84Encode((pageBcd >> 4) & 0x0f); // page tens + data[2] = TeletextHamming.Hamming84Encode(0); // S1 + data[3] = TeletextHamming.Hamming84Encode(erasePage ? 0x08 : 0x00); // S2 + C4 erase page + data[4] = TeletextHamming.Hamming84Encode(0); // S3 + data[5] = TeletextHamming.Hamming84Encode(0x08); // S4 + C6 subtitle + data[6] = TeletextHamming.Hamming84Encode(0x07); // C7 suppress header, C8 update, C9 interrupted + data[7] = TeletextHamming.Hamming84Encode(0x01); // C11 serial magazine, latin G0 charset + + // The header row is not displayed (C7), but the cells still have to be valid. + for (var i = 8; i < ColumnCount; i++) + { + data[i] = TeletextHamming.OddParityEncode(Space); + } + + return GetDataUnit(magazine, 0, data, unitIndex, alreadyEncoded: true); + } + + private static List GetTextDataUnits(Paragraph paragraph, int magazine, int firstUnitIndex, TeletextColorMap colorMap) + { + var result = new List(); + var text = paragraph.Text ?? string.Empty; + var alignment = GetAlignment(ref text, out var topAligned); + // Teletext has no italics or positioning of its own, so only the color tags survive. + var lines = Utilities.RemoveSsaTags( + HtmlUtil.RemoveOpenCloseTags(text, HtmlUtil.TagItalic, HtmlUtil.TagBold, HtmlUtil.TagUnderline)) + .SplitToLines() + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToList(); + if (lines.Count == 0) + { + return result; + } + + var rows = GetRowNumbers(lines.Count, paragraph.MarginV, topAligned, out var doubleHeight); + var enhancements = new List(); + var rowData = new List(); + for (var i = 0; i < lines.Count; i++) + { + rowData.Add(GetRow(lines[i], alignment, doubleHeight, rows[i], enhancements, colorMap)); + } + + // ETS 300 706, chapter 9.4.2: the colour map rides in a packet X/28/0, transmitted + // with every page so a decoder joining anywhere has it. Once any subtitle has put a + // colour in a redefinable entry, every later page carries the map - entries are only + // ever added, so a page is never sent ahead of an entry it uses. + if (colorMap.NeedsColorMapPacket) + { + result.Add(GetDataUnit(magazine, ColorMapPacketNumber, colorMap.GetColorMapPacketData(), + firstUnitIndex + result.Count, alreadyEncoded: true)); + } + + // ETS 300 706, annex B.2.2: packets with Y = 26 are transmitted before the rows they + // change, so a decoder has the enhancement in hand when the row arrives. + foreach (var enhancementPacket in GetEnhancementPackets(enhancements)) + { + result.Add(GetDataUnit(magazine, EnhancementPacketNumber, enhancementPacket, + firstUnitIndex + result.Count, alreadyEncoded: true)); + } + + for (var i = 0; i < rowData.Count; i++) + { + result.Add(GetDataUnit(magazine, rows[i], rowData[i], firstUnitIndex + result.Count, + alreadyEncoded: false)); + } + + return result; + } + + /// + /// Packs the triplets of a page into X/26 packets: every row starts with a "set active + /// position" triplet, then one triplet per character, and the rest of the packet is + /// filled with termination markers. + /// + private static List GetEnhancementPackets(List enhancements) + { + if (enhancements.Count == 0) + { + return new List(); + } + + var triplets = new List> { new List() }; + foreach (var row in enhancements.GroupBy(e => e.Row).OrderBy(g => g.Key)) + { + var ordered = row.OrderBy(e => e.Column).ToList(); + var index = 0; + while (index < ordered.Count) + { + // The active position only holds within one packet, so a row that is split + // over two packets has to set it again - and a set active position as the + // last triplet of a packet would say nothing. + var current = triplets[triplets.Count - 1]; + if (TripletsPerPacket - current.Count < 2) + { + if (triplets.Count == MaxEnhancementPackets) + { + // Nothing is lost that a reader could have shown anyway: the row keeps + // the plain stand-in these triplets would have overwritten. + return GetEnhancementPacketBytes(triplets); + } + + current = new List(); + triplets.Add(current); + } + + var count = Math.Min(ordered.Count - index, TripletsPerPacket - current.Count - 1); + current.Add(GetTriplet(RowAddressGroupStart + row.Key, SetActivePositionMode, ordered[index].Column)); + for (var i = 0; i < count; i++) + { + var enhancement = ordered[index + i]; + current.Add(GetTriplet(enhancement.Column, enhancement.Mode, enhancement.Data)); + } + + index += count; + } + } + + return GetEnhancementPacketBytes(triplets); + } + + private static List GetEnhancementPacketBytes(List> triplets) + { + var packets = new List(); + for (var i = 0; i < triplets.Count; i++) + { + var data = new byte[ColumnCount]; + data[0] = TeletextHamming.Hamming84Encode(i); // designation code + + for (var t = 0; t < TripletsPerPacket; t++) + { + var value = t < triplets[i].Count + ? triplets[i][t] + : GetTriplet(TerminationAddress, TerminationMode, TerminationData); + var encoded = TeletextHamming.Hamming2418Encode(value); + data[1 + t * 3] = (byte)encoded; + data[2 + t * 3] = (byte)(encoded >> 8); + data[3 + t * 3] = (byte)(encoded >> 16); + } + + packets.Add(data); + } + + return packets; + } + + /// + /// ETS 300 706, chapter 12.3.2: an X/26 triplet is an address, a mode and a data field. + /// + private static int GetTriplet(int address, int mode, int data) + { + return (address & 0x3f) | ((mode & 0x1f) << 6) | ((data & 0x7f) << 11); + } + + /// + /// The row of each line, counted down from the row the block starts on - MarginV when the + /// subtitle carries one, otherwise bottom anchored on . Or + /// the top of the screen for {\an7}-{\an9}. A double height row occupies the row below it + /// too, hence the gap of two. + /// + private static List GetRowNumbers(int lineCount, string marginV, bool topAligned, out bool doubleHeight) + { + doubleHeight = true; + var rows = new List(); + + if (topAligned && !IsTeletextRow(marginV)) + { + // Teletext.cs reports {\an8} again when every used row is above row 6. + for (var i = 0; i < lineCount; i++) + { + rows.Add(1 + i * 2); + } + + if (rows[rows.Count - 1] <= LastRow) + { + return rows; + } + + rows.Clear(); + } + + var spacing = 2; + var start = GetStartRow(lineCount, marginV, spacing); + if (start < 1) + { + // Too many lines to fit at double height - fall back to single height rows. + spacing = 1; + doubleHeight = false; + start = GetStartRow(lineCount, marginV, spacing); + } + + // More lines than the page has rows even at single height - keep every row on the page + // rather than emitting one that no decoder can address. + start = Math.Max(1, start); + + for (var i = 0; i < lineCount; i++) + { + rows.Add(Math.Min(LastRow, start + i * spacing)); + } + + return rows; + } + + /// + /// The row the *first* line goes on. MarginV is the EBU STL vertical position: the row the + /// line starts on, with the rest of the block below it - the same meaning Ebu.Save, + /// TeletextRowHelper (the alignment picker and the TT column) and SubtitlePositionToAssa + /// (the video preview) all use. Reading it as the bottom row instead put every multi-line + /// subtitle two rows too high and one row out of step with the preview. + /// Without a MarginV the block is bottom anchored so its last row lands on + /// . + /// + private static int GetStartRow(int lineCount, string marginV, int spacing) + { + var start = IsTeletextRow(marginV) + ? int.Parse(marginV, CultureInfo.InvariantCulture) + : DefaultBottomRow - (lineCount - 1) * spacing; + + // The text may have been re-wrapped since the row was set, so a start row that no + // longer leaves room for every line would push the tail off the page - the same clamp + // Ebu.Save applies to the STL vertical position. + if (start + (lineCount - 1) * spacing > LastRow) + { + start = LastRow - (lineCount - 1) * spacing; + } + + return start; + } + + private static bool IsTeletextRow(string marginV) + { + return int.TryParse(marginV, NumberStyles.Integer, CultureInfo.InvariantCulture, out var row) && + row >= 1 && row <= LastRow; + } + + private static Alignment GetAlignment(ref string text, out bool topAligned) + { + topAligned = false; + var alignment = Alignment.Center; + if (text.Length > 5 && text[0] == '{' && text[1] == '\\' && text[5] == '}') + { + var tag = text.Substring(0, 6); + switch (tag) + { + case "{\\an1}": + case "{\\an4}": + alignment = Alignment.Left; + break; + case "{\\an7}": + alignment = Alignment.Left; + topAligned = true; + break; + case "{\\an3}": + case "{\\an6}": + alignment = Alignment.Right; + break; + case "{\\an9}": + alignment = Alignment.Right; + topAligned = true; + break; + case "{\\an8}": + topAligned = true; + break; + } + + if (tag.StartsWith("{\\an", StringComparison.Ordinal)) + { + text = text.Substring(6); + } + } + + return alignment; + } + + /// + /// Turns one line of text into the 40 cells of a teletext row: a leading color attribute, + /// the double height and start box attributes, the text itself and the end box markers, + /// padded to the wanted alignment. + /// + private static byte[] GetRow(string line, Alignment alignment, bool doubleHeight, int row, List enhancements, TeletextColorMap colorMap) + { + var lead = new List(); + var body = new List(); + AppendText(line, lead, body, colorMap); + + var attributes = doubleHeight ? new List { DoubleHeight, StartBox, StartBox } : new List { StartBox, StartBox }; + var maxBody = ColumnCount - lead.Count - attributes.Count; + if (body.Count > maxBody) + { + body.RemoveRange(maxBody, body.Count - maxBody); + } + + // A row ending in the last column closes the box on its own, so the end box markers + // only take the space that is left - that is what real broadcast rows do, and it buys + // the two characters that would otherwise clip a full width line. + var endBoxCount = Math.Min(2, ColumnCount - lead.Count - attributes.Count - body.Count); + var used = lead.Count + attributes.Count + body.Count + endBoxCount; + var leftPad = 0; + if (alignment == Alignment.Center) + { + leftPad = (ColumnCount - used) / 2; + } + else if (alignment == Alignment.Right) + { + leftPad = ColumnCount - used; + } + + var cells = new List(ColumnCount); + cells.AddRange(Enumerable.Repeat(Space, leftPad)); + cells.AddRange(lead); + cells.AddRange(attributes); + + // Now that the padding is known, the cells that need an X/26 triplet know their column. + var bodyStart = cells.Count; + for (var i = 0; i < body.Count; i++) + { + cells.Add(body[i].Value); + + // The foreground colour triplet goes first at its column, so that a G2 character + // at the same cell (which only touches the text plane) cannot be affected. + if (body[i].ColorEntry >= 0) + { + enhancements.Add(new Enhancement + { + Row = row, + Column = bodyStart + i, + Mode = ForegroundColorMode, + Data = (byte)body[i].ColorEntry + }); + } + + if (body[i].Mode != 0) + { + enhancements.Add(new Enhancement + { + Row = row, + Column = bodyStart + i, + Mode = body[i].Mode, + Data = body[i].Data + }); + } + } + + for (var i = 0; i < endBoxCount; i++) + { + cells.Add(EndBox); + } + + while (cells.Count < ColumnCount) + { + cells.Add(Space); + } + + return cells.ToArray(); + } + + /// + /// Splits the line into the color attribute that applies before the box starts (teletext + /// shows a spacing attribute as a space, so a leading color is free of charge there) and + /// the boxed cells, where every further color change costs one cell. + /// + private static void AppendText(string line, List lead, List body, TeletextColorMap colorMap) + { + // A colour that needs a colour map entry is painted by an X/26 triplet on the first + // character cell that follows - on the spacing attribute's own cell the Level 1 + // attribute would win, and a spacing attribute in the lead has no body column at all. + var pendingColorEntry = -1; + + var i = 0; + while (i < line.Length) + { + if (line[i] == '<') + { + var end = line.IndexOf('>', i); + if (end > 0) + { + var tag = line.Substring(i, end - i + 1); + if (tag.StartsWith("", StringComparison.OrdinalIgnoreCase)) + { + pendingColorEntry = -1; + + // Back to the start-of-row default, but only when text follows. + if (body.Count > 0 && end + 1 < line.Length) + { + body.Add(new Cell(AlphaWhite)); + } + + i = end + 1; + continue; + } + } + } + + foreach (var cell in GetTeletextCells(line[i])) + { + if (pendingColorEntry >= 0) + { + body.Add(new Cell(cell.Value, cell.Mode, cell.Data, pendingColorEntry)); + pendingColorEntry = -1; + } + else + { + body.Add(cell); + } + } + + i++; + } + } + + /// + /// Where a character has no seven bit G0 code but the row can still show it through an + /// X/26 triplet, this is what stays in the row itself - all a Level 1.0 decoder, which + /// ignores the enhancement, has to go on. "#" for the music note is what broadcasters + /// send; ZDF page 777 does exactly that. + /// + private static readonly Dictionary Level1Fallbacks = new Dictionary + { + { '♪', '#' }, { '€', 'E' }, { '©', 'C' }, { '®', 'R' }, { '™', 'T' }, + { '‘', '\'' }, { '’', '\'' }, { '“', '"' }, { '”', '"' }, + { '¡', '!' }, { '¿', '?' }, { '―', '-' }, { '_', '-' }, { '°', 'o' }, { '×', 'x' }, + { 'Æ', 'A' }, { 'æ', 'a' }, { 'Ø', 'O' }, { 'ø', 'o' }, { 'Œ', 'O' }, { 'œ', 'o' }, + { 'ß', 's' }, { 'Ł', 'L' }, { 'ł', 'l' }, { 'Þ', 'P' }, { 'þ', 'p' }, + { 'Đ', 'D' }, { 'đ', 'd' }, { 'ð', 'd' }, { 'Ħ', 'H' }, { 'ħ', 'h' }, { 'ı', 'i' }, + { 'IJ', 'I' }, { 'ij', 'i' }, { 'Ŧ', 'T' }, { 'ŧ', 't' }, { 'Ŋ', 'N' }, { 'ŋ', 'n' }, + { 'ª', 'a' }, { 'º', 'o' }, { 'µ', 'u' }, { 'Ω', 'O' }, { 'α', 'a' } + }; + + /// + /// Characters that neither set has a code for: the printable ASCII the Latin G0 set gives + /// to the national options instead, and the typography a subtitle is full of. + /// + private static readonly Dictionary Substitutes = new Dictionary + { + { '[', "(" }, { ']', ")" }, { '{', "(" }, { '}', ")" }, + { '\\', "/" }, { '|', "/" }, { '`', "'" }, { '~', "-" }, + { '—', "-" }, { '–', "-" }, { '‒', "-" }, { '−', "-" }, { '‐', "-" }, + { '…', "..." }, { ' ', " " }, { '′', "'" }, { '″', "\"" }, + { '„', "\"" }, { '‚', "'" }, { '‹', "<" }, { '›', ">" } + }; + + /// + /// Turns one character into the cells that carry it: its G0 code where the row can hold + /// it, otherwise a stand-in plus the X/26 triplet that overwrites the cell with the G2 + /// character or the accented letter. What is left over is folded to its base letters + /// ("ǽ" to "ae"), and only then replaced by a question mark. + /// + private static IEnumerable GetTeletextCells(char c) + { + if (TeletextTables.TryGetLatinG0Code(c, out var code)) + { + yield return new Cell(code); + yield break; + } + + if (TeletextTables.TryGetG2Replacement(c, out var replacement)) + { + yield return new Cell(GetLevel1Fallback(c, replacement), replacement.Mode, replacement.Data); + yield break; + } + + if (Substitutes.TryGetValue(c, out var substitute)) + { + foreach (var s in substitute) + { + if (TeletextTables.TryGetLatinG0Code(s, out var substituteCode)) + { + yield return new Cell(substituteCode); + } + } + + yield break; + } + + var normalized = c.ToString().Normalize(NormalizationForm.FormD); + var any = false; + foreach (var n in normalized) + { + if (TeletextTables.TryGetLatinG0Code(n, out var folded)) + { + any = true; + yield return new Cell(folded); + } + } + + if (!any) + { + yield return new Cell((byte)'?'); + } + } + + private static byte GetLevel1Fallback(char c, TeletextTables.G2Replacement replacement) + { + // A diacritical mark is put on a plain letter, which is a fine stand-in by itself. + if (replacement.Mode != TeletextTables.G2Mode) + { + return replacement.Data; + } + + if (Level1Fallbacks.TryGetValue(c, out var fallback) && + TeletextTables.TryGetLatinG0Code(fallback, out var code)) + { + return code; + } + + return Space; + } + + /// + /// The colour of a font tag at the resolution the teletext colour map has: four bits per + /// component, red in the high nibble. + /// + private static bool TryParseColor(string fontTag, out int rgb12) + { + rgb12 = 0; + var colorStart = fontTag.IndexOf("color=", StringComparison.OrdinalIgnoreCase); + if (colorStart < 0) + { + return false; + } + + var color = fontTag.Substring(colorStart + "color=".Length).TrimStart().TrimEnd('>', '/', ' '); + color = color.Trim('"', '\'').Trim().Trim('#').ToLowerInvariant(); + if (color.Length == 0) + { + return false; + } + + switch (color) + { + case "black": rgb12 = 0x000; return true; + case "red": rgb12 = 0xf00; return true; + case "lime": + case "green": rgb12 = 0x0f0; return true; + case "yellow": rgb12 = 0xff0; return true; + case "blue": rgb12 = 0x00f; return true; + case "magenta": + case "fuchsia": rgb12 = 0xf0f; return true; + case "cyan": + case "aqua": rgb12 = 0x0ff; return true; + case "white": rgb12 = 0xfff; return true; + } + + if (color.Length == 6 && + int.TryParse(color.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) && + int.TryParse(color.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) && + int.TryParse(color.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b)) + { + // Round each component to the four bits the map holds - 0x11 steps, so #ff8822 + // becomes 0xf82 and reads back as exactly #ff8822. + rgb12 = TeletextColorMap.QuantizeRgb(r, g, b); + return true; + } + + return false; + } + + private static byte[] GetStuffingDataUnit() + { + var unit = new byte[DataUnitSize]; + unit[0] = StuffingDataUnitId; + unit[1] = DataUnitLength; + for (var i = 2; i < unit.Length; i++) + { + unit[i] = 0xff; + } + + return unit; + } + + /// + /// Wraps 40 data bytes in a teletext data unit: the VBI line, the framing code, the + /// Hamming 8/4 coded magazine and packet number, and the payload - all but the first byte + /// stored least significant bit first, as they go on air. + /// + private static byte[] GetDataUnit(int magazine, int packetNumber, byte[] data, int unitIndex, bool alreadyEncoded) + { + var unit = new byte[DataUnitSize]; + unit[0] = SubtitleDataUnitId; + unit[1] = DataUnitLength; + + var lineOffset = Math.Min(22, FirstLineOffset + Math.Max(0, unitIndex)); + unit[2] = (byte)(0xc0 | lineOffset); // reserved_future_use, field_parity, line_offset + unit[3] = FramingCode; + + var address = ((packetNumber & 0x1f) << 3) | (magazine & 0x07); + unit[4] = TeletextHamming.Reverse8[TeletextHamming.Hamming84Encode(address & 0x0f)]; + unit[5] = TeletextHamming.Reverse8[TeletextHamming.Hamming84Encode((address >> 4) & 0x0f)]; + + for (var i = 0; i < ColumnCount; i++) + { + var value = alreadyEncoded ? data[i] : TeletextHamming.OddParityEncode(data[i]); + unit[6 + i] = TeletextHamming.Reverse8[value]; + } + + return unit; + } + } +} diff --git a/src/libse/ContainerFormats/TransportStream/ManzanitaTransportStreamParser.cs b/src/libse/ContainerFormats/TransportStream/ManzanitaTransportStreamParser.cs index 18d3441f4f3..683360fa0b3 100644 --- a/src/libse/ContainerFormats/TransportStream/ManzanitaTransportStreamParser.cs +++ b/src/libse/ContainerFormats/TransportStream/ManzanitaTransportStreamParser.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Xml; @@ -13,11 +14,45 @@ namespace Nikse.SubtitleEdit.Core.ContainerFormats.TransportStream /// public class ManzanitaTransportStreamParser { + /// + /// The "type" attribute of a Manzanita file carrying DVB teletext (as opposed to + /// "dvb_subtitle", which carries the bitmap subtitles handled by ). + /// + public const string TeletextStreamType = "dvb_teletext"; + + // Teletext state in Teletext.cs is keyed by the transport stream packet id; a Manzanita + // dump holds a single elementary stream, so any fixed id will do. + private const int TeletextPacketId = 1; + + // "" - the XML preamble ends here and the binary payloads follow. + private static readonly byte[] EndTag = Encoding.ASCII.GetBytes(""); + + // How much of the preamble is read at a time; it is read in full however long it is. + private const int PreambleChunkSize = 200_000; + + // Backstop for a file that has no end tag at all, so it is not read into memory whole. + // No real preamble comes near this: 64 MB is over half a million packet lines. + private const int MaxPreambleSize = 64 * 1024 * 1024; + private readonly List _dvbSubs; + private readonly List _teletextPes; + + /// + /// Teletext page numbers (decimal, e.g. 888) seen in the parsed file. + /// + public List TeletextPages { get; } + + /// + /// The ISO 639-2 language code of the teletext descriptor in the XML preamble, or an + /// empty string when the file does not carry one. + /// + public string LanguageCode { get; private set; } = string.Empty; public ManzanitaTransportStreamParser() { _dvbSubs = new List(); + _teletextPes = new List(); + TeletextPages = new List(); } public void Parse(string fileName) @@ -34,7 +69,8 @@ public void Parse(string fileName) /// Input stream public void Parse(Stream ms) { - var dataIndices = GetDataIndicesAndPesStart(ms, out var dvbPesStartIndex); + var dataIndices = GetDataIndicesAndPesStart(ms, out var dvbPesStartIndex, out var streamType, out var languageCode); + LanguageCode = languageCode; if (dvbPesStartIndex <= 0) { return; @@ -51,6 +87,31 @@ public void Parse(Stream ms) break; // incomplete packet at end-of-file } + // A teletext dump holds the bare PES payload - data_identifier plus data units - + // so it has no PES header for the constructors below to read. + if (streamType == TeletextStreamType || DvbSubPes.IsTeletextPayload(pesData)) + { + if (!DvbSubPes.IsTeletextPayload(pesData)) + { + continue; // a teletext stream, but not in a shape this can decode + } + + var teletextPes = DvbSubPes.FromTeletextPayload(pesData, dataIndex.Pts); + foreach (var page in teletextPes.PrepareTeletext()) // also flips the bit order - call once per packet + { + // Page numbers are binary coded decimals, so the filler pages FF and FE + // that close a transmission read back as 965 and 964 - out of range for a + // real page (magazine 1-8, page 00-99) and empty anyway. + if (page >= 100 && page <= 899 && !TeletextPages.Contains(page)) + { + TeletextPages.Add(page); + } + } + + _teletextPes.Add(teletextPes); + continue; + } + DvbSubPes pes; if (VobSubParser.IsMpeg2PackHeader(pesData)) { @@ -71,22 +132,34 @@ public void Parse(Stream ms) } public static IEnumerable GetDataIndicesAndPesStart(Stream ms, out int startIndex) + { + return GetDataIndicesAndPesStart(ms, out startIndex, out _); + } + + /// + /// Same as , but also reports the + /// "type" attribute of the root element ("dvb_teletext", "dvb_subtitle", ...). + /// + public static IEnumerable GetDataIndicesAndPesStart(Stream ms, out int startIndex, out string streamType) + { + return GetDataIndicesAndPesStart(ms, out startIndex, out streamType, out _); + } + + private static IEnumerable GetDataIndicesAndPesStart(Stream ms, out int startIndex, out string streamType, out string languageCode) { startIndex = 0; + streamType = string.Empty; + languageCode = string.Empty; ms.Position = 0; - var buffer = new byte[200_000]; - var bytesRead = ms.Read(buffer, 0, buffer.Length); - var xml = Encoding.UTF8.GetString(buffer, 0, bytesRead); - const string endTag = ""; - var endIndex = xml.IndexOf(endTag, StringComparison.Ordinal); + var buffer = ReadPreamble(ms, out var bytesRead, out var endIndex); if (endIndex < 0) { return new List(); } - startIndex = FindBinaryStartIndex(bytesRead, buffer, endTag); + startIndex = GetBinaryStartIndex(buffer, bytesRead, endIndex); - xml = xml.Substring(0, endIndex + endTag.Length); + var xml = Encoding.UTF8.GetString(buffer, 0, endIndex + EndTag.Length); var xmlDoc = new XmlDocument { XmlResolver = null }; xmlDoc.LoadXml(xml); const string ns = "http://www.manzanitasystems.com/schema/v1.03/private_stream_1"; @@ -99,6 +172,11 @@ public static IEnumerable GetDataIndicesAndPesStart(Stream m return result; } + streamType = xmlDoc.DocumentElement.Attributes?["type"]?.Value ?? string.Empty; + + var teletextContentNode = xmlDoc.DocumentElement.SelectSingleNode("//ns:dvb_teletext_content", namespaceManager); + languageCode = teletextContentNode?.Attributes?["ISO_639_language_code"]?.Value ?? string.Empty; + var dataIndexNode = xmlDoc.DocumentElement.SelectSingleNode("ns:data_index", namespaceManager); if (dataIndexNode == null) { @@ -137,38 +215,136 @@ public static IEnumerable GetDataIndicesAndPesStart(Stream m return result; } - private static int FindBinaryStartIndex(int bytesRead, byte[] buffer, string endTag) + /// + /// The binary section starts right after the end tag and the line feed that closes its + /// line (</private_stream_1> + 0x0a). Zero when that line feed is not there: + /// the offsets in the data index are counted from it, so there is nothing to slice. + /// + private static int GetBinaryStartIndex(byte[] buffer, int bytesRead, int endTagIndex) + { + var afterEndTag = endTagIndex + EndTag.Length; + return afterEndTag < bytesRead && buffer[afterEndTag] == 0x0a ? afterEndTag + 1 : 0; + } + + /// + /// Reads the file up to and including the XML preamble, growing the buffer until the end + /// tag turns up. + /// + /// + /// The preamble carries one <packet ... /> line per teletext or DVB packet, + /// so it grows with the file - a .dvbttx written from some 1650 subtitles already needs + /// more than the 200 KB that used to be read in one go. A preamble that did not fit was + /// reported as "no packets at all" and the file then opened as nothing, with no error. + /// + /// The stream to read from, positioned at the start of the file. + /// Bytes actually in the returned buffer. + /// Index of the end tag, or -1 when there is none. + private static byte[] ReadPreamble(Stream ms, out int bytesRead, out int endTagIndex) + { + var buffer = new byte[PreambleChunkSize]; + bytesRead = 0; + endTagIndex = -1; + + while (true) + { + if (bytesRead == buffer.Length) + { + if (buffer.Length >= MaxPreambleSize) + { + return buffer; // not a Manzanita file - give up rather than read it all + } + + Array.Resize(ref buffer, Math.Min(MaxPreambleSize, buffer.Length * 2)); + } + + var read = ms.Read(buffer, bytesRead, buffer.Length - bytesRead); + if (read <= 0) + { + return buffer; // end of file + } + + if (endTagIndex < 0) + { + // Only the new bytes need looking at, less the overlap an end tag split + // across two reads would sit in. + var searchFrom = Math.Max(0, bytesRead - (EndTag.Length - 1)); + bytesRead += read; + endTagIndex = IndexOfEndTag(buffer, bytesRead, searchFrom); + } + else + { + bytesRead += read; + } + + // One byte past the end tag as well: that is the line feed GetBinaryStartIndex + // needs, and a read can stop right on the tag. + if (endTagIndex >= 0 && bytesRead > endTagIndex + EndTag.Length) + { + return buffer; + } + } + } + + private static int IndexOfEndTag(byte[] buffer, int bytesRead, int startAt) + { + for (var i = startAt; i <= bytesRead - EndTag.Length; i++) + { + var match = true; + for (var j = 0; j < EndTag.Length; j++) + { + if (buffer[i + j] != EndTag[j]) + { + match = false; + break; + } + } + + if (match) + { + return i; + } + } + + return -1; + } + + /// + /// Decodes every teletext page found while parsing. + /// + /// Page number (decimal, e.g. 888) to the subtitles on that page. + public Dictionary> GetTeletext() { - for (var i = 0; i < bytesRead - 20; i++) + var result = new Dictionary>(); + foreach (var page in TeletextPages.OrderBy(p => p)) { - // + 0x0a - // 3C 2F 70 72 69 76 61 74 65 5F 73 74 72 65 61 6D 5F 31 3E 0A - if (buffer[i + 0] == 0x3c && - buffer[i + 1] == 0x2f && - buffer[i + 2] == 0x70 && - buffer[i + 3] == 0x72 && - buffer[i + 4] == 0x69 && - buffer[i + 5] == 0x76 && - buffer[i + 6] == 0x61 && - buffer[i + 7] == 0x74 && - buffer[i + 8] == 0x65 && - buffer[i + 9] == 0x5f && - buffer[i + 10] == 0x73 && - buffer[i + 11] == 0x74 && - buffer[i + 12] == 0x72 && - buffer[i + 13] == 0x65 && - buffer[i + 14] == 0x61 && - buffer[i + 15] == 0x6d && - buffer[i + 16] == 0x5f && - buffer[i + 17] == 0x31 && - buffer[i + 18] == 0x3e && - buffer[i + 19] == 0x0a) - { - return i + endTag.Length + 1; + var pageBcd = Teletext.DecToBec(page); + Teletext.InitializeStaticFields(TeletextPacketId, pageBcd); + // Unlike a transport stream there is no video track to measure the start of the + // programme against, so the time stamps in the index are used as they are - a + // file whose first subtitle sits ten minutes in must not slide to zero. + var runSettings = new TeletextRunSettings(null); + var paragraphs = new List(); + foreach (var pes in _teletextPes) + { + foreach (var kvp in pes.GetTeletext(runSettings, page, pageBcd)) + { + paragraphs.Add(kvp.Value); + } + } + + // The last page has no following page header to close it - flush it by hand. + foreach (var kvp in Teletext.ProcessTelxPacketPendingLeftovers(runSettings, page)) + { + paragraphs.Add(kvp.Value); + } + + if (paragraphs.Count > 0) + { + result.Add(page, paragraphs); } } - return 0; + return result; } public List GetDvbSup() diff --git a/src/libse/ContainerFormats/TransportStream/ObjectDataSegment.cs b/src/libse/ContainerFormats/TransportStream/ObjectDataSegment.cs index 084272f7c5d..5de2a8c28d6 100644 --- a/src/libse/ContainerFormats/TransportStream/ObjectDataSegment.cs +++ b/src/libse/ContainerFormats/TransportStream/ObjectDataSegment.cs @@ -94,30 +94,39 @@ public void DecodeImage(byte[] buffer, int index, ClutDefinitionSegment cds) _fastImage = new FastBitmap(Image); _fastImage.LockImage(); + var clutLookup = BuildClutLookup(cds); + x = 0; y = 0; index = start; while (index < start + TopFieldDataBlockLength && index < buffer.Length) { - index = ProcessDataType(buffer, index, cds, ref dataType, start, twoToFourBitColorLookup, fourToEightBitColorLookup, twoToEightBitColorLookup, ref x, ref y, length, ref pixelCode, ref runLength); + index = ProcessDataType(buffer, index, clutLookup, ref dataType, start, twoToFourBitColorLookup, fourToEightBitColorLookup, twoToEightBitColorLookup, ref x, ref y, length, ref pixelCode, ref runLength); } x = 0; y = 1; + // EN 300 743 7.2.5: a zero bottom_field_data_block_length means the bottom + // field repeats the top field's pixel data, so decode the top block again - + // and bound the loop by the block actually being read, not by the (zero) + // bottom length, which would end the loop before a single run was drawn. + int bottomLength; if (BottomFieldDataBlockLength == 0) { index = start; + bottomLength = TopFieldDataBlockLength; } else { length = BottomFieldDataBlockLength; index = start + TopFieldDataBlockLength; start = index; + bottomLength = BottomFieldDataBlockLength - 1; } dataType = buffer[index - 1]; - while (index < start + BottomFieldDataBlockLength - 1 && index < buffer.Length) + while (index < start + bottomLength && index < buffer.Length) { - index = ProcessDataType(buffer, index, cds, ref dataType, start, twoToFourBitColorLookup, fourToEightBitColorLookup, twoToEightBitColorLookup, ref x, ref y, length, ref pixelCode, ref runLength); + index = ProcessDataType(buffer, index, clutLookup, ref dataType, start, twoToFourBitColorLookup, fourToEightBitColorLookup, twoToEightBitColorLookup, ref x, ref y, length, ref pixelCode, ref runLength); } _fastImage.UnlockImage(); } @@ -170,12 +179,13 @@ public Position FindPosition(byte[] buffer, int index, ClutDefinitionSegment cds } var height = y + 1; + var clutLookup = BuildClutLookup(cds); x = 0; y = 0; index = start; while (index < start + TopFieldDataBlockLength && index < buffer.Length) { - index = ProcessDataTypeForPosition(buffer, index, cds, ref dataType, start, twoToFourBitColorLookup, fourToEightBitColorLookup, twoToEightBitColorLookup, ref x, ref y, length, ref pixelCode, ref runLength, width, height, out Position subPos); + index = ProcessDataTypeForPosition(buffer, index, clutLookup, ref dataType, start, twoToFourBitColorLookup, fourToEightBitColorLookup, twoToEightBitColorLookup, ref x, ref y, length, ref pixelCode, ref runLength, width, height, out Position subPos); if (subPos.Left < pos.Left) { pos.Left = subPos.Left; @@ -188,20 +198,27 @@ public Position FindPosition(byte[] buffer, int index, ClutDefinitionSegment cds x = 0; y = 1; + // EN 300 743 7.2.5: a zero bottom_field_data_block_length means the bottom + // field repeats the top field's pixel data, so decode the top block again - + // and bound the loop by the block actually being read, not by the (zero) + // bottom length, which would end the loop before a single run was drawn. + int bottomLength; if (BottomFieldDataBlockLength == 0) { index = start; + bottomLength = TopFieldDataBlockLength; } else { length = BottomFieldDataBlockLength; index = start + TopFieldDataBlockLength; start = index; + bottomLength = BottomFieldDataBlockLength - 1; } dataType = buffer[index - 1]; - while (index < start + BottomFieldDataBlockLength - 1 && index < buffer.Length) + while (index < start + bottomLength && index < buffer.Length) { - index = ProcessDataTypeForPosition(buffer, index, cds, ref dataType, start, twoToFourBitColorLookup, fourToEightBitColorLookup, twoToEightBitColorLookup, ref x, ref y, length, ref pixelCode, ref runLength, width, height, out Position subPos); + index = ProcessDataTypeForPosition(buffer, index, clutLookup, ref dataType, start, twoToFourBitColorLookup, fourToEightBitColorLookup, twoToEightBitColorLookup, ref x, ref y, length, ref pixelCode, ref runLength, width, height, out Position subPos); if (subPos.Left < pos.Left) { pos.Left = subPos.Left; @@ -220,7 +237,7 @@ public Position FindPosition(byte[] buffer, int index, ClutDefinitionSegment cds return new Position(pos.Left == int.MaxValue ? 0 : pos.Left, pos.Top == int.MaxValue ? 0 : pos.Top); } - private int ProcessDataType(byte[] buffer, int index, ClutDefinitionSegment cds, ref int dataType, int start, + private int ProcessDataType(byte[] buffer, int index, SKColor[] clutLookup, ref int dataType, int start, List twoToFourBitColorLookup, List fourToEightBitColorLookup, List twoToEightBitColorLookup, ref int x, ref int y, int length, ref int pixelCode, ref int runLength) { @@ -229,7 +246,7 @@ private int ProcessDataType(byte[] buffer, int index, ClutDefinitionSegment cds, var bitIndex = 0; while (index < start + length - 1 && index < buffer.Length && TwoBitPixelDecoding(buffer, ref index, ref bitIndex, out pixelCode, out runLength)) { - DrawPixels(cds, twoToFourBitColorLookup[pixelCode], runLength, ref x, ref y); + DrawPixels(clutLookup, twoToFourBitColorLookup[pixelCode], runLength, ref x, ref y); } } else if (dataType == PixelDecoding4Bit) @@ -237,14 +254,14 @@ private int ProcessDataType(byte[] buffer, int index, ClutDefinitionSegment cds, var startHalf = false; while (index < start + length - 1 && index < buffer.Length && FourBitPixelDecoding(buffer, ref index, ref startHalf, out pixelCode, out runLength)) { - DrawPixels(cds, fourToEightBitColorLookup[pixelCode], runLength, ref x, ref y); + DrawPixels(clutLookup, fourToEightBitColorLookup[pixelCode], runLength, ref x, ref y); } } else if (dataType == PixelDecoding8Bit) { while (index < start + length - 1 && index < buffer.Length && EightBitPixelDecoding(buffer, ref index, out pixelCode, out runLength)) { - DrawPixels(cds, pixelCode, runLength, ref x, ref y); + DrawPixels(clutLookup, pixelCode, runLength, ref x, ref y); } } else if (dataType == MapTable2To4Bit) @@ -296,7 +313,7 @@ private int ProcessDataType(byte[] buffer, int index, ClutDefinitionSegment cds, return index; } - private int ProcessDataTypeForPosition(byte[] buffer, int index, ClutDefinitionSegment cds, ref int dataType, int start, + private int ProcessDataTypeForPosition(byte[] buffer, int index, SKColor[] clutLookup, ref int dataType, int start, List twoToFourBitColorLookup, List fourToEightBitColorLookup, List twoToEightBitColorLookup, ref int x, ref int y, int length, ref int pixelCode, ref int runLength, int width, int height, out Position pos) { @@ -307,7 +324,7 @@ private int ProcessDataTypeForPosition(byte[] buffer, int index, ClutDefinitionS var bitIndex = 0; while (index < start + length - 1 && index < buffer.Length && TwoBitPixelDecoding(buffer, ref index, ref bitIndex, out pixelCode, out runLength)) { - var subPos = GetFirstPixelPosition(cds, twoToFourBitColorLookup[pixelCode], runLength, ref x, ref y, width, height); + var subPos = GetFirstPixelPosition(clutLookup, twoToFourBitColorLookup[pixelCode], runLength, ref x, ref y, width, height); if (subPos.Left < pos.Left) { pos.Left = subPos.Left; @@ -323,7 +340,7 @@ private int ProcessDataTypeForPosition(byte[] buffer, int index, ClutDefinitionS var startHalf = false; while (index < start + length - 1 && index < buffer.Length && FourBitPixelDecoding(buffer, ref index, ref startHalf, out pixelCode, out runLength)) { - var subPos = GetFirstPixelPosition(cds, fourToEightBitColorLookup[pixelCode], runLength, ref x, ref y, width, height); + var subPos = GetFirstPixelPosition(clutLookup, fourToEightBitColorLookup[pixelCode], runLength, ref x, ref y, width, height); if (subPos.Left < pos.Left) { pos.Left = subPos.Left; @@ -338,7 +355,7 @@ private int ProcessDataTypeForPosition(byte[] buffer, int index, ClutDefinitionS { while (index < start + length - 1 && index < buffer.Length && EightBitPixelDecoding(buffer, ref index, out pixelCode, out runLength)) { - var subPos = GetFirstPixelPosition(cds, pixelCode, runLength, ref x, ref y, width, height); + var subPos = GetFirstPixelPosition(clutLookup, pixelCode, runLength, ref x, ref y, width, height); if (subPos.Left < pos.Left) { pos.Left = subPos.Left; @@ -463,60 +480,60 @@ private static int CalculateSize(byte[] buffer, int index, ref int dataType, int return index; } - private void DrawPixels(ClutDefinitionSegment cds, int pixelCode, int runLength, ref int x, ref int y) + /// + /// Resolves every CLUT entry id to its RGB color once. The per-run path used to + /// linear-scan (first match wins) and + /// redo the YCbCr conversion for every RLE run of every object. + /// + private static SKColor[] BuildClutLookup(ClutDefinitionSegment cds) { - var c = SKColors.Red; + var lookup = new SKColor[256]; + for (var i = 0; i < lookup.Length; i++) + { + lookup[i] = SKColors.Red; // same fallback as the old scan for unknown ids + } + if (cds != null) { + var seen = new bool[lookup.Length]; foreach (var item in cds.Entries) { - if (item.ClutEntryId == pixelCode) + var id = item.ClutEntryId; + if (id >= 0 && id < lookup.Length && !seen[id]) { - c = item.GetColor(); - break; + seen[id] = true; // first entry with an id wins, like the old scan + lookup[id] = item.GetColor(); } } } - for (var k = 0; k < runLength; k++) - { - if (y < _fastImage.Height && x < _fastImage.Width) - { - _fastImage.SetPixel(x, y, c); - } - - x++; - } + return lookup; } - private Position GetFirstPixelPosition(ClutDefinitionSegment cds, int pixelCode, int runLength, ref int x, ref int y, int width, int height) + private void DrawPixels(SKColor[] clutLookup, int pixelCode, int runLength, ref int x, ref int y) { - var c = SKColors.Red; - if (cds != null) + var c = (uint)pixelCode < (uint)clutLookup.Length ? clutLookup[pixelCode] : SKColors.Red; + + if (y < _fastImage.Height && x < _fastImage.Width) { - foreach (var item in cds.Entries) - { - if (item.ClutEntryId == pixelCode) - { - c = item.GetColor(); - break; - } - } + _fastImage.SetPixel(x, y, c, Math.Min(runLength, _fastImage.Width - x)); } - for (var k = 0; k < runLength; k++) - { - if (y < height && x < width) - { - if (c.Alpha > 0) - { - return new Position(x, y); - } - } + x += runLength; + } - x++; + private static Position GetFirstPixelPosition(SKColor[] clutLookup, int pixelCode, int runLength, ref int x, ref int y, int width, int height) + { + var c = (uint)pixelCode < (uint)clutLookup.Length ? clutLookup[pixelCode] : SKColors.Red; + + // x only grows inside a run, so the first pixel can be in bounds only at the + // run start; the old loop returned there without advancing x past the run. + if (c.Alpha > 0 && y < height && x < width) + { + return new Position(x, y); } + x += runLength; return new Position(int.MaxValue, int.MaxValue); } diff --git a/src/libse/ContainerFormats/TransportStream/Packet.cs b/src/libse/ContainerFormats/TransportStream/Packet.cs index 1caf9fa906a..ec22556b854 100644 --- a/src/libse/ContainerFormats/TransportStream/Packet.cs +++ b/src/libse/ContainerFormats/TransportStream/Packet.cs @@ -86,6 +86,56 @@ public bool IsVideoStream } } + /// + /// Program Identifier, read straight from the packet header - the same value + /// ends up with, without constructing a . + /// + public static int PeekPacketId(byte[] packetBuffer) + { + return (packetBuffer[1] & 31) * 256 + packetBuffer[2]; + } + + /// + /// True when this packet's payload starts with the PES marker for private stream 1 + /// (0xbd - subtitles), computed straight from the raw packet buffer. Deliberately does + /// NOT also match video packets (0xE0-0xEF): 's main + /// loop only needs this check after firstVideoMs is already known, at which point + /// video packets are never inspected again - matching them here would allocate a + /// for every video frame's first packet for nothing. + /// + /// 's constructor always copies the payload into a fresh byte[], and + /// almost every packet in a real file (audio/video content packets) was paying for that + /// copy just to be inspected via and discarded immediately. + /// + /// + public static bool PeekIsPrivateStream1(byte[] packetBuffer) + { + var adaptationFieldControl = (packetBuffer[3] & 48) >> 4; + if (adaptationFieldControl != 0b00000001 && adaptationFieldControl != 0b00000011) + { + return false; // no payload + } + + var payloadStart = 4; + if (adaptationFieldControl == 0b00000011) + { + // Matches the constructor below exactly: payloadStart = 4 + 1 + AdaptationField.Length, + // where AdaptationField.Length is the raw byte at offset 4 (no extra +1 here - that + // +1 belongs only to the separate, differently-used AdaptionFieldLength property). + payloadStart += 1 + (0xFF & packetBuffer[4]); + } + + if (payloadStart + 3 >= packetBuffer.Length) + { + return false; + } + + return packetBuffer[payloadStart] == 0 && + packetBuffer[payloadStart + 1] == 0 && + packetBuffer[payloadStart + 2] == 1 && + packetBuffer[payloadStart + 3] == 0xbd; + } + public Packet(byte[] packetBuffer) { if (packetBuffer == null || packetBuffer.Length < 30) diff --git a/src/libse/ContainerFormats/TransportStream/ProgramAssociationTable.cs b/src/libse/ContainerFormats/TransportStream/ProgramAssociationTable.cs index 6bea2b34b2c..446d411bd1d 100644 --- a/src/libse/ContainerFormats/TransportStream/ProgramAssociationTable.cs +++ b/src/libse/ContainerFormats/TransportStream/ProgramAssociationTable.cs @@ -35,7 +35,13 @@ public ProgramAssociationTable(byte[] packetBuffer, int index) ProgramNumbers = new List(); ProgramIds = new List(); index = index + 8; - for (int i = 0; i < (SectionLength - 5) / 8; i++) + // Each PAT entry is 4 bytes (program_number 16 + reserved 3 + program_map_PID 13), and + // section_length covers the 5 header bytes read above, 4*N and a 4-byte CRC - so + // N = (SectionLength - 9) / 4. The loop used to step 8 bytes while reading only 4, + // which is right by accident for a single-program stream (the common capture) but + // dropped every second program otherwise: their PMTs were never parsed, so their + // subtitle tracks did not exist as far as SE was concerned. + for (int i = 0; i < (SectionLength - 9) / 4; i++) { if (index + 3 < packetBuffer.Length) { @@ -43,7 +49,7 @@ public ProgramAssociationTable(byte[] packetBuffer, int index) int programId = (packetBuffer[index + 2] & 0b00011111) * 256 + packetBuffer[index + 3]; ProgramNumbers.Add(programNumber); ProgramIds.Add(programId); - index += 8; + index += 4; } } } diff --git a/src/libse/ContainerFormats/TransportStream/ProgramMapTableDescriptor.cs b/src/libse/ContainerFormats/TransportStream/ProgramMapTableDescriptor.cs index d0d8326245f..cc451884647 100644 --- a/src/libse/ContainerFormats/TransportStream/ProgramMapTableDescriptor.cs +++ b/src/libse/ContainerFormats/TransportStream/ProgramMapTableDescriptor.cs @@ -23,19 +23,25 @@ public ProgramMapTableDescriptor(byte[] data, int index) Size = length + 2; if (Tag == TagCaDescriptor) { - Buffer.BlockCopy(data, index + 2, CaSystemId, 0, 2); + // "length" is a byte from the stream: below 4 the private data size goes negative, + // and the copies need the bytes to actually be there. + if (length >= 4 && index + 2 + length <= data.Length) + { + Buffer.BlockCopy(data, index + 2, CaSystemId, 0, 2); - // 13 bytes (skip first 3) - CaPid = (uint)((data[index + 4] & 0b00011111) * 256 + // first 5 bytes - data[index + 5]); // last 8 bytes + // 13 bytes (skip first 3) + CaPid = (uint)((data[index + 4] & 0b00011111) * 256 + // first 5 bytes + data[index + 5]); // last 8 bytes - PrivateDataBytes = new byte[length - 4]; - Buffer.BlockCopy(data, index + 6, PrivateDataBytes, 0, length - 4); + PrivateDataBytes = new byte[length - 4]; + Buffer.BlockCopy(data, index + 6, PrivateDataBytes, 0, length - 4); + } } else { Content = new byte[length]; - if (index + 2 + length < data.Length && length > 0) + // "<=": a descriptor ending on the buffer's last byte is complete, not truncated. + if (index + 2 + length <= data.Length && length > 0) { Buffer.BlockCopy(data, index + 2, Content, 0, length); ContentAsString = Encoding.UTF8.GetString(Content); diff --git a/src/libse/ContainerFormats/TransportStream/RegionClutSegmentEntry.cs b/src/libse/ContainerFormats/TransportStream/RegionClutSegmentEntry.cs index 8046d34c26f..078ef316dca 100644 --- a/src/libse/ContainerFormats/TransportStream/RegionClutSegmentEntry.cs +++ b/src/libse/ContainerFormats/TransportStream/RegionClutSegmentEntry.cs @@ -50,7 +50,10 @@ public SKColor GetColor() int g = (int)(y - (0.34413 * (cb - 128)) - (0.71414 * (cr - 128))); int b = (int)(y + (1.772 * (cb - 128))); - int t = byte.MaxValue - BoundByteRange(ClutEntryT); + // A reduced-range entry stores transparency in 2 bits, like Y/Cr/Cb above it are + // stored in 6/4/4 - scale it to 8 bits (0, 64, 128, 192) instead of using the raw + // 0-3, which made every such entry come out opaque. + int t = byte.MaxValue - BoundByteRange(FullRangeFlag ? ClutEntryT : ClutEntryT << 6); r = BoundByteRange(r); g = BoundByteRange(g); b = BoundByteRange(b); diff --git a/src/libse/ContainerFormats/TransportStream/RegionCompositionSegment.cs b/src/libse/ContainerFormats/TransportStream/RegionCompositionSegment.cs index af175cf72d6..0ef3acd27ce 100644 --- a/src/libse/ContainerFormats/TransportStream/RegionCompositionSegment.cs +++ b/src/libse/ContainerFormats/TransportStream/RegionCompositionSegment.cs @@ -32,7 +32,12 @@ public RegionCompositionSegment(byte[] buffer, int index, int regionLength) Region4BitPixelCode = buffer[index + 9] >> 4; Region2BitPixelCode = (buffer[index + 9] & 0b00001100) >> 2; int i = 0; - while (i < regionLength && i + index < buffer.Length) + + // One object record spans buffer[index + i + 10 .. index + i + 15]. The old guard only + // tested the first of those six bytes against the buffer, and the caller guarantees no + // more than index + regionLength + 9, so a segment whose length is not 10 + 6k - a + // truncated or corrupt one - read up to five bytes past the end and threw. + while (i + 6 <= regionLength && index + i + 15 < buffer.Length) { var regionCompositionSegmentObject = new RegionCompositionSegmentObject { ObjectId = Helper.GetEndianWord(buffer, i + index + 10) }; i += 2; diff --git a/src/libse/ContainerFormats/TransportStream/Teletext.cs b/src/libse/ContainerFormats/TransportStream/Teletext.cs index 882b2f60162..119d7c53365 100644 --- a/src/libse/ContainerFormats/TransportStream/Teletext.cs +++ b/src/libse/ContainerFormats/TransportStream/Teletext.cs @@ -31,6 +31,7 @@ public enum DataUnitT { DataUnitEbuTeletextNonSubtitle = 0x02, DataUnitEbuTeletextSubtitle = 0x03, + DataUnitStuffing = 0xff, } public enum TransmissionMode @@ -103,7 +104,29 @@ public class TeletextPage public ulong ShowTimestamp { get; set; } public ulong HideTimestamp { get; set; } public int[,] Text { get; set; } = new int[25, 40]; + + /// + /// The colour map entry a packet X/26 foreground colour triplet sets at a character + /// position, or -1 where the row's own spacing attributes decide. A Level 1 row can + /// only name the first eight entries, and only at the cost of a cell. + /// + public int[,] ForegroundColor { get; set; } = NewColorPlane(); + public bool Tainted { get; set; } + + public static int[,] NewColorPlane() + { + var plane = new int[25, 40]; + for (var row = 0; row < 25; row++) + { + for (var col = 0; col < 40; col++) + { + plane[row, col] = -1; + } + } + + return plane; + } } public class PrimaryCharset @@ -120,11 +143,17 @@ public PrimaryCharset() } } - private static readonly string[] TeletextColors = + // ETS 300 706, chapter 12.4: the colour map in force, which packets X/28/0 and M/29/0 may + // redefine for the current page or magazine. Reset per decode, or one file's colours + // would leak into the next. + private static int[] _colorMap = (int[])TeletextTables.DefaultColorMap.Clone(); + + private static string GetColor(int entry) { - //black, red, green, yellow, blue, magenta, cyan, white - "#000000", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff" - }; + return entry >= 0 && entry < _colorMap.Length + ? TeletextTables.ColorToHtml(_colorMap[entry]) + : TeletextTables.ColorToHtml(TeletextTables.DefaultColorMap[7]); + } // subtitle type pages bitmap, 2048 bits = 2048 possible pages in teletext (excl. subpages) private static readonly byte[] CcMap = new byte[256]; @@ -152,9 +181,70 @@ public static void InitializeStaticFields(int packetId, int pageNumberBcd) _receivingData = false; _states = new States(); _pageBuffer = new TeletextPage(); + _colorMap = (int[])TeletextTables.DefaultColorMap.Clone(); TeletextTables.ResetLatinG0(); } + /// + /// Puts the charset designation of a packet X/28 or M/29 in the order the sub-set map is + /// indexed by. ETS 300 706 chapter 9.3.1.2 names the three national option bits C12, C13 + /// and C14, and the page header above reads them as C12 + 2*C13 + 4*C14 - the designation + /// field carries the same three bits the other way round, so they have to be swapped or + /// French and German (and Italian and Czech) trade places. + /// + private static int ToNationalOptionOrder(int designation) + { + return (designation & ~0x07) | ((designation & 0x01) << 2) | (designation & 0x02) | ((designation & 0x04) >> 2); + } + + /// + /// ETS 300 706, chapter 9.4.2: the colour map a packet X/28/0 or M/29/0 Format 1 carries + /// in its triplets 2 to 13. Those twelve triplets are one bit stream: a five bit default + /// screen colour, a five bit default row colour, then the sixteen entries of CLUT 2 and 3 + /// - twelve bits each, red in the low nibble - and the panel flags at the end. + /// + /// Packets X/28/4 and M/29/4, which redefine CLUT 0 and 1 instead, are left alone: none of + /// the streams this was written against transmits one, so where their entries start in the + /// bit stream could not be checked, and guessing would recolour ordinary subtitles. + /// + /// + private static void ReadColorMap(TeletextPacketPayload packet) + { + var stream = new bool[216]; + for (var triplet = 0; triplet < 12; triplet++) + { + var i = 4 + triplet * 3; + var value = TeletextHamming.UnHamming2418((packet.Data[i + 2] << 16) | (packet.Data[i + 1] << 8) | packet.Data[i]); + if (value == 0xffffffff) + { + // A damaged triplet would silently recolour the page - leave the map alone. + _config.LogError("! Unrecoverable data error in colour map; UNHAM24/18()"); + return; + } + + for (var bit = 0; bit < 18; bit++) + { + stream[triplet * 18 + bit] = ((value >> bit) & 1) != 0; + } + } + + const int firstColorMapBit = 10; // past the default screen and row colours + for (var entry = 0; entry < 16; entry++) + { + var value = 0; + for (var bit = 0; bit < 12; bit++) + { + if (stream[firstColorMapBit + entry * 12 + bit]) + { + value |= 1 << bit; + } + } + + // CLUT 2 and 3 are entries 16 to 31. + _colorMap[16 + entry] = ((value & 0x00f) << 8) | (value & 0x0f0) | ((value >> 8) & 0x00f); + } + } + private static void RemapG0Charset(int c) { if (c != _primaryCharset.Current) @@ -359,9 +449,19 @@ static void ProcessPage(TeletextPage page, TeletextRunSettings teletextRunSettin // v is just a shortcut var v = page.Text[row, col]; + // ETS 300 706, chapter 12.3.1: a packet X/26 foreground colour triplet sets the + // colour at a character position without spending a cell on it, and it can + // name all 32 colour map entries rather than the eight a spacing attribute + // reaches. It is set-at, so it counts from this position on. + var enhancedColor = page.ForegroundColor[row, col]; + if (col < colStart) { - if (v <= 0x7) + if (enhancedColor >= 0) + { + foregroundColor = enhancedColor; + } + else if (v <= 0x7) { foregroundColor = v; } @@ -369,15 +469,37 @@ static void ProcessPage(TeletextPage page, TeletextRunSettings teletextRunSettin if (col == colStart) { + // An enhancement on the first cell decides the colour the row opens with, + // rather than opening a tag that the next line would close straight away. + if (enhancedColor >= 0) + { + foregroundColor = enhancedColor; + } + if (foregroundColor != 0x7 && _config.Colors) { - sb.Append($""); + sb.Append($""); fontTagOpened = true; } } if (col >= colStart) { + if (enhancedColor >= 0 && col > colStart && _config.Colors) + { + if (fontTagOpened) + { + sb.Append(""); + fontTagOpened = false; + } + + if (enhancedColor != 0x7) + { + sb.Append($""); + fontTagOpened = true; + } + } + if (v <= 0x7) { // ETS 300 706, chapter 12.2: Unless operating in "Hold Mosaics" mode, @@ -394,7 +516,7 @@ static void ProcessPage(TeletextPage page, TeletextRunSettings teletextRunSettin // telxcc writes tags only when needed if (v > 0x0 && v < 0x7) { - sb.Append($""); + sb.Append($""); fontTagOpened = true; } } @@ -545,6 +667,7 @@ public static void ProcessTelxPacket(DataUnitT dataUnitId, TeletextPacketPayload _pageBuffer.ShowTimestamp = timestamp; _pageBuffer.HideTimestamp = 0; _pageBuffer.Text = new int[25, 40]; + _pageBuffer.ForegroundColor = TeletextPage.NewColorPlane(); _pageBuffer.Tainted = false; _receivingData = true; _primaryCharset.G0X28 = (int)BoolT.Undef; @@ -620,6 +743,14 @@ public static void ProcessTelxPacket(DataUnitT dataUnitId, TeletextPacketPayload break; } + // ETS 300 706, chapter 12.3.1, table 27: foreground colour. The data field + // names one of the 32 colour map entries, so it reaches the colours a Level 1 + // spacing attribute cannot, and it costs no cell. + if (mode == 0x00 && !rowAddressGroup) + { + _pageBuffer.ForegroundColor[x26Row, (int)address2] = (int)(data & 0x1f); + } + // ETS 300 706, chapter 12.3.1, table 27: character from G2 set int x26Col; if (mode == 0x0f && !rowAddressGroup) @@ -631,6 +762,14 @@ public static void ProcessTelxPacket(DataUnitT dataUnitId, TeletextPacketPayload } } + // ETS 300 706, chapter 12.3.1, table 27: G0 character without diacritical + // mark - used to place a character the national option sub-set in force has + // given away, so it must not fall through to the Level 1 row. + if (mode == 0x10 && !rowAddressGroup && data >= 0x20) + { + _pageBuffer.Text[x26Row, (int)address2] = TeletextTables.GetDefaultLatinG0((int)data); + } + // ETS 300 706, chapter 12.3.1, table 27: G0 character with diacritical mark if (mode >= 0x11 && mode <= 0x1f && !rowAddressGroup) { @@ -643,9 +782,10 @@ public static void ProcessTelxPacket(DataUnitT dataUnitId, TeletextPacketPayload { _pageBuffer.Text[x26Row, x26Col] = TeletextTables.G2Accents[mode - 0x11, data - 71]; } - else // other + else // a mark on something that is not a letter - show the bare character { - _pageBuffer.Text[x26Row, x26Col] = TelxToUcs2((byte)data); + // The data field carries no parity bit, so TelxToUcs2 would reject it. + _pageBuffer.Text[x26Row, x26Col] = TeletextTables.GetDefaultLatinG0((int)data); } } } @@ -671,8 +811,13 @@ public static void ProcessTelxPacket(DataUnitT dataUnitId, TeletextPacketPayload // ETS 300 706, chapter 9.4.2: Packet X/28/0 Format 1 only if ((triplet0 & 0x0f) == 0x00) { - _primaryCharset.G0X28 = (int)((triplet0 & 0x3f80) >> 7); + _primaryCharset.G0X28 = ToNationalOptionOrder((int)((triplet0 & 0x3f80) >> 7)); RemapG0Charset(_primaryCharset.G0X28); + + if (designationCode == 0) + { + ReadColorMap(packet); + } } } } @@ -699,12 +844,17 @@ public static void ProcessTelxPacket(DataUnitT dataUnitId, TeletextPacketPayload // ETS 300 706, table 13: Coding of Packet M/29/4 if ((triplet0 & 0xff) == 0x00) { - _primaryCharset.G0M29 = (int)((triplet0 & 0x3f80) >> 7); + _primaryCharset.G0M29 = ToNationalOptionOrder((int)((triplet0 & 0x3f80) >> 7)); // X/28 takes precedence over M/29 if (_primaryCharset.G0X28 == (int)BoolT.Undef) { RemapG0Charset(_primaryCharset.G0M29); } + + if (designationCode == 0) + { + ReadColorMap(packet); + } } } } diff --git a/src/libse/ContainerFormats/TransportStream/TeletextColorMap.cs b/src/libse/ContainerFormats/TransportStream/TeletextColorMap.cs new file mode 100644 index 00000000000..5024616043e --- /dev/null +++ b/src/libse/ContainerFormats/TransportStream/TeletextColorMap.cs @@ -0,0 +1,192 @@ +using System.Collections.Generic; + +namespace Nikse.SubtitleEdit.Core.ContainerFormats.TransportStream +{ + /// + /// The colour map a teletext writer builds up while it lays out a file: the 32 entries of + /// ETS 300 706 table 30, where entries 16 to 31 (CLUT 2 and 3) may be redefined by a packet + /// X/28/0 to carry colours of the writer's own choosing - the Level 2.5 mechanism FAB and + /// other teletext editors use for their "user defined" colours. + /// + /// A colour that is one of the eight full intensity CLUT 0 colours needs nothing beyond the + /// Level 1 spacing attribute every decoder understands. Any other colour gets a colour map + /// entry - an existing one when the colour is already in the map (the half intensity CLUT 1 + /// colours, for example), otherwise the next free redefinable entry - and is named at its + /// character position by an X/26 foreground colour triplet. Once all sixteen redefinable + /// entries are taken, further colours snap to the nearest entry in the map. + /// + /// + public class TeletextColorMap + { + private readonly int[] _map = (int[])TeletextTables.DefaultColorMap.Clone(); + private readonly Dictionary _assignedEntries = new Dictionary(); + private int _nextFreeEntry = 16; + + /// + /// True once a colour landed in a redefinable entry, so the file has to carry an X/28/0 + /// packet for the colour to mean anything. Colours served by the map's own defaults + /// (CLUT 1, or a CLUT 2/3 default hit exactly) need no packet: table 30 is the default + /// in every decoder. + /// + public bool NeedsColorMapPacket { get; private set; } + + /// + /// How a colour goes on air: the Level 1 spacing attribute that stands in for it (the + /// nearest of the eight CLUT 0 colours - all a Level 1 decoder will show), and the colour + /// map entry an X/26 foreground colour triplet should name, or -1 when the spacing + /// attribute alone already is the colour. + /// + public readonly struct ResolvedColor + { + public ResolvedColor(byte spacingAttribute, int entry) + { + SpacingAttribute = spacingAttribute; + Entry = entry; + } + + public byte SpacingAttribute { get; } + public int Entry { get; } + } + + /// + /// Resolves a 12 bit rgb colour (four bits per component, red in the high nibble - the + /// resolution the teletext colour map has) to its wire form. + /// + public ResolvedColor Resolve(int rgb12) + { + var spacing = GetNearestSpacingAttribute(rgb12); + if (_map[spacing] == rgb12) + { + return new ResolvedColor(spacing, -1); + } + + if (_assignedEntries.TryGetValue(rgb12, out var assigned)) + { + return new ResolvedColor(spacing, assigned); + } + + // The map may already hold the colour - a CLUT 1 half intensity colour, or a CLUT 2/3 + // default - and an existing entry beats spending a redefinable one. + for (var entry = 8; entry < _map.Length; entry++) + { + if (_map[entry] == rgb12) + { + _assignedEntries.Add(rgb12, entry); + return new ResolvedColor(spacing, entry); + } + } + + if (_nextFreeEntry < _map.Length) + { + var entry = _nextFreeEntry++; + _map[entry] = rgb12; + _assignedEntries.Add(rgb12, entry); + NeedsColorMapPacket = true; + return new ResolvedColor(spacing, entry); + } + + // All sixteen redefinable entries are taken - snap to the nearest colour in the map. + var nearest = GetNearestEntry(rgb12); + _assignedEntries.Add(rgb12, nearest < 8 ? -1 : nearest); + return new ResolvedColor(spacing, nearest < 8 ? -1 : nearest); + } + + /// + /// Rounds a 24 bit colour to the four bits per component the colour map holds - 0x11 + /// steps, so #ff8822 becomes 0xf82 and widens back to exactly #ff8822. + /// + public static int QuantizeRgb(int r, int g, int b) + { + return (((r * 15 + 127) / 255) << 8) | (((g * 15 + 127) / 255) << 4) | ((b * 15 + 127) / 255); + } + + /// + /// The Level 1 spacing attribute nearest to a colour: each component rounded to full + /// intensity or off, which is all the eight CLUT 0 colours can tell apart. + /// + public static byte GetNearestSpacingAttribute(int rgb12) + { + return (byte)((((rgb12 >> 8) & 0x0f) >= 8 ? 1 : 0) | + (((rgb12 >> 4) & 0x0f) >= 8 ? 2 : 0) | + ((rgb12 & 0x0f) >= 8 ? 4 : 0)); + } + + private int GetNearestEntry(int rgb12) + { + var best = 7; + var bestDistance = int.MaxValue; + for (var entry = 0; entry < _map.Length; entry++) + { + var r = ((_map[entry] >> 8) & 0x0f) - ((rgb12 >> 8) & 0x0f); + var g = ((_map[entry] >> 4) & 0x0f) - ((rgb12 >> 4) & 0x0f); + var b = (_map[entry] & 0x0f) - (rgb12 & 0x0f); + var distance = r * r + g * g + b * b; + if (distance < bestDistance) + { + bestDistance = distance; + best = entry; + } + } + + return best; + } + + /// + /// The 40 data bytes of a packet X/28/0 Format 1 carrying this colour map, ready for a + /// data unit: the designation code, a triplet declaring Format 1 with the default G0 + /// charset, and the bit stream holding the sixteen redefinable entries - the exact layout + /// reads back (and ZDF transmits), with the default screen and row + /// colours black and the CLUT remapping at zero, so Level 1 spacing attributes keep + /// meaning CLUT 0. + /// + public byte[] GetColorMapPacketData() + { + var stream = new bool[216]; + + // Bits 0-4 default screen colour, bits 5-9 default row colour - both black (0). + const int firstColorMapBit = 10; + for (var entry = 0; entry < 16; entry++) + { + var rgb12 = _map[16 + entry]; + + // The bit stream stores each entry with red in the low nibble. + var value = ((rgb12 >> 8) & 0x00f) | (rgb12 & 0x0f0) | ((rgb12 & 0x00f) << 8); + for (var bit = 0; bit < 12; bit++) + { + stream[firstColorMapBit + entry * 12 + bit] = ((value >> bit) & 1) != 0; + } + } + + var data = new byte[40]; + data[0] = TeletextHamming.Hamming84Encode(0); // designation code 0 + + // Triplet 1: page function and coding zero = Format 1, charset designation zero - + // the default G0 set the page header declares too. + WriteTriplet(data, 1, 0); + + for (var triplet = 0; triplet < 12; triplet++) + { + var value = 0; + for (var bit = 0; bit < 18; bit++) + { + if (stream[triplet * 18 + bit]) + { + value |= 1 << bit; + } + } + + WriteTriplet(data, 4 + triplet * 3, value); + } + + return data; + } + + private static void WriteTriplet(byte[] data, int index, int value) + { + var encoded = TeletextHamming.Hamming2418Encode(value); + data[index] = (byte)encoded; + data[index + 1] = (byte)(encoded >> 8); + data[index + 2] = (byte)(encoded >> 16); + } + } +} diff --git a/src/libse/ContainerFormats/TransportStream/TeletextHamming.cs b/src/libse/ContainerFormats/TransportStream/TeletextHamming.cs index e0c950afb04..d0b57609d3c 100644 --- a/src/libse/ContainerFormats/TransportStream/TeletextHamming.cs +++ b/src/libse/ContainerFormats/TransportStream/TeletextHamming.cs @@ -62,6 +62,30 @@ public static class TeletextHamming 0x08, 0xff, 0xff, 0x05, 0xff, 0x0e, 0x0d, 0xff, 0xff, 0x0e, 0x0f, 0xff, 0x0e, 0x0e, 0xff, 0x0e }; + // ETS 300 706, chapter 8.2 - the 16 Hamming 8/4 code words, i.e. the inverse of Unham84. + public static readonly byte[] Hamming84 = + { + 0x15, 0x02, 0x49, 0x5e, 0x64, 0x73, 0x38, 0x2f, + 0xd0, 0xc7, 0x8c, 0x9b, 0xa1, 0xb6, 0xfd, 0xea + }; + + /// + /// Hamming 8/4 encodes the lower four bits of . + /// + public static byte Hamming84Encode(int value) + { + return Hamming84[value & 0x0f]; + } + + /// + /// ETS 300 706, chapter 8.1 - adds the odd parity bit to a seven bit character. + /// + public static byte OddParityEncode(int value) + { + var v = (byte)(value & 0x7f); + return Parity8[v] == 1 ? v : (byte)(v | 0x80); + } + // ETS 300 706, chapter 8.2 - Hamming 8/4 public static byte UnHamming84(byte a) { @@ -74,6 +98,66 @@ public static byte UnHamming84(byte a) return (byte)(r & 0x0f); } + // ETS 300 706, chapter 8.3 - where the eighteen data bits D1-D18 sit in a code word. + private static readonly int[] Hamming2418DataBits = + { + 2, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20, 21, 22 + }; + + // The protection bits P1-P5 take the power of two positions; P6 is bit 23. + private static readonly int[] Hamming2418ProtectionBits = { 0, 1, 3, 7, 15 }; + + /// + /// ETS 300 706, chapter 8.3 - Hamming 24/18 encodes eighteen bits into the three bytes of + /// a triplet, the inverse of . + /// + public static int Hamming2418Encode(int value) + { + var result = 0; + for (var i = 0; i < Hamming2418DataBits.Length; i++) + { + if (((value >> i) & 1) != 0) + { + result |= 1 << Hamming2418DataBits[i]; + } + } + + // Tests A-E are odd parity checks over the bits whose one based position has the + // matching bit set, so those positions have to add up to 0x1f - the five protection + // bits, one per power of two, carry whatever the data bits leave missing. + var syndrome = 0x1f; + for (var i = 0; i < 23; i++) + { + if (((result >> i) & 1) != 0) + { + syndrome ^= i + 1; + } + } + + for (var i = 0; i < Hamming2418ProtectionBits.Length; i++) + { + if (((syndrome >> i) & 1) != 0) + { + result |= 1 << Hamming2418ProtectionBits[i]; + } + } + + // Test F: the whole code word carries an odd number of ones, so a single bit error + // can be told from a double one. + var ones = 0; + for (var i = 0; i < 24; i++) + { + ones += (result >> i) & 1; + } + + if (ones % 2 == 0) + { + result |= 1 << 23; + } + + return result; + } + // ETS 300 706, chapter 8.3 - Hamming 24/18 public static uint UnHamming2418(int a) { diff --git a/src/libse/ContainerFormats/TransportStream/TeletextTables.cs b/src/libse/ContainerFormats/TransportStream/TeletextTables.cs index 0e5ff717191..a536d33d4e1 100644 --- a/src/libse/ContainerFormats/TransportStream/TeletextTables.cs +++ b/src/libse/ContainerFormats/TransportStream/TeletextTables.cs @@ -1,4 +1,6 @@ -namespace Nikse.SubtitleEdit.Core.ContainerFormats.TransportStream +using System.Collections.Generic; + +namespace Nikse.SubtitleEdit.Core.ContainerFormats.TransportStream { public class TeletextTables { @@ -183,7 +185,7 @@ public G0Language(string language, int[] characters) new G0Language("Estonian", new[] { - 0x0023, 0x00f5, 0x0160, 0x00c4, 0x00d6, 0x017e, 0x00dc, 0x00d5, 0x0161, 0x00e4, 0x00f6, 0x017e, 0x00fc + 0x0023, 0x00f5, 0x0160, 0x00c4, 0x00d6, 0x017d, 0x00dc, 0x00d5, 0x0161, 0x00e4, 0x00f6, 0x017e, 0x00fc }), // b new G0Language("Lettish, Lithuanian", new[] @@ -397,5 +399,156 @@ public static void ResetLatinG0() 0x0000, 0x0000, 0x0000, 0x017e } }; + + // --- Colour map ---------------------------------------------------------------- + + /// + /// ETS 300 706, chapter 12.4, table 30: the 32 colour map entries a Level 2.5 decoder + /// knows, four bits per component. Entries 0-7 are the eight colours Level 1 can name + /// through a spacing attribute, 8-15 their half intensity companions, and 16-31 the two + /// tables a broadcaster is free to redefine in packet X/28/0 - ZDF's orange lives there. + /// + public static readonly int[] DefaultColorMap = + { + // CLUT 0 - black, red, green, yellow, blue, magenta, cyan, white + 0x000, 0xf00, 0x0f0, 0xff0, 0x00f, 0xf0f, 0x0ff, 0xfff, + // CLUT 1 + 0x000, 0x700, 0x070, 0x770, 0x007, 0x707, 0x077, 0x777, + // CLUT 2 + 0xf05, 0xf70, 0x0f7, 0xffb, 0x0ca, 0x500, 0x652, 0xc77, + // CLUT 3 + 0x333, 0xf77, 0x7f7, 0xff7, 0x77f, 0xf7f, 0x7ff, 0xddd + }; + + /// + /// Spells a colour map entry as "#rrggbb", widening each four bit component the usual way + /// so that 0xf becomes 0xff. + /// + public static string ColorToHtml(int entry) + { + var r = ((entry >> 8) & 0x0f) * 0x11; + var g = ((entry >> 4) & 0x0f) * 0x11; + var b = (entry & 0x0f) * 0x11; + return $"#{r:x2}{g:x2}{b:x2}"; + } + + // --- Reverse lookups, used when writing teletext ------------------------------- + + /// + /// X/26 mode for a character taken from the G2 supplementary set, ETS 300 706 table 27. + /// + public const byte G2Mode = 0x0f; + + /// + /// The first of the fifteen X/26 modes that put a diacritical mark on a G0 letter. + /// + public const byte FirstDiacriticalMarkMode = 0x11; + + /// + /// How a character that no G0 cell can hold is put on screen: an X/26 triplet naming + /// either a G2 character or a G0 letter that carries a diacritical mark. + /// + public struct G2Replacement + { + /// X/26 mode - , or 0x11-0x1f for a diacritical mark. + public byte Mode { get; } + + /// The G2 code, or the plain letter the diacritical mark belongs to. + public byte Data { get; } + + public G2Replacement(byte mode, byte data) + { + Mode = mode; + Data = data; + } + } + + private static readonly Dictionary LatinG0Codes = BuildLatinG0Codes(); + private static readonly Dictionary G2Replacements = BuildG2Replacements(); + + /// + /// Finds the seven bit code of a character in the default Latin G0 set. Teletext is not + /// ASCII: "#" is 0x5f, 0x23 is "£", and "[", "]" and "{" to "~" have no code at all. + /// + public static bool TryGetLatinG0Code(int codePoint, out byte code) + { + return LatinG0Codes.TryGetValue(codePoint, out code); + } + + /// + /// Reads a code from the default Latin G0 set, i.e. the one a national option sub-set has + /// not been patched into. Packet X/26 uses it to reach the characters that the sub-set in + /// force has given away - "@" on a German page, for one. + /// + public static int GetDefaultLatinG0(int code) + { + var index = code - 0x20; + return index >= 0 && index < G0LatinPristine.Length ? G0LatinPristine[index] : 0x20; + } + + /// + /// Finds the X/26 triplet that puts a character on screen, i.e. the inverse of what a + /// decoder does with the enhancement modes and 0x11-0x1f. + /// + public static bool TryGetG2Replacement(int codePoint, out G2Replacement replacement) + { + return G2Replacements.TryGetValue(codePoint, out replacement); + } + + private static Dictionary BuildLatinG0Codes() + { + // The pristine row, so a decode that remapped a national option sub-set in the + // meantime cannot change what gets written. + var result = new Dictionary(); + for (var i = 0; i < G0LatinPristine.Length; i++) + { + // "-" sits at both 0x2d and 0x60, and the lower code is the portable one. + if (G0LatinPristine[i] != 0 && !result.ContainsKey(G0LatinPristine[i])) + { + result.Add(G0LatinPristine[i], (byte)(0x20 + i)); + } + } + + return result; + } + + private static Dictionary BuildG2Replacements() + { + var result = new Dictionary(); + + // A letter with a diacritical mark comes first: it is what broadcasters send, and it + // leaves the plain letter in the row for a decoder that ignores the enhancement. + for (var mark = 0; mark < G2Accents.GetLength(0); mark++) + { + for (var i = 0; i < G2Accents.GetLength(1); i++) + { + var codePoint = G2Accents[mark, i]; + var letter = i < 26 ? 'A' + i : 'a' + i - 26; + if (codePoint != 0 && !result.ContainsKey(codePoint)) + { + result.Add(codePoint, new G2Replacement((byte)(FirstDiacriticalMarkMode + mark), (byte)letter)); + } + } + } + + for (var i = 0; i < G2.GetLength(1); i++) + { + var codePoint = G2[0, i]; + + // The unused slots, the two further spaces, and the standalone combining marks - + // none of them stands for a character worth writing. + if (codePoint == 0 || codePoint == 0x0020 || (codePoint >= 0x0300 && codePoint <= 0x036f)) + { + continue; + } + + if (!result.ContainsKey(codePoint)) + { + result.Add(codePoint, new G2Replacement(G2Mode, (byte)(0x20 + i))); + } + } + + return result; + } } } diff --git a/src/libse/ContainerFormats/TransportStream/TransportStreamParser.cs b/src/libse/ContainerFormats/TransportStream/TransportStreamParser.cs index 3c7222cd0fe..d1ff9b09a60 100644 --- a/src/libse/ContainerFormats/TransportStream/TransportStreamParser.cs +++ b/src/libse/ContainerFormats/TransportStream/TransportStreamParser.cs @@ -113,45 +113,50 @@ public void Parse(Stream ms, LoadTransportStreamCallback callback) if (packetBuffer[0] == Packet.SynchronizationByte) { - var packet = new Packet(packetBuffer); - if (packet.IsNullPacket) + // Constructing a Packet always copies the payload into a fresh byte[]. Once + // firstVideoMs is known, video/audio content packets - the vast majority of a + // real file - are only ever tested against IsPrivateStream1 and discarded, so + // peek the raw buffer for that marker first and skip the allocation entirely + // for everything that is neither a null packet nor a subtitle one. Before + // firstVideoMs is known, every packet must still be fully materialized to + // read the PES timestamp bytes, exactly as before. + var packetId = Packet.PeekPacketId(packetBuffer); + if (packetId == Packet.NullPacketId) { NumberOfNullPackets++; } - - else if (!firstVideoMs.HasValue && packet.IsVideoStream) + else if (!firstVideoMs.HasValue) { - if (packet.Payload != null && packet.Payload.Length > 10) + var packet = new Packet(packetBuffer); + if (packet.IsVideoStream) { - int presentationTimestampDecodeTimestampFlags = packet.Payload[7] >> 6; - if (presentationTimestampDecodeTimestampFlags == 0b00000010 || - presentationTimestampDecodeTimestampFlags == 0b00000011) + if (packet.Payload != null && packet.Payload.Length > 10) { - firstVideoMs = (ulong)packet.Payload[9 + 4] >> 1; - firstVideoMs += (ulong)packet.Payload[9 + 3] << 7; - firstVideoMs += (ulong)(packet.Payload[9 + 2] & 0b11111110) << 14; - firstVideoMs += (ulong)packet.Payload[9 + 1] << 22; - firstVideoMs += (ulong)(packet.Payload[9 + 0] & 0b00001110) << 29; - firstVideoMs = firstVideoMs / 90; + int presentationTimestampDecodeTimestampFlags = packet.Payload[7] >> 6; + if (presentationTimestampDecodeTimestampFlags == 0b00000010 || + presentationTimestampDecodeTimestampFlags == 0b00000011) + { + firstVideoMs = (ulong)packet.Payload[9 + 4] >> 1; + firstVideoMs += (ulong)packet.Payload[9 + 3] << 7; + firstVideoMs += (ulong)(packet.Payload[9 + 2] & 0b11111110) << 14; + firstVideoMs += (ulong)packet.Payload[9 + 1] << 22; + firstVideoMs += (ulong)(packet.Payload[9 + 0] & 0b00001110) << 29; + firstVideoMs = firstVideoMs / 90; + } } } - } - - else if (packet.IsPrivateStream1 || _subtitlePacketIdsLookup.Contains(packet.PacketId)) - { - TotalNumberOfPrivateStream1++; - - if (_subtitlePacketIdsLookup.Add(packet.PacketId)) + else if (packet.IsPrivateStream1 || _subtitlePacketIdsLookup.Contains(packet.PacketId)) { - SubtitlePacketIds.Add(packet.PacketId); + AddSubtitlePacket(packet, teletextPages, teletextPesList, ref firstMs); } - - if (packet.PayloadUnitStartIndicator) + } + else if (_subtitlePacketIdsLookup.Contains(packetId) || Packet.PeekIsPrivateStream1(packetBuffer)) + { + var packet = new Packet(packetBuffer); + if (packet.IsPrivateStream1 || _subtitlePacketIdsLookup.Contains(packet.PacketId)) { - firstMs = ProcessPackages(packet.PacketId, teletextPages, teletextPesList, firstMs); - SubtitlePackets.RemoveAll(p => p.PacketId == packet.PacketId); + AddSubtitlePacket(packet, teletextPages, teletextPesList, ref firstMs); } - SubtitlePackets.Add(packet); } TotalNumberOfPackets++; position += packetLength; @@ -517,6 +522,25 @@ private void AddToTeletextDictionary(Dictionary textDictionary, } } + /// Shared by both branches of the main packet loop that keep a subtitle packet. + private void AddSubtitlePacket(Packet packet, Dictionary> teletextPages, Dictionary> teletextPesList, ref ulong? firstMs) + { + TotalNumberOfPrivateStream1++; + + if (_subtitlePacketIdsLookup.Add(packet.PacketId)) + { + SubtitlePacketIds.Add(packet.PacketId); + } + + if (packet.PayloadUnitStartIndicator) + { + firstMs = ProcessPackages(packet.PacketId, teletextPages, teletextPesList, firstMs); + SubtitlePackets.RemoveAll(p => p.PacketId == packet.PacketId); + } + + SubtitlePackets.Add(packet); + } + private ulong? ProcessPackages(int packetId, Dictionary> teletextPages, Dictionary> teletextPesList, ulong? firstVideoMs) { var list = MakeSubtitlePesPackets(packetId, SubtitlePackets); @@ -812,7 +836,10 @@ public static List GetDvbSup(string fileName) sub.StartMilliseconds = (ulong)seconds * 1000UL; if (pes.PageCompositions.Count > 0) { - seconds += pes.PageCompositions[0].PageTimeOut; + // Only compute the end here - the shared clock is advanced once per PES by + // the block after this loop body. Doing it in both places double-counted + // the page time-out for every subtitle-bearing display set, so the start + // times drifted further and further ahead over the file. sub.EndMilliseconds = sub.StartMilliseconds + (ulong)pes.PageCompositions[0].PageTimeOut * 1000UL; } else diff --git a/src/libse/ContainerFormats/TransportStream/TransportStreamSubtitle.cs b/src/libse/ContainerFormats/TransportStream/TransportStreamSubtitle.cs index dfaeddc9e54..e3214fcab50 100644 --- a/src/libse/ContainerFormats/TransportStream/TransportStreamSubtitle.cs +++ b/src/libse/ContainerFormats/TransportStream/TransportStreamSubtitle.cs @@ -42,6 +42,11 @@ public SKBitmap GetBitmap() return _bdSup.GetBitmap(); } + if (Pes == null) + { + return new SKBitmap(1, 1); + } + if (ActiveImageIndex.HasValue && ActiveImageIndex >= 0 && ActiveImageIndex < Pes.ObjectDataList.Count) { var bitmap = Pes.GetImage(Pes.ObjectDataList[ActiveImageIndex.Value]); @@ -114,7 +119,9 @@ public int NumberOfImages return Pes.ObjectDataList.Count; } - return _bdSup.BitmapObjects.Count; + // Both sources are optional (the parameterless constructor leaves them unset), + // matching how GetScreenSize/GetPosition already handle it. + return _bdSup?.BitmapObjects.Count ?? 0; } } } diff --git a/src/libse/Dictionaries/StringWithoutSpaceSplitToWords.cs b/src/libse/Dictionaries/StringWithoutSpaceSplitToWords.cs index 4d2242931b5..f34c1d21ec6 100644 --- a/src/libse/Dictionaries/StringWithoutSpaceSplitToWords.cs +++ b/src/libse/Dictionaries/StringWithoutSpaceSplitToWords.cs @@ -59,9 +59,12 @@ public static string SplitWord(string[] words, string input, string ignoreWord, { // Allow split if the first letter is "I" (e.g. Iam) } - else if (input[0] == 'a' && threeLetterIsoLanguageName == "eng") + else if (input[0] == 'A' && threeLetterIsoLanguageName == "eng") { - // Allow split if the first letter is "a" (e.g. acat) + // Allow split if the first letter is "A" (e.g. Acat -> "A cat"). Testing the + // lowercase 'a' could never match: the enclosing guard only admits words whose + // first character already equals its own uppercase form. The 'I' sibling above + // uses the uppercase letter and works. } else { diff --git a/src/libse/Enums/FixType.cs b/src/libse/Enums/FixType.cs new file mode 100644 index 00000000000..508a962a56f --- /dev/null +++ b/src/libse/Enums/FixType.cs @@ -0,0 +1,14 @@ +namespace Nikse.SubtitleEdit.Core.Enums +{ + public enum FixType + { + Time, + Formatting, + Dialog, + Punctuation, + Casing, + Spacing, + Characters, + Ocr, + } +} diff --git a/src/libse/Forms/DurationsBridgeGaps.cs b/src/libse/Forms/DurationsBridgeGaps.cs index 85c199c45d7..924d654d3d8 100644 --- a/src/libse/Forms/DurationsBridgeGaps.cs +++ b/src/libse/Forms/DurationsBridgeGaps.cs @@ -31,10 +31,13 @@ public static int BridgeGaps(Subtitle subtitle, int minMsBetweenLines, bool divi continue; } - // next paragraph start-time will be pull to try to meet the current paragraph + // next paragraph start-time will be pull to try to meet the current paragraph. + // Split what is left after the minimum gap, not the raw gap: taking the whole + // minimum out of the left side afterwards otherwise made the current subtitle + // *shorter* whenever the gap was under twice the minimum. if (divideEven) { - next.StartTime.TotalMilliseconds -= currentGap / 2.0; + next.StartTime.TotalMilliseconds -= (currentGap - minMsBetweenLines) / 2.0; } cur.EndTime.TotalMilliseconds = next.StartTime.TotalMilliseconds - minMsBetweenLines; diff --git a/src/libse/Forms/FixCommonErrors/AddMissingQuotes.cs b/src/libse/Forms/FixCommonErrors/AddMissingQuotes.cs index 9f2eccf9ca6..0c4a7b44f3b 100644 --- a/src/libse/Forms/FixCommonErrors/AddMissingQuotes.cs +++ b/src/libse/Forms/FixCommonErrors/AddMissingQuotes.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; @@ -11,6 +12,8 @@ public static class Language public static string AddMissingQuote { get; set; } = "Add missing quote (\")"; } + public FixType FixType => FixType.Punctuation; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { string fixAction = Language.AddMissingQuote; diff --git a/src/libse/Forms/FixCommonErrors/Fix3PlusLines.cs b/src/libse/Forms/FixCommonErrors/Fix3PlusLines.cs index 52372904e24..221676e4d51 100644 --- a/src/libse/Forms/FixCommonErrors/Fix3PlusLines.cs +++ b/src/libse/Forms/FixCommonErrors/Fix3PlusLines.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors @@ -11,6 +12,8 @@ public static class Language public static string Fix3PlusLines { get; set; } = "Fix subtitles with more than two lines"; } + public FixType FixType => FixType.Formatting; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { var fixAction = Language.Fix3PlusLine; diff --git a/src/libse/Forms/FixCommonErrors/FixAloneLowercaseIToUppercaseI.cs b/src/libse/Forms/FixCommonErrors/FixAloneLowercaseIToUppercaseI.cs index 7f465fb0a26..a76184c549c 100644 --- a/src/libse/Forms/FixCommonErrors/FixAloneLowercaseIToUppercaseI.cs +++ b/src/libse/Forms/FixCommonErrors/FixAloneLowercaseIToUppercaseI.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; using System.Text.RegularExpressions; @@ -12,6 +13,57 @@ public static class Language public static string FixLowercaseIToUppercaseI { get; set; } = "Fix alone lowercase 'i' to 'I' (English)"; } + public FixType FixType => FixType.Casing; + + // Every needle below embeds Environment.NewLine or the target character, so none of them + // can be a compile-time constant: written inline they were ten fresh strings allocated + // for every line of the file, just to be handed to Replace. Prepared once instead. + private static readonly string UppercaseINewLine = ">I" + Environment.NewLine; + private static readonly string NewLineLowerImSpace = Environment.NewLine + "i'm "; + private static readonly string NewLineUpperImSpace = Environment.NewLine + "I'm "; + private static readonly string NewLineLowerImPeriod = Environment.NewLine + "i'm."; + private static readonly string NewLineUpperImPeriod = Environment.NewLine + "I'm."; + + /// Characters that stop the "i-l" -> "I-l" fix; see the match loop below. + private static readonly string LittleIStopChars = Environment.NewLine + @" <>!.?:;,"; + + /// + /// The four html-tag needles for one target character, prepared once. A single immutable + /// instance swapped by reference keeps this thread-safe without locking; every caller in + /// the app passes 'i', so the memo hits on all but the first call. + /// + private sealed class TargetNeedles + { + public readonly char Target; + public readonly string CloseTag; + public readonly string Space; + public readonly string ZeroWidthSpace; + public readonly string ZeroWidthNoBreakSpace; + + public TargetNeedles(char target) + { + Target = target; + CloseTag = ">" + target + "" + target + " "; + ZeroWidthSpace = ">" + target + "\u200B" + Environment.NewLine; + ZeroWidthNoBreakSpace = ">" + target + "\uFEFF" + Environment.NewLine; + } + } + + private static TargetNeedles _needles = new TargetNeedles('i'); + + private static TargetNeedles GetNeedles(char target) + { + var needles = _needles; + if (needles.Target != target) + { + needles = new TargetNeedles(target); + _needles = needles; + } + + return needles; + } + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { string fixAction = Language.FixLowercaseIToUppercaseI; @@ -38,16 +90,17 @@ public void Fix(Subtitle subtitle, IFixCallbacks callbacks) public static string FixAloneLowercaseIToUppercaseLine(Regex re, string oldText, string input, char target) { //html tags - var s = input.Replace(">" + target + "I" + target + " ", ">I ") - .Replace(">" + target + "\u200B" + Environment.NewLine, ">I" + Environment.NewLine) // Zero Width Space - .Replace(">" + target + "\uFEFF" + Environment.NewLine, ">I" + Environment.NewLine); // Zero Width No-Break Space + var needles = GetNeedles(target); + var s = input.Replace(needles.CloseTag, ">II ") + .Replace(needles.ZeroWidthSpace, UppercaseINewLine) // Zero Width Space + .Replace(needles.ZeroWidthNoBreakSpace, UppercaseINewLine); // Zero Width No-Break Space s = s.Replace(" i-i ", " I-I "); s = s.Replace(" i-i-i ", " I-I-I "); s = s.Replace(" i'm ", " I'm "); - s = s.Replace(Environment.NewLine + "i'm ", Environment.NewLine + "I'm "); - s = s.Replace(Environment.NewLine + "i'm.", Environment.NewLine + "I'm."); + s = s.Replace(NewLineLowerImSpace, NewLineUpperImSpace); + s = s.Replace(NewLineLowerImPeriod, NewLineUpperImPeriod); s = s.Replace(" i'm.", " I'm."); s = s.Replace(" i'm,", " I'm,"); s = s.Replace("-i'm-", "-I'm-"); @@ -96,7 +149,7 @@ public static string FixAloneLowercaseIToUppercaseLine(Regex re, string oldText, fix = false; } - if (fix && next == '-' && match.Index < s.Length - 5 && s[match.Index + 2] == 'l' && !(Environment.NewLine + @" <>!.?:;,").Contains(s[match.Index + 3])) + if (fix && next == '-' && match.Index < s.Length - 5 && s[match.Index + 2] == 'l' && !LittleIStopChars.Contains(s[match.Index + 3])) { fix = false; } diff --git a/src/libse/Forms/FixCommonErrors/FixCommas.cs b/src/libse/Forms/FixCommonErrors/FixCommas.cs index 73da2ed7034..81732aaf039 100644 --- a/src/libse/Forms/FixCommonErrors/FixCommas.cs +++ b/src/libse/Forms/FixCommonErrors/FixCommas.cs @@ -2,6 +2,7 @@ using Nikse.SubtitleEdit.Core.Interfaces; using System.Text.RegularExpressions; using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors { @@ -12,6 +13,8 @@ public static class Language public static string FixCommas { get; set; } = "Fix commas"; } + public FixType FixType => FixType.Punctuation; + // Hoisted out of Fix() so each Fix() call reuses the same compiled NFA instead of // recompiling per-call. The Arabic variants below used to be re-allocated *per // paragraph* — for a 1000-line Arabic subtitle that's ~5000 unnecessary Regex+compile @@ -48,11 +51,13 @@ public void Fix(Subtitle subtitle, IFixCallbacks callbacks) s = CommaTripleEndOfLine.Replace(s, "$1..."); s = CommaWhiteSpaceBetween.Replace(s, "$1,$2"); - var match = CommaFollowedByLetter.Match(s); - if (match.Success && (!(match.Index > 0 && s[match.Index-1] == 'ό' && s.Substring(match.Index).StartsWith(",τι", StringComparison.OrdinalIgnoreCase)) || callbacks.Language != "el")) - { - s = CommaFollowedByLetter.Replace(s, ", $1"); - } + // Per-match evaluator so a protected Greek "ό,τι" elsewhere in the text + // neither gets a space inserted nor shields genuine errors from the fix. + var input = s; + s = CommaFollowedByLetter.Replace(input, m => + callbacks.Language == "el" && m.Index > 0 && input[m.Index - 1] == 'ό' && input.Substring(m.Index).StartsWith(",τι", StringComparison.OrdinalIgnoreCase) + ? m.Value + : ", " + m.Groups[1].Value); s = RemoveCommaBeforeSentenceEndingChar(s, ','); } diff --git a/src/libse/Forms/FixCommonErrors/FixContinuationStyle.cs b/src/libse/Forms/FixCommonErrors/FixContinuationStyle.cs index 9c3f473efbd..5a30d78adeb 100644 --- a/src/libse/Forms/FixCommonErrors/FixContinuationStyle.cs +++ b/src/libse/Forms/FixCommonErrors/FixContinuationStyle.cs @@ -17,6 +17,8 @@ public static class Language public static string FixUnnecessaryLeadingDots { get; set; } = "Remove unnecessary leading dots"; } + public FixType FixType => FixType.Punctuation; + private ContinuationUtilities.ContinuationProfile _continuationProfile; private List _names; public string FixAction { get; set; } @@ -40,14 +42,22 @@ public void Fix(Subtitle subtitle, IFixCallbacks callbacks) var inSentence = false; bool? inItalicSentence = null; + // SanitizeString runs four regex replaces per call, and the loop sanitized every + // paragraph twice: once as pNext's text, then again as p's text one iteration later. + // Carry the sanitized "next" value forward instead. The carry is dropped whenever the + // loop writes pNext.Text below, so the following iteration re-sanitizes what the + // paragraph actually holds. + string carriedText = null; + for (var i = 0; i < subtitle.Paragraphs.Count - 1; i++) { var p = subtitle.Paragraphs[i]; var pNext = subtitle.Paragraphs[i + 1]; var oldText = p.Text; var oldTextNext = pNext.Text; - var text = ContinuationUtilities.SanitizeString(p.Text); + var text = carriedText ?? ContinuationUtilities.SanitizeString(p.Text); var textNext = ContinuationUtilities.SanitizeString(pNext.Text); + carriedText = textNext; // captured before the Arabic conversion below, which the next iteration reapplies var isChecked = true; var shouldProcess = true; @@ -207,6 +217,7 @@ public void Fix(Subtitle subtitle, IFixCallbacks callbacks) if (IsPreviewStep(callbacks) && isChecked || !IsPreviewStep(callbacks)) { pNext.Text = newTextNext; + carriedText = null; // pNext.Text changed - the next iteration must re-sanitize } fixCount++; diff --git a/src/libse/Forms/FixCommonErrors/FixDanishLetterI.cs b/src/libse/Forms/FixCommonErrors/FixDanishLetterI.cs index e0217fb9cfb..d486511ad91 100644 --- a/src/libse/Forms/FixCommonErrors/FixDanishLetterI.cs +++ b/src/libse/Forms/FixCommonErrors/FixDanishLetterI.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors @@ -10,6 +11,8 @@ public static class Language public static string FixDanishLetterI { get; set; } = "Fix Danish letter 'i'"; } + public FixType FixType => FixType.Casing; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { var fixCount = 0; diff --git a/src/libse/Forms/FixCommonErrors/FixDialogsOnOneLine.cs b/src/libse/Forms/FixCommonErrors/FixDialogsOnOneLine.cs index 1c78ed5df38..16ba25f9419 100644 --- a/src/libse/Forms/FixCommonErrors/FixDialogsOnOneLine.cs +++ b/src/libse/Forms/FixCommonErrors/FixDialogsOnOneLine.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors @@ -10,6 +11,8 @@ public static class Language public static string FixDialogsOnOneLine { get; set; } = "Fix dialogs on one line"; } + public FixType FixType => FixType.Dialog; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { var fixAction = Language.FixDialogsOnOneLine; diff --git a/src/libse/Forms/FixCommonErrors/FixDoubleApostrophes.cs b/src/libse/Forms/FixCommonErrors/FixDoubleApostrophes.cs index f572d1d250d..ada8f7fbb7c 100644 --- a/src/libse/Forms/FixCommonErrors/FixDoubleApostrophes.cs +++ b/src/libse/Forms/FixCommonErrors/FixDoubleApostrophes.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors @@ -10,6 +11,8 @@ public static class Language public static string FixDoubleApostrophes { get; set; } = "Fix double apostrophe characters ('') to a single quote (\")"; } + public FixType FixType => FixType.Punctuation; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { string fixAction = Language.FixDoubleApostrophes; diff --git a/src/libse/Forms/FixCommonErrors/FixDoubleDash.cs b/src/libse/Forms/FixCommonErrors/FixDoubleDash.cs index 8a68843617a..f64c41532e3 100644 --- a/src/libse/Forms/FixCommonErrors/FixDoubleDash.cs +++ b/src/libse/Forms/FixCommonErrors/FixDoubleDash.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; @@ -11,6 +12,8 @@ public static class Language public static string FixDoubleDash { get; set; } = "Fix '--' -> '...'"; } + public FixType FixType => FixType.Punctuation; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { int fixCount = 0; diff --git a/src/libse/Forms/FixCommonErrors/FixDoubleGreaterThan.cs b/src/libse/Forms/FixCommonErrors/FixDoubleGreaterThan.cs index 7321693e56c..736ad53e934 100644 --- a/src/libse/Forms/FixCommonErrors/FixDoubleGreaterThan.cs +++ b/src/libse/Forms/FixCommonErrors/FixDoubleGreaterThan.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; @@ -11,6 +12,8 @@ public static class Language public static string FixDoubleGreaterThan { get; set; } = "Remove '>>'"; } + public FixType FixType => FixType.Punctuation; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { int fixCount = 0; diff --git a/src/libse/Forms/FixCommonErrors/FixEllipsesStart.cs b/src/libse/Forms/FixCommonErrors/FixEllipsesStart.cs index 35ed23e7fb4..a8705bc0f1a 100644 --- a/src/libse/Forms/FixCommonErrors/FixEllipsesStart.cs +++ b/src/libse/Forms/FixCommonErrors/FixEllipsesStart.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; @@ -11,6 +12,8 @@ public static class Language public static string FixEllipsesStart { get; set; } = "Remove leading '...'"; } + public FixType FixType => FixType.Punctuation; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { int fixCount = 0; diff --git a/src/libse/Forms/FixCommonErrors/FixEmptyLines.cs b/src/libse/Forms/FixCommonErrors/FixEmptyLines.cs index 570860c5ad1..4fb5276b52c 100644 --- a/src/libse/Forms/FixCommonErrors/FixEmptyLines.cs +++ b/src/libse/Forms/FixCommonErrors/FixEmptyLines.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; @@ -17,6 +18,8 @@ public static class Language public static string RemovedEmptyLinesUnusedLineBreaks { get; set; } = "Remove empty lines/unused line breaks"; } + public FixType FixType => FixType.Formatting; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { var fixAction0 = Language.RemovedEmptyLine; diff --git a/src/libse/Forms/FixCommonErrors/FixHyphensInDialog.cs b/src/libse/Forms/FixCommonErrors/FixHyphensInDialog.cs index f10957676e3..01b1be1aaeb 100644 --- a/src/libse/Forms/FixCommonErrors/FixHyphensInDialog.cs +++ b/src/libse/Forms/FixCommonErrors/FixHyphensInDialog.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors @@ -10,6 +11,8 @@ public static class Language public static string FixHyphensInDialogs { get; set; } = "Fix dash in dialogs via style: {0}"; } + public FixType FixType => FixType.Dialog; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { string fixAction = string.Format(Language.FixHyphensInDialogs, Configuration.Settings.General.DialogStyle); diff --git a/src/libse/Forms/FixCommonErrors/FixHyphensRemoveDashSingleLine.cs b/src/libse/Forms/FixCommonErrors/FixHyphensRemoveDashSingleLine.cs index fcb1f8369da..a6d20d090dd 100644 --- a/src/libse/Forms/FixCommonErrors/FixHyphensRemoveDashSingleLine.cs +++ b/src/libse/Forms/FixCommonErrors/FixHyphensRemoveDashSingleLine.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors @@ -11,6 +12,8 @@ public static class Language public static string RemoveHyphensSingleLine { get; set; } = "Remove dialog dashes in single lines"; } + public FixType FixType => FixType.Dialog; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { var iFixes = 0; diff --git a/src/libse/Forms/FixCommonErrors/FixInvalidItalicTags.cs b/src/libse/Forms/FixCommonErrors/FixInvalidItalicTags.cs index b3311208e83..c871b56bd25 100644 --- a/src/libse/Forms/FixCommonErrors/FixInvalidItalicTags.cs +++ b/src/libse/Forms/FixCommonErrors/FixInvalidItalicTags.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors @@ -11,6 +12,8 @@ public static class Language public static string FixInvalidItalicTags { get; set; } = "Fix invalid italic tags"; } + public FixType FixType => FixType.Formatting; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { string fixAction = Language.FixInvalidItalicTag; diff --git a/src/libse/Forms/FixCommonErrors/FixLongDisplayTimes.cs b/src/libse/Forms/FixCommonErrors/FixLongDisplayTimes.cs index 83843dd5571..d580c0a8c4a 100644 --- a/src/libse/Forms/FixCommonErrors/FixLongDisplayTimes.cs +++ b/src/libse/Forms/FixCommonErrors/FixLongDisplayTimes.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors @@ -10,6 +11,8 @@ public static class Language public static string FixLongDisplayTime { get; set; } = "Fix long display time"; } + public FixType FixType => FixType.Time; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { int noOfLongDisplayTimes = 0; diff --git a/src/libse/Forms/FixCommonErrors/FixLongLines.cs b/src/libse/Forms/FixCommonErrors/FixLongLines.cs index 3bda5f67b8d..0740425cd29 100644 --- a/src/libse/Forms/FixCommonErrors/FixLongLines.cs +++ b/src/libse/Forms/FixCommonErrors/FixLongLines.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors @@ -13,6 +14,8 @@ public static class Language public static string UnableToFixTextXY { get; set; } = "Unable to fix text number {0}: {1}"; } + public FixType FixType => FixType.Formatting; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { var fixAction = Language.BreakLongLine; diff --git a/src/libse/Forms/FixCommonErrors/FixMissingOpenBracket.cs b/src/libse/Forms/FixCommonErrors/FixMissingOpenBracket.cs index 0fadd459655..6c514b6870f 100644 --- a/src/libse/Forms/FixCommonErrors/FixMissingOpenBracket.cs +++ b/src/libse/Forms/FixCommonErrors/FixMissingOpenBracket.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; using System.Linq; @@ -12,6 +13,8 @@ public static class Language public static string FixMissingOpenBracket { get; set; } = "Fix missing [ or ( in line"; } + public FixType FixType => FixType.Punctuation; + private static bool IsIgnorable(char ch) => char.IsWhiteSpace(ch) || ch == '-'; private static char GetOpeningPair(char tag) => tag == ')' ? '(' : '['; diff --git a/src/libse/Forms/FixCommonErrors/FixMissingPeriodsAtEndOfLine.cs b/src/libse/Forms/FixCommonErrors/FixMissingPeriodsAtEndOfLine.cs index b2be3f40135..6cf3df02943 100644 --- a/src/libse/Forms/FixCommonErrors/FixMissingPeriodsAtEndOfLine.cs +++ b/src/libse/Forms/FixCommonErrors/FixMissingPeriodsAtEndOfLine.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; using System.Linq; @@ -13,6 +14,8 @@ public static class Language public static string AddPeriods { get; set; } = "Add missing period at end of line"; } + public FixType FixType => FixType.Punctuation; + private static readonly char[] WordSplitChars = { ' ', '.', ',', '-', '?', '!', ':', ';', '"', '(', ')', '[', ']', '{', '}', '|', '<', '>', '/', '+', '\r', '\n' }; private static bool IsOneLineUrl(string s) diff --git a/src/libse/Forms/FixCommonErrors/FixMissingSpaces.cs b/src/libse/Forms/FixCommonErrors/FixMissingSpaces.cs index e269d490e86..4a1a566cfe8 100644 --- a/src/libse/Forms/FixCommonErrors/FixMissingSpaces.cs +++ b/src/libse/Forms/FixCommonErrors/FixMissingSpaces.cs @@ -1,7 +1,11 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; using System.Text.RegularExpressions; +#if NET8_0_OR_GREATER +using System.Buffers; +#endif namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors { @@ -13,6 +17,16 @@ public static class Language public static string FixMissingSpaces { get; set; } = "Fix missing spaces"; } + public FixType FixType => FixType.Spacing; + + // FixSpaceAfter asks "is this character in that little set?" for every occurrence of the + // fix character in the file. Both sets used to be probed with + // `"...".Contains(text[i].ToString())`, which allocates a one-character string per probe. + private const string SpaceAfterSkipChars = " \r\n\":;()[]<>.؟!\u060C"; +#if NET8_0_OR_GREATER + private static readonly SearchValues SpaceAfterSkipSearch = SearchValues.Create(SpaceAfterSkipChars); +#endif + private static readonly Regex FixMissingSpacesReComma = new Regex(@"[^\s\d],[^\s]", RegexOptions.Compiled); private static readonly Regex FixMissingSpacesRePeriod = new Regex(@"\p{Ll}\p{Ll}[.][\p{Ll}\p{Lu}]", RegexOptions.Compiled); private static readonly Regex FixMissingSpacesReQuestionMark = new Regex(@"[^\s\d]\?[\p{Ll}\p{Lu}\-\d]", RegexOptions.Compiled); @@ -85,7 +99,13 @@ public void Fix(Subtitle subtitle, IFixCallbacks callbacks) p.Text = p.Text.Replace(match.Value, match.Value[0] + ", " + match.Value[match.Value.Length - 1]); callbacks.AddFixToListView(p, fixAction, oldText, p.Text); } - match = match.NextMatch(); + + // Re-match against the UPDATED text as the "?", "!" and ":" loops do. + // NextMatch() walks the pre-replacement string, so after the first fix every + // later match.Index was one short and "p.Text[match.Index + 2]" read the + // comma itself - defeating the expectedChars exclusion for every comma after + // the first ("Well,I know,maybe." gained a space before the tag). + match = FixMissingSpacesReComma.Match(p.Text, match.Index + 1); } var allowFix = callbacks.AllowFix(p, fixAction); @@ -175,7 +195,12 @@ public void Fix(Subtitle subtitle, IFixCallbacks callbacks) isMatchAbbreviation = true; } - if (match.Value.Equals("h.d", StringComparison.OrdinalIgnoreCase) && match.Index > 0 && p.Text.Substring(match.Index - 1, 4).Equals("ph.d", StringComparison.OrdinalIgnoreCase)) + // FixMissingSpacesRePeriod matches exactly four characters, so + // match.Value can never equal the three-character "h.d" - the guard was + // left over from an earlier three-character pattern and never fired, so + // "his ph.d yesterday" was split into "ph. d". The leading letter is + // inside the match now, so test it directly. + if (match.Value.Equals("ph.d", StringComparison.OrdinalIgnoreCase)) { isMatchAbbreviation = true; } @@ -188,7 +213,9 @@ public void Fix(Subtitle subtitle, IFixCallbacks callbacks) callbacks.AddFixToListView(p, fixAction, oldText, p.Text); } } - match = match.NextMatch(); + + // Same as the comma loop above: continue over the updated text. + match = FixMissingSpacesRePeriod.Match(p.Text, match.Index + 1); } if (!p.Text.StartsWith("--", StringComparison.Ordinal)) @@ -403,7 +430,8 @@ private static string FixSpaceAfter(string input, char fixSpaceAfterChar) { skip = true; } - if (!skip && "0123456789".Contains(text[idx + 1].ToString())) + + if (!skip && CharUtils.IsAsciiDigit(text[idx + 1])) { skip = true; } @@ -414,7 +442,7 @@ private static string FixSpaceAfter(string input, char fixSpaceAfterChar) } } - if (!skip && !" \r\n\":;()[]<>.؟!\u060C".Contains(text[idx + 1].ToString())) + if (!skip && !IsSpaceAfterSkipChar(text[idx + 1])) { text = text.Insert(idx + 1, " "); } @@ -425,6 +453,15 @@ private static string FixSpaceAfter(string input, char fixSpaceAfterChar) return text; } + private static bool IsSpaceAfterSkipChar(char c) + { +#if NET8_0_OR_GREATER + return SpaceAfterSkipSearch.Contains(c); +#else + return SpaceAfterSkipChars.IndexOf(c) >= 0; +#endif + } + private static string FixMissingSpaceBeforeAfterMusicQuotes(string input, char musicSymbol) { var text = input; diff --git a/src/libse/Forms/FixCommonErrors/FixMusicNotation.cs b/src/libse/Forms/FixCommonErrors/FixMusicNotation.cs index 75e4f678e2e..9d163dafa61 100644 --- a/src/libse/Forms/FixCommonErrors/FixMusicNotation.cs +++ b/src/libse/Forms/FixCommonErrors/FixMusicNotation.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; @@ -11,6 +12,8 @@ public static class Language public static string FixMusicNotation { get; set; } = "Replace music symbols (e.g. âTª) with preferred symbol"; } + public FixType FixType => FixType.Characters; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { string fixAction = Language.FixMusicNotation; @@ -57,7 +60,10 @@ public void Fix(Subtitle subtitle, IFixCallbacks callbacks) if (count == 1) { var idx = newText.IndexOf('#'); - if (idx < newText.Length - 2) + // newText[idx + 1] only needs idx + 1 <= Length - 1. With + // "- 2" a '#' as the second-to-last character skipped the + // chord guard entirely, so "F#m" became "F(music note)m". + if (idx < newText.Length - 1) { if (char.IsLetterOrDigit(newText[idx + 1])) { diff --git a/src/libse/Forms/FixCommonErrors/FixOverlappingDisplayTimes.cs b/src/libse/Forms/FixCommonErrors/FixOverlappingDisplayTimes.cs index b5621e5efe4..45093b0b029 100644 --- a/src/libse/Forms/FixCommonErrors/FixOverlappingDisplayTimes.cs +++ b/src/libse/Forms/FixCommonErrors/FixOverlappingDisplayTimes.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using Nikse.SubtitleEdit.Core.SubtitleFormats; using System; @@ -17,6 +18,8 @@ public static class Language public static string UnableToFixTextXY { get; set; } = "Unable to fix text number {0}: {1}"; } + public FixType FixType => FixType.Time; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { // negative display time @@ -107,6 +110,7 @@ public void Fix(Subtitle subtitle, IFixCallbacks callbacks) if (!canBeEqual) { bool okEqual = true; + var changedCurrent = false; if (prev.DurationTotalMilliseconds > Configuration.Settings.General.SubtitleMinimumDisplayMilliseconds) { prev.EndTime.TotalMilliseconds--; @@ -114,6 +118,7 @@ public void Fix(Subtitle subtitle, IFixCallbacks callbacks) else if (p.DurationTotalMilliseconds > Configuration.Settings.General.SubtitleMinimumDisplayMilliseconds) { p.StartTime.TotalMilliseconds++; + changedCurrent = true; } else { @@ -123,7 +128,19 @@ public void Fix(Subtitle subtitle, IFixCallbacks callbacks) if (okEqual) { noOfOverlappingDisplayTimesFixed++; - callbacks.AddFixToListView(target, fixAction, oldPrevious, prev.ToString()); + + // Report the paragraph that actually moved. This branch changes + // "p" while prev is untouched, so reporting prev produced a fix + // row whose before and after were identical and hid the real + // change - every other branch reports the one it modified. + if (changedCurrent) + { + callbacks.AddFixToListView(p, fixAction, oldCurrent, p.ToString()); + } + else + { + callbacks.AddFixToListView(target, fixAction, oldPrevious, prev.ToString()); + } } } } diff --git a/src/libse/Forms/FixCommonErrors/FixShortDisplayTimes.cs b/src/libse/Forms/FixCommonErrors/FixShortDisplayTimes.cs index 7221759e7bc..fbd5af37233 100644 --- a/src/libse/Forms/FixCommonErrors/FixShortDisplayTimes.cs +++ b/src/libse/Forms/FixCommonErrors/FixShortDisplayTimes.cs @@ -1,5 +1,6 @@ using System; using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors @@ -13,6 +14,8 @@ public static class Language public static string UnableToFixTextXY { get; set; } = "Unable to fix text number {0}: {1}"; } + public FixType FixType => FixType.Time; + private IFixCallbacks _callbacks; public void Fix(Subtitle subtitle, IFixCallbacks callbacks) diff --git a/src/libse/Forms/FixCommonErrors/FixShortGaps.cs b/src/libse/Forms/FixCommonErrors/FixShortGaps.cs index 6597c5be8ce..5b12a10732d 100644 --- a/src/libse/Forms/FixCommonErrors/FixShortGaps.cs +++ b/src/libse/Forms/FixCommonErrors/FixShortGaps.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors @@ -11,6 +12,8 @@ public static class Language public static string FixShortGaps { get; set; } = "Fix short gaps"; } + public FixType FixType => FixType.Time; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { double minGap = Configuration.Settings.General.MinimumMillisecondsBetweenLines; diff --git a/src/libse/Forms/FixCommonErrors/FixShortLines.cs b/src/libse/Forms/FixCommonErrors/FixShortLines.cs index edb9fecd99e..982dbd1266e 100644 --- a/src/libse/Forms/FixCommonErrors/FixShortLines.cs +++ b/src/libse/Forms/FixCommonErrors/FixShortLines.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors @@ -10,6 +11,8 @@ public static class Language public static string MergeShortLine { get; set; } = "Merge short line (single sentence)"; } + public FixType FixType => FixType.Formatting; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { string fixAction = Language.MergeShortLine; diff --git a/src/libse/Forms/FixCommonErrors/FixShortLinesAll.cs b/src/libse/Forms/FixCommonErrors/FixShortLinesAll.cs index 40c8d836bb5..77de442fa8e 100644 --- a/src/libse/Forms/FixCommonErrors/FixShortLinesAll.cs +++ b/src/libse/Forms/FixCommonErrors/FixShortLinesAll.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; @@ -12,6 +13,8 @@ public static class Language public static string RemoveLineBreaks { get; set; } = "Remove line breaks in short texts with only one sentence"; } + public FixType FixType => FixType.Formatting; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { var fixAction = Language.MergeShortLineAll; diff --git a/src/libse/Forms/FixCommonErrors/FixShortLinesPixelWidth.cs b/src/libse/Forms/FixCommonErrors/FixShortLinesPixelWidth.cs index 333be977f57..a51e628e781 100644 --- a/src/libse/Forms/FixCommonErrors/FixShortLinesPixelWidth.cs +++ b/src/libse/Forms/FixCommonErrors/FixShortLinesPixelWidth.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; @@ -11,6 +12,8 @@ public static class Language public static string UnbreakShortLine { get; set; } = "Unbreak short line (pixel width)"; public static string RemoveLineBreaks { get; set; } = "Unbreak subtitles that can fit on one line (pixel width)"; } + + public FixType FixType => FixType.Formatting; private readonly Func _calcPixelWidth; diff --git a/src/libse/Forms/FixCommonErrors/FixSpanishInvertedQuestionAndExclamationMarks.cs b/src/libse/Forms/FixCommonErrors/FixSpanishInvertedQuestionAndExclamationMarks.cs index 47670316de8..a48ee6d86fb 100644 --- a/src/libse/Forms/FixCommonErrors/FixSpanishInvertedQuestionAndExclamationMarks.cs +++ b/src/libse/Forms/FixCommonErrors/FixSpanishInvertedQuestionAndExclamationMarks.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; using System.Globalization; @@ -17,6 +18,8 @@ public static class Language public static string FixSpanishInvertedQuestionAndExclamationMarks { get; set; } = "Fix Spanish inverted question and exclamation marks"; } + public FixType FixType => FixType.Punctuation; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { int fixCount = 0; @@ -187,7 +190,11 @@ private static void FixSpanishInvertedLetter(char mark, string inverseMark, Para } } } - else if (last != null && !isLastLineClosed && inverseMarkIndex == p.Text.IndexOf(mark) && !last.Text.Contains(inverseMark)) + // "inverseMarkIndex == p.Text.IndexOf(mark)" asked whether one position + // holds both the inverse mark and the mark, which is impossible, so this + // whole "the sentence started in the previous subtitle" branch was dead. + // The test is the same one the isLastLineClosed branch above uses. + else if (last != null && !isLastLineClosed && (inverseMarkIndex < 0 || inverseMarkIndex > markIndex) && !last.Text.Contains(inverseMark)) { int idx = last.Text.Length - 2; while (idx > 0 && last.Text.Substring(idx, 2) != ". " && last.Text.Substring(idx, 2) != "! " && last.Text.Substring(idx, 2) != "? ") diff --git a/src/libse/Forms/FixCommonErrors/FixStartWithUppercaseLetterAfterColon.cs b/src/libse/Forms/FixCommonErrors/FixStartWithUppercaseLetterAfterColon.cs index b71cd5ced99..c54d7a661e5 100644 --- a/src/libse/Forms/FixCommonErrors/FixStartWithUppercaseLetterAfterColon.cs +++ b/src/libse/Forms/FixCommonErrors/FixStartWithUppercaseLetterAfterColon.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors @@ -12,6 +13,8 @@ public static class Language public static string StartWithUppercaseLetterAfterColon { get; set; } = "Start with uppercase letter after colon/semicolon"; } + public FixType FixType => FixType.Casing; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { var fixAction = Language.StartWithUppercaseLetterAfterColon; diff --git a/src/libse/Forms/FixCommonErrors/FixStartWithUppercaseLetterAfterParagraph.cs b/src/libse/Forms/FixCommonErrors/FixStartWithUppercaseLetterAfterParagraph.cs index 37b5e5353be..66fbd8bbd68 100644 --- a/src/libse/Forms/FixCommonErrors/FixStartWithUppercaseLetterAfterParagraph.cs +++ b/src/libse/Forms/FixCommonErrors/FixStartWithUppercaseLetterAfterParagraph.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; @@ -11,6 +12,8 @@ public static class Language public static string FixFirstLetterToUppercaseAfterParagraph { get; set; } = "Fix first letter to uppercase after paragraph"; } + public FixType FixType => FixType.Casing; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { string fixAction = Language.FixFirstLetterToUppercaseAfterParagraph; @@ -331,7 +334,10 @@ private static bool IsEnglishCandidateForLowercaseLtoUppercaseI(string language, text.StartsWith("ls") || text.StartsWith("ldiot", StringComparison.Ordinal) || text.StartsWith("ln", StringComparison.Ordinal) || text.StartsWith("lm", StringComparison.Ordinal) || text.StartsWith("lt", StringComparison.Ordinal) || text.StartsWith("lf ", StringComparison.Ordinal) || text.StartsWith("lc", StringComparison.Ordinal) || - text.StartsWith("l'm ", StringComparison.Ordinal)) || text.StartsWith("l've ", StringComparison.Ordinal); + text.StartsWith("l'm ", StringComparison.Ordinal) || + // This one sat outside the "language == en" group, so a + // Danish line starting "l've" was rewritten to "I've". + text.StartsWith("l've ", StringComparison.Ordinal)); } } } diff --git a/src/libse/Forms/FixCommonErrors/FixStartWithUppercaseLetterAfterPeriodInsideParagraph.cs b/src/libse/Forms/FixCommonErrors/FixStartWithUppercaseLetterAfterPeriodInsideParagraph.cs index 56587649c03..dd3bc3f8062 100644 --- a/src/libse/Forms/FixCommonErrors/FixStartWithUppercaseLetterAfterPeriodInsideParagraph.cs +++ b/src/libse/Forms/FixCommonErrors/FixStartWithUppercaseLetterAfterPeriodInsideParagraph.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System.Text.RegularExpressions; @@ -11,6 +12,8 @@ public static class Language public static string StartWithUppercaseLetterAfterPeriodInsideParagraph { get; set; } = "Start with uppercase letter after period inside paragraph"; } + public FixType FixType => FixType.Casing; + private static readonly char[] ExpectedChars = { '.', '!', '?' }; // Bulgarian have "г." after years but the sentence continues with lowercase. diff --git a/src/libse/Forms/FixCommonErrors/FixTurkishAnsiToUnicode.cs b/src/libse/Forms/FixCommonErrors/FixTurkishAnsiToUnicode.cs index 6ff2acae84e..8e5340c5783 100644 --- a/src/libse/Forms/FixCommonErrors/FixTurkishAnsiToUnicode.cs +++ b/src/libse/Forms/FixCommonErrors/FixTurkishAnsiToUnicode.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors @@ -10,6 +11,8 @@ public static class Language public static string FixTurkishAnsi { get; set; } = "Fix Turkish ANSI (Icelandic) letters to Unicode"; } + public FixType FixType => FixType.Characters; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { int noOfFixes = 0; diff --git a/src/libse/Forms/FixCommonErrors/FixUnnecessaryLeadingDots.cs b/src/libse/Forms/FixCommonErrors/FixUnnecessaryLeadingDots.cs index a8603a019ae..9d850e66372 100644 --- a/src/libse/Forms/FixCommonErrors/FixUnnecessaryLeadingDots.cs +++ b/src/libse/Forms/FixCommonErrors/FixUnnecessaryLeadingDots.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; @@ -11,6 +12,8 @@ public static class Language public static string FixUnnecessaryLeadingDots { get; set; } = "Remove unnecessary leading dots"; } + public FixType FixType => FixType.Punctuation; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { string fixAction = Language.FixUnnecessaryLeadingDots; diff --git a/src/libse/Forms/FixCommonErrors/FixUnneededPeriods.cs b/src/libse/Forms/FixCommonErrors/FixUnneededPeriods.cs index 0d6dfeab12f..adf88e84573 100644 --- a/src/libse/Forms/FixCommonErrors/FixUnneededPeriods.cs +++ b/src/libse/Forms/FixCommonErrors/FixUnneededPeriods.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; using System.Text; @@ -13,6 +14,8 @@ public static class Language public static string RemoveUnneededPeriods { get; set; } = "Remove unneeded periods"; } + public FixType FixType => FixType.Punctuation; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { var fixAction = Language.UnneededPeriod; diff --git a/src/libse/Forms/FixCommonErrors/FixUnneededSpaces.cs b/src/libse/Forms/FixCommonErrors/FixUnneededSpaces.cs index 9ec6dc40d62..39fba962524 100644 --- a/src/libse/Forms/FixCommonErrors/FixUnneededSpaces.cs +++ b/src/libse/Forms/FixCommonErrors/FixUnneededSpaces.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; @@ -12,6 +13,8 @@ public static class Language public static string RemoveUnneededSpaces { get; set; } = "Remove unneeded spaces"; } + public FixType FixType => FixType.Spacing; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { string fixAction = Language.UnneededSpace; diff --git a/src/libse/Forms/FixCommonErrors/FixUppercaseIInsideWords.cs b/src/libse/Forms/FixCommonErrors/FixUppercaseIInsideWords.cs index c343d2374ed..2f5bdef057a 100644 --- a/src/libse/Forms/FixCommonErrors/FixUppercaseIInsideWords.cs +++ b/src/libse/Forms/FixCommonErrors/FixUppercaseIInsideWords.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System; using System.Text.RegularExpressions; @@ -13,6 +14,8 @@ public static class Language public static string FixUppercaseIInsideLowercaseWords { get; set; } = "Fix uppercase 'i' inside lowercase words (OCR error)"; } + public FixType FixType => FixType.Casing; + private static readonly Regex ReAfterLowercaseLetter = new Regex(@"\p{Ll}I", RegexOptions.Compiled); private static readonly Regex ReBeforeLowercaseLetter = new Regex(@"\p{L}I\p{Ll}", RegexOptions.Compiled); @@ -30,7 +33,10 @@ public void Fix(Subtitle subtitle, IFixCallbacks callbacks) Match match = ReAfterLowercaseLetter.Match(st.StrippedText); while (match.Success) { - if (!(match.Index > 1 && st.StrippedText.Substring(match.Index - 1, 2) == "Mc") // irish names, McDonalds etc. + // ">= 1", not "> 1": a line starting with "McIntyre" matches at index 1, so the + // guard was skipped and the name was corrupted to "Mclntyre". The second loop + // in this file already uses >= 1. + if (!(match.Index >= 1 && st.StrippedText.Substring(match.Index - 1, 2) == "Mc") // irish names, McDonalds etc. && st.StrippedText[match.Index + 1] == 'I' && callbacks.AllowFix(p, fixAction)) { diff --git a/src/libse/Forms/FixCommonErrors/Helper.cs b/src/libse/Forms/FixCommonErrors/Helper.cs index 3b2d04406e0..e09b3d0b8ca 100644 --- a/src/libse/Forms/FixCommonErrors/Helper.cs +++ b/src/libse/Forms/FixCommonErrors/Helper.cs @@ -294,7 +294,7 @@ public static string FixDialogsOnOneLine(string text, string language) part1.Length > 1 && (char.IsUpper(part1[0]) || "\"'♫♪{[(".Contains(part1[0]))) { text = text.Replace(" - ", Environment.NewLine + "- "); - if (char.IsLetter(part0[0]) || CharUtils.IsDigit(part0[0])) + if (char.IsLetter(part0[0]) || CharUtils.IsAsciiDigit(part0[0])) { if (text.Length > 3 && text[0] == '<' && text[2] == '>') { @@ -577,14 +577,18 @@ private static string RemoveSpacesBeginLine(string text) } } - if (text.LineStartsWithHtmlTag(true) && text[3] == 0x20) + // Bound the index like the "{\...}" branch above: LineStartsWithHtmlTag(true) is + // satisfied by a string that is exactly "", so text[3] read past the end. Reached + // by FixHyphensRemoveForSingleLine on "-" (the dash is stripped first, leaving + // ""), which took down the whole Fix-Common-Errors / Remove-text-for-HI run. + if (text.Length > 3 && text.LineStartsWithHtmlTag(true) && text[3] == 0x20) { text = text.Remove(3, 1).TrimStart(); } if (text.LineStartsWithHtmlTag(false, true)) { var closeIdx = text.IndexOf('>'); - if (closeIdx > 6 && text[closeIdx + 1] == 0x20) + if (closeIdx > 6 && closeIdx + 1 < text.Length && text[closeIdx + 1] == 0x20) { text = text.Remove(closeIdx + 1, 1); } @@ -649,7 +653,7 @@ public static string FixHyphensAdd(Subtitle subtitle, int i, string language) if (parts.Count == 2 && !string.IsNullOrWhiteSpace(parts[0])) { var part0 = parts[0].TrimEnd().Trim('"'); - bool doAdd = "!?.".Contains(part0[part0.Length - 1]) || LanguageAutoDetect.IsLanguageWithoutPeriods(language); + bool doAdd = part0.Length > 0 && ("!?.".Contains(part0[part0.Length - 1]) || LanguageAutoDetect.IsLanguageWithoutPeriods(language)); if (parts[0].TrimStart().StartsWith('-') && parts[1].Contains(':') && doAdd || parts[1].TrimStart().StartsWith('-') && parts[0].Contains(':') && doAdd) { diff --git a/src/libse/Forms/FixCommonErrors/HelperContinuation.cs b/src/libse/Forms/FixCommonErrors/HelperContinuation.cs deleted file mode 100644 index 3b1f157badd..00000000000 --- a/src/libse/Forms/FixCommonErrors/HelperContinuation.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; - -namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors -{ - public static class HelperContinuation - { - private static string dashes = "-‐–—"; - private static string quotes = "'\"“”‘’«»‹›„“‚‘"; - private static string singleQuotes = "'‘’‘"; - private static string doubleQuotes = "''‘‘’’‚‚‘‘"; - - public static string SanitizeString(string input) - { - string checkString = input; - checkString = Regex.Replace(checkString, "<.*?>", String.Empty); - checkString = Regex.Replace(checkString, "\\(.*?\\)", String.Empty); - checkString = Regex.Replace(checkString, "\\[.*?\\]", String.Empty); - checkString = Regex.Replace(checkString, "\\{.*?\\}", String.Empty); - checkString = checkString.Trim(); - - // Remove string elevation - if (checkString.EndsWith("\r\n_") || checkString.EndsWith("\r\n.") || checkString.EndsWith("\n_") || checkString.EndsWith("\n.")) - { - checkString = checkString.Substring(0, checkString.Length - 1).Trim(); - } - - // Remove dashes from the beginning - if (dashes.Contains(checkString[0])) - { - checkString = checkString.Substring(1).Trim(); - } - - // Remove single-char quotes from the beginning - if (quotes.Contains(checkString[0])) - { - if (singleQuotes.Contains(checkString[0]) && Char.IsLetter(checkString[1]) && !Char.IsUpper(checkString[1]) && Char.IsWhiteSpace(checkString[2]) && Char.IsLetter(checkString[3])) - { - // 's Avonds -- don't remove - } - else - { - checkString = checkString.Substring(1).Trim(); - } - } - - // Remove double-char quotes from the beginning - if (doubleQuotes.Contains(checkString.Substring(0, 2))) - { - checkString = checkString.Substring(2).Trim(); - } - - // Remove single-char quotes from the ending - if (quotes.Contains(checkString[checkString.Length - 1])) - { - if (singleQuotes.Contains(checkString[checkString.Length - 1])) - { - // Could be something like: nothin' - // TODO - } - - checkString = checkString.Substring(0, checkString.Length - 1).Trim(); - } - - // Remove double-char quotes from the ending - if (doubleQuotes.Contains(checkString.Substring(checkString.Length - 2, 2))) - { - checkString = checkString.Substring(0, checkString.Length - 2).Trim(); - } - - return checkString; - } - } -} diff --git a/src/libse/Forms/FixCommonErrors/NormalizeStrings.cs b/src/libse/Forms/FixCommonErrors/NormalizeStrings.cs index 8edd3cb3fbc..0147f5f994a 100644 --- a/src/libse/Forms/FixCommonErrors/NormalizeStrings.cs +++ b/src/libse/Forms/FixCommonErrors/NormalizeStrings.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using System.Linq; @@ -11,6 +12,8 @@ public static class Language public static string NormalizeStrings { get; set; } = "Normalize strings"; } + public FixType FixType => FixType.Characters; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { var twoLetterLanguageCode = callbacks.Language; @@ -31,13 +34,43 @@ public void Fix(Subtitle subtitle, IFixCallbacks callbacks) .Replace('\u2043', '-') // ⁃ Hyphen bullet (\u2043) .Replace('\u2010', '-') // ‐ Hyphen (\u2010) + .Replace('\u2011', '-') // ‑ Non-breaking hyphen (\u2011) .Replace('\u2012', '-') // ‒ Figure dash (\u2012) .Replace('\u2013', '-') // – En dash (\u2013) //.Replace('\u2014', '-') // — Em dash (\u2014) - we keep em dash .Replace('\u2015', '\u2014') // ― Horizontal bar (\u2015) + + // Machine translation hands back typographic punctuation the source never + // had - DeepL alone answers plain ASCII input with curly quotes, en dashes + // and a one-character ellipsis (issue #14092). + .Replace("\u2026", "...") // … Horizontal ellipsis (\u2026) + .Replace('\u2032', '\'') // ′ Prime (\u2032) + .Replace('\u2033', '"') // ″ Double prime (\u2033) ; - var cyrillicLanguages = new[] { "ru" }; + // Quotation marks are the language-specific part of this: „…“ and ‚…‘ are how German + // and several of its neighbours quote, so replacing them there would take the + // language's own punctuation away rather than normalize anything. + var lowQuoteLanguages = new[] { "de", "cs", "sk", "pl", "sl", "hr", "sr", "hu", "lt", "et", "ro", "is", "bg", "ka", "sq" }; + if (!lowQuoteLanguages.Contains(twoLetterLanguageCode)) + { + text = text + .Replace('\u2018', '\'') // ‘ Left single quotation mark (\u2018) + .Replace('\u2019', '\'') // ’ Right single quotation mark (\u2019) - the typographic apostrophe + .Replace('\u201A', '\'') // ‚ Single low-9 quotation mark (\u201A) + .Replace('\u201B', '\'') // ‛ Single high-reversed-9 quotation mark (\u201B) + .Replace('\u201C', '"') // “ Left double quotation mark (\u201C) + .Replace('\u201D', '"') // ” Right double quotation mark (\u201D) + .Replace('\u201E', '"') // „ Double low-9 quotation mark (\u201E) + .Replace('\u201F', '"') // ‟ Double high-reversed-9 quotation mark (\u201F) + ; + } + + // Replacing Cyrillic "е" with Latin "e" is an OCR heuristic for Latin-script + // languages. Only Russian was exempt, so Bulgarian, Ukrainian, Serbian, Macedonian + // and the rest had a Latin "e" spliced into Cyrillic words - breaking spell check, + // search and sorting. + var cyrillicLanguages = new[] { "ru", "uk", "bg", "sr", "mk", "be", "kk", "ky", "mn", "tg" }; if (!cyrillicLanguages.Contains(twoLetterLanguageCode)) { text = text diff --git a/src/libse/Forms/FixCommonErrors/RemoveDialogFirstLineInNonDialogs.cs b/src/libse/Forms/FixCommonErrors/RemoveDialogFirstLineInNonDialogs.cs index 4c811718b30..cf3320b8e89 100644 --- a/src/libse/Forms/FixCommonErrors/RemoveDialogFirstLineInNonDialogs.cs +++ b/src/libse/Forms/FixCommonErrors/RemoveDialogFirstLineInNonDialogs.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors @@ -10,6 +11,8 @@ public static class Language public static string RemoveDialogFirstInNonDialogs { get; set; } = "Remove start dash in non dialogs"; } + public FixType FixType => FixType.Dialog; + /// /// Dash characters that can be used as dialog marker - see issue #12748. /// diff --git a/src/libse/Forms/FixCommonErrors/RemoveSpaceBetweenNumbers.cs b/src/libse/Forms/FixCommonErrors/RemoveSpaceBetweenNumbers.cs index 5b4e1809bce..0e29b666af7 100644 --- a/src/libse/Forms/FixCommonErrors/RemoveSpaceBetweenNumbers.cs +++ b/src/libse/Forms/FixCommonErrors/RemoveSpaceBetweenNumbers.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; namespace Nikse.SubtitleEdit.Core.Forms.FixCommonErrors @@ -10,6 +11,8 @@ public static class Language public static string RemoveSpaceBetweenNumber { get; set; } = "Remove space between numbers"; } + public FixType FixType => FixType.Spacing; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { int noOfFixes = 0; diff --git a/src/libse/Forms/MergeLinesWithSameTimeCodes.cs b/src/libse/Forms/MergeLinesWithSameTimeCodes.cs index 64ff9962c52..ca2daa16166 100644 --- a/src/libse/Forms/MergeLinesWithSameTimeCodes.cs +++ b/src/libse/Forms/MergeLinesWithSameTimeCodes.cs @@ -119,7 +119,11 @@ private static string GetMergedLines(string line1, string line2, bool makeDialog case Enums.DialogType.DashSecondLineWithoutSpace: return line1 + Environment.NewLine + "-" + line2; default: - return (line1.StartsWith("- ") ? "" : "- ") + line1 + Environment.NewLine + "- " + line2; + // Test for a leading dash regardless of the space after it, as the + // DashBothLinesWithoutSpace case above does. Testing "- " missed a dash + // written without one, so "-Hello" came back as "- -Hello" - and this is + // the shipped default dialog style. + return (line1.StartsWith("-") ? "" : "- ") + line1 + Environment.NewLine + "- " + line2; } } else diff --git a/src/libse/Forms/MoveWordUpDown.cs b/src/libse/Forms/MoveWordUpDown.cs index 31dd324cade..a470ed0dc44 100644 --- a/src/libse/Forms/MoveWordUpDown.cs +++ b/src/libse/Forms/MoveWordUpDown.cs @@ -149,8 +149,11 @@ public void MoveWordUp() } // Build word with tags + // A Stack enumerates top-to-bottom, i.e. already innermost-first, so Insert(0, ...) + // reversed it again and produced crossed tags ("Hello"). Enumerate + // bottom-to-top and append, the way MoveWordDown does. var closingTags = new StringBuilder(); - foreach (var (opening, closing) in openTags) + foreach (var (opening, closing) in openTags.Reverse()) { if (!string.IsNullOrEmpty(closing)) { diff --git a/src/libse/Forms/RemoveInterjection.cs b/src/libse/Forms/RemoveInterjection.cs index 8e8546de4dc..7b812c7bb3d 100644 --- a/src/libse/Forms/RemoveInterjection.cs +++ b/src/libse/Forms/RemoveInterjection.cs @@ -79,7 +79,10 @@ public string Invoke(InterjectionRemoveContext context) } if (doSkip) { - break; + // Next interjection, not "give up on this paragraph": breaking + // here left doRepeat false, so the enclosing while ended too and + // one skip-listed hit suppressed every other removal in the line. + continue; } var removeAfter = true; diff --git a/src/libse/Forms/RemoveTextForHI.cs b/src/libse/Forms/RemoveTextForHI.cs index 97ae483d4ce..40e94beecb0 100644 --- a/src/libse/Forms/RemoveTextForHI.cs +++ b/src/libse/Forms/RemoveTextForHI.cs @@ -3,6 +3,9 @@ using System; using System.Collections.Generic; using System.Text; +#if NET8_0_OR_GREATER +using System.Buffers; +#endif namespace Nikse.SubtitleEdit.Core.Forms { @@ -10,6 +13,42 @@ public class RemoveTextForHI { private static readonly CharLookup MusicSymbols = CharLookup.Create('\u266A', '\u266B'); + // ShouldRemoveNarrator runs for every colon in the file and used to allocate all three + // of these sets on every call. Hoisting the string[] literals alone measured *slower* + // than the throw-away arrays, so the sets are prepared as multi-value searches instead: + // one pass over the text each, rather than two and fifteen separate IndexOf sweeps. + private static readonly string[] NarratorSkipWords = + { + "Previously on", + "Improved by", + " is ", + " are ", + " were ", + " was ", + " think ", + " guess ", + " will ", + " believe ", + " say ", + " said ", + " do ", + " want ", + "That's " + }; + + private static readonly string[] NarratorSkipUrlOrList = { "http", ", " }; + private static readonly char[] NarratorSkipPunctuation = { '!', '?', '\u00BF', '\u00A1' }; +#if NET8_0_OR_GREATER + private static readonly SearchValues NarratorSkipWordsSearch = + SearchValues.Create(NarratorSkipWords, StringComparison.OrdinalIgnoreCase); + + private static readonly SearchValues NarratorSkipUrlOrListSearch = + SearchValues.Create(NarratorSkipUrlOrList, StringComparison.OrdinalIgnoreCase); + + private static readonly SearchValues NarratorSkipPunctuationSearch = + SearchValues.Create(NarratorSkipPunctuation); +#endif + public RemoveTextForHISettings Settings { get; set; } public List Warnings { get; set; } @@ -68,51 +107,69 @@ public string RemoveHearingImpairedTagsInsideLine(string input) } var newText = input; - const string endChars = ".?!"; for (var i = 6; i < newText.Length; i++) { - var s = newText.Substring(i); - if (s.Length > 2 && endChars.Contains(s[0])) + // The tail past i used to be materialized with newText.Substring(i) on every + // character, and then sliced again two or three times - quadratic in time and + // allocation for every line this runs on. Everything below the sentence-ending + // character is index arithmetic now; the tail is only built once a candidate + // prefix has actually matched. + var ch = newText[i]; + if (newText.Length - i <= 2 || (ch != '.' && ch != '?' && ch != '!')) { - var pre = string.Empty; + continue; + } - s = s.Remove(0, 1); - if (s.StartsWith(' ')) - { - pre = s.StartsWith(" ", StringComparison.Ordinal) ? " " : " "; - } - else if (s.StartsWith("", StringComparison.Ordinal)) - { - pre = ""; - } - else if (s.StartsWith("", StringComparison.Ordinal)) - { - pre = ""; - } + // "s" in the old code started here (the sentence-ending character removed). + var tail = i + 1; + var pre = string.Empty; + if (newText[tail] == ' ') + { + pre = StartsWithAt(newText, tail, " ") ? " " : " "; + } + else if (StartsWithAt(newText, tail, "")) + { + pre = ""; + } + else if (StartsWithAt(newText, tail, "")) + { + pre = ""; + } - if (pre.Length > 0) - { - s = s.Remove(0, pre.Length); - if (s.Length > 1 && s[0] == ' ') - { - pre += " "; - s = s.Remove(0, 1); - } + if (pre.Length == 0) + { + continue; + } - if (HasHearImpairedTagsAtStartOrEnd(s)) - { - s = RemoveStartEndTags(s); - newText = newText.Substring(0, i + 1) + pre + " " + s; - newText = newText.Replace("", string.Empty); - newText = newText.Replace(" ", " ").FixExtraSpaces(); - } - } + var rest = tail + pre.Length; + if (newText.Length - rest > 1 && newText[rest] == ' ') + { + pre += " "; + rest++; + } + + var s = newText.Substring(rest); + if (HasHearImpairedTagsAtStartOrEnd(s)) + { + s = RemoveStartEndTags(s); + newText = newText.Substring(0, i + 1) + pre + " " + s; + newText = newText.Replace("", string.Empty); + newText = newText.Replace(" ", " ").FixExtraSpaces(); } } return newText; } + /// + /// Ordinal text.Substring(index).StartsWith(value) without the substring. + /// + private static bool StartsWithAt(string text, int index, string value) + { + return text.Length - index >= value.Length && + string.CompareOrdinal(text, index, value, 0, value.Length) == 0; + } + private static readonly string[] ExpectedStrings = { ". ", "! ", "? " }; public string RemoveColon(string text) { @@ -590,7 +647,10 @@ public string RemoveColon(string text) } } - if (insertDash) + // arr came from SplitToLines, which accepts "\n", "\r" and "\r\n" alike, so on + // Windows a text broken with a bare "\n" reached the Substring below with an index + // of -1 and threw out of the whole Remove-text-for-HI run. + if (insertDash && newText.IndexOf(Environment.NewLine, StringComparison.Ordinal) >= 0) { if (indexOfDialogChar < 0 || indexOfDialogChar > 4) { @@ -862,12 +922,18 @@ private string RemovePartialBeforeColon(string line, int indexOfColon, string ne if (partialRemove) { - newText = line.Remove(lastIndexOfPeriod + 4, indexOfColon - lastIndexOfPeriod - 3); - if (newText.Substring(lastIndexOfPeriod + 3).StartsWith(" ")) + var modifiedLine = line.Remove(lastIndexOfPeriod + 4, indexOfColon - lastIndexOfPeriod - 3); + var doubleSpaceAt = lastIndexOfPeriod + 3; + if (doubleSpaceAt + 1 < modifiedLine.Length && modifiedLine[doubleSpaceAt] == ' ' && modifiedLine[doubleSpaceAt + 1] == ' ') { - newText = newText.Remove(lastIndexOfPeriod + 3, 1); + modifiedLine = modifiedLine.Remove(lastIndexOfPeriod + 3, 1); } + // Append to the accumulator, the way every other branch of RemoveColon + // does. Assigning replaced it, throwing away every line already + // processed - so removing "NAME:" on line 2 deleted line 1. + newText = (newText + Environment.NewLine + modifiedLine).Trim(); + if (count == 0) { removedInFirstLine = true; @@ -905,35 +971,33 @@ private static bool IsInsideBrackets(string text, int targetIndex) private static bool ShouldRemoveNarrator(string pre, string language) { - if (pre.Length > 30 || pre.IndexOfAny(new[] { "http", ", " }, StringComparison.OrdinalIgnoreCase) >= 0) +#if NET8_0_OR_GREATER + if (pre.Length > 30 || pre.AsSpan().IndexOfAny(NarratorSkipUrlOrListSearch) >= 0) { return false; } - if (language == "en" && pre.Length > 15 && pre.IndexOfAny(new[] - { - "Previously on", - "Improved by", - " is ", - " are ", - " were ", - " was ", - " think ", - " guess ", - " will ", - " believe ", - " say ", - " said ", - " do ", - " want ", - "That's " - }, StringComparison.OrdinalIgnoreCase) >= 0) + if (language == "en" && pre.Length > 15 && pre.AsSpan().IndexOfAny(NarratorSkipWordsSearch) >= 0) { return false; } // Okay! Narrator: Hello! - return pre.IndexOfAny(new[] { '!', '?', '¿', '¡' }) < 0; + return pre.AsSpan().IndexOfAny(NarratorSkipPunctuationSearch) < 0; +#else + if (pre.Length > 30 || pre.IndexOfAny(NarratorSkipUrlOrList, StringComparison.OrdinalIgnoreCase) >= 0) + { + return false; + } + + if (language == "en" && pre.Length > 15 && pre.IndexOfAny(NarratorSkipWords, StringComparison.OrdinalIgnoreCase) >= 0) + { + return false; + } + + // Okay! Narrator: Hello! + return pre.IndexOfAny(NarratorSkipPunctuation) < 0; +#endif } private static readonly char[] TrimStartNoiseChar = { '-', ' ' }; @@ -1110,12 +1174,11 @@ public string RemoveTextFromHearImpaired(string inputWithoutUnicodeReplace, Subt text = RemoveColon(text); text = RemoveLineIfAllUppercase(text); text = RemoveHearingImpairedTagsInsideLine(text); - if (Settings.RemoveInterjections) + // Interjection removal needs a list; the caller supplies one via ReloadInterjection. + // Without this guard a caller that enables the setting but never loads a list (the + // seconv "removetextforhi" path) dereferenced null inside RemoveInterjection. + if (Settings.RemoveInterjections && _interjections != null) { - if (_interjections == null) - { - //ReloadInterjection(twoLetterIsoLanguageName); - } // reusable context _interjectionRemoveContext.Text = text; @@ -1421,34 +1484,10 @@ private string RemoveStartEndTags(string text) var newText = text; var s = text; int index; - if (Settings.RemoveTextBetweenSquares && s.StartsWith('[') && (index = s.IndexOf(']', 1)) > 0) - { - if (++index < s.Length && s[index] == ':') - { - index++; - } - - newText = s.Remove(0, index); - } - else if (Settings.RemoveTextBetweenBrackets && s.StartsWith('{') && (index = s.IndexOf('}', 1)) > 0) - { - if (++index < s.Length && s[index] == ':') - { - index++; - } - - newText = s.Remove(0, index); - } - else if (Settings.RemoveTextBetweenParentheses && s.StartsWith('(') && (index = s.IndexOf(')', 1)) > 0) - { - if (++index < s.Length && s[index] == ':') - { - index++; - } - - newText = s.Remove(0, index); - } - else if (Settings.RemoveTextBetweenQuestionMarks && s.StartsWith('?') && (index = s.IndexOf('?', 1)) > 0) + if (Settings.RemoveTextBetweenSquares && s.StartsWith('[') && (index = s.IndexOf(']', 1)) > 0 || + Settings.RemoveTextBetweenBrackets && s.StartsWith('{') && (index = s.IndexOf('}', 1)) > 0 || + Settings.RemoveTextBetweenParentheses && s.StartsWith('(') && (index = s.IndexOf(')', 1)) > 0 || + Settings.RemoveTextBetweenQuestionMarks && s.StartsWith('?') && (index = s.IndexOf('?', 1)) > 0) { if (++index < s.Length && s[index] == ':') { @@ -1601,7 +1640,7 @@ private static string RemoveTextBetweenTags(string startTag, string endTag, stri do { - var end = text.IndexOf(endTag, start + startTag.Length, StringComparison.Ordinal); + var end = FindMatchingEndTag(text, start, startTag, endTag); if (end < 0) { break; @@ -1633,6 +1672,49 @@ private static string RemoveTextBetweenTags(string startTag, string endTag, stri return text.FixExtraSpaces().TrimEnd(); } + /// + /// Index of the end tag that closes the start tag at , counting + /// nesting depth. Taking the first end tag instead cut the wrong span for a nested pair + /// of the same kind - "(WOMAN (V.O.)) Where are you?" lost "(WOMAN (V.O.)" and left the + /// outer ")" stranded. Returns -1 when the pair is never closed. + /// + private static int FindMatchingEndTag(string text, int start, string startTag, string endTag) + { + // Identical start/end tags (e.g. "#...#") cannot nest - the first one closes it. + if (startTag == endTag) + { + return text.IndexOf(endTag, start + startTag.Length, StringComparison.Ordinal); + } + + var depth = 1; + var i = start + startTag.Length; + while (i < text.Length) + { + if (string.CompareOrdinal(text, i, startTag, 0, startTag.Length) == 0) + { + depth++; + i += startTag.Length; + continue; + } + + if (string.CompareOrdinal(text, i, endTag, 0, endTag.Length) == 0) + { + depth--; + if (depth == 0) + { + return i; + } + + i += endTag.Length; + continue; + } + + i++; + } + + return -1; + } + public string RemoveLineIfAllUppercase(string text) { if (!Settings.RemoveIfAllUppercase) @@ -1643,14 +1725,17 @@ public string RemoveLineIfAllUppercase(string text) var whitelist = GetUppercaseWhitelist(); var sb = new StringBuilder(); - char[] endTrimChars = { '.', '!', '?', ':' }; + // Commas, semicolons and quote marks too: IsAllUppercase accepts them, so a + // whitelisted word punctuated any other way ("OK," / '"OK"') failed the lookup + // below and the whole line was deleted. + char[] endTrimChars = { '.', '!', '?', ':', ',', ';', '"', '\'', '\u201d', '\u2019' }; char[] trimChars = { ' ', '-', '—' }; foreach (var line in text.SplitToLines()) { var lineNoHtml = HtmlUtil.RemoveHtmlTags(line, true); if (Utilities.IsAllUppercase(lineNoHtml) && Utilities.HasUppercase(lineNoHtml)) { - var temp = lineNoHtml.TrimEnd(endTrimChars).Trim().Trim(trimChars); + var temp = lineNoHtml.Trim(endTrimChars).Trim().Trim(trimChars); // Single-letter lines (e.g. "I") are always kept; otherwise keep only the // user-configurable whitelist words / acronyms (OK, TV, WWE, ...) - issue #11563. if (temp.Length == 1 || whitelist.Contains(temp)) diff --git a/src/libse/Forms/SplitLongLinesHelper.cs b/src/libse/Forms/SplitLongLinesHelper.cs index ce9e42db967..e9dc8c0d63c 100644 --- a/src/libse/Forms/SplitLongLinesHelper.cs +++ b/src/libse/Forms/SplitLongLinesHelper.cs @@ -93,7 +93,10 @@ public static Subtitle SplitLongLinesInSubtitle(Subtitle subtitle, int totalLine // build second paragraph var newParagraph = new Paragraph(oldParagraph) { Text = lines[secondLine] }; - newParagraph.StartTime.TotalMilliseconds = oldParagraph.EndTime.TotalMilliseconds + halfMinGapsMood; + // Measured from the split point, not from the already-reduced end - adding + // it to oldParagraph.EndTime made the resulting gap halfMinGapsMood alone, + // i.e. half the configured minimum. + newParagraph.StartTime.TotalMilliseconds = oldParagraph.EndTime.TotalMilliseconds + halfMinGaps + halfMinGapsMood; newParagraph.EndTime.TotalMilliseconds = newParagraph.StartTime.TotalMilliseconds + millisecondsPerChar * HtmlUtil.RemoveHtmlTags(newParagraph.Text, true).Length; // only remove dash (if dialog) if first line is fully closed and "Split removes dashes" is true diff --git a/src/libse/Forms/TimeCodesBeautifier.cs b/src/libse/Forms/TimeCodesBeautifier.cs index ec59f919f41..6878a10589e 100644 --- a/src/libse/Forms/TimeCodesBeautifier.cs +++ b/src/libse/Forms/TimeCodesBeautifier.cs @@ -652,7 +652,11 @@ private bool FixChainableSubtitles(Paragraph leftParagraph = null, Paragraph rig case BeautifyTimeCodesSettings.BeautifyTimeCodesProfile.ChainingShotChangeBehaviorEnum.ExtendUntilShotChange: // Put the right in cue on the shot change, plus gap newLeftOutCueFrame = bestLeftOutCueFrame; - newRightInCueFrame = lastShotChangeInBetween.Value + Configuration.Settings.BeautifyTimeCodes.Profile.OutCuesGap; + // An IN cue takes InCuesGap - every other in-cue placement + // uses it, and IsCueOnShotChange tests in cues against it. With + // the Netflix profile (in 0, out 2) this started the next + // subtitle two frames late, failing that profile's own check. + newRightInCueFrame = lastShotChangeInBetween.Value + Configuration.Settings.BeautifyTimeCodes.Profile.InCuesGap; break; } } diff --git a/src/libse/Interfaces/IFixCommonError.cs b/src/libse/Interfaces/IFixCommonError.cs index 582e2df18b3..40d74fa51db 100644 --- a/src/libse/Interfaces/IFixCommonError.cs +++ b/src/libse/Interfaces/IFixCommonError.cs @@ -1,9 +1,12 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; namespace Nikse.SubtitleEdit.Core.Interfaces { public interface IFixCommonError { + FixType FixType { get; } + void Fix(Subtitle subtitle, IFixCallbacks callbacks); } } diff --git a/src/libse/Settings/SubtitleSettings.cs b/src/libse/Settings/SubtitleSettings.cs index 3f4ef5b4bb4..833ef99d792 100644 --- a/src/libse/Settings/SubtitleSettings.cs +++ b/src/libse/Settings/SubtitleSettings.cs @@ -1,4 +1,4 @@ -using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Common; using SkiaSharp; using System; @@ -45,6 +45,8 @@ public class SubtitleSettings public bool EbuStlTeletextUseBox { get; set; } public bool EbuStlTeletextUseDoubleHeight { get; set; } + /// 0=unchanged, 1=left, 2=centered, 3=right - the EBU justification code. + public int EbuStlJustificationCode { get; set; } public int EbuStlMarginTop { get; set; } public int EbuStlMarginBottom { get; set; } public int EbuStlNewLineRows { get; set; } @@ -122,6 +124,7 @@ public SubtitleSettings() EbuStlTeletextUseBox = true; EbuStlTeletextUseDoubleHeight = true; + EbuStlJustificationCode = 2; // centered is the broadcast default EbuStlMarginTop = 0; EbuStlMarginBottom = 2; EbuStlNewLineRows = 2; diff --git a/src/libse/SubtitleFormats/AQTitle.cs b/src/libse/SubtitleFormats/AQTitle.cs index 465cfa8c3d8..44cd7ff7b8f 100644 --- a/src/libse/SubtitleFormats/AQTitle.cs +++ b/src/libse/SubtitleFormats/AQTitle.cs @@ -127,7 +127,9 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string private static string EncodeTimeCode(TimeCode time) { - int frames = MillisecondsToFrames(time.TotalMilliseconds) + 1; + // No "+ 1" here: DecodeTimeCode does not subtract one again, so every save shifted + // the whole file one frame later - and the shift accumulated with each save. + int frames = MillisecondsToFrames(time.TotalMilliseconds); return frames.ToString(CultureInfo.InvariantCulture); } diff --git a/src/libse/SubtitleFormats/AbcIViewer.cs b/src/libse/SubtitleFormats/AbcIViewer.cs index 8404ef8a3cb..1f17bd7f337 100644 --- a/src/libse/SubtitleFormats/AbcIViewer.cs +++ b/src/libse/SubtitleFormats/AbcIViewer.cs @@ -55,10 +55,8 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string _errorCount = 0; bool allTwoCifferMs = true; - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - string xmlString = sb.ToString(); + string xmlString = JoinLines(lines); if (!xmlString.Contains(" lines, string { _errorCount = 0; - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - string allText = sb.ToString(); + string allText = JoinLines(lines); if (!allText.Contains("")) { return; diff --git a/src/libse/SubtitleFormats/AdobePremierePrProj.cs b/src/libse/SubtitleFormats/AdobePremierePrProj.cs index adeb91726b6..e1623fc88fe 100644 --- a/src/libse/SubtitleFormats/AdobePremierePrProj.cs +++ b/src/libse/SubtitleFormats/AdobePremierePrProj.cs @@ -71,10 +71,8 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { _errorCount = 0; - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - string xmlString = sb.ToString(); + string xmlString = JoinLines(lines); if (!xmlString.Contains(" lines, string fileName) { var subtitle = new Subtitle(); - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - var all = sb.ToString(); + var all = JoinLines(lines); if (!string.IsNullOrEmpty(fileName) && fileName.EndsWith(".ass", StringComparison.OrdinalIgnoreCase) && !all.Contains("[V4 Styles]")) { } @@ -288,7 +286,11 @@ string HeaderTemplate() => headerTemplate ??= $@"[Script Info] // Appending directly skips AppendFormat's per-call format parsing, the object[10] // and the boxed layer, plus the two intermediate time-code strings. sb.Append(p.IsComment ? "Comment: " : "Dialogue: "); - sb.Append(p.Layer).Append(','); + // InvariantCulture: StringBuilder.Append(int) uses the current culture, and + // sv-SE/nb-NO/fi-FI/lt-LT render the negative sign as U+2212, which libass and + // VSFilter parse as layer 0 - breaking SE's own Layer = -1000 background boxes + // and Layer = -1 progress bars. Every other number on the line is already invariant. + sb.Append(p.Layer.ToString(CultureInfo.InvariantCulture)).Append(','); AppendTimeCode(sb, p.StartTime).Append(','); AppendTimeCode(sb, p.EndTime).Append(','); sb.Append(style).Append(','); @@ -1620,6 +1622,13 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string var indexMarginV = 7; var indexEffect = 8; var indexText = 9; + + // True when the text field is the last field in the "Format:" line, i.e. every + // field after it is appended to the text verbatim. That is the case for every real + // ASSA/SSA file, and it lets an event line be parsed by walking commas over the + // line itself instead of allocating a string[] plus one string per field (and a + // second one per Trim) and then re-joining the text field's own commas. + var textIsTrailing = true; var errors = new StringBuilder(); var lineNumber = 0; @@ -1764,6 +1773,12 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string break; } } + + textIsTrailing = indexText >= 0 && + indexText > indexLayer && indexText > indexStart && indexText > indexEnd && + indexText > indexStyle && indexText > indexActor && indexText > indexName && + indexText > indexMarginL && indexText > indexMarginR && indexText > indexMarginV && + indexText > indexEffect; } else if (trimmedLine.Length > 0) { @@ -1778,75 +1793,107 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string var effect = string.Empty; var layer = 0; - string[] splitLine; + // Walk the fields over the line itself. Split(',') allocated a string[] + // plus one string per field on every event line, Trim() allocated a + // second one for each field that was kept, and the text field - by far + // the longest - was split apart and re-joined comma by comma. Only the + // fields this format actually uses are materialized now. + ReadOnlySpan fields; if (trimmedLine.StartsWith("dialog:", StringComparison.OrdinalIgnoreCase)) { - splitLine = line.Remove(0, 7).Split(','); + fields = line.AsSpan(7); } else if (trimmedLine.StartsWith("dialogue:", StringComparison.OrdinalIgnoreCase)) { - splitLine = line.Remove(0, 9).Split(','); + fields = line.AsSpan(9); } else { - splitLine = line.Split(','); + fields = line.AsSpan(); } - for (var i = 0; i < splitLine.Length; i++) + string text = null; + var fieldStart = 0; + for (var i = 0; ; i++) { + var comma = fields.Slice(fieldStart).IndexOf(','); + var fieldEnd = comma < 0 ? fields.Length : fieldStart + comma; + + if (textIsTrailing && i == indexText) + { + // Everything from here on is the text, commas included. + text = fields.Slice(fieldStart).ToString(); + break; + } + + var field = fields.Slice(fieldStart, fieldEnd - fieldStart); if (i == indexStart) { - start = splitLine[i].Trim(); + start = field.Trim().ToString(); } else if (i == indexEnd) { - end = splitLine[i].Trim(); + end = field.Trim().ToString(); } else if (i == indexStyle) { - style = splitLine[i].Trim(); + style = field.Trim().ToString(); } else if (i == indexActor && indexName == -1) { - actor = splitLine[i].Trim(); + actor = field.Trim().ToString(); } else if (i == indexName) { - actor = splitLine[i].Trim(); + actor = field.Trim().ToString(); } else if (i == indexMarginL) { - marginL = splitLine[i].Trim(); + marginL = field.Trim().ToString(); } else if (i == indexMarginR) { - marginR = splitLine[i].Trim(); + marginR = field.Trim().ToString(); } else if (i == indexMarginV) { - marginV = splitLine[i].Trim(); + marginV = field.Trim().ToString(); } else if (i == indexEffect) { - effect = splitLine[i].Trim(); + effect = field.Trim().ToString(); } else if (i == indexLayer) { - int.TryParse(splitLine[i].Replace("Comment:", string.Empty).Trim(), out layer); + if (field.IndexOf("Comment:".AsSpan(), StringComparison.Ordinal) >= 0) + { + int.TryParse(field.ToString().Replace("Comment:", string.Empty).Trim(), out layer); + } + else + { + int.TryParse(field.Trim(), NumberStyles.Integer, CultureInfo.CurrentCulture, out layer); + } } else if (i == indexText) { - textBuilder.Append(splitLine[i]); + textBuilder.Append(field); } else if (i > indexText) { // The text field may itself contain commas; rebuild via the // pooled builder instead of O(commas^2) string concatenation. - textBuilder.Append(',').Append(splitLine[i]); + textBuilder.Append(',').Append(field); + } + + if (comma < 0) + { + break; } + + fieldStart = fieldEnd + 1; } - var text = textBuilder.ToString(); + text ??= textBuilder.ToString(); try { @@ -2117,7 +2164,12 @@ public static string RemoveDrawingTag(string input) } } - var p0Index = s.IndexOf("{\\p0}", StringComparison.Ordinal); + // The drawing can be closed by "\p0" inside a multi-tag block ("{\p0\fs20}"), not + // just by the literal "{\p0}" - searching only for the literal left p0Index at -1 + // and the else branch below then truncated the whole rest of the line. + var p0Match = Regex.Match(s, @"\{[^}]*\\p0(?![0-9])[^}]*\}"); + var p0Index = p0Match.Success ? p0Match.Index : -1; + var p0Length = p0Match.Success ? p0Match.Length : 0; if (p1Index > 0 && (p0Index > p1Index || p0Index == -1)) { var startTagIndex = s.Substring(0, p1Index).LastIndexOf('{'); @@ -2125,7 +2177,7 @@ public static string RemoveDrawingTag(string input) { if (p0Index > p1Index) { - s = s.Remove(startTagIndex, p0Index - startTagIndex + "{\\p0}".Length); + s = s.Remove(startTagIndex, p0Index - startTagIndex + p0Length); } else { @@ -2142,12 +2194,20 @@ private static string RemoveTag(string s, string tag) int indexOfTag = s.IndexOf(@"\" + tag, StringComparison.Ordinal); if (indexOfTag > 0) { - var endIndex1 = s.IndexOf('\\', indexOfTag + 1); - var endIndex2 = s.IndexOf('}', indexOfTag + 1); - endIndex1 = Math.Min(endIndex1, endIndex2); - if (endIndex1 > 0) + // The tag ends at whichever comes first: the next tag in the block or the + // closing brace. -1 is IndexOf's "not found" sentinel, not a position, so + // Math.Min would pick it and the tag was never removed - which is the common + // case of a tag that is the only/last one in its block ("{\pos(1,2)}"). + var nextTagIndex = s.IndexOf('\\', indexOfTag + 1); + var closingBraceIndex = s.IndexOf('}', indexOfTag + 1); + var endIndex = nextTagIndex < 0 + ? closingBraceIndex + : closingBraceIndex < 0 + ? nextTagIndex + : Math.Min(nextTagIndex, closingBraceIndex); + if (endIndex > 0) { - return s.Remove(indexOfTag, endIndex1 - indexOfTag); + return s.Remove(indexOfTag, endIndex - indexOfTag); } } return s; @@ -2300,9 +2360,10 @@ public static string CheckForErrors(string header) string s = line.Trim().ToLowerInvariant(); if (s.StartsWith("format:", StringComparison.Ordinal)) { - if (line.Length > 10) + if (s.Length > 10) { - var format = line.Substring(8).ToLowerInvariant().Split(','); + // cut the trimmed line - leading whitespace shifted every field name + var format = s.Substring(8).Split(','); styleCount = format.Length; for (int i = 0; i < format.Length; i++) { @@ -2403,7 +2464,7 @@ public static string CheckForErrors(string header) if (line.Length > 10) { string rawLine = line; - var format = line.Substring(6).Split(','); + var format = line.Trim().Substring(6).Split(','); if (format.Length != styleCount) { diff --git a/src/libse/SubtitleFormats/AvidStl.cs b/src/libse/SubtitleFormats/AvidStl.cs index 0ea05e20c69..72fe62df5da 100644 --- a/src/libse/SubtitleFormats/AvidStl.cs +++ b/src/libse/SubtitleFormats/AvidStl.cs @@ -54,7 +54,7 @@ public static void WriteSubtitleBlock(FileStream fs, Paragraph p, int number) fs.WriteByte(2); fs.WriteByte(0); var buffer = Encoding.GetEncoding(1252).GetBytes(p.Text.Replace(Environment.NewLine, "Š")); - if (buffer.Length <= 128) + if (buffer.Length <= TextLength) { fs.Write(buffer, 0, buffer.Length); for (int i = buffer.Length; i < TextLength; i++) diff --git a/src/libse/SubtitleFormats/BdnXml.cs b/src/libse/SubtitleFormats/BdnXml.cs index 51ae6d094d2..0f482c776c7 100644 --- a/src/libse/SubtitleFormats/BdnXml.cs +++ b/src/libse/SubtitleFormats/BdnXml.cs @@ -59,10 +59,8 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { _errorCount = 0; - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - var xmlString = sb.ToString(); + var xmlString = JoinLines(lines); if (!xmlString.Contains(" lines, string } else { - var pText = texts[0].Replace("\\n", Environment.NewLine); + // Full JSON decode (escaped quotes/backslashes too), not just the + // newline placeholder ToText writes. + var pText = Json.DecodeJsonText(texts[0]); var p = new Paragraph(pText, startTime, endTime); subtitle.Paragraphs.Add(p); } diff --git a/src/libse/SubtitleFormats/Cappella.cs b/src/libse/SubtitleFormats/Cappella.cs index 50d096d9177..0ada658958c 100644 --- a/src/libse/SubtitleFormats/Cappella.cs +++ b/src/libse/SubtitleFormats/Cappella.cs @@ -78,9 +78,7 @@ private static string ToTimeCode(TimeCode tc) public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - string xmlString = sb.ToString(); + string xmlString = JoinLines(lines); if (!xmlString.Contains(" lines, string { _errorCount = 0; - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - string allText = sb.ToString(); + string allText = JoinLines(lines); if (!allText.Contains("") || !allText.Contains("")) { return; diff --git a/src/libse/SubtitleFormats/Captionate.cs b/src/libse/SubtitleFormats/Captionate.cs index 120d21c927f..ca5a6f200eb 100644 --- a/src/libse/SubtitleFormats/Captionate.cs +++ b/src/libse/SubtitleFormats/Captionate.cs @@ -89,9 +89,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string } else if (lines != null) { - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - xmlString = sb.ToString(); + xmlString = JoinLines(lines); } else { @@ -123,21 +121,62 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { string start = node.Attributes["time"].InnerText; double startMilliseconds = DecodeTimeToMilliseconds(start); + // An end marker (a caption with no ) carries this cue's exact + // end time; only a real following caption means "ends just before it". + // Always subtracting 1 ms moved the end one millisecond earlier on + // every save, and the shift accumulated. + var hasTextNode = node.SelectSingleNode("tracks/track0") != null; if (p != null) { - p.EndTime.TotalMilliseconds = startMilliseconds - 1; + p.EndTime.TotalMilliseconds = hasTextNode ? startMilliseconds - 1 : startMilliseconds; } if (node.SelectSingleNode("tracks/track0") != null) { - string text = node.SelectSingleNode("tracks/track0").InnerText; - text = HtmlUtil.RemoveHtmlTags(text); + // Walk the children:
is an ELEMENT, and InnerText drops it + // silently - so every line break was lost on read. + var trackNode = node.SelectSingleNode("tracks/track0"); + var textBuilder = new StringBuilder(); + foreach (XmlNode textChild in trackNode.ChildNodes) + { + if (textChild is XmlText || textChild is XmlCDataSection || textChild is XmlWhitespace || textChild is XmlSignificantWhitespace) + { + textBuilder.Append(textChild.Value); + } + else if (textChild.Name.Equals("br", StringComparison.OrdinalIgnoreCase)) + { + textBuilder.Append(Environment.NewLine); + } + else + { + textBuilder.Append(textChild.InnerText); + } + } + + string text = textBuilder.ToString(); text = text.Replace("
", Environment.NewLine).Replace("
", Environment.NewLine).Replace("
", Environment.NewLine); - p = new Paragraph(text, startMilliseconds, startMilliseconds + 3000); + text = HtmlUtil.RemoveHtmlTags(text); if (!string.IsNullOrWhiteSpace(text)) { + p = new Paragraph(text, startMilliseconds, startMilliseconds + 3000); subtitle.Paragraphs.Add(p); } + else + { + // A blank caption is the end marker for the previous one (the + // writer emits one after every gap). Keeping it as "p" made the + // NEXT caption set the end time on this phantom paragraph + // instead, so the real cue kept its 3 second placeholder. + p = null; + } + } + else + { + // A caption with no is the end marker ToText emits after a + // gap; it already set the previous cue's end above. Not clearing "p" + // here let the NEXT caption overwrite that end time, so every cue ran + // on until the following one started. + p = null; } } } @@ -158,7 +197,10 @@ private static double DecodeTimeToMilliseconds(string time) private static string EncodeTime(TimeCode time) { - return $"{time.Hours:00}:{time.Minutes:00}:{time.Seconds:00}:{time.Milliseconds / 10.0:00}"; + // Integer division: 999 ms through "/ 10.0" formatted as "100", a three digit + // field that no reader can parse back (and DecodeTimeToMilliseconds multiplies + // this field by 10, so it is hundredths, capped at 99). + return $"{time.Hours:00}:{time.Minutes:00}:{time.Seconds:00}:{time.Milliseconds / 10:00}"; } } diff --git a/src/libse/SubtitleFormats/CaptionateMs.cs b/src/libse/SubtitleFormats/CaptionateMs.cs index da87179968d..31a6ad5f1de 100644 --- a/src/libse/SubtitleFormats/CaptionateMs.cs +++ b/src/libse/SubtitleFormats/CaptionateMs.cs @@ -80,10 +80,8 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { _errorCount = 0; - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - string xmlString = sb.ToString(); + string xmlString = JoinLines(lines); if (!xmlString.Contains("") || !xmlString.Contains("")) { return; @@ -109,21 +107,58 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { string start = node.Attributes["time"].InnerText; double startMilliseconds = double.Parse(start, CultureInfo.InvariantCulture); + // An end marker (a caption with no ) carries this cue's exact + // end time; only a real following caption means "ends just before it". + // Always subtracting 1 ms moved the end one millisecond earlier on + // every save, and the shift accumulated. + var hasTextNode = node.SelectSingleNode("tracks/track0") != null; if (p != null) { - p.EndTime.TotalMilliseconds = startMilliseconds - 1; + p.EndTime.TotalMilliseconds = hasTextNode ? startMilliseconds - 1 : startMilliseconds; } if (node.SelectSingleNode("tracks/track0") != null) { - string text = node.SelectSingleNode("tracks/track0").InnerText; - text = HtmlUtil.RemoveHtmlTags(text); + // Walk the children:
is an ELEMENT, and InnerText drops it + // silently - so every line break was lost on read. + var trackNode = node.SelectSingleNode("tracks/track0"); + var textBuilder = new StringBuilder(); + foreach (XmlNode textChild in trackNode.ChildNodes) + { + if (textChild is XmlText || textChild is XmlCDataSection || textChild is XmlWhitespace || textChild is XmlSignificantWhitespace) + { + textBuilder.Append(textChild.Value); + } + else if (textChild.Name.Equals("br", StringComparison.OrdinalIgnoreCase)) + { + textBuilder.Append(Environment.NewLine); + } + else + { + textBuilder.Append(textChild.InnerText); + } + } + + string text = textBuilder.ToString(); text = text.Replace("
", Environment.NewLine).Replace("
", Environment.NewLine).Replace("
", Environment.NewLine); - p = new Paragraph(text, startMilliseconds, startMilliseconds + 3000); + text = HtmlUtil.RemoveHtmlTags(text); if (!string.IsNullOrWhiteSpace(text)) { + p = new Paragraph(text, startMilliseconds, startMilliseconds + 3000); subtitle.Paragraphs.Add(p); } + else + { + p = null; + } + } + else + { + // A caption with no is the end marker ToText emits after a + // gap; it already set the previous cue's end above. Not clearing "p" + // here let the NEXT caption overwrite that end time, so every cue ran + // on until the following one started. + p = null; } } } diff --git a/src/libse/SubtitleFormats/CaptionsInc.cs b/src/libse/SubtitleFormats/CaptionsInc.cs index 9946a467aac..b27f12cb277 100644 --- a/src/libse/SubtitleFormats/CaptionsInc.cs +++ b/src/libse/SubtitleFormats/CaptionsInc.cs @@ -243,7 +243,9 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string i++; - if (buffer[i] == 0xfe) + // The scan above stops at buffer.Length when the file has no trailing 0x0a, so + // both the marker read and the eight byte time code can run past the end. + if (i + 12 <= buffer.Length && buffer[i] == 0xfe) { string endTime = Encoding.ASCII.GetString(buffer, i + 4, 8); if (Utilities.IsInteger(endTime)) diff --git a/src/libse/SubtitleFormats/CaraokeXml.cs b/src/libse/SubtitleFormats/CaraokeXml.cs index a4b28fd28e2..07df4ac4f46 100644 --- a/src/libse/SubtitleFormats/CaraokeXml.cs +++ b/src/libse/SubtitleFormats/CaraokeXml.cs @@ -49,10 +49,8 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { _errorCount = 0; - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - string xmlAsText = sb.ToString(); + string xmlAsText = JoinLines(lines); if (!xmlAsText.Contains("(0xD9, "δι") }; + // GreekLookup is a byte-indexed reverse map for Greek: the Greek text decoder in + // ReadParagraph ran a LINQ FirstOrDefault scan of all 145 entries for every byte of + // every Greek subtitle line. Keys all fit in a byte, so a 256-entry array answers it + // with one array load instead of a linear scan. Bytes with no Greek entry stay null, + // which is how callers tell "not a Greek byte" from a mapping. (The element type is + // written without a nullable annotation because libse builds with nullable contexts + // off, where the annotation is inert and only warns - see CS8632.) + private static readonly string[] GreekLookup = BuildGreekLookup(); + + private static string[] BuildGreekLookup() + { + var table = new string[256]; + foreach (var entry in Greek) + { + table[entry.Item1] = entry.Item2; + } + + return table; + } + public override string Extension => ".890"; public const string NameOfFormat = "Cavena 890"; @@ -1137,7 +1163,8 @@ private static byte[] GetTextAsBytes(string text, int languageId) } else if (current == 'ẁ') { - AddTwo(buffer, ref index, 0x81, 0x75); + // 0x77 = 'w'; 0x75 is 'u', which made this round-trip as "ù" + AddTwo(buffer, ref index, 0x81, 0x77); } // capitals with accent aigu @@ -1897,8 +1924,8 @@ private static string FixText(byte[] buffer, int start, int textLength, int lang text = sb.ToString(); - text = text.Replace(encoding.GetString(new byte[] { 0x7F }), string.Empty); // Used to fill empty space upto 51 bytes - text = text.Replace(encoding.GetString(new byte[] { 0xBE }), "-"); + text = text.Replace(Cp1252FillerChar, string.Empty); // Used to fill empty space upto 51 bytes + text = text.Replace(Cp1252DashChar, "-"); text = FixColors(text); if (text.Contains("")) @@ -1918,10 +1945,10 @@ private static string FixText(byte[] buffer, int start, int textLength, int lang for (var i = 0; i < textLength; i++) { var b = buffer[start + i]; - var entry = Greek.FirstOrDefault(e => e.Item1 == b); + var entry = GreekLookup[b]; if (entry != null) { - sb.Append(entry.Item2); + sb.Append(entry); } else if (b != 0x7F) { @@ -1932,8 +1959,8 @@ private static string FixText(byte[] buffer, int start, int textLength, int lang text = sb.ToString(); - text = text.Replace(encoding.GetString(new byte[] { 0x7F }), string.Empty); // Used to fill empty space upto 51 bytes - text = text.Replace(encoding.GetString(new byte[] { 0xBE }), "-"); + text = text.Replace(Cp1252FillerChar, string.Empty); // Used to fill empty space upto 51 bytes + text = text.Replace(Cp1252DashChar, "-"); text = FixColors(text); if (text.Contains("")) @@ -1971,8 +1998,8 @@ private static string FixText(byte[] buffer, int start, int textLength, int lang text = sb.ToString(); - text = text.Replace(encoding.GetString(new byte[] { 0x7F }), string.Empty); // Used to fill empty space upto 51 bytes - text = text.Replace(encoding.GetString(new byte[] { 0xBE }), "-"); + text = text.Replace(Cp1252FillerChar, string.Empty); // Used to fill empty space upto 51 bytes + text = text.Replace(Cp1252DashChar, "-"); text = FixColors(text); text = ReverseAnsi(text); @@ -1996,7 +2023,7 @@ private static string FixText(byte[] buffer, int start, int textLength, int lang } text = sb.ToString(); - text = text.Replace(encoding.GetString(new byte[] { 0xBE }), "-"); + text = text.Replace(Cp1252DashChar, "-"); text = FixColors(text).Trim(); } else if (languageId == LanguageIdChineseTraditional || languageId == LanguageIdChineseSimplified) // (_language == "CCKM44" || _language == "TVB000") @@ -2048,8 +2075,8 @@ private static string FixText(byte[] buffer, int start, int textLength, int lang var encoding = Encoding.GetEncoding(1252); text = encoding.GetString(buffer, start, textLength).Replace("\0", string.Empty); - text = text.Replace(encoding.GetString(new byte[] { 0x7F }), string.Empty); // Used to fill empty space upto 51 bytes - text = text.Replace(encoding.GetString(new byte[] { 0xBE }), "-"); + text = text.Replace(Cp1252FillerChar, string.Empty); // Used to fill empty space upto 51 bytes + text = text.Replace(Cp1252DashChar, "-"); text = FixColors(text); // Raw-byte remappings must run before the control-code mappings below - those diff --git a/src/libse/SubtitleFormats/CheetahCaption.cs b/src/libse/SubtitleFormats/CheetahCaption.cs index b7a7e0cdbaf..1a8a756db7c 100644 --- a/src/libse/SubtitleFormats/CheetahCaption.cs +++ b/src/libse/SubtitleFormats/CheetahCaption.cs @@ -10,6 +10,10 @@ namespace Nikse.SubtitleEdit.Core.SubtitleFormats { public class CheetahCaption : SubtitleFormat, IBinaryPersistableSubtitle { + // Resolved once: GetEncoding(1252) was called per paragraph on write and per paragraph + // on read, and it is not free - it walks the provider chain on every call. + private static readonly Encoding Cp1252 = Encoding.GetEncoding(1252); + private static readonly Dictionary DicCodeLatin = new Dictionary { [0x81] = '♪', @@ -217,7 +221,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string sb.Clear(); var j = 0; var italics = false; - var encoding = Encoding.GetEncoding(1252); + var encoding = Cp1252; while (j < textLength) { var index = i + start + j; @@ -383,10 +387,12 @@ public bool Save(string fileName, Stream stream, Subtitle subtitle, bool batchMo textBytes.Add(0xd0); } - var encoding = Encoding.GetEncoding(1252); + var encoding = Cp1252; while (j < text.Length) { - if (text.Substring(j).StartsWith(Environment.NewLine, StringComparison.Ordinal)) + // Substring(j) copied the rest of the line for every character just to test + // its first one or two characters. + if (text.AsSpan(j).StartsWith(Environment.NewLine.AsSpan(), StringComparison.Ordinal)) { j += Environment.NewLine.Length; textBytes.Add(0); diff --git a/src/libse/SubtitleFormats/Chk.cs b/src/libse/SubtitleFormats/Chk.cs index 3c8796c9d53..2360b5d2894 100644 --- a/src/libse/SubtitleFormats/Chk.cs +++ b/src/libse/SubtitleFormats/Chk.cs @@ -189,7 +189,7 @@ private string GetText(byte[] buffer, int start, int end) text = _codePage.GetString(buffer, start, end - start); } } - if (text.Length > 4 && text[0] == 0x1f && text[1] == 'R' && text[4] == '.' && CharUtils.IsDigit(text[2]) && CharUtils.IsDigit(text[3])) + if (text.Length > 4 && text[0] == 0x1f && text[1] == 'R' && text[4] == '.' && CharUtils.IsAsciiDigit(text[2]) && CharUtils.IsAsciiDigit(text[3])) { text = text.Remove(0, 5); } @@ -249,7 +249,7 @@ private static string ApplyFont(string text) } sb.Append(""); - post = ""; + post = ""; i += 2; } else if (text[i] == 01 && i < text.Length - 4 && text[i + 1] == 06) @@ -260,7 +260,7 @@ private static string ApplyFont(string text) } sb.Append(""); - post = ""; + post = ""; i++; } else if (text[i] == 2) @@ -271,7 +271,7 @@ private static string ApplyFont(string text) } sb.Append(""); - post = ""; + post = ""; } else if (text[i] == 3) { @@ -281,7 +281,7 @@ private static string ApplyFont(string text) } sb.Append(""); - post = ""; + post = ""; } else if (text[i] == 6) { @@ -291,7 +291,7 @@ private static string ApplyFont(string text) } sb.Append(""); - post = ""; + post = ""; } else if (text[i] == 7) { @@ -301,7 +301,7 @@ private static string ApplyFont(string text) } sb.Append(""); - post = ""; + post = ""; } else { diff --git a/src/libse/SubtitleFormats/ClqttJson.cs b/src/libse/SubtitleFormats/ClqttJson.cs index 1006a931aba..47b77d65e38 100644 --- a/src/libse/SubtitleFormats/ClqttJson.cs +++ b/src/libse/SubtitleFormats/ClqttJson.cs @@ -18,6 +18,9 @@ public class ClqttJson : SubtitleFormat public override string Name => "CLQTT JSON"; + // Carries the region of every paragraph. + public override bool HasPositionSupport => true; + public override string ToText(Subtitle subtitle, string title) { return "Not implemented"; diff --git a/src/libse/SubtitleFormats/CombinedXml.cs b/src/libse/SubtitleFormats/CombinedXml.cs index f995468e7d8..a93b1eb304c 100644 --- a/src/libse/SubtitleFormats/CombinedXml.cs +++ b/src/libse/SubtitleFormats/CombinedXml.cs @@ -20,9 +20,7 @@ public class CombinedXml : SubtitleFormat public override bool IsMine(List lines, string fileName) { - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - var searchText = sb.ToString(); + var searchText = JoinLines(lines); var idx = searchText.IndexOf(findString, StringComparison.Ordinal); if (idx < 0) { @@ -48,9 +46,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { _errorCount = 0; subtitle.Paragraphs.Clear(); - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - var searchText = sb.ToString(); + var searchText = JoinLines(lines); var idx = searchText.IndexOf(findString, StringComparison.Ordinal); var start = idx; var parts = new List(); diff --git a/src/libse/SubtitleFormats/Csv.cs b/src/libse/SubtitleFormats/Csv.cs index 6504258ef00..83202d3530b 100644 --- a/src/libse/SubtitleFormats/Csv.cs +++ b/src/libse/SubtitleFormats/Csv.cs @@ -54,7 +54,9 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { if (CsvLine.IsMatch(line)) { - var parts = line.Split(Separator.ToCharArray(), StringSplitOptions.RemoveEmptyEntries); + // Only split off the three leading numeric fields - the text is the rest of the + // line and may contain the separator itself (and may be empty). + var parts = line.Split(Separator.ToCharArray(), 4, StringSplitOptions.None); if (parts.Length == 4) { try diff --git a/src/libse/SubtitleFormats/Csv3.cs b/src/libse/SubtitleFormats/Csv3.cs index 18cbf810715..c8f6cd93035 100644 --- a/src/libse/SubtitleFormats/Csv3.cs +++ b/src/libse/SubtitleFormats/Csv3.cs @@ -1,6 +1,7 @@ using Nikse.SubtitleEdit.Core.Common; using System; using System.Collections.Generic; +using System.Linq; using System.Text; using System.Text.RegularExpressions; @@ -75,18 +76,23 @@ public override string ToText(Subtitle subtitle, string title) sb.AppendLine(string.Format(format, Separator, "Start time (hh:mm:ss:ff)", "End time (hh:mm:ss:ff)", "Line 1", "Line 2")); foreach (Paragraph p in subtitle.Paragraphs) { + // The format has two text columns, so rebalance at THREE lines - the old + // ">3" guard let a three-line subtitle through and then wrote only the first + // two lines, silently dropping the third. var arr = p.Text.Trim().SplitToLines(); - if (arr.Count > 3) + if (arr.Count > 2) { string s = Utilities.AutoBreakLine(p.Text); arr = s.Trim().SplitToLines(); } - string line1 = string.Empty; + + string line1 = arr[0]; string line2 = string.Empty; - line1 = arr[0]; if (arr.Count > 1) { - line2 = arr[1]; + // Anything that still does not fit in two lines is appended to the second + // column rather than thrown away. + line2 = string.Join(" ", arr.Skip(1)); } line1 = line1.Replace("\"", "\"\""); @@ -142,8 +148,9 @@ private static string ReadText(string csv) return string.Empty; } - csv = csv.Replace("\"\"", "\""); - + // No pre-collapse of "" here: ToText escapes an embedded quote by doubling it, and + // collapsing before the walk below left the loop unable to tell an escaped quote + // from a field delimiter - so quoted words came back with their quotes eaten. var sb = new StringBuilder(); csv = csv.Trim(); if (csv.StartsWith('"')) @@ -160,9 +167,11 @@ private static string ReadText(string csv) for (int i = 0; i < csv.Length; i++) { var s = csv[i]; - if (s == '"' && csv.Substring(i).StartsWith("\"\"", StringComparison.Ordinal)) + if (s == '"' && i + 1 < csv.Length && csv[i + 1] == '"') { + // An escaped quote - emit one and consume BOTH characters. sb.Append('"'); + i++; } else if (s == '"') { diff --git a/src/libse/SubtitleFormats/CsvDaVinci.cs b/src/libse/SubtitleFormats/CsvDaVinci.cs index 5613e6f9d79..c76a4b2c59f 100644 --- a/src/libse/SubtitleFormats/CsvDaVinci.cs +++ b/src/libse/SubtitleFormats/CsvDaVinci.cs @@ -43,10 +43,27 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string var csvLines = ReadCsvLines(lines, ','); foreach (var line in csvLines) { + if (line.Text == null) + { + // No text column at this index: the file is some other csv dialect. Count it + // as an error so IsMine (paragraphs > errors) stops claiming those files and + // importing every cue with blank text. + _errorCount++; + if (_errorCount > 10) + { + return; + } + + continue; + } + if (ParseTimeCode(line.Start, out var start) && ParseTimeCode(line.End, out var end)) { - var p = new Paragraph(start, end, line.Text) + // A row with fewer columns than this layout expects (any other csv + // dialect) never assigns Text, and a Paragraph with a null Text makes + // everything downstream - the grid, saving, RemoveEmptyLines - throw. + var p = new Paragraph(start, end, line.Text ?? string.Empty) { Actor = line.Character, Effect = line.Play ? "True" : "False" diff --git a/src/libse/SubtitleFormats/CsvExcel.cs b/src/libse/SubtitleFormats/CsvExcel.cs new file mode 100644 index 00000000000..9d945c921cf --- /dev/null +++ b/src/libse/SubtitleFormats/CsvExcel.cs @@ -0,0 +1,195 @@ +using Nikse.SubtitleEdit.Core.Common; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; + +namespace Nikse.SubtitleEdit.Core.SubtitleFormats +{ + /// + /// A spreadsheet friendly csv: every field is quoted, the first row names the columns, and the + /// time codes carry milliseconds so nothing is lost when the file is edited in Excel/Sheets and + /// read back (issue #14321). + /// + public class CsvExcel : SubtitleFormat + { + private const char Separator = ','; + private const int NumberIndex = 0; + private const int StartIndex = 1; + private const int EndIndex = 2; + private const int TextIndex = 3; + private const int ActorIndex = 4; + private const int ForcedIndex = 5; + + private static readonly string[] HeaderFields = { "Number", "Start time", "End time", "Text", "Actor", "Forced" }; + + public override string Extension => ".csv"; + + public override string Name => "Csv Excel"; + + public override bool IsMine(List lines, string fileName) + { + if (!string.IsNullOrEmpty(fileName) && !fileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var first = lines.FirstOrDefault(p => !string.IsNullOrWhiteSpace(p)); + if (first == null || !IsHeaderLine(first)) + { + return false; + } + + return base.IsMine(lines, fileName); + } + + public override string ToText(Subtitle subtitle, string title) + { + var sb = new StringBuilder(); + sb.AppendLine(string.Join(Separator.ToString(), HeaderFields.Select(Quote))); + foreach (var p in subtitle.Paragraphs) + { + sb.AppendLine(string.Join(Separator.ToString(), + Quote(p.Number.ToString(CultureInfo.InvariantCulture)), + Quote(EncodeTimeCode(p.StartTime)), + Quote(EncodeTimeCode(p.EndTime)), + Quote(p.Text ?? string.Empty), + Quote(p.Actor ?? string.Empty), + Quote(p.Forced ? "True" : "False"))); + } + + return sb.ToString().Trim(); + } + + public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + _errorCount = 0; + var rows = CsvUtil.CsvSplitLines(lines, Separator); + foreach (var fields in rows) + { + if (fields.Count == 0) + { + continue; + } + + if (IsHeaderRow(fields)) + { + continue; + } + + if (fields.Count <= EndIndex || + !ParseTimeCode(fields[StartIndex], out var start) || + !ParseTimeCode(fields[EndIndex], out var end)) + { + _errorCount++; + if (_errorCount > 20) + { + return; + } + + continue; + } + + var p = new Paragraph(start, end, fields.Count > TextIndex ? fields[TextIndex] : string.Empty); + if (fields.Count > ActorIndex && !string.IsNullOrWhiteSpace(fields[ActorIndex])) + { + p.Actor = fields[ActorIndex]; + } + + if (fields.Count > ForcedIndex) + { + p.Forced = IsTrue(fields[ForcedIndex]); + } + + subtitle.Paragraphs.Add(p); + } + + subtitle.Renumber(); + } + + private static string Quote(string value) + { + return "\"" + (value ?? string.Empty).Replace("\"", "\"\"") + "\""; + } + + private static string EncodeTimeCode(TimeCode time) + { + return string.Format(CultureInfo.InvariantCulture, "{0:00}:{1:00}:{2:00}.{3:000}", time.Hours, time.Minutes, time.Seconds, time.Milliseconds); + } + + private static bool IsHeaderLine(string line) + { + var fields = CsvUtil.CsvSplit(line, false, out _, Separator); + return IsHeaderRow(fields.ToList()); + } + + private static bool IsHeaderRow(IReadOnlyList fields) + { + if (fields.Count < HeaderFields.Length) + { + return false; + } + + for (var i = 0; i < HeaderFields.Length; i++) + { + if (!string.Equals(fields[i].Trim(), HeaderFields[i], StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + + private static bool IsTrue(string value) + { + var s = value.Trim(); + return s.Equals("True", StringComparison.OrdinalIgnoreCase) || + s.Equals("Yes", StringComparison.OrdinalIgnoreCase) || + s.Equals("Forced", StringComparison.OrdinalIgnoreCase) || + s == "1"; + } + + /// + /// Reads "hh:mm:ss.mmm" (also with a comma as decimal separator) - and "hh:mm:ss:ff", so a + /// file where the milliseconds were replaced by frames still loads. + /// + private static bool ParseTimeCode(string s, out TimeCode tc) + { + tc = new TimeCode(); + if (string.IsNullOrWhiteSpace(s)) + { + return false; + } + + var arr = s.Trim().Split(new[] { ':', ';', '.', ',' }, StringSplitOptions.None); + if (arr.Length != 4 || + !int.TryParse(arr[0], NumberStyles.None, CultureInfo.InvariantCulture, out var hours) || + !int.TryParse(arr[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minutes) || + !int.TryParse(arr[2], NumberStyles.None, CultureInfo.InvariantCulture, out var seconds) || + !int.TryParse(arr[3], NumberStyles.None, CultureInfo.InvariantCulture, out var last)) + { + return false; + } + + var trimmed = s.Trim(); + var separatorBeforeLast = trimmed[trimmed.Length - arr[3].Length - 1]; + int milliseconds; + if (separatorBeforeLast == ':' || separatorBeforeLast == ';') + { + milliseconds = FramesToMillisecondsMax999(last); + } + else + { + // A decimal fraction, so ".05" is 50 ms and not 5 ms - the writer always emits + // three digits, but a hand-edited (or spreadsheet-reformatted) file may not. + var fraction = arr[3].Length > 3 ? arr[3].Substring(0, 3) : arr[3].PadRight(3, '0'); + milliseconds = int.Parse(fraction, CultureInfo.InvariantCulture); + } + + tc = new TimeCode(hours, minutes, seconds, milliseconds); + return true; + } + } +} diff --git a/src/libse/SubtitleFormats/DCinemaInterop.cs b/src/libse/SubtitleFormats/DCinemaInterop.cs index b4c5d2d6a61..eb65b5821a6 100644 --- a/src/libse/SubtitleFormats/DCinemaInterop.cs +++ b/src/libse/SubtitleFormats/DCinemaInterop.cs @@ -52,9 +52,7 @@ internal double GetVerticalPositionAsNumber() public override bool IsMine(List lines, string fileName) { - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - var xmlAsString = sb.ToString().Trim(); + var xmlAsString = JoinLinesTrimmed(lines); if (!xmlAsString.Contains("", StringComparison.Ordinal)) + if (!isItalic && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.Ordinal)) { if (txt.Length > 0) { @@ -279,7 +277,7 @@ public override string ToText(Subtitle subtitle, string title) isItalic = true; i += 2; } - else if (!isBold && line.Substring(i).StartsWith("", StringComparison.Ordinal)) + else if (!isBold && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.Ordinal)) { if (txt.Length > 0) { @@ -290,7 +288,7 @@ public override string ToText(Subtitle subtitle, string title) isBold = true; i += 2; } - else if (isItalic && line.Substring(i).StartsWith("", StringComparison.Ordinal)) + else if (isItalic && line.AsSpan(i).StartsWith("
".AsSpan(), StringComparison.Ordinal)) { if (txt.Length > 0) { @@ -323,7 +321,7 @@ public override string ToText(Subtitle subtitle, string title) isItalic = false; i += 3; } - else if (isBold && line.Substring(i).StartsWith("", StringComparison.Ordinal)) + else if (isBold && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.Ordinal)) { if (txt.Length > 0) { @@ -356,7 +354,7 @@ public override string ToText(Subtitle subtitle, string title) isBold = false; i += 3; } - else if (line.Substring(i).StartsWith("", StringComparison.Ordinal)) + else if (fontNo > 0 && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.Ordinal)) { if (txt.Length > 0) { @@ -643,10 +641,8 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { _errorCount = 0; - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); var xml = new XmlDocument { XmlResolver = null }; - xml.LoadXml(sb.ToString().Trim()); + xml.LoadXml(JoinLinesTrimmed(lines)); if (xml.DocumentElement == null) { return; diff --git a/src/libse/SubtitleFormats/DCinemaSmpte2007.cs b/src/libse/SubtitleFormats/DCinemaSmpte2007.cs index feda4ecc436..19e811a0163 100644 --- a/src/libse/SubtitleFormats/DCinemaSmpte2007.cs +++ b/src/libse/SubtitleFormats/DCinemaSmpte2007.cs @@ -45,9 +45,7 @@ public class DCinemaSmpte2007 : SubtitleFormat public override bool IsMine(List lines, string fileName) { - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - string xmlAsString = sb.ToString().Trim(); + string xmlAsString = JoinLinesTrimmed(lines); if (xmlAsString.Contains("http://www.smpte-ra.org/schemas/428-7/2010/DCST") || xmlAsString.Contains("http://www.smpte-ra.org/schemas/428-7/2014/DCST")) { @@ -146,7 +144,10 @@ public override string ToText(Subtitle subtitle, string title) } xml.DocumentElement.SelectSingleNode("dcst:Language", nsmgr).InnerText = ss.CurrentDCinemaLanguage; - if (ss.CurrentDCinemaEditRate == null && ss.CurrentDCinemaTimeCodeRate == null) + // Empty, not just null: SE5 mirrors its settings onto this singleton and pushes + // string.Empty for a rate never set in File > Properties, so a "== null" guard could + // never fire there and the file got - invalid D-Cinema XML. + if (string.IsNullOrEmpty(ss.CurrentDCinemaEditRate) && string.IsNullOrEmpty(ss.CurrentDCinemaTimeCodeRate)) { if (Configuration.Settings.General.CurrentFrameRate == 24) { @@ -312,7 +313,7 @@ public override string ToText(Subtitle subtitle, string title) var nodeTemp = xml.CreateElement("temp"); while (i < line.Length) { - if (!isItalic && line.Substring(i).StartsWith("")) + if (!isItalic && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.CurrentCulture)) { if (txt.Length > 0) { @@ -323,7 +324,7 @@ public override string ToText(Subtitle subtitle, string title) isItalic = true; i += 2; } - else if (!isBold && line.Substring(i).StartsWith("")) + else if (!isBold && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.CurrentCulture)) { if (txt.Length > 0) { @@ -334,7 +335,7 @@ public override string ToText(Subtitle subtitle, string title) isBold = true; i += 2; } - else if (isItalic && line.Substring(i).StartsWith("")) + else if (isItalic && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.CurrentCulture)) { if (txt.Length > 0) { @@ -359,7 +360,7 @@ public override string ToText(Subtitle subtitle, string title) isItalic = false; i += 3; } - else if (isBold && line.Substring(i).StartsWith("")) + else if (isBold && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.CurrentCulture)) { if (txt.Length > 0) { @@ -384,7 +385,7 @@ public override string ToText(Subtitle subtitle, string title) isBold = false; i += 3; } - else if (line.Substring(i).StartsWith("")) + else if (fontNo > 0 && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.CurrentCulture)) { if (txt.Length > 0) { @@ -594,10 +595,8 @@ private void ValidationCallBack(object sender, ValidationEventArgs e) public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); var xml = new XmlDocument { XmlResolver = null }; - xml.LoadXml(sb.ToString().Replace(" lines, string fileName) { - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - string xmlAsString = sb.ToString().Trim(); + string xmlAsString = JoinLinesTrimmed(lines); if (xmlAsString.Contains("http://www.smpte-ra.org/schemas/428-7/2007/DCST") || xmlAsString.Contains("http://www.smpte-ra.org/schemas/428-7/2014/DCST")) @@ -146,7 +144,10 @@ public override string ToText(Subtitle subtitle, string title) } xml.DocumentElement.SelectSingleNode("dcst:Language", nsmgr).InnerText = ss.CurrentDCinemaLanguage; - if (ss.CurrentDCinemaEditRate == null && ss.CurrentDCinemaTimeCodeRate == null) + // Empty, not just null: SE5 mirrors its settings onto this singleton and pushes + // string.Empty for a rate never set in File > Properties, so a "== null" guard could + // never fire there and the file got - invalid D-Cinema XML. + if (string.IsNullOrEmpty(ss.CurrentDCinemaEditRate) && string.IsNullOrEmpty(ss.CurrentDCinemaTimeCodeRate)) { if (Math.Abs(Configuration.Settings.General.CurrentFrameRate - 24) < 0.01) { @@ -312,7 +313,7 @@ public override string ToText(Subtitle subtitle, string title) XmlNode nodeTemp = xml.CreateElement("temp"); while (i < line.Length) { - if (!isItalic && line.Substring(i).StartsWith("", StringComparison.Ordinal)) + if (!isItalic && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.Ordinal)) { if (txt.Length > 0) { @@ -323,7 +324,7 @@ public override string ToText(Subtitle subtitle, string title) isItalic = true; i += 2; } - else if (!isBold && line.Substring(i).StartsWith("", StringComparison.Ordinal)) + else if (!isBold && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.Ordinal)) { if (txt.Length > 0) { @@ -334,7 +335,7 @@ public override string ToText(Subtitle subtitle, string title) isBold = true; i += 2; } - else if (isItalic && line.Substring(i).StartsWith("", StringComparison.Ordinal)) + else if (isItalic && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.Ordinal)) { if (txt.Length > 0) { @@ -360,7 +361,7 @@ public override string ToText(Subtitle subtitle, string title) isItalic = false; i += 3; } - else if (isBold && line.Substring(i).StartsWith("", StringComparison.Ordinal)) + else if (isBold && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.Ordinal)) { if (txt.Length > 0) { @@ -386,7 +387,7 @@ public override string ToText(Subtitle subtitle, string title) isBold = false; i += 3; } - else if (line.Substring(i).StartsWith("", StringComparison.Ordinal)) + else if (fontNo > 0 && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.Ordinal)) { if (txt.Length > 0) { @@ -629,10 +630,8 @@ private void ValidationCallBack(object sender, ValidationEventArgs e) public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); var xml = new XmlDocument { XmlResolver = null }; - xml.LoadXml(sb.ToString().Replace(" lines, string fileName) { - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - var xmlAsString = sb.ToString().Trim(); + var xmlAsString = JoinLinesTrimmed(lines); if (xmlAsString.Contains("http://www.smpte-ra.org/schemas/428-7/2007/DCST") || xmlAsString.Contains("http://www.smpte-ra.org/schemas/428-7/2010/DCST")) @@ -150,7 +148,10 @@ public override string ToText(Subtitle subtitle, string title) } xml.DocumentElement.SelectSingleNode("dcst:Language", nsmgr).InnerText = ss.CurrentDCinemaLanguage; - if (ss.CurrentDCinemaEditRate == null && ss.CurrentDCinemaTimeCodeRate == null) + // Empty, not just null: SE5 mirrors its settings onto this singleton and pushes + // string.Empty for a rate never set in File > Properties, so a "== null" guard could + // never fire there and the file got - invalid D-Cinema XML. + if (string.IsNullOrEmpty(ss.CurrentDCinemaEditRate) && string.IsNullOrEmpty(ss.CurrentDCinemaTimeCodeRate)) { if (Math.Abs(Configuration.Settings.General.CurrentFrameRate - 24) < 0.01) { @@ -316,7 +317,7 @@ public override string ToText(Subtitle subtitle, string title) XmlNode nodeTemp = xml.CreateElement("temp"); while (i < line.Length) { - if (!isItalic && line.Substring(i).StartsWith("", StringComparison.Ordinal)) + if (!isItalic && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.Ordinal)) { if (txt.Length > 0) { @@ -327,7 +328,7 @@ public override string ToText(Subtitle subtitle, string title) isItalic = true; i += 2; } - else if (!isBold && line.Substring(i).StartsWith("", StringComparison.Ordinal)) + else if (!isBold && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.Ordinal)) { if (txt.Length > 0) { @@ -338,7 +339,7 @@ public override string ToText(Subtitle subtitle, string title) isBold = true; i += 2; } - else if (isItalic && line.Substring(i).StartsWith("", StringComparison.Ordinal)) + else if (isItalic && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.Ordinal)) { if (txt.Length > 0) { @@ -364,7 +365,7 @@ public override string ToText(Subtitle subtitle, string title) isItalic = false; i += 3; } - else if (isBold && line.Substring(i).StartsWith("", StringComparison.Ordinal)) + else if (isBold && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.Ordinal)) { if (txt.Length > 0) { @@ -390,7 +391,7 @@ public override string ToText(Subtitle subtitle, string title) isBold = false; i += 3; } - else if (line.Substring(i).StartsWith("", StringComparison.Ordinal)) + else if (fontNo > 0 && line.AsSpan(i).StartsWith("".AsSpan(), StringComparison.Ordinal)) { if (txt.Length > 0) { @@ -607,10 +608,8 @@ private void ValidationCallBack(object sender, ValidationEventArgs e) public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); var xml = new XmlDocument { XmlResolver = null }; - xml.LoadXml(sb.ToString().Replace(" lines, string fileName) return false; } - var sb = new StringBuilder(); - lines.ForEach(line => sb.AppendLine(line)); - var text = sb.ToString(); + var text = JoinLines(lines); if (!text.Contains(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine("
"); + + sb.AppendLine("
"); + sb.AppendLine("

" + Encode(l.Title) + "

"); + sb.AppendLine("

" + Encode(summary) + "

"); + sb.AppendLine("

"); + if (!string.IsNullOrEmpty(subtitleFileName)) + { + sb.AppendLine(" " + Encode(string.Format(l.ExportFileX, subtitleFileName)) + ""); + } + + sb.AppendLine(" " + Encode(string.Format(l.ExportGeneratedX, Now())) + ""); + sb.AppendLine("

"); + sb.AppendLine("
"); + + AppendChips(sb, list, l.All); + AppendTable(sb, list); + + sb.AppendLine("
" + Encode(l.ExportMadeWithSubtitleEdit) + "
"); + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine(""); + return sb.ToString(); + } + + /// + /// One radio per error class in front of the table: checking it hides the rows of the other + /// classes through the sibling selectors in . Classes with no rows are left out. + /// + private static void AppendChips(StringBuilder sb, IReadOnlyList items, string allLabel) + { + sb.AppendLine(""); + foreach (var type in Enum.GetValues()) + { + if (items.Any(p => p.Type == type)) + { + sb.AppendLine(""); + } + } + + sb.AppendLine(""); + } + + private static void AppendTable(StringBuilder sb, IReadOnlyList items) + { + var l = Se.Language.ErrorList; + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine(" " + + "" + + "" + + "" + + "" + + "" + + "" + + ""); + sb.AppendLine(" "); + foreach (var item in items) + { + sb.AppendLine(" " + + "" + + "" + + "" + + "" + + "" + + "" + + ""); + } + + sb.AppendLine(" "); + sb.AppendLine("
" + Encode(Se.Language.General.NumberSymbol) + "" + Encode(Se.Language.General.Error) + "" + Encode(Se.Language.General.Show) + "" + Encode(Se.Language.General.Hide) + "" + Encode(l.Detail) + "" + Encode(Se.Language.General.Text) + "
" + item.Number + "" + Encode(item.Category) + "" + Encode(item.Show) + "" + Encode(item.Hide) + "" + Encode(item.Detail) + "" + Encode(item.Text) + "
"); + sb.AppendLine("
"); + } + + private static IReadOnlyList Headers() + { + return new[] + { + Se.Language.General.NumberSymbol, + Se.Language.General.Error, + Se.Language.General.Show, + Se.Language.General.Hide, + Se.Language.ErrorList.Detail, + Se.Language.General.Text, + }; + } + + private static IEnumerable Cells(ErrorListItem item) + { + yield return item.Number.ToString(CultureInfo.CurrentCulture); + yield return item.Category; + yield return item.Show; + yield return item.Hide; + yield return item.Detail; + yield return item.Text; + } + + /// A tab or a newline inside a cell would start a new column/row where it is pasted. + private static string CleanForTabSeparated(string s) + { + return s.Replace('\t', ' ').Replace("\r\n", " ").Replace('\n', ' ').Replace('\r', ' '); + } + + private static string Now() + { + return DateTime.Now.ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture); + } + + private static string Encode(string? text) + { + if (string.IsNullOrEmpty(text)) + { + return string.Empty; + } + + return text + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """); + } + + /// + /// Light by default, dark through prefers-color-scheme: every colour is a token on + /// :root and only the tokens are redefined, so nothing can end up defined in the dark + /// block alone. + /// + private const string Css = @" +:root { + color-scheme: light dark; + --bg: #f6f7f9; + --panel: #ffffff; + --panel-2: #fbfbfc; + --ink: #16191d; + --ink-dim: #5b6470; + --line: #e3e6ea; + --line-soft: #eef0f3; + --accent: #5d8aa8; + --shadow: 0 1px 2px rgba(16, 24, 40, .06), 0 8px 24px rgba(16, 24, 40, .06); + --radius: 12px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #14171b; + --panel: #1b1f24; + --panel-2: #20252b; + --ink: #e8ebef; + --ink-dim: #9aa4b1; + --line: #2b3138; + --line-soft: #23282f; + --accent: #7fb0cd; + --shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px rgba(0, 0, 0, .35); + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 32px 20px 56px; + background: var(--bg); + color: var(--ink); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans', Ubuntu, Cantarell, sans-serif; + font-size: 14px; + line-height: 1.5; + -webkit-text-size-adjust: 100%; +} + +main { max-width: 1180px; margin: 0 auto; } + +h1 { margin: 0; font-size: 26px; font-weight: 650; letter-spacing: -.01em; } + +.summary { margin: 6px 0 0; font-size: 15px; color: var(--ink-dim); } + +.meta { margin: 10px 0 0; display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 12.5px; color: var(--ink-dim); } +.meta span { overflow-wrap: anywhere; } + +.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 22px 0 14px; } + +.chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel); + color: var(--ink); + font-size: 13px; + cursor: pointer; + user-select: none; + transition: border-color .12s ease, transform .12s ease; +} +.chip:hover { border-color: var(--accent); transform: translateY(-1px); } +.chip i { width: 9px; height: 9px; border-radius: 50%; flex: none; } +.chip b { font-weight: 600; color: var(--ink-dim); font-variant-numeric: tabular-nums; } + +.table-wrap { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: auto; + max-height: 74vh; +} + +table { border-collapse: collapse; width: 100%; font-size: 13.5px; } + +thead th { + position: sticky; + top: 0; + z-index: 1; + text-align: left; + font-weight: 600; + font-size: 12px; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--ink-dim); + background: var(--panel-2); + border-bottom: 1px solid var(--line); + padding: 11px 14px; + white-space: nowrap; +} + +tbody td { padding: 10px 14px; border-bottom: 1px solid var(--line-soft); vertical-align: top; } +tbody tr:last-child td { border-bottom: 0; } +tbody tr:hover td { background: var(--panel-2); } + +td.num { width: 1%; text-align: right; color: var(--ink-dim); font-variant-numeric: tabular-nums; white-space: nowrap; } +td.time, td.detail { white-space: nowrap; font-variant-numeric: tabular-nums; } +td.time { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; } +td.detail { color: var(--ink-dim); } +td.text { min-width: 22em; overflow-wrap: anywhere; } + +.pill { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 3px 10px 3px 8px; + border-radius: 999px; + border: 1px solid var(--line); + background: transparent; /* fallback where color-mix is unknown */ + background: color-mix(in srgb, var(--dot) 12%, transparent); + white-space: nowrap; +} +.pill i { width: 8px; height: 8px; border-radius: 50%; background: var(--dot); flex: none; } + +footer { margin-top: 18px; font-size: 12px; color: var(--ink-dim); text-align: right; } + +/* Chip filtering, without script: the checked radio decides which rows stay visible. */ +input[name='f'] { display: none; } + +#f-all:checked ~ .chips [for='f-all'], +#f-0:checked ~ .chips [for='f-0'], +#f-1:checked ~ .chips [for='f-1'], +#f-2:checked ~ .chips [for='f-2'], +#f-3:checked ~ .chips [for='f-3'], +#f-4:checked ~ .chips [for='f-4'], +#f-5:checked ~ .chips [for='f-5'], +#f-6:checked ~ .chips [for='f-6'], +#f-7:checked ~ .chips [for='f-7'] { + border-color: var(--accent); + background: var(--panel-2); + background: color-mix(in srgb, var(--accent) 16%, var(--panel)); + box-shadow: inset 0 0 0 1px var(--accent); +} + +#f-0:checked ~ .table-wrap tbody tr:not(.t0), +#f-1:checked ~ .table-wrap tbody tr:not(.t1), +#f-2:checked ~ .table-wrap tbody tr:not(.t2), +#f-3:checked ~ .table-wrap tbody tr:not(.t3), +#f-4:checked ~ .table-wrap tbody tr:not(.t4), +#f-5:checked ~ .table-wrap tbody tr:not(.t5), +#f-6:checked ~ .table-wrap tbody tr:not(.t6), +#f-7:checked ~ .table-wrap tbody tr:not(.t7) { display: none; } + +@media print { + body { background: #fff; padding: 0; } + .chips { display: none; } + .table-wrap { max-height: none; box-shadow: none; border: 0; } + thead th { position: static; } +} +"; +} diff --git a/src/ui/Features/Shared/ErrorList/ErrorListItem.cs b/src/ui/Features/Shared/ErrorList/ErrorListItem.cs index e7209d52252..585cecfa27c 100644 --- a/src/ui/Features/Shared/ErrorList/ErrorListItem.cs +++ b/src/ui/Features/Shared/ErrorList/ErrorListItem.cs @@ -1,19 +1,43 @@ -using Nikse.SubtitleEdit.Features.Main; +using Avalonia.Media; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic.ValueConverters; +using System.Collections.Generic; +using System.Linq; namespace Nikse.SubtitleEdit.Features.Shared.ErrorList; +/// One row in "List errors": one error on one line. A line with several errors has several rows. public class ErrorListItem { - public int Number { get; set; } - public string Text { get; set; } - public string Error { get; set; } - public SubtitleLineViewModel Subtitle { get; set; } + public int Number { get; } + public string Text { get; } + public string Show { get; } + public string Hide { get; } + public LineErrorType Type { get; } + public string Category { get; } + public string Detail { get; } + public IBrush Brush { get; } + public SubtitleLineViewModel Subtitle { get; } - public ErrorListItem(SubtitleLineViewModel subtitle, SubtitleLineViewModel? prev, SubtitleLineViewModel? next) + /// Old single-string form ("Reading speed: 27.3 > 25"), kept for the Error column binding. + public string Error => $"{Category}: {Detail}"; + + public ErrorListItem(SubtitleLineViewModel subtitle, LineError error) { Subtitle = subtitle; - Text = subtitle.Text; + Text = subtitle.Text.Replace("\r\n", " ").Replace('\n', ' ').Replace('\r', ' '); Number = subtitle.Number; - Error = subtitle.GetErrors(prev, next); + Show = (string)TimeSpanToDisplayShortConverter.Instance.Convert(subtitle.StartTime, typeof(string), null, System.Globalization.CultureInfo.CurrentCulture); + Hide = (string)TimeSpanToDisplayShortConverter.Instance.Convert(subtitle.EndTime, typeof(string), null, System.Globalization.CultureInfo.CurrentCulture); + Type = error.Type; + Category = error.Label; + Detail = error.Detail; + Brush = LineError.GetBrush(error.Type); + } + + /// One item per error on the line - the caller knows the real neighbours, which the gap/overlap wording needs. + public static IEnumerable Make(SubtitleLineViewModel subtitle, SubtitleLineViewModel? prev, SubtitleLineViewModel? next) + { + return subtitle.GetErrorList(prev, next).Select(e => new ErrorListItem(subtitle, e)); } } diff --git a/src/ui/Features/Shared/ErrorList/ErrorListViewModel.cs b/src/ui/Features/Shared/ErrorList/ErrorListViewModel.cs index c55b9d580d6..433611caac3 100644 --- a/src/ui/Features/Shared/ErrorList/ErrorListViewModel.cs +++ b/src/ui/Features/Shared/ErrorList/ErrorListViewModel.cs @@ -3,9 +3,17 @@ using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Features.Shared.PromptFileSaved; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Media; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; namespace Nikse.SubtitleEdit.Features.Shared.ErrorList; @@ -14,29 +22,135 @@ public partial class ErrorListViewModel : ObservableObject [ObservableProperty] private ObservableCollection _subtitles; [ObservableProperty] private ErrorListItem? _selectedSubtitle; [ObservableProperty] private bool _hasErrors; + [ObservableProperty] private bool _canExport; + [ObservableProperty] private string _summary = string.Empty; + [ObservableProperty] private string _exportStatus = string.Empty; + + public ObservableCollection Cards { get; } = new(); public Window? Window { get; set; } public bool GoToPressed { get; private set; } - public ErrorListViewModel() + private readonly List _allItems = new(); + private readonly IFileHelper _fileHelper; + private readonly IWindowService _windowService; + private string _subtitleFileName = string.Empty; + + public ErrorListViewModel(IFileHelper fileHelper, IWindowService windowService) { + _fileHelper = fileHelper; + _windowService = windowService; Subtitles = new ObservableCollection(); } - + + /// + /// Puts what the window shows - the active card filter included - on the clipboard, tab + /// separated, so it pastes as columns into Excel/Sheets and as a readable block anywhere else. + /// + [RelayCommand] + private async Task CopyToClipboard() + { + if (Window == null || Subtitles.Count == 0) + { + return; + } + + await ClipboardHelper.SetTextAsync(Window, ErrorListExport.ToTabSeparated(Subtitles)); + + // Not awaited: the command would stay "running" - and its menu item disabled - for as + // long as the status is shown. + _ = ShowExportStatus(Se.Language.General.CopiedToClipboard); + } + + [RelayCommand] + private Task ExportText() + { + return ExportToFile(".txt", () => Encoding.UTF8.GetBytes(ErrorListExport.ToPlainText(Subtitles, Summary, _subtitleFileName))); + } + + [RelayCommand] + private Task ExportExcel() + { + return ExportToFile(".xlsx", () => ErrorListExport.ToXlsx(Subtitles)); + } + + [RelayCommand] + private Task ExportHtml() + { + return ExportToFile(".html", () => Encoding.UTF8.GetBytes(ErrorListExport.ToHtml(Subtitles, Summary, _subtitleFileName))); + } + + private async Task ExportToFile(string extension, Func makeContent) + { + if (Window == null || Subtitles.Count == 0) + { + return; + } + + var fileName = await _fileHelper.PickSaveFile(Window, extension, GetSuggestedFileName() + extension, Se.Language.General.Export); + if (string.IsNullOrEmpty(fileName)) + { + return; + } + + await File.WriteAllBytesAsync(fileName, makeContent()); + + _ = await _windowService.ShowDialogAsync(Window, vm => + { + vm.Initialize(Se.Language.General.FileSaved, string.Format(Se.Language.General.FileSavedToX, fileName), fileName, true, true); + }); + } + + private string GetSuggestedFileName() + { + var name = string.IsNullOrEmpty(_subtitleFileName) + ? "subtitle" + : Path.GetFileNameWithoutExtension(_subtitleFileName); + + return string.IsNullOrWhiteSpace(name) ? "subtitle-errors" : name + "-errors"; + } + + /// Clipboard copies are silent otherwise - the status line says it happened, then fades. + private async Task ShowExportStatus(string text) + { + ExportStatus = text; + await Task.Delay(3000); + if (ExportStatus == text) + { + ExportStatus = string.Empty; + } + } + [RelayCommand] private void GoTo() { + if (SelectedSubtitle == null) + { + return; + } + GoToPressed = true; Window?.Close(); } - + [RelayCommand] private void Cancel() { Window?.Close(); } + [RelayCommand] + private void SetFilter(SummaryCard? card) + { + if (card == null) + { + return; + } + + ApplyFilter(card); + } + internal void OnKeyDown(KeyEventArgs e) { if (e.Key == Key.Escape) @@ -50,12 +164,43 @@ internal void OnKeyDown(KeyEventArgs e) /// The items come ready-made from the caller: it is the only place that still knows each /// line's real neighbours, which the gap/overlap wording needs. /// - internal void Initialize(List errorListItems) + internal void Initialize(List errorListItems, int totalLines, string? subtitleFileName = null) + { + var l = Se.Language.ErrorList; + _subtitleFileName = subtitleFileName ?? string.Empty; + _allItems.Clear(); + _allItems.AddRange(errorListItems); + + var lineCount = errorListItems.Select(p => p.Number).Distinct().Count(); + Summary = errorListItems.Count == 0 + ? l.NoErrors + : string.Format(l.SummaryX, errorListItems.Count, lineCount, totalLines); + + Cards.Clear(); + Cards.Add(new SummaryCard { Key = null, Label = l.All, Count = errorListItems.Count, Brush = LineError.AllBrush, IsActive = true }); + foreach (var type in Enum.GetValues()) + { + var count = errorListItems.Count(p => p.Type == type); + Cards.Add(new SummaryCard { Key = type, Label = LineError.GetLabel(type), Hint = LineError.GetHint(type), Count = count, Brush = LineError.GetBrush(type) }); + } + + ApplyFilter(Cards[0]); + } + + private void ApplyFilter(SummaryCard card) { - foreach (var item in errorListItems) + foreach (var c in Cards) { - Subtitles.Add(item); + c.IsActive = ReferenceEquals(c, card); } + + var filtered = card.Key is LineErrorType type + ? _allItems.Where(p => p.Type == type) + : _allItems; + + Subtitles = new ObservableCollection(filtered); + SelectedSubtitle = Subtitles.FirstOrDefault(); + CanExport = Subtitles.Count > 0; } /// @@ -68,7 +213,7 @@ partial void OnSelectedSubtitleChanged(ErrorListItem? value) HasErrors = value != null; } - internal void OnBookmarksGridDoubleTapped(object? sender, TappedEventArgs e) + internal void OnGridDoubleTapped(object? sender, TappedEventArgs e) { Dispatcher.UIThread.Invoke(GoTo); } @@ -81,4 +226,4 @@ internal void GridKeyDown(KeyEventArgs e) GoTo(); } } -} \ No newline at end of file +} diff --git a/src/ui/Features/Shared/ErrorList/ErrorListWindow.cs b/src/ui/Features/Shared/ErrorList/ErrorListWindow.cs index 85779f43406..0bb6fedaf05 100644 --- a/src/ui/Features/Shared/ErrorList/ErrorListWindow.cs +++ b/src/ui/Features/Shared/ErrorList/ErrorListWindow.cs @@ -1,102 +1,235 @@ using Avalonia; +using Avalonia.Automation; using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Input; using Avalonia.Layout; -using System.Collections; +using Avalonia.Media; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; -using Nikse.SubtitleEdit.Logic.ValueConverters; +using System.Collections; namespace Nikse.SubtitleEdit.Features.Shared.ErrorList; +/// +/// "List errors": a verdict header, one summary card per error class (click to +/// filter), and the affected lines in a table. Double-click / Enter / "Go to" +/// jumps to the line in the main grid. +/// public class ErrorListWindow : Window { public ErrorListWindow(ErrorListViewModel vm) { UiUtil.InitializeWindow(this, GetType().Name); - Title = Se.Language.General.ListErrors; + Title = Se.Language.ErrorList.Title; CanResize = true; - Width = 600; - Height = 700; - MinWidth = 600; - MinHeight = 400; + Width = 960; + Height = 680; + MinWidth = 720; + MinHeight = 460; vm.Window = this; DataContext = vm; + var l = Se.Language.ErrorList; + + var labelTitle = new TextBlock + { + Text = l.Title, + FontSize = 20, + FontWeight = FontWeight.Bold, + }; + var labelSummary = new TextBlock + { + FontSize = 14, + Opacity = 0.75, + Margin = new Thickness(0, 4, 0, 0), + [!TextBlock.TextProperty] = new Binding(nameof(vm.Summary)), + }; + var header = new StackPanel + { + Orientation = Orientation.Vertical, + Margin = new Thickness(0, 0, 0, 12), + Children = { labelTitle, labelSummary }, + }; + + var cards = SummaryCard.MakeCardsPanel(vm.Cards, vm.SetFilterCommand); + + var labelTip = new TextBlock + { + Text = l.Tip, + TextWrapping = TextWrapping.Wrap, + Opacity = 0.7, + Margin = new Thickness(0, 10, 0, 10), + }; + var buttonGoTo = UiUtil.MakeButton(Se.Language.General.GoTo, vm.GoToCommand).WithBindIsEnabled(nameof(vm.HasErrors)); + var buttonExport = MakeExportButton(vm); var buttonCancel = UiUtil.MakeButtonDone(vm.CancelCommand); - var panelButtons = UiUtil.MakeButtonBar(buttonGoTo, buttonCancel); + var panelButtons = UiUtil.MakeButtonBar(MakeExportStatusLabel(vm), buttonExport, buttonGoTo, buttonCancel); var grid = new Grid { RowDefinitions = { + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, }, ColumnDefinitions = { new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, }, Margin = UiUtil.MakeWindowMargin(), - ColumnSpacing = 10, - RowSpacing = 10, - Width = double.NaN, - HorizontalAlignment = HorizontalAlignment.Stretch, }; - grid.Add(MakeErrorsGridView(vm), 0); - grid.Add(panelButtons, 1); + grid.Add(header, 0, 0); + grid.Add(cards, 1, 0); + grid.Add(MakeErrorsGridView(vm), 2, 0); + grid.Add(labelTip, 3, 0); + grid.Add(panelButtons, 4, 0); Content = grid; - Activated += delegate { buttonCancel.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, buttonCancel); // hack to make OnKeyDown work KeyDown += (s, e) => vm.OnKeyDown(e); } - private static Border MakeErrorsGridView(ErrorListViewModel vm) + /// + /// "Export..." with the four targets from #14379 - the list used to be shareable only as a + /// screenshot. Every target exports the rows as shown, so an active card filter applies. + /// + private static Button MakeExportButton(ErrorListViewModel vm) { - var dataGrid = TableViewExtras.MakeTableView(); - dataGrid.Height = double.NaN; // auto size inside scroll viewer - dataGrid.Margin = new Thickness(2); - dataGrid.ItemsSource = vm.Subtitles; - dataGrid.DataContext = vm.Subtitles; + var l = Se.Language.ErrorList; + var button = UiUtil.MakeButton(Se.Language.General.ExportDotDotDot); + button.Flyout = new MenuFlyout + { + Items = + { + new MenuItem + { + Header = Se.Language.General.CopyToClipboard, + Command = vm.CopyToClipboardCommand, + }, + new MenuItem + { + Header = l.ExportAsText, + Command = vm.ExportTextCommand, + }, + new MenuItem + { + Header = l.ExportAsExcel, + Command = vm.ExportExcelCommand, + }, + new MenuItem + { + Header = l.ExportAsHtml, + Command = vm.ExportHtmlCommand, + }, + }, + }; - dataGrid.DoubleTapped += vm.OnBookmarksGridDoubleTapped; + return button.WithBindIsEnabled(nameof(vm.CanExport)); + } + + /// Says "Copied to clipboard" next to the buttons - a clipboard copy has nothing else to show. + private static TextBlock MakeExportStatusLabel(ErrorListViewModel vm) + { + return new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 10, 0), + Opacity = 0.75, + [!TextBlock.TextProperty] = new Binding(nameof(vm.ExportStatus)), + }; + } - var fullTimeConverter = new TimeSpanToDisplayFullConverter(); - var shortTimeConverter = new TimeSpanToDisplayShortConverter(); + private static Border MakeErrorsGridView(ErrorListViewModel vm) + { + var l = Se.Language.ErrorList; + var dataGrid = TableViewExtras.MakeTableView(alwaysSelected: false, multiSelect: false); + dataGrid.Height = double.NaN; // auto size inside scroll viewer + dataGrid[!TableView.ItemsSourceProperty] = new Binding(nameof(vm.Subtitles)); - // Columns dataGrid.Columns.Add(new SeTableViewColumn { Header = Se.Language.General.NumberSymbol, Binding = new Binding(nameof(ErrorListItem.Number)), - Width = new GridLength(50), // was Auto; TableView treats Auto as star + Width = new GridLength(60), CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, }); dataGrid.Columns.Add(new SeTableViewColumn { - Header = Se.Language.General.Text, - Binding = new Binding(nameof(ErrorListItem.Text)), + Header = Se.Language.General.Error, + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + CellTemplate = new FuncDataTemplate((_, _) => + { + var dot = new Avalonia.Controls.Shapes.Ellipse + { + Width = 9, + Height = 9, + VerticalAlignment = VerticalAlignment.Center, + [!Avalonia.Controls.Shapes.Shape.FillProperty] = new Binding(nameof(ErrorListItem.Brush)), + }; + var text = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + [!TextBlock.TextProperty] = new Binding(nameof(ErrorListItem.Category)), + }; + return new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 6, + Margin = new Thickness(6, 0), + VerticalAlignment = VerticalAlignment.Center, + Children = { dot, text }, + }; + }), + Width = new GridLength(160), + }); + dataGrid.Columns.Add(new SeTableViewColumn + { + Header = Se.Language.General.Show, + Binding = new Binding(nameof(ErrorListItem.Show)), + Width = new GridLength(105), CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, - Width = new GridLength(1, GridUnitType.Star) // star sizing to take all available space }); dataGrid.Columns.Add(new SeTableViewColumn { - Header = Se.Language.General.Error, - Binding = new Binding(nameof(ErrorListItem.Error)), + Header = Se.Language.General.Hide, + Binding = new Binding(nameof(ErrorListItem.Hide)), + Width = new GridLength(105), + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + }); + dataGrid.Columns.Add(new SeTableViewColumn + { + Header = l.Detail, + Binding = new Binding(nameof(ErrorListItem.Detail)), + Width = new GridLength(200), + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + }); + dataGrid.Columns.Add(new SeTableViewColumn + { + Header = Se.Language.General.Text, + Binding = new Binding(nameof(ErrorListItem.Text)), CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, - Width = new GridLength(1, GridUnitType.Star) // star sizing to take all available space + Width = new GridLength(1, GridUnitType.Star), }); + AutomationProperties.SetName(dataGrid, l.Title); TableViewExtras.BindSelectedItem(dataGrid, vm, nameof(vm.SelectedSubtitle)); - dataGrid.DoubleTapped += (s, e) => vm.GoToCommand.Execute(null); + dataGrid.DoubleTapped += vm.OnGridDoubleTapped; dataGrid.KeyDown += (s, e) => vm.GridKeyDown(e); dataGrid.AddHandler(InputElement.KeyDownEvent, (object? _, KeyEventArgs e) => { diff --git a/src/ui/Features/Shared/ErrorList/LineError.cs b/src/ui/Features/Shared/ErrorList/LineError.cs new file mode 100644 index 00000000000..0d9dbff08ca --- /dev/null +++ b/src/ui/Features/Shared/ErrorList/LineError.cs @@ -0,0 +1,113 @@ +using Avalonia.Media; +using Nikse.SubtitleEdit.Logic.Config; + +namespace Nikse.SubtitleEdit.Features.Shared.ErrorList; + +/// The error classes the grid colours and "List errors" reports (Settings > General). +public enum LineErrorType +{ + TooManyLines, + CharactersPerSecond, + DurationTooShort, + DurationTooLong, + LineTooLong, + LineTooWide, + Overlap, + GapTooShort, +} + +/// One error on one line: the class plus a short detail such as "27.3 > 25". +public record LineError(LineErrorType Type, string Detail) +{ + public string Label => GetLabel(Type); + + /// "Reading speed: 27.3 > 25" - the old single-string form, still used by the batch error list. + public override string ToString() => $"{Label}: {Detail}"; + + public static string GetLabel(LineErrorType type) + { + var l = Se.Language.ErrorList; + return type switch + { + LineErrorType.TooManyLines => l.TooManyLines, + LineErrorType.CharactersPerSecond => l.CharactersPerSecond, + LineErrorType.DurationTooShort => l.DurationTooShort, + LineErrorType.DurationTooLong => l.DurationTooLong, + LineErrorType.LineTooLong => l.LineTooLong, + LineErrorType.LineTooWide => l.LineTooWide, + LineErrorType.Overlap => l.Overlap, + LineErrorType.GapTooShort => l.GapTooShort, + _ => type.ToString(), + }; + } + + public static string GetHint(LineErrorType type) + { + var l = Se.Language.ErrorList; + return type switch + { + LineErrorType.TooManyLines => l.TooManyLinesHint, + LineErrorType.CharactersPerSecond => l.CharactersPerSecondHint, + LineErrorType.DurationTooShort => l.DurationTooShortHint, + LineErrorType.DurationTooLong => l.DurationTooLongHint, + LineErrorType.LineTooLong => l.LineTooLongHint, + LineErrorType.LineTooWide => l.LineTooWideHint, + LineErrorType.Overlap => l.OverlapHint, + LineErrorType.GapTooShort => l.GapTooShortHint, + _ => string.Empty, + }; + } + + private const string TooManyLinesColor = "#E84393"; + private const string CpsColor = "#E8912D"; + private const string TooShortColor = "#F1C40F"; + private const string TooLongColor = "#9B59B6"; + private const string LineTooLongColor = "#3498DB"; + private const string LineTooWideColor = "#2980B9"; + private const string OverlapColor = "#E74C3C"; + private const string GapColor = "#1ABC9C"; + public const string AllColor = "#5D8AA8"; + + private static readonly IBrush TooManyLinesBrush = new SolidColorBrush(Color.Parse(TooManyLinesColor)); + private static readonly IBrush CpsBrush = new SolidColorBrush(Color.Parse(CpsColor)); + private static readonly IBrush TooShortBrush = new SolidColorBrush(Color.Parse(TooShortColor)); + private static readonly IBrush TooLongBrush = new SolidColorBrush(Color.Parse(TooLongColor)); + private static readonly IBrush LineTooLongBrush = new SolidColorBrush(Color.Parse(LineTooLongColor)); + private static readonly IBrush LineTooWideBrush = new SolidColorBrush(Color.Parse(LineTooWideColor)); + private static readonly IBrush OverlapBrush = new SolidColorBrush(Color.Parse(OverlapColor)); + private static readonly IBrush GapBrush = new SolidColorBrush(Color.Parse(GapColor)); + public static readonly IBrush AllBrush = new SolidColorBrush(Color.Parse(AllColor)); + + /// The dot colour as "#RRGGBB" - the html export paints the same palette as the window. + public static string GetColor(LineErrorType type) + { + return type switch + { + LineErrorType.TooManyLines => TooManyLinesColor, + LineErrorType.CharactersPerSecond => CpsColor, + LineErrorType.DurationTooShort => TooShortColor, + LineErrorType.DurationTooLong => TooLongColor, + LineErrorType.LineTooLong => LineTooLongColor, + LineErrorType.LineTooWide => LineTooWideColor, + LineErrorType.Overlap => OverlapColor, + LineErrorType.GapTooShort => GapColor, + _ => AllColor, + }; + } + + public static IBrush GetBrush(LineErrorType type) + { + return type switch + { + LineErrorType.TooManyLines => TooManyLinesBrush, + LineErrorType.CharactersPerSecond => CpsBrush, + LineErrorType.DurationTooShort => TooShortBrush, + LineErrorType.DurationTooLong => TooLongBrush, + LineErrorType.LineTooLong => LineTooLongBrush, + LineErrorType.LineTooWide => LineTooWideBrush, + LineErrorType.Overlap => OverlapBrush, + LineErrorType.GapTooShort => GapBrush, + _ => AllBrush, + }; + } +} diff --git a/src/ui/Features/Shared/FindText/FindTextWindow.cs b/src/ui/Features/Shared/FindText/FindTextWindow.cs index 6e8160caf5b..9f83b3b9e03 100644 --- a/src/ui/Features/Shared/FindText/FindTextWindow.cs +++ b/src/ui/Features/Shared/FindText/FindTextWindow.cs @@ -63,7 +63,7 @@ public FindTextWindow(FindTextViewModel vm) Content = grid; - Activated += delegate { textBoxSearch.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, textBoxSearch); // hack to make OnKeyDown work KeyDown += (_, e) => vm.OnKeyDown(e); } diff --git a/src/ui/Features/Shared/FormatLimitWarning/FormatLimitWarningViewModel.cs b/src/ui/Features/Shared/FormatLimitWarning/FormatLimitWarningViewModel.cs new file mode 100644 index 00000000000..6bf81463e3d --- /dev/null +++ b/src/ui/Features/Shared/FormatLimitWarning/FormatLimitWarningViewModel.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Logic.Config; + +namespace Nikse.SubtitleEdit.Features.Shared.FormatLimitWarning; + +/// +/// Shown before a save when the target format has hard limits (SCC: 32 chars x 4 lines) that some +/// subtitles exceed - the writer would otherwise silently re-wrap or truncate them +/// ("my merged lines came back split" after a reopen). +/// +public partial class FormatLimitWarningViewModel : ObservableObject +{ + [ObservableProperty] private string _summaryText = string.Empty; + [ObservableProperty] private string _maxCharactersText = string.Empty; + [ObservableProperty] private bool _isMaxCharactersVisible; + [ObservableProperty] private string _maxLinesText = string.Empty; + [ObservableProperty] private bool _isMaxLinesVisible; + [ObservableProperty] private string _linesText = string.Empty; + [ObservableProperty] private bool _doNotShowAgain; + + public Window? Window { get; set; } + public bool SaveAnywayPressed { get; private set; } + + private const int MaxListedLineNumbers = 30; + + public void Initialize(SubtitleFormat format, SubtitleFormatLimits limits, List violatingParagraphNumbers) + { + SummaryText = string.Format(Se.Language.Main.FormatLimitWarningXLinesExceedLimitsOfY, violatingParagraphNumbers.Count, format.Name); + + IsMaxCharactersVisible = limits.MaxCharactersPerLine.HasValue; + MaxCharactersText = string.Format(Se.Language.Main.FormatLimitWarningMaxXCharactersPerLine, limits.MaxCharactersPerLine ?? 0); + + IsMaxLinesVisible = limits.MaxLines.HasValue; + MaxLinesText = string.Format(Se.Language.Main.FormatLimitWarningMaxXLines, limits.MaxLines ?? 0); + + var numbers = string.Join(", ", violatingParagraphNumbers.Take(MaxListedLineNumbers)); + if (violatingParagraphNumbers.Count > MaxListedLineNumbers) + { + numbers += ", ..."; + } + + LinesText = string.Format(Se.Language.Main.FormatLimitWarningLinesX, numbers); + } + + [RelayCommand] + private void SaveAnyway() + { + if (DoNotShowAgain) + { + Se.Settings.General.ShowFormatLimitWarning = false; + Se.SaveSettings(); + } + + SaveAnywayPressed = true; + Window?.Close(); + } + + [RelayCommand] + private void Cancel() + { + Window?.Close(); + } + + internal void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + e.Handled = true; + Cancel(); + } + } +} diff --git a/src/ui/Features/Shared/FormatLimitWarning/FormatLimitWarningWindow.cs b/src/ui/Features/Shared/FormatLimitWarning/FormatLimitWarningWindow.cs new file mode 100644 index 00000000000..1d461c06ce0 --- /dev/null +++ b/src/ui/Features/Shared/FormatLimitWarning/FormatLimitWarningWindow.cs @@ -0,0 +1,144 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Layout; +using Avalonia.Media; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using Optris.Icons.Avalonia; + +namespace Nikse.SubtitleEdit.Features.Shared.FormatLimitWarning; + +public class FormatLimitWarningWindow : Window +{ + public FormatLimitWarningWindow(FormatLimitWarningViewModel vm) + { + UiUtil.InitializeWindow(this, GetType().Name); + Title = Se.Language.Main.FormatLimitWarningTitle; + CanResize = false; + SizeToContent = SizeToContent.WidthAndHeight; + vm.Window = this; + DataContext = vm; + + var header = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 12, + VerticalAlignment = VerticalAlignment.Center, + Children = + { + MakeIcon(IconNames.Alert, 36, 1.0), + new TextBlock + { + FontSize = 15, + FontWeight = FontWeight.SemiBold, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 420, + [!TextBlock.TextProperty] = new Binding(nameof(vm.SummaryText)), + }, + }, + }; + + var rows = new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 8, + Children = + { + MakeRow(IconNames.FormatFont, nameof(vm.MaxCharactersText), nameof(vm.IsMaxCharactersVisible)), + MakeRow(IconNames.FormatFont, nameof(vm.MaxLinesText), nameof(vm.IsMaxLinesVisible)), + MakeRow(IconNames.ViewList, nameof(vm.LinesText), null), + }, + }; + + var card = new Border + { + CornerRadius = new CornerRadius(8), + Background = new SolidColorBrush(Color.FromArgb(18, 128, 128, 128)), + BorderBrush = new SolidColorBrush(Color.FromArgb(48, 128, 128, 128)), + BorderThickness = new Thickness(1), + Padding = new Thickness(16, 14, 24, 14), + Child = rows, + }; + + var explanation = new TextBlock + { + Text = Se.Language.Main.FormatLimitWarningTextWillBeRewrapped, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 460, + Opacity = 0.85, + }; + + var checkBoxDoNotShowAgain = new CheckBox + { + Content = new TextBlock { Text = Se.Language.Main.FormatLimitWarningDoNotShowAgain, FontSize = 13 }, + Opacity = 0.8, + [!CheckBox.IsCheckedProperty] = new Binding(nameof(vm.DoNotShowAgain)) { Mode = BindingMode.TwoWay }, + }; + + var checkBoxSmall = new LayoutTransformControl + { + LayoutTransform = new ScaleTransform(0.85, 0.85), + HorizontalAlignment = HorizontalAlignment.Left, + Child = checkBoxDoNotShowAgain, + }; + + var buttonSaveAnyway = UiUtil.MakeButton(Se.Language.Main.FormatLimitWarningSaveAnyway, vm.SaveAnywayCommand); + var buttonCancel = UiUtil.MakeButtonCancel(vm.CancelCommand); + var buttonPanel = UiUtil.MakeButtonBar(buttonSaveAnyway, buttonCancel); + + var panel = new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 14, + Margin = UiUtil.MakeWindowMargin(), + MinWidth = 360, + Children = { header, card, explanation, checkBoxSmall, buttonPanel }, + }; + + Content = panel; + + // Cancel is the safe default: Enter must not accidentally commit a lossy save. + UiUtil.FocusOnFirstActivation(this, buttonCancel); + KeyDown += (_, e) => vm.OnKeyDown(e); + } + + private static Control MakeIcon(string iconName, double size, double opacity) + { + var icon = new ContentControl + { + Width = size, + Height = size, + Opacity = opacity, + VerticalAlignment = VerticalAlignment.Center, + }; + Attached.SetIcon(icon, iconName); + return icon; + } + + private static Control MakeRow(string iconName, string textPropertyPath, string? visiblePropertyPath) + { + var text = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 400, + }; + text.Bind(TextBlock.TextProperty, new Binding(textPropertyPath)); + + var row = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 10, + Children = { MakeIcon(iconName, 20, 0.85), text }, + }; + + if (visiblePropertyPath != null) + { + row.Bind(Visual.IsVisibleProperty, new Binding(visiblePropertyPath)); + } + + return row; + } +} diff --git a/src/ui/Features/Shared/FullScreenVideoPlayer.cs b/src/ui/Features/Shared/FullScreenVideoPlayer.cs index a0edabcc1bb..7c48ae6acbe 100644 --- a/src/ui/Features/Shared/FullScreenVideoPlayer.cs +++ b/src/ui/Features/Shared/FullScreenVideoPlayer.cs @@ -210,7 +210,7 @@ public FullScreenVideoWindow( // back to the docked player. Closed += (_, _) => videoPlayer.CloseAndDisposePlayer(); - Activated += delegate { Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, () => { Focus(); }); // hack to make OnKeyDown work Loaded += async(_, _) => { WindowState = WindowState.Maximized; @@ -256,6 +256,12 @@ public FullScreenVideoWindow( videoPlayer.VideoPlayer.Position = position; videoPlayer.Position = position; + + // Restore done - a rebuild reading from this control gets the live position + // again (issue #14218). Only once the player has actually arrived, though: mpv + // can still be loading here, and handing a rebuild the 0 of a player that never + // reached the target is the rewind this guard exists to prevent. + videoPlayer.EndPositionRestoreIfArrived(); }); }; } diff --git a/src/ui/Features/Shared/GetAudioClips/GetAudioClipsViewModel.cs b/src/ui/Features/Shared/GetAudioClips/GetAudioClipsViewModel.cs index 1d9c5b09455..3f60cd19f94 100644 --- a/src/ui/Features/Shared/GetAudioClips/GetAudioClipsViewModel.cs +++ b/src/ui/Features/Shared/GetAudioClips/GetAudioClipsViewModel.cs @@ -70,7 +70,9 @@ private void ExtractLines() ReportProgress(count); var outputFileName = Path.Combine(Path.GetTempPath(), $"se_audioclip_{Guid.NewGuid()}.wav"); - var process = GetExtractProcess(_videoFileName, line, outputFileName); + // One process per line - without the using, extracting clips for a long subtitle + // leaves a handle per line behind. + using var process = GetExtractProcess(_videoFileName, line, outputFileName); process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); diff --git a/src/ui/Features/Shared/GetAudioClips/GetAudioClipsWindow.cs b/src/ui/Features/Shared/GetAudioClips/GetAudioClipsWindow.cs index ac8127675a5..5a3431e2854 100644 --- a/src/ui/Features/Shared/GetAudioClips/GetAudioClipsWindow.cs +++ b/src/ui/Features/Shared/GetAudioClips/GetAudioClipsWindow.cs @@ -51,7 +51,7 @@ public GetAudioClipsWindow(GetAudioClipsViewModel vm) } }; - Activated += delegate { buttonCancel.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, buttonCancel); // hack to make OnKeyDown work // Loaded, not Activated: Activated fires again every time the window regains focus, which // started a second extraction loop from line one on top of the running one (#13777). diff --git a/src/ui/Features/Shared/GoToLineNumber/GoToLineNumberWindow.cs b/src/ui/Features/Shared/GoToLineNumber/GoToLineNumberWindow.cs index 92f6c15dab8..70ae61f7ca5 100644 --- a/src/ui/Features/Shared/GoToLineNumber/GoToLineNumberWindow.cs +++ b/src/ui/Features/Shared/GoToLineNumber/GoToLineNumberWindow.cs @@ -67,7 +67,7 @@ public GoToLineNumberWindow(GoToLineNumberViewModel vm) Content = contentPanel; - Activated += delegate { vm.Activated(); }; + UiUtil.FocusOnFirstActivation(this, () => { vm.Activated(); }); KeyDown += (_, args) => vm.OnKeyDown(args); } } \ No newline at end of file diff --git a/src/ui/Features/Shared/MediaInfoView/MediaInfoViewViewModel.cs b/src/ui/Features/Shared/MediaInfoView/MediaInfoViewViewModel.cs index 4688eb2f56f..485561835fa 100644 --- a/src/ui/Features/Shared/MediaInfoView/MediaInfoViewViewModel.cs +++ b/src/ui/Features/Shared/MediaInfoView/MediaInfoViewViewModel.cs @@ -126,7 +126,9 @@ private static string BuildFullInfoText(string videoFileName, FfmpegMediaInfo2 m sb.AppendLine($"Codec: MP3"); } - var mkvParser = new MatroskaFile(videoFileName); + // The ctor opens a FileStream for any file, valid Matroska or not, so this leaked a handle + // on every Media info for every video. + using var mkvParser = new MatroskaFile(videoFileName); if (mkvParser.IsValid) { sb.AppendLine($"Container: Matroska (mkv/webm)"); diff --git a/src/ui/Features/Shared/MessageBox.cs b/src/ui/Features/Shared/MessageBox.cs index db514a4171f..c09afb5b5a1 100644 --- a/src/ui/Features/Shared/MessageBox.cs +++ b/src/ui/Features/Shared/MessageBox.cs @@ -59,7 +59,6 @@ public class MessageBox : Window private readonly StackPanel _buttonPanel; private readonly KeyPressTracker _enterTracker = new(); private readonly KeyPressTracker _spaceTracker = new(); - private bool _initialFocusDone; private long _openedTimestamp; private bool IsInStartupGuardPeriod() @@ -342,16 +341,12 @@ void AddButton(string text, MessageBoxResult result) }, RoutingStrategies.Tunnel); Activated += delegate { - // Re-arm the guard on every activation (alt-tabbing back can bring a held key's - // auto-repeat along), but move focus only the first time so arrow-key navigation - // survives a focus round-trip to another window. + // Re-armed on every activation: alt-tabbing back can bring a held key's auto-repeat + // along. The initial focus below is deliberately not part of this - it must happen + // once, so arrow-key navigation survives a focus round-trip to another window. _openedTimestamp = Stopwatch.GetTimestamp(); - if (!_initialFocusDone) - { - _initialFocusDone = true; - buttonPanel.Children[0].Focus(); - } }; + UiUtil.FocusOnFirstActivation(this, () => buttonPanel.Children[0].Focus()); Deactivated += delegate { // Key releases go to the newly focused window, so presses counted here would diff --git a/src/ui/Features/Shared/OpenOriginalMismatch/OpenOriginalMismatchWindow.cs b/src/ui/Features/Shared/OpenOriginalMismatch/OpenOriginalMismatchWindow.cs index 2cbc0372d7a..deb8a65a8db 100644 --- a/src/ui/Features/Shared/OpenOriginalMismatch/OpenOriginalMismatchWindow.cs +++ b/src/ui/Features/Shared/OpenOriginalMismatch/OpenOriginalMismatchWindow.cs @@ -110,7 +110,7 @@ public OpenOriginalMismatchWindow(OpenOriginalMismatchViewModel vm) }, }; - Activated += delegate { radioMatching.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, radioMatching); // hack to make OnKeyDown work KeyDown += (_, e) => vm.OnKeyDown(e); } diff --git a/src/ui/Features/Shared/PickAlignment/PickAlignmentWindow.cs b/src/ui/Features/Shared/PickAlignment/PickAlignmentWindow.cs index 57e4a4a9664..360d9eb117e 100644 --- a/src/ui/Features/Shared/PickAlignment/PickAlignmentWindow.cs +++ b/src/ui/Features/Shared/PickAlignment/PickAlignmentWindow.cs @@ -69,7 +69,7 @@ public PickAlignmentWindow(PickAlignmentViewModel vm) Content = grid; - Activated += delegate { buttonAn2.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, buttonAn2); // hack to make OnKeyDown work KeyDown += (_, e) => vm.OnKeyDown(e); } } diff --git a/src/ui/Features/Shared/PickFontName/PickFontNameViewModel.cs b/src/ui/Features/Shared/PickFontName/PickFontNameViewModel.cs index 35363dbc378..aaa6249b42d 100644 --- a/src/ui/Features/Shared/PickFontName/PickFontNameViewModel.cs +++ b/src/ui/Features/Shared/PickFontName/PickFontNameViewModel.cs @@ -157,7 +157,7 @@ private void UpdatePreview() var fontName = GetActiveFontName(); if (string.IsNullOrWhiteSpace(fontName)) { - ImagePreview = new SKBitmap(1, 1, true).ToAvaloniaBitmap(); + SetPreview(new SKBitmap(1, 1, true).ToAvaloniaBitmap()); return; } @@ -178,12 +178,10 @@ private void UpdatePreview() skTypeface ??= SKTypeface.FromFamilyName(fontName); if (skTypeface == null) { - ImagePreview = new SKBitmap(1, 1, true).ToAvaloniaBitmap(); + SetPreview(new SKBitmap(1, 1, true).ToAvaloniaBitmap()); return; } - ImagePreview?.Dispose(); - var imageInfo = new SKImageInfo(previewWidth, previewHeight, SKColorType.Bgra8888, SKAlphaType.Premul); using var surface = SKSurface.Create(imageInfo); var canvas = surface.Canvas; @@ -216,7 +214,25 @@ private void UpdatePreview() // Convert to Avalonia bitmap using var skImage = surface.Snapshot(); var skBitmap = SKBitmap.FromImage(skImage); - ImagePreview = skBitmap.ToAvaloniaBitmap(); + SetPreview(skBitmap.ToAvaloniaBitmap()); + } + + /// + /// Publishes the new preview before releasing the old one. This runs on the update timer's + /// thread, so disposing first left the bound Image pointing at a disposed bitmap for as long + /// as the render took - and a render pass in that gap crashed. The early-return paths went + /// the other way and leaked the previous bitmap; both go through here now. + /// + private void SetPreview(Bitmap newPreview) + { + var old = ImagePreview; + ImagePreview = newPreview; + if (!ReferenceEquals(old, newPreview) && old != null) + { + // Queued behind the binding update this assignment triggers, so the old bitmap + // outlives the last render pass that can still reference it. + Dispatcher.UIThread.Post(() => old.Dispose(), DispatcherPriority.Background); + } } [RelayCommand] diff --git a/src/ui/Features/Shared/PickFontName/PickFontNameWindow.cs b/src/ui/Features/Shared/PickFontName/PickFontNameWindow.cs index e2870aa25f6..a5f3e972019 100644 --- a/src/ui/Features/Shared/PickFontName/PickFontNameWindow.cs +++ b/src/ui/Features/Shared/PickFontName/PickFontNameWindow.cs @@ -127,7 +127,7 @@ public PickFontNameWindow(PickFontNameViewModel vm) Content = grid; - Activated += delegate { textBoxSearch.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, textBoxSearch); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += (_, e) => vm.OnKeyDown(e); } diff --git a/src/ui/Features/Shared/PickLanguage/PickLanguageWindow.cs b/src/ui/Features/Shared/PickLanguage/PickLanguageWindow.cs index 4cc5021770a..660e790b834 100644 --- a/src/ui/Features/Shared/PickLanguage/PickLanguageWindow.cs +++ b/src/ui/Features/Shared/PickLanguage/PickLanguageWindow.cs @@ -92,7 +92,7 @@ public PickLanguageWindow(PickLanguageViewModel vm) Content = grid; - Activated += delegate { textBoxSearch.Focus(); }; + UiUtil.FocusOnFirstActivation(this, textBoxSearch); KeyDown += (_, e) => vm.OnKeyDown(e); } } diff --git a/src/ui/Features/Shared/PickLayer/PickLayerWindow.cs b/src/ui/Features/Shared/PickLayer/PickLayerWindow.cs index 44c5a08ddc1..eb4898b4ca7 100644 --- a/src/ui/Features/Shared/PickLayer/PickLayerWindow.cs +++ b/src/ui/Features/Shared/PickLayer/PickLayerWindow.cs @@ -76,7 +76,7 @@ public PickLayerWindow(PickLayerViewModel vm) Content = grid; - Activated += delegate { upDownLayer.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, upDownLayer); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += (_, e) => vm.OnKeyDown(e); } } diff --git a/src/ui/Features/Shared/PickMatroskaTrack/PickMatroskaTrackViewModel.cs b/src/ui/Features/Shared/PickMatroskaTrack/PickMatroskaTrackViewModel.cs index 8bedef1cb7e..946196d7682 100644 --- a/src/ui/Features/Shared/PickMatroskaTrack/PickMatroskaTrackViewModel.cs +++ b/src/ui/Features/Shared/PickMatroskaTrack/PickMatroskaTrackViewModel.cs @@ -56,6 +56,12 @@ public partial class PickMatroskaTrackViewModel : ObservableObject // slow part, so the progress window is only shown until that initial read has completed. private bool _clusterLoaded; + // Building the preview of a PGS track parses every cue - the exact same work the caller would + // otherwise redo right after OK, for a second (and unprogressed) "parsing Matroska file" wait + // on the very same data. Keep the parsed list so the caller can pick it up instead. (#14161) + private List? _previewBluRaySubtitles; + private int _previewBluRayTrackNumber = -1; + // Only bother with the progress window for files large enough that the read is noticeable. private const long MatroskaProgressWindowMinFileSize = 25 * 1024 * 1024; // 25 MB @@ -99,6 +105,15 @@ private void Close() Dispatcher.UIThread.Post(() => { Window?.Close(); }); } + /// + /// The PGS cues already parsed for the preview of , or null when + /// that track was not previewed (or is not PGS) and the caller has to parse them itself. + /// + public List? GetPreParsedBluRaySubtitles(int trackNumber) + { + return _previewBluRayTrackNumber == trackNumber ? _previewBluRaySubtitles : null; + } + [RelayCommand] private async Task Export() { @@ -148,19 +163,25 @@ private async Task Export() ScreenWidth = (int)Math.Round(pcsData[0].GetScreenSize().Width, MidpointRounding.AwayFromZero), ScreenHeight = (int)Math.Round(pcsData[0].GetScreenSize().Height, MidpointRounding.AwayFromZero), }); + var screenSize = pcsData[0].GetScreenSize(); for (var i = 0; i < pcsData.Count; i++) { var item = pcsData[i]; + using var bitmap = item.GetBitmap(); var ip = new ImageParameter { - Bitmap = item.GetBitmap(), + Bitmap = bitmap, StartTime = TimeSpan.FromMilliseconds(item.StartTimeCode.TotalMilliseconds), EndTime = TimeSpan.FromMilliseconds(item.EndTimeCode.TotalMilliseconds), - ScreenWidth = 1920, - ScreenHeight = 1080, + ScreenWidth = (int)Math.Round(screenSize.Width, MidpointRounding.AwayFromZero), + ScreenHeight = (int)Math.Round(screenSize.Height, MidpointRounding.AwayFromZero), Index = i + 1, OverridePosition = new SKPointI(item.GetPosition().Left, item.GetPosition().Top), }; + + // WriteParagraph only writes ImageParameter.Buffer, and CreateParagraph is what + // fills it - without this every cue wrote zero bytes and the .sup came out empty. + exportHandler.CreateParagraph(ip); exportHandler.WriteParagraph(ip); } @@ -370,6 +391,9 @@ private async Task TrackChangedAsync() return; } + _previewBluRaySubtitles = preview.BluRaySubtitles; + _previewBluRayTrackNumber = preview.BluRaySubtitles != null ? trackInfo.TrackNumber : -1; + foreach (var cue in preview.Cues) { Rows.Add(new MatroskaSubtitleCueDisplay @@ -410,13 +434,14 @@ private static PreviewResult BuildPreview(MatroskaTrackInfo trackInfo, MatroskaF var cues = new List(); var count = 0; int? forcedCount = null; + List? bluRaySubtitles = null; MatroskaFile.LoadMatroskaCallback? callback = pleaseWaitVm != null ? (position, total) => pleaseWaitVm.ReportProgress(position, total) : null; var subtitles = matroskaFile.GetSubtitle(trackInfo.TrackNumber, callback); if (subtitles == null) { - return new PreviewResult(0, null, cues); + return new PreviewResult(0, null, cues, null); } if (trackInfo.CodecId is MatroskaTrackType.SubRip @@ -441,17 +466,22 @@ or MatroskaTrackType.AdvancedSubStationAlpha or MatroskaTrackType.AdvancedSubSta else if (trackInfo.CodecId == MatroskaTrackType.BluRay) { var pcsData = BluRaySupParser.ParseBluRaySupFromMatroska(trackInfo, matroskaFile); + bluRaySubtitles = pcsData; count = pcsData.Count; forcedCount = pcsData.Count(p => p.IsForced); for (var i = 0; i < 20 && i < pcsData.Count; i++) { var item = pcsData[i]; + + // GetBitmap allocates a new native bitmap each call; ToAvaloniaBitmap copies out + // of it, so it has to be released like the VobSub branch below does. + using var previewBitmap = item.GetBitmap(); cues.Add(new PreviewCueData { Number = i + 1, Show = TimeSpan.FromMilliseconds(item.StartTimeCode.TotalMilliseconds), Duration = TimeSpan.FromMilliseconds(item.EndTimeCode.TotalMilliseconds - item.StartTimeCode.TotalMilliseconds), - Image = item.GetBitmap().ToAvaloniaBitmap(), + Image = previewBitmap.ToAvaloniaBitmap(), }); } } @@ -514,7 +544,7 @@ or MatroskaTrackType.AdvancedSubStationAlpha or MatroskaTrackType.AdvancedSubSta } } - return new PreviewResult(count, forcedCount, cues); + return new PreviewResult(count, forcedCount, cues, bluRaySubtitles); } /// @@ -531,7 +561,7 @@ internal static string FormatSubtitleCount(int count, int? forcedCount) } /// is null for formats without a forced flag. - private sealed record PreviewResult(int Count, int? ForcedCount, List Cues); + private sealed record PreviewResult(int Count, int? ForcedCount, List Cues, List? BluRaySubtitles); private sealed class PreviewCueData { diff --git a/src/ui/Features/Shared/PickMp4Track/PickMp4TrackViewModel.cs b/src/ui/Features/Shared/PickMp4Track/PickMp4TrackViewModel.cs index 94d105d028a..9564eeb40bb 100644 --- a/src/ui/Features/Shared/PickMp4Track/PickMp4TrackViewModel.cs +++ b/src/ui/Features/Shared/PickMp4Track/PickMp4TrackViewModel.cs @@ -201,16 +201,22 @@ private async Task Export() { var subPicture = subPictures[i]; var paragraph = paragraphs[i]; - exportHandler.WriteParagraph(new ImageParameter + using var bitmap = subPicture.GetBitmap(palette, SKColors.Transparent, SKColors.Black, SKColors.White, SKColors.Black, false); + var ip = new ImageParameter { - Bitmap = subPicture.GetBitmap(palette, SKColors.Transparent, SKColors.Black, SKColors.White, SKColors.Black, false), + Bitmap = bitmap, StartTime = TimeSpan.FromMilliseconds(paragraph.StartTime.TotalMilliseconds), EndTime = TimeSpan.FromMilliseconds(paragraph.EndTime.TotalMilliseconds), ScreenWidth = screenWidth, ScreenHeight = screenHeight, Index = i + 1, OverridePosition = new SKPointI(subPicture.ImageDisplayArea.Left, subPicture.ImageDisplayArea.Top), - }); + }; + + // WriteParagraph only writes ImageParameter.Buffer, and CreateParagraph is what + // fills it - without this every cue wrote zero bytes and the .sup came out empty. + exportHandler.CreateParagraph(ip); + exportHandler.WriteParagraph(ip); } exportHandler.WriteFooter(); @@ -287,15 +293,22 @@ private bool TrackChanged() i++; var cue = new Mp4SubtitleCueDisplay() { - Number = i + 1, + // i is already 1-based here (the Matroska picker's i is 0-based), so "+ 1" made + // the "#" column start at 2. + Number = i, Show = item.StartTime.TimeSpan, Hide = item.EndTime.TimeSpan, Duration = TimeSpan.FromMilliseconds(item.EndTime.TotalMilliseconds - item.StartTime.TotalMilliseconds), }; - if (selectedTrack.IsVobSubSubtitle && selectedTrack.Track is { } trackinfo) + // Export guards the same pairing with Math.Min, so the two lists demonstrably can + // differ in length - indexing blindly threw out of the selection-changed handler. + if (selectedTrack.IsVobSubSubtitle && selectedTrack.Track is { } trackinfo && + i - 1 < trackinfo.Mdia.Minf.Stbl.SubPictures.Count) { - cue.Image = new Image { Source = trackinfo.Mdia.Minf.Stbl.SubPictures[i - 1].GetBitmap(trackinfo.Mdia.Minf.Stbl.VobSubPalette, SKColors.Transparent, SKColors.Black, SKColors.White, SKColors.Black, false).ToAvaloniaBitmap() }; + using var subPictureBitmap = trackinfo.Mdia.Minf.Stbl.SubPictures[i - 1] + .GetBitmap(trackinfo.Mdia.Minf.Stbl.VobSubPalette, SKColors.Transparent, SKColors.Black, SKColors.White, SKColors.Black, false); + cue.Image = new Image { Source = subPictureBitmap.ToAvaloniaBitmap() }; } else { diff --git a/src/ui/Features/Shared/PickMp4Track/PickMp4TrackWindow.cs b/src/ui/Features/Shared/PickMp4Track/PickMp4TrackWindow.cs index be15a93d90f..74e696f466a 100644 --- a/src/ui/Features/Shared/PickMp4Track/PickMp4TrackWindow.cs +++ b/src/ui/Features/Shared/PickMp4Track/PickMp4TrackWindow.cs @@ -67,10 +67,10 @@ public PickMp4TrackWindow(PickMp4TrackViewModel vm) Content = grid; - Activated += delegate + UiUtil.FocusOnFirstActivation(this, () => { buttonOk.Focus(); // hack to make OnKeyDown work - }; + }); } protected override void OnLoaded(RoutedEventArgs e) @@ -91,7 +91,10 @@ private Border MakeTracksView(PickMp4TrackViewModel vm) // narrow columns get fixed widths and Name becomes the star column. var handlerColumn = new SeTableViewColumn { - Header = "HandlerName", + // Localized, and not the raw C# property name: this dialog showed "HandlerName" and + // "IsVobSubSubtitle" to the user as column headers. The Matroska track picker beside + // it uses Se.Language for every header. + Header = Se.Language.General.Type, CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, Binding = new Binding(nameof(Mp4TrackInfoDisplay.HandlerType)), @@ -99,7 +102,7 @@ private Border MakeTracksView(PickMp4TrackViewModel vm) }; var nameColumn = new SeTableViewColumn { - Header = "Name", + Header = Se.Language.General.Name, CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, Binding = new Binding(nameof(Mp4TrackInfoDisplay.Name)), @@ -107,7 +110,7 @@ private Border MakeTracksView(PickMp4TrackViewModel vm) }; var durationColumn = new SeTableViewColumn { - Header = "Duration", + Header = Se.Language.General.Duration, CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, Binding = new Binding(nameof(Mp4TrackInfoDisplay.Duration)) { Converter = new TimeSpanToDisplayFullConverter() }, @@ -115,7 +118,7 @@ private Border MakeTracksView(PickMp4TrackViewModel vm) }; var vobSubColumn = new SeTableViewColumn { - Header = "IsVobSubSubtitle", + Header = Se.Language.General.VobSub, CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, Binding = new Binding(nameof(Mp4TrackInfoDisplay.IsVobSubSubtitle)), @@ -123,7 +126,7 @@ private Border MakeTracksView(PickMp4TrackViewModel vm) }; var startPositionColumn = new SeTableViewColumn { - Header = "StartPosition", + Header = Se.Language.General.Start, CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, Binding = new Binding(nameof(Mp4TrackInfoDisplay.StartPosition)), @@ -167,7 +170,7 @@ private Border MakeSubtitleView(PickMp4TrackViewModel vm) { new SeTableViewColumn { - Header = "#", + Header = Se.Language.General.NumberSymbol, CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, Binding = new Binding(nameof(Mp4SubtitleCueDisplay.Number)), @@ -175,7 +178,7 @@ private Border MakeSubtitleView(PickMp4TrackViewModel vm) }, new SeTableViewColumn { - Header = "Show", + Header = Se.Language.General.Show, CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, Binding = new Binding(nameof(Mp4SubtitleCueDisplay.Show)) { Converter = fullTimeConverter }, @@ -183,7 +186,7 @@ private Border MakeSubtitleView(PickMp4TrackViewModel vm) }, new SeTableViewColumn { - Header = "Duration", + Header = Se.Language.General.Duration, CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, Binding = new Binding(nameof(Mp4SubtitleCueDisplay.Duration)) { Converter = shortTimeConverter }, @@ -191,7 +194,7 @@ private Border MakeSubtitleView(PickMp4TrackViewModel vm) }, new SeTableViewColumn { - Header = "Text/Image", + Header = Se.Language.General.TextOrImage, CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, Width = new GridLength(1, GridUnitType.Star), diff --git a/src/ui/Features/Shared/PickRuleProfile/PickRuleProfileWindow.cs b/src/ui/Features/Shared/PickRuleProfile/PickRuleProfileWindow.cs index 37c2427a88f..125a67b3d86 100644 --- a/src/ui/Features/Shared/PickRuleProfile/PickRuleProfileWindow.cs +++ b/src/ui/Features/Shared/PickRuleProfile/PickRuleProfileWindow.cs @@ -59,13 +59,13 @@ public PickRuleProfileWindow(PickRuleProfileViewModel vm) Content = grid; - Activated += delegate + UiUtil.FocusOnFirstActivation(this, () => { if (_profileGrid != null) { TableViewExtras.FocusRow(_profileGrid); // hack to make OnKeyDown work } - }; + }); KeyDown += vm.KeyDown; } diff --git a/src/ui/Features/Shared/PickSpellCheckDictionary/PickSpellCheckDictionaryWindow.cs b/src/ui/Features/Shared/PickSpellCheckDictionary/PickSpellCheckDictionaryWindow.cs index 84e76d497b4..f68c7ed8051 100644 --- a/src/ui/Features/Shared/PickSpellCheckDictionary/PickSpellCheckDictionaryWindow.cs +++ b/src/ui/Features/Shared/PickSpellCheckDictionary/PickSpellCheckDictionaryWindow.cs @@ -70,7 +70,7 @@ public PickSpellCheckDictionaryWindow(PickSpellCheckDictionaryViewModel vm) Content = grid; - Activated += delegate { combo.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, combo); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += (_, e) => vm.OnKeyDown(e); } } diff --git a/src/ui/Features/Shared/PickSubtitleFormat/PickSubtitleFormatWindow.cs b/src/ui/Features/Shared/PickSubtitleFormat/PickSubtitleFormatWindow.cs index 36ec3ab6ac3..7b7bd913ae1 100644 --- a/src/ui/Features/Shared/PickSubtitleFormat/PickSubtitleFormatWindow.cs +++ b/src/ui/Features/Shared/PickSubtitleFormat/PickSubtitleFormatWindow.cs @@ -105,10 +105,10 @@ public PickSubtitleFormatWindow(PickSubtitleFormatViewModel vm) Content = grid; - Activated += delegate + UiUtil.FocusOnFirstActivation(this, () => { textBoxSearch.Focus(); - }; + }); KeyDown += (_, e) => vm.OnKeyDown(e); } diff --git a/src/ui/Features/Shared/PickTeletextAlignment/PickTeletextAlignmentWindow.cs b/src/ui/Features/Shared/PickTeletextAlignment/PickTeletextAlignmentWindow.cs index 0b4c1982a82..d99f9e0c9f0 100644 --- a/src/ui/Features/Shared/PickTeletextAlignment/PickTeletextAlignmentWindow.cs +++ b/src/ui/Features/Shared/PickTeletextAlignment/PickTeletextAlignmentWindow.cs @@ -67,17 +67,8 @@ public PickTeletextAlignmentWindow(PickTeletextAlignmentViewModel vm) }, }; - var previewCheckBox = new CheckBox { Content = Se.Language.General.Preview }; - previewCheckBox.Bind(CheckBox.IsCheckedProperty, new Binding(nameof(vm.Preview)) - { - Mode = BindingMode.TwoWay, - }); - - var showTeletextCheckBox = new CheckBox { Content = Se.Language.General.ShowTeletext }; - showTeletextCheckBox.Bind(CheckBox.IsCheckedProperty, new Binding(nameof(vm.ShowTeletextColumn)) - { - Mode = BindingMode.TwoWay, - }); + var previewCheckBox = UiUtil.MakeCheckBox(Se.Language.General.PreviewAlignmentInListView, vm, nameof(vm.Preview)); + var showTeletextCheckBox = UiUtil.MakeCheckBox(Se.Language.General.ShowTeletext, vm, nameof(vm.ShowTeletextColumn)); var buttonPanel = new StackPanel { @@ -102,6 +93,7 @@ public PickTeletextAlignmentWindow(PickTeletextAlignmentViewModel vm) new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), + new RowDefinition(GridLength.Auto), }, ColumnDefinitions = { @@ -125,13 +117,15 @@ public PickTeletextAlignmentWindow(PickTeletextAlignmentViewModel vm) grid.Add(replaceLineCheckBox, 3, 0); grid.Add(replaceLinePanel, 3, 1); - grid.Add(previewCheckBox, 4, 0); - grid.Add(showTeletextCheckBox, 5, 0); - grid.Add(buttonPanel, 6, 1); + // View options, separated from the opt-in edit rows above. + grid.Add(UiUtil.MakeHorizontalSeparator(margin: new Thickness(0, 2)), 4, 0, 1, 2); + grid.Add(previewCheckBox, 5, 0, 1, 2); + grid.Add(showTeletextCheckBox, 6, 0, 1, 2); + grid.Add(buttonPanel, 7, 1); Content = grid; - Activated += delegate { lineBox.Focus(); }; + UiUtil.FocusOnFirstActivation(this, lineBox); KeyDown += (_, e) => vm.OnKeyDown(e); } @@ -161,7 +155,7 @@ private static NumericUpDown MakeLineBox(decimal minimum, decimal maximum, strin Minimum = minimum, Maximum = maximum, Increment = 1, - Width = 100, + Width = 120, }; numericUpDown.Bind(NumericUpDown.ValueProperty, new Binding(propertyName) diff --git a/src/ui/Features/Shared/PickTeletextColor/PickTeletextColorViewModel.cs b/src/ui/Features/Shared/PickTeletextColor/PickTeletextColorViewModel.cs index 81cbd5800e6..3e8a175ca22 100644 --- a/src/ui/Features/Shared/PickTeletextColor/PickTeletextColorViewModel.cs +++ b/src/ui/Features/Shared/PickTeletextColor/PickTeletextColorViewModel.cs @@ -6,6 +6,7 @@ using Nikse.SubtitleEdit.Logic.Config; using System; using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; namespace Nikse.SubtitleEdit.Features.Shared.PickTeletextColor; @@ -42,6 +43,34 @@ public partial class PickTeletextColorViewModel : ObservableObject new TeletextColorItem("White", Se.Language.General.ColorWhite, Color.FromRgb(0xFF, 0xFF, 0xFF)), ]; + /// + /// The half intensity colors of teletext CLUT 1, plus gray - reachable in a full teletext + /// stream (DVB teletext, Level 2.5) through an X/26 foreground color triplet, but not in an + /// EBU STL file. The hex values are the map defaults widened the 0x11-step way, so they + /// round-trip without touching a redefinable colour map entry. + /// + public List HalfIntensityColors { get; } = + [ + new TeletextColorItem("HalfRed", Se.Language.General.ColorHalfRed, Color.FromRgb(0x77, 0x00, 0x00)), + new TeletextColorItem("HalfGreen", Se.Language.General.ColorHalfGreen, Color.FromRgb(0x00, 0x77, 0x00)), + new TeletextColorItem("HalfYellow", Se.Language.General.ColorHalfYellow, Color.FromRgb(0x77, 0x77, 0x00)), + new TeletextColorItem("HalfBlue", Se.Language.General.ColorHalfBlue, Color.FromRgb(0x00, 0x00, 0x77)), + new TeletextColorItem("HalfMagenta", Se.Language.General.ColorHalfMagenta, Color.FromRgb(0x77, 0x00, 0x77)), + new TeletextColorItem("HalfCyan", Se.Language.General.ColorHalfCyan, Color.FromRgb(0x00, 0x77, 0x77)), + new TeletextColorItem("Gray", Se.Language.General.ColorGray, Color.FromRgb(0x77, 0x77, 0x77)), + ]; + + /// + /// Level 2.5 teletext (DVB teletext): also offer the CLUT 1 half intensity colors and a + /// free color choice - the writer puts a free color in a redefinable colour map entry. + /// + public bool IsLevel25 { get; private set; } + + /// + /// True when the user asked for the full color picker instead of a palette color. + /// + public bool CustomPressed { get; private set; } + /// /// The teletext color the selected line currently uses, or null when it has no /// explicit color code ("no color" is the current state). @@ -54,6 +83,12 @@ public partial class PickTeletextColorViewModel : ObservableObject public void Initialize(string? currentText) { + Initialize(currentText, level25: false); + } + + public void Initialize(string? currentText, bool level25) + { + IsLevel25 = level25; CurrentColor = null; if (string.IsNullOrEmpty(currentText)) { @@ -67,7 +102,8 @@ public void Initialize(string? currentText) } var value = match.Groups[1].Value.Trim().TrimStart('#'); - foreach (var item in TeletextColors) + var items = level25 ? TeletextColors.Concat(HalfIntensityColors) : TeletextColors.AsEnumerable(); + foreach (var item in items) { var hex = $"{item.Color.R:X2}{item.Color.G:X2}{item.Color.B:X2}"; if (value.Equals(item.EnglishName, StringComparison.OrdinalIgnoreCase) || @@ -95,6 +131,14 @@ private void PickNoColor() Window?.Close(); } + [RelayCommand] + private void PickCustomColor() + { + CustomPressed = true; + OkPressed = true; + Window?.Close(); + } + [RelayCommand] private void Cancel() { diff --git a/src/ui/Features/Shared/PickTeletextColor/PickTeletextColorWindow.cs b/src/ui/Features/Shared/PickTeletextColor/PickTeletextColorWindow.cs index 05d13e74662..0d1468f5292 100644 --- a/src/ui/Features/Shared/PickTeletextColor/PickTeletextColorWindow.cs +++ b/src/ui/Features/Shared/PickTeletextColor/PickTeletextColorWindow.cs @@ -43,9 +43,6 @@ public PickTeletextColorWindow(PickTeletextColorViewModel vm) item)); } - var buttonCancel = UiUtil.MakeButtonCancel(vm.CancelCommand); - var panelButtons = UiUtil.MakeButtonBar(buttonCancel); - var panel = new StackPanel { Margin = UiUtil.MakeWindowMargin(), @@ -53,13 +50,45 @@ public PickTeletextColorWindow(PickTeletextColorViewModel vm) Children = { tilePanel, - panelButtons, }, }; + if (vm.IsLevel25) + { + // A full teletext stream also reaches the CLUT 1 half intensity colors, and any + // other color through a redefinable colour map entry (Level 2.5). + var halfPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 4, + }; + + foreach (var item in vm.HalfIntensityColors) + { + halfPanel.Children.Add(MakeTile( + MakeColorSwatch(item.Color, vm.CurrentColor == item.Color), + item.DisplayName, + vm.PickColorCommand, + item)); + } + + panel.Children.Add(halfPanel); + } + + var buttonCancel = UiUtil.MakeButtonCancel(vm.CancelCommand); + if (vm.IsLevel25) + { + var buttonCustom = UiUtil.MakeButton(Se.Language.General.ChooseColorDotDotDot, vm.PickCustomColorCommand); + panel.Children.Add(UiUtil.MakeButtonBar(buttonCustom, buttonCancel)); + } + else + { + panel.Children.Add(UiUtil.MakeButtonBar(buttonCancel)); + } + Content = panel; - Activated += delegate { buttonCancel.Focus(); }; + UiUtil.FocusOnFirstActivation(this, buttonCancel); KeyDown += (_, e) => vm.OnKeyDown(e); } diff --git a/src/ui/Features/Shared/PickTsTrack/PickTsTrackViewModel.cs b/src/ui/Features/Shared/PickTsTrack/PickTsTrackViewModel.cs index b98e16a02e6..9dbdedb9070 100644 --- a/src/ui/Features/Shared/PickTsTrack/PickTsTrackViewModel.cs +++ b/src/ui/Features/Shared/PickTsTrack/PickTsTrackViewModel.cs @@ -1,4 +1,4 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Input; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -9,6 +9,7 @@ using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; @@ -111,6 +112,27 @@ internal void Initialize(TransportStreamParser tsParser, string fileName) } } + /// + /// Teletext pages from a Manzanita dump, which is a single elementary stream and so has no + /// program map table to take packet ids or languages from. + /// + internal void Initialize(Dictionary> teletextPages, string fileName) + { + _fileName = fileName; + WindowTitle = string.Format(Se.Language.File.PickTransportStreamTrackX, fileName); + + foreach (var page in teletextPages) + { + Tracks.Add(new TsTrackInfoDisplay + { + TrackNumber = page.Key, + Teletext = page.Value, + Codec = "Teletext", + IsTeletext = true, + }); + } + } + private void Close() { Dispatcher.UIThread.Post(() => @@ -157,7 +179,7 @@ internal void TracksGridSelectionChanged(object? sender, SelectionChangedEventAr private bool TrackChanged() { var selectedTrack = SelectedTrack; - if (selectedTrack == null || _tsParser == null) + if (selectedTrack == null) { SubtitleCountText = string.Empty; return false; @@ -187,6 +209,12 @@ private bool TrackChanged() return true; } + if (_tsParser == null) + { + SubtitleCountText = string.Empty; + return false; + } + // GetDvbSubtitles returns null for a packet id it decoded no images for - a subtitle PID // announced by the stream is not a guarantee that anything came out of it. var subtitles = _tsParser.GetDvbSubtitles(selectedTrack.TrackNumber); diff --git a/src/ui/Features/Shared/PickTsTrack/PickTsTrackWindow.cs b/src/ui/Features/Shared/PickTsTrack/PickTsTrackWindow.cs index 84da95ae51e..293cc2c87f5 100644 --- a/src/ui/Features/Shared/PickTsTrack/PickTsTrackWindow.cs +++ b/src/ui/Features/Shared/PickTsTrack/PickTsTrackWindow.cs @@ -60,10 +60,10 @@ public PickTsTrackWindow(PickTsTrackViewModel vm) Content = grid; - Activated += delegate + UiUtil.FocusOnFirstActivation(this, () => { buttonOk.Focus(); // hack to make OnKeyDown work - }; + }); Loaded += (_, _) => vm.SelectAndScrollToRow(0); KeyDown += (_, e) => vm.OnKeyDown(e); diff --git a/src/ui/Features/Shared/PromptCheckBox/PromptCheckBoxWindow.cs b/src/ui/Features/Shared/PromptCheckBox/PromptCheckBoxWindow.cs index 6bfaa6d99b0..cd59c43eec6 100644 --- a/src/ui/Features/Shared/PromptCheckBox/PromptCheckBoxWindow.cs +++ b/src/ui/Features/Shared/PromptCheckBox/PromptCheckBoxWindow.cs @@ -51,7 +51,7 @@ public PromptCheckBoxWindow(PromptCheckBoxViewModel vm) Content = grid; - Activated += delegate { checkBox.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, checkBox); // hack to make OnKeyDown work KeyDown += (_, e) => vm.OnKeyDown(e); } } diff --git a/src/ui/Features/Shared/PromptFileSaved/PromptFileSavedWindow.cs b/src/ui/Features/Shared/PromptFileSaved/PromptFileSavedWindow.cs index e6dce5c4e19..618445a4a55 100644 --- a/src/ui/Features/Shared/PromptFileSaved/PromptFileSavedWindow.cs +++ b/src/ui/Features/Shared/PromptFileSaved/PromptFileSavedWindow.cs @@ -145,7 +145,7 @@ public PromptFileSavedWindow(PromptFileSavedViewModel vm) Content = grid; - Activated += delegate { buttonDone.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, buttonDone); // hack to make OnKeyDown work KeyDown += (s, e) => vm.OnKeyDown(e); } diff --git a/src/ui/Features/Shared/PromptTextBox/PromptTextBoxWindow.cs b/src/ui/Features/Shared/PromptTextBox/PromptTextBoxWindow.cs index c66a1d2882e..f250f58dc31 100644 --- a/src/ui/Features/Shared/PromptTextBox/PromptTextBoxWindow.cs +++ b/src/ui/Features/Shared/PromptTextBox/PromptTextBoxWindow.cs @@ -59,7 +59,7 @@ public PromptTextBoxWindow(PromptTextBoxViewModel vm) Content = grid; - Activated += delegate { textBox.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, textBox); // hack to make OnKeyDown work KeyDown += (_, e) => vm.OnKeyDown(e); } } diff --git a/src/ui/Features/Shared/SetVideoOffset/SetVideoOffsetViewModel.cs b/src/ui/Features/Shared/SetVideoOffset/SetVideoOffsetViewModel.cs index 86171353901..f956fada6e9 100644 --- a/src/ui/Features/Shared/SetVideoOffset/SetVideoOffsetViewModel.cs +++ b/src/ui/Features/Shared/SetVideoOffset/SetVideoOffsetViewModel.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; using Avalonia.Controls; using Avalonia.Input; using CommunityToolkit.Mvvm.ComponentModel; @@ -12,19 +15,35 @@ public partial class SetVideoOffsetViewModel : ObservableObject [ObservableProperty] private TimeSpan? _timeOffset; [ObservableProperty] private bool _relativeToCurrentVideoPosition; [ObservableProperty] private bool _keepTimeCodes; + [ObservableProperty] private ObservableCollection _offsetHistory; + [ObservableProperty] private VideoOffsetHistoryItem? _selectedOffsetHistoryItem; - public Window? Window { get; set; } + private const int MaxHistoryItems = 10; + + // "Apply" and "OK" hand the offset to the caller instead of the caller reading it off a + // closed dialog, so the window can stay open and be applied again with a new value. + private Action? _applyCallback; + private Action? _resetCallback; - public bool OkPressed { get; private set; } - public bool ResetPressed { get; private set; } + // Whether the current inputs have already been applied, so OK after Apply just closes. In + // "relative to current video position" mode a re-apply is not idempotent while the video + // plays: it would recompute against the moved position and silently replace the offset (and + // shift baked time codes) the user just verified. + private bool _appliedWithCurrentInput; + + public Window? Window { get; set; } public SetVideoOffsetViewModel() { + OffsetHistory = new ObservableCollection(); TimeOffset = TimeSpan.FromMilliseconds(Se.Settings.General.CurrentVideoOffsetInMs); } - public void Initialize() + public void Initialize(Action applyCallback, Action resetCallback) { + _applyCallback = applyCallback; + _resetCallback = resetCallback; + LoadHistory(); } [RelayCommand] @@ -33,6 +52,12 @@ private void SetTenHours() TimeOffset = TimeSpan.FromHours(10); } + [RelayCommand] + private void Apply() + { + ApplyCurrentOffset(); + } + [RelayCommand] private void Ok() { @@ -42,16 +67,19 @@ private void Ok() return; } - Se.Settings.General.CurrentVideoOffsetInMs = (long)Math.Round(TimeOffset.Value.TotalMilliseconds, MidpointRounding.AwayFromZero); - OkPressed = true; + if (!_appliedWithCurrentInput) + { + ApplyCurrentOffset(); + } + Window?.Close(); } [RelayCommand] private void Reset() { - ResetPressed = true; - Window?.Close(); + TimeOffset = TimeSpan.Zero; + _resetCallback?.Invoke(); } [RelayCommand] @@ -72,4 +100,115 @@ public void OnKeyDown(KeyEventArgs e) Ok(); } } -} \ No newline at end of file + + private void ApplyCurrentOffset() + { + if (TimeOffset == null || _applyCallback == null) + { + return; + } + + var offset = TimeOffset.Value; + _applyCallback(offset, RelativeToCurrentVideoPosition, KeepTimeCodes); + + // The typed offset is remembered, not the one "relative to current video position" + // computed from it - the typed one is what the user would want to pick again. + AddToHistory(offset); + + // Last: the history sync above can echo a rounded value back into TimeOffset, which + // would clear the flag again right after it was set. + _appliedWithCurrentInput = true; + } + + private void LoadHistory() + { + var msList = (Se.Settings.General.VideoOffsetHistoryInMs ?? new List()) + .Where(p => p != 0) + .Distinct() + .Take(MaxHistoryItems) + .ToList(); + + if (msList.Count == 0) + { + // SE 4 parity: the two offsets a broadcast time code almost always needs. + msList.Add((long)TimeSpan.FromHours(1).TotalMilliseconds); + msList.Add((long)TimeSpan.FromHours(10).TotalMilliseconds); + } + + OffsetHistory.Clear(); + foreach (var ms in msList) + { + OffsetHistory.Add(new VideoOffsetHistoryItem(ms)); + } + + SyncSelectedHistoryItem(); + } + + private void AddToHistory(TimeSpan offset) + { + var ms = ToMilliseconds(offset); + if (ms == 0) + { + return; // "no offset" is what Reset is for - it would only be noise in the list + } + + // A repeat of an offset already in the list moves to the top instead of doubling up. + var existing = OffsetHistory.FirstOrDefault(p => p.TotalMilliseconds == ms); + if (existing != null) + { + OffsetHistory.Remove(existing); + } + + OffsetHistory.Insert(0, existing ?? new VideoOffsetHistoryItem(ms)); + + while (OffsetHistory.Count > MaxHistoryItems) + { + OffsetHistory.RemoveAt(OffsetHistory.Count - 1); + } + + Se.Settings.General.VideoOffsetHistoryInMs = OffsetHistory.Select(p => p.TotalMilliseconds).ToList(); + + // Removing the selected item cleared the drop-down selection - put it back on the entry + // the offset field now holds. + SyncSelectedHistoryItem(); + } + + /// + /// The drop-down shows the entry matching the offset field, or nothing when the field holds + /// something else - so picking an entry the user has just typed over selects it again. + /// + private void SyncSelectedHistoryItem() + { + var ms = TimeOffset.HasValue ? ToMilliseconds(TimeOffset.Value) : 0; + SelectedOffsetHistoryItem = OffsetHistory.FirstOrDefault(p => p.TotalMilliseconds == ms); + } + + private static long ToMilliseconds(TimeSpan offset) + { + return (long)Math.Round(offset.TotalMilliseconds, MidpointRounding.AwayFromZero); + } + + partial void OnTimeOffsetChanged(TimeSpan? value) + { + _appliedWithCurrentInput = false; + SyncSelectedHistoryItem(); + } + + partial void OnRelativeToCurrentVideoPositionChanged(bool value) + { + _appliedWithCurrentInput = false; + } + + partial void OnKeepTimeCodesChanged(bool value) + { + _appliedWithCurrentInput = false; + } + + partial void OnSelectedOffsetHistoryItemChanged(VideoOffsetHistoryItem? value) + { + if (value != null) + { + TimeOffset = value.Offset; + } + } +} diff --git a/src/ui/Features/Shared/SetVideoOffset/SetVideoOffsetWindow.cs b/src/ui/Features/Shared/SetVideoOffset/SetVideoOffsetWindow.cs index 17ac75fe1de..1bab0a2f35c 100644 --- a/src/ui/Features/Shared/SetVideoOffset/SetVideoOffsetWindow.cs +++ b/src/ui/Features/Shared/SetVideoOffset/SetVideoOffsetWindow.cs @@ -1,4 +1,5 @@ using Avalonia; +using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Layout; @@ -28,6 +29,19 @@ public SetVideoOffsetWindow(SetVideoOffsetViewModel vm) } }; + // Previously used offsets - picking one fills the time code box, so switching between the + // handful of offsets a user keeps coming back to takes one click instead of retyping. + var comboBoxHistory = UiUtil.MakeComboBox(vm.OffsetHistory, vm, nameof(vm.SelectedOffsetHistoryItem)); + + // Wide enough for a time code, so the box does not shrink to an arrow stub while the + // typed offset matches no entry and nothing is selected. + comboBoxHistory.MinWidth = 130; + AutomationProperties.SetName(comboBoxHistory, Se.Language.General.RecentlyUsedVideoOffsets); + if (Se.Settings.Appearance.ShowHints) + { + ToolTip.SetTip(comboBoxHistory, Se.Language.General.RecentlyUsedVideoOffsets); + } + var panelTimeCode = new StackPanel { Orientation = Orientation.Horizontal, @@ -37,6 +51,7 @@ public SetVideoOffsetWindow(SetVideoOffsetViewModel vm) { UiUtil.MakeLabel(Se.Language.General.VideoOffset), timeCodeUpDownOffset, + comboBoxHistory, UiUtil.MakeButton(Se.Language.General.TenHours, vm.SetTenHoursCommand).WithFontSize(10), } }; @@ -51,6 +66,7 @@ public SetVideoOffsetWindow(SetVideoOffsetViewModel vm) var buttonPanel = UiUtil.MakeButtonBar( UiUtil.MakeButtonOk(vm.OkCommand), + UiUtil.MakeButton(Se.Language.General.Apply, vm.ApplyCommand), UiUtil.MakeButton(Se.Language.General.Reset, vm.ResetCommand), UiUtil.MakeButtonCancel(vm.CancelCommand)); @@ -70,7 +86,7 @@ public SetVideoOffsetWindow(SetVideoOffsetViewModel vm) Content = contentPanel; - Activated += delegate { timeCodeUpDownOffset.Focus(); }; + UiUtil.FocusOnFirstActivation(this, timeCodeUpDownOffset); KeyDown += (sender, args) => vm.OnKeyDown(args); } -} \ No newline at end of file +} diff --git a/src/ui/Features/Shared/SetVideoOffset/VideoOffsetHistoryItem.cs b/src/ui/Features/Shared/SetVideoOffset/VideoOffsetHistoryItem.cs new file mode 100644 index 00000000000..3a0e51a6b2d --- /dev/null +++ b/src/ui/Features/Shared/SetVideoOffset/VideoOffsetHistoryItem.cs @@ -0,0 +1,27 @@ +using System; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Logic.Config; + +namespace Nikse.SubtitleEdit.Features.Shared.SetVideoOffset; + +/// +/// One previously used video offset, shown in the drop-down next to the offset field. +/// The display text is built once, in the same format the time code box next to it uses. +/// +public class VideoOffsetHistoryItem +{ + public long TotalMilliseconds { get; } + public TimeSpan Offset { get; } + public string DisplayText { get; } + + public VideoOffsetHistoryItem(long totalMilliseconds) + { + TotalMilliseconds = totalMilliseconds; + Offset = TimeSpan.FromMilliseconds(totalMilliseconds); + + var timeCode = new TimeCode(totalMilliseconds); + DisplayText = Se.Settings.General.UseFrameMode ? timeCode.ToHHMMSSFF() : timeCode.ToString(); + } + + public override string ToString() => DisplayText; +} diff --git a/src/ui/Features/Shared/ShowImage/ShowImageWindow.cs b/src/ui/Features/Shared/ShowImage/ShowImageWindow.cs index e207b45b660..009b89d294f 100644 --- a/src/ui/Features/Shared/ShowImage/ShowImageWindow.cs +++ b/src/ui/Features/Shared/ShowImage/ShowImageWindow.cs @@ -54,7 +54,7 @@ public ShowImageWindow(ShowImageViewModel vm) var label = UiUtil.MakeLabel().WithBindText(vm, nameof(vm.Text)).WithFontSize(30).WithMarginTop(20).WithAlignmentCenter(); if (!string.IsNullOrEmpty(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName)) { - label.FontFamily = new FontFamily(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); + label.FontFamily = FontFamilyHelper.Make(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); } var buttonOk = UiUtil.MakeButtonOk(vm.OkCommand); diff --git a/src/ui/Features/Shared/SourceView/SourceViewViewModel.cs b/src/ui/Features/Shared/SourceView/SourceViewViewModel.cs index 0cbacf7f593..802ca7878c8 100644 --- a/src/ui/Features/Shared/SourceView/SourceViewViewModel.cs +++ b/src/ui/Features/Shared/SourceView/SourceViewViewModel.cs @@ -653,18 +653,18 @@ private async Task Ok() _subtitleFormat.LoadSubtitle(subtitle, lines, string.Empty); if (subtitle.Paragraphs.Count > 0) { - Subtitle.Paragraphs.Clear(); - Subtitle.Paragraphs.AddRange(subtitle.Paragraphs); + ApplyParsedSubtitle(subtitle); OkPressed = true; Window?.Close(); return; } + // Parse returns NULL when no format matches - typing prose into the source view and + // pressing OK threw a NullReferenceException instead of reaching the message below. subtitle = Subtitle.Parse(lines, ".srt"); - if (subtitle.Paragraphs.Count > 0) + if (subtitle != null && subtitle.Paragraphs.Count > 0) { - Subtitle.Paragraphs.Clear(); - Subtitle.Paragraphs.AddRange(subtitle.Paragraphs); + ApplyParsedSubtitle(subtitle); OkPressed = true; Window?.Close(); return; @@ -673,6 +673,26 @@ private async Task Ok() await MessageBox.Show(Window, Se.Language.General.Error, Se.Language.General.NoSubtitlesFound, MessageBoxButtons.OK, MessageBoxIcon.Error); } + /// + /// Copies the parsed result back over the subtitle the caller holds - header and footer + /// included. Copying only the paragraphs silently discarded any edit the user made to an + /// ASSA [Script Info] / [V4+ Styles] block while accepting their dialogue edits. + /// + private void ApplyParsedSubtitle(Subtitle parsed) + { + Subtitle.Paragraphs.Clear(); + Subtitle.Paragraphs.AddRange(parsed.Paragraphs); + if (!string.IsNullOrEmpty(parsed.Header)) + { + Subtitle.Header = parsed.Header; + } + + if (!string.IsNullOrEmpty(parsed.Footer)) + { + Subtitle.Footer = parsed.Footer; + } + } + private static string TrimJunk(string text) { if (string.IsNullOrEmpty(text)) diff --git a/src/ui/Features/Shared/TextBoxUtils/TextBoxWrapper.cs b/src/ui/Features/Shared/TextBoxUtils/TextBoxWrapper.cs index 3b6197d6eb4..dcd21015b19 100644 --- a/src/ui/Features/Shared/TextBoxUtils/TextBoxWrapper.cs +++ b/src/ui/Features/Shared/TextBoxUtils/TextBoxWrapper.cs @@ -39,22 +39,27 @@ public string SelectedText set => _textBox.SelectedText = value; } + // Avalonia's SelectionStart is the ANCHOR and SelectionEnd the moving caret end, so a + // right-to-left selection (Shift+Left, or dragging leftwards) leaves End < Start. Reporting + // those raw gave a NEGATIVE SelectionLength, and callers that test "> 0" - Option+Backspace + // and Cmd+Backspace in the edit box - then took the no-selection path and deleted the wrong + // text. The other implementation of this interface (SyntaxTextView) normalizes with Min/Max. public int SelectionStart { - get => _textBox.SelectionStart; + get => Math.Min(_textBox.SelectionStart, _textBox.SelectionEnd); set => _textBox.SelectionStart = value; } public int SelectionLength { - get => _textBox.SelectionEnd - _textBox.SelectionStart; - set => _textBox.SelectionEnd = _textBox.SelectionStart + value; + get => Math.Abs(_textBox.SelectionEnd - _textBox.SelectionStart); + set => _textBox.SelectionEnd = SelectionStart + value; } - + public int SelectionEnd { - get => _textBox.SelectionEnd; - set => _textBox.SelectionEnd = value; + get => Math.Max(_textBox.SelectionStart, _textBox.SelectionEnd); + set => _textBox.SelectionEnd = value; } public void Select(int start, int length) diff --git a/src/ui/Features/Shared/Undocked/VideoPlayerUndockedViewModel.cs b/src/ui/Features/Shared/Undocked/VideoPlayerUndockedViewModel.cs index f0c1904447b..80e2b9de1d7 100644 --- a/src/ui/Features/Shared/Undocked/VideoPlayerUndockedViewModel.cs +++ b/src/ui/Features/Shared/Undocked/VideoPlayerUndockedViewModel.cs @@ -179,6 +179,12 @@ internal void Onloaded(object? sender, RoutedEventArgs e) if (!string.IsNullOrEmpty(_originalVideoFileName)) { + // Announced here rather than left to Open below: this control is already published + // as the undocked video player, so a rebuild landing before the posted open has run + // (Settings -> Apply followed by OK) would read the live position of a player that + // has not been given a file yet - 0 - and rewind the video (issue #14218). + videoPlayerControl.BeginPositionRestore(_originalPosition); + Dispatcher.UIThread.Post(async () => { await Task.Delay(100); @@ -199,6 +205,12 @@ internal void Onloaded(object? sender, RoutedEventArgs e) videoPlayerControl.Volume = _originalVolume; videoPlayerControl.Position = _originalPosition; + // The player is where it belongs - a rebuild from here on can read the live + // position again (issue #14218). If it is not there yet (mpv still loading), the + // target stays until the position tick sees the player arrive; handing a rebuild + // the 0 of a player that never got there is the rewind this guards against. + videoPlayerControl.EndPositionRestoreIfArrived(); + // Undocking opens the video in a new player, which starts on mpv's default audio // track - re-apply the track the user picked in the main window (issue #12844). MainViewModel?.ReapplySelectedAudioTrack(videoPlayerControl); diff --git a/src/ui/Features/Shared/WaveformGuessTimeCodes/WaveformGuessTimeCodesViewModel.cs b/src/ui/Features/Shared/WaveformGuessTimeCodes/WaveformGuessTimeCodesViewModel.cs index 3be881f0ec2..2e9899df976 100644 --- a/src/ui/Features/Shared/WaveformGuessTimeCodes/WaveformGuessTimeCodesViewModel.cs +++ b/src/ui/Features/Shared/WaveformGuessTimeCodes/WaveformGuessTimeCodesViewModel.cs @@ -71,6 +71,12 @@ private void LoadSettings() private void SaveSettings() { + var s = Se.Settings.Waveform; + s.GuessTimeCodeStartFromBeginning = StartFromBeginning; + s.GuessTimeCodeScanBlockSize = ScanBlockSize ?? 100; + s.GuessTimeCodeScanBlockAverageMin = ScanBlockAverageMin ?? 35; + s.GuessTimeCodeScanBlockAverageMax = ScanBlockAverageMax ?? 70; + s.GuessTimeCodeSplitLongSubtitlesAtMs = SplitLongSubtitlesAtMs ?? 3500; Se.SaveSettings(); } diff --git a/src/ui/Features/Shared/WaveformGuessTimeCodes/WaveformGuessTimeCodesWindow.cs b/src/ui/Features/Shared/WaveformGuessTimeCodes/WaveformGuessTimeCodesWindow.cs index ffd0d384eda..2866a457a12 100644 --- a/src/ui/Features/Shared/WaveformGuessTimeCodes/WaveformGuessTimeCodesWindow.cs +++ b/src/ui/Features/Shared/WaveformGuessTimeCodes/WaveformGuessTimeCodesWindow.cs @@ -1,5 +1,6 @@ using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Primitives; using Avalonia.Layout; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; @@ -7,8 +8,17 @@ namespace Nikse.SubtitleEdit.Features.Shared.WaveformGuessTimeCodes; +/// +/// The three option boxes used to be stacked in one column, which made the window tall enough +/// that a high UI scale (or a small working area) pushed it past the screen - it is then clamped +/// to the working area, and the clamp cut off the bottom options and the OK/Cancel buttons. The +/// boxes are laid out in two columns so the window fits at a high scale, and the options scroll +/// with the buttons pinned below them for the cases where it still does not. +/// public class WaveformGuessTimeCodesWindow : Window { + internal const string OptionsScrollViewerName = "OptionsScrollViewer"; + public WaveformGuessTimeCodesWindow(WaveformGuessTimeCodesViewModel vm) { UiUtil.InitializeWindow(this, GetType().Name); @@ -22,13 +32,53 @@ public WaveformGuessTimeCodesWindow(WaveformGuessTimeCodesViewModel vm) var buttonCancel = UiUtil.MakeButtonCancel(vm.CancelCommand); var panelButtons = UiUtil.MakeButtonBar(buttonOk, buttonCancel); - var grid = new Grid + // Two columns: the two short "which lines" boxes stacked on the left, the taller settings + // box on the right - about half the height of one stacked column, which is what keeps the + // window on screen at a high UI scale. + var panelLeft = new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 10, + VerticalAlignment = VerticalAlignment.Top, + Children = + { + MakeStartFromView(vm, out var radioStartFromVideoPosition), + MakeDeleteLinesView(vm), + }, + }; + + var panelOptions = new Grid { RowDefinitions = { new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + }, + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + }, + ColumnSpacing = 10, + }; + panelOptions.Add(panelLeft, 0); + var borderDetectOptions = MakeDetectOptionsView(vm); + borderDetectOptions.VerticalAlignment = VerticalAlignment.Top; + panelOptions.Add(borderDetectOptions, 0, 1); + + // Keeps the OK/Cancel buttons reachable when the window is clamped to the working area. + var scrollViewer = new ScrollViewer + { + Name = OptionsScrollViewerName, + Content = panelOptions, + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + }; + + var grid = new Grid + { + RowDefinitions = + { + new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, }, ColumnDefinitions = @@ -42,14 +92,12 @@ public WaveformGuessTimeCodesWindow(WaveformGuessTimeCodesViewModel vm) HorizontalAlignment = HorizontalAlignment.Stretch, }; - grid.Add(MakeStartFromView(vm, out var radioStartFromVideoPosition), 0); - grid.Add(MakeDeleteLinesView(vm), 1); - grid.Add(MakeDetectOptionsView(vm), 2); - grid.Add(panelButtons, 3); + grid.Add(scrollViewer, 0); + grid.Add(panelButtons, 1); Content = grid; - Activated += delegate { radioStartFromVideoPosition.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, radioStartFromVideoPosition); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += vm.KeyDown; } diff --git a/src/ui/Features/Shared/WaveformSeekSilence/WaveformSeekSilenceWindow.cs b/src/ui/Features/Shared/WaveformSeekSilence/WaveformSeekSilenceWindow.cs index 8c6e76e083b..fef96971594 100644 --- a/src/ui/Features/Shared/WaveformSeekSilence/WaveformSeekSilenceWindow.cs +++ b/src/ui/Features/Shared/WaveformSeekSilence/WaveformSeekSilenceWindow.cs @@ -67,7 +67,7 @@ public WaveformSeekSilenceWindow(WaveformSeekSilenceViewModel vm) Content = grid; - Activated += delegate { checkBoxSeekForward.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, checkBoxSeekForward); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += vm.KeyDown; } } diff --git a/src/ui/Features/SpellCheck/EditWholeText/EditWholeTextWindow.cs b/src/ui/Features/SpellCheck/EditWholeText/EditWholeTextWindow.cs index f720158b493..ae45f7d3e90 100644 --- a/src/ui/Features/SpellCheck/EditWholeText/EditWholeTextWindow.cs +++ b/src/ui/Features/SpellCheck/EditWholeText/EditWholeTextWindow.cs @@ -39,7 +39,7 @@ public EditWholeTextWindow(EditWholeTextViewModel vm) }; if (!string.IsNullOrEmpty(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName)) { - textBoxWholeText.FontFamily = new FontFamily(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); + textBoxWholeText.FontFamily = FontFamilyHelper.Make(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); } var buttonOk = UiUtil.MakeButtonOk(vm.OkCommand); @@ -69,7 +69,7 @@ public EditWholeTextWindow(EditWholeTextViewModel vm) Content = grid; - Activated += delegate { textBoxWholeText.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, textBoxWholeText); // hack to make OnKeyDown work KeyDown += (_, e) => vm.OnKeyDown(e); } } diff --git a/src/ui/Features/SpellCheck/FindDoubleLines/FindDoubleLinesWindow.cs b/src/ui/Features/SpellCheck/FindDoubleLines/FindDoubleLinesWindow.cs index 5b7c3531196..18cf5273700 100644 --- a/src/ui/Features/SpellCheck/FindDoubleLines/FindDoubleLinesWindow.cs +++ b/src/ui/Features/SpellCheck/FindDoubleLines/FindDoubleLinesWindow.cs @@ -50,7 +50,7 @@ public FindDoubleLinesWindow(FindDoubleLinesViewModel vm) Content = grid; - Activated += delegate { buttonCancel.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, buttonCancel); // hack to make OnKeyDown work KeyDown += (s, e) => vm.OnKeyDown(e); diff --git a/src/ui/Features/SpellCheck/FindDoubleWords/FindDoubleWordsWindow.cs b/src/ui/Features/SpellCheck/FindDoubleWords/FindDoubleWordsWindow.cs index fa656340187..035793ad773 100644 --- a/src/ui/Features/SpellCheck/FindDoubleWords/FindDoubleWordsWindow.cs +++ b/src/ui/Features/SpellCheck/FindDoubleWords/FindDoubleWordsWindow.cs @@ -50,7 +50,7 @@ public FindDoubleWordsWindow(FindDoubleWordsViewModel vm) Content = grid; - Activated += delegate { buttonCancel.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, buttonCancel); // hack to make OnKeyDown work KeyDown += (s, e) => vm.OnKeyDown(e); diff --git a/src/ui/Features/SpellCheck/GetDictionaries/GetDictionariesWindow.cs b/src/ui/Features/SpellCheck/GetDictionaries/GetDictionariesWindow.cs index 27bdeadbc36..26e7467b3aa 100644 --- a/src/ui/Features/SpellCheck/GetDictionaries/GetDictionariesWindow.cs +++ b/src/ui/Features/SpellCheck/GetDictionaries/GetDictionariesWindow.cs @@ -27,9 +27,8 @@ public GetDictionariesWindow(GetDictionariesViewModel vm) DataContext = vm; var downloadButton = UiUtil.MakeButton(string.Empty, vm.DownloadCommand) - .WithIconLeft(IconNames.Download) + .WithIconLeftBindText(IconNames.Download, nameof(vm.DownloadButtonText)) .WithBindEnabled(nameof(vm.IsDownloadEnabled)); - downloadButton.Bind(ContentControl.ContentProperty, new Binding(nameof(vm.DownloadButtonText))); var stack = new StackPanel { @@ -53,7 +52,7 @@ public GetDictionariesWindow(GetDictionariesViewModel vm) Padding = new Thickness(4), }; - Activated += delegate { downloadButton.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, downloadButton); // hack to make OnKeyDown work KeyDown += (_, e) => vm.OnKeyDown(e); } diff --git a/src/ui/Features/SpellCheck/SpellCheckCompletedWindow.cs b/src/ui/Features/SpellCheck/SpellCheckCompletedWindow.cs index 5d7c6f49ab5..7db8f9ef59a 100644 --- a/src/ui/Features/SpellCheck/SpellCheckCompletedWindow.cs +++ b/src/ui/Features/SpellCheck/SpellCheckCompletedWindow.cs @@ -107,7 +107,7 @@ public SpellCheckCompletedWindow(SpellCheckCompletedViewModel vm) Content = grid; - Activated += delegate { buttonDone.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, buttonDone); // hack to make OnKeyDown work KeyDown += (_, e) => vm.OnKeyDown(e); } diff --git a/src/ui/Features/SpellCheck/SpellCheckManager.cs b/src/ui/Features/SpellCheck/SpellCheckManager.cs index d88382cfec9..cd40ed17c2c 100644 --- a/src/ui/Features/SpellCheck/SpellCheckManager.cs +++ b/src/ui/Features/SpellCheck/SpellCheckManager.cs @@ -1,4 +1,4 @@ -using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Features.Main; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -118,6 +118,9 @@ public void ChangeAllWord(string fromWord, string toWord, SpellCheckWord spellCh if (!ChangeAllDictionary.ContainsKey(fromWord)) { ChangeAllDictionary.Add(fromWord, toWord); + // Mirror of RemoveChangeAllWord's UseAlwaysListRemove - without this the pair + // lived for the session only and the on-disk list could never grow. + WordLists?.UseAlwaysListAdd(fromWord, toWord); } ChangeWord(fromWord, toWord, spellCheckWord, p); diff --git a/src/ui/Features/SpellCheck/SpellCheckViewModel.cs b/src/ui/Features/SpellCheck/SpellCheckViewModel.cs index 2de74f67f8e..e55111482e9 100644 --- a/src/ui/Features/SpellCheck/SpellCheckViewModel.cs +++ b/src/ui/Features/SpellCheck/SpellCheckViewModel.cs @@ -1,4 +1,4 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Controls.Documents; using Avalonia.Input; using Avalonia.Media; @@ -56,11 +56,15 @@ public partial class SpellCheckViewModel : ObservableObject, IClosingCleanup [ObservableProperty] private bool _areSuggestionsAvailable; [ObservableProperty] private bool _isPrompting; [ObservableProperty] private ObservableCollection _paragraphs; - [ObservableProperty] private SubtitleLineViewModel? _selectedParagraph; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(PlayCurrentLineCommand))] + private SubtitleLineViewModel? _selectedParagraph; [ObservableProperty] private Bitmap? _sourceImage; [ObservableProperty] private bool _hasSourceImage; [ObservableProperty] private bool _isUndoVisible; [ObservableProperty] private string _undoText; + [ObservableProperty] private bool _isPlayVisible; public Window? Window { get; set; } public int TotalChangedWords { get; set; } @@ -106,6 +110,12 @@ public partial class SpellCheckViewModel : ObservableObject, IClosingCleanup // "continue spell check from current line?" has something to continue from. private bool _sessionInProgress; + // Video preview hooks handed in by the caller - they drive the main window's video player. + // Null when no video is loaded; the play button is then hidden. + private Action? _playLine; + private Action? _stopPlayback; + private bool _hasPlayed; + public SpellCheckViewModel(ISpellCheckManager spellCheckManager, IWindowService windowService, IFileHelper fileHelper, IBluRayHelper bluRayHelper, IOcrImageSourceHolder ocrImageSourceHolder) { _spellCheckManager = spellCheckManager; @@ -350,7 +360,7 @@ private async Task LoadSourceImage() } var subtitles = tsParser.GetDvbSubtitles(tsParser.SubtitlePacketIds[0]); - return subtitles.Count > 0 ? new OcrSubtitleTransportStream(tsParser, subtitles, fileName) : null; + return subtitles.Count > 0 ? new OcrSubtitleTransportStream(subtitles) : null; case ".mkv": case ".mks": @@ -431,11 +441,22 @@ private void UpdateSourceImage() previous?.Dispose(); } + /// + /// Starts the spell check. plays a line in the main video player + /// and pauses at its end, so a word can be checked against the audio - the reason to have it + /// here is STT output, where only the audio says what the word should be (issue #14145). Pass + /// null (no video loaded) to hide the play button. stops such + /// a preview when the window closes - only ever called when this window started playback. + /// public void Initialize(ObservableCollection paragraphs, int? selectedSubtitleIndex, - IFocusSubtitleLine focusSubtitleLine, SpellCheckDictionaryDisplay? spellCheckDictionary, bool sessionInProgress = false) + IFocusSubtitleLine focusSubtitleLine, SpellCheckDictionaryDisplay? spellCheckDictionary, bool sessionInProgress = false, + Action? playLine = null, Action? stopPlayback = null) { _focusSubtitleLine = focusSubtitleLine; _sessionInProgress = sessionInProgress; + _playLine = playLine; + _stopPlayback = stopPlayback; + IsPlayVisible = playLine != null; Paragraphs.Clear(); Paragraphs.AddRange(paragraphs); SetLanguage(spellCheckDictionary); @@ -922,6 +943,36 @@ private void Ok() Dispatcher.UIThread.Invoke(() => { Window?.Close(); }); } + /// + /// Plays the line the current word belongs to in the main video player and pauses at its end, + /// so an unknown word can be judged by ear without leaving the spell check (issue #14145). + /// + [RelayCommand(CanExecute = nameof(CanPlayCurrentLine))] + private void PlayCurrentLine() + { + var paragraph = SelectedParagraph; + if (paragraph == null || _playLine == null) + { + return; + } + + _hasPlayed = true; + _playLine(paragraph); + } + + private bool CanPlayCurrentLine() => SelectedParagraph != null; + + /// + /// True when the pressed keys match the user's main-window "play selected lines" (default F5) + /// or second play/pause (default Ctrl/Cmd+Space) binding. Bare Space is deliberately not + /// included - it types a space in the word text box. + /// + private static bool MatchesPlayShortcut(KeyEventArgs e) + { + return MainShortcutKeys.Matches(e, nameof(MainViewModel.PlaySelectedLinesWithoutLoopCommand), [nameof(Key.F5)]) || + MainShortcutKeys.Matches(e, nameof(MainViewModel.TogglePlayPause2Command), [MainShortcutKeys.CtrlOrCmd, nameof(Key.Space)]); + } + internal void OnKeyDown(KeyEventArgs e) { if (e.Key == Key.Escape) @@ -934,6 +985,11 @@ internal void OnKeyDown(KeyEventArgs e) e.Handled = true; UiUtil.ShowHelp("features/spell-check"); } + else if (IsPlayVisible && MatchesPlayShortcut(e)) + { + e.Handled = true; + PlayCurrentLine(); + } } private bool CanUndo() => _undoList.Count > 0; @@ -1116,7 +1172,7 @@ private void HighLightCurrentWord(SpellCheckWord word, SubtitleLineViewModel par var fontName = Se.Settings.Appearance.SubtitleTextBoxAndGridFontName; if (!string.IsNullOrEmpty(fontName)) { - textBlock.FontFamily = new FontFamily(fontName); + textBlock.FontFamily = FontFamilyHelper.Make(fontName); } var idx = word.Index; if (idx > 0) @@ -1124,7 +1180,7 @@ private void HighLightCurrentWord(SpellCheckWord word, SubtitleLineViewModel par var run = new Run(paragraph.Text.Substring(0, idx)); if (!string.IsNullOrEmpty(fontName)) { - run.FontFamily = new FontFamily(fontName); + run.FontFamily = FontFamilyHelper.Make(fontName); } textBlock.Inlines!.Add(run); } @@ -1137,7 +1193,7 @@ private void HighLightCurrentWord(SpellCheckWord word, SubtitleLineViewModel par }; if (!string.IsNullOrEmpty(fontName)) { - highlightRun.FontFamily = new FontFamily(fontName); + highlightRun.FontFamily = FontFamilyHelper.Make(fontName); } textBlock.Inlines!.Add(highlightRun); @@ -1146,7 +1202,7 @@ private void HighLightCurrentWord(SpellCheckWord word, SubtitleLineViewModel par var run = new Run(paragraph.Text.Substring(idx + word.Text.Length)); if (!string.IsNullOrEmpty(fontName)) { - run.FontFamily = new FontFamily(fontName); + run.FontFamily = FontFamilyHelper.Make(fontName); } textBlock.Inlines!.Add(run); } @@ -1171,6 +1227,13 @@ public void OnClosingCleanup() { _statusTimer?.StopAndDispose(StatusTimerElapsed); _statusTimer = null; + + // Only stop what this window started - a video the user left playing before opening the + // spell check should keep playing. + if (_hasPlayed) + { + _stopPlayback?.Invoke(); + } } private void StatusTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e) diff --git a/src/ui/Features/SpellCheck/SpellCheckWindow.cs b/src/ui/Features/SpellCheck/SpellCheckWindow.cs index 825dc2d8420..b54143eafbe 100644 --- a/src/ui/Features/SpellCheck/SpellCheckWindow.cs +++ b/src/ui/Features/SpellCheck/SpellCheckWindow.cs @@ -27,6 +27,30 @@ public SpellCheckWindow(SpellCheckViewModel vm) [!Label.ContentProperty] = new Binding(nameof(SpellCheckViewModel.LineText)) { Mode = BindingMode.OneWay } }; + // Plays the line the current word is in, in the main window's video player, and pauses at + // its end - hidden when no video is loaded (the view model then gets no play hook). Mainly + // for STT output, where only the audio says what an unknown word should be (#14145). + var buttonPlay = UiUtil.MakeButton(Se.Language.General.PlayCurrent, vm.PlayCurrentLineCommand) + .WithIconLeft("fa-solid fa-play"); + buttonPlay.VerticalAlignment = VerticalAlignment.Bottom; + buttonPlay.Margin = new Thickness(10, 0, 0, 2); + buttonPlay.Bind(Visual.IsVisibleProperty, new Binding(nameof(SpellCheckViewModel.IsPlayVisible))); + if (Se.Settings.Appearance.ShowHints) + { + ToolTip.SetTip(buttonPlay, Se.Language.SpellCheck.PlayCurrentLineHint); + } + + var panelLine = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Bottom, + Children = + { + labelLine, + buttonPlay, + } + }; + var buttonEditWholeText = UiUtil.MakeButton(vm.EditWholeTextCommand, IconNames.Pencil, Se.Language.SpellCheck.EditWholeText); buttonEditWholeText.HorizontalAlignment = HorizontalAlignment.Right; buttonEditWholeText.VerticalAlignment = VerticalAlignment.Top; @@ -89,7 +113,7 @@ public SpellCheckWindow(SpellCheckViewModel vm) }; - grid.Add(labelLine, 0, 0); + grid.Add(panelLine, 0, 0); grid.Add(borderWholeText, 1, 0); grid.Add(panelButtons, 2, 0); grid.Add(buttonEditWholeText, 2, 0); @@ -125,7 +149,7 @@ public SpellCheckWindow(SpellCheckViewModel vm) Content = grid; - Activated += delegate { buttonDone.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, buttonDone); // hack to make OnKeyDown work KeyDown += (_, e) => vm.OnKeyDown(e); } @@ -147,7 +171,7 @@ private static Grid MakeWordNotFound(SpellCheckViewModel vm) vm.TextBoxWordNotFound = textBoxWord; if (!string.IsNullOrEmpty(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName)) { - textBoxWord.FontFamily = new FontFamily(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); + textBoxWord.FontFamily = FontFamilyHelper.Make(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); } var buttonChange = new Button diff --git a/src/ui/Features/Ssa/SsaAttachmentsViewModel.cs b/src/ui/Features/Ssa/SsaAttachmentsViewModel.cs index bec738b7826..77680968828 100644 --- a/src/ui/Features/Ssa/SsaAttachmentsViewModel.cs +++ b/src/ui/Features/Ssa/SsaAttachmentsViewModel.cs @@ -114,8 +114,12 @@ private async Task FileImport() return; } + // Parse returns null when the picked file is not Sub Station Alpha (an empty file, a + // renamed .ass, a broken header) - dereferencing it crashed the dialog. var subtitle = Subtitle.Parse(fileName, new SubStationAlpha()); - var attachments = ListAttachments(subtitle.Footer.SplitToLines() ?? []); + var attachments = subtitle == null + ? new List() + : ListAttachments(subtitle.Footer.SplitToLines() ?? []); if (attachments.Count == 0) { await MessageBox.Show(Window, Se.Language.General.Error, Se.Language.Assa.NoAttachmentsFound, MessageBoxButtons.OK, MessageBoxIcon.Error); diff --git a/src/ui/Features/Ssa/SsaAttachmentsWindow.cs b/src/ui/Features/Ssa/SsaAttachmentsWindow.cs index 180a674b82d..eb89d307c28 100644 --- a/src/ui/Features/Ssa/SsaAttachmentsWindow.cs +++ b/src/ui/Features/Ssa/SsaAttachmentsWindow.cs @@ -70,7 +70,7 @@ public SsaAttachmentsWindow(SsaAttachmentsViewModel vm) Content = grid; // initial focus on an input, not an action button - a focused button clicks on bare Space - Activated += delegate { TableViewExtras.FocusRow(attachmentsGrid); }; + UiUtil.FocusOnFirstActivation(this, () => { TableViewExtras.FocusRow(attachmentsGrid); }); KeyDown += vm.KeyDown; Closing += delegate { UiUtil.SaveWindowPosition(this); }; diff --git a/src/ui/Features/Ssa/SsaPropertiesWindow.cs b/src/ui/Features/Ssa/SsaPropertiesWindow.cs index 5da03ade707..19a9f028d91 100644 --- a/src/ui/Features/Ssa/SsaPropertiesWindow.cs +++ b/src/ui/Features/Ssa/SsaPropertiesWindow.cs @@ -54,7 +54,7 @@ public SsaPropertiesWindow(SsaPropertiesViewModel vm) Content = grid; // initial focus on an input, not an action button - a focused button clicks on bare Space - Activated += delegate { textBoxTitle.Focus(); }; + UiUtil.FocusOnFirstActivation(this, textBoxTitle); KeyDown += vm.KeyDown; } diff --git a/src/ui/Features/Ssa/SsaStylesViewModel.cs b/src/ui/Features/Ssa/SsaStylesViewModel.cs index 0f2975ee07e..0004c518070 100644 --- a/src/ui/Features/Ssa/SsaStylesViewModel.cs +++ b/src/ui/Features/Ssa/SsaStylesViewModel.cs @@ -782,14 +782,17 @@ private void UpdatePreview() if (style.BorderStyle.Style == BorderStyleType.BoxPerLine) { + // The other two branches resolve the libass face name to a Skia family and pass + // (outlineColor, shadowColor) in that order; this one used the raw face name and had + // the two colors the wrong way round, so the shadow came out in the outline color. bitmap = TextToImageGenerator.GenerateImageWithPadding( text, - style.FontName, + libAssFontName, fontSize, style.Bold, style.ColorPrimary.ToSKColor(), - style.ColorShadow.ToSKColor(), style.ColorOutline.ToSKColor(), + style.ColorShadow.ToSKColor(), style.ColorOutline.ToSKColor(), 0, (float)style.ShadowWidth, @@ -799,8 +802,10 @@ private void UpdatePreview() if (style.ShadowWidth > 0) { - bitmap = TextToImageGenerator.AddShadowToBitmap(bitmap, + var withShadow = TextToImageGenerator.AddShadowToBitmap(bitmap, (int)Math.Round(style.ShadowWidth, MidpointRounding.AwayFromZero), style.ColorShadow.ToSKColor()); + bitmap.Dispose(); + bitmap = withShadow; } } else if (style.BorderStyle.Style == BorderStyleType.OneBox) @@ -840,8 +845,11 @@ private void UpdatePreview() isStrikeout: style.Strikeout); } - var frame = TextToImageGenerator.ComposeOnPreviewFrame(bitmap, GetAlignment(style), style.MarginLeft, style.MarginRight, style.MarginVertical); + // A 500 ms timer re-renders this for as long as the dialog is open, and ToAvaloniaBitmap + // copies the pixels out, so both native bitmaps must go or the preview leaks ~1 MB a tick. + using var frame = TextToImageGenerator.ComposeOnPreviewFrame(bitmap, GetAlignment(style), style.MarginLeft, style.MarginRight, style.MarginVertical); ImagePreview = frame.ToAvaloniaBitmap(); + bitmap.Dispose(); } private static int GetPlayResY(string? header) diff --git a/src/ui/Features/Ssa/SsaStylesWindow.cs b/src/ui/Features/Ssa/SsaStylesWindow.cs index 4092e3facb6..3fe1a098aa9 100644 --- a/src/ui/Features/Ssa/SsaStylesWindow.cs +++ b/src/ui/Features/Ssa/SsaStylesWindow.cs @@ -65,7 +65,7 @@ public SsaStylesWindow(SsaStylesViewModel vm) Content = grid; // initial focus on an input, not an action button - a focused button clicks on bare Space - Activated += delegate { TableViewExtras.FocusRow(vm.FileStyleGrid); }; + UiUtil.FocusOnFirstActivation(this, () => { TableViewExtras.FocusRow(vm.FileStyleGrid); }); KeyDown += vm.KeyDown; Closing += delegate { UiUtil.SaveWindowPosition(this); }; diff --git a/src/ui/Features/Sync/ChangeFrameRate/ChangeFrameRateViewModel.cs b/src/ui/Features/Sync/ChangeFrameRate/ChangeFrameRateViewModel.cs index 88f420d0542..895fc81a1d9 100644 --- a/src/ui/Features/Sync/ChangeFrameRate/ChangeFrameRateViewModel.cs +++ b/src/ui/Features/Sync/ChangeFrameRate/ChangeFrameRateViewModel.cs @@ -181,11 +181,29 @@ internal static double GetFrameRateRatio(double fromFrameRate, double toFrameRat internal static void ChangeFrameRate(ObservableCollection subtitles, double fromFrameRate, double toFrameRate) { double ratio = GetFrameRateRatio(fromFrameRate, toFrameRate); + SubtitleLineViewModel? previous = null; + var previousOriginalEndMs = 0d; foreach (var line in subtitles) { - line.SetTimes( - TimeSpan.FromMilliseconds(line.StartTime.TotalMilliseconds * ratio), - TimeSpan.FromMilliseconds(line.EndTime.TotalMilliseconds * ratio)); + var originalStartMs = line.StartTime.TotalMilliseconds; + var originalEndMs = line.EndTime.TotalMilliseconds; + + // Round to whole milliseconds via start + scaled duration, not start and end + // independently, so lines of equal length keep equal durations after scaling (#14056). + var newStart = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(originalStartMs * ratio); + var newDuration = TimeSpanExtensions.FromMillisecondsWholeMilliseconds((originalEndMs - originalStartMs) * ratio); + line.SetTimes(newStart, newStart + newDuration); + + // The two roundings can land the previous line's end 1 ms past this line's start, + // turning a clean join into an overlap the source never had. Clip the previous end + // back; overlaps that were already in the source are left as they were. + if (previous != null && previousOriginalEndMs <= originalStartMs && previous.EndTime > line.StartTime) + { + previous.SetTimes(previous.StartTime, line.StartTime); + } + + previous = line; + previousOriginalEndMs = originalEndMs; } } } \ No newline at end of file diff --git a/src/ui/Features/Sync/ChangeFrameRate/ChangeFrameRateWindow.cs b/src/ui/Features/Sync/ChangeFrameRate/ChangeFrameRateWindow.cs index 676aa5aaefd..f05c4f37c4b 100644 --- a/src/ui/Features/Sync/ChangeFrameRate/ChangeFrameRateWindow.cs +++ b/src/ui/Features/Sync/ChangeFrameRate/ChangeFrameRateWindow.cs @@ -93,7 +93,7 @@ public ChangeFrameRateWindow(ChangeFrameRateViewModel vm) Content = grid; - Activated += delegate { comboFromFrameRate.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, comboFromFrameRate); // initial focus on an input, not an action button - a focused button clicks on bare Space Loaded += (_, _) => UiUtil.RestoreWindowPosition(this); Closing += (_, _) => UiUtil.SaveWindowPosition(this); KeyDown += (_, e) => vm.OnKeyDown(e); diff --git a/src/ui/Features/Sync/ChangeSpeed/ChangeSpeedViewModel.cs b/src/ui/Features/Sync/ChangeSpeed/ChangeSpeedViewModel.cs index fa29ceea1d9..2d7b106321e 100644 --- a/src/ui/Features/Sync/ChangeSpeed/ChangeSpeedViewModel.cs +++ b/src/ui/Features/Sync/ChangeSpeed/ChangeSpeedViewModel.cs @@ -47,7 +47,9 @@ private void SetFromDropFrameValue() [RelayCommand] private void SetToDropFrameValue() { - SpeedPercent = 99.9889; + // Inverse of the from-drop-frame preset under the factor = 100 / percent + // convention: 100 / 99.9001 = 1.001001, so From -> To round-trips. + SpeedPercent = 99.9001; } [RelayCommand] diff --git a/src/ui/Features/Sync/ChangeSpeed/ChangeSpeedWindow.cs b/src/ui/Features/Sync/ChangeSpeed/ChangeSpeedWindow.cs index 85ce849ed8a..e3a6106a193 100644 --- a/src/ui/Features/Sync/ChangeSpeed/ChangeSpeedWindow.cs +++ b/src/ui/Features/Sync/ChangeSpeed/ChangeSpeedWindow.cs @@ -116,7 +116,7 @@ public ChangeSpeedWindow(ChangeSpeedViewModel vm) Content = grid; - Activated += delegate { buttonDone.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, buttonDone); // hack to make OnKeyDown work Loaded += (_, _) => UiUtil.RestoreWindowPosition(this); Closing += (_, _) => UiUtil.SaveWindowPosition(this); KeyDown += (_, e) => vm.OnKeyDown(e); diff --git a/src/ui/Features/Sync/PointSync/PointSyncViewModel.cs b/src/ui/Features/Sync/PointSync/PointSyncViewModel.cs index 1d876ce1ce6..9a324518c6a 100644 --- a/src/ui/Features/Sync/PointSync/PointSyncViewModel.cs +++ b/src/ui/Features/Sync/PointSync/PointSyncViewModel.cs @@ -1,4 +1,4 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Input; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -45,6 +45,9 @@ public partial class PointSyncViewModel : ObservableObject private string _videoFileName; private AudioVisualizer? _audioVisualizer; + // Carried down to the "Set sync point" dialog, which opens its own player (issue #13995). + private int _audioTrackId = -1; + // Only passed on to "Set sync point", which draws the subtitle on its video (#13767). private VideoPreviewSubtitleContext _previewContext = VideoPreviewSubtitleContext.Default; @@ -67,8 +70,10 @@ public void Initialize( string videoFileName, string fileName, VideoPreviewSubtitleContext previewContext, - AudioVisualizer? audioVisualizer) + AudioVisualizer? audioVisualizer, + int audioTrackId = -1) { + _audioTrackId = audioTrackId; Subtitles.Clear(); Subtitles.AddRange(subtitles); FileName = fileName; @@ -98,7 +103,7 @@ private async Task SetSyncPoint() var result = await _windowService.ShowDialogAsync(Window, vm => { - vm.Initialize(Subtitles.ToList(), SelectedSubtitle, _videoFileName, FileName, _previewContext, _audioVisualizer); + vm.Initialize(Subtitles.ToList(), SelectedSubtitle, _videoFileName, FileName, _previewContext, _audioVisualizer, _audioTrackId); }); // Keep a video opened (or found) in there, so the next sync point starts with it loaded - diff --git a/src/ui/Features/Sync/PointSync/SetSyncPoint/SetSyncPointViewModel.cs b/src/ui/Features/Sync/PointSync/SetSyncPoint/SetSyncPointViewModel.cs index 137fb846037..700e175c251 100644 --- a/src/ui/Features/Sync/PointSync/SetSyncPoint/SetSyncPointViewModel.cs +++ b/src/ui/Features/Sync/PointSync/SetSyncPoint/SetSyncPointViewModel.cs @@ -1,4 +1,4 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Input; using Avalonia.Threading; using Avalonia.VisualTree; @@ -14,6 +14,7 @@ using Nikse.SubtitleEdit.Logic.Config; using Nikse.SubtitleEdit.Logic.Media; using Nikse.SubtitleEdit.Logic.VideoPlayers; +using Nikse.SubtitleEdit.Logic.VideoPlayers.LibMpvDynamic; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -62,6 +63,11 @@ public partial class SetSyncPointViewModel : ObservableObject private readonly IVideoPreviewSubtitle _previewSubtitle; private string? _videoFileName; + + // The audio track picked in the main window's Video > Audio tracks. A brand new mpv instance + // starts on the file's default track, so it has to be re-applied here or a dubbed track plays + // while the user syncs against the original (issue #13995). + private int _audioTrackId = -1; private DispatcherTimer _positionTimer = new DispatcherTimer(); private List _subtitleLines = new List(); private VideoPreviewSubtitleContext _previewContext = VideoPreviewSubtitleContext.Default; @@ -95,8 +101,10 @@ public void Initialize( string? videoFileName, string? subtitleFileName, VideoPreviewSubtitleContext previewContext, - AudioVisualizer? audioVisualizer) + AudioVisualizer? audioVisualizer, + int audioTrackId = -1) { + _audioTrackId = audioTrackId; Paragraphs = new ObservableCollection(paragraphs.Select(p => new SubtitleDisplayItem(p))); _subtitleLines = paragraphs; @@ -138,7 +146,7 @@ public void Initialize( { if (!string.IsNullOrEmpty(_videoFileName)) { - _ = VideoPlayerControl.Open(_videoFileName); + _ = OpenPlayerAsync(_videoFileName); } // An audio visualizer without peaks is just an empty box - only show it when the main @@ -368,6 +376,25 @@ private async Task PlayTwoSecondsAndBackLeft() _updateAudioVisualizer = true; } + /// + /// Opens the preview player and, once the video is loaded, applies the audio track the user + /// selected in the main window - the same thing Visual Sync does (issue #11952). Setting it + /// before the file is open is silently ignored, so the await matters. + /// + private async Task OpenPlayerAsync(string videoFileName, double startPositionSeconds = 0) + { + await VideoPlayerControl.Open(videoFileName, startPositionSeconds); + ApplySelectedAudioTrack(); + } + + private void ApplySelectedAudioTrack() + { + if (_audioTrackId > 0 && VideoPlayerControl.VideoPlayer is LibMpvDynamicPlayer mpv) + { + mpv.SetAudioTrack(_audioTrackId); + } + } + private void CenterWaveform(VideoPlayerControl videoPlayerControl, AudioVisualizer audioVisualizer) { audioVisualizer.StartPositionSeconds = Math.Max(0, videoPlayerControl.Position - 0.5); @@ -399,7 +426,7 @@ private async Task OpenVideoFile() // the start of the newly opened file. It has to be passed to Open: the position slider is // clamped to Duration, which is only polled once the player is running, so seeking right // after the open would land at zero. - await VideoPlayerControl.Open(fileName, Math.Max(0, syncPoint.TotalSeconds)); + await OpenPlayerAsync(fileName, Math.Max(0, syncPoint.TotalSeconds)); await VideoPlayerControl.WaitForPlayersReadyAsync(); // The external subtitle went with the old file (if there was one) - it has to be added to diff --git a/src/ui/Features/Sync/PointSync/SetSyncPoint/SetSyncPointWindow.cs b/src/ui/Features/Sync/PointSync/SetSyncPoint/SetSyncPointWindow.cs index 5d2e2cc0fed..8c1c8e1a760 100644 --- a/src/ui/Features/Sync/PointSync/SetSyncPoint/SetSyncPointWindow.cs +++ b/src/ui/Features/Sync/PointSync/SetSyncPoint/SetSyncPointWindow.cs @@ -64,7 +64,6 @@ public SetSyncPointWindow(SetSyncPointViewModel vm) vm.AudioVisualizer = new AudioVisualizer { - Height = 80, Width = double.NaN, IsReadOnly = true, DrawGridLines = Se.Settings.Waveform.DrawGridLines, @@ -131,11 +130,19 @@ public SetSyncPointWindow(SetSyncPointViewModel vm) var buttonCancel = UiUtil.MakeButtonCancel(vm.CancelCommand); var buttonPanel = UiUtil.MakeButtonBar(buttonOk, buttonCancel); + // The waveform used to be pinned at 80 px under the player, too little to pick a precise + // sync point from; the drag handle between them mirrors the main window (issue #14414). + var split = new VideoWaveformSplitGrid(vm.VideoPlayerControl, vm.AudioVisualizer, Se.Settings.Synchronization.SetSyncPointWaveformHeight) + { + IsVideoVisible = vm.IsVideoVisible, + IsWaveformVisible = vm.IsAudioVisualizerVisible, + }; + // A Star row keeps its share of the height even when its child is hidden, so the row itself // has to collapse - otherwise the videoless dialog is mostly empty space. var rowVideoPlayer = new RowDefinition { - Height = vm.IsVideoVisible ? new GridLength(1, GridUnitType.Star) : new GridLength(0), + Height = vm.IsVideoVisible || vm.IsAudioVisualizerVisible ? new GridLength(1, GridUnitType.Star) : new GridLength(0), }; vm.VideoPlayerControl.WithBindIsVisible(nameof(vm.IsVideoVisible)); @@ -144,8 +151,7 @@ public SetSyncPointWindow(SetSyncPointViewModel vm) { RowDefinitions = { - rowVideoPlayer, // video player - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // audio visualizer + rowVideoPlayer, // video player over waveform new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // combo box new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // sync point time code new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // buttons @@ -160,11 +166,10 @@ public SetSyncPointWindow(SetSyncPointViewModel vm) HorizontalAlignment = HorizontalAlignment.Stretch, }; - gridLeft.Add(vm.VideoPlayerControl, 0); - gridLeft.Add(vm.AudioVisualizer, 1); - gridLeft.Add(comboBoxLeft, 2); - gridLeft.Add(panelTimeCode, 3); - gridLeft.Add(panelLeftButtons, 4); + gridLeft.Add(split, 0); + gridLeft.Add(comboBoxLeft, 1); + gridLeft.Add(panelTimeCode, 2); + gridLeft.Add(panelLeftButtons, 3); var grid = new Grid { @@ -193,23 +198,38 @@ public SetSyncPointWindow(SetSyncPointViewModel vm) // "Open video file..." can bring a video in after the videoless dialog is already up: give // the player its row back and grow the window, which is otherwise too short to show it. + // The waveform lent by the main window arrives a beat after construction and shares the row. vm.PropertyChanged += (_, e) => { - if (e.PropertyName != nameof(vm.IsVideoVisible) || !vm.IsVideoVisible) + if (e.PropertyName != nameof(vm.IsVideoVisible) && e.PropertyName != nameof(vm.IsAudioVisualizerVisible)) { return; } - rowVideoPlayer.Height = new GridLength(1, GridUnitType.Star); - MaxHeight = double.PositiveInfinity; - MinHeight = 650; - Height = Math.Max(Height, 800); + split.IsVideoVisible = vm.IsVideoVisible; + split.IsWaveformVisible = vm.IsAudioVisualizerVisible; + rowVideoPlayer.Height = vm.IsVideoVisible || vm.IsAudioVisualizerVisible ? new GridLength(1, GridUnitType.Star) : new GridLength(0); + + if (vm.IsVideoVisible) + { + MaxHeight = double.PositiveInfinity; + MinHeight = 650; + Height = Math.Max(Height, 800); + } + else if (vm.IsAudioVisualizerVisible) + { + // No player, but a waveform to show: it fills whatever height the user gives the + // window, so the videoless cap has to go. + MaxHeight = double.PositiveInfinity; + MinHeight = VideolessMinHeight + VideoWaveformSplitGrid.DefaultWaveformHeight; + Height = Math.Max(Height, VideolessHeight + 2 * VideoWaveformSplitGrid.DefaultWaveformHeight); + } }; // Focus the time code box, not a button, so the window receives key events without arming // any button: a focused button fires OnClick on bare Space, and "Set sync point" used to be // focused here - so the first Space a user pressed closed the dialog instead of playing. - Activated += delegate { vm.FocusTimeCodeUpDown(); }; + UiUtil.FocusOnFirstActivation(this, () => { vm.FocusTimeCodeUpDown(); }); AddHandler(KeyDownEvent, vm.OnKeyDownHandler, RoutingStrategies.Tunnel | RoutingStrategies.Bubble, handledEventsToo: false); @@ -218,6 +238,10 @@ public SetSyncPointWindow(SetSyncPointViewModel vm) // not enough to keep a focused button from clicking on Space release. AddHandler(KeyUpEvent, vm.OnKeyUpHandler, RoutingStrategies.Tunnel | RoutingStrategies.Bubble, handledEventsToo: true); Loaded += (_, e) => vm.OnLoaded(); - Closing += (_, e) => vm.OnClosing(); + Closing += (_, e) => + { + Se.Settings.Synchronization.SetSyncPointWaveformHeight = split.WaveformHeight; + vm.OnClosing(); + }; } } diff --git a/src/ui/Features/Sync/PointSyncViaOther/PointSyncViaOtherViewModel.cs b/src/ui/Features/Sync/PointSyncViaOther/PointSyncViaOtherViewModel.cs index f305077f620..f89dc351172 100644 --- a/src/ui/Features/Sync/PointSyncViaOther/PointSyncViaOtherViewModel.cs +++ b/src/ui/Features/Sync/PointSyncViaOther/PointSyncViaOtherViewModel.cs @@ -1,4 +1,4 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Input; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -50,6 +50,9 @@ public partial class PointSyncViaOtherViewModel : ObservableObject private readonly IFileHelper _fileHelper; private readonly IWindowService _windowService; + // Carried down to the "Set sync point" dialog, which opens its own player (issue #13995). + private int _audioTrackId = -1; + private string _videoFileName; private List _originalSubtitles; @@ -71,14 +74,16 @@ public PointSyncViaOtherViewModel(IFileHelper fileHelper, IWindowService windowS _originalSubtitles = new List(); } - public void Initialize(List subtitles, string videoFileName, string fileName, VideoPreviewSubtitleContext previewContext) + public void Initialize(List subtitles, string videoFileName, string fileName, VideoPreviewSubtitleContext previewContext, int audioTrackId = -1) { + _audioTrackId = audioTrackId; Subtitles.Clear(); Subtitles.AddRange(subtitles); _originalSubtitles = subtitles.Select(s => new SubtitleLineViewModel(s)).ToList(); FileName = fileName; _videoFileName = videoFileName; _previewContext = previewContext; + UpdateGaps(Subtitles); if (Subtitles.Count > 0) { @@ -86,6 +91,22 @@ public void Initialize(List subtitles, string videoFileNa } } + /// + /// Fills in the silence before each line's start, shown in the "Gap" column to point out + /// likely sync points (issue #10175). Unlike the main grid's gap-to-next, the gap here is + /// the one *before* a line - a line starting after silence is where two independently made + /// subtitle files are most likely to truly align. The first line's gap is measured from + /// 00:00, since it too starts after "silence". + /// + private static void UpdateGaps(IList lines) + { + for (var i = 0; i < lines.Count; i++) + { + var previousEnd = i == 0 ? TimeSpan.Zero : lines[i - 1].EndTime; + lines[i].PreviousGap = (lines[i].StartTime - previousEnd).TotalMilliseconds; + } + } + private void Close() { Dispatcher.UIThread.Post(() => { Window?.Close(); }); @@ -184,6 +205,7 @@ private async Task BrowseOther() Othersubtitles.Add(new SubtitleLineViewModel(p, subtitle.OriginalFormat)); } + UpdateGaps(Othersubtitles); SelectedOtherSubtitle = Othersubtitles.FirstOrDefault(); // Sync points reference lines in the replaced file, so they are no longer valid. @@ -231,7 +253,7 @@ private async Task SetSyncPointViaVideo() var result = await _windowService.ShowDialogAsync(Window, vm => { - vm.Initialize(Subtitles.ToList(), left, _videoFileName, FileName, _previewContext, null); + vm.Initialize(Subtitles.ToList(), left, _videoFileName, FileName, _previewContext, null, _audioTrackId); }); // Keep a video opened (or found) in there - also when the dialog was cancelled. @@ -313,6 +335,8 @@ private void Apply() Subtitles[i].StartTime = synced[i].StartTime; Subtitles[i].EndTime = synced[i].EndTime; } + + UpdateGaps(Subtitles); } [RelayCommand] diff --git a/src/ui/Features/Sync/PointSyncViaOther/PointSyncViaOtherWindow.cs b/src/ui/Features/Sync/PointSyncViaOther/PointSyncViaOtherWindow.cs index 18d9d0f6380..ec2a64a7ab4 100644 --- a/src/ui/Features/Sync/PointSyncViaOther/PointSyncViaOtherWindow.cs +++ b/src/ui/Features/Sync/PointSyncViaOther/PointSyncViaOtherWindow.cs @@ -1,10 +1,13 @@ using Avalonia; using Avalonia.Controls; +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.Media.Immutable; using System.Collections; using Nikse.SubtitleEdit.Features.Main; using Nikse.SubtitleEdit.Logic; @@ -15,6 +18,16 @@ namespace Nikse.SubtitleEdit.Features.Sync.PointSyncViaOther; public class PointSyncViaOtherWindow : Window { + /// + /// A line starting after at least this much silence is a likely reliable sync point - + /// two independently made subtitle files tend to truly align there (issue #10175). + /// + private const double SyncCandidateGapMs = 3000; + + // A translucent tint over the row background, so it reads in both light and dark theme + // and the selection highlight still shows through. + private static readonly IBrush SyncCandidateBrush = new ImmutableSolidColorBrush(Color.FromRgb(76, 175, 80), 0.3); + public PointSyncViaOtherWindow(PointSyncViaOtherViewModel vm) { vm.Window = this; @@ -43,6 +56,7 @@ public PointSyncViaOtherWindow(PointSyncViaOtherViewModel vm) { new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, }, ColumnDefinitions = { @@ -59,7 +73,8 @@ public PointSyncViaOtherWindow(PointSyncViaOtherViewModel vm) grid.Add(subtitleViewView, 0); grid.Add(controlView, 0, 1); grid.Add(subtitleOtherView, 0, 2); - grid.Add(panelButtons, 1, 0, 1, 3); + grid.Add(MakeGapLegend(), 1, 0, 1, 3); + grid.Add(panelButtons, 2, 0, 1, 3); Content = grid; @@ -72,6 +87,76 @@ public PointSyncViaOtherWindow(PointSyncViaOtherViewModel vm) KeyDown += (_, e) => vm.OnKeyDown(e); } + /// + /// Explains the green tint in the "Gap" columns - shown once, under the grids, instead of a + /// tooltip on every highlighted cell. + /// + private static Control MakeGapLegend() + { + var swatch = new Border + { + Width = 12, + Height = 12, + CornerRadius = new CornerRadius(2), + Background = SyncCandidateBrush, + BorderBrush = UiUtil.GetBorderBrush(), + BorderThickness = new Thickness(1), + VerticalAlignment = VerticalAlignment.Center, + }; + + var text = new TextBlock + { + Text = string.Format(Se.Language.Sync.SyncPointCandidateInfo, (int)(SyncCandidateGapMs / 1000.0)), + Opacity = 0.75, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + }; + + return new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 6, + Margin = new Thickness(0, 8, 0, 0), + Children = { swatch, text }, + }; + } + + /// + /// The gap before a line's start (unlike the main grid's gap-to-next), tinted when big + /// enough to mark the line as a sync point candidate (issue #10175). + /// + private static SeTableViewColumn MakeGapColumn() + { + var gapConverter = new DoubleToDisplayShortConverter(); + var candidateBrushConverter = new FuncValueConverter(gapMs => + gapMs >= SyncCandidateGapMs ? SyncCandidateBrush : Brushes.Transparent); + + return new SeTableViewColumn + { + Header = Se.Language.General.Gap, + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Width = new GridLength(90), + CellTemplate = new FuncDataTemplate((_, _) => + { + var border = new Border + { + Padding = new Thickness(4, 2), + [!Border.BackgroundProperty] = new Binding(nameof(SubtitleLineViewModel.PreviousGap)) { Converter = candidateBrushConverter, Mode = BindingMode.OneWay }, + }; + + var textBlock = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + [!TextBlock.TextProperty] = new Binding(nameof(SubtitleLineViewModel.PreviousGap)) { Converter = gapConverter, Mode = BindingMode.OneWay }, + }; + + border.Child = textBlock; + return border; + }), + }; + } + private static Control MakeControlView(PointSyncViaOtherViewModel vm) { var grid = new Grid @@ -174,9 +259,17 @@ private static Grid MakeSubtitleView(PointSyncViaOtherViewModel vm) HorizontalAlignment = HorizontalAlignment.Stretch, }; - var labelFileName = UiUtil.MakeLabel(string.Empty).WithBindText(vm, nameof(vm.FileName)); - labelFileName.VerticalAlignment = VerticalAlignment.Center; - var buttonFindText = UiUtil.MakeButton(Se.Language.Sync.FindText, vm.FindTextLeftCommand); + // TextBlock in a star column so a long file name shrinks with an ellipsis + // instead of pushing under the "Find text" button. + var labelFileName = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + Margin = new Thickness(5, 0, 5, 0), + }; + labelFileName.Bind(TextBlock.TextProperty, new Binding(nameof(vm.FileName)) { Source = vm }); + var buttonFindText = UiUtil.MakeButton(Se.Language.Sync.FindText, vm.FindTextLeftCommand) + .WithIconLeft(IconNames.Find); buttonFindText.HorizontalAlignment = HorizontalAlignment.Right; var panelHeader = new Grid { @@ -217,6 +310,7 @@ private static Grid MakeSubtitleView(PointSyncViaOtherViewModel vm) Binding = new Binding(nameof(SubtitleLineViewModel.StartTime)) { Converter = fullTimeConverter }, Width = new GridLength(115), }, + MakeGapColumn(), new SeTableViewColumn { Header = Se.Language.General.Text, @@ -273,7 +367,8 @@ private static Grid MakeSubtitleOtherView(PointSyncViaOtherViewModel vm) }; panelOtherBrowse.Add(buttonBrowseOther, 0, 0); panelOtherBrowse.Add(labelOtherFileName, 0, 1); - var buttonFindTextOther = UiUtil.MakeButton(Se.Language.Sync.FindText, vm.FindTextOtherCommand); + var buttonFindTextOther = UiUtil.MakeButton(Se.Language.Sync.FindText, vm.FindTextOtherCommand) + .WithIconLeft(IconNames.Find); buttonFindTextOther.HorizontalAlignment = HorizontalAlignment.Right; var panelOtherHeader = new Grid { @@ -310,6 +405,7 @@ private static Grid MakeSubtitleOtherView(PointSyncViaOtherViewModel vm) Binding = new Binding(nameof(SubtitleLineViewModel.StartTime)) { Converter = fullTimeConverter }, Width = new GridLength(115), }, + MakeGapColumn(), new SeTableViewColumn { Header = Se.Language.General.Text, diff --git a/src/ui/Features/Sync/PointSyncViaOther/PointSyncer.cs b/src/ui/Features/Sync/PointSyncViaOther/PointSyncer.cs index c75f87dad9f..5433305054f 100644 --- a/src/ui/Features/Sync/PointSyncViaOther/PointSyncer.cs +++ b/src/ui/Features/Sync/PointSyncViaOther/PointSyncer.cs @@ -101,6 +101,20 @@ private static void Sync( p.EndTime = originalSubtitles[i].EndTime; p.Adjust(factor, adjust); } + + // Adjust rounds start and duration to whole milliseconds separately, which can land + // a line's end 1 ms past the next line's start, turning a clean join into an overlap + // the source never had. Clip those back; source overlaps are left as they were. + for (var i = minIndex; i + 1 < subtitles.Count && i + 1 <= maxIndex; i++) + { + var current = subtitles[i]; + var next = subtitles[i + 1]; + if (originalSubtitles[i].EndTime <= originalSubtitles[i + 1].StartTime && + current.EndTime > next.StartTime) + { + current.SetTimes(current.StartTime, next.StartTime); + } + } } } } \ No newline at end of file diff --git a/src/ui/Features/Sync/PointSyncViaOther/SyncPoint.cs b/src/ui/Features/Sync/PointSyncViaOther/SyncPoint.cs index d5e1e07f66e..cc819f8cd1f 100644 --- a/src/ui/Features/Sync/PointSyncViaOther/SyncPoint.cs +++ b/src/ui/Features/Sync/PointSyncViaOther/SyncPoint.cs @@ -24,7 +24,8 @@ public SyncPoint( RightIndex = rightIndex; LeftStartTime = left.StartTime; RightStartTime = right.StartTime; - Text = new TimeCode(left.StartTime).ToShortDisplayString() + " -> " + + Text = "#" + left.Number + ": " + + new TimeCode(left.StartTime).ToShortDisplayString() + " → " + new TimeCode(right.StartTime).ToShortDisplayString(); } } \ No newline at end of file diff --git a/src/ui/Features/Sync/VideoWaveformSplitGrid.cs b/src/ui/Features/Sync/VideoWaveformSplitGrid.cs new file mode 100644 index 00000000000..2d73d4b4416 --- /dev/null +++ b/src/ui/Features/Sync/VideoWaveformSplitGrid.cs @@ -0,0 +1,143 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Nikse.SubtitleEdit.Controls.AudioVisualizerControl; +using Nikse.SubtitleEdit.Logic; +using System; + +namespace Nikse.SubtitleEdit.Features.Sync; + +/// +/// A video player over a waveform with a drag handle between them, laid out the way the main +/// window lays out its video and waveform. The sync dialogs used to pin the waveform at 80 px, +/// which is too little to pick a precise sync point from once the window holds a player as well +/// (issue #14414). +/// +public sealed class VideoWaveformSplitGrid : Grid +{ + public const double DefaultWaveformHeight = 80; + private const double MinWaveformHeight = 40; + private const double MinVideoHeight = 120; + + private readonly RowDefinition _rowVideo; + private readonly RowDefinition _rowWaveform; + private readonly GridSplitter _splitter; + private bool _isVideoVisible = true; + private bool _isWaveformVisible; + private double _waveformHeight; + + /// Raised while the handle is dragged, with the new waveform height in pixels. + public event Action? WaveformHeightChanged; + + /// The last dragged height - what to remember, never a collapsed or star-sized row. + public double WaveformHeight => _waveformHeight; + + public VideoWaveformSplitGrid(Control videoPlayer, AudioVisualizer waveform, double waveformHeight) + { + _waveformHeight = SanitizeHeight(waveformHeight); + + _rowVideo = new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }; + _rowWaveform = new RowDefinition { Height = new GridLength(_waveformHeight, GridUnitType.Pixel) }; + RowDefinitions.Add(_rowVideo); + RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // drag handle + RowDefinitions.Add(_rowWaveform); + ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + HorizontalAlignment = HorizontalAlignment.Stretch; + VerticalAlignment = VerticalAlignment.Stretch; + + _splitter = new GridSplitter + { + Height = UiUtil.SplitterWidthOrHeight, + ResizeDirection = GridResizeDirection.Rows, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2), + }; + _splitter.DragDelta += (_, _) => OnDragged(); + _splitter.DragCompleted += (_, _) => OnDragged(); + + // The row decides the height from here on. + waveform.Height = double.NaN; + waveform.VerticalAlignment = VerticalAlignment.Stretch; + + this.Add(videoPlayer, 0); + this.Add(_splitter, 1); + this.Add(waveform, 2); + UpdateRows(); + } + + public bool IsVideoVisible + { + get => _isVideoVisible; + set + { + _isVideoVisible = value; + UpdateRows(); + } + } + + public bool IsWaveformVisible + { + get => _isWaveformVisible; + set + { + _isWaveformVisible = value; + UpdateRows(); + } + } + + /// Follows another pane's handle, so two side-by-side waveforms stay the same height. + public void SetWaveformHeight(double height) + { + _waveformHeight = SanitizeHeight(height); + UpdateRows(); + } + + private void OnDragged() + { + if (!_rowWaveform.Height.IsAbsolute) + { + return; + } + + _waveformHeight = SanitizeHeight(_rowWaveform.Height.Value); + WaveformHeightChanged?.Invoke(_waveformHeight); + } + + private void UpdateRows() + { + // The handle only makes sense with something on both sides of it. + var hasHandle = _isVideoVisible && _isWaveformVisible; + _splitter.IsVisible = hasHandle; + + // A Star row keeps its share of the height when its child is merely hidden, so the rows + // themselves collapse - and a MinHeight on a collapsed row would prop it open. + _rowVideo.MinHeight = _isVideoVisible ? MinVideoHeight : 0; + _rowVideo.Height = _isVideoVisible ? new GridLength(1, GridUnitType.Star) : new GridLength(0); + + _rowWaveform.MinHeight = hasHandle ? MinWaveformHeight : 0; + if (!_isWaveformVisible) + { + _rowWaveform.Height = new GridLength(0); + } + else if (_isVideoVisible) + { + _rowWaveform.Height = new GridLength(_waveformHeight, GridUnitType.Pixel); + } + else + { + // No player to share with: the waveform takes whatever height the window has. + _rowWaveform.Height = new GridLength(1, GridUnitType.Star); + } + } + + private static double SanitizeHeight(double height) + { + if (double.IsNaN(height) || double.IsInfinity(height) || height < MinWaveformHeight) + { + return DefaultWaveformHeight; + } + + return Math.Round(height); + } +} diff --git a/src/ui/Features/Sync/VisualSync/ManualSyncWindow.cs b/src/ui/Features/Sync/VisualSync/ManualSyncWindow.cs index 5da9d38e05b..b900d2c05a8 100644 --- a/src/ui/Features/Sync/VisualSync/ManualSyncWindow.cs +++ b/src/ui/Features/Sync/VisualSync/ManualSyncWindow.cs @@ -57,7 +57,7 @@ public ManualSyncWindow(ManualSyncViewModel vm) Content = grid; - Activated += delegate { numericUpDownOffsetSeconds.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, numericUpDownOffsetSeconds); // initial focus on an input, not an action button - a focused button clicks on bare Space Loaded += (_, _) => UiUtil.RestoreWindowPosition(this); Closing += (_, _) => UiUtil.SaveWindowPosition(this); KeyDown += (_, e) => vm.OnKeyDown(e); diff --git a/src/ui/Features/Sync/VisualSync/VisualSyncViewModel.cs b/src/ui/Features/Sync/VisualSync/VisualSyncViewModel.cs index 2a665a119fd..a11d07c7725 100644 --- a/src/ui/Features/Sync/VisualSync/VisualSyncViewModel.cs +++ b/src/ui/Features/Sync/VisualSync/VisualSyncViewModel.cs @@ -293,14 +293,14 @@ private void LeftOneSecondBack() [RelayCommand] private void LeftOneSecondForward() { - VideoPlayerControlLeft.Position = Math.Max(0, VideoPlayerControlLeft.Position + 1); + VideoPlayerControlLeft.Position = Math.Min(VideoPlayerControlLeft.Duration, VideoPlayerControlLeft.Position + 1); _updateAudioVisualizer = true; } [RelayCommand] private void RightOneSecondBack() { - VideoPlayerControlRight.Position = Math.Min(VideoPlayerControlRight.Duration, VideoPlayerControlRight.Position - 1); + VideoPlayerControlRight.Position = Math.Max(0, VideoPlayerControlRight.Position - 1); _updateAudioVisualizer = true; } @@ -321,14 +321,14 @@ private void LeftHalfSecondBack() [RelayCommand] private void LeftHalfSecondForward() { - VideoPlayerControlLeft.Position = Math.Max(0, VideoPlayerControlLeft.Position + 0.5); + VideoPlayerControlLeft.Position = Math.Min(VideoPlayerControlLeft.Duration, VideoPlayerControlLeft.Position + 0.5); _updateAudioVisualizer = true; } [RelayCommand] private void RightHalfSecondBack() { - VideoPlayerControlRight.Position = Math.Min(VideoPlayerControlRight.Duration, VideoPlayerControlRight.Position - 0.5); + VideoPlayerControlRight.Position = Math.Max(0, VideoPlayerControlRight.Position - 0.5); _updateAudioVisualizer = true; } diff --git a/src/ui/Features/Sync/VisualSync/VisualSyncWindow.cs b/src/ui/Features/Sync/VisualSync/VisualSyncWindow.cs index c6d75c32097..317778db29e 100644 --- a/src/ui/Features/Sync/VisualSync/VisualSyncWindow.cs +++ b/src/ui/Features/Sync/VisualSync/VisualSyncWindow.cs @@ -48,7 +48,6 @@ public VisualSyncWindow(VisualSyncViewModel vm) vm.AudioVisualizerLeft = new AudioVisualizer { - Height = 80, Width = double.NaN, IsReadOnly = true, DrawGridLines = Se.Settings.Waveform.DrawGridLines, @@ -65,7 +64,6 @@ public VisualSyncWindow(VisualSyncViewModel vm) vm.AudioVisualizerRight = new AudioVisualizer { - Height = 80, Width = double.NaN, IsReadOnly = true, DrawGridLines = Se.Settings.Waveform.DrawGridLines, @@ -136,13 +134,25 @@ public VisualSyncWindow(VisualSyncViewModel vm) var buttonCancel = UiUtil.MakeButtonCancel(vm.CancelCommand); var buttonPanel = UiUtil.MakeButtonBar(labelInfo, buttonSync, buttonOk, buttonCancel); + // The waveforms used to be pinned at 80 px under the players, too little to pick a precise + // scene from; the drag handles mirror the main window and move together (issue #14414). + var splitLeft = new VideoWaveformSplitGrid(vm.VideoPlayerControlLeft, vm.AudioVisualizerLeft, Se.Settings.Synchronization.VisualSyncWaveformHeight) + { + IsWaveformVisible = vm.IsAudioVisualizerVisible, + }; + var splitRight = new VideoWaveformSplitGrid(vm.VideoPlayerControlRight, vm.AudioVisualizerRight, Se.Settings.Synchronization.VisualSyncWaveformHeight) + { + IsWaveformVisible = vm.IsAudioVisualizerVisible, + }; + splitLeft.WaveformHeightChanged += splitRight.SetWaveformHeight; + splitRight.WaveformHeightChanged += splitLeft.SetWaveformHeight; + var gridLeft = new Grid { RowDefinitions = { new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // label - new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, // video player - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // audio visualizer + new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, // video player over waveform new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // combo box new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // buttons }, @@ -157,18 +167,16 @@ public VisualSyncWindow(VisualSyncViewModel vm) }; gridLeft.Add(UiUtil.MakeLabel(Se.Language.Sync.StartScene), 0); - gridLeft.Add(vm.VideoPlayerControlLeft, 1); - gridLeft.Add(vm.AudioVisualizerLeft, 2); - gridLeft.Add(comboBoxLeft, 3); - gridLeft.Add(panelLeftButtons, 4); + gridLeft.Add(splitLeft, 1); + gridLeft.Add(comboBoxLeft, 2); + gridLeft.Add(panelLeftButtons, 3); var gridRight = new Grid { RowDefinitions = { new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // label - new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, // video player - new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // audio visualizer + new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, // video player over waveform new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // combo box new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // buttons }, @@ -183,10 +191,9 @@ public VisualSyncWindow(VisualSyncViewModel vm) }; gridRight.Add(UiUtil.MakeLabel(Se.Language.Sync.EndScene), 0); - gridRight.Add(vm.VideoPlayerControlRight, 1); - gridRight.Add(vm.AudioVisualizerRight, 2); - gridRight.Add(comboBoxRight, 3); - gridRight.Add(panelRightButtons, 4); + gridRight.Add(splitRight, 1); + gridRight.Add(comboBoxRight, 2); + gridRight.Add(panelRightButtons, 3); var grid = new Grid { @@ -215,10 +222,25 @@ public VisualSyncWindow(VisualSyncViewModel vm) Content = grid; - Activated += delegate { comboBoxLeft.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + // The lent waveform arrives a beat after construction, and goes away again when a + // different video is opened from the dialog. + vm.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(vm.IsAudioVisualizerVisible)) + { + splitLeft.IsWaveformVisible = vm.IsAudioVisualizerVisible; + splitRight.IsWaveformVisible = vm.IsAudioVisualizerVisible; + } + }; + + UiUtil.FocusOnFirstActivation(this, comboBoxLeft); // initial focus on an input, not an action button - a focused button clicks on bare Space AddHandler(KeyDownEvent, vm.OnKeyDownHandler, RoutingStrategies.Tunnel | RoutingStrategies.Bubble, handledEventsToo: false); Loaded += (_, _) => vm.OnLoaded(); - Closing += (_, e) => vm.OnClosing(); + Closing += (_, e) => + { + Se.Settings.Synchronization.VisualSyncWaveformHeight = splitLeft.WaveformHeight; + vm.OnClosing(); + }; } } diff --git a/src/ui/Features/Tools/AdjustDuration/AdjustDurationViewModel.cs b/src/ui/Features/Tools/AdjustDuration/AdjustDurationViewModel.cs index d806ad69b86..3e0f93ab8a6 100644 --- a/src/ui/Features/Tools/AdjustDuration/AdjustDurationViewModel.cs +++ b/src/ui/Features/Tools/AdjustDuration/AdjustDurationViewModel.cs @@ -2,6 +2,7 @@ using Avalonia.Input; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.Features.Main; using Nikse.SubtitleEdit.Features.Shared; using Nikse.SubtitleEdit.Logic; @@ -64,6 +65,14 @@ private void DoAdjustViaSeconds(ObservableCollection subt var subtitle = subtitles[i]; var nextSubtitle = subtitles.GetOrNull(i + 1); var newEndTime = subtitle.EndTime + TimeSpan.FromSeconds(AdjustSeconds); + + // A negative adjustment must not push the end time before the start time + var minEndTime = subtitle.StartTime + TimeSpan.FromMilliseconds(100); + if (AdjustSeconds < 0 && newEndTime < minEndTime) + { + newEndTime = minEndTime; + } + if (nextSubtitle != null && newEndTime <= nextSubtitle.StartTime || nextSubtitle == null) { subtitle.EndTime = newEndTime; @@ -90,7 +99,12 @@ private void DoAdjustViaFixed(ObservableCollection subtit if (nextSubtitle != null && newEndTime > nextSubtitle.StartTime) { - subtitle.EndTime = nextSubtitle.StartTime; + // Leave the minimum gap and keep a positive duration, as libse's + // SetFixedDuration / AdjustDisplayTimeUsingPercent do (so the dialog and Batch + // convert agree). Capping flat at next.Start gave a ZERO-duration line whenever + // two rows share a start time, and a negative one when rows are out of order - + // the DoAdjustViaSeconds branch above already floors its result. + subtitle.EndTime = ClampEndTime(subtitle.StartTime, nextSubtitle.StartTime); } else { @@ -99,6 +113,22 @@ private void DoAdjustViaFixed(ObservableCollection subtit } } + + /// + /// An end time that leaves the configured minimum gap before + /// and is still at least 1 ms after . + /// + private static TimeSpan ClampEndTime(TimeSpan startTime, TimeSpan nextStartTime) + { + var capped = nextStartTime - TimeSpan.FromMilliseconds(Configuration.Settings.General.MinimumMillisecondsBetweenLines); + if (capped <= startTime) + { + capped = startTime + TimeSpan.FromMilliseconds(1); + } + + return capped; + } + private void DoAdjustViaPercent(ObservableCollection subtitles) { for (int i = 0; i < subtitles.Count; i++) @@ -112,7 +142,12 @@ private void DoAdjustViaPercent(ObservableCollection subt if (nextSubtitle != null && newEndTime > nextSubtitle.StartTime) { - subtitle.EndTime = nextSubtitle.StartTime; + // Leave the minimum gap and keep a positive duration, as libse's + // SetFixedDuration / AdjustDisplayTimeUsingPercent do (so the dialog and Batch + // convert agree). Capping flat at next.Start gave a ZERO-duration line whenever + // two rows share a start time, and a negative one when rows are out of order - + // the DoAdjustViaSeconds branch above already floors its result. + subtitle.EndTime = ClampEndTime(subtitle.StartTime, nextSubtitle.StartTime); } else { @@ -126,10 +161,14 @@ private void DoAdjustViaRecalculate(ObservableCollection for (int i = 0; i < subtitles.Count; i++) { var subtitle = subtitles[i]; - var charCount = subtitle.Text?.Length ?? 0; - - var optimalDuration = TimeSpan.FromSeconds(charCount / AdjustRecalculateOptimalCharacterPerSecond); - var maxDuration = TimeSpan.FromSeconds(charCount / AdjustRecalculateMaxCharacterPerSecond); + // Count like the grid's CPS column does (tags and line breaks stripped), so the + // recalculated durations actually land at the requested chars-per-second. + var charCount = (double)(subtitle.Text ?? string.Empty).CountCharacters(true); + + // Whole milliseconds, rounded up: a fractional duration truncates to one ms short on + // save, which puts the line just over the CPS it was computed for (#14418). + var optimalDuration = CpsHelper.GetDurationForCps(charCount, AdjustRecalculateOptimalCharacterPerSecond); + var maxDuration = CpsHelper.GetDurationForCps(charCount, AdjustRecalculateMaxCharacterPerSecond); var nextSubtitle = subtitles.GetOrNull(i + 1); var maxEndTime = nextSubtitle?.StartTime ?? TimeSpan.MaxValue; diff --git a/src/ui/Features/Tools/AdjustDuration/AdjustDurationWindow.cs b/src/ui/Features/Tools/AdjustDuration/AdjustDurationWindow.cs index 9a178deaa53..dfddf33624d 100644 --- a/src/ui/Features/Tools/AdjustDuration/AdjustDurationWindow.cs +++ b/src/ui/Features/Tools/AdjustDuration/AdjustDurationWindow.cs @@ -95,7 +95,7 @@ public AdjustDurationWindow(AdjustDurationViewModel vm) Content = grid; - Activated += delegate { combo.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, combo); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += (_, e) => vm.OnKeyDown(e); } diff --git a/src/ui/Features/Tools/AiReview/AiReviewChunker.cs b/src/ui/Features/Tools/AiReview/AiReviewChunker.cs index 316683b6c01..c93755c7085 100644 --- a/src/ui/Features/Tools/AiReview/AiReviewChunker.cs +++ b/src/ui/Features/Tools/AiReview/AiReviewChunker.cs @@ -3,7 +3,12 @@ namespace Nikse.SubtitleEdit.Features.Tools.AiReview; -public record ReviewLine(int Number, string Text); +/// +/// One line as the model sees it: is the tag-stripped text, and +/// / are optional read-only context (ASSA actor +/// field and style name) that tell the model who speaks and whether a line is dialogue or a sign. +/// +public record ReviewLine(int Number, string Text, string? Actor = null, string? Style = null); public class ReviewChunk { diff --git a/src/ui/Features/Tools/AiReview/AiReviewProtocol.cs b/src/ui/Features/Tools/AiReview/AiReviewProtocol.cs index 1039a4cec5a..acfa686e3c6 100644 --- a/src/ui/Features/Tools/AiReview/AiReviewProtocol.cs +++ b/src/ui/Features/Tools/AiReview/AiReviewProtocol.cs @@ -28,8 +28,9 @@ public static class AiReviewProtocol public const string ProtocolText = "\n\nThe user message is a JSON object. \"lines\" holds the subtitle lines to review, each with a line number \"n\" and its \"text\" " + - "(a \"\\n\" inside a text is a line break inside that subtitle and must be kept). \"context_before\" and \"context_after\" are " + - "read-only surrounding lines - never change or return those. A sentence may continue across several lines; correct it across " + + "(a \"\\n\" inside a text is a line break inside that subtitle and must be kept). A line may also carry an \"actor\" " + + "(who speaks) and a \"style\" (e.g. a sign or song style) - use them only as context, never return them. " + + "\"context_before\" and \"context_after\" are read-only surrounding lines - never change or return those. A sentence may continue across several lines; correct it across " + "the lines, but never move words from one line to another.\n" + "Answer with ONLY a JSON object, no other text: {\"changes\":[{\"n\":,\"orig\":\"\"," + "\"text\":\"\",\"reason\":\"\",\"category\":\"spelling|grammar|punctuation|casing|other\"}]}. " + @@ -65,6 +66,16 @@ private static void WriteLines(Utf8JsonWriter writer, string name, List _allSuggestions = new(); private Subtitle _subtitle = new(); + private SubtitleFormat? _subtitleFormat; + + /// Leading/trailing ASSA blocks cut off each sent line, keyed by line number, glued back on in . + private readonly Dictionary _strippedByNumber = new(); private string _languageCode = "en"; private CancellationTokenSource _cancellationTokenSource = new(); private bool _syncingSelection; @@ -154,6 +159,7 @@ public void Initialize( Action? applyCallback = null) { _subtitle = subtitle; + _subtitleFormat = subtitleFormat; _playLine = playLine; _stopPlayback = stopPlayback; _applyCallback = applyCallback; @@ -375,13 +381,19 @@ await MessageBox.Show(Window, Se.Language.General.Error, ProgressValue = 0; var lines = new List(); + _strippedByNumber.Clear(); + var isAssa = _subtitleFormat is AdvancedSubStationAlpha or SubStationAlpha; for (var i = 0; i < _subtitle.Paragraphs.Count; i++) { - var text = _subtitle.Paragraphs[i].Text; - if (!string.IsNullOrWhiteSpace(text)) + var p = _subtitle.Paragraphs[i]; + var stripped = StrippedLine.Strip(p.Text); + if (string.IsNullOrWhiteSpace(stripped.Text)) { - lines.Add(new ReviewLine(i + 1, text)); + continue; // empty, or a pure override/drawing line - nothing to proofread } + + _strippedByNumber[i + 1] = stripped; + lines.Add(new ReviewLine(i + 1, stripped.Text, p.Actor, isAssa ? p.Extra : null)); } var unitIds = AiReviewChunker.BuildUnitIds(lines); @@ -397,6 +409,9 @@ await MessageBox.Show(Window, Se.Language.General.Error, using var client = new AiReviewClient(); var processedLines = 0; var consecutiveErrors = 0; + // Chunks the engine never answered. Counting their lines as reviewed let a run with an + // unreachable engine finish at 100% reporting "no issues found". + var failedChunks = 0; var delay = TimeSpan.FromSeconds(Math.Max(0, RequestDelaySeconds)); var lastRequestCompletedUtc = DateTime.MinValue; @@ -457,6 +472,7 @@ async Task ChatWithDelayAsync(string content) catch (HttpRequestException e) { consecutiveErrors++; + failedChunks++; if (consecutiveErrors >= 3) { await MessageBox.Show(Window, Se.Language.General.Error, @@ -477,9 +493,16 @@ await MessageBox.Show(Window, Se.Language.General.Error, ProgressValue = Math.Min(100.0, processedLines * 100.0 / Math.Max(1, lines.Count)); } - StatusText = _allSuggestions.Count == 0 && processedLines >= lines.Count + StatusText = _allSuggestions.Count == 0 && processedLines >= lines.Count && failedChunks == 0 ? l.NoIssuesFound : string.Format(l.ReviewDone, _allSuggestions.Count, processedLines); + + if (failedChunks > 0 && Window != null) + { + await MessageBox.Show(Window, Se.Language.General.Error, + string.Format(l.EngineError, $"{failedChunks} chunk(s) could not be reviewed"), + MessageBoxButtons.OK, MessageBoxIcon.Warning); + } } catch (OperationCanceledException) { @@ -596,7 +619,9 @@ private void AddSuggestion(AiReviewChange change, Dictionary unitIdByN } var before = _subtitle.Paragraphs[paragraphIndex].Text; - var after = change.NewText; + var after = _strippedByNumber.TryGetValue(change.Number, out var stripped) + ? stripped.Restore(change.NewText) + : change.NewText; if (before.Trim() == after.Trim()) { return; diff --git a/src/ui/Features/Tools/AiReview/AiReviewWindow.cs b/src/ui/Features/Tools/AiReview/AiReviewWindow.cs index b968a525906..5478e59b652 100644 --- a/src/ui/Features/Tools/AiReview/AiReviewWindow.cs +++ b/src/ui/Features/Tools/AiReview/AiReviewWindow.cs @@ -473,10 +473,12 @@ public AiReviewWindow(AiReviewViewModel vm) // keeps the window open so the review can be worked through in passes (issue #13807), Ok // writes them and closes, Cancel closes and leaves the unapplied ones behind. Apply is // hidden for callers without a live target - they have nowhere to receive a pass. + // WithIconLeft assigns Content, so setting it after binding ContentProperty replaced the + // bound text with the bare icon (and binding after WithIconLeft would drop the icon). + // WithIconLeftBindText builds the icon+bound-text panel once, which is what was wanted. var buttonApply = UiUtil.MakeButton(string.Empty, vm.ApplyCommand) - .WithBindIsVisible(nameof(vm.IsApplyVisible)); - buttonApply.Bind(ContentControl.ContentProperty, new Binding(nameof(vm.ApplyButtonText))); - buttonApply.WithIconLeft("fa-solid fa-check"); + .WithBindIsVisible(nameof(vm.IsApplyVisible)) + .WithIconLeftBindText("fa-solid fa-check", nameof(vm.ApplyButtonText)); var buttonOk = UiUtil.MakeButtonOk(vm.OkCommand); var buttonCancel = UiUtil.MakeButtonCancel(vm.CancelCommand); diff --git a/src/ui/Features/Tools/ApplyDurationLimits/ApplyDurationLimitsViewModel.cs b/src/ui/Features/Tools/ApplyDurationLimits/ApplyDurationLimitsViewModel.cs index 2bf0eced9b2..c2d9d624cf2 100644 --- a/src/ui/Features/Tools/ApplyDurationLimits/ApplyDurationLimitsViewModel.cs +++ b/src/ui/Features/Tools/ApplyDurationLimits/ApplyDurationLimitsViewModel.cs @@ -94,95 +94,107 @@ public void OnClosingCleanup() } private void UpdatePreview() + { + Dispatcher.UIThread.Post(BuildPreview); + } + + /// + /// Rebuilds , and the preview rows from the + /// current settings. Must run on the UI thread; kept separate from + /// so can build the result it hands to the caller rather than depending on the + /// preview timer having ticked. + /// + private void BuildPreview() { if (MinDurationMs == null || MaxDurationMs == null || _allSubtitles.Count == 0) { return; } - Dispatcher.UIThread.Post(() => + Subtitles.Clear(); + AllSubtitlesFixed.Clear(); + Fixes.Clear(); + + // Only a conflict when both limits are actually applied - bailing out whenever the + // numbers cross meant "shorten to 500 ms" with a stale 1000 ms minimum in the other + // box silently did nothing at all. + if (FixMinDurationMs && FixMaxDurationMs && MinDurationMs >= MaxDurationMs) + { + return; + } + + var minMs = MinDurationMs.Value; + var maxMs = MaxDurationMs.Value; + var fixCount = 0; + var improveCount = 0; + var skipCount = 0; + + for (var index = 0; index < _allSubtitles.Count; index++) { - Subtitles.Clear(); - AllSubtitlesFixed.Clear(); - Fixes.Clear(); - if (MinDurationMs >= MaxDurationMs) + var item = new SubtitleLineViewModel(_allSubtitles[index]); + AllSubtitlesFixed.Add(item); + + var next = _allSubtitles.GetOrNull(index + 1); + + if (item.Duration.TotalMilliseconds > maxMs && FixMaxDurationMs) { - return; + // Shortening never runs into the next line or a shot change. + var newEndTime = TimeSpan.FromMilliseconds(item.StartTime.TotalMilliseconds + maxMs); + Update(item, newEndTime); + fixCount++; } - var minMs = MinDurationMs.Value; - var maxMs = MaxDurationMs.Value; - var fixCount = 0; - var improveCount = 0; - var skipCount = 0; - - for (var index = 0; index < _allSubtitles.Count; index++) + if (item.Duration.TotalMilliseconds < minMs && FixMinDurationMs) { - var item = new SubtitleLineViewModel(_allSubtitles[index]); - AllSubtitlesFixed.Add(item); + var wantedEndTime = TimeSpan.FromMilliseconds(item.StartTime.TotalMilliseconds + minMs); + var allowedEndTime = wantedEndTime; + + // Never overlap the next line. + if (next != null && wantedEndTime > next.StartTime) + { + allowedEndTime = TimeSpan.FromMilliseconds(next.StartTime.TotalMilliseconds - Se.Settings.General.MinimumBetweenLines.GetMilliseconds()); + } - var next = _allSubtitles.GetOrNull(index + 1); + allowedEndTime = CapAtShotChange(item, allowedEndTime); - if (item.Duration.TotalMilliseconds > maxMs && FixMaxDurationMs) + if (allowedEndTime >= wantedEndTime) { - // Shortening never runs into the next line or a shot change. - var newEndTime = TimeSpan.FromMilliseconds(item.StartTime.TotalMilliseconds + maxMs); - Update(item, newEndTime); + Update(item, wantedEndTime); fixCount++; } - - if (item.Duration.TotalMilliseconds < minMs && FixMinDurationMs) + else if (allowedEndTime > item.EndTime) + { + // improved, but not fixed + Update(item, allowedEndTime, Se.Language.Tools.ApplyDurationLimits.OnlyPartialFixed); + improveCount++; + } + else { - var wantedEndTime = TimeSpan.FromMilliseconds(item.StartTime.TotalMilliseconds + minMs); - var allowedEndTime = wantedEndTime; - - // Never overlap the next line. - if (next != null && wantedEndTime > next.StartTime) - { - allowedEndTime = TimeSpan.FromMilliseconds(next.StartTime.TotalMilliseconds - Se.Settings.General.MinimumBetweenLines.GetMilliseconds()); - } - - allowedEndTime = CapAtShotChange(item, allowedEndTime); - - if (allowedEndTime >= wantedEndTime) - { - Update(item, wantedEndTime); - fixCount++; - } - else if (allowedEndTime > item.EndTime) - { - // improved, but not fixed - Update(item, allowedEndTime, Se.Language.Tools.ApplyDurationLimits.OnlyPartialFixed); - improveCount++; - } - else - { - // unfixable - Subtitles.Add(item); - skipCount++; - } + // unfixable + Subtitles.Add(item); + skipCount++; } } + } - if (fixCount == 0 && improveCount == 0 && skipCount == 0) - { - FixesInfo = Se.Language.Tools.ApplyDurationLimits.NoChangesNeeded; - FixesSkippedInfo = string.Empty; - return; - } + if (fixCount == 0 && improveCount == 0 && skipCount == 0) + { + FixesInfo = Se.Language.Tools.ApplyDurationLimits.NoChangesNeeded; + FixesSkippedInfo = string.Empty; + return; + } - if (improveCount == 0) - { - FixesInfo = string.Format(Se.Language.Tools.ApplyDurationLimits.FixedX, fixCount); - } - else - { - FixesInfo = string.Format(Se.Language.Tools.ApplyDurationLimits.FixedXImprovedY, fixCount, improveCount); - } + if (improveCount == 0) + { + FixesInfo = string.Format(Se.Language.Tools.ApplyDurationLimits.FixedX, fixCount); + } + else + { + FixesInfo = string.Format(Se.Language.Tools.ApplyDurationLimits.FixedXImprovedY, fixCount, improveCount); + } - FixesSkippedInfo = skipCount > 0 ? string.Format(Se.Language.Tools.ApplyDurationLimits.UnfixableX, skipCount) : string.Empty; - }); + FixesSkippedInfo = skipCount > 0 ? string.Format(Se.Language.Tools.ApplyDurationLimits.UnfixableX, skipCount) : string.Empty; } /// @@ -268,14 +280,22 @@ private async Task Ok() } SaveSettings(); - - if (FixMinDurationMs && FixMaxDurationMs && MinDurationMs > MaxDurationMs) + + if (FixMinDurationMs && FixMaxDurationMs && MinDurationMs >= MaxDurationMs) { var msg = Se.Language.Tools.ApplyDurationLimits.MaxDurationShouldBeHigherThanMinDuration; await MessageBox.Show(Window, Se.Language.General.Error, msg, MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } + // The preview timer fills AllSubtitlesFixed, so before its first tick - or right after a + // value changed - OK either did nothing or applied the previous limits. Build it now. + if (_isDirty || AllSubtitlesFixed.Count == 0) + { + _isDirty = false; + BuildPreview(); + } + if (FixMinDurationMs || FixMaxDurationMs) { OkPressed = true; diff --git a/src/ui/Features/Tools/ApplyDurationLimits/ApplyDurationLimitsWindow.cs b/src/ui/Features/Tools/ApplyDurationLimits/ApplyDurationLimitsWindow.cs index 6229ce47f29..48013cb42b6 100644 --- a/src/ui/Features/Tools/ApplyDurationLimits/ApplyDurationLimitsWindow.cs +++ b/src/ui/Features/Tools/ApplyDurationLimits/ApplyDurationLimitsWindow.cs @@ -63,7 +63,7 @@ public ApplyDurationLimitsWindow(ApplyDurationLimitsViewModel vm) Content = grid; - Activated += delegate { _checkBoxFixMinDuration.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, _checkBoxFixMinDuration); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += vm.KeyDown; Closing += delegate { UiUtil.SaveWindowPosition(this); }; diff --git a/src/ui/Features/Tools/ApplyMinGap/ApplyMinGapViewModel.cs b/src/ui/Features/Tools/ApplyMinGap/ApplyMinGapViewModel.cs index fb6892fe0d5..76e0f7406f5 100644 --- a/src/ui/Features/Tools/ApplyMinGap/ApplyMinGapViewModel.cs +++ b/src/ui/Features/Tools/ApplyMinGap/ApplyMinGapViewModel.cs @@ -86,6 +86,28 @@ public void OnClosingCleanup() } private void UpdatePreview() + { + var (fixedSubtitles, previewItems, fixedCount) = BuildFixedSubtitles(); + FixedSubtitles = fixedSubtitles; + + Dispatcher.UIThread.Post(() => + { + Subtitles.Clear(); + foreach (var item in previewItems) + { + Subtitles.Add(item); + } + + StatusText = string.Format(Se.Language.Tools.ApplyMinGaps.NumberOfGapsFixedX, fixedCount); + }); + } + + /// + /// Applies the current minimum gap to a copy of the subtitle and returns the result together + /// with the preview rows. Kept synchronous so can build the list it hands to + /// the caller instead of depending on the preview timer having ticked. + /// + private (List Fixed, List Preview, int FixedCount) BuildFixedSubtitles() { var minMsBetweenLines = MinGapMsOrFrames; if (Configuration.Settings.General.UseTimeFormatHHMMSSFF) @@ -93,41 +115,50 @@ private void UpdatePreview() minMsBetweenLines = SubtitleFormat.FramesToMilliseconds(minMsBetweenLines); } - FixedSubtitles.Clear(); - FixedSubtitles = new List(_allSubtitles.Select(p => new SubtitleLineViewModel(p))); + var fixedSubtitles = _allSubtitles.Select(p => new SubtitleLineViewModel(p)).ToList(); + var previewItems = new List(); + var fixedCount = 0; - Dispatcher.UIThread.Post(() => + for (var index = 0; index < fixedSubtitles.Count - 1; index++) { - Subtitles.Clear(); - var fixedCount = 0; - for (var index = 0; index < FixedSubtitles.Count-1; index++) + var current = fixedSubtitles[index]; + var next = fixedSubtitles[index + 1]; + var gapMs = next.StartTime.TotalMilliseconds - current.EndTime.TotalMilliseconds; + if (gapMs >= minMsBetweenLines) { - var current = FixedSubtitles[index]; - var next = FixedSubtitles[index + 1]; - var gapMs = next.StartTime.TotalMilliseconds - current.EndTime.TotalMilliseconds; - if (gapMs < minMsBetweenLines) - { - fixedCount++; - - var before = new TimeCode(gapMs).ToShortDisplayString(); - - var newEndMs = next.StartTime.TotalMilliseconds - minMsBetweenLines; - current.EndTime = TimeSpan.FromMilliseconds(newEndMs); - var newGapMs = next.StartTime.TotalMilliseconds - current.EndTime.TotalMilliseconds; - - var after = new TimeCode(newGapMs).ToShortDisplayString(); - var fixFormat = Se.Language.Tools.ApplyMinGaps.ChangedGapFromXToYCommentZ; - var comment = string.Empty; - var info = string.Format(fixFormat, before, after, comment); - - var vm = new ApplyMinGapItem(current); - vm.InfoText = info; - Subtitles.Add(vm); - } + continue; } - StatusText = string.Format(Se.Language.Tools.ApplyMinGaps.NumberOfGapsFixedX, fixedCount); - }); + var newEndMs = next.StartTime.TotalMilliseconds - minMsBetweenLines; + + // Skip only when the new end would land at or before this line's own start. That + // happens for a short line followed closely by the next one (a 50 ms line, next at + // +10 ms, gap 100), and produced a negative duration that was still counted as a fix + // and reached the grid and the saved file. + // Deliberately NOT the minimum-display threshold that BatchConverter.ApplyMinGap + // uses: shortening a line below it is this dialog's normal, intended behaviour. + var newDuration = newEndMs - current.StartTime.TotalMilliseconds; + if (newDuration <= 0) + { + continue; + } + + fixedCount++; + + var before = new TimeCode(gapMs).ToShortDisplayString(); + + current.EndTime = TimeSpan.FromMilliseconds(newEndMs); + var newGapMs = next.StartTime.TotalMilliseconds - current.EndTime.TotalMilliseconds; + + var after = new TimeCode(newGapMs).ToShortDisplayString(); + var fixFormat = Se.Language.Tools.ApplyMinGaps.ChangedGapFromXToYCommentZ; + var comment = string.Empty; + var info = string.Format(fixFormat, before, after, comment); + + previewItems.Add(new ApplyMinGapItem(current) { InfoText = info }); + } + + return (fixedSubtitles, previewItems, fixedCount); } public void Initialize(List subtitles) @@ -174,6 +205,12 @@ private void SaveSettings() [RelayCommand] private void Ok() { + // FixedSubtitles was only ever built by the 500 ms preview timer, and the caller replaces + // the whole subtitle with it - so pressing OK before the first tick wiped every line, and + // pressing it right after changing the gap applied the previous value. Build it here. + var (fixedSubtitles, _, _) = BuildFixedSubtitles(); + FixedSubtitles = fixedSubtitles; + SaveSettings(); OkPressed = true; Window?.Close(); diff --git a/src/ui/Features/Tools/ApplyMinGap/ApplyMinGapWindow.cs b/src/ui/Features/Tools/ApplyMinGap/ApplyMinGapWindow.cs index 3b3ab52dcda..e0a359003c7 100644 --- a/src/ui/Features/Tools/ApplyMinGap/ApplyMinGapWindow.cs +++ b/src/ui/Features/Tools/ApplyMinGap/ApplyMinGapWindow.cs @@ -64,7 +64,7 @@ public ApplyMinGapWindow(ApplyMinGapViewModel vm) Content = grid; - Activated += delegate { numericUpDownMinGap.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, numericUpDownMinGap); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += (_, e) => vm.OnKeyDown(e); Closing += delegate { UiUtil.SaveWindowPosition(this); }; diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertConfig.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertConfig.cs index 88fdcbb4adc..75121ba529f 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConvertConfig.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertConfig.cs @@ -43,6 +43,7 @@ public class BatchConvertConfig public SplitBreakLongLinesSettings SplitBreakLongLines { get; set; } public AssaChangeResolutionSettings AssaChangeResolution { get; set; } public AssaChangeStyleSettings AssaChangeStyle { get; set; } + public AssaChangeStylePropertiesSettings AssaChangeStyleProperties { get; set; } public AssaEmbedFontsSettings AssaEmbedFonts { get; set; } public MergeShortLinesSettings MergeShortLines { get; set; } public ApplyDurationLimitsSettings ApplyDurationLimits { get; set; } @@ -85,6 +86,7 @@ public BatchConvertConfig() SplitBreakLongLines = new SplitBreakLongLinesSettings(); AssaChangeResolution = new AssaChangeResolutionSettings(); AssaChangeStyle = new AssaChangeStyleSettings(); + AssaChangeStyleProperties = new AssaChangeStylePropertiesSettings(); AssaEmbedFonts = new AssaEmbedFontsSettings(); MergeShortLines = new MergeShortLinesSettings(); ApplyDurationLimits = new ApplyDurationLimitsSettings(); @@ -327,6 +329,27 @@ public AssaChangeStyleSettings() } } + /// + /// Sets fields on every style in the ASSA header, keeping the styles themselves. Requested for + /// translated Arabic subtitles, where a source style with letter spacing makes the text hard to + /// read and the block should sit on the right (issue #14150). + /// + public class AssaChangeStylePropertiesSettings + { + public bool IsActive { get; set; } + public bool SetSpacing { get; set; } + public decimal Spacing { get; set; } + public bool SetAlignment { get; set; } + public string Alignment { get; set; } + + public AssaChangeStylePropertiesSettings() + { + SetSpacing = true; + Spacing = 0; + Alignment = "an2"; + } + } + public class AssaEmbedFontsSettings { public bool IsActive { get; set; } diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertFixCommonErrorsSettingsWindow.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertFixCommonErrorsSettingsWindow.cs index fe2cb441924..0348ced7f1e 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConvertFixCommonErrorsSettingsWindow.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertFixCommonErrorsSettingsWindow.cs @@ -58,7 +58,7 @@ public BatchConvertFixCommonErrorsSettingsWindow(BatchConvertFixCommonErrorsSett grid.Add(panelButtons, 1, 0); Content = grid; - Activated += delegate { comboProfile.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, comboProfile); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += (_, e) => vm.OnKeyDown(e); } diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertFunction.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertFunction.cs index c8e35ab8809..28a1050bd94 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConvertFunction.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertFunction.cs @@ -60,6 +60,7 @@ public static BatchConvertFunction[] List(BatchConvertViewModel vm) MakeFunction(BatchConvertFunctionType.AutoTranslate, Se.Language.General.AutoTranslate, ViewAutoTranslate.Make(vm), activeFunctions), MakeFunction(BatchConvertFunctionType.AssaChangeResolution, Se.Language.Assa.ResolutionResamplerTitle, ViewAssaChangeResolution.Make(vm), activeFunctions), MakeFunction(BatchConvertFunctionType.AssaChangeStyle, Se.Language.Tools.BatchConvert.AssaChangeStyleTitle, ViewAssaChangeStyle.Make(vm), activeFunctions), + MakeFunction(BatchConvertFunctionType.AssaChangeStyleProperties, Se.Language.Tools.BatchConvert.AssaChangeStylePropertiesTitle, ViewAssaChangeStyleProperties.Make(vm), activeFunctions), MakeFunction(BatchConvertFunctionType.AssaEmbedFonts, Se.Language.Tools.BatchConvert.AssaEmbedFontsTitle, ViewAssaEmbedFonts.Make(vm), activeFunctions), MakeFunction(BatchConvertFunctionType.MergeShortLines, Se.Language.Tools.MergeShortLines.Title, ViewMergeShortLines.Make(vm), activeFunctions), MakeFunction(BatchConvertFunctionType.ApplyDurationLimits, Se.Language.Tools.ApplyDurationLimits.Title, ViewApplyDurationLimits.Make(vm), activeFunctions), diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertFunctionType.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertFunctionType.cs index f35f3358ca6..2f5018d097a 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConvertFunctionType.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertFunctionType.cs @@ -23,6 +23,7 @@ public enum BatchConvertFunctionType RemoveLineBreaks, AssaChangeResolution, AssaChangeStyle, + AssaChangeStyleProperties, AssaEmbedFonts, MergeShortLines, ApplyDurationLimits, diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertItemSplitter.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertItemSplitter.cs index a614319cace..df15bd47ccf 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConvertItemSplitter.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertItemSplitter.cs @@ -42,7 +42,7 @@ public List LoadTransportStream(BatchConvertItem item, Cancell Format = item.Format, FileName = item.FileName, LanguageCode = language, - ImageSubtitle = new OcrSubtitleTransportStream(tsParser, subtitles, item.FileName), + ImageSubtitle = new OcrSubtitleTransportStream(subtitles), }); } } diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsViewModel.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsViewModel.cs index 67c7c5ede1f..d8a8aa3503c 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsViewModel.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsViewModel.cs @@ -47,6 +47,8 @@ public partial class BatchConvertSettingsViewModel : ObservableObject [ObservableProperty] private TesseractEngineModeItem? _selectedTesseractEngineMode; [ObservableProperty] private ObservableCollection _paddleOcrLanguages; + [ObservableProperty] private ObservableCollection _appleVisionLanguages; + [ObservableProperty] private OcrLanguage2? _selectedAppleVisionLanguage; [ObservableProperty] private OcrLanguage2? _selectedPaddleOcrLanguage; [ObservableProperty] private ObservableCollection _binaryOcrDatabases; @@ -80,6 +82,7 @@ public partial class BatchConvertSettingsViewModel : ObservableObject [ObservableProperty] bool _isOllamaVisible; [ObservableProperty] bool _isLlamaCppVisible; [ObservableProperty] bool _isCrispEmbedVisible; + [ObservableProperty] bool _isAppleVisionVisible; public Window? Window { get; set; } public Action? RefreshCrispEmbedModelCombo { get; set; } @@ -96,6 +99,11 @@ public BatchConvertSettingsViewModel(IFolderHelper folderHelper, IWindowService TargetEncodings = new ObservableCollection(encodings); OcrEngines = new ObservableCollection { "nOcr", "BinaryOcr", "Tesseract", "Ollama", "llama.cpp" }; + if (AppleVisionOcr.IsAvailable()) + { + OcrEngines.Add(AppleVisionOcr.StaticName); + } + if (CrispEmbedEngine.CanBeDownloaded()) { OcrEngines.Add(CrispEmbedEngine.StaticName); @@ -122,6 +130,7 @@ public BatchConvertSettingsViewModel(IFolderHelper folderHelper, IWindowService } PaddleOcrLanguages = new ObservableCollection(PaddleOcr.GetLanguages().OrderBy(p => p.ToString())); + AppleVisionLanguages = new ObservableCollection(AppleVisionOcr.GetLanguages().OrderBy(p => p.ToString())); TesseractDictionaryItems = new ObservableCollection(); TesseractEngineModes = new ObservableCollection(TesseractEngineModeItem.List()); SelectedTesseractEngineMode = TesseractEngineModes.FirstOrDefault(p => p.Oem == Se.Settings.Tools.BatchConvert.TesseractEngineMode) @@ -142,7 +151,7 @@ public BatchConvertSettingsViewModel(IFolderHelper folderHelper, IWindowService OllamaModels = new ObservableCollection(Se.Settings.Ocr.OllamaModels); LlamaCppOcrModels = new ObservableCollection(); SelectedLlamaCppOcrModel = LlamaCppDownloadHelper.PopulateModels( - LlamaCppOcrModels, LlamaCppServerManager.OcrModels, Se.Settings.Ocr.LlamaCppOcrModel); + LlamaCppOcrModels, LlamaCppServerManager.GetAllOcrModels(), Se.Settings.Ocr.LlamaCppOcrModel); CrispEmbedBackends = new ObservableCollection(CrispEmbedEngine.GetBackends()); CrispEmbedModels = new ObservableCollection(); @@ -231,6 +240,12 @@ private void SaveSettings() Se.Settings.Tools.BatchConvert.PaddleLanguage = SelectedPaddleOcrLanguage?.Code ?? "en"; } + if (ocrEngine == AppleVisionOcr.StaticName) + { + Se.Settings.Tools.BatchConvert.AppleVisionLanguage = + SelectedAppleVisionLanguage?.Code ?? Se.Settings.Tools.BatchConvert.AppleVisionLanguage; + } + if (ocrEngine == "BinaryOcr") { Se.Settings.Tools.BatchConvert.BinaryOcrDatabase = SelectedBinaryOcrDatabase ?? "Latin"; @@ -351,7 +366,7 @@ await MessageBox.Show(Window!, Se.Language.General.Error, if (SelectedOcrEngine == "llama.cpp") { var ready = await LlamaCppDownloadHelper.EnsureReadyAsync(Window!, _windowService, - SelectedLlamaCppOcrModel?.Model.FileName, LlamaCppServerManager.OcrModels, persistAsTranslateModel: false); + SelectedLlamaCppOcrModel?.Model.FileName, LlamaCppServerManager.GetAllOcrModels(), persistAsTranslateModel: false); if (!ready) { return; @@ -417,7 +432,7 @@ internal void OnOcrEngineChanged() return; } - IsOcrLanguageVisible = ocrEngine != "nOcr" && ocrEngine != "BinaryOcr" && ocrEngine != "Ollama" && ocrEngine != "llama.cpp" && ocrEngine != CrispEmbedEngine.StaticName; + IsOcrLanguageVisible = ocrEngine != "nOcr" && ocrEngine != "BinaryOcr" && ocrEngine != "Ollama" && ocrEngine != "llama.cpp" && ocrEngine != CrispEmbedEngine.StaticName && ocrEngine != AppleVisionOcr.StaticName; IsTesseractOcrVisible = ocrEngine == "Tesseract"; IsPaddleOCrVisible = ocrEngine == "PaddleOCR"; IsBinaryOcrVisible = ocrEngine == "BinaryOcr"; @@ -425,6 +440,7 @@ internal void OnOcrEngineChanged() IsOllamaVisible = ocrEngine == "Ollama"; IsLlamaCppVisible = ocrEngine == "llama.cpp"; IsCrispEmbedVisible = ocrEngine == CrispEmbedEngine.StaticName; + IsAppleVisionVisible = ocrEngine == AppleVisionOcr.StaticName; if (ocrEngine == "Tesseract") { @@ -432,6 +448,14 @@ internal void OnOcrEngineChanged() .FirstOrDefault(p => p.Code == Se.Settings.Tools.BatchConvert.TesseractLanguage) ?? TesseractDictionaryItems.FirstOrDefault(); } + if (ocrEngine == AppleVisionOcr.StaticName) + { + SelectedAppleVisionLanguage = + AppleVisionLanguages.FirstOrDefault(p => p.Code == Se.Settings.Tools.BatchConvert.AppleVisionLanguage) ?? + AppleVisionLanguages.FirstOrDefault(p => p.Code == "en-US") ?? + AppleVisionLanguages.FirstOrDefault(); + } + if (ocrEngine == "PaddleOCR") { var paddleLanguage = PaddleOcr.NormalizeLanguageCode(Se.Settings.Tools.BatchConvert.PaddleLanguage); diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsWindow.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsWindow.cs index 5e00f8e17b4..9f630f618e6 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsWindow.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsWindow.cs @@ -125,6 +125,10 @@ public BatchConvertSettingsWindow(BatchConvertSettingsViewModel vm) model => model.Model.DisplayName, model => model.Model.Size, model => model.IsInstalled ? DownloadDotStatus.UpToDate : DownloadDotStatus.NotInstalled); + // Apple Vision: a language and nothing else - no model, no backend, no download. + var labelAppleVisionLanguage = UiUtil.MakeLabel(Se.Language.General.Language).WithBindVisible(vm, nameof(vm.IsAppleVisionVisible)).WithMarginLeft(10); + var comboBoxAppleVisionLanguages = UiUtil.MakeComboBox(vm.AppleVisionLanguages, vm, nameof(vm.SelectedAppleVisionLanguage)) + .WithBindVisible(nameof(vm.IsAppleVisionVisible)); var labelCrispEmbedBackend = UiUtil.MakeLabel(Se.Language.General.Backend).WithBindVisible(vm, nameof(vm.IsCrispEmbedVisible)).WithMarginLeft(10); var comboBoxCrispEmbedBackends = UiUtil.MakeComboBox(vm.CrispEmbedBackends, vm, nameof(vm.SelectedCrispEmbedBackend)) .WithBindVisible(nameof(vm.IsCrispEmbedVisible)); @@ -137,7 +141,7 @@ public BatchConvertSettingsWindow(BatchConvertSettingsViewModel vm) { Orientation = Orientation.Horizontal, Margin = new Avalonia.Thickness(0, 30, 0, 0), - Children = { labelOcrEngine, comboBoxOcrEngine, labelOcLanguage, comboBoxTesseractLanguages, labelTesseractEngineMode, comboBoxTesseractEngineMode, comboBoxPaddleLanguages, labelBinaryOcrDatabase, comboBoxBinaryOcrDatabases, labelBinaryOcrFallback, comboBoxBinaryOcrFallback, labelNOcrDatabase, comboBoxNOcrDatabases, labelNOcrFallback, comboBoxNOcrFallback, labelOllamaModel, comboBoxOllamaModels, buttonOllamaModelBrowse, labelLlamaCppModel, comboBoxLlamaCppModels, labelCrispEmbedBackend, comboBoxCrispEmbedBackends, labelCrispEmbedModel, comboBoxCrispEmbedModels } + Children = { labelOcrEngine, comboBoxOcrEngine, labelOcLanguage, comboBoxTesseractLanguages, labelTesseractEngineMode, comboBoxTesseractEngineMode, comboBoxPaddleLanguages, labelBinaryOcrDatabase, comboBoxBinaryOcrDatabases, labelBinaryOcrFallback, comboBoxBinaryOcrFallback, labelNOcrDatabase, comboBoxNOcrDatabases, labelNOcrFallback, comboBoxNOcrFallback, labelOllamaModel, comboBoxOllamaModels, buttonOllamaModelBrowse, labelLlamaCppModel, comboBoxLlamaCppModels, labelCrispEmbedBackend, comboBoxCrispEmbedBackends, labelCrispEmbedModel, comboBoxCrispEmbedModels, labelAppleVisionLanguage, comboBoxAppleVisionLanguages } }; comboBoxOcrEngine.SelectionChanged += (s, e) => vm.OnOcrEngineChanged(); @@ -205,7 +209,7 @@ public BatchConvertSettingsWindow(BatchConvertSettingsViewModel vm) Content = grid; - Activated += delegate { comboBoxTargetEncoding.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, comboBoxTargetEncoding); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += (s, e) => vm.OnKeyDown(e); } diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs index 51d5b302a0f..078cac622bd 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs @@ -176,6 +176,8 @@ public partial class BatchConvertViewModel : ObservableObject, IClosingCleanup [ObservableProperty] private ObservableCollection _llamaCppModels = new(); [ObservableProperty] private LlamaCppModelDisplay? _selectedLlamaCppModel; [ObservableProperty] private bool _llamaCppModelComboIsVisible; + [ObservableProperty] private bool _llamaCppRemoteToggleIsVisible; + [ObservableProperty] private bool _llamaCppUseRemoteServer; /// /// Re-assigns the auto-translate engine combo's item template, set by the view. The dots are a @@ -254,6 +256,13 @@ public partial class BatchConvertViewModel : ObservableObject, IClosingCleanup [ObservableProperty] private string _assaChangeStyleImportedStyleHeader; [ObservableProperty] private bool _assaChangeStyleTrimUnusedStyles; + // ASSA change style properties + [ObservableProperty] private bool _assaChangeStylePropertiesSetSpacing; + [ObservableProperty] private decimal _assaChangeStylePropertiesSpacing; + [ObservableProperty] private bool _assaChangeStylePropertiesSetAlignment; + [ObservableProperty] private ObservableCollection _assaChangeStylePropertiesAlignmentOptions; + [ObservableProperty] private DisplayAlignment? _selectedAssaChangeStylePropertiesAlignment; + // Merge short lines // Embed fonts (ASSA) [ObservableProperty] private bool _assaEmbedFontsTrim; @@ -431,6 +440,7 @@ public BatchConvertViewModel( AutoTranslators = [ new OllamaTranslate(), + new OllamaAdvancedTranslate(), new LibreTranslate(), new LmStudioTranslate(), new LlamaCppTranslate(), @@ -461,6 +471,12 @@ public BatchConvertViewModel( AssaChangeStyleImportedStyleHeader = string.Empty; AssaChangeStyleTrimUnusedStyles = false; + AssaChangeStylePropertiesAlignmentOptions = new ObservableCollection(DisplayAlignment.GetAll()); + SelectedAssaChangeStylePropertiesAlignment = AssaChangeStylePropertiesAlignmentOptions[1]; + AssaChangeStylePropertiesSetSpacing = true; + AssaChangeStylePropertiesSpacing = 0; + AssaChangeStylePropertiesSetAlignment = false; + FixCommonErrorsProfile = LoadDefaultProfile(); _targetFormatsWithSettings = new List @@ -561,7 +577,7 @@ private static bool UsesLocalLlamaCppServer(BatchConvertConfig config) { return config.AutoTranslate.IsActive && config.AutoTranslate.Translator is LlamaCppTranslate or LlamaCppAdvancedTranslate && - !Se.Settings.AutoTranslate.LlamaCppUseRemoteServer; + !Se.Settings.Tools.BatchConvert.LlamaCppUseRemoteServer; } private void UpdateFilteredFiles() @@ -666,6 +682,7 @@ private void SaveSettings() Se.Settings.Tools.BatchConvert.AutoTranslateEngine = SelectedAutoTranslator.Name; Se.Settings.Tools.BatchConvert.AutoTranslateSourceLanguage = SelectedSourceLanguage?.TwoLetterIsoLanguageName ?? "auto"; Se.Settings.Tools.BatchConvert.AutoTranslateTargetLanguage = SelectedTargetLanguage?.TwoLetterIsoLanguageName ?? "en"; + Se.Settings.Tools.BatchConvert.LlamaCppUseRemoteServer = LlamaCppUseRemoteServer; // Change casing if (NormalCasing) @@ -699,6 +716,13 @@ private void SaveSettings() // Change speed Se.Settings.Tools.BatchConvert.ChangeSpeedPercent = ChangeSpeedPercent; + // Merge lines with same text / same time codes (shared with the standalone dialogs) + Se.Settings.Tools.MergeSameText.MaxMillisecondsBetweenLines = MergeSameTextMaxMillisecondsBetweenLines; + Se.Settings.Tools.MergeSameText.IncludeIncrementingLines = MergeSameTextIncludeIncrementingLines; + Se.Settings.Tools.MergeSameTimeCode.MaxMillisecondsDifference = MergeSameTimeMaxMillisecondsDifference; + Se.Settings.Tools.MergeSameTimeCode.MergeDialog = MergeSameTimeMergeDialog; + Se.Settings.Tools.MergeSameTimeCode.AutoBreak = MergeSameTimeAutoBreak; + // Delete lines Se.Settings.Tools.BatchConvert.DeleteXFirstLines = DeleteXFirstLines; Se.Settings.Tools.BatchConvert.DeleteXLastLines = DeleteXLastLines; @@ -730,6 +754,12 @@ private void SaveSettings() Se.Settings.Tools.BatchConvert.AssaChangeStyleToStyle = AssaChangeStyleToStyle ?? string.Empty; Se.Settings.Tools.BatchConvert.AssaChangeStyleTrimUnusedStyles = AssaChangeStyleTrimUnusedStyles; + // ASSA change style properties + Se.Settings.Tools.BatchConvert.AssaChangeStylePropertiesSetSpacing = AssaChangeStylePropertiesSetSpacing; + Se.Settings.Tools.BatchConvert.AssaChangeStylePropertiesSpacing = AssaChangeStylePropertiesSpacing; + Se.Settings.Tools.BatchConvert.AssaChangeStylePropertiesSetAlignment = AssaChangeStylePropertiesSetAlignment; + Se.Settings.Tools.BatchConvert.AssaChangeStylePropertiesAlignment = SelectedAssaChangeStylePropertiesAlignment?.Code ?? "an2"; + // Embed fonts Se.Settings.Tools.BatchConvert.AssaEmbedFontsTrim = AssaEmbedFontsTrim; @@ -753,6 +783,15 @@ private void SaveSettings() Se.Settings.Tools.BatchConvert.BeautifyTimeCodesUseFixedFrameRate = BeautifyTimeCodesUseFixedFrameRate; Se.Settings.Tools.BatchConvert.BeautifyTimeCodesFixedFrameRate = SelectedBeautifyTimeCodesFrameRate; + // Remove formatting + Se.Settings.Tools.BatchConvert.FormattingRemoveAll = FormattingRemoveAll; + Se.Settings.Tools.BatchConvert.FormattingRemoveItalic = FormattingRemoveItalic; + Se.Settings.Tools.BatchConvert.FormattingRemoveBold = FormattingRemoveBold; + Se.Settings.Tools.BatchConvert.FormattingRemoveUnderline = FormattingRemoveUnderline; + Se.Settings.Tools.BatchConvert.FormattingRemoveFontTags = FormattingRemoveFontTags; + Se.Settings.Tools.BatchConvert.FormattingRemoveAlignmentTags = FormattingRemoveAlignmentTags; + Se.Settings.Tools.BatchConvert.FormattingRemoveColorTags = FormattingRemoveColors; + // Snap time codes to frames Se.Settings.Tools.BatchConvert.SnapTimeCodesToFramesUseFixedFrameRate = SnapTimeCodesToFramesUseFixedFrameRate; Se.Settings.Tools.BatchConvert.SnapTimeCodesToFramesFixedFrameRate = SelectedSnapTimeCodesToFramesFrameRate; @@ -787,6 +826,18 @@ private void SaveSettings() Se.Settings.Tools.BatchConvert.FixRtlMode = "ReverseStartEnd"; } + // These were read in LoadSettings but never written back, so every number the user typed + // into the bridge-gaps, min-gap and split/break function panels was discarded on close. + // They go to the same keys the dedicated dialogs use. + Se.Settings.Tools.BridgeGaps.BridgeGapsSmallerThanMs = BridgeGapsSmallerThanMs; + Se.Settings.Tools.BridgeGaps.MinGapMs = BridgeGapsMinGapMs; + Se.Settings.Tools.BridgeGaps.PercentForLeft = BridgeGapsPercentForLeft; + Se.Settings.Tools.ApplyMinGapMilliseconds = MinGapMs; + Se.Settings.Tools.SplitRebalanceLongLinesSplit = SplitBreakSplitLongLines; + Se.Settings.Tools.SplitRebalanceLongLinesRebalance = SplitBreakRebalanceLongLines; + Se.Settings.Tools.SplitRebalanceLongLinesSingleLineMaxLength = SplitBreakSingleLineMaxLength; + Se.Settings.Tools.SplitRebalanceLongLinesMaxNumberOfLines = SplitBreakMaxNumberOfLines; + Se.SaveSettings(); } @@ -889,6 +940,19 @@ private void LoadSettings() BridgeGapsMinGapMs = Se.Settings.Tools.BridgeGaps.MinGapMs; BridgeGapsPercentForLeft = Se.Settings.Tools.BridgeGaps.PercentForLeft; + // "Apply minimum gap" was never loaded. Its editor passes the saved value only as the + // converter's DefaultValue, which a non-nullable int can never reach, so the box opened at + // 0 and the function inserted no gap at all. + MinGapMs = Se.Settings.Tools.ApplyMinGapMilliseconds; + + FormattingRemoveAll = Se.Settings.Tools.BatchConvert.FormattingRemoveAll; + FormattingRemoveItalic = Se.Settings.Tools.BatchConvert.FormattingRemoveItalic; + FormattingRemoveBold = Se.Settings.Tools.BatchConvert.FormattingRemoveBold; + FormattingRemoveUnderline = Se.Settings.Tools.BatchConvert.FormattingRemoveUnderline; + FormattingRemoveFontTags = Se.Settings.Tools.BatchConvert.FormattingRemoveFontTags; + FormattingRemoveAlignmentTags = Se.Settings.Tools.BatchConvert.FormattingRemoveAlignmentTags; + FormattingRemoveColors = Se.Settings.Tools.BatchConvert.FormattingRemoveColorTags; + BeautifyTimeCodesSnapToShotChanges = Se.Settings.Tools.BatchConvert.BeautifyTimeCodesSnapToShotChanges; BeautifyTimeCodesUseFixedFrameRate = Se.Settings.Tools.BatchConvert.BeautifyTimeCodesUseFixedFrameRate; BeautifyTimeCodesUseVideoFrameRate = !BeautifyTimeCodesUseFixedFrameRate; @@ -912,8 +976,15 @@ private void LoadSettings() ConvertColorsToDialogAddNewLines = Se.Settings.Tools.BatchConvert.ConvertColorsToDialogAddNewLines; ConvertColorsToDialogReBreakLines = Se.Settings.Tools.BatchConvert.ConvertColorsToDialogReBreakLines; - SplitBreakSingleLineMaxLength = Se.Settings.General.SubtitleLineMaximumLength; - SplitBreakMaxNumberOfLines = Se.Settings.General.MaxNumberOfLines; + // Prefer this tool's own saved values over the app-wide rules, exactly as the dedicated + // split/break dialog does - otherwise the numbers typed here reset on every open (and + // writing them back to General.* would silently change a global setting). + SplitBreakSingleLineMaxLength = Se.Settings.Tools.SplitRebalanceLongLinesSingleLineMaxLength > 0 + ? Se.Settings.Tools.SplitRebalanceLongLinesSingleLineMaxLength + : Se.Settings.General.SubtitleLineMaximumLength; + SplitBreakMaxNumberOfLines = Se.Settings.Tools.SplitRebalanceLongLinesMaxNumberOfLines > 0 + ? Se.Settings.Tools.SplitRebalanceLongLinesMaxNumberOfLines + : Se.Settings.General.MaxNumberOfLines; SplitBreakSplitLongLines = Se.Settings.Tools.SplitRebalanceLongLinesSplit; SplitBreakRebalanceLongLines = Se.Settings.Tools.SplitRebalanceLongLinesRebalance; @@ -989,6 +1060,16 @@ private void LoadSettings() AssaChangeStyleToStyle = Se.Settings.Tools.BatchConvert.AssaChangeStyleToStyle ?? string.Empty; AssaChangeStyleTrimUnusedStyles = Se.Settings.Tools.BatchConvert.AssaChangeStyleTrimUnusedStyles; + // ASSA change style properties + AssaChangeStylePropertiesSetSpacing = Se.Settings.Tools.BatchConvert.AssaChangeStylePropertiesSetSpacing; + AssaChangeStylePropertiesSpacing = Se.Settings.Tools.BatchConvert.AssaChangeStylePropertiesSpacing; + AssaChangeStylePropertiesSetAlignment = Se.Settings.Tools.BatchConvert.AssaChangeStylePropertiesSetAlignment; + var styleAlignment = AssaChangeStylePropertiesAlignmentOptions.FirstOrDefault(p => p.Code == Se.Settings.Tools.BatchConvert.AssaChangeStylePropertiesAlignment); + if (styleAlignment != null) + { + SelectedAssaChangeStylePropertiesAlignment = styleAlignment; + } + // Embed fonts AssaEmbedFontsTrim = Se.Settings.Tools.BatchConvert.AssaEmbedFontsTrim; @@ -1444,11 +1525,11 @@ config.AutoTranslate.Translator is not (LlamaCppTranslate or LlamaCppAdvancedTra return true; } - // Remote mode: the user pointed llama.cpp at their own running llama-server. Detect it the same - // way the interactive Auto-translate window does - via the LlamaCppUseRemoteServer flag - not by - // whether AutoTranslateUrl is set, since that is pre-filled with the default localhost URL and so - // is never empty (which previously short-circuited the whole auto-start path). - if (Se.Settings.AutoTranslate.LlamaCppUseRemoteServer) + // Remote mode: the user pointed llama.cpp at their own running llama-server. Detect it via + // batch convert's own LlamaCppUseRemoteServer flag (#14005) - not by whether AutoTranslateUrl + // is set, since that is pre-filled with the default localhost URL and so is never empty + // (which previously short-circuited the whole auto-start path). + if (LlamaCppUseRemoteServer) { return true; } @@ -1554,7 +1635,9 @@ private void ChangeSpeedSetFromDropFrameValue() [RelayCommand] private void ChangeSpeedSetToDropFrameValue() { - ChangeSpeedPercent = 99.9889; + // Inverse of the from-drop-frame preset under the factor = 100 / percent + // convention: 100 / 99.9001 = 1.001001, so From -> To round-trips. + ChangeSpeedPercent = 99.9001; } [RelayCommand] @@ -1816,7 +1899,9 @@ private async Task ShowTargetFormatSettings() var subtitles = new ObservableCollection(); var p = new Paragraph("This is a sample text", 0, 1000); subtitles.Add(new SubtitleLineViewModel(p, new SubRip())); - vm.Initialize(exportHandler, subtitles, string.Empty, string.Empty, true); + // No file is loaded here - the dialog only edits the shared profile - so there + // is no header and script tags stay at scale 1.0. + vm.Initialize(exportHandler, subtitles, string.Empty, string.Empty, subtitleHeader: null, hideExportButton: true); }); return; } @@ -2350,13 +2435,27 @@ private void MakeBatchItemsInfo() [RelayCommand] private async Task AutoTranslateBrowseModel() { + // Both Ollama engines list the same installed models; the picker only needs the host, + // so the advanced engine's /v1/chat/completions URL works as-is (it strips the path). + var isAdvanced = SelectedAutoTranslator is OllamaAdvancedTranslate; + var model = isAdvanced ? Se.Settings.AutoTranslate.OllamaAdvancedModel : Se.Settings.AutoTranslate.OllamaModel; + var url = isAdvanced ? Se.Settings.AutoTranslate.OllamaAdvancedUrl : Se.Settings.AutoTranslate.OllamaUrl; + var result = await _windowService.ShowDialogAsync(Window!, - vm => { vm.Initialize(Se.Language.General.PickOllamaModel, Se.Settings.AutoTranslate.OllamaModel, Se.Settings.AutoTranslate.OllamaUrl); }); + vm => { vm.Initialize(Se.Language.General.PickOllamaModel, model, url); }); if (result is { OkPressed: true, SelectedModel: not null }) { AutoTranslateModel = result.SelectedModel; - Se.Settings.AutoTranslate.OllamaModel = result.SelectedModel; + if (isAdvanced) + { + Se.Settings.AutoTranslate.OllamaAdvancedModel = result.SelectedModel; + } + else + { + Se.Settings.AutoTranslate.OllamaModel = result.SelectedModel; + } + SaveSettings(); } } @@ -2655,7 +2754,7 @@ private BatchConvertConfig MakeBatchConvertConfig() MergeLinesWithSameTimeCodes = new BatchConvertConfig.MergeLinesWithSameTimeCodesSettings { IsActive = activeFunctions.Contains(BatchConvertFunctionType.MergeLinesWithSameTimeCodes), - MaxMillisecondsDifference = MergeSameTextMaxMillisecondsBetweenLines, + MaxMillisecondsDifference = MergeSameTimeMaxMillisecondsDifference, MergeDialog = MergeSameTimeMergeDialog, AutoBreak = MergeSameTimeAutoBreak, }, @@ -2758,6 +2857,15 @@ private BatchConvertConfig MakeBatchConvertConfig() TrimUnusedStyles = AssaChangeStyleTrimUnusedStyles, }, + AssaChangeStyleProperties = new BatchConvertConfig.AssaChangeStylePropertiesSettings + { + IsActive = activeFunctions.Contains(BatchConvertFunctionType.AssaChangeStyleProperties), + SetSpacing = AssaChangeStylePropertiesSetSpacing, + Spacing = AssaChangeStylePropertiesSpacing, + SetAlignment = AssaChangeStylePropertiesSetAlignment, + Alignment = SelectedAssaChangeStylePropertiesAlignment?.Code ?? "an2", + }, + AssaEmbedFonts = new BatchConvertConfig.AssaEmbedFontsSettings { IsActive = activeFunctions.Contains(BatchConvertFunctionType.AssaEmbedFonts), @@ -2880,13 +2988,18 @@ private void UpdateAutoTranslateSettings() if (engineType == typeof(LlamaCppTranslate) || engineType == typeof(LlamaCppAdvancedTranslate)) { - if (!string.IsNullOrEmpty(AutoTranslateUrl.Trim())) - { - Configuration.Settings.Tools.LlamaCppApiUrl = AutoTranslateUrl.Trim(); - } - else if (!string.IsNullOrEmpty(Se.Settings.AutoTranslate.LlamaCppApiUrl)) + // Only the external server takes its URL from the text box; in local mode the URL is + // owned by LlamaCppServerManager, which points LlamaCppApiUrl at the server it starts. + if (LlamaCppUseRemoteServer) { - Configuration.Settings.Tools.LlamaCppApiUrl = Se.Settings.AutoTranslate.LlamaCppApiUrl; + var apiUrl = AutoTranslateUrl.Trim(); + if (string.IsNullOrEmpty(apiUrl)) + { + apiUrl = LlamaCppTranslate.DefaultUrl; + } + + Configuration.Settings.Tools.LlamaCppApiUrl = apiUrl; + Se.Settings.AutoTranslate.LlamaCppApiUrl = apiUrl; } } @@ -2904,6 +3017,21 @@ private void UpdateAutoTranslateSettings() } } + if (engineType == typeof(OllamaAdvancedTranslate)) + { + // Both live in SE's own settings only - the advanced engine does not go through + // Configuration.Settings.Tools (that URL belongs to the classic Ollama engine). + if (!string.IsNullOrWhiteSpace(AutoTranslateUrl)) + { + Se.Settings.AutoTranslate.OllamaAdvancedUrl = AutoTranslateUrl.Trim(); + } + + if (!string.IsNullOrWhiteSpace(AutoTranslateModel)) + { + Se.Settings.AutoTranslate.OllamaAdvancedModel = AutoTranslateModel.Trim(); + } + } + if (engineType == typeof(NoLanguageLeftBehindServe)) { if (!string.IsNullOrEmpty(Se.Settings.AutoTranslate.NllbServeUrl)) @@ -3076,6 +3204,22 @@ partial void OnSelectedLlamaCppModelChanged(LlamaCppModelDisplay? value) Se.Settings.AutoTranslate.LlamaCppModel = LlamaCppServerManager.GetModelPath(value.Model.FileName); } + private bool _suppressLlamaCppRemoteToggle; + + partial void OnLlamaCppUseRemoteServerChanged(bool value) + { + if (_suppressLlamaCppRemoteToggle) + { + return; + } + + Se.Settings.Tools.BatchConvert.LlamaCppUseRemoteServer = value; + if (SelectedAutoTranslator is LlamaCppTranslate or LlamaCppAdvancedTranslate) + { + PopulateLlamaCppModels(); + } + } + /// /// Fills the llama.cpp model combo and pre-selects the last-used model, or hides the combo when /// a remote server is configured (it serves whatever model it was started with). Re-filling the @@ -3083,11 +3227,12 @@ partial void OnSelectedLlamaCppModelChanged(LlamaCppModelDisplay? value) /// private void PopulateLlamaCppModels() { - if (Se.Settings.AutoTranslate.LlamaCppUseRemoteServer) + if (LlamaCppUseRemoteServer) { - // Nothing local to pick or install - the remote server owns both. + // Nothing local to pick or install - the remote server owns both; the user just edits the URL. LlamaCppModelComboIsVisible = false; LlamaCppEngineSettingsButtonIsVisible = false; + AutoTranslateUrlIsVisible = true; LlamaCppModels.Clear(); SelectedLlamaCppModel = null; return; @@ -3095,6 +3240,7 @@ private void PopulateLlamaCppModels() LlamaCppModelComboIsVisible = true; LlamaCppEngineSettingsButtonIsVisible = true; + AutoTranslateUrlIsVisible = false; var savedModelName = Path.GetFileName(Se.Settings.AutoTranslate.LlamaCppModel ?? string.Empty); SelectedLlamaCppModel = LlamaCppDownloadHelper.PopulateModels(LlamaCppModels, GetLlamaCppModelsForEngine(), savedModelName); } @@ -3116,15 +3262,20 @@ internal void OnAutoTranslatorChanged() { var engine = SelectedAutoTranslator; - AutoTranslateModelIsVisible = engine is OllamaTranslate; + AutoTranslateModelIsVisible = engine is OllamaTranslate or OllamaAdvancedTranslate; CrispAsrModelComboIsVisible = engine is CrispAsrMadladTranslate; // Both turned back on by PopulateLlamaCppModels for a local llama.cpp. LlamaCppModelComboIsVisible = false; LlamaCppEngineSettingsButtonIsVisible = false; + // Batch convert has its own local/external switch for llama.cpp (#14005), so checking + // "use external server" in the Auto-translate window no longer leaks into batch runs. + LlamaCppRemoteToggleIsVisible = engine is LlamaCppTranslate or LlamaCppAdvancedTranslate; + // Batch size, context history and the synopsis/glossary/style prompt only exist on the - // advanced engine; they apply to local and remote llama-servers alike. - LlamaCppAdvancedButtonIsVisible = engine is LlamaCppAdvancedTranslate; + // advanced engines; they are shared settings, so the same window serves both llama.cpp + // (local or remote llama-server) and Ollama. + LlamaCppAdvancedButtonIsVisible = engine is LlamaCppAdvancedTranslate or OllamaAdvancedTranslate; // The regular llama.cpp engine has no advanced window - its prompt (and the shared // delay/max-bytes/merge settings) live in the translate settings dialog instead. @@ -3153,6 +3304,18 @@ internal void OnAutoTranslatorChanged() AutoTranslateApiKey = string.Empty; AutoTranslateApiKeyIsVisible = false; } + else if (engine is OllamaAdvancedTranslate) + { + // Same installed-model list as the classic Ollama engine - only the endpoint differs + // (the OpenAI-compatible /v1/chat/completions, not the native /api/generate). + AutoTranslateModel = Se.Settings.AutoTranslate.OllamaAdvancedModel; + AutoTranslateModelBrowseIsVisible = true; + AutoTranslateModelIsVisible = true; + AutoTranslateUrl = Se.Settings.AutoTranslate.OllamaAdvancedUrl; + AutoTranslateUrlIsVisible = true; + AutoTranslateApiKey = string.Empty; + AutoTranslateApiKeyIsVisible = false; + } else if (engine is LibreTranslate) { AutoTranslateModel = string.Empty; @@ -3179,12 +3342,14 @@ internal void OnAutoTranslatorChanged() AutoTranslateModelBrowseIsVisible = false; AutoTranslateModelIsVisible = false; AutoTranslateUrl = Se.Settings.AutoTranslate.LlamaCppApiUrl; - AutoTranslateUrlIsVisible = true; AutoTranslateApiKey = string.Empty; AutoTranslateApiKeyIsVisible = false; - // A remote server serves whatever model it was started with, so there is nothing to pick - // here - same as in the Auto-translate window, which owns that toggle. + // Local vs. external server: the URL field is only shown for an external server, and a + // remote server serves whatever model it was started with, so the model combo hides. + _suppressLlamaCppRemoteToggle = true; + LlamaCppUseRemoteServer = Se.Settings.Tools.BatchConvert.LlamaCppUseRemoteServer; + _suppressLlamaCppRemoteToggle = false; PopulateLlamaCppModels(); } else if (engine is NoLanguageLeftBehindServe) @@ -3192,7 +3357,7 @@ internal void OnAutoTranslatorChanged() AutoTranslateModel = string.Empty; AutoTranslateModelBrowseIsVisible = false; AutoTranslateModelIsVisible = false; - AutoTranslateUrl = Se.Settings.AutoTranslate.NnlbServeUrl; + AutoTranslateUrl = Se.Settings.AutoTranslate.NllbServeUrl; AutoTranslateUrlIsVisible = true; AutoTranslateApiKey = string.Empty; AutoTranslateApiKeyIsVisible = false; diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertWindow.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertWindow.cs index 038c8b6e542..8b2d99673db 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConvertWindow.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertWindow.cs @@ -116,7 +116,7 @@ public BatchConvertWindow(BatchConvertViewModel vm) Content = grid; - Activated += delegate { buttonDone.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, buttonDone); // hack to make OnKeyDown work Loaded += vm.Onloaded; Closing += vm.OnClosing; KeyDown += (s, e) => vm.OnKeyDown(e); diff --git a/src/ui/Features/Tools/BatchConvert/BatchConverter.cs b/src/ui/Features/Tools/BatchConvert/BatchConverter.cs index 3b61d5ea3cd..6309c628e6a 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConverter.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConverter.cs @@ -6,6 +6,7 @@ using Nikse.SubtitleEdit.Core.ContainerFormats.Matroska; using Nikse.SubtitleEdit.Core.ContainerFormats.Mp4; using Nikse.SubtitleEdit.Core.ContainerFormats.TransportStream; +using Nikse.SubtitleEdit.Core.Dictionaries; using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Forms; using Nikse.SubtitleEdit.Core.Interfaces; @@ -21,6 +22,7 @@ using Nikse.SubtitleEdit.Features.Translate; using Nikse.SubtitleEdit.UiLogic.AdjustDuration; using Nikse.SubtitleEdit.UiLogic.BatchConvert; +using Nikse.SubtitleEdit.Features.Tools.ChangeCasing; using Nikse.SubtitleEdit.Features.Tools.MergeSubtitlesWithSameTimeCodes; using Nikse.SubtitleEdit.Features.Tools.SplitBreakLongLines; using Nikse.SubtitleEdit.Logic; @@ -403,6 +405,13 @@ private async Task ConvertCore(BatchConvertItem item, CancellationToken cancella return; } } + else if (Se.Settings.Tools.BatchConvert.OcrEngine.Equals(AppleVisionOcr.StaticName, StringComparison.OrdinalIgnoreCase)) + { + if (!RunAppleVisionOcr(imageSubtitle, item, cancellationToken)) + { + return; + } + } else { await RunOcrTesseract(imageSubtitle, item, cancellationToken); @@ -525,7 +534,7 @@ private List LoadTransportStream(BatchConvertItem item, C var subtitles = tsParser.GetDvbSubtitles(packetId); if (subtitles.Count > 0) { - result.Add(new TransportStreamResult { IsImage = true, OcrSubtitle = new OcrSubtitleTransportStream(tsParser, subtitles, item.FileName) }); + result.Add(new TransportStreamResult { IsImage = true, OcrSubtitle = new OcrSubtitleTransportStream(subtitles) }); } } @@ -1430,10 +1439,11 @@ private async Task RunOllamaOcr(IOcrSubtitle imageSubtitles, BatchConvertI private async Task RunLlamaCppOcr(IOcrSubtitle imageSubtitles, BatchConvertItem item, CancellationToken cancellationToken) { - // Curated OCR model from settings (picked in batch convert settings / the OCR window). - // The batch run never downloads - the settings dialog prompts for that on OK. - var model = LlamaCppServerManager.OcrModels.FirstOrDefault(m => m.FileName == Se.Settings.Ocr.LlamaCppOcrModel) - ?? LlamaCppServerManager.OcrModels.FirstOrDefault(LlamaCppServerManager.IsModelInstalled); + // Curated or self-supplied OCR model from settings (picked in batch convert settings / + // the OCR window). The batch run never downloads - the settings dialog prompts for that on OK. + var ocrModels = LlamaCppServerManager.GetAllOcrModels(); + var model = ocrModels.FirstOrDefault(m => m.FileName == Se.Settings.Ocr.LlamaCppOcrModel) + ?? ocrModels.FirstOrDefault(LlamaCppServerManager.IsModelInstalled); if (model == null || !LlamaCppServerManager.IsEngineInstalled() || !LlamaCppServerManager.IsModelInstalled(model)) { item.Status = Se.Language.Ocr.LlamaCppNotDownloaded; @@ -1578,6 +1588,61 @@ private async Task RunCrispEmbedOcr(IOcrSubtitle imageSubtitles, BatchConv return !cancelled; } + /// + /// Runs the file through macOS's Vision recognizer. The one OCR engine here with nothing to + /// check first - no install, no model, no server, no API key - so unlike its siblings there + /// is no "not downloaded" status it can end on, and no async at all: Vision is synchronous + /// in-process work. + /// + /// False when the run was cancelled or every line came back blank, in which case a + /// terminal status is already set on the item. + private bool RunAppleVisionOcr(IOcrSubtitle imageSubtitles, BatchConvertItem item, CancellationToken cancellationToken) + { + var languageCode = Se.Settings.Tools.BatchConvert.AppleVisionLanguage; + + item.Status = Se.Language.General.OcrDotDotDot; + item.Subtitle = new Subtitle(); + var cancelled = false; + for (var i = 0; i < imageSubtitles.Count; i++) + { + var pct = (i + 1) * 100 / imageSubtitles.Count; + item.Status = string.Format(Se.Language.General.OcrPercentX, pct); + + string text; + try + { + text = AppleVisionOcr.Ocr(imageSubtitles.GetBitmap(i), languageCode, fast: false, cancellationToken); + } + catch (OperationCanceledException) + { + item.Status = Se.Language.General.Cancelled; + return false; + } + + item.Subtitle.Paragraphs.Add(new Paragraph(text, + imageSubtitles.GetStartTime(i).TotalMilliseconds, + imageSubtitles.GetEndTime(i).TotalMilliseconds)); + + if (cancellationToken.IsCancellationRequested) + { + item.Status = Se.Language.General.Cancelled; + cancelled = true; + break; + } + } + + // Every line blank is worth flagging rather than saving an empty subtitle - with this + // engine it usually means the chosen language does not match the file. + if (!cancelled && item.Subtitle.Paragraphs.Count > 0 && + item.Subtitle.Paragraphs.All(p => string.IsNullOrWhiteSpace(p.Text))) + { + item.Status = Se.Language.Ocr.AppleVisionReturnedNoText; + return false; + } + + return !cancelled; + } + /// /// Applies the "Adjust image brightness/alpha/color" function to a source image /// before export. Returns the input untouched when the function is off. @@ -1648,6 +1713,9 @@ private void WriteToImageBasedFormat(BatchConvertItem item, IOcrSubtitle? imageS Text = string.Empty, StartTime = imageSubtitle.GetStartTime(i), EndTime = imageSubtitle.GetEndTime(i), + // The source knows which cues are forced - carry it through so a forced + // Blu-ray/PGS track stays forced in the exported sup/BDN XML. + IsForced = imageSubtitle.GetIsForced(i), FontColor = SKColors.White, FontName = "Arial", FontSize = 24, @@ -1658,8 +1726,16 @@ private void WriteToImageBasedFormat(BatchConvertItem item, IOcrSubtitle? imageS ShadowWidth = 2, BackgroundColor = SKColors.Transparent, BackgroundCornerRadius = 0, - ScreenWidth = imageSubtitle.GetScreenSize(i).Width, - ScreenHeight = imageSubtitle.GetScreenSize(i).Height, + // Several IOcrSubtitle sources report "unknown" as -1 x -1 (DivX/XSUB, MP4 + // VobSub, WebVTT images, BDN...). Passing that straight through made every + // exported event land at a large negative X/Y, so fall back to the profile's + // resolution the way the text-rendering path does. + ScreenWidth = imageSubtitle.GetScreenSize(i).Width > 0 + ? imageSubtitle.GetScreenSize(i).Width + : profile.ScreenWidth, + ScreenHeight = imageSubtitle.GetScreenSize(i).Height > 0 + ? imageSubtitle.GetScreenSize(i).Height + : profile.ScreenHeight, BottomTopMargin = 0, LeftRightMargin = 0, Bitmap = ApplyImageAdjustments(imageSubtitle.GetBitmap(i)), @@ -1840,6 +1916,12 @@ private static SeExportImagesProfile GetExportImagesProfile() var (scriptWidth, scriptHeight) = ExportTextTags.GetScriptResolution(item.Subtitle.Header); + // Same fallback as the export dialog: an empty or unknown preset name in the profile + // selects the first list item (soft shadow). + var textEffectPreset = Enum.TryParse(profile.TextEffect, out var parsedPreset) + ? parsedPreset + : TextEffectPreset.SoftShadow; + var imageParameters = new List(); for (var i = 0; i < item.Subtitle.Paragraphs.Count; i++) { @@ -1849,7 +1931,9 @@ private static SeExportImagesProfile GetExportImagesProfile() // "{\an8}" & co. were stripped from the text but not honored, so top-positioned // lines silently ended up at the bottom (issue #13025). Alignment = ExportTextTags.GetAlignment(subtitle.Text, ExportAlignment.BottomCenter), - ContentAlignment = ExportContentAlignment.Center, + // Everything else here follows the export-images profile, so the justification + // has to as well - it was hardcoded, ignoring what the dialog had saved. + ContentAlignment = profile.ContentAlignment, PaddingLeftRight = profile.PaddingLeftRight, PaddingTopBottom = profile.PaddingTopBottom, Index = i, @@ -1877,8 +1961,28 @@ private static SeExportImagesProfile GetExportImagesProfile() FramesPerSecond = profile.FramesPerSecond, IsFullFrame = profile.IsFullFrame, FullFrameBackgroundColor = profile.FullFrameBackgroundColor.FromHexToColor().ToSKColor(), + // The text effect configured in the shared export-images dialog - without this + // a batch convert silently rendered classic text while the dialog's preview + // showed the effect. + TextEffects = TextEffectPresetFactory.Create( + profile.TextEffectEnabled, + textEffectPreset, + profile.FontSize, + profile.FontColor.FromHexToColor().ToSKColor(), + profile.OutlineColor.FromHexToColor().ToSKColor(), + profile.ShadowColor.FromHexToColor().ToSKColor(), + profile.TextEffectStrength, + profile.TextEffectLetterSpacing, + profile.TextEffectArcBend, + profile.TextEffectWave), }; + // "{\3c..}"/"{\4c..}"/"{\bord..}"/"{\shad..}", "{\fad(..)}" and "{\alpha&H..&}" + // change what is drawn - read before rendering, overrides before the + // transparencies that fade them. + ExportTextTags.ApplyStyleOverrideTags(imageParameter, subtitle.Text, scriptHeight); + ExportTextTags.ApplyTransparencyTags(imageParameter, subtitle.Text); + imageParameter.Bitmap = ExportImageBasedViewModel.GenerateBitmap(imageParameter); // "{\pos(x,y)}" anchors the rendered text, so it needs the bitmap size - and the @@ -1935,6 +2039,7 @@ private async Task RunConvertFunctions(BatchConvertItem item, bool ima s = FixRightToLeft(s); s = AssaChangeResolution(s); s = AssaChangeStyle(s); + s = AssaChangeStyleProperties(s); s = BeautifyTimeCodes(s, item.FileName); s = SnapTimeCodesToFrames(s, item.FileName); s = SortBy(s); @@ -2220,11 +2325,23 @@ private Subtitle SplitBreakLongLines(Subtitle subtitle, string language) var maxCharactersPerSubtitle = c.MaxNumberOfLines * c.SingleLineMaxLength; if (c.SplitLongLines) { + var splitOptions = new SplitBreakLongLinesViewModel.SplitOptions + { + MinimumGapMs = Se.Settings.General.MinimumBetweenLines.GetMilliseconds(), + AdjustTeletextRows = _config.TargetFormatName == Ebu.NameOfFormat, + TeletextDoubleHeight = Configuration.Settings.SubtitleSettings.EbuStlTeletextUseDoubleHeight, + }; + for (var index = 0; index < subtitles.Count; index++) { var item = new SubtitleLineViewModel(subtitles[index]); - var splitLines = SplitBreakLongLinesViewModel.Split(item, maxCharactersPerSubtitle, c.SingleLineMaxLength); + // Pass the split options the dialog passes. The 3-argument overload uses a + // default SplitOptions with MinimumGapMs = 0, so batch produced back-to-back + // events with a ZERO gap - which then trips the min-gap error rules and is + // illegal for several broadcast targets - and skipped the teletext row + // adjustment for EBU STL output. + var splitLines = SplitBreakLongLinesViewModel.Split(item, maxCharactersPerSubtitle, c.SingleLineMaxLength, splitOptions); foreach (var s in splitLines) { subtitlesFixed.Add(s); @@ -2291,13 +2408,13 @@ private Subtitle BridgeGaps(Subtitle subtitle) var dic = new Dictionary(); var fixedIndexes = new List(subtitle.Paragraphs.Count); + // Both values are milliseconds - the labels say "(ms)", the settings keys are named + // ...Ms, and the dialog reads the same two keys without converting. Running them through + // FramesToMilliseconds in HH:MM:SS:FF mode turned the 2000 ms default into 80 000 ms at + // 25 fps, so batch bridged half-minute gaps the dialog leaves alone. The interactive + // dialog was already fixed; this is the batch half of that fix. var minMsBetweenLines = _config.BridgeGaps.MinGapMs; var maxMs = _config.BridgeGaps.BridgeGapsSmallerThanMs; - if (Configuration.Settings.General.UseTimeFormatHHMMSSFF) - { - minMsBetweenLines = SubtitleFormat.FramesToMilliseconds(minMsBetweenLines); - maxMs = SubtitleFormat.FramesToMilliseconds(maxMs); - } var subtitles = new ObservableCollection(subtitle.Paragraphs.Select(p => new SubtitleLineViewModel(p, subtitle.OriginalFormat))); var fixedCount = DurationsBridgeGaps2.BridgeGaps(subtitles, minMsBetweenLines, _config.BridgeGaps.PercentForLeft, maxMs, fixedIndexes, dic, @@ -2329,7 +2446,11 @@ private Subtitle ApplyMinGap(Subtitle subtitle) { var newEndMs = next.StartTime.TotalMilliseconds - minMsBetweenLines; var newDuration = newEndMs - current.StartTime.TotalMilliseconds; - if (newDuration > Se.Settings.General.SubtitleMinimumDisplayMilliseconds) + + // Only a non-positive duration is skipped, like the Apply minimum gap dialog. + // Guarding on the minimum display duration instead (default 1000 ms) skipped + // ordinary shortening and left the gap the user asked for unapplied. + if (newDuration > 0) { current.EndTime.TotalMilliseconds = newEndMs; var newGapMs = next.StartTime.TotalMilliseconds - current.EndTime.TotalMilliseconds; @@ -2371,7 +2492,24 @@ private Subtitle BeautifyTimeCodes(Subtitle subtitle, string subtitleFileName) } } - new Core.Forms.TimeCodesBeautifier(subtitle, frameRate, new List(), shotChanges).Beautify(); + // Exact frame time codes, when a previous extraction cached them for this video. Batch + // never extracts on its own - that decodes the whole video per file. + var timeCodes = new List(); + if (hasVideoFile && Se.Settings.BeautifyTimeCodes.ExtractExactTimeCodes) + { + try + { + timeCodes = TimeCodesHelper.FromDisk(videoFileName); + } + catch + { + // unreadable/corrupt time-codes cache - beautify without them rather + // than aborting the rest of the batch + timeCodes = new List(); + } + } + + new Core.Forms.TimeCodesBeautifier(subtitle, frameRate, timeCodes, shotChanges).Beautify(); return subtitle; } @@ -2506,7 +2644,10 @@ private Subtitle ChangeFrameRate(Subtitle subtitle) return subtitle; } - subtitle.ChangeFrameRate(_config.ChangeFrameRate.FromFrameRate, _config.ChangeFrameRate.ToFrameRate); + // Not subtitle.ChangeFrameRate: that scales start and end independently and leaves + // fractional milliseconds, which reach every writer that formats from TotalMilliseconds - + // and two equal-length source cues can round to different durations (#14056). + subtitle.ChangeFrameRateWholeMilliseconds(_config.ChangeFrameRate.FromFrameRate, _config.ChangeFrameRate.ToFrameRate); return subtitle; } @@ -2518,6 +2659,14 @@ private Subtitle ChangeSpeed(Subtitle subtitle) return subtitle; } + // 100/0 is infinity and TimeSpan.FromMilliseconds throws on it, so a 0% speed failed the + // whole item instead of converting it. The dialog's spinner has Minimum = 1 with the same + // reasoning, but the batch config is deserialized from JSON so the UI cannot be the guard. + if (_config.ChangeSpeed.SpeedPercent <= 0) + { + return subtitle; + } + foreach (var p in subtitle.Paragraphs) { p.StartTime.TotalMilliseconds = TimeSpan.FromMilliseconds(p.StartTime.TotalMilliseconds * (100.0 / _config.ChangeSpeed.SpeedPercent)).TotalMilliseconds; @@ -2535,21 +2684,57 @@ private Subtitle ChangeCasing(Subtitle subtitle, string language) return subtitle; } - var fixCasing = new FixCasing(language) + // "Fix names only" turns every FixCasing flag off, so without the names pass below the + // whole step was a silent no-op - and with "Normal casing" + "Fix names" the names half + // was dropped. The batch UI offers both, so both have to do something here. + if (!_config.ChangeCasing.FixNamesOnly) { - FixNormal = _config.ChangeCasing.NormalCasing, - FixNormalOnlyAllUppercase = _config.ChangeCasing.NormalCasingOnlyUpper, - FixMakeUppercase = _config.ChangeCasing.AllUppercase, - FixMakeLowercase = _config.ChangeCasing.AllLowercase, - FixMakeProperCase = false, - FixProperCaseOnlyAllUppercase = false, - Format = subtitle.OriginalFormat, - }; - fixCasing.Fix(subtitle); + var fixCasing = new FixCasing(language) + { + FixNormal = _config.ChangeCasing.NormalCasing, + FixNormalOnlyAllUppercase = _config.ChangeCasing.NormalCasingOnlyUpper, + FixMakeUppercase = _config.ChangeCasing.AllUppercase, + FixMakeLowercase = _config.ChangeCasing.AllLowercase, + FixMakeProperCase = false, + FixProperCaseOnlyAllUppercase = false, + Format = subtitle.OriginalFormat, + }; + fixCasing.Fix(subtitle); + } + + if (_config.ChangeCasing.FixNamesOnly || + (_config.ChangeCasing.NormalCasing && _config.ChangeCasing.NormalCasingFixNames)) + { + FixNames(subtitle, language); + } return subtitle; } + /// + /// The unattended half of the Fix names dialog: apply the names it would have pre-checked. + /// + private void FixNames(Subtitle subtitle, string language) + { + var nameListLanguage = string.IsNullOrEmpty(language) ? "en_US" : language; + var nameList = new NameList(Se.DictionariesFolder, nameListLanguage, false, string.Empty); + var activeNames = FixNamesLogic + .FindNames(subtitle, nameList.GetAllNames(), Se.Settings.Tools.ChangeCasing.ExtraNames, nameListLanguage) + .Where(n => n.IsChecked) + .Select(n => n.Name) + .ToList(); + + if (activeNames.Count == 0) + { + return; + } + + foreach (var p in subtitle.Paragraphs) + { + p.Text = FixNamesLogic.ApplyNames(p.Text, activeNames); + } + } + private Subtitle FixCommonErrors(Subtitle subtitle) { if (!_config.FixCommonErrors.IsActive || _config.FixCommonErrors.Profile == null) @@ -2718,6 +2903,78 @@ private Subtitle AssaChangeStyle(Subtitle subtitle) return subtitle; } + /// + /// Sets fields on the styles a file already has, instead of replacing them like + /// does: a translated Arabic subtitle inherits the source styles, + /// and there letter spacing hurts readability and the block may need to sit on the right - but + /// the rest of each style (font, colors, margins) should survive (issue #14150). + /// + private Subtitle AssaChangeStyleProperties(Subtitle subtitle) + { + var c = _config.AssaChangeStyleProperties; + if (!c.IsActive || (!c.SetSpacing && !c.SetAlignment)) + { + return subtitle; + } + + if (subtitle.OriginalFormat == null || subtitle.OriginalFormat.Name != AdvancedSubStationAlpha.NameOfFormat) + { + return subtitle; + } + + var alignment = GetAssaStyleAlignment(c.Alignment); + if (c.SetAlignment && alignment == null) + { + return subtitle; + } + + if (string.IsNullOrEmpty(subtitle.Header)) + { + subtitle.Header = AdvancedSubStationAlpha.DefaultHeader; + } + + var styles = AdvancedSubStationAlpha.GetSsaStylesFromHeader(subtitle.Header); + if (styles.Count == 0) + { + return subtitle; + } + + foreach (var style in styles) + { + if (c.SetSpacing) + { + style.Spacing = c.Spacing; + } + + if (c.SetAlignment) + { + style.Alignment = alignment; + } + } + + subtitle.Header = AdvancedSubStationAlpha.GetHeaderAndStylesFromAdvancedSubStationAlpha(subtitle.Header, styles); + + return subtitle; + } + + /// + /// Turns an "an1".."an9" drop-down code into the numpad digit an ASSA style's Alignment field + /// holds. Returns null for anything else, so a hand-edited setting cannot write a broken style. + /// + internal static string? GetAssaStyleAlignment(string? alignmentCode) + { + if (alignmentCode == null || + alignmentCode.Length != 3 || + !alignmentCode.StartsWith("an", StringComparison.Ordinal) || + alignmentCode[2] < '1' || + alignmentCode[2] > '9') + { + return null; + } + + return alignmentCode.Substring(2); + } + private Subtitle MergeShortLines(Subtitle subtitle) { if (!_config.MergeShortLines.IsActive) @@ -2800,7 +3057,11 @@ private Subtitle DeleteLines(Subtitle subtitle) } var c = _config.DeleteLines; - if (c.DeleteXFirst == 0 && c.DeleteXLast == 0 && string.IsNullOrWhiteSpace(c.DeleteContains)) + // DeleteActorsOrStyles is a field of this function too; leaving it out of the early-out + // meant configuring only an actor or style deleted nothing and still reported success. + if (c.DeleteXFirst == 0 && c.DeleteXLast == 0 && + string.IsNullOrWhiteSpace(c.DeleteContains) && + string.IsNullOrWhiteSpace(c.DeleteActorsOrStyles)) { return subtitle; } @@ -2817,8 +3078,10 @@ private Subtitle DeleteLines(Subtitle subtitle) .Select(a => a.Trim()).ToList(); foreach (var actor in actorsOrSpeakers) { - paragraphs = paragraphs.Where(p => !p.Actor.Equals(actor, StringComparison.OrdinalIgnoreCase)).ToList(); - paragraphs = paragraphs.Where(p => !p.Style.Equals(actor, StringComparison.OrdinalIgnoreCase)).ToList(); + // Paragraph.Actor/Style have no initializer, so they are null for SRT and friends - + // p.Actor.Equals(...) threw an NRE on the first line of any non-ASSA file. + paragraphs = paragraphs.Where(p => !string.Equals(p.Actor, actor, StringComparison.OrdinalIgnoreCase)).ToList(); + paragraphs = paragraphs.Where(p => !string.Equals(p.Style, actor, StringComparison.OrdinalIgnoreCase)).ToList(); } subtitle.Paragraphs.Clear(); @@ -2843,7 +3106,13 @@ private Subtitle AdjustDisplayDuration(Subtitle subtitle) } else if (c.AdjustmentType == AdjustDurationType.Recalculate) { - subtitle.RecalculateDisplayTimes(c.MaxCharsPerSecond, null, c.OptimalCharsPerSecond, true, shotChanges, true); + // Honour the user's "extend only" choice - the same setting the Adjust durations + // dialog persists - instead of hardcoding true. With true, Recalculate could only + // ever lengthen cues, so batch silently left every over-long cue over-long: exactly + // what the user ran the step to fix. (The batch panel has no checkbox of its own, so + // read the shared setting rather than invent one.) + var extendOnly = Se.Settings.Tools.AdjustDurations.AdjustDurationExtendOnly; + subtitle.RecalculateDisplayTimes(c.MaxCharsPerSecond, null, c.OptimalCharsPerSecond, extendOnly, shotChanges, true); } else if (c.AdjustmentType == AdjustDurationType.Fixed) { @@ -2878,7 +3147,7 @@ private async Task AutoTranslate(Subtitle subtitle, BatchConvertItem i Configuration.Settings.Tools.AutoTranslateNllbApiUrl = Se.Settings.AutoTranslate.NllbApiUrl; - Configuration.Settings.Tools.AutoTranslateNllbServeUrl = Se.Settings.AutoTranslate.NnlbServeUrl; + Configuration.Settings.Tools.AutoTranslateNllbServeUrl = Se.Settings.AutoTranslate.NllbServeUrl; Configuration.Settings.Tools.AutoTranslateCrispAsrExe = Se.Settings.AutoTranslate.CrispAsrExe; Configuration.Settings.Tools.AutoTranslateCrispAsrModel = Se.Settings.AutoTranslate.CrispAsrModel; @@ -2916,7 +3185,12 @@ private async Task AutoTranslate(Subtitle subtitle, BatchConvertItem i for (var i = 0; i < subtitle.Paragraphs.Count && i < translatedSubtitle.Count; i++) { - subtitle.Paragraphs[i].Text = translatedSubtitle[i].TranslatedText; + // A row the engine never returned text for keeps its source text rather than + // being blanked - an engine failure part way through must not empty the rest. + if (!string.IsNullOrEmpty(translatedSubtitle[i].TranslatedText)) + { + subtitle.Paragraphs[i].Text = translatedSubtitle[i].TranslatedText; + } } return subtitle; @@ -2949,6 +3223,13 @@ private Subtitle RemoveTextForHearingImpaired(Subtitle subtitle, string language RemoveIfOnlyMusicSymbols = s.IsRemoveOnlyMusicSymbolsOn, CustomStart = s.CustomStart, CustomEnd = s.CustomEnd, + // The whitelist the dialog edits, not the libse default: without this the batch run + // kept using "YES, NO, WHY, HI, OK, TV" and ignored whatever the user configured. + UppercaseWhitelist = (s.UppercaseWhitelist ?? string.Empty) + .Split([',', ';'], StringSplitOptions.RemoveEmptyEntries) + .Select(p => p.Trim()) + .Where(p => p.Length > 0) + .ToList(), }; foreach (var item in s.TextContains.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries)) @@ -3023,21 +3304,27 @@ private Subtitle MergeLinesWithSameTimeCodes(Subtitle subtitle, string language) var makeDialog = _config.MergeLinesWithSameTimeCodes.MergeDialog; var removed = new HashSet(); + // Anchor on the FIRST cue of the current run, like libse's MergeLinesWithSameTimeCodes + // and the interactive dialog. Re-taking Paragraphs[i - 1] every iteration meant that for + // a run of three cues, the third merged into the SECOND - which had already been + // discarded - so its text was silently dropped from the output. + var lastMerged = false; + Paragraph? p = null; for (var i = 1; i < subtitle.Paragraphs.Count; i++) { - if (removed.Contains(i)) + if (!lastMerged) { - continue; + p = subtitle.Paragraphs[i - 1]; } - var p = subtitle.Paragraphs[i - 1]; var next = subtitle.Paragraphs[i]; if (!MergeSameTimeCodesViewModel.QualifiesForMerge( - new SubtitleLineViewModel(p, subtitle.OriginalFormat), + new SubtitleLineViewModel(p!, subtitle.OriginalFormat), new SubtitleLineViewModel(next, subtitle.OriginalFormat), _config.MergeLinesWithSameTimeCodes.MaxMillisecondsDifference)) { + lastMerged = false; continue; } @@ -3053,7 +3340,7 @@ private Subtitle MergeLinesWithSameTimeCodes(Subtitle subtitle, string language) .Replace("{\\an9}", string.Empty); string mergedText; - if (p.Text.StartsWith("", StringComparison.Ordinal) && p.Text.EndsWith("", StringComparison.Ordinal) && + if (p!.Text.StartsWith("", StringComparison.Ordinal) && p.Text.EndsWith("", StringComparison.Ordinal) && nextText.StartsWith("", StringComparison.Ordinal) && nextText.EndsWith("", StringComparison.Ordinal)) { mergedText = MergeSameTimeCodesViewModel.GetMergedLines( @@ -3071,9 +3358,10 @@ private Subtitle MergeLinesWithSameTimeCodes(Subtitle subtitle, string language) mergedText = Utilities.AutoBreakLine(mergedText, language); } - p.Text = mergedText; + p!.Text = mergedText; p.EndTime.TotalMilliseconds = next.EndTime.TotalMilliseconds; removed.Add(i); + lastMerged = true; } // rebuild subtitle without removed paragraphs diff --git a/src/ui/Features/Tools/BatchConvert/BatchErrorList/BatchErrorListItem.cs b/src/ui/Features/Tools/BatchConvert/BatchErrorList/BatchErrorListItem.cs index 75c65236c1e..dc6d03dfcd4 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchErrorList/BatchErrorListItem.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchErrorList/BatchErrorListItem.cs @@ -1,21 +1,46 @@ -using Nikse.SubtitleEdit.Features.Main; +using Avalonia.Media; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Features.Shared.ErrorList; +using Nikse.SubtitleEdit.Logic.ValueConverters; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; namespace Nikse.SubtitleEdit.Features.Tools.BatchConvert.BatchErrorList; +/// One row in the batch error list: one error on one line of one file. public class BatchErrorListItem { - public string FileName { get; set; } - public int Number { get; set; } - public string Text { get; set; } - public string Error { get; set; } - public SubtitleLineViewModel Subtitle { get; set; } + public string FileName { get; } + public int Number { get; } + public string Text { get; } + public string Show { get; } + public string Hide { get; } + public LineErrorType Type { get; } + public string Category { get; } + public string Detail { get; } + public IBrush Brush { get; } + public SubtitleLineViewModel Subtitle { get; } - public BatchErrorListItem(string fileName, SubtitleLineViewModel subtitle, SubtitleLineViewModel? prev, SubtitleLineViewModel? next) + /// "Reading speed: 27.3 > 25" - the single-string form used by the CSV export. + public string Error => $"{Category}: {Detail}"; + + public BatchErrorListItem(string fileName, SubtitleLineViewModel subtitle, LineError error) { FileName = fileName; Subtitle = subtitle; - Text = subtitle.Text; + Text = subtitle.Text.Replace("\r\n", " ").Replace('\n', ' ').Replace('\r', ' '); Number = subtitle.Number; - Error = subtitle.GetErrors(prev, next); + Show = (string)TimeSpanToDisplayShortConverter.Instance.Convert(subtitle.StartTime, typeof(string), null, CultureInfo.CurrentCulture); + Hide = (string)TimeSpanToDisplayShortConverter.Instance.Convert(subtitle.EndTime, typeof(string), null, CultureInfo.CurrentCulture); + Type = error.Type; + Category = error.Label; + Detail = error.Detail; + Brush = LineError.GetBrush(error.Type); + } + + public static IEnumerable Make(string fileName, SubtitleLineViewModel subtitle, SubtitleLineViewModel? prev, SubtitleLineViewModel? next) + { + return subtitle.GetErrorList(prev, next).Select(e => new BatchErrorListItem(fileName, subtitle, e)); } } diff --git a/src/ui/Features/Tools/BatchConvert/BatchErrorList/BatchErrorListViewModel.cs b/src/ui/Features/Tools/BatchConvert/BatchErrorList/BatchErrorListViewModel.cs index 3fd2708ff47..5f92674d50f 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchErrorList/BatchErrorListViewModel.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchErrorList/BatchErrorListViewModel.cs @@ -1,9 +1,10 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Input; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Nikse.SubtitleEdit.Core.SubtitleFormats; using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Features.Shared.ErrorList; using Nikse.SubtitleEdit.Features.Shared.PromptFileSaved; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; @@ -11,7 +12,9 @@ using Nikse.SubtitleEdit.UiLogic.BatchConvert; using System.Collections.Generic; using System.Collections.ObjectModel; +using System; using System.IO; +using System.Linq; using System.Text; using System.Threading.Tasks; @@ -22,6 +25,11 @@ public partial class BatchErrorListViewModel : ObservableObject [ObservableProperty] private ObservableCollection _subtitles; [ObservableProperty] private BatchErrorListItem? _selectedSubtitle; [ObservableProperty] private bool _hasErrors; + [ObservableProperty] private string _summary = string.Empty; + + public ObservableCollection Cards { get; } = new(); + + private readonly List _allItems = new(); public Window? Window { get; set; } @@ -51,6 +59,7 @@ private async Task Export() } var sb = new StringBuilder(); + // Exports what is shown - the active card filter applies. sb.AppendLine("FileName,LineNumber,Text,Error"); foreach (var errorItem in Subtitles) { @@ -75,6 +84,33 @@ private static string CsvTextEncode(string s) return $"\"{s}\""; } + [RelayCommand] + private void SetFilter(SummaryCard? card) + { + if (card == null) + { + return; + } + + ApplyFilter(card); + } + + private void ApplyFilter(SummaryCard card) + { + foreach (var c in Cards) + { + c.IsActive = ReferenceEquals(c, card); + } + + var filtered = card.Key is LineErrorType type + ? _allItems.Where(p => p.Type == type) + : _allItems; + + Subtitles = new ObservableCollection(filtered); + SelectedSubtitle = Subtitles.FirstOrDefault(); + HasErrors = Subtitles.Count > 0; + } + [RelayCommand] private void Cancel() { @@ -116,16 +152,27 @@ internal void Initialize(List batchItems) var next = i < lines.Count - 1 ? lines[i + 1] : null; if (line.HasErrors(prev, next)) { - Subtitles.Add(new BatchErrorListItem(batchItem.FileName, line, prev, next)); + _allItems.AddRange(BatchErrorListItem.Make(batchItem.FileName, line, prev, next)); } } } - HasErrors = Subtitles.Count > 0; - } + var l = Se.Language.ErrorList; + var lineCount = _allItems.Select(p => (p.FileName, p.Number)).Distinct().Count(); + var fileCount = _allItems.Select(p => p.FileName).Distinct().Count(); + Summary = _allItems.Count == 0 + ? l.NoErrors + : string.Format(l.SummaryFilesX, _allItems.Count, lineCount, fileCount); - internal void GridSelectionChanged(object? sender, SelectionChangedEventArgs e) - { - HasErrors = SelectedSubtitle != null; + Cards.Clear(); + Cards.Add(new SummaryCard { Key = null, Label = l.All, Count = _allItems.Count, Brush = LineError.AllBrush, IsActive = true }); + foreach (var type in Enum.GetValues()) + { + var count = _allItems.Count(p => p.Type == type); + Cards.Add(new SummaryCard { Key = type, Label = LineError.GetLabel(type), Hint = LineError.GetHint(type), Count = count, Brush = LineError.GetBrush(type) }); + } + + ApplyFilter(Cards[0]); } + } \ No newline at end of file diff --git a/src/ui/Features/Tools/BatchConvert/BatchErrorList/BatchErrorListWindow.cs b/src/ui/Features/Tools/BatchConvert/BatchErrorList/BatchErrorListWindow.cs index 3d1c88df05d..ad97a15a6e2 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchErrorList/BatchErrorListWindow.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchErrorList/BatchErrorListWindow.cs @@ -1,38 +1,72 @@ using Avalonia; +using Avalonia.Automation; using Avalonia.Controls; +using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; -using System.Collections; +using Avalonia.Media; using Nikse.SubtitleEdit.Features.Tools.BatchConvert.BatchErrorList; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; +using System.Collections; namespace Nikse.SubtitleEdit.Features.Shared.ErrorList; +/// +/// Batch convert's "List errors": same layout as the main window's error list +/// (verdict header, summary cards that filter, one row per error) plus a file +/// name column and CSV export. +/// public class BatchErrorListWindow : Window { public BatchErrorListWindow(BatchErrorListViewModel vm) { UiUtil.InitializeWindow(this, GetType().Name); - Title = Se.Language.General.ListErrors; + Title = Se.Language.ErrorList.Title; CanResize = true; - Width = 1024; + Width = 1100; Height = 700; - MinWidth = 600; - MinHeight = 400; + MinWidth = 760; + MinHeight = 460; vm.Window = this; DataContext = vm; - var buttonGoTo = UiUtil.MakeButton(Se.Language.General.ExportDotDotDot, vm.ExportCommand).WithBindIsEnabled(nameof(vm.HasErrors)); + var l = Se.Language.ErrorList; + + var labelTitle = new TextBlock + { + Text = l.Title, + FontSize = 20, + FontWeight = FontWeight.Bold, + }; + var labelSummary = new TextBlock + { + FontSize = 14, + Opacity = 0.75, + Margin = new Thickness(0, 4, 0, 0), + [!TextBlock.TextProperty] = new Binding(nameof(vm.Summary)), + }; + var header = new StackPanel + { + Orientation = Orientation.Vertical, + Margin = new Thickness(0, 0, 0, 12), + Children = { labelTitle, labelSummary }, + }; + + var cards = SummaryCard.MakeCardsPanel(vm.Cards, vm.SetFilterCommand); + + var buttonExport = UiUtil.MakeButton(Se.Language.General.ExportDotDotDot, vm.ExportCommand).WithBindIsEnabled(nameof(vm.HasErrors)); var buttonCancel = UiUtil.MakeButtonDone(vm.CancelCommand); - var panelButtons = UiUtil.MakeButtonBar(buttonGoTo, buttonCancel); + var panelButtons = UiUtil.MakeButtonBar(buttonExport, buttonCancel); var grid = new Grid { RowDefinitions = { + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, }, @@ -41,39 +75,35 @@ public BatchErrorListWindow(BatchErrorListViewModel vm) new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, }, Margin = UiUtil.MakeWindowMargin(), - ColumnSpacing = 10, RowSpacing = 10, - Width = double.NaN, - HorizontalAlignment = HorizontalAlignment.Stretch, }; - grid.Add(MakeErrorsGridView(vm), 0); - grid.Add(panelButtons, 1); + grid.Add(header, 0, 0); + grid.Add(cards, 1, 0); + grid.Add(MakeErrorsGridView(vm), 2, 0); + grid.Add(panelButtons, 3, 0); Content = grid; - Activated += delegate { buttonCancel.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, buttonCancel); // hack to make OnKeyDown work KeyDown += (s, e) => vm.OnKeyDown(e); } private static Border MakeErrorsGridView(BatchErrorListViewModel vm) { - var dataGrid = TableViewExtras.MakeTableView(); + var l = Se.Language.ErrorList; + var dataGrid = TableViewExtras.MakeTableView(alwaysSelected: false, multiSelect: false); dataGrid.Height = double.NaN; // auto size inside scroll viewer - dataGrid.Margin = new Thickness(2); - dataGrid.ItemsSource = vm.Subtitles; - dataGrid.DataContext = vm.Subtitles; + dataGrid[!TableView.ItemsSourceProperty] = new Binding(nameof(vm.Subtitles)); - // Columns - the number column was content-sized (Auto) on the DataGrid; TableView - // treats Auto as star, so it gets a fixed width instead. dataGrid.Columns.Add(new SeTableViewColumn { Header = Se.Language.General.FileName, Binding = new Binding(nameof(BatchErrorListItem.FileName)), CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, - Width = new GridLength(1, GridUnitType.Star) + Width = new GridLength(1, GridUnitType.Star), }); dataGrid.Columns.Add(new SeTableViewColumn { @@ -81,31 +111,73 @@ private static Border MakeErrorsGridView(BatchErrorListViewModel vm) Binding = new Binding(nameof(BatchErrorListItem.Number)), CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, - Width = new GridLength(60) + Width = new GridLength(60), }); dataGrid.Columns.Add(new SeTableViewColumn { - Header = Se.Language.General.Text, - Binding = new Binding(nameof(BatchErrorListItem.Text)), + Header = Se.Language.General.Error, + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + CellTemplate = new FuncDataTemplate((_, _) => + { + var dot = new Avalonia.Controls.Shapes.Ellipse + { + Width = 9, + Height = 9, + VerticalAlignment = VerticalAlignment.Center, + [!Avalonia.Controls.Shapes.Shape.FillProperty] = new Binding(nameof(BatchErrorListItem.Brush)), + }; + var text = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + [!TextBlock.TextProperty] = new Binding(nameof(BatchErrorListItem.Category)), + }; + return new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 6, + Margin = new Thickness(6, 0), + VerticalAlignment = VerticalAlignment.Center, + Children = { dot, text }, + }; + }), + Width = new GridLength(160), + }); + dataGrid.Columns.Add(new SeTableViewColumn + { + Header = Se.Language.General.Show, + Binding = new Binding(nameof(BatchErrorListItem.Show)), CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, - Width = new GridLength(1, GridUnitType.Star) + Width = new GridLength(105), }); dataGrid.Columns.Add(new SeTableViewColumn { - Header = Se.Language.General.Error, - Binding = new Binding(nameof(BatchErrorListItem.Error)), + Header = Se.Language.General.Hide, + Binding = new Binding(nameof(BatchErrorListItem.Hide)), CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, - Width = new GridLength(1, GridUnitType.Star) + Width = new GridLength(105), }); - - dataGrid.Bind(TableView.SelectedItemProperty, new Binding(nameof(vm.SelectedSubtitle)) + dataGrid.Columns.Add(new SeTableViewColumn { - Source = vm, - Mode = BindingMode.TwoWay + Header = l.Detail, + Binding = new Binding(nameof(BatchErrorListItem.Detail)), + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Width = new GridLength(200), }); - dataGrid.SelectionChanged += vm.GridSelectionChanged; + dataGrid.Columns.Add(new SeTableViewColumn + { + Header = Se.Language.General.Text, + Binding = new Binding(nameof(BatchErrorListItem.Text)), + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Width = new GridLength(1, GridUnitType.Star), + }); + AutomationProperties.SetName(dataGrid, l.Title); + + TableViewExtras.BindSelectedItem(dataGrid, vm, nameof(vm.SelectedSubtitle)); dataGrid.AddHandler(InputElement.KeyDownEvent, (object? _, KeyEventArgs e) => { if (e.Key is Key.Home or Key.End && dataGrid.ItemsSource is IList items && items.Count > 0 && diff --git a/src/ui/Features/Tools/BatchConvert/FunctionViews/ViewAdjustDuration.cs b/src/ui/Features/Tools/BatchConvert/FunctionViews/ViewAdjustDuration.cs index a8ee27ae47b..b9c10971e91 100644 --- a/src/ui/Features/Tools/BatchConvert/FunctionViews/ViewAdjustDuration.cs +++ b/src/ui/Features/Tools/BatchConvert/FunctionViews/ViewAdjustDuration.cs @@ -168,9 +168,11 @@ private static StackPanel MakeAdjustPercent(BatchConvertViewModel vm) private static StackPanel MakeAdjustFixed(BatchConvertViewModel vm) { + // The bound value is stored and applied as milliseconds + // (AdjustDurationFixedMilliseconds -> BatchConvertConfig.FixedMilliseconds). var textBlockSeconds = new TextBlock { - Text = Se.Language.General.Seconds, + Text = Se.Language.General.Milliseconds, VerticalAlignment = VerticalAlignment.Center, MinWidth = LabelMinWidth, }; diff --git a/src/ui/Features/Tools/BatchConvert/FunctionViews/ViewAssaChangeStyleProperties.cs b/src/ui/Features/Tools/BatchConvert/FunctionViews/ViewAssaChangeStyleProperties.cs new file mode 100644 index 00000000000..e838ac01ca2 --- /dev/null +++ b/src/ui/Features/Tools/BatchConvert/FunctionViews/ViewAssaChangeStyleProperties.cs @@ -0,0 +1,69 @@ +using Avalonia.Controls; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; + +namespace Nikse.SubtitleEdit.Features.Tools.BatchConvert.FunctionViews; + +public static class ViewAssaChangeStyleProperties +{ + public static Control Make(BatchConvertViewModel vm) + { + var labelHeader = new Label + { + Content = Se.Language.Tools.BatchConvert.AssaChangeStylePropertiesTitle, + FontWeight = Avalonia.Media.FontWeight.Bold, + Margin = new Avalonia.Thickness(0, 0, 0, 10), + }; + + var labelOnlyAssa = new Label + { + Content = Se.Language.Tools.BatchConvert.AssaChangeResolutionOnlyAppliesToAssa, + FontStyle = Avalonia.Media.FontStyle.Italic, + Margin = new Avalonia.Thickness(0, 0, 0, 10), + }; + + var labelInfo = new Label + { + Content = Se.Language.Tools.BatchConvert.AssaChangeStylePropertiesInfo, + Margin = new Avalonia.Thickness(0, 0, 0, 10), + }; + + var checkBoxSetSpacing = UiUtil.MakeCheckBox(Se.Language.Tools.BatchConvert.AssaChangeStylePropertiesSetSpacing, vm, nameof(vm.AssaChangeStylePropertiesSetSpacing)); + var numericUpDownSpacing = UiUtil.MakeNumericUpDownOneDecimal(-100, 100, 130, vm, nameof(vm.AssaChangeStylePropertiesSpacing)); + + var checkBoxSetAlignment = UiUtil.MakeCheckBox(Se.Language.Tools.BatchConvert.AssaChangeStylePropertiesSetAlignment, vm, nameof(vm.AssaChangeStylePropertiesSetAlignment)); + var comboBoxAlignment = UiUtil.MakeComboBox(vm.AssaChangeStylePropertiesAlignmentOptions, vm, nameof(vm.SelectedAssaChangeStylePropertiesAlignment)); + + var grid = new Grid + { + RowDefinitions = + { + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // Header + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // Only-ASSA note + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // Info + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // Spacing + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // Alignment + }, + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + }, + Margin = new Avalonia.Thickness(10), + ColumnSpacing = 10, + RowSpacing = 10, + }; + + grid.Add(labelHeader, 0, 0, 1, 2); + grid.Add(labelOnlyAssa, 1, 0, 1, 2); + grid.Add(labelInfo, 2, 0, 1, 2); + + grid.Add(checkBoxSetSpacing, 3); + grid.Add(numericUpDownSpacing, 3, 1); + + grid.Add(checkBoxSetAlignment, 4); + grid.Add(comboBoxAlignment, 4, 1); + + return grid; + } +} diff --git a/src/ui/Features/Tools/BatchConvert/FunctionViews/ViewAutoTranslate.cs b/src/ui/Features/Tools/BatchConvert/FunctionViews/ViewAutoTranslate.cs index 14f57c15d99..b38f0ab1948 100644 --- a/src/ui/Features/Tools/BatchConvert/FunctionViews/ViewAutoTranslate.cs +++ b/src/ui/Features/Tools/BatchConvert/FunctionViews/ViewAutoTranslate.cs @@ -83,10 +83,17 @@ public static Control Make(BatchConvertViewModel vm) ToolTip.SetTip(buttonAdvanced, Se.Language.Translate.AdvancedSettings); } + // Batch convert's own local/external llama-server switch (#14005) - independent of the one + // in the Auto-translate window. Shown only for the llama.cpp engines. + var checkBoxLlamaCppRemote = UiUtil.MakeCheckBox(Se.Language.General.LlamaCppUseRemoteServer, vm, nameof(vm.LlamaCppUseRemoteServer)) + .WithMarginLeft(10); + checkBoxLlamaCppRemote.Bind(CheckBox.IsVisibleProperty, new Binding(nameof(vm.LlamaCppRemoteToggleIsVisible))); + var panelEngineControls = UiUtil.MakeHorizontalPanel( cbEngines, buttonEngineSettings, - buttonAdvanced); + buttonAdvanced, + checkBoxLlamaCppRemote); // Prompt and the shared request settings for the regular llama.cpp engine (the advanced // engine has its own window instead). Right-aligned and away from the engine gear, the same diff --git a/src/ui/Features/Tools/BatchConvert/FunctionViews/ViewChangeSpeed.cs b/src/ui/Features/Tools/BatchConvert/FunctionViews/ViewChangeSpeed.cs index 7dd4e5406bf..196e4c13a63 100644 --- a/src/ui/Features/Tools/BatchConvert/FunctionViews/ViewChangeSpeed.cs +++ b/src/ui/Features/Tools/BatchConvert/FunctionViews/ViewChangeSpeed.cs @@ -29,7 +29,7 @@ public static Control Make(BatchConvertViewModel vm) { Width = 150, Margin = new Thickness(0, 0, 10, 0), - Minimum = 0, + Minimum = 1, // a speed of 0% means a 100/0 factor (divide by zero / infinite times) Maximum = 1000, Increment = 0.1m, [!NumericUpDown.ValueProperty] = new Binding(nameof(vm.ChangeSpeedPercent)) { Mode = BindingMode.TwoWay }, diff --git a/src/ui/Features/Tools/BeautifyTimeCodes/BeautifyTimeCodesViewModel.cs b/src/ui/Features/Tools/BeautifyTimeCodes/BeautifyTimeCodesViewModel.cs index cd5c320aa58..c463935e9af 100644 --- a/src/ui/Features/Tools/BeautifyTimeCodes/BeautifyTimeCodesViewModel.cs +++ b/src/ui/Features/Tools/BeautifyTimeCodes/BeautifyTimeCodesViewModel.cs @@ -8,6 +8,7 @@ using Nikse.SubtitleEdit.Features.Tools.BeautifyTimeCodes.Profile; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Media; using System; using System.Collections.Generic; using System.Linq; @@ -31,7 +32,14 @@ public partial class BeautifyTimeCodesViewModel : ObservableObject, IDisposable private readonly List _originalSubtitles; private readonly List _beautifiedSubtitles; private List _shotChanges; + private List _timeCodes; + + /// Shared "none" list - the beautifier only reads it, and the preview re-runs often. + private static readonly List NoTimeCodes = new List(); private double _frameRate = 25.0; + private double _videoDurationSeconds; + private string _videoFileName = string.Empty; + private CancellationTokenSource? _extractCancellation; private volatile bool _disposed; private readonly IWindowService _windowService; @@ -51,6 +59,12 @@ public partial class BeautifyTimeCodesViewModel : ObservableObject, IDisposable [ObservableProperty] private bool _canGoPrevious; [ObservableProperty] private bool _canGoNext; + [ObservableProperty] private bool _useExactTimeCodes; + [ObservableProperty] private bool _canExtractTimeCodes; + [ObservableProperty] private bool _isExtractingTimeCodes; + [ObservableProperty] private double _extractProgressValue; + [ObservableProperty] private string _timeCodesStatus = string.Empty; + public BeautifyTimeCodesViewModel(IWindowService windowService) { _windowService = windowService; @@ -58,6 +72,7 @@ public BeautifyTimeCodesViewModel(IWindowService windowService) _originalSubtitles = new List(); _beautifiedSubtitles = new List(); _shotChanges = new List(); + _timeCodes = new List(); _timerUpdatePreview = new System.Timers.Timer(500); _timerUpdatePreview.AutoReset = false; @@ -119,7 +134,7 @@ private void UpdatePreview() // beautifier — it reads the profile directly from Configuration.Settings.BeautifyTimeCodes. var subtitle = BuildSubtitleFromRows(); - var beautifier = new Core.Forms.TimeCodesBeautifier(subtitle, _frameRate, new List(), _shotChanges); + var beautifier = new Core.Forms.TimeCodesBeautifier(subtitle, _frameRate, ActiveTimeCodes(), _shotChanges); beautifier.Beautify(); Avalonia.Threading.Dispatcher.UIThread.Post(() => @@ -202,9 +217,16 @@ private void UpdateStatsLine() { var lang = Se.Language.Tools.BeautifyTimeCodes; var fps = _frameRate.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture); + + // With exact time codes the frame grid comes from the video, so showing only the nominal + // frame rate would misrepresent what the cues are being snapped to. + var frameSource = UseExactTimeCodes && _timeCodes.Count > 0 + ? $"{fps} ({lang.UseExactTimeCodes})" + : fps; + StatsLine = $"{lang.SubtitlesCount}: {_originalSubtitles.Count} · " + $"{lang.ChangedCount}: {_changedIndices.Count} · " + - $"{Se.Language.General.FrameRate}: {fps} · " + + $"{Se.Language.General.FrameRate}: {frameSource} · " + $"{lang.ShotChangesCount}: {_shotChanges.Count}"; } @@ -492,8 +514,11 @@ public void Initialize(List subtitles, Controls.AudioVisu _shotChanges = audioVisualizer.ShotChanges ?? new List(); _frameRate = 25.0; + _videoDurationSeconds = 0; + _videoFileName = string.Empty; if (!string.IsNullOrEmpty(videoFileName) && System.IO.File.Exists(videoFileName)) { + _videoFileName = videoFileName; try { var mediaInfo = Logic.Media.FfmpegMediaInfo2.Parse(videoFileName); @@ -501,6 +526,8 @@ public void Initialize(List subtitles, Controls.AudioVisu { _frameRate = (double)mediaInfo.FramesRate; } + + _videoDurationSeconds = mediaInfo.Duration?.TotalSeconds ?? 0; } catch { @@ -508,6 +535,24 @@ public void Initialize(List subtitles, Controls.AudioVisu } } + // Extracting time codes means decoding the whole video, so a previous run is reused. + _timeCodes = new List(); + try + { + _timeCodes = Logic.Media.TimeCodesHelper.FromDisk(_videoFileName); + } + catch (Exception exception) + { + Se.LogError(exception, "Reading cached video time codes failed"); + } + + if (!Logic.Media.TimeCodesHelper.IsUsableFor(_timeCodes, _videoDurationSeconds)) + { + _timeCodes = new List(); + } + + UseExactTimeCodes = Se.Settings.BeautifyTimeCodes.ExtractExactTimeCodes && _timeCodes.Count > 0; + UpdateTimeCodesStatus(); UpdateStatsLine(); Avalonia.Threading.Dispatcher.UIThread.Post(() => @@ -595,6 +640,123 @@ private async Task EditProfile() } } + /// + /// The time codes the beautifier should use. Empty unless the user asked for exact time codes + /// and a usable set is loaded - the beautifier then falls back to n/fps arithmetic. + /// + private List ActiveTimeCodes() + { + return UseExactTimeCodes ? _timeCodes : NoTimeCodes; + } + + partial void OnUseExactTimeCodesChanged(bool value) + { + Se.Settings.BeautifyTimeCodes.ExtractExactTimeCodes = value; + // Mirror into libse too: the profile dialog does + // Se.Settings.BeautifyTimeCodes.CopyFrom(Configuration.Settings.BeautifyTimeCodes), + // so leaving the libse copy stale let opening that dialog revert this tick. + Configuration.Settings.BeautifyTimeCodes.ExtractExactTimeCodes = value; + UpdateTimeCodesStatus(); + _dirty = true; // re-run beautify against the other frame grid + } + + private void UpdateTimeCodesStatus() + { + var lang = Se.Language.Tools.BeautifyTimeCodes; + + if (IsExtractingTimeCodes) + { + TimeCodesStatus = lang.ExtractingTimeCodes; + } + else + { + TimeCodesStatus = _timeCodes.Count > 0 + ? string.Format(lang.XTimeCodesLoaded, _timeCodes.Count) + : lang.NoTimeCodesLoaded; + } + + CanExtractTimeCodes = !IsExtractingTimeCodes && + _timeCodes.Count == 0 && + !string.IsNullOrEmpty(_videoFileName); + + UpdateStatsLine(); + } + + [RelayCommand] + private async Task ExtractTimeCodes() + { + if (IsExtractingTimeCodes || string.IsNullOrEmpty(_videoFileName)) + { + return; + } + + IsExtractingTimeCodes = true; + ExtractProgressValue = 0; + UpdateTimeCodesStatus(); + + using var cancellationSource = new CancellationTokenSource(); + _extractCancellation = cancellationSource; + + var progress = new Progress(percent => + { + if (!_disposed) + { + ExtractProgressValue = percent; + } + }); + + var extracted = new List(); + try + { + extracted = await TimeCodesGenerator.ExtractAsync(_videoFileName, _videoDurationSeconds, progress, cancellationSource.Token); + } + catch (Exception exception) + { + Se.LogError(exception, "Extracting video time codes failed"); + } + + // Read the flag while the source is still alive, then unpublish it so Dispose() cannot + // reach the instance this method is about to dispose. + var cancelled = cancellationSource.IsCancellationRequested; + _extractCancellation = null; + IsExtractingTimeCodes = false; + + // A completed run decoded the whole video, so its last frame time is the real duration - + // this keeps completed extractions usable when ffmpeg reported no duration up front + // (IsUsableFor rejects any list it cannot check against a known length). + if (!cancelled && _videoDurationSeconds <= 0 && extracted.Count > 0) + { + _videoDurationSeconds = extracted[extracted.Count - 1]; + } + + // A short list is worse than none: the beautifier maps frame number to list index, so cues + // past the end of a partial extraction would snap to the wrong frames rather than fall + // back to n/fps. Cancelling mid-way lands here too. + if (TimeCodesHelper.IsUsableFor(extracted, _videoDurationSeconds)) + { + _timeCodes = extracted; + try + { + TimeCodesHelper.Save(_videoFileName, extracted); + } + catch (Exception exception) + { + Se.LogError(exception, "Saving video time codes failed"); + } + + UseExactTimeCodes = true; + _dirty = true; // OnUseExactTimeCodesChanged does not fire if the box was already ticked + } + else if (!cancelled) + { + TimeCodesStatus = Se.Language.Tools.BeautifyTimeCodes.TimeCodesExtractFailed; + CanExtractTimeCodes = !string.IsNullOrEmpty(_videoFileName); + return; + } + + UpdateTimeCodesStatus(); + } + [RelayCommand] private void PreviousChange() { @@ -621,7 +783,7 @@ private void Ok() StopPositionTimer(); _timerUpdatePreview.StopAndDispose(TimerUpdatePreviewElapsed); - CommitBeautifiedTimes(_allSubtitles, _frameRate, _shotChanges); + CommitBeautifiedTimes(_allSubtitles, _frameRate, _shotChanges, ActiveTimeCodes()); OkPressed = true; Window?.Close(); @@ -632,7 +794,7 @@ private void Ok() /// start time, as the beautifier expects). The rows keep all their view-model-only state - /// the previous rebuild from Paragraphs silently dropped OriginalText, Id and the ASSA style. /// - internal static void CommitBeautifiedTimes(List rows, double frameRate, List shotChanges) + internal static void CommitBeautifiedTimes(List rows, double frameRate, List shotChanges, List? timeCodes = null) { var ordered = rows.OrderBy(p => p.StartTime.TotalMilliseconds).ToList(); var subtitle = new Subtitle(); @@ -641,7 +803,7 @@ internal static void CommitBeautifiedTimes(List rows, dou subtitle.Paragraphs.Add(vm.ToParagraph()); } - var beautifier = new Core.Forms.TimeCodesBeautifier(subtitle, frameRate, new List(), shotChanges); + var beautifier = new Core.Forms.TimeCodesBeautifier(subtitle, frameRate, timeCodes ?? new List(), shotChanges); beautifier.Beautify(); // Beautify adjusts cue boundaries only, so the paragraphs still line up with the rows. @@ -704,5 +866,16 @@ public void Dispose() _disposed = true; StopPositionTimer(); _timerUpdatePreview.StopAndDispose(TimerUpdatePreviewElapsed); + + // Closing the window must not leave an ffmpeg decode of the whole video running. The + // extract command owns and disposes the source, so only signal it here. + try + { + _extractCancellation?.Cancel(); + } + catch (ObjectDisposedException) + { + // the extraction finished between the null check and Cancel() + } } } diff --git a/src/ui/Features/Tools/BeautifyTimeCodes/BeautifyTimeCodesWindow.cs b/src/ui/Features/Tools/BeautifyTimeCodes/BeautifyTimeCodesWindow.cs index f81cf59bbb0..a1ee086f079 100644 --- a/src/ui/Features/Tools/BeautifyTimeCodes/BeautifyTimeCodesWindow.cs +++ b/src/ui/Features/Tools/BeautifyTimeCodes/BeautifyTimeCodesWindow.cs @@ -62,12 +62,65 @@ private static Control BuildTopBar(BeautifyTimeCodesViewModel vm) var grid = new Grid { - ColumnDefinitions = new ColumnDefinitions("*"), + ColumnDefinitions = new ColumnDefinitions("Auto,*"), }; - grid.Add(stats, 0, 0); + grid.Add(BuildTimeCodesBar(vm), 0, 0); + grid.Add(stats, 0, 1); return grid; } + /// + /// Opt-in for snapping against the video's real frame times rather than an assumed n/fps grid, + /// plus the extraction that produces them (a full decode, so it is explicit and cancellable). + /// + private static Control BuildTimeCodesBar(BeautifyTimeCodesViewModel vm) + { + var l = Se.Language.Tools.BeautifyTimeCodes; + + var checkBoxExact = new CheckBox + { + Content = l.UseExactTimeCodes, + VerticalAlignment = VerticalAlignment.Center, + [!CheckBox.IsCheckedProperty] = new Binding(nameof(vm.UseExactTimeCodes)) + { + Source = vm, + Mode = BindingMode.TwoWay, + UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged, + }, + }; + + if (Se.Settings.Appearance.ShowHints) + { + ToolTip.SetTip(checkBoxExact, l.TimeCodesHint); + } + + var status = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + Opacity = 0.8, + FontSize = 12, + [!TextBlock.TextProperty] = new Binding(nameof(vm.TimeCodesStatus)) { Source = vm }, + }; + + var buttonExtract = UiUtil.MakeButton(l.ExtractTimeCodes, vm.ExtractTimeCodesCommand); + buttonExtract.VerticalAlignment = VerticalAlignment.Center; + buttonExtract.Bind(Button.IsVisibleProperty, new Binding(nameof(vm.CanExtractTimeCodes)) { Source = vm }); + + var progress = UiUtil.MakeProgressBar(); + progress.Width = 140; + progress.VerticalAlignment = VerticalAlignment.Center; + progress.Bind(ProgressBar.ValueProperty, new Binding(nameof(vm.ExtractProgressValue)) { Source = vm }); + progress.Bind(ProgressBar.IsVisibleProperty, new Binding(nameof(vm.IsExtractingTimeCodes)) { Source = vm }); + + return new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + VerticalAlignment = VerticalAlignment.Center, + Children = { checkBoxExact, status, buttonExtract, progress }, + }; + } + private static Control BuildVisualizerArea(BeautifyTimeCodesViewModel vm) { var l = Se.Language.Tools.BeautifyTimeCodes; diff --git a/src/ui/Features/Tools/BeautifyTimeCodes/Profile/BeautifyTimeCodesProfileWindow.cs b/src/ui/Features/Tools/BeautifyTimeCodes/Profile/BeautifyTimeCodesProfileWindow.cs index c3549ee00c4..4b99caecfae 100644 --- a/src/ui/Features/Tools/BeautifyTimeCodes/Profile/BeautifyTimeCodesProfileWindow.cs +++ b/src/ui/Features/Tools/BeautifyTimeCodes/Profile/BeautifyTimeCodesProfileWindow.cs @@ -1,4 +1,4 @@ -using Avalonia; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Data; @@ -80,7 +80,7 @@ public BeautifyTimeCodesProfileWindow(BeautifyTimeCodesProfileViewModel vm) outerGrid.Add(buttonBar, 1, 0); Content = outerGrid; - Activated += delegate { ok.Focus(); }; + UiUtil.FocusOnFirstActivation(this, ok); KeyDown += (_, e) => vm.OnKeyDown(e); // Push initial values into the preview controls once they're attached @@ -191,7 +191,7 @@ private Control BuildGeneralSection() Orientation = Orientation.Horizontal, Spacing = 6, VerticalAlignment = VerticalAlignment.Center, - Children = { labelGap, nudGap, suffix, MakeHintIcon(_l.HintGap) }, + Children = { labelGap, nudGap, suffix, UiUtil.MakeHintIcon(_l.HintGap) }, }; return MakeGroupBox(_l.General, content); @@ -271,7 +271,7 @@ private Control BuildConnectedGroup() { Orientation = Orientation.Horizontal, Spacing = 6, - Children = { labelGap, nudGapInLeft, nudGapInRight, nudGapOutLeft, nudGapOutRight, MakeHintIcon(_l.HintConnected) }, + Children = { labelGap, nudGapInLeft, nudGapInRight, nudGapOutLeft, nudGapOutRight, UiUtil.MakeHintIcon(_l.HintConnected) }, }; var rowZones = MakeZonesRow( @@ -448,7 +448,7 @@ private Control BuildChainingSubPanel( { Orientation = Orientation.Horizontal, Spacing = 6, - Children = { labelBehavior, combo, MakeHintIcon(_l.HintChaining) }, + Children = { labelBehavior, combo, UiUtil.MakeHintIcon(_l.HintChaining) }, }; stack.Children.Add(behaviorRow); } @@ -494,7 +494,7 @@ private Control MakeZonesRow(string leftGreenPath, string leftRedPath, string ri MakeZoneNud(leftRedPath, isGreen: false), MakeZoneNud(rightRedPath, isGreen: false), MakeZoneNud(rightGreenPath, isGreen: true), - MakeHintIcon(_l.HintZones), + UiUtil.MakeHintIcon(_l.HintZones), }, }; } @@ -541,20 +541,6 @@ private NumericUpDown MakeZoneNud(string bindingPath, bool isGreen) return nud; } - private Control MakeHintIcon(string tip) - { - var icon = new ContentControl - { - Width = 16, - Height = 16, - VerticalAlignment = VerticalAlignment.Center, - Opacity = 0.7, - [ToolTip.TipProperty] = tip, - }; - Attached.SetIcon(icon, IconNames.Information); - return icon; - } - private static Control MakeGroupBox(string title, Control content) { var header = new TextBlock diff --git a/src/ui/Features/Tools/BridgeGaps/BridgeGapsViewModel.cs b/src/ui/Features/Tools/BridgeGaps/BridgeGapsViewModel.cs index 772b9bf86ae..215ee3d28c1 100644 --- a/src/ui/Features/Tools/BridgeGaps/BridgeGapsViewModel.cs +++ b/src/ui/Features/Tools/BridgeGaps/BridgeGapsViewModel.cs @@ -82,13 +82,12 @@ private void UpdatePreview() { _dic = new Dictionary(); var fixedIndexes = new List(Subtitles.Count); + // Both values are milliseconds - that is what the labels say, what the settings keys + // (BridgeGapsSmallerThanMs / MinGapMs) hold, and how MergeShortLines reads the same + // stored numbers. Converting them with FramesToMilliseconds in HH:MM:SS:FF mode turned + // the 2000 ms default into 80 000 ms at 25 fps. var minMsBetweenLines = MinGapMs; var maxMs = BridgeGapsSmallerThanMs; - if (Configuration.Settings.General.UseTimeFormatHHMMSSFF) - { - minMsBetweenLines = SubtitleFormat.FramesToMilliseconds(minMsBetweenLines); - maxMs = SubtitleFormat.FramesToMilliseconds(maxMs); - } var allSubtitles = new ObservableCollection(AllSubtitles.Select(p => new SubtitleLineViewModel(p))); var fixedCount = DurationsBridgeGaps2.BridgeGaps(allSubtitles, minMsBetweenLines, PercentForLeft, maxMs, fixedIndexes, _dic, Configuration.Settings.General.UseTimeFormatHHMMSSFF); @@ -158,6 +157,14 @@ private void SaveSettings() [RelayCommand] private void Ok() { + // Apply what the current settings produce, not what the 250/500 ms preview timer + // last computed: clicking OK right after changing a value handed the caller the + // previous settings' result while saving the new ones. + if (_dirty) + { + UpdatePreview(); + } + SaveSettings(); OkPressed = true; Window?.Close(); diff --git a/src/ui/Features/Tools/BridgeGaps/BridgeGapsWindow.cs b/src/ui/Features/Tools/BridgeGaps/BridgeGapsWindow.cs index aa1d453a3c5..23538999b3e 100644 --- a/src/ui/Features/Tools/BridgeGaps/BridgeGapsWindow.cs +++ b/src/ui/Features/Tools/BridgeGaps/BridgeGapsWindow.cs @@ -78,7 +78,7 @@ public BridgeGapsWindow(BridgeGapsViewModel vm) Content = grid; - Activated += delegate { numericUpDownBridgeGapSmallerThan.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, numericUpDownBridgeGapSmallerThan); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += (_, e) => vm.OnKeyDown(e); Closing += delegate { UiUtil.SaveWindowPosition(this); }; diff --git a/src/ui/Features/Tools/ChangeCasing/ChangeCasingViewModel.cs b/src/ui/Features/Tools/ChangeCasing/ChangeCasingViewModel.cs index a282eed7d92..1669ebd34cb 100644 --- a/src/ui/Features/Tools/ChangeCasing/ChangeCasingViewModel.cs +++ b/src/ui/Features/Tools/ChangeCasing/ChangeCasingViewModel.cs @@ -95,8 +95,19 @@ private async Task Ok() if (NormalCasingFixNames) { var result = await ShowFixNames(_subtitle, fixCasing.NoOfLinesChanged); - Subtitle = result.Subtitle; - OkPressed = result.OkPressed; + if (result.OkPressed) + { + Subtitle = result.Subtitle; + Info = result.Info; + } + else + { + // Cancelling the names step only skips the name fixes - the normal casing + // was already confirmed with OK on this dialog, so it still applies (as in SE4) + Subtitle = _subtitle; + } + + OkPressed = true; Window?.Close(); return; } @@ -125,7 +136,7 @@ private async Task ShowFixNames(Subtitle subtitle, int noOfFi { var result = await _windowService.ShowDialogAsync(Window!, vm => { - vm.Initialize(subtitle); + vm.Initialize(subtitle, noOfFixes); }); return result; diff --git a/src/ui/Features/Tools/ChangeCasing/ChangeCasingWindow.cs b/src/ui/Features/Tools/ChangeCasing/ChangeCasingWindow.cs index beaf7450cd3..48add2a65c0 100644 --- a/src/ui/Features/Tools/ChangeCasing/ChangeCasingWindow.cs +++ b/src/ui/Features/Tools/ChangeCasing/ChangeCasingWindow.cs @@ -104,7 +104,7 @@ public ChangeCasingWindow(ChangeCasingViewModel vm) Content = grid; - Activated += delegate { radioButtonNormalCasing.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, radioButtonNormalCasing); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += (_, e) => vm.OnKeyDown(e); } } diff --git a/src/ui/Features/Tools/ChangeCasing/FixNameHitItem.cs b/src/ui/Features/Tools/ChangeCasing/FixNameHitItem.cs index 4af724e64a4..66d3cc1f2be 100644 --- a/src/ui/Features/Tools/ChangeCasing/FixNameHitItem.cs +++ b/src/ui/Features/Tools/ChangeCasing/FixNameHitItem.cs @@ -16,7 +16,10 @@ public FixNameHitItem(string name, int lineIndex, string before, string after, b { Name = name; LineIndex = lineIndex; - LineIndexDisplay = lineIndex + 1; + // The producer passes Paragraph.Number, which is already 1-based (the apply path relies + // on that too, indexing Paragraphs[LineIndex - 1]) - adding one showed every hit against + // the following line, and the last line as a row number that does not exist. + LineIndexDisplay = lineIndex; Before = before; After = after; IsEnabled = isEnabled; diff --git a/src/ui/Features/Tools/ChangeCasing/FixNamesLogic.cs b/src/ui/Features/Tools/ChangeCasing/FixNamesLogic.cs new file mode 100644 index 00000000000..ac841c1e288 --- /dev/null +++ b/src/ui/Features/Tools/ChangeCasing/FixNamesLogic.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Nikse.SubtitleEdit.Core.Common; + +namespace Nikse.SubtitleEdit.Features.Tools.ChangeCasing; + +/// +/// The name-casing engine behind "Fix names": which names from the dictionary occur in the +/// subtitle with the wrong casing, and what each line looks like once they are fixed. +/// Kept free of UI state so batch convert runs exactly what the dialog previews - batch has +/// no place to show the name list, but it must not silently do nothing either. +/// +internal static class FixNamesLogic +{ + private const string PrefixChars = "([ --'>\r\n¿¡\"”“„"; + private const string SuffixChars = " ,.!?:;…')]<-\"\r\n"; + + /// + /// Names the dialog leaves unchecked by default: real words often capitalized mid-sentence, + /// where "fixing" the casing is usually wrong. Batch skips them for the same reason. + /// + private static readonly string[] CommonWords = ["US", "Lane", "Bill", "Rose"]; + + /// + /// Finds dictionary names that occur in the subtitle with different casing. IsChecked mirrors + /// how the dialog pre-selects each hit, so an unattended run applies the same subset. + /// + internal static List<(string Name, bool IsChecked)> FindNames(Subtitle subtitle, IEnumerable names, string extraNames, string language) + { + var text = HtmlUtil.RemoveHtmlTags(subtitle.GetAllTexts()); + + var allNames = names.ToList(); + foreach (var s in (extraNames ?? string.Empty).Split(',')) + { + var extra = s.Trim(); + if (extra.Length > 1 && !allNames.Contains(extra)) + { + allNames.Add(extra); + } + } + + const string english = "en"; + const string dont = "don't"; + + var usedNames = new HashSet(); + var result = new List<(string Name, bool IsChecked)>(); + + foreach (var name in allNames) + { + // filter out invalid names + if (name.Length <= 1 || name == name.ToLowerInvariant()) + { + continue; + } + + var startIndex = text.IndexOf(name, StringComparison.OrdinalIgnoreCase); + while (startIndex >= 0) + { + if (IsWordBoundary(text, startIndex, name) && !text.AsSpan().Slice(startIndex, name.Length).Equals(name, StringComparison.Ordinal)) // do not add names where casing already is correct + { + if (!usedNames.Contains(name)) + { + var skip = false; + var isChecked = true; + if (language.StartsWith(english, StringComparison.OrdinalIgnoreCase)) + { + skip = text.AsSpan()[startIndex..].StartsWith(dont, StringComparison.OrdinalIgnoreCase); + isChecked = !CommonWords.Contains(name); + } + + if (!skip) + { + usedNames.Add(name); + result.Add((name, isChecked)); + break; // break while + } + } + } + + startIndex = text.IndexOf(name, startIndex + name.Length, StringComparison.OrdinalIgnoreCase); + } + } + + return result; + } + + /// + /// Returns the line with every given name re-cased, or the unchanged line if nothing applies. + /// + internal static string ApplyNames(string paragraphText, IReadOnlyList activeNames) + { + var text = paragraphText; + + // reusable array + var processingNames = new string[1]; + + foreach (var name in activeNames) + { + // no extra processing if paragraph doesn't contain name + if (!text.Contains(name, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var textNoTags = HtmlUtil.RemoveHtmlTags(text, true); + + // has letter and not already uppercase + if (textNoTags != textNoTags.ToUpperInvariant()) + { + var st = new StrippableText(text); + processingNames[0] = name; + st.FixCasing(processingNames, true, false, false, string.Empty); + text = st.MergedString; + } + } + + return text; + } + + private static bool IsWordBoundary(string text, int startIndex, string name) + { + var afterNameIndex = startIndex + name.Length; + return (startIndex == 0 || PrefixChars.Contains(text[startIndex - 1])) + && (afterNameIndex == text.Length || SuffixChars.Contains(text[afterNameIndex])); + } +} diff --git a/src/ui/Features/Tools/ChangeCasing/FixNamesViewModel.cs b/src/ui/Features/Tools/ChangeCasing/FixNamesViewModel.cs index 9ed4140ca87..7d6477685ca 100644 --- a/src/ui/Features/Tools/ChangeCasing/FixNamesViewModel.cs +++ b/src/ui/Features/Tools/ChangeCasing/FixNamesViewModel.cs @@ -34,10 +34,6 @@ public partial class FixNamesViewModel : ObservableObject, IClosingCleanup private NameList? _nameList; private List _nameListInclMulti; private string _language; - private const string PrefixChars = "([ --'>\r\n¿¡\"”“„"; - private const string SuffixChars = " ,.!?:;…')]<-\"\r\n"; - private static readonly string[] CommonWords = ["US", "Lane", "Bill", "Rose"]; - private readonly HashSet _usedNames; public FixNamesViewModel() { @@ -48,14 +44,18 @@ public FixNamesViewModel() _language = "en_US"; _subtitleBefore = new Subtitle(); _subtitle = new Subtitle(); - _usedNames = new HashSet(); ExtraNames = string.Empty; Info = string.Empty; Subtitle = new Subtitle(); } - internal void Initialize(Subtitle subtitle) + // Lines already changed by the step that opened this dialog (normal casing), so the + // final "lines changed" info covers the whole operation, like SE4's combined count. + private int _noOfFixesBefore; + + internal void Initialize(Subtitle subtitle, int noOfFixesBefore = 0) { + _noOfFixesBefore = noOfFixesBefore; subtitle.Renumber(); _subtitle = new Subtitle(subtitle); _subtitleBefore = subtitle; @@ -68,59 +68,11 @@ internal void Initialize(Subtitle subtitle) private void FindAllNames() { - var text = HtmlUtil.RemoveHtmlTags(_subtitle.GetAllTexts()); - _nameListInclMulti = _nameList!.GetAllNames(); // Will contains both one word names and multi names - foreach (var s in ExtraNames.Split(',')) - { - var name = s.Trim(); - if (name.Length > 1 && !_nameListInclMulti.Contains(name)) - { - _nameListInclMulti.Add(name); - } - } - _usedNames.Clear(); - var names = new List(); - - const string english = "en"; - const string dont = "don't"; - - foreach (var name in _nameListInclMulti) - { - // filter out invalid names - if (name.Length <= 1 || name == name.ToLowerInvariant()) - { - continue; - } - - var startIndex = text.IndexOf(name, StringComparison.OrdinalIgnoreCase); - while (startIndex >= 0) - { - if (IsWordBoundary(text, startIndex, name) && !text.AsSpan().Slice(startIndex, name.Length).Equals(name, StringComparison.Ordinal)) // do not add names where casing already is correct - { - if (!_usedNames.Contains(name)) - { - var skip = false; - var isChecked = true; - if (_language.StartsWith(english, StringComparison.OrdinalIgnoreCase)) - { - skip = text.AsSpan()[startIndex..].StartsWith(dont, StringComparison.OrdinalIgnoreCase); - isChecked = !CommonWords.Contains(name); - } - - if (!skip) - { - _usedNames.Add(name); - names.Add(new FixNameItem(name, isChecked)); - break; // break while - } - } - } - - startIndex = text.IndexOf(name, startIndex + name.Length, StringComparison.OrdinalIgnoreCase); - } - } + var names = FixNamesLogic.FindNames(_subtitle, _nameListInclMulti, ExtraNames, _language) + .Select(n => new FixNameItem(n.Name, n.IsChecked)) + .ToList(); foreach (var item in names) { @@ -134,13 +86,6 @@ private void FindAllNames() Names.AddRange(names); } - private static bool IsWordBoundary(string text, int startIndex, string name) - { - var afterNameIndex = startIndex + name.Length; - return (startIndex == 0 || PrefixChars.Contains(text[startIndex - 1])) - && (afterNameIndex == text.Length || SuffixChars.Contains(text[afterNameIndex])); - } - private CancellationTokenSource? _cancellationTokenSource; private bool _isClosing; private bool _previewPending; @@ -199,31 +144,9 @@ private void GeneratePreview(string[] activeNames, CancellationToken token) { var hits = new List(); - // reusable array - var processingNames = new string[1]; - foreach (var p in _subtitle.Paragraphs) { - var text = p.Text; - foreach (var name in activeNames) - { - // no extra processing if paragraph doesn't contain name - if (!text.Contains(name, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var textNoTags = HtmlUtil.RemoveHtmlTags(text, true); - - // has letter and not already uppercase - if (textNoTags != textNoTags.ToUpperInvariant()) - { - var st = new StrippableText(text); - processingNames[0] = name; - st.FixCasing(processingNames, true, false, false, string.Empty); - text = st.MergedString; - } - } + var text = FixNamesLogic.ApplyNames(p.Text, activeNames); if (text != p.Text) { @@ -331,7 +254,7 @@ private async Task Ok() } } - Info = $"Change casing - lines changed: {noOfLinesChanged}"; + Info = $"Change casing - lines changed: {noOfLinesChanged + _noOfFixesBefore}"; OkPressed = true; Window?.Close(); diff --git a/src/ui/Features/Tools/ChangeFormatting/ChangeFormattingViewModel.cs b/src/ui/Features/Tools/ChangeFormatting/ChangeFormattingViewModel.cs index 3ea71cbf9be..67df9729d71 100644 --- a/src/ui/Features/Tools/ChangeFormatting/ChangeFormattingViewModel.cs +++ b/src/ui/Features/Tools/ChangeFormatting/ChangeFormattingViewModel.cs @@ -83,31 +83,40 @@ public void OnClosingCleanup() } private void UpdatePreview() + { + Dispatcher.UIThread.Post(BuildPreview); + } + + /// + /// Rebuilds the preview rows from the current from/to types and colour. Must run on the UI + /// thread; kept separate from so can build the + /// result it hands to the caller. Ok already runs on the UI thread, so posting the work from + /// there only queued it until after Ok had returned and the window had closed - the guard in + /// Ok then read an empty (just opened) or stale (setting just changed) collection. + /// + private void BuildPreview() { if (SelectedFromType == null || SelectedToType == null) { return; } - var allSubtitles = new ObservableCollection(AllSubtitles.Select(p => new SubtitleLineViewModel(p))); + var allSubtitles = AllSubtitles.Select(p => new SubtitleLineViewModel(p)).ToList(); var fixedCount = 0; - Dispatcher.UIThread.Post(() => + Subtitles.Clear(); + foreach (var v in allSubtitles) { - Subtitles.Clear(); - foreach (var v in allSubtitles) + var vm = new ChangeFormattingDisplayItem(v); + vm.NewText = FormattingReplacer.Replace(v.Text, SelectedFromType.Type, SelectedToType.Type, SelectedColor, _format); + if (vm.Text != vm.NewText) { - var vm = new ChangeFormattingDisplayItem(v); - vm.NewText = FormattingReplacer.Replace(v.Text, SelectedFromType.Type, SelectedToType.Type, SelectedColor, _format); - if (vm.Text != vm.NewText) - { - vm.SubtitleLineViewModel.Text = vm.NewText; - fixedCount++; - } - Subtitles.Add(vm); + vm.SubtitleLineViewModel.Text = vm.NewText; + fixedCount++; } + Subtitles.Add(vm); + } - StatusText = string.Format(Se.Language.General.LinesChangedX, fixedCount); - }); + StatusText = string.Format(Se.Language.General.LinesChangedX, fixedCount); } public void Initialize(List subtitles, SubtitleFormat format) @@ -138,6 +147,23 @@ private void SaveSettings() [RelayCommand] private void Ok() { + // Subtitles is only ever filled by the 500 ms preview timer, so before the first tick it + // is empty - and applying an empty result made the caller's count check fail and clear + // the whole grid. Build it now if a change is pending or nothing has been built yet. + // BuildPreview, not UpdatePreview: the latter posts to the UI thread we are already on. + if (_dirty || Subtitles.Count == 0) + { + _dirty = false; + BuildPreview(); + } + + if (Subtitles.Count == 0) + { + // Nothing to apply - close without touching the subtitle. + Window?.Close(); + return; + } + FixedSubtitle = Subtitles.Select(p => new SubtitleLineViewModel(p.SubtitleLineViewModel)).ToList(); SaveSettings(); OkPressed = true; diff --git a/src/ui/Features/Tools/ChangeFormatting/ChangeFormattingWindow.cs b/src/ui/Features/Tools/ChangeFormatting/ChangeFormattingWindow.cs index 7d2c3435872..948b3234452 100644 --- a/src/ui/Features/Tools/ChangeFormatting/ChangeFormattingWindow.cs +++ b/src/ui/Features/Tools/ChangeFormatting/ChangeFormattingWindow.cs @@ -78,7 +78,7 @@ public ChangeFormattingWindow(ChangeFormattingViewModel vm) Content = grid; - Activated += delegate { comboBoxFrom.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, comboBoxFrom); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += (_, e) => vm.OnKeyDown(e); Closing += delegate { UiUtil.SaveWindowPosition(this); }; @@ -145,7 +145,7 @@ private static Border MakeSubtitleView(ChangeFormattingViewModel vm) if (!string.IsNullOrEmpty(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName)) { - textBlock.FontFamily = new FontFamily(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); + textBlock.FontFamily = FontFamilyHelper.Make(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); } border.Child = textBlock; @@ -174,7 +174,7 @@ private static Border MakeSubtitleView(ChangeFormattingViewModel vm) if (!string.IsNullOrEmpty(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName)) { - textBlock.FontFamily = new FontFamily(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); + textBlock.FontFamily = FontFamilyHelper.Make(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); } border.Child = textBlock; diff --git a/src/ui/Features/Tools/ConvertActors/ConvertActorsViewModel.cs b/src/ui/Features/Tools/ConvertActors/ConvertActorsViewModel.cs index 08261811359..ca983bb509f 100644 --- a/src/ui/Features/Tools/ConvertActors/ConvertActorsViewModel.cs +++ b/src/ui/Features/Tools/ConvertActors/ConvertActorsViewModel.cs @@ -141,38 +141,29 @@ private void UpdatePreview() if (fromSquare && Contains(p.Text, '[', ']')) { - ProcessBracketActors(vm, p, '[', ']', converter, changeCasing, color, items, ref count); + var result = converter.FixActors(p, '[', ']', changeCasing, color); + AddConversion(vm, oldText, result, converter, items, ref count); } else if (fromParentheses && Contains(p.Text, '(', ')')) { - ProcessBracketActors(vm, p, '(', ')', converter, changeCasing, color, items, ref count); + var result = converter.FixActors(p, '(', ')', changeCasing, color); + AddConversion(vm, oldText, result, converter, items, ref count); } else if (fromColon && p.Text.Contains(':')) { - var newText = converter.FixActorsFromBeforeColon(p, ':', changeCasing, color); - if (newText != oldText) - { - var updatedVm = new SubtitleLineViewModel(vm); - updatedVm.Text = newText; - items.Add(new ConvertActorsDisplayItem(vm) { NewText = newText, IsChecked = true, UpdatedViewModel = updatedVm }); - count++; - } + var result = converter.FixActorsFromBeforeColon(p, ':', changeCasing, color); + AddConversion(vm, oldText, result, converter, items, ref count); } else if (fromActor && !string.IsNullOrEmpty(p.Actor)) { - var newText = converter.FixActorsFromActor(p, changeCasing, color); - if (newText != oldText) - { - var updatedVm = new SubtitleLineViewModel(vm); - updatedVm.Text = newText; - items.Add(new ConvertActorsDisplayItem(vm) { NewText = newText, IsChecked = true, UpdatedViewModel = updatedVm }); - count++; - } + var result = converter.FixActorsFromActor(p, changeCasing, color); + AddConversion(vm, oldText, result, converter, items, ref count); } } var statusText = string.Format(Se.Language.Tools.ConvertActors.NumberOfConversionsX, count); - Dispatcher.UIThread.Post(() => + + void Apply() { Subtitles.Clear(); foreach (var item in items) @@ -181,35 +172,55 @@ private void UpdatePreview() } StatusText = statusText; - }); + } + + // Apply inline when already on the UI thread so Ok() can refresh synchronously; posting + // unconditionally meant a refresh requested from Ok ran only after Ok had finished. + if (Dispatcher.UIThread.CheckAccess()) + { + Apply(); + } + else + { + Dispatcher.UIThread.Post(Apply); + } } - private void ProcessBracketActors( + /// + /// Turns one converted paragraph into a preview row - plus a second row when converting to the + /// actor column split a two-speaker paragraph in two. + /// + private void AddConversion( SubtitleLineViewModel vm, - Paragraph p, - char startChar, - char endChar, + string oldText, + ActorConverterResult result, ActorConverter converter, - int? changeCasing, - SkiaSharp.SKColor? color, List items, ref int count) { - var oldText = p.Text; - var result = converter.FixActors(p, startChar, endChar, changeCasing, color); if (result.Skip) { return; } + var newText = result.Paragraph.Text; + + // A row loaded from a format without actors has a null actor, the converter writes an empty + // string - not a change worth listing. + var newActor = result.Paragraph.Actor ?? string.Empty; + if (newText == oldText && newActor == (vm.Actor ?? string.Empty) && result.NextParagraph == null) + { + return; + } + var isChecked = result.Selected || !OnlyNames; var updatedVm = new SubtitleLineViewModel(vm); - updatedVm.Text = result.Paragraph.Text; - updatedVm.Actor = result.Paragraph.Actor; + updatedVm.Text = newText; + updatedVm.Actor = newActor; items.Add(new ConvertActorsDisplayItem(vm) { - NewText = result.Paragraph.Text, + NewText = newText, IsChecked = isChecked, UpdatedViewModel = updatedVm, }); @@ -219,7 +230,7 @@ private void ProcessBracketActors( { var nextVm = new SubtitleLineViewModel(vm, generateNewId: true); nextVm.Text = result.NextParagraph.Text; - nextVm.Actor = result.NextParagraph.Actor; + nextVm.Actor = result.NextParagraph.Actor ?? string.Empty; items.Add(new ConvertActorsDisplayItem(vm) { @@ -286,6 +297,16 @@ private void SaveSettings() [RelayCommand] private void Ok() { + // Apply what the CURRENT settings produce, not what the 500 ms preview timer last + // computed: clicking OK right after changing the from/to type, colour or casing applied + // the previous settings' conversions while SaveSettings persisted the new ones (and + // before the first tick it applied nothing at all). Every sibling dialog in this folder + // already does this. + if (_dirty) + { + UpdatePreview(); + } + var checkedChanges = Subtitles .Where(s => s.IsChecked && !s.IsNextParagraph) .ToDictionary(s => s.OriginalId, s => s); diff --git a/src/ui/Features/Tools/ConvertActors/ConvertActorsWindow.cs b/src/ui/Features/Tools/ConvertActors/ConvertActorsWindow.cs index 24c6993c7c6..b9a674aa4d5 100644 --- a/src/ui/Features/Tools/ConvertActors/ConvertActorsWindow.cs +++ b/src/ui/Features/Tools/ConvertActors/ConvertActorsWindow.cs @@ -130,7 +130,7 @@ public ConvertActorsWindow(ConvertActorsViewModel vm) Content = grid; - Activated += delegate { comboBoxFrom.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, comboBoxFrom); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += (_, e) => vm.OnKeyDown(e); Closing += delegate { UiUtil.SaveWindowPosition(this); }; @@ -201,7 +201,7 @@ private static Border MakeSubtitleView(ConvertActorsViewModel vm) }; if (!string.IsNullOrEmpty(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName)) { - textBlock.FontFamily = new FontFamily(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); + textBlock.FontFamily = FontFamilyHelper.Make(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); } border.Child = textBlock; return border; @@ -224,7 +224,7 @@ private static Border MakeSubtitleView(ConvertActorsViewModel vm) }; if (!string.IsNullOrEmpty(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName)) { - textBlock.FontFamily = new FontFamily(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); + textBlock.FontFamily = FontFamilyHelper.Make(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); } border.Child = textBlock; return border; diff --git a/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsLogWindow.cs b/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsLogWindow.cs index 95255bada69..88fd56f8625 100644 --- a/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsLogWindow.cs +++ b/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsLogWindow.cs @@ -73,7 +73,7 @@ public FixCommonErrorsLogWindow(FixCommonErrorsLogViewModel vm) Content = grid; - Activated += delegate { buttonDone.Focus(); }; + UiUtil.FocusOnFirstActivation(this, buttonDone); KeyDown += (s, e) => vm.OnKeyDown(e); } } diff --git a/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsProfileWindow.cs b/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsProfileWindow.cs index 8d96e54736a..c03473d4f80 100644 --- a/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsProfileWindow.cs +++ b/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsProfileWindow.cs @@ -132,7 +132,7 @@ public FixCommonErrorsProfileWindow(FixCommonErrorsProfileViewModel vm) Content = grid; - Activated += delegate { Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, () => { Focus(); }); // hack to make OnKeyDown work } protected override void OnKeyDown(KeyEventArgs e) diff --git a/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsViewModel.cs b/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsViewModel.cs index 81b309554f0..f9765eb404f 100644 --- a/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsViewModel.cs +++ b/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsViewModel.cs @@ -183,7 +183,10 @@ private void SaveProfiles() SelectedRules = new List() }; - foreach (var rule in profile.FixRules) + // The grid collection (FixRules) may be filtered by the search box - persist + // from the full list so hidden rules keep their selection. + var rules = profile.AllFixRules.Count > 0 ? profile.AllFixRules : profile.FixRules.ToList(); + foreach (var rule in rules) { if (rule.IsSelected) { @@ -211,6 +214,7 @@ private void LoadProfiles() IsSelected = setting.SelectedRules.Contains(rule.FixCommonErrorFunctionName) })) }; + profile.AllFixRules = profile.FixRules.ToList(); Profiles.Add(profile); } @@ -714,7 +718,10 @@ private void ApplyFixes() canonicalOrder.TryAdd(_allFixRules[i].FixCommonErrorFunctionName, i); } - var selectedRules = SelectedProfile.FixRules + var profileRules = SelectedProfile.AllFixRules.Count > 0 + ? (IEnumerable)SelectedProfile.AllFixRules // full set - FixRules may be search-filtered + : SelectedProfile.FixRules; + var selectedRules = profileRules .Where(f => f.IsSelected) .OrderBy(f => canonicalOrder.TryGetValue(f.FixCommonErrorFunctionName, out var order) ? order : int.MaxValue) .ToList(); // OrderBy is stable, so unknown rules keep their relative order at the end @@ -801,7 +808,10 @@ public static List MakeDefaultRules() FixLongDisplayTimes.Language.FixLongDisplayTime = language.FixLongDisplayTime; FixShortGaps.Language.FixShortGaps = language.FixShortGaps; - FixShortGaps.Language.FixShortGaps = language.FixShortGaps; + + // The per-fix action label ("Fix short gap") is the singular; assigning the plural twice + // left every fixed gap in the results list showing the untranslated English string. + FixShortGaps.Language.FixShortGap = language.FixShortGap; FixInvalidItalicTags.Language.FixInvalidItalicTags = language.FixInvalidItalicTags; FixInvalidItalicTags.Language.FixInvalidItalicTag = language.FixInvalidItalicTag; @@ -958,9 +968,15 @@ internal void TextBoxSearch_TextChanged(object? sender, TextChangedEventArgs e) return; } - var rules = SelectedProfile.FixRules.ToList(); + // Filter from the profile's full rule list, never from the already-filtered grid + // collection - filtering that one is one-way and permanently loses rules. + if (SelectedProfile.AllFixRules.Count == 0) + { + SelectedProfile.AllFixRules = SelectedProfile.FixRules.ToList(); + } + SelectedProfile.FixRules.Clear(); - foreach (var rule in rules) + foreach (var rule in SelectedProfile.AllFixRules) { if (string.IsNullOrEmpty(SearchText) || rule.Name.ToLowerInvariant().Contains(SearchText.ToLowerInvariant())) { diff --git a/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsWindow.cs b/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsWindow.cs index f60812833d1..467de862435 100644 --- a/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsWindow.cs +++ b/src/ui/Features/Tools/FixCommonErrors/FixCommonErrorsWindow.cs @@ -357,7 +357,7 @@ void FocusStepButton() } }; - Activated += delegate { FocusStepButton(); }; + UiUtil.FocusOnFirstActivation(this, () => { FocusStepButton(); }); Closing += delegate { UiUtil.SaveWindowPosition(this); }; Loaded += delegate { UiUtil.RestoreWindowPosition(this); }; @@ -749,7 +749,7 @@ private Grid MakeStep2Grid() }; if (!string.IsNullOrEmpty(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName)) { - textBlock.FontFamily = new FontFamily(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); + textBlock.FontFamily = FontFamilyHelper.Make(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); } return new Border @@ -848,7 +848,7 @@ private Grid MakeStep2EditPanel() }; if (!string.IsNullOrEmpty(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName)) { - textBox.FontFamily = new FontFamily(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); + textBox.FontFamily = FontFamilyHelper.Make(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); } AutomationProperties.SetName(textBox, Se.Language.General.Text); diff --git a/src/ui/Features/Tools/FixCommonErrors/FixCommonOcrErrors.cs b/src/ui/Features/Tools/FixCommonErrors/FixCommonOcrErrors.cs index 7b6e358d4e8..b99e1b5369e 100644 --- a/src/ui/Features/Tools/FixCommonErrors/FixCommonOcrErrors.cs +++ b/src/ui/Features/Tools/FixCommonErrors/FixCommonOcrErrors.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; using Nikse.SubtitleEdit.Core.Interfaces; using Nikse.SubtitleEdit.Features.Ocr.FixEngine; using Nikse.SubtitleEdit.Features.Ocr.OcrSubtitle; @@ -19,6 +20,8 @@ public static class Language public static string FixText { get; set; } = "Fix common OCR errors"; } + public FixType FixType => FixType.Ocr; + public void Fix(Subtitle subtitle, IFixCallbacks callbacks) { if (OcrFixEngine == null) diff --git a/src/ui/Features/Tools/FixCommonErrors/ProfileDisplayItem.cs b/src/ui/Features/Tools/FixCommonErrors/ProfileDisplayItem.cs index 9a832186664..ee7b334673b 100644 --- a/src/ui/Features/Tools/FixCommonErrors/ProfileDisplayItem.cs +++ b/src/ui/Features/Tools/FixCommonErrors/ProfileDisplayItem.cs @@ -1,4 +1,5 @@ -using System.Collections.ObjectModel; +using System.Collections.Generic; +using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; namespace Nikse.SubtitleEdit.Features.Tools.FixCommonErrors; @@ -8,11 +9,19 @@ public partial class ProfileDisplayItem : ObservableObject [ObservableProperty] private string _name; public ObservableCollection FixRules { get; set; } - + + /// + /// Every rule of the profile, independent of the search filter applied to + /// (the grid's collection). Same item instances, so selection + /// state is shared. Apply/save must read this list - the filtered one loses rules. + /// + public List AllFixRules { get; set; } + public ProfileDisplayItem() { Name = string.Empty; FixRules = new ObservableCollection(); + AllFixRules = new List(); } public override string ToString() { diff --git a/src/ui/Features/Tools/FixNetflixErrors/FixNetflixErrorsViewModel.cs b/src/ui/Features/Tools/FixNetflixErrors/FixNetflixErrorsViewModel.cs index e999d88d4ba..ff743697fcf 100644 --- a/src/ui/Features/Tools/FixNetflixErrors/FixNetflixErrorsViewModel.cs +++ b/src/ui/Features/Tools/FixNetflixErrors/FixNetflixErrorsViewModel.cs @@ -98,7 +98,12 @@ public FixNetflixErrorsViewModel(IWindowService windowService, IFileHelper fileH public void Initialize(Subtitle subtitle, string videoFileName) { - _subtitle = subtitle; + // Snapshot with the ids kept: the caller hands over the live working subtitle, which the + // auto-backup timer rebuilds (fresh paragraph ids) on any tick while this dialog is open. + // Reading it again at OK time would then hand back ids the caller's row map has never + // seen, and the id-based apply (#14053) degrades to a full row rebuild that empties the + // original column. Fix common errors snapshots the same way. + _subtitle = new Subtitle(subtitle, false); _videoFileName = videoFileName; _ = Task.Run(() => @@ -192,7 +197,19 @@ private void Ok() var fixedParagraph = Fixes.FirstOrDefault(ri => ri.Index == index); if (fixedParagraph != null && fixedParagraph.Apply) { - p.Text = fixedParagraph.After; + // Apply the whole fixed paragraph, not just its text: writing only Text left + // every timing fix (minimum duration, gaps, shot changes) with no effect at all. + var fixedFrom = fixedParagraph.Record?.FixedParagraph; + if (fixedFrom != null) + { + p.Text = fixedFrom.Text; + p.StartTime.TotalMilliseconds = fixedFrom.StartTime.TotalMilliseconds; + p.EndTime.TotalMilliseconds = fixedFrom.EndTime.TotalMilliseconds; + } + else + { + p.Text = fixedParagraph.After; + } } FixedSubtitle.Paragraphs.Add(p); @@ -309,13 +326,37 @@ private void GeneratePreview() continue; } + if (r.FixedParagraph == null) + { + continue; + } + var before = r.OriginalParagraph.Text; - var after = r.FixedParagraph?.Text; - if (!string.IsNullOrEmpty(after) && !string.Equals(before, after, StringComparison.Ordinal)) + var after = r.FixedParagraph.Text; + var textChanged = !string.IsNullOrEmpty(after) && !string.Equals(before, after, StringComparison.Ordinal); + + // Several checks (minimum duration, two-frames gap, maximum duration, bridge gaps, + // shot changes) produce a fix that only moves the times - comparing text alone meant + // those never became a fixable row at all, so the tool could report them but never + // correct them. + var timesChanged = + Math.Abs(r.OriginalParagraph.StartTime.TotalMilliseconds - r.FixedParagraph.StartTime.TotalMilliseconds) > 0.5 || + Math.Abs(r.OriginalParagraph.EndTime.TotalMilliseconds - r.FixedParagraph.EndTime.TotalMilliseconds) > 0.5; + + if (!textChanged && !timesChanged) + { + continue; + } + + if (!textChanged) { - // If multiple fixes affect the same paragraph, keep last suggestion - fixMap[idx] = (before, after, r.OriginalParagraph, r); + // Nothing to show in a text diff - show the timing change instead. + before = r.OriginalParagraph.StartTime.ToDisplayString() + " --> " + r.OriginalParagraph.EndTime.ToDisplayString(); + after = r.FixedParagraph.StartTime.ToDisplayString() + " --> " + r.FixedParagraph.EndTime.ToDisplayString(); } + + // If multiple fixes affect the same paragraph, keep last suggestion + fixMap[idx] = (before, after, r.OriginalParagraph, r); } if (fixMap.Count == 0) diff --git a/src/ui/Features/Tools/FixNetflixErrors/FixNetflixErrorsWindow.cs b/src/ui/Features/Tools/FixNetflixErrors/FixNetflixErrorsWindow.cs index 08ca44ec222..781774031bb 100644 --- a/src/ui/Features/Tools/FixNetflixErrors/FixNetflixErrorsWindow.cs +++ b/src/ui/Features/Tools/FixNetflixErrors/FixNetflixErrorsWindow.cs @@ -86,7 +86,7 @@ public FixNetflixErrorsWindow(FixNetflixErrorsViewModel vm) Content = grid; - Activated += delegate { _comboBoxLanguage.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, _comboBoxLanguage); // initial focus on an input, not an action button - a focused button clicks on bare Space Closing += delegate { UiUtil.SaveWindowPosition(this); }; Loaded += delegate { UiUtil.RestoreWindowPosition(this); }; diff --git a/src/ui/Features/Tools/JoinSubtitles/JoinSubtitlesViewModel.cs b/src/ui/Features/Tools/JoinSubtitles/JoinSubtitlesViewModel.cs index 5b060238399..975721b718d 100644 --- a/src/ui/Features/Tools/JoinSubtitles/JoinSubtitlesViewModel.cs +++ b/src/ui/Features/Tools/JoinSubtitles/JoinSubtitlesViewModel.cs @@ -447,7 +447,9 @@ private async Task SortAndLoad() } JoinedSubtitle = new Subtitle(); - if (JoinedFormat != null && JoinedFormat.FriendlyName != SubRip.NameOfFormat) + // FriendlyName is "SubRip (.srt)", never "SubRip", so this guard never fired and a join of + // mixed formats falling back to SubRip still inherited the last file's ASSA header. + if (JoinedFormat != null && JoinedFormat.Name != SubRip.NameOfFormat) { JoinedSubtitle.Header = header; } diff --git a/src/ui/Features/Tools/JoinSubtitles/JoinSubtitlesWindow.cs b/src/ui/Features/Tools/JoinSubtitles/JoinSubtitlesWindow.cs index ff6eac46a2a..9fb7d0fcf87 100644 --- a/src/ui/Features/Tools/JoinSubtitles/JoinSubtitlesWindow.cs +++ b/src/ui/Features/Tools/JoinSubtitles/JoinSubtitlesWindow.cs @@ -57,7 +57,7 @@ public JoinSubtitlesWindow(JoinSubtitlesViewModel vm) Content = grid; - Activated += delegate { TableViewExtras.FocusRow(_tableViewFiles); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, () => { TableViewExtras.FocusRow(_tableViewFiles); }); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += vm.KeyDown; } diff --git a/src/ui/Features/Tools/MergeContinuationLines/MergeContinuationLinesViewModel.cs b/src/ui/Features/Tools/MergeContinuationLines/MergeContinuationLinesViewModel.cs index e219fb70c8d..40cb8aa659e 100644 --- a/src/ui/Features/Tools/MergeContinuationLines/MergeContinuationLinesViewModel.cs +++ b/src/ui/Features/Tools/MergeContinuationLines/MergeContinuationLinesViewModel.cs @@ -8,6 +8,7 @@ using Nikse.SubtitleEdit.Logic.Config; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; namespace Nikse.SubtitleEdit.Features.Tools.MergeContinuationLines; @@ -112,7 +113,21 @@ private void UpdatePreview() [RelayCommand] private void Ok() { - AllSubtitlesFixed = MergeContinuationLinesHelper.Apply(_allSubtitles, Candidates, _language); + // Recompute the candidates from the current settings instead of applying the preview + // collection: the preview is filled by a 250 ms timer, so it is empty when OK comes + // right after opening and stale when it comes right after a settings change. The + // user's deselections in the shown list are carried over by first-line index. + var deselected = new HashSet(Candidates.Where(c => !c.IsSelected).Select(c => c.Index)); + var candidates = MergeContinuationLinesHelper.Detect(_allSubtitles, _language, MaxMillisecondsBetweenLines, MaxCharacters); + foreach (var candidate in candidates) + { + if (deselected.Contains(candidate.Index)) + { + candidate.IsSelected = false; + } + } + + AllSubtitlesFixed = MergeContinuationLinesHelper.Apply(_allSubtitles, candidates, _language); OkPressed = true; Window?.Close(); } diff --git a/src/ui/Features/Tools/MergeContinuationLines/MergeContinuationLinesWindow.cs b/src/ui/Features/Tools/MergeContinuationLines/MergeContinuationLinesWindow.cs index 101b09e6a01..2d13276206b 100644 --- a/src/ui/Features/Tools/MergeContinuationLines/MergeContinuationLinesWindow.cs +++ b/src/ui/Features/Tools/MergeContinuationLines/MergeContinuationLinesWindow.cs @@ -61,7 +61,7 @@ public MergeContinuationLinesWindow(MergeContinuationLinesViewModel vm) Content = grid; - Activated += delegate { _numericGap.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, _numericGap); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += vm.KeyDown; Loaded += delegate { vm.Loaded(); }; diff --git a/src/ui/Features/Tools/MergeShortLines/MergeShortLinesViewModel.cs b/src/ui/Features/Tools/MergeShortLines/MergeShortLinesViewModel.cs index 6c56d13dceb..579af8e5fc5 100644 --- a/src/ui/Features/Tools/MergeShortLines/MergeShortLinesViewModel.cs +++ b/src/ui/Features/Tools/MergeShortLines/MergeShortLinesViewModel.cs @@ -83,6 +83,11 @@ private void UpdatePreview() { Dispatcher.UIThread.Post(() => { + if (_isClosing) + { + return; // must not overwrite the final result Ok() just computed + } + Subtitles.Clear(); AllSubtitlesFixed.Clear(); Fixes.Clear(); @@ -157,6 +162,24 @@ private void Ok() return; } + // Always recompute the real merge from the current settings instead of handing back + // what the 250 ms preview timer last produced: the preview is empty when OK comes + // right after opening, stale when it comes right after a settings change, and in + // "highlight parts" mode it is not the real merge at all. + { + var gapThresholdMs = Se.Settings.Tools.BridgeGaps.BridgeGapsSmallerThanMs; + var unbreakLinesShorterThan = Se.Settings.General.UnbreakLinesShorterThan; + var mergeResult = MergeShortLinesHelper.Merge( + _allSubtitles, + _shotChanges, + SingleLineMaxLength, + MaxNumberOfLines, + gapThresholdMs, + unbreakLinesShorterThan); + AllSubtitlesFixed.Clear(); + AllSubtitlesFixed.AddRange(mergeResult.MergedSubtitles); + } + SaveSettings(); OkPressed = true; Window?.Close(); diff --git a/src/ui/Features/Tools/MergeShortLines/MergeShortLinesWindow.cs b/src/ui/Features/Tools/MergeShortLines/MergeShortLinesWindow.cs index 6219a0197db..c74e5529d47 100644 --- a/src/ui/Features/Tools/MergeShortLines/MergeShortLinesWindow.cs +++ b/src/ui/Features/Tools/MergeShortLines/MergeShortLinesWindow.cs @@ -54,7 +54,7 @@ public MergeShortLinesWindow(MergeShortLinesViewModel vm) Content = grid; - Activated += delegate { _numericUpDownSingleLineMaxLength.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, _numericUpDownSingleLineMaxLength); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += vm.KeyDown; Loaded += delegate { vm.Loaded(); }; diff --git a/src/ui/Features/Tools/MergeSubtitlesWithSameText/MergeSameTextViewModel.cs b/src/ui/Features/Tools/MergeSubtitlesWithSameText/MergeSameTextViewModel.cs index f05b13a26f4..b4b2ad7e1a1 100644 --- a/src/ui/Features/Tools/MergeSubtitlesWithSameText/MergeSameTextViewModel.cs +++ b/src/ui/Features/Tools/MergeSubtitlesWithSameText/MergeSameTextViewModel.cs @@ -102,7 +102,6 @@ private void UpdatePreview() var fixIncrementing = IncludeIncrementingLines; var numberOfMerges = 0; Paragraph? p = null; - var lineNumbers = new List(); for (var i = 1; i < _subtitles.Count; i++) { if (removed.Contains(i - 1)) @@ -138,16 +137,6 @@ private void UpdatePreview() p.Text = incrementText; } - if (lineNumbers.Count > 0) - { - lineNumbers.Add(next.Number); - } - else - { - lineNumbers.Add(p.Number); - lineNumbers.Add(next.Number); - } - removed.Add(j); numberOfMerges++; if (!mergedIndexes.Contains(j)) diff --git a/src/ui/Features/Tools/MergeSubtitlesWithSameText/MergeSameTextWindow.cs b/src/ui/Features/Tools/MergeSubtitlesWithSameText/MergeSameTextWindow.cs index b4883fe9568..d2d7527bd28 100644 --- a/src/ui/Features/Tools/MergeSubtitlesWithSameText/MergeSameTextWindow.cs +++ b/src/ui/Features/Tools/MergeSubtitlesWithSameText/MergeSameTextWindow.cs @@ -60,7 +60,7 @@ public MergeSameTextWindow(MergeSameTextViewModel vm) Content = grid; - Activated += delegate { buttonCancel.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, buttonCancel); // hack to make OnKeyDown work KeyDown += vm.OnKeyDown; Closing += delegate { UiUtil.SaveWindowPosition(this); }; diff --git a/src/ui/Features/Tools/MergeSubtitlesWithSameTimeCodes/MergeSameTimeCodesViewModel.cs b/src/ui/Features/Tools/MergeSubtitlesWithSameTimeCodes/MergeSameTimeCodesViewModel.cs index 67b91da8e1e..7a6f910a33a 100644 --- a/src/ui/Features/Tools/MergeSubtitlesWithSameTimeCodes/MergeSameTimeCodesViewModel.cs +++ b/src/ui/Features/Tools/MergeSubtitlesWithSameTimeCodes/MergeSameTimeCodesViewModel.cs @@ -106,10 +106,17 @@ private void UpdatePreview() var mergedText = string.Empty; for (var i = 1; i < _subtitles.Count; i++) { - p = _subtitles[i - 1]; + // Anchor on the FIRST cue of the current group. Reassigning every iteration compared + // each cue with its immediate predecessor, so a run of slowly drifting cues - each + // within tolerance of the last but far from the first - chained transitively into a + // single subtitle spanning the whole run. + if (singleMergeSubtitles.Count == 0) + { + p = _subtitles[i - 1]; + } var next = _subtitles[i]; - if (QualifiesForMerge(p, next, MaxMillisecondsDifference) && IsFixAllowed(p)) + if (p != null && QualifiesForMerge(p, next, MaxMillisecondsDifference) && IsFixAllowed(p)) { if (!singleMergeSubtitles.Contains(p)) { @@ -212,7 +219,11 @@ public static string GetMergedLines(string line1, string line2, bool makeDialog) case Core.Enums.DialogType.DashSecondLineWithoutSpace: return line1 + Environment.NewLine + "-" + line2; default: - return (line1.StartsWith("- ") ? "" : "- ") + line1 + Environment.NewLine + "- " + line2; + // Test for a leading dash regardless of the space after it, as the + // DashBothLinesWithoutSpace case above does. Testing "- " missed a dash + // written without one, so "-Hello" came back as "- -Hello" - and this is + // the shipped default dialog style. + return (line1.StartsWith("-") ? "" : "- ") + line1 + Environment.NewLine + "- " + line2; } } else diff --git a/src/ui/Features/Tools/MergeSubtitlesWithSameTimeCodes/MergeSameTimeCodesWindow.cs b/src/ui/Features/Tools/MergeSubtitlesWithSameTimeCodes/MergeSameTimeCodesWindow.cs index 39697a15eea..2be4cd4ddd7 100644 --- a/src/ui/Features/Tools/MergeSubtitlesWithSameTimeCodes/MergeSameTimeCodesWindow.cs +++ b/src/ui/Features/Tools/MergeSubtitlesWithSameTimeCodes/MergeSameTimeCodesWindow.cs @@ -65,7 +65,7 @@ public MergeSameTimeCodesWindow(MergeSameTimeCodesViewModel vm) Content = grid; - Activated += delegate { _numericUpDownMaxDiff.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, _numericUpDownMaxDiff); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += _vm.OnKeyDown; Closing += delegate { UiUtil.SaveWindowPosition(this); }; diff --git a/src/ui/Features/Tools/MergeTwoSubtitles/MergeTwoSubtitlesViewModel.cs b/src/ui/Features/Tools/MergeTwoSubtitles/MergeTwoSubtitlesViewModel.cs index 2d57b6aae20..898e75030f8 100644 --- a/src/ui/Features/Tools/MergeTwoSubtitles/MergeTwoSubtitlesViewModel.cs +++ b/src/ui/Features/Tools/MergeTwoSubtitles/MergeTwoSubtitlesViewModel.cs @@ -511,7 +511,9 @@ private void UpdatePreview() const int width = 480; const int height = 200; - var bitmap = new SKBitmap(width, height, SKColorType.Bgra8888, SKAlphaType.Premul); + // ToAvaloniaBitmap copies the pixels out, and UpdatePreview runs on every font, colour and + // size change - without the using, each keystroke leaked a 480x200 native bitmap. + using var bitmap = new SKBitmap(width, height, SKColorType.Bgra8888, SKAlphaType.Premul); using (var canvas = new SKCanvas(bitmap)) { DrawCheckerboard(canvas, width, height); diff --git a/src/ui/Features/Tools/MergeTwoSubtitles/MergeTwoSubtitlesWindow.cs b/src/ui/Features/Tools/MergeTwoSubtitles/MergeTwoSubtitlesWindow.cs index 5be243db520..8b53f58463e 100644 --- a/src/ui/Features/Tools/MergeTwoSubtitles/MergeTwoSubtitlesWindow.cs +++ b/src/ui/Features/Tools/MergeTwoSubtitles/MergeTwoSubtitlesWindow.cs @@ -77,14 +77,14 @@ public MergeTwoSubtitlesWindow(MergeTwoSubtitlesViewModel vm) Content = grid; - Activated += delegate + UiUtil.FocusOnFirstActivation(this, () => { // initial focus on an input, not an action button - a focused button clicks on bare Space if (_tableViewSubtitle1 != null) { TableViewExtras.FocusRow(_tableViewSubtitle1); } - }; + }); KeyDown += vm.KeyDown; } diff --git a/src/ui/Features/Tools/RemoveTextForHearingImpaired/InterjectionsWindow.cs b/src/ui/Features/Tools/RemoveTextForHearingImpaired/InterjectionsWindow.cs index 15a561b57b4..23c33668aae 100644 --- a/src/ui/Features/Tools/RemoveTextForHearingImpaired/InterjectionsWindow.cs +++ b/src/ui/Features/Tools/RemoveTextForHearingImpaired/InterjectionsWindow.cs @@ -86,7 +86,7 @@ public InterjectionsWindow(InterjectionsViewModel vm) Content = grid; - Activated += delegate { textBoxInterjections.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, textBoxInterjections); // initial focus on an input, not an action button - a focused button clicks on bare Space } protected override void OnKeyDown(KeyEventArgs e) diff --git a/src/ui/Features/Tools/RemoveTextForHearingImpaired/RemoveTextForHearingImpairedWindow.cs b/src/ui/Features/Tools/RemoveTextForHearingImpaired/RemoveTextForHearingImpairedWindow.cs index f5d7dae216d..7de8e3b8a26 100644 --- a/src/ui/Features/Tools/RemoveTextForHearingImpaired/RemoveTextForHearingImpairedWindow.cs +++ b/src/ui/Features/Tools/RemoveTextForHearingImpaired/RemoveTextForHearingImpairedWindow.cs @@ -82,7 +82,7 @@ public RemoveTextForHearingImpairedWindow(RemoveTextForHearingImpairedViewModel Content = grid; - Activated += delegate { (vm.IsApplyVisible ? buttonDone : buttonOk).Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, () => { (vm.IsApplyVisible ? buttonDone : buttonOk).Focus(); }); // hack to make OnKeyDown work Closing += delegate { UiUtil.SaveWindowPosition(this); }; Loaded += delegate { UiUtil.RestoreWindowPosition(this); }; diff --git a/src/ui/Features/Tools/RemoveUnicodeCharacters/RemoveUnicodeCharactersWindow.cs b/src/ui/Features/Tools/RemoveUnicodeCharacters/RemoveUnicodeCharactersWindow.cs index 106588fee61..6ac08c1e9f8 100644 --- a/src/ui/Features/Tools/RemoveUnicodeCharacters/RemoveUnicodeCharactersWindow.cs +++ b/src/ui/Features/Tools/RemoveUnicodeCharacters/RemoveUnicodeCharactersWindow.cs @@ -166,7 +166,7 @@ public RemoveUnicodeCharactersWindow(RemoveUnicodeCharactersViewModel vm) Content = grid; - Activated += delegate { TableViewExtras.FocusRow(dataGrid); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, () => { TableViewExtras.FocusRow(dataGrid); }); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += (_, e) => vm.OnKeyDown(e); } } diff --git a/src/ui/Features/Tools/SortBy/SortByViewModel.cs b/src/ui/Features/Tools/SortBy/SortByViewModel.cs index d073f8aed9e..99abd1852f5 100644 --- a/src/ui/Features/Tools/SortBy/SortByViewModel.cs +++ b/src/ui/Features/Tools/SortBy/SortByViewModel.cs @@ -173,11 +173,11 @@ private void InitializeAvailableProperties() private void UpdatePreview() { - if (SortCriteria.Count == 0) - { - return; - } - + // No early return on an empty criteria list: ApplySorting already returns the rows + // unsorted in that case, and returning here left Subtitles holding the PREVIOUS sorted + // order. Removing the last criterion therefore looked like it had done nothing, and OK + // then applied the stale sort anyway (ClearSortCriteria repopulates by hand for the same + // reason). Dispatcher.UIThread.Post(() => { var sorted = ApplySorting(_originalSubtitles); @@ -377,6 +377,13 @@ private void ClearSortCriteria() [RelayCommand] private void Ok() { + // Apply what the current criteria produce: the preview is rebuilt by a 500 ms timer, so + // clicking OK straight after adding a criterion applied the previous (or unsorted) order. + if (_dirty) + { + UpdatePreview(); + } + if (SortCriteria.Count > 0) { AllSubtitles.Clear(); diff --git a/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesItem.cs b/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesItem.cs index fd634fdc57c..5ea4325a8e9 100644 --- a/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesItem.cs +++ b/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesItem.cs @@ -1,19 +1,63 @@ -using Nikse.SubtitleEdit.Features.Main; +using CommunityToolkit.Mvvm.ComponentModel; +using Nikse.SubtitleEdit.Features.Main; namespace Nikse.SubtitleEdit.Features.Tools.SplitBreakLongLines; -public class SplitBreakLongLinesItem +public partial class SplitBreakLongLinesItem : ObservableObject { - public string Name { get; set; } - public int Number { get; set; } - public string Fix { get; set; } - public SubtitleLineViewModel SubtitleLine { get; set; } + public string Name { get; } + public int Number { get; } + public string Fix { get; } + public SubtitleLineViewModel SubtitleLine { get; } + + /// + /// Rebalance fixes are opt-out per row: the proposed text is applied while the row is + /// checked and the original text restored when it is unchecked. Split fixes change the + /// subtitle count and are always applied, so they show no checkbox. + /// + public bool IsSelectable { get; } + + private readonly string _originalText; + private readonly string _proposedText; + private readonly string _originalMarginV; + private readonly string _proposedMarginV; + + [ObservableProperty] private bool _isSelected = true; public SplitBreakLongLinesItem(string name, int number, string fix, SubtitleLineViewModel subtitleLine) + : this(name, number, fix, subtitleLine, null) + { + } + + /// Rebalanced text, or null for a fix that is not optional. + /// Teletext row that goes with the proposed text (EBU STL only). + public SplitBreakLongLinesItem(string name, int number, string fix, SubtitleLineViewModel subtitleLine, string? proposedText, string? proposedMarginV = null) { Name = name; Number = number; Fix = fix; SubtitleLine = subtitleLine; + + _originalText = subtitleLine.Text ?? string.Empty; + _proposedText = proposedText ?? _originalText; + _originalMarginV = subtitleLine.MarginV; + _proposedMarginV = proposedMarginV ?? _originalMarginV; + IsSelectable = proposedText != null; + + ApplySelection(); + } + + partial void OnIsSelectedChanged(bool value) + { + ApplySelection(); + } + + private void ApplySelection() + { + if (IsSelectable) + { + SubtitleLine.Text = IsSelected ? _proposedText : _originalText; + SubtitleLine.MarginV = IsSelected ? _proposedMarginV : _originalMarginV; + } } } diff --git a/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesViewModel.cs b/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesViewModel.cs index f3bae9c4555..249bf055e72 100644 --- a/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesViewModel.cs +++ b/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesViewModel.cs @@ -10,6 +10,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Globalization; namespace Nikse.SubtitleEdit.Features.Tools.SplitBreakLongLines; @@ -37,6 +38,7 @@ public partial class SplitBreakLongLinesViewModel : ObservableObject, IClosingCl private List _allSubtitles; private string _languageCode = "en"; + private bool _isFormatEbu; private readonly System.Timers.Timer _previewTimer; private volatile bool _isClosing; @@ -98,13 +100,36 @@ private void UpdatePreview() var rebalanceCount = 0; var maxCharactersPerSubtitle = MaxNumberOfLines * SingleLineMaxLength; + // AutoBreakLine keeps text on one line only when it is strictly shorter than the + // unbreak threshold, so a threshold at or above the single line max length means + // "keep any text that fits on one line" - and capping there also prevents merging + // to a single line that would exceed the max length (#12910). + var mergeLinesShorterThan = UnbreakLinesShorterThan >= SingleLineMaxLength + ? SingleLineMaxLength + 1 + : UnbreakLinesShorterThan; + if (SplitLongLines) { + var options = new SplitOptions + { + MinimumGapMs = Se.Settings.General.MinimumBetweenLines.GetMilliseconds(), + AdjustTeletextRows = _isFormatEbu, + TeletextDoubleHeight = Configuration.Settings.SubtitleSettings.EbuStlTeletextUseDoubleHeight, + }; + for (var index = 0; index < _allSubtitles.Count; index++) { var item = new SubtitleLineViewModel(_allSubtitles[index]); - var splitLines = Split(item, maxCharactersPerSubtitle, SingleLineMaxLength); + // Like SE4: a subtitle is not cut into several events when re-wrapping its + // lines is enough to make it fit - the rebalance pass below does that. + if (RebalanceLongLines && CanBeFixedByRebalancing(item.Text, SingleLineMaxLength, MaxNumberOfLines, mergeLinesShorterThan, _languageCode)) + { + AllSubtitlesFixed.Add(item); + continue; + } + + var splitLines = Split(item, maxCharactersPerSubtitle, SingleLineMaxLength, options); if (splitLines.Count > 1) { splitCount++; @@ -114,10 +139,8 @@ private void UpdatePreview() var fixItem = new SplitBreakLongLinesItem(Se.Language.Tools.SplitBreakLongLines.SplitLongLine, index + 1, fixDescription, item); Fixes.Add(fixItem); } - foreach (var s in splitLines) - { - AllSubtitlesFixed.Add(s); - } + + AllSubtitlesFixed.AddRange(splitLines); } } else @@ -131,14 +154,6 @@ private void UpdatePreview() if (RebalanceLongLines) { - // AutoBreakLine keeps text on one line only when it is strictly shorter than the - // unbreak threshold, so a threshold at or above the single line max length means - // "keep any text that fits on one line" - and capping there also prevents merging - // to a single line that would exceed the max length (#12910). - var mergeLinesShorterThan = UnbreakLinesShorterThan >= SingleLineMaxLength - ? SingleLineMaxLength + 1 - : UnbreakLinesShorterThan; - for (var index = 0; index < AllSubtitlesFixed.Count; index++) { var item = AllSubtitlesFixed[index]; @@ -156,13 +171,36 @@ private void UpdatePreview() var beforePreview = GetTextPreview(item.Text.Replace("\r\n", " · ").Replace("\n", " · "), 60); var afterPreview = GetTextPreview(rebalancedText.Replace("\r\n", " · ").Replace("\n", " · "), 60); var fixDescription = $"'{beforePreview}' → '{afterPreview}'"; - var fixItem = new SplitBreakLongLinesItem(Se.Language.Tools.SplitBreakLongLines.RebalanceLongLine, index + 1, fixDescription, item); + + string? rebalancedMarginV = null; + if (_isFormatEbu) + { + rebalancedMarginV = TeletextRowHelper.GetRowKeepingBottomEdge( + item.MarginV, + GetPlainLineCount(item.Text), + GetPlainLineCount(rebalancedText), + Configuration.Settings.SubtitleSettings.EbuStlTeletextUseDoubleHeight) + ?.ToString(CultureInfo.InvariantCulture); + } + + // The item applies the rebalanced text (and row) itself, and puts the + // original back when the user unchecks the row. + var fixItem = new SplitBreakLongLinesItem(Se.Language.Tools.SplitBreakLongLines.RebalanceLongLine, index + 1, fixDescription, item, rebalancedText, rebalancedMarginV); Fixes.Add(fixItem); - item.Text = rebalancedText; } } } + // Split events are clones of their source and keep its number - renumber so the + // preview (and the grid after OK) shows 12, 13, 14 rather than 12, 12, 13. + if (splitCount > 0) + { + for (var index = 0; index < AllSubtitlesFixed.Count; index++) + { + AllSubtitlesFixed[index].Number = index + 1; + } + } + if (splitCount == 0 && rebalanceCount == 0) { FixesInfo = Se.Language.Tools.ApplyDurationLimits.NoChangesNeeded; @@ -180,6 +218,79 @@ private void UpdatePreview() }); } + [RelayCommand] + private void SelectAll() + { + SetSelectableFixes(true); + } + + [RelayCommand] + private void SelectNone() + { + SetSelectableFixes(false); + } + + private void SetSelectableFixes(bool isSelected) + { + foreach (var fix in Fixes) + { + if (fix.IsSelectable) + { + fix.IsSelected = isSelected; + } + } + } + + /// + /// True when re-wrapping a subtitle's own lines makes it fit, so it does not have to be cut + /// into several events. + /// + public static bool CanBeFixedByRebalancing(string? text, int singleLineMaxLength, int maxNumberOfLines, int mergeLinesShorterThan, string languageCode) + { + if (!HasLineTooLong(text, singleLineMaxLength, maxNumberOfLines)) + { + return true; + } + + // SE4 left an existing line break alone when it ended a sentence and cut the subtitle + // there instead of re-wrapping across it (#11476). The two halves are usually two + // speakers, so pulling them onto one line - "Und, war ich in der Kantine? Nein." - + // is wrong no matter how well the result fits. + if (HasSentenceEndingLineBreak(text)) + { + return false; + } + + var rebalanced = Utilities.AutoBreakLine(text ?? string.Empty, singleLineMaxLength, mergeLinesShorterThan, languageCode); + return !HasLineTooLong(rebalanced, singleLineMaxLength, maxNumberOfLines); + } + + private const string SentenceEndings = ".!?؟"; + + /// + /// True when a line break follows sentence-ending punctuation, i.e. the break is where the + /// author ended a sentence rather than where the text happened to wrap. + /// + public static bool HasSentenceEndingLineBreak(string? text) + { + var lines = HtmlUtil.RemoveHtmlTags(text ?? string.Empty, true).SplitToLines(); + for (var index = 0; index < lines.Count - 1; index++) + { + var line = lines[index].TrimEnd(); + if (line.Length > 0 && SentenceEndings.Contains(line[^1])) + { + return true; + } + } + + return false; + } + + private static int GetPlainLineCount(string? text) + { + return HtmlUtil.RemoveHtmlTags(text ?? string.Empty, true).SplitToLines().Count; + } + public static bool HasLineTooLong(string? text, int singleLineMaxLength, int maxNumberOfLines) { var lines = HtmlUtil.RemoveHtmlTags(text ?? string.Empty, true).SplitToLines(); @@ -199,11 +310,31 @@ public static bool HasLineTooLong(string? text, int singleLineMaxLength, int max return false; } + public sealed class SplitOptions + { + /// Gap reserved between the events a subtitle is split into. + public double MinimumGapMs { get; init; } + + /// Move teletext rows (EBU STL MarginV) so the bottom edge of the text stays put. + public bool AdjustTeletextRows { get; init; } + + public bool TeletextDoubleHeight { get; init; } + } + public static List Split(SubtitleLineViewModel item, int maxCharactersPerSubtitle, int singleLineMaxLength) + { + return Split(item, maxCharactersPerSubtitle, singleLineMaxLength, new SplitOptions()); + } + + /// + /// Cuts a subtitle that does not fit its limits into several events. Text is never re-wrapped + /// here (#10959): split-only keeps each event's text as-is, and auto-wrapping stays with the + /// opt-in rebalance step. + /// + public static List Split(SubtitleLineViewModel item, int maxCharactersPerSubtitle, int singleLineMaxLength, SplitOptions options) { var lines = new List(); - // Guard clauses var originalText = item.Text ?? string.Empty; var originalStartMs = item.StartTime.TotalMilliseconds; var originalEndMs = item.EndTime.TotalMilliseconds; @@ -212,49 +343,37 @@ public static List Split(SubtitleLineViewModel item, int // Per-line maximum for visual wrapping and per-subtitle maximum for splitting var perLineMax = Math.Max(5, singleLineMaxLength); var perSubtitleMax = Math.Max(perLineMax, maxCharactersPerSubtitle); + var maxNumberOfLines = Math.Max(1, perSubtitleMax / perLineMax); - // If already short enough, return as-is var plainText = HtmlUtil.RemoveHtmlTags(originalText, true).Replace("\r\n", " ").Replace('\n', ' ').Trim(); - if (plainText.Length <= perSubtitleMax || string.IsNullOrWhiteSpace(plainText)) + var originalLineCount = GetPlainLineCount(originalText); + + // Same rule as SE4's QualifiesForSplit: a subtitle that fits in total can still be + // unusable because one of its lines is too long or it has too many lines. + var fitsInTotal = plainText.Length <= perSubtitleMax; + if (string.IsNullOrWhiteSpace(plainText) || + (fitsInTotal && !HasLineTooLong(originalText, perLineMax, maxNumberOfLines))) { lines.Add(item); return lines; } - // Prepare segments trying to split at natural boundaries - var remaining = originalText.Trim(); - var segments = new List(); - - while (!string.IsNullOrEmpty(remaining)) + List segments; + if (fitsInTotal && originalLineCount > 1) { - var remainingPlain = HtmlUtil.RemoveHtmlTags(remaining, true).Replace("\r\n", " ").Replace('\n', ' ').Trim(); - if (remainingPlain.Length <= perSubtitleMax) - { - segments.Add(remaining.Trim()); - break; - } - - // Find best split position that keeps plain length <= perSubtitleMax - var splitIdx = FindBestSplitIndexByPlainLength(remaining, perSubtitleMax); - if (splitIdx <= 0) - { - // Fallback to previous logic using raw index near limit - var approxCut = Math.Min(remaining.Length - 1, perSubtitleMax); - splitIdx = FindBestSplitIndex(remaining, approxCut); - } - - var part = remaining.Substring(0, splitIdx + 1).Trim(); - if (!string.IsNullOrWhiteSpace(part)) - { - segments.Add(part); - } - - remaining = splitIdx + 1 < remaining.Length - ? remaining.Substring(splitIdx + 1).Trim() - : string.Empty; + // Only a line is too long or there are too many lines: the author's line breaks + // are the natural event boundaries, so cut there instead of re-flowing the text. + segments = SplitAtLineBreaks(originalText, perLineMax, maxNumberOfLines); + } + else + { + // A single line that fits in total must still be cut so each event fits on one + // line; a text over the total limit is cut by the subtitle limit as before. + var limit = fitsInTotal ? perLineMax : perSubtitleMax; + segments = SplitByLength(originalText.Trim(), limit); } - if (segments.Count == 0) + if (segments.Count <= 1) { lines.Add(item); return lines; @@ -274,23 +393,19 @@ public static List Split(SubtitleLineViewModel item, int charCounts.Add(cnt); totalChars += cnt; } - if (totalChars <= 0) - { - totalChars = segments.Count; - charCounts.Clear(); - for (var i = 0; i < segments.Count; i++) - { - charCounts.Add(1); - } - } + + // Reserve the minimum gap between the new events, like SE4 did; the gaps may take at + // most half the duration so a short subtitle still leaves time for its text. + var gapCount = segments.Count - 1; + var gapMs = Math.Min(Math.Max(0, options.MinimumGapMs), originalDurationMs / (2.0 * gapCount)); + var textDurationMs = originalDurationMs - gapMs * gapCount; // Build new subtitle lines - double accumulatedMs = originalStartMs; + var accumulatedMs = originalStartMs; for (var i = 0; i < segments.Count; i++) { var segText = segments[i]; - // Calculate duration for this segment double segDurationMs; if (i == segments.Count - 1) { @@ -299,13 +414,10 @@ public static List Split(SubtitleLineViewModel item, int } else { - segDurationMs = originalDurationMs * (charCounts[i] / (double)totalChars); - // Ensure we don't overrun - segDurationMs = Math.Max(0, Math.Min(segDurationMs, Math.Max(0, originalEndMs - accumulatedMs))); + segDurationMs = textDurationMs * (charCounts[i] / (double)totalChars); + segDurationMs = Math.Max(0, Math.Min(segDurationMs, originalEndMs - accumulatedMs - gapMs)); } - // Keep the segment text as-is; auto-wrapping is opt-in via the - // RebalanceLongLines option, which runs its own AutoBreakLine pass. var newLine = new SubtitleLineViewModel(item, true) { Text = segText, @@ -313,20 +425,109 @@ public static List Split(SubtitleLineViewModel item, int EndTime = TimeSpan.FromMilliseconds(accumulatedMs + segDurationMs) }; newLine.UpdateDuration(); - lines.Add(newLine); - accumulatedMs += segDurationMs; + if (options.AdjustTeletextRows) + { + var newRow = TeletextRowHelper.GetRowKeepingBottomEdge(item.MarginV, originalLineCount, GetPlainLineCount(segText), options.TeletextDoubleHeight); + if (newRow.HasValue) + { + newLine.MarginV = newRow.Value.ToString(CultureInfo.InvariantCulture); + } + } + + lines.Add(newLine); + accumulatedMs += segDurationMs + gapMs; } // In rare rounding cases, force end of last to original end - if (lines.Count > 0) + var last = lines[^1]; + last.EndTime = TimeSpan.FromMilliseconds(originalEndMs); + last.UpdateDuration(); + + return lines; + + // Local helper: groups the existing lines into events of at most maxLines lines. A line + // over the per-line limit becomes an event of its own and is kept whole (SE4 did the + // same): the author's line is a better event than a cut in the middle of it, and the + // rebalance step can still wrap it. + static List SplitAtLineBreaks(string text, int perLineMax, int maxLines) { - var last = lines[^1]; - last.EndTime = TimeSpan.FromMilliseconds(originalEndMs); - last.UpdateDuration(); + var segments = new List(); + var group = new List(); + foreach (var line in text.SplitToLines()) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var trimmed = line.Trim(); + if (HtmlUtil.RemoveHtmlTags(trimmed, true).Length > perLineMax) + { + FlushGroup(); + segments.Add(trimmed); + continue; + } + + if (group.Count == maxLines) + { + FlushGroup(); + } + + group.Add(trimmed); + } + + FlushGroup(); + return segments; + + void FlushGroup() + { + if (group.Count > 0) + { + segments.Add(string.Join(Environment.NewLine, group)); + group.Clear(); + } + } } - return lines; + // Local helper: cuts text at natural boundaries so each piece has at most maxPlainLen + // visible characters. + static List SplitByLength(string text, int maxPlainLen) + { + var segments = new List(); + var remaining = text; + + while (!string.IsNullOrEmpty(remaining)) + { + var remainingPlain = HtmlUtil.RemoveHtmlTags(remaining, true).Replace("\r\n", " ").Replace('\n', ' ').Trim(); + if (remainingPlain.Length <= maxPlainLen) + { + segments.Add(remaining.Trim()); + break; + } + + // Find best split position that keeps plain length <= maxPlainLen + var splitIdx = FindBestSplitIndexByPlainLength(remaining, maxPlainLen); + if (splitIdx <= 0) + { + // Fallback to previous logic using raw index near limit + var approxCut = Math.Min(remaining.Length - 1, maxPlainLen); + splitIdx = FindBestSplitIndex(remaining, approxCut); + } + + var part = remaining.Substring(0, splitIdx + 1).Trim(); + if (!string.IsNullOrWhiteSpace(part)) + { + segments.Add(part); + } + + remaining = splitIdx + 1 < remaining.Length + ? remaining.Substring(splitIdx + 1).Trim() + : string.Empty; + } + + return segments; + } // Local helper: find the best split index near a target index (raw length based) static int FindBestSplitIndex(string text, int targetIndex) @@ -645,6 +846,14 @@ private void SaveSettings() [RelayCommand] private void Ok() { + // Apply what the current settings produce, not what the 250/500 ms preview timer + // last computed: clicking OK right after changing a value handed the caller the + // previous settings' result while saving the new ones. + if (_isDirty) + { + UpdatePreview(); + } + if (Window == null) { return; @@ -675,9 +884,10 @@ internal void KeyDown(object? sender, KeyEventArgs e) } } - public void Initialize(List toList) + public void Initialize(List toList, bool isFormatEbu = false) { _allSubtitles = toList; + _isFormatEbu = isFormatEbu; var subtitle = new Subtitle(); foreach (var line in toList) diff --git a/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesWindow.cs b/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesWindow.cs index 4cb955d55fd..d5016cbe7e0 100644 --- a/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesWindow.cs +++ b/src/ui/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesWindow.cs @@ -1,5 +1,7 @@ using Avalonia; +using Avalonia.Automation; using Avalonia.Controls; +using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Media; using Avalonia.Data; @@ -57,7 +59,7 @@ public SplitBreakLongLinesWindow(SplitBreakLongLinesViewModel vm) Content = grid; - Activated += delegate { _checkBoxSplitLongLines.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, _checkBoxSplitLongLines); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += vm.KeyDown; Loaded += (_, _) => vm.Loaded(); @@ -155,9 +157,23 @@ private static Grid MakeFixesView(SplitBreakLongLinesViewModel vm) var labelFixesAvailable = UiUtil.MakeLabel() .WithBindText(vm, nameof(vm.FixesInfo)) - .WithMarginTop(10) .WithMarginLeft(10); + // Select all / none only touch the rebalance rows (the ones with a checkbox). + var panelHeader = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 5, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 10, 0, 0), + Children = + { + labelFixesAvailable.WithMarginRight(10), + UiUtil.MakeButton(Se.Language.General.SelectAll, vm.SelectAllCommand), + UiUtil.MakeButton(Se.Language.General.SelectNone, vm.SelectNoneCommand), + }, + }; + // Sorting dropped in the DataGrid -> TableView conversion: the grid previews // split/rebalance fixes in subtitle order. var dataGrid = TableViewExtras.MakeTableView(multiSelect: false); @@ -167,6 +183,26 @@ private static Grid MakeFixesView(SplitBreakLongLinesViewModel vm) dataGrid.ItemsSource = vm.Fixes; dataGrid.Columns.AddRange(new TableViewColumn[] { + new SeTableViewColumn + { + Header = Se.Language.General.Apply, + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + CellTemplate = new FuncDataTemplate((_, _) => new Border + { + Background = Brushes.Transparent, + Padding = new Thickness(4), + Child = new CheckBox + { + Focusable = false, + [!ToggleButton.IsCheckedProperty] = new Binding(nameof(SplitBreakLongLinesItem.IsSelected)), + [!Visual.IsVisibleProperty] = new Binding(nameof(SplitBreakLongLinesItem.IsSelectable)), + [!AutomationProperties.NameProperty] = new Binding(nameof(SplitBreakLongLinesItem.Name)), + HorizontalAlignment = HorizontalAlignment.Center, + }, + }), + Width = new GridLength(70), + }, new SeTableViewColumn { Header = Se.Language.General.NumberSymbol, @@ -225,6 +261,9 @@ private static Grid MakeFixesView(SplitBreakLongLinesViewModel vm) }, }); + TableViewExtras.AddSpaceToggle(dataGrid, + item => item.IsSelected, (item, v) => { if (item.IsSelectable) { item.IsSelected = v; } }); + dataGrid.AddHandler(InputElement.KeyDownEvent, (object? _, KeyEventArgs e) => { if (e.Key is Key.Home or Key.End && dataGrid.ItemsSource is IList items && items.Count > 0) @@ -236,7 +275,7 @@ private static Grid MakeFixesView(SplitBreakLongLinesViewModel vm) } }, RoutingStrategies.Tunnel); - grid.Add(labelFixesAvailable, 0); + grid.Add(panelHeader, 0); grid.Add(UiUtil.MakeBorderForControlNoPadding(dataGrid), 1); return grid; diff --git a/src/ui/Features/Tools/SplitSubtitle/PartsSavedWindow.cs b/src/ui/Features/Tools/SplitSubtitle/PartsSavedWindow.cs index db19b34ba48..d4bdfa3c56a 100644 --- a/src/ui/Features/Tools/SplitSubtitle/PartsSavedWindow.cs +++ b/src/ui/Features/Tools/SplitSubtitle/PartsSavedWindow.cs @@ -53,7 +53,7 @@ public PartsSavedWindow(PartsSavedViewModel vm) Content = grid; - Activated += delegate { buttonOk.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, buttonOk); // hack to make OnKeyDown work } diff --git a/src/ui/Features/Tools/SplitSubtitle/SplitSubtitleWindow.cs b/src/ui/Features/Tools/SplitSubtitle/SplitSubtitleWindow.cs index af5eb819dd3..cc67d8f9bc0 100644 --- a/src/ui/Features/Tools/SplitSubtitle/SplitSubtitleWindow.cs +++ b/src/ui/Features/Tools/SplitSubtitle/SplitSubtitleWindow.cs @@ -58,7 +58,7 @@ public SplitSubtitleWindow(SplitSubtitleViewModel vm) Content = grid; - Activated += delegate { _textBoxOutputFolder.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, _textBoxOutputFolder); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += vm.KeyDown; } diff --git a/src/ui/Features/Tools/SplitSubtitle/SubtitleSplitter.cs b/src/ui/Features/Tools/SplitSubtitle/SubtitleSplitter.cs index 786a284454a..ae9337773e0 100644 --- a/src/ui/Features/Tools/SplitSubtitle/SubtitleSplitter.cs +++ b/src/ui/Features/Tools/SplitSubtitle/SubtitleSplitter.cs @@ -87,7 +87,10 @@ private static List SplitByCharacters(Subtitle subtitle, int numberOfP { var p = subtitle.Paragraphs[i]; var size = HtmlUtil.RemoveHtmlTags(p.Text, true).Length; - if (currentSize + size > nextLimit + 4 && parts.Count < numberOfParts - 1) + // Never close an empty part: when the very first paragraph already exceeds the + // limit, pushing the untouched accumulator produced a 0-byte "Part1" file with all + // the content in Part2. + if (currentSize + size > nextLimit + 4 && parts.Count < numberOfParts - 1 && temp.Paragraphs.Count > 0) { parts.Add(temp); currentSize = size; diff --git a/src/ui/Features/Translate/AutoTranslateViewModel.cs b/src/ui/Features/Translate/AutoTranslateViewModel.cs index 323fc7963a0..1906e44b3be 100644 --- a/src/ui/Features/Translate/AutoTranslateViewModel.cs +++ b/src/ui/Features/Translate/AutoTranslateViewModel.cs @@ -210,6 +210,10 @@ private void LoadSettings() Configuration.Settings.Tools.OpenRouterModel = Se.Settings.AutoTranslate.OpenRouterModel; Configuration.Settings.Tools.OpenRouterPrompt = Se.Settings.AutoTranslate.OpenRouterPrompt; + // The url is saved but was the only field of these two engines never bridged back, so both + // the url box and the translator fell back to the built-in endpoint on the next start. + Configuration.Settings.Tools.OpenRouterUrl = Se.Settings.AutoTranslate.OpenRouterUrl; + Configuration.Settings.Tools.ChatGptApiKey = Se.Settings.AutoTranslate.ChatGptApiKey; Configuration.Settings.Tools.ChatGptUrl = Se.Settings.AutoTranslate.ChatGptUrl; Configuration.Settings.Tools.ChatGptModel = Se.Settings.AutoTranslate.ChatGptModel; @@ -231,6 +235,7 @@ private void LoadSettings() Configuration.Settings.Tools.GroqApiKey = Se.Settings.AutoTranslate.GroqApiKey; Configuration.Settings.Tools.GroqModel = Se.Settings.AutoTranslate.GroqModel; Configuration.Settings.Tools.GroqPrompt = Se.Settings.AutoTranslate.GroqPrompt; + Configuration.Settings.Tools.GroqUrl = Se.Settings.AutoTranslate.GroqUrl; Configuration.Settings.Tools.GoogleApiV2Key = Se.Settings.AutoTranslate.GoogleApiV2Key; @@ -2518,6 +2523,10 @@ public void KeyDown(KeyEventArgs e) { Cancel(); } + else if (e.Key == Key.Enter && e.KeyModifiers == KeyModifiers.None) + { + RunDefaultButton(e); + } else if (UiUtil.IsHelp(e)) { e.Handled = true; @@ -2525,6 +2534,63 @@ public void KeyDown(KeyEventArgs e) } } + /// + /// The row grid marks Enter as handled without doing anything with it, so a key press made + /// with a line selected never reached the window. Take it while the event tunnels down when + /// the grid has the keyboard - everything else that uses Enter (a focused button, an open + /// combo box drop-down) is left alone and answered by on the way back up. + /// + public void PreviewKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Enter && e.KeyModifiers == KeyModifiers.None && RowGrid?.IsKeyboardFocusWithin == true) + { + RunDefaultButton(e); + } + } + + internal enum DefaultButtonAction + { + None, + Translate, + Ok, + } + + /// + /// What Enter does in the window - the same rule that gives one of the footer buttons the + /// accent colour: Translate until something has been translated, then OK. Nothing while a + /// translation is running, as both buttons are disabled then. + /// + internal DefaultButtonAction GetDefaultButtonAction() + { + if (IsOkPrimary) + { + return DefaultButtonAction.Ok; + } + + return IsTranslatePrimary ? DefaultButtonAction.Translate : DefaultButtonAction.None; + } + + /// + /// Avalonia has no WinForms-style AcceptButton, so the accented button only looked like the + /// default one: Enter did nothing unless that button also had keyboard focus. Anything that + /// uses Enter itself - a focused button, an open combo box drop-down - has already marked the + /// key handled before it reaches the window. + /// + private void RunDefaultButton(KeyEventArgs e) + { + switch (GetDefaultButtonAction()) + { + case DefaultButtonAction.Ok: + e.Handled = true; + Ok(); + break; + case DefaultButtonAction.Translate: + e.Handled = true; + TranslateCommand.Execute(null); + break; + } + } + /// /// Marshals row writes from the background translation loop to the UI thread - /// the rows are grid-bound and Avalonia bindings are not thread-safe. diff --git a/src/ui/Features/Translate/AutoTranslateWindow.cs b/src/ui/Features/Translate/AutoTranslateWindow.cs index 235bc32ee56..beb9d3675fc 100644 --- a/src/ui/Features/Translate/AutoTranslateWindow.cs +++ b/src/ui/Features/Translate/AutoTranslateWindow.cs @@ -1,4 +1,4 @@ -using Avalonia; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Data; @@ -6,6 +6,7 @@ using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Threading; using Nikse.SubtitleEdit.UiLogic.AutoTranslate; using Nikse.SubtitleEdit.Features.Translate.LlamaCppAdvanced; using Nikse.SubtitleEdit.Features.Video.SpeechToText; @@ -69,7 +70,14 @@ public AutoTranslateWindow(AutoTranslateViewModel vm) ApplyButtonAccentStates(vm); vm.PropertyChanged += OnViewModelPropertyChanged; + AddHandler(KeyDownEvent, (_, e) => _vm.PreviewKeyDown(e), RoutingStrategies.Tunnel, handledEventsToo: false); + Loaded += (s, e) => UiUtil.RestoreWindowPosition(this); + + // Start out on the accented button so it is selected, not just coloured like it - Enter + // then starts the translation right away. First activation only: coming back from a + // dialog must not pull focus away from where the user left it. + UiUtil.FocusOnFirstActivation(this, () => _buttonTranslate?.Focus()); } private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -90,6 +98,21 @@ private void ApplyButtonAccentStates(AutoTranslateViewModel vm) { SetAccent(_buttonTranslate, vm.IsTranslatePrimary); SetAccent(_buttonOk, vm.IsOkPrimary); + + // A focused button answers Enter itself, so let the focus follow the accent when OK takes + // over as the default button after a translation - otherwise Enter would keep translating. + // Posted: this runs from the first of the property changes that flip the two buttons, and + // OK is still disabled (hence unfocusable) until its own IsEnabled binding has caught up. + if (vm.IsOkPrimary && _buttonTranslate?.IsFocused == true) + { + Dispatcher.UIThread.Post(() => + { + if (_vm.IsOkPrimary && _buttonTranslate?.IsFocused == true) + { + _buttonOk?.Focus(); + } + }); + } } private static void SetAccent(Button? button, bool accent) diff --git a/src/ui/Features/Translate/CopyPasteTranslateBlockWindow.cs b/src/ui/Features/Translate/CopyPasteTranslateBlockWindow.cs index 33890f5c2b0..cd7b3642eab 100644 --- a/src/ui/Features/Translate/CopyPasteTranslateBlockWindow.cs +++ b/src/ui/Features/Translate/CopyPasteTranslateBlockWindow.cs @@ -60,7 +60,7 @@ public CopyPasteTranslateBlockWindow(CopyPasteTranslateBlockViewModel vm) Content = grid; - Activated += delegate { buttonCancel.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, buttonCancel); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += vm.KeyDown; Loaded += vm.Loaded; } diff --git a/src/ui/Features/Translate/CopyPasteTranslateWindow.cs b/src/ui/Features/Translate/CopyPasteTranslateWindow.cs index bcb6ceec86f..4a182a9a329 100644 --- a/src/ui/Features/Translate/CopyPasteTranslateWindow.cs +++ b/src/ui/Features/Translate/CopyPasteTranslateWindow.cs @@ -53,7 +53,7 @@ public CopyPasteTranslateWindow(CopyPasteTranslateViewModel vm) Content = grid; - Activated += delegate { TableViewExtras.FocusRow(vm.SubtitleGrid); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, () => { TableViewExtras.FocusRow(vm.SubtitleGrid); }); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += vm.KeyDown; } diff --git a/src/ui/Features/Translate/DownloadLlamaCppViewModel.cs b/src/ui/Features/Translate/DownloadLlamaCppViewModel.cs index 989565f1bfa..2c6f88d6515 100644 --- a/src/ui/Features/Translate/DownloadLlamaCppViewModel.cs +++ b/src/ui/Features/Translate/DownloadLlamaCppViewModel.cs @@ -108,7 +108,10 @@ public async void StartDownload() MakeExecutable(LlamaCppServerManager.GetExecutable()); } - if (Model != null && (ForceModelDownload || !LlamaCppServerManager.IsModelInstalled(Model.FileName))) + // Custom entries (a *.gguf the user dropped into the models folder) carry no Url - they + // are already on disk, so there is nothing to fetch even when a re-download was asked for. + if (Model != null && !string.IsNullOrEmpty(Model.Url) && + (ForceModelDownload || !LlamaCppServerManager.IsModelInstalled(Model.FileName))) { TitleText = string.Format(Se.Language.General.DownloadingX, Model.DisplayName); var finalPath = LlamaCppServerManager.GetModelPath(Model.FileName); diff --git a/src/ui/Features/Translate/DownloadLlamaCppWindow.cs b/src/ui/Features/Translate/DownloadLlamaCppWindow.cs index 33357c20d92..94c62a62ef1 100644 --- a/src/ui/Features/Translate/DownloadLlamaCppWindow.cs +++ b/src/ui/Features/Translate/DownloadLlamaCppWindow.cs @@ -59,7 +59,7 @@ public DownloadLlamaCppWindow(DownloadLlamaCppViewModel vm) } }; - Activated += delegate { buttonCancel.Focus(); }; + UiUtil.FocusOnFirstActivation(this, buttonCancel); KeyDown += (s, e) => vm.OnKeyDown(e); } } diff --git a/src/ui/Features/Translate/LlamaCppAdvanced/AdvancedTranslatorBase.cs b/src/ui/Features/Translate/LlamaCppAdvanced/AdvancedTranslatorBase.cs index 2c6cc5c7c91..a44901ae5cb 100644 --- a/src/ui/Features/Translate/LlamaCppAdvanced/AdvancedTranslatorBase.cs +++ b/src/ui/Features/Translate/LlamaCppAdvanced/AdvancedTranslatorBase.cs @@ -53,9 +53,12 @@ public List GetSupportedTargetLanguages() public async Task Translate(string text, string sourceLanguageCode, string targetLanguageCode, CancellationToken cancellationToken) { - var lines = new List { new(1, text.Trim()) }; + var stripped = StrippedLine.Strip(text.Trim()); + var lines = new List { new(1, stripped.Text) }; var map = await TranslateLinesAsync(lines, new List(), sourceLanguageCode, targetLanguageCode, cancellationToken); - return map.TryGetValue(1, out var translation) ? translation : string.Empty; + return map.TryGetValue(1, out var translation) && translation.Length > 0 + ? stripped.Restore(translation) + : string.Empty; } /// @@ -74,10 +77,18 @@ public async Task TranslateBatchAsync(ObservableCollection ro private async Task TranslateChunkAsync(ObservableCollection rows, int index, int count, string sourceLanguageCode, string targetLanguageCode, CancellationToken cancellationToken) { + // This path deliberately skips MergeAndSplitHelper (its merge/split heuristics would + // break the alignment the reply schema guarantees), and with it the Formatting pass that + // takes ASSA override blocks off every other engine's input. Strip them here instead: + // they cost a lot of tokens, and small models "normalize" them into something that no + // longer matches the source (#13927). A pure override/drawing line strips to empty, and + // IsComplete then accepts the model's empty answer for it rather than failing the batch. var lines = new List(count); + var stripped = new StrippedLine[count]; for (var i = 0; i < count; i++) { - lines.Add(new LlamaCppAdvancedProtocol.BatchLine(i + 1, rows[index + i].Text)); + stripped[i] = StrippedLine.Strip(rows[index + i].Text); + lines.Add(new LlamaCppAdvancedProtocol.BatchLine(i + 1, stripped[i].Text)); } // Translation-tuned models (TranslateGemma) can echo history into a lone line's @@ -93,7 +104,9 @@ private async Task TranslateChunkAsync(ObservableCollection r for (var i = 0; i < count; i++) { var translation = map[i + 1]; - rows[index + i].TranslatedText = translation.Length > 0 ? translation : rows[index + i].Text; + rows[index + i].TranslatedText = translation.Length > 0 + ? stripped[i].Restore(translation) + : rows[index + i].Text; } }); @@ -172,10 +185,21 @@ private async Task> TranslateLinesAsync(List(maxPairs); for (var i = index - 1; i >= 0 && history.Count < maxPairs; i--) { - if (!string.IsNullOrEmpty(rows[i].TranslatedText)) + if (string.IsNullOrEmpty(rows[i].TranslatedText)) { - history.Add(new LlamaCppAdvancedProtocol.HistoryPair(rows[i].Text, rows[i].TranslatedText)); + continue; } + + // Same stripping as the batch itself: override blocks carry no context and would + // otherwise fill the window twice per pair - once on each side (#13927). + var source = StrippedLine.Strip(rows[i].Text).Text; + var target = StrippedLine.Strip(rows[i].TranslatedText).Text; + if (source.Length == 0 || target.Length == 0) + { + continue; // a pure override/drawing line is no use as an example pair + } + + history.Add(new LlamaCppAdvancedProtocol.HistoryPair(source, target)); } history.Reverse(); diff --git a/src/ui/Features/Translate/LlamaCppEngineSettings/LlamaCppEngineSettingsViewModel.cs b/src/ui/Features/Translate/LlamaCppEngineSettings/LlamaCppEngineSettingsViewModel.cs index e0d46058cb8..1f7a3a42b74 100644 --- a/src/ui/Features/Translate/LlamaCppEngineSettings/LlamaCppEngineSettingsViewModel.cs +++ b/src/ui/Features/Translate/LlamaCppEngineSettings/LlamaCppEngineSettingsViewModel.cs @@ -107,7 +107,11 @@ private void ApplyStatus(DownloadHashManager.UpdateStatus status, bool installed StatusBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0x98, 0x00)); // amber break; default: - StatusLabel = Se.Language.General.UnknownNoInstallRecord; + // Installed, but nothing identifies the build: an install predating the + // .installed.sha256 sidecar, a manual install, or a build older than the ones + // SE has hashes for. It still works, so say "Installed" rather than something + // that reads as a failed install (#14057). + StatusLabel = Se.Language.General.Installed; StatusBrush = new SolidColorBrush(Color.FromRgb(0x9E, 0x9E, 0x9E)); // grey break; } diff --git a/src/ui/Features/Translate/LlamaCppEngineSettings/LlamaCppEngineSettingsWindow.cs b/src/ui/Features/Translate/LlamaCppEngineSettings/LlamaCppEngineSettingsWindow.cs index 0c4d2fe6c6b..94086899a47 100644 --- a/src/ui/Features/Translate/LlamaCppEngineSettings/LlamaCppEngineSettingsWindow.cs +++ b/src/ui/Features/Translate/LlamaCppEngineSettings/LlamaCppEngineSettingsWindow.cs @@ -32,7 +32,7 @@ public LlamaCppEngineSettingsWindow(LlamaCppEngineSettingsViewModel vm) Content = BuildContent(vm); var ok = UiUtil.MakeButtonOk(vm.OkCommand); - Activated += delegate { ok.Focus(); }; + UiUtil.FocusOnFirstActivation(this, ok); } private static Border BuildContent(LlamaCppEngineSettingsViewModel vm) diff --git a/src/ui/Features/Translate/TranslateSettingsViewModel.cs b/src/ui/Features/Translate/TranslateSettingsViewModel.cs index d65953c0246..43dd0251dd3 100644 --- a/src/ui/Features/Translate/TranslateSettingsViewModel.cs +++ b/src/ui/Features/Translate/TranslateSettingsViewModel.cs @@ -115,6 +115,10 @@ public void SaveValues() Se.Settings.AutoTranslate.RequestDelaySeconds = ServerDelaySeconds ?? 0; Configuration.Settings.Tools.AutoTranslateDelaySeconds = (int)Math.Round(ServerDelaySeconds ?? 0, MidpointRounding.AwayFromZero); Se.Settings.AutoTranslate.RequestMaxBytes = MaxBytesRequest ?? 0; + // Mirror into the libse setting too, the way the delay above is: MergeAndSplitHelper + // reads only Configuration.Settings.Tools.AutoTranslateMaxBytes, so without this the + // value was persisted and redisplayed but never actually capped a request. + Configuration.Settings.Tools.AutoTranslateMaxBytes = MaxBytesRequest ?? 0; Se.Settings.AutoTranslate.EngineStrategies[AutoTranslator.Name] = SelectedMergeOptions == Se.Language.Translate.TranslateEachLineSeparately ? nameof(TranslateStrategy.TranslateEachLineSeparately) diff --git a/src/ui/Features/Translate/TranslateSettingsWindow.cs b/src/ui/Features/Translate/TranslateSettingsWindow.cs index 87b6be82019..df9b4f86f9a 100644 --- a/src/ui/Features/Translate/TranslateSettingsWindow.cs +++ b/src/ui/Features/Translate/TranslateSettingsWindow.cs @@ -71,7 +71,9 @@ public TranslateSettingsWindow(TranslateSettingsViewModel vm) var promptTextBox = new TextBox { AcceptsReturn = true, - AcceptsTab = true, + // A tab is not meaningful in a translation prompt, and accepting it trapped the + // keyboard in this box - Tab could not reach the buttons below (#14313). + AcceptsTab = false, VerticalAlignment = VerticalAlignment.Stretch, HorizontalAlignment = HorizontalAlignment.Stretch, Width = double.NaN, @@ -137,7 +139,7 @@ public TranslateSettingsWindow(TranslateSettingsViewModel vm) Content = grid; - Activated += delegate { comboMerge.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, comboMerge); // initial focus on an input, not an action button - a focused button clicks on bare Space Loaded += vm.Onloaded; Closing += vm.OnClosing; } diff --git a/src/ui/Features/Translate/TranslationErrorWindow.cs b/src/ui/Features/Translate/TranslationErrorWindow.cs index dc98263f252..073f177987b 100644 --- a/src/ui/Features/Translate/TranslationErrorWindow.cs +++ b/src/ui/Features/Translate/TranslationErrorWindow.cs @@ -126,7 +126,7 @@ public TranslationErrorWindow(TranslationErrorViewModel vm) Content = content; - Activated += delegate { buttonOk.Focus(); }; + UiUtil.FocusOnFirstActivation(this, buttonOk); } protected override void OnKeyDown(KeyEventArgs e) diff --git a/src/ui/Features/Video/BlankVideo/BlankVideoViewModel.cs b/src/ui/Features/Video/BlankVideo/BlankVideoViewModel.cs index 3c113ec6bb5..94e52b47bde 100644 --- a/src/ui/Features/Video/BlankVideo/BlankVideoViewModel.cs +++ b/src/ui/Features/Video/BlankVideo/BlankVideoViewModel.cs @@ -172,7 +172,7 @@ await MessageBox.Show(Window!, MessageBoxButtons.OK, MessageBoxIcon.Error); - IsGenerating = true; + IsGenerating = false; ProgressValue = 0; }); @@ -503,6 +503,23 @@ internal void OnClosing() // The subtitle files handed to ffmpeg live as long as the window does - nothing else // removes them, and they used to pile up in the temp folder run after run (#13332). _tempSubtitleFiles.Delete(); + + // Stop the poll timer and any still-running encode - closing the window used to + // leave the ffmpeg process encoding to completion in the background. + _timerGenerate.StopAndDispose(TimerGenerateElapsed); + if (_ffmpegProcess != null && !_ffmpegProcess.HasExited) + { + try + { +#pragma warning disable CA1416 + _ffmpegProcess.Kill(true); +#pragma warning restore CA1416 + } + catch + { + // ignore - it may have exited in between + } + } } internal void OnKeyDown(KeyEventArgs e) @@ -510,7 +527,9 @@ internal void OnKeyDown(KeyEventArgs e) if (e.Key == Key.Escape) { e.Handled = true; - Window?.Close(); + // Route through Cancel so Escape during generation aborts the encode + // instead of closing the window over a running ffmpeg. + Cancel(); } else if (UiUtil.IsHelp(e)) { diff --git a/src/ui/Features/Video/BlankVideo/BlankVideoWindow.cs b/src/ui/Features/Video/BlankVideo/BlankVideoWindow.cs index f9810b306eb..3b30b2233ac 100644 --- a/src/ui/Features/Video/BlankVideo/BlankVideoWindow.cs +++ b/src/ui/Features/Video/BlankVideo/BlankVideoWindow.cs @@ -64,7 +64,7 @@ public BlankVideoWindow(BlankVideoViewModel vm) Content = grid; - Activated += delegate { _numericUpDownDuration?.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, () => { _numericUpDownDuration?.Focus(); }); // initial focus on an input, not an action button - a focused button clicks on bare Space } private Border MakeVideoSettingsView(BlankVideoViewModel vm) diff --git a/src/ui/Features/Video/BurnIn/BurnInEffectItem.cs b/src/ui/Features/Video/BurnIn/BurnInEffectItem.cs index 40f3566c30d..8da0720306b 100644 --- a/src/ui/Features/Video/BurnIn/BurnInEffectItem.cs +++ b/src/ui/Features/Video/BurnIn/BurnInEffectItem.cs @@ -19,7 +19,7 @@ public string ApplyEffect(string subtitleText, int screenWidth, int screenHeight { if (Type == BurnInEffectType.FadeInOut) { - return $"{{\\fad(250,250}}{subtitleText}"; + return $"{{\\fad(250,250)}}{subtitleText}"; } else if (Type == BurnInEffectType.SlowFontSizeChange) { diff --git a/src/ui/Features/Video/BurnIn/BurnInEffectWindow.cs b/src/ui/Features/Video/BurnIn/BurnInEffectWindow.cs index 097252de732..4cbc8a3db16 100644 --- a/src/ui/Features/Video/BurnIn/BurnInEffectWindow.cs +++ b/src/ui/Features/Video/BurnIn/BurnInEffectWindow.cs @@ -92,7 +92,7 @@ public BurnInEffectWindow(BurnInEffectViewModel vm) Content = grid; - Activated += delegate { (checkBoxPanel.Children.Count > 0 ? checkBoxPanel.Children[0] : buttonCancel).Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, () => { (checkBoxPanel.Children.Count > 0 ? checkBoxPanel.Children[0] : buttonCancel).Focus(); }); // initial focus on an input, not an action button - a focused button clicks on bare Space } protected override void OnLoaded(RoutedEventArgs e) diff --git a/src/ui/Features/Video/BurnIn/BurnInLogo.cs b/src/ui/Features/Video/BurnIn/BurnInLogo.cs index 7f00a724393..449ee7f8d29 100644 --- a/src/ui/Features/Video/BurnIn/BurnInLogo.cs +++ b/src/ui/Features/Video/BurnIn/BurnInLogo.cs @@ -16,4 +16,20 @@ public BurnInLogo() Alpha = 100; Size = 100; } + + /// + /// The logo dialog edits its instance live (dragging writes X/Y, the sliders write Alpha and + /// Size), so it has to be handed a copy - otherwise Cancel keeps every change. + /// + public BurnInLogo Clone() + { + return new BurnInLogo + { + LogoFileName = LogoFileName, + X = X, + Y = Y, + Alpha = Alpha, + Size = Size, + }; + } } \ No newline at end of file diff --git a/src/ui/Features/Video/BurnIn/BurnInLogoViewModel.cs b/src/ui/Features/Video/BurnIn/BurnInLogoViewModel.cs index 8795c47e1c2..8bf7650fa78 100644 --- a/src/ui/Features/Video/BurnIn/BurnInLogoViewModel.cs +++ b/src/ui/Features/Video/BurnIn/BurnInLogoViewModel.cs @@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Nikse.SubtitleEdit.Controls.VideoPlayer; +using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; using Nikse.SubtitleEdit.Logic.Media; using System; @@ -45,10 +46,9 @@ private void Ok() { PositionInSeconds = VideoPlayerControl?.Position ?? 0; - // Save logo settings + // Save logo settings - the caller adopts BurnInLogo only when OkPressed, and it is its own + // copy, so X/Y/Alpha/Size edited in the window are committed here and dropped on Cancel. BurnInLogo.LogoFileName = LogoFileName; - BurnInLogo.X = BurnInLogo.X; - BurnInLogo.Y = BurnInLogo.Y; OkPressed = true; Window?.Close(); @@ -123,43 +123,15 @@ public void UpdateBorder() return; } - // Use the actual content dimensions from the video player - var contentWidth = VideoPlayerControl.ContentWidth; - var contentHeight = VideoPlayerControl.ContentHeight; - - if (contentWidth <= 0 || contentHeight <= 0) + // Where the picture actually sits inside the measured surface. + var contentRect = VideoContentRect.Calculate( + VideoPlayerControl.ContentWidth, VideoPlayerControl.ContentHeight, VideoWidth, VideoHeight); + if (contentRect == null) { return; } - // Calculate the actual video display area based on aspect ratio - var screenAspect = (double)VideoWidth / VideoHeight; - var contentAspect = (double)contentWidth / contentHeight; - - double rectWidth, rectHeight, rectX, rectY; - - if (contentAspect > screenAspect) - { - // Video is narrower than content space - pillarboxed - rectHeight = contentHeight; - rectWidth = rectHeight * screenAspect; - rectX = (contentWidth - rectWidth) / 2; - rectY = 0; - } - else - { - // Video is wider than content space - letterboxed - rectWidth = contentWidth; - rectHeight = rectWidth / screenAspect; - rectX = 0; - rectY = (contentHeight - rectHeight) / 2; - } - - // Round to avoid sub-pixel rendering issues - rectWidth = Math.Round(rectWidth); - rectHeight = Math.Round(rectHeight); - rectX = Math.Round(rectX); - rectY = Math.Round(rectY); + var (rectX, rectY, rectWidth, rectHeight) = contentRect.Value; // Update the green border Dispatcher.UIThread.Post(() => diff --git a/src/ui/Features/Video/BurnIn/BurnInLogoWindow.cs b/src/ui/Features/Video/BurnIn/BurnInLogoWindow.cs index 8882b544c38..97712cb4f39 100644 --- a/src/ui/Features/Video/BurnIn/BurnInLogoWindow.cs +++ b/src/ui/Features/Video/BurnIn/BurnInLogoWindow.cs @@ -234,7 +234,7 @@ public BurnInLogoWindow(BurnInLogoViewModel vm) Content = grid; - Activated += delegate { sliderAlpha.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, sliderAlpha); // initial focus on an input, not an action button - a focused button clicks on bare Space } protected override void OnLoaded(RoutedEventArgs e) diff --git a/src/ui/Features/Video/BurnIn/BurnInResolutionPickerWindow.cs b/src/ui/Features/Video/BurnIn/BurnInResolutionPickerWindow.cs index 48719a37065..55021e8362d 100644 --- a/src/ui/Features/Video/BurnIn/BurnInResolutionPickerWindow.cs +++ b/src/ui/Features/Video/BurnIn/BurnInResolutionPickerWindow.cs @@ -129,7 +129,7 @@ public BurnInResolutionPickerWindow(BurnInResolutionPickerViewModel vm) Content = grid; - Activated += delegate { listBoxResolutions.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, listBoxResolutions); // initial focus on an input, not an action button - a focused button clicks on bare Space } protected override void OnKeyDown(KeyEventArgs e) diff --git a/src/ui/Features/Video/BurnIn/BurnInSettingsWindow.cs b/src/ui/Features/Video/BurnIn/BurnInSettingsWindow.cs index 143b5f85cd9..d6e6969f9bc 100644 --- a/src/ui/Features/Video/BurnIn/BurnInSettingsWindow.cs +++ b/src/ui/Features/Video/BurnIn/BurnInSettingsWindow.cs @@ -92,7 +92,7 @@ public BurnInSettingsWindow(BurnInSettingsViewModel vm) Content = grid; - Activated += delegate { checkBoxUseSourceFolder.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, checkBoxUseSourceFolder); // initial focus on an input, not an action button - a focused button clicks on bare Space } protected override void OnKeyDown(KeyEventArgs e) diff --git a/src/ui/Features/Video/BurnIn/BurnInViewModel.cs b/src/ui/Features/Video/BurnIn/BurnInViewModel.cs index 9776d6fec3d..07923bc761c 100644 --- a/src/ui/Features/Video/BurnIn/BurnInViewModel.cs +++ b/src/ui/Features/Video/BurnIn/BurnInViewModel.cs @@ -1,4 +1,4 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Input; using Avalonia.Media; using Avalonia.Media.Imaging; @@ -325,6 +325,14 @@ private void TimerAnalyzeElapsed(object? sender, ElapsedEventArgs e) _timerAnalyze.Stop(); + // A failed analyze pass writes no statistics file, so pass 2 could only fail too - it used + // to run anyway and report whatever was already at the output path. + if (_ffmpegProcess.ExitCode != 0) + { + ReportFfmpegFailure(JobItems[_jobItemIndex], "Two-pass analyze (pass 1) failed for"); + return; + } + Dispatcher.UIThread.Invoke(async () => { var jobItem = JobItems[_jobItemIndex]; @@ -393,29 +401,18 @@ private void TimerGenerateElapsed(object? sender, ElapsedEventArgs e) var jobItem = JobItems[_jobItemIndex]; Se.WriteToolsLog($"Burn-in ffmpeg finished with exit code {_ffmpegProcess.ExitCode} for \"{jobItem.OutputVideoFileName}\""); - if (!File.Exists(jobItem.OutputVideoFileName)) + // The output file existing is not proof that this run produced it: with an old file at the + // target path a refused or failed ffmpeg passed the check, and the job was reported as + // "Done" while the stale file was left untouched (issue #14210). + if (_ffmpegProcess.ExitCode != 0) { - Se.WriteToolsLog("Output video file not found: " + jobItem.OutputVideoFileName + Environment.NewLine + - "ffmpeg: " + _ffmpegProcess.StartInfo.FileName + Environment.NewLine + - "Parameters: " + _ffmpegProcess.StartInfo.Arguments + Environment.NewLine + - "OS: " + Environment.OSVersion + Environment.NewLine + - "64-bit: " + Environment.Is64BitOperatingSystem + Environment.NewLine + - "ffmpeg exit code: " + _ffmpegProcess.ExitCode + Environment.NewLine + - "ffmpeg log: " + _log); - - Dispatcher.UIThread.Invoke(async () => - { - await MessageBox.Show(Window!, - "Unable to generate video", - "Output video file not generated: " + jobItem.OutputVideoFileName + Environment.NewLine + - "Parameters: " + _ffmpegProcess.StartInfo.Arguments, - MessageBoxButtons.OK, - MessageBoxIcon.Error); - - IsGenerating = false; - ProgressValue = 0; - }); + ReportFfmpegFailure(jobItem, "ffmpeg failed for"); + return; + } + if (!File.Exists(jobItem.OutputVideoFileName)) + { + ReportFfmpegFailure(jobItem, "Output video file not generated:"); return; } @@ -461,6 +458,37 @@ await MessageBox.Show(Window!, }); } + /// + /// Tells the user that an ffmpeg run failed, and writes the full command line, exit code and + /// ffmpeg log to the tools log for bug reports. + /// + private void ReportFfmpegFailure(BurnInJobItem jobItem, string reason) + { + Se.WriteToolsLog(reason + " " + jobItem.OutputVideoFileName + Environment.NewLine + + "ffmpeg: " + _ffmpegProcess!.StartInfo.FileName + Environment.NewLine + + "Parameters: " + _ffmpegProcess.StartInfo.Arguments + Environment.NewLine + + "OS: " + Environment.OSVersion + Environment.NewLine + + "64-bit: " + Environment.Is64BitOperatingSystem + Environment.NewLine + + "ffmpeg exit code: " + _ffmpegProcess.ExitCode + Environment.NewLine + + "ffmpeg log: " + _log); + + jobItem.Status = Se.Language.General.Error; + + Dispatcher.UIThread.Invoke(async () => + { + await MessageBox.Show(Window!, + "Unable to generate video", + reason + " " + jobItem.OutputVideoFileName + Environment.NewLine + + "ffmpeg exit code: " + _ffmpegProcess.ExitCode + Environment.NewLine + + "Parameters: " + _ffmpegProcess.StartInfo.Arguments, + MessageBoxButtons.OK, + MessageBoxIcon.Error); + + IsGenerating = false; + ProgressValue = 0; + }); + } + private async Task InitAndStartJobItem(int index) { _startTicks = DateTime.UtcNow.Ticks; @@ -469,12 +497,29 @@ private async Task InitAndStartJobItem(int index) var mediaInfo = FfmpegMediaInfo.Parse(jobItem.InputVideoFileName); jobItem.TotalFrames = mediaInfo.GetTotalFrames(); jobItem.TotalSeconds = mediaInfo.Duration.TotalSeconds; - if (mediaInfo.Dimension.Width > 0 && mediaInfo.Dimension.Height > 0) + + // With "Cut" on, only the cut range is encoded (-ss/-t below). Measuring the full + // source made the target-file-size bit rate a fraction of what was asked for, and + // left the progress bar creeping to a few percent before jumping to Done. + if (IsCutActive && jobItem.TotalSeconds > 0) + { + var cutSeconds = (CutTo - CutFrom).TotalSeconds; + if (cutSeconds > 0 && cutSeconds < jobItem.TotalSeconds) + { + jobItem.TotalFrames = (long)Math.Round(jobItem.TotalFrames * (cutSeconds / jobItem.TotalSeconds)); + jobItem.TotalSeconds = cutSeconds; + } + } + // Only adopt the source resolution when the user asked for it. Unconditionally copying it + // threw away an explicitly picked output resolution (e.g. 720p from a 4K source), so the + // job still encoded at the source size. + if (mediaInfo.Dimension.Width > 0 && mediaInfo.Dimension.Height > 0 && + (UseSourceResolution || jobItem.Width <= 0 || jobItem.Height <= 0)) { jobItem.Width = mediaInfo.Dimension.Width; jobItem.Height = mediaInfo.Dimension.Height; } - else + else if (mediaInfo.Dimension.Width <= 0 || mediaInfo.Dimension.Height <= 0) { // No video stream to read a resolution from - keep the resolution chosen in the // UI and burn the subtitles onto a generated black canvas (issue #11570). @@ -837,7 +882,8 @@ private ObservableCollection GetCurrentVideoAsJobItems(string out : _tempSubtitleFiles.Write(subtitle, new SubRip()); _mediaInfo = FfmpegMediaInfo2.Parse(VideoFileName); - if (_mediaInfo.Dimension.Width > 0 && _mediaInfo.Dimension.Height > 0) + if (_mediaInfo.Dimension.Width > 0 && _mediaInfo.Dimension.Height > 0 && + (UseSourceResolution || VideoWidth is null or <= 0 || VideoHeight is null or <= 0)) { VideoWidth = _mediaInfo.Dimension.Width; VideoHeight = _mediaInfo.Dimension.Height; @@ -868,12 +914,21 @@ private string MakeAssa(string subtitleFileName) var isAssa = subtitleFileName.EndsWith(".ass", StringComparison.OrdinalIgnoreCase); var subtitle = Subtitle.Parse(subtitleFileName); + if (subtitle == null) + { + // Not a subtitle format we know: Parse returns null, and everything below + // dereferences it. TransparentSubtitlesViewModel skips the job the same way. + jobItem.Status = "Skipped"; + return string.Empty; + } + subtitle = GetSubtitleBasedOnCut(subtitle); - if (subtitle.OriginalFormat is NetflixImsc11Japanese) + if (subtitle.OriginalFormat is NetflixImsc11Japanese || NetflixImsc11JapaneseToAss.HasJapaneseMarkup(subtitle)) { // Furigana, bouten and vertical writing become extra positioned render lines - burning - // in the raw tags would put them on screen as literal text (issue #13861). + // in the raw tags would put them on screen as literal text (issue #13861). Lambda Cap + // decodes to the same markup (issue #14165). var japaneseAssaFileName = _tempSubtitleFiles.GetFileName(".ass"); File.WriteAllText(japaneseAssaFileName, NetflixImsc11JapaneseToAss.Convert(subtitle, jobItem.Width, jobItem.Height)); return japaneseAssaFileName; @@ -887,7 +942,10 @@ private string MakeAssa(string subtitleFileName) { var fontSize = CalculateFontSize(jobItem.Width, jobItem.Height, FontFactor ?? 0); var durationMs = (int)(s.Duration.TotalMilliseconds); - s.Text = effect.ApplyEffect(s.Text, VideoWidth ?? 0, VideoHeight ?? 0, fontSize, durationMs); + // This job's dimensions, not the UI's: the effects emit absolute ASSA + // coordinates that are resolved against the PlayResX/Y written from + // jobItem below, so a batch item of another size placed text wrongly. + s.Text = effect.ApplyEffect(s.Text, jobItem.Width, jobItem.Height, fontSize, durationMs); } } @@ -1030,7 +1088,7 @@ private async Task ShowLogo() var result = await _windowService.ShowDialogAsync(Window!, vm => { - vm.BurnInLogo = BurnInLogo; + vm.BurnInLogo = BurnInLogo.Clone(); vm.Initialize(VideoFileName, VideoWidth.Value, VideoHeight.Value); }); @@ -1380,7 +1438,10 @@ private void LoadSettings() SelectedFontOutline = settings.OutlineWidth; SelectedFontShadowWidth = settings.ShadowWidth; SelectedFontSpacing = settings.NonAssaSpacing; - SelectedFontName = settings.FontName; + // Guard the fallback the way the constructor does: assigning a font that is not + // in FontNames nulls the ComboBox selection, and the TwoWay binding then writes + // that null back over the saved font name. + SelectedFontName = FontNames.FirstOrDefault(p => p == settings.FontName) ?? FontNames[0]; FontTextColor = settings.NonAssaTextColor.FromHexToColor(); FontOutlineColor = settings.NonAssaOutlineColor.FromHexToColor(); FontBoxColor = settings.NonAssaBoxColor.FromHexToColor(); @@ -1422,7 +1483,7 @@ private void LoadSettings() UseTargetFileSize = settings.TargetFileSize; TargetFileSize = settings.TargetFileSizeMb; MatchSourceVideoSize = settings.TargetFileSizeMatchSource; - PromptForFfmpegParameters = settings.PromptFfmpegParameters; + PromptForFfmpegParameters = false; // one-shot, never restored from settings var effectsAsStringArray = settings.Effects?.Split(',') ?? []; _selectedEffects = BurnInEffectItem.List().Where(p => effectsAsStringArray.Contains(p.Name)).ToList(); @@ -1464,7 +1525,9 @@ private void SaveSettings() settings.TargetFileSize = UseTargetFileSize; settings.TargetFileSizeMb = TargetFileSize ?? 0; settings.TargetFileSizeMatchSource = MatchSourceVideoSize; - settings.PromptFfmpegParameters = PromptForFfmpegParameters; + // Deliberately not persisted: PromptForFfmpegParameters is a one-shot set by the + // "Prompt for ffmpeg params and generate" menu item. Saving it made the plain + // Generate button prompt forever, with no UI to turn it back off. settings.Effects = string.Join(",", _selectedEffects.Select(p => p.Name).Distinct()); @@ -1693,7 +1756,7 @@ private void FillAudioEncodings(string extension, string? preferredAudioEncoding private void FillPreset(string videoCodec) { - VideoPresetText = "Preset"; + VideoPresetText = Se.Language.Video.BurnIn.Preset; var previousPreset = SelectedVideoPreset; SelectedVideoPreset = null; @@ -1806,7 +1869,7 @@ private void FillPreset(string videoCodec) defaultItem = "standard"; - VideoPresetText = "Profile"; + VideoPresetText = Se.Language.General.Profile; } else if (videoCodec.Contains("av1")) { @@ -1849,7 +1912,7 @@ public void FillCrf(string videoCodec) { var previousCrf = SelectedVideoCrf; SelectedVideoCrf = null; - VideoCrfText = "CRF"; + VideoCrfText = Se.Language.Video.BurnIn.Crf; VideoCrfHint = string.Empty; var items = new List { " " }; @@ -1898,7 +1961,7 @@ public void FillCrf(string videoCodec) items.Add(i.ToString(CultureInfo.InvariantCulture)); } - VideoCrfText = "Quality"; + VideoCrfText = Se.Language.General.Quality; VideoCrfHint = "0=best quality, 10=best speed"; VideoCrf.Clear(); VideoCrf.AddRange(items); @@ -1915,7 +1978,7 @@ public void FillCrf(string videoCodec) items.Add(i.ToString(CultureInfo.InvariantCulture)); } - VideoCrfText = "Quality"; + VideoCrfText = Se.Language.General.Quality; VideoCrfHint = "1=lowest quality, 100=best quality (Apple silicon only)"; VideoCrf.Clear(); VideoCrf.AddRange(items); @@ -2355,7 +2418,7 @@ private void RemoveUnsupportedVideoEncodings() var height = VideoHeight ?? _mediaInfo?.Dimension.Height ?? 1080; var subtitle = new Subtitle(_subtitle, false); - if (_subtitleFormat is NetflixImsc11Japanese) + if (_subtitleFormat is NetflixImsc11Japanese || NetflixImsc11JapaneseToAss.HasJapaneseMarkup(subtitle)) { return NetflixImsc11JapaneseToAss.Convert(subtitle, width, height); } diff --git a/src/ui/Features/Video/BurnIn/BurnInWindow.cs b/src/ui/Features/Video/BurnIn/BurnInWindow.cs index 1cd58b48054..8e43f40498e 100644 --- a/src/ui/Features/Video/BurnIn/BurnInWindow.cs +++ b/src/ui/Features/Video/BurnIn/BurnInWindow.cs @@ -196,7 +196,7 @@ void UpdateGrowAreas() }; UpdateGrowAreas(); - Activated += delegate { _comboBoxFontName?.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, () => { _comboBoxFontName?.Focus(); }); // initial focus on an input, not an action button - a focused button clicks on bare Space Loaded += (_, _) => vm.Loaded(); KeyDown += (_, e) => vm.OnKeyDown(e); @@ -576,8 +576,10 @@ private Border MakeSubtitlesView(BurnInViewModel vm) private static Border MakeVideoSettingsView(BurnInViewModel vm) { var labelResolution = UiUtil.MakeLabel(Se.Language.General.Resolution); + var labelWidth = UiUtil.MakeLabel("W"); var textBoxWidth = UiUtil.MakeNumericUpDownInt(0, 10_000, 0, 130, vm, nameof(vm.VideoWidth)); var labelX = UiUtil.MakeLabel("x"); + var labelHeight = UiUtil.MakeLabel("H"); var textBoxHeight = UiUtil.MakeNumericUpDownInt(0, 10_000, 0, 130, vm, nameof(vm.VideoHeight)); var buttonResolution = UiUtil.MakeButtonBrowse(vm.BrowseResolutionCommand, accessibleName: Se.Language.General.Resolution); var panelResolution = new StackPanel @@ -586,8 +588,10 @@ private static Border MakeVideoSettingsView(BurnInViewModel vm) Spacing = 5, Children = { + labelWidth, textBoxWidth, labelX, + labelHeight, textBoxHeight, buttonResolution, } diff --git a/src/ui/Features/Video/BurnIn/SelectVideoPositionWindow.cs b/src/ui/Features/Video/BurnIn/SelectVideoPositionWindow.cs index a072343cfd4..29e93529f3f 100644 --- a/src/ui/Features/Video/BurnIn/SelectVideoPositionWindow.cs +++ b/src/ui/Features/Video/BurnIn/SelectVideoPositionWindow.cs @@ -58,7 +58,7 @@ public SelectVideoPositionWindow(SelectVideoPositionViewModel vm) Content = grid; - Activated += delegate { buttonCancel.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, buttonCancel); // initial focus on an input, not an action button - a focused button clicks on bare Space } protected override void OnLoaded(RoutedEventArgs e) diff --git a/src/ui/Features/Video/Chapters/ChaptersWindow.cs b/src/ui/Features/Video/Chapters/ChaptersWindow.cs index 94a70f3c176..dfcbd6237b2 100644 --- a/src/ui/Features/Video/Chapters/ChaptersWindow.cs +++ b/src/ui/Features/Video/Chapters/ChaptersWindow.cs @@ -64,7 +64,7 @@ public ChaptersWindow(ChaptersViewModel vm) Content = grid; - Activated += delegate { buttonCancel.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, buttonCancel); // hack to make OnKeyDown work } /// diff --git a/src/ui/Features/Video/Chapters/WriteChaptersToVideoWindow.cs b/src/ui/Features/Video/Chapters/WriteChaptersToVideoWindow.cs index 3743e084b1d..2bb52a44ebc 100644 --- a/src/ui/Features/Video/Chapters/WriteChaptersToVideoWindow.cs +++ b/src/ui/Features/Video/Chapters/WriteChaptersToVideoWindow.cs @@ -107,7 +107,7 @@ public WriteChaptersToVideoWindow(WriteChaptersToVideoViewModel vm) Content = grid; - Activated += delegate { textBoxOutput.Focus(); }; + UiUtil.FocusOnFirstActivation(this, textBoxOutput); } /// diff --git a/src/ui/Features/Video/CutVideo/CutVideoViewModel.cs b/src/ui/Features/Video/CutVideo/CutVideoViewModel.cs index 37a9e6884d2..646cce1d2e8 100644 --- a/src/ui/Features/Video/CutVideo/CutVideoViewModel.cs +++ b/src/ui/Features/Video/CutVideo/CutVideoViewModel.cs @@ -51,7 +51,6 @@ public partial class CutVideoViewModel : ObservableObject [ObservableProperty] private bool _isAudioVisualizerVisible; [ObservableProperty] private ObservableCollection _segments; [ObservableProperty] private SubtitleLineViewModel? _selectedSegment; - [ObservableProperty] private int _selectedSegmentIndex; [ObservableProperty] private ObservableCollection _cutTypes; [ObservableProperty] private CutTypeDisplay _selectedCutType; [ObservableProperty] private bool _isSetStartEnabled; @@ -80,6 +79,15 @@ public partial class CutVideoViewModel : ObservableObject private SubtitleFormat? _subtitleFormat; private string _inputVideoFileName; private bool _updateAudioVisualizer; + + // ffmpeg's stdout and stderr readers both call OutputHandlerKeyFrames, on two thread pool + // threads, while the waveform render thread walks AudioVisualizer.ShotChanges every frame. + // List.Add publishes the grown array and the new size non-atomically, so adding straight + // into the live list could throw IndexOutOfRangeException out of Render. Collect here under a + // lock and hand the visualizer a finished list on the UI thread instead - what every other + // shot-change writer does - coalescing the publishes so a long file cannot flood the queue. + private readonly List _keyFrameSeconds = new List(); + private bool _keyFramePublishPending; private DispatcherTimer _positionTimer = new DispatcherTimer(); private string _importFileName; private Subtitle _currentSubtitle; @@ -107,11 +115,12 @@ public CutVideoViewModel(IFolderHelper folderHelper, IFileHelper fileHelper, IWi FrameRates = new ObservableCollection { 23.976, 24, 25, 29.97, 30, 50, 59.94, 60 }; SelectedFrameRate = FrameRates[0]; + // No .webm: the video path always encodes libx264, which the WebM muxer cannot carry. + // (.mp3/.wav take the audio-only branch and are fine.) VideoExtensions = new ObservableCollection { ".mkv", ".mp4", - ".webm", ".mp3", ".wav", }; @@ -214,7 +223,10 @@ private void StartTitleTimer() _positionTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(150) }; _positionTimer.Tick += (s, e) => { - UpdateAudioVisualizer(VideoPlayer.VideoPlayer, AudioVisualizer, SelectedSegmentIndex); + // Derive the index from the row the grid actually binds. SelectedSegmentIndex is + // written nowhere in the repo and is not bound, so it stayed 0 and the waveform + // always highlighted the first segment no matter which row was selected. + UpdateAudioVisualizer(VideoPlayer.VideoPlayer, AudioVisualizer, SelectedSegment == null ? -1 : Segments.IndexOf(SelectedSegment)); if (_updateAudioVisualizer) { @@ -288,8 +300,29 @@ private void OutputHandlerKeyFrames(object sendingProcess, DataReceivedEventArgs if (double.TryParse(ptsValue, NumberStyles.Float, CultureInfo.InvariantCulture, out double seconds)) { - AudioVisualizer.ShotChanges.Add(seconds); - _updateAudioVisualizer = true; + lock (_keyFrameSeconds) + { + _keyFrameSeconds.Add(seconds); + if (_keyFramePublishPending) + { + return; + } + + _keyFramePublishPending = true; + } + + Dispatcher.UIThread.Post(() => + { + List snapshot; + lock (_keyFrameSeconds) + { + _keyFramePublishPending = false; + snapshot = new List(_keyFrameSeconds); + } + + AudioVisualizer.ShotChanges = snapshot; + _updateAudioVisualizer = true; + }, DispatcherPriority.Background); } } } @@ -360,7 +393,7 @@ await MessageBox.Show(Window!, MessageBoxButtons.OK, MessageBoxIcon.Error); - IsGenerating = true; + IsGenerating = false; ProgressValue = 0; }); @@ -565,18 +598,24 @@ private string MakeOutputFileName(string videoFileName) var nameNoExt = Path.GetFileNameWithoutExtension(videoFileName); var ext = SelectedVideoExtension; var suffix = Se.Settings.Video.BurnIn.BurnInSuffix; - var fileName = Path.Combine(Path.GetDirectoryName(videoFileName)!, nameNoExt + suffix + ext); - if (Se.Settings.Video.BurnIn.UseOutputFolder && - !string.IsNullOrEmpty(Se.Settings.Video.BurnIn.OutputFolder) && - Directory.Exists(Se.Settings.Video.BurnIn.OutputFolder)) - { - fileName = Path.Combine(Se.Settings.Video.BurnIn.OutputFolder, nameNoExt + suffix + ext); - } + + // Decide the folder once - the collision loop below used to combine with + // BurnIn.OutputFolder unconditionally, so with the default (empty, unused) output + // folder the "_2" fallback became a bare relative name resolved against the process + // working directory instead of the video's folder. + var useOutputFolder = Se.Settings.Video.BurnIn.UseOutputFolder && + !string.IsNullOrEmpty(Se.Settings.Video.BurnIn.OutputFolder) && + Directory.Exists(Se.Settings.Video.BurnIn.OutputFolder); + var outputFolder = useOutputFolder + ? Se.Settings.Video.BurnIn.OutputFolder + : Path.GetDirectoryName(videoFileName) ?? Path.GetTempPath(); + + var fileName = Path.Combine(outputFolder, nameNoExt + suffix + ext); var i = 2; while (File.Exists(fileName)) { - fileName = Path.Combine(Se.Settings.Video.BurnIn.OutputFolder, $"{nameNoExt}{suffix}_{i}{ext}"); + fileName = Path.Combine(outputFolder, $"{nameNoExt}{suffix}_{i}{ext}"); i++; } diff --git a/src/ui/Features/Video/CutVideo/CutVideoWindow.cs b/src/ui/Features/Video/CutVideo/CutVideoWindow.cs index 8f5db649059..54030566fe5 100644 --- a/src/ui/Features/Video/CutVideo/CutVideoWindow.cs +++ b/src/ui/Features/Video/CutVideo/CutVideoWindow.cs @@ -95,14 +95,14 @@ public CutVideoWindow(CutVideoViewModel vm) Content = grid; - Activated += delegate + UiUtil.FocusOnFirstActivation(this, () => { // initial focus on an input, not an action button - a focused button clicks on bare Space if (vm.SegmentGrid != null) { TableViewExtras.FocusRow(vm.SegmentGrid); } - }; + }); Loaded += (s, e) => vm.OnLoaded(); Closing += (s, e) => vm.OnClosing(); AddHandler(KeyDownEvent, vm.OnKeyDownHandler, RoutingStrategies.Tunnel | RoutingStrategies.Bubble, handledEventsToo: false); diff --git a/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbedTrackPreviewViewModel.cs b/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbedTrackPreviewViewModel.cs index 7813bd484f1..38d54eb44ab 100644 --- a/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbedTrackPreviewViewModel.cs +++ b/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbedTrackPreviewViewModel.cs @@ -54,6 +54,17 @@ private void Close() Dispatcher.UIThread.Post(() => { Window?.Close(); }); } + /// + /// The MatroskaFile handed to holds an open FileStream on the video + /// and this view model owns it for its lifetime; without this every Preview click left a + /// handle on a possibly multi-GB file behind. + /// + internal void OnClosing() + { + _matroskaFile?.Dispose(); + _matroskaFile = null; + } + [RelayCommand] private void Ok() { diff --git a/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbedTrackPreviewWindow.cs b/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbedTrackPreviewWindow.cs index 721f7fa8a74..451b18b88ed 100644 --- a/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbedTrackPreviewWindow.cs +++ b/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbedTrackPreviewWindow.cs @@ -56,6 +56,7 @@ public EmbedTrackPreviewWindow(EmbedTrackPreviewViewModel vm) //KeyDown += (_, e) => vm.OnKeyDown(e); AddHandler(KeyDownEvent, vm.OnKeyDownHandler, RoutingStrategies.Tunnel | RoutingStrategies.Bubble, handledEventsToo: false); + Closing += (_, _) => vm.OnClosing(); Loaded += (_, _) => diff --git a/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbeddedSubtitlesEditMp4ViewModel.cs b/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbeddedSubtitlesEditMp4ViewModel.cs index 055debc4c3e..45f81732f3b 100644 --- a/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbeddedSubtitlesEditMp4ViewModel.cs +++ b/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbeddedSubtitlesEditMp4ViewModel.cs @@ -629,6 +629,23 @@ internal void OnKeyDown(KeyEventArgs e) internal void OnClosing() { UiUtil.SaveWindowPosition(Window); + + // Stop the poll timer and any still-running encode - closing the window used to + // leave the ffmpeg process muxing to completion in the background. + _timerGenerate.StopAndDispose(TimerGenerateElapsed); + if (_ffmpegProcess != null && !_ffmpegProcess.HasExited) + { + try + { +#pragma warning disable CA1416 + _ffmpegProcess.Kill(true); +#pragma warning restore CA1416 + } + catch + { + // ignore - it may have exited in between + } + } } internal void OnLoaded() diff --git a/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbeddedSubtitlesEditMp4Window.cs b/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbeddedSubtitlesEditMp4Window.cs index 22427c7112e..fbb0abb14bb 100644 --- a/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbeddedSubtitlesEditMp4Window.cs +++ b/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbeddedSubtitlesEditMp4Window.cs @@ -82,7 +82,7 @@ public EmbeddedSubtitlesEditMp4Window(EmbeddedSubtitlesEditMp4ViewModel vm) Content = grid; - Activated += delegate { textBoxVideoFileName.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, textBoxVideoFileName); // initial focus on an input, not an action button - a focused button clicks on bare Space Loaded += (s, e) => vm.OnLoaded(); Closing += (s, e) => vm.OnClosing(); KeyDown += (s, e) => vm.OnKeyDown(e); diff --git a/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbeddedSubtitlesEditViewModel.cs b/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbeddedSubtitlesEditViewModel.cs index 53c4e35cb2e..d5a32b8ba03 100644 --- a/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbeddedSubtitlesEditViewModel.cs +++ b/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbeddedSubtitlesEditViewModel.cs @@ -47,6 +47,7 @@ public partial class EmbeddedSubtitlesEditViewModel : ObservableObject private long _processedFrames; private Process? _ffmpegProcess; private readonly Timer _timerGenerate; + private bool _loaded; private bool _doAbort; private SubtitleFormat? _subtitleFormat; private DispatcherTimer _positionTimer = new DispatcherTimer(); @@ -161,7 +162,6 @@ private void TimerGenerateElapsed(object? sender, ElapsedEventArgs e) _timerGenerate.Stop(); ProgressValue = 100; ProgressText = string.Empty; - Se.LogError(_log.ToString()); if (!File.Exists(_outputFileName)) { @@ -271,18 +271,24 @@ private string MakeOutputFileName(string videoFileName) var nameNoExt = Path.GetFileNameWithoutExtension(videoFileName); var ext = Path.GetExtension(VideoFileName) ?? ".mkv"; var suffix = Se.Settings.Video.BurnIn.BurnInSuffix; - var fileName = Path.Combine(Path.GetDirectoryName(videoFileName)!, nameNoExt + suffix + ext); - if (Se.Settings.Video.BurnIn.UseOutputFolder && - !string.IsNullOrEmpty(Se.Settings.Video.BurnIn.OutputFolder) && - Directory.Exists(Se.Settings.Video.BurnIn.OutputFolder)) - { - fileName = Path.Combine(Se.Settings.Video.BurnIn.OutputFolder, nameNoExt + suffix + ext); - } + + // Decide the folder once: the collision loop below used to combine with + // BurnIn.OutputFolder unconditionally, so with the default (empty, unused) folder + // the second file of a run got a bare relative name resolved against the process + // working directory instead of the video's folder. + var useOutputFolder = Se.Settings.Video.BurnIn.UseOutputFolder && + !string.IsNullOrEmpty(Se.Settings.Video.BurnIn.OutputFolder) && + Directory.Exists(Se.Settings.Video.BurnIn.OutputFolder); + var outputFolder = useOutputFolder + ? Se.Settings.Video.BurnIn.OutputFolder + : Path.GetDirectoryName(videoFileName) ?? Path.GetTempPath(); + + var fileName = Path.Combine(outputFolder, nameNoExt + suffix + ext); var i = 2; while (File.Exists(fileName)) { - fileName = Path.Combine(Se.Settings.Video.BurnIn.OutputFolder, $"{nameNoExt}{suffix}_{i}{ext}"); + fileName = Path.Combine(outputFolder, $"{nameNoExt}{suffix}_{i}{ext}"); i++; } @@ -499,7 +505,10 @@ private async Task BrowseVideoFile() VideoFileName = fileName; _ = Task.Run(() => { + // Parse once, off the UI thread - this used to also parse synchronously on the + // UI thread after starting the task, freezing the window for the probe. var mediaInfo = FfmpegMediaInfo2.Parse(fileName); + _mediaInfo = mediaInfo; Dispatcher.UIThread.Invoke(() => { Tracks.Clear(); @@ -513,7 +522,6 @@ private async Task BrowseVideoFile() SelectAndScrollToRow(0); }); }); - _mediaInfo = FfmpegMediaInfo2.Parse(fileName); } } @@ -598,10 +606,37 @@ internal void OnKeyDown(KeyEventArgs e) internal void OnClosing() { UiUtil.SaveWindowPosition(Window); + + // Stop the poll timer and any still-running encode - closing the window used to + // leave the ffmpeg process muxing to completion in the background. + _timerGenerate.StopAndDispose(TimerGenerateElapsed); + if (_ffmpegProcess != null && !_ffmpegProcess.HasExited) + { + try + { +#pragma warning disable CA1416 + _ffmpegProcess.Kill(true); +#pragma warning restore CA1416 + } + catch + { + // ignore - it may have exited in between + } + } } internal void OnLoaded() { + // Avalonia's Window.Loaded can fire more than once (re-attach to visual tree, layout + // pass), and the scan below APPENDS without clearing - so a second fire listed every + // existing subtitle track twice and Generate then mapped each kept stream twice into the + // output. The mp4 twin already guards this. + if (_loaded) + { + return; + } + + _loaded = true; StartTitleTimer(); UiUtil.RestoreWindowPosition(Window); Task.Run(() => @@ -647,7 +682,9 @@ private static List FindTracks(string videoFileName, FfmpegMediaI if (FileUtil.IsMatroskaFileFast(videoFileName)) { - var matroskaFile = new MatroskaFile(videoFileName); + // MatroskaFile opens a FileStream on the video in its constructor, so without the + // using every window open and every Browse left a handle on a multi-GB file behind. + using var matroskaFile = new MatroskaFile(videoFileName); if (matroskaFile.IsValid) { var tracks = matroskaFile.GetTracks(); diff --git a/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbeddedSubtitlesEditWindow.cs b/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbeddedSubtitlesEditWindow.cs index ee387ccd233..8a93711f987 100644 --- a/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbeddedSubtitlesEditWindow.cs +++ b/src/ui/Features/Video/EmbeddedSubtitlesEdit/EmbeddedSubtitlesEditWindow.cs @@ -88,7 +88,7 @@ public EmbeddedSubtitlesEditWindow(EmbeddedSubtitlesEditViewModel vm) Content = grid; - Activated += delegate { textBoxVideoFileName.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, textBoxVideoFileName); // initial focus on an input, not an action button - a focused button clicks on bare Space Loaded += (s, e) => vm.OnLoaded(); Closing += (s, e) => vm.OnClosing(); KeyDown += (s, e) => vm.OnKeyDown(e); diff --git a/src/ui/Features/Video/GoToVideoPosition/GoToVideoPositionWindow.cs b/src/ui/Features/Video/GoToVideoPosition/GoToVideoPositionWindow.cs index 12c94c6e3e0..b2f6d8c8fb3 100644 --- a/src/ui/Features/Video/GoToVideoPosition/GoToVideoPositionWindow.cs +++ b/src/ui/Features/Video/GoToVideoPosition/GoToVideoPositionWindow.cs @@ -67,7 +67,7 @@ public GoToVideoPositionWindow(GoToVideoPositionViewModel vm) Content = contentPanel; - Activated += delegate { vm.Activated(); }; + UiUtil.FocusOnFirstActivation(this, () => { vm.Activated(); }); } protected override void OnKeyDown(KeyEventArgs e) diff --git a/src/ui/Features/Video/OpenFromUrl/OpenFromUrlWindow.cs b/src/ui/Features/Video/OpenFromUrl/OpenFromUrlWindow.cs index fa984eca554..88e7f91d3c2 100644 --- a/src/ui/Features/Video/OpenFromUrl/OpenFromUrlWindow.cs +++ b/src/ui/Features/Video/OpenFromUrl/OpenFromUrlWindow.cs @@ -1,4 +1,4 @@ -using Avalonia; +using Avalonia; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Input; @@ -122,18 +122,11 @@ public OpenFromUrlWindow(OpenFromUrlViewModel vm) // the Loaded + Input-priority post this used to do never stuck on macOS. Guarded so // re-activating the window later doesn't yank focus back out of whatever the user // was on. - var focused = false; - Activated += delegate + UiUtil.FocusOnFirstActivation(this, () => { - if (focused) - { - return; - } - - focused = true; _urlTextBox.Focus(); _urlTextBox.CaretIndex = _urlTextBox.Text?.Length ?? 0; - }; + }); } private Button BuildCard(string iconGlyph, string title, string description, string note, IRelayCommand command, bool isRecommended) diff --git a/src/ui/Features/Video/OpenSecondarySubtitle/OpenSecondarySubtitleWindow.cs b/src/ui/Features/Video/OpenSecondarySubtitle/OpenSecondarySubtitleWindow.cs index 2ad6695d1a4..ebbf684ed93 100644 --- a/src/ui/Features/Video/OpenSecondarySubtitle/OpenSecondarySubtitleWindow.cs +++ b/src/ui/Features/Video/OpenSecondarySubtitle/OpenSecondarySubtitleWindow.cs @@ -117,7 +117,7 @@ public OpenSecondarySubtitleWindow(OpenSecondarySubtitleViewModel vm) Content = mainGrid; // initial focus on an input, not an action button - a focused button clicks on bare Space - Activated += delegate { numericFontSize.Focus(); }; + UiUtil.FocusOnFirstActivation(this, numericFontSize); AddHandler(KeyDownEvent, (_, e) => vm.OnKeyDown(e), RoutingStrategies.Tunnel | RoutingStrategies.Bubble, handledEventsToo: false); Loaded += (_, _) => vm.OnLoaded(); diff --git a/src/ui/Features/Video/ReEncodeVideo/ReEncodeVideoViewModel.cs b/src/ui/Features/Video/ReEncodeVideo/ReEncodeVideoViewModel.cs index 5d053653c32..b9107818760 100644 --- a/src/ui/Features/Video/ReEncodeVideo/ReEncodeVideoViewModel.cs +++ b/src/ui/Features/Video/ReEncodeVideo/ReEncodeVideoViewModel.cs @@ -75,11 +75,12 @@ public ReEncodeVideoViewModel(IFolderHelper folderHelper, IFileHelper fileHelper FrameRates = new ObservableCollection { 23.976, 24, 25, 29.97, 30, 50, 59.94, 60 }; SelectedFrameRate = FrameRates[0]; + // No .webm: this always encodes libx264, and the WebM muxer takes only VP8/VP9/AV1, so + // picking it could only ever end in "Output video file not generated". VideoExtensions = new ObservableCollection { ".mkv", ".mp4", - ".webm", }; SelectedVideoExtension = VideoExtensions[0]; @@ -204,7 +205,7 @@ await MessageBox.Show(Window!, MessageBoxButtons.OK, MessageBoxIcon.Error); - IsGenerating = true; + IsGenerating = false; ProgressValue = 0; }); @@ -262,8 +263,18 @@ private async Task InitAndStartJobItem(int index) var mediaInfo = FfmpegMediaInfo.Parse(jobItem.InputVideoFileName); jobItem.TotalFrames = mediaInfo.GetTotalFrames(); jobItem.TotalSeconds = mediaInfo.Duration.TotalSeconds; - jobItem.Width = mediaInfo.Dimension.Width; - jobItem.Height = mediaInfo.Dimension.Height; + + // Only adopt the source size when the user asked for it. This dialog exists to produce a + // SMALLER file ("high resolutions make subtitling slow"), and Initialize scales large + // sources down to 1280 wide - unconditionally overwriting both here threw that away, so + // every re-encode ran at the source resolution and the resolution picker did nothing. + // The burn-in dialog was fixed the same way. + if (mediaInfo.Dimension.Width > 0 && mediaInfo.Dimension.Height > 0 && + (UseSourceResolution || jobItem.Width <= 0 || jobItem.Height <= 0)) + { + jobItem.Width = mediaInfo.Dimension.Width; + jobItem.Height = mediaInfo.Dimension.Height; + } jobItem.UseTargetFileSize = false; jobItem.Status = Se.Language.General.Generating; @@ -403,16 +414,53 @@ private async Task PromptFfmpegParametersAndGeenrate() PromptForFfmpegParameters = false; } + /// + /// A distinct output name beside the source: "_reencoded", with a collision + /// counter, mirroring the cut-video dialog. Never returns the input path. + /// + private string MakeOutputFileName(string videoFileName) + { + var nameNoExt = Path.GetFileNameWithoutExtension(videoFileName); + var folder = Path.GetDirectoryName(videoFileName) ?? Path.GetTempPath(); + var fileName = Path.Combine(folder, nameNoExt + "_reencoded" + SelectedVideoExtension); + + var i = 2; + while (File.Exists(fileName)) + { + fileName = Path.Combine(folder, nameNoExt + "_reencoded_" + i.ToString(CultureInfo.InvariantCulture) + SelectedVideoExtension); + i++; + } + + return fileName; + } + [RelayCommand] private async Task Generate() { - var outputVideoFileName = Path.ChangeExtension(VideoFileName, SelectedVideoExtension); + // Suggest a DISTINCT name: ChangeExtension on an .mkv input with the default .mkv output + // handed the save dialog the input path itself, so accepting the suggestion pointed + // ffmpeg at the file it was reading and destroyed the user's video. The cut-video and + // embedded-subtitles dialogs both build a suffixed name with a collision counter. + var outputVideoFileName = MakeOutputFileName(VideoFileName); outputVideoFileName = await _fileHelper.PickSaveFile(Window!, SelectedVideoExtension, outputVideoFileName, Se.Language.General.SaveVideoAsVideoTitle); if (string.IsNullOrEmpty(outputVideoFileName)) { return; } + // ...and refuse it outright if the user still picks the input, as the write-chapters + // dialog does: ffmpeg reads the input while writing the output. + if (string.Equals(Path.GetFullPath(outputVideoFileName), Path.GetFullPath(VideoFileName), StringComparison.OrdinalIgnoreCase)) + { + await MessageBox.Show( + Window!, + Se.Language.General.Error, + Se.Language.General.OutputFileCannotBeTheInputFile, + MessageBoxButtons.OK, + MessageBoxIcon.Error); + return; + } + JobItems = GetCurrentVideoAsJobItems(outputVideoFileName); if (JobItems.Count == 0) { @@ -478,6 +526,24 @@ internal void OnClosing() // The subtitle files handed to ffmpeg live as long as the window does - nothing else // removes them, and they used to pile up in the temp folder run after run (#13332). _tempSubtitleFiles.Delete(); + + // Stop the poll timer and any still-running encode - closing the window used to leave the + // ffmpeg process encoding to completion in the background, with the timer still firing + // into a closed window. Same fix as the blank-video and embedded-subtitles dialogs. + _timerGenerate.StopAndDispose(TimerGenerateElapsed); + if (_ffmpegProcess != null && !_ffmpegProcess.HasExited) + { + try + { +#pragma warning disable CA1416 + _ffmpegProcess.Kill(true); +#pragma warning restore CA1416 + } + catch + { + // ignore - it may have exited in between + } + } } internal void OnKeyDown(KeyEventArgs e) @@ -485,7 +551,9 @@ internal void OnKeyDown(KeyEventArgs e) if (e.Key == Key.Escape) { e.Handled = true; - Window?.Close(); + // Route through Cancel so Escape during generation aborts the encode + // instead of closing the window over a running ffmpeg. + Cancel(); } else if (UiUtil.IsHelp(e)) { diff --git a/src/ui/Features/Video/ReEncodeVideo/ReEncodeVideoWindow.cs b/src/ui/Features/Video/ReEncodeVideo/ReEncodeVideoWindow.cs index ff4461a81f4..6608a998f6c 100644 --- a/src/ui/Features/Video/ReEncodeVideo/ReEncodeVideoWindow.cs +++ b/src/ui/Features/Video/ReEncodeVideo/ReEncodeVideoWindow.cs @@ -82,7 +82,7 @@ public ReEncodeVideoWindow(ReEncodeVideoViewModel vm) Content = grid; - Activated += delegate { _comboBoxFrameRate?.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, () => { _comboBoxFrameRate?.Focus(); }); // initial focus on an input, not an action button - a focused button clicks on bare Space } private Border MakeVideoSettingsView(ReEncodeVideoViewModel vm) diff --git a/src/ui/Features/Video/ShotChanges/ShotChangeListWindow.cs b/src/ui/Features/Video/ShotChanges/ShotChangeListWindow.cs index 7d78ead1ec2..ffe57f3eed9 100644 --- a/src/ui/Features/Video/ShotChanges/ShotChangeListWindow.cs +++ b/src/ui/Features/Video/ShotChanges/ShotChangeListWindow.cs @@ -56,7 +56,7 @@ public ShotChangeListWindow(ShotChangeListViewModel vm) Content = grid; - Activated += delegate { buttonCancel.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, buttonCancel); // hack to make OnKeyDown work } private static Border MakeBookmarkGridView(ShotChangeListViewModel vm) diff --git a/src/ui/Features/Video/ShotChanges/ShotChangesViewModel.cs b/src/ui/Features/Video/ShotChanges/ShotChangesViewModel.cs index d3dd5542b5e..caf360ad803 100644 --- a/src/ui/Features/Video/ShotChanges/ShotChangesViewModel.cs +++ b/src/ui/Features/Video/ShotChanges/ShotChangesViewModel.cs @@ -133,7 +133,17 @@ private void TimerGenerateElapsed(object? sender, ElapsedEventArgs e) if (FfmpegLines.Count > 0) { Ok(); + return; } + + // ffmpeg found nothing. Without clearing this the OK, Generate and sensitivity controls + // stay disabled for good, and Cancel only sets _doAbort for a timer that no longer runs. + Dispatcher.UIThread.Post(() => + { + IsGenerating = false; + ProgressValue = 0; + ProgressText = string.Empty; + }); } [RelayCommand] @@ -263,8 +273,10 @@ private void Ok() continue; } + // Parse the comma-normalized copy, not the raw line: a list written with decimal + // commas ("1,5") otherwise failed to parse and every line was silently dropped. s = s.Replace(",", "."); - if (double.TryParse(line, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var d)) + if (double.TryParse(s, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var d)) { if (TimeCodeFrames) { @@ -338,7 +350,7 @@ public void OnClosingCleanup() return null; } - private async Task LoadTextFile(string fileName) + internal async Task LoadTextFile(string fileName) { try { @@ -431,7 +443,11 @@ private async Task LoadTextFile(string fileName) } var ts = new TimeSpan(0, Convert.ToInt32(timeParts[0]), Convert.ToInt32(timeParts[1]), Convert.ToInt32(timeParts[2]), Convert.ToInt32(timeParts[3])); - sb.AppendLine(new TimeCode(ts).ToShortStringHHMMSSFF()); + + // HH:MM:SS,FFF - the caller selects the "hours:minutes:seconds:milliseconds" + // parser, so the last field must be milliseconds. ToShortStringHHMMSSFF() + // wrote FRAMES there, and frame 12 was then read back as 12 ms. + sb.AppendLine(new TimeCode(ts).ToString()); } } } @@ -489,10 +505,12 @@ private async Task LoadTextFile(string fileName) } } + // Plain seconds: the caller selects the "seconds" parser, which cannot read a + // HH:MM:SS:FF string at all - every line was dropped and the import came back empty. var sb = new StringBuilder(); foreach (var ms in list.OrderBy(p => p)) { - sb.AppendLine(new TimeCode(ms).ToShortStringHHMMSSFF()); + sb.AppendLine((ms / TimeCode.BaseUnit).ToString(CultureInfo.InvariantCulture)); } return sb.ToString(); diff --git a/src/ui/Features/Video/ShotChanges/ShotChangesWindow.cs b/src/ui/Features/Video/ShotChanges/ShotChangesWindow.cs index 302d93a5b92..c7fc93c5ea6 100644 --- a/src/ui/Features/Video/ShotChanges/ShotChangesWindow.cs +++ b/src/ui/Features/Video/ShotChanges/ShotChangesWindow.cs @@ -75,14 +75,14 @@ public ShotChangesWindow(ShotChangesViewModel vm) Content = grid; - Activated += delegate + UiUtil.FocusOnFirstActivation(this, () => { // initial focus on an input, not an action button - a focused button clicks on bare Space if (vm.FfmpegLinesGrid != null) { TableViewExtras.FocusRow(vm.FfmpegLinesGrid); } - }; + }); } private static Grid MakeGenerateView(ShotChangesViewModel vm) diff --git a/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextEngineViewModel.cs b/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextEngineViewModel.cs index 7031650cf25..a26daf1a6d5 100644 --- a/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextEngineViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextEngineViewModel.cs @@ -1,4 +1,4 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Input; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -55,7 +55,7 @@ protected override void OnPropertyChanged(System.ComponentModel.PropertyChangedE public ISpeechToTextEngine? Engine { get; internal set; } /// - /// CrispASR download variant. On Windows: "cpu", "cpu-legacy", "vulkan", or "cuda" (defaults to "vulkan"). + /// CrispASR download variant. On Windows: "cpu", "cpu-legacy", "vulkan", "cuda" or "cuda13" (defaults to "vulkan"). /// On Linux x86_64: "cuda", "cuda13", "vulkan", "hip", or null/empty for the default CPU build. /// Ignored on macOS / Linux ARM64. /// @@ -67,6 +67,8 @@ protected override void OnPropertyChanged(System.ComponentModel.PropertyChangedE /// public bool Qwen3AsrUseVulkan { get; set; } + /// The .7z being streamed to disk, so a cancel can remove the partial file. + private string? _tempArchiveFileName; private readonly IWhisperDownloadService _whisperDownloadService; private readonly IQwen3AsrCppDownloadService _qwen3AsrCppDownloadService; private readonly ICrispAsrDownloadService _crispAsrDownloadService; @@ -167,6 +169,13 @@ private void ProcessDownloadState() TitleText = Se.Language.General.Unpacking7ZipArchiveDotDotDot; StartIndeterminateProgress(); + + // Record the build before the archive is deleted below. This engine is streamed + // to a file rather than to memory (the archive is ~1.5 GB), so it takes the + // file overload. Without a sidecar nothing identifies the install and the + // engine-settings dialog can only report it as an unrecognized build (#14057). + DownloadHashManager.WriteSidecar(dir, DownloadHashManager.ResolvePurfviewFasterWhisperXxlKey(), tempFileName); + Unpacker.Extract7Zip(tempFileName, dir, "Faster-Whisper-XXL", _cancellationTokenSource, text => ProgressText = text); StopIndeterminateProgress(); @@ -200,6 +209,7 @@ private void ProcessDownloadState() TitleText = string.Format(Se.Language.General.UnpackingX, Engine.Name); StartIndeterminateProgress(); + DownloadHashManager.WriteSidecar(dir, DownloadHashManager.ResolveWhisperCTranslate2Key(), _downloadStream); Unpack(dir, string.Empty); StopIndeterminateProgress(); @@ -214,6 +224,50 @@ private void ProcessDownloadState() OkPressed = true; Close(); } + else if (Engine.Name == WhisperEngineWhisperX.StaticName) + { + var dir = Engine.GetAndCreateWhisperFolder(); + var tempFileName = Path.Combine(dir, Engine.Name + ".7z"); + + TitleText = string.Format(Se.Language.General.UnpackingX, Engine.Name); + StartIndeterminateProgress(); + + // Record the build before the archive is deleted below. Like Faster-Whisper-XXL + // this engine is streamed to a file rather than to memory (the archive is + // 216 MB-355 MB), so it takes the file overload. Without a sidecar nothing + // identifies the install and the engine-settings dialog can only report it as + // an unrecognized build (#14057). + DownloadHashManager.WriteSidecar(dir, DownloadHashManager.ResolveWhisperXKey(), tempFileName); + + // Flat archive (no top-level folder), so no folder level to skip. + Unpacker.Extract7Zip(tempFileName, dir, string.Empty, _cancellationTokenSource, text => ProgressText = text); + StopIndeterminateProgress(); + + try + { + File.Delete(tempFileName); + + MakeExecutable(Engine.GetExecutable()); + + // WhisperX bundles ctranslate2 from the same wheel family that hits the + // glibc 2.41 PT_GNU_STACK=RWE problem patched for Purfview Faster-Whisper-XXL. + ClearExecutableStack(dir); + } + catch + { + // ignore + } + + if (_cancellationTokenSource.IsCancellationRequested) + { + Cancel(); + return; + } + + // WhisperX runs its own bundled voice activity detection - no Silero sidecar needed. + OkPressed = true; + Close(); + } else { if (_downloadStream.Length == 0) @@ -241,6 +295,7 @@ private void ProcessDownloadState() : CrispAsrWindowsVariant switch { "cuda" => "crispasr-windows-x86_64-cuda", + "cuda13" => "crispasr-windows-x86_64-cuda13", "cpu" => "crispasr-windows-x86_64-cpu", "cpu-legacy" => "crispasr-windows-x86_64-cpu-legacy", "vulkan" => "crispasr-windows-x86_64-vulkan", @@ -257,6 +312,10 @@ private void ProcessDownloadState() { WriteWhisperCppInstalledHash(folder); } + else if (Engine is WhisperEngineConstMe) + { + DownloadHashManager.WriteSidecar(folder, DownloadHashManager.ResolveWhisperConstMeKey(), _downloadStream); + } else if (Engine is Qwen3AsrCppEngine) { // Sidecar powers the update prompt (issue #11375 - broken-JSON builds @@ -511,6 +570,27 @@ private void Cancel() { _cancellationTokenSource?.Cancel(); StopIndeterminateProgress(); + + // The Faster-Whisper-XXL / WhisperX archives stream straight to disk (216 MB - 1.5 GB) and + // were deleted only on the success path, so cancelling stranded the partial file in the + // engine folder where nothing would ever clean it up or notice it. + var partialArchive = _tempArchiveFileName; + _tempArchiveFileName = null; + if (!string.IsNullOrEmpty(partialArchive)) + { + try + { + if (File.Exists(partialArchive)) + { + File.Delete(partialArchive); + } + } + catch + { + // best-effort - it may still be open for writing + } + } + Close(); } @@ -551,6 +631,17 @@ public void StartDownload() { _downloadTask = _whisperDownloadService.DownloadWhisperCTranslate2(_downloadStream, downloadProgress, _cancellationTokenSource.Token); } + else if (Engine is WhisperEngineWhisperX) + { + // A file download, not the shared _downloadStream MemoryStream: at 216 MB-355 MB, + // buffering this in memory (with MemoryStream's doubling growth) would peak far + // higher before unpacking even starts. Purfview Faster-Whisper-XXL is downloaded + // the same way for the same reason. + var whisperXDir = Engine.GetAndCreateWhisperFolder(); + var whisperXTempFileName = Path.Combine(whisperXDir, Engine.Name + ".7z"); + _tempArchiveFileName = whisperXTempFileName; + _downloadTask = _whisperDownloadService.DownloadWhisperX(whisperXTempFileName, downloadProgress, _cancellationTokenSource.Token); + } else if (Engine is WhisperEngineConstMe) { _downloadTask = _whisperDownloadService.DownloadWhisperConstMe(_downloadStream, downloadProgress, _cancellationTokenSource.Token); @@ -559,6 +650,7 @@ public void StartDownload() { var dir = Engine.GetAndCreateWhisperFolder(); var tempFileName = Path.Combine(dir, Engine.Name + ".7z"); + _tempArchiveFileName = tempFileName; _downloadTask = _whisperDownloadService.DownloadWhisperPurfviewFasterWhisperXxl(tempFileName, downloadProgress, _cancellationTokenSource.Token); } else if (Engine is Qwen3AsrCppEngine) @@ -573,6 +665,7 @@ public void StartDownload() _downloadTask = CrispAsrWindowsVariant switch { "cuda" => _crispAsrDownloadService.DownloadEngineWindowsCuda(_downloadStream, downloadProgress, _cancellationTokenSource.Token), + "cuda13" => _crispAsrDownloadService.DownloadEngineWindowsCuda13(_downloadStream, downloadProgress, _cancellationTokenSource.Token), "cpu" => _crispAsrDownloadService.DownloadEngineWindowsCpu(_downloadStream, downloadProgress, _cancellationTokenSource.Token), "cpu-legacy" => _crispAsrDownloadService.DownloadEngineWindowsCpuLegacy(_downloadStream, downloadProgress, _cancellationTokenSource.Token), _ => _crispAsrDownloadService.DownloadEngineWindowsVulkan(_downloadStream, downloadProgress, _cancellationTokenSource.Token), diff --git a/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextEngineWindow.cs b/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextEngineWindow.cs index b1d3ba76626..7608d92b740 100644 --- a/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextEngineWindow.cs +++ b/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextEngineWindow.cs @@ -37,6 +37,20 @@ public DownloadSpeechToTextEngineWindow(DownloadSpeechToTextEngineViewModel vm) var statusText = new TextBlock(); statusText.Bind(TextBlock.TextProperty, new Binding(nameof(vm.ProgressText))); + // The view models set Error on every failure path, but nothing rendered it - the user saw + // only the generic "Download failed" while the real cause was written to a property no + // window bound. DownloadVideoFromUrlWindow and DownloadCrispEmbedWindow show it this way. + var errorText = new TextBlock + { + Foreground = new SolidColorBrush(Color.FromRgb(0xC0, 0x39, 0x2B)), + TextWrapping = TextWrapping.Wrap, + }; + errorText.Bind(TextBlock.TextProperty, new Binding(nameof(vm.Error))); + errorText.Bind(IsVisibleProperty, new Binding(nameof(vm.Error)) + { + Converter = new Avalonia.Data.Converters.FuncValueConverter(s => !string.IsNullOrEmpty(s)), + }); + var buttonCancel = UiUtil.MakeButton(Se.Language.General.Cancel, vm.CommandCancelCommand); var buttonBar = UiUtil.MakeButtonBar(buttonCancel); @@ -49,14 +63,15 @@ public DownloadSpeechToTextEngineWindow(DownloadSpeechToTextEngineViewModel vm) titleText, progressBar, statusText, + errorText, buttonBar, } }; - Activated += delegate + UiUtil.FocusOnFirstActivation(this, () => { buttonCancel.Focus(); // hack to make OnKeyDown work - }; + }); KeyDown += (s, e) => vm.OnKeyDown(e); } } \ No newline at end of file diff --git a/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextModelsViewModel.cs b/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextModelsViewModel.cs index 450d697d80e..9f44b43ab57 100644 --- a/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextModelsViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextModelsViewModel.cs @@ -111,52 +111,67 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) _timer.Stop(); - // IsCompletedSuccessfully, not the broader IsCompleted (also true for - // Faulted/Canceled), so a failed download falls through to the IsFaulted - // branch below instead of proceeding as if it had succeeded. - if (_downloadTask is { IsCompletedSuccessfully: true }) + try { - CompleteDownload(); - StartNextOrFinish(); - - return; - } - if (_downloadTask is { IsFaulted: true }) - { - var ex = _downloadTask.Exception?.InnerException ?? _downloadTask.Exception; - if (ex is OperationCanceledException) + // IsCompletedSuccessfully, not the broader IsCompleted (also true for + // Faulted/Canceled), so a failed download falls through to the IsFaulted + // branch below instead of proceeding as if it had succeeded. + if (_downloadTask is { IsCompletedSuccessfully: true }) { - ProgressText = Se.Language.General.DownloadCanceled; - Cancel(); + CompleteDownload(); + StartNextOrFinish(); return; } - // A 404 (FileNotFoundException) on an optional file isn't fatal: the URL list is - // the union of file names across all models, so every model 404s on some of them. - // Skip those and continue, like faster-whisper-xxl.exe does - only model.bin is - // required. (#12133 follow-up, #8703) - if (ex is FileNotFoundException && IsOptionalFile(_downloadUrls[_downloadIndex])) + if (_downloadTask is { IsFaulted: true }) { - DeletePartialDownload(); - StartNextOrFinish(); + var ex = _downloadTask.Exception?.InnerException ?? _downloadTask.Exception; + if (ex is OperationCanceledException) + { + ProgressText = Se.Language.General.DownloadCanceled; + Cancel(); + + return; + } + + // A 404 (FileNotFoundException) on an optional file isn't fatal: the URL list is + // the union of file names across all models, so every model 404s on some of them. + // Skip those and continue, like faster-whisper-xxl.exe does - only model.bin is + // required. (#12133 follow-up, #8703) + if (ex is FileNotFoundException && IsOptionalFile(_downloadUrls[_downloadIndex])) + { + DeletePartialDownload(); + StartNextOrFinish(); + + return; + } + + ProgressText = Se.Language.General.DownloadFailed; + Error = ex?.Message ?? Se.Language.General.UnknownError; return; } - ProgressText = Se.Language.General.DownloadFailed; - Error = ex?.Message ?? Se.Language.General.UnknownError; - - return; + // Guard the restart: OnClosingCleanup may have disposed the timer while this + // handler ran, and Start() on a disposed timer throws ObjectDisposedException, + // crashing the app from a thread-pool thread. (#12739) + if (!_isClosing) + { + _timer.Start(); + } } - - // Guard the restart: OnClosingCleanup may have disposed the timer while this - // handler ran, and Start() on a disposed timer throws ObjectDisposedException, - // crashing the app from a thread-pool thread. (#12739) - if (!_isClosing) + catch (Exception exception) { - _timer.Start(); + // An exception thrown from a timer callback is swallowed silently, and the timer + // was already stopped above - so without this catch the dialog hangs forever at + // 100% with no error shown (CompleteDownload throws on a bad credential response, + // and File.Move fails when model.bin is locked by a running whisper). The engine + // download dialog has carried this catch since #12127. + Se.LogError(exception, "Speech-to-text model download/unpack failed"); + ProgressText = Se.Language.General.DownloadFailed; + Error = exception.Message; } } } diff --git a/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextModelsWindow.cs b/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextModelsWindow.cs index 8f8f098f9b3..2da0cc4c8b3 100644 --- a/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextModelsWindow.cs +++ b/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextModelsWindow.cs @@ -84,6 +84,20 @@ public DownloadSpeechToTextModelsWindow(DownloadSpeechToTextModelsViewModel vm) statusText.Bind(TextBlock.TextProperty, new Binding(nameof(vm.ProgressText))); statusText.Bind(TextBlock.OpacityProperty, new Binding(nameof(vm.ProgressOpacity))); + // The view models set Error on every failure path, but nothing rendered it - the user saw + // only the generic "Download failed" while the real cause was written to a property no + // window bound. DownloadVideoFromUrlWindow and DownloadCrispEmbedWindow show it this way. + var errorText = new TextBlock + { + Foreground = new SolidColorBrush(Color.FromRgb(0xC0, 0x39, 0x2B)), + TextWrapping = TextWrapping.Wrap, + }; + errorText.Bind(TextBlock.TextProperty, new Binding(nameof(vm.Error))); + errorText.Bind(IsVisibleProperty, new Binding(nameof(vm.Error)) + { + Converter = new Avalonia.Data.Converters.FuncValueConverter(s => !string.IsNullOrEmpty(s)), + }); + var fileText = new TextBlock { Margin = new Thickness(0, 1, 0, 1), @@ -100,6 +114,7 @@ public DownloadSpeechToTextModelsWindow(DownloadSpeechToTextModelsViewModel vm) { progressBar, statusText, + errorText, fileText, } }; @@ -149,10 +164,10 @@ public DownloadSpeechToTextModelsWindow(DownloadSpeechToTextModelsViewModel vm) Content = grid; - Activated += delegate + UiUtil.FocusOnFirstActivation(this, () => { buttonCancel.Focus(); // hack to make OnKeyDown work - }; + }); KeyDown += (_, e) => vm.OnKeyDown(e); } } \ No newline at end of file diff --git a/src/ui/Features/Video/SpeechToText/EngineSettings/SpeechToTextEngineSettingsViewModel.cs b/src/ui/Features/Video/SpeechToText/EngineSettings/SpeechToTextEngineSettingsViewModel.cs index a731530b2c0..a2eec6f2496 100644 --- a/src/ui/Features/Video/SpeechToText/EngineSettings/SpeechToTextEngineSettingsViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/EngineSettings/SpeechToTextEngineSettingsViewModel.cs @@ -144,7 +144,11 @@ private void ApplyStatus(DownloadHashManager.UpdateStatus status, bool installed StatusBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0x98, 0x00)); // amber break; default: - StatusLabel = Se.Language.General.UnknownNoInstallRecord; + // Installed, but nothing identifies the build: an install predating the + // .installed.sha256 sidecar, a manual install, or a build older than the ones + // SE has hashes for. It still works, so say "Installed" rather than something + // that reads as a failed install (#14057). + StatusLabel = Se.Language.General.Installed; StatusBrush = new SolidColorBrush(Color.FromRgb(0x9E, 0x9E, 0x9E)); // grey break; } @@ -198,6 +202,7 @@ private static string DetectBackend(ISpeechToTextEngine engine, string folder) return winVariant switch { "cuda" => "Windows x64 (CUDA)", + "cuda13" => "Windows x64 (CUDA 13)", "vulkan" => "Windows x64 (Vulkan)", "cpu-legacy" => "Windows x64 (CPU, legacy)", "cpu" => "Windows x64 (CPU)", diff --git a/src/ui/Features/Video/SpeechToText/EngineSettings/SpeechToTextEngineSettingsWindow.cs b/src/ui/Features/Video/SpeechToText/EngineSettings/SpeechToTextEngineSettingsWindow.cs index d4d5a115f67..06d90b95443 100644 --- a/src/ui/Features/Video/SpeechToText/EngineSettings/SpeechToTextEngineSettingsWindow.cs +++ b/src/ui/Features/Video/SpeechToText/EngineSettings/SpeechToTextEngineSettingsWindow.cs @@ -32,7 +32,7 @@ public SpeechToTextEngineSettingsWindow(SpeechToTextEngineSettingsViewModel vm) Content = BuildContent(vm); var ok = UiUtil.MakeButtonOk(vm.OkCommand); - Activated += delegate { ok.Focus(); }; + UiUtil.FocusOnFirstActivation(this, ok); } private Border BuildContent(SpeechToTextEngineSettingsViewModel vm) diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrEngineBase.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrEngineBase.cs index 6955fd05a0c..8780484ea2c 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.29 release assets — + /// users off the CPU one. Figures are the v0.8.30 release assets — /// /// holds the pin; they drift with every release, so treat them as indicative. /// @@ -43,8 +43,8 @@ public virtual string DownloadSizeText } if (OperatingSystem.IsLinux()) { - // CPU ~37 MB (arm64 ~31 MB), Vulkan ~67 MB, ROCm ~99 MB, CUDA 13 ~202 MB, CUDA 12 ~271 MB. - return "~31 MB – 271 MB"; + // CPU ~37 MB (arm64 ~31 MB), Vulkan ~67 MB, ROCm ~98 MB, CUDA 13 ~115 MB, CUDA 12 ~149 MB. + return "~31 MB – 149 MB"; } if (OperatingSystem.IsMacOS()) { diff --git a/src/ui/Features/Video/SpeechToText/Engines/OpenRouterSttEngine.cs b/src/ui/Features/Video/SpeechToText/Engines/OpenRouterSttEngine.cs index 497340b8548..299381e9b13 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/OpenRouterSttEngine.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/OpenRouterSttEngine.cs @@ -12,9 +12,9 @@ namespace Nikse.SubtitleEdit.Features.Video.SpeechToText.Engines; /// /// Online speech-to-text via OpenRouter's audio transcription API. OpenRouter -/// routes to Whisper / GPT-4o-transcribe / Groq / Chirp behind one endpoint and -/// one API key, and returns the same verbose_json segment/word shape we already -/// parse. See for the request encoding. +/// routes to Whisper / GPT transcription / Groq / Chirp behind one endpoint and +/// one API key. Response formats are selected per model; see +/// for the request encoding. /// public class OpenRouterSttEngine : IOnlineSttEngine { diff --git a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCTranslate2.cs b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCTranslate2.cs index b3ae9aab125..25b8cad2806 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCTranslate2.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCTranslate2.cs @@ -69,7 +69,11 @@ public string GetExecutable() if (!File.Exists(fullPath) && OperatingSystem.IsLinux()) { - string[] paths = ["/usr/bin/whisper-cli", "usr/local/bin/"]; + // This engine's binary is whisper-ctranslate2. The list was copy-pasted from the + // whisper.cpp engines, so on Linux with a distro whisper.cpp installed the engine + // reported itself installed, no download was offered, and SE launched whisper-cli + // with faster-whisper arguments (--model_directory, --verbose True). + string[] paths = ["/usr/bin/whisper-ctranslate2", "/usr/local/bin/whisper-ctranslate2"]; foreach (var path in paths) { if (File.Exists(path)) diff --git a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCpp.cs b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCpp.cs index 110cfb51cf3..50e8c3ea303 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCpp.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCpp.cs @@ -82,7 +82,9 @@ public string GetExecutable() if (!File.Exists(fullPath) && OperatingSystem.IsLinux()) { - string[] paths = ["/usr/bin/whisper-cli", "usr/local/bin/"]; + // "usr/local/bin/" was relative and a directory, so File.Exists never matched it and + // a /usr/local/bin build was never found. + string[] paths = ["/usr/bin/whisper-cli", "/usr/local/bin/whisper-cli"]; foreach (var path in paths) { if (File.Exists(path)) diff --git a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCppCuBlas.cs b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCppCuBlas.cs index 201a33afe7f..72b8342b9e7 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCppCuBlas.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCppCuBlas.cs @@ -87,7 +87,9 @@ public string GetExecutable() if (!File.Exists(fullPath) && OperatingSystem.IsLinux()) { - string[] paths = ["/usr/bin/whisper-cli", "usr/local/bin/"]; + // "usr/local/bin/" was relative and a directory, so File.Exists never matched it and + // a /usr/local/bin build was never found. + string[] paths = ["/usr/bin/whisper-cli", "/usr/local/bin/whisper-cli"]; foreach (var path in paths) { if (File.Exists(path)) diff --git a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCppVulkan.cs b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCppVulkan.cs index 96c10cf68db..28b58d0f147 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCppVulkan.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCppVulkan.cs @@ -91,7 +91,9 @@ public string GetExecutable() if (!File.Exists(fullPath) && OperatingSystem.IsLinux()) { - string[] paths = ["/usr/bin/whisper-cli", "usr/local/bin/"]; + // "usr/local/bin/" was relative and a directory, so File.Exists never matched it and + // a /usr/local/bin build was never found. + string[] paths = ["/usr/bin/whisper-cli", "/usr/local/bin/whisper-cli"]; foreach (var path in paths) { if (File.Exists(path)) diff --git a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineFactory.cs b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineFactory.cs index bd341529d25..a040c39295f 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineFactory.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineFactory.cs @@ -42,6 +42,11 @@ public static ISpeechToTextEngine MakeEngineFromStaticName(string staticName) return new WhisperEnginePurfviewFasterWhisperXxl(); } + if (staticName == WhisperEngineWhisperX.StaticName) + { + return new WhisperEngineWhisperX(); + } + if (staticName == Qwen3AsrCppEngine.StaticName) { return new Qwen3AsrCppEngine(); diff --git a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineWhisperX.cs b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineWhisperX.cs new file mode 100644 index 00000000000..4cf98830ee1 --- /dev/null +++ b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineWhisperX.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Platform; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.UiLogic.AudioToText; + +namespace Nikse.SubtitleEdit.Features.Video.SpeechToText.Engines; + +/// +/// WhisperX, packaged as a standalone PyInstaller build (see +/// https://github.com/muaz978/subtitleedit-whisperx-standalone) so it downloads and runs like +/// every other engine here, with no separate Python install or managed virtual environment. +/// Requiring Python was the reason an earlier version of this engine (PR #14031) was not +/// accepted upstream. +/// +public class WhisperEngineWhisperX : ISpeechToTextEngine +{ + public static string StaticName => "WhisperX"; + public string Name => StaticName; + public string Choice => WhisperChoice.WhisperX; + public string Url => "https://github.com/m-bain/whisperX"; + + public List Languages => WhisperLanguage.Languages.OrderBy(p => p.Name).ToList(); + + // WhisperX runs on the same faster-whisper/ctranslate2 backend as WhisperEngineCTranslate2, + // so it takes the same model list (correct names like large-v3-turbo/distil-*, and the + // actual Hugging Face repos this backend downloads from) rather than whisper.cpp's ggml + // model list, which does not match what this backend understands. + // + // Minus the NbAiLab "*.nb" entries: their names only work for engines SE downloads models + // for (SE resolves the name to a local folder), while whisperx gets the bare name on the + // command line, and "tiny.nb" is neither a faster-whisper size nor a Hugging Face repo id - + // the run would fail after the model picker accepted it. + public List Models => new WhisperPurfviewFasterWhisperModel().Models + .Where(m => !m.Name.EndsWith(".nb", StringComparison.Ordinal)) + .ToList(); + + public string Extension => string.Empty; + public string UnpackSkipFolder => string.Empty; + + public bool IsEngineInstalled() + { + return File.Exists(GetExecutable()); + } + + public override string ToString() + { + return Name; + } + + public string GetAndCreateWhisperFolder() + { + var baseFolder = Se.SpeechToTextFolder; + if (!Directory.Exists(baseFolder)) + { + Directory.CreateDirectory(baseFolder); + } + + var folder = Path.Combine(baseFolder, "WhisperX"); + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + return folder; + } + + public string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) + { + // Shares Purfview's folder, matching WhisperEngineCTranslate2 - unused for actual model + // storage (WhisperX's models live in the Hugging Face cache, see IsModelInstalled), but + // this keeps the interface's expectation of "a real, existing folder" honest rather than + // pointing at whisper.cpp's unrelated ggml model folder. + return new WhisperEnginePurfviewFasterWhisperXxl().GetAndCreateWhisperModelFolder(whisperModel); + } + + // The archive is unpacked flat into GetAndCreateWhisperFolder(), so the executable and its + // "_internal" PyInstaller payload folder land directly there - same layout as CTranslate2. + public string GetExecutable() + { + return Path.Combine(GetAndCreateWhisperFolder(), GetExecutableFileName()); + } + + internal static string GetExecutableFileName() + { + return OperatingSystem.IsWindows() ? "whisperx-standalone.exe" : "whisperx-standalone"; + } + + public bool IsModelInstalled(WhisperModel model) + { + // WhisperX owns the Hugging Face cache and downloads the selected Whisper/alignment + // model itself on first use, showing its progress in the console log. Reporting + // "not installed" here made SE offer its own model download, which lands in Purfview's + // model folder - a folder whisperx never reads, since only the bare model name goes on + // the command line - so the user paid for gigabytes twice and the prompt came back on + // every run. Always "installed": the engine's own downloader is the only real one. + return true; + } + + public string GetModelForCmdLine(string modelName) => modelName; + + public async Task GetHelpText() + { + var uri = new Uri($"avares://SubtitleEdit/Assets/SpeechToText/{StaticName}.txt"); + await using var stream = AssetLoader.Open(uri); + using var reader = new StreamReader(stream); + return await reader.ReadToEndAsync(); + } + + public string GetWhisperModelDownloadFileName(WhisperModel whisperModel, string url) + => Path.Combine(GetAndCreateWhisperModelFolder(whisperModel), Path.GetFileName(url)); + + public bool CanBeDownloaded() => true; + + // Measured from the whisperx-standalone-101 support-files .7z assets (compressed download + // size); unpacks to roughly 950 MB (Windows), 1.1 GB (macOS), 1.7 GB (Linux) on disk, plus + // another ~2 GB of Whisper/alignment/diarization models that download from Hugging Face the + // first time each is used. + public string DownloadSizeText + { + get + { + if (OperatingSystem.IsWindows()) + { + return "~240 MB"; + } + + if (OperatingSystem.IsMacOS()) + { + return "~220 MB"; + } + + if (OperatingSystem.IsLinux()) + { + return "~355 MB"; + } + + return string.Empty; + } + } + + public string CommandLineParameter + { + get => Se.Settings.Tools.AudioToText.CommandLineParameterWhisperX; + set => Se.Settings.Tools.AudioToText.CommandLineParameterWhisperX = value; + } +} diff --git a/src/ui/Features/Video/SpeechToText/OpenAiCompatible/OpenAiSttService.cs b/src/ui/Features/Video/SpeechToText/OpenAiCompatible/OpenAiSttService.cs index ce55351b6e7..125558f6b0b 100644 --- a/src/ui/Features/Video/SpeechToText/OpenAiCompatible/OpenAiSttService.cs +++ b/src/ui/Features/Video/SpeechToText/OpenAiCompatible/OpenAiSttService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -265,17 +265,22 @@ private static async Task ParseSseStreamAsync( string eventType = ""; var dataBuilder = new StringBuilder(); - while ((line = await reader.ReadLineAsync()) != null) + // Pass the token: without it a server that opens the stream and then stalls blocks here + // forever, and the per-call timeout never fires. + while ((line = await reader.ReadLineAsync(cancellationToken)) != null) { cancellationToken.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(line)) { // Empty line = end of event, process it - if (dataBuilder.Length > 0 && !string.IsNullOrEmpty(eventType)) + if (dataBuilder.Length > 0) { var data = dataBuilder.ToString().Trim(); - ProcessSseEvent(eventType, data, segments, fullText, progress, segmentProgress, ref currentSegmentText, ref currentSegmentId, ref currentSegmentStart, ref currentSegmentEnd); + // Per the SSE spec an event with no "event:" line is type "message", and + // the transcription payloads carry their own "type" field - keying only + // off "event:" discarded every event from servers that omit it. + ProcessSseEvent(ResolveSseEventType(eventType, data), data, segments, fullText, progress, segmentProgress, ref currentSegmentText, ref currentSegmentId, ref currentSegmentStart, ref currentSegmentEnd); } eventType = ""; dataBuilder.Clear(); @@ -293,10 +298,10 @@ private static async Task ParseSseStreamAsync( } // Process any remaining event - if (dataBuilder.Length > 0 && !string.IsNullOrEmpty(eventType)) + if (dataBuilder.Length > 0) { var data = dataBuilder.ToString().Trim(); - ProcessSseEvent(eventType, data, segments, fullText, progress, segmentProgress, ref currentSegmentText, ref currentSegmentId, ref currentSegmentStart, ref currentSegmentEnd); + ProcessSseEvent(ResolveSseEventType(eventType, data), data, segments, fullText, progress, segmentProgress, ref currentSegmentText, ref currentSegmentId, ref currentSegmentStart, ref currentSegmentEnd); } return new OpenAiCompatibleSttResponse @@ -308,6 +313,36 @@ private static async Task ParseSseStreamAsync( }; } + /// + /// The event type to dispatch on: the SSE "event:" field when the server sends one, + /// otherwise the "type" the JSON payload carries itself (OpenAI's transcription stream + /// sends bare "data: {"type":"transcript.text.delta",...}" lines). + /// + private static string ResolveSseEventType(string eventType, string data) + { + if (!string.IsNullOrEmpty(eventType)) + { + return eventType; + } + + try + { + using var doc = JsonDocument.Parse(data); + if (doc.RootElement.ValueKind == JsonValueKind.Object && + doc.RootElement.TryGetProperty("type", out var typeElement) && + typeElement.ValueKind == JsonValueKind.String) + { + return typeElement.GetString() ?? string.Empty; + } + } + catch (JsonException) + { + // Not JSON (e.g. the "[DONE]" sentinel) - nothing to dispatch on. + } + + return string.Empty; + } + private static void ProcessSseEvent( string eventType, string data, @@ -341,6 +376,16 @@ private static void ProcessSseEvent( else if (eventType == "transcript.text.done") { var doneObj = JsonSerializer.Deserialize(data, options); + + // The done event carries the complete transcript. Servers that stream nothing but + // this final event (and any run whose deltas were dropped as malformed) left + // fullText empty, so the whole transcription came back blank. + if (fullText.Length == 0 && !string.IsNullOrEmpty(doneObj?.Text)) + { + fullText.Append(doneObj.Text); + progress?.Report(doneObj.Text); + } + if (doneObj?.Segments != null && doneObj.Segments.Count > 0) { if (segments.Count == 0) @@ -352,22 +397,11 @@ private static void ProcessSseEvent( } } } - else if (segments.Count == 0) - { - // No segments from streaming or from done.segments array - if (!string.IsNullOrEmpty(doneObj?.Text) && fullText.Length > 0) - { - var newSeg = new OpenAiCompatibleSegment - { - Id = 0, - Start = 0, - End = 0, - Text = fullText.ToString().Trim() - }; - segments.Add(newSeg); - segmentProgress?.Report(newSeg); - } - } + // No segments from streaming or from the done.segments array: leave the + // text to be returned as Text with no segments, so the caller's + // sentence-spreading fallback gives it real time codes. Synthesising a + // 0/0 segment here instead produced one cue holding the whole transcript + // at 00:00:00,000 --> 00:00:00,000 and suppressed that fallback. } else if (eventType == "transcript.segment" || eventType == "transcript.text.segment") { @@ -417,19 +451,13 @@ private static OpenAiCompatibleSttResponse ParseJsonResponse(string jsonResponse // If verbose_json fails, try simple text response } - // Fallback: treat as plain text response + // Fallback: treat as plain text response. No synthetic segment - the caller takes the + // segments branch whenever Segments.Count > 0, so a 0/0 entry put the whole transcript in + // one cue at 00:00:00,000 --> 00:00:00,000 and suppressed the sentence-spreading + // fallback. Same fix as the streaming path above; OpenRouterSttService does it this way. return new OpenAiCompatibleSttResponse { Text = jsonResponse.Trim(), - Segments = new List - { - new OpenAiCompatibleSegment - { - Start = 0, - End = 0, - Text = jsonResponse.Trim() - } - } }; } diff --git a/src/ui/Features/Video/SpeechToText/OpenAiCompatible/TranscriptionProgressViewModel.cs b/src/ui/Features/Video/SpeechToText/OpenAiCompatible/TranscriptionProgressViewModel.cs index 12c7fc1a0f6..73e2742e4ba 100644 --- a/src/ui/Features/Video/SpeechToText/OpenAiCompatible/TranscriptionProgressViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/OpenAiCompatible/TranscriptionProgressViewModel.cs @@ -5,6 +5,7 @@ using Nikse.SubtitleEdit.Logic.Config; using System; using System.Collections.ObjectModel; +using System.Globalization; using System.Text; using System.Threading; @@ -51,7 +52,7 @@ public void AddSegment(string segmentText, double start, double end) { Dispatcher.UIThread.Post(() => { - ReceivedSegments.Add($"[{TimeSpan.FromSeconds(start):mm\\:ss\\.fff} -> {TimeSpan.FromSeconds(end):mm\\:ss\\.fff}] {segmentText}"); + ReceivedSegments.Add($"[{FormatTimestamp(start)} -> {FormatTimestamp(end)}] {segmentText}"); SegmentCount = ReceivedSegments.Count; }); } @@ -65,6 +66,16 @@ public void SetCompleted() }); } + // "mm\\:ss\\.fff" drops whole hours - every segment past the first hour of a long file was + // shown at the same wrong minute. Only spend the hours field when there are hours. + private static string FormatTimestamp(double seconds) + { + var time = TimeSpan.FromSeconds(seconds); + return time.TotalHours >= 1 + ? time.ToString(@"h\:mm\:ss\.fff", CultureInfo.InvariantCulture) + : time.ToString(@"mm\:ss\.fff", CultureInfo.InvariantCulture); + } + [RelayCommand] private void Cancel() { diff --git a/src/ui/Features/Video/SpeechToText/OpenRouter/OpenRouterSttService.cs b/src/ui/Features/Video/SpeechToText/OpenRouter/OpenRouterSttService.cs index 721595fa9ae..53ba4537e50 100644 --- a/src/ui/Features/Video/SpeechToText/OpenRouter/OpenRouterSttService.cs +++ b/src/ui/Features/Video/SpeechToText/OpenRouter/OpenRouterSttService.cs @@ -17,12 +17,13 @@ namespace Nikse.SubtitleEdit.Features.Video.SpeechToText.OpenRouter; /// Speech-to-text via OpenRouter's audio transcription API. Unlike OpenAI's /// multipart /v1/audio/transcriptions, OpenRouter takes a JSON body with /// the audio base64-encoded under input_audio. The documented response is -/// text + usage only — response_format=verbose_json and -/// timestamp_granularities[] are sent opportunistically in case the -/// routed provider honors them and returns the segment/word shape we already -/// parse into . When only text -/// comes back, the caller's chunk pipeline spans each chunk's duration and -/// splits into sentences so timing survives. +/// text + usage only. Whisper-compatible models receive +/// response_format=verbose_json and timestamp_granularities[] when +/// supported. OpenAI's newer GPT transcription models only accept +/// response_format=json, so they receive the plain response format and +/// no timestamp hints. When only text comes back, the caller's chunk +/// pipeline spans each chunk's duration and splits into sentences so timing +/// survives. /// public class OpenRouterSttService : ISttTranscriber { @@ -127,8 +128,9 @@ private async Task TranscribeCoreAsync( /// /// Serialize the OpenRouter transcription request body. The audio is /// base64-encoded (raw, not a data URI) under input_audio, and - /// verbose_json + timestamp_granularities[] ask for segment and - /// word timings when the underlying model supports them. + /// The newer GPT transcription models only accept json, while + /// Whisper-compatible models can return verbose_json with segment and + /// word timings. /// internal static string BuildRequestBody(OpenRouterSttSettings settings, byte[] audioBytes, string format, string? language) { @@ -144,13 +146,20 @@ internal static string BuildRequestBody(OpenRouterSttSettings settings, byte[] a writer.WriteString("format", string.IsNullOrWhiteSpace(format) ? "mp3" : format); writer.WriteEndObject(); - writer.WriteString("response_format", "verbose_json"); + if (IsJsonOnlyModel(settings.Model)) + { + writer.WriteString("response_format", "json"); + } + else + { + writer.WriteString("response_format", "verbose_json"); - writer.WritePropertyName("timestamp_granularities"); - writer.WriteStartArray(); - writer.WriteStringValue("segment"); - writer.WriteStringValue("word"); - writer.WriteEndArray(); + writer.WritePropertyName("timestamp_granularities"); + writer.WriteStartArray(); + writer.WriteStringValue("segment"); + writer.WriteStringValue("word"); + writer.WriteEndArray(); + } var languageToUse = language ?? settings.Language; if (!string.IsNullOrWhiteSpace(languageToUse)) @@ -174,6 +183,24 @@ internal static string BuildRequestBody(OpenRouterSttSettings settings, byte[] a return Encoding.UTF8.GetString(stream.ToArray()); } + /// + /// OpenAI's GPT transcription models (e.g. gpt-4o-transcribe, + /// gpt-4o-mini-transcribe) reject verbose_json, unlike Whisper. + /// Matched by name shape rather than an exact list so future gpt-*-transcribe + /// models are covered without a code change. + /// + internal static bool IsJsonOnlyModel(string? model) + { + if (string.IsNullOrWhiteSpace(model)) + { + return false; + } + + var name = model[(model.LastIndexOf('/') + 1)..]; + return name.StartsWith("gpt-", StringComparison.OrdinalIgnoreCase) && + name.Contains("transcribe", StringComparison.OrdinalIgnoreCase); + } + /// /// Parse an OpenRouter transcription response into the shared shape. Reuses /// the OpenAI verbose_json model; if only word timings come back, they are diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextAdvancedViewModel.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextAdvancedViewModel.cs index e8d8a4b5967..6407c35138d 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextAdvancedViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextAdvancedViewModel.cs @@ -1,4 +1,4 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Input; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -21,6 +21,12 @@ public partial class SpeechToTextAdvancedViewModel : ObservableObject [ObservableProperty] private bool _isWhisperXxlVisible; [ObservableProperty] private bool _isWhisperCTranslate2Visible; [ObservableProperty] private bool _isCrispAsrVisible; + [ObservableProperty] private bool _isWordLevelCppActive; + [ObservableProperty] private bool _isVadCppActive; + [ObservableProperty] private bool _isVadCTranslate2Active; + [ObservableProperty] private bool _isHighlightWordsCTranslate2Active; + [ObservableProperty] private bool _isVadCrispAsrActive; + [ObservableProperty] private bool _isHighlightWordsCrispAsrActive; public Window? Window { get; set; } public List Engines { get; set; } @@ -57,21 +63,125 @@ private async Task EngineClicked(ISpeechToTextEngine engine) [RelayCommand] private void EnableVadCpp() { + if (IsVadCppActive) + { + Parameters = RemoveParameters(VadRegex); + return; + } + var fileName = GetVadCppFile(); if (string.IsNullOrEmpty(fileName)) { + // No Silero model to point at, so the box is left alone - but the button has + // already drawn itself pressed. Announce the unchanged "off" so the one-way + // binding pushes the button back out. + OnPropertyChanged(nameof(IsVadCppActive)); return; } - Parameters = $"--vad --vad-model \"{fileName}\""; + Parameters = AddParameters(VadRegex, $"--vad --vad-model \"{fileName}\""); + } + + // Both are switches, not key/value pairs - whisper-cli parses a following "true" as an + // input file name and then bails out with "input file not found 'true'". + private const string WordLevelCppParameters = "-owts -ojf"; + + // What comes off again when the button is pressed off: the pair it puts on. + private static readonly Regex WordLevelCppRegex = new( + @"(?:^|\s)(-owts|--output-words|-ojf|--output-json-full)(?=\s|$)", + RegexOptions.Compiled); + + // What counts as "on": the karaoke word output alone. -ojf only asks for a fuller JSON + // file and says nothing about word level, so it must not light the button by itself. + private static readonly Regex WordLevelCppDetectRegex = new( + @"(?:^|\s)(-owts|--output-words)(?=\s|$)", + RegexOptions.Compiled); + + private static readonly Regex VadRegex = new( + @"(?:^|\s)(--vad-model\s+""[^""]+""|--vad-model\s+\S+|-vm\s+""[^""]+""|-vm\s+\S+|--vad)(?=\s|$)", + RegexOptions.Compiled); + + // A model path on its own does nothing - only the --vad switch itself turns VAD on. + private static readonly Regex VadDetectRegex = new( + @"(?:^|\s)--vad(?=\s|$)", + RegexOptions.Compiled); + + private static readonly Regex VadCTranslate2Regex = new( + @"(?:^|\s)--vad_filter\s+\S+(?=\s|$)", + RegexOptions.Compiled); + + // "--vad_filter False" is the filter switched off, not on. + private static readonly Regex VadCTranslate2DetectRegex = new( + @"(?:^|\s)--vad_filter\s+(?i:true|1)(?=\s|$)", + RegexOptions.Compiled); + + private static readonly Regex HighlightWordsCTranslate2Regex = new( + @"(?:^|\s)(--highlight_words\s+\S+|--word_timestamps\s+\S+)(?=\s|$)", + RegexOptions.Compiled); + + // Word timestamps on their own are just timings - only asking for the words to be + // highlighted, and asking for it in earnest, means the button is on. + private static readonly Regex HighlightWordsCTranslate2DetectRegex = new( + @"(?:^|\s)--highlight_words\s+(?i:true|1)(?=\s|$)", + RegexOptions.Compiled); + + private static readonly Regex HighlightWordsCrispAsrRegex = new( + @"(?:^|\s)(-ml\s+\S+|--max-len\s+\S+|-sow|--split-on-word)(?=\s|$)", + RegexOptions.Compiled); + + // "Highlight word" is the two together: one word per segment, split at word boundaries. + // A maximum length on its own is not that - the "Standard" preset sets one - so matching + // any --max-len left the button lit after "Standard", and the press that should have + // switched highlighting on took the preset's --max-len back out instead. + private static readonly Regex MaxLenOneCrispAsrRegex = new( + @"(?:^|\s)(-ml|--max-len)\s+1(?=\s|$)", + RegexOptions.Compiled); + + private static readonly Regex SplitOnWordCrispAsrRegex = new( + @"(?:^|\s)(-sow|--split-on-word)(?=\s|$)", + RegexOptions.Compiled); + + private static readonly Regex HighlightWordsCrispAsrConflictRegex = new( + @"(?:^|\s)(-ml\s+\S+|--max-len\s+\S+|-sow|--split-on-word|--split-on-punct|-sp)(?=\s|$)", + RegexOptions.Compiled); + + private string RemoveParameters(Regex regex) + { + return regex.Replace(Parameters ?? string.Empty, string.Empty).Trim(); + } + + private string AddParameters(Regex regex, string parameters) + { + return AddParameters(regex, parameters, Parameters ?? string.Empty); + } + + private static string AddParameters(Regex regex, string parameters, string current) + { + var existing = regex.Replace(current, string.Empty).Trim(); + return string.IsNullOrWhiteSpace(existing) ? parameters : existing + " " + parameters; + } + + // Every toggle reads its state back out of the parameter text, so typing in the box by + // hand or switching engine leaves the buttons showing what is actually set. + partial void OnParametersChanged(string value) + { + var text = value ?? string.Empty; + IsWordLevelCppActive = WordLevelCppDetectRegex.IsMatch(text); + IsVadCppActive = VadDetectRegex.IsMatch(text); + IsVadCrispAsrActive = IsVadCppActive; + IsVadCTranslate2Active = VadCTranslate2DetectRegex.IsMatch(text); + IsHighlightWordsCTranslate2Active = HighlightWordsCTranslate2DetectRegex.IsMatch(text); + IsHighlightWordsCrispAsrActive = MaxLenOneCrispAsrRegex.IsMatch(text) && SplitOnWordCrispAsrRegex.IsMatch(text); } [RelayCommand] private void EnableWordLevelCpp() { - // Both are switches, not key/value pairs - whisper-cli parses a following "true" as an - // input file name and then bails out with "input file not found 'true'". - Parameters = "-owts -ojf"; + // A press used to overwrite the parameter box and there was no way to press it back + // off again, so word-level output stayed on for every later transcription. + Parameters = IsWordLevelCppActive + ? RemoveParameters(WordLevelCppRegex) + : AddParameters(WordLevelCppRegex, WordLevelCppParameters); } [RelayCommand] @@ -107,35 +217,58 @@ private void WhisperXxlSettingHighLightWord() [RelayCommand] private void WhisperCTranslate2HighLightWord() { - Parameters = "--vad_filter True --highlight_words True --word_timestamps True"; + if (IsHighlightWordsCTranslate2Active) + { + Parameters = RemoveParameters(HighlightWordsCTranslate2Regex); + return; + } + + // This button has always switched the VAD filter on together with highlighting. + var withVad = AddParameters(VadCTranslate2Regex, "--vad_filter True"); + + Parameters = AddParameters( + HighlightWordsCTranslate2Regex, + "--highlight_words True --word_timestamps True", + withVad); } [RelayCommand] private void EnableVadCTranslate2() { - Parameters = "--vad_filter True"; + Parameters = IsVadCTranslate2Active + ? RemoveParameters(VadCTranslate2Regex) + : AddParameters(VadCTranslate2Regex, "--vad_filter True"); } [RelayCommand] private void EnableVadCrispAsr() { + if (IsVadCrispAsrActive) + { + Parameters = RemoveParameters(VadRegex); + return; + } + var fileName = GetVadCrispAsrFile(); var vadArgs = string.IsNullOrEmpty(fileName) ? "--vad" : $"--vad --vad-model \"{fileName}\""; - var existing = Regex.Replace(Parameters ?? string.Empty, @"(?:^|\s)(--vad-model\s+""[^""]+""|--vad-model\s+\S+|--vad)(?=\s|$)", string.Empty).Trim(); - Parameters = string.IsNullOrWhiteSpace(existing) ? vadArgs : existing + " " + vadArgs; + Parameters = AddParameters(VadRegex, vadArgs); } [RelayCommand] private void EnableHighlightWordsCrispAsr() { - const string highlightArgs = "-ml 1 -sow"; - var existing = Regex.Replace(Parameters ?? string.Empty, - @"(?:^|\s)(-ml\s+\S+|--max-len\s+\S+|-sow|--split-on-word|--split-on-punct|-sp)(?=\s|$)", - string.Empty).Trim(); - Parameters = string.IsNullOrWhiteSpace(existing) ? highlightArgs : existing + " " + highlightArgs; + if (IsHighlightWordsCrispAsrActive) + { + Parameters = RemoveParameters(HighlightWordsCrispAsrRegex); + return; + } + + // The length/split switches this turns on conflict with the "standard" ones, so those + // are dropped as it goes on - but only its own switches come off again. + Parameters = AddParameters(HighlightWordsCrispAsrConflictRegex, "-ml 1 -sow"); } [RelayCommand] @@ -162,13 +295,23 @@ private void StandardCrispAsr() return File.Exists(fallback) ? fallback : null; } - private static string? GetVadCppFile() + private string? GetVadCppFile() { - var searchPaths = new List + // Look in the SELECTED backend's own folder first. The Silero model is unpacked into + // GetAndCreateWhisperFolder(), which is "CppCuBlas" or "CppVulkan" for those two + // backends - so hard-coding "Cpp" meant the lookup returned null, EnableVadCpp took its + // bail-out branch, and the toggle popped back out with no message. GetVadCrispAsrFile + // below already resolves its engine's folder. + var searchPaths = new List(); + if (SelectedEngine is WhisperCppEngine whisperCppEngine) { - Path.Combine(Se.SpeechToTextFolder, "Cpp", "Models"), - Path.Combine(Se.SpeechToTextFolder, "Cpp"), - }; + var engineFolder = whisperCppEngine.GetAndCreateWhisperFolder(); + searchPaths.Add(Path.Combine(engineFolder, "Models")); + searchPaths.Add(engineFolder); + } + + searchPaths.Add(Path.Combine(Se.SpeechToTextFolder, "Cpp", "Models")); + searchPaths.Add(Path.Combine(Se.SpeechToTextFolder, "Cpp")); foreach (var searchPath in searchPaths) { diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextAdvancedWindow.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextAdvancedWindow.cs index 4ef7c85fe11..128cea80010 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextAdvancedWindow.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextAdvancedWindow.cs @@ -1,9 +1,12 @@ -using Avalonia; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; +using CommunityToolkit.Mvvm.Input; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; @@ -27,7 +30,10 @@ public SpeechToTextAdvancedWindow(SpeechToText.SpeechToTextAdvancedViewModel vm) var textBoxParameters = new TextBox { AcceptsReturn = true, - AcceptsTab = true, + // A tab is never meaningful inside a whisper command line, and accepting it trapped + // the keyboard in this box: Tab and Shift+Tab both typed a tab character instead of + // moving to the next control, with no way out but the mouse (#14313). + AcceptsTab = false, // Long single-line parameter strings used to trigger a horizontal // scrollbar that overlapped the text inside this otherwise-tiny // multi-line textbox (#11181). Reserve enough vertical room so the @@ -80,6 +86,23 @@ public SpeechToTextAdvancedWindow(SpeechToText.SpeechToTextAdvancedViewModel vm) VerticalAlignment = VerticalAlignment.Stretch, }; + // The help text is a read-only TextBox, which moves its caret for the arrows and + // Home/End - and that drags the ScrollViewer along - but does nothing for PageUp and + // PageDown, leaving them dead in the one pane long enough to need them (#14313). + textBoxHelp.AddHandler(KeyDownEvent, (_, e) => + { + if (e.Key == Key.PageUp) + { + scrollViewer.PageUp(); + e.Handled = true; + } + else if (e.Key == Key.PageDown) + { + scrollViewer.PageDown(); + e.Handled = true; + } + }, RoutingStrategies.Tunnel); + var buttonXxlOptions = new SplitButton { Content = Se.Language.Video.AudioToText.WhisperXxlStandard, @@ -112,22 +135,49 @@ public SpeechToTextAdvancedWindow(SpeechToText.SpeechToTextAdvancedViewModel vm) } }.WithBindIsVisible(nameof(vm.IsWhisperXxlVisible)); + // These switch parameters on. As plain buttons there was no way to press one back off + // again, so anything they set - word-level output above all - stayed on for good. + static ToggleButton MakeToggleButton(string text, IRelayCommand command, string activePath, string visiblePath) + { + var toggleButton = new ToggleButton + { + Content = text, + Margin = new Thickness(4, 0), + Padding = new Thickness(12, 6), + MinWidth = 80, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + Command = command, + }.WithBindIsVisible(visiblePath); + + // One way only: the parameter text is what says whether the switch is set, the + // button just shows it. + toggleButton.Bind(ToggleButton.IsCheckedProperty, new Binding(activePath) + { + Mode = BindingMode.OneWay, + }); + + return toggleButton; + } + var buttonPanel = UiUtil.MakeButtonBar( buttonXxlOptions, - UiUtil.MakeButton(Se.Language.Video.AudioToText.EnableVad, vm.EnableVadCppCommand) - .WithBindIsVisible(nameof(vm.IsWhisperCppVisible)), - UiUtil.MakeButton(Se.Language.Video.AudioToText.EnableVad, vm.EnableVadCTranslate2Command) - .WithBindIsVisible(nameof(vm.IsWhisperCTranslate2Visible)), + MakeToggleButton(Se.Language.Video.AudioToText.EnableVad, vm.EnableVadCppCommand, + nameof(vm.IsVadCppActive), nameof(vm.IsWhisperCppVisible)), + MakeToggleButton(Se.Language.Video.AudioToText.EnableVad, vm.EnableVadCTranslate2Command, + nameof(vm.IsVadCTranslate2Active), nameof(vm.IsWhisperCTranslate2Visible)), UiUtil.MakeButton(Se.Language.Video.AudioToText.WhisperXxlStandard, vm.StandardCrispAsrCommand) .WithBindIsVisible(nameof(vm.IsCrispAsrVisible)), - UiUtil.MakeButton(Se.Language.Video.AudioToText.EnableVad, vm.EnableVadCrispAsrCommand) - .WithBindIsVisible(nameof(vm.IsCrispAsrVisible)), - UiUtil.MakeButton(Se.Language.Video.AudioToText.WhisperXxlHighlightWord, vm.EnableHighlightWordsCrispAsrCommand) - .WithBindIsVisible(nameof(vm.IsCrispAsrVisible)), - UiUtil.MakeButton(Se.Language.Video.AudioToText.WhisperXxlHighlightWord, vm.EnableWordLevelCppCommand) - .WithBindIsVisible(nameof(vm.IsWhisperCppVisible)), - UiUtil.MakeButton(Se.Language.Video.AudioToText.WhisperXxlHighlightWord, vm.WhisperCTranslate2HighLightWordCommand) - .WithBindIsVisible(nameof(vm.IsWhisperCTranslate2Visible)), + MakeToggleButton(Se.Language.Video.AudioToText.EnableVad, vm.EnableVadCrispAsrCommand, + nameof(vm.IsVadCrispAsrActive), nameof(vm.IsCrispAsrVisible)), + MakeToggleButton(Se.Language.Video.AudioToText.WhisperXxlHighlightWord, vm.EnableHighlightWordsCrispAsrCommand, + nameof(vm.IsHighlightWordsCrispAsrActive), nameof(vm.IsCrispAsrVisible)), + MakeToggleButton(Se.Language.Video.AudioToText.WhisperXxlHighlightWord, vm.EnableWordLevelCppCommand, + nameof(vm.IsWordLevelCppActive), nameof(vm.IsWhisperCppVisible)), + MakeToggleButton(Se.Language.Video.AudioToText.WhisperXxlHighlightWord, vm.WhisperCTranslate2HighLightWordCommand, + nameof(vm.IsHighlightWordsCTranslate2Active), nameof(vm.IsWhisperCTranslate2Visible)), UiUtil.MakeButton(Se.Language.General.Ok, vm.OkCommand), UiUtil.MakeButton(Se.Language.General.Cancel, vm.CancelCommand) ); @@ -184,7 +234,9 @@ public SpeechToTextAdvancedWindow(SpeechToText.SpeechToTextAdvancedViewModel vm) Content = grid; - Activated += delegate { textBoxParameters.Focus(); }; // hack to make OnKeyDown work + // Focus something so the window-level KeyDown below is reachable - but only on the first + // activation, or Alt+Tabbing back would throw focus away from wherever the user left it. + UiUtil.FocusOnFirstActivation(this, textBoxParameters); KeyDown += (s, e) => vm.OnKeyDown(e); } } diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessingViewModel.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessingViewModel.cs index 7c704d31121..5ec7c0af8f0 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessingViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessingViewModel.cs @@ -1,4 +1,4 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Input; using Avalonia.Media; using CommunityToolkit.Mvvm.ComponentModel; @@ -15,6 +15,9 @@ public partial class SpeechToTextPostProcessingViewModel : ObservableObject [ObservableProperty] private bool _fixShortDuration; [ObservableProperty] private bool _fixCasing; [ObservableProperty] private bool _addPeriods; + [ObservableProperty] private bool _removeNonSpeechLines; + [ObservableProperty] private bool _removeRepeatedLines; + [ObservableProperty] private bool _showQualityReport; [ObservableProperty] private bool _changeUnderlineToColor; [ObservableProperty] private Color _changeUnderlineToColorColor; diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessingWindow.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessingWindow.cs index 7d02629edeb..2b4cb56045b 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessingWindow.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessingWindow.cs @@ -1,4 +1,4 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Data; using Avalonia.Input; @@ -42,6 +42,17 @@ public SpeechToTextPostProcessingWindow(SpeechToTextPostProcessingViewModel vm) var labelAddPeriods = UiUtil.MakeTextBlock(Se.Language.Video.AudioToText.AddPeriods); var checkAddPeriods = UiUtil.MakeCheckBox(vm, nameof(SpeechToTextPostProcessingViewModel.AddPeriods)); + var labelRemoveNonSpeechLines = UiUtil.MakeTextBlock(Se.Language.Video.AudioToText.RemoveNonSpeechLines); + var checkRemoveNonSpeechLines = UiUtil.MakeCheckBox(vm, nameof(SpeechToTextPostProcessingViewModel.RemoveNonSpeechLines)); + SetHint(labelRemoveNonSpeechLines, checkRemoveNonSpeechLines, Se.Language.Video.AudioToText.RemoveNonSpeechLinesHint); + + var labelRemoveRepeatedLines = UiUtil.MakeTextBlock(Se.Language.Video.AudioToText.RemoveRepeatedLines); + var checkRemoveRepeatedLines = UiUtil.MakeCheckBox(vm, nameof(SpeechToTextPostProcessingViewModel.RemoveRepeatedLines)); + SetHint(labelRemoveRepeatedLines, checkRemoveRepeatedLines, Se.Language.Video.AudioToText.RemoveRepeatedLinesHint); + + var labelShowQualityReport = UiUtil.MakeTextBlock(Se.Language.Video.AudioToText.ShowQualityReport); + var checkShowQualityReport = UiUtil.MakeCheckBox(vm, nameof(SpeechToTextPostProcessingViewModel.ShowQualityReport)); + var labelChangeUnderlineToColor = UiUtil.MakeTextBlock(Se.Language.Video.AudioToText.ChangeUnderlineToColor); var checkChangeUnderlineToColor = UiUtil.MakeCheckBox(vm, nameof(SpeechToTextPostProcessingViewModel.ChangeUnderlineToColor)); var colorPickerUnderlineToColor = UiUtil.MakeColorPickerButton(_vm, nameof(_vm.ChangeUnderlineToColorColor)); @@ -62,6 +73,9 @@ public SpeechToTextPostProcessingWindow(SpeechToTextPostProcessingViewModel vm) new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, }, ColumnDefinitions = @@ -103,6 +117,18 @@ public SpeechToTextPostProcessingWindow(SpeechToTextPostProcessingViewModel vm) grid.Add(checkAddPeriods, row, 1); row++; + grid.Add(labelRemoveNonSpeechLines, row, 0); + grid.Add(checkRemoveNonSpeechLines, row, 1); + row++; + + grid.Add(labelRemoveRepeatedLines, row, 0); + grid.Add(checkRemoveRepeatedLines, row, 1); + row++; + + grid.Add(labelShowQualityReport, row, 0); + grid.Add(checkShowQualityReport, row, 1); + row++; + grid.Add(labelChangeUnderlineToColor, row, 0); grid.Add(checkChangeUnderlineToColor, row, 1); grid.Add(colorPickerUnderlineToColor, row, 2); @@ -135,7 +161,18 @@ public SpeechToTextPostProcessingWindow(SpeechToTextPostProcessingViewModel vm) Content = outerGrid; - Activated += delegate { Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, () => { Focus(); }); // hack to make OnKeyDown work + } + + private static void SetHint(Control label, Control checkBox, string hint) + { + if (!Se.Settings.Appearance.ShowHints) + { + return; + } + + ToolTip.SetTip(label, hint); + ToolTip.SetTip(checkBox, hint); } protected override void OnKeyDown(KeyEventArgs e) diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessor.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessor.cs index 3a8a49f583c..31e83d582fb 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessor.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessor.cs @@ -29,12 +29,39 @@ public enum Engine public int ParagraphMaxChars { get; set; } + /// + /// The command line the engine actually ran with, used to detect word-level output + /// (--highlight_words / --one_word) that merging and splitting would destroy. + /// SE 5 stores these per engine (engine.CommandLineParameter) - the old global + /// setting this used to read is never written, so the guard never fired. + /// + public string EngineCommandLineArguments { get; set; } = string.Empty; + private const int AudioToTextLineMaxChars = 86; private const int AudioToTextLineMaxCharsJp = 32; private const int AudioToTextLineMaxCharsCn = 36; public string TwoLetterLanguageCode { get; } + /// + /// Drop lines that are only a sound/music description ("[Music]", "(waves)"). + /// Whisper emits these on long stretches without speech (issue #13973). + /// + public bool RemoveNonSpeechLines { get; set; } + + /// + /// Drop lines whose text repeats the previous line - the classic whisper + /// hallucination loop (issue #13973). + /// + public bool RemoveRepeatedLines { get; set; } + + /// + /// What was found (and removed) during the last call. + /// Always populated, even when post-processing is off, so the user is told + /// when a transcription went badly instead of finding out later (issue #13973). + /// + public SpeechToTextQualityReport QualityReport { get; private set; } = new(); + public SpeechToTextPostProcessor(string twoLetterLanguageCode) { TwoLetterLanguageCode = twoLetterLanguageCode == "no" ? "nb" : twoLetterLanguageCode; @@ -77,10 +104,31 @@ public Subtitle Fix( Color changeUnderlineToColorColor) { var subtitle = new Subtitle(); + QualityReport = new SpeechToTextQualityReport(); + // Input line number (1-based) of the last paragraph added to the output, so the + // "repeated" detail can name the line actually duplicated - which is not index + // when non-speech or repeated lines were dropped in between. + var lastKeptNumber = 0; for (var index = 0; index < input.Paragraphs.Count; index++) { var paragraph = input.Paragraphs[index]; + + if (usePostProcessing && RemoveNonSpeechLines && SpeechToTextQualityReport.IsNonSpeechLine(paragraph.Text)) + { + QualityReport.Removed.Add(SpeechToTextQualityReport.MakeIssue(SpeechToTextQualityIssueType.NonSpeech, paragraph, index + 1, string.Empty)); + continue; + } + + // Compare against the last line we kept, so a run of five identical + // lines collapses to one rather than to every other line. + var lastKept = subtitle.GetParagraphOrDefault(subtitle.Paragraphs.Count - 1); + if (usePostProcessing && RemoveRepeatedLines && lastKept != null && SpeechToTextQualityReport.IsRepeatOf(paragraph.Text, lastKept.Text)) + { + QualityReport.Removed.Add(SpeechToTextQualityReport.MakeIssue(SpeechToTextQualityIssueType.Repeated, paragraph, index + 1, $"= #{lastKeptNumber}")); + continue; + } + if (usePostProcessing && engine == Engine.Vosk && TwoLetterLanguageCode == "en" && paragraph.Text == "the" && paragraph.EndTime.TotalSeconds - paragraph.StartTime.TotalSeconds > 1) { continue; @@ -119,6 +167,7 @@ public Subtitle Fix( } subtitle.Paragraphs.Add(paragraph); + lastKeptNumber = index + 1; } if (usePostProcessing && engine == Engine.Whisper && TwoLetterLanguageCode == "da") @@ -135,6 +184,9 @@ public Subtitle Fix( } } + var general = Configuration.Settings.General; + QualityReport.Analyze(postProcessed, general.SubtitleMinimumDisplayMilliseconds, general.SubtitleMaximumDisplayMilliseconds, general.SubtitleMaximumCharactersPerSeconds); + return postProcessed; } @@ -160,6 +212,11 @@ public Subtitle Fix(Subtitle subtitle, bool usePostProcessing, bool addPeriods, if (fixShortDuration) { subtitle = FixShortDuration(subtitle); + + // Engines hand back overlapping segments, and extending short + // lines can create more - straighten them out so the result + // does not arrive in the grid full of red overlaps (issue #13973). + subtitle = FixOverlaps(subtitle); } if (splitLines && !IsNonStandardLineTerminationLanguage(TwoLetterLanguageCode) && AllowLineContentMove(engine)) @@ -181,19 +238,19 @@ public Subtitle Fix(Subtitle subtitle, bool usePostProcessing, bool addPeriods, return subtitle; } - private static bool AllowLineContentMove(Engine engine) + private bool AllowLineContentMove(Engine engine) { if (engine != Engine.Whisper) { return true; } - if (string.IsNullOrEmpty(Se.Settings.Tools.AudioToText.WhisperCustomCommandLineArguments)) + if (string.IsNullOrEmpty(EngineCommandLineArguments)) { return true; } - var es = Se.Settings.Tools.AudioToText.WhisperCustomCommandLineArguments.ToLowerInvariant(); + var es = EngineCommandLineArguments.ToLowerInvariant(); return !es.Contains("--highlight_words", StringComparison.OrdinalIgnoreCase) && !es.Contains("-hw ", StringComparison.OrdinalIgnoreCase) && !es.Contains("-hw=true", StringComparison.OrdinalIgnoreCase) && @@ -581,5 +638,12 @@ private static Subtitle FixShortDuration(Subtitle inputSubtitle) new FixShortDisplayTimes().Fix(subtitle, new EmptyFixCallback()); return subtitle; } + + private static Subtitle FixOverlaps(Subtitle inputSubtitle) + { + var subtitle = new Subtitle(inputSubtitle); + new FixOverlappingDisplayTimes().Fix(subtitle, new EmptyFixCallback()); + return subtitle; + } } } diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReport.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReport.cs new file mode 100644 index 00000000000..815c22fa0aa --- /dev/null +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReport.cs @@ -0,0 +1,211 @@ +using Nikse.SubtitleEdit.Core.Common; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Nikse.SubtitleEdit.Features.Video.SpeechToText; + +public enum SpeechToTextQualityIssueType +{ + /// Display time below the minimum, or reading speed above the maximum. + TooShort, + + /// Display time above the maximum, or a long span with very little text (typical hallucination). + TooLong, + + /// End time is later than the next line's start time. + Overlap, + + /// Text is only a sound/music description such as "[Music]" or "(waves crashing)". + NonSpeech, + + /// Same text as the previous line (engine loop). + Repeated, +} + +public class SpeechToTextQualityIssue +{ + public SpeechToTextQualityIssueType Type { get; init; } + public int Number { get; init; } + public TimeCode StartTime { get; init; } = new TimeCode(); + public TimeCode EndTime { get; init; } = new TimeCode(); + public string Text { get; init; } = string.Empty; + + /// Short machine-independent detail, e.g. "0.2 s" or "31.5 cps". + public string Detail { get; init; } = string.Empty; +} + +/// +/// Result of checking a speech-to-text transcript for the defect classes users keep +/// running into (issue #13973): lines too short to read, suspiciously long lines, +/// overlaps, non-speech descriptions and repeated (looping) lines. Lines the +/// post-processor removed are listed with set so the report +/// can tell "fixed" from "still there". +/// +public class SpeechToTextQualityReport +{ + public List Issues { get; } = new(); + + /// Lines that were dropped by post-processing (non-speech / repeats), for the report. + public List Removed { get; } = new(); + + public int TotalLines { get; set; } + + public bool HasIssues => Issues.Count > 0 || Removed.Count > 0; + + public int Count(SpeechToTextQualityIssueType type) + { + return Issues.Count(p => p.Type == type); + } + + public int RemovedCount(SpeechToTextQualityIssueType type) + { + return Removed.Count(p => p.Type == type); + } + + /// + /// Long line with very little text — below this many characters per second over + /// or more is reported as "too long" even when + /// the duration is within the configured maximum. Whisper hallucinations on + /// silence typically look like 2-3 words stretched over 10-30 seconds. + /// + public const double SparseCharsPerSecond = 2.0; + public const int SparseMinDurationMs = 4000; + + // "[Music]", "(laughs)", "*applause*", "♪ ♪", "[Música] (risas)" - the whole + // line must be made of bracketed/parenthesised groups, music notes or + // whitespace. A bracketed speaker tag followed by real speech is kept. + private static readonly Regex NonSpeechRegex = new(@"^\s*(?:(?:\[[^\]]*\]|\([^)]*\)|\*[^*]*\*|<[^>]*>|[♪♫♬¶]+)[\s.,!?\-–—…]*)+$", RegexOptions.Compiled); + + public static bool IsNonSpeechLine(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + var noHtml = HtmlUtil.RemoveHtmlTags(text, true).Trim(); + if (noHtml.Length == 0) + { + return false; + } + + return NonSpeechRegex.IsMatch(noHtml); + } + + public static string NormalizeForRepeat(string text) + { + var s = HtmlUtil.RemoveHtmlTags(text, true).ToLowerInvariant(); + var sb = new System.Text.StringBuilder(s.Length); + foreach (var ch in s) + { + if (char.IsLetterOrDigit(ch)) + { + sb.Append(ch); + } + } + + return sb.ToString(); + } + + public static bool IsRepeatOf(string text, string previousText) + { + var a = NormalizeForRepeat(text); + if (a.Length == 0) + { + return false; + } + + return a == NormalizeForRepeat(previousText); + } + + public static SpeechToTextQualityIssue MakeIssue(SpeechToTextQualityIssueType type, Paragraph p, int number, string detail) + { + return new SpeechToTextQualityIssue + { + Type = type, + Number = number, + StartTime = new TimeCode(p.StartTime.TotalMilliseconds), + EndTime = new TimeCode(p.EndTime.TotalMilliseconds), + Text = p.Text, + Detail = detail, + }; + } + + /// + /// Scan the (final) subtitle and record what is still wrong. Uses the user's + /// general timing settings for the thresholds, so the report agrees with what + /// the main grid colors as errors. + /// + public void Analyze(Subtitle subtitle, int minDisplayMs, int maxDisplayMs, double maxCharsPerSecond) + { + TotalLines = subtitle.Paragraphs.Count; + + for (var i = 0; i < subtitle.Paragraphs.Count; i++) + { + var p = subtitle.Paragraphs[i]; + var number = i + 1; + var durationMs = p.DurationTotalMilliseconds; + var textNoHtml = HtmlUtil.RemoveHtmlTags(p.Text, true); + var cps = p.GetCharactersPerSecond(); + + if (IsNonSpeechLine(p.Text)) + { + Issues.Add(MakeIssue(SpeechToTextQualityIssueType.NonSpeech, p, number, string.Empty)); + } + else if (i > 0 && IsRepeatOf(p.Text, subtitle.Paragraphs[i - 1].Text)) + { + Issues.Add(MakeIssue(SpeechToTextQualityIssueType.Repeated, p, number, $"= #{i}")); + } + + if (durationMs < minDisplayMs) + { + Issues.Add(MakeIssue(SpeechToTextQualityIssueType.TooShort, p, number, FormatSeconds(durationMs))); + } + else if (cps > maxCharsPerSecond) + { + Issues.Add(MakeIssue(SpeechToTextQualityIssueType.TooShort, p, number, $"{cps:0.0} cps")); + } + + if (durationMs > maxDisplayMs) + { + Issues.Add(MakeIssue(SpeechToTextQualityIssueType.TooLong, p, number, FormatSeconds(durationMs))); + } + else if (durationMs >= SparseMinDurationMs && textNoHtml.Trim().Length > 0 && cps < SparseCharsPerSecond) + { + Issues.Add(MakeIssue(SpeechToTextQualityIssueType.TooLong, p, number, $"{FormatSeconds(durationMs)}, {cps:0.0} cps")); + } + + var next = subtitle.GetParagraphOrDefault(i + 1); + if (next != null && p.EndTime.TotalMilliseconds > next.StartTime.TotalMilliseconds + 0.5) + { + var overlapMs = p.EndTime.TotalMilliseconds - next.StartTime.TotalMilliseconds; + Issues.Add(MakeIssue(SpeechToTextQualityIssueType.Overlap, p, number, $"{overlapMs:0} ms")); + } + } + } + + private static string FormatSeconds(double ms) + { + return (ms / 1000.0).ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) + " s"; + } + + public string ToLogString() + { + var parts = new List(); + foreach (var type in Enum.GetValues()) + { + var n = Count(type); + var r = RemovedCount(type); + if (n > 0 || r > 0) + { + parts.Add(r > 0 ? $"{type}: {n} (removed {r})" : $"{type}: {n}"); + } + } + + return parts.Count == 0 + ? $"Quality check: no issues found in {TotalLines} lines" + : $"Quality check ({TotalLines} lines): {string.Join(", ", parts)}"; + } +} diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportViewModel.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportViewModel.cs new file mode 100644 index 00000000000..4dc3dbff02b --- /dev/null +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportViewModel.cs @@ -0,0 +1,211 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Nikse.SubtitleEdit.Features.Video.SpeechToText; + +/// One row in the quality report table. +public class QualityReportDisplayItem +{ + public SpeechToTextQualityIssueType? Type { get; init; } + public bool IsRemoved { get; init; } + public int Number { get; init; } + public string Category { get; init; } = string.Empty; + public string Start { get; init; } = string.Empty; + public string End { get; init; } = string.Empty; + public string Detail { get; init; } = string.Empty; + public string Text { get; init; } = string.Empty; + public IBrush Brush { get; init; } = Brushes.Gray; +} + +public partial class SpeechToTextQualityReportViewModel : ObservableObject +{ + [ObservableProperty] private string _summary = string.Empty; + [ObservableProperty] private string _tip = string.Empty; + [ObservableProperty] private bool _doNotShowAgain; + [ObservableProperty] private bool _hasIssues; + [ObservableProperty] private ObservableCollection _items = new(); + [ObservableProperty] private QualityReportDisplayItem? _selectedItem; + + public ObservableCollection Cards { get; } = new(); + + /// Card key for the "removed lines" filter (the issue-type keys are the enum values). + private static readonly object RemovedKey = new(); + + public int TotalLines { get; private set; } + public int IssueCount { get; private set; } + public int RemovedCount { get; private set; } + + public Window? Window { get; set; } + + private readonly List _allItems = new(); + + public static readonly IBrush TooShortBrush = new SolidColorBrush(Color.Parse("#E8912D")); + public static readonly IBrush TooLongBrush = new SolidColorBrush(Color.Parse("#9B59B6")); + public static readonly IBrush OverlapBrush = new SolidColorBrush(Color.Parse("#E74C3C")); + public static readonly IBrush NonSpeechBrush = new SolidColorBrush(Color.Parse("#3498DB")); + public static readonly IBrush RepeatedBrush = new SolidColorBrush(Color.Parse("#1ABC9C")); + public static readonly IBrush RemovedBrush = new SolidColorBrush(Color.Parse("#7F8C8D")); + public static readonly IBrush AllBrush = new SolidColorBrush(Color.Parse("#5D8AA8")); + + public static IBrush GetBrush(SpeechToTextQualityIssueType type) + { + return type switch + { + SpeechToTextQualityIssueType.TooShort => TooShortBrush, + SpeechToTextQualityIssueType.TooLong => TooLongBrush, + SpeechToTextQualityIssueType.Overlap => OverlapBrush, + SpeechToTextQualityIssueType.NonSpeech => NonSpeechBrush, + SpeechToTextQualityIssueType.Repeated => RepeatedBrush, + _ => AllBrush, + }; + } + + public static string GetLabel(SpeechToTextQualityIssueType type) + { + var l = Se.Language.Video.AudioToText; + return type switch + { + SpeechToTextQualityIssueType.TooShort => l.QualityReportTooShort, + SpeechToTextQualityIssueType.TooLong => l.QualityReportTooLong, + SpeechToTextQualityIssueType.Overlap => l.QualityReportOverlap, + SpeechToTextQualityIssueType.NonSpeech => l.QualityReportNonSpeech, + SpeechToTextQualityIssueType.Repeated => l.QualityReportRepeated, + _ => string.Empty, + }; + } + + private static string GetHint(SpeechToTextQualityIssueType type) + { + var l = Se.Language.Video.AudioToText; + return type switch + { + SpeechToTextQualityIssueType.TooShort => l.QualityReportTooShortHint, + SpeechToTextQualityIssueType.TooLong => l.QualityReportTooLongHint, + SpeechToTextQualityIssueType.Overlap => l.QualityReportOverlapHint, + SpeechToTextQualityIssueType.NonSpeech => l.QualityReportNonSpeechHint, + SpeechToTextQualityIssueType.Repeated => l.QualityReportRepeatedHint, + _ => string.Empty, + }; + } + + public void Initialize(SpeechToTextQualityReport report) + { + var l = Se.Language.Video.AudioToText; + TotalLines = report.TotalLines; + IssueCount = report.Issues.Count; + RemovedCount = report.Removed.Count; + HasIssues = report.HasIssues; + Tip = l.QualityReportTip; + Summary = report.Issues.Count == 0 && report.Removed.Count == 0 + ? l.QualityReportNoIssues + : string.Format(l.QualityReportSummaryX, report.Issues.Count, report.TotalLines); + + _allItems.Clear(); + foreach (var issue in report.Issues.OrderBy(p => p.Number).ThenBy(p => p.Type)) + { + _allItems.Add(MakeItem(issue, false)); + } + + foreach (var issue in report.Removed.OrderBy(p => p.Number)) + { + _allItems.Add(MakeItem(issue, true)); + } + + Cards.Clear(); + Cards.Add(new SummaryCard { Key = null, Label = l.QualityReportAll, Count = report.Issues.Count, Brush = AllBrush, IsActive = true }); + foreach (var type in new[] + { + SpeechToTextQualityIssueType.TooShort, + SpeechToTextQualityIssueType.TooLong, + SpeechToTextQualityIssueType.Overlap, + SpeechToTextQualityIssueType.NonSpeech, + SpeechToTextQualityIssueType.Repeated, + }) + { + Cards.Add(new SummaryCard { Key = type, Label = GetLabel(type), Hint = GetHint(type), Count = report.Count(type), Brush = GetBrush(type) }); + } + + if (report.Removed.Count > 0) + { + Cards.Add(new SummaryCard { Key = RemovedKey, Label = l.QualityReportRemoved, Count = report.Removed.Count, Brush = RemovedBrush }); + } + + ApplyFilter(Cards[0]); + } + + private static QualityReportDisplayItem MakeItem(SpeechToTextQualityIssue issue, bool removed) + { + var l = Se.Language.Video.AudioToText; + return new QualityReportDisplayItem + { + Type = issue.Type, + IsRemoved = removed, + Number = issue.Number, + Category = removed ? $"{l.QualityReportRemoved}: {GetLabel(issue.Type)}" : GetLabel(issue.Type), + Start = issue.StartTime.ToShortDisplayString(), + End = issue.EndTime.ToShortDisplayString(), + Detail = issue.Detail, + Text = issue.Text.Replace("\r\n", " ").Replace('\n', ' ').Replace('\r', ' '), + Brush = removed ? RemovedBrush : GetBrush(issue.Type), + }; + } + + [RelayCommand] + private void SetFilter(SummaryCard? card) + { + if (card == null) + { + return; + } + + ApplyFilter(card); + } + + private void ApplyFilter(SummaryCard card) + { + foreach (var c in Cards) + { + c.IsActive = ReferenceEquals(c, card); + } + + IEnumerable filtered = _allItems; + if (ReferenceEquals(card.Key, RemovedKey)) + { + filtered = _allItems.Where(p => p.IsRemoved); + } + else if (card.Key is SpeechToTextQualityIssueType type) + { + filtered = _allItems.Where(p => !p.IsRemoved && p.Type == type); + } + else + { + filtered = _allItems.Where(p => !p.IsRemoved); + } + + Items = new ObservableCollection(filtered); + SelectedItem = Items.FirstOrDefault(); + } + + [RelayCommand] + private void Ok() + { + Window?.Close(); + } + + internal void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + e.Handled = true; + Window?.Close(); + } + } +} diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportWindow.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportWindow.cs new file mode 100644 index 00000000000..d5a3aa39c61 --- /dev/null +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportWindow.cs @@ -0,0 +1,196 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; + +namespace Nikse.SubtitleEdit.Features.Video.SpeechToText; + +/// +/// Shows what post-processing found in a fresh transcription (issue #13973): +/// a header with the verdict, one summary card per issue type (click to +/// filter), and the affected lines in a table. +/// +public class SpeechToTextQualityReportWindow : Window +{ + private readonly SpeechToTextQualityReportViewModel _vm; + + public SpeechToTextQualityReportWindow(SpeechToTextQualityReportViewModel vm) + { + UiUtil.InitializeWindow(this, GetType().Name); + Title = Se.Language.Video.AudioToText.QualityReportTitle; + Width = 960; + Height = 640; + MinWidth = 720; + MinHeight = 460; + CanResize = true; + + _vm = vm; + vm.Window = this; + DataContext = vm; + + var l = Se.Language.Video.AudioToText; + + // Header: title + one-line verdict + var labelTitle = new TextBlock + { + Text = l.QualityReportTitle, + FontSize = 20, + FontWeight = FontWeight.Bold, + }; + var labelSummary = new TextBlock + { + FontSize = 14, + Opacity = 0.75, + Margin = new Thickness(0, 4, 0, 0), + [!TextBlock.TextProperty] = new Binding(nameof(vm.Summary)), + }; + var header = new StackPanel + { + Orientation = Orientation.Vertical, + Margin = new Thickness(0, 0, 0, 12), + Children = { labelTitle, labelSummary }, + }; + + // Summary cards - one per issue type, acting as filter buttons + var cards = SummaryCard.MakeCardsPanel(vm.Cards, vm.SetFilterCommand); + + // Issue table + var table = TableViewExtras.MakeTableView(alwaysSelected: false, multiSelect: false); + table[!TableView.ItemsSourceProperty] = new Binding(nameof(vm.Items)); + table[!SelectingItemsControl.SelectedItemProperty] = new Binding(nameof(vm.SelectedItem)) { Mode = BindingMode.TwoWay }; + table.Columns.Add(new SeTableViewColumn + { + Header = Se.Language.General.NumberSymbol, + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Binding = new Binding(nameof(QualityReportDisplayItem.Number)), + Width = new GridLength(60), + }); + table.Columns.Add(new SeTableViewColumn + { + Header = l.QualityReportIssue, + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + CellTemplate = new FuncDataTemplate((item, _) => + { + var dot = new Avalonia.Controls.Shapes.Ellipse + { + Width = 9, + Height = 9, + VerticalAlignment = VerticalAlignment.Center, + [!Avalonia.Controls.Shapes.Shape.FillProperty] = new Binding(nameof(QualityReportDisplayItem.Brush)), + }; + var text = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + [!TextBlock.TextProperty] = new Binding(nameof(QualityReportDisplayItem.Category)), + }; + return new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 6, + Margin = new Thickness(6, 0), + VerticalAlignment = VerticalAlignment.Center, + Children = { dot, text }, + }; + }), + Width = new GridLength(170), + }); + table.Columns.Add(new SeTableViewColumn + { + Header = Se.Language.General.Show, + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Binding = new Binding(nameof(QualityReportDisplayItem.Start)), + Width = new GridLength(105), + }); + table.Columns.Add(new SeTableViewColumn + { + Header = Se.Language.General.Hide, + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Binding = new Binding(nameof(QualityReportDisplayItem.End)), + Width = new GridLength(105), + }); + table.Columns.Add(new SeTableViewColumn + { + Header = l.QualityReportDetail, + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Binding = new Binding(nameof(QualityReportDisplayItem.Detail)), + Width = new GridLength(120), + }); + table.Columns.Add(new SeTableViewColumn + { + Header = Se.Language.General.Text, + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Binding = new Binding(nameof(QualityReportDisplayItem.Text)), + Width = new GridLength(1, GridUnitType.Star), + }); + AutomationProperties.SetName(table, l.QualityReportTitle); + var tableBorder = UiUtil.MakeBorderForControlNoPadding(table); + + // Footer: tip, "do not show again", OK + var labelTip = new TextBlock + { + TextWrapping = TextWrapping.Wrap, + Opacity = 0.7, + Margin = new Thickness(0, 10, 0, 10), + [!TextBlock.TextProperty] = new Binding(nameof(vm.Tip)), + }; + + var checkDoNotShowAgain = UiUtil.MakeCheckBox(l.QualityReportDoNotShowAgain, vm, nameof(vm.DoNotShowAgain)); + checkDoNotShowAgain.VerticalAlignment = VerticalAlignment.Center; + var buttonOk = UiUtil.MakeButtonOk(vm.OkCommand); + var footer = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + }, + }; + footer.Add(checkDoNotShowAgain, 0, 0); + footer.Add(UiUtil.MakeButtonBar(buttonOk), 0, 1); + + var grid = new Grid + { + RowDefinitions = + { + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + }, + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + }, + Margin = UiUtil.MakeWindowMargin(), + }; + grid.Add(header, 0, 0); + grid.Add(cards, 1, 0); + grid.Add(tableBorder, 2, 0); + grid.Add(labelTip, 3, 0); + grid.Add(footer, 4, 0); + + Content = grid; + + UiUtil.FocusOnFirstActivation(this, buttonOk); // hack to make OnKeyDown work + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + _vm.OnKeyDown(e); + } +} diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextTimingFixer.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextTimingFixer.cs index a86c149482f..7bb0b9d57f1 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextTimingFixer.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextTimingFixer.cs @@ -172,7 +172,11 @@ public static Subtitle ShortenViaWavePeaks(Subtitle subtitle, WavePeakData2 wave if (pctHere < percentageMax) { var startPosForward = p.StartTime.TotalSeconds; - while (pctHere < percentageMax && startPos < p.EndTime.TotalSeconds - 1) + // Bound the scan by the variable that actually advances. "startPos" is assigned + // once above and never changes inside the loop, so this guard was a constant: the + // scan ran past the cue's own end until FindPercentage gave up, and the -1 exit + // then abandoned the whole method, leaving every later line unadjusted. + while (pctHere < percentageMax && startPosForward < p.EndTime.TotalSeconds - 1) { pctHere = FindPercentage(startPosForward - 0.05, startPosForward + 0.05, wavePeaks); if (Math.Abs(pctHere - (-1)) < 0.01) diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs index 391c66c764a..d08361cb30f 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs @@ -152,6 +152,7 @@ public partial class SpeechToTextViewModel : ObservableObject private bool _crispAsrVadWasUsed; private bool _crispAsrVadSuppressed; private bool _loadedFromStdOut; + private SpeechToTextQualityReport? _qualityReport; private string? _videoFileName; private string _audioFileName = string.Empty; private int _audioTrackNumber; @@ -207,7 +208,7 @@ public partial class SpeechToTextViewModel : ObservableObject private List? _audioClips; private bool _audioClipsAutoStart; private string _qwen3AsrOutputJsonPath = string.Empty; - private int? _qwen3AsrExitCode; + private int? _engineExitCode; private readonly IWindowService _windowService; private readonly IFileHelper _fileHelper; @@ -254,6 +255,16 @@ public SpeechToTextViewModel(IWindowService windowService, IFileHelper fileHelpe Engines.Add(new WhisperEngineCTranslate2()); } + // Same platform/architecture support as the standalone build published at + // https://github.com/muaz978/subtitleedit-whisperx-standalone - only builds for + // Windows x64 (not ARM64, which would silently get a mismatched x64 binary). + if ((OperatingSystem.IsWindows() && RuntimeInformation.ProcessArchitecture == Architecture.X64) || + (OperatingSystem.IsMacOS() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64) || + (OperatingSystem.IsLinux() && RuntimeInformation.ProcessArchitecture == Architecture.X64)) + { + Engines.Add(new WhisperEngineWhisperX()); + } + Engines.Add(new WhisperEngineOpenAi()); // Add OpenAI Compatible STT engine (available on all platforms) @@ -390,9 +401,26 @@ private void SaveSettings() var engine = GetEffectiveSelectedEngine(); engine.CommandLineParameter = Parameters; Se.Settings.Tools.AudioToText.WhisperChoice = engine.Choice; - Se.Settings.Tools.AudioToText.WhisperModel = SelectedModel?.Model.Name ?? string.Empty; - Se.Settings.Tools.AudioToText.WhisperLanguageCode = SelectedLanguage?.Code ?? string.Empty; - Se.Settings.Tools.AudioToText.CrispAsrForcedAligner = SelectedForcedAligner?.Choice ?? ForcedAlignerOption.BuiltInChoice; + // Keep the remembered model/language when the current engine simply doesn't show + // those pickers (online STT engines null them out): overwriting with empty strings + // lost the user's choice for good, so switching back fell to tiny/English. + if (SelectedModel != null || IsModelSelectionVisible) + { + Se.Settings.Tools.AudioToText.WhisperModel = SelectedModel?.Model.Name ?? string.Empty; + } + + if (SelectedLanguage != null || IsLanguageSelectionVisible) + { + Se.Settings.Tools.AudioToText.WhisperLanguageCode = SelectedLanguage?.Code ?? string.Empty; + } + // Only write when this window actually has an aligner selected. SaveSettings runs on + // every EngineChanged, so with a non-CrispASR engine (SelectedForcedAligner == null) the + // "?? built-in" fallback overwrote the aligner the user had chosen in Import plain text > + // Forced aligner setup - the only place that reads this key back. + if (SelectedForcedAligner != null) + { + Se.Settings.Tools.AudioToText.CrispAsrForcedAligner = SelectedForcedAligner.Choice; + } Se.Settings.Tools.OpenAiCompatibleSttUrl = OpenAiCompatibleSttUrl ?? string.Empty; Se.Settings.Tools.OpenAiCompatibleSttApiKey = OpenAiCompatibleSttApiKey ?? string.Empty; @@ -443,6 +471,7 @@ or WhisperChoice.CppCuBlasLib or WhisperChoice.ConstMe or WhisperChoice.PurfviewFasterWhisperXxl or WhisperChoice.CTranslate2 + or WhisperChoice.WhisperX or WhisperChoice.OpenAi; } @@ -515,7 +544,10 @@ private void UpdateForcedAlignerUi() ? ForcedAlignerOption.BuiltInChoice : (crispEngine is CrispAsrQwen3 or CrispAsrMega or CrispAsrFunAsrNano ? ForcedAlignerOption.Qwen3Choice : ForcedAlignerOption.CanaryCtcChoice); - var match = ForcedAligners.FirstOrDefault(p => p.Choice == preferredChoice) + // Prefer what was saved - otherwise this window could never restore the user's own pick + // either, since nothing here ever read the setting back. + var match = ForcedAligners.FirstOrDefault(p => p.Choice == Se.Settings.Tools.AudioToText.CrispAsrForcedAligner) + ?? ForcedAligners.FirstOrDefault(p => p.Choice == preferredChoice) ?? ForcedAligners.FirstOrDefault(); if (!ReferenceEquals(SelectedForcedAligner, match)) { @@ -750,14 +782,15 @@ private void OnTimerWhisperOnElapsed(object? sender, ElapsedEventArgs args) var settings = Se.Settings.Tools.AudioToText; // Grab the exit code before disposing - it is the key diagnostic when the engine - // dies without producing output (e.g. a Qwen3 ASR GPU/Vulkan crash, issue #12815). + // dies without producing output (a Qwen3 ASR GPU/Vulkan crash, issue #12815; a Crisp + // ASR build that needs CPU instructions this machine lacks, issue #14038). try { - _qwen3AsrExitCode = _whisperProcess.ExitCode; + _engineExitCode = _whisperProcess.ExitCode; } catch { - _qwen3AsrExitCode = null; + _engineExitCode = null; } _whisperProcess.Dispose(); @@ -788,9 +821,13 @@ await MessageBox.Show(Window!, "Incomplete model", "The model is incomplete. Please download the full model."); hasError = true; } - else if (_unknownArgument && !string.IsNullOrEmpty(settings.WhisperCustomCommandLineArguments)) + else if (_unknownArgument) { - await MessageBox.Show(Window!, $"Unknown argument: {settings.WhisperCustomCommandLineArguments}", + // Report the parameters the engine actually ran with. This used to be + // gated on a global setting nothing ever writes, so a mistyped flag made + // the run fail with no message at all. + var badArgs = GetEffectiveSelectedEngine()?.CommandLineParameter ?? string.Empty; + await MessageBox.Show(Window!, $"Unknown argument: {badArgs}", "Unknown argument. Please check the advanced settings."); hasError = true; } @@ -877,6 +914,78 @@ internal static bool ShouldForceCrispAsrVad(ISpeechToTextEngine engine, string? return !Regex.IsMatch(crispArgs ?? string.Empty, @"(^|\s)(--vad|-vm|--vad-model|--chunk-seconds|-ck)\b"); } + /// + /// Windows terminates a process that executes an instruction its CPU does not implement with + /// STATUS_ILLEGAL_INSTRUCTION; Unix reports the SIGILL as 128+4. + /// + internal const int StatusIllegalInstruction = unchecked((int)0xC000001D); + + /// The shell convention for a child killed by SIGILL (128 + SIGILL). + internal const int UnixSigill = 132; + + /// + /// Explains a Crisp ASR run that produced nothing because the engine died, or null when the + /// exit code says it did not - an empty result with a clean exit has some other cause and the + /// generic message is the honest one. + /// + /// Worth spelling out because the failure is invisible: a process killed for an illegal + /// instruction never reaches stdout, so SE sees a well-behaved engine that simply produced no + /// subtitles and said so, which is all the user was told in #14038. The concrete case there + /// 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 (the current pin), 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. + /// + /// The engine process exit code, or null when it could not be read. + /// + /// The installed Crisp ASR package ("cuda", "vulkan", "cpu", ...) as reported by + /// , or null when it is not known. + /// + internal static string? DescribeCrispAsrCrash(int? exitCode, string? variant) + { + if (exitCode is null or 0) + { + return null; + } + + var code = $"exit code {exitCode.Value} (0x{(uint)exitCode.Value:X8})"; + if (exitCode.Value is not (StatusIllegalInstruction or UnixSigill)) + { + return $"Crisp ASR crashed before producing any output ({code}).{Environment.NewLine}{Environment.NewLine}" + + "Please check the tools log for engine output."; + } + + var isGpuBuild = variant is "cuda" or "cuda13" or "vulkan" or "hip"; + var advice = isGpuBuild + ? $"The installed \"{variant}\" package needs a newer CPU than this one. Download the speech to text " + + "engine again and choose the CPU build." + : "Download the speech to text engine again and choose the CPU (legacy) build, which targets the " + + "oldest CPUs."; + + return $"Crisp ASR was stopped for using CPU instructions this computer does not have ({code}, " + + $"illegal instruction), so it never produced any output.{Environment.NewLine}{Environment.NewLine}" + + advice; + } + + /// + /// The Crisp ASR package the user actually has installed, from the download sidecar. Best-effort: + /// this only sharpens a diagnostic message, so an unreadable sidecar is not worth failing over. + /// + private static string? TryGetCrispAsrVariant(ICrispAsrEngine engine) + { + try + { + var sidecar = DownloadHashManager.TryReadSidecar(engine.GetAndCreateWhisperFolder()); + return sidecar == null ? null : DownloadHashManager.GetCrispAsrVariant(sidecar.Value.Key); + } + catch + { + return null; + } + } + /// /// Re-runs a Crisp ASR job that produced nothing, this time without the VAD pass. /// @@ -944,9 +1053,14 @@ await MessageBox.Show(Window!, title, var parameters = Parameters ?? string.Empty; if (parameters.Contains("--compute_type", StringComparison.OrdinalIgnoreCase)) { + // Only the floating point types are worth naming: on the RTX 50 series - the cards + // this error shows up on most - every int8 variant fails with this exact cuBLAS + // error, so listing "int8" sent the user straight back to the same dialog + // (Purfview/whisper-standalone-win#403, OpenNMT/CTranslate2#1865). await MessageBox.Show(Window!, title, cause + "The parameters already set \"--compute_type\" - try another value, " + - "such as \"float16\", \"int8\", or \"float32\"."); + "such as \"float16\", \"float32\", or \"bfloat16\" - the \"int8\" types fail " + + "this way on many newer GPUs."); return; } @@ -1017,7 +1131,7 @@ private void ProcessQwen3AsrCppTranscription(SeAudioToText settings) var jsonPath = _qwen3AsrOutputJsonPath; if (string.IsNullOrEmpty(jsonPath) || !File.Exists(jsonPath)) { - var exitCode = _qwen3AsrExitCode; + var exitCode = _engineExitCode; var isVulkan = false; try { @@ -1050,9 +1164,10 @@ await MessageBox.Show(Window!, "Speech to text engine could not start", return; } - if (_unknownArgument && !string.IsNullOrEmpty(settings.WhisperCustomCommandLineArguments)) + if (_unknownArgument) { - await MessageBox.Show(Window!, $"Unknown argument: {settings.WhisperCustomCommandLineArguments}", + var badArgs = GetEffectiveSelectedEngine()?.CommandLineParameter ?? string.Empty; + await MessageBox.Show(Window!, $"Unknown argument: {badArgs}", "Unknown argument. Please check the advanced settings."); } LogToConsole($"Speech to text: Could not find output JSON file ({exitCodeText}){Environment.NewLine}"); @@ -1251,7 +1366,7 @@ await Dispatcher.UIThread.InvokeAsync(async () => await MessageBox.Show(Window!, Se.Language.General.ConfigurationRequired, configError ?? Se.Language.General.OpenAiCompatibleSttUrlMissing); - IsTranscribeEnabled = true; + FailCurrentOnlineSttJob(); }); return; } @@ -1292,7 +1407,7 @@ await Dispatcher.UIThread.InvokeAsync(async () => await MessageBox.Show(Window!, Se.Language.General.TranscriptionError, string.Format(Se.Language.General.OpenAiCompatibleSttUrlNotResponding, probeError)); - IsTranscribeEnabled = true; + FailCurrentOnlineSttJob(); }); return; } @@ -1397,7 +1512,7 @@ await MessageBox.Show(Window!, { await Dispatcher.UIThread.InvokeAsync(() => { - IsTranscribeEnabled = true; + FailCurrentOnlineSttJob(); HideProgressBar(); }); } @@ -1435,7 +1550,7 @@ await Dispatcher.UIThread.InvokeAsync(() => await Dispatcher.UIThread.InvokeAsync(async () => { await MessageBox.Show(Window!, Se.Language.General.TranscriptionError, message); - IsTranscribeEnabled = true; + FailCurrentOnlineSttJob(); }); } catch (TimeoutException ex) @@ -1477,7 +1592,7 @@ await Dispatcher.UIThread.InvokeAsync(async () => await Dispatcher.UIThread.InvokeAsync(async () => { await MessageBox.Show(Window!, Se.Language.General.TranscriptionError, $"{Se.Language.General.TranscriptionFailed}: {ex.Message}"); - IsTranscribeEnabled = true; + FailCurrentOnlineSttJob(); }); } finally @@ -1986,6 +2101,11 @@ private Subtitle PostProcess(Subtitle transcript) var postProcessor = new SpeechToTextPostProcessor(DoTranslateToEnglish ? "en" : languageCode) { ParagraphMaxChars = Configuration.Settings.General.SubtitleLineMaximumLength * 2, + RemoveNonSpeechLines = Se.Settings.Tools.AudioToText.WhisperPostProcessingRemoveNonSpeechLines, + RemoveRepeatedLines = Se.Settings.Tools.AudioToText.WhisperPostProcessingRemoveRepeatedLines, + // The engine's own parameters, so word-highlighted output is left alone by the + // merge/split steps (they are stored per engine, not in one global setting). + EngineCommandLineArguments = GetEffectiveSelectedEngine()?.CommandLineParameter ?? string.Empty, }; WavePeakData2? wavePeaks = null; @@ -2014,6 +2134,11 @@ private Subtitle PostProcess(Subtitle transcript) settings.WhisperPostProcessingChangeUnderlineToColorColor.FromHexToColor() ); + // Keep the report for MakeResult (shown once the run is done) and log a + // one-line summary so batch runs leave a trace too (issue #13973). + _qualityReport = postProcessor.QualityReport; + LogToConsole(_qualityReport.ToLogString()); + return transcript; } @@ -2115,7 +2240,9 @@ private static bool IsModelRejectedByServer(HttpRequestException ex, ISpeechToTe _filesToDelete.Add(targetFile); try { - var process = GetFfmpegProcess(_videoFileName, _audioTrackNumber, targetFile); + // One ffmpeg per batch item; the sibling _whisperProcess is disposed, and the + // text-to-speech twin uses "using var" for the same call. + using var process = GetFfmpegProcess(_videoFileName, _audioTrackNumber, targetFile); if (process == null) { return null; @@ -2258,7 +2385,7 @@ public bool GetResultFromSrt( return true; } - private static List GetResultFileCandidates(string ext, string waveFileName, string videoFileName, string whisperFolder, ConcurrentQueue outputText, string? sttTempFolder = null) + internal static List GetResultFileCandidates(string ext, string waveFileName, string videoFileName, string whisperFolder, ConcurrentQueue outputText, string? sttTempFolder = null) { var candidates = new List { @@ -2284,8 +2411,12 @@ private static List GetResultFileCandidates(string ext, string waveFileN { // A pre-extracted 16 kHz WAV skips extraction, so the engines' contained output lands // in the per-run folder under the USER'S file name - no other candidate covers that. - candidates.Add(Path.Combine(sttTempFolder, Path.GetFileNameWithoutExtension(videoFileName) + ext)); - candidates.Add(Path.Combine(sttTempFolder, Path.GetFileNameWithoutExtension(waveFileName) + ext)); + // The per-run folder is where SE told the engine to write, so it must be probed BEFORE + // the folder of the user's own file: with the wave-dir candidate first, a stale + // ".srt" already sitting next to the user's WAV was picked up as the result and + // then deleted as one of SE's temp files. + candidates.Insert(0, Path.Combine(sttTempFolder, Path.GetFileNameWithoutExtension(waveFileName) + ext)); + candidates.Insert(0, Path.Combine(sttTempFolder, Path.GetFileNameWithoutExtension(videoFileName) + ext)); } if (!string.IsNullOrEmpty(whisperFolder)) @@ -2420,6 +2551,20 @@ private async Task MakeResult(Subtitle? transcribedSubtitle) var anyLinesTranscribed = transcribedSubtitle != null && transcribedSubtitle.Paragraphs.Count > 0; + // A crashed engine leaves the same empty result as a clean run that found no speech, and + // the exit code is the only thing that tells them apart (#14038). Resolved here rather than + // in the dialog branch below so a batch run - which returns before any of that - still + // records why the file came back empty. + string? crispAsrCrash = null; + if (!anyLinesTranscribed && GetEffectiveSelectedEngine() is ICrispAsrEngine crispAsrEngine) + { + crispAsrCrash = DescribeCrispAsrCrash(_engineExitCode, TryGetCrispAsrVariant(crispAsrEngine)); + if (crispAsrCrash != null) + { + Se.WriteToolsLog(crispAsrCrash, true); + } + } + if (_abort) { // User cancelled mid-run. Leave the dialog open so they can adjust @@ -2456,6 +2601,7 @@ await MessageBox.Show(Window!, "No result file", } else if (anyLinesTranscribed) { + await ShowQualityReport(); OkPressed = anyLinesTranscribed; TranscribedSubtitle = transcribedSubtitle ?? new Subtitle(); Window?.Close(); @@ -2463,6 +2609,7 @@ await MessageBox.Show(Window!, "No result file", else if (GetEffectiveSelectedEngine() is ICrispAsrEngine) { await MessageBox.Show(Window!, "No transcription result", + crispAsrCrash ?? "Crisp ASR finished without generating subtitles. Please check the tools log for engine output."); if (Window != null) @@ -2473,6 +2620,28 @@ await MessageBox.Show(Window!, "No transcription result", } } + /// + /// Tell the user what post-processing found (issue #13973) before the dialog + /// closes. Only for the single-file flow - batch runs log the summary instead. + /// + private async Task ShowQualityReport() + { + var report = _qualityReport; + _qualityReport = null; + if (report == null || !report.HasIssues || !Se.Settings.Tools.AudioToText.WhisperPostProcessingShowQualityReport || Window == null) + { + return; + } + + var vm = await _windowService.ShowDialogAsync( + Window, viewModel => viewModel.Initialize(report)); + + if (vm.DoNotShowAgain) + { + Se.Settings.Tools.AudioToText.WhisperPostProcessingShowQualityReport = false; + } + } + private void OnTimerAudioExtractOnElapsed(object? sender, ElapsedEventArgs e) { lock (_lockObj) @@ -2677,8 +2846,6 @@ private static bool IsModelEnglishOnly(WhisperModel model) model.Name == "distil-large-v3"; } - - [RelayCommand] private void ShowWebLink() { @@ -2830,6 +2997,13 @@ await _windowService.ShowDialogAsync "vulkan", }; + if (crispVariant == "cuda") + { + // Upstream added a Windows CUDA 13 build alongside the CUDA 12 one in v0.8.31, so + // Windows now gets the same follow-up Linux has had since v0.8.30 (#14343). + return await PromptCrispAsrCudaVersionAsync(); + } + if (crispVariant == "cpu") { return await PromptCrispAsrCpuFlavorAsync(); @@ -2879,7 +3053,7 @@ await _windowService.ShowDialogAsync - /// Follow-up prompt after the user picks "CUDA" in the Linux CrispASR variant selector. + /// Follow-up prompt after the user picks "CUDA" in the CrispASR variant selector, on both + /// Windows and Linux - upstream ships a CUDA 12 and a CUDA 13 build for each. /// Returns "cuda" (CUDA 12 build) or "cuda13", or null when the user cancels. /// - private async Task PromptCrispAsrLinuxCudaVersionAsync() + private async Task PromptCrispAsrCudaVersionAsync() { var answer = await MessageBox.Show( Window!, @@ -3083,6 +3258,9 @@ private async Task ShowPostProcessingSettings() viewModal.AddPeriods = Se.Settings.Tools.AudioToText.WhisperPostProcessingAddPeriods; viewModal.MergeShortLines = Se.Settings.Tools.AudioToText.WhisperPostProcessingMergeLines; viewModal.BreakSplitLongLines = Se.Settings.Tools.AudioToText.WhisperPostProcessingSplitLines; + viewModal.RemoveNonSpeechLines = Se.Settings.Tools.AudioToText.WhisperPostProcessingRemoveNonSpeechLines; + viewModal.RemoveRepeatedLines = Se.Settings.Tools.AudioToText.WhisperPostProcessingRemoveRepeatedLines; + viewModal.ShowQualityReport = Se.Settings.Tools.AudioToText.WhisperPostProcessingShowQualityReport; viewModal.ChangeUnderlineToColor = Se.Settings.Tools.AudioToText.WhisperPostProcessingChangeUnderlineToColor; viewModal.ChangeUnderlineToColorColor = Se.Settings.Tools.AudioToText.WhisperPostProcessingChangeUnderlineToColorColor.FromHexToColor(); }); @@ -3096,12 +3274,14 @@ private async Task ShowPostProcessingSettings() Se.Settings.Tools.AudioToText.WhisperPostProcessingAddPeriods = vm.AddPeriods; Se.Settings.Tools.AudioToText.WhisperPostProcessingMergeLines = vm.MergeShortLines; Se.Settings.Tools.AudioToText.WhisperPostProcessingSplitLines = vm.BreakSplitLongLines; + Se.Settings.Tools.AudioToText.WhisperPostProcessingRemoveNonSpeechLines = vm.RemoveNonSpeechLines; + Se.Settings.Tools.AudioToText.WhisperPostProcessingRemoveRepeatedLines = vm.RemoveRepeatedLines; + Se.Settings.Tools.AudioToText.WhisperPostProcessingShowQualityReport = vm.ShowQualityReport; Se.Settings.Tools.AudioToText.WhisperPostProcessingChangeUnderlineToColor = vm.ChangeUnderlineToColor; Se.Settings.Tools.AudioToText.WhisperPostProcessingChangeUnderlineToColorColor = vm.ChangeUnderlineToColorColor.FromColorToHex(); } } - [RelayCommand] private async Task DownloadModel() { @@ -3531,7 +3711,6 @@ await EnsureAlignerModelDownloadedAsync(engine, crispMegaEngine.ForcedAlignerMod }); } - _videoFileName = _jobItems[0].InputVideoFileName; _videoInfo.TotalMilliseconds = _jobItems[0].MediaInfo.Duration.TotalMilliseconds; _videoInfo.TotalSeconds = _jobItems[0].MediaInfo.Duration.TotalSeconds; @@ -3551,17 +3730,43 @@ await EnsureAlignerModelDownloadedAsync(engine, crispMegaEngine.ForcedAlignerMod } } + /// + /// Fails the online-STT job that is currently running. In batch mode the item is marked + /// failed and the batch moves on, the same contract the ffmpeg-failure path uses - every + /// online error path used to just re-enable the window, which stopped the batch dead at + /// that file with no item marked and no summary. + /// + private void FailCurrentOnlineSttJob() + { + if (IsBatchMode && !_abort) + { + if (_batchIndex >= 0 && _batchIndex < _jobItems.Count) + { + _jobItems[_batchIndex].Status = Se.Language.General.Error; + } + + StartNext(null); + return; + } + + IsTranscribeEnabled = true; + } + [RelayCommand] private void Cancel() { if (!IsTranscribeEnabled) { + // Always set _abort, online engines included: during the audio-extraction phase + // _openAiCts does not exist yet (it is created once ffmpeg has exited), so + // cancelling did nothing at all and the upload started anyway. _abort is also + // what MakeResult checks to stop a whole batch rather than skip one item. + _abort = true; if (GetEffectiveSelectedEngine() is IOnlineSttEngine) { _openAiCts?.Cancel(); - return; } - _abort = true; + return; } @@ -3700,7 +3905,11 @@ public bool TranscribeViaWhisper(string waveFileName, string videoFileName, bool /// stdout/stderr to when one is given. /// The working directory is the executable's folder. /// - private static Process StartEngineProcess(string executable, string arguments, DataReceivedEventHandler? dataReceivedHandler) + private static Process StartEngineProcess( + string executable, + string arguments, + DataReceivedEventHandler? dataReceivedHandler, + Action? configureStartInfo = null) { var p = new Process { @@ -3713,6 +3922,8 @@ private static Process StartEngineProcess(string executable, string arguments, D } }; + configureStartInfo?.Invoke(p.StartInfo); + if (dataReceivedHandler != null) { p.StartInfo.StandardOutputEncoding = Encoding.UTF8; @@ -3756,6 +3967,35 @@ private static void AddEngineFolderToLibrarySearchPath(ProcessStartInfo startInf : engineFolder + Path.PathSeparator + existing; } + /// + /// WhisperX shells out to a real "ffmpeg" binary via subprocess for audio loading (it is not + /// bundled in the standalone build - see subtitleedit-whisperx-standalone's README). Puts + /// Subtitle Edit's own configured/bundled ffmpeg on PATH so WhisperX finds it without + /// requiring a separate ffmpeg install. When ffmpeg is not configured to an actual file + /// (e.g. still the bare "ffmpeg" fallback resolved through the system PATH), this leaves + /// PATH untouched - the child process falls back to the exact same system PATH resolution + /// Subtitle Edit's own ffmpeg calls would use in that situation. + /// + private static void AddFfmpegToPath(ProcessStartInfo startInfo) + { + var ffmpegLocation = FfmpegHelper.GetFfmpegLocation(); + if (string.IsNullOrEmpty(ffmpegLocation) || !File.Exists(ffmpegLocation)) + { + return; + } + + var ffmpegDir = Path.GetDirectoryName(ffmpegLocation); + if (string.IsNullOrEmpty(ffmpegDir)) + { + return; + } + + var existingPath = ProcessEnvironmentHelper.GetOrNull(startInfo, "PATH"); + startInfo.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(existingPath) + ? ffmpegDir + : ffmpegDir + Path.PathSeparator + existingPath; + } + /// /// Engines that demux and decode the source media themselves, so they can be pointed at the /// user's original file instead of SE's extracted WAV: Purfview Faster-Whisper-XXL bundles @@ -3766,7 +4006,7 @@ private static void AddEngineFolderToLibrarySearchPath(ProcessStartInfo startInf private static bool CanEngineReadSourceFileDirectly(ISpeechToTextEngine engine) { return engine.Name == WhisperEnginePurfviewFasterWhisperXxl.StaticName || - engine is WhisperEngineCTranslate2; + engine is WhisperEngineCTranslate2 or WhisperEngineWhisperX; } /// @@ -3809,12 +4049,44 @@ private Process GetWhisperProcess( DataReceivedEventHandler? dataReceivedHandler = null, string engineOutputFolder = "") { + if (engine is WhisperEngineWhisperX whisperX) + { + var exe = whisperX.GetExecutable(); + var whisperXArgs = whisperX.CommandLineParameter; + var languageArgX = language.Equals("auto", StringComparison.OrdinalIgnoreCase) + ? string.Empty + : $"--language {language} "; + var taskArg = translate ? "--task translate " : string.Empty; + var outputDir = string.IsNullOrEmpty(engineOutputFolder) + ? GetSttTempFolder() + : engineOutputFolder; + var parametersX = + $"{languageArgX}--model \"{model}\" --output_format srt --output_dir \"{outputDir}\" " + + $"{taskArg}{whisperXArgs} \"{waveFileName}\""; + + // The generic launch path is bypassed here, so repeat the two pieces of its setup a + // PyInstaller-frozen Python engine needs: the glibc 2.41+ executable-stack repair, + // and the Python UTF-8/unbuffered variables - without them Windows decodes piped + // output with the ANSI code page (mojibake, or a UnicodeEncodeError killing the run) + // and stdout block-buffers so the log sits empty until the process exits. + EnsureExecutableStackCleared(whisperX, whisperX.GetAndCreateWhisperFolder()); + + Se.WriteToolsLog($"{exe} {parametersX}"); + return StartEngineProcess(exe, parametersX, dataReceivedHandler, startInfo => + { + AddFfmpegToPath(startInfo); + startInfo.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8"; + startInfo.EnvironmentVariables["PYTHONUTF8"] = "1"; + startInfo.EnvironmentVariables["PYTHONUNBUFFERED"] = "1"; + }); + } + if (engine is Qwen3AsrCppEngine qwen3Asr) { var exe = qwen3Asr.GetExecutable(); var alignerModel = qwen3Asr.ForcedAlignerModel; _qwen3AsrOutputJsonPath = Path.Combine(Path.GetTempPath(), $"qwen3_asr_{Guid.NewGuid():N}.json"); - _qwen3AsrExitCode = null; + _engineExitCode = null; var qwen3ExtraArgs = engine.CommandLineParameter; var qwen3Params = string.IsNullOrWhiteSpace(qwen3ExtraArgs) @@ -4074,9 +4346,9 @@ or WhisperChoice.CppVulkan or WhisperChoice.CppCuBlasLib or WhisperChoice.ConstM private static bool _executableStackChecked; /// - /// Repairs an already-installed Purfview Faster-Whisper-XXL before launching it. + /// Repairs an already-installed Purfview Faster-Whisper-XXL or WhisperX before launching it. /// - /// Its bundled libctranslate2 is built with PT_GNU_STACK = RWE, and glibc 2.41 stopped making + /// Both bundle a libctranslate2 built with PT_GNU_STACK = RWE, and glibc 2.41 stopped making /// the stack executable at dlopen time, so on a distro with glibc 2.41+ (Fedora 42, Arch, /// Ubuntu 25.10) the run dies immediately with "cannot enable executable stack as shared /// object requires: Invalid argument". The download path clears the flag on unpack, but @@ -4090,7 +4362,7 @@ private static void EnsureExecutableStackCleared(ISpeechToTextEngine engine, str if (_executableStackChecked || !OperatingSystem.IsLinux() || string.IsNullOrEmpty(whisperFolder) || - engine is not WhisperEnginePurfviewFasterWhisperXxl) + engine is not (WhisperEnginePurfviewFasterWhisperXxl or WhisperEngineWhisperX)) { return; } diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextWindow.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextWindow.cs index 4f9e7fe721d..f11d8b4b633 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextWindow.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextWindow.cs @@ -495,7 +495,7 @@ public SpeechToTextWindow(SpeechToTextViewModel vm) Content = grid; - Activated += delegate { Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, () => { Focus(); }); // hack to make OnKeyDown work Loaded += (s, e) => vm.OnWindowLoaded(); Closing += (s, e) => vm.OnWindowClosing(e); KeyDown += (s, e) => vm.OnKeyDown(e); diff --git a/src/ui/Features/Video/TextToSpeech/ActorVoices/ActorVoiceMappingViewModel.cs b/src/ui/Features/Video/TextToSpeech/ActorVoices/ActorVoiceMappingViewModel.cs index 1694a949ba8..ca7ee3488b5 100644 --- a/src/ui/Features/Video/TextToSpeech/ActorVoices/ActorVoiceMappingViewModel.cs +++ b/src/ui/Features/Video/TextToSpeech/ActorVoices/ActorVoiceMappingViewModel.cs @@ -512,7 +512,8 @@ private void Ok() .Where(m => !string.IsNullOrEmpty(m.EngineName) && !string.IsNullOrEmpty(m.VoiceName)) .ToList(); - Se.Settings.Video.TextToSpeech.LastActorVoiceMappings = MergeWithPersistedMappings(Mappings); + Se.Settings.Video.TextToSpeech.LastActorVoiceMappings = + MergeWithPersistedMappings(Mappings, Rows.Select(r => r.Actor)); Se.SaveSettings(); OkPressed = true; @@ -522,7 +523,9 @@ private void Ok() // Keep mappings for actors not in this subtitle so a user who jumps between projects keeps // their full cast remembered. New/updated rows from this dialog overwrite anything saved // under the same actor name. - private static List MergeWithPersistedMappings(List current) + private static List MergeWithPersistedMappings( + List current, + IEnumerable actorsInDialog) { var previous = Se.Settings.Video.TextToSpeech.LastActorVoiceMappings ?? new List(); var byActor = previous @@ -530,6 +533,17 @@ private static List MergeWithPersistedMappings(List m.Actor.Trim(), StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + // Drop every actor this dialog showed, then re-add the ones that ended up with a + // voice. Merging only by overwrite meant a row the user deliberately cleared (or + // "Clear all") kept its old saved entry and came back on the next session. + foreach (var actor in actorsInDialog) + { + if (!string.IsNullOrWhiteSpace(actor)) + { + byActor.Remove(actor.Trim()); + } + } + foreach (var m in current) { byActor[m.Actor.Trim()] = m; diff --git a/src/ui/Features/Video/TextToSpeech/AdvancedTtsSettings/AdvancedTtsSettingsViewModel.cs b/src/ui/Features/Video/TextToSpeech/AdvancedTtsSettings/AdvancedTtsSettingsViewModel.cs index a8a39a594af..53284b94d1a 100644 --- a/src/ui/Features/Video/TextToSpeech/AdvancedTtsSettings/AdvancedTtsSettingsViewModel.cs +++ b/src/ui/Features/Video/TextToSpeech/AdvancedTtsSettings/AdvancedTtsSettingsViewModel.cs @@ -1,10 +1,11 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Input; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; using Nikse.SubtitleEdit.Logic.Config; using Nikse.SubtitleEdit.Logic.Media; +using System; using System.Threading.Tasks; namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.AdvancedTtsSettings; @@ -13,12 +14,12 @@ public partial class AdvancedTtsSettingsViewModel : ObservableObject { [ObservableProperty] private bool _doProAudioChain; [ObservableProperty] private bool _doAudioDucking; - [ObservableProperty] private string _audioDuckingVolume; + [ObservableProperty] private int _audioDuckingVolume; [ObservableProperty] private bool _doVadSilenceCompression; - [ObservableProperty] private string _vadMaxSilenceMs; + [ObservableProperty] private int _vadMaxSilenceMs; [ObservableProperty] private bool _doHighQualityTimeStretch; - [ObservableProperty] private string _silencePaddingMs; - [ObservableProperty] private string _outputSampleRate; + [ObservableProperty] private int _silencePaddingMs; + [ObservableProperty] private int _outputSampleRate; [ObservableProperty] private string _generationFolder; [ObservableProperty] private bool _doDeleteTempFiles; [ObservableProperty] private string _edgeTtsRate; @@ -35,16 +36,18 @@ public partial class AdvancedTtsSettingsViewModel : ObservableObject public AdvancedTtsSettingsViewModel(IFolderHelper folderHelper) { _folderHelper = folderHelper; - RubberbandStatus = FfmpegGenerator.IsRubberbandAvailable() ? "(installed)" : "(not found in FFmpeg)"; + RubberbandStatus = FfmpegGenerator.IsRubberbandAvailable() + ? Se.Language.Video.TextToSpeech.RubberbandInstalled + : Se.Language.Video.TextToSpeech.RubberbandNotFound; var s = Se.Settings.Video.TextToSpeech; DoProAudioChain = s.ProAudioChainEnabled; DoAudioDucking = s.AudioDuckingEnabled; - AudioDuckingVolume = s.AudioDuckingOriginalVolume.ToString(); + AudioDuckingVolume = s.AudioDuckingOriginalVolume; DoVadSilenceCompression = s.VadSilenceCompressionEnabled; - VadMaxSilenceMs = ((int)(s.VadMaxSilenceSeconds * 1000)).ToString(); + VadMaxSilenceMs = (int)Math.Round(s.VadMaxSilenceSeconds * 1000); DoHighQualityTimeStretch = s.HighQualityTimeStretchEnabled; - SilencePaddingMs = s.SilencePaddingMs.ToString(); - OutputSampleRate = s.OutputSampleRate.ToString(); + SilencePaddingMs = s.SilencePaddingMs; + OutputSampleRate = s.OutputSampleRate; EdgeTtsRate = s.EdgeTtsRate; EdgeTtsPitch = s.EdgeTtsPitch; EdgeTtsVolume = s.EdgeTtsVolume; @@ -68,12 +71,12 @@ private void Ok() var s = Se.Settings.Video.TextToSpeech; s.ProAudioChainEnabled = DoProAudioChain; s.AudioDuckingEnabled = DoAudioDucking; - s.AudioDuckingOriginalVolume = int.TryParse(AudioDuckingVolume, out var dv) ? dv : 15; + s.AudioDuckingOriginalVolume = AudioDuckingVolume; s.VadSilenceCompressionEnabled = DoVadSilenceCompression; - s.VadMaxSilenceSeconds = int.TryParse(VadMaxSilenceMs, out var vadMs) ? vadMs / 1000.0 : 0.15; + s.VadMaxSilenceSeconds = VadMaxSilenceMs / 1000.0; s.HighQualityTimeStretchEnabled = DoHighQualityTimeStretch; - s.SilencePaddingMs = int.TryParse(SilencePaddingMs, out var sp) ? sp : 0; - s.OutputSampleRate = int.TryParse(OutputSampleRate, out var sr) ? sr : 0; + s.SilencePaddingMs = SilencePaddingMs; + s.OutputSampleRate = OutputSampleRate; s.EdgeTtsRate = EdgeTts.NormalizeProsodyValue(EdgeTtsRate, "%"); s.EdgeTtsPitch = EdgeTts.NormalizeProsodyValue(EdgeTtsPitch, "Hz"); s.EdgeTtsVolume = EdgeTts.NormalizeProsodyValue(EdgeTtsVolume, "%"); diff --git a/src/ui/Features/Video/TextToSpeech/AdvancedTtsSettings/AdvancedTtsSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/AdvancedTtsSettings/AdvancedTtsSettingsWindow.cs index 883499ffa5b..6fd95e88489 100644 --- a/src/ui/Features/Video/TextToSpeech/AdvancedTtsSettings/AdvancedTtsSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/AdvancedTtsSettings/AdvancedTtsSettingsWindow.cs @@ -1,5 +1,7 @@ using Avalonia; +using Avalonia.Automation; using Avalonia.Controls; +using Avalonia.Controls.Primitives; using Avalonia.Data; using Avalonia.Input; using Avalonia.Layout; @@ -10,6 +12,12 @@ namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.AdvancedTtsSettings; +/// +/// Every option used to carry its explanation as a paragraph of wrapped text underneath it, which +/// made the window several screens tall - on a laptop it was clamped to the working area and the +/// OK/Cancel buttons ended up off-screen (#14331). The explanations now live in hover hints, the +/// options are grouped, and the content scrolls with the buttons pinned below it. +/// public class AdvancedTtsSettingsWindow : Window { private readonly AdvancedTtsSettingsViewModel _vm; @@ -18,266 +26,260 @@ public AdvancedTtsSettingsWindow(AdvancedTtsSettingsViewModel vm) { UiUtil.InitializeWindow(this, GetType().Name); Title = Se.Language.Video.TextToSpeech.AdvancedTtsSettings; - SizeToContent = SizeToContent.WidthAndHeight; + + // Explicit width + height-only auto sizing: SizeToContent.WidthAndHeight renders far too + // wide on macOS. + Width = 560; + SizeToContent = SizeToContent.Height; CanResize = false; - MaxWidth = 550; _vm = vm; vm.Window = this; DataContext = vm; - var sectionProAudio = MakeSection(vm, - Se.Language.Video.TextToSpeech.ProAudioPostProcessing, - nameof(vm.DoProAudioChain), - Se.Language.Video.TextToSpeech.ProAudioPostProcessingDescription); + var l = Se.Language.Video.TextToSpeech; - var content = new StackPanel + var checkBoxProAudio = MakeCheckBox(l.ProAudioPostProcessing, nameof(vm.DoProAudioChain)); + var checkBoxDucking = MakeCheckBox(l.AudioDucking, nameof(vm.DoAudioDucking)); + var checkBoxVad = MakeCheckBox(l.VadSilenceCompression, nameof(vm.DoVadSilenceCompression)); + var checkBoxTimeStretch = MakeCheckBox(l.HighQualityTimeStretch, nameof(vm.DoHighQualityTimeStretch)); + var checkBoxDeleteTempFiles = MakeCheckBox(l.DeleteTempFiles, nameof(vm.DoDeleteTempFiles)); + + var groupAudio = MakeGroupBox(l.AdvancedTtsAudioProcessing, new StackPanel { - Margin = UiUtil.MakeWindowMargin(), - Spacing = 5, + Spacing = 6, Children = { - sectionProAudio, - - MakeSection(vm, - Se.Language.Video.TextToSpeech.AudioDucking, - nameof(vm.DoAudioDucking), - Se.Language.Video.TextToSpeech.AudioDuckingDescription, - Se.Language.Video.TextToSpeech.OriginalVolumePercent, - nameof(vm.AudioDuckingVolume), - 50), - - MakeSection(vm, - Se.Language.Video.TextToSpeech.VadSilenceCompression, - nameof(vm.DoVadSilenceCompression), - Se.Language.Video.TextToSpeech.VadSilenceCompressionDescription, - Se.Language.Video.TextToSpeech.MaxSilenceMs, - nameof(vm.VadMaxSilenceMs), - 50), - - MakeSectionWithStatus(vm, - Se.Language.Video.TextToSpeech.HighQualityTimeStretch, - nameof(vm.DoHighQualityTimeStretch), - nameof(vm.RubberbandStatus), - Se.Language.Video.TextToSpeech.HighQualityTimeStretchDescription), - - MakeFieldRow(vm, Se.Language.Video.TextToSpeech.SilencePaddingMs, nameof(vm.SilencePaddingMs), 50, - Se.Language.Video.TextToSpeech.SilencePaddingMsDescription), - - MakeFieldRow(vm, Se.Language.Video.TextToSpeech.OutputSampleRate, nameof(vm.OutputSampleRate), 60, - Se.Language.Video.TextToSpeech.OutputSampleRateDescription), - - MakeGenerationFolderSection(vm), - - MakeFieldRow(vm, Se.Language.Video.TextToSpeech.EdgeTtsRate, nameof(vm.EdgeTtsRate), 120, - Se.Language.Video.TextToSpeech.EdgeTtsRateDescription, - nameof(vm.IsEdgeTtsEngine)), - - MakeFieldRow(vm, Se.Language.Video.TextToSpeech.EdgeTtsPitch, nameof(vm.EdgeTtsPitch), 120, - Se.Language.Video.TextToSpeech.EdgeTtsPitchDescription, - nameof(vm.IsEdgeTtsEngine)), - - MakeFieldRow(vm, Se.Language.Video.TextToSpeech.EdgeTtsVolume, nameof(vm.EdgeTtsVolume), 120, - Se.Language.Video.TextToSpeech.EdgeTtsVolumeDescription, - nameof(vm.IsEdgeTtsEngine)), + MakeRow(checkBoxProAudio, l.ProAudioPostProcessingDescription), + + MakeRow(checkBoxDucking, l.AudioDuckingDescription, + MakeField(l.OriginalVolumePercent, + UiUtil.MakeNumericUpDownInt(0, 100, 15, 110, vm, nameof(vm.AudioDuckingVolume)), + nameof(vm.DoAudioDucking))), + + MakeRow(checkBoxVad, l.VadSilenceCompressionDescription, + MakeField(l.MaxSilenceMs, + UiUtil.MakeNumericUpDownInt(0, 5000, 150, 110, vm, nameof(vm.VadMaxSilenceMs)), + nameof(vm.DoVadSilenceCompression))), + + MakeRow(checkBoxTimeStretch, l.HighQualityTimeStretchDescription, + status: MakeStatusLabel(nameof(vm.RubberbandStatus))), } + }); + + var groupOutput = MakeGroupBox(l.AdvancedTtsOutput, new StackPanel + { + Spacing = 6, + Children = + { + MakeRow(UiUtil.MakeLabel(l.SilencePaddingMs), l.SilencePaddingMsDescription, + UiUtil.MakeNumericUpDownInt(0, 10000, 0, 110, vm, nameof(vm.SilencePaddingMs))), + + MakeRow(UiUtil.MakeLabel(l.OutputSampleRate), l.OutputSampleRateDescription, + UiUtil.MakeNumericUpDownInt(0, 192000, 0, 120, vm, nameof(vm.OutputSampleRate))), + } + }); + + var groupFolder = MakeGroupBox(l.GenerationFolder, MakeGenerationFolderContent(vm, checkBoxDeleteTempFiles)); + + var groupEdgeTts = MakeGroupBox("Edge-TTS", new StackPanel + { + Spacing = 6, + Children = + { + MakeRow(UiUtil.MakeLabel(l.EdgeTtsRate), l.EdgeTtsRateDescription, + UiUtil.MakeTextBox(110, vm, nameof(vm.EdgeTtsRate))), + + MakeRow(UiUtil.MakeLabel(l.EdgeTtsPitch), l.EdgeTtsPitchDescription, + UiUtil.MakeTextBox(110, vm, nameof(vm.EdgeTtsPitch))), + + MakeRow(UiUtil.MakeLabel(l.EdgeTtsVolume), l.EdgeTtsVolumeDescription, + UiUtil.MakeTextBox(110, vm, nameof(vm.EdgeTtsVolume))), + } + }); + groupEdgeTts[!IsVisibleProperty] = new Binding(nameof(vm.IsEdgeTtsEngine)) { Mode = BindingMode.OneWay }; + + var content = new StackPanel + { + Spacing = 10, + Children = { groupAudio, groupOutput, groupFolder, groupEdgeTts }, + }; + + // Keeps OK/Cancel reachable when the window is clamped to a small working area (#14331). + var scrollViewer = new ScrollViewer + { + Content = content, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, }; var buttonOk = UiUtil.MakeButtonOk(vm.OkCommand); var buttonCancel = UiUtil.MakeButtonCancel(vm.CancelCommand); var panelButtons = UiUtil.MakeButtonBar(buttonOk, buttonCancel); - var mainPanel = new StackPanel + var grid = new Grid { - Children = + Margin = UiUtil.MakeWindowMargin(), + RowSpacing = 10, + RowDefinitions = { - content, - panelButtons, - } + new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, + new RowDefinition { Height = GridLength.Auto }, + }, }; + grid.Add(scrollViewer, 0); + grid.Add(panelButtons, 1); - Content = mainPanel; + Content = grid; // initial focus on an input, not an action button - a focused button clicks on bare Space - Activated += delegate { (sectionProAudio.Children[0] as CheckBox)?.Focus(); }; + UiUtil.FocusOnFirstActivation(this, () => checkBoxProAudio.Focus()); } - private static StackPanel MakeSection(AdvancedTtsSettingsViewModel vm, string title, string checkBoxBinding, string description, - string? fieldLabel = null, string? fieldBinding = null, int fieldWidth = 50) + private static CheckBox MakeCheckBox(string title, string binding) { - var checkBox = new CheckBox + return new CheckBox { - Content = title, - FontWeight = FontWeight.SemiBold, - [!CheckBox.IsCheckedProperty] = new Binding(checkBoxBinding) { Mode = BindingMode.TwoWay }, + // A TextBlock (not a bare string) so long translations wrap instead of widening the row. + Content = new TextBlock { Text = title, TextWrapping = TextWrapping.Wrap }, + VerticalAlignment = VerticalAlignment.Center, + [!CheckBox.IsCheckedProperty] = new Binding(binding) { Mode = BindingMode.TwoWay }, }; + } - var descBlock = new TextBlock + private static Label MakeStatusLabel(string statusBinding) + { + return new Label { - Text = description, - TextWrapping = TextWrapping.Wrap, - Opacity = 0.7, - Margin = new Thickness(26, 0, 0, 0), + Opacity = 0.6, + FontStyle = FontStyle.Italic, + VerticalAlignment = VerticalAlignment.Center, + Padding = new Thickness(0), + [!ContentControl.ContentProperty] = new Binding(statusBinding) { Mode = BindingMode.OneWay }, }; + } - var section = new StackPanel + /// + /// One option: its label/check box plus hint icon on the left, its input right-aligned so the + /// inputs of a group line up. + /// + private static Grid MakeRow(Control label, string hint, Control? input = null, Control? status = null) + { + var leftPanel = new StackPanel { - Spacing = 2, - Margin = new Thickness(0, 0, 0, 10), - Children = { checkBox, descBlock }, + Orientation = Orientation.Horizontal, + Spacing = 5, + VerticalAlignment = VerticalAlignment.Center, + Children = { label }, }; - if (fieldLabel != null && fieldBinding != null) + if (status != null) + { + leftPanel.Children.Add(status); + } + + // Screen readers get the hint as help text on the control it belongs to: the check box when + // the option is a toggle, otherwise the input the label names. + leftPanel.Children.Add(UiUtil.MakeHintIcon(hint, label is CheckBox ? label : input ?? label)); + + var grid = new Grid { - var fieldRow = new StackPanel + ColumnSpacing = 10, + ColumnDefinitions = { - Orientation = Orientation.Horizontal, - Margin = new Thickness(26, 4, 0, 0), - Children = - { - new Label { Content = fieldLabel, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0) }, - UiUtil.MakeTextBox(fieldWidth, vm, fieldBinding), - } - }; - section.Children.Add(fieldRow); + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + new ColumnDefinition { Width = GridLength.Auto }, + }, + }; + grid.Add(leftPanel, 0); + + if (input != null) + { + input.HorizontalAlignment = HorizontalAlignment.Right; + grid.Add(input, 0, 1); } - return section; + return grid; } /// - /// Where the per-line clips are written during a run, and whether they are swept when the - /// window closes. Before #13332 they went loose into the system temp folder and stayed there. + /// A sub-label plus input, greyed out while the option it belongs to is off. /// - private static StackPanel MakeGenerationFolderSection(AdvancedTtsSettingsViewModel vm) + private static Control MakeField(string label, Control input, string isEnabledBinding) { - var textBoxFolder = new TextBox + var panel = new StackPanel { + Orientation = Orientation.Horizontal, + Spacing = 5, VerticalAlignment = VerticalAlignment.Center, - Width = 330, - PlaceholderText = Path.GetTempPath(), - [!TextBox.TextProperty] = new Binding(nameof(vm.GenerationFolder)) { Mode = BindingMode.TwoWay, UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged }, - }; - - var buttonBrowse = UiUtil.MakeButtonBrowse(vm.BrowseGenerationFolderCommand, - accessibleName: Se.Language.Video.TextToSpeech.GenerationFolder); - - var deleteCheckBox = new CheckBox - { - Content = Se.Language.Video.TextToSpeech.DeleteTempFiles, - Margin = new Thickness(0, 6, 0, 0), - [!CheckBox.IsCheckedProperty] = new Binding(nameof(vm.DoDeleteTempFiles)) { Mode = BindingMode.TwoWay }, - }; - - return new StackPanel - { - Spacing = 2, - Margin = new Thickness(0, 0, 0, 10), Children = { - new Label - { - Content = Se.Language.Video.TextToSpeech.GenerationFolder, - FontWeight = FontWeight.SemiBold, - }, - new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 5, - Children = { textBoxFolder, buttonBrowse }, - }, - new TextBlock - { - Text = Se.Language.Video.TextToSpeech.GenerationFolderDescription, - TextWrapping = TextWrapping.Wrap, - Opacity = 0.7, - Margin = new Thickness(26, 0, 0, 0), - }, - deleteCheckBox, - new TextBlock - { - Text = Se.Language.Video.TextToSpeech.DeleteTempFilesDescription, - TextWrapping = TextWrapping.Wrap, - Opacity = 0.7, - Margin = new Thickness(26, 0, 0, 0), - }, - } + new Label { Content = label, VerticalAlignment = VerticalAlignment.Center, Padding = new Thickness(0) }, + input, + }, + [!IsEnabledProperty] = new Binding(isEnabledBinding) { Mode = BindingMode.OneWay }, }; + + return panel; } - private static StackPanel MakeSectionWithStatus(AdvancedTtsSettingsViewModel vm, string title, string checkBoxBinding, string statusBinding, string description) + /// + /// Where the per-line clips are written during a run, and whether they are swept when the + /// window closes. Before #13332 they went loose into the system temp folder and stayed there. + /// + private static Control MakeGenerationFolderContent(AdvancedTtsSettingsViewModel vm, CheckBox checkBoxDeleteTempFiles) { - var checkBox = new CheckBox - { - Content = title, - FontWeight = FontWeight.SemiBold, - [!CheckBox.IsCheckedProperty] = new Binding(checkBoxBinding) { Mode = BindingMode.TwoWay }, - }; + var l = Se.Language.Video.TextToSpeech; - var statusLabel = new Label + var textBoxFolder = new TextBox { - Opacity = 0.6, - FontStyle = FontStyle.Italic, VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(5, 0, 0, 0), - [!Label.ContentProperty] = new Binding(statusBinding) { Mode = BindingMode.OneWay }, + HorizontalAlignment = HorizontalAlignment.Stretch, + PlaceholderText = Path.GetTempPath(), + [!TextBox.TextProperty] = new Binding(nameof(vm.GenerationFolder)) { Mode = BindingMode.TwoWay, UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged }, }; + AutomationProperties.SetName(textBoxFolder, l.GenerationFolder); - var headerRow = new StackPanel - { - Orientation = Orientation.Horizontal, - Children = { checkBox, statusLabel }, - }; + var buttonBrowse = UiUtil.MakeButtonBrowse(vm.BrowseGenerationFolderCommand, accessibleName: l.GenerationFolder); - var descBlock = new TextBlock + var folderRow = new Grid { - Text = description, - TextWrapping = TextWrapping.Wrap, - Opacity = 0.7, - Margin = new Thickness(26, 0, 0, 0), + ColumnSpacing = 5, + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = GridLength.Auto }, + }, }; + folderRow.Add(textBoxFolder, 0); + folderRow.Add(buttonBrowse, 0, 1); + folderRow.Add(UiUtil.MakeHintIcon(l.GenerationFolderDescription, textBoxFolder), 0, 2); return new StackPanel { - Spacing = 2, - Margin = new Thickness(0, 0, 0, 10), - Children = { headerRow, descBlock }, + Spacing = 6, + Children = + { + folderRow, + MakeRow(checkBoxDeleteTempFiles, l.DeleteTempFilesDescription), + } }; } - private static StackPanel MakeFieldRow(AdvancedTtsSettingsViewModel vm, string label, string binding, int fieldWidth, string description, string? isVisibleBinding = null) + private static Border MakeGroupBox(string title, Control content) { - var panel = new StackPanel + var header = new TextBlock { - Spacing = 2, - Margin = new Thickness(0, 0, 0, 10), - Children = - { - new StackPanel - { - Orientation = Orientation.Horizontal, - Children = - { - new Label { Content = label, VerticalAlignment = VerticalAlignment.Center, FontWeight = FontWeight.SemiBold, Margin = new Thickness(0, 0, 5, 0) }, - UiUtil.MakeTextBox(fieldWidth, vm, binding), - } - }, - new TextBlock - { - Text = description, - TextWrapping = TextWrapping.Wrap, - Opacity = 0.7, - Margin = new Thickness(26, 0, 0, 0), - }, - } + Text = title, + FontWeight = FontWeight.SemiBold, + Margin = new Thickness(0, 0, 0, 6), }; - if (isVisibleBinding != null) + return UiUtil.MakeBorderForControl(new StackPanel { - panel[!StackPanel.IsVisibleProperty] = new Binding(isVisibleBinding) { Mode = BindingMode.OneWay }; - } - - return panel; + Children = { header, content }, + }); } protected override void OnKeyDown(KeyEventArgs e) diff --git a/src/ui/Features/Video/TextToSpeech/AudioCppTtsSettings/AudioCppTtsSettingsViewModel.cs b/src/ui/Features/Video/TextToSpeech/AudioCppTtsSettings/AudioCppTtsSettingsViewModel.cs new file mode 100644 index 00000000000..c1d76351f1d --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/AudioCppTtsSettings/AudioCppTtsSettingsViewModel.cs @@ -0,0 +1,274 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nikse.SubtitleEdit.Features.Shared; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.DownloadTts; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.ModelLicense; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Download; +using Nikse.SubtitleEdit.Logic.Media; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.AudioCppTtsSettings; + +/// +/// Everything the shared audio.cpp settings dialog needs to know about one engine. Higgs +/// Audio v3 and Fish Audio S2 Pro expose no per-request knobs (both auto-handle language and +/// take their expression from the text itself), so their settings dialogs are the same shape: +/// runtime status, two model quants, voices, folders. IndexTTS 2.5 keeps its own dialog — +/// it has emotion and speed controls this one does not. +/// +public sealed record AudioCppTtsSettingsAdapter( + string EngineName, + string Description, + string ModelKeyDefault, + string ModelKeyAlt, + Func ResolveModelKey, + Func AreModelsInstalled, + Func GetModelsFolder, + Func GetVoicesFolder, + Func IsLicenseAccepted, + ModelLicenseDefinition LicenseDefinition, + Action StartDownloadModels); + +public static class AudioCppTtsSettingsAdapters +{ + public static AudioCppTtsSettingsAdapter Higgs { get; } = new( + EngineName: "Higgs Audio v3 (audio.cpp)", + Description: new HiggsTtsAudioCpp().Description, + ModelKeyDefault: HiggsTtsAudioCpp.ModelKeyQ8_0, + ModelKeyAlt: HiggsTtsAudioCpp.ModelKeyBf16, + ResolveModelKey: HiggsTtsAudioCpp.ResolveModelKey, + AreModelsInstalled: HiggsTtsAudioCpp.AreModelsInstalled, + GetModelsFolder: HiggsTtsAudioCpp.GetSetModelsFolder, + GetVoicesFolder: HiggsTtsAudioCpp.GetSetVoicesFolder, + IsLicenseAccepted: HiggsTtsAudioCpp.IsLicenseAccepted, + LicenseDefinition: HiggsTtsAudioCpp.LicenseDefinition, + StartDownloadModels: (vm, modelKey) => vm.StartDownloadHiggsTtsAudioCppModels(modelKey)); + + public static AudioCppTtsSettingsAdapter Fish { get; } = new( + EngineName: "Fish Audio S2 Pro (audio.cpp)", + Description: new FishTtsAudioCpp().Description, + ModelKeyDefault: FishTtsAudioCpp.ModelKeyQ8_0, + ModelKeyAlt: FishTtsAudioCpp.ModelKeyBf16, + ResolveModelKey: FishTtsAudioCpp.ResolveModelKey, + AreModelsInstalled: FishTtsAudioCpp.AreModelsInstalled, + GetModelsFolder: FishTtsAudioCpp.GetSetModelsFolder, + GetVoicesFolder: FishTtsAudioCpp.GetSetVoicesFolder, + IsLicenseAccepted: FishTtsAudioCpp.IsLicenseAccepted, + LicenseDefinition: FishTtsAudioCpp.LicenseDefinition, + StartDownloadModels: (vm, modelKey) => vm.StartDownloadFishTtsAudioCppModels(modelKey)); +} + +public partial class AudioCppTtsSettingsViewModel : ObservableObject +{ + // A SolidColorBrush set as Shape.Fill is parented into that shape's visual tree, so the + // same instance can't be shared across bound Ellipses. Fresh brush per assignment. + private static IBrush Green() => new SolidColorBrush(Color.FromRgb(0x4C, 0xAF, 0x50)); + private static IBrush Amber() => new SolidColorBrush(Color.FromRgb(0xFF, 0x98, 0x00)); + private static IBrush Red() => new SolidColorBrush(Color.FromRgb(0xF4, 0x43, 0x36)); + private static IBrush Grey() => new SolidColorBrush(Color.FromRgb(0x9E, 0x9E, 0x9E)); + + private readonly IWindowService _windowService; + private readonly IFolderHelper _folderHelper; + + [ObservableProperty] private string _engineLabel = string.Empty; + [ObservableProperty] private IBrush _engineBrush = Grey(); + [ObservableProperty] private string _engineDownloadButtonText = string.Empty; + [ObservableProperty] private bool _isEngineInstalled; + + [ObservableProperty] private string _modelDefaultLabel = string.Empty; + [ObservableProperty] private IBrush _modelDefaultBrush = Grey(); + [ObservableProperty] private string _modelAltLabel = string.Empty; + [ObservableProperty] private IBrush _modelAltBrush = Grey(); + + [ObservableProperty] private string _voicesLabel = string.Empty; + [ObservableProperty] private string _modelsFolder = string.Empty; + [ObservableProperty] private string _voicesFolder = string.Empty; + + public AudioCppTtsSettingsAdapter Adapter { get; private set; } = AudioCppTtsSettingsAdapters.Higgs; + + public Window? Window { get; set; } + public bool OkPressed { get; private set; } + + public AudioCppTtsSettingsViewModel(IWindowService windowService, IFolderHelper folderHelper) + { + _windowService = windowService; + _folderHelper = folderHelper; + } + + public void Initialize(AudioCppTtsSettingsAdapter adapter) + { + Adapter = adapter; + ModelsFolder = adapter.GetModelsFolder(); + VoicesFolder = adapter.GetVoicesFolder(); + Refresh(); + } + + private void Refresh() + { + var exe = AudioCppRuntime.GetServerExecutable(); + IsEngineInstalled = File.Exists(exe); + + var backend = Se.Settings.Video.TextToSpeech.IndexTts25AudioCppBackend; + var backendSuffix = string.IsNullOrEmpty(backend) ? string.Empty : $" ({backend})"; + + if (!IsEngineInstalled) + { + EngineLabel = string.Format(Se.Language.Video.TtsEngineNotInstalled, "audio.cpp"); + EngineBrush = Red(); + EngineDownloadButtonText = string.Format(Se.Language.General.DownloadX, "audio.cpp"); + } + else if (DownloadHashManager.GetSidecarStatus(Path.GetDirectoryName(exe) ?? string.Empty) == DownloadHashManager.UpdateStatus.UpdateAvailable) + { + EngineLabel = string.Format(Se.Language.Video.TtsEngineUpdateAvailable, "audio.cpp" + backendSuffix); + EngineBrush = Amber(); + EngineDownloadButtonText = string.Format(Se.Language.Video.TtsUpdateX, "audio.cpp"); + } + else + { + EngineLabel = "audio.cpp" + backendSuffix; + EngineBrush = Green(); + EngineDownloadButtonText = string.Format(Se.Language.General.ReDownloadX, "audio.cpp"); + } + + ApplyModelStatus( + Adapter.AreModelsInstalled(Adapter.ModelKeyDefault), + label => ModelDefaultLabel = label, + brush => ModelDefaultBrush = brush); + + ApplyModelStatus( + Adapter.AreModelsInstalled(Adapter.ModelKeyAlt), + label => ModelAltLabel = label, + brush => ModelAltBrush = brush); + + try + { + var wavCount = Directory.Exists(VoicesFolder) + ? Directory.GetFiles(VoicesFolder, "*.wav").Length + : 0; + VoicesLabel = wavCount == 0 + ? "No voices imported" + : (wavCount == 1 ? "1 voice imported" : $"{wavCount} voices imported"); + } + catch + { + VoicesLabel = string.Empty; + } + } + + /// + /// Unlike the CrispASR engines there is no auto-download fallback: audio.cpp is pointed at + /// a model directory and fails if the GGUF is not there, so a missing model is simply + /// "Not downloaded" rather than "Auto-download on first use". + /// + private static void ApplyModelStatus(bool installed, Action setLabel, Action setBrush) + { + if (installed) + { + setLabel("Installed"); + setBrush(Green()); + return; + } + + setLabel("Not downloaded"); + setBrush(Grey()); + } + + [RelayCommand] + private async Task RedownloadEngine() + { + if (Window == null) + { + return; + } + + await TtsVoiceInstaller.EnsureAudioCppRuntime(Window, _windowService, forceRedownload: true, Adapter.EngineName); + Refresh(); + } + + [RelayCommand] + private async Task DownloadModel(string? modelKey) + { + if (Window == null) + { + return; + } + + var resolved = Adapter.ResolveModelKey(modelKey); + + // Same gate as the install flow: nothing is fetched before the model licence is + // accepted, including a download started from this dialog. + if (!Adapter.IsLicenseAccepted()) + { + var licenseResult = await _windowService.ShowDialogAsync( + Window, vm => vm.Initialize(Adapter.LicenseDefinition)); + if (!licenseResult.OkPressed || !Adapter.IsLicenseAccepted()) + { + return; + } + } + + await _windowService.ShowDialogAsync( + Window, vm => Adapter.StartDownloadModels(vm, resolved)); + Refresh(); + } + + [RelayCommand] + private async Task OpenModelsFolder() + { + if (Window == null || string.IsNullOrEmpty(ModelsFolder)) + { + return; + } + + try + { + await _folderHelper.OpenFolder(Window, ModelsFolder); + } + catch + { + // best-effort UX + } + } + + [RelayCommand] + private async Task OpenVoicesFolder() + { + if (Window == null || string.IsNullOrEmpty(VoicesFolder)) + { + return; + } + + try + { + await _folderHelper.OpenFolder(Window, VoicesFolder); + } + catch + { + // best-effort UX + } + } + + [RelayCommand] + private void Ok() + { + OkPressed = true; + Window?.Close(); + } + + internal void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + e.Handled = true; + Window?.Close(); + } + } +} diff --git a/src/ui/Features/Video/TextToSpeech/AudioCppTtsSettings/AudioCppTtsSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/AudioCppTtsSettings/AudioCppTtsSettingsWindow.cs new file mode 100644 index 00000000000..675fc1cf6db --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/AudioCppTtsSettings/AudioCppTtsSettingsWindow.cs @@ -0,0 +1,233 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.AudioCppTtsSettings; + +/// +/// Settings dialog shared by the audio.cpp engines that have no per-request knobs (Higgs +/// Audio v3, Fish Audio S2 Pro): runtime status with re-download, the two model quants, +/// imported-voices count, and folder shortcuts. Which engine it shows comes from the view +/// model's , handed to Initialize before this +/// constructor runs. +/// +public class AudioCppTtsSettingsWindow : Window +{ + private const int LabelWidth = 150; + private const int ValueWidth = 360; + + private readonly AudioCppTtsSettingsViewModel _vm; + + public AudioCppTtsSettingsWindow(AudioCppTtsSettingsViewModel vm) + { + UiUtil.InitializeWindow(this, GetType().Name); + Title = vm.Adapter.EngineName + " settings"; + SizeToContent = SizeToContent.WidthAndHeight; + CanResize = false; + MinWidth = 620; + + _vm = vm; + vm.Window = this; + DataContext = vm; + + Content = BuildContent(vm); + + var ok = UiUtil.MakeButtonOk(vm.OkCommand); + UiUtil.FocusOnFirstActivation(this, ok); + } + + private Border BuildContent(AudioCppTtsSettingsViewModel vm) + { + var stack = new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 14, + Children = { BuildHeader(vm), BuildDetails(vm), BuildActions(vm) }, + }; + + var outerGrid = new Grid { Margin = UiUtil.MakeWindowMargin() }; + outerGrid.Children.Add(stack); + + return new Border + { + Child = outerGrid, + Padding = new Thickness(4), + }; + } + + private static StackPanel BuildHeader(AudioCppTtsSettingsViewModel vm) + { + var title = new TextBlock + { + Text = vm.Adapter.EngineName, + FontSize = 18, + FontWeight = FontWeight.SemiBold, + }; + + var subtitle = new TextBlock + { + Text = vm.Adapter.Description, + FontSize = 12, + Opacity = 0.75, + Margin = new Thickness(0, 2, 0, 0), + }; + + return new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 0, + Children = { title, subtitle }, + }; + } + + private static Border BuildDetails(AudioCppTtsSettingsViewModel vm) + { + var grid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(LabelWidth, GridUnitType.Pixel) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + }, + RowDefinitions = + { + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // engine + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // default model + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // alt model + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // voices + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // folder + }, + ColumnSpacing = 12, + RowSpacing = 10, + }; + + grid.Add(MakeLabel(Se.Language.General.Engine), 0, 0); + var enginePanel = MakeStatusPanel(nameof(vm.EngineBrush), nameof(vm.EngineLabel)); + var engineButton = UiUtil.MakeButton(string.Empty, vm.RedownloadEngineCommand) + .WithIconLeftBindText(IconNames.Download, nameof(vm.EngineDownloadButtonText)) + .WithMarginLeft(12); + enginePanel.Children.Add(engineButton); + grid.Add(enginePanel, 0, 1); + + grid.Add(MakeLabel("Model " + vm.Adapter.ModelKeyDefault), 1, 0); + var defaultPanel = MakeStatusPanel(nameof(vm.ModelDefaultBrush), nameof(vm.ModelDefaultLabel)); + defaultPanel.Children.Add(UiUtil.MakeButton(string.Empty, vm.DownloadModelCommand, vm.Adapter.ModelKeyDefault) + .WithIconLeft(IconNames.Download) + .WithMarginLeft(12)); + grid.Add(defaultPanel, 1, 1); + + grid.Add(MakeLabel("Model " + vm.Adapter.ModelKeyAlt), 2, 0); + var altPanel = MakeStatusPanel(nameof(vm.ModelAltBrush), nameof(vm.ModelAltLabel)); + altPanel.Children.Add(UiUtil.MakeButton(string.Empty, vm.DownloadModelCommand, vm.Adapter.ModelKeyAlt) + .WithIconLeft(IconNames.Download) + .WithMarginLeft(12)); + grid.Add(altPanel, 2, 1); + + grid.Add(MakeLabel(Se.Language.Video.Voices), 3, 0); + grid.Add(new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + FontWeight = FontWeight.SemiBold, + [!TextBlock.TextProperty] = new Binding(nameof(vm.VoicesLabel)), + }, 3, 1); + + grid.Add(MakeLabel(Se.Language.General.InstallFolder), 4, 0); + grid.Add(new TextBox + { + IsReadOnly = true, + Width = ValueWidth, + BorderThickness = new Thickness(0), + Background = Brushes.Transparent, + Padding = new Thickness(0), + VerticalContentAlignment = VerticalAlignment.Center, + FontSize = 12, + [!TextBox.TextProperty] = new Binding(nameof(vm.ModelsFolder)), + }, 4, 1); + + return new Border + { + Child = grid, + Padding = new Thickness(14), + CornerRadius = new CornerRadius(6), + BorderThickness = new Thickness(1), + BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0x80, 0x80, 0x80)), + }; + } + + private static StackPanel MakeStatusPanel(string brushBindingPath, string labelBindingPath) + { + var dot = new Ellipse + { + Width = 10, + Height = 10, + VerticalAlignment = VerticalAlignment.Center, + [!Shape.FillProperty] = new Binding(brushBindingPath), + }; + var text = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + FontWeight = FontWeight.SemiBold, + Margin = new Thickness(8, 0, 0, 0), + [!TextBlock.TextProperty] = new Binding(labelBindingPath), + }; + return new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { dot, text }, + }; + } + + private static Control BuildActions(AudioCppTtsSettingsViewModel vm) + { + var openModelsFolder = UiUtil.MakeButton(Se.Language.General.OpenContainingFolder, vm.OpenModelsFolderCommand).WithIconLeft(IconNames.FolderOpen); + var openVoicesFolder = UiUtil.MakeButton(Se.Language.Video.Voices, vm.OpenVoicesFolderCommand).WithIconLeft(IconNames.FolderOpen); + var close = UiUtil.MakeButtonOk(vm.OkCommand); + + var leftPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + Children = { openModelsFolder, openVoicesFolder }, + }; + + var rightPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8, + Children = { close }, + }; + + var grid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + }, + }; + grid.Add(leftPanel, 0, 0); + grid.Add(rightPanel, 0, 2); + return grid; + } + + private static TextBlock MakeLabel(string text) => new() + { + Text = text, + Opacity = 0.7, + VerticalAlignment = VerticalAlignment.Center, + }; + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + _vm.OnKeyDown(e); + } +} diff --git a/src/ui/Features/Video/TextToSpeech/AutoCast/AutoCastSpeakersWindow.cs b/src/ui/Features/Video/TextToSpeech/AutoCast/AutoCastSpeakersWindow.cs index 930a517ce7d..817c01e638c 100644 --- a/src/ui/Features/Video/TextToSpeech/AutoCast/AutoCastSpeakersWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/AutoCast/AutoCastSpeakersWindow.cs @@ -61,6 +61,10 @@ public AutoCastSpeakersWindow(AutoCastSpeakersViewModel vm) Content = root; KeyDown += (_, e) => vm.OnKeyDown(e); + // SaveWindowPosition is the only writer of the stored position, so without this the + // Restore above could never find anything: this resizable table dialog reopened at its + // hard-coded default every run. The select-lines dialogs pair the two handlers. + Closing += (_, _) => UiUtil.SaveWindowPosition(this); Loaded += (_, _) => UiUtil.RestoreWindowPosition(this); } diff --git a/src/ui/Features/Video/TextToSpeech/AutoCast/SpeakerLabelParser.cs b/src/ui/Features/Video/TextToSpeech/AutoCast/SpeakerLabelParser.cs index 4c36deb3158..9f188895fdf 100644 --- a/src/ui/Features/Video/TextToSpeech/AutoCast/SpeakerLabelParser.cs +++ b/src/ui/Features/Video/TextToSpeech/AutoCast/SpeakerLabelParser.cs @@ -95,6 +95,10 @@ public static int MoveLabelsToActors(Subtitle subtitle) /// what matters is which speaker is talking for most of the line. Lines that overlap nothing /// (music, on-screen text, a line before the first segment) are left without an actor rather /// than guessed at. + /// + /// A segment's speaker is read from its actor field first: by the time the auto-cast flow gets + /// here, has already moved the label out of the text, so + /// re-parsing the text alone would find no speakers and quietly assign nothing. /// public static Dictionary AssignSpeakersByOverlap( IReadOnlyList lines, @@ -102,7 +106,7 @@ public static Dictionary AssignSpeakersByOverlap( { var byLine = new Dictionary(); var labelled = segments - .Select(s => (Speaker: TrySplit(s.Text, out var speaker, out _) ? speaker : string.Empty, Segment: s)) + .Select(s => (Speaker: GetSegmentSpeaker(s), Segment: s)) .Where(s => !string.IsNullOrEmpty(s.Speaker)) .ToList(); if (labelled.Count == 0) @@ -134,4 +138,14 @@ public static Dictionary AssignSpeakersByOverlap( return byLine; } + + private static string GetSegmentSpeaker(Paragraph segment) + { + if (!string.IsNullOrWhiteSpace(segment.Actor)) + { + return segment.Actor.Trim(); + } + + return TrySplit(segment.Text, out var speaker, out _) ? speaker : string.Empty; + } } diff --git a/src/ui/Features/Video/TextToSpeech/AutoCast/SpeakerReferenceBuilder.cs b/src/ui/Features/Video/TextToSpeech/AutoCast/SpeakerReferenceBuilder.cs index cd602a709bb..bb21f705d91 100644 --- a/src/ui/Features/Video/TextToSpeech/AutoCast/SpeakerReferenceBuilder.cs +++ b/src/ui/Features/Video/TextToSpeech/AutoCast/SpeakerReferenceBuilder.cs @@ -87,6 +87,7 @@ public static List PickReferenceLines(IReadOnlyList speake Directory.CreateDirectory(partsFolder); var parts = new List(); + var partTexts = new List(); for (var i = 0; i < picked.Count; i++) { // Exactly the line, with no growing into the gaps around it: these lines are far @@ -102,6 +103,9 @@ public static List PickReferenceLines(IReadOnlyList speake if (cut) { parts.Add(partFileName); + // Keep the text beside the clip it belongs to: the transcript has to describe the + // audio that actually ends up in the reference, not every line we tried to cut. + partTexts.Add(Utilities.UnbreakLine(HtmlUtil.RemoveHtmlTags(picked[i].Text ?? string.Empty, alsoSsaTags: true))); } } @@ -111,20 +115,24 @@ public static List PickReferenceLines(IReadOnlyList speake } var referenceFileName = Path.Combine(outputFolder, safeName + ".wav"); + var referenceTexts = partTexts; if (parts.Count == 1) { File.Move(parts[0], referenceFileName, overwrite: true); } else if (!await ConcatAsync(parts, referenceFileName, partsFolder, cancellationToken)) { - // Joining failed - one good part is still a usable reference. + // Joining failed - one good part is still a usable reference, but then the transcript + // must shrink to that one part too. Describing eight lines beside ~2 seconds of audio + // is exactly the ref-text/ref-audio mismatch that makes a clone come out garbled. File.Move(parts[0], referenceFileName, overwrite: true); + referenceTexts = partTexts.Take(1).ToList(); } // The transcript of what the joined audio says, in the same order. await File.WriteAllTextAsync( Path.ChangeExtension(referenceFileName, ".txt"), - string.Join(' ', picked.Select(p => Utilities.UnbreakLine(HtmlUtil.RemoveHtmlTags(p.Text ?? string.Empty, alsoSsaTags: true))).Where(t => t.Length > 0)), + string.Join(' ', referenceTexts.Where(t => t.Length > 0)), cancellationToken); TryDeleteFolder(partsFolder); diff --git a/src/ui/Features/Video/TextToSpeech/ChatterboxTtsSettings/ChatterboxTtsSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/ChatterboxTtsSettings/ChatterboxTtsSettingsWindow.cs index 4451ee58be7..380e4d08766 100644 --- a/src/ui/Features/Video/TextToSpeech/ChatterboxTtsSettings/ChatterboxTtsSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/ChatterboxTtsSettings/ChatterboxTtsSettingsWindow.cs @@ -34,7 +34,7 @@ public ChatterboxTtsSettingsWindow(ChatterboxTtsSettingsViewModel vm) Content = BuildContent(vm); var ok = UiUtil.MakeButtonOk(vm.OkCommand); - Activated += delegate { ok.Focus(); }; + UiUtil.FocusOnFirstActivation(this, ok); } private Border BuildContent(ChatterboxTtsSettingsViewModel vm) @@ -112,9 +112,8 @@ private static Border BuildDetails(ChatterboxTtsSettingsViewModel vm) grid.Add(MakeLabel(Se.Language.General.Engine), 0, 0); var enginePanel = MakeStatusPanel(nameof(vm.EngineBrush), nameof(vm.EngineLabel)); var engineButton = UiUtil.MakeButton(string.Empty, vm.RedownloadEngineCommand) - .WithIconLeft(IconNames.Download) + .WithIconLeftBindText(IconNames.Download, nameof(vm.EngineDownloadButtonText)) .WithMarginLeft(12); - engineButton.Bind(ContentControl.ContentProperty, new Binding(nameof(vm.EngineDownloadButtonText))); enginePanel.Children.Add(engineButton); grid.Add(enginePanel, 0, 1); @@ -133,7 +132,9 @@ private static Border BuildDetails(ChatterboxTtsSettingsViewModel vm) FontSize = 12, [!TextBox.TextProperty] = new Binding(nameof(vm.ModelsFolder)), }; - grid.Add(folderText, 3, 1); + // Row 2, beside its own label - every sibling settings window pairs (N,0) with (N,1). + // At row 3 the "Install folder" caption sat alone and the path appeared a row lower. + grid.Add(folderText, 2, 1); // Language spoken in imported reference WAVs — sent as `source_lang` so cross-lingual // cloning engages when the target language differs. Chatterbox clones from the WAV alone @@ -187,9 +188,7 @@ private static ItemsControl MakeModelList(ChatterboxTtsSettingsViewModel vm) nameof(ChatterboxModelStatusViewModel.StatusLabel)); status.Width = 110; var button = UiUtil.MakeButton(string.Empty) - .WithIconLeft(IconNames.Download); - button.Bind(ContentControl.ContentProperty, - new Binding(nameof(ChatterboxModelStatusViewModel.DownloadButtonText))); + .WithIconLeftBindText(IconNames.Download, nameof(ChatterboxModelStatusViewModel.DownloadButtonText)); button.Bind(Button.CommandProperty, new Binding(nameof(ChatterboxModelStatusViewModel.DownloadCommand))); diff --git a/src/ui/Features/Video/TextToSpeech/Confucius4TtsCrispAsrSettings/Confucius4TtsCrispAsrSettingsViewModel.cs b/src/ui/Features/Video/TextToSpeech/Confucius4TtsCrispAsrSettings/Confucius4TtsCrispAsrSettingsViewModel.cs new file mode 100644 index 00000000000..d0ceeaaa12a --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/Confucius4TtsCrispAsrSettings/Confucius4TtsCrispAsrSettingsViewModel.cs @@ -0,0 +1,266 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nikse.SubtitleEdit.Features.Shared; +using Nikse.SubtitleEdit.Features.Video.SpeechToText.Engines; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Download; +using Nikse.SubtitleEdit.Logic.Media; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Confucius4TtsCrispAsrSettings; + +public partial class Confucius4TtsCrispAsrSettingsViewModel : ObservableObject +{ + // A SolidColorBrush set as Shape.Fill is parented into that shape's visual tree, so the same + // instance can't be shared across bound Ellipses (only one dot would render). Fresh brush per + // assignment instead. + private static IBrush Green() => new SolidColorBrush(Color.FromRgb(0x4C, 0xAF, 0x50)); + private static IBrush Amber() => new SolidColorBrush(Color.FromRgb(0xFF, 0x98, 0x00)); + private static IBrush Red() => new SolidColorBrush(Color.FromRgb(0xF4, 0x43, 0x36)); + private static IBrush Grey() => new SolidColorBrush(Color.FromRgb(0x9E, 0x9E, 0x9E)); + + private readonly IWindowService _windowService; + private readonly IFolderHelper _folderHelper; + + [ObservableProperty] private string _engineLabel = string.Empty; + [ObservableProperty] private IBrush _engineBrush = Grey(); + [ObservableProperty] private string _engineDownloadButtonText = string.Empty; + + [ObservableProperty] private string _coreQ8_0Label = string.Empty; + [ObservableProperty] private IBrush _coreQ8_0Brush = Grey(); + + [ObservableProperty] private string _coreF16Label = string.Empty; + [ObservableProperty] private IBrush _coreF16Brush = Grey(); + + [ObservableProperty] private string _vocoderLabel = string.Empty; + [ObservableProperty] private IBrush _vocoderBrush = Grey(); + + [ObservableProperty] private string _w2vLabel = string.Empty; + [ObservableProperty] private IBrush _w2vBrush = Grey(); + + [ObservableProperty] private string _voicesLabel = string.Empty; + + [ObservableProperty] private double _odeSteps = Confucius4TtsCrispAsr.DefaultOdeSteps; + + public string OdeStepsLabel => $"{(int)OdeSteps} steps"; + + partial void OnOdeStepsChanged(double value) + { + OnPropertyChanged(nameof(OdeStepsLabel)); + Se.Settings.Video.TextToSpeech.Confucius4TtsCrispAsrOdeSteps = + Math.Clamp((int)Math.Round(value), Confucius4TtsCrispAsr.MinOdeSteps, Confucius4TtsCrispAsr.MaxOdeSteps); + } + + [ObservableProperty] private string _modelsFolder = string.Empty; + [ObservableProperty] private string _voicesFolder = string.Empty; + [ObservableProperty] private bool _isEngineInstalled; + + public Window? Window { get; set; } + public bool OkPressed { get; private set; } + + public Confucius4TtsCrispAsrSettingsViewModel(IWindowService windowService, IFolderHelper folderHelper) + { + _windowService = windowService; + _folderHelper = folderHelper; + } + + public void Initialize() + { + ModelsFolder = Confucius4TtsCrispAsr.GetSetModelsFolder(); + VoicesFolder = Confucius4TtsCrispAsr.GetSetVoicesFolder(); + OdeSteps = Confucius4TtsCrispAsr.ResolveOdeSteps(); + Refresh(); + } + + private void Refresh() + { + var exe = Confucius4TtsCrispAsr.GetCrispAsrExecutable(); + IsEngineInstalled = File.Exists(exe); + + if (!IsEngineInstalled) + { + EngineLabel = string.Format(Se.Language.Video.TtsEngineNotInstalled, "CrispASR"); + EngineBrush = Red(); + EngineDownloadButtonText = string.Format(Se.Language.General.DownloadX, "CrispASR"); + } + else if (Confucius4TtsCrispAsr.GetEngineUpdateStatus() == DownloadHashManager.UpdateStatus.UpdateAvailable) + { + EngineLabel = string.Format(Se.Language.Video.TtsEngineUpdateAvailable, "CrispASR"); + EngineBrush = Amber(); + EngineDownloadButtonText = string.Format(Se.Language.Video.TtsUpdateX, "CrispASR"); + } + else + { + EngineLabel = "CrispASR"; + EngineBrush = Green(); + EngineDownloadButtonText = string.Format(Se.Language.General.ReDownloadX, "CrispASR"); + } + + // Append the installed CrispASR version asynchronously — the probe spawns a child process + // and the first call can take a few hundred ms. + if (IsEngineInstalled) + { + var baseLabel = EngineLabel; + _ = Task.Run(() => + { + try + { + var version = CrispAsrVersion.TryGet(exe); + if (string.IsNullOrEmpty(version)) + { + return; + } + Dispatcher.UIThread.Post(() => + { + if (EngineLabel == baseLabel) + { + EngineLabel = baseLabel.Replace("CrispASR", $"CrispASR v{version}"); + } + }); + } + catch (Exception ex) + { + Se.LogError(ex, "Confucius4TtsCrispAsrSettings: CrispASR version probe failed"); + } + }); + } + + var coreQ8_0Ok = Confucius4TtsCrispAsr.IsValidLocalModelFile( + Confucius4TtsCrispAsr.GetT2sPath(Confucius4TtsCrispAsr.ModelKeyQ8_0), Confucius4TtsCrispAsr.T2sQ8_0FileName) + && Confucius4TtsCrispAsr.IsValidLocalModelFile( + Confucius4TtsCrispAsr.GetS2aPath(Confucius4TtsCrispAsr.ModelKeyQ8_0), Confucius4TtsCrispAsr.S2aQ8_0FileName); + ApplyModelStatus(coreQ8_0Ok, + IsEngineInstalled, + label => CoreQ8_0Label = label, + brush => CoreQ8_0Brush = brush); + + var coreF16Ok = Confucius4TtsCrispAsr.IsValidLocalModelFile( + Confucius4TtsCrispAsr.GetT2sPath(Confucius4TtsCrispAsr.ModelKeyF16), Confucius4TtsCrispAsr.T2sF16FileName) + && Confucius4TtsCrispAsr.IsValidLocalModelFile( + Confucius4TtsCrispAsr.GetS2aPath(Confucius4TtsCrispAsr.ModelKeyF16), Confucius4TtsCrispAsr.S2aF16FileName); + ApplyModelStatus(coreF16Ok, + IsEngineInstalled, + label => CoreF16Label = label, + brush => CoreF16Brush = brush); + + var vocoderPath = Confucius4TtsCrispAsr.GetVocoderPath(); + ApplyModelStatus(Confucius4TtsCrispAsr.IsValidLocalModelFile(vocoderPath, Confucius4TtsCrispAsr.VocoderFileName), + IsEngineInstalled, + label => VocoderLabel = label, + brush => VocoderBrush = brush); + + var w2vPath = Confucius4TtsCrispAsr.GetW2vPath(); + ApplyModelStatus(Confucius4TtsCrispAsr.IsValidLocalModelFile(w2vPath, Confucius4TtsCrispAsr.W2vFileName), + IsEngineInstalled, + label => W2vLabel = label, + brush => W2vBrush = brush); + + try + { + var wavCount = Directory.Exists(VoicesFolder) + ? Directory.GetFiles(VoicesFolder, "*.wav").Length + : 0; + VoicesLabel = wavCount == 0 + ? "No voices imported" + : (wavCount == 1 ? "1 voice imported" : $"{wavCount} voices imported"); + } + catch + { + VoicesLabel = string.Empty; + } + } + + private static void ApplyModelStatus(bool fileInstalled, bool engineInstalled, Action setLabel, Action setBrush) + { + if (fileInstalled) + { + setLabel("Installed"); + setBrush(Green()); + return; + } + + if (!engineInstalled) + { + // No CrispASR runtime means there is nothing to auto-download into, so don't promise + // a download that can't happen until the user installs CrispASR. + setLabel("CrispASR required"); + setBrush(Grey()); + return; + } + + setLabel("Auto-download on first use"); + setBrush(Grey()); + } + + [RelayCommand] + private async Task RedownloadEngine() + { + if (Window == null) + { + return; + } + + await TtsVoiceInstaller.EnsureCrispAsrForConfucius4Tts(Window, _windowService, forceRedownload: true); + Refresh(); + } + + [RelayCommand] + private async Task OpenModelsFolder() + { + if (Window == null || string.IsNullOrEmpty(ModelsFolder)) + { + return; + } + + try + { + await _folderHelper.OpenFolder(Window, ModelsFolder); + } + catch + { + // best-effort UX + } + } + + [RelayCommand] + private async Task OpenVoicesFolder() + { + if (Window == null || string.IsNullOrEmpty(VoicesFolder)) + { + return; + } + + try + { + await _folderHelper.OpenFolder(Window, VoicesFolder); + } + catch + { + // best-effort UX + } + } + + [RelayCommand] + private void Ok() + { + OkPressed = true; + Window?.Close(); + } + + internal void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + e.Handled = true; + Window?.Close(); + } + } +} diff --git a/src/ui/Features/Video/TextToSpeech/Confucius4TtsCrispAsrSettings/Confucius4TtsCrispAsrSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/Confucius4TtsCrispAsrSettings/Confucius4TtsCrispAsrSettingsWindow.cs new file mode 100644 index 00000000000..c77d372270e --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/Confucius4TtsCrispAsrSettings/Confucius4TtsCrispAsrSettingsWindow.cs @@ -0,0 +1,271 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Confucius4TtsCrispAsrSettings; + +public class Confucius4TtsCrispAsrSettingsWindow : Window +{ + private const int LabelWidth = 150; + private const int ValueWidth = 360; + + private readonly Confucius4TtsCrispAsrSettingsViewModel _vm; + + public Confucius4TtsCrispAsrSettingsWindow(Confucius4TtsCrispAsrSettingsViewModel vm) + { + UiUtil.InitializeWindow(this, GetType().Name); + Title = string.Format(Se.Language.Video.TtsCrispAsrSettingsTitle, "Confucius4-TTS"); + SizeToContent = SizeToContent.WidthAndHeight; + CanResize = false; + MinWidth = 580; + + _vm = vm; + vm.Window = this; + DataContext = vm; + + Content = BuildContent(vm); + + var ok = UiUtil.MakeButtonOk(vm.OkCommand); + UiUtil.FocusOnFirstActivation(this, ok); + } + + private Border BuildContent(Confucius4TtsCrispAsrSettingsViewModel vm) + { + var header = BuildHeader(); + var details = BuildDetails(vm); + var actions = BuildActions(vm); + + var stack = new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 14, + Children = { header, details, actions }, + }; + + var outerGrid = new Grid + { + Margin = UiUtil.MakeWindowMargin(), + }; + outerGrid.Children.Add(stack); + + return new Border + { + Child = outerGrid, + Padding = new Thickness(4), + }; + } + + private static StackPanel BuildHeader() + { + var title = new TextBlock + { + Text = "Confucius4-TTS (CrispASR)", + FontSize = 18, + FontWeight = FontWeight.SemiBold, + }; + + var subtitle = new TextBlock + { + Text = new Confucius4TtsCrispAsr().Description, + FontSize = 12, + Opacity = 0.75, + Margin = new Thickness(0, 2, 0, 0), + }; + + return new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 0, + Children = { title, subtitle }, + }; + } + + private static Border BuildDetails(Confucius4TtsCrispAsrSettingsViewModel vm) + { + var grid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(LabelWidth, GridUnitType.Pixel) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + }, + RowDefinitions = + { + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + }, + ColumnSpacing = 12, + RowSpacing = 10, + }; + + grid.Add(MakeLabel(Se.Language.General.Engine), 0, 0); + var enginePanel = MakeStatusPanel(nameof(vm.EngineBrush), nameof(vm.EngineLabel)); + var engineButton = UiUtil.MakeButton(string.Empty, vm.RedownloadEngineCommand) + .WithIconLeftBindText(IconNames.Download, nameof(vm.EngineDownloadButtonText)) + .WithMarginLeft(12); + enginePanel.Children.Add(engineButton); + grid.Add(enginePanel, 0, 1); + + grid.Add(MakeLabel("Model " + Confucius4TtsCrispAsr.ModelKeyQ8_0), 1, 0); + grid.Add(MakeStatusPanel(nameof(vm.CoreQ8_0Brush), nameof(vm.CoreQ8_0Label)), 1, 1); + + grid.Add(MakeLabel("Model " + Confucius4TtsCrispAsr.ModelKeyF16), 2, 0); + grid.Add(MakeStatusPanel(nameof(vm.CoreF16Brush), nameof(vm.CoreF16Label)), 2, 1); + + grid.Add(MakeLabel("BigVGAN vocoder"), 3, 0); + grid.Add(MakeStatusPanel(nameof(vm.VocoderBrush), nameof(vm.VocoderLabel)), 3, 1); + + grid.Add(MakeLabel("w2v-BERT encoder"), 4, 0); + grid.Add(MakeStatusPanel(nameof(vm.W2vBrush), nameof(vm.W2vLabel)), 4, 1); + + grid.Add(MakeLabel(Se.Language.Video.Voices), 5, 0); + var voicesText = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + FontWeight = FontWeight.SemiBold, + [!TextBlock.TextProperty] = new Binding(nameof(vm.VoicesLabel)), + }; + grid.Add(voicesText, 5, 1); + + grid.Add(MakeLabel("Quality"), 6, 0); + grid.Add(MakeOdeStepsPanel(), 6, 1); + + grid.Add(MakeLabel(Se.Language.General.InstallFolder), 7, 0); + var folderText = new TextBox + { + IsReadOnly = true, + Width = ValueWidth, + BorderThickness = new Thickness(0), + Background = Brushes.Transparent, + Padding = new Thickness(0), + VerticalContentAlignment = VerticalAlignment.Center, + FontSize = 12, + [!TextBox.TextProperty] = new Binding(nameof(vm.ModelsFolder)), + }; + grid.Add(folderText, 7, 1); + + return new Border + { + Child = grid, + Padding = new Thickness(14), + CornerRadius = new CornerRadius(6), + BorderThickness = new Thickness(1), + BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0x80, 0x80, 0x80)), + }; + } + + /// + /// Flow-matching Euler steps for the S2A stage — the quality/speed knob this backend exposes + /// (--tts-steps; CrispASR default 20, upstream reference samples at 25). + /// + private static StackPanel MakeOdeStepsPanel() + { + var slider = new Slider + { + Minimum = Confucius4TtsCrispAsr.MinOdeSteps, + Maximum = Confucius4TtsCrispAsr.MaxOdeSteps, + Width = 220, + TickFrequency = 1, + IsSnapToTickEnabled = true, + VerticalAlignment = VerticalAlignment.Center, + [!Slider.ValueProperty] = new Binding(nameof(Confucius4TtsCrispAsrSettingsViewModel.OdeSteps)), + }; + var label = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + FontWeight = FontWeight.SemiBold, + Margin = new Thickness(8, 0, 0, 0), + Width = 80, + [!TextBlock.TextProperty] = new Binding(nameof(Confucius4TtsCrispAsrSettingsViewModel.OdeStepsLabel)), + }; + return new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { slider, label }, + }; + } + + private static StackPanel MakeStatusPanel(string brushBindingPath, string labelBindingPath) + { + var dot = new Ellipse + { + Width = 10, + Height = 10, + VerticalAlignment = VerticalAlignment.Center, + [!Ellipse.FillProperty] = new Binding(brushBindingPath), + }; + var text = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + FontWeight = FontWeight.SemiBold, + Margin = new Thickness(8, 0, 0, 0), + [!TextBlock.TextProperty] = new Binding(labelBindingPath), + }; + return new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { dot, text }, + }; + } + + private static Grid BuildActions(Confucius4TtsCrispAsrSettingsViewModel vm) + { + var openModelsFolder = UiUtil.MakeButton(Se.Language.General.OpenContainingFolder, vm.OpenModelsFolderCommand).WithIconLeft(IconNames.FolderOpen); + var openVoicesFolder = UiUtil.MakeButton("Voices folder", vm.OpenVoicesFolderCommand).WithIconLeft(IconNames.FolderOpen); + var close = UiUtil.MakeButton(Se.Language.General.Close, vm.OkCommand); + + var leftPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + Children = { openModelsFolder, openVoicesFolder }, + }; + + var rightPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8, + Children = { close }, + }; + + var grid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + }, + }; + grid.Add(leftPanel, 0, 0); + grid.Add(rightPanel, 0, 2); + return grid; + } + + private static TextBlock MakeLabel(string text) => new() + { + Text = text, + Opacity = 0.7, + VerticalAlignment = VerticalAlignment.Center, + }; + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + _vm.OnKeyDown(e); + } +} diff --git a/src/ui/Features/Video/TextToSpeech/CosyVoice3CrispAsrSettings/CosyVoice3CrispAsrSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/CosyVoice3CrispAsrSettings/CosyVoice3CrispAsrSettingsWindow.cs index 34ca123eff7..9eef82fbb99 100644 --- a/src/ui/Features/Video/TextToSpeech/CosyVoice3CrispAsrSettings/CosyVoice3CrispAsrSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/CosyVoice3CrispAsrSettings/CosyVoice3CrispAsrSettingsWindow.cs @@ -33,7 +33,7 @@ public CosyVoice3CrispAsrSettingsWindow(CosyVoice3CrispAsrSettingsViewModel vm) Content = BuildContent(vm); var ok = UiUtil.MakeButtonOk(vm.OkCommand); - Activated += delegate { ok.Focus(); }; + UiUtil.FocusOnFirstActivation(this, ok); } private Border BuildContent(CosyVoice3CrispAsrSettingsViewModel vm) @@ -116,9 +116,8 @@ private static Border BuildDetails(CosyVoice3CrispAsrSettingsViewModel vm) grid.Add(MakeLabel(Se.Language.General.Engine), 0, 0); var enginePanel = MakeStatusPanel(nameof(vm.EngineBrush), nameof(vm.EngineLabel)); var engineButton = UiUtil.MakeButton(string.Empty, vm.RedownloadEngineCommand) - .WithIconLeft(IconNames.Download) + .WithIconLeftBindText(IconNames.Download, nameof(vm.EngineDownloadButtonText)) .WithMarginLeft(12); - engineButton.Bind(ContentControl.ContentProperty, new Binding(nameof(vm.EngineDownloadButtonText))); enginePanel.Children.Add(engineButton); grid.Add(enginePanel, 0, 1); diff --git a/src/ui/Features/Video/TextToSpeech/DetectSpeakers/DetectSpeakersRow.cs b/src/ui/Features/Video/TextToSpeech/DetectSpeakers/DetectSpeakersRow.cs new file mode 100644 index 00000000000..351936cb535 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/DetectSpeakers/DetectSpeakersRow.cs @@ -0,0 +1,19 @@ +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.DetectSpeakers; + +public class DetectSpeakersRow : SelectLinesRowBase +{ + public string Speaker { get; } + public TextSpeakerCandidate Candidate { get; } + + public DetectSpeakersRow(TextSpeakerCandidate candidate) + // An SDH-style name (ALL CAPS, "Speaker 1") is confidently a speaker; a mixed-case + // "Note:" is listed for the user to judge, but not pre-checked. + : base(TextSpeakerDetector.IsConfidentSpeakerName(candidate.Speaker), + candidate.Paragraph.Number, + candidate.Paragraph.StartTime.ToDisplayString(), + candidate.Paragraph.Text) + { + Speaker = candidate.Speaker; + Candidate = candidate; + } +} diff --git a/src/ui/Features/Video/TextToSpeech/DetectSpeakers/DetectSpeakersViewModel.cs b/src/ui/Features/Video/TextToSpeech/DetectSpeakers/DetectSpeakersViewModel.cs new file mode 100644 index 00000000000..57864062078 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/DetectSpeakers/DetectSpeakersViewModel.cs @@ -0,0 +1,40 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Nikse.SubtitleEdit.Logic.Config; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.DetectSpeakers; + +public partial class DetectSpeakersViewModel : SelectLinesViewModelBase +{ + [ObservableProperty] private bool _stickySpeakers; + + /// The tags the user confirmed - these names become actors and leave the spoken text. + public List ConfirmedCandidates { get; private set; } + + public DetectSpeakersViewModel() + { + ConfirmedCandidates = new List(); + // The SDH convention names only the speaker changes, so the lines between two tags belong + // to the tag above them - which is exactly what makes this usable on a real SDH file. + StickySpeakers = true; + } + + public void Initialize(List candidates) + { + Rows.Clear(); + foreach (var candidate in candidates) + { + Rows.Add(new DetectSpeakersRow(candidate)); + } + + var speakerCount = candidates.Select(c => c.Speaker).Distinct(StringComparer.OrdinalIgnoreCase).Count(); + RowsInfo = string.Format(Se.Language.Video.TextToSpeech.DetectSpeakersFoundX, Rows.Count, speakerCount); + } + + protected override void CollectResult() + { + ConfirmedCandidates = Rows.Where(r => r.IsSelected).Select(r => r.Candidate).ToList(); + } +} diff --git a/src/ui/Features/Video/TextToSpeech/DetectSpeakers/DetectSpeakersWindow.cs b/src/ui/Features/Video/TextToSpeech/DetectSpeakers/DetectSpeakersWindow.cs new file mode 100644 index 00000000000..e755a2e3e32 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/DetectSpeakers/DetectSpeakersWindow.cs @@ -0,0 +1,37 @@ +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Layout; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.DetectSpeakers; + +public class DetectSpeakersWindow : Window +{ + public DetectSpeakersWindow(DetectSpeakersViewModel vm) + { + var panelOptions = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 15, + }; + panelOptions.Children.Add(UiUtil.MakeCheckBox( + Se.Language.Video.TextToSpeech.DetectSpeakersSticky, vm, nameof(vm.StickySpeakers))); + panelOptions.Children.Add(UiUtil.MakeButton(Se.Language.General.SelectAll, vm.SelectAllCommand)); + panelOptions.Children.Add(UiUtil.MakeButton(Se.Language.General.InvertSelection, vm.InverseSelectionCommand)); + + var rowsView = SelectLinesWindowBuilder.MakeRowsView(vm, + Se.Language.Video.TextToSpeech.DetectSpeakersColumnUse, + new SeTableViewColumn + { + Header = Se.Language.General.Actor, + Binding = new Binding(nameof(DetectSpeakersRow.Speaker)), + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Width = new GridLength(160), + }); + + SelectLinesWindowBuilder.Initialize(this, vm, + Se.Language.Video.TextToSpeech.DetectSpeakersTitle, width: 900, panelOptions, rowsView); + } +} diff --git a/src/ui/Features/Video/TextToSpeech/DetectSpeakers/TextSpeakerDetector.cs b/src/ui/Features/Video/TextToSpeech/DetectSpeakers/TextSpeakerDetector.cs new file mode 100644 index 00000000000..8051510fc85 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/DetectSpeakers/TextSpeakerDetector.cs @@ -0,0 +1,188 @@ +using Nikse.SubtitleEdit.Core.Common; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.DetectSpeakers; + +/// +/// Finds SDH speaker tags written into the subtitle text - "MIKE: text", "[NARRATOR] text", +/// "(Speaker 1) text" - so the TTS flow can move them into the actor field and give each +/// speaker their own voice instead of reading the name aloud (issue #14106). +/// +public static partial class TextSpeakerDetector +{ + /// + /// "NAME:" at the start of a line - one to three words of letters (plus ' - . for names like + /// O'Brien or Mr. Smith), capped at name length so "Warning: do not open the door" is not a + /// speaker. The tag may sit alone on its line with the speech on the next line. + /// + [GeneratedRegex(@"^(?\p{Lu}[\p{L}'.\-]*(?:[ ]\p{L}[\p{L}'.\-]*){0,2}):\s*(?.*)$", + RegexOptions.CultureInvariant)] + private static partial Regex ColonTagRegex(); + + /// "[NAME]" or "(NAME)" at the start of a line. + [GeneratedRegex(@"^(?:\[(?[^\]\r\n]{1,30})\]|\((?[^)\r\n]{1,30})\))\s*(?.*)$", + RegexOptions.CultureInvariant)] + private static partial Regex BracketTagRegex(); + + private const int MaxNameLength = 30; + + /// + /// Splits one text line into its speaker tag and the words actually spoken. + /// A bracketed tag with nothing after it on the line is only a speaker when + /// - alone it is a sound annotation ("[door slams]"), + /// which is the noise prompt's business, not a name. + /// + public static bool TrySplit(string line, bool hasFollowingLine, out string speaker, out string spokenText) + { + speaker = string.Empty; + spokenText = line; + if (string.IsNullOrWhiteSpace(line)) + { + return false; + } + + var s = line.Trim(); + + var match = BracketTagRegex().Match(s); + if (!match.Success) + { + match = ColonTagRegex().Match(s); + } + + if (!match.Success) + { + return false; + } + + var name = NormalizeSpaces(match.Groups["name"].Value); + var text = match.Groups["text"].Value.Trim(); + if (name.Length == 0 || name.Length > MaxNameLength) + { + return false; + } + + if (text.Length == 0 && !hasFollowingLine) + { + return false; + } + + speaker = name; + spokenText = text; + return true; + } + + /// + /// A name written the SDH way - ALL UPPERCASE, or a bracketed "Speaker N" from diarization - + /// is confidently a speaker; anything else ("Warning:", "Note:") is listed for the user to + /// judge, but not pre-checked. + /// + public static bool IsConfidentSpeakerName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + if (Regex.IsMatch(name, @"^speaker[ _-]*\d+$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + { + return true; + } + + return name.Any(char.IsLetter) && !name.Any(char.IsLower); + } + + public static List Detect(Subtitle subtitle) + { + var candidates = new List(); + foreach (var paragraph in subtitle.Paragraphs) + { + var lines = (paragraph.Text ?? string.Empty).SplitToLines(); + for (var i = 0; i < lines.Count; i++) + { + if (TrySplit(lines[i], i + 1 < lines.Count, out var speaker, out _)) + { + candidates.Add(new TextSpeakerCandidate(paragraph, speaker, i)); + } + } + } + + return candidates; + } + + /// + /// Puts the confirmed speakers into the actor field and takes their tags out of the text, in + /// place. With , a line without a tag continues the previous + /// speaker - the SDH convention of naming only the changes (issue #14106). A paragraph with + /// two different confirmed speakers gets the first as its actor; the voice can only be one. + /// + public static int Apply(Subtitle subtitle, IReadOnlyCollection confirmed, bool stickySpeakers) + { + var byParagraph = confirmed + .GroupBy(c => c.Paragraph) + .ToDictionary(g => g.Key, g => g.OrderBy(c => c.LineIndex).ToList()); + + var applied = 0; + var previousSpeaker = string.Empty; + foreach (var paragraph in subtitle.Paragraphs) + { + if (!byParagraph.TryGetValue(paragraph, out var tags)) + { + if (stickySpeakers && previousSpeaker.Length > 0 && string.IsNullOrWhiteSpace(paragraph.Actor)) + { + paragraph.Actor = previousSpeaker; + } + + continue; + } + + var confirmedLineIndexes = tags.Select(t => t.LineIndex).ToHashSet(); + var lines = (paragraph.Text ?? string.Empty).SplitToLines(); + var newLines = new List(); + for (var i = 0; i < lines.Count; i++) + { + if (confirmedLineIndexes.Contains(i) && + TrySplit(lines[i], i + 1 < lines.Count, out _, out var spokenText)) + { + if (spokenText.Length > 0) + { + newLines.Add(spokenText); + } + } + else + { + newLines.Add(lines[i]); + } + } + + paragraph.Actor = tags[0].Speaker; + paragraph.Text = string.Join(Environment.NewLine, newLines).Trim(); + previousSpeaker = tags[tags.Count - 1].Speaker; + applied++; + } + + return applied; + } + + private static string NormalizeSpaces(string s) + { + return string.Join(" ", s.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + } +} + +/// One speaker tag found in the text: which paragraph, which of its lines, whose name. +public sealed class TextSpeakerCandidate +{ + public Paragraph Paragraph { get; } + public string Speaker { get; } + public int LineIndex { get; } + + public TextSpeakerCandidate(Paragraph paragraph, string speaker, int lineIndex) + { + Paragraph = paragraph; + Speaker = speaker; + LineIndex = lineIndex; + } +} diff --git a/src/ui/Features/Video/TextToSpeech/DotsTtsCrispAsrSettings/DotsTtsCrispAsrSettingsViewModel.cs b/src/ui/Features/Video/TextToSpeech/DotsTtsCrispAsrSettings/DotsTtsCrispAsrSettingsViewModel.cs new file mode 100644 index 00000000000..496cc7d61a1 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/DotsTtsCrispAsrSettings/DotsTtsCrispAsrSettingsViewModel.cs @@ -0,0 +1,269 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nikse.SubtitleEdit.Features.Shared; +using Nikse.SubtitleEdit.Features.Video.SpeechToText.Engines; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Download; +using Nikse.SubtitleEdit.Logic.Media; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.DotsTtsCrispAsrSettings; + +public partial class DotsTtsCrispAsrSettingsViewModel : ObservableObject +{ + // A SolidColorBrush set as Shape.Fill is parented into that shape's visual tree, so the same + // instance can't be shared across bound Ellipses (only one dot would render). Fresh brush per + // assignment instead. + private static IBrush Green() => new SolidColorBrush(Color.FromRgb(0x4C, 0xAF, 0x50)); + private static IBrush Amber() => new SolidColorBrush(Color.FromRgb(0xFF, 0x98, 0x00)); + private static IBrush Red() => new SolidColorBrush(Color.FromRgb(0xF4, 0x43, 0x36)); + private static IBrush Grey() => new SolidColorBrush(Color.FromRgb(0x9E, 0x9E, 0x9E)); + + private readonly IWindowService _windowService; + private readonly IFolderHelper _folderHelper; + + [ObservableProperty] private string _engineLabel = string.Empty; + [ObservableProperty] private IBrush _engineBrush = Grey(); + [ObservableProperty] private string _engineDownloadButtonText = string.Empty; + + [ObservableProperty] private string _coreQ4KLabel = string.Empty; + [ObservableProperty] private IBrush _coreQ4KBrush = Grey(); + + [ObservableProperty] private string _coreQ8_0Label = string.Empty; + [ObservableProperty] private IBrush _coreQ8_0Brush = Grey(); + + [ObservableProperty] private string _coreF16Label = string.Empty; + [ObservableProperty] private IBrush _coreF16Brush = Grey(); + + [ObservableProperty] private string _vocoderLabel = string.Empty; + [ObservableProperty] private IBrush _vocoderBrush = Grey(); + + [ObservableProperty] private string _speakerEncoderLabel = string.Empty; + [ObservableProperty] private IBrush _speakerEncoderBrush = Grey(); + + [ObservableProperty] private string _voicesLabel = string.Empty; + + [ObservableProperty] private double _odeSteps = DotsTtsCrispAsr.DefaultOdeSteps; + + public string OdeStepsLabel => $"{(int)OdeSteps} steps"; + + partial void OnOdeStepsChanged(double value) + { + OnPropertyChanged(nameof(OdeStepsLabel)); + Se.Settings.Video.TextToSpeech.DotsTtsCrispAsrOdeSteps = + Math.Clamp((int)Math.Round(value), DotsTtsCrispAsr.MinOdeSteps, DotsTtsCrispAsr.MaxOdeSteps); + } + + [ObservableProperty] private string _modelsFolder = string.Empty; + [ObservableProperty] private string _voicesFolder = string.Empty; + [ObservableProperty] private bool _isEngineInstalled; + + public Window? Window { get; set; } + public bool OkPressed { get; private set; } + + public DotsTtsCrispAsrSettingsViewModel(IWindowService windowService, IFolderHelper folderHelper) + { + _windowService = windowService; + _folderHelper = folderHelper; + } + + public void Initialize() + { + ModelsFolder = DotsTtsCrispAsr.GetSetModelsFolder(); + VoicesFolder = DotsTtsCrispAsr.GetSetVoicesFolder(); + OdeSteps = DotsTtsCrispAsr.ResolveOdeSteps(); + Refresh(); + } + + private void Refresh() + { + var exe = DotsTtsCrispAsr.GetCrispAsrExecutable(); + IsEngineInstalled = File.Exists(exe); + + if (!IsEngineInstalled) + { + EngineLabel = string.Format(Se.Language.Video.TtsEngineNotInstalled, "CrispASR"); + EngineBrush = Red(); + EngineDownloadButtonText = string.Format(Se.Language.General.DownloadX, "CrispASR"); + } + else if (DotsTtsCrispAsr.GetEngineUpdateStatus() == DownloadHashManager.UpdateStatus.UpdateAvailable) + { + EngineLabel = string.Format(Se.Language.Video.TtsEngineUpdateAvailable, "CrispASR"); + EngineBrush = Amber(); + EngineDownloadButtonText = string.Format(Se.Language.Video.TtsUpdateX, "CrispASR"); + } + else + { + EngineLabel = "CrispASR"; + EngineBrush = Green(); + EngineDownloadButtonText = string.Format(Se.Language.General.ReDownloadX, "CrispASR"); + } + + // Append the installed CrispASR version asynchronously — the probe spawns a child process + // and the first call can take a few hundred ms. + if (IsEngineInstalled) + { + var baseLabel = EngineLabel; + _ = Task.Run(() => + { + try + { + var version = CrispAsrVersion.TryGet(exe); + if (string.IsNullOrEmpty(version)) + { + return; + } + Dispatcher.UIThread.Post(() => + { + if (EngineLabel == baseLabel) + { + EngineLabel = baseLabel.Replace("CrispASR", $"CrispASR v{version}"); + } + }); + } + catch (Exception ex) + { + Se.LogError(ex, "DotsTtsCrispAsrSettings: CrispASR version probe failed"); + } + }); + } + + var coreQ4K = DotsTtsCrispAsr.GetCorePath(DotsTtsCrispAsr.ModelKeyQ4K); + ApplyModelStatus(DotsTtsCrispAsr.IsValidLocalModelFile(coreQ4K, DotsTtsCrispAsr.CoreQ4KFileName), + IsEngineInstalled, + label => CoreQ4KLabel = label, + brush => CoreQ4KBrush = brush); + + var coreQ8_0 = DotsTtsCrispAsr.GetCorePath(DotsTtsCrispAsr.ModelKeyQ8_0); + ApplyModelStatus(DotsTtsCrispAsr.IsValidLocalModelFile(coreQ8_0, DotsTtsCrispAsr.CoreQ8_0FileName), + IsEngineInstalled, + label => CoreQ8_0Label = label, + brush => CoreQ8_0Brush = brush); + + var coreF16 = DotsTtsCrispAsr.GetCorePath(DotsTtsCrispAsr.ModelKeyF16); + ApplyModelStatus(DotsTtsCrispAsr.IsValidLocalModelFile(coreF16, DotsTtsCrispAsr.CoreF16FileName), + IsEngineInstalled, + label => CoreF16Label = label, + brush => CoreF16Brush = brush); + + var vocoderPath = DotsTtsCrispAsr.GetVocoderPath(); + ApplyModelStatus(DotsTtsCrispAsr.IsValidLocalModelFile(vocoderPath, DotsTtsCrispAsr.VocoderFileName), + IsEngineInstalled, + label => VocoderLabel = label, + brush => VocoderBrush = brush); + + var speakerPath = DotsTtsCrispAsr.GetSpeakerEncoderPath(); + ApplyModelStatus(DotsTtsCrispAsr.IsValidLocalModelFile(speakerPath, DotsTtsCrispAsr.SpeakerEncoderFileName), + IsEngineInstalled, + label => SpeakerEncoderLabel = label, + brush => SpeakerEncoderBrush = brush); + + try + { + var wavCount = Directory.Exists(VoicesFolder) + ? Directory.GetFiles(VoicesFolder, "*.wav").Length + : 0; + VoicesLabel = wavCount == 0 + ? "No voices imported" + : (wavCount == 1 ? "1 voice imported" : $"{wavCount} voices imported"); + } + catch + { + VoicesLabel = string.Empty; + } + } + + private static void ApplyModelStatus(bool fileInstalled, bool engineInstalled, Action setLabel, Action setBrush) + { + if (fileInstalled) + { + setLabel("Installed"); + setBrush(Green()); + return; + } + + if (!engineInstalled) + { + // No CrispASR runtime means there is nothing to auto-download into, so don't promise + // a download that can't happen until the user installs CrispASR. + setLabel("CrispASR required"); + setBrush(Grey()); + return; + } + + setLabel("Auto-download on first use"); + setBrush(Grey()); + } + + [RelayCommand] + private async Task RedownloadEngine() + { + if (Window == null) + { + return; + } + + await TtsVoiceInstaller.EnsureCrispAsrForDotsTts(Window, _windowService, forceRedownload: true); + Refresh(); + } + + [RelayCommand] + private async Task OpenModelsFolder() + { + if (Window == null || string.IsNullOrEmpty(ModelsFolder)) + { + return; + } + + try + { + await _folderHelper.OpenFolder(Window, ModelsFolder); + } + catch + { + // best-effort UX + } + } + + [RelayCommand] + private async Task OpenVoicesFolder() + { + if (Window == null || string.IsNullOrEmpty(VoicesFolder)) + { + return; + } + + try + { + await _folderHelper.OpenFolder(Window, VoicesFolder); + } + catch + { + // best-effort UX + } + } + + [RelayCommand] + private void Ok() + { + OkPressed = true; + Window?.Close(); + } + + internal void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + e.Handled = true; + Window?.Close(); + } + } +} diff --git a/src/ui/Features/Video/TextToSpeech/DotsTtsCrispAsrSettings/DotsTtsCrispAsrSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/DotsTtsCrispAsrSettings/DotsTtsCrispAsrSettingsWindow.cs new file mode 100644 index 00000000000..2e5c52667c3 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/DotsTtsCrispAsrSettings/DotsTtsCrispAsrSettingsWindow.cs @@ -0,0 +1,276 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.DotsTtsCrispAsrSettings; + +public class DotsTtsCrispAsrSettingsWindow : Window +{ + private const int LabelWidth = 150; + private const int ValueWidth = 360; + + private readonly DotsTtsCrispAsrSettingsViewModel _vm; + + public DotsTtsCrispAsrSettingsWindow(DotsTtsCrispAsrSettingsViewModel vm) + { + UiUtil.InitializeWindow(this, GetType().Name); + Title = string.Format(Se.Language.Video.TtsCrispAsrSettingsTitle, "dots.tts"); + SizeToContent = SizeToContent.WidthAndHeight; + CanResize = false; + MinWidth = 580; + + _vm = vm; + vm.Window = this; + DataContext = vm; + + Content = BuildContent(vm); + + var ok = UiUtil.MakeButtonOk(vm.OkCommand); + UiUtil.FocusOnFirstActivation(this, ok); + } + + private Border BuildContent(DotsTtsCrispAsrSettingsViewModel vm) + { + var header = BuildHeader(); + var details = BuildDetails(vm); + var actions = BuildActions(vm); + + var stack = new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 14, + Children = { header, details, actions }, + }; + + var outerGrid = new Grid + { + Margin = UiUtil.MakeWindowMargin(), + }; + outerGrid.Children.Add(stack); + + return new Border + { + Child = outerGrid, + Padding = new Thickness(4), + }; + } + + private static StackPanel BuildHeader() + { + var title = new TextBlock + { + Text = "dots.tts (CrispASR)", + FontSize = 18, + FontWeight = FontWeight.SemiBold, + }; + + var subtitle = new TextBlock + { + Text = new DotsTtsCrispAsr().Description, + FontSize = 12, + Opacity = 0.75, + Margin = new Thickness(0, 2, 0, 0), + }; + + return new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 0, + Children = { title, subtitle }, + }; + } + + private static Border BuildDetails(DotsTtsCrispAsrSettingsViewModel vm) + { + var grid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(LabelWidth, GridUnitType.Pixel) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + }, + RowDefinitions = + { + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + }, + ColumnSpacing = 12, + RowSpacing = 10, + }; + + grid.Add(MakeLabel(Se.Language.General.Engine), 0, 0); + var enginePanel = MakeStatusPanel(nameof(vm.EngineBrush), nameof(vm.EngineLabel)); + var engineButton = UiUtil.MakeButton(string.Empty, vm.RedownloadEngineCommand) + .WithIconLeftBindText(IconNames.Download, nameof(vm.EngineDownloadButtonText)) + .WithMarginLeft(12); + enginePanel.Children.Add(engineButton); + grid.Add(enginePanel, 0, 1); + + grid.Add(MakeLabel("Model " + DotsTtsCrispAsr.ModelKeyQ4K), 1, 0); + grid.Add(MakeStatusPanel(nameof(vm.CoreQ4KBrush), nameof(vm.CoreQ4KLabel)), 1, 1); + + grid.Add(MakeLabel("Model " + DotsTtsCrispAsr.ModelKeyQ8_0), 2, 0); + grid.Add(MakeStatusPanel(nameof(vm.CoreQ8_0Brush), nameof(vm.CoreQ8_0Label)), 2, 1); + + grid.Add(MakeLabel("Model " + DotsTtsCrispAsr.ModelKeyF16), 3, 0); + grid.Add(MakeStatusPanel(nameof(vm.CoreF16Brush), nameof(vm.CoreF16Label)), 3, 1); + + grid.Add(MakeLabel("BigVGAN vocoder"), 4, 0); + grid.Add(MakeStatusPanel(nameof(vm.VocoderBrush), nameof(vm.VocoderLabel)), 4, 1); + + grid.Add(MakeLabel("CAM++ speaker encoder"), 5, 0); + grid.Add(MakeStatusPanel(nameof(vm.SpeakerEncoderBrush), nameof(vm.SpeakerEncoderLabel)), 5, 1); + + grid.Add(MakeLabel(Se.Language.Video.Voices), 6, 0); + var voicesText = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + FontWeight = FontWeight.SemiBold, + [!TextBlock.TextProperty] = new Binding(nameof(vm.VoicesLabel)), + }; + grid.Add(voicesText, 6, 1); + + grid.Add(MakeLabel("Quality"), 7, 0); + grid.Add(MakeOdeStepsPanel(), 7, 1); + + grid.Add(MakeLabel(Se.Language.General.InstallFolder), 8, 0); + var folderText = new TextBox + { + IsReadOnly = true, + Width = ValueWidth, + BorderThickness = new Thickness(0), + Background = Brushes.Transparent, + Padding = new Thickness(0), + VerticalContentAlignment = VerticalAlignment.Center, + FontSize = 12, + [!TextBox.TextProperty] = new Binding(nameof(vm.ModelsFolder)), + }; + grid.Add(folderText, 8, 1); + + return new Border + { + Child = grid, + Padding = new Thickness(14), + CornerRadius = new CornerRadius(6), + BorderThickness = new Thickness(1), + BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0x80, 0x80, 0x80)), + }; + } + + /// + /// Flow-matching Euler steps: the one real quality/speed knob dots.tts exposes. Measured on + /// M4 Metal with the F16 core, 4 steps synthesised in 25 s where 16 steps took 38 s for a + /// comparable line, so this is worth surfacing rather than hard-coding. + /// + private static StackPanel MakeOdeStepsPanel() + { + var slider = new Slider + { + Minimum = DotsTtsCrispAsr.MinOdeSteps, + Maximum = DotsTtsCrispAsr.MaxOdeSteps, + Width = 220, + TickFrequency = 1, + IsSnapToTickEnabled = true, + VerticalAlignment = VerticalAlignment.Center, + [!Slider.ValueProperty] = new Binding(nameof(DotsTtsCrispAsrSettingsViewModel.OdeSteps)), + }; + var label = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + FontWeight = FontWeight.SemiBold, + Margin = new Thickness(8, 0, 0, 0), + Width = 80, + [!TextBlock.TextProperty] = new Binding(nameof(DotsTtsCrispAsrSettingsViewModel.OdeStepsLabel)), + }; + return new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { slider, label }, + }; + } + + private static StackPanel MakeStatusPanel(string brushBindingPath, string labelBindingPath) + { + var dot = new Ellipse + { + Width = 10, + Height = 10, + VerticalAlignment = VerticalAlignment.Center, + [!Ellipse.FillProperty] = new Binding(brushBindingPath), + }; + var text = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + FontWeight = FontWeight.SemiBold, + Margin = new Thickness(8, 0, 0, 0), + [!TextBlock.TextProperty] = new Binding(labelBindingPath), + }; + return new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { dot, text }, + }; + } + + private static Grid BuildActions(DotsTtsCrispAsrSettingsViewModel vm) + { + var openModelsFolder = UiUtil.MakeButton(Se.Language.General.OpenContainingFolder, vm.OpenModelsFolderCommand).WithIconLeft(IconNames.FolderOpen); + var openVoicesFolder = UiUtil.MakeButton("Voices folder", vm.OpenVoicesFolderCommand).WithIconLeft(IconNames.FolderOpen); + var close = UiUtil.MakeButton(Se.Language.General.Close, vm.OkCommand); + + var leftPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + Children = { openModelsFolder, openVoicesFolder }, + }; + + var rightPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8, + Children = { close }, + }; + + var grid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + }, + }; + grid.Add(leftPanel, 0, 0); + grid.Add(rightPanel, 0, 2); + return grid; + } + + private static TextBlock MakeLabel(string text) => new() + { + Text = text, + Opacity = 0.7, + VerticalAlignment = VerticalAlignment.Center, + }; + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + _vm.OnKeyDown(e); + } +} diff --git a/src/ui/Features/Video/TextToSpeech/DownloadTts/DownloadTtsViewModel.cs b/src/ui/Features/Video/TextToSpeech/DownloadTts/DownloadTtsViewModel.cs index 0134dfa1a9d..e672948012e 100644 --- a/src/ui/Features/Video/TextToSpeech/DownloadTts/DownloadTtsViewModel.cs +++ b/src/ui/Features/Video/TextToSpeech/DownloadTts/DownloadTtsViewModel.cs @@ -57,8 +57,12 @@ public partial class DownloadTtsViewModel : ObservableObject private Task? _downloadTaskQwen3TtsCrispAsrVoices; private Task? _downloadTaskVibeVoiceCrispAsrModels; private Task? _downloadTaskVibeVoiceCrispAsrVoices; + private Task? _downloadTaskDotsTtsCrispAsrModels; + private Task? _downloadTaskConfucius4TtsCrispAsrModels; private Task? _downloadTaskIndexTtsCrispAsrModels; private Task? _downloadTaskIndexTtsCrispAsrVoices; + private Task? _downloadTaskPocketTtsCrispAsrModels; + private Task? _downloadTaskPocketTtsCrispAsrVoices; private Task? _downloadTaskCosyVoice3CrispAsrModels; private Task? _downloadTaskCosyVoice3CrispAsrVoices; private Task? _downloadTaskF5TtsCrispAsrModels; @@ -85,6 +89,9 @@ public partial class DownloadTtsViewModel : ObservableObject private readonly IQwen3TtsCrispAsrDownloadService _qwen3TtsCrispAsrDownloadService; private readonly IVibeVoiceCrispAsrDownloadService _vibeVoiceCrispAsrDownloadService; private readonly IIndexTtsCrispAsrDownloadService _indexTtsCrispAsrDownloadService; + private readonly IPocketTtsCrispAsrDownloadService _pocketTtsCrispAsrDownloadService; + private readonly IDotsTtsCrispAsrDownloadService _dotsTtsCrispAsrDownloadService; + private readonly IConfucius4TtsCrispAsrDownloadService _confucius4TtsCrispAsrDownloadService; private readonly IIndexTts25AudioCppDownloadService _indexTts25AudioCppDownloadService; private Task? _downloadTaskIndexTts25AudioCppEngine; private Task? _downloadTaskIndexTts25AudioCppModels; @@ -92,6 +99,14 @@ public partial class DownloadTtsViewModel : ObservableObject private readonly MemoryStream _downloadStreamIndexTts25AudioCppVoices; private Task? _downloadTaskIndexTts25AudioCppVoices; private string _indexTts25AudioCppBackend = IndexTts25AudioCppDownloadService.BackendCpu; + private readonly IHiggsTtsAudioCppDownloadService _higgsTtsAudioCppDownloadService; + private Task? _downloadTaskHiggsTtsAudioCppModels; + private readonly MemoryStream _downloadStreamHiggsTtsAudioCppVoices; + private Task? _downloadTaskHiggsTtsAudioCppVoices; + private readonly IFishTtsAudioCppDownloadService _fishTtsAudioCppDownloadService; + private Task? _downloadTaskFishTtsAudioCppModels; + private readonly MemoryStream _downloadStreamFishTtsAudioCppVoices; + private Task? _downloadTaskFishTtsAudioCppVoices; private readonly ICosyVoice3CrispAsrDownloadService _cosyVoice3CrispAsrDownloadService; private readonly IF5TtsCrispAsrDownloadService _f5TtsCrispAsrDownloadService; private readonly IVoxCPM2CrispAsrDownloadService _voxCPM2CrispAsrDownloadService; @@ -109,6 +124,7 @@ public partial class DownloadTtsViewModel : ObservableObject private readonly MemoryStream _downloadStreamQwen3TtsCrispAsrVoices; private readonly MemoryStream _downloadStreamVibeVoiceCrispAsrVoices; private readonly MemoryStream _downloadStreamIndexTtsCrispAsrVoices; + private readonly MemoryStream _downloadStreamPocketTtsCrispAsrVoices; private readonly MemoryStream _downloadStreamCosyVoice3CrispAsrVoices; private readonly MemoryStream _downloadStreamF5TtsCrispAsrVoices; private readonly MemoryStream _downloadStreamVoxCPM2CrispAsrVoices; @@ -128,7 +144,12 @@ public DownloadTtsViewModel(ITtsDownloadService ttsDownloadService, IZipUnpacker IQwen3TtsCrispAsrDownloadService qwen3TtsCrispAsrDownloadService, IVibeVoiceCrispAsrDownloadService vibeVoiceCrispAsrDownloadService, IIndexTtsCrispAsrDownloadService indexTtsCrispAsrDownloadService, + IPocketTtsCrispAsrDownloadService pocketTtsCrispAsrDownloadService, + IDotsTtsCrispAsrDownloadService dotsTtsCrispAsrDownloadService, + IConfucius4TtsCrispAsrDownloadService confucius4TtsCrispAsrDownloadService, IIndexTts25AudioCppDownloadService indexTts25AudioCppDownloadService, + IHiggsTtsAudioCppDownloadService higgsTtsAudioCppDownloadService, + IFishTtsAudioCppDownloadService fishTtsAudioCppDownloadService, ICosyVoice3CrispAsrDownloadService cosyVoice3CrispAsrDownloadService, IF5TtsCrispAsrDownloadService f5TtsCrispAsrDownloadService, IVoxCPM2CrispAsrDownloadService voxCPM2CrispAsrDownloadService, @@ -144,9 +165,16 @@ public DownloadTtsViewModel(ITtsDownloadService ttsDownloadService, IZipUnpacker _qwen3TtsCrispAsrDownloadService = qwen3TtsCrispAsrDownloadService; _vibeVoiceCrispAsrDownloadService = vibeVoiceCrispAsrDownloadService; _indexTtsCrispAsrDownloadService = indexTtsCrispAsrDownloadService; + _pocketTtsCrispAsrDownloadService = pocketTtsCrispAsrDownloadService; + _dotsTtsCrispAsrDownloadService = dotsTtsCrispAsrDownloadService; + _confucius4TtsCrispAsrDownloadService = confucius4TtsCrispAsrDownloadService; _indexTts25AudioCppDownloadService = indexTts25AudioCppDownloadService; _downloadStreamIndexTts25AudioCppEngine = new MemoryStream(); _downloadStreamIndexTts25AudioCppVoices = new MemoryStream(); + _higgsTtsAudioCppDownloadService = higgsTtsAudioCppDownloadService; + _downloadStreamHiggsTtsAudioCppVoices = new MemoryStream(); + _fishTtsAudioCppDownloadService = fishTtsAudioCppDownloadService; + _downloadStreamFishTtsAudioCppVoices = new MemoryStream(); _cosyVoice3CrispAsrDownloadService = cosyVoice3CrispAsrDownloadService; _f5TtsCrispAsrDownloadService = f5TtsCrispAsrDownloadService; _voxCPM2CrispAsrDownloadService = voxCPM2CrispAsrDownloadService; @@ -167,6 +195,7 @@ public DownloadTtsViewModel(ITtsDownloadService ttsDownloadService, IZipUnpacker _downloadStreamQwen3TtsCrispAsrVoices = new MemoryStream(); _downloadStreamVibeVoiceCrispAsrVoices = new MemoryStream(); _downloadStreamIndexTtsCrispAsrVoices = new MemoryStream(); + _downloadStreamPocketTtsCrispAsrVoices = new MemoryStream(); _downloadStreamCosyVoice3CrispAsrVoices = new MemoryStream(); _downloadStreamF5TtsCrispAsrVoices = new MemoryStream(); _downloadStreamVoxCPM2CrispAsrVoices = new MemoryStream(); @@ -184,7 +213,12 @@ public DownloadTtsViewModel(ITtsDownloadService ttsDownloadService, IZipUnpacker _timer.Interval = 500; _timer.Elapsed += OnTimerOnElapsed; - _timer.Start(); + // OnClosing disposes the timer, so restarting it from a chained download + // step threw ObjectDisposedException on a thread-pool thread (#12739). + if (!_isClosing) + { + _timer.Start(); + } } private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) @@ -622,7 +656,12 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) // CrispASR qwen3-tts backend rejects any other rate. _downloadTaskQwen3TtsCrispAsrVoices = _qwen3TtsCppDownloadService.DownloadVoices( _downloadStreamQwen3TtsCrispAsrVoices, voicesProgress, _cancellationTokenSource.Token); - _timer.Start(); + // OnClosing disposes the timer, so restarting it from a chained download + // step threw ObjectDisposedException on a thread-pool thread (#12739). + if (!_isClosing) + { + _timer.Start(); + } } else if (_downloadTaskQwen3TtsCrispAsrModels is { IsFaulted: true }) { @@ -724,7 +763,12 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) }); _downloadTaskVibeVoiceCrispAsrVoices = _qwen3TtsCppDownloadService.DownloadVoices( _downloadStreamVibeVoiceCrispAsrVoices, voicesProgress, _cancellationTokenSource.Token); - _timer.Start(); + // OnClosing disposes the timer, so restarting it from a chained download + // step threw ObjectDisposedException on a thread-pool thread (#12739). + if (!_isClosing) + { + _timer.Start(); + } } else if (_downloadTaskVibeVoiceCrispAsrModels is { IsFaulted: true }) { @@ -884,7 +928,12 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) }); _downloadTaskIndexTts25AudioCppVoices = _qwen3TtsCppDownloadService.DownloadVoices( _downloadStreamIndexTts25AudioCppVoices, voicesProgress, _cancellationTokenSource.Token); - _timer.Start(); + // OnClosing disposes the timer, so restarting it from a chained download + // step threw ObjectDisposedException on a thread-pool thread (#12739). + if (!_isClosing) + { + _timer.Start(); + } return; } @@ -951,6 +1000,278 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) return; } + if (_downloadTaskHiggsTtsAudioCppModels is { IsCompletedSuccessfully: true }) + { + _timer.Stop(); + _downloadTaskHiggsTtsAudioCppModels = null; + + // Chain the shared reference-voice pack (the same voices.zip the other cloning + // engines use), so the voice combo is not empty on first run — this engine can + // only clone, it has no built-in voices. + var voicesFolder = HiggsTtsAudioCpp.GetSetVoicesFolder(); + var voicesAlreadyInstalled = Directory.Exists(voicesFolder) + && Directory.EnumerateFiles(voicesFolder, "*.wav").Any(); + if (voicesAlreadyInstalled) + { + OkPressed = true; + Close(); + return; + } + + TitleText = string.Format(Se.Language.General.DownloadingX, "Higgs Audio v3 reference voices"); + ProgressValue = 0; + ProgressText = Se.Language.General.StartingDotDotDot; + var voicesProgress = new Progress(number => + { + var percentage = (int)Math.Round(number * 100.0, MidpointRounding.AwayFromZero); + var pctString = percentage.ToString(CultureInfo.InvariantCulture); + ProgressValue = percentage; + ProgressText = string.Format(Se.Language.General.DownloadingXPercent, pctString); + }); + _downloadTaskHiggsTtsAudioCppVoices = _qwen3TtsCppDownloadService.DownloadVoices( + _downloadStreamHiggsTtsAudioCppVoices, voicesProgress, _cancellationTokenSource.Token); + // OnClosing disposes the timer, so restarting it from a chained download + // step threw ObjectDisposedException on a thread-pool thread (#12739). + if (!_isClosing) + { + _timer.Start(); + } + return; + } + + if (_downloadTaskHiggsTtsAudioCppVoices is { IsCompletedSuccessfully: true }) + { + _timer.Stop(); + _downloadTaskHiggsTtsAudioCppVoices = null; + if (_downloadStreamHiggsTtsAudioCppVoices.Length > 0) + { + var voicesFolder = HiggsTtsAudioCpp.GetSetVoicesFolder(); + try + { + _downloadStreamHiggsTtsAudioCppVoices.Position = 0; + _zipUnpacker.UnpackZipStream(_downloadStreamHiggsTtsAudioCppVoices, voicesFolder, string.Empty, false, new List(), null); + // The pack ships at 16 kHz; Higgs clones from 24 kHz references. + ResampleVoicesTo24kHz(voicesFolder); + // The pack's .txt files are attribution blurbs, not transcripts, and + // this engine passes a .txt sidecar as reference_text — a blurb there + // conditions the clone on text nobody spoke. Same cleanup as Qwen3. + Qwen3TtsCrispAsr.NormalizeVoiceTranscripts(voicesFolder); + } + catch (Exception ex) + { + Se.LogError(ex); + } + + _downloadStreamHiggsTtsAudioCppVoices.Dispose(); + } + + OkPressed = true; + Close(); + return; + } + + if (_downloadTaskHiggsTtsAudioCppVoices is { IsFaulted: true }) + { + _timer.Stop(); + _downloadTaskHiggsTtsAudioCppVoices = null; + if (_cancellationTokenSource.IsCancellationRequested) + { + ProgressText = Se.Language.General.DownloadCanceled; + Close(); + return; + } + + // A missing voice pack is not fatal — the model is already installed and the + // user can import their own reference WAV. + OkPressed = true; + Close(); + return; + } + + if (_downloadTaskHiggsTtsAudioCppModels is { IsFaulted: true }) + { + _timer.Stop(); + var ex = _downloadTaskHiggsTtsAudioCppModels.Exception?.InnerException ?? _downloadTaskHiggsTtsAudioCppModels.Exception; + if (ex is OperationCanceledException) + { + ProgressText = Se.Language.General.DownloadCanceled; + Close(); + } + else + { + ProgressText = Se.Language.General.DownloadFailed; + Error = ex?.Message ?? Se.Language.General.UnknownError; + } + + return; + } + + if (_downloadTaskFishTtsAudioCppModels is { IsCompletedSuccessfully: true }) + { + _timer.Stop(); + _downloadTaskFishTtsAudioCppModels = null; + + // Chain the shared reference-voice pack (the same voices.zip the other cloning + // engines use), so the voice combo is not empty on first run — this engine can + // only clone, it has no built-in voices. + var voicesFolder = FishTtsAudioCpp.GetSetVoicesFolder(); + var voicesAlreadyInstalled = Directory.Exists(voicesFolder) + && Directory.EnumerateFiles(voicesFolder, "*.wav").Any(); + if (voicesAlreadyInstalled) + { + OkPressed = true; + Close(); + return; + } + + TitleText = string.Format(Se.Language.General.DownloadingX, "Fish Audio S2 Pro reference voices"); + ProgressValue = 0; + ProgressText = Se.Language.General.StartingDotDotDot; + var voicesProgress = new Progress(number => + { + var percentage = (int)Math.Round(number * 100.0, MidpointRounding.AwayFromZero); + var pctString = percentage.ToString(CultureInfo.InvariantCulture); + ProgressValue = percentage; + ProgressText = string.Format(Se.Language.General.DownloadingXPercent, pctString); + }); + _downloadTaskFishTtsAudioCppVoices = _qwen3TtsCppDownloadService.DownloadVoices( + _downloadStreamFishTtsAudioCppVoices, voicesProgress, _cancellationTokenSource.Token); + // OnClosing disposes the timer, so restarting it from a chained download + // step threw ObjectDisposedException on a thread-pool thread (#12739). + if (!_isClosing) + { + _timer.Start(); + } + return; + } + + if (_downloadTaskFishTtsAudioCppVoices is { IsCompletedSuccessfully: true }) + { + _timer.Stop(); + _downloadTaskFishTtsAudioCppVoices = null; + if (_downloadStreamFishTtsAudioCppVoices.Length > 0) + { + var voicesFolder = FishTtsAudioCpp.GetSetVoicesFolder(); + try + { + _downloadStreamFishTtsAudioCppVoices.Position = 0; + _zipUnpacker.UnpackZipStream(_downloadStreamFishTtsAudioCppVoices, voicesFolder, string.Empty, false, new List(), null); + // The pack ships at 16 kHz; the S2 Pro codec runs at 44.1 kHz. + ResampleVoicesTo44kHz(voicesFolder); + // The pack's .txt files are attribution blurbs, not transcripts, and + // this engine passes a .txt sidecar as reference_text — a blurb there + // conditions the clone on text nobody spoke. Same cleanup as Qwen3. + Qwen3TtsCrispAsr.NormalizeVoiceTranscripts(voicesFolder); + } + catch (Exception ex) + { + Se.LogError(ex); + } + + _downloadStreamFishTtsAudioCppVoices.Dispose(); + } + + OkPressed = true; + Close(); + return; + } + + if (_downloadTaskFishTtsAudioCppVoices is { IsFaulted: true }) + { + _timer.Stop(); + _downloadTaskFishTtsAudioCppVoices = null; + if (_cancellationTokenSource.IsCancellationRequested) + { + ProgressText = Se.Language.General.DownloadCanceled; + Close(); + return; + } + + // A missing voice pack is not fatal — the model is already installed and the + // user can import their own reference WAV. + OkPressed = true; + Close(); + return; + } + + if (_downloadTaskFishTtsAudioCppModels is { IsFaulted: true }) + { + _timer.Stop(); + var ex = _downloadTaskFishTtsAudioCppModels.Exception?.InnerException ?? _downloadTaskFishTtsAudioCppModels.Exception; + if (ex is OperationCanceledException) + { + ProgressText = Se.Language.General.DownloadCanceled; + Close(); + } + else + { + ProgressText = Se.Language.General.DownloadFailed; + Error = ex?.Message ?? Se.Language.General.UnknownError; + } + + return; + } + + if (_downloadTaskDotsTtsCrispAsrModels is { IsCompletedSuccessfully: true }) + { + // Unlike IndexTTS there is no voice pack to fetch afterwards: dots.tts clones + // from user-imported WAVs, and the engine seeds the voices folder from + // qwen3-tts.cpp on first use. + _timer.Stop(); + _downloadTaskDotsTtsCrispAsrModels = null; + OkPressed = true; + Close(); + return; + } + + if (_downloadTaskConfucius4TtsCrispAsrModels is { IsCompletedSuccessfully: true }) + { + // Same as dots.tts: no voice pack to fetch afterwards — Confucius4-TTS clones + // from user-imported WAVs, and the engine seeds the voices folder from + // qwen3-tts.cpp on first use. + _timer.Stop(); + _downloadTaskConfucius4TtsCrispAsrModels = null; + OkPressed = true; + Close(); + return; + } + + if (_downloadTaskDotsTtsCrispAsrModels is { IsFaulted: true }) + { + _timer.Stop(); + var dotsEx = _downloadTaskDotsTtsCrispAsrModels.Exception?.InnerException ?? _downloadTaskDotsTtsCrispAsrModels.Exception; + _downloadTaskDotsTtsCrispAsrModels = null; + if (dotsEx is OperationCanceledException) + { + ProgressText = Se.Language.General.DownloadCanceled; + Close(); + } + else + { + ProgressText = Se.Language.General.DownloadFailed; + Error = dotsEx?.Message ?? Se.Language.General.UnknownError; + } + return; + } + + if (_downloadTaskConfucius4TtsCrispAsrModels is { IsFaulted: true }) + { + _timer.Stop(); + var confuciusEx = _downloadTaskConfucius4TtsCrispAsrModels.Exception?.InnerException ?? _downloadTaskConfucius4TtsCrispAsrModels.Exception; + _downloadTaskConfucius4TtsCrispAsrModels = null; + if (confuciusEx is OperationCanceledException) + { + ProgressText = Se.Language.General.DownloadCanceled; + Close(); + } + else + { + ProgressText = Se.Language.General.DownloadFailed; + Error = confuciusEx?.Message ?? Se.Language.General.UnknownError; + } + return; + } + if (_downloadTaskIndexTtsCrispAsrModels is { IsCompletedSuccessfully: true }) { _timer.Stop(); @@ -978,7 +1299,12 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) }); _downloadTaskIndexTtsCrispAsrVoices = _qwen3TtsCppDownloadService.DownloadVoices( _downloadStreamIndexTtsCrispAsrVoices, voicesProgress, _cancellationTokenSource.Token); - _timer.Start(); + // OnClosing disposes the timer, so restarting it from a chained download + // step threw ObjectDisposedException on a thread-pool thread (#12739). + if (!_isClosing) + { + _timer.Start(); + } } else if (_downloadTaskIndexTtsCrispAsrModels is { IsFaulted: true }) { @@ -1035,6 +1361,97 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) Close(); } + if (_downloadTaskPocketTtsCrispAsrModels is { IsCompletedSuccessfully: true }) + { + _timer.Stop(); + _downloadTaskPocketTtsCrispAsrModels = null; + + var pocketVoicesFolder = PocketTtsCrispAsr.GetSetVoicesFolder(); + var pocketVoicesAlreadyInstalled = Directory.Exists(pocketVoicesFolder) + && Directory.EnumerateFiles(pocketVoicesFolder, "*.wav").Any(); + if (pocketVoicesAlreadyInstalled) + { + OkPressed = true; + Close(); + return; + } + + TitleText = string.Format(Se.Language.General.DownloadingX, "Pocket TTS (CrispASR) voices"); + ProgressValue = 0; + ProgressText = Se.Language.General.StartingDotDotDot; + var pocketVoicesProgress = new Progress(number => + { + var percentage = (int)Math.Round(number * 100.0, MidpointRounding.AwayFromZero); + var pctString = percentage.ToString(CultureInfo.InvariantCulture); + ProgressValue = percentage; + ProgressText = string.Format(Se.Language.General.DownloadingXPercent, pctString); + }); + _downloadTaskPocketTtsCrispAsrVoices = _qwen3TtsCppDownloadService.DownloadVoices( + _downloadStreamPocketTtsCrispAsrVoices, pocketVoicesProgress, _cancellationTokenSource.Token); + // OnClosing disposes the timer, so restarting it from a chained download + // step threw ObjectDisposedException on a thread-pool thread (#12739). + if (!_isClosing) + { + _timer.Start(); + } + } + else if (_downloadTaskPocketTtsCrispAsrModels is { IsFaulted: true }) + { + _timer.Stop(); + var pocketEx = _downloadTaskPocketTtsCrispAsrModels.Exception?.InnerException ?? _downloadTaskPocketTtsCrispAsrModels.Exception; + if (pocketEx is OperationCanceledException) + { + ProgressText = Se.Language.General.DownloadCanceled; + Close(); + } + else + { + ProgressText = Se.Language.General.DownloadFailed; + Error = pocketEx?.Message ?? Se.Language.General.UnknownError; + } + } + + if (_downloadTaskPocketTtsCrispAsrVoices is { IsCompletedSuccessfully: true }) + { + _timer.Stop(); + if (_downloadStreamPocketTtsCrispAsrVoices.Length > 0) + { + var pocketVoicesFolder = PocketTtsCrispAsr.GetSetVoicesFolder(); + try + { + _downloadStreamPocketTtsCrispAsrVoices.Position = 0; + _zipUnpacker.UnpackZipStream(_downloadStreamPocketTtsCrispAsrVoices, pocketVoicesFolder, string.Empty, false, new List(), null); + // The Mimi encoder conditions at 24 kHz; the qwen3-tts.cpp voice pack + // ships at 16 kHz, so resample once here rather than on every synth. + ResampleVoicesTo24kHz(pocketVoicesFolder); + } + catch (Exception ex) + { + Se.LogError(ex); + } + _downloadStreamPocketTtsCrispAsrVoices.Dispose(); + } + OkPressed = true; + Close(); + } + else if (_downloadTaskPocketTtsCrispAsrVoices is { IsFaulted: true }) + { + _timer.Stop(); + if (_cancellationTokenSource.IsCancellationRequested) + { + ProgressText = Se.Language.General.DownloadCanceled; + Close(); + return; + } + var pocketEx = _downloadTaskPocketTtsCrispAsrVoices.Exception?.InnerException ?? _downloadTaskPocketTtsCrispAsrVoices.Exception; + if (pocketEx != null) + { + Se.LogError(pocketEx); + } + OkPressed = true; + Close(); + } + if (_downloadTaskZonosTtsCrispAsrModels is { IsCompletedSuccessfully: true }) { _timer.Stop(); @@ -1062,7 +1479,12 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) }); _downloadTaskZonosTtsCrispAsrVoices = _qwen3TtsCppDownloadService.DownloadVoices( _downloadStreamZonosTtsCrispAsrVoices, voicesProgress, _cancellationTokenSource.Token); - _timer.Start(); + // OnClosing disposes the timer, so restarting it from a chained download + // step threw ObjectDisposedException on a thread-pool thread (#12739). + if (!_isClosing) + { + _timer.Start(); + } } else if (_downloadTaskZonosTtsCrispAsrModels is { IsFaulted: true }) { @@ -1177,7 +1599,12 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) }); _downloadTaskCosyVoice3CrispAsrVoices = _qwen3TtsCppDownloadService.DownloadVoices( _downloadStreamCosyVoice3CrispAsrVoices, voicesProgress, _cancellationTokenSource.Token); - _timer.Start(); + // OnClosing disposes the timer, so restarting it from a chained download + // step threw ObjectDisposedException on a thread-pool thread (#12739). + if (!_isClosing) + { + _timer.Start(); + } } else if (_downloadTaskCosyVoice3CrispAsrModels is { IsFaulted: true }) { @@ -1263,7 +1690,12 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) }); _downloadTaskF5TtsCrispAsrVoices = _qwen3TtsCppDownloadService.DownloadVoices( _downloadStreamF5TtsCrispAsrVoices, voicesProgress, _cancellationTokenSource.Token); - _timer.Start(); + // OnClosing disposes the timer, so restarting it from a chained download + // step threw ObjectDisposedException on a thread-pool thread (#12739). + if (!_isClosing) + { + _timer.Start(); + } } else if (_downloadTaskF5TtsCrispAsrModels is { IsFaulted: true }) { @@ -1349,7 +1781,12 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) }); _downloadTaskVoxCPM2CrispAsrVoices = _qwen3TtsCppDownloadService.DownloadVoices( _downloadStreamVoxCPM2CrispAsrVoices, voicesProgress, _cancellationTokenSource.Token); - _timer.Start(); + // OnClosing disposes the timer, so restarting it from a chained download + // step threw ObjectDisposedException on a thread-pool thread (#12739). + if (!_isClosing) + { + _timer.Start(); + } } else if (_downloadTaskVoxCPM2CrispAsrModels is { IsFaulted: true }) { @@ -1435,7 +1872,12 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) }); _downloadTaskMossTtsCrispAsrVoices = _qwen3TtsCppDownloadService.DownloadVoices( _downloadStreamMossTtsCrispAsrVoices, voicesProgress, _cancellationTokenSource.Token); - _timer.Start(); + // OnClosing disposes the timer, so restarting it from a chained download + // step threw ObjectDisposedException on a thread-pool thread (#12739). + if (!_isClosing) + { + _timer.Start(); + } } else if (_downloadTaskMossTtsCrispAsrModels is { IsFaulted: true }) { @@ -1574,7 +2016,12 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) }); _downloadTaskOmniVoices = _omniVoiceDownloadService.DownloadVoices( _downloadStreamOmniVoices, voicesProgress, _cancellationTokenSource.Token); - _timer.Start(); + // OnClosing disposes the timer, so restarting it from a chained download + // step threw ObjectDisposedException on a thread-pool thread (#12739). + if (!_isClosing) + { + _timer.Start(); + } } else if (_downloadTaskOmniVoice is { IsFaulted: true }) { @@ -1743,7 +2190,13 @@ private static string QuoteForBash(string value) // crispasr would otherwise upsample on every synth (audibly lossy and a bit slower). // Best-effort per file: on ffmpeg failure we leave the original 16 kHz WAV in place // rather than dropping the voice entirely. - private static void ResampleVoicesTo24kHz(string folder) + private static void ResampleVoicesTo24kHz(string folder) => + ResampleVoices(folder, "24k", FfmpegGenerator.ConvertToMono24kHzWav); + + private static void ResampleVoicesTo44kHz(string folder) => + ResampleVoices(folder, "44k", FfmpegGenerator.ConvertToMono44kHzWav); + + private static void ResampleVoices(string folder, string rateTag, Func convert) { if (!Directory.Exists(folder)) { @@ -1752,11 +2205,11 @@ private static void ResampleVoicesTo24kHz(string folder) foreach (var wav in Directory.GetFiles(folder, "*.wav")) { - var temp = wav + ".24k.wav"; + var temp = $"{wav}.{rateTag}.wav"; var consumed = false; try { - var ffmpeg = FfmpegGenerator.ConvertToMono24kHzWav(wav, temp); + var ffmpeg = convert(wav, temp, null); if (!ffmpeg.Start()) { continue; @@ -1771,14 +2224,13 @@ private static void ResampleVoicesTo24kHz(string folder) } catch (Exception ex) { - Se.LogError(ex, $"Resample voice to 24 kHz failed for '{wav}'; leaving original in place"); + Se.LogError(ex, $"Resample voice ({rateTag}) failed for '{wav}'; leaving original in place"); } finally { // Cover every non-success exit path: ffmpeg crashed, produced a 0-byte file, - // or threw during the rename. Without this an accumulating set of *.24k.wav - // temp files would slowly clutter the voices folder and bloat .zip / sync - // backups. + // or threw during the rename. Without this an accumulating set of temp files + // would slowly clutter the voices folder and bloat .zip / sync backups. if (!consumed && File.Exists(temp)) { try { File.Delete(temp); } catch { /* leave it; not worth retrying */ } @@ -2056,6 +2508,52 @@ public void StartDownloadVibeVoiceCrispAsrModels(string? modelKey = null) _vibeVoiceCrispAsrDownloadService.DownloadModels(VibeVoiceCrispAsr.GetSetModelsFolder(), resolved, downloadProgress, titleProgress, _cancellationTokenSource.Token); } + public void StartDownloadDotsTtsCrispAsrModels(string? modelKey = null) + { + var resolved = DotsTtsCrispAsr.ResolveModelKey(modelKey); + var coreFileName = DotsTtsCrispAsr.GetCoreFileName(resolved); + TitleText = string.Format(Se.Language.General.DownloadingX, $"dots.tts (CrispASR) models ({resolved}): {coreFileName}"); + + var downloadProgress = new Progress(number => + { + var percentage = (int)Math.Round(number * 100.0, MidpointRounding.AwayFromZero); + var pctString = percentage.ToString(CultureInfo.InvariantCulture); + ProgressValue = percentage; + ProgressText = string.Format(Se.Language.General.DownloadingXPercent, pctString); + }); + + var titleProgress = new Action(title => + { + Dispatcher.UIThread.Post(() => TitleText = title); + }); + + _downloadTaskDotsTtsCrispAsrModels = + _dotsTtsCrispAsrDownloadService.DownloadModels(DotsTtsCrispAsr.GetSetModelsFolder(), resolved, downloadProgress, titleProgress, _cancellationTokenSource.Token); + } + + public void StartDownloadConfucius4TtsCrispAsrModels(string? modelKey = null) + { + var resolved = Confucius4TtsCrispAsr.ResolveModelKey(modelKey); + var t2sFileName = Confucius4TtsCrispAsr.GetT2sFileName(resolved); + TitleText = string.Format(Se.Language.General.DownloadingX, $"Confucius4-TTS (CrispASR) models ({resolved}): {t2sFileName}"); + + var downloadProgress = new Progress(number => + { + var percentage = (int)Math.Round(number * 100.0, MidpointRounding.AwayFromZero); + var pctString = percentage.ToString(CultureInfo.InvariantCulture); + ProgressValue = percentage; + ProgressText = string.Format(Se.Language.General.DownloadingXPercent, pctString); + }); + + var titleProgress = new Action(title => + { + Dispatcher.UIThread.Post(() => TitleText = title); + }); + + _downloadTaskConfucius4TtsCrispAsrModels = + _confucius4TtsCrispAsrDownloadService.DownloadModels(Confucius4TtsCrispAsr.GetSetModelsFolder(), resolved, downloadProgress, titleProgress, _cancellationTokenSource.Token); + } + public void StartDownloadIndexTtsCrispAsrModels(string? modelKey = null) { var resolved = IndexTtsCrispAsr.ResolveModelKey(modelKey); @@ -2079,6 +2577,29 @@ public void StartDownloadIndexTtsCrispAsrModels(string? modelKey = null) _indexTtsCrispAsrDownloadService.DownloadModels(IndexTtsCrispAsr.GetSetModelsFolder(), resolved, downloadProgress, titleProgress, _cancellationTokenSource.Token); } + public void StartDownloadPocketTtsCrispAsrModels(string? modelKey = null) + { + var resolved = PocketTtsCrispAsr.ResolveModelKey(modelKey); + var modelFileName = PocketTtsCrispAsr.GetModelFileName(resolved); + TitleText = string.Format(Se.Language.General.DownloadingX, $"Pocket TTS (CrispASR) model ({resolved}): {modelFileName}"); + + var downloadProgress = new Progress(number => + { + var percentage = (int)Math.Round(number * 100.0, MidpointRounding.AwayFromZero); + var pctString = percentage.ToString(CultureInfo.InvariantCulture); + ProgressValue = percentage; + ProgressText = string.Format(Se.Language.General.DownloadingXPercent, pctString); + }); + + var titleProgress = new Action(title => + { + Dispatcher.UIThread.Post(() => TitleText = title); + }); + + _downloadTaskPocketTtsCrispAsrModels = + _pocketTtsCrispAsrDownloadService.DownloadModels(PocketTtsCrispAsr.GetSetModelsFolder(), resolved, downloadProgress, titleProgress, _cancellationTokenSource.Token); + } + /// /// Downloads the audio.cpp engine archive for the picked backend. The archives are /// .tar.gz on macOS/Linux and .zip on Windows; UnpackZipStream sniffs the gzip magic and @@ -2124,6 +2645,52 @@ public void StartDownloadIndexTts25AudioCppModels(string? modelKey = null) _indexTts25AudioCppDownloadService.DownloadModels(resolved, downloadProgress, titleProgress, _cancellationTokenSource.Token); } + public void StartDownloadHiggsTtsAudioCppModels(string? modelKey = null) + { + var resolved = HiggsTtsAudioCpp.ResolveModelKey(modelKey); + var fileName = HiggsTtsAudioCpp.GetModelFileName(resolved); + TitleText = string.Format(Se.Language.General.DownloadingX, $"Higgs Audio v3 model ({resolved}): {fileName}"); + + var downloadProgress = new Progress(number => + { + var percentage = (int)Math.Round(number * 100.0, MidpointRounding.AwayFromZero); + var pctString = percentage.ToString(CultureInfo.InvariantCulture); + ProgressValue = percentage; + ProgressText = string.Format(Se.Language.General.DownloadingXPercent, pctString); + }); + + var titleProgress = new Action(title => + { + Dispatcher.UIThread.Post(() => TitleText = title); + }); + + _downloadTaskHiggsTtsAudioCppModels = + _higgsTtsAudioCppDownloadService.DownloadModels(resolved, downloadProgress, titleProgress, _cancellationTokenSource.Token); + } + + public void StartDownloadFishTtsAudioCppModels(string? modelKey = null) + { + var resolved = FishTtsAudioCpp.ResolveModelKey(modelKey); + var fileName = FishTtsAudioCpp.GetModelFileName(resolved); + TitleText = string.Format(Se.Language.General.DownloadingX, $"Fish Audio S2 Pro model ({resolved}): {fileName}"); + + var downloadProgress = new Progress(number => + { + var percentage = (int)Math.Round(number * 100.0, MidpointRounding.AwayFromZero); + var pctString = percentage.ToString(CultureInfo.InvariantCulture); + ProgressValue = percentage; + ProgressText = string.Format(Se.Language.General.DownloadingXPercent, pctString); + }); + + var titleProgress = new Action(title => + { + Dispatcher.UIThread.Post(() => TitleText = title); + }); + + _downloadTaskFishTtsAudioCppModels = + _fishTtsAudioCppDownloadService.DownloadModels(resolved, downloadProgress, titleProgress, _cancellationTokenSource.Token); + } + public void StartDownloadZonosTtsCrispAsrModels() { TitleText = string.Format(Se.Language.General.DownloadingX, $"Zonos TTS (CrispASR) models: {ZonosTtsCrispAsr.TalkerFileName}"); @@ -2345,6 +2912,7 @@ internal void OnClosing() DisposeQuietly(_downloadStreamQwen3TtsCrispAsrVoices); DisposeQuietly(_downloadStreamVibeVoiceCrispAsrVoices); DisposeQuietly(_downloadStreamIndexTtsCrispAsrVoices); + DisposeQuietly(_downloadStreamPocketTtsCrispAsrVoices); DisposeQuietly(_downloadStreamCosyVoice3CrispAsrVoices); DisposeQuietly(_downloadStreamF5TtsCrispAsrVoices); DisposeQuietly(_downloadStreamVoxCPM2CrispAsrVoices); diff --git a/src/ui/Features/Video/TextToSpeech/DownloadTts/DownloadTtsWindow.cs b/src/ui/Features/Video/TextToSpeech/DownloadTts/DownloadTtsWindow.cs index 89bf96a5734..971a9f77c60 100644 --- a/src/ui/Features/Video/TextToSpeech/DownloadTts/DownloadTtsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/DownloadTts/DownloadTtsWindow.cs @@ -38,6 +38,20 @@ public DownloadTtsWindow(DownloadTtsViewModel vm) var statusText = new TextBlock(); statusText.Bind(TextBlock.TextProperty, new Binding(nameof(vm.ProgressText))); + // The view models set Error on every failure path, but nothing rendered it - the user saw + // only the generic "Download failed" while the real cause was written to a property no + // window bound. DownloadVideoFromUrlWindow and DownloadCrispEmbedWindow show it this way. + var errorText = new TextBlock + { + Foreground = new SolidColorBrush(Color.FromRgb(0xC0, 0x39, 0x2B)), + TextWrapping = TextWrapping.Wrap, + }; + errorText.Bind(TextBlock.TextProperty, new Binding(nameof(vm.Error))); + errorText.Bind(IsVisibleProperty, new Binding(nameof(vm.Error)) + { + Converter = new Avalonia.Data.Converters.FuncValueConverter(s => !string.IsNullOrEmpty(s)), + }); + var buttonCancel = UiUtil.MakeButtonCancel(vm.CancelCommand); var buttonBar = UiUtil.MakeButtonBar(buttonCancel); @@ -50,14 +64,15 @@ public DownloadTtsWindow(DownloadTtsViewModel vm) titleText, progressBar, statusText, + errorText, buttonBar, } }; - Activated += delegate + UiUtil.FocusOnFirstActivation(this, () => { buttonCancel.Focus(); // hack to make OnKeyDown work - }; + }); } protected override void OnKeyDown(KeyEventArgs e) diff --git a/src/ui/Features/Video/TextToSpeech/ElevenLabsSettings/ElevenLabsSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/ElevenLabsSettings/ElevenLabsSettingsWindow.cs index 80d4b0d6619..634d2c0e9dc 100644 --- a/src/ui/Features/Video/TextToSpeech/ElevenLabsSettings/ElevenLabsSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/ElevenLabsSettings/ElevenLabsSettingsWindow.cs @@ -149,7 +149,7 @@ public ElevenLabsSettingsWindow(ElevenLabsSettingsViewModel vm) Content = grid; - Activated += delegate { sliderStability.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, sliderStability); // initial focus on an input, not an action button - a focused button clicks on bare Space } protected override void OnKeyDown(KeyEventArgs e) diff --git a/src/ui/Features/Video/TextToSpeech/EncodingSettings/EncodingSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/EncodingSettings/EncodingSettingsWindow.cs index d7559116452..3d8942979e2 100644 --- a/src/ui/Features/Video/TextToSpeech/EncodingSettings/EncodingSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/EncodingSettings/EncodingSettingsWindow.cs @@ -75,7 +75,7 @@ public EncodingSettingsWindow(EncodingSettingsViewModel vm) Content = grid; - Activated += delegate { comboEncoding.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, comboEncoding); // initial focus on an input, not an action button - a focused button clicks on bare Space } protected override void OnKeyDown(KeyEventArgs e) diff --git a/src/ui/Features/Video/TextToSpeech/Engines/AudioCppRuntime.cs b/src/ui/Features/Video/TextToSpeech/Engines/AudioCppRuntime.cs new file mode 100644 index 00000000000..db0a6528310 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/Engines/AudioCppRuntime.cs @@ -0,0 +1,71 @@ +using Nikse.SubtitleEdit.Logic.Config; +using System; +using System.IO; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; + +/// +/// The pieces of the audio.cpp install shared by every engine that runs on it (IndexTTS 2.5, +/// Higgs Audio v3, Fish Audio S2 Pro): one binaries folder, one server executable, and one +/// backend marker. The binaries are downloaded once and reused by all of them; each engine +/// still runs its own server process on its own loopback port with its own model. +/// +public static class AudioCppRuntime +{ + /// + /// Where the audio.cpp binaries live: <data>/audio.cpp/, a top-level folder like + /// CrispASR and llama.cpp — audio.cpp is a whole runtime rather than one model's engine. + /// + public static string GetSetEngineFolder() + { + var folder = Se.AudioCppFolder; + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + return folder; + } + + public static string GetServerExecutable() => + Path.Combine(GetSetEngineFolder(), OperatingSystem.IsWindows() ? "audiocpp_server.exe" : "audiocpp_server"); + + /// + /// ggml backend the installed archive was built for, stored at install time. The setting + /// keeps its historical IndexTts25-prefixed name — it predates the runtime being shared — + /// and renaming it would throw away every existing user's stored backend choice. + /// + public static string GetBackend() + { + var saved = Se.Settings.Video.TextToSpeech.IndexTts25AudioCppBackend; + if (!string.IsNullOrEmpty(saved)) + { + return saved; + } + + return OperatingSystem.IsMacOS() ? "metal" : "cpu"; + } + + /// + /// Turns the exit codes that mean "this build cannot run on this machine" into an + /// actionable message, since the process dies in the loader before it can print anything + /// useful of its own: + /// - Windows 0xC0000135 / -1073741515 (STATUS_DLL_NOT_FOUND): a GPU build without its + /// runtime. The Vulkan binaries import vulkan-1.dll (from the GPU driver) at load time. + /// - Linux 127: the dynamic loader could not find a shared library. The Linux CUDA + /// archive does NOT bundle libcudart.so.12 / libcublas.so.12 the way the Windows CUDA + /// zip bundles its DLLs, so it needs a system CUDA 12 runtime. + /// + public static string DescribeStartupExit(int exitCode, string backend) => exitCode switch + { + -1073741515 => $"The {backend} build could not load its GPU runtime library. " + + "Re-download the engine and pick the CPU variant.", + -1073741795 => "The CPU build uses instructions this processor does not have.", + 127 when string.Equals(backend, "cuda", StringComparison.OrdinalIgnoreCase) => + "The Linux CUDA build needs the CUDA 12 runtime (libcudart.so.12 and libcublas.so.12) " + + "installed on this system. Install the CUDA 12 runtime, or re-download the engine " + + "and pick the CPU or Vulkan variant.", + 127 => $"The {backend} build could not load a shared library it needs.", + _ => string.Empty, + }; +} diff --git a/src/ui/Features/Video/TextToSpeech/Engines/Confucius4TtsCrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/Confucius4TtsCrispAsr.cs new file mode 100644 index 00000000000..ec17ecdd2b2 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/Engines/Confucius4TtsCrispAsr.cs @@ -0,0 +1,851 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Video.SpeechToText.Engines; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Voices; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Download; +using Nikse.SubtitleEdit.Logic.Media; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; + +/// +/// Confucius4-TTS (NetEase Youdao, Apache-2.0) run through the CrispASR runtime. A two-stage +/// pipeline: a GPT-2 T2S model (24L/1280d) turns text into semantic tokens, a flow-matching +/// DiT + WaveNet S2A model renders them into mel frames conditioned on a w2v-BERT embedding plus +/// a CAM++ style vector from the reference audio, and a BigVGAN vocoder outputs 22.05 kHz +/// mono — the only 22 kHz TTS engine SE ships. Officially trained on 14 languages +/// (), passed as the startup -l flag. +/// +/// Four GGUFs are needed: +/// - confucius4-tts-t2s-{q8_0,f16}.gguf : the T2S core (passed as -m) +/// - confucius4-tts-s2a-{q8_0,f16}.gguf : flow-matching S2A (passed as --codec-model) +/// - confucius4-tts-bigvgan-22k-f16.gguf : BigVGAN vocoder (auto-discovered sibling) +/// - confucius4-tts-w2v-f16.gguf : w2v-BERT reference encoder (auto-discovered sibling) +/// +/// Q4_K is deliberately not offered. The registry's default quant makes the flow-matching +/// alignment break down audibly — an A/B against Q8_0 with the same text and reference confirmed +/// upstream's report (CrispASR #377): Q4_K sounds flat and robotic where Q8_0 holds the +/// reference's prosody. That earlier Q4_K test is also why this engine was first judged "too +/// robotic to ship". Since Q8_0 vs F16 only differs in the T2S/S2A core (the vocoder and w2v +/// companions are F16 in both), Q8_0 is the default. +/// +/// Zero-shot cloning is mandatory, not optional — without --voice conditioning the +/// output is unintelligible by design (there is no default-voice mode at all), so the voice combo +/// only offers imported reference WAVs and synthesis refuses to run without one. +/// +/// The S2A/vocoder chain works at 22.05 kHz and the w2v-BERT/CAM++ encoders at 16 kHz; crispasr +/// resamples the reference internally to both, so references are staged at 22.05 kHz — the mel +/// path reads them untouched and the encoder path downsamples cleanly. +/// +/// Like indextts/dots-tts, the backend applies the reference only at init from the startup +/// --voice flag (crispasr_backend_confucius4_tts.cpp), and /v1/audio/speech has no +/// per-request language field, so a change of voice, quant, language or step count restarts the +/// server. No ref-text either: conditioning is from audio alone, so imported WAVs need no +/// .txt sidecar. +/// +/// CLI shape (verified on v0.8.31, macOS/Metal, 2026-09-01): +/// crispasr --backend confucius4-tts -m confucius4-tts-t2s-q8_0.gguf \ +/// --codec-model confucius4-tts-s2a-q8_0.gguf \ +/// --voice reference.wav --i-have-rights -l en \ +/// --tts "Hello, how are you today?" --tts-output out.wav +/// +public class Confucius4TtsCrispAsr : ITtsEngine +{ + public string Name => "Confucius4-TTS (CrispASR)"; + public string Description => "Confucius4-TTS (NetEase Youdao), 14 languages with voice cloning, via CrispASR"; + public bool HasLanguageParameter => true; + public bool HasApiKey => false; + public bool HasRegion => false; + public bool HasModel => true; + public bool HasKeyFile => false; + public bool SupportsVoiceCloning => true; + public bool SupportsPerLineVoiceCloning => false; + + // Labels carry the total download (T2S + S2A + vocoder + w2v-BERT companion), not just the + // quantized pair, so the dropdown does not understate what the user is about to fetch. + public const string ModelKeyQ8_0 = "Q8_0 (~1.9 GB)"; + public const string ModelKeyF16 = "F16 (~2.6 GB)"; + public const string DefaultModelKey = ModelKeyQ8_0; + + public const string T2sQ8_0FileName = "confucius4-tts-t2s-q8_0.gguf"; + public const string T2sF16FileName = "confucius4-tts-t2s-f16.gguf"; + public const string S2aQ8_0FileName = "confucius4-tts-s2a-q8_0.gguf"; + public const string S2aF16FileName = "confucius4-tts-s2a-f16.gguf"; + + /// + /// BigVGAN 22.05 kHz vocoder. F16 for both quants — the Q8_0 variant is the same size to + /// within 4 KB, so there is nothing to save. + /// + public const string VocoderFileName = "confucius4-tts-bigvgan-22k-f16.gguf"; + + /// + /// w2v-BERT reference encoder (~824 MB, F16 only — no other quant exists in the repo). + /// CrispASR auto-discovers it as a sibling of the T2S GGUF; without it the T2S stage loses + /// its native condition_emb and clone similarity drops. + /// + public const string W2vFileName = "confucius4-tts-w2v-f16.gguf"; + + public const string BackendName = "confucius4-tts"; + + /// + /// Flow-matching Euler steps for the S2A stage, passed via --tts-steps. CrispASR's + /// default is 20; upstream's reference implementation samples at 25. + /// + public const int MinOdeSteps = 10; + public const int MaxOdeSteps = 40; + public const int DefaultOdeSteps = 20; + + // Exact byte sizes from the HF tree API (cstr/confucius4-tts-GGUF). Same truncation guard as + // the other CrispASR TTS engines — a partial GGUF crashes the loader at server startup. + private static readonly Dictionary ExpectedFileSizes = new(StringComparer.OrdinalIgnoreCase) + { + [T2sQ8_0FileName] = 718043648L, + [T2sF16FileName] = 1326049024L, + [S2aQ8_0FileName] = 169737216L, + [S2aF16FileName] = 222990272L, + [VocoderFileName] = 224624608L, + [W2vFileName] = 823502048L, + }; + + public static string ResolveModelKey(string? modelKey) + { + if (string.IsNullOrEmpty(modelKey)) + { + var saved = Se.Settings.Video.TextToSpeech.Confucius4TtsCrispAsrModel; + return string.IsNullOrEmpty(saved) ? DefaultModelKey : ResolveModelKey(saved); + } + + return modelKey switch + { + ModelKeyF16 => ModelKeyF16, + _ => ModelKeyQ8_0, + }; + } + + public static string GetT2sFileName(string? modelKey) => ResolveModelKey(modelKey) switch + { + ModelKeyF16 => T2sF16FileName, + _ => T2sQ8_0FileName, + }; + + /// + /// The S2A quant follows the T2S quant. Passed explicitly as --codec-model because the + /// backend's own sibling auto-discovery probes q4_k FIRST (crispasr_backend_confucius4_tts.cpp) + /// — a stray q4_k file in the models folder would silently degrade quality. + /// + public static string GetS2aFileName(string? modelKey) => ResolveModelKey(modelKey) switch + { + ModelKeyF16 => S2aF16FileName, + _ => S2aQ8_0FileName, + }; + + public static int ResolveOdeSteps() + { + var saved = Se.Settings.Video.TextToSpeech.Confucius4TtsCrispAsrOdeSteps; + return saved <= 0 ? DefaultOdeSteps : Math.Clamp(saved, MinOdeSteps, MaxOdeSteps); + } + + public static bool IsValidLocalModelFile(string path, string fileName) + { + if (!File.Exists(path)) + { + return false; + } + + if (!ExpectedFileSizes.TryGetValue(fileName, out var expected)) + { + return true; + } + + try + { + return new FileInfo(path).Length == expected; + } + catch + { + return false; + } + } + + private static readonly HttpClient HttpClient = new() + { + Timeout = TimeSpan.FromMinutes(10), + }; + private static readonly SemaphoreSlim ServerLock = new(1, 1); + private static Process? _serverProcess; + private static int _serverPort; + private static string? _serverLaunchCommand; + // Same startup-flag cloning as indextts/dots-tts: the reference is read from the --voice path + // at server start, never from the request body, so a voice change means tear down and restart. + // Tracked alongside the quant, language and ODE steps, which are equally baked in. + private static string? _serverVoicePath; + private static string? _serverModelKey; + private static string? _serverLanguageArg; + private static int _serverOdeSteps; + private static bool _processExitHooked; + private static readonly StringBuilder _serverLog = new(); + + private static string ServerBaseUrl => $"http://127.0.0.1:{_serverPort}"; + + public Task IsInstalled(string? region) + { + return Task.FromResult(File.Exists(GetCrispAsrExecutable())); + } + + public override string ToString() => Name; + + /// + /// Path to the crispasr executable installed by the speech-to-text feature. Shared with every + /// other CrispASR-backed engine. + /// + public static string GetCrispAsrExecutable() + { + return new CrispAsrCohere().GetExecutable(); + } + + public static DownloadHashManager.UpdateStatus GetEngineUpdateStatus() + { + var exe = GetCrispAsrExecutable(); + if (!File.Exists(exe)) + { + return DownloadHashManager.UpdateStatus.Unknown; + } + + var folder = Path.GetDirectoryName(exe); + return string.IsNullOrEmpty(folder) + ? DownloadHashManager.UpdateStatus.Unknown + : DownloadHashManager.GetSidecarStatus(folder); + } + + public static string GetSetFolder() + { + if (!Directory.Exists(Se.TextToSpeechFolder)) + { + Directory.CreateDirectory(Se.TextToSpeechFolder); + } + + var folder = Path.Combine(Se.TextToSpeechFolder, "Confucius4TtsCrispAsr"); + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + return folder; + } + + public static string GetSetModelsFolder() + { + // Like the other CrispASR-backed engines, the GGUFs live alongside CrispASR's + // speech-to-text models in CrispASR/models/ rather than under TextToSpeech/. + var modelsFolder = Path.Combine(Se.CrispAsrFolder, "models"); + if (!Directory.Exists(modelsFolder)) + { + Directory.CreateDirectory(modelsFolder); + } + + return modelsFolder; + } + + public static string GetSetVoicesFolder() + { + var voicesFolder = Path.Combine(GetSetFolder(), "voices"); + if (!Directory.Exists(voicesFolder)) + { + Directory.CreateDirectory(voicesFolder); + } + + SeedVoicesFromQwen3TtsCppIfEmpty(voicesFolder); + return voicesFolder; + } + + private static bool _voiceSeedAttempted; + + /// + /// One-time best-effort seed of WAV reference voices from qwen3-tts.cpp's voices folder, so a + /// fresh install has something in the voice combo — with no reference this backend produces + /// nothing usable at all. Resampled to 22.05 kHz on the way in, matching + /// . + /// + private static void SeedVoicesFromQwen3TtsCppIfEmpty(string voicesFolder) + { + if (_voiceSeedAttempted) + { + return; + } + _voiceSeedAttempted = true; + + try + { + if (Directory.EnumerateFiles(voicesFolder, "*.wav").Any()) + { + return; + } + + var sourceFolder = Qwen3TtsCpp.GetSetVoicesFolder(); + if (!Directory.Exists(sourceFolder) || !Directory.EnumerateFiles(sourceFolder, "*.wav").Any()) + { + return; + } + + foreach (var src in Directory.GetFiles(sourceFolder, "*.wav")) + { + var dest = Path.Combine(voicesFolder, Path.GetFileName(src)); + VoiceSeedHelper.CopyOrResample(src, dest, 22050, "Confucius4-TTS (CrispASR)"); + } + } + catch (Exception ex) + { + Se.LogError(ex, "Confucius4-TTS (CrispASR): voice seeding from qwen3-tts.cpp folder failed"); + } + } + + public static string GetT2sPath(string? modelKey = null) => + Path.Combine(GetSetModelsFolder(), GetT2sFileName(modelKey)); + + public static string GetS2aPath(string? modelKey = null) => + Path.Combine(GetSetModelsFolder(), GetS2aFileName(modelKey)); + + public static string GetVocoderPath() => + Path.Combine(GetSetModelsFolder(), VocoderFileName); + + public static string GetW2vPath() => + Path.Combine(GetSetModelsFolder(), W2vFileName); + + public static bool AreModelsInstalled(string? modelKey = null) => + IsValidLocalModelFile(GetT2sPath(modelKey), GetT2sFileName(modelKey)) + && IsValidLocalModelFile(GetS2aPath(modelKey), GetS2aFileName(modelKey)) + && IsValidLocalModelFile(GetVocoderPath(), VocoderFileName) + && IsValidLocalModelFile(GetW2vPath(), W2vFileName); + + /// + /// Path crispasr's --auto-download writes GGUFs to, so SE's downloader can adopt files a + /// previous CLI run already fetched instead of re-pulling gigabytes. + /// + public static string GetCrispAsrCacheFolder() => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "crispasr"); + + public static bool TrySeedModelFromCrispAsrCache(string fileName, string destinationPath) + { + if (IsValidLocalModelFile(destinationPath, fileName)) + { + return true; + } + + try + { + if (File.Exists(destinationPath)) + { + Se.LogError($"Confucius4-TTS (CrispASR): removing wrong-sized local model file {destinationPath}"); + File.Delete(destinationPath); + } + + var cachePath = Path.Combine(GetCrispAsrCacheFolder(), fileName); + if (!IsValidLocalModelFile(cachePath, fileName)) + { + return false; + } + + File.Copy(cachePath, destinationPath); + return true; + } + catch (Exception ex) + { + Se.LogError(ex, $"Confucius4-TTS (CrispASR): cache seed copy failed for {fileName}"); + return false; + } + } + + public async Task GetVoices(string language) + { + var result = new List(); + + // Voice cloning only — there are no preset voices and no default voice at all (the + // backend's output is unintelligible without a reference), so the combo is empty until + // the user imports a reference WAV (or the qwen3-tts.cpp seed above runs). + // Off the UI thread: GetSetVoicesFolder does one-time seeding through ffmpeg. + var voicesFolder = await Task.Run(GetSetVoicesFolder); + if (Directory.Exists(voicesFolder)) + { + foreach (var file in Directory.GetFiles(voicesFolder, "*.wav")) + { + var name = Path.GetFileNameWithoutExtension(file).Replace('_', ' '); + result.Add(new Voice(new Confucius4TtsVoice(name, file))); + } + } + + return result.ToArray(); + } + + public bool IsVoiceInstalled(Voice voice) => true; + + public Task GetRegions() => Task.FromResult(Array.Empty()); + + public Task GetModels() => Task.FromResult(new[] { ModelKeyQ8_0, ModelKeyF16 }); + + public Task GetLanguages(Voice voice, string? model) => Task.FromResult(Confucius4TtsLanguages.All); + + public Task RefreshVoices(string language, CancellationToken cancellationToken) => + GetVoices(language); + + public async Task Speak( + string text, + string outputFolder, + Voice voice, + TtsLanguage? language, + string? region, + string? model, + CancellationToken cancellationToken) + { + if (voice.EngineVoice is not Confucius4TtsVoice confuciusVoice) + { + throw new ArgumentException("Voice is not a Confucius4TtsVoice"); + } + + if (string.IsNullOrEmpty(confuciusVoice.FilePath)) + { + throw new InvalidOperationException( + "Confucius4-TTS (CrispASR) requires a reference voice WAV — without one the " + + "output is unintelligible by design. Import one via the voice settings, then " + + "pick it in the voice combo. Reference WAV should be 3-10 s of clean speech."); + } + + var modelKey = ResolveModelKey(model); + var languageArg = Confucius4TtsLanguages.ResolveLanguageArg(language); + await EnsureServerRunningAsync(modelKey, confuciusVoice.FilePath, languageArg, cancellationToken); + + var outputFileName = Path.Combine(TtsOutputFolder.Resolve(outputFolder, GetSetFolder), Guid.NewGuid() + ".wav"); + + // Deliberately NO `voice` field: like indextts/dots-tts, the confucius4-tts backend takes + // its reference from the startup --voice path (applied only in the adapter's init), and + // the server rejects absolute paths in the request body outright (path-traversal guard). + // Also no `ref_text`: conditioning is w2v-BERT + CAM++ from the audio alone, so the field + // has nothing to bind to. No `speed` either — confucius4-tts is not in crispasr's + // --tts-speed backend list; the quality/speed knob that does work here is the ODE step + // count passed at server launch. No temperature: the backend's own 0.8 default matters + // (greedy T2S decode degenerates into a repeat loop that runs to the token cap). + var payload = new Dictionary + { + ["input"] = text, + ["response_format"] = "wav", + }; + + // Attests the user's own imported reference and the AI-disclosure duty; see + // CrispAsrTtsProvenance. Skipped when voice cloning has not been accepted in settings. + CrispAsrTtsProvenance.AddSpeechAttestations(payload); + + var body = JsonSerializer.Serialize(payload); + using var content = new StringContent(body, Encoding.UTF8, "application/json"); + Se.WriteToolsLog($"Confucius4-TTS (CrispASR): POST {ServerBaseUrl}/v1/audio/speech (voice={confuciusVoice}, language={(string.IsNullOrEmpty(languageArg) ? "(en)" : languageArg)}, textLen={text.Length})"); + + HttpResponseMessage response; + try + { + response = await HttpClient.PostAsync($"{ServerBaseUrl}/v1/audio/speech", content, cancellationToken); + } + catch (HttpRequestException ex) + { + var serverLog = SnapshotServerLog(); + var launchCommand = _serverLaunchCommand; + var died = _serverProcess?.HasExited == true; + if (died) + { + StopServerInternal(); + } + + var failMsg = $"Confucius4-TTS (CrispASR) request failed — Voice: {confuciusVoice}, Text: {text}, " + + $"RequestJson: {body}, ServerExited: {died}, ServerLog: {serverLog}" + + LaunchCmdSuffix(launchCommand); + Se.LogError(ex, failMsg); + Se.WriteToolsLog(failMsg); + + throw new InvalidOperationException( + (died + ? "Confucius4-TTS (CrispASR) — the crispasr server crashed during synthesis." + : "Confucius4-TTS (CrispASR) request failed — the connection to the crispasr server was dropped.") + + (string.IsNullOrEmpty(serverLog) ? string.Empty : $"{Environment.NewLine}Server log:{Environment.NewLine}{serverLog}") + + LaunchCmdSuffix(launchCommand), + ex); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + var errorBody = await SafeReadErrorAsync(response, cancellationToken); + var serverLog = SnapshotServerLog(); + var launchCommand = _serverLaunchCommand; + var errMsg = $"Confucius4-TTS (CrispASR) server error {(int)response.StatusCode} {response.StatusCode} — " + + $"Voice: {confuciusVoice}, Text: {text}, RequestJson: {body}, " + + $"ResponseBody: {errorBody}, ServerLog: {serverLog}" + + LaunchCmdSuffix(launchCommand); + Se.LogError(errMsg); + Se.WriteToolsLog(errMsg); + throw new InvalidOperationException( + $"Confucius4-TTS (CrispASR) synthesis failed ({(int)response.StatusCode}): {errorBody}" + + (string.IsNullOrEmpty(serverLog) ? string.Empty : $"{Environment.NewLine}Server log:{Environment.NewLine}{serverLog}") + + LaunchCmdSuffix(launchCommand)); + } + + await using var fileStream = File.Create(outputFileName); + await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); + await contentStream.CopyToAsync(fileStream, cancellationToken); + } + + return new TtsResult(outputFileName, text); + } + + private static string FormatLaunchCommand(string exe, System.Collections.ObjectModel.Collection args) + { + static string Quote(string s) => + !string.IsNullOrEmpty(s) && s.IndexOfAny(new[] { ' ', '\t' }) >= 0 + ? "\"" + s.Replace("\"", "\\\"") + "\"" + : s; + + var sb = new StringBuilder(Quote(exe)); + foreach (var a in args) + { + sb.Append(' ').Append(Quote(a)); + } + return sb.ToString(); + } + + private static string LaunchCmdSuffix(string? launchCommand) => + string.IsNullOrEmpty(launchCommand) + ? string.Empty + : $"{Environment.NewLine}Launch command: {launchCommand}"; + + private static async Task SafeReadErrorAsync(HttpResponseMessage response, CancellationToken ct) + { + try + { + return await response.Content.ReadAsStringAsync(ct); + } + catch (Exception ex) + { + return $""; + } + } + + private static async Task EnsureServerRunningAsync(string modelKey, string voicePath, string languageArg, CancellationToken ct) + { + var odeSteps = ResolveOdeSteps(); + + if (_serverProcess is { HasExited: false } && _serverPort != 0 + && string.Equals(_serverVoicePath, voicePath, StringComparison.OrdinalIgnoreCase) + && string.Equals(_serverModelKey, modelKey, StringComparison.OrdinalIgnoreCase) + && string.Equals(_serverLanguageArg, languageArg, StringComparison.OrdinalIgnoreCase) + && _serverOdeSteps == odeSteps) + { + return; + } + + await ServerLock.WaitAsync(ct); + try + { + if (_serverProcess is { HasExited: false } && _serverPort != 0 + && string.Equals(_serverVoicePath, voicePath, StringComparison.OrdinalIgnoreCase) + && string.Equals(_serverModelKey, modelKey, StringComparison.OrdinalIgnoreCase) + && string.Equals(_serverLanguageArg, languageArg, StringComparison.OrdinalIgnoreCase) + && _serverOdeSteps == odeSteps) + { + return; + } + + if (_serverProcess != null) + { + StopServerInternal(); + } + + var exe = GetCrispAsrExecutable(); + if (!File.Exists(exe)) + { + throw new FileNotFoundException( + "CrispASR executable not found. Install CrispASR via Video → Audio to text first.", exe); + } + + var t2sFileName = GetT2sFileName(modelKey); + var t2s = GetT2sPath(modelKey); + var hasLocalT2s = IsValidLocalModelFile(t2s, t2sFileName); + var s2aFileName = GetS2aFileName(modelKey); + var s2a = GetS2aPath(modelKey); + var hasLocalS2a = IsValidLocalModelFile(s2a, s2aFileName); + + var port = FindFreeLoopbackPort(); + var psi = new ProcessStartInfo + { + WorkingDirectory = Path.GetDirectoryName(exe) ?? GetSetFolder(), + FileName = exe, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + // The server writes UTF-8; without these the captured log reaches bug reports as + // mojibake on Windows (#13572). + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + psi.ArgumentList.Add("--server"); + psi.ArgumentList.Add("--backend"); + psi.ArgumentList.Add(BackendName); + psi.ArgumentList.Add("-m"); + psi.ArgumentList.Add(hasLocalT2s ? t2s : "auto"); + if (hasLocalS2a) + { + // Explicit rather than sibling discovery: the backend probes q4_k FIRST when left + // to find the S2A on its own, and q4_k is the quant with the audible flow-matching + // degradation this engine deliberately does not ship. + psi.ArgumentList.Add("--codec-model"); + psi.ArgumentList.Add(s2a); + } + if (!hasLocalT2s || !hasLocalS2a) + { + // Fallback when SE's own download was skipped or is incomplete. Note the registry + // set behind `-m auto` is the q4_k pair — degraded but intelligible, and better + // than refusing to speak; the SE downloader replaces it with Q8_0 on next install. + psi.ArgumentList.Add("--auto-download"); + } + psi.ArgumentList.Add("--host"); + psi.ArgumentList.Add("127.0.0.1"); + psi.ArgumentList.Add("--port"); + psi.ArgumentList.Add(port.ToString(CultureInfo.InvariantCulture)); + psi.ArgumentList.Add("--voice-dir"); + psi.ArgumentList.Add(GetSetVoicesFolder()); + // Required: the confucius4-tts backend clones only from the startup --voice path, and + // without a reference the output is unintelligible by design. The w2v-BERT companion + // next to the T2S GGUF is picked up automatically when this is set. + psi.ArgumentList.Add("--voice"); + psi.ArgumentList.Add(voicePath); + // Target language for the Chinese prompt template. Empty = no flag, which the backend + // reads as English. + if (!string.IsNullOrEmpty(languageArg)) + { + psi.ArgumentList.Add("-l"); + psi.ArgumentList.Add(languageArg); + } + psi.ArgumentList.Add("--tts-steps"); + psi.ArgumentList.Add(odeSteps.ToString(CultureInfo.InvariantCulture)); + CrispAsrTtsProvenance.AddServerMarkingArgs(psi.ArgumentList, exe); + + var process = Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start crispasr (confucius4-tts)"); + + var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); + _serverLaunchCommand = launchCommand; + Se.WriteToolsLog("Confucius4-TTS (CrispASR) server starting — " + + $"PID: {process.Id}, " + + $"Cmd: {launchCommand}"); + + lock (_serverLog) _serverLog.Clear(); + process.ErrorDataReceived += (_, e) => + { + if (e.Data != null) lock (_serverLog) _serverLog.AppendLine(e.Data); + }; + process.OutputDataReceived += (_, e) => + { + if (e.Data != null) lock (_serverLog) _serverLog.AppendLine(e.Data); + }; + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + + _serverProcess = process; + _serverPort = port; + _serverVoicePath = voicePath; + _serverModelKey = modelKey; + _serverLanguageArg = languageArg; + _serverOdeSteps = odeSteps; + HookProcessExitOnce(); + + // ~1.9 GB across four GGUFs on the warm path; first run may also be pulling those + // gigabytes through --auto-download. + var timeoutMinutes = hasLocalT2s && hasLocalS2a ? 10 : 30; + var deadline = DateTime.UtcNow.AddMinutes(timeoutMinutes); + while (DateTime.UtcNow < deadline) + { + ct.ThrowIfCancellationRequested(); + if (process.HasExited) + { + var tail = SnapshotServerLog(); + var exitCode = process.ExitCode; + var exitedLaunchCommand = _serverLaunchCommand; + _serverProcess = null; + _serverPort = 0; + _serverLaunchCommand = null; + _serverVoicePath = null; + _serverModelKey = null; + _serverLanguageArg = null; + _serverOdeSteps = 0; + throw new InvalidOperationException( + $"crispasr (confucius4-tts) exited during startup (code {exitCode}). Output: {tail}" + + LaunchCmdSuffix(exitedLaunchCommand)); + } + if (await ProbeHealthAsync(port, TimeSpan.FromSeconds(2), ct)) + { + return; + } + await Task.Delay(TimeSpan.FromSeconds(1), ct); + } + + var lastOutput = SnapshotServerLog(); + var timeoutLaunchCommand = _serverLaunchCommand; + StopServerInternal(); + throw new TimeoutException( + $"crispasr (confucius4-tts) did not report healthy within {timeoutMinutes} minutes. Last output: {lastOutput}" + + LaunchCmdSuffix(timeoutLaunchCommand)); + } + finally + { + ServerLock.Release(); + } + } + + private static string SnapshotServerLog() + { + lock (_serverLog) + { + var s = _serverLog.ToString().TrimEnd(); + return s.Length > 2000 ? s[^2000..] : s; + } + } + + private static async Task ProbeHealthAsync(int port, TimeSpan timeout, CancellationToken ct) + { + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + using var resp = await HttpClient.GetAsync($"http://127.0.0.1:{port}/health", cts.Token); + return resp.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + private static int FindFreeLoopbackPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + private static void HookProcessExitOnce() + { + if (_processExitHooked) + { + return; + } + _processExitHooked = true; + AppDomain.CurrentDomain.ProcessExit += (_, _) => StopServerInternal(); + } + + /// + /// Stop the running crispasr (confucius4-tts) server if any, releasing GPU memory. Called by + /// TextToSpeechViewModel when switching engines or closing the TTS window so the + /// CrispASR-based engines don't pile up in VRAM. + /// + public static void StopServer() => StopServerInternal(); + + private static void StopServerInternal() + { + var p = _serverProcess; + _serverProcess = null; + _serverPort = 0; + _serverLaunchCommand = null; + _serverVoicePath = null; + _serverModelKey = null; + _serverLanguageArg = null; + _serverOdeSteps = 0; + if (p == null) + { + return; + } + try + { + if (!p.HasExited) + { + p.Kill(entireProcessTree: true); + p.WaitForExit(2000); + } + } + catch + { + // best effort + } + finally + { + p.Dispose(); + } + } + + private static string GetUniqueDestinationFileName(string folder, string baseName) + { + var candidate = Path.Combine(folder, baseName + ".wav"); + if (!File.Exists(candidate)) + { + return candidate; + } + + var number = 1; + do + { + candidate = Path.Combine(folder, $"{baseName}_{number}.wav"); + number++; + } while (File.Exists(candidate)); + + return candidate; + } + + public bool ImportVoice(string fileName) + { + if (string.IsNullOrEmpty(fileName) || !File.Exists(fileName)) + { + return false; + } + + var voicesFolder = GetSetVoicesFolder(); + var baseName = Path.GetFileNameWithoutExtension(fileName); + var destinationFileName = GetUniqueDestinationFileName(voicesFolder, baseName); + + // 22.05 kHz mono: crispasr resamples the reference to 22.05 kHz for the S2A mel path and + // to 16 kHz for the w2v-BERT/CAM++ encoders, so staging at 22.05 kHz makes the mel path + // resample-free and leaves the encoders a clean downsample. No .txt sidecar: conditioning + // is from the audio alone, so the reference transcript is never needed. + try + { + var process = FfmpegGenerator.ConvertToMono22kHzWav(fileName, destinationFileName); + if (!process.Start()) + { + return false; + } + + process.WaitForExit(); + } + catch (Exception ex) + { + Se.LogError(ex, "Confucius4-TTS (CrispASR) voice import failed (ffmpeg conversion)."); + return false; + } + + return File.Exists(destinationFileName); + } +} diff --git a/src/ui/Features/Video/TextToSpeech/Engines/Confucius4TtsLanguages.cs b/src/ui/Features/Video/TextToSpeech/Engines/Confucius4TtsLanguages.cs new file mode 100644 index 00000000000..6148675736a --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/Engines/Confucius4TtsLanguages.cs @@ -0,0 +1,97 @@ +using Nikse.SubtitleEdit.Logic.Config; +using System; +using System.Linq; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; + +/// +/// The 14 languages Confucius4-TTS is trained on, per the NetEase Youdao technical report +/// (arXiv 2608.11650): Chinese, English, Japanese, Korean, German, French, Spanish, Indonesian, +/// Italian, Thai, Portuguese, Russian, Malay and Vietnamese. +/// +/// crispasr's confucius4-tts backend maps the -l ISO code onto the Chinese-language +/// prompt template the model was trained with ("请用X语朗读接下来的文字", +/// src/confucius4_tts.cpp) — an invented prompt string derails the T2S stage, which is why the +/// codes are passed through verbatim rather than spelled out. The backend's map actually +/// carries ~97 codes, but everything outside these 14 is a best-effort extension the model was +/// never trained on, so only the official list is offered here. +/// +/// There is deliberately no "Auto" entry: the backend has no language detection — with no +/// -l flag it silently reads everything as English, so an "Auto" label would just be a +/// misleading name for English. English leads the list as the default pick instead. +/// +/// Display names are taken from so the engines label the same +/// language identically in the UI; all 14 codes are present there. +/// +internal static class Confucius4TtsLanguages +{ + /// ISO-639-1 codes passed to crispasr's -l verbatim. English first — it is + /// the combo's default when nothing is saved, and no -l flag also means English. + private static readonly string[] Supported = + { + "en", "zh", "ja", "ko", "de", "fr", "es", "id", "it", "th", "pt", "ru", "ms", "vi", + }; + + /// + /// English first (the backend's implicit default), then the other 13 sorted by display name. + /// + public static readonly TtsLanguage[] All = Build(); + + private static TtsLanguage[] Build() + { + var omniNames = OmniVoiceLanguages.All.ToDictionary(l => l.Code, l => l.Name, StringComparer.OrdinalIgnoreCase); + + var languages = Supported + .Skip(1) + .Select(code => new TtsLanguage(omniNames.TryGetValue(code, out var name) ? name : code, code)) + .OrderBy(l => l.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + languages.Insert(0, new TtsLanguage(omniNames.TryGetValue("en", out var en) ? en : "English", "en")); + return languages.ToArray(); + } + + /// + /// The value to pass to crispasr's -l for , or an empty + /// string when no flag should be passed (which the backend reads as English). + /// + public static string ResolveLanguageArg(TtsLanguage? language) + { + // A null language means the CALLER had none to hand over, not a user choice - the cast + // dialog's voice-test button and cross-engine cast rows both pass null and rely on the + // engine falling back to its own saved default (#13272). + if (language == null) + { + return ResolveSavedLanguageArg(); + } + + var code = language.Code; + if (string.IsNullOrWhiteSpace(code)) + { + return string.Empty; + } + + // Guard against a language object left over from another engine (the view model can hold + // one while switching engines): only values this engine actually advertises are passed on. + return All.Any(l => string.Equals(l.Code, code, StringComparison.OrdinalIgnoreCase)) + ? code + : string.Empty; + } + + /// + /// The language code behind the pick saved by the main TTS window, or an empty string when + /// nothing is saved. The setting stores the DISPLAY NAME, which is how + /// TextToSpeechViewModel writes and restores it. + /// + public static string ResolveSavedLanguageArg() + { + var savedName = Se.Settings.Video.TextToSpeech.Confucius4TtsCrispAsrLanguage; + if (string.IsNullOrWhiteSpace(savedName)) + { + return string.Empty; + } + + return All.FirstOrDefault(l => string.Equals(l.Name, savedName, StringComparison.OrdinalIgnoreCase))?.Code + ?? string.Empty; + } +} diff --git a/src/ui/Features/Video/TextToSpeech/Engines/DotsTtsCrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/DotsTtsCrispAsr.cs new file mode 100644 index 00000000000..c3d298614ef --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/Engines/DotsTtsCrispAsr.cs @@ -0,0 +1,827 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Video.SpeechToText.Engines; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Voices; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Download; +using Nikse.SubtitleEdit.Logic.Media; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; + +/// +/// dots.tts SOAR (dots studio, Apache-2.0) run through the CrispASR runtime. A 2B continuous +/// autoregressive TTS model: a Qwen2.5-1.5B backbone drives an 18-layer flow-matching DiT that +/// predicts acoustic latents directly, and a BigVGAN vocoder renders them at 48 kHz — the +/// highest output rate of any TTS engine SE ships (the others are 24 kHz). There is no discrete +/// audio codec anywhere in the stack, and the backbone reads BPE text rather than phonemes, so no +/// G2P dictionary is involved. +/// +/// SOAR is the post-trained variant (Self-corrective Alignment): upstream reports the best +/// zero-shot speaker similarity of the three dots.tts checkpoints, which is why it is the one +/// CrispASR ships. +/// +/// Three GGUFs are needed: +/// - dots-tts-soar-{q4_k,q8_0,f16}.gguf : the core model (LLM + PatchEncoder + DiT) +/// - dots-tts-soar-vocoder-f16.gguf : BigVGAN 48 kHz vocoder (required companion) +/// - dots-tts-soar-spk-f16.gguf : CAM++ speaker encoder (required for voice cloning) +/// +/// The quants are deliberately mixed: cstr keeps the flow-matching DiT at F16 in every +/// quant, because it runs in a CFG Euler ODE loop (~16 steps x 18 layers x 2 CFG passes) where +/// per-step quantization noise compounds and derails generation — a fully-Q8 DiT produces no-EOS +/// runaway. Only the LLM and PatchEncoder are quantized, so Q4_K is a legitimate choice here +/// rather than the usual sub-8-bit gamble. Do not re-quantize these files. +/// +/// Verified against the pinned CrispASR v0.8.29 on macOS/Metal (2026-08-24): CLI and server mode +/// both work with the exact flags and payload below, output is 48 kHz mono, and the clone tracks +/// the reference speaker (median f0: reference 190.3 Hz, clone 195.8 Hz, preset voice 124.8 Hz). +/// Note that v0.8.29's --list-backends-json does not advertise the +/// voice-cloning capability for this backend even though cloning demonstrably works — the +/// capability bitmask is stale, not the feature. +/// +/// It is slow. Measured in server mode on an M4 at 16 ODE steps, same text both times: +/// F16 RTF 7.64 (3.5 s of audio in 26.91 s), Q8_0 RTF 7.60 (26.77 s) — against ~1.6 for +/// IndexTTS 2.5 on the same machine. Synthesis speed is therefore independent of the quant, +/// exactly as the mixed-quant design predicts: the cost is the CFG Euler loop in the DiT, which +/// stays F16 in all of them. What a smaller quant does buy is load time — the server reported +/// healthy in 4 s on Q8_0 versus 21 s on F16 — which is why Q8_0 is the default here. +/// +/// The one lever on synthesis time is the ODE step count, and it scales close to linearly: +/// 4 / 16 / 32 steps took 25 / 38 / 68 s wall including model load. +/// +/// CLI shape: +/// crispasr --backend dots-tts -m dots-tts-soar-q8_0.gguf \ +/// --voice reference.wav --i-have-rights \ +/// --tts "Hello, how are you today?" --tts-output out.wav +/// +/// Unlike F5-TTS / CosyVoice3, dots.tts conditions on a CAM++ speaker embedding (a 512-d x-vector +/// projected to a 1024-d g_cond) rather than by continuing a transcribed reference, so there is no +/// reference-transcript parameter at all — an imported WAV needs no .txt sidecar. +/// +public class DotsTtsCrispAsr : ITtsEngine +{ + public string Name => "dots.tts (CrispASR)"; + public string Description => "dots.tts SOAR 2B, 48 kHz with voice cloning, via CrispASR"; + public bool HasLanguageParameter => false; + public bool HasApiKey => false; + public bool HasRegion => false; + public bool HasModel => true; + public bool HasKeyFile => false; + public bool SupportsVoiceCloning => true; + public bool SupportsPerLineVoiceCloning => false; + + // Labels carry the total download (core + vocoder + speaker encoder), not just the core file, + // so the dropdown does not understate what the user is about to fetch. + public const string ModelKeyQ4K = "Q4_K (~2.4 GB)"; + public const string ModelKeyQ8_0 = "Q8_0 (~3.5 GB)"; + public const string ModelKeyF16 = "F16 (~5.0 GB)"; + public const string DefaultModelKey = ModelKeyQ8_0; + + public const string CoreQ4KFileName = "dots-tts-soar-q4_k.gguf"; + public const string CoreQ8_0FileName = "dots-tts-soar-q8_0.gguf"; + public const string CoreF16FileName = "dots-tts-soar-f16.gguf"; + + /// + /// BigVGAN vocoder. Kept at F16 for every core quant — it is only ~345 MB, and it is the last + /// stage before the waveform, so this is the one place where saving 25 MB is not worth it. + /// + public const string VocoderFileName = "dots-tts-soar-vocoder-f16.gguf"; + + /// + /// CAM++ speaker encoder (~14 MB). CrispASR auto-discovers it as a sibling of the core GGUF + /// and only loads it when --voice is passed, so there is no flag for it — it just has to be + /// in the same folder. Without it the backend cannot clone. + /// + public const string SpeakerEncoderFileName = "dots-tts-soar-spk-f16.gguf"; + + public const string BackendName = "dots-tts"; + + /// + /// Flow-matching Euler steps. Upstream recommends 10-32 (higher = better, slower); + /// CrispASR's default is 16. Passed to the server through CRISPASR_DOTS_ODE_STEPS — + /// there is no CLI flag for it. + /// + public const int MinOdeSteps = 8; + public const int MaxOdeSteps = 32; + public const int DefaultOdeSteps = 16; + + private const string OdeStepsEnvironmentVariable = "CRISPASR_DOTS_ODE_STEPS"; + + // Exact byte sizes from the HF tree API (cstr/dots-tts-soar-GGUF). Same truncation guard as + // the other CrispASR TTS engines — a partial GGUF crashes the loader at server startup. + private static readonly Dictionary ExpectedFileSizes = new(StringComparer.OrdinalIgnoreCase) + { + [CoreQ4KFileName] = 2322162816L, + [CoreQ8_0FileName] = 3128255616L, + [CoreF16FileName] = 4639679552L, + [VocoderFileName] = 362123680L, + [SpeakerEncoderFileName] = 14953856L, + }; + + public static string ResolveModelKey(string? modelKey) + { + if (string.IsNullOrEmpty(modelKey)) + { + var saved = Se.Settings.Video.TextToSpeech.DotsTtsCrispAsrModel; + return string.IsNullOrEmpty(saved) ? DefaultModelKey : ResolveModelKey(saved); + } + + return modelKey switch + { + ModelKeyQ4K => ModelKeyQ4K, + ModelKeyF16 => ModelKeyF16, + _ => ModelKeyQ8_0, + }; + } + + public static string GetCoreFileName(string? modelKey) => ResolveModelKey(modelKey) switch + { + ModelKeyQ4K => CoreQ4KFileName, + ModelKeyF16 => CoreF16FileName, + _ => CoreQ8_0FileName, + }; + + public static int ResolveOdeSteps() + { + var saved = Se.Settings.Video.TextToSpeech.DotsTtsCrispAsrOdeSteps; + return saved <= 0 ? DefaultOdeSteps : Math.Clamp(saved, MinOdeSteps, MaxOdeSteps); + } + + public static bool IsValidLocalModelFile(string path, string fileName) + { + if (!File.Exists(path)) + { + return false; + } + + if (!ExpectedFileSizes.TryGetValue(fileName, out var expected)) + { + return true; + } + + try + { + return new FileInfo(path).Length == expected; + } + catch + { + return false; + } + } + + private static readonly HttpClient HttpClient = new() + { + Timeout = TimeSpan.FromMinutes(10), + }; + private static readonly SemaphoreSlim ServerLock = new(1, 1); + private static Process? _serverProcess; + private static int _serverPort; + private static string? _serverLaunchCommand; + // Same startup-flag cloning as indextts/f5/voxcpm2: the reference is read from the --voice + // path at server start, never from the request body, so a voice change means tear down and + // restart. Tracked alongside the quant and the ODE-step count, which are equally baked in. + private static string? _serverVoicePath; + private static string? _serverModelKey; + private static int _serverOdeSteps; + private static bool _processExitHooked; + private static readonly StringBuilder _serverLog = new(); + + private static string ServerBaseUrl => $"http://127.0.0.1:{_serverPort}"; + + public Task IsInstalled(string? region) + { + return Task.FromResult(File.Exists(GetCrispAsrExecutable())); + } + + public override string ToString() => Name; + + /// + /// Path to the crispasr executable installed by the speech-to-text feature. Shared with every + /// other CrispASR-backed engine. + /// + public static string GetCrispAsrExecutable() + { + return new CrispAsrCohere().GetExecutable(); + } + + public static DownloadHashManager.UpdateStatus GetEngineUpdateStatus() + { + var exe = GetCrispAsrExecutable(); + if (!File.Exists(exe)) + { + return DownloadHashManager.UpdateStatus.Unknown; + } + + var folder = Path.GetDirectoryName(exe); + return string.IsNullOrEmpty(folder) + ? DownloadHashManager.UpdateStatus.Unknown + : DownloadHashManager.GetSidecarStatus(folder); + } + + public static string GetSetFolder() + { + if (!Directory.Exists(Se.TextToSpeechFolder)) + { + Directory.CreateDirectory(Se.TextToSpeechFolder); + } + + var folder = Path.Combine(Se.TextToSpeechFolder, "DotsTtsCrispAsr"); + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + return folder; + } + + public static string GetSetModelsFolder() + { + // Like the other CrispASR-backed engines, the GGUFs live alongside CrispASR's + // speech-to-text models in CrispASR/models/ rather than under TextToSpeech/. + var modelsFolder = Path.Combine(Se.CrispAsrFolder, "models"); + if (!Directory.Exists(modelsFolder)) + { + Directory.CreateDirectory(modelsFolder); + } + + return modelsFolder; + } + + public static string GetSetVoicesFolder() + { + var voicesFolder = Path.Combine(GetSetFolder(), "voices"); + if (!Directory.Exists(voicesFolder)) + { + Directory.CreateDirectory(voicesFolder); + } + + SeedVoicesFromQwen3TtsCppIfEmpty(voicesFolder); + return voicesFolder; + } + + private static bool _voiceSeedAttempted; + + /// + /// One-time best-effort seed of WAV reference voices from qwen3-tts.cpp's voices folder, so a + /// fresh install has something in the voice combo. Resampled to 24 kHz on the way in, matching + /// what produces. + /// + private static void SeedVoicesFromQwen3TtsCppIfEmpty(string voicesFolder) + { + if (_voiceSeedAttempted) + { + return; + } + _voiceSeedAttempted = true; + + try + { + if (Directory.EnumerateFiles(voicesFolder, "*.wav").Any()) + { + return; + } + + var sourceFolder = Qwen3TtsCpp.GetSetVoicesFolder(); + if (!Directory.Exists(sourceFolder) || !Directory.EnumerateFiles(sourceFolder, "*.wav").Any()) + { + return; + } + + foreach (var src in Directory.GetFiles(sourceFolder, "*.wav")) + { + var dest = Path.Combine(voicesFolder, Path.GetFileName(src)); + VoiceSeedHelper.CopyOrResample(src, dest, 24000, "dots.tts (CrispASR)"); + } + } + catch (Exception ex) + { + Se.LogError(ex, "dots.tts (CrispASR): voice seeding from qwen3-tts.cpp folder failed"); + } + } + + public static string GetCorePath(string? modelKey = null) => + Path.Combine(GetSetModelsFolder(), GetCoreFileName(modelKey)); + + public static string GetVocoderPath() => + Path.Combine(GetSetModelsFolder(), VocoderFileName); + + public static string GetSpeakerEncoderPath() => + Path.Combine(GetSetModelsFolder(), SpeakerEncoderFileName); + + public static bool AreModelsInstalled(string? modelKey = null) => + IsValidLocalModelFile(GetCorePath(modelKey), GetCoreFileName(modelKey)) + && IsValidLocalModelFile(GetVocoderPath(), VocoderFileName) + && IsValidLocalModelFile(GetSpeakerEncoderPath(), SpeakerEncoderFileName); + + /// + /// Path crispasr's --auto-download writes GGUFs to, so SE's downloader can adopt files a + /// previous CLI run already fetched instead of re-pulling gigabytes. + /// + public static string GetCrispAsrCacheFolder() => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "crispasr"); + + public static bool TrySeedModelFromCrispAsrCache(string fileName, string destinationPath) + { + if (IsValidLocalModelFile(destinationPath, fileName)) + { + return true; + } + + try + { + if (File.Exists(destinationPath)) + { + Se.LogError($"dots.tts (CrispASR): removing wrong-sized local model file {destinationPath}"); + File.Delete(destinationPath); + } + + var cachePath = Path.Combine(GetCrispAsrCacheFolder(), fileName); + if (!IsValidLocalModelFile(cachePath, fileName)) + { + return false; + } + + File.Copy(cachePath, destinationPath); + return true; + } + catch (Exception ex) + { + Se.LogError(ex, $"dots.tts (CrispASR): cache seed copy failed for {fileName}"); + return false; + } + } + + public async Task GetVoices(string language) + { + var result = new List(); + + // Voice cloning only — no built-in preset voices are exposed, so the combo is empty until + // the user imports a reference WAV (or the qwen3-tts.cpp seed above runs). + // Off the UI thread: GetSetVoicesFolder does one-time seeding through ffmpeg. + var voicesFolder = await Task.Run(GetSetVoicesFolder); + if (Directory.Exists(voicesFolder)) + { + foreach (var file in Directory.GetFiles(voicesFolder, "*.wav")) + { + var name = Path.GetFileNameWithoutExtension(file).Replace('_', ' '); + result.Add(new Voice(new DotsTtsVoice(name, file))); + } + } + + return result.ToArray(); + } + + public bool IsVoiceInstalled(Voice voice) => true; + + public Task GetRegions() => Task.FromResult(Array.Empty()); + + public Task GetModels() => Task.FromResult(new[] { ModelKeyQ4K, ModelKeyQ8_0, ModelKeyF16 }); + + public Task GetLanguages(Voice voice, string? model) => Task.FromResult(Array.Empty()); + + public Task RefreshVoices(string language, CancellationToken cancellationToken) => + GetVoices(language); + + public async Task Speak( + string text, + string outputFolder, + Voice voice, + TtsLanguage? language, + string? region, + string? model, + CancellationToken cancellationToken) + { + if (voice.EngineVoice is not DotsTtsVoice dotsVoice) + { + throw new ArgumentException("Voice is not a DotsTtsVoice"); + } + + if (string.IsNullOrEmpty(dotsVoice.FilePath)) + { + throw new InvalidOperationException( + "dots.tts (CrispASR) requires a reference voice WAV. " + + "Import one via the voice settings, then pick it in the voice combo. " + + "Reference WAV should be 24 kHz mono (3-10 s of clean speech)."); + } + + var modelKey = ResolveModelKey(model); + await EnsureServerRunningAsync(modelKey, dotsVoice.FilePath, cancellationToken); + + var outputFileName = Path.Combine(TtsOutputFolder.Resolve(outputFolder, GetSetFolder), Guid.NewGuid() + ".wav"); + + // Deliberately NO `voice` field: like indextts/f5/voxcpm2, the dots-tts backend takes its + // reference from the startup --voice path and the server rejects absolute paths in the + // request body outright (path-traversal guard). Verified on v0.8.29 — the server logs + // `voice=''` for this payload and the CAM++ embedding is built from that file. + // Also no `ref_text`: dots.tts conditions on a speaker embedding, not on a transcribed + // reference, so the field has nothing to bind to. No `speed` either — dots-tts is not in + // crispasr's --tts-speed backend list, and the quality/speed knob that does work here is + // the ODE step count passed at server launch. + var payload = new Dictionary + { + ["input"] = text, + ["response_format"] = "wav", + }; + + // Attests the user's own imported reference and the AI-disclosure duty; see + // CrispAsrTtsProvenance. Skipped when voice cloning has not been accepted in settings. + CrispAsrTtsProvenance.AddSpeechAttestations(payload); + + var body = JsonSerializer.Serialize(payload); + using var content = new StringContent(body, Encoding.UTF8, "application/json"); + Se.WriteToolsLog($"dots.tts (CrispASR): POST {ServerBaseUrl}/v1/audio/speech (voice={dotsVoice}, textLen={text.Length})"); + + HttpResponseMessage response; + try + { + response = await HttpClient.PostAsync($"{ServerBaseUrl}/v1/audio/speech", content, cancellationToken); + } + catch (HttpRequestException ex) + { + var serverLog = SnapshotServerLog(); + var launchCommand = _serverLaunchCommand; + var died = _serverProcess?.HasExited == true; + if (died) + { + StopServerInternal(); + } + + var failMsg = $"dots.tts (CrispASR) request failed — Voice: {dotsVoice}, Text: {text}, " + + $"RequestJson: {body}, ServerExited: {died}, ServerLog: {serverLog}" + + LaunchCmdSuffix(launchCommand); + Se.LogError(ex, failMsg); + Se.WriteToolsLog(failMsg); + + throw new InvalidOperationException( + (died + ? "dots.tts (CrispASR) — the crispasr server crashed during synthesis." + : "dots.tts (CrispASR) request failed — the connection to the crispasr server was dropped.") + + (string.IsNullOrEmpty(serverLog) ? string.Empty : $"{Environment.NewLine}Server log:{Environment.NewLine}{serverLog}") + + LaunchCmdSuffix(launchCommand), + ex); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + var errorBody = await SafeReadErrorAsync(response, cancellationToken); + var serverLog = SnapshotServerLog(); + var launchCommand = _serverLaunchCommand; + var errMsg = $"dots.tts (CrispASR) server error {(int)response.StatusCode} {response.StatusCode} — " + + $"Voice: {dotsVoice}, Text: {text}, RequestJson: {body}, " + + $"ResponseBody: {errorBody}, ServerLog: {serverLog}" + + LaunchCmdSuffix(launchCommand); + Se.LogError(errMsg); + Se.WriteToolsLog(errMsg); + throw new InvalidOperationException( + $"dots.tts (CrispASR) synthesis failed ({(int)response.StatusCode}): {errorBody}" + + (string.IsNullOrEmpty(serverLog) ? string.Empty : $"{Environment.NewLine}Server log:{Environment.NewLine}{serverLog}") + + LaunchCmdSuffix(launchCommand)); + } + + await using var fileStream = File.Create(outputFileName); + await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); + await contentStream.CopyToAsync(fileStream, cancellationToken); + } + + return new TtsResult(outputFileName, text); + } + + private static string FormatLaunchCommand(string exe, System.Collections.ObjectModel.Collection args) + { + static string Quote(string s) => + !string.IsNullOrEmpty(s) && s.IndexOfAny(new[] { ' ', '\t' }) >= 0 + ? "\"" + s.Replace("\"", "\\\"") + "\"" + : s; + + var sb = new StringBuilder(Quote(exe)); + foreach (var a in args) + { + sb.Append(' ').Append(Quote(a)); + } + return sb.ToString(); + } + + private static string LaunchCmdSuffix(string? launchCommand) => + string.IsNullOrEmpty(launchCommand) + ? string.Empty + : $"{Environment.NewLine}Launch command: {launchCommand}"; + + private static async Task SafeReadErrorAsync(HttpResponseMessage response, CancellationToken ct) + { + try + { + return await response.Content.ReadAsStringAsync(ct); + } + catch (Exception ex) + { + return $""; + } + } + + private static async Task EnsureServerRunningAsync(string modelKey, string voicePath, CancellationToken ct) + { + var odeSteps = ResolveOdeSteps(); + + if (_serverProcess is { HasExited: false } && _serverPort != 0 + && string.Equals(_serverVoicePath, voicePath, StringComparison.OrdinalIgnoreCase) + && string.Equals(_serverModelKey, modelKey, StringComparison.OrdinalIgnoreCase) + && _serverOdeSteps == odeSteps) + { + return; + } + + await ServerLock.WaitAsync(ct); + try + { + if (_serverProcess is { HasExited: false } && _serverPort != 0 + && string.Equals(_serverVoicePath, voicePath, StringComparison.OrdinalIgnoreCase) + && string.Equals(_serverModelKey, modelKey, StringComparison.OrdinalIgnoreCase) + && _serverOdeSteps == odeSteps) + { + return; + } + + if (_serverProcess != null) + { + StopServerInternal(); + } + + var exe = GetCrispAsrExecutable(); + if (!File.Exists(exe)) + { + throw new FileNotFoundException( + "CrispASR executable not found. Install CrispASR via Video → Audio to text first.", exe); + } + + var coreFileName = GetCoreFileName(modelKey); + var core = GetCorePath(modelKey); + var hasLocalCore = IsValidLocalModelFile(core, coreFileName); + var vocoder = GetVocoderPath(); + var hasLocalVocoder = IsValidLocalModelFile(vocoder, VocoderFileName); + + var port = FindFreeLoopbackPort(); + var psi = new ProcessStartInfo + { + WorkingDirectory = Path.GetDirectoryName(exe) ?? GetSetFolder(), + FileName = exe, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + // The server writes UTF-8; without these the captured log reaches bug reports as + // mojibake on Windows (#13572). + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + psi.ArgumentList.Add("--server"); + psi.ArgumentList.Add("--backend"); + psi.ArgumentList.Add(BackendName); + psi.ArgumentList.Add("-m"); + psi.ArgumentList.Add(hasLocalCore ? core : "auto"); + if (hasLocalVocoder) + { + psi.ArgumentList.Add("--codec-model"); + psi.ArgumentList.Add(vocoder); + } + if (!hasLocalCore || !hasLocalVocoder) + { + psi.ArgumentList.Add("--auto-download"); + } + psi.ArgumentList.Add("--host"); + psi.ArgumentList.Add("127.0.0.1"); + psi.ArgumentList.Add("--port"); + psi.ArgumentList.Add(port.ToString(CultureInfo.InvariantCulture)); + psi.ArgumentList.Add("--voice-dir"); + psi.ArgumentList.Add(GetSetVoicesFolder()); + // Required: the dots-tts backend clones only from the startup --voice path. The CAM++ + // speaker encoder next to the core GGUF is picked up automatically when this is set. + psi.ArgumentList.Add("--voice"); + psi.ArgumentList.Add(voicePath); + CrispAsrTtsProvenance.AddServerMarkingArgs(psi.ArgumentList, exe); + + // ODE steps have no CLI flag — the backend reads them from the environment. Set it + // explicitly rather than relying on the default so the value shown in the settings + // dialog is the value actually used. + psi.Environment[OdeStepsEnvironmentVariable] = odeSteps.ToString(CultureInfo.InvariantCulture); + + var process = Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start crispasr (dots-tts)"); + + var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); + _serverLaunchCommand = launchCommand; + Se.WriteToolsLog("dots.tts (CrispASR) server starting — " + + $"PID: {process.Id}, " + + $"{OdeStepsEnvironmentVariable}: {odeSteps}, " + + $"Cmd: {launchCommand}"); + + lock (_serverLog) _serverLog.Clear(); + process.ErrorDataReceived += (_, e) => + { + if (e.Data != null) lock (_serverLog) _serverLog.AppendLine(e.Data); + }; + process.OutputDataReceived += (_, e) => + { + if (e.Data != null) lock (_serverLog) _serverLog.AppendLine(e.Data); + }; + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + + _serverProcess = process; + _serverPort = port; + _serverVoicePath = voicePath; + _serverModelKey = modelKey; + _serverOdeSteps = odeSteps; + HookProcessExitOnce(); + + // Loading the F16 core is ~4.6 GB off disk, so even the warm path is not instant; + // first run may also be pulling those gigabytes through --auto-download. + var timeoutMinutes = hasLocalCore && hasLocalVocoder ? 10 : 30; + var deadline = DateTime.UtcNow.AddMinutes(timeoutMinutes); + while (DateTime.UtcNow < deadline) + { + ct.ThrowIfCancellationRequested(); + if (process.HasExited) + { + var tail = SnapshotServerLog(); + var exitCode = process.ExitCode; + var exitedLaunchCommand = _serverLaunchCommand; + _serverProcess = null; + _serverPort = 0; + _serverLaunchCommand = null; + _serverVoicePath = null; + _serverModelKey = null; + _serverOdeSteps = 0; + throw new InvalidOperationException( + $"crispasr (dots-tts) exited during startup (code {exitCode}). Output: {tail}" + + LaunchCmdSuffix(exitedLaunchCommand)); + } + if (await ProbeHealthAsync(port, TimeSpan.FromSeconds(2), ct)) + { + return; + } + await Task.Delay(TimeSpan.FromSeconds(1), ct); + } + + var lastOutput = SnapshotServerLog(); + var timeoutLaunchCommand = _serverLaunchCommand; + StopServerInternal(); + throw new TimeoutException( + $"crispasr (dots-tts) did not report healthy within {timeoutMinutes} minutes. Last output: {lastOutput}" + + LaunchCmdSuffix(timeoutLaunchCommand)); + } + finally + { + ServerLock.Release(); + } + } + + private static string SnapshotServerLog() + { + lock (_serverLog) + { + var s = _serverLog.ToString().TrimEnd(); + return s.Length > 2000 ? s[^2000..] : s; + } + } + + private static async Task ProbeHealthAsync(int port, TimeSpan timeout, CancellationToken ct) + { + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + using var resp = await HttpClient.GetAsync($"http://127.0.0.1:{port}/health", cts.Token); + return resp.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + private static int FindFreeLoopbackPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + private static void HookProcessExitOnce() + { + if (_processExitHooked) + { + return; + } + _processExitHooked = true; + AppDomain.CurrentDomain.ProcessExit += (_, _) => StopServerInternal(); + } + + /// + /// Stop the running crispasr (dots-tts) server if any, releasing GPU memory. Called by + /// TextToSpeechViewModel when switching engines or closing the TTS window so the + /// CrispASR-based engines don't pile up in VRAM. + /// + public static void StopServer() => StopServerInternal(); + + private static void StopServerInternal() + { + var p = _serverProcess; + _serverProcess = null; + _serverPort = 0; + _serverLaunchCommand = null; + _serverVoicePath = null; + _serverModelKey = null; + _serverOdeSteps = 0; + if (p == null) + { + return; + } + try + { + if (!p.HasExited) + { + p.Kill(entireProcessTree: true); + p.WaitForExit(2000); + } + } + catch + { + // best effort + } + finally + { + p.Dispose(); + } + } + + private static string GetUniqueDestinationFileName(string folder, string baseName) + { + var candidate = Path.Combine(folder, baseName + ".wav"); + if (!File.Exists(candidate)) + { + return candidate; + } + + var number = 1; + do + { + candidate = Path.Combine(folder, $"{baseName}_{number}.wav"); + number++; + } while (File.Exists(candidate)); + + return candidate; + } + + public bool ImportVoice(string fileName) + { + if (string.IsNullOrEmpty(fileName) || !File.Exists(fileName)) + { + return false; + } + + var voicesFolder = GetSetVoicesFolder(); + var baseName = Path.GetFileNameWithoutExtension(fileName); + var destinationFileName = GetUniqueDestinationFileName(voicesFolder, baseName); + + // 24 kHz mono, matching the other CrispASR cloning engines so the voices folders stay + // interchangeable. No .txt sidecar: dots.tts conditions on a CAM++ speaker embedding, so + // it never needs the reference transcript. + try + { + var process = FfmpegGenerator.ConvertToMono24kHzWav(fileName, destinationFileName); + if (!process.Start()) + { + return false; + } + + process.WaitForExit(); + } + catch (Exception ex) + { + Se.LogError(ex, "dots.tts (CrispASR) voice import failed (ffmpeg conversion)."); + return false; + } + + return File.Exists(destinationFileName); + } +} diff --git a/src/ui/Features/Video/TextToSpeech/Engines/FishTtsAudioCpp.cs b/src/ui/Features/Video/TextToSpeech/Engines/FishTtsAudioCpp.cs new file mode 100644 index 00000000000..a4809ba2093 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/Engines/FishTtsAudioCpp.cs @@ -0,0 +1,888 @@ +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.ModelLicense; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Voices; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Download; +using Nikse.SubtitleEdit.Logic.Media; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; + +/// +/// Fish Audio S2 Pro run through the audio.cpp runtime — the same pure C++/ggml install +/// uses (see ), so the binaries +/// are downloaded once and shared. Zero-shot voice cloning from a 10-30 s reference across +/// 80+ languages, with inline prosody and emotion control through the text itself. 44.1 kHz +/// output. +/// +/// Like the other audio.cpp engines, the server honours a per-request voice_ref, so +/// switching voice does NOT restart the server — only a model or backend change does — which +/// is also what makes per-line cloning ("Clone from video") free here. The +/// model auto-handles the language of the input text, so there is no language combo. A +/// .txt sidecar next to the reference WAV (the transcript convention the other cloning +/// engines share) is passed as reference_text, which improves clone fidelity but is +/// optional. +/// +/// Licence note: the audio.cpp binaries are Apache-2.0, but the S2 Pro *weights* are under +/// the Fish Audio Research License (research and non-commercial), which has to be accepted by +/// the user before the first download — see . +/// +public class FishTtsAudioCpp : ITtsEngine, IPerLineCloneEngine +{ + public string Name => "Fish Audio S2 Pro (audio.cpp)"; + public string Description => "Fish Audio S2 Pro voice cloning in 80+ languages, via audio.cpp"; + public bool HasLanguageParameter => false; + public bool HasApiKey => false; + public bool HasRegion => false; + public bool HasModel => true; + public bool HasKeyFile => false; + public bool SupportsVoiceCloning => true; + public bool SupportsPerLineVoiceCloning => true; + + // Q8_0 is the default: same 44.1 kHz output as BF16 at 3.6 GB less on disk. + public const string ModelKeyQ8_0 = "Q8_0 (~5.9 GB)"; + public const string ModelKeyBf16 = "BF16 (~9.5 GB)"; + public const string DefaultModelKey = ModelKeyQ8_0; + + public const string ModelQ8_0FileName = "fish-audio-s2-pro-q8_0.gguf"; + public const string ModelBf16FileName = "fish-audio-s2-pro-bf16.gguf"; + + /// Family name audio.cpp registers Fish Audio S2 Pro under. + public const string FamilyName = "fish_audio"; + + /// Model id used in the generated server config and in each request body. + private const string ServerModelId = "fishs2pro"; + + /// + /// Exact byte sizes on the audio-cpp/audio.cpp-gguf HuggingFace repo. A truncated GGUF is + /// the single most common failure here — a download that dies partway leaves a file the + /// loader rejects with "GGUF tensor data range is out of bounds", so size is checked + /// before the server is ever started. Same guard as . + /// + private static readonly Dictionary ExpectedFileSizes = new(StringComparer.OrdinalIgnoreCase) + { + [ModelQ8_0FileName] = 6317911232L, + [ModelBf16FileName] = 10229278080L, + }; + + /// + /// Bumping this re-prompts every user with the licence window. Keep it in step with the + /// summary text in . + /// + public const string LicenseVersion = "fish-audio-research-license-2026-09"; + + public static bool IsLicenseAccepted() => + string.Equals( + Se.Settings.Video.TextToSpeech.FishTtsAudioCppLicenseAccepted, + LicenseVersion, + StringComparison.Ordinal); + + public static void AcceptLicense() => + Se.Settings.Video.TextToSpeech.FishTtsAudioCppLicenseAccepted = LicenseVersion; + + /// + /// What the first-run licence gate shows. The weights are research / non-commercial — + /// meaningfully different terms from open source, which is exactly why the gate exists. + /// + public static ModelLicenseDefinition LicenseDefinition { get; } = new( + DialogTitle: "Fish Audio S2 Pro - model license", + Header: "The Fish Audio S2 Pro model has its own license", + Intro: "The audio.cpp engine is Apache-2.0, but the Fish Audio S2 Pro model weights are " + + "licensed under the Fish Audio Research License, which is not open source. Please " + + "read the main points before downloading.", + SummaryPoints: new[] + { + "The model weights are free of charge for research and non-commercial use. Any commercial use needs a separate written license from Fish Audio (business@fish.audio).", + "If you pass the model or anything derived from it to someone else, the license notice must travel with it: \"This model is licensed under the Fish Audio Research License, Copyright © 39 AI, INC. All Rights Reserved.\"", + "The model is provided as is, with no warranty, and Fish Audio accepts no liability for what is generated.", + "You are responsible for having the right to clone any voice you use as a reference. The model does not check consent.", + }, + LicenseUrl: "https://huggingface.co/fishaudio/s2-pro/blob/main/LICENSE.md", + ModelPageUrl: "https://huggingface.co/fishaudio/s2-pro", + AcceptCheckBoxText: "I have read and accept the Fish Audio Research License", + Accept: AcceptLicense); + + public static string ResolveModelKey(string? modelKey) + { + if (string.IsNullOrEmpty(modelKey)) + { + var saved = Se.Settings.Video.TextToSpeech.FishTtsAudioCppModel; + return string.IsNullOrEmpty(saved) ? DefaultModelKey : ResolveModelKey(saved); + } + + return modelKey == ModelKeyBf16 ? ModelKeyBf16 : ModelKeyQ8_0; + } + + public static string GetModelFileName(string? modelKey) => + ResolveModelKey(modelKey) == ModelKeyBf16 ? ModelBf16FileName : ModelQ8_0FileName; + + private static readonly HttpClient HttpClient = new() + { + Timeout = TimeSpan.FromMinutes(10), + }; + + private static readonly SemaphoreSlim ServerLock = new(1, 1); + private static Process? _serverProcess; + private static int _serverPort; + private static string? _serverLaunchCommand; + // Only the model and the backend are baked into the running server — voice is per request. + private static string? _serverModelKey; + private static string? _serverBackend; + private static bool _processExitHooked; + private static readonly StringBuilder _serverLog = new(); + + private static string ServerBaseUrl => $"http://127.0.0.1:{_serverPort}"; + + public Task IsInstalled(string? region) => Task.FromResult(File.Exists(AudioCppRuntime.GetServerExecutable())); + + public override string ToString() => Name; + + /// + /// Per-engine working folder under TextToSpeech (voices, synthesis output). The audio.cpp + /// binaries are shared (); this is not. + /// + public static string GetSetFolder() + { + if (!Directory.Exists(Se.TextToSpeechFolder)) + { + Directory.CreateDirectory(Se.TextToSpeechFolder); + } + + var folder = Path.Combine(Se.TextToSpeechFolder, "FishTtsAudioCpp"); + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + return folder; + } + + /// + /// audio.cpp is pointed at a directory, not a file, so the GGUF gets its own folder under + /// the shared models root: <data>/audio.cpp/models/Fish-Audio-S2-Pro-GGUF/. + /// + public static string GetSetModelsFolder() + { + var folder = Path.Combine(AudioCppRuntime.GetSetEngineFolder(), "models", "Fish-Audio-S2-Pro-GGUF"); + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + return folder; + } + + public static string GetSetVoicesFolder() + { + var folder = Path.Combine(GetSetFolder(), "voices"); + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + SeedVoicesFromQwen3TtsCppIfEmpty(folder); + NormalizeVoiceTranscriptsOnce(folder); + return folder; + } + + private static bool _voiceSeedAttempted; + private static bool _voicesNormalized; + + /// + /// One-time per session: drop unusable ref-text sidecars (the shared pack ships Wikimedia + /// attribution blurbs, not transcripts) and backfill missing transcriptions from the + /// sibling OmniVoice pack. This engine passes the .txt sidecar as reference_text, so a + /// blurb there would condition the clone on text nobody spoke — same cleanup CosyVoice3 + /// and MOSS-TTS run. + /// + private static void NormalizeVoiceTranscriptsOnce(string voicesFolder) + { + if (_voicesNormalized) + { + return; + } + _voicesNormalized = true; + + Qwen3TtsCrispAsr.NormalizeVoiceTranscripts(voicesFolder); + } + + /// + /// One-time best-effort seed of reference WAVs (plus their transcript sidecars) from the + /// shared Qwen3 voice pack, so the voice combo is not empty on first run — this engine + /// clones only and has no built-in voices. Transcripts matter more here than anywhere: + /// S2 Pro refuses to clone without reference_text. The pack ships at 16 kHz; the S2 Pro + /// codec runs at 44.1 kHz, so resample on seed. + /// + private static void SeedVoicesFromQwen3TtsCppIfEmpty(string voicesFolder) + { + if (_voiceSeedAttempted) + { + return; + } + + _voiceSeedAttempted = true; + + try + { + if (Directory.EnumerateFiles(voicesFolder, "*.wav").Any()) + { + return; + } + + var sourceFolder = Qwen3TtsCpp.GetSetVoicesFolder(); + if (!Directory.Exists(sourceFolder) || !Directory.EnumerateFiles(sourceFolder, "*.wav").Any()) + { + return; + } + + foreach (var src in Directory.GetFiles(sourceFolder, "*.wav")) + { + var dest = Path.Combine(voicesFolder, Path.GetFileName(src)); + VoiceSeedHelper.CopyOrResample(src, dest, 44100, "Fish Audio S2 Pro (audio.cpp)"); + + // Bring the transcript along; NormalizeVoiceTranscriptsOnce then drops the + // attribution blurbs and backfills real transcripts where a sibling has them. + var sidecar = Path.ChangeExtension(src, ".txt"); + var sidecarDest = Path.ChangeExtension(dest, ".txt"); + if (File.Exists(sidecar) && !File.Exists(sidecarDest) && File.Exists(dest)) + { + File.Copy(sidecar, sidecarDest); + } + } + } + catch (Exception ex) + { + Se.LogError(ex, "Fish Audio S2 Pro (audio.cpp): voice seeding from the shared voice pack failed"); + } + } + + public static string GetModelPath(string? modelKey = null) => + Path.Combine(GetSetModelsFolder(), GetModelFileName(modelKey)); + + public static bool IsValidLocalModelFile(string path, string fileName) + { + if (!File.Exists(path)) + { + return false; + } + + if (!ExpectedFileSizes.TryGetValue(fileName, out var expected)) + { + return true; + } + + try + { + var info = new FileInfo(path); + + // FileInfo.Length reports the size of the *link* for a symlink, not of its target, + // so a symlinked GGUF (a reasonable way to share a 5.9 GB model between apps or + // put it on another disk) would look truncated — and the caller deletes files it + // considers truncated. Resolve to the final target before measuring. + var length = info.ResolveLinkTarget(returnFinalTarget: true) is FileInfo target + ? target.Length + : info.Length; + + return length == expected; + } + catch + { + return false; + } + } + + public static bool AreModelsInstalled(string? modelKey = null) => + IsValidLocalModelFile(GetModelPath(modelKey), GetModelFileName(modelKey)); + + public static DownloadHashManager.UpdateStatus GetEngineUpdateStatus() + { + var exe = AudioCppRuntime.GetServerExecutable(); + if (!File.Exists(exe)) + { + return DownloadHashManager.UpdateStatus.Unknown; + } + + var folder = Path.GetDirectoryName(exe); + return string.IsNullOrEmpty(folder) + ? DownloadHashManager.UpdateStatus.Unknown + : DownloadHashManager.GetSidecarStatus(folder); + } + + public async Task GetVoices(string language) + { + var result = new List(); + + // Voice cloning only — the combo stays empty until the user imports a reference WAV. + var voicesFolder = await Task.Run(GetSetVoicesFolder); + if (Directory.Exists(voicesFolder)) + { + foreach (var file in Directory.GetFiles(voicesFolder, "*.wav")) + { + var name = Path.GetFileNameWithoutExtension(file).Replace('_', ' '); + result.Add(new Voice(new IndexTtsVoice(name, file))); + } + } + + return result.ToArray(); + } + + public bool IsVoiceInstalled(Voice voice) => true; + + public Task GetRegions() => Task.FromResult(Array.Empty()); + + public Task GetModels() => Task.FromResult(new[] { ModelKeyQ8_0, ModelKeyBf16 }); + + // S2 Pro auto-handles the language of the input text (80+ languages), so there is no + // language pick. + public Task GetLanguages(Voice voice, string? model) => + Task.FromResult(Array.Empty()); + + public Task RefreshVoices(string language, CancellationToken cancellationToken) => + GetVoices(language); + + /// + /// : the server takes voice_ref as a path per request, + /// so the voice simply points at the cut clip - nothing is staged into this engine's own + /// folders. audio.cpp resamples the reference itself, so the 24 kHz clip is used as cut. + /// S2 Pro refuses to clone without reference_text (see ), so a clip + /// with no transcript beside it is no reference at all: null, and the caller falls back to + /// an ordinary voice for that line instead of the run failing on it. + /// + public Voice? MakePerLineCloneVoice(string clipFileName, string voiceName) => + string.IsNullOrWhiteSpace(ChatterboxTtsCpp.TryReadReferenceTranscript(clipFileName)) + ? null + : new Voice(new IndexTtsVoice(voiceName, clipFileName)); + + /// The clip's own path, which is exactly what the voice carries. + public string? GetPerLineReferenceClip(Voice voice) => + voice.EngineVoice is IndexTtsVoice indexVoice && !string.IsNullOrEmpty(indexVoice.FilePath) + ? indexVoice.FilePath + : null; + + /// + /// : nothing is ever staged (the voice points straight at + /// the clip), so there is nothing to clear between runs. + /// + public void ResetStagedPerLineReferences() + { + } + + public async Task Speak( + string text, + string outputFolder, + Voice voice, + TtsLanguage? language, + string? region, + string? model, + CancellationToken cancellationToken) + { + if (voice.EngineVoice is not IndexTtsVoice indexVoice) + { + throw new ArgumentException("Voice is not an IndexTtsVoice"); + } + + if (string.IsNullOrEmpty(indexVoice.FilePath)) + { + throw new InvalidOperationException( + "Fish Audio S2 Pro (audio.cpp) requires a reference voice WAV. " + + "Import one via the voice settings, then pick it in the voice combo. " + + "10-30 s of clean speech works best."); + } + + if (!IsLicenseAccepted()) + { + throw new InvalidOperationException( + "The Fish Audio S2 Pro model licence (Fish Audio Research License) has not been accepted."); + } + + var modelKey = ResolveModelKey(model); + await EnsureServerRunningAsync(modelKey, cancellationToken); + + var outputFileName = Path.Combine(TtsOutputFolder.Resolve(outputFolder, GetSetFolder), Guid.NewGuid() + ".wav"); + + // audio.cpp's OpenAI-style speech payload. Voice cloning goes through `voice_ref`, + // honoured per request — no server restart when the user switches voice. + var options = new Dictionary(); + + // The transcript of the reference WAV, from the shared .txt sidecar convention. + // NOT optional, whatever upstream's docs say: audio.cpp v0.7.1 answers a voice_ref + // without reference_text with HTTP 500 "Fish Audio prepare with inline reference + // audio requires reference_text option" (verified against the real server), and an + // empty string is refused the same way. Fail here with an actionable message instead + // of surfacing the server's error. + var referenceText = ChatterboxTtsCpp.TryReadReferenceTranscript(indexVoice.FilePath); + if (string.IsNullOrWhiteSpace(referenceText)) + { + throw new InvalidOperationException( + "Fish Audio S2 Pro requires the transcript of the reference recording. " + + $"Save the spoken text as \"{Path.ChangeExtension(indexVoice.FilePath, ".txt")}\", " + + "or re-import the voice and enter the transcript when asked."); + } + + options["reference_text"] = referenceText; + + var payload = new Dictionary + { + ["model"] = ServerModelId, + ["input"] = text, + ["response_format"] = "wav", + ["voice_ref"] = new Dictionary + { + ["type"] = "path", + ["path"] = indexVoice.FilePath, + }, + }; + + if (options.Count > 0) + { + payload["options"] = options; + } + + var body = JsonSerializer.Serialize(payload); + using var content = new StringContent(body, Encoding.UTF8, "application/json"); + Se.WriteToolsLog($"Fish Audio S2 Pro (audio.cpp): POST {ServerBaseUrl}/v1/audio/speech (voice={indexVoice}, textLen={text.Length})"); + + HttpResponseMessage response; + try + { + response = await HttpClient.PostAsync($"{ServerBaseUrl}/v1/audio/speech", content, cancellationToken); + } + catch (HttpRequestException ex) + { + var serverLog = SnapshotServerLog(); + var launchCommand = _serverLaunchCommand; + var died = _serverProcess?.HasExited == true; + if (died) + { + StopServerInternal(); + } + + var failMsg = $"Fish Audio S2 Pro (audio.cpp) request failed — Voice: {indexVoice}, Text: {text}, " + + $"RequestJson: {body}, ServerExited: {died}, ServerLog: {serverLog}" + + LaunchCmdSuffix(launchCommand); + Se.LogError(ex, failMsg); + Se.WriteToolsLog(failMsg); + + throw new InvalidOperationException( + (died + ? "Fish Audio S2 Pro (audio.cpp) — the audiocpp_server process crashed during synthesis." + : "Fish Audio S2 Pro (audio.cpp) request failed — the connection to audiocpp_server was dropped.") + + (string.IsNullOrEmpty(serverLog) ? string.Empty : $"{Environment.NewLine}Server log:{Environment.NewLine}{serverLog}") + + LaunchCmdSuffix(launchCommand), + ex); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + var errorBody = await SafeReadErrorAsync(response, cancellationToken); + var serverLog = SnapshotServerLog(); + var launchCommand = _serverLaunchCommand; + var errMsg = $"Fish Audio S2 Pro (audio.cpp) server error {(int)response.StatusCode} {response.StatusCode} — " + + $"Voice: {indexVoice}, Text: {text}, RequestJson: {body}, " + + $"ResponseBody: {errorBody}, ServerLog: {serverLog}" + + LaunchCmdSuffix(launchCommand); + Se.LogError(errMsg); + Se.WriteToolsLog(errMsg); + throw new InvalidOperationException( + $"Fish Audio S2 Pro (audio.cpp) synthesis failed ({(int)response.StatusCode}): {errorBody}" + + (string.IsNullOrEmpty(serverLog) ? string.Empty : $"{Environment.NewLine}Server log:{Environment.NewLine}{serverLog}") + + LaunchCmdSuffix(launchCommand)); + } + + await using var fileStream = File.Create(outputFileName); + await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); + await contentStream.CopyToAsync(fileStream, cancellationToken); + } + + return new TtsResult(outputFileName, text); + } + + private static async Task EnsureServerRunningAsync(string modelKey, CancellationToken ct) + { + var backend = AudioCppRuntime.GetBackend(); + + if (_serverProcess is { HasExited: false } && _serverPort != 0 + && string.Equals(_serverModelKey, modelKey, StringComparison.OrdinalIgnoreCase) + && string.Equals(_serverBackend, backend, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await ServerLock.WaitAsync(ct); + try + { + if (_serverProcess is { HasExited: false } && _serverPort != 0 + && string.Equals(_serverModelKey, modelKey, StringComparison.OrdinalIgnoreCase) + && string.Equals(_serverBackend, backend, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (_serverProcess != null) + { + StopServerInternal(); + } + + var exe = AudioCppRuntime.GetServerExecutable(); + if (!File.Exists(exe)) + { + throw new FileNotFoundException( + "audio.cpp server not found. Download the Fish Audio S2 Pro engine first.", exe); + } + + var modelFileName = GetModelFileName(modelKey); + var modelPath = GetModelPath(modelKey); + if (!IsValidLocalModelFile(modelPath, modelFileName)) + { + throw new FileNotFoundException( + File.Exists(modelPath) + ? $"The Fish Audio S2 Pro model file is incomplete: {modelPath}. Delete it and download again." + : $"The Fish Audio S2 Pro model file is missing: {modelPath}", + modelPath); + } + + var port = FindFreeLoopbackPort(); + var configPath = WriteServerConfig(port, backend, modelKey); + + var psi = new ProcessStartInfo + { + WorkingDirectory = Path.GetDirectoryName(exe) ?? AudioCppRuntime.GetSetEngineFolder(), + FileName = exe, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + // The server writes UTF-8. Without these the reader decodes it in the OS default + // codepage, and non-ASCII text in the captured log - the line being synthesised, + // upstream's em dashes - reaches bug reports as mojibake (#13572). + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + psi.ArgumentList.Add("--config"); + psi.ArgumentList.Add(configPath); + + var process = Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start audiocpp_server (fish_audio)"); + + var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); + _serverLaunchCommand = launchCommand; + Se.WriteToolsLog($"Fish Audio S2 Pro (audio.cpp) server starting — PID: {process.Id}, Cmd: {launchCommand}"); + + lock (_serverLog) _serverLog.Clear(); + process.ErrorDataReceived += (_, e) => + { + if (e.Data != null) lock (_serverLog) _serverLog.AppendLine(e.Data); + }; + process.OutputDataReceived += (_, e) => + { + if (e.Data != null) lock (_serverLog) _serverLog.AppendLine(e.Data); + }; + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + + _serverProcess = process; + _serverPort = port; + _serverModelKey = modelKey; + _serverBackend = backend; + HookProcessExitOnce(); + + // The config uses lazy_load, so /health answers within a second or two — the 5.9 GB + // model is only read on the first synthesis request. A long deadline here would just + // hide a crash-at-startup (e.g. a Vulkan build on a box with no Vulkan driver, which + // dies in the loader with 0xC0000135 before printing anything). + var deadline = DateTime.UtcNow.AddMinutes(2); + while (DateTime.UtcNow < deadline) + { + ct.ThrowIfCancellationRequested(); + if (process.HasExited) + { + var tail = SnapshotServerLog(); + var exitCode = process.ExitCode; + var exitedLaunchCommand = _serverLaunchCommand; + _serverProcess = null; + _serverPort = 0; + _serverLaunchCommand = null; + _serverModelKey = null; + _serverBackend = null; + throw new InvalidOperationException( + $"audiocpp_server exited during startup (code {exitCode}). " + + AudioCppRuntime.DescribeStartupExit(exitCode, backend) + + $" Output: {tail}" + + LaunchCmdSuffix(exitedLaunchCommand)); + } + + if (await ProbeHealthAsync(port, TimeSpan.FromSeconds(2), ct)) + { + return; + } + + await Task.Delay(TimeSpan.FromSeconds(1), ct); + } + + var lastOutput = SnapshotServerLog(); + var timeoutLaunchCommand = _serverLaunchCommand; + StopServerInternal(); + throw new TimeoutException( + $"audiocpp_server did not report healthy within 2 minutes. Last output: {lastOutput}" + + LaunchCmdSuffix(timeoutLaunchCommand)); + } + finally + { + ServerLock.Release(); + } + } + + /// + /// Writes the audio.cpp server config next to the binary. lazy_load keeps startup instant; + /// the model is read on first use and then stays resident until the server is stopped. + /// + private static string WriteServerConfig(int port, string backend, string modelKey) + { + var config = new Dictionary + { + ["host"] = "127.0.0.1", + ["port"] = port, + ["backend"] = backend, + ["threads"] = Math.Max(1, Math.Min(8, Environment.ProcessorCount / 2)), + ["lazy_load"] = true, + ["models"] = new[] + { + new Dictionary + { + ["id"] = ServerModelId, + ["family"] = FamilyName, + ["path"] = GetSetModelsFolder(), + ["task"] = "tts", + ["mode"] = "offline", + }, + }, + }; + + var configPath = Path.Combine(AudioCppRuntime.GetSetEngineFolder(), "fish-s2-server.json"); + File.WriteAllText( + configPath, + JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }), + Encoding.UTF8); + + Se.WriteToolsLog($"Fish Audio S2 Pro (audio.cpp): server config written to {configPath} (model={modelKey}, backend={backend})"); + return configPath; + } + + private static string FormatLaunchCommand(string exe, System.Collections.ObjectModel.Collection args) + { + static string Quote(string s) => + !string.IsNullOrEmpty(s) && s.IndexOfAny(new[] { ' ', '\t' }) >= 0 + ? "\"" + s.Replace("\"", "\\\"") + "\"" + : s; + + var sb = new StringBuilder(Quote(exe)); + foreach (var a in args) + { + sb.Append(' ').Append(Quote(a)); + } + + return sb.ToString(); + } + + private static string LaunchCmdSuffix(string? launchCommand) => + string.IsNullOrEmpty(launchCommand) + ? string.Empty + : $"{Environment.NewLine}Launch command: {launchCommand}"; + + private static async Task SafeReadErrorAsync(HttpResponseMessage response, CancellationToken ct) + { + try + { + return await response.Content.ReadAsStringAsync(ct); + } + catch (Exception ex) + { + return $""; + } + } + + private static string SnapshotServerLog() + { + lock (_serverLog) + { + var s = _serverLog.ToString().TrimEnd(); + return s.Length > 2000 ? s[^2000..] : s; + } + } + + private static async Task ProbeHealthAsync(int port, TimeSpan timeout, CancellationToken ct) + { + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + using var resp = await HttpClient.GetAsync($"http://127.0.0.1:{port}/health", cts.Token); + return resp.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + private static int FindFreeLoopbackPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + private static void HookProcessExitOnce() + { + if (_processExitHooked) + { + return; + } + + _processExitHooked = true; + AppDomain.CurrentDomain.ProcessExit += (_, _) => StopServerInternal(); + } + + /// + /// Stop the audio.cpp server if running, releasing the loaded model's working set. + /// audio.cpp never unloads a model on its own once loaded, so this is the only way the + /// memory comes back. + /// + public static void StopServer() => StopServerInternal(); + + private static void StopServerInternal() + { + var p = _serverProcess; + _serverProcess = null; + _serverPort = 0; + _serverLaunchCommand = null; + _serverModelKey = null; + _serverBackend = null; + if (p == null) + { + return; + } + + try + { + if (!p.HasExited) + { + p.Kill(entireProcessTree: true); + p.WaitForExit(2000); + } + } + catch + { + // best effort + } + finally + { + p.Dispose(); + } + } + + private static string GetUniqueDestinationFileName(string folder, string baseName) + { + var candidate = Path.Combine(folder, baseName + ".wav"); + if (!File.Exists(candidate)) + { + return candidate; + } + + var number = 1; + do + { + candidate = Path.Combine(folder, $"{baseName}_{number}.wav"); + number++; + } while (File.Exists(candidate)); + + return candidate; + } + + public bool ImportVoice(string fileName) => ImportVoice(fileName, string.Empty); + + /// + /// Import with the reference transcript — the overload + /// routes to. S2 Pro cannot clone without the transcript, so getting it written as the + /// .txt sidecar at import time is what makes the voice usable at all. + /// + public bool ImportVoice(string fileName, string transcript) + { + if (string.IsNullOrEmpty(fileName) || !File.Exists(fileName)) + { + return false; + } + + var voicesFolder = GetSetVoicesFolder(); + var baseName = Path.GetFileNameWithoutExtension(fileName); + var destinationFileName = GetUniqueDestinationFileName(voicesFolder, baseName); + + // audio.cpp resamples the reference itself, but importing at 44.1 kHz mono matches + // the S2 Pro codec's native rate, so the reference is only resampled once, here. + try + { + var process = FfmpegGenerator.ConvertToMono44kHzWav(fileName, destinationFileName); + if (!process.Start()) + { + return false; + } + + process.WaitForExit(); + } + catch (Exception ex) + { + Se.LogError(ex, "Fish Audio S2 Pro (audio.cpp) voice import failed (ffmpeg conversion)."); + return false; + } + + if (!File.Exists(destinationFileName)) + { + return false; + } + + // Caller-supplied transcript wins; otherwise fall back to a sibling .txt next to the + // source WAV. Either way the sidecar lands next to the imported voice, where Speak + // reads it back as reference_text. + try + { + var destSidecar = Path.ChangeExtension(destinationFileName, ".txt"); + if (!string.IsNullOrWhiteSpace(transcript)) + { + File.WriteAllText(destSidecar, transcript.Trim()); + } + else + { + var sourceSidecar = Path.ChangeExtension(fileName, ".txt"); + if (File.Exists(sourceSidecar) && !File.Exists(destSidecar)) + { + File.Copy(sourceSidecar, destSidecar); + } + } + } + catch (Exception ex) + { + Se.LogError(ex, "Fish Audio S2 Pro (audio.cpp) voice import: failed to write .txt sidecar"); + } + + return true; + } +} diff --git a/src/ui/Features/Video/TextToSpeech/Engines/HiggsTtsAudioCpp.cs b/src/ui/Features/Video/TextToSpeech/Engines/HiggsTtsAudioCpp.cs new file mode 100644 index 00000000000..7755f0417ab --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/Engines/HiggsTtsAudioCpp.cs @@ -0,0 +1,875 @@ +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.ModelLicense; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Voices; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Download; +using Nikse.SubtitleEdit.Logic.Media; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; + +/// +/// Higgs Audio v3 TTS 4B (Boson AI) run through the audio.cpp runtime — the same pure C++/ggml +/// install uses (see ), so the +/// binaries are downloaded once and shared. Zero-shot voice cloning across 100+ languages — +/// by far the broadest language coverage of SE's cloning engines — with inline control over +/// emotion, style and prosody through the text itself. 24 kHz output. +/// +/// Like the IndexTTS 2.5 engine, the server honours a per-request voice_ref, so +/// switching voice does NOT restart the server — only a model or backend change does — which +/// is also what makes per-line cloning ("Clone from video") free here. The +/// model auto-detects the language of the input text, so there is no language combo. A +/// .txt sidecar next to the reference WAV (the transcript convention the other cloning +/// engines share) is passed as reference_text, which improves clone fidelity but is +/// optional. +/// +/// Licence note: the audio.cpp binaries are Apache-2.0, but the Higgs Audio v3 *weights* are +/// under Boson AI's research and non-commercial licence, which has to be accepted by the user +/// before the first download — see . +/// +public class HiggsTtsAudioCpp : ITtsEngine, IPerLineCloneEngine +{ + public string Name => "Higgs Audio v3 (audio.cpp)"; + public string Description => "Higgs Audio v3 (Boson AI) voice cloning in 100+ languages, via audio.cpp"; + public bool HasLanguageParameter => false; + public bool HasApiKey => false; + public bool HasRegion => false; + public bool HasModel => true; + public bool HasKeyFile => false; + public bool SupportsVoiceCloning => true; + public bool SupportsPerLineVoiceCloning => true; + + // Q8_0 is the default: same 24 kHz output as BF16 at 3.2 GB less on disk. + public const string ModelKeyQ8_0 = "Q8_0 (~4.7 GB)"; + public const string ModelKeyBf16 = "BF16 (~7.9 GB)"; + public const string DefaultModelKey = ModelKeyQ8_0; + + public const string ModelQ8_0FileName = "higgs-audio-v3-tts-4b-q8_0.gguf"; + public const string ModelBf16FileName = "higgs-audio-v3-tts-4b-bf16.gguf"; + + /// Family name audio.cpp registers Higgs Audio v3 TTS under. + public const string FamilyName = "higgs_audio_tts"; + + /// Model id used in the generated server config and in each request body. + private const string ServerModelId = "higgstts3"; + + /// + /// Exact byte sizes on the audio-cpp/audio.cpp-gguf HuggingFace repo. A truncated GGUF is + /// the single most common failure here — a download that dies partway leaves a file the + /// loader rejects with "GGUF tensor data range is out of bounds", so size is checked + /// before the server is ever started. Same guard as . + /// + private static readonly Dictionary ExpectedFileSizes = new(StringComparer.OrdinalIgnoreCase) + { + [ModelQ8_0FileName] = 5095354048L, + [ModelBf16FileName] = 8501587648L, + }; + + /// + /// Bumping this re-prompts every user with the licence window. Keep it in step with the + /// summary text in . + /// + public const string LicenseVersion = "boson-higgs-tts3-nc-license-2026-09"; + + public static bool IsLicenseAccepted() => + string.Equals( + Se.Settings.Video.TextToSpeech.HiggsTtsAudioCppLicenseAccepted, + LicenseVersion, + StringComparison.Ordinal); + + public static void AcceptLicense() => + Se.Settings.Video.TextToSpeech.HiggsTtsAudioCppLicenseAccepted = LicenseVersion; + + /// + /// What the first-run licence gate shows. The weights are research / non-commercial with a + /// carve-out for monetized creator content — meaningfully different terms from open source, + /// which is exactly why the gate exists. + /// + public static ModelLicenseDefinition LicenseDefinition { get; } = new( + DialogTitle: "Higgs Audio v3 - model license", + Header: "The Higgs Audio v3 model has its own license", + Intro: "The audio.cpp engine is Apache-2.0, but the Higgs Audio v3 model weights are " + + "licensed by Boson AI for research and non-commercial use. Please read the main " + + "points before downloading.", + SummaryPoints: new[] + { + "The model weights are licensed for research and non-commercial use. Production deployment, hosted APIs, embedding in a product, or reselling the model or fine-tunes of it needs a separate commercial license from Boson AI.", + "Digital creators may use the model in monetized content, provided Boson AI's Higgs Audio is credited prominently in the audio or in the accompanying text.", + "Voice cloning without the speaker's consent, impersonation, fraud, election deception, biometric surveillance, and any unlawful use are prohibited.", + "The model is provided as is, with no warranty, and Boson AI accepts no liability for what is generated.", + "You are responsible for having the right to clone any voice you use as a reference. The model does not check consent.", + }, + LicenseUrl: "https://huggingface.co/bosonai/higgs-tts-3-4b/blob/main/LICENSE", + ModelPageUrl: "https://huggingface.co/bosonai/higgs-tts-3-4b", + AcceptCheckBoxText: "I have read and accept the Boson Higgs TTS 3 Research and Non-Commercial License", + Accept: AcceptLicense); + + public static string ResolveModelKey(string? modelKey) + { + if (string.IsNullOrEmpty(modelKey)) + { + var saved = Se.Settings.Video.TextToSpeech.HiggsTtsAudioCppModel; + return string.IsNullOrEmpty(saved) ? DefaultModelKey : ResolveModelKey(saved); + } + + return modelKey == ModelKeyBf16 ? ModelKeyBf16 : ModelKeyQ8_0; + } + + public static string GetModelFileName(string? modelKey) => + ResolveModelKey(modelKey) == ModelKeyBf16 ? ModelBf16FileName : ModelQ8_0FileName; + + private static readonly HttpClient HttpClient = new() + { + Timeout = TimeSpan.FromMinutes(10), + }; + + private static readonly SemaphoreSlim ServerLock = new(1, 1); + private static Process? _serverProcess; + private static int _serverPort; + private static string? _serverLaunchCommand; + // Only the model and the backend are baked into the running server — voice is per request. + private static string? _serverModelKey; + private static string? _serverBackend; + private static bool _processExitHooked; + private static readonly StringBuilder _serverLog = new(); + + private static string ServerBaseUrl => $"http://127.0.0.1:{_serverPort}"; + + public Task IsInstalled(string? region) => Task.FromResult(File.Exists(AudioCppRuntime.GetServerExecutable())); + + public override string ToString() => Name; + + /// + /// Per-engine working folder under TextToSpeech (voices, synthesis output). The audio.cpp + /// binaries are shared (); this is not. + /// + public static string GetSetFolder() + { + if (!Directory.Exists(Se.TextToSpeechFolder)) + { + Directory.CreateDirectory(Se.TextToSpeechFolder); + } + + var folder = Path.Combine(Se.TextToSpeechFolder, "HiggsTtsAudioCpp"); + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + return folder; + } + + /// + /// audio.cpp is pointed at a directory, not a file, so the GGUF gets its own folder under + /// the shared models root: <data>/audio.cpp/models/Higgs-Audio-v3-TTS-4B-GGUF/. + /// + public static string GetSetModelsFolder() + { + var folder = Path.Combine(AudioCppRuntime.GetSetEngineFolder(), "models", "Higgs-Audio-v3-TTS-4B-GGUF"); + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + return folder; + } + + public static string GetSetVoicesFolder() + { + var folder = Path.Combine(GetSetFolder(), "voices"); + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + SeedVoicesFromQwen3TtsCppIfEmpty(folder); + NormalizeVoiceTranscriptsOnce(folder); + return folder; + } + + private static bool _voiceSeedAttempted; + private static bool _voicesNormalized; + + /// + /// One-time per session: drop unusable ref-text sidecars (the shared pack ships Wikimedia + /// attribution blurbs, not transcripts) and backfill missing transcriptions from the + /// sibling OmniVoice pack. This engine passes the .txt sidecar as reference_text, so a + /// blurb there would condition the clone on text nobody spoke — same cleanup CosyVoice3 + /// and MOSS-TTS run. + /// + private static void NormalizeVoiceTranscriptsOnce(string voicesFolder) + { + if (_voicesNormalized) + { + return; + } + _voicesNormalized = true; + + Qwen3TtsCrispAsr.NormalizeVoiceTranscripts(voicesFolder); + } + + /// + /// One-time best-effort seed of reference WAVs (plus their transcript sidecars) from the + /// shared Qwen3 voice pack, so the voice combo is not empty on first run — this engine + /// clones only and has no built-in voices. The pack ships at 16 kHz; Higgs clones from + /// 24 kHz, so resample on seed rather than letting the server upsample per request. + /// + private static void SeedVoicesFromQwen3TtsCppIfEmpty(string voicesFolder) + { + if (_voiceSeedAttempted) + { + return; + } + + _voiceSeedAttempted = true; + + try + { + if (Directory.EnumerateFiles(voicesFolder, "*.wav").Any()) + { + return; + } + + var sourceFolder = Qwen3TtsCpp.GetSetVoicesFolder(); + if (!Directory.Exists(sourceFolder) || !Directory.EnumerateFiles(sourceFolder, "*.wav").Any()) + { + return; + } + + foreach (var src in Directory.GetFiles(sourceFolder, "*.wav")) + { + var dest = Path.Combine(voicesFolder, Path.GetFileName(src)); + VoiceSeedHelper.CopyOrResample(src, dest, 24000, "Higgs Audio v3 (audio.cpp)"); + + // Bring the transcript along; NormalizeVoiceTranscriptsOnce then drops the + // attribution blurbs and backfills real transcripts where a sibling has them. + var sidecar = Path.ChangeExtension(src, ".txt"); + var sidecarDest = Path.ChangeExtension(dest, ".txt"); + if (File.Exists(sidecar) && !File.Exists(sidecarDest) && File.Exists(dest)) + { + File.Copy(sidecar, sidecarDest); + } + } + } + catch (Exception ex) + { + Se.LogError(ex, "Higgs Audio v3 (audio.cpp): voice seeding from the shared voice pack failed"); + } + } + + public static string GetModelPath(string? modelKey = null) => + Path.Combine(GetSetModelsFolder(), GetModelFileName(modelKey)); + + public static bool IsValidLocalModelFile(string path, string fileName) + { + if (!File.Exists(path)) + { + return false; + } + + if (!ExpectedFileSizes.TryGetValue(fileName, out var expected)) + { + return true; + } + + try + { + var info = new FileInfo(path); + + // FileInfo.Length reports the size of the *link* for a symlink, not of its target, + // so a symlinked GGUF (a reasonable way to share a 4.7 GB model between apps or + // put it on another disk) would look truncated — and the caller deletes files it + // considers truncated. Resolve to the final target before measuring. + var length = info.ResolveLinkTarget(returnFinalTarget: true) is FileInfo target + ? target.Length + : info.Length; + + return length == expected; + } + catch + { + return false; + } + } + + public static bool AreModelsInstalled(string? modelKey = null) => + IsValidLocalModelFile(GetModelPath(modelKey), GetModelFileName(modelKey)); + + public static DownloadHashManager.UpdateStatus GetEngineUpdateStatus() + { + var exe = AudioCppRuntime.GetServerExecutable(); + if (!File.Exists(exe)) + { + return DownloadHashManager.UpdateStatus.Unknown; + } + + var folder = Path.GetDirectoryName(exe); + return string.IsNullOrEmpty(folder) + ? DownloadHashManager.UpdateStatus.Unknown + : DownloadHashManager.GetSidecarStatus(folder); + } + + public async Task GetVoices(string language) + { + var result = new List(); + + // Voice cloning only — the combo stays empty until the user imports a reference WAV. + var voicesFolder = await Task.Run(GetSetVoicesFolder); + if (Directory.Exists(voicesFolder)) + { + foreach (var file in Directory.GetFiles(voicesFolder, "*.wav")) + { + var name = Path.GetFileNameWithoutExtension(file).Replace('_', ' '); + result.Add(new Voice(new IndexTtsVoice(name, file))); + } + } + + return result.ToArray(); + } + + public bool IsVoiceInstalled(Voice voice) => true; + + public Task GetRegions() => Task.FromResult(Array.Empty()); + + public Task GetModels() => Task.FromResult(new[] { ModelKeyQ8_0, ModelKeyBf16 }); + + // Higgs auto-detects the language of the input text (100+ languages), so there is no + // language pick — unlike IndexTTS 2.5, where auto only separates Han script from English. + public Task GetLanguages(Voice voice, string? model) => + Task.FromResult(Array.Empty()); + + public Task RefreshVoices(string language, CancellationToken cancellationToken) => + GetVoices(language); + + /// + /// : the server takes voice_ref as a path per request, + /// so the voice simply points at the cut clip - nothing is staged into this engine's own + /// folders. audio.cpp resamples the reference itself, so the 24 kHz clip is used as cut. + /// + public Voice? MakePerLineCloneVoice(string clipFileName, string voiceName) => + new Voice(new IndexTtsVoice(voiceName, clipFileName)); + + /// The clip's own path, which is exactly what the voice carries. + public string? GetPerLineReferenceClip(Voice voice) => + voice.EngineVoice is IndexTtsVoice indexVoice && !string.IsNullOrEmpty(indexVoice.FilePath) + ? indexVoice.FilePath + : null; + + /// + /// : nothing is ever staged (the voice points straight at + /// the clip), so there is nothing to clear between runs. + /// + public void ResetStagedPerLineReferences() + { + } + + public async Task Speak( + string text, + string outputFolder, + Voice voice, + TtsLanguage? language, + string? region, + string? model, + CancellationToken cancellationToken) + { + if (voice.EngineVoice is not IndexTtsVoice indexVoice) + { + throw new ArgumentException("Voice is not an IndexTtsVoice"); + } + + if (string.IsNullOrEmpty(indexVoice.FilePath)) + { + throw new InvalidOperationException( + "Higgs Audio v3 (audio.cpp) requires a reference voice WAV. " + + "Import one via the voice settings, then pick it in the voice combo. " + + "3-10 s of clean speech works best."); + } + + if (!IsLicenseAccepted()) + { + throw new InvalidOperationException( + "The Higgs Audio v3 model licence (Boson research and non-commercial license) has not been accepted."); + } + + var modelKey = ResolveModelKey(model); + await EnsureServerRunningAsync(modelKey, cancellationToken); + + var outputFileName = Path.Combine(TtsOutputFolder.Resolve(outputFolder, GetSetFolder), Guid.NewGuid() + ".wav"); + + // audio.cpp's OpenAI-style speech payload. Voice cloning goes through `voice_ref`, + // honoured per request — no server restart when the user switches voice. + var options = new Dictionary(); + + // The transcript of the reference WAV, when the shared .txt sidecar convention has + // one. Optional — Higgs clones from the audio alone — but a transcript improves clone + // fidelity, so pass it when it is there. + var referenceText = ChatterboxTtsCpp.TryReadReferenceTranscript(indexVoice.FilePath); + if (!string.IsNullOrEmpty(referenceText)) + { + options["reference_text"] = referenceText; + } + + var payload = new Dictionary + { + ["model"] = ServerModelId, + ["input"] = text, + ["response_format"] = "wav", + ["voice_ref"] = new Dictionary + { + ["type"] = "path", + ["path"] = indexVoice.FilePath, + }, + }; + + if (options.Count > 0) + { + payload["options"] = options; + } + + var body = JsonSerializer.Serialize(payload); + using var content = new StringContent(body, Encoding.UTF8, "application/json"); + Se.WriteToolsLog($"Higgs Audio v3 (audio.cpp): POST {ServerBaseUrl}/v1/audio/speech (voice={indexVoice}, textLen={text.Length})"); + + HttpResponseMessage response; + try + { + response = await HttpClient.PostAsync($"{ServerBaseUrl}/v1/audio/speech", content, cancellationToken); + } + catch (HttpRequestException ex) + { + var serverLog = SnapshotServerLog(); + var launchCommand = _serverLaunchCommand; + var died = _serverProcess?.HasExited == true; + if (died) + { + StopServerInternal(); + } + + var failMsg = $"Higgs Audio v3 (audio.cpp) request failed — Voice: {indexVoice}, Text: {text}, " + + $"RequestJson: {body}, ServerExited: {died}, ServerLog: {serverLog}" + + LaunchCmdSuffix(launchCommand); + Se.LogError(ex, failMsg); + Se.WriteToolsLog(failMsg); + + throw new InvalidOperationException( + (died + ? "Higgs Audio v3 (audio.cpp) — the audiocpp_server process crashed during synthesis." + : "Higgs Audio v3 (audio.cpp) request failed — the connection to audiocpp_server was dropped.") + + (string.IsNullOrEmpty(serverLog) ? string.Empty : $"{Environment.NewLine}Server log:{Environment.NewLine}{serverLog}") + + LaunchCmdSuffix(launchCommand), + ex); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + var errorBody = await SafeReadErrorAsync(response, cancellationToken); + var serverLog = SnapshotServerLog(); + var launchCommand = _serverLaunchCommand; + var errMsg = $"Higgs Audio v3 (audio.cpp) server error {(int)response.StatusCode} {response.StatusCode} — " + + $"Voice: {indexVoice}, Text: {text}, RequestJson: {body}, " + + $"ResponseBody: {errorBody}, ServerLog: {serverLog}" + + LaunchCmdSuffix(launchCommand); + Se.LogError(errMsg); + Se.WriteToolsLog(errMsg); + throw new InvalidOperationException( + $"Higgs Audio v3 (audio.cpp) synthesis failed ({(int)response.StatusCode}): {errorBody}" + + (string.IsNullOrEmpty(serverLog) ? string.Empty : $"{Environment.NewLine}Server log:{Environment.NewLine}{serverLog}") + + LaunchCmdSuffix(launchCommand)); + } + + await using var fileStream = File.Create(outputFileName); + await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); + await contentStream.CopyToAsync(fileStream, cancellationToken); + } + + return new TtsResult(outputFileName, text); + } + + private static async Task EnsureServerRunningAsync(string modelKey, CancellationToken ct) + { + var backend = AudioCppRuntime.GetBackend(); + + if (_serverProcess is { HasExited: false } && _serverPort != 0 + && string.Equals(_serverModelKey, modelKey, StringComparison.OrdinalIgnoreCase) + && string.Equals(_serverBackend, backend, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await ServerLock.WaitAsync(ct); + try + { + if (_serverProcess is { HasExited: false } && _serverPort != 0 + && string.Equals(_serverModelKey, modelKey, StringComparison.OrdinalIgnoreCase) + && string.Equals(_serverBackend, backend, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (_serverProcess != null) + { + StopServerInternal(); + } + + var exe = AudioCppRuntime.GetServerExecutable(); + if (!File.Exists(exe)) + { + throw new FileNotFoundException( + "audio.cpp server not found. Download the Higgs Audio v3 engine first.", exe); + } + + var modelFileName = GetModelFileName(modelKey); + var modelPath = GetModelPath(modelKey); + if (!IsValidLocalModelFile(modelPath, modelFileName)) + { + throw new FileNotFoundException( + File.Exists(modelPath) + ? $"The Higgs Audio v3 model file is incomplete: {modelPath}. Delete it and download again." + : $"The Higgs Audio v3 model file is missing: {modelPath}", + modelPath); + } + + var port = FindFreeLoopbackPort(); + var configPath = WriteServerConfig(port, backend, modelKey); + + var psi = new ProcessStartInfo + { + WorkingDirectory = Path.GetDirectoryName(exe) ?? AudioCppRuntime.GetSetEngineFolder(), + FileName = exe, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + // The server writes UTF-8. Without these the reader decodes it in the OS default + // codepage, and non-ASCII text in the captured log - the line being synthesised, + // upstream's em dashes - reaches bug reports as mojibake (#13572). + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + psi.ArgumentList.Add("--config"); + psi.ArgumentList.Add(configPath); + + var process = Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start audiocpp_server (higgs_audio_tts)"); + + var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); + _serverLaunchCommand = launchCommand; + Se.WriteToolsLog($"Higgs Audio v3 (audio.cpp) server starting — PID: {process.Id}, Cmd: {launchCommand}"); + + lock (_serverLog) _serverLog.Clear(); + process.ErrorDataReceived += (_, e) => + { + if (e.Data != null) lock (_serverLog) _serverLog.AppendLine(e.Data); + }; + process.OutputDataReceived += (_, e) => + { + if (e.Data != null) lock (_serverLog) _serverLog.AppendLine(e.Data); + }; + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + + _serverProcess = process; + _serverPort = port; + _serverModelKey = modelKey; + _serverBackend = backend; + HookProcessExitOnce(); + + // The config uses lazy_load, so /health answers within a second or two — the 4.7 GB + // model is only read on the first synthesis request. A long deadline here would just + // hide a crash-at-startup (e.g. a Vulkan build on a box with no Vulkan driver, which + // dies in the loader with 0xC0000135 before printing anything). + var deadline = DateTime.UtcNow.AddMinutes(2); + while (DateTime.UtcNow < deadline) + { + ct.ThrowIfCancellationRequested(); + if (process.HasExited) + { + var tail = SnapshotServerLog(); + var exitCode = process.ExitCode; + var exitedLaunchCommand = _serverLaunchCommand; + _serverProcess = null; + _serverPort = 0; + _serverLaunchCommand = null; + _serverModelKey = null; + _serverBackend = null; + throw new InvalidOperationException( + $"audiocpp_server exited during startup (code {exitCode}). " + + AudioCppRuntime.DescribeStartupExit(exitCode, backend) + + $" Output: {tail}" + + LaunchCmdSuffix(exitedLaunchCommand)); + } + + if (await ProbeHealthAsync(port, TimeSpan.FromSeconds(2), ct)) + { + return; + } + + await Task.Delay(TimeSpan.FromSeconds(1), ct); + } + + var lastOutput = SnapshotServerLog(); + var timeoutLaunchCommand = _serverLaunchCommand; + StopServerInternal(); + throw new TimeoutException( + $"audiocpp_server did not report healthy within 2 minutes. Last output: {lastOutput}" + + LaunchCmdSuffix(timeoutLaunchCommand)); + } + finally + { + ServerLock.Release(); + } + } + + /// + /// Writes the audio.cpp server config next to the binary. lazy_load keeps startup instant; + /// the model is read on first use and then stays resident until the server is stopped. + /// + private static string WriteServerConfig(int port, string backend, string modelKey) + { + var config = new Dictionary + { + ["host"] = "127.0.0.1", + ["port"] = port, + ["backend"] = backend, + ["threads"] = Math.Max(1, Math.Min(8, Environment.ProcessorCount / 2)), + ["lazy_load"] = true, + ["models"] = new[] + { + new Dictionary + { + ["id"] = ServerModelId, + ["family"] = FamilyName, + ["path"] = GetSetModelsFolder(), + ["task"] = "tts", + ["mode"] = "offline", + }, + }, + }; + + var configPath = Path.Combine(AudioCppRuntime.GetSetEngineFolder(), "higgs-tts-server.json"); + File.WriteAllText( + configPath, + JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }), + Encoding.UTF8); + + Se.WriteToolsLog($"Higgs Audio v3 (audio.cpp): server config written to {configPath} (model={modelKey}, backend={backend})"); + return configPath; + } + + private static string FormatLaunchCommand(string exe, System.Collections.ObjectModel.Collection args) + { + static string Quote(string s) => + !string.IsNullOrEmpty(s) && s.IndexOfAny(new[] { ' ', '\t' }) >= 0 + ? "\"" + s.Replace("\"", "\\\"") + "\"" + : s; + + var sb = new StringBuilder(Quote(exe)); + foreach (var a in args) + { + sb.Append(' ').Append(Quote(a)); + } + + return sb.ToString(); + } + + private static string LaunchCmdSuffix(string? launchCommand) => + string.IsNullOrEmpty(launchCommand) + ? string.Empty + : $"{Environment.NewLine}Launch command: {launchCommand}"; + + private static async Task SafeReadErrorAsync(HttpResponseMessage response, CancellationToken ct) + { + try + { + return await response.Content.ReadAsStringAsync(ct); + } + catch (Exception ex) + { + return $""; + } + } + + private static string SnapshotServerLog() + { + lock (_serverLog) + { + var s = _serverLog.ToString().TrimEnd(); + return s.Length > 2000 ? s[^2000..] : s; + } + } + + private static async Task ProbeHealthAsync(int port, TimeSpan timeout, CancellationToken ct) + { + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + using var resp = await HttpClient.GetAsync($"http://127.0.0.1:{port}/health", cts.Token); + return resp.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + private static int FindFreeLoopbackPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + private static void HookProcessExitOnce() + { + if (_processExitHooked) + { + return; + } + + _processExitHooked = true; + AppDomain.CurrentDomain.ProcessExit += (_, _) => StopServerInternal(); + } + + /// + /// Stop the audio.cpp server if running, releasing the loaded model's working set. + /// audio.cpp never unloads a model on its own once loaded, so this is the only way the + /// memory comes back. + /// + public static void StopServer() => StopServerInternal(); + + private static void StopServerInternal() + { + var p = _serverProcess; + _serverProcess = null; + _serverPort = 0; + _serverLaunchCommand = null; + _serverModelKey = null; + _serverBackend = null; + if (p == null) + { + return; + } + + try + { + if (!p.HasExited) + { + p.Kill(entireProcessTree: true); + p.WaitForExit(2000); + } + } + catch + { + // best effort + } + finally + { + p.Dispose(); + } + } + + private static string GetUniqueDestinationFileName(string folder, string baseName) + { + var candidate = Path.Combine(folder, baseName + ".wav"); + if (!File.Exists(candidate)) + { + return candidate; + } + + var number = 1; + do + { + candidate = Path.Combine(folder, $"{baseName}_{number}.wav"); + number++; + } while (File.Exists(candidate)); + + return candidate; + } + + public bool ImportVoice(string fileName) => ImportVoice(fileName, string.Empty); + + /// + /// Import with the reference transcript — the overload + /// routes to. Higgs clones from the audio alone, but a transcript improves clone fidelity, + /// so it is kept as the .txt sidecar Speak passes as reference_text. + /// + public bool ImportVoice(string fileName, string transcript) + { + if (string.IsNullOrEmpty(fileName) || !File.Exists(fileName)) + { + return false; + } + + var voicesFolder = GetSetVoicesFolder(); + var baseName = Path.GetFileNameWithoutExtension(fileName); + var destinationFileName = GetUniqueDestinationFileName(voicesFolder, baseName); + + // audio.cpp resamples the reference itself, but importing at 24 kHz mono keeps the + // reference clean and matches the model's own output rate. + try + { + var process = FfmpegGenerator.ConvertToMono24kHzWav(fileName, destinationFileName); + if (!process.Start()) + { + return false; + } + + process.WaitForExit(); + } + catch (Exception ex) + { + Se.LogError(ex, "Higgs Audio v3 (audio.cpp) voice import failed (ffmpeg conversion)."); + return false; + } + + if (!File.Exists(destinationFileName)) + { + return false; + } + + // Caller-supplied transcript wins; otherwise fall back to a sibling .txt next to the + // source WAV. + try + { + var destSidecar = Path.ChangeExtension(destinationFileName, ".txt"); + if (!string.IsNullOrWhiteSpace(transcript)) + { + File.WriteAllText(destSidecar, transcript.Trim()); + } + else + { + var sourceSidecar = Path.ChangeExtension(fileName, ".txt"); + if (File.Exists(sourceSidecar) && !File.Exists(destSidecar)) + { + File.Copy(sourceSidecar, destSidecar); + } + } + } + catch (Exception ex) + { + Se.LogError(ex, "Higgs Audio v3 (audio.cpp) voice import: failed to write .txt sidecar"); + } + + return true; + } +} diff --git a/src/ui/Features/Video/TextToSpeech/Engines/IPerLineCloneEngine.cs b/src/ui/Features/Video/TextToSpeech/Engines/IPerLineCloneEngine.cs new file mode 100644 index 00000000000..b281a32bd47 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/Engines/IPerLineCloneEngine.cs @@ -0,0 +1,46 @@ +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Voices; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; + +/// +/// The per-engine half of : how a cut reference clip becomes a +/// voice this engine can speak with, how such a voice gives its recording back, and what a new +/// run has to clear. Implemented by every engine whose +/// can be true - the capability flag says +/// the engine takes a reference per call, and this interface is where it says how. One without +/// the other is a wiring bug, which asserts on +/// rather than quietly dubbing every line in a fallback voice. +/// +public interface IPerLineCloneEngine +{ + /// + /// Wraps a cut reference clip as a voice this engine can speak with, staging the clip into + /// the engine's own folders when the backend can only read it from there. Null when the clip + /// cannot be used as a reference (e.g. no transcript beside it) - the caller then falls back + /// to an ordinary voice for that line rather than failing the run. + /// + /// The reference clip, with its transcript sidecar beside it. + /// + /// What to call the voice, for the rows and lists that show it - already resolved by the + /// caller, so never empty. + /// + Voice? MakePerLineCloneVoice(string clipFileName, string voiceName); + + /// + /// The recording clones from, when the voice is this engine's own + /// cloning voice type; null for another engine's voice, or for one that clones from nothing. + /// + /// + /// The counterpart of : whatever that hands out, this must + /// be able to point back at, or export and regenerate lose the recording a line was + /// generated from. + /// + string? GetPerLineReferenceClip(Voice voice); + + /// + /// Clears whatever a previous per-line run staged inside this engine's own folders; called as + /// a new per-line run starts. Engines that speak straight from the clip's own path stage + /// nothing and implement this as a no-op. + /// + void ResetStagedPerLineReferences(); +} diff --git a/src/ui/Features/Video/TextToSpeech/Engines/IndexTts25AudioCpp.cs b/src/ui/Features/Video/TextToSpeech/Engines/IndexTts25AudioCpp.cs index 3537880486c..6d755b8eda6 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/IndexTts25AudioCpp.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/IndexTts25AudioCpp.cs @@ -25,7 +25,8 @@ namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; /// /// Unlike (IndexTTS-1.5 via CrispASR), audio.cpp's server /// honours a per-request voice_ref, so switching voice does NOT restart the server — -/// only a model or backend change does. Everything else follows the same server-mode shape: +/// only a model or backend change does — which is also what makes per-line cloning ("Clone +/// from video") free here. Everything else follows the same server-mode shape: /// one persistent process on a loopback port, OpenAI-style POST /v1/audio/speech. /// /// The model is a single self-describing GGUF (the package spec is embedded), so no sidecar @@ -35,7 +36,7 @@ namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; /// under the bilibili Model Use License, which is not OSI-approved and has to be accepted by /// the user before the first download — see . /// -public class IndexTts25AudioCpp : ITtsEngine +public class IndexTts25AudioCpp : ITtsEngine, IPerLineCloneEngine { public string Name => "IndexTTS 2.5 (audio.cpp)"; public string Description => "IndexTTS-2.5 (Bilibili / IndexTeam) voice cloning in 5 languages, via audio.cpp"; @@ -45,7 +46,7 @@ public class IndexTts25AudioCpp : ITtsEngine public bool HasModel => true; public bool HasKeyFile => false; public bool SupportsVoiceCloning => true; - public bool SupportsPerLineVoiceCloning => false; + public bool SupportsPerLineVoiceCloning => true; // The Q8_0 GGUF is the default: same 22.05 kHz output as F16 at 1 GB less on disk. The // "orig" dtype build (7.3 GB) is deliberately not offered — it is a debugging artifact. @@ -125,20 +126,10 @@ public static string GetModelFileName(string? modelKey) => public override string ToString() => Name; /// - /// Where the audio.cpp binaries live: <data>/audio.cpp/, a top-level folder like - /// CrispASR and llama.cpp. audio.cpp is a whole runtime rather than one model's engine, so - /// a second audio.cpp-backed engine reuses this install instead of downloading its own. + /// The shared audio.cpp install — see . Kept as a local alias + /// because everything in this class reads it. /// - public static string GetSetEngineFolder() - { - var folder = Se.AudioCppFolder; - if (!Directory.Exists(folder)) - { - Directory.CreateDirectory(folder); - } - - return folder; - } + public static string GetSetEngineFolder() => AudioCppRuntime.GetSetEngineFolder(); /// /// Per-engine working folder under TextToSpeech (voices, synthesis output), matching where @@ -229,8 +220,7 @@ private static void SeedVoicesFromQwen3TtsCppIfEmpty(string voicesFolder) } } - public static string GetServerExecutable() => - Path.Combine(GetSetEngineFolder(), OperatingSystem.IsWindows() ? "audiocpp_server.exe" : "audiocpp_server"); + public static string GetServerExecutable() => AudioCppRuntime.GetServerExecutable(); public static string GetModelPath(string? modelKey = null) => Path.Combine(GetSetModelsFolder(), GetModelFileName(modelKey)); @@ -285,20 +275,10 @@ public static DownloadHashManager.UpdateStatus GetEngineUpdateStatus() } /// - /// ggml backend the installed archive was built for. Stored at install time by - /// ; falls back to the only backend that can be - /// assumed per platform when the marker is missing. + /// ggml backend the shared audio.cpp install was built for — see + /// . /// - public static string GetBackend() - { - var saved = Se.Settings.Video.TextToSpeech.IndexTts25AudioCppBackend; - if (!string.IsNullOrEmpty(saved)) - { - return saved; - } - - return OperatingSystem.IsMacOS() ? "metal" : "cpu"; - } + public static string GetBackend() => AudioCppRuntime.GetBackend(); public async Task GetVoices(string language) { @@ -331,17 +311,42 @@ public async Task GetVoices(string language) /// public Task GetLanguages(Voice voice, string? model) => Task.FromResult(new[] { - new TtsLanguage("auto", "Auto"), - new TtsLanguage("zh", "Chinese"), - new TtsLanguage("en", "English"), - new TtsLanguage("ja", "Japanese"), - new TtsLanguage("es", "Spanish"), - new TtsLanguage("ar", "Arabic"), + // TtsLanguage is (name, code) - every other engine writes it that way. Reversed here, the + // combo listed "zh"/"en"/"ja" instead of language names and Speak sent language.Code, i.e. + // the literal "Chinese", to audio.cpp - so no explicit pick ever worked. + new TtsLanguage("Auto", "auto"), + new TtsLanguage("Chinese", "zh"), + new TtsLanguage("English", "en"), + new TtsLanguage("Japanese", "ja"), + new TtsLanguage("Spanish", "es"), + new TtsLanguage("Arabic", "ar"), }); public Task RefreshVoices(string language, CancellationToken cancellationToken) => GetVoices(language); + /// + /// : the server takes voice_ref as a path per request, + /// so the voice simply points at the cut clip - nothing is staged into this engine's own + /// folders. audio.cpp resamples the reference itself, so the 24 kHz clip is used as cut. + /// + public Voice? MakePerLineCloneVoice(string clipFileName, string voiceName) => + new Voice(new IndexTtsVoice(voiceName, clipFileName)); + + /// The clip's own path, which is exactly what the voice carries. + public string? GetPerLineReferenceClip(Voice voice) => + voice.EngineVoice is IndexTtsVoice indexVoice && !string.IsNullOrEmpty(indexVoice.FilePath) + ? indexVoice.FilePath + : null; + + /// + /// : nothing is ever staged (the voice points straight at + /// the clip), so there is nothing to clear between runs. + /// + public void ResetStagedPerLineReferences() + { + } + public async Task Speak( string text, string outputFolder, @@ -651,28 +656,8 @@ private static async Task EnsureServerRunningAsync(string modelKey, Cancellation } } - /// - /// Turns the exit codes that mean "this build cannot run on this machine" into an - /// actionable message, since the process dies in the loader before it can print anything - /// useful of its own: - /// - Windows 0xC0000135 / -1073741515 (STATUS_DLL_NOT_FOUND): a GPU build without its - /// runtime. The Vulkan binaries import vulkan-1.dll (from the GPU driver) at load time. - /// - Linux 127: the dynamic loader could not find a shared library. The Linux CUDA - /// archive does NOT bundle libcudart.so.12 / libcublas.so.12 the way the Windows CUDA - /// zip bundles its DLLs, so it needs a system CUDA 12 runtime. - /// - private static string DescribeStartupExit(int exitCode, string backend) => exitCode switch - { - -1073741515 => $"The {backend} build could not load its GPU runtime library. " - + "Re-download the engine and pick the CPU variant.", - -1073741795 => "The CPU build uses instructions this processor does not have.", - 127 when string.Equals(backend, "cuda", StringComparison.OrdinalIgnoreCase) => - "The Linux CUDA build needs the CUDA 12 runtime (libcudart.so.12 and libcublas.so.12) " - + "installed on this system. Install the CUDA 12 runtime, or re-download the engine " - + "and pick the CPU or Vulkan variant.", - 127 => $"The {backend} build could not load a shared library it needs.", - _ => string.Empty, - }; + private static string DescribeStartupExit(int exitCode, string backend) => + AudioCppRuntime.DescribeStartupExit(exitCode, backend); /// /// Writes the audio.cpp server config next to the binary. lazy_load keeps startup instant; diff --git a/src/ui/Features/Video/TextToSpeech/Engines/OmniVoiceCrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/OmniVoiceCrispAsr.cs index 5bb2f76c60d..e5296a11669 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/OmniVoiceCrispAsr.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/OmniVoiceCrispAsr.cs @@ -57,7 +57,7 @@ namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; /// per-request field, applied without a restart. /// /// Honouring language per request needs v0.8.26 or newer, which the pin -/// (, now v0.8.29) satisfies. On the older +/// (, now v0.8.30) satisfies. On the older /// v0.8.25 the CLI adapter applied the language once at startup only, so the field was parsed and /// ignored and every line stayed language-agnostic (#13273); sending it there is harmless, since /// the IDs SE offers are the model's own, so an install predating the bump degrades quietly. diff --git a/src/ui/Features/Video/TextToSpeech/Engines/OmniVoiceTtsCpp.cs b/src/ui/Features/Video/TextToSpeech/Engines/OmniVoiceTtsCpp.cs index 3d8f42d10b7..1f5a2a86379 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/OmniVoiceTtsCpp.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/OmniVoiceTtsCpp.cs @@ -20,7 +20,7 @@ namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; /// expecting a sibling <name>.txt with the reference transcript (omnivoice-tts requires /// --ref-text whenever --ref-wav is set). /// -public class OmniVoiceTtsCpp : ITtsEngine +public class OmniVoiceTtsCpp : ITtsEngine, IPerLineCloneEngine { public string Name => "OmniVoice TTS"; public string Description => "646 languages, voice cloning, CPU/Vulkan/CUDA"; @@ -34,6 +34,28 @@ public class OmniVoiceTtsCpp : ITtsEngine // reference costs nothing beyond cutting the clip. public bool SupportsPerLineVoiceCloning => true; + /// + /// : each line is a fresh omnivoice-tts run taking the + /// clip's own path as --ref-wav (with its sibling .txt as --ref-text), so the voice simply + /// points at the clip - nothing is staged into this engine's own folders. + /// + public Voice? MakePerLineCloneVoice(string clipFileName, string voiceName) => + new Voice(new OmniVoice(voiceName, clipFileName)); + + /// The clip's own path, which is exactly what the voice carries. + public string? GetPerLineReferenceClip(Voice voice) => + voice.EngineVoice is OmniVoice omniVoice && !string.IsNullOrEmpty(omniVoice.FilePath) + ? omniVoice.FilePath + : null; + + /// + /// : nothing is ever staged (the voice points straight at + /// the clip), so there is nothing to clear between runs. + /// + public void ResetStagedPerLineReferences() + { + } + // Voice-design attribute keywords accepted by omnivoice-tts' --instruct flag (English set). // The CLI rejects free text - only these values, comma+space separated, are valid. Grouped // because each group is mutually exclusive (one gender, one age, one pitch, one accent). @@ -293,12 +315,24 @@ public async Task Speak( // Voice cloning: omnivoice-tts requires both --ref-wav and --ref-text. // Pair each voices/.wav with a sibling .txt holding the transcript. - // The transcript is captured at import time; if it has been deleted out from under - // us we fail loudly so the user knows custom voice cloning isn't happening, rather - // than silently producing the default speaker. - var usingReference = !string.IsNullOrEmpty(omniVoice.FilePath) && File.Exists(omniVoice.FilePath); + // The transcript is captured at import time; if either it or the recording itself has + // been deleted out from under us we fail loudly so the user knows custom voice cloning + // isn't happening, rather than silently producing the default speaker. + var usingReference = !string.IsNullOrEmpty(omniVoice.FilePath); if (usingReference) { + // A missing recording used to fall through to the built-in speaker without a word. + // That hit hardest where the recording is a temporary clip - the per-line clone cuts + // its references into the run folder, which is swept when Subtitle Edit closes - so + // regenerating an imported session simply spoke in the wrong voice (#14095). + if (!File.Exists(omniVoice.FilePath)) + { + throw new FileNotFoundException( + $"OmniVoice TTS cannot clone \"{omniVoice.Voice}\": its reference recording " + + $"{omniVoice.FilePath} is gone. Re-import the voice, or pick another one.", + omniVoice.FilePath); + } + var refTextPath = Path.ChangeExtension(omniVoice.FilePath, ".txt"); if (!File.Exists(refTextPath)) { diff --git a/src/ui/Features/Video/TextToSpeech/Engines/Piper.cs b/src/ui/Features/Video/TextToSpeech/Engines/Piper.cs index 8fd4385195b..b9ac46703e9 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/Piper.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/Piper.cs @@ -242,11 +242,26 @@ public async Task Speak( } var fileName = Path.Combine(TtsOutputFolder.Resolve(outputFolder, GetSetPiperFolder), Guid.NewGuid() + ".wav"); - var process = StartPiperProcess(piperVoice, text, fileName); + // Speak runs once per subtitle line, so without the using a long dub leaked one Process + // (plus its redirected stderr pipe) per line. And WaitForExitAsync returning on cancel + // leaves piper running with a half-written .wav behind - OmniVoiceTtsCpp.Speak documents + // and handles exactly this. + using var process = StartPiperProcess(piperVoice, text, fileName); Se.WriteToolsLog($"Piper: {process.StartInfo.FileName} {process.StartInfo.Arguments} (voice={piperVoice}, textLen={text.Length})"); - var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); - await process.WaitForExitAsync(cancellationToken); - var stderrOutput = await stderrTask; + + string stderrOutput; + try + { + var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + stderrOutput = await stderrTask; + } + catch (OperationCanceledException) + { + TryKill(process); + TryDelete(fileName); + throw; + } if (process.ExitCode != 0) { @@ -276,6 +291,39 @@ public async Task Speak( return new TtsResult(fileName, text); } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { +#pragma warning disable CA1416 + process.Kill(entireProcessTree: true); +#pragma warning restore CA1416 + } + } + catch + { + // best-effort - it may have exited in between + } + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // best-effort + } + } + private Process StartPiperProcess(PiperVoice voice, string inputText, string outputFileName) { var processPiper = new Process diff --git a/src/ui/Features/Video/TextToSpeech/Engines/PocketTtsCrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/PocketTtsCrispAsr.cs new file mode 100644 index 00000000000..13dd9412195 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/Engines/PocketTtsCrispAsr.cs @@ -0,0 +1,897 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Video.SpeechToText.Engines; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Voices; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Download; +using Nikse.SubtitleEdit.Logic.Media; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; + +/// +/// Kyutai Pocket TTS (100M) run through the CrispASR runtime. By far the smallest of the +/// voice-cloning engines wired up in SE — one 124-365 MB GGUF per language vs ~870 MB for +/// IndexTTS — and the fastest (continuous-latent AR at 12.5 Hz + one-step LSD flow head + +/// Mimi decoder; measured RTF ~0.1 on an M-class CPU). Six languages, one checkpoint each: +/// English, German, Spanish, Italian, Portuguese, and Kyutai's French 24-layer preview. +/// +/// The model produces near-silence without voice conditioning, so like the other cloning +/// engines the voice combo lists imported reference WAVs and there is no built-in default +/// voice. Cloning needs no reference transcript (the Mimi encoder + speaker projection +/// condition from audio alone), which also makes per-line voice cloning a plain file copy. +/// +/// License: CC-BY-4.0 plus Kyutai's gated-use conditions — see +/// https://huggingface.co/kyutai/pocket-tts (the GGUF repo itself is ungated). +/// +public class PocketTtsCrispAsr : ITtsEngine, IPerLineCloneEngine +{ + public string Name => "Pocket TTS (CrispASR)"; + public string Description => "Kyutai Pocket TTS 100M with voice cloning — 6 languages, via CrispASR"; + public bool HasLanguageParameter => false; + public bool HasApiKey => false; + public bool HasRegion => false; + public bool HasModel => true; + public bool HasKeyFile => false; + public bool SupportsVoiceCloning => true; + public bool SupportsPerLineVoiceCloning => true; + + // One checkpoint per language (upstream's defaults: English F16, the distilled 6L + // languages at Q8_0, and the undistilled French 24L preview at Q8_0). English also gets + // the Q8_0 for slow machines. The label carries the language, so HasLanguageParameter + // stays false — picking a model IS picking the language. + public const string ModelKeyEnglishF16 = "English F16 (~219 MB)"; + public const string ModelKeyEnglishQ8_0 = "English Q8_0 (~124 MB)"; + public const string ModelKeyGermanQ8_0 = "German Q8_0 (~124 MB)"; + public const string ModelKeySpanishQ8_0 = "Spanish Q8_0 (~124 MB)"; + public const string ModelKeyItalianQ8_0 = "Italian Q8_0 (~124 MB)"; + public const string ModelKeyPortugueseQ8_0 = "Portuguese Q8_0 (~124 MB)"; + public const string ModelKeyFrenchQ8_0 = "French 24L Q8_0 (~365 MB)"; + public const string DefaultModelKey = ModelKeyEnglishF16; + + public const string EnglishF16FileName = "pocket-tts-english-f16.gguf"; + public const string EnglishQ8_0FileName = "pocket-tts-english-q8_0.gguf"; + public const string GermanQ8_0FileName = "pocket-tts-german-q8_0.gguf"; + public const string SpanishQ8_0FileName = "pocket-tts-spanish-q8_0.gguf"; + public const string ItalianQ8_0FileName = "pocket-tts-italian-q8_0.gguf"; + public const string PortugueseQ8_0FileName = "pocket-tts-portuguese-q8_0.gguf"; + public const string FrenchQ8_0FileName = "pocket-tts-french_24l-q8_0.gguf"; + + // Exact byte sizes on cstr/pocket-tts-GGUF (HF LFS metadata). Used to reject truncated + // files that crispasr's --auto-download may have left behind — same guard as the other + // CrispASR-backed engines. + private static readonly Dictionary ExpectedFileSizes = new(StringComparer.OrdinalIgnoreCase) + { + [EnglishF16FileName] = 219234528L, + [EnglishQ8_0FileName] = 123632992L, + [GermanQ8_0FileName] = 123633408L, + [SpanishQ8_0FileName] = 123634464L, + [ItalianQ8_0FileName] = 123633664L, + [PortugueseQ8_0FileName] = 123634592L, + [FrenchQ8_0FileName] = 364587520L, + }; + + public static string ResolveModelKey(string? modelKey) + { + if (string.IsNullOrEmpty(modelKey)) + { + var saved = Se.Settings.Video.TextToSpeech.PocketTtsCrispAsrModel; + return string.IsNullOrEmpty(saved) ? DefaultModelKey : ResolveModelKey(saved); + } + + return modelKey switch + { + ModelKeyEnglishQ8_0 => ModelKeyEnglishQ8_0, + ModelKeyGermanQ8_0 => ModelKeyGermanQ8_0, + ModelKeySpanishQ8_0 => ModelKeySpanishQ8_0, + ModelKeyItalianQ8_0 => ModelKeyItalianQ8_0, + ModelKeyPortugueseQ8_0 => ModelKeyPortugueseQ8_0, + ModelKeyFrenchQ8_0 => ModelKeyFrenchQ8_0, + _ => ModelKeyEnglishF16, + }; + } + + public static string GetModelFileName(string? modelKey) => ResolveModelKey(modelKey) switch + { + ModelKeyEnglishQ8_0 => EnglishQ8_0FileName, + ModelKeyGermanQ8_0 => GermanQ8_0FileName, + ModelKeySpanishQ8_0 => SpanishQ8_0FileName, + ModelKeyItalianQ8_0 => ItalianQ8_0FileName, + ModelKeyPortugueseQ8_0 => PortugueseQ8_0FileName, + ModelKeyFrenchQ8_0 => FrenchQ8_0FileName, + _ => EnglishF16FileName, + }; + + /// + /// The explicit per-language backend names added in CrispASR v0.8.31 — passing these + /// (rather than the base "pocket-tts" plus a language flag) pins both the checkpoint's + /// tokenizer and its auto-download route, so a mismatched -m file fails loudly. + /// + public static string GetBackendName(string? modelKey) => ResolveModelKey(modelKey) switch + { + ModelKeyGermanQ8_0 => "pocket-tts-de", + ModelKeySpanishQ8_0 => "pocket-tts-es", + ModelKeyItalianQ8_0 => "pocket-tts-it", + ModelKeyPortugueseQ8_0 => "pocket-tts-pt", + ModelKeyFrenchQ8_0 => "pocket-tts-fr", + _ => "pocket-tts", + }; + + public static bool IsValidLocalModelFile(string path, string fileName) + { + if (!File.Exists(path)) + { + return false; + } + + if (!ExpectedFileSizes.TryGetValue(fileName, out var expected)) + { + return true; + } + + try + { + return new FileInfo(path).Length == expected; + } + catch + { + return false; + } + } + + private static readonly HttpClient HttpClient = new() + { + Timeout = TimeSpan.FromMinutes(5), + }; + private static readonly SemaphoreSlim ServerLock = new(1, 1); + private static Process? _serverProcess; + private static int _serverPort; + private static string? _serverLaunchCommand; + // Tracks the model key (= language + quant) the running server was started with. Unlike + // indextts, the pocket-tts backend resolves the per-request `voice` (a bare stem) against + // --voice-dir and caches the latents per reference, so a voice change needs NO restart — + // only a model change does. Verified against the v0.8.31 server (issue #255 upstream). + private static string? _serverModelKey; + private static bool _processExitHooked; + private static readonly StringBuilder _serverLog = new(); + + private static string ServerBaseUrl => $"http://127.0.0.1:{_serverPort}"; + + public Task IsInstalled(string? region) + { + return Task.FromResult(File.Exists(GetCrispAsrExecutable())); + } + + public override string ToString() => Name; + + /// + /// Path to the crispasr executable installed by the speech-to-text feature. Shared with + /// the other CrispASR TTS engines and all CrispASR ASR engines. + /// + public static string GetCrispAsrExecutable() + { + return new CrispAsrCohere().GetExecutable(); + } + + /// + /// Mirrors — reads the speech-to-text + /// CrispASR install's .installed.sha256 sidecar. + /// + public static DownloadHashManager.UpdateStatus GetEngineUpdateStatus() + { + var exe = GetCrispAsrExecutable(); + if (!File.Exists(exe)) + { + return DownloadHashManager.UpdateStatus.Unknown; + } + + var folder = Path.GetDirectoryName(exe); + return string.IsNullOrEmpty(folder) + ? DownloadHashManager.UpdateStatus.Unknown + : DownloadHashManager.GetSidecarStatus(folder); + } + + public static string GetSetFolder() + { + if (!Directory.Exists(Se.TextToSpeechFolder)) + { + Directory.CreateDirectory(Se.TextToSpeechFolder); + } + + var folder = Path.Combine(Se.TextToSpeechFolder, "PocketTtsCrispAsr"); + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + return folder; + } + + public static string GetSetModelsFolder() + { + // Like the other CrispASR-backed engines, the GGUFs live alongside CrispASR's + // speech-to-text models in CrispASR/models/ rather than under TextToSpeech/. + var modelsFolder = Path.Combine(Se.CrispAsrFolder, "models"); + if (!Directory.Exists(modelsFolder)) + { + Directory.CreateDirectory(modelsFolder); + } + + return modelsFolder; + } + + public static string GetSetVoicesFolder() + { + var voicesFolder = Path.Combine(GetSetFolder(), "voices"); + if (!Directory.Exists(voicesFolder)) + { + Directory.CreateDirectory(voicesFolder); + } + + SeedVoicesFromQwen3TtsCppIfEmpty(voicesFolder); + return voicesFolder; + } + + private static bool _voiceSeedAttempted; + + /// + /// One-time best-effort seed of WAV reference voices from qwen3-tts.cpp's voices folder. + /// Pocket TTS conditions through the 24 kHz Mimi encoder, and the qwen3-tts.cpp voice pack + /// ships at 16 kHz mono — resample on seed via ffmpeg, same path ImportVoice uses, so the + /// reference isn't upsampled on every synth call. Same rationale as IndexTTS (CrispASR). + /// + private static void SeedVoicesFromQwen3TtsCppIfEmpty(string voicesFolder) + { + if (_voiceSeedAttempted) + { + return; + } + _voiceSeedAttempted = true; + + try + { + if (Directory.EnumerateFiles(voicesFolder, "*.wav").Any()) + { + return; + } + + var sourceFolder = Qwen3TtsCpp.GetSetVoicesFolder(); + if (!Directory.Exists(sourceFolder) || !Directory.EnumerateFiles(sourceFolder, "*.wav").Any()) + { + return; + } + + foreach (var src in Directory.GetFiles(sourceFolder, "*.wav")) + { + var dest = Path.Combine(voicesFolder, Path.GetFileName(src)); + + // When ffmpeg cannot do it, seeding at 16 kHz beats skipping the voice. + VoiceSeedHelper.CopyOrResample(src, dest, 24000, "Pocket TTS (CrispASR)"); + } + } + catch (Exception ex) + { + Se.LogError(ex, "Pocket TTS (CrispASR): voice seeding from qwen3-tts.cpp folder failed"); + } + } + + public static string GetModelPath(string? modelKey = null) => + Path.Combine(GetSetModelsFolder(), GetModelFileName(modelKey)); + + public static bool AreModelsInstalled(string? modelKey = null) => + IsValidLocalModelFile(GetModelPath(modelKey), GetModelFileName(modelKey)); + + /// + /// Path crispasr's --auto-download writes GGUFs to. Mirrors the IndexTTS (CrispASR) helper + /// so the SE-side downloader can adopt already-cached files instead of re-pulling. + /// + public static string GetCrispAsrCacheFolder() => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "crispasr"); + + /// + /// Best-effort copy of from + /// into SE's models folder. See + /// for rationale — same truncation-guard semantics. + /// + public static bool TrySeedModelFromCrispAsrCache(string fileName, string destinationPath) + { + if (IsValidLocalModelFile(destinationPath, fileName)) + { + return true; + } + + try + { + if (File.Exists(destinationPath)) + { + Se.LogError($"Pocket TTS (CrispASR): removing wrong-sized local model file {destinationPath}"); + File.Delete(destinationPath); + } + + var cachePath = Path.Combine(GetCrispAsrCacheFolder(), fileName); + if (!IsValidLocalModelFile(cachePath, fileName)) + { + return false; + } + + File.Copy(cachePath, destinationPath); + return true; + } + catch (Exception ex) + { + Se.LogError(ex, $"Pocket TTS (CrispASR): cache seed copy failed for {fileName}"); + return false; + } + } + + public const string PerLineReferencePrefix = "se-per-line-"; + + private static bool IsStagedPerLineReference(string fileName) => + Path.GetFileName(fileName).StartsWith(PerLineReferencePrefix, StringComparison.OrdinalIgnoreCase); + + public async Task GetVoices(string language) + { + var result = new List(); + + // Voice cloning only — the model produces near-silence without voice conditioning, so + // there is no built-in default voice. The combo is empty until the user imports a + // reference WAV (or the qwen3-tts.cpp voice seed runs above). + // Off the UI thread: GetSetVoicesFolder does one-time reference-WAV seeding through + // ffmpeg, and this is awaited from SelectedEngineChanged on the dispatcher. + var voicesFolder = await Task.Run(GetSetVoicesFolder); + if (Directory.Exists(voicesFolder)) + { + foreach (var file in Directory.GetFiles(voicesFolder, "*.wav")) + { + // The per-line clone's own references live here too (the backend resolves bare + // stems against --voice-dir); they belong to one line of one run, not in a combo. + if (IsStagedPerLineReference(file)) + { + continue; + } + + var name = Path.GetFileNameWithoutExtension(file).Replace('_', ' '); + result.Add(new Voice(new PocketTtsVoice(name, file))); + } + } + + return result.ToArray(); + } + + public bool IsVoiceInstalled(Voice voice) => true; + + public Task GetRegions() => Task.FromResult(Array.Empty()); + + public Task GetModels() => Task.FromResult(new[] + { + ModelKeyEnglishF16, + ModelKeyEnglishQ8_0, + ModelKeyGermanQ8_0, + ModelKeySpanishQ8_0, + ModelKeyItalianQ8_0, + ModelKeyPortugueseQ8_0, + ModelKeyFrenchQ8_0, + }); + + public Task GetLanguages(Voice voice, string? model) => Task.FromResult(Array.Empty()); + + public Task RefreshVoices(string language, CancellationToken cancellationToken) => + GetVoices(language); + + /// + /// : the pocket-tts backend resolves the request's bare + /// voice stem against its --voice-dir, so the clip is staged in there and the voice's + /// FilePath points at the copy — that is the lookup key sends. Unlike + /// Qwen3, cloning needs no transcript (the Mimi encoder conditions from audio alone), so + /// staging never fails on a missing sidecar and every cut clip is usable. + /// + public Voice? MakePerLineCloneVoice(string clipFileName, string voiceName) => + StagePerLineReference(clipFileName) is { } staged + ? new Voice(new PocketTtsVoice(voiceName, staged)) + : null; + + /// + /// The staged copy in the voices folder, which is the only reference this engine ever + /// speaks from — exporting it is what lets an imported session be re-dubbed on a machine + /// that no longer has the video. + /// + public string? GetPerLineReferenceClip(Voice voice) => + voice.EngineVoice is PocketTtsVoice pocketVoice && !string.IsNullOrEmpty(pocketVoice.FilePath) + ? pocketVoice.FilePath + : null; + + /// + /// : see . + /// + public void ResetStagedPerLineReferences() => ClearStagedPerLineReferences(); + + public static string? StagePerLineReference(string clipFileName) + { + try + { + if (string.IsNullOrEmpty(clipFileName) || !File.Exists(clipFileName)) + { + return null; + } + + // An exported session carries the staged name, so re-staging it on import would + // otherwise pile a second prefix on top — once per round trip. + var baseName = Path.GetFileNameWithoutExtension(clipFileName); + if (baseName.StartsWith(PerLineReferencePrefix, StringComparison.OrdinalIgnoreCase)) + { + baseName = baseName.Substring(PerLineReferencePrefix.Length); + } + + var stagedFileName = Path.Combine(GetSetVoicesFolder(), PerLineReferencePrefix + baseName + ".wav"); + File.Copy(clipFileName, stagedFileName, overwrite: true); + return stagedFileName; + } + catch (Exception ex) + { + Se.LogError(ex, $"Pocket TTS (CrispASR): staging the per-line reference '{clipFileName}' failed"); + return null; + } + } + + /// + /// Removes every staged per-line reference, leaving the user's imported voices alone. + /// Called when a per-line run starts, so a run over a shorter subtitle cannot leave the + /// previous run's extra lines lying in the voices folder. + /// + public static void ClearStagedPerLineReferences() + { + try + { + var voicesFolder = Path.Combine(GetSetFolder(), "voices"); + if (!Directory.Exists(voicesFolder)) + { + return; + } + + foreach (var file in Directory.GetFiles(voicesFolder, PerLineReferencePrefix + "*")) + { + try + { + File.Delete(file); + } + catch + { + // A reference still open (the server may be reading it) is left for next time. + } + } + } + catch (Exception ex) + { + Se.LogError(ex, "Pocket TTS (CrispASR): clearing the staged per-line references failed"); + } + } + + public async Task Speak( + string text, + string outputFolder, + Voice voice, + TtsLanguage? language, + string? region, + string? model, + CancellationToken cancellationToken) + { + if (voice.EngineVoice is not PocketTtsVoice pocketVoice) + { + throw new ArgumentException("Voice is not a PocketTtsVoice"); + } + + if (string.IsNullOrEmpty(pocketVoice.FilePath)) + { + throw new InvalidOperationException( + "Pocket TTS (CrispASR) requires a reference voice WAV — the model produces " + + "near-silence without voice conditioning. Import one via the voice settings, " + + "then pick it in the voice combo (3-10 s of clean speech works best)."); + } + + var modelKey = ResolveModelKey(model); + await EnsureServerRunningAsync(modelKey, pocketVoice.FilePath, cancellationToken); + + var outputFileName = Path.Combine(TtsOutputFolder.Resolve(outputFolder, GetSetFolder), Guid.NewGuid() + ".wav"); + var inputText = text; + + // OpenAI-compatible /v1/audio/speech payload. Unlike indextts, the pocket-tts backend + // honours a per-request `voice`: a bare stem (no path separators, no .wav — the server's + // path-traversal guard rejects paths) is resolved against the startup --voice-dir and + // the reference latents are cached per stem, so switching voices needs no server + // restart. Verified against the v0.8.31 server; upstream issue #255. + var speed = Math.Clamp(Se.Settings.Video.TextToSpeech.PocketTtsCrispAsrSpeed, 0.25, 4.0); + var payload = new Dictionary + { + ["input"] = inputText, + ["response_format"] = "wav", + ["speed"] = speed, + ["voice"] = Path.GetFileNameWithoutExtension(pocketVoice.FilePath), + }; + + // Attests the user's own imported reference and the AI-disclosure duty; see + // CrispAsrTtsProvenance. Skipped when voice cloning has not been accepted in settings. + CrispAsrTtsProvenance.AddSpeechAttestations(payload); + + var body = JsonSerializer.Serialize(payload); + using var content = new StringContent(body, Encoding.UTF8, "application/json"); + Se.WriteToolsLog($"Pocket TTS (CrispASR): POST {ServerBaseUrl}/v1/audio/speech (voice={pocketVoice}, textLen={text.Length})"); + + HttpResponseMessage response; + try + { + response = await HttpClient.PostAsync($"{ServerBaseUrl}/v1/audio/speech", content, cancellationToken); + } + catch (HttpRequestException ex) + { + var serverLog = SnapshotServerLog(); + var launchCommand = _serverLaunchCommand; + var died = _serverProcess?.HasExited == true; + if (died) + { + StopServerInternal(); + } + + var failMsg = $"Pocket TTS (CrispASR) request failed — Voice: {pocketVoice}, Text: {text}, " + + $"RequestJson: {body}, ServerExited: {died}, ServerLog: {serverLog}" + + LaunchCmdSuffix(launchCommand); + Se.LogError(ex, failMsg); + Se.WriteToolsLog(failMsg); + + throw new InvalidOperationException( + (died + ? "Pocket TTS (CrispASR) — the crispasr server crashed during synthesis." + : "Pocket TTS (CrispASR) request failed — the connection to the crispasr server was dropped.") + + (string.IsNullOrEmpty(serverLog) ? string.Empty : $"{Environment.NewLine}Server log:{Environment.NewLine}{serverLog}") + + LaunchCmdSuffix(launchCommand), + ex); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + var errorBody = await SafeReadErrorAsync(response, cancellationToken); + var serverLog = SnapshotServerLog(); + var launchCommand = _serverLaunchCommand; + var errMsg = $"Pocket TTS (CrispASR) server error {(int)response.StatusCode} {response.StatusCode} — " + + $"Voice: {pocketVoice}, Text: {text}, RequestJson: {body}, " + + $"ResponseBody: {errorBody}, ServerLog: {serverLog}" + + LaunchCmdSuffix(launchCommand); + Se.LogError(errMsg); + Se.WriteToolsLog(errMsg); + throw new InvalidOperationException( + $"Pocket TTS (CrispASR) synthesis failed ({(int)response.StatusCode}): {errorBody}" + + (string.IsNullOrEmpty(serverLog) ? string.Empty : $"{Environment.NewLine}Server log:{Environment.NewLine}{serverLog}") + + LaunchCmdSuffix(launchCommand)); + } + + await using var fileStream = File.Create(outputFileName); + await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); + await contentStream.CopyToAsync(fileStream, cancellationToken); + } + + return new TtsResult(outputFileName, text); + } + + private static string FormatLaunchCommand(string exe, System.Collections.ObjectModel.Collection args) + { + static string Quote(string s) => + !string.IsNullOrEmpty(s) && s.IndexOfAny(new[] { ' ', '\t' }) >= 0 + ? "\"" + s.Replace("\"", "\\\"") + "\"" + : s; + + var sb = new StringBuilder(Quote(exe)); + foreach (var a in args) + { + sb.Append(' ').Append(Quote(a)); + } + return sb.ToString(); + } + + private static string LaunchCmdSuffix(string? launchCommand) => + string.IsNullOrEmpty(launchCommand) + ? string.Empty + : $"{Environment.NewLine}Launch command: {launchCommand}"; + + private static async Task SafeReadErrorAsync(HttpResponseMessage response, CancellationToken ct) + { + try + { + return await response.Content.ReadAsStringAsync(ct); + } + catch (Exception ex) + { + return $""; + } + } + + private static async Task EnsureServerRunningAsync(string modelKey, string voicePath, CancellationToken ct) + { + // Voice changes ride on the per-request stem (see Speak), so only a model change — + // a different language checkpoint, hence a different --backend AND -m — restarts the + // server. The startup --voice still carries the currently selected reference as the + // fallback conditioning for a request whose stem cannot be resolved. + if (_serverProcess is { HasExited: false } && _serverPort != 0 + && string.Equals(_serverModelKey, modelKey, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await ServerLock.WaitAsync(ct); + try + { + if (_serverProcess is { HasExited: false } && _serverPort != 0 + && string.Equals(_serverModelKey, modelKey, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (_serverProcess != null) + { + StopServerInternal(); + } + + var exe = GetCrispAsrExecutable(); + if (!File.Exists(exe)) + { + throw new FileNotFoundException( + "CrispASR executable not found. Install CrispASR via Video → Audio to text first.", exe); + } + + // Model GGUF: use the locally staged copy when present; otherwise fall back to + // crispasr's own --auto-download (fetches into ~/.cache/crispasr/ on first run). + var modelFileName = GetModelFileName(modelKey); + var modelPath = GetModelPath(modelKey); + var hasLocalModel = IsValidLocalModelFile(modelPath, modelFileName); + + var port = FindFreeLoopbackPort(); + var psi = new ProcessStartInfo + { + WorkingDirectory = Path.GetDirectoryName(exe) ?? GetSetFolder(), + FileName = exe, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + // The server writes UTF-8. Without these the reader decodes it in the OS default + // codepage, and non-ASCII text in the captured log - the line being synthesised, + // upstream's em dashes - reaches bug reports as mojibake (#13572). + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + psi.ArgumentList.Add("--server"); + psi.ArgumentList.Add("--backend"); + psi.ArgumentList.Add(GetBackendName(modelKey)); + psi.ArgumentList.Add("-m"); + psi.ArgumentList.Add(hasLocalModel ? modelPath : "auto"); + if (!hasLocalModel) + { + psi.ArgumentList.Add("--auto-download"); + // The Kyutai weights are behind a restricted-licence tag in crispasr's registry, + // so its managed download refuses without this. SE's own downloader (the normal + // path) fetches the ungated GGUF mirror directly and never needs it. + psi.ArgumentList.Add("--accept-license"); + psi.ArgumentList.Add("pocket-tts-terms"); + } + psi.ArgumentList.Add("--host"); + psi.ArgumentList.Add("127.0.0.1"); + psi.ArgumentList.Add("--port"); + psi.ArgumentList.Add(port.ToString()); + // /v1/audio/speech resolves the request's bare voice stem against this folder. + psi.ArgumentList.Add("--voice-dir"); + psi.ArgumentList.Add(GetSetVoicesFolder()); + // Fallback conditioning for a request without a resolvable stem — without ANY + // reference the model produces near-silence, never a normal default voice. + psi.ArgumentList.Add("--voice"); + psi.ArgumentList.Add(voicePath); + CrispAsrTtsProvenance.AddServerMarkingArgs(psi.ArgumentList, exe); + + var process = Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start crispasr (pocket-tts)"); + + var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); + _serverLaunchCommand = launchCommand; + Se.WriteToolsLog("Pocket TTS (CrispASR) server starting — " + + $"PID: {process.Id}, " + + $"Cmd: {launchCommand}"); + + lock (_serverLog) _serverLog.Clear(); + process.ErrorDataReceived += (_, e) => + { + if (e.Data != null) lock (_serverLog) _serverLog.AppendLine(e.Data); + }; + process.OutputDataReceived += (_, e) => + { + if (e.Data != null) lock (_serverLog) _serverLog.AppendLine(e.Data); + }; + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + + _serverProcess = process; + _serverPort = port; + _serverModelKey = modelKey; + HookProcessExitOnce(); + + // First-run auto-download is at most ~365 MB (French), and the model itself loads + // in seconds — much lighter than the other CrispASR TTS engines. + var deadline = DateTime.UtcNow.AddMinutes(hasLocalModel ? 5 : 15); + while (DateTime.UtcNow < deadline) + { + ct.ThrowIfCancellationRequested(); + if (process.HasExited) + { + var tail = SnapshotServerLog(); + var exitCode = process.ExitCode; + var exitedLaunchCommand = _serverLaunchCommand; + _serverProcess = null; + _serverPort = 0; + _serverLaunchCommand = null; + _serverModelKey = null; + throw new InvalidOperationException( + $"crispasr (pocket-tts) exited during startup (code {exitCode}). Output: {tail}" + + LaunchCmdSuffix(exitedLaunchCommand)); + } + if (await ProbeHealthAsync(port, TimeSpan.FromSeconds(2), ct)) + { + return; + } + await Task.Delay(TimeSpan.FromSeconds(1), ct); + } + + var lastOutput = SnapshotServerLog(); + var timeoutLaunchCommand = _serverLaunchCommand; + StopServerInternal(); + throw new TimeoutException( + $"crispasr (pocket-tts) did not report healthy within {(hasLocalModel ? 5 : 15)} minutes. Last output: {lastOutput}" + + LaunchCmdSuffix(timeoutLaunchCommand)); + } + finally + { + ServerLock.Release(); + } + } + + private static string SnapshotServerLog() + { + lock (_serverLog) + { + var s = _serverLog.ToString().TrimEnd(); + return s.Length > 2000 ? s[^2000..] : s; + } + } + + private static async Task ProbeHealthAsync(int port, TimeSpan timeout, CancellationToken ct) + { + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + using var resp = await HttpClient.GetAsync($"http://127.0.0.1:{port}/health", cts.Token); + return resp.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + private static int FindFreeLoopbackPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + private static void HookProcessExitOnce() + { + if (_processExitHooked) + { + return; + } + _processExitHooked = true; + AppDomain.CurrentDomain.ProcessExit += (_, _) => StopServerInternal(); + } + + /// + /// Stop the running crispasr (pocket-tts) server if any, releasing GPU memory. Called by + /// TextToSpeechViewModel when starting synthesis on a different engine or when the + /// TTS window closes, so the CrispASR-based TTS engines don't pile up in VRAM. + /// + public static void StopServer() => StopServerInternal(); + + private static void StopServerInternal() + { + var p = _serverProcess; + _serverProcess = null; + _serverPort = 0; + _serverLaunchCommand = null; + _serverModelKey = null; + if (p == null) + { + return; + } + try + { + if (!p.HasExited) + { + p.Kill(entireProcessTree: true); + p.WaitForExit(2000); + } + } + catch + { + // best effort + } + finally + { + p.Dispose(); + } + } + + private static string GetUniqueDestinationFileName(string folder, string baseName) + { + var candidate = Path.Combine(folder, baseName + ".wav"); + if (!File.Exists(candidate)) + { + return candidate; + } + + var number = 1; + do + { + candidate = Path.Combine(folder, $"{baseName}_{number}.wav"); + number++; + } while (File.Exists(candidate)); + + return candidate; + } + + public bool ImportVoice(string fileName) + { + if (string.IsNullOrEmpty(fileName) || !File.Exists(fileName)) + { + return false; + } + + var voicesFolder = GetSetVoicesFolder(); + var baseName = Path.GetFileNameWithoutExtension(fileName); + var destinationFileName = GetUniqueDestinationFileName(voicesFolder, baseName); + + // The Mimi encoder runs at 24 kHz. Resample on import via ffmpeg regardless of source + // format. No .txt sidecar needed (cloning conditions from audio alone). + try + { + var process = FfmpegGenerator.ConvertToMono24kHzWav(fileName, destinationFileName); + if (!process.Start()) + { + return false; + } + + process.WaitForExit(); + } + catch (Exception ex) + { + Se.LogError(ex, "Pocket TTS (CrispASR) voice import failed (ffmpeg conversion)."); + return false; + } + + return File.Exists(destinationFileName); + } +} diff --git a/src/ui/Features/Video/TextToSpeech/Engines/Qwen3TtsCrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/Qwen3TtsCrispAsr.cs index 72612767a88..b35ef0de5fa 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/Qwen3TtsCrispAsr.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/Qwen3TtsCrispAsr.cs @@ -38,7 +38,7 @@ namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; /// (~986 MB) and lives in this engine's own folder — see /// for the URLs. /// -public class Qwen3TtsCrispAsr : ITtsEngine +public class Qwen3TtsCrispAsr : ITtsEngine, IPerLineCloneEngine { public string Name => "Qwen3 TTS (CrispASR)"; public string Description => "via CrispASR (VoiceDesign or CustomVoice 1.7B)"; @@ -48,7 +48,16 @@ public class Qwen3TtsCrispAsr : ITtsEngine public bool HasModel => true; public bool HasKeyFile => false; public bool SupportsVoiceCloning => true; - public bool SupportsPerLineVoiceCloning => false; + + /// + /// True only while the Base (Voice clone) model is picked, and true at all only because this + /// backend takes the reference per request: voice is resolved against --voice-dir on + /// every call, so a reference that changes per line costs a file copy rather than a server + /// restart and a model reload. VoiceDesign and CustomVoice ignore reference WAVs outright, + /// so there the offer would be a promise the model cannot keep - the model combo is what + /// switches this on, and it re-pulls the voice list on every change. + /// + public bool SupportsPerLineVoiceCloning => IsCloneModel(null); public const string ModelKeyVoiceDesign = "1.7B VoiceDesign"; public const string ModelKeyCustomVoice = "1.7B CustomVoice"; @@ -261,6 +270,10 @@ public static string GetSetVoicesFolder() Directory.CreateDirectory(voicesFolder); } + // First: a crashed run can leave per-line clone references behind, and every pass below + // would otherwise treat them as the user's own voices - seeding would think the folder + // is already populated, and the transcript pass would rewrite their sidecars. + ClearStagedPerLineReferencesOnce(voicesFolder); SeedVoicesFromQwen3TtsCppIfEmpty(voicesFolder); NormalizeVoiceSampleRatesOnce(voicesFolder); NormalizeVoiceTranscriptsOnce(voicesFolder); @@ -268,8 +281,195 @@ public static string GetSetVoicesFolder() } private static bool _voiceSeedAttempted; + /// A reference WAV is a few seconds; past this ffmpeg is stuck. + private const int ResampleTimeoutMs = 60_000; + private static bool _voiceSampleRatesNormalized; private static bool _voiceTranscriptsNormalized; + private static bool _stagedPerLineReferencesCleared; + + /// + /// File-name prefix of the reference clips the per-line voice clone stages in the voices + /// folder. The name is reserved: a file called this is the current run's reference for one + /// subtitle line, never a voice the user imported, so it is hidden from the voice list and + /// cleared between runs. + /// + public const string PerLineReferencePrefix = "se-per-line-"; + + /// True when is a reference staged for one line. + public static bool IsStagedPerLineReference(string fileName) => + Path.GetFileName(fileName).StartsWith(PerLineReferencePrefix, StringComparison.OrdinalIgnoreCase); + + /// + /// Copies one per-line cloning reference into the voices folder and returns the staged WAV, + /// or null when the clip cannot be used as a reference. + /// + /// + /// A copy is needed because the backend resolves the request's voice as a bare name + /// inside --voice-dir; handing it the absolute path of a clip cut into a temp folder skips + /// the ref-text sidecar and fails. It is a plain copy and not an ffmpeg pass because the + /// clips are cut as 24 kHz mono PCM16 already - exactly what the Base backend demands. + /// + /// Null when there is no usable transcript beside the clip: the backend answers a reference + /// without ref-text with "ref-text not set" (HTTP 500), so the caller is better off falling + /// back to an ordinary voice for that line than failing it. + /// + public static string? StagePerLineReference(string clipFileName) + { + // The voices folder is resolved only once the clip is known to be usable: GetSetVoicesFolder + // runs the one-time seeding and resampling passes through ffmpeg, which is wasted work for a + // line that is about to fall back to an ordinary voice anyway. + return ReadReferenceTranscript(clipFileName) is { } transcript + ? StagePerLineReferenceIn(clipFileName, GetSetVoicesFolder(), transcript) + : null; + } + + /// + /// with the destination spelled out, so the copy can be + /// exercised without writing into the user's real voices folder. + /// + internal static string? StagePerLineReferenceIn(string clipFileName, string voicesFolder) => + ReadReferenceTranscript(clipFileName) is { } transcript + ? StagePerLineReferenceIn(clipFileName, voicesFolder, transcript) + : null; + + private static string? StagePerLineReferenceIn(string clipFileName, string voicesFolder, string transcript) + { + try + { + // An exported session carries the staged name, so re-staging it on import would + // otherwise pile a second prefix on top - once per round trip. + var baseName = Path.GetFileNameWithoutExtension(clipFileName); + if (baseName.StartsWith(PerLineReferencePrefix, StringComparison.OrdinalIgnoreCase)) + { + baseName = baseName.Substring(PerLineReferencePrefix.Length); + } + + var stagedFileName = Path.Combine(voicesFolder, PerLineReferencePrefix + baseName + ".wav"); + + // Regenerate hands back the clip a line was generated from, which IS the staged copy: + // source and destination are then the same file, and copying a file onto itself only + // throws. The clip is already in place - transcript sidecar beside it (that is where + // ReadReferenceTranscript just found it) - so there is nothing to do. + if (string.Equals(Path.GetFullPath(stagedFileName), Path.GetFullPath(clipFileName), StringComparison.OrdinalIgnoreCase)) + { + return clipFileName; + } + + Directory.CreateDirectory(voicesFolder); + File.Copy(clipFileName, stagedFileName, overwrite: true); + File.WriteAllText(Path.ChangeExtension(stagedFileName, ".txt"), transcript); + return stagedFileName; + } + catch (Exception ex) + { + Se.LogError(ex, $"Qwen3 TTS (CrispASR): staging the per-line reference '{clipFileName}' failed"); + return null; + } + } + + /// + /// : the qwen3-tts backend reads references from its own + /// --voice-dir and nowhere else, so the clip is staged in there first and the voice's + /// FilePath points at the copy - that is the lookup key, since sends the + /// file's bare name as the request's voice. The name stays free to be the friendly + /// one, which is how an imported session keeps the voice name a line was generated with. + /// Null when the clip cannot be staged (no transcript beside it) - that one line falls back + /// to an ordinary voice instead of failing the run. + /// + public Voice? MakePerLineCloneVoice(string clipFileName, string voiceName) => + StagePerLineReference(clipFileName) is { } staged + ? new Voice(new Qwen3TtsVoice(voiceName, staged)) + : null; + + /// + /// The staged copy in the voices folder, which is the only reference this engine ever speaks + /// from. Exporting it (with its .txt sidecar) is what lets an imported session be re-dubbed + /// on a machine that no longer has the video. + /// + public string? GetPerLineReferenceClip(Voice voice) => + voice.EngineVoice is Qwen3TtsVoice qwen3Voice && !string.IsNullOrEmpty(qwen3Voice.FilePath) + ? qwen3Voice.FilePath + : null; + + /// + /// : see . + /// + public void ResetStagedPerLineReferences() => ClearStagedPerLineReferences(); + + /// + /// What is spoken in a per-line reference clip, or null when the clip is missing or carries no + /// usable transcript - the backend answers a reference without ref-text with "ref-text not set" + /// (HTTP 500), so such a clip is not a reference and the line falls back instead. + /// + private static string? ReadReferenceTranscript(string clipFileName) + { + if (string.IsNullOrEmpty(clipFileName) || !File.Exists(clipFileName)) + { + return null; + } + + var transcript = TryReadUsableTranscript(clipFileName); + if (string.IsNullOrWhiteSpace(transcript)) + { + Se.WriteToolsLog( + $"Qwen3 TTS (CrispASR): no usable transcript beside '{clipFileName}' - not cloning this line"); + return null; + } + + return transcript; + } + + /// + /// Removes every staged per-line reference, leaving the user's imported voices alone. Called + /// when a per-line run starts, so a run over a shorter subtitle cannot leave the previous + /// run's extra lines lying in the voices folder. + /// + public static void ClearStagedPerLineReferences() => DeleteStagedPerLineReferences(GetSetVoicesFolder()); + + /// + /// with the folder spelled out - the test seam that + /// matches . + /// + internal static void ClearStagedPerLineReferencesIn(string voicesFolder) => + DeleteStagedPerLineReferences(voicesFolder); + + private static void ClearStagedPerLineReferencesOnce(string voicesFolder) + { + if (_stagedPerLineReferencesCleared) + { + return; + } + _stagedPerLineReferencesCleared = true; + DeleteStagedPerLineReferences(voicesFolder); + } + + private static void DeleteStagedPerLineReferences(string voicesFolder) + { + try + { + if (!Directory.Exists(voicesFolder)) + { + return; + } + + foreach (var file in Directory.GetFiles(voicesFolder, PerLineReferencePrefix + "*")) + { + try + { + File.Delete(file); + } + catch + { + // A reference still open (the server may be reading it) is left for next time. + } + } + } + catch (Exception ex) + { + Se.LogError(ex, "Qwen3 TTS (CrispASR): clearing the staged per-line references failed"); + } + } /// /// One-time best-effort seeding of reference voices from the existing qwen3-tts.cpp engine's @@ -292,7 +492,7 @@ private static void SeedVoicesFromQwen3TtsCppIfEmpty(string voicesFolder) try { - if (Directory.EnumerateFiles(voicesFolder, "*.wav").Any()) + if (Directory.EnumerateFiles(voicesFolder, "*.wav").Any(f => !IsStagedPerLineReference(f))) { return; } @@ -362,17 +562,26 @@ private static void NormalizeVoiceSampleRatesOnce(string voicesFolder) var consumed = false; try { - var process = FfmpegGenerator.ConvertToMono24kHzWav(wav, temp); + using var process = FfmpegGenerator.ConvertToMono24kHzWav(wav, temp); if (!process.Start()) { continue; } - process.WaitForExit(); - if (File.Exists(temp) && new FileInfo(temp).Length > 0) + // Require a clean exit, and never delete the original first. ffmpeg writes the + // 44-byte RIFF header before it encodes, so a run that starts and then fails + // leaves a non-empty stub - "exists and non-empty" passed, the user's reference + // WAV was deleted, and the stub took its place permanently. VoiceSeedHelper + // spells out exactly this rule; every other engine goes through it. + if (!process.WaitForExit(ResampleTimeoutMs)) + { + try { process.Kill(true); } catch { /* best-effort */ } + continue; + } + + if (process.ExitCode == 0 && File.Exists(temp) && new FileInfo(temp).Length > 0) { - File.Delete(wav); - File.Move(temp, wav); + File.Move(temp, wav, overwrite: true); consumed = true; } } @@ -706,6 +915,13 @@ public async Task GetVoices(string language) { foreach (var file in Directory.GetFiles(voicesFolder, "*.wav")) { + // The per-line clone's own references live here too (the backend reads them from + // nowhere else); they belong to one line of one run, not in a voice combo. + if (IsStagedPerLineReference(file)) + { + continue; + } + var name = Path.GetFileNameWithoutExtension(file).Replace('_', ' '); result.Add(new Voice(new Qwen3TtsVoice(name, file))); } diff --git a/src/ui/Features/Video/TextToSpeech/Engines/TtsEngineCatalog.cs b/src/ui/Features/Video/TextToSpeech/Engines/TtsEngineCatalog.cs index bfa894ed8a5..c84852bcda9 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/TtsEngineCatalog.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/TtsEngineCatalog.cs @@ -1,4 +1,4 @@ -using Nikse.SubtitleEdit.Logic.Download; +using Nikse.SubtitleEdit.Logic.Download; using System; using System.Collections.Generic; @@ -60,23 +60,51 @@ public static List CreateVoiceCloningEngines() // use Qwen3TtsCrispAsr until upstream qwen3-tts.cpp is fixed. new Qwen3TtsCrispAsr(), - // VibeVoiceCrispAsr hidden: output quality is unusable in practice even after - // bumping the post-synth speed knob (#11223). The engine class + download service + - // settings dialog are kept so this becomes a one-line re-enable when upstream - // CrispASR's VibeVoice backend ships a higher-quality build. - // new VibeVoiceCrispAsr(), + // VibeVoiceCrispAsr was hidden while its output quality was judged unusable. That + // was measured on the CrispASR build of the time (v0.8.29); re-checked by ear on + // v0.8.31 with the same 1.5B q4_k weights and the same request SE sends (speed 1.1), + // the output is acceptable, so it is back. The old note cited #11223 as the evidence, + // but that is the speed-slider PR - it set the 1.1 default, it never assessed quality. + new VibeVoiceCrispAsr(), new IndexTtsCrispAsr(), + // dots.tts SOAR 2B (CrispASR) — continuous-latent AR model with a flow-matching DiT + // head and a 48 kHz BigVGAN vocoder, Apache-2.0. Grouped with the other CrispASR + // engines; clones from a CAM++ speaker embedding so no reference transcript is needed. + new DotsTtsCrispAsr(), + + // Confucius4-TTS (CrispASR) — NetEase Youdao's two-stage GPT-2/flow-matching model, + // 22.05 kHz, 14 languages, Apache-2.0. Zero-shot cloning is mandatory (no default + // voice exists); clones from w2v-BERT + CAM++ conditioning so no reference + // transcript is needed. + new Confucius4TtsCrispAsr(), + // CosyVoice3 (CrispASR) sits immediately after IndexTtsCrispAsr to keep the CrispASR // engines grouped visually in the engine combo. new CosyVoice3CrispAsr(), + // Pocket TTS (CrispASR) — Kyutai's 100M model, the smallest and fastest cloning + // engine here (one 124-365 MB GGUF per language, RTF ~0.1 on CPU). Six languages; + // per-request reference resolution, so voice changes need no server restart. + new PocketTtsCrispAsr(), + // IndexTTS 2.5 on the audio.cpp runtime (not CrispASR): 5 languages, emotion // control and speaking-rate control, with a per-request reference voice so the // server is not restarted when the voice changes. new IndexTts25AudioCpp(), + // Higgs Audio v3 (audio.cpp) — Boson AI's 4B model, cloning across 100+ languages, + // the broadest coverage of any engine here. Shares the audio.cpp runtime install + // with IndexTTS 2.5, so it is grouped right after it. Weights are research / + // non-commercial licensed (first-run accept window, like IndexTTS 2.5's). + new HiggsTtsAudioCpp(), + + // Fish Audio S2 Pro (audio.cpp) — cloning from a 10-30 s reference across 80+ + // languages with inline prosody/emotion control. Same shared runtime; weights are + // under the Fish Audio Research License (first-run accept window). + new FishTtsAudioCpp(), + // F5-TTS (CrispASR) hidden: CrispASR 0.6.12 has no GPU backend for f5-tts, so // synthesis runs the fixed 32-step Euler ODE through a 22-layer DiT + Vocos on // CPU only. That's 3-8 minutes per short utterance on Mac CPU — unusable for the diff --git a/src/ui/Features/Video/TextToSpeech/Engines/VoiceCloneImporter.cs b/src/ui/Features/Video/TextToSpeech/Engines/VoiceCloneImporter.cs index cd5d5c26162..f63526c455e 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/VoiceCloneImporter.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/VoiceCloneImporter.cs @@ -34,6 +34,8 @@ public static bool Import(ITtsEngine engine, string fileName, string? transcript { CosyVoice3CrispAsr e => e.ImportVoice(fileName, text), F5TtsCrispAsr e => e.ImportVoice(fileName, text), + FishTtsAudioCpp e => e.ImportVoice(fileName, text), + HiggsTtsAudioCpp e => e.ImportVoice(fileName, text), MossTtsCrispAsr e => e.ImportVoice(fileName, text), OmniVoiceCrispAsr e => e.ImportVoice(fileName, text), OmniVoiceTtsCpp e => e.ImportVoice(fileName, text), diff --git a/src/ui/Features/Video/TextToSpeech/Engines/VoiceCloningConsent.cs b/src/ui/Features/Video/TextToSpeech/Engines/VoiceCloningConsent.cs index bb231bbc36e..b9a7cb7a2b4 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/VoiceCloningConsent.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/VoiceCloningConsent.cs @@ -90,6 +90,8 @@ public static void Decline() /// type — a preset, a designed voice or a built-in speaker leaves it empty. The types share no /// interface, hence the list; a cloning engine added later has to be added here too, and until /// it is, its clones simply are not gated at synthesis (the import gate still covers them). + /// VoiceCloningConsentTests.IsCloneVoice_RecognisesEveryCloningEngineInTheCatalog fails + /// for any engine in that is missing. /// /// This is what catches reference WAVs that never went through the import dialog — dropped into /// the voices folder by hand from the settings dialogs' "open voices folder", or seeded from a @@ -102,12 +104,15 @@ public static void Decline() // Clones every speaker in the video, one per line - the most cloning any single pick does. PerLineCloneVoice => true, ChatterboxVoice v => !string.IsNullOrEmpty(v.FilePath), + Confucius4TtsVoice v => !string.IsNullOrEmpty(v.FilePath), CosyVoice3Voice v => !string.IsNullOrEmpty(v.FilePath), + DotsTtsVoice v => !string.IsNullOrEmpty(v.FilePath), F5TtsVoice v => !string.IsNullOrEmpty(v.FilePath), IndexTtsVoice v => !string.IsNullOrEmpty(v.FilePath), MossTtsVoice v => !string.IsNullOrEmpty(v.FilePath), OmniVoice v => !string.IsNullOrEmpty(v.FilePath), OmniVoiceCrispAsrVoice v => !string.IsNullOrEmpty(v.FilePath), + PocketTtsVoice v => !string.IsNullOrEmpty(v.FilePath), Qwen3TtsVoice v => !string.IsNullOrEmpty(v.FilePath), VibeVoice v => !string.IsNullOrEmpty(v.FilePath), VoxCPM2Voice v => !string.IsNullOrEmpty(v.FilePath), diff --git a/src/ui/Features/Video/TextToSpeech/Engines/VoiceSeedHelper.cs b/src/ui/Features/Video/TextToSpeech/Engines/VoiceSeedHelper.cs index 63051a8ccd3..c2795c9deb8 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/VoiceSeedHelper.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/VoiceSeedHelper.cs @@ -29,7 +29,10 @@ public static class VoiceSeedHelper /// not produce a usable file. Does nothing when already exists. /// Never throws: seeding is best-effort and must not take the voice list down with it. /// - /// 24000 for most backends, 16000 for CosyVoice3's s3tok. + /// + /// 24000 for most backends, 16000 for CosyVoice3's s3tok, 22050 for Confucius4's S2A mel, + /// 44100 for Fish S2 Pro's codec. Anything else resamples to 24000. + /// /// Engine name used in log lines, e.g. "OmniVoice (CrispASR)". /// /// False for backends that reject a reference at any other sample rate (Qwen3 Base), where a @@ -85,9 +88,13 @@ public static void CopyOrResample( private static bool TryConvert(string src, string dest, int sampleRateHz, string enginePrefix) { - using var ffmpeg = sampleRateHz == 16000 - ? FfmpegGenerator.ConvertToMono16kHzWav(src, dest) - : FfmpegGenerator.ConvertToMono24kHzWav(src, dest); + using var ffmpeg = sampleRateHz switch + { + 16000 => FfmpegGenerator.ConvertToMono16kHzWav(src, dest), + 22050 => FfmpegGenerator.ConvertToMono22kHzWav(src, dest), + 44100 => FfmpegGenerator.ConvertToMono44kHzWav(src, dest), + _ => FfmpegGenerator.ConvertToMono24kHzWav(src, dest), + }; if (!ffmpeg.Start()) { // ffmpeg unavailable - the caller's plain copy seeds the voice at its original rate. diff --git a/src/ui/Features/Video/TextToSpeech/Engines/ZonosLanguages.cs b/src/ui/Features/Video/TextToSpeech/Engines/ZonosLanguages.cs new file mode 100644 index 00000000000..41fe3fe83f5 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/Engines/ZonosLanguages.cs @@ -0,0 +1,208 @@ +using Nikse.SubtitleEdit.Logic.Config; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; + +/// +/// The languages Zonos-v0.1 can be asked to speak, as the eSpeak-NG voice codes the model was +/// conditioned with (Zonos has an integer language conditioner indexed by these codes, and +/// CrispASR's zonos-tts backend also hands the same code to eSpeak for G2P). The codes +/// are read verbatim from the GGUF's zonos.language_codes table, so every entry here is +/// one the backend accepts; an unknown code is logged and silently falls back to en-us. +/// +/// Zyphra only advertises English, Japanese, Chinese, French and German as trained languages — +/// the rest of the table exists in the model but with little or no training data behind it. +/// They are still offered: eSpeak phonemising Czech AS Czech is always better than the English +/// G2P the backend falls back to when no language is sent, which was what every non-English +/// line got before SE sent one at all. +/// +/// The list leads with English (US) because that is the backend's default when the request has +/// no language field — so a combo that falls back to its first entry reproduces the old +/// behaviour, the same role "Auto" plays for . Zonos cannot +/// detect the language itself, so there is no honest "Auto" entry to offer. +/// +/// Left out of the model's table on purpose: the constructed and reconstructed entries +/// (Interlingua, Lojban, Lingua Franca Nova, Pyash, Ancient Greek, Classical Nahuatl). +/// +internal static class ZonosLanguages +{ + /// The backend's own default (sent as nothing = en-us); leads the list. + public static readonly TtsLanguage Default = new("English (US)", "en-us"); + + private static readonly TtsLanguage[] Catalog = new TtsLanguage[] + { + new("Afrikaans", "af"), + new("Albanian", "sq"), + new("Amharic", "am"), + new("Arabic", "ar"), + new("Aragonese", "an"), + new("Armenian", "hy"), + new("Armenian (Western)", "hyw"), + new("Assamese", "as"), + new("Azerbaijani", "az"), + new("Bashkir", "ba"), + new("Basque", "eu"), + new("Bengali", "bn"), + new("Bishnupriya Manipuri", "bpy"), + new("Bosnian", "bs"), + new("Bulgarian", "bg"), + new("Burmese", "my"), + new("Cantonese", "yue"), + new("Catalan", "ca"), + new("Chinese (Mandarin)", "cmn"), + new("Chinese (Hakka)", "hak"), + new("Croatian", "hr"), + new("Czech", "cs"), + new("Danish", "da"), + new("Dutch", "nl"), + new("English (Caribbean)", "en-029"), + new("English (UK)", "en-gb"), + new("English (UK, Lancashire)", "en-gb-x-gbclan"), + new("English (UK, Received Pronunciation)", "en-gb-x-rp"), + new("English (UK, West Midlands)", "en-gb-x-gbcwmd"), + new("English (Scotland)", "en-gb-scotland"), + new("Esperanto", "eo"), + new("Estonian", "et"), + new("Finnish", "fi"), + new("French", "fr-fr"), + new("French (Belgium)", "fr-be"), + new("French (Switzerland)", "fr-ch"), + new("Georgian", "ka"), + new("German", "de"), + new("Greek", "el"), + new("Greenlandic", "kl"), + new("Guarani", "gn"), + new("Gujarati", "gu"), + new("Haitian Creole", "ht"), + new("Hindi", "hi"), + new("Hungarian", "hu"), + new("Icelandic", "is"), + new("Indonesian", "id"), + new("Irish", "ga"), + new("Italian", "it"), + new("Japanese", "ja"), + new("Kannada", "kn"), + new("Kazakh", "kk"), + new("K'iche'", "quc"), + new("Konkani", "kok"), + new("Korean", "ko"), + new("Kurdish", "ku"), + new("Kyrgyz", "ky"), + new("Latin", "la"), + new("Latvian", "lv"), + new("Lithuanian", "lt"), + new("Macedonian", "mk"), + new("Malay", "ms"), + new("Malayalam", "ml"), + new("Maltese", "mt"), + new("Māori", "mi"), + new("Marathi", "mr"), + new("Nepali", "ne"), + new("Norwegian (Bokmål)", "nb"), + new("Odia", "or"), + new("Oromo", "om"), + new("Papiamento", "pap"), + new("Persian", "fa"), + new("Persian (Latin script)", "fa-latn"), + new("Polish", "pl"), + new("Portuguese", "pt"), + new("Portuguese (Brazil)", "pt-br"), + new("Punjabi", "pa"), + new("Romanian", "ro"), + new("Russian", "ru"), + new("Russian (Latvia)", "ru-lv"), + new("Scottish Gaelic", "gd"), + new("Serbian", "sr"), + new("Setswana", "tn"), + new("Shan", "shn"), + new("Sindhi", "sd"), + new("Sinhala", "si"), + new("Slovak", "sk"), + new("Slovenian", "sl"), + new("Spanish", "es"), + new("Spanish (Latin America)", "es-419"), + new("Swahili", "sw"), + new("Swedish", "sv"), + new("Tamil", "ta"), + new("Tatar", "tt"), + new("Telugu", "te"), + new("Turkish", "tr"), + new("Urdu", "ur"), + new("Uzbek", "uz"), + new("Vietnamese", "vi"), + new("Vietnamese (Central)", "vi-vn-x-central"), + new("Vietnamese (Southern)", "vi-vn-x-south"), + new("Welsh", "cy"), + }; + + /// + /// English (US) first, then the rest sorted by name (ordinal, case-insensitive - sorted here + /// rather than by hand so the table above can be edited without keeping it in order). + /// + /// Declared after on purpose: static field initializers run in textual + /// order, so moving this above the catalog would copy a null array. + /// + public static readonly TtsLanguage[] All = BuildAll(); + + private static TtsLanguage[] BuildAll() + { + var result = new TtsLanguage[Catalog.Length + 1]; + result[0] = Default; + Array.Copy(Catalog, 0, result, 1, Catalog.Length); + Array.Sort(result, 1, Catalog.Length, Comparer.Create( + (a, b) => StringComparer.OrdinalIgnoreCase.Compare(a.Name, b.Name))); + return result; + } + + /// + /// The value to send as the request's language field for , + /// or an empty string when no field should be sent (an unknown entry, or nothing selected + /// and nothing saved). Sending nothing leaves the backend on its en-us default. + /// + public static string ResolveLanguageArg(TtsLanguage? language) + { + // A null language means the CALLER had none to hand over, not that the user picked + // English: the cast dialog's voice-test button and every cross-engine cast row pass null + // on purpose ("engines fall back to their own saved defaults"), so without this fallback + // those paths would silently ignore the language the user did pick — the hole CosyVoice3 + // had in #13272 and Chatterbox closed in #13470. + if (language == null) + { + return ResolveSavedLanguageArg(); + } + + var code = language.Code; + if (string.IsNullOrWhiteSpace(code)) + { + return string.Empty; + } + + // Guard against a language object left over from another engine (the view model can hold + // one while switching engines): only codes this engine actually advertises are passed on. + return IsSupported(code) ? code : string.Empty; + } + + /// + /// The language code behind the pick saved by the main TTS window for this engine, or an + /// empty string when nothing is saved. The setting stores the DISPLAY NAME, which is how + /// TextToSpeechViewModel writes and restores it. + /// + public static string ResolveSavedLanguageArg() + { + var savedName = Se.Settings.Video.TextToSpeech.ZonosTtsCrispAsrLanguage; + if (string.IsNullOrWhiteSpace(savedName)) + { + return string.Empty; + } + + return All.FirstOrDefault(l => string.Equals(l.Name, savedName, StringComparison.OrdinalIgnoreCase))?.Code + ?? string.Empty; + } + + /// True when is one of the codes in the model's table. + public static bool IsSupported(string? code) => + !string.IsNullOrWhiteSpace(code) + && All.Any(l => string.Equals(l.Code, code, StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/ui/Features/Video/TextToSpeech/Engines/ZonosTtsCrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/ZonosTtsCrispAsr.cs index 49465d24f56..3bc9d246c07 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/ZonosTtsCrispAsr.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/ZonosTtsCrispAsr.cs @@ -32,12 +32,21 @@ namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; /// (eSpeak phonemisation), so unlike Qwen3 CustomVoice there is no .txt ref-text sidecar. /// This is a minimal engine: a single Q8_0 quant, no model dropdown, no per-engine settings /// dialog. Mirrors . +/// +/// Unlike most cloning engines, Zonos is NOT language-agnostic: the input text goes through +/// eSpeak G2P in whatever language the backend is told, and the model carries a language +/// conditioner on top. CrispASR defaults both to en-us when the request has no +/// language field, so a Czech line came out phonemised as English (#14433). The main +/// window's language combo () is passed as the server's startup +/// -l flag: the zonos backend reads the language once at init, so - exactly like the +/// voice - a language change restarts the server (verified against v0.8.31: the request body's +/// language / target_lang fields leave zonos on en-us). /// public class ZonosTtsCrispAsr : ITtsEngine { public string Name => "Zonos TTS (CrispASR)"; public string Description => "Zyphra Zonos-v0.1 with voice cloning, via CrispASR"; - public bool HasLanguageParameter => false; + public bool HasLanguageParameter => true; public bool HasApiKey => false; public bool HasRegion => false; public bool HasModel => false; @@ -94,6 +103,9 @@ public static bool IsValidLocalModelFile(string path, string fileName) // reference audio from the startup --voice flag, so a voice change requires us to tear // down and restart the server. Same as IndexTTS — see PR #11210 discussion. private static string? _serverVoicePath; + // The -l language the running server was started with (empty = backend default en-us). + // Same story as the voice: zonos reads it at init only, so a change restarts the server. + private static string _serverLanguage = string.Empty; private static bool _processExitHooked; private static readonly StringBuilder _serverLog = new(); @@ -205,30 +217,11 @@ private static void SeedVoicesFromQwen3TtsCppIfEmpty(string voicesFolder) foreach (var src in Directory.GetFiles(sourceFolder, "*.wav")) { var dest = Path.Combine(voicesFolder, Path.GetFileName(src)); - if (File.Exists(dest)) - { - continue; - } - - try - { - var ffmpeg = FfmpegGenerator.ConvertToMono24kHzWav(src, dest); - if (!ffmpeg.Start()) - { - // ffmpeg unavailable — better to seed at 16 kHz than skip the voice. - File.Copy(src, dest); - continue; - } - ffmpeg.WaitForExit(); - } - catch (Exception ex) - { - Se.LogError(ex, $"Zonos TTS (CrispASR): resample seed '{src}' failed; falling back to plain copy"); - try { if (!File.Exists(dest)) - { - File.Copy(src, dest); - } } catch { } - } + // Go through the shared helper like the other ten CrispASR engines. The + // hand-rolled loop here checked no exit code (so a failed ffmpeg left a truncated + // WAV that the File.Exists skip above made permanent), waited without a timeout, + // and leaked one Process per voice. + VoiceSeedHelper.CopyOrResample(src, dest, 24000, "Zonos TTS (CrispASR)"); } } catch (Exception ex) @@ -315,7 +308,11 @@ public Task GetVoices(string language) public Task GetModels() => Task.FromResult(Array.Empty()); - public Task GetLanguages(Voice voice, string? model) => Task.FromResult(Array.Empty()); + /// + /// The eSpeak voice codes from the model's own language table; English (US) leads as the + /// backend default. See . + /// + public Task GetLanguages(Voice voice, string? model) => Task.FromResult(ZonosLanguages.All); public Task RefreshVoices(string language, CancellationToken cancellationToken) => GetVoices(language); @@ -342,7 +339,12 @@ public async Task Speak( + "Reference WAV should be 24 kHz mono (a few seconds of clean speech)."); } - await EnsureServerRunningAsync(zonosVoice.FilePath, cancellationToken); + // The language to speak: picks the eSpeak G2P voice and the model's language + // conditioner. Nothing = the backend's en-us default, which is what every line got + // before #14433 - fine for English, English-phonemised gibberish for the rest. + var languageArg = ZonosLanguages.ResolveLanguageArg(language); + + await EnsureServerRunningAsync(zonosVoice.FilePath, languageArg, cancellationToken); var outputFileName = Path.Combine(TtsOutputFolder.Resolve(outputFolder, GetSetFolder), Guid.NewGuid() + ".wav"); var inputText = text; @@ -360,13 +362,21 @@ public async Task Speak( ["response_format"] = "wav", }; + // Also sent per request, which is CrispASR's documented TTS API for the language to + // speak. The v0.8.31 zonos backend does not act on it (only the startup -l above does), + // but a server that learns to will then agree with the flag rather than fight it. + if (!string.IsNullOrEmpty(languageArg)) + { + payload["language"] = languageArg; + } + // Attests the user's own imported reference and the AI-disclosure duty; see // CrispAsrTtsProvenance. Skipped when voice cloning has not been accepted in settings. CrispAsrTtsProvenance.AddSpeechAttestations(payload); var body = JsonSerializer.Serialize(payload); using var content = new StringContent(body, Encoding.UTF8, "application/json"); - Se.WriteToolsLog($"Zonos TTS (CrispASR): POST {ServerBaseUrl}/v1/audio/speech (voice={zonosVoice}, textLen={text.Length})"); + Se.WriteToolsLog($"Zonos TTS (CrispASR): POST {ServerBaseUrl}/v1/audio/speech (voice={zonosVoice}, textLen={text.Length}, language={(string.IsNullOrEmpty(languageArg) ? "(default en-us)" : languageArg)})"); HttpResponseMessage response; try @@ -457,14 +467,19 @@ private static async Task SafeReadErrorAsync(HttpResponseMessage respons } } - private static async Task EnsureServerRunningAsync(string voicePath, CancellationToken ct) + private static bool IsServerRunningWith(string voicePath, string languageArg) => + _serverProcess is { HasExited: false } && _serverPort != 0 + && string.Equals(_serverVoicePath, voicePath, StringComparison.OrdinalIgnoreCase) + && string.Equals(_serverLanguage, languageArg, StringComparison.OrdinalIgnoreCase); + + private static async Task EnsureServerRunningAsync(string voicePath, string languageArg, CancellationToken ct) { // CrispASR's TTS server backends don't honour the per-request `voice` field — they - // only read the reference audio from the --voice path passed at server startup. So we - // restart the server every time the selected voice changes. Tracked next to - // _serverProcess so an already-running server with the matching voice is reused. - if (_serverProcess is { HasExited: false } && _serverPort != 0 - && string.Equals(_serverVoicePath, voicePath, StringComparison.OrdinalIgnoreCase)) + // only read the reference audio from the --voice path passed at server startup. The + // zonos backend reads its language (-l) at init the same way. So we restart the server + // every time the selected voice or language changes. Tracked next to _serverProcess so + // an already-running server with the matching pair is reused. + if (IsServerRunningWith(voicePath, languageArg)) { return; } @@ -472,8 +487,7 @@ private static async Task EnsureServerRunningAsync(string voicePath, Cancellatio await ServerLock.WaitAsync(ct); try { - if (_serverProcess is { HasExited: false } && _serverPort != 0 - && string.Equals(_serverVoicePath, voicePath, StringComparison.OrdinalIgnoreCase)) + if (IsServerRunningWith(voicePath, languageArg)) { return; } @@ -537,6 +551,13 @@ private static async Task EnsureServerRunningAsync(string voicePath, Cancellatio // means the request's `voice` is ignored and the synth produces the wrong speaker. psi.ArgumentList.Add("--voice"); psi.ArgumentList.Add(voicePath); + // The language to speak, as the eSpeak code from the model's own table (see + // ZonosLanguages). Omitted = the backend's en-us default. The CLI lowercases it. + if (!string.IsNullOrEmpty(languageArg)) + { + psi.ArgumentList.Add("-l"); + psi.ArgumentList.Add(languageArg); + } CrispAsrTtsProvenance.AddServerMarkingArgs(psi.ArgumentList, exe); var process = Process.Start(psi) @@ -563,6 +584,7 @@ private static async Task EnsureServerRunningAsync(string voicePath, Cancellatio _serverProcess = process; _serverPort = port; _serverVoicePath = voicePath; + _serverLanguage = languageArg; HookProcessExitOnce(); // First-run auto-download (~1.8 GB total) needs a generous timeout. @@ -579,6 +601,7 @@ private static async Task EnsureServerRunningAsync(string voicePath, Cancellatio _serverPort = 0; _serverLaunchCommand = null; _serverVoicePath = null; + _serverLanguage = string.Empty; throw new InvalidOperationException( $"crispasr (zonos-tts) exited during startup (code {exitCode}). Output: {tail}" + LaunchCmdSuffix(exitedLaunchCommand)); @@ -665,6 +688,7 @@ private static void StopServerInternal() _serverPort = 0; _serverLaunchCommand = null; _serverVoicePath = null; + _serverLanguage = string.Empty; if (p == null) { return; diff --git a/src/ui/Features/Video/TextToSpeech/F5TtsCrispAsrSettings/F5TtsCrispAsrSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/F5TtsCrispAsrSettings/F5TtsCrispAsrSettingsWindow.cs index a68b2759a01..4ef832207e7 100644 --- a/src/ui/Features/Video/TextToSpeech/F5TtsCrispAsrSettings/F5TtsCrispAsrSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/F5TtsCrispAsrSettings/F5TtsCrispAsrSettingsWindow.cs @@ -33,7 +33,7 @@ public F5TtsCrispAsrSettingsWindow(F5TtsCrispAsrSettingsViewModel vm) Content = BuildContent(vm); var ok = UiUtil.MakeButtonOk(vm.OkCommand); - Activated += delegate { ok.Focus(); }; + UiUtil.FocusOnFirstActivation(this, ok); } private Border BuildContent(F5TtsCrispAsrSettingsViewModel vm) @@ -111,9 +111,8 @@ private static Border BuildDetails(F5TtsCrispAsrSettingsViewModel vm) grid.Add(MakeLabel(Se.Language.General.Engine), 0, 0); var enginePanel = MakeStatusPanel(nameof(vm.EngineBrush), nameof(vm.EngineLabel)); var engineButton = UiUtil.MakeButton(string.Empty, vm.RedownloadEngineCommand) - .WithIconLeft(IconNames.Download) + .WithIconLeftBindText(IconNames.Download, nameof(vm.EngineDownloadButtonText)) .WithMarginLeft(12); - engineButton.Bind(ContentControl.ContentProperty, new Binding(nameof(vm.EngineDownloadButtonText))); enginePanel.Children.Add(engineButton); grid.Add(enginePanel, 0, 1); diff --git a/src/ui/Features/Video/TextToSpeech/IndexTts25AudioCppSettings/IndexTts25AudioCppSettingsViewModel.cs b/src/ui/Features/Video/TextToSpeech/IndexTts25AudioCppSettings/IndexTts25AudioCppSettingsViewModel.cs index 61e03647c7f..85e1191de86 100644 --- a/src/ui/Features/Video/TextToSpeech/IndexTts25AudioCppSettings/IndexTts25AudioCppSettingsViewModel.cs +++ b/src/ui/Features/Video/TextToSpeech/IndexTts25AudioCppSettings/IndexTts25AudioCppSettingsViewModel.cs @@ -201,7 +201,7 @@ private async Task RedownloadEngine() return; } - await TtsVoiceInstaller.EnsureAudioCppForIndexTts25(Window, _windowService, forceRedownload: true); + await TtsVoiceInstaller.EnsureAudioCppRuntime(Window, _windowService, forceRedownload: true, "IndexTTS 2.5"); Refresh(); } diff --git a/src/ui/Features/Video/TextToSpeech/IndexTts25AudioCppSettings/IndexTts25AudioCppSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/IndexTts25AudioCppSettings/IndexTts25AudioCppSettingsWindow.cs index 95062444073..4818035e9ac 100644 --- a/src/ui/Features/Video/TextToSpeech/IndexTts25AudioCppSettings/IndexTts25AudioCppSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/IndexTts25AudioCppSettings/IndexTts25AudioCppSettingsWindow.cs @@ -33,7 +33,7 @@ public IndexTts25AudioCppSettingsWindow(IndexTts25AudioCppSettingsViewModel vm) Content = BuildContent(vm); var ok = UiUtil.MakeButtonOk(vm.OkCommand); - Activated += delegate { ok.Focus(); }; + UiUtil.FocusOnFirstActivation(this, ok); } private Border BuildContent(IndexTts25AudioCppSettingsViewModel vm) @@ -107,9 +107,8 @@ private static Border BuildDetails(IndexTts25AudioCppSettingsViewModel vm) grid.Add(MakeLabel(Se.Language.General.Engine), 0, 0); var enginePanel = MakeStatusPanel(nameof(vm.EngineBrush), nameof(vm.EngineLabel)); var engineButton = UiUtil.MakeButton(string.Empty, vm.RedownloadEngineCommand) - .WithIconLeft(IconNames.Download) + .WithIconLeftBindText(IconNames.Download, nameof(vm.EngineDownloadButtonText)) .WithMarginLeft(12); - engineButton.Bind(ContentControl.ContentProperty, new Binding(nameof(vm.EngineDownloadButtonText))); enginePanel.Children.Add(engineButton); grid.Add(enginePanel, 0, 1); diff --git a/src/ui/Features/Video/TextToSpeech/IndexTts25License/IndexTts25LicenseViewModel.cs b/src/ui/Features/Video/TextToSpeech/IndexTts25License/IndexTts25LicenseViewModel.cs index b528ef4a0d3..95c3f98f3fb 100644 --- a/src/ui/Features/Video/TextToSpeech/IndexTts25License/IndexTts25LicenseViewModel.cs +++ b/src/ui/Features/Video/TextToSpeech/IndexTts25License/IndexTts25LicenseViewModel.cs @@ -48,7 +48,11 @@ private void Accept() return; } + // AcceptLicense only assigns the settings field; persist it right away, as the + // voice-cloning consent dialog does - otherwise an acceptance given once is lost if the + // session ends before the next settings save and the user is prompted again. IndexTts25AudioCpp.AcceptLicense(); + Se.SaveSettings(); OkPressed = true; Window?.Close(); } diff --git a/src/ui/Features/Video/TextToSpeech/IndexTtsCrispAsrSettings/IndexTtsCrispAsrSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/IndexTtsCrispAsrSettings/IndexTtsCrispAsrSettingsWindow.cs index 4ce428251c2..9530077e516 100644 --- a/src/ui/Features/Video/TextToSpeech/IndexTtsCrispAsrSettings/IndexTtsCrispAsrSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/IndexTtsCrispAsrSettings/IndexTtsCrispAsrSettingsWindow.cs @@ -33,7 +33,7 @@ public IndexTtsCrispAsrSettingsWindow(IndexTtsCrispAsrSettingsViewModel vm) Content = BuildContent(vm); var ok = UiUtil.MakeButtonOk(vm.OkCommand); - Activated += delegate { ok.Focus(); }; + UiUtil.FocusOnFirstActivation(this, ok); } private Border BuildContent(IndexTtsCrispAsrSettingsViewModel vm) @@ -114,9 +114,8 @@ private static Border BuildDetails(IndexTtsCrispAsrSettingsViewModel vm) grid.Add(MakeLabel(Se.Language.General.Engine), 0, 0); var enginePanel = MakeStatusPanel(nameof(vm.EngineBrush), nameof(vm.EngineLabel)); var engineButton = UiUtil.MakeButton(string.Empty, vm.RedownloadEngineCommand) - .WithIconLeft(IconNames.Download) + .WithIconLeftBindText(IconNames.Download, nameof(vm.EngineDownloadButtonText)) .WithMarginLeft(12); - engineButton.Bind(ContentControl.ContentProperty, new Binding(nameof(vm.EngineDownloadButtonText))); enginePanel.Children.Add(engineButton); grid.Add(enginePanel, 0, 1); diff --git a/src/ui/Features/Video/TextToSpeech/KokoroTtsSettings/KokoroTtsSettingsViewModel.cs b/src/ui/Features/Video/TextToSpeech/KokoroTtsSettings/KokoroTtsSettingsViewModel.cs index 21eedce09e0..c38cd351413 100644 --- a/src/ui/Features/Video/TextToSpeech/KokoroTtsSettings/KokoroTtsSettingsViewModel.cs +++ b/src/ui/Features/Video/TextToSpeech/KokoroTtsSettings/KokoroTtsSettingsViewModel.cs @@ -97,7 +97,11 @@ private void ApplyStatus(DownloadHashManager.UpdateStatus status, bool installed StatusBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0x98, 0x00)); // amber break; default: - StatusLabel = Se.Language.General.UnknownNoInstallRecord; + // Installed, but nothing identifies the build: an install predating the + // .installed.sha256 sidecar, a manual install, or a build older than the ones + // SE has hashes for. It still works, so say "Installed" rather than something + // that reads as a failed install (#14057). + StatusLabel = Se.Language.General.Installed; StatusBrush = new SolidColorBrush(Color.FromRgb(0x9E, 0x9E, 0x9E)); // grey break; } diff --git a/src/ui/Features/Video/TextToSpeech/KokoroTtsSettings/KokoroTtsSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/KokoroTtsSettings/KokoroTtsSettingsWindow.cs index a74ed744749..8843cae75ad 100644 --- a/src/ui/Features/Video/TextToSpeech/KokoroTtsSettings/KokoroTtsSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/KokoroTtsSettings/KokoroTtsSettingsWindow.cs @@ -33,7 +33,7 @@ public KokoroTtsSettingsWindow(KokoroTtsSettingsViewModel vm) Content = BuildContent(vm); var ok = UiUtil.MakeButtonOk(vm.OkCommand); - Activated += delegate { ok.Focus(); }; + UiUtil.FocusOnFirstActivation(this, ok); } private Border BuildContent(KokoroTtsSettingsViewModel vm) diff --git a/src/ui/Features/Video/TextToSpeech/ModelLicense/ModelLicenseViewModel.cs b/src/ui/Features/Video/TextToSpeech/ModelLicense/ModelLicenseViewModel.cs new file mode 100644 index 00000000000..7f095e7b071 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/ModelLicense/ModelLicenseViewModel.cs @@ -0,0 +1,98 @@ +using Avalonia.Controls; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nikse.SubtitleEdit.Logic.Config; +using System; +using System.Diagnostics; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.ModelLicense; + +/// +/// Everything one engine's licence gate needs to say and do. The summary points are +/// deliberately a summary with a link to the full text rather than the whole agreement — +/// nobody reads a wall of legalese in a modal, and the points listed are the ones that bite. +/// +/// Window title, e.g. "Higgs Audio v3 - model license". +/// Headline inside the dialog. +/// One paragraph on why this dialog exists. +/// The terms that actually change what a user may do. +/// Full licence text. +/// The model's home page. +/// Checkbox label naming the licence being accepted. +/// Stamps the acceptance into settings (persisting is the VM's job). +public sealed record ModelLicenseDefinition( + string DialogTitle, + string Header, + string Intro, + string[] SummaryPoints, + string LicenseUrl, + string ModelPageUrl, + string AcceptCheckBoxText, + Action Accept); + +/// +/// First-run licence gate for model weights that carry their own terms. The audio.cpp engine +/// binaries are Apache-2.0 and need no acceptance, but several of the models SE offers on that +/// runtime (Higgs Audio v3, Fish Audio S2 Pro) are research / non-commercial licensed, which +/// puts real conditions on the user. So this is shown once, before the multi-GB download +/// starts, and the answer is remembered per licence version by the engine's own +/// IsLicenseAccepted / AcceptLicense pair — the same scheme +/// uses. +/// +public partial class ModelLicenseViewModel : ObservableObject +{ + [ObservableProperty] private bool _isAccepted; + + public ModelLicenseDefinition Definition { get; private set; } = + new(string.Empty, string.Empty, string.Empty, Array.Empty(), string.Empty, string.Empty, string.Empty, () => { }); + + public Window? Window { get; set; } + public bool OkPressed { get; private set; } + + public void Initialize(ModelLicenseDefinition definition) + { + Definition = definition; + } + + [RelayCommand] + private void Accept() + { + if (!IsAccepted) + { + return; + } + + // The definition's Accept only assigns the settings field; persist it right away, as + // the voice-cloning consent dialog does - otherwise an acceptance given once is lost if + // the session ends before the next settings save and the user is prompted again. + Definition.Accept(); + Se.SaveSettings(); + OkPressed = true; + Window?.Close(); + } + + [RelayCommand] + private void Cancel() + { + OkPressed = false; + Window?.Close(); + } + + [RelayCommand] + private void OpenLicense() => OpenUrl(Definition.LicenseUrl); + + [RelayCommand] + private void OpenModelPage() => OpenUrl(Definition.ModelPageUrl); + + private static void OpenUrl(string url) + { + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch (Exception ex) + { + Se.LogError(ex, $"Model license dialog: could not open {url}"); + } + } +} diff --git a/src/ui/Features/Video/TextToSpeech/ModelLicense/ModelLicenseWindow.cs b/src/ui/Features/Video/TextToSpeech/ModelLicense/ModelLicenseWindow.cs new file mode 100644 index 00000000000..068234df689 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/ModelLicense/ModelLicenseWindow.cs @@ -0,0 +1,156 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.ModelLicense; + +/// +/// Shown once before an engine's first model download when the weights carry their own +/// licence. The "accept" button stays disabled until the checkbox is ticked, so acceptance is +/// a deliberate act rather than a reflex click on a default-focused OK. The content comes from +/// a , which the caller hands to the view model's +/// Initialize before this constructor runs. +/// +public class ModelLicenseWindow : Window +{ + public ModelLicenseWindow(ModelLicenseViewModel vm) + { + UiUtil.InitializeWindow(this, GetType().Name); + Title = vm.Definition.DialogTitle; + // Explicit width rather than SizeToContent.WidthAndHeight: on macOS the latter makes + // text-heavy windows far too wide. + Width = 640; + SizeToContent = SizeToContent.Height; + CanResize = false; + + vm.Window = this; + DataContext = vm; + + Content = BuildContent(vm); + } + + private static Border BuildContent(ModelLicenseViewModel vm) + { + var stack = new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 14, + Children = + { + BuildHeader(vm), + BuildSummary(vm), + BuildLinks(vm), + BuildAcceptRow(vm), + BuildActions(vm), + }, + }; + + var outerGrid = new Grid { Margin = UiUtil.MakeWindowMargin() }; + outerGrid.Children.Add(stack); + + return new Border + { + Child = outerGrid, + Padding = new Thickness(4), + }; + } + + private static StackPanel BuildHeader(ModelLicenseViewModel vm) + { + var title = new TextBlock + { + Text = vm.Definition.Header, + FontSize = 18, + FontWeight = FontWeight.SemiBold, + TextWrapping = TextWrapping.Wrap, + }; + + var subtitle = new TextBlock + { + Text = vm.Definition.Intro, + FontSize = 12, + Opacity = 0.75, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 4, 0, 0), + }; + + return new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 0, + Children = { title, subtitle }, + }; + } + + private static Border BuildSummary(ModelLicenseViewModel vm) + { + var stack = new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 8, + }; + + foreach (var point in vm.Definition.SummaryPoints) + { + var row = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + Children = + { + new TextBlock { Text = "•", Opacity = 0.7, VerticalAlignment = VerticalAlignment.Top }, + new TextBlock { Text = point, TextWrapping = TextWrapping.Wrap, MaxWidth = 560 }, + }, + }; + stack.Children.Add(row); + } + + return new Border + { + Child = stack, + Padding = new Thickness(12), + CornerRadius = new CornerRadius(4), + BorderThickness = new Thickness(1), + BorderBrush = new SolidColorBrush(Color.FromArgb(60, 128, 128, 128)), + }; + } + + private static StackPanel BuildLinks(ModelLicenseViewModel vm) + { + return new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 16, + Children = + { + UiUtil.MakeLink(Se.Language.Video.IndexTts25LicenseReadFullText, vm.OpenLicenseCommand), + UiUtil.MakeLink(Se.Language.Video.IndexTts25LicenseModelPage, vm.OpenModelPageCommand), + }, + }; + } + + private static CheckBox BuildAcceptRow(ModelLicenseViewModel vm) + { + var checkBox = UiUtil.MakeCheckBox( + vm.Definition.AcceptCheckBoxText, + vm, + nameof(vm.IsAccepted)); + return checkBox; + } + + private static Control BuildActions(ModelLicenseViewModel vm) + { + var accept = UiUtil.MakeButton(Se.Language.Video.IndexTts25LicenseAcceptAndDownload, vm.AcceptCommand); + // Bound to the checkbox so the download cannot start on an unread licence. + accept.Bind( + Button.IsEnabledProperty, + new Avalonia.Data.Binding(nameof(vm.IsAccepted))); + + var cancel = UiUtil.MakeButtonCancel(vm.CancelCommand); + + return UiUtil.MakeButtonBar(accept, cancel); + } +} diff --git a/src/ui/Features/Video/TextToSpeech/MossTtsCrispAsrSettings/MossTtsCrispAsrSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/MossTtsCrispAsrSettings/MossTtsCrispAsrSettingsWindow.cs index 217dff07c4b..5045addc59f 100644 --- a/src/ui/Features/Video/TextToSpeech/MossTtsCrispAsrSettings/MossTtsCrispAsrSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/MossTtsCrispAsrSettings/MossTtsCrispAsrSettingsWindow.cs @@ -33,7 +33,7 @@ public MossTtsCrispAsrSettingsWindow(MossTtsCrispAsrSettingsViewModel vm) Content = BuildContent(vm); var ok = UiUtil.MakeButtonOk(vm.OkCommand); - Activated += delegate { ok.Focus(); }; + UiUtil.FocusOnFirstActivation(this, ok); } private Border BuildContent(MossTtsCrispAsrSettingsViewModel vm) @@ -114,9 +114,8 @@ private static Border BuildDetails(MossTtsCrispAsrSettingsViewModel vm) grid.Add(MakeLabel(Se.Language.General.Engine), 0, 0); var enginePanel = MakeStatusPanel(nameof(vm.EngineBrush), nameof(vm.EngineLabel)); var engineButton = UiUtil.MakeButton(string.Empty, vm.RedownloadEngineCommand) - .WithIconLeft(IconNames.Download) + .WithIconLeftBindText(IconNames.Download, nameof(vm.EngineDownloadButtonText)) .WithMarginLeft(12); - engineButton.Bind(ContentControl.ContentProperty, new Binding(nameof(vm.EngineDownloadButtonText))); enginePanel.Children.Add(engineButton); grid.Add(enginePanel, 0, 1); diff --git a/src/ui/Features/Video/TextToSpeech/OmniVoiceCrispAsrSettings/OmniVoiceCrispAsrSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/OmniVoiceCrispAsrSettings/OmniVoiceCrispAsrSettingsWindow.cs index 16b7fd9d6fa..a760a2453ec 100644 --- a/src/ui/Features/Video/TextToSpeech/OmniVoiceCrispAsrSettings/OmniVoiceCrispAsrSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/OmniVoiceCrispAsrSettings/OmniVoiceCrispAsrSettingsWindow.cs @@ -35,7 +35,7 @@ public OmniVoiceCrispAsrSettingsWindow(OmniVoiceCrispAsrSettingsViewModel vm) Content = BuildContent(vm); var ok = UiUtil.MakeButtonOk(vm.OkCommand); - Activated += delegate { ok.Focus(); }; + UiUtil.FocusOnFirstActivation(this, ok); } private Border BuildContent(OmniVoiceCrispAsrSettingsViewModel vm) @@ -115,9 +115,8 @@ private static Border BuildDetails(OmniVoiceCrispAsrSettingsViewModel vm) grid.Add(MakeLabel(Se.Language.General.Engine), 0, 0); var enginePanel = MakeStatusPanel(nameof(vm.EngineBrush), nameof(vm.EngineLabel)); var engineButton = UiUtil.MakeButton(string.Empty, vm.RedownloadEngineCommand) - .WithIconLeft(IconNames.Download) + .WithIconLeftBindText(IconNames.Download, nameof(vm.EngineDownloadButtonText)) .WithMarginLeft(12); - engineButton.Bind(ContentControl.ContentProperty, new Binding(nameof(vm.EngineDownloadButtonText))); enginePanel.Children.Add(engineButton); grid.Add(enginePanel, 0, 1); diff --git a/src/ui/Features/Video/TextToSpeech/OmniVoiceSettings/OmniVoiceSettingsViewModel.cs b/src/ui/Features/Video/TextToSpeech/OmniVoiceSettings/OmniVoiceSettingsViewModel.cs index 3b4e89558cb..643db1cb4b0 100644 --- a/src/ui/Features/Video/TextToSpeech/OmniVoiceSettings/OmniVoiceSettingsViewModel.cs +++ b/src/ui/Features/Video/TextToSpeech/OmniVoiceSettings/OmniVoiceSettingsViewModel.cs @@ -98,7 +98,11 @@ private void ApplyStatus(DownloadHashManager.UpdateStatus status, bool installed StatusBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0x98, 0x00)); // amber break; default: - StatusLabel = Se.Language.General.UnknownNoInstallRecord; + // Installed, but nothing identifies the build: an install predating the + // .installed.sha256 sidecar, a manual install, or a build older than the ones + // SE has hashes for. It still works, so say "Installed" rather than something + // that reads as a failed install (#14057). + StatusLabel = Se.Language.General.Installed; StatusBrush = new SolidColorBrush(Color.FromRgb(0x9E, 0x9E, 0x9E)); // grey break; } diff --git a/src/ui/Features/Video/TextToSpeech/OmniVoiceSettings/OmniVoiceSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/OmniVoiceSettings/OmniVoiceSettingsWindow.cs index aff7cd7191c..cfb2e764e85 100644 --- a/src/ui/Features/Video/TextToSpeech/OmniVoiceSettings/OmniVoiceSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/OmniVoiceSettings/OmniVoiceSettingsWindow.cs @@ -33,7 +33,7 @@ public OmniVoiceSettingsWindow(OmniVoiceSettingsViewModel vm) Content = BuildContent(vm); var ok = UiUtil.MakeButtonOk(vm.OkCommand); - Activated += delegate { ok.Focus(); }; // ensure OnKeyDown fires + UiUtil.FocusOnFirstActivation(this, ok); // ensure OnKeyDown fires } private Border BuildContent(OmniVoiceSettingsViewModel vm) diff --git a/src/ui/Features/Video/TextToSpeech/PerLineVoiceClone.cs b/src/ui/Features/Video/TextToSpeech/PerLineVoiceClone.cs index e27f4ef004e..04fe9aa8c05 100644 --- a/src/ui/Features/Video/TextToSpeech/PerLineVoiceClone.cs +++ b/src/ui/Features/Video/TextToSpeech/PerLineVoiceClone.cs @@ -5,6 +5,7 @@ using Nikse.SubtitleEdit.Logic.Media; using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; @@ -255,22 +256,88 @@ internal static (double StartSeconds, double DurationSeconds) GetReferenceRange( return (start, Math.Max(0.1, end - start)); } + /// + /// Every engine that knows how to clone per line. Taken from the shared catalog rather than + /// listed here, so an engine added there (implementing ) is + /// covered by for free. + /// + private static readonly Lazy CloneEngines = new(() => + TtsEngineCatalog.CreateVoiceCloningEngines().OfType().ToArray()); + /// /// Wraps a cut clip as a voice understands. /// /// - /// The engine voice types share no interface, so each engine that opts into per-line cloning - /// is named here. Returning null means the engine says it supports this but nobody taught this - /// method how to build its voice - the caller then falls back to the ordinary voice rather - /// than silently synthesising with the wrong speaker. + /// The per-engine knowledge lives on the engine itself, behind + /// . Returning null means the engine could not use the clip + /// as a reference (e.g. no transcript beside it) - the caller then falls back to the ordinary + /// voice rather than silently synthesising with the wrong speaker. An engine that sets + /// without implementing the interface is + /// a wiring bug, not a fallback case, and asserts in debug builds. + /// + /// + /// What to call the voice, for the rows and lists that show it. Defaults to the clip's file + /// name; an imported session passes the name the clip had when it was exported, so a line + /// keeps the voice name it was generated with instead of picking up the exported clip's. + /// + public static Voice? MakeVoiceForClip(ITtsEngine engine, string clipFileName, string? voiceName = null) + { + if (engine is not IPerLineCloneEngine cloneEngine) + { + // The capability flag promises per-line cloning; the interface is how it is + // delivered. One without the other would offer "Clone from video" and then dub + // every line in the fallback voice. + Debug.Assert(!engine.SupportsPerLineVoiceCloning, + $"{engine.Name} sets {nameof(ITtsEngine.SupportsPerLineVoiceCloning)} but does not implement {nameof(IPerLineCloneEngine)}"); + return null; + } + + var name = string.IsNullOrEmpty(voiceName) ? Path.GetFileNameWithoutExtension(clipFileName) : voiceName; + return cloneEngine.MakePerLineCloneVoice(clipFileName, name); + } + + /// + /// The recording clones from, or null when it clones from nothing - + /// or when no engine could be handed it back. + /// + /// + /// Asks every whether the voice is its own; each engine + /// keeps and + /// in step in one place. The + /// callers are export (which copies the recording so the session can be re-imported + /// elsewhere) and regenerate (which reuses it), and a reference no engine can be handed back + /// is worth neither. /// - public static Voice? MakeVoiceForClip(ITtsEngine engine, string clipFileName) + public static string? TryGetReferenceClip(Voice? voice) { - var name = Path.GetFileNameWithoutExtension(clipFileName); - return engine switch + if (voice == null) { - OmniVoiceTtsCpp => new Voice(new OmniVoice(name, clipFileName)), - _ => null, - }; + return null; + } + + foreach (var engine in CloneEngines.Value) + { + if (engine.GetPerLineReferenceClip(voice) is { } clip) + { + return clip; + } + } + + return null; } + + /// + /// Clears what a previous run staged inside an engine's own folders, and is called as a + /// per-line run starts. + /// + /// + /// Engines that can only read a reference from their voices folder keep a copy of every + /// line's clip there (see ). Cutting + /// the clips into a fresh folder each run - which the caller does - says nothing about those + /// copies, so a run over a shorter subtitle than the last would leave the previous run's + /// extra lines behind. Engines that take the clip's own path stage nothing and implement + /// this as a no-op. + /// + public static void ResetStagedReferences(ITtsEngine? engine) => + (engine as IPerLineCloneEngine)?.ResetStagedPerLineReferences(); } diff --git a/src/ui/Features/Video/TextToSpeech/PiperSettings/PiperSettingsViewModel.cs b/src/ui/Features/Video/TextToSpeech/PiperSettings/PiperSettingsViewModel.cs index 289e7c00447..22667d1208d 100644 --- a/src/ui/Features/Video/TextToSpeech/PiperSettings/PiperSettingsViewModel.cs +++ b/src/ui/Features/Video/TextToSpeech/PiperSettings/PiperSettingsViewModel.cs @@ -100,7 +100,11 @@ private void ApplyStatus(DownloadHashManager.UpdateStatus status, bool installed StatusBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0x98, 0x00)); // amber break; default: - StatusLabel = Se.Language.General.UnknownNoInstallRecord; + // Installed, but nothing identifies the build: an install predating the + // .installed.sha256 sidecar, a manual install, or a build older than the ones + // SE has hashes for. It still works, so say "Installed" rather than something + // that reads as a failed install (#14057). + StatusLabel = Se.Language.General.Installed; StatusBrush = new SolidColorBrush(Color.FromRgb(0x9E, 0x9E, 0x9E)); // grey break; } diff --git a/src/ui/Features/Video/TextToSpeech/PiperSettings/PiperSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/PiperSettings/PiperSettingsWindow.cs index 86d71bff471..d502cd47feb 100644 --- a/src/ui/Features/Video/TextToSpeech/PiperSettings/PiperSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/PiperSettings/PiperSettingsWindow.cs @@ -33,7 +33,7 @@ public PiperSettingsWindow(PiperSettingsViewModel vm) Content = BuildContent(vm); var ok = UiUtil.MakeButtonOk(vm.OkCommand); - Activated += delegate { ok.Focus(); }; + UiUtil.FocusOnFirstActivation(this, ok); } private Border BuildContent(PiperSettingsViewModel vm) diff --git a/src/ui/Features/Video/TextToSpeech/PocketTtsCrispAsrSettings/PocketTtsCrispAsrSettingsViewModel.cs b/src/ui/Features/Video/TextToSpeech/PocketTtsCrispAsrSettings/PocketTtsCrispAsrSettingsViewModel.cs new file mode 100644 index 00000000000..50b33172413 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/PocketTtsCrispAsrSettings/PocketTtsCrispAsrSettingsViewModel.cs @@ -0,0 +1,274 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nikse.SubtitleEdit.Features.Shared; +using Nikse.SubtitleEdit.Features.Video.SpeechToText.Engines; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Download; +using Nikse.SubtitleEdit.Logic.Media; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.PocketTtsCrispAsrSettings; + +public partial class PocketTtsCrispAsrSettingsViewModel : ObservableObject +{ + // A SolidColorBrush set as Shape.Fill is parented into that shape's visual tree, so the + // same instance can't be shared across multiple bound Ellipses (only one dot would render). + // Construct a fresh brush per assignment instead. + private static IBrush Green() => new SolidColorBrush(Color.FromRgb(0x4C, 0xAF, 0x50)); + private static IBrush Amber() => new SolidColorBrush(Color.FromRgb(0xFF, 0x98, 0x00)); + private static IBrush Red() => new SolidColorBrush(Color.FromRgb(0xF4, 0x43, 0x36)); + private static IBrush Grey() => new SolidColorBrush(Color.FromRgb(0x9E, 0x9E, 0x9E)); + + private readonly IWindowService _windowService; + private readonly IFolderHelper _folderHelper; + + [ObservableProperty] private string _engineLabel = string.Empty; + [ObservableProperty] private IBrush _engineBrush = Grey(); + [ObservableProperty] private string _engineDownloadButtonText = string.Empty; + + [ObservableProperty] private string _englishF16Label = string.Empty; + [ObservableProperty] private IBrush _englishF16Brush = Grey(); + + [ObservableProperty] private string _englishQ8_0Label = string.Empty; + [ObservableProperty] private IBrush _englishQ8_0Brush = Grey(); + + [ObservableProperty] private string _germanLabel = string.Empty; + [ObservableProperty] private IBrush _germanBrush = Grey(); + + [ObservableProperty] private string _spanishLabel = string.Empty; + [ObservableProperty] private IBrush _spanishBrush = Grey(); + + [ObservableProperty] private string _italianLabel = string.Empty; + [ObservableProperty] private IBrush _italianBrush = Grey(); + + [ObservableProperty] private string _portugueseLabel = string.Empty; + [ObservableProperty] private IBrush _portugueseBrush = Grey(); + + [ObservableProperty] private string _frenchLabel = string.Empty; + [ObservableProperty] private IBrush _frenchBrush = Grey(); + + [ObservableProperty] private string _voicesLabel = string.Empty; + + [ObservableProperty] private double _speed = 1.0; + + public string SpeedLabel => $"Speed {Speed:0.00}x"; + + partial void OnSpeedChanged(double value) + { + OnPropertyChanged(nameof(SpeedLabel)); + Se.Settings.Video.TextToSpeech.PocketTtsCrispAsrSpeed = Math.Clamp(value, 0.25, 4.0); + } + + [ObservableProperty] private string _modelsFolder = string.Empty; + [ObservableProperty] private string _voicesFolder = string.Empty; + [ObservableProperty] private bool _isEngineInstalled; + + public Window? Window { get; set; } + public bool OkPressed { get; private set; } + + public PocketTtsCrispAsrSettingsViewModel(IWindowService windowService, IFolderHelper folderHelper) + { + _windowService = windowService; + _folderHelper = folderHelper; + } + + public void Initialize() + { + ModelsFolder = PocketTtsCrispAsr.GetSetModelsFolder(); + VoicesFolder = PocketTtsCrispAsr.GetSetVoicesFolder(); + Speed = Math.Clamp(Se.Settings.Video.TextToSpeech.PocketTtsCrispAsrSpeed, 0.25, 4.0); + Refresh(); + } + + private void Refresh() + { + var exe = PocketTtsCrispAsr.GetCrispAsrExecutable(); + IsEngineInstalled = File.Exists(exe); + + if (!IsEngineInstalled) + { + EngineLabel = string.Format(Se.Language.Video.TtsEngineNotInstalled, "CrispASR"); + EngineBrush = Red(); + EngineDownloadButtonText = string.Format(Se.Language.General.DownloadX, "CrispASR"); + } + else if (PocketTtsCrispAsr.GetEngineUpdateStatus() == DownloadHashManager.UpdateStatus.UpdateAvailable) + { + EngineLabel = string.Format(Se.Language.Video.TtsEngineUpdateAvailable, "CrispASR"); + EngineBrush = Amber(); + EngineDownloadButtonText = string.Format(Se.Language.Video.TtsUpdateX, "CrispASR"); + } + else + { + EngineLabel = "CrispASR"; + EngineBrush = Green(); + EngineDownloadButtonText = string.Format(Se.Language.General.ReDownloadX, "CrispASR"); + } + + // Append the installed CrispASR version asynchronously - the probe is a child + // process and we don't want to block the dialog opening (the probe is cached + // after the first call but the first one can be a few hundred ms). + if (IsEngineInstalled) + { + var baseLabel = EngineLabel; + _ = Task.Run(() => + { + try + { + var version = CrispAsrVersion.TryGet(exe); + if (string.IsNullOrEmpty(version)) + { + return; + } + Dispatcher.UIThread.Post(() => + { + if (EngineLabel == baseLabel) + { + EngineLabel = baseLabel.Replace("CrispASR", $"CrispASR v{version}"); + } + }); + } + catch (Exception ex) + { + Se.LogError(ex, "PocketTtsCrispAsrSettings: CrispASR version probe failed"); + } + }); + } + + // One GGUF per language, in the engine's models folder OR auto-downloaded by crispasr + // into ~/.cache/crispasr. We can only verify the engine-folder copy here; when CrispASR + // is installed a missing local file is fine ("Auto-download on first use"), and when it + // is NOT installed that message would promise a download that can't happen yet. + ApplyStatus(PocketTtsCrispAsr.ModelKeyEnglishF16, PocketTtsCrispAsr.EnglishF16FileName, + label => EnglishF16Label = label, brush => EnglishF16Brush = brush); + ApplyStatus(PocketTtsCrispAsr.ModelKeyEnglishQ8_0, PocketTtsCrispAsr.EnglishQ8_0FileName, + label => EnglishQ8_0Label = label, brush => EnglishQ8_0Brush = brush); + ApplyStatus(PocketTtsCrispAsr.ModelKeyGermanQ8_0, PocketTtsCrispAsr.GermanQ8_0FileName, + label => GermanLabel = label, brush => GermanBrush = brush); + ApplyStatus(PocketTtsCrispAsr.ModelKeySpanishQ8_0, PocketTtsCrispAsr.SpanishQ8_0FileName, + label => SpanishLabel = label, brush => SpanishBrush = brush); + ApplyStatus(PocketTtsCrispAsr.ModelKeyItalianQ8_0, PocketTtsCrispAsr.ItalianQ8_0FileName, + label => ItalianLabel = label, brush => ItalianBrush = brush); + ApplyStatus(PocketTtsCrispAsr.ModelKeyPortugueseQ8_0, PocketTtsCrispAsr.PortugueseQ8_0FileName, + label => PortugueseLabel = label, brush => PortugueseBrush = brush); + ApplyStatus(PocketTtsCrispAsr.ModelKeyFrenchQ8_0, PocketTtsCrispAsr.FrenchQ8_0FileName, + label => FrenchLabel = label, brush => FrenchBrush = brush); + + try + { + var wavCount = Directory.Exists(VoicesFolder) + ? Directory.GetFiles(VoicesFolder, "*.wav").Length + : 0; + VoicesLabel = wavCount == 0 + ? "No voices imported" + : (wavCount == 1 ? "1 voice imported" : $"{wavCount} voices imported"); + } + catch + { + VoicesLabel = string.Empty; + } + } + + private void ApplyStatus(string modelKey, string fileName, Action setLabel, Action setBrush) + { + var path = PocketTtsCrispAsr.GetModelPath(modelKey); + if (PocketTtsCrispAsr.IsValidLocalModelFile(path, fileName)) + { + setLabel("Installed"); + setBrush(Green()); + return; + } + + if (!IsEngineInstalled) + { + // No CrispASR runtime means there's nothing to auto-download into, so don't + // promise a download that can't happen until the user installs CrispASR. + setLabel("CrispASR required"); + setBrush(Grey()); + return; + } + + setLabel("Auto-download on first use"); + setBrush(Grey()); + } + + [RelayCommand] + private async Task RedownloadEngine() + { + if (Window == null) + { + return; + } + + // CrispASR is shared across all CrispASR-driven TTS engines; we use the Pocket TTS + // entry point so prompts read "Pocket TTS (CrispASR)" rather than another engine's name + // (the crispasr binary itself doesn't care which engine triggered the download). + await TtsVoiceInstaller.EnsureCrispAsrForPocketTts(Window, _windowService, forceRedownload: true); + Refresh(); + } + + [RelayCommand] + private async Task OpenModelsFolder() + { + if (Window == null || string.IsNullOrEmpty(ModelsFolder)) + { + return; + } + + try + { + await _folderHelper.OpenFolder(Window, ModelsFolder); + } + catch + { + // best-effort UX + } + } + + [RelayCommand] + private async Task OpenVoicesFolder() + { + if (Window == null || string.IsNullOrEmpty(VoicesFolder)) + { + return; + } + + try + { + await _folderHelper.OpenFolder(Window, VoicesFolder); + } + catch + { + // best-effort UX + } + } + + [RelayCommand] + private void OpenLicensePage() + { + UiUtil.OpenUrl("https://huggingface.co/kyutai/pocket-tts"); + } + + [RelayCommand] + private void Ok() + { + OkPressed = true; + Window?.Close(); + } + + internal void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + e.Handled = true; + Window?.Close(); + } + } +} diff --git a/src/ui/Features/Video/TextToSpeech/PocketTtsCrispAsrSettings/PocketTtsCrispAsrSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/PocketTtsCrispAsrSettings/PocketTtsCrispAsrSettingsWindow.cs new file mode 100644 index 00000000000..72818985d02 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/PocketTtsCrispAsrSettings/PocketTtsCrispAsrSettingsWindow.cs @@ -0,0 +1,287 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.PocketTtsCrispAsrSettings; + +public class PocketTtsCrispAsrSettingsWindow : Window +{ + private const int LabelWidth = 170; + private const int ValueWidth = 360; + + private readonly PocketTtsCrispAsrSettingsViewModel _vm; + + public PocketTtsCrispAsrSettingsWindow(PocketTtsCrispAsrSettingsViewModel vm) + { + UiUtil.InitializeWindow(this, GetType().Name); + Title = string.Format(Se.Language.Video.TtsCrispAsrSettingsTitle, "Pocket TTS"); + SizeToContent = SizeToContent.WidthAndHeight; + CanResize = false; + MinWidth = 580; + + _vm = vm; + vm.Window = this; + DataContext = vm; + + Content = BuildContent(vm); + + var ok = UiUtil.MakeButtonOk(vm.OkCommand); + UiUtil.FocusOnFirstActivation(this, ok); + } + + private Border BuildContent(PocketTtsCrispAsrSettingsViewModel vm) + { + var header = BuildHeader(vm); + var details = BuildDetails(vm); + var actions = BuildActions(vm); + + var stack = new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 14, + Children = { header, details, actions }, + }; + + var outerGrid = new Grid + { + Margin = UiUtil.MakeWindowMargin(), + }; + outerGrid.Children.Add(stack); + + return new Border + { + Child = outerGrid, + Padding = new Thickness(4), + }; + } + + private static StackPanel BuildHeader(PocketTtsCrispAsrSettingsViewModel vm) + { + var title = new TextBlock + { + Text = "Pocket TTS (CrispASR)", + FontSize = 18, + FontWeight = FontWeight.SemiBold, + }; + + var subtitle = new TextBlock + { + Text = new PocketTtsCrispAsr().Description, + FontSize = 12, + Opacity = 0.75, + Margin = new Thickness(0, 2, 0, 0), + }; + + // The weights are CC-BY-4.0 with Kyutai's additional gated-use conditions; surface that + // (with a link to the model page) rather than leaving it buried in the download source. + var license = UiUtil.MakeLink("License: CC-BY-4.0 + Kyutai gated-use conditions", vm.OpenLicensePageCommand); + license.Margin = new Thickness(0, 2, 0, 0); + license.FontSize = 12; + + return new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 0, + Children = { title, subtitle, license }, + }; + } + + private static Border BuildDetails(PocketTtsCrispAsrSettingsViewModel vm) + { + var grid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(LabelWidth, GridUnitType.Pixel) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + }, + RowDefinitions = + { + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + }, + ColumnSpacing = 12, + RowSpacing = 10, + }; + + grid.Add(MakeLabel(Se.Language.General.Engine), 0, 0); + var enginePanel = MakeStatusPanel(nameof(vm.EngineBrush), nameof(vm.EngineLabel)); + var engineButton = UiUtil.MakeButton(string.Empty, vm.RedownloadEngineCommand) + .WithIconLeftBindText(IconNames.Download, nameof(vm.EngineDownloadButtonText)) + .WithMarginLeft(12); + enginePanel.Children.Add(engineButton); + grid.Add(enginePanel, 0, 1); + + grid.Add(MakeLabel(PocketTtsCrispAsr.ModelKeyEnglishF16), 1, 0); + grid.Add(MakeStatusPanel(nameof(vm.EnglishF16Brush), nameof(vm.EnglishF16Label)), 1, 1); + + grid.Add(MakeLabel(PocketTtsCrispAsr.ModelKeyEnglishQ8_0), 2, 0); + grid.Add(MakeStatusPanel(nameof(vm.EnglishQ8_0Brush), nameof(vm.EnglishQ8_0Label)), 2, 1); + + grid.Add(MakeLabel(PocketTtsCrispAsr.ModelKeyGermanQ8_0), 3, 0); + grid.Add(MakeStatusPanel(nameof(vm.GermanBrush), nameof(vm.GermanLabel)), 3, 1); + + grid.Add(MakeLabel(PocketTtsCrispAsr.ModelKeySpanishQ8_0), 4, 0); + grid.Add(MakeStatusPanel(nameof(vm.SpanishBrush), nameof(vm.SpanishLabel)), 4, 1); + + grid.Add(MakeLabel(PocketTtsCrispAsr.ModelKeyItalianQ8_0), 5, 0); + grid.Add(MakeStatusPanel(nameof(vm.ItalianBrush), nameof(vm.ItalianLabel)), 5, 1); + + grid.Add(MakeLabel(PocketTtsCrispAsr.ModelKeyPortugueseQ8_0), 6, 0); + grid.Add(MakeStatusPanel(nameof(vm.PortugueseBrush), nameof(vm.PortugueseLabel)), 6, 1); + + grid.Add(MakeLabel(PocketTtsCrispAsr.ModelKeyFrenchQ8_0), 7, 0); + grid.Add(MakeStatusPanel(nameof(vm.FrenchBrush), nameof(vm.FrenchLabel)), 7, 1); + + grid.Add(MakeLabel(Se.Language.Video.Voices), 8, 0); + var voicesText = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + FontWeight = FontWeight.SemiBold, + [!TextBlock.TextProperty] = new Binding(nameof(vm.VoicesLabel)), + }; + grid.Add(voicesText, 8, 1); + + grid.Add(MakeLabel(Se.Language.General.Speed), 9, 0); + grid.Add(MakeSpeedPanel(), 9, 1); + + grid.Add(MakeLabel(Se.Language.General.InstallFolder), 10, 0); + var folderText = new TextBox + { + IsReadOnly = true, + Width = ValueWidth, + BorderThickness = new Thickness(0), + Background = Brushes.Transparent, + Padding = new Thickness(0), + VerticalContentAlignment = VerticalAlignment.Center, + FontSize = 12, + [!TextBox.TextProperty] = new Binding(nameof(vm.ModelsFolder)), + }; + grid.Add(folderText, 10, 1); + + return new Border + { + Child = grid, + Padding = new Thickness(14), + CornerRadius = new CornerRadius(6), + BorderThickness = new Thickness(1), + BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0x80, 0x80, 0x80)), + }; + } + + private static StackPanel MakeSpeedPanel() + { + // Server accepts 0.25-4.0; cap UI at 0.5-2.0 since anything outside that range is + // already a hack rather than a "preferred pace" choice. + var slider = new Slider + { + Minimum = 0.5, + Maximum = 2.0, + Width = 220, + TickFrequency = 0.05, + IsSnapToTickEnabled = true, + VerticalAlignment = VerticalAlignment.Center, + [!Slider.ValueProperty] = new Binding(nameof(PocketTtsCrispAsrSettingsViewModel.Speed)), + }; + var label = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + FontWeight = FontWeight.SemiBold, + Margin = new Thickness(8, 0, 0, 0), + Width = 80, + [!TextBlock.TextProperty] = new Binding(nameof(PocketTtsCrispAsrSettingsViewModel.SpeedLabel)), + }; + return new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { slider, label }, + }; + } + + private static StackPanel MakeStatusPanel(string brushBindingPath, string labelBindingPath) + { + var dot = new Ellipse + { + Width = 10, + Height = 10, + VerticalAlignment = VerticalAlignment.Center, + [!Ellipse.FillProperty] = new Binding(brushBindingPath), + }; + var text = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + FontWeight = FontWeight.SemiBold, + Margin = new Thickness(8, 0, 0, 0), + [!TextBlock.TextProperty] = new Binding(labelBindingPath), + }; + return new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { dot, text }, + }; + } + + private static Grid BuildActions(PocketTtsCrispAsrSettingsViewModel vm) + { + var openModelsFolder = UiUtil.MakeButton(Se.Language.General.OpenContainingFolder, vm.OpenModelsFolderCommand).WithIconLeft(IconNames.FolderOpen); + var openVoicesFolder = UiUtil.MakeButton("Voices folder", vm.OpenVoicesFolderCommand).WithIconLeft(IconNames.FolderOpen); + var close = UiUtil.MakeButton(Se.Language.General.Close, vm.OkCommand); + + var leftPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + Children = { openModelsFolder, openVoicesFolder }, + }; + + var rightPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8, + Children = { close }, + }; + + var grid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }, + }, + }; + grid.Add(leftPanel, 0, 0); + grid.Add(rightPanel, 0, 2); + return grid; + } + + private static TextBlock MakeLabel(string text) => new() + { + Text = text, + Opacity = 0.7, + VerticalAlignment = VerticalAlignment.Center, + }; + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + _vm.OnKeyDown(e); + } +} diff --git a/src/ui/Features/Video/TextToSpeech/Qwen3TtsCrispAsrSettings/Qwen3TtsCrispAsrSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/Qwen3TtsCrispAsrSettings/Qwen3TtsCrispAsrSettingsWindow.cs index 2c0d83c9bf9..faba0dcbb04 100644 --- a/src/ui/Features/Video/TextToSpeech/Qwen3TtsCrispAsrSettings/Qwen3TtsCrispAsrSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/Qwen3TtsCrispAsrSettings/Qwen3TtsCrispAsrSettingsWindow.cs @@ -33,7 +33,7 @@ public Qwen3TtsCrispAsrSettingsWindow(Qwen3TtsCrispAsrSettingsViewModel vm) Content = BuildContent(vm); var ok = UiUtil.MakeButtonOk(vm.OkCommand); - Activated += delegate { ok.Focus(); }; + UiUtil.FocusOnFirstActivation(this, ok); } private Border BuildContent(Qwen3TtsCrispAsrSettingsViewModel vm) @@ -113,9 +113,8 @@ private static Border BuildDetails(Qwen3TtsCrispAsrSettingsViewModel vm) grid.Add(MakeLabel(Se.Language.General.Engine), 0, 0); var enginePanel = MakeStatusPanel(nameof(vm.EngineBrush), nameof(vm.EngineLabel)); var engineButton = UiUtil.MakeButton(string.Empty, vm.RedownloadEngineCommand) - .WithIconLeft(IconNames.Download) + .WithIconLeftBindText(IconNames.Download, nameof(vm.EngineDownloadButtonText)) .WithMarginLeft(12); - engineButton.Bind(ContentControl.ContentProperty, new Binding(nameof(vm.EngineDownloadButtonText))); enginePanel.Children.Add(engineButton); grid.Add(enginePanel, 0, 1); diff --git a/src/ui/Features/Video/TextToSpeech/Qwen3TtsSettings/Qwen3TtsSettingsViewModel.cs b/src/ui/Features/Video/TextToSpeech/Qwen3TtsSettings/Qwen3TtsSettingsViewModel.cs index c0c97b05d1e..4d4618ed06f 100644 --- a/src/ui/Features/Video/TextToSpeech/Qwen3TtsSettings/Qwen3TtsSettingsViewModel.cs +++ b/src/ui/Features/Video/TextToSpeech/Qwen3TtsSettings/Qwen3TtsSettingsViewModel.cs @@ -98,7 +98,11 @@ private void ApplyStatus(DownloadHashManager.UpdateStatus status, bool installed StatusBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0x98, 0x00)); // amber break; default: - StatusLabel = Se.Language.General.UnknownNoInstallRecord; + // Installed, but nothing identifies the build: an install predating the + // .installed.sha256 sidecar, a manual install, or a build older than the ones + // SE has hashes for. It still works, so say "Installed" rather than something + // that reads as a failed install (#14057). + StatusLabel = Se.Language.General.Installed; StatusBrush = new SolidColorBrush(Color.FromRgb(0x9E, 0x9E, 0x9E)); // grey break; } diff --git a/src/ui/Features/Video/TextToSpeech/Qwen3TtsSettings/Qwen3TtsSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/Qwen3TtsSettings/Qwen3TtsSettingsWindow.cs index 117c68f982d..0bc0b4d6cd5 100644 --- a/src/ui/Features/Video/TextToSpeech/Qwen3TtsSettings/Qwen3TtsSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/Qwen3TtsSettings/Qwen3TtsSettingsWindow.cs @@ -33,7 +33,7 @@ public Qwen3TtsSettingsWindow(Qwen3TtsSettingsViewModel vm) Content = BuildContent(vm); var ok = UiUtil.MakeButtonOk(vm.OkCommand); - Activated += delegate { ok.Focus(); }; // ensure OnKeyDown fires + UiUtil.FocusOnFirstActivation(this, ok); // ensure OnKeyDown fires } private Border BuildContent(Qwen3TtsSettingsViewModel vm) diff --git a/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewHistoryRow.cs b/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewHistoryRow.cs index 587f0ff0925..a8a3a1e9fef 100644 --- a/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewHistoryRow.cs +++ b/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewHistoryRow.cs @@ -23,6 +23,9 @@ public partial class ReviewHistoryRow : ObservableObject public ReviewHistoryRow() { + // The history dialog's only writer of this is StopPlay, which first runs from a watchdog + // timer tick - so every play button opened disabled and clicks in that window did nothing. + IsPlayingEnabled = true; FileName = string.Empty; VoiceName = string.Empty; EngineName = string.Empty; diff --git a/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechViewModel.cs b/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechViewModel.cs index 16c8cee934f..3e6abbdf77c 100644 --- a/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechViewModel.cs +++ b/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechViewModel.cs @@ -12,6 +12,7 @@ using Nikse.SubtitleEdit.Features.Video.TextToSpeech.ElevenLabsSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Voices; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.VoiceCloneConsent; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; using Nikse.SubtitleEdit.Logic.Media; @@ -116,6 +117,12 @@ public partial class ReviewSpeechViewModel : ObservableObject // picker was last used (#13881). public string SubtitleFileName { get; set; } = string.Empty; + // What the video says during a paragraph - the transcript for a reference clip cut here (see + // ResolvePerLineCloneVoiceAsync). Supplied by the TTS window, which knows the original-language + // subtitle a translation was dubbed from; null when it does not, and the line's own text is + // then the best guess left. + public Func? ReferenceTextOf { get; set; } + public bool OkPressed { get; private set; } // Text edits made in this window, published on OK so the caller can offer to apply them to @@ -169,7 +176,8 @@ public ReviewSpeechViewModel(IFolderHelper folderHelper, IWindowService windowSe _cancellationToken = _cancellationTokenSource.Token; _playLock = new Lock(); - _timer = new Timer(200); + // 100 ms: also drives the waveform playhead during playback, 200 ms looked steppy. + _timer = new Timer(100); _timer.Elapsed += OnTimerOnElapsed; _timer.Start(); } @@ -190,6 +198,7 @@ private async void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) // unguarded IsPaused read raced the dispose (NRE / native use-after-free window). var stopped = false; var paused = false; + var positionSeconds = 0.0; lock (_playLock) { if (_cancellationTokenSource.IsCancellationRequested || _mpvContext == null) @@ -199,6 +208,7 @@ private async void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) else { paused = _mpvContext.IsPaused; + positionSeconds = _mpvContext.Position; } } @@ -208,6 +218,15 @@ private async void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) return; } + // The clip plays from the cue's start, so the waveform playhead is start + clip + // position - the user sees where in the cue the speech currently is (#14000). + var playingRow = _playingRow; + if (!paused && playingRow?.WaveformParagraph != null) + { + var playheadSeconds = playingRow.WaveformParagraph.StartTime.TotalSeconds + positionSeconds; + await Dispatcher.UIThread.InvokeAsync(() => SetWaveformPlayhead(playheadSeconds)); + } + // The row that is actually playing - not SelectedLine: two-way grid selection meant // clicking another row during playback made auto-continue advance from the *clicked* // row, stranding the playing row's stop icon and skipping the clicked one. @@ -384,12 +403,16 @@ internal void Initialize( { Include = p.Include, Number = p.Paragraph.Number, - Text = p.Text, + // The subtitle's own text, not the tag-stripped/unbroken copy that was fed to + // the engine: edits made here are published back to the main subtitle, so + // starting from the stripped copy silently dropped italics and line breaks + // from every line the user touched. Synthesis strips at the point of use. + Text = p.Paragraph.Text, Voice = p.Voice == null ? string.Empty : p.Voice.ToString(), Speed = Math.Round(p.SpeedFactor, 2).ToString(CultureInfo.CurrentCulture), Cps = Math.Round(p.Paragraph.GetCharactersPerSecond(), 2).ToString(CultureInfo.CurrentCulture), StepResult = p, - OriginalText = p.Text, + OriginalText = p.Paragraph.Text, OriginalStartMs = p.Paragraph.StartTime.TotalMilliseconds, OriginalEndMs = p.Paragraph.EndTime.TotalMilliseconds, }; @@ -474,6 +497,221 @@ private void OnWaveformParagraphChanged(object? sender, PropertyChangedEventArgs row.Cps = Math.Round(paragraph.GetCharactersPerSecond(), 2).ToString(CultureInfo.CurrentCulture); } + private void SetWaveformPlayhead(double seconds) + { + var av = AudioVisualizer; + if (av == null) + { + return; + } + + av.CurrentVideoPositionSeconds = seconds; + av.InvalidateVisual(); + } + + // Length of each row's generated clip, keyed by file name: a regenerate always writes a new + // file, so a stale entry can never be served for a changed clip. Non-WAV or unreadable files + // yield 0, which the visualizer treats as "no bar". + private readonly Dictionary _audioLengthCache = new(); + + public double GetGeneratedAudioLengthSeconds(ReviewRow row) + { + var fileName = row.StepResult.CurrentFileName; + if (string.IsNullOrEmpty(fileName)) + { + return 0; + } + + if (_audioLengthCache.TryGetValue(fileName, out var cached)) + { + return cached; + } + + var seconds = 0.0; + try + { + if (File.Exists(fileName)) + { + using var stream = File.OpenRead(fileName); + var header = new WaveHeader2(stream); + if (header.ChunkId == "RIFF" && header.Format == "WAVE" && header.BytesPerSecond > 0) + { + seconds = header.LengthInSeconds; + } + } + } + catch (Exception exception) + { + SeLogger.Error(exception, $"ReviewSpeech: cannot read audio length of \"{fileName}\""); + } + + _audioLengthCache[fileName] = seconds; + return seconds; + } + + // Provider for AudioVisualizer.ParagraphAudioLengthProvider - the visualizer hands back the + // mirror instance it draws, so an instance lookup is right here (unlike the event args). + public double GetWaveformParagraphAudioLength(SubtitleLineViewModel waveformParagraph) + { + return _waveformParagraphToRow.TryGetValue(waveformParagraph, out var row) ? GetGeneratedAudioLengthSeconds(row) : 0; + } + + // "Fit duration to generated audio": the cue ends exactly where the speech ends - what the + // user otherwise does by dragging the right edge to the end of the red overrun bar. + [RelayCommand] + private void FitDurationToAudio(ReviewRow? row) + { + row ??= SelectedLine; + var wp = row?.WaveformParagraph; + if (row == null || wp == null) + { + return; + } + + var seconds = GetGeneratedAudioLengthSeconds(row); + if (seconds <= 0) + { + return; + } + + wp.EndTime = wp.StartTime + TimeSpan.FromSeconds(seconds); + wp.UpdateDuration(); + AudioVisualizer?.InvalidateVisual(); + } + + // Restores the times the line had when the window opened (waveform drags have no undo). + [RelayCommand] + private void ResetTiming(ReviewRow? row) + { + row ??= SelectedLine; + var wp = row?.WaveformParagraph; + if (row == null || wp == null) + { + return; + } + + wp.StartTime = TimeSpan.FromMilliseconds(row.OriginalStartMs); + wp.EndTime = TimeSpan.FromMilliseconds(row.OriginalEndMs); + wp.UpdateDuration(); + AudioVisualizer?.InvalidateVisual(); + } + + // Shifts the selected cue as a whole (duration kept), used by the waveform's keyboard nudge. + private void NudgeSelectedLine(double milliseconds) + { + var wp = SelectedLine?.WaveformParagraph; + if (wp == null) + { + return; + } + + var start = Math.Max(0, wp.StartTime.TotalMilliseconds + milliseconds); + var duration = wp.Duration.TotalMilliseconds; + wp.StartTime = TimeSpan.FromMilliseconds(start); + wp.EndTime = TimeSpan.FromMilliseconds(start + duration); + wp.UpdateDuration(); + AudioVisualizer?.InvalidateVisual(); + } + + // Right-click on the waveform: the row under the pointer becomes the context-menu target + // (and the selection) whether or not "right click selects" is on; an empty-area right-click + // keeps the current selection. Returns the target row or null. + public ReviewRow? SelectRowAtWaveformPosition(double seconds) + { + var wp = WaveformParagraphs.Find(p => p.StartTime.TotalSeconds <= seconds && seconds <= p.EndTime.TotalSeconds); + if (wp != null) + { + SelectFromWaveform(wp); + } + + return SelectedLine; + } + + // A left-click on empty waveform just parks the playhead there; the review window has no + // video to seek, so nothing plays until the user presses Play. + public void OnWaveformPositionClicked(double seconds) + { + if (_playingRow == null) + { + SetWaveformPlayhead(seconds); + } + } + + // Keys that act on the waveform when it has focus (the grid handles its own Up/Down): + // Home/End jump to the first/last row; Ctrl+Left/Right nudge the selected cue 100 ms + // (10 ms with Shift) without changing its duration. + public bool OnWaveformKeyDown(KeyEventArgs e) + { + if (Lines.Count == 0) + { + return false; + } + + var ctrl = e.KeyModifiers.HasFlag(OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control); + var shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift); + + if (e.Key == Key.Home && e.KeyModifiers == KeyModifiers.None) + { + SelectedLine = Lines[0]; + LineGrid.ScrollIntoView(Lines[0]); + return true; + } + + if (e.Key == Key.End && e.KeyModifiers == KeyModifiers.None) + { + SelectedLine = Lines[^1]; + LineGrid.ScrollIntoView(Lines[^1]); + return true; + } + + if (ctrl && e.Key is Key.Left or Key.Right) + { + var step = shift ? 10 : 100; + NudgeSelectedLine(e.Key == Key.Left ? -step : step); + return true; + } + + return false; + } + + // True while a selection change originates from a click/drag on the waveform itself. The + // block the user grabbed is already in view, so RefreshWaveformPosition must only update the + // highlight and not recenter - recentering would jump the view (and the block under the + // pointer) mid-drag (#14000). + private bool _selectionFromWaveform; + + // Click/drag on a waveform block selects the row that owns it, which in turn loads its text + // and per-line settings into the edit panel through OnSelectedLineChanged (#14000). + // + // The event args carry a *copy* of the paragraph (ParagraphEventArgs clones it), so the + // lookup goes by Id, which the copy preserves - never by instance. + public void SelectFromWaveform(SubtitleLineViewModel? waveformParagraph) + { + if (waveformParagraph == null) + { + return; + } + + var mirror = WaveformParagraphs.Find(wp => wp.Id == waveformParagraph.Id); + if (mirror == null || + !_waveformParagraphToRow.TryGetValue(mirror, out var row) || + ReferenceEquals(row, SelectedLine)) + { + return; + } + + _selectionFromWaveform = true; + try + { + SelectedLine = row; + LineGrid.ScrollIntoView(row); + } + finally + { + _selectionFromWaveform = false; + } + } + // Centers the visualizer on the currently selected paragraph and marks it as selected so the // user can grab its start/end handles. Safe to call before AudioVisualizer is attached. public void RefreshWaveformPosition() @@ -491,6 +729,14 @@ public void RefreshWaveformPosition() return; } + if (_selectionFromWaveform) + { + av.SelectedParagraph = waveformParagraph; + av.AllSelectedParagraphs = new List { waveformParagraph }; + av.InvalidateVisual(); + return; + } + // SetPosition still wants an index into the list it's given — we know the mirror is in // WaveformParagraphs because Initialize put it there. var index = WaveformParagraphs.IndexOf(waveformParagraph); @@ -569,6 +815,11 @@ await MessageBox.Show( var audioFolder = Path.Combine(folder, "wav"); Directory.CreateDirectory(audioFolder); + // The recordings cloned voices speak from travel with the session too - see + // ExportVoiceReference. The folder is only created when there is something to put in it. + var referenceFolder = Path.Combine(folder, "refs"); + var exportedReferences = new Dictionary(StringComparer.OrdinalIgnoreCase); + // Copy files. Files still referenced by rows or their history entries must not be // overwritten: re-exporting to the folder a session was imported from used to replace an // original take (e.g. 0002.wav) that a history entry still pointed at - "pick from @@ -675,6 +926,7 @@ await MessageBox.Show( : line.StepResult.EngineName, Model = line.StepResult.Model, Instruction = line.StepResult.Instruction, + VoiceFileName = ExportVoiceReference(line.StepResult.Voice, referenceFolder, exportedReferences), SpeedFactor = line.StepResult.SpeedFactor, Text = line.Text, Include = line.Include, @@ -698,6 +950,79 @@ await MessageBox.Show( await _folderHelper.OpenFolder(Window!, folder); } + /// + /// Copies the recording clones from into the export's "refs" folder, + /// returning the relative name to store with the line - or an empty string when the voice + /// clones from nothing, or the copy failed. + /// + /// + /// Without this an imported session could not regenerate a cloned line: an imported clone is + /// only a name in the engine's voice list on the machine that imported it, and the per-line + /// "clone from video" is not even that - its references are cut into the run folder, which is + /// swept when Subtitle Edit closes (#14095). + /// + /// Keyed by source path, so a cast of a few clones is copied once and shared by every line + /// using it rather than once per line. The transcript sidecar goes along when there is one - + /// the cloning engines need it, so a copy without it could only fail at synthesis. + /// + internal static string ExportVoiceReference(Voice? voice, string referenceFolder, Dictionary exported) + { + var source = PerLineVoiceClone.TryGetReferenceClip(voice); + if (string.IsNullOrEmpty(source) || !File.Exists(source)) + { + return string.Empty; + } + + var sourceFullPath = Path.GetFullPath(source); + if (exported.TryGetValue(sourceFullPath, out var alreadyExported)) + { + return alreadyExported; + } + + try + { + Directory.CreateDirectory(referenceFolder); + + // Keep the clip's own name - the voice name for an imported clone, "line-0007" for a + // per-line one. Suffix only when a *different* recording already claimed the name, + // which also covers re-exporting into a folder an older export still owns. + var baseName = Path.GetFileNameWithoutExtension(source); + var extension = Path.GetExtension(source); + var target = Path.Combine(referenceFolder, baseName + extension); + var suffix = 1; + while (File.Exists(target) && + !string.Equals(Path.GetFullPath(target), sourceFullPath, StringComparison.OrdinalIgnoreCase)) + { + target = Path.Combine(referenceFolder, $"{baseName}_{suffix}{extension}"); + suffix++; + } + + // Re-exporting to the folder the session was imported from makes source and target the + // same file; copying it onto itself would only throw. + if (!string.Equals(Path.GetFullPath(target), sourceFullPath, StringComparison.OrdinalIgnoreCase)) + { + File.Copy(source, target, true); + + var sourceTranscript = Path.ChangeExtension(source, ".txt"); + if (File.Exists(sourceTranscript)) + { + File.Copy(sourceTranscript, Path.ChangeExtension(target, ".txt"), true); + } + } + + var relativeName = "refs/" + Path.GetFileName(target); + exported[sourceFullPath] = relativeName; + return relativeName; + } + catch (Exception exception) + { + // One un-copyable reference must not take the whole export down - the line is exported + // without it, exactly as it was before references travelled at all. + SeLogger.Error(exception, $"ReviewSpeech export: copying the voice reference \"{source}\" failed"); + return string.Empty; + } + } + private static string? GetFolderName(string fileName) { if (string.IsNullOrEmpty(fileName)) @@ -797,6 +1122,106 @@ private async Task ShowStyleExaggerationHelp() await ElevenLabsSettingsViewModel.ShowStyleExaggerationHelp(Window!); } + /// + /// What the video says during a line - the transcript a freshly cut reference clip needs. The + /// original-language text when the TTS window supplied a lookup for it (a dub is generated + /// from a translation, and the clip holds what was said, not its translation), otherwise the + /// line's own text as it entered this window. + /// + private string SpokenTextInVideo(ReviewRow line) + { + var fromOriginal = ReferenceTextOf?.Invoke(line.StepResult.Paragraph); + if (!string.IsNullOrWhiteSpace(fromOriginal)) + { + return fromOriginal; + } + + var text = string.IsNullOrWhiteSpace(line.OriginalText) ? line.Text : line.OriginalText; + return Utilities.UnbreakLine(HtmlUtil.RemoveHtmlTags(text ?? string.Empty, alsoSsaTags: true)); + } + + /// + /// A real voice for the per-line clone marker: the reference this line was generated from when + /// it is still on disk, otherwise a fresh cut of the line's own audio in the video. Returns + /// null when there is nothing to clone from - the user has already been told why. + /// + private async Task ResolvePerLineCloneVoiceAsync(ITtsEngine engine, ReviewRow line) + { + // Prefer the clip the line was generated from, so a regenerate clones the same speaker the + // line's other takes did - and so an imported session uses the reference that travelled + // with it instead of cutting the video again. + var existingClip = PerLineVoiceClone.TryGetReferenceClip(line.StepResult.Voice); + if (!string.IsNullOrEmpty(existingClip) && File.Exists(existingClip)) + { + var reused = PerLineVoiceClone.MakeVoiceForClip(engine, existingClip, line.StepResult.Voice?.Name); + if (reused != null) + { + return reused; + } + } + + if (string.IsNullOrEmpty(_videoFileName) || !File.Exists(_videoFileName)) + { + await ShowCloneVoiceError(Se.Language.Video.TextToSpeech.CloneVoicePerLineNeedsVideo); + return null; + } + + var index = Lines.IndexOf(line); + if (index < 0) + { + return null; + } + + // A folder of its own, not the generate run's "clone-references": those clips belong to + // the rows as they were generated, and a re-cut of an edited line must not replace one. + // The video duration is unknown here, which only means the last line's clip is not + // clamped to the end of the video - ffmpeg stops at the end of the audio either way. + var clipFileName = await PerLineVoiceClone.CutReferenceClipAsync( + _videoFileName, + Lines.Select(l => l.StepResult.Paragraph).ToList(), + index, + SpokenTextInVideo(line), + Path.Combine(_waveFolder, "clone-references-regenerate"), + videoDurationSeconds: 0, + audioTrackFfIndex: -1, + // Not _cancellationToken: it still belongs to the previous regenerate at this point + // (this run replaces it further down), so cancelling one regenerate would abort the + // next one's cut before it started. The cut is one short ffmpeg call anyway - the + // main window's preview cut passes None for the same reason. + CancellationToken.None); + if (clipFileName == null) + { + await ShowCloneVoiceError(Se.Language.Video.TextToSpeech.CloneVoicePerLineNoClips); + return null; + } + + var clonedVoice = PerLineVoiceClone.MakeVoiceForClip(engine, clipFileName); + if (clonedVoice == null) + { + // The engine could not use the clip as a reference (or - a wiring bug - it claims + // per-line cloning without implementing IPerLineCloneEngine). Say so rather than + // quietly regenerating in some other voice. + await ShowCloneVoiceError($"{engine.Name} cannot clone the voice of each line."); + } + + return clonedVoice; + } + + private async Task ShowCloneVoiceError(string message) + { + if (Window == null) + { + return; + } + + await MessageBox.Show( + Window, + Se.Language.General.Error, + message, + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + // Replace _cancellationTokenSource, disposing the previous instance. The // RegenerateAudio path swaps in a CTS owned by GeneratingAudioViewModel, // so disposing the old one here just frees the per-window CTS created in @@ -877,6 +1302,31 @@ await MessageBox.Show( return; } + // "Clone from video" is a marker, not a voice any engine can speak with: the generate + // pipeline swaps it for a real one per paragraph, and this window used to hand it + // straight to the engine - which is what an imported session failed on with "Voice is + // not an OmniVoice" (#14095). + if (PerLineVoiceClone.IsSelected(voice)) + { + // Same one-time gate the TTS window puts in front of cloning; picking this voice + // here is a fresh decision to clone whoever speaks in the video. + if (Window != null && !await VoiceCloneConsentPrompt.EnsureAsync( + engine, + Window, + () => _windowService.ShowDialogAsync(Window, _ => { }))) + { + return; + } + + var clonedVoice = await ResolvePerLineCloneVoiceAsync(engine, line); + if (clonedVoice == null) + { + return; + } + + voice = clonedVoice; + } + if (!await TtsVoiceInstaller.EnsureVoiceInstalled(engine, voice, Window, _windowService)) { return; @@ -913,7 +1363,10 @@ await MessageBox.Show( try { var speakResult = await TtsInstructionSwap.RunAsync(engine, instruction, () => - engine.Speak(Utilities.UnbreakLine(line.Text), _waveFolder, voice, language, region, model, _cancellationToken)); + // Strip markup here the way the main generate path does - the row text is + // the subtitle's own text, and engines vocalize "" or garble on tags. + engine.Speak(Utilities.UnbreakLine(HtmlUtil.RemoveHtmlTags(line.Text, alsoSsaTags: true)), + _waveFolder, voice, language, region, model, _cancellationToken)); if (speakResult.Error || string.IsNullOrEmpty(speakResult.FileName) || !File.Exists(speakResult.FileName)) { @@ -1539,6 +1992,8 @@ public async Task SelectedEngineChangedAsync() ?? Languages.FirstOrDefault(), Qwen3TtsCrispAsr => Languages.FirstOrDefault(p => p.Name == Se.Settings.Video.TextToSpeech.Qwen3TtsCrispAsrLanguage) ?? Languages.FirstOrDefault(), + Confucius4TtsCrispAsr => Languages.FirstOrDefault(p => p.Name == Se.Settings.Video.TextToSpeech.Confucius4TtsCrispAsrLanguage) + ?? Languages.FirstOrDefault(), _ => Languages.FirstOrDefault(p => p.Name == Se.Settings.Video.TextToSpeech.ElevenLabsLanguage) ?? Languages.FirstOrDefault(p => p.Code == "en") ?? Languages.FirstOrDefault(), diff --git a/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechWindow.cs b/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechWindow.cs index b59e8268480..46943d49eb9 100644 --- a/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechWindow.cs @@ -83,7 +83,7 @@ public ReviewSpeechWindow(ReviewSpeechViewModel vm) // focused element) without arming any button: a focused button fires OnClick on bare // Space/Enter, and OK used to be focused here - so the first Space a user pressed // published the whole session instead of playing the selected line (#12093). - Activated += delegate { TableViewExtras.FocusRow(vm.LineGrid); }; + UiUtil.FocusOnFirstActivation(this, () => { TableViewExtras.FocusRow(vm.LineGrid); }); // Tunnel-stage handlers: see Space/R before the focused control does. KeyDown alone is // not enough - Avalonia's Button fires OnClick from OnKeyUp on Space (unconditionally @@ -233,7 +233,7 @@ private static Border MakeLineGrid(ReviewSpeechViewModel vm) }; if (!string.IsNullOrEmpty(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName)) { - textBox.FontFamily = new FontFamily(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); + textBox.FontFamily = FontFamilyHelper.Make(Se.Settings.Appearance.SubtitleTextBoxAndGridFontName); } var grid = new Grid @@ -676,6 +676,57 @@ private static Border MakeWaveform(ReviewSpeechViewModel vm) } }; + // Clicking or grabbing a block selects its row (#14000). The control only raises + // OnPrimarySingleClicked when something listens to OnVideoPositionChanged, hence the + // empty playhead handler. OnDragStarted fires on press so the row is selected before a + // move/resize mutates it; OnSelectRequested covers right-click-selects. + audioVisualizer.OnVideoPositionChanged += (_, _) => { }; + audioVisualizer.OnPrimarySingleClicked += (_, e) => + { + vm.SelectFromWaveform(e.Paragraph); + vm.OnWaveformPositionClicked(e.Seconds); + }; + audioVisualizer.OnDragStarted += (_, e) => vm.SelectFromWaveform(e.Paragraph); + audioVisualizer.OnSelectRequested += (_, e) => vm.SelectFromWaveform(e.Paragraph); + + // Generated-clip length bar under each block (green fits / red overrun). + audioVisualizer.ParagraphAudioLengthProvider = vm.GetWaveformParagraphAudioLength; + + // Context menu: the row actions from the grid plus the two timing fixes that only make + // sense here. The target is the row under the pointer (selected on open), so the items + // take it as CommandParameter rather than relying on SelectedLine. + var menuPlay = new MenuItem { Header = Se.Language.Video.TextToSpeech.PlayLine, Command = vm.PlayRowCommand }; + var menuRegenerate = new MenuItem { Header = Se.Language.Video.TextToSpeech.RegenerateAudio, Command = vm.RegenerateAudioCommand }; + var menuHistory = new MenuItem { Header = Se.Language.General.ShowHistory, Command = vm.ShowHistoryCommand }; + var menuFit = new MenuItem { Header = Se.Language.Video.TextToSpeech.FitDurationToGeneratedAudio, Command = vm.FitDurationToAudioCommand }; + var menuReset = new MenuItem { Header = Se.Language.Video.TextToSpeech.ResetTiming, Command = vm.ResetTimingCommand }; + var flyout = new MenuFlyout(); + flyout.Items.Add(menuPlay); + flyout.Items.Add(menuRegenerate); + flyout.Items.Add(menuHistory); + flyout.Items.Add(new Separator()); + flyout.Items.Add(menuFit); + flyout.Items.Add(menuReset); + audioVisualizer.MenuFlyout = flyout; + audioVisualizer.FlyoutMenuOpening += (_, e) => + { + var row = vm.SelectRowAtWaveformPosition(e.PositionInSeconds); + foreach (var item in new[] { menuPlay, menuRegenerate, menuHistory, menuFit, menuReset }) + { + item.CommandParameter = row; + item.IsEnabled = row != null; + } + + if (row != null) + { + menuPlay.IsEnabled = row.IsPlayingEnabled && !row.IsPlaying; + menuRegenerate.IsEnabled = vm.IsRegenerateEnabled && row.IsPlayingEnabled; + menuHistory.IsEnabled = row.HasHistory; + menuFit.IsEnabled = vm.GetGeneratedAudioLengthSeconds(row) > 0 && !audioVisualizer.IsReadOnly; + menuReset.IsEnabled = !audioVisualizer.IsReadOnly; + } + }; + return new Border { Margin = new Thickness(2), @@ -687,6 +738,12 @@ private static Border MakeWaveform(ReviewSpeechViewModel vm) protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); + if (!e.Handled && FocusManager?.GetFocusedElement() is AudioVisualizer && _vm.OnWaveformKeyDown(e)) + { + e.Handled = true; + return; + } + _vm.OnKeyDown(e); } diff --git a/src/ui/Features/Video/TextToSpeech/ReviewSpeechHistory/ReviewSpeechHistoryWindow.cs b/src/ui/Features/Video/TextToSpeech/ReviewSpeechHistory/ReviewSpeechHistoryWindow.cs index b3d388e755e..490d227d2c5 100644 --- a/src/ui/Features/Video/TextToSpeech/ReviewSpeechHistory/ReviewSpeechHistoryWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/ReviewSpeechHistory/ReviewSpeechHistoryWindow.cs @@ -53,14 +53,14 @@ public ReviewSpeechHistoryWindow(ReviewSpeechHistoryViewModel vm) Content = grid; - Activated += delegate + UiUtil.FocusOnFirstActivation(this, () => { // initial focus on an input, not an action button - a focused button clicks on bare Space if (_tableView != null) { TableViewExtras.FocusRow(_tableView); } - }; + }); KeyDown += (s, e) => vm.OnKeyDown(e); Closing += (s, e) => vm.OnWindowClosing(e); Loaded += (s, e) => vm.OnWindowLoaded(); diff --git a/src/ui/Features/Video/TextToSpeech/SelectLinesRowBase.cs b/src/ui/Features/Video/TextToSpeech/SelectLinesRowBase.cs new file mode 100644 index 00000000000..b2076a79343 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/SelectLinesRowBase.cs @@ -0,0 +1,24 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech; + +/// +/// A checkable line in a select-lines dialog (detect speakers, skip noise lines): +/// a checkbox plus the #/show/text columns every such dialog shares. +/// +public abstract partial class SelectLinesRowBase : ObservableObject +{ + [ObservableProperty] private bool _isSelected; + + public int Number { get; } + public string Show { get; } + public string Text { get; } + + protected SelectLinesRowBase(bool isSelected, int number, string show, string text) + { + IsSelected = isSelected; + Number = number; + Show = show; + Text = text; + } +} diff --git a/src/ui/Features/Video/TextToSpeech/SelectLinesViewModelBase.cs b/src/ui/Features/Video/TextToSpeech/SelectLinesViewModelBase.cs new file mode 100644 index 00000000000..0cc8286e940 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/SelectLinesViewModelBase.cs @@ -0,0 +1,70 @@ +using Avalonia.Controls; +using Avalonia.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using System.Collections.ObjectModel; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech; + +/// +/// Shared state and commands for the select-lines dialogs (detect speakers, skip noise lines): +/// a collection of checkable rows, Ok/Cancel, select-all/invert, and Escape-to-close. +/// +public abstract partial class SelectLinesViewModelBase : ObservableObject where TRow : SelectLinesRowBase +{ + [ObservableProperty] private ObservableCollection _rows; + [ObservableProperty] private string _rowsInfo; + + public Window? Window { get; set; } + public bool OkPressed { get; private set; } + + protected SelectLinesViewModelBase() + { + Rows = new ObservableCollection(); + RowsInfo = string.Empty; + } + + /// Collects the checked rows into the dialog's result, just before OK closes it. + protected abstract void CollectResult(); + + [RelayCommand] + private void Ok() + { + CollectResult(); + OkPressed = true; + Window?.Close(); + } + + [RelayCommand] + private void Cancel() + { + Window?.Close(); + } + + [RelayCommand] + private void SelectAll() + { + foreach (var row in Rows) + { + row.IsSelected = true; + } + } + + [RelayCommand] + private void InverseSelection() + { + foreach (var row in Rows) + { + row.IsSelected = !row.IsSelected; + } + } + + internal void KeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + e.Handled = true; + Window?.Close(); + } + } +} diff --git a/src/ui/Features/Video/TextToSpeech/SelectLinesWindowBuilder.cs b/src/ui/Features/Video/TextToSpeech/SelectLinesWindowBuilder.cs new file mode 100644 index 00000000000..390ba22eebc --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/SelectLinesWindowBuilder.cs @@ -0,0 +1,153 @@ +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Data; +using Avalonia.Layout; +using Avalonia.Media; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech; + +/// +/// Shared layout for the select-lines dialogs (detect speakers, skip noise lines): +/// info label on top, a checkable rows table, a selection panel, and the Ok/Cancel bar. +/// The windows pass in only what differs - title, width, selection panel, extra columns. +/// +public static class SelectLinesWindowBuilder +{ + public static void Initialize(Window window, SelectLinesViewModelBase vm, + string title, double width, Control selectionPanel, Control rowsView) + where TRow : SelectLinesRowBase + { + UiUtil.InitializeWindow(window, window.GetType().Name); + window.Title = UiUtil.MakeWindowTitle(title); + window.CanResize = true; + window.Width = width; + window.Height = 600; + window.MinWidth = 600; + window.MinHeight = 400; + vm.Window = window; + window.DataContext = vm; + + var buttonOk = UiUtil.MakeButtonOk(vm.OkCommand); + var buttonCancel = UiUtil.MakeButtonCancel(vm.CancelCommand); + var panelButtons = UiUtil.MakeButtonBar(buttonOk, buttonCancel); + + var grid = new Grid + { + RowDefinitions = + { + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + }, + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + }, + Margin = UiUtil.MakeWindowMargin(), + ColumnSpacing = 10, + RowSpacing = 10, + Width = double.NaN, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + + var labelInfo = UiUtil.MakeLabel().WithBindText(vm, nameof(vm.RowsInfo)); + + grid.Add(labelInfo, 0); + grid.Add(UiUtil.MakeBorderForControlNoPadding(rowsView), 1); + grid.Add(selectionPanel, 2); + grid.Add(panelButtons, 3); + + window.Content = grid; + + // Focus the table, not OK. AddSpaceToggle puts a tunnelling handler on the TableView, so + // Space only toggles a row while focus is inside it - with OK focused the very first + // Space activated the button and accepted the dialog with every line still pre-checked. + // ProfilesWindow spells out the same rule ("a focused button clicks on bare Space"). + UiUtil.FocusOnFirstActivation(window, () => + { + if (rowsView is TableView tableView) + { + TableViewExtras.FocusRow(tableView); + } + else + { + buttonOk.Focus(); + } + }); + window.KeyDown += vm.KeyDown; + + window.Closing += delegate { UiUtil.SaveWindowPosition(window); }; + window.Loaded += delegate { UiUtil.RestoreWindowPosition(window); }; + } + + public static Control MakeRowsView(SelectLinesViewModelBase vm, + string checkBoxColumnHeader, params SeTableViewColumn[] extraColumnsBeforeText) + where TRow : SelectLinesRowBase + { + var dataGrid = TableViewExtras.MakeTableView(); + dataGrid.Width = double.NaN; + dataGrid.Height = double.NaN; + dataGrid.DataContext = vm; + dataGrid.ItemsSource = vm.Rows; + + dataGrid.Columns.Add(new SeTableViewColumn + { + Header = checkBoxColumnHeader, + CellTheme = UiUtil.TableViewNoPaddingCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + CellTemplate = new FuncDataTemplate((_, _) => new Border + { + Background = Brushes.Transparent, + Padding = new Avalonia.Thickness(4), + Child = new CheckBox + { + Focusable = false, + [!ToggleButton.IsCheckedProperty] = new Binding(nameof(SelectLinesRowBase.IsSelected)) + { + Mode = BindingMode.TwoWay, + }, + HorizontalAlignment = HorizontalAlignment.Center, + }, + }), + Width = new GridLength(80), + }); + dataGrid.Columns.Add(new SeTableViewColumn + { + Header = Se.Language.General.NumberSymbol, + Binding = new Binding(nameof(SelectLinesRowBase.Number)), + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Width = new GridLength(60), + }); + dataGrid.Columns.Add(new SeTableViewColumn + { + Header = Se.Language.General.Show, + Binding = new Binding(nameof(SelectLinesRowBase.Show)), + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Width = new GridLength(120), + }); + foreach (var column in extraColumnsBeforeText) + { + dataGrid.Columns.Add(column); + } + dataGrid.Columns.Add(new SeTableViewColumn + { + Header = Se.Language.General.Text, + Binding = new Binding(nameof(SelectLinesRowBase.Text)), + CellTheme = UiUtil.TableViewCellTheme, + HeaderTheme = UiUtil.TableViewColumnHeaderTheme, + Width = new GridLength(1, GridUnitType.Star), + }); + + TableViewExtras.AddSpaceToggle(dataGrid, + item => item.IsSelected, + (item, value) => item.IsSelected = value); + + return dataGrid; + } +} diff --git a/src/ui/Features/Video/TextToSpeech/SkipNoiseLines/NoiseLineDetector.cs b/src/ui/Features/Video/TextToSpeech/SkipNoiseLines/NoiseLineDetector.cs new file mode 100644 index 00000000000..661b350f834 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/SkipNoiseLines/NoiseLineDetector.cs @@ -0,0 +1,40 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Video.SpeechToText; +using System.Collections.Generic; +using System.Linq; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.SkipNoiseLines; + +/// +/// Finds subtitle lines that carry no speech - SDH sound and music annotations like "♪", +/// "[door slams]" or "(sighs)", and lines that are empty once formatting tags are stripped. +/// Sent to a TTS engine, such lines get read aloud or hallucinated into made-up words +/// (issue #14106); the generate flow offers to leave them silent instead. +/// +public static class NoiseLineDetector +{ + /// + /// True when the line holds nothing a voice should say: whitespace, tags only, or only + /// bracketed/parenthesised groups and music symbols. A sound annotation followed by real + /// speech ("[gunshot] Get down!") is speech and is kept. + /// + public static bool IsNoiseOnly(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return true; + } + + if (HtmlUtil.RemoveHtmlTags(text, true).Trim().Length == 0) + { + return true; + } + + return SpeechToTextQualityReport.IsNonSpeechLine(text); + } + + public static List Detect(Subtitle subtitle) + { + return subtitle.Paragraphs.Where(p => IsNoiseOnly(p.Text)).ToList(); + } +} diff --git a/src/ui/Features/Video/TextToSpeech/SkipNoiseLines/SkipNoiseLineRow.cs b/src/ui/Features/Video/TextToSpeech/SkipNoiseLines/SkipNoiseLineRow.cs new file mode 100644 index 00000000000..95edbeb7bc9 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/SkipNoiseLines/SkipNoiseLineRow.cs @@ -0,0 +1,16 @@ +using Nikse.SubtitleEdit.Core.Common; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.SkipNoiseLines; + +public class SkipNoiseLineRow : SelectLinesRowBase +{ + public Paragraph Paragraph { get; } + + public SkipNoiseLineRow(Paragraph paragraph) + // Every detected line starts checked - the whole point of the dialog is skipping them, + // unchecking is for the occasional false positive. + : base(true, paragraph.Number, paragraph.StartTime.ToDisplayString(), paragraph.Text) + { + Paragraph = paragraph; + } +} diff --git a/src/ui/Features/Video/TextToSpeech/SkipNoiseLines/SkipNoiseLinesViewModel.cs b/src/ui/Features/Video/TextToSpeech/SkipNoiseLines/SkipNoiseLinesViewModel.cs new file mode 100644 index 00000000000..08d2748935f --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/SkipNoiseLines/SkipNoiseLinesViewModel.cs @@ -0,0 +1,33 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Logic.Config; +using System.Collections.Generic; +using System.Linq; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.SkipNoiseLines; + +public class SkipNoiseLinesViewModel : SelectLinesViewModelBase +{ + /// The lines the user confirmed should stay silent - no speech is generated for them. + public List SelectedParagraphs { get; private set; } + + public SkipNoiseLinesViewModel() + { + SelectedParagraphs = new List(); + } + + public void Initialize(List noiseLines) + { + Rows.Clear(); + foreach (var paragraph in noiseLines) + { + Rows.Add(new SkipNoiseLineRow(paragraph)); + } + + RowsInfo = string.Format(Se.Language.Video.TextToSpeech.SkipNoiseLinesFoundX, Rows.Count); + } + + protected override void CollectResult() + { + SelectedParagraphs = Rows.Where(r => r.IsSelected).Select(r => r.Paragraph).ToList(); + } +} diff --git a/src/ui/Features/Video/TextToSpeech/SkipNoiseLines/SkipNoiseLinesWindow.cs b/src/ui/Features/Video/TextToSpeech/SkipNoiseLines/SkipNoiseLinesWindow.cs new file mode 100644 index 00000000000..547ae8c8b93 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/SkipNoiseLines/SkipNoiseLinesWindow.cs @@ -0,0 +1,21 @@ +using Avalonia.Controls; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; + +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.SkipNoiseLines; + +public class SkipNoiseLinesWindow : Window +{ + public SkipNoiseLinesWindow(SkipNoiseLinesViewModel vm) + { + var panelSelection = UiUtil.MakeButtonBar( + UiUtil.MakeButton(Se.Language.General.SelectAll, vm.SelectAllCommand), + UiUtil.MakeButton(Se.Language.General.InvertSelection, vm.InverseSelectionCommand)); + + var rowsView = SelectLinesWindowBuilder.MakeRowsView(vm, + Se.Language.Video.TextToSpeech.SkipNoiseLinesColumnSkip); + + SelectLinesWindowBuilder.Initialize(this, vm, + Se.Language.Video.TextToSpeech.SkipNoiseLinesTitle, width: 800, panelSelection, rowsView); + } +} diff --git a/src/ui/Features/Video/TextToSpeech/TextToSpeechViewModel.cs b/src/ui/Features/Video/TextToSpeech/TextToSpeechViewModel.cs index 4cbe6939537..3d33ff5a535 100644 --- a/src/ui/Features/Video/TextToSpeech/TextToSpeechViewModel.cs +++ b/src/ui/Features/Video/TextToSpeech/TextToSpeechViewModel.cs @@ -24,9 +24,13 @@ using Nikse.SubtitleEdit.Features.Video.TextToSpeech.OmniVoiceCrispAsrSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.VoxCPM2CrispAsrSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.MossTtsCrispAsrSettings; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.DotsTtsCrispAsrSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.IndexTtsCrispAsrSettings; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.PocketTtsCrispAsrSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.IndexTts25AudioCppSettings; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.DetectSpeakers; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.KokoroTtsSettings; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.SkipNoiseLines; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.PiperSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.OmniVoiceSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Qwen3TtsCrispAsrSettings; @@ -341,10 +345,31 @@ private void SaveSettings() { Se.Settings.Video.TextToSpeech.IndexTtsCrispAsrModel = SelectedModel ?? IndexTtsCrispAsr.DefaultModelKey; } + else if (SelectedEngine is PocketTtsCrispAsr) + { + Se.Settings.Video.TextToSpeech.PocketTtsCrispAsrModel = SelectedModel ?? PocketTtsCrispAsr.DefaultModelKey; + } + else if (SelectedEngine is DotsTtsCrispAsr) + { + Se.Settings.Video.TextToSpeech.DotsTtsCrispAsrModel = SelectedModel ?? DotsTtsCrispAsr.DefaultModelKey; + } + else if (SelectedEngine is Confucius4TtsCrispAsr) + { + Se.Settings.Video.TextToSpeech.Confucius4TtsCrispAsrModel = SelectedModel ?? Confucius4TtsCrispAsr.DefaultModelKey; + Se.Settings.Video.TextToSpeech.Confucius4TtsCrispAsrLanguage = SelectedLanguage?.Name ?? string.Empty; + } else if (SelectedEngine is IndexTts25AudioCpp) { Se.Settings.Video.TextToSpeech.IndexTts25AudioCppModel = SelectedModel ?? IndexTts25AudioCpp.DefaultModelKey; } + else if (SelectedEngine is HiggsTtsAudioCpp) + { + Se.Settings.Video.TextToSpeech.HiggsTtsAudioCppModel = SelectedModel ?? HiggsTtsAudioCpp.DefaultModelKey; + } + else if (SelectedEngine is FishTtsAudioCpp) + { + Se.Settings.Video.TextToSpeech.FishTtsAudioCppModel = SelectedModel ?? FishTtsAudioCpp.DefaultModelKey; + } else if (SelectedEngine is CosyVoice3CrispAsr) { Se.Settings.Video.TextToSpeech.CosyVoice3CrispAsrModel = SelectedModel ?? CosyVoice3CrispAsr.DefaultModelKey; @@ -383,6 +408,10 @@ private void SaveSettings() Se.Settings.Video.TextToSpeech.ChatterboxCrispAsrLanguage = SelectedLanguage?.Name ?? string.Empty; } } + else if (SelectedEngine is ZonosTtsCrispAsr) + { + Se.Settings.Video.TextToSpeech.ZonosTtsCrispAsrLanguage = SelectedLanguage?.Name ?? string.Empty; + } else if (SelectedEngine is KokoroTtsCpp) { if (SelectedVoice?.EngineVoice is Voices.KokoroVoice kokoroVoice && !string.IsNullOrEmpty(kokoroVoice.Voice)) @@ -973,7 +1002,17 @@ public void Initialize( // fallback base - a configured generation folder wins. _waveFolder = TtsRunFolder.Create(waveFolder); - _castKind = ActorVoiceDetector.Detect(subtitle, format); + RefreshCast(ActorVoiceDetector.Detect(subtitle, format), subtitle); + } + + /// + /// Recomputes the cast state (kind, button, saved mappings) from . + /// Called at window setup, and again when the detect-speakers prompt writes actors into the + /// working subtitle mid-flow. + /// + private void RefreshCast(ActorVoiceDetector.CastKind castKind, Subtitle subtitle) + { + _castKind = castKind; // Only surface the cast button when there's actually more than one actor/voice to assign // — a single-speaker subtitle uses the global engine/voice and the button would be a no-op. var actorCount = _castKind == ActorVoiceDetector.CastKind.None @@ -1018,6 +1057,25 @@ public void Initialize( } } + + /// + /// The language name saved for , or null when that engine keeps no + /// per-engine language. Each engine has its own settings key, so reading one engine's key + /// from another engine's code path silently drops the user's choice. + /// + private static string? GetSavedLanguageName(ITtsEngine? engine) => engine switch + { + OmniVoiceCrispAsr => Se.Settings.Video.TextToSpeech.OmniVoiceCrispAsrLanguage, + MossTtsCrispAsr => Se.Settings.Video.TextToSpeech.MossTtsCrispAsrLanguage, + Confucius4TtsCrispAsr => Se.Settings.Video.TextToSpeech.Confucius4TtsCrispAsrLanguage, + CosyVoice3CrispAsr => Se.Settings.Video.TextToSpeech.CosyVoice3CrispAsrLanguage, + Qwen3TtsCrispAsr => Se.Settings.Video.TextToSpeech.Qwen3TtsCrispAsrLanguage, + ChatterboxTtsCpp => Se.Settings.Video.TextToSpeech.ChatterboxCrispAsrLanguage, + ZonosTtsCrispAsr => Se.Settings.Video.TextToSpeech.ZonosTtsCrispAsrLanguage, + ElevenLabs => Se.Settings.Video.TextToSpeech.ElevenLabsLanguage, + _ => null, + }; + [RelayCommand] private async Task ShowCast() { @@ -1123,6 +1181,117 @@ private async Task PromptMergeContinuationLines() MergedSubtitle = merged; } + /// + /// Lines the user chose to leave silent in this generation run (SDH sound/music annotations + /// like "♪" or "[door slams]" - see ). Held by reference: + /// the set is filled from and checked against the same instance, + /// after any merge prompt has replaced it. + /// + private readonly HashSet _skipNoiseParagraphs = new(); + + /// + /// Like the merge-continuation-lines prompt: sound/music-only lines get read aloud or + /// hallucinated into made-up words by TTS engines (#14106), so before generating, the lines + /// that carry no speech are offered for review and left silent. Runs after the merge prompt - + /// that one can replace . + /// + private async Task PromptSkipNoiseLines() + { + _skipNoiseParagraphs.Clear(); + if (Window == null) + { + return; + } + + var noiseLines = NoiseLineDetector.Detect(_subtitle); + if (noiseLines.Count == 0) + { + return; + } + + var answer = await MessageBox.Show( + Window!, + Se.Language.Video.TextToSpeech.SkipNoiseLinesPromptTitle, + Se.Language.Video.TextToSpeech.SkipNoiseLinesPromptMessage, + MessageBoxButtons.YesNo, + MessageBoxIcon.Question); + if (answer != MessageBoxResult.Yes) + { + return; + } + + var result = await _windowService.ShowDialogAsync( + Window!, vm => vm.Initialize(noiseLines)); + if (!result.OkPressed) + { + return; + } + + foreach (var paragraph in result.SelectedParagraphs) + { + _skipNoiseParagraphs.Add(paragraph); + } + } + + /// + /// The third generate-time prompt: SDH speaker tags written into the text ("MIKE: text", + /// "[NARRATOR] text") become actors so "Set up cast" can give each speaker a voice, and the + /// names are not read aloud (#14106). Only the TTS working copy changes - the subtitle in the + /// main window keeps its text (Tools → Convert actors is the way to persist actors). Runs + /// before the merge prompt: tag-free text merges better, and the merge rebuilds + /// while carrying actors through. + /// + private async Task PromptDetectSpeakers() + { + if (Window == null || _castKind != ActorVoiceDetector.CastKind.None) + { + return; + } + + var candidates = TextSpeakerDetector.Detect(_subtitle); + var speakerCount = candidates + .Where(c => TextSpeakerDetector.IsConfidentSpeakerName(c.Speaker)) + .Select(c => c.Speaker) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(); + if (speakerCount < 2) + { + // One speaker needs no cast, and a subtitle with no confident tag is most likely not + // SDH at all - prompting on every "Warning:" would teach users to click prompts away. + return; + } + + var answer = await MessageBox.Show( + Window!, + Se.Language.Video.TextToSpeech.DetectSpeakersPromptTitle, + Se.Language.Video.TextToSpeech.DetectSpeakersPromptMessage, + MessageBoxButtons.YesNo, + MessageBoxIcon.Question); + if (answer != MessageBoxResult.Yes) + { + return; + } + + var result = await _windowService.ShowDialogAsync( + Window!, vm => vm.Initialize(candidates)); + if (!result.OkPressed || result.ConfirmedCandidates.Count == 0) + { + return; + } + + TextSpeakerDetector.Apply(_subtitle, result.ConfirmedCandidates, result.StickySpeakers); + + // The actors exist only in the TTS working copy, so cast detection is not re-run against + // the main window's format - the copy holds ASSA-style actors now, whatever the file is. + RefreshCast(ActorVoiceDetector.CastKind.AssaActors, _subtitle); + + // Straight into voice assignment - the whole point of confirming the speakers. + if (HasCast) + { + await ShowCast(); + } + } + /// /// Stop the crispasr.exe servers of every CrispASR-based TTS engine EXCEPT /// . Pass null to stop all four. Called before starting synth @@ -1145,10 +1314,30 @@ private static void StopOtherCrispAsrServers(ITtsEngine? keepAlive) { IndexTtsCrispAsr.StopServer(); } + if (keepAlive is not PocketTtsCrispAsr) + { + PocketTtsCrispAsr.StopServer(); + } + if (keepAlive is not DotsTtsCrispAsr) + { + DotsTtsCrispAsr.StopServer(); + } + if (keepAlive is not Confucius4TtsCrispAsr) + { + Confucius4TtsCrispAsr.StopServer(); + } if (keepAlive is not IndexTts25AudioCpp) { IndexTts25AudioCpp.StopServer(); } + if (keepAlive is not HiggsTtsAudioCpp) + { + HiggsTtsAudioCpp.StopServer(); + } + if (keepAlive is not FishTtsAudioCpp) + { + FishTtsAudioCpp.StopServer(); + } if (keepAlive is not CosyVoice3CrispAsr) { CosyVoice3CrispAsr.StopServer(); @@ -1195,11 +1384,24 @@ public async Task GenerateTts() // The engine and/or its models may have just been downloaded - refresh the combo dots. RefreshDownloadDots?.Invoke(); + if (Se.Settings.Tools.TextToSpeechPromptDetectSpeakers) + { + await PromptDetectSpeakers(); + } + if (Se.Settings.Tools.TextToSpeechPromptMergeContinuationLines) { await PromptMergeContinuationLines(); } + // Cleared unconditionally so a run with the prompt turned off never inherits skips from + // an earlier run in the same window. + _skipNoiseParagraphs.Clear(); + if (Se.Settings.Tools.TextToSpeechPromptSkipNoiseLines) + { + await PromptSkipNoiseLines(); + } + var voice = SelectedVoice; if (voice == null) { @@ -1417,9 +1619,24 @@ private async Task RedownloadSelectedModel(ITtsEngine engine) case IndexTtsCrispAsr: await _windowService.ShowDialogAsync(Window!, vm => vm.StartDownloadIndexTtsCrispAsrModels(IndexTtsCrispAsr.ResolveModelKey(SelectedModel))); break; + case PocketTtsCrispAsr: + await _windowService.ShowDialogAsync(Window!, vm => vm.StartDownloadPocketTtsCrispAsrModels(PocketTtsCrispAsr.ResolveModelKey(SelectedModel))); + break; + case DotsTtsCrispAsr: + await _windowService.ShowDialogAsync(Window!, vm => vm.StartDownloadDotsTtsCrispAsrModels(DotsTtsCrispAsr.ResolveModelKey(SelectedModel))); + break; + case Confucius4TtsCrispAsr: + await _windowService.ShowDialogAsync(Window!, vm => vm.StartDownloadConfucius4TtsCrispAsrModels(Confucius4TtsCrispAsr.ResolveModelKey(SelectedModel))); + break; case IndexTts25AudioCpp: await _windowService.ShowDialogAsync(Window!, vm => vm.StartDownloadIndexTts25AudioCppModels(IndexTts25AudioCpp.ResolveModelKey(SelectedModel))); break; + case HiggsTtsAudioCpp: + await _windowService.ShowDialogAsync(Window!, vm => vm.StartDownloadHiggsTtsAudioCppModels(HiggsTtsAudioCpp.ResolveModelKey(SelectedModel))); + break; + case FishTtsAudioCpp: + await _windowService.ShowDialogAsync(Window!, vm => vm.StartDownloadFishTtsAudioCppModels(FishTtsAudioCpp.ResolveModelKey(SelectedModel))); + break; case CosyVoice3CrispAsr: await _windowService.ShowDialogAsync(Window!, vm => vm.StartDownloadCosyVoice3CrispAsrModels(CosyVoice3CrispAsr.ResolveModelKey(SelectedModel))); break; @@ -1490,9 +1707,24 @@ public DownloadDotStatus GetModelDotStatus(ITtsEngine? engine, string? modelKey) IndexTtsCrispAsr => IndexTtsCrispAsr.AreModelsInstalled(modelKey) ? DownloadDotStatus.UpToDate : DownloadDotStatus.NotInstalled, + PocketTtsCrispAsr => PocketTtsCrispAsr.AreModelsInstalled(modelKey) + ? DownloadDotStatus.UpToDate + : DownloadDotStatus.NotInstalled, + DotsTtsCrispAsr => DotsTtsCrispAsr.AreModelsInstalled(modelKey) + ? DownloadDotStatus.UpToDate + : DownloadDotStatus.NotInstalled, + Confucius4TtsCrispAsr => Confucius4TtsCrispAsr.AreModelsInstalled(modelKey) + ? DownloadDotStatus.UpToDate + : DownloadDotStatus.NotInstalled, IndexTts25AudioCpp => IndexTts25AudioCpp.AreModelsInstalled(modelKey) ? DownloadDotStatus.UpToDate : DownloadDotStatus.NotInstalled, + HiggsTtsAudioCpp => HiggsTtsAudioCpp.AreModelsInstalled(modelKey) + ? DownloadDotStatus.UpToDate + : DownloadDotStatus.NotInstalled, + FishTtsAudioCpp => FishTtsAudioCpp.AreModelsInstalled(modelKey) + ? DownloadDotStatus.UpToDate + : DownloadDotStatus.NotInstalled, CosyVoice3CrispAsr => CosyVoice3CrispAsr.AreModelsInstalled(modelKey) ? DownloadDotStatus.UpToDate : DownloadDotStatus.NotInstalled, @@ -1831,6 +2063,14 @@ private async Task Import() { voice = perEngineVoices.FirstOrDefault(v => string.Equals(v.Name, item.VoiceName, StringComparison.OrdinalIgnoreCase)); } + + // A cloned voice that is not in the engine's voice list - the per-line "clone from + // video" cuts one reference per line and never imports any of them - is rebuilt from + // the recording the export copied along. Without this the line came back voice-less, + // and regenerating it either spoke in whatever voice happened to be selected or failed + // outright with "Voice is not an OmniVoice" (#14095). + voice ??= RebuildClonedVoice(item, jsonFolder); + voice ??= Voices.FirstOrDefault(v => v.Name == item.VoiceName); stepResults.Add(new TtsStepResult { @@ -1900,6 +2140,9 @@ await MessageBox.Show( // of writing ActorVoiceMappings = [] back to SubtitleEditTts.json. vm.ActorVoiceMappings.AddRange(_actorVoiceMappings); vm.SubtitleFileName = GetLoadedSubtitleFileName(); + // So a regenerate that has to cut its own reference clip transcribes it with what the + // video says, not with the translation being dubbed over it. + vm.ReferenceTextOf = GetSpokenTextInVideo; if (peaksForReview == null || peaksForReview.Peaks.Count == 0) { @@ -1953,6 +2196,30 @@ await MessageBox.Show( } } + /// + /// The voice an imported line clones from, rebuilt from the reference recording that travelled + /// with the export. Null when the line's voice clones from nothing, when the recording is not + /// there, or when its engine cannot be given a recording directly. + /// + private Voice? RebuildClonedVoice(TtsImportExportItem item, string jsonFolder) + { + if (string.IsNullOrEmpty(item.VoiceFileName) || string.IsNullOrEmpty(item.EngineName)) + { + return null; + } + + var engine = Engines.FirstOrDefault(e => string.Equals(e.Name, item.EngineName, StringComparison.OrdinalIgnoreCase)); + if (engine == null) + { + return null; + } + + var clipFileName = ResolveImportedAudioFileName(item.VoiceFileName, jsonFolder); + return File.Exists(clipFileName) + ? PerLineVoiceClone.MakeVoiceForClip(engine, clipFileName, item.VoiceName) + : null; + } + /// /// Resolves an imported item's audio file. New exports store just the file name (resolved /// against the JSON's own folder, so the export folder can be moved or shared); older exports @@ -2579,11 +2846,23 @@ private string GetLoadedSubtitleFileName() // segments failed (e.g. the ElevenLabs 429 text) instead of a bare count (#12093). var errorMessages = new List(); _speakRetryFailures = 0; + var skippedNoiseCount = 0; for (var index = 0; index < _subtitle.Paragraphs.Count; index++) { ProgressText = $"Generating speech: segment {index + 1} of {_subtitle.Paragraphs.Count}"; var paragraph = _subtitle.Paragraphs[index]; + + // A line the user chose to leave silent gets no step result at all - exactly like + // a failed line downstream (FixSpeed drops it, the merge keeps the base silence + // track over its span) but without counting as a failure anywhere. + if (_skipNoiseParagraphs.Contains(paragraph)) + { + skippedNoiseCount++; + ProgressValue = (double)(index + 1) / _subtitle.Paragraphs.Count * 100.0; + continue; + } + var resolution = ResolveVoiceForParagraph(paragraph, castContext, engine, voice); // When the row's engine differs from the globally selected engine, the global // SelectedLanguage/SelectedRegion/SelectedModel were resolved for a different @@ -2643,6 +2922,11 @@ private string GetLoadedSubtitleFileName() } ProgressValue = 100; + if (skippedNoiseCount > 0) + { + Se.WriteToolsLog($"TTS generation: left {skippedNoiseCount} sound/music lines silent (skipped by user choice)"); + } + var failedCount = resultList.Count(r => string.IsNullOrEmpty(r.CurrentFileName)); // First engine-reported failure reason, e.g. the ElevenLabs 429 text. The generic // rate/pitch hint only applies when no engine said anything more specific. @@ -2779,11 +3063,17 @@ private async Task SpeakOneParagraphAsync( { try { - return await TtsInstructionSwap.RunAsync( + var result = await TtsInstructionSwap.RunAsync( resolution.Engine, resolution.Instruction, () => resolution.Engine.Speak(resolution.Text, _waveFolder, resolution.Voice, language, region, model, cancellationToken)); + + // The counter tracks failures *in a row*, so a line that succeeds first time + // clears it. Without this it accumulated over the whole run and retries were + // switched off permanently after the second failure, however far apart. + _speakRetryFailures = 0; + return result; } catch (OperationCanceledException) { @@ -2806,7 +3096,7 @@ private async Task SpeakOneParagraphAsync( resolution.Instruction, () => resolution.Engine.Speak(resolution.Text, _waveFolder, resolution.Voice, language, region, model, cancellationToken)); - _speakRetryFailures = 0; + _speakRetryFailures = 0; // see the reset on first-attempt success below too Se.WriteToolsLog("TTS generation: the segment succeeded on retry"); return retried; } @@ -2896,7 +3186,9 @@ await MessageBox.Show( } // A fresh folder per run: the clips of a previous run belong to whatever the subtitle - // looked like then, and the review window still points at the current run's files. + // looked like then, and the review window still points at the current run's files. The + // same goes for the copies an engine keeps in its own voices folder. + PerLineVoiceClone.ResetStagedReferences(engine); var clipFolder = Path.Combine(_waveFolder, "clone-references"); try { @@ -2921,7 +3213,9 @@ await MessageBox.Show( clipFolder, videoDurationSeconds, audioTrackFfIndex: -1, - progress: (done, total) => ProgressValue = total == 0 ? 0 : (double)done / total, + // The progress bar is 0-100, like every other stage reports (a raw 0-1 fraction + // left it pinned at 0 for the whole clip-cutting phase). + progress: (done, total) => ProgressValue = total == 0 ? 0 : (double)done / total * 100.0, cancellationToken); if (_perLineCloneClips.Count > 0) @@ -3495,6 +3789,7 @@ await MessageBox.Show( _wavePeakData); vm.ActorVoiceMappings.AddRange(_actorVoiceMappings); vm.SubtitleFileName = GetLoadedSubtitleFileName(); + vm.ReferenceTextOf = GetSpokenTextInVideo; }); if (result.OkPressed) @@ -3565,13 +3860,36 @@ private static string PrettifyEngineDescription(string? description) // once enough progress exists for it not to jump around. private readonly Stopwatch _generateStopwatch = new(); + // ProgressValue is driven 0->100 once per stage (generate, fix speed, post-process, + // merge), so the whole-run elapsed cannot be projected against it - that read "18:00 + // left" seconds before finishing. Time each stage separately for the projection and + // keep the run stopwatch for the elapsed figure. + private readonly Stopwatch _stageStopwatch = new(); + private double _lastProgressValue; + partial void OnProgressValueChanged(double value) { if (!IsGenerating || value <= 0) { + _stageStopwatch.Restart(); // a stage boundary resets the bar to 0 + _lastProgressValue = 0; return; } + // The import/merge path enters the generating state without starting the run + // stopwatch, which would otherwise still hold a previous run's elapsed. + if (!_generateStopwatch.IsRunning) + { + _generateStopwatch.Restart(); + } + + if (value < _lastProgressValue) + { + _stageStopwatch.Restart(); + } + + _lastProgressValue = value; + ProgressPercentText = $"{Math.Clamp((int)Math.Round(value), 0, 100)}%"; var elapsed = _generateStopwatch.Elapsed; @@ -3580,9 +3898,10 @@ partial void OnProgressValueChanged(double value) return; } - if (value >= 3 && value <= 100) + var stageElapsed = _stageStopwatch.IsRunning ? _stageStopwatch.Elapsed : elapsed; + if (value >= 3 && value <= 100 && stageElapsed.TotalSeconds >= 1) { - var remaining = TimeSpan.FromSeconds(elapsed.TotalSeconds * (100 - value) / value); + var remaining = TimeSpan.FromSeconds(stageElapsed.TotalSeconds * (100 - value) / value); ProgressEtaText = string.Format(Se.Language.Video.TextToSpeech.XElapsedYLeft, FormatProgressDuration(elapsed), FormatProgressDuration(remaining)); } else @@ -3686,12 +4005,18 @@ internal void SelectedEngineChanged(object? sender, SelectionChangedEventArgs e) ?? Languages.FirstOrDefault(), MossTtsCrispAsr => Languages.FirstOrDefault(l => l.Name == Se.Settings.Video.TextToSpeech.MossTtsCrispAsrLanguage) ?? Languages.FirstOrDefault(), + Confucius4TtsCrispAsr => Languages.FirstOrDefault(l => l.Name == Se.Settings.Video.TextToSpeech.Confucius4TtsCrispAsrLanguage) + ?? Languages.FirstOrDefault(), CosyVoice3CrispAsr => Languages.FirstOrDefault(l => l.Name == Se.Settings.Video.TextToSpeech.CosyVoice3CrispAsrLanguage) ?? Languages.FirstOrDefault(), Qwen3TtsCrispAsr => Languages.FirstOrDefault(l => l.Name == Se.Settings.Video.TextToSpeech.Qwen3TtsCrispAsrLanguage) ?? Languages.FirstOrDefault(), ChatterboxTtsCpp => Languages.FirstOrDefault(l => l.Name == Se.Settings.Video.TextToSpeech.ChatterboxCrispAsrLanguage) ?? Languages.FirstOrDefault(), + // Zonos leads with English (US) rather than "Auto" (the backend cannot + // detect a language), so the first-entry fallback is the backend default. + ZonosTtsCrispAsr => Languages.FirstOrDefault(l => l.Name == Se.Settings.Video.TextToSpeech.ZonosTtsCrispAsrLanguage) + ?? Languages.FirstOrDefault(), _ => Languages.FirstOrDefault(), }; } @@ -3795,6 +4120,36 @@ internal void SelectedEngineChanged(object? sender, SelectionChangedEventArgs e) IsEngineSettingsVisible = true; IsModelDownloadVisible = true; } + else if (SelectedEngine is PocketTtsCrispAsr) + { + SelectedModel = Models.FirstOrDefault(p => p == Se.Settings.Video.TextToSpeech.PocketTtsCrispAsrModel); + if (string.IsNullOrEmpty(SelectedModel)) + { + SelectedModel = Models.FirstOrDefault(); + } + IsEngineSettingsVisible = true; + IsModelDownloadVisible = true; + } + else if (SelectedEngine is DotsTtsCrispAsr) + { + SelectedModel = Models.FirstOrDefault(p => p == Se.Settings.Video.TextToSpeech.DotsTtsCrispAsrModel); + if (string.IsNullOrEmpty(SelectedModel)) + { + SelectedModel = Models.FirstOrDefault(); + } + IsEngineSettingsVisible = true; + IsModelDownloadVisible = true; + } + else if (SelectedEngine is Confucius4TtsCrispAsr) + { + SelectedModel = Models.FirstOrDefault(p => p == Se.Settings.Video.TextToSpeech.Confucius4TtsCrispAsrModel); + if (string.IsNullOrEmpty(SelectedModel)) + { + SelectedModel = Models.FirstOrDefault(); + } + IsEngineSettingsVisible = true; + IsModelDownloadVisible = true; + } else if (SelectedEngine is IndexTts25AudioCpp) { SelectedModel = Models.FirstOrDefault(p => p == Se.Settings.Video.TextToSpeech.IndexTts25AudioCppModel); @@ -3805,6 +4160,26 @@ internal void SelectedEngineChanged(object? sender, SelectionChangedEventArgs e) IsEngineSettingsVisible = true; IsModelDownloadVisible = true; } + else if (SelectedEngine is HiggsTtsAudioCpp) + { + SelectedModel = Models.FirstOrDefault(p => p == Se.Settings.Video.TextToSpeech.HiggsTtsAudioCppModel); + if (string.IsNullOrEmpty(SelectedModel)) + { + SelectedModel = Models.FirstOrDefault(); + } + IsEngineSettingsVisible = true; + IsModelDownloadVisible = true; + } + else if (SelectedEngine is FishTtsAudioCpp) + { + SelectedModel = Models.FirstOrDefault(p => p == Se.Settings.Video.TextToSpeech.FishTtsAudioCppModel); + if (string.IsNullOrEmpty(SelectedModel)) + { + SelectedModel = Models.FirstOrDefault(); + } + IsEngineSettingsVisible = true; + IsModelDownloadVisible = true; + } else if (SelectedEngine is CosyVoice3CrispAsr) { SelectedModel = Models.FirstOrDefault(p => p == Se.Settings.Video.TextToSpeech.CosyVoice3CrispAsrModel); @@ -4070,8 +4445,14 @@ internal void SelectedModelChanged(object? sender, SelectionChangedEventArgs e) // Keep the language the user already picked when it survives the model switch - // otherwise a MOSS-TTS quant change (Q4_K <-> F16) silently re-selects English. + // Ask for THIS engine's saved language. Consulting the ElevenLabs key here meant + // that e.g. Chatterbox Base -> Turbo -> Base landed on "Auto" instead of the saved + // German, and the next Generate then persisted "Auto" over it. + var savedLanguageName = GetSavedLanguageName(SelectedEngine); SelectedLanguage = Languages.FirstOrDefault(p => p.Name == previousLanguageName) - ?? Languages.FirstOrDefault(p => p.Name == Se.Settings.Video.TextToSpeech.ElevenLabsLanguage); + ?? (string.IsNullOrEmpty(savedLanguageName) + ? null + : Languages.FirstOrDefault(p => p.Name == savedLanguageName)); if (SelectedLanguage == null) { // Fall back to the list's first entry so the combo is never left empty - diff --git a/src/ui/Features/Video/TextToSpeech/TextToSpeechWindow.cs b/src/ui/Features/Video/TextToSpeech/TextToSpeechWindow.cs index 9049acd6c19..f8bbc3585fd 100644 --- a/src/ui/Features/Video/TextToSpeech/TextToSpeechWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/TextToSpeechWindow.cs @@ -162,7 +162,7 @@ public TextToSpeechWindow(TextToSpeechViewModel vm) Content = grid; - Activated += delegate { _comboBoxEngines?.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, () => { _comboBoxEngines?.Focus(); }); // initial focus on an input, not an action button - a focused button clicks on bare Space } // Install-status dot for the engine combo: green = ready, amber = a newer build is available, @@ -184,8 +184,18 @@ private static DownloadDotStatus GetTtsEngineDotStatus(ITtsEngine engine) return StatusDots.From(engine.IsInstalled(null).Result, VibeVoiceCrispAsr.GetEngineUpdateStatus()); case IndexTtsCrispAsr: return StatusDots.From(engine.IsInstalled(null).Result, IndexTtsCrispAsr.GetEngineUpdateStatus()); + case PocketTtsCrispAsr: + return StatusDots.From(engine.IsInstalled(null).Result, PocketTtsCrispAsr.GetEngineUpdateStatus()); + case DotsTtsCrispAsr: + return StatusDots.From(engine.IsInstalled(null).Result, DotsTtsCrispAsr.GetEngineUpdateStatus()); + case Confucius4TtsCrispAsr: + return StatusDots.From(engine.IsInstalled(null).Result, Confucius4TtsCrispAsr.GetEngineUpdateStatus()); case IndexTts25AudioCpp: return StatusDots.From(engine.IsInstalled(null).Result, IndexTts25AudioCpp.GetEngineUpdateStatus()); + case HiggsTtsAudioCpp: + return StatusDots.From(engine.IsInstalled(null).Result, HiggsTtsAudioCpp.GetEngineUpdateStatus()); + case FishTtsAudioCpp: + return StatusDots.From(engine.IsInstalled(null).Result, FishTtsAudioCpp.GetEngineUpdateStatus()); case CosyVoice3CrispAsr: return StatusDots.From(engine.IsInstalled(null).Result, CosyVoice3CrispAsr.GetEngineUpdateStatus()); case F5TtsCrispAsr: diff --git a/src/ui/Features/Video/TextToSpeech/TtsEngineInstaller.cs b/src/ui/Features/Video/TextToSpeech/TtsEngineInstaller.cs index d06dfe713c9..77166e65de8 100644 --- a/src/ui/Features/Video/TextToSpeech/TtsEngineInstaller.cs +++ b/src/ui/Features/Video/TextToSpeech/TtsEngineInstaller.cs @@ -5,6 +5,7 @@ using Nikse.SubtitleEdit.Features.Video.TextToSpeech.DownloadTts; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.IndexTts25License; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.ModelLicense; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; using Nikse.SubtitleEdit.Logic.Download; @@ -264,6 +265,124 @@ await Dispatcher.UIThread.InvokeAsync(async () => return true; } + if (engine is PocketTtsCrispAsr) + { + if (!await TtsVoiceInstaller.EnsureCrispAsrForPocketTts(window, windowService, forceRedownload: false)) + { + return false; + } + + var pocketModelKey = PocketTtsCrispAsr.ResolveModelKey(model); + if (!PocketTtsCrispAsr.AreModelsInstalled(pocketModelKey)) + { + // Model key already carries the language and size in its label (e.g. + // "German Q8_0 (~124 MB)") so we don't append a separate size. + var answer = await MessageBox.Show( + window, + "Download Pocket TTS (CrispASR) model?", + $"{Environment.NewLine}\"Pocket TTS (CrispASR)\" ({pocketModelKey}) requires a model.{Environment.NewLine}{Environment.NewLine}Download model?", + MessageBoxButtons.YesNoCancel, + MessageBoxIcon.Question); + + if (answer != MessageBoxResult.Yes) + { + return false; + } + + var dlResult = await windowService.ShowDialogAsync(window, vm => vm.StartDownloadPocketTtsCrispAsrModels(pocketModelKey)); + if (!dlResult.OkPressed || !PocketTtsCrispAsr.AreModelsInstalled(pocketModelKey)) + { + return false; + } + + await Dispatcher.UIThread.InvokeAsync(async () => + { + await refreshVoices(); + }); + return true; + } + + return true; + } + + if (engine is Confucius4TtsCrispAsr) + { + if (!await TtsVoiceInstaller.EnsureCrispAsrForConfucius4Tts(window, windowService, forceRedownload: false)) + { + return false; + } + + var confuciusModelKey = Confucius4TtsCrispAsr.ResolveModelKey(model); + if (!Confucius4TtsCrispAsr.AreModelsInstalled(confuciusModelKey)) + { + // Model key already carries the total download size, so no separate size here. + var answer = await MessageBox.Show( + window, + "Download Confucius4-TTS (CrispASR) models?", + $"{Environment.NewLine}\"Confucius4-TTS (CrispASR)\" ({confuciusModelKey}) requires models.{Environment.NewLine}{Environment.NewLine}Download models?", + MessageBoxButtons.YesNoCancel, + MessageBoxIcon.Question); + + if (answer != MessageBoxResult.Yes) + { + return false; + } + + var dlResult = await windowService.ShowDialogAsync(window, vm => vm.StartDownloadConfucius4TtsCrispAsrModels(confuciusModelKey)); + if (!dlResult.OkPressed || !Confucius4TtsCrispAsr.AreModelsInstalled(confuciusModelKey)) + { + return false; + } + + await Dispatcher.UIThread.InvokeAsync(async () => + { + await refreshVoices(); + }); + return true; + } + + return true; + } + + if (engine is DotsTtsCrispAsr) + { + if (!await TtsVoiceInstaller.EnsureCrispAsrForDotsTts(window, windowService, forceRedownload: false)) + { + return false; + } + + var dotsModelKey = DotsTtsCrispAsr.ResolveModelKey(model); + if (!DotsTtsCrispAsr.AreModelsInstalled(dotsModelKey)) + { + // Model key already carries the total download size, so no separate size here. + var answer = await MessageBox.Show( + window, + "Download dots.tts (CrispASR) models?", + $"{Environment.NewLine}\"dots.tts (CrispASR)\" ({dotsModelKey}) requires models.{Environment.NewLine}{Environment.NewLine}Download models?", + MessageBoxButtons.YesNoCancel, + MessageBoxIcon.Question); + + if (answer != MessageBoxResult.Yes) + { + return false; + } + + var dlResult = await windowService.ShowDialogAsync(window, vm => vm.StartDownloadDotsTtsCrispAsrModels(dotsModelKey)); + if (!dlResult.OkPressed || !DotsTtsCrispAsr.AreModelsInstalled(dotsModelKey)) + { + return false; + } + + await Dispatcher.UIThread.InvokeAsync(async () => + { + await refreshVoices(); + }); + return true; + } + + return true; + } + if (engine is IndexTts25AudioCpp) { // The model licence gate comes before anything is fetched: the weights are under @@ -284,7 +403,7 @@ await MessageBox.Show( } } - if (!await TtsVoiceInstaller.EnsureAudioCppForIndexTts25(window, windowService, forceRedownload: false)) + if (!await TtsVoiceInstaller.EnsureAudioCppRuntime(window, windowService, forceRedownload: false, "IndexTTS 2.5")) { return false; } @@ -321,6 +440,30 @@ await Dispatcher.UIThread.InvokeAsync(async () => return true; } + if (engine is HiggsTtsAudioCpp) + { + return await EnsureAudioCppEngineWithLicense( + window, windowService, refreshVoices, + engineDisplayName: "Higgs Audio v3", + licenseDefinition: HiggsTtsAudioCpp.LicenseDefinition, + isLicenseAccepted: HiggsTtsAudioCpp.IsLicenseAccepted, + modelKey: HiggsTtsAudioCpp.ResolveModelKey(model), + areModelsInstalled: HiggsTtsAudioCpp.AreModelsInstalled, + startDownloadModels: (vm, key) => vm.StartDownloadHiggsTtsAudioCppModels(key)); + } + + if (engine is FishTtsAudioCpp) + { + return await EnsureAudioCppEngineWithLicense( + window, windowService, refreshVoices, + engineDisplayName: "Fish Audio S2 Pro", + licenseDefinition: FishTtsAudioCpp.LicenseDefinition, + isLicenseAccepted: FishTtsAudioCpp.IsLicenseAccepted, + modelKey: FishTtsAudioCpp.ResolveModelKey(model), + areModelsInstalled: FishTtsAudioCpp.AreModelsInstalled, + startDownloadModels: (vm, key) => vm.StartDownloadFishTtsAudioCppModels(key)); + } + if (engine is ZonosTtsCrispAsr) { if (!await TtsVoiceInstaller.EnsureCrispAsrForZonos(window, windowService, forceRedownload: false)) @@ -845,6 +988,76 @@ public static string GetModelDownloadSizeText(ITtsEngine? engine, string? modelK }; } + /// + /// The shared install flow for the audio.cpp engines whose weights carry their own licence + /// (Higgs Audio v3, Fish Audio S2 Pro): licence gate first — nothing is fetched before the + /// user accepts, the weights are not open source — then the shared audio.cpp runtime, then + /// the engine's own GGUF. Same shape as the IndexTts25AudioCpp branch above, which predates + /// this helper and keeps its own engine-specific licence window. + /// + private static async Task EnsureAudioCppEngineWithLicense( + Window window, + IWindowService windowService, + Func refreshVoices, + string engineDisplayName, + ModelLicenseDefinition licenseDefinition, + Func isLicenseAccepted, + string modelKey, + Func areModelsInstalled, + Action startDownloadModels) + { + if (!isLicenseAccepted()) + { + var licenseResult = await windowService.ShowDialogAsync( + window, vm => vm.Initialize(licenseDefinition)); + if (!licenseResult.OkPressed || !isLicenseAccepted()) + { + await MessageBox.Show( + window, + engineDisplayName, + $"{Environment.NewLine}{engineDisplayName} cannot be used until the model license is accepted.", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + return false; + } + } + + if (!await TtsVoiceInstaller.EnsureAudioCppRuntime(window, windowService, forceRedownload: false, engineDisplayName)) + { + return false; + } + + if (!areModelsInstalled(modelKey)) + { + // Model key already carries its size (e.g. "Q8_0 (~4.7 GB)"). + var answer = await MessageBox.Show( + window, + $"Download {engineDisplayName} model?", + $"{Environment.NewLine}\"{engineDisplayName} (audio.cpp)\" ({modelKey}) requires a model.{Environment.NewLine}{Environment.NewLine}Download model?", + MessageBoxButtons.YesNoCancel, + MessageBoxIcon.Question); + + if (answer != MessageBoxResult.Yes) + { + return false; + } + + var dlResult = await windowService.ShowDialogAsync( + window, vm => startDownloadModels(vm, modelKey)); + if (!dlResult.OkPressed || !areModelsInstalled(modelKey)) + { + return false; + } + + await Dispatcher.UIThread.InvokeAsync(async () => + { + await refreshVoices(); + }); + } + + return true; + } + private static string GetSavedKeyFile(ITtsEngine engine) => engine switch { GoogleSpeech => Se.Settings.Video.TextToSpeech.GoogleKeyFile, diff --git a/src/ui/Features/Video/TextToSpeech/TtsEngineSettingsDialog.cs b/src/ui/Features/Video/TextToSpeech/TtsEngineSettingsDialog.cs index 8e6bd13e031..93dc9a4adf9 100644 --- a/src/ui/Features/Video/TextToSpeech/TtsEngineSettingsDialog.cs +++ b/src/ui/Features/Video/TextToSpeech/TtsEngineSettingsDialog.cs @@ -1,10 +1,13 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.ChatterboxTtsSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.CosyVoice3CrispAsrSettings; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Confucius4TtsCrispAsrSettings; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.DotsTtsCrispAsrSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.ElevenLabsSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.F5TtsCrispAsrSettings; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.AudioCppTtsSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.IndexTts25AudioCppSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.IndexTtsCrispAsrSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.KokoroTtsSettings; @@ -12,6 +15,7 @@ using Nikse.SubtitleEdit.Features.Video.TextToSpeech.OmniVoiceCrispAsrSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.OmniVoiceSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.PiperSettings; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.PocketTtsCrispAsrSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Qwen3TtsCrispAsrSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Qwen3TtsSettings; using Nikse.SubtitleEdit.Features.Video.TextToSpeech.VibeVoiceCrispAsrSettings; @@ -38,8 +42,13 @@ Qwen3TtsCpp or Qwen3TtsCrispAsr or VibeVoiceCrispAsr or IndexTts25AudioCpp or + HiggsTtsAudioCpp or + FishTtsAudioCpp or IndexTtsCrispAsr or + DotsTtsCrispAsr or + Confucius4TtsCrispAsr or CosyVoice3CrispAsr or + PocketTtsCrispAsr or F5TtsCrispAsr or OmniVoiceCrispAsr or VoxCPM2CrispAsr or @@ -71,14 +80,34 @@ public static async Task ShowAsync(ITtsEngine? engine, Window window, IWindowSer { await windowService.ShowDialogAsync(window, vm => vm.Initialize()); } + else if (engine is HiggsTtsAudioCpp) + { + await windowService.ShowDialogAsync(window, vm => vm.Initialize(AudioCppTtsSettingsAdapters.Higgs)); + } + else if (engine is FishTtsAudioCpp) + { + await windowService.ShowDialogAsync(window, vm => vm.Initialize(AudioCppTtsSettingsAdapters.Fish)); + } else if (engine is IndexTtsCrispAsr) { await windowService.ShowDialogAsync(window, vm => vm.Initialize()); } + else if (engine is DotsTtsCrispAsr) + { + await windowService.ShowDialogAsync(window, vm => vm.Initialize()); + } + else if (engine is Confucius4TtsCrispAsr) + { + await windowService.ShowDialogAsync(window, vm => vm.Initialize()); + } else if (engine is CosyVoice3CrispAsr) { await windowService.ShowDialogAsync(window, vm => vm.Initialize()); } + else if (engine is PocketTtsCrispAsr) + { + await windowService.ShowDialogAsync(window, vm => vm.Initialize()); + } else if (engine is F5TtsCrispAsr) { await windowService.ShowDialogAsync(window, vm => vm.Initialize()); diff --git a/src/ui/Features/Video/TextToSpeech/TtsImportExportItem.cs b/src/ui/Features/Video/TextToSpeech/TtsImportExportItem.cs index eb0ce49830b..af561f864bc 100644 --- a/src/ui/Features/Video/TextToSpeech/TtsImportExportItem.cs +++ b/src/ui/Features/Video/TextToSpeech/TtsImportExportItem.cs @@ -17,6 +17,14 @@ public class TtsImportExportItem public string Model { get; set; } public string Instruction { get; set; } + // The recording this line's voice clones from, relative to the JSON like AudioFileName + // ("refs/0001.wav"). A cloned voice is only a name in the engine's voice list when it was + // imported into it - the per-line "clone from video" cuts its reference into the run folder, + // which is gone by the next session, so without the copy travelling along a re-imported + // session could not regenerate a line in the voice it was generated with (#14095). Empty for + // ordinary voices and for legacy exports. + public string VoiceFileName { get; set; } + public bool Include { get; set; } public TtsImportExportItem() @@ -30,6 +38,7 @@ public TtsImportExportItem() VoiceName = string.Empty; Model = string.Empty; Instruction = string.Empty; + VoiceFileName = string.Empty; Include = true; } } diff --git a/src/ui/Features/Video/TextToSpeech/TtsPostProcessor.cs b/src/ui/Features/Video/TextToSpeech/TtsPostProcessor.cs index 5f6b37bfb2a..a5c0e4417b7 100644 --- a/src/ui/Features/Video/TextToSpeech/TtsPostProcessor.cs +++ b/src/ui/Features/Video/TextToSpeech/TtsPostProcessor.cs @@ -29,7 +29,9 @@ public static async Task ApplyPostProcessing(string currentFile, string if (doProChain) { var proChainOutput = Path.Combine(waveFolder, $"pro_{Guid.NewGuid()}.wav"); - var proProcess = FfmpegGenerator.ApplyProAudioChain(currentFile, proChainOutput); + // One ffmpeg per stage per LINE - a 900-line dub with every stage on created + // thousands of undisposed processes. PerLineVoiceClone uses "using var" too. + using var proProcess = FfmpegGenerator.ApplyProAudioChain(currentFile, proChainOutput); await proProcess.StartAndWaitAsync(cancellationToken); currentFile = AdoptStageOutput(currentFile, proChainOutput, "pro audio chain"); @@ -43,11 +45,11 @@ public static async Task ApplyPostProcessing(string currentFile, string if (silencePaddingMs > 0) { var silenceFile = Path.Combine(waveFolder, $"pad_{Guid.NewGuid()}.wav"); - var silenceProcess = FfmpegGenerator.GenerateSilence(silenceFile, silencePaddingMs); + using var silenceProcess = FfmpegGenerator.GenerateSilence(silenceFile, silencePaddingMs); await silenceProcess.StartAndWaitAsync(cancellationToken); var paddedOutput = Path.Combine(waveFolder, $"padded_{Guid.NewGuid()}.wav"); - var concatProcess = FfmpegGenerator.ConcatAudio(currentFile, silenceFile, paddedOutput); + using var concatProcess = FfmpegGenerator.ConcatAudio(currentFile, silenceFile, paddedOutput); await concatProcess.StartAndWaitAsync(cancellationToken); SafeDelete(silenceFile); @@ -62,7 +64,7 @@ public static async Task ApplyPostProcessing(string currentFile, string if (outputSampleRate > 0) { var resampledOutput = Path.Combine(waveFolder, $"sr_{Guid.NewGuid()}.wav"); - var srProcess = FfmpegGenerator.ChangeSampleRate(currentFile, resampledOutput, outputSampleRate); + using var srProcess = FfmpegGenerator.ChangeSampleRate(currentFile, resampledOutput, outputSampleRate); await srProcess.StartAndWaitAsync(cancellationToken); currentFile = AdoptStageOutput(currentFile, resampledOutput, "sample rate conversion"); diff --git a/src/ui/Features/Video/TextToSpeech/TtsVoiceInstaller.cs b/src/ui/Features/Video/TextToSpeech/TtsVoiceInstaller.cs index 1a210024b51..48b999fa236 100644 --- a/src/ui/Features/Video/TextToSpeech/TtsVoiceInstaller.cs +++ b/src/ui/Features/Video/TextToSpeech/TtsVoiceInstaller.cs @@ -95,6 +95,17 @@ public static Task EnsureCrispAsrForIndexTts(Window? window, IWindowServic extraCapabilityCheck: null, minVersionNote: null); + /// + /// Ensures the CrispASR runtime that Pocket TTS (CrispASR) runs on is installed. + /// The pocket-tts backend is older, but the per-language backends and model routing SE + /// relies on (pocket-tts-de/es/it/pt/fr) ship in CrispASR v0.8.31+. + /// + public static Task EnsureCrispAsrForPocketTts(Window? window, IWindowService windowService, bool forceRedownload) + => EnsureCrispAsrAsync(window, windowService, forceRedownload, + engineDisplayName: "Pocket TTS (CrispASR)", + extraCapabilityCheck: null, + minVersionNote: "v0.8.31 or newer"); + /// /// Ensures the CrispASR runtime that Zonos TTS (CrispASR) runs on is installed. /// The zonos-tts backend's GGUFs (transformer + DAC codec) are staged into SE's @@ -155,7 +166,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.29). + /// The moss-tts backend ships in CrispASR v0.8.13 and newer (SE pins v0.8.30). /// public static Task EnsureCrispAsrForMossTts(Window? window, IWindowService windowService, bool forceRedownload) => EnsureCrispAsrAsync(window, windowService, forceRedownload, @@ -163,6 +174,30 @@ public static Task EnsureCrispAsrForMossTts(Window? window, IWindowService extraCapabilityCheck: null, minVersionNote: "v0.8.13 or newer"); + /// + /// 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.30); the version + /// note names that floor because older builds have no dots-tts backend at all and abort on + /// the unknown --backend value. + /// + public static Task EnsureCrispAsrForDotsTts(Window? window, IWindowService windowService, bool forceRedownload) + => EnsureCrispAsrAsync(window, windowService, forceRedownload, + engineDisplayName: "dots.tts (CrispASR)", + extraCapabilityCheck: null, + minVersionNote: "v0.8.25 or newer"); + + /// + /// Ensures the CrispASR runtime that Confucius4-TTS (CrispASR) runs on is installed. + /// The confucius4-tts backend ships in CrispASR v0.8.30 and newer; the version note names + /// that floor because older builds have no confucius4-tts backend at all and abort on the + /// unknown --backend value. + /// + public static Task EnsureCrispAsrForConfucius4Tts(Window? window, IWindowService windowService, bool forceRedownload) + => EnsureCrispAsrAsync(window, windowService, forceRedownload, + engineDisplayName: "Confucius4-TTS (CrispASR)", + extraCapabilityCheck: null, + minVersionNote: "v0.8.30 or newer"); + /// /// Shared CrispASR install/update flow used by all TTS engines that sit on the /// CrispASR runtime. Prompts refer to so users @@ -172,19 +207,21 @@ public static Task EnsureCrispAsrForMossTts(Window? window, IWindowService /// even when CrispASR itself looks up to date. /// /// - /// Ensures the audio.cpp runtime that IndexTTS 2.5 runs on is installed. Unlike the - /// CrispASR engines, these binaries are built by SubtitleEdit itself (upstream ships - /// Windows-only prebuilts), and the archive is per backend: Metal on Apple Silicon, and - /// CPU / Vulkan / CUDA on Windows and Linux x64. + /// Ensures the shared audio.cpp runtime is installed — the same binaries back IndexTTS + /// 2.5, Higgs Audio v3 and Fish Audio S2 Pro, so whichever engine asks first downloads + /// for all of them. Unlike the CrispASR engines, these binaries are built by SubtitleEdit + /// itself (upstream ships Windows-only prebuilts), and the archive is per backend: Metal + /// on Apple Silicon, and CPU / Vulkan / CUDA on Windows and Linux x64. /// - public static async Task EnsureAudioCppForIndexTts25(Window? window, IWindowService windowService, bool forceRedownload) + /// The engine that asked, for the prompts ("IndexTTS 2.5"). + public static async Task EnsureAudioCppRuntime(Window? window, IWindowService windowService, bool forceRedownload, string engineDisplayName) { if (window == null) { return false; } - var isInstalled = File.Exists(IndexTts25AudioCpp.GetServerExecutable()); + var isInstalled = File.Exists(AudioCppRuntime.GetServerExecutable()); if (!forceRedownload && isInstalled) { return true; @@ -197,8 +234,8 @@ public static async Task EnsureAudioCppForIndexTts25(Window? window, IWind { await MessageBox.Show( window, - "IndexTTS 2.5", - $"{Environment.NewLine}IndexTTS 2.5 (audio.cpp) requires an Apple Silicon Mac.", + engineDisplayName, + $"{Environment.NewLine}\"{engineDisplayName}\" (audio.cpp) requires an Apple Silicon Mac.", MessageBoxButtons.OK, MessageBoxIcon.Information); return false; @@ -207,7 +244,7 @@ await MessageBox.Show( var answer = await MessageBox.Show( window, "Download audio.cpp?", - $"{Environment.NewLine}\"IndexTTS 2.5\" runs through the audio.cpp runtime. Download and install now?", + $"{Environment.NewLine}\"{engineDisplayName}\" runs through the audio.cpp runtime. Download and install now?", MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question); @@ -224,8 +261,8 @@ await MessageBox.Show( { await MessageBox.Show( window, - "IndexTTS 2.5", - $"{Environment.NewLine}IndexTTS 2.5 (audio.cpp) is only built for x86-64 on Windows and Linux.", + engineDisplayName, + $"{Environment.NewLine}\"{engineDisplayName}\" (audio.cpp) is only built for x86-64 on Windows and Linux.", MessageBoxButtons.OK, MessageBoxIcon.Information); return false; @@ -234,7 +271,7 @@ await MessageBox.Show( var variantAnswer = await MessageBox.Show( window, "Download audio.cpp?", - $"{Environment.NewLine}\"IndexTTS 2.5\" runs through the audio.cpp runtime. Select a build to download:", + $"{Environment.NewLine}\"{engineDisplayName}\" runs through the audio.cpp runtime. Select a build to download:", MessageBoxButtons.Cancel, MessageBoxIcon.Question, "CPU", @@ -278,7 +315,7 @@ await MessageBox.Show( var dlResult = await windowService.ShowDialogAsync( window, vm => vm.StartDownloadIndexTts25AudioCppEngine(backend)); - if (!dlResult.OkPressed || !File.Exists(IndexTts25AudioCpp.GetServerExecutable())) + if (!dlResult.OkPressed || !File.Exists(AudioCppRuntime.GetServerExecutable())) { return false; } @@ -376,6 +413,20 @@ private static async Task EnsureCrispAsrAsync( _ => "vulkan", }; + if (crispVariant == "cuda") + { + // Upstream added a Windows CUDA 13 build alongside the CUDA 12 one in v0.8.31, so + // Windows gets the same follow-up Linux has (#14343) - without it a fresh install + // from here can only ever land on CUDA 12. + var cudaAnswer = await PromptCrispAsrCudaVersionAsync(window); + if (cudaAnswer == null) + { + return false; + } + + crispVariant = cudaAnswer; + } + if (crispVariant == "cpu") { var cpuAnswer = await PromptCrispAsrCpuFlavorAsync(window); @@ -495,21 +546,7 @@ private static async Task EnsureCrispAsrAsync( if (answer == MessageBoxResult.Custom3) { - var cudaAnswer = await MessageBox.Show( - window, - "CrispASR CUDA build", - $"{Environment.NewLine}CUDA 12 works with most current NVIDIA drivers.{Environment.NewLine}{Environment.NewLine}Pick CUDA 13 only if your driver stack is built for CUDA 13.", - MessageBoxButtons.Cancel, - MessageBoxIcon.Question, - "CUDA 12", - "CUDA 13"); - - return cudaAnswer switch - { - MessageBoxResult.Custom1 => "cuda", - MessageBoxResult.Custom2 => "cuda13", - _ => null, - }; + return await PromptCrispAsrCudaVersionAsync(window); } return answer switch @@ -521,6 +558,30 @@ private static async Task EnsureCrispAsrAsync( }; } + /// + /// Follow-up prompt after the user picks "CUDA" in the CrispASR variant selector, on both + /// Windows and Linux - upstream ships a CUDA 12 and a CUDA 13 build for each. + /// Returns "cuda" (CUDA 12 build) or "cuda13", or null when the user cancels. + /// + private static async Task PromptCrispAsrCudaVersionAsync(Window window) + { + var cudaAnswer = await MessageBox.Show( + window, + "CrispASR CUDA build", + $"{Environment.NewLine}CUDA 12 works with most current NVIDIA drivers.{Environment.NewLine}{Environment.NewLine}Pick CUDA 13 only if your driver stack is built for CUDA 13.", + MessageBoxButtons.Cancel, + MessageBoxIcon.Question, + "CUDA 12", + "CUDA 13"); + + return cudaAnswer switch + { + MessageBoxResult.Custom1 => "cuda", + MessageBoxResult.Custom2 => "cuda13", + _ => null, + }; + } + private static void SafeDelete(string fileName) { try diff --git a/src/ui/Features/Video/TextToSpeech/VibeVoiceCrispAsrSettings/VibeVoiceCrispAsrSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/VibeVoiceCrispAsrSettings/VibeVoiceCrispAsrSettingsWindow.cs index 9c17108d0bb..a79395cdfd3 100644 --- a/src/ui/Features/Video/TextToSpeech/VibeVoiceCrispAsrSettings/VibeVoiceCrispAsrSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/VibeVoiceCrispAsrSettings/VibeVoiceCrispAsrSettingsWindow.cs @@ -33,7 +33,7 @@ public VibeVoiceCrispAsrSettingsWindow(VibeVoiceCrispAsrSettingsViewModel vm) Content = BuildContent(vm); var ok = UiUtil.MakeButtonOk(vm.OkCommand); - Activated += delegate { ok.Focus(); }; + UiUtil.FocusOnFirstActivation(this, ok); } private Border BuildContent(VibeVoiceCrispAsrSettingsViewModel vm) @@ -113,9 +113,8 @@ private static Border BuildDetails(VibeVoiceCrispAsrSettingsViewModel vm) grid.Add(MakeLabel(Se.Language.General.Engine), 0, 0); var enginePanel = MakeStatusPanel(nameof(vm.EngineBrush), nameof(vm.EngineLabel)); var engineButton = UiUtil.MakeButton(string.Empty, vm.RedownloadEngineCommand) - .WithIconLeft(IconNames.Download) + .WithIconLeftBindText(IconNames.Download, nameof(vm.EngineDownloadButtonText)) .WithMarginLeft(12); - engineButton.Bind(ContentControl.ContentProperty, new Binding(nameof(vm.EngineDownloadButtonText))); enginePanel.Children.Add(engineButton); grid.Add(enginePanel, 0, 1); diff --git a/src/ui/Features/Video/TextToSpeech/VoiceSettings/VoiceSettingsViewModel.cs b/src/ui/Features/Video/TextToSpeech/VoiceSettings/VoiceSettingsViewModel.cs index 86f84f17866..10fb7fd3585 100644 --- a/src/ui/Features/Video/TextToSpeech/VoiceSettings/VoiceSettingsViewModel.cs +++ b/src/ui/Features/Video/TextToSpeech/VoiceSettings/VoiceSettingsViewModel.cs @@ -262,6 +262,56 @@ private async Task ImportVoiceFromFileAsync(string fileName) ? omniCrispEngine.ImportVoice(fileName, (result.Text ?? string.Empty).Trim()) : omniCrispEngine.ImportVoice(fileName); } + else if (_engine is FishTtsAudioCpp fishEngine) + { + // Fish Audio S2 Pro REQUIRES the transcript at synth time — audio.cpp answers a + // voice_ref without reference_text with a server error. Always prompt (matching + // CosyVoice3's flow), with the sibling-text autofill so existing .txt sidecars + // don't force a re-type. + var transcript = TryReadSiblingTranscript(fileName) ?? string.Empty; + var audioFileName = fileName; + var result = await _windowService.ShowDialogAsync(Window!, vm => + { + vm.Initialize( + Se.Language.Video.TextToSpeech.VoiceCloneTranscriptTitle, + transcript, + 500, + 150); + vm.ConfigureExtraButton( + Se.Language.Video.TextToSpeech.UseSpeechToTextDotDotDot, + () => RunSpeechToTextAsync(audioFileName)); + }); + + if (!result.OkPressed || string.IsNullOrWhiteSpace(result.Text)) + { + return; + } + + ok = fishEngine.ImportVoice(fileName, result.Text.Trim()); + } + else if (_engine is HiggsTtsAudioCpp higgsEngine) + { + // Higgs clones zero-shot from audio alone; a transcription (reference_text) is + // optional but improves quality, so prompt like VoxCPM2 while allowing an empty + // answer. + var transcript = TryReadSiblingTranscript(fileName) ?? string.Empty; + var audioFileName = fileName; + var result = await _windowService.ShowDialogAsync(Window!, vm => + { + vm.Initialize( + Se.Language.Video.TextToSpeech.VoiceCloneTranscriptTitle, + transcript, + 500, + 150); + vm.ConfigureExtraButton( + Se.Language.Video.TextToSpeech.UseSpeechToTextDotDotDot, + () => RunSpeechToTextAsync(audioFileName)); + }); + + ok = result.OkPressed + ? higgsEngine.ImportVoice(fileName, (result.Text ?? string.Empty).Trim()) + : higgsEngine.ImportVoice(fileName); + } else if (_engine is MossTtsCrispAsr mossEngine) { // MOSS-TTS clones zero-shot from audio alone; a transcription (ref-text) is optional diff --git a/src/ui/Features/Video/TextToSpeech/VoiceSettings/VoiceSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/VoiceSettings/VoiceSettingsWindow.cs index f5a72942f1e..b97bf471043 100644 --- a/src/ui/Features/Video/TextToSpeech/VoiceSettings/VoiceSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/VoiceSettings/VoiceSettingsWindow.cs @@ -78,7 +78,7 @@ public VoiceSettingsWindow(VoiceSettingsViewModel vm) Closed += (_, _) => vm.PropertyChanged -= OnViewModelPropertyChanged; ApplyDropVisualState(vm.IsDragOver); - Activated += delegate { textBox.Focus(); }; // hack to make OnKeyDown work + UiUtil.FocusOnFirstActivation(this, textBox); // hack to make OnKeyDown work } private Control BuildDropArea(VoiceSettingsViewModel vm) diff --git a/src/ui/Features/Video/TextToSpeech/Voices/Confucius4TtsVoice.cs b/src/ui/Features/Video/TextToSpeech/Voices/Confucius4TtsVoice.cs new file mode 100644 index 00000000000..f7d62c56f22 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/Voices/Confucius4TtsVoice.cs @@ -0,0 +1,24 @@ +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Voices; + +public class Confucius4TtsVoice +{ + public string Voice { get; set; } + public string FilePath { get; set; } + + public override string ToString() + { + return Voice; + } + + public Confucius4TtsVoice() + { + Voice = string.Empty; + FilePath = string.Empty; + } + + public Confucius4TtsVoice(string voice, string filePath) + { + Voice = voice; + FilePath = filePath; + } +} diff --git a/src/ui/Features/Video/TextToSpeech/Voices/DotsTtsVoice.cs b/src/ui/Features/Video/TextToSpeech/Voices/DotsTtsVoice.cs new file mode 100644 index 00000000000..62df9a3d44b --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/Voices/DotsTtsVoice.cs @@ -0,0 +1,24 @@ +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Voices; + +public class DotsTtsVoice +{ + public string Voice { get; set; } + public string FilePath { get; set; } + + public override string ToString() + { + return Voice; + } + + public DotsTtsVoice() + { + Voice = string.Empty; + FilePath = string.Empty; + } + + public DotsTtsVoice(string voice, string filePath) + { + Voice = voice; + FilePath = filePath; + } +} diff --git a/src/ui/Features/Video/TextToSpeech/Voices/PocketTtsVoice.cs b/src/ui/Features/Video/TextToSpeech/Voices/PocketTtsVoice.cs new file mode 100644 index 00000000000..8c926f75663 --- /dev/null +++ b/src/ui/Features/Video/TextToSpeech/Voices/PocketTtsVoice.cs @@ -0,0 +1,24 @@ +namespace Nikse.SubtitleEdit.Features.Video.TextToSpeech.Voices; + +public class PocketTtsVoice +{ + public string Voice { get; set; } + public string FilePath { get; set; } + + public override string ToString() + { + return Voice; + } + + public PocketTtsVoice() + { + Voice = string.Empty; + FilePath = string.Empty; + } + + public PocketTtsVoice(string voice, string filePath) + { + Voice = voice; + FilePath = filePath; + } +} diff --git a/src/ui/Features/Video/TextToSpeech/VoxCPM2CrispAsrSettings/VoxCPM2CrispAsrSettingsWindow.cs b/src/ui/Features/Video/TextToSpeech/VoxCPM2CrispAsrSettings/VoxCPM2CrispAsrSettingsWindow.cs index 009304ecca6..c0fda2b3a9f 100644 --- a/src/ui/Features/Video/TextToSpeech/VoxCPM2CrispAsrSettings/VoxCPM2CrispAsrSettingsWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/VoxCPM2CrispAsrSettings/VoxCPM2CrispAsrSettingsWindow.cs @@ -33,7 +33,7 @@ public VoxCPM2CrispAsrSettingsWindow(VoxCPM2CrispAsrSettingsViewModel vm) Content = BuildContent(vm); var ok = UiUtil.MakeButtonOk(vm.OkCommand); - Activated += delegate { ok.Focus(); }; + UiUtil.FocusOnFirstActivation(this, ok); } private Border BuildContent(VoxCPM2CrispAsrSettingsViewModel vm) @@ -112,9 +112,8 @@ private static Border BuildDetails(VoxCPM2CrispAsrSettingsViewModel vm) grid.Add(MakeLabel(Se.Language.General.Engine), 0, 0); var enginePanel = MakeStatusPanel(nameof(vm.EngineBrush), nameof(vm.EngineLabel)); var engineButton = UiUtil.MakeButton(string.Empty, vm.RedownloadEngineCommand) - .WithIconLeft(IconNames.Download) + .WithIconLeftBindText(IconNames.Download, nameof(vm.EngineDownloadButtonText)) .WithMarginLeft(12); - engineButton.Bind(ContentControl.ContentProperty, new Binding(nameof(vm.EngineDownloadButtonText))); enginePanel.Children.Add(engineButton); grid.Add(enginePanel, 0, 1); diff --git a/src/ui/Features/Video/TransparentSubtitles/TransparentSettingsWindow.cs b/src/ui/Features/Video/TransparentSubtitles/TransparentSettingsWindow.cs index cf3e5b1dcea..3d7c8fd6b3a 100644 --- a/src/ui/Features/Video/TransparentSubtitles/TransparentSettingsWindow.cs +++ b/src/ui/Features/Video/TransparentSubtitles/TransparentSettingsWindow.cs @@ -19,7 +19,7 @@ public TransparentSettingsWindow(TransparentSettingsViewModel vm) var checkBoxUseSourceFolder = new RadioButton { - Content = "Use source folder", + Content = Se.Language.General.UseSourceFolder, IsChecked = vm.UseSourceFolder, VerticalAlignment = VerticalAlignment.Center, [!Avalonia.Controls.Primitives.ToggleButton.IsCheckedProperty] = new Binding(nameof(vm.UseSourceFolder)) { Mode = BindingMode.TwoWay, UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged }, @@ -27,7 +27,7 @@ public TransparentSettingsWindow(TransparentSettingsViewModel vm) var checkBoxUseOutputFolder = new RadioButton { - Content = "Use output folder", + Content = Se.Language.General.UseOutputFolder, IsChecked = vm.UseOutputFolder, VerticalAlignment = VerticalAlignment.Center, [!Avalonia.Controls.Primitives.ToggleButton.IsCheckedProperty] = new Binding(nameof(vm.UseOutputFolder)) { Mode = BindingMode.TwoWay, UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged }, @@ -38,7 +38,10 @@ public TransparentSettingsWindow(TransparentSettingsViewModel vm) Text = vm.OutputFolder, VerticalAlignment = VerticalAlignment.Center, [!TextBox.TextProperty] = new Binding(nameof(vm.OutputFolder)) { Mode = BindingMode.TwoWay, UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged }, - IsEnabled = vm.UseOutputFolder, + + // Bound, not read once at construction: ticking "use output folder" has to enable the + // box, otherwise the path can only be set through Browse (the burn-in twin binds it). + [!Control.IsEnabledProperty] = new Binding(nameof(vm.UseOutputFolder)) { Mode = BindingMode.OneWay }, Width = 400, }; @@ -87,7 +90,7 @@ public TransparentSettingsWindow(TransparentSettingsViewModel vm) Content = grid; - Activated += delegate { checkBoxUseSourceFolder.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, checkBoxUseSourceFolder); // initial focus on an input, not an action button - a focused button clicks on bare Space KeyDown += (_, e) => vm.OnKeyDown(e); } } diff --git a/src/ui/Features/Video/TransparentSubtitles/TransparentSubtitlesViewModel.cs b/src/ui/Features/Video/TransparentSubtitles/TransparentSubtitlesViewModel.cs index c5051480786..dff0d4f77c7 100644 --- a/src/ui/Features/Video/TransparentSubtitles/TransparentSubtitlesViewModel.cs +++ b/src/ui/Features/Video/TransparentSubtitles/TransparentSubtitlesViewModel.cs @@ -153,13 +153,9 @@ public TransparentSubtitlesViewModel(IFolderHelper folderHelper, IFileHelper fil FrameRates = new ObservableCollection { 23.976, 24, 25, 29.97, 30, 50, 59.94, 60 }; SelectedFrameRate = FrameRates[0]; - VideoExtensions = new ObservableCollection - { - ".mov", - ".mkv", - ".mp4", - ".webm", - }; + // Transparent output is always encoded as ProRes 4444, which mp4 has no tag for and WebM + // cannot carry at all - offering those two only produced a failed run with no file. + VideoExtensions = new ObservableCollection(OutputContainer.GetExtensions("prores_ks")); SelectedVideoExtension = VideoExtensions[0]; JobItems = new ObservableCollection(); @@ -354,7 +350,7 @@ await MessageBox.Show(Window!, MessageBoxButtons.OK, MessageBoxIcon.Error); - IsGenerating = true; + IsGenerating = false; ProgressValue = 0; }); @@ -414,6 +410,15 @@ private async Task InitAndStartJobItem(int index) var subtitle = !string.IsNullOrWhiteSpace(jobItem.SubtitleFileName) && File.Exists(jobItem.SubtitleFileName) ? Subtitle.Parse(jobItem.SubtitleFileName) : null; + + // Apply the cut before measuring: the generated video's duration comes from + // TotalSeconds, so measuring the untrimmed subtitle produced a full-length file + // with the cut range's text bunched at the start. + if (subtitle != null) + { + subtitle = GetSubtitleBasedOnCut(subtitle); + } + var totalMs = subtitle?.Paragraphs.Count > 0 ? subtitle.Paragraphs.Max(p => p.EndTime.TotalMilliseconds) + 2000 : 2000; @@ -641,10 +646,11 @@ private string MakeAssa(string subtitleFileName) subtitle = GetSubtitleBasedOnCut(subtitle); - if (subtitle.OriginalFormat is NetflixImsc11Japanese) + if (subtitle.OriginalFormat is NetflixImsc11Japanese || NetflixImsc11JapaneseToAss.HasJapaneseMarkup(subtitle)) { // Furigana, bouten and vertical writing become extra positioned render lines - the raw - // tags would otherwise be rendered as literal text (issue #13861). + // tags would otherwise be rendered as literal text (issue #13861). Lambda Cap decodes + // to the same markup (issue #14165). var japaneseJobItem = JobItems[_jobItemIndex]; var japaneseAssaFileName = _tempSubtitleFiles.GetFileName(".ass"); File.WriteAllText(japaneseAssaFileName, NetflixImsc11JapaneseToAss.Convert(subtitle, japaneseJobItem.Width, japaneseJobItem.Height)); @@ -714,7 +720,9 @@ private string MakeOutputFileName(string videoFileName) { var nameNoExt = Path.GetFileNameWithoutExtension(videoFileName); var ext = SelectedVideoExtension; - var suffix = Se.Settings.Video.BurnIn.BurnInSuffix; + // This dialog's own suffix ("_transparent"), which existed but was never read - + // transparent output was being named with burn-in's "_new". + var suffix = Se.Settings.Video.Transparent.OutputSuffix; // This dialog's own output folder, not burn-in's. The settings window has always written // Video.Transparent.OutputFolder / UseOutputFolder, but nothing read them - so whatever @@ -852,7 +860,11 @@ private async Task PickVideoFile() [RelayCommand] private async Task OutputProperties() { - await _windowService.ShowDialogAsync(Window!); + // This dialog's own settings window, not burn-in's: it reads and writes + // Video.Transparent.*, which is what MakeOutputFileName below actually uses. + // Opening the burn-in one edited a store nothing here reads and silently changed + // the burn-in dialog's output folder instead. + await _windowService.ShowDialogAsync(Window!); UpdateOutputProperties(); } @@ -1003,7 +1015,10 @@ private void LoadSettings() SelectedFontOutline = settings.OutlineWidth; SelectedFontShadowWidth = settings.ShadowWidth; SelectedFontSpacing = settings.NonAssaSpacing; - SelectedFontName = settings.FontName; + // Guard the fallback the way the constructor does: assigning a font that is not + // in FontNames nulls the ComboBox selection, and the TwoWay binding then writes + // that null back over the saved font name. + SelectedFontName = FontNames.FirstOrDefault(p => p == settings.FontName) ?? FontNames[0]; FontTextColor = settings.NonAssaTextColor.FromHexToColor(); FontOutlineColor = settings.NonAssaOutlineColor.FromHexToColor(); FontBoxColor = settings.NonAssaBoxColor.FromHexToColor(); @@ -1018,6 +1033,17 @@ private void LoadSettings() var effectsAsStringArray = settings.Effects?.Split(',') ?? []; _selectedEffects = BurnInEffectItem.List().Where(p => effectsAsStringArray.Contains(p.Name)).ToList(); DisplayEffect = string.Join(", ", _selectedEffects.Select(p => p.Name)); + + // The frame rate is a real encoding parameter and the extension picks the container, but + // neither was ever loaded or saved, so both reset to the default on every reopen. + var transparent = Se.Settings.Video.Transparent; + if (FrameRates.Contains(transparent.FrameRate)) + { + SelectedFrameRate = transparent.FrameRate; + } + + SelectedVideoExtension = VideoExtensions.FirstOrDefault(p => p == settings.GenTransparentVideoExtension) + ?? VideoExtensions[0]; } private void SaveSettings() @@ -1036,6 +1062,8 @@ private void SaveSettings() settings.NonAssaFixRtlUnicode = FontFixRtl; settings.NonAssaAlignment = SelectedFontAlignment.Code; settings.UseSourceResolution = UseSourceResolution; + settings.GenTransparentVideoExtension = SelectedVideoExtension; + Se.Settings.Video.Transparent.FrameRate = SelectedFrameRate; Se.SaveSettings(); } @@ -1145,19 +1173,19 @@ private static string TryGetSubtitleFileName(string fileName) private void UpdateOutputProperties() { - // Use the BurnIn output-folder settings: the "Output properties" dialog - // (BurnInSettingsWindow) and MakeOutputFileName both read/write BurnIn.*, - // so the UI must read the same store or it would show a stale/empty folder. - if (Se.Settings.Video.BurnIn.UseOutputFolder && - string.IsNullOrWhiteSpace(Se.Settings.Video.BurnIn.OutputFolder)) + // This dialog's own output-folder settings: the settings window and + // MakeOutputFileName both read/write Video.Transparent.*, so the UI has to read + // the same store or it shows a folder that has no effect on the output. + if (Se.Settings.Video.Transparent.UseOutputFolder && + string.IsNullOrWhiteSpace(Se.Settings.Video.Transparent.OutputFolder)) { // Output-folder mode is on but no folder is configured - fall back to the source folder. - Se.Settings.Video.BurnIn.UseOutputFolder = false; + Se.Settings.Video.Transparent.UseOutputFolder = false; } - UseSourceFolderVisible = !Se.Settings.Video.BurnIn.UseOutputFolder; - UseOutputFolderVisible = Se.Settings.Video.BurnIn.UseOutputFolder; - OutputFolder = Se.Settings.Video.BurnIn.OutputFolder; + UseSourceFolderVisible = !Se.Settings.Video.Transparent.UseOutputFolder; + UseOutputFolderVisible = Se.Settings.Video.Transparent.UseOutputFolder; + OutputFolder = Se.Settings.Video.Transparent.OutputFolder; } public void BoxTypeChanged(object? sender, SelectionChangedEventArgs e) @@ -1301,7 +1329,7 @@ private void UpdateNonAssaPreviewSkia() FontIsBold, FontTextColor.ToSKColor(), FontOutlineColor.ToSKColor(), - SKColors.Red, + FontShadowColor.ToSKColor(), FontShadowColor.ToSKColor(), (float)(SelectedFontOutline ?? 0), 0, @@ -1351,7 +1379,7 @@ internal void Loaded() var height = VideoHeight ?? 1080; var subtitle = new Subtitle(_subtitle, false); - if (_subtitleFormat is NetflixImsc11Japanese) + if (_subtitleFormat is NetflixImsc11Japanese || NetflixImsc11JapaneseToAss.HasJapaneseMarkup(subtitle)) { return NetflixImsc11JapaneseToAss.Convert(subtitle, width, height); } diff --git a/src/ui/Features/Video/TransparentSubtitles/TransparentSubtitlesWindow.cs b/src/ui/Features/Video/TransparentSubtitles/TransparentSubtitlesWindow.cs index ddf27a83317..f26185967f8 100644 --- a/src/ui/Features/Video/TransparentSubtitles/TransparentSubtitlesWindow.cs +++ b/src/ui/Features/Video/TransparentSubtitles/TransparentSubtitlesWindow.cs @@ -151,7 +151,7 @@ void UpdateGrowAreas() }; UpdateGrowAreas(); - Activated += delegate { _comboBoxFontName?.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, () => { _comboBoxFontName?.Focus(); }); // initial focus on an input, not an action button - a focused button clicks on bare Space Loaded += (_, _) => vm.Loaded(); Opened += (_, _) => LockMinimumToContentSize(); diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrEngineItem.cs b/src/ui/Features/Video/VideoOcr/VideoOcrEngineItem.cs index b4775e205ce..39469c73650 100644 --- a/src/ui/Features/Video/VideoOcr/VideoOcrEngineItem.cs +++ b/src/ui/Features/Video/VideoOcr/VideoOcrEngineItem.cs @@ -22,25 +22,40 @@ public override string ToString() return Name; } + /// + /// Best measured engine first (burned-in subtitle clips of real footage with real SRTs + /// as ground truth, 2026-08-26): Apple Vision led on accuracy and speed with zero setup; + /// Paddle Standalone was next and is by far the fastest batch engine on Windows/Linux; + /// the local VLM engines (CrispEmbed / llama.cpp, both defaulting to GLM-OCR) follow at + /// roughly 1 s/frame; Ollama needs a user-managed install and the GLM API needs a key, + /// so they close the list. The pip-based Paddle Python engine was dropped from this list: + /// getting a matching Python + paddle install working is the most support-heavy path, + /// and every remaining engine is either built in or downloaded automatically. + /// public static List GetEngines() { var list = new List(); + // Nothing to download and no key: on macOS this works the moment the window opens. + if (AppleVisionOcr.IsAvailable()) + { + list.Add(new(AppleVisionOcr.StaticName, OcrEngineType.AppleVision, + "macOS's built-in OCR engine - local, no download needed")); + } + if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()) { list.Add(new("Paddle OCR Standalone", OcrEngineType.PaddleOcrStandalone, "Local OCR engine (downloaded automatically) - fast and accurate")); } - list.Add(new("Paddle OCR Python", OcrEngineType.PaddleOcrPython, "Local OCR engine via Python (requires \"pip install paddleocr\")")); - list.Add(new("Ollama vision", OcrEngineType.Ollama, "Local vision model via Ollama - e.g. glm-ocr")); - list.Add(new("llama.cpp", OcrEngineType.LlamaCpp, "Local vision model via llama.cpp (downloaded automatically)")); - if (CrispEmbedEngine.CanBeDownloaded()) { list.Add(new(CrispEmbedEngine.StaticName, OcrEngineType.CrispEmbed, "Local OCR engine with multiple model backends (downloaded automatically)")); } + list.Add(new("llama.cpp", OcrEngineType.LlamaCpp, "Local vision model via llama.cpp (downloaded automatically)")); + list.Add(new("Ollama vision", OcrEngineType.Ollama, "Local vision model via Ollama - e.g. glm-ocr")); list.Add(new("GLM API", OcrEngineType.Glm, "GLM vision model via Z.ai / bigmodel.cn API (requires API key)")); return list; diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrFrameGroup.cs b/src/ui/Features/Video/VideoOcr/VideoOcrFrameGroup.cs index 949c0ad5aeb..e65112a8fe6 100644 --- a/src/ui/Features/Video/VideoOcr/VideoOcrFrameGroup.cs +++ b/src/ui/Features/Video/VideoOcr/VideoOcrFrameGroup.cs @@ -21,6 +21,23 @@ public class VideoOcrFrameGroup /// OCR result for the representative frame. public string Text { get; set; } = string.Empty; + /// + /// The OCR engine's recognition confidence for (0-1). Engines that + /// report no confidence leave it at 1, so it only ever down-weights a group's text in + /// 's vote - never favors one engine over another. + /// + public double Confidence { get; set; } = 1.0; + + /// + /// File name of another sampled frame belonging to this group, by its 0-based frame + /// index (the extraction writes "img000000.jpg" ... into one folder). + /// + public string GetSiblingFrameFileName(int frameIndex) + { + var folder = System.IO.Path.GetDirectoryName(RepresentativeFileName) ?? string.Empty; + return System.IO.Path.Combine(folder, $"img{frameIndex:000000}.jpg"); + } + public double GetStartMs(double framesPerSecond) { return StartFrame * 1000.0 / framesPerSecond; diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrFrameGrouper.cs b/src/ui/Features/Video/VideoOcr/VideoOcrFrameGrouper.cs index e2d6d203d66..48ca6b820f5 100644 --- a/src/ui/Features/Video/VideoOcr/VideoOcrFrameGrouper.cs +++ b/src/ui/Features/Video/VideoOcr/VideoOcrFrameGrouper.cs @@ -1,6 +1,7 @@ using SkiaSharp; using System; using System.Collections.Generic; +using System.IO; using System.Threading; namespace Nikse.SubtitleEdit.Features.Video.VideoOcr; @@ -16,6 +17,14 @@ public static class VideoOcrFrameGrouper { private const int ThumbnailWidth = 96; + // The brightness mask is thresholded at this width, then max-pooled down to + // ThumbnailWidth. Thresholding a 96px thumbnail directly destroys the mask: at that + // scale the anti-aliased blend of white glyphs and their dark outlines averages below + // any sensible brightness minimum, so whole subtitles read as "blank" and the frames + // are never OCR'ed. At ~360px the glyph cores survive thresholding, and max-pooling + // the binary mask keeps thin strokes visible to the group comparison. + private const int MaskSourceWidth = 360; + // Less than this fraction of bright pixels counts as "no text on screen". private const double BlankFraction = 0.002; @@ -39,7 +48,15 @@ public static List Group( var thumbnail = MakeThumbnail(frameFileNames[index], brightnessMinimum); if (thumbnail == null) { - continue; // unreadable frame - treat as part of the current group + // Unreadable frame - keep it inside the current group. Skipping it outright left + // the group's EndFrame behind, which shortens the subtitle's end time. + if (current != null) + { + current.EndFrame = index; + currentFileList.Add(frameFileNames[index]); + } + + continue; } var isBlank = brightnessMinimum > 0 && IsBlank(thumbnail); @@ -90,7 +107,7 @@ private static void CloseGroup(List groups, VideoOcrFrameGro groups.Add(group); } - private static bool IsBlank(byte[] thumbnail) + internal static bool IsBlank(byte[] thumbnail) { var bright = 0; foreach (var b in thumbnail) @@ -157,16 +174,21 @@ internal static int GetSimilarityPercent(byte[] a, byte[] b) return (int)Math.Round(100.0 - meanDiff * 100.0 / 255.0); } - private static byte[]? MakeThumbnail(string fileName, int brightnessMinimum) + internal static byte[]? MakeThumbnail(string fileName, int brightnessMinimum) { try { - using var bitmap = DecodeScaledDown(fileName); + using var bitmap = DecodeScaledDown(fileName, brightnessMinimum > 0 ? MaskSourceWidth : ThumbnailWidth); if (bitmap == null || bitmap.Width == 0 || bitmap.Height == 0) { return null; } + if (brightnessMinimum > 0) + { + return MakePooledMask(bitmap, brightnessMinimum); + } + var height = Math.Max(1, (int)Math.Round(bitmap.Height * ThumbnailWidth / (double)bitmap.Width)); using var small = bitmap.Resize(new SKImageInfo(ThumbnailWidth, height), new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.None)); if (small == null) @@ -179,22 +201,121 @@ internal static int GetSimilarityPercent(byte[] a, byte[] b) for (var i = 0; i < pixels.Length; i++) { var c = pixels[i]; - var luma = (byte)((c.Red * 299 + c.Green * 587 + c.Blue * 114) / 1000); - if (brightnessMinimum > 0) + result[i] = (byte)((c.Red * 299 + c.Green * 587 + c.Blue * 114) / 1000); + } + + return result; + } + catch + { + return null; + } + } + + /// + /// Thresholds the bitmap at its own (working) resolution and max-pools the resulting + /// binary mask down to a ThumbnailWidth-wide grid: a grid cell is bright when any pixel + /// in its source block clears the brightness minimum, so thin glyph strokes survive. + /// + private static byte[] MakePooledMask(SKBitmap bitmap, int brightnessMinimum) + { + var width = bitmap.Width; + var height = bitmap.Height; + var thumbHeight = Math.Max(1, (int)Math.Round(height * ThumbnailWidth / (double)width)); + var result = new byte[ThumbnailWidth * thumbHeight]; + var pixels = bitmap.Pixels; + + for (var y = 0; y < height; y++) + { + var thumbRow = Math.Min(thumbHeight - 1, y * thumbHeight / height) * ThumbnailWidth; + var sourceRow = y * width; + for (var x = 0; x < width; x++) + { + var c = pixels[sourceRow + x]; + var luma = (c.Red * 299 + c.Green * 587 + c.Blue * 114) / 1000; + if (luma >= brightnessMinimum) + { + result[thumbRow + Math.Min(ThumbnailWidth - 1, x * ThumbnailWidth / width)] = 255; + } + } + } + + return result; + } + + /// + /// Writes a copy of a video frame with everything below the brightness minimum blacked + /// out - the same input filtering VideOCR applies before recognition. Detector-based + /// OCR (PaddleOCR) then only sees the bright subtitle text, not darker scene text like + /// shirt prints or credits, which its detector otherwise picks up and prepends to + /// subtitles. The keep-mask is dilated a little so anti-aliased glyph edges survive. + /// Vision/VLM engines are better off with the natural frame - measured: masking cost + /// Apple Vision accuracy while it clearly helped PaddleOCR - so only the Paddle path + /// uses this. + /// + public static bool WriteMaskedCopy(string sourceFileName, string targetFileName, int brightnessMinimum) + { + try + { + using var bitmap = SKBitmap.Decode(sourceFileName); + if (bitmap == null || bitmap.Width == 0 || bitmap.Height == 0) + { + return false; + } + + var width = bitmap.Width; + var height = bitmap.Height; + var pixels = bitmap.Pixels; + var keep = new bool[pixels.Length]; + for (var i = 0; i < pixels.Length; i++) + { + var c = pixels[i]; + keep[i] = (c.Red * 299 + c.Green * 587 + c.Blue * 114) / 1000 >= brightnessMinimum; + } + + const int dilate = 2; + var keepDilated = new bool[pixels.Length]; + for (var y = 0; y < height; y++) + { + var row = y * width; + for (var x = 0; x < width; x++) { - result[i] = luma >= brightnessMinimum ? (byte)255 : (byte)0; + if (!keep[row + x]) + { + continue; + } + + var yEnd = Math.Min(height - 1, y + dilate); + var xEnd = Math.Min(width - 1, x + dilate); + for (var yy = Math.Max(0, y - dilate); yy <= yEnd; yy++) + { + var rowOut = yy * width; + for (var xx = Math.Max(0, x - dilate); xx <= xEnd; xx++) + { + keepDilated[rowOut + xx] = true; + } + } } - else + } + + var black = new SKColor(0, 0, 0); + for (var i = 0; i < pixels.Length; i++) + { + if (!keepDilated[i]) { - result[i] = luma; + pixels[i] = black; } } - return result; + bitmap.Pixels = pixels; + using var image = SKImage.FromBitmap(bitmap); + using var data = image.Encode(SKEncodedImageFormat.Jpeg, 92); + File.WriteAllBytes(targetFileName, data.ToArray()); + return true; } catch { - return null; + return false; } } @@ -202,7 +323,7 @@ internal static int GetSimilarityPercent(byte[] a, byte[] b) /// Decodes an image at reduced size when the codec supports it (JPEG decodes natively /// at 1/2, 1/4, 1/8 scale) - much cheaper than a full decode for thumbnail use. /// - private static SKBitmap? DecodeScaledDown(string fileName) + private static SKBitmap? DecodeScaledDown(string fileName, int targetWidth) { try { @@ -212,7 +333,7 @@ internal static int GetSimilarityPercent(byte[] a, byte[] b) return SKBitmap.Decode(fileName); } - var scaled = codec.GetScaledDimensions(ThumbnailWidth / (float)codec.Info.Width); + var scaled = codec.GetScaledDimensions(targetWidth / (float)codec.Info.Width); var info = new SKImageInfo(scaled.Width, scaled.Height); return SKBitmap.Decode(codec, info) ?? SKBitmap.Decode(fileName); } diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrLineBuilder.cs b/src/ui/Features/Video/VideoOcr/VideoOcrLineBuilder.cs index f2b0726e010..f28d9b8c7d3 100644 --- a/src/ui/Features/Video/VideoOcr/VideoOcrLineBuilder.cs +++ b/src/ui/Features/Video/VideoOcr/VideoOcrLineBuilder.cs @@ -46,6 +46,7 @@ public static List Build( { var work = new List(); WorkLine? current = null; + WorkLine? previous = null; foreach (var group in groups.OrderBy(p => p.StartFrame)) { @@ -61,17 +62,41 @@ public static List Build( var startMs = group.GetStartMs(framesPerSecond); var endMs = group.GetEndMs(framesPerSecond); + // Weight each observation by how long its text was on screen, scaled by the + // engine's recognition confidence, so a long-lived misread with hesitant + // confidence can lose the vote to a shorter, confident read. The floor keeps + // a zero-confidence report from erasing the only observation of a subtitle. + var weight = (endMs - startMs) * Math.Clamp(group.Confidence, 0.1, 1.0); + if (current != null && startMs - current.EndMs <= maxGapMs && GetTextSimilarityPercent(current.GetMajorityText(), text) >= textSimilarityPercent) { current.EndMs = endMs; - current.AddText(text, endMs - startMs); + current.AddText(text, weight); + } + else if (current != null && + current.EndMs - current.StartMs < minDurationMs && + previous != null && + startMs - previous.EndMs <= maxGapMs && + GetTextSimilarityPercent(previous.GetMajorityText(), text) >= textSimilarityPercent) + { + // A single junk observation (scene text flashing over the subtitle, e.g. a + // jersey number read as "14 Wait." between two clean "Wait." reads) must not + // sever the chain: the interrupted line would fall apart into fragments that + // are each below the minimum duration and silently vanish. When the current + // line is itself below the minimum duration - so it is going to be dropped + // anyway - and the new text continues the line before it, resume that line. + previous.EndMs = endMs; + previous.AddText(text, weight); + current = previous; + previous = null; } else { + previous = current; current = new WorkLine { StartMs = startMs, EndMs = endMs }; - current.AddText(text, endMs - startMs); + current.AddText(text, weight); work.Add(current); } } @@ -116,6 +141,11 @@ public static string CleanOcrResult(string text) // and emphasises only the second one ("Hello" then "**Hello**") got past a check on // the raw text and produced a subtitle with the line in it twice. line = StripMarkdownEmphasis(line); + // Adjacent identical lines are collapsed on purpose: vision models routinely echo a + // line twice (sometimes emphasising only one copy), and CleanOcrResult_KnownValues / + // CleanOcrResult_RepeatedLine_IsDroppedEvenWhenOnlyOneIsEmphasised pin that down. + // A burned-in subtitle whose two lines are genuinely identical ("- No." / "- No.") + // therefore loses its second line - accepted as the lesser evil versus echoed text. if (line == lastLine) { continue; diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrLineItem.cs b/src/ui/Features/Video/VideoOcr/VideoOcrLineItem.cs index 336e5f224a1..8b6cb1ebd4f 100644 --- a/src/ui/Features/Video/VideoOcr/VideoOcrLineItem.cs +++ b/src/ui/Features/Video/VideoOcr/VideoOcrLineItem.cs @@ -1,4 +1,10 @@ +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; using CommunityToolkit.Mvvm.ComponentModel; +using Nikse.SubtitleEdit.Features.Ocr.FixEngine; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.UiLogic.Ocr.FixEngine; using System; namespace Nikse.SubtitleEdit.Features.Video.VideoOcr; @@ -8,7 +14,40 @@ public partial class VideoOcrLineItem : ObservableObject [ObservableProperty] private int _number; [ObservableProperty] private TimeSpan _startTime; [ObservableProperty] private TimeSpan _endTime; - [ObservableProperty] private string _text = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FormattedText))] + private string _text = string.Empty; + + /// The OCR fix engine's per-word result for , or null when no + /// fix/spell check ran. Drives the per-word coloring in . + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FormattedText))] + private OcrFixLineResult? _fixResult; public TimeSpan Duration => EndTime - StartTime; + + /// + /// The text cell's content: per-word colored runs when the fix engine ran (green = + /// word known, red = unknown, matching the subtitle-bitmap OCR window), otherwise the + /// plain text. The base foreground is always set explicitly - the table's cell theme + /// does not flow the theme foreground into templated content, which rendered the text + /// black on the dark theme. + /// + public TextBlock FormattedText + { + get + { + var baseBrush = UiTheme.IsDarkThemeEnabled() ? Brushes.WhiteSmoke : Brushes.Black; + + var textBlock = FixResult != null + ? FixResult.GetFormattedText() + : new TextBlock { Text = Text }; + + textBlock.Foreground = baseBrush; + textBlock.VerticalAlignment = VerticalAlignment.Center; + textBlock.TextTrimming = TextTrimming.CharacterEllipsis; + return textBlock; + } + } } diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrObservationFilter.cs b/src/ui/Features/Video/VideoOcr/VideoOcrObservationFilter.cs new file mode 100644 index 00000000000..2c30141b99c --- /dev/null +++ b/src/ui/Features/Video/VideoOcr/VideoOcrObservationFilter.cs @@ -0,0 +1,74 @@ +using Nikse.SubtitleEdit.Features.Ocr.Engines; +using SkiaSharp; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Nikse.SubtitleEdit.Features.Video.VideoOcr; + +/// +/// Drops OCR observations whose bounding box holds (almost) no pixels above the brightness +/// minimum. Burned-in subtitles are bright by definition - that is what the brightness +/// minimum expresses - while darker scene text inside the scan area (shirt prints, opening +/// credits) is what the engines otherwise prepend to subtitles. This is the observation-level +/// counterpart of the input masking the Paddle path uses; vision engines measured better on +/// natural frames, so for them the filtering happens on the results instead. +/// +public static class VideoOcrObservationFilter +{ + // Fraction of an observation's box that must clear the brightness minimum. Subtitle + // glyph cores cover well over a tenth of their box; sub-threshold scene text has ~0. + private const double MinBrightFraction = 0.01; + + public static List FilterByBrightness( + IReadOnlyList observations, SKBitmap bitmap, int brightnessMinimum) + { + if (brightnessMinimum <= 0 || observations.Count == 0) + { + return observations.ToList(); + } + + // No never-empty fallback here (unlike the Paddle confidence cut): every frame that + // reaches OCR has bright pixels - the grouper classified it non-blank with the same + // threshold - so when no observation's box covers any of them, the bright thing on + // screen is not text, and dropping every observation is the correct answer. + var pixels = bitmap.Pixels; + var width = bitmap.Width; + var height = bitmap.Height; + + return observations + .Where(o => GetBrightFraction(o, pixels, width, height, brightnessMinimum) >= MinBrightFraction) + .ToList(); + } + + /// + /// Fraction of the observation's box pixels at or above the brightness minimum. The box + /// is in Vision's normalized bottom-left-origin coordinates. + /// + internal static double GetBrightFraction(AppleVisionObservation observation, SKColor[] pixels, int width, int height, int brightnessMinimum) + { + var x0 = Math.Clamp((int)Math.Floor(observation.Left * width), 0, width - 1); + var x1 = Math.Clamp((int)Math.Ceiling(observation.Right * width), x0 + 1, width); + var y0 = Math.Clamp((int)Math.Floor((1.0 - Math.Max(observation.Top, observation.Bottom)) * height), 0, height - 1); + var y1 = Math.Clamp((int)Math.Ceiling((1.0 - Math.Min(observation.Top, observation.Bottom)) * height), y0 + 1, height); + + long bright = 0; + long total = 0; + for (var y = y0; y < y1; y++) + { + var row = y * width; + for (var x = x0; x < x1; x++) + { + var c = pixels[row + x]; + if ((c.Red * 299 + c.Green * 587 + c.Blue * 114) / 1000 >= brightnessMinimum) + { + bright++; + } + + total++; + } + } + + return total == 0 ? 0 : bright / (double)total; + } +} diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrTimingRefiner.cs b/src/ui/Features/Video/VideoOcr/VideoOcrTimingRefiner.cs new file mode 100644 index 00000000000..db321078cb6 --- /dev/null +++ b/src/ui/Features/Video/VideoOcr/VideoOcrTimingRefiner.cs @@ -0,0 +1,302 @@ +using Nikse.SubtitleEdit.Logic.Media; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.Features.Video.VideoOcr; + +/// +/// Sharpens the subtitle boundaries after line building. The scan samples the video at a +/// few frames per second, so every start/end time snaps to that grid - 200ms bins at the +/// default 5 fps. For each boundary this re-extracts just the one coarse interval around +/// it at , computes the same brightness masks the grouper uses, and +/// finds the frame where the subtitle actually appears or disappears. No OCR is involved, +/// so the cost is two tiny ffmpeg calls per subtitle. +/// +public static class VideoOcrTimingRefiner +{ + // Used only when a frame's real timestamp could not be parsed from ffmpeg's showinfo. + private const double FallbackStepMs = 20; + + public sealed class Context + { + public string VideoFileName { get; init; } = string.Empty; + + /// Folder with the coarse scan frames ("img%06d.jpg"), used for reference masks. + public string FramesFolder { get; init; } = string.Empty; + + public double CoarseFps { get; init; } + public int BrightnessMinimum { get; init; } + public int ImageSimilarityPercent { get; init; } + + /// The crop/scale part of the extraction filter, identical to the scan's. + public string CropAndScaleFilter { get; init; } = string.Empty; + } + + public static async Task RefineAsync( + List lines, + Context context, + Action? progress, + CancellationToken cancellationToken) + { + if (lines.Count == 0 || context.CoarseFps <= 0 || context.CoarseFps >= 25) + { + return; // the scan grid is already fine enough + } + + var done = 0; + var options = new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount / 2), + }; + + await Parallel.ForEachAsync(lines, options, async (line, ct) => + { + await RefineLineAsync(line, context, ct); + progress?.Invoke(Interlocked.Increment(ref done), lines.Count); + }); + + // Refining both sides of a shared boundary independently can create a small + // overlap; the earlier line's (usually better-anchored) end wins. + for (var i = 1; i < lines.Count; i++) + { + if (lines[i].StartMs < lines[i - 1].EndMs) + { + lines[i].StartMs = lines[i - 1].EndMs; + } + } + } + + private static async Task RefineLineAsync(VideoOcrLineBuilder.OcrLine line, Context context, CancellationToken cancellationToken) + { + var coarseStepMs = 1000.0 / context.CoarseFps; + + // Start: the subtitle was first seen on the coarse frame at StartMs, so the true + // appearance lies within the coarse step before it. + var startReference = LoadCoarseMask(context, (int)Math.Round(line.StartMs * context.CoarseFps / 1000.0)); + if (startReference != null) + { + var windowStart = Math.Max(0, line.StartMs - coarseStepMs); + var frames = await ExtractWindowAsync(context, windowStart, line.StartMs - windowStart + 60, cancellationToken); + var refined = PickTransition(GetSimilarities(frames, startReference, context), findStart: true, FallbackStepMs); + CleanUp(frames); + if (refined.HasValue && refined.Value < line.StartMs) + { + line.StartMs = refined.Value; + } + } + + // End: the subtitle was last seen on the coarse frame before EndMs, so the true + // disappearance lies within the final coarse step. + var endReference = LoadCoarseMask(context, (int)Math.Round(line.EndMs * context.CoarseFps / 1000.0) - 1); + if (endReference != null) + { + var windowStart = Math.Max(0, line.EndMs - coarseStepMs); + var frames = await ExtractWindowAsync(context, windowStart, line.EndMs - windowStart + 60, cancellationToken); + var refined = PickTransition(GetSimilarities(frames, endReference, context), findStart: false, FallbackStepMs); + CleanUp(frames); + if (refined.HasValue && refined.Value < line.EndMs && refined.Value > line.StartMs) + { + line.EndMs = refined.Value; + } + } + } + + /// + /// The pure decision: given the boundary window's frames flagged similar/not-similar to + /// the in-subtitle reference, the subtitle starts at the first similar frame - and ends + /// where the frame after the last similar one begins (that is the moment the text is + /// gone; the fallback step covers a last similar frame at the window's edge). Null when + /// no frame matches (a fade or borderline mask - the coarse time is kept). + /// + internal static double? PickTransition(IReadOnlyList<(double TimeMs, bool IsSimilar)> frames, bool findStart, double fallbackStepMs) + { + if (findStart) + { + foreach (var frame in frames) + { + if (frame.IsSimilar) + { + return frame.TimeMs; + } + } + + return null; + } + + for (var i = frames.Count - 1; i >= 0; i--) + { + if (frames[i].IsSimilar) + { + return i + 1 < frames.Count ? frames[i + 1].TimeMs : frames[i].TimeMs + fallbackStepMs; + } + } + + return null; + } + + private static List<(double TimeMs, bool IsSimilar)> GetSimilarities( + List<(string FileName, double TimeMs)> frames, byte[] reference, Context context) + { + var result = new List<(double, bool)>(frames.Count); + foreach (var frame in frames) + { + var mask = VideoOcrFrameGrouper.MakeThumbnail(frame.FileName, context.BrightnessMinimum); + var similar = mask != null && + mask.Length == reference.Length && + (context.BrightnessMinimum > 0 + ? !VideoOcrFrameGrouper.IsBlank(mask) && + VideoOcrFrameGrouper.GetMaskSimilarityPercent(reference, mask) >= context.ImageSimilarityPercent + : VideoOcrFrameGrouper.GetSimilarityPercent(reference, mask) >= context.ImageSimilarityPercent); + result.Add((frame.TimeMs, similar)); + } + + return result; + } + + private static byte[]? LoadCoarseMask(Context context, int frameIndex) + { + if (frameIndex < 0) + { + return null; + } + + var fileName = Path.Combine(context.FramesFolder, $"img{frameIndex:000000}.jpg"); + if (!File.Exists(fileName)) + { + return null; + } + + var mask = VideoOcrFrameGrouper.MakeThumbnail(fileName, context.BrightnessMinimum); + if (mask == null || (context.BrightnessMinimum > 0 && VideoOcrFrameGrouper.IsBlank(mask))) + { + return null; + } + + return mask; + } + + /// + /// Extracts every source frame in the window (no resampling) and pairs each with its real + /// timestamp, parsed from ffmpeg's showinfo filter. Assuming a uniform grid instead put + /// each label off by up to one source frame - the dominant part of the remaining error. + /// + private static async Task> ExtractWindowAsync( + Context context, double windowStartMs, double lengthMs, CancellationToken cancellationToken) + { + var folder = Path.Combine(context.FramesFolder, "refine_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + + var arguments = "-nostdin -y " + + $"-ss {(windowStartMs / 1000.0).ToString("0.###", CultureInfo.InvariantCulture)} " + + $"-i \"{context.VideoFileName}\" " + + $"-t {(lengthMs / 1000.0).ToString("0.###", CultureInfo.InvariantCulture)} " + + $"-vf \"showinfo,{context.CropAndScaleFilter}\" " + + $"-q:v 2 \"{Path.Combine(folder, "f%03d.jpg")}\""; + + // showinfo logs one line per frame: "... n: 3 ... pts_time:0.125208 ..." - the + // frame's timestamp relative to the seek point. + var frameTimes = new Dictionary(); + using var process = FfmpegGenerator.GetProcess(arguments, (_, e) => + { + if (e.Data == null || !e.Data.Contains("pts_time:")) + { + return; + } + + var match = ShowInfoRegex.Match(e.Data); + if (match.Success && + int.TryParse(match.Groups[1].Value, out var frameNumber) && + double.TryParse(match.Groups[2].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var ptsSeconds)) + { + lock (frameTimes) + { + frameTimes[frameNumber] = windowStartMs + Math.Max(0, ptsSeconds) * 1000.0; + } + } + }); + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + try + { + if (!process.HasExited) + { + process.Kill(true); + } + } + catch + { + // ignore + } + + throw; + } + + var files = Directory.GetFiles(folder, "*.jpg").OrderBy(p => p, StringComparer.Ordinal).ToList(); + if (files.Count == 0) + { + // CleanUp works off the first frame's path, so with no frames the folder was never + // removed - one leaked "refine_" directory per boundary ffmpeg produced nothing for. + try + { + Directory.Delete(folder, true); + } + catch + { + // ignore + } + + return new List<(string, double)>(); + } + + var result = new List<(string, double)>(files.Count); + for (var i = 0; i < files.Count; i++) + { + // Output file f001.jpg is showinfo frame n:0. Frames whose line got lost fall + // back to a uniform grid - still within one coarse interval of the truth. + double time; + lock (frameTimes) + { + time = frameTimes.TryGetValue(i, out var t) ? t : windowStartMs + i * FallbackStepMs; + } + + result.Add((files[i], time)); + } + + return result; + } + + private static readonly System.Text.RegularExpressions.Regex ShowInfoRegex = new( + @" n:\s*(\d+).*?\spts_time:([0-9.]+)", + System.Text.RegularExpressions.RegexOptions.Compiled, + TimeSpan.FromSeconds(1)); + + private static void CleanUp(List<(string FileName, double TimeMs)> frames) + { + if (frames.Count == 0) + { + return; + } + + try + { + Directory.Delete(Path.GetDirectoryName(frames[0].FileName)!, true); + } + catch + { + // ignore + } + } +} diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs b/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs index d5007e8564b..ff70bddf4fb 100644 --- a/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs +++ b/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs @@ -10,6 +10,8 @@ using Nikse.SubtitleEdit.Features.Ocr.Download; using Nikse.SubtitleEdit.Features.Ocr.Engines; using Nikse.SubtitleEdit.Features.Shared; +using Nikse.SubtitleEdit.Features.SpellCheck; +using Nikse.SubtitleEdit.Features.SpellCheck.GetDictionaries; using Nikse.SubtitleEdit.Features.Translate; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; @@ -28,6 +30,8 @@ using System.Threading.Tasks; using Nikse.SubtitleEdit.UiLogic.LlamaCpp; using Nikse.SubtitleEdit.UiLogic.Media; +using Nikse.SubtitleEdit.UiLogic.Ocr.FixEngine; +using Nikse.SubtitleEdit.UiLogic.SpellCheck; namespace Nikse.SubtitleEdit.Features.Video.VideoOcr; @@ -40,9 +44,15 @@ public partial class VideoOcrViewModel : ObservableObject [ObservableProperty] private bool _isGlmEngine; [ObservableProperty] private bool _isLlamaCppEngine; [ObservableProperty] private bool _isCrispEmbedEngine; + [ObservableProperty] private bool _isAppleVisionEngine; [ObservableProperty] private string _selectedEngineDescription; + [ObservableProperty] private ObservableCollection _dictionaries; + [ObservableProperty] private SpellCheckDictionaryDisplay? _selectedDictionary; + [ObservableProperty] private bool _doFixOcrErrors; [ObservableProperty] private ObservableCollection _paddleLanguages; [ObservableProperty] private OcrLanguage2? _selectedPaddleLanguage; + [ObservableProperty] private ObservableCollection _appleVisionLanguages; + [ObservableProperty] private OcrLanguage2? _selectedAppleVisionLanguage; [ObservableProperty] private string _ollamaUrl; [ObservableProperty] private string _ollamaModel; [ObservableProperty] private string _ollamaLanguage; @@ -87,7 +97,12 @@ public partial class VideoOcrViewModel : ObservableObject public CropAreaSelector? CropSelector { get; set; } private string _videoFileName = string.Empty; + + /// The video being OCR'ed - the window shows its file name in the title. + public string VideoFileName => _videoFileName; private CancellationTokenSource _cancellationTokenSource = new(); + private readonly ISpellCheckManager _spellCheckManager; + private readonly IOcrFixEngine _ocrFixEngine; private Process? _ffmpegProcess; private long _extractedFrames; private readonly DispatcherTimer _previewTimer; @@ -104,14 +119,19 @@ public partial class VideoOcrViewModel : ObservableObject private readonly IWindowService _windowService; - public VideoOcrViewModel(IWindowService windowService) + public VideoOcrViewModel(IWindowService windowService, ISpellCheckManager spellCheckManager, IOcrFixEngine ocrFixEngine) { _windowService = windowService; + _spellCheckManager = spellCheckManager; + _ocrFixEngine = ocrFixEngine; + Dictionaries = new ObservableCollection(); Engines = new ObservableCollection(VideoOcrEngineItem.GetEngines()); SelectedEngine = Engines[0]; PaddleLanguages = new ObservableCollection(PaddleOcr.GetLanguages()); SelectedPaddleLanguage = PaddleLanguages.FirstOrDefault(p => p.Code == "en"); + AppleVisionLanguages = new ObservableCollection(AppleVisionOcr.GetLanguages().OrderBy(p => p.ToString())); + SelectedAppleVisionLanguage = AppleVisionLanguages.FirstOrDefault(p => p.Code == "en-US") ?? AppleVisionLanguages.FirstOrDefault(); Lines = new ObservableCollection(); OllamaUrl = string.Empty; @@ -124,7 +144,19 @@ public VideoOcrViewModel(IWindowService windowService) LlamaCppModels = new ObservableCollection(); LlamaCppLanguage = string.Empty; LlamaCppServerButtonText = Se.Language.General.StartServer; - CrispEmbedBackends = new ObservableCollection(CrispEmbedEngine.GetBackends()); + // For burned-in video, only the backends that measured well are offered, best first + // (real-footage clips with burned real SRTs as ground truth, 2026-08-26): GLM-OCR + // 19/24 lines exact at ~1.1 s/frame; DeepSeek-OCR-2 was the close second in the + // 2026-08-12 frame corpus; PP-OCRv6 is the light option (79 MB, detector-based) and + // holds up on ordinary backgrounds. GOT-OCR2 (13/24, 27 phantom lines from textless + // frames) and Qwen3-VL-2B (18/24, 22 phantom lines, 1.7 s/frame) are left to the + // subtitle-bitmap OCR window, whose clean crops they were tuned for. + var videoBackendNames = new[] { "GLM-OCR", "DeepSeek-OCR-2", "PP-OCRv6" }; + CrispEmbedBackends = new ObservableCollection( + videoBackendNames + .Select(name => CrispEmbedEngine.GetBackends().FirstOrDefault(p => p.Name == name)) + .Where(p => p != null) + .Select(p => p!)); CrispEmbedModels = new ObservableCollection(); ProgressText = string.Empty; PreviewPositionText = string.Empty; @@ -224,12 +256,13 @@ partial void OnSelectedEngineChanged(VideoOcrEngineItem value) IsGlmEngine = value.EngineType == OcrEngineType.Glm; IsLlamaCppEngine = value.EngineType == OcrEngineType.LlamaCpp; IsCrispEmbedEngine = value.EngineType == OcrEngineType.CrispEmbed; + IsAppleVisionEngine = value.EngineType == OcrEngineType.AppleVision; SelectedEngineDescription = value.Description; if (IsLlamaCppEngine && LlamaCppModels.Count == 0) { var savedModelName = Path.GetFileName(Se.Settings.Video.VideoOcr.LlamaCppModel); - SelectedLlamaCppModel = LlamaCppDownloadHelper.PopulateModels(LlamaCppModels, LlamaCppServerManager.OcrModels, savedModelName); + SelectedLlamaCppModel = LlamaCppDownloadHelper.PopulateModels(LlamaCppModels, LlamaCppServerManager.GetAllOcrModels(), savedModelName); } if (IsCrispEmbedEngine && SelectedCrispEmbedBackend == null) @@ -349,7 +382,7 @@ private async Task DownloadLlamaCpp() if (downloaded != null) { var selectName = string.IsNullOrEmpty(downloaded) ? model?.FileName : downloaded; - SelectedLlamaCppModel = LlamaCppDownloadHelper.PopulateModels(LlamaCppModels, LlamaCppServerManager.OcrModels, selectName); + SelectedLlamaCppModel = LlamaCppDownloadHelper.PopulateModels(LlamaCppModels, LlamaCppServerManager.GetAllOcrModels(), selectName); (Window as VideoOcrWindow)?.RefreshDownloadDots(); } } @@ -423,7 +456,7 @@ private async Task EnsureLlamaCppReady() } } - SelectedLlamaCppModel = LlamaCppDownloadHelper.PopulateModels(LlamaCppModels, LlamaCppServerManager.OcrModels, model.FileName); + SelectedLlamaCppModel = LlamaCppDownloadHelper.PopulateModels(LlamaCppModels, LlamaCppServerManager.GetAllOcrModels(), model.FileName); (Window as VideoOcrWindow)?.RefreshDownloadDots(); try @@ -534,6 +567,9 @@ private async Task TestOcr() return; } + // Fresh token before preparing the engine - see StartOcr. + _cancellationTokenSource = new CancellationTokenSource(); + var engineOk = await EnsureEngineIsAvailable(); if (!engineOk) { @@ -542,13 +578,17 @@ private async Task TestOcr() ClampSelection(); - _cancellationTokenSource = new CancellationTokenSource(); var cancellationToken = _cancellationTokenSource.Token; IsRunning = true; ProgressText = Se.Language.Video.VideoOcr.TestOcrRunning; - var frameFileName = Path.Combine(Path.GetTempPath(), "se_video_ocr_test_" + Guid.NewGuid() + ".jpg"); + // Its own scratch folder, not the shared temp root: OcrGroups derives the masked-copy + // folder from the frame's directory, so a brightness minimum wrote full-resolution + // masked JPEGs straight into %TEMP%/masked, which nothing ever cleaned up. + var testFolder = Path.Combine(Path.GetTempPath(), "se_video_ocr_test_" + Guid.NewGuid()); + Directory.CreateDirectory(testFolder); + var frameFileName = Path.Combine(testFolder, "frame.jpg"); try { await ExtractSingleFrame(frameFileName, PreviewPositionSeconds, cancellationToken); @@ -586,7 +626,7 @@ await MessageBox.Show( try { - File.Delete(frameFileName); + Directory.Delete(testFolder, true); } catch { @@ -597,17 +637,10 @@ await MessageBox.Show( private async Task ExtractSingleFrame(string outputFileName, double positionSeconds, CancellationToken cancellationToken) { - var scale = string.Empty; - var maxImageWidth = Se.Settings.Video.VideoOcr.MaxImageWidth; - if (maxImageWidth > 0 && SelectionWidth > maxImageWidth) - { - scale = $",scale={maxImageWidth}:-2"; - } - // -ss before -i: seek in the demuxer, so a test frame late in a long video is still fast. var arguments = $"-nostdin -y -ss {positionSeconds.ToString("0.###", CultureInfo.InvariantCulture)} " + $"-i \"{_videoFileName}\" " + - $"-vf \"crop={SelectionWidth}:{SelectionHeight}:{SelectionX}:{SelectionY}{scale}\" " + + $"-vf \"{GetCropAndScaleFilter()}\" " + $"-frames:v 1 -q:v 2 \"{outputFileName}\""; Se.WriteToolsLog("Video OCR: extracting test frame - ffmpeg " + arguments); @@ -649,6 +682,11 @@ private async Task StartOcr() return; } + // Fresh token first: EnsureEngineIsAvailable passes _cancellationTokenSource.Token down + // to the llama.cpp server start, so after a cancel it threw immediately on the stale + // cancelled token and the scan could never be started again. + _cancellationTokenSource = new CancellationTokenSource(); + var engineOk = await EnsureEngineIsAvailable(); if (!engineOk) { @@ -658,7 +696,6 @@ private async Task StartOcr() ClampSelection(); SaveSettings(); - _cancellationTokenSource = new CancellationTokenSource(); var cancellationToken = _cancellationTokenSource.Token; IsRunning = true; @@ -699,10 +736,39 @@ private async Task StartOcr() }, cancellationToken), cancellationToken); + InitializeOcrFixEngine(contextSubtitle: null); + await RunOcr(groups, cancellationToken); var mergedLines = VideoOcrLineBuilder.Build(groups, FramesPerSecond, TextSimilarityPercent, MaxGapMs, MinDurationMs); + var lastRefineUpdate = 0L; + await VideoOcrTimingRefiner.RefineAsync( + mergedLines, + new VideoOcrTimingRefiner.Context + { + VideoFileName = _videoFileName, + FramesFolder = framesFolder, + CoarseFps = FramesPerSecond, + BrightnessMinimum = BrightnessMinimum, + ImageSimilarityPercent = Se.Settings.Video.VideoOcr.ImageSimilarityPercent, + CropAndScaleFilter = GetCropAndScaleFilter(), + }, + (current, total) => + { + var now = Environment.TickCount64; + if (now - lastRefineUpdate > 200 || current == total) + { + lastRefineUpdate = now; + Dispatcher.UIThread.Post(() => + { + ProgressText = string.Format(Se.Language.Video.VideoOcr.RefiningTimingXY, current, total); + ProgressValue = total == 0 ? 0 : current * 100.0 / total; + }); + } + }, + cancellationToken); + var positionTag = string.Empty; if (AddAssaPositionTag) { @@ -715,15 +781,19 @@ private async Task StartOcr() var number = 1; foreach (var line in mergedLines) { - Lines.Add(new VideoOcrLineItem + var item = new VideoOcrLineItem { Number = number++, StartTime = TimeSpan.FromMilliseconds(line.StartMs), EndTime = TimeSpan.FromMilliseconds(line.EndMs), Text = positionTag + line.Text, - }); + }; + item.PropertyChanged += LineItemPropertyChanged; + Lines.Add(item); } + ApplyOcrFixes(); + IsRunning = false; IsOkEnabled = Lines.Count > 0; ProgressValue = 0; @@ -742,7 +812,11 @@ await MessageBox.Show( catch (OperationCanceledException) { IsRunning = false; - IsOkEnabled = Lines.Count > 0; + // Lines still holds the raw per-frame-group preview rows - the merge/filter/tag/ + // refine pipeline only rebuilds it on completion. Inserting those would produce + // near-duplicate paragraphs on the scan grid with no position tag, so a cancelled + // scan offers nothing to apply. + IsOkEnabled = false; ProgressValue = 0; ProgressText = string.Empty; } @@ -774,7 +848,9 @@ await MessageBox.Show( } } - private async Task ExtractFrames(string framesFolder, CancellationToken cancellationToken) + /// The crop (and optional downscale) part of the extraction filter - shared by + /// the scan, the test frame and the timing refinement so they all see the same pixels. + private string GetCropAndScaleFilter() { var scale = string.Empty; var maxImageWidth = Se.Settings.Video.VideoOcr.MaxImageWidth; @@ -783,12 +859,17 @@ private async Task ExtractFrames(string framesFolder, CancellationToken cancella scale = $",scale={maxImageWidth}:-2"; } + return $"crop={SelectionWidth}:{SelectionHeight}:{SelectionX}:{SelectionY}{scale}"; + } + + private async Task ExtractFrames(string framesFolder, CancellationToken cancellationToken) + { // JPEG (near-lossless q=2) instead of PNG: a long video at 5 fps produces tens of // thousands of frames, and PNG would need gigabytes of temp disk space. var outputPattern = Path.Combine(framesFolder, "img%06d.jpg"); var arguments = $"-nostdin -y -i \"{_videoFileName}\" " + $"-vf \"fps={FramesPerSecond.ToString(CultureInfo.InvariantCulture)}," + - $"crop={SelectionWidth}:{SelectionHeight}:{SelectionX}:{SelectionY}{scale}\" " + + $"{GetCropAndScaleFilter()}\" " + $"-q:v 2 -start_number 0 \"{outputPattern}\""; _extractedFrames = 0; @@ -889,13 +970,15 @@ void AddPreviewLine(VideoOcrFrameGroup group) Dispatcher.UIThread.Post(() => { - Lines.Add(new VideoOcrLineItem + var item = new VideoOcrLineItem { Number = Lines.Count + 1, StartTime = TimeSpan.FromMilliseconds(group.GetStartMs(FramesPerSecond)), EndTime = TimeSpan.FromMilliseconds(group.GetEndMs(FramesPerSecond)), Text = group.Text, - }); + }; + ApplyFixToItem(item, Lines.Count); + Lines.Add(item); }); } @@ -930,18 +1013,48 @@ private async Task OcrGroups(List ocrGroups, Action reportPr if (group != null) { group.Text = VideoOcrLineBuilder.CleanOcrResult(p.Text); + group.Confidence = p.Confidence; reportProgress(); addPreviewLine(group); } }); + // Black out everything below the brightness minimum before recognition, like + // VideOCR does: Paddle's detector otherwise picks up darker scene text (shirt + // prints, credits) and prepends it to subtitles. Only for the Paddle path - + // vision/VLM engines measured better on the natural frames. + var ocrFileNames = ocrGroups.Select(g => g.RepresentativeFileName).ToList(); + if (BrightnessMinimum > 0 && ocrGroups.Count > 0) + { + var maskedFolder = Path.Combine( + Path.GetDirectoryName(ocrGroups[0].RepresentativeFileName) ?? string.Empty, "masked"); + Directory.CreateDirectory(maskedFolder); + Parallel.For(0, ocrGroups.Count, + new ParallelOptions { CancellationToken = cancellationToken, MaxDegreeOfParallelism = Environment.ProcessorCount }, + i => + { + var source = ocrGroups[i].RepresentativeFileName; + var target = Path.Combine(maskedFolder, Path.GetFileName(source)); + if (VideoOcrFrameGrouper.WriteMaskedCopy(source, target, BrightnessMinimum)) + { + ocrFileNames[i] = target; + } + }); + } + // The frames are already image files on disk, so pass them by file name - // one batch, no per-image decode/encode, memory stays flat. var batch = ocrGroups - .Select((g, i) => new PaddleOcrBatchInput { Index = i, SourceFileName = g.RepresentativeFileName }) + .Select((g, i) => new PaddleOcrBatchInput { Index = i, SourceFileName = ocrFileNames[i] }) .ToList(); - var paddleOcr = new PaddleOcr(); + var paddleOcr = new PaddleOcr + { + // Low-confidence regions in a video frame are nearly always background + // clutter (scene text, logos) rather than subtitle text - same cut VideOCR + // applies. Only for Video OCR; the subtitle-bitmap OCR window keeps everything. + MinConfidencePercent = 75, + }; await paddleOcr.OcrBatch(engineType, batch, language, mode, progress, cancellationToken); if (!string.IsNullOrEmpty(paddleOcr.Error) && ocrGroups.All(p => string.IsNullOrEmpty(p.Text))) { @@ -953,14 +1066,14 @@ private async Task OcrGroups(List ocrGroups, Action reportPr using var ollamaOcr = new OllamaOcr(Se.Settings.Ocr.OllamaOcrTimeoutMinutes); await RunLlmOcr(ocrGroups, group => OcrWithBitmap(group, bitmap => ollamaOcr.Ocr(bitmap, OllamaUrl, OllamaModel, OllamaLanguage, cancellationToken)), - () => ollamaOcr.Error, reportProgress, addPreviewLine, cancellationToken); + () => ollamaOcr.Error, reportProgress, addPreviewLine, cancellationToken, CountUnknownWords); } else if (engineType == OcrEngineType.Glm) { var glmOcr = new GlmOcr(GlmApiKey); await RunLlmOcr(ocrGroups, group => glmOcr.Ocr(group.RepresentativeFileName, GlmUrl, GlmModel, GlmLanguage, cancellationToken), - () => glmOcr.Error, reportProgress, addPreviewLine, cancellationToken); + () => glmOcr.Error, reportProgress, addPreviewLine, cancellationToken, CountUnknownWords); } else if (engineType == OcrEngineType.LlamaCpp) { @@ -972,12 +1085,37 @@ await RunLlmOcr(ocrGroups, group => var prompt = Se.Settings.Ocr.LlamaCppOcrPrompt; await RunLlmOcr(ocrGroups, group => OcrWithBitmap(group, bitmap => llamaCppOcr.Ocr(bitmap, url, modelName, LlamaCppLanguage, prompt, cancellationToken)), - () => llamaCppOcr.Error, reportProgress, addPreviewLine, cancellationToken); + () => llamaCppOcr.Error, reportProgress, addPreviewLine, cancellationToken, CountUnknownWords); } else if (engineType == OcrEngineType.CrispEmbed) { await OcrGroupsWithCrispEmbed(ocrGroups, reportProgress, addPreviewLine, cancellationToken); } + else if (engineType == OcrEngineType.AppleVision) + { + // Vision is synchronous, in-process CPU work with no server or API behind it, so + // each frame goes to the thread pool rather than blocking the caller for the whole + // scan. RunLlmOcr's fail-fast-on-first-frame check does nothing here (there is no + // error string to report) but the loop, progress and preview are the same. + var languageCode = SelectedAppleVisionLanguage?.Code ?? string.Empty; + var brightnessMinimum = BrightnessMinimum; + await RunLlmOcr(ocrGroups, + group => Task.Run(() => OcrFrameWithAppleVision(group, languageCode, brightnessMinimum, cancellationToken), cancellationToken), + () => string.Empty, reportProgress, addPreviewLine, cancellationToken, CountUnknownWords); + } + } + + private static string OcrFrameWithAppleVision(VideoOcrFrameGroup group, string languageCode, int brightnessMinimum, CancellationToken cancellationToken) + { + using var bitmap = SKBitmap.Decode(group.RepresentativeFileName); + if (bitmap == null) + { + return string.Empty; + } + + var observations = AppleVisionOcr.OcrObservations(bitmap, languageCode, fast: false, cancellationToken); + var kept = VideoOcrObservationFilter.FilterByBrightness(observations, bitmap, brightnessMinimum); + return AppleVisionTextLayout.Compose(kept); } /// @@ -1016,7 +1154,7 @@ private async Task OcrGroupsWithCrispEmbed( } await RunLlmOcr(ocrGroups, group => OcrWithBitmap(group, bitmap => engine.Ocr(bitmap, cancellationToken)), - () => engine.Error, reportProgress, addPreviewLine, cancellationToken); + () => engine.Error, reportProgress, addPreviewLine, cancellationToken, CountUnknownWords); } private static async Task OcrWithBitmap(VideoOcrFrameGroup group, Func> ocr) @@ -1030,13 +1168,14 @@ private static async Task OcrWithBitmap(VideoOcrFrameGroup group, Func ocrGroups, Func> ocr, Func getError, Action reportProgress, Action addPreviewLine, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Func? countUnknownWords = null) { var isFirst = true; foreach (var group in ocrGroups) @@ -1045,6 +1184,63 @@ private static async Task RunLlmOcr( group.Text = VideoOcrLineBuilder.CleanOcrResult(await ocr(group)); + // An empty result on a group the mask says holds text is often just an unlucky + // representative frame - e.g. white text drifting over a white wall mid-group - + // so try frames from other parts of the group before giving up. Measured: the + // one subtitle a 21-minute episode lost was read perfectly from the frame at + // three quarters of its group. + if (string.IsNullOrEmpty(group.Text) && group.EndFrame - group.StartFrame >= 2) + { + var span = group.EndFrame - group.StartFrame; + foreach (var alternateIndex in new[] { group.StartFrame + span * 3 / 4, group.StartFrame + span / 4 }) + { + var alternateFileName = group.GetSiblingFrameFileName(alternateIndex); + if (alternateFileName == group.RepresentativeFileName || !File.Exists(alternateFileName)) + { + continue; + } + + cancellationToken.ThrowIfCancellationRequested(); + group.RepresentativeFileName = alternateFileName; + group.Text = VideoOcrLineBuilder.CleanOcrResult(await ocr(group)); + if (!string.IsNullOrEmpty(group.Text)) + { + break; + } + } + } + else if (!string.IsNullOrEmpty(group.Text) && group.EndFrame - group.StartFrame >= 2) + { + // Verify a non-empty read against a second frame of the group. Real subtitle + // text is stable across the group's frames, so the two reads agree apart from + // OCR jitter - while hallucinated ghosts (a vision model inventing text from a + // logo or scoreboard) come out different on every frame. On disagreement the + // longer read wins when it is substantial - a real line polluted by changing + // scene text (rolling credits) must survive, as must long text whose verify + // frame happened to be unreadable - and anything short is dropped as a ghost. + var verified = await OcrVerificationFrame(group, ocr, cancellationToken); + // An empty verification read carries no evidence of a ghost - the verify frame + // was simply unreadable (a fade in/out). Scoring it as a disagreement deleted + // correctly-read short lines outright; the retry path above already treats an + // empty read as bad luck rather than proof. + if (!string.IsNullOrWhiteSpace(verified)) + { + var similarity = VideoOcrLineBuilder.GetTextSimilarityPercent(group.Text, verified); + if (similarity < TextSimilarityDefaultPercent) + { + var best = CountLettersAndDigits(verified) > CountLettersAndDigits(group.Text) ? verified : group.Text; + group.Text = CountLettersAndDigits(best) >= 10 ? best : string.Empty; + } + else if (verified != group.Text && countUnknownWords != null && + countUnknownWords(verified) < countUnknownWords(group.Text)) + { + // The two reads agree apart from OCR jitter ("I'think" / "I think") - + // the spell check arbitrates: the read the dictionary knows more of wins. + group.Text = verified; + } + } + } + // Fail fast on a broken engine (wrong API key/URL) instead of grinding // through the whole video and reporting "no subtitles found". var error = getError(); @@ -1059,6 +1255,51 @@ private static async Task RunLlmOcr( } } + // The verification threshold uses the default text similarity rather than the user's + // merge setting: verification compares two reads of the SAME frame content, where only + // OCR jitter separates them, so the bar is independent of how aggressively the user + // wants consecutive lines merged. + private const int TextSimilarityDefaultPercent = 80; + + private static async Task OcrVerificationFrame( + VideoOcrFrameGroup group, + Func> ocr, + CancellationToken cancellationToken) + { + var span = group.EndFrame - group.StartFrame; + foreach (var alternateIndex in new[] { group.StartFrame + span * 3 / 4, group.StartFrame + span / 4 }) + { + var alternateFileName = group.GetSiblingFrameFileName(alternateIndex); + if (alternateFileName == group.RepresentativeFileName || !File.Exists(alternateFileName)) + { + continue; + } + + cancellationToken.ThrowIfCancellationRequested(); + var original = group.RepresentativeFileName; + group.RepresentativeFileName = alternateFileName; + var text = VideoOcrLineBuilder.CleanOcrResult(await ocr(group)); + group.RepresentativeFileName = original; + return text; + } + + return null; + } + + private static int CountLettersAndDigits(string text) + { + var count = 0; + foreach (var ch in text) + { + if (char.IsLetterOrDigit(ch)) + { + count++; + } + } + + return count; + } + private async Task EnsureEngineIsAvailable() { var engineType = SelectedEngine.EngineType; @@ -1110,6 +1351,9 @@ private void LoadSettings() var settings = Se.Settings.Video.VideoOcr; SelectedEngine = Engines.FirstOrDefault(p => p.EngineType.ToString() == settings.Engine) ?? Engines[0]; OnSelectedEngineChanged(SelectedEngine); + SelectedAppleVisionLanguage = AppleVisionLanguages.FirstOrDefault(p => p.Code == settings.AppleVisionLanguage) + ?? SelectedAppleVisionLanguage; + var paddleLanguage = PaddleOcr.NormalizeLanguageCode(settings.PaddleLanguage); SelectedPaddleLanguage = PaddleLanguages.FirstOrDefault(p => p.Code == paddleLanguage) ?? PaddleLanguages.FirstOrDefault(p => p.Code == "en"); @@ -1127,6 +1371,93 @@ private void LoadSettings() MaxGapMs = Math.Clamp(settings.MaxGapMs, 0, 10_000); MinDurationMs = Math.Clamp(settings.MinDurationMs, 0, 10_000); AddAssaPositionTag = settings.AddAssaPositionTag; + DoFixOcrErrors = settings.FixOcrErrors; + LoadDictionaries(settings.DictionaryFileName); + } + + /// + /// Fills the spell check dictionary combo: "- None -" first, then the downloaded + /// dictionaries. The saved pick wins; otherwise the first dictionary matching the + /// engine's OCR language is chosen, so post-processing works without any setup for + /// users who already have the dictionary. + /// + private void LoadDictionaries(string savedDictionaryFileName) + { + Dictionaries.Clear(); + Dictionaries.Add(new SpellCheckDictionaryDisplay + { + Name = $"- {Se.Language.General.None} -", + DictionaryFileName = string.Empty, + }); + + List languages; + try + { + languages = _spellCheckManager.GetDictionaryLanguages(Se.DictionariesFolder); + } + catch + { + languages = new List(); + } + + Dictionaries.AddRange(LanguageFavoritesHelper.Order(languages, d => SpellCheckDictionaryDisplay.GetTwoLetterLanguageCode(d))); + + if (!string.IsNullOrEmpty(savedDictionaryFileName)) + { + SelectedDictionary = Dictionaries.FirstOrDefault(d => d.DictionaryFileName == savedDictionaryFileName); + } + + SelectedDictionary ??= Dictionaries.FirstOrDefault(d => + SpellCheckDictionaryDisplay.GetTwoLetterLanguageCode(d) == GetOcrTwoLetterLanguageCode()) + ?? Dictionaries[0]; + } + + [RelayCommand] + private async Task DownloadDictionary() + { + if (Window == null) + { + return; + } + + var result = await _windowService.ShowDialogAsync(Window); + if (result.OkPressed && result.SelectedDictionary != null) + { + LoadDictionaries(Se.Settings.Video.VideoOcr.DictionaryFileName); + + // Select the just-downloaded dictionary by its file name - matching the display + // name fails on non-English UIs (the list shows the localized culture name). + var downloadedFileName = Path.GetFileName(result.SpellCheckDictionary?.DictionaryFileName ?? string.Empty); + SelectedDictionary = + (!string.IsNullOrEmpty(downloadedFileName) + ? Dictionaries.FirstOrDefault(d => string.Equals( + Path.GetFileName(d.DictionaryFileName), downloadedFileName, StringComparison.OrdinalIgnoreCase)) + : null) + ?? Dictionaries.FirstOrDefault(d => + d.Name.Contains(result.SelectedDictionary.EnglishName, StringComparison.OrdinalIgnoreCase) || + d.Name.Contains(result.SelectedDictionary.NativeName, StringComparison.OrdinalIgnoreCase)) + ?? SelectedDictionary; + } + } + + /// The current engine's OCR language as a two-letter code, for the dictionary auto-pick. + private string GetOcrTwoLetterLanguageCode() + { + var engineType = SelectedEngine?.EngineType; + if (engineType is OcrEngineType.PaddleOcrStandalone or OcrEngineType.PaddleOcrPython) + { + var code = SelectedPaddleLanguage?.Code ?? "en"; + return code.Length >= 2 ? code[..2] : "en"; + } + + if (engineType == OcrEngineType.AppleVision) + { + var code = SelectedAppleVisionLanguage?.Code ?? "en"; + return code.Length >= 2 ? code[..2] : "en"; + } + + // The VLM engines take a language name ("English"); default to English. + return "en"; } private void SaveSettings() @@ -1134,6 +1465,7 @@ private void SaveSettings() var settings = Se.Settings.Video.VideoOcr; settings.Engine = SelectedEngine.EngineType.ToString(); settings.PaddleLanguage = SelectedPaddleLanguage?.Code ?? "en"; + settings.AppleVisionLanguage = SelectedAppleVisionLanguage?.Code ?? settings.AppleVisionLanguage; settings.OllamaUrl = OllamaUrl; settings.OllamaModel = OllamaModel; settings.OllamaLanguage = OllamaLanguage; @@ -1154,6 +1486,8 @@ private void SaveSettings() settings.MaxGapMs = MaxGapMs; settings.MinDurationMs = MinDurationMs; settings.AddAssaPositionTag = AddAssaPositionTag; + settings.FixOcrErrors = DoFixOcrErrors; + settings.DictionaryFileName = SelectedDictionary?.DictionaryFileName ?? string.Empty; if (VideoWidth > 0 && VideoHeight > 0) { @@ -1211,6 +1545,208 @@ internal void DeleteLines(List items) } /// Moves the preview to the given line's start time (double-click in the grid). + /// + /// Runs the OCR fix engine (replace lists + spell check) over the result lines: each + /// line's text is replaced by the fixed text, and lines with words the dictionary does + /// not know are marked so the table can tint them. No per-word prompting here - a video + /// run produces hundreds of lines, so unknown words are marked for in-place fixing + /// instead. + /// + internal void ApplyOcrFixes() + { + if (!_ocrFixEngine.IsLoaded() && Lines.Count > 0) + { + InitializeOcrFixEngine(contextSubtitle: null); + } + + if (!_ocrFixEngine.IsLoaded()) + { + return; + } + + // Re-initialize with the full result as context so the engine's name lists and + // word statistics see the whole subtitle, then fix each line. + var contextSubtitle = new Subtitle(); + foreach (var line in Lines) + { + contextSubtitle.Paragraphs.Add(new Paragraph(line.Text, line.StartTime.TotalMilliseconds, line.EndTime.TotalMilliseconds)); + } + + InitializeOcrFixEngine(contextSubtitle); + for (var i = 0; i < Lines.Count; i++) + { + ApplyFixToItem(Lines[i], i); + } + } + + /// Loads the OCR fix engine for the chosen dictionary (or unloads it when the + /// fix is off / no dictionary is chosen). Runs before OCR starts, so lines are fixed and + /// colored as they appear. + private void InitializeOcrFixEngine(Subtitle? contextSubtitle) + { + if (!DoFixOcrErrors || + SelectedDictionary is not { } dictionary || + string.IsNullOrEmpty(dictionary.DictionaryFileName)) + { + _ocrFixEngine.Unload(); + return; + } + + try + { + _ocrFixEngine.Initialize(contextSubtitle ?? new Subtitle(), dictionary.GetThreeLetterCode(), dictionary); + } + catch (Exception exception) + { + Se.LogError(exception, "Video OCR: could not initialize the OCR fix engine"); + } + } + + /// Runs one line through the fix engine: the fixed text replaces the raw OCR + /// text and the per-word result drives the coloring. + private void ApplyFixToItem(VideoOcrLineItem item, int index) + { + if (!_ocrFixEngine.IsLoaded()) + { + return; + } + + try + { + OcrFixLineResult result; + lock (_ocrFixEngineLock) + { + result = _ocrFixEngine.FixOcrErrors(index, item.Text, doTryToGuessUnknownWords: false); + } + + item.Text = result.GetText(); + item.FixResult = result; + } + catch (Exception exception) + { + Se.LogError(exception, "Video OCR: fix engine failed on a line"); + } + } + + // The fix engine is used both from the OCR worker (spell-check arbitration between two + // frame reads) and from the UI thread (preview-line fixes), so its calls are serialized. + private readonly object _ocrFixEngineLock = new(); + + /// How many words of the text the spell check does not know - the tiebreak + /// between two nearly identical frame reads. 0 when no dictionary is loaded, so the + /// arbitration never favors either read without a spell check behind it. + private int CountUnknownWords(string text) + { + if (!_ocrFixEngine.IsLoaded()) + { + return 0; + } + + try + { + lock (_ocrFixEngineLock) + { + return _ocrFixEngine.FixOcrErrors(0, text, doTryToGuessUnknownWords: false) + .Words.Count(w => w.IsSpellCheckedOk == false); + } + } + catch + { + return 0; + } + } + + /// + /// Re-evaluates the coloring when a line's text changes (the Edit dialog, italic + /// toggle). Only the coloring is updated - the text is left exactly as written. + /// + private void LineItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(VideoOcrLineItem.Text) || + sender is not VideoOcrLineItem item || + item.FixResult == null || + !_ocrFixEngine.IsLoaded()) + { + return; + } + + try + { + OcrFixLineResult result; + lock (_ocrFixEngineLock) + { + result = _ocrFixEngine.FixOcrErrors(Lines.IndexOf(item), item.Text, doTryToGuessUnknownWords: false); + } + + // Only keep the per-word coloring when the engine's view of the line matches the + // text exactly - the cell renders the result's words, and a mismatch (the user + // deliberately typed something the engine would "fix") would display stale text. + item.FixResult = result.GetText() == item.Text ? result : null; + } + catch + { + // ignore - coloring is best-effort + } + } + + /// Wraps the lines in italic tags - or unwraps them when every line is already italic. + internal static void ToggleItalic(List items) + { + if (items.Count == 0) + { + return; + } + + var allItalic = items.All(p => IsFullyItalic(p.Text)); + foreach (var item in items) + { + if (allItalic) + { + var text = item.Text.Trim(); + item.Text = text[3..^4].Trim(); + } + else if (!IsFullyItalic(item.Text)) + { + item.Text = "" + item.Text.Trim() + ""; + } + } + } + + private static bool IsFullyItalic(string text) + { + var trimmed = text.Trim(); + return trimmed.StartsWith("", StringComparison.OrdinalIgnoreCase) && + trimmed.EndsWith("", StringComparison.OrdinalIgnoreCase) && + + // "a b c" starts and ends with tags but is not fully italic. + trimmed.IndexOf("", StringComparison.OrdinalIgnoreCase) == trimmed.Length - 4; + } + + /// Opens the text of a line in a small edit window (multi-line texts do not + /// edit comfortably inside a table row). + internal async Task EditLine(VideoOcrLineItem item) + { + if (Window == null) + { + return; + } + + var result = await _windowService.ShowDialogAsync(Window, viewModel => + { + viewModel.Initialize( + string.Format(Se.Language.Video.VideoOcr.EditLineX, item.Number), + item.Text, + 500, + 80); + }); + + if (result.OkPressed) + { + item.Text = result.Text.Trim().Replace("\r\n", "\n").Replace('\r', '\n'); + } + } + internal void SeekPreview(VideoOcrLineItem item) { PreviewPositionSeconds = Math.Clamp(item.StartTime.TotalSeconds, 0, DurationSeconds); diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrWindow.cs b/src/ui/Features/Video/VideoOcr/VideoOcrWindow.cs index 0ba2f8f77ab..41735b9878f 100644 --- a/src/ui/Features/Video/VideoOcr/VideoOcrWindow.cs +++ b/src/ui/Features/Video/VideoOcr/VideoOcrWindow.cs @@ -25,7 +25,11 @@ public class VideoOcrWindow : Window public VideoOcrWindow(VideoOcrViewModel vm) { UiUtil.InitializeWindow(this, GetType().Name); - Title = Se.Language.Video.VideoOcr.Title; + // The view model is initialized with the video before the window is built, so the + // file being OCR'ed can go straight into the title. + Title = string.IsNullOrEmpty(vm.VideoFileName) + ? Se.Language.Video.VideoOcr.Title + : $"{Se.Language.Video.VideoOcr.Title} - {System.IO.Path.GetFileName(vm.VideoFileName)}"; CanResize = true; Width = 1100; Height = 800; @@ -76,7 +80,9 @@ public VideoOcrWindow(VideoOcrViewModel vm) Content = grid; - Activated += delegate { _comboEngine?.Focus(); }; // initial focus on an input, not an action button - a focused button clicks on bare Space + UiUtil.FocusOnFirstActivation(this, () => { _comboEngine?.Focus(); }); // initial focus on an input, not an action button - a focused button clicks on bare Space + Loaded += delegate { UiUtil.RestoreWindowPosition(this); }; + Closing += delegate { UiUtil.SaveWindowPosition(this); }; Loaded += (s, e) => vm.OnLoaded(); Closing += (s, e) => vm.OnClosing(); AddHandler(KeyDownEvent, vm.OnKeyDownHandler, RoutingStrategies.Tunnel | RoutingStrategies.Bubble, handledEventsToo: false); @@ -160,13 +166,23 @@ private static Border MakePreviewView(VideoOcrViewModel vm) UiUtil.MakeButton(Se.Language.Video.VideoOcr.BottomThird, vm.SetScanAreaBottomThirdCommand), UiUtil.MakeButton(Se.Language.Video.VideoOcr.BottomHalf, vm.SetScanAreaBottomHalfCommand), UiUtil.MakeButton(Se.Language.Video.VideoOcr.FullFrame, vm.SetScanAreaFullFrameCommand), - UiUtil.MakeButton(Se.Language.Video.VideoOcr.TestOcr, vm.TestOcrCommand) - .WithBindEnabled(nameof(vm.IsRunning), new InverseBooleanConverter()) - .WithMarginLeft(10), scanAreaText, }, }; + // On its own row: sharing the scan-area row squeezed that row's buttons once the + // preview column narrowed. + var testRow = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 5, + Children = + { + UiUtil.MakeButton(Se.Language.Video.VideoOcr.TestOcr, vm.TestOcrCommand) + .WithBindEnabled(nameof(vm.IsRunning), new InverseBooleanConverter()), + }, + }; + var grid = new Grid { RowDefinitions = @@ -174,12 +190,14 @@ private static Border MakePreviewView(VideoOcrViewModel vm) new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, }, RowSpacing = 5, }; grid.Add(imageArea, 0, 0); grid.Add(sliderRow, 1, 0); grid.Add(scanAreaRow, 2, 0); + grid.Add(testRow, 3, 0); return UiUtil.MakeBorderForControl(grid); } @@ -196,7 +214,11 @@ private Border MakeSettingsView(VideoOcrViewModel vm) { Orientation = Orientation.Vertical, Spacing = 4, - Width = 350, + Width = 390, + + // Gutter between the controls and the scrollbar - without it the bar sits + // flush against the numeric up/downs. + Margin = new Thickness(0, 0, 12, 0), }; // The engine picker, plus a settings button for the one engine here with something to @@ -291,32 +313,71 @@ private Border MakeSettingsView(VideoOcrViewModel vm) crispEmbedPanel.Bind(StackPanel.IsVisibleProperty, new Binding(nameof(vm.IsCrispEmbedEngine)) { Source = vm }); panel.Children.Add(crispEmbedPanel); + // Apple Vision settings - language only: the engine is part of macOS, so there is no + // model to pick, nothing to download and no server to start. + var appleVisionPanel = new StackPanel { Orientation = Orientation.Vertical, Spacing = 4 }; + appleVisionPanel.Children.Add(UiUtil.MakeLabel(Se.Language.General.Language)); + appleVisionPanel.Children.Add(UiUtil.MakeComboBox(vm.AppleVisionLanguages, vm, nameof(vm.SelectedAppleVisionLanguage)).WithWidth(330)); + appleVisionPanel.Bind(StackPanel.IsVisibleProperty, new Binding(nameof(vm.IsAppleVisionEngine)) { Source = vm }); + panel.Children.Add(appleVisionPanel); + // Scan settings panel.Children.Add(MakeHeader(Se.Language.Video.VideoOcr.Scan)); panel.Children.Add(MakeSettingRow( Se.Language.Video.VideoOcr.FramesPerSecond, - UiUtil.MakeNumericUpDownInt(1, 30, 5, 120, vm, nameof(vm.FramesPerSecond)))); + UiUtil.MakeNumericUpDownInt(1, 30, 5, 120, vm, nameof(vm.FramesPerSecond)), + Se.Language.Video.VideoOcr.FramesPerSecondHint)); panel.Children.Add(MakeSettingRow( Se.Language.Video.VideoOcr.TextBrightnessMinimum, - UiUtil.MakeNumericUpDownInt(0, 255, 190, 120, vm, nameof(vm.BrightnessMinimum)))); + UiUtil.MakeNumericUpDownInt(0, 255, 190, 120, vm, nameof(vm.BrightnessMinimum)), + Se.Language.Video.VideoOcr.TextBrightnessMinimumHint)); // Post-processing settings panel.Children.Add(MakeHeader(Se.Language.Video.VideoOcr.PostProcessing)); panel.Children.Add(MakeSettingRow( Se.Language.Video.VideoOcr.TextSimilarityPercent, - UiUtil.MakeNumericUpDownInt(0, 100, 80, 120, vm, nameof(vm.TextSimilarityPercent)))); + UiUtil.MakeNumericUpDownInt(0, 100, 80, 120, vm, nameof(vm.TextSimilarityPercent)), + Se.Language.Video.VideoOcr.TextSimilarityPercentHint)); panel.Children.Add(MakeSettingRow( Se.Language.Video.VideoOcr.MaxGapMs, - UiUtil.MakeNumericUpDownInt(0, 10_000, 250, 120, vm, nameof(vm.MaxGapMs)))); + UiUtil.MakeNumericUpDownInt(0, 10_000, 250, 120, vm, nameof(vm.MaxGapMs)), + Se.Language.Video.VideoOcr.MaxGapHint)); panel.Children.Add(MakeSettingRow( Se.Language.Video.VideoOcr.MinDurationMs, - UiUtil.MakeNumericUpDownInt(0, 10_000, 250, 120, vm, nameof(vm.MinDurationMs)))); + UiUtil.MakeNumericUpDownInt(0, 10_000, 250, 120, vm, nameof(vm.MinDurationMs)), + Se.Language.Video.VideoOcr.MinDurationHint)); panel.Children.Add(UiUtil.MakeCheckBox(Se.Language.Video.VideoOcr.AddAssaPositionTag, vm, nameof(vm.AddAssaPositionTag))); + panel.Children.Add(UiUtil.MakeCheckBox(Se.Language.Video.VideoOcr.FixOcrErrors, vm, nameof(vm.DoFixOcrErrors))); + var buttonDownloadDictionary = UiUtil.MakeButton("...", vm.DownloadDictionaryCommand) + .WithBindEnabled(nameof(vm.DoFixOcrErrors)); + if (Se.Settings.Appearance.ShowHints) + { + ToolTip.SetTip(buttonDownloadDictionary, Se.Language.Video.VideoOcr.DownloadDictionaryHint); + } + + panel.Children.Add(MakeSettingRow( + Se.Language.Video.VideoOcr.Dictionary, + new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 3, + Children = + { + UiUtil.MakeComboBox(vm.Dictionaries, vm, nameof(vm.SelectedDictionary)).WithWidth(190) + .WithBindEnabled(nameof(vm.DoFixOcrErrors)), + buttonDownloadDictionary, + }, + }, + Se.Language.Video.VideoOcr.DictionaryHint)); var scrollViewer = new ScrollViewer { Content = panel, HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled, + + // Give the scrollbar its own layout lane: as an auto-hiding overlay it is drawn + // on top of the content and covered the rightmost controls. + AllowAutoHide = false, }; return UiUtil.MakeBorderForControl(scrollViewer); @@ -407,7 +468,7 @@ private static TextBlock MakeHeader(string text, bool isFirst = false) }; } - private static Grid MakeSettingRow(string label, Control control) + private static Grid MakeSettingRow(string label, Control control, string? hint = null) { var grid = new Grid { @@ -419,6 +480,12 @@ private static Grid MakeSettingRow(string label, Control control) }; grid.Add(UiUtil.MakeLabel(label), 0, 0); grid.Add(control, 0, 1); + + if (hint != null && Se.Settings.Appearance.ShowHints) + { + ToolTip.SetTip(grid, hint); + } + return grid; } @@ -463,36 +530,72 @@ private static Border MakeLinesView(VideoOcrViewModel vm) Width = new GridLength(90), }); - // TableView has no cell editing, so the editable text column (OCR mistakes must stay - // fixable in place) becomes a borderless in-cell TextBox bound two-way. + // Text is edited in a dialog (context menu "Edit..." - multi-line texts do not edit + // comfortably inside a table row). The cell renders the item's FormattedText: the + // fix engine's per-word coloring (green = word known, red = unknown, same palette as + // the subtitle-bitmap OCR window) as lines arrive during OCR, plain themed text + // before that. Everything is bound (never captured), so container recycling stays + // safe - see the FuncDataTemplate recycling notes in NOcrTrainFontListTests. tableView.Columns.Add(new SeTableViewColumn { Header = Se.Language.General.Text, - CellTheme = UiUtil.TableViewNoPaddingCellTheme, + CellTheme = UiUtil.TableViewCellTheme, HeaderTheme = UiUtil.TableViewColumnHeaderTheme, Width = new GridLength(1, GridUnitType.Star), - CellTemplate = new Avalonia.Controls.Templates.FuncDataTemplate((item, _) => - { - if (item == null) - { - return new TextBlock(); - } - - var textBox = new TextBox + CellTemplate = new Avalonia.Controls.Templates.FuncDataTemplate((_, _) => + new ContentControl { - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), VerticalContentAlignment = VerticalAlignment.Center, - [!TextBox.TextProperty] = new Binding(nameof(VideoOcrLineItem.Text)) { Mode = BindingMode.TwoWay }, - }; - - // Clicking into a cell to edit should also make it the current row, so the - // double-tap preview seek and Delete act on the line being edited. - textBox.GotFocus += (_, _) => tableView.SelectedItem = item; - return textBox; - }), + [!ContentControl.ContentProperty] = new Binding(nameof(VideoOcrLineItem.FormattedText)), + }, supportsRecycling: true), }); + var flyout = new MenuFlyout(); + var menuItemEdit = new MenuItem + { + Header = Se.Language.General.EditDotDotDot, + InputGesture = new Avalonia.Input.KeyGesture(Avalonia.Input.Key.Enter), + }; + menuItemEdit.Click += async (_, _) => + { + if (tableView.SelectedItem is VideoOcrLineItem item) + { + await vm.EditLine(item); + } + }; + flyout.Items.Add(menuItemEdit); + + var menuItemItalic = new MenuItem + { + Header = Se.Language.General.Italic, + }; + menuItemItalic.Click += (_, _) => + { + var selected = tableView.SelectedItems?.OfType().ToList(); + if (selected != null) + { + VideoOcrViewModel.ToggleItalic(selected); + } + }; + flyout.Items.Add(menuItemItalic); + + var menuItemDelete = new MenuItem + { + Header = Se.Language.General.Delete, + }; + menuItemDelete.Click += (_, _) => + { + var selected = tableView.SelectedItems?.OfType().ToList(); + if (selected != null) + { + vm.DeleteLines(selected); + } + }; + flyout.Items.Add(menuItemDelete); + + tableView.ContextFlyout = flyout; + UiUtil.AttachMacContextFlyoutHandler(tableView); + // Double-click a line to see the frame it came from in the preview. tableView.DoubleTapped += (s, e) => { @@ -517,6 +620,19 @@ private static Border MakeLinesView(VideoOcrViewModel vm) } }; + // Tunnel phase: the TableView handles Enter internally (see #12734), so a bubbling + // KeyDown never sees it - the edit shortcut must run before the control's own handling. + tableView.AddHandler(KeyDownEvent, (s, e) => + { + if (e.Key is Avalonia.Input.Key.Enter or Avalonia.Input.Key.F2 && + e.Source is not TextBox && + tableView.SelectedItem is VideoOcrLineItem editItem) + { + e.Handled = true; + _ = vm.EditLine(editItem); + } + }, RoutingStrategies.Tunnel); + return UiUtil.MakeBorderForControl(tableView); } @@ -529,22 +645,26 @@ private static Grid MakeProgressView(VideoOcrViewModel vm) var statusText = new TextBlock { - Margin = new Thickness(5, 20, 0, 0), + Margin = new Thickness(5, 0, 0, 0), DataContext = vm, }; statusText.Bind(TextBlock.TextProperty, new Binding(nameof(vm.ProgressText))); + // Bar and text each get their own row - overlaying them in one cell with a fixed + // top margin on the text made the two collide. var grid = new Grid { RowDefinitions = { - new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // progress bar + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, // status text }, + RowSpacing = 3, HorizontalAlignment = HorizontalAlignment.Stretch, }; grid.Add(progressBar, 0, 0); - grid.Add(statusText, 0, 0); + grid.Add(statusText, 1, 0); return grid; } diff --git a/src/ui/Features/WebVtt/WebVttStyleDisplay.cs b/src/ui/Features/WebVtt/WebVttStyleDisplay.cs index 1616699fb77..890a114d078 100644 --- a/src/ui/Features/WebVtt/WebVttStyleDisplay.cs +++ b/src/ui/Features/WebVtt/WebVttStyleDisplay.cs @@ -94,8 +94,16 @@ public WebVttStyleDisplay() _shadowColor = Colors.Black; } + /// + /// Whether the cue selector this style came from was a class selector (::cue(.red)) + /// rather than an element one (::cue(b)). Styles added in the editor are class + /// selectors; without this, an element selector was silently rewritten as a class on save. + /// + public bool IsClassSelector { get; set; } = true; + public WebVttStyleDisplay(WebVttStyle style) : this() { + IsClassSelector = (style.Name ?? string.Empty).TrimStart().StartsWith('.'); _name = (style.Name ?? string.Empty).TrimStart('.'); _fontName = style.FontName ?? string.Empty; _fontSize = style.FontSize ?? 0; @@ -146,7 +154,7 @@ public WebVttStyle ToWebVttStyle() { return new WebVttStyle { - Name = "." + Name.TrimStart('.'), + Name = (IsClassSelector ? "." : string.Empty) + Name.TrimStart('.'), FontName = string.IsNullOrWhiteSpace(FontName) ? null : FontName, FontSize = FontSize > 0 ? FontSize : null, Bold = Bold ? true : null, diff --git a/src/ui/Features/WebVtt/WebVttStylePickerWindow.cs b/src/ui/Features/WebVtt/WebVttStylePickerWindow.cs index e21862486bc..b74dbc9072f 100644 --- a/src/ui/Features/WebVtt/WebVttStylePickerWindow.cs +++ b/src/ui/Features/WebVtt/WebVttStylePickerWindow.cs @@ -67,7 +67,7 @@ public WebVttStylePickerWindow(WebVttStylePickerViewModel vm) Content = grid; // initial focus on an input, not an action button - a focused button clicks on bare Space - Activated += delegate { TableViewExtras.FocusRow(stylesGrid); }; + UiUtil.FocusOnFirstActivation(this, () => { TableViewExtras.FocusRow(stylesGrid); }); KeyDown += vm.KeyDown; } diff --git a/src/ui/Features/WebVtt/WebVttStylesViewModel.cs b/src/ui/Features/WebVtt/WebVttStylesViewModel.cs index e0da09f4574..a377be094dc 100644 --- a/src/ui/Features/WebVtt/WebVttStylesViewModel.cs +++ b/src/ui/Features/WebVtt/WebVttStylesViewModel.cs @@ -474,7 +474,8 @@ await MessageBox.Show( private static string ToRawStyle(WebVttStyleDisplay style) { - return "::cue(." + style.Name.TrimStart('.') + ") { " + style.Css + " }"; + var selector = (style.IsClassSelector ? "." : string.Empty) + style.Name.TrimStart('.'); + return "::cue(" + selector + ") { " + style.Css + " }"; } [RelayCommand] @@ -501,25 +502,60 @@ private void SetHeader() header = "WEBVTT"; } + // Only "::cue(...)" blocks are parsed into Styles, so a STYLE block the reader ignored - + // a global "::cue { color: yellow }", an @import, a keyframes rule - has no entry to be + // regenerated from. Those are kept verbatim instead of being dropped on OK. var styleOn = false; var sb = new StringBuilder(); + var currentStyleBlock = new StringBuilder(); + var keptStyleBlocks = new StringBuilder(); + + void FlushStyleBlock() + { + var block = currentStyleBlock.ToString(); + currentStyleBlock.Clear(); + if (block.Trim().Length == 0 || block.Contains("::cue(", StringComparison.Ordinal)) + { + return; + } + + keptStyleBlocks.AppendLine("STYLE"); + keptStyleBlocks.Append(block); + keptStyleBlocks.AppendLine(); + } + foreach (var line in header.SplitToLines()) { if (line.Trim().Equals("STYLE", StringComparison.OrdinalIgnoreCase)) { + FlushStyleBlock(); styleOn = true; } else if (line.Trim().Length == 0 && styleOn) { + FlushStyleBlock(); styleOn = false; } - else if (!styleOn) + else if (styleOn) + { + currentStyleBlock.AppendLine(line); + } + else { sb.AppendLine(line); } } + FlushStyleBlock(); + var result = new StringBuilder(sb.ToString().Trim()); + if (keptStyleBlocks.Length > 0) + { + result.AppendLine(); + result.AppendLine(); + result.Append(keptStyleBlocks.ToString().TrimEnd()); + } + if (Styles.Count > 0) { result.AppendLine(); diff --git a/src/ui/Features/WebVtt/WebVttStylesWindow.cs b/src/ui/Features/WebVtt/WebVttStylesWindow.cs index 7937881fa14..fe67f20c683 100644 --- a/src/ui/Features/WebVtt/WebVttStylesWindow.cs +++ b/src/ui/Features/WebVtt/WebVttStylesWindow.cs @@ -65,7 +65,7 @@ public WebVttStylesWindow(WebVttStylesViewModel vm) Content = grid; // initial focus on an input, not an action button - a focused button clicks on bare Space - Activated += delegate { TableViewExtras.FocusRow(vm.StyleGrid); }; + UiUtil.FocusOnFirstActivation(this, () => { TableViewExtras.FocusRow(vm.StyleGrid); }); KeyDown += vm.KeyDown; Closing += delegate { UiUtil.SaveWindowPosition(this); }; diff --git a/src/ui/Logic/AutoBackupService.cs b/src/ui/Logic/AutoBackupService.cs index 14c22a2bc88..7eff313dc3b 100644 --- a/src/ui/Logic/AutoBackupService.cs +++ b/src/ui/Logic/AutoBackupService.cs @@ -1,4 +1,4 @@ -using Avalonia.Threading; +using Avalonia.Threading; using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.Core.SubtitleFormats; using Nikse.SubtitleEdit.Features.Main; @@ -121,14 +121,36 @@ private static void SaveAutoBackup(Subtitle subtitle, SubtitleFormat saveFormat) var fileName = Path.Combine(folder, $"{DateTime.Now.Year:0000}-{DateTime.Now.Month:00}-{DateTime.Now.Day:00}_{DateTime.Now.Hour:00}-{DateTime.Now.Minute:00}-{DateTime.Now.Second:00}{title}{saveFormat.Extension}"); + // IsTextBased only rules out binary formats; the read-only text formats (WSB, FTE, + // DlDd, SPU image...) are "text based" but their ToText throws, and the catch below + // then swallowed it - leaving the user with auto-backup silently doing nothing. + var format = saveFormat.IsTextBased ? saveFormat : new SubRip(); + string text; try { - var format = saveFormat.IsTextBased ? saveFormat : new SubRip(); - File.WriteAllText(fileName, format.ToText(subtitle, string.Empty)); + text = format.ToText(subtitle, string.Empty); } catch { - // ignore + try + { + format = new SubRip(); + fileName = Path.ChangeExtension(fileName, format.Extension); + text = format.ToText(subtitle, string.Empty); + } + catch + { + return; + } + } + + try + { + File.WriteAllText(fileName, text); + } + catch + { + // ignore - a backup that cannot be written must not disturb editing } } diff --git a/src/ui/Logic/CasingToggler.cs b/src/ui/Logic/CasingToggler.cs index 919d6e6390f..57a84b09829 100644 --- a/src/ui/Logic/CasingToggler.cs +++ b/src/ui/Logic/CasingToggler.cs @@ -96,7 +96,11 @@ private static List> RemoveAndSaveTags(string input, S || input[index + 1] == 'n' || input[index + 1] == 'h')) { - tags.Add(new KeyValuePair(index, input.Substring(index, 2))); + // sb.Length, not index: every other tag records its position in the + // stripped text, and RestoreSavedAndRemovedTags inserts into that text. + // Using the raw input offset pushed the break past the end whenever a tag + // preceded it, so "{\an8}Hello\NWorld" came back as "{\an8}HELLOWORLD\N". + tags.Add(new KeyValuePair(sb.Length, input.Substring(index, 2))); skipNext = true; continue; } diff --git a/src/ui/Logic/ClipboardHelper.cs b/src/ui/Logic/ClipboardHelper.cs index 7c0a97dc169..7cd98a1dab7 100644 --- a/src/ui/Logic/ClipboardHelper.cs +++ b/src/ui/Logic/ClipboardHelper.cs @@ -306,16 +306,24 @@ private static async Task CopyImageToClipboardLinux(Bitmap bitmap) var tempPath = Path.Combine(Path.GetTempPath(), $"clipboard_{Guid.NewGuid()}.png"); bitmap.Save(tempPath, PngBitmapEncoderOptions.Default); - // Try xclip first + // Build the argument vector rather than a command line: with UseShellExecute=false + // there is no shell, so on Unix .NET only honours " and \ - the single quotes were + // passed to xclip as part of the file name, and "<" reached wl-copy as a literal + // argument instead of redirecting stdin. Both silently did nothing. var psi = new ProcessStartInfo { FileName = "xclip", - Arguments = $"-selection clipboard -t image/png -i '{tempPath}'", UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; + psi.ArgumentList.Add("-selection"); + psi.ArgumentList.Add("clipboard"); + psi.ArgumentList.Add("-t"); + psi.ArgumentList.Add("image/png"); + psi.ArgumentList.Add("-i"); + psi.ArgumentList.Add(tempPath); try { @@ -332,13 +340,23 @@ private static async Task CopyImageToClipboardLinux(Bitmap bitmap) } catch { - // If xclip fails, try wl-copy (Wayland) + // If xclip fails, try wl-copy (Wayland). It reads the image from stdin, so feed + // the file in ourselves - there is no shell to do the redirect for us. psi.FileName = "wl-copy"; - psi.Arguments = $"--type image/png < '{tempPath}'"; + psi.ArgumentList.Clear(); + psi.ArgumentList.Add("--type"); + psi.ArgumentList.Add("image/png"); + psi.RedirectStandardInput = true; using var process = Process.Start(psi); if (process != null) { + using (var stdin = process.StandardInput.BaseStream) + using (var pngFile = File.OpenRead(tempPath)) + { + await pngFile.CopyToAsync(stdin); + } + await process.WaitForExitAsync(); await Task.Delay(500); @@ -365,13 +383,17 @@ private static async Task CopyImageToClipboardMacOS(Bitmap bitmap) var psi = new ProcessStartInfo { FileName = "osascript", - Arguments = $"-e 'set the clipboard to (read (POSIX file \"{tempPath}\") as «class PNGf»)'", UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; + // One -e argument, passed whole. As a command line the outer single quotes were + // literal and the script was split on spaces, so osascript never compiled it. + psi.ArgumentList.Add("-e"); + psi.ArgumentList.Add($"set the clipboard to (read (POSIX file \"{tempPath}\") as «class PNGf»)"); + using var process = Process.Start(psi); if (process != null) { diff --git a/src/ui/Logic/ColorService.cs b/src/ui/Logic/ColorService.cs index db82966d1eb..21425f8cf6c 100644 --- a/src/ui/Logic/ColorService.cs +++ b/src/ui/Logic/ColorService.cs @@ -1,6 +1,7 @@ using Avalonia.Media; using Avalonia.Skia; using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.ContainerFormats.TransportStream; using Nikse.SubtitleEdit.Core.SubtitleFormats; using Nikse.SubtitleEdit.Features.Main; using System; @@ -123,7 +124,7 @@ public string SetColorTag(string input, Color color, Subtitle subtitle, Subtitle return text; } - if (subtitleFormat is WebVTT) + if (subtitleFormat is WebVTT or WebVTTFileWithLineNumber) { try { @@ -152,6 +153,24 @@ public string SetColorTag(string input, Color color, Subtitle subtitle, Subtitle return text; } + // An STL file carries eight teletext colours, and Ebu.Save snaps whatever it finds to the + // nearest of them - so a shortcut colour like orange was shown orange in the grid and in + // the video preview and came out yellow in the file. Snap when the tag is written instead: + // grid, preview and file then agree. Written as the colour name the STL reader itself + // produces, so the shortcut also toggles off a colour that came from a file. + var colorText = ToHex(color); + if (subtitleFormat is Ebu) + { + colorText = Ebu.GetNearestColorName(colorText) ?? colorText; + } + else if (subtitleFormat is DvbTeletext) + { + // A .dvbttx colour map entry holds four bits per component (Level 2.5), so snap the + // tag to that grid - grid, preview and the written file then agree, and the tag + // matches what the teletext reader produces so ContainsColor can toggle it off. + colorText = TeletextTables.ColorToHtml(TeletextColorMap.QuantizeRgb(color.R, color.G, color.B)); + } + string pre = string.Empty; if (text.StartsWith("{\\", StringComparison.Ordinal) && text.IndexOf('}') >= 0) { @@ -171,7 +190,7 @@ public string SetColorTag(string input, Color color, Subtitle subtitle, Subtitle if (f.Contains(" face=", StringComparison.OrdinalIgnoreCase) && !f.Contains(" color=", StringComparison.OrdinalIgnoreCase)) { var start = s.IndexOf(" face=", StringComparison.OrdinalIgnoreCase); - s = s.Insert(start, string.Format(" color=\"{0}\"", ToHex(color))); + s = s.Insert(start, string.Format(" color=\"{0}\"", colorText)); text = pre + s; return text; } @@ -179,19 +198,35 @@ public string SetColorTag(string input, Color color, Subtitle subtitle, Subtitle var colorStart = f.IndexOf(" color=", StringComparison.OrdinalIgnoreCase); if (colorStart >= 0) { - if (s.IndexOf('"', colorStart + 8) > 0) + var valueStart = colorStart + " color=".Length; + var quoteEnd = s.IndexOf('"', valueStart); + if (quoteEnd > 0) + { + // Quoted value: the closing quote comes from the tail we keep. + s = s.Substring(0, colorStart) + string.Format(" color=\"{0}", colorText) + s.Substring(quoteEnd); + } + else { - end = s.IndexOf('"', colorStart + 8); + // Unquoted value (""). "end" is the '>' here, so the old + // code emitted an opening quote and then kept the rest of the tag, + // producing the broken '{text}"; + return $"{pre}{text}"; } public string RemoveColorTag(string input, Color color, Subtitle subtitle, SubtitleFormat subtitleFormat) @@ -216,7 +251,7 @@ public string RemoveColorTag(string input, Color color, Subtitle subtitle, Subti return text; } - if (subtitleFormat is WebVTT) + if (subtitleFormat is WebVTT or WebVTTFileWithLineNumber) { try { diff --git a/src/ui/Logic/Compression/ZipUnpacker.cs b/src/ui/Logic/Compression/ZipUnpacker.cs index 68bf91b29c5..033cfaa579c 100644 --- a/src/ui/Logic/Compression/ZipUnpacker.cs +++ b/src/ui/Logic/Compression/ZipUnpacker.cs @@ -59,7 +59,19 @@ public void UnpackZipStream( entryFullName = Path.GetFileName(entryFullName); } - var filePath = Path.Combine(outputPath, entryFullName); + // Keep every entry inside the extraction folder, the way the 7-zip unpacker does. + // This unpacker handles third-party plugin zips, and combining an unchecked entry + // name let an archive write outside the target directory. + var targetRoot = Path.GetFullPath(outputPath); + var filePath = Path.GetFullPath(Path.Combine(targetRoot, entryFullName)); + var relativePath = Path.GetRelativePath(targetRoot, filePath); + if (Path.IsPathRooted(entryFullName) || + relativePath.Equals("..", StringComparison.Ordinal) || + relativePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || + Path.IsPathRooted(relativePath)) + { + throw new InvalidDataException("Archive entry is outside the extraction folder: " + entryFullName); + } var directoryPath = Path.GetDirectoryName(filePath); if (!string.IsNullOrEmpty(directoryPath) && !Directory.Exists(directoryPath)) @@ -114,7 +126,19 @@ private static void UnpackTarGzStream(Stream zipStream, string outputPath, strin entryFullName = Path.GetFileName(entryFullName); } - var filePath = Path.Combine(outputPath, entryFullName); + // Keep every entry inside the extraction folder, the way the 7-zip unpacker does. + // This unpacker handles third-party plugin zips, and combining an unchecked entry + // name let an archive write outside the target directory. + var targetRoot = Path.GetFullPath(outputPath); + var filePath = Path.GetFullPath(Path.Combine(targetRoot, entryFullName)); + var relativePath = Path.GetRelativePath(targetRoot, filePath); + if (Path.IsPathRooted(entryFullName) || + relativePath.Equals("..", StringComparison.Ordinal) || + relativePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || + Path.IsPathRooted(relativePath)) + { + throw new InvalidDataException("Archive entry is outside the extraction folder: " + entryFullName); + } var directoryPath = Path.GetDirectoryName(filePath); if (!string.IsNullOrEmpty(directoryPath) && !Directory.Exists(directoryPath)) @@ -151,6 +175,25 @@ private static void UnpackTarGzStream(Stream zipStream, string outputPath, strin continue; } + // The containment check above validates where the LINK is written, never where it + // points. An archive shipping "lib -> /etc" (or "../..") followed by a regular + // "lib/x" entry passes that check and then writes outside the extraction folder, + // and the copy fallback below would pull an arbitrary host file in. Reject an + // absolute target, and require a relative one to resolve inside the root. + if (Path.IsPathRooted(linkTarget)) + { + continue; + } + + var resolvedLinkTarget = Path.GetFullPath(Path.Combine(directoryPath ?? targetRoot, linkTarget)); + var linkTargetRelative = Path.GetRelativePath(targetRoot, resolvedLinkTarget); + if (linkTargetRelative.Equals("..", StringComparison.Ordinal) || + linkTargetRelative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || + Path.IsPathRooted(linkTargetRelative)) + { + continue; + } + try { if (File.Exists(filePath) || Directory.Exists(filePath)) @@ -164,10 +207,9 @@ private static void UnpackTarGzStream(Stream zipStream, string outputPath, strin { // Symlinks may be unsupported (e.g. Windows without privileges) - fall back to // copying the already-extracted target file when it is available. - var resolvedTarget = Path.Combine(directoryPath ?? outputPath, linkTarget); - if (File.Exists(resolvedTarget) && !File.Exists(filePath)) + if (File.Exists(resolvedLinkTarget) && !File.Exists(filePath)) { - File.Copy(resolvedTarget, filePath); + File.Copy(resolvedLinkTarget, filePath); } else { diff --git a/src/ui/Logic/Config/Language/Assa/LanguageAssa.cs b/src/ui/Logic/Config/Language/Assa/LanguageAssa.cs index f0e21ec19fc..fc8183f468c 100644 --- a/src/ui/Logic/Config/Language/Assa/LanguageAssa.cs +++ b/src/ui/Logic/Config/Language/Assa/LanguageAssa.cs @@ -5,6 +5,7 @@ public class LanguageAssa // ASSA Draw public string AssaDraw { get; set; } public string DrawSelectTool { get; set; } + public string DrawUseShapeForErase { get; set; } public string DrawLineTool { get; set; } public string DrawBezierTool { get; set; } public string DrawRectangleTool { get; set; } @@ -86,6 +87,8 @@ public class LanguageAssa public string ResolutionResamplerFromVideo { get; set; } public string ResolutionResamplerSourceAndTargetEqual { get; set; } public string ResolutionResamplerNothingSelected { get; set; } + public string ResolutionResamplerVideoDiffers { get; set; } + public string ResolutionResamplerAskOnVideoOpen { get; set; } // Background Box Generator public string BackgroundBoxGenerator { get; set; } @@ -246,6 +249,7 @@ public LanguageAssa() // ASSA Draw AssaDraw = "ASSA Draw"; DrawSelectTool = "Select (move points)"; + DrawUseShapeForErase = "Use shape for erase (iclip)"; DrawLineTool = "Line Tool (F4)"; DrawBezierTool = "Bezier Curve (F5)"; DrawRectangleTool = "Rectangle (F6)"; @@ -323,6 +327,8 @@ public LanguageAssa() ResolutionResamplerFromVideo = "From video..."; ResolutionResamplerSourceAndTargetEqual = "Source and target resolution are the same - nothing to do."; ResolutionResamplerNothingSelected = "Please select at least one option to change."; + ResolutionResamplerVideoDiffers = "The subtitle resolution ({0}x{1}) differs from the video resolution ({2}x{3}).\nChange the subtitle to match the video?"; + ResolutionResamplerAskOnVideoOpen = "Ask when a video with a different resolution is opened"; // Background Box Generator BackgroundBoxGenerator = "ASSA background box generator"; diff --git a/src/ui/Logic/Config/Language/Edit/LanguageModifySelection.cs b/src/ui/Logic/Config/Language/Edit/LanguageModifySelection.cs index d4265974dc4..f102f164e0b 100644 --- a/src/ui/Logic/Config/Language/Edit/LanguageModifySelection.cs +++ b/src/ui/Logic/Config/Language/Edit/LanguageModifySelection.cs @@ -26,6 +26,7 @@ public class LanguageModifySelection public string ExactlyTwoLines { get; set; } public string MoreThanTwoLines { get; set; } public string Bookmarked { get; set; } + public string Forced { get; set; } = string.Empty; public string BookmarkContains { get; set; } public string BlankLines { get; set; } public string HearingImpaired { get; set; } @@ -67,6 +68,7 @@ public LanguageModifySelection() ExactlyTwoLines = "Exactly two lines"; MoreThanTwoLines = "More than two lines"; Bookmarked = "Bookmarked"; + Forced = "Forced"; BookmarkContains = "Bookmark contains"; BlankLines = "Blank lines"; HearingImpaired = "Hearing impaired (SDH)"; diff --git a/src/ui/Logic/Config/Language/File/LanguageEbuSaveOptions.cs b/src/ui/Logic/Config/Language/File/LanguageEbuSaveOptions.cs index 9b326bd412b..37be56839d3 100644 --- a/src/ui/Logic/Config/Language/File/LanguageEbuSaveOptions.cs +++ b/src/ui/Logic/Config/Language/File/LanguageEbuSaveOptions.cs @@ -31,6 +31,10 @@ public class LanguageEbuSaveOptions public string Teletext { get; set; } public string UseBox { get; set; } public string DoubleHeight { get; set; } + public string VideoPreview { get; set; } + public string PreviewFont { get; set; } + public string PreviewFontDefault { get; set; } + public string PreviewFontSample { get; set; } public string Errors { get; set; } public string ErrorsX { get; set; } public string MaxLengthError { get; set; } @@ -70,6 +74,10 @@ public LanguageEbuSaveOptions() Teletext = "Teletext"; UseBox = "Use box around text"; DoubleHeight = "Use double height for text"; + VideoPreview = "Video preview"; + PreviewFont = "Custom font"; + PreviewFontDefault = "(use the video preview font)"; + PreviewFontSample = "I know the quick brown fox jumps over the lazy dog - 0123456789"; Errors = "Errors"; ErrorsX = "Errors: {0}"; MaxLengthError = "Line {0} exceeds max length ({1}) by {2}: {3}"; diff --git a/src/ui/Logic/Config/Language/File/LanguageExport.cs b/src/ui/Logic/Config/Language/File/LanguageExport.cs index 1f489e5bcdb..c9cebaf85e0 100644 --- a/src/ui/Logic/Config/Language/File/LanguageExport.cs +++ b/src/ui/Logic/Config/Language/File/LanguageExport.cs @@ -21,6 +21,8 @@ public class LanguageExport public string ExportCavenaTranslator { get; set; } public string ExportCavenaComment { get; set; } public string ExportCavenaStartOfProgramme { get; set; } + public string TitleExportDvbTeletext { get; set; } + public string ExportDvbTeletextPageNumber { get; set; } public string CustomTextFormatsDotDotDot { get; set; } public string PlainTextDotDotDot { get; set; } public string CustomTextFormats { get; set; } @@ -43,6 +45,33 @@ public class LanguageExport public string ImageBasedSubtitleSaved { get; set; } public string FullFrameImage { get; set; } public string FullFrameImageHint { get; set; } + public string TextEffect { get; set; } + public string TextEffectSettingsTitle { get; set; } + public string TextEffectPreset { get; set; } + public string TextEffectStrength { get; set; } + public string TextEffectLetterSpacing { get; set; } + public string TextEffectArcBend { get; set; } + public string TextEffectWave { get; set; } + public string TextEffectSoftShadow { get; set; } + public string TextEffectGradientGold { get; set; } + public string TextEffectDoubleOutline { get; set; } + public string TextEffectNeonGlow { get; set; } + public string TextEffectExtrude3D { get; set; } + public string TextEffectChrome { get; set; } + public string TextEffectFire { get; set; } + public string TextEffectComic { get; set; } + public string TextEffectRetro80s { get; set; } + public string TextEffectAnaglyph3D { get; set; } + public string TextEffectIce { get; set; } + public string TextEffectEmboss { get; set; } + public string TextEffectHollow { get; set; } + public string TextEffectMarble { get; set; } + public string TextEffectWood { get; set; } + public string TextEffectLava { get; set; } + public string TextEffectBrushedSteel { get; set; } + public string TextEffectCandyCane { get; set; } + public string TextEffectRainbow { get; set; } + public string TextEffectPolkaDots { get; set; } public LanguageExport() { @@ -65,6 +94,8 @@ public LanguageExport() ExportCavenaTranslator = "Translator"; ExportCavenaComment = "Comment"; ExportCavenaStartOfProgramme = "Start of programme"; + TitleExportDvbTeletext = "DVB teletext (Manzanita)"; + ExportDvbTeletextPageNumber = "Teletext page number"; CustomTextFormatsDotDotDot = "_Custom text formats..."; PlainTextDotDotDot = "_Plain text..."; CustomTextFormats = "Custom text formats"; @@ -87,5 +118,32 @@ public LanguageExport() ImageBasedSubtitleSaved = "Image-based subtitle saved"; FullFrameImage = "Full frame image"; FullFrameImageHint = "Make each image the size of the video frame, with the subtitle in its place"; + TextEffect = "Text effect"; + TextEffectSettingsTitle = "Text effect settings"; + TextEffectPreset = "Preset"; + TextEffectStrength = "Strength"; + TextEffectLetterSpacing = "Letter spacing"; + TextEffectArcBend = "Curve"; + TextEffectWave = "Wave"; + TextEffectSoftShadow = "Soft shadow"; + TextEffectGradientGold = "Gradient (gold)"; + TextEffectDoubleOutline = "Double outline"; + TextEffectNeonGlow = "Neon glow"; + TextEffectExtrude3D = "3D extrude"; + TextEffectChrome = "Chrome"; + TextEffectFire = "Fire"; + TextEffectComic = "Comic book"; + TextEffectRetro80s = "Retro 80s"; + TextEffectAnaglyph3D = "3D glasses"; + TextEffectIce = "Ice"; + TextEffectEmboss = "Emboss"; + TextEffectHollow = "Hollow (outline only)"; + TextEffectMarble = "Marble"; + TextEffectWood = "Wood"; + TextEffectLava = "Lava"; + TextEffectBrushedSteel = "Brushed steel"; + TextEffectCandyCane = "Candy cane"; + TextEffectRainbow = "Rainbow"; + TextEffectPolkaDots = "Polka dots"; } } \ No newline at end of file diff --git a/src/ui/Logic/Config/Language/File/LanguageFile.cs b/src/ui/Logic/Config/Language/File/LanguageFile.cs index d27f6b22da6..095e029ef95 100644 --- a/src/ui/Logic/Config/Language/File/LanguageFile.cs +++ b/src/ui/Logic/Config/Language/File/LanguageFile.cs @@ -24,7 +24,9 @@ public class LanguageFile public string CompareTextOrTimeDifference { get; set; } public string CompareNumberDifference { get; set; } public string IgnoreWhitespace { get; set; } + public string IgnoreWhitespaceHint { get; set; } public string IgnoreFormatting { get; set; } + public string IgnoreFormattingHint { get; set; } public string ShowOnlyDifferencesInText { get; set; } public string LoadXFromFile { get; set; } public string SaveCompareHtmlTitle { get; set; } @@ -33,6 +35,18 @@ public class LanguageFile public string PickMp4TrackX { get; set; } public string RosettaProperties { get; set; } public string RosettaFontSize { get; set; } + public string PropertyTimeBase { get; set; } + public string PropertyFrameRateMultiplier { get; set; } + public string PropertyDropMode { get; set; } + public string PropertyDefaultStyle { get; set; } + public string PropertyDefaultRegion { get; set; } + public string PropertyStyleAttributeName { get; set; } + public string PropertyTimeCodeFormat { get; set; } + public string PropertyFileExtension { get; set; } + public string PropertyTopOrigin { get; set; } + public string PropertyTopExtent { get; set; } + public string PropertyBottomOrigin { get; set; } + public string PropertyBottomExtent { get; set; } public string XProperties { get; set; } public LanguageFile() @@ -50,7 +64,9 @@ public LanguageFile() CompareNumberDifference = "Number difference"; ShowOnlyDifferencesInText = "Only differences in text"; IgnoreWhitespace = "Ignore whitespace"; + IgnoreWhitespaceHint = "Lines that differ only in spaces, tabs or line breaks do not count as different"; IgnoreFormatting = "Ignore formatting"; + IgnoreFormattingHint = "Lines that differ only in formatting tags, like or {\\an8}, do not count as different"; LoadXFromFile = "Load \"{0}\" from file"; SaveCompareHtmlTitle = "Save compare HTML file"; PickMatroskaTrackX = "Pick Matroska track - {0}"; @@ -58,6 +74,18 @@ public LanguageFile() PickMp4TrackX = "Pick MP4 track - {0}"; RosettaProperties = "Timed Text Rosetta IMSC properties"; RosettaFontSize = "Font size (row height)"; + PropertyTimeBase = "Time base"; + PropertyFrameRateMultiplier = "Frame rate multiplier"; + PropertyDropMode = "Drop mode"; + PropertyDefaultStyle = "Default style"; + PropertyDefaultRegion = "Default region"; + PropertyStyleAttributeName = "Style attribute name"; + PropertyTimeCodeFormat = "Time code format"; + PropertyFileExtension = "File extension"; + PropertyTopOrigin = "Top origin"; + PropertyTopExtent = "Top extent"; + PropertyBottomOrigin = "Bottom origin"; + PropertyBottomExtent = "Bottom extent"; XProperties = "{0} properties"; } } \ No newline at end of file diff --git a/src/ui/Logic/Config/Language/File/LanguageImport.cs b/src/ui/Logic/Config/Language/File/LanguageImport.cs index 6c7659b4c41..9695940b752 100644 --- a/src/ui/Logic/Config/Language/File/LanguageImport.cs +++ b/src/ui/Logic/Config/Language/File/LanguageImport.cs @@ -87,10 +87,10 @@ public LanguageImport() ForcedAlignerSetupIntro = "A forced aligner matches the subtitle text you already have against the audio, without transcribing it first. Long videos are aligned in chunks, so any length works."; ForcedAlignerModel = "Aligner model"; ForcedAlignerDownloadEngine = "Download / update engine..."; - CsvXlsxCustomColumnsDotDotDot = "CSV/XLSX with custom columns..."; - TitleImportCsvXlsxCustomColumns = "Import CSV/XLSX with custom columns"; + CsvXlsxCustomColumnsDotDotDot = "CSV/XLSX/ODS with custom columns..."; + TitleImportCsvXlsxCustomColumns = "Import CSV/XLSX/ODS with custom columns"; DetectedSeparatorX = "Detected separator: {0}"; - CsvXlsxFilterTitle = "CSV/XLSX"; + CsvXlsxFilterTitle = "CSV/XLSX/ODS"; SeparatorTab = "Tab"; SeparatorSpace = "Space"; } diff --git a/src/ui/Logic/Config/Language/LanguageErrorList.cs b/src/ui/Logic/Config/Language/LanguageErrorList.cs new file mode 100644 index 00000000000..4aa3cedd35e --- /dev/null +++ b/src/ui/Logic/Config/Language/LanguageErrorList.cs @@ -0,0 +1,79 @@ +namespace Nikse.SubtitleEdit.Logic.Config.Language; + +public class LanguageErrorList +{ + public string Title { get; set; } + public string SummaryX { get; set; } + public string NoErrors { get; set; } + public string SummaryFilesX { get; set; } + public string All { get; set; } + public string TooManyLines { get; set; } + public string CharactersPerSecond { get; set; } + public string DurationTooShort { get; set; } + public string DurationTooLong { get; set; } + public string LineTooLong { get; set; } + public string LineTooWide { get; set; } + public string Overlap { get; set; } + public string GapTooShort { get; set; } + public string TooManyLinesHint { get; set; } + public string CharactersPerSecondHint { get; set; } + public string DurationTooShortHint { get; set; } + public string DurationTooLongHint { get; set; } + public string LineTooLongHint { get; set; } + public string LineTooWideHint { get; set; } + public string OverlapHint { get; set; } + public string GapTooShortHint { get; set; } + public string DetailXGreaterThanY { get; set; } + public string DetailXLessThanY { get; set; } + public string DetailOverlapFromPrevious { get; set; } + public string DetailOverlapToNext { get; set; } + public string DetailGapToPrevious { get; set; } + public string DetailGapToNext { get; set; } + public string Detail { get; set; } + public string Tip { get; set; } + public string ExportAsText { get; set; } + public string ExportAsExcel { get; set; } + public string ExportAsHtml { get; set; } + public string ExportFileX { get; set; } + public string ExportGeneratedX { get; set; } + public string ExportMadeWithSubtitleEdit { get; set; } + + public LanguageErrorList() + { + Title = "List errors"; + SummaryX = "{0} error(s) in {1} of {2} line(s)"; + NoErrors = "No errors found."; + SummaryFilesX = "{0} error(s) in {1} line(s) across {2} file(s)"; + All = "All"; + TooManyLines = "Too many lines"; + CharactersPerSecond = "Reading speed"; + DurationTooShort = "Too short"; + DurationTooLong = "Too long"; + LineTooLong = "Line too long"; + LineTooWide = "Line too wide"; + Overlap = "Overlapping"; + GapTooShort = "Gap too short"; + TooManyLinesHint = "More lines than the maximum number of lines"; + CharactersPerSecondHint = "Characters per second above the maximum"; + DurationTooShortHint = "Shorter than the minimum display time"; + DurationTooLongHint = "Longer than the maximum display time"; + LineTooLongHint = "A line has more characters than the maximum line length"; + LineTooWideHint = "A line is wider (in pixels) than the maximum width"; + OverlapHint = "Overlaps the previous or next line"; + GapTooShortHint = "Gap to the previous or next line is below the minimum"; + DetailXGreaterThanY = "{0} > {1}"; + DetailXLessThanY = "{0} < {1}"; + DetailOverlapFromPrevious = "from previous: {0} ms"; + DetailOverlapToNext = "to next: {0} ms"; + DetailGapToPrevious = "to previous: {0} < {1} ms"; + DetailGapToNext = "to next: {0} < {1} ms"; + Detail = "Detail"; + Tip = "Double-click a line (or press Enter) to go to it. Which checks run, and their limits, are set in Settings > General."; + ExportAsText = "Text file (.txt)..."; + ExportAsExcel = "Excel file (.xlsx)..."; + ExportAsHtml = "Web page (.html)..."; + ExportFileX = "File: {0}"; + ExportGeneratedX = "Generated: {0}"; + ExportMadeWithSubtitleEdit = "Made with Subtitle Edit"; + } +} diff --git a/src/ui/Logic/Config/Language/LanguageGeneral.cs b/src/ui/Logic/Config/Language/LanguageGeneral.cs index 65fa65c7707..f0b375d9afc 100644 --- a/src/ui/Logic/Config/Language/LanguageGeneral.cs +++ b/src/ui/Logic/Config/Language/LanguageGeneral.cs @@ -32,6 +32,14 @@ public class LanguageGeneral public string Apply { get; set; } public string ApplyTo { get; set; } public string Ascending { get; set; } + public string AssistedMove { get; set; } + public string AssistedMoveChooseMove { get; set; } + public string AssistedMoveDotDotDot { get; set; } + public string AssistedMoveNoSuggestions { get; set; } + public string AssistedSplitNoSuggestions { get; set; } + public string AssistedSplit { get; set; } + public string AssistedSplitChooseSplitPoint { get; set; } + public string AssistedSplitDotDotDot { get; set; } public string Descending { get; set; } public string AttachDotDotDot { get; set; } public string AudioFileSaved { get; set; } @@ -106,12 +114,21 @@ public class LanguageGeneral public string ColorBlue { get; set; } public string ColorCyan { get; set; } public string ColorDotDotDot { get; set; } + public string ColorGray { get; set; } public string ColorGreen { get; set; } + public string ColorHalfBlue { get; set; } + public string ColorHalfCyan { get; set; } + public string ColorHalfGreen { get; set; } + public string ColorHalfMagenta { get; set; } + public string ColorHalfRed { get; set; } + public string ColorHalfYellow { get; set; } public string ColorMagenta { get; set; } public string ColorRed { get; set; } public string ColorWhite { get; set; } public string ColorYellow { get; set; } public string Column { get; set; } + public string Columns { get; set; } + public string ColumnsDotDotDot { get; set; } public string ConsoleLog { get; set; } public string ContentAlignment { get; set; } public string ContinueFindTitle { get; set; } @@ -180,6 +197,9 @@ public class LanguageGeneral public string Engine { get; set; } public string EnterProfileName { get; set; } public string Error { get; set; } + public string Start { get; set; } + public string VobSub { get; set; } + public string OutputFileCannotBeTheInputFile { get; set; } public string ErrorX { get; set; } public string Example { get; set; } public string ExampleX { get; set; } @@ -303,6 +323,7 @@ public class LanguageGeneral public string LoadDefaults { get; set; } public string LockTimeCodes { get; set; } public string Logo { get; set; } + public string LookUpX { get; set; } public string Margin { get; set; } public string Match { get; set; } public string MaxCharactersPerSecond { get; set; } @@ -432,8 +453,10 @@ public class LanguageGeneral public string PoweredBy { get; set; } public string Prefix { get; set; } public string Preview { get; set; } + public string PreviewAlignmentInListView { get; set; } public string Previous { get; set; } public string Profile { get; set; } + public string Quality { get; set; } public string ProfileName { get; set; } public string Profiles { get; set; } public string Property { get; set; } @@ -441,6 +464,7 @@ public class LanguageGeneral public string ReDownloadX { get; set; } public string Reason { get; set; } public string RecentFiles { get; set; } + public string RecentlyUsedVideoOffsets { get; set; } public string Redo { get; set; } public string Redownload { get; set; } public string Release { get; set; } @@ -526,6 +550,7 @@ public class LanguageGeneral public string SetStartAndKeepDuration { get; set; } public string SetStartAndOffsetTheRest { get; set; } public string SetUpLikeSubtitleEdit4 { get; set; } + public string ImageBackgroundColorDotDotDot { get; set; } public string SetVideoOffset { get; set; } public string Settings { get; set; } public string Shadow { get; set; } @@ -539,6 +564,7 @@ public class LanguageGeneral public string ShowCpsColumn { get; set; } public string ShowDurationColumn { get; set; } public string ShowGapColumn { get; set; } + public string ShowForcedColumn { get; set; } = string.Empty; public string ShowStartColumn { get; set; } public string ShowHideColumn { get; set; } public string ShowHistory { get; set; } @@ -570,8 +596,21 @@ public class LanguageGeneral public string SpeechToTextSelectedLinesPromptAlways { get; set; } public string SpeechToTextSelectedLinesPromptFirstTime { get; set; } public string Speed { get; set; } + public string BalanceWithNextSubtitle { get; set; } + public string BalanceWithPreviousSubtitle { get; set; } + public string FetchLastWordFromPreviousSubtitle { get; set; } + public string FetchRestOfSentenceFromNextSubtitle { get; set; } + public string FetchUnfinishedSentenceFromPreviousSubtitle { get; set; } + public string MoveFirstWordToPreviousSubtitle { get; set; } + public string MoveRestOfSentenceToPreviousSubtitle { get; set; } + public string MoveUnfinishedSentenceToNextSubtitle { get; set; } + public string SplitAtComma { get; set; } + public string SplitAtDialogDash { get; set; } + public string SplitAtLineBreak { get; set; } + public string SplitAtSentenceEnd { get; set; } public string SplitAtTextBoxCursorPosition { get; set; } public string SplitLine { get; set; } + public string SplitNearMiddle { get; set; } public string SplitLineAtTextBoxCursorPosition { get; set; } public string SplitLineAtVideoAndTextBoxPosition { get; set; } public string SplitLineAtVideoPosition { get; set; } @@ -581,6 +620,7 @@ public class LanguageGeneral public string StartingDotDotDot { get; set; } public string Status { get; set; } public string Stereo { get; set; } + public string SpreadsheetFiles { get; set; } public string Stop { get; set; } public string Strikeout { get; set; } public string Style { get; set; } @@ -618,6 +658,10 @@ public class LanguageGeneral public string ToggleCasing { get; set; } public string ToggleDirection { get; set; } public string ToggleForced { get; set; } + public string MarkedXLinesAsForced { get; set; } = string.Empty; + public string UnmarkedXLinesAsForced { get; set; } = string.Empty; + public string NoForcedLinesFound { get; set; } = string.Empty; + public string SaveForcedLinesAs { get; set; } = string.Empty; public string ToggleShotChangesAtVideoPosition { get; set; } public string Toolbar { get; set; } public string Tools { get; set; } @@ -792,6 +836,22 @@ public LanguageGeneral() Apply = "Apply"; ApplyTo = "Apply to"; Ascending = "Ascending"; + AssistedMove = "Assisted move"; + AssistedMoveChooseMove = "Click an option or press its number to move words"; + AssistedMoveDotDotDot = "Assisted move..."; + AssistedMoveNoSuggestions = "No move suggestions - the sentence does not continue into the previous/next subtitle"; + AssistedSplitNoSuggestions = "No split suggestions for this line"; + BalanceWithNextSubtitle = "Balance with next subtitle"; + BalanceWithPreviousSubtitle = "Balance with previous subtitle"; + FetchLastWordFromPreviousSubtitle = "Fetch last word from previous subtitle"; + FetchRestOfSentenceFromNextSubtitle = "Fetch rest of sentence from next subtitle"; + FetchUnfinishedSentenceFromPreviousSubtitle = "Fetch unfinished sentence from previous subtitle"; + MoveFirstWordToPreviousSubtitle = "Move first word to previous subtitle"; + MoveRestOfSentenceToPreviousSubtitle = "Move rest of sentence to previous subtitle"; + MoveUnfinishedSentenceToNextSubtitle = "Move unfinished sentence to next subtitle"; + AssistedSplit = "Assisted split"; + AssistedSplitChooseSplitPoint = "Click an option or press its number to split the line"; + AssistedSplitDotDotDot = "Assisted split..."; Descending = "Descending"; AttachDotDotDot = "Attach..."; AudioFileSaved = "Audio file saved"; @@ -866,12 +926,21 @@ public LanguageGeneral() ColorBlue = "Blue"; ColorCyan = "Cyan"; ColorDotDotDot = "Color..."; + ColorGray = "Gray"; ColorGreen = "Green"; + ColorHalfBlue = "Half blue"; + ColorHalfCyan = "Half cyan"; + ColorHalfGreen = "Half green"; + ColorHalfMagenta = "Half magenta"; + ColorHalfRed = "Half red"; + ColorHalfYellow = "Half yellow"; ColorMagenta = "Magenta"; ColorRed = "Red"; ColorWhite = "White"; ColorYellow = "Yellow"; Column = "Column"; + Columns = "Columns"; + ColumnsDotDotDot = "Columns..."; ConsoleLog = "Console log"; ContentAlignment = "Content alignment"; ContinueFindTitle = "Continue Find?"; @@ -940,6 +1009,9 @@ public LanguageGeneral() Engine = "Engine"; EnterProfileName = "Enter profile name"; Error = "Error"; + Start = "Start"; + VobSub = "VobSub"; + OutputFileCannotBeTheInputFile = "The output file cannot be the same as the input file."; ErrorX = "Error; {0}"; Example = "Example"; ExampleX = "Example: {0}"; @@ -1063,6 +1135,7 @@ public LanguageGeneral() LoadDefaults = "Load defaults"; LockTimeCodes = "Lock time codes"; Logo = "Logo"; + LookUpX = "Look up \"{0}\""; Margin = "Margin"; Match = "Match"; MaxCharactersPerSecond = "Max characters per second"; @@ -1192,8 +1265,10 @@ public LanguageGeneral() PoweredBy = "Powered by"; Prefix = "Prefix"; Preview = "Preview"; + PreviewAlignmentInListView = "Preview alignment in list view"; Previous = "Previous"; Profile = "Profile"; + Quality = "Quality"; ProfileName = "Profile name"; Profiles = "Profiles"; Property = "Property"; @@ -1201,6 +1276,7 @@ public LanguageGeneral() ReDownloadX = "Re-download {0}"; Reason = "Reason"; RecentFiles = "Recent files"; + RecentlyUsedVideoOffsets = "Recently used video offsets"; Redo = "Redo"; Redownload = "Re-download..."; Release = "Release"; @@ -1286,6 +1362,7 @@ public LanguageGeneral() SetStartAndKeepDuration = "Set start and keep duration"; SetStartAndOffsetTheRest = "Set start and offset the rest"; SetUpLikeSubtitleEdit4 = "Set up like Subtitle Edit 4 (theme, shortcuts, replace rules)"; + ImageBackgroundColorDotDotDot = "Image background color..."; SetVideoOffset = "Set video offset"; Settings = "Settings"; Shadow = "Shadow"; @@ -1299,6 +1376,7 @@ public LanguageGeneral() ShowCpsColumn = "Show \"Chars/sec\" column"; ShowDurationColumn = "Show \"Duration\" column"; ShowGapColumn = "Show \"Gap\" column"; + ShowForcedColumn = "Show \"Forced\" column"; ShowStartColumn = "Show \"Start\" column"; ShowHideColumn = "Show \"Hide\" column"; ShowHistory = "Show history"; @@ -1306,7 +1384,7 @@ public LanguageGeneral() ShowPreview = "Show preview"; ShowShotChangesList = "Show shot changes list"; ShowStyleColumn = "Show \"Style\" column"; - ShowTeletext = "Show TT"; + ShowTeletext = "Show teletext column in list view"; ShowTimeCodes = "Show time codes"; ShowWpmColumn = "Show \"Words/min\" column"; ShowPixelWidthColumn = "Show \"Pixel width\" column"; @@ -1330,8 +1408,13 @@ public LanguageGeneral() SpeechToTextSelectedLinesPromptAlways = "Speech to text selected lines (always prompt engine/language)"; SpeechToTextSelectedLines = "Speech to text selected (see Options - Settings)"; Speed = "Speed"; + SplitAtComma = "Split at comma"; + SplitAtDialogDash = "Split at dialog dash"; + SplitAtLineBreak = "Split at line break"; + SplitAtSentenceEnd = "Split at end of sentence"; SplitAtTextBoxCursorPosition = "Split at text cursor position"; SplitLine = "Split line"; + SplitNearMiddle = "Split at space nearest the middle"; SplitLineAtTextBoxCursorPosition = "Split line at cursor position"; SplitLineAtVideoAndTextBoxPosition = "Split line at video and text box position"; SplitLineAtVideoPosition = "Split line at video position"; @@ -1341,6 +1424,7 @@ public LanguageGeneral() StartingDotDotDot = "Starting..."; Status = "Status"; Stereo = "Stereo"; + SpreadsheetFiles = "Spreadsheet files"; Stop = "Stop"; Strikeout = "Strikeout"; Style = "Style"; @@ -1378,6 +1462,10 @@ public LanguageGeneral() ToggleCasing = "Toggle casing"; ToggleDirection = "Toggle direction"; ToggleForced = "Toggle forced"; + MarkedXLinesAsForced = "Marked {0} line(s) as forced"; + UnmarkedXLinesAsForced = "Unmarked {0} line(s) as forced"; + NoForcedLinesFound = "No forced lines found"; + SaveForcedLinesAs = "Save forced lines as..."; ToggleShotChangesAtVideoPosition = "Toggle shot changes at video position"; Toolbar = "Toolbar"; Tools = "Tools"; diff --git a/src/ui/Logic/Config/Language/Main/LanguageMain.cs b/src/ui/Logic/Config/Language/Main/LanguageMain.cs index 7292d5908cc..25a5ba921a6 100644 --- a/src/ui/Logic/Config/Language/Main/LanguageMain.cs +++ b/src/ui/Logic/Config/Language/Main/LanguageMain.cs @@ -45,9 +45,11 @@ public class LanguageMain public string LineXTimingChanged { get; set; } public string LoadingWaveInfoFromCache { get; set; } public string ClickToGenerateWaveform { get; set; } + public string WaveformFfmpegNotFoundClickToSetUp { get; set; } public string NoTextInClipboard { get; set; } public string NumberOfLinesEvenlyDistributedX { get; set; } public string OneLineCopiedFromOriginal { get; set; } + public string OneLineCopiedToOriginal { get; set; } public string OneLineMerged { get; set; } public string OneLineSwitched { get; set; } public string OverwriteExistingCells { get; set; } @@ -77,7 +79,9 @@ public class LanguageMain public string UnbreakHint { get; set; } public string UndoPerformed { get; set; } public string UndoPerformedXActionLeft { get; set; } + public string VideoAndWaveformPreviewTextX { get; set; } public string XLinesCopiedFromOriginal { get; set; } + public string XLinesCopiedToOriginal { get; set; } public string XLinesMerged { get; set; } public string XLinesSelectedOfY { get; set; } public string XLinesSwitched { get; set; } @@ -94,6 +98,7 @@ public class LanguageMain public string OriginalTextReadOnly { get; set; } public string OriginalTextEditMode { get; set; } public string OriginalIsReadOnlyNotSaved { get; set; } + public string OriginalIsEmptyNotSaved { get; set; } public string OriginalIsReadOnlyReference { get; set; } public string AllowEditOfOriginalSubtitle { get; set; } public string ShowAllOriginalLinesX { get; set; } @@ -134,10 +139,19 @@ public class LanguageMain public string ReplacedXOccurrences { get; set; } public string FfmpegDownloadedAndInstalledToX { get; set; } public string NothingToSave { get; set; } + public string FormatLimitWarningTitle { get; set; } + public string FormatLimitWarningXLinesExceedLimitsOfY { get; set; } + public string FormatLimitWarningMaxXCharactersPerLine { get; set; } + public string FormatLimitWarningMaxXLines { get; set; } + public string FormatLimitWarningTextWillBeRewrapped { get; set; } + public string FormatLimitWarningLinesX { get; set; } + public string FormatLimitWarningSaveAnyway { get; set; } + public string FormatLimitWarningDoNotShowAgain { get; set; } public string NothingToSaveOriginal { get; set; } public string LiveSpellCheckLanguageXLoaded { get; set; } public string DownloadFfmpegTitle { get; set; } public string DownloadFfmpegQuestion { get; set; } + public string FfmpegNotFoundInstallHint { get; set; } public string SelectCurrentSubtitleWhilePlayingOn { get; set; } public string SelectCurrentSubtitleWhilePlayingOff { get; set; } public string SetUpLikeSe4Question { get; set; } @@ -189,9 +203,11 @@ public LanguageMain() LineXTimingChanged = "Line {0}: Timing changed"; LoadingWaveInfoFromCache = "Loading wave info from cache..."; ClickToGenerateWaveform = "Click to generate waveform"; + WaveformFfmpegNotFoundClickToSetUp = "FFmpeg not found - click the waveform to set up FFmpeg and generate the waveform"; NoTextInClipboard = "No text in clipboard"; NumberOfLinesEvenlyDistributedX = "Evenly distributed {0} lines"; OneLineCopiedFromOriginal = "One line copied from original subtitle"; + OneLineCopiedToOriginal = "One line copied to original subtitle"; OneLineMerged = "One line merged"; OneLineSwitched = "One line switched"; OverwriteExistingCells = "Overwrite existing cells"; @@ -221,7 +237,9 @@ public LanguageMain() UnbreakHint = "Unbreak selected lines"; UndoPerformed = "Undo performed"; UndoPerformedXActionLeft = "Undo performed (actions left: {0})"; + VideoAndWaveformPreviewTextX = "Video/waveform preview: {0}"; XLinesCopiedFromOriginal = "{0} lines copied from original subtitle"; + XLinesCopiedToOriginal = "{0} lines copied to original subtitle"; XLinesMerged = "{0} lines merged"; XLinesSelectedOfY = "{0} lines selected of {1}"; XLinesSwitched = "{0} lines switched"; @@ -238,6 +256,7 @@ public LanguageMain() OriginalTextReadOnly = "Original text (read-only)"; OriginalTextEditMode = "Original text (edit mode)"; OriginalIsReadOnlyNotSaved = "The original subtitle is a read-only reference and was not saved"; + OriginalIsEmptyNotSaved = "The original subtitle has no text at all and was not saved"; OriginalIsReadOnlyReference = "The original subtitle is open as a read-only reference"; AllowEditOfOriginalSubtitle = "Allow edit of original subtitle"; ShowAllOriginalLinesX = "Show all original lines ({0} have no match here)"; @@ -278,10 +297,19 @@ public LanguageMain() ReplacedXOccurrences = "Replaced {0} occurrences"; FfmpegDownloadedAndInstalledToX = "ffmpeg downloaded and installed to {0}"; NothingToSave = "Nothing to save"; + FormatLimitWarningTitle = "Format limits exceeded"; + FormatLimitWarningXLinesExceedLimitsOfY = "{0} subtitle(s) exceed the limits of the \"{1}\" format:"; + FormatLimitWarningMaxXCharactersPerLine = "Max {0} characters per line"; + FormatLimitWarningMaxXLines = "Max {0} lines per subtitle"; + FormatLimitWarningTextWillBeRewrapped = "When saved, these subtitles will be re-wrapped or truncated to fit, so the file will not match what is shown here."; + FormatLimitWarningLinesX = "Lines: {0}"; + FormatLimitWarningSaveAnyway = "Save anyway"; + FormatLimitWarningDoNotShowAgain = "Do not show this warning again"; NothingToSaveOriginal = "Nothing to save (original)"; LiveSpellCheckLanguageXLoaded = "Live spell check language {0} loaded"; DownloadFfmpegTitle = "Download FFmpeg?"; DownloadFfmpegQuestion = "FFmpeg is required for playing online videos and for some video editing features.\n\nDownload FFmpeg now?"; + FfmpegNotFoundInstallHint = "FFmpeg was not found.\n\nPlease install FFmpeg (e.g. via your package manager) so it is available on the PATH, or set the FFmpeg path in Options -> Settings."; SelectCurrentSubtitleWhilePlayingOn = "Select current subtitle while playing: ON"; SelectCurrentSubtitleWhilePlayingOff = "Select current subtitle while playing: OFF"; SetUpLikeSe4Question = "This will import Subtitle Edit 4 shortcuts and replace rules and apply the Subtitle Edit 4 theme, toolbar and waveform look.\n\nContinue?"; diff --git a/src/ui/Logic/Config/Language/Main/LanguageMainMenu.cs b/src/ui/Logic/Config/Language/Main/LanguageMainMenu.cs index a3e1ffe1d36..95a0b0dd091 100644 --- a/src/ui/Logic/Config/Language/Main/LanguageMainMenu.cs +++ b/src/ui/Logic/Config/Language/Main/LanguageMainMenu.cs @@ -9,6 +9,12 @@ public class LanguageMainMenu public string NewKeepVideo { get; set; } public string NewWindow { get; set; } public string WindowTitle { get; set; } + + // macOS only: the Window-menu staples AppKit expects every app to author itself, + // plus File > Close window. Unused by the Windows/Linux menu. + public string WindowMinimize { get; set; } + public string WindowZoom { get; set; } + public string CloseWindow { get; set; } public string Open { get; set; } public string OpenKeepVideo { get; set; } public string OpenOriginal { get; set; } @@ -52,6 +58,7 @@ public class LanguageMainMenu public string AiReview { get; set; } public string FixCommonErrors { get; set; } public string CheckAndFixNetflixErrors { get; set; } + public string ListErrors { get; set; } public string MakeEmptyTranslationFromCurrentSubtitle { get; set; } public string MergeLinesWithSameText { get; set; } public string MergeLinesWithSameTimeCodes { get; set; } @@ -149,6 +156,9 @@ public LanguageMainMenu() NewKeepVideo = "New (keep _video)"; NewWindow = "New _window"; WindowTitle = "Window"; + WindowMinimize = "Minimize"; + WindowZoom = "Zoom"; + CloseWindow = "Close window"; Open = "_Open..."; OpenKeepVideo = "Open (_keep video)..."; OpenOriginal = "Open ori_ginal..."; @@ -186,6 +196,7 @@ public LanguageMainMenu() AiReview = "AI review..."; FixCommonErrors = "_Fix common errors..."; CheckAndFixNetflixErrors = "Check and fix Netfli_x errors..."; + ListErrors = "L_ist errors..."; MakeEmptyTranslationFromCurrentSubtitle = "Make new _empty translation from current subtitle"; MergeLinesWithSameText = "_Merge lines with same text..."; MergeLinesWithSameTimeCodes = "Merge lines with same time codes..."; diff --git a/src/ui/Logic/Config/Language/Main/LanguageMainWaveform.cs b/src/ui/Logic/Config/Language/Main/LanguageMainWaveform.cs index 3be4804d969..741054e3e3d 100644 --- a/src/ui/Logic/Config/Language/Main/LanguageMainWaveform.cs +++ b/src/ui/Logic/Config/Language/Main/LanguageMainWaveform.cs @@ -14,6 +14,7 @@ public class LanguageMainWaveform public string ZoomVerticalHint { get; set; } public string SelectCurrentLineWhilePlayingHint { get; set; } public string VideoPosition { get; set; } + public string VideoPositionTextBox { get; set; } public string HideWaveformToolbar { get; set; } public string ResetZoomAndSpeed { get; set; } public string RemoveBlankLines { get; set; } @@ -42,6 +43,7 @@ public LanguageMainWaveform() ZoomVerticalHint = "Zoom vertical {0}"; SelectCurrentLineWhilePlayingHint = "Select current subtitle while playing {0}"; VideoPosition = "Video position {0}"; + VideoPositionTextBox = "Video position text box {0}"; HideWaveformToolbar = "Hide toolbar {0}"; ResetZoomAndSpeed = "Reset zoom & playback speed {0}"; RemoveBlankLines = "Remove blank lines {0}"; diff --git a/src/ui/Logic/Config/Language/Ocr/LanguageOcr.cs b/src/ui/Logic/Config/Language/Ocr/LanguageOcr.cs index 13a389d59ce..2d81504112f 100644 --- a/src/ui/Logic/Config/Language/Ocr/LanguageOcr.cs +++ b/src/ui/Logic/Config/Language/Ocr/LanguageOcr.cs @@ -138,6 +138,7 @@ public class LanguageOcr public string LlamaCppDownloadModelPrompt { get; set; } public string CrispEmbedNotDownloaded { get; set; } public string CrispEmbedReturnedNoText { get; set; } + public string AppleVisionReturnedNoText { get; set; } public string CrispEmbedSettingsTitle { get; set; } public string CrispEmbedDescription { get; set; } public string TrainNOcrDatabase { get; set; } @@ -293,6 +294,7 @@ public LanguageOcr() LlamaCppDownloadModelPrompt = "llama.cpp requires the selected OCR model to be downloaded. Download now?"; CrispEmbedNotDownloaded = "CrispEmbed engine/model not downloaded - download via batch convert settings"; CrispEmbedReturnedNoText = "CrispEmbed returned no text - check the model"; + AppleVisionReturnedNoText = "Apple Vision returned no text - check the language"; CrispEmbedSettingsTitle = "CrispEmbed settings"; CrispEmbedDescription = "Local OCR engine with multiple model backends - download the engine and the models here."; diff --git a/src/ui/Logic/Config/Language/Options/LanguageSettings.cs b/src/ui/Logic/Config/Language/Options/LanguageSettings.cs index d18f2f0ec4c..c6a25cbc089 100644 --- a/src/ui/Logic/Config/Language/Options/LanguageSettings.cs +++ b/src/ui/Logic/Config/Language/Options/LanguageSettings.cs @@ -54,6 +54,7 @@ public class LanguageSettings public string SubtitleGridTextDisplayWrap { get; set; } public string SubtitleGridTextDisplayEllipsis { get; set; } public string SubtitleGridLiveSpellCheck { get; set; } + public string SubtitleGridCenterText { get; set; } public string SubtitleGridShowFormatting { get; set; } public string ShowUpDownStartTime { get; set; } public string ShowUpDownEndTime { get; set; } @@ -196,6 +197,8 @@ public class LanguageSettings public string FavoriteSubtitleFormats { get; set; } public string FavoriteLanguages { get; set; } public string WebVttUseXTimestampMap { get; set; } + public string AssaAutoSetResolution { get; set; } + public string AssaAutoSetResolutionPrompt { get; set; } public string ShowStopButton { get; set; } public string ShowFullscreenButton { get; set; } @@ -310,9 +313,12 @@ public class LanguageSettings public string MusicSymbol { get; set; } public string MusicSymbolsToReplace { get; set; } public string TextToSpeechPromptMergeContinuationLines { get; set; } + public string TextToSpeechPromptSkipNoiseLines { get; set; } + public string TextToSpeechPromptDetectSpeakers { get; set; } public string UseFocusedButtonBackgroundColor { get; set; } public string FocusedButtonBackgroundColor { get; set; } public string ForceCrLfOnSave { get; set; } + public string ShowFormatLimitWarning { get; set; } public string TextBoxButtonShowAutoBreak { get; set; } public string TextBoxButtonShowUnbreak { get; set; } public string TextBoxButtonShowItalic { get; set; } @@ -328,6 +334,7 @@ public class LanguageSettings public string SubtitlePreviewProperties { get; set; } public string UsePositionFromSubtitleFile { get; set; } public string MarginIsPartOfSubtitleArea { get; set; } + public string TextJustify { get; set; } public string PixelWidthInfo { get; set; } public string SpellCheckEngineHunSpelll { get; set; } public string SpellCheckEngineMsWord { get; set; } @@ -388,6 +395,7 @@ public LanguageSettings() SubtitleGridTextDisplayWrap = "Wrap to fit window"; SubtitleGridTextDisplayEllipsis = "Single line with ellipsis"; SubtitleGridLiveSpellCheck = "Live spell check in subtitle grid"; + SubtitleGridCenterText = "Center text in subtitle grid"; SubtitleGridShowFormatting = "Show formatted (HTML/ASSA) text in subtitle grid"; ShowUpDownStartTime = "Show up/down control for \"Show\""; ShowUpDownEndTime = "Show up/down control for \"Hide\""; @@ -542,6 +550,8 @@ public LanguageSettings() DefaultSaveAsFormat = "Default \"Save as\" format"; FavoriteSubtitleFormats = "Favorite subtitle formats"; WebVttUseXTimestampMap = "WebVTT: use X-TIMESTAMP-MAP (offset time codes on load)"; + AssaAutoSetResolution = "ASSA: set resolution (PlayResX/PlayResY) from the video when a video is opened"; + AssaAutoSetResolutionPrompt = "ASSA: ask before changing the resolution to match the video (otherwise resample automatically)"; FavoriteLanguages = "Favorite languages"; FilesAndLogs = "Files and logs"; ShowErrorLogFile = "Show error log file"; @@ -643,9 +653,12 @@ public LanguageSettings() MusicSymbol = "Music symbol"; MusicSymbolsToReplace = "Music symbols to replace (separated by comma)"; TextToSpeechPromptMergeContinuationLines = "Text to speech: prompt to merge continuation lines"; + TextToSpeechPromptSkipNoiseLines = "Text to speech: prompt to skip sound/music lines"; + TextToSpeechPromptDetectSpeakers = "Text to speech: prompt to detect speaker names in the text"; UseFocusedButtonBackgroundColor = "Use focused button background color"; FocusedButtonBackgroundColor = "Focused button background color"; ForceCrLfOnSave = "Force CR+LF on save (text subtitle files)"; + ShowFormatLimitWarning = "Warn on save when lines exceed the format's limits (e.g. SCC 32 chars/line)"; TextBoxButtonShowAutoBreak = "Text box: show auto-break button"; TextBoxButtonShowUnbreak = "Text box: show unbreak button"; TextBoxButtonShowItalic = "Text box: show italic button"; @@ -661,6 +674,7 @@ public LanguageSettings() SubtitlePreviewProperties = "Subtitle preview properties"; UsePositionFromSubtitleFile = "Use position from subtitle file (TTML/PAC/EBU STL)"; MarginIsPartOfSubtitleArea = "Margin is part of the subtitle area"; + TextJustify = "Justify lines"; PixelWidthInfo = "Green lines = max-width limit | Red area = text exceeds limit"; SpellCheckEngineHunSpelll = "Hunspell"; SpellCheckEngineMsWord = "MS Word"; diff --git a/src/ui/Logic/Config/Language/Options/LanguageSettingsShortcuts.cs b/src/ui/Logic/Config/Language/Options/LanguageSettingsShortcuts.cs index 5157bed6066..065f6087ca5 100644 --- a/src/ui/Logic/Config/Language/Options/LanguageSettingsShortcuts.cs +++ b/src/ui/Logic/Config/Language/Options/LanguageSettingsShortcuts.cs @@ -22,16 +22,20 @@ public class LanguageSettingsShortcuts public string GeneralUnbreakNoSpaceCjk { get; set; } public string GeneralMergeSelectedLinesBilingual { get; set; } public string GeneralToggleTranslationMode { get; set; } + public string GeneralToggleTranslationAndOriginalInPreviews { get; set; } public string GeneralChooseLayout { get; set; } public string GeneralGoToNextSubtitle { get; set; } + public string GeneralGoToNextSubtitlePlayTranslate { get; set; } public string GeneralGoToNextSubtitleCursorAtEnd { get; set; } public string GeneralGoToPrevSubtitle { get; set; } + public string GeneralGoToPrevSubtitlePlayTranslate { get; set; } public string GeneralGoToFirstLine { get; set; } public string GeneralGoToLastLine { get; set; } public string AlsoSetVideoPosition { get; set; } public string GeneralGoToVideoPosition { get; set; } public string GeneralToggleItalic { get; set; } public string GeneralToggleBold { get; set; } + public string GeneralToggleUnderline { get; set; } public string FileOpen { get; set; } public string FileOpenKeepVideo { get; set; } @@ -74,7 +78,9 @@ public class LanguageSettingsShortcuts public string SourceView { get; set; } public string AddOrEditBookmark { get; set; } public string ToggleBookmark { get; set; } + public string ToggleForcedSelectedLines { get; set; } = string.Empty; public string GoToNextBookmark { get; set; } + public string ClearBookmarks { get; set; } public string GoToNextEmptyLine { get; set; } public string ToggleWaveformToolbar { get; set; } public string ToggleSubtitleGridFormatting { get; set; } @@ -86,6 +92,8 @@ public class LanguageSettingsShortcuts public string MoveFirstWordFromNextLineUpCurrentSubtitle { get; set; } public string MoveTextFromCursorToNextAndGoToNext { get; set; } public string MoveTextFromCursorToNextAndGoToNextAndPlay { get; set; } + public string BreakAtFirstSpaceFromCursor { get; set; } + public string BreakAtFirstSpaceFromCursorAndGoToNext { get; set; } public string ToggleFocusGridAndWaveform { get; set; } public string ToggleFocusTextBoxAndWaveform { get; set; } public string ToggleFocusTextBoxAndGrid { get; set; } @@ -162,8 +170,11 @@ public class LanguageSettingsShortcuts public string ChooseRuleProfile { get; set; } public string VideoFullScreen { get; set; } public string CopyTextFromOriginalSelectedLines { get; set; } + public string CopyTextToOriginalSelectedLines { get; set; } public string TextBoxRemoveAllFormatting { get; set; } public string TextBoxItalic { get; set; } + public string TextBoxBold { get; set; } + public string TextBoxUnderline { get; set; } public string ResetWaveformZoomAndSpeed { get; set; } public string TogglePlaybackSpeed { get; set; } public string PlaybackSpeedSlower { get; set; } @@ -173,6 +184,7 @@ public class LanguageSettingsShortcuts public string SeekSilence { get; set; } public string SeekSilenceBack { get; set; } public string SeekSilenceForward { get; set; } + public string WaveformGuessStart { get; set; } public string SetVideoPositionCurrentSubtitleStart { get; set; } public string GoToSubtitlePositionAndPause { get; set; } public string SetVideoPositionCurrentSubtitleEnd { get; set; } @@ -185,6 +197,11 @@ public class LanguageSettingsShortcuts public string ColorX { get; set; } public string SurroundWith { get; set; } public string SurroundWithXY { get; set; } + public string SurroundWithNumberX { get; set; } + public string SearchVia { get; set; } + public string SearchViaX { get; set; } + public string SearchViaNumberX { get; set; } + public string SearchViaUrlHint { get; set; } public string MoveVideoPositionMilliseconds { get; set; } public string ImportShortcutsTitle { get; set; } public string ExportShortcutsTitle { get; set; } @@ -279,16 +296,20 @@ public LanguageSettingsShortcuts() GeneralUnbreakNoSpaceCjk = "Unbreak without space (CJK)"; GeneralMergeSelectedLinesBilingual = "Merge selected lines bilingual"; GeneralToggleTranslationMode = "Toggle translation mode"; + GeneralToggleTranslationAndOriginalInPreviews = "Toggle translation and original in video/audio preview"; GeneralChooseLayout = "Choose layout"; GeneralGoToNextSubtitle = "Go to next subtitle"; + GeneralGoToNextSubtitlePlayTranslate = "Go to next subtitle (play translate)"; GeneralGoToNextSubtitleCursorAtEnd = "Go to next subtitle and set cursor at end"; GeneralGoToPrevSubtitle = "Go to previous subtitle"; + GeneralGoToPrevSubtitlePlayTranslate = "Go to previous subtitle (play translate)"; GeneralGoToFirstLine = "Go to first line"; GeneralGoToLastLine = "Go to last line"; AlsoSetVideoPosition = "Also set video position"; GeneralGoToVideoPosition = "Go to video position"; GeneralToggleItalic = "Toggle italic"; GeneralToggleBold = "Toggle bold"; + GeneralToggleUnderline = "Toggle underline"; FileOpen = "Open"; FileOpenKeepVideo = "Open (keep video)"; @@ -336,7 +357,9 @@ public LanguageSettingsShortcuts() SourceView = "Source view"; AddOrEditBookmark = "Add or edit bookmark"; ToggleBookmark = "Toggle bookmark (selected lines, no text)"; + ToggleForcedSelectedLines = "Toggle forced (selected lines)"; GoToNextBookmark = "Go to next bookmark"; + ClearBookmarks = "Clear bookmarks"; GoToNextEmptyLine = "Go to next empty line"; ToggleWaveformToolbar = "Toggle waveform toolbar"; ToggleSubtitleGridFormatting = "Toggle grid formatting (show formatting/show tags/no formatting/hide tags)"; @@ -348,6 +371,8 @@ public LanguageSettingsShortcuts() MoveFirstWordFromNextLineUpCurrentSubtitle = "Move first word from next line up (current subtitle)"; MoveTextFromCursorToNextAndGoToNext = "Move text after cursor position to next subtitle and go to next"; MoveTextFromCursorToNextAndGoToNextAndPlay = "Move text after cursor position to next subtitle, go to next and play"; + BreakAtFirstSpaceFromCursor = "Break at first space from cursor position"; + BreakAtFirstSpaceFromCursorAndGoToNext = "Break at first space from cursor position and go to next"; ToggleFocusGridAndWaveform = "Toggle focus between subtitle grid and waveform/spectrogram"; ToggleFocusTextBoxAndWaveform = "Toggle focus between text box and waveform/spectrogram"; ToggleFocusTextBoxAndGrid = "Toggle focus between text box and subtitle grid"; @@ -424,8 +449,11 @@ public LanguageSettingsShortcuts() ChooseRuleProfile = "Choose rule profile"; VideoFullScreen = "Video full screen"; CopyTextFromOriginalSelectedLines = "Copy text from original (selected lines)"; + CopyTextToOriginalSelectedLines = "Copy text to original (selected lines)"; TextBoxRemoveAllFormatting = "Text box, remove all formatting"; TextBoxItalic = "Text box italic"; + TextBoxBold = "Text box bold"; + TextBoxUnderline = "Text box underline"; ResetWaveformZoomAndSpeed = "Reset waveform zoom and playback speed (play rate)"; TogglePlaybackSpeed = "Toggle playback speed (play rate)"; PlaybackSpeedSlower = "Playback speed slower (play rate)"; @@ -435,6 +463,7 @@ public LanguageSettingsShortcuts() SeekSilence = "Seek silence"; SeekSilenceBack = "Seek silence back"; SeekSilenceForward = "Seek silence forward"; + WaveformGuessStart = "Guess start time from waveform"; SetVideoPositionCurrentSubtitleStart = "Set video position to current line start"; GoToSubtitlePositionAndPause = "Go to sub position and pause"; SetVideoPositionCurrentSubtitleEnd = "Set video position to current line end"; @@ -447,6 +476,11 @@ public LanguageSettingsShortcuts() ColorX = "Color {0}"; SurroundWith = "Surround with..."; SurroundWithXY = "Surround with {0}/{1}"; + SurroundWithNumberX = "Surround with #{0}"; + SearchVia = "Search via"; + SearchViaX = "Search via {0}"; + SearchViaNumberX = "Search via #{0}"; + SearchViaUrlHint = "The searched text takes the place of {0} in the URL"; MoveVideoPositionMilliseconds = "Move video position in milliseconds"; ImportShortcutsTitle = "Import shortcuts"; ExportShortcutsTitle = "Export shortcuts"; diff --git a/src/ui/Logic/Config/Language/SeLanguage.cs b/src/ui/Logic/Config/Language/SeLanguage.cs index a199a617536..b01b1f2ae03 100644 --- a/src/ui/Logic/Config/Language/SeLanguage.cs +++ b/src/ui/Logic/Config/Language/SeLanguage.cs @@ -36,6 +36,7 @@ public class SeLanguage public LanguageOcr Ocr { get; set; } = new(); public LanguageAssa Assa { get; set; } = new(); public LanguageAbout About { get; set; } = new(); + public LanguageErrorList ErrorList { get; set; } = new(); /// /// Serializes a language to the exact text used for English.json - the base file every diff --git a/src/ui/Logic/Config/Language/SpellCheck/LanguageSpellCheck.cs b/src/ui/Logic/Config/Language/SpellCheck/LanguageSpellCheck.cs index f2e40381962..f600910645f 100644 --- a/src/ui/Logic/Config/Language/SpellCheck/LanguageSpellCheck.cs +++ b/src/ui/Logic/Config/Language/SpellCheck/LanguageSpellCheck.cs @@ -33,6 +33,7 @@ public class LanguageSpellCheck public string ChooseSpellCheckDictionary { get; set; } public string PickLiveSpellCheckDictionary { get; set; } public string UndoX { get; set; } + public string PlayCurrentLineHint { get; set; } public string EditWholeText { get; set; } public string EditWholeTextTitle { get; set; } public string ContinueFromCurrentLine { get; set; } @@ -76,6 +77,7 @@ public LanguageSpellCheck() ChooseSpellCheckDictionary = "Choose spell check dictionary"; PickLiveSpellCheckDictionary = "Pick live spell check dictionary"; UndoX = "Undo: {0}"; + PlayCurrentLineHint = "Play the current line in the video player and pause at its end"; EditWholeText = "Edit whole text"; EditWholeTextTitle = "Spell check - Edit whole text"; ContinueFromCurrentLine = "Continue spell check from the current line?\n\nYes = continue from the current line\nNo = start from the beginning"; diff --git a/src/ui/Logic/Config/Language/Sync/LanguageSync.cs b/src/ui/Logic/Config/Language/Sync/LanguageSync.cs index fb2f7358c21..d8fc9b3786d 100644 --- a/src/ui/Logic/Config/Language/Sync/LanguageSync.cs +++ b/src/ui/Logic/Config/Language/Sync/LanguageSync.cs @@ -29,6 +29,7 @@ public class LanguageSync public string SyncPoints { get; set; } public string PointSync { get; set; } public string PointSyncViaOther { get; set; } + public string SyncPointCandidateInfo { get; set; } public string AdjustAllShortcuts { get; set; } public string AdjustAllShortcutsFrames { get; set; } public string OffsetInSeconds { get; set; } @@ -62,6 +63,7 @@ public LanguageSync() SyncPoints = "Sync points"; PointSync = "Point sync"; PointSyncViaOther = "Point sync via other subtitle"; + SyncPointCandidateInfo = "Lines starting after {0}+ seconds of silence are highlighted - they often make reliable sync points"; AdjustAllShortcuts = "Keyboard shortcuts:\r\n\r\n• Shift + Left/Right: Move 10 ms\r\n• Ctrl + Left/Right: Move 100 ms\r\n• Alt + Left/Right: Move 500 ms"; AdjustAllShortcutsFrames = "Keyboard shortcuts:\r\n\r\n• Shift + Left/Right: Move 1 frame\r\n• Ctrl + Left/Right: Move 10 frames\r\n• Alt + Left/Right: Move 1 second"; OffsetInSeconds = "Offset in seconds"; diff --git a/src/ui/Logic/Config/Language/Tools/LanguageBatchConvert.cs b/src/ui/Logic/Config/Language/Tools/LanguageBatchConvert.cs index e5b5f7010a3..8bf66fd4ae3 100644 --- a/src/ui/Logic/Config/Language/Tools/LanguageBatchConvert.cs +++ b/src/ui/Logic/Config/Language/Tools/LanguageBatchConvert.cs @@ -45,6 +45,10 @@ public class LanguageBatchConvert public string AssaChangeStyleToStyle { get; set; } public string AssaChangeStyleImportStyle { get; set; } public string AssaChangeStyleTrimUnusedStyles { get; set; } + public string AssaChangeStylePropertiesTitle { get; set; } + public string AssaChangeStylePropertiesInfo { get; set; } + public string AssaChangeStylePropertiesSetSpacing { get; set; } + public string AssaChangeStylePropertiesSetAlignment { get; set; } public string ConvertColorsToDialogTitle { get; set; } public string ConvertColorsToDialogRemoveColorTags { get; set; } public string ConvertColorsToDialogAddNewLines { get; set; } @@ -97,6 +101,10 @@ public LanguageBatchConvert() AssaChangeStyleToStyle = "to"; AssaChangeStyleImportStyle = "Import style..."; AssaChangeStyleTrimUnusedStyles = "Trim unused styles"; + AssaChangeStylePropertiesTitle = "Change style properties"; + AssaChangeStylePropertiesInfo = "Changes the chosen fields in every style in the file, leaving the styles themselves alone."; + AssaChangeStylePropertiesSetSpacing = "Set spacing to"; + AssaChangeStylePropertiesSetAlignment = "Set alignment to"; AssaSource = "ASSA source"; ConvertColorsToDialogTitle = "Convert colors to dialog"; ConvertColorsToDialogRemoveColorTags = "Remove color tags"; diff --git a/src/ui/Logic/Config/Language/Tools/LanguageBeautifyTimeCodes.cs b/src/ui/Logic/Config/Language/Tools/LanguageBeautifyTimeCodes.cs index ab05fff3b3d..19128bf0fa4 100644 --- a/src/ui/Logic/Config/Language/Tools/LanguageBeautifyTimeCodes.cs +++ b/src/ui/Logic/Config/Language/Tools/LanguageBeautifyTimeCodes.cs @@ -25,6 +25,13 @@ public class LanguageBeautifyTimeCodes public string BatchFrameRateFromVideo { get; set; } public string BatchFrameRateFixed { get; set; } public string BatchInfo { get; set; } + public string UseExactTimeCodes { get; set; } + public string ExtractTimeCodes { get; set; } + public string ExtractingTimeCodes { get; set; } + public string NoTimeCodesLoaded { get; set; } + public string XTimeCodesLoaded { get; set; } + public string TimeCodesExtractFailed { get; set; } + public string TimeCodesHint { get; set; } public LanguageBeautifyTimeCodes() { @@ -51,5 +58,12 @@ public LanguageBeautifyTimeCodes() BatchFrameRateFromVideo = "Use frame rate from video file with same name as subtitle file"; BatchFrameRateFixed = "Use fixed frame rate"; BatchInfo = "Shot changes are read from a video file with the same name as the subtitle file, if one is found."; + UseExactTimeCodes = "Use exact time codes from video"; + ExtractTimeCodes = "Extract time codes"; + ExtractingTimeCodes = "Extracting time codes..."; + NoTimeCodesLoaded = "No time codes loaded"; + XTimeCodesLoaded = "{0} time codes loaded"; + TimeCodesExtractFailed = "Could not read time codes from the video"; + TimeCodesHint = "Snap cues to the video's real frame times instead of assuming a constant frame rate. Requires reading the whole video once; the result is cached."; } } diff --git a/src/ui/Logic/Config/Language/Tools/LanguageFixCommonErrors.cs b/src/ui/Logic/Config/Language/Tools/LanguageFixCommonErrors.cs index b707a9a7cf7..dfa4974b898 100644 --- a/src/ui/Logic/Config/Language/Tools/LanguageFixCommonErrors.cs +++ b/src/ui/Logic/Config/Language/Tools/LanguageFixCommonErrors.cs @@ -18,6 +18,7 @@ public class LanguageFixCommonErrors public string FixShortDisplayTimes { get; set; } public string FixLongDisplayTimes { get; set; } public string FixShortGaps { get; set; } + public string FixShortGap { get; set; } public string FixInvalidItalicTags { get; set; } public string RemoveUnneededSpaces { get; set; } public string RemoveUnneededPeriods { get; set; } @@ -143,6 +144,7 @@ public LanguageFixCommonErrors() FixShortDisplayTimes = "Fix short display times"; FixLongDisplayTimes = "Fix long display times"; FixShortGaps = "Fix short gaps"; + FixShortGap = "Fix short gap"; FixInvalidItalicTags = "Fix invalid italic tags"; RemoveUnneededSpaces = "Remove unneeded spaces"; RemoveUnneededPeriods = "Remove unneeded periods"; diff --git a/src/ui/Logic/Config/Language/Tools/LanguageImageBasedEdit.cs b/src/ui/Logic/Config/Language/Tools/LanguageImageBasedEdit.cs index 645b3c74116..df18e85ae22 100644 --- a/src/ui/Logic/Config/Language/Tools/LanguageImageBasedEdit.cs +++ b/src/ui/Logic/Config/Language/Tools/LanguageImageBasedEdit.cs @@ -23,6 +23,7 @@ public class LanguageImageBasedEdit public string ResizeImages { get; set; } public string Percentage { get; set; } public string ResizeImagesInfo { get; set; } + public string OriginalSizeXNewSizeY { get; set; } public string AdjustColorDotDotDot { get; set; } public string AdjustColor { get; set; } public string ColorAdjustmentInfo { get; set; } @@ -43,6 +44,7 @@ public class LanguageImageBasedEdit public string XInTopBar { get; set; } public string XInBottomBar { get; set; } public string NoImageSubtitlesLoaded { get; set; } + public string ImageBasedFormatNotSupported { get; set; } public LanguageImageBasedEdit() { @@ -67,6 +69,7 @@ public LanguageImageBasedEdit() ResizeImages = "Resize images"; Percentage = "Percentage"; ResizeImagesInfo = "Enter the percentage to resize images.\nPreview updates automatically."; + OriginalSizeXNewSizeY = "Original: {0} \u00d7 {1} px\nNew: {2} \u00d7 {3} px"; AdjustColorDotDotDot = "Adjust color..."; AdjustColor = "Adjust color"; ColorAdjustmentInfo = "Click the color swatch to pick a color. Bright subtitle pixels shift toward the chosen hue; dark outlines and shadows are preserved.\nPreview shows the first selected subtitle."; @@ -87,5 +90,6 @@ public LanguageImageBasedEdit() XInTopBar = "{0} in top bar"; XInBottomBar = "{0} in bottom bar"; NoImageSubtitlesLoaded = "No image subtitles loaded"; + ImageBasedFormatNotSupported = "Image based subtitle format not found/supported."; } } \ No newline at end of file diff --git a/src/ui/Logic/Config/Language/Video/LanguageAudioToText.cs b/src/ui/Logic/Config/Language/Video/LanguageAudioToText.cs index 7826ff08988..13d4b6816e7 100644 --- a/src/ui/Logic/Config/Language/Video/LanguageAudioToText.cs +++ b/src/ui/Logic/Config/Language/Video/LanguageAudioToText.cs @@ -1,4 +1,4 @@ -namespace Nikse.SubtitleEdit.Logic.Config.Language; +namespace Nikse.SubtitleEdit.Logic.Config.Language; public class LanguageAudioToText { @@ -32,6 +32,33 @@ public class LanguageAudioToText public string FixCasing { get; set; } public string AddPeriods { get; set; } public string ChangeUnderlineToColor { get; set; } + public string RemoveNonSpeechLines { get; set; } + public string RemoveNonSpeechLinesHint { get; set; } + public string RemoveRepeatedLines { get; set; } + public string RemoveRepeatedLinesHint { get; set; } + public string ShowQualityReport { get; set; } + public string QualityReportTitle { get; set; } + public string QualityReportNoIssues { get; set; } + public string QualityReportSummaryX { get; set; } + public string QualityReportLinesChecked { get; set; } + public string QualityReportIssuesFound { get; set; } + public string QualityReportLinesRemoved { get; set; } + public string QualityReportTooShort { get; set; } + public string QualityReportTooShortHint { get; set; } + public string QualityReportTooLong { get; set; } + public string QualityReportTooLongHint { get; set; } + public string QualityReportOverlap { get; set; } + public string QualityReportOverlapHint { get; set; } + public string QualityReportNonSpeech { get; set; } + public string QualityReportNonSpeechHint { get; set; } + public string QualityReportRepeated { get; set; } + public string QualityReportRepeatedHint { get; set; } + public string QualityReportRemoved { get; set; } + public string QualityReportAll { get; set; } + public string QualityReportIssue { get; set; } + public string QualityReportDetail { get; set; } + public string QualityReportTip { get; set; } + public string QualityReportDoNotShowAgain { get; set; } public string EngineSettings { get; set; } public string EngineSettingsSubtitle { get; set; } @@ -71,6 +98,33 @@ public LanguageAudioToText() FixCasing = "Fix casing"; AddPeriods = "Add periods"; ChangeUnderlineToColor = "Change underline to color"; + RemoveNonSpeechLines = "Remove non-speech lines"; + RemoveNonSpeechLinesHint = "Drop lines that only describe sound, like \"[Music]\" or \"(waves crashing)\""; + RemoveRepeatedLines = "Remove repeated lines"; + RemoveRepeatedLinesHint = "Drop lines that repeat the previous line word for word (engine loops)"; + ShowQualityReport = "Show quality report after transcription"; + QualityReportTitle = "Transcription quality report"; + QualityReportNoIssues = "No issues found - the transcription looks good."; + QualityReportSummaryX = "{0} issue(s) found in {1} line(s)"; + QualityReportLinesChecked = "Lines checked"; + QualityReportIssuesFound = "Issues found"; + QualityReportLinesRemoved = "Lines removed"; + QualityReportTooShort = "Too short"; + QualityReportTooShortHint = "Shorter than the minimum display time, or reading speed above the maximum"; + QualityReportTooLong = "Too long"; + QualityReportTooLongHint = "Longer than the maximum display time, or very few words over a long time (often a hallucination)"; + QualityReportOverlap = "Overlapping"; + QualityReportOverlapHint = "Ends after the next line starts"; + QualityReportNonSpeech = "Non-speech"; + QualityReportNonSpeechHint = "Only a sound or music description, like \"[Music]\""; + QualityReportRepeated = "Repeated"; + QualityReportRepeatedHint = "Same text as the previous line (engine loop)"; + QualityReportRemoved = "Removed"; + QualityReportAll = "All"; + QualityReportIssue = "Issue"; + QualityReportDetail = "Detail"; + QualityReportTip = "Lines are listed with their numbers in the new subtitle. Use \"Fix common errors\" or the post-processing settings to fix more automatically, or try another model/engine if many lines are affected."; + QualityReportDoNotShowAgain = "Do not show this report again"; EngineSettings = "Speech-to-text engine settings"; EngineSettingsSubtitle = "Speech-to-text engine"; diff --git a/src/ui/Logic/Config/Language/Video/LanguageTextToSpeech.cs b/src/ui/Logic/Config/Language/Video/LanguageTextToSpeech.cs index 8612ad91f88..67a8db7a9a8 100644 --- a/src/ui/Logic/Config/Language/Video/LanguageTextToSpeech.cs +++ b/src/ui/Logic/Config/Language/Video/LanguageTextToSpeech.cs @@ -30,6 +30,9 @@ public class LanguageTextToSpeech public string ElevenLabsSettingsResetHint { get; set; } public string RegenerateAudio { get; set; } public string AutoContinuePlaying { get; set; } + public string PlayLine { get; set; } + public string FitDurationToGeneratedAudio { get; set; } + public string ResetTiming { get; set; } public string AddingAudioToVideoFileDotDotDot { get; set; } public string PreparingMergeDotDotDot { get; set; } public string ImportVoiceDotDotDot { get; set; } @@ -80,6 +83,8 @@ public class LanguageTextToSpeech public string VoiceCloneConsentAccept { get; set; } public string VoiceCloneConsentDeclined { get; set; } public string AdvancedTtsSettings { get; set; } + public string AdvancedTtsAudioProcessing { get; set; } + public string AdvancedTtsOutput { get; set; } public string ProAudioPostProcessing { get; set; } public string ProAudioPostProcessingDescription { get; set; } public string AudioDucking { get; set; } @@ -90,6 +95,8 @@ public class LanguageTextToSpeech public string MaxSilenceMs { get; set; } public string HighQualityTimeStretch { get; set; } public string HighQualityTimeStretchDescription { get; set; } + public string RubberbandInstalled { get; set; } + public string RubberbandNotFound { get; set; } public string SilencePaddingMs { get; set; } public string SilencePaddingMsDescription { get; set; } public string OutputSampleRate { get; set; } @@ -141,6 +148,17 @@ public class LanguageTextToSpeech public string NoWebVttVoicesFoundMessage { get; set; } public string MergeContinuationLinesPromptTitle { get; set; } public string MergeContinuationLinesPromptMessage { get; set; } + public string SkipNoiseLinesPromptTitle { get; set; } + public string SkipNoiseLinesPromptMessage { get; set; } + public string SkipNoiseLinesTitle { get; set; } + public string SkipNoiseLinesFoundX { get; set; } + public string SkipNoiseLinesColumnSkip { get; set; } + public string DetectSpeakersPromptTitle { get; set; } + public string DetectSpeakersPromptMessage { get; set; } + public string DetectSpeakersTitle { get; set; } + public string DetectSpeakersFoundX { get; set; } + public string DetectSpeakersColumnUse { get; set; } + public string DetectSpeakersSticky { get; set; } // Applying the TTS window's changes (review text edits, merged lines) back to the subtitle public string SubtitleUpdatedFromReviewSingular { get; set; } @@ -176,6 +194,9 @@ public LanguageTextToSpeech() ElevenLabsSettingsResetHint = "Reset ElevenLabs settings to default values"; RegenerateAudio = "Regenerate audio"; AutoContinuePlaying = "Auto-continue playing"; + PlayLine = "Play line"; + FitDurationToGeneratedAudio = "Fit duration to generated audio"; + ResetTiming = "Reset timing"; AddingAudioToVideoFileDotDotDot = "Adding audio to video file..."; PreparingMergeDotDotDot = "Preparing merge..."; ImportVoiceDotDotDot = "Import voice..."; @@ -220,30 +241,34 @@ public LanguageTextToSpeech() VoiceCloneConsentAccept = "Accept and continue"; VoiceCloneConsentDeclined = "Voice cloning is not available until these terms are accepted."; AdvancedTtsSettings = "Advanced TTS settings"; + AdvancedTtsAudioProcessing = "Audio processing"; + AdvancedTtsOutput = "Output"; ProAudioPostProcessing = "Pro audio post-processing"; - ProAudioPostProcessingDescription = "Applies EQ warmth, noise gate, compression, loudness normalization (-16 LUFS), and fade in/out to each segment."; + ProAudioPostProcessingDescription = "Adds EQ, noise gate, compression, loudness normalization (-16 LUFS) and a short fade in/out to every clip."; AudioDucking = "Audio ducking"; - AudioDuckingDescription = "Reduces the original video audio volume and mixes it with the TTS audio, so the original soundtrack is still faintly audible."; + AudioDuckingDescription = "Turns the original video sound down and mixes the speech over it, so the original track stays faintly audible. Only applies when the speech is added to the video file."; OriginalVolumePercent = "Original volume %"; VadSilenceCompression = "VAD silence compression"; - VadSilenceCompressionDescription = "Shortens pauses between words before changing tempo. Uses Voice Activity Detection to compress only silence gaps while keeping speech untouched. This is the preferred first step — it reduces duration without any quality loss."; + VadSilenceCompressionDescription = "Shortens the pauses between words instead of speeding up the speech, so a clip fits without any loss of quality."; MaxSilenceMs = "Max silence (ms)"; - HighQualityTimeStretch = "High-quality time-stretch (WSOLA/rubberband)"; - HighQualityTimeStretchDescription = "Uses the rubberband algorithm (WSOLA) instead of the default atempo filter for pitch-preserving speed changes. Produces more natural-sounding speech, especially at higher speed factors. Requires librubberband in your FFmpeg build — falls back to atempo automatically if unavailable."; + HighQualityTimeStretch = "High-quality time-stretch"; + HighQualityTimeStretchDescription = "Speeds up speech with the rubberband algorithm, which sounds more natural than the default. Needs librubberband in FFmpeg; otherwise the default is used."; + RubberbandInstalled = "(installed)"; + RubberbandNotFound = "(not found in FFmpeg)"; SilencePaddingMs = "Silence padding (ms)"; - SilencePaddingMsDescription = "Adds a short silence at the end of each segment. Useful for breathing room between sentences."; - OutputSampleRate = "Output sample rate (0 = default)"; - OutputSampleRateDescription = "Resamples all segments to the specified sample rate (e.g. 44100, 48000). Set to 0 to keep the original rate."; + SilencePaddingMsDescription = "Extra silence added at the end of every clip, to give room between sentences."; + OutputSampleRate = "Output sample rate"; + OutputSampleRateDescription = "Resamples every clip, e.g. to 44100 or 48000. Use 0 to keep the original rate."; GenerationFolder = "Generation folder"; - GenerationFolderDescription = "Folder for the audio clips generated while a run is in progress. Leave empty to use the system temp folder. Each run gets its own sub-folder."; + GenerationFolderDescription = "Where clips are written while generating. Leave empty for the system temp folder. Each run gets its own sub-folder."; DeleteTempFiles = "Delete generated clips when closing"; - DeleteTempFilesDescription = "Removes the run's generation folder when the Text to speech window closes. The saved audio and video files are not affected. Turn this off to keep the individual clips."; + DeleteTempFilesDescription = "Deletes the generation folder when the Text to speech window closes. Saved audio and video files are kept."; EdgeTtsRate = "Edge-TTS rate"; - EdgeTtsRateDescription = "Speech rate for Edge-TTS, e.g. \"+50%\", \"-30%\", or \"+0%\" for default."; + EdgeTtsRateDescription = "Speech rate, e.g. \"+50%\" or \"-30%\". Use \"+0%\" for the default."; EdgeTtsPitch = "Edge-TTS pitch"; - EdgeTtsPitchDescription = "Pitch adjustment for Edge-TTS, e.g. \"+10Hz\", \"-5Hz\", or \"+0Hz\" for default."; + EdgeTtsPitchDescription = "Pitch adjustment, e.g. \"+10Hz\" or \"-5Hz\". Use \"+0Hz\" for the default."; EdgeTtsVolume = "Edge-TTS volume"; - EdgeTtsVolumeDescription = "Volume adjustment for Edge-TTS, e.g. \"+20%\", \"-10%\", or \"+0%\" for default."; + EdgeTtsVolumeDescription = "Volume adjustment, e.g. \"+20%\" or \"-10%\". Use \"+0%\" for the default."; DownloadPiperPrompt = $"\"Text to speech\" requires Piper.{Environment.NewLine}{Environment.NewLine}Download and use Piper?"; OmniVoiceTtsSettings = "OmniVoice TTS settings"; @@ -282,6 +307,21 @@ public LanguageTextToSpeech() MergeContinuationLinesPromptMessage = "Some lines appear to be a single sentence split across multiple subtitles." + Environment.NewLine + Environment.NewLine + "Merging them before generation lets the TTS engine speak each thought as one breath group, which usually sounds more natural." + Environment.NewLine + Environment.NewLine + "Review and apply merges now?"; + SkipNoiseLinesPromptTitle = "Skip sound and music lines?"; + SkipNoiseLinesPromptMessage = "Some lines contain only sounds or music - like ♪ or [door slams]." + Environment.NewLine + Environment.NewLine + + "Speech engines try to read such lines aloud and often make up words." + Environment.NewLine + Environment.NewLine + + "Review these lines and leave them silent?"; + SkipNoiseLinesTitle = "Lines with only sounds or music"; + SkipNoiseLinesFoundX = "{0} lines contain only sounds or music - checked lines are left silent"; + SkipNoiseLinesColumnSkip = "Skip"; + DetectSpeakersPromptTitle = "Speaker names found in the text?"; + DetectSpeakersPromptMessage = "Some lines start with a speaker name - like \"MIKE:\" or \"[NARRATOR]\"." + Environment.NewLine + Environment.NewLine + + "Moved into the actor field, each speaker can get their own voice via \"Set up cast\", and the name is not read aloud." + Environment.NewLine + Environment.NewLine + + "Review the names now? (only the speech generation is affected - the subtitle itself is not changed)"; + DetectSpeakersTitle = "Speaker names in the text"; + DetectSpeakersFoundX = "{0} speaker tags found ({1} speakers) - checked names become actors and are not read aloud"; + DetectSpeakersColumnUse = "Use"; + DetectSpeakersSticky = "Lines without a name continue the previous speaker"; SubtitleUpdatedFromReviewSingular = "Updated one line from the speech review"; SubtitleUpdatedFromReviewPlural = "Updated {0} lines from the speech review"; diff --git a/src/ui/Logic/Config/Language/Video/LanguageVideo.cs b/src/ui/Logic/Config/Language/Video/LanguageVideo.cs index 93e26331fd1..f1c726b8d9c 100644 --- a/src/ui/Logic/Config/Language/Video/LanguageVideo.cs +++ b/src/ui/Logic/Config/Language/Video/LanguageVideo.cs @@ -20,6 +20,8 @@ public class LanguageVideo public string OpenSecondarySubtitleOnVideoPlayerDotDotDot { get; set; } public string OpenSecondarySubtitleOnVideoPlayer { get; set; } public string RemoveSecondarySubtitleOnVideoPlayer { get; set; } + public string OpenRecentVideo { get; set; } + public string ClearRecentVideos { get; set; } public string CutVideoTitle { get; set; } public string CutVideoDotDotDot { get; set; } public string EmbedSubtitlesDotDotDot { get; set; } @@ -111,9 +113,11 @@ public LanguageVideo() GenerateBlankVideoDotDotDot = "Generate blank video..."; ReEncodeVideoForBetterSubtitlingTitle = "Re-encode video for better subtitling"; ReEncodeVideoForBetterSubtitlingDotDotDot = "Re-encode video for better subtitling..."; - OpenSecondarySubtitleOnVideoPlayer = "Secondary subtitle (on video player)"; - OpenSecondarySubtitleOnVideoPlayerDotDotDot = "Secondary subtitle on video player, open..."; - RemoveSecondarySubtitleOnVideoPlayer = "Secondary subtitle on video player, remove"; + OpenSecondarySubtitleOnVideoPlayer = "Second subtitle file (on video player)"; + OpenSecondarySubtitleOnVideoPlayerDotDotDot = "Open second subtitle file..."; + RemoveSecondarySubtitleOnVideoPlayer = "Remove second subtitle file"; + OpenRecentVideo = "Open recent video"; + ClearRecentVideos = "Clear recent videos"; CutVideoTitle = "Cut video"; CutVideoDotDotDot = "Cut video..."; EmbedSubtitlesDotDotDot = "Add/remove embedded subtitles..."; diff --git a/src/ui/Logic/Config/Language/Video/LanguageVideoOcr.cs b/src/ui/Logic/Config/Language/Video/LanguageVideoOcr.cs index d30ca8eac7e..2619b5171d3 100644 --- a/src/ui/Logic/Config/Language/Video/LanguageVideoOcr.cs +++ b/src/ui/Logic/Config/Language/Video/LanguageVideoOcr.cs @@ -24,6 +24,17 @@ public class LanguageVideoOcr public string NoLinesFoundTitle { get; set; } public string NoLinesFoundMessage { get; set; } public string LinesFoundX { get; set; } + public string RefiningTimingXY { get; set; } + public string FixOcrErrors { get; set; } + public string Dictionary { get; set; } + public string EditLineX { get; set; } + public string DownloadDictionaryHint { get; set; } + public string FramesPerSecondHint { get; set; } + public string TextBrightnessMinimumHint { get; set; } + public string TextSimilarityPercentHint { get; set; } + public string MaxGapHint { get; set; } + public string MinDurationHint { get; set; } + public string DictionaryHint { get; set; } public string PreviewPosition { get; set; } public string UnableToReadVideoTitle { get; set; } public string UnableToReadVideoMessage { get; set; } @@ -58,6 +69,17 @@ public LanguageVideoOcr() NoLinesFoundTitle = "No subtitles found"; NoLinesFoundMessage = "No text was found in the scan area - try adjusting the scan area, engine, or brightness minimum."; LinesFoundX = "{0} lines found"; + RefiningTimingXY = "Refining timing... {0}/{1}"; + FixOcrErrors = "Fix OCR errors"; + Dictionary = "Dictionary"; + EditLineX = "Edit line {0}"; + DownloadDictionaryHint = "Download a spell check dictionary..."; + FramesPerSecondHint = "How many frames per second are scanned. 5 covers normal subtitles; higher catches very short lines and tightens grouping, but costs more OCR time."; + TextBrightnessMinimumHint = "Pixels darker than this do not count as subtitle text: frame grouping follows the bright text, frames without any bright pixels are skipped, and the Paddle engine reads frames with everything darker blacked out. Set to 0 for dark subtitle text."; + TextSimilarityPercentHint = "How similar (in percent) two consecutive OCR results must be to count as the same subtitle and merge into one line."; + MaxGapHint = "Longest pause between two similar OCR results that still merges them into one subtitle - bridges one-frame flickers. Too high can merge a line that is shown twice in a row."; + MinDurationHint = "Lines on screen shorter than this are dropped as OCR blips."; + DictionaryHint = "Spell check dictionary used by the OCR fix engine and the word coloring: known words show green, unknown words red."; PreviewPosition = "Preview position"; UnableToReadVideoTitle = "Unable to read video"; UnableToReadVideoMessage = "Could not read video info from: {0}"; diff --git a/src/ui/Logic/Config/Se.cs b/src/ui/Logic/Config/Se.cs index 9a68917c403..6c47a8ae61e 100644 --- a/src/ui/Logic/Config/Se.cs +++ b/src/ui/Logic/Config/Se.cs @@ -3,6 +3,7 @@ using Nikse.SubtitleEdit.Core.SubtitleFormats; using Nikse.SubtitleEdit.Features.Main; using Nikse.SubtitleEdit.Logic.Config.Language; +using Nikse.SubtitleEdit.UiLogic.Ocr; using System; using System.Collections.Generic; using System.Globalization; @@ -19,7 +20,7 @@ public class Se internal const int CurrentMacOsFontMigrationVersion = 1; internal const int CurrentShortcutsMigrationVersion = 2; - public static string Version { get; set; } = "v5.2.0-beta21"; + public static string Version { get; set; } = "v5.2.0-beta32"; public SeGeneral General { get; set; } = new(); public List Shortcuts { get; set; } = new(); @@ -38,6 +39,26 @@ public class Se public string Surround2Right { get; set; } = "♫"; public string Surround3Left { get; set; } = "["; public string Surround3Right { get; set; } = "]"; + public string Surround4Left { get; set; } = string.Empty; + public string Surround4Right { get; set; } = string.Empty; + public string Surround5Left { get; set; } = string.Empty; + public string Surround5Right { get; set; } = string.Empty; + public string Surround6Left { get; set; } = string.Empty; + public string Surround6Right { get; set; } = string.Empty; + public string Surround7Left { get; set; } = string.Empty; + public string Surround7Right { get; set; } = string.Empty; + public string Surround8Left { get; set; } = string.Empty; + public string Surround8Right { get; set; } = string.Empty; + public string CustomSearch1Name { get; set; } = "The Free Dictionary"; + public string CustomSearch1Url { get; set; } = "https://www.thefreedictionary.com/{0}"; + public string CustomSearch2Name { get; set; } = "Wikipedia"; + public string CustomSearch2Url { get; set; } = "https://en.wikipedia.org/wiki?search={0}"; + public string CustomSearch3Name { get; set; } = "DuckDuckGo"; + public string CustomSearch3Url { get; set; } = "https://duckduckgo.com/?q={0}"; + public string CustomSearch4Name { get; set; } = string.Empty; + public string CustomSearch4Url { get; set; } = string.Empty; + public string CustomSearch5Name { get; set; } = string.Empty; + public string CustomSearch5Url { get; set; } = string.Empty; public string Actor1 { get; set; } = "Actor 1"; public string Actor2 { get; set; } = "Actor 2"; public string Actor3 { get; set; } = "Actor 3"; @@ -174,6 +195,7 @@ public static string DictionariesFolder public static string WaveformsFolder => Path.Combine(DataFolder, "Waveforms"); public static string SpectrogramsFolder => Path.Combine(DataFolder, "Spectrograms"); public static string ShotChangesFolder => Path.Combine(DataFolder, "ShotChanges"); + public static string TimeCodesFolder => Path.Combine(DataFolder, "TimeCodes"); public static string ChaptersFolder => Path.Combine(DataFolder, "Chapters"); public static string PluginsFolder => Path.Combine(DataFolder, "Plugins"); @@ -183,17 +205,23 @@ public static string DictionariesFolder public static string OcrFolder => Path.Combine(DataFolder, "OCR"); public static string TranslationFolder => Path.Combine(DataFolder, "Languages"); // The folder name carries the PaddleOCR version the bundled standalone engine is built - // from (3-4 = PaddleOCR 3.4 = PaddleOCR-Standalone v1.4.0). Bump it whenever the engine - // or the support-files bundle moves to a new PaddleOCR release: extracting a new build - // over an old one would leave orphaned files from the previous Python/paddle runtime, - // and the old models bundle is missing recognition models the current language list - // offers (the 3.1 bundle has no arabic/cyrillic/devanagari PP-OCRv5 or el/ta/te/th/ka - // models at all). A new folder means engine and models always come from the same release. - public static string PaddleOcrFolder => Path.Combine(OcrFolder, "PaddleOCR3-4"); + // from (3-7 = PaddleOCR 3.7 = PaddleOCR-Standalone v3.7.0; upstream re-aligned its tag + // scheme to the PaddleOCR version, so v1.4.0 is followed by v3.7.0). Bump it whenever the + // engine or the support-files bundle moves to a new PaddleOCR release: extracting a new + // build over an old one would leave orphaned files from the previous Python/paddle + // runtime, and the old models bundle is missing recognition models the current language + // list offers (the 3.4 bundle has no PP-OCRv6 models, the 3.1 one has no arabic/cyrillic/ + // devanagari PP-OCRv5 or el/ta/te/th/ka models at all). A new folder means engine and + // models always come from the same release. + public static string PaddleOcrFolder => Path.Combine(OcrFolder, "PaddleOCR3-7"); public static string PaddleOcrModelsFolder => Path.Combine(PaddleOcrFolder, "models"); /// Install folders of superseded PaddleOCR versions, deleted after a new install succeeds. - public static IReadOnlyList PaddleOcrLegacyFolders => new[] { Path.Combine(OcrFolder, "PaddleOCR3-1") }; + public static IReadOnlyList PaddleOcrLegacyFolders => new[] + { + Path.Combine(OcrFolder, "PaddleOCR3-1"), + Path.Combine(OcrFolder, "PaddleOCR3-4"), + }; public static string GoogleLensOcrFolder => Path.Combine(OcrFolder, "Google-Lens"); public static string CrispEmbedFolder => Path.Combine(OcrFolder, "CrispEmbed"); public static string VlcFolder => Path.Combine(DataFolder, "VLC"); @@ -306,6 +334,91 @@ private static void SeedBundledTesseractModels(string modelFolder) } } + /// + /// Number of configurable "surround with" slots (#14232). Slots are one-based; the ones left + /// blank are simply hidden from the subtitle grid context menu. + /// + public const int SurroundWithSlotCount = 8; + + public string GetSurroundLeft(int slotNumber) => slotNumber switch + { + 1 => Surround1Left, + 2 => Surround2Left, + 3 => Surround3Left, + 4 => Surround4Left, + 5 => Surround5Left, + 6 => Surround6Left, + 7 => Surround7Left, + 8 => Surround8Left, + _ => string.Empty, + }; + + public string GetSurroundRight(int slotNumber) => slotNumber switch + { + 1 => Surround1Right, + 2 => Surround2Right, + 3 => Surround3Right, + 4 => Surround4Right, + 5 => Surround5Right, + 6 => Surround6Right, + 7 => Surround7Right, + 8 => Surround8Right, + _ => string.Empty, + }; + + public void SetSurround(int slotNumber, string left, string right) + { + switch (slotNumber) + { + case 1: Surround1Left = left; Surround1Right = right; break; + case 2: Surround2Left = left; Surround2Right = right; break; + case 3: Surround3Left = left; Surround3Right = right; break; + case 4: Surround4Left = left; Surround4Right = right; break; + case 5: Surround5Left = left; Surround5Right = right; break; + case 6: Surround6Left = left; Surround6Right = right; break; + case 7: Surround7Left = left; Surround7Right = right; break; + case 8: Surround8Left = left; Surround8Right = right; break; + } + } + + /// + /// Number of configurable "search via" slots (name + URL, SE 4 parity). Slots are one-based; + /// a slot without a URL is hidden from the text box context menu. + /// + public const int CustomSearchSlotCount = 5; + + public string GetCustomSearchName(int slotNumber) => slotNumber switch + { + 1 => CustomSearch1Name, + 2 => CustomSearch2Name, + 3 => CustomSearch3Name, + 4 => CustomSearch4Name, + 5 => CustomSearch5Name, + _ => string.Empty, + }; + + public string GetCustomSearchUrl(int slotNumber) => slotNumber switch + { + 1 => CustomSearch1Url, + 2 => CustomSearch2Url, + 3 => CustomSearch3Url, + 4 => CustomSearch4Url, + 5 => CustomSearch5Url, + _ => string.Empty, + }; + + public void SetCustomSearch(int slotNumber, string name, string url) + { + switch (slotNumber) + { + case 1: CustomSearch1Name = name; CustomSearch1Url = url; break; + case 2: CustomSearch2Name = name; CustomSearch2Url = url; break; + case 3: CustomSearch3Name = name; CustomSearch3Url = url; break; + case 4: CustomSearch4Name = name; CustomSearch4Url = url; break; + case 5: CustomSearch5Name = name; CustomSearch5Url = url; break; + } + } + public void InitializeMainShortcuts(MainViewModel vm) { MigrateShortcuts(); @@ -429,30 +542,40 @@ public static string GetSettingsFilePath() public static void LoadSettings(string settingsFileName) { - if (!System.IO.File.Exists(settingsFileName)) - { - MigrateMacOsFontSettings(Settings.Appearance, OperatingSystem.IsMacOS(), false); - return; - } + // Only the deserialize is conditional. Returning early on a missing file also skipped + // UpdateLibSeSettings() - the single bridge onto libse's Configuration.Settings - so on a + // first run libse kept its own defaults for the whole session. Among them + // RememberUseAlwaysList, which gates every _UseAlways.xml load and save, so spell + // check's "Change all" was silently session-only until the first settings save. + var settingsFileExists = System.IO.File.Exists(settingsFileName); + if (settingsFileExists) + { + try + { + // Stream + source-generated metadata: no UTF-16 string round-trip and no + // runtime reflection over the settings type graph. + using var stream = System.IO.File.OpenRead(settingsFileName); + Settings = JsonSerializer.Deserialize(stream, SeJsonContext.Default.Se)!; + } + catch (Exception exception) + { + Se.LogError(exception); + Settings = new Se(); + } - try - { - // Stream + source-generated metadata: no UTF-16 string round-trip and no - // runtime reflection over the settings type graph. - using var stream = System.IO.File.OpenRead(settingsFileName); - Settings = JsonSerializer.Deserialize(stream, SeJsonContext.Default.Se)!; + SetDefaultValues(); } - catch (Exception exception) - { - Se.LogError(exception); - Settings = new Se(); - } - - SetDefaultValues(); - MigrateMacOsFontSettings(Settings.Appearance, OperatingSystem.IsMacOS(), true); + MigrateMacOsFontSettings(Settings.Appearance, OperatingSystem.IsMacOS(), settingsFileExists); UpdateLibSeSettings(); + + // Startup-only: the libse mirror of these is session truth - Ebu.LoadSubtitle re-seeds it + // from every loaded STL file - so the persisted values may only win before any file is + // open (see the note in UpdateLibSeSettings). + Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox = Settings.File.EbuSaveOptions.TeletextUseBox; + Configuration.Settings.SubtitleSettings.EbuStlTeletextUseDoubleHeight = Settings.File.EbuSaveOptions.TeletextUseDoubleHeight; + ApplyStartupOnlyDCinemaSettings(); } internal static void MigrateMacOsFontSettings(SeAppearance appearance, bool isMacOs, bool isLegacySettings) @@ -471,6 +594,22 @@ internal static void MigrateMacOsFontSettings(SeAppearance appearance, bool isMa appearance.MacOsFontMigrationVersion = CurrentMacOsFontMigrationVersion; } + /// + /// Moves a settings file still holding the pre-#14221 llama.cpp OCR prompt onto the current + /// default. That prompt asked the models to "preserve line breaks", which measurably merged + /// two-line subtitles into one (see ), and the + /// default is persisted, so without this only fresh installs would ever get the fix. Matched + /// verbatim: a user who has edited the prompt at all keeps their own version. + /// + internal static void MigrateLlamaCppOcrPrompt(SeOcr ocr) + { + const string legacyPrompt = "Extract all text exactly as written. The language is {language}. Preserve line breaks."; + if (ocr.LlamaCppOcrPrompt?.Trim() == legacyPrompt) + { + ocr.LlamaCppOcrPrompt = SeOcrDefaults.LlamaCppOcrPrompt; + } + } + /// /// Drops "-vsync vfr" from a settings file written before ffmpeg 9. ffmpeg 9 removed the /// long-deprecated -vsync option, so it aborts with "Unrecognized option 'vsync'" and shot @@ -676,6 +815,8 @@ private static void SetDefaultValues() Settings.Ocr = new(); } + MigrateLlamaCppOcrPrompt(Settings.Ocr); + if (Settings.Formats == null) { Settings.Formats = new SeFormats(); @@ -763,6 +904,10 @@ internal static void UpdateLibSeSettings() Configuration.Settings.Tools.AutoTranslateDelaySeconds = (int)Math.Round(Settings.AutoTranslate.RequestDelaySeconds, MidpointRounding.AwayFromZero); + if (Settings.AutoTranslate.RequestMaxBytes > 0) + { + Configuration.Settings.Tools.AutoTranslateMaxBytes = (int)Math.Round(Settings.AutoTranslate.RequestMaxBytes, MidpointRounding.AwayFromZero); + } // BeautifyTimeCodes profile: skip apply on a fresh install so libse's built-in // default-preset values stay intact. Once the user clicks OK in the profile editor, @@ -778,6 +923,18 @@ internal static void UpdateLibSeSettings() var dc = Settings.File.DCinemaSmpte; var ss = Configuration.Settings.SubtitleSettings; + + // Ebu.Save reads these off the libse Configuration singleton, so a plain "Save" that + // never opens the EBU save options dialog must still see the persisted choices. The + // teletext box/double-height flags are deliberately NOT re-applied here: Ebu.LoadSubtitle + // seeds them from the loaded file, and this sync runs after every SaveSettings - it would + // clobber the file's flags. They are applied once at startup in LoadSettings instead. + var ebu = Settings.File.EbuSaveOptions; + ss.EbuStlJustificationCode = ebu.JustificationCode; + ss.EbuStlMarginTop = ebu.MarginTop; + ss.EbuStlMarginBottom = ebu.MarginBottom; + ss.EbuStlNewLineRows = ebu.NewLineRows; + ss.WebVttUseXTimestampMap = Settings.Formats.WebVttUseXTimestampMap; ss.WebVttUseMultipleXTimestampMap = Settings.Formats.WebVttUseMultipleXTimestampMap; ss.WebVttMergeLinesWithSameText = Settings.Formats.WebVttMergeLinesWithSameText; @@ -791,11 +948,39 @@ internal static void UpdateLibSeSettings() ss.WebVttCueAn7 = Settings.Formats.WebVttCueAn7 ?? string.Empty; ss.WebVttCueAn8 = Settings.Formats.WebVttCueAn8 ?? string.Empty; ss.WebVttCueAn9 = Settings.Formats.WebVttCueAn9 ?? string.Empty; + ss.TimedText10TimeCodeFormat = Settings.Formats.TimedText10TimeCodeFormat; + ss.TimedText10FileExtension = Settings.Formats.TimedText10FileExtension; + ss.TimedTextItunesTopOrigin = Settings.Formats.TimedTextItunesTopOrigin; + ss.TimedTextItunesTopExtent = Settings.Formats.TimedTextItunesTopExtent; + ss.TimedTextItunesBottomOrigin = Settings.Formats.TimedTextItunesBottomOrigin; + ss.TimedTextItunesBottomExtent = Settings.Formats.TimedTextItunesBottomExtent; + ss.TimedTextItunesTimeCodeFormat = Settings.Formats.TimedTextItunesTimeCodeFormat; + ss.TimedTextItunesStyleAttribute = Settings.Formats.TimedTextItunesStyleAttribute; + ss.TimedTextItunesLanguage = Settings.Formats.TimedTextItunesLanguage ?? string.Empty; + ss.TimedTextImsc11TimeCodeFormat = Settings.Formats.TimedTextImsc11TimeCodeFormat; + ss.TimedTextImsc11FileExtension = Settings.Formats.TimedTextImsc11FileExtension; + ss.DCinemaAutoGenerateSubtitleId = dc.DCinemaAutoGenerateSubtitleId; ss.DCinemaFontSize = dc.DCinemaFontSize; ss.DCinemaBottomMargin = dc.DCinemaBottomMargin; ss.DCinemaFadeUpTime = dc.DCinemaFadeUpTime; ss.DCinemaFadeDownTime = dc.DCinemaFadeDownTime; + Configuration.Settings.Tools.RememberUseAlwaysList = Settings.Tools.SpellCheckRememberUseAlwaysList; + } + + /// + /// The "current D-Cinema file" values, applied at startup only. Same reason as the EBU + /// teletext flags above: DCinemaSmpte2007/2010/2014.LoadSubtitle seeds every one of these from + /// the opened file, so re-applying the persisted defaults after every SaveSettings (any dialog + /// OK) threw away the reel number, rates and start time of the file the user has open. The + /// D-Cinema properties dialogs push their own values through + /// MainViewModel.CopyCurrentDCinemaSettingsFromSe instead. + /// + private static void ApplyStartupOnlyDCinemaSettings() + { + var dc = Settings.File.DCinemaSmpte; + var ss = Configuration.Settings.SubtitleSettings; + ss.CurrentDCinemaSubtitleId = dc.CurrentDCinemaSubtitleId; ss.CurrentDCinemaMovieTitle = dc.CurrentDCinemaMovieTitle; ss.CurrentDCinemaReelNumber = dc.CurrentDCinemaReelNumber; diff --git a/src/ui/Logic/Config/SeAdjustDisplayDurations.cs b/src/ui/Logic/Config/SeAdjustDisplayDurations.cs index 3c0acb9a74d..4671cb79942 100644 --- a/src/ui/Logic/Config/SeAdjustDisplayDurations.cs +++ b/src/ui/Logic/Config/SeAdjustDisplayDurations.cs @@ -9,8 +9,6 @@ public class SeAdjustDisplayDurations public int AdjustDurationPercent { get; set; } public string AdjustDurationLast { get; set; } public bool AdjustDurationExtendOnly { get; set; } - public bool AdjustDurationExtendEnforceDurationLimits { get; set; } - public bool AdjustDurationExtendCheckShotChanges { get; set; } public double AdjustDurationMaximumCps { get; set; } public double AdjustDurationOptimalCps { get; set; } diff --git a/src/ui/Logic/Config/SeAiReview.cs b/src/ui/Logic/Config/SeAiReview.cs index 4a7c2ba5d8d..f6d76cdeb38 100644 --- a/src/ui/Logic/Config/SeAiReview.cs +++ b/src/ui/Logic/Config/SeAiReview.cs @@ -20,7 +20,7 @@ public class SeAiReview public static string DefaultPrompt => "You are a subtitle proofreader. Fix typos, spelling, grammar and punctuation in {language}." + "\n\nDo not rephrase, do not change meaning, tone or style. Keep names, slang and intentional dialect as they are. " + - "Keep all formatting tags (like or {\\an8}) and line breaks exactly as they are. Only correct actual errors."; + "Keep all formatting tags (like ) and line breaks exactly as they are. Only correct actual errors."; public SeAiReview() { diff --git a/src/ui/Logic/Config/SeAppearance.cs b/src/ui/Logic/Config/SeAppearance.cs index e83ec6acda3..9cd6cf13af6 100644 --- a/src/ui/Logic/Config/SeAppearance.cs +++ b/src/ui/Logic/Config/SeAppearance.cs @@ -33,6 +33,13 @@ public class SeAppearance public int SubtitleGridFormattingType { get; set; } public bool SubtitleGridLiveSpellCheck { get; set; } + /// + /// Backdrop behind subtitle bitmap thumbnails in the OCR and binary-edit grids, as + /// #AARRGGBB. Empty means . + /// + public string ImagePreviewBackgroundColor { get; set; } + public bool SubtitleGridCenterText { get; set; } + public bool SubtitleTextBoxCenterText { get; set; } public bool SubtitleTextBoxLiveSpellCheck { get; set; } public bool ShowHints { get; set; } @@ -113,6 +120,8 @@ public SeAppearance() SubtitleTextBoxColorTags = true; ShowHints = true; SubtitleTextBoxCenterText = false; + ImagePreviewBackgroundColor = string.Empty; + SubtitleGridCenterText = false; SubtitleTextBoxLiveSpellCheck = false; SubtitleGridFormattingType = (int)SubtitleGridFormattingTypes.ShowFormatting; GridLinesAppearance = SeGridLinesVisibility.None.ToString(); diff --git a/src/ui/Logic/Config/SeAssa.cs b/src/ui/Logic/Config/SeAssa.cs index 9195920dad1..5ec6fbbf4f6 100644 --- a/src/ui/Logic/Config/SeAssa.cs +++ b/src/ui/Logic/Config/SeAssa.cs @@ -9,6 +9,13 @@ public class SeAssa { public bool AutoSetResolution { get; set; } public bool AutoSetResolutionConvert { get; set; } + + /// + /// When an opened video's picture size differs from the script's PlayResX/PlayResY, show the + /// resolution resampler pre-filled with both instead of resampling silently (#14367). + /// + public bool AutoSetResolutionPrompt { get; set; } + public List StoredStyles { get; set; } public string LastOverrideTag { get; set; } public List LastOverrideTags { get; set; } @@ -37,6 +44,7 @@ public SeAssa() { AutoSetResolution = true; AutoSetResolutionConvert = true; + AutoSetResolutionPrompt = true; // Seed the storage so a fresh install (and a settings reset) has a default style to // apply to new/converted ASSA subtitles instead of an empty "Styles saved" list. diff --git a/src/ui/Logic/Config/SeAudioToText.cs b/src/ui/Logic/Config/SeAudioToText.cs index 9b637b8d96d..97d31ec1e27 100644 --- a/src/ui/Logic/Config/SeAudioToText.cs +++ b/src/ui/Logic/Config/SeAudioToText.cs @@ -11,10 +11,6 @@ public class SeAudioToText // matches on ISpeechToTextEngine.Choice/Name, so an unknown value selects no engine at all. public string WhisperChoice { get; set; } = WhisperChoices.Cpp; - public bool WhisperIgnoreVersion { get; set; } = false; - - public bool WhisperDeleteTempFiles { get; set; } = true; - public bool WhisperAddLanguageCodeToFileName { get; set; } public string? WhisperModel { get; set; } = string.Empty; @@ -25,24 +21,18 @@ public class SeAudioToText public string WhisperCtranslate2Location { get; set; } = string.Empty; - public string WhisperPurfviewFasterWhisperLocation { get; set; } = string.Empty; - - public string WhisperPurfviewFasterWhisperDefaultCmd { get; set; } = string.Empty; - public string WhisperXLocation { get; set; } = string.Empty; public string WhisperStableTsLocation { get; set; } = string.Empty; public string WhisperCppModelLocation { get; set; } = string.Empty; - public string WhisperCustomCommandLineArguments { get; set; } = string.Empty; - public bool WhisperCustomCommandLineArgumentsPurfviewBlank { get; set; } - public string CommandLineParameterCpp { get; set; } = string.Empty; public string CommandLineParameterCppCuBlas { get; set; } = string.Empty; public string CommandLineParameterCppVulkan { get; set; } = string.Empty; public string CommandLineParameterConstMe { get; set; } = string.Empty; public string CommandLineParameterCTranslate2 { get; set; } = "--vad_filter True"; + public string CommandLineParameterWhisperX { get; set; } = string.Empty; public string CommandLineParameterPurfviewFasterWhisperXxl { get; set; } = "--standard"; public string CommandLineParameterOpenAi { get; set; } = string.Empty; public string CommandLineParameterQwen3AsrCpp { get; set; } = string.Empty; @@ -67,12 +57,8 @@ public class SeAudioToText public string CommandLineParameterCrispAsrVoxtral { get; set; } = "--max-len 50 --split-on-punct"; public string CrispAsrForcedAligner { get; set; } = "built-in"; - public string WhisperExtraSettingsHistory { get; set; } = string.Empty; - public bool WhisperAutoAdjustTimings { get; set; } = true; - public bool WhisperUseLineMaxChars { get; set; } = true; - public bool WhisperPostProcessingAddPeriods { get; set; } = false; public bool WhisperPostProcessingMergeLines { get; set; } = true; @@ -83,7 +69,12 @@ public class SeAudioToText public bool WhisperPostProcessingFixShortDuration { get; set; } = true; + public bool WhisperPostProcessingRemoveNonSpeechLines { get; set; } + + public bool WhisperPostProcessingRemoveRepeatedLines { get; set; } + + public bool WhisperPostProcessingShowQualityReport { get; set; } = true; + public bool WhisperPostProcessingChangeUnderlineToColor { get; set; } public string WhisperPostProcessingChangeUnderlineToColorColor { get; set; } = Colors.Red.FromColorToHex(); - public string WhisperCppVulkanGpuDevice { get; set; } = string.Empty; } \ No newline at end of file diff --git a/src/ui/Logic/Config/SeAutoTranslate.cs b/src/ui/Logic/Config/SeAutoTranslate.cs index 4093eb3e29d..865fa76dbd0 100644 --- a/src/ui/Logic/Config/SeAutoTranslate.cs +++ b/src/ui/Logic/Config/SeAutoTranslate.cs @@ -62,7 +62,6 @@ public class SeAutoTranslate public string OpenRouterPrompt { get; set; } public string OpenRouterApiKey { get; set; } public string OpenRouterModel { get; set; } - public string NnlbServeUrl { get; set; } public string LibreTranslateApiKey { get; set; } public string LibreTranslateUrl { get; set; } public string DeepLApiKey { get; set; } @@ -189,8 +188,6 @@ public SeAutoTranslate() NllbApiUrl = "http://localhost:7860/api/v4/"; NllbServeModel = string.Empty; NllbServeUrl = "http://127.0.0.1:6060/"; - NnlbServeUrl = "http://127.0.0.1:6060/"; - NnlbServeUrl = string.Empty; OllamaModel = string.Empty; OllamaModels = "llama3.2,llama3.2:1b,phi3,gemma2,qwen2,mistral"; OllamaPrompt = "Translate from {0} to {1}, keep punctuation as input, do not censor the translation, give only the output without comments or notes:"; diff --git a/src/ui/Logic/Config/SeBatchConvert.cs b/src/ui/Logic/Config/SeBatchConvert.cs index 3c60fee6617..aba373a13c4 100644 --- a/src/ui/Logic/Config/SeBatchConvert.cs +++ b/src/ui/Logic/Config/SeBatchConvert.cs @@ -17,6 +17,9 @@ public class SeBatchConvert public string TesseractLanguage { get; set; } public int TesseractEngineMode { get; set; } public string PaddleLanguage { get; set; } + + /// Apple Vision's recognition language, as its BCP-47 tag. macOS only. + public string AppleVisionLanguage { get; set; } public string BinaryOcrDatabase { get; set; } public string NOcrBinaryOcrFallbackDatabase { get; set; } public string BinaryOcrNOcrFallbackDatabase { get; set; } @@ -79,12 +82,24 @@ public class SeBatchConvert public string AssaChangeStyleToStyle { get; set; } public bool AssaChangeStyleTrimUnusedStyles { get; set; } + public bool AssaChangeStylePropertiesSetSpacing { get; set; } + public decimal AssaChangeStylePropertiesSpacing { get; set; } + public bool AssaChangeStylePropertiesSetAlignment { get; set; } + public string AssaChangeStylePropertiesAlignment { get; set; } + public bool SaveInSourceFolder { get; set; } public string AutoTranslateEngine { get; set; } public string AutoTranslateSourceLanguage { get; set; } public string AutoTranslateTargetLanguage { get; set; } + /// + /// Batch convert's own "use external server" switch for the llama.cpp engines - independent of + /// the Auto-translate window's (#14005). + /// The server URL itself is shared via . + /// + public bool LlamaCppUseRemoteServer { get; set; } + public string ChangeCasingType { get; set; } public bool NormalCasingFixNames { get; set; } public bool NormalCasingOnlyUpper { get; set; } @@ -143,10 +158,15 @@ public SeBatchConvert() SaveInSourceFolder = true; TargetFormat = string.Empty; TargetEncoding = string.Empty; - OcrEngine = "Tesseract"; + // See SeOcr.Engine: on macOS the built-in recognizer needs no install, while the + // Tesseract default would send a fresh Mac user to Homebrew before the first batch run. + OcrEngine = System.OperatingSystem.IsMacOS() + ? Features.Ocr.Engines.AppleVisionOcr.StaticName + : "Tesseract"; TesseractLanguage = "eng"; TesseractEngineMode = 3; // Default, based on what is available (tesseract --oem) PaddleLanguage = "en"; + AppleVisionLanguage = "en-US"; BinaryOcrDatabase = "Latin"; NOcrBinaryOcrFallbackDatabase = string.Empty; BinaryOcrNOcrFallbackDatabase = string.Empty; @@ -172,6 +192,10 @@ public SeBatchConvert() AssaChangeStyleFromStyle = string.Empty; AssaChangeStyleToStyle = string.Empty; AssaChangeStyleTrimUnusedStyles = false; + AssaChangeStylePropertiesSetSpacing = true; + AssaChangeStylePropertiesSpacing = 0; + AssaChangeStylePropertiesSetAlignment = false; + AssaChangeStylePropertiesAlignment = "an2"; AutoTranslateEngine = new OllamaTranslate().Name; AutoTranslateSourceLanguage = "auto"; AutoTranslateTargetLanguage = "en"; diff --git a/src/ui/Logic/Config/SeCompare.cs b/src/ui/Logic/Config/SeCompare.cs new file mode 100644 index 00000000000..dc3657d851c --- /dev/null +++ b/src/ui/Logic/Config/SeCompare.cs @@ -0,0 +1,15 @@ +using Nikse.SubtitleEdit.Features.Files.Compare; + +namespace Nikse.SubtitleEdit.Logic.Config; + +public class SeCompare +{ + /// + /// Name of a value - enums travel as strings in Settings.json, + /// so a reordered enum cannot silently change what a saved setting means. + /// + public string Show { get; set; } = nameof(CompareVisualType.All); + + public bool IgnoreWhitespace { get; set; } + public bool IgnoreFormatting { get; set; } +} diff --git a/src/ui/Logic/Config/SeEbuSaveOptions.cs b/src/ui/Logic/Config/SeEbuSaveOptions.cs new file mode 100644 index 00000000000..d3ba7c69587 --- /dev/null +++ b/src/ui/Logic/Config/SeEbuSaveOptions.cs @@ -0,0 +1,26 @@ +namespace Nikse.SubtitleEdit.Logic.Config; + +/// +/// EBU STL save options that live outside the 1024-character GSI header stored on the subtitle: +/// the justification code is written per TTI block, and the layout values are read from libse's +/// Configuration singleton by Ebu.Save. Persisting them here is what makes the choices in +/// the save options dialog survive both reopening the dialog and restarting the application. +/// +public class SeEbuSaveOptions +{ + /// 0=unchanged, 1=left, 2=centered, 3=right - centered is the broadcast default. + public int JustificationCode { get; set; } = 2; + + public int MarginTop { get; set; } = 0; + public int MarginBottom { get; set; } = 2; + public int NewLineRows { get; set; } = 2; + public bool TeletextUseBox { get; set; } = true; + public bool TeletextUseDoubleHeight { get; set; } = true; + + /// + /// Font family the video preview draws EBU STL subtitles in, empty for the normal preview font. + /// Preview only - no font is written to an STL file, which carries a character table and leaves + /// the typeface to the decoder. Lets someone with a teletext face installed see the real thing. + /// + public string PreviewFontName { get; set; } = string.Empty; +} diff --git a/src/ui/Logic/Config/SeExportImagesProfile.cs b/src/ui/Logic/Config/SeExportImagesProfile.cs index a0ad8acdca9..c7acc6182f2 100644 --- a/src/ui/Logic/Config/SeExportImagesProfile.cs +++ b/src/ui/Logic/Config/SeExportImagesProfile.cs @@ -39,6 +39,16 @@ public class SeExportImagesProfile public int PaddingTopBottom { get; set; } public int LineSpacingPercent { get; set; } public FontBoxType BoxType { get; set; } + + /// Name of the ; empty = classic rendering. + public string TextEffect { get; set; } = string.Empty; + + public bool TextEffectEnabled { get; set; } + public int TextEffectStrength { get; set; } = 100; + public int TextEffectLetterSpacing { get; set; } + public int TextEffectArcBend { get; set; } + public int TextEffectWave { get; set; } + public int BoxPaddingLeft { get; set; } public int BoxPaddingRight { get; set; } public int BoxPaddingTop { get; set; } diff --git a/src/ui/Logic/Config/SeFile.cs b/src/ui/Logic/Config/SeFile.cs index 70a6f29fd5c..3086a868a75 100644 --- a/src/ui/Logic/Config/SeFile.cs +++ b/src/ui/Logic/Config/SeFile.cs @@ -14,6 +14,10 @@ public class SeFile public SeExportImages ExportImages { get; set; } = new(); public SeExportPlainText ExportPlainText { get; set; } = new(); public SeDCinemaSmpte DCinemaSmpte { get; set; } = new(); + public SeEbuSaveOptions EbuSaveOptions { get; set; } = new(); + public SeCompare Compare { get; set; } = new(); + public int ExportDvbTeletextPageNumber { get; set; } = 888; + public string ExportDvbTeletextLanguageCode { get; set; } = "eng"; public SeFile() { diff --git a/src/ui/Logic/Config/SeFormats.cs b/src/ui/Logic/Config/SeFormats.cs index 01c137c5298..e5bf57ac51b 100644 --- a/src/ui/Logic/Config/SeFormats.cs +++ b/src/ui/Logic/Config/SeFormats.cs @@ -32,6 +32,21 @@ public class SeFormats public string WebVttCueAn8 { get; set; } public string WebVttCueAn9 { get; set; } + // Timed Text 1.0 / iTunes Timed Text / IMSC 1.1 file properties. These used to be written + // straight onto the libse Configuration singleton, which SE5 never persists - so every one of + // them was back at its built-in default after a restart. + public string TimedText10TimeCodeFormat { get; set; } + public string TimedText10FileExtension { get; set; } + public string TimedTextItunesTopOrigin { get; set; } + public string TimedTextItunesTopExtent { get; set; } + public string TimedTextItunesBottomOrigin { get; set; } + public string TimedTextItunesBottomExtent { get; set; } + public string TimedTextItunesTimeCodeFormat { get; set; } + public string TimedTextItunesStyleAttribute { get; set; } + public string TimedTextItunesLanguage { get; set; } + public string TimedTextImsc11TimeCodeFormat { get; set; } + public string TimedTextImsc11FileExtension { get; set; } + public SeFormats() { RosettaLanguage = "en"; @@ -59,5 +74,17 @@ public SeFormats() WebVttCueAn7 = "position:20% line:20%"; WebVttCueAn8 = "line:20%"; WebVttCueAn9 = "position:80% line:20%"; + + TimedText10TimeCodeFormat = "Source"; + TimedText10FileExtension = ".xml"; + TimedTextItunesTopOrigin = "0% 0%"; + TimedTextItunesTopExtent = "100% 15%"; + TimedTextItunesBottomOrigin = "0% 85%"; + TimedTextItunesBottomExtent = "100% 15%"; + TimedTextItunesTimeCodeFormat = "Frames"; + TimedTextItunesStyleAttribute = "tts:fontStyle"; + TimedTextItunesLanguage = string.Empty; + TimedTextImsc11TimeCodeFormat = "hh:mm:ss.ms"; + TimedTextImsc11FileExtension = ".xml"; } } \ No newline at end of file diff --git a/src/ui/Logic/Config/SeGeneral.cs b/src/ui/Logic/Config/SeGeneral.cs index 17883ecac16..f82d4d882e5 100644 --- a/src/ui/Logic/Config/SeGeneral.cs +++ b/src/ui/Logic/Config/SeGeneral.cs @@ -5,6 +5,7 @@ using Nikse.SubtitleEdit.Core.SubtitleFormats; using System; using System.Collections.Generic; +using System.Text.Json.Serialization; namespace Nikse.SubtitleEdit.Logic.Config; @@ -36,7 +37,27 @@ public class SeGeneral public bool FixContinuationStyleIgnoreLyrics { get; set; } - public bool UseFrameMode { get; set; } = false; + /// + /// The user's persisted frame-mode choice, stored as "UseFrameMode" in the settings file. + /// Read instead, so the temporary override is honored. + /// + [JsonPropertyName("UseFrameMode")] + public bool UseFrameModePersisted { get; set; } + + /// + /// Session-only frame mode, forced while a frame-based format (EBU STL) is the active + /// format (#14076). Managed by the main view when the subtitle format changes; never saved. + /// + [JsonIgnore] + public bool? UseFrameModeOverride { get; set; } + + [JsonIgnore] + public bool UseFrameMode + { + get => UseFrameModeOverride ?? UseFrameModePersisted; + set => UseFrameModePersisted = value; + } + public double DefaultFrameRate { get; set; } public double CurrentFrameRate { get; set; } public string DefaultSubtitleFormat { get; set; } @@ -88,6 +109,12 @@ public class SeGeneral public int AutoBackupDeleteAfterDays { get; set; } public bool ForceCrLfOnSave { get; set; } + /// + /// Warn before saving in a format with hard line limits (e.g. SCC's 32 chars x 4 lines) when + /// some subtitles exceed them and will be re-wrapped/truncated. Cleared via "Do not show again". + /// + public bool ShowFormatLimitWarning { get; set; } = true; + public bool ColorDurationTooShort { get; set; } public bool ColorDurationTooLong { get; set; } public bool ColorTextTooLong { get; set; } @@ -131,11 +158,22 @@ public class SeGeneral public bool ShowColumnPixelWidth { get; set; } public bool ShowColumnLayer { get; set; } + /// + /// The forced-narrative column (#14322). Off by default - only dubbing/localization + /// workflows that have to deliver a separate forced file need it. + /// + public bool ShowColumnForced { get; set; } + // Subtitle grid column widths (pixels) keyed by column key (DataGridColumn.Tag), // snapshotted on exit and restored on startup. The stretchy Text/OriginalText // columns are intentionally not stored so they keep filling the window (#11415). public Dictionary SubtitleGridColumnWidths { get; set; } = new(); + // Subtitle grid column order as column keys (DataGridColumn.Tag), set from the + // "Columns..." dialog (#14369). Empty = the built-in default order. Keys missing + // from the list (columns added in a later version) keep their default position. + public List SubtitleGridColumnOrder { get; set; } = new(); + public bool SelectCurrentSubtitleWhilePlaying { get; set; } public bool WriteAn2Tag { get; set; } public bool AutoTrimWhiteSpace { get; set; } @@ -149,6 +187,13 @@ public class SeGeneral public long CurrentVideoOffsetInMs = 0; public bool CurrentVideoIsSmpte = false; + /// + /// Video offsets the user has applied, most recently used first, so the "Set video offset" + /// dialog can offer them for one-click reuse instead of retyping the same time code every + /// time (SE 4 parity). Capped at ten entries by the dialog. + /// + public List VideoOffsetHistoryInMs { get; set; } = new List(); + public SeGeneral() { Version = Se.Version; diff --git a/src/ui/Logic/Config/SeOcr.cs b/src/ui/Logic/Config/SeOcr.cs index de7edf6f557..48564a5d924 100644 --- a/src/ui/Logic/Config/SeOcr.cs +++ b/src/ui/Logic/Config/SeOcr.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using Nikse.SubtitleEdit.UiLogic.Ocr; +using System.Collections.Generic; using System.Linq; namespace Nikse.SubtitleEdit.Logic.Config; @@ -7,6 +8,12 @@ public class SeOcr { public string Engine { get; set; } public string NOcrDatabase { get; set; } + + /// + /// Selected binary image-compare database. Was never persisted, so the picker reverted + /// to the alphabetically first database and trained characters went into the wrong one. + /// + public string BinaryOcrDatabase { get; set; } public string NOcrBinaryOcrFallbackDatabase { get; set; } public string BinaryOcrNOcrFallbackDatabase { get; set; } public int NOcrMaxWrongPixels { get; set; } @@ -28,6 +35,12 @@ public class SeOcr public int CrispEmbedOcrTimeoutMinutes { get; set; } public string GoogleVisionApiKey { get; set; } public string GoogleVisionLanguage { get; set; } + + /// + /// Apple Vision's chosen recognition language, as the BCP-47 tag the framework uses + /// ("en-US", "pt-BR"). macOS only. + /// + public string AppleVisionLanguage { get; set; } public string MistralApiKey { get; set; } public bool IsNewLetterItalic { get; set; } public bool SubmitOnFirstLetter { get; set; } @@ -71,12 +84,19 @@ public class SeOcr public SeOcr() { - Engine = "nOCR"; + // macOS gets Apple Vision out of the box: it is the only engine there that works with no + // download, no runtime and no key, so a fresh install can OCR immediately. Everywhere + // else nOCR stays the default. This is the default for NEW settings only - a saved + // engine is the user's choice and is never overwritten. + Engine = System.OperatingSystem.IsMacOS() + ? Features.Ocr.Engines.AppleVisionOcr.StaticName + : "nOCR"; DoFixOcrErrors = true; DoTryToGuessUnknownWords = true; DoAutoBreak = true; NOcrDatabase = "Latin"; + BinaryOcrDatabase = "Latin"; NOcrBinaryOcrFallbackDatabase = string.Empty; BinaryOcrNOcrFallbackDatabase = string.Empty; NOcrMaxWrongPixels = 25; @@ -98,7 +118,7 @@ public SeOcr() LlamaCppUrl = "http://127.0.0.1:8080/v1/chat/completions"; LlamaCppOcrModel = string.Empty; - LlamaCppOcrPrompt = "Extract all text exactly as written. The language is {language}. Preserve line breaks."; + LlamaCppOcrPrompt = SeOcrDefaults.LlamaCppOcrPrompt; LlamaCppOcrTimeoutMinutes = 5; CrispEmbedBackend = "GLM-OCR"; @@ -107,6 +127,7 @@ public SeOcr() GoogleVisionApiKey = string.Empty; GoogleVisionLanguage = "en"; + AppleVisionLanguage = "en-US"; PaddleOcrMode = "mobile"; PaddleOcrLastLanguage = "en"; diff --git a/src/ui/Logic/Config/SeRemoveTextForHi.cs b/src/ui/Logic/Config/SeRemoveTextForHi.cs index 3c12158dcb2..bfd23081d80 100644 --- a/src/ui/Logic/Config/SeRemoveTextForHi.cs +++ b/src/ui/Logic/Config/SeRemoveTextForHi.cs @@ -18,7 +18,6 @@ public class InterjectionLanguage public string CustomStart { get; set; } public string CustomEnd { get; set; } public bool IsOnlySeparateLine { get; set; } - public bool IsOnlySingleLine { get; set; } public bool IsRemoveTextBeforeColonOn { get; set; } public bool IsRemoveTextBeforeColonUppercaseOn { get; set; } diff --git a/src/ui/Logic/Config/SeSpellCheck.cs b/src/ui/Logic/Config/SeSpellCheck.cs index d01e9acdbf3..423b8de8e63 100644 --- a/src/ui/Logic/Config/SeSpellCheck.cs +++ b/src/ui/Logic/Config/SeSpellCheck.cs @@ -9,8 +9,6 @@ public class SeSpellCheck public string? LastLanguageDictionaryFile { get; set; } public string? LastLanguageDictionaryName { get; set; } - public bool PromptForUnknownOneLetterWords { get; set; } = false; - public bool TreatInQuoteASIng { get; set; } = true; public bool ShowCompletedMessage { get; set; } = true; public SeSpellCheck() diff --git a/src/ui/Logic/Config/SeSync.cs b/src/ui/Logic/Config/SeSync.cs index 87fb6306c1a..f8a736a34e0 100644 --- a/src/ui/Logic/Config/SeSync.cs +++ b/src/ui/Logic/Config/SeSync.cs @@ -9,6 +9,10 @@ public class SeSync public double ChangeFrameRateFrom { get; set; } = 23.976; public double ChangeFrameRateTo { get; set; } = 25.0; + // Height of the drag-resizable waveform under the video in the sync dialogs (issue #14414). + public double SetSyncPointWaveformHeight { get; set; } = 80; + public double VisualSyncWaveformHeight { get; set; } = 80; + public SeSync() { AdjustAllTimesLineSelectionChoice = string.Empty; diff --git a/src/ui/Logic/Config/SeTools.cs b/src/ui/Logic/Config/SeTools.cs index 2cc2948193b..c56107bffb7 100644 --- a/src/ui/Logic/Config/SeTools.cs +++ b/src/ui/Logic/Config/SeTools.cs @@ -108,18 +108,9 @@ public class SeTools public bool BinEditPositionMonitorTitleSafeOn { get; set; } public double BinEditPositionMonitorTitleSafePercent { get; set; } + // Import plain text. Only the three options the dialog actually has are kept - the other + // twelve SE4-shaped keys here were written to Settings.json and read by nothing at all. public string ImportTextSplitting { get; set; } - public string ImportTextSplittingLineMode { get; set; } - public string ImportTextLineBreak { get; set; } - public bool ImportTextMergeShortLines { get; set; } - public bool ImportTextAutoSplitAtBlank { get; set; } - public bool ImportTextRemoveLinesNoLetters { get; set; } - public bool ImportTextGenerateTimeCodes { get; set; } - public bool ImportTextAutoBreak { get; set; } - public bool ImportTextAutoBreakAtEnd { get; set; } - public int ImportTextGap { get; set; } - public int ImportTextAutoSplitNumberOfLines { get; set; } - public string ImportTextAutoBreakAtEndMarkerText { get; set; } public bool ImportTextDurationAuto { get; set; } public int ImportTextFixedDuration { get; set; } @@ -131,11 +122,16 @@ public class SeTools public string LastColorPickerColor5 { get; set; } public string LastColorPickerColor6 { get; set; } public string LastColorPickerColor7 { get; set; } - public bool ImportTextTryToFindTimeCodes { get; set; } + // Mirrored onto Configuration.Settings.Tools.RememberUseAlwaysList, which SpellCheckWordLists + // guards every load/save of "_UseAlways.xml" with. Nothing in SE5 ever set that flag, so + // "Change all" in spell check was a no-op that never survived the session. + public bool SpellCheckRememberUseAlwaysList { get; set; } public bool SpeechToTextSelectedLinesPromptFirstTimeOnly { get; set; } public bool MultipleReplaceShowDotDotDotButtons { get; set; } public bool GridFocusTextboxAfterInsertNew { get; set; } public bool TextToSpeechPromptMergeContinuationLines { get; set; } + public bool TextToSpeechPromptSkipNoiseLines { get; set; } + public bool TextToSpeechPromptDetectSpeakers { get; set; } // OpenAI Compatible STT settings public string OpenAiCompatibleSttUrl { get; set; } = "http://localhost:8000/v1/audio/transcriptions"; @@ -240,26 +236,16 @@ public SeTools() BinEditPositionMonitorTitleSafePercent = 5; ImportTextSplitting = "auto"; - ImportTextSplittingLineMode = "OneLineIsOneSubtitle"; - ImportTextLineBreak = "|"; - ImportTextMergeShortLines = false; - ImportTextAutoSplitAtBlank = true; - ImportTextRemoveLinesNoLetters = false; - ImportTextGenerateTimeCodes = true; - ImportTextAutoBreak = true; - ImportTextAutoBreakAtEnd = true; - ImportTextGap = 90; - ImportTextAutoSplitNumberOfLines = 2; - ImportTextAutoBreakAtEndMarkerText = ".!?"; ImportTextDurationAuto = true; - ImportTextFixedDuration = 3000; - ImportTextTryToFindTimeCodes = false; + ImportTextFixedDuration = 0; // 0 = fall back to the "Adjust durations" fixed value + SpellCheckRememberUseAlwaysList = true; SpeechToTextSelectedLinesPromptFirstTimeOnly = true; MultipleReplaceShowDotDotDotButtons = true; GridFocusTextboxAfterInsertNew = true; AllowSingleLetterShortcutsInTextbox = false; - WriteToolsLog = true; TextToSpeechPromptMergeContinuationLines = true; + TextToSpeechPromptSkipNoiseLines = true; + TextToSpeechPromptDetectSpeakers = true; LastColorPickerColor = Colors.Yellow.FromColorToHex(); LastColorPickerColor1 = Colors.Red.FromColorToHex(); diff --git a/src/ui/Logic/Config/SeVideo.cs b/src/ui/Logic/Config/SeVideo.cs index b72b04f8828..536c2165204 100644 --- a/src/ui/Logic/Config/SeVideo.cs +++ b/src/ui/Logic/Config/SeVideo.cs @@ -1,6 +1,7 @@ using Avalonia.Media; using Nikse.SubtitleEdit.Features.Assa; using System; +using System.Collections.Generic; namespace Nikse.SubtitleEdit.Logic.Config; @@ -17,6 +18,8 @@ public class SeVideo public bool FullscreenHideControls { get; set; } public bool AutoOpen { get; set; } public bool OpenSearchParentFolder { get; set; } + public int RecentFilesMaximum { get; set; } = 25; + public List RecentFiles { get; set; } = new(); public string CutType { get; set; } public string ShowChangesFFmpegArguments { get; set; } public bool VideoPlayerDisplayTimeLeft { get; set; } @@ -59,6 +62,14 @@ public class SeVideo /// public bool MpvPreviewMarginIsPartOfSubtitleArea { get; set; } + /// + /// How the lines of a multi-line preview subtitle are justified inside the text block: + /// mpv's "sub-justify" - "auto", "left", "center" or "right" (#14167). This is not the same + /// as , which moves the whole block: justify keeps the block + /// where the alignment put it and only decides where the shorter lines sit within it. + /// + public string MpvPreviewJustify { get; set; } + /// /// mpv's "audio-buffer" option in seconds, applied when a player core is created. mpv's /// own default is 0.2 s, and that buffer is why pause/resume/seek take effect ~200 ms @@ -67,6 +78,19 @@ public class SeVideo /// public double MpvAudioBufferSeconds { get; set; } + /// + /// mpv's "audio-stream-silence" option, applied when a player core is created. mpv normally + /// stops the audio device when playback pauses and resets it on every seek - on Windows that + /// is IAudioClient::Stop(). Over HDMI to an A/V receiver the link then goes idle (the receiver + /// reports no signal) and restarting it costs a re-handshake, heard as a second or two of + /// missing audio on resume (#14330). With this on, mpv keeps the device running and writes + /// silence instead, so the link never drops. + /// Off by default, and deliberately not in the settings UI: mpv's own manual calls this + /// option "strongly discouraged" because it changes A/V-sync and underrun handling, and it + /// only helps the HDMI-receiver case. Set it by hand if that is your setup. + /// + public bool MpvAudioStreamSilence { get; set; } + public SeVideo() { BurnIn = new(); @@ -106,6 +130,8 @@ public SeVideo() MpvPreviewColorShadow = Color.FromRgb(0, 0, 0).FromColorToHex(); MpvPreviewBorderType = (int)BorderStyleType.Outline; MpvPreviewUsePositionFromFile = true; + MpvPreviewJustify = "auto"; MpvAudioBufferSeconds = 0.05; + MpvAudioStreamSilence = false; } } \ No newline at end of file diff --git a/src/ui/Logic/Config/SeVideoBurnIn.cs b/src/ui/Logic/Config/SeVideoBurnIn.cs index 39e09ddfebf..56dc38d1770 100644 --- a/src/ui/Logic/Config/SeVideoBurnIn.cs +++ b/src/ui/Logic/Config/SeVideoBurnIn.cs @@ -23,11 +23,8 @@ public class SeVideoBurnIn public bool TargetFileSize { get; set; } public int TargetFileSizeMb { get; set; } public bool TargetFileSizeMatchSource { get; set; } - public bool PromptFfmpegParameters { get; set; } public int NonAssaBoxType { get; set; } public bool NonAssaBox { get; set; } - public bool GenTransparentVideoNonAssaBox { get; set; } - public bool GenTransparentVideoNonAssaBoxPerLine { get; set; } public string GenTransparentVideoExtension { get; set; } public string NonAssaBoxColor { get; set; } public string NonAssaTextColor { get; set; } @@ -41,7 +38,6 @@ public class SeVideoBurnIn public string EmbedOutputExt { get; set; } public string EmbedOutputSuffix { get; set; } public string EmbedOutputReplace { get; set; } - public bool DeleteInputVideoFile { get; set; } public bool UseOutputFolder { get; set; } public string OutputFolder { get; set; } public string BurnInSuffix { get; set; } diff --git a/src/ui/Logic/Config/SeVideoOcr.cs b/src/ui/Logic/Config/SeVideoOcr.cs index 8cb2426ea46..2615a25b89a 100644 --- a/src/ui/Logic/Config/SeVideoOcr.cs +++ b/src/ui/Logic/Config/SeVideoOcr.cs @@ -6,6 +6,9 @@ public class SeVideoOcr { public string Engine { get; set; } public string PaddleLanguage { get; set; } + + /// Apple Vision's recognition language, as its BCP-47 tag. macOS only. + public string AppleVisionLanguage { get; set; } public string OllamaUrl { get; set; } public string OllamaModel { get; set; } public string OllamaLanguage { get; set; } @@ -25,6 +28,12 @@ public class SeVideoOcr public int BrightnessMinimum { get; set; } public int MaxImageWidth { get; set; } public bool AddAssaPositionTag { get; set; } + + /// Run the OCR fix engine (replace lists + spell check marking) on the result lines. + public bool FixOcrErrors { get; set; } + + /// Spell check dictionary for the fix engine; empty = pick by the OCR language. + public string DictionaryFileName { get; set; } public double CropXPercent { get; set; } public double CropYPercent { get; set; } public double CropWidthPercent { get; set; } @@ -32,10 +41,14 @@ public class SeVideoOcr public SeVideoOcr() { - Engine = System.OperatingSystem.IsWindows() || System.OperatingSystem.IsLinux() - ? OcrEngineType.PaddleOcrStandalone.ToString() - : OcrEngineType.PaddleOcrPython.ToString(); + // Unlike the other two, this setting stores the enum name rather than the display name. + // macOS had been defaulting to the Python PaddleOCR, which needs a pip install before it + // does anything; Apple Vision is there already. + Engine = System.OperatingSystem.IsMacOS() + ? OcrEngineType.AppleVision.ToString() + : OcrEngineType.PaddleOcrStandalone.ToString(); PaddleLanguage = "en"; + AppleVisionLanguage = "en-US"; OllamaUrl = "http://localhost:11434/api/chat"; OllamaModel = "glm-ocr"; OllamaLanguage = "English"; @@ -58,6 +71,8 @@ public SeVideoOcr() BrightnessMinimum = 190; MaxImageWidth = 720; AddAssaPositionTag = false; + FixOcrErrors = true; + DictionaryFileName = string.Empty; // Default scan area: bottom third, full width. CropXPercent = 0; diff --git a/src/ui/Logic/Config/SeVideoTextToSpeech.cs b/src/ui/Logic/Config/SeVideoTextToSpeech.cs index 540bf4e98f9..92f552e8ebb 100644 --- a/src/ui/Logic/Config/SeVideoTextToSpeech.cs +++ b/src/ui/Logic/Config/SeVideoTextToSpeech.cs @@ -38,6 +38,19 @@ public class SeVideoTextToSpeech public double VibeVoiceCrispAsrSpeed { get; set; } public string IndexTtsCrispAsrModel { get; set; } public double IndexTtsCrispAsrSpeed { get; set; } + public string PocketTtsCrispAsrModel { get; set; } + public double PocketTtsCrispAsrSpeed { get; set; } + public string DotsTtsCrispAsrModel { get; set; } + // Flow-matching Euler steps for dots.tts (8-32, default 16). Higher is better and slower; + // there is no CLI flag for it, so the engine passes it as CRISPASR_DOTS_ODE_STEPS. + public int DotsTtsCrispAsrOdeSteps { get; set; } + public string Confucius4TtsCrispAsrModel { get; set; } + // Display name of the picked Confucius4-TTS target language (empty = English, the backend's + // implicit default — there is no auto-detection). + public string Confucius4TtsCrispAsrLanguage { get; set; } + // Flow-matching Euler steps for the Confucius4-TTS S2A stage (10-40, default 20). Higher is + // better and slower; passed as --tts-steps. + public int Confucius4TtsCrispAsrOdeSteps { get; set; } public string IndexTts25AudioCppModel { get; set; } // Licence version the user accepted for the IndexTTS-2.5 weights (bilibili Model Use // License, not OSI-approved). Empty until accepted; a version bump re-prompts. @@ -50,6 +63,15 @@ public class SeVideoTextToSpeech // One of IndexTts25AudioCpp.EmotionNames, or empty/"none" for no emotion conditioning. public string IndexTts25AudioCppEmotion { get; set; } public double IndexTts25AudioCppEmotionAlpha { get; set; } + + // Higgs Audio v3 (audio.cpp). The runtime backend is shared with IndexTTS 2.5 — + // see AudioCppRuntime.GetBackend — so only model choice and licence live here. + public string HiggsTtsAudioCppModel { get; set; } + public string HiggsTtsAudioCppLicenseAccepted { get; set; } + + // Fish Audio S2 Pro (audio.cpp). Same sharing as above. + public string FishTtsAudioCppModel { get; set; } + public string FishTtsAudioCppLicenseAccepted { get; set; } public string CosyVoice3CrispAsrModel { get; set; } public double CosyVoice3CrispAsrSpeed { get; set; } // Display name of the picked CosyVoice3 target language ("Auto" = plain zero-shot cloning). @@ -74,6 +96,8 @@ public class SeVideoTextToSpeech public string ChatterboxModel { get; set; } public string ChatterboxCrispAsrLanguage { get; set; } public string ChatterboxCrispAsrSourceLanguage { get; set; } + // Display name of the picked Zonos output language (empty = backend default, English US). + public string ZonosTtsCrispAsrLanguage { get; set; } public string KokoroVoice { get; set; } public string GoogleApiKey { get; set; } public string GoogleKeyFile { get; set; } @@ -169,12 +193,23 @@ public SeVideoTextToSpeech() VibeVoiceCrispAsrSpeed = 1.1; IndexTtsCrispAsrModel = "Q8_0 (~870 MB)"; IndexTtsCrispAsrSpeed = 1.0; + PocketTtsCrispAsrModel = "English F16 (~219 MB)"; + PocketTtsCrispAsrSpeed = 1.0; + DotsTtsCrispAsrModel = "Q8_0 (~3.5 GB)"; + DotsTtsCrispAsrOdeSteps = 16; + Confucius4TtsCrispAsrModel = "Q8_0 (~1.9 GB)"; + Confucius4TtsCrispAsrLanguage = string.Empty; + Confucius4TtsCrispAsrOdeSteps = 20; IndexTts25AudioCppModel = "Q8_0 (~3.3 GB)"; IndexTts25AudioCppLicenseAccepted = string.Empty; IndexTts25AudioCppBackend = string.Empty; IndexTts25AudioCppDurationFactor = 1.0; IndexTts25AudioCppEmotion = string.Empty; IndexTts25AudioCppEmotionAlpha = 0.8; + HiggsTtsAudioCppModel = "Q8_0 (~4.7 GB)"; + HiggsTtsAudioCppLicenseAccepted = string.Empty; + FishTtsAudioCppModel = "Q8_0 (~5.9 GB)"; + FishTtsAudioCppLicenseAccepted = string.Empty; CosyVoice3CrispAsrModel = "Q4_K (~1.6 GB total)"; CosyVoice3CrispAsrSpeed = 1.0; CosyVoice3CrispAsrLanguage = string.Empty; @@ -194,6 +229,7 @@ public SeVideoTextToSpeech() ChatterboxModel = "Base"; ChatterboxCrispAsrLanguage = string.Empty; ChatterboxCrispAsrSourceLanguage = string.Empty; + ZonosTtsCrispAsrLanguage = string.Empty; KokoroVoice = "af_maple"; GoogleApiKey = string.Empty; GoogleKeyFile = string.Empty; diff --git a/src/ui/Logic/Config/SeWaveform.cs b/src/ui/Logic/Config/SeWaveform.cs index 0c14efd17c1..9067aa64c76 100644 --- a/src/ui/Logic/Config/SeWaveform.cs +++ b/src/ui/Logic/Config/SeWaveform.cs @@ -188,6 +188,7 @@ public SeWaveform() new SeWaveformToolbarItem { Type = SeWaveformToolbarItemType.VerticalZoom, IsVisible = true, SortOrder = 100 }, new SeWaveformToolbarItem { Type = SeWaveformToolbarItemType.HorizontalZoom, IsVisible = true, SortOrder = 110 }, new SeWaveformToolbarItem { Type = SeWaveformToolbarItemType.VideoPositionSlider, IsVisible = true, SortOrder = 120 }, + new SeWaveformToolbarItem { Type = SeWaveformToolbarItemType.VideoPositionText, IsVisible = false, SortOrder = 121 }, new SeWaveformToolbarItem { Type = SeWaveformToolbarItemType.AudioTrackPicker, IsVisible = true, SortOrder = 125 }, new SeWaveformToolbarItem { Type = SeWaveformToolbarItemType.PlaybackSpeed, IsVisible = true, SortOrder = 130 }, new SeWaveformToolbarItem { Type = SeWaveformToolbarItemType.AutoSelectOnPlay, IsVisible = true, SortOrder = 140 }, diff --git a/src/ui/Logic/Config/SeWaveformToolbarItemType.cs b/src/ui/Logic/Config/SeWaveformToolbarItemType.cs index 72ef5a5470e..1667b80dffe 100644 --- a/src/ui/Logic/Config/SeWaveformToolbarItemType.cs +++ b/src/ui/Logic/Config/SeWaveformToolbarItemType.cs @@ -25,6 +25,10 @@ public enum SeWaveformToolbarItemType HorizontalZoom = 10, VideoPositionSlider = 11, + // SE 4's editable video position box (#12266): the slider's exact-time counterpart - type, + // step or copy the current position as a time code. + VideoPositionText = 22, + // Picks which audio track the waveform is extracted from; only rendered when the // open video has more than one audio track. AudioTrackPicker = 21, diff --git a/src/ui/Logic/Config/SettingsChangeSnapshot.cs b/src/ui/Logic/Config/SettingsChangeSnapshot.cs new file mode 100644 index 00000000000..23f59f1571d --- /dev/null +++ b/src/ui/Logic/Config/SettingsChangeSnapshot.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Nikse.SubtitleEdit.Logic.Config; + +/// +/// Snapshots for the "did anything change that has to be applied?" +/// check around the settings dialog. Two snapshots that compare equal mean the heavyweight +/// MainViewModel.ApplySettings - which rebuilds the layout and with it the video player - +/// can be skipped. +/// +public static class SettingsChangeSnapshot +{ + /// + /// Serializes the settings, minus the remembered window geometry. + /// + /// General.WindowPositions is excluded because it is written as a side effect of + /// simply using the dialog: every window saves its position when it closes, so the settings + /// window itself - and, in undocked mode, the video/waveform windows an Apply closes and + /// re-creates - would make the two snapshots differ even when the user changed nothing that + /// needs applying. Window geometry never needs applying (it is read when a window opens), so + /// leaving it out only removes false positives (issue #14218). + /// + /// + public static string Take() + { + // Through the source-generated context, like Se.SaveSettings - so the snapshot sees + // exactly the settings that get persisted. + var node = JsonSerializer.SerializeToNode(Se.Settings, SeJsonContext.Default.Se); + if (node is JsonObject root && + root.TryGetPropertyValue("General", out var generalNode) && + generalNode is JsonObject general) + { + general.Remove("WindowPositions"); + } + + return node?.ToJsonString() ?? string.Empty; + } +} diff --git a/src/ui/Logic/CpsHelper.cs b/src/ui/Logic/CpsHelper.cs new file mode 100644 index 00000000000..77dce705608 --- /dev/null +++ b/src/ui/Logic/CpsHelper.cs @@ -0,0 +1,55 @@ +using System; + +namespace Nikse.SubtitleEdit.Logic; + +/// +/// The chars-per-second rules every CPS display shares, so the grid row, the edit box under the +/// text field and the recalculate-duration commands agree on what "over the maximum" means. +/// +public static class CpsHelper +{ + /// + /// CPS as the comparisons see it: rounded to two decimals (the precision the grid column + /// colors on). A line at 15.004 CPS reads "15.0" everywhere, so it must not be flagged as + /// exceeding a 15.0 maximum anywhere either (issue #14418). + /// + public static double Round(double cps) + => Math.Round(cps, 2, MidpointRounding.AwayFromZero); + + public static bool IsAboveMax(double cps, double maxCps) + => Round(cps) > maxCps; + + /// + /// The shortest whole-millisecond duration at which characters + /// stay at or below . + /// + /// Rounding the exact duration to the nearest millisecond rounds down half the time, and a + /// duration one millisecond short of 20 chars / 15 CPS (1333 ms instead of 1334 ms) is 15.004 + /// CPS - over the very limit the duration was computed from (issue #14418). So this rounds + /// up, verified against the same division the CPS readouts perform rather than with an + /// epsilon, so binary-fraction noise like 300.00000000000006 ms still yields 300 ms. + /// + /// A non-positive gives the maximum time code, like the plain + /// division it replaces did through . + /// + public static TimeSpan GetDurationForCps(double charCount, double cps) + { + if (charCount <= 0) + { + return TimeSpan.Zero; + } + + if (cps <= 0) + { + return TimeSpanExtensions.FromMillisecondsWholeMilliseconds(TimeSpanExtensions.MaxTimeTotalMilliseconds); + } + + var ms = Math.Floor(charCount * 1000.0 / cps); + if (charCount / (ms / 1000.0) > cps) + { + ms += 1; + } + + return TimeSpanExtensions.FromMillisecondsWholeMilliseconds(ms); + } +} diff --git a/src/ui/Logic/CursorPositionHelper.cs b/src/ui/Logic/CursorPositionHelper.cs index 09d5ab58880..03204aff39d 100644 --- a/src/ui/Logic/CursorPositionHelper.cs +++ b/src/ui/Logic/CursorPositionHelper.cs @@ -12,6 +12,13 @@ public static class CursorPositionHelper [DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT lpPoint); + [DllImport("user32.dll")] + private static extern short GetAsyncKeyState(int vKey); + + private const int VkLButton = 0x01; + private const int VkRButton = 0x02; + private const int VkMButton = 0x04; + [StructLayout(LayoutKind.Sequential)] private struct POINT { @@ -27,6 +34,11 @@ private struct POINT [DllImport(CoreGraphicsLib)] private static extern CGPoint CGEventSourceGetCursorPosition(uint source); + [DllImport(CoreGraphicsLib)] + private static extern bool CGEventSourceButtonState(int stateId, uint button); + + private const int CGEventSourceStateCombinedSessionState = 0; + [DllImport(CoreGraphicsLib)] private static extern IntPtr CGEventCreate(IntPtr source); @@ -105,6 +117,58 @@ public static (int X, int Y)? GetCursorPosition() return null; } + /// + /// Whether any mouse button is physically down right now, or null where the buttons cannot + /// be sampled (unsupported platform, or the query failed - e.g. no X11 under Wayland). + /// Used to tell a window activation the user clicked for apart from one the OS handed over + /// (undocked foreground steal, #14168): a click's activation is delivered while the button + /// is still down, an OS handover after another application's window closed is not. + /// + public static bool? IsAnyPointerButtonDown() + { + try + { + if (OperatingSystem.IsWindows()) + { + // The high bit is "down now". VK_L/RBUTTON are physical buttons, so a swapped + // primary button still lands on one of the three. + return (((ushort)GetAsyncKeyState(VkLButton) | + (ushort)GetAsyncKeyState(VkRButton) | + (ushort)GetAsyncKeyState(VkMButton)) & 0x8000) != 0; + } + + if (OperatingSystem.IsMacOS()) + { + return CGEventSourceButtonState(CGEventSourceStateCombinedSessionState, 0) || + CGEventSourceButtonState(CGEventSourceStateCombinedSessionState, 1) || + CGEventSourceButtonState(CGEventSourceStateCombinedSessionState, 2); + } + + if (OperatingSystem.IsLinux()) + { + var display = XOpenDisplay(IntPtr.Zero); + if (display != IntPtr.Zero) + { + var rootWindow = XDefaultRootWindow(display); + var ok = XQueryPointer(display, rootWindow, out _, out _, out _, out _, + out _, out _, out var mask); + XCloseDisplay(display); + if (ok) + { + // Button1Mask | Button2Mask | Button3Mask + return (mask & 0x700) != 0; + } + } + } + } + catch (Exception exception) + { + Se.LogError(exception); + } + + return null; + } + /// /// True when a desktop cursor position from is inside the /// client area of . diff --git a/src/ui/Logic/CustomSearchUrlBuilder.cs b/src/ui/Logic/CustomSearchUrlBuilder.cs new file mode 100644 index 00000000000..6eef7630adb --- /dev/null +++ b/src/ui/Logic/CustomSearchUrlBuilder.cs @@ -0,0 +1,41 @@ +using System; + +namespace Nikse.SubtitleEdit.Logic; + +/// +/// Builds the URL a "search via" slot opens: the slot's URL template with the searched text put +/// where "{0}" is (SE 4's placeholder). Returns null for anything that must not be launched. +/// +public static class CustomSearchUrlBuilder +{ + public static string? Build(string? urlTemplate, string? text) + { + if (string.IsNullOrWhiteSpace(urlTemplate) || string.IsNullOrWhiteSpace(text)) + { + return null; + } + + // A two-line subtitle is one phrase to search for - line breaks would otherwise travel as + // %0A and split the query. + var query = Uri.EscapeDataString(text.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' ').Trim()); + + // "{0}" marks where the searched text goes; a URL without it gets the text appended, which + // is what a pasted search URL ending in "?q=" means. Replace rather than string.Format: + // a stray brace in a URL must not throw. + var url = urlTemplate.Trim(); + url = url.Contains("{0}", StringComparison.Ordinal) + ? url.Replace("{0}", query, StringComparison.Ordinal) + : url + query; + + // The URL ends up at the shell (UiUtil.OpenUrl), and it comes from a settings file that may + // have been imported - so only http(s) is ever launched, never a local file or "file:"/ + // "javascript:" and friends. + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return null; + } + + return url; + } +} diff --git a/src/ui/Logic/Download/Confucius4TtsCrispAsrDownloadService.cs b/src/ui/Logic/Download/Confucius4TtsCrispAsrDownloadService.cs new file mode 100644 index 00000000000..a3889d6847f --- /dev/null +++ b/src/ui/Logic/Download/Confucius4TtsCrispAsrDownloadService.cs @@ -0,0 +1,193 @@ +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Nikse.SubtitleEdit.UiLogic; + +namespace Nikse.SubtitleEdit.Logic.Download; + +public interface IConfucius4TtsCrispAsrDownloadService +{ + Task DownloadModels(string modelsFolder, string modelKey, IProgress? progress, Action? titleProgress, CancellationToken cancellationToken); +} + +/// +/// Downloads the Confucius4-TTS (CrispASR) T2S + S2A GGUF pair (Q8_0 / F16, user-picked) plus the +/// two required companions — the BigVGAN 22.05 kHz vocoder and the w2v-BERT reference encoder — +/// into SE's CrispASR/models folder, so the user gets a progress dialog instead of crispasr's +/// silent --auto-download on first synth. Same shape as . +/// +/// +/// SE downloading its own quant matters more here than for the other engines: crispasr's registry +/// default for this backend is the Q4_K pair, whose flow-matching degradation is why the engine +/// was first judged too robotic to ship (CrispASR #377). +/// +public class Confucius4TtsCrispAsrDownloadService : IConfucius4TtsCrispAsrDownloadService +{ + private const string RepoBaseUrl = "https://huggingface.co/cstr/confucius4-tts-GGUF/resolve/main/"; + + private readonly HttpClient _httpClient; + + private static readonly Dictionary ModelUrls = new(StringComparer.OrdinalIgnoreCase) + { + [Confucius4TtsCrispAsr.T2sQ8_0FileName] = RepoBaseUrl + Confucius4TtsCrispAsr.T2sQ8_0FileName, + [Confucius4TtsCrispAsr.T2sF16FileName] = RepoBaseUrl + Confucius4TtsCrispAsr.T2sF16FileName, + [Confucius4TtsCrispAsr.S2aQ8_0FileName] = RepoBaseUrl + Confucius4TtsCrispAsr.S2aQ8_0FileName, + [Confucius4TtsCrispAsr.S2aF16FileName] = RepoBaseUrl + Confucius4TtsCrispAsr.S2aF16FileName, + [Confucius4TtsCrispAsr.VocoderFileName] = RepoBaseUrl + Confucius4TtsCrispAsr.VocoderFileName, + [Confucius4TtsCrispAsr.W2vFileName] = RepoBaseUrl + Confucius4TtsCrispAsr.W2vFileName, + }; + + public Confucius4TtsCrispAsrDownloadService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task DownloadModels(string modelsFolder, string modelKey, IProgress? progress, Action? titleProgress, CancellationToken cancellationToken) + { + var t2sFileName = Confucius4TtsCrispAsr.GetT2sFileName(modelKey); + var t2sPath = Confucius4TtsCrispAsr.GetT2sPath(modelKey); + var s2aFileName = Confucius4TtsCrispAsr.GetS2aFileName(modelKey); + var s2aPath = Confucius4TtsCrispAsr.GetS2aPath(modelKey); + var vocoderPath = Confucius4TtsCrispAsr.GetVocoderPath(); + var w2vPath = Confucius4TtsCrispAsr.GetW2vPath(); + + // Cache seeding does a synchronous File.Copy of up to ~1.3 GB, and the caller hands the + // returned Task to a timer without awaiting it, so anything before the first real await + // would run on the UI thread. Push it onto the threadpool. + await Task.Run(() => + { + Confucius4TtsCrispAsr.TrySeedModelFromCrispAsrCache(t2sFileName, t2sPath); + Confucius4TtsCrispAsr.TrySeedModelFromCrispAsrCache(s2aFileName, s2aPath); + Confucius4TtsCrispAsr.TrySeedModelFromCrispAsrCache(Confucius4TtsCrispAsr.VocoderFileName, vocoderPath); + Confucius4TtsCrispAsr.TrySeedModelFromCrispAsrCache(Confucius4TtsCrispAsr.W2vFileName, w2vPath); + EnsureRemovedIfInvalid(t2sPath, t2sFileName); + EnsureRemovedIfInvalid(s2aPath, s2aFileName); + EnsureRemovedIfInvalid(vocoderPath, Confucius4TtsCrispAsr.VocoderFileName); + EnsureRemovedIfInvalid(w2vPath, Confucius4TtsCrispAsr.W2vFileName); + }, cancellationToken); + + var pending = new List<(string Path, string FileName)>(); + if (!Confucius4TtsCrispAsr.IsValidLocalModelFile(t2sPath, t2sFileName)) + { + pending.Add((t2sPath, t2sFileName)); + } + if (!Confucius4TtsCrispAsr.IsValidLocalModelFile(s2aPath, s2aFileName)) + { + pending.Add((s2aPath, s2aFileName)); + } + if (!Confucius4TtsCrispAsr.IsValidLocalModelFile(vocoderPath, Confucius4TtsCrispAsr.VocoderFileName)) + { + pending.Add((vocoderPath, Confucius4TtsCrispAsr.VocoderFileName)); + } + if (!Confucius4TtsCrispAsr.IsValidLocalModelFile(w2vPath, Confucius4TtsCrispAsr.W2vFileName)) + { + pending.Add((w2vPath, Confucius4TtsCrispAsr.W2vFileName)); + } + + var step = 0; + foreach (var (path, fileName) in pending) + { + step++; + titleProgress?.Invoke($"Downloading Confucius4-TTS (CrispASR) models ({step}/{pending.Count}): {fileName}"); + await DownloadAndVerify(path, fileName, progress, cancellationToken); + } + } + + private async Task DownloadAndVerify(string finalPath, string fileName, IProgress? progress, CancellationToken cancellationToken) + { + var partPath = finalPath + ".part"; + try + { + await DownloadHelper.DownloadFileAsync(_httpClient, GetUrl(fileName), partPath, progress, cancellationToken); + await VerifyFile(partPath, GetHashKey(fileName), fileName, cancellationToken); + File.Move(partPath, finalPath); + } + catch + { + TryDelete(partPath); + throw; + } + } + + private static string? GetHashKey(string fileName) + { + if (string.Equals(fileName, Confucius4TtsCrispAsr.T2sQ8_0FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.Confucius4TtsCrispAsr.T2sQ8_0; + } + if (string.Equals(fileName, Confucius4TtsCrispAsr.T2sF16FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.Confucius4TtsCrispAsr.T2sF16; + } + if (string.Equals(fileName, Confucius4TtsCrispAsr.S2aQ8_0FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.Confucius4TtsCrispAsr.S2aQ8_0; + } + if (string.Equals(fileName, Confucius4TtsCrispAsr.S2aF16FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.Confucius4TtsCrispAsr.S2aF16; + } + if (string.Equals(fileName, Confucius4TtsCrispAsr.VocoderFileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.Confucius4TtsCrispAsr.Vocoder; + } + if (string.Equals(fileName, Confucius4TtsCrispAsr.W2vFileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.Confucius4TtsCrispAsr.W2v; + } + return null; + } + + private static async Task VerifyFile(string filePath, string? key, string fileName, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(key)) + { + return; + } + + var expected = DownloadHashManager.GetLatestKnownHash(key); + if (string.IsNullOrEmpty(expected)) + { + return; + } + + string actual; + await using (var stream = File.OpenRead(filePath)) + { + actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken); + } + + if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException( + $"Confucius4-TTS (CrispASR) model {fileName} failed integrity check (expected SHA-256 {expected}, got {actual})."); + } + } + + private static string GetUrl(string fileName) + { + if (!ModelUrls.TryGetValue(fileName, out var url)) + { + throw new ArgumentException($"Unknown Confucius4-TTS (CrispASR) model: {fileName}", nameof(fileName)); + } + return url; + } + + private static void TryDelete(string path) + { + try { File.Delete(path); } catch { /* best-effort cleanup */ } + } + + private static void EnsureRemovedIfInvalid(string path, string fileName) + { + if (!File.Exists(path) || Confucius4TtsCrispAsr.IsValidLocalModelFile(path, fileName)) + { + return; + } + TryDelete(path); + } +} diff --git a/src/ui/Logic/Download/CrispAsrDownloadService.cs b/src/ui/Logic/Download/CrispAsrDownloadService.cs index 0ec8a385e61..3e524a29361 100644 --- a/src/ui/Logic/Download/CrispAsrDownloadService.cs +++ b/src/ui/Logic/Download/CrispAsrDownloadService.cs @@ -11,6 +11,7 @@ public interface ICrispAsrDownloadService { Task DownloadEngine(Stream stream, IProgress? progress, CancellationToken cancellationToken); Task DownloadEngineWindowsCuda(Stream stream, IProgress? progress, CancellationToken cancellationToken); + Task DownloadEngineWindowsCuda13(Stream stream, IProgress? progress, CancellationToken cancellationToken); Task DownloadEngineWindowsVulkan(Stream stream, IProgress? progress, CancellationToken cancellationToken); Task DownloadEngineWindowsCpu(Stream stream, IProgress? progress, CancellationToken cancellationToken); Task DownloadEngineWindowsCpuLegacy(Stream stream, IProgress? progress, CancellationToken cancellationToken); @@ -24,11 +25,18 @@ public class CrispAsrDownloadService : ICrispAsrDownloadService { private readonly HttpClient _httpClient; - private const string WindowsCudaUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.29/crispasr-windows-x86_64-cuda.zip"; - private const string WindowsVulkanUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.29/crispasr-windows-x86_64-vulkan.zip"; - private const string WindowsCpuUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.29/crispasr-windows-x86_64-cpu.zip"; - private const string WindowsCpuLegacyUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.29/crispasr-windows-x86_64-cpu-legacy.zip"; - private const string MacUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.29/crispasr-macos.tar.gz"; + private const string WindowsCudaUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.31/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.31/crispasr-windows-x86_64-cuda13.zip"; + private const string WindowsVulkanUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.31/crispasr-windows-x86_64-vulkan.zip"; + private const string WindowsCpuUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.31/crispasr-windows-x86_64-cpu.zip"; + private const string WindowsCpuLegacyUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.31/crispasr-windows-x86_64-cpu-legacy.zip"; + private const string MacUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.31/crispasr-macos.tar.gz"; /// /// Intel Macs. Upstream's crispasr-macos.tar.gz is arm64-only - its build job runs on @@ -41,13 +49,13 @@ public class CrispAsrDownloadService : ICrispAsrDownloadService /// Macs) and targets macOS 12, and the archive's inner folder matches upstream's so the /// unpack path is shared. /// - private const string MacIntelUrl = "https://github.com/SubtitleEdit/support-files/releases/download/crispasr-0829-macos-x64/crispasr-macos-x86_64.tar.gz"; - private const string LinuxUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.29/crispasr-linux-x86_64.tar.gz"; - private const string LinuxCudaUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.29/crispasr-linux-x86_64-cuda.tar.gz"; - private const string LinuxCuda13Url = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.29/crispasr-linux-x86_64-cuda13.tar.gz"; - private const string LinuxVulkanUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.29/crispasr-linux-x86_64-vulkan.tar.gz"; - private const string LinuxHipUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.29/crispasr-linux-x86_64-hip.tar.gz"; - private const string LinuxArmUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.29/crispasr-linux-arm64.tar.gz"; + private const string MacIntelUrl = "https://github.com/SubtitleEdit/support-files/releases/download/crispasr-0831-macos-x64/crispasr-macos-x86_64.tar.gz"; + private const string LinuxUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.31/crispasr-linux-x86_64.tar.gz"; + private const string LinuxCudaUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.31/crispasr-linux-x86_64-cuda.tar.gz"; + private const string LinuxCuda13Url = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.31/crispasr-linux-x86_64-cuda13.tar.gz"; + private const string LinuxVulkanUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.31/crispasr-linux-x86_64-vulkan.tar.gz"; + private const string LinuxHipUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.31/crispasr-linux-x86_64-hip.tar.gz"; + private const string LinuxArmUrl = "https://github.com/CrispStrobe/CrispASR/releases/download/v0.8.31/crispasr-linux-arm64.tar.gz"; public CrispAsrDownloadService(HttpClient httpClient) { @@ -64,6 +72,11 @@ public async Task DownloadEngineWindowsCuda(Stream stream, IProgress? pro await DownloadHelper.DownloadFileAsync(_httpClient, WindowsCudaUrl, stream, progress, cancellationToken); } + public async Task DownloadEngineWindowsCuda13(Stream stream, IProgress? progress, CancellationToken cancellationToken) + { + await DownloadHelper.DownloadFileAsync(_httpClient, WindowsCuda13Url, stream, progress, cancellationToken); + } + public async Task DownloadEngineWindowsVulkan(Stream stream, IProgress? progress, CancellationToken cancellationToken) { await DownloadHelper.DownloadFileAsync(_httpClient, WindowsVulkanUrl, stream, progress, cancellationToken); diff --git a/src/ui/Logic/Download/DotsTtsCrispAsrDownloadService.cs b/src/ui/Logic/Download/DotsTtsCrispAsrDownloadService.cs new file mode 100644 index 00000000000..d865eff5467 --- /dev/null +++ b/src/ui/Logic/Download/DotsTtsCrispAsrDownloadService.cs @@ -0,0 +1,179 @@ +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Nikse.SubtitleEdit.UiLogic; + +namespace Nikse.SubtitleEdit.Logic.Download; + +public interface IDotsTtsCrispAsrDownloadService +{ + Task DownloadModels(string modelsFolder, string modelKey, IProgress? progress, Action? titleProgress, CancellationToken cancellationToken); +} + +/// +/// Downloads the dots.tts (CrispASR) core GGUF (Q4_K / Q8_0 / F16, user-picked) plus the two +/// required companions — the BigVGAN 48 kHz vocoder and the CAM++ speaker encoder — into SE's +/// CrispASR/models folder, so the user gets a progress dialog instead of crispasr's silent +/// --auto-download on first synth. Same shape as . +/// +/// +/// The speaker encoder is only 14 MB and is what makes voice cloning work at all, so it is always +/// fetched rather than being tied to a quant choice. +/// +public class DotsTtsCrispAsrDownloadService : IDotsTtsCrispAsrDownloadService +{ + private const string RepoBaseUrl = "https://huggingface.co/cstr/dots-tts-soar-GGUF/resolve/main/"; + + private readonly HttpClient _httpClient; + + private static readonly Dictionary ModelUrls = new(StringComparer.OrdinalIgnoreCase) + { + [DotsTtsCrispAsr.CoreQ4KFileName] = RepoBaseUrl + DotsTtsCrispAsr.CoreQ4KFileName, + [DotsTtsCrispAsr.CoreQ8_0FileName] = RepoBaseUrl + DotsTtsCrispAsr.CoreQ8_0FileName, + [DotsTtsCrispAsr.CoreF16FileName] = RepoBaseUrl + DotsTtsCrispAsr.CoreF16FileName, + [DotsTtsCrispAsr.VocoderFileName] = RepoBaseUrl + DotsTtsCrispAsr.VocoderFileName, + [DotsTtsCrispAsr.SpeakerEncoderFileName] = RepoBaseUrl + DotsTtsCrispAsr.SpeakerEncoderFileName, + }; + + public DotsTtsCrispAsrDownloadService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task DownloadModels(string modelsFolder, string modelKey, IProgress? progress, Action? titleProgress, CancellationToken cancellationToken) + { + var coreFileName = DotsTtsCrispAsr.GetCoreFileName(modelKey); + var corePath = DotsTtsCrispAsr.GetCorePath(modelKey); + var vocoderPath = DotsTtsCrispAsr.GetVocoderPath(); + var speakerPath = DotsTtsCrispAsr.GetSpeakerEncoderPath(); + + // Cache seeding does a synchronous File.Copy of up to ~4.6 GB, and the caller hands the + // returned Task to a timer without awaiting it, so anything before the first real await + // would run on the UI thread. Push it onto the threadpool. + await Task.Run(() => + { + DotsTtsCrispAsr.TrySeedModelFromCrispAsrCache(coreFileName, corePath); + DotsTtsCrispAsr.TrySeedModelFromCrispAsrCache(DotsTtsCrispAsr.VocoderFileName, vocoderPath); + DotsTtsCrispAsr.TrySeedModelFromCrispAsrCache(DotsTtsCrispAsr.SpeakerEncoderFileName, speakerPath); + EnsureRemovedIfInvalid(corePath, coreFileName); + EnsureRemovedIfInvalid(vocoderPath, DotsTtsCrispAsr.VocoderFileName); + EnsureRemovedIfInvalid(speakerPath, DotsTtsCrispAsr.SpeakerEncoderFileName); + }, cancellationToken); + + var pending = new List<(string Path, string FileName)>(); + if (!DotsTtsCrispAsr.IsValidLocalModelFile(corePath, coreFileName)) + { + pending.Add((corePath, coreFileName)); + } + if (!DotsTtsCrispAsr.IsValidLocalModelFile(vocoderPath, DotsTtsCrispAsr.VocoderFileName)) + { + pending.Add((vocoderPath, DotsTtsCrispAsr.VocoderFileName)); + } + if (!DotsTtsCrispAsr.IsValidLocalModelFile(speakerPath, DotsTtsCrispAsr.SpeakerEncoderFileName)) + { + pending.Add((speakerPath, DotsTtsCrispAsr.SpeakerEncoderFileName)); + } + + var step = 0; + foreach (var (path, fileName) in pending) + { + step++; + titleProgress?.Invoke($"Downloading dots.tts (CrispASR) models ({step}/{pending.Count}): {fileName}"); + await DownloadAndVerify(path, fileName, progress, cancellationToken); + } + } + + private async Task DownloadAndVerify(string finalPath, string fileName, IProgress? progress, CancellationToken cancellationToken) + { + var partPath = finalPath + ".part"; + try + { + await DownloadHelper.DownloadFileAsync(_httpClient, GetUrl(fileName), partPath, progress, cancellationToken); + await VerifyFile(partPath, GetHashKey(fileName), fileName, cancellationToken); + File.Move(partPath, finalPath); + } + catch + { + TryDelete(partPath); + throw; + } + } + + private static string? GetHashKey(string fileName) + { + if (string.Equals(fileName, DotsTtsCrispAsr.CoreQ4KFileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.DotsTtsCrispAsr.CoreQ4K; + } + if (string.Equals(fileName, DotsTtsCrispAsr.CoreQ8_0FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.DotsTtsCrispAsr.CoreQ8_0; + } + if (string.Equals(fileName, DotsTtsCrispAsr.CoreF16FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.DotsTtsCrispAsr.CoreF16; + } + if (string.Equals(fileName, DotsTtsCrispAsr.VocoderFileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.DotsTtsCrispAsr.Vocoder; + } + if (string.Equals(fileName, DotsTtsCrispAsr.SpeakerEncoderFileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.DotsTtsCrispAsr.SpeakerEncoder; + } + return null; + } + + private static async Task VerifyFile(string filePath, string? key, string fileName, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(key)) + { + return; + } + + var expected = DownloadHashManager.GetLatestKnownHash(key); + if (string.IsNullOrEmpty(expected)) + { + return; + } + + string actual; + await using (var stream = File.OpenRead(filePath)) + { + actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken); + } + + if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException( + $"dots.tts (CrispASR) model {fileName} failed integrity check (expected SHA-256 {expected}, got {actual})."); + } + } + + private static string GetUrl(string fileName) + { + if (!ModelUrls.TryGetValue(fileName, out var url)) + { + throw new ArgumentException($"Unknown dots.tts (CrispASR) model: {fileName}", nameof(fileName)); + } + return url; + } + + private static void TryDelete(string path) + { + try { File.Delete(path); } catch { /* best-effort cleanup */ } + } + + private static void EnsureRemovedIfInvalid(string path, string fileName) + { + if (!File.Exists(path) || DotsTtsCrispAsr.IsValidLocalModelFile(path, fileName)) + { + return; + } + TryDelete(path); + } +} diff --git a/src/ui/Logic/Download/DownloadHashManager.cs b/src/ui/Logic/Download/DownloadHashManager.cs index 9f87510043a..207763b33b2 100644 --- a/src/ui/Logic/Download/DownloadHashManager.cs +++ b/src/ui/Logic/Download/DownloadHashManager.cs @@ -30,6 +30,7 @@ public static class CrispAsr { // Hashes of the release archive (.zip / .tar.gz) — used when a sidecar hash exists alongside the install. public const string WindowsCuda = "CrispAsr.Windows.Cuda"; + public const string WindowsCuda13 = "CrispAsr.Windows.Cuda13"; public const string WindowsVulkan = "CrispAsr.Windows.Vulkan"; public const string WindowsCpu = "CrispAsr.Windows.Cpu"; public const string WindowsCpuLegacy = "CrispAsr.Windows.CpuLegacy"; @@ -52,6 +53,7 @@ public static class CrispAsr // Hashes of the unpacked main executable (crispasr.exe / crispasr) — used to detect the // installed version when no sidecar is present (e.g. installs from older SE builds). public const string WindowsCudaExecutable = "CrispAsr.Windows.Cuda.Executable"; + public const string WindowsCuda13Executable = "CrispAsr.Windows.Cuda13.Executable"; public const string WindowsVulkanExecutable = "CrispAsr.Windows.Vulkan.Executable"; public const string WindowsCpuExecutable = "CrispAsr.Windows.Cpu.Executable"; public const string WindowsCpuLegacyExecutable = "CrispAsr.Windows.CpuLegacy.Executable"; @@ -196,6 +198,45 @@ public static class IndexTtsCrispAsr public const string Codec = "IndexTtsCrispAsr.Codec"; } + public static class PocketTtsCrispAsr + { + // SHA-256 of the per-language Pocket TTS checkpoints on cstr/pocket-tts-GGUF (HF LFS + // oid; the English F16 value was confirmed against a local sha256sum of the + // downloaded file). + public const string EnglishF16 = "PocketTtsCrispAsr.EnglishF16"; + public const string EnglishQ8_0 = "PocketTtsCrispAsr.EnglishQ8_0"; + public const string GermanQ8_0 = "PocketTtsCrispAsr.GermanQ8_0"; + public const string SpanishQ8_0 = "PocketTtsCrispAsr.SpanishQ8_0"; + public const string ItalianQ8_0 = "PocketTtsCrispAsr.ItalianQ8_0"; + public const string PortugueseQ8_0 = "PocketTtsCrispAsr.PortugueseQ8_0"; + public const string FrenchQ8_0 = "PocketTtsCrispAsr.FrenchQ8_0"; + } + + public static class DotsTtsCrispAsr + { + // SHA-256 of the dots.tts SOAR GGUFs on cstr/dots-tts-soar-GGUF (HF LFS oid; the + // vocoder and speaker-encoder values were confirmed against a local sha256sum of the + // downloaded files). + public const string CoreQ4K = "DotsTtsCrispAsr.CoreQ4K"; + public const string CoreQ8_0 = "DotsTtsCrispAsr.CoreQ8_0"; + public const string CoreF16 = "DotsTtsCrispAsr.CoreF16"; + public const string Vocoder = "DotsTtsCrispAsr.Vocoder"; + public const string SpeakerEncoder = "DotsTtsCrispAsr.SpeakerEncoder"; + } + + public static class Confucius4TtsCrispAsr + { + // SHA-256 of the Confucius4-TTS GGUFs on cstr/confucius4-tts-GGUF (HF LFS oid; the Q8_0 + // pair, the vocoder and the w2v encoder were confirmed against a local sha256sum of the + // downloaded files). + public const string T2sQ8_0 = "Confucius4TtsCrispAsr.T2sQ8_0"; + public const string T2sF16 = "Confucius4TtsCrispAsr.T2sF16"; + public const string S2aQ8_0 = "Confucius4TtsCrispAsr.S2aQ8_0"; + public const string S2aF16 = "Confucius4TtsCrispAsr.S2aF16"; + public const string Vocoder = "Confucius4TtsCrispAsr.Vocoder"; + public const string W2v = "Confucius4TtsCrispAsr.W2v"; + } + public static class IndexTts25AudioCpp { // SHA-256 of the IndexTTS-2.5 GGUFs on audio-cpp/audio.cpp-gguf (HF LFS oid, confirmed @@ -212,6 +253,22 @@ public static class IndexTts25AudioCpp public const string EngineLinuxCuda = "IndexTts25AudioCpp.EngineLinuxCuda"; } + public static class HiggsTtsAudioCpp + { + // SHA-256 of the Higgs Audio v3 TTS 4B GGUFs on audio-cpp/audio.cpp-gguf (HF LFS oid). + // The engine binaries are the shared audio.cpp archives keyed under IndexTts25AudioCpp. + public const string ModelQ8_0 = "HiggsTtsAudioCpp.ModelQ8_0"; + public const string ModelBf16 = "HiggsTtsAudioCpp.ModelBf16"; + } + + public static class FishTtsAudioCpp + { + // SHA-256 of the Fish Audio S2 Pro GGUFs on audio-cpp/audio.cpp-gguf (HF LFS oid). + // The engine binaries are the shared audio.cpp archives keyed under IndexTts25AudioCpp. + public const string ModelQ8_0 = "FishTtsAudioCpp.ModelQ8_0"; + public const string ModelBf16 = "FishTtsAudioCpp.ModelBf16"; + } + public static class ZonosTtsCrispAsr { // SHA-256 of the Zonos-v0.1 transformer (Q8_0) and the shared DAC 44 kHz codec. @@ -321,6 +378,38 @@ public static class WhisperCpp public const string LinuxCudaExecutable = "WhisperCpp.Linux.Cuda.Executable"; } + public static class PurfviewFasterWhisperXxl + { + // Hashes of the release archive (.7z) — written to the sidecar at install time. + // Upstream publishes every build under one rolling "Faster-Whisper-XXL" tag, so it is the + // file name pinned in WhisperDownloadService.cs that identifies the release, not the tag. + public const string Windows = "PurfviewFasterWhisperXxl.Windows"; // Faster-Whisper-XXL__windows.7z + public const string Linux = "PurfviewFasterWhisperXxl.Linux"; // Faster-Whisper-XXL__linux.7z + } + + public static class WhisperCTranslate2 + { + // Hashes of the release archive (.zip) — written to the sidecar at install time. + public const string Windows = "WhisperCTranslate2.Windows"; // whisper-ctranslate2-win64.zip + public const string MacArm64 = "WhisperCTranslate2.MacArm64"; // whisper-ctranslate2-mac.zip + public const string LinuxX64 = "WhisperCTranslate2.Linux.X64"; // whisper-ctranslate2-Linux64.zip + } + + public static class WhisperConstMe + { + // Hash of the release archive (.zip) — written to the sidecar at install time. + public const string Windows = "WhisperConstMe.Windows"; // cli.zip (Const-me/Whisper) + } + + public static class WhisperX + { + // Hashes of the release archive (.7z) — written to the sidecar at install time. + // SE-built standalone bundles under https://github.com/SubtitleEdit/support-files/releases. + public const string Windows = "WhisperX.Windows"; // whisperx-standalone-windows-x64.7z + public const string MacArm64 = "WhisperX.MacArm64"; // whisperx-standalone-macos-arm64.7z + public const string LinuxX64 = "WhisperX.Linux.X64"; // whisperx-standalone-linux-x64.7z + } + // For each key, hashes are ordered newest-first. Index 0 is the latest known release. // All hashes are lower-case hex SHA-256. private static readonly IReadOnlyDictionary> KnownHashes = @@ -329,13 +418,15 @@ public static class WhisperCpp // CrispASR — https://github.com/CrispStrobe/CrispASR/releases // Index 0 must match whatever version CrispAsrDownloadService.cs is pinned to, // otherwise users will be prompted to "update" to the same version they just got. - // That is v0.8.29 on every key, HIP included — v0.8.29 publishes all eleven assets + // That is v0.8.30 on every key, HIP included — v0.8.30 publishes all eleven assets // SE downloads. v0.8.26 is absent throughout: its Linux legs never built and SE never // pinned it. v0.8.27 is absent from the two HIP lists alone, since it never shipped // that tarball (CrispStrobe/CrispASR#339, repaired in v0.8.28). [CrispAsr.WindowsCuda] = new[] { - "9d3b6fe1f8c3bfce3169e8599c482239cf7fd7f45f9544a3a654b11eb00baa6b", // v0.8.29 (current download URL) + "8ecb5b245d24d8008914ba1c4f3687c99d7c10c9dd86a1401e6cdbbf605717ff", // v0.8.31 (current download URL) + "035ce417ee32a2374b528066be93e156a9d74379ce23258dcf76172015b67988", // v0.8.30 + "9d3b6fe1f8c3bfce3169e8599c482239cf7fd7f45f9544a3a654b11eb00baa6b", // v0.8.29 "f8b71d12518b2b31ca5491f20b07a82657f143c56a5f3c64df5efe53ccc51107", // v0.8.28 "4847b650e8b52c2c681c7387e5cf90abf92e1d1011e70e43b695aeddb66a4c66", // v0.8.27 "3a19127a8f082e5ef4f9eaa0505cd2d9bf93f7ec45dff174a51990ef675f055e", // v0.8.25 @@ -375,9 +466,17 @@ public static class WhisperCpp "a5296dc09d3cbd393ac694b39a629aaa521d7130ff0a369b90117fcab2bdd805", // v0.5.3 "eee943adef921529e0a1c5d1d14f358cbafe8e5dc1260147e374b029932e774c", // v0.5.2 }, + // Windows CUDA 13, new in v0.8.31 - upstream added it beside the CUDA 12 build + // rather than replacing it, so this list starts at v0.8.31. Archive hashes. + [CrispAsr.WindowsCuda13] = new[] + { + "cff0dd759e511fcba0ee25f41dd5c2e3402ce6d0accf93ff44d81543927f5fd3", // v0.8.31 (current download URL) + }, [CrispAsr.WindowsVulkan] = new[] { - "d43c17f8a6c351fd988578d992f1b7753a342af26a8eea1416d7cf58f9daab0f", // v0.8.29 (current download URL) + "207efee1e09badcd802a8345bb3405a6d4f97872bfbcbca6be6007bd085e3e20", // v0.8.31 (current download URL) + "f62f06b47765553ca75fdfa4c92b1ae1c8c7008c93599a49b230be855c72bfef", // v0.8.30 + "d43c17f8a6c351fd988578d992f1b7753a342af26a8eea1416d7cf58f9daab0f", // v0.8.29 "4825feeee6220d93064c21e658c8a9c253c46892dd68ceb2a3d0b6f2a038e7b1", // v0.8.28 "f56529ddbdce8fb4551deb81877ddaec3be0d7d2f31acb007494876eef5b49e8", // v0.8.27 "938f30ee33c673934b0869945cf04750e8a89e3318863c516b54c3a9949fe3df", // v0.8.25 @@ -419,7 +518,9 @@ public static class WhisperCpp }, [CrispAsr.WindowsCpu] = new[] { - "fdfeb735172403b86537a106b218e26dc25ef1cf21094461d0269c69d155c1be", // v0.8.29 (current download URL) + "dfc8ab78dfd2cb4b2e059887785876e0024b3d262a3cc35cf1b284c2ca5850a2", // v0.8.31 (current download URL) + "182c093f6c70f164efcd152700adb7fa03cd4c8ec04fc717dd12bf29d4557e0f", // v0.8.30 + "fdfeb735172403b86537a106b218e26dc25ef1cf21094461d0269c69d155c1be", // v0.8.29 "7f220e7ab1b83e44d464fca14a44df17f3c9fe82a9c8762b50c8eebddf6eb09f", // v0.8.28 "9536d0530b6ec05b80ac1c95450950d1c3e4dd11858d8ab2171497f69d97fd5c", // v0.8.27 "07c668ecfa2942f6d6d4bfc82429a84d35824a7419a796d03fae6f15a3ce8d45", // v0.8.25 @@ -454,7 +555,9 @@ public static class WhisperCpp }, [CrispAsr.WindowsCpuLegacy] = new[] { - "c9de44543033bc98880b877f3dcf5c83b08107f234b43adc53745f01f58ac530", // v0.8.29 (current download URL) + "8fa7a43ed14f13a00cb64089946b8dfe820ce5c8f4ac82b4cf834748a58a4ad0", // v0.8.31 (current download URL) + "c5f6b3b22674a954e016237e96c97a7b6856d62f77d4304cf1fa8a287d5209ee", // v0.8.30 + "c9de44543033bc98880b877f3dcf5c83b08107f234b43adc53745f01f58ac530", // v0.8.29 "05304f9d8ace6c7bbef4de2117045058cfffd86ae4fde38cb3cf0f51e650adf0", // v0.8.28 "ba788abb293e8102c69634ae1cd669816fec9e394ab6400624091769deb0eb76", // v0.8.27 "964890a77147365f92ad41e358889026db8b31fbc839c039a13a97f271c8b861", // v0.8.25 @@ -495,11 +598,15 @@ public static class WhisperCpp }, [CrispAsr.MacOsX64] = new[] { - "1ab9bc657c81e9ffcb5c733b66aaa340977cae7308309f4de13651e4896d7783", // v0.8.29 (current download URL) + "ae781eda4a1e1223a617f753da4df3feff892fda84e1cd7e666d51a11dfbb846", // v0.8.31 (current download URL) + "ab6d673642e6a7c52374cdab9558d63c455ab8485654f56fcdae8054d34f7f75", // v0.8.30 + "1ab9bc657c81e9ffcb5c733b66aaa340977cae7308309f4de13651e4896d7783", // v0.8.29 }, [CrispAsr.MacOs] = new[] { - "1425b177a19ff763dcf057c13f4f5244b902e53dc72c3c8be037276a66faf941", // v0.8.29 (current download URL) + "dac9d8fe197921e5fbca3b37cfe3213727ce8e5e8ba8302220b27c7d7417b345", // v0.8.31 (current download URL) + "51b83d6b4a2d68e0a7d1f5351963ac162b250b3f2fc0791813db4928b88e096c", // v0.8.30 + "1425b177a19ff763dcf057c13f4f5244b902e53dc72c3c8be037276a66faf941", // v0.8.29 "1972e6b7df4cf0d62dd458d07e39373db59b612f60949ec389338410bfca20a6", // v0.8.28 "7c40c53dfaf1d93d9fa744207f65f4a6ef2ccd0346fe2ffdaf0349399186af43", // v0.8.27 "0aa78b9a5e1e0e930e4d7a79706b486306c36c0a358ec432ea6729727a767891", // v0.8.25 @@ -541,7 +648,9 @@ public static class WhisperCpp }, [CrispAsr.Linux] = new[] { - "a864d07de5fa1c22c4ed3bd0d39e42dd9fc7e176518b563af065336b0609eee2", // v0.8.29 (current download URL) + "05aae003ff95e7841a403d317040f7479e70d556fde230a8e1cf1648566d2f7e", // v0.8.31 (current download URL) + "d0a74a4f541f39065ea1d084b526a56f120169b6ac676c4d5999803d52a339c5", // v0.8.30 + "a864d07de5fa1c22c4ed3bd0d39e42dd9fc7e176518b563af065336b0609eee2", // v0.8.29 "8dc5a5b5727218616de640787799700ceaaf8685eddebf25d8dfb91123f7ee11", // v0.8.28 "a8ce6facef237bdba8727ec8dc12d322e7ef5560658bc88e4a983fb4c1f1f1aa", // v0.8.27 "050be2dd783d9c5c213b2455c45d00844c7cbbbb06afe3166fc010108a6a5a08", // v0.8.25 @@ -583,7 +692,9 @@ public static class WhisperCpp }, [CrispAsr.LinuxCuda] = new[] { - "1c6736d76618cbf997ebf4a6db147c57efaac4d2691e738f2529580dba25dca2", // v0.8.29 (current download URL) + "21657ab1c1a6b30c94203484e04e2d8ac16847ed34ff21fe363b0827a368b057", // v0.8.31 (current download URL) + "0fc0797910c08ea6d953516708adb727d26ce0fc5620286eda4e1056c39a0126", // v0.8.30 + "1c6736d76618cbf997ebf4a6db147c57efaac4d2691e738f2529580dba25dca2", // v0.8.29 "1143faf24c77ac0b8f4edc6513e836406cff4a2d9c8445a4f852c8e52af5d673", // v0.8.28 "e6252049d42046fba3fdeda411630f1f756482f50f6995b8a7c1cdb6a8b7d5d7", // v0.8.27 "bc504fff3d9c02d92e348b1c624169fd4a37de7b87eccb1276fe34fa1c2ebe1f", // v0.8.25 @@ -616,7 +727,9 @@ public static class WhisperCpp }, [CrispAsr.LinuxCuda13] = new[] { - "78b94314eb3aa8092c41beb22afcf27d88e8a3d2ecb526eaeebb7db895a9ceb8", // v0.8.29 (current download URL) + "53783890cf452af15763ed2638bb7cd97404445e20bcc692c36377a177ad7dc3", // v0.8.31 (current download URL) + "b02f928607f4defb83ff42188301f44bfe9031c96c8fb581a0e301ff7d467194", // v0.8.30 + "78b94314eb3aa8092c41beb22afcf27d88e8a3d2ecb526eaeebb7db895a9ceb8", // v0.8.29 "2e7b99e09c725de66e3175fca7effee17e5fddc3ee63b02c515159b837fa948d", // v0.8.28 "c6875a77e3f9bf2658c4d3a8e86bad8cb64fa8ad0a5c97fc452b5f464226e813", // v0.8.27 "176dab15daded7e7b7fcc9a1685ff2b9a63047d4365d4ba5fa8dc4e97c069771", // v0.8.25 @@ -626,7 +739,9 @@ public static class WhisperCpp }, [CrispAsr.LinuxVulkan] = new[] { - "74906231768a8e96cf5e21a37bd983bd85aeab47c928d08391eb1ab753fd12b7", // v0.8.29 (current download URL) + "ff8b8d6eca2a4b5c49001a7a08e2126030eabe1d8234e345dc87044589546899", // v0.8.31 (current download URL) + "f1f81ae19f7cec8bccaa9bfcbf97ff63f8977fe951350ca9e6895dd640e1da2c", // v0.8.30 + "74906231768a8e96cf5e21a37bd983bd85aeab47c928d08391eb1ab753fd12b7", // v0.8.29 "081e814933ab3d1e1b3ab4da46243d6fc8fe9a6cc89d98ae05b0e2157fe59ad6", // v0.8.28 "088648011b6123d7fa81e8ba09224c4e1dcf26345ec825d4ef7df4a6ba8fe37d", // v0.8.27 "d8bd2d0dbbf3bb721dd185f40b35974b47322bf010fde05d2052d6f04e48ef33", // v0.8.25 @@ -636,7 +751,9 @@ public static class WhisperCpp }, [CrispAsr.LinuxHip] = new[] { - "d8820c259ed544c883ae60c69913dc2950bf1f5bc64c16df31acb9256604a6c4", // v0.8.29 (current download URL) + "5363603581b9a26f84cfc40e19a7bdc4bffad8bbcc7cba0b545979a41a3300ef", // v0.8.31 (current download URL) + "327095743d73525c2ac12a647f0ad7860c59b9ad3f1c15ff943d52c6102a0b3e", // v0.8.30 + "d8820c259ed544c883ae60c69913dc2950bf1f5bc64c16df31acb9256604a6c4", // v0.8.29 "1e990a1c9489db344ad51beddb2be583434024b34b397e1cbedf7dcca8b74e34", // v0.8.28 "f6469c530113a8336018e1d769c2f0383889f6691f93d858a7106655dfea643d", // v0.8.25 "3b3c4ec77f4d977e7dad5969a5938321f61694819cba03b5d7b3738d35ade8e4", // v0.8.24 @@ -645,7 +762,9 @@ public static class WhisperCpp }, [CrispAsr.LinuxArm] = new[] { - "f1ff16d3727263afd3518c395eace3802e46d259ac9f57adda5d3ae18621355e", // v0.8.29 (current download URL) + "3eedcce261191daa22b94db97e6c778a263ec814ba8e6432be8b56d971d28b3c", // v0.8.31 (current download URL) + "3239b875dc0eb429031d7f7aef153cf4b92abd3a66eb29dc32004e54947385d5", // v0.8.30 + "f1ff16d3727263afd3518c395eace3802e46d259ac9f57adda5d3ae18621355e", // v0.8.29 "0a343c3bf1c52e89dd6f916b13c0c5260a9eab9cfa5be7194f79df7bfbd77b38", // v0.8.28 "f9caefac964f67a101eabb63892c37776f24b9fe8e11f1607d784944968347d4", // v0.8.27 "19d6031178a3a9a223f4a7cc47c97d87ae1bd11270fd26a68f3accb2397e5183", // v0.8.25 @@ -926,7 +1045,9 @@ public static class WhisperCpp // SHA-256 of crispasr.exe / crispasr extracted from each archive above. [CrispAsr.WindowsCudaExecutable] = new[] { - "d0253d7e62cfc9a82b94e9e265f1a3b145cc4ca0923b219a3c4be6be54397150", // v0.8.29 (current download URL) + "93638202f3a6c5d44d937b6f38222f0742e945582ffbdd974a3f6e5cc8cc108b", // v0.8.31 (current download URL) + "c9fd01bdb0b9a46cea73710267a8fe847da42d32f73af1db74e2994e346d7c86", // v0.8.30 + "d0253d7e62cfc9a82b94e9e265f1a3b145cc4ca0923b219a3c4be6be54397150", // v0.8.29 "3c7045d9cbef62f9bcd977654c5e51ad987ea98448f53c0755793d94f9f14430", // v0.8.28 "dc6d836151800fab02ed87baff10e0b72dccc58362bff7cfb41035a5bea324d9", // v0.8.27 "75d770cb6d876d87b457c7aee7e498a62e18f1a54930afc31c72fec2471103b7", // v0.8.25 @@ -951,9 +1072,17 @@ public static class WhisperCpp "f48979a07eac3c0bc0698f0d01c0e5dd25436867afc99d314d3fbc5113587b52", // v0.7.1 "d771396ed4d9fe66626a8be9f648af904798f39ec73549ed9d3ba795e2ef51f4", // v0.7.0 }, + // Windows CUDA 13, new in v0.8.31 - upstream added it beside the CUDA 12 build + // rather than replacing it, so this list starts at v0.8.31. Executable hashes. + [CrispAsr.WindowsCuda13Executable] = new[] + { + "c4b32c46ad52acaf6901f93dbedf8fdf407448db6e67cf00cda7f29bb32a153b", // v0.8.31 (current download URL) + }, [CrispAsr.WindowsVulkanExecutable] = new[] { - "86c775545161cb3206d781dbd7961a9a7ecd9069c7f5658fd93b080a5126e80c", // v0.8.29 (current download URL) + "dd80203049d00664ad852a8944815c049dc3f831b8a0a982ea537936f5f4adf5", // v0.8.31 (current download URL) + "305589fa623a58737608180fc72c7ee3c3633d831c0fb57e761bdf533f6fd9a2", // v0.8.30 + "86c775545161cb3206d781dbd7961a9a7ecd9069c7f5658fd93b080a5126e80c", // v0.8.29 "3bcee3a38c3e56f1f82db5bb3b401ca13105e63a554ce93f3918e2cfc8c6a92c", // v0.8.28 "f10268dcf2dcfaf3c74c5b11cf9d453c627e4ff57d4100ae6ec445df80a5e3d3", // v0.8.27 "4d5f35ee9deef8d698ec7d056422ae8e858dc088db434932f8032e8c6766ce7a", // v0.8.25 @@ -995,7 +1124,9 @@ public static class WhisperCpp }, [CrispAsr.WindowsCpuExecutable] = new[] { - "774b93fdebd71a31987c3962c1b8101196926a1f3aaea446a2bafd2003258afe", // v0.8.29 (current download URL) + "65a0f5f8c79c304d714a9cefdb6eeb49ea23df20537f767add6ce2ba0f8223de", // v0.8.31 (current download URL) + "5c6950b3bf8d6df985eefdd6320ad2654607bb54d512d2f2ca03db97078e310a", // v0.8.30 + "774b93fdebd71a31987c3962c1b8101196926a1f3aaea446a2bafd2003258afe", // v0.8.29 "94924871e6e7af89eac097adaa9d031e7633806217b161a78ae420daa4acfbef", // v0.8.28 "7682b316d04e4d554e68ebcfdc58f3557132d2c156cb8e40e15fbac09fe32b31", // v0.8.27 "775ce2a860bac8ba988cc0016d6224f0f8a453684244bd4fd7ab73e559df1df1", // v0.8.25 @@ -1030,7 +1161,9 @@ public static class WhisperCpp }, [CrispAsr.WindowsCpuLegacyExecutable] = new[] { - "a84fa61870f3c89816662de7db9b4dca2c4217fd7945e6443386bf3b818832e8", // v0.8.29 (current download URL) + "bfd295b1583b6c8287d7bdeb61e4468246985a204ad54e8ac9959818a75a04bd", // v0.8.31 (current download URL) + "09b71842c3df6470dcb73c380566669d7f087d9ac502191ff8aa9c23a8e108cd", // v0.8.30 + "a84fa61870f3c89816662de7db9b4dca2c4217fd7945e6443386bf3b818832e8", // v0.8.29 "c2df5c9b4364cf5be47f0112ac28d22fcb7f5724ca4deda8fd6d04acb4abb97a", // v0.8.28 "760034450019ee82c4fa383496014bef27fe455c584b3577387c1deec07b4f8c", // v0.8.27 "58569b7845a6de2f359ae8125460aebe7cb46081571b24c5293728eb48909c63", // v0.8.25 @@ -1071,7 +1204,9 @@ public static class WhisperCpp }, [CrispAsr.LinuxExecutable] = new[] { - "e59912f57a8b4489e76ff77d2b5c14e9cd51b1616b35275c0282d98a09ab15e0", // v0.8.29 (current download URL) + "85642de467426baf01394ca0aee75c750a8ac341a3321d6d8b5bb05de8afa5b7", // v0.8.31 (current download URL) + "87da5064cfe790be3344372332be925997a282f6c9c0d2fc5a0b2271d5c12b88", // v0.8.30 + "e59912f57a8b4489e76ff77d2b5c14e9cd51b1616b35275c0282d98a09ab15e0", // v0.8.29 "a72982ace1b40ed146c4726ba8eeb29c92b4eea40f546292aed86fa5dc68c778", // v0.8.28 "52b26893cc0e0b7d1433c254a721ef8e4bd5fce9e17886df9ef1d7c17e9c9865", // v0.8.27 "497e8d5c4a57cac2517d0c4a5b1f25004af5b2c95052a07e0cf04b4d12435d91", // v0.8.25 @@ -1113,11 +1248,15 @@ public static class WhisperCpp }, [CrispAsr.MacOsX64Executable] = new[] { - "64d488a7b304d14e03ba353a3763b281fa2b55e067182f90ae5ba626ebaf3250", // v0.8.29 (current download URL) + "3f09562abe72bfb7b0bc697a046171fd058754868db81896da4b632aa7be3acb", // v0.8.31 (current download URL) + "4dc7534c7a8196f5894c5ab685e1f407ae479ed5538340d457d1e668a3fdf580", // v0.8.30 + "64d488a7b304d14e03ba353a3763b281fa2b55e067182f90ae5ba626ebaf3250", // v0.8.29 }, [CrispAsr.MacOsExecutable] = new[] { - "8f01526037dac3696caaec59735d9d1a39fceb4b60dfeca8a405e6633f402980", // v0.8.29 (current download URL) + "00e2f8456143989f6477432f1f488b27376689bcbe13b7fa04e46c4e77c2f5a9", // v0.8.31 (current download URL) + "f0c6d2ff575295bdfb74814908f8b2bfc1ed0e38f25a3984269dc7f24d47ca0b", // v0.8.30 + "8f01526037dac3696caaec59735d9d1a39fceb4b60dfeca8a405e6633f402980", // v0.8.29 "d4e404c21b1a3acd32350d620bab9f000547a69eeffe37debe846b91c4d96d2d", // v0.8.28 "02aa64ca43c0ebf094a2cda841eebfbe02bc5a0a1e50967b994e908a625c3b1d", // v0.8.27 "bcac974bd9a7dfddc4f4194d86dc1b6d42dc622ca161855fe5a498fd73a31701", // v0.8.25 @@ -1159,7 +1298,9 @@ public static class WhisperCpp }, [CrispAsr.LinuxCudaExecutable] = new[] { - "0b369ba41bbfc00eec48020b45185cb97a5a27946b157870afc01987feac53eb", // v0.8.29 (current download URL) + "c70bdf375493d9ac9ba9972720f26f0d5a2fb2aecac3c809164d259a64da3719", // v0.8.31 (current download URL) + "5ebddf4ab2ba282427fb66f3e3a636514ec02e93da698e61b06e5e38cca4b9d2", // v0.8.30 + "0b369ba41bbfc00eec48020b45185cb97a5a27946b157870afc01987feac53eb", // v0.8.29 "c2413af7d5e70a166b74fe96f5afb6d7463f1414a5ac2dea7cd982da9f83f827", // v0.8.28 "271c213589b739a45743b6ae12075a4e30c97f1fe49929dfdff4b0a2fad5e124", // v0.8.27 "403804c5d2a438d43f2da984a8f351eb36600aa164214b4737691e0ea5950b4b", // v0.8.25 @@ -1192,7 +1333,9 @@ public static class WhisperCpp }, [CrispAsr.LinuxCuda13Executable] = new[] { - "61fb50caad1352fe254d8a4290d6c3fa8f82c56da689abc5ceffe438448708c2", // v0.8.29 (current download URL) + "75e382247ca984583d2229332c2799294e88d853783191cec9b9df8343543205", // v0.8.31 (current download URL) + "b282745f56f8d3a039c23bd4077a8c2766aa5896a10847f2d3beae84d3ab7b22", // v0.8.30 + "61fb50caad1352fe254d8a4290d6c3fa8f82c56da689abc5ceffe438448708c2", // v0.8.29 "f6263c9ce4de91af07cc92fbdb858f594539181555b750a5f5007c4ad3aac0d5", // v0.8.28 "562edbada8ac18413c6e0e503181180269bb8de74a4d2e04e624da478564fe18", // v0.8.27 "a1ece1aeb9896c5550a45f4af7c9502c0a76b78a0de9ff00078b5730a28ec7c5", // v0.8.25 @@ -1202,7 +1345,9 @@ public static class WhisperCpp }, [CrispAsr.LinuxVulkanExecutable] = new[] { - "9d7b6e840ba9330efe15c04bee9575fcb0bf21a75ef13b9f3ea9c4ec8fe34e36", // v0.8.29 (current download URL) + "67a820daef26d1ac65d502c34fa57e597bb416c3010062d5aef6799f501f3276", // v0.8.31 (current download URL) + "f1ef9466c43adda45fae229cf801de3178b8cb45ee3b0f4fdbc169c4fdc37f17", // v0.8.30 + "9d7b6e840ba9330efe15c04bee9575fcb0bf21a75ef13b9f3ea9c4ec8fe34e36", // v0.8.29 "e25960acb27f307adeaec80555391307a9d42c8698af1ac3f44658f0efb7c510", // v0.8.28 "79365abfbfb6f87380220f496415e3b7fa247ef5cc7fd9da013d208a088ce62c", // v0.8.27 "204b9becbb783ca70f64d72652911ed360369a6201b3e16982a5535391c9f372", // v0.8.25 @@ -1212,7 +1357,9 @@ public static class WhisperCpp }, [CrispAsr.LinuxHipExecutable] = new[] { - "608f3face2afb02817ac1b0e6a647d89a74cee0104a3e1d5417fcd6658a8dd08", // v0.8.29 (current download URL) + "7f7421f1efe6e45cc3efa3d1579ce5b16d6a14ec7cef3decc66d6e810269ce68", // v0.8.31 (current download URL) + "3d22ba76b6e994aae253a4439a1aefe24830f11e453914e8e7bb7abb34258d91", // v0.8.30 + "608f3face2afb02817ac1b0e6a647d89a74cee0104a3e1d5417fcd6658a8dd08", // v0.8.29 "0c6fd42a98cb900e42816502ce20943a1516d0d3a896786391ba17989ecedd59", // v0.8.28 "2a5504fb5ed295a6ca9689b5f8657af0346425ffa1263f137659dfba24082b9a", // v0.8.25 "d4ceff3e5096fa566f4fff21a664969efd484ccc0463df3789536dc91dc82d77", // v0.8.24 @@ -1221,7 +1368,9 @@ public static class WhisperCpp }, [CrispAsr.LinuxArmExecutable] = new[] { - "cbbd59c8822d261ff45cede17cb4d99cfa5e23a4849090d44fa5931106fe9bb5", // v0.8.29 (current download URL) + "c2c95d76cb57884702a2fa29e2ea0e1879e29320f94d71fab965e71c9978b5eb", // v0.8.31 (current download URL) + "0fcf64241d3fc7a0a6c18eccaa706a03f67f09ba1c79f209a8160ce2f1cd1305", // v0.8.30 + "cbbd59c8822d261ff45cede17cb4d99cfa5e23a4849090d44fa5931106fe9bb5", // v0.8.29 "61bb8362fd25eb349aa80f2931087171ad8286810e2488cc96e0c2083963fdf1", // v0.8.28 "ae98804c62bad77062780288e3426933bb5ae6d81ed59dfb79a5054966106d1c", // v0.8.27 "29b0aa5ab51fc12f29ea9b705f2fedfd2d5d640a333404f8a64e62eeabf4f943", // v0.8.25 @@ -1254,7 +1403,9 @@ public static class WhisperCpp // otherwise users will be prompted to "update" to the same version they just got. [LlamaCpp.WindowsCpu] = new[] { - "778a3588dd634ac43b088b96b24da35ab83d68562fb921499b0ecdf831d6552d", // b10507 (current download URL) + "ed409470580a35501b48396b0b6a6d75f7835fa9741b39af668fc94952c37e98", // b10760 (current download URL) + "7047937dfbc372f9a9c41426f74c034529539bcc17fc4dd6e797013f2ad61fe2", // b10625 + "778a3588dd634ac43b088b96b24da35ab83d68562fb921499b0ecdf831d6552d", // b10507 "bb9cded3135a013d4b4a015920b8601be61555de77e3422f6263c9421277a386", // b10310 "53f2e78056250a85aeff3e92f5081436f68a88e3e6b0b74f3642ddc2e0cf6b40", // b10256 "ca78df53654be907193f2615f590c51960f0a009c09285d1d1a5efa8b84d69b9", // b10216 @@ -1270,7 +1421,9 @@ public static class WhisperCpp }, [LlamaCpp.WindowsVulkan] = new[] { - "c6e5b1885a47eb6a5ec9a77f9eefbbdb7364b39ed953032bf9d051be4af716e6", // b10507 (current download URL) + "34dfb5aab953a1e69faf0fc185edda10ff08e515f5607f7b8cdda740b1ed88cb", // b10760 (current download URL) + "322b8da9a596200ef323c1b3a6f95feea088845896309710b0817403f494be5e", // b10625 + "c6e5b1885a47eb6a5ec9a77f9eefbbdb7364b39ed953032bf9d051be4af716e6", // b10507 "7fcd045b9bf91c341e28abb10336cd618c4d1b35b6f74f632fce856506d31492", // b10310 "ea787c151309a80b908809a04b6f71d44da41de0ea4b2794114567b67df861f6", // b10256 "ec2d403113c9e55994f8def7c0f47d019b13bc3e07a63cd4342698238926a3f7", // b10216 @@ -1286,7 +1439,9 @@ public static class WhisperCpp }, [LlamaCpp.WindowsCuda] = new[] { - "94c88d8ae80fc9f599ee487ca4e36cbea0ec1e43ad8d1e8d71e3a6e786a61e2f", // b10507 (current download URL) + "e9dd9eed1fc920cd5f9ff803730c32e0f831823f6c6294024fe7b1093e33e2fc", // b10760 (current download URL) + "0f4bc27e978b973f6074ae241aeeb799924b1fe3b173a3337e94c2e23e6f34bb", // b10625 + "94c88d8ae80fc9f599ee487ca4e36cbea0ec1e43ad8d1e8d71e3a6e786a61e2f", // b10507 "469d0f07a45688869f50706e9526722ad1f866a6481be9688fa00832a7b534de", // b10310 "229db8cd65b161e0b817bd516ca014d5df21e57fd65e56263c4882e1dcacd0c0", // b10256 "bbb6855aa89e091c87f9c5eaf1afcd9c06d90da94435d538463308d16c077670", // b10216 @@ -1304,7 +1459,9 @@ public static class WhisperCpp // SE ships it from), so the list stops there rather than going back to b9145. [LlamaCpp.WindowsCuda13] = new[] { - "59de660ae24ff021bf5df7c5db914b2902e3d42b21e23397aea3b65220bb355e", // b10507 (current download URL) + "92c4baffec8d3fda30c3c4712771254303d0b5c1a2aea94c2017178e0a81acea", // b10760 (current download URL) + "81b22343bb3137334e87181cef6152c0716c4a93af003483b49916a315357962", // b10625 + "59de660ae24ff021bf5df7c5db914b2902e3d42b21e23397aea3b65220bb355e", // b10507 "1c4fae4bb4293af9d12801eb225201d80aa631ee6f3bec99bc8fffa52439ccf1", // b10310 "41b18a6d8807e63529babe7a5e0cb0692b10288acf98ef37cdcd064a92c594a1", // b10256 "d6743bc4b02cab1fe466c5265d9aa8c9fd5d9382f0e53604e8011e71277ee659", // b10216 @@ -1313,7 +1470,9 @@ public static class WhisperCpp [LlamaCpp.WindowsCudaRuntime] = new[] { // Identical bytes to b9297 — the CUDA 12.4 redistributable is unchanged across releases. - "8c79a9b226de4b3cacfd1f83d24f962d0773be79f1e7b75c6af4ded7e32ae1d6", // b10507 (current download URL) + "8c79a9b226de4b3cacfd1f83d24f962d0773be79f1e7b75c6af4ded7e32ae1d6", // b10760 (current download URL) + "8c79a9b226de4b3cacfd1f83d24f962d0773be79f1e7b75c6af4ded7e32ae1d6", // b10625 + "8c79a9b226de4b3cacfd1f83d24f962d0773be79f1e7b75c6af4ded7e32ae1d6", // b10507 "8c79a9b226de4b3cacfd1f83d24f962d0773be79f1e7b75c6af4ded7e32ae1d6", // b10310 "8c79a9b226de4b3cacfd1f83d24f962d0773be79f1e7b75c6af4ded7e32ae1d6", // b10256 "8c79a9b226de4b3cacfd1f83d24f962d0773be79f1e7b75c6af4ded7e32ae1d6", // b10216 @@ -1327,7 +1486,9 @@ public static class WhisperCpp [LlamaCpp.WindowsCuda13Runtime] = new[] { // Identical bytes to b10142 — the CUDA 13.3 redistributable is unchanged across releases. - "1462a050eb4c684921ba51dcc4cc488a036674c3e73e9945ee705b854808d03e", // b10507 (current download URL) + "1462a050eb4c684921ba51dcc4cc488a036674c3e73e9945ee705b854808d03e", // b10760 (current download URL) + "1462a050eb4c684921ba51dcc4cc488a036674c3e73e9945ee705b854808d03e", // b10625 + "1462a050eb4c684921ba51dcc4cc488a036674c3e73e9945ee705b854808d03e", // b10507 "1462a050eb4c684921ba51dcc4cc488a036674c3e73e9945ee705b854808d03e", // b10310 "1462a050eb4c684921ba51dcc4cc488a036674c3e73e9945ee705b854808d03e", // b10256 "1462a050eb4c684921ba51dcc4cc488a036674c3e73e9945ee705b854808d03e", // b10216 @@ -1335,7 +1496,9 @@ public static class WhisperCpp }, [LlamaCpp.LinuxCpu] = new[] { - "f7035274bb6a50c7eda05e21929ab9dbd5fdc1cc37915a9b510c34951491f3ae", // b10507 (current download URL) + "00cfac8189ebec8d5576c2a5acfcd7bff230ec2aa4b8454a8f2fa77548b4cc15", // b10760 (current download URL) + "7624160036a3045b388b6115190f24b8c53b4f1066919bffea0276b1e28ca93a", // b10625 + "f7035274bb6a50c7eda05e21929ab9dbd5fdc1cc37915a9b510c34951491f3ae", // b10507 "3082c73e7485542865d429436e06af6255aca13506c32a21b7c4ea7424fac6c0", // b10310 "aae348ab1d00c6724e299c1ee23645dfd363a6636655429c09b3eff0004a1484", // b10256 "4edc67163cac10b82fa0f63b813accce40e9f01490b700b49a34b86510ad6afe", // b10216 @@ -1351,7 +1514,9 @@ public static class WhisperCpp }, [LlamaCpp.LinuxVulkan] = new[] { - "ce89da3d0b91166b4fc92717279d476db3115de802efd1d09aeb338efd395dc0", // b10507 (current download URL) + "da708714681a23f97d072abe9e23457972a8e0870205ca4acf125b005e70f4d3", // b10760 (current download URL) + "8941d79d1c5f7cbb069a043e6303ab102e080be5790fea10de718d8dca5da5e5", // b10625 + "ce89da3d0b91166b4fc92717279d476db3115de802efd1d09aeb338efd395dc0", // b10507 "09c0a14ca3a55671a49485161f3e39ec0e216441393b04ec0db4d4b79823edc2", // b10310 "75acc88a11e0aa811a8700dfc430a5cc2a32cc18f7b1f675400dc0b1d53ba20f", // b10256 "43793a565c0cfe1fdec430050950d6eed3e87aa7d14ebba8517125d78da15d79", // b10216 @@ -1367,7 +1532,9 @@ public static class WhisperCpp }, [LlamaCpp.LinuxArm64Cpu] = new[] { - "8021c17258bb10946a312df2d7096a9930261b1a7bb3a4cac09fd57fad8fbb83", // b10507 (current download URL) + "ea26ba267c3a81e014bc1342fb3310cfeaab29e96c831b211bde9770078e666f", // b10760 (current download URL) + "a94a0c358b13bece77123f4529d590ae5378ea6232a8b26ffe86f9b378184e87", // b10625 + "8021c17258bb10946a312df2d7096a9930261b1a7bb3a4cac09fd57fad8fbb83", // b10507 "2be4e4454e8f9f5a0465ee8d02683ec4c47bc2d6405203a2097d80004b5a45ae", // b10310 "45dfdcb064af6356f6026b4e03270c50dadbb8eff2cbf545e9edce26485b61e7", // b10256 "a66b83e36d2591b2a65a6179a8173db450dad4f3e6238efb40cf837f5ef08b06", // b10216 @@ -1382,7 +1549,9 @@ public static class WhisperCpp }, [LlamaCpp.LinuxArm64Vulkan] = new[] { - "02b08d4b8f1360b230e72e02c900afadb5a31913e8731cb4ef3437fff9645a48", // b10507 (current download URL) + "2447915dfcaa66cd5492d73e1fba0bafaba171fbdc8e9e254a837629d247736f", // b10760 (current download URL) + "b9950553353886410f8e8fe9a55d0494dcc6e9a3f9998b2c7b454256d19f2f8c", // b10625 + "02b08d4b8f1360b230e72e02c900afadb5a31913e8731cb4ef3437fff9645a48", // b10507 "a1608f0b9a40334968dc7814e3c0270a3255a0e33ed86a505345a4f16432bf77", // b10310 "dffdb284ec9bfa0bcb529f1862cde9bab1aae27af428a0a768d03eb4eb9eb998", // b10256 "d91755c9e503379ab976efae476e81e44288c4e0b83087526f350febd461e217", // b10216 @@ -1397,7 +1566,9 @@ public static class WhisperCpp }, [LlamaCpp.MacOsArm64] = new[] { - "40acf05610d56a39552116cd8e3749b8a43023c318f31f179feb49e3cfda5d7b", // b10507 (current download URL) + "4451e74e6f6d76838b6a10be8c0224d74f0fe2b2c9c23e9a4ff46c33855dd782", // b10760 (current download URL) + "f13c74d104c1ff2e37a14ecb2025afe5c9c4c148064badfd8116376018dd5159", // b10625 + "40acf05610d56a39552116cd8e3749b8a43023c318f31f179feb49e3cfda5d7b", // b10507 "fdec9afcb2ee389dee74cc7c5f86c7f270b0f88fb248f0a7d1e5956fa61f100b", // b10310 "e5cf39e8fef6edcdf81da4748095737d5a2cf8dc20c79e073b4d8606fbdfe7fa", // b10256 "d03b61e1ec9a4abb62d63f16f33574a2a4832de1885a7537035476886f8efe50", // b10216 @@ -1413,7 +1584,9 @@ public static class WhisperCpp }, [LlamaCpp.MacOsX64] = new[] { - "84feb84165647a9b4e02a115f32f7f7973989a404a8071eb020c486a238e27bb", // b10507 (current download URL) + "909188c4feef3519a4f5e95001b41dd35f07319b81ee4965fcbb524e7bc8e3a9", // b10760 (current download URL) + "80fe3c556749fdfc23eae27560e8d95c4ca6d3d7039982ce0c493109d70b471b", // b10625 + "84feb84165647a9b4e02a115f32f7f7973989a404a8071eb020c486a238e27bb", // b10507 "550157dde57e2b0f4b7405cd1b6f910fd998a1a376c490d231a97c23dedbb759", // b10310 "b90b0e5f2976fc3f20c075fc0bfe1084a7d6c64075f64f154dedfc9470136c79", // b10256 "f296d166297768dba4f8dc717b85258f096f545cf4592fc8adf4e68dfa64bc2f", // b10216 @@ -1433,7 +1606,9 @@ public static class WhisperCpp // three ship the identical dispatcher EXE that dynamically loads the backend DLLs. [LlamaCpp.WindowsExecutable] = new[] { - "061da4d787ddcba37b3a2fac606a1df529f481a1b36b2ca4188b37597b7a81d7", // b10507 (current download URL) + "4d2f56c46859a3679fbfa35cc0fbad405ac239a7a35f64e9f2ff613407be0963", // b10760 (current download URL) + "0a057010f95ef1609bdc731d587c296b8d30d8eba36d48f98a84b8f3392f21d8", // b10625 + "061da4d787ddcba37b3a2fac606a1df529f481a1b36b2ca4188b37597b7a81d7", // b10507 "65969537ed80578fc70c358e963c54123ca2d1488eab7cf1a8814f63efc6d390", // b10310 "57ac5133e847dc0666fdd16834b5c0dc0e75acecb89f581d6d4afd83a18b31c0", // b10256 "dcc76b45556b0252b353a4693e84376c8a04d2eb44b5bf153efc18a5556c54e6", // b10216 @@ -1449,7 +1624,9 @@ public static class WhisperCpp }, [LlamaCpp.LinuxExecutable] = new[] { - "c5aabbf808bab4029ac7fcdb382fe3ab0806a1abe1b036ad2bf160e8742320f5", // b10507 (current download URL) + "64af3e80bdbf635bc3be71e42b53a3f2fec9be3206701684beeca76d08efa30a", // b10760 (current download URL) + "c61f5be8dcbd8031ec7fb6acd8f2aa369b8d73628e2e5c94d3521878d21c95a5", // b10625 + "c5aabbf808bab4029ac7fcdb382fe3ab0806a1abe1b036ad2bf160e8742320f5", // b10507 "5827bd0feb25d8f2bcd0fd6f07d5be63e927d66bee663d2f00a6e954ab9495ed", // b10310 "8ba862116a13643cab341d5eb821a538ceeaeaaaf0981698d19d0429bdd63e35", // b10256 "9c5ac295714edfd3ed85898f31a3b8e7aff4d0922890364e9983b36f19506b7f", // b10216 @@ -1465,7 +1642,9 @@ public static class WhisperCpp }, [LlamaCpp.LinuxArm64Executable] = new[] { - "32838ffe45a59a54379ef57189dda95f8c98cd05e105f06df23060c6af5237dd", // b10507 (current download URL) + "ef54bfa150a9ca9d0a6dc018a64abb37f34abce1057f896a26aaef6ec54e22cd", // b10760 (current download URL) + "0535902f7db6f4d0c4bfa7df65f4e773ba3be34b4c4e1e6cf5b7c914f2044954", // b10625 + "32838ffe45a59a54379ef57189dda95f8c98cd05e105f06df23060c6af5237dd", // b10507 "2812284b1630b7789ae681a776e07e1a2e550c379b0d1d416b33a79115d0cf28", // b10310 "835a34179cc24fcbfac93f2cefdfc2162382bfac50fe9e6fc3131ed2a58eb221", // b10256 "f6234f84afbb7f8d4a17c5ae4791e5085abac2e83e7a8382ae45c16293dffd32", // b10216 @@ -1480,7 +1659,10 @@ public static class WhisperCpp }, [LlamaCpp.MacOsArm64Executable] = new[] { - "d0878274b8d6bd3c8ea26a78eb66cd1ffd943d007c62b9dff31c8aa99922d713", // b10507 (current download URL) + // Identical bytes to b10625 — the macOS ARM64 llama-server is unchanged since then. + "c32a2010dec561243448447599b7c13789022052ed59a336005758fbacf03639", // b10760 (current download URL) + "c32a2010dec561243448447599b7c13789022052ed59a336005758fbacf03639", // b10625 + "d0878274b8d6bd3c8ea26a78eb66cd1ffd943d007c62b9dff31c8aa99922d713", // b10507 "02723fc39fbeebd9849ce4c9ca3799649df3cf91f101c2cd56b8756e1db54d28", // b10310 // Identical bytes to b10216 — the macOS ARM64 llama-server is unchanged since then. "ff0e2445d93e2d6305c44cce6386db1020385194261dc184deaf0f37c7148d85", // b10256 @@ -1497,7 +1679,10 @@ public static class WhisperCpp }, [LlamaCpp.MacOsX64Executable] = new[] { - "f3136584b712d052374aa14765bea077721dc886af647228483ce79e2d838964", // b10507 (current download URL) + // Identical bytes to b10625 — the macOS x64 llama-server is unchanged since then. + "62754700882ef81d9c0511593222a8f70fe46269ddf1e67c399d31fe1ed51107", // b10760 (current download URL) + "62754700882ef81d9c0511593222a8f70fe46269ddf1e67c399d31fe1ed51107", // b10625 + "f3136584b712d052374aa14765bea077721dc886af647228483ce79e2d838964", // b10507 "721790e96eb2660278d308f8d31a6eece6bc2173f86e33b34b0e7080ee06aa3c", // b10310 // Identical bytes to b10216 — the macOS x64 llama-server is unchanged since then. "58a29482e3273083944d964528a17bc6629ac46d4cf0d152e0fcb6e5c0835e01", // b10256 @@ -1669,6 +1854,83 @@ public static class WhisperCpp { "fcba9a322d80ef318da8a17c01e8a5e7f299ccdf881c62a43abf62cb3c104268", // indextts-bigvgan.gguf }, + [PocketTtsCrispAsr.EnglishF16] = new[] + { + "66d16cef2b59e51003be1a025a8df2fea5f878dcf9434ca72aab5f13a48a2603", // pocket-tts-english-f16.gguf + }, + [PocketTtsCrispAsr.EnglishQ8_0] = new[] + { + "fc80ece425f647b93174860ef279b877e5ad3454bbba3ec4fc76f535942c12c4", // pocket-tts-english-q8_0.gguf + }, + [PocketTtsCrispAsr.GermanQ8_0] = new[] + { + "dfd58f89a443991b2ee538a9e0595a1c41728907a0b086441b0bff99ba7b28c1", // pocket-tts-german-q8_0.gguf + }, + [PocketTtsCrispAsr.SpanishQ8_0] = new[] + { + "9f53d588fc8564f8aa0de2b8f17493a56b834202bf200744b87d3e0cd8c0dd66", // pocket-tts-spanish-q8_0.gguf + }, + [PocketTtsCrispAsr.ItalianQ8_0] = new[] + { + "73c36a7e00779fb45b92ab90f613ffe040ee418408990958e69103e6a5820b98", // pocket-tts-italian-q8_0.gguf + }, + [PocketTtsCrispAsr.PortugueseQ8_0] = new[] + { + "02f28b603e1e3b2a9c6bf51c93688b151203f387f50f09e89fb02329ab787e81", // pocket-tts-portuguese-q8_0.gguf + }, + [PocketTtsCrispAsr.FrenchQ8_0] = new[] + { + "9fe5ca8c8797c03b83ce081124037cfa9970fc5db92972b57def6ef5f3a31cff", // pocket-tts-french_24l-q8_0.gguf + }, + + // dots.tts SOAR weights, from cstr/dots-tts-soar-GGUF. + [DotsTtsCrispAsr.CoreQ4K] = new[] + { + "9f6bb49d456df154083fa21244657e7a7aa1c775d94bb24edec6977e68b7cb95", // dots-tts-soar-q4_k.gguf + }, + [DotsTtsCrispAsr.CoreQ8_0] = new[] + { + "d13578498e54c77fde118ce2541903e4c38f595ed1aea26b55d89f87dfb11bba", // dots-tts-soar-q8_0.gguf + }, + [DotsTtsCrispAsr.CoreF16] = new[] + { + "9811ab16ac4ecc6cb42bdd9e031cbade42b1eb50c6ff920bd1f1e9621fb44988", // dots-tts-soar-f16.gguf + }, + [DotsTtsCrispAsr.Vocoder] = new[] + { + "10e9990e126165d9eac1a6e90178296b46ae039230d2d18d650336ad627ccec7", // dots-tts-soar-vocoder-f16.gguf + }, + [DotsTtsCrispAsr.SpeakerEncoder] = new[] + { + "6f6d41f1394273b0da5a7a9be8f15f0e59d3b4d5983cab7d1fac08630b912e7f", // dots-tts-soar-spk-f16.gguf + }, + + // Confucius4-TTS weights, from cstr/confucius4-tts-GGUF. Q8_0 pair, vocoder and w2v + // confirmed by local sha256sum of the downloaded files; T2S/S2A F16 are the HF LFS oids. + [Confucius4TtsCrispAsr.T2sQ8_0] = new[] + { + "c1ed6026f8959d6cbc639ae97ef450ed7b1399159af753e69e54c6d7aac5e9e0", // confucius4-tts-t2s-q8_0.gguf + }, + [Confucius4TtsCrispAsr.T2sF16] = new[] + { + "b7dc5fdad32d90f5e303da226b69aa04eafe5331e93be6a0334e47e4ea534691", // confucius4-tts-t2s-f16.gguf + }, + [Confucius4TtsCrispAsr.S2aQ8_0] = new[] + { + "55be5d58d1b7443af01ec7b3b96f347c3978728215346bd162753c36885ca7d3", // confucius4-tts-s2a-q8_0.gguf + }, + [Confucius4TtsCrispAsr.S2aF16] = new[] + { + "ea259809ebafdff7bf7fd3955b68e0d0722a0ad792b8428d48fbfbdd1e361d0d", // confucius4-tts-s2a-f16.gguf + }, + [Confucius4TtsCrispAsr.Vocoder] = new[] + { + "353d37d41f01152796e3a3b21d3740b0e38d7b0ba486a8df82744028d69dae69", // confucius4-tts-bigvgan-22k-f16.gguf + }, + [Confucius4TtsCrispAsr.W2v] = new[] + { + "02ea34b6a0bc1f027f9c4325186368ddac8d51ded3dc6a90f565dce90a26c0ac", // confucius4-tts-w2v-f16.gguf + }, // IndexTTS-2.5 weights, from audio-cpp/audio.cpp-gguf. Q8_0 confirmed by local // sha256sum of the downloaded file; F16 is the HF LFS oid. @@ -1681,41 +1943,68 @@ public static class WhisperCpp "87bed9b82fc8f22119a1a1042332091016c28e37f29b0e93343ccdbfa76ef66a", // index-tts2_5-f16.gguf }, + // Higgs Audio v3 TTS 4B weights, from audio-cpp/audio.cpp-gguf (HF LFS oid). + [HiggsTtsAudioCpp.ModelQ8_0] = new[] + { + "79746822045b5bf8f9ab2bda87b16cd3f8ea3d9e319cbcf887a87aa1b537a74a", // higgs-audio-v3-tts-4b-q8_0.gguf + }, + [HiggsTtsAudioCpp.ModelBf16] = new[] + { + "2c04410d9ae6af9a48ce989c1b6d7dfaa0320c8e09ff7da8d3e04b8d9b0669dc", // higgs-audio-v3-tts-4b-bf16.gguf + }, + + // Fish Audio S2 Pro weights, from audio-cpp/audio.cpp-gguf (HF LFS oid). + [FishTtsAudioCpp.ModelQ8_0] = new[] + { + "4ffc169447b7a26df8bf49e8637adb4000bfa763a22c018b6c03968564259d0b", // fish-audio-s2-pro-q8_0.gguf + }, + [FishTtsAudioCpp.ModelBf16] = new[] + { + "781fdece3ff837838c48f7d5a7b37e37c4d661a6416416ad57fe92fed47d96ff", // fish-audio-s2-pro-bf16.gguf + }, + // audio.cpp engine archives we build in SubtitleEdit/support-files - // (audiocpp-indextts25-2026-08-18). Newest first — index 0 is the pinned release, + // (audiocpp-indextts25-2026-09-01). Newest first — index 0 is the pinned release, // so anything older prompts an update instead of being treated as current. [IndexTts25AudioCpp.EngineMacArm64] = new[] { + "b9fe7380a54f4c3021061448dbb2eefc2d6248cd82f4bad7eae4201571d4070d", // audiocpp-indextts25-macos-arm64.tar.gz (2026-09-01, upstream v0.7.1 + higgs_audio_tts + fish_audio) "9b8d3e5be13e97a80964356be090c684f555ea41bc02583fc4ea5d82d1a42cdc", // audiocpp-indextts25-macos-arm64.tar.gz (2026-08-18) "7c23622c3d7d75efe3cec61fff542aab0de5c37a8bdef2be23c55b83cfba967a", // audiocpp-indextts25-macos-arm64.tar.gz (2026-08-16-r4) }, [IndexTts25AudioCpp.EngineWindowsCpu] = new[] { + "f4bbf0a5fa5e263abf05aa1e2597b9f4e36055ca1d0a6243a959734f912e9d15", // audiocpp-indextts25-windows-x86_64-cpu.zip (2026-09-01, upstream v0.7.1 + higgs_audio_tts + fish_audio) "7ab8239cd64b87a0dbf072dbbd252361018e21697369c2a6ecbb9df9887d9381", // audiocpp-indextts25-windows-x86_64-cpu.zip (2026-08-18) "6b843f9cb5ae8e35f5928a4b49ba95419b3083c6c478e0a5006efe9679f25a72", // audiocpp-indextts25-windows-x86_64-cpu.zip (2026-08-16-r4) }, [IndexTts25AudioCpp.EngineWindowsVulkan] = new[] { + "e67342b0b6953f268030da3c0d382498897e72dff8de729aab3680e7ed2dbd0d", // audiocpp-indextts25-windows-x86_64-vulkan.zip (2026-09-01, upstream v0.7.1 + higgs_audio_tts + fish_audio) "f8a265995037c143db13255d4035fe2381e5226546d1c2e9aefc03fdf32f5204", // audiocpp-indextts25-windows-x86_64-vulkan.zip (2026-08-18) "25452a065fa525831436d9934d0e4196d1078f780ff32ac5cd74edb0112d556e", // audiocpp-indextts25-windows-x86_64-vulkan.zip (2026-08-16-r4) }, [IndexTts25AudioCpp.EngineWindowsCuda] = new[] { + "408f40079277e48974389ce2bbe941b8a2a93f799ee07fdee37642b978208f41", // audiocpp-indextts25-windows-x86_64-cuda.zip (2026-09-01, upstream v0.7.1 + higgs_audio_tts + fish_audio) "8b18e790a2a60503af58ee427c86bf388124daac2a6a9aa48ba21eb587548fca", // audiocpp-indextts25-windows-x86_64-cuda.zip (2026-08-18) "af3c6c105ae08951d5cf48ffb0b4dc622831e2794b58dd682f23aeeef2724374", // audiocpp-indextts25-windows-x86_64-cuda.zip (2026-08-16-r4) }, [IndexTts25AudioCpp.EngineLinuxCpu] = new[] { + "29838ffe5b96ee3850e8c6ea9ed14d964b4144e2fa45a09552b39f404fd8682d", // audiocpp-indextts25-linux-x86_64.tar.gz (2026-09-01, upstream v0.7.1 + higgs_audio_tts + fish_audio) "3e0a4ed8eb5eadfbebf6f3b6fb7060326bd3971cc541abe7a2862efeb4f736d1", // audiocpp-indextts25-linux-x86_64.tar.gz (2026-08-18) "c9a061f7f0682787ca0b4d0a12f882a027d0db29cb3acbf1dbc9ff0a423ce78a", // audiocpp-indextts25-linux-x86_64.tar.gz (2026-08-16-r4) }, [IndexTts25AudioCpp.EngineLinuxVulkan] = new[] { + "c7af9df012bf551e35420f678b85caac2c0a358d050d7ab69b2fbd7e1641cfbc", // audiocpp-indextts25-linux-x86_64-vulkan.tar.gz (2026-09-01, upstream v0.7.1 + higgs_audio_tts + fish_audio) "cf85c85859777082f8c789d177e04316eead0bd25a729badaf0a4139a472f47e", // audiocpp-indextts25-linux-x86_64-vulkan.tar.gz (2026-08-18) "742263d8173030a3e6cf4603eb255791d73ea1c4a7b30c5886402a48ef49ed8d", // audiocpp-indextts25-linux-x86_64-vulkan.tar.gz (2026-08-16-r4) }, [IndexTts25AudioCpp.EngineLinuxCuda] = new[] { + "ad0392bd6ab8d95c9114def0629193650cd21530036a66af39b7f6b8845afc07", // audiocpp-indextts25-linux-x86_64-cuda.tar.gz (2026-09-01, upstream v0.7.1 + higgs_audio_tts + fish_audio) "03f686900535ce27d15b1377a1009f279d306ce8515fb2515c56aa50906888aa", // audiocpp-indextts25-linux-x86_64-cuda.tar.gz (2026-08-18) "4d5839e622c532d8bee9f7a9dcde0d40697009fa0698d27ea4142cea19cf0bcf", // audiocpp-indextts25-linux-x86_64-cuda.tar.gz (2026-08-16-r4) }, @@ -1985,6 +2274,54 @@ public static class WhisperCpp "33bad46e07f0d9bb64fc03ca8c7047b212482c1f45536290f80bb0fe716c9775", // whispercpp-185 / v1.8.5 (missing shared libraries) "315f46514fc09a4fefe2f7b6ae95cfac5441a03b8be04eed1b5f567d5ccc38de", // whispercpp-184 / v1.8.4 (missing shared libraries) }, + + // Purfview Faster-Whisper-XXL — https://github.com/Purfview/whisper-standalone-win/releases + // Everything upstream publishes lives under the single rolling "Faster-Whisper-XXL" tag, + // so index 0 is the archive whose file name WhisperDownloadService.cs is pinned to. + [PurfviewFasterWhisperXxl.Windows] = new[] + { + "237dee23939cdabfc96ef859fc5e584b842c3a5557e0d2ca744e1f87c14c5844", // r245.4 (current download URL) + }, + [PurfviewFasterWhisperXxl.Linux] = new[] + { + "510ee48ed73a7d4779fa8a7531437513ae109a76d934e983cbdaea3fc248c4f4", // r245.4 (current download URL) + }, + + // whisper-ctranslate2 — SE-repackaged builds under + // https://github.com/SubtitleEdit/support-files/releases + [WhisperCTranslate2.Windows] = new[] + { + "a076c16b184ee1a8b8c87e7765db77d345a30c504a8e98c77b2cf5b069562ccb", // whispercpp-183 (current download URL) + }, + [WhisperCTranslate2.MacArm64] = new[] + { + "f1c67d47be9216e9998df53d32a59fdaa0310f3e576fa4d9135aa1d579a71f86", // whispercpp-183 (current download URL) + }, + [WhisperCTranslate2.LinuxX64] = new[] + { + "02c6c1b738a10b8f72fbd581febbbc4f4e60abe96ad1fab76e1b432e2cce041b", // whispercpp-183 (current download URL) + }, + + // Const-me/Whisper — https://github.com/Const-me/Whisper/releases + [WhisperConstMe.Windows] = new[] + { + "baa9b70c824e50fe91f1858006a24b870b7637135659f17fc42beb1af57bd447", // 1.12.0 (current download URL) + }, + + // WhisperX standalone — SE builds under + // https://github.com/SubtitleEdit/support-files/releases (build-whisperx-standalone-release.yml) + [WhisperX.Windows] = new[] + { + "439776243a3040693e9a2767a3efb4b8dd7549244bb6695ce0ef7209e5456bf3", // whisperx-standalone-101 / v1.0.1 (current download URL) + }, + [WhisperX.MacArm64] = new[] + { + "89ff2f2dd120c8a2ab51c21e6be34a16c954965d4646ecdff77d0911ac6a2c27", // whisperx-standalone-101 / v1.0.1 (current download URL) + }, + [WhisperX.LinuxX64] = new[] + { + "46070b23bfa7c152c259264ac2a135406b4462b2b979ea126510a9c6f44f80e2", // whisperx-standalone-101 / v1.0.1 (current download URL) + }, }; /// @@ -2091,6 +2428,36 @@ public static void WriteSidecar(string installFolder, string? key, Stream archiv } } + /// + /// Writes the .installed.sha256 sidecar for a release archive that was downloaded to a + /// file instead of to memory — Faster-Whisper-XXL is ~1.5 GB, so it is streamed to disk. + /// Same format as the stream overload: hash key on the first line, archive SHA-256 on the + /// second. Best-effort — failures are swallowed since the sidecar only powers update detection. + /// + public static void WriteSidecar(string installFolder, string? key, string archiveFilePath) + { + try + { + if (string.IsNullOrEmpty(key)) + { + return; + } + + var hash = Sha256Util.ComputeSha256(archiveFilePath); + if (string.IsNullOrEmpty(hash)) + { + return; + } + + var sidecar = Path.Combine(installFolder, ".installed.sha256"); + File.WriteAllText(sidecar, key + Environment.NewLine + hash); + } + catch + { + // ignore — hash side-car is best-effort + } + } + /// /// Returns the latest known SHA-256 hash for , or null if the key is unknown. /// @@ -2150,6 +2517,7 @@ public static IReadOnlyList GetKnownHashes(string key) return variant switch { "cuda" => CrispAsr.WindowsCuda, + "cuda13" => CrispAsr.WindowsCuda13, "cpu" => CrispAsr.WindowsCpu, "cpu-legacy" => CrispAsr.WindowsCpuLegacy, "vulkan" => CrispAsr.WindowsVulkan, @@ -2162,7 +2530,7 @@ public static IReadOnlyList GetKnownHashes(string key) /// /// Reverse of — returns the variant string matching the - /// given hash key. For Windows: "cuda" / "vulkan" / "cpu" / "cpu-legacy". For Linux x86_64: + /// given hash key. For Windows: "cuda" / "cuda13" / "vulkan" / "cpu" / "cpu-legacy". For Linux x86_64: /// "cuda" / "cuda13" / "vulkan" / "hip" for the GPU builds, empty string for the default CPU /// build. Returns null otherwise. /// @@ -2171,6 +2539,7 @@ public static IReadOnlyList GetKnownHashes(string key) return key switch { CrispAsr.WindowsCuda or CrispAsr.WindowsCudaExecutable => "cuda", + CrispAsr.WindowsCuda13 or CrispAsr.WindowsCuda13Executable => "cuda13", CrispAsr.WindowsVulkan or CrispAsr.WindowsVulkanExecutable => "vulkan", CrispAsr.WindowsCpu or CrispAsr.WindowsCpuExecutable => "cpu", CrispAsr.WindowsCpuLegacy or CrispAsr.WindowsCpuLegacyExecutable => "cpu-legacy", @@ -2330,6 +2699,7 @@ public static bool IsQwen3AsrCppVulkanInstall(string installFolder) return key switch { CrispAsr.WindowsCuda or CrispAsr.WindowsCudaExecutable => "cuda", + CrispAsr.WindowsCuda13 or CrispAsr.WindowsCuda13Executable => "cuda13", CrispAsr.WindowsVulkan or CrispAsr.WindowsVulkanExecutable => "vulkan", CrispAsr.WindowsCpu or CrispAsr.WindowsCpuExecutable => "cpu", CrispAsr.WindowsCpuLegacy or CrispAsr.WindowsCpuLegacyExecutable => "cpu-legacy", @@ -2371,6 +2741,7 @@ public static bool IsQwen3AsrCppVulkanInstall(string installFolder) return variant switch { "cuda" => CrispAsr.WindowsCudaExecutable, + "cuda13" => CrispAsr.WindowsCuda13Executable, "vulkan" => CrispAsr.WindowsVulkanExecutable, "cpu" => CrispAsr.WindowsCpuExecutable, "cpu-legacy" => CrispAsr.WindowsCpuLegacyExecutable, @@ -2383,9 +2754,10 @@ public static bool IsQwen3AsrCppVulkanInstall(string installFolder) /// /// Detects which Windows CrispASR variant is installed. - /// Returns "cuda" / "vulkan" / "cpu" / "cpu-legacy", or null if the folder doesn't look like a - /// CrispASR install. CUDA and Vulkan are identified by their backend DLLs; CPU vs CPU-legacy - /// is decided by sidecar or executable-hash match (CPU-legacy is the AVX2-less fallback). + /// Returns "cuda" / "cuda13" / "vulkan" / "cpu" / "cpu-legacy", or null if the folder doesn't + /// look like a CrispASR install. CUDA and Vulkan are identified by their backend DLLs; the + /// CUDA major version and CPU vs CPU-legacy are decided by sidecar or executable-hash match + /// (CPU-legacy is the AVX2-less fallback). /// public static string? DetectCrispAsrWindowsVariant(string installFolder) { @@ -2396,7 +2768,26 @@ public static bool IsQwen3AsrCppVulkanInstall(string installFolder) if (File.Exists(Path.Combine(installFolder, "ggml-cuda.dll"))) { - return "cuda"; + // Upstream added a CUDA 13 build beside the CUDA 12 one in v0.8.31 and both ship the + // same ggml-cuda.dll name, so returning "cuda" for either would re-download the CUDA + // 12 archive over a CUDA 13 install (TtsVoiceInstaller re-downloads "the variant the + // user originally picked"). The sidecar written at install time is the direct answer; + // an install made before CUDA 13 existed has none, and is told apart by the cudart + // redistributable bundled alongside - the same discriminator + // DetectLlamaCppWindowsVariant uses. RemoveStaleCrispAsrBinaries wipes the binaries + // before every unpack, so no stale _13 runtime can be left behind by an earlier build. + var cudaSidecarKey = TryReadInstalledKey(installFolder); + if (cudaSidecarKey == CrispAsr.WindowsCuda13) + { + return "cuda13"; + } + + if (cudaSidecarKey == CrispAsr.WindowsCuda) + { + return "cuda"; + } + + return File.Exists(Path.Combine(installFolder, "cudart64_13.dll")) ? "cuda13" : "cuda"; } if (File.Exists(Path.Combine(installFolder, "ggml-vulkan.dll"))) @@ -2604,6 +2995,85 @@ public static bool IsQwen3AsrCppVulkanInstall(string installFolder) return null; } + /// + /// Resolves the Purfview Faster-Whisper-XXL archive hash key for the current OS. Mirrors the + /// platform rules in : Windows x64 and Linux x64 only. + /// Returns null elsewhere (macOS and Linux ARM64 have no build to download). + /// + public static string? ResolvePurfviewFasterWhisperXxlKey() + { + if (OperatingSystem.IsWindows()) + { + return PurfviewFasterWhisperXxl.Windows; + } + + if (OperatingSystem.IsLinux() && RuntimeInformation.ProcessArchitecture != Architecture.Arm64) + { + return PurfviewFasterWhisperXxl.Linux; + } + + return null; + } + + /// + /// Resolves the whisper-ctranslate2 archive hash key for the current OS and architecture. + /// Mirrors the platform rules in : Windows, macOS ARM64 + /// and Linux x64 are the only combinations with a download. + /// + public static string? ResolveWhisperCTranslate2Key() + { + if (OperatingSystem.IsWindows()) + { + return WhisperCTranslate2.Windows; + } + + if (OperatingSystem.IsMacOS() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64) + { + return WhisperCTranslate2.MacArm64; + } + + if (OperatingSystem.IsLinux() && RuntimeInformation.ProcessArchitecture == Architecture.X64) + { + return WhisperCTranslate2.LinuxX64; + } + + return null; + } + + /// + /// Resolves the Const-me Whisper archive hash key. Windows-only engine (it is built on + /// Direct3D 11), so every other platform returns null. + /// + public static string? ResolveWhisperConstMeKey() + { + return OperatingSystem.IsWindows() ? WhisperConstMe.Windows : null; + } + + /// + /// Resolves the WhisperX standalone archive hash key for the current OS and architecture. + /// Mirrors the platform rules in : Windows x64, + /// macOS ARM64 and Linux x64 are the only combinations with a download. + /// + public static string? ResolveWhisperXKey() + { + if (OperatingSystem.IsWindows() && RuntimeInformation.ProcessArchitecture == Architecture.X64) + { + return WhisperX.Windows; + } + + if (OperatingSystem.IsMacOS() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64) + { + return WhisperX.MacArm64; + } + + if (OperatingSystem.IsLinux() && RuntimeInformation.ProcessArchitecture == Architecture.X64) + { + return WhisperX.LinuxX64; + } + + return null; + } + /// /// Resolves the llama.cpp archive hash key for the current OS, architecture and (Windows/Linux) /// variant ("cpu" / "vulkan" / "cuda" / "cuda13"). Returns null when the combination is unknown. diff --git a/src/ui/Logic/Download/DownloadHelper.cs b/src/ui/Logic/Download/DownloadHelper.cs index 70e37f97245..0bf75ba5651 100644 --- a/src/ui/Logic/Download/DownloadHelper.cs +++ b/src/ui/Logic/Download/DownloadHelper.cs @@ -93,6 +93,19 @@ public static async Task DownloadFileAsync( throw new FileNotFoundException($"The requested URL was not found: {url}"); } + // A Range request must be answered with 206. Proxies and CDNs that advertise + // range support but ignore the header answer 200 with the whole body, which was + // appended after the partial bytes - silently corrupting the file whenever the + // response was chunked and the length check below could not catch it. + if (request.Headers.Range != null && + response.StatusCode != System.Net.HttpStatusCode.PartialContent && + destination.CanSeek) + { + destination.Seek(startPosition, SeekOrigin.Begin); + destination.SetLength(startPosition); + currentPosition = startPosition; + } + response.EnsureSuccessStatusCode(); // Get total size from headers @@ -154,10 +167,13 @@ ex is TaskCanceledException || { lastException = ex; - // If cancellation was requested by user, don't retry + // If cancellation was requested by user, don't retry - and surface it as a + // cancellation. Falling through to the "failed after N attempts" throw below + // meant every caller's catch (OperationCanceledException) was skipped and Cancel + // was reported to the user as a download error. if (cancellationToken.IsCancellationRequested) { - break; + throw new OperationCanceledException(cancellationToken); } // If this was the last retry, don't wait diff --git a/src/ui/Logic/Download/FishTtsAudioCppDownloadService.cs b/src/ui/Logic/Download/FishTtsAudioCppDownloadService.cs new file mode 100644 index 00000000000..85fa2548315 --- /dev/null +++ b/src/ui/Logic/Download/FishTtsAudioCppDownloadService.cs @@ -0,0 +1,122 @@ +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; +using Nikse.SubtitleEdit.UiLogic; +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.Logic.Download; + +public interface IFishTtsAudioCppDownloadService +{ + /// Downloads the selected Fish Audio S2 Pro GGUF into audio.cpp's models folder. + Task DownloadModels(string modelKey, IProgress? progress, Action? titleProgress, CancellationToken cancellationToken); +} + +/// +/// Model weights only: the audio.cpp engine binaries are shared with the other audio.cpp +/// engines and downloaded through . The GGUF +/// comes straight from HuggingFace and is never redistributed by us — the weights carry the +/// Fish Audio Research License, which the user accepts in the model-licence window first (see +/// ). +/// +public class FishTtsAudioCppDownloadService : IFishTtsAudioCppDownloadService +{ + private readonly HttpClient _httpClient; + + private const string ModelUrlBase = + "https://huggingface.co/audio-cpp/audio.cpp-gguf/resolve/main/Fish-Audio-S2-Pro-GGUF/"; + + public FishTtsAudioCppDownloadService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task DownloadModels(string modelKey, IProgress? progress, Action? titleProgress, CancellationToken cancellationToken) + { + var fileName = FishTtsAudioCpp.GetModelFileName(modelKey); + var finalPath = FishTtsAudioCpp.GetModelPath(modelKey); + + // A partial GGUF is worse than none: audio.cpp rejects it with "GGUF tensor data range + // is out of bounds", which reads like a corrupt model rather than a short download. + // Size-check on the threadpool — this runs from the download dialog's init callback. + await Task.Run(() => EnsureRemovedIfInvalid(finalPath, fileName), cancellationToken); + + if (FishTtsAudioCpp.IsValidLocalModelFile(finalPath, fileName)) + { + return; + } + + titleProgress?.Invoke($"Downloading Fish Audio S2 Pro model: {fileName}"); + + var partPath = finalPath + ".part"; + try + { + await DownloadHelper.DownloadFileAsync(_httpClient, ModelUrlBase + fileName, partPath, progress, cancellationToken); + await VerifyFile(partPath, GetHashKey(fileName), fileName, cancellationToken); + File.Move(partPath, finalPath); + } + catch + { + TryDelete(partPath); + throw; + } + } + + private static string? GetHashKey(string fileName) + { + if (string.Equals(fileName, FishTtsAudioCpp.ModelQ8_0FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.FishTtsAudioCpp.ModelQ8_0; + } + + if (string.Equals(fileName, FishTtsAudioCpp.ModelBf16FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.FishTtsAudioCpp.ModelBf16; + } + + return null; + } + + private static async Task VerifyFile(string filePath, string? key, string fileName, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(key)) + { + return; + } + + var expected = DownloadHashManager.GetLatestKnownHash(key); + if (string.IsNullOrEmpty(expected)) + { + return; + } + + string actual; + await using (var stream = File.OpenRead(filePath)) + { + actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken); + } + + if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException( + $"Fish Audio S2 Pro model {fileName} failed integrity check (expected SHA-256 {expected}, got {actual})."); + } + } + + private static void TryDelete(string path) + { + try { File.Delete(path); } catch { /* best-effort cleanup */ } + } + + private static void EnsureRemovedIfInvalid(string path, string fileName) + { + if (!File.Exists(path) || FishTtsAudioCpp.IsValidLocalModelFile(path, fileName)) + { + return; + } + + TryDelete(path); + } +} diff --git a/src/ui/Logic/Download/HiggsTtsAudioCppDownloadService.cs b/src/ui/Logic/Download/HiggsTtsAudioCppDownloadService.cs new file mode 100644 index 00000000000..2c67190556c --- /dev/null +++ b/src/ui/Logic/Download/HiggsTtsAudioCppDownloadService.cs @@ -0,0 +1,122 @@ +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; +using Nikse.SubtitleEdit.UiLogic; +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.Logic.Download; + +public interface IHiggsTtsAudioCppDownloadService +{ + /// Downloads the selected Higgs Audio v3 GGUF into audio.cpp's models folder. + Task DownloadModels(string modelKey, IProgress? progress, Action? titleProgress, CancellationToken cancellationToken); +} + +/// +/// Model weights only: the audio.cpp engine binaries are shared with the other audio.cpp +/// engines and downloaded through . The GGUF +/// comes straight from HuggingFace and is never redistributed by us — the weights carry Boson +/// AI's research and non-commercial licence, which the user accepts in the model-licence +/// window first (see ). +/// +public class HiggsTtsAudioCppDownloadService : IHiggsTtsAudioCppDownloadService +{ + private readonly HttpClient _httpClient; + + private const string ModelUrlBase = + "https://huggingface.co/audio-cpp/audio.cpp-gguf/resolve/main/Higgs-Audio-v3-TTS-4B-GGUF/"; + + public HiggsTtsAudioCppDownloadService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task DownloadModels(string modelKey, IProgress? progress, Action? titleProgress, CancellationToken cancellationToken) + { + var fileName = HiggsTtsAudioCpp.GetModelFileName(modelKey); + var finalPath = HiggsTtsAudioCpp.GetModelPath(modelKey); + + // A partial GGUF is worse than none: audio.cpp rejects it with "GGUF tensor data range + // is out of bounds", which reads like a corrupt model rather than a short download. + // Size-check on the threadpool — this runs from the download dialog's init callback. + await Task.Run(() => EnsureRemovedIfInvalid(finalPath, fileName), cancellationToken); + + if (HiggsTtsAudioCpp.IsValidLocalModelFile(finalPath, fileName)) + { + return; + } + + titleProgress?.Invoke($"Downloading Higgs Audio v3 model: {fileName}"); + + var partPath = finalPath + ".part"; + try + { + await DownloadHelper.DownloadFileAsync(_httpClient, ModelUrlBase + fileName, partPath, progress, cancellationToken); + await VerifyFile(partPath, GetHashKey(fileName), fileName, cancellationToken); + File.Move(partPath, finalPath); + } + catch + { + TryDelete(partPath); + throw; + } + } + + private static string? GetHashKey(string fileName) + { + if (string.Equals(fileName, HiggsTtsAudioCpp.ModelQ8_0FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.HiggsTtsAudioCpp.ModelQ8_0; + } + + if (string.Equals(fileName, HiggsTtsAudioCpp.ModelBf16FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.HiggsTtsAudioCpp.ModelBf16; + } + + return null; + } + + private static async Task VerifyFile(string filePath, string? key, string fileName, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(key)) + { + return; + } + + var expected = DownloadHashManager.GetLatestKnownHash(key); + if (string.IsNullOrEmpty(expected)) + { + return; + } + + string actual; + await using (var stream = File.OpenRead(filePath)) + { + actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken); + } + + if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException( + $"Higgs Audio v3 model {fileName} failed integrity check (expected SHA-256 {expected}, got {actual})."); + } + } + + private static void TryDelete(string path) + { + try { File.Delete(path); } catch { /* best-effort cleanup */ } + } + + private static void EnsureRemovedIfInvalid(string path, string fileName) + { + if (!File.Exists(path) || HiggsTtsAudioCpp.IsValidLocalModelFile(path, fileName)) + { + return; + } + + TryDelete(path); + } +} diff --git a/src/ui/Logic/Download/IndexTts25AudioCppDownloadService.cs b/src/ui/Logic/Download/IndexTts25AudioCppDownloadService.cs index 161312ba3e6..82fcffb0aac 100644 --- a/src/ui/Logic/Download/IndexTts25AudioCppDownloadService.cs +++ b/src/ui/Logic/Download/IndexTts25AudioCppDownloadService.cs @@ -33,7 +33,9 @@ public class IndexTts25AudioCppDownloadService : IIndexTts25AudioCppDownloadServ { private readonly HttpClient _httpClient; - private const string ReleaseTag = "audiocpp-indextts25-2026-08-18"; + // 2026-09-01: upstream v0.7.1, now compiled with the index_tts2 + higgs_audio_tts + + // fish_audio families — the same archives back all three audio.cpp engines. + private const string ReleaseTag = "audiocpp-indextts25-2026-09-01"; private const string ReleaseBase = "https://github.com/SubtitleEdit/support-files/releases/download/" + ReleaseTag + "/"; diff --git a/src/ui/Logic/Download/LlamaCppDownloadService.cs b/src/ui/Logic/Download/LlamaCppDownloadService.cs index 2d2cf350692..3705543c53a 100644 --- a/src/ui/Logic/Download/LlamaCppDownloadService.cs +++ b/src/ui/Logic/Download/LlamaCppDownloadService.cs @@ -21,7 +21,7 @@ public class LlamaCppDownloadService(HttpClient httpClient) : ILlamaCppDownloadS /// The pinned llama.cpp release tag. Public so the engine settings dialog can show which build /// SE downloads; index 0 of every hash list must match it. /// - public const string ReleaseTag = "b10507"; + public const string ReleaseTag = "b10760"; private const string BaseUrl = "https://github.com/ggml-org/llama.cpp/releases/download/" + ReleaseTag + "/"; diff --git a/src/ui/Logic/Download/PocketTtsCrispAsrDownloadService.cs b/src/ui/Logic/Download/PocketTtsCrispAsrDownloadService.cs new file mode 100644 index 00000000000..1f3a725e991 --- /dev/null +++ b/src/ui/Logic/Download/PocketTtsCrispAsrDownloadService.cs @@ -0,0 +1,175 @@ +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Nikse.SubtitleEdit.UiLogic; + +namespace Nikse.SubtitleEdit.Logic.Download; + +public interface IPocketTtsCrispAsrDownloadService +{ + Task DownloadModels(string modelsFolder, string modelKey, IProgress? progress, Action? titleProgress, CancellationToken cancellationToken); +} + +/// +/// Downloads the Pocket TTS (CrispASR) checkpoint for the user-picked language into SE's +/// CrispASR/models folder so the user gets a progress dialog instead of crispasr's silent +/// --auto-download on first synth. One GGUF per language, no companion. Same shape as +/// : ModelUrls maps every filename to its HF +/// URL so the .part / size-check / hash-verify path doesn't care which language was picked. +/// Note the GGUF mirror (cstr/pocket-tts-GGUF) is public and ungated — the gated repo is +/// Kyutai's own upstream, which SE never fetches from. +/// +public class PocketTtsCrispAsrDownloadService : IPocketTtsCrispAsrDownloadService +{ + private readonly HttpClient _httpClient; + + private static readonly Dictionary ModelUrls = new(StringComparer.OrdinalIgnoreCase) + { + [PocketTtsCrispAsr.EnglishF16FileName] = + "https://huggingface.co/cstr/pocket-tts-GGUF/resolve/main/pocket-tts-english-f16.gguf", + [PocketTtsCrispAsr.EnglishQ8_0FileName] = + "https://huggingface.co/cstr/pocket-tts-GGUF/resolve/main/pocket-tts-english-q8_0.gguf", + [PocketTtsCrispAsr.GermanQ8_0FileName] = + "https://huggingface.co/cstr/pocket-tts-GGUF/resolve/main/pocket-tts-german-q8_0.gguf", + [PocketTtsCrispAsr.SpanishQ8_0FileName] = + "https://huggingface.co/cstr/pocket-tts-GGUF/resolve/main/pocket-tts-spanish-q8_0.gguf", + [PocketTtsCrispAsr.ItalianQ8_0FileName] = + "https://huggingface.co/cstr/pocket-tts-GGUF/resolve/main/pocket-tts-italian-q8_0.gguf", + [PocketTtsCrispAsr.PortugueseQ8_0FileName] = + "https://huggingface.co/cstr/pocket-tts-GGUF/resolve/main/pocket-tts-portuguese-q8_0.gguf", + [PocketTtsCrispAsr.FrenchQ8_0FileName] = + "https://huggingface.co/cstr/pocket-tts-GGUF/resolve/main/pocket-tts-french_24l-q8_0.gguf", + }; + + public PocketTtsCrispAsrDownloadService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task DownloadModels(string modelsFolder, string modelKey, IProgress? progress, Action? titleProgress, CancellationToken cancellationToken) + { + var modelFileName = PocketTtsCrispAsr.GetModelFileName(modelKey); + var modelPath = PocketTtsCrispAsr.GetModelPath(modelKey); + + // Cache seeding does a synchronous File.Copy of up to ~365 MB; the caller passes us + // the resulting Task without awaiting (DownloadTtsViewModel polls it from a timer), + // so anything before the first real await runs on the caller's thread (the UI + // thread when invoked from the download dialog's init callback). Push the seeding + // and size-check work onto the threadpool so the dialog stays responsive. + await Task.Run(() => + { + PocketTtsCrispAsr.TrySeedModelFromCrispAsrCache(modelFileName, modelPath); + EnsureRemovedIfInvalid(modelPath, modelFileName); + }, cancellationToken); + + if (PocketTtsCrispAsr.IsValidLocalModelFile(modelPath, modelFileName)) + { + return; + } + + titleProgress?.Invoke($"Downloading Pocket TTS (CrispASR) model: {modelFileName}"); + await DownloadAndVerify(modelPath, modelFileName, progress, cancellationToken); + } + + private async Task DownloadAndVerify(string finalPath, string fileName, IProgress? progress, CancellationToken cancellationToken) + { + var partPath = finalPath + ".part"; + try + { + await DownloadHelper.DownloadFileAsync(_httpClient, GetUrl(fileName), partPath, progress, cancellationToken); + await VerifyFile(partPath, GetHashKey(fileName), fileName, cancellationToken); + File.Move(partPath, finalPath); + } + catch + { + TryDelete(partPath); + throw; + } + } + + private static string? GetHashKey(string fileName) + { + if (string.Equals(fileName, PocketTtsCrispAsr.EnglishF16FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.PocketTtsCrispAsr.EnglishF16; + } + if (string.Equals(fileName, PocketTtsCrispAsr.EnglishQ8_0FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.PocketTtsCrispAsr.EnglishQ8_0; + } + if (string.Equals(fileName, PocketTtsCrispAsr.GermanQ8_0FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.PocketTtsCrispAsr.GermanQ8_0; + } + if (string.Equals(fileName, PocketTtsCrispAsr.SpanishQ8_0FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.PocketTtsCrispAsr.SpanishQ8_0; + } + if (string.Equals(fileName, PocketTtsCrispAsr.ItalianQ8_0FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.PocketTtsCrispAsr.ItalianQ8_0; + } + if (string.Equals(fileName, PocketTtsCrispAsr.PortugueseQ8_0FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.PocketTtsCrispAsr.PortugueseQ8_0; + } + if (string.Equals(fileName, PocketTtsCrispAsr.FrenchQ8_0FileName, StringComparison.OrdinalIgnoreCase)) + { + return DownloadHashManager.PocketTtsCrispAsr.FrenchQ8_0; + } + return null; + } + + private static async Task VerifyFile(string filePath, string? key, string fileName, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(key)) + { + return; + } + + var expected = DownloadHashManager.GetLatestKnownHash(key); + if (string.IsNullOrEmpty(expected)) + { + return; + } + + string actual; + await using (var stream = File.OpenRead(filePath)) + { + actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken); + } + + if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException( + $"Pocket TTS (CrispASR) model {fileName} failed integrity check (expected SHA-256 {expected}, got {actual})."); + } + } + + private static string GetUrl(string fileName) + { + if (!ModelUrls.TryGetValue(fileName, out var url)) + { + throw new ArgumentException($"Unknown Pocket TTS (CrispASR) model: {fileName}", nameof(fileName)); + } + return url; + } + + private static void TryDelete(string path) + { + try { File.Delete(path); } catch { /* best-effort cleanup */ } + } + + private static void EnsureRemovedIfInvalid(string path, string fileName) + { + if (!File.Exists(path) || PocketTtsCrispAsr.IsValidLocalModelFile(path, fileName)) + { + return; + } + TryDelete(path); + } +} diff --git a/src/ui/Logic/Download/WhisperDownloadService.cs b/src/ui/Logic/Download/WhisperDownloadService.cs index 0f79f8364a0..6f8b76691c8 100644 --- a/src/ui/Logic/Download/WhisperDownloadService.cs +++ b/src/ui/Logic/Download/WhisperDownloadService.cs @@ -16,6 +16,7 @@ public interface IWhisperDownloadService Task DownloadWhisperPurfviewFasterWhisperXxl(string destinationFileName, IProgress? progress, CancellationToken cancellationToken); Task DownloadWhisperCppVulkan(Stream stream, Progress progress, CancellationToken cancellationToken); Task DownloadWhisperCTranslate2(Stream stream, Progress progress, CancellationToken cancellationToken); + Task DownloadWhisperX(string destinationFileName, IProgress? progress, CancellationToken cancellationToken); Task DownloadSileroVad(Stream stream, IProgress? progress, CancellationToken cancellationToken); } @@ -46,7 +47,14 @@ public class WhisperDownloadService : IWhisperDownloadService private const string MacArmCTranslate2 = "https://github.com/SubtitleEdit/support-files/releases/download/whispercpp-183/whisper-ctranslate2-mac.zip"; private const string LinuxCTranslate2 = "https://github.com/SubtitleEdit/support-files/releases/download/whispercpp-183/whisper-ctranslate2-Linux64.zip"; private const string WindowCTranslate2 = "https://github.com/SubtitleEdit/support-files/releases/download/whispercpp-183/whisper-ctranslate2-win64.zip"; - + + // Built by support-files' build-whisperx-standalone-release.yml from a pinned ref of + // https://github.com/muaz978/subtitleedit-whisperx-standalone (v1.0.1), so every install + // of a given SE version gets the exact same, known-good build. + private const string MacArmWhisperX = "https://github.com/SubtitleEdit/support-files/releases/download/whisperx-standalone-101/whisperx-standalone-macos-arm64.7z"; + private const string LinuxWhisperX = "https://github.com/SubtitleEdit/support-files/releases/download/whisperx-standalone-101/whisperx-standalone-linux-x64.7z"; + private const string WindowsWhisperX = "https://github.com/SubtitleEdit/support-files/releases/download/whisperx-standalone-101/whisperx-standalone-windows-x64.7z"; + public WhisperDownloadService(HttpClient httpClient) { httpClient.DefaultRequestHeaders.UserAgent.ParseAdd( @@ -107,6 +115,14 @@ public async Task DownloadWhisperCTranslate2(Stream stream, Progress pro await DownloadHelper.DownloadFileAsync(_httpClient, GetUrlTranslate2(), stream, progress, cancellationToken); } + public async Task DownloadWhisperX(string destinationFileName, IProgress? progress, CancellationToken cancellationToken) + { + // Downloads straight to a file, like Purfview Faster-Whisper-XXL, instead of the shared + // in-memory _downloadStream - at 216 MB-355 MB, buffering this in memory would peak far + // higher before unpacking even starts (MemoryStream's doubling growth). + await DownloadHelper.DownloadFileAsync(_httpClient, GetUrlWhisperX(), destinationFileName, progress, cancellationToken); + } + public async Task DownloadSileroVad(Stream stream, IProgress? progress, CancellationToken cancellationToken) { await DownloadHelper.DownloadFileAsync(_httpClient, SileroVadUrl, stream, progress, cancellationToken); @@ -118,12 +134,12 @@ private static string GetUrlTranslate2() { return WindowCTranslate2; } - + if (OperatingSystem.IsMacOS() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64) { return MacArmCTranslate2; } - + if (OperatingSystem.IsLinux() && RuntimeInformation.ProcessArchitecture == Architecture.X64) { return LinuxCTranslate2; @@ -132,6 +148,31 @@ private static string GetUrlTranslate2() throw new PlatformNotSupportedException(); } + private static string GetUrlWhisperX() + { + if (OperatingSystem.IsWindows()) + { + if (RuntimeInformation.ProcessArchitecture != Architecture.X64) + { + throw new PlatformNotSupportedException("WhisperX standalone build is not available for Windows ARM64."); + } + + return WindowsWhisperX; + } + + if (OperatingSystem.IsMacOS() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64) + { + return MacArmWhisperX; + } + + if (OperatingSystem.IsLinux() && RuntimeInformation.ProcessArchitecture == Architecture.X64) + { + return LinuxWhisperX; + } + + throw new PlatformNotSupportedException(); + } + private static string GetUrlCppVulkan() { if (OperatingSystem.IsWindows()) diff --git a/src/ui/Logic/Download/YtDlpDownloadService.cs b/src/ui/Logic/Download/YtDlpDownloadService.cs index 132088e94f2..1b3e9c4043e 100644 --- a/src/ui/Logic/Download/YtDlpDownloadService.cs +++ b/src/ui/Logic/Download/YtDlpDownloadService.cs @@ -157,8 +157,34 @@ private static string GetUrl() public async Task DownloadYtDlp(IProgress? progress, CancellationToken cancellationToken) { var fileName = GetFullFileName(); - await DownloadHelper.DownloadFileAsync(_httpClient, GetUrl(), fileName, progress, cancellationToken); - await VerifyChecksumAsync(fileName, CurrentVersion, cancellationToken); + + // Download beside the target and move into place only once the checksum passes. + // Writing straight to the final path left a truncated binary behind when the user + // cancelled - which the "is yt-dlp installed?" check then accepted and ran - and on an + // update it truncated the working binary before the first byte even arrived. + var partFileName = fileName + ".part"; + try + { + await DownloadHelper.DownloadFileAsync(_httpClient, GetUrl(), partFileName, progress, cancellationToken); + await VerifyChecksumAsync(partFileName, CurrentVersion, cancellationToken); + File.Move(partFileName, fileName, true); + } + catch + { + try + { + if (File.Exists(partFileName)) + { + File.Delete(partFileName); + } + } + catch + { + // ignore - nothing useful to do if the partial file cannot be removed + } + + throw; + } } /// diff --git a/src/ui/Logic/DurationsBridgeGaps2.cs b/src/ui/Logic/DurationsBridgeGaps2.cs index b700f3645c5..a996a0b9d4f 100644 --- a/src/ui/Logic/DurationsBridgeGaps2.cs +++ b/src/ui/Logic/DurationsBridgeGaps2.cs @@ -46,11 +46,14 @@ public static int BridgeGaps( var previousExtension = (gapToBridge * percentageForPrevious) / 100.0; var nextReduction = gapToBridge - previousExtension; - // Extend the current subtitle by the calculated amount - cur.EndTime = TimeSpan.FromMilliseconds(cur.EndTime.TotalMilliseconds + previousExtension); + // Round to a whole millisecond at the producer (#14056). previousExtension is + // fractional whenever the gap does not divide evenly - gap 101 ms, min 24, 50% for + // the left gives an end time of X.5 ms, which the grid truncates and the format + // writers then save a millisecond early. + cur.EndTime = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(cur.EndTime.TotalMilliseconds + previousExtension); // Move the next subtitle's start time backward by the calculated amount - next.SetStartTimeOnly(TimeSpan.FromMilliseconds(next.StartTime.TotalMilliseconds - nextReduction)); + next.SetStartTimeOnly(TimeSpanExtensions.FromMillisecondsWholeMilliseconds(next.StartTime.TotalMilliseconds - nextReduction)); if (fixedIndexes != null) { diff --git a/src/ui/Logic/FindService.cs b/src/ui/Logic/FindService.cs index 5c67fae0d60..b1a94a0ff86 100644 --- a/src/ui/Logic/FindService.cs +++ b/src/ui/Logic/FindService.cs @@ -68,6 +68,10 @@ public void Initialize(List textLines, int currentLineNumber, bool whole public int FindNext(string searchText, List textLines, int startLineIndex, int startTextIndex, List? originalTextLines = null, bool startInOriginal = false) { + // Adopt the caller's list BEFORE testing it: the guard used to read the field, i.e. the + // previous search's lines, so a Find opened on an empty subtitle kept answering "not + // found" for every later F3 no matter how many lines had been added since. + _textLines = textLines; if (string.IsNullOrEmpty(searchText) || _textLines.Count == 0) { ResetSearchState(); @@ -75,7 +79,6 @@ public int FindNext(string searchText, List textLines, int startLineInde } SearchText = RegexUtils.EscapeNewLines(searchText); - _textLines = textLines; _originalTextLines = originalTextLines; AddToSearchHistory(searchText); @@ -161,6 +164,8 @@ private int NotFound() public int FindPrevious(string searchText, List textLines, int startLineIndex, int startTextIndex, List? originalTextLines = null, bool startInOriginal = false) { + // Same as FindNext: adopt the caller's list before testing it. + _textLines = textLines; if (string.IsNullOrEmpty(searchText) || _textLines.Count == 0) { ResetSearchState(); @@ -168,7 +173,6 @@ public int FindPrevious(string searchText, List textLines, int startLine } SearchText = RegexUtils.EscapeNewLines(searchText); - _textLines = textLines; _originalTextLines = originalTextLines; AddToSearchHistory(searchText); @@ -594,7 +598,7 @@ private void AddToSearchHistory(string searchText) var beforePart = line.Substring(0, startIndex); var afterPart = line.Substring(startIndex); - var newAfterPart = regex.Replace(afterPart, replaceText, 1); + var newAfterPart = regex.Replace(afterPart, EscapeReplacement(replaceText), 1); var totalReplacements = newAfterPart != afterPart ? 1 : 0; return (totalReplacements > 0, beforePart + newAfterPart, totalReplacements); @@ -603,8 +607,8 @@ private void AddToSearchHistory(string searchText) { // Replace all or limited occurrences var newText = maxReplacements == -1 - ? regex.Replace(line, replaceText) - : regex.Replace(line, replaceText, maxReplacements); + ? regex.Replace(line, EscapeReplacement(replaceText)) + : regex.Replace(line, EscapeReplacement(replaceText), maxReplacements); var totalReplacements = regex.Matches(line).Count; if (maxReplacements != -1 && totalReplacements > maxReplacements) @@ -621,6 +625,17 @@ private void AddToSearchHistory(string searchText) } } + /// + /// Escapes a user-supplied replacement so Regex.Replace inserts it literally. Whole-word + /// replace goes through a regex while plain replace splices the string in, so without this + /// ticking "whole word" silently changed the meaning of the *replacement* field too - "$$" + /// collapsed to "$", and "${x}" threw into a swallowed catch, quietly replacing nothing. + /// + private static string EscapeReplacement(string replaceText) + { + return string.IsNullOrEmpty(replaceText) ? replaceText : replaceText.Replace("$", "$$"); + } + private (bool found, int index, string foundText) FindWithStringComparison(string line, string searchText, StringComparison comparison, int startIndex) { var searchLine = startIndex > 0 ? line.Substring(startIndex) : line; @@ -632,10 +647,14 @@ private void AddToSearchHistory(string searchText) try { - var match = GetCachedRegex(pattern, options).Match(searchLine); + // Match the whole line from an offset rather than a slice of it: slicing + // manufactures a word boundary at position 0, so resuming from a caret inside + // a word matched that word's tail ("cat" inside "Bobcat") - while Count, which + // evaluates the same pattern against the full line, reported no match. + var match = GetCachedRegex(pattern, options).Match(line, Math.Min(startIndex, line.Length)); if (match.Success) { - return (true, startIndex + match.Index, match.Value); + return (true, match.Index, match.Value); } } catch (Exception exception) when (exception is ArgumentException or RegexMatchTimeoutException) @@ -666,11 +685,24 @@ private void AddToSearchHistory(string searchText) try { - var matches = GetCachedRegex(pattern, options).Matches(searchLine); - if (matches.Count > 0) + // Match the full line and take the last hit at or before the caret: truncating + // the line fabricates a trailing word boundary, so a caret after "cat" inside + // "cathedral" matched it as a whole word. + var matches = GetCachedRegex(pattern, options).Matches(line); + Match? lastBefore = null; + foreach (Match m in matches) + { + if (m.Index > startIndex) + { + break; + } + + lastBefore = m; + } + + if (lastBefore != null) { - var lastMatch = matches[matches.Count - 1]; - return (true, lastMatch.Index, lastMatch.Value); + return (true, lastBefore.Index, lastBefore.Value); } } catch (Exception exception) when (exception is ArgumentException or RegexMatchTimeoutException) @@ -694,6 +726,9 @@ private void AddToSearchHistory(string searchText) { try { + // The line is sliced at the caret on purpose: "^" then re-anchors at the resume + // point, which is what lets a "(^|\n).{38,}($|\n)"-style pattern find the second + // line of a paragraph in full (see FindServiceTests.RegexOverlappingNewlineBoundary). var searchLine = startIndex > 0 ? line.Substring(startIndex) : line; var originalLength = searchLine.Length; searchLine = NormalizeLineEndingsForRegex(searchLine, out var indexMap); @@ -716,6 +751,8 @@ private void AddToSearchHistory(string searchText) { try { + // Truncated at the caret on purpose, mirroring FindWithRegex: "$" then anchors at + // the resume point so the search walks backwards line by line. var searchLine = line.Substring(0, Math.Min(startIndex + 1, line.Length)); var originalLength = searchLine.Length; searchLine = NormalizeLineEndingsForRegex(searchLine, out var indexMap); diff --git a/src/ui/Logic/FontFamilyHelper.cs b/src/ui/Logic/FontFamilyHelper.cs new file mode 100644 index 00000000000..e969ef8e8b6 --- /dev/null +++ b/src/ui/Logic/FontFamilyHelper.cs @@ -0,0 +1,28 @@ +using Avalonia.Media; + +namespace Nikse.SubtitleEdit.Logic; + +/// +/// Turns a font name from the settings into a . +/// Kept out of on purpose: the subtitle grid's syntax highlighting converter +/// needs this, and UiUtil's static state pulls in enough of Avalonia to abort a headless test run. +/// +public static class FontFamilyHelper +{ + /// + /// "Default" is a placeholder in the appearance settings, not a font family that exists on any + /// platform - it is what the font drop-downs show for "whatever the system uses". Handing it to + /// asks Avalonia for a family nothing can resolve, and that unresolvable + /// name then travels into its glyph-fallback lookups: on Windows the subtitle grid rendered Arabic + /// as empty boxes while the same text was fine in the text box, waveform and video (issue #14150). + /// Map the placeholder (and an empty name) to the platform default instead. + /// ".AppleSystemUIFont" is deliberately not mapped - on macOS that is the real family name stored + /// when the user picks "System Font", and the platform default there is Helvetica Neue (#12009). + /// + public static FontFamily Make(string? fontName) + { + return string.IsNullOrWhiteSpace(fontName) || fontName == "Default" + ? FontFamily.Default + : new FontFamily(fontName); + } +} diff --git a/src/ui/Logic/ForegroundWindowTracker.cs b/src/ui/Logic/ForegroundWindowTracker.cs new file mode 100644 index 00000000000..f8c9eb16029 --- /dev/null +++ b/src/ui/Logic/ForegroundWindowTracker.cs @@ -0,0 +1,252 @@ +using Nikse.SubtitleEdit.Logic.Config; +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace Nikse.SubtitleEdit.Logic; + +/// +/// Tracks who owned the Windows foreground, so an activation the user aimed at a window can be +/// told apart from one the OS handed over by itself. +/// +/// The undocked foreground-steal watcher (#14168) classifies tool-window activations and hands +/// the foreground back to the main window when the OS picked the tool window on its own. Deciding +/// that from the physical pointer buttons alone misreads every keyboard-aimed switch: Alt+Tab to +/// the undocked waveform flickered into the foreground and lost it 200 ms later (#14354). +/// +/// Two signals are collected, both from WinEvent hooks: +/// +/// * The task switcher. The classic switch window raises EVENT_SYSTEM_SWITCHSTART when it opens +/// and EVENT_SYSTEM_SWITCHEND when it commits, right before the chosen window is activated. +/// Neither reached SE on the reporter's Windows 11, whose switcher is the XAML one the shell +/// hosts (window class MultitaskingViewFrame / XamlExplorerHostIslandWindow) - so that window +/// simply taking the foreground counts as the same signal. +/// +/// * The foreground history. answers what became +/// of the window that held the foreground before the current one. This is the signal that does +/// not depend on the switcher announcing itself in any way: the OS only has to pick a new +/// foreground window when the old one went away, so a previous window that is still there means +/// the user moved on deliberately - Alt+Tab, the taskbar, Alt+Esc, Win+Tab alike - while one +/// that was closed or minimized is the handover the watcher exists to correct. +/// +/// Windows only; on other platforms nothing is hooked, stays +/// false and stays null (unknown), which leaves +/// the caller on its pointer-only rules. The hooks must be installed from a thread that pumps +/// messages (the UI thread) and live for the process lifetime. +/// +public static class ForegroundWindowTracker +{ + private const uint EventSystemForeground = 0x0003; + private const uint EventSystemSwitchStart = 0x0014; + private const uint EventSystemSwitchEnd = 0x0015; + private const uint WinEventOutOfContext = 0x0000; + + // The task switcher's own window, across the shells that have hosted it. The XAML ones take + // the foreground themselves, so they must be kept out of the history as well as recognised. + private static readonly string[] TaskSwitcherWindowClasses = + { + "XamlExplorerHostIslandWindow", // Windows 11 Alt+Tab and Task view + "MultitaskingViewFrame", // Windows 10 Alt+Tab and Task view + "TaskSwitcherWnd", // Windows 7/8 switcher + "#32771", // the classic switch window (AltTabSettings) + }; + + private delegate void WinEventDelegate(IntPtr hWinEventHook, uint eventType, IntPtr hwnd, + int idObject, int idChild, uint dwEventThread, uint dwmsEventTime); + + [DllImport("user32.dll")] + private static extern IntPtr SetWinEventHook(uint eventMin, uint eventMax, + IntPtr hmodWinEventProc, WinEventDelegate pfnWinEventProc, uint idProcess, uint idThread, + uint dwFlags); + + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern bool IsWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool IsIconic(IntPtr hWnd); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount); + + // Keeps the delegate alive for the lifetime of the hooks - the GC must not collect it while + // user32 still holds the callback pointer. + private static WinEventDelegate? _callback; + private static bool _initialized; + private static bool _foregroundHookInstalled; + private static bool _switcherOpen; + private static long _lastSwitchEndTick; + private static long _lastSwitcherForegroundTick; + private static IntPtr _currentForeground; + private static IntPtr _previousForeground; + + /// + /// Installs the hooks once. Call from the UI thread. No-op off Windows; a hook that could not + /// be installed just leaves its signal inert (fail-safe) and is logged, because a silent + /// install failure is otherwise indistinguishable from an event that never fires. + /// + public static void EnsureStarted() + { + if (_initialized || !OperatingSystem.IsWindows()) + { + return; + } + + _initialized = true; + + try + { + _callback = OnWinEvent; + + // The window that holds the foreground right now is the first history entry - + // otherwise the very first switch away from it has no "previous" to judge. + _currentForeground = GetForegroundWindow(); + + var foregroundHook = SetWinEventHook(EventSystemForeground, EventSystemForeground, + IntPtr.Zero, _callback, 0, 0, WinEventOutOfContext); + _foregroundHookInstalled = foregroundHook != IntPtr.Zero; + if (!_foregroundHookInstalled) + { + Se.LogError("SetWinEventHook(EVENT_SYSTEM_FOREGROUND) failed - undocked windows " + + "fall back to pointer-only activation rules"); + } + + if (SetWinEventHook(EventSystemSwitchStart, EventSystemSwitchEnd, IntPtr.Zero, + _callback, 0, 0, WinEventOutOfContext) == IntPtr.Zero) + { + Se.LogError("SetWinEventHook(EVENT_SYSTEM_SWITCHSTART) failed"); + } + } + catch (Exception exception) + { + Se.LogError(exception); + } + } + + private static void OnWinEvent(IntPtr hWinEventHook, uint eventType, IntPtr hwnd, + int idObject, int idChild, uint dwEventThread, uint dwmsEventTime) + { + if (eventType == EventSystemSwitchStart) + { + _switcherOpen = true; + return; + } + + if (eventType == EventSystemSwitchEnd) + { + _switcherOpen = false; + _lastSwitchEndTick = Environment.TickCount64; + return; + } + + if (eventType != EventSystemForeground || hwnd == IntPtr.Zero) + { + return; + } + + if (IsTaskSwitcherWindow(hwnd)) + { + // The switcher taking the foreground IS the Alt+Tab signal on the shells that raise + // no SWITCHSTART. It must not enter the history: it is gone by the time the window + // the user picked is activated, which would read as "the previous window went away". + _lastSwitcherForegroundTick = Environment.TickCount64; + return; + } + + if (hwnd == _currentForeground) + { + return; + } + + _previousForeground = _currentForeground; + _currentForeground = hwnd; + } + + private static bool IsTaskSwitcherWindow(IntPtr hwnd) + { + var className = new StringBuilder(64); + if (GetClassName(hwnd, className, className.Capacity) == 0) + { + return false; + } + + var name = className.ToString(); + foreach (var switcherClass in TaskSwitcherWindowClasses) + { + if (string.Equals(name, switcherClass, StringComparison.Ordinal)) + { + return true; + } + } + + // The XAML host has been renamed once already across shell versions; match the family. + return name.StartsWith("XamlExplorerHost", StringComparison.Ordinal); + } + + /// + /// True while the task switcher is open, or within of it + /// committing a switch. The activation the switcher causes arrives on the message queue right + /// after it closes, so a small window is plenty - it only needs to be generous enough to + /// survive dispatcher latency, not user-scale time. + /// + public static bool TaskSwitchJustCommitted(int withinMs = 1000) + { + var now = Environment.TickCount64; + return _switcherOpen || + (_lastSwitchEndTick > 0 && now - _lastSwitchEndTick <= withinMs) || + (_lastSwitcherForegroundTick > 0 && now - _lastSwitcherForegroundTick <= withinMs); + } + + /// + /// Whether the window that held the foreground before the current one is still there - i.e. + /// the foreground moved because the user moved, not because the OS had to pick somebody after + /// the old window was closed or minimized. + /// + /// Null means "unknown", and the caller must fall back to its other evidence: off Windows, or + /// with no hook, or with nothing recorded yet - and, importantly, whenever the hook has not + /// caught up with the real foreground. The callback is delivered through the same message + /// queue as the activation it describes, so at Activated time the history may still be one + /// window behind; comparing it against GetForegroundWindow() is what makes reading it safe. + /// + /// A previous window that is alive but not visible is unknown too. Neither thing a user does + /// looks like that - closing destroys the window, and minimizing leaves it visible and iconic + /// - so it is some helper or island window that held the foreground briefly, and the switcher + /// classes above are only the ones known by name. Answering "unknown" there leaves the caller + /// on its older rules instead of asserting a handover that may not have happened. + /// + public static bool? PreviousForegroundWindowStillUsable() + { + if (!_foregroundHookInstalled) + { + return null; + } + + var foreground = GetForegroundWindow(); + if (foreground == IntPtr.Zero || foreground != _currentForeground) + { + return null; // the history has not caught up - it would describe the wrong window + } + + if (_previousForeground == IntPtr.Zero) + { + return null; + } + + if (!IsWindow(_previousForeground)) + { + return false; // closed: the OS had to hand the foreground to somebody + } + + if (IsIconic(_previousForeground)) + { + return false; // minimized - IsWindowVisible stays true for those + } + + return IsWindowVisible(_previousForeground) ? true : null; + } +} diff --git a/src/ui/Logic/ISplitManager.cs b/src/ui/Logic/ISplitManager.cs index 1bdbe979fa6..a737501988c 100644 --- a/src/ui/Logic/ISplitManager.cs +++ b/src/ui/Logic/ISplitManager.cs @@ -9,17 +9,40 @@ namespace Nikse.SubtitleEdit.Logic; +/// +/// How to split the editable original text of a row along with its translation (#14434). +/// +/// +/// Caret position in the original text, or -1 to auto-split the original on its own line break +/// (or auto-break). The original never shares the translation's caret: the natural split point +/// differs between the two languages. +/// +/// Two-letter language of the original; dialog-dash and auto-break rules depend on it. +public sealed record OriginalSplit(int TextIndex, string LanguageCode); + public interface ISplitManager { void Split(ObservableCollection subtitles, SubtitleLineViewModel subtitle, string languageCode); void Split(ObservableCollection subtitles, SubtitleLineViewModel subtitle, double videoPositionSeconds, string languageCode); void Split(ObservableCollection subtitles, SubtitleLineViewModel subtitle, double videoPositionSeconds, int textIndex, string languageCode); void Split(ObservableCollection subtitles, SubtitleLineViewModel subtitle, int textIndex, string languageCode); + + /// + /// Splits and, when is given, its + /// too. With null the original is copied + /// whole onto both halves, which is what a read-only reference wants. + /// + void Split(ObservableCollection subtitles, SubtitleLineViewModel subtitle, double videoPositionSeconds, int textIndex, string languageCode, OriginalSplit? original); } public class SplitManager : ISplitManager { public void Split(ObservableCollection subtitles, SubtitleLineViewModel subtitle, double videoPositionSeconds, int textIndex, string languageCode) + { + Split(subtitles, subtitle, videoPositionSeconds, textIndex, languageCode, original: null); + } + + public void Split(ObservableCollection subtitles, SubtitleLineViewModel subtitle, double videoPositionSeconds, int textIndex, string languageCode, OriginalSplit? original) { var idx = subtitles.IndexOf(subtitle); if (idx < 0 || idx >= subtitles.Count) @@ -36,41 +59,146 @@ public void Split(ObservableCollection subtitles, Subtitl && videoPositionSeconds > subtitle.StartTime.TotalSeconds && videoPositionSeconds < subtitle.EndTime.TotalSeconds; - var text = subtitle.Text; + var (firstText, secondText) = SplitText(subtitle.Text, textIndex, languageCode); + subtitle.Text = firstText; + newSubtitle.Text = secondText; + + // An editable original is split by the same rules, at its own caret or on its own line + // break. Before #14434 the copy constructor above left the complete original on both + // halves, so every split desynchronized the two columns. + if (original != null && !string.IsNullOrEmpty(subtitle.OriginalText)) + { + var (firstOriginal, secondOriginal) = SplitText(subtitle.OriginalText, original.TextIndex, original.LanguageCode); + subtitle.OriginalText = firstOriginal; + newSubtitle.OriginalText = secondOriginal; + } + + // Time split — done AFTER the text split so we can weight the divide point + // by the resulting text-length ratio when no user video position was given. + // + // The gap between the two halves is the full MinimumBetweenLines (SE 4 parity). + // Before #11247 follow-up, SE 5 inserted only half the configured gap because + // `gap` was divided by 2 and applied only on the leading edge. + var minGapMs = Se.Settings.General.MinimumBetweenLines.GetMilliseconds(); + var halfGapMs = minGapMs / 2.0; + + double subtitleEndMs; + double newSubtitleStartMs; + + if (hasUserVideoPosition) + { + // User picked a video frame — that frame becomes the start of the new + // line; the old line ends one full MinGap earlier. Mirrors SE 4's + // SplitBehavior == 0 path in SetSplitTime. + var divideMs = videoPositionSeconds * 1000.0; + newSubtitleStartMs = divideMs; + subtitleEndMs = divideMs - minGapMs; + } + else + { + // Auto-split — divide at the midpoint, weighted by the text-length + // ratio of the two halves when they differ by more than a couple of + // characters. Clamp to [0.25, 0.75] so a very lopsided split still + // produces two viable durations. SE 4 Main.cs:12808 SetSplitTime. + var firstTextLen = StripForLengthMeasure(subtitle.Text).Length; + var secondTextLen = StripForLengthMeasure(newSubtitle.Text).Length; + + var startFactor = 0.5; + if (Math.Abs(firstTextLen - secondTextLen) > 2) + { + var total = firstTextLen + secondTextLen; + if (total > 0) + { + startFactor = (double)firstTextLen / total; + if (startFactor < 0.25) + { + startFactor = 0.25; + } + if (startFactor > 0.75) + { + startFactor = 0.75; + } + } + } + + var middleMs = originalStartMs + originalDurationMs * startFactor; + subtitleEndMs = middleMs - halfGapMs; + newSubtitleStartMs = middleMs + halfGapMs; + } + + // Don't let either half collapse to zero / negative duration when MinGap is + // larger than the available headroom on a short subtitle. Each half needs + // strictly positive duration; we give up the configured gap before we give + // up a positive duration on either side. + if (subtitleEndMs < originalStartMs + 1) + { + subtitleEndMs = originalStartMs + 1; + } + if (newSubtitleStartMs > originalEndMs - 1) + { + newSubtitleStartMs = originalEndMs - 1; + } + if (subtitleEndMs > newSubtitleStartMs) + { + // No room left for the requested gap — collapse to the midpoint of the + // available window so both halves still end up with positive duration + // (originalEnd > newSubtitleStart > originalStart, and + // originalStart < subtitleEnd < originalEnd). + var midpoint = (subtitleEndMs + newSubtitleStartMs) / 2.0; + subtitleEndMs = midpoint; + newSubtitleStartMs = midpoint; + } + + subtitle.EndTime = TimeSpan.FromMilliseconds(subtitleEndMs); + newSubtitle.SetStartTimeOnly(TimeSpan.FromMilliseconds(newSubtitleStartMs)); + + subtitles.Insert(idx + 1, newSubtitle); + } + + /// + /// Splits one text into two halves: at when it is inside the + /// text, otherwise on the line break (two lines), a weighted line count (more), or an + /// auto-break (one line). Dialog dashes, continuation style and tag balancing are applied + /// the same way for the translation and the original. + /// + private static (string First, string Second) SplitText(string text, int textIndex, string languageCode) + { + var first = text; + var second = string.Empty; var lines = text.SplitToLines(); - if (textIndex > 0 && textIndex <= subtitle.Text.Length) + if (textIndex > 0 && textIndex <= text.Length) { - subtitle.Text = text.Substring(0, textIndex).Trim(); - newSubtitle.Text = text.Substring(textIndex).Trim(); + first = text.Substring(0, textIndex).Trim(); + second = text.Substring(textIndex).Trim(); if (lines.Count == 2) { var dialogHelper = new DialogSplitMerge { DialogStyle = Configuration.Settings.General.DialogStyle, TwoLetterLanguageCode = languageCode }; if (dialogHelper.IsDialog(lines)) { - subtitle.Text = DialogSplitMerge.RemoveStartDash(subtitle.Text); - newSubtitle.Text = DialogSplitMerge.RemoveStartDash(newSubtitle.Text); + first = DialogSplitMerge.RemoveStartDash(first); + second = DialogSplitMerge.RemoveStartDash(second); } } // SE 4 parity (#11245 follow-up): if either half ends up with a single line // longer than the configured max, auto-break it. Cursor-split at the middle // of a long single-line subtitle would otherwise leave a too-wide line. - subtitle.Text = AutoBreakIfTooLong(subtitle.Text, languageCode); - newSubtitle.Text = AutoBreakIfTooLong(newSubtitle.Text, languageCode); + first = AutoBreakIfTooLong(first, languageCode); + second = AutoBreakIfTooLong(second, languageCode); } else if (lines.Count == 2) { var dialogHelper = new DialogSplitMerge { DialogStyle = Configuration.Settings.General.DialogStyle, TwoLetterLanguageCode = languageCode }; if (dialogHelper.IsDialog(lines)) { - newSubtitle.Text = lines[1].TrimStart(' ', DialogSplitMerge.GetDashChar(), DialogSplitMerge.GetAlternateDashChar()).Trim(); - subtitle.Text = lines[0].TrimStart(' ', DialogSplitMerge.GetDashChar(), DialogSplitMerge.GetAlternateDashChar()).Trim(); + second = lines[1].TrimStart(' ', DialogSplitMerge.GetDashChar(), DialogSplitMerge.GetAlternateDashChar()).Trim(); + first = lines[0].TrimStart(' ', DialogSplitMerge.GetDashChar(), DialogSplitMerge.GetAlternateDashChar()).Trim(); } else { - newSubtitle.Text = lines[1].Trim(); - subtitle.Text = lines[0].Trim(); + second = lines[1].Trim(); + first = lines[0].Trim(); } } else if (lines.Count > 2) @@ -110,21 +238,21 @@ public void Split(ObservableCollection subtitles, Subtitl } } - subtitle.Text = string.Join(Environment.NewLine, lines.GetRange(0, splitIndex)).Trim(); - newSubtitle.Text = string.Join(Environment.NewLine, lines.GetRange(splitIndex, lines.Count - splitIndex)).Trim(); + first = string.Join(Environment.NewLine, lines.GetRange(0, splitIndex)).Trim(); + second = string.Join(Environment.NewLine, lines.GetRange(splitIndex, lines.Count - splitIndex)).Trim(); } else { var brokenLines = Utilities.AutoBreakLine(text, Se.Settings.General.SubtitleLineMaximumLength, 0, languageCode).SplitToLines(); if (brokenLines.Count == 2) { - subtitle.Text = brokenLines[0].Trim(); - newSubtitle.Text = brokenLines[1].Trim(); + first = brokenLines[0].Trim(); + second = brokenLines[1].Trim(); } else { - subtitle.Text = text; - newSubtitle.Text = string.Empty; + first = text; + second = string.Empty; } } @@ -132,103 +260,20 @@ public void Split(ObservableCollection subtitles, Subtitl // style (e.g. "Ellipses (trailing only)") to the halves - a trailing ellipsis on // the first line, and a leading marker on the second for leading styles - instead // of a clean cut. - if (!string.IsNullOrWhiteSpace(subtitle.Text) - && !string.IsNullOrWhiteSpace(newSubtitle.Text) + if (!string.IsNullOrWhiteSpace(first) + && !string.IsNullOrWhiteSpace(second) && Enum.TryParse(Se.Settings.General.ContinuationStyle, out var continuationStyle) && continuationStyle != ContinuationStyle.None) { var continuationProfile = ContinuationUtilities.GetContinuationProfile(continuationStyle); - if (ContinuationUtilities.ShouldAddSuffix(subtitle.Text, continuationProfile)) + if (ContinuationUtilities.ShouldAddSuffix(first, continuationProfile)) { - subtitle.Text = ContinuationUtilities.AddSuffixIfNeeded(subtitle.Text, continuationProfile, false); - newSubtitle.Text = ContinuationUtilities.AddPrefixIfNeeded(newSubtitle.Text, continuationProfile, false); - } - } - - var (s1, s2) = FixTags(subtitle.Text, newSubtitle.Text); - subtitle.Text = s1; - newSubtitle.Text = s2; - - // Time split — done AFTER the text split so we can weight the divide point - // by the resulting text-length ratio when no user video position was given. - // - // The gap between the two halves is the full MinimumBetweenLines (SE 4 parity). - // Before #11247 follow-up, SE 5 inserted only half the configured gap because - // `gap` was divided by 2 and applied only on the leading edge. - var minGapMs = Se.Settings.General.MinimumBetweenLines.GetMilliseconds(); - var halfGapMs = minGapMs / 2.0; - - double subtitleEndMs; - double newSubtitleStartMs; - - if (hasUserVideoPosition) - { - // User picked a video frame — that frame becomes the start of the new - // line; the old line ends one full MinGap earlier. Mirrors SE 4's - // SplitBehavior == 0 path in SetSplitTime. - var divideMs = videoPositionSeconds * 1000.0; - newSubtitleStartMs = divideMs; - subtitleEndMs = divideMs - minGapMs; - } - else - { - // Auto-split — divide at the midpoint, weighted by the text-length - // ratio of the two halves when they differ by more than a couple of - // characters. Clamp to [0.25, 0.75] so a very lopsided split still - // produces two viable durations. SE 4 Main.cs:12808 SetSplitTime. - var firstTextLen = StripForLengthMeasure(subtitle.Text).Length; - var secondTextLen = StripForLengthMeasure(newSubtitle.Text).Length; - - var startFactor = 0.5; - if (Math.Abs(firstTextLen - secondTextLen) > 2) - { - var total = firstTextLen + secondTextLen; - if (total > 0) - { - startFactor = (double)firstTextLen / total; - if (startFactor < 0.25) - { - startFactor = 0.25; - } - if (startFactor > 0.75) - { - startFactor = 0.75; - } - } + first = ContinuationUtilities.AddSuffixIfNeeded(first, continuationProfile, false); + second = ContinuationUtilities.AddPrefixIfNeeded(second, continuationProfile, false); } - - var middleMs = originalStartMs + originalDurationMs * startFactor; - subtitleEndMs = middleMs - halfGapMs; - newSubtitleStartMs = middleMs + halfGapMs; } - // Don't let either half collapse to zero / negative duration when MinGap is - // larger than the available headroom on a short subtitle. Each half needs - // strictly positive duration; we give up the configured gap before we give - // up a positive duration on either side. - if (subtitleEndMs < originalStartMs + 1) - { - subtitleEndMs = originalStartMs + 1; - } - if (newSubtitleStartMs > originalEndMs - 1) - { - newSubtitleStartMs = originalEndMs - 1; - } - if (subtitleEndMs > newSubtitleStartMs) - { - // No room left for the requested gap — collapse to the midpoint of the - // available window so both halves still end up with positive duration - // (originalEnd > newSubtitleStart > originalStart, and - // originalStart < subtitleEnd < originalEnd). - var midpoint = (subtitleEndMs + newSubtitleStartMs) / 2.0; - subtitleEndMs = midpoint; - newSubtitleStartMs = midpoint; - } - - subtitle.EndTime = TimeSpan.FromMilliseconds(subtitleEndMs); - newSubtitle.SetStartTimeOnly(TimeSpan.FromMilliseconds(newSubtitleStartMs)); - - subtitles.Insert(idx + 1, newSubtitle); + return FixTags(first, second); } // Strip HTML/ASSA tags and ALL line-break variants for length measurement. diff --git a/src/ui/Logic/IconNames.cs b/src/ui/Logic/IconNames.cs index ca625614bc1..07169534d79 100644 --- a/src/ui/Logic/IconNames.cs +++ b/src/ui/Logic/IconNames.cs @@ -17,6 +17,7 @@ internal class IconNames public const string ArrowUpDownBold = "mdi-arrow-up-down-bold"; public const string ArrowUpThin = "mdi-arrow-up-thin"; public const string Bold = "mdi-format-bold"; + public const string BookAlphabet = "mdi-book-alphabet"; public const string Bookmark = "mdi-bookmark"; public const string CardRemoveOutline = "mdi-card-remove-outline"; public const string CaseSensitiveAlt = "mdi-case-sensitive-alt"; diff --git a/src/ui/Logic/ImagePreviewBackground.cs b/src/ui/Logic/ImagePreviewBackground.cs new file mode 100644 index 00000000000..ce421aec6ee --- /dev/null +++ b/src/ui/Logic/ImagePreviewBackground.cs @@ -0,0 +1,96 @@ +using Avalonia.Controls; +using Avalonia.Media; +using Nikse.SubtitleEdit.Features.Shared.ColorPicker; +using Nikse.SubtitleEdit.Logic.Config; +using System; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.Logic; + +/// +/// The backdrop behind subtitle bitmap thumbnails, shared by the OCR window and the binary +/// (Blu-ray sup / VobSub) edit window. +/// +/// Subtitle bitmaps are light or dark text on a transparent background, so they need a backdrop +/// of their own or they vanish into the row. A checkerboard was tried and dropped for the OCR +/// grid (#12692): at thumbnail size the tiling competes with the glyphs. The binary edit window +/// kept it, and there the light half of the mid-gray checkerboard left light subtitles unreadable +/// (#14328). Both now use one flat colour, defaulting to the OCR window's near-black, and both +/// offer the same context-menu entry to change it - SE 4 had this configurable too +/// (Tools.BinEditImageBackgroundColor). +/// +public static class ImagePreviewBackground +{ + /// The OCR window's original hard-coded backdrop. + public static Color DefaultColor { get; } = Color.FromRgb(0x2D, 0x2D, 0x30); + + /// + /// A brush for one window's preview cells. + /// + /// Deliberately one instance per window rather than a shared static: a brush is an + /// AvaloniaObject and carries dispatcher affinity, so a single static instance handed to + /// every window is not safe to reuse across Avalonia application lifetimes. Every cell in a + /// window shares that window's brush, which is what lets a colour pick repaint them all with + /// no grid rebuild - and nothing is captured per cell, which a recycled FuncDataTemplate + /// would drop anyway. + /// + public static SolidColorBrush CreateBrush() + { + return new SolidColorBrush(CurrentColor); + } + + public static Color CurrentColor => ParseOrDefault(Se.Settings.Appearance.ImagePreviewBackgroundColor); + + /// + /// The shared "Image background color..." entry. Built here so the two windows cannot drift + /// into offering different wording or behaviour. is the window's own + /// preview brush, recoloured in place so the change shows immediately. + /// + public static MenuItem MakeMenuItem(IWindowService windowService, Func getOwner, SolidColorBrush brush) + { + var menuItem = new MenuItem { Header = Se.Language.General.ImageBackgroundColorDotDotDot }; + menuItem.Click += async (_, _) => await PickAsync(windowService, getOwner(), brush); + return menuItem; + } + + private static async Task PickAsync(IWindowService windowService, Window? owner, SolidColorBrush brush) + { + if (owner == null) + { + return; + } + + var result = await windowService.ShowDialogAsync( + owner, vm => vm.Initialize(brush.Color)); + + if (!result.OkPressed) + { + return; + } + + brush.Color = result.SelectedColor; + Se.Settings.Appearance.ImagePreviewBackgroundColor = result.SelectedColor.FromColorToHex(); + Se.SaveSettings(); + } + + /// + /// The saved hex, or the default when it is missing or unreadable - a hand-edited settings + /// file should not take the previews down with it. + /// + internal static Color ParseOrDefault(string? hex) + { + if (string.IsNullOrWhiteSpace(hex)) + { + return DefaultColor; + } + + try + { + return hex.FromHexToColor(); + } + catch (Exception) + { + return DefaultColor; + } + } +} diff --git a/src/ui/Logic/MacDictionaryLookup.cs b/src/ui/Logic/MacDictionaryLookup.cs new file mode 100644 index 00000000000..fbebdc56de3 --- /dev/null +++ b/src/ui/Logic/MacDictionaryLookup.cs @@ -0,0 +1,68 @@ +using System; + +namespace Nikse.SubtitleEdit.Logic; + +/// +/// macOS "Look Up" (#14277): every macOS text field offers it, and it opens the word in +/// Dictionary.app - which shows whatever dictionaries the user has installed, in any language, +/// plus thesaurus and Wikipedia. Dictionary.app is reached through the "dict:" URL scheme, which +/// only exists on macOS, so the menu item using this is macOS-only too. +/// +public static class MacDictionaryLookup +{ + /// Longest text shown inside the menu header before it is elided. + internal const int MaxHeaderTextLength = 32; + + /// + /// The text to look up: a single trimmed line - or null when there is nothing to look up. + /// A selection can span two lines and can carry tabs, and Dictionary.app searches for the + /// whole phrase, so the white space is folded into single spaces. + /// + public static string? Normalize(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + var oneLine = text + .Replace("\r\n", " ") + .Replace('\r', ' ') + .Replace('\n', ' ') + .Replace('\t', ' ') + .Trim(); + + while (oneLine.Contains(" ", StringComparison.Ordinal)) + { + oneLine = oneLine.Replace(" ", " ", StringComparison.Ordinal); + } + + return oneLine.Length == 0 ? null : oneLine; + } + + /// + /// The "dict:" URL Dictionary.app opens for the text, or null when there is nothing to look up. + /// + public static string? BuildUrl(string? text) + { + var word = Normalize(text); + return word == null ? null : "dict://" + Uri.EscapeDataString(word); + } + + /// + /// The menu header, macOS style: Look up "word". A long selection is elided so one right-click + /// on a whole subtitle line cannot stretch the context menu across the screen. + /// + public static string BuildHeader(string template, string? text) + { + var word = Normalize(text) ?? string.Empty; + if (word.Length > MaxHeaderTextLength) + { + word = word.Substring(0, MaxHeaderTextLength - 1).TrimEnd() + "…"; + } + + // Replace rather than string.Format: the template is translated, and a stray brace in a + // translation must not throw while a context menu is opening. + return template.Replace("{0}", word, StringComparison.Ordinal); + } +} diff --git a/src/ui/Logic/Media/EbuStlPreviewStyler.cs b/src/ui/Logic/Media/EbuStlPreviewStyler.cs new file mode 100644 index 00000000000..ed76c5bd89c --- /dev/null +++ b/src/ui/Logic/Media/EbuStlPreviewStyler.cs @@ -0,0 +1,141 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Logic.Config; +using SkiaSharp; +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Nikse.SubtitleEdit.Logic.Media; + +/// +/// Draws an EBU STL or DVB teletext subtitle in the video preview the way a teletext decoder +/// would: the box behind the text and the double height rows. +/// +/// +/// Shared by the mpv and the VLC reloader. They used to carry a copy each, and only the mpv copy +/// was ever fixed - the VLC one still drew the box in a hard coded 12 pt Tahoma that threw away +/// the font, size, color and margins the preview was set up with. +/// +internal static class EbuStlPreviewStyler +{ + /// + /// True when is the verbatim GSI block Ebu.LoadSubtitle keeps: + /// 3 characters of code page number, then the disk format code ("STL25.01"). + /// + public static bool IsStlHeader([NotNullWhen(true)] string? header) + { + return header != null && header.Length > 20 && header.AsSpan(3, 3).SequenceEqual("STL"); + } + + /// + /// True when the subtitle in the video preview is still a teletext format - EBU STL, or DVB + /// teletext (.dvbttx), which draws the same boxed double height rows. + /// + /// + /// The GSI block (and the dvbteletext marker) stays on the subtitle when the format is + /// switched in the toolbar, so the header on its own says only which file the subtitle was + /// read from - a subtitle shown as SubRip must lose the teletext box and the double height + /// with the format. + /// + public static bool IsTeletextPreview([NotNullWhen(true)] string? header, Type? uiFormatType) + { + return (uiFormatType == typeof(Ebu) && IsStlHeader(header)) || + (uiFormatType == typeof(DvbTeletext) && DvbTeletext.IsDvbTeletextHeader(header)); + } + + /// + /// Replaces the preview header with a boxed and an unboxed style and points every paragraph at + /// the right one. + /// + /// Preview copy of the subtitle - the header and the paragraphs are rewritten in place. + /// The GSI block of the STL file the subtitle came from. + /// The style the preview is configured with - font, size, colors, alignment, margins. + /// Script title for the generated ASSA header. + public static void Apply(Subtitle subtitle, string sourceHeader, SsaStyle previewStyle, string title) + { + // The box and the double height are teletext control codes, so display standard "0" + // (open subtitling) is written without either however the save options are set - see + // Ebu.EncodeText, which gates both the same way. + var useBox = false; + var useDoubleHeight = false; + if (DvbTeletext.IsDvbTeletextHeader(sourceHeader)) + { + // A .dvbttx is always teletext, and its writer always boxes and double-heights the + // rows (ManzanitaTeletextWriter.GetRow) - the EBU save options play no part here. + useBox = true; + useDoubleHeight = true; + } + else + { + try + { + var encoding = Ebu.GetEncoding(sourceHeader[..3]); + var header = Ebu.ReadHeader(encoding.GetBytes(sourceHeader)); + if (header.DisplayStandardCode != "0") + { + var subtitleSettings = Configuration.Settings.SubtitleSettings; + useBox = subtitleSettings.EbuStlTeletextUseBox; + useDoubleHeight = subtitleSettings.EbuStlTeletextUseDoubleHeight; + } + } + catch + { + // ignore - an unreadable header is previewed as plain text + } + } + + var defaultStyle = new SsaStyle(previewStyle); + + // Preview only, and never written to the file - an STL carries a character table, not a + // typeface. It is here for someone with a teletext face installed who wants the preview to + // look like a decoder, so it applies whatever the display standard is: a house font is as + // valid for open subtitling as a teletext font is for teletext. + var previewFontName = Se.Settings.File.EbuSaveOptions.PreviewFontName; + if (!string.IsNullOrWhiteSpace(previewFontName)) + { + defaultStyle.FontName = previewFontName; + } + + var boxStyle = new SsaStyle(defaultStyle) + { + Name = "Box", + BorderStyle = "3", // opaque box + Outline = SKColors.Black, // border style 3 fills the box with the outline color, and a teletext box is black + ShadowWidth = 0, + }; + + if (boxStyle.OutlineWidth < 1) + { + boxStyle.OutlineWidth = 1; // the box would otherwise cling to the glyphs + } + + if (useDoubleHeight) + { + // A double height row is the same glyphs drawn at twice the height - the width is + // untouched, which is why a double height line still fits the same 40 characters. + // Both styles get it: the code is written per text field, so a file that uses double + // height uses it for every line, boxed or not. + defaultStyle.ScaleY = 200; + boxStyle.ScaleY = 200; + } + + subtitle.Header = string.Format( + AdvancedSubStationAlpha.HeaderNoStyles, + title, + boxStyle.ToRawAss(SsaStyle.DefaultAssStyleFormat) + Environment.NewLine + + defaultStyle.ToRawAss(SsaStyle.DefaultAssStyleFormat)); + + for (var index = 0; index < subtitle.Paragraphs.Count; index++) + { + var p = subtitle.Paragraphs[index]; + + p.Extra = useBox ? "Box" : "Default"; + + if (p.Text.Contains("", StringComparison.Ordinal)) + { + p.Extra = "Box"; + p.Text = p.Text.Replace("", string.Empty).Replace("", string.Empty); + } + } + } +} diff --git a/src/ui/Logic/Media/FfmpegGenerator.cs b/src/ui/Logic/Media/FfmpegGenerator.cs index 54dae29d141..b5821a4373c 100644 --- a/src/ui/Logic/Media/FfmpegGenerator.cs +++ b/src/ui/Logic/Media/FfmpegGenerator.cs @@ -282,8 +282,12 @@ public static string GenerateHardcodedVideoFile(string inputVideoFileName, strin filterParameter = $"-filter_complex \"{filterComplex}\""; } + // "-y" (overwrite): the output file name comes from a "save as" dialog that has already + // asked about replacing an existing file, or from the batch naming that never collides. + // Without it ffmpeg hits "File ... already exists. Exiting." and writes nothing - and as + // the old file is still there, the burn-in looked like it succeeded (issue #14210). return - $"{cutStart}-i \"{inputVideoFileName}\"{canvasInput}{logoInput}{cutEnd} {filterParameter} -g 30 -bf 2 -s {width}x{height} {videoEncodingSettings} {passSettings} {presetSettings} {crfSettings} {pixelFormat} {audioSettings}{tuneParameter} -use_editlist 0 -movflags +faststart{shortestParameter} {outputVideoFileName}"; + $"-y{cutStart}-i \"{inputVideoFileName}\"{canvasInput}{logoInput}{cutEnd} {filterParameter} -g 30 -bf 2 -s {width}x{height} {videoEncodingSettings} {passSettings} {presetSettings} {crfSettings} {pixelFormat} {audioSettings}{tuneParameter} -use_editlist 0 -movflags +faststart{shortestParameter} {outputVideoFileName}"; } private static Process GetFFmpegProcess(string imageFileName, string outputFileName, int videoWidth, int videoHeight, int seconds, decimal frameRate, bool addTimeCode = false, string addTimeColor = "white") @@ -873,6 +877,53 @@ public static Process ConvertToMono24kHzWav(string inputFileName, string outputF return process; } + /// + /// Resamples / mixes the input to mono PCM16 WAV at 22.05 kHz. Used by Confucius4-TTS + /// (CrispASR) for its voice-cloning reference WAV — the S2A/vocoder chain works at 22.05 kHz + /// (the reference-mel path reads the file at that rate) while the w2v-BERT/CAM++ encoders + /// downsample to 16 kHz internally. + /// + public static Process ConvertToMono22kHzWav(string inputFileName, string outputFileName, DataReceivedEventHandler? dataReceivedHandler = null) + { + var process = new Process + { + StartInfo = + { + FileName = GetFfmpegLocation(), + Arguments = $"-y -i \"{inputFileName}\" -ar 22050 -ac 1 -c:a pcm_s16le \"{outputFileName}\"", + UseShellExecute = false, + CreateNoWindow = true, + } + }; + + SetupDataReceiveHandler(dataReceivedHandler, process); + + return process; + } + + /// + /// Resamples / mixes the input to mono PCM16 WAV at 44.1 kHz. Used by Fish Audio S2 Pro + /// (audio.cpp) for its voice-cloning reference WAV — the S2 Pro codec runs at 44.1 kHz, + /// so importing at that rate means the reference is only resampled once. + /// + public static Process ConvertToMono44kHzWav(string inputFileName, string outputFileName, DataReceivedEventHandler? dataReceivedHandler = null) + { + var process = new Process + { + StartInfo = + { + FileName = GetFfmpegLocation(), + Arguments = $"-y -i \"{inputFileName}\" -ar 44100 -ac 1 -c:a pcm_s16le \"{outputFileName}\"", + UseShellExecute = false, + CreateNoWindow = true, + } + }; + + SetupDataReceiveHandler(dataReceivedHandler, process); + + return process; + } + /// /// Resamples / mixes the input to mono PCM16 WAV at 16 kHz. Used by CosyVoice3 (CrispASR) /// for its zero-shot voice-cloning reference WAV — the s3tok speech tokenizer expects @@ -949,7 +1000,9 @@ public static Process GenerateVideoFile(string previewFileName, int seconds, int else if (checkered) { var tempImageFileName = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".png"); - var skBitmap = new SKBitmap(width, height, true); + // The branch above uses "using" for its bitmap; this one leaked ~8 MB of native + // pixels at 1080p on every "generate video with checkered background". + using var skBitmap = new SKBitmap(width, height, true); using (var canvas = new SKCanvas(skBitmap)) { UiUtil.DrawCheckerboardBackground(canvas, width, height); @@ -1235,7 +1288,11 @@ public static string GetRemoveSegmentsParameters( keepIndex++; } - lastEnd = seg.EndTime.TotalSeconds; + // Never move the cursor backwards: cut segments are sorted by start but not merged, + // so an overlapping pair like [10-20] then [12-15] used to reset lastEnd to 15 and + // leave 15-20 s in the output - while the subtitle re-timer treats it as removed, + // desyncing everything after the cut. + lastEnd = Math.Max(lastEnd, seg.EndTime.TotalSeconds); } // Keep remainder (from lastEnd → EOF) @@ -1442,7 +1499,10 @@ internal static string AlterEmbeddedTracksMatroska(List embeddedT args.Add($"-i \"{inputFileName}\""); // New external subtitle inputs - var newInputs = embeddedTracks.Where(t => t.New && !string.IsNullOrEmpty(t.FileName) && File.Exists(t.FileName)).ToList(); + // "!t.Deleted" as in the mp4 path below: a track the user added and then removed was + // still -i'd and -map'd in, and since outputSubs excludes it, every following subtitle + // stream picked up the previous track's language/title/disposition metadata. + var newInputs = embeddedTracks.Where(t => t.New && !t.Deleted && !string.IsNullOrEmpty(t.FileName) && File.Exists(t.FileName)).ToList(); foreach (var track in newInputs) { args.Add($"-i \"{track.FileName}\""); diff --git a/src/ui/Logic/Media/FfmpegHelper.cs b/src/ui/Logic/Media/FfmpegHelper.cs index 4ef7a8afbc3..535f045a2d9 100644 --- a/src/ui/Logic/Media/FfmpegHelper.cs +++ b/src/ui/Logic/Media/FfmpegHelper.cs @@ -291,6 +291,13 @@ private static string ProbeVersionOutput(string ffmpegPath) return string.Empty; } + // Drain both pipes BEFORE waiting, as ProbeEncodersOutput does: a full build's + // "configuration:" line runs to several KB, past the 4 KB Windows pipe buffer, and + // reading only after WaitForExit deadlocks until the timeout kills ffmpeg - which + // returned empty and let IsSupportedVersion accept any ffmpeg at all. + var stdOut = process.StandardOutput.ReadToEndAsync(); + var stdErr = process.StandardError.ReadToEndAsync(); + // "-version" prints a short banner and exits immediately; 4 s is generous. if (!process.WaitForExit(4000)) { @@ -298,7 +305,10 @@ private static string ProbeVersionOutput(string ffmpegPath) return string.Empty; } - return process.StandardOutput.ReadToEnd() + "\n" + process.StandardError.ReadToEnd(); + // Lets the async readers finish after the timed wait returned (documented pattern). + process.WaitForExit(); + + return stdOut.Result + "\n" + stdErr.Result; } catch (Exception ex) { diff --git a/src/ui/Logic/Media/FfmpegMediainfo2.cs b/src/ui/Logic/Media/FfmpegMediainfo2.cs index 618d582752b..d951a04ee99 100644 --- a/src/ui/Logic/Media/FfmpegMediainfo2.cs +++ b/src/ui/Logic/Media/FfmpegMediainfo2.cs @@ -28,6 +28,10 @@ public class FfmpegMediaInfo2 // "Stream #0:2(eng): Subtitle: ..." or "Stream #0:2[0x3](eng): ...". // Captures the 2-3 letter ISO code in group "lang". private static readonly Regex StreamLanguageRegex = new Regex(@"^Stream #\d+:\d+(?:\[[^\]]+\])?\((?[a-zA-Z]{2,3})\)", RegexOptions.Compiled); + // Rotation side data ffmpeg prints under a video stream, e.g. + // " Side data:" + // " Display Matrix: rotation of -90.00 degrees". + private static readonly Regex DisplayMatrixRotationRegex = new Regex(@"^Display Matrix: rotation of (?-?\d+(?:\.\d+)?) degrees", RegexOptions.Compiled); private FfmpegMediaInfo2() { @@ -105,11 +109,23 @@ private static FfmpegMediaInfo2 ParseLog(string log) // the MP4 embedded-subtitles window) would show duplicate tracks. var seenStreamPrefixes = new HashSet(StringComparer.Ordinal); + // ffmpeg reports the STORED frame size on the "Stream #" line, which for phone + // recordings is often landscape with a "Display Matrix: rotation of -90 degrees" + // side-data entry that makes it portrait on playback. ffmpeg auto-rotates on + // decode, so the frames every filter and encoder sees are the rotated ones. + // Report those display dimensions here, otherwise callers such as burn-in size + // the output (and the generated ASS PlayRes) to the unrotated frame and the + // picture comes out stretched. + var inDimensionStreamBlock = false; + var rotationApplied = false; + foreach (var line in log.SplitToLines()) { var s = line.Trim(); if (s.StartsWith("Stream #", StringComparison.Ordinal)) { + inDimensionStreamBlock = false; + var resolutionMatch = ResolutionRegex.Match(s); if (resolutionMatch.Success) { @@ -120,6 +136,7 @@ private static FfmpegMediaInfo2 ParseLog(string log) int.TryParse(parts[1], out var h)) { info.Dimension = new Dimension(w, h); + inDimensionStreamBlock = true; } } @@ -163,6 +180,22 @@ private static FfmpegMediaInfo2 ParseLog(string log) } } } + else if (inDimensionStreamBlock && !rotationApplied) + { + var rotationMatch = DisplayMatrixRotationRegex.Match(s); + if (rotationMatch.Success && + double.TryParse(rotationMatch.Groups["deg"].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var degrees)) + { + // Guard against the duplicated retry log swapping a second time. + rotationApplied = true; + + var normalized = ((degrees % 360) + 360) % 360; + if (Math.Abs(normalized - 90) < 1 || Math.Abs(normalized - 270) < 1) + { + info.Dimension = new Dimension(info.Dimension.Height, info.Dimension.Width); + } + } + } var match = DurationRegex.Match(line); if (match.Success) diff --git a/src/ui/Logic/Media/FileHelper.cs b/src/ui/Logic/Media/FileHelper.cs index cdf94b742d4..2c775548424 100644 --- a/src/ui/Logic/Media/FileHelper.cs +++ b/src/ui/Logic/Media/FileHelper.cs @@ -1,4 +1,4 @@ -using Avalonia; +using Avalonia; using Avalonia.Controls; using Avalonia.Platform.Storage; using Nikse.SubtitleEdit.Core.Common; @@ -110,7 +110,7 @@ public async Task PickOpenFiles(Visual sender, string title, string ex return files.Select(p => p.Path.LocalPath).ToArray(); } - public async Task PickOpenSubtitleFile(Visual sender, string title, bool includeVideoFiles = true, string? lastOpenedFilePath = null) + public async Task PickOpenSubtitleFile(Visual sender, string title, bool includeVideoFiles = true, string? lastOpenedFilePath = null, bool includeSpreadsheets = false) { var topLevel = TopLevel.GetTopLevel(sender)!; @@ -118,7 +118,7 @@ public async Task PickOpenSubtitleFile(Visual sender, string title, bool { Title = title, AllowMultiple = false, - FileTypeFilter = MakeOpenSubtitleFilter(includeVideoFiles), + FileTypeFilter = MakeOpenSubtitleFilter(includeVideoFiles, includeSpreadsheets), }; if (!string.IsNullOrEmpty(lastOpenedFilePath)) @@ -185,7 +185,15 @@ public async Task PickOpenSubtitleFiles(Visual sender, string title, b return files.Select(p => p.Path.LocalPath).ToArray(); } - private static List MakeOpenSubtitleFilter(bool includeVideoFiles) + /// + /// The file types the "open subtitle" pickers offer. + /// adds an entry for the spreadsheets the main window can import (#14168): no + /// SubtitleFormat claims .xlsx/.ods, so they were not listed by any filter and looked + /// unsupported. Off by default because only the main window's open path has the + /// UnknownFormatImporter fallback that reads them - everywhere else (compare, join, batch + /// convert, ...) a spreadsheet would just fail to load. + /// + private static List MakeOpenSubtitleFilter(bool includeVideoFiles, bool includeSpreadsheets = false) { var fileTypes = new List { @@ -197,12 +205,21 @@ private static List MakeOpenSubtitleFilter(bool includeVideo { Patterns = GetVideoExtensions(), }, - new FilePickerFileType(Se.Language.General.AllFiles) - { - Patterns = new List { "*" }, - } }; + if (includeSpreadsheets) + { + fileTypes.Add(new FilePickerFileType(Se.Language.General.SpreadsheetFiles) + { + Patterns = new List { "*.csv", "*.tsv", "*.xlsx", "*.ods" }, + }); + } + + fileTypes.Add(new FilePickerFileType(Se.Language.General.AllFiles) + { + Patterns = new List { "*" }, + }); + return fileTypes; } @@ -240,6 +257,7 @@ public static List GetOpenSubtitleExtensions(bool includeVideoFiles) AddExt(existingTypes, extensions, ".pac"); AddExt(existingTypes, extensions, ".890"); AddExt(existingTypes, extensions, ".fpc"); + AddExt(existingTypes, extensions, ".dvbttx"); if (includeVideoFiles) { @@ -620,16 +638,37 @@ private static List MakeSaveFilePickerFileTypes(string name, return fileTypes; } - public async Task PickOpenVideoFile(Visual sender, string title) + public async Task PickOpenVideoFile(Visual sender, string title, string? lastOpenedFilePath = null) { var topLevel = TopLevel.GetTopLevel(sender)!; - var files = await NativePickers.OpenFilePickerAsync(topLevel, new FilePickerOpenOptions + var options = new FilePickerOpenOptions { Title = title, AllowMultiple = false, FileTypeFilter = MakeOpenVideoFilter(), - }); + }; + + if (!string.IsNullOrEmpty(lastOpenedFilePath)) + { + var lastDir = Path.GetDirectoryName(lastOpenedFilePath); + if (!string.IsNullOrEmpty(lastDir)) + { + try + { + var folder = await topLevel.StorageProvider.TryGetFolderFromPathAsync(lastDir); + if (folder != null) + { + options.SuggestedStartLocation = folder; + } + } + catch + { + } + } + } + + var files = await NativePickers.OpenFilePickerAsync(topLevel, options); if (files.Count >= 1) { diff --git a/src/ui/Logic/Media/IFileHelper.cs b/src/ui/Logic/Media/IFileHelper.cs index b657a7b2574..9a283eb5e44 100644 --- a/src/ui/Logic/Media/IFileHelper.cs +++ b/src/ui/Logic/Media/IFileHelper.cs @@ -11,7 +11,7 @@ public interface IFileHelper Task PickOpenFile(Visual sender, string title, string extensionTitle, string extension, string extensionTitle2 = "", string extension2 = "", string? suggestedStartFolder = null); Task PickOpenFiles(Visual sender, string title, string extensionTitle, List extensions, string extensionTitle2, List extensions2); - Task PickOpenSubtitleFile(Visual sender, string title, bool includeVideoFiles = true, string? lastOpenedFilePath = null); + Task PickOpenSubtitleFile(Visual sender, string title, bool includeVideoFiles = true, string? lastOpenedFilePath = null, bool includeSpreadsheets = false); Task PickOpenSubtitleFiles(Visual sender, string title, bool includeVideoFiles = true, string? lastOpenedFilePath = null); Task PickSaveSubtitleFile( Visual sender, @@ -47,7 +47,7 @@ Task PickSaveFile( string suggestedFileName, string title); - Task PickOpenVideoFile(Visual sender, string title); + Task PickOpenVideoFile(Visual sender, string title, string? lastOpenedFilePath = null); Task PickOpenVideoFiles(Visual sender, string title); Task PickOpenImageFile(Visual sender, string title); } \ No newline at end of file diff --git a/src/ui/Logic/Media/MpvReloader.cs b/src/ui/Logic/Media/MpvReloader.cs index 5903b3d5290..b826734a3fe 100644 --- a/src/ui/Logic/Media/MpvReloader.cs +++ b/src/ui/Logic/Media/MpvReloader.cs @@ -72,9 +72,13 @@ public async Task RefreshMpv(LibMpvDynamicPlayer mpvContext, Subtitle subt // Applied on every refresh, not only on load: toggling "margin is part of the // subtitle area" in settings has to take effect on the video already open (#13934). mpvContext.ApplySubtitleMarginArea(); + mpvContext.ApplySubtitleJustify(); var uiFormatType = uiFormat.GetType(); + // Read here, on the UI thread, so the background serialize below only sees a bool. + var uiFormatHasPositions = uiFormat.HasPositionSupport; + // Deep copy on the calling (UI) thread: the caller usually passes the live // GetUpdateSubtitle() instance, which other UI code clears and repopulates at // will, so it must not be touched from the background serialize below. @@ -94,7 +98,7 @@ public async Task RefreshMpv(LibMpvDynamicPlayer mpvContext, Subtitle subt // is why the preview used to wait out a long typing pause (issue #13234). // "subtitle" is a thread-confined copy from here on; the secondary subtitle is // only read (its paragraphs are never mutated by the transforms). - var built = await Task.Run(() => BuildPreviewText(subtitle, subtitleSecondary, uiFormatType, oldHash, oldText)); + var built = await Task.Run(() => BuildPreviewText(subtitle, subtitleSecondary, uiFormatType, uiFormatHasPositions, oldHash, oldText)); if (version != _refreshVersion) { @@ -174,24 +178,20 @@ public async Task RefreshMpv(LibMpvDynamicPlayer mpvContext, Subtitle subt // Runs on a background thread (see RefreshMpv). Must only touch the thread-confined // subtitle copy, read-only state and libse statics - never the memo fields. - private (Subtitle Subtitle, string Text, int Hash, bool HashValid) BuildPreviewText(Subtitle subtitle, Subtitle? subtitleSecondary, Type uiFormatType, int oldHash, string oldText) + private (Subtitle Subtitle, string Text, int Hash, bool HashValid) BuildPreviewText(Subtitle subtitle, Subtitle? subtitleSecondary, Type uiFormatType, bool uiFormatHasPositions, int oldHash, string oldText) { if (SmpteMode) { - foreach (var paragraph in subtitle.Paragraphs) - { - paragraph.StartTime.TotalMilliseconds *= 1.001; - paragraph.EndTime.TotalMilliseconds *= 1.001; - } + SmptePreviewStretch.Apply(subtitle); } - if (uiFormatType == typeof(NetflixImsc11Japanese)) + if (uiFormatType == typeof(NetflixImsc11Japanese) || NetflixImsc11JapaneseToAss.HasJapaneseMarkup(subtitle)) { // Furigana, bouten and vertical writing have no libass equivalent - they have to be // exploded into separately positioned render lines, or the tags show up as literal - // text on the video (issue #13861). + // text on the video (issue #13861). Lambda Cap decodes to the same markup (issue #14165). subtitle = NetflixImsc11JapaneseToAss.ConvertToSubtitle(subtitle, VideoWidth, VideoHeight); - SecondarySubtitleMerger.AddSecondarySubtitle(subtitle, subtitleSecondary); + SecondarySubtitleMerger.AddSecondarySubtitle(subtitle, subtitleSecondary, SmpteMode); return (subtitle, subtitle.ToText(_assFormat), 0, false); } @@ -202,7 +202,7 @@ public async Task RefreshMpv(LibMpvDynamicPlayer mpvContext, Subtitle subt // No extra copy here: "subtitle" is already RefreshMpv's private copy, and // Convert deep-copies its input again internally without mutating it. subtitle = WebVttToAssa.Convert(subtitle, defaultStyle, VideoWidth, VideoHeight); - SecondarySubtitleMerger.AddSecondarySubtitle(subtitle, subtitleSecondary); + SecondarySubtitleMerger.AddSecondarySubtitle(subtitle, subtitleSecondary, SmpteMode); // The WebVTT path never used the hash memo, so keep it untouched (HashValid false). return (subtitle, subtitle.ToText(_assFormat), 0, false); } @@ -241,52 +241,26 @@ public async Task RefreshMpv(LibMpvDynamicPlayer mpvContext, Subtitle subt subtitle.Header = MpvPreviewStyleHeader; } - if (oldHeader != null && oldHeader.Length > 20 && oldHeader.AsSpan(3, 3).SequenceEqual("STL")) + // The GSI block survives a format change in the toolbar, so an STL header alone does + // not mean the subtitle is still EBU STL - shown as SubRip it must lose the teletext + // box, the double height and the rows. + if (EbuStlPreviewStyler.IsTeletextPreview(oldHeader, uiFormatType)) { - var previewFontName = Configuration.IsRunningOnLinux ? Configuration.DefaultLinuxFontName : "Tahoma"; - var boxStyle = $"Style: Box,{previewFontName},12,&H00FFFFFF,&H0300FFFF,&H00000000,&H02000000,-1,0,0,0,100,100,0,0,3,2,0,2,10,10,10,1{Environment.NewLine}Style: Default,"; - subtitle.Header = subtitle.Header.Replace("Style: Default,", boxStyle, StringComparison.Ordinal); - - var useBox = false; - if (Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox) - { - try - { - var encoding = Ebu.GetEncoding(oldHeader[..3]); - var buffer = encoding.GetBytes(oldHeader); - var header = Ebu.ReadHeader(buffer); - if (header.DisplayStandardCode != "0") - { - useBox = true; - } - } - catch - { - // ignore - } - } - - for (var index = 0; index < subtitle.Paragraphs.Count; index++) - { - var p = subtitle.Paragraphs[index]; - - p.Extra = useBox ? "Box" : "Default"; - - if (p.Text.Contains("", StringComparison.Ordinal)) - { - p.Extra = "Box"; - p.Text = p.Text.Replace("", string.Empty).Replace("", string.Empty); - } - } + EbuStlPreviewStyler.Apply(subtitle, oldHeader, GetMpvPreviewStyle(Se.Settings.Video), MpvPreviewTitle); } // TTML regions, PAC vertical alignment and EBU STL teletext rows say where the line // belongs on the video - without this every line lands at the one fixed preview - // alignment from the settings (discussion #13857). - SubtitlePositionToAssa.ApplyPositions(subtitle, oldHeader, Se.Settings.Video.MpvPreviewUsePositionFromFile); + // alignment from the settings (discussion #13857). Only for a format that still carries + // them: the header and the paragraph fields of the file the subtitle was read from + // survive a format change in the toolbar. The call is made either way - with the + // positioning off it strips the leftovers, and a teletext row or a percentage left in + // MarginV would otherwise count as an ASSA pixel margin. + var usePositions = Se.Settings.Video.MpvPreviewUsePositionFromFile && uiFormatHasPositions; + SubtitlePositionToAssa.ApplyPositions(subtitle, oldHeader, usePositions); } - SecondarySubtitleMerger.AddSecondarySubtitle(subtitle, subtitleSecondary); + SecondarySubtitleMerger.AddSecondarySubtitle(subtitle, subtitleSecondary, SmpteMode); var hash = subtitle.GetFastHashCode(null); if (hash != oldHash || string.IsNullOrEmpty(oldText)) { @@ -310,10 +284,12 @@ private string MpvPreviewStyleHeader set => _mpvPreviewStyleHeader = value; } + private const string MpvPreviewTitle = "MPV preview file"; + public void UpdateMpvStyle() { var mpvStyle = GetMpvPreviewStyle(Se.Settings.Video); - MpvPreviewStyleHeader = string.Format(AdvancedSubStationAlpha.HeaderNoStyles, "MPV preview file", mpvStyle.ToRawAss(SsaStyle.DefaultAssStyleFormat)); + MpvPreviewStyleHeader = string.Format(AdvancedSubStationAlpha.HeaderNoStyles, MpvPreviewTitle, mpvStyle.ToRawAss(SsaStyle.DefaultAssStyleFormat)); } private static SsaStyle GetMpvPreviewStyle(SeVideo gs) diff --git a/src/ui/Logic/Media/SecondarySubtitleMerger.cs b/src/ui/Logic/Media/SecondarySubtitleMerger.cs index 780bce2aee4..7c2d473e639 100644 --- a/src/ui/Logic/Media/SecondarySubtitleMerger.cs +++ b/src/ui/Logic/Media/SecondarySubtitleMerger.cs @@ -16,8 +16,11 @@ public static class SecondarySubtitleMerger /// the secondary subtitle tiny for WebVTT mains (PlayResY = video height) and huge /// for ASSA mains with a small PlayResY (issue #13425), so resample it to the /// target's scale first. + /// The secondary subtitle is shared between refreshes and is only ever read, so its + /// paragraphs are added by reference - except in SMPTE mode, where the stretch the main + /// subtitle already got has to apply to them too, and so goes onto a copy. /// - public static void AddSecondarySubtitle(Subtitle subtitle, Subtitle? subtitleSecondary) + public static void AddSecondarySubtitle(Subtitle subtitle, Subtitle? subtitleSecondary, bool smpteMode) { if (subtitleSecondary == null) { @@ -49,7 +52,7 @@ public static void AddSecondarySubtitle(Subtitle subtitle, Subtitle? subtitleSec subtitle.Header = AdvancedSubStationAlpha.AddSsaStyle(style, subtitle.Header); foreach (var p in subtitleSecondary.Paragraphs) { - subtitle.Paragraphs.Add(p); + subtitle.Paragraphs.Add(smpteMode ? SmptePreviewStretch.Stretched(p) : p); } } diff --git a/src/ui/Logic/Media/ShotChangesHelper.cs b/src/ui/Logic/Media/ShotChangesHelper.cs index 5b7713580c7..f4d1ad860dc 100644 --- a/src/ui/Logic/Media/ShotChangesHelper.cs +++ b/src/ui/Logic/Media/ShotChangesHelper.cs @@ -458,8 +458,12 @@ public static (double StartMs, double EndMs)? GetSnappedToNearestMs( nearestEnd = FirstAfterWithin(shotChanges, nearestStart.Value, endMs, maxSameShotEndDistanceMs); } - var newStartMs = nearestStart != null ? nearestStart.Value + inCuesGapMs : startMs; - var newEndMs = nearestEnd != null ? nearestEnd.Value - outCuesGapMs : endMs; + // Shot changes come from video frame positions, so cut ± gap is a fractional + // millisecond. Round to the whole millisecond the subtitle will actually store; the + // no-op guard below then compares like with like, so re-running the command on an + // already-snapped line stays a no-op instead of reporting a change (#14056). + var newStartMs = nearestStart != null ? Math.Round(nearestStart.Value + inCuesGapMs, MidpointRounding.AwayFromZero) : startMs; + var newEndMs = nearestEnd != null ? Math.Round(nearestEnd.Value - outCuesGapMs, MidpointRounding.AwayFromZero) : endMs; if (newEndMs <= newStartMs) { diff --git a/src/ui/Logic/Media/SmptePreviewStretch.cs b/src/ui/Logic/Media/SmptePreviewStretch.cs new file mode 100644 index 00000000000..277ee911f6d --- /dev/null +++ b/src/ui/Logic/Media/SmptePreviewStretch.cs @@ -0,0 +1,42 @@ +using Nikse.SubtitleEdit.Core.Common; + +namespace Nikse.SubtitleEdit.Logic.Media; + +/// +/// SMPTE preview timing: the subtitle's time codes count 30/24 frames per second while the +/// video runs at 29.97/23.976, so everything handed to the player has to run 0.1 % slower +/// to stay in sync with the picture. The factor and both ways of applying it live here so +/// the mpv and VLC reloaders, and the secondary subtitle, cannot drift apart. +/// +public static class SmptePreviewStretch +{ + public const double Factor = 1.001; + + /// + /// Stretches every paragraph in place. Only for a preview copy - the caller's live + /// subtitle must never see this. + /// + public static void Apply(Subtitle subtitle) + { + foreach (var paragraph in subtitle.Paragraphs) + { + Apply(paragraph); + } + } + + public static void Apply(Paragraph paragraph) + { + paragraph.StartTime.TotalMilliseconds *= Factor; + paragraph.EndTime.TotalMilliseconds *= Factor; + } + + /// + /// A stretched copy, for paragraphs that are shared and must stay untouched. + /// + public static Paragraph Stretched(Paragraph paragraph) + { + var copy = new Paragraph(paragraph, false); + Apply(copy); + return copy; + } +} diff --git a/src/ui/Logic/Media/TextToImageGenerator.cs b/src/ui/Logic/Media/TextToImageGenerator.cs index e1e62f211fe..221cfcd88d2 100644 --- a/src/ui/Logic/Media/TextToImageGenerator.cs +++ b/src/ui/Logic/Media/TextToImageGenerator.cs @@ -1,5 +1,7 @@ using SkiaSharp; using System; +using System.Collections.Generic; +using Nikse.SubtitleEdit.Core.Common; namespace Nikse.SubtitleEdit.Logic.Media; @@ -124,31 +126,57 @@ public static SKBitmap GenerateImageWithPadding( font.SkewX = -0.25f; // synthetic slant so italic shows even when the font has no italic face } - font.MeasureText(text, out var textBounds, paint); + // MeasureText and GetTextPath are single-run APIs with no notion of a line break - a "\n" + // became a .notdef glyph and every line was drawn side by side on one baseline. The Set + // text dialog feeds this user text from a multi-line TextBox and writes the result straight + // into a VobSub/Blu-ray subtitle, so a normal two-line caption came out as one long line. + var lines = (text ?? string.Empty).SplitToLines(); + if (lines.Count == 0) + { + lines = new List { string.Empty }; + } + + var lineBounds = new SKRect[lines.Count]; + var maxLineWidth = 0f; + for (var i = 0; i < lines.Count; i++) + { + font.MeasureText(lines[i], out lineBounds[i], paint); + maxLineWidth = Math.Max(maxLineWidth, lineBounds[i].Width); + } + + var metrics = font.Metrics; + var lineHeight = Math.Abs(metrics.Ascent) + Math.Abs(metrics.Descent); + var lineStep = lineHeight + Math.Abs(metrics.Leading); - var width = (int)(textBounds.Width + padding * 2 + outlineWidth * 2 + shadowWidth); - var height = (int)(textBounds.Height + padding * 2 + outlineWidth * 2 + shadowWidth); + var width = (int)(maxLineWidth + padding * 2 + outlineWidth * 2 + shadowWidth); + var height = (int)(lineHeight + (lines.Count - 1) * lineStep + padding * 2 + outlineWidth * 2 + shadowWidth); var bitmap = new SKBitmap(width, height); using var canvas = new SKCanvas(bitmap); canvas.Clear(backgroundColor); var x = padding + outlineWidth; - var y = height - padding - outlineWidth; + var firstBaseline = padding + outlineWidth + Math.Abs(metrics.Ascent); + var y = firstBaseline; - // Create a path for the text - using var textPath = font.GetTextPath(text, new SKPoint(x, y)); - - // Draw shadow (includes both text and outline with rounded corners) - if (shadowWidth > 0) + for (var i = 0; i < lines.Count; i++) { - DrawTextWithRoundedOutline(x + shadowWidth, y + shadowWidth, shadowColor, shadowColor, textPath, x, y, cornerRadius, outlineWidth, paint, canvas); - } + y = firstBaseline + i * lineStep; - // Draw main text with rounded outline - DrawTextWithRoundedOutline(x, y, textColor, outlineColor, textPath, x, y, cornerRadius, outlineWidth, paint, canvas); + // Create a path for the line + using var textPath = font.GetTextPath(lines[i], new SKPoint(x, y)); - DrawUnderlineAndStrikeout(canvas, font, textColor, x, y, textBounds.Width, fontSize, isUnderline, isStrikeout); + // Draw shadow (includes both text and outline with rounded corners) + if (shadowWidth > 0) + { + DrawTextWithRoundedOutline(x + shadowWidth, y + shadowWidth, shadowColor, shadowColor, textPath, x, y, cornerRadius, outlineWidth, paint, canvas); + } + + // Draw main text with rounded outline + DrawTextWithRoundedOutline(x, y, textColor, outlineColor, textPath, x, y, cornerRadius, outlineWidth, paint, canvas); + + DrawUnderlineAndStrikeout(canvas, font, textColor, x, y, lineBounds[i].Width, fontSize, isUnderline, isStrikeout); + } return bitmap; } diff --git a/src/ui/Logic/Media/TimeCodesGenerator.cs b/src/ui/Logic/Media/TimeCodesGenerator.cs new file mode 100644 index 00000000000..d9c9b431afe --- /dev/null +++ b/src/ui/Logic/Media/TimeCodesGenerator.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.Logic.Media; + +/// +/// Extracts a video's exact frame time codes with ffmpeg, for "Beautify time codes". +/// +/// The time codes must be presentation time stamps: a subtitle cue is correct when it is +/// on screen at the right moment, which has nothing to do with the order frames are stored or +/// decoded in. With B-frames those two orders differ, and a container can additionally start at a +/// non-zero time stamp, so decode/packet-domain values are offset from what the viewer sees. +/// +/// ffmpeg's "showinfo" filter reports frames as they leave the decoder - presentation order, and +/// normalized to a zero-based timeline (no -copyts) to match the subtitle timeline - which is +/// exactly the domain wanted here. It needs a full decode, hence the progress reporting, +/// cancellation, and the on-disk cache in . +/// +public static class TimeCodesGenerator +{ + private const string PtsTimeKey = "pts_time:"; + + /// + /// Decodes the video and returns its frame presentation times in seconds, ascending. + /// + /// Video to read frame time codes from. + /// Video length, used for the progress percentage. 0 if unknown. + /// Receives 0-100 while decoding. Called on a background thread. + /// Kills ffmpeg and returns what was gathered so far. + public static async Task> ExtractAsync( + string videoFileName, + double durationSeconds, + IProgress? progress, + CancellationToken cancellationToken) + { + var timeCodes = new List(); + var tracker = durationSeconds > 0 ? new FfmpegProgressTracker(durationSeconds) : null; + + // "-map 0:v:0 -an -sn -dn": only the first video stream is decoded; audio/subtitle/data + // streams would just cost time. Frames are discarded by the null muxer - the time codes + // come from what showinfo logs on stderr, while progress arrives on stdout. + var arguments = + $"{FfmpegProgressTracker.ProgressArguments} -i \"{videoFileName}\" " + + "-map 0:v:0 -an -sn -dn -vf showinfo -threads 0 -f null -"; + + void OutputHandler(object sender, DataReceivedEventArgs outLine) + { + var line = outLine.Data; + if (string.IsNullOrEmpty(line)) + { + return; + } + + if (tracker != null && tracker.TryGetNewPercent(line, out var percent)) + { + progress?.Report(percent); + return; + } + + if (TryParsePtsTime(line, out var seconds)) + { + timeCodes.Add(seconds); + } + } + + var process = FfmpegGenerator.GetProcess(arguments, OutputHandler); +#pragma warning disable CA1416 // Validate platform compatibility + process.Start(); +#pragma warning restore CA1416 + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + try + { + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + // ffmpeg dying mid-decode (corrupt file, unsupported codec) still exits "normally" + // here, leaving a partial list that looks complete when the duration is unknown. + if (process.ExitCode != 0) + { + throw new Exception($"ffmpeg exited with code {process.ExitCode} while extracting time codes"); + } + } + catch (OperationCanceledException) + { + KillQuietly(process); + } + finally + { + process.Dispose(); + } + + return TimeCodesHelper.Normalize(timeCodes); + } + + /// + /// Reads the seconds value out of a showinfo line, e.g. + /// "[Parsed_showinfo_0 @ 0x..] n: 1 pts: 40 pts_time:0.04 pos:5049 fmt:yuv420p ...". + /// Hand-rolled rather than a regex: this runs once per frame of the video, so tens of + /// thousands of times for a feature-length file. + /// + internal static bool TryParsePtsTime(string line, out double seconds) + { + seconds = 0; + + var start = line.IndexOf(PtsTimeKey, StringComparison.Ordinal); + if (start < 0) + { + return false; + } + + start += PtsTimeKey.Length; + var end = start; + while (end < line.Length && (char.IsAsciiDigit(line[end]) || line[end] == '.' || line[end] == '-')) + { + end++; + } + + if (end == start) + { + return false; // "pts_time:N/A" - the frame carries no usable time stamp + } + + return double.TryParse( + line.AsSpan(start, end - start), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out seconds); + } + + private static void KillQuietly(Process process) + { + try + { + if (!process.HasExited) + { +#pragma warning disable CA1416 // Validate platform compatibility + process.Kill(true); +#pragma warning restore CA1416 + } + } + catch + { + // already gone, or not ours to kill - nothing useful to do either way + } + } +} diff --git a/src/ui/Logic/Media/TimeCodesHelper.cs b/src/ui/Logic/Media/TimeCodesHelper.cs new file mode 100644 index 00000000000..b1b6d62ec00 --- /dev/null +++ b/src/ui/Logic/Media/TimeCodesHelper.cs @@ -0,0 +1,183 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Logic.Config; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +namespace Nikse.SubtitleEdit.Logic.Media; + +/// +/// Disk cache for a video's exact frame time codes (presentation time stamps, in seconds). +/// +/// "Beautify time codes" snaps cues to real frame boundaries. With no time codes it has to assume +/// a perfect n/fps grid, which is only true for genuinely constant-frame-rate material - variable +/// frame rate, remux artifacts and time stamp discontinuities all put the real frames somewhere +/// else. extracts the real ones; this stores them so the (full +/// video decode) extraction only has to happen once per file. +/// +public static class TimeCodesHelper +{ + private const string Extension = ".timecodes"; + + /// + /// Load cached time codes for a video file. Returns an empty list when nothing is cached, so + /// callers can pass the result straight to the beautifier (which then falls back to n/fps). + /// + public static List FromDisk(string? videoFileName) + { + if (string.IsNullOrEmpty(videoFileName)) + { + return new List(); + } + + var fileName = FindTimeCodesFileName(videoFileName); + if (string.IsNullOrEmpty(fileName)) + { + return new List(); + } + + var list = new List(); + foreach (var line in File.ReadLines(fileName)) + { + if (!string.IsNullOrWhiteSpace(line) && + double.TryParse(line, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds)) + { + list.Add(seconds); + } + } + + // A cache file can be truncated by a crash or a full disk, so it gets the same treatment + // as freshly extracted values rather than being trusted on sight. + return Normalize(list); + } + + /// Saves time codes (in seconds) for a video file. + public static void Save(string videoFileName, IReadOnlyList timeCodes) + { + var sb = new StringBuilder(); + foreach (var seconds in timeCodes) + { + sb.AppendLine(seconds.ToString("0.######", CultureInfo.InvariantCulture)); + } + + File.WriteAllText(GetTimeCodesFileName(videoFileName), sb.ToString().TrimEnd()); + } + + /// Deletes the cached time codes for a video file, if any. + public static void Delete(string videoFileName) + { + var fileName = FindTimeCodesFileName(videoFileName); + if (!string.IsNullOrEmpty(fileName) && File.Exists(fileName)) + { + File.Delete(fileName); + } + } + + /// + /// Makes a raw list safe for , which both binary + /// searches the list (ClosestIndexTo) and indexes it by frame number. Both require + /// strictly increasing values, and neither validates - a list that is merely out of order + /// yields silently wrong frame numbers instead of an error. Demuxed/packet-ordered sources are + /// exactly that, so sort, and drop anything that cannot be a frame time (non-finite, negative, + /// or a duplicate of the previous frame). + /// + public static List Normalize(IEnumerable timeCodes) + { + var sorted = new List(); + foreach (var seconds in timeCodes) + { + if (double.IsFinite(seconds) && seconds >= 0) + { + sorted.Add(seconds); + } + } + + sorted.Sort(); + + var result = new List(sorted.Count); + foreach (var seconds in sorted) + { + if (result.Count == 0 || seconds > result[result.Count - 1]) + { + result.Add(seconds); + } + } + + return result; + } + + /// + /// True when a list looks like a complete frame list for a video of the given length, i.e. it + /// covers most of the running time. A short list is worse than none at all: the beautifier + /// treats index as frame number, so cues past the end of a partial list snap to the wrong + /// frames instead of degrading to n/fps. + /// + public static bool IsUsableFor(IReadOnlyList timeCodes, double durationSeconds) + { + if (timeCodes.Count < 2) + { + return false; + } + + if (durationSeconds <= 0) + { + // Unknown length - a partial list cannot be told apart from a complete one, and + // accepting a partial one snaps every cue to its few entries. Callers that know + // the extraction ran to completion can pass the last frame time as the duration. + return false; + } + + var covered = timeCodes[timeCodes.Count - 1]; + return covered >= durationSeconds * 0.9; + } + + private static string GetTimeCodesFileName(string videoFileName) + { + var dir = Se.TimeCodesFolder; + if (!Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + return Path.Combine(dir, MakeBaseName(videoFileName) + Extension); + } + + private static string FindTimeCodesFileName(string videoFileName) + { + var dir = Se.TimeCodesFolder; + if (!Directory.Exists(dir)) + { + return string.Empty; + } + + var fileName = Path.Combine(dir, MakeBaseName(videoFileName) + Extension); + if (File.Exists(fileName)) + { + return fileName; + } + + // The readable suffix is cosmetic, so a file cached under an older/renamed video name is + // still a hit - the hash is what identifies the video. + var files = Directory.GetFiles(dir, MovieHasher.GenerateHash(videoFileName) + "*" + Extension); + return files.Length > 0 ? files[0] : string.Empty; + } + + /// + /// Hash (identity) plus a short readable part, so the cache folder can be made sense of by a + /// human without opening every file. + /// + private static string MakeBaseName(string videoFileName) + { + var readable = Path.GetFileNameWithoutExtension(videoFileName) + .Replace(".", string.Empty) + .Replace("_", string.Empty); + if (readable.Length > 25) + { + readable = readable.Substring(0, 25); + } + + return $"{MovieHasher.GenerateHash(videoFileName)}_{readable}"; + } +} diff --git a/src/ui/Logic/Media/VlcReloader.cs b/src/ui/Logic/Media/VlcReloader.cs index 4e78613967e..25217f8cf58 100644 --- a/src/ui/Logic/Media/VlcReloader.cs +++ b/src/ui/Logic/Media/VlcReloader.cs @@ -39,21 +39,17 @@ public async Task RefreshVlc(LibVlcDynamicPlayer vlc, Subtitle subtitle, Subtitl if (SmpteMode) { - foreach (var paragraph in subtitle.Paragraphs) - { - paragraph.StartTime.TotalMilliseconds *= 1.001; - paragraph.EndTime.TotalMilliseconds *= 1.001; - } + SmptePreviewStretch.Apply(subtitle); } SubtitleFormat format = _assFormat; string text; - if (uiFormatType == typeof(NetflixImsc11Japanese)) + if (uiFormatType == typeof(NetflixImsc11Japanese) || NetflixImsc11JapaneseToAss.HasJapaneseMarkup(subtitle)) { // See MpvReloader - the furigana/bouten/vertical markup has to become positioned - // render lines before libass sees it (issue #13861). + // render lines before libass sees it (issue #13861, issue #14165). subtitle = NetflixImsc11JapaneseToAss.ConvertToSubtitle(subtitle, VideoWidth, VideoHeight); - SecondarySubtitleMerger.AddSecondarySubtitle(subtitle, subtitleSecondary); + SecondarySubtitleMerger.AddSecondarySubtitle(subtitle, subtitleSecondary, SmpteMode); text = subtitle.ToText(_assFormat); } else if (uiFormatType == typeof(WebVTT) || uiFormatType == typeof(WebVTTFileWithLineNumber)) @@ -62,7 +58,7 @@ public async Task RefreshVlc(LibVlcDynamicPlayer vlc, Subtitle subtitle, Subtitl defaultStyle.BorderStyle = "3"; subtitle = new Subtitle(subtitle); subtitle = WebVttToAssa.Convert(subtitle, defaultStyle, VideoWidth, VideoHeight); - SecondarySubtitleMerger.AddSecondarySubtitle(subtitle, subtitleSecondary); + SecondarySubtitleMerger.AddSecondarySubtitle(subtitle, subtitleSecondary, SmpteMode); text = subtitle.ToText(_assFormat); } else @@ -98,51 +94,21 @@ public async Task RefreshVlc(LibVlcDynamicPlayer vlc, Subtitle subtitle, Subtitl subtitle.Header = MpvPreviewStyleHeader; } - if (oldSub.Header != null && oldSub.Header.Length > 20 && oldSub.Header.AsSpan(3, 3).SequenceEqual("STL")) + // See MpvReloader - the GSI block survives a format change in the toolbar, so + // an STL header alone does not mean the subtitle is still EBU STL. + if (EbuStlPreviewStyler.IsTeletextPreview(oldSub.Header, uiFormatType)) { - var previewFontName = Configuration.IsRunningOnLinux ? Configuration.DefaultLinuxFontName : "Tahoma"; - var boxStyle = $"Style: Box,{previewFontName},12,&H00FFFFFF,&H0300FFFF,&H00000000,&H02000000,-1,0,0,0,100,100,0,0,3,2,0,2,10,10,10,1{Environment.NewLine}Style: Default,"; - subtitle.Header = subtitle.Header.Replace("Style: Default,", boxStyle, StringComparison.Ordinal); - - var useBox = false; - if (Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox) - { - try - { - var encoding = Ebu.GetEncoding(oldSub.Header[..3]); - var buffer = encoding.GetBytes(oldSub.Header); - var header = Ebu.ReadHeader(buffer); - if (header.DisplayStandardCode != "0") - { - useBox = true; - } - } - catch - { - // ignore - } - } - - for (var index = 0; index < subtitle.Paragraphs.Count; index++) - { - var p = subtitle.Paragraphs[index]; - - p.Extra = useBox ? "Box" : "Default"; - - if (p.Text.Contains("", StringComparison.Ordinal)) - { - p.Extra = "Box"; - p.Text = p.Text.Replace("", string.Empty).Replace("", string.Empty); - } - } + EbuStlPreviewStyler.Apply(subtitle, oldSub.Header, GetMpvPreviewStyle(Se.Settings.Video), MpvPreviewTitle); } // See MpvReloader - the position the source format carries beats the one fixed - // preview alignment (discussion #13857). - SubtitlePositionToAssa.ApplyPositions(subtitle, oldSub.Header, Se.Settings.Video.MpvPreviewUsePositionFromFile); + // preview alignment (discussion #13857), but only while the format still carries + // it. With the positioning off the call strips what the old format left behind. + var usePositions = Se.Settings.Video.MpvPreviewUsePositionFromFile && uiFormat.HasPositionSupport; + SubtitlePositionToAssa.ApplyPositions(subtitle, oldSub.Header, usePositions); } - SecondarySubtitleMerger.AddSecondarySubtitle(subtitle, subtitleSecondary); + SecondarySubtitleMerger.AddSecondarySubtitle(subtitle, subtitleSecondary, SmpteMode); var hash = subtitle.GetFastHashCode(null); if (hash != _mpvSubOldHash || string.IsNullOrEmpty(_mpvTextOld)) { @@ -198,10 +164,12 @@ private string MpvPreviewStyleHeader set => _mpvPreviewStyleHeader = value; } + private const string MpvPreviewTitle = "MPV preview file"; + public void UpdateMpvStyle() { var mpvStyle = GetMpvPreviewStyle(Se.Settings.Video); - MpvPreviewStyleHeader = string.Format(AdvancedSubStationAlpha.HeaderNoStyles, "MPV preview file", mpvStyle.ToRawAss(SsaStyle.DefaultAssStyleFormat)); + MpvPreviewStyleHeader = string.Format(AdvancedSubStationAlpha.HeaderNoStyles, MpvPreviewTitle, mpvStyle.ToRawAss(SsaStyle.DefaultAssStyleFormat)); } private static SsaStyle GetMpvPreviewStyle(SeVideo gs) diff --git a/src/ui/Logic/Media/WaveToVisualizer2.cs b/src/ui/Logic/Media/WaveToVisualizer2.cs index e0418073894..d04f9c60c59 100644 --- a/src/ui/Logic/Media/WaveToVisualizer2.cs +++ b/src/ui/Logic/Media/WaveToVisualizer2.cs @@ -396,7 +396,12 @@ private SpectrogramData2(string loadFromFilePath) public int ImageWidth { get; private set; } - public double SampleDuration { get; private set; } + /// + /// Seconds of audio per spectrogram column. Settable so SMPTE drop-frame mode can + /// compress it by 1.001 in step with the wave peaks - otherwise the two panels share + /// an X axis but not a time base and drift apart. + /// + public double SampleDuration { get; set; } public IList Images { get; private set; } @@ -448,7 +453,7 @@ private void LoadFromBinaryFile(string filePath) SampleDuration = br.ReadDouble(); // Read raw samples - var sampleCount = (int)((fs.Length - 20) / sizeof(float)); // 20 bytes = 3 ints (12 bytes) + 1 double (8 bytes) + var sampleCount = (int)((fs.Length - 16) / sizeof(float)); // 16 bytes = 2 ints (8 bytes) + 1 double (8 bytes), matching SaveToBinaryFile _rawSamples = new float[sampleCount]; var byteSpan = MemoryMarshal.AsBytes(_rawSamples.AsSpan()); @@ -1320,17 +1325,30 @@ public static string GetSpectrogramFileName(string videoFileName, int trackNumbe var hash = MovieHasher.GenerateHash(videoFileName); - var files = Directory.GetFiles(dir, $"{hash}-*.spectrogram") - .OrderBy(p => p) - .ToList(); - if (files.Count > 0 && trackNumber < 0) + if (trackNumber < 0) { - return files[0]; - } + // Same preference order as GetPeakWaveFileName: the untracked file first, then + // the first per-track one. Preferring the glob here while the peak resolver + // preferred the bare file meant the two panels could show different audio + // tracks of the same video. + var bare = Path.Combine(dir, $"{hash}.spectrogram"); + if (File.Exists(bare) || Directory.Exists(bare)) + { + return bare; + } + + var files = Directory.GetFiles(dir, $"{hash}-*.spectrogram") + .OrderBy(p => p) + .ToList(); + if (files.Count > 0) + { + return files[0]; + } - var spectrogramFileName = trackNumber >= 0 ? $"{hash}-{trackNumber}.spectrogram" : $"{hash}.spectrogram"; + return bare; + } - return Path.Combine(dir, spectrogramFileName); + return Path.Combine(dir, $"{hash}-{trackNumber}.spectrogram"); } public SpectrogramDrawer(int nfft, SKColor[] palette) diff --git a/src/ui/Logic/MergeManager.cs b/src/ui/Logic/MergeManager.cs index 94ee3de4bb8..e340ac66e16 100644 --- a/src/ui/Logic/MergeManager.cs +++ b/src/ui/Logic/MergeManager.cs @@ -44,6 +44,18 @@ public Subtitle MergeSelectedLines(Subtitle inputSubtitle, int[] selectedIndices } var subtitle = new Subtitle(inputSubtitle, false); + + // Only a contiguous ascending selection can be merged. Validate up front: the + // loop below rewrites continuation marks as it goes, so refusing mid-loop would + // leave the first lines (and the unselected line after them) already mutated. + for (var i = 1; i < selectedIndices.Length; i++) + { + if (selectedIndices[i] != selectedIndices[0] + i) + { + return subtitle; + } + } + var sb = new StringBuilder(); var deleteIndices = new List(); var first = true; @@ -160,7 +172,19 @@ public void MergeSelectedLines(ObservableCollection input return; } - // var subtitle = new Subtitle(inputSubtitle, false); + // Only a contiguous ascending selection can be merged. Validate up front: the + // loop below rewrites continuation marks in the live view-models as it goes, so + // refusing mid-loop would leave the first lines (and the unselected line right + // after them) already mutated. + var selectedGridIndices = selectedItems.Select(inputSubtitle.IndexOf).ToList(); + for (var i = 0; i < selectedGridIndices.Count; i++) + { + if (selectedGridIndices[i] < 0 || (i > 0 && selectedGridIndices[i] != selectedGridIndices[0] + i)) + { + return; + } + } + var sb = new StringBuilder(); var sbOriginal = new StringBuilder(); var deleteIndices = new List(); diff --git a/src/ui/Logic/NetflixQualityCheck/NetflixCheckBridgeGaps.cs b/src/ui/Logic/NetflixQualityCheck/NetflixCheckBridgeGaps.cs index c55aa27534f..a9349c5758f 100644 --- a/src/ui/Logic/NetflixQualityCheck/NetflixCheckBridgeGaps.cs +++ b/src/ui/Logic/NetflixQualityCheck/NetflixCheckBridgeGaps.cs @@ -38,7 +38,10 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) continue; } - var gapInFrames = SubtitleFormat.MillisecondsToFrames(next.StartTime.TotalMilliseconds - p.EndTime.TotalMilliseconds); + // Measured at the video's frame rate, like the two bounds it is compared against. + // With the app's CurrentFrameRate instead, a 470 ms gap on a 23.976 video read as + // 12 frames rather than 11 and fell outside the bridge range entirely. + var gapInFrames = SubtitleFormat.MillisecondsToFrames(next.StartTime.TotalMilliseconds - p.EndTime.TotalMilliseconds, controller.FrameRate); if (gapInFrames > 2 && gapInFrames < halfSecGap && !p.StartTime.IsMaxTime) { var fixedParagraph = new Paragraph(p, false) { EndTime = { TotalMilliseconds = next.StartTime.TotalMilliseconds - twoFramesGap } }; diff --git a/src/ui/Logic/NetflixQualityCheck/NetflixCheckGlyph.cs b/src/ui/Logic/NetflixQualityCheck/NetflixCheckGlyph.cs index 884d8a0dc1c..5f699474cbc 100644 --- a/src/ui/Logic/NetflixQualityCheck/NetflixCheckGlyph.cs +++ b/src/ui/Logic/NetflixQualityCheck/NetflixCheckGlyph.cs @@ -13,6 +13,11 @@ public class NetflixCheckGlyph : INetflixQualityChecker { private static HashSet? _netflixGlyphs = null; + // Every character of every line is probed against the allowed set, so the probe is the whole + // cost of this check. A BMP bitmap answers it with one array load instead of hashing an int; + // astral code points (rare, and outside the table) still go to the hash set. + private static bool[]? _netflixGlyphsBmp = null; + public string Name { get; set; } public NetflixCheckGlyph(string name) @@ -30,7 +35,7 @@ private static HashSet LoadNetflixGlyphs() var glyphFileName = Path.Combine(Se.DataFolder, "netflix_glyphs.txt"); if (!File.Exists(glyphFileName)) { - using var _ = Unpack(); + Unpack(); } var lines = File.ReadAllText(glyphFileName).SplitToLines(); @@ -50,14 +55,30 @@ private static HashSet LoadNetflixGlyphs() list.Add(13); } - _netflixGlyphs = new HashSet(list); + var set = new HashSet(list); + var bmp = new bool[0x10000]; + foreach (var codePoint in set) + { + if ((uint)codePoint < (uint)bmp.Length) + { + bmp[codePoint] = true; + } + } + + _netflixGlyphsBmp = bmp; + _netflixGlyphs = set; return _netflixGlyphs; } - private static async Task Unpack() + /// + /// Synchronous on purpose: nothing in here is actually asynchronous, and the caller reads the + /// unpacked file on the very next line. As an async Task it was started and never awaited + /// (`using var _ = Unpack();` also disposed a Task that may not have completed, which throws). + /// + private static void Unpack() { var zipUri = new Uri("avares://SubtitleEdit/Assets/NetflixGlyphs.zip"); - await using var zipStream = AssetLoader.Open(zipUri); + using var zipStream = AssetLoader.Open(zipUri); var zipUnpacker = new ZipUnpacker(); zipUnpacker.UnpackZipStream(zipStream, Se.DataFolder); } @@ -66,17 +87,35 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) { // Load allowed glyphs var allowedGlyphsSet = LoadNetflixGlyphs(); + var allowedGlyphsBmp = _netflixGlyphsBmp!; foreach (var paragraph in subtitle.Paragraphs) { - for (int pos = 0, actualPos = 0; pos < paragraph.Text.Length; pos += char.IsSurrogatePair(paragraph.Text, pos) ? 2 : 1, actualPos++) + var text = paragraph.Text; + for (int pos = 0, actualPos = 0; pos < text.Length; actualPos++) { - int curCodepoint = char.ConvertToUtf32(paragraph.Text, pos); + var c = text[pos]; + var reportPos = pos; // pos advances below; the report wants the character's own index + int curCodepoint; + if (char.IsSurrogate(c)) + { + // Throws on a lone surrogate, exactly as ConvertToUtf32 did before. + curCodepoint = char.ConvertToUtf32(text, pos); + pos += 2; + } + else + { + curCodepoint = c; + pos++; + } - if (!allowedGlyphsSet.Contains(curCodepoint)) + var allowed = (uint)curCodepoint < (uint)allowedGlyphsBmp.Length + ? allowedGlyphsBmp[curCodepoint] + : allowedGlyphsSet.Contains(curCodepoint); + if (!allowed) { var timeCode = paragraph.StartTime.ToHHMMSSFF(); - var context = NetflixQualityController.StringContext(paragraph.Text, pos, 6); + var context = NetflixQualityController.StringContext(text, reportPos, 6); var comment = string.Format(Se.Language.Tools.NetflixCheckAndFix.GlyphCheckReport, $"U+{curCodepoint:X}", actualPos); controller.AddRecord(paragraph, timeCode, context, comment, false); diff --git a/src/ui/Logic/NetflixQualityCheck/NetflixCheckMaxCps.cs b/src/ui/Logic/NetflixQualityCheck/NetflixCheckMaxCps.cs index b49ed773296..a463ba49087 100644 --- a/src/ui/Logic/NetflixQualityCheck/NetflixCheckMaxCps.cs +++ b/src/ui/Logic/NetflixQualityCheck/NetflixCheckMaxCps.cs @@ -20,23 +20,23 @@ public NetflixCheckMaxCps(string name) public void Check(Subtitle subtitle, NetflixQualityController controller) { - ICalcLength calc = CalcFactory.MakeCalculator(nameof(CalcAll)); + var isJapanese = controller.Language == "ja"; + var calc = CalcFactory.MakeCalculator(controller.Language == "ko" ? nameof(CalcCjk) : nameof(CalcAll)); var charactersPerSecond = controller.CharactersPerSecond; var comment = string.Format(Se.Language.Tools.NetflixCheckAndFix.MaximumXCharactersPerSecond, charactersPerSecond); foreach (var p in subtitle.Paragraphs) { - var jp = new Paragraph(p); - if (controller.Language == "ja") + // Only Japanese needs a scratch copy to strip tags into; every other language read + // the clone unchanged, so the deep copy (paragraph + two time codes) was pure waste + // on every line of the file. + var jp = p; + if (isJapanese) { + jp = new Paragraph(p); jp.Text = HtmlUtil.RemoveHtmlTags(jp.Text, true); jp.Text = NetflixImsc11Japanese.RemoveTags(jp.Text); } - if (controller.Language == "ko") - { - calc = CalcFactory.MakeCalculator(nameof(CalcCjk)); - } - var charactersPerSeconds = jp.GetCharactersPerSecond(calc); if (charactersPerSeconds > charactersPerSecond && !p.StartTime.IsMaxTime) { diff --git a/src/ui/Logic/NetflixQualityCheck/NetflixCheckMaxLineLength.cs b/src/ui/Logic/NetflixQualityCheck/NetflixCheckMaxLineLength.cs index 612acf55e1d..610288668d5 100644 --- a/src/ui/Logic/NetflixQualityCheck/NetflixCheckMaxLineLength.cs +++ b/src/ui/Logic/NetflixQualityCheck/NetflixCheckMaxLineLength.cs @@ -23,8 +23,16 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) { foreach (var p in subtitle.Paragraphs) { + // One record per paragraph - the fix below re-breaks the whole paragraph, so a + // second over-long line would add a duplicate record with the identical fix. + var reported = false; foreach (var line in p.Text.SplitToLines()) { + if (reported) + { + break; + } + if (controller.Language == "ja") { var vertical = p.Text.Contains("{\\an7", StringComparison.Ordinal) || p.Text.Contains("{\\an9", StringComparison.Ordinal); @@ -36,6 +44,7 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) { var comment = Se.Language.Tools.NetflixCheckAndFix.SingleVerticalLineLengthMax11; controller.AddRecord(p, p.StartTime.ToHHMMSSFF(), line.Length.ToString(CultureInfo.InvariantCulture), comment, false); + reported = true; } } else // Horizontal subtitles - Maximum 13 full-width characters per line @@ -44,6 +53,7 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) { var comment = Se.Language.Tools.NetflixCheckAndFix.SingleHorizontalLineLengthMax13; controller.AddRecord(p, p.StartTime.ToHHMMSSFF(), line.Length.ToString(CultureInfo.InvariantCulture), comment); + reported = true; } } } @@ -53,13 +63,15 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) fixedParagraph.Text = Utilities.AutoBreakLine(fixedParagraph.Text, controller.SingleLineMaxLength, controller.SingleLineMaxLength - 3, controller.Language); var comment = string.Format(Se.Language.Tools.NetflixCheckAndFix.SingleLineLengthExceedsX, controller.SingleLineMaxLength); controller.AddRecord(p, fixedParagraph, comment, line.CountCharacters(nameof(CalcCjk), false).ToString(CultureInfo.InvariantCulture), true); + reported = true; } else if (line.CountCharacters(false) > controller.SingleLineMaxLength) { var fixedParagraph = new Paragraph(p, false); fixedParagraph.Text = Utilities.AutoBreakLine(fixedParagraph.Text, controller.SingleLineMaxLength, controller.SingleLineMaxLength - 3, controller.Language); var comment = string.Format(Se.Language.Tools.NetflixCheckAndFix.SingleLineLengthExceedsX, controller.SingleLineMaxLength); - controller.AddRecord(p, fixedParagraph, comment, line.Length.ToString(CultureInfo.InvariantCulture), true ); + controller.AddRecord(p, fixedParagraph, comment, line.Length.ToString(CultureInfo.InvariantCulture), true); + reported = true; } } } diff --git a/src/ui/Logic/NetflixQualityCheck/NetflixCheckNumbersOneToTenSpellOut.cs b/src/ui/Logic/NetflixQualityCheck/NetflixCheckNumbersOneToTenSpellOut.cs index 2865eb338ca..2a68d5f156d 100644 --- a/src/ui/Logic/NetflixQualityCheck/NetflixCheckNumbersOneToTenSpellOut.cs +++ b/src/ui/Logic/NetflixQualityCheck/NetflixCheckNumbersOneToTenSpellOut.cs @@ -33,33 +33,7 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) var m = NumberOneToNine.Match(newText); while (m.Success) { - bool ok = newText.Length <= m.Index + 1 || newText.Length > m.Index + 1 && !":.".Contains(newText[m.Index + 1].ToString()); - if (!ok && newText.Length > m.Index + 1) - { - var rest = newText.Substring(m.Index + m.Length); - if (rest == "." || rest == "?" || rest == "!" || - rest == "." || rest == "?" || rest == "!" || - rest == "." + Environment.NewLine || rest == "?" + Environment.NewLine || rest == "!" + Environment.NewLine || - rest == "." + Environment.NewLine || rest == "?" + Environment.NewLine || rest == "!" + Environment.NewLine) - { - ok = true; - } - } - - if (ok && m.Index + m.Length < newText.Length && newText.Substring(m.Index + m.Length).StartsWith(",")) - { - var rest = newText.Substring(m.Index + 1); - var regex = new Regex(@",\d"); - if (regex.IsMatch(rest)) - { - ok = false; - } - } - - if (ok && m.Index > 0 && ":.".Contains(newText[m.Index - 1].ToString())) - { - ok = false; - } + var ok = ShouldSpellOut(newText, m.Index, m.Length); if (ok) { @@ -72,11 +46,7 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) m = NumberTen.Match(newText); while (m.Success) { - bool ok = newText.Length <= m.Index + 2 || newText.Length > m.Index + 2 && newText[m.Index + 2] != ':'; - if (ok && m.Index > 0 && ":.".Contains(newText[m.Index - 1].ToString())) - { - ok = false; - } + var ok = ShouldSpellOut(newText, m.Index, m.Length); if (ok) { @@ -95,4 +65,60 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) } } + /// + /// Whether the number matched at should be written out. Shared by + /// both loops above: the 1-9 pass and the 10 pass used to make this decision separately, and + /// the 10 pass only ever looked for a colon - so "Chapter 10. Introduction" became + /// "Chapter ten. Introduction" while "Chapter 3. Introduction" was correctly left alone. + /// + private static bool ShouldSpellOut(string text, int index, int length) + { + var after = index + length; + + // A ':' or '.' next to the number means a time code, a decimal or a numbered heading - + // unless the number ends the sentence, where the period belongs to the sentence. + var ok = after >= text.Length || !IsColonOrPeriod(text[after]); + if (!ok && IsSentenceEnding(text.Substring(after))) + { + ok = true; + } + + // A comma followed by a digit is a thousands separator ("1,000"). Only the character + // right after THIS comma decides that: the old test searched the whole rest of the line + // for ",", so a later "4,000" stopped an earlier "3," being written out. + if (ok && after < text.Length && text[after] == ',' && + after + 1 < text.Length && char.IsDigit(text[after + 1])) + { + ok = false; + } + + if (ok && index > 0 && IsColonOrPeriod(text[index - 1])) + { + ok = false; + } + + return ok; + } + + private static bool IsSentenceEnding(string rest) + { + foreach (var ending in new[] { ".", "?", "!" }) + { + if (rest == ending || + rest == ending + "" || + rest == ending + Environment.NewLine || + rest == ending + "" + Environment.NewLine) + { + return true; + } + } + + return false; + } + + /// + /// Replaces ":.".Contains(c.ToString()): the old shape boxed every neighbour + /// character into a one-char string before searching a two-character literal. + /// + private static bool IsColonOrPeriod(char c) => c == ':' || c == '.'; } diff --git a/src/ui/Logic/NetflixQualityCheck/NetflixCheckShotChange.cs b/src/ui/Logic/NetflixQualityCheck/NetflixCheckShotChange.cs index 7d4705a48bf..0fa99f28501 100644 --- a/src/ui/Logic/NetflixQualityCheck/NetflixCheckShotChange.cs +++ b/src/ui/Logic/NetflixQualityCheck/NetflixCheckShotChange.cs @@ -40,6 +40,11 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) shotChanges = shotChanges.Select(sc => Math.Round(sc /= 1.001, 3, MidpointRounding.AwayFromZero)).ToList(); } + // Every frame conversion below uses controller.FrameRate - the video's rate, the same + // one these two bounds come from. They used the app's CurrentFrameRate, so absolute + // times were converted in a different frame space than the bounds they were compared + // against, and the error grew with the time code (at ten minutes, 25 vs 23.976 is a + // difference of about 600 frames). int halfSecGapInFrames = (int)Math.Round(controller.FrameRate / 2, MidpointRounding.AwayFromZero); double twoFramesGap = 1000.0 / controller.FrameRate * 2.0; @@ -47,19 +52,19 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) { // These frame values are invariant across all shot changes - compute once per paragraph // instead of recomputing inside every Where/FirstOrDefault predicate. - var startFrame = SubtitleFormat.MillisecondsToFrames(p.StartTime.TotalMilliseconds); - var endFrame = SubtitleFormat.MillisecondsToFrames(p.EndTime.TotalMilliseconds); + var startFrame = SubtitleFormat.MillisecondsToFrames(p.StartTime.TotalMilliseconds, controller.FrameRate); + var endFrame = SubtitleFormat.MillisecondsToFrames(p.EndTime.TotalMilliseconds, controller.FrameRate); - List previousStartShotChanges = shotChanges.Where(x => SubtitleFormat.MillisecondsToFrames(x * 1000) < startFrame).ToList(); - List nextStartShotChanges = shotChanges.Where(x => SubtitleFormat.MillisecondsToFrames(x * 1000) > startFrame).ToList(); - List previousEndShotChanges = shotChanges.Where(x => SubtitleFormat.MillisecondsToFrames(x * 1000) < endFrame).ToList(); - List nextEndShotChanges = shotChanges.Where(x => SubtitleFormat.MillisecondsToFrames(x * 1000) > endFrame).ToList(); - var onShotChange = shotChanges.FirstOrDefault(x => SubtitleFormat.MillisecondsToFrames(x * 1000) == endFrame); + List previousStartShotChanges = shotChanges.Where(x => SubtitleFormat.MillisecondsToFrames(x * 1000, controller.FrameRate) < startFrame).ToList(); + List nextStartShotChanges = shotChanges.Where(x => SubtitleFormat.MillisecondsToFrames(x * 1000, controller.FrameRate) > startFrame).ToList(); + List previousEndShotChanges = shotChanges.Where(x => SubtitleFormat.MillisecondsToFrames(x * 1000, controller.FrameRate) < endFrame).ToList(); + List nextEndShotChanges = shotChanges.Where(x => SubtitleFormat.MillisecondsToFrames(x * 1000, controller.FrameRate) > endFrame).ToList(); + var onShotChange = shotChanges.FirstOrDefault(x => SubtitleFormat.MillisecondsToFrames(x * 1000, controller.FrameRate) == endFrame); if (previousStartShotChanges.Count > 0) { double nearestStartPrevShotChange = previousStartShotChanges.Aggregate((x, y) => Math.Abs(x - p.StartTime.TotalSeconds) < Math.Abs(y - p.StartTime.TotalSeconds) ? x : y); - var gapToShotChange = SubtitleFormat.MillisecondsToFrames(p.StartTime.TotalMilliseconds - nearestStartPrevShotChange * 1000); + var gapToShotChange = SubtitleFormat.MillisecondsToFrames(p.StartTime.TotalMilliseconds - nearestStartPrevShotChange * 1000, controller.FrameRate); if (gapToShotChange != 0 && gapToShotChange < halfSecGapInFrames) { var fixedParagraph = new Paragraph(p, false); @@ -72,7 +77,7 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) if (nextStartShotChanges.Count > 0) { double nearestStartNextShotChange = nextStartShotChanges.Aggregate((x, y) => Math.Abs(x - p.StartTime.TotalSeconds) < Math.Abs(y - p.StartTime.TotalSeconds) ? x : y); - var gapToShotChange = SubtitleFormat.MillisecondsToFrames(nearestStartNextShotChange * 1000 - p.StartTime.TotalMilliseconds); + var gapToShotChange = SubtitleFormat.MillisecondsToFrames(nearestStartNextShotChange * 1000 - p.StartTime.TotalMilliseconds, controller.FrameRate); var threshold = (int)Math.Round(halfSecGapInFrames * 0.75, MidpointRounding.AwayFromZero); if (gapToShotChange != 0 && gapToShotChange < halfSecGapInFrames) { @@ -98,7 +103,7 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) if (previousEndShotChanges.Count > 0) { double nearestEndPrevShotChange = previousEndShotChanges.Aggregate((x, y) => Math.Abs(x - p.EndTime.TotalSeconds) < Math.Abs(y - p.EndTime.TotalSeconds) ? x : y); - if (SubtitleFormat.MillisecondsToFrames(p.EndTime.TotalMilliseconds - nearestEndPrevShotChange * 1000) < halfSecGapInFrames) + if (SubtitleFormat.MillisecondsToFrames(p.EndTime.TotalMilliseconds - nearestEndPrevShotChange * 1000, controller.FrameRate) < halfSecGapInFrames) { var fixedParagraph = new Paragraph(p, false); fixedParagraph.EndTime.TotalMilliseconds = nearestEndPrevShotChange * 1000 - twoFramesGap; @@ -114,7 +119,7 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) // extend the out-time to the shot change, respecting the two-frame gap from the shot // change." An out-cue already sitting on the two-frame gap is what we would move it // to, so it is not an issue. - var framesToShotChange = SubtitleFormat.MillisecondsToFrames(nearestEndNextShotChange * 1000 - p.EndTime.TotalMilliseconds); + var framesToShotChange = SubtitleFormat.MillisecondsToFrames(nearestEndNextShotChange * 1000 - p.EndTime.TotalMilliseconds, controller.FrameRate); if (framesToShotChange < halfSecGapInFrames && framesToShotChange != 2) { var fixedParagraph = new Paragraph(p, false); diff --git a/src/ui/Logic/NetflixQualityCheck/NetflixCheckStartNumberSpellOut.cs b/src/ui/Logic/NetflixQualityCheck/NetflixCheckStartNumberSpellOut.cs index 1328e30d31f..d924235609f 100644 --- a/src/ui/Logic/NetflixQualityCheck/NetflixCheckStartNumberSpellOut.cs +++ b/src/ui/Logic/NetflixQualityCheck/NetflixCheckStartNumberSpellOut.cs @@ -9,9 +9,13 @@ namespace Nikse.SubtitleEdit.Logic.NetflixQualityCheck; /// public class NetflixCheckStartNumberSpellOut : INetflixQualityChecker { - private static readonly Regex NumberStart = new Regex(@"^\d+ [A-Za-z]", RegexOptions.Compiled); - private static readonly Regex NumberStartInside = new Regex(@"[\.,!] \d+ [A-Za-z]", RegexOptions.Compiled); - private static readonly Regex NumberStartInside2 = new Regex(@"[\.,!]\r\n\d+ [A-Za-z]", RegexOptions.Compiled); + // The digits are captured so the replacement can use the group's own index/length - the + // offsets used to be counted off the whole match, which broke as soon as the separator was + // not exactly the assumed width. The line break alternation matters off Windows, where + // paragraph text is separated by "\n" and the old "\r\n" pattern never matched at all. + private static readonly Regex NumberStart = new Regex(@"^(\d+) [A-Za-z]", RegexOptions.Compiled); + private static readonly Regex NumberStartInside = new Regex(@"[\.,!] (\d+) [A-Za-z]", RegexOptions.Compiled); + private static readonly Regex NumberStartInside2 = new Regex(@"[\.,!](?:\r\n|\n|\r)(\d+) [A-Za-z]", RegexOptions.Compiled); public string Name { get; set; } @@ -26,29 +30,9 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) { var newText = p.Text; - var m = NumberStart.Match(newText); - while (m.Success) - { - var length = m.Length - 2; - newText = newText.Remove(m.Index, length).Insert(m.Index, NetflixHelper.ConvertNumberToString(m.Value.Substring(0, length), true, controller.Language)); - m = NumberStart.Match(newText, m.Index + 1); - } - - m = NumberStartInside.Match(newText); - while (m.Success) - { - var length = m.Length - 4; - newText = newText.Remove(m.Index + 2, length).Insert(m.Index + 2, NetflixHelper.ConvertNumberToString(m.Value.Substring(2, length), true, controller.Language)); - m = NumberStartInside.Match(newText, m.Index + 1); - } - - m = NumberStartInside2.Match(newText); - while (m.Success) - { - var length = m.Length - 5; - newText = newText.Remove(m.Index + 3, length).Insert(m.Index + 3, NetflixHelper.ConvertNumberToString(m.Value.Substring(3, length), true, controller.Language)); - m = NumberStartInside2.Match(newText, m.Index + 1); - } + newText = SpellOutNumbers(NumberStart, newText, controller.Language); + newText = SpellOutNumbers(NumberStartInside, newText, controller.Language); + newText = SpellOutNumbers(NumberStartInside2, newText, controller.Language); if (newText != p.Text) { @@ -59,4 +43,17 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) } } + private static string SpellOutNumbers(Regex regex, string text, string language) + { + var m = regex.Match(text); + while (m.Success) + { + var digits = m.Groups[1]; + text = text.Remove(digits.Index, digits.Length) + .Insert(digits.Index, NetflixHelper.ConvertNumberToString(digits.Value, true, language)); + m = regex.Match(text, m.Index + 1); + } + + return text; + } } diff --git a/src/ui/Logic/NetflixQualityCheck/NetflixCheckTextForHiUseBrackets.cs b/src/ui/Logic/NetflixQualityCheck/NetflixCheckTextForHiUseBrackets.cs index accbba583f8..9cf8f600b2d 100644 --- a/src/ui/Logic/NetflixQualityCheck/NetflixCheckTextForHiUseBrackets.cs +++ b/src/ui/Logic/NetflixQualityCheck/NetflixCheckTextForHiUseBrackets.cs @@ -26,7 +26,6 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) foreach (Paragraph p in subtitle.Paragraphs) { string newText = p.Text; - var arr = p.Text.SplitToLines(); if (newText.StartsWith('(') && newText.EndsWith(')')) { newText = "[" + newText.Substring(1, newText.Length - 2) + "]"; @@ -35,11 +34,17 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) { newText = "[" + newText.Substring(1, newText.Length - 2) + "]"; } - else if (arr.Count == 2 && arr[0].StartsWith('-') && arr[1].StartsWith('-')) + // Only the two-line dash branch needs the split, and it can only fire when the text + // starts with a dash - so do not split every line in the file up front. + else if (newText.StartsWith('-') && p.Text.SplitToLines() is { Count: 2 } arr && + arr[0].StartsWith('-') && arr[1].StartsWith('-')) { if ((arr[0].StartsWith("-(", StringComparison.Ordinal) && arr[0].EndsWith(')')) || (arr[0].StartsWith("-{", StringComparison.Ordinal) && arr[0].EndsWith('}') && !IsAssaOverride(arr[0].Substring(1)))) { - arr[0] = "-[" + newText.Substring(2, newText.Length - 3) + "]"; + // arr[0], not newText: newText is still the whole two-line paragraph here, so + // the offered fix came out as a mangled, duplicated three-line string. The + // arr[1] branch just below is the correct shape. + arr[0] = "-[" + arr[0].Substring(2, arr[0].Length - 3) + "]"; } if ((arr[1].StartsWith("-(", StringComparison.Ordinal) && arr[1].EndsWith(')')) || (arr[1].StartsWith("-{", StringComparison.Ordinal) && arr[1].EndsWith('}') && !IsAssaOverride(arr[1].Substring(1)))) { diff --git a/src/ui/Logic/NetflixQualityCheck/NetflixCheckTwoFramesGap.cs b/src/ui/Logic/NetflixQualityCheck/NetflixCheckTwoFramesGap.cs index 39bd8cb2069..e3bef77b647 100644 --- a/src/ui/Logic/NetflixQualityCheck/NetflixCheckTwoFramesGap.cs +++ b/src/ui/Logic/NetflixQualityCheck/NetflixCheckTwoFramesGap.cs @@ -29,7 +29,13 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) { Paragraph p = subtitle.Paragraphs[index]; var next = subtitle.GetParagraphOrDefault(index + 1); - if (next != null && SubtitleFormat.MillisecondsToFrames(next.StartTime.TotalMilliseconds - p.EndTime.TotalMilliseconds) < 2 && !p.StartTime.IsMaxTime) + // controller.FrameRate, not the app's CurrentFrameRate: the threshold above is + // computed from the video's frame rate, so measuring the gap with a different one + // let real violations through (23.976 video with the 25 default: a 61 ms gap is + // 1.5 frames but measured as 2, so it was never flagged). + if (next != null && + SubtitleFormat.MillisecondsToFrames(next.StartTime.TotalMilliseconds - p.EndTime.TotalMilliseconds, controller.FrameRate) < 2 && + !p.StartTime.IsMaxTime) { var fixedParagraph = new Paragraph(p, false) { EndTime = { TotalMilliseconds = next.StartTime.TotalMilliseconds - twoFramesGap } }; string comment; diff --git a/src/ui/Logic/NetflixQualityCheck/NetflixCheckWhiteSpace.cs b/src/ui/Logic/NetflixQualityCheck/NetflixCheckWhiteSpace.cs index 5e0c30672de..cee96cc90b6 100644 --- a/src/ui/Logic/NetflixQualityCheck/NetflixCheckWhiteSpace.cs +++ b/src/ui/Logic/NetflixQualityCheck/NetflixCheckWhiteSpace.cs @@ -31,15 +31,20 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) { foreach (Paragraph p in subtitle.Paragraphs) { - // Line endings - if (LineEndingSpaceBefore.IsMatch(p.Text)) + // Line endings. Both patterns are anchored, so they can only match when the very + // first / very last character is white space - and on a normal subtitle line neither + // is. Testing that one character first keeps the regex engine out of the loop + // without changing what either pattern accepts (note "$" also matches before a + // trailing newline, which is why the guard tests the character, not the pattern). + var text = p.Text; + if (text.Length > 1 && char.IsWhiteSpace(text[0]) && LineEndingSpaceBefore.IsMatch(text)) { AddWhiteSpaceWarning(p, controller, Se.Language.Tools.NetflixCheckAndFix.WhiteSpaceLineEnding, 1); } - if (LineEndingSpaceAfter.IsMatch(p.Text)) + if (text.Length > 1 && char.IsWhiteSpace(text[text.Length - 1]) && LineEndingSpaceAfter.IsMatch(text)) { - AddWhiteSpaceWarning(p, controller, Se.Language.Tools.NetflixCheckAndFix.WhiteSpaceLineEnding, p.Text.Length); + AddWhiteSpaceWarning(p, controller, Se.Language.Tools.NetflixCheckAndFix.WhiteSpaceLineEnding, text.Length); } // Spaces before punctuation diff --git a/src/ui/Logic/Ocr/GoogleLens/Core.cs b/src/ui/Logic/Ocr/GoogleLens/Core.cs index 85b871fbb91..502f89e2dfe 100644 --- a/src/ui/Logic/Ocr/GoogleLens/Core.cs +++ b/src/ui/Logic/Ocr/GoogleLens/Core.cs @@ -3,6 +3,7 @@ using SkiaSharp; using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; @@ -105,8 +106,8 @@ public async Task> FetchAsync(HttpContent formdata, int[] originalD { "s", "4" }, { "re", "df" }, { "stcs", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString() }, - { "vpw", HeaderData.Config["viewport"].ToString()! }, - { "vph", HeaderData.Config["viewport"].ToString()! }, + { "vpw", HeaderData.ViewportWidth.ToString(CultureInfo.InvariantCulture) }, + { "vph", HeaderData.ViewportHeight.ToString(CultureInfo.InvariantCulture) }, { "ep", "subb" } }; diff --git a/src/ui/Logic/Ocr/GoogleLens/HeaderData.cs b/src/ui/Logic/Ocr/GoogleLens/HeaderData.cs index dc18b9944b8..2402d70991c 100644 --- a/src/ui/Logic/Ocr/GoogleLens/HeaderData.cs +++ b/src/ui/Logic/Ocr/GoogleLens/HeaderData.cs @@ -4,6 +4,11 @@ namespace Nikse.SubtitleEdit.Logic.Ocr.GoogleLens; internal class HeaderData { + // The request's "vpw"/"vph" query values. Kept as two ints: the int[] "viewport" config + // entry was being stringified with ToString(), which sent "System.Int32[]" for both. + public const int ViewportWidth = 1920; + public const int ViewportHeight = 1080; + public static Dictionary Config = []; public static Dictionary Cookies = []; public static Dictionary GenerateHeaders() @@ -46,7 +51,7 @@ public static void PopulateConfig() { "sbisrc", $"Google Chrome {chromeVersion} (Official) Windows" }, { "userAgent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" }, { "endpoint", Constants.LENS_ENDPOINT }, - { "viewport", new int[] { 1920, 1080 } }, + { "viewport", new int[] { ViewportWidth, ViewportHeight } }, { "headers", new Dictionary() }, { "fetchOptions", new Dictionary() } }; diff --git a/src/ui/Logic/Ocr/GoogleLens/LensCore.cs b/src/ui/Logic/Ocr/GoogleLens/LensCore.cs index a96276c0b63..b73e95b0e3b 100644 --- a/src/ui/Logic/Ocr/GoogleLens/LensCore.cs +++ b/src/ui/Logic/Ocr/GoogleLens/LensCore.cs @@ -91,9 +91,16 @@ public void UpdateOptions(Dictionary options) ParseCookies(); } - protected byte[] CreateLensProtoRequest(byte[] imageBytes, int width, int height) + protected byte[] CreateLensProtoRequest(byte[] imageBytes, int width, int height, string? twoLetterLanguageCode = null) { - var targetLanguage = _config.GetValueOrDefault("targetLanguage") as string ?? "en"; + // Prefer the language the caller was given. Nothing ever calls UpdateOptions and the Lens + // instance is constructed with no config, so the config value is always the "en" default - + // the request's LocaleContext.Language was pinned to English whatever OCR language the + // user picked, even though ScanByData already threads the code into the Accept-Language + // header. + var targetLanguage = !string.IsNullOrEmpty(twoLetterLanguageCode) + ? twoLetterLanguageCode + : _config.GetValueOrDefault("targetLanguage") as string ?? "en"; var region = _config.GetValueOrDefault("region") as string ?? "US"; var timeZone = _config.GetValueOrDefault("timeZone") as string ?? "America/New_York"; @@ -345,7 +352,7 @@ public async Task ScanByData(byte[] uint8Array, string mime, int[] o var actualDimensions = Helper.ImageDimensionsFromData(uint8Array); - var serializedRequest = CreateLensProtoRequest(uint8Array, actualDimensions.Width, actualDimensions.Height); + var serializedRequest = CreateLensProtoRequest(uint8Array, actualDimensions.Width, actualDimensions.Height, twoLetterLanguageCode); var serverResponse = await SendProtoRequest(serializedRequest, twoLetterLanguageCode); return ParseLensProtoResponse(serverResponse, originalDimensions ?? new[] { actualDimensions.Width, actualDimensions.Height }); diff --git a/src/ui/Logic/Platform/Windows/FileTypeAssociations.cs b/src/ui/Logic/Platform/Windows/FileTypeAssociations.cs index 14d0256f1d6..cb3629e95b5 100644 --- a/src/ui/Logic/Platform/Windows/FileTypeAssociations.cs +++ b/src/ui/Logic/Platform/Windows/FileTypeAssociations.cs @@ -30,10 +30,14 @@ internal static bool IsDefault(string ext, string appName) // This key is read-only for apps, but we can read it to see who won the 'war'. using (var userChoice = Registry.CurrentUser.OpenSubKey($@"Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\{ext}\UserChoice")) { + // UserChoice is authoritative when present: it is what Explorer's "Always open + // with" writes. Only falling through on a MATCH meant that after the user + // reassigned the extension to another app, the legacy Software\Classes default - + // which SE wrote itself when it registered - still answered "yes, SE is default". var progIdValue = userChoice?.GetValue("Progid") as string; - if (progIdValue == progId) + if (!string.IsNullOrEmpty(progIdValue)) { - return true; + return progIdValue == progId; } } diff --git a/src/ui/Logic/Se4Setup/Se4SettingsXmlImporter.cs b/src/ui/Logic/Se4Setup/Se4SettingsXmlImporter.cs new file mode 100644 index 00000000000..d3f9924352d --- /dev/null +++ b/src/ui/Logic/Se4Setup/Se4SettingsXmlImporter.cs @@ -0,0 +1,583 @@ +using Avalonia.Media; +using Nikse.SubtitleEdit.Core.Enums; +using Nikse.SubtitleEdit.Features.Options.Shortcuts; +using Nikse.SubtitleEdit.Logic.Config; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; + +namespace Nikse.SubtitleEdit.Logic.Se4Setup; + +/// +/// Reads an SE 4 Settings.xml for the Settings import dialog, which otherwise only accepts +/// an SE 5 Settings.json export (#14309 - users coming from 4.0.12 have nothing else). +/// +/// Only the categories the dialog offers are mapped: rules, syntax coloring, waveform, shortcuts, +/// appearance and auto-translate. Everything is applied field by field onto the *current* settings +/// rather than by replacing a whole section: SE 4's tree is not SE 5's, so an unmapped SE 5 field +/// must keep its current value instead of being reset to a default (which is what a section-level +/// assignment - the JSON import path - would do). +/// +public static class Se4SettingsXmlImporter +{ + public sealed class Se4SettingsFile + { + internal XElement? General { get; init; } + internal XElement? Tools { get; init; } + internal XElement? VideoControls { get; init; } + internal XElement? Shortcuts { get; init; } + internal string Xml { get; init; } = string.Empty; + + public bool HasRules => General != null; + public bool HasSyntaxColoring => Tools != null || General != null; + public bool HasWaveform => VideoControls != null; + public bool HasAppearance => General != null; + public bool HasAutoTranslate => Tools != null; + public bool HasShortcuts => Shortcuts != null; + } + + /// + /// Cheap check on the file *content* - the import dialog takes both .json and .xml, and a + /// user pointing at an SE 4 file that has been renamed should still get the XML path. + /// + public static bool LooksLikeXml(string text) + { + return text.TrimStart('', ' ', '\t', '\r', '\n').StartsWith('<'); + } + + /// + /// Returns null when the text is not an SE 4 Settings.xml (unparseable, or a different + /// SE 4 XML such as an exported replace list or shortcut file). + /// + public static Se4SettingsFile? Parse(string xml) + { + XDocument doc; + try + { + doc = XDocument.Parse(xml); + } + catch + { + return null; + } + + var root = doc.Root; + if (root == null || root.Name.LocalName != "Settings") + { + return null; + } + + var file = new Se4SettingsFile + { + Xml = xml, + General = Section(root, "General"), + Tools = Section(root, "Tools"), + VideoControls = Section(root, "VideoControls"), + Shortcuts = Section(root, "Shortcuts"), + }; + + return file.HasRules || file.HasWaveform || file.HasShortcuts || file.HasAutoTranslate + ? file + : null; + } + + private static XElement? Section(XElement root, string name) + { + var section = root.Elements().FirstOrDefault(e => e.Name.LocalName == name); + return section != null && section.HasElements ? section : null; + } + + /// + /// The "Rules" half of SE 4's General settings - the same values SE 5 keeps in + /// and the Settings window's Rules page shows. + /// + public static void ApplyRules(Se4SettingsFile file) + { + var general = file.General; + if (general == null) + { + return; + } + + var g = Se.Settings.General; + + SetInt(general, "SubtitleLineMaximumLength", 1, 500, v => g.SubtitleLineMaximumLength = v); + SetInt(general, "MaxNumberOfLines", 1, 20, v => g.MaxNumberOfLines = v); + SetInt(general, "MaxNumberOfLinesPlusAbort", 0, 100, v => g.MaxNumberOfLinesPlusAbort = v); + SetInt(general, "MergeLinesShorterThan", 0, 500, v => g.UnbreakLinesShorterThan = v); + SetInt(general, "SubtitleMinimumDisplayMilliseconds", 0, 100000, v => g.SubtitleMinimumDisplayMilliseconds = v); + SetInt(general, "SubtitleMaximumDisplayMilliseconds", 0, 500000, v => g.SubtitleMaximumDisplayMilliseconds = v); + SetInt(general, "SubtitleLineMaximumPixelWidth", 0, 10000, v => g.SubtitleLineMaximumPixelWidth = v); + + // SE 5 keeps the minimum gap as ms *and* frames; SE 4 only has the ms value, so leave the + // frame count alone rather than deriving one from a frame rate the file may not carry. + SetInt(general, "MinimumMillisecondsBetweenLines", 0, 100000, v => g.MinimumBetweenLines.Milliseconds = v); + + SetDouble(general, "SubtitleMaximumCharactersPerSeconds", 0, 1000, v => g.SubtitleMaximumCharactersPerSeconds = v); + SetDouble(general, "SubtitleOptimalCharactersPerSeconds", 0, 1000, v => g.SubtitleOptimalCharactersPerSeconds = v); + SetDouble(general, "SubtitleMaximumWordsPerMinute", 0, 10000, v => g.SubtitleMaximumWordsPerMinute = v); + + // Same guard as the SE 4 setup importer: SE 4 wrote the frame rate with the invariant + // culture but could read back a locale-mangled one ("23,976" -> 23976). + SetDouble(general, "DefaultFrameRate", 10, 200, v => g.DefaultFrameRate = v); + SetDouble(general, "CurrentFrameRate", 10, 200, v => g.CurrentFrameRate = v); + + // Stored as strings in SE 5 but parsed back into the libse enums (SeGeneral.ToProfile + // throws on an unknown name), so only accept values that still exist. + SetEnumName(general, "DialogStyle", v => g.DialogStyle = v); + SetEnumName(general, "ContinuationStyle", v => g.ContinuationStyle = v); + + var cpsStrategy = Value(general, "CpsLineLengthStrategy"); + if (!string.IsNullOrWhiteSpace(cpsStrategy)) + { + g.CpsLineLengthStrategy = cpsStrategy; + } + } + + /// + /// SE 4 keeps the syntax-coloring toggles in Tools and the "too wide" measuring font in + /// General; SE 5 has all of it on next to the rules it colors. + /// + public static void ApplySyntaxColoring(Se4SettingsFile file) + { + var g = Se.Settings.General; + var tools = file.Tools; + + if (tools != null) + { + SetBool(tools, "ListViewSyntaxColorDurationSmall", v => g.ColorDurationTooShort = v); + SetBool(tools, "ListViewSyntaxColorDurationBig", v => g.ColorDurationTooLong = v); + SetBool(tools, "ListViewSyntaxColorLongLines", v => g.ColorTextTooLong = v); + SetBool(tools, "ListViewSyntaxColorWideLines", v => g.ColorTextTooWide = v); + SetBool(tools, "ListViewSyntaxMoreThanXLines", v => g.ColorTextTooManyLines = v); + SetBool(tools, "ListViewSyntaxColorOverlap", v => g.ColorTimeCodeOverlap = v); + SetBool(tools, "ListViewSyntaxColorGap", v => g.ColorGapTooShort = v); + SetColor(tools, "ListViewSyntaxErrorColor", v => g.ErrorColor = v); + } + + var general = file.General; + if (general != null) + { + SetInt(general, "SubtitleLineMaximumPixelWidth", 0, 10000, v => g.ColorTextTooWidePixels = v); + SetInt(general, "MeasureFontSize", 1, 500, v => g.ColorTextTooWideFontSize = v); + + var measureFontName = Value(general, "MeasureFontName"); + if (!string.IsNullOrWhiteSpace(measureFontName)) + { + g.ColorTextTooWideFontName = measureFontName; + } + } + } + + /// + /// SE 4's waveform lives in VideoControls. Only the values with a real SE 5 counterpart are + /// carried - SE 5's paragraph/shot-change colors and draw style have no SE 4 equivalent and + /// keep whatever the user has now. + /// + public static void ApplyWaveform(Se4SettingsFile file) + { + var videoControls = file.VideoControls; + if (videoControls == null) + { + return; + } + + var w = Se.Settings.Waveform; + + SetColor(videoControls, "WaveformColor", v => w.WaveformColor = v); + SetColor(videoControls, "WaveformSelectedColor", v => w.WaveformSelectedColor = v); + SetColor(videoControls, "WaveformBackgroundColor", v => w.WaveformBackgroundColor = v); + SetColor(videoControls, "WaveformTextColor", v => w.WaveformTextColor = v); + SetColor(videoControls, "WaveformCursorColor", v => w.WaveformCursorColor = v); + + SetBool(videoControls, "WaveformDrawGrid", v => w.DrawGridLines = v); + SetBool(videoControls, "WaveformDrawCps", v => w.WaveformShowCps = v); + SetBool(videoControls, "WaveformFocusOnMouseEnter", v => w.FocusOnMouseOver = v); + SetBool(videoControls, "WaveformSnapToShotChanges", v => w.SnapToShotChanges = v); + SetBool(videoControls, "WaveformTextBold", v => w.WaveformTextFontBold = v); + SetInt(videoControls, "WaveformTextSize", 1, 100, v => w.WaveformTextFontSize = v); + } + + /// + /// Theme, fonts and toolbar buttons. SE 4's icon theme names do not line up with SE 5's, so + /// the icon set is left alone - "Set up like Subtitle Edit 4" is the way to get those. + /// + public static void ApplyAppearance(Se4SettingsFile file) + { + var general = file.General; + if (general == null) + { + return; + } + + var a = Se.Settings.Appearance; + + SetBool(general, "UseDarkTheme", v => a.Theme = v ? UiTheme.ThemeNameDark : UiTheme.ThemeNameLight); + SetColor(general, "DarkThemeBackColor", v => a.DarkModeBackgroundColor = v); + SetColor(general, "DarkThemeForeColor", v => a.DarkModeForegroundColor = v); + + var fontName = Value(general, "SubtitleFontName"); + if (!string.IsNullOrWhiteSpace(fontName)) + { + a.SubtitleTextBoxAndGridFontName = fontName; + } + + SetInt(general, "SubtitleTextBoxFontSize", 1, 500, v => a.SubtitleTextBoxFontSize = v); + SetInt(general, "SubtitleListViewFontSize", 1, 500, v => a.SubtitleGridFontSize = v); + SetBool(general, "SubtitleTextBoxFontBold", v => a.SubtitleTextBoxFontBold = v); + SetBool(general, "CenterSubtitleInTextBox", v => a.SubtitleTextBoxCenterText = v); + SetBool(general, "SubtitleTextBoxSyntaxColor", v => a.SubtitleTextBoxColorTags = v); + + foreach (var (se4Name, apply) in ToolbarMap(a)) + { + SetBool(general, se4Name, apply); + } + } + + // SE 4 setting name -> the SE 5 toolbar toggle it turns on. SE 5 toolbar buttons with no SE 4 + // counterpart (multiple replace, point sync, auto-translate, speech to text, ...) are left as + // they are - SE 4 could not have had an opinion about them. + private static IEnumerable<(string Name, Action Apply)> ToolbarMap(SeAppearance a) + { + yield return ("ShowToolbarNew", v => a.ToolbarShowFileNew = v); + yield return ("ShowToolbarOpen", v => a.ToolbarShowFileOpen = v); + yield return ("ShowToolbarOpenVideo", v => a.ToolbarShowVideoFileOpen = v); + yield return ("ShowToolbarSave", v => a.ToolbarShowSave = v); + yield return ("ShowToolbarSaveAs", v => a.ToolbarShowSaveAs = v); + yield return ("ShowToolbarFind", v => a.ToolbarShowFind = v); + yield return ("ShowToolbarReplace", v => a.ToolbarShowReplace = v); + yield return ("ShowToolbarFixCommonErrors", v => a.ToolbarShowFixCommonErrors = v); + yield return ("ShowToolbarRemoveTextForHi", v => a.ToolbarShowRemoveTextForHi = v); + yield return ("ShowToolbarToggleSourceView", v => a.ToolbarShowSourceView = v); + yield return ("ShowToolbarVisualSync", v => a.ToolbarShowVisualSync = v); + yield return ("ShowToolbarBurnIn", v => a.ToolbarShowBurnIn = v); + yield return ("ShowToolbarSpellCheck", v => a.ToolbarShowSpellCheck = v); + yield return ("ShowToolbarBeautifyTimeCodes", v => a.ToolbarShowBeautifyTimeCodes = v); + yield return ("ShowToolbarSettings", v => a.ToolbarShowSettings = v); + yield return ("ShowToolbarHelp", v => a.ToolbarShowHelp = v); + yield return ("ShowFrameRate", v => a.ToolbarShowFrameRate = v); + } + + /// + /// Engine URLs, models, prompts and API keys. SE 4 prefixed several of these with + /// "AutoTranslate" and SE 5 does not, so the pairs are spelled out. + /// + public static void ApplyAutoTranslate(Se4SettingsFile file) + { + var tools = file.Tools; + if (tools == null) + { + return; + } + + var t = Se.Settings.AutoTranslate; + + foreach (var (se4Name, apply) in AutoTranslateMap(t)) + { + var value = Value(tools, se4Name); + if (!string.IsNullOrWhiteSpace(value)) + { + apply(value); + } + } + + SetInt(tools, "AutoTranslateMaxBytes", 1, int.MaxValue, v => t.RequestMaxBytes = v); + SetInt(tools, "AutoTranslateDelaySeconds", 0, 3600, v => t.RequestDelaySeconds = v); + SetInt(tools, "TranslateViaCopyPasteMaxSize", 1, int.MaxValue, v => t.CopyPasteMaxBlockSize = v); + } + + private static IEnumerable<(string Name, Action Apply)> AutoTranslateMap(SeAutoTranslate t) + { + yield return ("GoogleApiV2Key", v => t.GoogleApiV2Key = v); + yield return ("MicrosoftBingApiId", v => t.MicrosoftBingApiId = v); + yield return ("MicrosoftTranslatorApiKey", v => t.MicrosoftTranslatorApiKey = v); + yield return ("MicrosoftTranslatorTokenEndpoint", v => t.MicrosoftTranslatorTokenEndpoint = v); + yield return ("MicrosoftTranslatorCategory", v => t.MicrosoftTranslatorCategory = v); + + yield return ("AutoTranslateDeepLApiKey", v => t.DeepLApiKey = v); + yield return ("AutoTranslateDeepLUrl", v => t.DeepLUrl = v); + yield return ("AutoTranslateDeepLFormality", v => t.DeepLFormality = v); + yield return ("AutoTranslateDeepLXUrl", v => t.DeepLXUrl = v); + yield return ("AutoTranslateLibreUrl", v => t.LibreTranslateUrl = v); + yield return ("AutoTranslateLibreApiKey", v => t.LibreTranslateApiKey = v); + yield return ("AutoTranslateMyMemoryApiKey", v => t.MyMemoryApiKey = v); + yield return ("AutoTranslateNllbApiUrl", v => t.NllbApiUrl = v); + yield return ("AutoTranslateNllbServeUrl", v => t.NllbServeUrl = v); + yield return ("AutoTranslateNllbServeModel", v => t.NllbServeModel = v); + yield return ("AutoTranslateSeamlessM4TUrl", v => t.SeamlessM4TUrl = v); + yield return ("AutoTranslatePapagoApiKeyId", v => t.PapagoApiKeyId = v); + yield return ("AutoTranslatePapagoApiKey", v => t.PapagoApiKey = v); + yield return ("AutoTranslateMistralApiKey", v => t.MistralApiKey = v); + yield return ("AutoTranslateMistralUrl", v => t.MistralUrl = v); + yield return ("AutoTranslateMistralModel", v => t.MistralModel = v); + yield return ("AutoTranslateMistralPrompt", v => t.MistralPrompt = v); + + yield return ("ChatGptUrl", v => t.ChatGptUrl = v); + yield return ("ChatGptPrompt", v => t.ChatGptPrompt = v); + yield return ("ChatGptApiKey", v => t.ChatGptApiKey = v); + yield return ("ChatGptModel", v => t.ChatGptModel = v); + yield return ("GroqUrl", v => t.GroqUrl = v); + yield return ("GroqPrompt", v => t.GroqPrompt = v); + yield return ("GroqApiKey", v => t.GroqApiKey = v); + yield return ("GroqModel", v => t.GroqModel = v); + yield return ("DeepSeekUrl", v => t.DeepSeekUrl = v); + yield return ("DeepSeekPrompt", v => t.DeepSeekPrompt = v); + yield return ("DeepSeekApiKey", v => t.DeepSeekApiKey = v); + yield return ("DeepSeekModel", v => t.DeepSeekModel = v); + yield return ("AvalAiUrl", v => t.AvalAiUrl = v); + yield return ("AvalAiPrompt", v => t.AvalAiPrompt = v); + yield return ("AvalAiApiKey", v => t.AvalAiApiKey = v); + yield return ("AvalAiModel", v => t.AvalAiModel = v); + yield return ("OpenRouterUrl", v => t.OpenRouterUrl = v); + yield return ("OpenRouterPrompt", v => t.OpenRouterPrompt = v); + yield return ("OpenRouterApiKey", v => t.OpenRouterApiKey = v); + yield return ("OpenRouterModel", v => t.OpenRouterModel = v); + yield return ("LmStudioApiUrl", v => t.LmStudioApiUrl = v); + yield return ("LmStudioModel", v => t.LmStudioModel = v); + yield return ("LmStudioPrompt", v => t.LmStudioPrompt = v); + yield return ("OllamaApiUrl", v => t.OllamaUrl = v); + yield return ("OllamaModels", v => t.OllamaModels = v); + yield return ("OllamaModel", v => t.OllamaModel = v); + yield return ("OllamaPrompt", v => t.OllamaPrompt = v); + yield return ("KoboldCppUrl", v => t.KoboldCppUrl = v); + yield return ("KoboldCppPrompt", v => t.KoboldCppPrompt = v); + yield return ("AnthropicApiUrl", v => t.AnthropicApiUrl = v); + yield return ("AnthropicPrompt", v => t.AnthropicPrompt = v); + yield return ("AnthropicApiKey", v => t.AnthropicApiKey = v); + yield return ("AnthropicApiModel", v => t.AnthropicApiModel = v); + yield return ("BaiduUrl", v => t.BaiduUrl = v); + yield return ("BaiduApiKey", v => t.BaiduApiKey = v); + yield return ("GeminiProApiKey", v => t.GeminiProApiKey = v); + yield return ("GeminiModel", v => t.GeminiModel = v); + yield return ("TranslateViaCopyPasteSeparator", v => t.CopyPasteLineSeparator = v); + } + + /// + /// Shortcuts go through the existing SE 4 importer (it also carries the custom-search slots + /// and the toggle-custom-tags pair the keys point at). A merge, like every other SE 4 import: + /// only actions the file actually binds are replaced. + /// + /// + /// Called with the imported bindings before they are merged in. SE 4 runs on Windows, so its + /// modifiers are the Ctrl-based ones; importing on macOS hands them here to be swapped for the + /// Cmd key the SE 5 defaults use. + /// + public static Se4ShortcutsImporter.ImportResult ApplyShortcuts( + Se4SettingsFile file, + Action>? normalizeShortcuts = null) + { + var result = Se4ShortcutsImporter.ImportFromXml(file.Xml); + normalizeShortcuts?.Invoke(result.Shortcuts); + + foreach (var shortcut in result.Shortcuts) + { + var existing = Se.Settings.Shortcuts.FirstOrDefault(s => s.ActionName == shortcut.ActionName); + if (existing != null) + { + Se.Settings.Shortcuts.Remove(existing); + } + + Se.Settings.Shortcuts.Add(shortcut); + } + + ApplyCustomSearches(result); + ApplyCustomTags(result); + + return result; + } + + // Slot N of SE 4 is slot N of SE 5, and the key that fires it travels in the same import - + // a key pointing at a slot that searches a different site is worse than no key at all. + private static void ApplyCustomSearches(Se4ShortcutsImporter.ImportResult result) + { + foreach (var (slotNumber, search) in result.CustomSearches) + { + if (slotNumber < 1 || slotNumber > Se.CustomSearchSlotCount) + { + continue; + } + + Se.Settings.SetCustomSearch(slotNumber, search.Name, search.Url); + ShortcutsMain.CommandTranslationLookup[$"CustomSearch{slotNumber}Command"] = + ShortcutsMain.GetSearchViaTitle(slotNumber, search.Name, search.Url); + } + } + + // SE 4 has one "toggle custom tags" pair; SE 5 has eight "surround with" slots. Park the pair + // on the slot already holding it, or on the first free one, and move the imported key onto + // that slot - see ShortcutsViewModel.ApplySe4CustomTags, which does the same for the Shortcuts + // window. When every slot is taken the key stays on slot 1: overwriting a pair the user is + // using would cost more than the import gains. + private static void ApplyCustomTags(Se4ShortcutsImporter.ImportResult result) + { + var start = result.CustomTagsStart; + var end = result.CustomTagsEnd; + if (start == null || end == null) + { + return; + } + + var importedShortcut = result.Shortcuts + .FirstOrDefault(s => s.ActionName == nameof(Features.Main.MainViewModel.SurroundWith1Command)); + if (importedShortcut == null) + { + return; + } + + var slotNumber = -1; + for (var i = 1; i <= Se.SurroundWithSlotCount; i++) + { + if (Se.Settings.GetSurroundLeft(i) == start && Se.Settings.GetSurroundRight(i) == end) + { + slotNumber = i; + break; + } + } + + for (var i = 1; slotNumber < 0 && i <= Se.SurroundWithSlotCount; i++) + { + if (string.IsNullOrEmpty(Se.Settings.GetSurroundLeft(i)) && + string.IsNullOrEmpty(Se.Settings.GetSurroundRight(i))) + { + slotNumber = i; + } + } + + if (slotNumber < 0) + { + return; + } + + Se.Settings.SetSurround(slotNumber, start, end); + + var commandName = $"SurroundWith{slotNumber}Command"; + var existing = Se.Settings.Shortcuts.FirstOrDefault(s => s.ActionName == commandName); + if (existing != null) + { + Se.Settings.Shortcuts.Remove(existing); + } + + // The shortcut was added as SurroundWith1Command above; move it to the slot the pair + // landed on (it is the same object, so the list entry moves with it). + importedShortcut.ActionName = commandName; + if (!Se.Settings.Shortcuts.Contains(importedShortcut)) + { + Se.Settings.Shortcuts.Add(importedShortcut); + } + + ShortcutsMain.CommandTranslationLookup[commandName] = + ShortcutsMain.GetSurroundWithTitle(slotNumber, start, end); + } + + private static string? Value(XElement parent, string name) + { + return parent.Elements().FirstOrDefault(e => e.Name.LocalName == name)?.Value; + } + + private static void SetInt(XElement parent, string name, int min, int max, Action apply) + { + var value = Value(parent, name); + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number) && + number >= min && + number <= max) + { + apply(number); + } + } + + private static void SetDouble(XElement parent, string name, double min, double max, Action apply) + { + var value = Value(parent, name); + if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && + number >= min && + number <= max) + { + apply(number); + } + } + + private static void SetBool(XElement parent, string name, Action apply) + { + if (bool.TryParse(Value(parent, name), out var value)) + { + apply(value); + } + } + + private static void SetEnumName(XElement parent, string name, Action apply) where T : struct, Enum + { + var value = Value(parent, name); + if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse(value, out var parsed)) + { + apply(parsed.ToString()); + } + } + + // SE 5 stores hex; SE 4 has two spellings, so both are accepted - see TryParseSe4Color. + private static void SetColor(XElement parent, string name, Action apply) + { + if (TryParseSe4Color(Value(parent, name), out var color)) + { + apply(color.FromColorToHex()); + } + } + + /// + /// A color as SE 4's Settings.xml spells it. Most are System.Drawing's signed 32-bit ARGB + /// (Color.ToArgb()), but the dark theme colors go through SE 4's own ToHtml() - + /// Utilities.ColorToHexWithTransparency, i.e. "#rrggbbaa" with the alpha *last*. Reading + /// only the integer form dropped DarkThemeBackColor and DarkThemeForeColor without a word, so + /// an imported SE 4 dark theme came up in SE 5's default colors. + /// + internal static bool TryParseSe4Color(string? value, out Color color) + { + color = default; + var text = value?.Trim(); + if (string.IsNullOrEmpty(text)) + { + return false; + } + + if (text[0] == '#') + { + var hex = text.Substring(1); + if (hex.Length != 6 && hex.Length != 8) + { + return false; + } + + foreach (var c in hex) + { + if (!Uri.IsHexDigit(c)) + { + return false; + } + } + + var rgba = uint.Parse(hex.PadRight(8, 'f'), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + color = Color.FromArgb( + (byte)(rgba & 0xFF), // alpha last, unlike ToArgb + (byte)((rgba >> 24) & 0xFF), + (byte)((rgba >> 16) & 0xFF), + (byte)((rgba >> 8) & 0xFF)); + return true; + } + + // SE 4 itself still reads a bare integer here (its DarkThemeBackColor reader keeps a + // "-14803426" fallback), so a settings file written by an older SE 4 is accepted too. + if (!int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var argb)) + { + return false; + } + + var bytes = unchecked((uint)argb); + color = Color.FromArgb( + (byte)((bytes >> 24) & 0xFF), + (byte)((bytes >> 16) & 0xFF), + (byte)((bytes >> 8) & 0xFF), + (byte)(bytes & 0xFF)); + return true; + } +} diff --git a/src/ui/Logic/Se4Setup/Se4SetupApplier.cs b/src/ui/Logic/Se4Setup/Se4SetupApplier.cs index a7828256757..a18825b860c 100644 --- a/src/ui/Logic/Se4Setup/Se4SetupApplier.cs +++ b/src/ui/Logic/Se4Setup/Se4SetupApplier.cs @@ -72,7 +72,7 @@ private static void ApplyShortcuts(MainViewModel vm, string? settingsXmlPath, Se } result.ShortcutsImported = imported.Shortcuts.Count; - result.ShortcutsSkipped = imported.SkippedNoMapping; + result.ShortcutsSkipped = imported.SkippedNoMapping + imported.SkippedDuplicate; return; } diff --git a/src/ui/Logic/SevenZipExtractor/Unpacker.cs b/src/ui/Logic/SevenZipExtractor/Unpacker.cs index 2ab20efbdaa..bf5c4a74bd5 100644 --- a/src/ui/Logic/SevenZipExtractor/Unpacker.cs +++ b/src/ui/Logic/SevenZipExtractor/Unpacker.cs @@ -146,6 +146,11 @@ private static void Unpack7ZipViaSystemExecutable(string sevenZipPath, string te process.Start(); + // Drain stderr concurrently: 7-zip writes one line per problem file, and once it + // exceeds the 4 KB pipe buffer the child blocks on the write, stdout's EndOfStream + // never returns, and the dialog hangs with no way out. + var errorReader = process.StandardError.ReadToEndAsync(); + while (!process.StandardOutput.EndOfStream) { if (cancellationTokenSource.IsCancellationRequested) @@ -158,7 +163,12 @@ private static void Unpack7ZipViaSystemExecutable(string sevenZipPath, string te { // Ignore if already exited } - return; + + // Throw rather than return: a bare return was indistinguishable from a + // completed unpack, so callers reported the install as successful, deleted + // the archive, and left a half-extracted folder that passes their + // "is it installed?" file check. + throw new OperationCanceledException(cancellationTokenSource.Token); } var line = process.StandardOutput.ReadLine(); @@ -176,7 +186,7 @@ private static void Unpack7ZipViaSystemExecutable(string sevenZipPath, string te if (process.ExitCode != 0) { - var error = process.StandardError.ReadToEnd(); + var error = errorReader.Result; throw new Exception($"7zz extraction failed with exit code {process.ExitCode} (command: \"{sevenZipPath}\" x \"{tempFileName}\" -o\"{extractPath}\" -y): {error}"); } @@ -257,6 +267,9 @@ private static void Unpack7ZipVia77zExecutable(string tempFileName, string dir, throw new Exception($"Could not start 7-zip (command: \"{sevenZipPath}\" x \"{tempFileName}\" -o\"{extractPath}\" -y)", exception); } + // See the note above: stderr must be drained while 7-zip is still running. + var errorReader = process.StandardError.ReadToEndAsync(); + while (!process.StandardOutput.EndOfStream) { if (cancellationTokenSource.IsCancellationRequested) @@ -269,7 +282,12 @@ private static void Unpack7ZipVia77zExecutable(string tempFileName, string dir, { // Ignore if already exited } - return; + + // Throw rather than return: a bare return was indistinguishable from a + // completed unpack, so callers reported the install as successful, deleted + // the archive, and left a half-extracted folder that passes their + // "is it installed?" file check. + throw new OperationCanceledException(cancellationTokenSource.Token); } var line = process.StandardOutput.ReadLine(); @@ -287,7 +305,7 @@ private static void Unpack7ZipVia77zExecutable(string tempFileName, string dir, if (process.ExitCode != 0) { - var error = process.StandardError.ReadToEnd(); + var error = errorReader.Result; throw new Exception($"7zip extraction failed with exit code {process.ExitCode} (command: \"{sevenZipPath}\" x \"{tempFileName}\" -o\"{extractPath}\" -y): {error}"); } @@ -338,7 +356,10 @@ private static void MoveDirectoryContents(string sourceDir, string targetDir, Ca { if (cancellationTokenSource.IsCancellationRequested) { - return; + // Throw, as every other cancellation point here does: a bare return made a + // cancelled move look like a finished one, so the caller reported success and + // its finally block deleted the temp folder with the un-moved files still in it. + throw new OperationCanceledException(cancellationTokenSource.Token); } var fileName = Path.GetFileName(file); @@ -363,7 +384,10 @@ private static void MoveDirectoryContents(string sourceDir, string targetDir, Ca { if (cancellationTokenSource.IsCancellationRequested) { - return; + // Throw, as every other cancellation point here does: a bare return made a + // cancelled move look like a finished one, so the caller reported success and + // its finally block deleted the temp folder with the un-moved files still in it. + throw new OperationCanceledException(cancellationTokenSource.Token); } var dirName = Path.GetFileName(subDir); @@ -385,7 +409,9 @@ public static void Extract7ZipSlow(string tempFileName, string dir, string? skip { if (cancellationTokenSource.IsCancellationRequested) { - return; + // See the process-based paths above: a cancelled unpack must not look like a + // completed one to the caller. + throw new OperationCanceledException(cancellationTokenSource.Token); } if (!string.IsNullOrEmpty(reader.Entry.Key)) diff --git a/src/ui/Logic/ShortcutsMain.cs b/src/ui/Logic/ShortcutsMain.cs index 241cabd8346..ef6293fe124 100644 --- a/src/ui/Logic/ShortcutsMain.cs +++ b/src/ui/Logic/ShortcutsMain.cs @@ -1,4 +1,4 @@ -using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Input; using Nikse.SubtitleEdit.Features.Main; using Nikse.SubtitleEdit.Features.Options.Shortcuts; using Nikse.SubtitleEdit.Logic.Config; @@ -82,6 +82,53 @@ public static void ReloadCommandTranslations() } } + /// + /// Display name for a "surround with" slot: the configured tags when it has any, otherwise just + /// the slot number - the extra slots from #14232 ship unconfigured. + /// + public static string GetSurroundWithTitle(int slotNumber) + { + var left = Se.Settings.GetSurroundLeft(slotNumber); + var right = Se.Settings.GetSurroundRight(slotNumber); + return GetSurroundWithTitle(slotNumber, left, right); + } + + public static string GetSurroundWithTitle(int slotNumber, string left, string right) + { + if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right)) + { + return string.Format(Se.Language.Options.Shortcuts.SurroundWithNumberX, slotNumber.ToString()); + } + + return string.Format(Se.Language.Options.Shortcuts.SurroundWithXY, left, right); + } + + /// + /// Display name for a "search via" slot: the configured name when it has one, otherwise the + /// URL, otherwise just the slot number - the extra slots ship unconfigured. + /// + public static string GetSearchViaTitle(int slotNumber) + { + var name = Se.Settings.GetCustomSearchName(slotNumber); + var url = Se.Settings.GetCustomSearchUrl(slotNumber); + return GetSearchViaTitle(slotNumber, name, url); + } + + public static string GetSearchViaTitle(int slotNumber, string name, string url) + { + if (!string.IsNullOrWhiteSpace(name)) + { + return string.Format(Se.Language.Options.Shortcuts.SearchViaX, name); + } + + if (!string.IsNullOrWhiteSpace(url)) + { + return string.Format(Se.Language.Options.Shortcuts.SearchViaX, url); + } + + return string.Format(Se.Language.Options.Shortcuts.SearchViaNumberX, slotNumber.ToString()); + } + private static Dictionary BuildCommandTranslations() { return new Dictionary @@ -102,10 +149,15 @@ private static Dictionary BuildCommandTranslations() { nameof(MainViewModel.DoAlignmentAn9Command), string.Format(Se.Language.General.AlignmentX, "an9")}, { nameof(MainViewModel.AddOrEditBookmarkCommand), Se.Language.Options.Shortcuts.AddOrEditBookmark}, { nameof(MainViewModel.ToggleBookmarkSelectedLinesNoTextCommand), Se.Language.Options.Shortcuts.ToggleBookmark}, + { nameof(MainViewModel.ToggleForcedSelectedLinesCommand), Se.Language.Options.Shortcuts.ToggleForcedSelectedLines}, + { nameof(MainViewModel.SaveForcedLinesAsCommand), Se.Language.General.SaveForcedLinesAs}, + { nameof(MainViewModel.ToggleShowColumnForcedCommand), Se.Language.General.ShowForcedColumn}, { nameof(MainViewModel.CopyTextFromOriginalToTranslationCommand), Se.Language.Options.Shortcuts.CopyTextFromOriginalSelectedLines}, + { nameof(MainViewModel.CopyTextFromTranslationToOriginalCommand), Se.Language.Options.Shortcuts.CopyTextToOriginalSelectedLines}, { nameof(MainViewModel.SwitchOriginalAndTranslationTextSelectedLinesCommand), Se.Language.Options.Shortcuts.SwitchOriginalAndTranslationSelectedLines}, { nameof(MainViewModel.MergeOriginalIntoTranslationSelectedLinesCommand), Se.Language.Options.Shortcuts.MergeOriginalIntoTranslationSelectedLines}, { nameof(MainViewModel.ListBookmarksCommand), Se.Language.General.BookmarksList}, + { nameof(MainViewModel.ClearBookmarksCommand), Se.Language.Options.Shortcuts.ClearBookmarks}, { nameof(MainViewModel.GoToNextBookmarkCommand), Se.Language.Options.Shortcuts.GoToNextBookmark}, { nameof(MainViewModel.GoToPreviousBookmarkCommand), Se.Language.Options.Shortcuts.GoToPreviousBookmark}, { nameof(MainViewModel.GoToNextEmptyLineCommand), Se.Language.Options.Shortcuts.GoToNextEmptyLine}, @@ -120,6 +172,7 @@ private static Dictionary BuildCommandTranslations() { nameof(MainViewModel.FileOpenOriginalCommand), Se.Language.Options.Shortcuts.FileOpenOriginal }, { nameof(MainViewModel.FileCloseOriginalCommand), Se.Language.Options.Shortcuts.FileCloseOriginal }, { nameof(MainViewModel.ToggleTranslationModeCommand), Se.Language.Options.Shortcuts.GeneralToggleTranslationMode }, + { nameof(MainViewModel.ToggleOriginalTextInPreviewCommand), Se.Language.Options.Shortcuts.GeneralToggleTranslationAndOriginalInPreviews }, { nameof(MainViewModel.FileCloseTranslationCommand), Se.Language.Options.Shortcuts.FileCloseTranslation }, { nameof(MainViewModel.CommandExitCommand), Se.Language.Options.Shortcuts.FileExit }, { nameof(MainViewModel.CommandFileNewCommand), Se.Language.General.New }, @@ -215,6 +268,7 @@ private static Dictionary BuildCommandTranslations() { nameof(MainViewModel.ShowGoToVideoPositionCommand), Se.Language.Options.Shortcuts.GeneralGoToVideoPosition }, { nameof(MainViewModel.ToggleLinesItalicOrSelectedTextCommand), Se.Language.Options.Shortcuts.GeneralToggleItalic }, { nameof(MainViewModel.ToggleLinesBoldOrSelectedTextCommand), Se.Language.Options.Shortcuts.GeneralToggleBold }, + { nameof(MainViewModel.ToggleLinesUnderlineOrSelectedTextCommand), Se.Language.Options.Shortcuts.GeneralToggleUnderline }, { nameof(MainViewModel.PlayCommand), Se.Language.General.Play }, { nameof(MainViewModel.PlayNextCommand), Se.Language.General.PlayNext }, @@ -230,8 +284,10 @@ private static Dictionary BuildCommandTranslations() { nameof(MainViewModel.CommandShowLayoutCommand), Se.Language.Options.Shortcuts.GeneralChooseLayout }, { nameof(MainViewModel.GoToNextLineCommand), Se.Language.Options.Shortcuts.GeneralGoToNextSubtitle }, + { nameof(MainViewModel.GoToNextSubtitlePlayTranslateCommand), Se.Language.Options.Shortcuts.GeneralGoToNextSubtitlePlayTranslate }, { nameof(MainViewModel.GoToNextLineCursorAtEndCommand), Se.Language.Options.Shortcuts.GeneralGoToNextSubtitleCursorAtEnd }, { nameof(MainViewModel.GoToPreviousLineCommand), Se.Language.Options.Shortcuts.GeneralGoToPrevSubtitle }, + { nameof(MainViewModel.GoToPrevSubtitlePlayTranslateCommand), Se.Language.Options.Shortcuts.GeneralGoToPrevSubtitlePlayTranslate }, { nameof(MainViewModel.GoToFirstLineCommand), Se.Language.Options.Shortcuts.GeneralGoToFirstLine }, { nameof(MainViewModel.GoToLastLineCommand), Se.Language.Options.Shortcuts.GeneralGoToLastLine }, { nameof(MainViewModel.GoToNextLineAndSetVideoPositionCommand), Se.Language.Options.Shortcuts.GoToNextLineAndSetVideoPosition }, @@ -246,6 +302,8 @@ private static Dictionary BuildCommandTranslations() { nameof(MainViewModel.UnbreakCommand), Se.Language.General.Unbreak }, { nameof(MainViewModel.AutoBreakCommand), Se.Language.General.AutoBreak }, { nameof(MainViewModel.SplitCommand), Se.Language.General.SplitLine }, + { nameof(MainViewModel.AssistedSplitCommand), Se.Language.General.AssistedSplit }, + { nameof(MainViewModel.AssistedMoveCommand), Se.Language.General.AssistedMove }, { nameof(MainViewModel.SplitAtVideoPositionCommand), Se.Language.General.SplitLineAtVideoPosition }, { nameof(MainViewModel.SplitAtTextBoxCursorPositionCommand), Se.Language.General.SplitAtTextBoxCursorPosition }, { nameof(MainViewModel.SplitAtVideoPositionAndTextBoxCursorPositionCommand), Se.Language.General.SplitLineAtVideoAndTextBoxPosition }, @@ -259,6 +317,8 @@ private static Dictionary BuildCommandTranslations() { nameof(MainViewModel.TextBoxSelectAllCommand), Se.Language.Options.Shortcuts.TextBoxSelectAll }, { nameof(MainViewModel.TextBoxRemoveAllFormattingCommand), Se.Language.Options.Shortcuts.TextBoxRemoveAllFormatting }, { nameof(MainViewModel.TextBoxItalicCommand), Se.Language.Options.Shortcuts.TextBoxItalic }, + { nameof(MainViewModel.TextBoxBoldCommand), Se.Language.Options.Shortcuts.TextBoxBold }, + { nameof(MainViewModel.TextBoxUnderlineCommand), Se.Language.Options.Shortcuts.TextBoxUnderline }, { nameof(MainViewModel.VideoOneFrameBackCommand), Se.Language.General.VideoOneFrameBack }, { nameof(MainViewModel.VideoOneFrameForwardCommand), Se.Language.General.VideoOneFrameForward }, @@ -314,6 +374,7 @@ private static Dictionary BuildCommandTranslations() { nameof(MainViewModel.ShowWaveformSeekSilenceCommand), Se.Language.Options.Shortcuts.SeekSilence }, { nameof(MainViewModel.SeekSilenceBackCommand), Se.Language.Options.Shortcuts.SeekSilenceBack }, { nameof(MainViewModel.SeekSilenceForwardCommand), Se.Language.Options.Shortcuts.SeekSilenceForward }, + { nameof(MainViewModel.WaveformGuessStartCommand), Se.Language.Options.Shortcuts.WaveformGuessStart }, { nameof(MainViewModel.ShowShotChangesListCommand), Se.Language.General.ShowShotChangesList }, { nameof(MainViewModel.VideoUndockControlsCommand), Se.Language.Options.Shortcuts.UndockVideoControls }, { nameof(MainViewModel.VideoRedockControlsCommand), Se.Language.Options.Shortcuts.RedockVideoControls }, @@ -364,6 +425,8 @@ private static Dictionary BuildCommandTranslations() { nameof(MainViewModel.MoveFirstWordFromNextLineUpCurrentSubtitleCommand), Se.Language.Options.Shortcuts.MoveFirstWordFromNextLineUpCurrentSubtitle }, { nameof(MainViewModel.MoveTextFromCursorToNextAndGoToNextCommand), Se.Language.Options.Shortcuts.MoveTextFromCursorToNextAndGoToNext }, { nameof(MainViewModel.MoveTextFromCursorToNextAndGoToNextAndPlayCommand), Se.Language.Options.Shortcuts.MoveTextFromCursorToNextAndGoToNextAndPlay }, + { nameof(MainViewModel.BreakAtFirstSpaceFromCursorCommand), Se.Language.Options.Shortcuts.BreakAtFirstSpaceFromCursor }, + { nameof(MainViewModel.BreakAtFirstSpaceFromCursorAndGoToNextCommand), Se.Language.Options.Shortcuts.BreakAtFirstSpaceFromCursorAndGoToNext }, { nameof(MainViewModel.ToggleFocusGridAndWaveformCommand), Se.Language.Options.Shortcuts.ToggleFocusGridAndWaveform }, { nameof(MainViewModel.ToggleFocusTextBoxAndWaveformCommand), Se.Language.Options.Shortcuts.ToggleFocusTextBoxAndWaveform }, { nameof(MainViewModel.ToggleFocusTextBoxAndSubtitleGridCommand), Se.Language.Options.Shortcuts.ToggleFocusTextBoxAndGrid }, @@ -420,9 +483,14 @@ private static Dictionary BuildCommandTranslations() { nameof(MainViewModel.SetActor10Command), string.Format(Se.Language.Options.Shortcuts.SetActorXY, "10", Se.Settings.Actor10) }, { nameof(MainViewModel.SetNewActorCommand), Se.Language.Options.Shortcuts.SetNewActor }, { nameof(MainViewModel.RemoveActorCommand), Se.Language.General.Actor + " - " + Se.Language.General.Remove }, - { nameof(MainViewModel.SurroundWith1Command), string.Format(Se.Language.Options.Shortcuts.SurroundWithXY, Se.Settings.Surround1Left, Se.Settings.Surround1Right) }, - { nameof(MainViewModel.SurroundWith2Command), string.Format(Se.Language.Options.Shortcuts.SurroundWithXY, Se.Settings.Surround2Left, Se.Settings.Surround2Right) }, - { nameof(MainViewModel.SurroundWith3Command), string.Format(Se.Language.Options.Shortcuts.SurroundWithXY, Se.Settings.Surround3Left, Se.Settings.Surround3Right) }, + { nameof(MainViewModel.SurroundWith1Command), GetSurroundWithTitle(1) }, + { nameof(MainViewModel.SurroundWith2Command), GetSurroundWithTitle(2) }, + { nameof(MainViewModel.SurroundWith3Command), GetSurroundWithTitle(3) }, + { nameof(MainViewModel.SurroundWith4Command), GetSurroundWithTitle(4) }, + { nameof(MainViewModel.SurroundWith5Command), GetSurroundWithTitle(5) }, + { nameof(MainViewModel.SurroundWith6Command), GetSurroundWithTitle(6) }, + { nameof(MainViewModel.SurroundWith7Command), GetSurroundWithTitle(7) }, + { nameof(MainViewModel.SurroundWith8Command), GetSurroundWithTitle(8) }, { nameof(MainViewModel.InsertLineBeforeCommand), Se.Language.General.InsertBefore }, { nameof(MainViewModel.InsertLineAfterCommand), Se.Language.General.InsertAfter }, { nameof(MainViewModel.WaveformInsertNewSelectionCommand), Se.Language.Options.Shortcuts.WaveformInsertNewSelection }, @@ -450,6 +518,11 @@ private static Dictionary BuildCommandTranslations() { nameof(MainViewModel.SelectionToUpperCommand), Se.Language.Options.Shortcuts.SelectionToUpper }, { nameof(MainViewModel.SelectionToSentenceCaseCommand), Se.Language.Options.Shortcuts.SelectionToSentenceCase }, { nameof(MainViewModel.GoogleItCommand), Se.Language.Options.Shortcuts.GoogleIt }, + { nameof(MainViewModel.CustomSearch1Command), GetSearchViaTitle(1) }, + { nameof(MainViewModel.CustomSearch2Command), GetSearchViaTitle(2) }, + { nameof(MainViewModel.CustomSearch3Command), GetSearchViaTitle(3) }, + { nameof(MainViewModel.CustomSearch4Command), GetSearchViaTitle(4) }, + { nameof(MainViewModel.CustomSearch5Command), GetSearchViaTitle(5) }, { nameof(MainViewModel.ImportImageSubtitleForEditCommand), Se.Language.Options.Shortcuts.ImportImageSubtitleForEdit }, { nameof(MainViewModel.ShowMediaInformationCommand), Se.Language.Options.Shortcuts.ShowMediaInformation }, { nameof(MainViewModel.ShowSubtitleFormatPickerCommand), Se.Language.Options.Shortcuts.ChooseSubtitleFormat }, @@ -520,10 +593,15 @@ private static List GetAllAvailableShortcuts(MainViewModel vm AddShortcut(shortcuts, vm.DoAlignmentAn9Command, nameof(vm.DoAlignmentAn9Command), ShortcutCategory.SubtitleGridAndTextBox); AddShortcut(shortcuts, vm.AddOrEditBookmarkCommand, nameof(vm.AddOrEditBookmarkCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.ToggleBookmarkSelectedLinesNoTextCommand, nameof(vm.ToggleBookmarkSelectedLinesNoTextCommand), ShortcutCategory.General); + AddShortcut(shortcuts, vm.ToggleForcedSelectedLinesCommand, nameof(vm.ToggleForcedSelectedLinesCommand), ShortcutCategory.General); + AddShortcut(shortcuts, vm.SaveForcedLinesAsCommand, nameof(vm.SaveForcedLinesAsCommand), ShortcutCategory.General); + AddShortcut(shortcuts, vm.ToggleShowColumnForcedCommand, nameof(vm.ToggleShowColumnForcedCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.CopyTextFromOriginalToTranslationCommand, nameof(vm.CopyTextFromOriginalToTranslationCommand), ShortcutCategory.SubtitleGrid, ShortcutGroup.Translate); + AddShortcut(shortcuts, vm.CopyTextFromTranslationToOriginalCommand, nameof(vm.CopyTextFromTranslationToOriginalCommand), ShortcutCategory.SubtitleGrid, ShortcutGroup.Translate); AddShortcut(shortcuts, vm.SwitchOriginalAndTranslationTextSelectedLinesCommand, nameof(vm.SwitchOriginalAndTranslationTextSelectedLinesCommand), ShortcutCategory.SubtitleGrid, ShortcutGroup.Translate); AddShortcut(shortcuts, vm.MergeOriginalIntoTranslationSelectedLinesCommand, nameof(vm.MergeOriginalIntoTranslationSelectedLinesCommand), ShortcutCategory.SubtitleGrid, ShortcutGroup.Translate); AddShortcut(shortcuts, vm.ListBookmarksCommand, nameof(vm.ListBookmarksCommand), ShortcutCategory.General); + AddShortcut(shortcuts, vm.ClearBookmarksCommand, nameof(vm.ClearBookmarksCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.GoToNextBookmarkCommand, nameof(vm.GoToNextBookmarkCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.GoToPreviousBookmarkCommand, nameof(vm.GoToPreviousBookmarkCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.GoToNextEmptyLineCommand, nameof(vm.GoToNextEmptyLineCommand), ShortcutCategory.General); @@ -551,6 +629,7 @@ private static List GetAllAvailableShortcuts(MainViewModel vm AddShortcut(shortcuts, vm.FileOpenOriginalCommand, nameof(vm.FileOpenOriginalCommand), ShortcutCategory.General, ShortcutGroup.File); AddShortcut(shortcuts, vm.FileCloseOriginalCommand, nameof(vm.FileCloseOriginalCommand), ShortcutCategory.General, ShortcutGroup.File); AddShortcut(shortcuts, vm.ToggleTranslationModeCommand, nameof(vm.ToggleTranslationModeCommand), ShortcutCategory.General, ShortcutGroup.Translate); + AddShortcut(shortcuts, vm.ToggleOriginalTextInPreviewCommand, nameof(vm.ToggleOriginalTextInPreviewCommand), ShortcutCategory.General, ShortcutGroup.Translate); AddShortcut(shortcuts, vm.FileCloseTranslationCommand, nameof(vm.FileCloseTranslationCommand), ShortcutCategory.General, ShortcutGroup.File); AddShortcut(shortcuts, vm.ExportBluRaySupCommand, nameof(vm.ExportBluRaySupCommand), ShortcutCategory.General, ShortcutGroup.File); AddShortcut(shortcuts, vm.ShowExportCustomTextFormatCommand, nameof(vm.ShowExportCustomTextFormatCommand), ShortcutCategory.General, ShortcutGroup.File); @@ -573,6 +652,7 @@ private static List GetAllAvailableShortcuts(MainViewModel vm AddShortcut(shortcuts, vm.ShowGoToVideoPositionCommand, nameof(vm.ShowGoToVideoPositionCommand), ShortcutCategory.General, ShortcutGroup.Video); AddShortcut(shortcuts, vm.ToggleLinesItalicOrSelectedTextCommand, nameof(vm.ToggleLinesItalicOrSelectedTextCommand), ShortcutCategory.SubtitleGridAndTextBox); AddShortcut(shortcuts, vm.ToggleLinesBoldOrSelectedTextCommand, nameof(vm.ToggleLinesBoldOrSelectedTextCommand), ShortcutCategory.SubtitleGridAndTextBox); + AddShortcut(shortcuts, vm.ToggleLinesUnderlineOrSelectedTextCommand, nameof(vm.ToggleLinesUnderlineOrSelectedTextCommand), ShortcutCategory.SubtitleGridAndTextBox); AddShortcut(shortcuts, vm.PlayCommand, nameof(vm.PlayCommand), ShortcutCategory.General, ShortcutGroup.Video); AddShortcut(shortcuts, vm.PlayNextCommand, nameof(vm.PlayNextCommand), ShortcutCategory.General, ShortcutGroup.Video); @@ -596,6 +676,8 @@ private static List GetAllAvailableShortcuts(MainViewModel vm AddShortcut(shortcuts, vm.GoToPreviousLineCommand, nameof(vm.GoToPreviousLineCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.GoToNextLineCommand, nameof(vm.GoToNextLineCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.GoToNextLineCursorAtEndCommand, nameof(vm.GoToNextLineCursorAtEndCommand), ShortcutCategory.General); + AddShortcut(shortcuts, vm.GoToPrevSubtitlePlayTranslateCommand, nameof(vm.GoToPrevSubtitlePlayTranslateCommand), ShortcutCategory.General, ShortcutGroup.Video); + AddShortcut(shortcuts, vm.GoToNextSubtitlePlayTranslateCommand, nameof(vm.GoToNextSubtitlePlayTranslateCommand), ShortcutCategory.General, ShortcutGroup.Video); AddShortcut(shortcuts, vm.GoToFirstLineCommand, nameof(vm.GoToFirstLineCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.GoToLastLineCommand, nameof(vm.GoToLastLineCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.GoToPreviousLineAndSetVideoPositionCommand, nameof(vm.GoToPreviousLineAndSetVideoPositionCommand), ShortcutCategory.General); @@ -610,6 +692,8 @@ private static List GetAllAvailableShortcuts(MainViewModel vm AddShortcut(shortcuts, vm.UnbreakNoSpaceCommand, nameof(vm.UnbreakNoSpaceCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.AutoBreakCommand, nameof(vm.AutoBreakCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.SplitCommand, nameof(vm.SplitCommand), ShortcutCategory.General); + AddShortcut(shortcuts, vm.AssistedSplitCommand, nameof(vm.AssistedSplitCommand), ShortcutCategory.General); + AddShortcut(shortcuts, vm.AssistedMoveCommand, nameof(vm.AssistedMoveCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.SplitAtVideoPositionCommand, nameof(vm.SplitAtVideoPositionCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.SplitAtTextBoxCursorPositionCommand, nameof(vm.SplitAtTextBoxCursorPositionCommand), ShortcutCategory.TextBox); AddShortcut(shortcuts, vm.SplitAtVideoPositionAndTextBoxCursorPositionCommand, nameof(vm.SplitAtVideoPositionAndTextBoxCursorPositionCommand), ShortcutCategory.General); @@ -623,6 +707,10 @@ private static List GetAllAvailableShortcuts(MainViewModel vm AddShortcut(shortcuts, vm.TextBoxSelectAllCommand, nameof(vm.TextBoxSelectAllCommand), ShortcutCategory.TextBox); AddShortcut(shortcuts, vm.TextBoxRemoveAllFormattingCommand, nameof(vm.TextBoxRemoveAllFormattingCommand), ShortcutCategory.TextBox); AddShortcut(shortcuts, vm.TextBoxItalicCommand, nameof(vm.TextBoxItalicCommand), ShortcutCategory.TextBox); + AddShortcut(shortcuts, vm.TextBoxBoldCommand, nameof(vm.TextBoxBoldCommand), ShortcutCategory.TextBox); + AddShortcut(shortcuts, vm.TextBoxUnderlineCommand, nameof(vm.TextBoxUnderlineCommand), ShortcutCategory.TextBox); + AddShortcut(shortcuts, vm.BreakAtFirstSpaceFromCursorCommand, nameof(vm.BreakAtFirstSpaceFromCursorCommand), ShortcutCategory.TextBox); + AddShortcut(shortcuts, vm.BreakAtFirstSpaceFromCursorAndGoToNextCommand, nameof(vm.BreakAtFirstSpaceFromCursorAndGoToNextCommand), ShortcutCategory.TextBox); // Tools AddShortcut(shortcuts, vm.ShowBridgeGapsCommand, nameof(vm.ShowBridgeGapsCommand), ShortcutCategory.General, ShortcutGroup.Tools); @@ -721,6 +809,7 @@ private static List GetAllAvailableShortcuts(MainViewModel vm AddShortcut(shortcuts, vm.ShowWaveformSeekSilenceCommand, nameof(vm.ShowWaveformSeekSilenceCommand), ShortcutCategory.Waveform); AddShortcut(shortcuts, vm.SeekSilenceBackCommand, nameof(vm.SeekSilenceBackCommand), ShortcutCategory.Waveform); AddShortcut(shortcuts, vm.SeekSilenceForwardCommand, nameof(vm.SeekSilenceForwardCommand), ShortcutCategory.Waveform); + AddShortcut(shortcuts, vm.WaveformGuessStartCommand, nameof(vm.WaveformGuessStartCommand), ShortcutCategory.Waveform); AddShortcut(shortcuts, vm.GoToPreviousShotChangeCommand, nameof(vm.GoToPreviousShotChangeCommand), ShortcutCategory.General, ShortcutGroup.Video); AddShortcut(shortcuts, vm.GoToNextShotChangeCommand, nameof(vm.GoToNextShotChangeCommand), ShortcutCategory.General, ShortcutGroup.Video); AddShortcut(shortcuts, vm.ShowVideoChaptersCommand, nameof(vm.ShowVideoChaptersCommand), ShortcutCategory.General, ShortcutGroup.Video); @@ -838,6 +927,11 @@ private static List GetAllAvailableShortcuts(MainViewModel vm AddShortcut(shortcuts, vm.SurroundWith1Command, nameof(vm.SurroundWith1Command), ShortcutCategory.SubtitleGridAndTextBox); AddShortcut(shortcuts, vm.SurroundWith2Command, nameof(vm.SurroundWith2Command), ShortcutCategory.SubtitleGridAndTextBox); AddShortcut(shortcuts, vm.SurroundWith3Command, nameof(vm.SurroundWith3Command), ShortcutCategory.SubtitleGridAndTextBox); + AddShortcut(shortcuts, vm.SurroundWith4Command, nameof(vm.SurroundWith4Command), ShortcutCategory.SubtitleGridAndTextBox); + AddShortcut(shortcuts, vm.SurroundWith5Command, nameof(vm.SurroundWith5Command), ShortcutCategory.SubtitleGridAndTextBox); + AddShortcut(shortcuts, vm.SurroundWith6Command, nameof(vm.SurroundWith6Command), ShortcutCategory.SubtitleGridAndTextBox); + AddShortcut(shortcuts, vm.SurroundWith7Command, nameof(vm.SurroundWith7Command), ShortcutCategory.SubtitleGridAndTextBox); + AddShortcut(shortcuts, vm.SurroundWith8Command, nameof(vm.SurroundWith8Command), ShortcutCategory.SubtitleGridAndTextBox); AddShortcut(shortcuts, vm.InsertLineBeforeCommand, nameof(vm.InsertLineBeforeCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.InsertLineAfterCommand, nameof(vm.InsertLineAfterCommand), ShortcutCategory.General); AddShortcut(shortcuts, vm.WaveformInsertNewSelectionCommand, nameof(vm.WaveformInsertNewSelectionCommand), ShortcutCategory.Waveform); @@ -867,6 +961,11 @@ private static List GetAllAvailableShortcuts(MainViewModel vm AddShortcut(shortcuts, vm.SelectionToUpperCommand, nameof(vm.SelectionToUpperCommand), ShortcutCategory.TextBox); AddShortcut(shortcuts, vm.SelectionToSentenceCaseCommand, nameof(vm.SelectionToSentenceCaseCommand), ShortcutCategory.TextBox); AddShortcut(shortcuts, vm.GoogleItCommand, nameof(vm.GoogleItCommand), ShortcutCategory.TextBox, ShortcutGroup.Search); + AddShortcut(shortcuts, vm.CustomSearch1Command, nameof(vm.CustomSearch1Command), ShortcutCategory.TextBox, ShortcutGroup.Search); + AddShortcut(shortcuts, vm.CustomSearch2Command, nameof(vm.CustomSearch2Command), ShortcutCategory.TextBox, ShortcutGroup.Search); + AddShortcut(shortcuts, vm.CustomSearch3Command, nameof(vm.CustomSearch3Command), ShortcutCategory.TextBox, ShortcutGroup.Search); + AddShortcut(shortcuts, vm.CustomSearch4Command, nameof(vm.CustomSearch4Command), ShortcutCategory.TextBox, ShortcutGroup.Search); + AddShortcut(shortcuts, vm.CustomSearch5Command, nameof(vm.CustomSearch5Command), ShortcutCategory.TextBox, ShortcutGroup.Search); AddShortcut(shortcuts, vm.ImportImageSubtitleForEditCommand, nameof(vm.ImportImageSubtitleForEditCommand), ShortcutCategory.General, ShortcutGroup.File); AddShortcut(shortcuts, vm.ShowMediaInformationCommand, nameof(vm.ShowMediaInformationCommand), ShortcutCategory.General, ShortcutGroup.Video); AddShortcut(shortcuts, vm.ShowSubtitleFormatPickerCommand, nameof(vm.ShowSubtitleFormatPickerCommand), ShortcutCategory.General, ShortcutGroup.File); diff --git a/src/ui/Logic/SubRipSourceSyntaxHighlighting.cs b/src/ui/Logic/SubRipSourceSyntaxHighlighting.cs index 7d5ddbe06d9..21ad1a04975 100644 --- a/src/ui/Logic/SubRipSourceSyntaxHighlighting.cs +++ b/src/ui/Logic/SubRipSourceSyntaxHighlighting.cs @@ -258,7 +258,13 @@ private static void ColorizeHtmlAndAssTags(string lineText, SourceSyntaxLineStyl styler.Apply(valueStart, 1, CharsColor); // Color the value content (check for style attribute) - var hasColon = lineText.IndexOf(':', i + 1, valueEnd - i - 2) != -1; + // Clamp the span, as SubtitleSyntaxTokenizer does: with the opening quote as + // the last character of the line (typing ' maxCps ? _errorBrush : _transparentBrush; + // Same rounded comparison as the grid row, so the label cannot read "15.0" in red while + // the row for the same line is not flagged (#14418). + cpsBackground = Se.Settings.General.ColorCharactersPerSecond && CpsHelper.IsAboveMax(cps, maxCps) ? _errorBrush : _transparentBrush; totalText = info.TotalText; totalBackground = info.TotalBackground; } diff --git a/src/ui/Logic/SummaryCard.cs b/src/ui/Logic/SummaryCard.cs new file mode 100644 index 00000000000..72129b08e9c --- /dev/null +++ b/src/ui/Logic/SummaryCard.cs @@ -0,0 +1,124 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Layout; +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using Nikse.SubtitleEdit.Logic.Config; +using System.Windows.Input; + +namespace Nikse.SubtitleEdit.Logic; + +/// +/// A clickable "count + label" summary card with a colored accent bar, used as a +/// filter toggle above a result table (speech-to-text quality report, list errors). +/// is whatever the owner uses to identify the filter; the +/// active card shows its accent color as the border. +/// +public partial class SummaryCard : ObservableObject +{ + [ObservableProperty] private bool _isActive; + [ObservableProperty] private IBrush _borderBrush = UiUtil.GetTextColor(0.25); + + partial void OnIsActiveChanged(bool value) + { + BorderBrush = value ? Brush : UiUtil.GetTextColor(0.25); + } + + public object? Key { get; init; } + public string Label { get; init; } = string.Empty; + public string Hint { get; init; } = string.Empty; + public int Count { get; init; } + public IBrush Brush { get; init; } = Brushes.Gray; + + /// + /// Build the visual for a card. A bordered Button rather than a ToggleButton: the + /// Fluent checked state floods the card with the accent color and drowns the + /// colored count. + /// + public static Control MakeControl(SummaryCard? card, ICommand command) + { + if (card == null) + { + return new Border(); + } + + var accent = new Border + { + Width = 5, + CornerRadius = new CornerRadius(3), + Background = card.Brush, + Margin = new Thickness(0, 0, 10, 0), + VerticalAlignment = VerticalAlignment.Stretch, + }; + var count = new TextBlock + { + Text = card.Count.ToString(), + FontSize = 24, + FontWeight = FontWeight.Bold, + Foreground = card.Brush, + VerticalAlignment = VerticalAlignment.Center, + }; + var label = new TextBlock + { + Text = card.Label, + FontSize = 12, + Opacity = 0.85, + VerticalAlignment = VerticalAlignment.Center, + }; + var texts = new StackPanel + { + Orientation = Orientation.Vertical, + VerticalAlignment = VerticalAlignment.Center, + Children = { count, label }, + }; + var content = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { accent, texts }, + }; + + var border = new Border + { + Child = content, + Padding = new Thickness(12, 8), + MinWidth = 120, + BorderThickness = new Thickness(2), + CornerRadius = new CornerRadius(UiUtil.CornerRadius + 2), + DataContext = card, + [!Border.BorderBrushProperty] = new Binding(nameof(BorderBrush)), + }; + + var button = new Button + { + Content = border, + Padding = new Thickness(0), + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + CornerRadius = new CornerRadius(UiUtil.CornerRadius + 2), + Command = command, + CommandParameter = card, + Opacity = card.Count == 0 && card.Key != null ? 0.5 : 1.0, + }; + AutomationProperties.SetName(button, $"{card.Label}: {card.Count}"); + if (Se.Settings.Appearance.ShowHints && !string.IsNullOrEmpty(card.Hint)) + { + ToolTip.SetTip(button, card.Hint); + } + + return button; + } + + /// An ItemsControl that lays cards out in a wrapping row. + public static ItemsControl MakeCardsPanel(System.Collections.IEnumerable cards, ICommand command) + { + return new ItemsControl + { + ItemsSource = cards, + Margin = new Thickness(0, 0, 0, 12), + ItemsPanel = new Avalonia.Controls.Templates.FuncTemplate(() => new WrapPanel { Orientation = Orientation.Horizontal, ItemSpacing = 8, LineSpacing = 8 }), + ItemTemplate = new Avalonia.Controls.Templates.FuncDataTemplate((card, _) => MakeControl(card, command)), + }; + } +} diff --git a/src/ui/Logic/TableViewExtras.cs b/src/ui/Logic/TableViewExtras.cs index c3c01db6e88..cbcc9d54e20 100644 --- a/src/ui/Logic/TableViewExtras.cs +++ b/src/ui/Logic/TableViewExtras.cs @@ -141,6 +141,23 @@ private void Apply(Comparison comparison) if (selectedSet.Count > 0) { _tableView.Selection.BeginBatchUpdate(); + + // Select the anchor FIRST - the first Select in the batch sets SelectedIndex, so the + // current row is restored without a later "SelectedItem =" assignment. That + // assignment routes to Selection.SelectedIndex, which REPLACES the selection, so + // clicking a column header collapsed a restored multi-row selection down to one row + // (contradicting this class's own "selection is preserved" contract). + // MoveSelectedRows below uses the same ordering and explains it. + if (selectedItem != null) + { + var anchorIndex = sorted.IndexOf(selectedItem); + if (anchorIndex >= 0) + { + _tableView.Selection.Select(anchorIndex); + selectedSet.Remove(selectedItem); + } + } + for (var i = 0; i < sorted.Count && selectedSet.Count > 0; i++) { if (selectedSet.Remove(sorted[i])) @@ -151,8 +168,11 @@ private void Apply(Comparison comparison) _tableView.Selection.EndBatchUpdate(); } + else + { + _tableView.SelectedItem = selectedItem; + } - _tableView.SelectedItem = selectedItem; if (selectedItem != null) { _tableView.ScrollIntoView(selectedItem); @@ -207,6 +227,52 @@ public void Add(TableViewColumn column) Sync(); } + /// + /// Reorders the managed columns to match (matched against + /// ). Columns whose key is not in the list keep + /// their current position - a saved order from an older version must not hide or + /// displace columns added since. A null/empty list leaves the order untouched. + /// + public void ApplyOrder(IReadOnlyList? keys) + { + if (keys == null || keys.Count == 0) + { + return; + } + + var ordered = new List(); + foreach (var key in keys) + { + var column = _columns.FirstOrDefault(c => c is SeTableViewColumn se && se.Tag as string == key); + if (column != null && !ordered.Contains(column)) + { + ordered.Add(column); + } + } + + if (ordered.Count == 0) + { + return; + } + + for (var i = 0; i < _columns.Count; i++) + { + var column = _columns[i]; + if (!ordered.Contains(column)) + { + ordered.Insert(Math.Min(i, ordered.Count), column); + } + } + + _columns.Clear(); + _columns.AddRange(ordered); + + // Sync() can only insert missing columns, not permute existing ones, so clear the + // live list first and let it rebuild in the new order. + _tableView.Columns.Clear(); + Sync(); + } + private void Sync() { var target = _columns.Where(c => c is not SeTableViewColumn se || se.IsVisible).ToList(); @@ -242,6 +308,16 @@ private void Sync() /// public static class TableViewExtras { + /// + /// The visual whose origin is the viewport's top-left corner. The TableView template keeps the + /// column header INSIDE the ScrollViewer (pinned above the rows), so the ScrollViewer's own + /// origin sits a header height above the viewport - measuring against it inflated every row + /// top while Viewport.Height excludes the header, so a row clipped under the header counted as + /// fully visible and page/centre targets were off by one row. Same helper as + /// TableViewScrollAnchor.ViewportOrigin and TableViewIndexScrollBar. + /// + private static Visual ViewportOrigin(ScrollViewer scrollViewer) => (Visual?)scrollViewer.Presenter ?? scrollViewer; + private static readonly TextToFlowDirectionConverter TextToFlowDirection = new(); /// @@ -673,7 +749,7 @@ public static int GetPageTarget(TableView tableView, int currentIndex, bool down continue; } - var top = ((Visual)row).TranslatePoint(new Point(0, 0), scrollViewer)?.Y; + var top = ((Visual)row).TranslatePoint(new Point(0, 0), ViewportOrigin(scrollViewer))?.Y; if (top == null || top.Value < -0.5 || top.Value + height > scrollViewer.Viewport.Height + 0.5) { continue; @@ -751,7 +827,7 @@ public static void PrePositionScroll(TableView tableView, int index) } var first = rows[0]; - var firstTop = ((Visual)first.Row).TranslatePoint(new Point(0, 0), scrollViewer)?.Y; + var firstTop = ((Visual)first.Row).TranslatePoint(new Point(0, 0), ViewportOrigin(scrollViewer))?.Y; if (firstTop == null) { return; @@ -835,7 +911,7 @@ public static bool IsRowFullyVisible(TableView tableView, object item) return false; } - var rowTop = row.TranslatePoint(new Point(0, 0), scrollViewer)?.Y; + var rowTop = row.TranslatePoint(new Point(0, 0), ViewportOrigin(scrollViewer))?.Y; if (rowTop == null) { return false; @@ -864,7 +940,7 @@ private static void AdjustScrollForRow(TableView tableView, object item, Func _anchorCandidates = new(); private int _anchorIndex = -1; private int _anchorItemCount = -1; private double _anchorTop; @@ -164,71 +167,81 @@ private void Remember() return; } - var top = double.MaxValue; - Control? anchorRow = null; + // Every row intersecting the viewport, with its top edge, ordered top first. + var visible = new List<(int Index, double Top)>(); foreach (var row in _tableView.GetRealizedContainers().OfType()) { if (row.Bounds.Height <= 0 || ((Visual)row).TranslatePoint(new Point(0, 0), viewportOrigin)?.Y is not { } rowTop || rowTop + row.Bounds.Height <= 0.5 || - rowTop >= top) + rowTop >= _scrollViewer.Viewport.Height - 0.5) { continue; } - top = rowTop; - anchorRow = row; + var index = _tableView.IndexFromContainer(row); + if (index >= 0) + { + visible.Add((index, rowTop)); + } } - if (anchorRow == null) + visible.Sort((a, b) => a.Top.CompareTo(b.Top)); + + if (visible.Count == 0) { - _anchorItem = null; + _anchorCandidates.Clear(); _anchorIndex = -1; _anchorItemCount = -1; return; } - var index = _tableView.IndexFromContainer(anchorRow); - if (index < 0) + _anchorIndex = visible[0].Index; + _anchorTop = visible[0].Top; + _anchorItemCount = _tableView.ItemCount; + _anchorCandidates.Clear(); + var itemsView = _tableView.ItemsView; + foreach (var (index, top) in visible) { - return; + if (index < itemsView.Count && itemsView[index] is { } item) + { + _anchorCandidates.Add((item, top)); + } } - - _anchorIndex = index; - _anchorTop = top; - _anchorItemCount = _tableView.ItemCount; - _anchorItem = index < _tableView.ItemsView.Count ? _tableView.ItemsView[index] : null; } /// - /// Where the anchored row lives now, or -1 when there is nothing safe to restore to. - /// The item wins over the index so an insert above the anchor keeps the same content in - /// view; the index is only trusted when the list is still the same length, so a reload + /// Where the anchored row lives now - its index plus the viewport top it should get + /// back - or null when there is nothing safe to restore to. The items win over the + /// index so an insert above the anchor keeps the same content in view, and when the + /// top row itself is gone (cut/delete of the selected line, issue #14231) the anchor + /// migrates to the next remembered row that survived, restored to its own old top. + /// The index is only trusted when the list is still the same length, so a reload /// (every item replaced) leaves the view alone instead of jumping to a stale row. /// - private int ResolveAnchorIndex() + private (int Index, double Top)? ResolveAnchor() { var count = _tableView.ItemCount; if (count == 0) { - return -1; + return null; } - if (_anchorItem != null) + foreach (var (item, top) in _anchorCandidates) { - var index = _tableView.ItemsView.IndexOf(_anchorItem); + var index = _tableView.ItemsView.IndexOf(item); if (index >= 0) { - return index; + return (index, top); } } if (_anchorIndex >= 0 && _anchorIndex < count && count == _anchorItemCount) { - return _anchorIndex; + return (_anchorIndex, _anchorTop); } - return -1; + return null; } private void Restore() @@ -238,8 +251,7 @@ private void Restore() return; } - var index = ResolveAnchorIndex(); - if (index < 0) + if (ResolveAnchor() is not var (index, anchorTop)) { return; } @@ -269,7 +281,7 @@ ViewportOrigin is not { } viewportOrigin || } var newY = Math.Clamp( - _scrollViewer.Offset.Y + (rowTop - _anchorTop), + _scrollViewer.Offset.Y + (rowTop - anchorTop), 0, Math.Max(0, _scrollViewer.Extent.Height - _scrollViewer.Viewport.Height)); if (Math.Abs(newY - _scrollViewer.Offset.Y) < 0.5) { diff --git a/src/ui/Logic/TableViewScrollSync.cs b/src/ui/Logic/TableViewScrollSync.cs new file mode 100644 index 00000000000..88a1c845d9b --- /dev/null +++ b/src/ui/Logic/TableViewScrollSync.cs @@ -0,0 +1,244 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.VisualTree; +using System; +using System.Linq; + +namespace Nikse.SubtitleEdit.Logic; + +/// +/// Keeps two side-by-side s looking at the same row: scrolling either +/// one puts the row at the other one's viewport top too (issue #13504). +/// +/// SE4's Compare did this with a timer that copied the active list view's TopItem index to the +/// other one (Compare.SelectLinesInBothListViews). SE5's two grids scroll independently, so +/// with larger files the sides drift a page or more apart and the comparison is unreadable. +/// +/// The sync works in row *indices*, not pixels, and that is not a detail: the two grids hold +/// different text, a row wraps to one or two lines depending on its content, and TableView +/// virtualizes with a VirtualizingStackPanel whose extent is estimated from the average +/// realized row height (see and +/// , upstream AvaloniaUI/Avalonia #17831). Two +/// grids with different content therefore produce different - and drifting - estimates, so +/// copying Offset.Y across would neither start nor stay aligned. Instead the row at the +/// source's viewport top is measured, and the same index is placed at the same top edge on the +/// other side with the pre-position + settle loop the other TableView helpers use. +/// +/// Rows below the top can still diverge when one side wraps to two lines and its twin does not; +/// aligning those as well would mean giving matched rows a shared height, which is a change to +/// the compare rows themselves rather than to scrolling. +/// +public sealed class TableViewScrollSync +{ + /// Refinement steps the alignment is allowed to take, like PrePositionScroll. + private const int SettlePasses = 3; + + private readonly TableView _left; + private readonly TableView _right; + private ScrollViewer? _leftScrollViewer; + private ScrollViewer? _rightScrollViewer; + + // Aligning one side scrolls it, which reports back as another scroll change; without this + // the two sides would push each other back and forth (and fight at the bottom, where the + // shorter extent cannot follow the taller one). + private bool _syncing; + + public TableViewScrollSync(TableView left, TableView right) + { + _left = left; + _right = right; + + Hook(left, isLeft: true); + Hook(right, isLeft: false); + } + + /// + /// Puts 's top row at the top of the other grid. Called on every + /// scroll, and by callers that moved the selection instead of the offset - a mirrored + /// selection only guarantees the row is somewhere in view, which is not the same as aligned. + /// + public void SyncFrom(TableView source) + { + if (_syncing) + { + return; + } + + ScrollViewer? sourceScrollViewer; + TableView target; + ScrollViewer? targetScrollViewer; + if (source == _left) + { + sourceScrollViewer = _leftScrollViewer; + target = _right; + targetScrollViewer = _rightScrollViewer; + } + else if (source == _right) + { + sourceScrollViewer = _rightScrollViewer; + target = _left; + targetScrollViewer = _leftScrollViewer; + } + else + { + return; + } + + if (sourceScrollViewer == null || targetScrollViewer == null || + targetScrollViewer.Viewport.Height <= 0 || target.ItemCount == 0) + { + return; + } + + // Set before the first layout pass, not just around the alignment: laying the source out + // reports its own scroll change back here, and answering that would re-enter this method + // for the same scroll until the stack runs out. + _syncing = true; + + // Placing a row at the viewport top realizes rows as it goes, so the extent estimate + // moves under the settle loop - an anchor restore in the middle of that would drag the + // view back to where it just left (#13619). + using var anchorSuspended = TableViewScrollAnchor.GetFor(target)?.Suspend(); + try + { + // The caller may have just scrolled the source with ScrollIntoView; measure what is + // on screen now, not what was there before that layout pass. + source.UpdateLayout(); + if (GetTopRow(source, sourceScrollViewer) is not { } topRow) + { + return; + } + + Align(target, targetScrollViewer, topRow.Index, topRow.Top); + } + finally + { + _syncing = false; + } + } + + private void Hook(TableView tableView, bool isLeft) + { + if (tableView.IsLoaded) + { + HookScrollViewer(tableView, isLeft); + } + + tableView.Loaded += (_, _) => HookScrollViewer(tableView, isLeft); + } + + private void HookScrollViewer(TableView tableView, bool isLeft) + { + if ((isLeft ? _leftScrollViewer : _rightScrollViewer) != null) + { + return; + } + + var scrollViewer = tableView.GetVisualDescendants().OfType().FirstOrDefault(); + if (scrollViewer == null) + { + return; + } + + if (isLeft) + { + _leftScrollViewer = scrollViewer; + } + else + { + _rightScrollViewer = scrollViewer; + } + + scrollViewer.ScrollChanged += (_, e) => + { + // An extent change with no offset change is the panel re-estimating under the view, + // not a scroll - the other side has no reason to move for it. + if (_syncing || Math.Abs(e.OffsetDelta.Y) < 0.5) + { + return; + } + + SyncFrom(tableView); + }; + } + + /// + /// The visual whose origin is the viewport's top-left corner. The TableView template keeps + /// the column header inside the ScrollViewer (pinned above the rows), so the + /// ScrollViewer's own origin sits a header height above the viewport. Same helper as + /// and TableViewExtras. + /// + private static Visual ViewportOrigin(ScrollViewer scrollViewer) => (Visual?)scrollViewer.Presenter ?? scrollViewer; + + /// + /// The topmost row still showing in the viewport, with its top edge in viewport + /// coordinates - negative while the row is scrolled into. + /// + private static (int Index, double Top)? GetTopRow(TableView tableView, ScrollViewer scrollViewer) + { + var top = double.MaxValue; + Control? topRow = null; + foreach (var row in tableView.GetRealizedContainers().OfType()) + { + if (row.Bounds.Height <= 0 || + ((Visual)row).TranslatePoint(new Point(0, 0), ViewportOrigin(scrollViewer))?.Y is not { } rowTop || + rowTop + row.Bounds.Height <= 0.5 || + rowTop >= top) + { + continue; + } + + top = rowTop; + topRow = row; + } + + if (topRow == null) + { + return null; + } + + var index = tableView.IndexFromContainer(topRow); + return index < 0 ? null : (index, top); + } + + /// + /// Puts row of at + /// in viewport coordinates. + /// + private static void Align(TableView tableView, ScrollViewer scrollViewer, int index, double top) + { + index = Math.Clamp(index, 0, tableView.ItemCount - 1); + + // The target row is usually outside the realized window - that is the whole point of + // the sync - so bring it back the cheap way first instead of letting the panel walk. + if (tableView.ContainerFromIndex(index) is not { Bounds.Height: > 0 }) + { + TableViewExtras.PrePositionScroll(tableView, index); + tableView.ScrollIntoView(index); + tableView.UpdateLayout(); + } + + // Then put its top edge where the other side's is. Setting the offset makes the panel + // re-estimate and re-anchor, which can nudge the rows by a fraction of a row - measure + // and correct until the row stays put. + for (var pass = 0; pass < SettlePasses; pass++) + { + if (tableView.ContainerFromIndex(index) is not { Bounds.Height: > 0 } row || + ((Visual)row).TranslatePoint(new Point(0, 0), ViewportOrigin(scrollViewer))?.Y is not { } rowTop) + { + return; + } + + var newY = Math.Clamp( + scrollViewer.Offset.Y + (rowTop - top), + 0, Math.Max(0, scrollViewer.Extent.Height - scrollViewer.Viewport.Height)); + if (Math.Abs(newY - scrollViewer.Offset.Y) < 0.5) + { + return; + } + + scrollViewer.Offset = new Vector(scrollViewer.Offset.X, newY); + tableView.UpdateLayout(); + } + } +} diff --git a/src/ui/Logic/TeletextRowHelper.cs b/src/ui/Logic/TeletextRowHelper.cs index 5a4b34c1584..2d0eebf96fe 100644 --- a/src/ui/Logic/TeletextRowHelper.cs +++ b/src/ui/Logic/TeletextRowHelper.cs @@ -39,4 +39,28 @@ public static int GetBottomStartRow(int lineCount, bool doubleHeight) var newRow = GetBottomStartRow(newLineCount, doubleHeight); return newRow >= 1 ? newRow : null; } + + /// + /// Row for a subtitle whose text is rewritten by a batch tool (split into several events, or + /// wrapped to another number of lines): the bottom edge stays where it was, so the start row + /// moves by the rows gained or lost (with double height 21 -> 23 for two lines becoming one, + /// 19 -> 21, 20 -> 22, and the inverse). Returns null when the row must be left alone: it is not + /// a teletext row, the line count did not change, or the new row would leave the page. + /// + public static int? GetRowKeepingBottomEdge(string marginV, int oldLineCount, int newLineCount, bool doubleHeight) + { + if (oldLineCount == newLineCount || oldLineCount < 1 || newLineCount < 1) + { + return null; + } + + if (!int.TryParse(marginV, out var row) || row < 1 || row > BottomRow) + { + return null; + } + + var rowsPerLine = doubleHeight ? 2 : 1; + var newRow = row + (oldLineCount - newLineCount) * rowsPerLine; + return newRow >= 1 && newRow <= BottomRow ? newRow : null; + } } diff --git a/src/ui/Logic/TimeSpanExtensions.cs b/src/ui/Logic/TimeSpanExtensions.cs index 90ebed52863..ba83884140b 100644 --- a/src/ui/Logic/TimeSpanExtensions.cs +++ b/src/ui/Logic/TimeSpanExtensions.cs @@ -10,4 +10,51 @@ public static bool IsMaxTime(this TimeSpan ts) { return Math.Abs(ts.TotalMilliseconds - MaxTimeTotalMilliseconds) < 0.01; } + + /// + /// rounded to a whole millisecond - + /// see . + /// + public static TimeSpan FromSecondsWholeMilliseconds(double seconds) + { + return FromMillisecondsWholeMilliseconds(seconds * 1000.0); + } + + /// + /// rounded to a whole millisecond - the only + /// resolution subtitle formats (and every time code SE displays) can represent. + /// + /// Since .NET Core 3.0 and + /// truncate to ticks instead of rounding to + /// whole milliseconds like .NET Framework did, so e.g. TimeSpan.FromSeconds(0.82) is + /// 819.9999 ms, not 820 ms. Such a value renders as "0,820" in the duration up/down (which + /// rounds) and as "0,819" in the grid (which truncates, like the format writers do), and the + /// end time it produces really is a millisecond early - see issue #14056. Every place that + /// turns a fractional-millisecond double (typed input, pixel positions, video positions, + /// scale factors) into a subtitle time must come through here. + /// + /// Out-of-range values are clamped to the time-code domain (±99:59:59,999) rather than + /// TimeSpan's, so the result is always a whole millisecond a + /// can hold. NaN throws, like always has - a NaN + /// time is a bug at the call site and must not silently become 00:00:00,000. + /// + public static TimeSpan FromMillisecondsWholeMilliseconds(double milliseconds) + { + if (double.IsNaN(milliseconds)) + { + throw new ArgumentException("TimeSpan does not accept floating point Not-a-Number values.", nameof(milliseconds)); + } + + var ms = Math.Round(milliseconds, MidpointRounding.AwayFromZero); + if (ms > MaxTimeTotalMilliseconds) + { + ms = MaxTimeTotalMilliseconds; + } + else if (ms < -MaxTimeTotalMilliseconds) + { + ms = -MaxTimeTotalMilliseconds; + } + + return TimeSpan.FromTicks((long)ms * TimeSpan.TicksPerMillisecond); + } } diff --git a/src/ui/Logic/UiEbuSaveHelper.cs b/src/ui/Logic/UiEbuSaveHelper.cs index 9d2b8e60b3f..56f02a54c25 100644 --- a/src/ui/Logic/UiEbuSaveHelper.cs +++ b/src/ui/Logic/UiEbuSaveHelper.cs @@ -1,19 +1,61 @@ -using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Logic.Config; namespace Nikse.SubtitleEdit.Logic; +/// +/// Carries the EBU STL save options that do not fit in the 1024-character GSI header from the +/// options dialog to . SE 4 could do without this because +/// the dialog itself was the save helper and wrote straight into the header object being saved; +/// here the dialog closes long before the save runs, so the leftovers are parked on the helper and +/// put back when the writer initializes. +/// public class UiEbuSaveHelper : Ebu.IEbuUiHelper { - private byte _justificationCode = 2; + private byte _justificationCode; + private string? _frameRateHeader; + private double _frameRate; public UiEbuSaveHelper() { + // A save that never went through the options dialog (e.g. Ctrl+S on a loaded STL file) + // still uses the justification the user last picked; 2 (centered) on a fresh install. + var justification = Se.Settings.File.EbuSaveOptions.JustificationCode; + _justificationCode = justification >= 0 && justification <= 3 ? (byte)justification : (byte)2; + } + + /// + /// Remembers the frame rate picked in the save options dialog. It is tied to the header the + /// dialog produced, so it is applied to that subtitle only - saving some other file must keep + /// using the rate its own disk format code implies. + /// + public void SetFrameRate(string? header, double frameRate) + { + _frameRateHeader = header; + _frameRate = frameRate; + } + + /// + /// The frame rate last picked for , if any - the options dialog uses it + /// to show the rate the user chose rather than the one the disk format code implies. + /// + public bool TryGetFrameRate(string? header, out double frameRate) + { + frameRate = _frameRate; + return _frameRate > 20 && header != null && header == _frameRateHeader; } public void Initialize(Ebu.EbuGeneralSubtitleInformation header, byte justificationCode, string fileName, Subtitle subtitle) { _justificationCode = justificationCode; + + // The frame rate is not part of the header bytes, so the writer has just lost it while + // re-reading the header off the subtitle - put it back. + if (header != null && TryGetFrameRate(subtitle?.Header, out var frameRate)) + { + header.FrameRateFromSaveDialog = frameRate; + } } public bool ShowDialogOk() diff --git a/src/ui/Logic/UiUtil.cs b/src/ui/Logic/UiUtil.cs index 02c3a82efa1..50f2ab3b536 100644 --- a/src/ui/Logic/UiUtil.cs +++ b/src/ui/Logic/UiUtil.cs @@ -1,4 +1,4 @@ -using Avalonia; +using Avalonia; using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Controls.Primitives; @@ -498,6 +498,25 @@ public static Button MakeBrowseButton(IRelayCommand? command) }; } + /// + /// Gives a card-style button a green-ish background while hovered, focused or pressed, + /// so the active choice stands out (used by the assisted split/move option cards). + /// + public static Button WithGreenishActiveBackground(this Button button) + { + var activeBrush = new SolidColorBrush(Color.FromArgb(0x50, 0x4C, 0xAF, 0x50)); + var pressedBrush = new SolidColorBrush(Color.FromArgb(0x78, 0x4C, 0xAF, 0x50)); + foreach (var (pseudoClass, brush) in new[] { (":pointerover", activeBrush), (":focus", activeBrush), (":pressed", pressedBrush) }) + { + button.Styles.Add(new Style(x => x.OfType bool HasPlaybackRestartedSince(long stopwatchTimestamp) => false; } diff --git a/src/ui/Logic/VideoPlayers/LibMpvDynamic/LibMpvDynamicPlayer.cs b/src/ui/Logic/VideoPlayers/LibMpvDynamic/LibMpvDynamicPlayer.cs index 9d152299069..f72d2e15592 100644 --- a/src/ui/Logic/VideoPlayers/LibMpvDynamic/LibMpvDynamicPlayer.cs +++ b/src/ui/Logic/VideoPlayers/LibMpvDynamic/LibMpvDynamicPlayer.cs @@ -1,4 +1,4 @@ -using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.Logic.Config; using Nikse.SubtitleEdit.Logic.Download; using System; @@ -35,6 +35,13 @@ public sealed class LibMpvDynamicPlayer : IDisposable, IVideoPlayer private string _fileName = string.Empty; private double? _audioEndBound; + // A LoadFile that arrived before the (lazily initialized) core was up - replayed by + // MarkCoreInitialized as soon as the first render pass brings the core online (#14047). + // Claimed via Interlocked.Exchange so the replay and a concurrent LoadFile can't both + // run the same request. + private string? _pendingLoadFileName; + private double _pendingLoadStartPositionSeconds; + // Observed-property caches, kept current by the mpv event thread (see StartEventLoop). // While _eventLoopActive the pause/speed/duration/eof getters read these instead of // doing a synchronous P/Invoke into the core per call. Doubles go through Interlocked @@ -51,6 +58,32 @@ public sealed class LibMpvDynamicPlayer : IDisposable, IVideoPlayer private volatile bool _observedTimePosValid; // false = property unavailable (no file) -> Position reports 0, like the live read private long _lastPlaybackRestartTimestamp; // Stopwatch ticks of the last MPV_EVENT_PLAYBACK_RESTART + // Seek generations. The Position setter hands each async seek an id; the event loop records + // which ids mpv has acknowledged (MPV_EVENT_COMMAND_REPLY) and, for every playback restart, + // the id generation that restart followed. See HasPlaybackRestartedSince for why a plain + // timestamp comparison is not enough. + private long _lastSeekCommandId; // newest id handed out by the Position setter + private long _ackedSeekCommandId; // newest id mpv has replied to (event thread) + private long _restartAckedSeekCommandId; // _ackedSeekCommandId as of the last restart + + // Two-tier scrub seeking (see ScrubSeekPolicy): a seek issued mid-burst is served fast, at + // keyframes, and records here that it still owes an exact landing. The restart that seek + // fires is what asks whether the burst has settled, so the state has to be published before + // the command goes out - mpv can serve a keyframe seek and post the restart while this thread + // is still in the setter, and a follow-up recorded after that would wait for a restart that + // has already been and gone. + private long _scrubFollowUpSeekId; // keyframe seek owing an exact landing, 0 if none + private long _scrubFollowUpTargetBits; // that seek's target, as double bits + + // Guards every publish and claim of the seek generation + follow-up debt (IssueSeek, the + // follow-up/settle/cancel paths). The id, target and debt slot are three separate fields, so + // without one region a follow-up on the event thread could pass its staleness check against + // a generation the UI thread was mid-way through replacing, claim the superseded debt, and + // send an exact seek to the old scrub target after the user's newer seek - and the follow-up's + // own IssueSeek could then overwrite the newer seek's just-published debt. Uncontended in + // practice: the UI thread seeks, the event thread pays at most one follow-up per settled burst. + private readonly object _seekStateLock = new(); + [StructLayout(LayoutKind.Sequential)] private struct MpvOpenGlInitParams { @@ -263,9 +296,15 @@ private struct MpvEventProperty private const int MPV_FORMAT_DOUBLE = 5; private const int MPV_EVENT_SHUTDOWN = 1; + private const int MPV_EVENT_COMMAND_REPLY = 5; private const int MPV_EVENT_PLAYBACK_RESTART = 21; private const int MPV_EVENT_PROPERTY_CHANGE = 22; + // reply_userdata base for the async seek commands (see the Position setter). Command + // replies and property-change events carry reply_userdata from separate namespaces, but + // keeping seek ids far clear of the ObserveId* values leaves nothing to confuse. + private const ulong SeekReplyIdBase = 1UL << 32; + // reply_userdata ids for the observed properties (see StartEventLoop) private const ulong ObserveIdPause = 1; private const ulong ObserveIdSpeed = 2; @@ -355,35 +394,39 @@ private static string[] GetLibraryPaths() private void LoadLibMpvMethods() { - _mpvCreate = (MpvCreate)GetDllType(typeof(MpvCreate), "mpv_create"); - _mpvInitialize = (MpvInitialize)GetDllType(typeof(MpvInitialize), "mpv_initialize"); - _mpvWaitEvent = (MpvWaitEvent)GetDllType(typeof(MpvWaitEvent), "mpv_wait_event"); - _mpvObserveProperty = (MpvObserveProperty)GetDllType(typeof(MpvObserveProperty), "mpv_observe_property"); - _mpvWakeup = (MpvWakeup)GetDllType(typeof(MpvWakeup), "mpv_wakeup"); - _mpvCommand = (MpvCommand)GetDllType(typeof(MpvCommand), "mpv_command"); - _mpvCommandAsync = (MpvCommandAsync)GetDllType(typeof(MpvCommandAsync), "mpv_command_async"); - _mpvSetOption = (MpvSetOption)GetDllType(typeof(MpvSetOption), "mpv_set_option"); - _mpvSetOptionString = (MpvSetOptionString)GetDllType(typeof(MpvSetOptionString), "mpv_set_option_string"); - _mpvGetPropertyString = (MpvGetPropertyString)GetDllType(typeof(MpvGetPropertyString), "mpv_get_property"); - _mpvGetPropertyDouble = (MpvGetPropertyDouble)GetDllType(typeof(MpvGetPropertyDouble), "mpv_get_property"); - _mpvGetPropertyFlag = (MpvGetPropertyFlag)GetDllType(typeof(MpvGetPropertyFlag), "mpv_get_property"); - _mpvSetProperty = (MpvSetProperty)GetDllType(typeof(MpvSetProperty), "mpv_set_property"); - _mpvFree = (MpvFree)GetDllType(typeof(MpvFree), "mpv_free"); - _mpvClientApiVersion = (MpvClientApiVersion)GetDllType(typeof(MpvClientApiVersion), "mpv_client_api_version"); - _mpvErrorString = (MpvErrorString)GetDllType(typeof(MpvErrorString), "mpv_error_string"); - _mpvTerminateDestroy = (MpvTerminateDestroy)GetDllType(typeof(MpvTerminateDestroy), "mpv_terminate_destroy"); + _mpvCreate = (MpvCreate?)GetDllType(typeof(MpvCreate), "mpv_create"); + _mpvInitialize = (MpvInitialize?)GetDllType(typeof(MpvInitialize), "mpv_initialize"); + _mpvWaitEvent = (MpvWaitEvent?)GetDllType(typeof(MpvWaitEvent), "mpv_wait_event"); + _mpvObserveProperty = (MpvObserveProperty?)GetDllType(typeof(MpvObserveProperty), "mpv_observe_property"); + _mpvWakeup = (MpvWakeup?)GetDllType(typeof(MpvWakeup), "mpv_wakeup"); + _mpvCommand = (MpvCommand?)GetDllType(typeof(MpvCommand), "mpv_command"); + _mpvCommandAsync = (MpvCommandAsync?)GetDllType(typeof(MpvCommandAsync), "mpv_command_async"); + _mpvSetOption = (MpvSetOption?)GetDllType(typeof(MpvSetOption), "mpv_set_option"); + _mpvSetOptionString = (MpvSetOptionString?)GetDllType(typeof(MpvSetOptionString), "mpv_set_option_string"); + _mpvGetPropertyString = (MpvGetPropertyString?)GetDllType(typeof(MpvGetPropertyString), "mpv_get_property"); + _mpvGetPropertyDouble = (MpvGetPropertyDouble?)GetDllType(typeof(MpvGetPropertyDouble), "mpv_get_property"); + _mpvGetPropertyFlag = (MpvGetPropertyFlag?)GetDllType(typeof(MpvGetPropertyFlag), "mpv_get_property"); + _mpvSetProperty = (MpvSetProperty?)GetDllType(typeof(MpvSetProperty), "mpv_set_property"); + _mpvFree = (MpvFree?)GetDllType(typeof(MpvFree), "mpv_free"); + _mpvClientApiVersion = (MpvClientApiVersion?)GetDllType(typeof(MpvClientApiVersion), "mpv_client_api_version"); + _mpvErrorString = (MpvErrorString?)GetDllType(typeof(MpvErrorString), "mpv_error_string"); + _mpvTerminateDestroy = (MpvTerminateDestroy?)GetDllType(typeof(MpvTerminateDestroy), "mpv_terminate_destroy"); // Load render API functions - _mpvRenderContextCreate = (MpvRenderContextCreate)GetDllType(typeof(MpvRenderContextCreate), "mpv_render_context_create"); - _mpvRenderContextRender = (MpvRenderContextRender)GetDllType(typeof(MpvRenderContextRender), "mpv_render_context_render"); - _mpvRenderContextFree = (MpvRenderContextFree)GetDllType(typeof(MpvRenderContextFree), "mpv_render_context_free"); - _mpvRenderContextSetUpdateCallback = (MpvRenderContextSetUpdateCallback)GetDllType(typeof(MpvRenderContextSetUpdateCallback), "mpv_render_context_set_update_callback"); + _mpvRenderContextCreate = (MpvRenderContextCreate?)GetDllType(typeof(MpvRenderContextCreate), "mpv_render_context_create"); + _mpvRenderContextRender = (MpvRenderContextRender?)GetDllType(typeof(MpvRenderContextRender), "mpv_render_context_render"); + _mpvRenderContextFree = (MpvRenderContextFree?)GetDllType(typeof(MpvRenderContextFree), "mpv_render_context_free"); + _mpvRenderContextSetUpdateCallback = (MpvRenderContextSetUpdateCallback?)GetDllType(typeof(MpvRenderContextSetUpdateCallback), "mpv_render_context_set_update_callback"); } - private object GetDllType(Type type, string name) + private object? GetDllType(Type type, string name) { + // null, not IntPtr.Zero, when the export is missing: every caller casts the result to a + // delegate type, so a boxed IntPtr threw InvalidCastException instead - which made the + // "== null" libvlc-4 fallbacks unreachable and turned one missing symbol into a failed + // load and a silent EmptyVideoPlayer. var address = NativeMethods.CrossGetProcAddress(_library, name); - return address != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(address, type) : IntPtr.Zero; + return address != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(address, type) : null; } private bool LoadLibraryInternal() @@ -437,18 +480,58 @@ public int Initialize() } SetYtDlpPathOption(); - SetAudioBufferOption(); + SetPreInitAudioOptions(); var err = _mpvInitialize(_mpv); if (err >= 0) { - _coreInitialized = true; - StartEventLoop(); + MarkCoreInitialized(); } return err; } + /// + /// Flips the core to initialized, starts the event loop, and replays a LoadFile that + /// arrived before the core was up. The core is created lazily by the first render pass + /// (see WaitForCoreInitializedAsync), so a load issued before any frame was rendered - + /// e.g. an "Open with" launch while the video window is still being built, or a window + /// opened minimized/behind - used to fail with MPV_ERROR_UNINITIALIZED and the open was + /// silently lost (#14047). + /// + private void MarkCoreInitialized() + { + _coreInitialized = true; + StartEventLoop(); + + var pendingFileName = Interlocked.Exchange(ref _pendingLoadFileName, null); + if (string.IsNullOrEmpty(pendingFileName)) + { + return; + } + + var startPositionSeconds = _pendingLoadStartPositionSeconds; + + // Off this thread: the rendering Initialize* methods run during a render pass, and + // loadfile has no business there (it can block on I/O). + Task.Run(async () => + { + try + { + if (_disposed) + { + return; + } + + await LoadFile(pendingFileName, startPositionSeconds); + } + catch (Exception e) + { + Se.LogError(e, "LibMpvDynamicPlayer deferred LoadFile replay"); + } + }); + } + /// /// Observes the state-shaped properties (pause/speed/duration/eof-reached) and runs a /// dedicated thread draining mpv's event queue into the caches above. Before this, every @@ -563,7 +646,24 @@ private void RunEventLoop(IntPtr handle) if (mpvEvent.eventId == MPV_EVENT_PLAYBACK_RESTART) { + // Timestamp first, generation second. HasPlaybackRestartedSince needs BOTH, so + // publishing the generation first would open a window where an old restart's + // timestamp is paired with this restart's generation - and the answer would then + // depend on the caller's timestamp rather than on one restart. In this order the + // half-published state fails the generation gate, i.e. reads as "not yet". Interlocked.Exchange(ref _lastPlaybackRestartTimestamp, System.Diagnostics.Stopwatch.GetTimestamp()); + Interlocked.Exchange(ref _restartAckedSeekCommandId, Interlocked.Read(ref _ackedSeekCommandId)); + + // A restart is "a seek finished", which is the only honest moment to ask whether + // a scrub burst has stopped and a deferred exact landing is now due. + IssueScrubFollowUpSeekIfSettled(); + } + else if (mpvEvent.eventId == MPV_EVENT_COMMAND_REPLY && mpvEvent.replyUserdata >= SeekReplyIdBase) + { + // mpv ran one of our seeks. Its queue is FIFO and the reply is posted when the + // command runs, so every restart dequeued after this one can have been caused by + // this seek - and every restart dequeued before it cannot. + Interlocked.Exchange(ref _ackedSeekCommandId, (long)(mpvEvent.replyUserdata - SeekReplyIdBase)); } else if (mpvEvent.eventId == MPV_EVENT_PROPERTY_CHANGE && mpvEvent.data != IntPtr.Zero) { @@ -679,7 +779,133 @@ private void StopEventLoop() /// public bool HasPlaybackRestartedSince(long stopwatchTimestamp) { - return Interlocked.Read(ref _lastPlaybackRestartTimestamp) > stopwatchTimestamp; + if (Interlocked.Read(ref _lastPlaybackRestartTimestamp) <= stopwatchTimestamp) + { + return false; + } + + // A restart stamped after the caller's timestamp is not on its own proof that the seek + // the caller cares about has landed: the event thread stamps restarts when it PROCESSES + // them, so a restart mpv queued earlier - the one that starting playback fires, say - + // can be dequeued, and stamped, after a seek issued in the meantime. That made a fresh + // seek look already finished, and Pause() then discarded the target it was about to + // reach (#14187). mpv's event queue is FIFO, so the honest test is the seek generation: + // a restart that was dequeued before the newest seek's own MPV_EVENT_COMMAND_REPLY + // cannot have been caused by that seek. + var pending = Interlocked.Read(ref _lastSeekCommandId); + return pending == 0 || Interlocked.Read(ref _restartAckedSeekCommandId) >= pending; + } + + /// + /// Whether a seek SE issued has not landed yet - the burst signal two-tier seeking keys on + /// (see ). Always false without the event loop: no restart + /// events arrive there, so nothing could ever pay a deferred exact landing and every seek + /// has to be exact on the spot. + /// + private bool IsSeekInFlight() + { + var issuedAt = Interlocked.Read(ref _lastSeekIssuedTimestamp); + var age = issuedAt == 0 + ? 0 + : (System.Diagnostics.Stopwatch.GetTimestamp() - issuedAt) / (double)System.Diagnostics.Stopwatch.Frequency; + + return ScrubSeekPolicy.SeekIsInFlight( + _eventLoopActive, + Interlocked.Read(ref _lastSeekCommandId), + Interlocked.Read(ref _restartAckedSeekCommandId), + age); + } + + /// + /// Pays the exact landing a mid-burst keyframe seek deferred, once that seek has landed and + /// nothing newer has replaced it. Runs on the event thread, from the restart event that says + /// the seek finished. + /// + private void IssueScrubFollowUpSeekIfSettled() + { + if (_disposed || _mpv == IntPtr.Zero) + { + return; + } + + // The whole decide-claim-issue sequence under the lock: checked against a generation the + // setter can no longer be mid-way through replacing, and a newer seek published while the + // follow-up is being decided waits at IssueSeek's lock instead of racing it. That newer + // seek then finds the debt already claimed and zeroed, records its own, and its own + // restart asks again. + lock (_seekStateLock) + { + var followUpId = Interlocked.Read(ref _scrubFollowUpSeekId); + if (followUpId == 0) + { + return; + } + + var target = BitConverter.Int64BitsToDouble(Interlocked.Read(ref _scrubFollowUpTargetBits)); + + // The observed position, never the Position getter: that one answers with the pinned + // target while paused, so every deferred landing would look already reached. + double? reported = _observedTimePosValid + ? BitConverter.Int64BitsToDouble(Interlocked.Read(ref _observedTimePosBits)) + : null; + + if (!ScrubSeekPolicy.ShouldIssueFollowUp( + followUpId, + Interlocked.Read(ref _lastSeekCommandId), + Interlocked.Read(ref _restartAckedSeekCommandId), + target, + reported)) + { + return; + } + + // Claim it, so one settled burst issues one follow-up. + Interlocked.Exchange(ref _scrubFollowUpSeekId, 0); + + // Generation-tracked like every other seek, so the playhead pin holds for this landing + // rather than releasing on the keyframe one. Reentrant: IssueSeek takes the same lock. + IssueSeek(target, forceExact: true); + } + } + + /// + /// Pays a deferred exact landing now, if one is owed. Called where the position has to be the + /// one the user picked before the next thing happens - starting playback or stepping a frame - + /// because mpv runs queued commands in order, so the seek lands first. Without this, playback + /// could start at the keyframe the burst stopped on, a whole GOP before the chosen frame. + /// + private void SettlePendingExactSeek() + { + // Locked so the claim and the target read are one step - unlocked, a concurrent seek + // could replace the target between them and the settle would land on the wrong burst's + // position. + lock (_seekStateLock) + { + var followUpId = Interlocked.Exchange(ref _scrubFollowUpSeekId, 0); + if (followUpId == 0 || _disposed || _mpv == IntPtr.Zero) + { + return; + } + + IssueSeek(BitConverter.Int64BitsToDouble(Interlocked.Read(ref _scrubFollowUpTargetBits)), forceExact: true); + } + } + + /// + /// Drops a deferred exact landing, for the paths that discard the position outright - load, + /// close, stop. Paths that keep playing from it settle it instead + /// (), and pause deliberately leaves it standing: pausing + /// during or right after a scrub is exactly when that landing is still wanted. + /// + private void CancelPendingExactSeek() + { + // Locked so a follow-up mid-decision on the event thread cannot issue the landing this + // cancel is dropping: it either finishes first (the cancel then clears nothing new) or + // waits and finds the debt gone. + lock (_seekStateLock) + { + Interlocked.Exchange(ref _scrubFollowUpSeekId, 0); + } } private static byte[] GetUtf8Bytes(string s) @@ -725,6 +951,28 @@ public void ApplySubtitleMarginArea() SetOptionString("sub-ass-force-margins", useMargins ? "yes" : "no"); } + /// + /// How the lines of a multi-line preview subtitle are justified inside the text block + /// (#14167) - not the same thing as the alignment, which moves the whole block and rides + /// along in the generated ASS style. Justification has no ASS style field: it is a player + /// option, and sub-justify reaches an ASS subtitle - which the preview is - only with + /// sub-ass-justify on, which mpv defaults to "no". + /// + /// Both options are written on every call, so picking "auto" again restores mpv's own + /// defaults instead of leaving the last value in place. + /// + public void ApplySubtitleJustify() + { + var justify = Se.Settings.Video.MpvPreviewJustify; + if (string.IsNullOrWhiteSpace(justify)) + { + justify = "auto"; + } + + SetOptionString("sub-justify", justify); + SetOptionString("sub-ass-justify", justify == "auto" ? "no" : "yes"); + } + public int SetOptionString(string name, string value) { if (_mpvSetOptionString == null || _mpv == IntPtr.Zero) @@ -879,6 +1127,42 @@ private void SetAudioBufferOption() } } + /// + /// Every mpv audio option that has to be set before mpv_initialize, in one call so a new + /// init path cannot pick up half of them. + /// + private void SetPreInitAudioOptions() + { + SetAudioBufferOption(); + SetAudioStreamSilenceOption(); + } + + /// + /// mpv's "audio-stream-silence". Normally mpv stops the audio device when playback pauses + /// (on Windows, IAudioClient::Stop) and resets it on every seek. Over HDMI to an A/V + /// receiver the link then goes idle - the receiver reports no signal - and restarting it + /// costs a re-handshake, heard as a second or two of missing audio on resume (#14330). With + /// the option set, mpv keeps the device running and writes silence while paused, seeking or + /// at end of file, so the link never drops. + /// Left alone unless the setting asks for it: mpv's manual calls this option + /// "strongly discouraged" because it changes A/V-sync and underrun handling, and it only + /// helps that HDMI-receiver case. + /// Must be called before mpv_initialize. + /// + private void SetAudioStreamSilenceOption() + { + if (!Se.Settings.Video.MpvAudioStreamSilence) + { + return; // mpv's own default: stop the device on pause + } + + var err = SetOptionString("audio-stream-silence", "yes"); + if (err < 0) + { + Se.LogError(new InvalidOperationException(GetErrorString(err)), "LibMpvDynamicPlayer could not set audio-stream-silence"); + } + } + private int _brightness; public int ToggleBrightness() @@ -951,14 +1235,18 @@ private int DoMpvCommand(params string[] args) /// form) holds the caller until the core's dispatch accepts the command - during a scrub /// or slider-drag seek storm on a heavy file that stall lands on the UI thread, once per /// mouse move. mpv_command_async returns as soon as the command is copied into the queue; - /// the core posts an MPV_EVENT_COMMAND_REPLY back, which the event loop drains (and - /// ignores - matching the synchronous path, whose seek result was never acted on either). + /// the core posts an MPV_EVENT_COMMAND_REPLY back, which the event loop drains - for seeks + /// it also reads it, as the marker that orders a seek against the playback restarts around + /// it (see ). /// mpv copies the argument array before returning, so the buffers are freed right away /// exactly like in . Falls back to the synchronous path when - /// the event loop is not running, so the reply events cannot pile up unread. + /// the event loop is not running, so the reply events cannot pile up unread - + /// says which path ran, because only the async one produces + /// the MPV_EVENT_COMMAND_REPLY the seek generations are tracked by. /// - private int DoMpvCommandFireAndForget(params string[] args) + private int DoMpvCommandFireAndForget(ulong replyUserdata, out bool queuedAsync, params string[] args) { + queuedAsync = false; if (_mpv == IntPtr.Zero) { return 0; @@ -969,8 +1257,9 @@ private int DoMpvCommandFireAndForget(params string[] args) return DoMpvCommand(args); } + queuedAsync = true; var mainPtr = AllocateUtf8IntPtrArrayWithSentinel(args, out var byteArrayPointers); - var result = _mpvCommandAsync(_mpv, 0, mainPtr); + var result = _mpvCommandAsync(_mpv, replyUserdata, mainPtr); foreach (var ptr in byteArrayPointers) { Marshal.FreeHGlobal(ptr); @@ -988,7 +1277,11 @@ private void OnRenderUpdate(IntPtr ctx) public void InitializeWithOpenGL(GetProcAddress getProcAddress) { - LoadLibraryInternal(); + // LoadLib(), not LoadLibraryInternal(): the latter always calls mpv_create() and + // overwrites _mpv. CanLoad() has already created a core by the time the render path gets + // here, so this created a second one and orphaned the first - its threads and allocations + // leaked for the process lifetime, on every player construction. + LoadLib(); EnsureNotDisposed(); if (_mpvInitialize == null || _mpvRenderContextCreate == null || _mpvRenderContextSetUpdateCallback == null) @@ -1009,7 +1302,7 @@ public void InitializeWithOpenGL(GetProcAddress getProcAddress) // so let mpv auto-detect from the context it receives. SetYtDlpPathOption(); - SetAudioBufferOption(); + SetPreInitAudioOptions(); // Initialize mpv first var err = _mpvInitialize(_mpv); @@ -1019,8 +1312,7 @@ public void InitializeWithOpenGL(GetProcAddress getProcAddress) } else { - _coreInitialized = true; - StartEventLoop(); + MarkCoreInitialized(); } // Create OpenGL init params @@ -1096,7 +1388,11 @@ public void InitializeWithOpenGL(GetProcAddress getProcAddress) [System.Runtime.Versioning.SupportedOSPlatform("macos")] public void InitializeWithMetal(IntPtr mtlDevice, IntPtr metalLayer) { - LoadLibraryInternal(); + // LoadLib(), not LoadLibraryInternal(): the latter always calls mpv_create() and + // overwrites _mpv. CanLoad() has already created a core by the time the render path gets + // here, so this created a second one and orphaned the first - its threads and allocations + // leaked for the process lifetime, on every player construction. + LoadLib(); EnsureNotDisposed(); if (_mpvInitialize == null || _mpvRenderContextCreate == null || _mpvRenderContextSetUpdateCallback == null) @@ -1111,7 +1407,7 @@ public void InitializeWithMetal(IntPtr mtlDevice, IntPtr metalLayer) SetStartPausedOption(); SetYtDlpPathOption(); - SetAudioBufferOption(); + SetPreInitAudioOptions(); var err = _mpvInitialize(_mpv); if (err < 0) @@ -1120,8 +1416,7 @@ public void InitializeWithMetal(IntPtr mtlDevice, IntPtr metalLayer) } else { - _coreInitialized = true; - StartEventLoop(); + MarkCoreInitialized(); } // Build mpv_metal_init_params: device (required) + layer (optional). @@ -1361,6 +1656,7 @@ private void FreeRenderContext() public void Dispose() { _disposed = true; + _pendingLoadFileName = null; if (_renderContextNeedsGraphicsContext && _renderContext != IntPtr.Zero) { @@ -1419,6 +1715,9 @@ public async Task LoadFile(string path, double startPositionSeconds = 0) { EnsureNotDisposed(); + // A fresh explicit load supersedes any older one still parked for the core (#14047). + _pendingLoadFileName = null; + // For audio-only files there is no video track, so mpv never fires the render // callback and subtitles are never drawn. Inject a virtual black video stream // via lavfi so mpv has something to render subtitles on top of. @@ -1446,13 +1745,46 @@ public async Task LoadFile(string path, double startPositionSeconds = 0) await WaitForCoreInitializedAsync(); + if (!_coreInitialized) + { + if (_disposed) + { + return; + } + + // Still not up: the core only comes online when the first render pass runs one of + // the Initialize* methods, and that pass hasn't happened yet (video window still + // being built, opened minimized or behind - e.g. an "Open with" launch, #14047). + // Issuing loadfile now would fail with MPV_ERROR_UNINITIALIZED and the open would + // be lost with only a log line to show for it. Park the request instead; + // MarkCoreInitialized replays it the moment the core comes up. Position before + // file name - the file name is the claim token, so it must be published last. + _fileName = path; + _pendingLoadStartPositionSeconds = startPositionSeconds; + _pendingLoadFileName = path; + + // The core may have come up between the timeout above and parking the request, + // with MarkCoreInitialized finding no pending load to replay. Re-check and take + // the request back; if the exchange loses, MarkCoreInitialized owns the replay. + if (!_coreInitialized || Interlocked.Exchange(ref _pendingLoadFileName, null) == null) + { + Se.LogError("LibMpvDynamicPlayer LoadFile: mpv core not initialized yet - load of \"" + path + "\" deferred to core initialization"); + return; + } + } + // mpv's own default is pause=no, so it starts playing the instant it has decoded // something. The core is created paused (see the Initialize* methods) and every caller // that wants playback asks for it explicitly, but pause it here too: it is a user // property, so anything the user did to the previous file - or a play that ran while // this one was being picked - would otherwise carry over into this load. - DoMpvCommand("set", "pause", "yes"); + if (DoMpvCommand("set", "pause", "yes") >= 0) + { + SetObservedPause(true); + } + _pausedValue = null; + CancelPendingExactSeek(); // Before loadfile, not after: mpv applies "sid" when the file loads, and setting it // afterwards left a window in which an external subtitle pushed by MpvReloader was added @@ -1461,7 +1793,11 @@ public async Task LoadFile(string path, double startPositionSeconds = 0) // without ever undoing a sub-add that got in first. SetOptionString("sid", "no"); - var err = await Task.Run(() => DoMpvCommand("loadfile", path)); + // Long local paths get the "\\?\" prefix on Windows: mpv opens the file with the path + // as given, and a plain path past MAX_PATH fails silently - no error, duration 0:00, + // Play does nothing (#14407). _fileName keeps the path the caller knows. + var loadPath = NativeMediaPath.ForMpv(path); + var err = await Task.Run(() => DoMpvCommand("loadfile", loadPath)); if (_disposed) { return; @@ -1482,6 +1818,7 @@ public async Task LoadFile(string path, double startPositionSeconds = 0) SetOptionString("rebase-start-time", "no"); ApplySubtitleMarginArea(); + ApplySubtitleJustify(); _fileName = path; @@ -1533,7 +1870,7 @@ public async Task LoadAudio(string path) // buttons in the text-to-speech windows, which load a file and expect to hear it. // Those windows build their own core via Initialize(), which - unlike the three // rendering Initialize* methods - deliberately leaves mpv's pause default alone. - var err = await Task.Run(() => DoMpvCommand("loadfile", path)); + var err = await Task.Run(() => DoMpvCommand("loadfile", NativeMediaPath.ForMpv(path))); if (_disposed) { return; @@ -1541,7 +1878,7 @@ public async Task LoadAudio(string path) if (err < 0) { - Se.LogError(new InvalidOperationException(GetErrorString(err)), "LibMpvDynamicPlayer LoadFile"); + Se.LogError(new InvalidOperationException(GetErrorString(err)), "LibMpvDynamicPlayer LoadAudio"); } SetOptionString("keep-open", "always"); @@ -1555,6 +1892,7 @@ public async Task LoadAudio(string path) public void PlayOrPause() { + SettlePendingExactSeek(); _pausedValue = null; EnsureNotDisposed(); if (_mpv == IntPtr.Zero) @@ -1567,12 +1905,18 @@ public void PlayOrPause() { Se.LogError(new InvalidOperationException(GetErrorString(err)), "LibMpvDynamicPlayer PlayOrPause"); } + else + { + RefreshObservedPause(); + } } public void CloseFile() { _fileName = string.Empty; + _pendingLoadFileName = null; // a close discards a load still parked for the core _pausedValue = null; + CancelPendingExactSeek(); _audioEndBound = null; _lastRawTimePos = -1; @@ -1664,8 +2008,58 @@ public bool IsPaused } + /// + /// Writes the pause state we just commanded straight into the observed cache. + /// / - and, through them, the paused-value + /// branch of the getter - read _observedPause, which only the + /// event thread updates when mpv's pause property-change event is dequeued. The pause + /// commands below are synchronous (mpv_command returns after the core has applied them), so + /// between the command returning and that event being processed the cache says the opposite + /// of the truth. A waveform click hits exactly that window: seek, then Pause(), then the + /// cursor reads Position - and with IsPaused still false the getter skipped the cached seek + /// target and served mpv's pre-seek time-pos (#14187). + /// + /// A pause change-event still queued from an earlier transition can briefly overwrite this + /// with its own (older) value, but the event for the command we just ran follows right + /// behind it and settles the cache again - and that is the same value we write here. + /// + private void SetObservedPause(bool paused) + { + _observedPause = paused; + } + + /// + /// Re-reads mpv's pause property into the observed cache. Used after "cycle pause", where + /// the resulting state is the core's to decide rather than ours to predict. + /// + private void RefreshObservedPause() + { + if (_mpv == IntPtr.Zero || _mpvGetPropertyFlag == null) + { + return; + } + + try + { + var pauseValue = 0; + if (_mpvGetPropertyFlag(_mpv, PropertyNamePause, MPV_FORMAT_FLAG, ref pauseValue) >= 0) + { + _observedPause = pauseValue != 0; + } + } + catch + { + // leave the cache to the event thread + } + } + private double? _pausedValue; + // Stopwatch timestamp of the last seek issued through the Position setter; 0 = none yet. + // Fed to HasPlaybackRestartedSince so Pause() can tell a seek target that is still in + // flight (keep it) from one whose seek finished long ago (stale - clear it). + private long _lastSeekIssuedTimestamp; + // Last raw time-pos seen by the Position getter/setter, used to gate the eof-reached // probe below. -1 = unknown (always probe). private double _lastRawTimePos = -1; @@ -1780,19 +2174,83 @@ public double Position } set { - _pausedValue = value; - _lastRawTimePos = value; // keep the eof-reached gate accurate across seeks - EnsureNotDisposed(); - if (_mpv == IntPtr.Zero) + // mpv clamps a negative seek to the start, but the cached value below is what the + // getter reports while paused - so nudging back at 0:00 handed callers (set start + // time, waveform, position display) a negative time until real playback resumed. + if (value < 0) { - return; + value = 0; } + IssueSeek(value, forceExact: false); + } + } + + /// + /// Sends one seek to mpv and records its generation. + /// + /// Target position in seconds. + /// + /// Skip the burst check and demand a precise landing. Used where the position has to be right + /// before the next command runs - paying a deferred landing before playback starts or a frame + /// is stepped - since mpv runs queued commands in order. + /// + private void IssueSeek(double value, bool forceExact) + { + EnsureNotDisposed(); + if (_mpv == IntPtr.Zero) + { + return; + } + + // Locked from the position pin through the command send: the pin, id, target and debt + // slot publish as one step against the event thread's follow-up claim, and the send + // inside the lock keeps mpv's FIFO command order matching generation order even when a + // follow-up and a fresh seek race. mpv_command_async only enqueues, so nothing slow + // runs here. + lock (_seekStateLock) + { + _pausedValue = value; + _lastRawTimePos = value; // keep the eof-reached gate accurate across seeks + + // A seek arriving while an earlier one is still in flight is a burst - a waveform + // drag, a slider drag, a wheel spin, one seek per input event - and all but its last + // seek are about to be superseded. Those are served fast, at keyframes, and the exact + // landing is deferred to when the burst settles (ScrubSeekPolicy). An isolated seek, + // the common case, is exact right away as before. + var inBurst = !forceExact && IsSeekInFlight(); + var seekFlags = ScrubSeekPolicy.FlagsFor(inBurst); + // Fire-and-forget: seeks arrive in storms (scrubbing, slider drags, wheel steps - - // one per input event), every caller already treats the result as asynchronous - // (the playhead pin waits for the position to actually arrive), and the error - // result was never acted on here. - DoMpvCommandFireAndForget("seek", value.ToString(CultureInfo.InvariantCulture), "absolute"); + // one per input event) and every caller already treats the result as asynchronous + // (the playhead pin waits for the position to actually arrive). The reply id is the + // seek's generation marker, so "has this seek restarted yet?" can be answered from + // mpv's own event order rather than from two independently taken clock readings. + var seekId = Interlocked.Increment(ref _lastSeekCommandId); + Interlocked.Exchange(ref _lastSeekIssuedTimestamp, System.Diagnostics.Stopwatch.GetTimestamp()); + + // Debt state before the command: mpv can serve a keyframe seek and post the restart + // while this thread is still in the setter, and a follow-up recorded after that would + // wait for a restart that has already been and gone. + Interlocked.Exchange(ref _scrubFollowUpTargetBits, BitConverter.DoubleToInt64Bits(value)); + Interlocked.Exchange(ref _scrubFollowUpSeekId, inBurst ? seekId : 0); + + var seekResult = DoMpvCommandFireAndForget(SeekReplyIdBase + (ulong)seekId, out var queuedAsync, + "seek", value.ToString(CultureInfo.InvariantCulture), seekFlags); + if (!queuedAsync || seekResult < 0) + { + // No MPV_EVENT_COMMAND_REPLY is coming for this one - it ran synchronously, or + // mpv refused it - so nothing would ever advance the acknowledged generation and + // the seek would look in flight forever. Retire the id here instead. + Interlocked.Exchange(ref _ackedSeekCommandId, seekId); + Interlocked.Exchange(ref _restartAckedSeekCommandId, seekId); + + // No restart event is coming either, so nothing would ever issue the deferred + // exact seek. (IsSeekInFlight is false without the event loop, so this is only + // reachable for a seek mpv refused - but an owed landing that can never be paid + // must not be left standing.) + Interlocked.Exchange(ref _scrubFollowUpSeekId, 0); + } } } @@ -1936,6 +2394,7 @@ public double Speed public void Stop() { _pausedValue = null; + CancelPendingExactSeek(); EnsureNotDisposed(); if (_mpv == IntPtr.Zero) { @@ -1948,6 +2407,10 @@ public void Stop() { Se.LogError(new InvalidOperationException(GetErrorString(err)), "LibMpvDynamicPlayer Stop pause"); } + else + { + SetObservedPause(true); + } // Seek back to position 0 err = DoMpvCommand("seek", "0", "absolute"); @@ -1962,6 +2425,7 @@ public void Stop() public void Play() { + SettlePendingExactSeek(); _pausedValue = null; EnsureNotDisposed(); if (_mpv == IntPtr.Zero) @@ -1974,6 +2438,10 @@ public void Play() { Se.LogError(new InvalidOperationException(GetErrorString(err)), "LibMpvDynamicPlayer play"); } + else + { + SetObservedPause(false); + } } public void Pause() @@ -1984,15 +2452,42 @@ public void Pause() return; } + // A finished seek's target is stale: pausing after a seek made minutes ago during + // playback made the Position getter keep returning that old target, so the slider, + // clock and playhead all jumped back to it - clear it, like the other state + // transitions do (LoadFile/PlayOrPause/CloseFile/Stop/Play/frame steps). + // + // But a seek still in flight is the opposite case: its target IS where playback is + // about to be, and the waveform click path depends on the getter reporting it. A + // click seeks first (pointer release) and pauses a moment later (tap), and the + // second Position assignment no-ops in Avalonia because the property already holds + // the value - so clearing here left the getter serving mpv's pre-seek position + // until the async seek landed, and the cursor jumped away from the click (#14187). + // "In flight" means the newest seek's own playback restart has not been observed yet - + // HasPlaybackRestartedSince checks the seek generation, not just the clock, so a restart + // left over from starting playback cannot pass for this seek's. + var seekInFlight = _eventLoopActive && + Interlocked.Read(ref _lastSeekIssuedTimestamp) != 0 && + !HasPlaybackRestartedSince(Interlocked.Read(ref _lastSeekIssuedTimestamp)); + if (!seekInFlight) + { + _pausedValue = null; + } + var err = DoMpvCommand("set", "pause", "yes"); if (err < 0) { Se.LogError(new InvalidOperationException(GetErrorString(err)), "LibMpvDynamicPlayer pause"); } + else + { + SetObservedPause(true); + } } public void StepOneFrameForward() { + SettlePendingExactSeek(); _pausedValue = null; EnsureNotDisposed(); if (_mpv == IntPtr.Zero) @@ -2009,6 +2504,7 @@ public void StepOneFrameForward() public void StepOneFrameBack() { + SettlePendingExactSeek(); _pausedValue = null; EnsureNotDisposed(); if (_mpv == IntPtr.Zero) @@ -2215,7 +2711,11 @@ public void SetAudioTrack(int trackId) public void InitializeWithSoftwareRendering() { - LoadLibraryInternal(); + // LoadLib(), not LoadLibraryInternal(): the latter always calls mpv_create() and + // overwrites _mpv. CanLoad() has already created a core by the time the render path gets + // here, so this created a second one and orphaned the first - its threads and allocations + // leaked for the process lifetime, on every player construction. + LoadLib(); EnsureNotDisposed(); // Set mpv to use software rendering @@ -2228,7 +2728,7 @@ public void InitializeWithSoftwareRendering() } SetYtDlpPathOption(); - SetAudioBufferOption(); + SetPreInitAudioOptions(); // Initialize mpv var err = _mpvInitialize(_mpv); @@ -2237,8 +2737,7 @@ public void InitializeWithSoftwareRendering() throw new InvalidOperationException(GetErrorString(err)); } - _coreInitialized = true; - StartEventLoop(); + MarkCoreInitialized(); // Build render context params for software rendering var apiTypeBytes = Encoding.UTF8.GetBytes(MPV_RENDER_API_TYPE_SW + "\0"); diff --git a/src/ui/Logic/VideoPlayers/LibMpvDynamic/ScrubSeekPolicy.cs b/src/ui/Logic/VideoPlayers/LibMpvDynamic/ScrubSeekPolicy.cs new file mode 100644 index 00000000000..220a6434cde --- /dev/null +++ b/src/ui/Logic/VideoPlayers/LibMpvDynamic/ScrubSeekPolicy.cs @@ -0,0 +1,128 @@ +using System; + +namespace Nikse.SubtitleEdit.Logic.VideoPlayers.LibMpvDynamic; + +/// +/// Decides how each seek SE sends to mpv is served: precisely, or fast and then precisely. +/// +/// An absolute seek is exact by default (mpv's own rule, and SE also sets "hr-seek=yes"), which +/// means decoding every frame from the preceding keyframe up to the target - tens to hundreds of +/// milliseconds on a long-GOP 4K file. That is the right trade for a single click on the waveform. +/// It is the wrong trade for a drag or a wheel burst, where seeks arrive one per input event and +/// all but the last one are about to be superseded: paying for an exact landing on a position the +/// user is already moving away from is what makes scrubbing feel heavy. +/// +/// +/// So a seek issued while an earlier one is still in flight - the signature of a burst - is served +/// with "keyframes" instead, which skips that decode. Precision is not given up, only deferred: +/// the keyframe seek is recorded as owing an exact landing, and when the burst settles (that seek +/// lands and nothing newer has replaced it) one exact seek to the final target is issued. Isolated +/// seeks never take that path - the first seek of a burst is exact, as before - so nothing that +/// clicks once behaves differently. +/// +/// +public static class ScrubSeekPolicy +{ + /// + /// mpv seek flags for a precise landing. "absolute" alone already implies "exact"; saying it + /// keeps the intent readable and independent of the hr-seek option. + /// + public const string ExactSeekFlags = "absolute+exact"; + + /// + /// mpv seek flags for a fast landing at the preceding keyframe, used mid-burst. + /// + public const string KeyframeSeekFlags = "absolute+keyframes"; + + /// + /// How close the keyframe landing has to be to the target to count as already exact. A seek + /// whose target happens to be a keyframe lands on it, and re-seeking there would cost a + /// decode for no movement. + /// + public const double FollowUpToleranceSeconds = 0.001; + + /// + /// How long a seek may be counted as still in flight. Every seek ends in a playback restart, + /// but a seek mpv drops - on a core that has stopped, or a file that went away - never gets + /// one, and "in flight" would then latch forever: every later seek served at keyframes, each + /// deferring an exact landing that no restart is coming to pay. Past this age the seek is + /// treated as finished, which puts seeking back on the exact path by itself. + /// + public const double MaxSeekInFlightSeconds = 5.0; + + /// + /// The flags for a seek issued now: fast if this one is joining a burst, precise otherwise. + /// + public static string FlagsFor(bool seekInFlight) + { + return seekInFlight ? KeyframeSeekFlags : ExactSeekFlags; + } + + /// + /// Whether a seek SE issued is still on its way - the burst signal + /// keys on. + /// + /// + /// False when no mpv events are being read. Nothing could pay a deferred landing then, so no + /// seek counts as in flight and every seek stays exact. + /// + /// Newest seek id handed out. + /// Newest seek id mpv has finished a playback restart for. + /// Age of the newest seek. + public static bool SeekIsInFlight( + bool eventLoopActive, + long lastSeekCommandId, + long restartAckedSeekCommandId, + double secondsSinceLastSeekIssued) + { + if (!eventLoopActive || lastSeekCommandId == 0) + { + return false; + } + + if (restartAckedSeekCommandId >= lastSeekCommandId) + { + return false; // it has landed + } + + return secondsSinceLastSeekIssued < MaxSeekInFlightSeconds; + } + + /// + /// Whether the exact seek owed by a keyframe seek should be issued now. + /// + /// Id of the keyframe seek owing an exact landing, 0 if none. + /// Newest seek id handed out. + /// Newest seek id mpv has finished a playback restart for. + /// Position that keyframe seek was aiming at. + /// Where mpv says it is now, null if it has no position yet. + public static bool ShouldIssueFollowUp( + long followUpSeekId, + long lastSeekCommandId, + long restartAckedSeekCommandId, + double target, + double? reportedPosition) + { + if (followUpSeekId == 0) + { + return false; // nothing owes an exact landing + } + + if (followUpSeekId != lastSeekCommandId) + { + return false; // a newer seek owns the position now, and carries its own follow-up + } + + if (restartAckedSeekCommandId < followUpSeekId) + { + return false; // the burst is still running - a later restart gets to ask again + } + + if (reportedPosition == null) + { + return true; // cannot confirm the landing, so pay for it + } + + return Math.Abs(reportedPosition.Value - target) > FollowUpToleranceSeconds; + } +} diff --git a/src/ui/Logic/VideoPlayers/LibVlcDynamic/LibVlcDynamicPlayer.cs b/src/ui/Logic/VideoPlayers/LibVlcDynamic/LibVlcDynamicPlayer.cs index b2f9fb5ff35..064d1d7704e 100644 --- a/src/ui/Logic/VideoPlayers/LibVlcDynamic/LibVlcDynamicPlayer.cs +++ b/src/ui/Logic/VideoPlayers/LibVlcDynamic/LibVlcDynamicPlayer.cs @@ -318,65 +318,69 @@ private static string[] GetLibraryPaths() private void LoadLibVlcMethods() { - _libvlc_new = (libvlc_new)GetDllType(typeof(libvlc_new), "libvlc_new"); - _libvlc_get_version = (libvlc_get_version)GetDllType(typeof(libvlc_get_version), "libvlc_get_version"); - _libvlc_release = (libvlc_release)GetDllType(typeof(libvlc_release), "libvlc_release"); - - _libvlc_media_new_path = (libvlc_media_new_path)GetDllType(typeof(libvlc_media_new_path), "libvlc_media_new_path"); - _libvlc_media_player_new_from_media = (libvlc_media_player_new_from_media)GetDllType(typeof(libvlc_media_player_new_from_media), "libvlc_media_player_new_from_media"); - _libvlc_media_release = (libvlc_media_release)GetDllType(typeof(libvlc_media_release), "libvlc_media_release"); - _libvlc_media_parse_with_options = (libvlc_media_parse_with_options)GetDllType(typeof(libvlc_media_parse_with_options), "libvlc_media_parse_with_options"); - _libvlc_media_get_duration = (libvlc_media_get_duration)GetDllType(typeof(libvlc_media_get_duration), "libvlc_media_get_duration"); - _libvlc_media_add_option = (libvlc_media_add_option)GetDllType(typeof(libvlc_media_add_option), "libvlc_media_add_option"); - _libvlc_media_player_set_media = (libvlc_media_player_set_media)GetDllType(typeof(libvlc_media_player_set_media), "libvlc_media_player_set_media"); - - _libvlc_video_get_size = (libvlc_video_get_size)GetDllType(typeof(libvlc_video_get_size), "libvlc_video_get_size"); - _libvlc_video_set_spu = (libvlc_video_set_spu)GetDllType(typeof(libvlc_video_set_spu), "libvlc_video_set_spu"); - _libvlc_video_get_spu_description = (libvlc_video_get_spu_description)GetDllType(typeof(libvlc_video_get_spu_description), "libvlc_video_get_spu_description"); - _libvlc_video_set_callbacks = (libvlc_video_set_callbacks)GetDllType(typeof(libvlc_video_set_callbacks), "libvlc_video_set_callbacks"); - _libvlc_video_set_format = (libvlc_video_set_format)GetDllType(typeof(libvlc_video_set_format), "libvlc_video_set_format"); - _libvlc_video_take_snapshot = (libvlc_video_take_snapshot)GetDllType(typeof(libvlc_video_take_snapshot), "libvlc_video_take_snapshot"); - - _libvlc_audio_get_track_count = (libvlc_audio_get_track_count)GetDllType(typeof(libvlc_audio_get_track_count), "libvlc_audio_get_track_count"); - _libvlc_audio_get_track_description = (libvlc_audio_get_track_description)GetDllType(typeof(libvlc_audio_get_track_description), "libvlc_audio_get_track_description"); - _libvlc_audio_get_track = (libvlc_audio_get_track)GetDllType(typeof(libvlc_audio_get_track), "libvlc_audio_get_track"); - _libvlc_audio_set_track = (libvlc_audio_set_track)GetDllType(typeof(libvlc_audio_set_track), "libvlc_audio_set_track"); - _libvlc_audio_get_delay = (libvlc_audio_get_delay)GetDllType(typeof(libvlc_audio_get_delay), "libvlc_audio_get_delay"); - _libvlc_audio_get_volume = (libvlc_audio_get_volume)GetDllType(typeof(libvlc_audio_get_volume), "libvlc_audio_get_volume"); - _libvlc_audio_set_volume = (libvlc_audio_set_volume)GetDllType(typeof(libvlc_audio_set_volume), "libvlc_audio_set_volume"); - _libvlc_audio_set_mute = (libvlc_audio_set_mute)GetDllType(typeof(libvlc_audio_set_mute), "libvlc_audio_set_mute"); - - _libvlc_track_description_release = (libvlc_track_description_release)GetDllType(typeof(libvlc_track_description_release), "libvlc_track_description_release"); + _libvlc_new = (libvlc_new?)GetDllType(typeof(libvlc_new), "libvlc_new"); + _libvlc_get_version = (libvlc_get_version?)GetDllType(typeof(libvlc_get_version), "libvlc_get_version"); + _libvlc_release = (libvlc_release?)GetDllType(typeof(libvlc_release), "libvlc_release"); + + _libvlc_media_new_path = (libvlc_media_new_path?)GetDllType(typeof(libvlc_media_new_path), "libvlc_media_new_path"); + _libvlc_media_player_new_from_media = (libvlc_media_player_new_from_media?)GetDllType(typeof(libvlc_media_player_new_from_media), "libvlc_media_player_new_from_media"); + _libvlc_media_release = (libvlc_media_release?)GetDllType(typeof(libvlc_media_release), "libvlc_media_release"); + _libvlc_media_parse_with_options = (libvlc_media_parse_with_options?)GetDllType(typeof(libvlc_media_parse_with_options), "libvlc_media_parse_with_options"); + _libvlc_media_get_duration = (libvlc_media_get_duration?)GetDllType(typeof(libvlc_media_get_duration), "libvlc_media_get_duration"); + _libvlc_media_add_option = (libvlc_media_add_option?)GetDllType(typeof(libvlc_media_add_option), "libvlc_media_add_option"); + _libvlc_media_player_set_media = (libvlc_media_player_set_media?)GetDllType(typeof(libvlc_media_player_set_media), "libvlc_media_player_set_media"); + + _libvlc_video_get_size = (libvlc_video_get_size?)GetDllType(typeof(libvlc_video_get_size), "libvlc_video_get_size"); + _libvlc_video_set_spu = (libvlc_video_set_spu?)GetDllType(typeof(libvlc_video_set_spu), "libvlc_video_set_spu"); + _libvlc_video_get_spu_description = (libvlc_video_get_spu_description?)GetDllType(typeof(libvlc_video_get_spu_description), "libvlc_video_get_spu_description"); + _libvlc_video_set_callbacks = (libvlc_video_set_callbacks?)GetDllType(typeof(libvlc_video_set_callbacks), "libvlc_video_set_callbacks"); + _libvlc_video_set_format = (libvlc_video_set_format?)GetDllType(typeof(libvlc_video_set_format), "libvlc_video_set_format"); + _libvlc_video_take_snapshot = (libvlc_video_take_snapshot?)GetDllType(typeof(libvlc_video_take_snapshot), "libvlc_video_take_snapshot"); + + _libvlc_audio_get_track_count = (libvlc_audio_get_track_count?)GetDllType(typeof(libvlc_audio_get_track_count), "libvlc_audio_get_track_count"); + _libvlc_audio_get_track_description = (libvlc_audio_get_track_description?)GetDllType(typeof(libvlc_audio_get_track_description), "libvlc_audio_get_track_description"); + _libvlc_audio_get_track = (libvlc_audio_get_track?)GetDllType(typeof(libvlc_audio_get_track), "libvlc_audio_get_track"); + _libvlc_audio_set_track = (libvlc_audio_set_track?)GetDllType(typeof(libvlc_audio_set_track), "libvlc_audio_set_track"); + _libvlc_audio_get_delay = (libvlc_audio_get_delay?)GetDllType(typeof(libvlc_audio_get_delay), "libvlc_audio_get_delay"); + _libvlc_audio_get_volume = (libvlc_audio_get_volume?)GetDllType(typeof(libvlc_audio_get_volume), "libvlc_audio_get_volume"); + _libvlc_audio_set_volume = (libvlc_audio_set_volume?)GetDllType(typeof(libvlc_audio_set_volume), "libvlc_audio_set_volume"); + _libvlc_audio_set_mute = (libvlc_audio_set_mute?)GetDllType(typeof(libvlc_audio_set_mute), "libvlc_audio_set_mute"); + + _libvlc_track_description_release = (libvlc_track_description_release?)GetDllType(typeof(libvlc_track_description_release), "libvlc_track_description_release"); if (_libvlc_track_description_release == null) { //TODO: libvlc 4 beta... check when final version is out - _libvlc_track_description_release = (libvlc_track_description_release)GetDllType(typeof(libvlc_track_description_release), "libvlc_track_description_list_release"); + _libvlc_track_description_release = (libvlc_track_description_release?)GetDllType(typeof(libvlc_track_description_release), "libvlc_track_description_list_release"); } - _libvlc_media_player_play = (libvlc_media_player_play)GetDllType(typeof(libvlc_media_player_play), "libvlc_media_player_play"); - _libvlc_media_player_set_hwnd = (libvlc_media_player_set_hwnd)GetDllType(typeof(libvlc_media_player_set_hwnd), "libvlc_media_player_set_hwnd"); - _libvlc_media_player_set_xwindow = (libvlc_media_player_set_xwindow)GetDllType(typeof(libvlc_media_player_set_xwindow), "libvlc_media_player_set_xwindow"); - _libvlc_media_player_is_playing = (libvlc_media_player_is_playing)GetDllType(typeof(libvlc_media_player_is_playing), "libvlc_media_player_is_playing"); - _libvlc_media_player_set_pause = (libvlc_media_player_set_pause)GetDllType(typeof(libvlc_media_player_set_pause), "libvlc_media_player_set_pause"); - _libvlc_media_player_get_time = (libvlc_media_player_get_time)GetDllType(typeof(libvlc_media_player_get_time), "libvlc_media_player_get_time"); - _libvlc_media_player_set_time = (libvlc_media_player_set_time)GetDllType(typeof(libvlc_media_player_set_time), "libvlc_media_player_set_time"); - _libvlc_media_player_get_state = (libvlc_media_player_get_state)GetDllType(typeof(libvlc_media_player_get_state), "libvlc_media_player_get_state"); - _libvlc_media_player_get_length = (libvlc_media_player_get_length)GetDllType(typeof(libvlc_media_player_get_length), "libvlc_media_player_get_length"); - _libvlc_media_player_release = (libvlc_media_player_release)GetDllType(typeof(libvlc_media_player_release), "libvlc_media_player_release"); - _libvlc_media_player_get_rate = (libvlc_media_player_get_rate)GetDllType(typeof(libvlc_media_player_get_rate), "libvlc_media_player_get_rate"); - _libvlc_media_player_set_rate = (libvlc_media_player_set_rate)GetDllType(typeof(libvlc_media_player_set_rate), "libvlc_media_player_set_rate"); - _libvlc_media_player_next_frame = (libvlc_media_player_next_frame)GetDllType(typeof(libvlc_media_player_next_frame), "libvlc_media_player_next_frame"); - _libvlc_media_player_add_slave = (libvlc_media_player_add_slave)GetDllType(typeof(libvlc_media_player_add_slave), "libvlc_media_player_add_slave"); - _libvlc_media_player_stop = (libvlc_media_player_stop)GetDllType(typeof(libvlc_media_player_stop), "libvlc_media_player_stop"); + _libvlc_media_player_play = (libvlc_media_player_play?)GetDllType(typeof(libvlc_media_player_play), "libvlc_media_player_play"); + _libvlc_media_player_set_hwnd = (libvlc_media_player_set_hwnd?)GetDllType(typeof(libvlc_media_player_set_hwnd), "libvlc_media_player_set_hwnd"); + _libvlc_media_player_set_xwindow = (libvlc_media_player_set_xwindow?)GetDllType(typeof(libvlc_media_player_set_xwindow), "libvlc_media_player_set_xwindow"); + _libvlc_media_player_is_playing = (libvlc_media_player_is_playing?)GetDllType(typeof(libvlc_media_player_is_playing), "libvlc_media_player_is_playing"); + _libvlc_media_player_set_pause = (libvlc_media_player_set_pause?)GetDllType(typeof(libvlc_media_player_set_pause), "libvlc_media_player_set_pause"); + _libvlc_media_player_get_time = (libvlc_media_player_get_time?)GetDllType(typeof(libvlc_media_player_get_time), "libvlc_media_player_get_time"); + _libvlc_media_player_set_time = (libvlc_media_player_set_time?)GetDllType(typeof(libvlc_media_player_set_time), "libvlc_media_player_set_time"); + _libvlc_media_player_get_state = (libvlc_media_player_get_state?)GetDllType(typeof(libvlc_media_player_get_state), "libvlc_media_player_get_state"); + _libvlc_media_player_get_length = (libvlc_media_player_get_length?)GetDllType(typeof(libvlc_media_player_get_length), "libvlc_media_player_get_length"); + _libvlc_media_player_release = (libvlc_media_player_release?)GetDllType(typeof(libvlc_media_player_release), "libvlc_media_player_release"); + _libvlc_media_player_get_rate = (libvlc_media_player_get_rate?)GetDllType(typeof(libvlc_media_player_get_rate), "libvlc_media_player_get_rate"); + _libvlc_media_player_set_rate = (libvlc_media_player_set_rate?)GetDllType(typeof(libvlc_media_player_set_rate), "libvlc_media_player_set_rate"); + _libvlc_media_player_next_frame = (libvlc_media_player_next_frame?)GetDllType(typeof(libvlc_media_player_next_frame), "libvlc_media_player_next_frame"); + _libvlc_media_player_add_slave = (libvlc_media_player_add_slave?)GetDllType(typeof(libvlc_media_player_add_slave), "libvlc_media_player_add_slave"); + _libvlc_media_player_stop = (libvlc_media_player_stop?)GetDllType(typeof(libvlc_media_player_stop), "libvlc_media_player_stop"); if (_libvlc_media_player_stop == null) { //TODO: libvlc 4 beta... check when final version is out - _libvlc_media_player_stop = (libvlc_media_player_stop)GetDllType(typeof(libvlc_media_player_stop), "libvlc_media_player_stop_async"); + _libvlc_media_player_stop = (libvlc_media_player_stop?)GetDllType(typeof(libvlc_media_player_stop), "libvlc_media_player_stop_async"); } } - private object GetDllType(Type type, string name) + private object? GetDllType(Type type, string name) { + // null, not IntPtr.Zero, when the export is missing: every caller casts the result to a + // delegate type, so a boxed IntPtr threw InvalidCastException instead - which made the + // "== null" libvlc-4 fallbacks unreachable and turned one missing symbol into a failed + // load and a silent EmptyVideoPlayer. var address = NativeMethods.CrossGetProcAddress(_library, name); - return address != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(address, type) : IntPtr.Zero; + return address != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(address, type) : null; } private bool LoadLibraryInternal() @@ -655,7 +659,10 @@ await Task.Run(() => return; } - var media = _libvlc_media_new_path(_libVlc, GetUtf8Bytes(path)); + // libvlc opens the file with the path as given, so a plain path past MAX_PATH + // fails silently on Windows (#14407). VLC cannot take the "\\?\" prefix (it + // reads it as a UNC host), so long paths are handed over in 8.3 short form. + var media = _libvlc_media_new_path(_libVlc, GetUtf8Bytes(NativeMediaPath.ForVlc(path))); // Parse media to get duration and metadata (0x00 = parse local, timeout in ms) try @@ -945,13 +952,11 @@ public double Volume try { + // The slider runs 0..MaxVolume (130), and libvlc's own scale is the same, so the + // value passes straight through. Rescaling and capping at 100 here was the mirror + // of the setter bug below. var v = _libvlc_audio_get_volume(_mediaPlayer); - if (MaxVolume > 100) - { - var result = (int)Math.Round(v / (MaxVolume / 100.0)); - return result > 100 ? 100 : result; - } - return v > 100 ? 100 : v; + return v > MaxVolume ? MaxVolume : v; } catch { @@ -969,15 +974,12 @@ public double Volume try { - var clampedVolume = Math.Max(0, Math.Min(100, value)); - if (MaxVolume > 100) - { - _libvlc_audio_set_volume(_mediaPlayer, (int)(clampedVolume * (MaxVolume / 100.0))); - } - else - { - _libvlc_audio_set_volume(_mediaPlayer, (int)clampedVolume); - } + // Clamp to the player maximum, not to 100: the slider's Maximum IS MaxVolume + // (130), so clamping to 100 and then scaling by MaxVolume/100 sent 130 when the + // user asked for 100 - 30% amplification at the nominal setting - and made + // 101..130 do nothing. The mpv player was already fixed the same way. + var clampedVolume = Math.Max(0, Math.Min(MaxVolume, value)); + _libvlc_audio_set_volume(_mediaPlayer, (int)clampedVolume); } catch { diff --git a/src/ui/Logic/VideoPlayers/NativeMediaPath.cs b/src/ui/Logic/VideoPlayers/NativeMediaPath.cs new file mode 100644 index 00000000000..fc2325b081e --- /dev/null +++ b/src/ui/Logic/VideoPlayers/NativeMediaPath.cs @@ -0,0 +1,170 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace Nikse.SubtitleEdit.Logic.VideoPlayers; + +/// +/// Prepares file paths for the native players (libmpv, libvlc) on Windows. +/// +/// Both libraries open media through the Win32 file APIs with the path as given, and those +/// cap a plain path at MAX_PATH (260 chars) unless the host exe declares itself long-path +/// aware and Windows has LongPathsEnabled turned on - neither is a given on a user's machine. +/// The failure is silent: the file never loads, the duration stays 0:00 and Play does nothing. +/// ffmpeg adds the extended-length prefix itself, so the waveform still gets extracted, which +/// is what made the symptom look like a broken container (#14407). +/// +/// +public static class NativeMediaPath +{ + /// Win32 MAX_PATH, counting the terminating NUL - so 259 chars is the longest plain path. + public const int WindowsMaxPath = 260; + + private const string LongPathPrefix = @"\\?\"; + private const string UncLongPathPrefix = @"\\?\UNC\"; + + /// + /// The path to hand to libmpv's "loadfile". mpv passes it straight to CreateFileW, which + /// accepts the \\?\ extended-length form regardless of manifest or registry, so long + /// local paths get that prefix. Everything else (short paths, URLs, non-Windows) is returned + /// unchanged. + /// + public static string ForMpv(string path) + { + if (!TryGetLongLocalPath(path, out var fullPath)) + { + return path; + } + + return AddLongPathPrefix(fullPath); + } + + /// + /// The path to hand to libvlc_media_new_path. VLC converts the path to a file URI first and + /// reads \\?\C:\... as a UNC path with host "?", so the prefix cannot be used there. + /// Fall back to the 8.3 short name instead, which fits under MAX_PATH whenever the volume + /// keeps short names (the default on the system drive). If no short enough name can be + /// produced the original path is returned and VLC gets the same chance it always had - the + /// app manifest is long-path aware, so it works on machines with LongPathsEnabled. + /// + public static string ForVlc(string path) + { + if (!TryGetLongLocalPath(path, out var fullPath)) + { + return path; + } + + var shortPath = GetShortPath(AddLongPathPrefix(fullPath)); + if (string.IsNullOrEmpty(shortPath)) + { + return path; + } + + var plainShortPath = RemoveLongPathPrefix(shortPath); + return plainShortPath.Length < WindowsMaxPath ? plainShortPath : path; + } + + /// + /// True (with the normalized full path) for a local Windows path too long to open without + /// the extended-length prefix. URLs and already-prefixed paths are left alone. + /// + private static bool TryGetLongLocalPath(string path, out string fullPath) + { + fullPath = path; + if (!OperatingSystem.IsWindows() || + string.IsNullOrWhiteSpace(path) || + path.Contains("://", StringComparison.Ordinal) || + path.StartsWith(LongPathPrefix, StringComparison.Ordinal) || + path.StartsWith(@"\\.\", StringComparison.Ordinal)) + { + return false; + } + + try + { + // The extended-length form turns off Win32 path normalization, so it must be + // applied to a fully qualified path with backslashes and no "." / ".." segments - + // GetFullPath produces exactly that. A short relative path can expand past the + // limit, hence the length check on the full path. + fullPath = Path.GetFullPath(path); + } + catch + { + return false; + } + + return fullPath.Length >= WindowsMaxPath; + } + + /// + /// C:\dir\file becomes \\?\C:\dir\file; a UNC path \\server\share\file + /// becomes \\?\UNC\server\share\file. + /// + internal static string AddLongPathPrefix(string fullPath) + { + if (fullPath.StartsWith(LongPathPrefix, StringComparison.Ordinal)) + { + return fullPath; + } + + if (fullPath.StartsWith(@"\\", StringComparison.Ordinal)) + { + return UncLongPathPrefix + fullPath.Substring(2); + } + + return LongPathPrefix + fullPath; + } + + internal static string RemoveLongPathPrefix(string path) + { + if (path.StartsWith(UncLongPathPrefix, StringComparison.OrdinalIgnoreCase)) + { + return @"\\" + path.Substring(UncLongPathPrefix.Length); + } + + if (path.StartsWith(LongPathPrefix, StringComparison.Ordinal)) + { + return path.Substring(LongPathPrefix.Length); + } + + return path; + } + + /// + /// 8.3 short form of a path, or null when Windows cannot produce one (short names disabled + /// on the volume, file missing, API failure). The input should carry the \\?\ prefix + /// so the lookup itself is not subject to MAX_PATH; the result carries it too. + /// + private static string? GetShortPath(string prefixedFullPath) + { + if (!OperatingSystem.IsWindows()) + { + return null; + } + + try + { + var buffer = new char[prefixedFullPath.Length + 1]; + var length = GetShortPathNameW(prefixedFullPath, buffer, (uint)buffer.Length); + if (length > buffer.Length) + { + buffer = new char[length]; + length = GetShortPathNameW(prefixedFullPath, buffer, (uint)buffer.Length); + } + + if (length == 0 || length > buffer.Length) + { + return null; + } + + return new string(buffer, 0, (int)length); + } + catch + { + return null; + } + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetShortPathNameW(string lpszLongPath, [Out] char[] lpszShortPath, uint cchBuffer); +} diff --git a/src/ui/Logic/XlsxWriter.cs b/src/ui/Logic/XlsxWriter.cs new file mode 100644 index 00000000000..ccc49c73966 --- /dev/null +++ b/src/ui/Logic/XlsxWriter.cs @@ -0,0 +1,321 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Text; + +namespace Nikse.SubtitleEdit.Logic; + +/// +/// Writes a single-sheet .xlsx (Office Open XML) workbook - no dependency, just a zip with a +/// handful of xml parts. Strings are written inline, so there is no shared-strings table to +/// keep in sync, and numbers are written as numbers so Excel sorts and sums them. +/// +/// Preferred over csv for "export to Excel": a csv is split on the list separator of the +/// machine that opens it, so a comma-separated file loses its columns on a Danish or German +/// Excel, and a text/quote guess mangles time codes and non-ASCII text. +/// +/// +public static class XlsxWriter +{ + private const string MainNs = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + private const string RelNs = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + /// Header style (bold, white on blue) - cellXfs index 1 in . + private const int HeaderStyle = 1; + + /// Body style (top aligned) - cellXfs index 2. + private const int BodyStyle = 2; + + /// + /// Builds the workbook in memory. Cells are strings unless the value is a number + /// (int/long/double/decimal), and null becomes an empty cell. + /// + /// Sheet tab name; invalid characters are replaced. + /// The first row, written bold and frozen. + /// One list of cell values per row; must be at most long. + /// Optional width (in characters) per column. + public static byte[] Create( + string sheetName, + IReadOnlyList headers, + IEnumerable> rows, + IReadOnlyList? columnWidths = null) + { + var sheet = MakeSheetXml(headers, rows, columnWidths); + + using (var ms = new MemoryStream()) + { + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, true)) + { + AddEntry(zip, "[Content_Types].xml", ContentTypesXml); + AddEntry(zip, "_rels/.rels", RootRelsXml); + AddEntry(zip, "xl/workbook.xml", MakeWorkbookXml(sheetName)); + AddEntry(zip, "xl/_rels/workbook.xml.rels", WorkbookRelsXml); + AddEntry(zip, "xl/styles.xml", StylesXml); + AddEntry(zip, "xl/worksheets/sheet1.xml", sheet); + } + + return ms.ToArray(); + } + } + + /// Builds the workbook and writes it to . + public static void Save( + string fileName, + string sheetName, + IReadOnlyList headers, + IEnumerable> rows, + IReadOnlyList? columnWidths = null) + { + File.WriteAllBytes(fileName, Create(sheetName, headers, rows, columnWidths)); + } + + private static void AddEntry(ZipArchive zip, string name, string content) + { + var entry = zip.CreateEntry(name, CompressionLevel.Optimal); + using (var stream = entry.Open()) + { + // No BOM: Excel reads the parts as the xml declaration says. + var bytes = new UTF8Encoding(false).GetBytes(content); + stream.Write(bytes, 0, bytes.Length); + } + } + + private static string MakeSheetXml( + IReadOnlyList headers, + IEnumerable> rows, + IReadOnlyList? columnWidths) + { + var body = new StringBuilder(); + var rowNumber = 1; + + body.Append(""); + for (var i = 0; i < headers.Count; i++) + { + AppendInlineString(body, ColumnName(i) + "1", HeaderStyle, headers[i]); + } + + body.Append(""); + + foreach (var row in rows) + { + rowNumber++; + body.Append(""); + for (var i = 0; i < row.Count && i < headers.Count; i++) + { + var reference = ColumnName(i) + rowNumber.ToString(CultureInfo.InvariantCulture); + var value = row[i]; + if (value == null) + { + continue; + } + + if (TryGetNumber(value, out var number)) + { + body.Append("") + .Append(number.ToString("R", CultureInfo.InvariantCulture)) + .Append(""); + } + else + { + AppendInlineString(body, reference, BodyStyle, value.ToString() ?? string.Empty); + } + } + + body.Append(""); + } + + var lastColumn = ColumnName(Math.Max(headers.Count - 1, 0)); + var dimension = "A1:" + lastColumn + rowNumber.ToString(CultureInfo.InvariantCulture); + + var cols = new StringBuilder(); + if (columnWidths != null && columnWidths.Count > 0) + { + cols.Append(""); + for (var i = 0; i < columnWidths.Count; i++) + { + cols.Append(""); + } + + cols.Append(""); + } + + return "" + + "" + + "" + + "" + + "" + + "" + + "" + + cols + + "" + body + "" + + "" + + ""; + } + + private static void AppendInlineString(StringBuilder sb, string reference, int style, string text) + { + sb.Append("').Append(EncodeXml(text)).Append(""); + } + + private static bool NeedsSpacePreserve(string text) + { + return text.Length > 0 && (char.IsWhiteSpace(text[0]) || char.IsWhiteSpace(text[text.Length - 1])); + } + + private static bool TryGetNumber(object value, out double number) + { + switch (value) + { + case int i: + number = i; + return true; + case long l: + number = l; + return true; + case double d: + number = d; + return !double.IsNaN(d) && !double.IsInfinity(d); + case float f: + number = f; + return !float.IsNaN(f) && !float.IsInfinity(f); + case decimal m: + number = (double)m; + return true; + default: + number = 0; + return false; + } + } + + /// 0 -> "A", 25 -> "Z", 26 -> "AA". + internal static string ColumnName(int zeroBasedIndex) + { + var name = string.Empty; + var index = zeroBasedIndex; + while (true) + { + name = (char)('A' + index % 26) + name; + index = index / 26 - 1; + if (index < 0) + { + return name; + } + } + } + + /// + /// Xml-escapes and drops the control characters xml 1.0 cannot carry - Excel reports a + /// corrupt file rather than skipping them, and subtitle text can hold anything. + /// + internal static string EncodeXml(string text) + { + var sb = new StringBuilder(text.Length); + foreach (var ch in text) + { + switch (ch) + { + case '&': + sb.Append("&"); + break; + case '<': + sb.Append("<"); + break; + case '>': + sb.Append(">"); + break; + case '"': + sb.Append("""); + break; + case '\'': + sb.Append("'"); + break; + default: + if (ch == '\t' || ch == '\n' || ch == '\r' || ch >= ' ' && ch != '\uFFFE' && ch != '\uFFFF') + { + sb.Append(ch); + } + + break; + } + } + + return sb.ToString(); + } + + /// Excel rejects these characters in a sheet name, and caps it at 31 characters. + private static string MakeSheetName(string sheetName) + { + var sb = new StringBuilder(); + foreach (var ch in sheetName) + { + sb.Append("[]:*?/\\".IndexOf(ch) >= 0 ? '-' : ch); + } + + var name = sb.ToString().Trim('\''); + if (name.Length > 31) + { + name = name.Substring(0, 31); + } + + return string.IsNullOrWhiteSpace(name) ? "Sheet1" : name; + } + + private static string MakeWorkbookXml(string sheetName) + { + return "" + + "" + + "" + + ""; + } + + private const string ContentTypesXml = + "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; + + private const string RootRelsXml = + "" + + "" + + "" + + ""; + + private const string WorkbookRelsXml = + "" + + "" + + "" + + "" + + ""; + + private const string StylesXml = + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; +} diff --git a/src/ui/Logic/XmlSourceSyntaxHighlighting.cs b/src/ui/Logic/XmlSourceSyntaxHighlighting.cs index b4e8a932bfb..6da1b4dcded 100644 --- a/src/ui/Logic/XmlSourceSyntaxHighlighting.cs +++ b/src/ui/Logic/XmlSourceSyntaxHighlighting.cs @@ -99,7 +99,10 @@ private static string FormatXml(string xml) sb.Append(c); if (c == '-' && i + 2 < xml.Length && xml[i + 1] == '-' && xml[i + 2] == '>') { - sb.Append("-->"); + // sb.Append(c) above already emitted the first '-', so only the remaining + // two characters belong here - appending "-->" turned every comment's + // terminator into "--->". + sb.Append("->"); i += 3; inComment = false; afterCloseTag = true; @@ -172,9 +175,11 @@ private static string FormatXml(string xml) afterCloseTag = true; } - else if (!inTag && char.IsWhiteSpace(c)) + else if (!inTag && afterCloseTag && char.IsWhiteSpace(c)) { - // Skip whitespace between tags + // Skip whitespace BETWEEN tags only. afterCloseTag is cleared as soon as real + // character data is emitted, so without it this also ate the spaces inside text - + // "

Hello world

" was previewed as "

Helloworld

". i++; continue; } diff --git a/src/ui/Program.cs b/src/ui/Program.cs index a02559162fa..a2a3ab96147 100644 --- a/src/ui/Program.cs +++ b/src/ui/Program.cs @@ -33,6 +33,21 @@ internal class Program public static string? PendingVideoToOpen { get; set; } public static bool FileOpenedViaActivation { get; set; } + // Set once the first editor window has made its startup file decision - after the + // activation grace delay in MainViewModel.OnLoaded. Until then a macOS File + // activation belongs to that window (routed via PendingFileToOpen); afterwards it + // means "opened while running" and gets a window of its own. lifetime.MainWindow + // cannot serve as this test: it is assigned before Start() pumps the run loop that + // delivers activations, so it is already non-null when a cold-launch Finder + // double-click's activation arrives - which used to send the file to a second + // window while the primary one started up empty. + public static bool StartupFileDecisionDone { get; set; } + + // Distinguishes a genuine double-click on the Dock icon from the single-click + // activation AppKit sends on every Dock click - see the Reopen handling below. + private static long _lastReopenActivationTicks = long.MinValue / 2; + private const long DoubleClickIntervalMs = 500; + [STAThread] public static void Main(string[] args) { @@ -166,6 +181,36 @@ public static void Main(string[] args) } }); } + else if (OperatingSystem.IsWindows()) + { + // Windows was the only platform left without an explicit fallback chain, so a + // glyph the current font lacks had to be resolved by Skia/DirectWrite alone - + // which does not always answer for a run that also carries a style variant. That + // is how italic Arabic in the subtitle grid ("Show formatting" renders {\i1} as + // a real italic run) came out as empty boxes while the same line was fine in the + // text box, waveform and video (issue #14150). Naming the families explicitly + // makes Avalonia resolve the missing glyph itself, before it ever gets there. + // Segoe UI covers Latin/Greek/Cyrillic/Arabic/Hebrew/Thai; the rest fill in the + // scripts it has no glyphs for. A fallback is only consulted for a character the + // requested font cannot render, so this adds candidates without changing any text + // that already renders. + appBuilder = appBuilder + .With(new FontManagerOptions + { + FontFallbacks = new[] + { + new FontFallback { FontFamily = new FontFamily("Segoe UI") }, + new FontFallback { FontFamily = new FontFamily("Tahoma") }, + new FontFallback { FontFamily = new FontFamily("Arial") }, + new FontFallback { FontFamily = new FontFamily("Nirmala UI") }, + new FontFallback { FontFamily = new FontFamily("Microsoft YaHei") }, + new FontFallback { FontFamily = new FontFamily("Microsoft JhengHei") }, + new FontFallback { FontFamily = new FontFamily("Yu Gothic UI") }, + new FontFallback { FontFamily = new FontFamily("Malgun Gothic") }, + new FontFallback { FontFamily = new FontFamily("Segoe UI Emoji") }, + } + }); + } appBuilder = appBuilder .With(new X11PlatformOptions @@ -205,6 +250,10 @@ public static void Main(string[] args) // Setup main window (Batch Convert standalone if requested via CLI) if (HasBatchConvertUiArg(args)) { + // No editor window will run OnLoaded to consume PendingFileToOpen, so + // route any macOS File activation straight to a new editor window. + StartupFileDecisionDone = true; + SetupBatchConvertOnlyWindow(lifetime); // Force-terminate the process once the window closes. Under @@ -391,6 +440,28 @@ private static void SetupNativeMenu(Application app, ClassicDesktopStyleApplicat activatable.Activated += (sender, e) => { + // Clicking the Dock icon while SE is already running delivers a Reopen + // activation on every click, not just a double-click - AppKit uses the same + // callback for "bring the running app to the front" as it does for "the user + // wants a new window" (e.g. Finder, TextEdit). A single click must keep doing + // the former (the OS already brings existing windows forward on its own); only + // a second click landing within the standard double-click window counts as the + // latter, matching every other platform's "open" gesture (a second Windows + // process, a fresh Linux launch) handing the user a new, empty window. + if (e is ActivatedEventArgs reopenArgs && reopenArgs.Kind == ActivationKind.Reopen) + { + var now = Environment.TickCount64; + var isDoubleClick = now - _lastReopenActivationTicks <= DoubleClickIntervalMs; + _lastReopenActivationTicks = now; + + if (isDoubleClick && lifetime.MainWindow != null) + { + Dispatcher.UIThread.Post(Nikse.SubtitleEdit.Features.Main.Layout.MainWindowFactory.OpenNewWindow); + } + + return; + } + if (e is not FileActivatedEventArgs args || args.Kind != ActivationKind.File) { return; @@ -402,19 +473,26 @@ private static void SetupNativeMenu(Application app, ClassicDesktopStyleApplicat if (System.IO.File.Exists(filePath)) { FileOpenedViaActivation = true; - var mainView = lifetime.MainWindow == null - ? null - : UiTheme.GetUnscaledContent(lifetime.MainWindow) as MainView; - if (mainView != null) + if (!StartupFileDecisionDone) { - Dispatcher.UIThread.Post(async () => - { - await mainView.OpenFile(filePath); - }); + // Still starting up - the primary window's startup file + // decision (MainViewModel.OnLoaded) has not run its + // activation grace delay yet, so it will load this file + // itself instead of leaving its window empty. + PendingFileToOpen = filePath; } else { - PendingFileToOpen = filePath; + // Already running: macOS delivers this when a file is opened + // from Finder while SE is running, or dropped on the Dock + // icon. Open it in a new window instead of hijacking whichever + // window happens to be lifetime.MainWindow - matches Windows, + // where each Finder/Explorer open is a separate process and + // never touches existing windows. + Dispatcher.UIThread.Post(async () => + { + await Nikse.SubtitleEdit.Features.Main.Layout.MainWindowFactory.OpenNewWindowWithFile(filePath); + }); } break; diff --git a/src/ui/app.manifest b/src/ui/app.manifest index a335105135d..be3f252d4b7 100644 --- a/src/ui/app.manifest +++ b/src/ui/app.manifest @@ -15,4 +15,13 @@ + + + + + true + + diff --git a/tests/UI/Controls/AudioVisualizerDragDurationBoxTests.cs b/tests/UI/Controls/AudioVisualizerDragDurationBoxTests.cs new file mode 100644 index 00000000000..2777e08383d --- /dev/null +++ b/tests/UI/Controls/AudioVisualizerDragDurationBoxTests.cs @@ -0,0 +1,192 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Nikse.SubtitleEdit.Controls; +using Nikse.SubtitleEdit.Controls.AudioVisualizerControl; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Media; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace UITests.Controls; + +// Repro for "when moving end in the waveform, the duration box time is not changing": +// dragging a cue edge mutates the shared SubtitleLineViewModel, and a SecondsUpDown bound +// TwoWay to its Duration must tick live during the drag. +public partial class AudioVisualizerDragDurationBoxTests : IDisposable +{ + // A window left open outlives the test: it keeps the application-wide activation and focused + // element, so a later test's click or key press is delivered to it instead. Closing here rather + // than at the end of each test also covers the tests that stop early on a failed assertion. + private readonly List _windows = new(); + + public void Dispose() + { + foreach (var window in _windows) + { + window.Close(); + } + + _windows.Clear(); + } + + private const int SampleRate = 126; + private const double WidthPx = 800; + private const double HeightPx = 200; + + private static WavePeakData2 MakePeaks(int seconds) + { + var peaks = new WavePeak2[SampleRate * seconds]; + for (var i = 0; i < peaks.Length; i++) + { + peaks[i] = new WavePeak2(8000, -8000); + } + + return new WavePeakData2(SampleRate, peaks); + } + + public partial class Vm : CommunityToolkit.Mvvm.ComponentModel.ObservableObject + { + [CommunityToolkit.Mvvm.ComponentModel.ObservableProperty] + private SubtitleLineViewModel? _selectedSubtitle; + } + + // Same, but through the app's actual binding shape: DataContext = main view model, + // path "SelectedSubtitle.Duration" (see InitListViewAndEditBox). + [AvaloniaFact] + public void DraggingTheEndEdgeUpdatesTheDurationBoxBoundViaSelectedSubtitle() + { + var snapToFrames = Se.Settings.Waveform.SnapToFrames; + var snapToShotChanges = Se.Settings.Waveform.SnapToShotChanges; + var frameMode = Se.Settings.General.UseFrameMode; + Se.Settings.Waveform.SnapToFrames = false; + Se.Settings.Waveform.SnapToShotChanges = false; + Se.Settings.General.UseFrameMode = false; + try + { + var line = new SubtitleLineViewModel + { + Text = "text", + StartTime = TimeSpan.FromSeconds(1), + EndTime = TimeSpan.FromSeconds(3), + }; + var vm = new Vm { SelectedSubtitle = line }; + + var av = new AudioVisualizer + { + WavePeaks = MakePeaks(60), + Width = WidthPx, + Height = HeightPx, + }; + av.SetPosition(0, new List { line }, 0, 0, new List()); + + var durationBox = new SecondsUpDown + { + DataContext = vm, + [!SecondsUpDown.ValueProperty] = new Binding($"{nameof(Vm.SelectedSubtitle)}.{nameof(SubtitleLineViewModel.Duration)}") + { + Mode = BindingMode.TwoWay, + }, + }; + + var panel = new StackPanel(); + panel.Children.Add(av); + panel.Children.Add(durationBox); + var window = new Window { Width = WidthPx, Height = HeightPx + 40, Content = panel }; + _windows.Add(window); + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + + var textBox = durationBox.GetVisualDescendants().OfType().Single(); + Assert.Equal("2.000", textBox.Text!.Replace(',', '.')); + + window.MouseDown(new Point(378, 100), MouseButton.Left, RawInputModifiers.None); + window.MouseMove(new Point(378 + 126, 100), RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(4000, line.EndTime.TotalMilliseconds); + Assert.Equal(3000, line.Duration.TotalMilliseconds); + Assert.Equal("3.000", textBox.Text!.Replace(',', '.')); + + window.MouseUp(new Point(378 + 126, 100), MouseButton.Left, RawInputModifiers.None); + } + finally + { + Se.Settings.Waveform.SnapToFrames = snapToFrames; + Se.Settings.Waveform.SnapToShotChanges = snapToShotChanges; + Se.Settings.General.UseFrameMode = frameMode; + } + } + + [AvaloniaFact] + public void DraggingTheEndEdgeUpdatesABoundDurationBox() + { + var snapToFrames = Se.Settings.Waveform.SnapToFrames; + var snapToShotChanges = Se.Settings.Waveform.SnapToShotChanges; + var frameMode = Se.Settings.General.UseFrameMode; + Se.Settings.Waveform.SnapToFrames = false; + Se.Settings.Waveform.SnapToShotChanges = false; + Se.Settings.General.UseFrameMode = false; + try + { + var line = new SubtitleLineViewModel + { + Text = "text", + StartTime = TimeSpan.FromSeconds(1), + EndTime = TimeSpan.FromSeconds(3), + }; + + var av = new AudioVisualizer + { + WavePeaks = MakePeaks(60), + Width = WidthPx, + Height = HeightPx, + }; + av.SetPosition(0, new List { line }, 0, 0, new List()); + + var durationBox = new SecondsUpDown { DataContext = line }; + durationBox[!SecondsUpDown.ValueProperty] = + new Binding(nameof(SubtitleLineViewModel.Duration)) { Mode = BindingMode.TwoWay }; + + var panel = new StackPanel(); + panel.Children.Add(av); + panel.Children.Add(durationBox); + var window = new Window { Width = WidthPx, Height = HeightPx + 40, Content = panel }; + _windows.Add(window); + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + + var textBox = durationBox.GetVisualDescendants().OfType().Single(); + Assert.Equal("2.000", textBox.Text!.Replace(',', '.')); + + // End edge is at 3 s = 378 px; grab it and drag +126 px (one second). + window.MouseDown(new Point(378, 100), MouseButton.Left, RawInputModifiers.None); + window.MouseMove(new Point(378 + 126, 100), RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + + // Mid-drag, before pointer release: the box must already show the new duration. + Assert.Equal(4000, line.EndTime.TotalMilliseconds); + Assert.Equal(3000, line.Duration.TotalMilliseconds); + Assert.Equal("3.000", textBox.Text!.Replace(',', '.')); + + window.MouseUp(new Point(378 + 126, 100), MouseButton.Left, RawInputModifiers.None); + } + finally + { + Se.Settings.Waveform.SnapToFrames = snapToFrames; + Se.Settings.Waveform.SnapToShotChanges = snapToShotChanges; + Se.Settings.General.UseFrameMode = frameMode; + } + } +} diff --git a/tests/UI/Controls/AudioVisualizerDragTests.cs b/tests/UI/Controls/AudioVisualizerDragTests.cs index b59472bacef..860fbd8dad6 100644 --- a/tests/UI/Controls/AudioVisualizerDragTests.cs +++ b/tests/UI/Controls/AudioVisualizerDragTests.cs @@ -28,8 +28,24 @@ namespace UITests.Controls; /// as a clipped overlay instead. The fancy style bakes selection into per-column colors, so it /// still rebuilds. /// -public class AudioVisualizerDragTests +public class AudioVisualizerDragTests : IDisposable { + // A window left open outlives the test: it keeps the application-wide activation and focused + // element, so a later test's click or key press is delivered to it instead. The tests close + // their window on the last line, which leaks one whenever an assertion above it fails - and one + // stranded window is enough to take the rest of the class down with it. + private readonly List _windows = new(); + + public void Dispose() + { + foreach (var window in _windows) + { + window.Close(); + } + + _windows.Clear(); + } + private const int SampleRate = 126; // Se.Settings.Waveform.WaveformMinimumSampleRate default private const double WidthPx = 800; private const double HeightPx = 200; @@ -52,7 +68,7 @@ private static WavePeakData2 MakePeaks(int seconds) EndTime = TimeSpan.FromSeconds(endSeconds), }; - private static (Window Window, AudioVisualizer Av, List Lines) Open(params SubtitleLineViewModel[] lines) + private (Window Window, AudioVisualizer Av, List Lines) Open(params SubtitleLineViewModel[] lines) { var av = new AudioVisualizer { @@ -70,6 +86,7 @@ private static (Window Window, AudioVisualizer Av, List L Height = HeightPx, Content = av, }; + _windows.Add(window); window.Show(); Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); @@ -144,6 +161,59 @@ public void MoveWholeLine_FollowsScroll_WhenViewScrollsMidDrag() window.Close(); } + // #14000: hosts (the TTS review window) select the grabbed line on press, before the drag + // mutates it. The event must name the line under the pointer, fire for both a middle grab + // and an edge grab, and stay silent for a press on empty waveform (new-selection drag) and + // when time codes are locked. The args carry a copy of the line (ParagraphEventArgs clones), + // so identity is checked by Id, as hosts must. + [AvaloniaFact] + public void OnDragStarted_FiresWithGrabbedLine_ForMoveAndResize() + { + var first = Line(1, 3); + var second = Line(5, 7); + var (window, av, _) = Open(first, second); + var raised = new List(); + av.OnDragStarted += (_, e) => raised.Add(e.Paragraph); + + // Middle of the second (unselected) line: x = 6 s * 126 = 756. + window.MouseDown(new Point(756, 100), MouseButton.Left, RawInputModifiers.None); + window.MouseUp(new Point(756, 100), MouseButton.Left, RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + Assert.Single(raised); + Assert.Equal(second.Id, raised[0].Id); + + // Right edge of the first line: x = 3 s * 126 = 378. + window.MouseDown(new Point(378, 100), MouseButton.Left, RawInputModifiers.None); + window.MouseUp(new Point(378, 100), MouseButton.Left, RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + Assert.Equal(2, raised.Count); + Assert.Equal(first.Id, raised[1].Id); + + // Empty waveform (x = 4 s) starts a new-selection drag, not a line drag. + window.MouseDown(new Point(504, 100), MouseButton.Left, RawInputModifiers.None); + window.MouseUp(new Point(504, 100), MouseButton.Left, RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + Assert.Equal(2, raised.Count); + + window.Close(); + } + + [AvaloniaFact] + public void OnDragStarted_Silent_WhenReadOnly() + { + var (window, av, _) = Open(Line(1, 3)); + av.IsReadOnly = true; + var raised = 0; + av.OnDragStarted += (_, _) => raised++; + + window.MouseDown(new Point(252, 100), MouseButton.Left, RawInputModifiers.None); + window.MouseUp(new Point(252, 100), MouseButton.Left, RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(0, raised); + window.Close(); + } + /// Ages the "last pointer edit" stamp past the 500 ms grace, i.e. simulates half a /// second of a held-down drag in which the time codes did not change. private static void ExpirePointerEditGrace(AudioVisualizer av) @@ -242,6 +312,32 @@ public void IsEditingWithPointer_False_ForAPlainClick() window.Close(); } + // #14000: the generated-audio length bar. The provider is consulted once per drawn + // paragraph with the very instance the control draws (hosts key on it), and a render with + // a provider that overruns the cue, fits the cue, or returns 0 must not throw. + [AvaloniaFact] + public void ParagraphAudioLengthProvider_IsAskedPerDrawnParagraph() + { + var av = MakeMeasuredVisualizer(WaveformDrawStyle.Classic, out var line); + var asked = new List(); + var lengths = new[] { 5.0, 1.0, 0.0 }; + foreach (var length in lengths) + { + asked.Clear(); + av.ParagraphAudioLengthProvider = p => + { + asked.Add(p); + return length; + }; + RenderFrame(av); + Assert.Single(asked); + Assert.Same(line, asked[0]); + } + + av.ParagraphAudioLengthProvider = null; + RenderFrame(av); + } + private static Geometry FirstCachedWaveformGeometry(AudioVisualizer av) { var field = typeof(AudioVisualizer).GetField("_waveformCacheDraws", BindingFlags.NonPublic | BindingFlags.Instance)!; diff --git a/tests/UI/Controls/AudioVisualizerOriginalTextTests.cs b/tests/UI/Controls/AudioVisualizerOriginalTextTests.cs new file mode 100644 index 00000000000..700be2dd696 --- /dev/null +++ b/tests/UI/Controls/AudioVisualizerOriginalTextTests.cs @@ -0,0 +1,94 @@ +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using Nikse.SubtitleEdit.Controls.AudioVisualizerControl; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic.Media; +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace UITests.Controls; + +/// +/// "Toggle translation and original in video/audio preview" (#14252) also swaps the text drawn in +/// the waveform - the video preview alone showing the original, with the waveform still on the +/// translation, is what SE 4 never did. +/// +public class AudioVisualizerOriginalTextTests +{ + private const int SampleRate = 126; // px per second at zoom 1 + private const double WidthPx = 800; + private const double HeightPx = 200; + private const double LineStartSeconds = 100; + + [AvaloniaFact] + public void ShowOriginalText_DrawsTheOriginalInsteadOfTheTranslation() + { + var av = new AudioVisualizer { WavePeaks = MakePeaks(300) }; + var window = new Window + { + Width = WidthPx, + Height = HeightPx, + Content = av, + }; + + window.Show(); + window.UpdateLayout(); + + var line = new SubtitleLineViewModel + { + // Deliberately different lengths: the footer's CPS follows the drawn text, so the + // frames below can only match if it swapped too. + Text = "Vertaalde regel", + OriginalText = "Source line", + StartTime = TimeSpan.FromSeconds(LineStartSeconds), + EndTime = TimeSpan.FromSeconds(LineStartSeconds + 3), + }; + + var lines = new List { line }; + var noSelection = new List(); + + av.SetPosition(LineStartSeconds - 1, lines, 0, -1, noSelection); + var translation = Capture(window); + + av.ShowOriginalText = true; + av.InvalidateVisual(); + var original = Capture(window); + + Assert.NotEqual(translation, original); + + // ... and it is the original text that was drawn, not merely something else: the same + // waveform with the original text in the ordinary Text property paints the same frame - + // text, and the CPS in the paragraph footer with it. + av.ShowOriginalText = false; + line.Text = "Source line"; + av.InvalidateVisual(); + Assert.Equal(original, Capture(window)); + } + + private static WavePeakData2 MakePeaks(int seconds) + { + var peaks = new WavePeak2[SampleRate * seconds]; + for (var i = 0; i < peaks.Length; i++) + { + peaks[i] = new WavePeak2(200, -200); + } + + return new WavePeakData2(SampleRate, peaks); + } + + private static byte[] Capture(Window window) + { + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + + using var frame = window.CaptureRenderedFrame()!; + using var stream = new MemoryStream(); + frame.Save(stream, PngBitmapEncoderOptions.Default); + return stream.ToArray(); + } +} diff --git a/tests/UI/Controls/AudioVisualizerParagraphRepaintTests.cs b/tests/UI/Controls/AudioVisualizerParagraphRepaintTests.cs new file mode 100644 index 00000000000..ce342f1c993 --- /dev/null +++ b/tests/UI/Controls/AudioVisualizerParagraphRepaintTests.cs @@ -0,0 +1,156 @@ +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using Nikse.SubtitleEdit.Controls.AudioVisualizerControl; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic.Media; +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace UITests.Controls; + +/// +/// The blank waveform in issue #14218: after Options/OK the waveform kept the time ruler and the +/// cursor but drew no paragraphs at all - no boxes, no text, no number/duration/CPS - until +/// playback was resumed, which brought them all back. +/// +/// The paragraph set the waveform draws is rebuilt on the 50 ms position timer and lives in plain +/// fields, so a rebuild of it asks for no repaint. While the video is paused nothing else moves an +/// AffectsRender property either, so whatever was on screen when the set was last painted just +/// stays there. Options/OK produced exactly that: the rebuilt video player reports 0 for a moment, +/// "center video position" scrolls the waveform to the start (16 ms cursor timer), the paragraph +/// reload empties the list, and the frame painted when the player lands back on the real position +/// shows the right time range with nothing in it. The reload a tick later fixed the list but not +/// the picture. +/// +public class AudioVisualizerParagraphRepaintTests : IDisposable +{ + // A window left open outlives the test: it keeps the application-wide activation and focused + // element, so a later test's click or key press is delivered to it instead. Closing here rather + // than at the end of each test also covers the tests that stop early on a failed assertion. + private readonly List _windows = new(); + + public void Dispose() + { + foreach (var window in _windows) + { + window.Close(); + } + + _windows.Clear(); + } + + private const int SampleRate = 126; // px per second at zoom 1 + private const double WidthPx = 800; + private const double HeightPx = 200; + private const double LineStartSeconds = 100; + + private static WavePeakData2 MakePeaks(int seconds) + { + var peaks = new WavePeak2[SampleRate * seconds]; + for (var i = 0; i < peaks.Length; i++) + { + peaks[i] = new WavePeak2(200, -200); + } + + return new WavePeakData2(SampleRate, peaks); + } + + private static byte[] Capture(Window window) + { + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + + using var frame = window.CaptureRenderedFrame()!; + using var stream = new MemoryStream(); + frame.Save(stream, PngBitmapEncoderOptions.Default); + return stream.ToArray(); + } + + [AvaloniaFact] + public void ParagraphReloadRepaintsWhenTheDrawnSetChanged_Issue14218() + { + var av = new AudioVisualizer { WavePeaks = MakePeaks(300) }; + var window = new Window + { + Width = WidthPx, + Height = HeightPx, + Content = av, + }; + + _windows.Add(window); + window.Show(); + window.UpdateLayout(); + + var lines = new List + { + new() + { + Text = "- Where is Arturo?" + Environment.NewLine + "- He was here.", + StartTime = TimeSpan.FromSeconds(LineStartSeconds), + EndTime = TimeSpan.FromSeconds(LineStartSeconds + 3), + }, + }; + + var noSelection = new List(); + + // The waveform is scrolled to the start of the file - what "center video position" does + // while the rebuilt player still reports 0 - so the line is far outside the drawn window. + av.SetPosition(0, lines, 0, -1, noSelection); + Capture(window); + + // The player lands back where the user was. The cursor timer moves the view, which is an + // AffectsRender property, so this frame is painted - with the paragraph set still empty. + av.StartPositionSeconds = LineStartSeconds - 1; + var withoutParagraphs = Capture(window); + + // The next reload brings the line back into the set. Nothing else changes here (same view, + // same cursor - the video is paused), so unless the reload asks for the repaint itself the + // waveform goes on showing the frame above. + av.SetPosition(LineStartSeconds - 1, lines, 0, -1, noSelection); + var withParagraphs = Capture(window); + + Assert.NotEqual(withoutParagraphs, withParagraphs); + } + + [AvaloniaFact] + public void ParagraphReloadRepaintsWhenTheSelectionChanged_Issue14218() + { + // Same hole on the selection half of the reload: AllSelectedParagraphs is an AffectsRender + // property, but only its identity is - the reload mutates the list in place, so a selection + // that arrives while the video is paused repaints nothing on its own. + var av = new AudioVisualizer { WavePeaks = MakePeaks(300) }; + var window = new Window + { + Width = WidthPx, + Height = HeightPx, + Content = av, + }; + + _windows.Add(window); + window.Show(); + window.UpdateLayout(); + + var lines = new List + { + new() + { + Text = "Where are you, Arturo?", + StartTime = TimeSpan.FromSeconds(LineStartSeconds), + EndTime = TimeSpan.FromSeconds(LineStartSeconds + 3), + }, + }; + + av.SetPosition(LineStartSeconds - 1, lines, 0, -1, new List()); + var unselected = Capture(window); + + av.SetPosition(LineStartSeconds - 1, lines, 0, -1, new List(lines)); + var selected = Capture(window); + + Assert.NotEqual(unselected, selected); + } +} diff --git a/tests/UI/Controls/AudioVisualizerRtlTextTests.cs b/tests/UI/Controls/AudioVisualizerRtlTextTests.cs new file mode 100644 index 00000000000..d6be666f779 --- /dev/null +++ b/tests/UI/Controls/AudioVisualizerRtlTextTests.cs @@ -0,0 +1,127 @@ +using Avalonia; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Nikse.SubtitleEdit.Controls.AudioVisualizerControl; +using System; +using System.Runtime.InteropServices; +using Xunit; + +namespace UITests.Controls; + +/// +/// Issue #14262 (matter H): Arabic in the waveform was shaped with a left to right paragraph +/// direction, so the neutral characters around the letters - the dialogue dash, the ellipsis, +/// brackets - took the paragraph direction instead of the direction of the letters next to them +/// and ended up on the wrong side of the line. SE 4 got this right, SE 5 beta 27 did not. +/// +public class AudioVisualizerRtlTextTests +{ + // "- Hello my friend..." - a dialogue dash before the letters and an ellipsis after them, + // the two neutrals from the issue's screenshots. + private const string ArabicLine = "- مرحبا يا صديقي..."; + + [AvaloniaFact] + public void ArabicParagraphIsShapedRightToLeft() + { + var av = new AudioVisualizer(); + + var prepared = av.GetPreparedParagraphText(ArabicLine); + + Assert.True(prepared.RightToLeft); + Assert.Equal(FlowDirection.RightToLeft, av.GetCachedParagraphText(prepared.Unwrapped, prepared.RightToLeft).FlowDirection); + } + + [AvaloniaFact] + public void LatinParagraphKeepsLeftToRight() + { + var av = new AudioVisualizer(); + + var prepared = av.GetPreparedParagraphText("- Where is Arturo?" + Environment.NewLine + "- He was here."); + + Assert.False(prepared.RightToLeft); + Assert.Equal(FlowDirection.LeftToRight, av.GetCachedParagraphText(prepared.Unwrapped, prepared.RightToLeft).FlowDirection); + } + + /// + /// The direction is decided for the whole paragraph, not line by line: a second line holding + /// only neutrals, digits or a Latin name belongs to the same block as the first one and must + /// not flip back to left to right in the middle of a subtitle. + /// + [AvaloniaFact] + public void ASecondLineWithoutRightToLeftLettersFollowsTheParagraph() + { + var av = new AudioVisualizer(); + + var prepared = av.GetPreparedParagraphText("مرحبا يا صديقي" + Environment.NewLine + "- (Gino)..."); + + Assert.True(prepared.RightToLeft); + Assert.Equal(2, prepared.Lines.Count); + } + + /// + /// The symptom itself, measured on the shaped line: laid out left to right, the dialogue dash + /// is the leftmost thing on the line and the trailing "..." the rightmost - both on the wrong + /// side of a right to left line, which is exactly what the issue's SE 5 screenshot shows. + /// (Avalonia hands out no highlight geometry for a right to left line - it comes back empty - + /// so the right to left half of this is measured by rendering, below.) + /// + [AvaloniaFact] + public void LeftToRightLeavesTheNeutralsOnTheWrongSideOfAnArabicLine() + { + var av = new AudioVisualizer(); + var leftToRight = av.GetCachedParagraphText(ArabicLine, false); + + var dash = leftToRight.BuildHighlightGeometry(new Point(0, 0), 0, 1)!.Bounds; + var ellipsis = leftToRight.BuildHighlightGeometry(new Point(0, 0), ArabicLine.Length - 3, 3)!.Bounds; + + Assert.True(dash.Left < leftToRight.Width / 2, "the dash should start the line when it is laid out left to right"); + Assert.True(ellipsis.Left > leftToRight.Width / 2, "the ellipsis should end the line when it is laid out left to right"); + } + + /// + /// And the direction really reaches the shaping: the same string drawn right to left comes out + /// as a different picture. + /// + /// + /// Only the picture, not the width. This used to also assert that the two directions measure + /// the same, and that made the test flaky: a trailing neutral run ("...") measures wider laid + /// out right to left than left to right (about a tenth of the line here), and how much wider + /// depends on which font the Arabic falls back to - which in turn depends on what else ran + /// first in the shared headless application. Avalonia promises no such equality, and nothing + /// in the waveform compares the two directions, so the width was never the point. + /// + [AvaloniaFact] + public void TheDirectionChangesWhatIsDrawn() + { + var av = new AudioVisualizer(); + var rightToLeft = av.GetCachedParagraphText(ArabicLine, true); + var leftToRight = av.GetCachedParagraphText(ArabicLine, false); + + Assert.NotEqual(Render(leftToRight), Render(rightToLeft)); + } + + private static byte[] Render(FormattedText text) + { + using var bitmap = new RenderTargetBitmap(new PixelSize(200, 40), new Vector(96, 96)); + using (var context = bitmap.CreateDrawingContext()) + { + context.FillRectangle(Brushes.Black, new Rect(0, 0, 200, 40)); + context.DrawText(text, new Point(0, 0)); + } + + var pixels = new byte[200 * 40 * 4]; + var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned); + try + { + bitmap.CopyPixels(new PixelRect(0, 0, 200, 40), handle.AddrOfPinnedObject(), pixels.Length, 200 * 4); + } + finally + { + handle.Free(); + } + + return pixels; + } +} diff --git a/tests/UI/Controls/AudioVisualizerScrubFeedbackTests.cs b/tests/UI/Controls/AudioVisualizerScrubFeedbackTests.cs index d7e2ef4cc36..7d9dc19d2ae 100644 --- a/tests/UI/Controls/AudioVisualizerScrubFeedbackTests.cs +++ b/tests/UI/Controls/AudioVisualizerScrubFeedbackTests.cs @@ -28,8 +28,24 @@ namespace UITests.Controls; /// is set. These tests pin the two facts that guard depends on: the flag is already true when the /// scrub fires, and a consumer that does scroll mid-drag really does amplify the drag. /// -public class AudioVisualizerScrubFeedbackTests +public class AudioVisualizerScrubFeedbackTests : IDisposable { + // A window left open outlives the test: it keeps the application-wide activation and focused + // element, so a later test's click or key press is delivered to it instead. The tests close + // their window on the last line, which leaks one whenever an assertion above it fails - and one + // stranded window is enough to take the rest of the class down with it. + private readonly List _windows = new(); + + public void Dispose() + { + foreach (var window in _windows) + { + window.Close(); + } + + _windows.Clear(); + } + private const int SampleRate = 126; // px per second at zoom 1 private const double WidthPx = 800; private const double LineStart = 100; @@ -51,7 +67,7 @@ private static WavePeakData2 MakePeaks(int seconds) return new WavePeakData2(SampleRate, peaks); } - private static (Window Window, AudioVisualizer Av) Open() + private (Window Window, AudioVisualizer Av) Open() { var av = new AudioVisualizer { WavePeaks = MakePeaks(200), Width = WidthPx, Height = 200 }; var line = new SubtitleLineViewModel @@ -64,6 +80,7 @@ private static (Window Window, AudioVisualizer Av) Open() av.SetPosition(ViewStart, new List { line }, 0, 0, new List()); var window = new Window { Width = WidthPx, Height = 200, Content = av }; + _windows.Add(window); window.Show(); Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); diff --git a/tests/UI/Controls/AudioVisualizerShotChangeSnapTests.cs b/tests/UI/Controls/AudioVisualizerShotChangeSnapTests.cs index fa7af4c8999..65591ac8681 100644 --- a/tests/UI/Controls/AudioVisualizerShotChangeSnapTests.cs +++ b/tests/UI/Controls/AudioVisualizerShotChangeSnapTests.cs @@ -29,8 +29,24 @@ namespace UITests.Controls; /// in or out. A whole-paragraph drag preserves its duration, so whichever cue the cut captures, /// the other one moves with it. /// -public class AudioVisualizerShotChangeSnapTests +public class AudioVisualizerShotChangeSnapTests : IDisposable { + // A window left open outlives the test: it keeps the application-wide activation and focused + // element, so a later test's click or key press is delivered to it instead. The tests close + // their window on the last line, which leaks one whenever an assertion above it fails - and one + // stranded window is enough to take the rest of the class down with it. + private readonly List _windows = new(); + + public void Dispose() + { + foreach (var window in _windows) + { + window.Close(); + } + + _windows.Clear(); + } + private const int SampleRate = 126; // px per second at zoom 1 private const double WidthPx = 800; private const double HeightPx = 200; @@ -108,7 +124,7 @@ private static WavePeakData2 MakePeaks(int seconds) EndTime = TimeSpan.FromSeconds(endSeconds), }; - private static (Window Window, AudioVisualizer Av) Open(List shotChanges, params SubtitleLineViewModel[] lines) + private (Window Window, AudioVisualizer Av) Open(List shotChanges, params SubtitleLineViewModel[] lines) { var av = new AudioVisualizer { @@ -121,6 +137,7 @@ private static (Window Window, AudioVisualizer Av) Open(List shotChanges av.ShotChanges = shotChanges; var window = new Window { Width = WidthPx, Height = HeightPx, Content = av }; + _windows.Add(window); window.Show(); Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); @@ -180,7 +197,7 @@ public void MoveWholeLine_NoShotChangeInRange_FollowsThePointer() Drag(window, 252, 312); - Assert.Equal(1 + 60.0 / SampleRate, line.StartTime.TotalSeconds, 6); + Assert.Equal(1476, line.StartTime.TotalMilliseconds); // 1 + 60/126 s, rounded to the whole ms the drag stores Assert.Equal(2, line.Duration.TotalSeconds, 6); window.Close(); } @@ -194,7 +211,7 @@ public void MoveWholeLine_SnapToShotChangesOff_FollowsThePointer() Drag(window, 252, 312); - Assert.Equal(1 + 60.0 / SampleRate, line.StartTime.TotalSeconds, 6); + Assert.Equal(1476, line.StartTime.TotalMilliseconds); // 1 + 60/126 s, rounded to the whole ms the drag stores window.Close(); } @@ -307,7 +324,7 @@ public void CaptureDistance_JustOutsideThePixelRadius_DoesNotSnap() var (window, av) = Open(new List { cutAt }, Line(1, 3)); var line = av.SelectedParagraph!; Drag(window, 252, 312); - Assert.Equal(draggedStart, line.StartTime.TotalSeconds, 6); + Assert.Equal(1476, line.StartTime.TotalMilliseconds); // draggedStart rounded to the whole ms the drag stores window.Close(); } @@ -321,7 +338,7 @@ public void CaptureDistance_FollowsTheSetting() var (window, av) = Open(new List { cutAt }, Line(1, 3)); var line = av.SelectedParagraph!; Drag(window, 252, 312); - Assert.Equal(draggedStart, line.StartTime.TotalSeconds, 6); + Assert.Equal(1476, line.StartTime.TotalMilliseconds); // draggedStart rounded to the whole ms the drag stores window.Close(); Se.Settings.Waveform.SnapToShotChangesPixels = 16; diff --git a/tests/UI/Controls/AudioVisualizerWheelModifierTests.cs b/tests/UI/Controls/AudioVisualizerWheelModifierTests.cs new file mode 100644 index 00000000000..92aff7b72e4 --- /dev/null +++ b/tests/UI/Controls/AudioVisualizerWheelModifierTests.cs @@ -0,0 +1,183 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Threading; +using Nikse.SubtitleEdit.Controls.AudioVisualizerControl; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Media; +using System; +using System.Collections.Generic; + +namespace UITests.Controls; + +/// +/// Issue #14306: with "mouse-wheel sets video position" turned off, the wheel still jumped the +/// video to the cursor after any Ctrl shortcut was used over the waveform. +/// +/// The wheel handler read a Ctrl/Alt/Shift/Meta mirror maintained from KeyDown/KeyUp. A shortcut +/// handler that swallows the key-up leaves the mirror stuck down, and the wheel then took the +/// Ctrl branch ("scroll and set the video position at the cursor") with no Ctrl held. It now +/// reads the modifiers off the wheel event itself, like every other pointer handler in the +/// control, so a stale mirror cannot reach it. +/// +public class AudioVisualizerWheelModifierTests : IDisposable +{ + // A window left open outlives the test: it keeps the application-wide activation and focused + // element, so a later test's input is delivered to it instead. + private readonly List _windows = new(); + + public void Dispose() + { + foreach (var window in _windows) + { + window.Close(); + } + + _windows.Clear(); + } + + private const int SampleRate = 126; // px per second at zoom 1 + private const double WidthPx = 800; + private const double ViewStart = 98; + private static readonly Point WheelPoint = new(400, 100); + + private static WavePeakData2 MakePeaks(int seconds) + { + var peaks = new WavePeak2[SampleRate * seconds]; + for (var i = 0; i < peaks.Length; i++) + { + peaks[i] = new WavePeak2(8000, -8000); + } + + return new WavePeakData2(SampleRate, peaks); + } + + private (Window Window, AudioVisualizer Av) Open() + { + var av = new AudioVisualizer { WavePeaks = MakePeaks(200), Width = WidthPx, Height = 200 }; + var line = new SubtitleLineViewModel + { + Text = "text", + StartTime = TimeSpan.FromSeconds(100), + EndTime = TimeSpan.FromSeconds(102), + }; + + av.SetPosition(ViewStart, new List { line }, 0, 0, new List()); + + var window = new Window { Width = WidthPx, Height = 200, Content = av }; + _windows.Add(window); + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + av.Focus(); + Dispatcher.UIThread.RunJobs(); + return (window, av); + } + + /// Runs an action with "mouse-wheel sets video position" forced to . + private static void WithWheelSetsVideoPosition(bool on, Action body) + { + var old = Se.Settings.Waveform.MouseWheelSetsVideoPosition; + Se.Settings.Waveform.MouseWheelSetsVideoPosition = on; + try + { + body(); + } + finally + { + Se.Settings.Waveform.MouseWheelSetsVideoPosition = old; + } + } + + // Ctrl goes down over the waveform and the matching key-up never arrives, which is what a + // shortcut handler that marks the key event handled looks like from here. + private static void PressCtrlWithoutRelease(Window window) + { + window.KeyPress(Key.LeftCtrl, RawInputModifiers.Control, PhysicalKey.ControlLeft, null); + Dispatcher.UIThread.RunJobs(); + } + + // The reported bug, with the setting off: after a Ctrl shortcut the wheel repositioned the + // video even though no modifier was held while scrolling. + [AvaloniaFact] + public void Wheel_AfterCtrlShortcut_DoesNotMoveTheVideo() + { + WithWheelSetsVideoPosition(false, () => + { + var (window, av) = Open(); + var moved = 0; + av.OnVideoPositionChanged += (_, _) => moved++; + + PressCtrlWithoutRelease(window); + window.MouseWheel(WheelPoint, new Vector(0, 1), RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(0, moved); + }); + } + + // The same stale Ctrl with the setting on: it suppressed the step-seek the setting exists for + // and did the Ctrl "set position at cursor" instead. + [AvaloniaFact] + public void Wheel_AfterCtrlShortcut_StillStepsTheVideo_WhenSettingIsOn() + { + WithWheelSetsVideoPosition(true, () => + { + var (window, av) = Open(); + av.CurrentVideoPositionSeconds = 100; + var positions = new List(); + av.OnVideoPositionChanged += (_, e) => positions.Add(e.PositionInSeconds); + + PressCtrlWithoutRelease(window); + window.MouseWheel(WheelPoint, new Vector(0, 1), RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + + Assert.Single(positions); + // A step off the current position, not a jump to wherever the pointer happens to be. + Assert.True(Math.Abs(positions[0] - 100) < 1, + $"expected a step near 100s, got {positions[0]}s"); + }); + } + + // Ctrl genuinely held while scrolling keeps setting the video position at the cursor. + [AvaloniaFact] + public void Wheel_WithCtrlActuallyHeld_MovesTheVideo() + { + WithWheelSetsVideoPosition(false, () => + { + var (window, av) = Open(); + var moved = 0; + av.OnVideoPositionChanged += (_, _) => moved++; + + window.MouseWheel(WheelPoint, new Vector(0, 1), RawInputModifiers.Control); + Dispatcher.UIThread.RunJobs(); + + Assert.True(moved > 0, "Ctrl+wheel should still set the video position"); + }); + } + + // Without any modifier the wheel only scrolls the view - the baseline the reporter had before + // touching a shortcut. + [AvaloniaFact] + public void Wheel_WithNoModifier_OnlyScrolls() + { + WithWheelSetsVideoPosition(false, () => + { + var (window, av) = Open(); + var moved = 0; + var scrolled = 0; + av.OnVideoPositionChanged += (_, _) => moved++; + av.OnHorizontalScroll += (_, _) => scrolled++; + + window.MouseWheel(WheelPoint, new Vector(0, 1), RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(0, moved); + Assert.True(scrolled > 0, "the wheel should scroll the waveform"); + }); + } +} diff --git a/tests/UI/Controls/SecondsUpDownParseTests.cs b/tests/UI/Controls/SecondsUpDownParseTests.cs new file mode 100644 index 00000000000..4052f37e8e2 --- /dev/null +++ b/tests/UI/Controls/SecondsUpDownParseTests.cs @@ -0,0 +1,58 @@ +using System; +using System.Reflection; +using Avalonia.Headless.XUnit; +using Nikse.SubtitleEdit.Controls; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Logic.Config; + +namespace UITests.Controls; + +// In frame mode the duration field shows "seconds:frames", and typing the value without the +// colon (300 for 3:00) used to reset it to 0:00 - the masked start/end fields accept that form. +public class SecondsUpDownParseTests +{ + private static readonly MethodInfo ParseTime = + typeof(SecondsUpDown).GetMethod("ParseTime", BindingFlags.NonPublic | BindingFlags.Static)!; + + private static int ParseMs(string input, bool frameMode) + { + var oldFrameMode = Se.Settings.General.UseFrameMode; + var oldFrameRate = Configuration.Settings.General.CurrentFrameRate; + try + { + Se.Settings.General.UseFrameMode = frameMode; + Configuration.Settings.General.CurrentFrameRate = 25; + return (int)((TimeSpan)ParseTime.Invoke(null, new object?[] { input })!).TotalMilliseconds; + } + finally + { + Se.Settings.General.UseFrameMode = oldFrameMode; + Configuration.Settings.General.CurrentFrameRate = oldFrameRate; + } + } + + [AvaloniaTheory] + [InlineData("3:00", 3000)] + [InlineData("3:12", 3480)] + [InlineData("300", 3000)] // no colon - last two digits are frames + [InlineData("312", 3480)] + [InlineData("1200", 12000)] + [InlineData("5", 5000)] // one or two digits are whole seconds + [InlineData("12", 12000)] + [InlineData("012", 480)] // ...but a leading zero means 0 seconds and 12 frames + [InlineData("199", 1960)] // 99 frames cannot fit at 25 fps - clamped to 24 + [InlineData("0", 0)] + public void ParsesFrameMode(string input, int expectedMs) + { + Assert.Equal(expectedMs, ParseMs(input, true)); + } + + [AvaloniaTheory] + [InlineData("3.000", 3000)] + [InlineData("3,000", 3000)] + [InlineData("300", 300000)] // seconds mode - a bare number is seconds, as before + public void ParsesSecondsMode(string input, int expectedMs) + { + Assert.Equal(expectedMs, ParseMs(input, false)); + } +} diff --git a/tests/UI/Controls/TimeCodeUpDownNegativeTests.cs b/tests/UI/Controls/TimeCodeUpDownNegativeTests.cs index 8d361f057b4..b3f1a8583df 100644 --- a/tests/UI/Controls/TimeCodeUpDownNegativeTests.cs +++ b/tests/UI/Controls/TimeCodeUpDownNegativeTests.cs @@ -11,6 +11,7 @@ using Nikse.SubtitleEdit.Controls; using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.Logic.Config; +using System.Collections.Generic; namespace UITests.Controls; @@ -18,8 +19,23 @@ namespace UITests.Controls; // and the editor used to clamp any negative value to zero and write that back through its two-way // binding - so every line the user selected had its time code destroyed. The control now shows, // edits and steps negative time codes the way SE 4.x did. -public partial class TimeCodeUpDownNegativeTests +public partial class TimeCodeUpDownNegativeTests : IDisposable { + // A window left open outlives the test: it keeps the application-wide activation and focused + // element, so a later test's click or key press is delivered to it instead. Closing here rather + // than at the end of each test also covers the tests that stop early on a failed assertion. + private readonly List _windows = new(); + + public void Dispose() + { + foreach (var window in _windows) + { + window.Close(); + } + + _windows.Clear(); + } + public partial class Line : ObservableObject { [ObservableProperty] private TimeSpan _start = TimeSpan.Zero; @@ -57,9 +73,10 @@ public void Dispose() private static TimeCodeSettings FrameMode() => new(frameMode: true, stepMs: 100); - private static (Window window, TimeCodeUpDown control, TextBox textBox) Show(TimeCodeUpDown control) + private (Window window, TimeCodeUpDown control, TextBox textBox) Show(TimeCodeUpDown control) { var window = new Window { Content = control }; + _windows.Add(window); window.Show(); var textBox = control.GetVisualDescendants().OfType().Single(); return (window, control, textBox); diff --git a/tests/UI/Controls/VideoPlayerControlRestorePositionTests.cs b/tests/UI/Controls/VideoPlayerControlRestorePositionTests.cs new file mode 100644 index 00000000000..fae6926e89d --- /dev/null +++ b/tests/UI/Controls/VideoPlayerControlRestorePositionTests.cs @@ -0,0 +1,159 @@ +using Avalonia.Headless.XUnit; +using Nikse.SubtitleEdit.Controls.VideoPlayer; +using Nikse.SubtitleEdit.Logic.VideoPlayers; +using Nikse.SubtitleEdit.Logic.VideoPlayers.LibMpvDynamic; +using System.Threading.Tasks; +using Xunit; + +namespace UITests.Controls; + +/// +/// Covers the rewind behind issue #14218. A rebuild of the video player (Options/OK, dock or +/// undock, fullscreen) carries the current position over to the player it builds - but a player +/// that is itself still opening reports 0, so a rebuild landing on top of another rebuild used +/// to carry 0 forward and rewind the video to the start. Settings -> Apply -> OK does exactly +/// that: Apply rebuilds the player, OK rebuilds it again a moment later. +/// +public class VideoPlayerControlRestorePositionTests +{ + private sealed class NotYetLoadedVideoPlayer : IVideoPlayer + { + public string Name => "not-yet-loaded"; + public string FileName { get; private set; } = string.Empty; + + public bool CanLoad() => true; + + public Task LoadFile(string fileName, double startPositionSeconds = 0) + { + FileName = fileName; + + // A real player reports 0 until its core is up and playback has restarted - that is + // the whole point of this test. + Position = 0; + return Task.CompletedTask; + } + + public void CloseFile() => FileName = string.Empty; + + public void Play() + { + } + + public void PlayOrPause() + { + } + + public void Pause() + { + } + + public void Stop() + { + } + + public AudioTrackInfo? ToggleAudioTrack() => null; + + public bool IsPlaying => false; + public bool IsPaused => true; + public double Position { get; set; } + public double Duration => 600; + public int VolumeMaximum => 100; + public double Volume { get; set; } = 50; + public double Speed { get; set; } = 1.0; + } + + [AvaloniaFact] + public async Task AnOpenInFlightReportsWhereItIsHeadingRatherThanZero() + { + var control = new VideoPlayerControl(new NotYetLoadedVideoPlayer()); + + await control.Open("fake.mkv", 321.5); + + // The player has not caught up yet, so the live position is still the start of the file. + Assert.Equal(0, control.Position); + + // A rebuild sampling now must get the position the open is heading for. + Assert.Equal(321.5, control.PositionForRestore); + } + + [AvaloniaFact] + public async Task ARestoreAnnouncedBeforeAStartLessOpenSurvivesIt() + { + var control = new VideoPlayerControl(new NotYetLoadedVideoPlayer()); + + // The docked layout rebuild opens without a start position and seeks afterwards. + control.BeginPositionRestore(210); + await control.Open("fake.mkv"); + + Assert.Equal(210, control.PositionForRestore); + } + + [AvaloniaFact] + public async Task TheLivePositionRulesAgainOnceTheRestoreIsDone() + { + var control = new VideoPlayerControl(new NotYetLoadedVideoPlayer()); + + await control.Open("fake.mkv", 321.5); + Assert.Equal(321.5, control.PositionForRestore); + + // The restoring code is done - from here the player itself is the truth again, so a + // pending target must not go on shadowing it for the rest of the control's life. + control.EndPositionRestore(); + + Assert.Equal(control.Position, control.PositionForRestore); + } + + [AvaloniaFact] + public async Task ARestoreThatNeverLandedKeepsItsTarget() + { + var player = new NotYetLoadedVideoPlayer(); + var control = new VideoPlayerControl(player); + + await control.Open("fake.mkv", 321.5); + + // The restoring code has run out of seeks (a bounded ready wait plus a fixed number of + // retries) while the player is still at the start of the file. Handing the live position + // back here is what rewound the video: the next rebuild would sample this player's 0. + control.EndPositionRestoreIfArrived(); + + Assert.Equal(321.5, control.PositionForRestore); + } + + [AvaloniaFact] + public async Task ASeekDuringAPendingRestoreRetargetsIt() + { + var player = new NotYetLoadedVideoPlayer(); + var control = new VideoPlayerControl(player); + + await control.Open("fake.mkv", 600); + + // The user does not wait for the slow restore - they seek somewhere else. The pending + // target must follow: keeping 600 would make the next rebuild jump back there, and + // dropping it would hand the rebuild the 0 of a player that is still loading (#14218). + control.SeekTo(300); + player.Position = 0; // the still-loading player swallowed that seek too + control.EndPositionRestoreIfArrived(); + + Assert.Equal(300, control.PositionForRestore); + + // And once the player lands near the new target, the live position rules again. + player.Position = 300; + control.EndPositionRestoreIfArrived(); + Assert.Equal(control.Position, control.PositionForRestore); + } + + [AvaloniaFact] + public async Task ARestoreThatLandedGivesTheLivePositionBack() + { + var player = new NotYetLoadedVideoPlayer(); + var control = new VideoPlayerControl(player); + + await control.Open("fake.mkv", 321.5); + + // The player got where it was told to go, so it is the truth again. + player.Position = 321.5; + control.EndPositionRestoreIfArrived(); + + Assert.Equal(control.Position, control.PositionForRestore); + } +} diff --git a/tests/UI/Controls/VideoPlayerControlSmpteSeekTests.cs b/tests/UI/Controls/VideoPlayerControlSmpteSeekTests.cs new file mode 100644 index 00000000000..3535be07b2a --- /dev/null +++ b/tests/UI/Controls/VideoPlayerControlSmpteSeekTests.cs @@ -0,0 +1,77 @@ +using Avalonia.Headless.XUnit; +using Nikse.SubtitleEdit.Controls.VideoPlayer; +using Nikse.SubtitleEdit.Logic.VideoPlayers; +using Nikse.SubtitleEdit.Logic.VideoPlayers.LibMpvDynamic; +using System.Threading.Tasks; +using Xunit; + +namespace UITests.Controls; + +/// +/// SMPTE drop-frame seeks. While SMPTE timing is enabled every position READ from the player is +/// compressed by 1000/1001 before it reaches the UI (the control's position timer, the playhead +/// estimator, the waveform time axis), so UI position values live on the drop-frame clock. A seek +/// must expand its UI value back to the player's real clock - passing it through unchanged lands +/// every seek 0.1% early, proportional to the absolute position (about a second per 17 minutes), +/// and the playhead pin then snapped the waveform cursor back once its 600 ms timeout expired. +/// +public class VideoPlayerControlSmpteSeekTests +{ + private sealed class RecordingVideoPlayer : IVideoPlayer + { + public string Name => "recording"; + public string FileName { get; private set; } = string.Empty; + public bool CanLoad() => true; + + public Task LoadFile(string fileName, double startPositionSeconds = 0) + { + FileName = fileName; + Position = startPositionSeconds; + return Task.CompletedTask; + } + + public void CloseFile() => FileName = string.Empty; + public void Play() => IsPlaying = true; + public void PlayOrPause() => IsPlaying = !IsPlaying; + public void Pause() => IsPlaying = false; + public void Stop() => IsPlaying = false; + public AudioTrackInfo? ToggleAudioTrack() => null; + + public bool IsPlaying { get; set; } + public bool IsPaused => !IsPlaying; + public double Position { get; set; } + public double Duration => 7200; + public int VolumeMaximum => 100; + public double Volume { get; set; } = 50; + public double Speed { get; set; } = 1.0; + } + + [AvaloniaFact] + public void SeekTo_WithSmpteTiming_ExpandsToThePlayersRealClock() + { + var player = new RecordingVideoPlayer(); + var control = new VideoPlayerControl(player) { IsSmpteTimingEnabled = true }; + control.Duration = 7200; // published before seeking, or the bound slider clamps the display write + + control.SeekTo(3600); + + // The player's clock runs 1001/1000 relative to the UI's drop-frame clock; a read of the + // landed position compresses back to exactly the requested UI value. + Assert.Equal(3600 * 1001.0 / 1000.0, player.Position, 4); + Assert.Equal(3600, player.Position * 1000.0 / 1001.0, 4); + Assert.Equal(3600, control.Position, 4); // the display shows the UI value, unexpanded + } + + [AvaloniaFact] + public void SeekTo_WithoutSmpteTiming_PassesTheValueThrough() + { + var player = new RecordingVideoPlayer(); + var control = new VideoPlayerControl(player); + control.Duration = 7200; // published before seeking, or the bound slider clamps the display write + + control.SeekTo(3600); + + Assert.Equal(3600, player.Position, 4); + Assert.Equal(3600, control.Position, 4); + } +} diff --git a/tests/UI/Features/Assa/AssaAttachmentsMoveTests.cs b/tests/UI/Features/Assa/AssaAttachmentsMoveTests.cs new file mode 100644 index 00000000000..3a3e1ef1930 --- /dev/null +++ b/tests/UI/Features/Assa/AssaAttachmentsMoveTests.cs @@ -0,0 +1,116 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Microsoft.Extensions.DependencyInjection; +using Nikse.SubtitleEdit; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Assa; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace UITests.Features.Assa; + +/// +/// SE 4's attachments window could reorder the list with move up/down/to top/to bottom. The +/// order is not presentation-only: it is the order the attachments are written back into the +/// subtitle's [Fonts]/[Graphics] sections on OK, which is why these grids have no header sort. +/// +public class AssaAttachmentsMoveTests : IDisposable +{ + private readonly List _windows = new(); + + public void Dispose() + { + foreach (var window in _windows) + { + window.Close(); + } + + _windows.Clear(); + } + + private (AssaAttachmentsWindow Window, AssaAttachmentsViewModel Vm) MakeWindowWithThreeFonts() + { + var services = new ServiceCollection(); + services.AddSubtitleEditServices(); + Locator.Services = services.BuildServiceProvider(); + + var vm = Locator.Services.GetRequiredService(); + foreach (var name in new[] { "a.ttf", "b.ttf", "c.ttf" }) + { + vm.Attachments.Add(new AssaAttachmentItem + { + FileName = name, + Category = Se.Language.General.Fonts, + Content = "M" + name, + Bytes = [1, 2, 3], + Size = "3 bytes", + }); + } + + var window = new AssaAttachmentsWindow(vm); + _windows.Add(window); + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + return (window, vm); + } + + private static void Select(AssaAttachmentsViewModel vm, AssaAttachmentItem item) + { + vm.AttachmentGrid.SelectedItem = item; + Dispatcher.UIThread.RunJobs(); + } + + [AvaloniaFact] + public void MoveDownReordersTheAttachmentsAndKeepsTheSelection() + { + var (_, vm) = MakeWindowWithThreeFonts(); + var first = vm.Attachments[0]; + + Select(vm, first); + vm.MoveDownCommand.Execute(null); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(new[] { "b.ttf", "a.ttf", "c.ttf" }, vm.Attachments.Select(a => a.FileName)); + Assert.Same(first, vm.AttachmentGrid.SelectedItem); + } + + [AvaloniaFact] + public void MoveToBottomWritesTheNewOrderToTheFooter() + { + var (_, vm) = MakeWindowWithThreeFonts(); + + Select(vm, vm.Attachments[0]); + vm.MoveToBottomCommand.Execute(null); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(new[] { "b.ttf", "c.ttf", "a.ttf" }, vm.Attachments.Select(a => a.FileName)); + + vm.OkCommand.Execute(null); + + var fontNames = vm.Footer.SplitToLines()! + .Where(line => line.StartsWith("fontname: ", StringComparison.Ordinal)) + .Select(line => line.Substring("fontname: ".Length)) + .ToArray(); + Assert.Equal(new[] { "b.ttf", "c.ttf", "a.ttf" }, fontNames); + } + + [AvaloniaFact] + public void MoveToTopMovesTheSelectedAttachmentFirst() + { + var (_, vm) = MakeWindowWithThreeFonts(); + + Select(vm, vm.Attachments[2]); + vm.MoveToTopCommand.Execute(null); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(new[] { "c.ttf", "a.ttf", "b.ttf" }, vm.Attachments.Select(a => a.FileName)); + } +} diff --git a/tests/UI/Features/Assa/AssaResamplerTextTests.cs b/tests/UI/Features/Assa/AssaResamplerTextTests.cs new file mode 100644 index 00000000000..573f930faf9 --- /dev/null +++ b/tests/UI/Features/Assa/AssaResamplerTextTests.cs @@ -0,0 +1,61 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Features.Assa.ResolutionResampler; + +namespace UITests.Features.Assa; + +/// +/// is what the main window runs over the grid rows +/// when a video with another picture size is opened (#14367): the rows are the working text, so +/// scaling only _subtitle.Paragraphs left every positioned line at its old coordinates +/// while the header already named the new resolution. +/// +public class AssaResamplerTextTests +{ + [Fact] + public void ResampleText_ScalesPositionMoveAndOrigin() + { + var text = @"{\pos(640,360)\move(100,200,300,400)\org(64,36)}Hello"; + + var result = AssaResamplerHelper.ResampleText(text, 1280, 720, 1920, 1080); + + Assert.Equal(@"{\pos(960,540)\move(150,300,450,600)\org(96,54)}Hello", result); + } + + [Fact] + public void ResampleText_ScalesFontSizeOnlyWhenAsked() + { + var text = @"{\fs40\pos(640,360)}Hello"; + + var positionsOnly = AssaResamplerHelper.ResampleText(text, 1280, 720, 1920, 1080, changeFontSize: false); + var fontOnly = AssaResamplerHelper.ResampleText(text, 1280, 720, 1920, 1080, changePositions: false); + + Assert.Equal(@"{\fs40\pos(960,540)}Hello", positionsOnly); + Assert.Equal(@"{\fs60\pos(640,360)}Hello", fontOnly); + } + + [Fact] + public void ResampleText_LeavesPlainTextAlone() + { + Assert.Equal("Hello", AssaResamplerHelper.ResampleText("Hello", 1280, 720, 1920, 1080)); + Assert.Equal(string.Empty, AssaResamplerHelper.ResampleText(string.Empty, 1280, 720, 1920, 1080)); + } + + [Fact] + public void ApplyResampling_ScalesParagraphsAndHeader() + { + var subtitle = new Subtitle + { + Header = AdvancedSubStationAlpha.AddTagToHeader("PlayResY", "PlayResY: 720", + "[Script Info]", AdvancedSubStationAlpha.AddTagToHeader("PlayResX", "PlayResX: 1280", + "[Script Info]", AdvancedSubStationAlpha.DefaultHeader)), + }; + subtitle.Paragraphs.Add(new Paragraph(@"{\pos(640,360)}Hello", 0, 1000)); + + AssaResamplerHelper.ApplyResampling(subtitle, 1280, 720, 1920, 1080); + + Assert.Equal(@"{\pos(960,540)}Hello", subtitle.Paragraphs[0].Text); + Assert.Equal("1920", AdvancedSubStationAlpha.GetTagValueFromHeader("PlayResX", "[Script Info]", subtitle.Header)); + Assert.Equal("1080", AdvancedSubStationAlpha.GetTagValueFromHeader("PlayResY", "[Script Info]", subtitle.Header)); + } +} diff --git a/tests/UI/Features/Assa/FontCollectorEmbeddedMatchTests.cs b/tests/UI/Features/Assa/FontCollectorEmbeddedMatchTests.cs index ef1b9918423..158a7198326 100644 --- a/tests/UI/Features/Assa/FontCollectorEmbeddedMatchTests.cs +++ b/tests/UI/Features/Assa/FontCollectorEmbeddedMatchTests.cs @@ -25,7 +25,7 @@ private sealed class FakeFileHelper : IFileHelper // Only the members the collector could touch matter; the rest throw if reached. public Task PickOpenFile(Visual sender, string title, string extensionTitle, string extension, string extensionTitle2 = "", string extension2 = "", string? suggestedStartFolder = null) => Task.FromResult(string.Empty); public Task PickOpenFiles(Visual sender, string title, string extensionTitle, List extensions, string extensionTitle2, List extensions2) => Task.FromResult(Array.Empty()); - public Task PickOpenSubtitleFile(Visual sender, string title, bool includeVideoFiles = true, string? lastOpenedFilePath = null) => throw new NotSupportedException(); + public Task PickOpenSubtitleFile(Visual sender, string title, bool includeVideoFiles = true, string? lastOpenedFilePath = null, bool includeSpreadsheets = false) => throw new NotSupportedException(); public Task PickOpenSubtitleFiles(Visual sender, string title, bool includeVideoFiles = true, string? lastOpenedFilePath = null) => throw new NotSupportedException(); public Task PickSaveSubtitleFile(Visual sender, Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat currentFormat, string suggestedFileName, string title) => throw new NotSupportedException(); public Task PickSaveSubtitleFileAs(Visual sender, Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat currentFormat, string suggestedFileName, string title) => throw new NotSupportedException(); @@ -33,7 +33,7 @@ private sealed class FakeFileHelper : IFileHelper public Task PickSaveFile(Visual sender, string extension, string suggestedFileName, string title) => throw new NotSupportedException(); public Task PickSaveFile(Visual sender, string extension, string extensionTitle, string suggestedFileName, string title) => throw new NotSupportedException(); public Task PickSaveFile(Visual sender, IReadOnlyList<(string Name, string Extension)> fileTypes, string suggestedFileName, string title) => throw new NotSupportedException(); - public Task PickOpenVideoFile(Visual sender, string title) => throw new NotSupportedException(); + public Task PickOpenVideoFile(Visual sender, string title, string? lastOpenedFilePath = null) => throw new NotSupportedException(); public Task PickOpenVideoFiles(Visual sender, string title) => throw new NotSupportedException(); public Task PickOpenImageFile(Visual sender, string title) => throw new NotSupportedException(); } diff --git a/tests/UI/Features/BugHunt13Tests.cs b/tests/UI/Features/BugHunt13Tests.cs new file mode 100644 index 00000000000..1f6f8755715 --- /dev/null +++ b/tests/UI/Features/BugHunt13Tests.cs @@ -0,0 +1,81 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Assa.AssaApplyCustomOverrideTags; +using Nikse.SubtitleEdit.Features.Video.BurnIn; +using Nikse.SubtitleEdit.Features.WebVtt; +using System.Linq; + +namespace UITests.Features; + +/// +/// Guard tests for the 2026-08-27 bug hunt: defects found by auditing settings that are saved +/// but never loaded back, dialogs that mutate the caller's object before Cancel, and emitted +/// ASSA/WebVTT markup that does not match what the reader accepts. +/// +public class BugHunt13Tests +{ + [Fact] + public void FadeOverrideTag_IsWellFormed() + { + // "{\fad(300,300}" - libass tolerates the missing parenthesis, other renderers need not, + // and this tag is written into the subtitle the user saves and shares. + var fade = OverrideTagDisplay.List().FirstOrDefault(p => p.Tag.Contains("\\fad")); + + Assert.NotNull(fade); + Assert.Contains("\\fad(300,300)", fade!.Tag, System.StringComparison.Ordinal); + } + + [Fact] + public void BurnInFadeEffect_IsWellFormed() + { + var effect = BurnInEffectItem.List().First(p => p.Type == BurnInEffectType.FadeInOut); + + var result = effect.ApplyEffect("Hello", 1920, 1080, 40, 2000); + + Assert.Contains("\\fad(250,250)", result, System.StringComparison.Ordinal); + } + + [Fact] + public void BurnInLogo_Clone_IsIndependentOfTheOriginal() + { + // The logo dialog edits its instance live, so it must get a copy - otherwise Cancel keeps + // every drag and slider change. + var original = new BurnInLogo { LogoFileName = "logo.png", X = 10, Y = 20, Alpha = 50, Size = 75 }; + + var clone = original.Clone(); + clone.X = 999; + clone.Alpha = 1; + + Assert.Equal(10, original.X); + Assert.Equal(50, original.Alpha); + Assert.Equal("logo.png", clone.LogoFileName); + Assert.Equal(20, clone.Y); + Assert.Equal(75, clone.Size); + } + + [Fact] + public void WebVttStyleDisplay_ClassSelector_KeepsItsDot() + { + var display = new WebVttStyleDisplay(new WebVttStyle { Name = ".red" }); + + Assert.True(display.IsClassSelector); + Assert.Equal("red", display.Name); + Assert.Equal(".red", display.ToWebVttStyle().Name); + } + + [Fact] + public void WebVttStyleDisplay_ElementSelector_DoesNotBecomeAClass() + { + // "::cue(b)" styles bold text; rewriting it as "::cue(.b)" moves the styling to cues + // carrying class "b" instead, so bold text silently loses it. + var display = new WebVttStyleDisplay(new WebVttStyle { Name = "b" }); + + Assert.False(display.IsClassSelector); + Assert.Equal("b", display.ToWebVttStyle().Name); + } + + [Fact] + public void WebVttStyleDisplay_NewStyle_DefaultsToAClassSelector() + { + Assert.True(new WebVttStyleDisplay().IsClassSelector); + } +} diff --git a/tests/UI/Features/BugHunt21Tests.cs b/tests/UI/Features/BugHunt21Tests.cs new file mode 100644 index 00000000000..961de9264ee --- /dev/null +++ b/tests/UI/Features/BugHunt21Tests.cs @@ -0,0 +1,78 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Features.Main.AssistedSplit; +using Nikse.SubtitleEdit.Features.Options.Shortcuts; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using System.Collections.Generic; +using System.Linq; + +namespace UITests.Features; + +/// +/// Guard tests for the 2026-08-30 bug hunt: an SE 4 import that silently replaced one binding +/// with another, and a split suggester whose decimal-number guard also swallowed real sentence +/// ends. +/// +public class BugHunt21Tests +{ + [Fact] + public void Se4Import_KeepsTheFirstBindingWhenTwoActionsShareOneCommand() + { + // "MainFileSave" and "MainFileSaveAll" both map onto CommandFileSaveCommand. Both used to + // be handed to the caller, which removes any existing entry for a command before adding - + // so the second silently replaced the first while still counting as imported. + const string xml = "" + + "Control+S" + + "Control+Shift+S" + + ""; + + var result = Se4ShortcutsImporter.ImportFromXml(xml); + + var save = result.Shortcuts.Where(s => s.ActionName == nameof(MainViewModel.CommandFileSaveCommand)).ToList(); + Assert.Single(save); + Assert.Equal(new[] { "Control", "S" }, save[0].Keys); + Assert.Equal(1, result.SkippedDuplicate); + } + + [Fact] + public void Se4Import_DistinctCommandsAreAllKept() + { + const string xml = "" + + "Control+S" + + "Control+Shift+S" + + ""; + + var result = Se4ShortcutsImporter.ImportFromXml(xml); + + Assert.Equal(2, result.Shortcuts.Count); + Assert.Equal(0, result.SkippedDuplicate); + } + + private static List Split(string text) + { + var line = new SubtitleLineViewModel { Text = text, StartTime = System.TimeSpan.Zero, EndTime = System.TimeSpan.FromSeconds(4) }; + return AssistedSplitCandidateGenerator.Generate(line, "en", new SplitManager()); + } + + [Fact] + public void AssistedSplit_OffersASentenceEndFollowedByAClosingQuote() + { + // The decimal-number guard skipped every '.' that was not followed by whitespace, which + // also skipped a '.' followed by the closing quote - and that made the closing-quote run + // below it unreachable, so this line got no sentence-end suggestion at all. + var candidates = Split("She said \"Stop.\" He left the room without another word."); + + Assert.Contains(candidates, c => c.FirstText.TrimEnd().EndsWith("\"", System.StringComparison.Ordinal)); + } + + [Theory] + [InlineData("The price was 1.5 million and nobody blinked at it")] + [InlineData("Go to nikse.dk for the latest build of the program")] + public void AssistedSplit_StillDoesNotSplitInsideNumbersOrAddresses(string text) + { + var candidates = Split(text); + + Assert.DoesNotContain(candidates, c => c.Title == Se.Language.General.SplitAtSentenceEnd); + } +} diff --git a/tests/UI/Features/Edit/MultipleReplaceMoveFocusTests.cs b/tests/UI/Features/Edit/MultipleReplaceMoveFocusTests.cs new file mode 100644 index 00000000000..a69fcebfb40 --- /dev/null +++ b/tests/UI/Features/Edit/MultipleReplaceMoveFocusTests.cs @@ -0,0 +1,182 @@ +using Avalonia; +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.Config; +using Nikse.SubtitleEdit.Logic.Media; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace UITests.Features.Edit; + +/// +/// Reordering has to be repeatable: Ctrl+Up on a rule moved it once and then dropped keyboard +/// focus, so the tree handed focus - and with it the selection - to the category above on the +/// next key press and the second Ctrl+Up moved the category instead (#14136). These tests drive +/// the real window, because the bug lives in the container lookup, not in the reorder itself. +/// +public class MultipleReplaceMoveFocusTests : IDisposable +{ + // A window left open outlives the test: it keeps the application-wide activation and focused + // element, so a later test's click or key press is delivered to it instead. Closing here rather + // than at the end of each test also covers the tests that stop early on a failed assertion. + 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, Window Window) Open() + { + var vm = new MultipleReplaceViewModel(new WindowService(new NullServiceProvider()), new FileHelper()); + vm.Nodes.Clear(); + + for (var c = 1; c <= 2; c++) + { + var category = new RuleTreeNode(true) { CategoryName = $"c{c}", IsActive = true, IsExpanded = true }; + for (var r = 1; r <= 4; r++) + { + category.SubNodes!.Add(new RuleTreeNode(false) + { + Find = $"c{c}r{r}", + ReplaceWith = "x", + IsActive = true, + Parent = category, + Type = MultipleReplaceType.CaseInsensitive, + }); + } + + vm.Nodes.Add(category); + } + + var subtitle = new Subtitle(); + subtitle.Paragraphs.Add(new Paragraph("hello", 0, 2000)); + vm.Initialize(subtitle); + + var window = new MultipleReplaceWindow(vm); + _windows.Add(window); + window.Show(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + return (vm, window); + } + + private static string Rules(RuleTreeNode category) => + string.Join(",", category.SubNodes!.Select(n => n.Find)); + + private static string Categories(MultipleReplaceViewModel vm) => + string.Join(",", vm.Nodes.Select(n => n.CategoryName)); + + private static TreeViewItem ContainerOf(Window window, RuleTreeNode node) + { + var container = window.GetVisualDescendants().OfType() + .FirstOrDefault(i => ReferenceEquals(i.DataContext, node)); + Assert.NotNull(container); + return container!; + } + + // Clicking the row is the only way to get real keyboard focus into the tree, and the bug is + // about what happens to that focus afterwards. + private static void ClickNode(Window window, MultipleReplaceViewModel vm, RuleTreeNode node) + { + var container = ContainerOf(window, node); + var bounds = container.Bounds; + var point = container.TranslatePoint(new Point(bounds.Width / 2, 8), window)!.Value; + window.MouseDown(point, MouseButton.Left); + window.MouseUp(point, MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + + Assert.Same(node, vm.SelectedNode); + } + + private static void CtrlKey(Window window, PhysicalKey key) + { + window.KeyPressQwerty(key, RawInputModifiers.Control); + Dispatcher.UIThread.RunJobs(); + } + + [AvaloniaFact] + public void CtrlUp_WalksTheSameRuleUpTheCategory() + { + var (vm, window) = Open(); + var category = vm.Nodes[0]; + var rule = category.SubNodes![3]; + ClickNode(window, vm, rule); + + CtrlKey(window, PhysicalKey.ArrowUp); + Assert.Equal("c1r1,c1r2,c1r4,c1r3", Rules(category)); + Assert.Same(rule, vm.SelectedNode); + + CtrlKey(window, PhysicalKey.ArrowUp); + Assert.Equal("c1r1,c1r4,c1r2,c1r3", Rules(category)); + Assert.Same(rule, vm.SelectedNode); + + CtrlKey(window, PhysicalKey.ArrowUp); + Assert.Equal("c1r4,c1r1,c1r2,c1r3", Rules(category)); + Assert.Same(rule, vm.SelectedNode); + + // The category never becomes the selection, so it never gets reordered either. + Assert.Equal("c1,c2", Categories(vm)); + } + + [AvaloniaFact] + public void CtrlDown_WalksTheSameRuleDownTheCategory() + { + var (vm, window) = Open(); + var category = vm.Nodes[1]; + var rule = category.SubNodes![0]; + ClickNode(window, vm, rule); + + CtrlKey(window, PhysicalKey.ArrowDown); + CtrlKey(window, PhysicalKey.ArrowDown); + + Assert.Equal("c2r2,c2r3,c2r1,c2r4", Rules(category)); + Assert.Same(rule, vm.SelectedNode); + Assert.Equal("c1,c2", Categories(vm)); + } + + // The moved row has to keep keyboard focus - that is what makes the next key press reach it. + [AvaloniaFact] + public void MovedRule_KeepsKeyboardFocus() + { + var (vm, window) = Open(); + var rule = vm.Nodes[0].SubNodes![2]; + ClickNode(window, vm, rule); + + CtrlKey(window, PhysicalKey.ArrowUp); + + var focused = window.FocusManager?.GetFocusedElement(); + Assert.Same(rule, (focused as TreeViewItem)?.DataContext); + } + + [AvaloniaFact] + public void CtrlUp_WalksTheSameCategory() + { + var (vm, window) = Open(); + var category = vm.Nodes[1]; + ClickNode(window, vm, category); + + CtrlKey(window, PhysicalKey.ArrowUp); + + Assert.Equal("c2,c1", Categories(vm)); + Assert.Same(category, vm.SelectedNode); + } +} diff --git a/tests/UI/Features/Edit/MultipleReplaceParagraphIdTests.cs b/tests/UI/Features/Edit/MultipleReplaceParagraphIdTests.cs new file mode 100644 index 00000000000..7069d30f6bb --- /dev/null +++ b/tests/UI/Features/Edit/MultipleReplaceParagraphIdTests.cs @@ -0,0 +1,116 @@ +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Edit.MultipleReplace; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Media; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace UITests.Features.Edit; + +/// +/// The paragraph ids of the subtitle Multiple replace was handed are how the main window finds the +/// grid row each line came from, so the result must carry them back - "Apply" rounds included. +/// Copying the subtitle with new ids made every line look like a line the main window had never +/// seen, which meant rebuilding the rows and losing the original text of a translation (#14053). +/// +public class MultipleReplaceParagraphIdTests +{ + private sealed class NullServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + [AvaloniaFact] + public void Apply_HandsBackTheParagraphIdsItWasGiven() + { + var (vm, ids) = WithThreeMatchingLines(); + Subtitle? applied = null; + vm.OnApply = (subtitle, _) => applied = subtitle; + + vm.ApplyCommand.Execute(null); + Dispatcher.UIThread.RunJobs(); + + Assert.NotNull(applied); + Assert.Equal(ids, applied!.Paragraphs.Select(p => p.Id).ToList()); + Assert.Equal("The color is red.", applied.Paragraphs[0].Text); + } + + [AvaloniaFact] + public void SecondApplyRound_StillHasTheSameParagraphIds() + { + var (vm, ids) = WithThreeMatchingLines(); + var applied = new List(); + vm.OnApply = (subtitle, _) => applied.Add(subtitle); + + vm.ApplyCommand.Execute(null); + Dispatcher.UIThread.RunJobs(); + vm.ApplyCommand.Execute(null); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(2, applied.Count); + Assert.Equal(ids, applied[1].Paragraphs.Select(p => p.Id).ToList()); + } + + [AvaloniaFact] + public void Ok_HandsBackTheParagraphIdsItWasGiven() + { + var (vm, ids) = WithThreeMatchingLines(); + + vm.OkCommand.Execute(null); + Dispatcher.UIThread.RunJobs(); + + Assert.True(vm.OkPressed); + Assert.Equal(ids, vm.FixedSubtitle.Paragraphs.Select(p => p.Id).ToList()); + } + + private static (MultipleReplaceViewModel Vm, List Ids) WithThreeMatchingLines() + { + var vm = new MultipleReplaceViewModel(new WindowService(new NullServiceProvider()), new FileHelper()); + vm.Nodes.Clear(); + + var category = new RuleTreeNode(true) { CategoryName = "c1", IsActive = true }; + vm.Nodes.Add(category); + category.SubNodes!.Add(new RuleTreeNode(false) + { + Find = "colour", + ReplaceWith = "color", + IsActive = true, + Parent = category, + Type = MultipleReplaceType.CaseInsensitive, + }); + + var subtitle = new Subtitle(); + subtitle.Paragraphs.Add(new Paragraph("The colour is red.", 0, 2000)); + subtitle.Paragraphs.Add(new Paragraph("The colour is green.", 2000, 4000)); + subtitle.Paragraphs.Add(new Paragraph("The colour is blue.", 4000, 6000)); + vm.Initialize(subtitle); + + WaitForPreview(vm, 3); + Assert.Equal(3, vm.Fixes.Count); + + return (vm, subtitle.Paragraphs.Select(p => p.Id).ToList()); + } + + // The preview is generated on a background timer, so waiting is the only way to observe it. + private static void WaitForPreview(MultipleReplaceViewModel vm, int expectedFixes) + { + var end = Environment.TickCount64 + 3000; + while (Environment.TickCount64 < end) + { + Dispatcher.UIThread.RunJobs(); + if (vm.Fixes.Count == expectedFixes) + { + Dispatcher.UIThread.RunJobs(); + return; + } + + Thread.Sleep(20); + } + + Dispatcher.UIThread.RunJobs(); + } +} diff --git a/tests/UI/Features/Edit/MultipleReplaceRegexTimeoutTests.cs b/tests/UI/Features/Edit/MultipleReplaceRegexTimeoutTests.cs index 89999383ad7..46e78417a0c 100644 --- a/tests/UI/Features/Edit/MultipleReplaceRegexTimeoutTests.cs +++ b/tests/UI/Features/Edit/MultipleReplaceRegexTimeoutTests.cs @@ -25,8 +25,12 @@ namespace UITests.Features.Edit; /// even in an unticked category": validation must not be what builds the RegexOptions.Compiled /// regex, or opening the dialog emits IL for every rule in the file. /// -public class MultipleReplaceRegexTimeoutTests +public class MultipleReplaceRegexTimeoutTests : IDisposable { + private readonly ShortRegexTimeout _shortRegexTimeout = new(); + + public void Dispose() => _shortRegexTimeout.Dispose(); + private sealed class NullServiceProvider : IServiceProvider { public object? GetService(Type serviceType) => null; diff --git a/tests/UI/Features/Files/Compare/CompareOptionsTests.cs b/tests/UI/Features/Files/Compare/CompareOptionsTests.cs new file mode 100644 index 00000000000..e2165c25d0e --- /dev/null +++ b/tests/UI/Features/Files/Compare/CompareOptionsTests.cs @@ -0,0 +1,147 @@ +using Avalonia.Headless.XUnit; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Files.Compare; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Media; +using System.Collections.ObjectModel; + +namespace UITests.Features.Files.Compare; + +/// +/// The two "ignore" options and the "Show" choice, from #14299: "Ignore formatting" did nothing +/// at all on an .ass/.ssa file (the ASSA tags were not stripped), and none of the three settings +/// survived closing the window. +/// +/// [AvaloniaFact] rather than [Fact]: the view model builds Avalonia controls (its TableViews, and +/// a StackPanel per CompareItem), and creating those off the Avalonia thread leaves the headless +/// session unable to set itself up - which surfaces as unrelated test classes failing their cleanup. +/// +public class CompareOptionsTests : IDisposable +{ + private readonly SeCompare _savedSettings = Se.Settings.File.Compare; + + public CompareOptionsTests() + { + Se.Settings.File.Compare = new SeCompare(); + } + + public void Dispose() + { + Se.Settings.File.Compare = _savedSettings; + } + + private static CompareViewModel MakeViewModel() => new(new FileHelper(), new FolderHelper()); + + [AvaloniaTheory] + [InlineData(@"{\b1}Test bold{\b0}", "Test bold")] + [InlineData(@"{\i1}Test italic{\i0}", "Test italic")] + [InlineData(@"{\an8}Alignment test", "Alignment test")] + [InlineData("Test italic", "Test italic")] + public void IgnoreFormatting_StripsAssaTagsToo(string withTags, string withoutTags) + { + var vm = MakeViewModel(); + vm.IgnoreFormatting = true; + + Assert.Equal(vm.NormalizeForCompare(withoutTags), vm.NormalizeForCompare(withTags)); + } + + [AvaloniaFact] + public void IgnoreFormattingOff_KeepsAssaTagsAsADifference() + { + var vm = MakeViewModel(); + + Assert.NotEqual(vm.NormalizeForCompare("Test bold"), vm.NormalizeForCompare(@"{\b1}Test bold{\b0}")); + } + + [AvaloniaFact] + public void IgnoreWhiteSpace_IgnoresDoubleSpacesAndLineBreaks() + { + var vm = MakeViewModel(); + vm.IgnoreWhiteSpace = true; + + Assert.Equal( + vm.NormalizeForCompare("Same text not everywhere."), + vm.NormalizeForCompare("Same text not" + Environment.NewLine + "everywhere.")); + } + + [AvaloniaFact] + public void BothOptions_IgnoreTagsAndWhitespaceTogether() + { + var vm = MakeViewModel(); + vm.IgnoreFormatting = true; + vm.IgnoreWhiteSpace = true; + + Assert.Equal(vm.NormalizeForCompare("Test bold"), vm.NormalizeForCompare(@" {\b1}Test bold{\b0} ")); + } + + [AvaloniaFact] + public void SaveSettings_RemembersShowAndBothOptions() + { + var vm = MakeViewModel(); + vm.SelectedCompareVisual = vm.CompareVisuals.First(p => p.Type == CompareVisualType.ShowOnlyDifferencesInText); + vm.IgnoreWhiteSpace = true; + vm.IgnoreFormatting = true; + + vm.SaveSettings(); + + Assert.Equal(nameof(CompareVisualType.ShowOnlyDifferencesInText), Se.Settings.File.Compare.Show); + Assert.True(Se.Settings.File.Compare.IgnoreWhitespace); + Assert.True(Se.Settings.File.Compare.IgnoreFormatting); + + // A new window picks up where the last one left off (#14299). + var reopened = MakeViewModel(); + Assert.Equal(CompareVisualType.ShowOnlyDifferencesInText, reopened.SelectedCompareVisual.Type); + Assert.True(reopened.IgnoreWhiteSpace); + Assert.True(reopened.IgnoreFormatting); + } + + [AvaloniaFact] + public void LoadSettings_UnknownShowValue_FallsBackToAll() + { + Se.Settings.File.Compare.Show = "SomethingRemoved"; + + Assert.Equal(CompareVisualType.All, MakeViewModel().SelectedCompareVisual.Type); + } + + [AvaloniaFact] + public void IgnoreFormatting_HidesTheAssaOnlyRowsFromTheDifferenceCount() + { + // The screenshot in #14299: three of the six lines differ only by an ASSA tag. + var left = MakeLines( + "Test 1, test 2, test 3.", + "Same text everywhere.", + @"{\b1}Test bold{\b0}", + @"Test {\i1}italic{\i0}", + @"{\an8}Alignment test"); + var right = MakeLines( + "Test 1, test 2, test 3.", + "Same text everywhere.", + "Test bold", + "Test italic", + "Alignment test"); + + var vm = MakeViewModel(); + vm.IgnoreFormatting = true; + vm.Initialize(left, "left.ass", right, "right.ass", false); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + + Assert.All(vm.LeftSubtitles, item => Assert.False(item.HasDifference)); + Assert.All(vm.RightSubtitles, item => Assert.False(item.HasDifference)); + } + + private static ObservableCollection MakeLines(params string[] texts) + { + var lines = new ObservableCollection(); + for (var i = 0; i < texts.Length; i++) + { + lines.Add(new SubtitleLineViewModel(new Paragraph(texts[i], i * 2000, i * 2000 + 1500), null!) + { + Number = i + 1, + }); + } + + return lines; + } +} + diff --git a/tests/UI/Features/Files/Compare/CompareScrollSyncTests.cs b/tests/UI/Features/Files/Compare/CompareScrollSyncTests.cs new file mode 100644 index 00000000000..ed857c1ba96 --- /dev/null +++ b/tests/UI/Features/Files/Compare/CompareScrollSyncTests.cs @@ -0,0 +1,187 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Files.Compare; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic.Media; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace UITests.Features.Files.Compare; + +/// +/// Compare's two grids scroll as one: whichever side moves, the other lines up on the same row +/// again (#13504). SE4 did this by copying the active list view's TopItem; SE5's grids were +/// independent, so with larger files the sides drifted a page or more apart. +/// +/// The sync has to work in row indices rather than pixels - the two sides hold different text, +/// so a row that wraps to two lines on one side is one line on the other, and equal pixel +/// offsets stop meaning equal rows. The unequal-heights test below is the one that fails for a +/// naive Offset.Y copy. +/// +public class CompareScrollSyncTests : IDisposable +{ + private readonly List _windows = new(); + + public void Dispose() + { + foreach (var window in _windows) + { + window.Close(); + } + + _windows.Clear(); + } + + [AvaloniaFact] + public void ScrollingTheLeftGrid_BringsTheRightGridToTheSameRow() + { + var (window, vm) = OpenCompare(200); + + ScrollTo(window, LeftScrollViewer(vm), 1500); + + Assert.Equal(TopRowIndex(vm.LeftGrid!), TopRowIndex(vm.RightGrid!)); + } + + [AvaloniaFact] + public void ScrollingTheRightGrid_BringsTheLeftGridToTheSameRow() + { + var (window, vm) = OpenCompare(200); + + ScrollTo(window, RightScrollViewer(vm), 2200); + + Assert.Equal(TopRowIndex(vm.RightGrid!), TopRowIndex(vm.LeftGrid!)); + } + + // The right lines wrap to two rendered lines where the left ones do not, so the two grids + // scale their pixels differently. Copying the offset across would land on a different row; + // only the index-based sync stays aligned. + [AvaloniaFact] + public void ScrollingWithUnequalRowHeights_AlignsRowsRatherThanPixels() + { + var (window, vm) = OpenCompare(200, rightLineSuffix: Environment.NewLine + "second rendered line"); + + var leftScrollViewer = LeftScrollViewer(vm); + var rightScrollViewer = RightScrollViewer(vm); + ScrollTo(window, leftScrollViewer, 1500); + + Assert.Equal(TopRowIndex(vm.LeftGrid!), TopRowIndex(vm.RightGrid!)); + Assert.True(Math.Abs(rightScrollViewer.Offset.Y - leftScrollViewer.Offset.Y) > 1, + "the two sides ended on the same pixel offset, so this file does not exercise unequal row heights"); + } + + // Selecting a row already mirrored the selection, but scrolled the other side with + // ScrollIntoView - which only promises the row is somewhere in view. With the taller rows on + // the right that lands a different number of rows into the view, so the two sides could keep + // the same row selected while showing different ranges. + [AvaloniaFact] + public void SelectingARowFarDown_LinesUpBothViews() + { + var (window, vm) = OpenCompare(200, rightLineSuffix: Environment.NewLine + "second rendered line"); + + vm.LeftGrid!.SelectedIndex = 150; + Settle(window); + + Assert.Equal(150, vm.RightGrid!.SelectedIndex); + Assert.Equal(TopRowIndex(vm.LeftGrid!), TopRowIndex(vm.RightGrid!)); + } + + // The right-hand side is empty until a second file is loaded (Tools > Compare always opens + // that way), so the sync has nothing to align to and must simply leave it alone. + [AvaloniaFact] + public void ScrollingWithAnEmptyRightGrid_DoesNotThrow() + { + var (window, vm) = OpenCompare(200, loadRight: false); + + ScrollTo(window, LeftScrollViewer(vm), 1500); + + Assert.Empty(vm.RightSubtitles); + Assert.True(LeftScrollViewer(vm).Offset.Y > 0, "the left grid did not scroll"); + } + + private static int TopRowIndex(TableView tableView) + { + var scrollViewer = tableView.GetVisualDescendants().OfType().First(); + var viewportOrigin = (Visual?)scrollViewer.Presenter ?? scrollViewer; + + var top = double.MaxValue; + var index = -1; + foreach (var row in tableView.GetRealizedContainers().OfType()) + { + if (row.Bounds.Height <= 0 || + ((Visual)row).TranslatePoint(new Point(0, 0), viewportOrigin)?.Y is not { } rowTop || + rowTop + row.Bounds.Height <= 0.5 || + rowTop >= top) + { + continue; + } + + top = rowTop; + index = tableView.IndexFromContainer(row); + } + + return index; + } + + private static ScrollViewer LeftScrollViewer(CompareViewModel vm) + => vm.LeftGrid!.GetVisualDescendants().OfType().First(); + + private static ScrollViewer RightScrollViewer(CompareViewModel vm) + => vm.RightGrid!.GetVisualDescendants().OfType().First(); + + private static void ScrollTo(Window window, ScrollViewer scrollViewer, double offsetY) + { + scrollViewer.Offset = new Vector(scrollViewer.Offset.X, offsetY); + Settle(window); + } + + private (Window Window, CompareViewModel Vm) OpenCompare( + int lineCount, + string rightLineSuffix = "", + bool loadRight = true) + { + var vm = new CompareViewModel(new FileHelper(), new FolderHelper()); + vm.Initialize( + MakeLines(lineCount, string.Empty), + "left.srt", + loadRight ? MakeLines(lineCount, rightLineSuffix) : new ObservableCollection(), + loadRight ? "right.srt" : string.Empty, + false); + + var window = new CompareWindow(vm); + _windows.Add(window); + window.Show(); + Settle(window); + + Assert.Equal(lineCount, vm.LeftSubtitles.Count); + return (window, vm); + } + + private static ObservableCollection MakeLines(int count, string suffix) + { + var lines = new ObservableCollection(); + for (var i = 0; i < count; i++) + { + lines.Add(new SubtitleLineViewModel(new Paragraph($"Line {i + 1}{suffix}", i * 2000, i * 2000 + 1500), null!) + { + Number = i + 1, + }); + } + + return lines; + } + + private static void Settle(Window window) + { + for (var pump = 0; pump < 12; pump++) + { + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + } + } +} diff --git a/tests/UI/Features/Files/Compare/TextDiffHighlighterTests.cs b/tests/UI/Features/Files/Compare/TextDiffHighlighterTests.cs index 68d04fa8e30..100e0b98db4 100644 --- a/tests/UI/Features/Files/Compare/TextDiffHighlighterTests.cs +++ b/tests/UI/Features/Files/Compare/TextDiffHighlighterTests.cs @@ -207,6 +207,137 @@ public void Compare_UnchangedRuns_DoNotOverrideTheInheritedForeground() } } + [Fact] + public void Compare_AppendedSuffix_HighlightsTheAddedTail() + { + // The whole of text1 is the common prefix, so commonStart == text1.Length and + // commonEnd == 0 - the old hasDifferences derivation concluded "identical" and the + // appended tail was rendered with no highlighting at all. + var (left, right) = TextDiffHighlighter.Compare("Hello", "Hello world"); + + Assert.Equal("Hello", JoinRuns(left.Inlines)); + Assert.Equal("Hello world", JoinRuns(right.Inlines)); + + var rightRuns = right.Inlines!.Cast().ToArray(); + Assert.Contains(rightRuns, r => r.Text == " world" && r.Foreground != null); + } + + [Fact] + public void CompareReplacement_AppendedSuffix_HighlightsTheAddedTail() + { + var (before, after) = TextDiffHighlighter.CompareReplacement("Hello", "Hello world"); + + Assert.Equal("Hello", JoinRuns(before.Inlines)); + Assert.Equal("Hello world", JoinRuns(after.Inlines)); + + var afterRuns = after.Inlines!.Cast().ToArray(); + Assert.Contains(afterRuns, r => r.Text == " world" && r.Foreground != null); + } + + [Fact] + public void Compare_IgnoreWhiteSpace_LeavesTheExtraSpaceUnmarked() + { + // With "Ignore whitespace" on, the row coloring already drops the line - but the cell + // kept the pink mark on the extra space and washed the whole text in the "this line + // differs" green, so an ignored difference still read as a difference (#14299). + var (left, right) = TextDiffHighlighter.Compare( + "Same text not everywhere.", + "Same text not everywhere.", + ignoreWhiteSpace: true, + ignoreFormatting: false); + + var leftRun = Assert.IsType(Assert.Single(left.Inlines!)); + var rightRun = Assert.IsType(Assert.Single(right.Inlines!)); + + Assert.Null(leftRun.Background); + Assert.Null(rightRun.Background); + } + + [Fact] + public void Compare_IgnoreFormatting_LeavesAssaTagsUnmarked() + { + var (left, right) = TextDiffHighlighter.Compare( + @"{\b1}Test bold{\b0}", + "Test bold", + ignoreWhiteSpace: false, + ignoreFormatting: true); + + var leftRun = Assert.IsType(Assert.Single(left.Inlines!)); + var rightRun = Assert.IsType(Assert.Single(right.Inlines!)); + + Assert.Equal(@"{\b1}Test bold{\b0}", leftRun.Text); + Assert.Null(leftRun.Background); + Assert.Null(rightRun.Background); + } + + [Fact] + public void Compare_IgnoreFormatting_LeavesHtmlTagsUnmarked() + { + var (left, right) = TextDiffHighlighter.Compare( + "Hello", + "Hello", + ignoreWhiteSpace: false, + ignoreFormatting: true); + + Assert.Null(Assert.IsType(Assert.Single(left.Inlines!)).Background); + Assert.Null(Assert.IsType(Assert.Single(right.Inlines!)).Background); + } + + [Fact] + public void Compare_IgnoreFormatting_StillMarksARealDifferenceButNotTheTag() + { + // A line can hold both an ignored and a real difference - only the real one is marked. + var (left, right) = TextDiffHighlighter.Compare( + @"{\an8}Hello there", + "Hello world", + ignoreWhiteSpace: false, + ignoreFormatting: true); + + var leftRuns = left.Inlines!.Cast().ToArray(); + Assert.Equal(@"{\an8}Hello there", JoinRuns(left.Inlines)); + Assert.DoesNotContain(leftRuns, r => r.Text!.Contains(@"{\an8}") && r.IsSet(TextElement.ForegroundProperty)); + Assert.Contains(right.Inlines!.Cast(), r => r.Text == "world" && r.IsSet(TextElement.ForegroundProperty)); + } + + [Fact] + public void Compare_IgnoreFormatting_BracesWithoutATagAreStillCompared() + { + // "{...}" only opens an ASSA override block when a tag follows - spoken text in braces + // must keep counting as a difference. + var (left, right) = TextDiffHighlighter.Compare( + "Look at {this}", + "Look at {that}", + ignoreWhiteSpace: false, + ignoreFormatting: true); + + Assert.Contains(left.Inlines!.Cast(), r => r.IsSet(TextElement.ForegroundProperty)); + Assert.Contains(right.Inlines!.Cast(), r => r.IsSet(TextElement.ForegroundProperty)); + } + + [Fact] + public void Compare_IgnoreOptionsOff_KeepsMarkingWhitespaceAndTags() + { + var (left, right) = TextDiffHighlighter.Compare("Same text not", "Same text not"); + + Assert.Contains(left.Inlines!.Cast(), r => r.IsSet(TextElement.ForegroundProperty)); + Assert.Contains(right.Inlines!.Cast(), r => r.Background != null); + } + + [Fact] + public void MakePlainText_AddsOneUncoloredRunPerSide() + { + var (left, right) = TextDiffHighlighter.MakePlainText("First\r\nSecond", "Something else"); + + var leftRun = Assert.IsType(Assert.Single(left.Inlines!)); + var rightRun = Assert.IsType(Assert.Single(right.Inlines!)); + + Assert.Equal("First\nSecond", leftRun.Text); + Assert.Equal("Something else", rightRun.Text); + Assert.Null(leftRun.Background); + Assert.Null(rightRun.Background); + Assert.False(leftRun.IsSet(TextElement.ForegroundProperty)); + } + private static void AssertNoRunSplitsAWord(InlineCollection? inlines) { var isWordChar = typeof(TextDiffHighlighter).GetMethod( diff --git a/tests/UI/Features/Files/EbuSaveOptionsPersistenceTests.cs b/tests/UI/Features/Files/EbuSaveOptionsPersistenceTests.cs new file mode 100644 index 00000000000..6f96d6de30a --- /dev/null +++ b/tests/UI/Features/Files/EbuSaveOptionsPersistenceTests.cs @@ -0,0 +1,335 @@ +using System.Text; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Features.Files.Export.ExportEbuStl; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Media; + +namespace UITests.Features.Files; + +/// +/// The justification code and the layout values (margins, rows per line break, teletext box / +/// double height) are the EBU STL save options that do not fit in the 1024-character GSI header +/// stored on the subtitle. They used to be reset to hard-coded defaults every time the save +/// options dialog opened, so "Centered text" silently became "Left-justified text" on reopen +/// (reported by email against 5.2.0-beta24). They are persisted in Se.Settings now. +/// +public class EbuSaveOptionsPersistenceTests +{ + public EbuSaveOptionsPersistenceTests() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } + + private static string[] ScopePaths => new[] + { + "File.EbuSaveOptions.JustificationCode", + "File.EbuSaveOptions.MarginTop", + "File.EbuSaveOptions.MarginBottom", + "File.EbuSaveOptions.NewLineRows", + "File.EbuSaveOptions.TeletextUseBox", + "File.EbuSaveOptions.TeletextUseDoubleHeight", + "File.EbuSaveOptions.PreviewFontName", + }; + + /// + /// The dialog's OK also writes into libse's Configuration singleton (the values Ebu.Save + /// reads), which SettingsScope does not cover - snapshot and restore that side too, or a + /// margin left behind here changes what every later EBU test writes. + /// + private sealed class LibSeEbuScope : IDisposable + { + private readonly int _justification = Configuration.Settings.SubtitleSettings.EbuStlJustificationCode; + private readonly int _marginTop = Configuration.Settings.SubtitleSettings.EbuStlMarginTop; + private readonly int _marginBottom = Configuration.Settings.SubtitleSettings.EbuStlMarginBottom; + private readonly int _newLineRows = Configuration.Settings.SubtitleSettings.EbuStlNewLineRows; + private readonly bool _useBox = Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox; + private readonly bool _useDoubleHeight = Configuration.Settings.SubtitleSettings.EbuStlTeletextUseDoubleHeight; + + public void Dispose() + { + Configuration.Settings.SubtitleSettings.EbuStlJustificationCode = _justification; + Configuration.Settings.SubtitleSettings.EbuStlMarginTop = _marginTop; + Configuration.Settings.SubtitleSettings.EbuStlMarginBottom = _marginBottom; + Configuration.Settings.SubtitleSettings.EbuStlNewLineRows = _newLineRows; + Configuration.Settings.SubtitleSettings.EbuStlTeletextUseBox = _useBox; + Configuration.Settings.SubtitleSettings.EbuStlTeletextUseDoubleHeight = _useDoubleHeight; + } + } + + private static Subtitle MakeSubtitle() + { + var subtitle = new Subtitle(); + subtitle.Paragraphs.Add(new Paragraph("Hello world", 1000, 3000)); + return subtitle; + } + + private static ExportEbuStlViewModel OpenDialog(Subtitle subtitle) + { + var viewModel = new ExportEbuStlViewModel(new FileHelper(), new StubWindowService()); + viewModel.Initialize(subtitle); + Dispatcher.UIThread.RunJobs(); + return viewModel; + } + + [AvaloniaFact] + public void Justification_DefaultsToCentered() + { + using var scope = new SettingsScope(ScopePaths); + using var libSeScope = new LibSeEbuScope(); + Se.Settings.File.EbuSaveOptions.JustificationCode = new SeEbuSaveOptions().JustificationCode; + + var viewModel = OpenDialog(MakeSubtitle()); + + Assert.Equal(viewModel.Justifications[2], viewModel.SelectedJustification); // centered + } + + [AvaloniaFact] + public void Justification_SurvivesReopeningTheDialog() + { + using var scope = new SettingsScope(ScopePaths); + using var libSeScope = new LibSeEbuScope(); + var subtitle = MakeSubtitle(); + + var viewModel = OpenDialog(subtitle); + viewModel.SelectedJustification = viewModel.Justifications[3]; // right-justified + viewModel.OkCommand.Execute(null); + Dispatcher.UIThread.RunJobs(); + + var reopened = OpenDialog(subtitle); + + Assert.Equal(reopened.Justifications[3], reopened.SelectedJustification); + } + + [AvaloniaFact] + public void LayoutOptions_SurviveReopeningTheDialog() + { + using var scope = new SettingsScope(ScopePaths); + using var libSeScope = new LibSeEbuScope(); + var subtitle = MakeSubtitle(); + + var viewModel = OpenDialog(subtitle); + viewModel.SelectedTopAlignment = 1; + viewModel.SelectedBottomAlignment = 4; + viewModel.SelectedRowsAddByNewLine = 1; + viewModel.UseBox = false; + viewModel.UseDoubleHeight = false; + viewModel.OkCommand.Execute(null); + Dispatcher.UIThread.RunJobs(); + + var reopened = OpenDialog(subtitle); + + Assert.Equal(1, reopened.SelectedTopAlignment); + Assert.Equal(4, reopened.SelectedBottomAlignment); + Assert.Equal(1, reopened.SelectedRowsAddByNewLine); + Assert.False(reopened.UseBox); + Assert.False(reopened.UseDoubleHeight); + } + + // The justification travels to the writer on the UI helper, not in the stored header - a save + // that never opens the dialog (Ctrl+S on a loaded STL file) must still use the persisted pick. + [AvaloniaFact] + public void FreshSaveHelper_UsesThePersistedJustification() + { + using var scope = new SettingsScope(ScopePaths); + using var libSeScope = new LibSeEbuScope(); + Se.Settings.File.EbuSaveOptions.JustificationCode = 3; + + Assert.Equal(3, new UiEbuSaveHelper().JustificationCode); + } + + [AvaloniaFact] + public void ChosenJustification_ReachesTheSavedTtiBlock() + { + using var scope = new SettingsScope(ScopePaths); + using var libSeScope = new LibSeEbuScope(); + var subtitle = MakeSubtitle(); + + var viewModel = OpenDialog(subtitle); + viewModel.SelectedJustification = viewModel.Justifications[2]; // centered + viewModel.OkCommand.Execute(null); + Dispatcher.UIThread.RunJobs(); + + Ebu.EbuUiHelper = new UiEbuSaveHelper { JustificationCode = viewModel.JustificationCode }; + + var bytes = SaveToBytes(subtitle); + + Assert.Equal(2, bytes[1024 + 14]); // TTI justification code + } + + // Cross-restart persistence rides on the source-generated JSON context picking the new + // settings type up transitively from the root. + [Fact] + public void EbuSaveOptions_RoundTripThroughSettingsJson() + { + var settings = new Se(); + settings.File.EbuSaveOptions.JustificationCode = 3; + settings.File.EbuSaveOptions.MarginBottom = 4; + settings.File.EbuSaveOptions.TeletextUseBox = false; + + var json = System.Text.Json.JsonSerializer.Serialize(settings, SeJsonContext.Default.Se); + var back = System.Text.Json.JsonSerializer.Deserialize(json, SeJsonContext.Default.Se)!; + + Assert.Equal(3, back.File.EbuSaveOptions.JustificationCode); + Assert.Equal(4, back.File.EbuSaveOptions.MarginBottom); + Assert.False(back.File.EbuSaveOptions.TeletextUseBox); + } + + internal static byte[] SaveToBytes(Subtitle subtitle) + { + var fileName = Path.Combine(Path.GetTempPath(), "ebu-options-test-" + Guid.NewGuid() + ".stl"); + try + { + Assert.True(new Ebu().Save(fileName, subtitle)); + return File.ReadAllBytes(fileName); + } + finally + { + if (File.Exists(fileName)) + { + File.Delete(fileName); + } + } + } + + // The box and the double height are teletext control codes - Ebu.Save writes neither when the + // display standard is open subtitling, and the video preview shows neither. The two check + // boxes have to say so instead of promising a box nothing ever draws (user report on PR #14228). + [AvaloniaFact] + public void TeletextOptions_AreOnlyEnabledForTeletext() + { + using var scope = new SettingsScope(ScopePaths); + using var libSeScope = new LibSeEbuScope(); + + var viewModel = OpenDialog(MakeSubtitle()); + + Assert.Equal(viewModel.DisplayStandardCodes[0], viewModel.SelectedDisplayStandardCode); // 0 = open subtitling + Assert.False(viewModel.IsTeletext); + + viewModel.SelectedDisplayStandardCode = viewModel.DisplayStandardCodes[1]; // 1 = level-1 teletext + Assert.True(viewModel.IsTeletext); + + viewModel.SelectedDisplayStandardCode = viewModel.DisplayStandardCodes[2]; // 2 = level-2 teletext + Assert.True(viewModel.IsTeletext); + + viewModel.SelectedDisplayStandardCode = viewModel.DisplayStandardCodes[3]; // undefined + Assert.False(viewModel.IsTeletext); + } + + // The video preview reads the justification off the libse settings, next to the margins and the + // teletext flags. It used to read Ebu.EbuUiHelper, which is the carrier that takes the code to + // the writer and does not exist until a save or this dialog creates one - so the preview showed + // everything centered until then, and followed batch convert's job code after (PR #14229). + [AvaloniaFact] + public void Justification_ReachesTheLibSeSettingsForThePreview() + { + using var scope = new SettingsScope(ScopePaths); + using var libSeScope = new LibSeEbuScope(); + + var viewModel = OpenDialog(MakeSubtitle()); + viewModel.SelectedJustification = viewModel.Justifications[1]; // left-justified + viewModel.OkCommand.Execute(null); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(1, Configuration.Settings.SubtitleSettings.EbuStlJustificationCode); + } + + // A save that never opens the dialog still has to preview and write the persisted pick, so the + // settings sync has to carry it over like every other EBU STL option. + [AvaloniaFact] + public void Justification_IsMirroredIntoLibSeBySettingsSync() + { + using var scope = new SettingsScope(ScopePaths); + using var libSeScope = new LibSeEbuScope(); + Se.Settings.File.EbuSaveOptions.JustificationCode = 3; + Configuration.Settings.SubtitleSettings.EbuStlJustificationCode = 0; + + Se.UpdateLibSeSettings(); + + Assert.Equal(3, Configuration.Settings.SubtitleSettings.EbuStlJustificationCode); + } + + // Preview only - nothing about it is written to the STL file, so it lives in the persisted + // settings alone and the first list entry means "leave the video preview font alone". + [AvaloniaFact] + public void PreviewFont_DefaultsToTheVideoPreviewFont() + { + using var scope = new SettingsScope(ScopePaths); + using var libSeScope = new LibSeEbuScope(); + Se.Settings.File.EbuSaveOptions.PreviewFontName = string.Empty; + + var viewModel = OpenDialog(MakeSubtitle()); + + Assert.Equal(viewModel.PreviewFonts[0], viewModel.SelectedPreviewFont); + + viewModel.OkCommand.Execute(null); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(string.Empty, Se.Settings.File.EbuSaveOptions.PreviewFontName); + } + + [AvaloniaFact] + public void PreviewFont_SurvivesReopeningTheDialog() + { + using var scope = new SettingsScope(ScopePaths); + using var libSeScope = new LibSeEbuScope(); + var subtitle = MakeSubtitle(); + + var viewModel = OpenDialog(subtitle); + var font = viewModel.PreviewFonts.Last(); + viewModel.SelectedPreviewFont = font; + viewModel.OkCommand.Execute(null); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(font, Se.Settings.File.EbuSaveOptions.PreviewFontName); + + var reopened = OpenDialog(subtitle); + + Assert.Equal(font, reopened.SelectedPreviewFont); + } + + // A font that is no longer installed must not leave the combo box blank. + [AvaloniaFact] + public void PreviewFont_FallsBackWhenTheFontIsGone() + { + using var scope = new SettingsScope(ScopePaths); + using var libSeScope = new LibSeEbuScope(); + Se.Settings.File.EbuSaveOptions.PreviewFontName = "A font nobody has installed"; + + var viewModel = OpenDialog(MakeSubtitle()); + + Assert.Equal(viewModel.PreviewFonts[0], viewModel.SelectedPreviewFont); + } + + // The sample label next to the drop-down: the first entry is not a font family, so it has to + // show the font the preview will really use rather than a family named "(use the ...)". + [AvaloniaFact] + public void PreviewFontSample_UsesTheVideoPreviewFontForTheDefaultEntry() + { + using var scope = new SettingsScope(ScopePaths); + using var libSeScope = new LibSeEbuScope(); + Se.Settings.File.EbuSaveOptions.PreviewFontName = string.Empty; + + var viewModel = OpenDialog(MakeSubtitle()); + + Assert.Equal( + FontFamilyHelper.Make(Se.Settings.Video.MpvPreviewFontName), + viewModel.PreviewFontFamily); + } + + [AvaloniaFact] + public void PreviewFontSample_FollowsThePickedFont() + { + using var scope = new SettingsScope(ScopePaths); + using var libSeScope = new LibSeEbuScope(); + + var viewModel = OpenDialog(MakeSubtitle()); + var font = viewModel.PreviewFonts.Last(); + viewModel.SelectedPreviewFont = font; + + Assert.Equal(FontFamilyHelper.Make(font), viewModel.PreviewFontFamily); + } +} diff --git a/tests/UI/Features/Files/ExportEbuStlHeaderTests.cs b/tests/UI/Features/Files/ExportEbuStlHeaderTests.cs new file mode 100644 index 00000000000..4ba49658aec --- /dev/null +++ b/tests/UI/Features/Files/ExportEbuStlHeaderTests.cs @@ -0,0 +1,276 @@ +using System.Globalization; +using System.Text; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Features.Files.Export.ExportEbuStl; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Media; + +namespace UITests.Features.Files; + +/// +/// The EBU STL save options dialog hands its result to the writer as a 1024-character GSI header +/// stored on the subtitle. Anything that makes that header come out wrong is invisible to the user: +/// just falls back to a default header ("No Title", USA, no start of +/// programme) and still writes a perfectly good subtitle grid. Reported by email against 5.2.0, +/// where a programme title longer than the 32-character field did exactly that. +/// +public class ExportEbuStlHeaderTests +{ + public ExportEbuStlHeaderTests() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } + + private static Subtitle MakeSubtitle() + { + var subtitle = new Subtitle(); + subtitle.Paragraphs.Add(new Paragraph("Hello world", 1000, 3000)); + subtitle.Paragraphs.Add(new Paragraph("Second line", 4000, 6000)); + return subtitle; + } + + private static byte[] SaveViaDialog(Action fillIn) + { + return SaveViaDialog(MakeSubtitle(), fillIn); + } + + /// + /// Runs the options dialog the way the main window does - fill in, OK, hand the leftovers to + /// the save helper - and saves the subtitle it worked on. + /// + private static byte[] SaveViaDialog(Subtitle subtitle, Action fillIn) + { + var viewModel = new ExportEbuStlViewModel(new FileHelper(), new StubWindowService()); + viewModel.Initialize(subtitle); + Dispatcher.UIThread.RunJobs(); + + fillIn(viewModel); + viewModel.OkCommand.Execute(null); + Dispatcher.UIThread.RunJobs(); + + var helper = new UiEbuSaveHelper { JustificationCode = viewModel.JustificationCode }; + helper.SetFrameRate(viewModel.StoredHeader, viewModel.FrameRateFromSaveDialog); + Ebu.EbuUiHelper = helper; + + return Save(subtitle); + } + + private static byte[] Save(Subtitle subtitle) + { + var fileName = Path.Combine(Path.GetTempPath(), "ebu-header-test-" + Guid.NewGuid() + ".stl"); + try + { + new Ebu().Save(fileName, subtitle); + return File.ReadAllBytes(fileName); + } + finally + { + if (File.Exists(fileName)) + { + File.Delete(fileName); + } + } + } + + // TTI block 0 starts right after the 1024-byte header; the in-cue time code sits at 5..8. + private static string InCueTimeCode(byte[] bytes) + { + return $"{bytes[1024 + 5]:00}:{bytes[1024 + 6]:00}:{bytes[1024 + 7]:00}:{bytes[1024 + 8]:00}"; + } + + private static string Field(byte[] bytes, int index, int length) + { + return Ebu.GetEncoding(Encoding.ASCII.GetString(bytes, 0, 3)).GetString(bytes, index, length); + } + + [AvaloniaFact] + public void HeaderFields_ReachTheSavedFile() + { + var bytes = SaveViaDialog(vm => + { + vm.OriginalProgramTitle = "My film"; + vm.OriginalEpisodeTitle = "Episode 1"; + vm.CountryOfOrigin = "DEU"; + vm.StartOfProgramme = new TimeSpan(10, 0, 0); + }); + + Assert.Equal("My film".PadRight(32), Field(bytes, 16, 32)); + Assert.Equal("Episode 1".PadRight(32), Field(bytes, 48, 32)); + Assert.Equal("DEU", Field(bytes, 274, 3)); + Assert.Equal("10000000", Field(bytes, 256, 8)); + } + + // A title longer than the 32-character GSI field used to push the header past 1024 characters, + // and every other field the user filled in went down with it. + [AvaloniaFact] + public void OverlongTextFields_AreTruncated_RestOfHeaderSurvives() + { + var bytes = SaveViaDialog(vm => + { + vm.OriginalProgramTitle = "A very long programme title that is well past thirty-two characters"; + vm.SubtitleListReferenceCode = "reference code that is too long"; + vm.TranslatorsName = new string('x', 40); + vm.CountryOfOrigin = "DEU"; + vm.StartOfProgramme = new TimeSpan(10, 0, 0); + }); + + Assert.Equal("A very long programme title that", Field(bytes, 16, 32)); + Assert.Equal("reference code t", Field(bytes, 208, 16)); + Assert.Equal(new string('x', 32), Field(bytes, 144, 32)); + Assert.Equal("DEU", Field(bytes, 274, 3)); + Assert.Equal("10000000", Field(bytes, 256, 8)); + } + + // STL23.01 is offered by the dialog's own disk format code list, but the "is this an STL + // header" check did not know about it - so picking it dropped the whole header on save. + [AvaloniaFact] + public void NonStandardDiskFormatCode_KeepsTheHeader() + { + var bytes = SaveViaDialog(vm => + { + vm.SelectedDiskFormatCode = vm.DiskFormatCodes.First(p => p.StartsWith("STL23")); + vm.OriginalProgramTitle = "My film"; + vm.CountryOfOrigin = "DEU"; + }); + + Assert.Equal("STL23.01", Field(bytes, 3, 8)); + Assert.Equal("My film".PadRight(32), Field(bytes, 16, 32)); + Assert.Equal("DEU", Field(bytes, 274, 3)); + } + + // A country code shorter than three characters was replaced by "USA" rather than padded. + [AvaloniaFact] + public void ShortCountryCode_IsKeptNotReplacedByUsa() + { + var bytes = SaveViaDialog(vm => + { + vm.OriginalProgramTitle = "My film"; + vm.CountryOfOrigin = "D"; + }); + + Assert.Equal("D ", Field(bytes, 274, 3)); + } + + // An untouched country field still gets the long-standing default. + [AvaloniaFact] + public void EmptyCountryCode_FallsBackToUsa() + { + var bytes = SaveViaDialog(vm => { vm.OriginalProgramTitle = "My film"; }); + + Assert.Equal("USA", Field(bytes, 274, 3)); + } + + // The frame rate is the one save option that is not part of the 1024-character header, so it + // was lost when the writer re-read the header off the subtitle and every file came out with + // the rate its disk format code implies. + [AvaloniaTheory] + [InlineData("23.976", "00:00:01:23")] + [InlineData("25", "00:00:01:24")] + public void PickedFrameRate_DecidesTheTimeCodeFrames(string frameRate, string expected) + { + var subtitle = new Subtitle(); + subtitle.Paragraphs.Add(new Paragraph("Hello world", 1960, 3000)); + + var bytes = SaveViaDialog(subtitle, vm => + { + vm.SelectedFrameRate = frameRate; + vm.OriginalProgramTitle = "My film"; + }); + + Assert.Equal(expected, InCueTimeCode(bytes)); + } + + // ...but it may not leak to the next file: that one keeps the rate its own header implies. + [AvaloniaFact] + public void PickedFrameRate_DoesNotLeakToAnotherSubtitle() + { + var first = new Subtitle(); + first.Paragraphs.Add(new Paragraph("Hello world", 1960, 3000)); + SaveViaDialog(first, vm => + { + vm.SelectedFrameRate = "23.976"; + vm.OriginalProgramTitle = "My film"; + }); + + // A second, STL25 subtitle saved without opening the dialog again. + var second = new Subtitle { Header = new Ebu.EbuGeneralSubtitleInformation().ToString() }; + second.Paragraphs.Add(new Paragraph("Hello world", 1960, 3000)); + + Assert.Equal("00:00:01:24", InCueTimeCode(Save(second))); + } + + // Reopening the dialog showed the 25 fps default for every file, because the rate was read from + // a field that is never part of a header read back from bytes. + [AvaloniaFact] + public void ReopeningTheDialog_ShowsTheRateOfTheFile() + { + var subtitle = new Subtitle + { + Header = new Ebu.EbuGeneralSubtitleInformation { DiskFormatCode = "STL30.01" }.ToString(), + }; + subtitle.Paragraphs.Add(new Paragraph("Hello world", 1000, 3000)); + + var viewModel = new ExportEbuStlViewModel(new FileHelper(), new StubWindowService()); + viewModel.Initialize(subtitle); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal("30", viewModel.SelectedFrameRate); + + // And a rate that no disk format code can express is remembered for this header. + Ebu.EbuUiHelper = new UiEbuSaveHelper(); + SaveViaDialog(subtitle, vm => { vm.SelectedFrameRate = "23.976"; }); + + var reopened = new ExportEbuStlViewModel(new FileHelper(), new StubWindowService()); + reopened.Initialize(subtitle); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal("23.976", reopened.SelectedFrameRate); + } + + // An unquoted font color plus a quotation mark later in the line crashed the writer with + // "length ('-13') must be a non-negative value", so File > Export > EBU STL silently produced + // no file at all (reported by email against 5.2.0-beta24). + [AvaloniaFact] + public void UnquotedFontColor_TeletextExport_WritesTheFile() + { + var subtitle = new Subtitle(); + subtitle.Paragraphs.Add(new Paragraph("Er sagte \"Hallo\" zu mir", 1000, 3000)); + + var bytes = SaveViaDialog(subtitle, vm => + { + vm.SelectedDisplayStandardCode = vm.DisplayStandardCodes[1]; // Level-1 teletext + }); + + Assert.True(bytes.Length >= 1024 + 128); + } + + // The frame rate list is written with invariant decimal points, so it must not be read back + // with the UI culture: in a comma-decimal culture "23.976" parsed as 23976 and the pick was + // dropped on the floor. + [AvaloniaFact] + public void PickedFrameRate_SurvivesACommaDecimalCulture() + { + var culture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("da-DK"); + try + { + var subtitle = new Subtitle(); + subtitle.Paragraphs.Add(new Paragraph("Hello world", 1960, 3000)); + + var bytes = SaveViaDialog(subtitle, vm => + { + vm.SelectedFrameRate = "23.976"; + vm.OriginalProgramTitle = "My film"; + }); + + Assert.Equal("00:00:01:23", InCueTimeCode(bytes)); + } + finally + { + CultureInfo.CurrentCulture = culture; + } + } +} diff --git a/tests/UI/Features/Files/ExportImageBasedScriptResolutionTests.cs b/tests/UI/Features/Files/ExportImageBasedScriptResolutionTests.cs new file mode 100644 index 00000000000..a55a2f0847f --- /dev/null +++ b/tests/UI/Features/Files/ExportImageBasedScriptResolutionTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using Avalonia.Headless.XUnit; +using Nikse.SubtitleEdit.Features.Files.ExportImageBased; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Media; +using Nikse.SubtitleEdit.UiLogic.Export; + +namespace UITests.Features.Files; + +/// +/// The export dialog and the headless exporters (seconv, batch convert) share ExportTextTags, +/// whose "\pos" coordinates and "\bord"/"\shad" widths are in the script's own PlayResX/PlayResY +/// resolution. The dialog is handed the subtitle's header so it can scale them to the export +/// canvas like the other callers do - without it a 288-line script's {\bord2} rendered a fifth +/// of its intended width on a 1080-line canvas. +/// +public class ExportImageBasedScriptResolutionTests +{ + private const string Header288 = "[Script Info]\r\nPlayResX: 384\r\nPlayResY: 288\r\n"; + + private sealed class NullServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + private static ExportImageBasedViewModel BuildViewModel(string? subtitleHeader) + { + var vm = new ExportImageBasedViewModel( + new FileHelper(), + new FolderHelper(), + new WindowService(new NullServiceProvider())); + + var subtitles = new ObservableCollection + { + new SubtitleLineViewModel + { + Number = 1, + Text = "{\\bord2}Hello world", + StartTime = TimeSpan.FromSeconds(1), + EndTime = TimeSpan.FromSeconds(3), + }, + }; + + vm.Initialize(new ExportHandlerBluRaySup(), subtitles, null, null, subtitleHeader); + vm.SelectedResolution = vm.Resolutions.First(r => r is { Width: 1920, Height: 1080 }); + return vm; + } + + [AvaloniaFact] + public void WithScriptHeader_BordWidthIsScaledToTheExportCanvas() + { + var vm = BuildViewModel(Header288); + + // 288 line script on a 1080 line canvas: 2 * 1080/288 = 7.5 + Assert.Equal(7.5, vm.GetImageParameter(0).OutlineWidth); + } + + [AvaloniaFact] + public void WithoutHeader_TagValuesAreUsedUnscaled() + { + var vm = BuildViewModel(null); + + Assert.Equal(2.0, vm.GetImageParameter(0).OutlineWidth); + } +} diff --git a/tests/UI/Features/Files/ImageRendererTextEffectsTests.cs b/tests/UI/Features/Files/ImageRendererTextEffectsTests.cs new file mode 100644 index 00000000000..f8fa770e14a --- /dev/null +++ b/tests/UI/Features/Files/ImageRendererTextEffectsTests.cs @@ -0,0 +1,211 @@ +using System; +using System.Linq; +using Nikse.SubtitleEdit.UiLogic.Export; +using SkiaSharp; +using Xunit; + +namespace UITests.Features.Files; + +/// +/// The advanced text effects path in ImageRenderer: when ImageParameter.TextEffects is set, +/// rendering goes through the layered pipeline (gradient fills, multiple outlines, blurred +/// shadows, glow, extrude, bevel) instead of the classic fill/outline/shadow path. +/// +public class ImageRendererTextEffectsTests +{ + private static ImageParameter MakeParameter(TextEffectPreset preset) + { + return new ImageParameter + { + Text = "Hello world", + FontName = "Arial", + FontSize = 40, + FontColor = SKColors.White, + OutlineColor = SKColors.Black, + OutlineWidth = 2, + ShadowColor = SKColors.Black, + ShadowWidth = 2, + ScreenWidth = 1280, + ScreenHeight = 720, + LineSpacingPercent = 0, + ContentAlignment = ExportContentAlignment.Center, + TextEffects = TextEffectPresetFactory.Create(preset, 40, SKColors.White, SKColors.Black, SKColors.Black), + }; + } + + private static bool HasVisiblePixels(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + { + for (var x = 0; x < bitmap.Width; x++) + { + if (bitmap.GetPixel(x, y).Alpha > 0) + { + return true; + } + } + } + + return false; + } + + [Fact] + public void NonePreset_YieldsNullEffects_SoClassicPathIsUsed() + { + var effects = TextEffectPresetFactory.Create(TextEffectPreset.None, 40, SKColors.White, SKColors.Black, SKColors.Black); + Assert.Null(effects); + } + + [Theory] + [InlineData(TextEffectPreset.SoftShadow)] + [InlineData(TextEffectPreset.GradientGold)] + [InlineData(TextEffectPreset.DoubleOutline)] + [InlineData(TextEffectPreset.NeonGlow)] + [InlineData(TextEffectPreset.Extrude3D)] + [InlineData(TextEffectPreset.Chrome)] + [InlineData(TextEffectPreset.Fire)] + [InlineData(TextEffectPreset.Comic)] + [InlineData(TextEffectPreset.Retro80s)] + [InlineData(TextEffectPreset.Anaglyph3D)] + [InlineData(TextEffectPreset.Ice)] + [InlineData(TextEffectPreset.Emboss)] + [InlineData(TextEffectPreset.Hollow)] + [InlineData(TextEffectPreset.Marble)] + [InlineData(TextEffectPreset.Wood)] + [InlineData(TextEffectPreset.Lava)] + [InlineData(TextEffectPreset.BrushedSteel)] + [InlineData(TextEffectPreset.CandyCane)] + [InlineData(TextEffectPreset.Rainbow)] + [InlineData(TextEffectPreset.PolkaDots)] + public void GenerateBitmap_WithPreset_DrawsVisiblePixels(TextEffectPreset preset) + { + var ip = MakeParameter(preset); + + using var bitmap = ImageRenderer.GenerateBitmap(ip); + + Assert.True(bitmap.Width > 2, $"bitmap width was {bitmap.Width}"); + Assert.True(bitmap.Height > 2, $"bitmap height was {bitmap.Height}"); + Assert.True(HasVisiblePixels(bitmap), "expected at least one non-transparent pixel"); + } + + [Fact] + public void GenerateBitmap_WithPreset_DiffersFromClassicRendering() + { + var classic = MakeParameter(TextEffectPreset.None); + var styled = MakeParameter(TextEffectPreset.GradientGold); + + using var classicBitmap = ImageRenderer.GenerateBitmap(classic); + using var styledBitmap = ImageRenderer.GenerateBitmap(styled); + + using var classicImage = SKImage.FromBitmap(classicBitmap); + using var styledImage = SKImage.FromBitmap(styledBitmap); + using var classicPng = classicImage.Encode(SKEncodedImageFormat.Png, 100); + using var styledPng = styledImage.Encode(SKEncodedImageFormat.Png, 100); + + Assert.False(classicPng.ToArray().SequenceEqual(styledPng.ToArray())); + } + + [Fact] + public void GenerateBitmap_EmptyText_StillReturnsTinyTransparentBitmap() + { + var ip = MakeParameter(TextEffectPreset.NeonGlow); + ip.Text = " "; + + using var bitmap = ImageRenderer.GenerateBitmap(ip); + + Assert.True(bitmap.Width <= 2 && bitmap.Height <= 2, "whitespace-only text should not produce a full-size bitmap"); + } + + [Fact] + public void LetterSpacing_WidensTheRenderedText() + { + var plain = MakeParameter(TextEffectPreset.SoftShadow); + var spaced = MakeParameter(TextEffectPreset.SoftShadow); + spaced.TextEffects = TextEffectPresetFactory.Create(TextEffectPreset.SoftShadow, 40, + SKColors.White, SKColors.Black, SKColors.Black, + new TextEffectAdjustments { LetterSpacing = 20 }); + + using var plainBitmap = ImageRenderer.GenerateBitmap(plain); + using var spacedBitmap = ImageRenderer.GenerateBitmap(spaced); + + Assert.True(spacedBitmap.Width > plainBitmap.Width + 50, + $"spaced width {spacedBitmap.Width} should clearly exceed plain width {plainBitmap.Width}"); + } + + [Fact] + public void ArcBend_MakesTheRenderedTextTaller() + { + var straight = MakeParameter(TextEffectPreset.SoftShadow); + straight.Text = "A longer single line of subtitle text"; + var curved = MakeParameter(TextEffectPreset.SoftShadow); + curved.Text = straight.Text; + curved.TextEffects = TextEffectPresetFactory.Create(TextEffectPreset.SoftShadow, 40, + SKColors.White, SKColors.Black, SKColors.Black, + new TextEffectAdjustments { ArcBendPercent = 60 }); + + using var straightBitmap = ImageRenderer.GenerateBitmap(straight); + using var curvedBitmap = ImageRenderer.GenerateBitmap(curved); + + Assert.True(curvedBitmap.Height > straightBitmap.Height + 20, + $"curved height {curvedBitmap.Height} should clearly exceed straight height {straightBitmap.Height}"); + } + + [Fact] + public void Strength_ScalesEffectSizes() + { + var normal = TextEffectPresetFactory.Create(TextEffectPreset.DoubleOutline, 40, + SKColors.White, SKColors.Black, SKColors.Black, + new TextEffectAdjustments { StrengthPercent = 100 })!; + var strong = TextEffectPresetFactory.Create(TextEffectPreset.DoubleOutline, 40, + SKColors.White, SKColors.Black, SKColors.Black, + new TextEffectAdjustments { StrengthPercent = 200 })!; + + Assert.Equal(normal.Strokes[0].Width * 2, strong.Strokes[0].Width, 3); + Assert.Equal(normal.Shadows[0].Blur * 2, strong.Shadows[0].Blur, 3); + } + + [Fact] + public void StripesFill_ReusesTheCachedTileShader() + { + // An export builds a fresh TextEffects per subtitle line, so the tile shader cache + // is keyed by the tile parameters: two equal fills on different bounds must resolve + // to the same native shader instead of re-rasterizing the tile per line. + var fill1 = new TextEffectFill + { + Kind = TextEffectFillKind.Stripes, + Colors = new[] { new SKColor(200, 30, 40), SKColors.White }, + TileSize = 9f, + TileAngleDegrees = 45f, + }; + var fill2 = new TextEffectFill + { + Kind = TextEffectFillKind.Stripes, + Colors = new[] { new SKColor(200, 30, 40), SKColors.White }, + TileSize = 9f, + TileAngleDegrees = 45f, + }; + + using var paint1 = new SKPaint(); + using var paint2 = new SKPaint(); + fill1.ApplyTo(paint1, new SKRect(0, 0, 100, 40)); + fill2.ApplyTo(paint2, new SKRect(0, 0, 640, 80)); // different bounds - the tile ignores them + + Assert.NotNull(paint1.Shader); + Assert.NotNull(paint2.Shader); + Assert.Equal(paint1.Shader!.Handle, paint2.Shader!.Handle); + } + + [Fact] + public void SafetyMargin_CoversBlurredShadowAndGlow() + { + var effects = new TextEffects + { + Shadows = { new TextEffectShadow { Dx = 4, Dy = 6, Blur = 10 } }, + Glow = new TextEffectGlow { Radius = 20 }, + }; + + var margin = effects.GetSafetyMargin(); + + Assert.True(margin >= 20 * 3, $"margin {margin} does not cover the glow radius"); + } +} diff --git a/tests/UI/Features/Files/TextEffectSettingsCloseTests.cs b/tests/UI/Features/Files/TextEffectSettingsCloseTests.cs new file mode 100644 index 00000000000..f801c383325 --- /dev/null +++ b/tests/UI/Features/Files/TextEffectSettingsCloseTests.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using Avalonia.Headless.XUnit; +using Nikse.SubtitleEdit.Features.Files.ExportImageBased; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Media; +using Nikse.SubtitleEdit.UiLogic.Export; + +namespace UITests.Features.Files; + +/// +/// The text effect settings dialog pushes every slider change straight into the export dialog's +/// view model so the preview follows live. Any close that is not an OK must put the original +/// values back - the Cancel button and Escape go through Cancel(), while the title bar X and +/// Alt+F4 only raise the window's Closing event, which the window forwards to OnWindowClosing(). +/// +public class TextEffectSettingsCloseTests +{ + private sealed class NullServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + private static ExportImageBasedViewModel BuildParent() + { + var parent = new ExportImageBasedViewModel( + new FileHelper(), + new FolderHelper(), + new WindowService(new NullServiceProvider())); + + var subtitles = new ObservableCollection + { + new SubtitleLineViewModel + { + Number = 1, + Text = "Hello world", + StartTime = TimeSpan.FromSeconds(1), + EndTime = TimeSpan.FromSeconds(3), + }, + }; + + parent.Initialize(new ExportHandlerBluRaySup(), subtitles, null, null); + + parent.SelectedTextEffect = parent.TextEffectItems.First(t => t.Preset == TextEffectPreset.NeonGlow); + parent.TextEffectStrength = 120; + parent.TextEffectLetterSpacing = 5; + parent.TextEffectArcBend = 10; + parent.TextEffectWave = 15; + return parent; + } + + [AvaloniaFact] + public void CloseWithoutOk_PutsTheLivePushedValuesBack() + { + var parent = BuildParent(); + var vm = new TextEffectViewModel(); + vm.Initialize(parent); + + vm.SelectedPreset = vm.Presets.First(p => p.Preset == TextEffectPreset.Fire); + vm.Strength = 250; + vm.Wave = 90; + + // The changes reached the parent live... + Assert.Equal(TextEffectPreset.Fire, parent.SelectedTextEffect?.Preset); + Assert.Equal(250, parent.TextEffectStrength); + Assert.Equal(90, parent.TextEffectWave); + + // ...and a close without OK (title bar X, Alt+F4) takes them back out. + vm.OnWindowClosing(); + + Assert.Equal(TextEffectPreset.NeonGlow, parent.SelectedTextEffect?.Preset); + Assert.Equal(120, parent.TextEffectStrength); + Assert.Equal(5, parent.TextEffectLetterSpacing); + Assert.Equal(10, parent.TextEffectArcBend); + Assert.Equal(15, parent.TextEffectWave); + } + + [AvaloniaFact] + public void CloseAfterOk_KeepsTheValues() + { + var parent = BuildParent(); + var vm = new TextEffectViewModel(); + vm.Initialize(parent); + + vm.Strength = 250; + vm.OkCommand.Execute(null); + vm.OnWindowClosing(); + + Assert.Equal(250, parent.TextEffectStrength); + } + + /// + /// Cancel restores and then closes, and the close raises the window's Closing event too - + /// the restore must not run a second time over values set after the first one. + /// + [AvaloniaFact] + public void CancelFollowedByTheClosingEvent_RestoresOnlyOnce() + { + var parent = BuildParent(); + var vm = new TextEffectViewModel(); + vm.Initialize(parent); + + vm.Strength = 250; + vm.CancelCommand.Execute(null); + Assert.Equal(120, parent.TextEffectStrength); + + parent.TextEffectStrength = 175; + vm.OnWindowClosing(); + + Assert.Equal(175, parent.TextEffectStrength); + } +} diff --git a/tests/UI/Features/Main/ApplyFixedSubtitleRowMappingTests.cs b/tests/UI/Features/Main/ApplyFixedSubtitleRowMappingTests.cs new file mode 100644 index 00000000000..80fa4ededf5 --- /dev/null +++ b/tests/UI/Features/Main/ApplyFixedSubtitleRowMappingTests.cs @@ -0,0 +1,343 @@ +using System.Reflection; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Microsoft.Extensions.DependencyInjection; +using Nikse.SubtitleEdit; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic; + +namespace UITests.Features.Main; + +/// +/// The subtitle a dialog returns (Multiple replace, Fix common errors, Fix Netflix errors, AI +/// review) is applied onto the very grid rows it was built from, matched by paragraph id. +/// +/// Rebuilding the rows instead dropped everything a row holds beside the paragraph: the original +/// text of a translation lives on the row and nowhere else, so Multiple replace emptied the +/// original column and saving the original then wrote empty lines (#14053). Display-only original +/// rows disappeared the same way, as they are never part of the dialog's subtitle. +/// +public class ApplyFixedSubtitleRowMappingTests +{ + [AvaloniaFact] + public void ReplacedText_KeepsTheOriginalTextAndTheRowItself() + { + var (window, vm) = CreateMainViewModel(); + try + { + AddLine(vm, "Translated one", "Original one", 0, 2000); + AddLine(vm, "Translated two", "Original two", 2000, 4000); + vm.ShowColumnOriginalText = true; + var rows = vm.Subtitles.ToList(); + + var fixedSubtitle = OpenDialogWith(vm, out var rowMap); + fixedSubtitle.Paragraphs[0].Text = "Replaced one"; + ApplyFixedSubtitle(vm, fixedSubtitle, rowMap); + + Assert.Equal(new[] { "Replaced one", "Translated two" }, vm.Subtitles.Select(p => p.Text)); + Assert.Equal(new[] { "Original one", "Original two" }, vm.Subtitles.Select(p => p.OriginalText)); + + // Same row instances - nothing that hangs off a row (selection, the waveform's match by + // id, the original text) is invalidated by an edit. + Assert.Same(rows[0], vm.Subtitles[0]); + Assert.Same(rows[1], vm.Subtitles[1]); + } + finally + { + CloseWindow(window, vm); + } + } + + /// + /// Fix common errors removes empty lines and Fix Netflix errors calls RemoveEmptyLines, so a + /// result with fewer lines than the grid is an everyday case - and the lines that stay must keep + /// their original text. + /// + [AvaloniaFact] + public void DeletedLine_RemovesThatRowOnlyAndKeepsTheOtherOriginals() + { + var (window, vm) = CreateMainViewModel(); + try + { + AddLine(vm, "Translated one", "Original one", 0, 2000); + AddLine(vm, "Translated two", "Original two", 2000, 4000); + AddLine(vm, "Translated three", "Original three", 4000, 6000); + vm.ShowColumnOriginalText = true; + var rows = vm.Subtitles.ToList(); + + var fixedSubtitle = OpenDialogWith(vm, out var rowMap); + fixedSubtitle.Paragraphs.RemoveAt(1); + ApplyFixedSubtitle(vm, fixedSubtitle, rowMap); + + Assert.Equal(new[] { "Translated one", "Translated three" }, vm.Subtitles.Select(p => p.Text)); + Assert.Equal(new[] { "Original one", "Original three" }, vm.Subtitles.Select(p => p.OriginalText)); + Assert.Same(rows[0], vm.Subtitles[0]); + Assert.Same(rows[2], vm.Subtitles[1]); + + // The remaining lines are renumbered, and the saved original follows the grid. + Assert.Equal(new[] { 1, 2 }, vm.Subtitles.Select(p => p.Number)); + Assert.Equal( + new[] { "Original one", "Original three" }, + vm.GetUpdateSubtitleOriginal().Paragraphs.Select(p => p.Text)); + } + finally + { + CloseWindow(window, vm); + } + } + + [AvaloniaFact] + public void AddedLine_ComesInAsANewRow() + { + var (window, vm) = CreateMainViewModel(); + try + { + AddLine(vm, "Translated one", "Original one", 0, 2000); + AddLine(vm, "Translated two", "Original two", 4000, 6000); + vm.ShowColumnOriginalText = true; + var rows = vm.Subtitles.ToList(); + + var fixedSubtitle = OpenDialogWith(vm, out var rowMap); + fixedSubtitle.Paragraphs.Insert(1, new Paragraph("Added line", 2000, 4000)); + ApplyFixedSubtitle(vm, fixedSubtitle, rowMap); + + Assert.Equal( + new[] { "Translated one", "Added line", "Translated two" }, + vm.Subtitles.Select(p => p.Text)); + Assert.Equal( + new[] { "Original one", string.Empty, "Original two" }, + vm.Subtitles.Select(p => p.OriginalText)); + Assert.Same(rows[0], vm.Subtitles[0]); + Assert.Same(rows[1], vm.Subtitles[2]); + Assert.Equal(new[] { 1, 2, 3 }, vm.Subtitles.Select(p => p.Number)); + } + finally + { + CloseWindow(window, vm); + } + } + + /// + /// Display-only rows hold original lines with no counterpart in the working subtitle, so they + /// are not in the dialog's subtitle at all: they must survive the apply, and stay where they + /// were relative to the lines around them. + /// + [AvaloniaFact] + public void DeletedLine_KeepsTheDisplayOnlyOriginalRowsInPlace() + { + var (window, vm) = CreateMainViewModel(); + try + { + AddLine(vm, "Translated one", "Original one", 0, 2000); + AddLine(vm, "Translated two", "Original two", 2000, 4000); + var referenceRow = AddLine(vm, string.Empty, "Original with no translation", 4000, 6000); + referenceRow.IsReferenceOnly = true; + AddLine(vm, "Translated three", "Original three", 6000, 8000); + vm.ShowColumnOriginalText = true; + + var fixedSubtitle = OpenDialogWith(vm, out var rowMap); + + // Only the three working lines are handed to a dialog. + Assert.Equal(3, fixedSubtitle.Paragraphs.Count); + + fixedSubtitle.Paragraphs.RemoveAt(1); + ApplyFixedSubtitle(vm, fixedSubtitle, rowMap); + + Assert.Equal( + new[] { "Translated one", string.Empty, "Translated three" }, + vm.Subtitles.Select(p => p.Text)); + Assert.Same(referenceRow, vm.Subtitles[1]); + Assert.True(vm.Subtitles[1].IsReferenceOnly); + Assert.Equal("Original with no translation", vm.Subtitles[1].OriginalText); + + // A display-only row still takes no number. + Assert.Equal(new[] { 1, 2 }, vm.Subtitles.Where(p => !p.IsReferenceOnly).Select(p => p.Number)); + } + finally + { + CloseWindow(window, vm); + } + } + + /// + /// Multiple replace's "Apply" keeps the window open for another round, so the same mapping is + /// used again - after it has already deleted rows. A row that is gone from the grid must not be + /// resurrected by a later round. + /// + [AvaloniaFact] + public void SecondApplyRound_DoesNotBringBackARowTheFirstOneDeleted() + { + var (window, vm) = CreateMainViewModel(); + try + { + AddLine(vm, "Translated one", "Original one", 0, 2000); + AddLine(vm, "Translated two", "Original two", 2000, 4000); + vm.ShowColumnOriginalText = true; + var rows = vm.Subtitles.ToList(); + + var firstRound = OpenDialogWith(vm, out var rowMap); + firstRound.Paragraphs.RemoveAt(1); + ApplyFixedSubtitle(vm, firstRound, rowMap); + Assert.Single(vm.Subtitles); + + // The dialog carries its own subtitle over to the next round with the ids intact. + var secondRound = new Subtitle(firstRound, false); + secondRound.Paragraphs[0].Text = "Replaced one"; + ApplyFixedSubtitle(vm, secondRound, rowMap); + + Assert.Single(vm.Subtitles); + Assert.Same(rows[0], vm.Subtitles[0]); + Assert.Equal("Replaced one", vm.Subtitles[0].Text); + Assert.Equal("Original one", vm.Subtitles[0].OriginalText); + } + finally + { + CloseWindow(window, vm); + } + } + + /// + /// A rule may empty the grid - a subtitle of nothing but empty lines run through Fix common + /// errors, say - and there is then no row left to select. + /// + [AvaloniaFact] + public void EveryLineDeleted_LeavesAnEmptyGrid() + { + var (window, vm) = CreateMainViewModel(); + try + { + AddLine(vm, "Translated one", "Original one", 0, 2000); + AddLine(vm, "Translated two", "Original two", 2000, 4000); + vm.ShowColumnOriginalText = true; + + var fixedSubtitle = OpenDialogWith(vm, out var rowMap); + fixedSubtitle.Paragraphs.Clear(); + ApplyFixedSubtitle(vm, fixedSubtitle, rowMap); + + Assert.Empty(vm.Subtitles); + } + finally + { + CloseWindow(window, vm); + } + } + + /// + /// The safety net for a dialog that hands back a subtitle with fresh paragraph ids: nothing + /// matches, but as long as the line count is unchanged the rows are still updated in place + /// rather than rebuilt. + /// + [AvaloniaFact] + public void ResultWithUnknownIds_AndTheSameLineCount_StillUpdatesTheRowsInPlace() + { + var (window, vm) = CreateMainViewModel(); + try + { + AddLine(vm, "Translated one", "Original one", 0, 2000); + AddLine(vm, "Translated two", "Original two", 2000, 4000); + vm.ShowColumnOriginalText = true; + var rows = vm.Subtitles.ToList(); + + OpenDialogWith(vm, out var rowMap); + + // generateNewId defaults to true, so not one id of this copy is in the map. + var fixedSubtitle = new Subtitle(vm.GetUpdateSubtitle()); + fixedSubtitle.Paragraphs[1].Text = "Replaced two"; + ApplyFixedSubtitle(vm, fixedSubtitle, rowMap); + + Assert.Equal(new[] { "Translated one", "Replaced two" }, vm.Subtitles.Select(p => p.Text)); + Assert.Equal(new[] { "Original one", "Original two" }, vm.Subtitles.Select(p => p.OriginalText)); + Assert.Same(rows[0], vm.Subtitles[0]); + Assert.Same(rows[1], vm.Subtitles[1]); + } + finally + { + CloseWindow(window, vm); + } + } + + /// + /// What a dialog is handed and the mapping that puts its result back, exactly as the call sites + /// do it. + /// + private static Subtitle OpenDialogWith(MainViewModel vm, out IReadOnlyDictionary rowMap) + { + var method = typeof(MainViewModel).GetMethod( + "GetUpdateSubtitleWithRowMap", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("GetUpdateSubtitleWithRowMap not found"); + + var args = new object?[] { null }; + var subtitle = (Subtitle)method.Invoke(vm, args)!; + rowMap = (IReadOnlyDictionary)args[0]!; + + // The dialogs work on a copy that keeps the paragraph ids. + return new Subtitle(subtitle, false); + } + + private static void ApplyFixedSubtitle( + MainViewModel vm, Subtitle fixedSubtitle, IReadOnlyDictionary rowMap) + { + var method = typeof(MainViewModel).GetMethod( + "ApplyFixedSubtitle", + BindingFlags.Instance | BindingFlags.NonPublic, + null, + new[] + { + typeof(Subtitle), + typeof(IReadOnlyDictionary), + typeof(int), + typeof(SubtitleFormat), + }, + null) + ?? throw new InvalidOperationException("ApplyFixedSubtitle not found"); + + method.Invoke(vm, new object?[] { fixedSubtitle, rowMap, 0, null }); + Dispatcher.UIThread.RunJobs(); + } + + private static (Window Window, MainViewModel Vm) CreateMainViewModel() + { + var services = new ServiceCollection(); + services.AddSubtitleEditServices(); + Locator.Services = services.BuildServiceProvider(); + + var window = new Window { Width = 1200, Height = 800 }; + MainView.NextHostWindow = window; + var view = new MainView(); + window.Content = view; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + return (window, (MainViewModel)view.DataContext!); + } + + private static SubtitleLineViewModel AddLine( + MainViewModel vm, string text, string originalText, int startMs, int endMs) + { + var line = new SubtitleLineViewModel(new Paragraph(text, startMs, endMs), null!) + { + OriginalText = originalText, + Number = vm.Subtitles.Count + 1, + }; + + vm.Subtitles.Add(line); + return line; + } + + private static void CloseWindow(Window window, MainViewModel vm) + { + foreach (var ownedWindow in window.OwnedWindows.ToArray()) + { + ownedWindow.Close(); + } + + window.Closing -= vm.OnClosing; + if (window.IsVisible) + { + window.Close(); + } + } +} diff --git a/tests/UI/Features/Main/ApplySettingsKeepsSelectionTests.cs b/tests/UI/Features/Main/ApplySettingsKeepsSelectionTests.cs new file mode 100644 index 00000000000..cc468960b75 --- /dev/null +++ b/tests/UI/Features/Main/ApplySettingsKeepsSelectionTests.cs @@ -0,0 +1,117 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Microsoft.Extensions.DependencyInjection; +using Nikse.SubtitleEdit; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic; + +namespace UITests.Features.Main; + +/// +/// Applying settings rebuilds the layout - and with it the subtitle grid - and refreshes the +/// subtitle format list. The fresh grid silently auto-selects row 0, and every format-combo +/// SelectionChanged raised by the list refresh read that row 0 back and scrolled to it, so the +/// row the user was editing was lost on Apply and on OK after Apply (issue #14421). +/// +public class ApplySettingsKeepsSelectionTests : IDisposable +{ + private readonly List _windows = new(); + + public void Dispose() + { + foreach (var window in _windows) + { + window.Close(); + } + + _windows.Clear(); + } + + [AvaloniaFact] + public async Task ApplySettings_KeepsTheSelectedRow() + { + var (window, vm) = ShowMainWindowWithLines(300); + + vm.SelectAndScrollToSubtitle(vm.Subtitles[100]); + await SettleAsync(window); + Assert.Equal("Line 101", vm.SelectedSubtitle?.Text); + Assert.Equal(100, vm.SubtitleGrid.SelectedIndex); + + vm.ApplySettings(); + await SettleAsync(window); + await SettleAsync(window); + + Assert.Equal("Line 101", vm.SelectedSubtitle?.Text); + Assert.Equal(100, vm.SubtitleGrid.SelectedIndex); + Assert.Equal("Line 101", (vm.SubtitleGrid.SelectedItem as SubtitleLineViewModel)?.Text); + } + + [AvaloniaFact] + public async Task ApplySettings_Twice_KeepsTheSelectedRow() + { + // Apply followed by OK with a further change runs the apply path twice in a row. + var (window, vm) = ShowMainWindowWithLines(300); + + vm.SelectAndScrollToSubtitle(vm.Subtitles[42]); + await SettleAsync(window); + + vm.ApplySettings(); + await SettleAsync(window); + vm.ApplySettings(); + await SettleAsync(window); + await SettleAsync(window); + + Assert.Equal("Line 43", vm.SelectedSubtitle?.Text); + Assert.Equal(42, vm.SubtitleGrid.SelectedIndex); + } + + private (Window Window, MainViewModel Vm) ShowMainWindowWithLines(int lineCount) + { + var services = new ServiceCollection(); + services.AddSubtitleEditServices(); + Locator.Services = services.BuildServiceProvider(); + + var window = new Window { Width = 1400, Height = 900 }; + _windows.Add(window); + MainView.NextHostWindow = window; + var view = new MainView(); + window.Content = view; + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + var vm = (MainViewModel)view.DataContext!; + window.SuppressSaveChangesPromptOnClose(vm); + vm.Menu.IsVisible = true; + + for (var i = 0; i < lineCount; i++) + { + vm.Subtitles.Add(new SubtitleLineViewModel(new Paragraph($"Line {i + 1}", i * 2000, i * 2000 + 1500), null!) + { + Number = i + 1, + }); + } + + Settle(window); + return (window, vm); + } + + private static void Settle(Window window) + { + for (var pump = 0; pump < 8; pump++) + { + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + } + } + + // The scroll/selection restore is posted, so give the dispatcher a real tick as well. + private static async Task SettleAsync(Window window) + { + Settle(window); + await Task.Delay(50); + Settle(window); + } +} diff --git a/tests/UI/Features/Main/AutoCastApplyTests.cs b/tests/UI/Features/Main/AutoCastApplyTests.cs new file mode 100644 index 00000000000..29340638202 --- /dev/null +++ b/tests/UI/Features/Main/AutoCastApplyTests.cs @@ -0,0 +1,149 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Microsoft.Extensions.DependencyInjection; +using Nikse.SubtitleEdit; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.AutoCast; +using Nikse.SubtitleEdit.Logic; + +namespace UITests.Features.Main; + +/// +/// "Find voices in video and clone" ends by putting the detected speakers into the subtitle. +/// These tests drive exactly the way +/// ShowVideoAutoCastFromVideo does - labels already moved into the actor field by +/// - and check the three promises the flow +/// makes: the lines get their actors, the format becomes ASSA (the format that keeps them), and +/// the actor column becomes visible. +/// +public class AutoCastApplyTests : IDisposable +{ + private readonly List _windows = new(); + private readonly string _tempDirectory; + + public AutoCastApplyTests() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), "SubtitleEdit.UITests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDirectory); + } + + public void Dispose() + { + foreach (var window in _windows) + { + window.Close(); + } + + _windows.Clear(); + + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + } + + private (Window Window, MainViewModel Vm) ShowEmptyMainWindow() + { + var services = new ServiceCollection(); + services.AddSubtitleEditServices(); + Locator.Services = services.BuildServiceProvider(); + + var window = new Window { Width = 1400, Height = 900 }; + _windows.Add(window); + MainView.NextHostWindow = window; + var view = new MainView(); + window.Content = view; + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + var vm = (MainViewModel)view.DataContext!; + window.SuppressSaveChangesPromptOnClose(vm); + return (window, vm); + } + + private static void Settle(Window window) + { + for (var pump = 0; pump < 5; pump++) + { + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + } + } + + private string WriteSrt(string name) + { + var fileName = Path.Combine(_tempDirectory, name); + File.WriteAllText(fileName, + "1" + Environment.NewLine + + "00:00:01,000 --> 00:00:02,000" + Environment.NewLine + + "Hello there." + Environment.NewLine + + Environment.NewLine + + "2" + Environment.NewLine + + "00:00:03,000 --> 00:00:04,000" + Environment.NewLine + + "Hi yourself." + Environment.NewLine); + return fileName; + } + + /// + /// A diarized transcription the way the flow holds it right before applying: the engine's + /// "(Speaker N)" labels have already been moved out of the text and into the actor field. + /// + private static Subtitle MakeDiarizedTranscription() + { + var transcription = new Subtitle(); + transcription.Paragraphs.Add(new Paragraph("(Speaker 1) Hello there.", 900, 2100)); + transcription.Paragraphs.Add(new Paragraph("(Speaker 2) Hi yourself.", 2900, 4100)); + SpeakerLabelParser.MoveLabelsToActors(transcription); + return transcription; + } + + private static readonly Dictionary RenamedSpeakers = new() + { + { "Speaker 1", "Alice" }, + { "Speaker 2", "Bob" }, + }; + + [AvaloniaFact] + public async Task OpenSubtitleGainsActorsAssaFormatAndActorColumn() + { + using var _ = new SettingsScope("General.ShowColumnActor"); + Nikse.SubtitleEdit.Logic.Config.Se.Settings.General.ShowColumnActor = false; + + var (window, vm) = ShowEmptyMainWindow(); + await vm.SubtitleOpen(WriteSrt("autocast-open.srt"), skipLoadVideo: true); + Settle(window); + Assert.False(vm.SelectedSubtitleFormat is AdvancedSubStationAlpha); + + vm.ApplyAutoCastToSubtitle(MakeDiarizedTranscription(), RenamedSpeakers); + Settle(window); + + Assert.IsType(vm.SelectedSubtitleFormat); + Assert.True(vm.ShowColumnActor); + Assert.Equal(2, vm.Subtitles.Count); + Assert.Equal("Alice", vm.Subtitles[0].Actor); + Assert.Equal("Bob", vm.Subtitles[1].Actor); + } + + [AvaloniaFact] + public void TranscriptionBecomesSubtitleWithActorsWhenNothingIsOpen() + { + using var _ = new SettingsScope("General.ShowColumnActor"); + Nikse.SubtitleEdit.Logic.Config.Se.Settings.General.ShowColumnActor = false; + + var (window, vm) = ShowEmptyMainWindow(); + + vm.ApplyAutoCastToSubtitle(MakeDiarizedTranscription(), RenamedSpeakers); + Settle(window); + + Assert.IsType(vm.SelectedSubtitleFormat); + Assert.True(vm.ShowColumnActor); + Assert.Equal(2, vm.Subtitles.Count); + Assert.Equal("Alice", vm.Subtitles[0].Actor); + Assert.Equal("Bob", vm.Subtitles[1].Actor); + Assert.Equal("Hello there.", vm.Subtitles[0].Text); + } +} diff --git a/tests/UI/Features/Main/FormatPropertiesToolbarButtonTests.cs b/tests/UI/Features/Main/FormatPropertiesToolbarButtonTests.cs new file mode 100644 index 00000000000..892f9d9f559 --- /dev/null +++ b/tests/UI/Features/Main/FormatPropertiesToolbarButtonTests.cs @@ -0,0 +1,74 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Microsoft.Extensions.DependencyInjection; +using Nikse.SubtitleEdit; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Features.Main.Layout; +using Nikse.SubtitleEdit.Logic; + +namespace UITests.Features.Main; + +/// +/// The toolbar's format-properties button (the gear next to the format selector) mirrors the +/// File menu's "<format> properties..." item: it must appear exactly for the formats with a +/// format-specific properties/options dialog and follow the format selector as it changes. +/// +public class FormatPropertiesToolbarButtonTests +{ + [AvaloniaFact] + public void FormatPropertiesButton_FollowsSelectedFormat() + { + var services = new ServiceCollection(); + services.AddSubtitleEditServices(); + Locator.Services = services.BuildServiceProvider(); + + var window = new Window { Width = 1400, Height = 800 }; + MainView.NextHostWindow = window; + var view = new MainView(); + window.Content = view; + window.Show(); + Dispatcher.UIThread.RunJobs(); + var vm = (MainViewModel)view.DataContext!; + + try + { + InitLayout.MakeLayout(vm.MainView!, vm, 0); + Dispatcher.UIThread.RunJobs(); + + var button = window.GetVisualDescendants() + .OfType