From 91c1e6b6b627aee99a07bcf799d604ee7b616373 Mon Sep 17 00:00:00 2001 From: Muaz Date: Mon, 13 Jul 2026 05:14:57 +0300 Subject: [PATCH 001/380] Register Linux test build workflow --- .github/workflows/release-linux-test.yml | 83 ++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 .github/workflows/release-linux-test.yml diff --git a/.github/workflows/release-linux-test.yml b/.github/workflows/release-linux-test.yml new file mode 100644 index 00000000000..57d9b1064f5 --- /dev/null +++ b/.github/workflows/release-linux-test.yml @@ -0,0 +1,83 @@ +name: Release Linux test build + +on: + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag (blank = auto timestamp, e.g. linux-test-YYYYMMDD-HHMMSS)' + required: false + default: '' + type: string + +jobs: + build-linux: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Extract version from Se.cs + shell: pwsh + run: | + $seContent = Get-Content "src/ui/Logic/Config/Se.cs" -Raw + $m = [regex]::Match($seContent, 'public\s+static\s+string\s+Version\s*\{[^}]+\}\s*=\s*"v([^"]+)"') + if (-not $m.Success) { throw "Could not find Version in Se.cs" } + $versionString = $m.Groups[1].Value + $parts = $versionString -split '-', 2 + $numericFields = $parts[0].Split('.') + $major = [int]$numericFields[0] + $minor = if ($numericFields.Length -gt 1) { [int]$numericFields[1] } else { 0 } + $build = if ($numericFields.Length -gt 2) { [int]$numericFields[2] } else { 0 } + $revision = 0 + if ($parts.Length -gt 1) { + $rm = [regex]::Match($parts[1], '\d+$') + if ($rm.Success) { $revision = [int]$rm.Value } + } + "APP_VER_DISPLAY=$versionString" >> $env:GITHUB_ENV + "APP_VER_FULL=$major.$minor.$build.$revision" >> $env:GITHUB_ENV + + - name: Restore dependencies + run: dotnet restore + + - name: Publish Linux x64 app + run: | + dotnet publish src/ui/UI.csproj -c Release -r linux-x64 --self-contained true \ + -p:PublishSingleFile=true \ + -p:DebugSymbols=false \ + -p:DebugType=none \ + -p:Version=$APP_VER_FULL \ + -p:FileVersion=$APP_VER_FULL \ + -p:InformationalVersion=$APP_VER_DISPLAY \ + -o ./publish/linux-x64 + rm -f ./publish/linux-x64/*.pdb + chmod +x ./publish/linux-x64/SubtitleEdit || true + + - name: Tar Linux x64 build + run: tar -C ./publish/linux-x64 -czf ./SubtitleEdit-Linux-x64.tar.gz . + + - name: Generate release tag + id: tag + shell: bash + run: | + if [ -n "${{ inputs.release_tag }}" ]; then + echo "tag=$(echo '${{ inputs.release_tag }}' | xargs)" >> $GITHUB_OUTPUT + else + echo "tag=linux-test-$(date +'%Y%m%d-%H%M%S')" >> $GITHUB_OUTPUT + fi + + - name: Create pre-release with Linux tarball + uses: softprops/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.tag.outputs.tag }} + name: "Linux test build - ${{ steps.tag.outputs.tag }}" + prerelease: true + files: ./SubtitleEdit-Linux-x64.tar.gz From bda09a8384f720d5995e056aef8b27a2a257949c Mon Sep 17 00:00:00 2001 From: Muaz Date: Thu, 16 Jul 2026 23:00:35 +0300 Subject: [PATCH 002/380] Keep the macOS and Windows test build workflows on main They only existed on the test-build/fixes-plus-features branch, which is 362 commits behind upstream, so they were one branch deletion away from being lost and drifted from mainstream. main already carried the Linux one, so all three now live together and build against current upstream. Also drop three em dashes from the macOS release notes. --- .github/workflows/release-macos-test.yml | 295 +++++++++++++++++++++ .github/workflows/release-windows-test.yml | 89 +++++++ 2 files changed, 384 insertions(+) create mode 100644 .github/workflows/release-macos-test.yml create mode 100644 .github/workflows/release-windows-test.yml diff --git a/.github/workflows/release-macos-test.yml b/.github/workflows/release-macos-test.yml new file mode 100644 index 00000000000..b74f2185ca2 --- /dev/null +++ b/.github/workflows/release-macos-test.yml @@ -0,0 +1,295 @@ +name: Release macOS test build + +# Builds and publishes UNSIGNED macOS test DMGs (Apple Silicon + Intel) from the +# current branch and attaches them to a GitHub pre-release on this fork, so they +# can be handed to Mac testers (e.g. to try the MLX Whisper Mac GPU engine). +# +# This is a macOS-only, fork-friendly slimmed copy of build-ui.yml's macOS path: +# the release is gated ONLY on the Mac build (not on Windows/Linux/Flatpak), and +# code signing/notarization are skipped (no certificate on a fork). + +on: + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag (blank = auto timestamp, e.g. macos-test-YYYYMMDD-HHMMSS)' + required: false + default: '' + type: string + +env: + IINA_VERSION: v1.4.2 + IINA_SHA256: 2e0fd89fbba1c92a6c115171e5b51904883bb497fbe513a6961d080fbab08ff6 + +jobs: + build-macos: + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + include: + - arch: arm64 + arch_label: ARM64 + runtime: osx-arm64 + ffmpeg_url: https://www.osxexperts.net/ffmpeg81arm.zip + ffmpeg_sha256: ebb82529562b71170807bbc6b0e7eb4f0b13af8cbb0e085bb9e8f6fe709598ad + dmg_format: UDBZ + expected_arch: arm64 + - arch: x64 + arch_label: x64 + runtime: osx-x64 + ffmpeg_url: https://www.osxexperts.net/ffmpeg80intel.zip + ffmpeg_sha256: 2d24d22db78c87f394a5822867acd5c5dc5e762cd261a44bd26923f3a5af3e07 + dmg_format: UDZO + expected_arch: x86_64 + steps: + - uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Extract version from Se.cs + run: | + set -e + line=$(grep -E 'public[[:space:]]+static[[:space:]]+string[[:space:]]+Version[[:space:]]*\{' src/ui/Logic/Config/Se.cs) + version_string=$(printf '%s' "$line" | sed -E 's/.*"v([^"]+)".*/\1/') + numeric_part="${version_string%%-*}" + suffix="" + case "$version_string" in + *-*) suffix="${version_string#*-}" ;; + esac + dot_count=$(printf '%s' "$numeric_part" | tr -cd '.' | wc -c | tr -d ' ') + case "$dot_count" in + 0) numeric_part="${numeric_part}.0.0" ;; + 1) numeric_part="${numeric_part}.0" ;; + esac + revision=$(printf '%s' "$suffix" | grep -oE '[0-9]+$' || true) + revision=${revision:-0} + app_ver_full="${numeric_part}.${revision}" + echo "APP_VER_DISPLAY=$version_string" >> "$GITHUB_ENV" + echo "APP_VER_FULL=$app_ver_full" >> "$GITHUB_ENV" + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Download + verify static ffmpeg (${{ matrix.arch }}) + run: | + set -e + curl -fL -o ffmpeg.zip "${{ matrix.ffmpeg_url }}" + echo "${{ matrix.ffmpeg_sha256 }} ffmpeg.zip" | shasum -a 256 -c - + mkdir -p ffmpeg-temp + unzip -o ffmpeg.zip -d ffmpeg-temp + rm ffmpeg.zip + if [ ! -f ffmpeg-temp/ffmpeg ]; then + found=$(find ffmpeg-temp -maxdepth 3 -type f -name ffmpeg | head -1) + [ -n "$found" ] && mv "$found" ffmpeg-temp/ffmpeg + fi + chmod +x ffmpeg-temp/ffmpeg + archs=$(lipo -archs ffmpeg-temp/ffmpeg) + if [ "$archs" != "${{ matrix.expected_arch }}" ]; then + echo "ERROR: ffmpeg binary archs are '$archs', expected exactly '${{ matrix.expected_arch }}'" + exit 1 + fi + + - name: Download + verify + extract libmpv from IINA + run: | + set -e + curl -fL -o iina.dmg "https://github.com/iina/iina/releases/download/${IINA_VERSION}/IINA.${IINA_VERSION}.dmg" + echo "${IINA_SHA256} iina.dmg" | shasum -a 256 -c - + hdiutil attach iina.dmg -nobrowse -readonly -mountpoint ./iina-mnt + mkdir -p libmpv-temp + for dylib in ./iina-mnt/IINA.app/Contents/Frameworks/*.dylib; do + name=$(basename "$dylib") + case "$name" in + libswift_*) continue ;; + esac + cp -L "$dylib" "./libmpv-temp/$name" + done + hdiutil detach ./iina-mnt + rm -f iina.dmg + [ -f libmpv-temp/libmpv.2.dylib ] || { + echo "ERROR: libmpv.2.dylib not extracted from IINA - did the bundle layout change?"; exit 1; + } + + - name: Publish macOS app (${{ matrix.arch }}) + run: | + set -e + dotnet publish src/ui/UI.csproj -c Release -r ${{ matrix.runtime }} --self-contained true \ + -p:PublishSingleFile=true \ + -p:DebugSymbols=false \ + -p:DebugType=none \ + -p:Version="$APP_VER_FULL" \ + -p:FileVersion="$APP_VER_FULL" \ + -p:InformationalVersion="$APP_VER_DISPLAY" \ + -o ./publish/macos-${{ matrix.arch }} + find ./publish/macos-${{ matrix.arch }} -name "*.pdb" -delete + archs=$(lipo -archs ./publish/macos-${{ matrix.arch }}/SubtitleEdit) + if [ "$archs" != "${{ matrix.expected_arch }}" ]; then + echo "ERROR: SubtitleEdit binary archs are '$archs', expected exactly '${{ matrix.expected_arch }}'" + exit 1 + fi + + - name: Update Info.plist with version from Se.cs + run: | + chmod +x ./installer/macBundle/update-plist-version.sh + ./installer/macBundle/update-plist-version.sh + + - name: Assemble and package unsigned DMG (${{ matrix.arch }}) + run: | + set -e + app="./SubtitleEdit-${{ matrix.arch_label }}.app" + cp -R "./installer/macBundle/SubtitleEdit.app" "$app" + + cp "./publish/macos-${{ matrix.arch }}/SubtitleEdit" "$app/Contents/MacOS/" + cp "./publish/macos-${{ matrix.arch }}/"*.dylib "$app/Contents/MacOS/" 2>/dev/null || true + chmod +x "$app/Contents/MacOS/SubtitleEdit" + rm -rf "./publish/macos-${{ matrix.arch }}" + + cp "./ffmpeg-temp/ffmpeg" "$app/Contents/MacOS/" + chmod +x "$app/Contents/MacOS/ffmpeg" + rm -rf "./ffmpeg-temp" + + mkdir -p "$app/Contents/Frameworks" + cp "./libmpv-temp/"*.dylib "$app/Contents/Frameworks/" + chmod -R 755 "$app/Contents/Frameworks/" + rm -rf "./libmpv-temp" + + executable="$app/Contents/MacOS/SubtitleEdit" + if ! otool -l "$executable" | grep -q "@executable_path/../Frameworks"; then + install_name_tool -add_rpath "@executable_path/../Frameworks" "$executable" || true + fi + + # No Apple Developer ID here, so ad-hoc sign (sign -) instead of removing the + # signature. On Apple Silicon an app with NO signature is killed by the kernel and + # won't run at all; an ad-hoc signature makes it launchable once the user clears the + # download quarantine (Gatekeeper). Sign inside-out: nested dylibs + bundled ffmpeg + # first, then the app bundle. No --options runtime, so .NET's JIT works without + # hardened-runtime entitlements. This does NOT bypass Gatekeeper - the user still + # allows it once via the bundled fix-unsigned-app.sh / `xattr -cr` / Open Anyway. + for f in "$app/Contents/Frameworks/"*.dylib "$app/Contents/MacOS/"*.dylib "$app/Contents/MacOS/ffmpeg"; do + [ -f "$f" ] || continue + codesign --force --sign - "$f" 2>/dev/null || true + done + codesign --force --sign - "$app/Contents/MacOS/SubtitleEdit" 2>/dev/null || true + codesign --force --sign - "$app" 2>/dev/null || true + + dmg_dir="./dmg-temp-${{ matrix.arch }}" + dmg_out="./SubtitleEdit-macOS-${{ matrix.arch_label }}.dmg" + mkdir -p "$dmg_dir" + cp -R "$app" "$dmg_dir/Subtitle Edit.app" + rm -rf "$app" + + cp "./LICENSE" "$dmg_dir/LICENSE" + cp "./installer/macBundle/THIRD_PARTY_LICENSES.txt" "$dmg_dir/THIRD_PARTY_LICENSES.txt" + cp "./installer/macBundle/README-macOS.txt" "$dmg_dir/README-macOS.txt" + cp "./installer/macBundle/fix-unsigned-app.sh" "$dmg_dir/fix-unsigned-app.sh" + chmod +x "$dmg_dir/fix-unsigned-app.sh" + ln -s /Applications "$dmg_dir/Applications" + + sync + attempts=0 + until hdiutil create -volname "SubtitleEdit ${{ matrix.arch_label }}" -srcfolder "$dmg_dir" -ov -format ${{ matrix.dmg_format }} "$dmg_out"; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 3 ]; then + echo "::error::hdiutil create failed after $attempts attempts" + exit 1 + fi + echo "hdiutil create failed (attempt $attempts), retrying in 5s..." + sleep 5 + done + rm -rf "$dmg_dir" + + - name: Upload macOS DMG artifact + uses: actions/upload-artifact@v5 + with: + name: macos-dmg-${{ matrix.arch }} + path: ./SubtitleEdit-macOS-${{ matrix.arch_label }}.dmg + overwrite: true + + create-release: + needs: [build-macos] + # Publish whatever arch builds succeeded. The Intel/x64 leg depends on an external static + # ffmpeg whose architecture occasionally changes upstream and breaks that leg; don't let that + # block the (primary) Apple Silicon DMG from being released. + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + + - name: Generate release tag + id: tag + run: | + if [ -n "${{ inputs.release_tag }}" ]; then + echo "tag=$(echo '${{ inputs.release_tag }}' | xargs)" >> $GITHUB_OUTPUT + else + echo "tag=macos-test-$(date +'%Y%m%d-%H%M%S')" >> $GITHUB_OUTPUT + fi + + - name: Download macOS artifacts + uses: actions/download-artifact@v5 + with: + path: ./artifacts + continue-on-error: true + + - name: Stage release assets + run: | + mkdir -p ./release-assets + # Copy whichever DMGs were produced; a failed arch leg simply has no artifact. + cp ./artifacts/macos-dmg-arm64/*.dmg ./release-assets/ 2>/dev/null || true + cp ./artifacts/macos-dmg-x64/*.dmg ./release-assets/ 2>/dev/null || true + ls -la ./release-assets/ + if [ -z "$(ls -A ./release-assets/ 2>/dev/null)" ]; then + echo "::error::No DMG artifacts were produced by any arch build." + exit 1 + fi + + - name: Create pre-release with macOS DMGs + uses: softprops/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.tag.outputs.tag }} + name: "macOS test build - ${{ steps.tag.outputs.tag }}" + prerelease: true + draft: false + body: | + **Unsigned macOS test build** with the **MLX Whisper Mac** (Apple GPU) speech-to-text engine. + + ### Downloads + - **Apple Silicon (M1/M2/M3/M4/M5):** `SubtitleEdit-macOS-ARM64.dmg` + - **Intel:** `SubtitleEdit-macOS-x64.dmg` + + ### Install (the app is unsigned, required Terminal steps) + This build has no paid Apple Developer signature, so macOS will otherwise show + *"Subtitle Edit is damaged"* or kill it on launch (especially on Apple Silicon). This is + expected, and it is not a defect in the build. After dragging **Subtitle Edit** to your + Applications folder, run these three commands in **Terminal**: + + ```bash + codesign --force --deep --sign - "/Applications/Subtitle Edit.app" + xattr -cr "/Applications/Subtitle Edit.app" + open "/Applications/Subtitle Edit.app" + ``` + + - The first line re-signs the app with a local ad-hoc signature so Apple Silicon will run it. + - The second clears the download quarantine. + - The third launches it. After this it opens normally every time. + + If `codesign` reports a permission error, prefix it with `sudo`. See `README-macOS.txt` in the DMG for more. + + ### Trying the MLX Whisper Mac engine (Apple GPU) + 1. Install the backend once: `pip3 install mlx-whisper` + 2. In Subtitle Edit: **Video → Audio to text (Whisper)**, pick **MLX Whisper Mac**, choose model **large-v3-turbo** and transcribe. + 3. The model downloads automatically on first use. Inference runs on the Apple GPU / Neural Engine via MLX, which is much faster than CPU on M-series Macs. + + This is a community **test build** from a fork, not an official Subtitle Edit release. + files: | + ./release-assets/SubtitleEdit-macOS-ARM64.dmg + ./release-assets/SubtitleEdit-macOS-x64.dmg diff --git a/.github/workflows/release-windows-test.yml b/.github/workflows/release-windows-test.yml new file mode 100644 index 00000000000..25142038cf5 --- /dev/null +++ b/.github/workflows/release-windows-test.yml @@ -0,0 +1,89 @@ +name: Release Windows test build + +on: + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag (blank = auto timestamp, e.g. win-test-YYYYMMDD-HHMMSS)' + required: false + default: '' + type: string + +jobs: + build-windows: + runs-on: windows-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Extract version from Se.cs + shell: pwsh + run: | + $seContent = Get-Content "src/ui/Logic/Config/Se.cs" -Raw + $m = [regex]::Match($seContent, 'public\s+static\s+string\s+Version\s*\{[^}]+\}\s*=\s*"v([^"]+)"') + if (-not $m.Success) { throw "Could not find Version in Se.cs" } + $versionString = $m.Groups[1].Value + $parts = $versionString -split '-', 2 + $numericFields = $parts[0].Split('.') + $major = [int]$numericFields[0] + $minor = if ($numericFields.Length -gt 1) { [int]$numericFields[1] } else { 0 } + $build = if ($numericFields.Length -gt 2) { [int]$numericFields[2] } else { 0 } + $revision = 0 + if ($parts.Length -gt 1) { + $rm = [regex]::Match($parts[1], '\d+$') + if ($rm.Success) { $revision = [int]$rm.Value } + } + "APP_VER_DISPLAY=$versionString" >> $env:GITHUB_ENV + "APP_VER_FULL=$major.$minor.$build.$revision" >> $env:GITHUB_ENV + + - name: Restore dependencies + run: dotnet restore + + - name: Download libmpv for Windows + run: | + Invoke-WebRequest -Uri "https://github.com/SubtitleEdit/support-files/releases/download/libmpv-2026-04-21/libmpv2-win64.zip" -OutFile "libmpv2-64.zip" + Expand-Archive -Path "libmpv2-64.zip" -DestinationPath "libmpv-temp" + + - name: Publish Windows x64 app + run: | + dotnet publish src/ui/UI.csproj -c Release -r win-x64 --self-contained true ` + -p:PublishSingleFile=true ` + -p:DebugSymbols=false ` + -p:DebugType=none ` + -p:Version=$env:APP_VER_FULL ` + -p:FileVersion=$env:APP_VER_FULL ` + -p:InformationalVersion=$env:APP_VER_DISPLAY ` + -o ./publish/windows-x64 + Copy-Item "libmpv-temp/libmpv-2.dll" "./publish/windows-x64/" + Get-ChildItem -Path "./publish/windows-x64" -Filter "*.pdb" -Recurse -File | Remove-Item -Force + + - name: Zip Windows x64 build + shell: pwsh + run: Compress-Archive -Path ./publish/windows-x64/* -DestinationPath ./SubtitleEdit-Windows-x64.zip + + - name: Generate release tag + id: tag + shell: bash + run: | + if [ -n "${{ inputs.release_tag }}" ]; then + echo "tag=$(echo '${{ inputs.release_tag }}' | xargs)" >> $GITHUB_OUTPUT + else + echo "tag=win-test-$(date +'%Y%m%d-%H%M%S')" >> $GITHUB_OUTPUT + fi + + - name: Create pre-release with Windows zip + uses: softprops/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.tag.outputs.tag }} + name: "Windows test build - ${{ steps.tag.outputs.tag }}" + prerelease: true + files: ./SubtitleEdit-Windows-x64.zip From cd945fd8f3d7698797c69630ef6fb8128c468415 Mon Sep 17 00:00:00 2001 From: Muaz Date: Wed, 19 Aug 2026 17:37:03 +0300 Subject: [PATCH 003/380] Keep the MLX Whisper speech-to-text engine on the Mac fork Upstream removed this in 21bf99975 (#12969) because it is not downloadable in-app and the pip3 / PEP 668 install path confuses non-technical users. On Apple Silicon it is fast and reliable once installed, so this restores it as a fork-only addition while that install-path question is discussed upstream in issue #12966. --- src/libuilogic/AudioToText/WhisperChoice.cs | 1 + src/ui/Assets/Languages/Dutch.json | 8 + src/ui/Assets/Languages/English.json | 8 + src/ui/Assets/Languages/German.json | 8 + src/ui/Assets/Languages/Italian.json | 8 + src/ui/Assets/Languages/Norwegian.json | 8 + .../Assets/Languages/Portuguese (Brazil).json | 8 + src/ui/Assets/Languages/Spanish.json | 8 + src/ui/Assets/SpeechToText/MLXWhisper.txt | 59 ++ .../SpeechToText/mlx_whisper_transcribe.py | 978 ++++++++++++++++++ .../SpeechToText/Engines/MlxWhisperMac.cs | 425 ++++++++ .../Engines/WhisperEngineFactory.cs | 5 + .../SpeechToTextPostProcessingViewModel.cs | 6 + .../SpeechToTextPostProcessingWindow.cs | 56 + .../SpeechToText/SpeechToTextViewModel.cs | 155 ++- .../Video/SpeechToText/SpeechToTextWindow.cs | 7 + .../Language/Video/LanguageAudioToText.cs | 16 + src/ui/Logic/Config/SeAudioToText.cs | 10 + 18 files changed, 1770 insertions(+), 4 deletions(-) create mode 100644 src/ui/Assets/SpeechToText/MLXWhisper.txt create mode 100644 src/ui/Assets/SpeechToText/mlx_whisper_transcribe.py create mode 100644 src/ui/Features/Video/SpeechToText/Engines/MlxWhisperMac.cs diff --git a/src/libuilogic/AudioToText/WhisperChoice.cs b/src/libuilogic/AudioToText/WhisperChoice.cs index 785cb0b4c86..31b5db79900 100644 --- a/src/libuilogic/AudioToText/WhisperChoice.cs +++ b/src/libuilogic/AudioToText/WhisperChoice.cs @@ -10,6 +10,7 @@ public class WhisperChoice public const string WhisperX = "WhisperX"; public const string ConstMe = "Const-me"; public const string CTranslate2 = "CTranslate2"; + public const string MlxWhisperMac = "MLX Whisper Mac"; public const string StableTs = "stable-ts"; public const string PurfviewFasterWhisperXxl = "Purfview's Faster-Whisper-XXL"; public const string Qwen3AsrCpp = "Qwen3 ASR CPP"; diff --git a/src/ui/Assets/Languages/Dutch.json b/src/ui/Assets/Languages/Dutch.json index 8ec66181eae..48d182fd59d 100644 --- a/src/ui/Assets/Languages/Dutch.json +++ b/src/ui/Assets/Languages/Dutch.json @@ -2132,6 +2132,14 @@ "FixCasing": "Hoofdlettergebruik herstellen", "AddPeriods": "Punten toevoegen", "ChangeUnderlineToColor": "Onderstreping in kleur wijzigen", + "CueBuilding": "Cue-opbouw (MLX Whisper)", + "CueRebuild": "Cues opnieuw opbouwen uit woordtijdstempels", + "CueMaxChars": "Max. tekens per cue", + "CueMaxSeconds": "Max. seconden per cue", + "CueMaxCps": "Max. tekens per seconde", + "VocabularyPrompt": "Woordenschatprompt (namen, plaatsen, termen)", + "BeamSize": "Beamgrootte", + "BeamSizeNote": "0 decodeert het snelst. 5 geeft de beste nauwkeurigheid (zoals Faster Whisper) maar kost ongeveer 20-30% meer tijd. Waarden boven 5 helpen zelden.", "EngineSettings": "Instellingen voor spraak-naar-tekst-engine", "EngineSettingsSubtitle": "Spraak-naar-tekst-engine", "BackendAndUpdateStatus": "Backend- en updatestatus" diff --git a/src/ui/Assets/Languages/English.json b/src/ui/Assets/Languages/English.json index 0a453defaf8..f28883a5ff3 100644 --- a/src/ui/Assets/Languages/English.json +++ b/src/ui/Assets/Languages/English.json @@ -2109,6 +2109,14 @@ "fixCasing": "Fix casing", "addPeriods": "Add periods", "changeUnderlineToColor": "Change underline to color", + "cueBuilding": "Cue building (MLX Whisper)", + "cueRebuild": "Rebuild cues from word timestamps", + "cueMaxChars": "Max characters per cue", + "cueMaxSeconds": "Max seconds per cue", + "cueMaxCps": "Max characters per second", + "vocabularyPrompt": "Vocabulary prompt (names, places, terms)", + "beamSize": "Beam size", + "beamSizeNote": "0 decodes fastest. 5 gives the best accuracy (like Faster Whisper) at roughly 20-30% more time. Values above 5 rarely help.", "engineSettings": "Speech-to-text engine settings", "engineSettingsSubtitle": "Speech-to-text engine", "backendAndUpdateStatus": "Backend and update status", diff --git a/src/ui/Assets/Languages/German.json b/src/ui/Assets/Languages/German.json index 60ede62a7fb..973cee0724f 100644 --- a/src/ui/Assets/Languages/German.json +++ b/src/ui/Assets/Languages/German.json @@ -1905,6 +1905,14 @@ "fixCasing": "Groß-/Kleinschreibung korrigieren", "addPeriods": "Punkte hinzufügen", "changeUnderlineToColor": "Unterstreichung in Farbe ändern", + "CueBuilding": "Cue-Erstellung (MLX Whisper)", + "CueRebuild": "Cues aus Wort-Zeitstempeln neu erstellen", + "CueMaxChars": "Max. Zeichen pro Cue", + "CueMaxSeconds": "Max. Sekunden pro Cue", + "CueMaxCps": "Max. Zeichen pro Sekunde", + "VocabularyPrompt": "Vokabular-Prompt (Namen, Orte, Begriffe)", + "BeamSize": "Beam-Größe", + "BeamSizeNote": "0 dekodiert am schnellsten. 5 liefert die beste Genauigkeit (wie Faster Whisper) bei etwa 20-30% mehr Zeit. Werte über 5 helfen selten.", "engineSettings": "Sprache-zu-Text-Engine-Einstellungen", "engineSettingsSubtitle": "Sprache-zu-Text-Engine", "backendAndUpdateStatus": "Backend- und Update-Status" diff --git a/src/ui/Assets/Languages/Italian.json b/src/ui/Assets/Languages/Italian.json index c434a272c8d..644834852f4 100644 --- a/src/ui/Assets/Languages/Italian.json +++ b/src/ui/Assets/Languages/Italian.json @@ -2106,6 +2106,14 @@ "fixCasing": "Correggi maiuscolo/minuscolo", "addPeriods": "Aggiungi periodi", "changeUnderlineToColor": "Cambia sottolineatura in colore", + "CueBuilding": "Costruzione taglio (MLX Whisper)", + "CueRebuild": "Ricostruisci tagli dai timestamp parole", + "CueMaxChars": "Caratteri massimi per taglio", + "CueMaxSeconds": "Secondi massimi per taglio", + "CueMaxCps": "Numero massimo caratteri al secondo", + "VocabularyPrompt": "Prompt vocabolario (nomi, luoghi, termini)", + "BeamSize": "Dimensione raggio", + "BeamSizeNote": "0 decodifica più velocemente. 5 offre la migliore precisione (come Faster Whisper) con circa il 20-30% di tempo in più. I valori superiori a 5 raramente aiutano.", "engineSettings": "Impostazioni motore sintesi vocale", "engineSettingsSubtitle": "Motore sintesi vocale", "backendAndUpdateStatus": "Backend e aggiornamento stato", diff --git a/src/ui/Assets/Languages/Norwegian.json b/src/ui/Assets/Languages/Norwegian.json index 5f6cb830913..1db801a32ad 100644 --- a/src/ui/Assets/Languages/Norwegian.json +++ b/src/ui/Assets/Languages/Norwegian.json @@ -2132,6 +2132,14 @@ "FixCasing": "Fiks bokstavstørrelse", "AddPeriods": "Legg til punktum", "ChangeUnderlineToColor": "Endre understreking til farge", + "CueBuilding": "Bygging av undertekster (MLX Whisper)", + "CueRebuild": "Bygg undertekster på nytt fra ordtidsstempler", + "CueMaxChars": "Maks tegn per undertekst", + "CueMaxSeconds": "Maks sekunder per undertekst", + "CueMaxCps": "Maks tegn per sekund", + "VocabularyPrompt": "Ordforrådsledetekst (navn, steder, termer)", + "BeamSize": "Beam-størrelse", + "BeamSizeNote": "0 dekoder raskest. 5 gir best nøyaktighet (som Faster Whisper) med omtrent 20-30 % lengre tid. Verdier over 5 hjelper sjelden.", "EngineSettings": "Innstillinger for tale-til-tekst-motor", "EngineSettingsSubtitle": "Tale-til-tekst-motor", "BackendAndUpdateStatus": "Backend- og oppdateringsstatus" diff --git a/src/ui/Assets/Languages/Portuguese (Brazil).json b/src/ui/Assets/Languages/Portuguese (Brazil).json index 85ffcc3e7cd..c9be889d678 100644 --- a/src/ui/Assets/Languages/Portuguese (Brazil).json +++ b/src/ui/Assets/Languages/Portuguese (Brazil).json @@ -2102,6 +2102,14 @@ "fixCasing": "Corrigir maiúsculas/minúsculas", "addPeriods": "Adicionar pontos", "changeUnderlineToColor": "Alterar sublinhado para cor", + "CueBuilding": "Criação de CUE (MLX Whisper)", + "CueRebuild": "Recriar CUEs dos carimbos de data/hora de palavras", + "CueMaxChars": "Máximo de caracteres por CUE", + "CueMaxSeconds": "Máximo de segundos por CUE", + "CueMaxCps": "Máximo de caracteres por segundo", + "VocabularyPrompt": "Prompt de vocabulário (nomes, lugares, termos)", + "BeamSize": "Tamanho do beam", + "BeamSizeNote": "0 decodifica mais rápido. 5 oferece a melhor precisão (como Faster Whisper) em aproximadamente 20-30% mais tempo. Valores acima de 5 raramente ajudam.", "engineSettings": "Configurações do mecanismo de fala para texto", "engineSettingsSubtitle": "Mecanismo de fala para texto", "backendAndUpdateStatus": "Back-end e status de atualização", diff --git a/src/ui/Assets/Languages/Spanish.json b/src/ui/Assets/Languages/Spanish.json index cfbdfcb863c..a66228bbe6a 100644 --- a/src/ui/Assets/Languages/Spanish.json +++ b/src/ui/Assets/Languages/Spanish.json @@ -2132,6 +2132,14 @@ "FixCasing": "Corregir mayúsculas", "AddPeriods": "Añadir puntos", "ChangeUnderlineToColor": "Cambiar subrayado por color", + "CueBuilding": "Creación de líneas (MLX Whisper)", + "CueRebuild": "Reconstruir líneas a partir de las marcas de tiempo por palabra", + "CueMaxChars": "Máx. caracteres por línea", + "CueMaxSeconds": "Máx. segundos por línea", + "CueMaxCps": "Máx. caracteres por segundo", + "VocabularyPrompt": "Prompt de vocabulario (nombres, lugares, términos)", + "BeamSize": "Tamaño del haz (beam)", + "BeamSizeNote": "0 decodifica más rápido. 5 ofrece la mejor precisión (como Faster Whisper) con aproximadamente un 20-30 % más de tiempo. Los valores superiores a 5 rara vez ayudan.", "EngineSettings": "Ajustes del motor de voz a texto", "EngineSettingsSubtitle": "Motor de voz a texto", "BackendAndUpdateStatus": "Estado del backend y de la actualización" diff --git a/src/ui/Assets/SpeechToText/MLXWhisper.txt b/src/ui/Assets/SpeechToText/MLXWhisper.txt new file mode 100644 index 00000000000..754ea14cc7e --- /dev/null +++ b/src/ui/Assets/SpeechToText/MLXWhisper.txt @@ -0,0 +1,59 @@ +MLX Whisper runs Apple's "mlx-whisper" Python library via python3. + +Requirements +- An Apple Silicon Mac (M1 or newer). +- Install once: pip3 install mlx-whisper + +Why use it +MLX runs Whisper on the Apple GPU and Neural Engine via Apple's MLX framework, +so on M-series Macs it is typically much faster than the CPU-only +"Faster Whisper Mac" engine, at the same accuracy. + +Models +tiny, base, small, medium, large-v2, large-v3, large-v3-turbo. These are +MLX-format Whisper weights hosted on Hugging Face (the mlx-community org) and are +downloaded automatically on first use. + +Tip: large-v3-turbo is multilingual and the best speed/quality pick. + +Advanced +The "command line parameters" box forwards any recognized mlx-whisper transcribe +option to the decoder. The option name is matched against the installed +mlx-whisper version, so unsupported options are reported rather than silently +dropped, and the value is coerced to the right type automatically. + +Parameters (mlx-whisper transcribe options; type them into the box): + + --condition-on-previous-text N [true ] feed the previous window's text as + context; set False to stop repetition + loops and hallucinated credit lines + --temperature N [0 ] sampling temperature; a single 0 + disables the temperature fallback + --compression-ratio-threshold N [2.40 ] treat a window as repetitive above this + --logprob-threshold N [-1.00 ] retry a window below this confidence + --no-speech-threshold N [0.60 ] silence sensitivity + --best-of N [5 ] candidates to sample on a retry + --beam-size N [0 ] beam search width (0 = greedy) + --patience N [ ] beam search patience + --length-penalty N [ ] beam search length penalty + --suppress-tokens LIST [-1 ] token ids to suppress (-1 = default set) + --suppress-blank N [true ] suppress blank outputs at the start + --max-initial-timestamp N [1.00 ] cap on the first timestamp + --clip-timestamps LIST [0 ] comma separated start,end seconds to decode + --prepend-punctuations LIST ["'([{- ] punctuation merged onto the next word + --append-punctuations LIST [.,!?:) ] punctuation merged onto the previous word + --hallucination-silence-threshold N [ ] skip silence longer than this on a + hallucination (needs word timestamps) + +The option names and value types are read from the installed mlx-whisper version, +so unsupported options are reported rather than dropped. Options Subtitle Edit +already controls (language, task, word timestamps, the initial prompt) are ignored +if typed here. + +You can also type a full Hugging Face repo id as the model (anything containing +"/") to use a custom MLX Whisper model. + +Progress +When the optional VAD (faster-whisper) is not installed, long files are decoded +in fixed windows so the console keeps showing progress instead of nothing for a +long time, and repetition loops cannot spread across the whole recording. diff --git a/src/ui/Assets/SpeechToText/mlx_whisper_transcribe.py b/src/ui/Assets/SpeechToText/mlx_whisper_transcribe.py new file mode 100644 index 00000000000..2a404f94ea3 --- /dev/null +++ b/src/ui/Assets/SpeechToText/mlx_whisper_transcribe.py @@ -0,0 +1,978 @@ +#!/usr/bin/env python3 +""" +Subtitle Edit - MLX Whisper Mac helper. + +Transcribes an audio file with Apple's MLX implementation of Whisper +(the `mlx-whisper` pip package) and writes a subtitle file (SRT or VTT) +into the output directory, named ., which Subtitle Edit +then loads. + +Unlike CTranslate2 (faster-whisper), MLX runs on the Apple Silicon GPU and +Neural Engine via Apple's MLX framework, so on M-series Macs it is typically +much faster than the CPU-only faster-whisper path. Models are MLX-format +Whisper weights hosted on Hugging Face (e.g. mlx-community/whisper-large-v3-turbo) +and are downloaded automatically on first use. + +Raw Whisper segments make poor subtitles: they are often far too long or too +short, and their boundaries ignore sentence structure. The helper therefore +requests word-level timestamps and rebuilds the cues itself, exactly like the +Faster Whisper Mac helper: breaking at sentence-ending punctuation and silence +gaps, balance-splitting oversized sentences at soft punctuation or the largest +speech pause, then applying the industry conventions from the Netflix Timed +Text Style Guide and the BBC subtitle guidelines (84 characters and 7 seconds +max per cue, 5/6 second minimum with short cues merging into a neighbor, a +two-frame minimum gap, reading speed capped at 20 characters per second). +`--raw-segments` restores the plain Whisper segmentation. MLX decodes +sequentially with running text context, so punctuation (which drives the +sentence splitting) is intact in every language without extra prompting. + +Each segment is printed as `[MM:SS.mmm --> MM:SS.mmm] text` so the host can show +live progress and, if needed, recover the transcript from stdout. +""" +import argparse +import os +import re +import sys + + +def fmt_timestamp(seconds, sep): + if seconds < 0: + seconds = 0.0 + total_ms = int(round(seconds * 1000)) + hours, total_ms = divmod(total_ms, 3600000) + minutes, total_ms = divmod(total_ms, 60000) + secs, millis = divmod(total_ms, 1000) + return f"{hours:02d}:{minutes:02d}:{secs:02d}{sep}{millis:03d}" + + +def fmt_short(seconds): + if seconds < 0: + seconds = 0.0 + total_ms = int(round(seconds * 1000)) + minutes, total_ms = divmod(total_ms, 60000) + secs, millis = divmod(total_ms, 1000) + return f"{minutes:02d}:{secs:02d}.{millis:03d}" + + +def ensure_model_downloaded(repo): + """Pre-download the MLX model so the first run shows progress instead of looking frozen. + + mlx-whisper downloads the model from Hugging Face on first use. Unauthenticated downloads + are throttled and the 1+ GB weights file can take minutes; the host only sees newline- + terminated stdout, while Hugging Face's tqdm bars update with carriage returns, so without + this step a first run looks stalled. We fetch the snapshot up front and print our own + percent-progress lines the host can display. A local model path needs no download. + """ + if not repo or os.path.isdir(repo) or "/" not in repo: + return + + try: + from huggingface_hub import snapshot_download + except Exception: + return # mlx-whisper will handle the download itself. + + try: + from tqdm.auto import tqdm as _base_tqdm + except Exception: + try: + from tqdm import tqdm as _base_tqdm + except Exception: + _base_tqdm = None + + tqdm_class = None + if _base_tqdm is not None: + class _LineTqdm(_base_tqdm): + # Emit a newline-terminated " NN%" line (only on percent change) so the host + # shows live download progress, while the base class keeps its normal stderr bar. + def __init__(self, *a, **k): + self._last_pct = -1 + super().__init__(*a, **k) + + def _emit(self): + total = self.total or 0 + if total <= 0: + return + pct = int(self.n * 100 / total) + if pct == self._last_pct: + return + self._last_pct = pct + # getattr: huggingface_hub can drive this class through paths where + # the tqdm base has not populated .desc yet. + label = (getattr(self, "desc", None) or "Downloading model").strip().rstrip(":") or "Downloading model" + print(f"{label}: {pct}%", flush=True) + + def update(self, n=1): + ret = super().update(n) + self._emit() + return ret + + def close(self): + self._emit() + return super().close() + + tqdm_class = _LineTqdm + + print(f"Downloading model '{repo}' from Hugging Face (first use; this can take a while)...", + flush=True) + try: + snapshot_download(repo_id=repo, tqdm_class=tqdm_class) + print("Model download complete.", flush=True) + except Exception as e: + # Not fatal: fall through and let mlx-whisper try its own download path. + print(f"warning: model pre-download failed ({e}); letting mlx-whisper download it", + file=sys.stderr, flush=True) + + +def enable_beam_search(): + """Add beam search decoding to mlx-whisper, which only ships a greedy decoder. + + This is a port of openai/whisper's reference BeamSearchDecoder to MLX arrays, + installed by swapping the decoder inside DecodingTask. The library is already + prepared for it: the batch tiling for n_group > 1, the likelihood ranker, and + Inference.rearrange_kv_cache all exist; only the decoder class is missing + (requesting beam_size raises NotImplementedError upstream). Returns True when + the patch is active. + """ + import dataclasses + + import mlx.core as mx + import numpy as np + from mlx.utils import tree_map + from mlx_whisper import decoding + + if getattr(decoding.DecodingTask, "_se_beam_patch", False): + return True + + class BeamSearchDecoder(decoding.TokenDecoder): + def __init__(self, beam_size, eot, inference, patience=None): + self.beam_size = beam_size + self.eot = eot + self.inference = inference + self.patience = patience or 1.0 + self.max_candidates = round(beam_size * self.patience) + self.finished_sequences = None + self._rows = None + if self.max_candidates <= 0: + raise ValueError("Invalid beam size / patience") + + def reset(self): + self.finished_sequences = None + self._rows = None + + def _rearrange_kv_cache(self, source_indices): + # The library's rearrange_kv_cache reindexes every cached array, but + # the cache mixes self attention entries (batch = number of beams) + # with cross attention entries (batch = number of audios). Indexing + # the latter with beam indices reads out of bounds, which MLX does + # not check on the GPU, silently corrupting the decode. Only arrays + # whose batch dimension matches the beam count are reordered; the + # cross attention rows are identical across beams anyway. + if source_indices == list(range(len(source_indices))): + return + n = len(source_indices) + idx = mx.array(source_indices) + self.inference.kv_cache = tree_map( + lambda x: mx.take(x, idx, axis=0) if x.shape[0] == n else x, + self.inference.kv_cache) + + def update(self, tokens, logits, sum_logprobs): + if tokens.shape[0] % self.beam_size != 0: + raise ValueError("batch size is not a multiple of beam size") + n_audio = tokens.shape[0] // self.beam_size + if self.finished_sequences is None: + self.finished_sequences = [{} for _ in range(n_audio)] + + # Beam bookkeeping happens in python like the reference implementation, + # but the top-k selection runs on the GPU first so only beam_size + 1 + # candidates per beam cross to the CPU each step instead of the whole + # vocabulary-sized probability matrix. + k = self.beam_size + 1 + logprobs = logits - mx.logsumexp(logits, axis=-1, keepdims=True) + top_idx = mx.argpartition(logprobs, -k, axis=-1)[:, -k:] + top_val = mx.take_along_axis(logprobs, top_idx, axis=-1) + top_idx = np.array(top_idx) + top_val = np.array(top_val) + sums = np.array(sum_logprobs) + # The candidate rows are rebuilt in python each step, so reuse them + # instead of pulling the whole growing token matrix off the GPU. + rows = self._rows + if rows is None: + rows = np.array(tokens).tolist() + + next_tokens, source_indices, new_sums, newly_finished = [], [], [], [] + for i in range(n_audio): + scores, sources, finished = {}, {}, {} + # Rank every (beam, candidate token) continuation by cumulative + # log probability; the dict keyed by full sequence also merges + # beams that converged onto identical hypotheses. + for j in range(self.beam_size): + idx = i * self.beam_size + j + for token, val in zip(top_idx[idx], top_val[idx]): + sequence = tuple(rows[idx] + [int(token)]) + scores[sequence] = float(sums[idx] + val) + sources[sequence] = idx + + saved = 0 + for sequence in sorted(scores, key=scores.get, reverse=True): + if sequence[-1] == self.eot: + finished[sequence] = scores[sequence] + else: + new_sums.append(scores[sequence]) + next_tokens.append(list(sequence)) + source_indices.append(sources[sequence]) + saved += 1 + if saved == self.beam_size: + break + newly_finished.append(finished) + + self._rows = next_tokens + tokens = mx.array(next_tokens) + self._rearrange_kv_cache(source_indices) + + for previously, newly in zip(self.finished_sequences, newly_finished): + for seq in sorted(newly, key=newly.get, reverse=True): + if len(previously) >= self.max_candidates: + break + previously[seq] = newly[seq] + + completed = all( + len(sequences) >= self.max_candidates + for sequences in self.finished_sequences + ) + return tokens, completed, mx.array(new_sums) + + def finalize(self, preceding_tokens, sum_logprobs): + # Collect the finished hypotheses, topping up from the best still + # running beams when too few finished naturally. The caller expects + # rectangular arrays and truncates each row at its first EOT, so + # shorter sequences are padded with EOT. + sums = np.array(sum_logprobs) + preceding = np.array(preceding_tokens).tolist() + groups = [] + for i, sequences in enumerate(self.finished_sequences): + if len(sequences) < self.beam_size: + for j in list(np.argsort(sums[i]))[::-1]: + sequence = tuple(preceding[i][j] + [self.eot]) + sequences.setdefault(sequence, float(sums[i][j])) + if len(sequences) >= self.beam_size: + break + groups.append(sequences) + + count = max(len(sequences) for sequences in groups) + length = max(len(seq) for sequences in groups for seq in sequences) + tokens = [] + logprobs = [] + for sequences in groups: + ordered = sorted(sequences.items(), key=lambda kv: kv[1], reverse=True) + while len(ordered) < count: + ordered.append(ordered[0]) + tokens.append([list(seq) + [self.eot] * (length - len(seq)) + for seq, _ in ordered]) + logprobs.append([score for _, score in ordered]) + return mx.array(tokens), mx.array(logprobs) + + original_init = decoding.DecodingTask.__init__ + + def patched_init(self, model, options): + beam_size = options.beam_size + patience = options.patience + if beam_size: + options = dataclasses.replace( + options, beam_size=None, patience=None, best_of=None) + original_init(self, model, options) + if beam_size: + self.options = dataclasses.replace( + self.options, beam_size=beam_size, patience=patience) + self.n_group = beam_size + self.decoder = BeamSearchDecoder( + beam_size, self.tokenizer.eot, self.inference, patience) + + decoding.DecodingTask.__init__ = patched_init + decoding.DecodingTask._se_beam_patch = True + return True + + +def load_wav_as_array(path): + """Read a PCM WAV directly into a 16 kHz mono float32 array. + + mlx-whisper's own loader shells out to the `ffmpeg` binary, which may be + missing or not executable in the host's environment. Subtitle Edit always + feeds a 16 kHz mono PCM WAV, so we decode it here with the standard library + and hand mlx-whisper the waveform directly, avoiding ffmpeg entirely. + Returns a float32 numpy array, or None if the file is not a WAV we can read. + """ + import wave + import numpy as np + + with wave.open(path, "rb") as w: + channels = w.getnchannels() + sample_width = w.getsampwidth() + framerate = w.getframerate() + frames = w.readframes(w.getnframes()) + + if sample_width == 2: + data = np.frombuffer(frames, dtype=" 1: + data = data.reshape(-1, channels).mean(axis=1) + + if framerate != 16000 and len(data) > 0: + target_len = int(round(len(data) * 16000 / framerate)) + if target_len > 0: + old_idx = np.linspace(0.0, 1.0, num=len(data), endpoint=False) + new_idx = np.linspace(0.0, 1.0, num=target_len, endpoint=False) + data = np.interp(new_idx, old_idx, data).astype(np.float32) + + return np.ascontiguousarray(data, dtype=np.float32) + + +# Sentence-ending characters that always close a cue: Latin, Arabic (؟), Urdu (۔), +# CJK full stops and their fullwidth companions, plus ellipsis. +SENTENCE_END = ".!?…؟۔。!?" + +# Soft break points preferred when a too-long sentence must be split: Latin and +# Arabic commas and semicolons, and colons. +SOFT_BREAK = ",;:،؛" + +# Punctuated sample prompts per language, used to stop Whisper omitting punctuation +# (see main). Languages not listed rely on Whisper's normal behavior; --initial-prompt +# covers any special case. +PUNCTUATION_PROMPTS = { + "ar": "مرحباً، كيف حالك؟ أنا بخير. شكراً جزيلاً!", + "en": "Hello there. How are you? I'm fine, thanks!", + "tr": "Merhaba, nasılsın? Ben iyiyim. Teşekkür ederim!", +} + + +def apply_standards(cues, max_chars, max_duration, max_cps, + min_duration=0.833, min_gap=0.083): + """Apply the industry timing conventions to the built cues. + + Per the Netflix Timed Text Style Guide / BBC subtitle guidelines: a cue + shorter than 5/6 second merges into its predecessor when the result still + respects the length and duration limits; cue ends are extended when the + reading speed would exceed max_cps characters per second (or the minimum + duration is not met) and there is room; and a two-frame minimum gap is kept + before the next cue. + """ + if not cues: + return cues + + merged = [] + for start, end, text in cues: + if merged: + p_start, p_end, p_text = merged[-1] + if (end - start < min_duration + and start - p_end < 1.0 + and len(p_text) + 1 + len(text) <= max_chars + and end - p_start <= max_duration): + merged[-1] = (p_start, end, p_text + " " + text) + continue + merged.append((start, end, text)) + + out = [] + for i, (start, end, text) in enumerate(merged): + wanted = max(end, start + min_duration, start + len(text) / max_cps) + end = min(wanted, start + max_duration) + if i + 1 < len(merged): + next_start = merged[i + 1][0] + latest = next_start - min_gap + if end > latest: + # Cap the extension at the gap before the next cue. When two cues + # start almost simultaneously the gap itself is not attainable; + # keep a minimal duration then, but never overlap the next cue. + end = max(latest, start + 0.05) + if end > next_start: + end = next_start + out.append((start, end, tidy_punctuation(text))) + + return out + + +def detect_speech_clips(audio): + """Silero VAD (via faster-whisper, optional): [start1, end1, start2, ...] seconds of speech. + + mlx-whisper has no voice-activity detection, so music and long silences reach the + decoder, which reacts with hallucination loops (repeated garbage tokens with + zero-length timestamps), phantom music labels, and echoes of the initial prompt. + Clipping the decode to the detected speech regions kills all of that at the root. + Returns None when faster-whisper's VAD is not installed or detection fails. + """ + try: + from faster_whisper.vad import get_speech_timestamps + except Exception: + print("note: faster-whisper VAD not available; decoding the full audio " + "(pip3 install faster-whisper enables music/silence skipping)", flush=True) + return None + + try: + regions = get_speech_timestamps(audio) + except Exception as e: + print(f"warning: VAD failed ({e}); decoding the full audio", file=sys.stderr, flush=True) + return None + + if not regions: + return None + + clips = [] + for region in regions: + clips.extend([region["start"] / 16000.0, region["end"] / 16000.0]) + return clips + + +def _has_expected_script(text, language): + """True when the text contains at least one character of the language's script.""" + if not any(c.isalpha() or c.isdigit() for c in text): + return True # punctuation/symbol-only cues carry no script signal to judge + if language == "ar": + return any("\u0600" <= c <= "\u06ff" or "\u0750" <= c <= "\u077f" or c.isdigit() for c in text) + if language in ("en", "tr"): + return any(("a" <= c.lower() <= "z") or c.isdigit() or c in "çğıöşüÇĞİÖŞÜ" for c in text) + return True + + +def tidy_punctuation(text): + """Collapse doubled punctuation the decoder sometimes emits (e.g. "؟." or "،."). + + Whisper occasionally closes a sentence with a period even though the previous + token already ended it with a question mark, comma, or exclamation mark. The + stray period is dropped; ellipses ("...") are left alone. + """ + return re.sub("(?<=[؟?!،؛,;:…])\\s*\\.(?!\\.)", "", text) + + +def sanitize_cues(cues, language=None, prompt=None): + """Drop decoder-hallucination artifacts before the timing pass sees them. + + Whisper reacts to music and long silences with degenerate output: zero-length + cues, the same token repeated dozens of times, text in a completely different + script, and echoes of the initial prompt. VAD clipping prevents most of it at + the source; this pass is the safety net for whatever still gets through (and + for runs where VAD was unavailable). + """ + out = [] + repeats = 0 + for start, end, text in cues: + if end - start < 0.05: + continue + if language and text and not _has_expected_script(text, language): + continue + if out and out[-1][2] == text: + repeats += 1 + if repeats >= 2: + # A third+ identical adjacent cue is a hallucination loop, not dialogue. + continue + else: + repeats = 0 + out.append((start, end, text)) + + # Leading cues that only quote the injected punctuation prompt are echoes the + # decoder produced against intro music/silence, not speech. + if prompt: + lead = 0 + for _, _, text in out: + if text and text in prompt: + lead += 1 + else: + break + out = out[lead:] + + return out + + +def split_chunk(chunk, max_chars, max_duration): + """Split one sentence's words into cue-sized parts, balanced instead of greedy. + + Cutting a sentence at the last word that still fits leaves orphan cues like a + lone "editors." half a second long. Instead, an oversized run is split near its + middle, preferring (in order) a soft-punctuation boundary, then the largest + silence gap between words (a real speech pause, which is where a subtitler + would cut), then the word boundary nearest the middle; recursing until every + part fits, so all pieces stay readable. + """ + total_chars = sum(len(w[2]) for w in chunk) + duration = chunk[-1][1] - chunk[0][0] + if (total_chars <= max_chars and duration <= max_duration) or len(chunk) == 1: + return [chunk] + + # Candidate split points: after word i (never after the last word). Track the + # cumulative character position of each candidate to measure distance from the + # middle, and the pause before the next word. + candidates = [] + acc = 0 + for i, w in enumerate(chunk[:-1]): + acc += len(w[2]) + gap = chunk[i + 1][0] - w[1] + candidates.append((i, acc, gap)) + + total = acc + len(chunk[-1][2]) + half = total / 2 + + # Keep splits inside the middle band so neither piece comes out tiny. + band = [c for c in candidates if 0.3 * total <= c[1] <= 0.7 * total] or candidates + + soft = [c for c in band if chunk[c[0]][2].rstrip()[-1:] in SOFT_BREAK] + if soft: + best_i = min(soft, key=lambda c: abs(c[1] - half))[0] + else: + by_gap = max(band, key=lambda c: c[2]) + if by_gap[2] >= 0.15: + best_i = by_gap[0] + else: + best_i = min(band, key=lambda c: abs(c[1] - half))[0] + + left, right = chunk[:best_i + 1], chunk[best_i + 1:] + return split_chunk(left, max_chars, max_duration) + split_chunk(right, max_chars, max_duration) + + +def resegment(words, max_chars, max_duration, gap_break=1.0): + """Rebuild subtitle cues from word-level timestamps. + + First group words into sentences (closed by sentence-ending punctuation or a + silence gap), then balance-split any sentence exceeding the length or duration + limit via split_chunk. Each cue is timed first-word-start to last-word-end, so + cue timecodes hug the actual speech instead of inheriting Whisper's coarse + segment bounds. Returns a list of (start, end, text). + """ + # When the decode produced no sentence punctuation at all (it happens with + # dialectal speech even after prompting), rely on the speech rhythm instead: + # a tighter pause threshold recovers sentence-like grouping from the audio. + if not any(w[2].strip()[-1:] in SENTENCE_END for w in words if w[2].strip()): + gap_break = min(gap_break, 0.6) + + sentences = [] + cur = [] + + for start, end, token in words: + stripped = token.strip() + if not stripped: + continue + + if cur and start - cur[-1][1] >= gap_break: + sentences.append(cur) + cur = [] + + cur.append((start, end, token)) + + if stripped[-1] in SENTENCE_END: + sentences.append(cur) + cur = [] + + if cur: + sentences.append(cur) + + cues = [] + for sentence in sentences: + for part in split_chunk(sentence, max_chars, max_duration): + text = "".join(w[2] for w in part).strip() + if text: + cues.append((part[0][0], part[-1][1], text)) + + return cues + + +# Options that Subtitle Edit controls through its own fields; the user must not +# override them through the advanced command line box or decode_piece would get +# a duplicate keyword and the run would abort. +_RESERVED_PASSTHROUGH = { + "audio", "path_or_hf_repo", "language", "task", "verbose", "word_timestamps", + "initial_prompt", +} + + +def _coerce_option_value(raw, default): + """Coerce a command line string to the type of the option's default value.""" + if isinstance(default, bool): + return raw.strip().lower() in ("1", "true", "yes", "on") + if isinstance(default, int) and not isinstance(default, bool): + try: + return int(raw) + except ValueError: + return float(raw) + if isinstance(default, float): + return float(raw) + if isinstance(default, tuple): + parts = [p for p in raw.replace(" ", "").split(",") if p] + return tuple(float(p) for p in parts) if parts else default + # str, None, or unknown default type: pass the string through unchanged. + return raw + + +def parse_passthrough_options(unknown, option_defaults): + """Turn leftover ``--key value`` / ``--key`` tokens into mlx_whisper.transcribe kwargs. + + ``option_defaults`` maps a supported option name to its default value (used + both to recognise the option and to coerce the value to the right type). + Returns (kwargs, unknown_tokens). A recognised option with no value is treated + as a boolean flag set to True when its default is boolean. + """ + kwargs = {} + leftovers = [] + i = 0 + n = len(unknown) + while i < n: + token = unknown[i] + if not token.startswith("--"): + leftovers.append(token) + i += 1 + continue + + name = token[2:] + # Support --key=value as well as --key value. + inline_value = None + if "=" in name: + name, inline_value = name.split("=", 1) + name = name.replace("-", "_") + + has_following_value = inline_value is not None or ( + i + 1 < n and not unknown[i + 1].startswith("--")) + + if name in _RESERVED_PASSTHROUGH: + leftovers.append(token + " (controlled by Subtitle Edit, ignored)") + i += 2 if (inline_value is None and has_following_value) else 1 + continue + + if name in option_defaults: + default = option_defaults[name] + if inline_value is not None: + kwargs[name] = _coerce_option_value(inline_value, default) + i += 1 + elif has_following_value: + kwargs[name] = _coerce_option_value(unknown[i + 1], default) + i += 2 + elif isinstance(default, bool): + kwargs[name] = True + i += 1 + else: + leftovers.append(token + " (missing value)") + i += 1 + continue + + leftovers.append(token) + i += 2 if (inline_value is None and has_following_value) else 1 + + return kwargs, leftovers + + +def main(): + parser = argparse.ArgumentParser( + description="mlx-whisper transcription helper for Subtitle Edit") + parser.add_argument("--audio", required=True, help="Path to the input audio file") + parser.add_argument("--model", default="mlx-community/whisper-large-v3-turbo", + help="MLX Whisper model (Hugging Face repo id)") + parser.add_argument("--language", default=None, help="Language code, or omit/auto to auto-detect") + parser.add_argument("--output-dir", default=None, help="Directory for the subtitle file (default: audio's folder)") + parser.add_argument("--output-format", default="srt", choices=["srt", "vtt"]) + parser.add_argument("--task", default="transcribe", choices=["transcribe", "translate"]) + parser.add_argument("--max-cue-chars", type=int, default=84, + help="Max characters per subtitle cue before a forced break (two 42-char lines)") + parser.add_argument("--max-cue-duration", type=float, default=7.0, + help="Max seconds per subtitle cue before a forced break (Netflix limit)") + parser.add_argument("--max-cps", type=float, default=20.0, + help="Max reading speed in characters per second; cue ends extend to meet it") + parser.add_argument("--initial-prompt", default=None, + help="Text to bias the decoder (overrides the automatic punctuation prompt)") + parser.add_argument("--beam-size", type=int, default=0, + help="Decode with beam search of this width (0 = greedy). " + "Higher accuracy on hard speech at roughly beam-size times the decode cost.") + parser.add_argument("--no-punctuation-prompt", action="store_true", + help="Disable the automatic punctuation-biasing prompt") + parser.add_argument("--raw-segments", action="store_true", + help="Write Whisper's raw segments instead of word-timestamp resegmented cues") + args, unknown = parser.parse_known_args() + + try: + import mlx_whisper + except ImportError: + print("error: mlx-whisper not found - install it with: pip3 install mlx-whisper", + file=sys.stderr) + return 3 + + if not os.path.isfile(args.audio): + print(f"error: audio file not found: {args.audio}", file=sys.stderr) + return 2 + + language = args.language + if language in (None, "", "auto"): + language = None + + out_dir = args.output_dir or os.path.dirname(os.path.abspath(args.audio)) + os.makedirs(out_dir, exist_ok=True) + base = os.path.splitext(os.path.basename(args.audio))[0] + ext = args.output_format + out_path = os.path.join(out_dir, base + "." + ext) + + # Prefer decoding the WAV ourselves so we never depend on a system ffmpeg binary + # (mlx-whisper's path loader calls `ffmpeg`, which may be missing/non-executable). + audio_input = args.audio + if os.path.splitext(args.audio)[1].lower() == ".wav": + try: + arr = load_wav_as_array(args.audio) + if arr is not None: + audio_input = arr + except Exception as e: + print(f"warning: direct WAV load failed ({e}); falling back to ffmpeg path", + file=sys.stderr, flush=True) + + ensure_model_downloaded(args.model) + + print(f"Loading MLX Whisper model '{args.model}' (Apple GPU/Neural Engine)...", flush=True) + + # Older mlx-whisper releases predate some of the options used below. Passing an + # unknown option would abort the whole run with a TypeError, so feature-detect + # against the installed version and drop (with a note) whatever it lacks: + # options named by transcribe() itself, and decode options that flow into + # DecodingOptions. + import dataclasses + import inspect + try: + transcribe_params = set(inspect.signature(mlx_whisper.transcribe).parameters) + except (TypeError, ValueError): + transcribe_params = None + try: + from mlx_whisper.decoding import DecodingOptions + option_fields = {f.name for f in dataclasses.fields(DecodingOptions)} + except Exception: + option_fields = None + + def supports_option(name): + if transcribe_params is None and option_fields is None: + return True # cannot introspect; keep previous behavior + if transcribe_params is not None and name in transcribe_params: + return True + return option_fields is not None and name in option_fields + + def note_unsupported(name, consequence): + print(f"note: this mlx-whisper version does not support {name}; {consequence}", + flush=True) + + # Advanced command line box: forward any recognized mlx_whisper.transcribe + # option the user typed (for example --condition-on-previous-text False, a + # common mitigation for repetition loops and hallucinated credit lines on long + # low-speech files). Options are recognized and type-coerced from the installed + # version's own signature and DecodingOptions, so nothing needs to be hard + # coded and unsupported options are reported instead of silently dropped. + option_defaults = {} + if transcribe_params is not None: + for _name, _param in inspect.signature(mlx_whisper.transcribe).parameters.items(): + if _param.default is not inspect.Parameter.empty: + option_defaults[_name] = _param.default + if option_fields is not None: + for _field in dataclasses.fields(DecodingOptions): + if _field.name not in option_defaults and _field.default is not dataclasses.MISSING: + option_defaults[_field.name] = _field.default + + passthrough_kwargs, leftover_args = parse_passthrough_options(unknown, option_defaults) + if leftover_args: + print(f"warning: ignoring unrecognized argument(s): {' '.join(leftover_args)}", + file=sys.stderr, flush=True) + for _key in list(passthrough_kwargs): + if not supports_option(_key): + note_unsupported(_key, "ignoring it") + passthrough_kwargs.pop(_key, None) + + use_words = not args.raw_segments + if use_words and not supports_option("word_timestamps"): + use_words = False + note_unsupported("word_timestamps", "writing raw Whisper segments") + + transcribe_kwargs = {} + if use_words: + # Built-in hallucination guard: with word timestamps available, skip silent + # stretches longer than this when the decoder output looks hallucinated. + if supports_option("hallucination_silence_threshold"): + transcribe_kwargs["hallucination_silence_threshold"] = 2.0 + else: + note_unsupported("hallucination_silence_threshold", + "relying on the VAD clipping alone") + + # When a segment fails the quality checks and is retried at a higher + # temperature, sample five candidates and keep the most likely one, matching + # openai/whisper's default. mlx-whisper drops this option at temperature 0, + # so the fast greedy path is unaffected; only hard segments pay for it. + if supports_option("best_of"): + transcribe_kwargs["best_of"] = 5 + + if args.beam_size and args.beam_size > 1: + if supports_option("beam_size"): + try: + enable_beam_search() + transcribe_kwargs["beam_size"] = args.beam_size + print(f"Beam search enabled (beam size {args.beam_size}).", flush=True) + except Exception as e: + print(f"warning: beam search unavailable ({e}); decoding greedily", + file=sys.stderr, flush=True) + else: + note_unsupported("beam_size", "decoding greedily") + + # User supplied options override the defaults set above (for example turning + # off best_of or condition_on_previous_text). + if passthrough_kwargs: + transcribe_kwargs.update(passthrough_kwargs) + print("Applying advanced options: " + + ", ".join(f"{k}={v}" for k, v in passthrough_kwargs.items()), flush=True) + + # Whisper drops punctuation on some real-world speech (dialectal Arabic notably) + # even with sequential decoding, and punctuation drives the sentence-based cue + # splitting. A short punctuated prompt in the audio's language restores it, but + # the initial prompt only reaches the first decoding window, so over a long file + # its effect decays. Decoding each VAD speech chunk as its own call (below) + # re-applies the prompt at every chunk, which keeps punctuation practically + # present across a full episode instead of only near the start. + # A user prompt (vocabulary: names, places, technical terms) combines with the + # punctuation prompt instead of replacing it, so custom terms never cost + # punctuation. Whisper only reads the final 224 tokens of a prompt, which both + # of these together stay well within. + prompt = args.initial_prompt + if not args.no_punctuation_prompt and language: + punctuation_prompt = PUNCTUATION_PROMPTS.get(language.lower()) + if punctuation_prompt: + prompt = f"{punctuation_prompt} {prompt}".strip() if prompt else punctuation_prompt + + collected = [] + words = [] + + def decode_piece(piece, offset, piece_prompt): + """Transcribe one audio piece, printing and collecting absolute-time output.""" + kwargs = dict(transcribe_kwargs) + if use_words: + kwargs["word_timestamps"] = True + if piece_prompt and supports_option("initial_prompt"): + kwargs["initial_prompt"] = piece_prompt + result = mlx_whisper.transcribe( + piece, + path_or_hf_repo=args.model, + language=language, + task=args.task, + verbose=None, + **kwargs, + ) + piece_texts = [] + leading = True + for seg in result.get("segments", []): + text = (seg.get("text") or "").strip() + if leading and text and piece_prompt and text in piece_prompt: + # The decoder sometimes opens a chunk by echoing the injected + # prompt (the punctuation prompt or the previous chunk's tail); + # that is prompt text, not speech, so drop it. + continue + if text: + leading = False + start = offset + float(seg.get("start", 0.0)) + end = offset + float(seg.get("end", 0.0)) + # Live line per segment, in absolute time (the pieces are chunk-relative). + print(f"[{fmt_short(start)} --> {fmt_short(end)}] {text}", flush=True) + collected.append((start, end, text)) + piece_texts.append(text) + if use_words: + for w in seg.get("words") or []: + words.append((offset + float(w.get("start", 0.0)), + offset + float(w.get("end", 0.0)), + w.get("word") or "")) + return " ".join(piece_texts) + + # Skip music and long silences via VAD when possible (see detect_speech_clips); + # only possible when the audio was decoded to an array (Subtitle Edit always + # feeds a 16 kHz mono WAV, so this is the normal path). Nearby regions merge + # into chunks so the per-chunk overhead stays negligible, capped so the + # punctuation prompt is refreshed at least every few minutes. + clips = None if isinstance(audio_input, str) else detect_speech_clips(audio_input) + if clips: + chunks = [] + chunk_start, chunk_end = clips[0], clips[1] + for i in range(2, len(clips), 2): + start, end = clips[i], clips[i + 1] + if start - chunk_end <= 3.0 and end - chunk_start <= 240.0: + chunk_end = end + else: + chunks.append((chunk_start, chunk_end)) + chunk_start, chunk_end = start, end + chunks.append((chunk_start, chunk_end)) + + # A single uninterrupted speech region can exceed the cap on its own; + # split it so the prompt refresh interval holds for it as well. + capped = [] + for start, end in chunks: + while end - start > 240.0: + capped.append((start, start + 240.0)) + start += 240.0 + capped.append((start, end)) + chunks = capped + + speech_seconds = sum(end - start for start, end in chunks) + total_seconds = len(audio_input) / 16000.0 + print(f"VAD: {len(chunks)} speech chunk(s), " + f"{speech_seconds / 60.0:.1f} of {total_seconds / 60.0:.1f} minutes to decode.", + flush=True) + + # Carry a tail of the previous chunk's text so cross-chunk context (names, + # style, and the punctuation habit) persists like sequential conditioning. + previous_tail = "" + for chunk_start, chunk_end in chunks: + chunk_prompt = prompt or "" + if previous_tail: + chunk_prompt = (chunk_prompt + " " + previous_tail).strip() + piece = audio_input[int(chunk_start * 16000):int(chunk_end * 16000)] + chunk_text = decode_piece(piece, chunk_start, chunk_prompt or None) + if chunk_text: + previous_tail = chunk_text[-150:] + elif isinstance(audio_input, str): + # A file path was passed straight to mlx-whisper (no in-memory array to + # window); decode in one shot. + decode_piece(audio_input, 0.0, prompt) + else: + # No VAD available (faster-whisper not installed): decode in fixed windows + # so the console shows steady progress instead of nothing for a long file, + # and so repetition loops and hallucinations cannot spread across the whole + # recording. The previous chunk's tail is carried for context. + total_seconds = len(audio_input) / 16000.0 + window = 120.0 + window_count = int(total_seconds // window) + (1 if total_seconds % window else 0) + print(f"No VAD available; decoding in {max(1, window_count)} window(s) of up to " + f"{int(window)}s ({total_seconds / 60.0:.1f} minutes total).", flush=True) + previous_tail = "" + start = 0.0 + while start < total_seconds: + end = min(start + window, total_seconds) + chunk_prompt = prompt or "" + if previous_tail: + chunk_prompt = (chunk_prompt + " " + previous_tail).strip() + piece = audio_input[int(start * 16000):int(end * 16000)] + chunk_text = decode_piece(piece, start, chunk_prompt or None) + if chunk_text: + previous_tail = chunk_text[-150:] + start = end + + if use_words and words: + cues = resegment(words, args.max_cue_chars, args.max_cue_duration) + cues = sanitize_cues(cues, language, prompt) + cues = apply_standards(cues, args.max_cue_chars, args.max_cue_duration, args.max_cps) + print(f"Resegmented {len(collected)} raw segment(s) into {len(cues)} subtitle cue(s) " + f"(max {args.max_cue_chars} chars / {args.max_cue_duration:g}s per cue, " + f"max {args.max_cps:g} chars/second).", flush=True) + collected = cues + elif use_words: + print("No word timestamps returned; keeping Whisper's raw segments.", flush=True) + collected = sanitize_cues(collected, language, prompt) + + sep = "," if ext == "srt" else "." + with open(out_path, "w", encoding="utf-8") as f: + if ext == "vtt": + f.write("WEBVTT\n\n") + for index, (start, end, text) in enumerate(collected, 1): + if ext == "srt": + f.write(f"{index}\n") + f.write(f"{fmt_timestamp(start, sep)} --> {fmt_timestamp(end, sep)}\n{text}\n\n") + + print(f"Wrote {len(collected)} subtitle(s) to {out_path}", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/ui/Features/Video/SpeechToText/Engines/MlxWhisperMac.cs b/src/ui/Features/Video/SpeechToText/Engines/MlxWhisperMac.cs new file mode 100644 index 00000000000..c32eed0d498 --- /dev/null +++ b/src/ui/Features/Video/SpeechToText/Engines/MlxWhisperMac.cs @@ -0,0 +1,425 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Platform; +using Nikse.SubtitleEdit.UiLogic.AudioToText; +using Nikse.SubtitleEdit.Logic.Config; + +namespace Nikse.SubtitleEdit.Features.Video.SpeechToText.Engines; + +/// +/// Speech-to-text engine for Apple Silicon Macs that drives the pip-installed "mlx-whisper" +/// Python package. Unlike CTranslate2 / faster-whisper (CPU-only on macOS), MLX runs Whisper on +/// the Apple GPU and Neural Engine via Apple's MLX framework, so on M-series Macs it is typically +/// much faster. Because the pip package ships a library rather than a console tool, transcription +/// runs a small bundled helper script (mlx_whisper_transcribe.py) via python3, which +/// writes an SRT next to the input audio. Installation is detected by importing the package +/// (python3 -c "import mlx_whisper"); MLX-format models are downloaded automatically from +/// Hugging Face (the mlx-community org) on first use. +/// +public class MlxWhisperMac : ISpeechToTextEngine +{ + public static string StaticName => "MLX Whisper"; + public string Name => StaticName; + public string Choice => WhisperChoice.MlxWhisperMac; + public string Url => "https://github.com/ml-explore/mlx-examples/tree/main/whisper"; + + private const string TranscribeScriptName = "mlx_whisper_transcribe.py"; + + // The console-script shim pip/pipx installs for the package (mlx_whisper --help). + private const string CliShimName = "mlx_whisper"; + + // Friendly model name -> MLX-format Hugging Face repo (mlx-community). + private static readonly Dictionary ModelRepos = new(StringComparer.OrdinalIgnoreCase) + { + ["tiny"] = "mlx-community/whisper-tiny-mlx", + ["base"] = "mlx-community/whisper-base-mlx", + ["small"] = "mlx-community/whisper-small-mlx", + ["medium"] = "mlx-community/whisper-medium-mlx", + ["large-v2"] = "mlx-community/whisper-large-v2-mlx", + ["large-v3"] = "mlx-community/whisper-large-v3-mlx", + ["large-v3-turbo"] = "mlx-community/whisper-large-v3-turbo", + }; + + private static bool? _isMlxWhisperInstalled; + + // The python3 interpreter that can actually "import mlx_whisper". Resolved during the install + // check and reused for transcription so both use the same Python (see GetExecutable). + private static string? _resolvedPython; + + public List Languages => WhisperLanguage.Languages.OrderBy(p => p.Name).ToList(); + + // Built explicitly (not filtered from the CTranslate2 list, which has no turbo entry). large-v3-turbo + // is multilingual and the best speed/quality pick; on the Apple GPU it is very fast. Sizes are the + // approximate MLX download sizes. Models download from Hugging Face automatically on first use. + public List Models => new List + { + new WhisperModel { Name = "large-v3-turbo", Size = "1.6 GB" }, + new WhisperModel { Name = "large-v3", Size = "2.9 GB" }, + new WhisperModel { Name = "large-v2", Size = "2.9 GB" }, + new WhisperModel { Name = "medium", Size = "1.5 GB" }, + new WhisperModel { Name = "small", Size = "483 MB" }, + new WhisperModel { Name = "base", Size = "145 MB" }, + new WhisperModel { Name = "tiny", Size = "75 MB" }, + }; + + public string Extension => string.Empty; + public string UnpackSkipFolder => string.Empty; + + public string CommandLineParameter + { + get => Se.Settings.Tools.AudioToText.CommandLineParameterMlxWhisperMac; + set => Se.Settings.Tools.AudioToText.CommandLineParameterMlxWhisperMac = value; + } + + public bool IsEngineInstalled() + { + if (!OperatingSystem.IsMacOS()) + { + return false; + } + + if (_isMlxWhisperInstalled.HasValue) + { + return _isMlxWhisperInstalled.Value; + } + + // mlx-whisper is almost never installed into Homebrew's Python (it is "externally managed", + // PEP 668), so checking a single fixed interpreter wrongly reports "not installed". Probe each + // candidate and pick the first one that can import the package; remember it for transcription. + foreach (var python in GetPythonCandidates()) + { + if (CanImportMlxWhisper(python)) + { + _resolvedPython = python; + _isMlxWhisperInstalled = true; + return true; + } + } + + _isMlxWhisperInstalled = false; + return false; + } + + private static bool CanImportMlxWhisper(string python) + { + try + { + using var process = new Process + { + StartInfo = new ProcessStartInfo(python, "-c \"import mlx_whisper\"") + { + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + } + }; + +#pragma warning disable CA1416 + process.Start(); + if (!process.WaitForExit(10_000)) + { + process.Kill(true); + return false; + } +#pragma warning restore CA1416 + + return process.ExitCode == 0; + } + catch + { + return false; + } + } + + /// + /// The python3 interpreters to probe for mlx-whisper, in priority order: the interpreter behind + /// an installed mlx_whisper CLI shim (covers pipx / venv / conda), then Homebrew, + /// python.org framework builds (newest first), pyenv, the system Python, and finally PATH + /// resolution. + /// + private static IEnumerable GetPythonCandidates() + { + var seen = new HashSet(StringComparer.Ordinal); + var candidates = new List(); + + void Add(string path) + { + if (!string.IsNullOrEmpty(path) && seen.Add(path)) + { + candidates.Add(path); + } + } + + // Highest priority: the exact interpreter behind an installed mlx_whisper CLI shim. pipx, + // a hand-made venv, and conda all install the package into an isolated environment that + // none of the shared interpreters below can import, so probing them alone reports "not + // found" even though the user did install it (#12209). The shim's shebang names the one + // interpreter that can import the package. + foreach (var interpreter in GetShebangInterpreters()) + { + Add(interpreter); + } + + Add("/opt/homebrew/bin/python3"); + Add("/usr/local/bin/python3"); + + const string frameworkDir = "/Library/Frameworks/Python.framework/Versions"; + if (Directory.Exists(frameworkDir)) + { + foreach (var versionDir in Directory.GetDirectories(frameworkDir).OrderByDescending(p => p)) + { + Add(Path.Combine(versionDir, "bin", "python3")); + } + } + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(home)) + { + Add(Path.Combine(home, ".pyenv", "shims", "python3")); + } + + Add("/usr/bin/python3"); + Add("python3"); // resolved via PATH + + return candidates.Where(p => p == "python3" || File.Exists(p)); + } + + /// + /// Interpreters discovered by reading the shebang of an installed mlx_whisper CLI shim. + /// A pip/pipx/venv/conda console script starts with #!/abs/path/to/that/env/bin/python, + /// which points at the exact interpreter that can import mlx_whisper - the one case the + /// fixed interpreter list cannot cover, because an isolated environment's package is not + /// importable from Homebrew/system Python (#12209). + /// + private static IEnumerable GetShebangInterpreters() + { + var result = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var shim in GetCliShimCandidates()) + { + try + { + if (!File.Exists(shim)) + { + continue; + } + + // Read only the first line - a console script is tiny, but a same-named non-script + // file on one of these paths shouldn't be slurped whole. + using var reader = new StreamReader(shim); + var firstLine = reader.ReadLine(); + if (firstLine == null || !firstLine.StartsWith("#!", StringComparison.Ordinal)) + { + continue; + } + + // "#!/path/to/python" or "#!/path/to/python -E ...": take the interpreter token. + // The "#!/usr/bin/env python3" form names no specific environment, so its first + // token ("/usr/bin/env") is filtered out by the python-name check below - the bare + // "python3" PATH candidate already covers that case. + var interpreterPath = firstLine.Substring(2).Trim().Split(' ', '\t')[0]; + if (Path.IsPathRooted(interpreterPath) && + Path.GetFileName(interpreterPath).StartsWith("python", StringComparison.OrdinalIgnoreCase) && + File.Exists(interpreterPath) && + seen.Add(interpreterPath)) + { + result.Add(interpreterPath); + } + } + catch + { + // Unreadable / permission-denied shim - skip and try the next candidate. + } + } + + return result; + } + + /// + /// Locations an installed mlx_whisper console-script shim commonly lives: pipx and + /// "pip install --user" write it to ~/.local/bin; a Homebrew-managed Python writes it + /// into its own bin. The PATH is also scanned so a shim on the user's PATH is found when + /// Subtitle Edit is launched from a shell. + /// + private static IEnumerable GetCliShimCandidates() + { + var seen = new HashSet(StringComparer.Ordinal); + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(home)) + { + var localBin = Path.Combine(home, ".local", "bin", CliShimName); + if (seen.Add(localBin)) + { + yield return localBin; + } + } + + foreach (var dir in new[] { "/opt/homebrew/bin", "/usr/local/bin" }) + { + var shim = Path.Combine(dir, CliShimName); + if (seen.Add(shim)) + { + yield return shim; + } + } + + var pathEnv = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(pathEnv)) + { + yield break; + } + + foreach (var dir in pathEnv.Split(Path.PathSeparator)) + { + if (string.IsNullOrEmpty(dir)) + { + continue; + } + + string shim; + try + { + shim = Path.Combine(dir, CliShimName); + } + catch + { + continue; // malformed PATH entry (illegal characters) + } + + if (seen.Add(shim)) + { + yield return shim; + } + } + } + + 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, "MlxWhisperMac"); + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + return folder; + } + + public string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) + { + // mlx-whisper downloads models itself into the Hugging Face cache. + return new WhisperCTranslate2Model().ModelFolder; + } + + public string GetExecutable() + { + // The engine drives the mlx-whisper *library* through python3 (see the class summary), so the + // executable is the Python interpreter. Prefer the interpreter that actually has mlx-whisper + // importable (resolved during the install check) so detection and transcription stay in sync. + if (_resolvedPython != null) + { + return _resolvedPython; + } + + IsEngineInstalled(); + if (_resolvedPython != null) + { + return _resolvedPython; + } + + // Not installed in any probed interpreter; return a sensible default so the "pip3 install + // mlx-whisper" guidance points at a real interpreter. + return GetPythonCandidates().FirstOrDefault() ?? "python3"; + } + + /// + /// Extracts the bundled transcription helper script to the engine folder and returns its path. + /// The script is rewritten on every call so it always matches the running build. + /// + public string GetTranscribeScript() + { + var scriptPath = Path.Combine(GetAndCreateWhisperFolder(), TranscribeScriptName); + + var uri = new Uri($"avares://SubtitleEdit/Assets/SpeechToText/{TranscribeScriptName}"); + using var stream = AssetLoader.Open(uri); + using var fileStream = File.Create(scriptPath); + stream.CopyTo(fileStream); + + return scriptPath; + } + + public bool IsModelInstalled(WhisperModel model) + { + // mlx-whisper resolves models by Hugging Face repo and downloads them on first use, + // so any of the supported models can be used right away. + return true; + } + + public string GetModelForCmdLine(string modelName) + { + if (string.IsNullOrWhiteSpace(modelName)) + { + return ModelRepos["large-v3-turbo"]; + } + + // Allow passing a full Hugging Face repo id directly. + if (modelName.Contains('/')) + { + return modelName; + } + + return ModelRepos.TryGetValue(modelName, out var repo) ? repo : modelName; + } + + public async Task GetHelpText() + { + var assetName = $"{StaticName.Replace(" ", string.Empty)}.txt"; + var uri = new Uri($"avares://SubtitleEdit/Assets/SpeechToText/{assetName}"); + + try + { + await using var stream = AssetLoader.Open(uri); + using var reader = new StreamReader(stream); + var contents = await reader.ReadToEndAsync(); + return contents; + } + catch + { + return "MLX Whisper runs Apple's \"mlx-whisper\" Python library via python3.\n\n" + + "Install it with: pip3 install mlx-whisper\n\n" + + "Requires an Apple Silicon Mac. MLX runs Whisper on the GPU / Neural Engine, so it is\n" + + "typically much faster than CPU-only Whisper engines on Apple Silicon.\n\n" + + "Models (tiny, base, small, medium, large-v2, large-v3, large-v3-turbo) are MLX-format\n" + + "weights downloaded from Hugging Face (mlx-community) on first use.\n" + + "Tip: large-v3-turbo is multilingual and the best speed/quality pick."; + } + } + + public string GetWhisperModelDownloadFileName(WhisperModel whisperModel, string url) + { + return string.Empty; + } + + public bool CanBeDownloaded() + { + return false; + } +} diff --git a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineFactory.cs b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineFactory.cs index bd341529d25..aba1c887ce4 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 == MlxWhisperMac.StaticName) + { + return new MlxWhisperMac(); + } + if (staticName == Qwen3AsrCppEngine.StaticName) { return new Qwen3AsrCppEngine(); diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessingViewModel.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessingViewModel.cs index 7c704d31121..c8b04e759d3 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessingViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessingViewModel.cs @@ -17,6 +17,12 @@ public partial class SpeechToTextPostProcessingViewModel : ObservableObject [ObservableProperty] private bool _addPeriods; [ObservableProperty] private bool _changeUnderlineToColor; [ObservableProperty] private Color _changeUnderlineToColorColor; + [ObservableProperty] private bool _cueRebuild; + [ObservableProperty] private int _cueMaxChars; + [ObservableProperty] private double _cueMaxSeconds; + [ObservableProperty] private double _cueMaxCps; + [ObservableProperty] private string _vocabularyPrompt = string.Empty; + [ObservableProperty] private int _beamSize; public Window? Window { get; set; } diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessingWindow.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessingWindow.cs index 7d02629edeb..7bb3381f308 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessingWindow.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessingWindow.cs @@ -46,6 +46,27 @@ public SpeechToTextPostProcessingWindow(SpeechToTextPostProcessingViewModel vm) var checkChangeUnderlineToColor = UiUtil.MakeCheckBox(vm, nameof(SpeechToTextPostProcessingViewModel.ChangeUnderlineToColor)); var colorPickerUnderlineToColor = UiUtil.MakeColorPickerButton(_vm, nameof(_vm.ChangeUnderlineToColorColor)); + // Cue building for the MLX Whisper engine: + // subtitle cues rebuilt from word timestamps per the Netflix Timed Text Style + // Guide / BBC guideline limits, editable here instead of via command-line flags. + var labelCueBuilding = UiUtil.MakeLabel(Se.Language.Video.AudioToText.CueBuilding).WithBold(); + var labelCueRebuild = UiUtil.MakeTextBlock(Se.Language.Video.AudioToText.CueRebuild); + var checkCueRebuild = UiUtil.MakeCheckBox(vm, nameof(SpeechToTextPostProcessingViewModel.CueRebuild)); + var labelCueMaxChars = UiUtil.MakeTextBlock(Se.Language.Video.AudioToText.CueMaxChars); + var numericCueMaxChars = UiUtil.MakeNumericUpDownInt(20, 200, 84, 120, vm, nameof(SpeechToTextPostProcessingViewModel.CueMaxChars)); + var labelCueMaxSeconds = UiUtil.MakeTextBlock(Se.Language.Video.AudioToText.CueMaxSeconds); + var numericCueMaxSeconds = UiUtil.MakeNumericUpDownDouble(1, 15, 7, 120, vm, nameof(SpeechToTextPostProcessingViewModel.CueMaxSeconds)); + var labelCueMaxCps = UiUtil.MakeTextBlock(Se.Language.Video.AudioToText.CueMaxCps); + var numericCueMaxCps = UiUtil.MakeNumericUpDownDouble(5, 60, 20, 120, vm, nameof(SpeechToTextPostProcessingViewModel.CueMaxCps)); + var labelVocabularyPrompt = UiUtil.MakeTextBlock(Se.Language.Video.AudioToText.VocabularyPrompt); + var textBoxVocabularyPrompt = UiUtil.MakeTextBox(260, vm, nameof(SpeechToTextPostProcessingViewModel.VocabularyPrompt)); + var labelBeamSize = UiUtil.MakeTextBlock(Se.Language.Video.AudioToText.BeamSize); + var numericBeamSize = UiUtil.MakeNumericUpDownInt(0, 10, 5, 120, vm, nameof(SpeechToTextPostProcessingViewModel.BeamSize)); + var labelBeamSizeNote = UiUtil.MakeTextBlock(Se.Language.Video.AudioToText.BeamSizeNote); + labelBeamSizeNote.TextWrapping = Avalonia.Media.TextWrapping.Wrap; + labelBeamSizeNote.MaxWidth = 460; + labelBeamSizeNote.Opacity = 0.7; + var buttonPanel = UiUtil.MakeButtonBar( UiUtil.MakeButton(Se.Language.General.Ok, vm.OKCommand), UiUtil.MakeButton(Se.Language.General.Cancel, vm.CancelCommand) @@ -55,6 +76,14 @@ public SpeechToTextPostProcessingWindow(SpeechToTextPostProcessingViewModel vm) { 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) }, @@ -106,6 +135,33 @@ public SpeechToTextPostProcessingWindow(SpeechToTextPostProcessingViewModel vm) grid.Add(labelChangeUnderlineToColor, row, 0); grid.Add(checkChangeUnderlineToColor, row, 1); grid.Add(colorPickerUnderlineToColor, row, 2); + row++; + + grid.Add(labelCueBuilding, row, 0, 1, 3); + row++; + + grid.Add(labelCueRebuild, row, 0); + grid.Add(checkCueRebuild, row, 1); + row++; + + grid.Add(labelCueMaxChars, row, 0); + grid.Add(numericCueMaxChars, row, 1, 1, 2); + row++; + + grid.Add(labelCueMaxSeconds, row, 0); + grid.Add(numericCueMaxSeconds, row, 1, 1, 2); + row++; + + grid.Add(labelCueMaxCps, row, 0); + grid.Add(numericCueMaxCps, row, 1, 1, 2); + row++; + grid.Add(labelVocabularyPrompt, row, 0); + grid.Add(textBoxVocabularyPrompt, row, 1, 1, 2); + row++; + grid.Add(labelBeamSize, row, 0); + grid.Add(numericBeamSize, row, 1, 1, 2); + row++; + grid.Add(labelBeamSizeNote, row, 0, 1, 3); // The settings scroll while the OK/Cancel bar stays pinned, so the dialog // remains usable when the screen is too small for the full form (e.g. high diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs index 15d17709755..ceb59a4623e 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs @@ -248,6 +248,14 @@ public SpeechToTextViewModel(IWindowService windowService, IFileHelper fileHelpe Engines.Add(new WhisperEngineCTranslate2()); } + // MLX Whisper runs Whisper on the Apple GPU / Neural Engine and is arm64-only, so offer it + // only on Apple Silicon. + if (OperatingSystem.IsMacOS() && + RuntimeInformation.ProcessArchitecture == Architecture.Arm64) + { + Engines.Add(new MlxWhisperMac()); + } + Engines.Add(new WhisperEngineOpenAi()); // Add OpenAI Compatible STT engine (available on all platforms) @@ -2533,8 +2541,6 @@ private static bool IsModelEnglishOnly(WhisperModel model) model.Name == "distil-large-v3"; } - - [RelayCommand] private void ShowWebLink() { @@ -2941,6 +2947,12 @@ private async Task ShowPostProcessingSettings() viewModal.BreakSplitLongLines = Se.Settings.Tools.AudioToText.WhisperPostProcessingSplitLines; viewModal.ChangeUnderlineToColor = Se.Settings.Tools.AudioToText.WhisperPostProcessingChangeUnderlineToColor; viewModal.ChangeUnderlineToColorColor = Se.Settings.Tools.AudioToText.WhisperPostProcessingChangeUnderlineToColorColor.FromHexToColor(); + viewModal.CueRebuild = Se.Settings.Tools.AudioToText.WhisperCueRebuild; + viewModal.CueMaxChars = Se.Settings.Tools.AudioToText.WhisperCueMaxChars; + viewModal.CueMaxSeconds = Se.Settings.Tools.AudioToText.WhisperCueMaxSeconds; + viewModal.CueMaxCps = Se.Settings.Tools.AudioToText.WhisperCueMaxCps; + viewModal.VocabularyPrompt = Se.Settings.Tools.AudioToText.WhisperVocabularyPrompt; + viewModal.BeamSize = Se.Settings.Tools.AudioToText.WhisperBeamSize; }); if (vm.OkPressed) @@ -2954,10 +2966,15 @@ private async Task ShowPostProcessingSettings() Se.Settings.Tools.AudioToText.WhisperPostProcessingSplitLines = vm.BreakSplitLongLines; Se.Settings.Tools.AudioToText.WhisperPostProcessingChangeUnderlineToColor = vm.ChangeUnderlineToColor; Se.Settings.Tools.AudioToText.WhisperPostProcessingChangeUnderlineToColorColor = vm.ChangeUnderlineToColorColor.FromColorToHex(); + Se.Settings.Tools.AudioToText.WhisperCueRebuild = vm.CueRebuild; + Se.Settings.Tools.AudioToText.WhisperCueMaxChars = vm.CueMaxChars; + Se.Settings.Tools.AudioToText.WhisperCueMaxSeconds = vm.CueMaxSeconds; + Se.Settings.Tools.AudioToText.WhisperCueMaxCps = vm.CueMaxCps; + Se.Settings.Tools.AudioToText.WhisperVocabularyPrompt = vm.VocabularyPrompt ?? string.Empty; + Se.Settings.Tools.AudioToText.WhisperBeamSize = vm.BeamSize; } } - [RelayCommand] private async Task DownloadModel() { @@ -3150,6 +3167,27 @@ private async Task Transcribe() if (!engine.IsEngineInstalled()) { + if (engine is MlxWhisperMac) + { + // pip-managed engine - Subtitle Edit cannot download it. The message explains + // the detection model (SE looks for a Python that can import mlx_whisper, not a + // binary) and the pipx/venv/conda caveat, since a user who installed it that way + // otherwise hits "not found" with no idea why (#12209). + await MessageBox.Show( + Window!, + $"{engine.Name} not found", + "Subtitle Edit could not find a Python that can import the \"mlx_whisper\" package." + Environment.NewLine + + Environment.NewLine + + "If it is not installed yet, install it with:" + Environment.NewLine + + " pip3 install mlx-whisper" + Environment.NewLine + + Environment.NewLine + + "If you installed it with pipx, a virtual environment, or conda, Subtitle Edit finds it " + + "automatically only when the \"mlx_whisper\" command is on your PATH or at " + + "~/.local/bin/mlx_whisper (where pipx puts it). Check with:" + Environment.NewLine + + " which mlx_whisper"); + return; + } + if (engine is ICrispAsrEngine && Configuration.IsRunningOnWindows) { var crispVariant = await PromptCrispAsrWindowsVariantAsync(engine.Name); @@ -3386,7 +3424,6 @@ await EnsureAlignerModelDownloadedAsync(engine, crispMegaEngine.ForcedAlignerMod }); } - _videoFileName = _jobItems[0].InputVideoFileName; _videoInfo.TotalMilliseconds = _jobItems[0].MediaInfo.Duration.TotalMilliseconds; _videoInfo.TotalSeconds = _jobItems[0].MediaInfo.Duration.TotalSeconds; @@ -3747,6 +3784,106 @@ private Process GetWhisperProcess( return StartEngineProcess(exe, crispParams, dataReceivedHandler); } + // Cue-building settings for the helper-script engines, from the Whisper + // post-processing dialog. The helpers ignore unknown flags, but only these two + // engines understand them, so they are added only in their blocks below. + static string GetCueArgs() + { + var audioToText = Se.Settings.Tools.AudioToText; + + // Vocabulary prompt: Whisper's documented way to bias recognition toward + // names and technical terms. Quotes are stripped so the value stays a + // single command line argument. + var promptPart = string.Empty; + var vocabularyPrompt = (audioToText.WhisperVocabularyPrompt ?? string.Empty) + .Replace("\"", string.Empty).Trim(); + if (vocabularyPrompt.Length > 0) + { + promptPart = $" --initial-prompt \"{vocabularyPrompt}\""; + } + + if (audioToText.WhisperBeamSize > 1) + { + promptPart += $" --beam-size {audioToText.WhisperBeamSize}"; + } + + if (!audioToText.WhisperCueRebuild) + { + return promptPart + " --raw-segments"; + } + + return promptPart + + $" --max-cue-chars {audioToText.WhisperCueMaxChars}" + + $" --max-cue-duration {audioToText.WhisperCueMaxSeconds.ToString(CultureInfo.InvariantCulture)}" + + $" --max-cps {audioToText.WhisperCueMaxCps.ToString(CultureInfo.InvariantCulture)}"; + } + + if (engine is MlxWhisperMac mlxWhisperMac) + { + // mlx-whisper is a library, not a CLI, so we run a bundled helper script via python3. + // It writes ".srt" into the audio's folder, which GetResultFromSrt then + // picks up. MLX runs Whisper on the Apple GPU / Neural Engine. + var python = mlxWhisperMac.GetExecutable(); + var scriptPath = mlxWhisperMac.GetTranscribeScript(); + var outputDir = Path.GetDirectoryName(waveFileName) ?? string.Empty; + + var mlxLanguage = language; + if (mlxLanguage.Equals("english", StringComparison.OrdinalIgnoreCase)) + { + mlxLanguage = "en"; + } + + var languagePart = !string.IsNullOrWhiteSpace(mlxLanguage) && + !mlxLanguage.Equals("auto", StringComparison.OrdinalIgnoreCase) + ? $" --language {mlxLanguage}" + : string.Empty; + var taskPart = translate ? " --task translate" : string.Empty; + var mlxExtraArgs = engine.CommandLineParameter; + var extraPart = string.IsNullOrWhiteSpace(mlxExtraArgs) ? string.Empty : " " + mlxExtraArgs.Trim(); + + var mlxParameters = + $"\"{scriptPath}\" --audio \"{waveFileName}\" --model {mlxWhisperMac.GetModelForCmdLine(model)} " + + $"--output-format srt --output-dir \"{outputDir}\"{languagePart}{taskPart}{GetCueArgs()}{extraPart}"; + + Se.WriteToolsLog($"{python} {mlxParameters}"); + + var mlxProcess = new Process + { + StartInfo = new ProcessStartInfo(python, mlxParameters) + { + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + UseShellExecute = false, + } + }; + + mlxProcess.StartInfo.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8"; + mlxProcess.StartInfo.EnvironmentVariables["PYTHONUTF8"] = "1"; + mlxProcess.StartInfo.EnvironmentVariables["PYTHONUNBUFFERED"] = "1"; + + if (dataReceivedHandler != null) + { + mlxProcess.StartInfo.StandardOutputEncoding = Encoding.UTF8; + mlxProcess.StartInfo.StandardErrorEncoding = Encoding.UTF8; + mlxProcess.StartInfo.RedirectStandardOutput = true; + mlxProcess.StartInfo.RedirectStandardError = true; + mlxProcess.OutputDataReceived += dataReceivedHandler; + mlxProcess.ErrorDataReceived += dataReceivedHandler; + } + +#pragma warning disable CA1416 + mlxProcess.Start(); +#pragma warning restore CA1416 + + if (dataReceivedHandler != null) + { + mlxProcess.BeginOutputReadLine(); + mlxProcess.BeginErrorReadLine(); + } + + return mlxProcess; + } + var settings = Se.Settings.Tools.AudioToText; var args = engine.CommandLineParameter; var cppVulkanDevice = string.Empty; @@ -4444,6 +4581,16 @@ private void UpdateEngineStatusUi(ISpeechToTextEngine engine) // also the answer to issue #11022 (switch backend after the initial install). IsEngineSettingsButtonVisible = canDownload && isInstalled && IsSettingsCapable(engine); + if (engine is MlxWhisperMac && !isInstalled) + { + // pip-managed engine - Subtitle Edit cannot download it, so show install help instead. + // Kept to one line here; the blocking dialog on transcribe explains the pipx/venv + // detection caveat in full (#12209). + EngineDownloadHint = "mlx-whisper not found - install with \"pip3 install mlx-whisper\", or ensure the \"mlx_whisper\" command is on your PATH"; + IsEngineDownloadButtonVisible = false; + return; + } + if (!canDownload || isInstalled) { EngineDownloadHint = string.Empty; diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextWindow.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextWindow.cs index 4f9e7fe721d..c21ccec0b8f 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextWindow.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextWindow.cs @@ -882,6 +882,13 @@ private static DownloadDotStatus GetEngineDotStatus(ISpeechToTextEngine engine) { if (!engine.CanBeDownloaded()) { + // MLX Whisper is installed via pip rather than downloaded by Subtitle Edit, + // but it still has a local install state worth surfacing. + if (engine is MlxWhisperMac) + { + return engine.IsEngineInstalled() ? DownloadDotStatus.UpToDate : DownloadDotStatus.NotInstalled; + } + return DownloadDotStatus.None; // cloud / API engine - nothing to install } diff --git a/src/ui/Logic/Config/Language/Video/LanguageAudioToText.cs b/src/ui/Logic/Config/Language/Video/LanguageAudioToText.cs index 7826ff08988..a1696b839d7 100644 --- a/src/ui/Logic/Config/Language/Video/LanguageAudioToText.cs +++ b/src/ui/Logic/Config/Language/Video/LanguageAudioToText.cs @@ -32,6 +32,14 @@ public class LanguageAudioToText public string FixCasing { get; set; } public string AddPeriods { get; set; } public string ChangeUnderlineToColor { get; set; } + public string CueBuilding { get; set; } + public string CueRebuild { get; set; } + public string CueMaxChars { get; set; } + public string CueMaxSeconds { get; set; } + public string CueMaxCps { get; set; } + public string VocabularyPrompt { get; set; } + public string BeamSize { get; set; } + public string BeamSizeNote { get; set; } public string EngineSettings { get; set; } public string EngineSettingsSubtitle { get; set; } @@ -71,6 +79,14 @@ public LanguageAudioToText() FixCasing = "Fix casing"; AddPeriods = "Add periods"; ChangeUnderlineToColor = "Change underline to color"; + CueBuilding = "Cue building (MLX Whisper)"; + CueRebuild = "Rebuild cues from word timestamps"; + CueMaxChars = "Max characters per cue"; + CueMaxSeconds = "Max seconds per cue"; + CueMaxCps = "Max characters per second"; + VocabularyPrompt = "Vocabulary prompt (names, places, terms)"; + BeamSize = "Beam size"; + BeamSizeNote = "0 decodes fastest. 5 gives the best accuracy (like Faster Whisper) at roughly 20-30% more time. Values above 5 rarely help."; EngineSettings = "Speech-to-text engine settings"; EngineSettingsSubtitle = "Speech-to-text engine"; diff --git a/src/ui/Logic/Config/SeAudioToText.cs b/src/ui/Logic/Config/SeAudioToText.cs index 9b637b8d96d..2c3250b5ed4 100644 --- a/src/ui/Logic/Config/SeAudioToText.cs +++ b/src/ui/Logic/Config/SeAudioToText.cs @@ -43,6 +43,7 @@ public class SeAudioToText public string CommandLineParameterCppVulkan { get; set; } = string.Empty; public string CommandLineParameterConstMe { get; set; } = string.Empty; public string CommandLineParameterCTranslate2 { get; set; } = "--vad_filter True"; + public string CommandLineParameterMlxWhisperMac { get; set; } = string.Empty; public string CommandLineParameterPurfviewFasterWhisperXxl { get; set; } = "--standard"; public string CommandLineParameterOpenAi { get; set; } = string.Empty; public string CommandLineParameterQwen3AsrCpp { get; set; } = string.Empty; @@ -83,6 +84,15 @@ public class SeAudioToText public bool WhisperPostProcessingFixShortDuration { get; set; } = true; + // Cue building for the MLX Whisper helper script: + // rebuild cues from word timestamps per the Netflix Timed Text Style Guide / BBC + // subtitle guideline limits below, instead of writing Whisper's raw segments. + public bool WhisperCueRebuild { get; set; } = true; + public int WhisperCueMaxChars { get; set; } = 84; + public string WhisperVocabularyPrompt { get; set; } = string.Empty; + public int WhisperBeamSize { get; set; } = 5; + public double WhisperCueMaxSeconds { get; set; } = 7.0; + public double WhisperCueMaxCps { get; set; } = 20.0; public bool WhisperPostProcessingChangeUnderlineToColor { get; set; } public string WhisperPostProcessingChangeUnderlineToColorColor { get; set; } = Colors.Red.FromColorToHex(); public string WhisperCppVulkanGpuDevice { get; set; } = string.Empty; From e4e72975b61700d8fe740cac9c465463dad34e4a Mon Sep 17 00:00:00 2001 From: Ivandro Jao Date: Sat, 22 Aug 2026 10:34:32 +0100 Subject: [PATCH 004/380] Utilities: vectorize CountTagInText(string, char) on .NET 8+ The char overload re-entered string.IndexOf once per hit, so a quote-heavy dialog line cost ~5 ns per quote. On net8+ MemoryExtensions.Count(char) does the whole line in one vectorized pass; the netstandard2.1 build keeps the original loop behind #if. The string overload is left as is - Count(span) is the same IndexOf-then-advance loop internally and measured as a wash. Adds CountTagInTextBenchmarks (loop vs span over typical subtitle lines). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011dWU4gggfkBmGx5pBoTk4u --- src/libse/Common/Utilities.cs | 4 ++ tests/benchmarks/CountTagInTextBenchmarks.cs | 47 ++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 tests/benchmarks/CountTagInTextBenchmarks.cs diff --git a/src/libse/Common/Utilities.cs b/src/libse/Common/Utilities.cs index 94090fa9cf4..72241df0b6c 100644 --- a/src/libse/Common/Utilities.cs +++ b/src/libse/Common/Utilities.cs @@ -1567,6 +1567,9 @@ public static int CountTagInText(string text, string tag) public static int CountTagInText(string text, char tag) { +#if NET8_0_OR_GREATER + return text.AsSpan().Count(tag); +#else int count = 0; int index = text.IndexOf(tag); while (index >= 0) @@ -1580,6 +1583,7 @@ public static int CountTagInText(string text, char tag) index = text.IndexOf(tag, index + 1); } return count; +#endif } /// diff --git a/tests/benchmarks/CountTagInTextBenchmarks.cs b/tests/benchmarks/CountTagInTextBenchmarks.cs new file mode 100644 index 00000000000..b5926159c3f --- /dev/null +++ b/tests/benchmarks/CountTagInTextBenchmarks.cs @@ -0,0 +1,47 @@ +using BenchmarkDotNet.Attributes; +using Nikse.SubtitleEdit.Core.Common; + +namespace Nikse.SubtitleEdit.Benchmarks; + +/// +/// Utilities.CountTagInText(string, char): the net8+ build uses MemoryExtensions.Count (one +/// vectorized pass), the netstandard2.1 build keeps the original IndexOf loop, which re-enters +/// the search once per hit. "Loop" below is a verbatim copy of that netstandard path; "Span" +/// calls the real Utilities method (this host is net10.0). +/// Inputs are typical subtitle lines: a quote-heavy dialog line, a no-match line, and a long +/// ASSA-tagged line. +/// +[MemoryDiagnoser] +public class CountTagInTextBenchmarks +{ + private const string NoTags = "It was the best of times, it was the worst of times."; + private const string Quotes = "\"Are you coming?\" she asked.\r\n\"No,\" he said. \"I'll stay.\""; + private const string LongAssa = "{\\an8}{\\pos(10,20)}It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity."; + + [Benchmark(Baseline = true)] public int Loop_Quotes() => CountLoop(Quotes, '"'); + [Benchmark] public int Span_Quotes() => Utilities.CountTagInText(Quotes, '"'); + + [Benchmark] public int Loop_NoMatch() => CountLoop(NoTags, '"'); + [Benchmark] public int Span_NoMatch() => Utilities.CountTagInText(NoTags, '"'); + + [Benchmark] public int Loop_LongBraces() => CountLoop(LongAssa, '{'); + [Benchmark] public int Span_LongBraces() => Utilities.CountTagInText(LongAssa, '{'); + + // Verbatim copy of the netstandard2.1 branch of Utilities.CountTagInText(string, char). + private static int CountLoop(string text, char tag) + { + int count = 0; + int index = text.IndexOf(tag); + while (index >= 0) + { + count++; + if ((index + 1) == text.Length) + { + return count; + } + + index = text.IndexOf(tag, index + 1); + } + return count; + } +} From 65a2ff98dd603f36cd09c43d471bb2cf99463a99 Mon Sep 17 00:00:00 2001 From: bovirus <1262554+bovirus@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:35:33 +0200 Subject: [PATCH 005/380] Update Italian language --- src/ui/Assets/Languages/Italian.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ui/Assets/Languages/Italian.json b/src/ui/Assets/Languages/Italian.json index 70afffb27f4..4705ee0334e 100644 --- a/src/ui/Assets/Languages/Italian.json +++ b/src/ui/Assets/Languages/Italian.json @@ -1,7 +1,7 @@ { "title": "SubtitleEdit", - "version": "v. 5.2.0-beta20", - "translatedBy": "v. 21.08.2026 di bovirus e GitGianluc", + "version": "v. 5.2.0-beta21", + "translatedBy": "v. 22.08.2026 di bovirus e GitGianluc", "cultureName": "it-IT", "general": { "abort": "Annulla", @@ -1799,6 +1799,7 @@ "addFolderDotDotDot": "Aggiungi cartella...", "selectFolderToConvert": "Seleziona la cartella con i file da convertire", "includeSubfolders": "Quando aggiungi una cartella includi le sottocartelle", + "keepSourceFileTimestamp": "Usa data/ora file sorgente per i file destinazione", "scanningFolderX": "Scansione {0}..." }, "changeCasing": { @@ -2697,6 +2698,10 @@ "waveformMouseWheelStepMilliseconds": "{0} ms", "waveformSnapToShotChanges": "Aggancia ai cambi di inquadratura", "waveformSnapToFrames": "Aggancia ai fotogrammi", + "waveformSnapToShotChangesPixels": "Distanza snap durante trascinamento (pixel)", + "waveformSnapToShotChangeStartMaxSeconds": "Salto cambio scena più vicino: distanza iniziale massima (secondi)", + "waveformSnapToShotChangeEndMaxSeconds": "Salto cambio scena più vicino: distanza finale massima (secondi)", + "waveformSnapToShotChangeSameShotEndMaxSeconds": "Salto cambio scena più vicino: distanza finale massima quando l'inizio e la fine condividono un taglio (secondi)", "waveformShotChangesAutoGenerate": "Genera automaticamente cambi inquadratura", "waveformTextFontSize": "Dimensione font testo forma d'onda", "waveformTextFontBold": "Font testo forma d'onda in grassetto", @@ -3052,6 +3057,7 @@ "seekSilenceBack": "Cerca indietro il silenzio", "seekSilenceForward": "Cerca il silenzio in avanti", "setVideoPositionCurrentSubtitleStart": "Imposta la posizione video all'inizio della riga attuale", + "goToSubtitlePositionAndPause": "Vai alla posizione sottotitolo e metti in pausa", "setVideoPositionCurrentSubtitleEnd": "Imposta la posizione video alla fine della riga attuale", "toggleAudioTracks": "Attiva/disattiva tracce audio", "goToNextError": "Vai all'errore successivo", From 8751cfca3998c45feeabc18e893c41c1e154abd0 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sat, 22 Aug 2026 15:56:21 +0200 Subject: [PATCH 006/380] Remember the batch convert "Remove formatting" check boxes All seven of them - remove all / italic / bold / underline / font name / alignment / color - were back to unticked every time the batch convert window opened, so the function had to be reconfigured for every run and did nothing at all if the user forgot. The settings already existed: SeBatchConvert declares FormattingRemoveAll and its six siblings, and SE 4 saved and restored the same set. The SE 5 port never wired them - nothing read or wrote those properties, so they were dead storage and the view model's fields started at false every time. Load and save now handle them like the neighbouring batch convert settings. Note the deliberate name difference at the boundary: the view model calls it FormattingRemoveColors, the settings file FormattingRemoveColorTags. Which function tabs are ticked (ActiveFunctions) was always persisted and is untouched. Fixes #13996 Co-Authored-By: Claude Fable 5 --- .../BatchConvert/BatchConvertViewModel.cs | 17 +++ ...ConvertRemoveFormattingPersistenceTests.cs | 130 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 tests/UI/Features/Tools/BatchConvert/BatchConvertRemoveFormattingPersistenceTests.cs diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs index 51d5b302a0f..f3d729ab058 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs @@ -753,6 +753,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; @@ -889,6 +898,14 @@ private void LoadSettings() BridgeGapsMinGapMs = Se.Settings.Tools.BridgeGaps.MinGapMs; BridgeGapsPercentForLeft = Se.Settings.Tools.BridgeGaps.PercentForLeft; + 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; diff --git a/tests/UI/Features/Tools/BatchConvert/BatchConvertRemoveFormattingPersistenceTests.cs b/tests/UI/Features/Tools/BatchConvert/BatchConvertRemoveFormattingPersistenceTests.cs new file mode 100644 index 00000000000..76149487789 --- /dev/null +++ b/tests/UI/Features/Tools/BatchConvert/BatchConvertRemoveFormattingPersistenceTests.cs @@ -0,0 +1,130 @@ +using Avalonia.Headless.XUnit; +using Microsoft.Extensions.DependencyInjection; +using Nikse.SubtitleEdit; +using Nikse.SubtitleEdit.Features.Tools.BatchConvert; +using Nikse.SubtitleEdit.Logic.Config; +using System.Reflection; + +namespace UITests.Features.Tools.BatchConvert; + +/// +/// Batch convert's "Remove formatting" check boxes (issue #13996). SE 4 saved and restored all +/// seven of them; the SE 5 port declared the settings but never wired load or save, so every time +/// the window opened they were back to unticked and the function silently did nothing. +/// +/// Which function tabs are ticked is a separate setting (ActiveFunctions) and was never affected. +/// +public class BatchConvertRemoveFormattingPersistenceTests +{ + private static BatchConvertViewModel MakeViewModel() + { + var services = new ServiceCollection(); + services.AddSubtitleEditServices(); + using var provider = services.BuildServiceProvider(); + return provider.GetRequiredService(); + } + + private static void Invoke(BatchConvertViewModel vm, string method) => + typeof(BatchConvertViewModel) + .GetMethod(method, BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(vm, null); + + /// Restores whatever the seven settings held, so the test leaves no trace. + private sealed class FormattingSettingsScope : IDisposable + { + private readonly SeBatchConvert _saved = new() + { + 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, + FormattingRemoveColorTags = Se.Settings.Tools.BatchConvert.FormattingRemoveColorTags, + }; + + public void Dispose() + { + var s = Se.Settings.Tools.BatchConvert; + s.FormattingRemoveAll = _saved.FormattingRemoveAll; + s.FormattingRemoveItalic = _saved.FormattingRemoveItalic; + s.FormattingRemoveBold = _saved.FormattingRemoveBold; + s.FormattingRemoveUnderline = _saved.FormattingRemoveUnderline; + s.FormattingRemoveFontTags = _saved.FormattingRemoveFontTags; + s.FormattingRemoveAlignmentTags = _saved.FormattingRemoveAlignmentTags; + s.FormattingRemoveColorTags = _saved.FormattingRemoveColorTags; + } + } + + [AvaloniaFact] + public void TickedBoxes_SurviveSaveAndLoad() + { + using var _ = new FormattingSettingsScope(); + + var saver = MakeViewModel(); + saver.FormattingRemoveAll = true; + saver.FormattingRemoveItalic = true; + saver.FormattingRemoveBold = false; + saver.FormattingRemoveUnderline = true; + saver.FormattingRemoveFontTags = false; + saver.FormattingRemoveAlignmentTags = true; + saver.FormattingRemoveColors = true; + Invoke(saver, "SaveSettings"); + + // A freshly built view model starts from the stored settings, like reopening the window. + var loader = MakeViewModel(); + loader.FormattingRemoveAll = false; + loader.FormattingRemoveItalic = false; + loader.FormattingRemoveUnderline = false; + loader.FormattingRemoveAlignmentTags = false; + loader.FormattingRemoveColors = false; + Invoke(loader, "LoadSettings"); + + Assert.True(loader.FormattingRemoveAll); + Assert.True(loader.FormattingRemoveItalic); + Assert.False(loader.FormattingRemoveBold); + Assert.True(loader.FormattingRemoveUnderline); + Assert.False(loader.FormattingRemoveFontTags); + Assert.True(loader.FormattingRemoveAlignmentTags); + Assert.True(loader.FormattingRemoveColors); + } + + // The view model calls them "colors" and the settings file calls them "color tags"; a mapping + // that crossed wires here would be invisible in the round trip above if both ends were wrong. + [AvaloniaFact] + public void RemoveColors_MapsToTheColorTagsSetting() + { + using var _ = new FormattingSettingsScope(); + + var vm = MakeViewModel(); + vm.FormattingRemoveColors = true; + vm.FormattingRemoveFontTags = false; + Invoke(vm, "SaveSettings"); + + Assert.True(Se.Settings.Tools.BatchConvert.FormattingRemoveColorTags); + Assert.False(Se.Settings.Tools.BatchConvert.FormattingRemoveFontTags); + } + + [AvaloniaFact] + public void UntickedBoxes_StayUnticked() + { + using var _ = new FormattingSettingsScope(); + + var saver = MakeViewModel(); + saver.FormattingRemoveAll = false; + saver.FormattingRemoveItalic = false; + saver.FormattingRemoveBold = false; + saver.FormattingRemoveUnderline = false; + saver.FormattingRemoveFontTags = false; + saver.FormattingRemoveAlignmentTags = false; + saver.FormattingRemoveColors = false; + Invoke(saver, "SaveSettings"); + + var loader = MakeViewModel(); + loader.FormattingRemoveAll = true; + Invoke(loader, "LoadSettings"); + + Assert.False(loader.FormattingRemoveAll); + Assert.False(loader.FormattingRemoveColors); + } +} From 65e45ef4e2c6072a1546ba2787dcafff3e981649 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sat, 22 Aug 2026 16:03:35 +0200 Subject: [PATCH 007/380] Use the selected audio track in the sync windows Point sync, Point sync via other subtitle and the "Set sync point" dialog they both open each create their own mpv instance, and a new instance starts on the file's default audio track. So a user who picked the original track in Video > Audio tracks - to sync against it rather than against a dub - got the dubbed track back as soon as they opened a sync window. Visual Sync already had this fixed (#11952): carry the selected track id in, await the open, then set the track. The same shape is applied here, with the id plumbed main window -> point sync -> set sync point. Setting the track before the file is open is silently ignored, which is why the await matters. The "Open video file..." button inside the dialog goes through the same path, so a video picked there gets the track too. The parameter is optional and defaults to -1, meaning "no track picked - leave mpv on the file's default". Fixes #13995 Co-Authored-By: Claude Fable 5 --- src/ui/Features/Main/MainViewModel.cs | 4 +- .../Sync/PointSync/PointSyncViewModel.cs | 11 +- .../SetSyncPoint/SetSyncPointViewModel.cs | 35 +++++- .../PointSyncViaOtherViewModel.cs | 10 +- .../PointSync/SyncAudioTrackPlumbingTests.cs | 101 ++++++++++++++++++ 5 files changed, 149 insertions(+), 12 deletions(-) create mode 100644 tests/UI/Features/Sync/PointSync/SyncAudioTrackPlumbingTests.cs diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index a6d74a68b72..19181b02fe7 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -10513,7 +10513,7 @@ private async Task ShowPointSync() { var selectedItems = SubtitleGridSelectedItems.Cast().ToList(); var paragraphs = Subtitles.Select(p => new SubtitleLineViewModel(p)).ToList(); - vm.Initialize(paragraphs, selectedItems, _videoFileName ?? string.Empty, _subtitleFileName ?? string.Empty, MakeVideoPreviewSubtitleContext(), AudioVisualizer); + vm.Initialize(paragraphs, selectedItems, _videoFileName ?? string.Empty, _subtitleFileName ?? string.Empty, MakeVideoPreviewSubtitleContext(), AudioVisualizer, _audioTrack?.Id ?? -1); }); if (result.OkPressed) @@ -10547,7 +10547,7 @@ private async Task ShowPointSyncViaOther() var result = await ShowDialogAsync(vm => { var paragraphs = Subtitles.Select(p => new SubtitleLineViewModel(p)).ToList(); - vm.Initialize(paragraphs, _videoFileName ?? string.Empty, _subtitleFileName ?? string.Empty, MakeVideoPreviewSubtitleContext()); + vm.Initialize(paragraphs, _videoFileName ?? string.Empty, _subtitleFileName ?? string.Empty, MakeVideoPreviewSubtitleContext(), _audioTrack?.Id ?? -1); }); if (result.OkPressed) 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/PointSyncViaOther/PointSyncViaOtherViewModel.cs b/src/ui/Features/Sync/PointSyncViaOther/PointSyncViaOtherViewModel.cs index f305077f620..d5e05cf7827 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,8 +74,9 @@ 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(); @@ -231,7 +235,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. diff --git a/tests/UI/Features/Sync/PointSync/SyncAudioTrackPlumbingTests.cs b/tests/UI/Features/Sync/PointSync/SyncAudioTrackPlumbingTests.cs new file mode 100644 index 00000000000..84d96f6916a --- /dev/null +++ b/tests/UI/Features/Sync/PointSync/SyncAudioTrackPlumbingTests.cs @@ -0,0 +1,101 @@ +using Avalonia.Headless.XUnit; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Features.Sync.PointSync; +using Nikse.SubtitleEdit.Features.Sync.PointSync.SetSyncPoint; +using Nikse.SubtitleEdit.Features.Sync.PointSyncViaOther; +using Nikse.SubtitleEdit.Features.Sync.VisualSync; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Media; +using System.Reflection; + +namespace UITests.Features.Sync.PointSync; + +/// +/// Every sync window opens its own mpv instance, and a new instance starts on the file's default +/// audio track - so the track picked in the main window's Video > Audio tracks has to be carried in +/// and re-applied, or a dubbed track plays while the user syncs against the original. +/// +/// Visual Sync learned this in #11952; Point sync, Point sync via other subtitle and the Set sync +/// point dialog they both open did not, which is issue #13995. These tests pin the plumbing: the +/// track has to survive every hop from the main window down to the window that owns the player. +/// Whether mpv then honours it is covered by the same await-then-set order Visual Sync uses. +/// +public class SyncAudioTrackPlumbingTests +{ + private sealed class NullServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + private static SetSyncPointViewModel MakeSetSyncPointViewModel() + => new(new WindowService(new NullServiceProvider()), new FileHelper(), new VideoPreviewSubtitle(new MpvReloader(), new VlcReloader())); + + private static List OneLine() + => new() { new() { Text = "one", StartTime = TimeSpan.FromSeconds(1), EndTime = TimeSpan.FromSeconds(2) } }; + + private static int AudioTrackIdOf(object viewModel) => + (int)viewModel.GetType() + .GetField("_audioTrackId", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(viewModel)!; + + private static ParameterInfo AudioTrackParameterOf(Type type) + { + var initialize = type.GetMethods() + .Single(m => m.Name == "Initialize" && m.GetParameters().Any(p => p.Name == "audioTrackId")); + return initialize.GetParameters().Single(p => p.Name == "audioTrackId"); + } + + [Theory] + [InlineData(typeof(SetSyncPointViewModel))] + [InlineData(typeof(PointSyncViewModel))] + [InlineData(typeof(PointSyncViaOtherViewModel))] + [InlineData(typeof(VisualSyncViewModel))] + public void EverySyncWindow_TakesAnAudioTrack(Type viewModelType) + { + var parameter = AudioTrackParameterOf(viewModelType); + + Assert.Equal(typeof(int), parameter.ParameterType); + } + + // Optional and defaulting to -1 ("no track picked"), so the existing callers that have no + // track - and the tests that construct these view models directly - keep compiling and mean + // "leave mpv on the file's default". + [Theory] + [InlineData(typeof(SetSyncPointViewModel))] + [InlineData(typeof(PointSyncViewModel))] + [InlineData(typeof(PointSyncViaOtherViewModel))] + [InlineData(typeof(VisualSyncViewModel))] + public void TheAudioTrackParameter_IsOptionalAndDefaultsToNoTrack(Type viewModelType) + { + var parameter = AudioTrackParameterOf(viewModelType); + + Assert.True(parameter.IsOptional); + Assert.Equal(-1, parameter.DefaultValue); + } + + // The dialog that actually owns the player: the id has to reach its field, not just its + // signature. + [AvaloniaFact] + public void SetSyncPoint_KeepsTheAudioTrackItWasGiven() + { + var lines = OneLine(); + var vm = MakeSetSyncPointViewModel(); + + vm.Initialize(lines, lines[0], videoFileName: null, subtitleFileName: null, + previewContext: VideoPreviewSubtitleContext.Default, audioVisualizer: null, audioTrackId: 3); + + Assert.Equal(3, AudioTrackIdOf(vm)); + } + + [AvaloniaFact] + public void SetSyncPoint_WithoutATrack_MeansTheFilesDefault() + { + var lines = OneLine(); + var vm = MakeSetSyncPointViewModel(); + + vm.Initialize(lines, lines[0], videoFileName: null, subtitleFileName: null, + previewContext: VideoPreviewSubtitleContext.Default, audioVisualizer: null); + + Assert.Equal(-1, AudioTrackIdOf(vm)); + } +} From 36b258aa4e447aa8ef6ab824ee5725d9f20853b5 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sat, 22 Aug 2026 16:54:49 +0200 Subject: [PATCH 008/380] Keep the minimum gap when nudging a cue one frame "Move start one frame back" and "Move end one frame forward" only checked the nudged line's own end, so repeated presses walked the cue straight through its neighbour - overlapping it even with "Allow overlap (when moving/resizing)" turned off. SE 4 clamped: MoveStartCurrent stopped a backwards nudge at "previous end + minimum gap" rather than crossing it. The same clamp is restored here, and mirrored for the end moving forward. Three deliberate exemptions: - the KeepGapPrev / KeepGapNext variants, which carry the neighbour along and so have nothing to run into; - "Allow overlap (when moving/resizing)", which is the user asking for overlap - the waveform drag already reads it the same way; - lines that already overlap before the press. A one-frame nudge is not a repair tool, and jumping the cue forward to the gap would surprise. Fixes #13999 Co-Authored-By: Claude Fable 5 --- src/ui/Features/Main/MainViewModel.cs | 38 +++++ tests/UI/Features/Main/FrameNudgeGapTests.cs | 162 +++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 tests/UI/Features/Main/FrameNudgeGapTests.cs diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index a6d74a68b72..e4882cef202 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -12647,6 +12647,28 @@ private void MoveStartByFrames(int frames, bool keepGapPrevIfClose) } } + // Moving the start back must not run into the previous line (issue #13999). SE 4 clamped + // to "previous end + minimum gap" here; SE 5 checked only this line's own end, so repeated + // presses walked straight through the neighbour. The KeepGapPrev variant is exempt: it + // carries the previous line along, so there is nothing to run into. "Allow overlap (when + // moving/resizing)" is the user saying they want to - the waveform drag reads it the same + // way. + if (deltaMs < 0 && !prevIsClose && prev != null && !Se.Settings.Waveform.AllowOverlap) + { + var floorMs = prev.EndTime.TotalMilliseconds + gapMs; + if (newStartMs < floorMs) + { + // Already overlapping before this press (a nudge cannot be asked to repair that): + // leave it alone rather than jumping the cue forward to the gap. + if (s.StartTime.TotalMilliseconds <= floorMs) + { + return; + } + + newStartMs = floorMs; + } + } + s.SetStartTimeOnly(TimeSpan.FromMilliseconds(newStartMs)); if (prevIsClose && prev != null) @@ -12699,6 +12721,22 @@ private void MoveEndByFrames(int frames, bool keepGapNextIfClose) } } + // Symmetric counterpart of the clamp in MoveStartByFrames (issue #13999): moving the end + // forward must not run into the next line. + if (deltaMs > 0 && !nextIsClose && next != null && !Se.Settings.Waveform.AllowOverlap) + { + var ceilingMs = next.StartTime.TotalMilliseconds - gapMs; + if (newEndMs > ceilingMs) + { + if (s.EndTime.TotalMilliseconds >= ceilingMs) + { + return; + } + + newEndMs = ceilingMs; + } + } + s.EndTime = TimeSpan.FromMilliseconds(newEndMs); if (nextIsClose && next != null) diff --git a/tests/UI/Features/Main/FrameNudgeGapTests.cs b/tests/UI/Features/Main/FrameNudgeGapTests.cs new file mode 100644 index 00000000000..62e4e7a7a10 --- /dev/null +++ b/tests/UI/Features/Main/FrameNudgeGapTests.cs @@ -0,0 +1,162 @@ +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; +using Nikse.SubtitleEdit.Logic.Config; +using System; +using System.Collections.Generic; + +namespace UITests.Features.Main; + +/// +/// "Move start/end one frame back/forward" and the minimum gap (issue #13999). +/// +/// SE 4's MoveStartCurrent clamped a backwards nudge to "previous end + minimum gap"; the SE 5 port +/// checked only the nudged line's own end, so repeated presses walked the cue straight through its +/// neighbour and produced overlaps even with "Allow overlap (when moving/resizing)" off. +/// +/// The KeepGapPrev / KeepGapNext variants are deliberately exempt - they carry the neighbour along, +/// so there is nothing to run into. +/// +public class FrameNudgeGapTests : IDisposable +{ + private readonly List _windows = new(); + private readonly bool _allowOverlap = Se.Settings.Waveform.AllowOverlap; + private readonly double _frameRate = Se.Settings.General.CurrentFrameRate; + + public void Dispose() + { + Se.Settings.Waveform.AllowOverlap = _allowOverlap; + Se.Settings.General.CurrentFrameRate = _frameRate; + foreach (var w in _windows) + { + w.Close(); + } + } + + private (Window Window, MainViewModel Vm) CreateMainViewModel() + { + 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); + } + + /// Two lines, the second starting after the first ends. + private (Window Window, MainViewModel Vm) TwoLines(int gapMs, bool allowOverlap = false) + { + Se.Settings.Waveform.AllowOverlap = allowOverlap; + Se.Settings.General.CurrentFrameRate = 25; // one frame = 40 ms + + var (window, vm) = CreateMainViewModel(); + vm.Subtitles.Add(new SubtitleLineViewModel(new Paragraph("one", 1000, 3000), null!) { Number = 1 }); + vm.Subtitles.Add(new SubtitleLineViewModel(new Paragraph("two", 3000 + gapMs, 6000), null!) { Number = 2 }); + Dispatcher.UIThread.RunJobs(); + return (window, vm); + } + + private static double MinGapMs => Se.Settings.General.MinimumBetweenLines.GetMilliseconds(); + + [AvaloniaFact] + public void MoveStartBack_StopsAtTheMinimumGap() + { + var (window, vm) = TwoLines(gapMs: 1000); + vm.SelectedSubtitle = vm.Subtitles[1]; + Dispatcher.UIThread.RunJobs(); + + var floor = vm.Subtitles[0].EndTime.TotalMilliseconds + MinGapMs; + for (var i = 0; i < 60; i++) // far more presses than the 1000 ms gap allows + { + vm.MoveStartOneFrameBackCommand.Execute(null); + } + + Assert.True(vm.Subtitles[1].StartTime.TotalMilliseconds >= floor - 0.001, + $"start {vm.Subtitles[1].StartTime.TotalMilliseconds} went past the floor {floor}"); + Assert.True(vm.Subtitles[1].StartTime.TotalMilliseconds > vm.Subtitles[0].EndTime.TotalMilliseconds); + window.Close(); + } + + [AvaloniaFact] + public void MoveEndForward_StopsAtTheMinimumGap() + { + var (window, vm) = TwoLines(gapMs: 1000); + vm.SelectedSubtitle = vm.Subtitles[0]; + Dispatcher.UIThread.RunJobs(); + + var ceiling = vm.Subtitles[1].StartTime.TotalMilliseconds - MinGapMs; + for (var i = 0; i < 60; i++) + { + vm.MoveEndOneFrameForwardCommand.Execute(null); + } + + Assert.True(vm.Subtitles[0].EndTime.TotalMilliseconds <= ceiling + 0.001, + $"end {vm.Subtitles[0].EndTime.TotalMilliseconds} went past the ceiling {ceiling}"); + Assert.True(vm.Subtitles[0].EndTime.TotalMilliseconds < vm.Subtitles[1].StartTime.TotalMilliseconds); + window.Close(); + } + + // A single press well clear of the neighbour must still move a whole frame - the clamp must not + // quietly round every nudge to the gap. + [AvaloniaFact] + public void MoveStartBack_AwayFromTheNeighbour_MovesAFullFrame() + { + var (window, vm) = TwoLines(gapMs: 1000); + vm.SelectedSubtitle = vm.Subtitles[1]; + Dispatcher.UIThread.RunJobs(); + + var before = vm.Subtitles[1].StartTime.TotalMilliseconds; + vm.MoveStartOneFrameBackCommand.Execute(null); + + Assert.Equal(before - 40, vm.Subtitles[1].StartTime.TotalMilliseconds, 3); + window.Close(); + } + + [AvaloniaFact] + public void MoveStartBack_WithAllowOverlapOn_IsNotClamped() + { + var (window, vm) = TwoLines(gapMs: 1000, allowOverlap: true); + vm.SelectedSubtitle = vm.Subtitles[1]; + Dispatcher.UIThread.RunJobs(); + + for (var i = 0; i < 60; i++) + { + vm.MoveStartOneFrameBackCommand.Execute(null); + } + + // The user asked for overlap, so the cue is free to cross the previous line's end. + Assert.True(vm.Subtitles[1].StartTime.TotalMilliseconds < vm.Subtitles[0].EndTime.TotalMilliseconds); + window.Close(); + } + + // Lines that already overlap are left alone: a one-frame nudge is not a repair tool, and + // jumping the cue forward to the gap would be a surprise. + [AvaloniaFact] + public void MoveStartBack_WhenAlreadyOverlapping_DoesNothing() + { + var (window, vm) = TwoLines(gapMs: -500); + vm.SelectedSubtitle = vm.Subtitles[1]; + Dispatcher.UIThread.RunJobs(); + + var before = vm.Subtitles[1].StartTime.TotalMilliseconds; + vm.MoveStartOneFrameBackCommand.Execute(null); + + Assert.Equal(before, vm.Subtitles[1].StartTime.TotalMilliseconds, 3); + window.Close(); + } +} From 6ffff21346806d80bc676c6cc345b601a4551e27 Mon Sep 17 00:00:00 2001 From: Alain Date: Sat, 22 Aug 2026 17:13:16 +0200 Subject: [PATCH 009/380] Burn-in: use display dimensions for rotated video ffmpeg reports the stored frame size on the "Stream #" line. Phone recordings are commonly stored landscape with a display matrix rotation of +/-90 degrees that makes them portrait on playback, and ffmpeg auto-rotates on decode, so every filter and encoder sees the rotated frames. Both ffmpeg media-info parsers ignored that side data, so burn-in sized the output to the unrotated frame: a video stored 1024x576 and displayed 576x1024 produced "-vf scale=1024:576 ... -s 1024x576", stretching the portrait picture into landscape. The generated ASS PlayRes was sized from the same values, so the subtitles were distorted along with it. Read the "Display Matrix: rotation of N degrees" side data that follows the video stream line and swap width/height on a quarter turn, in both FfmpegMediaInfo (which burn-in sizes the encode from) and FfmpegMediaInfo2 (which fills the burn-in resolution fields). Videos without rotation side data are unaffected. Also label the two burn-in resolution fields W and H - they were an unlabelled pair either side of an "x". --- src/libuilogic/Media/FfmpegMediaInfo.cs | 37 +++++++++++++++++++- src/ui/Features/Video/BurnIn/BurnInWindow.cs | 4 +++ src/ui/Logic/Media/FfmpegMediainfo2.cs | 33 +++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/libuilogic/Media/FfmpegMediaInfo.cs b/src/libuilogic/Media/FfmpegMediaInfo.cs index 5219df09d98..942d9ea89f0 100644 --- a/src/libuilogic/Media/FfmpegMediaInfo.cs +++ b/src/libuilogic/Media/FfmpegMediaInfo.cs @@ -39,6 +39,13 @@ public partial class FfmpegMediaInfo private static partial Regex StreamIndexRegexGen(); private static readonly Regex StreamIndexRegex = StreamIndexRegexGen(); + // Rotation side data ffmpeg prints under a video stream, e.g. + // " Side data:" + // " Display Matrix: rotation of -90.00 degrees". + [GeneratedRegex(@"^Display Matrix: rotation of (?-?\d+(?:\.\d+)?) degrees")] + private static partial Regex DisplayMatrixRotationRegexGen(); + private static readonly Regex DisplayMatrixRotationRegex = DisplayMatrixRotationRegexGen(); + private FfmpegMediaInfo() { Tracks = new List(); @@ -123,11 +130,22 @@ internal static FfmpegMediaInfo ParseLog(string log) } } + // 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 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) { @@ -137,7 +155,8 @@ internal static FfmpegMediaInfo ParseLog(string log) int.TryParse(parts[0], out var w) && int.TryParse(parts[1], out var h)) { - info.Dimension = new Dimension(w, h); + info.Dimension = new Dimension(w, h); + inDimensionStreamBlock = true; } } @@ -170,6 +189,22 @@ internal static FfmpegMediaInfo 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 a 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/Features/Video/BurnIn/BurnInWindow.cs b/src/ui/Features/Video/BurnIn/BurnInWindow.cs index 1cd58b48054..65d9322bd74 100644 --- a/src/ui/Features/Video/BurnIn/BurnInWindow.cs +++ b/src/ui/Features/Video/BurnIn/BurnInWindow.cs @@ -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/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) From aac1680f252e33161d6aecaca23b051d00cc2910 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sat, 22 Aug 2026 18:46:31 +0200 Subject: [PATCH 010/380] Pin the settings the frame-nudge gap tests depend on The new tests read the minimum gap from ambient settings, so another test in the same run could change the answer. They passed locally and failed on CI, where the gap came out as zero: with no gap, a clamped cue lands exactly on its neighbour's edge, and asserting a strict "start > previous end" is then wrong - touching is not overlapping. Two fixes: - pin everything the clamp reads: MinimumBetweenLines, UseFrameMode (it decides whether the gap is read in ms or frames), and the frame rate, which has two independent homes - Se.Settings and the libse Configuration. - state the contract as "no overlap" (>= / <=) rather than a strict inequality, so a zero gap is still a pass. Verified both ways: green with the 24 ms gap and with the gap forced to zero, which is what CI hit. Co-Authored-By: Claude Fable 5 --- tests/UI/Features/Main/FrameNudgeGapTests.cs | 31 +++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/tests/UI/Features/Main/FrameNudgeGapTests.cs b/tests/UI/Features/Main/FrameNudgeGapTests.cs index 62e4e7a7a10..8e4f7d95223 100644 --- a/tests/UI/Features/Main/FrameNudgeGapTests.cs +++ b/tests/UI/Features/Main/FrameNudgeGapTests.cs @@ -24,14 +24,26 @@ namespace UITests.Features.Main; /// public class FrameNudgeGapTests : IDisposable { + private const int GapMs = 24; + private const double Fps = 25; + private const double OneFrameMs = 1000.0 / Fps; + private readonly List _windows = new(); private readonly bool _allowOverlap = Se.Settings.Waveform.AllowOverlap; private readonly double _frameRate = Se.Settings.General.CurrentFrameRate; + private readonly double _coreFrameRate = Configuration.Settings.General.CurrentFrameRate; + private readonly bool _useFrameMode = Se.Settings.General.UseFrameMode; + private readonly int _minBetweenMs = Se.Settings.General.MinimumBetweenLines.Milliseconds; + private readonly int _minBetweenFrames = Se.Settings.General.MinimumBetweenLines.Frames; public void Dispose() { Se.Settings.Waveform.AllowOverlap = _allowOverlap; Se.Settings.General.CurrentFrameRate = _frameRate; + Configuration.Settings.General.CurrentFrameRate = _coreFrameRate; + Se.Settings.General.UseFrameMode = _useFrameMode; + Se.Settings.General.MinimumBetweenLines.Milliseconds = _minBetweenMs; + Se.Settings.General.MinimumBetweenLines.Frames = _minBetweenFrames; foreach (var w in _windows) { w.Close(); @@ -61,8 +73,16 @@ public void Dispose() /// Two lines, the second starting after the first ends. private (Window Window, MainViewModel Vm) TwoLines(int gapMs, bool allowOverlap = false) { + // Everything the clamp reads is pinned here. The minimum gap comes from + // MinimumBetweenLines *and* UseFrameMode, and the frame rate has two independent homes + // (Se.Settings and the libse Configuration) - leaving any of them to whatever another test + // in the run happened to set makes this test order-dependent, which is how it passed + // locally and failed on CI. Se.Settings.Waveform.AllowOverlap = allowOverlap; - Se.Settings.General.CurrentFrameRate = 25; // one frame = 40 ms + Se.Settings.General.CurrentFrameRate = Fps; // one frame = 40 ms + Configuration.Settings.General.CurrentFrameRate = Fps; + Se.Settings.General.UseFrameMode = false; + Se.Settings.General.MinimumBetweenLines.Milliseconds = GapMs; var (window, vm) = CreateMainViewModel(); vm.Subtitles.Add(new SubtitleLineViewModel(new Paragraph("one", 1000, 3000), null!) { Number = 1 }); @@ -73,6 +93,7 @@ public void Dispose() private static double MinGapMs => Se.Settings.General.MinimumBetweenLines.GetMilliseconds(); + [AvaloniaFact] public void MoveStartBack_StopsAtTheMinimumGap() { @@ -88,7 +109,8 @@ public void MoveStartBack_StopsAtTheMinimumGap() Assert.True(vm.Subtitles[1].StartTime.TotalMilliseconds >= floor - 0.001, $"start {vm.Subtitles[1].StartTime.TotalMilliseconds} went past the floor {floor}"); - Assert.True(vm.Subtitles[1].StartTime.TotalMilliseconds > vm.Subtitles[0].EndTime.TotalMilliseconds); + Assert.True(vm.Subtitles[1].StartTime.TotalMilliseconds >= vm.Subtitles[0].EndTime.TotalMilliseconds, + "the nudged start overlapped the previous line"); window.Close(); } @@ -107,7 +129,8 @@ public void MoveEndForward_StopsAtTheMinimumGap() Assert.True(vm.Subtitles[0].EndTime.TotalMilliseconds <= ceiling + 0.001, $"end {vm.Subtitles[0].EndTime.TotalMilliseconds} went past the ceiling {ceiling}"); - Assert.True(vm.Subtitles[0].EndTime.TotalMilliseconds < vm.Subtitles[1].StartTime.TotalMilliseconds); + Assert.True(vm.Subtitles[0].EndTime.TotalMilliseconds <= vm.Subtitles[1].StartTime.TotalMilliseconds, + "the nudged end overlapped the next line"); window.Close(); } @@ -123,7 +146,7 @@ public void MoveStartBack_AwayFromTheNeighbour_MovesAFullFrame() var before = vm.Subtitles[1].StartTime.TotalMilliseconds; vm.MoveStartOneFrameBackCommand.Execute(null); - Assert.Equal(before - 40, vm.Subtitles[1].StartTime.TotalMilliseconds, 3); + Assert.Equal(before - OneFrameMs, vm.Subtitles[1].StartTime.TotalMilliseconds, 3); window.Close(); } From fc7d144e4cff2070a6c351c1a230fd69fb980c94 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sat, 22 Aug 2026 20:46:10 +0200 Subject: [PATCH 011/380] Hold the scroll anchor still while the reference projection rebuilds Merging lines with a read-only original open still scrolled the grid slowly up and back down after the #13962 fix, which addressed the focus and selection half of that report. The rebuild rewrites the row collection one row at a time - display-only rows are removed, brought back and glided into order. Every one of those raises a collection change, the virtualizing panel re-estimates its extent, and TableViewScrollAnchor reads that as "the panel moved under the view" and answers with a full restore: PrePositionScroll, a ScrollIntoView and up to three synchronous UpdateLayout passes, plus an O(n) IndexOf. A merge that brings a handful of reference rows back paid for a handful of those in a row, and the grid was unusable until they finished. The anchor now stays suspended for the whole rebuild. It keeps following the view - Suspend only stops it *moving* the view - so the burst settles once at the end instead of N times. TableViewIndexScrollBar already uses Suspend for the same reason: deliberate multi-pass scrolling that an anchor restore would fight. Fixes #14003 Co-Authored-By: Claude Fable 5 --- src/ui/Features/Main/MainViewModel.cs | 14 ++ .../Main/ReferenceRebuildScrollAnchorTests.cs | 163 ++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 tests/UI/Features/Main/ReferenceRebuildScrollAnchorTests.cs diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index a6d74a68b72..f8bc78fe970 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -2816,6 +2816,20 @@ private void ReapplyOriginalReference(bool capture = true) var original = _subtitleOriginal; _reapplyingReadOnlyReference = true; + + // The rebuild below rewrites the row collection one row at a time - display-only rows are + // removed, re-inserted and glided back into order. Every one of those raises a collection + // change, the virtualizing panel re-estimates its extent, and TableViewScrollAnchor reads + // that as "the panel moved under the view" and restores the anchor: PrePositionScroll, a + // ScrollIntoView and up to three synchronous UpdateLayout passes, each time. A merge that + // brings a handful of reference rows back therefore paid for that handful of full restores + // in a row, which is the slow scroll up and back down after merging (issues #13962, + // #14003). Suspending the anchor for the whole rebuild leaves it following the view - it + // just stops moving it - so the burst costs one settle at the end instead of N. + using var anchorSuspended = SubtitleGrid is { } grid + ? TableViewScrollAnchor.GetFor(grid)?.Suspend() + : null; + try { // Which original line each id belongs to. Ids are unique per file; claimed lines are diff --git a/tests/UI/Features/Main/ReferenceRebuildScrollAnchorTests.cs b/tests/UI/Features/Main/ReferenceRebuildScrollAnchorTests.cs new file mode 100644 index 00000000000..6db061651b4 --- /dev/null +++ b/tests/UI/Features/Main/ReferenceRebuildScrollAnchorTests.cs @@ -0,0 +1,163 @@ +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.Features.Main.MainHelpers; +using Nikse.SubtitleEdit.Logic; +using System; +using System.Collections.Specialized; +using System.Reflection; + +namespace UITests.Features.Main; + +/// +/// Rebuilding the read-only reference projection churns the row collection: display-only rows are +/// removed, brought back and glided into order, one row at a time. Each of those makes the +/// virtualizing panel re-estimate its extent, which reads as +/// "the panel moved under the view" and answers with a full restore - PrePositionScroll, a +/// ScrollIntoView and up to three synchronous layout passes. +/// +/// Paying that per row is the slow scroll up and back down after merging that survived the #13962 +/// fix and was reported again as #14003. The rebuild therefore suspends the anchor for its whole +/// run: it keeps following the view, it just stops moving it, so the burst settles once at the end. +/// +public class ReferenceRebuildScrollAnchorTests +{ + [AvaloniaFact] + public void ReferenceRebuild_SuspendsTheScrollAnchorWhileTheRowsChurn() + { + var (window, vm) = CreateMainViewModel(); + try + { + AddLine(vm, "Translated one", "Reference one", 0, 2000); + AddLine(vm, "Translated two", "Reference two", 4000, 6000); + vm.ShowColumnOriginalText = true; + ImportReference(vm, BuildSampleReference()); + Dispatcher.UIThread.RunJobs(); + + var anchor = TableViewScrollAnchor.GetFor(vm.SubtitleGrid); + Assert.NotNull(anchor); + + // Watch the suspend count at the exact moment the rows move. + var sawChurn = false; + var suspendedThroughout = true; + void OnChanged(object? _, NotifyCollectionChangedEventArgs __) + { + sawChurn = true; + if (SuspendCountOf(anchor!) == 0) + { + suspendedThroughout = false; + } + } + + // Drop a row the reference still has a line for, so the rebuild has to bring it back + // as a display-only row - that re-insert is the churn under test. Done before the + // handler is attached: this removal is the test's own setup, not part of the rebuild. + vm.Subtitles.RemoveAt(1); + + vm.Subtitles.CollectionChanged += OnChanged; + try + { + InvokeReapplyOriginalReference(vm); + } + finally + { + vm.Subtitles.CollectionChanged -= OnChanged; + } + + Assert.True(sawChurn, "the rebuild did not touch the row collection, so nothing was measured"); + Assert.True(suspendedThroughout, "the scroll anchor was live while the reference rebuild moved rows"); + } + finally + { + window.Close(); + } + } + + // The suspension must not leak: once the rebuild is done the anchor has to be doing its job + // again, or ordinary editing stops holding the view steady (#13619). + [AvaloniaFact] + public void ReferenceRebuild_ReleasesTheAnchorAfterwards() + { + var (window, vm) = CreateMainViewModel(); + try + { + AddLine(vm, "Translated one", "Reference one", 0, 2000); + AddLine(vm, "Translated two", "Reference two", 4000, 6000); + vm.ShowColumnOriginalText = true; + ImportReference(vm, BuildSampleReference()); + Dispatcher.UIThread.RunJobs(); + + var anchor = TableViewScrollAnchor.GetFor(vm.SubtitleGrid); + Assert.NotNull(anchor); + + vm.Subtitles.RemoveAt(1); + InvokeReapplyOriginalReference(vm); + InvokeReapplyOriginalReference(vm); // twice: a leak would stack + + Assert.Equal(0, SuspendCountOf(anchor!)); + } + finally + { + window.Close(); + } + } + + private static int SuspendCountOf(TableViewScrollAnchor anchor) => + (int)typeof(TableViewScrollAnchor) + .GetField("_suspendCount", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(anchor)!; + + private static void InvokeReapplyOriginalReference(MainViewModel vm) => + typeof(MainViewModel) + .GetMethod("ReapplyOriginalReference", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(vm, new object?[] { true }); + + private static void ImportReference(MainViewModel vm, Subtitle reference) + { + var match = ImportOriginalHelper.MatchOriginalLines(vm.Subtitles, reference); + typeof(MainViewModel) + .GetMethod("ImportOriginalSubtitle", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(vm, new object?[] { 0, "reference.srt", reference, match, true }); + } + + private static Subtitle BuildSampleReference() + { + var reference = new Subtitle(); + reference.Paragraphs.Add(new Paragraph("Reference one", 0, 2000)); + reference.Paragraphs.Add(new Paragraph("Reference only - no translation", 2000, 4000)); + reference.Paragraphs.Add(new Paragraph("Reference two", 4000, 6000)); + return reference; + } + + 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 (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!); + } +} From b50fcc1a0fb01350b668144dcb9c6fffeb5d2ed1 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sat, 22 Aug 2026 23:06:55 +0200 Subject: [PATCH 012/380] Fix #14005: give batch convert its own llama.cpp "Use external server" toggle Batch convert used to read the Auto-translate window's LlamaCppUseRemoteServer flag, so checking it in the main window silently switched batch runs to the external server too. Batch convert now has its own checkbox and setting (SeBatchConvert.LlamaCppUseRemoteServer); the server URL stays shared. In local mode the URL field is hidden (the managed server owns it), matching the Auto-translate window; in external mode the URL from the text box is used. Co-Authored-By: Claude Fable 5 --- .../BatchConvert/BatchConvertViewModel.cs | 66 ++++++++++++++----- .../FunctionViews/ViewAutoTranslate.cs | 9 ++- src/ui/Logic/Config/SeBatchConvert.cs | 7 ++ 3 files changed, 64 insertions(+), 18 deletions(-) diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertViewModel.cs index 51d5b302a0f..584b241e5a2 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 @@ -561,7 +563,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 +668,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) @@ -1444,11 +1447,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; } @@ -2880,13 +2883,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; } } @@ -3076,6 +3084,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 +3107,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 +3120,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); } @@ -3122,6 +3148,10 @@ internal void OnAutoTranslatorChanged() 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; @@ -3179,12 +3209,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) 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/Logic/Config/SeBatchConvert.cs b/src/ui/Logic/Config/SeBatchConvert.cs index 3c60fee6617..40d94d917a9 100644 --- a/src/ui/Logic/Config/SeBatchConvert.cs +++ b/src/ui/Logic/Config/SeBatchConvert.cs @@ -85,6 +85,13 @@ public class SeBatchConvert 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; } From 69f72f1901d16f1bef06ee2489e5693862dd08f6 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sat, 22 Aug 2026 23:09:37 +0200 Subject: [PATCH 013/380] Fix #14004: retry Google Translate V1 on transient 5xx/429 responses The free gtx endpoint intermittently answers an HTML "Error 500" page mid-run on long translations; one such reply aborted the whole job and the user had to restart it by hand. Google V1 was the only engine without the shared retry loop - give it one, extended to 500/502/504 (DeepLTranslate.ShouldRetry only covers 429/503), with 1/3/7 s backoff. Co-Authored-By: Claude Fable 5 --- .../AutoTranslate/GoogleTranslateV1.cs | 35 +++++++++++++++++-- .../GoogleTranslateV1RetryTests.cs | 27 ++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 tests/libuilogic/AutoTranslate/GoogleTranslateV1RetryTests.cs diff --git a/src/libuilogic/AutoTranslate/GoogleTranslateV1.cs b/src/libuilogic/AutoTranslate/GoogleTranslateV1.cs index 1d64b107da2..266e3720e31 100644 --- a/src/libuilogic/AutoTranslate/GoogleTranslateV1.cs +++ b/src/libuilogic/AutoTranslate/GoogleTranslateV1.cs @@ -55,9 +55,26 @@ public async Task Translate(string input, string sourceLanguageCode, str var text = input.Replace("\r", string.Empty).Trim(); var url = $"translate_a/single?client=gtx&sl={sourceLanguageCode}&tl={targetLanguageCode}&dt=t&q={Utilities.UrlEncode(text)}"; - var result = await _httpClient.GetAsync(url, cancellationToken); - var bytes = await result.Content.ReadAsByteArrayAsync(cancellationToken); - jsonResultString = Encoding.UTF8.GetString(bytes).Trim(); + // The free "gtx" endpoint intermittently answers 500/502/503/504 (and 429) mid-run + // on long translations; a single failure aborted the whole job and the user had to + // restart it by hand (issue #14004). Retry with a short backoff before giving up. + int[] retryDelays = { 1007, 3013, 7019 }; + HttpResponseMessage result = null!; + jsonResultString = string.Empty; + for (var attempt = 0; attempt <= retryDelays.Length; attempt++) + { + result = await _httpClient.GetAsync(url, cancellationToken); + var bytes = await result.Content.ReadAsByteArrayAsync(cancellationToken); + jsonResultString = Encoding.UTF8.GetString(bytes).Trim(); + + if (!ShouldRetry(result, jsonResultString) || attempt == retryDelays.Length) + { + break; + } + + SeLogger.Error($"{StaticName} returned {(int)result.StatusCode} ({result.StatusCode}) - retrying in {retryDelays[attempt]} ms (attempt {attempt + 1} of {retryDelays.Length})"); + await Task.Delay(retryDelays[attempt], cancellationToken); + } if (!result.IsSuccessStatusCode) { @@ -75,6 +92,18 @@ public async Task Translate(string input, string sourceLanguageCode, str return string.Join(Environment.NewLine, resultList); } + /// + /// Transient server-side failures worth retrying: the shared 429/503 rule plus the + /// 500/502/504 that translate.googleapis.com hands out under load. + /// + public static bool ShouldRetry(HttpResponseMessage result, string resultContent) + { + return DeepLTranslate.ShouldRetry(result, resultContent) || + result.StatusCode == HttpStatusCode.InternalServerError || + result.StatusCode == HttpStatusCode.BadGateway || + result.StatusCode == HttpStatusCode.GatewayTimeout; + } + public static List GetTranslationPairs() { return new List diff --git a/tests/libuilogic/AutoTranslate/GoogleTranslateV1RetryTests.cs b/tests/libuilogic/AutoTranslate/GoogleTranslateV1RetryTests.cs new file mode 100644 index 00000000000..940917dd32a --- /dev/null +++ b/tests/libuilogic/AutoTranslate/GoogleTranslateV1RetryTests.cs @@ -0,0 +1,27 @@ +using System.Net; +using Nikse.SubtitleEdit.UiLogic.AutoTranslate; + +namespace LibUiLogicTests.AutoTranslate; + +public class GoogleTranslateV1RetryTests +{ + [Theory] + // Issue #14004: the free gtx endpoint answers an HTML "Error 500" page mid-run on long + // translations; before, one such reply aborted the whole job. + [InlineData(HttpStatusCode.InternalServerError, true)] + [InlineData(HttpStatusCode.BadGateway, true)] + [InlineData(HttpStatusCode.ServiceUnavailable, true)] + [InlineData(HttpStatusCode.GatewayTimeout, true)] + [InlineData(HttpStatusCode.TooManyRequests, true)] + // Permanent failures must still surface immediately. + [InlineData(HttpStatusCode.BadRequest, false)] + [InlineData(HttpStatusCode.Forbidden, false)] + [InlineData(HttpStatusCode.NotFound, false)] + [InlineData(HttpStatusCode.OK, false)] + public void ShouldRetry_OnlyForTransientServerErrors(HttpStatusCode statusCode, bool expected) + { + using var response = new HttpResponseMessage(statusCode); + + Assert.Equal(expected, GoogleTranslateV1.ShouldRetry(response, "Error 500 (Server Error)!!1")); + } +} From df3d78bcb002b5e210784833ed9b3fee9b978927 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sat, 22 Aug 2026 23:17:13 +0200 Subject: [PATCH 014/380] Fix #14000: click or grab a TTS review waveform block to select its line The review-speech waveform subscribed to no interaction events, so clicking a block did nothing and dragging an unselected block silently edited a row the panel was not showing. - AudioVisualizer: new OnDragStarted event, raised on press when a move/resize drag lands on a paragraph (silent for new-selection drags and read-only). - ReviewSpeechViewModel.SelectFromWaveform: look the row up by Id (event args carry a copy), select it, scroll the grid; while the selection originates on the waveform only the highlight updates - no recenter, so the view does not jump under the pointer. - ReviewSpeechWindow: wire OnPrimarySingleClicked, OnDragStarted and OnSelectRequested (plus the OnVideoPositionChanged subscriber the single-click event is gated on). Whole-block moving already worked in the control; it just was not discoverable without the selection following the pointer. Co-Authored-By: Claude Fable 5 --- .../AudioVisualizerControl/AudioVisualizer.cs | 9 ++++ .../ReviewSpeech/ReviewSpeechViewModel.cs | 46 ++++++++++++++++ .../ReviewSpeech/ReviewSpeechWindow.cs | 9 ++++ tests/UI/Controls/AudioVisualizerDragTests.cs | 53 +++++++++++++++++++ 4 files changed, 117 insertions(+) diff --git a/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs b/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs index ffecbb301f0..90f01b998c7 100644 --- a/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs +++ b/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs @@ -518,6 +518,11 @@ public class ContextEventArgs : EventArgs public event ParagraphNullableEventHandler? OnPrimaryDoubleClicked; public event PositionEventHandler? OnSetStartAndOffsetTheRest; + /// Raised when a primary-button press lands on an existing paragraph and starts a + /// move/resize drag. Lets hosts select the paragraph the user grabbed before the drag + /// mutates it (#14000) - a click is delivered via instead. + public event ParagraphEventHandler? OnDragStarted; + /// Raised when the user clicks the empty waveform to generate it on demand /// (shown only when auto-generate is off and there are no cached peaks). public event EventHandler? OnGenerateWaveformRequested; @@ -1215,6 +1220,10 @@ private void OnPointerPressed(object? sender, PointerPressedEventArgs e) } _pointerDragActive = _interactionMode != InteractionMode.None; + if (_pointerDragActive && _activeParagraph != null) + { + OnDragStarted?.Invoke(this, new ParagraphEventArgs(_startPointerSeconds, _activeParagraph)); + } } /// diff --git a/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechViewModel.cs b/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechViewModel.cs index 16c8cee934f..a147aa834ec 100644 --- a/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechViewModel.cs +++ b/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechViewModel.cs @@ -474,6 +474,44 @@ private void OnWaveformParagraphChanged(object? sender, PropertyChangedEventArgs row.Cps = Math.Round(paragraph.GetCharactersPerSecond(), 2).ToString(CultureInfo.CurrentCulture); } + // 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 +529,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); diff --git a/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechWindow.cs b/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechWindow.cs index b59e8268480..4309af8207e 100644 --- a/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechWindow.cs @@ -676,6 +676,15 @@ 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); + audioVisualizer.OnDragStarted += (_, e) => vm.SelectFromWaveform(e.Paragraph); + audioVisualizer.OnSelectRequested += (_, e) => vm.SelectFromWaveform(e.Paragraph); + return new Border { Margin = new Thickness(2), diff --git a/tests/UI/Controls/AudioVisualizerDragTests.cs b/tests/UI/Controls/AudioVisualizerDragTests.cs index b59472bacef..e45e5a430fb 100644 --- a/tests/UI/Controls/AudioVisualizerDragTests.cs +++ b/tests/UI/Controls/AudioVisualizerDragTests.cs @@ -144,6 +144,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) From 90a25a7ea733136d6c837e70e89aff21b02d95ae Mon Sep 17 00:00:00 2001 From: niksedk Date: Sat, 22 Aug 2026 23:30:04 +0200 Subject: [PATCH 015/380] TTS review waveform: context menu, clip-length bar, playhead, keyboard (#14000) Follow-up to the click/grab selection: turn the review waveform into a place where timing actually gets fixed. - AudioVisualizer.ParagraphAudioLengthProvider: draws a thin bar under each block for the generated clip's length - green where it fits the cue, red for the part that overruns - so lines needing timing work stand out. - Context menu on the waveform: Play line, Regenerate audio, Show history, and two new actions: "Fit duration to generated audio" (end = start + clip length) and "Reset timing" (restore the times from when the window opened). The row under the pointer is selected and used as the target regardless of the right-click-selects setting. - Playhead: the playback timer (now 100 ms) reads mpv's position under the play lock and moves the waveform cursor to cue start + clip position. A click on the waveform parks the cursor. - Keyboard with the waveform focused: Home/End first/last row, Ctrl+Left/Right nudge the cue 100 ms (Shift: 10 ms) with its duration kept. Co-Authored-By: Claude Fable 5 --- .../AudioVisualizerControl/AudioVisualizer.cs | 45 +++++ .../ReviewSpeech/ReviewSpeechViewModel.cs | 191 +++++++++++++++++- .../ReviewSpeech/ReviewSpeechWindow.cs | 50 ++++- .../Language/Video/LanguageTextToSpeech.cs | 6 + tests/UI/Controls/AudioVisualizerDragTests.cs | 26 +++ 5 files changed, 316 insertions(+), 2 deletions(-) diff --git a/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs b/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs index 90f01b998c7..1827139c2db 100644 --- a/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs +++ b/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs @@ -518,6 +518,18 @@ public class ContextEventArgs : EventArgs public event ParagraphNullableEventHandler? OnPrimaryDoubleClicked; public event PositionEventHandler? OnSetStartAndOffsetTheRest; + /// + /// Optional: seconds of audio that belongs to a paragraph (the TTS review window's generated + /// clip). When it returns more than 0, a thin bar is drawn along the bottom of the paragraph + /// from its start for that many seconds - green while it fits inside the cue, red for the + /// part that runs past the cue's end - so the user sees at a glance which lines need their + /// timing fixed (#14000). + /// + public Func? ParagraphAudioLengthProvider { get; set; } + + private static readonly IBrush PaintAudioLengthFits = new SolidColorBrush(Color.FromArgb(190, 70, 190, 110)); + private static readonly IBrush PaintAudioLengthOverrun = new SolidColorBrush(Color.FromArgb(220, 235, 70, 70)); + /// Raised when a primary-button press lands on an existing paragraph and starts a /// move/resize drag. Lets hosts select the paragraph the user grabbed before the drag /// mutates it (#14000) - a click is delivered via instead. @@ -3325,6 +3337,39 @@ private void DrawParagraph(SubtitleLineViewModel paragraph, DrawingContext conte DrawParagraphFooter(context, paragraph, currentRegionLeft, currentRegionWidth, height, ref renderCtx); } + + DrawParagraphAudioLength(context, paragraph, currentRegionLeft, currentRegionRight, height, ref renderCtx); + } + + // Drawn outside the text clip on purpose: the overrun part extends past the right border. + private void DrawParagraphAudioLength(DrawingContext context, SubtitleLineViewModel paragraph, + double currentRegionLeft, double currentRegionRight, double height, ref RenderContext renderCtx) + { + var provider = ParagraphAudioLengthProvider; + if (provider == null) + { + return; + } + + var audioSeconds = provider(paragraph); + if (audioSeconds <= 0) + { + return; + } + + var audioRight = SecondsToXPositionOptimized(paragraph.StartTime.TotalSeconds + audioSeconds - renderCtx.StartPositionSeconds, renderCtx.SampleRate, renderCtx.ZoomFactor); + const double barHeight = 4; + var y = height - barHeight - 1; + var fitsRight = Math.Min(audioRight, currentRegionRight - 1); + if (fitsRight > currentRegionLeft + 1) + { + context.FillRectangle(PaintAudioLengthFits, new Rect(currentRegionLeft + 1, y, fitsRight - currentRegionLeft - 1, barHeight)); + } + + if (audioRight > currentRegionRight) + { + context.FillRectangle(PaintAudioLengthOverrun, new Rect(currentRegionRight - 1, y, audioRight - currentRegionRight + 1, barHeight)); + } } // SE 4 parity: small footer at the bottom-left of each paragraph rectangle with diff --git a/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechViewModel.cs b/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechViewModel.cs index a147aa834ec..ee2c31c96e7 100644 --- a/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechViewModel.cs +++ b/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechViewModel.cs @@ -169,7 +169,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 +191,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 +201,7 @@ private async void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) else { paused = _mpvContext.IsPaused; + positionSeconds = _mpvContext.Position; } } @@ -208,6 +211,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. @@ -474,6 +486,183 @@ 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 diff --git a/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechWindow.cs b/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechWindow.cs index 4309af8207e..d6957624d69 100644 --- a/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechWindow.cs +++ b/src/ui/Features/Video/TextToSpeech/ReviewSpeech/ReviewSpeechWindow.cs @@ -681,10 +681,52 @@ private static Border MakeWaveform(ReviewSpeechViewModel vm) // 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); + 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), @@ -696,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/Logic/Config/Language/Video/LanguageTextToSpeech.cs b/src/ui/Logic/Config/Language/Video/LanguageTextToSpeech.cs index 8612ad91f88..459741e7ba2 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; } @@ -176,6 +179,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..."; diff --git a/tests/UI/Controls/AudioVisualizerDragTests.cs b/tests/UI/Controls/AudioVisualizerDragTests.cs index e45e5a430fb..45173e4cab5 100644 --- a/tests/UI/Controls/AudioVisualizerDragTests.cs +++ b/tests/UI/Controls/AudioVisualizerDragTests.cs @@ -295,6 +295,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)!; From 76bc600690fb846bfe2af92dc1c6690ed957a870 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sat, 22 Aug 2026 23:49:20 +0200 Subject: [PATCH 016/380] Fix split long lines: overlong lines, gaps between split events, duplicate numbers Re-does the bug fixes from PR #14006 by Triathlon-rally on top of main. - Split qualifies like SE4's QualifiesForSplit: a subtitle that fits in total is still split when one of its lines is too long or it has too many lines. Multi-line subtitles are cut at the author's line breaks; split-only still never inserts line breaks (#10959). When Rebalance is on, subtitles that re-wrapping can fix are not cut into events (SE4 parity). - The general minimum gap is reserved between the events a subtitle is split into (clamped to half the duration), instead of back-to-back events. - Renumber after the dialog so split events do not show duplicate numbers. - Rebalance fixes get an Apply checkbox with Select all / Select none. - EBU STL: teletext rows keep their bottom edge when the line count changes (21 <-> 23, 19 <-> 21, 20 <-> 22); gated on IsFormatEbu so ASSA margins are left alone. Co-Authored-By: Claude Fable 5 --- src/ui/Features/Main/MainViewModel.cs | 5 +- .../SplitBreakLongLinesItem.cs | 56 +++- .../SplitBreakLongLinesViewModel.cs | 314 ++++++++++++++---- .../SplitBreakLongLinesWindow.cs | 43 ++- src/ui/Logic/TeletextRowHelper.cs | 24 ++ .../SplitBreakLongLinesTests.cs | 199 +++++++++++ tests/UI/Logic/TeletextRowHelperTests.cs | 26 ++ 7 files changed, 584 insertions(+), 83 deletions(-) diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index fa537c7d4c0..dddb6e2dd38 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -7357,11 +7357,12 @@ private async Task ShowToolsSplitBreakLongLines() } var result = await ShowDialogAsync( - vm => { vm.Initialize(Subtitles.ToList()); }); + vm => { vm.Initialize(Subtitles.ToList(), IsFormatEbu); }); if (result.OkPressed && result.AllSubtitlesFixed.Count > 0) { ReplaceSubtitles(result.AllSubtitlesFixed); + Renumber(); SelectAndScrollToRow(0); _updateAudioVisualizer = true; RefreshSubtitlePreview(); @@ -15655,7 +15656,7 @@ private async Task ShowToolsSplitBreakLongLinesSelectedLines() } var result = await ShowDialogAsync( - vm => { vm.Initialize(selectedInOrder); }); + vm => { vm.Initialize(selectedInOrder, IsFormatEbu); }); if (!result.OkPressed || result.AllSubtitlesFixed.Count == 0) { 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..12ce5e2e1ae 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, mergeLinesShorterThan)) + { + 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,45 @@ 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; + } + } + } + + private bool CanBeFixedByRebalancing(string? text, int mergeLinesShorterThan) + { + if (!HasLineTooLong(text, SingleLineMaxLength, MaxNumberOfLines)) + { + return true; + } + + var rebalanced = Utilities.AutoBreakLine(text ?? string.Empty, SingleLineMaxLength, mergeLinesShorterThan, _languageCode); + return !HasLineTooLong(rebalanced, SingleLineMaxLength, MaxNumberOfLines); + } + + 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 +276,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 +309,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 +359,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 +380,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 +391,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) @@ -675,9 +842,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..e3deb5259ad 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; @@ -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/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/tests/UI/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesTests.cs b/tests/UI/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesTests.cs index a472fdd0cda..5c2f3ac2405 100644 --- a/tests/UI/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesTests.cs +++ b/tests/UI/Features/Tools/SplitBreakLongLines/SplitBreakLongLinesTests.cs @@ -1,5 +1,7 @@ +using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.Features.Main; using Nikse.SubtitleEdit.Features.Tools.SplitBreakLongLines; +using Nikse.SubtitleEdit.Logic; namespace UITests.Features.Tools.SplitBreakLongLines; @@ -75,4 +77,201 @@ public void HasLineTooLong_FlagsOnlyNonCompliantSubtitles(string text, int maxLe var normalized = text.Replace("\n", "\r\n"); Assert.Equal(expected, SplitBreakLongLinesViewModel.HasLineTooLong(normalized, maxLength, maxLines)); } + + // --- SE4 parity (PR #14006 by Triathlon-rally): a subtitle that fits in total can still be + // --- unusable because one line is too long or it has too many lines. + + [Fact] + public void Split_TwoLinesOneTooLong_SplitsAtTheExistingLineBreak() + { + var item = MakeSubtitle("Und, war ich in der Kantine?\r\nNein, ich konnte Sie dort nicht finden.", 10, 14); + + var result = SplitBreakLongLinesViewModel.Split(item, maxCharactersPerSubtitle: 72, singleLineMaxLength: 36); + + Assert.Equal(2, result.Count); + Assert.Equal("Und, war ich in der Kantine?", result[0].Text); + Assert.Equal("Nein, ich konnte Sie dort nicht finden.", result[1].Text); // 39 > 36 + Assert.Equal(item.StartTime, result[0].StartTime); + Assert.Equal(item.EndTime, result[^1].EndTime); + } + + [Fact] + public void Split_TooManyLines_GroupsLinesIntoEvents() + { + var item = MakeSubtitle("One\r\nTwo\r\nThree", 10, 14); + + var result = SplitBreakLongLinesViewModel.Split(item, maxCharactersPerSubtitle: 72, singleLineMaxLength: 36); + + Assert.Equal(2, result.Count); + Assert.Equal("One\r\nTwo", result[0].Text.Replace("\n", "\r\n").Replace("\r\r", "\r")); + Assert.Equal("Three", result[1].Text); + } + + [Fact] + public void Split_SingleLineOverLineLimitButWithinTotal_SplitsIntoSingleLineEvents() + { + // Split-only never inserts a line break (#10959), so the only way to make this fit is + // two events that each fit on one line. + var item = MakeSubtitle("This single line is too long and must become two events.", 10, 14); // 57 chars + + var result = SplitBreakLongLinesViewModel.Split(item, maxCharactersPerSubtitle: 72, singleLineMaxLength: 36); + + Assert.Equal(2, result.Count); + Assert.All(result, line => + { + Assert.DoesNotContain("\n", line.Text); + Assert.True(line.Text.Length <= 36); + }); + } + + [Fact] + public void Split_CompliantUnbalancedTwoLines_RemainsUnchanged() + { + var item = MakeSubtitle("This is intentionally longer\r\nshort.", 10, 14); + + var result = SplitBreakLongLinesViewModel.Split(item, maxCharactersPerSubtitle: 72, singleLineMaxLength: 36); + + Assert.Single(result); + Assert.Same(item, result[0]); + } + + [Fact] + public void Split_ReservesMinimumGapBetweenEventsAndKeepsOuterTimeCodes() + { + var item = MakeSubtitle(string.Join(' ', Enumerable.Repeat("word", 40)), 10, 14); + var options = new SplitBreakLongLinesViewModel.SplitOptions { MinimumGapMs = 120 }; + + var result = SplitBreakLongLinesViewModel.Split(item, maxCharactersPerSubtitle: 72, singleLineMaxLength: 36, options); + + Assert.True(result.Count >= 2); + Assert.Equal(item.StartTime, result[0].StartTime); + Assert.Equal(item.EndTime, result[^1].EndTime); + for (var i = 1; i < result.Count; i++) + { + var gapMs = result[i].StartTime.TotalMilliseconds - result[i - 1].EndTime.TotalMilliseconds; + Assert.Equal(120, gapMs, 1); + Assert.True(result[i - 1].Duration.TotalMilliseconds > 0); + } + } + + [Fact] + public void Split_GapIsClampedSoTextKeepsAtLeastHalfTheDuration() + { + var item = MakeSubtitle(string.Join(' ', Enumerable.Repeat("word", 40)), 10, 10.2); + var options = new SplitBreakLongLinesViewModel.SplitOptions { MinimumGapMs = 1000 }; + + var result = SplitBreakLongLinesViewModel.Split(item, maxCharactersPerSubtitle: 72, singleLineMaxLength: 36, options); + + Assert.True(result.Count >= 2); + Assert.Equal(item.EndTime, result[^1].EndTime); + var textMs = result.Sum(p => p.Duration.TotalMilliseconds); + Assert.True(textMs >= 100 - 1, $"text time {textMs} ms"); + Assert.All(result, p => Assert.True(p.Duration.TotalMilliseconds > 0)); + } + + [Fact] + public void Split_WithoutGap_EventsAreBackToBack() + { + var item = MakeSubtitle(string.Join(' ', Enumerable.Repeat("word", 40)), 10, 14); + + var result = SplitBreakLongLinesViewModel.Split(item, maxCharactersPerSubtitle: 72, singleLineMaxLength: 36); + + for (var i = 1; i < result.Count; i++) + { + Assert.Equal(result[i - 1].EndTime, result[i].StartTime); + } + } + + [Theory] + [InlineData("21", "23")] + [InlineData("19", "21")] + [InlineData("20", "22")] + public void Split_TeletextTwoLinesIntoOneLineEvents_KeepsBottomEdge(string marginV, string expectedOneLineRow) + { + var item = MakeSubtitle("Und, war ich in der Kantine?\r\nNein, ich konnte Sie dort wirklich überhaupt nicht finden.", 10, 14); + item.MarginV = marginV; + var options = new SplitBreakLongLinesViewModel.SplitOptions { AdjustTeletextRows = true, TeletextDoubleHeight = true }; + + var result = SplitBreakLongLinesViewModel.Split(item, maxCharactersPerSubtitle: 72, singleLineMaxLength: 36, options); + + Assert.Equal(2, result.Count); + Assert.All(result, p => Assert.Single(p.Text.SplitToLines())); + Assert.All(result, p => Assert.Equal(expectedOneLineRow, p.MarginV)); // bottom edge unchanged + Assert.NotEqual(expectedOneLineRow, marginV); + } + + [Theory] + [InlineData("23", "21")] + [InlineData("21", "19")] + [InlineData("22", "20")] + public void Rebalance_TeletextOneLineIntoTwo_KeepsBottomEdge(string marginV, string expectedTwoLineRow) + { + Assert.Equal(int.Parse(expectedTwoLineRow), TeletextRowHelper.GetRowKeepingBottomEdge(marginV, 1, 2, doubleHeight: true)); + } + + [Fact] + public void Split_NotEbu_LeavesMarginVAlone() + { + // MarginV is a pixel margin for ASSA - only an EBU file gets its rows shifted. + var item = MakeSubtitle("Und, war ich in der Kantine?\r\nNein, ich konnte Sie dort nicht finden.", 10, 14); + item.MarginV = "21"; + + var result = SplitBreakLongLinesViewModel.Split(item, maxCharactersPerSubtitle: 72, singleLineMaxLength: 36); + + Assert.Equal(2, result.Count); + Assert.All(result, p => Assert.Equal("21", p.MarginV)); + } + + [Fact] + public void RebalanceFix_UncheckRestoresOriginalText() + { + var subtitle = MakeSubtitle("This is the original subtitle text.", 10, 14); + var fix = new SplitBreakLongLinesItem("Rebalance", 1, "preview", subtitle, "This is the\r\nproposed text."); + + Assert.True(fix.IsSelectable); + Assert.True(fix.IsSelected); + Assert.Equal("This is the\r\nproposed text.", subtitle.Text); + + fix.IsSelected = false; + Assert.Equal("This is the original subtitle text.", subtitle.Text); + + fix.IsSelected = true; + Assert.Equal("This is the\r\nproposed text.", subtitle.Text); + } + + [Fact] + public void RebalanceFix_TeletextRowFollowsTheCheckbox() + { + var subtitle = MakeSubtitle("One line on the bottom row.", 10, 14); + subtitle.MarginV = "23"; + var fix = new SplitBreakLongLinesItem("Rebalance", 1, "preview", subtitle, "One line on\r\nthe bottom row.", "21"); + + Assert.Equal("21", subtitle.MarginV); + fix.IsSelected = false; + Assert.Equal("23", subtitle.MarginV); + Assert.Equal("One line on the bottom row.", subtitle.Text); + } + + [Fact] + public void SelectAllAndSelectNone_TouchOnlyRebalanceFixes() + { + var vm = new SplitBreakLongLinesViewModel(); + var rebalanced = MakeSubtitle("Original.", 10, 14); + var split = MakeSubtitle("Split item.", 10, 14); + var rebalanceFix = new SplitBreakLongLinesItem("Rebalance", 1, "preview", rebalanced, "Changed."); + var splitFix = new SplitBreakLongLinesItem("Split", 2, "preview", split); + vm.Fixes.Add(rebalanceFix); + vm.Fixes.Add(splitFix); + + vm.SelectNoneCommand.Execute(null); + Assert.False(rebalanceFix.IsSelected); + Assert.True(splitFix.IsSelected); + Assert.Equal("Original.", rebalanced.Text); + + vm.SelectAllCommand.Execute(null); + Assert.True(rebalanceFix.IsSelected); + Assert.Equal("Changed.", rebalanced.Text); + + vm.OnClosingCleanup(); + } } diff --git a/tests/UI/Logic/TeletextRowHelperTests.cs b/tests/UI/Logic/TeletextRowHelperTests.cs index cac70c828b3..97f8f59b686 100644 --- a/tests/UI/Logic/TeletextRowHelperTests.cs +++ b/tests/UI/Logic/TeletextRowHelperTests.cs @@ -55,4 +55,30 @@ public void GetAdjustedBottomRow_DoesNotLeaveThePage() // 12 double-height lines would start above row 1 - leave the row untouched. Assert.Null(TeletextRowHelper.GetAdjustedBottomRow("23", 1, 13, doubleHeight: true)); } + + // Batch tools (split long lines) keep the bottom edge of the text where it was, for any row. + [Theory] + [InlineData("21", 2, 1, true, 23)] + [InlineData("19", 2, 1, true, 21)] + [InlineData("20", 2, 1, true, 22)] + [InlineData("23", 1, 2, true, 21)] + [InlineData("21", 1, 2, true, 19)] + [InlineData("22", 1, 2, true, 20)] + [InlineData("23", 1, 2, false, 22)] + public void GetRowKeepingBottomEdge_MovesStartRowByRowsGainedOrLost(string marginV, int oldLines, int newLines, bool doubleHeight, int expected) + { + Assert.Equal(expected, TeletextRowHelper.GetRowKeepingBottomEdge(marginV, oldLines, newLines, doubleHeight)); + } + + [Theory] + [InlineData("23", 1, 1)] // line count unchanged + [InlineData("", 1, 2)] // no row + [InlineData("abc", 1, 2)] // not a row + [InlineData("40", 1, 2)] // ASSA-style pixel margin, not a teletext row + [InlineData("23", 2, 1)] // would leave the page at the bottom + [InlineData("2", 1, 2)] // would leave the page at the top + public void GetRowKeepingBottomEdge_LeavesRowAlone(string marginV, int oldLines, int newLines) + { + Assert.Null(TeletextRowHelper.GetRowKeepingBottomEdge(marginV, oldLines, newLines, doubleHeight: true)); + } } From 6e4415c3a5e5cee3f496fc8067ea06ca1113d748 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sun, 23 Aug 2026 00:02:11 +0200 Subject: [PATCH 017/380] Warn before saving SCC when lines exceed 32 chars / 4 lines SCC (CEA-608) writes silently re-wrap or truncate any line over 32 columns or subtitle over 4 rows, so merged lines came back split after a reopen with no hint why. Add a SubtitleFormat.FormatLimits hook (SCC declares 32x4), check it before Save/Save As, and show a dialog listing the affected lines with "Save anyway"/"Cancel" and a "Do not show this warning again" checkbox (backed by General.ShowFormatLimitWarning, also exposed in Options > Settings). Auto-save never prompts. Co-Authored-By: Claude Fable 5 --- .../ScenaristClosedCaptions.cs | 6 + src/libse/SubtitleFormats/SubtitleFormat.cs | 7 + .../SubtitleFormats/SubtitleFormatLimits.cs | 50 ++++++ src/ui/Assets/Languages/English.json | 12 ++ src/ui/DependencyInjectionExtensions.cs | 2 + src/ui/Features/Main/MainViewModel.cs | 40 ++++- .../Features/Options/Settings/SettingsPage.cs | 1 + .../Options/Settings/SettingsViewModel.cs | 3 + .../FormatLimitWarningViewModel.cs | 78 ++++++++++ .../FormatLimitWarningWindow.cs | 144 ++++++++++++++++++ .../Config/Language/Main/LanguageMain.cs | 16 ++ .../Language/Options/LanguageSettings.cs | 2 + src/ui/Logic/Config/SeGeneral.cs | 6 + .../ScenaristClosedCaptionsTest.cs | 32 ++++ 14 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 src/libse/SubtitleFormats/SubtitleFormatLimits.cs create mode 100644 src/ui/Features/Shared/FormatLimitWarning/FormatLimitWarningViewModel.cs create mode 100644 src/ui/Features/Shared/FormatLimitWarning/FormatLimitWarningWindow.cs diff --git a/src/libse/SubtitleFormats/ScenaristClosedCaptions.cs b/src/libse/SubtitleFormats/ScenaristClosedCaptions.cs index 9859efb70f8..b1f146ba586 100644 --- a/src/libse/SubtitleFormats/ScenaristClosedCaptions.cs +++ b/src/libse/SubtitleFormats/ScenaristClosedCaptions.cs @@ -543,6 +543,12 @@ private static string InsertExtendedCharFallbacks(string text) public override string Name => "Scenarist Closed Captions"; + public override SubtitleFormatLimits FormatLimits => new SubtitleFormatLimits + { + MaxCharactersPerLine = MaxCharactersPerLine, + MaxLines = 4, + }; + private static string FixMax4LinesAndMax32CharsPerLine(string text, string language) { // fix attempt 1 diff --git a/src/libse/SubtitleFormats/SubtitleFormat.cs b/src/libse/SubtitleFormats/SubtitleFormat.cs index 6e2fe8a4948..cb7016407e6 100644 --- a/src/libse/SubtitleFormats/SubtitleFormat.cs +++ b/src/libse/SubtitleFormats/SubtitleFormat.cs @@ -560,6 +560,13 @@ public static int FramesToMillisecondsMax999(double frames) public virtual bool HasStyleSupport => false; + /// + /// Hard limits the format enforces when writing (e.g. CEA-608's 32 columns x 4 rows). A format + /// that declares them lets the UI warn before a save silently re-wraps or truncates text + /// that does not fit. Null means no such limits. + /// + public virtual SubtitleFormatLimits? FormatLimits => null; + public bool BatchMode { get; set; } public double? BatchSourceFrameRate { get; set; } diff --git a/src/libse/SubtitleFormats/SubtitleFormatLimits.cs b/src/libse/SubtitleFormats/SubtitleFormatLimits.cs new file mode 100644 index 00000000000..d9f8a5d9312 --- /dev/null +++ b/src/libse/SubtitleFormats/SubtitleFormatLimits.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using Nikse.SubtitleEdit.Core.Common; + +namespace Nikse.SubtitleEdit.Core.SubtitleFormats +{ + /// + /// Hard per-subtitle limits a format enforces on save - see . + /// + public class SubtitleFormatLimits + { + /// Max visible characters per line (HTML tags excluded), or null for no limit. + public int? MaxCharactersPerLine { get; set; } + + /// Max number of lines per subtitle, or null for no limit. + public int? MaxLines { get; set; } + + /// + /// Returns the 1-based numbers of the paragraphs that break these limits - the ones the + /// writer will re-wrap or truncate. + /// + public List GetViolatingParagraphNumbers(Subtitle subtitle) + { + var result = new List(); + for (var i = 0; i < subtitle.Paragraphs.Count; i++) + { + var lines = subtitle.Paragraphs[i].Text.SplitToLines(); + var tooManyLines = MaxLines.HasValue && lines.Count > MaxLines.Value; + var tooLong = false; + if (MaxCharactersPerLine.HasValue) + { + foreach (var line in lines) + { + if (HtmlUtil.RemoveHtmlTags(line, true).Length > MaxCharactersPerLine.Value) + { + tooLong = true; + break; + } + } + } + + if (tooManyLines || tooLong) + { + result.Add(i + 1); + } + } + + return result; + } + } +} diff --git a/src/ui/Assets/Languages/English.json b/src/ui/Assets/Languages/English.json index 30bead7b11a..85fe8c2e91c 100644 --- a/src/ui/Assets/Languages/English.json +++ b/src/ui/Assets/Languages/English.json @@ -1081,6 +1081,14 @@ "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?", @@ -2163,6 +2171,9 @@ "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...", @@ -2869,6 +2880,7 @@ "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", diff --git a/src/ui/DependencyInjectionExtensions.cs b/src/ui/DependencyInjectionExtensions.cs index f672f2f1aef..af1140bae95 100644 --- a/src/ui/DependencyInjectionExtensions.cs +++ b/src/ui/DependencyInjectionExtensions.cs @@ -78,6 +78,7 @@ using Nikse.SubtitleEdit.Features.Shared.ColumnPaste; using Nikse.SubtitleEdit.Features.Shared.ErrorList; using Nikse.SubtitleEdit.Features.Shared.FindText; +using Nikse.SubtitleEdit.Features.Shared.FormatLimitWarning; using Nikse.SubtitleEdit.Features.Shared.GoToLineNumber; using Nikse.SubtitleEdit.Features.Shared.PickAlignment; using Nikse.SubtitleEdit.Features.Shared.PickTeletextAlignment; @@ -425,6 +426,7 @@ public static void AddSubtitleEditServices(this IServiceCollection collection) collection.AddTransient(); collection.AddTransient(); collection.AddTransient(); + collection.AddTransient(); collection.AddTransient(); collection.AddTransient(); collection.AddTransient(); diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index dddb6e2dd38..a95acc9101c 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -85,6 +85,7 @@ using Nikse.SubtitleEdit.Features.Shared.ColumnPaste; using Nikse.SubtitleEdit.Features.Shared.ErrorList; using Nikse.SubtitleEdit.Features.Shared.GetAudioClips; +using Nikse.SubtitleEdit.Features.Shared.FormatLimitWarning; using Nikse.SubtitleEdit.Features.Shared.GoToLineNumber; using Nikse.SubtitleEdit.Features.Shared.MediaInfoView; using Nikse.SubtitleEdit.Features.Shared.PickAlignment; @@ -21090,7 +21091,17 @@ private async Task SaveSubtitle(bool isAutoSave = false) return await SaveBinarySubtitle(binaryFormat, isAutoSave); } - var text = GetUpdateSubtitle(true).ToText(SelectedSubtitleFormat); + var subtitleToSave = GetUpdateSubtitle(true); + + // Formats with hard limits (SCC: 32 chars x 4 lines) silently re-wrap/truncate anything + // that does not fit, so the saved file stops matching the grid. Warn first - but never + // from the auto-save timer, which must not pop modal dialogs. + if (!isAutoSave && !await ConfirmFormatLimitsBeforeSave(subtitleToSave, SelectedSubtitleFormat)) + { + return false; + } + + var text = subtitleToSave.ToText(SelectedSubtitleFormat); if (Se.Settings.General.ForceCrLfOnSave) { @@ -21321,6 +21332,33 @@ public Subtitle GetUpdateSubtitleOriginal(bool subtractVideoOffset = false) return _subtitleOriginal; } + /// + /// Returns false if the user cancelled the save because some subtitles exceed the format's limits. + /// + private async Task ConfirmFormatLimitsBeforeSave(Subtitle subtitle, SubtitleFormat format) + { + if (!Se.Settings.General.ShowFormatLimitWarning || Window == null) + { + return true; + } + + var limits = format.FormatLimits; + if (limits == null) + { + return true; + } + + var violating = limits.GetViolatingParagraphNumbers(subtitle); + if (violating.Count == 0) + { + return true; + } + + var vm = await ShowDialogAsync( + vm => vm.Initialize(format, limits, violating)); + return vm.SaveAnywayPressed; + } + private async Task SaveSubtitleAs() { var newFileName = "New"; diff --git a/src/ui/Features/Options/Settings/SettingsPage.cs b/src/ui/Features/Options/Settings/SettingsPage.cs index f9aecf56ca3..6a89a033cf5 100644 --- a/src/ui/Features/Options/Settings/SettingsPage.cs +++ b/src/ui/Features/Options/Settings/SettingsPage.cs @@ -278,6 +278,7 @@ private List CreateSections() MakeCheckboxSetting(Se.Language.Options.Settings.OpenLastFileOnStart, nameof(_vm.OpenLastFileOnStart)), MakeCheckboxSetting(Se.Language.Options.Settings.AutoConvertToUtf8, nameof(_vm.AutoConvertToUtf8)), MakeCheckboxSetting(Se.Language.Options.Settings.ForceCrLfOnSave, nameof(_vm.ForceCrLfOnSave)), + MakeCheckboxSetting(Se.Language.Options.Settings.ShowFormatLimitWarning, nameof(_vm.ShowFormatLimitWarning)), MakeCheckboxSetting(Se.Language.Options.Settings.AutoTrimWhiteSpace, nameof(_vm.AutoTrimWhiteSpace)), MakeCheckboxSetting(Se.Language.Options.Settings.RemoveBlankLinesWhenOpening, nameof(_vm.RemoveBlankLinesWhenOpening)), new SettingsItem(Se.Language.Options.Settings.DefaultEncoding, () => new ComboBox diff --git a/src/ui/Features/Options/Settings/SettingsViewModel.cs b/src/ui/Features/Options/Settings/SettingsViewModel.cs index 46d6e24191f..6f2d3dae7d9 100644 --- a/src/ui/Features/Options/Settings/SettingsViewModel.cs +++ b/src/ui/Features/Options/Settings/SettingsViewModel.cs @@ -156,6 +156,7 @@ partial void OnMaxLinesChanged(int? value) [ObservableProperty] private TextEncoding _defaultEncoding; [ObservableProperty] private bool _autoConvertToUtf8; [ObservableProperty] private bool _forceCrLfOnSave; + [ObservableProperty] private bool _showFormatLimitWarning; [ObservableProperty] private bool _autoTrimWhiteSpace; [ObservableProperty] private bool _removeBlankLinesWhenOpening; @@ -780,6 +781,7 @@ private void LoadSettings() DefaultSaveLocationCustomFolder = Se.Settings.General.DefaultSaveLocationCustomFolder ?? string.Empty; AutoConvertToUtf8 = general.AutoConvertToUtf8; ForceCrLfOnSave = general.ForceCrLfOnSave; + ShowFormatLimitWarning = general.ShowFormatLimitWarning; AutoTrimWhiteSpace = general.AutoTrimWhiteSpace; RemoveBlankLinesWhenOpening = general.RemoveBlankLinesWhenOpening; @@ -1654,6 +1656,7 @@ private void SaveSettings() general.DefaultSaveLocationCustomFolder = DefaultSaveLocationCustomFolder; general.AutoConvertToUtf8 = AutoConvertToUtf8; general.ForceCrLfOnSave = ForceCrLfOnSave; + general.ShowFormatLimitWarning = ShowFormatLimitWarning; general.AutoTrimWhiteSpace = AutoTrimWhiteSpace; general.RemoveBlankLinesWhenOpening = RemoveBlankLinesWhenOpening; 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..87fe861823d --- /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. + Activated += delegate { buttonCancel.Focus(); }; + 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/Logic/Config/Language/Main/LanguageMain.cs b/src/ui/Logic/Config/Language/Main/LanguageMain.cs index 7292d5908cc..fb6a4b88a0b 100644 --- a/src/ui/Logic/Config/Language/Main/LanguageMain.cs +++ b/src/ui/Logic/Config/Language/Main/LanguageMain.cs @@ -134,6 +134,14 @@ 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; } @@ -278,6 +286,14 @@ 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?"; diff --git a/src/ui/Logic/Config/Language/Options/LanguageSettings.cs b/src/ui/Logic/Config/Language/Options/LanguageSettings.cs index d18f2f0ec4c..fa91009c048 100644 --- a/src/ui/Logic/Config/Language/Options/LanguageSettings.cs +++ b/src/ui/Logic/Config/Language/Options/LanguageSettings.cs @@ -313,6 +313,7 @@ public class LanguageSettings 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; } @@ -646,6 +647,7 @@ public LanguageSettings() 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"; diff --git a/src/ui/Logic/Config/SeGeneral.cs b/src/ui/Logic/Config/SeGeneral.cs index 17883ecac16..1950d2041a6 100644 --- a/src/ui/Logic/Config/SeGeneral.cs +++ b/src/ui/Logic/Config/SeGeneral.cs @@ -88,6 +88,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; } diff --git a/tests/libse/SubtitleFormats/ScenaristClosedCaptionsTest.cs b/tests/libse/SubtitleFormats/ScenaristClosedCaptionsTest.cs index 122f1f415af..9e296968004 100644 --- a/tests/libse/SubtitleFormats/ScenaristClosedCaptionsTest.cs +++ b/tests/libse/SubtitleFormats/ScenaristClosedCaptionsTest.cs @@ -139,3 +139,35 @@ public void DecodesAlignedMusicNotes() Assert.Equal("♪ De Pueblo Paleta soy ♪", subtitle.Paragraphs[0].Text); } } + +public class ScenaristClosedCaptionsFormatLimitsTest +{ + [Fact] + public void FormatLimits_Are32CharsAnd4Lines() + { + var limits = new ScenaristClosedCaptions().FormatLimits; + Assert.NotNull(limits); + Assert.Equal(32, limits!.MaxCharactersPerLine); + Assert.Equal(4, limits.MaxLines); + } + + [Fact] + public void GetViolatingParagraphNumbers_FlagsLongLinesAndTooManyLines_IgnoringTags() + { + var subtitle = new Subtitle(); + subtitle.Paragraphs.Add(new Paragraph("Short line", 0, 1000)); + subtitle.Paragraphs.Add(new Paragraph("This merged line is well over thirty-two chars", 1000, 2000)); + subtitle.Paragraphs.Add(new Paragraph("Tags do not count toward 32", 2000, 3000)); + subtitle.Paragraphs.Add(new Paragraph("1\r\n2\r\n3\r\n4\r\n5", 3000, 4000)); + + var violating = new ScenaristClosedCaptions().FormatLimits!.GetViolatingParagraphNumbers(subtitle); + + Assert.Equal(new List { 2, 4 }, violating); + } + + [Fact] + public void FormatLimits_DefaultIsNull() + { + Assert.Null(new SubRip().FormatLimits); + } +} From 7701bd14226dbbffdda7e80f28d3aab16f13046e Mon Sep 17 00:00:00 2001 From: Adam Malich <117507909+potplayer-fanpack@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:58:35 +0200 Subject: [PATCH 018/380] Update Polish.json --- src/ui/Assets/Languages/Polish.json | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/ui/Assets/Languages/Polish.json b/src/ui/Assets/Languages/Polish.json index 48cb79fc663..af3068151b1 100644 --- a/src/ui/Assets/Languages/Polish.json +++ b/src/ui/Assets/Languages/Polish.json @@ -1,7 +1,7 @@ { "title": "Subtitle Edit", - "version": "v5.2.0-beta20", - "translatedBy": "Adam Malich (21.08.2026)", + "version": "v5.2.0-beta21", + "translatedBy": "Adam Malich (23.08.2026)", "cultureName": "pl-PL", "general": { "abort": "Przerwij", @@ -1081,6 +1081,14 @@ "replacedXOccurrences": "Zastąpiono {0} wystąpień", "ffmpegDownloadedAndInstalledToX": "ffmpeg pobrano i zainstalowano w {0}", "nothingToSave": "Nie ma nic do zapisania", + "formatLimitWarningTitle": "Przekroczono limity formatu", + "formatLimitWarningXLinesExceedLimitsOfY": "{0} napisów przekracza limity formatu \"{1}\":", + "formatLimitWarningMaxXCharactersPerLine": "Maks. {0} znaków na linię", + "formatLimitWarningMaxXLines": "Maks. {0} linii na napis", + "formatLimitWarningTextWillBeRewrapped": "Podczas zapisywania napisy zostaną sformatowane lub skrócone, tak aby pasowały, więc plik nie będzie odpowiadał temu, co jest tutaj pokazane.", + "formatLimitWarningLinesX": "Linii: {0}", + "formatLimitWarningSaveAnyway": "Zapisz mimo wszystko", + "formatLimitWarningDoNotShowAgain": "Nie pokazuj tego ostrzeżenia ponownie", "nothingToSaveOriginal": "Nie ma nic do zapisania (oryginał)", "liveSpellCheckLanguageXLoaded": "Wczytano język {0} sprawdzania pisowni na żywo", "downloadFfmpegTitle": "Pobrać FFmpeg?", @@ -1799,6 +1807,7 @@ "addFolderDotDotDot": "Dodaj folder...", "selectFolderToConvert": "Wybierz folder z plikami do konwersji", "includeSubfolders": "Uwzględnij podfoldery podczas dodawania folderu", + "keepSourceFileTimestamp": "Zachowaj datę/godzinę pliku źródłowego w plikach wyjściowych", "scanningFolderX": "Skanowanie {0}..." }, "changeCasing": { @@ -2162,6 +2171,9 @@ "elevenLabsSettingsResetHint": "Zresetuj ustawienia ElevenLabs do wartości domyślnych", "regenerateAudio": "Regeneruj audio", "autoContinuePlaying": "Automatyczne kontynuowanie odtwarzania", + "playLine": "Odtwórz linię", + "fitDurationToGeneratedAudio": "Dostosuj czas trwania do wygenerowanego audio", + "resetTiming": "Resetuj dopasowanie czasu", "addingAudioToVideoFileDotDotDot": "Dodawanie audio do pliku wideo...", "preparingMergeDotDotDot": "Przygotowywanie łączenia...", "importVoiceDotDotDot": "Importuj głos...", @@ -2697,6 +2709,10 @@ "waveformMouseWheelStepMilliseconds": "{0} ms", "waveformSnapToShotChanges": "Przyciągaj do zmian ujęcia (przytrzymaj Shift, aby pominąć)", "waveformSnapToFrames": "Przyciągaj do klatek", + "waveformSnapToShotChangesPixels": "Odległość przechwytu podczas przeciągania (piksele)", + "waveformSnapToShotChangeStartMaxSeconds": "Przechwyć do najbliższej zmiany ujęcia: maksymalna odległość początkowa (w sekundach)", + "waveformSnapToShotChangeEndMaxSeconds": "Przechwyć do najbliższej zmiany ujęcia: maksymalny czas do końca (w sekundach)", + "waveformSnapToShotChangeSameShotEndMaxSeconds": "Przechwyć do najbliższej zmiany ujęcia: maksymalna odległość końcowa, gdy początek i koniec ujęcia pokrywają się (w sekundach)", "waveformShotChangesAutoGenerate": "Automatycznie generuj zmiany ujęcia", "waveformTextFontSize": "Rozmiar czcionki tekstu na przebiegu fali", "waveformTextFontBold": "Pogrubiona czcionka tekstu na przebiegu fali", @@ -2864,6 +2880,7 @@ "useFocusedButtonBackgroundColor": "Użyj koloru tła przycisku w stanie aktywnym", "focusedButtonBackgroundColor": "Kolor tła przycisku w stanie aktywnym", "forceCrLfOnSave": "Wymuś CR+LF przy zapisywaniu (pliki napisów tekstowych)", + "showFormatLimitWarning": "Wyświetlaj ostrzeżenie podczas zapisywania, gdy długość linii przekracza ograniczenia formatu (np. SCC – 32 znaki/linię)", "textBoxButtonShowAutoBreak": "Pole tekstowe: pokaż przycisk automatycznego łamania linii", "textBoxButtonShowUnbreak": "Pole tekstowe: pokaż przycisk usuwania łamania linii", "textBoxButtonShowItalic": "Pole tekstowe: pokaż przycisk kursywy", @@ -3052,6 +3069,7 @@ "seekSilenceBack": "Szukaj ciszy do tyłu", "seekSilenceForward": "Szukaj ciszy do przodu", "setVideoPositionCurrentSubtitleStart": "Ustaw pozycję wideo na początek bieżącej linii", + "goToSubtitlePositionAndPause": "Przejdź do pozycji napisu i wstrzymaj", "setVideoPositionCurrentSubtitleEnd": "Ustaw pozycję wideo na koniec bieżącej linii", "toggleAudioTracks": "Przełącz ścieżki audio", "goToNextError": "Przejdź do następnego błędu", From 35ee965f4e4efd23d8f1deb674dc837f3f9bf3d8 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sun, 23 Aug 2026 08:00:31 +0200 Subject: [PATCH 019/380] FixContinuationStyle: sanitize each paragraph once instead of twice The rule walks paragraph pairs and called ContinuationUtilities.SanitizeString on both p and pNext every iteration, so every paragraph was sanitized twice: once as pNext's text, then again as p's text one iteration later. SanitizeString runs four regex replaces per call. Carry the sanitized "next" value forward instead. The carry is captured before the Arabic conversion (which the next iteration reapplies) and dropped whenever the loop writes pNext.Text, so the following iteration re-sanitizes what the paragraph actually holds. Over a 1500-cue file: SanitizeString calls 3462 -> 1964 (-43%), and the rule goes 0.926 ms -> 0.654 ms (-29%). On a continuation-heavy 1200-cue file, where more paragraphs get rewritten and drop the carry, 2.274 ms -> 2.111 ms (-7%). Verified output-identical across 192 configurations (2 corpora x 12 continuation styles x 4 languages x 2 lyrics settings), comparing every paragraph text and every callback log entry - 85,116 recorded fix events, identical checksums. A further 2.4M randomized rule invocations comparing carry against no-carry found no divergence. Adds tests: FixContinuationStyleTest pins a case that actually discriminates a correct carry from a wrong one (a plain continuation chain does not - it passes either way), and UtilitiesCountTagInTextTest covers Utilities.CountTagInText, which had no direct tests despite having two implementations to keep in step. Co-Authored-By: Claude Opus 5 --- .../FixCommonErrors/FixContinuationStyle.cs | 11 ++++- .../Common/UtilitiesCountTagInTextTest.cs | 35 ++++++++++++++++ .../FixContinuationStyleTest.cs | 42 +++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 tests/libse/Common/UtilitiesCountTagInTextTest.cs create mode 100644 tests/libse/Forms/FixCommonErrors/FixContinuationStyleTest.cs diff --git a/src/libse/Forms/FixCommonErrors/FixContinuationStyle.cs b/src/libse/Forms/FixCommonErrors/FixContinuationStyle.cs index 9c3f473efbd..a4c544b0b12 100644 --- a/src/libse/Forms/FixCommonErrors/FixContinuationStyle.cs +++ b/src/libse/Forms/FixCommonErrors/FixContinuationStyle.cs @@ -40,14 +40,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 +215,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/tests/libse/Common/UtilitiesCountTagInTextTest.cs b/tests/libse/Common/UtilitiesCountTagInTextTest.cs new file mode 100644 index 00000000000..d4663ee7e8d --- /dev/null +++ b/tests/libse/Common/UtilitiesCountTagInTextTest.cs @@ -0,0 +1,35 @@ +using Nikse.SubtitleEdit.Core.Common; + +namespace LibSETests.Common; + +public class UtilitiesCountTagInTextTest +{ + // The char overload has two implementations: MemoryExtensions.Count on net8+ and an + // IndexOf loop on netstandard2.1. Both must agree on these, in particular on a hit that + // lands on the very last index - the loop returns early from inside its body there. + [Theory] + [InlineData("", '"', 0)] + [InlineData("no quotes here", '"', 0)] + [InlineData("\"", '"', 1)] + [InlineData("say \"this\"", '"', 2)] + [InlineData("\"\"\"", '"', 3)] + [InlineData("- No.\r\n- Then stay.", '-', 2)] + [InlineData("{\\an8}{\\pos(10,20)}Hi", '{', 2)] + [InlineData("aaa", 'a', 3)] + public void CountsEveryOccurrenceOfChar(string text, char tag, int expected) + { + Assert.Equal(expected, Utilities.CountTagInText(text, tag)); + } + + // The char and string overloads are separate implementations; for a single-character tag + // they must not drift apart. + [Theory] + [InlineData("\"Are you coming?\" she asked.", '"')] + [InlineData("- Yes.\r\n- No.", '-')] + [InlineData("nothing to find", 'z')] + [InlineData("trailing hit-", '-')] + public void CharOverloadAgreesWithStringOverload(string text, char tag) + { + Assert.Equal(Utilities.CountTagInText(text, tag.ToString()), Utilities.CountTagInText(text, tag)); + } +} diff --git a/tests/libse/Forms/FixCommonErrors/FixContinuationStyleTest.cs b/tests/libse/Forms/FixCommonErrors/FixContinuationStyleTest.cs new file mode 100644 index 00000000000..ab8c8c82581 --- /dev/null +++ b/tests/libse/Forms/FixCommonErrors/FixContinuationStyleTest.cs @@ -0,0 +1,42 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Enums; +using Nikse.SubtitleEdit.Core.Forms.FixCommonErrors; + +namespace LibSETests.Forms.FixCommonErrors; + +public class FixContinuationStyleTest +{ + // The rule walks paragraph pairs and carries paragraph i+1's sanitized text forward to use + // as paragraph i's on the next iteration, rather than sanitizing every paragraph twice. + // Carrying the wrong side of the pair still produces the right answer on a plain + // continuation chain, so this pins a case that actually discriminates: only paragraph 2 + // continues into paragraph 3 (40 ms gap), while paragraph 1 ends a sentence and paragraph 4 + // is too far away (1600 ms). Get the carry wrong and paragraph 2 keeps its dots-less text. + [Fact] + public void OnlyTheContinuingParagraphGetsTrailingDots() + { + var previousStyle = Configuration.Settings.General.ContinuationStyle; + try + { + Configuration.Settings.General.ContinuationStyle = ContinuationStyle.OnlyTrailingDots; + + var subtitle = new Subtitle(); + subtitle.Paragraphs.Add(new Paragraph("Wait!", 0, 1122)); + subtitle.Paragraphs.Add(new Paragraph("I was going to tell you", 1162, 3083)); + subtitle.Paragraphs.Add(new Paragraph("and now it is too late", 3123, 4220)); + subtitle.Paragraphs.Add(new Paragraph("[door slams]", 5820, 7720)); + subtitle.Renumber(); + + new FixContinuationStyle { FixAction = "act" }.Fix(subtitle, new EmptyFixCallback()); + + Assert.Equal("Wait!", subtitle.Paragraphs[0].Text); + Assert.Equal("I was going to tell you...", subtitle.Paragraphs[1].Text); + Assert.Equal("and now it is too late", subtitle.Paragraphs[2].Text); + Assert.Equal("[door slams]", subtitle.Paragraphs[3].Text); + } + finally + { + Configuration.Settings.General.ContinuationStyle = previousStyle; + } + } +} From 641da8ad5a89f1db2f6732fd7a389a4f6d685dbb Mon Sep 17 00:00:00 2001 From: niksedk Date: Sun, 23 Aug 2026 08:03:58 +0200 Subject: [PATCH 020/380] AI review/assistant: keep ASSA override blocks away from the model, send actor/style as context (#13927) Leading and trailing {\...} blocks are stripped before a line is sent and restored verbatim around the model's answer, so tag-heavy ASSA lines no longer waste tokens or get their corrections dropped by the tag guard. Pure drawing/tag lines are skipped. The ASSA actor and style name go along as read-only context. Same stripping for the AI assistant's current line and its context lines. Co-Authored-By: Claude Fable 5 --- docs/features/ai-review.md | 3 +- src/ui/Features/Main/MainViewModel.cs | 9 +- .../Tools/AiReview/AiReviewChunker.cs | 7 +- .../Tools/AiReview/AiReviewProtocol.cs | 15 ++- .../Tools/AiReview/AiReviewViewModel.cs | 21 +++- .../Tools/AiReview/AssaTagStripper.cs | 65 +++++++++++++ src/ui/Logic/Config/SeAiReview.cs | 2 +- .../Tools/AiReview/AssaTagStripperTests.cs | 97 +++++++++++++++++++ 8 files changed, 207 insertions(+), 12 deletions(-) create mode 100644 src/ui/Features/Tools/AiReview/AssaTagStripper.cs create mode 100644 tests/UI/Features/Tools/AiReview/AssaTagStripperTests.cs diff --git a/docs/features/ai-review.md b/docs/features/ai-review.md index f9c9f8b3819..e5e0aff3e42 100644 --- a/docs/features/ai-review.md +++ b/docs/features/ai-review.md @@ -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/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index a95acc9101c..1d5b266d9ea 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -7219,7 +7219,7 @@ private async Task ShowAiAssistant() continue; } - var text = Subtitles[i].Text; + var text = StrippedLine.RemoveAllBlocks(Subtitles[i].Text); if (!string.IsNullOrWhiteSpace(text)) { context.AppendLine(text.Replace(Environment.NewLine, " ").Replace("\n", " ")); @@ -7229,9 +7229,12 @@ private async Task ShowAiAssistant() var languageName = TwoLetterCodeToLanguageName( LanguageAutoDetect.AutoDetectGoogleLanguageOrNull(GetUpdateSubtitle())); + // ASSA override blocks around the line stay on our side (#13927): the model only gets + // the text, and the blocks are glued back on around whatever it returns. + var stripped = StrippedLine.Strip(current.Text); var result = await ShowDialogAsync(vm => vm.Initialize( - current.Text, + stripped.Text, context.ToString().TrimEnd(), languageName, Se.Settings.General.SubtitleMaximumCharactersPerSeconds, @@ -7239,7 +7242,7 @@ private async Task ShowAiAssistant() if (result.ApplyPressed && !string.IsNullOrEmpty(result.ResultToApply)) { - current.Text = result.ResultToApply; + current.Text = stripped.Restore(result.ResultToApply); SelectAndScrollToRow(index); } } 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 +158,7 @@ public void Initialize( Action? applyCallback = null) { _subtitle = subtitle; + _subtitleFormat = subtitleFormat; _playLine = playLine; _stopPlayback = stopPlayback; _applyCallback = applyCallback; @@ -375,13 +380,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); @@ -596,7 +607,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/AssaTagStripper.cs b/src/ui/Features/Tools/AiReview/AssaTagStripper.cs new file mode 100644 index 00000000000..9a1e0c988e8 --- /dev/null +++ b/src/ui/Features/Tools/AiReview/AssaTagStripper.cs @@ -0,0 +1,65 @@ +using System.Text.RegularExpressions; + +namespace Nikse.SubtitleEdit.Features.Tools.AiReview; + +/// +/// Keeps ASSA override blocks ("{\pos(946.5,250.8)\fs54\1c&HFDF9AA&}") away from the model +/// (#13927): leading and trailing blocks are cut off before a line is sent and glued back on +/// verbatim around the model's answer. They carry no meaning for proofreading, cost a lot of +/// tokens, and small models "normalize" them so often that the tag guard used to drop the +/// whole correction. Blocks in the middle of the text are left in place - they are rare, and +/// the guard still verifies them. +/// +public readonly record struct StrippedLine(string Prefix, string Text, string Suffix) +{ + private static readonly Regex LeadingBlocks = new Regex(@"^(\s*\{\\[^}]*\})+", RegexOptions.Compiled); + private static readonly Regex TrailingBlocks = new Regex(@"(\{\\[^}]*\}\s*)+$", RegexOptions.Compiled); + private static readonly Regex AnyBlock = new Regex(@"\{\\[^}]*\}", RegexOptions.Compiled); + private static readonly Regex DrawingMode = new Regex(@"\\p[1-9]", RegexOptions.Compiled); + + public static StrippedLine Strip(string? input) + { + var text = input ?? string.Empty; + var prefix = string.Empty; + var suffix = string.Empty; + + var lead = LeadingBlocks.Match(text); + if (lead.Success) + { + if (DrawingMode.IsMatch(lead.Value)) + { + // vector drawing ("{\p1}m 0 0 l 100 0 ...") - the "text" is shape commands, not words + return new StrippedLine(text, string.Empty, string.Empty); + } + + prefix = lead.Value; + text = text.Substring(lead.Length); + } + + var trail = TrailingBlocks.Match(text); + if (trail.Success) + { + suffix = trail.Value; + text = text.Substring(0, trail.Index); + } + + // keep the whitespace next to the blocks with the blocks, so the restored line is byte-identical + var trimmedStart = text.TrimStart(); + prefix += text.Substring(0, text.Length - trimmedStart.Length); + var trimmed = trimmedStart.TrimEnd(); + suffix = trimmedStart.Substring(trimmed.Length) + suffix; + + return new StrippedLine(prefix, trimmed, suffix); + } + + public string Restore(string newText) + { + return Prefix + newText + Suffix; + } + + /// Removes every override block - for read-only context the model must not edit. + public static string RemoveAllBlocks(string? input) + { + return AnyBlock.Replace(input ?? string.Empty, string.Empty); + } +} 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/tests/UI/Features/Tools/AiReview/AssaTagStripperTests.cs b/tests/UI/Features/Tools/AiReview/AssaTagStripperTests.cs new file mode 100644 index 00000000000..70857f56358 --- /dev/null +++ b/tests/UI/Features/Tools/AiReview/AssaTagStripperTests.cs @@ -0,0 +1,97 @@ +using Nikse.SubtitleEdit.Features.Tools.AiReview; +using System.Collections.Generic; + +namespace UITests.Features.Tools.AiReview; + +public class AssaTagStripperTests +{ + [Fact] + public void Strip_LeadingBlock_RestoresVerbatim() + { + const string input = @"{\bord0\blur0.8\pos(946.5,250.8)\fs54\fax0.13\frz348\fry358\frx4\1c&HFDF9AA&\3c&HFDF9AA&}Overboard"; + + var stripped = StrippedLine.Strip(input); + + Assert.Equal("Overboard", stripped.Text); + Assert.Equal(input, stripped.Restore(stripped.Text)); + Assert.Equal(@"{\bord0\blur0.8\pos(946.5,250.8)\fs54\fax0.13\frz348\fry358\frx4\1c&HFDF9AA&\3c&HFDF9AA&}Overbored", stripped.Restore("Overbored")); + } + + [Fact] + public void Strip_LeadingAndTrailingBlocks_WithWhitespace() + { + const string input = @"{\an8}{\i1} Hello there {\i0} "; + + var stripped = StrippedLine.Strip(input); + + Assert.Equal("Hello there", stripped.Text); + Assert.Equal(input, stripped.Restore(stripped.Text)); + } + + [Fact] + public void Strip_InlineBlock_StaysInText() + { + var stripped = StrippedLine.Strip(@"{\an8}He {\i1}really{\i0} said so."); + + Assert.Equal(@"He {\i1}really{\i0} said so.", stripped.Text); + Assert.Equal(@"{\an8}", stripped.Prefix); + } + + [Fact] + public void Strip_DrawingLine_IsEmpty() + { + Assert.Equal(string.Empty, StrippedLine.Strip(@"{\p1}m 0 0 l 100 0 100 100{\p0}").Text); + Assert.Equal(string.Empty, StrippedLine.Strip(@"{\pos(10,10)\p1}m 0 0 l 100 0{\p0}").Text); + } + + [Fact] + public void Strip_NoTags_Unchanged() + { + var stripped = StrippedLine.Strip("Plain text"); + + Assert.Equal("Plain text", stripped.Text); + Assert.Equal(string.Empty, stripped.Prefix); + Assert.Equal(string.Empty, stripped.Suffix); + } + + [Fact] + public void Strip_CurlyBracesWithoutBackslash_NotATag() + { + var stripped = StrippedLine.Strip("{laughs} Hello"); + + Assert.Equal("{laughs} Hello", stripped.Text); + } + + [Fact] + public void RemoveAllBlocks_RemovesInlineToo() + { + Assert.Equal("He really said so.", StrippedLine.RemoveAllBlocks(@"{\an8}He {\i1}really{\i0} said so.")); + } + + [Fact] + public void BuildUserContent_WritesActorAndStyleOnlyWhenPresent() + { + var chunk = new ReviewChunk(); + chunk.Lines.Add(new ReviewLine(1, "Overboard", "Narrator", "Sign")); + chunk.Lines.Add(new ReviewLine(2, "Hello", null, " ")); + + var json = AiReviewProtocol.BuildUserContent(chunk); + + Assert.Contains("\"n\":1,\"text\":\"Overboard\",\"actor\":\"Narrator\",\"style\":\"Sign\"", json); + Assert.Contains("\"n\":2,\"text\":\"Hello\"}", json); + Assert.DoesNotContain("\"actor\":\"\"", json); + } + + [Fact] + public void ParseChanges_StrippedText_EchoMatchesStrippedLine() + { + var editable = new Dictionary { { 1, "Overbored" } }; + + var changes = AiReviewProtocol.ParseChanges( + "{\"changes\":[{\"n\":1,\"orig\":\"Overbored\",\"text\":\"Overboard\",\"reason\":\"typo\",\"category\":\"spelling\"}]}", + editable); + + Assert.Single(changes); + Assert.Equal("Overboard", changes[0].NewText); + } +} From e1e0ceb8bb19e13adb630d516e90a51facc0aef4 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sun, 23 Aug 2026 08:32:19 +0200 Subject: [PATCH 021/380] Speech to text: quality report + non-speech/repeat removal + overlap fix (#13973) Post-processing now analyzes the final transcript for the defect classes from issue #13973 - lines too short to read (min display time / max cps), too long (max display time, or a few words stretched over 4+ s), overlaps, non-speech descriptions like "[Music]", and repeated lines (engine loops) - and shows a quality report window after a single-file transcription with summary cards (click to filter) and the affected lines. Batch runs log a one-line summary instead. Two new opt-in post-processing options remove non-speech lines and repeated lines; "Fix short duration" now also runs the overlap fixer so engine overlaps don't land in the grid as red. The report can be turned off with "Do not show this report again" and re-enabled in the post-processing settings. Co-Authored-By: Claude Fable 5 --- src/ui/DependencyInjectionExtensions.cs | 2 + .../SpeechToTextPostProcessingViewModel.cs | 5 +- .../SpeechToTextPostProcessingWindow.cs | 39 ++- .../SpeechToText/SpeechToTextPostProcessor.cs | 51 ++++ .../SpeechToText/SpeechToTextQualityReport.cs | 211 +++++++++++++ .../SpeechToTextQualityReportViewModel.cs | 228 +++++++++++++++ .../SpeechToTextQualityReportWindow.cs | 276 ++++++++++++++++++ .../SpeechToText/SpeechToTextViewModel.cs | 37 +++ .../Language/Video/LanguageAudioToText.cs | 56 +++- src/ui/Logic/Config/SeAudioToText.cs | 6 + .../SpeechToTextPostProcessorTests.cs | 139 +++++++++ 11 files changed, 1047 insertions(+), 3 deletions(-) create mode 100644 src/ui/Features/Video/SpeechToText/SpeechToTextQualityReport.cs create mode 100644 src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportViewModel.cs create mode 100644 src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportWindow.cs diff --git a/src/ui/DependencyInjectionExtensions.cs b/src/ui/DependencyInjectionExtensions.cs index af1140bae95..1216bb8afa2 100644 --- a/src/ui/DependencyInjectionExtensions.cs +++ b/src/ui/DependencyInjectionExtensions.cs @@ -213,6 +213,7 @@ using VideoPlayerUndockedViewModel = Nikse.SubtitleEdit.Features.Shared.Undocked.VideoPlayerUndockedViewModel; using SpeechToTextAdvancedViewModel = Nikse.SubtitleEdit.Features.Video.SpeechToText.SpeechToTextAdvancedViewModel; using SpeechToTextPostProcessingViewModel = Nikse.SubtitleEdit.Features.Video.SpeechToText.SpeechToTextPostProcessingViewModel; +using SpeechToTextQualityReportViewModel = Nikse.SubtitleEdit.Features.Video.SpeechToText.SpeechToTextQualityReportViewModel; namespace Nikse.SubtitleEdit; @@ -571,6 +572,7 @@ public static void AddSubtitleEditServices(this IServiceCollection collection) collection.AddTransient(); collection.AddTransient(); collection.AddTransient(); + collection.AddTransient(); collection.AddTransient(); } 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..eca5058f4db 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); @@ -138,6 +164,17 @@ public SpeechToTextPostProcessingWindow(SpeechToTextPostProcessingViewModel vm) Activated += delegate { 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) { base.OnKeyDown(e); diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessor.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessor.cs index 3a8a49f583c..963506f0692 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessor.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextPostProcessor.cs @@ -35,6 +35,25 @@ public enum Engine 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 +96,27 @@ public Subtitle Fix( Color changeUnderlineToColorColor) { var subtitle = new Subtitle(); + QualityReport = new SpeechToTextQualityReport(); 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, $"= #{index}")); + continue; + } + if (usePostProcessing && engine == Engine.Vosk && TwoLetterLanguageCode == "en" && paragraph.Text == "the" && paragraph.EndTime.TotalSeconds - paragraph.StartTime.TotalSeconds > 1) { continue; @@ -135,6 +171,9 @@ public Subtitle Fix( } } + var general = Configuration.Settings.General; + QualityReport.Analyze(postProcessed, general.SubtitleMinimumDisplayMilliseconds, general.SubtitleMaximumDisplayMilliseconds, general.SubtitleMaximumCharactersPerSeconds); + return postProcessed; } @@ -160,6 +199,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)) @@ -581,5 +625,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..4d67b28b368 --- /dev/null +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportViewModel.cs @@ -0,0 +1,228 @@ +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; +} + +/// A clickable summary card / filter: one per issue type, plus "All" and "Removed". +public partial class QualityReportCard : 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); + } + + /// null = all issues; Removed uses . + public SpeechToTextQualityIssueType? Type { get; init; } + public bool IsRemovedFilter { 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; +} + +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(); + + 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 QualityReportCard { Type = 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 QualityReportCard { Type = type, Label = GetLabel(type), Hint = GetHint(type), Count = report.Count(type), Brush = GetBrush(type) }); + } + + if (report.Removed.Count > 0) + { + Cards.Add(new QualityReportCard { IsRemovedFilter = true, 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(QualityReportCard? card) + { + if (card == null) + { + return; + } + + ApplyFilter(card); + } + + private void ApplyFilter(QualityReportCard card) + { + foreach (var c in Cards) + { + c.IsActive = ReferenceEquals(c, card); + } + + IEnumerable filtered = _allItems; + if (card.IsRemovedFilter) + { + filtered = _allItems.Where(p => p.IsRemoved); + } + else if (card.Type != null) + { + filtered = _allItems.Where(p => !p.IsRemoved && p.Type == card.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..d21f118b95a --- /dev/null +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportWindow.cs @@ -0,0 +1,276 @@ +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 = new ItemsControl + { + ItemsSource = vm.Cards, + Margin = new Thickness(0, 0, 0, 12), + ItemsPanel = new FuncTemplate(() => new WrapPanel { Orientation = Orientation.Horizontal, ItemSpacing = 8, LineSpacing = 8 }), + ItemTemplate = new FuncDataTemplate((card, _) => MakeCard(card)), + }; + + // 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; + + Activated += delegate { buttonOk.Focus(); }; // hack to make OnKeyDown work + } + + private Control MakeCard(QualityReportCard? card) + { + 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 }, + }; + + // A bordered button rather than a ToggleButton: the Fluent checked state + // floods the card with the accent color and drowns the colored count. + var border = new Border + { + Child = content, + Padding = new Thickness(12, 8), + MinWidth = 120, + BorderThickness = new Thickness(2), + CornerRadius = new CornerRadius(UiUtil.CornerRadius + 2), + [!Border.BorderBrushProperty] = new Binding(nameof(QualityReportCard.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 = _vm.SetFilterCommand, + CommandParameter = card, + Opacity = card.Count == 0 && card.Type != 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; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + _vm.OnKeyDown(e); + } +} diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs index 391c66c764a..c461f1e0e0c 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; @@ -1986,6 +1987,8 @@ 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, }; WavePeakData2? wavePeaks = null; @@ -2014,6 +2017,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; } @@ -2456,6 +2464,7 @@ await MessageBox.Show(Window!, "No result file", } else if (anyLinesTranscribed) { + await ShowQualityReport(); OkPressed = anyLinesTranscribed; TranscribedSubtitle = transcribedSubtitle ?? new Subtitle(); Window?.Close(); @@ -2473,6 +2482,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) @@ -3083,6 +3114,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,6 +3130,9 @@ 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(); } 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/SeAudioToText.cs b/src/ui/Logic/Config/SeAudioToText.cs index 9b637b8d96d..e60be20d3f8 100644 --- a/src/ui/Logic/Config/SeAudioToText.cs +++ b/src/ui/Logic/Config/SeAudioToText.cs @@ -83,6 +83,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; diff --git a/tests/UI/Features/Video/SpeechToText/SpeechToTextPostProcessorTests.cs b/tests/UI/Features/Video/SpeechToText/SpeechToTextPostProcessorTests.cs index 22987b05a93..8013a1d31cb 100644 --- a/tests/UI/Features/Video/SpeechToText/SpeechToTextPostProcessorTests.cs +++ b/tests/UI/Features/Video/SpeechToText/SpeechToTextPostProcessorTests.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.Common; +using System.Linq; using Nikse.SubtitleEdit.Features.Video.SpeechToText; namespace UITests.Features.Video.SpeechToText; @@ -56,3 +57,141 @@ public void MergeShortLines_UsesTheLineLengthCapForTheLanguage(string languageCo Assert.Equal(expectedMaxChars, postProcessor.ParagraphMaxChars); } } + +// Issue #13973: the post-processor should tell the user what went wrong +// (too short / too long / overlaps / non-speech / repeats) and optionally drop +// the non-speech and looping lines. +public class SpeechToTextQualityReportTests +{ + private static Subtitle Make(params (string text, double startMs, double endMs)[] lines) + { + var s = new Subtitle(); + foreach (var (text, start, end) in lines) + { + s.Paragraphs.Add(new Paragraph(text, start, end)); + } + + return s; + } + + [Theory] + [InlineData("[Music]", true)] + [InlineData("(waves crashing)", true)] + [InlineData("♪ ♪", true)] + [InlineData("[Música] (risas)", true)] + [InlineData("[Applause]", true)] + [InlineData("[John] Hello there", false)] + [InlineData("Hello (maybe)", false)] + [InlineData("Hello", false)] + [InlineData("", false)] + public void IsNonSpeechLine(string text, bool expected) + { + Assert.Equal(expected, SpeechToTextQualityReport.IsNonSpeechLine(text)); + } + + [Theory] + [InlineData("Thank you.", "thank you", true)] + [InlineData("Thank you.", "Thank you!", true)] + [InlineData("Thank you.", "Thanks.", false)] + [InlineData("...", "...", false)] + public void IsRepeatOf(string text, string previous, bool expected) + { + Assert.Equal(expected, SpeechToTextQualityReport.IsRepeatOf(text, previous)); + } + + [Fact] + public void Analyze_FindsEachIssueType() + { + var subtitle = Make( + ("Hi", 0, 200), // too short + ("The boy ran up the hill and kept on running all the way", 1000, 1500), // too fast + ("Yes", 3000, 20000), // too long (sparse) + ("Overlaps next", 21000, 23000), + ("Overlapped", 22000, 24000), + ("[Music]", 25000, 27000), + ("[Music]", 28000, 30000)); // repeat + non-speech + + var report = new SpeechToTextQualityReport(); + report.Analyze(subtitle, 1000, 8000, 25.0); + + Assert.Equal(7, report.TotalLines); + Assert.Equal(2, report.Count(SpeechToTextQualityIssueType.TooShort)); + Assert.Equal(1, report.Count(SpeechToTextQualityIssueType.TooLong)); + Assert.Equal(1, report.Count(SpeechToTextQualityIssueType.Overlap)); + Assert.Equal(2, report.Count(SpeechToTextQualityIssueType.NonSpeech)); + Assert.Equal(0, report.Count(SpeechToTextQualityIssueType.Repeated)); // non-speech wins over repeat + Assert.Equal(4, report.Issues.Single(p => p.Type == SpeechToTextQualityIssueType.Overlap).Number); + Assert.True(report.HasIssues); + } + + [Fact] + public void Analyze_CleanSubtitle_HasNoIssues() + { + var subtitle = Make(("Hello there.", 0, 1500), ("How are you?", 1600, 3000)); + + var report = new SpeechToTextQualityReport(); + report.Analyze(subtitle, 1000, 8000, 25.0); + + Assert.False(report.HasIssues); + Assert.Contains("no issues", report.ToLogString()); + } + + [Fact] + public void Fix_RemovesNonSpeechAndRepeatedLines_WhenEnabled() + { + var subtitle = Make( + ("Hello there.", 0, 1500), + ("[Music]", 2000, 3500), + ("Thank you.", 4000, 5500), + ("Thank you.", 6000, 7500), + ("Thank you.", 8000, 9500), + ("Bye.", 10000, 11500)); + + var pp = new SpeechToTextPostProcessor("en") { RemoveNonSpeechLines = true, RemoveRepeatedLines = true }; + var result = pp.Fix(SpeechToTextPostProcessor.Engine.Whisper, subtitle, true, false, false, false, false, false, false, Avalonia.Media.Colors.Red); + + Assert.Equal(new[] { "Hello there.", "Thank you.", "Bye." }, result.Paragraphs.Select(p => p.Text).ToArray()); + Assert.Equal(1, pp.QualityReport.RemovedCount(SpeechToTextQualityIssueType.NonSpeech)); + Assert.Equal(2, pp.QualityReport.RemovedCount(SpeechToTextQualityIssueType.Repeated)); + Assert.Equal(0, pp.QualityReport.Count(SpeechToTextQualityIssueType.Repeated)); + } + + [Fact] + public void Fix_KeepsLinesButReportsThem_WhenDisabled() + { + var subtitle = Make(("Hello there.", 0, 1500), ("[Music]", 2000, 3500), ("Bye.", 4000, 5500), ("Bye.", 6000, 7500)); + + var pp = new SpeechToTextPostProcessor("en"); + var result = pp.Fix(SpeechToTextPostProcessor.Engine.Whisper, subtitle, true, false, false, false, false, false, false, Avalonia.Media.Colors.Red); + + Assert.Equal(4, result.Paragraphs.Count); + Assert.Empty(pp.QualityReport.Removed); + Assert.Equal(1, pp.QualityReport.Count(SpeechToTextQualityIssueType.NonSpeech)); + Assert.Equal(1, pp.QualityReport.Count(SpeechToTextQualityIssueType.Repeated)); + } + + [Fact] + public void Fix_FixShortDuration_AlsoFixesOverlaps() + { + var subtitle = Make(("First line here.", 0, 3000), ("Second line here.", 2000, 5000)); + + var pp = new SpeechToTextPostProcessor("en"); + var result = pp.Fix(SpeechToTextPostProcessor.Engine.Whisper, subtitle, true, false, false, false, true, false, false, Avalonia.Media.Colors.Red); + + Assert.True(result.Paragraphs[0].EndTime.TotalMilliseconds <= result.Paragraphs[1].StartTime.TotalMilliseconds); + Assert.Equal(0, pp.QualityReport.Count(SpeechToTextQualityIssueType.Overlap)); + } + + [Fact] + public void Fix_ReportIsPopulated_EvenWithoutPostProcessing() + { + var subtitle = Make(("Hi", 0, 100), ("[Music]", 200, 1500)); + + var pp = new SpeechToTextPostProcessor("en"); + pp.Fix(SpeechToTextPostProcessor.Engine.Whisper, subtitle, false, false, false, false, false, false, false, Avalonia.Media.Colors.Red); + + Assert.Equal(2, pp.QualityReport.TotalLines); + Assert.Equal(1, pp.QualityReport.Count(SpeechToTextQualityIssueType.TooShort)); + Assert.Equal(1, pp.QualityReport.Count(SpeechToTextQualityIssueType.NonSpeech)); + } +} From 043acd8b61fc394c46276c2b87e79060aa70899d Mon Sep 17 00:00:00 2001 From: niksedk Date: Sun, 23 Aug 2026 08:44:24 +0200 Subject: [PATCH 022/380] List errors: summary cards, per-error rows, typed error classes Rebuild the "List errors" window in the style of the speech-to-text quality report: a verdict header ("N error(s) in X of Y lines"), one summary card per error class (click to filter), and a table with one row per error (colour dot + class, show/hide, detail, text) instead of a multi-line error blob. Double-click / Enter / Go to behave as before. SubtitleLineViewModel.GetErrors now builds on a typed GetErrorList (LineError + LineErrorType), and the previously hard-coded English error strings move to a new LanguageErrorList section. The card control is lifted into a shared SummaryCard used by both windows. Co-Authored-By: Claude Fable 5 --- src/ui/Features/Main/MainViewModel.cs | 4 +- src/ui/Features/Main/SubtitleLineViewModel.cs | 87 ++++++----- .../Shared/ErrorList/ErrorListItem.cs | 40 ++++- .../Shared/ErrorList/ErrorListViewModel.cs | 69 ++++++++- .../Shared/ErrorList/ErrorListWindow.cs | 144 ++++++++++++++---- src/ui/Features/Shared/ErrorList/LineError.cs | 86 +++++++++++ .../SpeechToTextQualityReportViewModel.cs | 41 ++--- .../SpeechToTextQualityReportWindow.cs | 82 +--------- .../Config/Language/LanguageErrorList.cs | 65 ++++++++ src/ui/Logic/Config/Language/SeLanguage.cs | 1 + src/ui/Logic/SummaryCard.cs | 124 +++++++++++++++ .../Main/SubtitleMetricsRegressionTests.cs | 7 +- .../Shared/PrePopulatedGridSelectionTests.cs | 6 +- 13 files changed, 550 insertions(+), 206 deletions(-) create mode 100644 src/ui/Features/Shared/ErrorList/LineError.cs create mode 100644 src/ui/Logic/Config/Language/LanguageErrorList.cs create mode 100644 src/ui/Logic/SummaryCard.cs diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 1d5b266d9ea..0a31d78b23c 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -11977,11 +11977,11 @@ private async Task ListErrors() var next = i < Subtitles.Count - 1 ? Subtitles[i + 1] : null; if (s.HasErrors(prev, next)) { - list.Add(new ErrorListItem(s, prev, next)); + list.AddRange(ErrorListItem.Make(s, prev, next)); } } - var result = await ShowDialogAsync(vm => { vm.Initialize(list); }); + var result = await ShowDialogAsync(vm => { vm.Initialize(list, Subtitles.Count); }); if (result.GoToPressed && result.SelectedSubtitle != null) { diff --git a/src/ui/Features/Main/SubtitleLineViewModel.cs b/src/ui/Features/Main/SubtitleLineViewModel.cs index 1ccb6802f6e..3ea569ae19e 100644 --- a/src/ui/Features/Main/SubtitleLineViewModel.cs +++ b/src/ui/Features/Main/SubtitleLineViewModel.cs @@ -1,7 +1,8 @@ -using Avalonia.Media; +using Avalonia.Media; using CommunityToolkit.Mvvm.ComponentModel; using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Features.Shared.ErrorList; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; using SkiaSharp; @@ -1223,108 +1224,112 @@ private static bool HasGapError(SeGeneral general, double gapMs) ? general.ColorTimeCodeOverlap : general.ColorGapTooShort && gapMs < general.MinimumBetweenLines.GetMilliseconds(); + /// All errors as one newline-separated string (batch error list, tooltips). public string GetErrors(SubtitleLineViewModel? prev, SubtitleLineViewModel? next) { var errors = new StringBuilder(); + foreach (var error in GetErrorList(prev, next)) + { + errors.AppendLine(error.ToString()); + } + + return errors.ToString(); + } + /// + /// The errors on this line as typed entries, so "List errors" can count and filter + /// by class. Same rules as ; keep the two in sync. + /// + public List GetErrorList(SubtitleLineViewModel? prev, SubtitleLineViewModel? next) + { + var errors = new List(); var general = Se.Settings.General; + var l = Se.Language.ErrorList; - if (Se.Settings.General.ColorTextTooManyLines) + if (general.ColorTextTooManyLines) { var lineCount = GetStrippedLines().Count; if (lineCount > general.MaxNumberOfLines) { - errors.AppendLine("Max #lines: " + lineCount + " >" + general.MaxNumberOfLines); + errors.Add(new LineError(LineErrorType.TooManyLines, string.Format(l.DetailXGreaterThanY, lineCount, general.MaxNumberOfLines))); } } var cpsRounded = Math.Round(CharactersPerSecond, 2, MidpointRounding.AwayFromZero); - if (cpsRounded > general.SubtitleMaximumCharactersPerSeconds && Se.Settings.General.ColorCharactersPerSecond) + if (cpsRounded > general.SubtitleMaximumCharactersPerSeconds && general.ColorCharactersPerSecond) { - errors.AppendLine("Cps: " + cpsRounded + " > " + general.SubtitleMaximumCharactersPerSeconds); + errors.Add(new LineError(LineErrorType.CharactersPerSecond, string.Format(l.DetailXGreaterThanY, cpsRounded, general.SubtitleMaximumCharactersPerSeconds))); } var durMsRounded = Math.Round(Duration.TotalMilliseconds, 3, MidpointRounding.AwayFromZero); - if (durMsRounded < general.SubtitleMinimumDisplayMilliseconds) + if (durMsRounded < general.SubtitleMinimumDisplayMilliseconds && general.ColorDurationTooShort) { - if (Se.Settings.General.ColorDurationTooShort) - { - errors.AppendLine("Min duration: " + durMsRounded + " < " + general.SubtitleMinimumDisplayMilliseconds); - } + errors.Add(new LineError(LineErrorType.DurationTooShort, string.Format(l.DetailXLessThanY, durMsRounded, general.SubtitleMinimumDisplayMilliseconds))); } - if (durMsRounded > general.SubtitleMaximumDisplayMilliseconds) + + if (durMsRounded > general.SubtitleMaximumDisplayMilliseconds && general.ColorDurationTooLong) { - if (Se.Settings.General.ColorDurationTooLong) - { - errors.AppendLine("Max duration: " + durMsRounded + " > " + general.SubtitleMaximumDisplayMilliseconds); - } + errors.Add(new LineError(LineErrorType.DurationTooLong, string.Format(l.DetailXGreaterThanY, durMsRounded, general.SubtitleMaximumDisplayMilliseconds))); } - if (Se.Settings.General.ColorTextTooLong) + if (general.ColorTextTooLong) { foreach (var line in GetStrippedLines()) { var lineLength = SubtitleTextInfoHelper.GetLineLength(line); if (lineLength > general.SubtitleLineMaximumLength) { - errors.AppendLine("Max line length: " + lineLength + " > " + general.SubtitleLineMaximumLength); + errors.Add(new LineError(LineErrorType.LineTooLong, string.Format(l.DetailXGreaterThanY, lineLength, general.SubtitleLineMaximumLength))); } } } - if (Se.Settings.General.ColorTextTooWide) + if (general.ColorTextTooWide) { foreach (var line in GetStrippedLines()) { var pixelWidth = CalculatePixelWidth(line); if (pixelWidth > general.ColorTextTooWidePixels) { - errors.AppendLine("Max width (px): " + pixelWidth + " > " + general.ColorTextTooWidePixels); + errors.Add(new LineError(LineErrorType.LineTooWide, string.Format(l.DetailXGreaterThanY, pixelWidth, general.ColorTextTooWidePixels))); } } } + var minGap = general.MinimumBetweenLines.GetMilliseconds(); if (prev != null) { var gapPrev = (StartTime - prev.EndTime).TotalMilliseconds; if (gapPrev < 0) { - if (Se.Settings.General.ColorTimeCodeOverlap) + if (general.ColorTimeCodeOverlap) { - errors.AppendLine("Overlap from previous: " + Math.Round(-gapPrev, 3)); + errors.Add(new LineError(LineErrorType.Overlap, string.Format(l.DetailOverlapFromPrevious, Math.Round(-gapPrev, 3)))); } } - else if (gapPrev < general.MinimumBetweenLines.GetMilliseconds()) + else if (gapPrev < minGap && general.ColorGapTooShort) { - if (Se.Settings.General.ColorGapTooShort) - { - errors.AppendLine("Min gap to previous: " + Math.Round(gapPrev, 3) + " < " + general.MinimumBetweenLines.GetMilliseconds()); - } + errors.Add(new LineError(LineErrorType.GapTooShort, string.Format(l.DetailGapToPrevious, Math.Round(gapPrev, 3), minGap))); } } - if (next == null) - { - return errors.ToString(); - } - - var gapNext = (next.StartTime - EndTime).TotalMilliseconds; - if (gapNext < 0) + if (next != null) { - if (Se.Settings.General.ColorTimeCodeOverlap) + var gapNext = (next.StartTime - EndTime).TotalMilliseconds; + if (gapNext < 0) { - errors.AppendLine("Overlap to next: " + Math.Round(-gapNext, 3)); + if (general.ColorTimeCodeOverlap) + { + errors.Add(new LineError(LineErrorType.Overlap, string.Format(l.DetailOverlapToNext, Math.Round(-gapNext, 3)))); + } } - } - else if (gapNext < general.MinimumBetweenLines.GetMilliseconds()) - { - if (Se.Settings.General.ColorGapTooShort) + else if (gapNext < minGap && general.ColorGapTooShort) { - errors.AppendLine("Min gap to next: " + Math.Round(gapNext, 3) + " < " + general.MinimumBetweenLines.GetMilliseconds()); + errors.Add(new LineError(LineErrorType.GapTooShort, string.Format(l.DetailGapToNext, Math.Round(gapNext, 3), minGap))); } } - return errors.ToString(); + return errors; } public void RefreshTimeCodes() 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..c957a4b50de 100644 --- a/src/ui/Features/Shared/ErrorList/ErrorListViewModel.cs +++ b/src/ui/Features/Shared/ErrorList/ErrorListViewModel.cs @@ -3,9 +3,12 @@ using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; namespace Nikse.SubtitleEdit.Features.Shared.ErrorList; @@ -14,29 +17,50 @@ public partial class ErrorListViewModel : ObservableObject [ObservableProperty] private ObservableCollection _subtitles; [ObservableProperty] private ErrorListItem? _selectedSubtitle; [ObservableProperty] private bool _hasErrors; + [ObservableProperty] private string _summary = string.Empty; + + public ObservableCollection Cards { get; } = new(); public Window? Window { get; set; } public bool GoToPressed { get; private set; } + private readonly List _allItems = new(); + public ErrorListViewModel() { Subtitles = new ObservableCollection(); } - + [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 +74,41 @@ 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) + { + var l = Se.Language.ErrorList; + _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(); } /// @@ -68,7 +121,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 +134,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..c325e1be113 100644 --- a/src/ui/Features/Shared/ErrorList/ErrorListWindow.cs +++ b/src/ui/Features/Shared/ErrorList/ErrorListWindow.cs @@ -1,29 +1,69 @@ 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 buttonCancel = UiUtil.MakeButtonDone(vm.CancelCommand); var panelButtons = UiUtil.MakeButtonBar(buttonGoTo, buttonCancel); @@ -32,22 +72,24 @@ public ErrorListWindow(ErrorListViewModel vm) { 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; @@ -58,45 +100,85 @@ public ErrorListWindow(ErrorListViewModel vm) private static Border MakeErrorsGridView(ErrorListViewModel 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)); - dataGrid.DoubleTapped += vm.OnBookmarksGridDoubleTapped; - - var fullTimeConverter = new TimeSpanToDisplayFullConverter(); - var shortTimeConverter = new TimeSpanToDisplayShortConverter(); - - // 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..81c23167d6d --- /dev/null +++ b/src/ui/Features/Shared/ErrorList/LineError.cs @@ -0,0 +1,86 @@ +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 static readonly IBrush TooManyLinesBrush = new SolidColorBrush(Color.Parse("#E84393")); + private static readonly IBrush CpsBrush = new SolidColorBrush(Color.Parse("#E8912D")); + private static readonly IBrush TooShortBrush = new SolidColorBrush(Color.Parse("#F1C40F")); + private static readonly IBrush TooLongBrush = new SolidColorBrush(Color.Parse("#9B59B6")); + private static readonly IBrush LineTooLongBrush = new SolidColorBrush(Color.Parse("#3498DB")); + private static readonly IBrush LineTooWideBrush = new SolidColorBrush(Color.Parse("#2980B9")); + private static readonly IBrush OverlapBrush = new SolidColorBrush(Color.Parse("#E74C3C")); + private static readonly IBrush GapBrush = new SolidColorBrush(Color.Parse("#1ABC9C")); + public static readonly IBrush AllBrush = new SolidColorBrush(Color.Parse("#5D8AA8")); + + 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/Video/SpeechToText/SpeechToTextQualityReportViewModel.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportViewModel.cs index 4d67b28b368..4dc3dbff02b 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportViewModel.cs @@ -25,26 +25,6 @@ public class QualityReportDisplayItem public IBrush Brush { get; init; } = Brushes.Gray; } -/// A clickable summary card / filter: one per issue type, plus "All" and "Removed". -public partial class QualityReportCard : 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); - } - - /// null = all issues; Removed uses . - public SpeechToTextQualityIssueType? Type { get; init; } - public bool IsRemovedFilter { 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; -} - public partial class SpeechToTextQualityReportViewModel : ObservableObject { [ObservableProperty] private string _summary = string.Empty; @@ -54,7 +34,10 @@ public partial class SpeechToTextQualityReportViewModel : ObservableObject [ObservableProperty] private ObservableCollection _items = new(); [ObservableProperty] private QualityReportDisplayItem? _selectedItem; - public ObservableCollection Cards { get; } = new(); + 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; } @@ -137,7 +120,7 @@ public void Initialize(SpeechToTextQualityReport report) } Cards.Clear(); - Cards.Add(new QualityReportCard { Type = null, Label = l.QualityReportAll, Count = report.Issues.Count, Brush = AllBrush, IsActive = true }); + Cards.Add(new SummaryCard { Key = null, Label = l.QualityReportAll, Count = report.Issues.Count, Brush = AllBrush, IsActive = true }); foreach (var type in new[] { SpeechToTextQualityIssueType.TooShort, @@ -147,12 +130,12 @@ public void Initialize(SpeechToTextQualityReport report) SpeechToTextQualityIssueType.Repeated, }) { - Cards.Add(new QualityReportCard { Type = type, Label = GetLabel(type), Hint = GetHint(type), Count = report.Count(type), Brush = GetBrush(type) }); + 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 QualityReportCard { IsRemovedFilter = true, Label = l.QualityReportRemoved, Count = report.Removed.Count, Brush = RemovedBrush }); + Cards.Add(new SummaryCard { Key = RemovedKey, Label = l.QualityReportRemoved, Count = report.Removed.Count, Brush = RemovedBrush }); } ApplyFilter(Cards[0]); @@ -176,7 +159,7 @@ private static QualityReportDisplayItem MakeItem(SpeechToTextQualityIssue issue, } [RelayCommand] - private void SetFilter(QualityReportCard? card) + private void SetFilter(SummaryCard? card) { if (card == null) { @@ -186,7 +169,7 @@ private void SetFilter(QualityReportCard? card) ApplyFilter(card); } - private void ApplyFilter(QualityReportCard card) + private void ApplyFilter(SummaryCard card) { foreach (var c in Cards) { @@ -194,13 +177,13 @@ private void ApplyFilter(QualityReportCard card) } IEnumerable filtered = _allItems; - if (card.IsRemovedFilter) + if (ReferenceEquals(card.Key, RemovedKey)) { filtered = _allItems.Where(p => p.IsRemoved); } - else if (card.Type != null) + else if (card.Key is SpeechToTextQualityIssueType type) { - filtered = _allItems.Where(p => !p.IsRemoved && p.Type == card.Type); + filtered = _allItems.Where(p => !p.IsRemoved && p.Type == type); } else { diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportWindow.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportWindow.cs index d21f118b95a..c0b3efb14ea 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportWindow.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextQualityReportWindow.cs @@ -59,13 +59,7 @@ public SpeechToTextQualityReportWindow(SpeechToTextQualityReportViewModel vm) }; // Summary cards - one per issue type, acting as filter buttons - var cards = new ItemsControl - { - ItemsSource = vm.Cards, - Margin = new Thickness(0, 0, 0, 12), - ItemsPanel = new FuncTemplate(() => new WrapPanel { Orientation = Orientation.Horizontal, ItemSpacing = 8, LineSpacing = 8 }), - ItemTemplate = new FuncDataTemplate((card, _) => MakeCard(card)), - }; + var cards = SummaryCard.MakeCardsPanel(vm.Cards, vm.SetFilterCommand); // Issue table var table = TableViewExtras.MakeTableView(alwaysSelected: false, multiSelect: false); @@ -194,80 +188,6 @@ public SpeechToTextQualityReportWindow(SpeechToTextQualityReportViewModel vm) Activated += delegate { buttonOk.Focus(); }; // hack to make OnKeyDown work } - private Control MakeCard(QualityReportCard? card) - { - 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 }, - }; - - // A bordered button rather than a ToggleButton: the Fluent checked state - // floods the card with the accent color and drowns the colored count. - var border = new Border - { - Child = content, - Padding = new Thickness(12, 8), - MinWidth = 120, - BorderThickness = new Thickness(2), - CornerRadius = new CornerRadius(UiUtil.CornerRadius + 2), - [!Border.BorderBrushProperty] = new Binding(nameof(QualityReportCard.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 = _vm.SetFilterCommand, - CommandParameter = card, - Opacity = card.Count == 0 && card.Type != 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; - } - protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); diff --git a/src/ui/Logic/Config/Language/LanguageErrorList.cs b/src/ui/Logic/Config/Language/LanguageErrorList.cs new file mode 100644 index 00000000000..98e76a14cdf --- /dev/null +++ b/src/ui/Logic/Config/Language/LanguageErrorList.cs @@ -0,0 +1,65 @@ +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 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 LanguageErrorList() + { + Title = "List errors"; + SummaryX = "{0} error(s) in {1} of {2} line(s)"; + NoErrors = "No errors found."; + 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."; + } +} 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/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/tests/UI/Features/Main/SubtitleMetricsRegressionTests.cs b/tests/UI/Features/Main/SubtitleMetricsRegressionTests.cs index 4c0c0cf996d..210c097d070 100644 --- a/tests/UI/Features/Main/SubtitleMetricsRegressionTests.cs +++ b/tests/UI/Features/Main/SubtitleMetricsRegressionTests.cs @@ -1,3 +1,4 @@ +using Nikse.SubtitleEdit.Features.Shared.ErrorList; using Avalonia.Headless.XUnit; using Avalonia.Media; using Nikse.SubtitleEdit.Core.Common; @@ -53,12 +54,12 @@ public void CpsError_IsGatedOnColorCharactersPerSecond_NotColorDurationTooShort( EndTime = TimeSpan.FromSeconds(1), }; - Assert.Contains("Cps:", vm.GetErrors(null, null), StringComparison.Ordinal); + Assert.Contains(vm.GetErrorList(null, null), e => e.Type == LineErrorType.CharactersPerSecond); Se.Settings.General.ColorCharactersPerSecond = false; Se.Settings.General.ColorDurationTooShort = true; - Assert.DoesNotContain("Cps:", vm.GetErrors(null, null), StringComparison.Ordinal); + Assert.DoesNotContain(vm.GetErrorList(null, null), e => e.Type == LineErrorType.CharactersPerSecond); } finally { @@ -92,7 +93,7 @@ public void StrategyAwareLineLength_IsConsistentBetweenHighlightAndErrors() var brush = Assert.IsType(vm.TextBackgroundBrush); Assert.Equal(Colors.Transparent, brush.Color); - Assert.DoesNotContain("Max line length", vm.GetErrors(null, null), StringComparison.Ordinal); + Assert.DoesNotContain(vm.GetErrorList(null, null), e => e.Type == LineErrorType.LineTooLong); } finally { diff --git a/tests/UI/Features/Shared/PrePopulatedGridSelectionTests.cs b/tests/UI/Features/Shared/PrePopulatedGridSelectionTests.cs index f8c7b408ee0..ddfdf33ab7f 100644 --- a/tests/UI/Features/Shared/PrePopulatedGridSelectionTests.cs +++ b/tests/UI/Features/Shared/PrePopulatedGridSelectionTests.cs @@ -63,9 +63,9 @@ public void ErrorListWindow_HasASelectionAndAnEnabledGoToOnOpen() var vm = new ErrorListViewModel(); vm.Initialize(new List { - new(lines[0], null, lines[1]), - new(lines[1], lines[0], null), - }); + new(lines[0], new LineError(LineErrorType.DurationTooShort, "200 < 1000")), + new(lines[1], new LineError(LineErrorType.CharactersPerSecond, "30 > 25")), + }, 2); var window = new ErrorListWindow(vm); Show(window); From 3a56a1ae9cf3423a0bb99c2514c24efa74820f65 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sun, 23 Aug 2026 08:52:49 +0200 Subject: [PATCH 023/380] Batch convert: error list with summary cards and per-error rows Same layout as the main List errors window (#14020): verdict header with file count, summary cards per error class that filter the table, and one row per error with colour dot, show/hide and detail columns. CSV export keeps its columns and exports what is shown (the active filter applies). Co-Authored-By: Claude Fable 5 --- .../BatchErrorList/BatchErrorListItem.cs | 43 ++++-- .../BatchErrorList/BatchErrorListViewModel.cs | 61 +++++++- .../BatchErrorList/BatchErrorListWindow.cs | 134 ++++++++++++++---- .../Config/Language/LanguageErrorList.cs | 2 + 4 files changed, 193 insertions(+), 47 deletions(-) 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..4a0c04484c6 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,14 +75,13 @@ 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; @@ -59,21 +92,18 @@ public BatchErrorListWindow(BatchErrorListViewModel vm) 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/Logic/Config/Language/LanguageErrorList.cs b/src/ui/Logic/Config/Language/LanguageErrorList.cs index 98e76a14cdf..4115509306e 100644 --- a/src/ui/Logic/Config/Language/LanguageErrorList.cs +++ b/src/ui/Logic/Config/Language/LanguageErrorList.cs @@ -5,6 +5,7 @@ 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; } @@ -36,6 +37,7 @@ 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"; From ef3af8607078fea990c17d28644f5f24cbeb28bb Mon Sep 17 00:00:00 2001 From: niksedk Date: Sun, 23 Aug 2026 09:03:13 +0200 Subject: [PATCH 024/380] Beautify time codes: snap to the video's real frame times Beautify could only assume a perfect n/fps frame grid: every SE5 call site passed TimeCodesBeautifier an empty time-code list, so it always fell back to nominal frame-rate arithmetic. That grid is exact for true CFR material and wrong for everything else - variable frame rate, remuxes, timestamp discontinuities - which is what issue #10235 reported. SE4 had this feature and extracted the frame times with "ffprobe -show_entries frame=pkt_dts_time". That was the original complaint: DTS is a decode/packet-domain timestamp, so with B-frames it is not the order frames are shown in, and it keeps the container's start offset. Cues snapped against it drift away from the picture. The port was dropped in SE5 rather than fixed, taking the feature with it and leaving ExtractExactTimeCodes as a dead setting. Extract with ffmpeg's showinfo filter instead of ffprobe: ffmpeg -i - 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 +791,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 +854,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/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/Se.cs b/src/ui/Logic/Config/Se.cs index 9a68917c403..4a39162a53e 100644 --- a/src/ui/Logic/Config/Se.cs +++ b/src/ui/Logic/Config/Se.cs @@ -174,6 +174,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"); diff --git a/src/ui/Logic/Media/TimeCodesGenerator.cs b/src/ui/Logic/Media/TimeCodesGenerator.cs new file mode 100644 index 00000000000..7b50c071768 --- /dev/null +++ b/src/ui/Logic/Media/TimeCodesGenerator.cs @@ -0,0 +1,144 @@ +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); + } + 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..4df40bc8f9d --- /dev/null +++ b/src/ui/Logic/Media/TimeCodesHelper.cs @@ -0,0 +1,180 @@ +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) + { + return true; // unknown length - nothing to check against + } + + 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/tests/UI/Logic/Media/TimeCodesTests.cs b/tests/UI/Logic/Media/TimeCodesTests.cs new file mode 100644 index 00000000000..c2a982e600a --- /dev/null +++ b/tests/UI/Logic/Media/TimeCodesTests.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.Linq; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Features.Tools.BeautifyTimeCodes; +using Nikse.SubtitleEdit.Logic.Media; + +namespace UITests.Logic.Media; + +/// +/// "Beautify time codes" snaps cues to frame boundaries. Without a frame list it has to assume a +/// perfect n/fps grid, which is wrong for variable frame rate and remuxed material (issue #10235). +/// These cover reading the real frame times out of ffmpeg and the guards that keep a bad list from +/// being trusted - the beautifier binary searches the list and indexes it by frame number, and +/// validates neither, so a malformed list silently produces wrong frame numbers. +/// +public class TimeCodesTests +{ + [Theory] + // Real "-vf showinfo" lines, as they arrive on ffmpeg's stderr. + [InlineData("[Parsed_showinfo_0 @ 0xb8ad4c0c0] n: 0 pts: 0 pts_time:0 pos: 626 fmt:yuv420p sar:1/1 s:320x240 i:P iskey:1 type:I", 0.0)] + [InlineData("[Parsed_showinfo_0 @ 0xb8ad4c0c0] n: 1 pts: 40 pts_time:0.04 pos: 5049 fmt:yuv420p sar:1/1 s:320x240 i:P iskey:0 type:B", 0.04)] + [InlineData("[Parsed_showinfo_0 @ 0x7f8] n: 1234 pts: 1234567 pts_time:1234.567 duration:1 duration_time:0.04", 1234.567)] + public void TryParsePtsTime_ReadsPresentationTime(string line, double expected) + { + Assert.True(TimeCodesGenerator.TryParsePtsTime(line, out var seconds)); + Assert.Equal(expected, seconds, 3); + } + + [Theory] + [InlineData("[Parsed_showinfo_0 @ 0x7f8] n: 5 pts: N/A pts_time:N/A pos: 100")] // frame without a time stamp + [InlineData("frame= 123 fps= 25 q=-0.0 size=N/A time=00:00:04.92 bitrate=N/A speed=9.8x")] // progress line + [InlineData("out_time_us=40000")] + [InlineData("")] + public void TryParsePtsTime_IgnoresLinesWithoutAUsableTime(string line) + { + Assert.False(TimeCodesGenerator.TryParsePtsTime(line, out _)); + } + + [Fact] + public void Normalize_SortsPacketOrderedInputAscending() + { + // Packet/decode-ordered sources list B-frames out of presentation order. The beautifier + // binary searches the list, so an unsorted list would return nonsense frame numbers. + var normalized = TimeCodesHelper.Normalize(new[] { 0.0, 0.12, 0.04, 0.08, 0.24, 0.16, 0.2 }); + + Assert.Equal(new[] { 0.0, 0.04, 0.08, 0.12, 0.16, 0.2, 0.24 }, normalized); + } + + [Fact] + public void Normalize_DropsValuesThatCannotBeFrameTimes() + { + var normalized = TimeCodesHelper.Normalize(new[] { 0.04, double.NaN, 0.04, -1.0, double.PositiveInfinity, 0.08 }); + + Assert.Equal(new[] { 0.04, 0.08 }, normalized); + } + + [Fact] + public void IsUsableFor_RejectsAnIncompleteList() + { + var fullList = Enumerable.Range(0, 250).Select(n => n / 25.0).ToList(); + Assert.True(TimeCodesHelper.IsUsableFor(fullList, 10)); + + // Cancelled or truncated half-way: worse than nothing, because cues past the end would + // snap to whatever frame happens to sit at that index instead of falling back to n/fps. + Assert.False(TimeCodesHelper.IsUsableFor(fullList.Take(125).ToList(), 10)); + Assert.False(TimeCodesHelper.IsUsableFor(new List(), 10)); + } + + /// + /// The point of the whole feature: on material whose real frames are not on an n/fps grid, + /// beautifying with the video's own time codes must land cues on real frames, where nominal + /// frame-rate arithmetic cannot. + /// + [Fact] + public void Beautify_WithExactTimeCodes_SnapsToRealFramesNotTheNominalGrid() + { + // 12.5 fps nominal, but the frames actually sit at an irregular 0/67/167/233/333... ms + // (the pattern a VFR source produces). + var timeCodes = new List { 0, 0.067, 0.167, 0.233, 0.333, 0.4, 0.5, 0.567, 0.667, 0.733, 0.833, 0.9, 1.0 }; + + var row = new SubtitleLineViewModel(new Paragraph("Hello", 230, 900), new SubRip()); + + var withExact = new List { new SubtitleLineViewModel(row) }; + BeautifyTimeCodesViewModel.CommitBeautifiedTimes(withExact, 12.5, new List(), timeCodes); + + var nominalOnly = new List { new SubtitleLineViewModel(row) }; + BeautifyTimeCodesViewModel.CommitBeautifiedTimes(nominalOnly, 12.5, new List()); + + // Exact time codes put both cues on times the video actually has frames at. + Assert.Equal(233, withExact[0].StartTime.TotalMilliseconds, 0); + Assert.Equal(900, withExact[0].EndTime.TotalMilliseconds, 0); + foreach (var ms in new[] { withExact[0].StartTime.TotalMilliseconds, withExact[0].EndTime.TotalMilliseconds }) + { + Assert.Contains(timeCodes, t => System.Math.Abs(t * 1000 - ms) < 1); + } + + // The nominal 12.5 fps grid is multiples of 80 ms - it has no frame at either instant. + Assert.Equal(240, nominalOnly[0].StartTime.TotalMilliseconds, 0); + Assert.Equal(880, nominalOnly[0].EndTime.TotalMilliseconds, 0); + } +} From b064e940b6153df944f6bdfe86fb0f4574822dad Mon Sep 17 00:00:00 2001 From: niksedk Date: Sun, 23 Aug 2026 09:27:25 +0200 Subject: [PATCH 025/380] Prepare v5.2.0-beta22 Bump the version in Se.cs (and the generated English.json) and add the change-log section for the 24 pull requests merged since the beta21 tag. Co-Authored-By: Claude Opus 5 --- change-log.txt | 25 +++++++++++++++++++++++++ src/ui/Assets/Languages/English.json | 2 +- src/ui/Logic/Config/Se.cs | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/change-log.txt b/change-log.txt index 0d17de5f3ba..0dbfcd1f757 100644 --- a/change-log.txt +++ b/change-log.txt @@ -3,6 +3,31 @@ Subtitle Edit Changelog ----------------------------------------------------------------------------------------------------- +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/src/ui/Assets/Languages/English.json b/src/ui/Assets/Languages/English.json index 665599aabc2..16a368eba22 100644 --- a/src/ui/Assets/Languages/English.json +++ b/src/ui/Assets/Languages/English.json @@ -1,6 +1,6 @@ { "title": "Subtitle Edit", - "version": "v5.2.0-beta21", + "version": "v5.2.0-beta22", "translatedBy": "", "cultureName": "en-US", "general": { diff --git a/src/ui/Logic/Config/Se.cs b/src/ui/Logic/Config/Se.cs index 4a39162a53e..b7c0466155b 100644 --- a/src/ui/Logic/Config/Se.cs +++ b/src/ui/Logic/Config/Se.cs @@ -19,7 +19,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-beta22"; public SeGeneral General { get; set; } = new(); public List Shortcuts { get; set; } = new(); From 760649d2ebba6935f8c9b177b492d3f2d7bed28f Mon Sep 17 00:00:00 2001 From: niksedk Date: Sun, 23 Aug 2026 10:08:19 +0200 Subject: [PATCH 026/380] Fix ten bugs from a random sweep of libse/libuilogic Convert actors (Name: source): color was computed and discarded, every cut was indexed into the untrimmed line so leading whitespace mangled the text, and the "to Actor field" path dropped the name instead of setting p.Actor. IsActor also rejected "Mr."/"Dr." before its own title allowance could run. Fix casing: "I-if" -> "I-If " inserted a double space; FixStutter's bounds were one too strict so a stutter at the very end of a line was never fixed. Unknown 33/34/59 writers rounded seconds without carrying, emitting ":60". KaraokeWordTransform overshot by one after whitespace so a tag following a space was split on its inner space. The OCR multi-guess loop never tried the last position of a word. LoadMatroskaSSA crashed on a Dialogue payload with too few commas. Co-Authored-By: Claude Opus 5 --- src/libse/Common/ActorConverter.cs | 40 ++++--- src/libse/Common/FixCasing.cs | 6 +- .../Common/TextEffect/KaraokeWordTransform.cs | 5 +- src/libse/Common/Utilities.cs | 7 +- .../SubtitleFormats/UnknownSubtitle33.cs | 5 +- .../SubtitleFormats/UnknownSubtitle34.cs | 5 +- .../SubtitleFormats/UnknownSubtitle59.cs | 5 +- .../Ocr/FixEngine/OcrFixReplaceList2.cs | 2 +- .../ConvertActors/ConvertActorsViewModel.cs | 4 + tests/libse/Core/BugHunt20260823Test.cs | 102 ++++++++++++++++++ 10 files changed, 146 insertions(+), 35 deletions(-) create mode 100644 tests/libse/Core/BugHunt20260823Test.cs diff --git a/src/libse/Common/ActorConverter.cs b/src/libse/Common/ActorConverter.cs index 1fb76b36c7f..60a23572490 100644 --- a/src/libse/Common/ActorConverter.cs +++ b/src/libse/Common/ActorConverter.cs @@ -73,10 +73,12 @@ public string FixActorsFromActor(Paragraph p, int? changeCasing, SKColor? color) public string FixActorsFromBeforeColon(Paragraph p, char ch, int? changeCasing, SKColor? color) { var sb = new StringBuilder(); + var lineIdx = 0; foreach (var line in p.Text.SplitToLines()) { + // index into the trimmed line - leading whitespace used to shift every cut by its width var s = line.Trim(); - var startIdx = line.IndexOf(ch); + var startIdx = s.IndexOf(ch); if (startIdx > 0) { var actor = s.Substring(0, startIdx).Trim(' ', '-', '"'); @@ -97,34 +99,29 @@ public string FixActorsFromBeforeColon(Paragraph p, char ch, int? changeCasing, { actor = actor + ":"; } - else if (ToActor) - { - } if (color.HasValue && !ToActor) { - SetColor(_subtitleFormat, color.Value, actor); + actor = SetColor(_subtitleFormat, color.Value, actor); } - if (ToSquare) - { - s = actor + " " + s.Substring(startIdx + 1).TrimStart(' '); - } - else if (ToParentheses) + if (ToActor) { - s = actor + " " + s.Substring(startIdx + 1).TrimStart(' '); + if (lineIdx == 0) + { + p.Actor = actor; + } + + s = s.Substring(startIdx + 1).TrimStart(' '); } - else if (ToColon) + else { s = actor + " " + s.Substring(startIdx + 1).TrimStart(' '); } - else if (ToActor) - { - s = s.Substring(startIdx + 1); - } } sb.AppendLine(s); + lineIdx++; } return sb.ToString().Trim(); @@ -269,6 +266,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 +293,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/FixCasing.cs b/src/libse/Common/FixCasing.cs index 7ba5b75c677..e8544e4f131 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)) @@ -220,7 +220,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 +231,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] != '-') { 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/Utilities.cs b/src/libse/Common/Utilities.cs index 72241df0b6c..a7987fce734 100644 --- a/src/libse/Common/Utilities.cs +++ b/src/libse/Common/Utilities.cs @@ -3424,8 +3424,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++) diff --git a/src/libse/SubtitleFormats/UnknownSubtitle33.cs b/src/libse/SubtitleFormats/UnknownSubtitle33.cs index 69a539a6c94..2272f252261 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle33.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle33.cs @@ -65,8 +65,9 @@ public override string ToText(Subtitle subtitle, string title) private static string EncodeTimeCode(TimeCode timeCode) { - int seconds = (int)Math.Round(timeCode.Seconds + timeCode.Milliseconds / 1000.0); - return $"{timeCode.Hours:00}:{timeCode.Minutes:00}:{seconds:00}"; + // round to whole seconds on the total so 59.6s carries into the minute instead of writing ":60" + var rounded = new TimeCode(Math.Round(timeCode.TotalMilliseconds / 1000.0) * 1000.0); + return $"{rounded.Hours:00}:{rounded.Minutes:00}:{rounded.Seconds:00}"; } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) diff --git a/src/libse/SubtitleFormats/UnknownSubtitle34.cs b/src/libse/SubtitleFormats/UnknownSubtitle34.cs index 53ffe5fdb05..c0362881801 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle34.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle34.cs @@ -41,8 +41,9 @@ public override string ToText(Subtitle subtitle, string title) private static string EncodeTimeCode(TimeCode timeCode) { - int seconds = (int)Math.Round(timeCode.Seconds + timeCode.Milliseconds / 1000.0); - return $"{timeCode.Hours:00}:{timeCode.Minutes:00}:{seconds:00}"; + // round to whole seconds on the total so 59.6s carries into the minute instead of writing ":60" + var rounded = new TimeCode(Math.Round(timeCode.TotalMilliseconds / 1000.0) * 1000.0); + return $"{rounded.Hours:00}:{rounded.Minutes:00}:{rounded.Seconds:00}"; } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) diff --git a/src/libse/SubtitleFormats/UnknownSubtitle59.cs b/src/libse/SubtitleFormats/UnknownSubtitle59.cs index 1af4b968895..5433bfc6b2a 100644 --- a/src/libse/SubtitleFormats/UnknownSubtitle59.cs +++ b/src/libse/SubtitleFormats/UnknownSubtitle59.cs @@ -41,8 +41,9 @@ public override string ToText(Subtitle subtitle, string title) private static string EncodeTimeCode(TimeCode timeCode) { - int seconds = (int)Math.Round(timeCode.Seconds + timeCode.Milliseconds / 1000.0); - return $"{timeCode.Hours:00}:{timeCode.Minutes:00}:{seconds:00}"; + // round to whole seconds on the total so 59.6s carries into the minute instead of writing ":60" + var rounded = new TimeCode(Math.Round(timeCode.TotalMilliseconds / 1000.0) * 1000.0); + return $"{rounded.Hours:00}:{rounded.Minutes:00}:{rounded.Seconds:00}"; } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) diff --git a/src/libuilogic/Ocr/FixEngine/OcrFixReplaceList2.cs b/src/libuilogic/Ocr/FixEngine/OcrFixReplaceList2.cs index bce05048850..2c6bddf6721 100644 --- a/src/libuilogic/Ocr/FixEngine/OcrFixReplaceList2.cs +++ b/src/libuilogic/Ocr/FixEngine/OcrFixReplaceList2.cs @@ -759,7 +759,7 @@ public IEnumerable CreateGuessesFromLetters(string word, string threeLet foreach (var previousGuess in previousGuesses) { - for (var i = 0; i < previousGuess.Length - letter.Length; i++) + for (var i = 0; i <= previousGuess.Length - letter.Length; i++) { if (previousGuess.AsSpan(i).StartsWith(letter, StringComparison.Ordinal)) { diff --git a/src/ui/Features/Tools/ConvertActors/ConvertActorsViewModel.cs b/src/ui/Features/Tools/ConvertActors/ConvertActorsViewModel.cs index 08261811359..6eca65c5aab 100644 --- a/src/ui/Features/Tools/ConvertActors/ConvertActorsViewModel.cs +++ b/src/ui/Features/Tools/ConvertActors/ConvertActorsViewModel.cs @@ -154,6 +154,10 @@ private void UpdatePreview() { var updatedVm = new SubtitleLineViewModel(vm); updatedVm.Text = newText; + if (converter.ToActor) + { + updatedVm.Actor = p.Actor; + } items.Add(new ConvertActorsDisplayItem(vm) { NewText = newText, IsChecked = true, UpdatedViewModel = updatedVm }); count++; } diff --git a/tests/libse/Core/BugHunt20260823Test.cs b/tests/libse/Core/BugHunt20260823Test.cs new file mode 100644 index 00000000000..5c15d55eab6 --- /dev/null +++ b/tests/libse/Core/BugHunt20260823Test.cs @@ -0,0 +1,102 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Common.TextEffect; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using SkiaSharp; + +namespace LibSETests.Core; + +public class BugHunt20260823Test +{ + [Fact] + public void ActorConverter_ColonSource_AppliesColor() + { + var c = new ActorConverter(new SubRip(), "en") { ToSquare = true }; + var p = new Paragraph { Text = "Joe: How are you?" }; + var result = c.FixActorsFromBeforeColon(p, ':', null, SKColors.Red); + Assert.StartsWith("[Joe] How are you?", result); + } + + [Fact] + public void ActorConverter_ColonSource_LeadingWhitespace() + { + var c = new ActorConverter(new SubRip(), "en") { ToSquare = true }; + var p = new Paragraph { Text = " Joe: How are you?" }; + var result = c.FixActorsFromBeforeColon(p, ':', null, null); + Assert.Equal("[Joe] How are you?", result); + } + + [Fact] + public void ActorConverter_ColonSource_ToActor_SetsActor() + { + var c = new ActorConverter(new SubRip(), "en") { ToActor = true }; + var p = new Paragraph { Text = "Joe: How are you?" }; + var result = c.FixActorsFromBeforeColon(p, ':', null, null); + Assert.Equal("How are you?", result); + Assert.Equal("Joe", p.Actor); + } + + [Fact] + public void ActorConverter_TitleIsAllowedInActorName() + { + var dataDir = Path.Combine(Path.GetTempPath(), "SeBugHunt20260823_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(dataDir, "Dictionaries")); + File.WriteAllText(Path.Combine(dataDir, "Dictionaries", "names.xml"), "John"); + var oldDataDir = Configuration.DataDirectory; + Configuration.DataDirectory = dataDir; + try + { + var c = new ActorConverter(new SubRip(), "en") { ToSquare = true }; + Assert.True(c.FixActors(new Paragraph { Text = "[John] How are you?" }, '[', ']', null, null).Selected); + Assert.True(c.FixActors(new Paragraph { Text = "[Mr. John] How are you?" }, '[', ']', null, null).Selected); + Assert.False(c.FixActors(new Paragraph { Text = "[Mr. 3] How are you?" }, '[', ']', null, null).Selected); + } + finally + { + Configuration.DataDirectory = oldDataDir; + Directory.Delete(dataDir, true); + } + } + + [Fact] + public void FixCasing_IIf_NoDoubleSpace() + { + var fc = new FixCasing("en") { FixNormal = true, Format = new SubRip() }; + var s = new Subtitle(new List { new Paragraph("I-if you say so.", 0, 2000) }); + fc.Fix(s); + Assert.Equal("I-If you say so.", s.Paragraphs[0].Text); + } + + [Fact] + public void FixStutter_AtEndOfText() + { + Assert.Equal("N-N-No", FixCasing.FixStutter("N-n-no")); + Assert.Equal("N-No", FixCasing.FixStutter("N-no")); + } + + [Fact] + public void UnknownSubtitle33_SecondsRoundingCarries() + { + var sub = new Subtitle(); + sub.Paragraphs.Add(new Paragraph("Hello", new TimeCode(0, 0, 59, 600).TotalMilliseconds, new TimeCode(0, 1, 2, 0).TotalMilliseconds)); + Assert.StartsWith("00:01:00", new UnknownSubtitle33().ToText(sub, "t")); + Assert.StartsWith("00:01:00", new UnknownSubtitle34().ToText(sub, "t")); + Assert.StartsWith("00:01:00", new UnknownSubtitle59().ToText(sub, "t")); + } + + [Fact] + public void KaraokeWordTransform_TagAfterSpaceIsKeptIntact() + { + var res = new KaraokeWordTransform().Transform("Hello world"); + Assert.Equal(2, res.Length); + Assert.Equal("Hello world", res[0]); + Assert.Equal("Hello world", res[1]); + } + + [Fact] + public void KaraokeWordTransform_MultipleSpaces() + { + var res = new KaraokeWordTransform().Transform("a b c"); + Assert.Equal(new[] { "a b c", "a b c", "a b c" }, res); + } +} From 2fcb8cabc946ff7f06bfdfc44eefa528671c3a89 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sun, 23 Aug 2026 10:30:56 +0200 Subject: [PATCH 027/380] Fix ten more bugs from a second random sweep The four "DVD Studio Pro with space" readers cut fixed offsets out of a line whose regex declares variable-width time codes, so a valid file threw ArgumentOutOfRangeException; they now split on the separator, which also keeps a separator that appears inside the text. Csv had the same shape: it split on every ";" and required exactly four parts, so any line whose text contained a semicolon was dropped without an error - even one Csv itself had written. AssaResampler cut tag parameters at a fixed offset although its regexes allow whitespace, so "\pos (10,11)" failed to parse and the "break" then abandoned every remaining tag on the line. Paragraph.WordsPerMinute had no zero-duration guard, unlike GetCharactersPerSecond right below it, so one zero-length line made the average and maximum WPM of a whole statistics report infinite. WebVTT thumbnails rejected every .jpeg sprite sheet, and the .jpeg branch of GetBitmap indexed with posJpg instead of posJpeg. Netflix "spell out leading number" matched only "\r\n" so the rule was dead off Windows; it now captures the digits and accepts either line break. Netflix max-line-length logged the same fix once per over-long line. Video OCR dropped unreadable frames out of their group instead of extending it, shortening the subtitle. ToRawAss had an unreachable duplicate "strikeout" branch, and ASSA CheckForErrors cut the untrimmed line after testing the trimmed one. Co-Authored-By: Claude Opus 5 --- src/libse/Common/AssaResampler.cs | 32 +++-- src/libse/Common/Paragraph.cs | 8 ++ src/libse/Common/SsaStyle.cs | 4 - .../AdvancedSubStationAlpha.cs | 5 +- src/libse/SubtitleFormats/Csv.cs | 4 +- .../SubtitleFormats/DvdStudioProSpace.cs | 9 +- .../DvdStudioProSpaceGraphic.cs | 9 +- .../SubtitleFormats/DvdStudioProSpaceOne.cs | 9 +- .../DvdStudioProSpaceOneSemicolon.cs | 9 +- src/libse/SubtitleFormats/WebVttThumbnail.cs | 12 +- .../Video/VideoOcr/VideoOcrFrameGrouper.cs | 10 +- .../NetflixCheckMaxLineLength.cs | 14 ++- .../NetflixCheckStartNumberSpellOut.cs | 49 ++++---- tests/libse/Core/BugHunt20260823Round2Test.cs | 111 ++++++++++++++++++ 14 files changed, 230 insertions(+), 55 deletions(-) create mode 100644 tests/libse/Core/BugHunt20260823Round2Test.cs 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/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/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/SubtitleFormats/AdvancedSubStationAlpha.cs b/src/libse/SubtitleFormats/AdvancedSubStationAlpha.cs index 6d65457889c..cb915b5d46e 100644 --- a/src/libse/SubtitleFormats/AdvancedSubStationAlpha.cs +++ b/src/libse/SubtitleFormats/AdvancedSubStationAlpha.cs @@ -2302,7 +2302,8 @@ public static string CheckForErrors(string header) { if (line.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 +2404,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/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/DvdStudioProSpace.cs b/src/libse/SubtitleFormats/DvdStudioProSpace.cs index 10eefefa0c9..53151f9e696 100644 --- a/src/libse/SubtitleFormats/DvdStudioProSpace.cs +++ b/src/libse/SubtitleFormats/DvdStudioProSpace.cs @@ -50,15 +50,18 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { if (RegexTimeCodes.IsMatch(line)) { - string[] toPart = line.Substring(0, 25).Split(new[] { " ," }, StringSplitOptions.None); + // Split on the separator instead of fixed offsets - the time code parts are + // "\d+" in the regex above, so they are not always two digits wide, and the + // text may itself contain " , ". + string[] toPart = line.Split(new[] { " , " }, 3, StringSplitOptions.None); Paragraph p = new Paragraph(); - if (toPart.Length == 2 && + if (toPart.Length == 3 && DvdStudioPro.GetTimeCode(p.StartTime, toPart[0]) && DvdStudioPro.GetTimeCode(p.EndTime, toPart[1])) { number++; p.Number = number; - string text = line.Substring(27).Trim(); + string text = toPart[2].Trim(); p.Text = text.Replace(" | ", Environment.NewLine).Replace("|", Environment.NewLine); p.Text = DvdStudioPro.DecodeStyles(p.Text); if (p.Text.Trim().StartsWith("<>")) diff --git a/src/libse/SubtitleFormats/DvdStudioProSpaceGraphic.cs b/src/libse/SubtitleFormats/DvdStudioProSpaceGraphic.cs index e41f942cd8e..f13fe2654f2 100644 --- a/src/libse/SubtitleFormats/DvdStudioProSpaceGraphic.cs +++ b/src/libse/SubtitleFormats/DvdStudioProSpaceGraphic.cs @@ -46,15 +46,18 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { if (RegexTimeCodes.IsMatch(line)) { - string[] toPart = line.Substring(0, 25).Split(new[] { " ," }, StringSplitOptions.None); + // Split on the separator instead of fixed offsets - the time code parts are + // "\d+" in the regex above, so they are not always two digits wide, and the + // text may itself contain " , ". + string[] toPart = line.Split(new[] { " , " }, 3, StringSplitOptions.None); Paragraph p = new Paragraph(); - if (toPart.Length == 2 && + if (toPart.Length == 3 && DvdStudioPro.GetTimeCode(p.StartTime, toPart[0]) && DvdStudioPro.GetTimeCode(p.EndTime, toPart[1])) { number++; p.Number = number; - string text = line.Substring(27).Trim(); + string text = toPart[2].Trim(); p.Text = text.Replace(" | ", Environment.NewLine).Replace("|", Environment.NewLine); p.Text = DvdStudioPro.DecodeStyles(p.Text); p.Text = DvdStudioPro.GetAlignment(verticalAlign, horizontalAlign) + p.Text; diff --git a/src/libse/SubtitleFormats/DvdStudioProSpaceOne.cs b/src/libse/SubtitleFormats/DvdStudioProSpaceOne.cs index c559882ce34..77dc5d56bde 100644 --- a/src/libse/SubtitleFormats/DvdStudioProSpaceOne.cs +++ b/src/libse/SubtitleFormats/DvdStudioProSpaceOne.cs @@ -54,15 +54,18 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { if (RegexTimeCodes.IsMatch(line)) { - string[] toPart = line.Substring(0, 24).Trim(',').Split(','); + // Split on the separator instead of fixed offsets - the time code parts are + // "\d+" in the regex above, so they are not always two digits wide, and the + // text may itself contain ",". + string[] toPart = line.Split(new[] { ',' }, 3); var p = new Paragraph(); - if (toPart.Length == 2 && + if (toPart.Length == 3 && DvdStudioPro.GetTimeCode(p.StartTime, toPart[0]) && DvdStudioPro.GetTimeCode(p.EndTime, toPart[1])) { number++; p.Number = number; - string text = line.Substring(25).Trim(); + string text = toPart[2].Trim(); p.Text = text.Replace(" | ", Environment.NewLine).Replace("|", Environment.NewLine); p.Text = DvdStudioPro.DecodeStyles(p.Text); if (italicOn && !p.Text.Contains("")) diff --git a/src/libse/SubtitleFormats/DvdStudioProSpaceOneSemicolon.cs b/src/libse/SubtitleFormats/DvdStudioProSpaceOneSemicolon.cs index d6bb84b7403..a758632119b 100644 --- a/src/libse/SubtitleFormats/DvdStudioProSpaceOneSemicolon.cs +++ b/src/libse/SubtitleFormats/DvdStudioProSpaceOneSemicolon.cs @@ -52,15 +52,18 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string { if (RegexTimeCodes.IsMatch(line)) { - string[] toPart = line.Substring(0, 24).Trim(',').Split(','); + // Split on the separator instead of fixed offsets - the time code parts are + // "\d+" in the regex above, so they are not always two digits wide, and the + // text may itself contain ",". + string[] toPart = line.Split(new[] { ',' }, 3); var p = new Paragraph(); - if (toPart.Length == 2 && + if (toPart.Length == 3 && DvdStudioPro.GetTimeCode(p.StartTime, toPart[0]) && DvdStudioPro.GetTimeCode(p.EndTime, toPart[1])) { number++; p.Number = number; - string text = line.Substring(25).Trim(); + string text = toPart[2].Trim(); p.Text = text.Replace(" | ", Environment.NewLine).Replace("|", Environment.NewLine); p.Text = DvdStudioPro.DecodeStyles(p.Text); if (italicOn && !p.Text.Contains("")) diff --git a/src/libse/SubtitleFormats/WebVttThumbnail.cs b/src/libse/SubtitleFormats/WebVttThumbnail.cs index 4f3fecc70fa..2f925f50de9 100644 --- a/src/libse/SubtitleFormats/WebVttThumbnail.cs +++ b/src/libse/SubtitleFormats/WebVttThumbnail.cs @@ -47,6 +47,11 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string continue; } + if (p.Text.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + if (p.Text.Contains(".png#xywh=", StringComparison.OrdinalIgnoreCase)) { continue; @@ -57,6 +62,11 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string continue; } + if (p.Text.Contains(".jpeg#xywh=", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + return; } @@ -108,7 +118,7 @@ public SKBitmap GetBitmap(string fileName, Subtitle subtitle, int index) var posJpeg = imageFileName.IndexOf(".jpeg#xywh=", StringComparison.OrdinalIgnoreCase); if (posJpeg >= 0) { - string spec = imageFileName.Substring(posJpg + 11); // after ".jepg#xywh=" + string spec = imageFileName.Substring(posJpeg + 11); // after ".jpeg#xywh=" imageFileName = imageFileName.Substring(0, posJpeg + 5); // keep ".jpeg" ParseSpriteSpec(spec, out x, out y, out w, out h); useSprite = true; diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrFrameGrouper.cs b/src/ui/Features/Video/VideoOcr/VideoOcrFrameGrouper.cs index e2d6d203d66..c0d6dffd556 100644 --- a/src/ui/Features/Video/VideoOcr/VideoOcrFrameGrouper.cs +++ b/src/ui/Features/Video/VideoOcr/VideoOcrFrameGrouper.cs @@ -39,7 +39,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); 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/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/tests/libse/Core/BugHunt20260823Round2Test.cs b/tests/libse/Core/BugHunt20260823Round2Test.cs new file mode 100644 index 00000000000..871de93167a --- /dev/null +++ b/tests/libse/Core/BugHunt20260823Round2Test.cs @@ -0,0 +1,111 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; + +namespace LibSETests.Core; + +public class BugHunt20260823Round2Test +{ + [Fact] + public void DvdStudioProSpace_ShortTimeCodesAndSeparatorInText() + { + var sub = new Subtitle(); + new DvdStudioProSpace().LoadSubtitle(sub, new List { "0:0:0:0 , 0:0:0:1 , Hi , there" }, null); + Assert.Single(sub.Paragraphs); + Assert.Equal("Hi , there", sub.Paragraphs[0].Text); + Assert.Equal(0, sub.Paragraphs[0].StartTime.TotalMilliseconds); + } + + [Fact] + public void DvdStudioProSpaceGraphic_ShortTimeCodes() + { + var sub = new Subtitle(); + new DvdStudioProSpaceGraphic().LoadSubtitle(sub, new List { "0:0:0:0 , 0:0:0:1 , <>a.png" }, null); + Assert.Single(sub.Paragraphs); + Assert.EndsWith("a.png", sub.Paragraphs[0].Text); + Assert.Equal(0, sub.Paragraphs[0].StartTime.TotalMilliseconds); + } + + [Fact] + public void DvdStudioProSpaceOne_ShortTimeCodes() + { + var sub = new Subtitle(); + new DvdStudioProSpaceOne().LoadSubtitle(sub, new List { "0:0:0:0,0:0:0:1, Hi, there" }, null); + Assert.Single(sub.Paragraphs); + Assert.Equal("Hi, there", sub.Paragraphs[0].Text); + } + + [Fact] + public void DvdStudioProSpaceOneSemicolon_ShortTimeCodes() + { + new DvdStudioProSpaceOneSemicolon().LoadSubtitle(new Subtitle(), new List { "0:0:0;0,0:0:0;1, Hi" }, null); + } + + [Fact] + public void Csv_RoundTripWithSeparatorInText() + { + var sub = new Subtitle(); + sub.Paragraphs.Add(new Paragraph("Hello; world", 0, 1000)); + sub.Paragraphs.Add(new Paragraph("Plain line", 2000, 3000)); + + var loaded = new Subtitle(); + new Csv().LoadSubtitle(loaded, new Csv().ToText(sub, "t").SplitToLines(), null); + + Assert.Equal(2, loaded.Paragraphs.Count); + Assert.Equal("Hello; world", loaded.Paragraphs[0].Text); + Assert.Equal("Plain line", loaded.Paragraphs[1].Text); + } + + [Fact] + public void AssaResampler_WhitespaceBeforeParenthesis() + { + Assert.Equal("{\\pos(20,22)}Hi", AssaResampler.ResampleOverrideTagsPosition(720, 1440, 480, 960, "{\\pos(10,11)}Hi")); + Assert.Equal("{\\pos(20,22)}Hi", AssaResampler.ResampleOverrideTagsPosition(720, 1440, 480, 960, "{\\pos (10,11)}Hi")); + Assert.Equal("{\\move(20,22,40,42)}Hi", AssaResampler.ResampleOverrideTagsPosition(720, 1440, 480, 960, "{\\move ( 10 , 11 , 20 , 21 )}Hi")); + } + + [Fact] + public void AssaResampler_OneOddTagDoesNotAbandonTheRest() + { + var result = AssaResampler.ResampleOverrideTagsPosition(720, 1440, 480, 960, "{\\pos (10,11)}A{\\pos(30,31)}B"); + Assert.Equal("{\\pos(20,22)}A{\\pos(60,62)}B", result); + } + + [Fact] + public void WordsPerMinute_IsFiniteForZeroAndNegativeDuration() + { + Assert.True(double.IsFinite(new Paragraph("Hello world", 1000, 1000).WordsPerMinute)); + Assert.True(new Paragraph("Hello world", 2000, 1000).WordsPerMinute >= 0); + // unchanged for a normal line: 2 words in 1 second = 120 wpm + Assert.Equal(120, new Paragraph("Hello world", 0, 1000).WordsPerMinute, 3); + } + + [Fact] + public void WebVttThumbnail_AcceptsJpeg() + { + var sub = new Subtitle(); + new WebVttThumbnail().LoadSubtitle(sub, new List + { + "WEBVTT", "", "00:00:00.000 --> 00:00:10.000", "sheet.jpeg#xywh=0,0,120,67", "" + }, null); + Assert.Single(sub.Paragraphs); + } + + [Fact] + public void SsaStyle_StrikeoutIsWrittenFromTheStyle() + { + const string format = "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"; + var style = new SsaStyle { Name = "Test", Strikeout = true }; + var raw = style.ToRawAss(format); + // StrikeOut is field 11 (0-based 10) - it must carry the style's own value, not a constant + Assert.Equal("-1", raw.Substring("Style: ".Length).Split(',')[10].Trim()); + } + + [Fact] + public void AssaCheckForErrors_IndentedHeaderStillFindsEmptyName() + { + const string header = @"[V4+ Styles] + Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding + Style: ,Arial,20,&H00FFFFFF,&H0300FFFF,&H00000000,&H02000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1"; + Assert.Contains("'Name' is empty", AdvancedSubStationAlpha.CheckForErrors(header)); + } +} From 3faf31a93f1555edff2d65bd209c3fcc03aa4aba Mon Sep 17 00:00:00 2001 From: niksedk Date: Sun, 23 Aug 2026 10:47:38 +0200 Subject: [PATCH 028/380] Fix ten more bugs from a third random sweep UUEncode copied "i + 2 - length" tail bytes instead of "length - i" - the remainder only by coincidence when one byte is left, and zero when two are. Every ASSA attachment whose byte count % 3 == 2 had its last two bytes written out as zeros. Mdhd, Tfdt and Tfhd sized their buffer with unsigned arithmetic on a size read straight from the file, so "size - 8" on a too-small box underflowed into an ~18 exabyte allocation; Tfdt/Tfhd guarded it with "size - 8 <= 0", which can never be true for a ulong. They now check a real minimum and an upper bound, plus the extra room the 64-bit layouts and the optional tfhd fields need. The PMT CA descriptor allocated "length - 4" from a stream byte with no lower bound and copied without checking the buffer, and the non-CA branch used "<" where the descriptor ending on the last byte is complete, silently dropping it. RiffParser read a chunk size as a signed int and never rejected a negative one - it slipped past the bounds check and then grew bytesleft - and zeroed bytesleft before interpolating it into the error message. TransportStreamSubtitle assumed one of its two optional sources was always set. Plain text import could consume nothing per iteration and spin forever. HelperContinuation was an unreferenced stale fork of ContinuationUtilities.SanitizeString missing all of its bounds guards, and merge-same-text kept a write-only line number accumulator. Co-Authored-By: Claude Opus 5 --- src/libse/Common/UUEncoding.cs | 6 +- src/libse/ContainerFormats/Mp4/Boxes/Mdhd.cs | 15 +++ src/libse/ContainerFormats/Mp4/Boxes/Tfdt.cs | 16 ++- src/libse/ContainerFormats/Mp4/Boxes/Tfhd.cs | 24 +++- src/libse/ContainerFormats/RiffParser.cs | 11 +- .../ProgramMapTableDescriptor.cs | 20 ++-- .../TransportStreamSubtitle.cs | 9 +- .../FixCommonErrors/HelperContinuation.cs | 76 ------------- .../ImportPlainText/PlainTextSplitter.cs | 9 +- .../MergeSameTextViewModel.cs | 11 -- tests/libse/Core/BugHunt20260823Round3Test.cs | 104 ++++++++++++++++++ 11 files changed, 196 insertions(+), 105 deletions(-) delete mode 100644 src/libse/Forms/FixCommonErrors/HelperContinuation.cs create mode 100644 tests/libse/Core/BugHunt20260823Round3Test.cs diff --git a/src/libse/Common/UUEncoding.cs b/src/libse/Common/UUEncoding.cs index cf7b3071e01..a30ca0eb87f 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 { 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/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/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/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/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/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/ui/Features/Files/ImportPlainText/PlainTextSplitter.cs b/src/ui/Features/Files/ImportPlainText/PlainTextSplitter.cs index 7af6a7805e0..eb6ede417ae 100644 --- a/src/ui/Features/Files/ImportPlainText/PlainTextSplitter.cs +++ b/src/ui/Features/Files/ImportPlainText/PlainTextSplitter.cs @@ -16,6 +16,11 @@ public static List AutomaticSplit(string plainText, int m return subtitles; } + // Both come straight from settings; at zero or below the split loop below makes no + // progress and spins forever on the UI thread. + maxNumberOfLines = Math.Max(1, maxNumberOfLines); + singleLineMaximumLength = Math.Max(1, singleLineMaximumLength); + var paragraphs = plainText.SplitToLines(); foreach (var paragraph in paragraphs) @@ -46,7 +51,9 @@ private static List SplitParagraphIntoLines(string text, result.Add(new SubtitleLineViewModel { Text = subtitleText.Trim() }); } - remainingText = remainingText.Substring(usedLength).TrimStart(); + // Backstop: consuming nothing would loop forever on text that does not start with + // whitespace (TrimStart is the only other thing that shortens it). + remainingText = remainingText.Substring(Math.Max(1, usedLength)).TrimStart(); } return result; 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/tests/libse/Core/BugHunt20260823Round3Test.cs b/tests/libse/Core/BugHunt20260823Round3Test.cs new file mode 100644 index 00000000000..75935159ffe --- /dev/null +++ b/tests/libse/Core/BugHunt20260823Round3Test.cs @@ -0,0 +1,104 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.ContainerFormats.Mp4.Boxes; +using Nikse.SubtitleEdit.Core.ContainerFormats.TransportStream; + +namespace LibSETests.Core; + +public class BugHunt20260823Round3Test +{ + [Theory] + [InlineData(1)] + [InlineData(2)] // length % 3 == 2 - used to lose both bytes + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] // length % 3 == 2 + [InlineData(8)] // length % 3 == 2 + [InlineData(64)] + [InlineData(65)] + public void UuEncode_RoundTripsEveryTailLength(int length) + { + var bytes = new byte[length]; + for (var i = 0; i < length; i++) + { + bytes[i] = (byte)(i + 1); + } + + var decoded = UUEncoding.UUDecode(UUEncoding.UUEncode(bytes)); + + // UUEncode pads the last group to three bytes, so the decoded array may be longer + Assert.True(decoded.Length >= length); + Assert.Equal(bytes, decoded.Take(length).ToArray()); + } + + [Theory] + [InlineData(0)] + [InlineData(2)] + [InlineData(4)] + [InlineData(8)] + public void Mp4Boxes_TooSmallSizeIsRejectedNotUnderflowed(ulong size) + { + using var ms = new MemoryStream(new byte[512]); + Assert.NotNull(new Mdhd(ms, size)); + ms.Position = 0; + Assert.NotNull(new Tfdt(ms, size)); + ms.Position = 0; + Assert.NotNull(new Tfhd(ms, size)); + } + + [Fact] + public void Mp4Boxes_HugeSizeIsRejected() + { + using var ms = new MemoryStream(new byte[512]); + Assert.NotNull(new Mdhd(ms, uint.MaxValue)); + } + + [Fact] + public void Mdhd_StillReadsAValidBox() + { + // version 0 mdhd: version/flags, creation, modification, timescale(1000), duration, "eng" + var payload = new byte[24]; + payload[15] = 0xE8; // timescale low byte of 1000 + payload[14] = 0x03; + // "eng" packed as 5+5+5 bits with the pad bit clear + var packed = ((('e' - 0x60) & 0x1f) << 10) | ((('n' - 0x60) & 0x1f) << 5) | (('g' - 0x60) & 0x1f); + payload[20] = (byte)(packed >> 8); + payload[21] = (byte)(packed & 0xff); + + using var ms = new MemoryStream(payload); + var mdhd = new Mdhd(ms, (ulong)payload.Length + 4); + Assert.Equal("eng", mdhd.Iso639ThreeLetterCode); + Assert.Equal(1000UL, mdhd.TimeScale); + } + + [Fact] + public void PmtDescriptor_CaDescriptorWithShortLengthIsRejected() + { + var data = new byte[] { 9, 1, 0, 0, 0, 0, 0, 0 }; + var d = new ProgramMapTableDescriptor(data, 0); + Assert.Null(d.PrivateDataBytes); + } + + [Fact] + public void PmtDescriptor_ContentEndingExactlyAtBufferEndIsRead() + { + var data = new byte[] { 5, 2, 0x41, 0x42 }; + Assert.Equal("AB", new ProgramMapTableDescriptor(data, 0).ContentAsString); + } + + [Fact] + public void PmtDescriptor_TruncatedContentIsStillRejected() + { + var data = new byte[] { 5, 4, 0x41, 0x42 }; + Assert.Null(new ProgramMapTableDescriptor(data, 0).ContentAsString); + } + + [Fact] + public void TransportStreamSubtitle_WithNoSourceDoesNotThrow() + { + var s = new TransportStreamSubtitle(); + Assert.Equal(0, s.NumberOfImages); + Assert.NotNull(s.GetBitmap()); + Assert.NotNull(s.GetScreenSize()); + Assert.NotNull(s.GetPosition()); + } +} From f1549cafbd50c4edfde700113fff19edb33ac713 Mon Sep 17 00:00:00 2001 From: Muaz Sabbagh Date: Sun, 23 Aug 2026 14:49:26 +0300 Subject: [PATCH 029/380] Fix OpenRouter STT requests for GPT transcription models --- .../Engines/OpenRouterSttEngine.cs | 6 +-- .../OpenRouter/OpenRouterSttService.cs | 44 +++++++++++++------ .../OpenRouter/OpenRouterSttServiceTests.cs | 19 ++++++++ 3 files changed, 52 insertions(+), 17 deletions(-) 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/OpenRouter/OpenRouterSttService.cs b/src/ui/Features/Video/SpeechToText/OpenRouter/OpenRouterSttService.cs index 721595fa9ae..679c6d6edce 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,13 @@ internal static string BuildRequestBody(OpenRouterSttSettings settings, byte[] a return Encoding.UTF8.GetString(stream.ToArray()); } + internal static bool IsJsonOnlyModel(string? model) + { + return model?.Contains("gpt-transcribe", StringComparison.OrdinalIgnoreCase) == true || + model?.Contains("gpt-4o-transcribe", StringComparison.OrdinalIgnoreCase) == true || + model?.Contains("gpt-4o-mini-transcribe", StringComparison.OrdinalIgnoreCase) == true; + } + /// /// 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/tests/UI/Features/Video/SpeechToText/OpenRouter/OpenRouterSttServiceTests.cs b/tests/UI/Features/Video/SpeechToText/OpenRouter/OpenRouterSttServiceTests.cs index 32ac1cb95a2..c84156894dc 100644 --- a/tests/UI/Features/Video/SpeechToText/OpenRouter/OpenRouterSttServiceTests.cs +++ b/tests/UI/Features/Video/SpeechToText/OpenRouter/OpenRouterSttServiceTests.cs @@ -40,6 +40,25 @@ public void BuildRequestBody_EncodesAudioAndAsksForTimestamps() Assert.Equal("de", root.GetProperty("language").GetString()); } + [Theory] + [InlineData("openai/gpt-transcribe")] + [InlineData("openai/gpt-4o-transcribe")] + [InlineData("openai/gpt-4o-mini-transcribe")] + public void BuildRequestBody_GptTranscriptionModelsUseJsonWithoutTimestamps(string model) + { + var body = OpenRouterSttService.BuildRequestBody( + MakeSettings(model), + Encoding.UTF8.GetBytes("hello-bytes"), + "mp3", + "ar"); + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + Assert.Equal("json", root.GetProperty("response_format").GetString()); + Assert.False(root.TryGetProperty("timestamp_granularities", out _)); + } + [Fact] public void BuildRequestBody_OmitsEmptyLanguageAndZeroTemperature() { From 562ab95c13057879e79fd54a9032534f02918c40 Mon Sep 17 00:00:00 2001 From: FunkyKnilch Date: Sun, 23 Aug 2026 20:44:58 +0200 Subject: [PATCH 030/380] Update German translation --- src/ui/Assets/Languages/German.json | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/ui/Assets/Languages/German.json b/src/ui/Assets/Languages/German.json index 198ba8faf7c..f46bb78c493 100644 --- a/src/ui/Assets/Languages/German.json +++ b/src/ui/Assets/Languages/German.json @@ -2206,23 +2206,25 @@ "options": { "settings": { "dialogStyle": "Dialogstil", - "dialogStyleDashSecondLineWithoutSpace": "Zweite Zeile ohne Leerzeichen stricheln", - "dialogStyleDashSecondLineWithSpace": "Zweite Zeile mit Leerzeichen stricheln", - "dialogStyleDashBothLinesWithSpace": "Beide Zeilen mit Leerzeichen stricheln", - "dialogStyleDashBothLinesWithoutSpace": "Beide Zeilen ohne Leerzeichen stricheln", + "dialogStyleDashSecondLineWithoutSpace": "Zweite Zeile: Strich ohne Leerzeichen", + "dialogStyleDashSecondLineWithSpace": "Zweite Zeile: Strich mit Leerzeichen", + "dialogStyleDashBothLinesWithSpace": "Beide Zeilen: Strich mit Leerzeichen", + "dialogStyleDashBothLinesWithoutSpace": "Beide Zeilen: Strich ohne Leerzeichen", "continuationStyle": "Fortsetzungsstil", - "continuationStyleNoneTrailingDots": "Keine, Punkte für Pausen (nur nachlaufend)", + "continuationStyleNoneTrailingDots": "Keine, Punkte für Pausen (nur nachgestellt)", "continuationStyleNoneLeadingTrailingDots": "Keine, Punkte für Pausen", - "continuationStyleNoneTrailingEllipsis": "Keine, Auslassungspunkte für Pausen (nur nachlaufend)", + "continuationStyleNoneTrailingEllipsis": "Keine, Auslassungspunkte für Pausen (nur nachgestellt)", "continuationStyleNoneLeadingTrailingEllipsis": "Keine, Auslassungspunkte für Pausen", + "continuationStyleOnlyTrailingDots": "Punkte (nur nachgestellt)", "continuationStyleLeadingTrailingDots": "Punkte", + "continuationStyleOnlyTrailingEllipsis": "Ellipsis (nur nachgestellt)", "continuationStyleLeadingTrailingEllipsis": "Ellipse", "continuationStyleLeadingTrailingDash": "Bindestrich", "continuationStyleLeadingTrailingDashDots": "Strich, aber Punkte für Pausen", - "cpsLineLengthStyle": "Cps/Zeilenlänge", - "cpsLineLengthStyleCalcAll": "Zähle alle Charaktere", - "cpsLineLengthStyleCalcNoSpaceCpsOnly": "Zählen Sie alle außer dem Leerzeichen, nur cps", - "cpsLineLengthStyleCalcNoSpace": "Zählen Sie alle außer Leerzeichen", + "cpsLineLengthStyle": "CPS/Zeilenlänge", + "cpsLineLengthStyleCalcAll": "Zähle alle Zeichen", + "cpsLineLengthStyleCalcNoSpaceCpsOnly": "Zähle alle außer Leerzeichen, nur CPS", + "cpsLineLengthStyleCalcNoSpace": "Zähle alle außer Leerzeichen", "cpsLineLengthStyleCalcCjk": "CJK 1, Latein 0,5", "cpsLineLengthStyleCalcCjkNoSpace": "CJK 1, Latein 0,5, Leerzeichen 0", "cpsLineLengthStyleCalcIncludeCompositionCharacters": "Kompositionszeichen einschließen", @@ -2275,10 +2277,13 @@ "unbreakSubtitlesShortThan": "Untertitel umbrechen, die kürzer sind als", "newEmptyDefaultMs": "Standarddauer neuer Untertitel (ms)", "timeCodeUpDownStepMs": "Schrittweite Zeit auf/ab (ms)", + "promptBeforeDelete": "Vor dem Löschen fragen", "rememberPositionAndSize": "Fensterposition und Größe speichern", + "openLastFileOnStart": "Zuletzt verwendete Datei beim Start öffnen", "autoBackupOn": "Auto-Sicherung", "autoBackupIntervalMinutes": "Auto-Sicherungsintervall (Minuten)", - "autoBackupDeleteAfterDays": "Auto-backup retention (days)", + "autoBackupDeleteAfterDays": "Sicherung entfernen nach (Tage)", + "autoConvertToUtf8": "Beim Öffnen automatisch in UTF-8 konvertieren", "defaultEncoding": "Standard Zeichenkodierung", "colorDurationTooShort": "Dauer farblich markieren, wenn zu kurz", "colorDurationTooLong": "Dauer farblich markieren, wenn zu lang", @@ -2360,7 +2365,7 @@ "resetWaveform": "Wave-Form zurücksetzen", "resetRules": "Regeln zurücksetzen", "useFrameMode": "Bild-Modus verwenden (hh.mm.ss.ff)", - "textBoxLimitNewLines": "Limit number of lines in subtitle text box", + "textBoxLimitNewLines": "Anzahl der Zeilen im Textfeld begrenzen", "mpvOpenGl": "libmpv - OpenGL", "mpvSoftwareRendering": "libmpv – Software-Rendering (langsam)", "mpvWidRendering": "libmpv – Natives Fenster-ID-Rendering", From 75a87a0f6ac171c6bf61396733489e60e2447f54 Mon Sep 17 00:00:00 2001 From: niksedk Date: Sun, 23 Aug 2026 22:14:34 +0200 Subject: [PATCH 031/380] Perf hunt round 14: Netflix checks, HI removal and two format writers A random sweep turned up 25 candidates; each was measured with BenchmarkDotNet as a whole-file pass over a real 1804-cue movie subtitle, current shape against candidate shape in one process with a per-candidate equivalence assert. 17 sites measured a real win; 8 candidates measured flat or slower and were dropped. Scenarist Closed Captions export is the headline: GetCodeFromLetter walked all 339 entries of the letter table with a LINQ predicate for every character written, and the italic scan did Substring(i).StartsWith("") per character, so writing an .scc was quadratic twice over. End to end on a real file the export goes 27.5 ms -> 5.2 ms (5.3x) and allocates 28.8 MB -> 11.1 MB. Micro results, default job over the same file (current -> candidate): SCC GetCodeFromLetter 339-entry LINQ scan -> reverse dictionary 47.6x Netflix numbers Substring(i).StartsWith(",") -> char compare 354x RemoveTextForHI " " probe Substring -> index compare 316x SCC italic tail test Substring -> span 10.1x FixSpaceAfter "0123456789".Contains(c.ToString()) 10.0x Netflix numbers ":.".Contains(c.ToString()) -> char compare 9.5x FixSpaceAfter separator set -> SearchValues 7.5x FixAloneLowercaseI Environment.NewLine stop-char concat 6.6x ShouldRemoveNarrator string sets -> SearchValues 5.8x Netflix white space anchored regexes behind an exact guard 4.8x SCC per-character Substring(i, 1) -> char-keyed lookup 4.7x Netflix HI brackets lazy SplitToLines 2.9x Netflix glyph check HashSet -> BMP bitmap 2.4x ShouldRemoveNarrator punctuation -> SearchValues 2.0x FixAloneLowercaseI hoist the six per-call Replace needles 1.7x Netflix max CPS drop the per-paragraph Paragraph clone 1.7x Cheetah newline probe + cp1252 lookup 1.4x Two further changes are correct but not measurable on an English movie subtitle, because the sites are cold there: hoisting the `new Regex(@",\d")` out of the Netflix number loop, and hoisting CalcFactory.MakeCalculator out of the max-CPS loop. Two findings worth recording. Hoisting the ShouldRemoveNarrator string[] literals into static readonly fields measured *slower* than the throw-away arrays (153 -> 197 us), so the sets became SearchValues instead. And the two white-space patterns were left as regexes behind a character guard rather than hand-rolled, because "$" in .NET also matches before a trailing newline, so "a \n" matches [^\s]( |\n|\r\n)$ and a naive char test disagrees. Dropped as unverified: the tail-literal compare in the Netflix number check, the controller property hoist, the FinalCutPro styled-run scan, DialogSplitMerge TrimStart, and three RemoveTextForHI/FixAloneLowercaseI span rewrites - all within noise on a real file. Note that big micro numbers do not always reach the user: ShouldRemoveNarrator only fires on colons and FixSpaceAfter only runs for Arabic, so the whole-file HI and fix-common-errors passes are unchanged end to end on this subtitle. Also repairs SubtitleErrorPathBenchmarks, which stopped compiling when ErrorListItem's constructor changed in f109666e0. Co-Authored-By: Claude Opus 5 --- .../FixAloneLowercaseIToUppercaseI.cs | 64 +- .../Forms/FixCommonErrors/FixMissingSpaces.cs | 26 +- src/libse/Forms/RemoveTextForHI.cs | 80 +- src/libse/SubtitleFormats/CheetahCaption.cs | 12 +- .../ScenaristClosedCaptions.cs | 60 +- .../NetflixQualityCheck/NetflixCheckGlyph.cs | 44 +- .../NetflixQualityCheck/NetflixCheckMaxCps.cs | 16 +- .../NetflixCheckNumbersOneToTenSpellOut.cs | 21 +- .../NetflixCheckTextForHiUseBrackets.cs | 6 +- .../NetflixCheckWhiteSpace.cs | 13 +- tests/benchmarks/PerfHuntRound14Benchmarks.cs | 1537 +++++++++++++++++ .../PerfHuntRound14RealFileBenchmarks.cs | 169 ++ .../benchmarks/SubtitleErrorPathBenchmarks.cs | 4 +- 13 files changed, 1981 insertions(+), 71 deletions(-) create mode 100644 tests/benchmarks/PerfHuntRound14Benchmarks.cs create mode 100644 tests/benchmarks/PerfHuntRound14RealFileBenchmarks.cs diff --git a/src/libse/Forms/FixCommonErrors/FixAloneLowercaseIToUppercaseI.cs b/src/libse/Forms/FixCommonErrors/FixAloneLowercaseIToUppercaseI.cs index 7f465fb0a26..4e33d26e4c2 100644 --- a/src/libse/Forms/FixCommonErrors/FixAloneLowercaseIToUppercaseI.cs +++ b/src/libse/Forms/FixCommonErrors/FixAloneLowercaseIToUppercaseI.cs @@ -12,6 +12,55 @@ public static class Language public static string FixLowercaseIToUppercaseI { get; set; } = "Fix alone lowercase 'i' to 'I' (English)"; } + // 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 +87,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 +146,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/FixMissingSpaces.cs b/src/libse/Forms/FixCommonErrors/FixMissingSpaces.cs index e269d490e86..7c0fc519561 100644 --- a/src/libse/Forms/FixCommonErrors/FixMissingSpaces.cs +++ b/src/libse/Forms/FixCommonErrors/FixMissingSpaces.cs @@ -2,6 +2,9 @@ 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 +16,14 @@ public static class Language public static string FixMissingSpaces { get; set; } = "Fix missing spaces"; } + // 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); @@ -403,7 +414,7 @@ private static string FixSpaceAfter(string input, char fixSpaceAfterChar) { skip = true; } - if (!skip && "0123456789".Contains(text[idx + 1].ToString())) + if (!skip && IsAsciiDigit(text[idx + 1])) { skip = true; } @@ -414,7 +425,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 +436,17 @@ private static string FixSpaceAfter(string input, char fixSpaceAfterChar) return text; } + private static bool IsAsciiDigit(char c) => c >= '0' && c <= '9'; + + 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/RemoveTextForHI.cs b/src/libse/Forms/RemoveTextForHI.cs index 97ae483d4ce..cfc6e8af1bb 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; } @@ -863,7 +902,8 @@ 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 doubleSpaceAt = lastIndexOfPeriod + 3; + if (doubleSpaceAt + 1 < newText.Length && newText[doubleSpaceAt] == ' ' && newText[doubleSpaceAt + 1] == ' ') { newText = newText.Remove(lastIndexOfPeriod + 3, 1); } @@ -905,35 +945,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.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(new[] { '!', '?', '¿', '¡' }) < 0; + return pre.IndexOfAny(NarratorSkipPunctuation) < 0; +#endif } private static readonly char[] TrimStartNoiseChar = { '-', ' ' }; 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/ScenaristClosedCaptions.cs b/src/libse/SubtitleFormats/ScenaristClosedCaptions.cs index b1f146ba586..f5b390eca2f 100644 --- a/src/libse/SubtitleFormats/ScenaristClosedCaptions.cs +++ b/src/libse/SubtitleFormats/ScenaristClosedCaptions.cs @@ -406,6 +406,41 @@ public class ScenaristClosedCaptions : SubtitleFormat private static readonly Dictionary LettersCodeLookup = BuildLettersCodeLookup(); + // GetCodeFromLetter used to walk all 339 entries of LetterDictionary with a LINQ + // predicate for every character written, which made writing an .scc quadratic in the + // table size. These reverse maps answer the same question in one probe; "first entry + // wins" matches FirstOrDefault's behaviour for the duplicate values in the table. + private static readonly Dictionary CodeFromLetterLookup = BuildCodeFromLetterLookup(); + private static readonly Dictionary CodeFromCharLookup = BuildCodeFromCharLookup(); + + private static Dictionary BuildCodeFromLetterLookup() + { + var lookup = new Dictionary(LetterDictionary.Count, StringComparer.Ordinal); + foreach (var p in LetterDictionary) + { + if (p.Value != null && !lookup.ContainsKey(p.Value)) + { + lookup.Add(p.Value, p.Key); + } + } + + return lookup; + } + + private static Dictionary BuildCodeFromCharLookup() + { + var lookup = new Dictionary(LetterDictionary.Count); + foreach (var p in LetterDictionary) + { + if (p.Value != null && p.Value.Length == 1 && !lookup.ContainsKey(p.Value[0])) + { + lookup.Add(p.Value[0], p.Key); + } + } + + return lookup; + } + // CEA-608 extended characters (the 0x12/0x13 sets) erase the preceding standard // character, so compliant encoders transmit a standard fallback letter first // ("o" + extended "ö"). Generate a "fallback + extended" combined entry for every @@ -731,16 +766,18 @@ private static string ToSccText(string text, string language) while (i < text.Length) { - string s = text.Substring(i, 1); - string codeFromLetter = GetCodeFromLetter(s); + string codeFromLetter = GetCodeFromLetter(text[i]); string newCode; - if (text.Substring(i).StartsWith("", StringComparison.Ordinal)) + // Tail tests over a span: Substring(i) copied the rest of the line twice per + // character, which made this loop quadratic in line length. + var tail = text.AsSpan(i); + if (tail.StartsWith("".AsSpan(), StringComparison.Ordinal)) { newCode = "91ae"; i += 2; italic++; } - else if (text.Substring(i).StartsWith("", StringComparison.Ordinal) && italic > 0) + else if (tail.StartsWith("".AsSpan(), StringComparison.Ordinal) && italic > 0) { newCode = "9120"; i += 3; @@ -857,13 +894,16 @@ private static string ToSccText(string text, string language) private static string GetCodeFromLetter(string letter) { - var code = LetterDictionary.FirstOrDefault(x => x.Value == letter); - if (code.Equals(new KeyValuePair())) - { - return null; - } + return letter != null && CodeFromLetterLookup.TryGetValue(letter, out var code) ? code : null; + } - return code.Key; + /// + /// for a single character, so the writer does not + /// have to allocate a one-character string per character of every line. + /// + private static string GetCodeFromLetter(char letter) + { + return CodeFromCharLookup.TryGetValue(letter, out var code) ? code : null; } private static string GetLetterFromCode(string hexCode) diff --git a/src/ui/Logic/NetflixQualityCheck/NetflixCheckGlyph.cs b/src/ui/Logic/NetflixQualityCheck/NetflixCheckGlyph.cs index 884d8a0dc1c..311618afcf5 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) @@ -50,7 +55,18 @@ 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; } @@ -66,17 +82,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/NetflixCheckNumbersOneToTenSpellOut.cs b/src/ui/Logic/NetflixQualityCheck/NetflixCheckNumbersOneToTenSpellOut.cs index 2865eb338ca..1cf7861e183 100644 --- a/src/ui/Logic/NetflixQualityCheck/NetflixCheckNumbersOneToTenSpellOut.cs +++ b/src/ui/Logic/NetflixQualityCheck/NetflixCheckNumbersOneToTenSpellOut.cs @@ -13,6 +13,10 @@ public class NetflixCheckNumbersOneToTenSpellOut : INetflixQualityChecker private static readonly Regex NumberOneToNine = new Regex(@"\b\d\b", RegexOptions.Compiled); private static readonly Regex NumberTen = new Regex(@"\b10\b", RegexOptions.Compiled); + // Was constructed inside the per-match loop below, so every "3," in the file paid for a + // fresh Regex parse. Compiled once here instead. + private static readonly Regex CommaDigit = new Regex(@",\d", RegexOptions.Compiled); + public string Name { get; set; } public NetflixCheckNumbersOneToTenSpellOut(string name) @@ -33,7 +37,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()); + bool ok = newText.Length <= m.Index + 1 || newText.Length > m.Index + 1 && !IsColonOrPeriod(newText[m.Index + 1]); if (!ok && newText.Length > m.Index + 1) { var rest = newText.Substring(m.Index + m.Length); @@ -46,17 +50,15 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) } } - if (ok && m.Index + m.Length < newText.Length && newText.Substring(m.Index + m.Length).StartsWith(",")) + if (ok && m.Index + m.Length < newText.Length && newText[m.Index + m.Length] == ',') { - var rest = newText.Substring(m.Index + 1); - var regex = new Regex(@",\d"); - if (regex.IsMatch(rest)) + if (CommaDigit.IsMatch(newText, m.Index + 1)) { ok = false; } } - if (ok && m.Index > 0 && ":.".Contains(newText[m.Index - 1].ToString())) + if (ok && m.Index > 0 && IsColonOrPeriod(newText[m.Index - 1])) { ok = false; } @@ -73,7 +75,7 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) 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())) + if (ok && m.Index > 0 && IsColonOrPeriod(newText[m.Index - 1])) { ok = false; } @@ -95,4 +97,9 @@ public void Check(Subtitle subtitle, NetflixQualityController controller) } } + /// + /// 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/NetflixCheckTextForHiUseBrackets.cs b/src/ui/Logic/NetflixQualityCheck/NetflixCheckTextForHiUseBrackets.cs index accbba583f8..8ef97544475 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,7 +34,10 @@ 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)))) { 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/tests/benchmarks/PerfHuntRound14Benchmarks.cs b/tests/benchmarks/PerfHuntRound14Benchmarks.cs new file mode 100644 index 00000000000..422984dc9c3 --- /dev/null +++ b/tests/benchmarks/PerfHuntRound14Benchmarks.cs @@ -0,0 +1,1537 @@ +using BenchmarkDotNet.Attributes; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Common.TextLengthCalculator; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using System.Buffers; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace Nikse.SubtitleEdit.Benchmarks; + +/// +/// Round 14 performance hunt: a random sweep over the Netflix quality checkers, the +/// hearing-impaired / fix-common-errors text passes and a few subtitle format writers. +/// +/// Every candidate is measured as a *whole-file pass* over a real movie subtitle so the input +/// distribution (tag density, line lengths, dialog dashes, digits) is the one users actually hit. +/// Point SE_BENCH_SUBTITLE at an .srt to use it; otherwise a synthetic corpus is built. +/// +/// Both the current shape and the candidate shape live in this file, each with its own direct +/// call site (never behind a shared delegate — that goes megamorphic and lies), and +/// asserts the two produce identical output before any timing runs. +/// +[MemoryDiagnoser] +public class PerfHuntRound14Benchmarks +{ + private string[] _texts = Array.Empty(); + private Subtitle _subtitle = new(); + + static PerfHuntRound14Benchmarks() + { + // Program.cs does this at start-up; the benchmark host does not. + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + Cp1252 = Encoding.GetEncoding(1252); + } + + [GlobalSetup] + public void Setup() + { + _subtitle = LoadCorpus(); + _texts = _subtitle.Paragraphs.Select(p => p.Text).ToArray(); + AssertEquivalence(); + } + + private static Subtitle LoadCorpus() + { + var path = Environment.GetEnvironmentVariable("SE_BENCH_SUBTITLE"); + var subtitle = new Subtitle(); + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + { + var lines = File.ReadAllLines(path).ToList(); + new SubRip().LoadSubtitle(subtitle, lines, path); + if (subtitle.Paragraphs.Count > 0) + { + return subtitle; + } + } + + // Fallback corpus with the same shape mix as a real movie subtitle. + var samples = new[] + { + "It was the best of times, it was the worst of times.", + "Are you coming with us?", + "- No.\r\n- Then stay here and wait.", + "MAN: There were 3 of them,\r\nand only 1 got away.", + "[DOOR CREAKING]", + "(SIGHS) i think i'm going to be sick.", + "Chapter 10: the long way home...", + "You'll find it at 10:30, i.e. after lunch.", + "♪ Somewhere over there ♪", + "{\\an8}Somewhere in Denmark", + }; + var ms = 1000.0; + for (var i = 0; i < 1800; i++) + { + subtitle.Paragraphs.Add(new Paragraph(samples[i % samples.Length], ms, ms + 2000)); + ms += 2200; + } + + return subtitle; + } + + private void AssertEquivalence() + { + foreach (var t in _texts) + { + Check("C01", C01_Current(t), C01_Candidate(t)); + Check("C02", C02_Current(t), C02_Candidate(t)); + Check("C03", C03_Current(t), C03_Candidate(t)); + Check("C04", C04_Current(t), C04_Candidate(t)); + Check("C07", C07_Current(t), C07_Candidate(t)); + Check("C10", C10_Current(t), C10_Candidate(t)); + Check("C12", C12_Current(t), C12_Candidate(t)); + Check("C13", C13_Current(t), C13_Candidate(t)); + Check("C14", C14_Current(t), C14_Candidate(t)); + Check("C15", C15_Current(t), C15_Candidate(t)); + Check("C16", C16_Current(t), C16_Candidate(t)); + Check("C17", C17_Current(t), C17_Candidate(t)); + Check("C18", C18_Current(t), C18_Candidate(t)); + Check("C19", C19_Current(t), C19_Candidate(t)); + Check("C22", C22_Current(t), C22_Candidate(t)); + Check("C23", C23_Current(t), C23_Candidate(t)); + Check("C24", C24_Current(t), C24_Candidate(t)); + Check("C25", C25_Current(t), C25_Candidate(t)); + } + + Check("C05", C05_Current().ToString(CultureInfo.InvariantCulture), C05_Candidate().ToString(CultureInfo.InvariantCulture)); + Check("C08", C08_Current().ToString(CultureInfo.InvariantCulture), C08_Candidate().ToString(CultureInfo.InvariantCulture)); + Check("C09", C09_Current().ToString(CultureInfo.InvariantCulture), C09_Candidate().ToString(CultureInfo.InvariantCulture)); + Check("C11", C11_Current().ToString(CultureInfo.InvariantCulture), C11_Candidate().ToString(CultureInfo.InvariantCulture)); + Check("C20", C20_Current(), C20_Candidate()); + Check("C21", C21_Current(), C21_Candidate()); + Check("C06", C06_Current().ToString(CultureInfo.InvariantCulture), C06_Candidate().ToString(CultureInfo.InvariantCulture)); + } + + private static void Check(string name, string current, string candidate) + { + if (!string.Equals(current, candidate, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"{name}: candidate diverges.\ncurrent : {current}\ncandidate: {candidate}"); + } + } + + private static string Fold(string[] texts, Func f) + { + var sb = new StringBuilder(); + foreach (var t in texts) + { + sb.Append(f(t)); + } + + return sb.ToString(); + } + + // --------------------------------------------------------------------------------------- + // C01 - NetflixCheckNumbersOneToTenSpellOut: `new Regex(@",\d")` constructed inside the + // per-match while loop. + // --------------------------------------------------------------------------------------- + private static readonly Regex NumberOneToNine = new Regex(@"\b\d\b", RegexOptions.Compiled); + private static readonly Regex CommaDigitCached = new Regex(@",\d", RegexOptions.Compiled); + + private static string C01_Current(string text) + { + var hits = 0; + var m = NumberOneToNine.Match(text); + while (m.Success) + { + if (m.Index + m.Length < text.Length && text.Substring(m.Index + m.Length).StartsWith(",")) + { + var rest = text.Substring(m.Index + 1); + var regex = new Regex(@",\d"); + if (regex.IsMatch(rest)) + { + hits++; + } + } + + m = NumberOneToNine.Match(text, m.Index + 1); + } + + return hits.ToString(CultureInfo.InvariantCulture); + } + + private static string C01_Candidate(string text) + { + var hits = 0; + var m = NumberOneToNine.Match(text); + while (m.Success) + { + if (m.Index + m.Length < text.Length && text[m.Index + m.Length] == ',') + { + if (CommaDigitCached.IsMatch(text, m.Index + 1)) + { + hits++; + } + } + + m = NumberOneToNine.Match(text, m.Index + 1); + } + + return hits.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C01_RegexInLoop_Current() => Fold(_texts, C01_Current); + [Benchmark] public string C01_RegexInLoop_Candidate() => Fold(_texts, C01_Candidate); + + // --------------------------------------------------------------------------------------- + // C02 - NetflixCheckNumbersOneToTenSpellOut: `":.".Contains(text[i].ToString())` (3 sites) + // --------------------------------------------------------------------------------------- + private static string C02_Current(string text) + { + var n = 0; + for (var i = 0; i < text.Length; i++) + { + if (":.".Contains(text[i].ToString())) + { + n++; + } + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + private static string C02_Candidate(string text) + { + var n = 0; + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + if (c == ':' || c == '.') + { + n++; + } + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C02_CharToStringContains_Current() => Fold(_texts, C02_Current); + [Benchmark] public string C02_CharToStringContains_Candidate() => Fold(_texts, C02_Candidate); + + // --------------------------------------------------------------------------------------- + // C03 - `text.Substring(i).StartsWith(",")` - allocates the whole tail to test one char. + // --------------------------------------------------------------------------------------- + private static string C03_Current(string text) + { + var n = 0; + for (var i = 0; i < text.Length; i++) + { + if (text.Substring(i).StartsWith(",")) + { + n++; + } + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + private static string C03_Candidate(string text) + { + var n = 0; + for (var i = 0; i < text.Length; i++) + { + if (text[i] == ',') + { + n++; + } + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C03_SubstringStartsWith_Current() => Fold(_texts, C03_Current); + [Benchmark] public string C03_SubstringStartsWith_Candidate() => Fold(_texts, C03_Candidate); + + // --------------------------------------------------------------------------------------- + // C04 - the "rest" tail Substring compared against 12 literals. + // --------------------------------------------------------------------------------------- + private static readonly string[] RestLiterals = + { + ".", "?", "!", + ".", "?", "!", + "." + "\r\n", "?" + "\r\n", "!" + "\r\n", + "." + "\r\n", "?" + "\r\n", "!" + "\r\n", + }; + + private static string C04_Current(string text) + { + var n = 0; + var m = NumberOneToNine.Match(text); + while (m.Success) + { + var rest = text.Substring(m.Index + m.Length); + if (rest == "." || rest == "?" || rest == "!" || + rest == "." || rest == "?" || rest == "!" || + rest == "." + "\r\n" || rest == "?" + "\r\n" || rest == "!" + "\r\n" || + rest == "." + "\r\n" || rest == "?" + "\r\n" || rest == "!" + "\r\n") + { + n++; + } + + m = NumberOneToNine.Match(text, m.Index + 1); + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + private static string C04_Candidate(string text) + { + var n = 0; + var m = NumberOneToNine.Match(text); + while (m.Success) + { + var rest = text.AsSpan(m.Index + m.Length); + foreach (var lit in RestLiterals) + { + if (rest.SequenceEqual(lit)) + { + n++; + break; + } + } + + m = NumberOneToNine.Match(text, m.Index + 1); + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C04_TailCompare_Current() => Fold(_texts, C04_Current); + [Benchmark] public string C04_TailCompare_Candidate() => Fold(_texts, C04_Candidate); + + // --------------------------------------------------------------------------------------- + // C05 - NetflixCheckMaxCps: `new Paragraph(p)` deep clone for every paragraph, needed only + // for the Japanese tag-stripping branch. + // --------------------------------------------------------------------------------------- + private int C05_Current() + { + var calc = CalcFactory.MakeCalculator(nameof(CalcAll)); + var n = 0; + foreach (var p in _subtitle.Paragraphs) + { + var jp = new Paragraph(p); + if (jp.GetCharactersPerSecond(calc) > 20) + { + n++; + } + } + + return n; + } + + private int C05_Candidate() + { + var calc = CalcFactory.MakeCalculator(nameof(CalcAll)); + var n = 0; + foreach (var p in _subtitle.Paragraphs) + { + // language != "ja": no tag stripping needed, so measure the original paragraph. + if (p.GetCharactersPerSecond(calc) > 20) + { + n++; + } + } + + return n; + } + + [Benchmark] public int C05_MaxCpsClone_Current() => C05_Current(); + [Benchmark] public int C05_MaxCpsClone_Candidate() => C05_Candidate(); + + // --------------------------------------------------------------------------------------- + // C06 - NetflixCheckMaxCps: CalcFactory.MakeCalculator() resolved inside the paragraph loop. + // --------------------------------------------------------------------------------------- + private int C06_Current() + { + var n = 0; + foreach (var p in _subtitle.Paragraphs) + { + var calc = CalcFactory.MakeCalculator(nameof(CalcCjk)); + if (p.GetCharactersPerSecond(calc) > 20) + { + n++; + } + } + + return n; + } + + private int C06_Candidate() + { + var calc = CalcFactory.MakeCalculator(nameof(CalcCjk)); + var n = 0; + foreach (var p in _subtitle.Paragraphs) + { + if (p.GetCharactersPerSecond(calc) > 20) + { + n++; + } + } + + return n; + } + + [Benchmark] public int C06_CalcFactoryInLoop_Current() => C06_Current(); + [Benchmark] public int C06_CalcFactoryInLoop_Candidate() => C06_Candidate(); + + // --------------------------------------------------------------------------------------- + // C07 - NetflixCheckTextForHiUseBrackets: SplitToLines() for every paragraph, used only in + // the rare two-line-dash branch. + // --------------------------------------------------------------------------------------- + private static string C07_Current(string newText) + { + var arr = newText.SplitToLines(); + if (newText.StartsWith('(') && newText.EndsWith(')')) + { + return "[" + newText.Substring(1, newText.Length - 2) + "]"; + } + + if (arr.Count == 2 && arr[0].StartsWith('-') && arr[1].StartsWith('-')) + { + return arr[0] + "\r\n" + arr[1]; + } + + return newText; + } + + private static string C07_Candidate(string newText) + { + if (newText.StartsWith('(') && newText.EndsWith(')')) + { + return "[" + newText.Substring(1, newText.Length - 2) + "]"; + } + + if (newText.StartsWith('-')) + { + var arr = newText.SplitToLines(); + if (arr.Count == 2 && arr[0].StartsWith('-') && arr[1].StartsWith('-')) + { + return arr[0] + "\r\n" + arr[1]; + } + } + + return newText; + } + + [Benchmark] public string C07_LazySplitToLines_Current() => Fold(_texts, C07_Current); + [Benchmark] public string C07_LazySplitToLines_Candidate() => Fold(_texts, C07_Candidate); + + // --------------------------------------------------------------------------------------- + // C08 - NetflixCheckMaxLineLength: `controller.Language` / `controller.SingleLineMaxLength` + // are switch-per-access properties evaluated inside the per-line loop. + // --------------------------------------------------------------------------------------- + private sealed class FakeController + { + public string Language { get; set; } = "en"; + + public int SingleLineMaxLength + { + get + { + switch (Language) + { + case "ja": return 23; + case "th": return 35; + case "ko": + case "zh": return 16; + default: return 42; + } + } + } + } + + private readonly FakeController _controller = new(); + + private int C08_Current() + { + var n = 0; + foreach (var t in _texts) + { + foreach (var line in t.SplitToLines()) + { + if (_controller.Language == "ja") + { + n++; + } + else if (_controller.Language == "ko" && line.CountCharacters(nameof(CalcCjk), false) > _controller.SingleLineMaxLength) + { + n++; + } + else if (line.CountCharacters(false) > _controller.SingleLineMaxLength) + { + n++; + } + } + } + + return n; + } + + private int C08_Candidate() + { + var n = 0; + var language = _controller.Language; + var isJa = language == "ja"; + var isKo = language == "ko"; + var maxLen = _controller.SingleLineMaxLength; + foreach (var t in _texts) + { + foreach (var line in t.SplitToLines()) + { + if (isJa) + { + n++; + } + else if (isKo && line.CountCharacters(nameof(CalcCjk), false) > maxLen) + { + n++; + } + else if (line.CountCharacters(false) > maxLen) + { + n++; + } + } + } + + return n; + } + + [Benchmark] public int C08_HoistControllerProps_Current() => C08_Current(); + [Benchmark] public int C08_HoistControllerProps_Candidate() => C08_Candidate(); + + // --------------------------------------------------------------------------------------- + // C09 - NetflixCheckGlyph: HashSet probe per code point vs a BMP bitmap. + // --------------------------------------------------------------------------------------- + private static readonly HashSet GlyphSet = BuildGlyphSet(); + private static readonly bool[] GlyphBmp = BuildGlyphBmp(); + + private static HashSet BuildGlyphSet() + { + var set = new HashSet { 10, 13 }; + for (var i = 0x20; i <= 0x7E; i++) + { + set.Add(i); + } + + foreach (var cp in new[] { 0x2026, 0x266A, 0x266B, 0xA1, 0xBF, 0xE9, 0xE8, 0xF6, 0xFC, 0x2019, 0x201C, 0x201D }) + { + set.Add(cp); + } + + return set; + } + + private static bool[] BuildGlyphBmp() + { + var table = new bool[0x10000]; + foreach (var cp in GlyphSet) + { + if (cp < table.Length) + { + table[cp] = true; + } + } + + return table; + } + + private int C09_Current() + { + var bad = 0; + foreach (var text in _texts) + { + for (int pos = 0; pos < text.Length; pos += char.IsSurrogatePair(text, pos) ? 2 : 1) + { + var cp = char.ConvertToUtf32(text, pos); + if (!GlyphSet.Contains(cp)) + { + bad++; + } + } + } + + return bad; + } + + private int C09_Candidate() + { + var bad = 0; + foreach (var text in _texts) + { + for (var pos = 0; pos < text.Length; pos++) + { + var c = text[pos]; + if (!char.IsSurrogate(c)) + { + if (!GlyphBmp[c]) + { + bad++; + } + + continue; + } + + var cp = char.ConvertToUtf32(text, pos); + if (!GlyphSet.Contains(cp)) + { + bad++; + } + + pos++; // consume the low surrogate + } + } + + return bad; + } + + [Benchmark] public int C09_GlyphLookup_Current() => C09_Current(); + [Benchmark] public int C09_GlyphLookup_Candidate() => C09_Candidate(); + + // --------------------------------------------------------------------------------------- + // C10 - NetflixCheckWhiteSpace: two anchored compiled regexes vs direct char tests. + // --------------------------------------------------------------------------------------- + private static readonly Regex LineEndingSpaceBefore = new Regex(@"^( |\n|\r\n)[^\s]", RegexOptions.Compiled); + private static readonly Regex LineEndingSpaceAfter = new Regex(@"[^\s]( |\n|\r\n)$", RegexOptions.Compiled); + + private static string C10_Current(string text) + { + var n = 0; + if (LineEndingSpaceBefore.IsMatch(text)) + { + n++; + } + + if (LineEndingSpaceAfter.IsMatch(text)) + { + n += 2; + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + // What actually shipped. A hand-rolled equivalent of these two patterns is a trap: "$" in + // .NET also matches *before* a trailing newline, so "a \n" matches LineEndingSpaceAfter. + // The guard below is provably implied by both patterns instead - each can only match when + // the first / last character is white space - so it changes nothing but keeps the regex + // engine out of the loop for ordinary lines. + private static string C10_Candidate(string text) + { + var n = 0; + if (text.Length > 1 && char.IsWhiteSpace(text[0]) && LineEndingSpaceBefore.IsMatch(text)) + { + n++; + } + + if (text.Length > 1 && char.IsWhiteSpace(text[text.Length - 1]) && LineEndingSpaceAfter.IsMatch(text)) + { + n += 2; + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C10_AnchoredRegex_Current() => Fold(_texts, C10_Current); + [Benchmark] public string C10_AnchoredRegex_Candidate() => Fold(_texts, C10_Candidate); + + // --------------------------------------------------------------------------------------- + // C11 - RemoveTextForHI.ShouldRemoveNarrator: three arrays allocated on every call. + // --------------------------------------------------------------------------------------- + private static readonly string[] NarratorSkipShort = { "http", ", " }; + + private static readonly string[] NarratorSkipEnglish = + { + "Previously on", "Improved by", " is ", " are ", " were ", " was ", " think ", + " guess ", " will ", " believe ", " say ", " said ", " do ", " want ", "That's ", + }; + + private static readonly char[] NarratorSkipChars = { '!', '?', '¿', '¡' }; + + private int C11_Current() + { + var n = 0; + foreach (var pre in _texts) + { + if (pre.Length > 30 || pre.IndexOfAny(new[] { "http", ", " }, StringComparison.OrdinalIgnoreCase) >= 0) + { + continue; + } + + if (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) + { + continue; + } + + if (pre.IndexOfAny(new[] { '!', '?', '¿', '¡' }) < 0) + { + n++; + } + } + + return n; + } + + private int C11_Candidate() + { + var n = 0; + foreach (var pre in _texts) + { + if (pre.Length > 30 || pre.IndexOfAny(NarratorSkipShort, StringComparison.OrdinalIgnoreCase) >= 0) + { + continue; + } + + if (pre.Length > 15 && pre.IndexOfAny(NarratorSkipEnglish, StringComparison.OrdinalIgnoreCase) >= 0) + { + continue; + } + + if (pre.IndexOfAny(NarratorSkipChars) < 0) + { + n++; + } + } + + return n; + } + + // Split of the same site: which half of the fix pays? The two string[] literals are pure + // allocation, but `IndexOfAny(new[] { '!', ... })` hands the JIT a known-length char[] that + // a static readonly field does not, so the char set is measured on its own. + private static readonly SearchValues NarratorSkipSearch = SearchValues.Create("!?¿¡"); + + private int C11b_Current() + { + var n = 0; + foreach (var pre in _texts) + { + if (pre.IndexOfAny(new[] { '!', '?', '¿', '¡' }) < 0) + { + n++; + } + } + + return n; + } + + private int C11b_StaticArray() + { + var n = 0; + foreach (var pre in _texts) + { + if (pre.IndexOfAny(NarratorSkipChars) < 0) + { + n++; + } + } + + return n; + } + + private int C11b_SearchValues() + { + var n = 0; + foreach (var pre in _texts) + { + if (pre.AsSpan().IndexOfAny(NarratorSkipSearch) < 0) + { + n++; + } + } + + return n; + } + + private int C11c_Current() + { + var n = 0; + foreach (var pre in _texts) + { + if (pre.IndexOfAny(new[] { "http", ", " }, StringComparison.OrdinalIgnoreCase) >= 0) + { + continue; + } + + if (pre.IndexOfAny(new[] + { + "Previously on", "Improved by", " is ", " are ", " were ", " was ", " think ", + " guess ", " will ", " believe ", " say ", " said ", " do ", " want ", "That's ", + }, StringComparison.OrdinalIgnoreCase) >= 0) + { + continue; + } + + n++; + } + + return n; + } + + private int C11c_Static() + { + var n = 0; + foreach (var pre in _texts) + { + if (pre.IndexOfAny(NarratorSkipShort, StringComparison.OrdinalIgnoreCase) >= 0) + { + continue; + } + + if (pre.IndexOfAny(NarratorSkipEnglish, StringComparison.OrdinalIgnoreCase) >= 0) + { + continue; + } + + n++; + } + + return n; + } + + // .NET 9+ multi-substring search: one Aho-Corasick-style pass instead of 15 IndexOf sweeps. + // ShouldRemoveNarrator only asks "any of these present?", so leftmost-vs-array-order does + // not matter here. + private static readonly SearchValues NarratorSkipShortSv = + SearchValues.Create(new[] { "http", ", " }, StringComparison.OrdinalIgnoreCase); + + private static readonly SearchValues NarratorSkipEnglishSv = + SearchValues.Create(NarratorSkipEnglish, StringComparison.OrdinalIgnoreCase); + + private int C11c_SearchValues() + { + var n = 0; + foreach (var pre in _texts) + { + if (pre.AsSpan().IndexOfAny(NarratorSkipShortSv) >= 0) + { + continue; + } + + if (pre.AsSpan().IndexOfAny(NarratorSkipEnglishSv) >= 0) + { + continue; + } + + n++; + } + + return n; + } + + [Benchmark] public int C11c_StringSets_SearchValues() => C11c_SearchValues(); + + [Benchmark] public int C11_NarratorArrays_Current() => C11_Current(); + [Benchmark] public int C11_NarratorArrays_Candidate() => C11_Candidate(); + [Benchmark] public int C11b_CharSet_Current() => C11b_Current(); + [Benchmark] public int C11b_CharSet_StaticArray() => C11b_StaticArray(); + [Benchmark] public int C11b_CharSet_SearchValues() => C11b_SearchValues(); + [Benchmark] public int C11c_StringSets_Current() => C11c_Current(); + [Benchmark] public int C11c_StringSets_Static() => C11c_Static(); + + // --------------------------------------------------------------------------------------- + // C12 - RemoveTextForHI.RemovePartialBeforeColon: Substring(i).StartsWith(" ") + // --------------------------------------------------------------------------------------- + private static string C12_Current(string text) + { + var n = 0; + for (var i = 0; i < text.Length; i++) + { + if (text.Substring(i).StartsWith(" ")) + { + n++; + } + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + private static string C12_Candidate(string text) + { + var n = 0; + for (var i = 0; i < text.Length; i++) + { + if (i + 1 < text.Length && text[i] == ' ' && text[i + 1] == ' ') + { + n++; + } + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C12_DoubleSpaceProbe_Current() => Fold(_texts, C12_Current); + [Benchmark] public string C12_DoubleSpaceProbe_Candidate() => Fold(_texts, C12_Candidate); + + // --------------------------------------------------------------------------------------- + // C13 - RemoveTextForHI: line.Substring(idx + 1).StartsWith(Environment.NewLine) + // --------------------------------------------------------------------------------------- + private static string C13_Current(string text) + { + var n = 0; + var idx = text.IndexOf(':'); + while (idx >= 0 && idx < text.Length - 1) + { + if (text.Substring(idx + 1).StartsWith(Environment.NewLine, StringComparison.Ordinal)) + { + n++; + } + + idx = text.IndexOf(':', idx + 1); + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + private static string C13_Candidate(string text) + { + var n = 0; + var idx = text.IndexOf(':'); + while (idx >= 0 && idx < text.Length - 1) + { + if (text.AsSpan(idx + 1).StartsWith(Environment.NewLine, StringComparison.Ordinal)) + { + n++; + } + + idx = text.IndexOf(':', idx + 1); + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C13_TailStartsWithNewLine_Current() => Fold(_texts, C13_Current); + [Benchmark] public string C13_TailStartsWithNewLine_Candidate() => Fold(_texts, C13_Candidate); + + // --------------------------------------------------------------------------------------- + // C14 - FixMissingSpaces.FixSpaceAfter: "0123456789".Contains(c.ToString()) + // --------------------------------------------------------------------------------------- + private static string C14_Current(string text) + { + var n = 0; + for (var i = 0; i < text.Length; i++) + { + if ("0123456789".Contains(text[i].ToString())) + { + n++; + } + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + private static string C14_Candidate(string text) + { + var n = 0; + for (var i = 0; i < text.Length; i++) + { + if (char.IsAsciiDigit(text[i])) + { + n++; + } + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C14_DigitProbe_Current() => Fold(_texts, C14_Current); + [Benchmark] public string C14_DigitProbe_Candidate() => Fold(_texts, C14_Candidate); + + // --------------------------------------------------------------------------------------- + // C15 - FixMissingSpaces.FixSpaceAfter: separator-set membership via char.ToString() + // --------------------------------------------------------------------------------------- + private static readonly SearchValues SpaceAfterSkipLookup = + SearchValues.Create(" \r\n\":;()[]<>.؟!،"); + + private static string C15_Current(string text) + { + var n = 0; + for (var i = 0; i < text.Length; i++) + { + if (!" \r\n\":;()[]<>.؟!،".Contains(text[i].ToString())) + { + n++; + } + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + private static string C15_Candidate(string text) + { + var n = 0; + for (var i = 0; i < text.Length; i++) + { + if (!SpaceAfterSkipLookup.Contains(text[i])) + { + n++; + } + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C15_SeparatorSet_Current() => Fold(_texts, C15_Current); + [Benchmark] public string C15_SeparatorSet_Candidate() => Fold(_texts, C15_Candidate); + + // --------------------------------------------------------------------------------------- + // C16 - FixAloneLowercaseIToUppercaseLine: `">" + target + "" + target + "I" + target + " ", ">I ") + .Replace(">" + target + "​" + Environment.NewLine, ">I" + Environment.NewLine) + .Replace(">" + target + "" + Environment.NewLine, ">I" + Environment.NewLine); + } + + private static readonly string ILtSlash = ">ii "; + private static readonly string IZwsp = ">i​" + "\r\n"; + private static readonly string IZwnbsp = ">i" + "\r\n"; + private static readonly string INewLine = ">I" + "\r\n"; + + private static string C16_Candidate(string input) + { + return input.Replace(ILtSlash, ">II ") + .Replace(IZwsp, INewLine) + .Replace(IZwnbsp, INewLine); + } + + [Benchmark] public string C16_ReplaceLiterals_Current() => Fold(_texts, C16_Current); + [Benchmark] public string C16_ReplaceLiterals_Candidate() => Fold(_texts, C16_Candidate); + + // --------------------------------------------------------------------------------------- + // C17 - FixAloneLowercaseIToUppercaseLine: s.Substring(i).StartsWith("i.e."/"i-") + // --------------------------------------------------------------------------------------- + private static readonly Regex LittleI = new Regex(@"\bi\b", RegexOptions.Compiled); + + private static string C17_Current(string s) + { + var n = 0; + var match = LittleI.Match(s); + while (match.Success) + { + if (!s.Substring(match.Index).StartsWith("i.e.", StringComparison.Ordinal) && + !s.Substring(match.Index).StartsWith("i-", StringComparison.Ordinal)) + { + n++; + } + + match = match.NextMatch(); + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + private static string C17_Candidate(string s) + { + var n = 0; + var match = LittleI.Match(s); + while (match.Success) + { + var tail = s.AsSpan(match.Index); + if (!tail.StartsWith("i.e.", StringComparison.Ordinal) && + !tail.StartsWith("i-", StringComparison.Ordinal)) + { + n++; + } + + match = match.NextMatch(); + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C17_LittleITail_Current() => Fold(_texts, C17_Current); + [Benchmark] public string C17_LittleITail_Candidate() => Fold(_texts, C17_Candidate); + + // --------------------------------------------------------------------------------------- + // C18 - FixAloneLowercaseIToUppercaseLine: wholePrev = s.Substring(0, i-1) then + // .TrimEnd().EndsWith("...") - two allocations to look at three characters. + // --------------------------------------------------------------------------------------- + private static string C18_Current(string s) + { + var n = 0; + var match = LittleI.Match(s); + while (match.Success) + { + var wholePrev = string.Empty; + if (match.Index > 1) + { + wholePrev = s.Substring(0, match.Index - 1); + } + + if (!wholePrev.TrimEnd().EndsWith("...", StringComparison.Ordinal)) + { + n++; + } + + match = match.NextMatch(); + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + private static string C18_Candidate(string s) + { + var n = 0; + var match = LittleI.Match(s); + while (match.Success) + { + var end = match.Index > 1 ? match.Index - 1 : 0; + var prev = s.AsSpan(0, end).TrimEnd(); + if (!prev.EndsWith("...", StringComparison.Ordinal)) + { + n++; + } + + match = match.NextMatch(); + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C18_WholePrevTrim_Current() => Fold(_texts, C18_Current); + [Benchmark] public string C18_WholePrevTrim_Candidate() => Fold(_texts, C18_Candidate); + + // --------------------------------------------------------------------------------------- + // C19 - FixAloneLowercaseIToUppercaseLine: (Environment.NewLine + @" <>!.?:;,").Contains(c) + // concatenates a fresh string on every evaluation. + // --------------------------------------------------------------------------------------- + private static readonly string LittleIStopChars = Environment.NewLine + @" <>!.?:;,"; + + private static string C19_Current(string s) + { + var n = 0; + for (var i = 0; i < s.Length; i++) + { + if (!(Environment.NewLine + @" <>!.?:;,").Contains(s[i])) + { + n++; + } + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + private static string C19_Candidate(string s) + { + var n = 0; + for (var i = 0; i < s.Length; i++) + { + if (!LittleIStopChars.Contains(s[i])) + { + n++; + } + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C19_StopCharConcat_Current() => Fold(_texts, C19_Current); + [Benchmark] public string C19_StopCharConcat_Candidate() => Fold(_texts, C19_Candidate); + + // --------------------------------------------------------------------------------------- + // C20 - ScenaristClosedCaptions.GetCodeFromLetter: LINQ FirstOrDefault over a 339-entry + // KeyValuePair list, once per character written. + // --------------------------------------------------------------------------------------- + private static readonly List> SccLetters = BuildSccLetters(); + private static readonly Dictionary SccReverse = BuildSccReverse(); + + private static List> BuildSccLetters() + { + // Same shape/size as the real table: 339 entries, printable ASCII early, accents late. + var list = new List>(339); + for (var c = 0x20; c <= 0x7E; c++) + { + list.Add(new KeyValuePair(c.ToString("x2"), ((char)c).ToString())); + } + + var extra = 0x100; + while (list.Count < 339) + { + list.Add(new KeyValuePair(extra.ToString("x4"), ((char)extra).ToString())); + extra++; + } + + return list; + } + + private static Dictionary BuildSccReverse() + { + var d = new Dictionary(SccLetters.Count, StringComparer.Ordinal); + foreach (var kv in SccLetters) + { + if (!d.ContainsKey(kv.Value)) + { + d.Add(kv.Value, kv.Key); + } + } + + return d; + } + + private static string GetCodeFromLetter_Current(string letter) + { + var code = SccLetters.FirstOrDefault(x => x.Value == letter); + if (code.Equals(new KeyValuePair())) + { + return null; + } + + return code.Key; + } + + private static string GetCodeFromLetter_Candidate(string letter) + { + return SccReverse.TryGetValue(letter, out var code) ? code : null; + } + + private string C20_Current() + { + var sb = new StringBuilder(); + foreach (var text in _texts) + { + for (var i = 0; i < text.Length; i++) + { + sb.Append(GetCodeFromLetter_Current(text.Substring(i, 1)) ?? "--"); + } + } + + return sb.Length.ToString(CultureInfo.InvariantCulture); + } + + private string C20_Candidate() + { + var sb = new StringBuilder(); + foreach (var text in _texts) + { + for (var i = 0; i < text.Length; i++) + { + sb.Append(GetCodeFromLetter_Candidate(text.Substring(i, 1)) ?? "--"); + } + } + + return sb.Length.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C20_SccCodeLookup_Current() => C20_Current(); + [Benchmark] public string C20_SccCodeLookup_Candidate() => C20_Candidate(); + + // --------------------------------------------------------------------------------------- + // C21 - ScenaristClosedCaptions.ToText: text.Substring(i, 1) allocated for every character. + // --------------------------------------------------------------------------------------- + private string C21_Current() + { + var sb = new StringBuilder(); + foreach (var text in _texts) + { + for (var i = 0; i < text.Length; i++) + { + var s = text.Substring(i, 1); + sb.Append(GetCodeFromLetter_Candidate(s) ?? "--"); + } + } + + return sb.Length.ToString(CultureInfo.InvariantCulture); + } + + private static readonly Dictionary SccReverseChar = BuildSccReverseChar(); + + private static Dictionary BuildSccReverseChar() + { + var d = new Dictionary(SccLetters.Count); + foreach (var kv in SccLetters) + { + if (kv.Value.Length == 1 && !d.ContainsKey(kv.Value[0])) + { + d.Add(kv.Value[0], kv.Key); + } + } + + return d; + } + + private string C21_Candidate() + { + var sb = new StringBuilder(); + foreach (var text in _texts) + { + for (var i = 0; i < text.Length; i++) + { + sb.Append(SccReverseChar.TryGetValue(text[i], out var code) ? code : "--"); + } + } + + return sb.Length.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C21_SccPerCharSubstring_Current() => C21_Current(); + [Benchmark] public string C21_SccPerCharSubstring_Candidate() => C21_Candidate(); + + // --------------------------------------------------------------------------------------- + // C22 - ScenaristClosedCaptions.ToText: text.Substring(i).StartsWith(""/"") inside + // the per-character loop (quadratic in line length). + // --------------------------------------------------------------------------------------- + private static string C22_Current(string text) + { + var italic = 0; + var i = 0; + while (i < text.Length) + { + if (text.Substring(i).StartsWith("", StringComparison.Ordinal)) + { + i += 3; + italic++; + } + else if (text.Substring(i).StartsWith("", StringComparison.Ordinal) && italic > 0) + { + i += 4; + italic--; + } + else + { + i++; + } + } + + return italic.ToString(CultureInfo.InvariantCulture); + } + + private static string C22_Candidate(string text) + { + var italic = 0; + var i = 0; + while (i < text.Length) + { + var tail = text.AsSpan(i); + if (tail.StartsWith("", StringComparison.Ordinal)) + { + i += 3; + italic++; + } + else if (tail.StartsWith("", StringComparison.Ordinal) && italic > 0) + { + i += 4; + italic--; + } + else + { + i++; + } + } + + return italic.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C22_SccItalicScan_Current() => Fold(_texts, C22_Current); + [Benchmark] public string C22_SccItalicScan_Candidate() => Fold(_texts, C22_Candidate); + + // --------------------------------------------------------------------------------------- + // C23 - FinalCutProXmlCaptions.SplitStyledRuns: four Substring(i).StartsWith per '<'. + // --------------------------------------------------------------------------------------- + private static string C23_Current(string input) + { + var sb = new StringBuilder(); + var runs = 0; + var i = 0; + while (i < input.Length) + { + if (input[i] == '<') + { + var toggled = true; + if (input.Substring(i).StartsWith("", StringComparison.OrdinalIgnoreCase)) + { + runs++; + i += 3; + } + else if (input.Substring(i).StartsWith("", StringComparison.OrdinalIgnoreCase)) + { + runs++; + i += 4; + } + else if (input.Substring(i).StartsWith("", StringComparison.OrdinalIgnoreCase)) + { + runs++; + i += 3; + } + else if (input.Substring(i).StartsWith("", StringComparison.OrdinalIgnoreCase)) + { + runs++; + i += 4; + } + else + { + toggled = false; + } + + if (toggled) + { + continue; + } + } + + sb.Append(input[i]); + i++; + } + + return runs.ToString(CultureInfo.InvariantCulture) + ":" + sb.Length.ToString(CultureInfo.InvariantCulture); + } + + private static string C23_Candidate(string input) + { + var sb = new StringBuilder(); + var runs = 0; + var i = 0; + while (i < input.Length) + { + if (input[i] == '<') + { + var toggled = true; + var tail = input.AsSpan(i); + if (tail.StartsWith("", StringComparison.OrdinalIgnoreCase)) + { + runs++; + i += 3; + } + else if (tail.StartsWith("", StringComparison.OrdinalIgnoreCase)) + { + runs++; + i += 4; + } + else if (tail.StartsWith("", StringComparison.OrdinalIgnoreCase)) + { + runs++; + i += 3; + } + else if (tail.StartsWith("", StringComparison.OrdinalIgnoreCase)) + { + runs++; + i += 4; + } + else + { + toggled = false; + } + + if (toggled) + { + continue; + } + } + + sb.Append(input[i]); + i++; + } + + return runs.ToString(CultureInfo.InvariantCulture) + ":" + sb.Length.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C23_FcpStyledRuns_Current() => Fold(_texts, C23_Current); + [Benchmark] public string C23_FcpStyledRuns_Candidate() => Fold(_texts, C23_Candidate); + + // --------------------------------------------------------------------------------------- + // C24 - CheetahCaption.ToText: Substring(j).StartsWith(NewLine) per character, plus + // Encoding.GetEncoding(1252) resolved for every paragraph. + // --------------------------------------------------------------------------------------- + private static string C24_Current(string text) + { + var encoding = Encoding.GetEncoding(1252); + var bytes = 0; + var j = 0; + while (j < text.Length) + { + if (text.Substring(j).StartsWith(Environment.NewLine, StringComparison.Ordinal)) + { + j += Environment.NewLine.Length; + bytes += 4; + } + else + { + bytes += encoding.GetByteCount(text.AsSpan(j, 1)); + j++; + } + } + + return bytes.ToString(CultureInfo.InvariantCulture); + } + + private static readonly Encoding Cp1252; + + private static string C24_Candidate(string text) + { + var encoding = Cp1252; + var bytes = 0; + var j = 0; + while (j < text.Length) + { + if (text.AsSpan(j).StartsWith(Environment.NewLine, StringComparison.Ordinal)) + { + j += Environment.NewLine.Length; + bytes += 4; + } + else + { + bytes += encoding.GetByteCount(text.AsSpan(j, 1)); + j++; + } + } + + return bytes.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C24_CheetahScan_Current() => Fold(_texts, C24_Current); + [Benchmark] public string C24_CheetahScan_Candidate() => Fold(_texts, C24_Candidate); + + // --------------------------------------------------------------------------------------- + // C25 - DialogSplitMerge: l.TrimStart().StartsWith(dash) allocates a trimmed copy of every + // line only to look at its first non-space character. + // --------------------------------------------------------------------------------------- + private static string C25_Current(string text) + { + var n = 0; + foreach (var l in text.SplitToLines()) + { + if (l.TrimStart().StartsWith('-')) + { + n++; + } + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + private static string C25_Candidate(string text) + { + var n = 0; + foreach (var l in text.SplitToLines()) + { + var span = l.AsSpan().TrimStart(); + if (span.Length > 0 && span[0] == '-') + { + n++; + } + } + + return n.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C25_DialogTrimStart_Current() => Fold(_texts, C25_Current); + [Benchmark] public string C25_DialogTrimStart_Candidate() => Fold(_texts, C25_Candidate); +} diff --git a/tests/benchmarks/PerfHuntRound14RealFileBenchmarks.cs b/tests/benchmarks/PerfHuntRound14RealFileBenchmarks.cs new file mode 100644 index 00000000000..e5608ad834d --- /dev/null +++ b/tests/benchmarks/PerfHuntRound14RealFileBenchmarks.cs @@ -0,0 +1,169 @@ +using BenchmarkDotNet.Attributes; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Forms; +using Nikse.SubtitleEdit.Core.Forms.FixCommonErrors; +using Nikse.SubtitleEdit.Core.Interfaces; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using System.Text; + +namespace Nikse.SubtitleEdit.Benchmarks; + +/// +/// End-to-end passes over a real subtitle file, for the round-14 hunt. Unlike +/// these call the shipping code, so the same class is run +/// once against a baseline worktree and once against the patched tree. +/// +/// is the drift control: nothing in this round touches +/// SubRip, so if that row moves the two halves of the comparison are not trustworthy. +/// +/// Point SE_BENCH_SUBTITLE at an .srt; a synthetic corpus is used when it is unset. +/// +[MemoryDiagnoser] +public class PerfHuntRound14RealFileBenchmarks +{ + private List _rawLines = new(); + private Subtitle _subtitle = new(); + private RemoveTextForHI _removeTextForHi = null!; + private readonly StubFixCallbacks _callbacks = new(); + private readonly StubFixCallbacks _arabicCallbacks = new() { LanguageCode = "ar" }; + + static PerfHuntRound14RealFileBenchmarks() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } + + [GlobalSetup] + public void Setup() + { + _rawLines = LoadLines(); + _subtitle = new Subtitle(); + new SubRip().LoadSubtitle(_subtitle, _rawLines, "movie.srt"); + _removeTextForHi = new RemoveTextForHI(new RemoveTextForHISettings(_subtitle)); + } + + private static List LoadLines() + { + var path = Environment.GetEnvironmentVariable("SE_BENCH_SUBTITLE"); + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + { + return File.ReadAllLines(path).ToList(); + } + + var samples = new[] + { + "It was the best of times, it was the worst of times.", + "Are you coming with us?", + "- No.|- Then stay here and wait.", + "MAN: There were 3 of them,|and only 1 got away.", + "[DOOR CREAKING]", + "(SIGHS) i think i'm going to be sick.", + "NARRATOR: Chapter 10: the long way home...", + "You'll find it at 10:30, i.e. after lunch.", + }; + + var lines = new List(1800 * 4); + var ms = 1000; + for (var i = 0; i < 1800; i++) + { + lines.Add((i + 1).ToString()); + lines.Add($"{new TimeCode(ms)}".Replace('.', ',') + " --> " + $"{new TimeCode(ms + 2000)}".Replace('.', ',')); + foreach (var part in samples[i % samples.Length].Split('|')) + { + lines.Add(part); + } + + lines.Add(string.Empty); + ms += 2200; + } + + return lines; + } + + /// Drift control - untouched by this round. A ratio far from 1.0 invalidates the pair. + [Benchmark] + public int SubRipParseControl() + { + var subtitle = new Subtitle(); + new SubRip().LoadSubtitle(subtitle, _rawLines, "movie.srt"); + return subtitle.Paragraphs.Count; + } + + /// Save as Scenarist Closed Captions - the per-character code lookup and italic scan. + [Benchmark] + public int SccExport() => new ScenaristClosedCaptions().ToText(_subtitle, "movie").Length; + + /// Save as Cheetah Caption - the per-character newline probe and the cp1252 lookup. + [Benchmark] + public long CheetahExport() + { + using var stream = new MemoryStream(); + new CheetahCaption().Save("movie.cap", stream, _subtitle, true); + return stream.Length; + } + + /// Remove text for hearing impaired over the whole file. + [Benchmark] + public int RemoveTextForHearingImpaired() + { + var total = 0; + for (var i = 0; i < _subtitle.Paragraphs.Count; i++) + { + total += _removeTextForHi.RemoveTextFromHearImpaired(_subtitle.Paragraphs[i].Text, _subtitle, i, "en").Length; + } + + return total; + } + + /// Two of the fix-common-errors passes over the whole file. + [Benchmark] + public int FixCommonErrorsPass() + { + var working = new Subtitle(_subtitle); + new FixMissingSpaces().Fix(working, _callbacks); + new FixAloneLowercaseIToUppercaseI().Fix(working, _callbacks); + return working.Paragraphs.Count; + } + + /// Just the lowercase-i fix: it prepares four html needles per line. + [Benchmark] + public int FixAloneLowercaseIOnly() + { + var working = new Subtitle(_subtitle); + new FixAloneLowercaseIToUppercaseI().Fix(working, _callbacks); + return working.Paragraphs.Count; + } + + /// + /// FixSpaceAfter - and with it the two character-set probes - only runs for Arabic, so it + /// needs a callback that reports "ar" to be exercised at all. + /// + [Benchmark] + public int FixMissingSpacesArabic() + { + var working = new Subtitle(_subtitle); + new FixMissingSpaces().Fix(working, _arabicCallbacks); + return working.Paragraphs.Count; + } + + /// How much of the deep copy the two passes above are measured on top of. + [Benchmark] + public int SubtitleCopyControl() => new Subtitle(_subtitle).Paragraphs.Count; + + private sealed class StubFixCallbacks : IFixCallbacks + { + public bool AllowFix(Paragraph p, string action) => true; + public void AddFixToListView(Paragraph p, string action, string before, string after) { } + public void AddFixToListView(Paragraph p, string action, string before, string after, bool isChecked) { } + public void LogStatus(string sender, string message) { } + public void LogStatus(string sender, string message, bool isImportant) { } + public void UpdateFixStatus(int fixes, string message) { } + public bool IsName(string candidate) => false; + public HashSet GetAbbreviations() => new(); + public void AddToTotalErrors(int count) { } + public void AddToDeleteIndices(int index) { } + public SubtitleFormat Format { get; } = new SubRip(); + public Encoding Encoding { get; } = Encoding.UTF8; + public string LanguageCode { get; init; } = "en"; + public string Language => LanguageCode; + } +} diff --git a/tests/benchmarks/SubtitleErrorPathBenchmarks.cs b/tests/benchmarks/SubtitleErrorPathBenchmarks.cs index 90b830727d8..389645114da 100644 --- a/tests/benchmarks/SubtitleErrorPathBenchmarks.cs +++ b/tests/benchmarks/SubtitleErrorPathBenchmarks.cs @@ -156,7 +156,7 @@ public int ListErrorsFullNew() var next = i < _lines.Count - 1 ? _lines[i + 1] : null; if (s.HasErrors(prev, next)) { - items.Add(new ErrorListItem(s, prev, next)); + items.AddRange(ErrorListItem.Make(s, prev, next)); } } @@ -209,7 +209,7 @@ public int ListErrorsFull() { var prev = i > 0 ? list[i - 1] : null; var next = i < list.Count - 1 ? list[i + 1] : null; - items.Add(new ErrorListItem(list[i], prev, next)); + items.AddRange(ErrorListItem.Make(list[i], prev, next)); } return items.Count; From ca6ee0fdef50bdfe285d751b3312fd3faa442bcd Mon Sep 17 00:00:00 2001 From: niksedk Date: Sun, 23 Aug 2026 22:36:15 +0200 Subject: [PATCH 032/380] Update Japanese translation (v5.2.0-beta22) Thanks to hisui3393 :) Co-Authored-By: Claude Opus 5 --- src/ui/Assets/Languages/Japanese.json | 93 +++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 5 deletions(-) diff --git a/src/ui/Assets/Languages/Japanese.json b/src/ui/Assets/Languages/Japanese.json index cb652e0bcad..1aafa08d2f7 100644 --- a/src/ui/Assets/Languages/Japanese.json +++ b/src/ui/Assets/Languages/Japanese.json @@ -1,6 +1,6 @@ { "title": "Subtitle Edit", - "version": "v5.2.0-beta20", + "version": "v5.2.0-beta22", "translatedBy": "", "cultureName": "jp-JP", "general": { @@ -1081,6 +1081,14 @@ "replacedXOccurrences": "{0} 件置換しました", "ffmpegDownloadedAndInstalledToX": "FFmpegをダウンロードし、{0}にインストールしました", "nothingToSave": "保存する内容はありません", + "formatLimitWarningTitle": "フォーマットの制限を超えています", + "formatLimitWarningXLinesExceedLimitsOfY": "{0}件の字幕が「{1}」形式の制限を超えています:", + "formatLimitWarningMaxXCharactersPerLine": "1行あたり最大{0}文字", + "formatLimitWarningMaxXLines": "字幕1件あたり最大{0}行", + "formatLimitWarningTextWillBeRewrapped": "保存時に、これらの字幕は制限に収まるように改行し直すか、切り詰められます。そのため、保存後のファイルはここに表示されている内容と一致しません。", + "formatLimitWarningLinesX": "行数:{0}", + "formatLimitWarningSaveAnyway": "このまま保存", + "formatLimitWarningDoNotShowAgain": "今後この警告を表示しない", "nothingToSaveOriginal": "保存する内容はありません(元データ)", "liveSpellCheckLanguageXLoaded": "ライブスペルチェック言語「{0}」を読み込み", "downloadFfmpegTitle": "FFmpegをダウンロードしますか?", @@ -1698,7 +1706,14 @@ "batchSnapToShotChanges": "ショットの切り替わりに字幕を合わせる(利用可能な場合)", "batchFrameRateFromVideo": "字幕ファイルと同名の動画ファイルのフレームレートを使用", "batchFrameRateFixed": "固定フレームレートを使用", - "batchInfo": "ショットの切り替わりは、字幕ファイルと同名の動画ファイルが見つかった場合、そのファイルから読み取られます。" + "batchInfo": "ショットの切り替わりは、字幕ファイルと同名の動画ファイルが見つかった場合、そのファイルから読み取られます。", + "useExactTimeCodes": "映像の正確なタイムコードを使用", + "extractTimeCodes": "タイムコードを抽出", + "extractingTimeCodes": "タイムコードを抽出中...", + "noTimeCodesLoaded": "タイムコードが読み込まれていません", + "xTimeCodesLoaded": "{0} 個のタイムコードを読み込みました", + "timeCodesExtractFailed": "映像からタイムコードを読み取れませんでした", + "timeCodesHint": "一定のフレームレートを前提とせず、映像の実際のフレーム時刻に字幕を合わせます。映像全体を一度読み取る必要があります。結果はキャッシュされます。" }, "beautifyTimeCodesProfile": { "title": "表示タイミングプロファイルを編集", @@ -1799,6 +1814,7 @@ "addFolderDotDotDot": "フォルダーを追加...", "selectFolderToConvert": "変換するファイルが入ったフォルダーを選択", "includeSubfolders": "フォルダーの追加時にサブフォルダーを含める", + "keepSourceFileTimestamp": "出力ファイルの日付・時刻を元ファイルの日時に維持", "scanningFolderX": "{0} をスキャン中..." }, "changeCasing": { @@ -2129,8 +2145,35 @@ "fixCasing": "大文字・小文字を修正", "addPeriods": "ピリオドを追加", "changeUnderlineToColor": "下線の色を変更", - "engineSettings": "Speech-to-text エンジン設定", - "engineSettingsSubtitle": "Speech-to-text エンジン", + "removeNonSpeechLines": "発話以外の行を削除", + "removeNonSpeechLinesHint": "「[Music]」や「(waves crashing)」のような、音や音楽の説明だけの行を削除します", + "removeRepeatedLines": "重複する行を削除", + "removeRepeatedLinesHint": "直前の行と一字一句同じ行を削除します(エンジンのループ)", + "showQualityReport": "文字起こし後に品質レポートを表示", + "qualityReportTitle": "文字起こし品質レポート", + "qualityReportNoIssues": "問題は見つかりませんでした。文字起こしは良好です。", + "qualityReportSummaryX": "{1} 行中 {0} 件の問題が見つかりました", + "qualityReportLinesChecked": "チェックした行数", + "qualityReportIssuesFound": "見つかった問題", + "qualityReportLinesRemoved": "削除した行数", + "qualityReportTooShort": "短すぎる", + "qualityReportTooShortHint": "最小表示時間より短い、または読み上げ速度が最大値を超えています", + "qualityReportTooLong": "長すぎる", + "qualityReportTooLongHint": "最大表示時間を超えている、または長時間なのに単語数が非常に少ない(多くの場合、ハルシネーション)", + "qualityReportOverlap": "重複している", + "qualityReportOverlapHint": "次の行が始まった後まで続いています", + "qualityReportNonSpeech": "発話以外", + "qualityReportNonSpeechHint": "音や音楽の説明だけの行", + "qualityReportRepeated": "重複", + "qualityReportRepeatedHint": "直前の行と同じテキスト(エンジンのループ)", + "qualityReportRemoved": "削除済み", + "qualityReportAll": "すべて", + "qualityReportIssue": "問題", + "qualityReportDetail": "詳細", + "qualityReportTip": "行は、新しい字幕での行番号とともに表示されます。「よくあるエラーを修正」または後処理設定を使うと、さらに自動的に修正できます。多くの行に問題がある場合は、別のモデルやエンジンを試してください。", + "qualityReportDoNotShowAgain": "このレポートを今後表示しない", + "engineSettings": "音声認識エンジンの設定", + "engineSettingsSubtitle": "音声認識エンジン", "backendAndUpdateStatus": "バックエンドと更新状態", "addLanguageCodeToFileName": "ファイル名に言語コードを追加", "addLanguageCodeToFileNameHint": "生成される字幕を「video.srt」ではなく「video.en.srt」のような名前にします" @@ -2162,6 +2205,9 @@ "elevenLabsSettingsResetHint": "ElevenLabs の設定を初期値に戻します", "regenerateAudio": "音声を再生成", "autoContinuePlaying": "自動再生を継続", + "playLine": "行を再生", + "fitDurationToGeneratedAudio": "生成した音声に合わせて表示時間を調整", + "resetTiming": "タイミングをリセット", "addingAudioToVideoFileDotDotDot": "動画に音声を追加中...", "preparingMergeDotDotDot": "結合を準備中...", "importVoiceDotDotDot": "音声をインポート中...", @@ -2697,6 +2743,10 @@ "waveformMouseWheelStepMilliseconds": "{0} ミリ秒", "waveformSnapToShotChanges": "カット検出位置に自動で吸着", "waveformSnapToFrames": "波形をフレーム単位に合わせる", + "waveformSnapToShotChangesPixels": "ドラッグ時のスナップ距離(ピクセル)", + "waveformSnapToShotChangeStartMaxSeconds": "最も近いカット点にスナップ:開始位置の最大距離(秒)", + "waveformSnapToShotChangeEndMaxSeconds": "最も近いカット点にスナップ:終了位置の最大距離(秒)", + "waveformSnapToShotChangeSameShotEndMaxSeconds": "最も近いカット点にスナップ:開始・終了が同じカットにある場合の終了位置の最大距離(秒)", "waveformShotChangesAutoGenerate": "ショット変更を自動生成", "waveformTextFontSize": "波形テキストの文字サイズ", "waveformTextFontBold": "波形テキストを太字に", @@ -2864,6 +2914,7 @@ "useFocusedButtonBackgroundColor": "フォーカスされた背景色のボタンを使用", "focusedButtonBackgroundColor": "フォーカスされた背景色のボタン", "forceCrLfOnSave": "改行形式はWindows仕様で保存する", + "showFormatLimitWarning": "形式の制限を超える行がある場合、保存時に警告", "textBoxButtonShowAutoBreak": "テキスト欄: 自動改行ボタンを表示", "textBoxButtonShowUnbreak": "テキスト欄: 改行解除ボタンを表示", "textBoxButtonShowItalic": "テキスト欄: 斜体ボタンを表示", @@ -3052,6 +3103,7 @@ "seekSilenceBack": "前の無音位置へ移動", "seekSilenceForward": "次の無音位置へ移動", "setVideoPositionCurrentSubtitleStart": "動画の位置を現在の行の先頭に設定", + "goToSubtitlePositionAndPause": "字幕の位置へ移動して一時停止", "setVideoPositionCurrentSubtitleEnd": "動画の位置を現在の行の末尾に設定", "toggleAudioTracks": "オーディオトラックの切り替え", "goToNextError": "次のエラーへ移動", @@ -3581,5 +3633,36 @@ "payPal": "PayPal", "gitHubSponsor": "Github sponsor", "or": " or " - } + }, + "errorList": { + "title": "List errors", + "summaryX": "{2} 行中 {1} 行に {0} 件のエラー", + "noErrors": "エラーは見つかりませんでした。", + "summaryFilesX": "{2} ファイル、{1} 行中に {0} 件のエラー", + "all": "すべて", + "tooManyLines": "行数が多すぎる", + "charactersPerSecond": "読み上げ速度", + "durationTooShort": "短すぎる", + "durationTooLong": "長すぎる", + "lineTooLong": "1行が長すぎる", + "lineTooWide": "1行の幅が広すぎる", + "overlap": "重複している", + "gapTooShort": "間隔が短すぎる", + "tooManyLinesHint": "最大行数を超えています", + "charactersPerSecondHint": "1秒あたりの文字数が最大値を超えています", + "durationTooShortHint": "最小表示時間より短くなっています", + "durationTooLongHint": "最大表示時間を超えています", + "lineTooLongHint": "1行の文字数が最大文字数を超えています", + "lineTooWideHint": "1行の幅が最大幅(ピクセル)を超えています", + "overlapHint": "前後の字幕と重なっています", + "gapTooShortHint": "前後の字幕との間隔が最小値を下回っています", + "detailXGreaterThanY": "{0} > {1}", + "detailXLessThanY": "{0} < {1}", + "detailOverlapFromPrevious": "前の字幕と:{0} ms 重複", + "detailOverlapToNext": "次の字幕と:{0} ms 重複", + "detailGapToPrevious": "前の字幕との間隔:{0} < {1} ms", + "detailGapToNext": "次の字幕との間隔:{0} < {1} ms", + "detail": "詳細", + "tip": "行をダブルクリック(またはEnterキーを押す)すると、その行へ移動します。実行するチェック項目とその制限値は、「設定 > 一般」で設定できます。" + } } \ No newline at end of file From c9aadf77ba17d54188d7aa70659bd33291f450aa Mon Sep 17 00:00:00 2001 From: Ivandro Jao Date: Sun, 23 Aug 2026 22:21:15 +0100 Subject: [PATCH 033/380] Rename CharUtils.IsDigit to IsAsciiDigit - Clarifies that the helper only matches ASCII 0-9, unlike char.IsDigit - Replace duplicate private IsAsciiDigit in FixMissingSpaces with CharUtils.IsAsciiDigit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016knDHQiSH7WKGM8xHmNYLv --- src/libse/Common/CharUtils.cs | 2 +- src/libse/Common/FileUtil.cs | 2 +- src/libse/Common/StringExtensions.cs | 2 +- .../Forms/FixCommonErrors/FixMissingSpaces.cs | 5 ++-- src/libse/Forms/FixCommonErrors/Helper.cs | 2 +- src/libse/SubtitleFormats/Chk.cs | 2 +- src/libse/SubtitleFormats/MacSub.cs | 2 +- src/libse/SubtitleFormats/WebVTT.cs | 2 +- tests/libse/Core/CharUtilsTest.cs | 30 +++++++++---------- 9 files changed, 24 insertions(+), 25 deletions(-) diff --git a/src/libse/Common/CharUtils.cs b/src/libse/Common/CharUtils.cs index 6ea30179028..0998a69f7d8 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 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/StringExtensions.cs b/src/libse/Common/StringExtensions.cs index 0cb2324386d..d0a2d216d0e 100644 --- a/src/libse/Common/StringExtensions.cs +++ b/src/libse/Common/StringExtensions.cs @@ -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; } diff --git a/src/libse/Forms/FixCommonErrors/FixMissingSpaces.cs b/src/libse/Forms/FixCommonErrors/FixMissingSpaces.cs index 7c0fc519561..1a3e9b5aed7 100644 --- a/src/libse/Forms/FixCommonErrors/FixMissingSpaces.cs +++ b/src/libse/Forms/FixCommonErrors/FixMissingSpaces.cs @@ -414,7 +414,8 @@ private static string FixSpaceAfter(string input, char fixSpaceAfterChar) { skip = true; } - if (!skip && IsAsciiDigit(text[idx + 1])) + + if (!skip && CharUtils.IsAsciiDigit(text[idx + 1])) { skip = true; } @@ -436,8 +437,6 @@ private static string FixSpaceAfter(string input, char fixSpaceAfterChar) return text; } - private static bool IsAsciiDigit(char c) => c >= '0' && c <= '9'; - private static bool IsSpaceAfterSkipChar(char c) { #if NET8_0_OR_GREATER diff --git a/src/libse/Forms/FixCommonErrors/Helper.cs b/src/libse/Forms/FixCommonErrors/Helper.cs index 3b2d04406e0..e1d7bb25b5f 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] == '>') { diff --git a/src/libse/SubtitleFormats/Chk.cs b/src/libse/SubtitleFormats/Chk.cs index 3c8796c9d53..f1f9511bd9b 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); } diff --git a/src/libse/SubtitleFormats/MacSub.cs b/src/libse/SubtitleFormats/MacSub.cs index c3327317e43..99fa0bf3f45 100644 --- a/src/libse/SubtitleFormats/MacSub.cs +++ b/src/libse/SubtitleFormats/MacSub.cs @@ -117,7 +117,7 @@ public static bool ContainsOnlyNumber(string input) int halfLen = len / 2; for (int i = 1; i <= halfLen; i++) // /10.0 (Do not parse double) { - if (!(CharUtils.IsDigit(input[i]) && CharUtils.IsDigit(input[len - i]))) + if (!(CharUtils.IsAsciiDigit(input[i]) && CharUtils.IsAsciiDigit(input[len - i]))) { return false; } diff --git a/src/libse/SubtitleFormats/WebVTT.cs b/src/libse/SubtitleFormats/WebVTT.cs index e074a8452ce..d5f38a72720 100644 --- a/src/libse/SubtitleFormats/WebVTT.cs +++ b/src/libse/SubtitleFormats/WebVTT.cs @@ -538,7 +538,7 @@ private static double GetXTimeStampSeconds(string input) for (int i = startIndex + 7; i < s.Length; i++) { var ch = s[i]; - if (CharUtils.IsDigit(ch)) + if (CharUtils.IsAsciiDigit(ch)) { tsSb.Append(ch); } diff --git a/tests/libse/Core/CharUtilsTest.cs b/tests/libse/Core/CharUtilsTest.cs index 3b5261122ba..b16d0aaf2b8 100644 --- a/tests/libse/Core/CharUtilsTest.cs +++ b/tests/libse/Core/CharUtilsTest.cs @@ -5,23 +5,23 @@ namespace LibSETests.Core; public class CharUtilsTest { [Fact] - public void IsDigit() + public void IsAsciiDigit() { - Assert.True(CharUtils.IsDigit('0')); - Assert.True(CharUtils.IsDigit('1')); - Assert.True(CharUtils.IsDigit('2')); - Assert.True(CharUtils.IsDigit('3')); - Assert.True(CharUtils.IsDigit('4')); - Assert.True(CharUtils.IsDigit('5')); - Assert.True(CharUtils.IsDigit('6')); - Assert.True(CharUtils.IsDigit('7')); - Assert.True(CharUtils.IsDigit('8')); - Assert.True(CharUtils.IsDigit('9')); + Assert.True(CharUtils.IsAsciiDigit('0')); + Assert.True(CharUtils.IsAsciiDigit('1')); + Assert.True(CharUtils.IsAsciiDigit('2')); + Assert.True(CharUtils.IsAsciiDigit('3')); + Assert.True(CharUtils.IsAsciiDigit('4')); + Assert.True(CharUtils.IsAsciiDigit('5')); + Assert.True(CharUtils.IsAsciiDigit('6')); + Assert.True(CharUtils.IsAsciiDigit('7')); + Assert.True(CharUtils.IsAsciiDigit('8')); + Assert.True(CharUtils.IsAsciiDigit('9')); - Assert.False(CharUtils.IsDigit('.')); - Assert.False(CharUtils.IsDigit('A')); - Assert.False(CharUtils.IsDigit(' ')); - Assert.False(CharUtils.IsDigit('z')); + Assert.False(CharUtils.IsAsciiDigit('.')); + Assert.False(CharUtils.IsAsciiDigit('A')); + Assert.False(CharUtils.IsAsciiDigit(' ')); + Assert.False(CharUtils.IsAsciiDigit('z')); } [Fact] From a889c160fb778960ef4f69874f29ab322ed84f20 Mon Sep 17 00:00:00 2001 From: Ivandro Jao Date: Sun, 23 Aug 2026 22:30:01 +0100 Subject: [PATCH 034/380] Rename CharUtils.IsEnglishAlphabet to IsAsciiLetter Matches the BCL name (char.IsAsciiLetter) for the same A-Z/a-z check. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016knDHQiSH7WKGM8xHmNYLv --- src/libse/Common/CharUtils.cs | 3 ++- .../SubtitleFormats/FinalCutProXml14Text.cs | 2 +- src/libse/SubtitleFormats/FinalCutProXml15.cs | 2 +- tests/libse/Core/CharUtilsTest.cs | 22 +++++++++---------- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/libse/Common/CharUtils.cs b/src/libse/Common/CharUtils.cs index 6ea30179028..270b2251043 100644 --- a/src/libse/Common/CharUtils.cs +++ b/src/libse/Common/CharUtils.cs @@ -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/SubtitleFormats/FinalCutProXml14Text.cs b/src/libse/SubtitleFormats/FinalCutProXml14Text.cs index ab40ba84881..824f5f99d86 100644 --- a/src/libse/SubtitleFormats/FinalCutProXml14Text.cs +++ b/src/libse/SubtitleFormats/FinalCutProXml14Text.cs @@ -71,7 +71,7 @@ public override string ToText(Subtitle subtitle, string title) trimmedTitle.Clear(); foreach (var ch in HtmlUtil.RemoveHtmlTags(p.Text, true)) { - if (CharUtils.IsEnglishAlphabet(ch) || char.IsDigit(ch)) + if (CharUtils.IsAsciiLetter(ch) || char.IsDigit(ch)) { trimmedTitle.Append(ch); } diff --git a/src/libse/SubtitleFormats/FinalCutProXml15.cs b/src/libse/SubtitleFormats/FinalCutProXml15.cs index 080f8577ce4..248064ae614 100644 --- a/src/libse/SubtitleFormats/FinalCutProXml15.cs +++ b/src/libse/SubtitleFormats/FinalCutProXml15.cs @@ -192,7 +192,7 @@ public override string ToText(Subtitle subtitle, string title) XmlNode video = xml.CreateElement("video"); foreach (var ch in HtmlUtil.RemoveHtmlTags(p.Text, true)) { - if (CharUtils.IsEnglishAlphabet(ch) || char.IsDigit(ch)) + if (CharUtils.IsAsciiLetter(ch) || char.IsDigit(ch)) { sbTrimmedTitle.Append(ch); } diff --git a/tests/libse/Core/CharUtilsTest.cs b/tests/libse/Core/CharUtilsTest.cs index 3b5261122ba..cd25b248a0a 100644 --- a/tests/libse/Core/CharUtilsTest.cs +++ b/tests/libse/Core/CharUtilsTest.cs @@ -25,18 +25,18 @@ public void IsDigit() } [Fact] - public void IsEnglishAlphabet() + public void IsAsciiLetter() { - Assert.True(CharUtils.IsEnglishAlphabet('a')); - Assert.True(CharUtils.IsEnglishAlphabet('b')); - Assert.True(CharUtils.IsEnglishAlphabet('z')); - Assert.True(CharUtils.IsEnglishAlphabet('A')); - Assert.True(CharUtils.IsEnglishAlphabet('Y')); - Assert.True(CharUtils.IsEnglishAlphabet('Z')); + Assert.True(CharUtils.IsAsciiLetter('a')); + Assert.True(CharUtils.IsAsciiLetter('b')); + Assert.True(CharUtils.IsAsciiLetter('z')); + Assert.True(CharUtils.IsAsciiLetter('A')); + Assert.True(CharUtils.IsAsciiLetter('Y')); + Assert.True(CharUtils.IsAsciiLetter('Z')); - Assert.False(CharUtils.IsEnglishAlphabet('æ')); - Assert.False(CharUtils.IsEnglishAlphabet('ü')); - Assert.False(CharUtils.IsEnglishAlphabet('2')); - Assert.False(CharUtils.IsEnglishAlphabet('!')); + Assert.False(CharUtils.IsAsciiLetter('æ')); + Assert.False(CharUtils.IsAsciiLetter('ü')); + Assert.False(CharUtils.IsAsciiLetter('2')); + Assert.False(CharUtils.IsAsciiLetter('!')); } } From e0927eff2a5aed99417ac4de7268b00221e07ea9 Mon Sep 17 00:00:00 2001 From: 12si27 <88251502+12si27@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:30:31 +0900 Subject: [PATCH 035/380] Update Korean.json --- src/ui/Assets/Languages/Korean.json | 115 ++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 16 deletions(-) diff --git a/src/ui/Assets/Languages/Korean.json b/src/ui/Assets/Languages/Korean.json index 74d76312e0d..c8e452c247a 100644 --- a/src/ui/Assets/Languages/Korean.json +++ b/src/ui/Assets/Languages/Korean.json @@ -1,6 +1,6 @@ { "title": "Subtitle Edit", - "version": "v5.2.0-beta20", + "version": "v5.2.0-beta22", "translatedBy": "Hack茶ん, 12시27분(@12si27)", "cultureName": "ko-KR", "general": { @@ -562,7 +562,7 @@ "smart": "스마트", "snapSelectedLinesEndToPreviousShotChange": "선택한 줄의 끝을 이전 장면 전환으로 이동", "snapSelectedLinesStartToNextShotChange": "선택한 줄의 시작을 다음 장면 전환으로 이동", - "snapSelectedLinesToNearestShotChange": "선택한 라인의 시작/끝 지점을 가장 가까운 컷 전환 지점으로 이동", + "snapSelectedLinesToNearestShotChange": "선택한 줄의 시작/끝 지점을 가장 가까운 컷 전환 지점으로 이동", "solidColor": "단색", "sourceViewX": "소스 보기 - {0}", "spacing": "간격", @@ -633,7 +633,7 @@ "twoLetterLanguageCode": "두 글자 언어 코드", "type": "유형", "unbreak": "줄 바꿈 제거", - "unbreakLines": "라인의 줄 바꿈 제거", + "unbreakLines": "자막의 줄 바꿈 제거", "underline": "밑줄", "undo": "실행 취소", "unknown": "알 수 없음", @@ -1081,6 +1081,14 @@ "replacedXOccurrences": "{0}개 항목이 교체", "ffmpegDownloadedAndInstalledToX": "{0}에 ffmpeg 다운로드 및 설치 완료", "nothingToSave": "저장할 항목이 없음", + "formatLimitWarningTitle": "형식 제한 초과", + "formatLimitWarningXLinesExceedLimitsOfY": "{0}개의 자막이 \"{1}\" 형식의 제한을 초과했습니다:", + "formatLimitWarningMaxXCharactersPerLine": "자막 줄당 최대 {0}자", + "formatLimitWarningMaxXLines": "자막 줄당 최대 {0}줄", + "formatLimitWarningTextWillBeRewrapped": "저장하면 이 자막은 형식 제한에 맞게 줄이 다시 나뉘거나 잘립니다. 따라서 저장된 파일은 현재 표시 내용과 달라질 수 있습니다.", + "formatLimitWarningLinesX": "자막 줄: {0}", + "formatLimitWarningSaveAnyway": "그래도 저장", + "formatLimitWarningDoNotShowAgain": "이 경고를 다시 표시하지 않기", "nothingToSaveOriginal": "저장할 내용이 없음(원본)", "liveSpellCheckLanguageXLoaded": "실시간 맞춤법 검사 언어 {0} 불러옴", "downloadFfmpegTitle": "FFmpeg을 다운로드하시겠습니까?", @@ -1484,12 +1492,12 @@ "editPrompt": "프롬프트 편집...", "editPromptTitle": "검토 프롬프트 편집", "promptInfo": "모델에게 전송되는 지침 - {language}는 자막 언어로 대체됩니다.", - "protocolInfo": "Subtitle Edit은 프롬프트에 엄격한 JSON 조건을 추가합니다: 라인들은 번호가 매겨진 JSON으로 전송되며, 모델은 변경된 라인만 포함하는 유효한 JSON로 응답해야 합니다. 조건을 위반하는 응답은 다시 시도된 후 건너뜁니다.", + "protocolInfo": "Subtitle Edit은 프롬프트에 엄격한 JSON 조건을 추가합니다: 자막은 번호가 매겨진 JSON으로 전송되며, 모델은 변경된 줄만 포함하는 유효한 JSON로 응답해야 합니다. 조건을 위반하는 응답은 다시 시도된 후 건너뜁니다.", "resetToDefault": "기본값으로 초기화", "applyXFixes": "{0}개의 수정사항 적용", "xSuggestionsYSelected": "{0}개의 제안 - {1}개가 선택됨", - "reviewingLineXOfY": "라인 {0}/{1} 검토 중...", - "reviewDone": "검토 완료 - {1}개의 라인에서 {0}개의 제안 발견", + "reviewingLineXOfY": "자막 줄 {0}/{1} 검토 중...", + "reviewDone": "검토 완료 - {1}개의 자막 줄에서 {0}개의 제안 발견", "noIssuesFound": "문제 발견되지 않음", "categorySpelling": "맞춤법", "categoryGrammar": "문법", @@ -1526,7 +1534,7 @@ "removedEmptyLine": "빈 줄 제거", "ruleDetailsDotDotDot": "규칙 세부 정보...", "showOnlyThisRule": "이 규칙의 수정만 표시", - "appliedRulesForLineX": "라인 {0}에 적용된 규칙", + "appliedRulesForLineX": "{0}번째 줄에 적용된 규칙", "ruleX": "규칙: {0}", "removedEmptyLineAtTop": "맨 위에 빈 줄 삭제", "removedEmptyLineAtBottom": "맨 아래 빈 줄 삭제", @@ -1641,7 +1649,7 @@ "unbreakShortLine": "짧은 문장의 줄바꿈 제거", "fixText": "텍스트 수정", "removeSpaceBetweenNumbers": "숫자 사이 공백 제거", - "fixDialogsOnOneLine": "한 줄 대사 라인 수정" + "fixDialogsOnOneLine": "한 줄 대사 자막 수정" }, "adjustDurations": { "title": "길이 조정", @@ -1698,7 +1706,14 @@ "batchSnapToShotChanges": "큐를 장면 전환에 스냅 (가능한 경우)", "batchFrameRateFromVideo": "자막 파일과 같은 이름의 비디오 파일에서 프레임 속도 사용", "batchFrameRateFixed": "고정 프레임 속도 사용", - "batchInfo": "자막 파일과 같은 이름의 비디오 파일이 있으면 해당 파일에서 장면 전환을 읽습니다." + "batchInfo": "자막 파일과 같은 이름의 비디오 파일이 있으면 해당 파일에서 장면 전환을 읽습니다.", + "useExactTimeCodes": "비디오에서 정확한 타임코드 사용", + "extractTimeCodes": "타임코드 추출", + "extractingTimeCodes": "타임코드 추출 중...", + "noTimeCodesLoaded": "로드된 타임코드가 없습니다", + "xTimeCodesLoaded": "{0}개의 타임코드가 로드되었습니다", + "timeCodesExtractFailed": "비디오에서 타임코드를 읽을 수 없습니다", + "timeCodesHint": "고정 프레임 속도를 가정하지 않고 자막을 비디오의 실제 프레임 시점에 맞춥니다. 비디오 전체를 한 번 읽어야 하며 결과는 캐시됩니다." }, "beautifyTimeCodesProfile": { "title": "타임코드 다듬기 프로필 편집", @@ -1799,6 +1814,7 @@ "addFolderDotDotDot": "폴더 추가...", "selectFolderToConvert": "변환할 파일이 있는 폴더를 선택하세요", "includeSubfolders": "폴더를 추가할 때 하위 폴더 포함", + "keepSourceFileTimestamp": "출력 파일에 원본 파일의 날짜/시간 유지", "scanningFolderX": "{0} 스캔 중..." }, "changeCasing": { @@ -2129,6 +2145,33 @@ "fixCasing": "대/소문자 수정", "addPeriods": "마침표 추가", "changeUnderlineToColor": "밑줄 대신 색상으로 변경", + "removeNonSpeechLines": "대화가 아닌 자막 제거", + "removeNonSpeechLinesHint": "\"[음악]\"나 \"(파도 치는 소리)\"처럼 소리를 설명하는 자막을 제거합니다.", + "removeRepeatedLines": "반복된 자막 제거", + "removeRepeatedLinesHint": "이전 자막을 그대로 반복하는 자막을 제거합니다 (엔진 루프)", + "showQualityReport": "변환 후 품질 보고서 표시", + "qualityReportTitle": "변환 품질 보고서", + "qualityReportNoIssues": "문제가 발견되지 않았습니다. 텍스트 변환 결과가 양호합니다.", + "qualityReportSummaryX": "{0}개의 문제를 {1}줄에서 찾았습니다.", + "qualityReportLinesChecked": "확인된 줄", + "qualityReportIssuesFound": "발견된 문제", + "qualityReportLinesRemoved": "제거된 줄", + "qualityReportTooShort": "너무 짧음", + "qualityReportTooShortHint": "최소 표시 시간보다 짧거나 읽기 속도가 최대치를 초과함", + "qualityReportTooLong": "너무 긺", + "qualityReportTooLongHint": "최대 표시 시간보다 길거나, 긴 시간 동안 단어가 거의 없음 (할루시네이션 가능성 높음)", + "qualityReportOverlap": "겹침", + "qualityReportOverlapHint": "다음 자막이 시작된 후에 끝남", + "qualityReportNonSpeech": "대화가 아닌 자막", + "qualityReportNonSpeechHint": "\"[음악]\"처럼 소리나 음악 등을 설명하는 자막", + "qualityReportRepeated": "반복됨", + "qualityReportRepeatedHint": "이전 자막과 동일한 텍스트 (엔진 루프)", + "qualityReportRemoved": "제거됨", + "qualityReportAll": "모두", + "qualityReportIssue": "문제", + "qualityReportDetail": "세부사항", + "qualityReportTip": "자막 줄은 새 자막에서 번호와 함께 나열됩니다. 더 수정하려면 \"일반적인 오류 수정\" 또는 후처리 설정을 사용하시고, 많은 줄이 영향을 받는 경우 다른 모델/엔진을 시도해 보세요.", + "qualityReportDoNotShowAgain": "이 보고서를 다시 표시하지 않기", "engineSettings": "STT 엔진 설정", "engineSettingsSubtitle": "STT 엔진", "backendAndUpdateStatus": "백엔드 및 업데이트 상태", @@ -2162,6 +2205,9 @@ "elevenLabsSettingsResetHint": "ElevenLabs 설정을 기본값으로 다시 설정", "regenerateAudio": "오디오 다시 생성", "autoContinuePlaying": "자동 재생 계속", + "playLine": "줄 재생", + "fitDurationToGeneratedAudio": "생성된 오디오에 맞게 길이 조정", + "resetTiming": "타이밍 초기화", "addingAudioToVideoFileDotDotDot": "비디오 파일에 오디오를 추가하는 중...", "preparingMergeDotDotDot": "합치기 준비 중...", "importVoiceDotDotDot": "음성 가져오기...", @@ -2523,7 +2569,7 @@ "blockXOfY": "{1} 중 {0}번째 블록", "noTextInClipboard": "클립보드에 텍스트가 없음", "textInClipboardIsSameAsSourceText": "클립보드에 있는 텍스트가 원본 텍스트와 동일합니다. 다시 시도해 주세요.", - "lineMerge": "라인 합치기", + "lineMerge": "자막 합치기", "delayInSecondsBetweenRequests": "요청 간 지연 시간(초)", "maxBytesPerRequest": "요청당 최대 바이트 수", "promptText": "프롬프트 텍스트", @@ -2589,7 +2635,7 @@ "continuationStyleLeadingTrailingEllipsis": "줄임표", "continuationStyleLeadingTrailingDash": "대시", "continuationStyleLeadingTrailingDashDots": "대시, 하지만 쉼표는 점으로 표시", - "cpsLineLengthStyle": "Cps/라인 길이", + "cpsLineLengthStyle": "Cps/줄 길이", "cpsLineLengthStyleCalcAll": "모든 문자 세기", "cpsLineLengthStyleCalcNoSpaceCpsOnly": "띄어쓰기를 제외한 모든 것을 세고, 초당 단어 수(cps)만 계산", "cpsLineLengthStyleCalcNoSpace": "띄어쓰기를 제외한 모든 것을 셈", @@ -2636,7 +2682,7 @@ "showGridLines": "격자선 표시", "showHorizontalLineAboveToolbar": "도구 모음 위에 수평선 표시", "bookmarkColor": "즐겨찾기 색상", - "singleLineMaxLength": "줄(라인)당 최대 길이", + "singleLineMaxLength": "자막 줄당 최대 길이", "optimalCharsPerSec": "초당 최적 문자 수", "maxCharsPerSec": "초당 최대 문자 수", "maxWordsPerMin": "분당 최대 단어 수", @@ -2697,6 +2743,10 @@ "waveformMouseWheelStepMilliseconds": "{0}밀리초 (ms)", "waveformSnapToShotChanges": "장면 전환에 스냅 (Shift를 누르면 무시)", "waveformSnapToFrames": "프레임에 스냅", + "waveformSnapToShotChangesPixels": "드래그 시 스냅 거리 (픽셀)", + "waveformSnapToShotChangeStartMaxSeconds": "가까운 장면 전환에 스냅: 최대 시작 거리 (초)", + "waveformSnapToShotChangeEndMaxSeconds": "가까운 장면 전환에 스냅: 최대 끝 거리 (초)", + "waveformSnapToShotChangeSameShotEndMaxSeconds": "가까운 장면 전환에 스냅: 시작점과 끝점이 같은 장면에 있을 때 최대 끝점 거리 (초)", "waveformShotChangesAutoGenerate": "장면 전환 자동 생성", "waveformTextFontSize": "파형 텍스트 글자 크기", "waveformTextFontBold": "파형 텍스트 글자 굵게", @@ -2864,6 +2914,7 @@ "useFocusedButtonBackgroundColor": "포커스 버튼 배경색 사용", "focusedButtonBackgroundColor": "포커스 버튼 배경색", "forceCrLfOnSave": "저장 시, CR+LF 강제 적용(텍스트 자막 파일)", + "showFormatLimitWarning": "자막이 형식의 제한을 초과할 때 저장 시 경고 표시 (예: SCC 32 자/줄)", "textBoxButtonShowAutoBreak": "입력칸: 자동 줄바꿈 버튼 표시", "textBoxButtonShowUnbreak": "입력칸: 줄바꿈 해제 버튼 표시", "textBoxButtonShowItalic": "입력칸: 기울임 버튼 표시", @@ -3052,6 +3103,7 @@ "seekSilenceBack": "무음 앞 탐색", "seekSilenceForward": "무음 뒤 탐색", "setVideoPositionCurrentSubtitleStart": "비디오 위치를 현재 줄 시작 자리로 설정", + "goToSubtitlePositionAndPause": "자막 위치로 이동 후 일시정지", "setVideoPositionCurrentSubtitleEnd": "비디오 위치를 현재 줄 끝 자리로 설정", "toggleAudioTracks": "오디오 트랙 전환", "goToNextError": "다음 오류로 이동", @@ -3177,8 +3229,8 @@ "installedX": "설치됨 ({0})", "updateAvailableXToY": "업데이트 가능 ({0} → {1})", "applyPluginToWhichLinesX": "다음에 '{0}' 적용:", - "applyToSelectedLinesX": "선택한 줄({0})", - "applyToAllLinesX": "모든 라인({0})", + "applyToSelectedLinesX": "선택한 자막 줄({0})", + "applyToAllLinesX": "모든 자막 줄({0})", "updateAllXAvailable": "모두 업데이트 ({0})", "updatingXOfY": "{0}/{1} 업데이트 중..." }, @@ -3197,7 +3249,7 @@ "autoDrawAgain": "자동으로 다시 그리기", "startOcr": "OCR 시작", "pauseOcr": "OCR 중지", - "inspectLine": "라인 점검...", + "inspectLine": "자막 점검...", "ocrEngine": "OCR 엔진", "tesseractEngineMode": "엔진 모드", "tesseractEngineModeLegacy": "기존 Tesseract만 (이탤릭체 감지 가능)", @@ -3226,7 +3278,7 @@ "addNewCharcter": "새 문자 추가", "lineIndexX": "{0}번 줄", "inspectNOcrAdditions": "새 nOCR 항목 확인하기", - "ocrSelectedLines": "OCR 선택된 라인", + "ocrSelectedLines": "OCR 선택된 자막 줄", "fillSelectedLinesWithClipboard": "선택한 줄을 클립보드 텍스트로 채우기", "showImage": "이미지 표시", "fixOcrErrors": "OCR 오류 수정", @@ -3581,5 +3633,36 @@ "payPal": "페이팔", "gitHubSponsor": "GitHub 스폰서", "or": " 혹은 " + }, + "errorList": { + "title": "오류 목록", + "summaryX": "전체 {2}줄 중 {1}줄에서 {0}개의 오류가 발견되었습니다.", + "noErrors": "오류를 찾을 수 없습니다.", + "summaryFilesX": "{0}개의 오류가 {1}개의 자막에 있으며, {2}개의 파일에 걸쳐 있습니다.", + "all": "모두", + "tooManyLines": "줄이 너무 많음", + "charactersPerSecond": "읽기 속도", + "durationTooShort": "너무 짧음", + "durationTooLong": "너무 긺", + "lineTooLong": "줄이 너무 긺", + "lineTooWide": "줄이 너무 넓음", + "overlap": "겹침", + "gapTooShort": "간격이 너무 짧음", + "tooManyLinesHint": "최대 줄 수를 초과하는 줄", + "charactersPerSecondHint": "초당 문자 수가 최대치를 초과함", + "durationTooShortHint": "최소 표시 시간보다 짧음", + "durationTooLongHint": "최대 표시 시간보다 긺", + "lineTooLongHint": "줄이 최대 줄 길이를 초과함", + "lineTooWideHint": "줄이 최대 너비를 초과함", + "overlapHint": "이전 또는 다음 자막과 겹침", + "gapTooShortHint": "이전 또는 다음 자막과의 간격이 최소치보다 짧음", + "detailXGreaterThanY": "{0} > {1}", + "detailXLessThanY": "{0} < {1}", + "detailOverlapFromPrevious": "이전 자막과 겹침: {0} ms", + "detailOverlapToNext": "다음 자막과 겹침: {0} ms", + "detailGapToPrevious": "이전 자막까지: {0} < {1} ms", + "detailGapToNext": "다음 자막까지: {0} < {1} ms", + "detail": "상세 정보", + "tip": "자막 줄을 두 번 클릭하거나 엔터 키를 눌러 해당 위치로 이동합니다. 실행되는 검사 항목과 그 제한은 설정 > 일반에서 지정됩니다." } } From da3a28154bf620ae347a329d4672f7bd254945ef Mon Sep 17 00:00:00 2001 From: 12si27 <88251502+12si27@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:47:38 +0900 Subject: [PATCH 036/380] Update Korean.json Updated the Korean translations for 'margin' and 'marginIsPartOfSubtitleArea'. --- src/ui/Assets/Languages/Korean.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/Assets/Languages/Korean.json b/src/ui/Assets/Languages/Korean.json index c8e452c247a..8ada8f371e2 100644 --- a/src/ui/Assets/Languages/Korean.json +++ b/src/ui/Assets/Languages/Korean.json @@ -303,7 +303,7 @@ "loadDefaults": "기본값 불러오기", "lockTimeCodes": "타임코드 고정", "logo": "로고", - "margin": "마진", + "margin": "여백", "match": "일치", "maxCharactersPerSecond": "초당 최대 문자 수", "mediaInformation": "매체 정보", @@ -2929,7 +2929,7 @@ "matchIconColorToDarkTheme": "다크 테마에 맞게 아이콘 색상 변경", "subtitlePreviewProperties": "자막 미리보기 속성", "usePositionFromSubtitleFile": "자막 파일(TTML/PAC/EBU STL)에서 위치 사용", - "marginIsPartOfSubtitleArea": "여백은 자막 영역의 일부입니다", + "marginIsPartOfSubtitleArea": "여백을 자막 영역에 포함", "pixelWidthInfo": "녹색 선 = 최대 너비 제한 | 빨간색 영역 = 텍스트가 제한을 초과함", "spellCheckEngineHunSpelll": "Hunspell", "spellCheckEngineMsWord": "MS 워드", From 98e2935eb5fad22c3e56b37f44de06a039af1e3f Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 07:14:14 +0200 Subject: [PATCH 037/380] Perf hunt round 15: binary subtitle parsing Netflix checks/text passes and format writers already got a pass in round 14, so this round swept the binary parsers instead: Blu-ray .sup palette decoding, MPEG transport stream packet scanning, and the Cavena 890 Greek text decoder. Verified with BenchmarkDotNet, current shape against candidate shape in one process with an equivalence assert. Two changes are real, whole-file wins: BluRaySupPalette.YCbCr2Rgb 2.3x, int[3]-per-call -> out params TransportStreamParser packet loop 7.4x, allocate-then-discard -> peek-then-allocate Cavena890 Greek decoder 43.5x, 145-entry LINQ scan -> byte-indexed array YCbCr2Rgb is called once per palette entry - up to 256 times per subtitle image - from three call sites (BluRaySupPalette itself, Mp4VobSubPalette, and TextST), each returning a fresh int[3] just to be unpacked into three bytes. All three now call a new non-allocating out-param overload; the original array-returning overload is kept and now just delegates to it. TransportStreamParser's packet loop constructed a Packet - which always copies the payload into a fresh byte[] - for every single packet in the file, even though video/audio content packets (the vast majority of a real broadcast) are only ever tested against IsPrivateStream1 and immediately discarded. Two new static peek methods (Packet.PeekPacketId, Packet.PeekIsPrivateStream1) answer both checks straight from the raw buffer, so a Packet is now only materialized for null packets (free - just a counter bump) and packets that turn out to actually be kept. Verified byte-identical against a real 30MB Blu-ray-PGS-in-Matroska file: 1641 images decoded, same pixel-byte count and hash before and after. One trap hit while writing this: my first PeekIsPrivateStream1 had an off-by-one in the adaptation-field-length arithmetic (confused with the similarly-named but differently-computed AdaptionFieldLength property a few lines up), and my synthetic packet corpus never used adaptation fields (AFC still or 3) so the equivalence assert never caught it - only the existing TransportStreamDvbTimingTest suite did. Fixed, and the corpus now mixes AFC 01/11 packets so this class of bug cannot hide again. Cavena890's Greek decoder ran a LINQ FirstOrDefault over a 145-entry List> for every byte of every Greek-language line - the same shape as round 14's Scenarist Closed Captions fix. All 145 keys fit in a byte, so a 256-entry array answers it in one load. Also hoisted the two encoding.GetString(new byte[] { .. }) filler/dash marker strings that were rebuilt at every one of Cavena890's eight ReadParagraph call sites - the ~100 other single-use remap strings further down that same method are a once-per-paragraph cost, not once-per-character, so they were left alone. Three candidates from the initial sweep are not in this commit because they turned out to already be shipped: the Blu-ray RLE fill (SIMD pixel-run writer, already landed as commit 38492acfe) and the segment-buffer ArrayPool rental (already landed) fully cover what a naive re-optimization pass would have "found" here; and BluRaySupPicture's non-direct SKBitmap.GetPixel row reader already has a direct-pointer fast path for the Unpremul RGBA/BGRA layouts the encoder always produces. Checking git history before writing a "fix" avoided re-doing that work under a new name. A Rgb2YCbCr(0, 0, 0, ...) hoist was also dropped after checking real call sites: the ctor already calls it once outside the palette-fill loop, not once per entry - there was nothing to hoist. Co-Authored-By: Claude Opus 5 --- src/libse/BluRaySup/BluRaySupPalette.cs | 25 +- .../ContainerFormats/Mp4/Mp4VobSubPalette.cs | 4 +- .../TransportStream/Packet.cs | 50 +++ .../TransportStream/TransportStreamParser.cs | 78 +++-- src/libse/SubtitleFormats/Cavena890.cs | 45 ++- src/libse/SubtitleFormats/TextST.cs | 4 +- tests/benchmarks/PerfHuntRound15Benchmarks.cs | 309 ++++++++++++++++++ 7 files changed, 467 insertions(+), 48 deletions(-) create mode 100644 tests/benchmarks/PerfHuntRound15Benchmarks.cs 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/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/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/TransportStreamParser.cs b/src/libse/ContainerFormats/TransportStream/TransportStreamParser.cs index 3c7222cd0fe..485759ee453 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); diff --git a/src/libse/SubtitleFormats/Cavena890.cs b/src/libse/SubtitleFormats/Cavena890.cs index dbdf99fe0c2..92422c23969 100644 --- a/src/libse/SubtitleFormats/Cavena890.cs +++ b/src/libse/SubtitleFormats/Cavena890.cs @@ -11,6 +11,12 @@ namespace Nikse.SubtitleEdit.Core.SubtitleFormats { public class Cavena890 : SubtitleFormat, IBinaryPersistableSubtitle { + // The two filler/dash markers built via encoding.GetString(new byte[] { .. }) at every + // ReadParagraph call site below - a byte[] and a string allocated per paragraph, per + // language branch, just to hand straight to Replace(). cp1252 is fixed for all of them. + private static readonly string Cp1252FillerChar = Encoding.GetEncoding(1252).GetString(new byte[] { 0x7F }); + private static readonly string Cp1252DashChar = Encoding.GetEncoding(1252).GetString(new byte[] { 0xBE }); + public const int LanguageIdDanish = 0x07; public const int LanguageIdSwedish = 0x28; public const int LanguageIdNorwegian = 0x1e; @@ -506,6 +512,23 @@ public class Cavena890 : SubtitleFormat, IBinaryPersistableSubtitle new Tuple(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. + 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"; @@ -1897,8 +1920,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 +1941,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 +1955,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 +1994,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 +2019,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 +2071,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/TextST.cs b/src/libse/SubtitleFormats/TextST.cs index d78fcf17751..b2a675e61c2 100644 --- a/src/libse/SubtitleFormats/TextST.cs +++ b/src/libse/SubtitleFormats/TextST.cs @@ -82,8 +82,8 @@ public SKColor Color { get { - var arr = BluRaySupPalette.YCbCr2Rgb(Y, Cb, Cr, false); - return ColorUtils.FromArgb(T, (byte)arr[0], (byte)arr[1], (byte)arr[2]); + BluRaySupPalette.YCbCr2Rgb(Y, Cb, Cr, false, out var r8, out var g8, out var b8); + return ColorUtils.FromArgb(T, (byte)r8, (byte)g8, (byte)b8); } } } diff --git a/tests/benchmarks/PerfHuntRound15Benchmarks.cs b/tests/benchmarks/PerfHuntRound15Benchmarks.cs new file mode 100644 index 00000000000..6588e8115c7 --- /dev/null +++ b/tests/benchmarks/PerfHuntRound15Benchmarks.cs @@ -0,0 +1,309 @@ +using BenchmarkDotNet.Attributes; +using Nikse.SubtitleEdit.Core.BluRaySup; +using Nikse.SubtitleEdit.Core.ContainerFormats.TransportStream; +using System.Globalization; +using System.Text; + +namespace Nikse.SubtitleEdit.Benchmarks; + +/// +/// Round 15 performance hunt: binary subtitle parsing - Blu-ray .sup palette decoding, MPEG +/// transport stream packet scanning, and the Cavena 890 Greek text decoder. +/// +/// Every candidate calls the shipping code directly (not a self-contained copy), so these +/// benchmarks double as a regression check: if a future edit reintroduces the old shape, the +/// "Candidate" benchmark name stays but its behavior - and this file's intent - would need +/// updating too. +/// +/// This round's earlier draft included three more candidates (a palette-buffer consolidation, +/// a memoized RLE fill, and an SKBitmap.GetPixel row reader) that turned out to already be +/// shipped from prior perf-hunt rounds once checked against real source - see the round's +/// write-up. They are intentionally not here. +/// +[MemoryDiagnoser] +public class PerfHuntRound15Benchmarks +{ + private const int PaletteEntries = 256; + private byte[] _paletteBuffer = Array.Empty(); + private byte[][] _tsPackets = Array.Empty(); + private byte[] _cavenaGreekBytes = Array.Empty(); + + [GlobalSetup] + public void Setup() + { + var rnd = new DeterministicRandom(12345); + + // A Blu-ray palette definition segment: five bytes per entry (index, Y, Cr, Cb, alpha). + _paletteBuffer = new byte[PaletteEntries * 5]; + for (var i = 0; i < PaletteEntries; i++) + { + _paletteBuffer[i * 5] = (byte)i; + _paletteBuffer[i * 5 + 1] = (byte)(16 + rnd.Next(220)); + _paletteBuffer[i * 5 + 2] = (byte)rnd.Next(256); + _paletteBuffer[i * 5 + 3] = (byte)rnd.Next(256); + _paletteBuffer[i * 5 + 4] = (byte)rnd.Next(256); + } + + // 188-byte transport stream packets, the same mix as a real file: mostly video/audio + // content packets, occasional null packets, and a subtitle (private stream 1) packet + // every so often. + _tsPackets = new byte[20000][]; + for (var i = 0; i < _tsPackets.Length; i++) + { + var p = new byte[188]; + p[0] = 0x47; + var pid = i % 50 == 0 ? 0x1FFF : i % 17 == 0 ? 0x120 : 0x100 + i % 8; + p[1] = (byte)((pid >> 8) & 31); + p[2] = (byte)(pid & 0xFF); + + // A real broadcast mixes AFC=01 (payload only) and AFC=11 (adaptation field + + // payload, e.g. PCR-carrying packets) - every third packet here carries an + // adaptation field, so the payload offset must be computed correctly for both. + var hasAdaptationField = i % 3 == 0; + var adaptationFieldLength = hasAdaptationField ? 1 + i % 6 : 0; + p[3] = (byte)((hasAdaptationField ? 0b00110000 : 0b00010000) | i % 16); + var markerStart = 4; + if (hasAdaptationField) + { + p[4] = (byte)adaptationFieldLength; + for (var k = 0; k < adaptationFieldLength; k++) + { + p[5 + k] = (byte)rnd.Next(256); + } + + markerStart = 4 + 1 + adaptationFieldLength; + } + + if (i % 17 == 0) + { + p[markerStart] = 0; p[markerStart + 1] = 0; p[markerStart + 2] = 1; p[markerStart + 3] = 0xbd; // private stream 1 + p[1] |= 64; // payload unit start + } + else + { + p[markerStart] = 0; p[markerStart + 1] = 0; p[markerStart + 2] = 1; p[markerStart + 3] = 0xE0; // video PES start + } + + for (var j = markerStart + 4; j < p.Length; j++) + { + p[j] = (byte)rnd.Next(256); + } + + _tsPackets[i] = p; + } + + // A Cavena 890 Greek text record - 400 lines' worth of raw Greek-table bytes. + _cavenaGreekBytes = new byte[51 * 400]; + for (var i = 0; i < _cavenaGreekBytes.Length; i++) + { + _cavenaGreekBytes[i] = (byte)GreekBytes[rnd.Next(GreekBytes.Length)]; + } + + AssertEquivalence(); + } + + /// xorshift - deterministic so the corpus is identical on every run. + private sealed class DeterministicRandom + { + private uint _state; + public DeterministicRandom(uint seed) => _state = seed; + + public int Next(int max) + { + _state ^= _state << 13; + _state ^= _state >> 17; + _state ^= _state << 5; + return (int)(_state % (uint)max); + } + } + + // The 145 byte values Cavena890's Greek table actually maps (0x20-0xD9), read off the real + // table via reflection would be brittle across edits, so this mirrors the known key range. + private static readonly int[] GreekBytes = BuildGreekByteRange(); + + private static int[] BuildGreekByteRange() + { + var list = new List(); + for (var b = 0x20; b <= 0xD9; b++) + { + list.Add(b); + } + + return list.ToArray(); + } + + private void AssertEquivalence() + { + Check("C01", C01_Current(), C01_Candidate()); + Check("C02", C02_Current(), C02_Candidate()); + Check("C03", C03_Current(), C03_Candidate()); + } + + private static void Check(string name, string current, string candidate) + { + if (!string.Equals(current, candidate, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"{name}: candidate diverges.\ncurrent : {current}\ncandidate: {candidate}"); + } + } + + // ======================================================================================= + // C01 - BluRaySupPalette.YCbCr2Rgb: the array-returning overload (kept for compatibility) + // versus the new out-parameter overload it now delegates to. DecodePalette calls this once + // per palette entry - up to 256 times per subtitle image - so the array was pure per-call + // garbage for every image in a .sup file. + // ======================================================================================= + private string C01_Current() + { + var sum = 0L; + for (var img = 0; img < 400; img++) + { + for (var i = 0; i < PaletteEntries; i++) + { + var rgb = BluRaySupPalette.YCbCr2Rgb(_paletteBuffer[i * 5 + 1], _paletteBuffer[i * 5 + 3], _paletteBuffer[i * 5 + 2], false); + sum += rgb[0] + rgb[1] + rgb[2]; + } + } + + return sum.ToString(CultureInfo.InvariantCulture); + } + + private string C01_Candidate() + { + var sum = 0L; + for (var img = 0; img < 400; img++) + { + for (var i = 0; i < PaletteEntries; i++) + { + BluRaySupPalette.YCbCr2Rgb(_paletteBuffer[i * 5 + 1], _paletteBuffer[i * 5 + 3], _paletteBuffer[i * 5 + 2], false, out var r, out var g, out var b); + sum += r + g + b; + } + } + + return sum.ToString(CultureInfo.InvariantCulture); + } + + [Benchmark] public string C01_YCbCr2Rgb_Current() => C01_Current(); + [Benchmark] public string C01_YCbCr2Rgb_Candidate() => C01_Candidate(); + + // ======================================================================================= + // C02 - TransportStreamParser's packet loop: constructing a Packet (which always copies the + // payload into a fresh byte[]) for every single packet, versus peeking the raw buffer first + // and only materializing a Packet for null/private-stream-1 packets. + // ======================================================================================= + private string C02_Current() + { + var nulls = 0; + var kept = 0; + foreach (var raw in _tsPackets) + { + var packet = new Packet(raw); + if (packet.IsNullPacket) + { + nulls++; + } + else if (packet.IsPrivateStream1) + { + kept++; + } + } + + return nulls + ":" + kept; + } + + private string C02_Candidate() + { + var nulls = 0; + var kept = 0; + foreach (var raw in _tsPackets) + { + var pid = Packet.PeekPacketId(raw); + if (pid == Packet.NullPacketId) + { + nulls++; + continue; + } + + if (!Packet.PeekIsPrivateStream1(raw)) + { + continue; + } + + var packet = new Packet(raw); + if (packet.IsPrivateStream1) + { + kept++; + } + } + + return nulls + ":" + kept; + } + + [Benchmark] public string C02_TsPacketScan_Current() => C02_Current(); + [Benchmark] public string C02_TsPacketScan_Candidate() => C02_Candidate(); + + // ======================================================================================= + // C03 - Cavena890's Greek decoder: List>.FirstOrDefault per byte, versus + // the byte-indexed GreekLookup array now shipped. + // ======================================================================================= + private string C03_Current() + { + var sb = new StringBuilder(); + foreach (var b in _cavenaGreekBytes) + { + var entry = LegacyGreekTable.FirstOrDefault(e => e.Item1 == b); + if (entry != null) + { + sb.Append(entry.Item2); + } + } + + return sb.Length.ToString(CultureInfo.InvariantCulture); + } + + private string C03_Candidate() + { + var sb = new StringBuilder(); + foreach (var b in _cavenaGreekBytes) + { + var entry = CavenaGreekLookupMirror[b]; + if (entry != null) + { + sb.Append(entry); + } + } + + return sb.Length.ToString(CultureInfo.InvariantCulture); + } + + // A same-shape mirror of Cavena890's private Greek table/lookup - the real fields are + // private to that class, so this reproduces the same 145-entry table to measure the same + // scan-versus-array-lookup gap without reflection. + private static readonly List> LegacyGreekTable = BuildLegacyGreekTable(); + private static readonly string?[] CavenaGreekLookupMirror = BuildLookupMirror(); + + private static List> BuildLegacyGreekTable() + { + var list = new List>(GreekBytes.Length); + foreach (var b in GreekBytes) + { + list.Add(new Tuple(b, ((char)(0x370 + (b - 0x20))).ToString())); + } + + return list; + } + + private static string?[] BuildLookupMirror() + { + var table = new string?[256]; + foreach (var entry in LegacyGreekTable) + { + table[entry.Item1] = entry.Item2; + } + + return table; + } + + [Benchmark] public string C03_CavenaGreekLookup_Current() => C03_Current(); + [Benchmark] public string C03_CavenaGreekLookup_Candidate() => C03_Candidate(); +} From 7a289c7f3eb1bb680a79831a6e37353c1f520949 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 07:29:57 +0200 Subject: [PATCH 038/380] Match GPT transcription models by name shape, not a fixed list gpt-*-transcribe was matched via three hardcoded substrings, so a future model like gpt-5-transcribe would silently fall back to verbose_json and fail the same way this PR fixes. Match on the gpt-*-transcribe name shape instead. --- .../OpenRouter/OpenRouterSttService.cs | 17 +++++++++++++--- .../OpenRouter/OpenRouterSttServiceTests.cs | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/ui/Features/Video/SpeechToText/OpenRouter/OpenRouterSttService.cs b/src/ui/Features/Video/SpeechToText/OpenRouter/OpenRouterSttService.cs index 679c6d6edce..53ba4537e50 100644 --- a/src/ui/Features/Video/SpeechToText/OpenRouter/OpenRouterSttService.cs +++ b/src/ui/Features/Video/SpeechToText/OpenRouter/OpenRouterSttService.cs @@ -183,11 +183,22 @@ 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) { - return model?.Contains("gpt-transcribe", StringComparison.OrdinalIgnoreCase) == true || - model?.Contains("gpt-4o-transcribe", StringComparison.OrdinalIgnoreCase) == true || - model?.Contains("gpt-4o-mini-transcribe", StringComparison.OrdinalIgnoreCase) == true; + if (string.IsNullOrWhiteSpace(model)) + { + return false; + } + + var name = model[(model.LastIndexOf('/') + 1)..]; + return name.StartsWith("gpt-", StringComparison.OrdinalIgnoreCase) && + name.Contains("transcribe", StringComparison.OrdinalIgnoreCase); } /// diff --git a/tests/UI/Features/Video/SpeechToText/OpenRouter/OpenRouterSttServiceTests.cs b/tests/UI/Features/Video/SpeechToText/OpenRouter/OpenRouterSttServiceTests.cs index c84156894dc..4e80d943eed 100644 --- a/tests/UI/Features/Video/SpeechToText/OpenRouter/OpenRouterSttServiceTests.cs +++ b/tests/UI/Features/Video/SpeechToText/OpenRouter/OpenRouterSttServiceTests.cs @@ -44,6 +44,7 @@ public void BuildRequestBody_EncodesAudioAndAsksForTimestamps() [InlineData("openai/gpt-transcribe")] [InlineData("openai/gpt-4o-transcribe")] [InlineData("openai/gpt-4o-mini-transcribe")] + [InlineData("openai/gpt-5-transcribe")] // not released at the time of writing; matched by name shape, not an exact list public void BuildRequestBody_GptTranscriptionModelsUseJsonWithoutTimestamps(string model) { var body = OpenRouterSttService.BuildRequestBody( @@ -59,6 +60,25 @@ public void BuildRequestBody_GptTranscriptionModelsUseJsonWithoutTimestamps(stri Assert.False(root.TryGetProperty("timestamp_granularities", out _)); } + [Theory] + [InlineData("openai/whisper-1")] + [InlineData("openai/whisper-large-v3")] + [InlineData("openai/gpt-4o-audio-preview")] // "gpt-" prefixed but not a transcription model + public void BuildRequestBody_NonTranscribeModelsKeepVerboseJson(string model) + { + var body = OpenRouterSttService.BuildRequestBody( + MakeSettings(model), + Encoding.UTF8.GetBytes("hello-bytes"), + "mp3", + "ar"); + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + Assert.Equal("verbose_json", root.GetProperty("response_format").GetString()); + Assert.True(root.TryGetProperty("timestamp_granularities", out _)); + } + [Fact] public void BuildRequestBody_OmitsEmptyLanguageAndZeroTemperature() { From 9c78d8667b40fcf4bd7bcc29d280d118b24f1a74 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 08:34:08 +0200 Subject: [PATCH 039/380] OCR fix replace lists: repair broken rules, remove dead entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the 19 shipped replace lists (structural pass + .NET regex validation) found ~115 defective entries across 11 files: - hrv: "iscj?edi" had replaceWith="$1scijedi" with no capture group, emitting literal "$1" into subtitle text; now "([iI])scj?edi". Two rules with "\(" instead of "(" never compiled and were silently dropped since they were added; now active. - dan: "\b\l\b" (invalid \l escape, dead since 2020) removed. - swe: ungated "\bl\b" -> "I" removed - it turned the liter symbol ("2 l") into "2 I". - spa: "¿Con cuanta" wrongly mapped to plural "¿Con cuántas"; the correct singular entry now wins. Three no-op entries restored to their evident intent (¿a quien/¿A quien accent fix, lowercase escuchado exclamation wrap). - 11 RegularExpressionsIfSpelledCorrectly entries missing the spellCheck attribute (silently skipped by the loader): eng standalone "\bl\b" -> "I" gained spellCheck="I" and is now live; the copies in dan/fra/nld/nob/nor/swe were removed (lone "l" is the liter symbol, and "I" is not a dictionary word there). The 4 fra elision rules were no-ops (straight apostrophe to straight apostrophe) and redundant with the hardcoded typographic-apostrophe normalization. - deu: real-word collisions removed (gernhaben, ranzukommen, runterspült, wiederhaben, Blutgetränkte, immerwährende are all correct German); chains flattened (Spell/Spells -> Spaß, weillt -> weißt, filr'ne -> für 'ne); dash-variant dead duplicates dropped. - Across files: 24 no-op from==to entries (skipped by loader), ~30 exact duplicates, and later same-key entries shadowed by first-wins removed; remaining chains flattened (dan fieme -> fjerne, hrv đta -> što, maiopre -> malo prije, obiastima -> područjima, hrb casu -> čašu). All find patterns now compile under .NET and every $N reference has a matching group. Tests: 293 libuilogic Ocr + 90 UI Ocr.FixEngine pass. Co-Authored-By: Claude Fable 5 --- Dictionaries/dan_OCRFixReplaceList.xml | 5 +-- Dictionaries/deu_OCRFixReplaceList.xml | 31 +++--------------- Dictionaries/eng_OCRFixReplaceList.xml | 7 +--- Dictionaries/fra_OCRFixReplaceList.xml | 10 ------ Dictionaries/hrb_OCRFixReplaceList.xml | 11 +------ Dictionaries/hrv_OCRFixReplaceList.xml | 44 ++++---------------------- Dictionaries/nld_OCRFixReplaceList.xml | 8 ----- Dictionaries/nob_OCRFixReplaceList.xml | 2 -- Dictionaries/nor_OCRFixReplaceList.xml | 2 -- Dictionaries/spa_OCRFixReplaceList.xml | 8 ++--- Dictionaries/swe_OCRFixReplaceList.xml | 3 -- 11 files changed, 17 insertions(+), 114 deletions(-) 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/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/spa_OCRFixReplaceList.xml b/Dictionaries/spa_OCRFixReplaceList.xml index 7abe2471b71..dd5f7532598 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 @@ - 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 @@ - - From 2521e25484a41e373230e8bf4757d89c1844b3e7 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 08:39:00 +0200 Subject: [PATCH 040/380] Fix four bugs found reviewing the last day's commits - ASSA CheckForErrors: the "Format:" length guard checked the raw line while Substring(8) indexed the trimmed one, so a padded "Format:" line threw ArgumentOutOfRangeException instead of reporting header errors. - List errors / batch error list: GetErrorList had no branch for the teletext page-width rule, so lines the grid reddens on EBU-sourced subtitles silently vanished from both dialogs and their summary cards. - Beautify time codes: with an unknown video duration, IsUsableFor accepted any list with two entries, so a cancelled extraction (or an ffmpeg crash mid-decode, whose nonzero exit was never checked) was saved and every cue snapped to its few frames. IsUsableFor now rejects unverifiable lists, ExtractAsync throws on a nonzero ffmpeg exit, and a completed run vouches for itself via its last frame time. - STT quality report: the "repeated" detail named the removed line's own input index instead of the kept line it duplicated whenever a non-speech line was dropped in between. Co-Authored-By: Claude Fable 5 --- .../AdvancedSubStationAlpha.cs | 2 +- src/ui/Features/Main/SubtitleLineViewModel.cs | 17 +++++++ .../BeautifyTimeCodesViewModel.cs | 8 ++++ .../SpeechToText/SpeechToTextPostProcessor.cs | 7 ++- src/ui/Logic/Media/TimeCodesGenerator.cs | 7 +++ src/ui/Logic/Media/TimeCodesHelper.cs | 5 ++- .../SubtitleLineViewModelHasErrorsTests.cs | 45 +++++++++++++++++++ .../SpeechToTextPostProcessorTests.cs | 18 ++++++++ tests/UI/Logic/Media/TimeCodesTests.cs | 15 +++++++ tests/libse/Core/BugHunt20260824Test.cs | 23 ++++++++++ 10 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 tests/libse/Core/BugHunt20260824Test.cs diff --git a/src/libse/SubtitleFormats/AdvancedSubStationAlpha.cs b/src/libse/SubtitleFormats/AdvancedSubStationAlpha.cs index cb915b5d46e..93f5d1a23b9 100644 --- a/src/libse/SubtitleFormats/AdvancedSubStationAlpha.cs +++ b/src/libse/SubtitleFormats/AdvancedSubStationAlpha.cs @@ -2300,7 +2300,7 @@ 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) { // cut the trimmed line - leading whitespace shifted every field name var format = s.Substring(8).Split(','); diff --git a/src/ui/Features/Main/SubtitleLineViewModel.cs b/src/ui/Features/Main/SubtitleLineViewModel.cs index 3ea569ae19e..c088a81d23a 100644 --- a/src/ui/Features/Main/SubtitleLineViewModel.cs +++ b/src/ui/Features/Main/SubtitleLineViewModel.cs @@ -1296,6 +1296,23 @@ public List GetErrorList(SubtitleLineViewModel? prev, SubtitleLineVie } } + // Teletext page width - applies to EBU/teletext-sourced subtitles regardless of the + // general "too long" setting, matching the teletext branch in HasTextError. + if (UseTeletextLineLength) + { + var maxCharacters = Text.Contains(" timeCodes, double durationS if (durationSeconds <= 0) { - return true; // unknown length - nothing to check against + // 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]; diff --git a/tests/UI/Features/Main/SubtitleLineViewModelHasErrorsTests.cs b/tests/UI/Features/Main/SubtitleLineViewModelHasErrorsTests.cs index 957441c6a7f..c91f0913d7c 100644 --- a/tests/UI/Features/Main/SubtitleLineViewModelHasErrorsTests.cs +++ b/tests/UI/Features/Main/SubtitleLineViewModelHasErrorsTests.cs @@ -172,6 +172,51 @@ public void HasErrors_FollowsTextChange() } } + /// + /// The teletext page-width rule (EBU-sourced subtitles) reddens the grid via HasErrors + /// regardless of the general "too long" setting - the error list must report the same + /// lines, or they silently vanish from "List errors" and batch convert's error list. + /// + [AvaloniaFact] + public void HasErrors_MatchesGetErrors_WithTeletextLineLengthOn() + { + var originalSettings = Se.Settings; + var originalTeletext = SubtitleLineViewModel.UseTeletextLineLength; + try + { + Se.Settings = new Se(); + var general = Se.Settings.General; + general.ColorDurationTooShort = false; + general.ColorDurationTooLong = false; + general.ColorTextTooLong = false; + general.ColorTextTooWide = false; + general.ColorTextTooManyLines = false; + general.ColorCharactersPerSecond = false; + general.ColorWordsPerMinute = false; + general.ColorTimeCodeOverlap = false; + general.ColorGapTooShort = false; + SubtitleLineViewModel.UseTeletextLineLength = true; + + var lines = new List + { + Line("Fits on a teletext row.", 1000, 3000), // 23 chars, clean + Line("This line is longer than the thirty-seven characters a teletext row holds.", 4000, 8000), + Line("Exactly thirty-seven characters here!", 9000, 12000), // 37 > 36 with color + }; + + var withErrors = AssertAgrees(lines); + Assert.Equal(2, withErrors); + + var errors = lines[1].GetErrorList(lines[0], lines[2]); + Assert.Contains(errors, e => e.Type == Nikse.SubtitleEdit.Features.Shared.ErrorList.LineErrorType.LineTooLong); + } + finally + { + Se.Settings = originalSettings; + SubtitleLineViewModel.UseTeletextLineLength = originalTeletext; + } + } + /// The pixel width column reads the same memo, so it must follow the text too. [AvaloniaFact] public void PixelWidth_FollowsTextChange() diff --git a/tests/UI/Features/Video/SpeechToText/SpeechToTextPostProcessorTests.cs b/tests/UI/Features/Video/SpeechToText/SpeechToTextPostProcessorTests.cs index 8013a1d31cb..dc77c1b5da2 100644 --- a/tests/UI/Features/Video/SpeechToText/SpeechToTextPostProcessorTests.cs +++ b/tests/UI/Features/Video/SpeechToText/SpeechToTextPostProcessorTests.cs @@ -156,6 +156,24 @@ public void Fix_RemovesNonSpeechAndRepeatedLines_WhenEnabled() Assert.Equal(0, pp.QualityReport.Count(SpeechToTextQualityIssueType.Repeated)); } + [Fact] + public void Fix_RepeatedDetail_NamesTheLineActuallyDuplicated() + { + // Line 2 ("[Music]") is removed first, so line 3 repeats line 1 - the detail must + // say "= #1", not point at the removed line in between. + var subtitle = Make( + ("Hello there.", 0, 1500), + ("[Music]", 2000, 3500), + ("Hello there.", 4000, 5500)); + + var pp = new SpeechToTextPostProcessor("en") { RemoveNonSpeechLines = true, RemoveRepeatedLines = true }; + pp.Fix(SpeechToTextPostProcessor.Engine.Whisper, subtitle, true, false, false, false, false, false, false, Avalonia.Media.Colors.Red); + + var repeated = pp.QualityReport.Removed.Single(i => i.Type == SpeechToTextQualityIssueType.Repeated); + Assert.Equal(3, repeated.Number); + Assert.Equal("= #1", repeated.Detail); + } + [Fact] public void Fix_KeepsLinesButReportsThem_WhenDisabled() { diff --git a/tests/UI/Logic/Media/TimeCodesTests.cs b/tests/UI/Logic/Media/TimeCodesTests.cs index c2a982e600a..d32ddf18474 100644 --- a/tests/UI/Logic/Media/TimeCodesTests.cs +++ b/tests/UI/Logic/Media/TimeCodesTests.cs @@ -68,6 +68,21 @@ public void IsUsableFor_RejectsAnIncompleteList() Assert.False(TimeCodesHelper.IsUsableFor(new List(), 10)); } + [Fact] + public void IsUsableFor_RejectsAnyListWhenTheDurationIsUnknown() + { + // With no duration to check against, a cancelled 3-frame extraction is + // indistinguishable from a complete one - it must not be trusted (callers that know + // the run completed pass the last frame time as the duration instead). + var fullList = Enumerable.Range(0, 250).Select(n => n / 25.0).ToList(); + Assert.False(TimeCodesHelper.IsUsableFor(fullList.Take(3).ToList(), 0)); + Assert.False(TimeCodesHelper.IsUsableFor(fullList, 0)); + Assert.False(TimeCodesHelper.IsUsableFor(fullList, -1)); + + // A completed run can vouch for itself: its own last frame time is the duration. + Assert.True(TimeCodesHelper.IsUsableFor(fullList, fullList[fullList.Count - 1])); + } + /// /// The point of the whole feature: on material whose real frames are not on an n/fps grid, /// beautifying with the video's own time codes must land cues on real frames, where nominal diff --git a/tests/libse/Core/BugHunt20260824Test.cs b/tests/libse/Core/BugHunt20260824Test.cs new file mode 100644 index 00000000000..e042c64a96a --- /dev/null +++ b/tests/libse/Core/BugHunt20260824Test.cs @@ -0,0 +1,23 @@ +using Nikse.SubtitleEdit.Core.SubtitleFormats; + +namespace LibSETests.Core; + +public class BugHunt20260824Test +{ + [Fact] + public void AssaCheckForErrors_PaddedFormatLineDoesNotThrow() + { + // "Format:" plus whitespace trims to 7 chars while the raw line is longer than the + // guard's 10 - Substring(8) on the trimmed string must not be reached. + var header = "[V4+ Styles]\nFormat: \nStyle: Default,Arial,20,&H00FFFFFF,&H0300FFFF,&H00000000,&H02000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1"; + var result = AdvancedSubStationAlpha.CheckForErrors(header); + Assert.NotNull(result); + } + + [Fact] + public void AssaCheckForErrors_PaddedFormatLineWithFieldsStillParses() + { + var header = "[V4+ Styles]\n Format: Name, Fontname, Fontsize \nStyle: ,Arial,20"; + Assert.Contains("'Name' is empty", AdvancedSubStationAlpha.CheckForErrors(header)); + } +} From 54b5f44d6c812abb8d4b64f0e48a6304621e3d67 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 08:39:15 +0200 Subject: [PATCH 041/380] Add CI guard test for shipped OCR fix replace lists Every defect class the loaders swallow silently is now a test failure: regexes that do not compile, $N references without a matching capture group (emitted literally into subtitle text), gated rules missing the spellCheck attribute, no-op from==to pairs, exact duplicates, entries shadowed by an earlier first-wins key, and unknown section names. Duplicate same-name sections are checked as one merged view, matching how the loaders read them. Verified against the pre-cleanup lists: the test fails on exactly the 11 files the audit flagged and passes the 8 clean ones. Co-Authored-By: Claude Fable 5 --- .../Ocr/ShippedOcrFixReplaceListsTests.cs | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 tests/libuilogic/Ocr/ShippedOcrFixReplaceListsTests.cs diff --git a/tests/libuilogic/Ocr/ShippedOcrFixReplaceListsTests.cs b/tests/libuilogic/Ocr/ShippedOcrFixReplaceListsTests.cs new file mode 100644 index 00000000000..30437d17485 --- /dev/null +++ b/tests/libuilogic/Ocr/ShippedOcrFixReplaceListsTests.cs @@ -0,0 +1,181 @@ +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace LibUiLogicTests.Ocr; + +/// +/// Guards the shipped Dictionaries/<lang>_OCRFixReplaceList.xml files. The loaders +/// drop bad entries silently - an invalid regex, a "$1" without a capture group, a gated rule +/// missing its spellCheck attribute, a from==to pair, or an entry shadowed by an earlier key all +/// look like the fix "just doesn't work" at runtime. A 2026-08 audit found ~115 such entries, +/// some of them years old; this test turns every one of those defect classes into a CI failure. +/// +public class ShippedOcrFixReplaceListsTests +{ + // The section names OcrFixReplaceList2/SpellCheckRegex read. Anything else in a shipped + // file is dead weight the loaders never look at. + private static readonly string[] PairSections = + { + "WholeWords", "PartialWordsAlways", "PartialWords", "PartialLines", + "PartialLinesAlways", "BeginLines", "EndLines", "WholeLines", + }; + + private static readonly string[] RegexSections = + { + "RegularExpressions", "RegularExpressionsIfSpelledCorrectly", + }; + + // PartialWords is loaded as a pair list where several readings of the same OCR artifact are + // by design (deu ships i->t and i->l); every other pair section is a first-wins dictionary. + private static readonly string[] MultiReadingSections = { "PartialWords" }; + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(Directory.GetCurrentDirectory()); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "SubtitleEdit.sln"))) + { + dir = dir.Parent; + } + + Assert.NotNull(dir); + return dir!.FullName; + } + + private static string DictionariesFolder() => Path.Combine(FindRepoRoot(), "Dictionaries"); + + public static TheoryData ReplaceListFiles() + { + var data = new TheoryData(); + foreach (var file in Directory.GetFiles(DictionariesFolder(), "*_OCRFixReplaceList.xml").OrderBy(f => f)) + { + data.Add(Path.GetFileName(file)); + } + + return data; + } + + [Fact] + public void ThereAreReplaceListsToCheck() + { + Assert.NotEmpty(Directory.GetFiles(DictionariesFolder(), "*_OCRFixReplaceList.xml")); + } + + [Theory] + [MemberData(nameof(ReplaceListFiles))] + public void EveryEntryIsLoadableAndReachable(string fileName) + { + var root = XDocument.Load(Path.Combine(DictionariesFolder(), fileName)).Root; + Assert.NotNull(root); + Assert.Equal("OCRFixReplaceList", root!.Name.LocalName); + + var knownSections = PairSections.Concat(RegexSections).ToHashSet(); + foreach (var section in root.Elements()) + { + Assert.True(knownSections.Contains(section.Name.LocalName), + $"{fileName}: unknown section <{section.Name.LocalName}> is never read by the loader"); + } + + // Duplicate same-name sections are legal (the loaders merge them), so all checks run on + // the merged per-name view - a duplicate hiding in a second section block must still fail. + foreach (var name in PairSections) + { + CheckPairSection(fileName, name, root.Elements(name).SelectMany(s => s.Elements())); + } + + foreach (var name in RegexSections) + { + CheckRegexSection(fileName, name, root.Elements(name).SelectMany(s => s.Elements())); + } + } + + private static void CheckPairSection(string fileName, string sectionName, IEnumerable items) + { + var seenPairs = new HashSet<(string From, string To)>(); + var seenKeys = new Dictionary(StringComparer.Ordinal); + foreach (var item in items) + { + var where = $"{fileName} <{sectionName}>"; + var from = item.Attribute("from")?.Value; + var to = item.Attribute("to")?.Value; + Assert.True(from != null && to != null, $"{where}: entry without from/to is skipped: {item}"); + Assert.False(from!.Length == 0, $"{where}: empty from: {item}"); + Assert.False(from == to, $"{where}: from==to '{from}' is a no-op the loader skips"); + Assert.True(seenPairs.Add((from, to!)), + $"{where}: exact duplicate '{from}' -> '{to}'"); + + if (!MultiReadingSections.Contains(sectionName)) + { + Assert.False(seenKeys.TryGetValue(from, out var firstTo), + $"{where}: '{from}' -> '{to}' is dead, first-wins already maps it to '{seenKeys.GetValueOrDefault(from)}'"); + seenKeys[from] = to!; + } + } + } + + private static void CheckRegexSection(string fileName, string sectionName, IEnumerable items) + { + var gated = sectionName == "RegularExpressionsIfSpelledCorrectly"; + var seenFinds = new HashSet(StringComparer.Ordinal); + foreach (var item in items) + { + var where = $"{fileName} <{sectionName}>"; + var find = item.Attribute("find")?.Value; + var replaceWith = item.Attribute("replaceWith")?.Value; + var spellCheck = item.Attribute("spellCheck")?.Value; + Assert.False(string.IsNullOrEmpty(find), $"{where}: entry without find is skipped: {item}"); + Assert.NotNull(replaceWith); + if (gated) + { + // SpellCheckRegex.LoadRegExNodes requires all three attributes and skips the + // rule silently otherwise - 11 shipped rules were dead this way for months. + Assert.False(string.IsNullOrEmpty(spellCheck), + $"{where}: gated rule without spellCheck is silently skipped: {item}"); + } + + Regex regex; + try + { + regex = new Regex(find!); + } + catch (ArgumentException e) + { + Assert.Fail($"{where}: find='{find}' does not compile and is silently dropped: {e.Message}"); + return; + } + + // An unmatched $N in a .NET replacement is emitted literally, so a bad group + // reference corrupts the subtitle text instead of fixing it. + var groups = regex.GetGroupNumbers(); + foreach (var (attrName, value) in new[] { ("replaceWith", replaceWith), ("spellCheck", spellCheck) }) + { + if (value == null) + { + continue; + } + + foreach (Match reference in Regex.Matches(value.Replace("$$", string.Empty), @"\$(\d+)")) + { + var n = int.Parse(reference.Groups[1].Value); + Assert.True(groups.Contains(n), + $"{where}: {attrName}='{value}' references ${n} but find='{find}' has no group {n}"); + } + } + + if (gated) + { + // Gated rules are a list, so several fixes for the same find are legal (each + // candidate is dictionary-checked) - only an identical rule is a defect. + var signature = find + "\n" + spellCheck + "\n" + replaceWith + "\n" + + item.Attribute("replaceAllFrom")?.Value + "\n" + item.Attribute("replaceAllTo")?.Value; + Assert.True(seenFinds.Add(signature), + $"{where}: identical duplicate rule find='{find}' is applied twice"); + } + else + { + // RegularExpressions is a first-wins dictionary - a repeated find is dead. + Assert.True(seenFinds.Add(find!), + $"{where}: duplicate find='{find}' - the later entry is dead"); + } + } + } +} From 2cf426aea4397087b5bc84dfe0a0fad89a991c36 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 09:11:18 +0200 Subject: [PATCH 042/380] Add gated OCR accent and l/I rules for Spanish, Portuguese, Italian MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rich rule blocks (l/I confusion in all-caps captions, leading I->l) only existed for dan/deu/eng/fra/nld/nob/nor/swe; spa/por/ita had just the shared italic-i block. This ports the caps family and adds a new dictionary-gated accent-restoration family, generalizing what the lists did one word at a time ("dia" -> "día" as WholeWords) and what the spa "clón" -> "ción" rule proved out (#13701): - spa: final-syllable acute accents (monton -> montón, capitan -> capitán, jardin -> jardín, quizas -> quizás, vivio -> vivió, cafe -> café) - por: tildes and accents (sao -> são, irmaos -> irmãos, avioes -> aviões, voce -> você, ingles -> inglês, necessario -> necessário; -cao is left to the existing ungated \Bcao\b rule) - ita: final-vowel accents (citta -> città, perche -> perché, cosi -> così, piu -> più, caffe -> caffè, pero -> però; both e-accents are tried and the dictionary picks the right one) A rule only fires when the whole line failed the spell check, the misread form is NOT in the dictionary and the fixed form IS, so real words are never touched; lowercase-only character classes keep proper names (Mia, Joao, Maria) out of reach. Without a Hunspell dictionary the rules are inert. Tests run against the shipped XMLs with fake spell checkers, including negative cases for protected words/names and no-dictionary cases picked to avoid words the ungated rules already fix. Co-Authored-By: Claude Fable 5 --- Dictionaries/ita_OCRFixReplaceList.xml | 29 +++ Dictionaries/por_OCRFixReplaceList.xml | 39 ++++ Dictionaries/spa_OCRFixReplaceList.xml | 34 ++++ .../OcrFixItalianAccentAndCapsTests.cs | 161 +++++++++++++++++ .../OcrFixPortugueseAccentAndCapsTests.cs | 162 +++++++++++++++++ .../OcrFixSpanishAccentAndCapsTests.cs | 166 ++++++++++++++++++ 6 files changed, 591 insertions(+) create mode 100644 tests/UI/Features/Ocr/FixEngine/OcrFixItalianAccentAndCapsTests.cs create mode 100644 tests/UI/Features/Ocr/FixEngine/OcrFixPortugueseAccentAndCapsTests.cs create mode 100644 tests/UI/Features/Ocr/FixEngine/OcrFixSpanishAccentAndCapsTests.cs diff --git a/Dictionaries/ita_OCRFixReplaceList.xml b/Dictionaries/ita_OCRFixReplaceList.xml index 6db756845ee..29fc2618e8b 100644 --- a/Dictionaries/ita_OCRFixReplaceList.xml +++ b/Dictionaries/ita_OCRFixReplaceList.xml @@ -83,5 +83,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Dictionaries/por_OCRFixReplaceList.xml b/Dictionaries/por_OCRFixReplaceList.xml index e7659bcebee..58e10f98f68 100644 --- a/Dictionaries/por_OCRFixReplaceList.xml +++ b/Dictionaries/por_OCRFixReplaceList.xml @@ -549,5 +549,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Dictionaries/spa_OCRFixReplaceList.xml b/Dictionaries/spa_OCRFixReplaceList.xml index dd5f7532598..f0ead283e51 100644 --- a/Dictionaries/spa_OCRFixReplaceList.xml +++ b/Dictionaries/spa_OCRFixReplaceList.xml @@ -978,5 +978,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/UI/Features/Ocr/FixEngine/OcrFixItalianAccentAndCapsTests.cs b/tests/UI/Features/Ocr/FixEngine/OcrFixItalianAccentAndCapsTests.cs new file mode 100644 index 00000000000..c0f05205ee1 --- /dev/null +++ b/tests/UI/Features/Ocr/FixEngine/OcrFixItalianAccentAndCapsTests.cs @@ -0,0 +1,161 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Ocr.FixEngine; +using Nikse.SubtitleEdit.Features.SpellCheck; +using System; +using System.Collections.Generic; +using System.IO; +using Nikse.SubtitleEdit.UiLogic.Ocr.FixEngine; +using Nikse.SubtitleEdit.UiLogic.SpellCheck; + +namespace UITests.Features.Ocr.FixEngine; + +// Gated Italian rules: lost accents on the final vowel (citta -> città, perche -> perché, +// caffe -> caffè - both accents are tried, the dictionary picks the right one) and l/I +// confusion in all-caps captions. A rule is only applied when the misread form is NOT in the +// dictionary and the fixed form IS; lowercase-only classes keep proper names untouched. +// Runs against the shipped Dictionaries/ita_OCRFixReplaceList.xml, not a copy of its rules. +public class OcrFixItalianAccentAndCapsTests : IDisposable +{ + private readonly Func _originalSpellCheckDictionariesFolder; + private readonly string _tempDictionariesFolder; + private readonly bool _originalUseHardcodedRules; + + public OcrFixItalianAccentAndCapsTests() + { + _originalSpellCheckDictionariesFolder = SpellCheckConfig.DictionariesFolder; + + // The hardcoded rules fix some all-caps l/I words on their own; turn them off so these + // tests prove the XML rules, not the hardcoded fallback. + _originalUseHardcodedRules = Configuration.Settings.Tools.OcrFixUseHardcodedRules; + Configuration.Settings.Tools.OcrFixUseHardcodedRules = false; + + _tempDictionariesFolder = Path.Combine( + Path.GetTempPath(), + "SeOcrFixItalianAccentTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDictionariesFolder); + + File.Copy( + Path.Combine(FindRepoRoot(), "Dictionaries", "ita_OCRFixReplaceList.xml"), + Path.Combine(_tempDictionariesFolder, "ita_OCRFixReplaceList.xml")); + + SpellCheckConfig.DictionariesFolder = () => _tempDictionariesFolder; + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Dictionaries"))) + { + dir = dir.Parent; + } + + return dir?.FullName ?? throw new DirectoryNotFoundException("Could not find repo root"); + } + + [Theory] + [InlineData("La citta e bella.", "La città e bella.")] + [InlineData("Non so perche.", "Non so perché.")] + [InlineData("Si fa cosi.", "Si fa così.")] + [InlineData("Ne voglio di piu.", "Ne voglio di più.")] + [InlineData("Prendo un caffe.", "Prendo un caffè.")] + [InlineData("Va bene, pero non ora.", "Va bene, però non ora.")] + public void FixOcrErrors_LostFinalVowelAccent_IsRestored(string text, string expected) + { + var engine = CreateEngine(); + + var result = engine.FixOcrErrors(0, text, doTryToGuessUnknownWords: false); + + Assert.Equal(expected, result.GetText()); + } + + [Theory] + [InlineData("[MUSlCA]", "[MUSICA]")] + [InlineData("FlNE", "FINE")] + public void FixOcrErrors_MisreadLAndI_IsFixed(string text, string expected) + { + var engine = CreateEngine(); + + var result = engine.FixOcrErrors(0, text, doTryToGuessUnknownWords: false); + + Assert.Equal(expected, result.GetText()); + } + + [Theory] + [InlineData("Vieni anche tu Zzyzx.")] // correct -e word is in the dictionary + [InlineData("Maria canta Zzyzx.")] // capitalized name is never accented + public void FixOcrErrors_CorrectWordsAndNames_AreLeftAlone(string text) + { + // "Zzyzx" keeps the line from being spelled OK, so every gated rule really runs. + var engine = CreateEngine(); + + var result = engine.FixOcrErrors(0, text, doTryToGuessUnknownWords: false); + + Assert.Equal(text, result.GetText()); + } + + [Fact] + public void FixOcrErrors_WithoutADictionary_AccentRulesDoNothing() + { + var engine = CreateEngine(new EmptySpellChecker()); + + var result = engine.FixOcrErrors(0, "La citta Zzyzx.", doTryToGuessUnknownWords: false); + + Assert.Equal("La citta Zzyzx.", result.GetText()); + } + + private static IOcrFixEngine CreateEngine(ISpellChecker? spellChecker = null) + { + IOcrFixEngine engine = new OcrFixEngine(spellChecker ?? new FakeItalianSpellChecker()); + engine.Initialize(new Subtitle(), "ita", new SpellCheckDictionaryDisplay()); + return engine; + } + + private sealed class FakeItalianSpellChecker : ISpellChecker + { + private static readonly HashSet Words = new(StringComparer.Ordinal) + { + "la", "città", "e", "bella", "non", "so", "perché", "si", "fa", "così", + "ne", "voglio", "di", "più", "prendo", "un", "caffè", "va", "bene", "però", + "ora", "musica", "fine", "vieni", "anche", "tu", "canta", + }; + + public bool Initialize(string dictionaryFile, string twoLetterLanguageCode) => true; + + public bool IsWordCorrect(string word) + { + if (string.IsNullOrWhiteSpace(word)) + { + return true; + } + + // Like Hunspell, accept capitalized/all-caps forms of a lowercase dictionary word. + return Words.Contains(word) || Words.Contains(word.ToLowerInvariant()); + } + + public List GetSuggestions(string word) => new(); + } + + // Stands in for "no Hunspell dictionary installed for the language". + private sealed class EmptySpellChecker : ISpellChecker + { + public bool Initialize(string dictionaryFile, string twoLetterLanguageCode) => false; + + public bool IsWordCorrect(string word) => string.IsNullOrWhiteSpace(word); + + public List GetSuggestions(string word) => new(); + } + + public void Dispose() + { + SpellCheckConfig.DictionariesFolder = _originalSpellCheckDictionariesFolder; + Configuration.Settings.Tools.OcrFixUseHardcodedRules = _originalUseHardcodedRules; + try + { + Directory.Delete(_tempDictionariesFolder, recursive: true); + } + catch + { + // Best-effort cleanup. + } + } +} diff --git a/tests/UI/Features/Ocr/FixEngine/OcrFixPortugueseAccentAndCapsTests.cs b/tests/UI/Features/Ocr/FixEngine/OcrFixPortugueseAccentAndCapsTests.cs new file mode 100644 index 00000000000..6d7701f962b --- /dev/null +++ b/tests/UI/Features/Ocr/FixEngine/OcrFixPortugueseAccentAndCapsTests.cs @@ -0,0 +1,162 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Ocr.FixEngine; +using Nikse.SubtitleEdit.Features.SpellCheck; +using System; +using System.Collections.Generic; +using System.IO; +using Nikse.SubtitleEdit.UiLogic.Ocr.FixEngine; +using Nikse.SubtitleEdit.UiLogic.SpellCheck; + +namespace UITests.Features.Ocr.FixEngine; + +// Gated Portuguese rules: lost tildes/accents on the final syllable (nao -> não, +// coracao -> coração, voce -> você) and l/I confusion in all-caps captions. A rule is only +// applied when the misread form is NOT in the dictionary and the fixed form IS; lowercase-only +// classes keep proper names (Joao as a name) untouched. +// Runs against the shipped Dictionaries/por_OCRFixReplaceList.xml, not a copy of its rules. +public class OcrFixPortugueseAccentAndCapsTests : IDisposable +{ + private readonly Func _originalSpellCheckDictionariesFolder; + private readonly string _tempDictionariesFolder; + private readonly bool _originalUseHardcodedRules; + + public OcrFixPortugueseAccentAndCapsTests() + { + _originalSpellCheckDictionariesFolder = SpellCheckConfig.DictionariesFolder; + + // The hardcoded rules fix some all-caps l/I words on their own; turn them off so these + // tests prove the XML rules, not the hardcoded fallback. + _originalUseHardcodedRules = Configuration.Settings.Tools.OcrFixUseHardcodedRules; + Configuration.Settings.Tools.OcrFixUseHardcodedRules = false; + + _tempDictionariesFolder = Path.Combine( + Path.GetTempPath(), + "SeOcrFixPortugueseAccentTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDictionariesFolder); + + File.Copy( + Path.Combine(FindRepoRoot(), "Dictionaries", "por_OCRFixReplaceList.xml"), + Path.Combine(_tempDictionariesFolder, "por_OCRFixReplaceList.xml")); + + SpellCheckConfig.DictionariesFolder = () => _tempDictionariesFolder; + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Dictionaries"))) + { + dir = dir.Parent; + } + + return dir?.FullName ?? throw new DirectoryNotFoundException("Could not find repo root"); + } + + [Theory] + [InlineData("Eles sao irmaos.", "Eles são irmãos.")] + [InlineData("Os avioes chegam.", "Os aviões chegam.")] + [InlineData("Isso e necessario.", "Isso e necessário.")] + [InlineData("Sabe que voce fala ingles.", "Sabe que você fala inglês.")] + [InlineData("Quero um cafe.", "Quero um café.")] + public void FixOcrErrors_LostFinalSyllableAccent_IsRestored(string text, string expected) + { + var engine = CreateEngine(); + + var result = engine.FixOcrErrors(0, text, doTryToGuessUnknownWords: false); + + Assert.Equal(expected, result.GetText()); + } + + [Theory] + [InlineData("SlM", "SIM")] + [InlineData("[GRlTOS]", "[GRITOS]")] + public void FixOcrErrors_MisreadLAndI_IsFixed(string text, string expected) + { + var engine = CreateEngine(); + + var result = engine.FixOcrErrors(0, text, doTryToGuessUnknownWords: false); + + Assert.Equal(expected, result.GetText()); + } + + [Theory] + [InlineData("Joao chega logo Zzyzx.")] // capitalized name is never accented + [InlineData("O cachorro late Zzyzx.")] // correct -e word is in the dictionary + public void FixOcrErrors_CorrectWordsAndNames_AreLeftAlone(string text) + { + // "Zzyzx" keeps the line from being spelled OK, so every gated rule really runs. + var engine = CreateEngine(); + + var result = engine.FixOcrErrors(0, text, doTryToGuessUnknownWords: false); + + Assert.Equal(text, result.GetText()); + } + + [Fact] + public void FixOcrErrors_WithoutADictionary_AccentRulesDoNothing() + { + var engine = CreateEngine(new EmptySpellChecker()); + + // "voce"/"ingles" have no ungated rules ("nao" would be fixed even without a dictionary, + // by the ungated \b(n|N)ao\b regex), so this proves the gate really needs the dictionary. + var result = engine.FixOcrErrors(0, "Sabe que voce fala ingles Zzyzx.", doTryToGuessUnknownWords: false); + + Assert.Equal("Sabe que voce fala ingles Zzyzx.", result.GetText()); + } + + private static IOcrFixEngine CreateEngine(ISpellChecker? spellChecker = null) + { + IOcrFixEngine engine = new OcrFixEngine(spellChecker ?? new FakePortugueseSpellChecker()); + engine.Initialize(new Subtitle(), "por", new SpellCheckDictionaryDisplay()); + return engine; + } + + private sealed class FakePortugueseSpellChecker : ISpellChecker + { + private static readonly HashSet Words = new(StringComparer.Ordinal) + { + "eles", "são", "irmãos", "os", "aviões", "chegam", "isso", "e", "necessário", + "sabe", "que", "você", "fala", "inglês", "quero", "um", "café", "sim", + "gritos", "chega", "logo", "o", "cachorro", "late", + }; + + public bool Initialize(string dictionaryFile, string twoLetterLanguageCode) => true; + + public bool IsWordCorrect(string word) + { + if (string.IsNullOrWhiteSpace(word)) + { + return true; + } + + // Like Hunspell, accept capitalized/all-caps forms of a lowercase dictionary word. + return Words.Contains(word) || Words.Contains(word.ToLowerInvariant()); + } + + public List GetSuggestions(string word) => new(); + } + + // Stands in for "no Hunspell dictionary installed for the language". + private sealed class EmptySpellChecker : ISpellChecker + { + public bool Initialize(string dictionaryFile, string twoLetterLanguageCode) => false; + + public bool IsWordCorrect(string word) => string.IsNullOrWhiteSpace(word); + + public List GetSuggestions(string word) => new(); + } + + public void Dispose() + { + SpellCheckConfig.DictionariesFolder = _originalSpellCheckDictionariesFolder; + Configuration.Settings.Tools.OcrFixUseHardcodedRules = _originalUseHardcodedRules; + try + { + Directory.Delete(_tempDictionariesFolder, recursive: true); + } + catch + { + // Best-effort cleanup. + } + } +} diff --git a/tests/UI/Features/Ocr/FixEngine/OcrFixSpanishAccentAndCapsTests.cs b/tests/UI/Features/Ocr/FixEngine/OcrFixSpanishAccentAndCapsTests.cs new file mode 100644 index 00000000000..6f6e250a99d --- /dev/null +++ b/tests/UI/Features/Ocr/FixEngine/OcrFixSpanishAccentAndCapsTests.cs @@ -0,0 +1,166 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Ocr.FixEngine; +using Nikse.SubtitleEdit.Features.SpellCheck; +using System; +using System.Collections.Generic; +using System.IO; +using Nikse.SubtitleEdit.UiLogic.Ocr.FixEngine; +using Nikse.SubtitleEdit.UiLogic.SpellCheck; + +namespace UITests.Features.Ocr.FixEngine; + +// The Spanish list only enumerated hand-picked accent fixes ("dia" -> "día") as WholeWords, so +// every word not on the list slipped through. The gated rules generalize the pattern: a final +// syllable accent (cancion -> canción) or an l/I confusion is only applied when the misread form +// is NOT in the dictionary and the fixed form IS. Lowercase-only classes keep proper names +// (Mia, Sofia) untouched. +// Runs against the shipped Dictionaries/spa_OCRFixReplaceList.xml, not a copy of its rules. +public class OcrFixSpanishAccentAndCapsTests : IDisposable +{ + private readonly Func _originalSpellCheckDictionariesFolder; + private readonly string _tempDictionariesFolder; + private readonly bool _originalUseHardcodedRules; + + public OcrFixSpanishAccentAndCapsTests() + { + _originalSpellCheckDictionariesFolder = SpellCheckConfig.DictionariesFolder; + + // The hardcoded rules fix some all-caps l/I words on their own; turn them off so these + // tests prove the XML rules, not the hardcoded fallback. + _originalUseHardcodedRules = Configuration.Settings.Tools.OcrFixUseHardcodedRules; + Configuration.Settings.Tools.OcrFixUseHardcodedRules = false; + + _tempDictionariesFolder = Path.Combine( + Path.GetTempPath(), + "SeOcrFixSpanishAccentTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDictionariesFolder); + + File.Copy( + Path.Combine(FindRepoRoot(), "Dictionaries", "spa_OCRFixReplaceList.xml"), + Path.Combine(_tempDictionariesFolder, "spa_OCRFixReplaceList.xml")); + + SpellCheckConfig.DictionariesFolder = () => _tempDictionariesFolder; + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Dictionaries"))) + { + dir = dir.Parent; + } + + return dir?.FullName ?? throw new DirectoryNotFoundException("Could not find repo root"); + } + + [Theory] + [InlineData("Un monton de cosas.", "Un montón de cosas.")] + [InlineData("El capitan llega.", "El capitán llega.")] + [InlineData("Hay un jardin.", "Hay un jardín.")] + [InlineData("Vendra quizas.", "Vendra quizás.")] + [InlineData("Ella vivio sola.", "Ella vivió sola.")] + [InlineData("Toma un cafe.", "Toma un café.")] + public void FixOcrErrors_LostFinalSyllableAccent_IsRestored(string text, string expected) + { + var engine = CreateEngine(); + + var result = engine.FixOcrErrors(0, text, doTryToGuessUnknownWords: false); + + Assert.Equal(expected, result.GetText()); + } + + [Theory] + [InlineData("SlN SALlDA", "SIN SALIDA")] + [InlineData("[GRlTOS]", "[GRITOS]")] + [InlineData("Vamos a Iavar la ropa.", "Vamos a lavar la ropa.")] + public void FixOcrErrors_MisreadLAndI_IsFixed(string text, string expected) + { + var engine = CreateEngine(); + + var result = engine.FixOcrErrors(0, text, doTryToGuessUnknownWords: false); + + Assert.Equal(expected, result.GetText()); + } + + [Theory] + [InlineData("Mia canta bien Zzyzx.")] // capitalized name, even though "mía" is a word + [InlineData("La familia llega Zzyzx.")] // correct -ia word is in the dictionary + [InlineData("Mi amigo llega Zzyzx.")] // correct -o word is in the dictionary + public void FixOcrErrors_CorrectWordsAndNames_AreLeftAlone(string text) + { + // "Zzyzx" keeps the line from being spelled OK, so every gated rule really runs. + var engine = CreateEngine(); + + var result = engine.FixOcrErrors(0, text, doTryToGuessUnknownWords: false); + + Assert.Equal(text, result.GetText()); + } + + [Fact] + public void FixOcrErrors_WithoutADictionary_AccentRulesDoNothing() + { + var engine = CreateEngine(new EmptySpellChecker()); + + // "capitan" has no ungated rule ("cancion" would be fixed even without a dictionary, + // by the ungated ([sc]i)o(n) regex), so this proves the gate really needs the dictionary. + var result = engine.FixOcrErrors(0, "El capitan llega Zzyzx.", doTryToGuessUnknownWords: false); + + Assert.Equal("El capitan llega Zzyzx.", result.GetText()); + } + + private static IOcrFixEngine CreateEngine(ISpellChecker? spellChecker = null) + { + IOcrFixEngine engine = new OcrFixEngine(spellChecker ?? new FakeSpanishSpellChecker()); + engine.Initialize(new Subtitle(), "spa", new SpellCheckDictionaryDisplay()); + return engine; + } + + private sealed class FakeSpanishSpellChecker : ISpellChecker + { + private static readonly HashSet Words = new(StringComparer.Ordinal) + { + "un", "montón", "de", "cosas", "el", "capitán", "llega", "hay", "jardín", + "quizás", "ella", "vivió", "sola", "toma", "café", "sin", "salida", "gritos", + "vamos", "a", "lavar", "la", "ropa", "mía", "canta", "bien", "familia", "mi", "amigo", + }; + + public bool Initialize(string dictionaryFile, string twoLetterLanguageCode) => true; + + public bool IsWordCorrect(string word) + { + if (string.IsNullOrWhiteSpace(word)) + { + return true; + } + + // Like Hunspell, accept capitalized/all-caps forms of a lowercase dictionary word. + return Words.Contains(word) || Words.Contains(word.ToLowerInvariant()); + } + + public List GetSuggestions(string word) => new(); + } + + // Stands in for "no Hunspell dictionary installed for the language". + private sealed class EmptySpellChecker : ISpellChecker + { + public bool Initialize(string dictionaryFile, string twoLetterLanguageCode) => false; + + public bool IsWordCorrect(string word) => string.IsNullOrWhiteSpace(word); + + public List GetSuggestions(string word) => new(); + } + + public void Dispose() + { + SpellCheckConfig.DictionariesFolder = _originalSpellCheckDictionariesFolder; + Configuration.Settings.Tools.OcrFixUseHardcodedRules = _originalUseHardcodedRules; + try + { + Directory.Delete(_tempDictionariesFolder, recursive: true); + } + catch + { + // Best-effort cleanup. + } + } +} From 669b714bd46aa26116bdb94f431992738fc886b0 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 09:37:34 +0200 Subject: [PATCH 043/380] Add Cyrillic homoglyph rules (rus/mkd) and preposition splits (spa/por/ita) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rus and mkd had no regex rules at all. OCR of Cyrillic text often emits Latin homoglyphs (a/c/e/o/p/x/y, B/C/E/H/K/M/O/P/T/X/Y, mkd also j/s) or digits (3/6/0) inside Cyrillic words. A Latin letter with a Cyrillic neighbor is definitely wrong, so the rules are ungated and work with no dictionary: "Bот нaш дом" -> "Вот наш дом", "о6ед" -> "обед", "jас" -> "јас". Fully Latin words (IKEA, YouTube, Windows) have no Cyrillic neighbors and are never touched; a digit following a digit (16кг) is left alone. spa/por/ita also get the merged preposition/article split family that fra/dan already have, dictionary-gated: "deMadrid" -> "de Madrid", "daRoma" -> "da Roma", "deLisboa" -> "de Lisboa" - only when the capitalized word is in the dictionary. Tests run against the shipped XMLs; homoglyph tests use an empty spell checker to prove dictionary independence. Co-Authored-By: Claude Fable 5 --- Dictionaries/ita_OCRFixReplaceList.xml | 19 +++ Dictionaries/mkd_OCRFixReplaceList.xml | 34 +++++- Dictionaries/por_OCRFixReplaceList.xml | 18 +++ Dictionaries/rus_OCRFixReplaceList.xml | 30 ++++- Dictionaries/spa_OCRFixReplaceList.xml | 18 +++ .../OcrFixItalianAccentAndCapsTests.cs | 12 ++ .../OcrFixMacedonianHomoglyphTests.cs | 101 ++++++++++++++++ .../OcrFixPortugueseAccentAndCapsTests.cs | 12 ++ .../FixEngine/OcrFixRussianHomoglyphTests.cs | 108 ++++++++++++++++++ .../OcrFixSpanishAccentAndCapsTests.cs | 13 +++ 10 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 tests/UI/Features/Ocr/FixEngine/OcrFixMacedonianHomoglyphTests.cs create mode 100644 tests/UI/Features/Ocr/FixEngine/OcrFixRussianHomoglyphTests.cs diff --git a/Dictionaries/ita_OCRFixReplaceList.xml b/Dictionaries/ita_OCRFixReplaceList.xml index 29fc2618e8b..4c7707c6aec 100644 --- a/Dictionaries/ita_OCRFixReplaceList.xml +++ b/Dictionaries/ita_OCRFixReplaceList.xml @@ -112,5 +112,24 @@ + + + + + + + + + + + + + + + + + + + \ 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/por_OCRFixReplaceList.xml b/Dictionaries/por_OCRFixReplaceList.xml index 58e10f98f68..e270ba60857 100644 --- a/Dictionaries/por_OCRFixReplaceList.xml +++ b/Dictionaries/por_OCRFixReplaceList.xml @@ -588,5 +588,23 @@ + + + + + + + + + + + + + + + + + + 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 f0ead283e51..b63440d74da 100644 --- a/Dictionaries/spa_OCRFixReplaceList.xml +++ b/Dictionaries/spa_OCRFixReplaceList.xml @@ -1012,5 +1012,23 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/UI/Features/Ocr/FixEngine/OcrFixItalianAccentAndCapsTests.cs b/tests/UI/Features/Ocr/FixEngine/OcrFixItalianAccentAndCapsTests.cs index c0f05205ee1..d3782613cc0 100644 --- a/tests/UI/Features/Ocr/FixEngine/OcrFixItalianAccentAndCapsTests.cs +++ b/tests/UI/Features/Ocr/FixEngine/OcrFixItalianAccentAndCapsTests.cs @@ -80,6 +80,17 @@ public void FixOcrErrors_MisreadLAndI_IsFixed(string text, string expected) Assert.Equal(expected, result.GetText()); } + [Theory] + [InlineData("Arrivo daRoma.", "Arrivo da Roma.")] + public void FixOcrErrors_MissingSpaceAfterPreposition_IsSplitWhenTheNameIsKnown(string text, string expected) + { + var engine = CreateEngine(); + + var result = engine.FixOcrErrors(0, text, doTryToGuessUnknownWords: false); + + Assert.Equal(expected, result.GetText()); + } + [Theory] [InlineData("Vieni anche tu Zzyzx.")] // correct -e word is in the dictionary [InlineData("Maria canta Zzyzx.")] // capitalized name is never accented @@ -114,6 +125,7 @@ private sealed class FakeItalianSpellChecker : ISpellChecker { private static readonly HashSet Words = new(StringComparer.Ordinal) { + "arrivo", "roma", "la", "città", "e", "bella", "non", "so", "perché", "si", "fa", "così", "ne", "voglio", "di", "più", "prendo", "un", "caffè", "va", "bene", "però", "ora", "musica", "fine", "vieni", "anche", "tu", "canta", diff --git a/tests/UI/Features/Ocr/FixEngine/OcrFixMacedonianHomoglyphTests.cs b/tests/UI/Features/Ocr/FixEngine/OcrFixMacedonianHomoglyphTests.cs new file mode 100644 index 00000000000..3e3b762f2cc --- /dev/null +++ b/tests/UI/Features/Ocr/FixEngine/OcrFixMacedonianHomoglyphTests.cs @@ -0,0 +1,101 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Ocr.FixEngine; +using Nikse.SubtitleEdit.Features.SpellCheck; +using System; +using System.Collections.Generic; +using System.IO; +using Nikse.SubtitleEdit.UiLogic.Ocr.FixEngine; +using Nikse.SubtitleEdit.UiLogic.SpellCheck; + +namespace UITests.Features.Ocr.FixEngine; + +// Macedonian version of the Cyrillic homoglyph rules, including the letters Latin script +// shares glyphs with only in Macedonian: j (ј) and s (ѕ). Ungated - a Latin letter with a +// Cyrillic neighbor is definitely wrong - so no dictionary is needed. +// Runs against the shipped Dictionaries/mkd_OCRFixReplaceList.xml, not a copy of its rules. +public class OcrFixMacedonianHomoglyphTests : IDisposable +{ + private readonly Func _originalSpellCheckDictionariesFolder; + private readonly string _tempDictionariesFolder; + + public OcrFixMacedonianHomoglyphTests() + { + _originalSpellCheckDictionariesFolder = SpellCheckConfig.DictionariesFolder; + + _tempDictionariesFolder = Path.Combine( + Path.GetTempPath(), + "SeOcrFixMacedonianHomoglyphTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDictionariesFolder); + + File.Copy( + Path.Combine(FindRepoRoot(), "Dictionaries", "mkd_OCRFixReplaceList.xml"), + Path.Combine(_tempDictionariesFolder, "mkd_OCRFixReplaceList.xml")); + + SpellCheckConfig.DictionariesFolder = () => _tempDictionariesFolder; + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Dictionaries"))) + { + dir = dir.Parent; + } + + return dir?.FullName ?? throw new DirectoryNotFoundException("Could not find repo root"); + } + + [Theory] + [InlineData("Каj си?", "Кај си?")] // Latin j after Cyrillic + [InlineData("Тоа е jасно.", "Тоа е јасно.")] // Latin j before Cyrillic + [InlineData("Bо ред е.", "Во ред е.")] // Latin B + [InlineData("Гледам sвезда.", "Гледам ѕвезда.")] // Latin s for dze + public void FixOcrErrors_LatinHomoglyphInCyrillicWord_IsFixedWithoutADictionary(string text, string expected) + { + var engine = CreateEngine(); + + var result = engine.FixOcrErrors(0, text, doTryToGuessUnknownWords: false); + + Assert.Equal(expected, result.GetText()); + } + + [Fact] + public void FixOcrErrors_FullyLatinWord_IsLeftAlone() + { + var engine = CreateEngine(); + + var result = engine.FixOcrErrors(0, "Ова е Windows.", doTryToGuessUnknownWords: false); + + Assert.Equal("Ова е Windows.", result.GetText()); + } + + private static IOcrFixEngine CreateEngine() + { + // No dictionary at all: the homoglyph rules must work stand-alone. + IOcrFixEngine engine = new OcrFixEngine(new EmptySpellChecker()); + engine.Initialize(new Subtitle(), "mkd", new SpellCheckDictionaryDisplay()); + return engine; + } + + private sealed class EmptySpellChecker : ISpellChecker + { + public bool Initialize(string dictionaryFile, string twoLetterLanguageCode) => false; + + public bool IsWordCorrect(string word) => string.IsNullOrWhiteSpace(word); + + public List GetSuggestions(string word) => new(); + } + + public void Dispose() + { + SpellCheckConfig.DictionariesFolder = _originalSpellCheckDictionariesFolder; + try + { + Directory.Delete(_tempDictionariesFolder, recursive: true); + } + catch + { + // Best-effort cleanup. + } + } +} diff --git a/tests/UI/Features/Ocr/FixEngine/OcrFixPortugueseAccentAndCapsTests.cs b/tests/UI/Features/Ocr/FixEngine/OcrFixPortugueseAccentAndCapsTests.cs index 6d7701f962b..9a669321380 100644 --- a/tests/UI/Features/Ocr/FixEngine/OcrFixPortugueseAccentAndCapsTests.cs +++ b/tests/UI/Features/Ocr/FixEngine/OcrFixPortugueseAccentAndCapsTests.cs @@ -79,6 +79,17 @@ public void FixOcrErrors_MisreadLAndI_IsFixed(string text, string expected) Assert.Equal(expected, result.GetText()); } + [Theory] + [InlineData("Venho deLisboa.", "Venho de Lisboa.")] + public void FixOcrErrors_MissingSpaceAfterPreposition_IsSplitWhenTheNameIsKnown(string text, string expected) + { + var engine = CreateEngine(); + + var result = engine.FixOcrErrors(0, text, doTryToGuessUnknownWords: false); + + Assert.Equal(expected, result.GetText()); + } + [Theory] [InlineData("Joao chega logo Zzyzx.")] // capitalized name is never accented [InlineData("O cachorro late Zzyzx.")] // correct -e word is in the dictionary @@ -115,6 +126,7 @@ private sealed class FakePortugueseSpellChecker : ISpellChecker { private static readonly HashSet Words = new(StringComparer.Ordinal) { + "venho", "lisboa", "eles", "são", "irmãos", "os", "aviões", "chegam", "isso", "e", "necessário", "sabe", "que", "você", "fala", "inglês", "quero", "um", "café", "sim", "gritos", "chega", "logo", "o", "cachorro", "late", diff --git a/tests/UI/Features/Ocr/FixEngine/OcrFixRussianHomoglyphTests.cs b/tests/UI/Features/Ocr/FixEngine/OcrFixRussianHomoglyphTests.cs new file mode 100644 index 00000000000..ca0b8b09e4f --- /dev/null +++ b/tests/UI/Features/Ocr/FixEngine/OcrFixRussianHomoglyphTests.cs @@ -0,0 +1,108 @@ +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Ocr.FixEngine; +using Nikse.SubtitleEdit.Features.SpellCheck; +using System; +using System.Collections.Generic; +using System.IO; +using Nikse.SubtitleEdit.UiLogic.Ocr.FixEngine; +using Nikse.SubtitleEdit.UiLogic.SpellCheck; + +namespace UITests.Features.Ocr.FixEngine; + +// OCR of Cyrillic text often emits Latin homoglyphs (a/c/e/o/p/x/y, B/H/K...) or digits (3/6/0) +// inside Cyrillic words. A Latin letter with a Cyrillic neighbor is definitely wrong, so the +// rules are ungated and work without any dictionary; fully Latin words (IKEA, YouTube) have no +// Cyrillic neighbors and are never touched. +// Runs against the shipped Dictionaries/rus_OCRFixReplaceList.xml, not a copy of its rules. +public class OcrFixRussianHomoglyphTests : IDisposable +{ + private readonly Func _originalSpellCheckDictionariesFolder; + private readonly string _tempDictionariesFolder; + + public OcrFixRussianHomoglyphTests() + { + _originalSpellCheckDictionariesFolder = SpellCheckConfig.DictionariesFolder; + + _tempDictionariesFolder = Path.Combine( + Path.GetTempPath(), + "SeOcrFixRussianHomoglyphTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDictionariesFolder); + + File.Copy( + Path.Combine(FindRepoRoot(), "Dictionaries", "rus_OCRFixReplaceList.xml"), + Path.Combine(_tempDictionariesFolder, "rus_OCRFixReplaceList.xml")); + + SpellCheckConfig.DictionariesFolder = () => _tempDictionariesFolder; + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Dictionaries"))) + { + dir = dir.Parent; + } + + return dir?.FullName ?? throw new DirectoryNotFoundException("Could not find repo root"); + } + + [Theory] + [InlineData("Bот нaш дом.", "Вот наш дом.")] // Latin B, Latin a + [InlineData("Мы xотим есть.", "Мы хотим есть.")] // Latin x + [InlineData("Yтро доброе.", "Утро доброе.")] // Latin Y at word start + [InlineData("Пошли на о6ед.", "Пошли на обед.")] // digit 6 + [InlineData("Это отка3.", "Это отказ.")] // digit 3 at word end + [InlineData("Ему с0рок лет.", "Ему сорок лет.")] // digit 0 + public void FixOcrErrors_LatinHomoglyphInCyrillicWord_IsFixedWithoutADictionary(string text, string expected) + { + var engine = CreateEngine(); + + var result = engine.FixOcrErrors(0, text, doTryToGuessUnknownWords: false); + + Assert.Equal(expected, result.GetText()); + } + + [Theory] + [InlineData("Это IKEA.")] // fully Latin word has no Cyrillic neighbors + [InlineData("Смотрю YouTube.")] + [InlineData("У меня 6 книг.")] // free-standing digit + [InlineData("Вес 16кг.")] // digit after digit is left alone + public void FixOcrErrors_LatinWordsAndRealDigits_AreLeftAlone(string text) + { + var engine = CreateEngine(); + + var result = engine.FixOcrErrors(0, text, doTryToGuessUnknownWords: false); + + Assert.Equal(text, result.GetText()); + } + + private static IOcrFixEngine CreateEngine() + { + // No dictionary at all: the homoglyph rules must work stand-alone. + IOcrFixEngine engine = new OcrFixEngine(new EmptySpellChecker()); + engine.Initialize(new Subtitle(), "rus", new SpellCheckDictionaryDisplay()); + return engine; + } + + private sealed class EmptySpellChecker : ISpellChecker + { + public bool Initialize(string dictionaryFile, string twoLetterLanguageCode) => false; + + public bool IsWordCorrect(string word) => string.IsNullOrWhiteSpace(word); + + public List GetSuggestions(string word) => new(); + } + + public void Dispose() + { + SpellCheckConfig.DictionariesFolder = _originalSpellCheckDictionariesFolder; + try + { + Directory.Delete(_tempDictionariesFolder, recursive: true); + } + catch + { + // Best-effort cleanup. + } + } +} diff --git a/tests/UI/Features/Ocr/FixEngine/OcrFixSpanishAccentAndCapsTests.cs b/tests/UI/Features/Ocr/FixEngine/OcrFixSpanishAccentAndCapsTests.cs index 6f6e250a99d..8dca74b1557 100644 --- a/tests/UI/Features/Ocr/FixEngine/OcrFixSpanishAccentAndCapsTests.cs +++ b/tests/UI/Features/Ocr/FixEngine/OcrFixSpanishAccentAndCapsTests.cs @@ -82,6 +82,18 @@ public void FixOcrErrors_MisreadLAndI_IsFixed(string text, string expected) Assert.Equal(expected, result.GetText()); } + [Theory] + [InlineData("Vengo deMadrid.", "Vengo de Madrid.")] + [InlineData("Brindo porEspana Zzyzx.", "Brindo porEspana Zzyzx.")] // merged word not in the dictionary stays + public void FixOcrErrors_MissingSpaceAfterPreposition_IsSplitWhenTheNameIsKnown(string text, string expected) + { + var engine = CreateEngine(); + + var result = engine.FixOcrErrors(0, text, doTryToGuessUnknownWords: false); + + Assert.Equal(expected, result.GetText()); + } + [Theory] [InlineData("Mia canta bien Zzyzx.")] // capitalized name, even though "mía" is a word [InlineData("La familia llega Zzyzx.")] // correct -ia word is in the dictionary @@ -119,6 +131,7 @@ private sealed class FakeSpanishSpellChecker : ISpellChecker { private static readonly HashSet Words = new(StringComparer.Ordinal) { + "vengo", "madrid", "brindo", "un", "montón", "de", "cosas", "el", "capitán", "llega", "hay", "jardín", "quizás", "ella", "vivió", "sola", "toma", "café", "sin", "salida", "gritos", "vamos", "a", "lavar", "la", "ropa", "mía", "canta", "bien", "familia", "mi", "amigo", From ea0ea382f107a37e20387abca0a5f5e25880dec2 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 09:42:44 +0200 Subject: [PATCH 044/380] Prepare v5.2.0-beta23 Bump the version in Se.cs (and the generated English.json) and add the change-log section for the 14 pull requests merged since the beta22 tag. Co-Authored-By: Claude Opus 5 --- change-log.txt | 27 +++++++++++++++++++++++++++ src/ui/Assets/Languages/English.json | 2 +- src/ui/Logic/Config/Se.cs | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/change-log.txt b/change-log.txt index 0dbfcd1f757..2af68dec855 100644 --- a/change-log.txt +++ b/change-log.txt @@ -3,6 +3,33 @@ Subtitle Edit Changelog ----------------------------------------------------------------------------------------------------- +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 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 diff --git a/src/ui/Assets/Languages/English.json b/src/ui/Assets/Languages/English.json index 16a368eba22..9e479b10be2 100644 --- a/src/ui/Assets/Languages/English.json +++ b/src/ui/Assets/Languages/English.json @@ -1,6 +1,6 @@ { "title": "Subtitle Edit", - "version": "v5.2.0-beta22", + "version": "v5.2.0-beta23", "translatedBy": "", "cultureName": "en-US", "general": { diff --git a/src/ui/Logic/Config/Se.cs b/src/ui/Logic/Config/Se.cs index b7c0466155b..405fbc8de95 100644 --- a/src/ui/Logic/Config/Se.cs +++ b/src/ui/Logic/Config/Se.cs @@ -19,7 +19,7 @@ public class Se internal const int CurrentMacOsFontMigrationVersion = 1; internal const int CurrentShortcutsMigrationVersion = 2; - public static string Version { get; set; } = "v5.2.0-beta22"; + public static string Version { get; set; } = "v5.2.0-beta23"; public SeGeneral General { get; set; } = new(); public List Shortcuts { get; set; } = new(); From 135e6798f26bca24355af506dd5d2cd662c08256 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 09:49:40 +0200 Subject: [PATCH 045/380] Change-log: add the #14042 fixes to the beta23 section #14042 merged into main between compiling the beta23 change-log and merging the prep PR, so its three shipped-bug fixes were missing. Its fourth fix (the ASSA CheckForErrors guard) is left out: that bug was introduced by #14026 in this same beta and never shipped. Co-Authored-By: Claude Opus 5 --- change-log.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/change-log.txt b/change-log.txt index 2af68dec855..f3f3badcba3 100644 --- a/change-log.txt +++ b/change-log.txt @@ -19,6 +19,9 @@ v5.2.0-beta23 (24th of August 2026) * 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 From 24a8d44b9a34b03673cb1dff7e6d5dbf79cef2a8 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 12:43:51 +0200 Subject: [PATCH 046/380] Fix two silent failures that could drop the video on mkv "Open with" (#14047) Opening an mkv at startup (Explorer "Open with", CLI argument, session restore) loads the embedded subtitle but could silently skip the video. Two independent gates could swallow the open with no message and no log: 1. VideoOpenFile returned immediately when GetVideoPlayerControl() was null. With undocked video controls the startup path calls VideoUndockControls() - which rebuilds the window via dispatcher posts and only gets its player control once the window's Loaded event has run - and then opens the file 100 ms later, racing a null control. Now waits (bounded, 5 s) for the control to come up, and logs an error if it never does instead of returning silently. 2. LibMpvDynamicPlayer.LoadFile issued "loadfile" after a bounded wait for the lazily initialized core (brought up by the first render pass). If no frame had rendered yet - video window still being built, opened minimized or behind - the command failed with MPV_ERROR_UNINITIALIZED and the open was lost with only a log line. The request is now parked and replayed by MarkCoreInitialized the moment the core comes online; the pending load is claimed via Interlocked.Exchange so the replay and a concurrent explicit load can't both run, and is discarded on CloseFile/Dispose or when a newer load supersedes it. Verified on macOS: mkv via CLI argument opens subtitle + video in both docked and undocked-at-startup configurations, no new error-log entries; full UI test suite passes (3556 tests). Co-Authored-By: Claude Opus 5 --- src/ui/Features/Main/MainViewModel.cs | 20 +++- .../LibMpvDynamic/LibMpvDynamicPlayer.cs | 93 +++++++++++++++++-- 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 0a31d78b23c..055196c2434 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -22147,7 +22147,25 @@ private async Task VideoOpenFile(string videoFileName, int desiredAudioTrackId = var vp = GetVideoPlayerControl(); if (vp == null) { - return; + // The player control can be legitimately missing for a moment: the undocked video + // window only gets its control once its Loaded event has run, and layout rebuilds + // swap controls via dispatcher posts. An open issued right after VideoUndockControls() + // - e.g. an "Open with"/CLI launch or session restore at startup - used to race that + // and silently never open the video (#14047). Wait (bounded) for the control; the + // delays yield the UI thread so the pending window/layout build can finish. + var waitUntil = DateTime.UtcNow.AddSeconds(5); + while (vp == null && DateTime.UtcNow < waitUntil) + { + await Task.Delay(50); + vp = GetVideoPlayerControl(); + } + + if (vp == null) + { + Se.LogError(new InvalidOperationException("No video player control available after 5 seconds"), + $"VideoOpenFile could not open \"{videoFileName}\""); + return; + } } _videoOpenTokenSource?.Cancel(); diff --git a/src/ui/Logic/VideoPlayers/LibMpvDynamic/LibMpvDynamicPlayer.cs b/src/ui/Logic/VideoPlayers/LibMpvDynamic/LibMpvDynamicPlayer.cs index 9d152299069..49261a142a7 100644 --- a/src/ui/Logic/VideoPlayers/LibMpvDynamic/LibMpvDynamicPlayer.cs +++ b/src/ui/Logic/VideoPlayers/LibMpvDynamic/LibMpvDynamicPlayer.cs @@ -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 @@ -442,13 +449,53 @@ public int Initialize() 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 @@ -1019,8 +1066,7 @@ public void InitializeWithOpenGL(GetProcAddress getProcAddress) } else { - _coreInitialized = true; - StartEventLoop(); + MarkCoreInitialized(); } // Create OpenGL init params @@ -1120,8 +1166,7 @@ public void InitializeWithMetal(IntPtr mtlDevice, IntPtr metalLayer) } else { - _coreInitialized = true; - StartEventLoop(); + MarkCoreInitialized(); } // Build mpv_metal_init_params: device (required) + layer (optional). @@ -1361,6 +1406,7 @@ private void FreeRenderContext() public void Dispose() { _disposed = true; + _pendingLoadFileName = null; if (_renderContextNeedsGraphicsContext && _renderContext != IntPtr.Zero) { @@ -1419,6 +1465,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,6 +1495,34 @@ 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 @@ -1572,6 +1649,7 @@ public void PlayOrPause() public void CloseFile() { _fileName = string.Empty; + _pendingLoadFileName = null; // a close discards a load still parked for the core _pausedValue = null; _audioEndBound = null; _lastRawTimePos = -1; @@ -2237,8 +2315,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"); From d1173b7168fd7c7623294d3bfe1bccf49d9a95d4 Mon Sep 17 00:00:00 2001 From: bovirus <1262554+bovirus@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:59:02 +0200 Subject: [PATCH 047/380] Update Italian.json --- src/ui/Assets/Languages/Italian.json | 83 +++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/src/ui/Assets/Languages/Italian.json b/src/ui/Assets/Languages/Italian.json index 4705ee0334e..7fc4b0beee3 100644 --- a/src/ui/Assets/Languages/Italian.json +++ b/src/ui/Assets/Languages/Italian.json @@ -1,7 +1,7 @@ { "title": "SubtitleEdit", - "version": "v. 5.2.0-beta21", - "translatedBy": "v. 22.08.2026 di bovirus e GitGianluc", + "version": "v. 5.2.0-beta22", + "translatedBy": "v. 24.08.2026 di bovirus e GitGianluc", "cultureName": "it-IT", "general": { "abort": "Annulla", @@ -1081,6 +1081,14 @@ "replacedXOccurrences": "Sostituite {0} occorrenze", "ffmpegDownloadedAndInstalledToX": "ffmpeg scaricato e installato in {0}", "nothingToSave": "Niente da salvare", + "formatLimitWarningTitle": "Superati limiti del formato", + "formatLimitWarningXLinesExceedLimitsOfY": "{0} sottotitoli superano i limiti del formato '{1}':", + "formatLimitWarningMaxXCharactersPerLine": "Max {0} caratteri per riga", + "formatLimitWarningMaxXLines": "Massimo {0} righe per sottotitolo", + "formatLimitWarningTextWillBeRewrapped": "Una volta salvati, questi sottotitoli verranno riformattati per adattarli, quindi il file non corrisponderà a quanto visualizzato qui.", + "formatLimitWarningLinesX": "Righe: {0}", + "formatLimitWarningSaveAnyway": "Salva comunque", + "formatLimitWarningDoNotShowAgain": "Non visualizzare più questo avviso", "nothingToSaveOriginal": "Niente da salvare (originale)", "liveSpellCheckLanguageXLoaded": "Lingua controllo ortografico {0} in tempo reale caricata", "downloadFfmpegTitle": "Vuoi scaricare FFmpeg?", @@ -1698,7 +1706,14 @@ "batchSnapToShotChanges": "Aggancia i marcatori ai cambi di inquadratura (se disponibili)", "batchFrameRateFromVideo": "Usa la frequenza fotogrammi del file video che presenta lo stesso nome del file dei sottotitoli", "batchFrameRateFixed": "Usa frequenza fotogrammi fissa", - "batchInfo": "I cambi di inquadratura vengono letti da un file video che presenta lo stesso nome del file dei sottotitoli, se trovato." + "batchInfo": "I cambi di inquadratura vengono letti da un file video che presenta lo stesso nome del file dei sottotitoli, se trovato.", + "useExactTimeCodes": "Usa time code esatti dal video", + "extractTimeCodes": "Estrai time code", + "extractingTimeCodes": "Estrazione time code...", + "noTimeCodesLoaded": "Nessun time code caricato", + "xTimeCodesLoaded": "{0} time code caricati", + "timeCodesExtractFailed": "Impossibile leggere i time code dal video", + "timeCodesHint": "Aggancia tagli alla frequenza fotogrammi reale del video invece di usare una frequenza fotogrammi costante. Richiede la lettura dell'intero video una volta; il risultato viene memorizzato nella cache." }, "beautifyTimeCodesProfile": { "title": "Modifica profilo abbellimento time code", @@ -2130,6 +2145,33 @@ "fixCasing": "Correggi maiuscolo/minuscolo", "addPeriods": "Aggiungi periodi", "changeUnderlineToColor": "Cambia sottolineatura in colore", + "removeNonSpeechLines": "Rimuovi righe non vocali", + "removeNonSpeechLinesHint": "Elimina righe che descrivono solo il suono, come '[Musica]' o '(onde che si infrangono)'", + "removeRepeatedLines": "Rimuovi righe ripetute", + "removeRepeatedLinesHint": "Elimina righe che ripetono la riga precedente parola per parola (loop motore)", + "showQualityReport": "Dopo la trascrizione visualizza rapporto sulla qualità", + "qualityReportTitle": "Rapporto qualità trascrizione", + "qualityReportNoIssues": "Nessun problema trovato: la trascrizione sembra buona.", + "qualityReportSummaryX": "{0} problema/i trovati in {1} righa/e", + "qualityReportLinesChecked": "Righe controllate", + "qualityReportIssuesFound": "Problemi rilevati", + "qualityReportLinesRemoved": "Righe rimosse", + "qualityReportTooShort": "Troppo corto", + "qualityReportTooShortHint": "Tempo visualizzazione inferiore al minimo o velocità lettura superiore al massimo", + "qualityReportTooLong": "Troppo lungo", + "qualityReportTooLongHint": "Tempo visualizzazione più lungo del massimo o pochissime parole per un lungo periodo (spesso un'allucinazione)", + "qualityReportOverlap": "Sovrapposizione", + "qualityReportOverlapHint": "Termina dopo l'inizio della riga successiva", + "qualityReportNonSpeech": "Non parlato", + "qualityReportNonSpeechHint": "Solo una descrizione del suono o della musica, ad esempio '[Musica]'", + "qualityReportRepeated": "Ripetuto", + "qualityReportRepeatedHint": "Stesso testo della riga precedente (loop motore)", + "qualityReportRemoved": "Rimosso", + "qualityReportAll": "Tutti", + "qualityReportIssue": "Problema", + "qualityReportDetail": "Dettagli", + "qualityReportTip": "Nel nuovo sottotitolo le righe sono elencate con i loro numeri. Usa 'Correggi errori comuni' o le impostazioni di post-elaborazione per correggerli in modo più automatico o se sono interessate molte righe prova un altro modello/motore.", + "qualityReportDoNotShowAgain": "Non visualizzare più questo rapporto", "engineSettings": "Impostazioni motore sintesi vocale", "engineSettingsSubtitle": "Motore sintesi vocale", "backendAndUpdateStatus": "Backend e aggiornamento stato", @@ -2163,6 +2205,9 @@ "elevenLabsSettingsResetHint": "Ripristina le impostazioni di ElevenLabs ai valori predefiniti", "regenerateAudio": "Rigenera audio", "autoContinuePlaying": "Continua automaticamente la riproduzione", + "playLine": "Riproduci riga", + "fitDurationToGeneratedAudio": "Adatta durata all'audio generato", + "resetTiming": "Ripristina tempi", "addingAudioToVideoFileDotDotDot": "Aggiunta audio al file video...", "preparingMergeDotDotDot": "Preparazione unione...", "importVoiceDotDotDot": "Importa voce...", @@ -2869,6 +2914,7 @@ "useFocusedButtonBackgroundColor": "Usa colore di sfondo pulsante evidenziato", "focusedButtonBackgroundColor": "Colore di sfondo pulsante in evidenza", "forceCrLfOnSave": "Forza CR+LF al salvataggio (file sottotitoli testo)", + "showFormatLimitWarning": "Avvisa al salvataggio quando le righe superano i limiti del formato (es. SCC 32 caratteri/riga)", "textBoxButtonShowAutoBreak": "Casella testo: visualizza pulsante 'Interruzione automatica'", "textBoxButtonShowUnbreak": "Casella testo: visualizza pulsante 'Interruzione'", "textBoxButtonShowItalic": "Casella testo: visualizza pulsante 'Corsivo'", @@ -3587,5 +3633,36 @@ "payPal": "PayPal", "gitHubSponsor": "sponsor Github", "or": " o " + }, + "errorList": { + "title": "Elenco errori", + "summaryX": "0} errore/i in {1} / {2} riga/e", + "noErrors": "Nessun errore rilevato.", + "summaryFilesX": "{0} errore/i in {1} riga/e in {2} file", + "all": "Tutti", + "tooManyLines": "Troppe righe", + "charactersPerSecond": "Velocità lettura", + "durationTooShort": "Troppo corta", + "durationTooLong": "Troppo lunga", + "lineTooLong": "Riga troppo lunga", + "lineTooWide": "Riga troppo larga", + "overlap": "Sovrapposizione", + "gapTooShort": "Gap troppo breve", + "tooManyLinesHint": "Più righe rispetto al numero massimo di righe", + "charactersPerSecondHint": "Caratteri al secondo superiori al massimo", + "durationTooShortHint": "Tempo visualizzazione inferiore al minimo", + "durationTooLongHint": "Tempo visualizzazione più lungo del massimo", + "lineTooLongHint": "Una riga contiene più caratteri della lunghezza massima della riga", + "lineTooWideHint": "Una riga è più larga (in pixel) della larghezza massima", + "overlapHint": "Si sovrappone alla riga precedente o successiva", + "gapTooShortHint": "Lo spazio tra la riga precedente o successiva è inferiore al minimo", + "detailXGreaterThanY": "{0} > {1}", + "detailXLessThanY": "{0} < {1}", + "detailOverlapFromPrevious": "dal precedente: {0} ms", + "detailOverlapToNext": "al successivo: {0} ms", + "detailGapToPrevious": "al precedente: {0} < {1} ms", + "detailGapToNext": "al successivo: {0} < {1} ms", + "detail": "Dettagli", + "tip": "Fai doppio clic su una riga (o premi 'Invio') per accedervi. I controlli da eseguire e i relativi limiti vengono impostati in Impostazioni > Generali." } } From 182e77f55e049ea913036f46a67871c16a8932ab Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 13:05:05 +0200 Subject: [PATCH 048/380] Show a clear message when Google blocks the free translate endpoint (#14015) Google's "Sorry..." abuse page (HTTP 429/403, "unusual traffic") is an IP-level block that lasts minutes to hours. Detect it, skip the seconds-scale retry ladder, and surface a plain-language explanation instead of the raw HTML page and a "quota exceeded?" guess. Co-Authored-By: Claude Fable 5 --- .../AutoTranslate/GoogleTranslateV1.cs | 38 +++++++++++++++++++ .../GoogleTranslateV1RetryTests.cs | 27 +++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/libuilogic/AutoTranslate/GoogleTranslateV1.cs b/src/libuilogic/AutoTranslate/GoogleTranslateV1.cs index 266e3720e31..df7bb907396 100644 --- a/src/libuilogic/AutoTranslate/GoogleTranslateV1.cs +++ b/src/libuilogic/AutoTranslate/GoogleTranslateV1.cs @@ -78,6 +78,20 @@ public async Task Translate(string input, string sourceLanguageCode, str if (!result.IsSuccessStatusCode) { + if (IsGoogleSorryBlockPage(result.StatusCode, jsonResultString)) + { + // Keep the HTML page out of Error so the error dialog shows the + // explanation below instead of a wall of markup. + Error = string.Empty; + SeLogger.Error($"Error in {StaticName}.Translate: Google \"unusual traffic\" block page, status code {(int)result.StatusCode}: " + jsonResultString); + throw new Exception( + $"Google is temporarily blocking translation requests from your IP address (status code {(int)result.StatusCode}, \"unusual traffic\" block)." + Environment.NewLine + + Environment.NewLine + + "This is a block on Google's side, not an error in Subtitle Edit. It is usually lifted again after some minutes to a few hours." + Environment.NewLine + + Environment.NewLine + + "You can wait a while and try again, try another network or VPN, or switch to another translation engine."); + } + Error = jsonResultString; SeLogger.Error($"Error in {StaticName}.Translate: " + Error); throw new Exception($"{StaticName} failed with status code {(int)result.StatusCode} ({result.StatusCode}) - free API quota exceeded?" + Environment.NewLine + Environment.NewLine + jsonResultString); @@ -98,12 +112,36 @@ public async Task Translate(string input, string sourceLanguageCode, str /// public static bool ShouldRetry(HttpResponseMessage result, string resultContent) { + if (IsGoogleSorryBlockPage(result.StatusCode, resultContent)) + { + // Google's "Sorry..." page is an IP-level "unusual traffic" block that lasts + // minutes to hours (issue #14015) - retrying within seconds cannot clear it, + // so fail fast and let Translate surface the explanation instead. + return false; + } + return DeepLTranslate.ShouldRetry(result, resultContent) || result.StatusCode == HttpStatusCode.InternalServerError || result.StatusCode == HttpStatusCode.BadGateway || result.StatusCode == HttpStatusCode.GatewayTimeout; } + /// + /// Google's "Sorry..." abuse page: an IP-reputation block served with 429 (sometimes 403) + /// when Google decides an IP sends "unusual traffic"/"automated queries" (issue #14015). + /// + public static bool IsGoogleSorryBlockPage(HttpStatusCode statusCode, string resultContent) + { + if (statusCode != HttpStatusCode.TooManyRequests && statusCode != HttpStatusCode.Forbidden) + { + return false; + } + + return resultContent.Contains("Sorry", StringComparison.OrdinalIgnoreCase) || + resultContent.Contains("unusual traffic", StringComparison.OrdinalIgnoreCase) || + resultContent.Contains("automated queries", StringComparison.OrdinalIgnoreCase); + } + public static List<TranslationPair> GetTranslationPairs() { return new List<TranslationPair> diff --git a/tests/libuilogic/AutoTranslate/GoogleTranslateV1RetryTests.cs b/tests/libuilogic/AutoTranslate/GoogleTranslateV1RetryTests.cs index 940917dd32a..7631cd358f3 100644 --- a/tests/libuilogic/AutoTranslate/GoogleTranslateV1RetryTests.cs +++ b/tests/libuilogic/AutoTranslate/GoogleTranslateV1RetryTests.cs @@ -24,4 +24,31 @@ public void ShouldRetry_OnlyForTransientServerErrors(HttpStatusCode statusCode, Assert.Equal(expected, GoogleTranslateV1.ShouldRetry(response, "<html><title>Error 500 (Server Error)!!1")); } + + // Issue #14015: a 429 carrying Google's "Sorry..." abuse page is an IP-level block that + // lasts minutes to hours, so the seconds-scale retry ladder must not run - fail fast and + // let Translate surface the explanation instead. + private const string SorryPage = "Sorry..." + + "Our systems have detected unusual traffic from your computer network."; + + [Fact] + public void ShouldRetry_NotForGoogleSorryBlockPage() + { + using var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + + Assert.False(GoogleTranslateV1.ShouldRetry(response, SorryPage)); + } + + [Theory] + [InlineData(HttpStatusCode.TooManyRequests, SorryPage, true)] + [InlineData(HttpStatusCode.Forbidden, SorryPage, true)] + // A plain rate-limit 429 without the abuse page is still a transient error, not a block. + [InlineData(HttpStatusCode.TooManyRequests, "429 Too Many Requests", false)] + // The markers only count on the blocking status codes, not on a successful reply. + [InlineData(HttpStatusCode.OK, SorryPage, false)] + [InlineData(HttpStatusCode.InternalServerError, SorryPage, false)] + public void IsGoogleSorryBlockPage_DetectsAbusePageOnly(HttpStatusCode statusCode, string content, bool expected) + { + Assert.Equal(expected, GoogleTranslateV1.IsGoogleSorryBlockPage(statusCode, content)); + } } From 9593d31f03e3c7c9c24d34882ff1b84254abbbe3 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 13:12:10 +0200 Subject: [PATCH 049/380] Ride out Google's IP block via the clients5 fallback endpoint (#14015) When translate.googleapis.com serves the "Sorry..." block page, retry the line via clients5.google.com/translate_a/t?client=dict-chrome-ex - verified to keep answering from an IP that gtx is actively blocking. The clear block message now only surfaces when both endpoints fail. Also: modernize the 2015-era Chrome/41 user agent, drop the Content-Type header sent on GET requests (both odd fingerprints that feed the bot scoring), and space requests 500 ms apart once a run has seen its first non-success response - successful runs pay no delay. Co-Authored-By: Claude Fable 5 --- .../AutoTranslate/GoogleTranslateV1.cs | 125 +++++++++++++++++- .../Translate/GoogleTranslateV1Tests.cs | 39 ++++++ 2 files changed, 160 insertions(+), 4 deletions(-) diff --git a/src/libuilogic/AutoTranslate/GoogleTranslateV1.cs b/src/libuilogic/AutoTranslate/GoogleTranslateV1.cs index df7bb907396..1c109c5f0b6 100644 --- a/src/libuilogic/AutoTranslate/GoogleTranslateV1.cs +++ b/src/libuilogic/AutoTranslate/GoogleTranslateV1.cs @@ -19,6 +19,13 @@ namespace Nikse.SubtitleEdit.UiLogic.AutoTranslate public class GoogleTranslateV1 : IAutoTranslator, IDisposable { private HttpClient _httpClient = null!; + private bool _paceRequests; + private DateTime _lastRequestTime = DateTime.MinValue; + + // Once Google has answered a non-success status this run, request bursts are what + // keep feeding the "unusual traffic" scoring (issue #14015) - space the remaining + // requests out instead of hammering on. Successful runs pay no delay at all. + private const int PacedRequestIntervalMs = 500; public static string StaticName { get; set; } = "Google Translate V1 API"; public override string ToString() => StaticName; @@ -29,10 +36,14 @@ public class GoogleTranslateV1 : IAutoTranslator, IDisposable public void Initialize() { + _paceRequests = false; + _lastRequestTime = DateTime.MinValue; _httpClient?.Dispose(); _httpClient = HttpClientFactoryWithProxy.CreateHttpClientWithProxy(); - _httpClient.DefaultRequestHeaders.Add("user-agent", "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36"); - _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json; charset=UTF-8"); + // A 2015-era Chrome version (and a Content-Type on GET requests) are odd + // fingerprints that feed the bot scoring behind Google's "unusual traffic" + // block (issue #14015) - look like a current browser instead. + _httpClient.DefaultRequestHeaders.Add("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"); _httpClient.BaseAddress = new Uri("https://translate.googleapis.com/"); } @@ -63,10 +74,16 @@ public async Task Translate(string input, string sourceLanguageCode, str jsonResultString = string.Empty; for (var attempt = 0; attempt <= retryDelays.Length; attempt++) { + await PaceRequest(cancellationToken); result = await _httpClient.GetAsync(url, cancellationToken); var bytes = await result.Content.ReadAsByteArrayAsync(cancellationToken); jsonResultString = Encoding.UTF8.GetString(bytes).Trim(); + if (!result.IsSuccessStatusCode) + { + _paceRequests = true; + } + if (!ShouldRetry(result, jsonResultString) || attempt == retryDelays.Length) { break; @@ -80,12 +97,19 @@ public async Task Translate(string input, string sourceLanguageCode, str { if (IsGoogleSorryBlockPage(result.StatusCode, jsonResultString)) { + SeLogger.Error($"Error in {StaticName}.Translate: Google \"unusual traffic\" block page, status code {(int)result.StatusCode} - trying the clients5.google.com fallback endpoint. Page: " + jsonResultString); + + var fallbackTranslation = await TranslateViaFallbackEndpoint(text, sourceLanguageCode, targetLanguageCode, cancellationToken); + if (fallbackTranslation != null) + { + return fallbackTranslation; + } + // Keep the HTML page out of Error so the error dialog shows the // explanation below instead of a wall of markup. Error = string.Empty; - SeLogger.Error($"Error in {StaticName}.Translate: Google \"unusual traffic\" block page, status code {(int)result.StatusCode}: " + jsonResultString); throw new Exception( - $"Google is temporarily blocking translation requests from your IP address (status code {(int)result.StatusCode}, \"unusual traffic\" block)." + Environment.NewLine + + $"Google is temporarily blocking translation requests from your IP address (status code {(int)result.StatusCode}, \"unusual traffic\" block), and the fallback endpoint (clients5.google.com) did not answer either." + Environment.NewLine + Environment.NewLine + "This is a block on Google's side, not an error in Subtitle Edit. It is usually lifted again after some minutes to a few hours." + Environment.NewLine + Environment.NewLine + @@ -142,6 +166,99 @@ public static bool IsGoogleSorryBlockPage(HttpStatusCode statusCode, string resu resultContent.Contains("automated queries", StringComparison.OrdinalIgnoreCase); } + private async Task PaceRequest(CancellationToken cancellationToken) + { + if (_paceRequests) + { + var wait = PacedRequestIntervalMs - (int)(DateTime.UtcNow - _lastRequestTime).TotalMilliseconds; + if (wait > 0 && wait <= PacedRequestIntervalMs) + { + await Task.Delay(wait, cancellationToken); + } + } + + _lastRequestTime = DateTime.UtcNow; + } + + /// + /// The Chrome-extension endpoint at clients5.google.com - observed to keep answering + /// while translate.googleapis.com serves the "Sorry..." block page (issue #14015). + /// Returns null on any failure so the caller can surface the block explanation. + /// + private async Task TranslateViaFallbackEndpoint(string text, string sourceLanguageCode, string targetLanguageCode, CancellationToken cancellationToken) + { + try + { + var url = $"https://clients5.google.com/translate_a/t?client=dict-chrome-ex&sl={sourceLanguageCode}&tl={targetLanguageCode}&q={Utilities.UrlEncode(text)}"; + await PaceRequest(cancellationToken); + var result = await _httpClient.GetAsync(url, cancellationToken); + var bytes = await result.Content.ReadAsByteArrayAsync(cancellationToken); + var resultContent = Encoding.UTF8.GetString(bytes).Trim(); + + if (!result.IsSuccessStatusCode) + { + SeLogger.Error($"{StaticName}: fallback endpoint failed with status code {(int)result.StatusCode} ({result.StatusCode}): " + resultContent); + return null; + } + + return ConvertDictChromeExResultToText(resultContent); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + SeLogger.Error(exception, $"{StaticName}: fallback endpoint failed"); + return null; + } + } + + /// + /// Parses the dict-chrome-ex response: ["text"] with a concrete source language, or + /// [["text","detected-language"]] with sl=auto. Returns null when no translation is found. + /// + public static string? ConvertDictChromeExResultToText(string result) + { + var parser = new SeJsonParser(); + var elements = parser.GetArrayElements(result); + var sb = new StringBuilder(); + foreach (var element in elements) + { + var s = element; + if (s.StartsWith('[')) + { + var inner = parser.GetArrayElements(s); + if (inner.Count == 0) + { + continue; + } + + s = inner[0]; + } + + s = s.Trim('"'); + try + { + s = Regex.Unescape(s); + } + catch + { + s = s.Replace("\\n", "\n"); + } + + sb.AppendLine(s); + } + + var text = sb.ToString().Trim(); + if (text.Length == 0) + { + return null; + } + + return string.Join(Environment.NewLine, text.SplitToLines()); + } + public static List GetTranslationPairs() { return new List diff --git a/tests/libuilogic/Translate/GoogleTranslateV1Tests.cs b/tests/libuilogic/Translate/GoogleTranslateV1Tests.cs index b5d7cde6a0b..e99d8e51b76 100644 --- a/tests/libuilogic/Translate/GoogleTranslateV1Tests.cs +++ b/tests/libuilogic/Translate/GoogleTranslateV1Tests.cs @@ -48,4 +48,43 @@ public void SingleSegment_IsReturnedUnchanged() Assert.Equal(new[] { "Ahoj." }, lines); } + + // The clients5.google.com dict-chrome-ex fallback (issue #14015) answers a much simpler + // shape than gtx: a flat array of translated strings, or [text, detected-language] pairs + // when the source language is "auto". + + [Fact] + public void DictChromeEx_SingleLine() + { + Assert.Equal("Hej verden", GoogleTranslateV1.ConvertDictChromeExResultToText("[\"Hej verden\"]")); + } + + [Fact] + public void DictChromeEx_EscapedNewlineBecomesLineBreak() + { + var text = GoogleTranslateV1.ConvertDictChromeExResultToText("[\"Hej verden\\nHvordan har du det?\"]"); + + Assert.Equal("Hej verden" + Environment.NewLine + "Hvordan har du det?", text); + } + + [Fact] + public void DictChromeEx_AutoDetectPairShape_TakesTranslationOnly() + { + Assert.Equal("Bonjour", GoogleTranslateV1.ConvertDictChromeExResultToText("[[\"Bonjour\",\"en\"]]")); + } + + [Fact] + public void DictChromeEx_UnicodeEscape_IsDecoded() + { + Assert.Equal("café", GoogleTranslateV1.ConvertDictChromeExResultToText("[\"caf\\u00e9\"]")); + } + + [Theory] + [InlineData("[]")] + [InlineData("")] + [InlineData("Sorry...")] + public void DictChromeEx_NoTranslation_ReturnsNull(string json) + { + Assert.Null(GoogleTranslateV1.ConvertDictChromeExResultToText(json)); + } } From f055b2a795b86bb859acc8429a76c4500d175f3c Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 13:19:50 +0200 Subject: [PATCH 050/380] Fix malformed format string in Italian.json (errorList.summaryX) "0} errore/i..." was missing the opening brace, failing LanguageFileFormatStringTests.EveryTranslation_FormatsWithoutThrowing on main since #14049. Co-Authored-By: Claude Fable 5 --- src/ui/Assets/Languages/Italian.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/Assets/Languages/Italian.json b/src/ui/Assets/Languages/Italian.json index 7fc4b0beee3..c12d964beb4 100644 --- a/src/ui/Assets/Languages/Italian.json +++ b/src/ui/Assets/Languages/Italian.json @@ -3636,7 +3636,7 @@ }, "errorList": { "title": "Elenco errori", - "summaryX": "0} errore/i in {1} / {2} riga/e", + "summaryX": "{0} errore/i in {1} / {2} riga/e", "noErrors": "Nessun errore rilevato.", "summaryFilesX": "{0} errore/i in {1} riga/e in {2} file", "all": "Tutti", From 45b540d91d92b4592847ce6f17b4e543e58cf5bd Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 13:40:57 +0200 Subject: [PATCH 051/380] Fix Qwen 3/3.5/3.6 leakage in llama.cpp translate/review The curated Qwen entries forced --no-jinja --chat-template chatml to bypass the model's thinking mode. A controlled A/B (seconv EN->DA, 10 reps x 20 lines on the Q8_0 9B, 200 lines/variant) showed this is unreliable: it leaks raw text - which runs the model out of its token budget before it ever reaches the translation - into 2.5% of lines. NoThinking (--reasoning off, embedded template left alone) stayed at 0% across every rep on both the 9B and 4B quants, and stacking both overrides was worse than either alone (6.5%/16.7%), so switch every curated Qwen entry to NoThinking instead, matching what Gemma 4 already does. Also harden ChatGptTranslate.RemovePreamble (shared by every OpenAI-compatible translate engine) so an unterminated block returns empty instead of passing raw reasoning text through as if it were the translation - this activates the existing retry-on-no-progress logic in DoAutoTranslate as a defense-in-depth backstop. Co-Authored-By: Claude Sonnet 5 --- .../AutoTranslate/ChatGptTranslate.cs | 15 ++++- .../LlamaCpp/LlamaCppServerManager.cs | 58 +++++++++++-------- .../ChatGptTranslateRemovePreambleTests.cs | 53 +++++++++++++++++ .../LlamaCppServerArgumentsTests.cs | 22 +++++++ tests/seconv/Core/AutoTranslateRunnerTest.cs | 7 ++- 5 files changed, 126 insertions(+), 29 deletions(-) create mode 100644 tests/libuilogic/AutoTranslate/ChatGptTranslateRemovePreambleTests.cs diff --git a/src/libuilogic/AutoTranslate/ChatGptTranslate.cs b/src/libuilogic/AutoTranslate/ChatGptTranslate.cs index f533daa3298..39db94e4d68 100644 --- a/src/libuilogic/AutoTranslate/ChatGptTranslate.cs +++ b/src/libuilogic/AutoTranslate/ChatGptTranslate.cs @@ -57,9 +57,20 @@ public static string RemovePreamble(string original, string input) var translation = input; var indexOfStartThink = translation.IndexOf(""); var indexOfEndThink = translation.IndexOf(""); - if (indexOfStartThink >= 0 && indexOfEndThink > indexOfStartThink) + if (indexOfStartThink >= 0) { - translation = translation.Remove(indexOfStartThink, indexOfEndThink - indexOfStartThink + 8).Trim(); + if (indexOfEndThink > indexOfStartThink) + { + translation = translation.Remove(indexOfStartThink, indexOfEndThink - indexOfStartThink + 8).Trim(); + } + else + { + // The model started reasoning but the response was cut off (hit its token + // budget) before closing the tag, so everything after "" is raw + // internal monologue, not a translation. Empty triggers the caller's + // retry-on-no-progress logic (DoAutoTranslate) instead of shipping it. + return string.Empty; + } } var match = PreambleRegex.Match(translation); diff --git a/src/libuilogic/LlamaCpp/LlamaCppServerManager.cs b/src/libuilogic/LlamaCpp/LlamaCppServerManager.cs index 2529e3bea4a..c03d54396a5 100644 --- a/src/libuilogic/LlamaCpp/LlamaCppServerManager.cs +++ b/src/libuilogic/LlamaCpp/LlamaCppServerManager.cs @@ -128,30 +128,37 @@ public static class LlamaCppServerManager // Alternative model family. Qwen 3 is the strongest open model for CJK // (Chinese/Japanese/Korean) and competitive elsewhere — useful fallback // when Gemma's quirks bite (occasional refusals, formatting drift, etc). - // --no-jinja + chatml bypasses the embedded Jinja template's - // enable_thinking logic on the hybrid Qwen3-8B so output is clean - // translation, not ... reasoning blocks. + // NoThinking (--reasoning off) suppresses the hybrid Qwen3 template's thinking mode so + // output is clean translation, not ... reasoning blocks - same mechanism + // as Gemma 4 above. This used to be "--no-jinja --chat-template chatml" instead (forcing + // the template also bypasses enable_thinking), but that proved unreliable: a controlled + // A/B (seconv EN->DA, 10 reps x 20 lines on the Q8_0 9B, 200 lines/variant) measured + // chatml+no-jinja leaking raw text - which runs the model out of its token budget + // before it ever reaches the translation - into 2.5% of lines (5/200), while NoThinking + // alone stayed at 0% (0/200) across every rep on both the 9B and the 4B Q4_K_M (0/60). + // Stacking both overrides is worse, not better (6.5% on 9B, 16.7% on 4B): --reasoning off + // does not reliably suppress thinking once --chat-template chatml has replaced the + // template it hooks into, so the two must never be combined. new LlamaCppModel("Qwen 3 4B Instruct (Q4_K_M)", "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf", "2.5 GB", "https://huggingface.co/bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF/resolve/main/Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf", - ChatTemplate: "chatml", NoJinja: true), + NoThinking: true), new LlamaCppModel("Qwen 3 8B (Q4_K_M)", "Qwen_Qwen3-8B-Q4_K_M.gguf", "4.7 GB", "https://huggingface.co/bartowski/Qwen_Qwen3-8B-GGUF/resolve/main/Qwen_Qwen3-8B-Q4_K_M.gguf", - ChatTemplate: "chatml", NoJinja: true), + NoThinking: true), - // Qwen 3.5 - newer Qwen generation. Same chatml + --no-jinja handling as Qwen 3 (bypasses the - // embedded thinking template so the output is clean translation). Kept to <= 8 GB. + // Qwen 3.5 - newer Qwen generation. Same NoThinking handling as Qwen 3 above. Kept to <= 8 GB. new LlamaCppModel("Qwen 3.5 4B (Q4_K_M)", "Qwen_Qwen3.5-4B-Q4_K_M.gguf", "2.8 GB", "https://huggingface.co/bartowski/Qwen_Qwen3.5-4B-GGUF/resolve/main/Qwen_Qwen3.5-4B-Q4_K_M.gguf", - ChatTemplate: "chatml", NoJinja: true), + NoThinking: true), new LlamaCppModel("Qwen 3.5 4B (Q8_0)", "Qwen_Qwen3.5-4B-Q8_0.gguf", "4.3 GB", "https://huggingface.co/bartowski/Qwen_Qwen3.5-4B-GGUF/resolve/main/Qwen_Qwen3.5-4B-Q8_0.gguf", - ChatTemplate: "chatml", NoJinja: true), + NoThinking: true), new LlamaCppModel("Qwen 3.5 9B (Q4_K_M)", "Qwen_Qwen3.5-9B-Q4_K_M.gguf", "5.7 GB", "https://huggingface.co/bartowski/Qwen_Qwen3.5-9B-GGUF/resolve/main/Qwen_Qwen3.5-9B-Q4_K_M.gguf", - ChatTemplate: "chatml", NoJinja: true), + NoThinking: true), new LlamaCppModel("Qwen 3.5 9B (Q8_0)", "Qwen_Qwen3.5-9B-Q8_0.gguf", "9.8 GB", "https://huggingface.co/bartowski/Qwen_Qwen3.5-9B-GGUF/resolve/main/Qwen_Qwen3.5-9B-Q8_0.gguf", - ChatTemplate: "chatml", NoJinja: true), + NoThinking: true), // Qwen 3.6 35B-A3B - a mixture-of-experts model: 35B total but only ~3B active per token, so // it generates fast even fully on CPU. That makes it the option for machines with plenty of @@ -163,7 +170,7 @@ public static class LlamaCppServerManager // Qwen 3.5 MoE arch), which the pinned engine already supports. new LlamaCppModel("Qwen 3.6 35B-A3B (IQ2_M) - fast on CPU, 2-bit quality", "Qwen3.6-35B-A3B-UD-IQ2_M.gguf", "11.5 GB", "https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF/resolve/main/Qwen3.6-35B-A3B-UD-IQ2_M.gguf", - ChatTemplate: "chatml", NoJinja: true), + NoThinking: true), // Hy-MT2 (Tencent Hunyuan-MT 2, 2026) - translation-specialized, official GGUFs, Apache-2.0. // Excellent for its 33+5 supported languages (CJK, major European/Asian) but has NO Nordic @@ -198,22 +205,23 @@ public static class LlamaCppServerManager // JSON output, where the plain instruct models are much stronger. Kept to ~12 GB or below. public static readonly IReadOnlyList ReviewModels = new[] { + // NoThinking instead of chatml+no-jinja - see the note in TranslateModels for the A/B data. new LlamaCppModel("Qwen 3.5 4B (Q4_K_M)", "Qwen_Qwen3.5-4B-Q4_K_M.gguf", "2.8 GB", "https://huggingface.co/bartowski/Qwen_Qwen3.5-4B-GGUF/resolve/main/Qwen_Qwen3.5-4B-Q4_K_M.gguf", - ChatTemplate: "chatml", NoJinja: true), + NoThinking: true), new LlamaCppModel("Qwen 3.5 4B (Q8_0)", "Qwen_Qwen3.5-4B-Q8_0.gguf", "4.3 GB", "https://huggingface.co/bartowski/Qwen_Qwen3.5-4B-GGUF/resolve/main/Qwen_Qwen3.5-4B-Q8_0.gguf", - ChatTemplate: "chatml", NoJinja: true), + NoThinking: true), new LlamaCppModel("Qwen 3.5 9B (Q4_K_M)", "Qwen_Qwen3.5-9B-Q4_K_M.gguf", "5.7 GB", "https://huggingface.co/bartowski/Qwen_Qwen3.5-9B-GGUF/resolve/main/Qwen_Qwen3.5-9B-Q4_K_M.gguf", - ChatTemplate: "chatml", NoJinja: true), + NoThinking: true), new LlamaCppModel("Qwen 3.5 9B (Q8_0)", "Qwen_Qwen3.5-9B-Q8_0.gguf", "9.8 GB", "https://huggingface.co/bartowski/Qwen_Qwen3.5-9B-GGUF/resolve/main/Qwen_Qwen3.5-9B-Q8_0.gguf", - ChatTemplate: "chatml", NoJinja: true), + NoThinking: true), // MoE, ~3B active - fast on CPU; see the note in TranslateModels for the 2-bit caveat. new LlamaCppModel("Qwen 3.6 35B-A3B (IQ2_M) - fast on CPU, 2-bit quality", "Qwen3.6-35B-A3B-UD-IQ2_M.gguf", "11.5 GB", "https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF/resolve/main/Qwen3.6-35B-A3B-UD-IQ2_M.gguf", - ChatTemplate: "chatml", NoJinja: true), + NoThinking: true), new LlamaCppModel("Gemma 3 4B it (Q4_K_M)", "google_gemma-3-4b-it-Q4_K_M.gguf", "2.5 GB", "https://huggingface.co/bartowski/google_gemma-3-4b-it-GGUF/resolve/main/google_gemma-3-4b-it-Q4_K_M.gguf", ChatTemplate: "gemma", NoJinja: true), @@ -396,12 +404,14 @@ public static bool IsModelInstalled(string fileName) /// Picks the llama-server chat-template flags for a .gguf we do not curate (a file the user /// downloaded themselves, e.g. a TranslateGemma quant or size we do not offer). A curated entry with /// the same file name wins; otherwise the family is guessed from the file name, because getting this - /// wrong is not cosmetic: every Gemma we ship needs gemma + --no-jinja (TranslateGemma's - /// embedded Jinja template is non-standard) and every Qwen needs chatml + --no-jinja (to - /// bypass the embedded template's thinking mode, which otherwise emits <think> blocks instead of a - /// translation). Families with a usable embedded template (Aya, Llama, EuroLLM, Phi) fall through to - /// the default of no override. Gemma 4 keeps its embedded template but needs - /// --reasoning off instead (NoThinking), for the reason documented on + /// wrong is not cosmetic: every Gemma (2/3) we ship needs gemma + --no-jinja + /// (TranslateGemma's embedded Jinja template is non-standard), and every Qwen needs + /// --reasoning off (NoThinking) to suppress the hybrid template's thinking mode, which + /// otherwise emits <think> blocks instead of a translation - see the note on the curated Qwen + /// entries in for why this is NoThinking and not a + /// chatml/--no-jinja template override. Families with a usable embedded template (Aya, + /// Llama, EuroLLM, Phi) fall through to the default of no override. Gemma 4 also keeps its embedded + /// template and needs --reasoning off, for the same reason documented on /// . /// public static (string? ChatTemplate, bool NoJinja, bool NoThinking) InferChatTemplate(string fileName) @@ -429,7 +439,7 @@ public static (string? ChatTemplate, bool NoJinja, bool NoThinking) InferChatTem if (fileName.Contains("qwen", StringComparison.OrdinalIgnoreCase)) { - return ("chatml", true, false); + return (null, false, true); } return (null, false, false); diff --git a/tests/libuilogic/AutoTranslate/ChatGptTranslateRemovePreambleTests.cs b/tests/libuilogic/AutoTranslate/ChatGptTranslateRemovePreambleTests.cs new file mode 100644 index 00000000000..c8cebbdc7aa --- /dev/null +++ b/tests/libuilogic/AutoTranslate/ChatGptTranslateRemovePreambleTests.cs @@ -0,0 +1,53 @@ +using Nikse.SubtitleEdit.UiLogic.AutoTranslate; + +namespace LibUiLogicTests.AutoTranslate; + +/// +/// is the shared post-processing step every +/// OpenAI-compatible engine (ChatGPT, DeepSeek, Groq, Ollama, llama.cpp, ...) runs a raw model +/// response through. A thinking model that closes its <think> block behaves fine; one that +/// gets cut off mid-thought (hits its token budget before ever reaching the translation - the +/// Qwen 3.5 leakage bug, see the note on the curated Qwen entries in LlamaCppServerManager) used +/// to pass the raw reasoning text straight through as if it were the translation. +/// +public class ChatGptTranslateRemovePreambleTests +{ + [Fact] + public void ClosedThinkBlock_IsStrippedFromTranslation() + { + var result = ChatGptTranslate.RemovePreamble("Hello", "reasoning hereHej"); + + Assert.Equal("Hej", result); + } + + /// + /// The model hit its token budget before closing the tag: what follows "<think>" is raw + /// internal monologue, not a translation. Returning it unstripped ships garbage into the + /// subtitle file (#Qwen 3.5 thinking-leakage bug); returning empty instead lets the caller's + /// existing retry-on-no-progress logic (DoAutoTranslate) re-request the line. + /// + [Fact] + public void UnterminatedThinkBlock_ReturnsEmpty() + { + var result = ChatGptTranslate.RemovePreamble("Hello", + "\nThinking Process:\n1. **Analyze the Request:**\n * Source Text:"); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void NoThinkBlock_IsUnaffected() + { + var result = ChatGptTranslate.RemovePreamble("Hello", "Hej"); + + Assert.Equal("Hej", result); + } + + [Fact] + public void HerePreamble_IsStrippedAfterThinkBlockRemoval() + { + var result = ChatGptTranslate.RemovePreamble("Hello", "xHere is the translation: Hej"); + + Assert.Equal("Hej", result); + } +} diff --git a/tests/libuilogic/AutoTranslate/LlamaCppServerArgumentsTests.cs b/tests/libuilogic/AutoTranslate/LlamaCppServerArgumentsTests.cs index 5a45e9fba61..ee8e11f4498 100644 --- a/tests/libuilogic/AutoTranslate/LlamaCppServerArgumentsTests.cs +++ b/tests/libuilogic/AutoTranslate/LlamaCppServerArgumentsTests.cs @@ -118,4 +118,26 @@ public void CuratedGemma4Models_AllDisableThinking() Assert.NotEmpty(gemma4); Assert.All(gemma4, m => Assert.True(m.NoThinking, m.DisplayName + " must set NoThinking")); } + + /// + /// A controlled A/B (seconv EN->DA, 10 reps x 20 lines on the Q8_0 9B, 200 lines/variant) + /// measured the old "chatml" + "--no-jinja" override leaking raw <think> text into 2.5% + /// of lines, while NoThinking alone stayed at 0% on both the 9B and 4B quants - and stacking + /// both overrides was worse than either alone (6.5% on 9B, 16.7% on 4B). Every curated Qwen + /// 3/3.5/3.6 entry must therefore use NoThinking and must not also force the chatml template. + /// + [Fact] + public void CuratedQwenModels_AllDisableThinkingAndNeverForceChatml() + { + var qwen = LlamaCppServerManager.TranslateModels + .Concat(LlamaCppServerManager.ReviewModels) + .Concat(LlamaCppServerManager.OcrModels) + .Where(m => m.FileName.Contains("qwen", System.StringComparison.OrdinalIgnoreCase)) + .ToList(); + + Assert.NotEmpty(qwen); + Assert.All(qwen, m => Assert.True(m.NoThinking, m.DisplayName + " must set NoThinking")); + Assert.All(qwen, m => Assert.Null(m.ChatTemplate)); + Assert.All(qwen, m => Assert.False(m.NoJinja)); + } } diff --git a/tests/seconv/Core/AutoTranslateRunnerTest.cs b/tests/seconv/Core/AutoTranslateRunnerTest.cs index d911c33d8a3..d7de1d7dbdf 100644 --- a/tests/seconv/Core/AutoTranslateRunnerTest.cs +++ b/tests/seconv/Core/AutoTranslateRunnerTest.cs @@ -154,8 +154,9 @@ public void Create_LlamaCppLocal_ModelByFullPath_InheritsCuratedTemplateFlags() Assert.NotNull(runner.LlamaCppModel); Assert.Equal(path, runner.LlamaCppModel!.FileName); - Assert.Equal("chatml", runner.LlamaCppModel.ChatTemplate); - Assert.True(runner.LlamaCppModel.NoJinja); + Assert.Null(runner.LlamaCppModel.ChatTemplate); + Assert.False(runner.LlamaCppModel.NoJinja); + Assert.True(runner.LlamaCppModel.NoThinking); } // A TranslateGemma size/quant we do not curate (issue #12440 asked for the 27B). Whether it is @@ -195,7 +196,7 @@ public void Create_LlamaCppLocal_UncuratedTranslateGemmaByFullPath_GetsGemmaTemp [InlineData("translategemma-4b_Q5_K_M.gguf", "gemma", true, false)] // curated: exact match [InlineData("translategemma-27b-it.Q4_K_M.gguf", "gemma", true, false)] // uncurated: inferred [InlineData("google_gemma-3-27b-it-Q4_K_M.gguf", "gemma", true, false)] - [InlineData("Qwen_Qwen3.5-32B-Q4_K_M.gguf", "chatml", true, false)] + [InlineData("Qwen_Qwen3.5-32B-Q4_K_M.gguf", null, false, true)] [InlineData("aya-expanse-8b-Q4_K_M.gguf", null, false, false)] // curated: embedded template [InlineData("Meta-Llama-3.1-70B-Instruct-Q4_K_M.gguf", null, false, false)] [InlineData("some-unknown-model.gguf", null, false, false)] From 720bbda04dad19aa38ef14ecbbc1bb5b6053eee6 Mon Sep 17 00:00:00 2001 From: Muaz Date: Mon, 24 Aug 2026 15:07:08 +0300 Subject: [PATCH 052/380] Add WhisperX as a standalone (no Python) speech-to-text engine Requiring a Python install plus a managed virtual environment was the reason an earlier version of this (#14031) was not accepted upstream. This packages WhisperX with PyInstaller instead - see https://github.com/muaz978/subtitleedit-whisperx-standalone - so it downloads and runs exactly like every other engine here, no separate Python setup required. Modeled on WhisperEngineCTranslate2 (the closest existing engine: also a downloaded prebuilt binary, same flat-unpack layout) rather than the prior managed-venv approach. WhisperX shells out to a real ffmpeg binary for audio loading, which is not bundled in the standalone build, so the process launch now puts Subtitle Edit's own configured ffmpeg on PATH for it. Verified end to end on macOS arm64 (VAD, transcription, and forced alignment with a real downloaded wav2vec2 model) and the standalone build's own CI now verifies the packaging itself - build, a real smoke-test transcription, and packaging - on Windows, macOS, and Linux. --- src/ui/Assets/SpeechToText/WhisperX.txt | 180 ++++++++++++++++++ .../DownloadSpeechToTextEngineViewModel.cs | 23 +++ .../Engines/WhisperEngineFactory.cs | 5 + .../Engines/WhisperEngineWhisperX.cs | 127 ++++++++++++ .../SpeechToText/SpeechToTextViewModel.cs | 67 ++++++- src/ui/Logic/Config/SeAudioToText.cs | 1 + .../Logic/Download/WhisperDownloadService.cs | 38 +++- .../Engines/WhisperEngineWhisperXTests.cs | 63 ++++++ 8 files changed, 499 insertions(+), 5 deletions(-) create mode 100644 src/ui/Assets/SpeechToText/WhisperX.txt create mode 100644 src/ui/Features/Video/SpeechToText/Engines/WhisperEngineWhisperX.cs create mode 100644 tests/UI/Features/Video/SpeechToText/Engines/WhisperEngineWhisperXTests.cs diff --git a/src/ui/Assets/SpeechToText/WhisperX.txt b/src/ui/Assets/SpeechToText/WhisperX.txt new file mode 100644 index 00000000000..f3228aa0056 --- /dev/null +++ b/src/ui/Assets/SpeechToText/WhisperX.txt @@ -0,0 +1,180 @@ +usage: whisperx-standalone [-h] [--model MODEL] + [--model_cache_only MODEL_CACHE_ONLY] + [--model_dir MODEL_DIR] [--device DEVICE] + [--device_index DEVICE_INDEX] + [--batch_size BATCH_SIZE] + [--compute_type {default,float16,float32,int8}] + [--output_dir OUTPUT_DIR] + [--output_format {all,srt,vtt,txt,tsv,json,aud}] + [--verbose VERBOSE] + [--log-level {debug,info,warning,error,critical}] + [--task {transcribe,translate}] + [--language LANGUAGE] + [--align_model ALIGN_MODEL] + [--interpolate_method {nearest,linear,ignore}] + [--no_align] [--return_char_alignments] + [--vad_method {pyannote,silero}] + [--vad_onset VAD_ONSET] [--vad_offset VAD_OFFSET] + [--chunk_size CHUNK_SIZE] [--diarize] + [--min_speakers MIN_SPEAKERS] + [--max_speakers MAX_SPEAKERS] + [--diarize_model DIARIZE_MODEL] + [--speaker_embeddings] [--temperature TEMPERATURE] + [--best_of BEST_OF] [--beam_size BEAM_SIZE] + [--patience PATIENCE] + [--length_penalty LENGTH_PENALTY] + [--suppress_tokens SUPPRESS_TOKENS] + [--suppress_numerals] + [--initial_prompt INITIAL_PROMPT] + [--hotwords HOTWORDS] + [--condition_on_previous_text CONDITION_ON_PREVIOUS_TEXT] + [--fp16 FP16] + [--temperature_increment_on_fallback TEMPERATURE_INCREMENT_ON_FALLBACK] + [--compression_ratio_threshold COMPRESSION_RATIO_THRESHOLD] + [--logprob_threshold LOGPROB_THRESHOLD] + [--no_speech_threshold NO_SPEECH_THRESHOLD] + [--max_line_width MAX_LINE_WIDTH] + [--max_line_count MAX_LINE_COUNT] + [--highlight_words HIGHLIGHT_WORDS] + [--segment_resolution {sentence,chunk}] + [--threads THREADS] [--hf_token HF_TOKEN] + [--print_progress PRINT_PROGRESS] [--version] + [--python-version] + audio [audio ...] + +positional arguments: + audio audio file(s) to transcribe + +options: + -h, --help show this help message and exit + --model MODEL name of the Whisper model to use (default: small) + --model_cache_only MODEL_CACHE_ONLY + If True, will not attempt to download models, instead + using cached models from --model_dir (default: False) + --model_dir MODEL_DIR + the path to save model files; uses ~/.cache/whisper by + default (default: None) + --device DEVICE device type to use for PyTorch inference (e.g. cpu, + cuda) (default: cpu) + --device_index DEVICE_INDEX + device index to use for FasterWhisper inference + (default: 0) + --batch_size BATCH_SIZE + the preferred batch size for inference (default: 8) + --compute_type {default,float16,float32,int8} + compute type for computation; 'default' uses float16 + on GPU, float32 on CPU (default: default) + --output_dir OUTPUT_DIR, -o OUTPUT_DIR + directory to save the outputs (default: .) + --output_format {all,srt,vtt,txt,tsv,json,aud}, -f {all,srt,vtt,txt,tsv,json,aud} + format of the output file (default: all) + --verbose VERBOSE whether to print out progress and debug messages + (default: True) + --log-level {debug,info,warning,error,critical} + log level (default: info) + --task {transcribe,translate} + whether to transcribe or translate to English + (default: transcribe) + --language LANGUAGE language spoken in the audio; specify None to perform + language detection (default: None) + --align_model ALIGN_MODEL + name of phoneme-level ASR model to do alignment + (default: None) + --interpolate_method {nearest,linear,ignore} + for word .srt, method to assign timestamps to non- + aligned words, or merge them into neighbouring + (default: nearest) + --no_align do not perform alignment (default: False) + --return_char_alignments + return character-level alignments in the output json + file (default: False) + --vad_method {pyannote,silero} + VAD method to be used (default: pyannote) + --vad_onset VAD_ONSET + onset threshold for VAD (see pyannote.audio pipelines) + (default: 0.5) + --vad_offset VAD_OFFSET + offset threshold for VAD (see pyannote.audio + pipelines) (default: 0.363) + --chunk_size CHUNK_SIZE + Chunk size for merging VAD segments (default: 30) + --diarize apply diarization to assign speaker labels to each + segment/word (default: False) + --min_speakers MIN_SPEAKERS + minimum number of speakers to in audio file (default: + None) + --max_speakers MAX_SPEAKERS + maximum number of speakers to in audio file (default: + None) + --diarize_model DIARIZE_MODEL + Name of the diarization model to use (default: + pyannote/speaker-diarization-3.1) + --speaker_embeddings Include speaker embeddings in JSON output (only works + with --diarize too) (default: False) + --temperature TEMPERATURE + temperature to use for sampling (default: 0) + --best_of BEST_OF number of candidates when sampling with non-zero + temperature (default: 5) + --beam_size BEAM_SIZE + number of beams in beam search, only applicable when + temperature is zero (default: 5) + --patience PATIENCE optional patience value to use in beam decoding + (default: 1.0) + --length_penalty LENGTH_PENALTY + optional token length penalty coefficient (alpha) + (default: 1.0) + --suppress_tokens SUPPRESS_TOKENS + comma-separated list of token ids to suppress during + sampling (default: -1) + --suppress_numerals whether to suppress numeric symbols and currency + symbols during sampling, since wav2vec2 cannot align + them correctly (default: False) + --initial_prompt INITIAL_PROMPT + optional text to provide as a prompt for the first + window (default: None) + --hotwords HOTWORDS hotwords/hint phrases for the model (default: None) + --condition_on_previous_text CONDITION_ON_PREVIOUS_TEXT + if True, provide the previous output as a prompt for + the next window (default: False) + --fp16 FP16 whether to perform inference in fp16 (default: True) + --temperature_increment_on_fallback TEMPERATURE_INCREMENT_ON_FALLBACK + temperature to increase when falling back (default: + 0.2) + --compression_ratio_threshold COMPRESSION_RATIO_THRESHOLD + if gzip compression ratio is higher than this, treat + decoding as failed (default: 2.4) + --logprob_threshold LOGPROB_THRESHOLD + if average log probability is lower than this, treat + decoding as failed (default: -1.0) + --no_speech_threshold NO_SPEECH_THRESHOLD + if the no-speech probability is higher than this and + decoding has failed, consider the segment as silence + (default: 0.6) + --max_line_width MAX_LINE_WIDTH + the maximum number of characters in a line before + breaking (default: None) + --max_line_count MAX_LINE_COUNT + the maximum number of lines in a segment (default: + None) + --highlight_words HIGHLIGHT_WORDS + underline each word as it is spoken in srt/vtt + (default: False) + --segment_resolution {sentence,chunk} + the resolution of the segments (default: sentence) + --threads THREADS number of threads used by torch for CPU inference + (default: 0) + --hf_token HF_TOKEN Hugging Face access token, needed to run diarization + (gated models) (default: ) + --print_progress PRINT_PROGRESS + if True, prints progress in transcribing/aligning + (default: False) + --version show program's version number and exit + --python-version print the Python interpreter version used to build + this executable, then exit + +Notes for the standalone build (https://github.com/muaz978/subtitleedit-whisperx-standalone): +- No Python install is required - this is a self-contained build. +- Requires ffmpeg on PATH (Subtitle Edit passes its own bundled copy automatically). +- Whisper and alignment models download from Hugging Face on first use. +- --diarize needs a Hugging Face token with access to the gated pyannote + speaker-diarization model, passed via --hf_token. diff --git a/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextEngineViewModel.cs b/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextEngineViewModel.cs index 7031650cf25..6c113fa05ee 100644 --- a/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextEngineViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/DownloadSpeechToTextEngineViewModel.cs @@ -214,6 +214,25 @@ private void ProcessDownloadState() OkPressed = true; Close(); } + else if (Engine.Name == WhisperEngineWhisperX.StaticName) + { + var dir = Engine.GetAndCreateWhisperFolder(); + + TitleText = string.Format(Se.Language.General.UnpackingX, Engine.Name); + StartIndeterminateProgress(); + Unpack(dir, string.Empty); + StopIndeterminateProgress(); + + 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) @@ -551,6 +570,10 @@ public void StartDownload() { _downloadTask = _whisperDownloadService.DownloadWhisperCTranslate2(_downloadStream, downloadProgress, _cancellationTokenSource.Token); } + else if (Engine is WhisperEngineWhisperX) + { + _downloadTask = _whisperDownloadService.DownloadWhisperX(_downloadStream, downloadProgress, _cancellationTokenSource.Token); + } else if (Engine is WhisperEngineConstMe) { _downloadTask = _whisperDownloadService.DownloadWhisperConstMe(_downloadStream, downloadProgress, _cancellationTokenSource.Token); 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..3872db0a7ec --- /dev/null +++ b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineWhisperX.cs @@ -0,0 +1,127 @@ +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(); + public List Models => new WhisperModel().Models.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) + { + var folder = new WhisperModel().ModelFolder; + Directory.CreateDirectory(folder); + return folder; + } + + // 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 on first use. Do not route its model name through SE's .pt/.bin downloader. + return IsEngineInstalled(); + } + + 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; + + public string DownloadSizeText + { + get + { + if (OperatingSystem.IsWindows()) + { + return "~750 MB"; + } + + if (OperatingSystem.IsMacOS()) + { + return "~300 MB"; + } + + if (OperatingSystem.IsLinux()) + { + return "~750 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/SpeechToTextViewModel.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs index c461f1e0e0c..87edaafae78 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs @@ -255,6 +255,15 @@ 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. + if (OperatingSystem.IsWindows() || + (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) @@ -3737,7 +3746,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 { @@ -3750,6 +3763,8 @@ private static Process StartEngineProcess(string executable, string arguments, D } }; + configureStartInfo?.Invoke(p.StartInfo); + if (dataReceivedHandler != null) { p.StartInfo.StandardOutputEncoding = Encoding.UTF8; @@ -3793,6 +3808,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 @@ -3803,7 +3847,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; } /// @@ -3846,6 +3890,25 @@ 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}\""; + + Se.WriteToolsLog($"{exe} {parametersX}"); + return StartEngineProcess(exe, parametersX, dataReceivedHandler, AddFfmpegToPath); + } + if (engine is Qwen3AsrCppEngine qwen3Asr) { var exe = qwen3Asr.GetExecutable(); diff --git a/src/ui/Logic/Config/SeAudioToText.cs b/src/ui/Logic/Config/SeAudioToText.cs index e60be20d3f8..abc2552b340 100644 --- a/src/ui/Logic/Config/SeAudioToText.cs +++ b/src/ui/Logic/Config/SeAudioToText.cs @@ -43,6 +43,7 @@ public class SeAudioToText 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; diff --git a/src/ui/Logic/Download/WhisperDownloadService.cs b/src/ui/Logic/Download/WhisperDownloadService.cs index 0f79f8364a0..7d152da74fd 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(Stream stream, IProgress? progress, CancellationToken cancellationToken); Task DownloadSileroVad(Stream stream, IProgress? progress, CancellationToken cancellationToken); } @@ -46,7 +47,13 @@ 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"; - + + // "latest" (not a pinned tag) so every release of the standalone build - see + // https://github.com/muaz978/subtitleedit-whisperx-standalone - is picked up automatically. + private const string MacArmWhisperX = "https://github.com/muaz978/subtitleedit-whisperx-standalone/releases/latest/download/whisperx-standalone-macos-arm64.zip"; + private const string LinuxWhisperX = "https://github.com/muaz978/subtitleedit-whisperx-standalone/releases/latest/download/whisperx-standalone-linux-x64.zip"; + private const string WindowsWhisperX = "https://github.com/muaz978/subtitleedit-whisperx-standalone/releases/latest/download/whisperx-standalone-windows-x64.zip"; + public WhisperDownloadService(HttpClient httpClient) { httpClient.DefaultRequestHeaders.UserAgent.ParseAdd( @@ -107,6 +114,11 @@ public async Task DownloadWhisperCTranslate2(Stream stream, Progress pro await DownloadHelper.DownloadFileAsync(_httpClient, GetUrlTranslate2(), stream, progress, cancellationToken); } + public async Task DownloadWhisperX(Stream stream, IProgress? progress, CancellationToken cancellationToken) + { + await DownloadHelper.DownloadFileAsync(_httpClient, GetUrlWhisperX(), stream, progress, cancellationToken); + } + public async Task DownloadSileroVad(Stream stream, IProgress? progress, CancellationToken cancellationToken) { await DownloadHelper.DownloadFileAsync(_httpClient, SileroVadUrl, stream, progress, cancellationToken); @@ -118,12 +130,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 +144,26 @@ private static string GetUrlTranslate2() throw new PlatformNotSupportedException(); } + private static string GetUrlWhisperX() + { + if (OperatingSystem.IsWindows()) + { + 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/tests/UI/Features/Video/SpeechToText/Engines/WhisperEngineWhisperXTests.cs b/tests/UI/Features/Video/SpeechToText/Engines/WhisperEngineWhisperXTests.cs new file mode 100644 index 00000000000..a8db5af58bd --- /dev/null +++ b/tests/UI/Features/Video/SpeechToText/Engines/WhisperEngineWhisperXTests.cs @@ -0,0 +1,63 @@ +using System.Linq; +using Nikse.SubtitleEdit.Features.Video.SpeechToText.Engines; +using Nikse.SubtitleEdit.UiLogic.AudioToText; + +namespace UITests.Features.Video.SpeechToText.Engines; + +public class WhisperEngineWhisperXTests +{ + [Fact] + public void FactoryCreatesWhisperXEngine() + { + var engine = WhisperEngineFactory.MakeEngineFromStaticName(WhisperEngineWhisperX.StaticName); + + Assert.IsType(engine); + Assert.Equal(WhisperChoice.WhisperX, engine.Choice); + Assert.True(engine.CanBeDownloaded()); + } + + [Fact] + public void LanguageCatalogIncludesLanguagesBeyondTheOriginalWhisperXSubset() + { + var engine = new WhisperEngineWhisperX(); + var languageCodes = engine.Languages.Select(p => p.Code).ToHashSet(); + + Assert.Contains("tr", languageCodes); + Assert.Contains("ar", languageCodes); + Assert.Contains("ru", languageCodes); + Assert.Contains("fa", languageCodes); + Assert.True(languageCodes.Count > 50); + } + + [Fact] + public void ParametersCanSelectReliableMacCpuMode() + { + var engine = new WhisperEngineWhisperX(); + var original = engine.CommandLineParameter; + + try + { + engine.CommandLineParameter = "--device cpu --compute_type int8"; + Assert.Contains("--device cpu", engine.CommandLineParameter); + Assert.Contains("--compute_type int8", engine.CommandLineParameter); + } + finally + { + engine.CommandLineParameter = original; + } + } + + [Fact] + public void ExecutableLivesDirectlyInsideTheWhisperXFolder() + { + // The standalone build's zip is unpacked flat (Unpack(dir, string.Empty)), so the + // executable and its "_internal" PyInstaller payload land directly in the engine + // folder - same layout as WhisperEngineCTranslate2, not a nested subfolder. + var engine = new WhisperEngineWhisperX(); + + var executable = engine.GetExecutable(); + var folder = engine.GetAndCreateWhisperFolder(); + + Assert.Equal(folder, System.IO.Path.GetDirectoryName(executable)); + } +} From 4800fec240ef20a25d866cbbf974743872d0d909 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 16:04:32 +0200 Subject: [PATCH 053/380] Keep the original text when a dialog's result is applied (#14053) Multiple replace put its result into the grid with SetSubtitles, which clears the rows and builds new ones from the paragraphs. The original text of a translation lives on the row and nowhere else - the original file is serialized from it in GetUpdateSubtitleOriginal - so every OK and every Apply emptied the original column, and saving the original then wrote empty lines. Fix common errors, Fix Netflix errors and AI review had the same hole in ApplyFixedSubtitle: it only updated the rows in place while the line count was unchanged, and fell back to the same SetSubtitles reset as soon as a rule removed a line (FixEmptyLines, Subtitle.RemoveEmptyLines) - or as soon as display-only original rows made the counts differ at all. The dialog's result is now applied onto the rows it came from, matched by paragraph id: the ids are captured with the subtitle handed to the dialog (GetUpdateSubtitleWithRowMap) and survive the round trip, as the dialogs copy their subtitle with generateNewId: false - Multiple replace's Apply did not, and now does. Rows are updated in place, deleted lines remove exactly their row, added lines come in as new rows, and display-only original rows are put back where they sat. A result with unknown ids and an unchanged line count still maps positionally, so no dialog can end up worse off than before. Co-Authored-By: Claude Opus 5 --- .../MultipleReplaceViewModel.cs | 8 +- src/ui/Features/Main/MainViewModel.cs | 227 ++++++++++-- .../Edit/MultipleReplaceParagraphIdTests.cs | 116 ++++++ .../Main/ApplyFixedSubtitleRowMappingTests.cs | 343 ++++++++++++++++++ .../FixCommonErrorsParagraphIdTests.cs | 72 ++++ 5 files changed, 732 insertions(+), 34 deletions(-) create mode 100644 tests/UI/Features/Edit/MultipleReplaceParagraphIdTests.cs create mode 100644 tests/UI/Features/Main/ApplyFixedSubtitleRowMappingTests.cs create mode 100644 tests/UI/Features/Tools/FixCommonErrors/FixCommonErrorsParagraphIdTests.cs diff --git a/src/ui/Features/Edit/MultipleReplace/MultipleReplaceViewModel.cs b/src/ui/Features/Edit/MultipleReplace/MultipleReplaceViewModel.cs index 47b95d23b2a..71852614029 100644 --- a/src/ui/Features/Edit/MultipleReplace/MultipleReplaceViewModel.cs +++ b/src/ui/Features/Edit/MultipleReplace/MultipleReplaceViewModel.cs @@ -1,4 +1,4 @@ -using Avalonia; +using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; @@ -287,8 +287,10 @@ private void Apply() // Apply the currently checked replacements to the document, then make the result the new // working subtitle so the next round operates on the already-fixed text - without closing // the window (Subtitle Edit 4 had this re-usable "Apply" button - #12029). - OnApply?.Invoke(new Subtitle(FixedSubtitle), Fixes.Count(f => f.Apply)); - _subtitle = new Subtitle(FixedSubtitle); + // generateNewId: false - the paragraph ids are how the main window finds the grid row each + // line came from, so they must survive every Apply round (#14053). + OnApply?.Invoke(new Subtitle(FixedSubtitle, false), Fixes.Count(f => f.Apply)); + _subtitle = new Subtitle(FixedSubtitle, false); _dirty = true; GeneratePreview(); } diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 055196c2434..b5b77346149 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -7155,8 +7155,10 @@ private async Task ShowToolsAiReview() var idx = SelectedSubtitleIndex ?? 0; - // Same filter as GetUpdateSubtitle() below, so index N of the reviewed subtitle is line N - // here - that mapping is what lets the review window play the line of a suggestion. + var subtitle = GetUpdateSubtitleWithRowMap(out var rowByParagraphId); + + // Same filter as GetUpdateSubtitle(), so index N of the reviewed subtitle is line N here - + // that mapping is what lets the review window play the line of a suggestion. var reviewedLines = Subtitles.Where(s => !s.IsReferenceOnly).ToList(); // Apply/Ok: each Apply pushes the checked fixes into the grid and the review window stays // open with the rest of the suggestions, so a review that took minutes is not spent on one @@ -7175,11 +7177,11 @@ void ApplyToGrid(Subtitle applied) } } - ApplyFixedSubtitle(applied, idx, SelectedSubtitleFormat); + ApplyFixedSubtitle(applied, rowByParagraphId, idx, SelectedSubtitleFormat); ShowStatus(string.Format(Se.Language.Main.FixedXLines, changed)); - // Re-fill the same list instance the play hook closed over: with reference-only rows in - // the grid, ApplyFixedSubtitle rebuilds it and the rows captured before are detached. + // Re-fill the same list instance the play hook closed over, in case a row of it is no + // longer in the grid. var current = Subtitles.Where(s => !s.IsReferenceOnly).ToList(); reviewedLines.Clear(); reviewedLines.AddRange(current); @@ -7187,7 +7189,7 @@ void ApplyToGrid(Subtitle applied) await ShowDialogAsync(vm => { - vm.Initialize(GetUpdateSubtitle(), SelectedSubtitleFormat, MakeReviewLinePlayer(reviewedLines), StopReviewLinePlayback, ApplyToGrid); + vm.Initialize(subtitle, SelectedSubtitleFormat, MakeReviewLinePlayer(reviewedLines), StopReviewLinePlayback, ApplyToGrid); }); } @@ -7279,11 +7281,12 @@ private async Task ShowToolsFixCommonErrors() } var idx = SelectedSubtitleIndex ?? 0; - var viewModel = await ShowDialogAsync(vm => { vm.Initialize(GetUpdateSubtitle(), SelectedSubtitleFormat); }); + var subtitle = GetUpdateSubtitleWithRowMap(out var rowByParagraphId); + var viewModel = await ShowDialogAsync(vm => { vm.Initialize(subtitle, SelectedSubtitleFormat); }); if (viewModel.OkPressed) { - ApplyFixedSubtitle(viewModel.FixedSubtitle, idx, SelectedSubtitleFormat); + ApplyFixedSubtitle(viewModel.FixedSubtitle, rowByParagraphId, idx, SelectedSubtitleFormat); ShowStatus(string.Format(Se.Language.Main.FixedXLines, viewModel.FixedSubtitle.Paragraphs.Count)); } } @@ -7303,11 +7306,12 @@ private async Task ShowToolsFixNetflixErrors() } var idx = SelectedSubtitleIndex ?? 0; - var viewModel = await ShowDialogAsync(vm => { vm.Initialize(GetUpdateSubtitle(), _videoFileName ?? string.Empty); }); + var subtitle = GetUpdateSubtitleWithRowMap(out var rowByParagraphId); + var viewModel = await ShowDialogAsync(vm => { vm.Initialize(subtitle, _videoFileName ?? string.Empty); }); if (viewModel.OkPressed) { - ApplyFixedSubtitle(viewModel.FixedSubtitle, idx, SelectedSubtitleFormat); + ApplyFixedSubtitle(viewModel.FixedSubtitle, rowByParagraphId, idx, SelectedSubtitleFormat); ShowStatus(string.Format(Se.Language.Main.FixedXLines, viewModel.FixedSubtitle.Paragraphs.Count)); } } @@ -14366,29 +14370,26 @@ private async Task ShowMultipleReplace() return; } + var subtitle = GetUpdateSubtitleWithRowMap(out var rowByParagraphId); var result = await ShowDialogAsync(vm => { - vm.Initialize(GetUpdateSubtitle()); + vm.Initialize(subtitle); // "Apply" applies the replacements live without closing, so several rounds can be run (#12029). vm.OnApply = (fixedSubtitle, count) => { - // Replacements are 1:1 with the lines they came from, so stay on the line the - // user was on - jumping to the top on every Apply lost their place, and Apply + // The result goes back on the rows it came from, so the user stays on the line + // they were on - jumping to the top on every Apply lost their place, and Apply // is meant to be used several times in a row (issue #13822). - var selectedIndex = SelectedSubtitleIndex ?? 0; - SetSubtitles(fixedSubtitle); - SelectAndScrollToRow(Math.Min(selectedIndex, Subtitles.Count - 1)); + ApplyFixedSubtitle(fixedSubtitle, rowByParagraphId, SelectedSubtitleIndex ?? 0, SelectedSubtitleFormat); ShowStatus(string.Format(Se.Language.Main.ReplacedXOccurrences, count)); }; }); if (result.OkPressed) { - var selectedIndex = SelectedSubtitleIndex ?? 0; - SetSubtitles(result.FixedSubtitle); - SelectAndScrollToRow(Math.Min(selectedIndex, Subtitles.Count - 1)); + ApplyFixedSubtitle(result.FixedSubtitle, rowByParagraphId, SelectedSubtitleIndex ?? 0, SelectedSubtitleFormat); ShowStatus(string.Format(Se.Language.Main.ReplacedXOccurrences, result.TotalReplaced)); } } @@ -18832,26 +18833,190 @@ private void CenterSelectedRowInSubtitleGrid(SubtitleLineViewModel itemToCenter) } /// - /// Applies the fixed subtitle returned by Fix Common Errors / Fix Netflix Errors. - /// When the paragraph count is unchanged (the common case), updates each - /// SubtitleLineViewModel in-place so the grid keeps its current scroll position - /// and selection without any manipulation. - /// Falls back to a full SetSubtitles reset only when rows were added or removed. + /// for a dialog that edits the subtitle, plus the row mapping + /// + /// needs to put the dialog's result back on the rows it came from. + /// + private Subtitle GetUpdateSubtitleWithRowMap(out Dictionary rowByParagraphId) + { + var subtitle = GetUpdateSubtitle(); + rowByParagraphId = MapParagraphIdsToRows(subtitle); + return subtitle; + } + + /// + /// Pairs the working rows with the paragraphs just built from + /// them - one paragraph per non-reference row, in row order. The dialogs copy their input with + /// new Subtitle(subtitle, generateNewId: false), so these ids survive the round trip and + /// still name a row in the result, whatever the dialog did to the line count. /// - private void ApplyFixedSubtitle(Subtitle fixedSubtitle, int selectedIndex, SubtitleFormat? subtitleFormat) + private Dictionary MapParagraphIdsToRows(Subtitle subtitle) { - if (fixedSubtitle.Paragraphs.Count == Subtitles.Count) + var map = new Dictionary(subtitle.Paragraphs.Count); + var index = 0; + foreach (var row in Subtitles) { - for (var i = 0; i < Subtitles.Count; i++) - Subtitles[i].UpdateFrom(fixedSubtitle.Paragraphs[i], subtitleFormat); + if (row.IsReferenceOnly) + { + continue; + } + + if (index >= subtitle.Paragraphs.Count) + { + break; + } + + if (subtitle.Paragraphs[index].Id is { } id) + { + map[id] = row; + } + + index++; + } + + return map; + } + + /// + /// Applies the subtitle a dialog returned (Fix common errors, Fix Netflix errors, AI review, + /// Multiple replace) onto the rows it was built from, matched by paragraph id. + /// + /// The rows are updated in place and never rebuilt. A rebuild drops everything a row holds + /// beside the paragraph - the original/translation text above all, which lives on the row and + /// nowhere else ( serializes the file from it), so + /// replacing the rows emptied the original column of a translation and then wrote empty lines + /// when it was saved (#14053). Display-only original rows went the same way, as they are not + /// part of the dialog's subtitle at all. + /// + /// Deletions are honored by removing exactly the rows whose paragraph is gone - Fix common + /// errors removes empty lines, Fix Netflix errors calls RemoveEmptyLines - and the display-only + /// rows are put back where they sat. + /// + private void ApplyFixedSubtitle( + Subtitle fixedSubtitle, + IReadOnlyDictionary rowByParagraphId, + int selectedIndex, + SubtitleFormat? subtitleFormat) + { + // Only rows still in the grid may be reused: after an "Apply" that removed lines, the map + // still holds the rows that went with them. + var live = new HashSet(Subtitles); + var workingRows = Subtitles.Where(p => !p.IsReferenceOnly).ToList(); + + // A dialog that copied its subtitle with fresh ids would match nothing and every line would + // come in as a new row. As long as the line count still matches the grid, fall back to the + // positional mapping this method used before the ids were tracked - rebuilding the rows is + // the one outcome worth avoiding. + if (fixedSubtitle.Paragraphs.Count == workingRows.Count && + !fixedSubtitle.Paragraphs.Any(p => p.Id is { } id && rowByParagraphId.ContainsKey(id))) + { + var positional = new Dictionary(workingRows.Count); + for (var i = 0; i < workingRows.Count; i++) + { + if (fixedSubtitle.Paragraphs[i].Id is { } id) + { + positional[id] = workingRows[i]; + } + } + + rowByParagraphId = positional; + } + + var working = new List(fixedSubtitle.Paragraphs.Count); + var kept = new HashSet(); + foreach (var p in fixedSubtitle.Paragraphs) + { + // No id match means the dialog added the line, or a merge consumed the row that owned + // the id - either way there is no row to keep, so it comes in as a new row. + if (p.Id is { } id && + rowByParagraphId.TryGetValue(id, out var row) && + live.Contains(row) && + kept.Add(row)) + { + row.UpdateFrom(p, subtitleFormat); + working.Add(row); + } + else + { + working.Add(new SubtitleLineViewModel(p, subtitleFormat ?? SelectedSubtitleFormat)); + } + } + + // Each display-only row follows the working row it sat behind; ones that came before any + // surviving row go back to the front. + var leadingReferenceRows = new List(); + var referenceRowsAfter = new Dictionary>(); + SubtitleLineViewModel? anchor = null; + foreach (var row in Subtitles) + { + if (row.IsReferenceOnly) + { + if (anchor == null) + { + leadingReferenceRows.Add(row); + } + else + { + if (!referenceRowsAfter.TryGetValue(anchor, out var rows)) + { + rows = new List(); + referenceRowsAfter[anchor] = rows; + } + + rows.Add(row); + } + } + else if (kept.Contains(row)) + { + anchor = row; + } + } + + var result = new List(working.Count + leadingReferenceRows.Count); + result.AddRange(leadingReferenceRows); + foreach (var row in working) + { + result.Add(row); + if (referenceRowsAfter.TryGetValue(row, out var rows)) + { + result.AddRange(rows); + } + } + + if (SameRows(result)) + { + // The common case: same rows in the same order, so the grid keeps its scroll position + // and selection with no manipulation at all (#13822). Renumber(); UpdateGaps(); + return; } - else + + ReplaceSubtitles(result); + Renumber(); + UpdateGaps(); + if (Subtitles.Count > 0) { - SetSubtitles(fixedSubtitle); - SelectAndScrollToRow(Math.Min(selectedIndex, Subtitles.Count - 1)); + SelectAndScrollToRow(Math.Clamp(selectedIndex, 0, Subtitles.Count - 1)); + } + } + + private bool SameRows(List rows) + { + if (rows.Count != Subtitles.Count) + { + return false; } + + for (var i = 0; i < rows.Count; i++) + { + if (!ReferenceEquals(rows[i], Subtitles[i])) + { + return false; + } + } + + return true; } /// 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/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/Tools/FixCommonErrors/FixCommonErrorsParagraphIdTests.cs b/tests/UI/Features/Tools/FixCommonErrors/FixCommonErrorsParagraphIdTests.cs new file mode 100644 index 00000000000..ccd769edb6d --- /dev/null +++ b/tests/UI/Features/Tools/FixCommonErrors/FixCommonErrorsParagraphIdTests.cs @@ -0,0 +1,72 @@ +using Avalonia.Headless.XUnit; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.Forms.FixCommonErrors; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Features.Tools.FixCommonErrors; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Dictionaries; + +namespace UITests.Features.Tools.FixCommonErrors; + +/// +/// The paragraph ids of the subtitle the window was handed are how the main window finds the grid +/// row each line came from, so the fixed subtitle must carry them back - the lines that survive a +/// rule which removes lines above all, since those rows hold the original text of a translation and +/// would otherwise be rebuilt empty (#14053). +/// +public class FixCommonErrorsParagraphIdTests : IDisposable +{ + private readonly List _originalProfiles; + + public FixCommonErrorsParagraphIdTests() + { + // Se.Settings is static and starts out without profiles in tests; the scan does nothing + // without one. Restored in Dispose so no other test sees this profile. + _originalProfiles = Se.Settings.Tools.FixCommonErrors.Profiles; + Se.Settings.Tools.FixCommonErrors.Profiles = new List + { + new() + { + ProfileName = "Default", + SelectedRules = new List { nameof(FixEmptyLines) }, + }, + }; + } + + public void Dispose() + { + Se.Settings.Tools.FixCommonErrors.Profiles = _originalProfiles; + } + + [AvaloniaFact] + public async Task RemovedEmptyLine_KeepsTheIdsOfTheLinesThatStay() + { + var subtitle = new Subtitle(); + subtitle.Paragraphs.Add(new Paragraph("Hello there", 0, 2000)); + subtitle.Paragraphs.Add(new Paragraph(string.Empty, 2500, 4000)); + subtitle.Paragraphs.Add(new Paragraph("Goodbye there", 4500, 6000)); + subtitle.Renumber(); + var ids = subtitle.Paragraphs.Select(p => p.Id).ToList(); + + var vm = new FixCommonErrorsViewModel(new FakeNamesList(), null!, null!); + vm.Initialize(subtitle, new SubRip()); + await vm.DoRefreshFixes(); + await vm.ApplySelectedFixes(); + + Assert.Equal( + new[] { "Hello there", "Goodbye there" }, + vm.FixedSubtitle.Paragraphs.Select(p => p.Text)); + Assert.Equal(new[] { ids[0], ids[2] }, vm.FixedSubtitle.Paragraphs.Select(p => p.Id)); + } + + private sealed class FakeNamesList : INamesList + { + public void Load(string dictionaryFolder, string languageCode) + { + } + + public bool IsName(string candidate) => false; + + public HashSet GetAbbreviations() => new(); + } +} From 5cc9df7dd05a214ba5d649a218a03bec3e1b9c5a Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 16:18:50 +0200 Subject: [PATCH 054/380] Add "List errors..." to the Tools menu SE4 had "List errors..." in the Tools menu; in SE5 the command survived only as the Ctrl/Cmd+F8 shortcut, so it was unreachable unless you already knew it existed (raised in PR #14019). Added to both menu builders - the macOS NSMenuBar in InitNativeMacMenu.cs is a hand-maintained mirror of InitMenu.cs, so it needs the entry too. Both lists sort alphabetically at build time, so it lands between "Fix common errors..." and "Make new empty translation...". The shortcut gesture is picked up automatically from the shortcut settings. Co-Authored-By: Claude Opus 5 --- src/ui/Features/Main/Layout/InitMenu.cs | 5 +++++ src/ui/Features/Main/Layout/InitNativeMacMenu.cs | 1 + src/ui/Logic/Config/Language/Main/LanguageMainMenu.cs | 2 ++ 3 files changed, 8 insertions(+) diff --git a/src/ui/Features/Main/Layout/InitMenu.cs b/src/ui/Features/Main/Layout/InitMenu.cs index 0024162aed7..c480fa12057 100644 --- a/src/ui/Features/Main/Layout/InitMenu.cs +++ b/src/ui/Features/Main/Layout/InitMenu.cs @@ -488,6 +488,11 @@ public static void Make(MainViewModel vm) Command = vm.ShowToolsFixNetflixErrorsCommand, }, new MenuItem + { + Header = l.ListErrors, + Command = vm.ListErrorsCommand, + }, + new MenuItem { Header = l.AiReview, Command = vm.ShowToolsAiReviewCommand, diff --git a/src/ui/Features/Main/Layout/InitNativeMacMenu.cs b/src/ui/Features/Main/Layout/InitNativeMacMenu.cs index 1df81dcd156..b17ff1e3795 100644 --- a/src/ui/Features/Main/Layout/InitNativeMacMenu.cs +++ b/src/ui/Features/Main/Layout/InitNativeMacMenu.cs @@ -292,6 +292,7 @@ public static void MakeStructure(NativeMenu root, Window window) Item(Clean(l.ChangeFormatting), v => v.ShowToolsChangeFormattingCommand), Item(Clean(l.FixCommonErrors), v => v.ShowToolsFixCommonErrorsCommand), Item(Clean(l.CheckAndFixNetflixErrors), v => v.ShowToolsFixNetflixErrorsCommand), + Item(Clean(l.ListErrors), v => v.ListErrorsCommand), Item(Clean(l.AiReview), v => v.ShowToolsAiReviewCommand), Item(Clean(l.MakeEmptyTranslationFromCurrentSubtitle), v => v.ToolsMakeEmptyTranslationFromCurrentSubtitleCommand), Item(Clean(l.MergeLinesWithSameText), v => v.ShowToolsMergeLinesWithSameTextCommand), diff --git a/src/ui/Logic/Config/Language/Main/LanguageMainMenu.cs b/src/ui/Logic/Config/Language/Main/LanguageMainMenu.cs index a3e1ffe1d36..5915ba6b00e 100644 --- a/src/ui/Logic/Config/Language/Main/LanguageMainMenu.cs +++ b/src/ui/Logic/Config/Language/Main/LanguageMainMenu.cs @@ -52,6 +52,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; } @@ -186,6 +187,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..."; From a0816416d9174d224b53706b6d8dc9592c6ef4c8 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 16:20:38 +0200 Subject: [PATCH 055/380] Add missing "Remove/replace Unicode characters..." to the macOS Tools menu The macOS NSMenuBar is a hand-maintained mirror of InitMenu.cs and this Tools entry was never carried over, so it was unreachable on macOS. Co-Authored-By: Claude Opus 5 --- src/ui/Features/Main/Layout/InitNativeMacMenu.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/Features/Main/Layout/InitNativeMacMenu.cs b/src/ui/Features/Main/Layout/InitNativeMacMenu.cs index b17ff1e3795..63646a6c6e0 100644 --- a/src/ui/Features/Main/Layout/InitNativeMacMenu.cs +++ b/src/ui/Features/Main/Layout/InitNativeMacMenu.cs @@ -306,6 +306,7 @@ public static void MakeStructure(NativeMenu root, Window window) Item(Clean(l.Renumber), v => v.ShowToolsRenumberCommand), Item(Clean(l.RemoveTextForHearingImpaired), v => v.ShowToolsRemoveTextForHearingImpairedCommand), Item(Clean(l.ConvertActors), v => v.ShowToolsConvertActorsCommand), + Item(Clean(l.RemoveUnicodeCharacters), v => v.ShowToolsRemoveUnicodeCharactersCommand), }; var toolItems = new NativeMenu(); foreach (var toolItem in toolsList.OrderBy(i => i.Header?.Replace("_", string.Empty))) From 0ee78b6c3e077d7b463e386a2f8f93e54cbfcb01 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 16:30:22 +0200 Subject: [PATCH 056/380] Add the three menu entries macOS was missing, and a test to keep it that way The macOS NSMenuBar in InitNativeMacMenu.cs is a hand-maintained mirror of InitMenu.cs, and three entries had drifted out of it: - SSA tools (Styles / Properties / Attachments), the .ssa sibling of the ASSA menu. Mac users editing a plain SSA file had no menu route to those dialogs at all - the ASSA menu is gated on IsFormatAssa, so nothing took its place. - File > Export > IMSC image. - Video > More > Auto cast from video (#13698). The gaps are invisible from a build or a run, so they are added here together with a parity test that compares the command set of the two builders. Both are pure builders whose entries all name a MainViewModel command, so a one-sided add now fails a test instead of shipping. Deliberate differences are listed with their reason: Exit (macOS gets Quit in the app menu from Avalonia) and the parameterised audio-track command. Presence only is checked, never placement - which submenu an item lives in legitimately differs between the two. Verified the test is not vacuous: dropping the new IMSC line again makes it fail naming that exact command. Co-Authored-By: Claude Opus 5 --- .../Features/Main/Layout/InitNativeMacMenu.cs | 18 ++++ .../Features/Main/MacNativeMenuParityTests.cs | 100 ++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 tests/UI/Features/Main/MacNativeMenuParityTests.cs diff --git a/src/ui/Features/Main/Layout/InitNativeMacMenu.cs b/src/ui/Features/Main/Layout/InitNativeMacMenu.cs index 63646a6c6e0..52f9ae2bfef 100644 --- a/src/ui/Features/Main/Layout/InitNativeMacMenu.cs +++ b/src/ui/Features/Main/Layout/InitNativeMacMenu.cs @@ -237,6 +237,7 @@ public static void MakeStructure(NativeMenu root, Window window) exportItems.Items.Add(Item(Se.Language.General.BluRaySup, v => v.ExportBluRaySupCommand)); exportItems.Items.Add(Item(Se.Language.General.BdnXml, v => v.ExportBdnXmlCommand)); exportItems.Items.Add(Item(Se.Language.General.BdnXml8Bit, v => v.ExportBdnXml8BitCommand)); + exportItems.Items.Add(Item(lExport.TitleExportImscImage, v => v.ExportImscImageCommand)); exportItems.Items.Add(Item(new CapMakerPlus().Name, v => v.ExportCapMakerPlusCommand)); exportItems.Items.Add(Item(CheetahCaption.NameOfFormat, v => v.ExportCheetahCaptionCommand)); exportItems.Items.Add(Item(CheetahCaptionOld.NameOfFormat, v => v.ExportCheetahCaptionOldCommand)); @@ -371,6 +372,10 @@ public static void MakeStructure(NativeMenu root, Window window) v => v.IsSubtitleSecondaryVisible, nameof(MainViewModel.IsSubtitleSecondaryVisible)), Item(Clean(lVideo.ReEncodeVideoForBetterSubtitlingDotDotDot), v => v.VideoReEncodeCommand), Item(Clean(lVideo.CutVideoDotDotDot), v => v.VideoCutCommand), + + // Finds who speaks in the video, clones each of them and assigns the cast, so the + // whole thing can be dubbed in its own voices (#13698). + Item(Clean(lVideo.TextToSpeech.AutoCastMenuItem), v => v.ShowVideoAutoCastFromVideoCommand), }; videoMoreList.Add(Toggle(Clean(l.WaveformToolbar), v => v.ToggleIsWaveformToolbarVisibleCommand, v => v.IsWaveformToolbarVisible, nameof(MainViewModel.IsWaveformToolbarVisible))); @@ -441,6 +446,18 @@ public static void MakeStructure(NativeMenu root, Window window) var assaMenu = new NativeMenuItem(Clean(l.AssaTools)) { Menu = assaItems }; state.Visibilities.Add((assaMenu, v => v.IsFormatAssa, [nameof(MainViewModel.IsFormatAssa)])); + // ── SSA Tools ───────────────────────────────────────────────────────── + // The old .ssa sibling of the ASSA menu: same three dialogs, but the SSA-specific + // commands, and never visible at the same time (IsFormatSsa vs IsFormatAssa). + // Kept in InitMenu's order rather than sorted - the headers are the shared Assa* + // strings, so sorting them here would only diverge from the non-macOS menu. + var ssaItems = new NativeMenu(); + ssaItems.Items.Add(Item(Clean(l.AssaStyles), v => v.ShowSsaStylesCommand)); + ssaItems.Items.Add(Item(Clean(l.AssaProperties), v => v.ShowSsaPropertiesCommand)); + ssaItems.Items.Add(Item(Clean(l.AssaAttachments), v => v.ShowSsaAttachmentsCommand)); + var ssaMenu = new NativeMenuItem(Clean(l.SsaTools)) { Menu = ssaItems }; + state.Visibilities.Add((ssaMenu, v => v.IsFormatSsa, [nameof(MainViewModel.IsFormatSsa)])); + // ── Assemble ────────────────────────────────────────────────────────── root.Items.Add(new NativeMenuItem(Clean(l.File)) { Menu = fileItems }); root.Items.Add(new NativeMenuItem(Clean(l.Edit)) { Menu = editItems }); @@ -461,6 +478,7 @@ public static void MakeStructure(NativeMenu root, Window window) root.Items.Add(new NativeMenuItem(Clean(l.HelpTitle)) { Menu = helpItems }); root.Items.Add(assaMenu); + root.Items.Add(ssaMenu); _building = null; diff --git a/tests/UI/Features/Main/MacNativeMenuParityTests.cs b/tests/UI/Features/Main/MacNativeMenuParityTests.cs new file mode 100644 index 00000000000..21a5b3eec83 --- /dev/null +++ b/tests/UI/Features/Main/MacNativeMenuParityTests.cs @@ -0,0 +1,100 @@ +using System.Text.RegularExpressions; + +namespace UITests.Features.Main; + +/// +/// The macOS NSMenuBar in InitNativeMacMenu.cs is a hand-maintained mirror of the +/// Avalonia menu in InitMenu.cs - Avalonia's NativeMenu is a separate API, so there is +/// no shared builder the two could grow from. Every menu entry added to one file has to +/// be added to the other by hand, and when that is forgotten the item is simply missing +/// on macOS with nothing to notice it: the app still builds, still runs, and the gap is +/// only found by someone using that menu on a Mac. +/// +/// This test is that missing signal. Both files are pure builders whose entries all name +/// a command on MainViewModel, so comparing the two command sets catches a one-sided add +/// at build time. It deliberately checks presence only, not placement: which submenu an +/// item lives in legitimately differs (macOS groups some entries differently, and the +/// native menus sort at build time), and pinning layout here would fight normal edits. +/// +public class MacNativeMenuParityTests +{ + // Matches the command references both builders use: + // InitMenu.cs Command = vm.ShowFindCommand + // InitNativeMacMenu.cs Item(..., v => v.ShowFindCommand) / state.Vm?.FilePropertiesShowCommand + // GetVm()?.ShowAboutCommand (the application-menu items) + // A receiver is required so type names (IRelayCommand, ICommand) never match. + private static readonly Regex CommandRegex = + new(@"(?:(? + /// In InitMenu.cs but intentionally not in the macOS menu bar. + /// + private static readonly HashSet AllowedMissingOnMac = + [ + // macOS puts Quit in the application menu, where Avalonia appends it (along with + // Hide / Hide Others / Show All) automatically. A second "Exit" in the File menu + // is exactly what a Mac user does not expect. + "CommandExitCommand", + ]; + + /// + /// In InitNativeMacMenu.cs but intentionally not in InitMenu.cs. + /// + private static readonly HashSet AllowedMacOnly = + [ + // The audio-track submenu is rebuilt per track. NativeMenuItem has no ICommand + // binding, so the macOS side routes every track through one parameterised command, + // while InitMenu.cs builds bound MenuItems in MainViewModel instead. + "PickAudioTrackCommand", + ]; + + [Fact] + public void EveryMenuCommand_IsInBothMenuBuilders() + { + var crossPlatform = ExtractCommands("InitMenu.cs"); + var mac = ExtractCommands("InitNativeMacMenu.cs"); + + Assert.NotEmpty(crossPlatform); + Assert.NotEmpty(mac); + + var missingOnMac = crossPlatform.Except(mac).Except(AllowedMissingOnMac).Order().ToList(); + var macOnly = mac.Except(crossPlatform).Except(AllowedMacOnly).Order().ToList(); + + Assert.True( + missingOnMac.Count == 0 && macOnly.Count == 0, + "The macOS menu bar and the Avalonia menu are out of sync." + + Environment.NewLine + + "Add the entry to the other builder, or - if the difference is deliberate - " + + "list the command in AllowedMissingOnMac / AllowedMacOnly with the reason." + + Environment.NewLine + + $"In InitMenu.cs but missing from InitNativeMacMenu.cs: {Format(missingOnMac)}" + + Environment.NewLine + + $"In InitNativeMacMenu.cs but missing from InitMenu.cs: {Format(macOnly)}"); + } + + private static string Format(List commands) + { + return commands.Count == 0 ? "(none)" : string.Join(", ", commands); + } + + private static HashSet ExtractCommands(string fileName) + { + var path = Path.Combine(FindRepoRoot(), "src", "ui", "Features", "Main", "Layout", fileName); + Assert.True(File.Exists(path), $"Menu builder not found: {path}"); + + return CommandRegex.Matches(File.ReadAllText(path)) + .Select(m => m.Groups[1].Value) + .ToHashSet(); + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "src", "ui"))) + { + dir = dir.Parent; + } + + return dir?.FullName ?? throw new DirectoryNotFoundException("Could not find repo root"); + } +} From b1915a6be4e213f57c62ac2b6a8abfa8562f4bbb Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 16:40:26 +0200 Subject: [PATCH 057/380] macOS: add the Window-menu staples, so Cmd+M and Cmd+W finally do something AppKit gives an app no default Window menu and no default key equivalents: on macOS a shortcut like Cmd+M or Cmd+W works only where a menu item carries it. SE authored neither, so both keys were simply dead - unlike Hide and Quit, which Avalonia appends to the application menu for us. Added Minimize (Cmd+M) and Zoom to the Window menu, and Close window (Cmd+W) to the File menu, which is where macOS keeps it. Zoom is the green button's resize, not full screen, so it toggles Avalonia's Maximized, which maps onto that same zoomed state. The items are authored at build time, above the window list: once MacWindowsMenuInterop hands the menu to AppKit, AppKit appends its own window entries below whatever the menu already holds - the standard layout. The manual fallback in UpdateWindowMenus clears the whole menu on each refresh, so it re-adds them there too. Each item acts on its own menu bar's window (macOS shows the focused window's menu bar) and does nothing while a modal dialog is open: the dialog leaves its owner input-disabled but still showing the menu bar, so an unguarded Cmd+W would close the window out from under the open dialog. These carry no view-model command - they act on the window - so the parity test cannot see them; MacWindowMenuItemsTests pins them instead, and runs on any OS since the builder is platform-independent (only its caller is macOS-only). Co-Authored-By: Claude Opus 5 --- .../Features/Main/Layout/InitNativeMacMenu.cs | 68 +++++++++++++++ .../Config/Language/Main/LanguageMainMenu.cs | 9 ++ .../Features/Main/MacWindowMenuItemsTests.cs | 86 +++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 tests/UI/Features/Main/MacWindowMenuItemsTests.cs diff --git a/src/ui/Features/Main/Layout/InitNativeMacMenu.cs b/src/ui/Features/Main/Layout/InitNativeMacMenu.cs index 52f9ae2bfef..e926e0ffb00 100644 --- a/src/ui/Features/Main/Layout/InitNativeMacMenu.cs +++ b/src/ui/Features/Main/Layout/InitNativeMacMenu.cs @@ -183,6 +183,8 @@ public static void MakeStructure(NativeMenu root, Window window) fileItems.Items.Add(Conditional(Clean(l.NewKeepVideo), v => v.CommandFileNewKeepVideoCommand, v => v.IsVideoLoaded, nameof(MainViewModel.IsVideoLoaded))); fileItems.Items.Add(Item(Clean(l.NewWindow), v => v.CommandFileNewWindowCommand)); + fileItems.Items.Add(WindowActionItem(Clean(l.CloseWindow), state, + new KeyGesture(Key.W, KeyModifiers.Meta), w => w.Close())); fileItems.Items.Add(new NativeMenuItemSeparator()); fileItems.Items.Add(Item(Clean(l.Open), v => v.CommandFileOpenCommand)); fileItems.Items.Add(Conditional(Clean(l.OpenKeepVideo), v => v.CommandFileOpenKeepVideoCommand, @@ -474,6 +476,7 @@ public static void MakeStructure(NativeMenu root, Window window) // the Dock icon's window list), so this menu is the discoverable way to switch // between the windows File > New window opens. state.WindowListItem = new NativeMenuItem(Clean(l.WindowTitle)) { Menu = new NativeMenu() }; + AddStandardWindowItems(state.WindowListItem.Menu, state); root.Items.Add(state.WindowListItem); root.Items.Add(new NativeMenuItem(Clean(l.HelpTitle)) { Menu = helpItems }); @@ -485,6 +488,70 @@ public static void MakeStructure(NativeMenu root, Window window) UpdateWindowMenus(); } + /// + /// Minimize / Zoom / Close window, the Window-menu staples AppKit expects every app to + /// author for itself. Their key equivalents exist only where a menu item carries them, so + /// without these items ⌘M and ⌘W do nothing at all in SE - there is no fallback for a + /// missing NSMenuItem the way there is for the app menu's Hide and Quit, which Avalonia + /// appends. + /// + /// Added at build time so they sit above the window list: AppKit appends its own entries + /// below whatever the menu already holds when hands it + /// over, which is the standard layout. The manual fallback in + /// clears the whole menu on every refresh, so it re-adds them there too. + /// + /// + private static void AddStandardWindowItems(NativeMenu menu, MenuState state) + { + var l = Se.Language.Main.Menu; + + // Zoom is AppKit's performZoom: - the green button's resize, not full screen. + // Avalonia's Maximized maps onto that same zoomed state, and toggling matches + // performZoom:, which returns a zoomed window to its user size. + menu.Items.Add(WindowActionItem(Clean(l.WindowMinimize), state, + new KeyGesture(Key.M, KeyModifiers.Meta), w => w.WindowState = WindowState.Minimized)); + menu.Items.Add(WindowActionItem(Clean(l.WindowZoom), state, gesture: null, + w => w.WindowState = w.WindowState == WindowState.Maximized + ? WindowState.Normal + : WindowState.Maximized)); + menu.Items.Add(new NativeMenuItemSeparator()); + } + + /// + /// A menu item that acts on this menu bar's own window rather than on a view-model command. + /// macOS shows the focused window's menu bar, so is always the + /// window the user means. + /// + private static NativeMenuItem WindowActionItem( + string header, MenuState state, KeyGesture? gesture, Action action) + { + var item = new NativeMenuItem(header); + if (gesture != null) + { + item.Gesture = gesture; + } + + item.Click += (_, _) => + { + if (state.Window is not { } window) + { + return; + } + + // A modal dialog leaves its owner input-disabled but still showing the menu bar, so + // an unguarded ⌘W here would close the window out from under the open dialog (#13405 + // is the same "keys land in the main window" trap from the other direction). + if (WindowService.IsModalDialogOpen) + { + return; + } + + action(window); + }; + + return item; + } + // Rebuilds every window's "Window" menu so all menu bars list all open editor // windows. Called when a window opens, closes, or is activated (activation also // refreshes titles, which change when a file is opened or saved). @@ -505,6 +572,7 @@ private static void UpdateWindowMenus() } menu.Items.Clear(); + AddStandardWindowItems(menu, menuState); foreach (var other in _states) { var window = other.Window; diff --git a/src/ui/Logic/Config/Language/Main/LanguageMainMenu.cs b/src/ui/Logic/Config/Language/Main/LanguageMainMenu.cs index 5915ba6b00e..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; } @@ -150,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..."; diff --git a/tests/UI/Features/Main/MacWindowMenuItemsTests.cs b/tests/UI/Features/Main/MacWindowMenuItemsTests.cs new file mode 100644 index 00000000000..ab805549922 --- /dev/null +++ b/tests/UI/Features/Main/MacWindowMenuItemsTests.cs @@ -0,0 +1,86 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Nikse.SubtitleEdit.Features.Main.Layout; +using Nikse.SubtitleEdit.Logic.Config; + +namespace UITests.Features.Main; + +/// +/// Minimize, Zoom and Close window in the macOS menu bar. AppKit gives an app no default +/// Window menu and no default ⌘M / ⌘W: a key equivalent works only where a menu item +/// carries it, so before these items existed both shortcuts were simply dead in SE, with +/// nothing in a build or a run to show it. The items carry no view-model command either - +/// they act on the window directly - so the source-level check in +/// cannot see them, and this test pins them instead. +/// +/// The builder itself is platform-independent (only its caller in Program.cs is macOS-only), +/// so the structure is asserted on any OS - which matters, because CI runs tests on Linux. +/// +public class MacWindowMenuItemsTests : IDisposable +{ + private readonly List _windows = new(); + + public void Dispose() + { + // InitNativeMacMenu keeps one MenuState per window and drops it on Closed; leaving a + // window open would leak that state into the next test's menu bar. + foreach (var window in _windows) + { + window.Close(); + } + + _windows.Clear(); + } + + [AvaloniaFact] + public void WindowMenu_HasMinimizeAndZoom_WithCommandM() + { + var l = Se.Language.Main.Menu; + var windowMenu = BuildMenu().First(i => i.Header == Strip(l.WindowTitle)); + + var minimize = windowMenu.Menu!.Items.OfType() + .First(i => i.Header == Strip(l.WindowMinimize)); + Assert.Equal(new KeyGesture(Key.M, KeyModifiers.Meta), minimize.Gesture); + + var zoom = windowMenu.Menu!.Items.OfType() + .FirstOrDefault(i => i.Header == Strip(l.WindowZoom)); + Assert.NotNull(zoom); + + // Both sit above the window list: AppKit appends its window entries below whatever the + // menu already holds, which is the standard macOS Window-menu layout. + Assert.Equal(0, windowMenu.Menu!.Items.IndexOf(minimize)); + Assert.Equal(1, windowMenu.Menu!.Items.IndexOf(zoom!)); + } + + [AvaloniaFact] + public void FileMenu_HasCloseWindow_WithCommandW() + { + var l = Se.Language.Main.Menu; + var fileMenu = BuildMenu().First(i => i.Header == Strip(l.File)); + + var close = fileMenu.Menu!.Items.OfType() + .First(i => i.Header == Strip(l.CloseWindow)); + + // ⌘W closes a window on macOS and lives in File, not in the Window menu. + Assert.Equal(new KeyGesture(Key.W, KeyModifiers.Meta), close.Gesture); + } + + private List BuildMenu() + { + var window = new Window(); + _windows.Add(window); + + var root = new NativeMenu(); + InitNativeMacMenu.MakeStructure(root, window); + + return root.Items.OfType().ToList(); + } + + // The menu builder strips the access-key underscores the language strings carry for the + // Windows/Linux menu, so headers have to be compared the same way. + private static string Strip(string header) + { + return header.Replace("_", string.Empty); + } +} From 4bb2d65ca3fa92d26a31e3648210d3045a501619 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 16:41:58 +0200 Subject: [PATCH 058/380] macOS: correct the bundle's minimum system version and document types Three things in Info.plist had fallen behind what the app actually is. LSMinimumSystemVersion said 10.15. The .NET runtime we ship is built against macOS 12 (LC_BUILD_VERSION minos 12.0 in libcoreclr.dylib), so on anything older the app cannot load at all - the stale key only meant those users got a crash instead of Finder's "requires a newer version of macOS". Set to 12.0, with the otool command to re-derive it after a TFM bump. Ten subtitle extensions the app reads were missing, so Finder never offered SE for them: ttml, dfxp, itt, smi, scc, mcc, cap, pac, lrc and idx. Each is backed by a format in libse (TimedText10, ItunesTimedText, Sami, ScenaristClosedCaptions, MacCaption10, CheetahCaption/CapMakerPlus, Pac, Lrc, Idx). .xml moved out of the subtitle entry into its own, at Alternate rank. SE reads several XML subtitle formats, but at Owner rank it was claiming every XML file on the machine ahead of whatever really owns it. Alternate keeps SE in "Open With" without taking the default. Verified with plutil -lint, and that update-plist-version.sh still sets both version keys on the edited file (PlistBuddy rewrites the plist, so the comments are repo-only - the built bundle is unaffected). Co-Authored-By: Claude Opus 5 --- .../SubtitleEdit.app/Contents/Info.plist | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/installer/macBundle/SubtitleEdit.app/Contents/Info.plist b/installer/macBundle/SubtitleEdit.app/Contents/Info.plist index 120286a6157..6b8c8392048 100644 --- a/installer/macBundle/SubtitleEdit.app/Contents/Info.plist +++ b/installer/macBundle/SubtitleEdit.app/Contents/Info.plist @@ -20,8 +20,13 @@ SBED CFBundleIconFile SE.icns + LSMinimumSystemVersion - 10.15 + 12.0 NSHighResolutionCapable NSSupportsAutomaticGraphicsSwitching @@ -41,7 +46,16 @@ vtt sbv stl - xml + ttml + dfxp + itt + smi + scc + mcc + cap + pac + lrc + idx CFBundleTypeName Subtitle Files @@ -50,6 +64,22 @@ LSHandlerRank Owner + + + CFBundleTypeExtensions + + xml + + CFBundleTypeName + XML Subtitle Files + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + CFBundleTypeExtensions From 787719178c31ab2dde002e0a0f1b5f7df2f3154d Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 16:51:18 +0200 Subject: [PATCH 059/380] Clear the last five build warnings, so a new one stands out The solution built with five warnings, all in libse and all long-standing. That is few enough that they are still noise rather than a wall - but it is exactly the level at which a genuinely new warning slips by unnoticed. This takes the whole solution to zero. Four CS8632: SubtitleFormat.FormatLimits and Cavena890's GreekLookup carried `?` nullable annotations, but libse has no setting, so the annotations are inert and only warn. Dropped, with the null contract each one documented instead of implied by a marker the compiler ignores. One xUnit2002: Assert.NotNull on TransportStreamSubtitle.GetScreenSize(), which returns an SKSize - a struct, so the assert could never fail and the analyzer is right that it says nothing. Replaced with the no-source fallback that method actually returns, so the line now tests something. Full suite still green: 5960 tests across libse, libuilogic, seconv and UI. Co-Authored-By: Claude Opus 5 --- src/libse/SubtitleFormats/Cavena890.cs | 11 +++++++---- src/libse/SubtitleFormats/SubtitleFormat.cs | 6 +++++- tests/libse/Core/BugHunt20260823Round3Test.cs | 9 ++++++++- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/libse/SubtitleFormats/Cavena890.cs b/src/libse/SubtitleFormats/Cavena890.cs index 92422c23969..62aa8c5ce72 100644 --- a/src/libse/SubtitleFormats/Cavena890.cs +++ b/src/libse/SubtitleFormats/Cavena890.cs @@ -515,12 +515,15 @@ public class Cavena890 : SubtitleFormat, IBinaryPersistableSubtitle // 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. - private static readonly string?[] GreekLookup = BuildGreekLookup(); + // 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() + private static string[] BuildGreekLookup() { - var table = new string?[256]; + var table = new string[256]; foreach (var entry in Greek) { table[entry.Item1] = entry.Item2; diff --git a/src/libse/SubtitleFormats/SubtitleFormat.cs b/src/libse/SubtitleFormats/SubtitleFormat.cs index cb7016407e6..764b494d842 100644 --- a/src/libse/SubtitleFormats/SubtitleFormat.cs +++ b/src/libse/SubtitleFormats/SubtitleFormat.cs @@ -565,7 +565,11 @@ public static int FramesToMillisecondsMax999(double frames) /// that declares them lets the UI warn before a save silently re-wraps or truncates text /// that does not fit. Null means no such limits. /// - public virtual SubtitleFormatLimits? FormatLimits => null; + /// + /// Declared without a nullable annotation: libse builds with nullable contexts off, so + /// the annotation is inert here and only warns (CS8632). The null contract is above. + /// + public virtual SubtitleFormatLimits FormatLimits => null; public bool BatchMode { get; set; } public double? BatchSourceFrameRate { get; set; } diff --git a/tests/libse/Core/BugHunt20260823Round3Test.cs b/tests/libse/Core/BugHunt20260823Round3Test.cs index 75935159ffe..a229953e3e2 100644 --- a/tests/libse/Core/BugHunt20260823Round3Test.cs +++ b/tests/libse/Core/BugHunt20260823Round3Test.cs @@ -1,6 +1,7 @@ using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.Core.ContainerFormats.Mp4.Boxes; using Nikse.SubtitleEdit.Core.ContainerFormats.TransportStream; +using SkiaSharp; namespace LibSETests.Core; @@ -98,7 +99,13 @@ public void TransportStreamSubtitle_WithNoSourceDoesNotThrow() var s = new TransportStreamSubtitle(); Assert.Equal(0, s.NumberOfImages); Assert.NotNull(s.GetBitmap()); - Assert.NotNull(s.GetScreenSize()); + + // GetScreenSize returns a struct, so "not null" asserted nothing at all (xUnit2002). + // Pin the no-source fallback it actually returns instead. + Assert.Equal( + new SKSize(DvbSubPes.DefaultScreenWidth, DvbSubPes.DefaultScreenHeight), + s.GetScreenSize()); + Assert.NotNull(s.GetPosition()); } } From 45b79afe7645223e3afb001b3334fb612bd69e96 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 16:52:34 +0200 Subject: [PATCH 060/380] Bump Paddle OCR to standalone v3.7.0 (PaddleOCR 3.7 / PP-OCRv6) The engine and the models cannot be bumped separately: v3.7.0 ships no PP-OCRv5 support-files bundle at all, and the PP-OCRv6 bundle it does ship has dropped PP-OCRv5_mobile_rec, PP-OCRv5_server_rec and en_PP-OCRv5_mobile_rec - exactly the models GetRecName asked for on Chinese, English and Japanese. Pointing only the URLs at the new tag would leave those languages resolving to folders that do not exist. PP-OCRv6 is one unified model for Chinese, English, Japanese and the Latin languages except Pali (_PPOCRV6_LANGS in PaddleOCR 3.7); no non-Latin script has a v6 model, so Arabic, Cyrillic, E-Slavic, Devanagari, Korean, Greek, Tamil, Telugu, Thai and Pali keep their PP-OCRv5 models and Georgian its PP-OCRv3 one - all still in the same bundle. v6 also replaced the mobile/server pair with tiny/small/medium tiers, so the stored mode maps onto the two tiers the bundle ships (mobile -> small, server -> medium). The install folder becomes OCR/PaddleOCR3-7 and PaddleOCR3-4 joins the legacy list, so the old install is removed once the new one succeeds. Everyone re-downloads once (~450 MB CPU, more for GPU). Paddle OCR Python uses the same downloaded models, so it now needs pip paddleocr 3.7 or newer - PaddleX resolves the model name through a registry and older versions raise "`PP-OCRv6_small_rec` is not registered on BasePredictor". That error now gets a hint telling the user to upgrade. Verified against the actual upstream release, not just the release notes: all eight pinned asset names exist in v3.7.0, the CPU engine archive wraps its content in "PaddleOCR-CPU-v3.7.0/paddleocr.exe" (the folder level the unpack strips), and the model lists in the tests are transcribed from the unpacked PP-OCRv6 bundle. Full UI test suite: 3787 passed, 0 failed. Co-Authored-By: Claude Opus 5 --- docs/reference/command-line.md | 2 +- docs/third-party-components.md | 10 ++- src/ui/Features/Ocr/Engines/PaddleOcr.cs | 74 +++++++++++++------ src/ui/Features/Ocr/OcrViewModel.cs | 15 ++++ src/ui/Logic/Config/Se.cs | 22 ++++-- .../Engines/PaddleOcrDownloadArchiveTests.cs | 2 +- .../Engines/PaddleOcrLanguageMappingTests.cs | 39 +++++++--- 7 files changed, 118 insertions(+), 46 deletions(-) diff --git a/docs/reference/command-line.md b/docs/reference/command-line.md index 0418d216315..001e75db3d5 100644 --- a/docs/reference/command-line.md +++ b/docs/reference/command-line.md @@ -235,7 +235,7 @@ An AVI stream header carries no language, so a multi-stream `.avi` names its out | `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`, …). | +| `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 | |---|---| diff --git a/docs/third-party-components.md b/docs/third-party-components.md index ce0d8d5b564..2ff38149cf5 100644 --- a/docs/third-party-components.md +++ b/docs/third-party-components.md @@ -37,7 +37,7 @@ Subtitle Edit stores these components in its **Data Folder**. | **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` | @@ -120,10 +120,12 @@ Use [Speech to Text](features/speech-to-text.md) for the current engine list and ### 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. diff --git a/src/ui/Features/Ocr/Engines/PaddleOcr.cs b/src/ui/Features/Ocr/Engines/PaddleOcr.cs index 9e5876fc24c..1632f19a394 100644 --- a/src/ui/Features/Ocr/Engines/PaddleOcr.cs +++ b/src/ui/Features/Ocr/Engines/PaddleOcr.cs @@ -35,7 +35,7 @@ public partial class PaddleOcr // The pinned PaddleOCR-Standalone release. Bumping it means updating this one line and // the file names below - and Se.PaddleOcrFolder when the underlying PaddleOCR version // changes, so engine and models never mix across releases. - private const string StandaloneRelease = "https://github.com/timminator/PaddleOCR-Standalone/releases/download/v1.4.0/"; + private const string StandaloneRelease = "https://github.com/timminator/PaddleOCR-Standalone/releases/download/v3.7.0/"; /// /// One downloadable Paddle OCR archive: the file(s) to fetch, and the folder level inside @@ -48,19 +48,19 @@ public static PaddleOcrArchive GetArchive(PaddleOcrDownloadType downloadType) { return downloadType switch { - PaddleOcrDownloadType.Models => Archive("PaddleOCR.PP-OCRv5.support.files.VideOCR.7z", "PaddleOCR.PP-OCRv5.support.files"), - PaddleOcrDownloadType.EngineCpu => Archive("PaddleOCR-CPU-v1.4.0.7z"), - PaddleOcrDownloadType.EngineGpu11 => Archive("PaddleOCR-GPU-v1.4.0-CUDA-11.8.7z"), - PaddleOcrDownloadType.EngineGpu12 => Archive("PaddleOCR-GPU-v1.4.0-CUDA-12.9.7z"), - PaddleOcrDownloadType.EngineCpuLinux => Archive("PaddleOCR-CPU-v1.4.0-Linux.7z"), - PaddleOcrDownloadType.EngineGpu11Linux => Archive("PaddleOCR-GPU-v1.4.0-CUDA-11.8-Linux.7z"), + PaddleOcrDownloadType.Models => Archive("PaddleOCR.PP-OCRv6.support.files.VideOCR.7z", "PaddleOCR.PP-OCRv6.support.files"), + PaddleOcrDownloadType.EngineCpu => Archive("PaddleOCR-CPU-v3.7.0.7z"), + PaddleOcrDownloadType.EngineGpu11 => Archive("PaddleOCR-GPU-v3.7.0-CUDA-11.8.7z"), + PaddleOcrDownloadType.EngineGpu12 => Archive("PaddleOCR-GPU-v3.7.0-CUDA-12.9.7z"), + PaddleOcrDownloadType.EngineCpuLinux => Archive("PaddleOCR-CPU-v3.7.0-Linux.7z"), + PaddleOcrDownloadType.EngineGpu11Linux => Archive("PaddleOCR-GPU-v3.7.0-CUDA-11.8-Linux.7z"), // Split into two volumes upstream. Both have to land in the same folder before the // .001 is handed to the extractor - the download queue takes care of that. PaddleOcrDownloadType.EngineGpu12Linux => Archive( - "PaddleOCR-GPU-v1.4.0-CUDA-12.9-Linux.7z.001", - "PaddleOCR-GPU-v1.4.0-CUDA-12.9-Linux", - "PaddleOCR-GPU-v1.4.0-CUDA-12.9-Linux.7z.002"), + "PaddleOCR-GPU-v3.7.0-CUDA-12.9-Linux.7z.001", + "PaddleOCR-GPU-v3.7.0-CUDA-12.9-Linux", + "PaddleOCR-GPU-v3.7.0-CUDA-12.9-Linux.7z.002"), _ => throw new ArgumentOutOfRangeException(nameof(downloadType), downloadType, "Unknown Paddle OCR download type"), }; @@ -83,7 +83,7 @@ private static PaddleOcrArchive Archive(string fileName, string? rootFolderInArc private const string TextlineOrientationModelName = "PP-LCNet_x1_0_textline_ori"; // The script groups below mirror LATIN_LANGS/ARABIC_LANGS/ESLAV_LANGS/CYRILLIC_LANGS/ - // DEVANAGARI_LANGS in PaddleOCR 3.4 (paddleocr/_pipelines/ocr.py) - the version the + // DEVANAGARI_LANGS in PaddleOCR 3.7 (paddleocr/_utils/langs.py) - the version the // bundled standalone engine is built from. Keep them in sync with GetLanguages(); a // code offered in the dropdown but missing from every group here silently falls // through to the Latin recognition model and OCRs to garbage. @@ -126,6 +126,31 @@ private static PaddleOcrArchive Archive(string fileName, string? rootFolderInArc "el", "ta", "te", "th" }; + // Pali is the one Latin language PP-OCRv6 does not cover, so it stays on the PP-OCRv5 + // Latin model (_PPOCRV6_UNSUPPORTED_LATIN_LANGS in paddleocr/_pipelines/ocr.py). + private const string PaliLanguageCode = "pi"; + + /// + /// True for the languages PP-OCRv6 (new in PaddleOCR 3.7) recognizes with its single + /// unified model: Chinese, English, Japanese and the Latin languages except Pali - the + /// _PPOCRV6_LANGS set in paddleocr/_pipelines/ocr.py. No non-Latin script has a v6 model + /// at all, so every other language keeps the PP-OCRv5 (Georgian: PP-OCRv3) models that + /// the support-files bundle still ships alongside the v6 pair. + /// + private static bool IsPpOcrV6Language(string language) + { + if (language is "ch" or "chinese_cht" or "en" or "japan") + { + return true; + } + + return LatinLanguageCodes.Contains(language) && language != PaliLanguageCode; + } + + // PP-OCRv6 replaced the mobile/server pair with tiny/small/medium tiers, and the bundle + // ships small and medium - so the saved mode picks between those two. + private static string PpOcrV6Tier(string mode) => mode == "server" ? "medium" : "small"; + internal static IReadOnlyCollection GetLatinLanguageCodesForTest() => LatinLanguageCodes; internal static IEnumerable GetAllScriptGroupCodesForTest() => @@ -148,19 +173,16 @@ public PaddleOcr() _cancellationToken = new CancellationToken(); } - // Only the recognition models shipped in "PaddleOCR.PP-OCRv5.support.files" are on + // Only the recognition models shipped in "PaddleOCR.PP-OCRv6.support.files" are on // disk - nothing is fetched per language. Returning a name that is not in that bundle // points at a folder that does not exist, and the run then fails when PaddleX tries to // read the model's inference.yml. internal static string GetRecName(string language, string mode) { string recName; - if (language == "ch" || - language == "chinese_cht" || - language == "en" || - language == "japan") + if (IsPpOcrV6Language(language)) { - recName = $"PP-OCRv5_{mode}_rec"; + recName = $"PP-OCRv6_{PpOcrV6Tier(mode)}_rec"; } else if (ArabicLanguageCodes.Contains(language)) { @@ -193,6 +215,8 @@ internal static string GetRecName(string language, string mode) } else { + // Pali, plus the safety net for a code no script group claims - the v6 bundle + // no longer has a general-purpose PP-OCRv5 model to fall back on. recName = "latin_PP-OCRv5_mobile_rec"; } @@ -201,10 +225,16 @@ internal static string GetRecName(string language, string mode) internal static string GetDetectionName(string language, string mode) { - // Georgian is the one remaining PP-OCRv3 language; everything else recognizes - // with a PP-OCRv5 model and detects with the matching PP-OCRv5 detector. - return language == "ka" - ? "PP-OCRv3_mobile_det" + // Georgian is the one remaining PP-OCRv3 language. + if (language == "ka") + { + return "PP-OCRv3_mobile_det"; + } + + // Detector and recognition model come from the same PaddleOCR generation, which is + // how upstream pairs them (PP-OCRv6 languages get the v6 detector of the same tier). + return IsPpOcrV6Language(language) + ? $"PP-OCRv6_{PpOcrV6Tier(mode)}_det" : $"PP-OCRv5_{mode}_det"; } @@ -1122,7 +1152,7 @@ private void OutputHandlerBatch(object sendingProcess, DataReceivedEventArgs out } - // Every language PaddleOCR 3.4 supports with a recognition model that ships in the + // Every language PaddleOCR 3.7 supports with a recognition model that ships in the // bundled support files. Adding a code here is enough to offer it - as long as the // code is also listed in the matching script group above, so GetRecName picks the // right model (PaddleOcrLanguageMappingTests guards that). diff --git a/src/ui/Features/Ocr/OcrViewModel.cs b/src/ui/Features/Ocr/OcrViewModel.cs index 3e93d93c6f0..be5618036e6 100644 --- a/src/ui/Features/Ocr/OcrViewModel.cs +++ b/src/ui/Features/Ocr/OcrViewModel.cs @@ -2805,6 +2805,21 @@ private void ShowPaddleOcrError(OcrEngineType engineType, string error) "Details:" + Environment.NewLine + error; } + else if (engineType == OcrEngineType.PaddleOcrPython && + error.Contains("PP-OCRv6", StringComparison.Ordinal) && + error.Contains("is not registered on", StringComparison.OrdinalIgnoreCase)) + { + // The downloaded models are PP-OCRv6 (PaddleOCR 3.7). PaddleX looks the model name + // up in a registry, so an older pip "paddleocr" fails with "`PP-OCRv6_small_rec` + // is not registered on BasePredictor" for every language v6 covers. + message = "Paddle OCR Python is too old for the downloaded models." + Environment.NewLine + + Environment.NewLine + + "The models are PP-OCRv6, which needs \"paddleocr\" 3.7 or newer:" + Environment.NewLine + + " python -m pip install --upgrade paddleocr" + Environment.NewLine + + Environment.NewLine + + "Details:" + Environment.NewLine + + error; + } Dispatcher.UIThread.Post(async void () => { diff --git a/src/ui/Logic/Config/Se.cs b/src/ui/Logic/Config/Se.cs index 405fbc8de95..812b9540d0a 100644 --- a/src/ui/Logic/Config/Se.cs +++ b/src/ui/Logic/Config/Se.cs @@ -184,17 +184,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"); diff --git a/tests/UI/Features/Ocr/Engines/PaddleOcrDownloadArchiveTests.cs b/tests/UI/Features/Ocr/Engines/PaddleOcrDownloadArchiveTests.cs index a6cf441ea9c..86f7ecd8346 100644 --- a/tests/UI/Features/Ocr/Engines/PaddleOcrDownloadArchiveTests.cs +++ b/tests/UI/Features/Ocr/Engines/PaddleOcrDownloadArchiveTests.cs @@ -75,7 +75,7 @@ public void EngineArchives_StripTheFolderNamedAfterTheArchive(PaddleOcrDownloadT { // The models archive is the exception: its root folder drops the ".VideOCR" part. var models = PaddleOcr.GetArchive(PaddleOcrDownloadType.Models); - Assert.Equal("PaddleOCR.PP-OCRv5.support.files", models.RootFolderInArchive); + Assert.Equal("PaddleOCR.PP-OCRv6.support.files", models.RootFolderInArchive); return; } diff --git a/tests/UI/Features/Ocr/Engines/PaddleOcrLanguageMappingTests.cs b/tests/UI/Features/Ocr/Engines/PaddleOcrLanguageMappingTests.cs index a4e6412eebd..49f0c52346e 100644 --- a/tests/UI/Features/Ocr/Engines/PaddleOcrLanguageMappingTests.cs +++ b/tests/UI/Features/Ocr/Engines/PaddleOcrLanguageMappingTests.cs @@ -11,17 +11,18 @@ namespace UITests.Features.Ocr.Engines; /// public class PaddleOcrLanguageMappingTests { - // Model folders shipped in "PaddleOCR.PP-OCRv5.support.files" (standalone v1.4.0), - // which is the only model source - nothing is downloaded per language. + // Model folders shipped in "PaddleOCR.PP-OCRv6.support.files" (standalone v3.7.0), which + // is the only model source - nothing is downloaded per language. Listed from the unpacked + // archive: the v6 bundle dropped the general-purpose PP-OCRv5 recognition models (mobile, + // server and en_), so anything still asking for one of those points at a missing folder. private static readonly HashSet BundledRecModels = new() { - "PP-OCRv5_mobile_rec", - "PP-OCRv5_server_rec", + "PP-OCRv6_medium_rec", + "PP-OCRv6_small_rec", "arabic_PP-OCRv5_mobile_rec", "cyrillic_PP-OCRv5_mobile_rec", "devanagari_PP-OCRv5_mobile_rec", "el_PP-OCRv5_mobile_rec", - "en_PP-OCRv5_mobile_rec", "eslav_PP-OCRv5_mobile_rec", "ka_PP-OCRv3_mobile_rec", "korean_PP-OCRv5_mobile_rec", @@ -36,6 +37,8 @@ public class PaddleOcrLanguageMappingTests "PP-OCRv3_mobile_det", "PP-OCRv5_mobile_det", "PP-OCRv5_server_det", + "PP-OCRv6_medium_det", + "PP-OCRv6_small_det", }; public static TheoryData LanguageAndMode() @@ -60,13 +63,29 @@ public void EveryOfferedLanguage_MapsToBundledModels(string code, string mode) [Theory] [MemberData(nameof(LanguageAndMode))] - public void OnlyLatinLanguages_UseTheLatinModel(string code, string mode) + public void OnlyPali_UsesTheLatinPpOcrV5Model(string code, string mode) { - var isLatinCode = PaddleOcr.GetLatinLanguageCodesForTest().Contains(code); + // PP-OCRv6 recognizes every Latin language except Pali, so the PP-OCRv5 Latin model is + // Pali's now - and still the fallback for a code no script group claims, which is what + // makes this the test that catches such a code (it would report "latin" but not be pi). + Assert.Equal(code == "pi", PaddleOcr.GetRecName(code, mode) == "latin_PP-OCRv5_mobile_rec"); + } + + [Theory] + [MemberData(nameof(LanguageAndMode))] + public void PpOcrV6Languages_UseTheUnifiedV6ModelPair(string code, string mode) + { + // _PPOCRV6_LANGS in PaddleOCR 3.7: Chinese, English, Japanese and the Latin languages + // except Pali. Everything else has no v6 model at all and must stay on PP-OCRv5/v3. + var isV6Language = + code is "ch" or "chinese_cht" or "en" or "japan" || + (PaddleOcr.GetLatinLanguageCodesForTest().Contains(code) && code != "pi"); + + // "mobile"/"server" is what the setting stores; v6 ships tiers instead. + var tier = mode == "server" ? "medium" : "small"; - // A code missing from every script group falls through to the Latin model, so - // "uses latin" and "is a Latin language" must be the same set. - Assert.Equal(isLatinCode, PaddleOcr.GetRecName(code, mode) == "latin_PP-OCRv5_mobile_rec"); + Assert.Equal(isV6Language, PaddleOcr.GetRecName(code, mode) == $"PP-OCRv6_{tier}_rec"); + Assert.Equal(isV6Language, PaddleOcr.GetDetectionName(code, mode) == $"PP-OCRv6_{tier}_det"); } [Fact] From 1bc623567b9613ee45a1d84a77698ece0c94cfb8 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 16:58:25 +0200 Subject: [PATCH 061/380] Keep subtitle times on whole milliseconds (#14056) Since .NET Core 3.0, TimeSpan.FromSeconds/FromMilliseconds truncate to ticks instead of rounding to whole milliseconds the way .NET Framework did, so TimeSpan.FromSeconds(0.82) is 819.9999 ms. A value like that reads back as "0,820" in the duration up/down (which rounds) but as "0,819" in the grid (which truncates, like every format writer), and the end time it produces really is a millisecond early - typing a duration of 0,820 at 00:11:22,700 hid the line at 00:11:23,519, and saved it that way. Pixel-derived waveform drags and video positions land on arbitrary fractions of a millisecond and produce the same split: Show and Hide look exact while the grid's duration reads one millisecond less than the field. Subtitle formats have no sub-millisecond resolution and neither does any time code SE shows, so snap on the way in: SubtitleLineViewModel's StartTime/EndTime/Duration are now hand-written (the generated [ObservableProperty] setter offers no coercion hook) and round every assignment to a whole millisecond. The duration field parses to whole milliseconds too, and moving a line on the waveform adds its duration as a whole-millisecond TimeSpan so the length cannot drift by a millisecond. Four existing tests asserted sub-millisecond precision that no longer survives - a dragged cue at microsecond resolution, frame-rate-scaled times to three decimals of a millisecond, and a fractional \fad duration; they now assert the whole-millisecond result. Co-Authored-By: Claude Opus 5 --- .../AudioVisualizerControl/AudioVisualizer.cs | 13 +- src/ui/Controls/SecondsUpDown.cs | 8 +- src/ui/Features/Main/SubtitleLineViewModel.cs | 55 ++++++- src/ui/Logic/TimeSpanExtensions.cs | 83 +++++++++++ .../AudioVisualizerShotChangeSnapTests.cs | 8 +- tests/UI/Features/Assa/AdvancedEffectTests.cs | 7 +- .../Features/Main/SubMillisecondTimeTests.cs | 135 ++++++++++++++++++ .../ChangeFrameRateViewModelTests.cs | 10 +- 8 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 tests/UI/Features/Main/SubMillisecondTimeTests.cs diff --git a/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs b/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs index 1827139c2db..ce48c2df351 100644 --- a/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs +++ b/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs @@ -1530,8 +1530,11 @@ private void OnPointerMoved(object? sender, PointerEventArgs e) if (_activeParagraph != null) { - _activeParagraph.StartTime = TimeSpan.FromSeconds(newStart); - _activeParagraph.EndTime = TimeSpan.FromSeconds(newStart + _originalDurationSeconds); + // Add the duration as a whole-millisecond TimeSpan rather than in seconds, so + // moving a line cannot round its length a millisecond up or down (#14056). + var movedStart = TimeSpanExtensions.FromSecondsWholeMilliseconds(newStart); + _activeParagraph.StartTime = movedStart; + _activeParagraph.EndTime = movedStart + TimeSpanExtensions.FromSecondsWholeMilliseconds(_originalDurationSeconds); } break; case InteractionMode.MovingSelection: @@ -1558,9 +1561,9 @@ private void OnPointerMoved(object? sender, PointerEventArgs e) foreach (var item in _selectionMoveSnapshot) { - var start = item.StartSeconds + shift; - item.Paragraph.StartTime = TimeSpan.FromSeconds(start); - item.Paragraph.EndTime = TimeSpan.FromSeconds(start + item.DurationSeconds); + var start = TimeSpanExtensions.FromSecondsWholeMilliseconds(item.StartSeconds + shift); + item.Paragraph.StartTime = start; + item.Paragraph.EndTime = start + TimeSpanExtensions.FromSecondsWholeMilliseconds(item.DurationSeconds); } break; diff --git a/src/ui/Controls/SecondsUpDown.cs b/src/ui/Controls/SecondsUpDown.cs index be415cd97b0..9295269b28f 100644 --- a/src/ui/Controls/SecondsUpDown.cs +++ b/src/ui/Controls/SecondsUpDown.cs @@ -7,6 +7,7 @@ using Avalonia.Layout; using Avalonia.Media; using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; using System; using System.Globalization; @@ -259,7 +260,7 @@ private static TimeSpan ParseTime(string text) int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var frames)) { var totalMs = seconds * 1000 + SubtitleFormat.FramesToMilliseconds(frames); - return TimeSpan.FromMilliseconds(totalMs); + return TimeSpanExtensions.FromMillisecondsWholeMilliseconds(totalMs); } } else @@ -267,7 +268,10 @@ private static TimeSpan ParseTime(string text) // Expect "seconds.ms" or "seconds,ms" if (double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds)) { - return TimeSpan.FromSeconds(seconds); + // Snap to a whole millisecond: TimeSpan.FromSeconds(0.82) is 819.9999 ms, which + // reads back as "0,820" here but as "0,819" in the grid, and ends the line a + // millisecond early (#14056). + return TimeSpanExtensions.FromSecondsWholeMilliseconds(seconds); } } diff --git a/src/ui/Features/Main/SubtitleLineViewModel.cs b/src/ui/Features/Main/SubtitleLineViewModel.cs index c088a81d23a..b7fccc43bfc 100644 --- a/src/ui/Features/Main/SubtitleLineViewModel.cs +++ b/src/ui/Features/Main/SubtitleLineViewModel.cs @@ -53,15 +53,58 @@ public partial class SubtitleLineViewModel : ObservableObject [ObservableProperty] private string? _bookmark; - [ObservableProperty] + // The three time properties are hand-written rather than [ObservableProperty] so that every + // assignment is snapped to a whole millisecond - the generated setter offers no coercion hook. + // Subtitle formats store whole milliseconds and every time code SE shows is a whole + // millisecond, but .NET Core's TimeSpan.FromSeconds/FromMilliseconds truncate to ticks, so a + // pixel-derived waveform drag or a typed "0,820" lands here as e.g. 819.9999 ms. Such a value + // renders one millisecond apart in the grid (which truncates) and in the duration up/down + // (which rounds), and saves a millisecond early - see issue #14056. private TimeSpan _startTime; - [ObservableProperty] + public TimeSpan StartTime + { + get => _startTime; + set + { + var snapped = value.SnapToWholeMilliseconds(); + if (SetProperty(ref _startTime, snapped)) + { + OnStartTimeChanged(snapped); + } + } + } + private TimeSpan _endTime; - [ObservableProperty] + public TimeSpan EndTime + { + get => _endTime; + set + { + var snapped = value.SnapToWholeMilliseconds(); + if (SetProperty(ref _endTime, snapped)) + { + OnEndTimeChanged(snapped); + } + } + } + private TimeSpan _duration; + public TimeSpan Duration + { + get => _duration; + set + { + var snapped = value.SnapToWholeMilliseconds(); + if (SetProperty(ref _duration, snapped)) + { + OnDurationChanged(snapped); + } + } + } + [ObservableProperty] [NotifyPropertyChangedFor(nameof(TeletextDisplay))] [NotifyPropertyChangedFor(nameof(TeletextTextAlignment))] @@ -917,7 +960,7 @@ internal void SetStartTimeKeepDuration(TimeSpan timeSpan) SetTimes(timeSpan, timeSpan + (EndTime - StartTime)); } - partial void OnStartTimeChanged(TimeSpan value) + private void OnStartTimeChanged(TimeSpan value) { OnPropertyChanged(nameof(StartTimeOnly)); OnPropertyChanged(nameof(StartTimeKeepDuration)); @@ -935,7 +978,7 @@ partial void OnStartTimeChanged(TimeSpan value) _skipUpdate = false; } - partial void OnEndTimeChanged(TimeSpan value) + private void OnEndTimeChanged(TimeSpan value) { if (_skipUpdate) { @@ -950,7 +993,7 @@ partial void OnEndTimeChanged(TimeSpan value) _skipUpdate = false; } - partial void OnDurationChanged(TimeSpan value) + private void OnDurationChanged(TimeSpan value) { if (_skipUpdate) { diff --git a/src/ui/Logic/TimeSpanExtensions.cs b/src/ui/Logic/TimeSpanExtensions.cs index 90ebed52863..16bf73cc800 100644 --- a/src/ui/Logic/TimeSpanExtensions.cs +++ b/src/ui/Logic/TimeSpanExtensions.cs @@ -10,4 +10,87 @@ public static bool IsMaxTime(this TimeSpan ts) { return Math.Abs(ts.TotalMilliseconds - MaxTimeTotalMilliseconds) < 0.01; } + + /// + /// Rounds a time 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. Snapping on the way + /// in keeps the two displays in agreement and the saved file faithful to what was typed. + /// + /// + public static TimeSpan SnapToWholeMilliseconds(this TimeSpan ts) + { + var rest = ts.Ticks % TimeSpan.TicksPerMillisecond; + if (rest == 0) + { + return ts; + } + + // Away from zero on the half, matching the rounding SE uses elsewhere for time codes. + // TimeSpan.MaxValue/MinValue have no whole millisecond to round to, so they stay put + // rather than wrapping around. + var ticks = ts.Ticks - rest; + if (rest >= TimeSpan.TicksPerMillisecond / 2) + { + if (ticks > long.MaxValue - TimeSpan.TicksPerMillisecond) + { + return ts; + } + + ticks += TimeSpan.TicksPerMillisecond; + } + else if (rest <= -TimeSpan.TicksPerMillisecond / 2) + { + if (ticks < long.MinValue + TimeSpan.TicksPerMillisecond) + { + return ts; + } + + ticks -= TimeSpan.TicksPerMillisecond; + } + + return TimeSpan.FromTicks(ticks); + } + + /// + /// snapped to a whole millisecond - + /// see . + /// + public static TimeSpan FromSecondsWholeMilliseconds(double seconds) + { + return FromMillisecondsWholeMilliseconds(seconds * 1000.0); + } + + /// + /// snapped to a whole millisecond - + /// see . + /// + public static TimeSpan FromMillisecondsWholeMilliseconds(double milliseconds) + { + if (double.IsNaN(milliseconds)) + { + return TimeSpan.Zero; + } + + // Keep the tick multiplication below in range; TimeSpan.FromMilliseconds would throw here. + const double maxMs = long.MaxValue / (double)TimeSpan.TicksPerMillisecond; + var ms = Math.Round(milliseconds, MidpointRounding.AwayFromZero); + if (ms >= maxMs) + { + return TimeSpan.MaxValue; + } + + if (ms <= -maxMs) + { + return TimeSpan.MinValue; + } + + return TimeSpan.FromTicks((long)ms * TimeSpan.TicksPerMillisecond); + } } diff --git a/tests/UI/Controls/AudioVisualizerShotChangeSnapTests.cs b/tests/UI/Controls/AudioVisualizerShotChangeSnapTests.cs index fa7af4c8999..63e42f07bbf 100644 --- a/tests/UI/Controls/AudioVisualizerShotChangeSnapTests.cs +++ b/tests/UI/Controls/AudioVisualizerShotChangeSnapTests.cs @@ -180,7 +180,7 @@ public void MoveWholeLine_NoShotChangeInRange_FollowsThePointer() Drag(window, 252, 312); - Assert.Equal(1 + 60.0 / SampleRate, line.StartTime.TotalSeconds, 6); + Assert.Equal(1 + 60.0 / SampleRate, line.StartTime.TotalSeconds, 3); // snapped to whole ms Assert.Equal(2, line.Duration.TotalSeconds, 6); window.Close(); } @@ -194,7 +194,7 @@ public void MoveWholeLine_SnapToShotChangesOff_FollowsThePointer() Drag(window, 252, 312); - Assert.Equal(1 + 60.0 / SampleRate, line.StartTime.TotalSeconds, 6); + Assert.Equal(1 + 60.0 / SampleRate, line.StartTime.TotalSeconds, 3); // snapped to whole ms window.Close(); } @@ -307,7 +307,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(draggedStart, line.StartTime.TotalSeconds, 3); // snapped to whole ms window.Close(); } @@ -321,7 +321,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(draggedStart, line.StartTime.TotalSeconds, 3); // snapped to whole ms window.Close(); Se.Settings.Waveform.SnapToShotChangesPixels = 16; diff --git a/tests/UI/Features/Assa/AdvancedEffectTests.cs b/tests/UI/Features/Assa/AdvancedEffectTests.cs index a0de01609a5..b2beaba2725 100644 --- a/tests/UI/Features/Assa/AdvancedEffectTests.cs +++ b/tests/UI/Features/Assa/AdvancedEffectTests.cs @@ -97,8 +97,9 @@ public void AudioTextPulse_FractionalBorder_IsCultureInvariant() } /// - /// The fade duration is emitted as a rounded integer - a fractional line duration must - /// not leak a decimal point (or comma) into \fad. + /// The fade duration is emitted as a rounded integer - it must not leak a decimal point + /// (or comma) into \fad. A line's times are snapped to whole milliseconds on the way into + /// the view model (#14056), so a fractional duration rounds before the effect ever sees it. /// [Fact] public void FadeIn_FractionalDuration_EmitsIntegerFad() @@ -107,7 +108,7 @@ public void FadeIn_FractionalDuration_EmitsIntegerFad() var result = effect.ApplyEffect(string.Empty, [MakeLine("Hello", durationMs: 1500.5)], 1280, 720, null); var overlay = result[0]; - Assert.Contains(@"\fad(0,1500)", overlay.Text); + Assert.Contains(@"\fad(0,1501)", overlay.Text); } /// diff --git a/tests/UI/Features/Main/SubMillisecondTimeTests.cs b/tests/UI/Features/Main/SubMillisecondTimeTests.cs new file mode 100644 index 00000000000..9381817d6d4 --- /dev/null +++ b/tests/UI/Features/Main/SubMillisecondTimeTests.cs @@ -0,0 +1,135 @@ +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.VisualTree; +using Nikse.SubtitleEdit.Controls; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using System; +using System.Linq; + +namespace UITests.Features.Main; + +/// +/// Sub-millisecond time codes (issue #14056). +/// +/// Since .NET Core 3.0 TimeSpan.FromSeconds/FromMilliseconds truncate to ticks instead of rounding +/// to whole milliseconds the way .NET Framework did, so TimeSpan.FromSeconds(0.82) is 819.9999 ms. +/// A value like that reads back as "0,820" in the duration up/down (which rounds) but as "0,819" in +/// the grid (which truncates, like every format writer does), and the end time it produces really +/// is a millisecond early. Subtitle times are whole milliseconds, so they are snapped on the way in. +/// +public class SubMillisecondTimeTests +{ + private static string GridText(TimeSpan ts) => new TimeCode(ts).ToShortString(); + + private static string FieldText(TimeSpan ts) => ts.TotalSeconds.ToString("0.000"); + + private static SubtitleLineViewModel Line(double startMs, double endMs) => + new() { StartTime = TimeSpan.FromMilliseconds(startMs), EndTime = TimeSpan.FromMilliseconds(endMs) }; + + // The reported case: Show 00:11:22,700 and a typed duration of 0,820 must hide at ,520. + [AvaloniaFact] + public void TypingADurationEndsTheLineOnAWholeMillisecond() + { + using var _ = new SettingsScope("General.UseFrameMode"); + Se.Settings.General.UseFrameMode = false; + + var line = Line(682_700, 683_200); + + var upDown = new SecondsUpDown { DataContext = line }; + upDown[!SecondsUpDown.ValueProperty] = + new Binding(nameof(SubtitleLineViewModel.Duration)) { Mode = BindingMode.TwoWay }; + var window = new Window { Content = upDown }; + window.Show(); + var textBox = upDown.GetVisualDescendants().OfType().Single(); + + textBox.Focus(); + textBox.Text = "0,820"; + window.KeyPress(Key.Enter, RawInputModifiers.None, PhysicalKey.Enter, null); + + Assert.Equal(820, line.Duration.TotalMilliseconds); + Assert.Equal(683_520, line.EndTime.TotalMilliseconds); + Assert.Equal("00:11:23,520", new TimeCode(line.EndTime).ToDisplayString()); + Assert.Equal("0,820", GridText(line.Duration)); + Assert.Equal("0,820", textBox.Text); + } + + // The other reported case: a waveform drag lands the cues on fractional milliseconds, so the + // grid (truncating) and the duration up/down (rounding) disagreed by one millisecond. + [Theory] + [InlineData(589_780.6, 590_910.3)] // pixel-derived, as a waveform drag produces + [InlineData(682_700.0, 683_519.9999)] // tick-truncated, as TimeSpan.FromSeconds produces + [InlineData(1_000.4, 2_000.5)] + [InlineData(1_000.5, 2_000.6)] + public void TheGridAndTheDurationFieldNeverDisagree(double startMs, double endMs) + { + using var _ = new SettingsScope("General.UseFrameMode"); + Se.Settings.General.UseFrameMode = false; + + var line = Line(startMs, endMs); + + Assert.Equal(0, line.StartTime.Ticks % TimeSpan.TicksPerMillisecond); + Assert.Equal(0, line.EndTime.Ticks % TimeSpan.TicksPerMillisecond); + Assert.Equal(0, line.Duration.Ticks % TimeSpan.TicksPerMillisecond); + Assert.Equal(GridText(line.Duration), FieldText(line.Duration)); + Assert.Equal(line.EndTime - line.StartTime, line.Duration); + } + + // Nudging Show must not leave the duration reading one value in the grid and another in the + // field - the reporter's follow-up test, which moved both cues but kept the bad span. + [AvaloniaFact] + public void MovingALineKeepsAWholeMillisecondDuration() + { + var line = Line(682_700, 683_520); + + // A pixel-derived waveform drag, i.e. an arbitrary fraction of a millisecond. + line.SetStartTimeKeepDuration(TimeSpan.FromMilliseconds(682_700.6)); + + Assert.Equal(682_701, line.StartTime.TotalMilliseconds); + Assert.Equal(683_521, line.EndTime.TotalMilliseconds); + Assert.Equal(820, line.Duration.TotalMilliseconds); + Assert.Equal(GridText(line.Duration), FieldText(line.Duration)); + } + + [Theory] + [InlineData(0.82, 820)] + [InlineData(1.13, 1130)] + [InlineData(1.129, 1129)] + [InlineData(0.0004, 0)] + [InlineData(0.0005, 1)] + [InlineData(-0.0005, -1)] + [InlineData(-1.13, -1130)] + public void SecondsAreSnappedToWholeMilliseconds(double seconds, double expectedMs) + { + var snapped = TimeSpanExtensions.FromSecondsWholeMilliseconds(seconds); + + Assert.Equal(expectedMs, snapped.TotalMilliseconds); + Assert.Equal(0, snapped.Ticks % TimeSpan.TicksPerMillisecond); + } + + // The extremes have no whole millisecond to round to - they must stay put, not wrap around. + [Fact] + public void SnappingTheExtremesDoesNotWrapAround() + { + Assert.Equal(TimeSpan.MaxValue, TimeSpan.MaxValue.SnapToWholeMilliseconds()); + Assert.Equal(TimeSpan.MinValue, TimeSpan.MinValue.SnapToWholeMilliseconds()); + Assert.Equal(TimeSpan.MaxValue, TimeSpanExtensions.FromSecondsWholeMilliseconds(double.MaxValue)); + Assert.Equal(TimeSpan.MinValue, TimeSpanExtensions.FromSecondsWholeMilliseconds(double.MinValue)); + Assert.Equal(TimeSpan.Zero, TimeSpanExtensions.FromSecondsWholeMilliseconds(double.NaN)); + } + + [Fact] + public void SnappingIsAlreadyWholeMillisecondsIdempotent() + { + var whole = TimeSpan.FromMilliseconds(1130); + + Assert.Equal(whole, whole.SnapToWholeMilliseconds()); + Assert.Equal(TimeSpan.Zero, TimeSpan.Zero.SnapToWholeMilliseconds()); + Assert.Equal(whole, TimeSpan.FromSeconds(1.13).SnapToWholeMilliseconds()); + } +} diff --git a/tests/UI/Features/Sync/ChangeFrameRate/ChangeFrameRateViewModelTests.cs b/tests/UI/Features/Sync/ChangeFrameRate/ChangeFrameRateViewModelTests.cs index ceaa537c551..d7fd7111dd7 100644 --- a/tests/UI/Features/Sync/ChangeFrameRate/ChangeFrameRateViewModelTests.cs +++ b/tests/UI/Features/Sync/ChangeFrameRate/ChangeFrameRateViewModelTests.cs @@ -42,10 +42,12 @@ public void ChangeFrameRate_ScalesStartAndEndByFromOverTo() ChangeFrameRateViewModel.ChangeFrameRate(subtitles, 25.0, 30.0); + // Scaled times land on whole milliseconds - the only resolution subtitle formats have, + // and the one the grid stores since #14056. var ratio = 25.0 / 30.0; - Assert.Equal(1000.0 * ratio, subtitles[0].StartTime.TotalMilliseconds, 3); - Assert.Equal(3000.0 * ratio, subtitles[0].EndTime.TotalMilliseconds, 3); - Assert.Equal(6000.0 * ratio, subtitles[1].StartTime.TotalMilliseconds, 3); - Assert.Equal(9000.0 * ratio, subtitles[1].EndTime.TotalMilliseconds, 3); + Assert.Equal(1000.0 * ratio, subtitles[0].StartTime.TotalMilliseconds, 0); + Assert.Equal(3000.0 * ratio, subtitles[0].EndTime.TotalMilliseconds, 0); + Assert.Equal(6000.0 * ratio, subtitles[1].StartTime.TotalMilliseconds, 0); + Assert.Equal(9000.0 * ratio, subtitles[1].EndTime.TotalMilliseconds, 0); } } From 5c36b27094f131722e59d80f987f3bef3de59b6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=AC=99=E6=AD=8C?= Date: Mon, 24 Aug 2026 23:08:48 +0800 Subject: [PATCH 062/380] Update string --- .../Assets/Languages/ChineseTraditional.json | 231 ++++++++++++++---- 1 file changed, 185 insertions(+), 46 deletions(-) diff --git a/src/ui/Assets/Languages/ChineseTraditional.json b/src/ui/Assets/Languages/ChineseTraditional.json index 684904d72bd..64dbcf17ed6 100644 --- a/src/ui/Assets/Languages/ChineseTraditional.json +++ b/src/ui/Assets/Languages/ChineseTraditional.json @@ -1,6 +1,6 @@ { "title": "Subtitle Edit", - "version": "v5.2.0-beta17", + "version": "v5.2.0-beta23", "translatedBy": "昇 | BAWAN | 風逸蘭 | Jackie Lam | SiderealArt", "cultureName": "zh-Hant", "general": { @@ -102,13 +102,21 @@ "codec": "編解碼器", "collapse": "摺疊", "color": "顏色", - "colorDotDotDot": "顏色...", + "colorBlack": "黑色", + "colorBlue": "藍色", + "colorCyan": "青色", + "colorDotDotDot": "顏色……", + "colorGreen": "綠色", + "colorMagenta": "洋紅色", + "colorRed": "紅色", + "colorWhite": "白色", + "colorYellow": "黃色", "column": "欄位", "consoleLog": "控制台記錄LOG", "contentAlignment": "內容對齊", "continueFindTitle": "繼續尋找?", - "searchItemNotFoundContinueFromTop": "找不到尋找項目。\n要從文件頂端開始,再搜尋一次嗎?", - "searchItemNotFoundContinueFromBottom": "找不到尋找項目。\n要從文件末端開始,再搜尋一次嗎?", + "searchItemNotFoundContinueFromTop": "找不到搜尋項目。\n要從文件頂端開始,再搜尋一次嗎?", + "searchItemNotFoundContinueFromBottom": "找不到搜尋項目。\n要從文件末端開始,再搜尋一次嗎?", "conversionCancelledByUser": "使用者已取消轉換", "convert": "轉換", "converted": "已轉換", @@ -237,7 +245,7 @@ "generating": "正在產生...", "generatingImageXofY": "正在產生 {1:#,##0} 的圖像 {0:#,##0}...", "generatingAudioFile": "正在產生音訊檔案...", - "getAudioClips": "取得音訊剪輯", + "getAudioClips": "取得音訊片段", "goTo": "跳至", "goToLineNumber": "跳至行號", "goToNextShotChange": "跳至下一個 場景變更線", @@ -336,12 +344,13 @@ "newSubtitleStartKeyDownSetEndKeyUp": "插入字幕:按鍵按下時設定起點、放開時設定終點", "setSubtitleStartAtVideoPositionSetEndAtKeyUpAndGoToNext": "在視訊位置設置起點、放開按鍵時設定終點,並跳至下一行", "no": "不要", + "noColor": "無顏色", "noFilesToConvert": "沒有要轉換的檔案", "noLanguageCode": "無語言程式碼", - "noSubtitleLoaded": "未載入字幕", + "noSubtitleLoaded": "未讀取到字幕", "noSubtitlesFound": "找不到字幕!", "noSymbolLines": "#行", - "noVideoLoaded": "未載入視訊", + "noVideoLoaded": "未讀取到視訊", "none": "無", "normalCasing": "正常大小寫", "notAvailable": "N/A", @@ -457,6 +466,7 @@ "removeUnderline": "移除下劃線", "rename": "重新命名", "replace": "取代(_R)", + "replaceLine": "取代行", "replaceWith": "取代...", "requiresRestart": "需要重新啟動", "reset": "重置", @@ -521,6 +531,7 @@ "shadow": "陰影", "shadowColor": "陰影顏色", "shadowWidth": "陰影寬度", + "shiftLineBy": "行位移", "shortcut": "快捷鍵", "shortcuts": "快捷鍵", "show": "顯示", @@ -535,6 +546,7 @@ "showPreview": "顯示預覽", "showShotChangesList": "顯示 場景變更線 清單", "showStyleColumn": "顯示「樣式」欄", + "showTeletext": "顯示電傳視訊", "showTimeCodes": "顯示時間碼", "showWpmColumn": "顯示「單字/分鐘」欄", "showPixelWidthColumn": "顯示「像素寬度」欄", @@ -550,7 +562,7 @@ "smart": "智慧", "snapSelectedLinesEndToPreviousShotChange": "貼齊已選取行終點 至上一個 場景變更線", "snapSelectedLinesStartToNextShotChange": "貼齊已選取行起點至下一個 場景變更線", - "snapSelectedLinesToNearestShotChange": "貼齊已選取行的起點/終點 至最近的 場景變更線", + "snapSelectedLinesToNearestShotChange": "貼齊已選取行的起點/終點 至 最近的場景變更線", "solidColor": "純色", "sourceViewX": "原始碼視圖 - {0}", "spacing": "間距", @@ -580,7 +592,7 @@ "subtitleFileSavedToX": "字幕檔已儲存至 {0}", "subtitleFiles": "字幕檔案", "subtitleFormats": "字幕格式", - "subtitleLoadedX": "已載入字幕:{0}", + "subtitleLoadedX": "已讀取字幕:{0}", "suffix": "後綴:", "suggestions": "建議", "sync": "同步", @@ -588,6 +600,9 @@ "system": "系統", "targetEncoding": "目標編碼", "targetFormat": "目標格式", + "teletextAlignment": "電傳視訊 對齊方式", + "teletextColor": "電傳視訊 顏色", + "teletextLine": "電傳視訊 行", "tenHours": "10小時", "text": "文字", "textColor": "文字顏色", @@ -665,6 +680,8 @@ "videoOffset": "視訊偏移", "videoOneFrameBack": "視訊:向後移動一影格", "videoOneFrameForward": "視訊:向前移動一影格", + "videoOneFrameBackWithPlay": "視訊:播放時向後移動一影格", + "videoOneFrameForwardWithPlay": "視訊:播放時向前移動一影格", "videoOneSecondBack": "視訊:倒退一秒", "videoOneSecondForward": "視訊:快轉一秒", "videoPlayer": "視訊播放器", @@ -736,7 +753,7 @@ "transcriptionComplete": "轉錄完成", "processingResponse": "正在處理回應...", "unauthorizedApiKey": "未經授權:API 金鑰無效或權限不足。", - "requestTimeout": "請求逾時:伺服器回應時間過長。", + "requestTimeout": "請求逾時:伺服器回應時間太長。", "openAiCompatibleSttUrlMissing": "需要 OpenAI 相容的 STT 端點 URL。請輸入 URL,然後再試一次。", "openAiCompatibleSttUrlNotResponding": "OpenAI 相容 STT 端點沒有回應:\n{0}\n\n請驗證 URL 以及伺服器是否正在運行。", "transcriptionFailed": "轉錄失敗", @@ -944,12 +961,12 @@ "columnPaste": "整欄貼上", "createdEmptyTranslation": "已從目前字幕建立空白翻譯", "deleteText": "刪除文字", - "deleteTextAndShiftCellsUp": "刪除文字並將儲存格上移", + "deleteTextAndShiftCellsUp": "刪除文字並將儲存格向上位移", "endTimeMustBeAfterStartTime": "結束時間必須晚於起始時間。", "errorLoad7Zip": "此檔案似乎是壓縮的 7-Zip 檔案。\n\nSubtitle Edit 無法開啟壓縮檔案。", "errorLoadBinaryZeroes": "抱歉,此檔案僅包含二進位零!\n\n如果您曾使用 Subtitle Edit 編輯過此檔案,或許可以透過「檔案 → 還原自動備份……」功能找回備份...", "errorLoadGZip": "此檔案似乎是壓縮的 GZip 檔案。\n\nSubtitle Edit 無法開啟壓縮檔案。", - "errorLoadLargeFile": "此檔案過大,無法作為字幕檔開啟。", + "errorLoadLargeFile": "此檔案太大,無法作為字幕檔開啟。", "errorLoadVideoFilePrompt": "此檔案似乎是視訊檔案:\n{0}\n\n您可以透過「視訊 -> 開啟視訊」或將視訊拖放至視訊播放器來開啟視訊。\n\n您要將其作為視訊檔案開啟嗎?", "errorLoadJpg": "此檔案似乎是 JPG圖像檔案。\n\nSubtitle Edit 無法開啟圖像檔案。", "errorLoadPng": "此檔案似乎是 PNG圖像檔案。\n\nSubtitle Edit 無法開啟圖像檔案。", @@ -964,16 +981,16 @@ "fixedRightToLeftUsingUnicodeControlCharactersX": "已在 {0} 行中利用 Unicode 控制字元修正 RTL", "generatingSpectrogramDotDotDot": "正在建立頻譜圖...", "generatingWaveformDotDotDot": "正在產生波形...", - "insertEmptyTextAndShiftCellsDown": "插入空文字並將儲存格向下移動", + "insertEmptyTextAndShiftCellsDown": "插入空文字並將儲存格向下位移", "insertTextFromSubtitleDotDotDot": "插入字幕中的文字...", "copyTextFromOriginalToCurrent": "從原件複製文字至目前字幕", "insertedXTextsFromSubtitleY": "已插入字幕檔案「{1}」中的 {0} 條文本", "italicHint": "已選取行/文字 改為斜體", - "joinedSubtitleLoaded": "已載入拼接後的字幕", + "joinedSubtitleLoaded": "已讀取拼接後的字幕", "lineXTextAndTimingChanged": "第 {0} 行:文字和時間軸已變更", "lineXTextChangedFromYToZ": "第 {0} 行:文字從「{1}」改為「{2}」", "lineXTimingChanged": "第 {0} 行:時間軸已變更", - "loadingWaveInfoFromCache": "正在從快取載入波形資訊...", + "loadingWaveInfoFromCache": "正在從快取讀取波形資訊...", "clickToGenerateWaveform": "點擊以產生波形", "noTextInClipboard": "剪貼簿中無文字", "numberOfLinesEvenlyDistributedX": "已均勻分配 {0} 行", @@ -981,19 +998,19 @@ "oneLineMerged": "已合併 1 行", "oneLineSwitched": "已對調 1 行", "overwriteExistingCells": "覆寫現有儲存格", - "overwriteOrShiftCellsDown": "覆寫/向下移動儲存格", + "overwriteOrShiftCellsDown": "覆寫/向下位移儲存格", "parsingMatroskaFile": "正在解析 Matroska 檔案...", "pasteFromClipboardDotDotDot": "從剪貼簿貼上...", "redoPerformed": "已執行重做", "redoPerformedXActionLeft": "已執行重做(剩餘操作:{0})", "removedUnicodeControlCharactersX": "已從 {0} 行中移除 Unicode 控制字元", - "removedXBlankLines": "移除了 {0} 條空白行", + "removedXBlankLines": "已移除 {0} 條空白行", "replacedXWithYCountZ": "將「{0}」取代為「{1}」(出現 {2} 次)", "reversedStartAndEndingsForRightToLeftX": "已反轉 {0} 行由右至左語言的開頭與結尾", "ruleProfileIsX": "規則設定檔現為「{0}」", "saveLanguageFile": "儲存語言檔案", "saveXFileAs": "另存 {0} 檔案為", - "shiftTextCellsDown": "向下移動文字儲存格", + "shiftTextCellsDown": "向下位移文字儲存格", "singleLineLength": "行長度:", "speedIsNowX": "速度現為「{0}」", "subtitleGridFormattingX": "網格格式排版:{0}", @@ -1011,7 +1028,7 @@ "xLinesMerged": "已合併 {0} 行", "xLinesSelectedOfY": "已選取 {0} 行,共 {1} 行", "xLinesSwitched": "已調換 {0} 行", - "xShotChangedLoaded": "已載入 {0} 個場景變更線", + "xShotChangedLoaded": "已讀取 {0} 個場景變更線", "youtubeDlDownloadedSuccessfully": "「yt-dlp」下載成功。", "youtubeDlNotInstalledDownloadNow": "未安裝「yt-dlp」,並且需要播放線上視訊。\n\n現在下載?", "promptInsertSubtitleOverlap": "插入的行將與現有行重疊。 \n\n是否仍插入?", @@ -1050,6 +1067,7 @@ "sortedByActor": "依角色/說話者排序", "sortedByStyle": "依樣式排序", "videoBrightnessSetTo": "亮度:{0}", + "videoContrastSetTo": "對比:{0}", "colorHint": "標色已選取行", "removeFormattingHint": "從已選取行移除格式排版", "assaResolutionResamplerDone": "ASSA 解析度已變更。", @@ -1063,8 +1081,16 @@ "replacedXOccurrences": "已取代 {0} 處", "ffmpegDownloadedAndInstalledToX": "ffmpeg 下載並安裝到 {0}", "nothingToSave": "沒有可儲存的內容", + "formatLimitWarningTitle": "已超過格式限制", + "formatLimitWarningXLinesExceedLimitsOfY": "{0} 則字幕超過「{1}」格式的限制:", + "formatLimitWarningMaxXCharactersPerLine": "每行最多 {0} 個字元", + "formatLimitWarningMaxXLines": "每則字幕最多 {0} 行", + "formatLimitWarningTextWillBeRewrapped": "儲存時,這些字幕會重新換行或截斷以符合限制,因此檔案內容將與此處顯示的不符合。", + "formatLimitWarningLinesX": "行數:{0}", + "formatLimitWarningSaveAnyway": "仍要儲存", + "formatLimitWarningDoNotShowAgain": "不再顯示此警告", "nothingToSaveOriginal": "無東西可儲存(原件字幕)", - "liveSpellCheckLanguageXLoaded": "已載入即時拼字檢查語言 {0}", + "liveSpellCheckLanguageXLoaded": "已讀取即時拼字檢查語言 {0}", "downloadFfmpegTitle": "下載 FFmpeg 嗎?", "downloadFfmpegQuestion": "播放線上視訊和某些視訊編輯功能需要 FFmpeg。\n\n現在下載 FFmpeg 嗎?", "selectCurrentSubtitleWhilePlayingOn": "播放時選取目前字幕:ON", @@ -1105,12 +1131,15 @@ "marginTop": "頂端 外邊距(用於頂端對齊字幕)", "marginBottom": "底端 外邊距(用於底部對齊字幕)", "newLineRows": "換行新增的行數", - "teletext": "電視文字模式", + "teletext": "電傳視訊", "useBox": "文字使用外框", "doubleHeight": "文字使用雙倍高度", "errors": "錯誤", "errorsX": "錯誤: {0}", - "maxLengthError": "行 {0} 超出了經由 {2} 的最大長度 ({1}): {3}", + "maxLengthError": "第 {0} 行超過最大長度 ({1}) {2}:{3}", + "maxLengthErrorTeletextColored": "第 {0}-{1} 行:雙倍高度的彩字最多 36 字元(非{2})", + "maxLengthErrorTeletextWhite": "第 {0}-{1} 行:雙倍高度的白字最多 37 字元(非{2})", + "maxLengthErrorTeletextSingleHeight": "第 {0} 行:單倍高度的白字最多 38 字元(非{1})", "discSequenceNumber": "光碟序列編號" }, "import": { @@ -1257,8 +1286,8 @@ "wpmAboveMaximumX": "超過最大 單字/分鐘 ({0:0.##})", "durationBelowMinimumX": "持續時間低於最小值 ({0:0.###} 秒)", "durationAboveMaximumX": "持續時間超過最大值 ({0:0.###} 秒)", - "lineTooLongX": "單行長度過長 (> {0})", - "lineTooWideX": "單行寬度過寬 (> {0} 像素)", + "lineTooLongX": "單行長度太長 (> {0})", + "lineTooWideX": "單行寬度太寬 (> {0} 像素)", "gapBelowMinimumX": "字幕間隔低於最小值 ({0:#,##0} 毫秒)" }, "manualChosenEncoding": { @@ -1677,7 +1706,14 @@ "batchSnapToShotChanges": "貼齊提示點至場景變更線(若可用)", "batchFrameRateFromVideo": "使用與字幕檔案同名的視訊檔案之影格率", "batchFrameRateFixed": "使用固定影格率", - "batchInfo": "若找到與字幕檔案同名的視訊檔案,將從中讀取場景變更線資訊。" + "batchInfo": "若找到與字幕檔同名的視訊檔案,將從中讀取場景變更線資訊。", + "useExactTimeCodes": "使用視訊的精確時間碼", + "extractTimeCodes": "擷取時間碼", + "extractingTimeCodes": "正在擷取時間碼...", + "noTimeCodesLoaded": "未讀取到時間碼", + "xTimeCodesLoaded": "已讀取 {0} 個時間碼", + "timeCodesExtractFailed": "無法從視訊中讀取時間碼", + "timeCodesHint": "貼齊提示點至視訊的實際影格時間,而非假設固定影格率。需要完整讀取一次視訊,將已存結果快取。" }, "beautifyTimeCodesProfile": { "title": "編輯美化時間碼設定檔", @@ -1709,7 +1745,7 @@ "subtitlePreviewText": "字幕文字。", "resetWarning": "這將會覆寫目前的所有設定。是否繼續?", "hintGap": "字幕之間最小間隔(以影格為單位)。下方所有「間隔」欄位皆以此為基準值。", - "hintZones": "以最近的場景變更線為基準,計算貼齊(紅)與警告(綠)的閥值(單位:影格)。四個數值由左至右依序為:左綠、左紅、右紅、右綠。", + "hintZones": "以最近的場景變更線為基準,計算貼齊(紅)與警告(綠)的閥值(影格)。\n四個數值由左至右依序為:左綠、左紅、右紅、右綠。", "hintConnected": "當兩行字幕(間隔低於閾值)緊鄰時,將保留與最近提示點之間的間隔。", "hintChaining": "如何處理幾乎相鄰的字幕 — 根據中間是否有場景變更線,決定要將字幕延伸在一起或推開。" }, @@ -1778,6 +1814,7 @@ "addFolderDotDotDot": "新增資料夾……", "selectFolderToConvert": "挑選要轉換檔案的資料夾所在", "includeSubfolders": "新增資料夾時包括子資料夾", + "keepSourceFileTimestamp": "在輸出檔案中保留來源檔案的日期/時間", "scanningFolderX": "正在掃描 {0}……" }, "changeCasing": { @@ -1832,6 +1869,8 @@ "title": "拆分/重新平衡長行", "splitLongLines": "拆分長行(為多行)", "rebalanceLongLines": "重新平衡長行", + "rebalanceOnlyLinesTooLong": "僅處理過長的字幕行", + "rebalanceOnlyLinesTooLongHint": "當所有行均在單行最大長度限制內時,則保留現有斷行不變 - 刻意不平均的字幕在編輯上可能是正確的", "splitLongLine": "拆分長行", "rebalanceLongLine": "重新平衡長行", "splitIntoXLines": "拆分為 {0} 行:「{1}」→「{2}...」", @@ -1954,7 +1993,7 @@ "topAlignLines": "靠頂端對齊", "bottomAlignLines": "靠底端對齊", "appendSubtitleDotDotDot": "附加字幕...", - "shiftTimeCodes": "時間軸偏移", + "shiftTimeCodes": "時間軸位移", "sortByStartTime": "依起始時間排序", "videoPreview": "視訊", "positionMap": "位置分佈圖", @@ -1967,7 +2006,7 @@ "xInActivePicture": "{0} 條在畫面內", "xInTopBar": "{0} 條在頂端黑邊", "xInBottomBar": "{0} 條在底端黑邊", - "noImageSubtitlesLoaded": "未載入圖像字幕" + "noImageSubtitlesLoaded": "未讀取到圖像字幕" }, "removeTextForHearingImpaired": { "interjections": "感嘆詞", @@ -2106,11 +2145,38 @@ "fixCasing": "修正大小寫", "addPeriods": "增加句點", "changeUnderlineToColor": "將下劃線轉為顏色", + "removeNonSpeechLines": "移除非語音行", + "removeNonSpeechLinesHint": "丟棄僅描述聲音的行,例如「[音樂]」或「(海浪拍打聲)」", + "removeRepeatedLines": "移除已重複行", + "removeRepeatedLinesHint": "丟棄與上一行逐字重複的行(引擎重複產生)", + "showQualityReport": "轉錄後顯示品質報告", + "qualityReportTitle": "轉錄品質報告", + "qualityReportNoIssues": "未發現問題——轉錄結果看起來良好。", + "qualityReportSummaryX": "在 {1} 行中發現 {0} 個問題", + "qualityReportLinesChecked": "已檢查行", + "qualityReportIssuesFound": "問題發現", + "qualityReportLinesRemoved": "已移除行", + "qualityReportTooShort": "太短", + "qualityReportTooShortHint": "短於最小顯示時間,或閱讀速度超過上限", + "qualityReportTooLong": "太長", + "qualityReportTooLongHint": "長於最大顯示時間,或長時間內僅有極少單字(通常是幻覺內容)", + "qualityReportOverlap": "重疊", + "qualityReportOverlapHint": "終點晚於下一行的起點", + "qualityReportNonSpeech": "非語音", + "qualityReportNonSpeechHint": "僅包含聲音或音樂描述,例如「[音樂]」", + "qualityReportRepeated": "已重複", + "qualityReportRepeatedHint": "與上一行文字相同(引擎重複產生)", + "qualityReportRemoved": "已移除", + "qualityReportAll": "全部", + "qualityReportIssue": "問題", + "qualityReportDetail": "詳情", + "qualityReportTip": "新字幕中會列出各行及其編號。\n使用「修正常見錯誤」或後處理設定可自動修正更多問題;如果有許多行受到影響,也可以嘗試其他模型/引擎。", + "qualityReportDoNotShowAgain": "不再顯示此報告", "engineSettings": "語音轉文本引擎設定", "engineSettingsSubtitle": "語音轉文本引擎", "backendAndUpdateStatus": "後端與更新狀態", "addLanguageCodeToFileName": "增加語言代碼至檔案名稱", - "addLanguageCodeToFileNameHint": "將產生的字幕命名為像是【video.en.srt】,而非【video.srt】" + "addLanguageCodeToFileNameHint": "將已產生的字幕命名為像是【video.en.srt】,而非【video.srt】" }, "textToSpeech": { "title": "文字轉語音", @@ -2139,6 +2205,9 @@ "elevenLabsSettingsResetHint": "重置 ElevenLabs 設定為預設值", "regenerateAudio": "重新產生音訊", "autoContinuePlaying": "自動繼續", + "playLine": "播放此行", + "fitDurationToGeneratedAudio": "使持續時間與已產生的音訊相符", + "resetTiming": "重置時間軸", "addingAudioToVideoFileDotDotDot": "將音訊新增至視訊檔案...", "preparingMergeDotDotDot": "準備合併...", "importVoiceDotDotDot": "匯入語音...", @@ -2197,6 +2266,10 @@ "silencePaddingMsDescription": "在每個片段末尾加入一段短暫靜音。有助於在句子之間留出喘息空檔。", "outputSampleRate": "輸出取樣率(0 = 預設)", "outputSampleRateDescription": "將所有片段重新採樣為指定的取樣率 (例如 44100、48000)。設置為 0 則保持原始取樣率。", + "generationFolder": "產出資料夾", + "generationFolderDescription": "執行期間產生的音訊片段所使用的資料夾。留空則使用系統暫存資料夾。每次執行都有各自子資料夾。", + "deleteTempFiles": "關閉時刪除已產生的片段", + "deleteTempFilesDescription": "關閉文字轉語音視窗時,移除該次執行所使用的產出資料夾。\n已儲存的音訊與視訊檔案不受影響。關閉此選項即可保留個別音訊片段。", "edgeTtsRate": "Edge-TTS 語速", "edgeTtsRateDescription": "Edge-TTS 的語速,例如預設為「+50%」、「-30%」或「+0%」。", "edgeTtsPitch": "Edge-TTS 音調", @@ -2236,7 +2309,7 @@ "noActorsFoundMessage": "找不到任何角色。請先在字幕行設置角色欄位。", "noWebVttVoicesFoundMessage": "在 WebVTT 檔案中找不到 語音。", "mergeContinuationLinesPromptTitle": "要合併續接行嗎?", - "mergeContinuationLinesPromptMessage": "部分字幕行似乎是被拆分為多行字幕的同一個句子。\n\n在產生語音前將它們合併,能讓文字轉語音 (TTS) 引擎將每個語意當作同一個停頓區間來發音,這通常聽起來會更自然。\n\n要立刻校對並應用合併嗎?", + "mergeContinuationLinesPromptMessage": "部分字幕行似乎是被拆分為多行字幕的同一個句子。\n\n在產出語音前將它們合併,能讓文字轉語音 (TTS) 引擎將每個語意當作同一個停頓區間來發音,這通常聽起來會更自然。\n\n要立刻校對並應用合併嗎?", "subtitleUpdatedFromReviewSingular": "已根據語音校對更新 1 行字幕", "subtitleUpdatedFromReviewPlural": "已根據語音校對更新 {0} 行字幕", "subtitleMergedLinesAppliedSingular": "已應用 1 行文字轉語音的字幕行合併", @@ -2263,8 +2336,8 @@ "startTime": "起始時間", "addChapter": "新增章節", "addChapterAtVideoPosition": "在視訊位置增加章節", - "goToPreviousChapter": "前往上一章節", - "goToNextChapter": "前往下一章節", + "goToPreviousChapter": "跳至上一章節", + "goToNextChapter": "跳至下一章節", "toggleChapterAtVideoPosition": "切換在視訊位置的章節", "newChapterTitle": "章節 {0}", "deleteSelectedChapterQuestion": "確定要刪除已選取章節嗎?", @@ -2292,7 +2365,7 @@ "writtenToX": "章節已寫入 {0}", "unableToWriteChapters": "無法將章節寫入視訊檔案。", "chaptersSavedToX": "章節已儲存至 {0}", - "noVideoLoaded": "未載入視訊", + "noVideoLoaded": "未讀取到視訊", "emptyListCallToAction": "尚無章節 - 可在視訊位置新增一個,或從視訊與檔案匯入章節。" }, "videoOcr": { @@ -2336,7 +2409,7 @@ "reEncodeVideoForBetterSubtitlingDotDotDot": "重新編碼視訊以獲得更好的字幕...", "openSecondarySubtitleOnVideoPlayerDotDotDot": "視訊播放器上的輔助字幕,開啟...", "openSecondarySubtitleOnVideoPlayer": "輔助字幕(在視訊播放器上)", - "removeSecondarySubtitleOnVideoPlayer": "視訊播放器上的輔助字幕,刪除", + "removeSecondarySubtitleOnVideoPlayer": "視訊播放器上的輔助字幕,移除", "cutVideoTitle": "剪輯視訊", "cutVideoDotDotDot": "剪輯視訊...", "embedSubtitlesDotDotDot": "新增/移除內嵌字幕...", @@ -2347,7 +2420,7 @@ "cutVideoMergeSegments": "合併片段", "cutVideoSplitSegments": "分別儲存各片段", "cutVideoAlsoCutSubtitle": "同時剪輯字幕", - "cutVideoSubtitleFileGeneratedX": "已產生字幕檔:{0}", + "cutVideoSubtitleFileGeneratedX": "已產生的字幕檔:{0}", "importCurrentSubtitle": "匯入目前字幕", "addRemoveEmbeddedSubtitlesTitle": "新增/移除內嵌字幕", "addRemoveEmbeddedSubtitlesMp4Title": "新增/移除內嵌字幕(MP4)", @@ -2407,7 +2480,7 @@ "reEncodeUnableToGenerateVideo": "無法產生視訊", "reEncodeOutputVideoFileNotGenerated": "未產生要輸出的視訊檔案:{0}\r\n參數:{1}", "reEncodeGeneratingDone": "產生完畢", - "reEncodeGeneratedFilesX": "已產生檔案 ({0}):", + "reEncodeGeneratedFilesX": "已產生的檔案 ({0}):", "reEncodeFfmpegParameters": "FFmpeg 參數", "voices": "語音", "presets": "預設集", @@ -2417,7 +2490,7 @@ "ttsUpdateX": "更新 {0}", "ttsEngineNotInstalled": "未安裝 {0}", "ttsEngineUpdateAvailable": "{0} - 有可用更新", - "ttsEngineTooOldUpdateRequired": "{0} 版本過舊 - 需要更新", + "ttsEngineTooOldUpdateRequired": "{0} 版本太舊 - 需要更新", "ttsEngineChatterboxCapable": "{0} (支援 Chatterbox)" }, "waveform": { @@ -2490,7 +2563,7 @@ "translateViaCopyPaste": "透過複製/貼上進行自動翻譯", "maxBlockSize": "最大區塊大小", "lineSeparator": "換行分隔符", - "blockCopyInfo": "請前往翻譯工具或 AI 貼上文字(已複製到剪貼簿),並將翻譯結果複製回剪貼簿,最後點擊下方按鈕。", + "blockCopyInfo": "跳至翻譯工具或 AI 並貼上文字(已在剪貼簿中),並將翻譯結果複製回剪貼簿,最後點擊下方按鈕。", "blockCopyGetFromClipboard": "從剪貼簿取得文字(來自翻譯工具/AI)", "reCopyTextToClipboard": "再次複製文字至剪貼簿(供翻譯工具/AI 使用)", "blockXOfY": "區塊 {0} / {1}", @@ -2534,6 +2607,11 @@ "repeatPenalty": "重複懲罰 (Repeat penalty)", "maxTokensPerReply": "每篇回覆最大 Token 數", "serverContextSizeTokens": "伺服器上下文大小 (Tokens)", + "extraServerParameters": "額外伺服器參數", + "extraServerParametersHint": "額外的 llama-server 命令列參數,例如「-ngl 30 --no-mmap」——啟動區域伺服器時應用", + "useOnlyExtraServerParameters": "僅使用這些參數", + "useOnlyExtraServerParametersHint": "使用上述參數啟動 llama-server,而不使用 Subtitle Edit 自有的調校設定——仍會設定模型、主機與連接埠", + "serverRunningAtX": "伺服器執行於 {0}", "customPromptHint": "自訂提示詞({0} = 來源語言,{1} = 目標語言);留空 = 使用內建提示詞", "llamaCppDownloadEngineAndModelPrompt": "llama.cpp 需要下載 llama-server 引擎與翻譯模型。立刻下載?", "llamaCppDownloadEnginePrompt": "llama.cpp 需要下載 llama-server 引擎。立刻下載?", @@ -2612,6 +2690,11 @@ "maxDurationMs": "最長持續時間 (毫秒)", "minGapMs": "最小間隔 (毫秒)", "minGapFrames": "最小間隔 (影格)", + "minGapCalculateDotDotDot": "從影格率計算最小間隔……", + "minGapCalculateTitle": "字幕間的最小間隔(毫秒)", + "minGapCalculateFrames": "最小間隔(影格)", + "minGapCalculateXFramesAtYGivesZMs": "影格率為 {1} 時,{0} 個影格等於 {2} 毫秒。", + "minGapCalculateUseXAsNewGap": "使用「{0}」毫秒設為新的最小間隔嗎?", "maxLines": "最大行數", "unbreakSubtitlesShortThan": "比這短字幕取消斷行", "autoBreakLineEndingEarly": "句尾標點(.!?)提早自動斷行", @@ -2634,16 +2717,16 @@ "autoTrimWhiteSpace": "自動修剪空白字元", "removeBlankLinesWhenOpening": "開啟字幕時移除空白行", "defaultEncoding": "預設編碼", - "colorDurationTooShort": "持續時間過短時之顏色", - "colorDurationTooLong": "持續時間過長時之顏色", - "colorTextTooLong": "文字過長時之顏色", - "colorTextTooWide": "文字過寬時之顏色(像素)", + "colorDurationTooShort": "持續時間太短時之顏色", + "colorDurationTooLong": "持續時間太長時之顏色", + "colorTextTooLong": "文字太長時之顏色", + "colorTextTooWide": "文字太寬時之顏色(像素)", "colorTextTooManyLines": "文字超過 2 行時之顏色", "colorTextTooManyLinesX": "文字超過 {0} 行時之顏色", - "colorCharactersPerSecond": "字元/分 過高時之顏色", - "colorWordsPerMinute": "單字/分鐘 過高時之顏色", + "colorCharactersPerSecond": "字元/分 太高時之顏色", + "colorWordsPerMinute": "單字/分鐘 太高時之顏色", "colorOverlap": "時間碼重疊時之顏色", - "colorGapTooShort": "間隔過短時之顏色", + "colorGapTooShort": "間隔太短時之顏色", "errorBackgroundColor": "錯誤背景顏色", "waveformDrawGridLines": "繪製網格線", "waveformCenterVideoPositionAlsoWhenPaused": "暫停時波形圖同樣置中於視訊位置", @@ -2660,6 +2743,10 @@ "waveformMouseWheelStepMilliseconds": "{0} 毫秒", "waveformSnapToShotChanges": "貼齊至場景變更線(按住 Shift 可忽略)", "waveformSnapToFrames": "貼齊至影格", + "waveformSnapToShotChangesPixels": "拖曳時的貼齊距離(像素)", + "waveformSnapToShotChangeStartMaxSeconds": "貼齊至最近的場景變更線:最大起點距離(秒)", + "waveformSnapToShotChangeEndMaxSeconds": "貼齊至最近的場景變更線:最大終點距離(秒)", + "waveformSnapToShotChangeSameShotEndMaxSeconds": "貼齊至最近的場景變更線:起點與終點共用同一剪輯點(秒)時之最大終點距離", "waveformShotChangesAutoGenerate": "自動產生場景變更線", "waveformTextFontSize": "波形文字字型尺寸", "waveformTextFontBold": "波形文字字型加粗", @@ -2802,6 +2889,7 @@ "subtitleGridFormattingNone": "無格式排版", "subtitleGridFormattingShowFormatting": "顯示格式排版", "subtitleGridFormattingShowTags": "顯示標籤", + "subtitleGridFormattingHideTags": "隱藏標籤", "waveformParagraphBackgroundColor": "波形字幕背景顏色", "waveformParagraphSelectedBackgroundColor": "波形已選取字幕背景顏色", "waveformAllowOverlap": "允許重疊 (當 移動/調整大小 時)", @@ -2826,6 +2914,7 @@ "useFocusedButtonBackgroundColor": "使用目前對焦按鈕的背景顏色", "focusedButtonBackgroundColor": "對焦按鈕背景顏色", "forceCrLfOnSave": "儲存時強制 CR+LF(文字字幕檔案)", + "showFormatLimitWarning": "儲存時,若行數超過格式限制則顯示警告(例:SCC 32字元/行)", "textBoxButtonShowAutoBreak": "文字框:顯示自動斷行按鈕", "textBoxButtonShowUnbreak": "文字框:顯示取消斷行按鈕", "textBoxButtonShowItalic": "文字框:顯示斜體按鈕", @@ -2839,6 +2928,8 @@ "waveformToolbarItems": "波形工具列項目", "matchIconColorToDarkTheme": "使圖示顏色符合深色主題的前景顏色", "subtitlePreviewProperties": "字幕預覽屬性", + "usePositionFromSubtitleFile": "使用字幕檔案中的位置(TTML/PAC/EBU STL)", + "marginIsPartOfSubtitleArea": "外邊距屬於字幕區域的一部分", "pixelWidthInfo": "綠線 = 最大寬度限制 | 紅區 = 文字超出限制", "spellCheckEngineHunSpelll": "Hunspell", "spellCheckEngineMsWord": "微軟Word", @@ -3012,6 +3103,7 @@ "seekSilenceBack": "向後跳轉至靜音處", "seekSilenceForward": "向前跳轉至靜音處", "setVideoPositionCurrentSubtitleStart": "將視訊位置設置為目前行起點", + "goToSubtitlePositionAndPause": "跳至字幕位置並暫停", "setVideoPositionCurrentSubtitleEnd": "將視訊位置設置為目前行終點", "toggleAudioTracks": "切換音軌", "goToNextError": "轉往下一個錯誤", @@ -3037,6 +3129,7 @@ "chooseSubtitleFormat": "選擇字幕格式", "trimWhitespaceSelectedLines": "修剪空白字元(已選取行)", "waveformInsertNewSelection": "在波形中插入新選取範圍", + "waveformNewSelectionPasteFromClipboard": "在波形中將剪貼簿文字貼到新選取範圍", "waveformHorizontalZoomInCommand": "波形水平放大", "waveformHorizontalZoomOutCommand": "波形水平縮小", "waveformVerticalZoomInCommand": "波形垂直放大", @@ -3058,6 +3151,7 @@ "sortByActor": "依角色/說話者排序", "sortByStyle": "依樣式排序", "videoToggleBrightness": "切換亮度 (僅限 mpv)", + "videoToggleContrast": "視訊:切換對比(僅限 mpv)", "duplicatesFound": "發現以下重複的快捷鍵:", "copyTextToClipboard": "複製文字至剪貼簿(已選取行)", "copyTextFromOriginalToClipboard": "從原件複製文字至剪貼簿(已選取行)", @@ -3123,7 +3217,7 @@ "pluginXReportedError": "擴充功能「{0}」報告錯誤。", "pluginXDone": "{0}已完成", "pluginXReturnedUnparsableSubtitle": "擴充功能「{0}」返回了無法解析的字幕。", - "loadingPluginList": "正在載入擴充功能清單...", + "loadingPluginList": "正在讀取擴充功能清單...", "noPluginsAvailable": "沒有可用的擴充功能。", "xPluginsAvailable": "{0} 個可用擴充功能。", "couldNotLoadPluginList": "無法載入擴充功能清單:{0}", @@ -3237,6 +3331,20 @@ "importTextFromSubtitleXLinesImported": "已匯入 {0} 行文字。", "exportTextAsSubtitleDotDotDot": "將文字匯出為字幕...", "exportTextAsSubtitleNoText": "沒有可匯出的 OCR 文字。首先執行 OCR 或匯入文字。", + "saveAllImagesWithHtmlIndexDotDotDot": "儲存所有圖像並建立 HTML 索引……", + "saveAllImagesWithHtmlIndexPickFolder": "選擇存放圖像與 HTML 索引的資料夾", + "saveAllImagesWithHtmlIndexSaved": "已儲存 {0} 張圖像並建立 HTML 索引", + "savingImagesDotDotDot": "正在儲存圖像……", + "htmlExportTitle": "字幕圖像", + "htmlExportXImagesYWithoutText": "{0} 張圖像 - 其中 {1} 張沒有文字", + "htmlExportSearch": "搜尋文字", + "htmlExportOnlyWithoutText": "僅沒有文字的行", + "htmlExportNoText": "(無文字)", + "htmlExportNoMatches": "沒有符合篩選條件的行", + "htmlExportImageBackground": "圖像背景", + "htmlExportCheckerboard": "棋盤格", + "htmlExportTheme": "主題", + "htmlExportGeneratedBySubtitleEdit": "由 Subtitle Edit 生成", "vobSubColors": "VobSub/DVD 顏色...", "vobSubColorsTitle": "VobSub/DVD 顏色", "vobSubColorsHeader": "自訂四種 VobSub 顏色", @@ -3525,5 +3633,36 @@ "payPal": "PayPal", "gitHubSponsor": "GitHub 贊助者", "or": " 或 " + }, + "errorList": { + "title": "列出錯誤", + "summaryX": "{0} 個錯誤,涉及 {2} 行中的 {1} 行", + "noErrors": "未找到錯誤。", + "summaryFilesX": "{0} 個錯誤,涉及 {2} 個檔案中的 {1} 行", + "all": "全部", + "tooManyLines": "太多行數", + "charactersPerSecond": "閱讀速度", + "durationTooShort": "太短", + "durationTooLong": "太長", + "lineTooLong": "行太長", + "lineTooWide": "行太寬", + "overlap": "重疊", + "gapTooShort": "間隔太短", + "tooManyLinesHint": "行數多於最大行數限制", + "charactersPerSecondHint": "每秒字元數超過上限", + "durationTooShortHint": "短於最小顯示時間", + "durationTooLongHint": "長於最大顯示時間", + "lineTooLongHint": "某一行的字元數多於最大行長度", + "lineTooWideHint": "某一行的寬度(像素)多於最大寬度", + "overlapHint": "與上一行或下一行重疊", + "gapTooShortHint": "與上一行或下一行的間隔低於最小值", + "detailXGreaterThanY": "{0} > {1}", + "detailXLessThanY": "{0} < {1}", + "detailOverlapFromPrevious": "從上一行:{0} 毫秒", + "detailOverlapToNext": "至下一行:{0} 毫秒", + "detailGapToPrevious": "從上一行:{0} < {1} 毫秒", + "detailGapToNext": "至下一行:{0} < {1} 毫秒", + "detail": "詳情", + "tip": "雙擊某一行(或按 Enter)即可跳至該行。\n要執行哪些檢查及其限制,可在「設定 > 一般」中調整。" } } From dd522467ef55c201467216550e2ea15e0660ef70 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 17:09:55 +0200 Subject: [PATCH 063/380] Add Apple Vision as an OCR engine on macOS macOS ships a text recognizer - the one behind Live Text and Preview - and SE was not using it, while local OCR on a Mac was the weakest of the three platforms: Tesseract wants Homebrew, PaddleOCR wants pip, and CrispEmbed has no Intel build at all. On an Intel Mac that left no local engine SE could offer without sending the user to a package manager. Apple Vision needs no download, no runtime, no model and no API key, so it is now the first thing a Mac user can reach for. Driven through objc_msgSend, the same way MacWindowsMenuInterop drives AppKit, with Vision dlopen'd on first use since it is not linked into the app. Every entry point reports "unavailable" instead of throwing, so the engine simply does not appear if the framework is ever missing, and the language list is read from the framework rather than hard-coded - the set grows with each macOS release and the recognizer rejects a language it does not know. Two details worth recording: Corners, not boundingBox. A CGRect is four doubles, which x86_64 returns through the hidden-pointer objc_msgSend_stret path while arm64 returns it in registers - two calls to get right, one of which cannot be exercised on an Apple Silicon machine. topLeft/bottomRight are CGPoints, two doubles, returned in registers on both, so the geometry needs one code path. An explicit autorelease pool per call. Nothing drains a .NET worker thread's pool, so without one a batch of a few thousand subtitle images would hold every intermediate NSString and NSData until the process exits. Vision returns observations with no reading order, and splits one visual line into several wherever the gap between words is wide - which is exactly what a two-speaker dialogue line looks like. AppleVisionTextLayout groups them back into visual lines by vertical position and reads each left to right. That logic is pure geometry, so it is tested on any OS, which matters because CI runs on Linux; the interop tests skip off macOS and cover what no pure logic can - the P/Invoke signatures, the selector names, and that Vision reads SE's images. It reads them untouched: a subtitle image with a transparent background gives the same result as the same text composited onto black, so unlike the PaddleOCR path there is no flattening step. There is a test pinning that, so a future macOS that changes it will say so. Verified against generated SE-shaped images (white text, black outline, transparent background) on Apple Silicon: single line, two-line, and the transparent-vs-black equivalence all read exactly. Full UI suite green at 3589 passed, 1 skipped. Co-Authored-By: Claude Opus 5 --- src/ui/Features/Ocr/Engines/AppleVisionOcr.cs | 418 ++++++++++++++++++ .../Ocr/Engines/AppleVisionTextLayout.cs | 125 ++++++ src/ui/Features/Ocr/Engines/OcrEngineItem.cs | 8 + src/ui/Features/Ocr/Engines/OcrEngineType.cs | 1 + src/ui/Features/Ocr/OcrViewModel.cs | 85 ++++ src/ui/Features/Ocr/OcrWindow.cs | 9 + src/ui/Logic/Config/SeOcr.cs | 7 + .../Ocr/AppleVisionTextLayoutTests.cs | 152 +++++++ .../Ocr/Engines/AppleVisionOcrTests.cs | 184 ++++++++ 9 files changed, 989 insertions(+) create mode 100644 src/ui/Features/Ocr/Engines/AppleVisionOcr.cs create mode 100644 src/ui/Features/Ocr/Engines/AppleVisionTextLayout.cs create mode 100644 tests/UI/Features/Ocr/AppleVisionTextLayoutTests.cs create mode 100644 tests/UI/Features/Ocr/Engines/AppleVisionOcrTests.cs diff --git a/src/ui/Features/Ocr/Engines/AppleVisionOcr.cs b/src/ui/Features/Ocr/Engines/AppleVisionOcr.cs new file mode 100644 index 00000000000..41d6dad13ee --- /dev/null +++ b/src/ui/Features/Ocr/Engines/AppleVisionOcr.cs @@ -0,0 +1,418 @@ +using SkiaSharp; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; + +namespace Nikse.SubtitleEdit.Features.Ocr.Engines; + +/// +/// OCR through Apple's Vision framework (VNRecognizeTextRequest) - the same recognizer Preview +/// and Live Text use. +/// +/// It is the only local OCR engine on macOS that needs no download at all: no runtime, no model, +/// no Homebrew, no Python. That matters most on Intel Macs, where CrispEmbed has no build and +/// the remaining local options are Tesseract (brew) and PaddleOCR (pip) - see +/// . +/// +/// Driven through objc_msgSend rather than a binding library, the same way +/// drives AppKit. Vision is not linked into the +/// process, so the framework is dlopen'd on first use; every entry point below is defensive and +/// reports "unavailable" rather than throwing, so a macOS that ever lacks the framework simply +/// does not offer the engine. +/// +public static class AppleVisionOcr +{ + public const string StaticName = "Apple Vision"; + + private const string LibObjC = "/usr/lib/libobjc.A.dylib"; + private const string LibSystem = "/usr/lib/libSystem.dylib"; + private const string VisionFramework = "/System/Library/Frameworks/Vision.framework/Vision"; + + // VNRequestTextRecognitionLevel + private const long RecognitionLevelAccurate = 0; + private const long RecognitionLevelFast = 1; + + [DllImport(LibSystem)] + private static extern IntPtr dlopen(string path, int mode); + + [DllImport(LibObjC)] + private static extern IntPtr objc_getClass(string name); + + [DllImport(LibObjC)] + private static extern IntPtr sel_registerName(string name); + + [DllImport(LibObjC, EntryPoint = "objc_msgSend")] + private static extern IntPtr SendPtr(IntPtr receiver, IntPtr sel); + + [DllImport(LibObjC, EntryPoint = "objc_msgSend")] + private static extern IntPtr SendPtrPtr(IntPtr receiver, IntPtr sel, IntPtr a); + + [DllImport(LibObjC, EntryPoint = "objc_msgSend")] + private static extern IntPtr SendPtrPtrPtr(IntPtr receiver, IntPtr sel, IntPtr a, IntPtr b); + + [DllImport(LibObjC, EntryPoint = "objc_msgSend")] + private static extern IntPtr SendPtrBytesLong(IntPtr receiver, IntPtr sel, byte[] bytes, long length); + + [DllImport(LibObjC, EntryPoint = "objc_msgSend")] + private static extern IntPtr SendPtrUtf8(IntPtr receiver, IntPtr sel, string a); + + [DllImport(LibObjC, EntryPoint = "objc_msgSend")] + private static extern IntPtr SendPtrLong(IntPtr receiver, IntPtr sel, long a); + + [DllImport(LibObjC, EntryPoint = "objc_msgSend")] + private static extern long SendLong(IntPtr receiver, IntPtr sel); + + [DllImport(LibObjC, EntryPoint = "objc_msgSend")] + private static extern void SendVoid(IntPtr receiver, IntPtr sel); + + [DllImport(LibObjC, EntryPoint = "objc_msgSend")] + private static extern void SendVoidLong(IntPtr receiver, IntPtr sel, long a); + + [DllImport(LibObjC, EntryPoint = "objc_msgSend")] + private static extern void SendVoidBool(IntPtr receiver, IntPtr sel, byte a); + + [DllImport(LibObjC, EntryPoint = "objc_msgSend")] + private static extern void SendVoidPtr(IntPtr receiver, IntPtr sel, IntPtr a); + + /// + /// CGPoint. Read the corners rather than boundingBox on purpose: a CGRect is four + /// doubles, and on x86_64 a struct that size comes back through the hidden-pointer + /// objc_msgSend_stret path while arm64 returns it in registers - two different calls to get + /// right, one of which cannot be exercised on an Apple Silicon dev machine. A CGPoint is two + /// doubles, which both architectures return in registers, so the corners need one code path. + /// + [StructLayout(LayoutKind.Sequential)] + private struct CGPoint + { + public double X; + public double Y; + } + + [DllImport(LibObjC, EntryPoint = "objc_msgSend")] + private static extern CGPoint SendPoint(IntPtr receiver, IntPtr sel); + + private static readonly object AvailabilityLock = new(); + private static bool _availabilityChecked; + private static bool _isAvailable; + private static List? _languages; + + /// + /// True when this process can actually run a Vision text request. Checked once and cached: + /// the answer cannot change while SE runs, and + /// asks on every OCR window. + /// + public static bool IsAvailable() + { + if (!OperatingSystem.IsMacOS()) + { + return false; + } + + lock (AvailabilityLock) + { + if (_availabilityChecked) + { + return _isAvailable; + } + + _availabilityChecked = true; + try + { + // RTLD_LAZY. Vision is not linked into the app, so without this the runtime + // classes below are simply not registered and objc_getClass returns nil. + _isAvailable = dlopen(VisionFramework, 1) != IntPtr.Zero && + objc_getClass("VNRecognizeTextRequest") != IntPtr.Zero && + objc_getClass("VNImageRequestHandler") != IntPtr.Zero; + } + catch + { + _isAvailable = false; + } + + return _isAvailable; + } + } + + /// + /// The languages this machine's Vision can recognize, straight from the framework rather + /// than a hard-coded list: the set grows with macOS releases, and the recognizer rejects a + /// language it does not know. + /// + public static List GetLanguages() + { + lock (AvailabilityLock) + { + if (_languages != null) + { + return _languages; + } + + _languages = ReadSupportedLanguages(); + return _languages; + } + } + + private static List ReadSupportedLanguages() + { + var list = new List(); + if (!IsAvailable()) + { + return list; + } + + var pool = NewAutoreleasePool(); + var request = IntPtr.Zero; + try + { + request = NewRequest(); + if (request == IntPtr.Zero) + { + return list; + } + + var supported = SendPtrPtr(request, Sel("supportedRecognitionLanguagesAndReturnError:"), IntPtr.Zero); + if (supported == IntPtr.Zero) + { + return list; + } + + var count = SendLong(supported, Sel("count")); + for (long i = 0; i < count; i++) + { + var code = NsToString(SendPtrLong(supported, Sel("objectAtIndex:"), i)); + if (!string.IsNullOrEmpty(code)) + { + list.Add(new OcrLanguage2(code!, DisplayName(code!))); + } + } + } + catch + { + // A machine that cannot list languages cannot recognize text either; the empty + // list leaves the engine selectable but with nothing to choose, which the caller + // reports better than an exception out of a property getter would. + } + finally + { + Release(request); + DrainAutoreleasePool(pool); + } + + return list; + } + + /// + /// Turns Vision's BCP-47 tag ("pt-BR", "zh-Hans") into something readable. .NET's own + /// culture data already knows these, and falls back to the tag itself for the ones it does + /// not - which is still better than showing nothing. + /// + private static string DisplayName(string code) + { + try + { + var culture = CultureInfo.GetCultureInfo(code); + if (!string.IsNullOrWhiteSpace(culture.EnglishName) && !culture.EnglishName.StartsWith("Unknown", StringComparison.OrdinalIgnoreCase)) + { + return $"{culture.EnglishName} ({code})"; + } + } + catch (CultureNotFoundException) + { + // fall through to the raw tag + } + + return code; + } + + /// + /// Recognizes the text in one subtitle image. + /// + /// The subtitle image. A transparent background is fine - Vision reads + /// these as well as it reads the same text on black, so SE's images go in untouched. + /// A tag from . Empty means "let Vision + /// pick", which is its own default. + /// Vision's fast recognition level instead of the accurate one. Roughly an + /// order of magnitude quicker and noticeably worse on the small, soft text subtitles are made + /// of, so the accurate level is the default. + /// Checked before the request starts; a Vision request on one + /// subtitle image is short enough that it is not worth interrupting once running. + /// The recognized text, or an empty string when Vision found none. + public static string Ocr(SKBitmap? bitmap, string? languageCode, bool fast, CancellationToken cancellationToken) + { + if (bitmap == null || bitmap.Width < 1 || bitmap.Height < 1 || !IsAvailable()) + { + return string.Empty; + } + + cancellationToken.ThrowIfCancellationRequested(); + + var png = EncodePng(bitmap); + if (png == null || png.Length == 0) + { + return string.Empty; + } + + // Every NSString / NSArray / NSData below is autoreleased. Nothing drains the thread's + // pool for us on a .NET worker thread, so without an explicit pool a batch of a few + // thousand subtitle images would hold every intermediate object until the process ends. + var pool = NewAutoreleasePool(); + var request = IntPtr.Zero; + var handler = IntPtr.Zero; + try + { + request = NewRequest(); + if (request == IntPtr.Zero) + { + return string.Empty; + } + + SendVoidLong(request, Sel("setRecognitionLevel:"), fast ? RecognitionLevelFast : RecognitionLevelAccurate); + SendVoidBool(request, Sel("setUsesLanguageCorrection:"), 1); + + if (!string.IsNullOrEmpty(languageCode)) + { + var languages = SendPtrPtr(Cls("NSArray"), Sel("arrayWithObject:"), NsString(languageCode!)); + if (languages != IntPtr.Zero) + { + SendVoidPtr(request, Sel("setRecognitionLanguages:"), languages); + } + } + + var data = SendPtrBytesLong(Cls("NSData"), Sel("dataWithBytes:length:"), png, png.Length); + if (data == IntPtr.Zero) + { + return string.Empty; + } + + handler = SendPtrPtrPtr(SendPtr(Cls("VNImageRequestHandler"), Sel("alloc")), + Sel("initWithData:options:"), data, IntPtr.Zero); + if (handler == IntPtr.Zero) + { + return string.Empty; + } + + var requests = SendPtrPtr(Cls("NSArray"), Sel("arrayWithObject:"), request); + SendPtrPtrPtr(handler, Sel("performRequests:error:"), requests, IntPtr.Zero); + + return AppleVisionTextLayout.Compose(ReadObservations(request)); + } + catch + { + // One unreadable image must not stop a run of thousands: an empty result reads as + // "nothing recognized here", which is what the caller already handles. + return string.Empty; + } + finally + { + Release(handler); + Release(request); + DrainAutoreleasePool(pool); + } + } + + private static IEnumerable ReadObservations(IntPtr request) + { + var results = SendPtr(request, Sel("results")); + if (results == IntPtr.Zero) + { + yield break; + } + + var count = SendLong(results, Sel("count")); + for (long i = 0; i < count; i++) + { + var observation = SendPtrLong(results, Sel("objectAtIndex:"), i); + if (observation == IntPtr.Zero) + { + continue; + } + + // topCandidates: returns the recognizer's ranked readings; one is enough, and asking + // for more makes Vision do extra work per observation. + var candidates = SendPtrLong(observation, Sel("topCandidates:"), 1); + if (candidates == IntPtr.Zero || SendLong(candidates, Sel("count")) < 1) + { + continue; + } + + var top = SendPtrLong(candidates, Sel("objectAtIndex:"), 0); + var text = NsToString(SendPtr(top, Sel("string"))); + if (string.IsNullOrEmpty(text)) + { + continue; + } + + var topLeft = SendPoint(observation, Sel("topLeft")); + var bottomRight = SendPoint(observation, Sel("bottomRight")); + + yield return new AppleVisionObservation(text!, topLeft.X, bottomRight.X, topLeft.Y, bottomRight.Y); + } + } + + private static byte[]? EncodePng(SKBitmap bitmap) + { + try + { + using var image = SKImage.FromBitmap(bitmap); + using var data = image?.Encode(SKEncodedImageFormat.Png, 100); + return data?.ToArray(); + } + catch + { + return null; + } + } + + private static IntPtr NewRequest() + { + var requestClass = Cls("VNRecognizeTextRequest"); + return requestClass == IntPtr.Zero + ? IntPtr.Zero + : SendPtr(SendPtr(requestClass, Sel("alloc")), Sel("init")); + } + + private static IntPtr NewAutoreleasePool() + { + var poolClass = Cls("NSAutoreleasePool"); + return poolClass == IntPtr.Zero + ? IntPtr.Zero + : SendPtr(SendPtr(poolClass, Sel("alloc")), Sel("init")); + } + + private static void DrainAutoreleasePool(IntPtr pool) + { + if (pool != IntPtr.Zero) + { + SendVoid(pool, Sel("drain")); + } + } + + private static void Release(IntPtr obj) + { + if (obj != IntPtr.Zero) + { + SendVoid(obj, Sel("release")); + } + } + + private static IntPtr Cls(string name) => objc_getClass(name); + + private static IntPtr Sel(string name) => sel_registerName(name); + + private static IntPtr NsString(string value) => + SendPtrUtf8(Cls("NSString"), Sel("stringWithUTF8String:"), value); + + private static string? NsToString(IntPtr nsString) + { + if (nsString == IntPtr.Zero) + { + return null; + } + + var utf8 = SendPtr(nsString, Sel("UTF8String")); + return utf8 == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(utf8); + } +} diff --git a/src/ui/Features/Ocr/Engines/AppleVisionTextLayout.cs b/src/ui/Features/Ocr/Engines/AppleVisionTextLayout.cs new file mode 100644 index 00000000000..f60ae3d0270 --- /dev/null +++ b/src/ui/Features/Ocr/Engines/AppleVisionTextLayout.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Nikse.SubtitleEdit.Features.Ocr.Engines; + +/// +/// One recognized piece of text and where Vision found it. +/// +/// +/// The coordinates are Vision's own: normalized to 0-1 of the image, with the origin at the +/// bottom left, so a larger Y is higher up the image. Kept in Vision's system rather +/// than flipped at the interop boundary, so the numbers here can be compared against what the +/// framework prints while debugging. +/// +public readonly struct AppleVisionObservation +{ + public string Text { get; } + public double Left { get; } + public double Right { get; } + public double Top { get; } + public double Bottom { get; } + + public AppleVisionObservation(string text, double left, double right, double top, double bottom) + { + Text = text; + Left = left; + Right = right; + Top = top; + Bottom = bottom; + } + + internal double CenterY => (Top + Bottom) / 2; + internal double Height => Math.Abs(Top - Bottom); +} + +/// +/// Turns Vision's observations into subtitle text. +/// +/// Vision returns a flat, unordered set of observations, and it splits one visual line into +/// several of them wherever the gap between words is wide - which is exactly what a two-speaker +/// dialogue line ("- Yes. - No.") looks like. Reading the list in the order it arrives +/// would scramble such a line, and a naive "one observation per line" rule would break it in +/// two. So the observations are grouped back into visual lines by their vertical position, then +/// read left to right within each line. +/// +public static class AppleVisionTextLayout +{ + /// + /// Two observations belong to the same visual line when their vertical centres are closer + /// than this fraction of the shorter one's height. Half a line height is comfortably below + /// the spacing of even tightly set subtitles, and comfortably above the wobble Vision leaves + /// on parts of the same line. + /// + private const double SameLineToleranceOfHeight = 0.5; + + public static string Compose(IEnumerable observations) + { + if (observations == null) + { + return string.Empty; + } + + var ordered = observations + .Where(o => !string.IsNullOrWhiteSpace(o.Text)) + .OrderByDescending(o => o.CenterY) // Vision's Y grows upwards, so this is top-first. + .ToList(); + + if (ordered.Count == 0) + { + return string.Empty; + } + + var sb = new StringBuilder(); + var line = new List(); + + foreach (var observation in ordered) + { + if (line.Count > 0 && !BelongsToLine(line, observation)) + { + AppendLine(sb, line); + line.Clear(); + } + + line.Add(observation); + } + + AppendLine(sb, line); + + return sb.ToString(); + } + + /// + /// Compared against the line's lowest member rather than its first: on a line Vision split + /// into several observations the pieces drift slightly, and measuring from the piece nearest + /// the candidate keeps a long line from splitting halfway along. + /// + private static bool BelongsToLine(List line, AppleVisionObservation candidate) + { + var nearest = line.MinBy(o => Math.Abs(o.CenterY - candidate.CenterY)); + var height = Math.Min(nearest.Height, candidate.Height); + if (height <= 0) + { + return false; + } + + return Math.Abs(nearest.CenterY - candidate.CenterY) < height * SameLineToleranceOfHeight; + } + + private static void AppendLine(StringBuilder sb, List line) + { + if (line.Count == 0) + { + return; + } + + if (sb.Length > 0) + { + sb.Append(Environment.NewLine); + } + + sb.Append(string.Join(" ", line.OrderBy(o => o.Left).Select(o => o.Text.Trim()))); + } +} diff --git a/src/ui/Features/Ocr/Engines/OcrEngineItem.cs b/src/ui/Features/Ocr/Engines/OcrEngineItem.cs index e5cbdd22683..bab14ca477b 100644 --- a/src/ui/Features/Ocr/Engines/OcrEngineItem.cs +++ b/src/ui/Features/Ocr/Engines/OcrEngineItem.cs @@ -43,6 +43,14 @@ public static List GetOcrEngines() list.Add(new("Ollama", OcrEngineType.Ollama, "Ollama e.g. via llama-vision", "", "http://localhost:11434/api/chat")); list.Add(new("llama.cpp", OcrEngineType.LlamaCpp, "llama.cpp", "", "http://127.0.0.1:8080/v1/chat/completions")); + // Apple Vision sits with the other local engines, above the cloud ones: on macOS it is + // the only engine that needs no download, no runtime and no key, and on an Intel Mac it + // is the only local engine SE can offer without sending the user to Homebrew or pip. + if (AppleVisionOcr.IsAvailable()) + { + list.Add(new(AppleVisionOcr.StaticName, OcrEngineType.AppleVision, "Apple Vision is macOS's built-in OCR engine (no download needed)", "", "")); + } + if (CrispEmbedEngine.CanBeDownloaded()) { list.Add(new(CrispEmbedEngine.StaticName, OcrEngineType.CrispEmbed, "CrispEmbed is a local OCR engine with multiple model backends (free/open source)", "", "")); diff --git a/src/ui/Features/Ocr/Engines/OcrEngineType.cs b/src/ui/Features/Ocr/Engines/OcrEngineType.cs index fbad6371737..a7ad8c38219 100644 --- a/src/ui/Features/Ocr/Engines/OcrEngineType.cs +++ b/src/ui/Features/Ocr/Engines/OcrEngineType.cs @@ -17,4 +17,5 @@ public enum OcrEngineType BinaryImageCompare, Glm, CrispEmbed, + AppleVision, } \ No newline at end of file diff --git a/src/ui/Features/Ocr/OcrViewModel.cs b/src/ui/Features/Ocr/OcrViewModel.cs index be5618036e6..7b20309a5ae 100644 --- a/src/ui/Features/Ocr/OcrViewModel.cs +++ b/src/ui/Features/Ocr/OcrViewModel.cs @@ -114,6 +114,7 @@ public partial class OcrViewModel : ObservableObject [ObservableProperty] private bool _isTesseractVisible; [ObservableProperty] private bool _isBinaryImageCompareVisible; [ObservableProperty] private bool _isPaddleOcrVisible; + [ObservableProperty] private bool _isAppleVisionVisible; [ObservableProperty] private bool _isGoogleVisionVisible; [ObservableProperty] private bool _isGoogleLensVisible; [ObservableProperty] private bool _isMistralOcrVisible; @@ -126,6 +127,8 @@ public partial class OcrViewModel : ObservableObject [ObservableProperty] private string _mistralApiKey; [ObservableProperty] private ObservableCollection _googleVisionLanguages; [ObservableProperty] private OcrLanguage? _selectedGoogleVisionLanguage; + [ObservableProperty] private ObservableCollection _appleVisionLanguages; + [ObservableProperty] private OcrLanguage2? _selectedAppleVisionLanguage; [ObservableProperty] private ObservableCollection _googleLensLanguages; [ObservableProperty] private OcrLanguage2 _selectedGoogleLensLanguage; [ObservableProperty] private ObservableCollection _paddleOcrLanguages; @@ -258,6 +261,7 @@ public OcrViewModel( GoogleVisionApiKey = string.Empty; MistralApiKey = string.Empty; GoogleVisionLanguages = new ObservableCollection(GoogleVisionOcr.GetLanguages().OrderBy(p => p.ToString())); + AppleVisionLanguages = new ObservableCollection(AppleVisionOcr.GetLanguages().OrderBy(p => p.ToString())); GoogleLensLanguages = new ObservableCollection(GoogleLensOcr.GetLanguages().OrderBy(p => p.ToString())); SelectedGoogleLensLanguage = GoogleLensLanguages.FirstOrDefault(p => p.Code == "en") ?? GoogleLensLanguages.First(); PaddleOcrLanguages = new ObservableCollection(PaddleOcr.GetLanguages().OrderBy(p => p.ToString())); @@ -312,6 +316,7 @@ private void LoadSettings() GoogleVisionApiKey = ocr.GoogleVisionApiKey; MistralApiKey = ocr.MistralApiKey; SelectedGoogleVisionLanguage = GoogleVisionLanguages.FirstOrDefault(p => p.Code == ocr.GoogleVisionLanguage); + SelectedAppleVisionLanguage = AppleVisionLanguages.FirstOrDefault(p => p.Code == ocr.AppleVisionLanguage); var paddleOcrLastLanguage = PaddleOcr.NormalizeLanguageCode(Se.Settings.Ocr.PaddleOcrLastLanguage); SelectedPaddleOcrLanguage = PaddleOcrLanguages.FirstOrDefault(p => p.Code == paddleOcrLastLanguage) ?? PaddleOcrLanguages.FirstOrDefault(p => p.Code == "en") ?? @@ -360,6 +365,7 @@ private void SaveSettings() ocr.GoogleVisionApiKey = GoogleVisionApiKey; ocr.MistralApiKey = MistralApiKey; ocr.GoogleVisionLanguage = SelectedGoogleVisionLanguage?.Code ?? "en"; + ocr.AppleVisionLanguage = SelectedAppleVisionLanguage?.Code ?? ocr.AppleVisionLanguage; ocr.TesseractLastLanguage = SelectedTesseractDictionaryItem?.Code ?? "eng"; ocr.TesseractEngineMode = SelectedTesseractEngineMode?.Oem ?? 3; ocr.DoFixOcrErrors = DoFixOcrErrors; @@ -428,6 +434,23 @@ private static string GetDictionaryNameNone() partial void OnSelectedPaddleOcrLanguageChanged(OcrLanguage2? value) => AutoSelectDictionaryForOcrLanguage(value?.Code); partial void OnSelectedGoogleLensLanguageChanged(OcrLanguage2 value) => AutoSelectDictionaryForOcrLanguage(value?.Code); partial void OnSelectedGoogleVisionLanguageChanged(OcrLanguage? value) => AutoSelectDictionaryForOcrLanguage(value?.Code); + partial void OnSelectedAppleVisionLanguageChanged(OcrLanguage2? value) => AutoSelectDictionaryForOcrLanguage(LanguagePartOf(value?.Code)); + + /// + /// The language part of a BCP-47 tag - "pt-BR" becomes "pt". Apple Vision names its + /// languages that way, while the dictionary and ISO lookups here are keyed on the bare + /// language code. + /// + private static string? LanguagePartOf(string? bcp47) + { + if (string.IsNullOrEmpty(bcp47)) + { + return bcp47; + } + + var dash = bcp47!.IndexOf('-'); + return dash < 0 ? bcp47 : bcp47.Substring(0, dash); + } partial void OnSelectedOllamaLanguageChanged(string? value) => AutoSelectDictionaryForOcrLanguage(Iso639Dash2LanguageCode.GetTwoLetterCodeFromEnglishName(value ?? string.Empty)); private void AutoSelectDictionaryForOcrLanguage(string? languageCode) @@ -496,6 +519,12 @@ private void AutoDetectSourceLanguage(string? languageCode = null) SelectedGoogleLensLanguage = lensLanguage; } + var appleVisionLanguage = AppleVisionLanguages.FirstOrDefault(p => LanguagePartOf(p.Code) == iso.TwoLetterCode); + if (appleVisionLanguage != null) + { + SelectedAppleVisionLanguage = appleVisionLanguage; + } + var visionLanguage = GoogleVisionLanguages.FirstOrDefault(p => p.Code == iso.ThreeLetterCode || p.Code == iso.TwoLetterCode); if (visionLanguage != null) { @@ -2661,6 +2690,10 @@ await MessageBox.Show( { RunGoogleVisionOcr(selectedIndices, _cancellationTokenSource.Token); } + else if (ocrEngine.EngineType == OcrEngineType.AppleVision) + { + RunAppleVisionOcr(selectedIndices, _cancellationTokenSource.Token); + } else if (ocrEngine.EngineType == OcrEngineType.GoogleLens) { if (Configuration.IsRunningOnWindows && !File.Exists(Path.Combine(Se.GoogleLensOcrFolder, GoogleLensOcr.ExeFileName))) @@ -4465,6 +4498,44 @@ private void RunGoogleVisionOcr(List selectedIndices, CancellationToken can }); } + private void RunAppleVisionOcr(List selectedIndices, CancellationToken cancellationToken) + { + var languageCode = SelectedAppleVisionLanguage?.Code ?? string.Empty; + + _ = Task.Run(() => + { + try + { + for (var processedIndex = 0; processedIndex < selectedIndices.Count; processedIndex++) + { + var i = selectedIndices[processedIndex]; + if (cancellationToken.IsCancellationRequested) + { + return; + } + + UpdateOcrProgress(processedIndex + 1, selectedIndices.Count); + + var item = OcrSubtitleItems[i]; + var bitmap = item.GetSkBitmap(); + + OcrUiUpdates.EnqueueSelect(i); + + item.Text = AppleVisionOcr.Ocr(bitmap, languageCode, fast: false, cancellationToken); + + OcrFixLineAndSetText(i, item); + } + } + catch (OperationCanceledException) + { + } + finally + { + PauseOcr(); + } + }, cancellationToken); + } + private async Task CheckAndDownloadTesseract() { if (OperatingSystem.IsWindows()) @@ -4988,6 +5059,7 @@ private void EngineSelectionChanged() IsCrispEmbedVisible = et == OcrEngineType.CrispEmbed; IsTesseractVisible = et == OcrEngineType.Tesseract; IsPaddleOcrVisible = et == OcrEngineType.PaddleOcrStandalone || et == OcrEngineType.PaddleOcrPython; + IsAppleVisionVisible = et == OcrEngineType.AppleVision; IsGoogleVisionVisible = et == OcrEngineType.GoogleVision; IsGoogleLensVisible = et == OcrEngineType.GoogleLens || et == OcrEngineType.GoogleLensSharp; IsMistralOcrVisible = et == OcrEngineType.Mistral; @@ -5036,6 +5108,19 @@ private void EngineSelectionChanged() } } + if (IsAppleVisionVisible) + { + if (SelectedAppleVisionLanguage == null) + { + // Vision's tags are BCP-47 ("en-US", "pt-BR"), so match the source language on + // the part before the region rather than on the whole tag. + SelectedAppleVisionLanguage = + AppleVisionLanguages.FirstOrDefault(p => _sourceLanguageIso != null && LanguagePartOf(p.Code) == _sourceLanguageIso.TwoLetterCode) ?? + AppleVisionLanguages.FirstOrDefault(p => p.Code == "en-US") ?? + AppleVisionLanguages.FirstOrDefault(); + } + } + if (IsGoogleVisionVisible) { if (SelectedGoogleVisionLanguage == null) diff --git a/src/ui/Features/Ocr/OcrWindow.cs b/src/ui/Features/Ocr/OcrWindow.cs index 9368cbf7b43..8233b9d3cc3 100644 --- a/src/ui/Features/Ocr/OcrWindow.cs +++ b/src/ui/Features/Ocr/OcrWindow.cs @@ -375,6 +375,15 @@ private static Grid MakeTopControlsView(OcrViewModel vm) .BindIsVisible(vm, nameof(vm.IsCrispEmbedVisible)) .BindIsEnabled(vm, nameof(OcrViewModel.IsOcrRunning), new InverseBooleanConverter()), + // Apple Vision settings - language only: the engine ships with macOS, so there is + // nothing to download, no key to enter and no model to pick. + UiUtil.MakeLabel(Se.Language.General.Language, vm => vm.IsAppleVisionVisible), + UiUtil.MakeComboBox(vm.AppleVisionLanguages, vm, nameof(vm.SelectedAppleVisionLanguage), + nameof(vm.IsAppleVisionVisible)) + .WithWidth(180) + .WithMarginRight(10) + .BindIsEnabled(vm, nameof(OcrViewModel.IsOcrRunning), new InverseBooleanConverter()), + // Google vision settings UiUtil.MakeLabel(Se.Language.General.Language, vm => vm.IsGoogleVisionVisible), UiUtil.MakeComboBox(vm.GoogleVisionLanguages, vm, nameof(vm.SelectedGoogleVisionLanguage), diff --git a/src/ui/Logic/Config/SeOcr.cs b/src/ui/Logic/Config/SeOcr.cs index de7edf6f557..455b4817167 100644 --- a/src/ui/Logic/Config/SeOcr.cs +++ b/src/ui/Logic/Config/SeOcr.cs @@ -28,6 +28,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; } @@ -107,6 +113,7 @@ public SeOcr() GoogleVisionApiKey = string.Empty; GoogleVisionLanguage = "en"; + AppleVisionLanguage = "en-US"; PaddleOcrMode = "mobile"; PaddleOcrLastLanguage = "en"; diff --git a/tests/UI/Features/Ocr/AppleVisionTextLayoutTests.cs b/tests/UI/Features/Ocr/AppleVisionTextLayoutTests.cs new file mode 100644 index 00000000000..303b26868bc --- /dev/null +++ b/tests/UI/Features/Ocr/AppleVisionTextLayoutTests.cs @@ -0,0 +1,152 @@ +using Nikse.SubtitleEdit.Features.Ocr.Engines; + +namespace UITests.Features.Ocr; + +/// +/// Turning Vision's observations back into subtitle text. +/// +/// Vision hands back a flat set of text observations with no reading order, and it splits one +/// visual line into several observations wherever the gap between words is wide - which is +/// exactly what a two-speaker dialogue line looks like. So the two things that can go wrong are +/// reading the lines in the wrong order and breaking one line into two, and both are decided +/// purely by the geometry, with no framework involved. That makes this testable on any OS, which +/// matters because CI runs on Linux where Vision does not exist. +/// +/// Coordinates below are Vision's own: normalized 0-1, origin at the BOTTOM left, so a larger Y +/// is higher up the image. +/// +public class AppleVisionTextLayoutTests +{ + private static AppleVisionObservation Obs(string text, double left, double right, double top, double bottom) + { + return new AppleVisionObservation(text, left, right, top, bottom); + } + + [Fact] + public void Compose_NoObservations_IsEmpty() + { + Assert.Equal(string.Empty, AppleVisionTextLayout.Compose([])); + } + + [Fact] + public void Compose_Null_IsEmpty() + { + Assert.Equal(string.Empty, AppleVisionTextLayout.Compose(null!)); + } + + [Fact] + public void Compose_SingleObservation_IsThatText() + { + var text = AppleVisionTextLayout.Compose([Obs("It's a beautiful day.", 0.25, 0.75, 0.81, 0.35)]); + + Assert.Equal("It's a beautiful day.", text); + } + + [Fact] + public void Compose_TwoLines_ReadsTopLineFirst() + { + // Vision's Y grows upwards, so the upper line is the one with the LARGER Y. Reading them + // in ascending Y - the intuitive reading for a top-left origin - would swap the lines. + var upper = Obs("- Are you coming with us?", 0.16, 0.83, 0.90, 0.61); + var lower = Obs("- No, I'll stay here.", 0.26, 0.74, 0.49, 0.16); + + var text = AppleVisionTextLayout.Compose([upper, lower]); + + Assert.Equal($"- Are you coming with us?{Environment.NewLine}- No, I'll stay here.", text); + } + + [Fact] + public void Compose_ObservationsArriveOutOfOrder_StillReadsTopToBottom() + { + var upper = Obs("First line", 0.16, 0.83, 0.90, 0.61); + var middle = Obs("Second line", 0.16, 0.83, 0.58, 0.33); + var lower = Obs("Third line", 0.26, 0.74, 0.30, 0.05); + + var text = AppleVisionTextLayout.Compose([lower, upper, middle]); + + Assert.Equal($"First line{Environment.NewLine}Second line{Environment.NewLine}Third line", text); + } + + [Fact] + public void Compose_OneLineSplitByAWideGap_StaysOneLine() + { + // The dialogue case: "- Yes. - No." on a single visual line comes back as two + // observations at the same height. Joined with a space, not broken onto two lines. + var left = Obs("- Yes.", 0.10, 0.30, 0.60, 0.40); + var right = Obs("- No.", 0.70, 0.90, 0.61, 0.41); + + var text = AppleVisionTextLayout.Compose([left, right]); + + Assert.Equal("- Yes. - No.", text); + } + + [Fact] + public void Compose_SplitLine_ReadsLeftToRightWhateverTheOrder() + { + var left = Obs("Hello", 0.10, 0.30, 0.60, 0.40); + var right = Obs("world", 0.70, 0.90, 0.60, 0.40); + + var text = AppleVisionTextLayout.Compose([right, left]); + + Assert.Equal("Hello world", text); + } + + [Fact] + public void Compose_TwoSplitLines_GroupsEachLineSeparately() + { + var upperLeft = Obs("Upper", 0.10, 0.30, 0.90, 0.70); + var upperRight = Obs("line", 0.70, 0.90, 0.90, 0.70); + var lowerLeft = Obs("Lower", 0.10, 0.30, 0.40, 0.20); + var lowerRight = Obs("line", 0.70, 0.90, 0.40, 0.20); + + var text = AppleVisionTextLayout.Compose([lowerRight, upperLeft, lowerLeft, upperRight]); + + Assert.Equal($"Upper line{Environment.NewLine}Lower line", text); + } + + [Fact] + public void Compose_SlightVerticalWobbleOnOneLine_DoesNotSplitIt() + { + // Vision leaves a little vertical drift on parts of the same line; anything under half a + // line height has to stay one line. + var left = Obs("Left part", 0.10, 0.40, 0.600, 0.400); + var right = Obs("right part", 0.60, 0.90, 0.615, 0.415); + + var text = AppleVisionTextLayout.Compose([left, right]); + + Assert.Equal("Left part right part", text); + } + + [Fact] + public void Compose_LinesCloseTogether_AreStillTwoLines() + { + // Tightly set two-liner: the gap is small but the centres are still more than half a + // line height apart, so it must not collapse into one line. + var upper = Obs("Upper", 0.10, 0.90, 0.90, 0.62); + var lower = Obs("Lower", 0.10, 0.90, 0.58, 0.30); + + var text = AppleVisionTextLayout.Compose([upper, lower]); + + Assert.Equal($"Upper{Environment.NewLine}Lower", text); + } + + [Fact] + public void Compose_BlankObservations_AreDropped() + { + var real = Obs("Real text", 0.10, 0.90, 0.60, 0.40); + var blank = Obs(" ", 0.10, 0.90, 0.30, 0.10); + + var text = AppleVisionTextLayout.Compose([real, blank]); + + Assert.Equal("Real text", text); + } + + [Fact] + public void Compose_TrimsEachObservation() + { + var left = Obs(" Hello ", 0.10, 0.30, 0.60, 0.40); + var right = Obs(" world ", 0.70, 0.90, 0.60, 0.40); + + Assert.Equal("Hello world", AppleVisionTextLayout.Compose([left, right])); + } +} diff --git a/tests/UI/Features/Ocr/Engines/AppleVisionOcrTests.cs b/tests/UI/Features/Ocr/Engines/AppleVisionOcrTests.cs new file mode 100644 index 00000000000..dc83d6bd48c --- /dev/null +++ b/tests/UI/Features/Ocr/Engines/AppleVisionOcrTests.cs @@ -0,0 +1,184 @@ +using Nikse.SubtitleEdit.Features.Ocr.Engines; +using SkiaSharp; +using System.Threading; + +namespace UITests.Features.Ocr.Engines; + +/// +/// End-to-end cover for the Vision interop: does the framework actually load, list languages and +/// read a subtitle image through objc_msgSend. +/// +/// Every test here is skipped off macOS, so CI (Linux) never runs them - which is the point of +/// carrying the logic that can be tested anywhere. What +/// is left here is exactly the part no amount of pure logic can stand in for: the P/Invoke +/// signatures, the selector names, and whether Vision reads SE-shaped images at all. +/// +/// The images are drawn here rather than checked in: white subtitle text with a black outline on +/// a transparent background is what SE hands an OCR engine once it has pulled a Blu-ray sup or +/// VobSub image apart, and drawing it keeps the fixture honest about that. +/// +public class AppleVisionOcrTests +{ + private static bool SkipIfUnavailable() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip("Apple Vision is macOS only."); + return true; + } + + if (!AppleVisionOcr.IsAvailable()) + { + Assert.Skip("Vision.framework did not load on this machine."); + return true; + } + + return false; + } + + [Fact] + public void IsAvailable_OffMacOs_IsFalse() + { + if (OperatingSystem.IsMacOS()) + { + Assert.Skip("Only meaningful off macOS."); + return; + } + + Assert.False(AppleVisionOcr.IsAvailable()); + Assert.Empty(AppleVisionOcr.GetLanguages()); + } + + [Fact] + public void GetLanguages_ReturnsTheFrameworksOwnList() + { + if (SkipIfUnavailable()) + { + return; + } + + var languages = AppleVisionOcr.GetLanguages(); + + Assert.NotEmpty(languages); + + // en-US has been in every revision of the recognizer; asserting the exact set would + // just break on the next macOS that adds a language. + Assert.Contains(languages, l => l.Code == "en-US"); + Assert.All(languages, l => Assert.False(string.IsNullOrWhiteSpace(l.Name))); + } + + [Fact] + public void Ocr_NullBitmap_IsEmptyRatherThanThrowing() + { + Assert.Equal(string.Empty, AppleVisionOcr.Ocr(null, "en-US", fast: false, CancellationToken.None)); + } + + [Fact] + public void Ocr_SingleLineOnTransparentBackground_ReadsTheText() + { + if (SkipIfUnavailable()) + { + return; + } + + using var bitmap = DrawSubtitle(["It's a beautiful day."]); + + var text = AppleVisionOcr.Ocr(bitmap, "en-US", fast: false, CancellationToken.None); + + Assert.Equal("It's a beautiful day.", text); + } + + [Fact] + public void Ocr_TwoLines_KeepsThemInReadingOrder() + { + if (SkipIfUnavailable()) + { + return; + } + + using var bitmap = DrawSubtitle(["- Are you coming with us?", "- No, I will stay here."]); + + var text = AppleVisionOcr.Ocr(bitmap, "en-US", fast: false, CancellationToken.None); + var lines = text.Split(Environment.NewLine); + + Assert.Equal(2, lines.Length); + Assert.StartsWith("- Are you coming", lines[0]); + Assert.StartsWith("- No, I will stay", lines[1]); + } + + [Fact] + public void Ocr_TransparentAndBlackBackgrounds_ReadTheSame() + { + if (SkipIfUnavailable()) + { + return; + } + + // SE hands over images with a transparent background. Compositing them onto black first + // would be easy to add and is what the PaddleOCR path does, so this pins the finding + // that Vision does not need it - if a future macOS starts reading the two differently, + // this is the test that says so. + using var transparent = DrawSubtitle(["It's a beautiful day."]); + using var onBlack = DrawSubtitle(["It's a beautiful day."], transparentBackground: false); + + Assert.Equal( + AppleVisionOcr.Ocr(onBlack, "en-US", fast: false, CancellationToken.None), + AppleVisionOcr.Ocr(transparent, "en-US", fast: false, CancellationToken.None)); + } + + [Fact] + public void Ocr_EmptyImage_IsEmpty() + { + if (SkipIfUnavailable()) + { + return; + } + + using var bitmap = DrawSubtitle([]); + + Assert.Equal(string.Empty, AppleVisionOcr.Ocr(bitmap, "en-US", fast: false, CancellationToken.None)); + } + + [Fact] + public void Ocr_CancelledBeforeStart_Throws() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + using var bitmap = DrawSubtitle(["Anything at all"]); + + Assert.Throws( + () => AppleVisionOcr.Ocr(bitmap, "en-US", fast: false, cts.Token)); + } + + private static SKBitmap DrawSubtitle(string[] lines, bool transparentBackground = true) + { + const int width = 1200; + const int lineHeight = 90; + var height = lineHeight * Math.Max(lines.Length, 1) + 40; + + var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(transparentBackground ? SKColors.Transparent : SKColors.Black); + + using var typeface = SKTypeface.FromFamilyName("Helvetica Neue", SKFontStyle.Bold); + using var font = new SKFont(typeface, 64); + using var fill = new SKPaint { Color = SKColors.White, IsAntialias = true }; + using var outline = new SKPaint + { + Color = SKColors.Black, + IsAntialias = true, + Style = SKPaintStyle.Stroke, + StrokeWidth = 4, + }; + + for (var i = 0; i < lines.Length; i++) + { + var x = (width - font.MeasureText(lines[i])) / 2; + var y = 70 + i * lineHeight; + canvas.DrawText(lines[i], x, y, font, outline); + canvas.DrawText(lines[i], x, y, font, fill); + } + + return bitmap; + } +} From 3cebbad87c272281a7649cb3632e306eb2ed7bc0 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 17:24:30 +0200 Subject: [PATCH 064/380] Offer Apple Vision in batch convert and video OCR, and default to it on macOS The engine existed only in the OCR window, so the two other places SE runs OCR still had nothing local a Mac user could pick without a package manager. Batch convert follows the six-place wiring the CrispEmbed work established: engine list, its own language combo, the visibility flag, save/restore of the chosen language, the converter dispatch, and the "returned no text" status in both LanguageOcr and the status colour converter. Two of the six are no-ops here and worth naming: there is no ensure-downloaded gate before a run and no download prompt on the settings dialog's OK, because there is nothing to download - which is also why RunAppleVisionOcr has no "not downloaded" status its siblings all need, and no async at all. The trap that wiring hits is the shared "Language" label, shown for every engine not on an exclusion list: an engine bringing its own combo shows two language controls at once until it is added to that list. There is a test for it, and it runs on every OS since the exclusion is a plain string comparison. Video OCR was gated on a question worth answering first: it feeds crops of real video frames, not clean sup/VobSub images, so the text sits on whatever the picture happens to be. Checked before wiring it up - white subtitle text with a black outline over gradients, busy shapes, per-pixel noise, a bright sky, plain white, and a fine diagonal texture, then a real H.264 frame of ffmpeg's testsrc2 pattern (saturated bars, checkerboards, gradients) with the subtitles burned in. It read both lines correctly on the video frame and on 14 of 16 synthetic lines, the two misses being one "I" read as "1" and one missing space after a dash - the class of thing SE's OCR fix lists already handle. It never failed to find the text or merged the lines, so the engine is offered there too. macOS now defaults to it in all three places, replacing nOCR in the OCR window, Tesseract in batch convert, and the Python PaddleOCR in video OCR - each of which needed a download, a brew, or a pip before a fresh Mac install could OCR anything. Defaults apply to new settings only; a saved engine is the user's choice and is never overwritten. Note the three settings do not agree on what they store: video OCR keeps the enum name, the other two the display name. Full UI suite green: 3593 passed, 1 skipped. Co-Authored-By: Claude Opus 5 --- src/ui/Assets/Languages/English.json | 1 + .../BatchConvertSettingsViewModel.cs | 26 ++++- .../BatchConvertSettingsWindow.cs | 6 +- .../Tools/BatchConvert/BatchConverter.cs | 62 ++++++++++++ .../Video/VideoOcr/VideoOcrEngineItem.cs | 8 ++ .../Video/VideoOcr/VideoOcrViewModel.cs | 29 ++++++ .../Features/Video/VideoOcr/VideoOcrWindow.cs | 8 ++ .../Logic/Config/Language/Ocr/LanguageOcr.cs | 2 + src/ui/Logic/Config/SeBatchConvert.cs | 10 +- src/ui/Logic/Config/SeOcr.cs | 8 +- src/ui/Logic/Config/SeVideoOcr.cs | 13 ++- .../BatchConvertStatusColorConverter.cs | 1 + ...onvertSettingsViewModelAppleVisionTests.cs | 95 +++++++++++++++++++ 13 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 tests/UI/Features/Tools/BatchConvert/BatchConvertSettingsViewModelAppleVisionTests.cs diff --git a/src/ui/Assets/Languages/English.json b/src/ui/Assets/Languages/English.json index 9e479b10be2..d6e28e2f172 100644 --- a/src/ui/Assets/Languages/English.json +++ b/src/ui/Assets/Languages/English.json @@ -3378,6 +3378,7 @@ "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.", "trainNOcrDatabase": "Train nOCR database...", diff --git a/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsViewModel.cs b/src/ui/Features/Tools/BatchConvert/BatchConvertSettingsViewModel.cs index 67c7c5ede1f..9ebf7ec795b 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) @@ -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"; @@ -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..8ffdeaf14ec 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(); diff --git a/src/ui/Features/Tools/BatchConvert/BatchConverter.cs b/src/ui/Features/Tools/BatchConvert/BatchConverter.cs index 8577399647a..31defe0d2d9 100644 --- a/src/ui/Features/Tools/BatchConvert/BatchConverter.cs +++ b/src/ui/Features/Tools/BatchConvert/BatchConverter.cs @@ -403,6 +403,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); @@ -1578,6 +1585,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. diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrEngineItem.cs b/src/ui/Features/Video/VideoOcr/VideoOcrEngineItem.cs index b4775e205ce..f5b999794ca 100644 --- a/src/ui/Features/Video/VideoOcr/VideoOcrEngineItem.cs +++ b/src/ui/Features/Video/VideoOcr/VideoOcrEngineItem.cs @@ -35,6 +35,14 @@ public static List GetEngines() 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)")); + // Nothing to download and no key: on macOS this is the one engine that works the + // moment the window opens, so it goes first. + if (AppleVisionOcr.IsAvailable()) + { + list.Insert(0, new(AppleVisionOcr.StaticName, OcrEngineType.AppleVision, + "macOS's built-in OCR engine - local, no download needed")); + } + if (CrispEmbedEngine.CanBeDownloaded()) { list.Add(new(CrispEmbedEngine.StaticName, OcrEngineType.CrispEmbed, diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs b/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs index d5007e8564b..4f3fdae3c09 100644 --- a/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs +++ b/src/ui/Features/Video/VideoOcr/VideoOcrViewModel.cs @@ -40,9 +40,12 @@ 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 _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; @@ -112,6 +115,8 @@ public VideoOcrViewModel(IWindowService windowService) 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; @@ -224,6 +229,7 @@ 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) @@ -978,6 +984,25 @@ await RunLlmOcr(ocrGroups, group => OcrWithBitmap(group, bitmap => { 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; + await RunLlmOcr(ocrGroups, + group => Task.Run(() => OcrFrameWithAppleVision(group, languageCode, cancellationToken), cancellationToken), + () => string.Empty, reportProgress, addPreviewLine, cancellationToken); + } + } + + private static string OcrFrameWithAppleVision(VideoOcrFrameGroup group, string languageCode, CancellationToken cancellationToken) + { + using var bitmap = SKBitmap.Decode(group.RepresentativeFileName); + return bitmap == null + ? string.Empty + : AppleVisionOcr.Ocr(bitmap, languageCode, fast: false, cancellationToken); } /// @@ -1110,6 +1135,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"); @@ -1134,6 +1162,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; diff --git a/src/ui/Features/Video/VideoOcr/VideoOcrWindow.cs b/src/ui/Features/Video/VideoOcr/VideoOcrWindow.cs index 0ba2f8f77ab..8c43caf5a9f 100644 --- a/src/ui/Features/Video/VideoOcr/VideoOcrWindow.cs +++ b/src/ui/Features/Video/VideoOcr/VideoOcrWindow.cs @@ -291,6 +291,14 @@ 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( 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/SeBatchConvert.cs b/src/ui/Logic/Config/SeBatchConvert.cs index 40d94d917a9..096f66766c0 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; } @@ -150,10 +153,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; diff --git a/src/ui/Logic/Config/SeOcr.cs b/src/ui/Logic/Config/SeOcr.cs index 455b4817167..51ccc872025 100644 --- a/src/ui/Logic/Config/SeOcr.cs +++ b/src/ui/Logic/Config/SeOcr.cs @@ -77,7 +77,13 @@ 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; diff --git a/src/ui/Logic/Config/SeVideoOcr.cs b/src/ui/Logic/Config/SeVideoOcr.cs index 8cb2426ea46..76e9d5de9f3 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; } @@ -32,10 +35,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"; diff --git a/src/ui/Logic/ValueConverters/BatchConvertStatusColorConverter.cs b/src/ui/Logic/ValueConverters/BatchConvertStatusColorConverter.cs index 3bb4f59196a..08f1d0f9863 100644 --- a/src/ui/Logic/ValueConverters/BatchConvertStatusColorConverter.cs +++ b/src/ui/Logic/ValueConverters/BatchConvertStatusColorConverter.cs @@ -51,6 +51,7 @@ public class BatchConvertStatusColorConverter : IValueConverter status == Se.Language.Ocr.LlamaCppReturnedNoText || status == Se.Language.Ocr.CrispEmbedNotDownloaded || status == Se.Language.Ocr.CrispEmbedReturnedNoText || + status == Se.Language.Ocr.AppleVisionReturnedNoText || (errorPrefix.Length > 0 && status.StartsWith(errorPrefix, StringComparison.OrdinalIgnoreCase)) || status.StartsWith("BinaryOcr database not found", StringComparison.Ordinal)) { diff --git a/tests/UI/Features/Tools/BatchConvert/BatchConvertSettingsViewModelAppleVisionTests.cs b/tests/UI/Features/Tools/BatchConvert/BatchConvertSettingsViewModelAppleVisionTests.cs new file mode 100644 index 00000000000..19bbe6862cf --- /dev/null +++ b/tests/UI/Features/Tools/BatchConvert/BatchConvertSettingsViewModelAppleVisionTests.cs @@ -0,0 +1,95 @@ +using Avalonia.Headless.XUnit; +using Microsoft.Extensions.DependencyInjection; +using Nikse.SubtitleEdit; +using Nikse.SubtitleEdit.Features.Ocr.Engines; +using Nikse.SubtitleEdit.Features.Tools.BatchConvert; +using Nikse.SubtitleEdit.Logic.Config; + +namespace UITests.Features.Tools.BatchConvert; + +/// +/// The Apple Vision option in batch convert settings. +/// +/// The one trap an engine with its own language combo hits here is the shared "Language" label: +/// it is shown for every engine that is not on an exclusion list, so an engine that brings its +/// own combo shows two language controls at once unless it is added to that list. That is the +/// mistake worth a test, and it is checked on every OS - the exclusion is a string comparison +/// with no framework behind it, so it can go wrong on Linux CI just as easily. +/// +public class BatchConvertSettingsViewModelAppleVisionTests +{ + [AvaloniaFact] + public void OcrEngines_ContainAppleVision_OnlyWhereItRuns() + { + var viewModel = MakeViewModel(); + + Assert.Equal(AppleVisionOcr.IsAvailable(), viewModel.OcrEngines.Contains(AppleVisionOcr.StaticName)); + } + + [AvaloniaFact] + public void SelectingAppleVision_ShowsItsOwnLanguage_AndHidesTheSharedOne() + { + var viewModel = MakeViewModel(); + + viewModel.SelectedOcrEngine = AppleVisionOcr.StaticName; + viewModel.OnOcrEngineChanged(); + + Assert.True(viewModel.IsAppleVisionVisible); + Assert.False(viewModel.IsOcrLanguageVisible); + + // Nothing else may claim the row at the same time. + Assert.False(viewModel.IsCrispEmbedVisible); + Assert.False(viewModel.IsTesseractOcrVisible); + Assert.False(viewModel.IsPaddleOCrVisible); + } + + [AvaloniaFact] + public void SelectingAnotherEngine_HidesAppleVision() + { + var viewModel = MakeViewModel(); + + viewModel.SelectedOcrEngine = AppleVisionOcr.StaticName; + viewModel.OnOcrEngineChanged(); + viewModel.SelectedOcrEngine = "nOcr"; + viewModel.OnOcrEngineChanged(); + + Assert.False(viewModel.IsAppleVisionVisible); + } + + [AvaloniaFact] + public void LanguageCombo_FollowsTheSavedBatchSetting() + { + if (!AppleVisionOcr.IsAvailable()) + { + Assert.Skip("Apple Vision is macOS only."); + return; + } + + var saved = Se.Settings.Tools.BatchConvert.AppleVisionLanguage; + try + { + // Any second language this machine's Vision supports; the point is that the combo + // restores what was saved rather than always landing on the en-US default. + var other = AppleVisionOcr.GetLanguages().First(l => l.Code != "en-US").Code; + Se.Settings.Tools.BatchConvert.AppleVisionLanguage = other; + + var viewModel = MakeViewModel(); + viewModel.SelectedOcrEngine = AppleVisionOcr.StaticName; + viewModel.OnOcrEngineChanged(); + + Assert.Equal(other, viewModel.SelectedAppleVisionLanguage?.Code); + } + finally + { + Se.Settings.Tools.BatchConvert.AppleVisionLanguage = saved; + } + } + + private static BatchConvertSettingsViewModel MakeViewModel() + { + var services = new ServiceCollection(); + services.AddSubtitleEditServices(); + using var provider = services.BuildServiceProvider(); + return provider.GetRequiredService(); + } +} From eb3cc5d437fcde9fb4e51eece5e16aa40b1868a4 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 17:27:11 +0200 Subject: [PATCH 065/380] Require macOS 14, the oldest release .NET 10 is supported on The bundle and the docs both said macOS 12. That was the technical floor - the shipped runtime is built against macOS 12 (LC_BUILD_VERSION minos 12.0 in libcoreclr.dylib), so it loads there - but not a supported one: Microsoft tracks Apple's own three-release window, and .NET 10 lists macOS 14, 15 and 26. Users on 12 and 13 were on a configuration the runtime we depend on is never tested against, which fails later and less clearly than not starting at all. Raised to 14 (Sonoma) in the one place that enforces it and the three that document it - LSMinimumSystemVersion, README.md, docs/overview.md and docs/faq.md - so Finder now says "requires a newer version of macOS" instead. The plist comment records both numbers and why they differ, since the technical floor is still the thing to re-derive after a TFM bump. Historical macOS 12 mentions in the changelogs are left alone: they describe releases that really did run there. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- docs/faq.md | 2 +- docs/overview.md | 2 +- .../SubtitleEdit.app/Contents/Info.plist | 15 ++++++++++----- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1ee75106595..2a56f8580a5 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ You can find the latest cross-platform builds here: ### macOS -- **Minimum macOS version**: 12 (Monterey) or newer +- **Minimum macOS version**: 14 (Sonoma) or newer - the oldest macOS that .NET 10 is supported on - 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/docs/faq.md b/docs/faq.md index 48b8e7d04cd..ceec4ad8947 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 14 (Sonoma) or later, on both Apple Silicon (arm64) and Intel (x64) ### 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). diff --git a/docs/overview.md b/docs/overview.md index 343c685ec37..8a816e46633 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 14 (Sonoma) or newer - [FFmpeg](https://ffmpeg.org/) (for audio/video processing) - [libmpv](https://mpv.io/) for video playback diff --git a/installer/macBundle/SubtitleEdit.app/Contents/Info.plist b/installer/macBundle/SubtitleEdit.app/Contents/Info.plist index 6b8c8392048..d9617ae4dcb 100644 --- a/installer/macBundle/SubtitleEdit.app/Contents/Info.plist +++ b/installer/macBundle/SubtitleEdit.app/Contents/Info.plist @@ -20,13 +20,18 @@ SBED CFBundleIconFile SE.icns - LSMinimumSystemVersion - 12.0 + 14.0 NSHighResolutionCapable NSSupportsAutomaticGraphicsSwitching From f8b500daed39b23a8e6c0bbc824aae0bb0d10a68 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 17:31:34 +0200 Subject: [PATCH 066/380] Rework #14056 fix: round at the producers, not in the model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the first approach (coercing to whole milliseconds inside the SubtitleLineViewModel property setters) surfaced real problems with the mechanism: it inverted the [ObservableProperty] notification order, added EventArgs allocations on the waveform-drag hot path, silently stored values callers never assigned, and clamped out-of-range input to TimeSpan.MaxValue - which is not a whole millisecond and could overflow StartTime + Duration on the UI thread. The model now stores exactly what it is given ([ObservableProperty] is restored untouched), and the rounding happens at the places that actually create fractional milliseconds, all through one helper: - SecondsUpDown.ParseTime (typed durations) - every pixel-derived assignment in the AudioVisualizer drag handling, with the whole-line/selection moves also switched to SetTimes so both ends apply atomically and the length cannot round apart - the vp.Position, frame-alignment, CPS-derived, shot-change and distribute-by-text-length sites in MainViewModel - Adjust and ChangeFrameRate round via start + scaled duration instead of start and end independently, so lines of equal length keep equal durations after a sync or frame-rate change - ShotChangesHelper.GetSnappedToNearestMs rounds its output, which also revives its exact-equality no-op guard - binary edit's ScaleBinarySubtitleTimes and the Blu-ray 90 kHz PTS conversion, which had the same grid-vs-field mismatch The helper clamps out-of-range values to the time-code domain (±99:59:59,999) and throws on NaN like TimeSpan.FromSeconds always has, so a bad drag position fails loudly instead of relocating the cue to zero. Test assertions that had been loosened to absorb the snapping are now exact whole-millisecond assertions instead, and the fractional-duration \fad test is restored to its original form - the effect sees the raw duration again. Co-Authored-By: Claude Opus 5 --- .../AudioVisualizerControl/AudioVisualizer.cs | 41 +++--- src/ui/Features/Main/MainViewModel.cs | 55 ++++---- src/ui/Features/Main/SubtitleLineViewModel.cs | 66 ++------- .../Ocr/OcrSubtitle/OcrSubtitleBluRay.cs | 6 +- .../Shared/BinaryEdit/BinaryEditViewModel.cs | 8 +- .../ChangeFrameRateViewModel.cs | 8 +- src/ui/Logic/Media/ShotChangesHelper.cs | 8 +- src/ui/Logic/TimeSpanExtensions.cs | 82 +++-------- .../AudioVisualizerShotChangeSnapTests.cs | 8 +- tests/UI/Features/Assa/AdvancedEffectTests.cs | 7 +- .../Features/Main/SubMillisecondTimeTests.cs | 131 ++++++++++-------- .../BinaryEdit/BinaryEditViewModelTests.cs | 5 +- .../ChangeFrameRateViewModelTests.cs | 13 +- 13 files changed, 192 insertions(+), 246 deletions(-) diff --git a/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs b/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs index ce48c2df351..e70460dbb78 100644 --- a/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs +++ b/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs @@ -700,7 +700,7 @@ private void OnTapped(object? sender, TappedEventArgs e) { if (seconds < firstSelected.EndTime.TotalSeconds - 0.01) { - firstSelected.SetStartTimeOnly(TimeSpan.FromSeconds(seconds)); + firstSelected.SetStartTimeOnly(TimeSpanExtensions.FromSecondsWholeMilliseconds(seconds)); } e.Handled = true; @@ -717,7 +717,7 @@ private void OnTapped(object? sender, TappedEventArgs e) { if (seconds > firstSelected.StartTime.TotalSeconds + 0.01) { - firstSelected.EndTime = TimeSpan.FromSeconds(seconds); + firstSelected.EndTime = TimeSpanExtensions.FromSecondsWholeMilliseconds(seconds); } e.Handled = true; @@ -732,7 +732,7 @@ private void OnTapped(object? sender, TappedEventArgs e) var seconds = RelativeXPositionToSeconds(point.X); if (firstSelected != null) { - firstSelected.SetStartTimeKeepDuration(TimeSpan.FromSeconds(seconds)); + firstSelected.SetStartTimeKeepDuration(TimeSpanExtensions.FromSecondsWholeMilliseconds(seconds)); e.Handled = true; InvalidateVisual(); return; @@ -1114,8 +1114,8 @@ private void OnPointerPressed(object? sender, PointerPressedEventArgs e) var deltaSeconds = RelativeXPositionToSeconds(deltaX); _newSelectionSeconds = deltaSeconds; - NewSelectionParagraph.StartTime = TimeSpan.FromSeconds(deltaSeconds); - NewSelectionParagraph.EndTime = TimeSpan.FromSeconds(deltaSeconds); + NewSelectionParagraph.StartTime = TimeSpanExtensions.FromSecondsWholeMilliseconds(deltaSeconds); + NewSelectionParagraph.EndTime = TimeSpanExtensions.FromSecondsWholeMilliseconds(deltaSeconds); _pointerDragActive = true; InvalidateVisual(); return; @@ -1349,13 +1349,15 @@ private void OnPointerMoved(object? sender, PointerEventArgs e) if (seconds > _newSelectionSeconds) { - newP.StartTime = TimeSpan.FromSeconds(_newSelectionSeconds); - newP.EndTime = TimeSpan.FromSeconds(seconds); + newP.SetTimes( + TimeSpanExtensions.FromSecondsWholeMilliseconds(_newSelectionSeconds), + TimeSpanExtensions.FromSecondsWholeMilliseconds(seconds)); } else { - newP.StartTime = TimeSpan.FromSeconds(seconds); - newP.EndTime = TimeSpan.FromSeconds(_newSelectionSeconds); + newP.SetTimes( + TimeSpanExtensions.FromSecondsWholeMilliseconds(seconds), + TimeSpanExtensions.FromSecondsWholeMilliseconds(_newSelectionSeconds)); } _lastPointerEditMs = Environment.TickCount64; @@ -1530,11 +1532,11 @@ private void OnPointerMoved(object? sender, PointerEventArgs e) if (_activeParagraph != null) { - // Add the duration as a whole-millisecond TimeSpan rather than in seconds, so + // SetTimes applies both ends atomically (no transient duration exposed to the + // bound editors), and adding the duration as a whole-millisecond TimeSpan means // moving a line cannot round its length a millisecond up or down (#14056). var movedStart = TimeSpanExtensions.FromSecondsWholeMilliseconds(newStart); - _activeParagraph.StartTime = movedStart; - _activeParagraph.EndTime = movedStart + TimeSpanExtensions.FromSecondsWholeMilliseconds(_originalDurationSeconds); + _activeParagraph.SetTimes(movedStart, movedStart + TimeSpanExtensions.FromSecondsWholeMilliseconds(_originalDurationSeconds)); } break; case InteractionMode.MovingSelection: @@ -1562,8 +1564,7 @@ private void OnPointerMoved(object? sender, PointerEventArgs e) foreach (var item in _selectionMoveSnapshot) { var start = TimeSpanExtensions.FromSecondsWholeMilliseconds(item.StartSeconds + shift); - item.Paragraph.StartTime = start; - item.Paragraph.EndTime = start + TimeSpanExtensions.FromSecondsWholeMilliseconds(item.DurationSeconds); + item.Paragraph.SetTimes(start, start + TimeSpanExtensions.FromSecondsWholeMilliseconds(item.DurationSeconds)); } break; @@ -1576,8 +1577,8 @@ private void OnPointerMoved(object? sender, PointerEventArgs e) newStart = snappedLeft; if (_activeParagraphPrevious != null) { - _activeParagraph.SetStartTimeOnly(TimeSpan.FromSeconds(newStart)); - _activeParagraphPrevious.EndTime = TimeSpan.FromSeconds(newPrevEnd); + _activeParagraph.SetStartTimeOnly(TimeSpanExtensions.FromSecondsWholeMilliseconds(newStart)); + _activeParagraphPrevious.EndTime = TimeSpanExtensions.FromSecondsWholeMilliseconds(newPrevEnd); } break; @@ -1589,8 +1590,8 @@ private void OnPointerMoved(object? sender, PointerEventArgs e) newEnd = snappedRight; if (_activeParagraphNext != null) { - _activeParagraph.EndTime = TimeSpan.FromSeconds(newEnd); - _activeParagraphNext.SetStartTimeOnly(TimeSpan.FromSeconds(newNextStart)); + _activeParagraph.EndTime = TimeSpanExtensions.FromSecondsWholeMilliseconds(newEnd); + _activeParagraphNext.SetStartTimeOnly(TimeSpanExtensions.FromSecondsWholeMilliseconds(newNextStart)); } break; @@ -1621,7 +1622,7 @@ private void OnPointerMoved(object? sender, PointerEventArgs e) if (newStart < _activeParagraph.EndTime.TotalSeconds - 0.1) { - _activeParagraph.SetStartTimeOnly(TimeSpan.FromSeconds(newStart)); + _activeParagraph.SetStartTimeOnly(TimeSpanExtensions.FromSecondsWholeMilliseconds(newStart)); } break; @@ -1647,7 +1648,7 @@ private void OnPointerMoved(object? sender, PointerEventArgs e) if (newEnd > _activeParagraph.StartTime.TotalSeconds + 0.1) { - _activeParagraph.EndTime = TimeSpan.FromSeconds(newEnd); + _activeParagraph.EndTime = TimeSpanExtensions.FromSecondsWholeMilliseconds(newEnd); } break; diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index b5b77346149..cb449a7665c 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -4827,7 +4827,7 @@ await MessageBox.Show(Window, Se.Language.General.Error, Se.Language.General.Unk { var newParagraph = new SubtitleLineViewModel(p, SelectedSubtitleFormat); var offset = p.StartTime.TotalMilliseconds - firstStartTime; - newParagraph.SetStartTimeKeepDuration(TimeSpan.FromMilliseconds(videoPosition * 1000 + offset)); + newParagraph.SetStartTimeKeepDuration(TimeSpanExtensions.FromMillisecondsWholeMilliseconds(videoPosition * 1000 + offset)); _insertService.InsertInCorrectPosition(Subtitles, newParagraph); } @@ -4884,7 +4884,7 @@ await MessageBox.Show(Window, Se.Language.General.Error, Se.Language.General.Unk foreach (var p in subtitle.Paragraphs) { var newParagraph = new SubtitleLineViewModel(p, SelectedSubtitleFormat); - newParagraph.SetStartTimeKeepDuration(TimeSpan.FromMilliseconds(p.StartTime.TotalMilliseconds + timeOffset)); + newParagraph.SetStartTimeKeepDuration(TimeSpanExtensions.FromMillisecondsWholeMilliseconds(p.StartTime.TotalMilliseconds + timeOffset)); _insertService.InsertInCorrectPosition(Subtitles, newParagraph); } @@ -5406,8 +5406,8 @@ private async Task RecalculateDurationSelectedLines() var nextSubtitle = GetNextWorkingRow(idx); var charCount = selectedLine.Text?.Length ?? 0; - var optimalDuration = TimeSpan.FromSeconds(charCount / Se.Settings.General.SubtitleOptimalCharactersPerSeconds); - var maxDuration = TimeSpan.FromSeconds(charCount / Se.Settings.General.SubtitleMaximumCharactersPerSeconds); + var optimalDuration = TimeSpanExtensions.FromSecondsWholeMilliseconds(charCount / Se.Settings.General.SubtitleOptimalCharactersPerSeconds); + var maxDuration = TimeSpanExtensions.FromSecondsWholeMilliseconds(charCount / Se.Settings.General.SubtitleMaximumCharactersPerSeconds); var maxEndTime = TimeSpan.FromMilliseconds(selectedLine.StartTime.TotalMilliseconds + Se.Settings.General.SubtitleMaximumDisplayMilliseconds); if (nextSubtitle != null) { @@ -5485,7 +5485,7 @@ private void SetDurationMaxCpsSelectedLines() continue; } - var newEndTime = selectedLine.StartTime + TimeSpan.FromSeconds(charCount / maxCps); + var newEndTime = selectedLine.StartTime + TimeSpanExtensions.FromSecondsWholeMilliseconds(charCount / maxCps); if (newEndTime < minEndTime) { newEndTime = minEndTime; @@ -7441,13 +7441,13 @@ private void SnapAllTimesToFrames() if (Math.Abs(newStartMs - s.StartTime.TotalMilliseconds) >= 0.5) { - s.SetStartTimeOnly(TimeSpan.FromMilliseconds(newStartMs)); + s.SetStartTimeOnly(TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newStartMs)); changed++; } if (Math.Abs(newEndMs - s.EndTime.TotalMilliseconds) >= 0.5) { - s.EndTime = TimeSpan.FromMilliseconds(newEndMs); + s.EndTime = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newEndMs); changed++; } } @@ -8275,7 +8275,7 @@ private async Task SpeechToTextSelectedLines(bool promptEngineAndLanguage, foreach (var p in transcribedLine.Transcription.Paragraphs) { var newLine = new SubtitleLineViewModel(p, SelectedSubtitleFormat); - newLine.SetStartTimeKeepDuration(selectedLine.StartTime + p.StartTime.TimeSpan); + newLine.SetStartTimeKeepDuration(TimeSpanExtensions.FromMillisecondsWholeMilliseconds(selectedLine.StartTime.TotalMilliseconds + p.StartTime.TotalMilliseconds)); newLine.Style = selectedLine.Style; // the lines replace selectedLine, so they keep its style newLines.Add(newLine); } @@ -9904,7 +9904,7 @@ private void ExtendSelectedLinesToNextShotChangeOrNextSubtitle() continue; } - line.EndTime = TimeSpan.FromMilliseconds(newEndMs.Value); + line.EndTime = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newEndMs.Value); } _updateAudioVisualizer = true; @@ -9993,7 +9993,7 @@ private void ExtendSelectedLinesToPreviousShotChange() // Use SetStartTimeOnly so EndTime stays fixed (the StartTime setter would otherwise // shift EndTime to preserve Duration - see SubtitleLineViewModel.OnStartTimeChanged). - line.SetStartTimeOnly(TimeSpan.FromMilliseconds(newStartMs.Value)); + line.SetStartTimeOnly(TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newStartMs.Value)); } _updateAudioVisualizer = true; @@ -10029,7 +10029,7 @@ private void SnapSelectedLinesStartToNextShotChange() // Use SetStartTimeOnly so EndTime stays fixed (the StartTime // setter would otherwise shift EndTime to preserve Duration). - line.SetStartTimeOnly(TimeSpan.FromMilliseconds(newStartMs.Value)); + line.SetStartTimeOnly(TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newStartMs.Value)); } _updateAudioVisualizer = true; @@ -10063,7 +10063,7 @@ private void SnapSelectedLinesEndToPreviousShotChange() continue; } - line.EndTime = TimeSpan.FromMilliseconds(newEndMs.Value); + line.EndTime = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newEndMs.Value); } _updateAudioVisualizer = true; @@ -10186,10 +10186,10 @@ private void SetCueToClosestShotChangeGreenZone(bool isInCue, bool isLeftZone, s // Use SetStartTimeOnly so the line's EndTime stays put // (the StartTime setter would otherwise shift EndTime to // preserve Duration — see OnStartTimeChanged). - line.SetStartTimeOnly(TimeSpan.FromMilliseconds(newStartMs)); + line.SetStartTimeOnly(TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newStartMs)); if (Math.Abs(newPreviousEndMs - prev.EndTime.TotalMilliseconds) > 0.5) { - prev.EndTime = TimeSpan.FromMilliseconds(newPreviousEndMs); + prev.EndTime = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newPreviousEndMs); if (!selectedSet.Contains(prev)) { adjustedNeighbors.Add(prev); @@ -10198,7 +10198,7 @@ private void SetCueToClosestShotChangeGreenZone(bool isInCue, bool isLeftZone, s } else { - line.SetStartTimeOnly(TimeSpan.FromMilliseconds(newStartMs)); + line.SetStartTimeOnly(TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newStartMs)); } } else @@ -10237,12 +10237,12 @@ private void SetCueToClosestShotChangeGreenZone(bool isInCue, bool isLeftZone, s continue; } - line.EndTime = TimeSpan.FromMilliseconds(newEndMs); + line.EndTime = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newEndMs); if (Math.Abs(newNextStartMs - next.StartTime.TotalMilliseconds) > 0.5) { // SetStartTimeOnly so the next subtitle's EndTime // isn't shifted to preserve its previous Duration. - next.SetStartTimeOnly(TimeSpan.FromMilliseconds(newNextStartMs)); + next.SetStartTimeOnly(TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newNextStartMs)); if (!selectedSet.Contains(next)) { adjustedNeighbors.Add(next); @@ -10251,7 +10251,7 @@ private void SetCueToClosestShotChangeGreenZone(bool isInCue, bool isLeftZone, s } else { - line.EndTime = TimeSpan.FromMilliseconds(newEndMs); + line.EndTime = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newEndMs); } } @@ -12692,11 +12692,11 @@ private void MoveStartByFrames(int frames, bool keepGapPrevIfClose) } } - s.SetStartTimeOnly(TimeSpan.FromMilliseconds(newStartMs)); + s.SetStartTimeOnly(TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newStartMs)); if (prevIsClose && prev != null) { - prev.EndTime = TimeSpan.FromMilliseconds(newStartMs - prevGapMs); + prev.EndTime = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newStartMs - prevGapMs); } _updateAudioVisualizer = true; @@ -12760,11 +12760,11 @@ private void MoveEndByFrames(int frames, bool keepGapNextIfClose) } } - s.EndTime = TimeSpan.FromMilliseconds(newEndMs); + s.EndTime = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newEndMs); if (nextIsClose && next != null) { - next.SetStartTimeOnly(TimeSpan.FromMilliseconds(newEndMs + nextGapMs)); + next.SetStartTimeOnly(TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newEndMs + nextGapMs)); } _updateAudioVisualizer = true; @@ -15288,7 +15288,7 @@ private void InsertSubtitleAtVideoPositionSetEndAtKeyUp() return; } - var startMs = vp.Position * 1000.0; + var startMs = Math.Round(vp.Position * 1000.0, MidpointRounding.AwayFromZero); var endMs = startMs + Se.Settings.General.NewEmptyDefaultMs; var newParagraph = new SubtitleLineViewModel(new Paragraph(string.Empty, startMs, endMs), SelectedSubtitleFormat); @@ -15337,7 +15337,7 @@ private void SetSubtitleStartAtVideoPositionSetEndAtKeyUpAndGoToNext() return; } - var startMs = vp.Position * 1000.0; + var startMs = Math.Round(vp.Position * 1000.0, MidpointRounding.AwayFromZero); selectedSubtitle.StartTime = TimeSpan.FromMilliseconds(startMs); _setEndAtKeyUpLine = selectedSubtitle; _setEndAtKeyUpLineGoToNext = true; @@ -15612,8 +15612,7 @@ private void EvenlyDistributeSelectedLines() var ratio = p.Text.CountCharacters(true) / totalLength; var newDurationMs = (double)ratio * totalDurationWithGapsMs; var newStartMs = previousEndMs + gapMs; - p.StartTime = TimeSpan.FromMilliseconds(newStartMs); - p.EndTime = TimeSpan.FromMilliseconds(newStartMs + newDurationMs); + p.SetTimes(TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newStartMs), TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newStartMs) + TimeSpanExtensions.FromMillisecondsWholeMilliseconds(newDurationMs)); previousEndMs = p.EndTime.TotalMilliseconds; } @@ -16223,7 +16222,7 @@ private void WaveformInsertAtPositionAndFocusTextBox() return; } - var startMs = vp.Position * 1000.0; + var startMs = Math.Round(vp.Position * 1000.0, MidpointRounding.AwayFromZero); var endMs = startMs + Se.Settings.General.NewEmptyDefaultMs; var newParagraph = new SubtitleLineViewModel(new Paragraph(string.Empty, startMs, endMs), SelectedSubtitleFormat); @@ -16265,7 +16264,7 @@ private void WaveformInsertAtPositionNoFocusTextBox() return; } - var startMs = vp.Position * 1000.0; + var startMs = Math.Round(vp.Position * 1000.0, MidpointRounding.AwayFromZero); var endMs = startMs + Se.Settings.General.NewEmptyDefaultMs; var newParagraph = new SubtitleLineViewModel(new Paragraph(string.Empty, startMs, endMs), SelectedSubtitleFormat); diff --git a/src/ui/Features/Main/SubtitleLineViewModel.cs b/src/ui/Features/Main/SubtitleLineViewModel.cs index b7fccc43bfc..366241579d9 100644 --- a/src/ui/Features/Main/SubtitleLineViewModel.cs +++ b/src/ui/Features/Main/SubtitleLineViewModel.cs @@ -53,58 +53,15 @@ public partial class SubtitleLineViewModel : ObservableObject [ObservableProperty] private string? _bookmark; - // The three time properties are hand-written rather than [ObservableProperty] so that every - // assignment is snapped to a whole millisecond - the generated setter offers no coercion hook. - // Subtitle formats store whole milliseconds and every time code SE shows is a whole - // millisecond, but .NET Core's TimeSpan.FromSeconds/FromMilliseconds truncate to ticks, so a - // pixel-derived waveform drag or a typed "0,820" lands here as e.g. 819.9999 ms. Such a value - // renders one millisecond apart in the grid (which truncates) and in the duration up/down - // (which rounds), and saves a millisecond early - see issue #14056. + [ObservableProperty] private TimeSpan _startTime; - public TimeSpan StartTime - { - get => _startTime; - set - { - var snapped = value.SnapToWholeMilliseconds(); - if (SetProperty(ref _startTime, snapped)) - { - OnStartTimeChanged(snapped); - } - } - } - + [ObservableProperty] private TimeSpan _endTime; - public TimeSpan EndTime - { - get => _endTime; - set - { - var snapped = value.SnapToWholeMilliseconds(); - if (SetProperty(ref _endTime, snapped)) - { - OnEndTimeChanged(snapped); - } - } - } - + [ObservableProperty] private TimeSpan _duration; - public TimeSpan Duration - { - get => _duration; - set - { - var snapped = value.SnapToWholeMilliseconds(); - if (SetProperty(ref _duration, snapped)) - { - OnDurationChanged(snapped); - } - } - } - [ObservableProperty] [NotifyPropertyChangedFor(nameof(TeletextDisplay))] [NotifyPropertyChangedFor(nameof(TeletextTextAlignment))] @@ -960,7 +917,7 @@ internal void SetStartTimeKeepDuration(TimeSpan timeSpan) SetTimes(timeSpan, timeSpan + (EndTime - StartTime)); } - private void OnStartTimeChanged(TimeSpan value) + partial void OnStartTimeChanged(TimeSpan value) { OnPropertyChanged(nameof(StartTimeOnly)); OnPropertyChanged(nameof(StartTimeKeepDuration)); @@ -978,7 +935,7 @@ private void OnStartTimeChanged(TimeSpan value) _skipUpdate = false; } - private void OnEndTimeChanged(TimeSpan value) + partial void OnEndTimeChanged(TimeSpan value) { if (_skipUpdate) { @@ -993,7 +950,7 @@ private void OnEndTimeChanged(TimeSpan value) _skipUpdate = false; } - private void OnDurationChanged(TimeSpan value) + partial void OnDurationChanged(TimeSpan value) { if (_skipUpdate) { @@ -1205,9 +1162,14 @@ internal void Adjust(double factor, double adjustmentInSeconds) // Set both times atomically via SetTimes; updating start then end // separately can briefly expose start > end to the bound editor // controls, which clamp the negative duration and corrupt the end time. - var newStart = TimeSpan.FromMilliseconds(StartTime.TotalMilliseconds * factor + adjustmentInSeconds * TimeCode.BaseUnit); - var newEnd = TimeSpan.FromMilliseconds(EndTime.TotalMilliseconds * factor + adjustmentInSeconds * TimeCode.BaseUnit); - SetTimes(newStart, newEnd); + // + // Round to whole milliseconds via start + scaled duration, not start and end + // independently: with independent rounding, lines of equal length scale to durations + // that differ by 1 ms depending on where they sit, flipping min-duration/CPS warnings + // on some rows and not others (#14056). + var newStart = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(StartTime.TotalMilliseconds * factor + adjustmentInSeconds * TimeCode.BaseUnit); + var newDuration = TimeSpanExtensions.FromMillisecondsWholeMilliseconds((EndTime.TotalMilliseconds - StartTime.TotalMilliseconds) * factor); + SetTimes(newStart, newStart + newDuration); } internal double GetCharactersPerSecond() diff --git a/src/ui/Features/Ocr/OcrSubtitle/OcrSubtitleBluRay.cs b/src/ui/Features/Ocr/OcrSubtitle/OcrSubtitleBluRay.cs index 1be5f5af7af..b4c57c15a7d 100644 --- a/src/ui/Features/Ocr/OcrSubtitle/OcrSubtitleBluRay.cs +++ b/src/ui/Features/Ocr/OcrSubtitle/OcrSubtitleBluRay.cs @@ -1,4 +1,5 @@ using Nikse.SubtitleEdit.Core.BluRaySup; +using Nikse.SubtitleEdit.Logic; using SkiaSharp; using System; using System.Collections.Generic; @@ -29,12 +30,13 @@ public SKBitmap GetBitmap(int index) public TimeSpan GetStartTime(int index) { - return TimeSpan.FromMilliseconds(_pcsDataList[index].StartTime / 90.0); + // 90 kHz PTS is a fractional millisecond; round to the whole ms subtitle formats store (#14056). + return TimeSpanExtensions.FromMillisecondsWholeMilliseconds(_pcsDataList[index].StartTime / 90.0); } public TimeSpan GetEndTime(int index) { - return TimeSpan.FromMilliseconds(_pcsDataList[index].EndTime / 90.0); + return TimeSpanExtensions.FromMillisecondsWholeMilliseconds(_pcsDataList[index].EndTime / 90.0); } public List MakeOcrSubtitleItems() diff --git a/src/ui/Features/Shared/BinaryEdit/BinaryEditViewModel.cs b/src/ui/Features/Shared/BinaryEdit/BinaryEditViewModel.cs index fba22b7bd63..4eadf7c389f 100644 --- a/src/ui/Features/Shared/BinaryEdit/BinaryEditViewModel.cs +++ b/src/ui/Features/Shared/BinaryEdit/BinaryEditViewModel.cs @@ -1833,8 +1833,8 @@ void ApplyScaled(IEnumerable items, double factor) continue; } - s.StartTime = TimeSpan.FromMilliseconds(o.Item1 * factor); - s.EndTime = TimeSpan.FromMilliseconds(o.Item2 * factor); + s.StartTime = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(o.Item1 * factor); + s.EndTime = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(o.Item2 * factor); } } @@ -2726,8 +2726,8 @@ internal static void ScaleBinarySubtitleTimes(IEnumerable su { foreach (var s in subtitles) { - var newStart = TimeSpan.FromMilliseconds(s.StartTime.TotalMilliseconds * factor); - var newEnd = TimeSpan.FromMilliseconds(s.EndTime.TotalMilliseconds * factor); + var newStart = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(s.StartTime.TotalMilliseconds * factor); + var newEnd = TimeSpanExtensions.FromMillisecondsWholeMilliseconds(s.EndTime.TotalMilliseconds * factor); s.StartTime = newStart; s.EndTime = newEnd; } diff --git a/src/ui/Features/Sync/ChangeFrameRate/ChangeFrameRateViewModel.cs b/src/ui/Features/Sync/ChangeFrameRate/ChangeFrameRateViewModel.cs index 88f420d0542..d2c34a5cefd 100644 --- a/src/ui/Features/Sync/ChangeFrameRate/ChangeFrameRateViewModel.cs +++ b/src/ui/Features/Sync/ChangeFrameRate/ChangeFrameRateViewModel.cs @@ -183,9 +183,11 @@ internal static void ChangeFrameRate(ObservableCollection double ratio = GetFrameRateRatio(fromFrameRate, toFrameRate); foreach (var line in subtitles) { - line.SetTimes( - TimeSpan.FromMilliseconds(line.StartTime.TotalMilliseconds * ratio), - TimeSpan.FromMilliseconds(line.EndTime.TotalMilliseconds * ratio)); + // 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(line.StartTime.TotalMilliseconds * ratio); + var newDuration = TimeSpanExtensions.FromMillisecondsWholeMilliseconds((line.EndTime.TotalMilliseconds - line.StartTime.TotalMilliseconds) * ratio); + line.SetTimes(newStart, newStart + newDuration); } } } \ No newline at end of file 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/TimeSpanExtensions.cs b/src/ui/Logic/TimeSpanExtensions.cs index 16bf73cc800..ba83884140b 100644 --- a/src/ui/Logic/TimeSpanExtensions.cs +++ b/src/ui/Logic/TimeSpanExtensions.cs @@ -12,55 +12,8 @@ public static bool IsMaxTime(this TimeSpan ts) } /// - /// Rounds a time 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. Snapping on the way - /// in keeps the two displays in agreement and the saved file faithful to what was typed. - /// - /// - public static TimeSpan SnapToWholeMilliseconds(this TimeSpan ts) - { - var rest = ts.Ticks % TimeSpan.TicksPerMillisecond; - if (rest == 0) - { - return ts; - } - - // Away from zero on the half, matching the rounding SE uses elsewhere for time codes. - // TimeSpan.MaxValue/MinValue have no whole millisecond to round to, so they stay put - // rather than wrapping around. - var ticks = ts.Ticks - rest; - if (rest >= TimeSpan.TicksPerMillisecond / 2) - { - if (ticks > long.MaxValue - TimeSpan.TicksPerMillisecond) - { - return ts; - } - - ticks += TimeSpan.TicksPerMillisecond; - } - else if (rest <= -TimeSpan.TicksPerMillisecond / 2) - { - if (ticks < long.MinValue + TimeSpan.TicksPerMillisecond) - { - return ts; - } - - ticks -= TimeSpan.TicksPerMillisecond; - } - - return TimeSpan.FromTicks(ticks); - } - - /// - /// snapped to a whole millisecond - - /// see . + /// rounded to a whole millisecond - + /// see . /// public static TimeSpan FromSecondsWholeMilliseconds(double seconds) { @@ -68,27 +21,38 @@ public static TimeSpan FromSecondsWholeMilliseconds(double seconds) } /// - /// snapped to a whole millisecond - - /// see . + /// 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)) { - return TimeSpan.Zero; + throw new ArgumentException("TimeSpan does not accept floating point Not-a-Number values.", nameof(milliseconds)); } - // Keep the tick multiplication below in range; TimeSpan.FromMilliseconds would throw here. - const double maxMs = long.MaxValue / (double)TimeSpan.TicksPerMillisecond; var ms = Math.Round(milliseconds, MidpointRounding.AwayFromZero); - if (ms >= maxMs) + if (ms > MaxTimeTotalMilliseconds) { - return TimeSpan.MaxValue; + ms = MaxTimeTotalMilliseconds; } - - if (ms <= -maxMs) + else if (ms < -MaxTimeTotalMilliseconds) { - return TimeSpan.MinValue; + ms = -MaxTimeTotalMilliseconds; } return TimeSpan.FromTicks((long)ms * TimeSpan.TicksPerMillisecond); diff --git a/tests/UI/Controls/AudioVisualizerShotChangeSnapTests.cs b/tests/UI/Controls/AudioVisualizerShotChangeSnapTests.cs index 63e42f07bbf..e71f88b3abe 100644 --- a/tests/UI/Controls/AudioVisualizerShotChangeSnapTests.cs +++ b/tests/UI/Controls/AudioVisualizerShotChangeSnapTests.cs @@ -180,7 +180,7 @@ public void MoveWholeLine_NoShotChangeInRange_FollowsThePointer() Drag(window, 252, 312); - Assert.Equal(1 + 60.0 / SampleRate, line.StartTime.TotalSeconds, 3); // snapped to whole ms + 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 +194,7 @@ public void MoveWholeLine_SnapToShotChangesOff_FollowsThePointer() Drag(window, 252, 312); - Assert.Equal(1 + 60.0 / SampleRate, line.StartTime.TotalSeconds, 3); // snapped to whole ms + Assert.Equal(1476, line.StartTime.TotalMilliseconds); // 1 + 60/126 s, rounded to the whole ms the drag stores window.Close(); } @@ -307,7 +307,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, 3); // snapped to whole ms + Assert.Equal(1476, line.StartTime.TotalMilliseconds); // draggedStart rounded to the whole ms the drag stores window.Close(); } @@ -321,7 +321,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, 3); // snapped to whole ms + 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/Features/Assa/AdvancedEffectTests.cs b/tests/UI/Features/Assa/AdvancedEffectTests.cs index b2beaba2725..a0de01609a5 100644 --- a/tests/UI/Features/Assa/AdvancedEffectTests.cs +++ b/tests/UI/Features/Assa/AdvancedEffectTests.cs @@ -97,9 +97,8 @@ public void AudioTextPulse_FractionalBorder_IsCultureInvariant() } /// - /// The fade duration is emitted as a rounded integer - it must not leak a decimal point - /// (or comma) into \fad. A line's times are snapped to whole milliseconds on the way into - /// the view model (#14056), so a fractional duration rounds before the effect ever sees it. + /// The fade duration is emitted as a rounded integer - a fractional line duration must + /// not leak a decimal point (or comma) into \fad. /// [Fact] public void FadeIn_FractionalDuration_EmitsIntegerFad() @@ -108,7 +107,7 @@ public void FadeIn_FractionalDuration_EmitsIntegerFad() var result = effect.ApplyEffect(string.Empty, [MakeLine("Hello", durationMs: 1500.5)], 1280, 720, null); var overlay = result[0]; - Assert.Contains(@"\fad(0,1501)", overlay.Text); + Assert.Contains(@"\fad(0,1500)", overlay.Text); } /// diff --git a/tests/UI/Features/Main/SubMillisecondTimeTests.cs b/tests/UI/Features/Main/SubMillisecondTimeTests.cs index 9381817d6d4..1660de3495b 100644 --- a/tests/UI/Features/Main/SubMillisecondTimeTests.cs +++ b/tests/UI/Features/Main/SubMillisecondTimeTests.cs @@ -7,9 +7,11 @@ using Nikse.SubtitleEdit.Controls; using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Features.Sync.ChangeFrameRate; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; using System; +using System.Collections.ObjectModel; using System.Linq; namespace UITests.Features.Main; @@ -21,18 +23,17 @@ namespace UITests.Features.Main; /// to whole milliseconds the way .NET Framework did, so TimeSpan.FromSeconds(0.82) is 819.9999 ms. /// A value like that reads back as "0,820" in the duration up/down (which rounds) but as "0,819" in /// the grid (which truncates, like every format writer does), and the end time it produces really -/// is a millisecond early. Subtitle times are whole milliseconds, so they are snapped on the way in. +/// is a millisecond early. Subtitle formats store whole milliseconds, so every producer that turns +/// a fractional double (typed input, pixel positions, video positions, scale factors) into a +/// subtitle time rounds through TimeSpanExtensions.FromSeconds/FromMillisecondsWholeMilliseconds. /// public class SubMillisecondTimeTests { - private static string GridText(TimeSpan ts) => new TimeCode(ts).ToShortString(); - - private static string FieldText(TimeSpan ts) => ts.TotalSeconds.ToString("0.000"); - private static SubtitleLineViewModel Line(double startMs, double endMs) => new() { StartTime = TimeSpan.FromMilliseconds(startMs), EndTime = TimeSpan.FromMilliseconds(endMs) }; - // The reported case: Show 00:11:22,700 and a typed duration of 0,820 must hide at ,520. + // The reported case: Show 00:11:22,700 and a typed duration of 0,820 must hide at ,520 - + // not ,519 - and the grid cell and the field must agree afterwards. [AvaloniaFact] public void TypingADurationEndsTheLineOnAWholeMillisecond() { @@ -55,81 +56,93 @@ public void TypingADurationEndsTheLineOnAWholeMillisecond() Assert.Equal(820, line.Duration.TotalMilliseconds); Assert.Equal(683_520, line.EndTime.TotalMilliseconds); Assert.Equal("00:11:23,520", new TimeCode(line.EndTime).ToDisplayString()); - Assert.Equal("0,820", GridText(line.Duration)); + Assert.Equal("0,820", new TimeCode(line.Duration).ToShortString()); Assert.Equal("0,820", textBox.Text); } - // The other reported case: a waveform drag lands the cues on fractional milliseconds, so the - // grid (truncating) and the duration up/down (rounding) disagreed by one millisecond. [Theory] - [InlineData(589_780.6, 590_910.3)] // pixel-derived, as a waveform drag produces - [InlineData(682_700.0, 683_519.9999)] // tick-truncated, as TimeSpan.FromSeconds produces - [InlineData(1_000.4, 2_000.5)] - [InlineData(1_000.5, 2_000.6)] - public void TheGridAndTheDurationFieldNeverDisagree(double startMs, double endMs) + [InlineData(0.82, 820)] + [InlineData(1.13, 1130)] + [InlineData(1.129, 1129)] + [InlineData(0.0004, 0)] + [InlineData(0.0005, 1)] + [InlineData(-0.0005, -1)] + [InlineData(-1.13, -1130)] + public void SecondsAreRoundedToWholeMilliseconds(double seconds, double expectedMs) { - using var _ = new SettingsScope("General.UseFrameMode"); - Se.Settings.General.UseFrameMode = false; - - var line = Line(startMs, endMs); + Assert.Equal(expectedMs, TimeSpanExtensions.FromSecondsWholeMilliseconds(seconds).TotalMilliseconds); + } - Assert.Equal(0, line.StartTime.Ticks % TimeSpan.TicksPerMillisecond); - Assert.Equal(0, line.EndTime.Ticks % TimeSpan.TicksPerMillisecond); - Assert.Equal(0, line.Duration.Ticks % TimeSpan.TicksPerMillisecond); - Assert.Equal(GridText(line.Duration), FieldText(line.Duration)); - Assert.Equal(line.EndTime - line.StartTime, line.Duration); + // Out-of-range input is clamped to the time-code domain (±99:59:59,999) - still a whole + // millisecond a TimeCode can hold - and NaN throws like TimeSpan.FromSeconds always has: + // a NaN time is a bug at the call site and must not silently become 00:00:00,000. + [Fact] + public void OutOfRangeIsClampedToTheTimeCodeDomainAndNaNThrows() + { + Assert.Equal(TimeCode.MaxTimeTotalMilliseconds, + TimeSpanExtensions.FromSecondsWholeMilliseconds(1e30).TotalMilliseconds); + Assert.Equal(-TimeCode.MaxTimeTotalMilliseconds, + TimeSpanExtensions.FromSecondsWholeMilliseconds(-1e30).TotalMilliseconds); + Assert.Throws(() => TimeSpanExtensions.FromSecondsWholeMilliseconds(double.NaN)); } - // Nudging Show must not leave the duration reading one value in the grid and another in the - // field - the reporter's follow-up test, which moved both cues but kept the bad span. + // Typing a huge duration must not corrupt the line: the parsed value is clamped to the + // time-code domain instead of TimeSpan.MaxValue, so StartTime + Duration cannot overflow. [AvaloniaFact] - public void MovingALineKeepsAWholeMillisecondDuration() + public void TypingAHugeDurationClampsInsteadOfOverflowing() { - var line = Line(682_700, 683_520); + using var _ = new SettingsScope("General.UseFrameMode"); + Se.Settings.General.UseFrameMode = false; - // A pixel-derived waveform drag, i.e. an arbitrary fraction of a millisecond. - line.SetStartTimeKeepDuration(TimeSpan.FromMilliseconds(682_700.6)); + var line = Line(682_700, 683_200); - Assert.Equal(682_701, line.StartTime.TotalMilliseconds); - Assert.Equal(683_521, line.EndTime.TotalMilliseconds); - Assert.Equal(820, line.Duration.TotalMilliseconds); - Assert.Equal(GridText(line.Duration), FieldText(line.Duration)); - } + var upDown = new SecondsUpDown { DataContext = line }; + upDown[!SecondsUpDown.ValueProperty] = + new Binding(nameof(SubtitleLineViewModel.Duration)) { Mode = BindingMode.TwoWay }; + var window = new Window { Content = upDown }; + window.Show(); + var textBox = upDown.GetVisualDescendants().OfType().Single(); - [Theory] - [InlineData(0.82, 820)] - [InlineData(1.13, 1130)] - [InlineData(1.129, 1129)] - [InlineData(0.0004, 0)] - [InlineData(0.0005, 1)] - [InlineData(-0.0005, -1)] - [InlineData(-1.13, -1130)] - public void SecondsAreSnappedToWholeMilliseconds(double seconds, double expectedMs) - { - var snapped = TimeSpanExtensions.FromSecondsWholeMilliseconds(seconds); + textBox.Focus(); + textBox.Text = "1e30"; + window.KeyPress(Key.Enter, RawInputModifiers.None, PhysicalKey.Enter, null); - Assert.Equal(expectedMs, snapped.TotalMilliseconds); - Assert.Equal(0, snapped.Ticks % TimeSpan.TicksPerMillisecond); + Assert.Equal(TimeCode.MaxTimeTotalMilliseconds, line.Duration.TotalMilliseconds); + Assert.Equal(0, line.EndTime.Ticks % TimeSpan.TicksPerMillisecond); } - // The extremes have no whole millisecond to round to - they must stay put, not wrap around. + // Scaling rounds via start + scaled duration, not start and end independently, so lines of + // equal length keep equal durations - independent rounding turned uniform 2000 ms lines into + // a mix of 1666 and 1667 ms, flipping duration/CPS warnings on some rows and not others. [Fact] - public void SnappingTheExtremesDoesNotWrapAround() + public void ChangeFrameRateKeepsEqualDurationsEqual() { - Assert.Equal(TimeSpan.MaxValue, TimeSpan.MaxValue.SnapToWholeMilliseconds()); - Assert.Equal(TimeSpan.MinValue, TimeSpan.MinValue.SnapToWholeMilliseconds()); - Assert.Equal(TimeSpan.MaxValue, TimeSpanExtensions.FromSecondsWholeMilliseconds(double.MaxValue)); - Assert.Equal(TimeSpan.MinValue, TimeSpanExtensions.FromSecondsWholeMilliseconds(double.MinValue)); - Assert.Equal(TimeSpan.Zero, TimeSpanExtensions.FromSecondsWholeMilliseconds(double.NaN)); + var subtitles = new ObservableCollection(); + for (var i = 0; i < 5; i++) + { + subtitles.Add(Line(1000 + i * 3100, 1000 + i * 3100 + 2000)); + } + + ChangeFrameRateViewModel.ChangeFrameRate(subtitles, 25.0, 30.0); + + foreach (var line in subtitles) + { + Assert.Equal(0, line.StartTime.Ticks % TimeSpan.TicksPerMillisecond); + Assert.Equal(1667, line.Duration.TotalMilliseconds); // round(2000 * 25 / 30) + } } [Fact] - public void SnappingIsAlreadyWholeMillisecondsIdempotent() + public void AdjustKeepsEqualDurationsEqual() { - var whole = TimeSpan.FromMilliseconds(1130); + var early = Line(1000, 3000); + var late = Line(601_000, 603_000); + + early.Adjust(25.0 / 30.0, 0); + late.Adjust(25.0 / 30.0, 0); - Assert.Equal(whole, whole.SnapToWholeMilliseconds()); - Assert.Equal(TimeSpan.Zero, TimeSpan.Zero.SnapToWholeMilliseconds()); - Assert.Equal(whole, TimeSpan.FromSeconds(1.13).SnapToWholeMilliseconds()); + Assert.Equal(early.Duration, late.Duration); + Assert.Equal(1667, early.Duration.TotalMilliseconds); + Assert.Equal(0, late.StartTime.Ticks % TimeSpan.TicksPerMillisecond); } } diff --git a/tests/UI/Features/Shared/BinaryEdit/BinaryEditViewModelTests.cs b/tests/UI/Features/Shared/BinaryEdit/BinaryEditViewModelTests.cs index e65bc18b1ee..0814bc14d33 100644 --- a/tests/UI/Features/Shared/BinaryEdit/BinaryEditViewModelTests.cs +++ b/tests/UI/Features/Shared/BinaryEdit/BinaryEditViewModelTests.cs @@ -88,8 +88,9 @@ public void ScaleBinarySubtitleTimes_ChangeSpeed_ShortSubtitleAtHighOffset_Prese BinaryEditViewModel.ScaleBinarySubtitleTimes([item], factor); - Assert.Equal(9090.909, item.StartTime.TotalMilliseconds, 3); - Assert.Equal(9272.727, item.EndTime.TotalMilliseconds, 3); + // Scaled times are rounded to the whole millisecond subtitle formats store (#14056). + Assert.Equal(9091, item.StartTime.TotalMilliseconds); // round(10000 * 100/110) + Assert.Equal(9273, item.EndTime.TotalMilliseconds); // round(10200 * 100/110) Assert.True(item.Duration > TimeSpan.Zero); } } diff --git a/tests/UI/Features/Sync/ChangeFrameRate/ChangeFrameRateViewModelTests.cs b/tests/UI/Features/Sync/ChangeFrameRate/ChangeFrameRateViewModelTests.cs index d7fd7111dd7..ce40a8a7826 100644 --- a/tests/UI/Features/Sync/ChangeFrameRate/ChangeFrameRateViewModelTests.cs +++ b/tests/UI/Features/Sync/ChangeFrameRate/ChangeFrameRateViewModelTests.cs @@ -42,12 +42,11 @@ public void ChangeFrameRate_ScalesStartAndEndByFromOverTo() ChangeFrameRateViewModel.ChangeFrameRate(subtitles, 25.0, 30.0); - // Scaled times land on whole milliseconds - the only resolution subtitle formats have, - // and the one the grid stores since #14056. - var ratio = 25.0 / 30.0; - Assert.Equal(1000.0 * ratio, subtitles[0].StartTime.TotalMilliseconds, 0); - Assert.Equal(3000.0 * ratio, subtitles[0].EndTime.TotalMilliseconds, 0); - Assert.Equal(6000.0 * ratio, subtitles[1].StartTime.TotalMilliseconds, 0); - Assert.Equal(9000.0 * ratio, subtitles[1].EndTime.TotalMilliseconds, 0); + // Scaled times land on whole milliseconds - the only resolution subtitle formats + // have - rounded as start + scaled duration so equal lengths stay equal (#14056). + Assert.Equal(833, subtitles[0].StartTime.TotalMilliseconds); // round(1000 * 25/30) + Assert.Equal(2500, subtitles[0].EndTime.TotalMilliseconds); // 833 + round(2000 * 25/30) + Assert.Equal(5000, subtitles[1].StartTime.TotalMilliseconds); // round(6000 * 25/30) + Assert.Equal(7500, subtitles[1].EndTime.TotalMilliseconds); // 5000 + round(3000 * 25/30) } } From 0e1e7590e0803b81e8a7a3006750e180333a7b38 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 17:40:56 +0200 Subject: [PATCH 067/380] Honour an already-cancelled token off macOS too CI (Linux) caught this: Ocr checked availability before the cancellation token, so on a platform without Vision an already-cancelled call returned an empty string where the same call on macOS threw. A method taking a CancellationToken should observe it before anything else, and the caller in batch convert relies on the exception to mark the item cancelled. Moved the check to the top. The test that failed is deliberately not gated on macOS - honouring cancellation is not platform-specific - and it now also passes a null bitmap, which takes the same early-return branch the unavailable engine does. That reproduces the CI failure on macOS, so this could be verified here: with the old ordering the new assertion fails with the same "No exception was thrown", and passes with the fix. Co-Authored-By: Claude Opus 5 --- src/ui/Features/Ocr/Engines/AppleVisionOcr.cs | 8 ++++++-- tests/UI/Features/Ocr/Engines/AppleVisionOcrTests.cs | 10 ++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/ui/Features/Ocr/Engines/AppleVisionOcr.cs b/src/ui/Features/Ocr/Engines/AppleVisionOcr.cs index 41d6dad13ee..fc0470264ef 100644 --- a/src/ui/Features/Ocr/Engines/AppleVisionOcr.cs +++ b/src/ui/Features/Ocr/Engines/AppleVisionOcr.cs @@ -242,13 +242,17 @@ private static string DisplayName(string code) /// The recognized text, or an empty string when Vision found none. public static string Ocr(SKBitmap? bitmap, string? languageCode, bool fast, CancellationToken cancellationToken) { + // Cancellation first: a caller that has already cancelled wants to hear about it whatever + // the state of the engine, and checking it after the availability gate made the method + // behave differently by platform - off macOS it returned empty for a cancelled token + // instead of throwing. + cancellationToken.ThrowIfCancellationRequested(); + if (bitmap == null || bitmap.Width < 1 || bitmap.Height < 1 || !IsAvailable()) { return string.Empty; } - cancellationToken.ThrowIfCancellationRequested(); - var png = EncodePng(bitmap); if (png == null || png.Length == 0) { diff --git a/tests/UI/Features/Ocr/Engines/AppleVisionOcrTests.cs b/tests/UI/Features/Ocr/Engines/AppleVisionOcrTests.cs index dc83d6bd48c..bdae12f7266 100644 --- a/tests/UI/Features/Ocr/Engines/AppleVisionOcrTests.cs +++ b/tests/UI/Features/Ocr/Engines/AppleVisionOcrTests.cs @@ -139,6 +139,11 @@ public void Ocr_EmptyImage_IsEmpty() Assert.Equal(string.Empty, AppleVisionOcr.Ocr(bitmap, "en-US", fast: false, CancellationToken.None)); } + /// + /// Deliberately not gated on macOS: an already-cancelled token has to be honoured on every + /// platform, and it was not - off macOS the availability gate returned empty before the token + /// was ever looked at, which is how CI caught this. + /// [Fact] public void Ocr_CancelledBeforeStart_Throws() { @@ -148,6 +153,11 @@ public void Ocr_CancelledBeforeStart_Throws() Assert.Throws( () => AppleVisionOcr.Ocr(bitmap, "en-US", fast: false, cts.Token)); + + // A null bitmap takes the same early-return branch the unavailable engine does, so this + // reproduces the CI failure on macOS too: before the fix it returned empty here. + Assert.Throws( + () => AppleVisionOcr.Ocr(null, "en-US", fast: false, cts.Token)); } private static SKBitmap DrawSubtitle(string[] lines, bool transparentBackground = true) From c995bb352146739da40b7442b2766f13ab72faaf Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 17:44:43 +0200 Subject: [PATCH 068/380] Add live duration-box regression tests for waveform end drags Dragging a cue edge mutates the shared SubtitleLineViewModel, and the edit box's Duration up/down must tick mid-drag - with ShowUpDownEndTime off it is the only live readout of an end drag. Covers the bare control binding, the app's SelectedSubtitle.Duration binding shape, and a full real-main-window drag in that configuration. Co-Authored-By: Claude Opus 5 --- .../AudioVisualizerDragDurationBoxTests.cs | 175 ++++++++++++++++++ .../Main/WaveformDragDurationBoxTests.cs | 137 ++++++++++++++ 2 files changed, 312 insertions(+) create mode 100644 tests/UI/Controls/AudioVisualizerDragDurationBoxTests.cs create mode 100644 tests/UI/Features/Main/WaveformDragDurationBoxTests.cs diff --git a/tests/UI/Controls/AudioVisualizerDragDurationBoxTests.cs b/tests/UI/Controls/AudioVisualizerDragDurationBoxTests.cs new file mode 100644 index 00000000000..fcde9c9b46d --- /dev/null +++ b/tests/UI/Controls/AudioVisualizerDragDurationBoxTests.cs @@ -0,0 +1,175 @@ +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 +{ + 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 }; + 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 }; + 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/Features/Main/WaveformDragDurationBoxTests.cs b/tests/UI/Features/Main/WaveformDragDurationBoxTests.cs new file mode 100644 index 00000000000..909ea249724 --- /dev/null +++ b/tests/UI/Features/Main/WaveformDragDurationBoxTests.cs @@ -0,0 +1,137 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Microsoft.Extensions.DependencyInjection; +using Nikse.SubtitleEdit; +using Nikse.SubtitleEdit.Controls; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Logic.Media; + +namespace UITests.Features.Main; + +/// +/// Dragging a cue's end edge on the waveform must tick the edit box's Duration up/down live - +/// with ShowUpDownEndTime off (the reporter's setup) the duration box is the only live readout +/// of an end drag. +/// +public class WaveformDragDurationBoxTests : IDisposable +{ + private readonly List _windows = new(); + private readonly bool _showEnd = Se.Settings.Appearance.ShowUpDownEndTime; + private readonly bool _showDuration = Se.Settings.Appearance.ShowUpDownDuration; + private readonly bool _frameMode = Se.Settings.General.UseFrameMode; + private readonly bool _snapFrames = Se.Settings.Waveform.SnapToFrames; + private readonly bool _snapCuts = Se.Settings.Waveform.SnapToShotChanges; + private readonly int _layout = Se.Settings.General.LayoutNumber; + + public void Dispose() + { + foreach (var window in _windows) + { + window.Close(); + } + + _windows.Clear(); + Se.Settings.Appearance.ShowUpDownEndTime = _showEnd; + Se.Settings.Appearance.ShowUpDownDuration = _showDuration; + Se.Settings.General.UseFrameMode = _frameMode; + Se.Settings.Waveform.SnapToFrames = _snapFrames; + Se.Settings.Waveform.SnapToShotChanges = _snapCuts; + Se.Settings.General.LayoutNumber = _layout; + } + + private static WavePeakData2 MakePeaks(int sampleRate, 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); + } + + [AvaloniaFact] + public void DraggingTheEndEdgeTicksTheDurationBox() + { + // The reporter's setup: layout 1, ms mode, Start + Duration up/downs but no End box. + Se.Settings.General.LayoutNumber = 1; + Se.Settings.General.UseFrameMode = false; + Se.Settings.Appearance.ShowUpDownEndTime = false; + Se.Settings.Appearance.ShowUpDownDuration = true; + Se.Settings.Waveform.SnapToFrames = false; + Se.Settings.Waveform.SnapToShotChanges = true; // on, like the reporter; no shot-change list loaded + + 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); + + for (var i = 0; i < 3; i++) + { + vm.Subtitles.Add(new SubtitleLineViewModel(new Paragraph($"Line {i + 1}", i * 5000 + 1000, i * 5000 + 3000), null!) + { + Number = i + 1, + }); + } + + vm.SelectedSubtitle = vm.Subtitles[0]; + var av = vm.AudioVisualizer; + av.WavePeaks = MakePeaks(126, 60); + av.SetPosition(0, vm.Subtitles, 0, 0, new List { vm.Subtitles[0] }); + Settle(window); + + var line = vm.Subtitles[0]; // 1000..3000 ms + Assert.Same(line, av.SelectedParagraph); + + var durationBox = window.GetVisualDescendants().OfType() + .Single(c => c.IsEffectivelyVisible); + var durationText = durationBox.GetVisualDescendants().OfType().Single(); + Assert.Equal("2.000", durationText.Text!.Replace(',', '.')); + + // The waveform's own coordinate frame: end edge of 3 s at zoom 1 with 126 px/s. + var origin = av.TranslatePoint(new Point(0, 0), window)!.Value; + var y = origin.Y + av.Bounds.Height / 2; + var endEdgeX = origin.X + 3.0 * 126; + + window.MouseDown(new Point(endEdgeX, y), MouseButton.Left, RawInputModifiers.None); + window.MouseMove(new Point(endEdgeX + 126, y), RawInputModifiers.None); + Settle(window); + + // Mid-drag: the line's end moved a second; the duration box must show it. + Assert.Equal(4000, line.EndTime.TotalMilliseconds); + Assert.Equal(3000, line.Duration.TotalMilliseconds); + Assert.Equal("3.000", durationText.Text!.Replace(',', '.')); + + window.MouseUp(new Point(endEdgeX + 126, y), MouseButton.Left, RawInputModifiers.None); + Settle(window); + Assert.Equal("3.000", durationText.Text!.Replace(',', '.')); + } + + private static void Settle(Window window) + { + for (var pump = 0; pump < 8; pump++) + { + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + } + } +} From bf73c700529d70884336c436c11f019b6df50cd7 Mon Sep 17 00:00:00 2001 From: niksedk Date: Mon, 24 Aug 2026 17:47:28 +0200 Subject: [PATCH 069/380] Add a format-properties toolbar button and restore the EBU save options prompt Saving as EBU STL in SE 5 never showed the save options dialog SE 4 had - every manual save silently wrote a header with defaults the user never saw, and the only way to reach the options was File > Export > EBU STL, which forces a separate export. Reported by email; related to PR #14032. - Add a gear button next to the toolbar's format selector, shown for every format that has a File > " properties..." dialog (it reuses the same command/visibility), so format options are reachable without saving. - Add EBU STL to the File > properties formats: the options dialog now opens settings-only, storing the resulting STL header on the subtitle for later saves - and the chosen justification actually reaches the writer (it was dropped on the floor in the export flow before). - Prompt for EBU options on the first manual save into EBU STL (cancel aborts the save). Once the subtitle carries an STL header - loaded from a file, or stored by any dialog OK - saving is silent again, and auto-save never prompts. - Extract the four copies of the "is this a 1024-char STL header" check into Ebu.IsStlHeader. Co-Authored-By: Claude Fable 5 --- src/libse/SubtitleFormats/Ebu.cs | 13 +++- .../ExportEbuStl/ExportEbuStlViewModel.cs | 7 +- src/ui/Features/Main/Layout/InitToolbar.cs | 18 +++++ src/ui/Features/Main/MainViewModel.cs | 49 ++++++++++-- .../FormatPropertiesToolbarButtonTests.cs | 74 +++++++++++++++++++ tests/libse/SubtitleFormats/EbuTest.cs | 15 ++++ 6 files changed, 161 insertions(+), 15 deletions(-) create mode 100644 tests/UI/Features/Main/FormatPropertiesToolbarButtonTests.cs diff --git a/src/libse/SubtitleFormats/Ebu.cs b/src/libse/SubtitleFormats/Ebu.cs index d0c94a5ed21..7d0f7d947c6 100644 --- a/src/libse/SubtitleFormats/Ebu.cs +++ b/src/libse/SubtitleFormats/Ebu.cs @@ -967,6 +967,17 @@ internal SpecialCharacter(string character, bool switchOrder = false, int priori internal int Priority { get; set; } } + /// + /// True when is a full 1024-character EBU STL header - present after + /// loading an STL file or after the EBU save options dialog has stored one on the subtitle. + /// + public static bool IsStlHeader(string header) + { + return header != null && + header.Length == 1024 && + (header.Contains("STL24") || header.Contains("STL25") || header.Contains("STL29") || header.Contains("STL30")); + } + public bool Save(string fileName, Subtitle subtitle) { return Save(fileName, subtitle, false); @@ -1005,7 +1016,7 @@ public bool Save(string fileName, Stream stream, Subtitle subtitle, bool batchMo // STL file (see LoadSubtitle). Every other format writes its own meaning into it - // ASSA dialogue lines always carry one - so the vertical position below may only be // taken from MarginV when this is true. - var isEbuSource = subtitle.Header != null && subtitle.Header.Length == 1024 && (subtitle.Header.Contains("STL24") || subtitle.Header.Contains("STL25") || subtitle.Header.Contains("STL29") || subtitle.Header.Contains("STL30")); + var isEbuSource = IsStlHeader(subtitle.Header); if (isEbuSource) { header = ReadHeader(GetEncoding(subtitle.Header.Substring(0, 3)).GetBytes(subtitle.Header)); diff --git a/src/ui/Features/Files/ExportEbuStl/ExportEbuStlViewModel.cs b/src/ui/Features/Files/ExportEbuStl/ExportEbuStlViewModel.cs index cdc4ea2bdfb..ec15578483c 100644 --- a/src/ui/Features/Files/ExportEbuStl/ExportEbuStlViewModel.cs +++ b/src/ui/Features/Files/ExportEbuStl/ExportEbuStlViewModel.cs @@ -292,12 +292,7 @@ public void Initialize(Subtitle? subtitle) // Keep the settings the file was written with instead of resetting them to the // defaults above every time the export dialog is opened. - if (!string.IsNullOrEmpty(_subtitle.Header) && - _subtitle.Header.Length == 1024 && - (_subtitle.Header.Contains("STL24") || - _subtitle.Header.Contains("STL25") || - _subtitle.Header.Contains("STL29") || - _subtitle.Header.Contains("STL30"))) + if (Ebu.IsStlHeader(_subtitle.Header)) { try { diff --git a/src/ui/Features/Main/Layout/InitToolbar.cs b/src/ui/Features/Main/Layout/InitToolbar.cs index 4caded7c9d3..81716736cba 100644 --- a/src/ui/Features/Main/Layout/InitToolbar.cs +++ b/src/ui/Features/Main/Layout/InitToolbar.cs @@ -518,6 +518,24 @@ private static Grid CreateToolbar(MainViewModel vm) VerticalAlignment = VerticalAlignment.Center, }; + // One properties/options button for every format with format-specific settings (EBU STL + // options, DCinema/timed-text/WebVTT properties, ...) - the same dialogs as the File menu's + // " properties..." item. Placed left of the format selector: the right-aligned + // panel grows leftwards, so the selector keeps its position when the button appears. + var formatPropertiesButton = new Button + { + Content = MakeImage("Settings"), + Command = vm.FilePropertiesShowCommand, + Background = Brushes.Transparent, + [!AutomationProperties.NameProperty] = new Binding(nameof(vm.FilePropertiesText)) { Source = vm }, + [!Visual.IsVisibleProperty] = new Binding(nameof(vm.IsFilePropertiesVisible)) { Source = vm }, + }; + if (Se.Settings.Appearance.ShowHints) + { + formatPropertiesButton[!ToolTip.TipProperty] = new Binding(nameof(vm.FilePropertiesText)) { Source = vm }; + } + stackPanelRight.Children.Add(formatPropertiesButton); + // subtitle formats stackPanelRight.Children.Add(new TextBlock { diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index b5b77346149..f69e24c7dc0 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -3723,6 +3723,11 @@ private async Task FilePropertiesShow() SetLibSeSettings(); } + if (format is Ebu) + { + await ShowEbuOptionsDialog(); + } + _shortcutManager.ClearKeys(); } @@ -4434,6 +4439,29 @@ private async Task ExportPacUnicode() _shortcutManager.ClearKeys(); } + /// + /// Shows the EBU STL options dialog (header fields, justification, teletext settings). On OK + /// the dialog stores the resulting 1024-character STL header on the working subtitle, so + /// later saves write the options the user chose. Returns false when the dialog was cancelled. + /// + private async Task ShowEbuOptionsDialog() + { + var result = await ShowDialogAsync( + vm => { vm.Initialize(GetUpdateSubtitle()); }, + w => { w.Title = Se.Language.File.EbuSaveOptions.Title; }); + + if (!result.OkPressed) + { + return false; + } + + // The justification combo box is the one option that does not live in the header - it is + // written per text block, from the UI helper. + Ebu.EbuUiHelper ??= new UiEbuSaveHelper(); + Ebu.EbuUiHelper.JustificationCode = result.JustificationCode; + return true; + } + [RelayCommand] private async Task ExportEbuStl() { @@ -4448,16 +4476,11 @@ private async Task ExportEbuStl() return; } - var result = - await ShowDialogAsync(vm => { vm.Initialize(GetUpdateSubtitle()); }); - - if (!result.OkPressed) + if (!await ShowEbuOptionsDialog()) { return; } - Ebu.EbuUiHelper ??= new UiEbuSaveHelper(); - var format = new Ebu(); var fileName = await _fileHelper.PickSaveSubtitleFile( @@ -4471,7 +4494,7 @@ private async Task ExportEbuStl() return; } - format.Save(fileName, result.Subtitle); + format.Save(fileName, GetUpdateSubtitle()); ShowStatus(string.Format(Se.Language.Main.FileExportedInFormatXToFileY, format.Name, fileName)); } @@ -21334,6 +21357,16 @@ private async Task SaveBinarySubtitle(IBinaryPersistableSubtitle binaryFor return false; } + // SE 4 showed the EBU options dialog on every save; SE 5 saved silently with a default + // header the user never got to see. Prompt on the first manual save into EBU STL - + // once the subtitle carries an STL header (loaded from an STL file or stored by the + // dialog's OK, also reachable via File > "EBU STL properties...") saving is silent + // again, and the auto-save timer must never pop a modal dialog. + if (binaryFormat is Ebu && !isAutoSave && !Ebu.IsStlHeader(_subtitle.Header) && !await ShowEbuOptionsDialog()) + { + return false; + } + try { // EBU STL needs a UI helper to resolve its header; reuse the same one as File > Export. @@ -28561,7 +28594,7 @@ internal void ComboBoxSubtitleFormatChanged(object? sender, SelectionChangedEven if (e.AddedItems.Count == 1) { var format = e.AddedItems[0] as SubtitleFormat; - if (format is TimedTextImsc11 or ItunesTimedText or TimedText10 or TimedTextImscRosetta or TmpegEncXml or DCinemaSmpte2007 or DCinemaSmpte2010 or DCinemaSmpte2014 or DCinemaInterop or WebVTT or WebVTTFileWithLineNumber) + if (format is TimedTextImsc11 or ItunesTimedText or TimedText10 or TimedTextImscRosetta or TmpegEncXml or DCinemaSmpte2007 or DCinemaSmpte2010 or DCinemaSmpte2014 or DCinemaInterop or WebVTT or WebVTTFileWithLineNumber or Ebu) { IsFilePropertiesVisible = true; FilePropertiesText = string.Format(Se.Language.Main.XPropertiesDotDotDot, format.Name); 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