diff --git a/.github/workflows/build-flatpak.yml b/.github/workflows/build-flatpak.yml index b1adbb3008c..402bb3eecea 100644 --- a/.github/workflows/build-flatpak.yml +++ b/.github/workflows/build-flatpak.yml @@ -17,13 +17,13 @@ jobs: regenerate-nuget-sources: runs-on: ubuntu-latest env: - # The runtime version the flatpak org.freedesktop.Sdk.Extension.dotnet10//24.08 + # The runtime version the flatpak org.freedesktop.Sdk.Extension.dotnet10//26.08 # extension ships. generate-nuget-sources.sh pins the pre-fetched shared-framework # packs to this exact version; otherwise a self-contained restore rolls them forward # to the newest patch on nuget.org, which mismatches the older runtime the flatpak SDK # pins and breaks the offline restore inside the sandbox (NU1102). Bump this to match # whenever the flathub dotnet10 extension updates its runtime. - DOTNET_RUNTIME_VERSION: '10.0.8' + DOTNET_RUNTIME_VERSION: '10.0.12' steps: - uses: actions/checkout@v5 @@ -58,7 +58,7 @@ jobs: needs: regenerate-nuget-sources runs-on: ubuntu-latest container: - image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-24.08 + image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-26.08 options: --privileged steps: diff --git a/.github/workflows/build-ui.yml b/.github/workflows/build-ui.yml index 5799a270faf..4ddfe485746 100644 --- a/.github/workflows/build-ui.yml +++ b/.github/workflows/build-ui.yml @@ -112,17 +112,17 @@ jobs: uses: actions/cache@v4 with: path: ~/.local/share/flatpak - key: flatpak-fd24.08-dotnet10-v2 + key: flatpak-fd26.08-dotnet10-v2 restore-keys: | - flatpak-fd24.08-dotnet10- + flatpak-fd26.08-dotnet10- - name: Install/update Freedesktop SDK + dotnet extension run: | flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo flatpak --user install -y --noninteractive flathub \ - org.freedesktop.Platform//24.08 \ - org.freedesktop.Sdk//24.08 \ - org.freedesktop.Sdk.Extension.dotnet10//24.08 + org.freedesktop.Platform//26.08 \ + org.freedesktop.Sdk//26.08 \ + org.freedesktop.Sdk.Extension.dotnet10//26.08 flatpak --user update -y --noninteractive - name: Regenerate nuget-sources.json @@ -149,7 +149,7 @@ jobs: needs: regenerate-flatpak-nuget-sources runs-on: ubuntu-latest container: - image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-24.08 + image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-26.08 options: --privileged steps: @@ -229,17 +229,17 @@ jobs: uses: actions/cache@v4 with: path: ~/.local/share/flatpak - key: flatpak-fd24.08-dotnet10-v2 + key: flatpak-fd26.08-dotnet10-v2 restore-keys: | - flatpak-fd24.08-dotnet10- + flatpak-fd26.08-dotnet10- - name: Install/update Freedesktop SDK + dotnet extension run: | flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo flatpak --user install -y --noninteractive flathub \ - org.freedesktop.Platform//24.08 \ - org.freedesktop.Sdk//24.08 \ - org.freedesktop.Sdk.Extension.dotnet10//24.08 + org.freedesktop.Platform//26.08 \ + org.freedesktop.Sdk//26.08 \ + org.freedesktop.Sdk.Extension.dotnet10//26.08 flatpak --user update -y --noninteractive # Checks out the pinned commit directly in this checkout (safe: this job's @@ -310,7 +310,7 @@ jobs: runner: ubuntu-24.04-arm runs-on: ${{ matrix.runner }} container: - image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-24.08 + image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-26.08 options: --privileged steps: diff --git a/change-log.txt b/change-log.txt index f0ef7da818d..f00f02994d7 100644 --- a/change-log.txt +++ b/change-log.txt @@ -3,6 +3,89 @@ Subtitle Edit Changelog ----------------------------------------------------------------------------------------------------- +v5.3.0-rc1 (10th of October 2026) + +* Add search of help topics from the macOS Help menu search field +* Fix llama.cpp OCR ignoring the server URL (OCR + batch OCR can now use an external server) - thx GitGerby +* Fix iTT export losing italic in nested formatting spans - thx michi987654 +* Fix Compare window jumping to a scrolled-away row when focus returns - thx WAusJackBauer +* Fix jitter on held short millisecond seek shortcuts - thx 114562175 +* Update Avalonia to 12.1.4 +* Update German translation - thx FunkyKnilch +* Update Italian translation - thx bovirus +* Update Japanese translation - thx hisui3393 +* Update Korean translation - thx 12si27 + + +----------------------------------------------------------------------------------------------------- + +v5.3.0-beta26 (9th of October 2026) + +* Add ElevenLabs Eleven v4 and v4 Turbo TTS models (v4 Turbo is the new default, converted to audio pause tags) +* Add optional Sidon cleaning of voice-clone reference audio +* Add Find and Replace to the OCR window +* Add video player deinterlace setting (default auto) - thx MaxBunny1786 +* Add reload of the subtitle file when changed outside SE - thx jojorne +* Add typing a custom frame rate - thx jojorne +* Add Cut/Copy/Paste to the Find/Replace regex context menu - thx hisui3393 +* ASSA draw: live resolution change, zoom/reset view and F9 preview of the rendered drawing +* Set position / Set background / Image color picker: use the paused video frame inside the line - thx 114562175 +* Set start/end and offset the rest: work without waveform focus, F9 default - thx DrDanAharoni +* Fix no progress shown for whisper.cpp in Speech to text - thx abc16361 +* Fix custom ms seek accumulating after jump to start/end of line - thx 114562175 +* Fix Merge as dialog dash placed inside leading formatting tags - thx accessallareas +* Fix OCR "All fixes"/"All guesses" line numbers after deleting lines - thx Peter Vella +* Fix ASSA draw OK overwriting selected text lines + canvas sync bugs +* Fix main menu dropdown size with UI scale at startup +* Fix seconv --overwrite emptying the input file if interrupted - thx yottayoshida +* Update CrispASR to v0.8.42 +* Update audio.cpp runtime to v0.9.1 +* Update Chinese Traditional translation - thx love80312 +* Update Italian translation - thx bovirus +* Update Japanese translation - thx hisui3393 +* Update Polish translation - thx potplayer-fanpack + +----------------------------------------------------------------------------------------------------- + +v5.3.0-beta25 (8th of October 2026) + +* Add taskbar/Dock progress for Speech to text, Text to speech, AI review, Auto-translate, Burn-in and Generate shot changes - thx pdjdev +* Add spell check editor for the "Use always" list +* Add "Show recent files" option to disable recent files - thx amymor +* OCR: automation ids + "Running OCR..." in window title while running - thx skylinekiller +* Fix seek shortcuts not working in Visual sync - thx amymor +* Fix plugin time codes changing after apply in frame mode (ArteCheck corrections reappearing) - thx Triathlon-rally +* Plugins: follow the EBU STL frame rate a plugin sets in the header - thx Triathlon-rally +* Fix Set sync point stuck at 0:00 when no video player library can be loaded - thx ovencovsky +* Fix spell check "Change all" pairs changing correctly spelled words (e.g. "and" to "&") - thx fraternl +* Fix spell check stale suggestion and change-all pairs/undo kept when switching dictionary +* Fix hard-to-read colored text in light theme (Fix common errors, AI review, Restore auto-backup, Split, Batch convert, Statistics, Translation error) - thx subcor +* Fix unstable duration/scrubbing for .ts/.mpg video files - thx dsouzabs +* Fix submenu/context menu item size with UI scale at startup +* Fix Linux startup crash when Skia reports no default font family - thx MaryJaneInChain +* Update Italian translation - thx bovirus + +----------------------------------------------------------------------------------------------------- + +v5.3.0-beta24 (7th of October 2026) + +* Add "Play with speech": speak lines in sync while the video plays - thx Jose4D +* Add "custom milliseconds back/forward and pause" video shortcuts - thx accessallareas +* Add option to remember waveform zoom across restarts - thx bichitoxxx +* Batch convert: ask before adding video files from a folder - thx jsinfate +* Batch convert: faster adding of many files +* Close translation: ask before closing, keep the original's timing on a mismatched original - thx bichitoxxx +* SMPTE timing: frame numbers follow the NDF time code clock (FCP 7 XML + Final Cut Pro X) - thx smt-joen +* Fix ASSA override tags piling up when moving words - thx rRobis +* Fix EBU STL import frame-rate race - thx Triathlon-rally +* Update Flatpak runtime to freedesktop 26.08 (24.08 is end-of-life) - thx Gensplejs +* Update German translation - thx FunkyKnilch +* Update Japanese translation - thx hisui3393 +* Update Korean translation - thx 12si27 +* Update Polish translation - thx potplayer-fanpack + +----------------------------------------------------------------------------------------------------- + v5.3.0-beta23 (6th of October 2026) * Add batch convert functions: Remove Unicode control characters, Renumber, Merge continuation lines, Convert actors @@ -36,6 +119,7 @@ v5.3.0-beta23 (6th of October 2026) * Update Italian translation - thx bovirus * Update Polish translation - thx potplayer-fanpack +----------------------------------------------------------------------------------------------------- v5.3.0-beta22 (5th of October 2026) diff --git a/docs/features/assa-draw.md b/docs/features/assa-draw.md index f41bd253e23..d72526dc482 100644 --- a/docs/features/assa-draw.md +++ b/docs/features/assa-draw.md @@ -28,10 +28,11 @@ A vector drawing tool for creating ASSA drawing commands (\p1 ... \p0) used in A ### Toolbar Actions - **Save**, **Load**, **Copy to Clipboard** (`Ctrl+C`). - **Close Shape** (`F8`/`Enter`), **Delete Shape** (`Del`), **Clear All** (`Ctrl+N`). -- **Zoom in**, **Zoom out**, **Reset View** (`Ctrl+0`), **Toggle Grid** (`Ctrl+G`). +- **Zoom in**, **Zoom out** (10% steps, same as `Ctrl`+mouse wheel), **Reset View** (`Ctrl+0`, fits the whole frame), **Toggle Grid** (`Ctrl+G`). +- **Toggle preview** (`F9`): shows the drawing as it will render (filled, in layer colors, with erase shapes cut out) over the video frame, with the outlines and points still on top for editing. Needs ffmpeg with libass. ### Canvas -- Configurable canvas size (default 1920×1080). +- Canvas size follows the script resolution (PlayResX/PlayResY), else the video size, and can be changed. - Zoom in/out for detailed editing. - Grid overlay for alignment. - Real-time position display. diff --git a/docs/features/ocr.md b/docs/features/ocr.md index c658d965f50..7d4406574b6 100644 --- a/docs/features/ocr.md +++ b/docs/features/ocr.md @@ -127,10 +127,18 @@ Right-click the subtitle list for: - **Inspect line...** — nOCR and Binary image compare only - **Show image**, **Save image as...**, **Copy image to clipboard** - **Delete**, and **Fill selected lines with clipboard text** when several lines are selected +- **Find...**, **Replace...** — See [Find and Replace](#find-and-replace) - **Italic**, **Bold** - **Edit/export...**, **Import text from subtitle...**, **Export text as subtitle...** - **Save all images with HTML index...** — See [Saving the Images](#saving-the-images) +## Find and Replace + +**Find...** and **Replace...** (subtitle list or text box right-click menu, Ctrl+F / Ctrl+H) open +the same Find and Replace windows as the main window, so text can be searched and fixed while the +image is still next to it. A match selects its line and highlights the text in the text box. +Replace all also updates the lines' highlighted OCR text in the list. + ## Saving the Images The subtitle list's right-click menu has **Save all images with HTML index...**. Pick a folder and @@ -152,6 +160,9 @@ number, time codes, duration and image size. |----------|--------| | Escape | Cancel OCR / Close window | | Ctrl+G | Go to line number | +| Ctrl+F | Find | +| Ctrl+H | Replace (Cmd+Alt+F on macOS) | +| F3 / Shift+F3 | Find next / Find previous | | Ctrl++ | Zoom in (images in grid) | | Ctrl+- | Zoom out (images in grid) | | F1 | Show help | diff --git a/docs/features/shortcuts.md b/docs/features/shortcuts.md index d97299145e9..11bcd256c46 100644 --- a/docs/features/shortcuts.md +++ b/docs/features/shortcuts.md @@ -48,7 +48,7 @@ Some commands have additional configuration beyond the shortcut key: - **Set color 1–8** — Choose a color for each color shortcut - **Surround with 1–8** — Define the left/right text to surround selected text with (this replaces the *Shortcut toggle custom start/end* setting from Subtitle Edit 4), and its **Behavior**: *Toggle* adds the text, or removes it when it is already there; *Add* adds it every time, so pressing the shortcut twice adds it twice; *Remove (all)* only removes it, all at once; *Remove (one each time)* removes one pair per press, so it undoes *Add* step by step. **Works on** picks *Selection, else whole text* (the selected part of the text box, otherwise the whole text of each selected subtitle) or *Each line* (every line of a subtitle gets its own pair, e.g. `[Hello]` / `[Bye]`) -- **Video move custom 1–4 back/forward** — Set the number of milliseconds to skip +- **Video move custom 1–4 back/forward** — Set the number of milliseconds to skip. Each one also has a **…and pause** variant that uses the same milliseconds but pauses playback first, so a jog/shuttle step leaves the video paused on the new position - **Set actor 1–10** — Define the actor name assigned by each actor shortcut - **Custom search 1–5** — Set the name and URL for each search slot - **Custom shortcut 1–8** — Build your own shortcut from steps (see below) diff --git a/docs/features/spell-check.md b/docs/features/spell-check.md index 579c5667752..94d7ecc771a 100644 --- a/docs/features/spell-check.md +++ b/docs/features/spell-check.md @@ -36,6 +36,26 @@ When a misspelled word is found, the spell checker provides a list of suggested - **Use once** — Replace with the selected suggestion for this occurrence - **Use always** — Replace all occurrences with the selected suggestion +## "Use always" list + +**Change all** and **Use always** save the correction per language in the "Use always" list +(`_UseAlways.xml` in the dictionary folder). A saved correction is only applied to words +the dictionary flags as misspelled, so a correctly spelled word is never replaced. + +To view or edit the list, click the list button next to the dictionary dropdown in the spell +check window, or use **Edit "Use always" list...** in **Options → Word lists**: + +- Pick the language, and search to filter the pairs +- Select a pair to edit it, or type a misspelled word and its replacement and click **Add** +- Remove a pair with its trash button (or **Delete**) +- Pairs whose word is spelled correctly (or is a name or user word) are marked with a warning + icon - spell check never flags those words, so the pair is never used. **Remove unused** deletes + them all +- Changes are saved when you click **OK** + +The list is only used and saved when **Spell check: remember "Use always" list** is turned on in +**Settings → Tools**. + ## Dictionaries Spell check requires a dictionary to be installed. If no dictionary is found, you will be prompted to download one. diff --git a/docs/features/text-to-speech.md b/docs/features/text-to-speech.md index b63050eea93..5bea77315c1 100644 --- a/docs/features/text-to-speech.md +++ b/docs/features/text-to-speech.md @@ -76,6 +76,24 @@ While reading, the selected line, the video position and the waveform follow the - **Speed:** the **Speed** dropdown in the waveform toolbar sets how fast the lines are read, and a change is heard right away - **Stop:** press **Escape**, click another line, choose **Stop speaking** in the grid's right-click menu, or run the command (or shortcut) again +## Play With Speech + +Watch a video with its subtitles spoken, like a live dub - handy for a course or a film in a language you do not speak, so you can watch the picture instead of reading. **Play with speech...** plays the video and speaks each line when the video reaches it. Nothing is saved; for an audio track to keep, use **Text to speech** above. + +- **Menu:** **Video → Play with speech...** +- **Shortcut:** Configurable (no default) + +1. Open the video and the (translated) subtitle, and move the video to where you want to start +2. Choose **Play with speech...** +3. Pick an engine, a voice and (for Supertonic) a language - the same fast engines as **Speak from current line** - and the options below, then click **OK** + +- **Lower video volume while speaking:** the video's own sound is turned down while a line is spoken, and back up between lines +- **Pause video when speech runs into the next line:** a spoken line that is longer than its subtitle is first sped up (up to 1.5x). If it still runs into the next line, the video waits for it to finish, so no line is cut off or skipped. With this off, the video keeps playing and a line the video has already passed is skipped + +The next few lines are generated ahead while the video plays. If the engine falls behind - for example while a local engine starts - the video waits for it. Pausing the video pauses the speech, seeking (clicking the waveform, a line or the position slider) continues from the new position, and the waveform toolbar's **Speed** applies to both the video and the speech. The subtitle is read as it was when you started - edits made while playing are spoken after a restart. + +- **Stop:** press **Escape**, choose **Stop speaking** in the grid's right-click menu, or run the command (or shortcut) again + ## Supported Engines - **Piper** — Local, open-source TTS (Windows and Linux). Custom voice models are supported: any Piper voice is an `.onnx` model with an `.onnx.json` config beside it, and **Import voice...** in the voice settings dialog (the settings button next to **Test voice**) copies such a pair into the Piper folder (`TextToSpeech/Piper` in the data folder). It then appears in the voice list as *Custom - name*; a pair copied into the folder by hand is picked up the same way diff --git a/docs/features/video-player.md b/docs/features/video-player.md index 29186a17c3f..fd6a0ec5ac9 100644 --- a/docs/features/video-player.md +++ b/docs/features/video-player.md @@ -49,6 +49,7 @@ The layout applies to every video player, including the ones in dialogs like **S | 1 second back | Jump 1 second backward | | 1 second forward | Jump 1 second forward | | Custom back/forward | Configurable jump amounts | +| Custom back/forward and pause | Same jump amounts, but pause playback first | > **Note:** Actual key bindings depend on your shortcut configuration. See **Options → Shortcuts**. diff --git a/installer/flatpak/dk.nikse.subtitleedit.yaml b/installer/flatpak/dk.nikse.subtitleedit.yaml index bfe4f5946f9..2cc6498f280 100644 --- a/installer/flatpak/dk.nikse.subtitleedit.yaml +++ b/installer/flatpak/dk.nikse.subtitleedit.yaml @@ -1,6 +1,6 @@ app-id: dk.nikse.subtitleedit runtime: org.freedesktop.Platform -runtime-version: '24.08' +runtime-version: '26.08' sdk: org.freedesktop.Sdk sdk-extensions: - org.freedesktop.Sdk.Extension.dotnet10 diff --git a/installer/flatpak/flathub/dk.nikse.subtitleedit.yaml b/installer/flatpak/flathub/dk.nikse.subtitleedit.yaml index 09605c42b01..a19b6513072 100644 --- a/installer/flatpak/flathub/dk.nikse.subtitleedit.yaml +++ b/installer/flatpak/flathub/dk.nikse.subtitleedit.yaml @@ -1,6 +1,6 @@ app-id: dk.nikse.subtitleedit runtime: org.freedesktop.Platform -runtime-version: '24.08' +runtime-version: '26.08' sdk: org.freedesktop.Sdk sdk-extensions: - org.freedesktop.Sdk.Extension.dotnet10 diff --git a/installer/flatpak/flathub/prepare-flathub-release.sh b/installer/flatpak/flathub/prepare-flathub-release.sh index c5982b18795..f698847f23c 100755 --- a/installer/flatpak/flathub/prepare-flathub-release.sh +++ b/installer/flatpak/flathub/prepare-flathub-release.sh @@ -31,7 +31,7 @@ # anything to flathub/flathub, or push to flathub/dk.nikse.subtitleedit. # # Requires: git, python3 (used to parse/update the manifest's tag/commit fields), and -# either (a) flatpak + org.freedesktop.Sdk.Extension.dotnet10//24.08, or (b) a system +# either (a) flatpak + org.freedesktop.Sdk.Extension.dotnet10//26.08, or (b) a system # dotnet 10 SDK install (see ../generate-nuget-sources.sh for details on both paths). # # Usage (from repo root): diff --git a/installer/flatpak/generate-nuget-sources.sh b/installer/flatpak/generate-nuget-sources.sh index b9c8b6bd2c8..74e70295ed0 100644 --- a/installer/flatpak/generate-nuget-sources.sh +++ b/installer/flatpak/generate-nuget-sources.sh @@ -10,8 +10,8 @@ # Run this script whenever NuGet dependencies change, then commit both # nuget-sources.json and src/ui/packages.lock.json alongside the manifest. # -# Requires (preferred): flatpak, org.freedesktop.Sdk//24.08, -# org.freedesktop.Sdk.Extension.dotnet10//24.08, python3 +# Requires (preferred): flatpak, org.freedesktop.Sdk//26.08, +# org.freedesktop.Sdk.Extension.dotnet10//26.08, python3 # Requires (fallback): dotnet >= 10, python3 # # Usage (from repo root): @@ -34,7 +34,7 @@ GENERATOR="$SCRIPT_DIR/flatpak-dotnet-generator.py" GENERATOR_COMMIT="3fc0620788a1dda1a3a539b8f972edadce8260ab" GENERATOR_URL="https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/${GENERATOR_COMMIT}/dotnet/flatpak-dotnet-generator.py" -FREEDESKTOP_VERSION="24.08" +FREEDESKTOP_VERSION="26.08" DOTNET_VERSION="10" cd "$REPO_ROOT" diff --git a/src/libse/Common/DialogSplitMerge.cs b/src/libse/Common/DialogSplitMerge.cs index 4c957bedad8..4ba560e2af2 100644 --- a/src/libse/Common/DialogSplitMerge.cs +++ b/src/libse/Common/DialogSplitMerge.cs @@ -443,7 +443,7 @@ public static DialogType GetDialogStyleFromIndex(int index) } } - private static string GetStartTags(string input) + public static string GetStartTags(string input) { var pre = new StringBuilder(); var s = input; diff --git a/src/libse/Common/StringExtensions.cs b/src/libse/Common/StringExtensions.cs index 721595e90b8..ad9c9f8f5b9 100644 --- a/src/libse/Common/StringExtensions.cs +++ b/src/libse/Common/StringExtensions.cs @@ -132,6 +132,33 @@ public static bool Contains(this string source, string value, StringComparison c public static List SplitToLines(this string s) => s.SplitToLines(s.Length); + /// + /// Returns the number of lines would return, using the same + /// line break rules ("\r\r\n" is two breaks, #8854), without allocating the lines. + /// + public static int CountLines(this string s) + { + var span = s.AsSpan(); + var count = 1; + while (true) + { + var idx = span.IndexOfAny('\r', '\n', '\u2028'); + if (idx < 0) + { + return count; + } + + count++; + var skip = idx + 1; + if (span[idx] == '\r' && skip < span.Length && span[skip] == '\n') + { + skip++; + } + + span = span.Slice(skip); + } + } + /// /// Rewrites every line break to , so text coming from /// the outside (a paste, an API answer) uses the same line break as text SE builds diff --git a/src/libse/Forms/MoveWordUpDown.cs b/src/libse/Forms/MoveWordUpDown.cs index bdb559189e7..5e3fd141e90 100644 --- a/src/libse/Forms/MoveWordUpDown.cs +++ b/src/libse/Forms/MoveWordUpDown.cs @@ -6,7 +6,7 @@ namespace Nikse.SubtitleEdit.Core.Forms { - public class MoveWordUpDown + public partial class MoveWordUpDown { public string S1 { get; private set; } public string S2 { get; private set; } @@ -18,6 +18,12 @@ public class MoveWordUpDown /// public bool AutoBreak { get; set; } = true; + /// + /// S1 and S2 are the two lines of one subtitle. ASSA override tags carry across the line + /// break, so a moved word needs no tags of its own: only the line break moves. + /// + public bool SameSubtitle { get; set; } + public MoveWordUpDown(string s1, string s2) { S1 = s1 ?? string.Empty; @@ -34,6 +40,12 @@ public void MoveWordUp() return; } + if (UseAssaPath()) + { + MoveWordUpAssa(); + return; + } + var s2Trimmed = S2.Trim(); // Parse to find the first word with its surrounding tags @@ -43,6 +55,7 @@ public void MoveWordUp() var tagSb = new StringBuilder(); var inWord = false; var wordEndPos = -1; + var wordClosedTopTag = false; for (int i = 0; i < s2Trimmed.Length; i++) { @@ -78,6 +91,7 @@ public void MoveWordUp() { // We hit a closing tag after the word started wordEndPos = i + 1; + wordClosedTopTag = true; break; } else if (openTags.Count > 0) @@ -103,6 +117,7 @@ public void MoveWordUp() if (inWord) { wordEndPos = i + 1; + wordClosedTopTag = true; break; } else if (openTags.Count > 0) @@ -221,7 +236,15 @@ public void MoveWordUp() // OR if a tag doesn't have a closing equivalent, we need to add back the opening tag var tagsToReopen = new System.Collections.Generic.List<(string opening, string closing)>(); - foreach (var (opening, closing) in openTags.Reverse()) + // The innermost tag was closed right after the word (moved up with it), so it + // must not be reopened just because a later run of the same tag closes in S2. + var stillOpen = new System.Collections.Generic.List<(string opening, string closing)>(openTags.Reverse()); + if (wordClosedTopTag && stillOpen.Count > 0) + { + stillOpen.RemoveAt(stillOpen.Count - 1); + } + + foreach (var (opening, closing) in stillOpen) { if (string.IsNullOrEmpty(closing)) { @@ -260,6 +283,12 @@ public void MoveWordDown() return; } + if (UseAssaPath()) + { + MoveWordDownAssa(); + return; + } + var s1Trimmed = S1.Trim(); // Find start of last word and any active tags @@ -408,6 +437,7 @@ public void MoveWordDown() } private static readonly Regex EmptyFontTagRegex = new Regex(@"]*>\s*", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex HtmlTagRegex = new Regex(@" + /// Word moves for text with ASSA override blocks ("{\c&H0000FF&}"). An override has no + /// closing tag: it stays in effect until the end of the subtitle. So the moved word gets the + /// tags in effect where it was, and nothing more, instead of a copy of every block. Before + /// this, every block was a never-closed opener, and moving a word back and forth piled the + /// tags up (issue #15756). + /// + public partial class MoveWordUpDown + { + private enum TokenKind + { + Text, + Block, + Space, + } + + private sealed class Token + { + public TokenKind Kind { get; } + public string Value { get; } + + public Token(TokenKind kind, string value) + { + Kind = kind; + Value = value; + } + } + + private sealed class AssaTag + { + public string Name { get; } + public string Value { get; } + public string Key { get; } + + public AssaTag(string name, string value) + { + Name = name; + Value = value; + Key = CanonicalKey(name); + } + + public override string ToString() => "\\" + Name + Value; + } + + /// + /// Override tags in effect, one stack per tag. A stack, so a block right after a word + /// (SE closes a color selection with "{\c&H(style color)&}") restores the value + /// before it instead of counting as a new color. + /// + private sealed class TagState + { + private readonly List _order = new List(); + private readonly Dictionary> _stacks = new Dictionary>(); + + public bool IsEmpty => _order.Count == 0; + + public bool Has(string key) => _stacks.ContainsKey(key); + + public AssaTag Top(string key) => _stacks.TryGetValue(key, out var stack) ? stack[stack.Count - 1] : null; + + public List Snapshot() => _order.Select(Top).ToList(); + + public TagState Flatten() + { + var state = new TagState(); + foreach (var tag in Snapshot()) + { + state.Push(tag); + } + + return state; + } + + public void Apply(AssaTag tag, bool isSuffix) + { + if (tag.Key == "r") + { + _order.Clear(); + _stacks.Clear(); + if (tag.Value.Length > 0) + { + Push(tag); + } + + return; + } + + if (IsReset(tag)) + { + Remove(tag.Key); + } + else if (isSuffix && _stacks.TryGetValue(tag.Key, out var stack)) + { + stack.RemoveAt(stack.Count - 1); + if (stack.Count == 0) + { + Remove(tag.Key); + } + } + else + { + Push(tag); + } + } + + public bool SameAs(TagState other) + { + if (_order.Count != other._order.Count) + { + return false; + } + + foreach (var key in _order) + { + var otherTag = other.Top(key); + if (otherTag == null || !string.Equals(Top(key).Value, otherTag.Value, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + + private void Push(AssaTag tag) + { + if (!_stacks.TryGetValue(tag.Key, out var stack)) + { + stack = new List(); + _stacks.Add(tag.Key, stack); + _order.Add(tag.Key); + } + + stack.Add(tag); + } + + private void Remove(string key) + { + _stacks.Remove(key); + _order.Remove(key); + } + } + + // Longest first, so "\bord" is not read as "\b" + "ord". + private static readonly string[] AssaTagNames = new[] + { + "xbord", "ybord", "xshad", "yshad", "iclip", "alpha", + "fscx", "fscy", "bord", "shad", "blur", "move", "fade", "clip", + "pos", "org", "fsp", "fax", "fay", "frx", "fry", "frz", "pbo", "fad", + "an", "fn", "fs", "fe", "fr", "be", "kf", "ko", + "a", "b", "c", "i", "k", "K", "p", "q", "r", "s", "t", "u", + }; + + // Tags for the whole subtitle (position, alignment, fade, clip): they stay where they are. + private static readonly HashSet EventLevelKeys = new HashSet(StringComparer.Ordinal) + { + "pos", "move", "org", "an", "a", "fade", "clip", "iclip", "q", + }; + + // Karaoke, animation and drawing tags belong to the word they precede and travel with it. + private static readonly HashSet WordLocalKeys = new HashSet(StringComparer.Ordinal) + { + "k", "kf", "ko", "K", "t", "p", "pbo", + }; + + private static string CanonicalKey(string name) + { + switch (name) + { + case "c": return "1c"; + case "fr": return "frz"; + case "fad": return "fade"; + default: return name; + } + } + + private static bool IsToggle(string key) => key == "i" || key == "b" || key == "u" || key == "s"; + + private static bool IsReset(AssaTag tag) => tag.Value.Length == 0 || (IsToggle(tag.Key) && tag.Value == "0"); + + // "{\c}" at the very end of a line changes nothing; "{\i0}" is kept as the usual close. + private static bool IsNoOpAtLineEnd(AssaTag tag) => tag.Value.Length == 0 && !IsToggle(tag.Key) && tag.Key != "r"; + + private static AssaTag ResetOf(AssaTag tag) => new AssaTag(tag.Name, IsToggle(tag.Key) ? "0" : string.Empty); + + private bool UseAssaPath() + { + var text = S1 + S2; + return text.Contains("{\\") && !HtmlTagRegex.IsMatch(text); + } + + private static List Tokenize(string s) + { + var tokens = new List(); + var i = 0; + while (i < s.Length) + { + var start = i; + if (s[i] == '{' && s.IndexOf('}', i) > i) + { + i = s.IndexOf('}', i) + 1; + tokens.Add(new Token(TokenKind.Block, s.Substring(start, i - start))); + } + else if (char.IsWhiteSpace(s[i])) + { + while (i < s.Length && char.IsWhiteSpace(s[i])) + { + i++; + } + + tokens.Add(new Token(TokenKind.Space, s.Substring(start, i - start))); + } + else + { + i++; + while (i < s.Length && !char.IsWhiteSpace(s[i]) && s[i] != '{') + { + i++; + } + + tokens.Add(new Token(TokenKind.Text, s.Substring(start, i - start))); + } + } + + return tokens; + } + + private static List ParseBlock(string block) + { + var tags = new List(); + var content = block.Substring(1, block.Length - 2); + if (!content.StartsWith("\\", StringComparison.Ordinal)) + { + return tags; // comment block + } + + var i = 0; + while (i < content.Length) + { + if (content[i] != '\\') + { + i++; + continue; + } + + i++; + string name; + if (i + 1 < content.Length && char.IsDigit(content[i]) && (content[i + 1] == 'c' || content[i + 1] == 'a')) + { + name = content.Substring(i, 2); + } + else + { + name = AssaTagNames.FirstOrDefault(n => string.CompareOrdinal(content, i, n, 0, n.Length) == 0); + if (name == null) + { + var end = i; + while (end < content.Length && char.IsLetter(content[end])) + { + end++; + } + + name = content.Substring(i, end - i); + } + } + + i += name.Length; + var valueStart = i; + var depth = 0; + while (i < content.Length && (content[i] != '\\' || depth > 0)) + { + if (content[i] == '(') + { + depth++; + } + else if (content[i] == ')' && depth > 0) + { + depth--; + } + + i++; + } + + if (name.Length > 0) + { + tags.Add(new AssaTag(name, content.Substring(valueStart, i - valueStart))); + } + } + + return tags; + } + + private static string BuildBlock(IEnumerable tags) + { + var sb = new StringBuilder(); + foreach (var tag in tags) + { + sb.Append(tag); + } + + return sb.Length == 0 ? string.Empty : "{" + sb + "}"; + } + + private static string Join(List tokens, int start, int end) + { + var sb = new StringBuilder(); + for (var k = start; k < end; k++) + { + sb.Append(tokens[k].Value); + } + + return sb.ToString(); + } + + private static bool HasText(List tokens, int start, int end) + { + for (var k = start; k < end; k++) + { + if (tokens[k].Kind == TokenKind.Text) + { + return true; + } + } + + return false; + } + + /// + /// A block glued to the end of a word ("word{\c&HFFFFFF&} next") closes what the + /// word opened, so it restores the earlier value. + /// + private static bool IsSuffixBlock(List tokens, int index) + { + var before = index - 1; + while (before >= 0 && tokens[before].Kind == TokenKind.Block) + { + before--; + } + + var after = index + 1; + while (after < tokens.Count && tokens[after].Kind == TokenKind.Block) + { + after++; + } + + return before >= 0 && tokens[before].Kind == TokenKind.Text && (after >= tokens.Count || tokens[after].Kind == TokenKind.Space); + } + + private static TagState Simulate(List tokens, int start, int end, TagState state = null) + { + state = state ?? new TagState(); + for (var k = start; k < end; k++) + { + if (tokens[k].Kind != TokenKind.Block) + { + continue; + } + + var isSuffix = IsSuffixBlock(tokens, k); + foreach (var tag in ParseBlock(tokens[k].Value)) + { + if (!EventLevelKeys.Contains(tag.Key) && !WordLocalKeys.Contains(tag.Key)) + { + state.Apply(tag, isSuffix); + } + } + } + + return state; + } + + private static List BlockTags(List tokens, int start, int end) + { + var tags = new List(); + for (var k = start; k < end; k++) + { + if (tokens[k].Kind == TokenKind.Block) + { + tags.AddRange(ParseBlock(tokens[k].Value)); + } + } + + return tags; + } + + private static int LastTextIndex(List tokens) + { + for (var k = tokens.Count - 1; k >= 0; k--) + { + if (tokens[k].Kind == TokenKind.Text) + { + return k; + } + } + + return -1; + } + + private static int FirstTextIndex(List tokens) + { + return tokens.FindIndex(t => t.Kind == TokenKind.Text); + } + + /// Tags that turn into . + private static List Diff(TagState from, TagState to) + { + var result = new List(); + var toR = to.Top("r"); + var fromR = from.Top("r"); + if (toR != null || fromR != null) + { + if (toR == null || fromR == null || !string.Equals(toR.Value, fromR.Value, StringComparison.Ordinal)) + { + if (toR == null) + { + result.Add(new AssaTag("r", string.Empty)); + } + + result.AddRange(to.Snapshot()); + return result; + } + } + + foreach (var tag in from.Snapshot()) + { + if (!to.Has(tag.Key)) + { + result.Add(ResetOf(tag)); + } + } + + foreach (var tag in to.Snapshot()) + { + var old = from.Top(tag.Key); + if (old == null || !string.Equals(old.Value, tag.Value, StringComparison.OrdinalIgnoreCase)) + { + result.Add(tag); + } + } + + return result; + } + + /// The word's tokens, without the closing tags that do nothing at the end of a line. + private static string JoinForLineEnd(List word, bool dropSuffix) + { + var lastText = LastTextIndex(word); + var sb = new StringBuilder(Join(word, 0, lastText + 1)); + if (!dropSuffix) + { + for (var k = lastText + 1; k < word.Count; k++) + { + sb.Append(BuildBlock(ParseBlock(word[k].Value).Where(t => !IsNoOpAtLineEnd(t)))); + } + } + + return sb.ToString(); + } + + private void MoveWordDownAssa() + { + var t1 = Tokenize(S1.Trim()); + var lastText = LastTextIndex(t1); + if (lastText < 0) + { + return; + } + + var chunkStart = lastText; + while (chunkStart > 0 && t1[chunkStart - 1].Kind != TokenKind.Space) + { + chunkStart--; + } + + if (SameSubtitle) + { + // Overrides carry across the line break, so just move the break. + S2 = (Join(t1, chunkStart, t1.Count).Trim() + " " + S2.Trim()).Trim(); + S1 = Join(t1, 0, chunkStart).Trim(); + S2 = AutoBreakIfNeeded(S2); + return; + } + + var wordStart = t1.FindIndex(chunkStart, t => t.Kind == TokenKind.Text); + var leadTags = BlockTags(t1, chunkStart, wordStart); + var events = leadTags.Where(t => EventLevelKeys.Contains(t.Key)).ToList(); + var locals = leadTags.Where(t => WordLocalKeys.Contains(t.Key)).ToList(); + var word = t1.Skip(wordStart).Where(t => t.Kind != TokenKind.Space).ToList(); + + var startState = Simulate(t1, 0, wordStart); + var endState = Simulate(word, 0, word.Count, startState.Flatten()); + + // What stays on the first line + if (!HasText(t1, 0, chunkStart)) + { + S1 = string.Empty; + } + else + { + var restState = Simulate(t1, 0, chunkStart); + var closing = BlockTags(word, LastTextIndex(word) + 1, word.Count) + .Where(t => restState.Has(t.Key) && !IsNoOpAtLineEnd(t)); + S1 = Join(t1, 0, chunkStart).Trim() + BuildBlock(events) + BuildBlock(closing); + } + + // The word goes in front of the second line + var t2 = Tokenize(S2.Trim()); + var firstText2 = FirstTextIndex(t2); + var lead2End = firstText2 < 0 ? t2.Count : firstText2; + var lead2Tags = BlockTags(t2, 0, lead2End); + var prefix = BuildBlock(lead2Tags.Where(t => EventLevelKeys.Contains(t.Key)) + .Concat(startState.Snapshot()) + .Concat(locals)); + + if (firstText2 < 0) + { + S2 = prefix + JoinForLineEnd(word, false); + } + else + { + var lead2State = Simulate(t2, 0, firstText2); + var lead2Locals = lead2Tags.Where(t => WordLocalKeys.Contains(t.Key)); + var rest2 = Join(t2, firstText2, t2.Count); + if (endState.SameAs(lead2State)) + { + S2 = prefix + Join(word, 0, word.Count) + " " + BuildBlock(lead2Locals) + rest2; + } + else + { + var closer = endState.Snapshot().Where(t => !lead2State.Has(t.Key)).Select(ResetOf); + var lead2Inline = lead2Tags.Where(t => !EventLevelKeys.Contains(t.Key)); + S2 = prefix + Join(word, 0, word.Count) + BuildBlock(closer) + " " + BuildBlock(lead2Inline) + rest2; + } + } + + S2 = AutoBreakIfNeeded(S2); + } + + private void MoveWordUpAssa() + { + var t2 = Tokenize(S2.Trim()); + var firstText = FirstTextIndex(t2); + if (firstText < 0) + { + return; + } + + var wordEnd = t2.FindIndex(firstText, t => t.Kind == TokenKind.Space); + if (wordEnd < 0) + { + wordEnd = t2.Count; + } + + if (SameSubtitle) + { + // Overrides carry across the line break, so just move the break. + S1 = (S1.Trim() + " " + Join(t2, 0, wordEnd).Trim()).Trim(); + S2 = Join(t2, wordEnd, t2.Count).Trim(); + S1 = AutoBreakIfNeeded(S1); + return; + } + + var leadTags = BlockTags(t2, 0, firstText); + var events = leadTags.Where(t => EventLevelKeys.Contains(t.Key)).ToList(); + var locals = leadTags.Where(t => WordLocalKeys.Contains(t.Key)).ToList(); + var word = t2.GetRange(firstText, wordEnd - firstText); + + var startState = Simulate(t2, 0, firstText); + var endState = Simulate(word, 0, word.Count, startState.Flatten()); + + // The word goes at the end of the first line + var t1 = Tokenize(S1.Trim()); + var lastText1 = LastTextIndex(t1); + var toggleClose = endState.Snapshot().Where(t => IsToggle(t.Key)).Select(ResetOf).ToList(); + if (lastText1 < 0) + { + S1 = BuildBlock(startState.Snapshot().Concat(locals)) + JoinForLineEnd(word, false) + BuildBlock(toggleClose); + } + else + { + var hasTrail = lastText1 + 1 < t1.Count; + var beforeTrail = Simulate(t1, 0, lastText1 + 1); + if (hasTrail && !startState.IsEmpty && beforeTrail.SameAs(startState)) + { + // "Hello {\i1}world{\i0}" + "{\i1}today" -> "Hello {\i1}world today{\i0}" + S1 = Join(t1, 0, lastText1 + 1) + " " + BuildBlock(locals) + JoinForLineEnd(word, true) + Join(t1, lastText1 + 1, t1.Count).Trim(); + } + else + { + var diff = Diff(Simulate(t1, 0, t1.Count), startState); + S1 = S1.Trim() + " " + BuildBlock(diff.Concat(locals)) + JoinForLineEnd(word, false) + BuildBlock(toggleClose); + } + } + + S1 = AutoBreakIfNeeded(S1); + + // What stays on the second line + if (!HasText(t2, wordEnd, t2.Count)) + { + S2 = string.Empty; + return; + } + + var restState = Simulate(t2, 0, wordEnd); + S2 = BuildBlock(events.Concat(restState.Snapshot())) + Join(t2, wordEnd, t2.Count).Trim(); + } + } +} diff --git a/src/libse/Forms/SplitLongLinesHelper.cs b/src/libse/Forms/SplitLongLinesHelper.cs index e9dc8c0d63c..e120897c326 100644 --- a/src/libse/Forms/SplitLongLinesHelper.cs +++ b/src/libse/Forms/SplitLongLinesHelper.cs @@ -65,7 +65,7 @@ public static Subtitle SplitLongLinesInSubtitle(Subtitle subtitle, int totalLine text = Utilities.AutoBreakLine(oldParagraph.Text, language, true); } - if (noHtmlLines.Count == 1 && text.SplitToLines().Count <= 2 && noHtmlLines[0].Length > singleLineMaxCharacters && !QualifiesForSplit(text, singleLineMaxCharacters, totalLineMaxCharacters)) + if (noHtmlLines.Count == 1 && text.CountLines() <= 2 && noHtmlLines[0].Length > singleLineMaxCharacters && !QualifiesForSplit(text, singleLineMaxCharacters, totalLineMaxCharacters)) { oldParagraph.Text = text; continue; diff --git a/src/libse/SubtitleFormats/CapMakerPlus.cs b/src/libse/SubtitleFormats/CapMakerPlus.cs index 22747b7d368..62f620a8771 100644 --- a/src/libse/SubtitleFormats/CapMakerPlus.cs +++ b/src/libse/SubtitleFormats/CapMakerPlus.cs @@ -124,13 +124,16 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string } } + // Merge records sharing a start time (one record per line of a cue). Backwards, so a + // run of three or more folds into its first record - forward, the third line was + // merged into the already-deleted second one and lost, and the cue got zero duration. var deletes = new List(); - for (i = 0; i < subtitle.Paragraphs.Count - 1; i++) + for (i = subtitle.Paragraphs.Count - 2; i >= 0; i--) { if (subtitle.Paragraphs[i].StartTime.TotalMilliseconds == subtitle.Paragraphs[i + 1].StartTime.TotalMilliseconds) { subtitle.Paragraphs[i].Text += Environment.NewLine + subtitle.Paragraphs[i + 1].Text; - subtitle.Paragraphs[i].EndTime = subtitle.Paragraphs[i + 1].EndTime; + subtitle.Paragraphs[i].EndTime = new TimeCode(subtitle.Paragraphs[i + 1].EndTime.TotalMilliseconds); deletes.Add(i + 1); } } diff --git a/src/libse/SubtitleFormats/DCinemaInterop.cs b/src/libse/SubtitleFormats/DCinemaInterop.cs index eb65b5821a6..bcf1a552c20 100644 --- a/src/libse/SubtitleFormats/DCinemaInterop.cs +++ b/src/libse/SubtitleFormats/DCinemaInterop.cs @@ -685,9 +685,11 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string node = xml.DocumentElement.SelectSingleNode("Font"); if (node != null) { - if (node.Attributes?["ID"] != null) + // Interop (and this writer) use "Id"; "ID" is the SMPTE spelling. + var fontIdAttribute = node.Attributes?["Id"] ?? node.Attributes?["ID"]; + if (fontIdAttribute != null) { - ss.CurrentDCinemaFontId = node.Attributes["ID"].InnerText; + ss.CurrentDCinemaFontId = fontIdAttribute.InnerText; } if (node.Attributes?["Size"] != null) @@ -1108,6 +1110,10 @@ public static string FixInvalidItalicTags(string input) isItalic = true; } } + else + { + output.Append(tempTag); // font/bold/... tags pass through + } } else if (isSkipping) { diff --git a/src/libse/SubtitleFormats/Ebu.cs b/src/libse/SubtitleFormats/Ebu.cs index 7b9f7109692..f5364460b80 100644 --- a/src/libse/SubtitleFormats/Ebu.cs +++ b/src/libse/SubtitleFormats/Ebu.cs @@ -1386,13 +1386,12 @@ public void LoadSubtitle(Subtitle subtitle, byte[] buffer) Paragraph last = null; byte lastExtensionBlockNumber = 0xff; JustificationCodes = new List(); - Configuration.Settings.General.CurrentFrameRate = header.FrameRate; - if (OverrideReadFrameRate > 20) - { - Configuration.Settings.General.CurrentFrameRate = OverrideReadFrameRate; - } + // Resolve this import once, independently of concurrent video-frame-rate updates. + var overrideReadFrameRate = OverrideReadFrameRate; + var readFrameRate = overrideReadFrameRate > 20 ? overrideReadFrameRate : header.FrameRate; + Configuration.Settings.General.CurrentFrameRate = readFrameRate; - foreach (var tti in ReadTextAndTiming(buffer, header)) + foreach (var tti in ReadTextAndTiming(buffer, header, readFrameRate)) { if (tti.ExtensionBlockNumber != 0xfe) // FEh : Reserved for User Data { @@ -1872,7 +1871,7 @@ private static string GetCharacter(out bool skipNext, EbuGeneralSubtitleInformat /// Read Text and Timing Information (TTI) block. /// Each Text and Timing Information (TTI) block consists of 128 bytes. /// - private IEnumerable ReadTextAndTiming(byte[] buffer, EbuGeneralSubtitleInformation header) + private IEnumerable ReadTextAndTiming(byte[] buffer, EbuGeneralSubtitleInformation header, double readFrameRate) { const int startOfTextAndTimingBlock = 1024; const int ttiSize = 128; @@ -1901,11 +1900,11 @@ private IEnumerable ReadTextAndTiming(byte[] buffer, E TimeCodeInHours = buffer[index + 5 + 0], TimeCodeInMinutes = buffer[index + 5 + 1], TimeCodeInSeconds = buffer[index + 5 + 2], - TimeCodeInMilliseconds = FramesToMillisecondsMax999(buffer[index + 5 + 3]), + TimeCodeInMilliseconds = Math.Min(FramesToMilliseconds(buffer[index + 5 + 3], readFrameRate), 999), TimeCodeOutHours = buffer[index + 9 + 0], TimeCodeOutMinutes = buffer[index + 9 + 1], TimeCodeOutSeconds = buffer[index + 9 + 2], - TimeCodeOutMilliseconds = FramesToMillisecondsMax999(buffer[index + 9 + 3]), + TimeCodeOutMilliseconds = Math.Min(FramesToMilliseconds(buffer[index + 9 + 3], readFrameRate), 999), VerticalPosition = buffer[index + 13], JustificationCode = buffer[index + 14], CommentFlag = buffer[index + 15] diff --git a/src/libse/SubtitleFormats/FinalCutProTest2Xml.cs b/src/libse/SubtitleFormats/FinalCutProTest2Xml.cs index 1664f4779c4..09f269141fd 100644 --- a/src/libse/SubtitleFormats/FinalCutProTest2Xml.cs +++ b/src/libse/SubtitleFormats/FinalCutProTest2Xml.cs @@ -12,6 +12,8 @@ public class FinalCutProTest2Xml : SubtitleFormat { public override string Extension => ".xml"; + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Test2 Xml"; public static string GetFrameRateAsString() @@ -69,7 +71,7 @@ public override string ToText(Subtitle subtitle, string title) int duration = 0; if (subtitle.Paragraphs.Count > 0) { - duration = (int)Math.Round(subtitle.Paragraphs[subtitle.Paragraphs.Count - 1].EndTime.TotalSeconds * Configuration.Settings.General.CurrentFrameRate); + duration = (int)Math.Round(subtitle.Paragraphs[subtitle.Paragraphs.Count - 1].EndTime.TotalSeconds * ToTimeCodeFrameRate(Configuration.Settings.General.CurrentFrameRate)); } string seString = "Subtitle Edit at " + DateTime.Now.ToShortDateString() + " " + DateTime.Now.ToShortTimeString(); @@ -130,7 +132,7 @@ public override string ToText(Subtitle subtitle, string title) generatorItem.InnerXml = xmlTrackStructure.Replace("[NUMBER]", number.ToString()).Replace("[FONTSTYLE]", fontStyle).Replace("[FONTSIZE]", Configuration.Settings.SubtitleSettings.FcpFontSize.ToString(CultureInfo.InvariantCulture)); - double frameRate = Configuration.Settings.General.CurrentFrameRate; + double frameRate = ToTimeCodeFrameRate(Configuration.Settings.General.CurrentFrameRate); XmlNode start = generatorItem.SelectSingleNode("generatoritem/start"); start.InnerText = ((int)Math.Round(p.StartTime.TotalSeconds * frameRate)).ToString(); @@ -317,7 +319,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string text = "" + text + ""; } - subtitle.Paragraphs.Add(new Paragraph(text, Convert.ToDouble((startFrame / frameRate) * 1000, CultureInfo.InvariantCulture), Convert.ToDouble((endFrame / frameRate) * 1000, CultureInfo.InvariantCulture))); + subtitle.Paragraphs.Add(new Paragraph(text, Convert.ToDouble((startFrame / ToTimeCodeFrameRate(frameRate)) * 1000, CultureInfo.InvariantCulture), Convert.ToDouble((endFrame / ToTimeCodeFrameRate(frameRate)) * 1000, CultureInfo.InvariantCulture))); } } } diff --git a/src/libse/SubtitleFormats/FinalCutProTextXml.cs b/src/libse/SubtitleFormats/FinalCutProTextXml.cs index 78ad3b1db37..623d787d61b 100644 --- a/src/libse/SubtitleFormats/FinalCutProTextXml.cs +++ b/src/libse/SubtitleFormats/FinalCutProTextXml.cs @@ -12,6 +12,8 @@ public class FinalCutProTestXml : SubtitleFormat { public override string Extension => ".xml"; + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Test Xml"; public static string GetFrameRateAsString() @@ -82,7 +84,7 @@ public override string ToText(Subtitle subtitle, string title) int duration = 0; if (subtitle.Paragraphs.Count > 0) { - duration = (int)Math.Round(subtitle.Paragraphs[subtitle.Paragraphs.Count - 1].EndTime.TotalSeconds * Configuration.Settings.General.CurrentFrameRate); + duration = (int)Math.Round(subtitle.Paragraphs[subtitle.Paragraphs.Count - 1].EndTime.TotalSeconds * ToTimeCodeFrameRate(Configuration.Settings.General.CurrentFrameRate)); } string xmlStructure = @@ -210,10 +212,14 @@ public override string ToText(Subtitle subtitle, string title) { fontStyle = "3"; //3==italic } + else if (s.StartsWith("") && s.EndsWith("")) + { + fontStyle = "2"; //2==bold + } generatorItem.InnerXml = xmlTrackStructure.Replace("[NUMBER]", number.ToString()).Replace("[FONTSTYLE]", fontStyle); - double frameRate = Configuration.Settings.General.CurrentFrameRate; + double frameRate = ToTimeCodeFrameRate(Configuration.Settings.General.CurrentFrameRate); XmlNode start = generatorItem.SelectSingleNode("generatoritem/start"); start.InnerText = ((int)Math.Round(p.StartTime.TotalSeconds * frameRate)).ToString(); @@ -385,7 +391,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string text = "" + text + ""; } - subtitle.Paragraphs.Add(new Paragraph(text, Convert.ToDouble((startFrame / frameRate) * 1000, CultureInfo.InvariantCulture), Convert.ToDouble((endFrame / frameRate) * 1000, CultureInfo.InvariantCulture))); + subtitle.Paragraphs.Add(new Paragraph(text, Convert.ToDouble((startFrame / ToTimeCodeFrameRate(frameRate)) * 1000, CultureInfo.InvariantCulture), Convert.ToDouble((endFrame / ToTimeCodeFrameRate(frameRate)) * 1000, CultureInfo.InvariantCulture))); } } } diff --git a/src/libse/SubtitleFormats/FinalCutProXCM.cs b/src/libse/SubtitleFormats/FinalCutProXCM.cs index 5328ef35fd7..470cda0f693 100644 --- a/src/libse/SubtitleFormats/FinalCutProXCM.cs +++ b/src/libse/SubtitleFormats/FinalCutProXCM.cs @@ -13,10 +13,14 @@ public class FinalCutProXCM : SubtitleFormat public override string Extension => ".fcpxml"; + + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro X Chapter Marker"; public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); if (Configuration.Settings.General.CurrentFrameRate > 26) { FrameRate = 30; @@ -96,6 +100,12 @@ public override bool IsMine(List lines, string fileName) } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; FrameRate = Configuration.Settings.General.CurrentFrameRate; diff --git a/src/libse/SubtitleFormats/FinalCutProXXml.cs b/src/libse/SubtitleFormats/FinalCutProXXml.cs index 834d23e76ef..eab3a28da2c 100644 --- a/src/libse/SubtitleFormats/FinalCutProXXml.cs +++ b/src/libse/SubtitleFormats/FinalCutProXXml.cs @@ -13,10 +13,14 @@ public class FinalCutProXXml : SubtitleFormat public override string Extension => ".fcpxml"; + + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro X Xml"; public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); if (Configuration.Settings.General.CurrentFrameRate > 26) { FrameRate = 30; @@ -120,6 +124,12 @@ public override bool IsMine(List lines, string fileName) } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; FrameRate = Configuration.Settings.General.CurrentFrameRate; diff --git a/src/libse/SubtitleFormats/FinalCutProXml.cs b/src/libse/SubtitleFormats/FinalCutProXml.cs index 28964a9768b..82282ae51ca 100644 --- a/src/libse/SubtitleFormats/FinalCutProXml.cs +++ b/src/libse/SubtitleFormats/FinalCutProXml.cs @@ -12,6 +12,8 @@ public class FinalCutProXml : SubtitleFormat { public override string Extension => ".xml"; + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Xml"; public static string GetFrameRateAsString() @@ -69,7 +71,7 @@ public override string ToText(Subtitle subtitle, string title) int duration = 0; if (subtitle.Paragraphs.Count > 0) { - duration = (int)Math.Round(subtitle.Paragraphs[subtitle.Paragraphs.Count - 1].EndTime.TotalSeconds * Configuration.Settings.General.CurrentFrameRate); + duration = (int)Math.Round(subtitle.Paragraphs[subtitle.Paragraphs.Count - 1].EndTime.TotalSeconds * ToTimeCodeFrameRate(Configuration.Settings.General.CurrentFrameRate)); } string xmlStructure = @@ -440,7 +442,7 @@ public override string ToText(Subtitle subtitle, string title) var generatorItem = template.CloneNode(true); generatorItem.Attributes["id"].Value = "Outline Text" + number.ToString(CultureInfo.InvariantCulture); - double frameRate = Configuration.Settings.General.CurrentFrameRate; + double frameRate = ToTimeCodeFrameRate(Configuration.Settings.General.CurrentFrameRate); XmlNode start = generatorItem.SelectSingleNode("start"); start.InnerText = ((int)Math.Round(p.StartTime.TotalSeconds * frameRate)).ToString(); @@ -674,7 +676,7 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string text = "" + text + ""; } - subtitle.Paragraphs.Add(new Paragraph(text, Convert.ToDouble((startFrame / frameRate) * 1000, CultureInfo.InvariantCulture), Convert.ToDouble((endFrame / frameRate) * 1000, CultureInfo.InvariantCulture))); + subtitle.Paragraphs.Add(new Paragraph(text, Convert.ToDouble((startFrame / ToTimeCodeFrameRate(frameRate)) * 1000, CultureInfo.InvariantCulture), Convert.ToDouble((endFrame / ToTimeCodeFrameRate(frameRate)) * 1000, CultureInfo.InvariantCulture))); } } } diff --git a/src/libse/SubtitleFormats/FinalCutProXml13.cs b/src/libse/SubtitleFormats/FinalCutProXml13.cs index 38ef8d0087c..66c004d144f 100644 --- a/src/libse/SubtitleFormats/FinalCutProXml13.cs +++ b/src/libse/SubtitleFormats/FinalCutProXml13.cs @@ -13,10 +13,14 @@ public class FinalCutProXml13 : SubtitleFormat public override string Extension => ".fcpxml"; + + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Xml 1.3"; public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); if (Configuration.Settings.General.CurrentFrameRate > 26) { FrameRate = 30; @@ -106,6 +110,12 @@ public override string ToText(Subtitle subtitle, string title) } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; FrameRate = Configuration.Settings.General.CurrentFrameRate; diff --git a/src/libse/SubtitleFormats/FinalCutProXml14.cs b/src/libse/SubtitleFormats/FinalCutProXml14.cs index 559c16fdbce..d101766e2a3 100644 --- a/src/libse/SubtitleFormats/FinalCutProXml14.cs +++ b/src/libse/SubtitleFormats/FinalCutProXml14.cs @@ -13,10 +13,14 @@ public class FinalCutProXml14 : SubtitleFormat public override string Extension => ".fcpxml"; + + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Xml 1.4"; public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); if (Configuration.Settings.General.CurrentFrameRate > 26) { FrameRate = 30; @@ -94,6 +98,12 @@ public override string ToText(Subtitle subtitle, string title) } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; FrameRate = Configuration.Settings.General.CurrentFrameRate; diff --git a/src/libse/SubtitleFormats/FinalCutProXml14Text.cs b/src/libse/SubtitleFormats/FinalCutProXml14Text.cs index 1314591d00c..e5feef8cc03 100644 --- a/src/libse/SubtitleFormats/FinalCutProXml14Text.cs +++ b/src/libse/SubtitleFormats/FinalCutProXml14Text.cs @@ -13,10 +13,14 @@ public class FinalCutProXml14Text : SubtitleFormat public override string Extension => ".fcpxml"; + + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Xml 1.4 Text"; public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); if (Configuration.Settings.General.CurrentFrameRate > 26) { FrameRate = 30; @@ -128,6 +132,12 @@ private static bool IsNearleWholeNumber(double number) } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; FrameRate = Configuration.Settings.General.CurrentFrameRate; diff --git a/src/libse/SubtitleFormats/FinalCutProXml15.cs b/src/libse/SubtitleFormats/FinalCutProXml15.cs index ebe85fb11fa..f9a1a29ec41 100644 --- a/src/libse/SubtitleFormats/FinalCutProXml15.cs +++ b/src/libse/SubtitleFormats/FinalCutProXml15.cs @@ -63,6 +63,9 @@ public FinalCutProXml15() public override string Extension => ".fcpxml"; + + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Xml " + FcpXmlVersion; internal static string GetFrameDuration() @@ -150,6 +153,7 @@ internal static string GetNdfDf() public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); string xmlStructure = "" + Environment.NewLine + "" + Environment.NewLine + @@ -442,6 +446,12 @@ private static string ToColorString(SKColor fontColor) } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; FrameRate = Configuration.Settings.General.CurrentFrameRate; diff --git a/src/libse/SubtitleFormats/FinalCutProXmlCaptions.cs b/src/libse/SubtitleFormats/FinalCutProXmlCaptions.cs index 79cd9a2a268..1ee635812c3 100644 --- a/src/libse/SubtitleFormats/FinalCutProXmlCaptions.cs +++ b/src/libse/SubtitleFormats/FinalCutProXmlCaptions.cs @@ -17,10 +17,13 @@ public class FinalCutProXmlCaptions : SubtitleFormat { public override string Extension => ".fcpxml"; + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Xml Captions"; public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); var frameDuration = FinalCutProXml15.GetFrameDuration(); // e.g. "1001/24000s" var arr = frameDuration.TrimEnd('s').Split('/'); var frameNumerator = long.Parse(arr[0]); @@ -295,6 +298,12 @@ private static string GetFcpFormatName() } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; var x = JoinLines(lines); diff --git a/src/libse/SubtitleFormats/FinalCutProXmlGap.cs b/src/libse/SubtitleFormats/FinalCutProXmlGap.cs index 4270b7d1406..ba55adbad87 100644 --- a/src/libse/SubtitleFormats/FinalCutProXmlGap.cs +++ b/src/libse/SubtitleFormats/FinalCutProXmlGap.cs @@ -13,10 +13,14 @@ public class FinalCutProXmlGap : SubtitleFormat public override string Extension => ".fcpxml"; + + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Xml Gap"; public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); if (Configuration.Settings.General.CurrentFrameRate > 26) { FrameRate = 30; @@ -92,6 +96,12 @@ public override bool IsMine(List lines, string fileName) } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; FrameRate = Configuration.Settings.General.CurrentFrameRate; diff --git a/src/libse/SubtitleFormats/FinalCutProXmlName.cs b/src/libse/SubtitleFormats/FinalCutProXmlName.cs index 02c41b6f408..29af024794f 100644 --- a/src/libse/SubtitleFormats/FinalCutProXmlName.cs +++ b/src/libse/SubtitleFormats/FinalCutProXmlName.cs @@ -31,11 +31,15 @@ public FinalCutProXmlName() public override string Extension => ".fcpxml"; + + public override bool StoresFrameNumbers => true; + public override string Name => "Final Cut Pro Xml Name"; public override string ToText(Subtitle subtitle, string title) { + subtitle = ToMediaTime(subtitle); string xmlStructure = "" + Environment.NewLine + "" + Environment.NewLine + @@ -112,6 +116,12 @@ public override bool IsMine(List lines, string fileName) } public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + { + LoadSubtitleMediaTime(subtitle, lines, fileName); + FromMediaTime(subtitle); + } + + private void LoadSubtitleMediaTime(Subtitle subtitle, List lines, string fileName) { _errorCount = 0; FrameRate = Configuration.Settings.General.CurrentFrameRate; diff --git a/src/libse/SubtitleFormats/ItunesTimedText.cs b/src/libse/SubtitleFormats/ItunesTimedText.cs index fab41123525..b6f3dbc4434 100644 --- a/src/libse/SubtitleFormats/ItunesTimedText.cs +++ b/src/libse/SubtitleFormats/ItunesTimedText.cs @@ -298,8 +298,13 @@ public override string ToText(Subtitle subtitle, string title) paragraph.Attributes.Append(styleP); } + // Formatting tags become nested spans, so text is written as + // text - flat sibling spans lost the italic (#15846). + // Tags still open at the end of a line are closed before the
and reopened after it. + var openTags = new List(); var first = true; - var italicOn = false; + paragraph.AppendChild(xml.CreateTextNode(string.Empty)); // mixed content - stops the writer from indenting the spans + foreach (var line in text.SplitToLines()) { if (!first) @@ -308,12 +313,18 @@ public override string ToText(Subtitle subtitle, string title) paragraph.AppendChild(br); } - var styles = new Stack(); + // parents[n] is the container that was current before openTags[n] was opened + var parents = new List(); + XmlNode container = paragraph; + foreach (var tag in openTags) + { + parents.Add(container); + container = OpenSpan(xml, container, tag); + } + // Text is collected here and written to the node once per run - "InnerText += c" // per character re-read and re-set the node text for every character of the line. var pending = new StringBuilder(); - XmlNode currentStyle = xml.CreateTextNode(string.Empty); - paragraph.AppendChild(currentStyle); var skipCount = 0; for (var i = 0; i < line.Length; i++) { @@ -325,28 +336,28 @@ public override string ToText(Subtitle subtitle, string title) if (line[i] == '<' && pending.Length > 0) { - currentStyle.InnerText += pending.ToString(); + container.AppendChild(xml.CreateTextNode(pending.ToString())); pending.Clear(); } + XmlAttribute newTag = null; + string closeTagName = null; if (line.StartsWithAt(i, "", StringComparison.OrdinalIgnoreCase)) { - styles.Push(currentStyle); - currentStyle = xml.CreateNode(XmlNodeType.Element, "span", null); - paragraph.AppendChild(currentStyle); - var attr = CreateParagraphStyleAttribute(xml); - attr.InnerText = "italic"; - currentStyle.Attributes.Append(attr); + newTag = CreateParagraphStyleAttribute(xml); + newTag.InnerText = "italic"; skipCount = 2; - italicOn = true; } else if (line.StartsWithAt(i, "", StringComparison.OrdinalIgnoreCase)) { - currentStyle = xml.CreateNode(XmlNodeType.Element, "span", null); - paragraph.AppendChild(currentStyle); - var attr = xml.CreateAttribute("tts:fontWeight", "http://www.w3.org/ns/ttml#styling"); - attr.InnerText = "bold"; - currentStyle.Attributes.Append(attr); + newTag = xml.CreateAttribute("tts:fontWeight", "http://www.w3.org/ns/ttml#styling"); + newTag.InnerText = "bold"; + skipCount = 2; + } + else if (line.StartsWithAt(i, "", StringComparison.OrdinalIgnoreCase)) + { + newTag = xml.CreateAttribute("tts:textDecoration", "http://www.w3.org/ns/ttml#styling"); + newTag.InnerText = "underline"; skipCount = 2; } else if (line.StartsWithAt(i, " without color still gets an entry, so its closes the right tag + newTag = newTag ?? xml.CreateAttribute(FontWithoutColor); } else { skipCount = line.Length; } } - else if (line.StartsWithAt(i, "", StringComparison.OrdinalIgnoreCase) || line.StartsWithAt(i, "", StringComparison.OrdinalIgnoreCase) || line.StartsWithAt(i, "", StringComparison.OrdinalIgnoreCase)) + else if (line.StartsWithAt(i, "", StringComparison.OrdinalIgnoreCase)) { - currentStyle = xml.CreateTextNode(string.Empty); - if (styles.Count > 0) - { - currentStyle = styles.Pop().CloneNode(true); - currentStyle.InnerText = string.Empty; - } - paragraph.AppendChild(currentStyle); - if (line.StartsWithAt(i, "", StringComparison.OrdinalIgnoreCase)) - { - skipCount = 6; - } - else - { - skipCount = 3; - } - - italicOn = false; + closeTagName = "i"; + skipCount = 3; + } + else if (line.StartsWithAt(i, "", StringComparison.OrdinalIgnoreCase)) + { + closeTagName = "b"; + skipCount = 3; + } + else if (line.StartsWithAt(i, "", StringComparison.OrdinalIgnoreCase)) + { + closeTagName = "u"; + skipCount = 3; + } + else if (line.StartsWithAt(i, "", StringComparison.OrdinalIgnoreCase)) + { + closeTagName = "font"; + skipCount = 6; } else { - if (i == 0 && italicOn && !line.StartsWithAt(i, "", StringComparison.OrdinalIgnoreCase)) + pending.Append(line[i]); + } + + if (newTag != null) + { + openTags.Add(newTag); + parents.Add(container); + container = OpenSpan(xml, container, newTag); + } + else if (closeTagName != null) + { + var idx = openTags.FindLastIndex(t => GetTagName(t) == closeTagName); + if (idx >= 0) { - styles.Push(currentStyle); - currentStyle = xml.CreateNode(XmlNodeType.Element, "span", null); - paragraph.AppendChild(currentStyle); - var attr = xml.CreateAttribute("tts:fontStyle", "http://www.w3.org/ns/ttml#styling"); - attr.InnerText = "italic"; - currentStyle.Attributes.Append(attr); + // close the tag and everything opened inside it, then reopen the inner ones + container = parents[idx]; + openTags.RemoveAt(idx); + parents.RemoveRange(idx, parents.Count - idx); + for (var j = idx; j < openTags.Count; j++) + { + parents.Add(container); + container = OpenSpan(xml, container, openTags[j]); + } } - pending.Append(line[i]); } } + if (pending.Length > 0) { - currentStyle.InnerText += pending.ToString(); + container.AppendChild(xml.CreateTextNode(pending.ToString())); } first = false; } + RemoveEmptySpans(paragraph); + var start = xml.CreateAttribute("begin"); start.InnerText = ConvertToTimeString(p.StartTime, Configuration.Settings.SubtitleSettings.TimedTextItunesTimeCodeFormat); paragraph.Attributes.Append(start); @@ -438,6 +465,56 @@ public override string ToText(Subtitle subtitle, string title) return xmlString; } + private const string FontWithoutColor = "font"; + + private static XmlNode OpenSpan(XmlDocument xml, XmlNode container, XmlAttribute tag) + { + if (tag.Name == FontWithoutColor) + { + return container; + } + + var span = xml.CreateNode(XmlNodeType.Element, "span", null); + span.Attributes.Append((XmlAttribute)tag.CloneNode(true)); + container.AppendChild(span); + return span; + } + + private static string GetTagName(XmlAttribute tag) + { + if (tag.Name == "tts:fontWeight") + { + return "b"; + } + + if (tag.Name == "tts:textDecoration") + { + return "u"; + } + + if (tag.Name == "tts:color" || tag.Name == FontWithoutColor) + { + return "font"; + } + + return "i"; + } + + private static void RemoveEmptySpans(XmlNode node) + { + foreach (var child in node.ChildNodes.Cast().ToList()) + { + if (child.Name == "span") + { + RemoveEmptySpans(child); + if (!child.HasChildNodes) + { + node.RemoveChild(child); + } + } + } + } + private static XmlAttribute CreateParagraphStyleAttribute(XmlDocument xml) { if (string.IsNullOrEmpty(Configuration.Settings.SubtitleSettings.TimedTextItunesStyleAttribute) || diff --git a/src/libse/SubtitleFormats/MsOfficeWorkbook.cs b/src/libse/SubtitleFormats/MsOfficeWorkbook.cs index 5c5cb367d48..c12ef68a245 100644 --- a/src/libse/SubtitleFormats/MsOfficeWorkbook.cs +++ b/src/libse/SubtitleFormats/MsOfficeWorkbook.cs @@ -122,7 +122,8 @@ private static XmlNode MakeParagraph(XmlDocument xml, Paragraph p) MakeCell(xml, row, HtmlUtil.RemoveHtmlTags(p.Text.Replace(Environment.NewLine, "\n"), true)); MakeCell(xml, row, string.Empty); MakeCell(xml, row, string.Empty); - MakeCell(xml, row, "0"); + MakeCell(xml, row, "0"); // Good + MakeCell(xml, row, "0"); // Marked MakeAlignmentCell(xml, p, row); MakeCell(xml, row, "None"); MakeCell(xml, row, p.Actor); @@ -184,13 +185,12 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string var start = cells[0].SelectSingleNode("ss:Data", xmlNamespaceManager)?.InnerText; var end = cells[1].SelectSingleNode("ss:Data", xmlNamespaceManager)?.InnerText; var text = cells[2].SelectSingleNode("ss:Data", xmlNamespaceManager)?.InnerText; - var alignment = cells[7].SelectSingleNode("ss:Data", xmlNamespaceManager)?.InnerText; - - // The writer puts the actor in the ninth column ("Actors" in the header row), - // but the reader never read it back, so the actor was lost on every load. - var actor = cells.Count > 8 - ? cells[8].SelectSingleNode("ss:Data", xmlNamespaceManager)?.InnerText - : null; + // Columns per the header row: Start, End, Text, Description, Comment, Good, Marked, + // Position, Line Shift, Actors. Older SE files wrote 9 cells (no Marked), so + // Position was the 7th cell and the actor the 9th. + var tenColumns = cells.Count >= 10; + var alignment = cells[tenColumns ? 7 : 6].SelectSingleNode("ss:Data", xmlNamespaceManager)?.InnerText; + var actor = cells[tenColumns ? 9 : 8].SelectSingleNode("ss:Data", xmlNamespaceManager)?.InnerText; if (string.IsNullOrEmpty(start) || !char.IsDigit(start.TrimStart()[0])) { diff --git a/src/libse/SubtitleFormats/ProjectionSubtitleList.cs b/src/libse/SubtitleFormats/ProjectionSubtitleList.cs index a8db151825d..2ced4e6eed1 100644 --- a/src/libse/SubtitleFormats/ProjectionSubtitleList.cs +++ b/src/libse/SubtitleFormats/ProjectionSubtitleList.cs @@ -20,9 +20,19 @@ public override string ToText(Subtitle subtitle, string title) sb.Append($"{GetTimeCodes(p, index)}{{\\uc0\\pard\\qc "); var text = Utilities.RemoveSsaTags(p.Text); text = HtmlUtil.RemoveOpenCloseTags(text, HtmlUtil.TagBold, HtmlUtil.TagUnderline, HtmlUtil.TagFont); + // Italic is a group per line: \par ends it on load, so an italic run spanning + // lines is closed before each \par and reopened on the next line. + var italicOpen = false; foreach (var line in text.SplitToLines()) { - sb.Append(RftEncode(line)); + var l = italicOpen ? "" + line : line; + italicOpen = l.LastIndexOf("", StringComparison.Ordinal) > l.LastIndexOf("", StringComparison.Ordinal); + if (italicOpen) + { + l += ""; + } + + sb.Append(RftEncode(l)); sb.Append("\\par "); } @@ -218,6 +228,7 @@ private static string RtfDecode(string text) else { sb.Append(ch); + ch = '\0'; // an escaped "\\" must not start a control word } } else if (ch == '\\') @@ -226,8 +237,13 @@ private static string RtfDecode(string text) } else if (ch == '{' || ch == '}') { - // start/end of block + // start/end of block - "}" also closes an italic group ("{\\i ... }") codeOn = false; + if (ch == '}' && italicOn) + { + sb.Append(""); + italicOn = false; + } } else if (!"\r\n".Contains(ch)) { @@ -240,7 +256,9 @@ private static string RtfDecode(string text) { sb.Append(""); } - return Utilities.RemoveUnneededSpaces(sb.ToString(), string.Empty); + // Italic is written per line, so a multi-line italic run reads back as one per line. + var result = Utilities.RemoveUnneededSpaces(sb.ToString(), string.Empty); + return result.Replace("" + Environment.NewLine + "", Environment.NewLine); } } } diff --git a/src/libse/SubtitleFormats/SubStationAlpha.cs b/src/libse/SubtitleFormats/SubStationAlpha.cs index 95260d468db..7dd1d6955d6 100644 --- a/src/libse/SubtitleFormats/SubStationAlpha.cs +++ b/src/libse/SubtitleFormats/SubStationAlpha.cs @@ -442,7 +442,10 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string graphicsStarted = false; } - if (!eventsStarted) + // [Fonts]/[Graphics] payload belongs in the footer (like ASSA), not the header. + if (!eventsStarted && !fontsStarted && !graphicsStarted && + !line.Trim().Equals("[fonts]", StringComparison.OrdinalIgnoreCase) && + !line.Trim().Equals("[graphics]", StringComparison.OrdinalIgnoreCase)) { header.AppendLine(line); } @@ -473,6 +476,10 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string footer.AppendLine(); footer.AppendLine("[Graphics]"); } + else if (fontsStarted || graphicsStarted) + { + footer.AppendLine(line); + } else if (eventsStarted && !string.IsNullOrWhiteSpace(line)) { var s = line.Trim().ToLowerInvariant(); @@ -520,14 +527,6 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string } } } - else if (fontsStarted) - { - footer.AppendLine(line); - } - else if (graphicsStarted) - { - footer.AppendLine(line); - } else if (!string.IsNullOrEmpty(s)) { var text = string.Empty; diff --git a/src/libse/SubtitleFormats/SubtitleFormat.cs b/src/libse/SubtitleFormats/SubtitleFormat.cs index 825d8e4de58..9b20ce07e85 100644 --- a/src/libse/SubtitleFormats/SubtitleFormat.cs +++ b/src/libse/SubtitleFormats/SubtitleFormat.cs @@ -535,6 +535,13 @@ public abstract string Name public bool IsFrameBased => !IsTimeBased; + /// + /// True when the file pins lines to video frames - every frame-based format, plus time based ones + /// that write frame counts (FCP 7 XML) or frame-aligned media time (FCP X). Toggling SMPTE timing + /// keeps these lines on the same frame (#15753). + /// + public virtual bool StoresFrameNumbers => IsFrameBased; + private string _friendlyName; private string _friendlyNameExtension; @@ -597,7 +604,81 @@ public static int MillisecondsToFrames(double milliseconds) public static int MillisecondsToFrames(double milliseconds, double frameRate) { - return (int)Math.Round(milliseconds / (TimeCode.BaseUnit / GetFrameForCalculation(frameRate)), MidpointRounding.AwayFromZero); + return (int)Math.Round(milliseconds / (TimeCode.BaseUnit / GetTimeCodeFrameRate(frameRate)), MidpointRounding.AwayFromZero); + } + + /// + /// True when SMPTE timing is on and is 23.976, 29.97 or 59.94. + /// The subtitle's times are then on the non-drop-frame time code clock - 24/30/60 frames per + /// time code second, with the video stretched by 1.001 to match - so frame 86400 at 23.976 is + /// 01:00:00:00, i.e. 3600000 ms, not 3603600 ms (#15753). + /// + public static bool IsSmpteTimeCodeClock(double frameRate) + { + return Configuration.Settings.General.CurrentVideoIsSmpte && + (Math.Abs(frameRate - 23.976) < 0.001 || + Math.Abs(frameRate - 29.97) < 0.001 || + Math.Abs(frameRate - 59.94) < 0.001); + } + + /// + /// The frame rate subtitle times and frame numbers convert with: the whole-number time code + /// rate in SMPTE timing (see ), otherwise the exact + /// NTSC rate from . + /// + public static double GetTimeCodeFrameRate(double frameRate) + { + return IsSmpteTimeCodeClock(frameRate) ? Math.Round(frameRate) : GetFrameForCalculation(frameRate); + } + + /// + /// For formats that count frames with their own math (FCP 7 XML): the whole-number rate in + /// SMPTE timing (see ), otherwise + /// unchanged. + /// + public static double ToTimeCodeFrameRate(double frameRate) + { + return IsSmpteTimeCodeClock(frameRate) ? Math.Round(frameRate) : frameRate; + } + + /// + /// Final Cut Pro X stores real media time (a 23.976 frame is 1001/24000 s), but in SMPTE + /// timing the subtitle's times are on the time code clock, 1.001 faster (see + /// ). Returns a copy on media time to write, or + /// itself when SMPTE timing is off. + /// + protected static Subtitle ToMediaTime(Subtitle subtitle) + { + if (!IsSmpteTimeCodeClock(Configuration.Settings.General.CurrentFrameRate)) + { + return subtitle; + } + + var copy = new Subtitle(subtitle, false); + foreach (var p in copy.Paragraphs) + { + p.StartTime.TotalMilliseconds *= 1.001; + p.EndTime.TotalMilliseconds *= 1.001; + } + + return copy; + } + + /// + /// The reverse of for a subtitle just read from media time. + /// + protected static void FromMediaTime(Subtitle subtitle) + { + if (!IsSmpteTimeCodeClock(Configuration.Settings.General.CurrentFrameRate)) + { + return; + } + + foreach (var p in subtitle.Paragraphs) + { + p.StartTime.TotalMilliseconds /= 1.001; + p.EndTime.TotalMilliseconds /= 1.001; + } } public static double GetFrameForCalculation(double frameRate) @@ -620,7 +701,7 @@ public static double GetFrameForCalculation(double frameRate) public static int MillisecondsToFramesMaxFrameRate(double milliseconds) { - var frames = (int)Math.Round(milliseconds / (TimeCode.BaseUnit / GetFrameForCalculation(Configuration.Settings.General.CurrentFrameRate)), MidpointRounding.AwayFromZero); + var frames = (int)Math.Round(milliseconds / (TimeCode.BaseUnit / GetTimeCodeFrameRate(Configuration.Settings.General.CurrentFrameRate)), MidpointRounding.AwayFromZero); if (frames >= Configuration.Settings.General.CurrentFrameRate) { frames = (int)(Configuration.Settings.General.CurrentFrameRate - 0.01); @@ -636,12 +717,12 @@ public static int FramesToMilliseconds(double frames) public static int FramesToMilliseconds(double frames, double frameRate) { - return (int)Math.Round(frames * (TimeCode.BaseUnit / GetFrameForCalculation(frameRate)), MidpointRounding.AwayFromZero); + return (int)Math.Round(frames * (TimeCode.BaseUnit / GetTimeCodeFrameRate(frameRate)), MidpointRounding.AwayFromZero); } public static int FramesToMillisecondsMax999(double frames) { - var ms = (int)Math.Round(frames * (TimeCode.BaseUnit / GetFrameForCalculation(Configuration.Settings.General.CurrentFrameRate)), MidpointRounding.AwayFromZero); + var ms = (int)Math.Round(frames * (TimeCode.BaseUnit / GetTimeCodeFrameRate(Configuration.Settings.General.CurrentFrameRate)), MidpointRounding.AwayFromZero); return Math.Min(ms, 999); } diff --git a/src/libse/SubtitleFormats/TimedText10.cs b/src/libse/SubtitleFormats/TimedText10.cs index 090b2153e0a..b4ab890a14b 100644 --- a/src/libse/SubtitleFormats/TimedText10.cs +++ b/src/libse/SubtitleFormats/TimedText10.cs @@ -857,7 +857,8 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string var arr = frameRateMultiplier.InnerText.Split(); if (arr.Length == 2 && Utilities.IsInteger(arr[0]) && Utilities.IsInteger(arr[1]) && int.Parse(arr[1]) > 0) { - fr = double.Parse(arr[0], CultureInfo.InvariantCulture) / double.Parse(arr[1], CultureInfo.InvariantCulture); + // The multiplier scales ttp:frameRate (60 * 1000/1001 = 59.94); it is not a rate itself. + fr = fr * double.Parse(arr[0], CultureInfo.InvariantCulture) / double.Parse(arr[1], CultureInfo.InvariantCulture); if (fr > 20 && fr < 100) { Configuration.Settings.General.CurrentFrameRate = fr; @@ -1200,7 +1201,149 @@ public static string SetExtra(Paragraph p) return $"{style} / {lang}"; } + /// + /// Text styling in effect at a node - only changes against the inherited styling become tags, + /// so a span using a style with the default color/font does not get a redundant font tag. + /// + private sealed class TtmlTextStyle + { + public bool Italic; + public bool Bold; + public bool Underline; + public string FontFamily; + public string Color; + + public TtmlTextStyle Clone() => (TtmlTextStyle)MemberwiseClone(); + } + + private static void ApplyTtmlTextStyle(TtmlTextStyle style, XmlNode node, List styles, TtmlHeadIndex headIndex) + { + if (node?.Attributes == null) + { + return; + } + + var styleAttribute = node.Attributes["style"]; + if (styleAttribute != null && headIndex.HasHead) + { + foreach (var styleName in styleAttribute.Value.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (!styles.Contains(styleName)) + { + continue; + } + + // Indexed once per load - this used to run a document-wide XPath per span. + foreach (var styleNode in headIndex.GetStyles(styleName)) + { + ApplyTtmlTextStyleAttributes(style, styleNode); + } + } + } + + ApplyTtmlTextStyleAttributes(style, node); + } + + private static void ApplyTtmlTextStyleAttributes(TtmlTextStyle style, XmlNode node) + { + var fontStyle = node.Attributes["tts:fontStyle"]; + if (fontStyle != null) + { + style.Italic = fontStyle.Value == "italic" || fontStyle.Value == "oblique"; + } + + var fontWeight = node.Attributes["tts:fontWeight"]; + if (fontWeight != null) + { + style.Bold = fontWeight.Value == "bold"; + } + + var textDecoration = node.Attributes["tts:textDecoration"]; + if (textDecoration != null) + { + style.Underline = textDecoration.Value == "underline"; + } + + var fontFamily = node.Attributes["tts:fontFamily"]; + if (fontFamily != null) + { + style.FontFamily = fontFamily.Value; + } + + var color = node.Attributes["tts:color"]; + if (color != null) + { + style.Color = color.Value; + } + } + + private static readonly HashSet TtmlGenericFontFamilies = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "default", "monospace", "sansSerif", "serif", "monospaceSansSerif", "monospaceSerif", "proportionalSansSerif", "proportionalSerif", + }; + + private static bool IsSameTtmlColor(string a, string b) + { + if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) + { + return false; + } + + try + { + return ColorTranslator.FromHtml(a) == ColorTranslator.FromHtml(b); + } + catch + { + return false; + } + } + + /// + /// Styling a paragraph inherits from body/div - the document default, which is not written as tags. + /// + private static TtmlTextStyle GetInheritedTtmlTextStyle(XmlNode paragraph, List styles, TtmlHeadIndex headIndex) + { + var ancestors = new List(); + for (var node = paragraph.ParentNode; node != null && node.NodeType == XmlNodeType.Element; node = node.ParentNode) + { + ancestors.Add(node); + if (node.LocalName == "body") + { + break; + } + } + + var style = new TtmlTextStyle { Color = "white" }; // players default to white text + for (var i = ancestors.Count - 1; i >= 0; i--) + { + ApplyTtmlTextStyle(style, ancestors[i], styles, headIndex); + } + + return style; + } + private static void ReadParagraph(StringBuilder pText, XmlNode node, List styles, TtmlHeadIndex headIndex) + { + var inherited = GetInheritedTtmlTextStyle(node, styles, headIndex); + var paragraphStyle = inherited.Clone(); + ApplyTtmlTextStyle(paragraphStyle, node, styles, headIndex); + + // italic/bold/underline set on the

become tags; its color/font are kept as paragraph effects + var italic = paragraphStyle.Italic && !inherited.Italic; + var bold = paragraphStyle.Bold && !inherited.Bold; + var underline = paragraphStyle.Underline && !inherited.Underline; + pText.Append(italic ? "" : string.Empty).Append(bold ? "" : string.Empty).Append(underline ? "" : string.Empty); + ReadParagraphChildren(pText, node, styles, headIndex, paragraphStyle); + pText.Append(underline ? "" : string.Empty).Append(bold ? "" : string.Empty).Append(italic ? "" : string.Empty); + } + + private static void ReadParagraphChildren(StringBuilder pText, XmlNode node, List styles, TtmlHeadIndex headIndex, TtmlTextStyle parentStyle) { foreach (XmlNode child in node.ChildNodes) { @@ -1216,73 +1359,29 @@ private static void ReadParagraph(StringBuilder pText, XmlNode node, Lista b - line breaks are just indentation + } else if (child.Name == "span" || child.Name == "tt:span") { - var isItalic = false; - var isBold = false; - var isUnderlined = false; - string fontFamily = null; - string color = null; - - // Composing styles - - if (child.Attributes["style"] != null) - { - var styleName = child.Attributes["style"].Value; - if (styles.Contains(styleName) && headIndex.HasHead) - { - // Indexed once per load - this used to run a document-wide XPath per span. - foreach (var styleNode in headIndex.GetStyles(styleName)) - { - if (styleNode.Attributes["tts:fontStyle"] != null && styleNode.Attributes["tts:fontStyle"].Value == "italic") - { - isItalic = true; - } - if (styleNode.Attributes["tts:fontWeight"] != null && styleNode.Attributes["tts:fontWeight"].Value == "bold") - { - isBold = true; - } - if (styleNode.Attributes["tts:textDecoration"] != null && styleNode.Attributes["tts:textDecoration"].Value == "underline") - { - isUnderlined = true; - } - if (styleNode.Attributes["tts:fontFamily"] != null) - { - fontFamily = styleNode.Attributes["tts:fontFamily"].Value; - } - if (styleNode.Attributes["tts:color"] != null) - { - color = styleNode.Attributes["tts:color"].Value; - } - } - } - } - - if (child.Attributes["tts:fontStyle"] != null && child.Attributes["tts:fontStyle"].Value == "italic") - { - isItalic = true; - } - - if (child.Attributes["tts:fontWeight"] != null && child.Attributes["tts:fontWeight"].Value == "bold") - { - isBold = true; - } - - if (child.Attributes["tts:textDecoration"] != null && child.Attributes["tts:textDecoration"].Value == "underline") + if (!child.HasChildNodes) { - isUnderlined = true; - } - - if (child.Attributes["tts:fontFamily"] != null) - { - fontFamily = child.Attributes["tts:fontFamily"].Value; - } - - if (child.Attributes["tts:color"] != null) - { - color = child.Attributes["tts:color"].Value; + continue; // an empty styles nothing } + // Composing styles + var spanStyle = parentStyle.Clone(); + ApplyTtmlTextStyle(spanStyle, child, styles, headIndex); + + var isItalic = spanStyle.Italic && !parentStyle.Italic; + var isBold = spanStyle.Bold && !parentStyle.Bold; + var isUnderlined = spanStyle.Underline && !parentStyle.Underline; + var fontFamily = !string.IsNullOrEmpty(spanStyle.FontFamily) && spanStyle.FontFamily != parentStyle.FontFamily && + !(parentStyle.FontFamily == null && TtmlGenericFontFamilies.Contains(spanStyle.FontFamily)) + ? spanStyle.FontFamily + : null; + var color = !string.IsNullOrEmpty(spanStyle.Color) && !IsSameTtmlColor(spanStyle.Color, parentStyle.Color) ? spanStyle.Color : null; // Applying styles if (isItalic) @@ -1317,7 +1416,7 @@ private static void ReadParagraph(StringBuilder pText, XmlNode node, List"); } - ReadParagraph(pText, child, styles, headIndex); + ReadParagraphChildren(pText, child, styles, headIndex, spanStyle); if (!string.IsNullOrEmpty(fontFamily) || !string.IsNullOrEmpty(color)) { diff --git a/src/libse/SubtitleFormats/TimedTextImsc11.cs b/src/libse/SubtitleFormats/TimedTextImsc11.cs index 898ff2b13a2..63cbad402ea 100644 --- a/src/libse/SubtitleFormats/TimedTextImsc11.cs +++ b/src/libse/SubtitleFormats/TimedTextImsc11.cs @@ -592,7 +592,8 @@ public override void LoadSubtitle(Subtitle subtitle, List lines, string var arr = frameRateMultiplier.InnerText.Split(); if (arr.Length == 2 && Utilities.IsInteger(arr[0]) && Utilities.IsInteger(arr[1]) && int.Parse(arr[1]) > 0) { - fr = double.Parse(arr[0], CultureInfo.InvariantCulture) / double.Parse(arr[1], CultureInfo.InvariantCulture); + // The multiplier scales ttp:frameRate (60 * 1000/1001 = 59.94); it is not a rate itself. + fr = fr * double.Parse(arr[0], CultureInfo.InvariantCulture) / double.Parse(arr[1], CultureInfo.InvariantCulture); if (fr > 20 && fr < 100) { Configuration.Settings.General.CurrentFrameRate = fr; diff --git a/src/libuilogic/Export/ImageRenderer.cs b/src/libuilogic/Export/ImageRenderer.cs index a41d6be7499..03eb5cc28aa 100644 --- a/src/libuilogic/Export/ImageRenderer.cs +++ b/src/libuilogic/Export/ImageRenderer.cs @@ -204,6 +204,10 @@ private static SKBitmap RenderBitmap(ImageParameter ip) // under g/p/y was drawn outside the box. var firstLineTopInBitmap = firstBaseline - lineMetrics[0].Ascent - bitmapTop; + // Same for the left edge: the crop starts at the outline's left edge, not at textStartX, + // so per-line boxes laid out from column 0 sat outlineWidth too far left. + var textLeftInBitmap = textStartX - drawnBounds.Left; + var cropTop = Math.Max(0, bitmapTop); var cropBottom = Math.Min(tempBitmap.Height - 1, Math.Max(drawnBounds.Bottom, lineBoxBottom)); var overflowTop = cropTop - bitmapTop; @@ -217,7 +221,7 @@ private static SKBitmap RenderBitmap(ImageParameter ip) if (ip.BoxType != ExportBoxType.None) { - textBitmap = Replace(textBitmap, DrawBoxBehindText(textBitmap, lines, ip, fonts, lineMetrics, lineSpacing, firstLineTopInBitmap)); + textBitmap = Replace(textBitmap, DrawBoxBehindText(textBitmap, lines, ip, fonts, lineMetrics, lineSpacing, firstLineTopInBitmap, textLeftInBitmap)); } if (ip.PaddingTopBottom == 0 && ip.PaddingLeftRight == 0) @@ -397,7 +401,8 @@ private static SKBitmap DrawBoxBehindText( FontSet fonts, LineMetrics[] lineMetrics, float lineSpacing, - float firstLineTopInBitmap) + float firstLineTopInBitmap, + float textLeftInBitmap) { var padLeft = ip.BoxPaddingLeft; var padRight = ip.BoxPaddingRight; @@ -426,11 +431,12 @@ private static SKBitmap DrawBoxBehindText( } else if (ip.BoxType == ExportBoxType.BoxPerLine) { - // Pre-calculate line widths (same logic as RenderTextToCanvas) + // Pre-calculate line widths (same logic as RenderTextToCanvas, including the RTL + // segment order the 0.17em styled-segment padding depends on) var lineWidths = new float[lines.Count]; for (var li = 0; li < lines.Count; li++) { - var line = lines[li]; + var line = ip.IsRightToLeft ? lines[li].AsEnumerable().Reverse().ToList() : lines[li]; for (var j = 0; j < line.Count; j++) { var seg = line[j]; @@ -448,13 +454,7 @@ private static SKBitmap DrawBoxBehindText( { var lineWidth = lineWidths[li]; var lineHeight = lineMetrics[li].Ascent + lineMetrics[li].Descent; - float textX; - if (ip.ResolvedContentAlignment == ExportContentAlignment.Center) - textX = padLeft + (maxLineWidth - lineWidth) / 2; - else if (ip.ResolvedContentAlignment == ExportContentAlignment.Right) - textX = padLeft + maxLineWidth - lineWidth; - else - textX = padLeft; + var textX = GetLineStartX(ip.ResolvedContentAlignment, padLeft + textLeftInBitmap, maxLineWidth, lineWidth); canvas.DrawRoundRect( new SKRoundRect(new SKRect(textX - padLeft, currentY - padTop, textX + lineWidth + padRight, currentY + lineHeight + padBottom), radius, radius), diff --git a/src/libuilogic/Media/FfmpegMediaInfo.cs b/src/libuilogic/Media/FfmpegMediaInfo.cs index 942d9ea89f0..7eace41730f 100644 --- a/src/libuilogic/Media/FfmpegMediaInfo.cs +++ b/src/libuilogic/Media/FfmpegMediaInfo.cs @@ -41,8 +41,8 @@ public partial class FfmpegMediaInfo // 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")] + // " Display Matrix: rotation of -90.00 degrees" (ffmpeg 4.x prints "displaymatrix: rotation of ..."). + [GeneratedRegex(@"^(?:Display Matrix|displaymatrix): rotation of (?-?\d+(?:\.\d+)?) degrees")] private static partial Regex DisplayMatrixRotationRegexGen(); private static readonly Regex DisplayMatrixRotationRegex = DisplayMatrixRotationRegexGen(); diff --git a/src/libuilogic/SpellCheck/SpellCheckWordLists.cs b/src/libuilogic/SpellCheck/SpellCheckWordLists.cs index 65782cc56c7..62030d02111 100644 --- a/src/libuilogic/SpellCheck/SpellCheckWordLists.cs +++ b/src/libuilogic/SpellCheck/SpellCheckWordLists.cs @@ -178,33 +178,27 @@ private void LoadUseAlwaysList() return; } - var fileName = GetUseAlwaysListFileName(); - var xmlDoc = new XmlDocument(); - if (File.Exists(fileName)) + foreach (var pair in UseAlwaysListFile.Load(GetUseAlwaysListFileName())) { - xmlDoc.Load(fileName); - var xmlNodeList = xmlDoc.DocumentElement?.SelectNodes("Pair"); - if (xmlNodeList != null) + if (!_useAlwaysList.ContainsKey(pair.Key)) { - foreach (XmlNode item in xmlNodeList) - { - if (item.Attributes?["from"] != null && item.Attributes["to"] != null) - { - var to = item.Attributes["to"]?.Value; - var from = item.Attributes["from"]?.Value; - if (to != null && from != null && !_useAlwaysList.ContainsKey(from)) - { - _useAlwaysList.Add(from, to); - } - } - } + _useAlwaysList.Add(pair.Key, pair.Value); } } } + ///

+ /// Re-reads <lang>_UseAlways.xml, e.g. after it was edited in the "use always" list editor. + /// + public void ReloadUseAlwaysList() + { + _useAlwaysList.Clear(); + LoadUseAlwaysList(); + } + private string GetUseAlwaysListFileName() { - return Path.Combine(_dictionaryFolder, _languageName + "_UseAlways.xml"); + return UseAlwaysListFile.GetFileName(_dictionaryFolder, _languageName); } public void UseAlwaysListRemove(string key) @@ -228,9 +222,6 @@ private void SaveUseAlwaysList(string? newKey = null, string? newValue = null, s return; } - var xmlDoc = new XmlDocument(); - xmlDoc.LoadXml(""); - if (newKey != null && newValue != null && !_useAlwaysList.ContainsKey(newKey.Trim())) { _useAlwaysList.Add(newKey.Trim(), newValue.Trim()); @@ -240,21 +231,7 @@ private void SaveUseAlwaysList(string? newKey = null, string? newValue = null, s _useAlwaysList.Remove(oldKey.Trim()); } - foreach (KeyValuePair kvp in _useAlwaysList) - { - XmlNode node = xmlDoc.CreateElement("Pair"); - var f = xmlDoc.CreateAttribute("from"); - f.Value = kvp.Key; - var t = xmlDoc.CreateAttribute("to"); - t.Value = kvp.Value; - if (node.Attributes != null) - { - node.Attributes.Append(f); - node.Attributes.Append(t); - } - xmlDoc.DocumentElement?.AppendChild(node); - } - xmlDoc.Save(GetUseAlwaysListFileName()); + UseAlwaysListFile.Save(GetUseAlwaysListFileName(), _useAlwaysList); } public void RemoveUserWord(string word) diff --git a/src/libuilogic/SpellCheck/SpellChecker.cs b/src/libuilogic/SpellCheck/SpellChecker.cs index ddd951d4b14..1df3c1346de 100644 --- a/src/libuilogic/SpellCheck/SpellChecker.cs +++ b/src/libuilogic/SpellCheck/SpellChecker.cs @@ -124,6 +124,10 @@ private static bool TryGetDictionaryCulture(string fileName, out CultureInfo cul public bool Initialize(string dictionaryFile, string twoLetterLanguageCode) { SkipAllList.Clear(); + + // Change-all pairs are per language (_UseAlways.xml, re-applied below) - switching + // dictionary mid-session used to keep the previous language's pairs active. (#15767) + ChangeAllDictionary.Clear(); _twoLetterLanguageCode = twoLetterLanguageCode ?? string.Empty; // The Voikko marker path need not exist (bundled / system dictionary); TryCreate validates. @@ -275,6 +279,25 @@ public void RemoveIgnoreWord(string word) } } + /// + /// Reloads the persisted "use always" pairs, e.g. after the list was edited in its editor window. + /// Session-only pairs (when the list is not remembered) are dropped too. + /// + public void ReloadUseAlwaysList() + { + ChangeAllDictionary.Clear(); + if (WordLists == null) + { + return; + } + + WordLists.ReloadUseAlwaysList(); + foreach (var pair in WordLists.GetUseAlwaysList()) + { + ChangeAllDictionary[pair.Key] = pair.Value; + } + } + public void RemoveChangeAllWord(string fromWord) { if (string.IsNullOrEmpty(fromWord)) diff --git a/src/libuilogic/SpellCheck/UseAlwaysListFile.cs b/src/libuilogic/SpellCheck/UseAlwaysListFile.cs new file mode 100644 index 00000000000..fd915249141 --- /dev/null +++ b/src/libuilogic/SpellCheck/UseAlwaysListFile.cs @@ -0,0 +1,64 @@ +using System.Xml; + +namespace Nikse.SubtitleEdit.UiLogic.SpellCheck; + +/// +/// Reads and writes the spell check "use always" list (<lang>_UseAlways.xml): the pairs stored by +/// "Change all" / "Use always" that replace a misspelled word automatically. Shared by the spell +/// checker and the list editor. +/// +public static class UseAlwaysListFile +{ + public static string GetFileName(string dictionaryFolder, string languageName) + { + return Path.Combine(dictionaryFolder, languageName + "_UseAlways.xml"); + } + + public static Dictionary Load(string fileName) + { + var result = new Dictionary(); + if (!File.Exists(fileName)) + { + return result; + } + + var xmlDoc = new XmlDocument(); + xmlDoc.Load(fileName); + var xmlNodeList = xmlDoc.DocumentElement?.SelectNodes("Pair"); + if (xmlNodeList == null) + { + return result; + } + + foreach (XmlNode item in xmlNodeList) + { + var from = item.Attributes?["from"]?.Value; + var to = item.Attributes?["to"]?.Value; + if (to != null && from != null && !result.ContainsKey(from)) + { + result.Add(from, to); + } + } + + return result; + } + + public static void Save(string fileName, IEnumerable> pairs) + { + var xmlDoc = new XmlDocument(); + xmlDoc.LoadXml(""); + foreach (var kvp in pairs) + { + XmlNode node = xmlDoc.CreateElement("Pair"); + var f = xmlDoc.CreateAttribute("from"); + f.Value = kvp.Key; + var t = xmlDoc.CreateAttribute("to"); + t.Value = kvp.Value; + node.Attributes?.Append(f); + node.Attributes?.Append(t); + xmlDoc.DocumentElement?.AppendChild(node); + } + + xmlDoc.Save(fileName); + } +} diff --git a/src/libuilogic/Translate/MergeAndSplitHelper.cs b/src/libuilogic/Translate/MergeAndSplitHelper.cs index 65a01637f19..40ceb88c30d 100644 --- a/src/libuilogic/Translate/MergeAndSplitHelper.cs +++ b/src/libuilogic/Translate/MergeAndSplitHelper.cs @@ -487,7 +487,7 @@ private static int TrySplitByLineCount( Action applyRowUpdate) { var translatedLines = mergedTranslation.SplitToLines(); - if (translatedLines.Count != mergeResult.Text.SplitToLines().Count) + if (translatedLines.Count != mergeResult.Text.CountLines()) { return 0; } @@ -500,7 +500,7 @@ private static int TrySplitByLineCount( foreach (var mergeItem in mergeResult.MergeResultItems) { var numberOfParagraphs = mergeItem.EndIndex - mergeItem.StartIndex + 1; - var numberOfLines = mergeItem.Text.SplitToLines().Count; + var numberOfLines = mergeItem.Text.CountLines(); var translatedText = BuildTranslatedText(translatedLines, ref translatedLinesIdx, numberOfLines); @@ -1130,7 +1130,7 @@ private static List SplitContinuousText(string text, MergeResultItem ite private static List? SplitByPreservedLineBreaks(string text, MergeResultItem item) { var replyLines = text.SplitToLines(); - var lineCounts = item.Paragraphs.Select(p => p.Text.SplitToLines().Count).ToList(); + var lineCounts = item.Paragraphs.Select(p => p.Text.CountLines()).ToList(); if (replyLines.Count != lineCounts.Sum()) { return null; diff --git a/src/seconv/Core/AtomicFileWriter.cs b/src/seconv/Core/AtomicFileWriter.cs new file mode 100644 index 00000000000..e9227ba1c62 --- /dev/null +++ b/src/seconv/Core/AtomicFileWriter.cs @@ -0,0 +1,136 @@ +using System.Text; + +namespace SeConv.Core; + +/// +/// Writes output files via a temp file in the same folder that is then renamed over the target, +/// so an interrupted write (killed process, crash, power loss) leaves either the old file or the +/// new one - never a truncated one. With --overwrite the target is often the input itself, and +/// File.WriteAllText truncates before it writes (issue #15829). +/// +internal static class AtomicFileWriter +{ + public static void WriteAllText(string path, string content, Encoding encoding) + { + Write(path, stream => + { + // StreamWriter writes the encoding's preamble at position 0, like File.WriteAllText. + using var writer = new StreamWriter(stream, encoding, bufferSize: -1, leaveOpen: true); + writer.Write(content); + }); + } + + public static void Write(string path, Action write) + { + var target = ResolveTarget(path); + var directory = Path.GetDirectoryName(Path.GetFullPath(target)); + if (string.IsNullOrEmpty(directory)) + { + directory = Directory.GetCurrentDirectory(); + } + + // Same folder, so the rename stays on one volume and is atomic. + var tempPath = Path.Combine(directory, "." + Path.GetFileName(target) + "." + Path.GetRandomFileName() + ".tmp"); + FileStream tempStream; + try + { + tempStream = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + // The folder is not writable (the file may still be) - write in place as before. + using var fs = new FileStream(target, FileMode.Create, FileAccess.Write); + write(fs); + return; + } + + try + { + using (tempStream) + { + write(tempStream); + tempStream.Flush(flushToDisk: true); + } + + CopyUnixFileMode(target, tempPath); + ReplaceFile(tempPath, target); + } + finally + { + TryDelete(tempPath); + } + } + + /// + /// A rename over a symbolic link would replace the link with a regular file - write to the + /// file it points at instead. + /// + private static string ResolveTarget(string path) + { + try + { + var info = new FileInfo(path); + if (info.Exists && info.LinkTarget != null) + { + return info.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? path; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Fall back to the given path. + } + + return path; + } + + private static void CopyUnixFileMode(string from, string to) + { + if (OperatingSystem.IsWindows() || !File.Exists(from)) + { + return; + } + + try + { + File.SetUnixFileMode(to, File.GetUnixFileMode(from)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The new file keeps the default (umask) mode. + } + } + + private static void ReplaceFile(string tempPath, string target) + { + if (OperatingSystem.IsWindows() && File.Exists(target)) + { + try + { + // Keeps the target's attributes, ACLs and creation time. + File.Replace(tempPath, target, destinationBackupFileName: null, ignoreMetadataErrors: true); + return; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + // E.g. some network shares - a plain rename below. + } + } + + File.Move(tempPath, target, overwrite: true); + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A leftover temp file is harmless; the target is intact. + } + } +} diff --git a/src/seconv/Core/LibSEIntegration.cs b/src/seconv/Core/LibSEIntegration.cs index fb9eedba09d..2ccb58d380b 100644 --- a/src/seconv/Core/LibSEIntegration.cs +++ b/src/seconv/Core/LibSEIntegration.cs @@ -424,7 +424,7 @@ public static void SaveSubtitle( { Directory.CreateDirectory(outputDirCustom); } - File.WriteAllText(filePath, rendered, new UTF8Encoding(true)); + AtomicFileWriter.WriteAllText(filePath, rendered, new UTF8Encoding(true)); return; } @@ -453,7 +453,7 @@ public static void SaveSubtitle( parts.Add(text); } } - File.WriteAllText(filePath, string.Join(" ", parts), plainEncoding); + AtomicFileWriter.WriteAllText(filePath, string.Join(" ", parts), plainEncoding); return; } @@ -473,7 +473,7 @@ public static void SaveSubtitle( } sb.AppendLine(text); } - File.WriteAllText(filePath, sb.ToString(), plainEncoding); + AtomicFileWriter.WriteAllText(filePath, sb.ToString(), plainEncoding); return; } @@ -528,7 +528,12 @@ public static void SaveSubtitle( var headerBytes = File.ReadAllBytes(ebuHeaderFile); header = Ebu.ReadHeader(headerBytes); } - ebu.Save(filePath, subtitle, true, header); + // Ebu.Save(path, ...) truncates the target before writing - write via a temp file. + using var ebuStream = new MemoryStream(); + if (ebu.Save(filePath, ebuStream, subtitle, true, header)) + { + AtomicFileWriter.Write(filePath, s => ebuStream.WriteTo(s)); + } return; } @@ -539,15 +544,14 @@ public static void SaveSubtitle( // like Subtitle Edit's Export PAC dialog and batch convert. pac.CodePage = pacCodePage ?? Pac.CodePageLatin; pac.SecondaryCodePage = options?.PacSecondaryCodePage ?? -1; - pac.Save(filePath, subtitle); + AtomicFileWriter.Write(filePath, s => pac.Save(filePath, s, subtitle)); return; } // Other binary formats (Cavena890, CheetahCaption, CapMakerPlus, Ayato, ...) if (targetFormat is IBinaryPersistableSubtitle binary) { - using var fs = File.Create(filePath); - binary.Save(filePath, fs, subtitle, true); + AtomicFileWriter.Write(filePath, s => binary.Save(filePath, s, subtitle, true)); return; } @@ -599,21 +603,21 @@ private static void SaveTextFormat(Subtitle subtitle, string filePath, SubtitleF // WebVTT — always UTF-8 with BOM if (format is WebVTT) { - File.WriteAllText(filePath, content, new UTF8Encoding(true)); + AtomicFileWriter.WriteAllText(filePath, content, new UTF8Encoding(true)); return; } // iTunes Timed Text — UTF-8 without BOM if (format is ItunesTimedText) { - File.WriteAllText(filePath, content, new UTF8Encoding(false)); + AtomicFileWriter.WriteAllText(filePath, content, new UTF8Encoding(false)); return; } // .rtf — always ASCII if (string.Equals(Path.GetExtension(filePath), ".rtf", StringComparison.OrdinalIgnoreCase)) { - File.WriteAllText(filePath, content, Encoding.ASCII); + AtomicFileWriter.WriteAllText(filePath, content, Encoding.ASCII); return; } @@ -622,15 +626,15 @@ private static void SaveTextFormat(Subtitle subtitle, string filePath, SubtitleF var textEncoding = GetTextEncoding(encodingName); if (textEncoding.DisplayName == TextEncoding.Utf8WithBom) { - File.WriteAllText(filePath, content, new UTF8Encoding(true)); + AtomicFileWriter.WriteAllText(filePath, content, new UTF8Encoding(true)); } else if (textEncoding.DisplayName == TextEncoding.Utf8WithoutBom) { - File.WriteAllText(filePath, content, new UTF8Encoding(false)); + AtomicFileWriter.WriteAllText(filePath, content, new UTF8Encoding(false)); } else { - File.WriteAllText(filePath, content, encoding); + AtomicFileWriter.WriteAllText(filePath, content, encoding); } } diff --git a/src/ui/Assets/Languages/ChineseTraditional.json b/src/ui/Assets/Languages/ChineseTraditional.json index 9123ba75af5..0cf14f3b4b3 100644 --- a/src/ui/Assets/Languages/ChineseTraditional.json +++ b/src/ui/Assets/Languages/ChineseTraditional.json @@ -1,6 +1,6 @@ { "title": "Subtitle Edit", - "version": "v5.3.0-beta17", + "version": "v5.3.0-beta25", "translatedBy": "昇(以下舊譯者:BAWAN、風逸蘭、Jackie Lam、SiderealArt)", "cultureName": "zh-Hant", "general": { @@ -9,10 +9,22 @@ "actors": "角色", "setActorDotDotDot": "設置角色...", "actorPickerTitle": "設置角色", - "actorPickerFilterHint": "輸入文字進行篩選,或輸入新角色名稱", - "actorPickerKeysHint": "按數字鍵或 Enter 設置角色。\nAlt+上/下方向鍵可變更順序,Delete 可移除角色。", + "actorPickerFilterHint": "輸入來篩選,或輸入新角色名稱", + "actorPickerKeysHint": "按數字鍵或 Enter鍵 設置角色。\nAlt+上/下方向鍵可變更順序,Delete 可移除角色。", "actorPickerNewActorX": "新角色「{0}」", "actorPickerLinesSelectedX": "已選取行數:{0}", + "setStyleDotDotDot": "設置樣式...", + "stylePickerTitle": "設置樣式", + "stylePickerFilterHint": "輸入來篩選,或輸入新樣式名稱", + "stylePickerKeysHint": "按下數字鍵(1-9、0)即可將該樣式套用至所有已選取行。", + "stylePickerNewStyleX": "新樣式「{0}」", + "stylePickerNewStyleInfo": "按 Enter鍵 即可加入此預設設定的樣式,並設置在已選取行。往後可使用「管理樣式...」來變更其字型、顏色與位置。", + "stylePickerCurrentStyleX": "目前樣式:{0}", + "stylePickerLinesUsingStyleX": "使用此樣式的行數:{0}", + "stylePickerMarginsXYZ": "左 {0},右 {1},垂直 {2}", + "stylePickerSetStyle": "設置樣式(_S)", + "stylePickerManageStyles": "管理樣式...", + "stylePickerManageStylesHint": "關閉此視窗並開啟樣式管理器,以編輯字型、顏色、位置等設定", "add": "新增", "addDotDotDot": "新增...", "addToNamesListCaseSensitive": "新增至名稱列表(區分大小寫)", @@ -20,7 +32,7 @@ "addVideoFiles": "新增視訊檔案...", "adjustAlpha": "調整 Alpha(透明度)", "adjustDisplayDuration": "調整持續時間", - "adjustment": "調整", + "adjustment": "調節", "advancedSettings": "進階設定", "after": "之後", "alignment": "對齊方式", @@ -28,7 +40,7 @@ "alignmentX": "為已選取行設置對齊方式「{0}」", "all": "全部", "allFiles": "所有檔案", - "alphaAdjustment": "Alpha 調整值", + "alphaAdjustment": "Alpha 調節", "alphaThreshold": "Alpha 閾值", "apiKey": "API 金鑰", "formality": "正式度", @@ -39,18 +51,18 @@ "applyTo": "應用於", "ascending": "升序", "assistedMove": "輔助移動", - "assistedMoveChooseMove": "點擊選項或按下相對應的數字以移動單字", + "assistedMoveChooseMove": "點擊選項或按對應數字鍵來移動單字", "assistedMoveDotDotDot": "輔助移動...", "assistedMoveNoSuggestions": "沒有移動建議 - 句子並未延續至上一則/下一則字幕", "assistedSplitNoSuggestions": "此行沒有分割建議", "assistedSplit": "輔助分割", - "assistedSplitChooseSplitPoint": "點擊選項或按下相對應的數字鍵以分割該行", + "assistedSplitChooseSplitPoint": "點擊選項或按對應數字鍵來分割該行", "assistedSplitDotDotDot": "輔助分割...", "descending": "降序", "attachDotDotDot": "附加...", "audioFileSaved": "音訊檔案已儲存", "audioFiles": "音訊檔案", - "audioVisualizer": "音訊視覺化工具", + "audioVisualizer": "音訊視覺化", "auto": "自動", "autoBreak": "文本自動斷行", "autoBalanceLines": "自動平衡每行字數", @@ -61,7 +73,7 @@ "background": "背景", "backgroundColor": "背景顏色", "backward": "倒轉", - "batchMode": "批量模式", + "batchMode": "批次模式", "bdnXml": "BDN/xml", "bdnXml8Bit": "BDN/xml 8-bit", "before": "之前", @@ -98,7 +110,7 @@ "centerHorizontally": "水平置中", "centerVertically": "垂直置中", "change": "變更", - "changeAll": "全部變更", + "changeAll": "變更全部", "changeCasing": "變更大小寫", "changeFormatting": "變更格式排版", "changeFrameRate": "變更影格率", @@ -135,7 +147,7 @@ "column": "欄位", "columns": "欄位", "columnsDotDotDot": "欄位...", - "consoleLog": "控制台記錄LOG", + "consoleLog": "控制台日誌", "contentAlignment": "內容對齊方式", "continueFindTitle": "繼續尋找?", "searchItemNotFoundContinueFromTop": "未找到搜尋項目。\n要從文件頂端開始,再搜尋一次嗎?", @@ -205,7 +217,7 @@ "error": "錯誤", "start": "開始", "vobSub": "VobSub", - "outputFileCannotBeTheInputFile": "輸出檔不能與輸入檔相同。", + "outputFileCannotBeTheInputFile": "輸出檔案不能相同於輸入檔案。", "errorX": "錯誤; {0}", "example": "範例", "exampleX": "範例: {0}", @@ -222,7 +234,7 @@ "extendNextStartToSelectedEnd": "延伸下一行的起點至已選取項的終點", "extractingAudioClips": "正在提取音訊片段...", "exitFullScreen": "退出全螢幕", - "fade": "淡出", + "fade": "淡化", "fetchFirstWordFromNextSubtitle": "從下一則字幕中取得第一個單字", "fileAlreadyExists": "檔案已存在", "fileExtension": "檔案副檔名", @@ -280,7 +292,7 @@ "group": "群組", "header": "標頭", "height": "高度", - "help": "幫助", + "help": "支援", "hex": "十六進位", "hide": "隱藏", "history": "歷史", @@ -297,8 +309,8 @@ "include": "包括", "information": "資訊", "insertAfter": "於後方插入", - "insertAtPositionAndFocusTextBox": "於視訊位置插入字幕並聚焦至文字框", - "insertAtPositionNoFocusTextBox": "於視訊位置插入字幕 (不聚焦至文字框)", + "insertAtPositionAndFocusTextBox": "於視訊位置插入字幕並聚焦文字框", + "insertAtPositionNoFocusTextBox": "於視訊位置插入字幕 (不聚焦文字框)", "insertBefore": "於前方插入", "insertLine": "插入一行", "insertNewSelection": "插入新選取範圍", @@ -431,6 +443,7 @@ "percent": "百分比", "pickLayer": "設置圖層", "pickOllamaModel": "挑選 Ollama 模型", + "pickModel": "挑選 模型", "llamaCppUseRemoteServer": "使用外部伺服器 (URL)", "llamaCppEngineSettings": "llama.cpp 引擎設定", "llamaCppEngineSettingsSubtitle": "本地 llama.cpp 伺服器建置", @@ -472,7 +485,7 @@ "profiles": "設定檔", "property": "屬性", "question": "問題", - "reDownloadX": "重新下載{0}", + "reDownloadX": "重新下載 {0}", "reason": "原因", "recentFiles": "近期檔案", "recentlyUsedVideoOffsets": "近期已使用的視訊偏移量", @@ -507,14 +520,15 @@ "reset": "重置", "resizePanels": "調整面板大小", "resolution": "解析度", + "retry": "重試", "reverseRightToLeftStartEnd": "反轉 由右至左書寫 起頭/結尾", "right": "右邊", "rightToLeft": "由右至左", "rules": "規則", - "saveChangesMessage": "您想儲存目前字幕檔的變更嗎?", + "saveChangesMessage": "確定要儲存目前字幕檔的變更嗎?", "saveChangesTitle": "儲存變更嗎?", - "saveChangesToX": "是否要儲存變更至「{0}」?", - "saveChangesToXOriginal": "是否要儲存變更至原件字幕檔案「{0}」?", + "saveChangesToX": "確定要儲存變更至「{0}」?", + "saveChangesToXOriginal": "確定要儲存變更至原件字幕檔案「{0}」?", "saveDotDotDot": "存檔...", "saveFileAsTitle": "將檔案另存為", "saveImageAs": "另存圖像為...", @@ -549,7 +563,7 @@ "setEnd": "設置終點", "setEndAndGoToNext": "設置終點並跳至下一項", "setEndAddNewAndGoToNew": "設置終點,新增後跳至新項", - "setEndAddNewAndGoToNewNoFocusTextBox": "設置終點,新增後跳至新項(無聚焦文字框)", + "setEndAddNewAndGoToNewNoFocusTextBox": "設置終點,新增後跳至新項(不聚焦文字框)", "setEndAndOffsetTheRest": "設置終點並偏移後續行", "setFontDotDotDot": "設置字型...", "setInCueToClosestShotChangeLeftGreenZone": "設置進聲提示點至最近的場景變更線 (貼齊至左側綠色區域)", @@ -561,13 +575,15 @@ "setStartAndKeepDuration": "設置起點並保持持續時間", "setStartAndOffsetTheRest": "設置起點並偏移後續行", "setUpLikeSubtitleEdit4": "設置類似 Subtitle Edit 4 (佈景主題、快捷鍵、取代規則)", - "imageBackgroundColorDotDotDot": "圖像背景顏色…", + "imageBackgroundColorDotDotDot": "圖像背景顏色...", "setVideoOffset": "設置視訊偏移量", "settings": "設定", "shadow": "陰影", "shadowColor": "陰影顏色", "shadowWidth": "陰影寬度", "shiftLineBy": "行位移", + "shotIn": "場景切入線", + "shotOut": "場景切出線", "shortcut": "快捷鍵", "shortcuts": "快捷鍵", "show": "顯示", @@ -582,6 +598,8 @@ "showLayerColumn": "顯示「圖層」欄", "showPreview": "顯示預覽", "showShotChangesList": "顯示 場景變更線 列表", + "showShotInColumn": "顯示「場景切入線」欄", + "showShotOutColumn": "顯示「場景切出線」欄", "showStyleColumn": "顯示「樣式」欄", "showTeletext": "顯示電傳視訊", "showTimeCodes": "顯示時間碼", @@ -599,7 +617,7 @@ "smart": "智慧", "snapSelectedLinesEndToPreviousShotChange": "貼齊已選取行終點 至上一個 場景變更線", "snapSelectedLinesStartToNextShotChange": "貼齊已選取行起點至下一個 場景變更線", - "snapSelectedLinesToNearestShotChange": "貼齊已選取行的起點/終點 至 最近的場景變更線", + "snapSelectedLinesToNearestShotChange": "貼齊已選取行的 起點/終點 至 最近的場景變更線", "solidColor": "純色", "sourceViewX": "原始碼視圖 - {0}", "spacing": "間距", @@ -680,7 +698,7 @@ "topLeft": "上/左", "topRight": "上/右", "top": "頂部", - "totalAdjustmentX": "調整總數: {0}", + "totalAdjustmentX": "總數調節:{0}", "translate": "翻譯", "translatePercentX": "翻譯了:{0} %", "translateRow": "翻譯列", @@ -708,7 +726,7 @@ "url": "URL", "usages": "用法", "use": "使用", - "useAlways": "一律使用", + "useAlways": "始終使用", "useOnce": "使用一次", "useOutputFolder": "使用輸出資料夾", "useSourceFolder": "使用來源資料夾", @@ -719,18 +737,31 @@ "video100MsForward": "視訊:快轉 100 毫秒數", "video500MsBack": "視訊:倒轉 500 毫秒數", "video500MsForward": "視訊:快轉 500 毫秒數", - "videoCustom1BackX": "視訊:倒轉 ({0:#,###,##0}) 自訂毫秒數,1", - "videoCustom1ForwardX": "視訊:快轉({0:#,###,##0})自訂毫秒數,1", - "videoCustom2BackX": "視訊:倒轉 ({0:#,###,##0}) 自訂毫秒數,2", - "videoCustom2ForwardX": "視訊:快轉({0:#,###,##0})自訂毫秒數,2", - "videoCustom3BackX": "視訊:倒轉 ({0:#,###,##0}) 自訂毫秒數,3", - "videoCustom3ForwardX": "視訊:快轉({0:#,###,##0})自訂毫秒數,3", - "videoCustom4BackX": "視訊:倒轉 ({0:#,###,##0}) 自訂毫秒數,4", - "videoCustom4ForwardX": "視訊:快轉({0:#,###,##0})自訂毫秒數,4", + "videoCustom1BackX": "視訊:倒轉({0:#,###,##0})自訂毫秒數,1", + "videoCustom1ForwardX": "視訊:快轉({0:#,###,##0})自訂毫秒數,1", + "videoCustom2BackX": "視訊:倒轉({0:#,###,##0})自訂毫秒數,2", + "videoCustom2ForwardX": "視訊:快轉({0:#,###,##0})自訂毫秒數,2", + "videoCustom3BackX": "視訊:倒轉({0:#,###,##0})自訂毫秒數,3", + "videoCustom3ForwardX": "視訊:快轉({0:#,###,##0})自訂毫秒數,3", + "videoCustom4BackX": "視訊:倒轉({0:#,###,##0})自訂毫秒數,4", + "videoCustom4ForwardX": "視訊:快轉({0:#,###,##0})自訂毫秒數,4", + "videoCustom1BackAndPauseX": "視訊:倒轉({0:#,###,##0})自訂毫秒數,並暫停,1", + "videoCustom1ForwardAndPauseX": "視訊:快轉({0:#,###,##0})自訂毫秒數,並暫停,1", + "videoCustom2BackAndPauseX": "視訊:倒轉({0:#,###,##0})自訂毫秒數,並暫停,2", + "videoCustom2ForwardAndPauseX": "視訊:快轉({0:#,###,##0})自訂毫秒數,並暫停,2", + "videoCustom3BackAndPauseX": "視訊:倒轉({0:#,###,##0})自訂毫秒數,並暫停,3", + "videoCustom3ForwardAndPauseX": "視訊:快轉({0:#,###,##0})自訂毫秒數,並暫停,3", + "videoCustom4BackAndPauseX": "視訊:倒轉({0:#,###,##0})自訂毫秒數,並暫停,4", + "videoCustom4ForwardAndPauseX": "視訊:快轉({0:#,###,##0})自訂毫秒數,並暫停,4", "videoExtension": "視訊檔案副檔名", "videoFile": "視訊檔案", - "videoFileGenerated": "已產生的視訊檔案", - "videoFileGeneratedX": "已產生的視訊檔案:「{0}」", + "videoFileGenerated": "已生成視訊檔案", + "videoFileGeneratedX": "已生成視訊檔案:「{0}」", + "videoFilesGenerated": "已生成視訊檔案", + "xVideosGenerated": "已生成 {0} 個視訊", + "xOfYVideosGenerated": "已生成 {0} / {1} 個影片", + "savedInX": "已儲存於 {0}", + "savedInXFolders": "已儲存於 {0} 個資料夾", "videoFiles": "視訊檔案", "videoAndAudioFiles": "視訊和音訊檔案", "videoOffset": "視訊偏移", @@ -800,7 +831,7 @@ "googleCloudSttProjectId": "專案 ID(僅適用於應用程式預設憑證)", "googleCloudSttBucketName": "儲存桶(留空=自動)", "googleCloudSttDynamicBatching": "動態批次處理(費用約低 5 倍,不保證延遲)", - "onlineSttApiKeyMissing": "需求 API 金鑰。請輸入您的 API 金鑰後重試。", + "onlineSttApiKeyMissing": "需求 API 金鑰。請輸入您的 API 金鑰後再嘗試。", "openAiCompatibleSttAutoTranscribeOnAudioSelection": "經由語音轉文字自動轉錄新波形選取範圍", "openAiCompatibleSttStream": "串流回應", "openAiCompatibleSttStreamHint": "傳送 'stream=true' 以接收即時文字增量 (SSE)。若伺服器不接受 'stream' 參數(例如 Groq)請停用此選項。", @@ -850,7 +881,7 @@ "edit": "編輯(_E)", "undo": "復原(_U)", "redo": "重做(_D)", - "showHistory": "顯示操作復原紀錄(_S)...", + "showHistory": "顯示復原的操作紀錄(_S)...", "find": "尋找(_F)...", "findNext": "尋找下一個(_N)", "replace": "取代(_R)", @@ -862,7 +893,7 @@ "tools": "工具(_T)", "adjustDurations": "調整持續時間(_A)...", "applyDurationLimits": "應用持續時間限制(_L)...", - "batchConvert": "批量轉換(_B)...", + "batchConvert": "批次轉換(_B)...", "beautifyTimeCodes": "美化時間碼...", "improveTimeCodes": "改進時間碼(強制對齊)...", "bridgeGaps": "填補間隔(_G)...", @@ -871,7 +902,7 @@ "changeFormatting": "變更格式排版...", "aiReview": "AI 校對...", "fixCommonErrors": "修正常見錯誤(_F)...", - "checkAndFixNetflixErrors": "檢查並修正 Netfli_x 錯誤...", + "checkAndFixNetflixErrors": "檢查並修正 Netflix 錯誤(_X)...", "listErrors": "列出錯誤(_I)…", "makeEmptyTranslationFromCurrentSubtitle": "從目前字幕建立新的空白翻譯(_E)", "mergeLinesWithSameText": "合併相同文字的字幕行(_M)...", @@ -940,13 +971,13 @@ "translate": "翻譯(_A)", "autoTranslate": "自動翻譯(_A)...", "translateViaCopyPaste": "經由複製貼上自動翻譯(_C)...", - "helpTitle": "幫助(_H)", + "helpTitle": "支援(_H)", "help": "說明(_H)", "about": "關於(_A)...", - "checkForUpdates": "檢查更新(_C)...", + "checkForUpdates": "檢查是否有更新(_C)...", "fixRightToLeftViaUnicodeControlCharacters": "經由 Unicode控制字元 修正 RTL(僅已選取行)", "removeUnicodeControlCharacters": "移除 Unicode控制字元(僅已選取行)", - "reverseRightToLeftStartEnd": "反轉 RTL 起點/終點(僅已選取行)", + "reverseRightToLeftStartEnd": "反轉 RTL 起頭/結尾(僅已選取行)", "pointSync": "點同步(_P)...", "pointSyncViaOther": "經由其他字幕進行點同步(_O)...", "sortSubtitles": "對字幕進行排序(_S)...", @@ -1003,7 +1034,7 @@ "selectCurrentLineWhilePlayingHint": "播放時選取目前字幕 {0}", "videoPosition": "從目前的視訊位置 {0}", "videoPositionTextBox": "視訊位置文字框 {0}", - "toolbarLineBreakX": "斷行 {0}(工具列其餘部分會在新一列繼續)", + "toolbarLineBreakX": "斷行處 {0}(工具列其餘部分會在新一列繼續)", "initialText": "已選取行的初始文字", "initialTextHint": "當該行被選取時,已選取行的文字原本樣子", "hideWaveformToolbar": "隱藏工具列 {0}", @@ -1057,11 +1088,11 @@ "errorLoadBinaryZeroes": "抱歉,此檔案包含僅二進位的零!\n\n如果您曾使用 Subtitle Edit 編輯過此檔案,或許可以經由「檔案 → 還原自動備份……」功能找回備份...", "errorLoadGZip": "此檔案似乎是壓縮的 GZip 檔案。\n\nSubtitle Edit 無法開啟壓縮檔案。", "errorLoadLargeFile": "此檔案太大,無法作為字幕檔開啟。", - "errorLoadVideoFilePrompt": "此檔案似乎是視訊檔案:\n{0}\n\n您可以經由「視訊 -> 開啟視訊」或將視訊拖放至視訊播放器來開啟視訊。\n\n您要將其作為視訊檔案開啟嗎?", + "errorLoadVideoFilePrompt": "此檔案似乎是視訊檔案:\n{0}\n\n您可以經由「視訊 -> 開啟視訊」或將視訊拖曳至視訊播放器來開啟視訊。\n\n確定要將其作為視訊檔案開啟嗎?", "errorLoadJpg": "此檔案似乎是 JPG圖像檔案。\n\nSubtitle Edit 無法開啟圖像檔案。", "errorLoadPng": "此檔案似乎是 PNG圖像檔案。\n\nSubtitle Edit 無法開啟圖像檔案。", "errorLoadRar": "此檔案似乎是 RAR壓縮檔案。\n\nSubtitle Edit 無法開啟壓縮檔案。", - "errorLoadRawPgsPrompt": "此檔案似乎是沒有 Blu-ray SUP「PG」標頭的原始 PGS 串流,因此不包含時間戳記。\n\n這通常發生在以「原始 (raw)」模式提取 Matroska PGS 軌道時。若要取得正確的時間軸,請以一般軌道模式重新匯出該軌道(例如執行「mkvextract tracks」時不要加上 --raw 參數),或直接在 Subtitle Edit 中開啟原件 Matroska 檔案。\n\n您仍要匯入這些字幕圖像嗎?系統將自動生成預設的時間軸,後續需由您手動修復。", + "errorLoadRawPgsPrompt": "此檔案似乎是沒有 Blu-ray SUP「PG」標頭的原始 PGS 串流,因此不包含時間戳記。\n\n這通常發生在以「原始 (raw)」模式提取 Matroska PGS 軌道時。若要取得正確的時間軸,請以一般軌道模式重新匯出該軌道(例如執行「mkvextract tracks」時不要加上 --raw 參數),或直接在 Subtitle Edit 中開啟原件 Matroska 檔案。\n\n無論如何,您仍要匯入字幕圖像嗎(將使用必須手動修正的自動產生占位時間碼)?", "errorLoadSrr": "此檔案似乎是 ReScene SRR 檔案。\n\nSubtitle Edit 無法開啟 SRR 檔案。", "errorLoadTorrent": "此檔案似乎是 BitTorrent 種子檔案。\n\nSubtitle Edit 無法開啟種子檔案。", "errorLoadZip": "此檔案似乎是壓縮的 ZIP 檔案。\n\nSubtitle Edit 無法開啟壓縮檔案。", @@ -1085,7 +1116,7 @@ "waveformFfmpegNotFoundClickToSetUp": "未找到 FFmpeg - 請點擊波形以設置 FFmpeg 並產生波形", "noTextInClipboard": "剪貼簿中無文字", "numberOfLinesEvenlyDistributedX": "已均勻分配 {0} 行", - "oneLineCopiedFromOriginal": "已從原件字幕複製將 1 行", + "oneLineCopiedFromOriginal": "已從原件字幕複製 1 行", "oneLineCopiedToOriginal": "已將 1 行複製到原件字幕", "onlineOnlyVideo": "僅限線上視訊檔案", "onlineOnlyVideoXSizeYDownloadAndOpen": "視訊檔案「{0}」({1})僅儲存於線上,例如 Dropbox 或 iCloud Drive,必須先下載才能開啟。\n\n現在要下載並開啟嗎?", @@ -1094,6 +1125,7 @@ "overwriteExistingCells": "覆寫現有儲存格", "overwriteOrShiftCellsDown": "覆寫/向下位移儲存格", "parsingMatroskaFile": "正在解析 Matroska 檔案...", + "readingDvdSubtitles": "正在閱覽 DVD 字幕...", "pasteFromClipboardDotDotDot": "從剪貼簿貼上...", "redoPerformed": "已執行重做", "redoPerformedXActionLeft": "已執行重做(剩餘操作:{0})", @@ -1133,15 +1165,17 @@ "trimmedXLines": "已裁切 {0} 行字幕", "pastedXLinesOverSelectedLines": "已將 {0} 行貼上已選取行", "pastedXOfYLinesOverSelectedLines": "已將 {1} 行中的 {0} 行貼上已選取行,剩下的超出選取範圍", + "insertedXLinesFromClipboard": "已從剪貼簿插入 {0} 行", "openOriginalDifferentNumberOfSubtitlesXY": "原件字幕檔案的字幕數量與目前字幕檔案的字幕數量不同。\n\n• 原件字幕:{0}\n• 目前字幕:{1}", "originalTextReadOnly": "原件文字(唯讀)", "originalTextEditMode": "原件文字(編輯模式)", "originalIsReadOnlyNotSaved": "原件字幕以唯讀參考,尚未儲存", "originalIsEmptyNotSaved": "原件字幕完全沒有文字,因此未儲存", "originalIsReadOnlyReference": "原件字幕以唯讀參考來開啟", + "closeTranslationQuestionXY": "要關閉翻譯「{0}」嗎?\n\n原件字幕「{1}」將成為您編輯的字幕。翻譯檔案本身不會變更。", "allowEditOfOriginalSubtitle": "允許編輯原件字幕", "showAllOriginalLinesX": "顯示所有原件行({0} 在此未相符)", - "showAllOriginalLinesHint": "未相符行會以額外列顯示,方便您查看翻譯的遺漏處。", + "showAllOriginalLinesHint": "未相符行會以額外列顯示,方便您參閱被遺漏的翻譯。", "showAllOriginalLinesNote": "輸入額外的一列,即可將該行加入字幕", "showMatchingOriginalLinesX": "僅顯示 {0} 行相符的原件字幕行", "showMatchingOriginalLinesHint": "其餘 {0} 行原件字幕行皆不會顯示。", @@ -1149,7 +1183,7 @@ "allowEditHintReadOnly": "原件以唯讀方式顯示且不會儲存,因此其檔案無法更改。", "allowEditHintAllLines": "畫面已顯示完整的原件,因此儲存時會保留每一行。", "allowEditHintMatchingOnlyX": "儲存原件只會寫入顯示的行 - 其餘 {0} 將會遺失。", - "videoOpenedChangeLayoutQuestion": "視訊檔案已開啟。\n\n您想變更佈局以顯示視訊面板嗎?", + "videoOpenedChangeLayoutQuestion": "視訊檔案已經開啟。\n\n確定要變更佈局以顯示視訊面板嗎?", "sortedByStartTime": "已依「演出」時間排序", "sortedByEndTime": "已依「隱藏」時間排序", "sortedByNumber": "依編號排序", @@ -1173,6 +1207,7 @@ "fileExportedInFormatXToY": "檔案已以 {0} 格式匯出至 {1}", "fileExportedInFormatXToFileY": "檔案已以「{0}」格式匯出至檔案「{1}」", "fixedXLines": "已修正 {0} 行", + "toolXChangedYLines": "{0}:{1} 行已變更", "transcriptionCompletedWithXLines": "轉錄已完成 {0} 行", "openSubtitleFileX": "開啟字幕檔「{0}」?", "replacedXOccurrences": "已取代 {0} 處", @@ -1259,10 +1294,25 @@ "subtitleWithManuallyChosenEncodingDotDotDot": "字幕(手動所選編碼)(_S)...", "titleImportImages": "匯入圖像", "importFileLabel": "選擇要匯入的圖像(支援檔名中包含時間碼)", - "importFilesInfo": "使用內含時間碼的檔名:\nstart_HH_MM_SS_MMM__end_HH_MM_SS_MMM[_index].ext\n\n範例:\n0_00_01_042__0_00_03_919_0001.png\n0_00_01_042__0_00_03_919.png\n\n規則:\n• 起始與結尾時間使用 HH_MM_SS_MMM\n• 使用雙底線分隔起點/終點\n• 結尾時間後選填索引", + "importFilesInfo": "使用內含時間碼的檔名:\nstart_HH_MM_SS_MMM__end_HH_MM_SS_MMM[_index].ext\n\n範例:\n0_00_01_042__0_00_03_919_0001.png\n0_00_01_042__0_00_03_919.png\n\n規則:\n• 起始與結尾時間使用 HH_MM_SS_MMM\n• 使用雙底線分隔 起點/終點\n• 結尾時間後選填索引", "formattingDotDotDot": "格式排版...", "imageBasedSubtitleForEditDotDotDot": "編輯基於圖像的字幕...", "imageBasedSubtitleForOcrDotDotDot": "OCR 基於圖像的字幕...", + "dvdSubtitlesDotDotDot": "從 DVD 字幕 (IFO/VOB)...", + "titleImportDvd": "從 DVD 匯入字幕", + "dvdIfoFile": "IFO 檔案", + "dvdVobFiles": "VOB 檔案", + "dvdVideoStandard": "視訊標準", + "dvdStartRipping": "開始翻錄", + "dvdRippingAborted": "已中止翻錄", + "dvdOpenIfoFile": "開啟 DVD IFO 檔案", + "dvdAddVobFiles": "加入 VOB 檔案", + "dvdIfoFiles": "DVD IFO 檔案", + "dvdVobFileType": "DVD VOB 檔案", + "dvdDropHint": "開啟 IFO 檔案 (VIDEO_TS\\VTS_01_0.IFO) 或加入 VOB 檔案,亦可直接拖放至此處", + "dvdNotAnIfoFileX": "{0} 不是 DVD IFO 檔案", + "dvdMenuIfoNoTitleSetsX": "在 {0} 旁找不到任何標題組 (VTS_xx_0.IFO)", + "dvdEncryptedXOfY": "在 {1} 個字幕封包中有 {0} 個仍受 CSS 加密保護 - 這些 VOB 檔案是在未解密的情況下複製的,因此部分字幕圖像將會出現亂碼。\n\n若要獲得正確結果,請先使用 DVD 翻錄工具解密 DVD。\n\n仍要繼續嗎?", "splitTextAt": "拆分文字於", "blankLines": "空白行", "oneLineIsOneSubtitle": "一行為一欄字幕", @@ -1296,7 +1346,7 @@ "lineSpacingPercent": "行間距%", "paddingLeftRight": "左/右 內邊距", "paddingTopBottom": "頂端/底端 內邊距", - "previewTitle": "預覽 - 目前大小:{0}x{1},目標大小:{2}x{3},縮放:{4}%", + "previewTitle": "預覽 - 目前尺寸:{0}x{1},目標尺寸:{2}x{3},縮放:{4}%", "titleExportDvdSup": "DVD sup (MuxMan/Scenarist)", "titleExportVobSub": "VobSub (sub/idx)", "titleExportImscImage": "IMSC 1.1 圖像設定檔", @@ -1343,9 +1393,9 @@ "depth3D": "深度", "depth3DHint": "字幕突出螢幕的距離——正值使其靠近螢幕,負值使其遠離螢幕", "plane3D": "3D 平面", - "plane3DHint": "使用由 BD3D2MK3D 或 OFSExtractor 產生的 3D 平面(.ofs)檔案,為每行字幕指定 3D 藍光儲存的深度。3D 平面沒有深度資訊的部分會使用「深度」設定", + "plane3DHint": "為每個字幕賦予 3D藍光光碟 儲存的深度訊息,該深度資訊來自 BD3D2MK3D 或 OFSExtractor 生成的 3D平面 (.ofs) 檔案。當 3D平面 沒有深度資訊時,則使用此深度資訊。", "openPlane3DTitle": "開啟 3D 平面檔案", - "unableToLoadPlane3DX": "無法載入 3D 平面:{0}", + "unableToLoadPlane3DX": "未能載入 3D 平面:{0}", "plane3DXDepthYToZ": "{0}(深度 {1} 至 {2})", "plane3DXNoDepth": "{0}(無深度值)", "textEffect": "文本效果", @@ -1450,8 +1500,8 @@ "xBackups": "{0}個備份", "openAutoBackupFolder": "開啟自動備份資料夾", "restoreAutoBackupFile": "還原自動備份檔案", - "restoreXFromY": "您想從 {1} 恢復「{0}」嗎?", - "deleteAllSubtitleBackups": "您想刪除所有字幕備份檔嗎?", + "restoreXFromY": "確定要從 {1} 恢復「{0}」嗎?", + "deleteAllSubtitleBackups": "確定要刪除所有字幕備份檔嗎?", "deleteAll": "全部刪除", "subtitles": "字幕", "settingsBackupInfo": "Subtitle Edit 啟動時會備份 Settings.json,最多每 {0} 天備份一次。保留最新的 {1} 個備份。", @@ -1463,11 +1513,11 @@ "backUpSettingsNow": "立即備份", "settingsBackedUp": "目前的設定已備份。", "restoreSettings": "還原設定", - "restoreSettingsFromX": "確定要以 {0} 的備份取代目前的設定嗎?\n\n首先將備份您目前的設定。部分變更必須重新啟動 Subtitle Edit 後才會生效。", - "settingsRestored": "設定已還原。請重新啟動 Subtitle Edit,讓所有變更生效。", + "restoreSettingsFromX": "確定要以 {0} 的備份取代目前設定嗎?\n\n首先將備份您目前的設定。部分變更必須重新啟動 Subtitle Edit 後才會生效。", + "settingsRestored": "已還原設定。請重新啟動 Subtitle Edit,讓所有變更生效。", "settingsRestoreFailed": "無法還原設定:{0}", "settingsBackupNotValid": "備份不是有效的設定檔,因此保留目前的設定", - "deleteAllSettingsBackups": "要刪除所有設定備份檔案嗎?" + "deleteAllSettingsBackups": "確定要刪除所有設定備份檔案嗎?" }, "propertiesDCinema": { "title": "D-Cinema SMPTE 屬性", @@ -1524,15 +1574,65 @@ "compareTextOrTimeDifference": "文字/時間差異", "compareNumberDifference": "編號差異", "ignoreWhitespace": "忽略空白字元", - "ignoreWhitespaceHint": "僅在空格、定位字元或斷行有差異的行,未曾計入為不同", + "ignoreWhitespaceHint": "僅在空格、定位字元或斷行處不一致的行,未計入為不同", "ignoreFormatting": "忽略格式排版", - "ignoreFormattingHint": "僅在格式排版標籤(像 或 {\\an8})有差異的行,未曾計入為不同", - "showOnlyDifferencesInText": "僅文字上的差異", + "ignoreFormattingHint": "僅在格式排版標籤(像 或 {\\an8})不一致的行,未計入為不同", + "showOnlyDifferencesInText": "僅文字內不同", + "ignoreNumbering": "忽略編號數", + "ignoreNumberingHint": "僅編號不一致的行未計入為不同", + "ignoreTimeDifferenceMs": "時間容許誤差(毫秒)", + "ignoreTimeDifferenceMsHint": "起頭和結尾時間相差這麼多毫秒數或更少則未計入為不同", + "compareDifferences": "差異", + "compareTextDifferences": "文字差異", + "compareCurrent": "目前", + "compareReference": "參考", + "compareEditable": "可編輯", + "compareEditableHint": "點擊「套用」時,在此處所作的變更將寫回字幕", + "compareReadOnly": "唯讀", + "compareTakeFromReference": "從參考採取文字與時間軸", + "compareInsertFromReference": "將此行插入目前字幕", + "compareDeleteFromCurrent": "從目前字幕刪除此行", + "compareTakeText": "採取文字", + "compareTakeTiming": "採取時間軸", + "compareEditLine": "編輯行", + "compareEditHint": "按 Ctrl+Enter 鍵儲存,Esc 鍵取消", + "compareEdited": "已編輯", + "compareOnePendingChange": "1 項有待變更", + "compareXPendingChanges": "{0} 項有待變更", + "compareDiscardXChanges": "要拋棄在「比對」中所做的 {0} 項變更?", + "compareXChangesApplied": "比對:已應用 {0} 個變更", + "compareChangeTextX": "根據參考的 #{0} 文字", + "compareChangeTimingX": "根據參考的 #{0} 時間軸", + "compareChangeTextAndTimingX": "根據參考的 #{0} 文字及時間軸", + "compareChangeEditedX": "已編輯 #{0}", + "compareChangeInsertedX": "已插入 #{0}", + "compareChangeDeletedX": "已刪除 #{0}", + "compareSyncPoint": "同步點", + "compareSyncPointHint": "同步點 - 這兩行會始終成對顯示,且其上方與下方的行會各自對齊", + "compareSyncPickCurrent": "同步點:使用此目前行", + "compareSyncPickReference": "同步點:使用此參考行", + "compareSyncRemove": "移除同步點", + "compareClearXSyncPoints": "清除同步點 ({0})", + "compareSyncCurrentPickedX": "目前 #{0} 被挑選為同步點 - 請選取相符參考行並點擊「同步」", + "compareSyncReferencePickedX": "參考 #{0} 被挑選為同步點 - 請選取相符目前行並點擊「同步」", + "compareSyncWithCurrentX": "與目前 #{0} 同步", + "compareSyncWithReferenceX": "與參考 #{0} 同步", + "compareSyncApply": "同步", + "compareSyncPointSetXY": "同步點設置:目前 #{0} 與參考 #{1} 現在是成對顯示", + "compareSyncAlreadyPairedXY": "目前 #{0} 與參考 #{1} 已經以相同起點又成對顯示 - 無需同步", + "compareSyncPointShiftedXYZW": "同步點設置:目前 #{0}-#{1} 已偏移 {2} 到以參考 #{3} 的起點", + "compareChangeSyncShiftXYZ": "#{0}-#{1} 已偏移 {2}(同步點)", + "compareTimingDeltaHint": "參考的起點與終點比目前行偏晚 (+) 或偏早 (−) 多少", "loadXFromFile": "從檔案載入「{0}」", - "saveCompareHtmlTitle": "儲存比對結果為 HTML 檔案", + "compareLoadSavedFile": "讀取已儲存檔案", + "saveCompareHtmlTitle": "儲存比對 HTML 檔案", "pickMatroskaTrackX": "挑選 Matroska 軌道 - {0}", "pickTransportStreamTrackX": "挑選傳輸串流軌道 - {0}", + "chapters": "章節", + "dvdVobFilesMissingX": "遺漏的 VOB 檔案(找到 {0}%)", "pickMp4TrackX": "挑選 MP4 軌道 - {0}", + "pickMxfTrackX": "挑選 MXF 軌道 - {0}", + "pickMpegTrackX": "挑選 MPEG 軌道 - {0}", "rosettaProperties": "Timed Text Rosetta IMSC 屬性", "rosettaFontSize": "字型大小(列高)", "propertyTimeBase": "時間基準", @@ -1584,7 +1684,7 @@ "hearingImpairedLineContains": "行包含", "hearingImpairedMusicSymbols": "僅音樂符號", "hearingImpairedInterjections": "感嘆詞", - "hearingImpairedSettingsHint": "詳細資訊來自「工具 > 移除聽障輔助文字」", + "hearingImpairedSettingsHint": "詳情來自「工具 > 移除聽障輔助文字」", "selectionNew": "新增選取範圍", "selectionAdd": "增加選取範圍", "selectionSubtract": "從選取範圍中減去", @@ -1607,7 +1707,8 @@ "findWhat": "尋找目標", "descriptionOptional": "說明(選填)", "invalidRegularExpressionX": "無效的正規表達式:{0}", - "regularExpressionTooSlowX": "正規表達式在 {0} 秒後逾時 - 已略過此規則,請嘗試更簡單的模式" + "regularExpressionTooSlowX": "正規表達式在 {0} 秒後逾時 - 已略過此規則,請嘗試更簡單範本", +"moveToCategory": "移至分類" }, "find": { "searchTextWatermark": "搜尋文字...", @@ -1639,14 +1740,14 @@ "inCharacterGroup": "在字元群組內 ([test])", "notInCharacterGroup": "不在字元群組內 ([^test])" }, - "showHistory": "操作復原紀錄", + "showHistory": "復原的操作紀錄", "restoreSelected": "還原已選取項", "noSubtitleSelected": "無字幕被選取", "pleaseSelectExactlyOneSubtitle": "請剛好選取一條字幕。" }, "sourceView": { - "discardChangesTitle": "放棄變更?", - "discardChanges": "來源文字已變更。\n是否放棄變更?", + "discardChangesTitle": "拋棄變更?", + "discardChanges": "來源文字已經變更。\n要拋棄變更?", "xLinesYSubtitles": "{0} 行,{1} 則字幕", "xLinesYSubtitlesZErrors": "{0} 行,{1} 則字幕,{2} 個錯誤", "couldNotParseAsX": "無法解析為 {0}", @@ -1654,7 +1755,7 @@ "selectedXCharactersYLines": "已選取:{1} 行中的 {0} 個字元", "replacedXOccurrences": "已取代 {0} 處", "invalidRegularExpression": "無效的正規表達式", - "regularExpressionTooSlowX": "正規表達式在 {0} 秒後逾時 - 請嘗試更簡單的模式", + "regularExpressionTooSlowX": "正規表達式在 {0} 秒後逾時 - 請嘗試更簡單範本", "moveLineUp": "向上移動行", "moveLineDown": "向下移動行", "duplicateLine": "複製行", @@ -1687,7 +1788,22 @@ "mismatchWarning": "與原件不相似 - 可能屬於不同的行", "engineError": "無法連線至 AI 引擎:{0}", "playCurrentLineHint": "在視訊播放器中播放已選取行,並在其終點暫停(也可雙擊某行)", - "editAfterHint": "點擊已選取列的文字,可在進行應用修正之前編輯(Enter 儲存、Esc 取消)" + "editAfterHint": "點擊已選取列的文字,可在進行應用修正之前編輯(Enter 儲存、Esc 取消)", + "template": "範本", + "templatePlaceholder": "選擇範本以取代提示詞", + "templateStandard": "標準校稿", + "templateSpeechToText": "語音轉文字清理", + "templateMachineTranslation": "機器翻譯修正", + "templateSdh": "SDH / 聽障輔助", + "referenceContext": "參考上下文", + "referenceContextInfo": "選填。名稱、角色、詞彙表、概觀或樣式注意事項 - 模型會將此當成名稱與術語的權威依據。每次批次皆會隨附傳送。", + "referenceContextPlaceholder": "名稱:海斗、梅阿姨、霍利斯醫生\n專有名詞:新幹線、曲速核心\n概觀:海斗前往東京尋找失蹤的父親。", + "generateContext": "使用 AI 產生", + "generateContextHint": "讓已選取 AI 引擎讀取字幕並草擬名稱、術語及概觀 - 使用前請檢查結果", + "generatingContextPartXOfY": "閱覽第 {0} / {1} 部分...", + "generatingContextSynopsis": "正在撰寫概觀...", + "replaceContextQuestion": "要取代目前的參考上下文嗎?", + "noContextGenerated": "AI 引擎未回傳任何可用的參考資訊。" }, "aiAssistant": { "title": "AI 助理", @@ -1709,14 +1825,14 @@ "next": "下一步(&N) >", "fixes": "修正項", "removedEmptyLine": "移除空行", - "ruleDetailsDotDotDot": "規則詳細資訊...", + "ruleDetailsDotDotDot": "規則詳情...", "showOnlyThisRule": "僅顯示此規則的修正項", "appliedRulesForLineX": "第 {0} 行已應用的規則", "ruleX": "規則:{0}", "removedEmptyLineAtTop": "移除頂部空行", "removedEmptyLineAtBottom": "移除底端空行", "removedEmptyLineInMiddle": "移除中間空行", - "removedEmptyLinesUnusedLineBreaks": "移除空白行/未使用的斷行", + "removedEmptyLinesUnusedLineBreaks": "移除空白行/未使用的斷行處", "fixOverlappingDisplayTimes": "修正重疊的顯示時間", "fixShortDisplayTimes": "修正過短的顯示時間", "fixLongDisplayTimes": "修正過長的顯示時間", @@ -1732,7 +1848,8 @@ "removeLineBreaksAll": "移除短文字中的斷行(對話除外)", "removeLineBreaksPixelWidth": "將可容納於單行(像素寬度)的字幕取消斷行", "fixUppercaseIInsideLowercaseWords": "修正小寫單字中的大寫 'I'(OCR 錯誤)", - "fixDoubleApostrophes": "將雙單引號 ('') 修正為雙引號 (\")", + "fixDoubleApostrophes": "將雙個單引號字元 ('') 修正為雙引號 (\")", + "fixMisreadQuotes": "修正誤讀為雙引號的單引號(OCR、英文)", "addPeriods": "若下一行起頭為大寫字母,則在行尾加入句點", "startWithUppercaseLetterAfterParagraph": "段落後起頭的起頭字母大寫", "startWithUppercaseLetterAfterPeriodInsideParagraph": "段落內句點後的起頭字母大寫", @@ -1755,6 +1872,7 @@ "fixCommasExample": ",, -> ,", "removeLineBreaksExample": "Foo
bar! -> Foo bar!", "fixDoubleApostrophesExample": "''有雙單引號'' -> \"有單個雙引號\"", + "fixMisreadQuotesExample": "\"Hello' -> \"Hello\"", "addPeriodsExample": "Hello world -> Hello world.", "startWithUppercaseLetterAfterParagraphExample": "p1: Foobar! || p2: foobar! -> p1: Foobar! || p2: Foobar!", "startWithUppercaseLetterAfterPeriodInsideParagraphExample": "Hello there! how are you? -> Hello there! How are you?", @@ -1766,10 +1884,10 @@ "analysing": "正在分析...", "nothingToFix": "沒有需要修正的項目 :)", "log": "Log記錄", - "nothingFixableBut": "沒有任何項目可以自動修正。字幕包含錯誤 - 詳情請看記錄LOG", - "xFixedBut": "已修正 {0} 個問題,但字幕仍包含錯誤 - 詳情請看記錄LOG", - "xCouldBeFixedBut": "可以修正 {0} 個問題,但字幕修正後仍會包含錯誤 - 詳情請看記錄LOG", - "numberOfImportantLogMessages": "{0} 則重要記錄LOG訊息!", + "nothingFixableBut": "沒有任何項目可以自動修正。字幕包含錯誤 - 詳情請參閱日誌", + "xFixedBut": "已修正 {0} 個問題,但字幕仍包含錯誤 - 詳情請參閱日誌", + "xCouldBeFixedBut": "可以修正 {0} 個問題,但字幕修正後仍會包含錯誤 - 詳情請參閱日誌", + "numberOfImportantLogMessages": "{0} 則重要日誌訊息!", "fixedOkXY": "已修正且沒有問題 - 「{0}」:{1}", "xFixesApplied": "已應用修正項:{0}", "xFixesYSelected": "{0} 件修正項 - 已選取 {1} 件", @@ -1788,9 +1906,9 @@ "fixUppercaseIInsideLowercaseWordsExample": "The earth is fIat. -> The earth is flat.", "fixLowercaseIToUppercaseIExample": "What do i care. -> What do I care.", "startTimeLaterThanEndTime": "文字編號 {0}:起頭時間晚於結尾時間:{4}{1} -> {2} {3}", - "unableToFixStartTimeLaterThanEndTime": "無法修正文字編號 {0}:起頭時間晚於結尾時間:{1}", + "unableToFixStartTimeLaterThanEndTime": "未能修正文字編號 {0}:起頭時間晚於結尾時間:{1}", "xFixedToYZ": "{0} 已修正為:{1}{2}", - "unableToFixTextXY": "無法修正文字編號 {0}:{1}", + "unableToFixTextXY": "未能修正文字編號 {0}:{1}", "unneededSpace": "多餘的空格", "unneededPeriod": "多餘的句點", "fixMissingSpace": "修正遺漏的空格", @@ -1821,7 +1939,7 @@ "goToApplyFixes": "跳至應用修正項", "backToFixList": "返回修正列表", "fixCommonOcrErrorsStep2": "修正常見錯誤,步驟 2(應用修正項)", - "fixCommonOcrErrorsStep2FixesFoundX": "修正常見錯誤,第 2 步 - 發現的修正項:{0}", + "fixCommonOcrErrorsStep2FixesFoundX": "修正常見錯誤,步驟 2 - 發現的修正項:{0}", "action": "動作", "applySelectedFixes": "應用已選取修正項", "unbreakShortLine": "將短行取消斷行", @@ -1844,6 +1962,8 @@ "fixMinDurationMs": "修正最小持續時間(毫秒)", "doNotGoPastShotChange": "請勿超過場景變更線", "fixMaxDurationMs": "修正最大持續時間(毫秒)", + "fixMinDurationFrames": "修正最小持續時間(影格)", + "fixMaxDurationFrames": "修正最大持續時間(影格)", "minimumDurationMilliseconds": "最小持續時間(毫秒數):", "maximumDurationMilliseconds": "最大持續時間(毫秒數):", "maxDurationShouldBeHigherThanMinDuration": "最長持續時間應高於最短持續時間", @@ -1881,7 +2001,7 @@ "previousChange": "上一個變更", "nextChange": "下一個變更", "editProfile": "編輯設定檔...", - "batchSnapToShotChanges": "貼齊提示點至場景變更線(若可用)", + "batchSnapToShotChanges": "貼齊提示點至場景變更線(若適用)", "batchFrameRateFromVideo": "使用與字幕檔案同名的視訊檔案之影格率", "batchFrameRateFixed": "使用固定影格率", "batchInfo": "若找到與字幕檔同名的視訊檔案,將從中讀取場景變更線資訊。", @@ -1891,26 +2011,40 @@ "noTimeCodesLoaded": "未讀取到時間碼", "xTimeCodesLoaded": "已讀取 {0} 個時間碼", "timeCodesExtractFailed": "無法從視訊中讀取時間碼", - "timeCodesHint": "貼齊提示點至視訊的實際影格時間,而非假設固定影格率。需要完整讀取一次視訊,將已存結果快取。" + "timeCodesHint": "貼齊提示點至視訊的實際影格時間,而非假設固定影格率。需要完整閱覽一次視訊,將已存結果快取。" }, "improveTimeCodes": { - "title": "改進時間碼 (強制對齊)", + "title": "改進時間碼 (強制對齊方式)", "aligner": "對齊器", "alignerHint": "用於在音訊中聆聽每行字幕的模型。最適合該字幕語言的選項會優先列出。", "maxShift": "最大偏移量 (秒)", - "maxShiftHint": "字幕可不同步的最大幅度。\n若某行起點會移動超限,則保持原樣並加上標記。\n\n無論數值為何,若某一行字幕欲移動的距離比周圍行多出半秒上限,不會盲目移動:當周圍行都移動時,它會跟隨鄰近字幕移動;否則會以未勾選狀態提供您檢查。", + "maxShiftHint": "字幕可不同步的最大幅度。\n若某行起點會移動超限,則保持原樣並加上標記。\n\n無論數值為何,若某一行字幕欲移動的距離比周圍行多出半秒上限,不會盲目移動:當周圍行都移動時,它會跟隨鄰近字幕移動;否則提供未勾選,供您檢查。", "adjustStartTimes": "調整起頭時間", "adjustEndTimes": "調整結尾時間", "isolateSpeech": "先隔離語音 (較慢)", "isolateSpeechHint": "在對齊之前先移除音樂與音效,使對齊器僅聆聽對白。有助於在吵雜的音樂或動作聲中朗讀的台詞。但使用 GPU 耗時大約與音訊同樣時長 - 而沒用 GPU 則耗時更長。", "isolatingSpeech": "正在隔離語音... (這需要一些時間)", "isolateSpeechFailed": "無法隔離語音 - 已改為對照原件音訊進行對齊。", + "checkWithSpeechToText": "以語音轉文字檢查", + "checkWithSpeechToTextHint": "同時轉錄音訊,並對照實際聽取單字位置來檢查每次位移。語音確認的大幅度移動會自動為您勾選;而偏離實際聽取位置的字幕行則會留待未勾選,供您檢查。也顯示了每行字幕被聽取程度 - 低比例意味著文字與實際表達不同。對於同步偏差超過最大位移的字幕 - 持續的偏移、影格率不符導致的漂移,或因剪輯產生的跳躍 - 會優先依據聽取單字進行同步。使用 Crisp ASR Parakeet,以 GPU 處理一部長片約需數分鐘。", + "checkWithSpeechToTextNotAvailableX": "語音轉文字檢查功能不適用於 {0}", + "transcribingToCheck": "進行轉錄以檢查對齊方式...", + "speechToTextFailed": "語音轉文字已失敗 - 時間碼未針對它檢查。", + "speechToTextX": "語音轉文字:{0}", + "speechToTextConfirmedXDisputedY": "語音:{0} 個已確認,{1} 個有疑義", + "hardlyHeardX": "語音轉文字僅聽取該字幕單字的 {0}%。字幕語言是否與朗讀一致?對齊器不能夠安置翻譯。", + "heard": "聽取", + "heardHint": "語音轉文字聽取該行多少內容。低比例意味著文字與實際表達不同 - 這也是對齊器最容易出錯之處。", + "statusConfirmedBySpeech": "語音已確認", + "statusDisputedBySpeech": "於原點聽取", + "statusMovedWithSync": "隨同步移動", + "syncedFirstXY": "優先同步:於起點 {0} 秒,於終點 {1} 秒", "showSpeechOnly": "僅顯示語音", "showSpeechOnlyHint": "根據已移除音樂、音效的音訊繪製已對齊波形;原件波形保持不變,以供比對。\n語音隔離完成後即可使用 - 可於此處或主視窗波形圖中的「僅顯示語音」開啟。", "align": "對齊", "original": "原件", "aligned": "已對齊", - "intro": "挑選對齊器並按下「對齊」,即可聆聽在音訊中每行並上緊時間碼。", + "intro": "挑選對齊器並按下「對齊」,在音訊中聆聽每行字幕並上緊時間碼。", "extractingAudio": "正在提取音訊...", "aligningBatchXOfY": "正在對齊... 第 {0}/{1} 批次", "extractAudioFailed": "無法使用 ffmpeg 提取音訊。", @@ -1918,7 +2052,8 @@ "summaryXRetimedYKeptZSkipped": "重新對齊時間軸:{0} · 保留:{1} · 無語音:{2}", "meanShiftX": "平均偏移量:{0} 毫秒", "toCheckX": "待檢查:{0}", - "largeMovesToCheckX": "有 {0} 行字幕將自行大幅移動因而未勾選 - 播放「原件/已對齊」並勾選正確的項目。", + "largeMovesToCheckX": "尚有 {0} 行位移幅度過大是留待未勾選 - 請聆聽這些行(F5 播放對齊後的行,Shift+F5 播放原行),並勾選正確那一個。", + "linesToCheckX": "尚有 {0} 行是留待未勾選以檢查 - 請聆聽這些行(F5 播放對齊後的行,Shift+F5 播放原行),並勾選正確那一個。", "changeXOfY": "變更第 {0}/{1} 個", "previousChange": "上一個變更", "nextChange": "下一個變更", @@ -1926,18 +2061,20 @@ "endShift": "終點", "statusRetimed": "重新對齊時間軸", "statusMovedWithNeighbours": "已隨鄰近字幕移動", - "statusLargeMoveUnconfirmed": "移動幅度較大 - 聆聽,勾選以應用", + "statusLargeMoveUnconfirmed": "大幅移動 - 聆聽,勾選以應用", "statusUnchanged": "位置未變", "statusNoSpeech": "無語音", "statusShiftTooLarge": "已保留 - 偏移量過大", "statusFailed": "已保留—對齊器失敗", + "statusAdjustedByHand": "手動調整", + "alignedWaveformHint": "拖曳線條來移動它,或拖曳其邊緣以變更起點或終點。", + "playLine": "播放該行", + "undoAdjustment": "復原調節", "playPause": "播放 / 暫停", - "playOriginal": "原件", "playAligned": "已對齊", - "playOriginalHint": "使用原件時間碼播放已選取行", "playAlignedHint": "播放已選取行伴隨已對齊時間碼", "apply": "應用", - "applyHint": "取消勾選字幕行以保留其原件時間碼。雙擊字幕行即可播放。" + "applyHint": "取消勾選字幕行以保留其原件時間碼。雙擊字幕行就可播放。亦可在對齊波形中移動各行或調整其長度。" }, "beautifyTimeCodesProfile": { "title": "編輯美化時間碼設定檔", @@ -1975,7 +2112,7 @@ "hintGap": "字幕之間最小間隔(以影格為單位)。下方所有「間隔」欄槽皆以此為基準值。", "hintZones": "以最近的場景變更線為基準,計算貼齊(紅)與警告(綠)的閥值(影格)。\n四個數值由左至右依序為:左綠、左紅、右紅、右綠。", "hintConnected": "當兩行字幕(間隔低於閾值)緊鄰時,將保留與最近提示點之間的間隔。", - "hintChaining": "如何處理幾乎相鄰的字幕 — 根據中間是否有場景變更線,決定要將字幕延伸在一起或推開。" + "hintChaining": "如何處理字幕幾乎互碰 — 取決於中間是否有場景變更線,決定要將其延伸相連或推隔開。" }, "bridgeGaps": { "bridgeGapsSmallerThan": "填補小於此值的間隔(毫秒)", @@ -1998,7 +2135,7 @@ "sortByEndTime": "結尾時間" }, "batchConvert": { - "title": "批量轉換", + "title": "批次轉換", "oneActionsSelected": "已選取 1 個動作", "xActionsSelected": "已選取 {0} 個動作", "outputFolderSource": " 輸出資料夾:來源資料夾", @@ -2007,7 +2144,7 @@ "targetFormatSettings": "目標格式設定", "fileNameContainsDotDotDot": "檔案名稱包含...", "trackLanguageContainsDotDotDot": "軌道語言包含...", - "batchConvertSettings": "批量轉換設定", + "batchConvertSettings": "批次轉換設定", "assaSource": "ASSA 原始碼", "addFormatting": "增加格式排版", "addItalic": "增加斜體", @@ -2016,7 +2153,8 @@ "addAlignment": "增加對齊方式", "addColor": "增加顏色", "deleteLinesWithSpecificActorsOrStyles": "刪除特定角色或樣式的字幕行(多個項目請以逗號分隔)", - "useSourceStylesIfPossible": "如果可能的話使用來源樣式", + "useSourceStylesIfPossible": "盡可能使用來源樣式(下方的標頭僅用於 非ASSA 輸入)", + "keepSourceEmbeddedFonts": "保留來源內嵌字型(註腳)", "editStyles": "編輯樣式", "editProperties": "編輯屬性", "editAttachments": "編輯附件", @@ -2033,7 +2171,7 @@ "assaEmbedFontsTitle": "內嵌字型", "assaEmbedFontsInfo": "將字幕所使用到的字型檔案(包含樣式與內聯字型標籤)內嵌至輸出檔案的 [Fonts] 區段中。軟體會於 Subtitle Edit 字型資料夾及系統字型資料夾中搜尋字型;已內嵌的字型則會保留。本功能僅適用於目標格式為 Advanced Sub Station Alpha 時。", "adjustImageColorsTitle": "調整圖像亮度/Alpha/顏色", - "adjustImageColorsInfo": "僅適用於將圖像字幕轉換為圖像格式時(例如藍光 SUP 轉換為藍光 SUP)。", + "adjustImageColorsInfo": "僅適用基於圖像的字幕轉換至基於圖像的格式時(例如:藍光(sup)至 藍光(sup))。", "assaChangeStyleFromStyle": "變更樣式從", "assaChangeStyleToStyle": "至", "assaChangeStyleImportStyle": "匯入樣式...", @@ -2051,6 +2189,18 @@ "addFolderRecursiveDotDotDot": "遞迴新增資料夾…", "selectFolderToConvert": "挑選要轉換檔案的資料夾所在", "includeSubfolders": "新增資料夾時包括子資料夾", + "videoFilesWhenAddingFolder": "加入資料夾時的視訊檔案 (mkv, mp4, ts...)", + "videoFilesAsk": "詢問", + "videoFilesInclude": "加入其字幕軌道", + "videoFilesSkip": "略過", + "folderContainsXVideoFiles": "資料夾包含 {0} 個視訊檔案 (mkv, mp4, ts...)。\n\n要一併加入其內嵌字幕軌道,還是僅加入字幕檔案?", + "addVideoFiles": "加入視訊檔案", + "skipVideoFiles": "僅字幕檔案", + "doNotAskAgainVideoFiles": "不再詢問(可在「批次轉換」設定中變更)", + "settingsSectionOutput": "輸出", + "settingsSectionAddingFiles": "正在加入檔案", + "settingsSectionImageBasedSubtitles": "基於圖像的字幕 (OCR)", + "settingsSectionOther": "其它", "keepSourceFileTimestamp": "在輸出檔案中保留來源檔案的日期/時間", "preventSleepWhileConverting": "轉換時防止電腦進入睡眠", "scanningFolderX": "正在掃描 {0}……", @@ -2062,18 +2212,31 @@ "transportStreamOverrideVideoSize": "覆寫原件視訊尺寸", "transportStreamBottomMargin": "底部邊距", "transportStreamFileNameEnding": "檔案名稱結尾", - "transportStreamFileNameEndingInfo": "會新增在每個已提取軌道的副檔名之前。留空則使用一般的語言後綴。", + "transportStreamFileNameEndingInfo": "會新增在每個已提取軌道的副檔名之前。留空則使用常規語言後綴。", "transportStreamOnlyTeletext": "僅電傳視訊", "transportStreamGetSizeFromVideo": "從視訊取得尺寸…", "twoLetterLanguageCodeUppercase": "兩字母語言代碼(大寫)", - "threeLetterLanguageCodeUppercase": "三字母語言代碼(大寫)" + "threeLetterLanguageCodeUppercase": "三字母語言代碼(大寫)", + "formatContainsDotDotDot": "格式包含...", + "extensionIsDotDotDot": "副檔名為...", + "statusIsError": "狀態為錯誤", + "forcedTracksOnly": "僅強制軌道", + "xConvertedYFailedInZ": "已轉換 {0:#,###,##0} 個,{2} 內已失敗 {1:#,###,##0} 個", + "xNotProcessed": "{0:#,###,##0} 個未處理", + "removeUnicodeControlCharactersTitle": "移除 Unicode 控制字元", + "removeUnicodeControlCharactersInfo": "從文字移除不可見的 Unicode 方向控制字元(LRM、RLM、LRE、RLE、PDF、LRO 及 RLO),並將不斷行空格替換為一般空格。", + "preset": "預設集", + "savePresetDotDotDot": "儲存預設集...", + "deletePreset": "刪除預設集", + "presetName": "預設集名稱", + "deletePresetX": "要刪除預設集「{0}」嗎?" }, "changeCasing": { "fixNames": "修正名稱", "namesX": "名稱:{0:#,##0}", "hitsX": "符合項:{0:#,##0}", "extraNames": "額外名稱", - "enterExtraNamesHint": "輸入要修復的額外名稱,以逗號分隔", + "enterExtraNamesHint": "輸入要修正的額外名稱,以逗號分隔", "onlyFixUppercaseLines": "僅修正大寫行", "fixNamesOnly": "僅修正名稱", "allUppercase": "全部大寫", @@ -2112,7 +2275,7 @@ }, "splitSubtitle": { "title": "拆分字幕", - "numberOfEqualParts": "相等部份的數量", + "numberOfEqualParts": "相等部分的數量", "saveSplitParts": "儲存拆分部分(_S)", "xPartsSavedInFormatYToFolder": "{0} 部分以 {1} 格式儲存到資料夾:" }, @@ -2121,7 +2284,7 @@ "splitLongLines": "拆分長行(為多行)", "rebalanceLongLines": "重新平衡長行", "rebalanceOnlyLinesTooLong": "僅處理過長的字幕行", - "rebalanceOnlyLinesTooLongHint": "當所有行均在單行最大長度限制內時,則保留現有斷行不變 - 刻意不平均的字幕在編輯上可能是正確的", + "rebalanceOnlyLinesTooLongHint": "當所有行均在單行最大長度限制內時,則保留現有斷行處不變 - 刻意不平均的字幕在編輯上可能是正確的", "splitLongLine": "拆分長行", "rebalanceLongLine": "重新平衡長行", "splitIntoXLines": "拆分為 {0} 行:「{1}」→「{2}...」", @@ -2151,11 +2314,13 @@ }, "mergeLinesWithSameText": { "maxMsBetweenLines": "行之間的最大毫秒數", + "maxFramesBetweenLines": "行之間的最大影格", "includeIncrementingLines": "包含文字遞增的行", "includeRollUpCaptions": "包含捲簾式(滾動式)聽障字幕" }, "mergeLinesWithSameTimeCodes": { "maxMsDifference": "最大時間差(毫秒數)", + "maxFramesDifference": "最大時間差(影格)", "makeDialog": "製作對話" }, "netflixCheckAndFix": { @@ -2184,7 +2349,7 @@ "sdh": "SDH(聽障/聽損)", "glyphCheckReport": "在第 {1} 欄發現無效字元 {0}", "whiteSpaceCheckForXReport": "在第 {1} 欄發現空白字元問題 ({0})。", - "whiteSpaceBeforePunctuation": "標點符號前缺失", + "whiteSpaceBeforePunctuation": "標點符號前遺漏的", "whiteSpaceLineEnding": "行尾符號", "whiteSpaceCheckConsecutive": "2 個以上連續顯示", "twoLinesMaximum": "最多兩行", @@ -2228,9 +2393,9 @@ "setText": "設置文字...", "screenWidth": "螢幕寬度", "screenHeight": "螢幕高度", - "alphaThresholdInfo": "Alpha 低於閾值的像素會變為完全透明", + "alphaThresholdInfo": "Alpha 低於閾值的像素會變成完全透明", "resetToDefaults": "重置為預設值", - "alphaAdjustmentInfo": "Alpha 調整值:對 Alpha 通道進行加/減運算。\n預覽會自動更新,並以棋盤格背景顯示,以便查看透明效果。", + "alphaAdjustmentInfo": "Alpha 調節:對 Alpha 通道進行加/減運算。\n預覽會自動更新,並以棋盤格背景可視化透明度。", "adjustBrightness": "調整亮度", "brightness": "亮度", "contrast": "對比", @@ -2270,7 +2435,14 @@ "xInTopBar": "{0} 條在頂端黑邊", "xInBottomBar": "{0} 條在底端黑邊", "noImageSubtitlesLoaded": "未讀取到圖像字幕", - "imageBasedFormatNotSupported": "未找到/不支援圖像字幕格式。" + "imageBasedFormatNotSupported": "未找到/不支援圖像字幕格式。", + "frameRateDeclaredInFile": "藍光(sup)檔案中宣告的影格率", + "frameRateDetectedFromTimeCodesX": "從 藍光(sup)時間碼偵測到的影格率(檔案宣告為 {0})", + "frameRateFromVideo": "視訊的影格率", + "frameRateCurrent": "目前影格率", + "frameRateSetManually": "手動設置影格率", + "frameRateUsageInfo": "Blu-ray(sup)、BDN XML、DOST 及 FCP 的匯出以此影格率寫入。這不會變更任何時間 - 若需變更請使用「變更影格率」。", + "useVideoFrameRateXInsteadOfY": "視訊的影格率為 {0},但字幕是設置 {1}。\n\n要改用視訊的影格率 ({0}) 嗎?" }, "removeTextForHearingImpaired": { "interjections": "感嘆詞", @@ -2300,12 +2472,13 @@ "pickFontNameInstalledFonts": "已安裝的字型", "pickFontNameCollectedFonts": "已收集的字型", "colorPickerTitle": "選擇顏色", + "colorPickerBrightness": "亮度", "filterLayersTitle": "篩選顯示的圖層", "filterLayersHideFromWaveform": "從波形/頻譜圖中隱藏", "filterLayersHideFromSubtitleGrid": "從字幕網格中隱藏", "filterLayersHideFromVideoPreview": "從視訊預覽中隱藏", "pickSubtitleFormat": "選擇字幕格式", - "pickSubtitleFormatImageBasedNoPreview": "以圖像為基礎的字幕格式。\n轉換過程中會產生點陣圖 - 無文字預覽。", + "pickSubtitleFormatImageBasedNoPreview": "基於圖像的字幕格式。\n轉換過程中會產生點陣圖 - 無文字預覽。", "recentColors": "近期顏色", "fixCommonErrorsSettingsTitle": "修正常見錯誤之設定" }, @@ -2326,7 +2499,7 @@ "loadSourceImage": "同時顯示來源圖像…", "lineXofY": "拼字檢查 - 第 {0} / {1} 行", "changeWordFromXToY": "將單字從「{0}」變更為「{1}」", - "changeAllWordsFromXToY": "將所有單字從「{0}」變更為「{1}」", + "changeAllWordsFromXToY": "從「{0}」至「{1}」變更全部單字", "ignoreWordXOnce": "忽略單字「{0}」一次", "ignoreWordXAlways": "始終忽略單字「{0}」", "wordXAddedToNamesList": "單字「{0}」已新增至名稱列表中", @@ -2350,12 +2523,26 @@ "correctWordsX": "正確單字:{0}", "namesX": "人名/地名:{0}", "addedToDictionaryX": "已新增至字典:{0}", - "doNotShowThisAgain": "請勿再顯示此訊息" + "doNotShowThisAgain": "請勿再顯示此訊息", + "editUseAlwaysList": "編輯「始終使用」列表...", + "useAlwaysListTitle": "拼字檢查 - 「始終使用」列表", + "useAlwaysListHeader": "「始終使用」列表", + "useAlwaysListDescription": "單字拼字檢查自動替換,經由點擊「變更全部」及「始終使用」儲存。當單字拼字錯誤時才使用該配對。", + "useAlwaysWord": "拼字錯誤的單字", + "xPairs": "{0:#,##0} 個配對", + "xUnusedPairs": "{0:#,##0} 個從未使用", + "useAlwaysUnusedHint": "從未使用 - 此單字拼寫正確(或屬於名字/使用者自訂單字),因此拼字檢查從未將其標記", + "removeUnused": "移除未使用項", + "useAlwaysEmpty": "該語言仍無「始終使用」配對", + "useAlwaysEmptyHint": "當您在拼字檢查中點擊「變更全部」或「始終使用」時,就會新增配對。", + "useAlwaysNoMatch": "沒有符合搜尋條件的配對", + "useAlwaysSameWord": "取代項必須與原單字不同。", + "useAlwaysRememberOff": "設定內【記住「始終使用」列表】已關閉,因此拼字檢查不會使用或儲存此列表。" }, "video": { "burnIn": { "title": "產生燒錄字幕的視訊", - "targetFileSize": "目標檔案容量 (需要2次編碼)", + "targetFileSize": "目標檔案大小 (需要2次編碼)", "fileSizeMb": "檔案大小 (MB)", "matchSourceVideoSize": "相符來源視訊大小", "bitRate": "位元率", @@ -2388,6 +2575,11 @@ "translateToEnglish": "翻譯成英文", "transcribing": "正在轉錄...", "transcribingXOfY": "正在轉錄 {0} / {1}...", + "linesTranscribedXOfYFailedZ": "已轉錄 {0} / {1} 行 - {2} 行失敗或無語音。\n\n已失敗行會保留它現有文字。要應用已轉錄行,或是重試已失敗的行?(您可先變更引擎或設定)", + "linesTranscribedXOfYCancelled": "轉錄已於 {0} / {1} 行時被取消。\n\n要應用已轉錄行嗎?其餘行會保留它現有文字。", + "noLinesTranscribed": "這 {0} 行皆不能被轉錄。\n詳情請檢查工具日誌。", + "applyTranscribedLines": "應用已轉錄內容", + "retryFailedLines": "重試已失敗", "isolateSpeech": "隔離語音(較慢)", "isolateSpeechHint": "在轉錄前移除音樂和音效。對於有吵雜音樂的音訊,可減少遺失行及更佳時間軸,但使用 GPU 耗時大約與音訊同樣時長 - 而沒用 GPU 則耗時更長。", "isolatingSpeech": "正在隔離語音...", @@ -2395,6 +2587,9 @@ "detectSpeakers": "偵測說話者", "detectSpeakersHint": "使用 NVIDIA 的 Sortformer 語者分鏡(最多支援 8 位說話者),在每行字幕以說話者起頭,例如「(speaker 0)」。搭配會在說話者切換時拆分字幕行的後端(如 Parakeet 與 Cohere)效果最佳。", "detectSpeakersNeedsNewerCrispAsr": "「偵測說話者」需要 Crisp ASR v{0} 或更新版本,但目前已安裝 v{1}。\n\n請更新 Crisp ASR(引擎旁的齒輪按鈕)或關閉「偵測說話者」。", + "xNeedsNewerCrispAsr": "{0} 需要 Crisp ASR v{1} 或更新版本,但目前已安裝 v{2}。\n\n要立刻下載並安裝更新嗎?", + "vad": "VAD", + "vadHint": "語音活動偵測 (VAD):僅轉錄語音,並剪輯說話者停頓之處。\n自動:使用 Silero,且僅適用於需要的引擎 (Cohere、Mega、Index-Echo)。\nSilero、FireRedVAD、WebRTC:始終開啟,適用於所有 Crisp ASR 引擎。FireRedVAD 的精準度最高。\nIndex-Echo 始終使用 Silero。進階設定中的 --vad、--vad-model 或 --chunk-seconds 採取優先權。", "inputLanguage": "輸入語言", "advancedWhisperSettings": "進階語音轉文本參數", "downloadingSpeechToTextEngine": "下載語音轉文字引擎", @@ -2408,7 +2603,7 @@ "selectModel": "模型", "addCustomModelDotDotDot": "新增自訂模型...", "customModelHelp": "您可以使用自己的模型:挑選模型檔案(whisper.cpp ggml '.bin')或模型資料夾(包含 'model.bin' 的 faster-whisper 資料夾)。模型會被複製到 models 資料夾中,並新增至上方的列表中。", - "viewToolsLogFile": "查看工具記錄LOG檔案", + "viewToolsLogFile": "查看工具日誌檔案", "updateXTitle": "更新{0}?", "updateXMessage": "有較新版本的 {0} 可供使用。 {1}{1}立即下載並安裝更新嗎?", "downloadingSpeechToTextModel": "下載語音轉文字模型", @@ -2487,8 +2682,10 @@ "customVoices": "自訂語音", "customVoicesHint": "語音名稱(以逗號分隔),由「自訂」提供者以及未公布語音列表的 OpenRouter 模型使用", "instructions": "指令", - "instructionsHint": "選填的說話風格,例如「以平靜、溫暖的聲音說話」。僅傳送給支援此功能的模型(不適用於 tts-1/tts-1-hd)", + "instructionsHint": "選填的說話風格,例如「以平靜、溫和聲音朗讀」。僅傳送給支援此功能的模型(不適用於 tts-1/tts-1-hd)", "openAiSpeedHint": "語音速度,範圍從 0.25 到 4.0(1.0 為正常)。非所有模型都支援", + "outputFormat": "輸出格式", + "outputFormatHint": "向伺服器要求的音訊格式。「自動」會先要求 MP3,並針對僅回傳 PCM 的模型(例如 Gemini TTS)自動切換為 PCM", "regenerateAudio": "重新產生音訊", "autoContinuePlaying": "自動繼續", "playLine": "播放此行", @@ -2506,16 +2703,27 @@ "voiceXCouldNotBeDeletedX": "無法刪除語音「{0}」:{1}", "voiceImportSuccessTitle": "語音匯入", "voiceXImported": "語音「{0}」匯入成功", - "voiceXCouldNotBeImported": "無法匯入語音「{0}」 - 詳情請看記錄log", + "voiceXCouldNotBeImported": "無法匯入語音「{0}」 - 詳情請參閱日誌", "importPiperVoiceTitle": "開啟 Piper 語音模型 (.onnx)", - "piperVoiceConfigMissingTitle": "組態檔案缺失", + "piperVoiceConfigMissingTitle": "遺漏的組態檔案", "piperVoiceConfigMissingMessage": "Piper 語音的模型檔案旁需要有其對應的組態檔案:{0}", - "dropAudioFileHereToImportVoice": "將音訊檔案拖放到此處即可匯入語音", + "dropAudioFileHereToImportVoice": "拖放音訊檔案來這匯入語音", "dropAudioFileHereHint": ".wav 或 .mp3", "voiceCloneTranscriptTitle": "輸入音訊的逐字稿(語音仿造所需求)", "voiceManagerTitle": "TTS - 語音管理器", "voiceManagerDotDotDot": "語音管理器...", "voiceManagerMenuItem": "TTS 語音管理器...", + "speakFromCurrentLineTitle": "從目前行朗讀", + "speakFromCurrentLineDotDotDot": "從目前行朗讀...", + "speakFromCurrentLineHint": "開始於目前行,依序朗讀各行字幕。按 Esc 鍵或點擊其它行停止。", + "stopSpeaking": "停止朗讀", + "speakingLineX": "正在朗讀第 {0} 行...(Esc 鍵停止)", + "playWithSpeechTitle": "以朗讀播放", + "playWithSpeechDotDotDot": "以朗讀播放...", + "playWithSpeechHint": "播放視訊並於到達各行時逐句朗讀字幕。暫停與照常跳轉;按 Esc 鍵即可停止。", + "lowerVideoVolumeWhileSpeaking": "朗讀時降低視訊音量", + "pauseVideoWhenSpeechRunsLate": "朗讀超時影響至下一行時暫停視訊", + "waitingForSpeechLineX": "正在產生第 {0} 行的語音...(Esc 鍵停止)", "voiceKindClone": "仿造語音", "voiceKindPreset": "內建", "voiceKindModel": "語音模型", @@ -2532,7 +2740,7 @@ "copyVoiceToDotDotDot": "複製語音至引擎...", "copyingVoiceXToYDotDotDot": "正在將語音「{0}」複製至 {1}...", "voiceXCopiedToY": "語音「{0}」已複製至 {1}", - "voiceXCouldNotBeCopiedToY": "無法將語音「{0}」複製至 {1} - 詳細資訊請參閱記錄", + "voiceXCouldNotBeCopiedToY": "無法將語音「{0}」複製至 {1} - 詳情請參閱日誌", "voiceXAlreadyExistsInYContinue": "{1} 中已存在名為「{0}」的語音。\n\n仍要複製嗎?", "openVoicesFolder": "開啟語音資料夾", "downloadVoicePacksDotDotDot": "下載語音包...", @@ -2557,12 +2765,12 @@ "autoCastNoSpeakersFound": "未找到說話者。轉錄必須由能夠區分說話者的語音轉文字引擎完成,像是 Crisp ASR MOSS Diarize。", "autoCastCloningX": "正在仿造 {0}……", "autoCastDoneXVoices": "已仿造 {0} 個語音 - 開啟「文字轉語音」以產生配音", - "autoCastNothingCloned": "無任何語音已仿造 - 詳情請看記錄LOG。", + "autoCastNothingCloned": "無任何語音已仿造 - 詳情請參閱日誌。", "autoCastReplaceSubtitleQuestion": "這會以視訊的轉錄內容取代目前開啟的字幕。要繼續嗎?", "cloneVoicePerLine": "從視訊仿造(每行的語音)", "cloneVoicePerLinePreparing": "正在從視訊中提取每行的語音……", "cloneVoicePerLineNeedsVideo": "要進行仿造每行的語音,需要該字幕所屬的視訊。請開啟視訊後再試一次。", - "cloneVoicePerLineNoClips": "無法從視訊中提取任何音訊,因此沒有可供仿造的內容。請確認視訊包含音軌。", + "cloneVoicePerLineNoClips": "無法從視訊中提取任何音訊,因此沒有可供仿造的內容。請檢查視訊包含音軌。", "cloneVoicePerLineTranscribeClipsXYZ": "{0} 只有在知道視訊中該行對應的語音內容時才能仿造該行。{1} 行(共 {2} 行)的語音無法得知 - 此資訊通常來自原件語言字幕(檔案 > 開啟原件...)。\n\n現在要使用語音轉文字來轉錄視訊中的這些行嗎?\n\n是:挑選引擎及視訊內朗讀的語言,及生成語音之前先轉錄參考片段。\n否:仍要生成 - 這些行將由普通語音朗讀,而不是視訊中的語音。", "voiceCloneConsentTitle": "進行語音仿造 - 開始前請注意", "voiceCloneConsentHeader": "您即將仿造一個語音", @@ -2580,16 +2788,21 @@ "advancedTtsSettings": "進階 TTS 設定", "advancedTtsAudioProcessing": "音訊處理", "advancedTtsOutput": "輸出", - "proAudioPostProcessing": "專業音訊後製處理", + "proAudioPostProcessing": "專業音訊後處理", "proAudioPostProcessingDescription": "對每個片段應用 EQ 溫暖度、雜訊門、壓縮、響度標準化 (-16 LUFS) 和淡入/淡出。", + "generatingSpeechSegmentXOfY": "正在生成語音:區段 {0} / {1}", + "adjustingSpeedSegmentXOfY": "正在調整速度:區段 {0} / {1}", + "mergingAudioSegmentXOfY": "正在合併音訊:區段 {0} / {1}", + "postProcessingSegmentXOfY": "正在後處理:區段 {0} / {1}", "audioDucking": "音訊閃避", "audioDuckingDescription": "降低原件視訊音量,並將語音混音疊加其上,讓原件音軌仍能隱約可聽見。僅適用當語音被新增至視訊檔案中時。", "removeOriginalSpeech": "移除原件語音(緩慢)", "removeOriginalSpeechDescription": "保留原件視訊的音樂與音效並移除其語音,再將新的語音混音疊加於其上。使用 GPU 耗時大約與視訊同樣時長;而沒用 GPU 則耗時更長。開啟音訊閃避時,其音量適用於音樂與音效。僅於語音被新增至視訊檔案時適用。", "removingOriginalSpeech": "正在移除原件語音...", "removeOriginalSpeechFailed": "無法移除原件語音 - 該語音被新增至視訊中,但未移除原件語音。", + "videoTruncatedWarning": "新視訊檔案中的畫面在 {0} 後即停止,但原視訊長度為 {1} - 畫面將會凍結,而聲音繼續。\n\n檔案已保留。請參閱 Subtitle Edit 數據資料夾中的 error-log.txt 有關 ffmpeg 的輸出。", "originalVolumePercent": "原件音量百分比 %", - "vadSilenceCompression": "VAD靜音壓縮", + "vadSilenceCompression": "VAD 靜音壓縮", "vadSilenceCompressionDescription": "縮短字詞之間的停頓而非加快語速,讓片段得以完美嵌合且完全不損及品質。", "maxSilenceMs": "最大靜音(毫秒)", "highQualityTimeStretch": "高品質時間伸縮", @@ -2607,9 +2820,9 @@ "edgeTtsRate": "Edge-TTS 語速", "edgeTtsRateDescription": "Edge-TTS 的語速,例如:「+50%」、「-30%」。預設為使用「+0%」。", "edgeTtsPitch": "Edge-TTS 音調", - "edgeTtsPitchDescription": "Edge-TTS 的音調調整,例如預設為「+10Hz」、「-5Hz」或「+0Hz」。", + "edgeTtsPitchDescription": "音調調節,例如預設為「+10Hz」、「-5Hz」或「+0Hz」。", "edgeTtsVolume": "Edge-TTS 音量", - "edgeTtsVolumeDescription": "Edge-TTS 的音量調節,例如預設為「+20%」、「-10%」或「+0%」。", + "edgeTtsVolumeDescription": "音量調節,例如預設為「+20%」、「-10%」或「+0%」。", "downloadPiperPrompt": "「文字轉語音」需要 Piper。\n\n下載並使用 Piper?", "omniVoiceTtsSettings": "OmniVoice TTS 設定", "reDownloadOmniVoiceTts": "重新下載 OmniVoice TTS", @@ -2622,7 +2835,7 @@ "chatterboxTtsSettings": "Chatterbox TTS 設定", "piperSettings": "Piper 設定", "voiceInstruction": "語音設計", - "voiceInstructionHint": "選填 - 例如:「用平靜且友善的語氣說話」", + "voiceInstructionHint": "選填 - 例如:「用平靜且友善的語氣朗讀」", "voiceGender": "性別", "voiceAge": "年齡", "voicePitch": "音高", @@ -2643,16 +2856,16 @@ "noActorsFoundMessage": "找不到任何角色。請先在字幕行設置角色欄槽。", "noWebVttVoicesFoundMessage": "在 WebVTT 檔案中找不到 語音。", "mergeContinuationLinesPromptTitle": "要合併續接行嗎?", - "mergeContinuationLinesPromptMessage": "部分字幕行似乎是被拆分為多行字幕的同一個句子。\n\n在產出語音前將它們合併,能讓文字轉語音 (TTS) 引擎將每個語意當作同一個停頓區間來發音,這通常聽起來會更自然。\n\n要立刻校對並應用合併嗎?", + "mergeContinuationLinesPromptMessage": "部分字幕行似乎是被拆分為多行字幕的同一個句子。\n\n在產出語音前將它們進行合併,能讓文字轉語音 (TTS) 引擎將每個語意當作同一個停頓區間來朗讀,這通常聽起來會更自然。\n\n要立刻校對並應用合併嗎?", "skipNoiseLinesPromptTitle": "略過音效與音樂行?", "skipNoiseLinesPromptMessage": "有些行只包含音效或音樂,例如 ♪ 或 [摔門聲]。\n\n語音引擎會嘗試將這些行朗讀出來,而且經常會自行編造詞語。\n\n要檢查這些行並讓它們保持靜音嗎?", "skipNoiseLinesTitle": "僅包含音效或音樂的行", - "skipNoiseLinesFoundX": "{0} 行僅包含音效或音樂——被勾選行將保持靜音", + "skipNoiseLinesFoundX": "{0} 行僅包含音效或音樂 - 被勾選行將留待靜音", "skipNoiseLinesColumnSkip": "略過", "detectSpeakersPromptTitle": "在文本內找到說話者名稱?", "detectSpeakersPromptMessage": "某些行以說話者名稱起頭,例如「MIKE:」或「[NARRATOR]」。\n\n將名稱移入角色欄槽後,每位說話者即可經由「設置配音陣容」擁有專屬語音,且名稱不會被朗讀出聲。\n\n要立即校對這些名稱嗎?(僅影響語音生成,字幕本身不會變更)", "detectSpeakersTitle": "文本內的說話者名稱", - "detectSpeakersFoundX": "找到 {0} 個說話者標籤(共 {1} 位說話者) - 被勾選的名稱會成為角色,且不會被朗讀出聲", + "detectSpeakersFoundX": "找到 {0} 個說話者標籤(共 {1} 位說話者) - 被勾選名稱會成為角色,且不會被朗讀出聲", "detectSpeakersColumnUse": "使用", "detectSpeakersSticky": "沒有名稱的行會沿用上一位說話者", "subtitleUpdatedFromReviewSingular": "已根據校對語音更新 1 行字幕", @@ -2670,7 +2883,9 @@ "timeCodeFormatColon": "時間碼格式:", "shotChangesList": "場景變更線列表", "shotChangesClearQuestion": "確定要清除所有場景變更線?", - "deleteSelectedShotChangeQuestion": "您確定要刪除已選取場景變更線嗎?" + "deleteSelectedShotChangeQuestion": "您確定要刪除已選取場景變更線嗎?", + "shotChanges": "場景變更線", + "exportShotChanges": "匯出場景變更線" }, "chapters": { "title": "章節", @@ -2707,7 +2922,7 @@ "outputFileName": "輸出檔案名稱", "writing": "正在寫入章節……", "writtenToX": "章節已寫入 {0}", - "unableToWriteChapters": "無法將章節寫入視訊檔案。", + "unableToWriteChapters": "未能將章節寫入視訊檔案。", "chaptersSavedToX": "章節已儲存至 {0}", "emptyListCallToAction": "尚無章節 - 可在視訊位置新增一個,或從視訊與檔案匯入章節。" }, @@ -2733,19 +2948,20 @@ "runningOcrXY": "正在執行 OCR... {0}/{1}", "noLinesFoundTitle": "未找到字幕", "noLinesFoundMessage": "在掃描區域內未找到任何文字 - 請嘗試調整掃描區域、引擎或文字亮度下限。", + "ocrStoppedEarlyMessage": "OCR 引擎在讀取完所有影格前已停止,所以保留了至此已找到的行,其餘則遺漏的。{0}{0}{1}", "linesFoundX": "找到 {0} 行字幕", "refiningTimingXY": "正在微調時間軸……{0}/{1}", "fixOcrErrors": "修正 OCR 錯誤", "editLineX": "編輯第 {0} 行", "downloadDictionaryHint": "下載拼字檢查字典……", "framesPerSecondHint": "每秒掃描的影格數。5 適用於一般字幕;較高的值可以捕捉非常短的行並讓分組更精確,但會增加 OCR 處理時間。", - "textBrightnessMinimumHint": "比這些更暗的像素未曾計入為字幕文字:影格分組會跟隨明亮的文字進行,完全沒有明亮像素的影格會被略過,而 Paddle 引擎讀取影格時會將任何較暗部分塗黑。對於深色字幕文字,請設置為 0。", + "textBrightnessMinimumHint": "比這些更暗的像素未計入為字幕文字:影格分組會跟隨明亮的文字進行,完全沒有明亮像素的影格會被略過,而 Paddle 引擎讀取影格時會將任何較暗部分塗黑。對於深色字幕文字,請設置為 0。", "textSimilarityPercentHint": "兩個連續 OCR 結果必須達到的相似程度(百分比),才能視為相同字幕並合併成一行。", "maxGapHint": "兩次相似 OCR 結果仍可合併為同一條字幕的最長停頓時間 - 用於銜接單影格閃爍。數值過高可能會將連續顯示兩次的字幕行合併。", "minDurationHint": "畫面上顯示時間短於此值的行會被視為 OCR 雜訊並被丟棄。", "dictionaryHint": "OCR 修正引擎和單字上色所使用的拼字檢查字典:已知單字顯示為綠色,未知單字顯示為紅色。", "previewPosition": "預覽位置", - "unableToReadVideoTitle": "無法讀取視訊", + "unableToReadVideoTitle": "未能讀取視訊", "unableToReadVideoMessage": "無法從以下路徑讀取視訊資訊:{0}", "abortOcrTitle": "中止 OCR?", "abortOcrMessage": "您確定要中止正在執行的 OCR 嗎?", @@ -2758,7 +2974,7 @@ "engineExternalServer": "外部伺服器(在 Subtitle Edit 外部安裝及管理)", "engineCloudApi": "雲端 API(需要 API 金鑰)", "engineNothingToInstall": "無需安裝", - "engineModelsMissing": "引擎已安裝,但缺少模型", + "engineModelsMissing": "已安裝引擎,但遺漏的模型", "website": "網站" }, "backgroundMusic": { @@ -2797,9 +3013,9 @@ "downloadingModel": "正在下載 ACE-Step 1.5 模型...", "saveAudioTitle": "儲存背景音樂", "audioFileSavedX": "音訊檔案已儲存:{0}", - "unableToGenerateMusic": "無法產生音樂", - "unableToAddMusicToVideo": "無法將音樂加入視訊", - "outputLengthTextToSpeech": "依照產生的語音長度 - 音樂會無縫循環,並在有人說話時降低音量", + "unableToGenerateMusic": "未能產生音樂", + "unableToAddMusicToVideo": "未能將音樂加入視訊", + "outputLengthTextToSpeech": "跟隨已產生語音 - 音樂會無縫循環,並在有人說話時降低音量", "addBackgroundMusic": "加入背景音樂", "addBackgroundMusicHint": "使用 ACE-Step 1.5 在本機產生音樂,並將其混入語音下方 - 有人說話時會降低音量", "generatingBackgroundMusicDotDotDot": "正在產生背景音樂...", @@ -2835,6 +3051,13 @@ "remuxVideoFailed": "視訊重新封裝失敗。", "remuxVideoPleaseSelectBoth": "請同時選取視訊和音訊檔案。", "remuxVideoAssRequiresMkv": "ASS/SSA 字幕需要使用 MKV 容器,才能保留所有格式和樣式。", + "remuxVideoSccRequiresMov": "Scenarist (.scc) 字幕作為「CEA-608 隱藏式字幕」內嵌,這需要 MOV 或 MPG 容器。輸出格式已經自動切換為 .mov。", + "remuxVideoSccNotWithWebM": "Scenarist (.scc) 「隱藏式字幕」需要 MOV 容器,但該容器無法容納 .webm 檔案的 VP9/AV1 視訊。請移除 .scc 檔案或將其轉換為其他字幕格式。", + "remuxVideoMpgMaxTwoCaptionTracks": ".mpg 檔案最多僅支援兩軌「隱藏式字幕」 (CC1 與 CC3) - 請移除部分字幕檔案,僅保留一至兩軌。", + "remuxVideoMccRequiresMpg": "MacCaption (.mcc) 字幕作為「CEA-608/708 隱藏式字幕」內嵌於 MPEG-2 視訊中,這需要 MPG 容器。輸出格式已自動切換為 .mpg。", + "remuxVideoMpgReencodeVideoX": ".mpg 檔案內「隱藏式字幕」需要 MPEG-2 視訊,而現在視訊是 {0}。要將視訊重新編碼為 MPEG-2 嗎?這需要較長時間且會稍微降低品質。", + "remuxVideoFileHasNoAudioX": "「{0}」沒有音訊軌,所以未能夠用作音訊來源。", + "remuxVideoAddingClosedCaptions": "正在加入「隱藏式字幕」...", "remuxVideoSelectAudioTrack": "選取音訊軌道", "remuxVideoSelectAudioTrackFor": "選取 {0} 的音軌", "remuxVideoSelectAudioTrackPrompt": "檔案「{0}」包含 {1} 個音訊軌道。請選取要重新封裝的音訊軌:", @@ -2847,6 +3070,7 @@ "remuxVideoMixAudio": "音訊混合到一個音軌中", "remuxVideoVolumePercent": "音量%:", "remuxVideoVolumeX": "音量 {0}%", + "remuxVideoFastStart": "針對網路串流最佳化(將索引移至前端 - 較慢)", "embedSubtitlesDotDotDot": "新增/移除內嵌字幕...", "generateTimeCodes": "產生時間碼", "checkeredImage": "棋盤格圖案", @@ -2858,16 +3082,16 @@ "cutVideoSubtitleFileGeneratedX": "已產生的字幕檔:{0}", "cutVideoTransition": "區段之間的轉場", "cutVideoTransitionDurationSeconds": "持續時間(秒)", - "cutVideoFadeInSeconds": "起頭從黑色淡入(秒)", - "cutVideoFadeOutSeconds": "最後淡出至黑色(秒)", + "cutVideoFadeInSeconds": "起頭從黑畫面淡入(秒)", + "cutVideoFadeOutSeconds": "結尾淡出至黑畫面(秒)", "cutVideoPreviewTransition": "預覽轉場", "cutVideoPreviewTransitionTitle": "轉場預覽", "cutVideoPreviewNeedsJoin": "無可預覽的拼接處 - 請至少新增兩個要合併的區段,或從區段中間剪除。", - "cutVideoTransitionInfoX": "每個轉場都會重疊拼接處,因此每次拼接會使輸出時間縮短 {0} 秒。剪輯字幕會重新對齊時間軸以相符。", - "cutVideoTransitionFade": "淡入淡出", - "cutVideoTransitionFadeBlack": "淡入黑色", - "cutVideoTransitionFadeWhite": "淡入白色", - "cutVideoTransitionFadeGrays": "淡入灰色", + "cutVideoTransitionInfoX": "每個轉場都會重疊部分拼接處,因此每次拼接會使輸出時間縮短 {0} 秒。剪輯字幕會重新對齊時間軸以相符。", + "cutVideoTransitionFade": "淡化", + "cutVideoTransitionFadeBlack": "淡化入黑而出", + "cutVideoTransitionFadeWhite": "淡化入白而出", + "cutVideoTransitionFadeGrays": "淡化入灰而出", "cutVideoTransitionDissolve": "交叉溶解", "cutVideoTransitionWipeLeft": "向左劃過", "cutVideoTransitionWipeRight": "向右劃過", @@ -2892,14 +3116,14 @@ "embeddedTrackNoSubtitlesFoundMessage": "已選取字幕檔案不包含任何字幕。", "embeddedTrackNoTracksTitle": "未新增軌道", "embeddedTrackNoTracksMessage": "新增一份或多份字幕軌道,或載入已內嵌字幕的視訊。", - "embeddedTrackUnableToGenerateTitle": "無法產生視訊", + "embeddedTrackUnableToGenerateTitle": "未能產生視訊", "embeddedTrackUnableToGenerateMessage": "未產生輸出視訊檔:{0}{1}參數:{2}", "embeddedTrackPreviewUnavailableTitle": "預覽不可用", "embeddedTrackPreviewUnavailableMessage": "無法提取已選取字幕串流。該編碼可能不是 ffmpeg 能轉換為 SRT 的文字格式(例如:基於點陣圖字幕)。", "embeddedTrackGeneratingVideoXY": "正在產生視訊... {0}% {1}", "embeddedTrackGeneratingVideo": "正在產生視訊...", "mp4FilesFilter": "MP4 檔案", - "embeddedTracksPickVideoHint": "選擇視訊檔案,或拖放至此處,以查看其中的字幕軌道。", + "embeddedTracksPickVideoHint": "選擇視訊檔案,或拖放一個來這,以參閱其中的字幕軌道。", "viewMatroskaTrackX": "檢視 Matroska 軌道 - {0}", "resolutionSeparator": "x", "openFromUrlTitle": "從 URL 開啟視訊檔案", @@ -2941,11 +3165,15 @@ "reEncodeInfo": "重新編碼可以使字幕更加流暢:\n• 解析度較小(高解析度會使字幕變慢)\n• 將視訊重新編碼為 H.264 + yuv420p 使其更相容\n• 針對快速跳轉進行了最佳化", "reEncodeGeneratingVideoX": "正在產生視訊... {0}% {1}", "reEncodeGeneratingVideoXofY": "正在產生視訊 {0}/{1}... {2}% {3}", - "reEncodeUnableToGenerateVideo": "無法產生視訊", + "reEncodeUnableToGenerateVideo": "未能產生視訊", "reEncodeOutputVideoFileNotGenerated": "未產生要輸出的視訊檔案:{0}\n參數:{1}", "reEncodeGeneratingDone": "產生完畢", "reEncodeGeneratedFilesX": "已產生的檔案 ({0}):", "reEncodeFfmpegParameters": "FFmpeg 參數", + "analyzingVideoX": "正在分析影片... {0}% {1}", + "analyzingVideoXofY": "正在分析影片 {0}/{1}... {2}% {3}", + "backgroundImageFileNotSelected": "未選取背景圖片檔案", + "backgroundImageFileDoesNotExistX": "背景圖片檔案不存在:{0}", "voices": "語音", "presets": "預設集", "talkerX": "講者 {0}", @@ -2982,7 +3210,7 @@ "timelineShowThumbnails": "顯示視訊縮圖", "showSpeechOnly": "僅顯示語音(產生較慢)", "isolatingSpeechForWaveformX": "正在為波形隔離語音... {0}", - "isolatingSpeechForWaveformFailed": "無法為波形隔離語音 - 請參閱工具記錄", + "isolatingSpeechForWaveformFailed": "無法為波形隔離語音 - 請參閱工具日誌", "speechOnlyWaveformReady": "波形現在僅顯示語音", "showOnlyWaveform": "僅顯示波形", "showOnlySpectrogram": "僅顯示頻譜圖", @@ -3009,7 +3237,7 @@ "startSceneMustComeBeforeEndScene": "起頭場景必須在結尾場景之前", "goToSubPos": "跳至字幕位置", "speedInPercentage": "速度百分比 (%)", - "fromDropFrameValue": "自丟格值 (Drop-frame)", + "fromDropFrameValue": "從丟格值 (Drop-frame)", "toDropFrameValue": "至丟格值 (Drop-frame)", "adjustAll": "調整全部", "adjustSelectedLines": "調整已選取行", @@ -3026,7 +3254,8 @@ "syncPoints": "同步點", "pointSync": "點同步...", "pointSyncViaOther": "經由其他字幕進行點同步", - "syncPointCandidateInfo": "靜音處 {0}+ 秒之後開始字幕行會醒目顯示 - 它們通常是可靠的同步點", + "syncPointCandidateAfterInfo": "已醒目顯示其後有 {0} 秒以上靜音的行 - 這些行常常可做為可靠的同步點", + "gapAfter": "後續間隔", "adjustAllShortcuts": "鍵盤快捷鍵:\n\n• Shift + 左/右:移動 10 毫秒\n• Ctrl + 向左/向右:移動 100 毫秒\n• Alt + 向左/向右:移動 500 毫秒", "adjustAllShortcutsFrames": "鍵盤快捷鍵:\n\n• Shift + 左/右:移動 1 格\n• Ctrl + 左/右:移動 10 影格\n• Alt + 左/右:移動 1 秒", "offsetInSeconds": "偏移時間 (秒)", @@ -3060,19 +3289,19 @@ "readyToTranslate": "準備翻譯", "translating": "翻譯中...", "translationComplete": "翻譯完成", - "translateInPlaceNoOriginal": "直接翻譯(請勿建立原件)", + "translateInPlaceNoOriginal": "直接翻譯(勿建立原件)", "translationCancelled": "翻譯已取消", "swapLanguages": "交換源語言和目標語言", "xIsAlreadyDownloadedReDownload": "{0} 已下載。要重新下載嗎?", "advancedSettingsSubtitle": "進階 llama.cpp 引擎的批次、上下文與採樣選項", "context": "上下文", - "synopsis": "劇情概要", + "synopsis": "概觀", "synopsisHint": "內容的選填描述(類型、背景、語調) - 將包括在每個請求中", - "glossary": "術語對照表", + "glossary": "詞彙表", "glossaryHint": "每行一個:來源術語 = 翻譯", "previousLinesAsContext": "將前幾行作為上下文", "linesPerBatch": "每批次行數", - "keepLineBreaks": "保留斷行", + "keepLineBreaks": "保留斷行處", "samplingParameters": "採樣參數", "minusOneMeansDefault": "-1 = 模型/伺服器預設值", "temperature": "溫度 (Temperature)", @@ -3138,7 +3367,7 @@ "subtitleGridTextDisplayClip": "片段 (單行)", "subtitleGridTextDisplayWrap": "自動換行以符合視窗", "subtitleGridTextDisplayEllipsis": "單行並顯示刪節號", - "subtitleGridLiveSpellCheck": "字幕網格即時拼字檢查", + "subtitleGridLiveSpellCheck": "字幕網格內即時拼字檢查", "subtitleGridCenterText": "字幕網格內文本置中", "subtitleGridShowFormatting": "字幕網格顯示 (HTML/ASSA) 已格式化文字", "showUpDownStartTime": "顯示「顯示時間」的增減控制項", @@ -3184,9 +3413,12 @@ "newEmptyDefaultMs": "新字幕預設持續時間(毫秒)", "timeCodeUpDownStepMs": "時間碼 上/下 調整增量(毫秒)", "moveSelectedLinesStepMs": "移動已選取行的快捷鍵步進值(毫秒)", + "moveStartEndStepMs": "移動 起點/終點 快捷鍵步進值(毫秒)", "moveLinesShortenNeighbor": "移動行:縮短上一行/下一行,而非與其重疊", "promptBeforeDelete": "刪除之前提示", "rememberPositionAndSize": "記憶視窗位置與大小", + "titleBarFullFileName": "在標題列中顯示完整檔案路徑", + "showRecentFiles": "顯示近期檔案(以便重新開啟)", "openLastFileOnStart": "啟動時開啟上次的近期檔案", "autoSave": "自動儲存(在編輯時同步儲存開啟的檔案)", "autoBackupOn": "自動備份", @@ -3212,7 +3444,9 @@ "errorBackgroundColor": "錯誤背景顏色", "waveformDrawGridLines": "繪製網格線", "waveformUseSkiaRenderer": "使用實驗性的快速繪製器", - "waveformCenterVideoPositionAlsoWhenPaused": "暫停時視訊位置也置中", + "waveformCenterVideoPositionAlsoWhenPaused": "暫停時也置中視訊位置", + "waveformSelectCurrentSubtitleWhilePaused": "暫停時也選取目前字幕", + "waveformRememberZoom": "記住縮放(水平與垂直)", "waveformShowToolbar": "顯示工具列", "waveformShowToolbarEdit": "編輯工具列...", "waveformShowToolbarEditLabel": "工具列項目", @@ -3233,7 +3467,7 @@ "waveformGuessStartOffsetMs": "從波形猜測起頭時間:將起點提前(毫秒)", "waveformGuessEndOffsetMs": "從波形猜測結尾時間:將終點延後(毫秒)", "waveformShotChangesAutoGenerate": "自動產生場景變更線", - "waveformTextFontSize": "波形文字字型尺寸", + "waveformTextFontSize": "波形文字字型大小", "waveformTextFontBold": "波形文字字型加粗", "waveformTextColor": "波形文字顏色", "waveformColor": "波形顏色", @@ -3249,7 +3483,7 @@ "proxyBypassList": "繞過代理伺服器", "proxyBypassListHint": "直接連線的主機名稱,以分號區隔,例如「internal.company.com;example.org」", "updates": "更新", - "checkForUpdatesOnStartup": "應用程式啟動時檢查更新", + "checkForUpdatesOnStartup": "應用程式啟動時檢查是否有更新", "checkForUpdatesChannel": "通知版本", "checkForUpdatesChannelStable": "僅限穩定版", "checkForUpdatesChannelStableAndBeta": "穩定版與 Beta 測試版", @@ -3257,7 +3491,7 @@ "defaultSaveAsFormat": "預設「另存新檔」格式", "favoriteSubtitleFormats": "最愛的字幕格式", "favoriteLanguages": "最愛的語言", - "webVttUseXTimestampMap": "WebVTT:使用 X-TIMESTAMP-MAP(載入時偏移時間碼)", + "webVttUseXTimestampMap": "WebVTT:使用 X-TIMESTAMP-MAP(讀取完偏移時間碼)", "assaAutoSetResolution": "ASSA:當視訊開啟時依視訊來設置解析度(PlayResX/PlayResY)", "assaAutoSetResolutionPrompt": "ASSA:在變更解析度以相符視訊之前先詢問(否則會自動重新取樣)", "videoControls": "視訊控制項", @@ -3274,19 +3508,20 @@ "allowSingleLetterShortcutsInTextbox": "在文字框中允許單一字母快捷鍵", "allowTextNavigationShortcutsInTextbox": "允許在文字框中使用文字導航鍵的快捷鍵(Ctrl+左/右、Home/End)", "spellCheckEnglishTreatInApostropheAsIng": "拼字檢查:將以 'in'' 結尾的單字視為 'ing'(僅限英文)", - "goToLineNumberSetsVideoPosition": "移至行號時同步視訊位置", + "goToLineNumberSetsVideoPosition": "移至行號時也設置視訊位置", "adjustAllTimesRememberLineSelectionChoice": "調整所有時間,並記住行選取範圍的選擇", "mergeKeepEndTime": "合併行:保留結尾時間(允許與下一則字幕重疊)", "mergeKeepEndTimeOnlyAssa": "合併行:僅保留「Advanced Sub Station Alpha」的結尾時間", - "filesAndLogs": "檔案和記錄LOG", - "showErrorLogFile": "顯示錯誤記錄LOG檔案", - "showToolsLogFile": "顯示工具記錄LOG檔案", - "writeToolsLog": "寫入工具記錄LOG", + "mergeAutoBreak": "合併行:自動斷行已合併文字 (關閉則保留各行自身斷行處)", + "filesAndLogs": "檔案和日誌", + "showErrorLogFile": "顯示錯誤日誌檔案", + "showToolsLogFile": "顯示工具日誌檔案", + "writeToolsLog": "寫入工具日誌", "showSettingsFile": "顯示設定檔案", "showAssaLayer": "顯示 ASSA 圖層框", "waveformCursorColor": "波形游標/播放頭顏色", "waveformShotChangeColor": "波形場景變更線顏色", - "Waveformgridcolor": "波形網格顏色", + "waveformGridColor": "波形網格顏色", "waveformParagraphLeftColor": "波形左邊界顏色", "waveformParagraphRightColor": "波形右邊界顏色", "waveformFancyHighColor": "波形漸變高亮顏色", @@ -3307,14 +3542,17 @@ "useSpecialStyleAfterLongGaps": "在長間隔後使用特殊樣式", "addSpace": "增加空格", "processIfEndsWithComma": "若以逗號結尾則處理", - "removeComma": "刪除逗號", + "removeComma": "移除逗號", "editContinuationStyleCustom": "編輯自訂的續接行樣式", "longGapThreshold": "長間隔閾值(毫秒)", "afterLongGap": "長間隔之後:", "resetSyntaxColoring": "重置語法著色", "resetWaveform": "重置波形", "resetRules": "重置規則", - "useFrameMode": "使用影格模式 (時.分.秒.影格)", + "timeCodeMode": "時間碼模式", + "timeCodeModeTime": "時間 (hh:mm:ss,zzz)", + "timeCodeModeFrames": "影格 (hh:mm:ss:ff)", + "timeCodeModeFrameNumbers": "影格編號", "textBoxLimitNewLines": "限制字幕文字框中的行數", "mpvOpenGl": "libmpv - OpenGL", "mpvSoftwareRendering": "libmpv - 軟體彩現(慢)", @@ -3365,7 +3603,7 @@ "waveformParagraphBackgroundColor": "波形字幕背景顏色", "waveformParagraphSelectedBackgroundColor": "波形已選取字幕背景顏色", "waveformAllowOverlap": "允許重疊 (當 移動/調整大小 時)", - "waveformSetVideoPositionOnMoveStartEnd": "設置移動起點/終點時的視訊位置", + "waveformSetVideoPositionOnMoveStartEnd": "當移動 起點/終點 同時設置視訊位置", "saveAsBehaviorUseSubtitleVideoFilename": "使用 字幕/視訊 檔案名稱", "saveAsBehaviorUseVideoSubtitleFilename": "使用 視訊/字幕 檔案名稱", "saveAsBehaviorUseVideoFileName": "使用 視訊 檔案名稱", @@ -3377,10 +3615,10 @@ "splitOddLinesAction": "拆分奇數行動作", "ocrUseWordSplitList": "OCR:使用單字拆分列表", "ocrGuessUnknownWords": "OCR:嘗試推測未知單字", - "spellCheckRememberUseAlwaysList": "拼字檢查:記住「一律使用」列表", + "spellCheckRememberUseAlwaysList": "拼字檢查:記住「始終使用」列表", "fixShortDisplayTimesAllowMoveStartTime": "修正常見錯誤:修正過短的顯示時間可能會移動起頭時間", "speechToTextSelectedLinesPromptFirstTimeOnly": "語音轉文字:已選取行,僅第一次詢問語言/引擎", - "multipleReplaceShowDotDotDotButtons": "多重取代:顯示快捷選單按鈕", + "multipleReplaceShowDotDotDotButtons": "多重取代:顯示快顯功能表按鈕", "gridFocusTextboxAfterInsertNew": "網格:插入新字幕之後聚焦文字框", "undoRedoGoToChangedLine": "復原/重做:跳至已變更行(及視訊位置)", "musicSymbol": "音樂符號", @@ -3391,6 +3629,7 @@ "useFocusedButtonBackgroundColor": "使用目前對焦按鈕背景顏色", "focusedButtonBackgroundColor": "對焦按鈕背景顏色", "forceCrLfOnSave": "儲存時強制 CR+LF(文字字幕檔案)", + "linuxClipboardUseExternalTool": "經由 xclip/wl-copy 複製文字(可保留變音符號字元)", "showFormatLimitWarning": "儲存時,若行數超過格式限制則顯示警告(例:SCC 32 字元/行)", "textBoxButtonShowAutoBreak": "文字框:顯示自動斷行按鈕", "textBoxButtonShowUnbreak": "文字框:顯示取消斷行按鈕", @@ -3398,11 +3637,12 @@ "textBoxButtonShowColor": "文字框:顯示顏色按鈕", "textBoxButtonShowRemoveFormatting": "文字框:顯示移除格式排版按鈕", "textBoxButtonShowAiAssistant": "文字框:顯示 AI 助理按鈕", - "waveformSingleClickAction": "波形點選操作", - "waveformDoubleClickAction": "波形雙擊操作(點擊操作後)", + "waveformSingleClickAction": "波形 單擊操作", + "waveformDoubleClickAction": "波形 雙擊操作(單擊操作後)", "allSettings": "所有設定", "uiScale": "介面縮放比例(%)", "fontScale": "字型縮放比例(%)", + "textSelectionOpacity": "選取範圍的文字不透明度 (%)", "waveformToolbarItems": "波形工具列項目", "matchIconColorToDarkTheme": "使圖示顏色相符深色主題的前景顏色", "subtitlePreviewProperties": "字幕預覽屬性", @@ -3437,11 +3677,34 @@ "categoryVideo": "視訊", "categorySync": "同步", "categoryAi": "AI", + "categoryCustom": "自訂", + "editCustomShortcut": "編輯自訂快捷鍵", + "customShortcutTextBoxKeyHint": "此處請搭配 Ctrl 或 Alt 鍵使用 - 使用普通單鍵將不能夠在文字框中輸入。", + "customShortcutNumberX": "自訂快捷鍵 #{0}", + "customShortcutNumberXY": "自訂快捷鍵 #{0}:{1}", + "customShortcutStep": "步驟", + "customShortcutSteps": "步驟", + "customShortcutStepRunCommand": "執行命令", + "customShortcutStepInsertText": "插入文字", + "customShortcutStepReplace": "尋找與取代", + "customShortcutInsertAtCursor": "於游標處", + "customShortcutInsertAtStart": "於文字起頭", + "customShortcutInsertAtEnd": "於文字結尾", + "customShortcutSummaryRunCommandX": "執行:{0}", + "customShortcutSummaryInsertXY": "插入「{0}」({1})", + "customShortcutSummaryReplaceXY": "將「{0}」取代為「{1}」", + "customShortcutSummaryReplaceRegexXY": "將正規表達式「{0}」取代為「{1}」", + "customShortcutPickCommand": "請選取要執行的命令。", + "customShortcutEnterText": "請輸入要插入的文字。", + "customShortcutEnterFind": "請輸入要尋找的文字。", + "customShortcutInvalidRegexX": "無效的正規表達式:{0}", + "customShortcutStepsHint": "步驟會由上至下依序執行。文字步驟會作用於已選取行;會開啟視窗的命令則會在視窗關閉後繼續執行。", "activeIn": "作用於", "activeInEverywhere": "所有位置", "generalMergeSelectedLinesAndUnbreak": "合併已選取行並取消斷行", "generalMergeSelectedLinesAndUnbreakCjk": "合併已選取行並取消斷行且不含空格(CJK)", "generalUnbreakNoSpaceCjk": "取消斷行且不含空格(CJK)", + "generalToggleBreak": "切換 斷行/取消斷行", "generalMergeSelectedLinesBilingual": "合併已選取行為雙語", "generalToggleTranslationMode": "切換翻譯模式", "generalToggleTranslationAndOriginalInPreviews": "在視訊/音訊預覽中切換翻譯與原件字幕", @@ -3453,7 +3716,7 @@ "generalGoToPrevSubtitlePlayTranslate": "前往上一則字幕(播放翻譯)", "generalGoToFirstLine": "跳至第一行", "generalGoToLastLine": "跳至最後一行", - "alsoSetVideoPosition": "同時設置視訊位置", + "alsoSetVideoPosition": "也設置視訊位置", "generalGoToVideoPosition": "跳至視訊位置", "generalToggleItalic": "切換斜體", "generalToggleBold": "切換粗體", @@ -3463,13 +3726,14 @@ "fileOpenKeepVideo": "開啟(保留視訊)", "fileSave": "儲存", "fileSaveAs": "另存新檔", + "fileSaveOriginal": "儲存原件", "fileCompare": "比對", "fileStatistics": "統計資訊", "fileImportPlainText": "匯入純文字", "fileExportEbuStl": "匯出EBU STL", "fileExportPac": "匯出PAC", "fileExit": "匯出", - "openSeDataFolder": "開啟字幕編輯資料夾", + "openSeDataFolder": "開啟 Subtitle Edit 數據資料夾", "editFindPrevious": "尋找上一個", "fillSelectedLinesWithClipboard": "用剪貼簿文字填滿已選取行", "listInverseSelection": "反向選取範圍", @@ -3483,6 +3747,7 @@ "detectKey": "偵測按鍵", "control": "Ctrl", "alt": "Alt", + "altGr": "右側 Alt", "win": "Win", "shift": "Shift", "controlMac": "⌃", @@ -3490,9 +3755,14 @@ "winMac": "⌘", "shiftMac": "⇧", "resetShortcuts": "重置快捷鍵", - "resetShortcutsDetail": "您想將所有快捷鍵重置為預設值嗎?", + "resetShortcutsDetail": "確定要將所有快捷鍵重置為預設值嗎?", "togglePlayPause": "切換 播放/暫停", "toggleLockTimeCodes": "切換鎖定時間碼", + "toggleTimeCodeMode": "切換時間碼模式(時間/影格/影格編號)", + "toggleScreenPrivacy": "切換隱私畫面(隱藏檔案名稱,接著還有文字)", + "screenPrivacyFileNamesHidden": "隱私畫面:已隱藏檔案名稱", + "screenPrivacyFileNamesAndTextsHidden": "隱私畫面:已隱藏檔案名稱及文字", + "screenPrivacyOff": "隱私畫面:關閉", "duplicateSelectedLines": "複製已選取行", "sourceView": "原始碼視圖", "addOrEditBookmark": "新增或編輯書籤", @@ -3541,6 +3811,7 @@ "listViewColumnTextUp": "欄位:文字上移", "listViewColumnTextDown": "欄位:文字下移", "autoTranslateSelectedLines": "自動翻譯已選取行...", + "autoTranslateSelectedLinesNoPrompt": "自動翻譯已選取行(無提示,使用上次的引擎與語言)", "setAssaResolution": "設置 ASSA 解析度 (PlayResX/PlayResY)", "setShortcutForX": "設置「{0}」的快捷鍵", "commandFileNewKeepVideo": "新(保留視訊)", @@ -3550,14 +3821,14 @@ "restoreAutoBackup": "還原自動備份", "importTimeCodes": "匯入時間碼", "importSubtitleWithManuallyChosenEncoding": "以手動所選編碼匯入字幕", - "exportBluRaySup": "藍光 sup...", + "exportBluRaySup": "匯出 藍光(sup)...", "exportCustomTextFormat": "匯出自訂文字格式...", "exportPlainText": "純文字...", "toggleRightToLeft": "切換由右至左書寫", "modifySelection": "修改選取範圍", "adjustDurations": "調整持續時間", "applyDurationLimits": "應用持續時間限制", - "batchConvert": "批量轉換", + "batchConvert": "批次轉換", "applyMinGap": "應用最小間隔", "makeEmptyTranslationFromCurrentSubtitle": "從目前字幕進行空白翻譯", "splitBreakLongLines": "拆分/重新平衡長行", @@ -3591,10 +3862,10 @@ "videoFullScreen": "視訊全螢幕", "copyTextFromOriginalSelectedLines": "從原件複製文字(已選取行)", "copyTextToOriginalSelectedLines": "將文本複製至原件(已選取行)", - "textBoxRemoveAllFormatting": "文字框,刪除所有格式排版", + "textBoxRemoveAllFormatting": "文字框:移除所有格式排版", "textBoxItalic": "文字框:斜體", - "textBoxBold": "文字框粗體", - "textBoxUnderline": "文字框底線", + "textBoxBold": "文字框:粗體", + "textBoxUnderline": "文字框:底線", "textBoxBox": "文字框區塊(EBU STL)", "resetWaveformZoomAndSpeed": "重置波形縮放和重播速度(播放速率)", "togglePlaybackSpeed": "切換重播速度(播放速率)", @@ -3621,10 +3892,18 @@ "surroundWith": "周圍有...", "surroundWithXY": "以 {0}/{1} 包圍", "surroundWithNumberX": "以 #{0} 包圍", + "surroundWithBehavior": "行為模式", + "surroundWithBehaviorToggle": "切換(新增,或呈現就移除)", + "surroundWithBehaviorAdd": "新增(每次)", + "surroundWithBehaviorRemove": "移除(全部)", + "surroundWithBehaviorRemoveOnce": "移除(每次一項)", + "surroundWithWorksOn": "作用對象", + "surroundWithScopeSelectionOrText": "選取範圍,否則全文", + "surroundWithScopeEachLine": "每一行", "searchVia": "搜尋經由", "searchViaX": "搜尋經由 {0}", "searchViaNumberX": "搜尋經由 #{0}", - "searchViaUrlHint": "搜尋的文字會取代 URL 中的 {0}", + "searchViaUrlHint": "已搜尋文字會佔據 URL 中的 {0}", "moveVideoPositionMilliseconds": "以毫秒數為單位移動視訊位置", "importShortcutsTitle": "匯入快捷鍵", "exportShortcutsTitle": "匯出快捷鍵", @@ -3648,6 +3927,11 @@ "copySubtitlePathToClipboard": "複製字幕路徑至剪貼簿", "copySubtitleOriginalPathToClipboard": "複製原件字幕路徑至剪貼簿", "focusTextBox": "聚焦文字框", + "focusSubtitleListView": "聚焦字幕列表檢視", + "focusWaveform": "聚焦波形", + "focusOriginalTextBox": "聚焦原件文字框", + "textBoxGoToStart": "文字框:跳至起點", + "textBoxGoToEnd": "文字框:跳至終點", "sortByNumber": "依編號排序", "sortByStartTime": "依「演出」時間排序", "sortByEndTime": "依「隱藏」時間排序", @@ -3663,7 +3947,7 @@ "sortByStyle": "依樣式排序", "videoToggleBrightness": "視訊:切換亮度 (僅限 mpv)", "videoToggleContrast": "視訊:切換對比(僅限 mpv)", - "duplicatesFound": "發現以下重複的快捷鍵:", + "duplicatesFound": "發現後續重複的快捷鍵:", "copyTextToClipboard": "複製文字至剪貼簿(已選取行)", "copyTextFromOriginalToClipboard": "從原件複製文字至剪貼簿(已選取行)", "assaDraw": "ASSA 繪圖", @@ -3677,8 +3961,8 @@ "toggleWaveformAndSpectrogramHeight": "切換波形/頻譜圖劃分高度", "toggleSpectrogramStyle": "切換頻譜圖樣式", "copyMsRelativeToCurrentSubtitleLineToClipboard": "將相對於目前字幕行的毫秒數複製到剪貼簿", - "layoutZoomIn": "佈局放大(縮放 UI)", - "layoutZoomOut": "佈局縮小(縮放 UI)", + "layoutZoomIn": "放大佈局(縮放 UI)", + "layoutZoomOut": "縮小佈局(縮放 UI)", "goToPreviousBookmark": "跳至上一個書籤", "selectionToLower": "文字框:選取範圍改小寫", "selectionToUpper": "文字框:選取範圍改大寫", @@ -3687,6 +3971,7 @@ "googleIt": "Google 搜尋(已選取文字)", "setActorX": "設置角色 {0}", "setNewActor": "設置新角色...", + "setStyleX": "設置樣式:{0}", "toggleDialogDashes": "切換對話破折號", "mergeWithLineBeforeAsDialog": "與前方行合併成對話", "mergeWithLineAfterAsDialog": "與後方行合併成對話", @@ -3698,14 +3983,18 @@ "moveStartOneFrameForwardKeepGapPrev": "起點 快轉一影格(若過近,則保持至上一行間隔)", "moveEndOneFrameBackKeepGapNext": "終點 倒轉一影格(若過近,則保持至下一行間隔)", "moveEndOneFrameForwardKeepGapNext": "終點快轉一影格(若過近,則保持至下一行間隔)", - "moveSelectedLinesXMsBack": "已選取行 倒轉 X 毫秒(X 可在「設定」中設置)", - "moveSelectedLinesXMsForward": "已選取行快轉 X 毫秒(X 可在「設定」中設置)", - "moveSelectedLinesAndForwardXMsBack": "已選取行及後續各行 倒轉 X 毫秒(X 可在「設定」中設置)", - "moveSelectedLinesAndForwardXMsForward": "已選取行及後續各行 快轉 X 毫秒(X 可在「設定」中設置)", - "moveAllLinesXMsBack": "將所有行 倒轉 X 毫秒(X 可在「設定」中設置)", - "moveAllLinesXMsForward": "將所有行 快轉 X 毫秒(X 可在「設定」中設置)", + "moveStartXMsBack": "起點 倒轉 X 毫秒(X 可在「設定」內設置,亦可在「視覺同步」內)", + "moveStartXMsForward": "起點 快轉 X 毫秒(X 可在「設定」內設置,亦可在「視覺同步」內)", + "moveEndXMsBack": "終點 倒轉 X 毫秒(X 可在「設定」內設置,亦可在「視覺同步」內)", + "moveEndXMsForward": "終點快轉 X 毫秒(X 可在「設定」內設置,亦可在「視覺同步」內)", + "moveSelectedLinesXMsBack": "已選取行 倒轉 X 毫秒(X 可在「設定」內設置)", + "moveSelectedLinesXMsForward": "已選取行快轉 X 毫秒(X 可在「設定」內設置)", + "moveSelectedLinesAndForwardXMsBack": "已選取行及後續各行 倒轉 X 毫秒(X 可在「設定」內設置)", + "moveSelectedLinesAndForwardXMsForward": "已選取行及後續各行 快轉 X 毫秒(X 可在「設定」內設置)", + "moveAllLinesXMsBack": "將所有行 倒轉 X 毫秒(X 可在「設定」內設置)", + "moveAllLinesXMsForward": "將所有行 快轉 X 毫秒(X 可在「設定」內設置)", "moveSelectedLinesCustomMsBack": "已選取行 倒轉({0:#,###,##0})自訂毫秒數,{1}", - "moveSelectedLinesCustomMsForward": "已選取行快轉({0:#,###,##0}),{1}", + "moveSelectedLinesCustomMsForward": "已選取行快轉({0:#,###,##0})自訂毫秒數,{1}", "moveSelectedLinesAndForwardCustomMsBack": "已選取行及後續各行 倒轉({0:#,###,##0})自訂毫秒數,{1}", "moveSelectedLinesAndForwardCustomMsForward": "已選取行及後續各行 快轉({0:#,###,##0})自訂毫秒數,{1}", "moveAllLinesCustomMsBack": "將所有行倒轉({0:#,###,##0})自訂毫秒數,{1}", @@ -3717,8 +4006,8 @@ "nameAndIgnoreList": "名稱/忽略 列表", "userWords": "使用者單字列表", "ocrFixList": "OCR 修正列表", - "unableToAddItem": "無法增加項目,它可能已存在!", - "unableToRemoveItem": "無法刪除項目!" + "unableToAddItem": "未能增加項目,它可能已存在!", + "unableToRemoveItem": "未能移除項目!" }, "chooseLanguage": { "title": "選擇介面語言" @@ -3733,12 +4022,12 @@ "disabled": "已停用", "notSupportedOnThisOs": "此作業系統不支援", "removePluginXConfirm": "是否移除擴充功能「{0}」?這將刪除擴充功能資料夾:{1}", - "couldNotRemovePluginX": "無法刪除擴充功能「{0}」:{1}", + "couldNotRemovePluginX": "無法移除擴充功能「{0}」:{1}", "pluginXHasNoExecutable": "擴充功能「{0}」沒有適用於此作業系統的可執行檔。", "pluginXFailed": "擴充功能「{0}」失敗。", "pluginXReportedError": "擴充功能「{0}」報告錯誤。", "pluginXDone": "{0}已完成", - "pluginXReturnedUnparsableSubtitle": "擴充功能「{0}」返回了無法解析的字幕。", + "pluginXReturnedUnparsableSubtitle": "擴充功能「{0}」回傳了一個無法被解析的字幕。", "loadingPluginList": "正在讀取擴充功能列表...", "noPluginsAvailable": "沒有可用的擴充功能。", "xPluginsAvailable": "{0} 個可用擴充功能。", @@ -3762,11 +4051,11 @@ }, "help": { "aboutSubtitleEdit": "關於...", - "checkForUpdates": "檢查有無更新", - "checkForUpdatesChecking": "正在檢查更新...", + "checkForUpdates": "檢查是否有更新", + "checkForUpdatesChecking": "正在檢查是否有更新...", "checkForUpdatesUpToDate": "您正在運行最新版本。", "checkForUpdatesNewVersionAvailable": "可用新版本:{0}", - "checkForUpdatesUnableToCheck": "無法檢查更新。", + "checkForUpdatesUnableToCheck": "未能檢查是否有更新。", "checkForUpdatesDownloadNewVersion": "下載新版本" }, "ocr": { @@ -3796,9 +4085,9 @@ "expandInfoX": "展開計數:{0}", "editNOcrDatabaseXWithYItems": "使用 {1:#,###,##0} 項目編輯 nOCR 資料庫 {0}", "newNOcrDatabase": "新增 nOCR資料庫", - "newBinaryImageCompareDatabase": "新增二進位圖像比較資料庫", + "newBinaryImageCompareDatabase": "新增「二進位圖像比對」資料庫", "renameNOcrDatabase": "重新命名 nOCR資料庫", - "renameBinaryImageCompareDatabase": "重新命名二進位圖像比對資料庫", + "renameBinaryImageCompareDatabase": "重新命名「二進位圖像比對」資料庫", "nOcrDatabase": "nOCR資料庫", "drawMode": "繪圖模式:", "addNewCharcter": "新增字元", @@ -3852,9 +4141,11 @@ "binaryImageCompareInspectImageMatches": "「二進位圖像比對」 - 檢查相符的圖像", "saveBlankTextTitle": "儲存空白文字?", "saveBlankTextPrompt": "儲存圖像的空白文字?", + "drawnLinesDoNotMatchTitle": "線條與圖像不相符", + "drawnLinesDoNotMatchPrompt": "某些繪製線條與圖片不相符(綠線必須位於字元上,紅線必須位於字元外),所以此字元將未能辨識。\n\n仍要儲存?", "yesAndNeverAskAgain": "是的,再也不會問了", "importTextFromSubtitleDotDotDot": "從字幕匯入文字...", - "importTextFromSubtitleOverwritePrompt": "有些行已經有 OCR 文字。覆蓋現有文字?", + "importTextFromSubtitleOverwritePrompt": "有些行已經有 OCR 文字。覆寫現有文字?", "importTextFromSubtitleNoMatchesFound": "在字幕檔案中找不到相符行。", "importTextFromSubtitleXLinesImported": "已匯入 {0} 行文字。", "exportTextAsSubtitleDotDotDot": "將文字匯出為字幕...", @@ -3882,7 +4173,7 @@ "vobSubColorEmphasis2": "強調2", "vobSubColorsInvert": "反轉背景/強調 1", "vobSubIsolateColors": "分離 VobSub 顏色以利 OCR", - "vobSubIsolateColorsHint": "在進行 OCR 前將每個 VobSub 影像重建為清晰的白底黑字,僅保留最高頻率的顏色(內文文字)並捨棄描邊/反鋸齒顏色。可改進灰階描邊導致字元連在一起的光碟辨識效果。", + "vobSubIsolateColorsHint": "在進行 OCR 前將每個 VobSub 圖像重建為清晰的白底黑字,僅保留最高頻率的顏色(內文文字)並捨棄描邊/反鋸齒顏色。可改進灰階描邊導致字元相連的光碟辨識效果。", "llamaCppOcrSettingsTitle": "llama.cpp OCR 設定", "selectTesseractDictionary": "選取 Tesseract 字典:", "inspectBinaryOcrAdditionsTitle": "檢查二進位 OCR 新增項目", @@ -3898,15 +4189,15 @@ "pickFallbackDatabase": "挑選後備 OCR 資料庫", "fallbackOcrDatabase": "後備 OCR 資料庫", "showAllOllamaModels": "顯示所有模型(不僅限於支援視覺功能的模型)", - "ollamaModelLikelyWrong": "Ollama 未回傳任何文字 - 選取的模型可能不支援 OCR/視覺功能", - "llamaCppNotDownloaded": "未下載 llama.cpp 引擎/模型 - 經由批次轉換設定下載", - "llamaCppReturnedNoText": "llama.cpp 未回傳任何文字 - 請檢查伺服器與模型狀態", + "ollamaModelLikelyWrong": "Ollama 無文字回傳 - 已選取模型可能未支援 OCR/視覺功能", + "llamaCppNotDownloaded": "未下載 llama.cpp 引擎/模型 - 經由「批次轉換設定」下載", + "llamaCppReturnedNoText": "llama.cpp 無文字回傳 - 請檢查伺服器與模型", "llamaCppDownloadEngineAndModelPrompt": "llama.cpp 需要下載 llama-server 引擎與已選取 OCR 模型。要立即下載嗎?", "llamaCppDownloadEnginePrompt": "llama.cpp 需要下載 llama-server 引擎。要立即下載嗎?", "llamaCppDownloadModelPrompt": "llama.cpp 需要下載已選取 OCR 模型。要立即下載嗎?", - "crispEmbedNotDownloaded": "未下載 CrispEmbed 引擎/模型 - 經由批次轉換設定下載", + "crispEmbedNotDownloaded": "未下載 CrispEmbed 引擎/模型 - 經由「批次轉換設定」下載", "crispEmbedReturnedNoText": "CrispEmbed 無文字回傳 - 請檢查模型", - "appleVisionReturnedNoText": "Apple Vision 未回傳任何文字 - 請檢查語言", + "appleVisionReturnedNoText": "Apple Vision 無文字回傳 - 請檢查語言", "crispEmbedSettingsTitle": "CrispEmbed 設置", "crispEmbedDescription": "具備多種模型後端的本機 OCR 引擎 - 可在此下載引擎與模型。", "trainNOcrDatabase": "訓練 nOCR 資料庫...", @@ -3956,8 +4247,8 @@ "progressBarChapters": "章節", "progressBarSplitterWidth": "分隔條寬度", "progressBarSplitterHeight": "分隔條高度", - "progressBarXAdjustment": "X 軸調整", - "progressBarYAdjustment": "Y 軸調整", + "progressBarXAdjustment": "X軸調節", + "progressBarYAdjustment": "Y軸調節", "progressBarTextAlignment": "文本對齊方式", "fontCollectorTitle": "字型收集器", "fontCollectorUsedIn": "使用於", @@ -3984,14 +4275,14 @@ "fontCollectorXFontFilesEmbedded": "已將 {0} 個字型檔案內嵌至字幕。", "fontCollectorTrimFonts": "僅內嵌使用中字元(裁切字型)", "trimFontsDotDotDot": "將字型裁切至僅使用中字元……", - "trimFontsPromptX": "要將 {0} 個內嵌字型裁切為僅目前使用的字元嗎?\n\n之後新增至字幕的文字可能會顯示缺少字元,因此請作為最後的步驟執行裁切。", + "trimFontsPromptX": "要將 {0} 個內嵌字型裁切為僅目前使用的字元嗎?\n\n之後新增至字幕的文字可能會顯示遺漏的字元,因此請作為最後的步驟執行裁切。", "trimFontsNoFontsToTrim": "沒有可裁切的內嵌字型。", "trimFontsXFontsTrimmedSavedYZ": "已裁切 {0} 個字型,節省 {1}。\n\n{2}", "trimFontsTotalSavingX": "總計節省:{0}", "trimFontsReasonNotTrueType": "只有 TrueType 字型可以裁切", "trimFontsReasonFontCollection": "不支援字型集合", "trimFontsReasonCouldNotParse": "無法讀取字型", - "trimFontsReasonNoSavings": "沒有縮減大小", + "trimFontsReasonNoSavings": "無尺寸縮減", "resolutionResamplerTitle": "變更解析度", "resolutionResamplerSourceRes": "來源解析度", "resolutionResamplerTargetRes": "目標解析度", @@ -4042,13 +4333,26 @@ "graphics": "圖形", "copyToStorageStyles": "複製到儲存樣式", "copyToFileStyles": "複製到檔案樣式", + "styleXAlreadyExistsInStorage": "儲存區中已存在名為「{0}」的樣式。\n\n要覆寫,或兩者皆保留(複本將使用新名稱)?", + "styleXAlreadyExistsInFile": "檔案中已存在名為「{0}」的樣式。\n\n要覆寫,或兩者皆保留(複本將使用新名稱)?", + "styleNameCannotBeEmpty": "樣式名稱不可為空白。", + "styleNameXIsUsedMoreThanOnce": "檔案中有很多個樣式使用「{0}」這個樣式名稱。", + "overwrite": "覆寫", + "keepBoth": "兩者皆保留", + "doThisForAllConflictingStyles": "對所有衝突的樣式都這樣做。", "setStyleAsDefault": "將樣式設置為預設", - "takeUsagesFromDotDotDot": "借鏡用法...", + "takeUsagesFromDotDotDot": "採取用法根據...", "replaceStyleWithDotDotDot": "取代樣式為...", "noAttachmentsFound": "在已選取 ASSA 檔案中找不到附件。", "deleteStyleQuestion": "刪除樣式?", "deleteStylesQuestion": "刪除樣式?", + "deleteXStylesQuestion": "確定要刪除 {0} 個樣式嗎?", + "deleteXStylesFromStorageQuestion": "確定要從儲存區刪除 {0} 個樣式嗎?", + "deleteStyleXFromStorageQuestion": "確定要從儲存區刪除樣式「{0}」嗎?", + "deleteStyleXFromFileQuestion": "確定要從目前檔案刪除樣式「{0}」嗎?", "openStyleImportFile": "開啟字幕檔案以從中匯入樣式", + "styleImportFiles": "樣式檔案 (.ass、.sty、Subtitle Edit 4 類別.template)", + "ssaStyleImportFiles": "樣式檔案 (.ssa, .ass, .sty)", "primary": "主要", "secondary": "次要", "applyOverrideTags": "應用覆蓋標籤", @@ -4077,11 +4381,11 @@ "advancedEffectTypewriter": "打字機", "advancedEffectTypewriterDescription": "在字幕持續時間內逐字顯示字元", "advancedEffectTypewriterWithHighlight": "帶有醒目顯示的打字機", - "advancedEffectTypewriterWithHighlightDescription": "逐字顯示字元,並在目前顯示字元加上發光醒目顯示", + "advancedEffectTypewriterWithHighlightDescription": "逐字呈現文字,並在目前字元加上發光醒目顯示", "advancedEffectWordByWord": "逐字逐句", "advancedEffectWordByWordDescription": "在字幕持續時間內逐字顯示單字", "advancedEffectKaraoke": "卡拉OK", - "advancedEffectKaraokeDescription": "依序逐字醒目顯示字元,從左至右呈現,如同卡拉 OK 歌詞", + "advancedEffectKaraokeDescription": "依序醒目顯示每個字元,從左至右呈現文字,如同卡拉 OK 歌詞", "advancedEffectFancyKaraoke": "卡拉OK - 生效單字彈跳", "advancedEffectFancyKaraokeDescription": "透過預先標記的生效單字(經由 \\u1 下劃線或醒目顯示顏色)增強字幕 — 淡化未作用單字,並對作用中單字採取彈跳發光的動畫效果", "advancedEffectScrambleReveal": "亂碼揭示", @@ -4111,7 +4415,7 @@ "advancedEffectGlitch": "毛刺", "advancedEffectGlitchDescription": "帶有色差、白色閃光和失真疊加的數位故障", "advancedEffectBounceIn": "彈入", - "advancedEffectBounceInDescription": "每個角色都以彈性彈出動畫彈出,從左到右交錯", + "advancedEffectBounceInDescription": "每個字元都以彈性彈出動畫彈出,從左到右交錯", "advancedEffectFireflies": "螢火蟲(背景)", "advancedEffectFirefliesDescription": "溫暖的黃琥珀色點有機地飄過螢幕,發出脈動的光芒", "advancedEffectMatrix": "矩陣", @@ -4130,7 +4434,7 @@ "advancedEffectFancyKaraokeActiveColor": "作用中顏色", "advancedEffectFancyKaraokeInactiveColor": "未作用顏色", "advancedEffectWordSpacingPixels": "間距(像素)", - "advancedEffectSlowZoomOut": "緩慢縮小", + "advancedEffectSlowZoomOut": "慢慢縮小", "advancedEffectSlowZoomOutDescription": "文字起頭偏大,且在字幕持續時間內逐漸收縮至正常大小", "advancedEffectSlowZoomIn": "慢慢放大", "advancedEffectSlowZoomInDescription": "文字起頭正常大小,且在字幕持續時間內逐漸偏大增長", @@ -4139,18 +4443,18 @@ "advancedEffectFadeOut": "轉場 - 淡出", "advancedEffectFadeOutDescription": "逐行淡出效果,畫面最後成黑色。", "advancedEffectTvClose": "轉場 - 電視關閉", - "advancedEffectTvCloseDescription": "黑色條從底端和頂端向內生長,而中間褪色為白色(黑色頂部/白色中間/黑色底部)。後半部是純黑色。", + "advancedEffectTvCloseDescription": "黑色條從底端和頂端向內生長,而中間淡化至白色(黑色頂部/白色中間/黑色底部)。後半部是純黑色。", "advancedEffectSlideInLeft": "從左側滑入", "advancedEffectSlideInLeftDescription": "文字從螢幕外左側滑入、停留,而後退出回到左側", "advancedEffectSlideInRight": "從右側滑入", "advancedEffectSlideInRightDescription": "文字從螢幕外右側滑入、停留,而後退出回到右側", - "advancedEffectFadeInOutDescription": "每則字幕開始時淡入,結束時淡出", + "advancedEffectFadeInOutDescription": "每則字幕起頭時淡入,結尾時淡出", "advancedEffectWordFlip3D": "3D 單字翻轉", "advancedEffectWordFlip3DDescription": "每個單字在朗讀時會以 3D 翻轉式摺疊與彈性彈出的方式呈現 — 仿現代「短影音」樣式聽障字幕", "advancedEffectCinematicTitle": "電影感標題", "advancedEffectCinematicTitleDescription": "字母痕跡從寬間距至正常間距,同時從朦朧逐漸清晰——經典電影片名揭示效果", "advancedEffectLowerThird": "左下角名牌(Lower Third)", - "advancedEffectLowerThirdDescription": "底端偏左顯示姓名與職務的橫幅,並搭配強調色條滑入——採訪/紀錄片風格(第一行=姓名,後續各行=職務)", + "advancedEffectLowerThirdDescription": "底端偏左顯示姓名與職務的橫幅,並搭配強調色條滑入 - 採訪/紀錄片風格(第一行=姓名,後續各行=職務)", "advancedEffectLowerThirdAccentColor": "強調顏色", "overrideTagsHistory": "覆蓋標籤歷程紀錄" }, @@ -4195,7 +4499,7 @@ "detailGapToPrevious": "至上一行:{0} < {1} 毫秒", "detailGapToNext": "至下一行:{0} < {1} 毫秒", "detail": "詳情", - "tip": "雙擊某一行(或按 Enter 鍵)即可跳至該行。要運行哪些檢查,以及其限制,可在「設定 > 一般」中進行設置。", + "tip": "雙擊某一行(或按 Enter鍵)即可跳至該行。要運行哪些檢查,以及其限制,可在「設定 > 一般」中進行設置。", "exportAsText": "文字檔案 (.txt)...", "exportAsExcel": "Excel 檔案 (.xlsx)...", "exportAsHtml": "網頁 (.html)...", diff --git a/src/ui/Assets/Languages/English.json b/src/ui/Assets/Languages/English.json index fba4c4920a0..ad5126eb5d6 100644 --- a/src/ui/Assets/Languages/English.json +++ b/src/ui/Assets/Languages/English.json @@ -1,6 +1,6 @@ { "title": "Subtitle Edit", - "version": "v5.3.0-beta23", + "version": "v5.3.0-rc1", "translatedBy": "", "cultureName": "en-US", "general": { @@ -656,12 +656,14 @@ "styleExaggeration": "Style exaggeration", "styles": "Styles", "subtitleFile": "Subtitle file", + "subtitleFileChangedOutsideX": "The file \"{0}\" was changed outside Subtitle Edit.\n\nReload it and lose your changes?", "subtitleFileName": "Subtitle file name", "subtitleFileSaved": "Subtitle file saved", "subtitleFileSavedToX": "Subtitle file saved to {0}", "subtitleFiles": "Subtitle files", "subtitleFormats": "Subtitle formats", "subtitleLoadedX": "Subtitle loaded: {0}", + "subtitleReloadedChangedOutsideX": "Subtitle reloaded (changed outside Subtitle Edit): {0}", "suffix": "Suffix", "suggestions": "Suggestions", "sync": "Sync", @@ -745,6 +747,14 @@ "videoCustom3ForwardX": "Video, custom milliseconds ({0:#,###,##0}) forward, 3", "videoCustom4BackX": "Video, custom milliseconds ({0:#,###,##0}) back, 4", "videoCustom4ForwardX": "Video, custom milliseconds ({0:#,###,##0}) forward, 4", + "videoCustom1BackAndPauseX": "Video, custom milliseconds ({0:#,###,##0}) back and pause, 1", + "videoCustom1ForwardAndPauseX": "Video, custom milliseconds ({0:#,###,##0}) forward and pause, 1", + "videoCustom2BackAndPauseX": "Video, custom milliseconds ({0:#,###,##0}) back and pause, 2", + "videoCustom2ForwardAndPauseX": "Video, custom milliseconds ({0:#,###,##0}) forward and pause, 2", + "videoCustom3BackAndPauseX": "Video, custom milliseconds ({0:#,###,##0}) back and pause, 3", + "videoCustom3ForwardAndPauseX": "Video, custom milliseconds ({0:#,###,##0}) forward and pause, 3", + "videoCustom4BackAndPauseX": "Video, custom milliseconds ({0:#,###,##0}) back and pause, 4", + "videoCustom4ForwardAndPauseX": "Video, custom milliseconds ({0:#,###,##0}) forward and pause, 4", "videoExtension": "Video file extension", "videoFile": "Video file", "videoFileGenerated": "Video file generated", @@ -1164,6 +1174,7 @@ "originalIsReadOnlyNotSaved": "The original subtitle is a read-only reference and was not saved", "originalIsEmptyNotSaved": "The original subtitle has no text at all and was not saved", "originalIsReadOnlyReference": "The original subtitle is open as a read-only reference", + "closeTranslationQuestionXY": "Close the translation \"{0}\"?\n\nThe original subtitle \"{1}\" becomes the subtitle you edit. The translation file itself is not changed.", "allowEditOfOriginalSubtitle": "Allow edit of original subtitle", "showAllOriginalLinesX": "Show all original lines ({0} have no match here)", "showAllOriginalLinesHint": "The lines with no match are shown as extra rows, so you can see what the translation is missing.", @@ -2180,6 +2191,18 @@ "addFolderRecursiveDotDotDot": "Add folder recursive...", "selectFolderToConvert": "Select folder with files to convert", "includeSubfolders": "Include subfolders when adding a folder", + "videoFilesWhenAddingFolder": "Video files (mkv, mp4, ts...) when adding a folder", + "videoFilesAsk": "Ask", + "videoFilesInclude": "Add their subtitle tracks", + "videoFilesSkip": "Skip", + "folderContainsXVideoFiles": "The folder contains {0} video file(s) (mkv, mp4, ts...).\n\nAdd their embedded subtitle tracks too, or only the subtitle files?", + "addVideoFiles": "Add video files", + "skipVideoFiles": "Subtitle files only", + "doNotAskAgainVideoFiles": "Do not ask again (change in Batch convert settings)", + "settingsSectionOutput": "Output", + "settingsSectionAddingFiles": "Adding files", + "settingsSectionImageBasedSubtitles": "Image-based subtitles (OCR)", + "settingsSectionOther": "Other", "keepSourceFileTimestamp": "Keep source file date/time on output files", "preventSleepWhileConverting": "Prevent computer from sleeping while converting", "scanningFolderX": "Scanning {0}...", @@ -2502,7 +2525,21 @@ "correctWordsX": "Correct words: {0}", "namesX": "Names: {0}", "addedToDictionaryX": "Added to dictionary: {0}", - "doNotShowThisAgain": "Do not show this message again" + "doNotShowThisAgain": "Do not show this message again", + "editUseAlwaysList": "Edit \"Use always\" list...", + "useAlwaysListTitle": "Spell check - \"Use always\" list", + "useAlwaysListHeader": "\"Use always\" list", + "useAlwaysListDescription": "Words spell check replaces automatically, saved by \"Change all\" and \"Use always\". A pair is only used when the word is misspelled.", + "useAlwaysWord": "Misspelled word", + "xPairs": "{0:#,##0} pairs", + "xUnusedPairs": "{0:#,##0} never used", + "useAlwaysUnusedHint": "Never used - this word is spelled correctly (or is a name or user word), so spell check never flags it", + "removeUnused": "Remove unused", + "useAlwaysEmpty": "No \"Use always\" pairs for this language yet", + "useAlwaysEmptyHint": "Pairs are added when you click \"Change all\" or \"Use always\" in spell check.", + "useAlwaysNoMatch": "No pairs match the search", + "useAlwaysSameWord": "The replacement must be different from the word.", + "useAlwaysRememberOff": "\"Remember 'Use always' list\" is turned off in Settings, so spell check does not use or save this list." }, "video": { "burnIn": { @@ -2683,6 +2720,12 @@ "speakFromCurrentLineHint": "Reads the lines aloud one after another, starting at the current line. Press Escape or click another line to stop.", "stopSpeaking": "Stop speaking", "speakingLineX": "Speaking line {0}... (Escape to stop)", + "playWithSpeechTitle": "Play with speech", + "playWithSpeechDotDotDot": "Play with speech...", + "playWithSpeechHint": "Plays the video and speaks each line when the video reaches it. Pause and seek as usual; press Escape to stop.", + "lowerVideoVolumeWhileSpeaking": "Lower video volume while speaking", + "pauseVideoWhenSpeechRunsLate": "Pause video when speech runs into the next line", + "waitingForSpeechLineX": "Generating speech for line {0}... (Escape to stop)", "voiceKindClone": "Cloned voice", "voiceKindPreset": "Built-in", "voiceKindModel": "Voice model", @@ -2747,6 +2790,12 @@ "advancedTtsSettings": "Advanced TTS settings", "advancedTtsAudioProcessing": "Audio processing", "advancedTtsOutput": "Output", + "advancedTtsVoiceCloning": "Voice cloning", + "cleanCloneReferences": "Clean voice-clone references (Sidon)", + "cleanCloneReferencesDescription": "Removes music, noise and reverb from the audio a voice is cloned from: clips taken from the video and recordings imported in the voice manager. Uses the Sidon speech restoration model on audio.cpp (940 MB download). The cloned voice no longer copies the background, but very quiet or overlapping speech can come out muffled.", + "cleaningCloneReferencesDotDotDot": "Cleaning voice-clone references...", + "downloadSidonTitle": "Download Sidon?", + "downloadSidonQuestionX": "Cleaning voice-clone references needs the Sidon speech restoration model ({0}).\n\nDownload it now? Without it the references are used as they are.", "proAudioPostProcessing": "Pro audio post-processing", "proAudioPostProcessingDescription": "Adds EQ, noise gate, compression, loudness normalization (-16 LUFS) and a short fade in/out to every clip.", "generatingSpeechSegmentXOfY": "Generating speech: segment {0} of {1}", @@ -3377,6 +3426,7 @@ "promptBeforeDelete": "Prompt before delete", "rememberPositionAndSize": "Remember window position and size", "titleBarFullFileName": "Show full file path in title bar", + "showRecentFiles": "Show recent files (for reopen)", "openLastFileOnStart": "Open last recent file on start", "autoSave": "Auto-save (save the open file while editing)", "autoBackupOn": "Auto-backup", @@ -3404,6 +3454,7 @@ "waveformUseSkiaRenderer": "Use experimental fast renderer", "waveformCenterVideoPositionAlsoWhenPaused": "Center video position also while paused", "waveformSelectCurrentSubtitleWhilePaused": "Select current subtitle also while paused", + "waveformRememberZoom": "Remember zoom (horizontal and vertical)", "waveformShowToolbar": "Show toolbar", "waveformShowToolbarEdit": "Edit toolbar...", "waveformShowToolbarEditLabel": "Toolbar items", @@ -3606,6 +3657,10 @@ "usePositionFromSubtitleFile": "Use position from subtitle file (TTML/PAC/EBU STL)", "marginIsPartOfSubtitleArea": "Margin is part of the subtitle area", "textJustify": "Justify lines", + "deinterlace": "Deinterlace video", + "deinterlaceOff": "Off", + "deinterlaceAuto": "Auto (interlaced frames only)", + "deinterlaceAlways": "Always", "pixelWidthInfo": "Green lines = max-width limit | Red area = text exceeds limit", "spellCheckEngineHunSpelll": "Hunspell", "spellCheckEngineMsWord": "MS Word", @@ -4187,6 +4242,8 @@ "drawZoomOut": "Zoom Out (Ctrl+-)", "drawResetView": "Reset View (Ctrl+0)", "drawToggleGrid": "Toggle Grid (Ctrl+G)", + "drawTogglePreview": "Toggle preview (F9)", + "drawPreviewFailed": "Preview failed - ffmpeg with libass (subtitles filter) is required", "drawCopyToClipboard": "Copy to Clipboard (Ctrl+C)", "drawShapes": "Shapes", "drawSelectedPoint": "Selected point", diff --git a/src/ui/Assets/Languages/German.json b/src/ui/Assets/Languages/German.json index 6e31a05cbf4..7d0c93748fa 100644 --- a/src/ui/Assets/Languages/German.json +++ b/src/ui/Assets/Languages/German.json @@ -1,12 +1,30 @@ { "title": "Subtitle Edit", - "version": "v5.2.0-rc7", - "translatedBy": "Übersetzt von Christoph Kitsche. Überarbeitet von Netspark und Stephan Lenhart.", + "version": "v5.3.0-beta23", + "translatedBy": "Übersetzt von Christoph Kitsche. Überarbeitet von Netspark, Stephan Lenhart und FunkyKnilch.", "cultureName": "de-DE", "general": { "abort": "Abbrechen", "actor": "Darsteller", "actors": "Darsteller", + "setActorDotDotDot": "Darsteller festlegen...", + "actorPickerTitle": "Darsteller festlegen", + "actorPickerFilterHint": "Tippen zum Filtern oder neuen Sprechernamen eingeben", + "actorPickerKeysHint": "Drücken Sie eine Zahl oder die Eingabetaste, um den Sprecher festzulegen. Alt+Bild-auf/Bild-ab ändert die Reihenfolge, Entf entfernt den Sprecher.", + "actorPickerNewActorX": "Neuer Darsteller \"{0}\"", + "actorPickerLinesSelectedX": "Zeilen ausgewählt: {0}", + "setStyleDotDotDot": "Stil festlegen...", + "stylePickerTitle": "Stil festlegen", + "stylePickerFilterHint": "Tippen zum Filtern oder neuen Stil-Namen eingeben", + "stylePickerKeysHint": "Drücken Sie eine Zahl (1–9, 0), um den Stil für alle ausgewählten Zeilen festzulegen.", + "stylePickerNewStyleX": "Neuer Stil \"{0}\"", + "stylePickerNewStyleInfo": "Drücken Sie Enter, um diesen Stil mit Standardeinstellungen hinzuzufügen und auf die ausgewählten Zeilen anzuwenden. Verwenden Sie \"Stile verwalten...\", um Schriftart, Farben und Position später zu ändern.", + "stylePickerCurrentStyleX": "Aktueller Stil: {0}", + "stylePickerLinesUsingStyleX": "Zeilen mit diesem Stil: {0}", + "stylePickerMarginsXYZ": "Links {0}, Rechts {1}, Vertikal {2}", + "stylePickerSetStyle": "_Stil festlegen", + "stylePickerManageStyles": "Stile verwalten...", + "stylePickerManageStylesHint": "Schließen Sie dieses Fenster und öffnen Sie die Stil-Verwaltung, um Schriftarten, Farben, Positionen und mehr zu bearbeiten.", "add": "Hinzufügen", "addDotDotDot": "Hinzufügen...", "addToNamesListCaseSensitive": "Zur Namensliste hinzufügen (Groß-/Kleinschreibung)", @@ -19,7 +37,7 @@ "after": "Nachher", "alignment": "Ausrichtung", "alignmentDotDotDot": "Ausrichtung...", - "alignmentX": "Ausrichtung „{0}“ für ausgewählte Zeilen festlegen", + "alignmentX": "Ausrichtung \"{0}\" für ausgewählte Zeilen festlegen", "all": "Alle", "allFiles": "Alle Dateien", "alphaAdjustment": "Alpha-Anpassung", @@ -143,7 +161,7 @@ "copySubtitle": "Untertitel kopieren", "copyTextOnly": "Kopieren (nur Text)", "copyTextToClipboard": "Text in die Zwischenablage kopieren", - "couldNotSaveFileXErrorY": "Die Datei „{0}“ konnte nicht gespeichert werden.\nFehler: {1}", + "couldNotSaveFileXErrorY": "Die Datei \"{0}\" konnte nicht gespeichert werden.\nFehler: {1}", "couldNotOpenFileXErrorY": "Datei \"{0}\" konnte nicht geöffnet werden. Fehler: {1}", "count": "Zählen", "cps": "Zeichen/Sek.", @@ -224,7 +242,7 @@ "fileNameX": "Dateiname: {0}", "fileSaved": "Datei gespeichert", "fileSavedToX": "Datei gespeichert unter {0}", - "fileXAlreadyExists": "Datei „{0}“ existiert bereits.", + "fileXAlreadyExists": "Datei \"{0}\" existiert bereits.", "fileXOfY": "Datei {0} von {1}", "filter": "Filter", "filterByLayer": "Nach Ebene filtern", @@ -251,7 +269,7 @@ "forward": "Vorwärts", "foundNoMatches": "Keine Treffer gefunden", "foundOneMatch": "1 Treffer", - "foundXInLineYZ": "„{0}“ gefunden in Zeile {1}, Position {2}", + "foundXInLineYZ": "\"{0}\" gefunden in Zeile {1}, Position {2}", "foundXMatches": "{0} Treffer", "frameRate": "Bildrate", "frames": "Frames", @@ -411,7 +429,7 @@ "outputFolder": "Ausgabeordner", "outputProperties": "Ausgabeeigenschaften", "overwriteExistingFiles": "Vorhandene Dateien überschreiben?", - "overwriteFilesInFolderX": "Dateien im Ordner „{0}“ überschreiben", + "overwriteFilesInFolderX": "Dateien im Ordner \"{0}\" überschreiben", "overwriteQuestion": "Überschreiben?", "padding": "Einrückung", "parameters": "Parameter", @@ -447,7 +465,7 @@ "playSelectedLinesWithLoop": "Ausgewählte Zeilen in Dauerschleife abspielen", "playSelectedLinesAndFocusWaveform": "Ausgewählte Zeilen abspielen und Wellenform fokussieren", "playSelectedLinesWithLoopAndFocusWaveform": "Ausgewählte Zeilen in Schleife abspielen und Wellenform fokussieren", - "pleaseEnterAValidValueForX": "Bitte geben Sie einen gültigen Wert für „{0}“ ein.", + "pleaseEnterAValidValueForX": "Bitte geben Sie einen gültigen Wert für \"{0}\" ein.", "pleaseWait": "Bitte warten...", "position": "Position", "positionX": "Position: {0}", @@ -513,8 +531,8 @@ "saveOriginalAsTitle": "Original speichern unter", "saveTranslationAsTitle": "Übersetzung speichern unter", "saveVideoAsVideoTitle": "Video speichern unter", - "savedChangesToX": "„{0}“ gespeichert", - "savedChangesToXAndY": "„{0}“ und „{1}“ gespeichert", + "savedChangesToX": "\"{0}\" gespeichert", + "savedChangesToXAndY": "\"{0}\" und \"{1}\" gespeichert", "savingDotDotDot": "Speichere...", "script": "Skript", "search": "Suchen", @@ -562,22 +580,22 @@ "shortcut": "Tastenkombination", "shortcuts": "Tastenkürzel", "show": "Anzeigen", - "showActorColumn": "Spalte „Schauspieler“ anzeigen", - "showCpsColumn": "Spalte „Chars/sec“ anzeigen", - "showDurationColumn": "Spalte „Dauer“ anzeigen", - "showGapColumn": "Spalte „Abstand“ anzeigen", + "showActorColumn": "Spalte \"Schauspieler\" anzeigen", + "showCpsColumn": "Spalte \"Chars/sec\" anzeigen", + "showDurationColumn": "Spalte \"Dauer\" anzeigen", + "showGapColumn": "Spalte \"Abstand\" anzeigen", "showForcedColumn": "Spalte \"Erzwungen\" anzeigen", - "showStartColumn": "Spalte „Start“ anzeigen", - "showHideColumn": "Spalte „Ausblenden“ anzeigen", + "showStartColumn": "Spalte \"Start\" anzeigen", + "showHideColumn": "Spalte \"Ausblenden\" anzeigen", "showHistory": "Verlauf (für Rückgängig) anzeigen", - "showLayerColumn": "Spalte „Ebene“ anzeigen", + "showLayerColumn": "Spalte \"Ebene\" anzeigen", "showPreview": "Vorschau anzeigen", "showShotChangesList": "Liste der Shotänderungen anzeigen", - "showStyleColumn": "Spalte „Stil“ anzeigen", + "showStyleColumn": "Spalte \"Stil\" anzeigen", "showTeletext": "TT anzeigen", "showTimeCodes": "Zeitstempel anzeigen", - "showWpmColumn": "Spalte „Wörter/Minute“ anzeigen", - "showPixelWidthColumn": "Spalte „Pixelbreite“ anzeigen", + "showWpmColumn": "Spalte \"Wörter/Minute\" anzeigen", + "showPixelWidthColumn": "Spalte \"Pixelbreite\" anzeigen", "shrink": "Schrumpfen", "singleMode": "Einzelmodus", "textTotalLength": "Text - Gesamtlänge", @@ -587,7 +605,7 @@ "skip": "Überspringen", "skipAll": "Alle überspringen", "skipOnce": "Einmal überspringen", - "smart": "Smart", + "smart": "Intelligent", "snapSelectedLinesEndToPreviousShotChange": "Ende ausgewählter Zeilen an vorherigen Szenenwechsel anpassen", "snapSelectedLinesStartToNextShotChange": "Start ausgewählter Zeilen an nächsten Szenenwechsel anpassen", "snapSelectedLinesToNearestShotChange": "Start/Ende ausgewählter Zeilen an nächsten Szenenwechsel anpassen", @@ -623,7 +641,7 @@ "status": "Status", "stereo": "Stereo", "spreadsheetFiles": "Tabellendateien", - "stop": "Stop", + "stop": "Stopp", "strikeout": "Durchgestrichen", "style": "Stil", "styleExaggeration": "Stilübertreibung", @@ -720,7 +738,7 @@ "videoExtension": "Videodateierweiterung", "videoFile": "Videodatei", "videoFileGenerated": "Videodatei erstellt", - "videoFileGeneratedX": "Videodatei erzeugt: „{0}“", + "videoFileGeneratedX": "Videodatei erzeugt: \"{0}\"", "videoFiles": "Videodateien", "videoAndAudioFiles": "Video- und Audiodateien", "videoOffset": "Video-Offset", @@ -749,12 +767,12 @@ "xOfYFiles": "{0:#,###,##0} von {1:#,###,##0} Dateien", "xFilesConvertedInY": "{0:#,###,##0} Dateien konvertiert in {1}", "xNoLinesUpdated": "{0}: keine Zeile(n) aktualisiert", - "xNotFound": "„{0}“ nicht gefunden", + "xNotFound": "\"{0}\" nicht gefunden", "xOfYLinesUpdated": "{0}: {1} von {2} Zeile(n) aktualisiert", "xOfYLinesUpdatedAndZNeighborsAdjusted": "{0}: {1} von {2} Zeile(n) aktualisiert, {3} Nachbar(n) angepasst", "noBookmarksFound": "Keine weiteren Lesezeichen gefunden", "xRequiresAnApiKey": "{0} benötigt einen API-Schlüssel", - "xRequiresAValidUrl": "{0} erfordert eine gültige URL (z. B. „http://...“ oder „https://...“)", + "xRequiresAValidUrl": "{0} erfordert eine gültige URL (z. B. \"http://...\" oder \"https://...\")", "yes": "Ja", "zeroBytes": "0 Bytes", "copyToClipboard": "In Zwischenablage kopieren", @@ -871,7 +889,7 @@ "renumber": "Neu nummerieren...", "removeTextForHearingImpaired": "Untertitel für Hörgeschädigte entfernen...", "removeUnicodeCharacters": "_Unicode-Zeichen entfernen/ersetzen...", - "convertActors": "Akteure konvertieren...", + "convertActors": "Darsteller konvertieren...", "joinSubtitles": "Untertitel verbinden...", "splitSubtitle": "Untertitel teilen...", "mergeTwoSubtitles": "Zwei Untertitel zusammenführen...", @@ -900,8 +918,8 @@ "openVideoFromUrl": "Video über URL öffnen...", "closeVideoFile": "Videodatei schließen", "audioTracks": "_Audiospuren", - "speechToText": "Sprache zu Text...", - "textToSpeech": "Text zu Sprache...", + "speechToText": "Sprache-zu-Text...", + "textToSpeech": "Text-zu-Sprache...", "videoOcr": "OCR für eingebrannte Untertitel...", "setVideoOffset": "Video Ausgleich einstellen...", "updateVideoOffsetX": "Update video offset from {0}...", @@ -934,8 +952,8 @@ "fixRightToLeftViaUnicodeControlCharacters": "RTL über Unicode Steuerzeichen korrigieren (ausgewählte Texte)", "removeUnicodeControlCharacters": "Unicode-Steuerzeichen entfernen (aus ausgewählten Zeilen)", "reverseRightToLeftStartEnd": "Rechts nach links Start/Ende umkehren", - "pointSync": "Punkt Anpassung...", - "pointSyncViaOther": "Punkt Anpassung mit anderen Untertitel...", + "pointSync": "Punkt-Synchronisation...", + "pointSyncViaOther": "Punkt-Synchronisation über anderen Untertitel...", "sortSubtitles": "Untertitel _sortieren...", "setLayer": "Ebene festlegen...", "filterLayersForDisplayDotDotDot": "Ebenen für Anzeige filtern...", @@ -951,7 +969,7 @@ "saveAsHint": "Untertitel unter neuem Namen speichern {0}", "findHint": "Text in Untertiteln suchen {0}", "replaceHint": "Text suchen und ersetzen {0}", - "multipleReplaceHint": "Mehrfachersetzung {0}", + "multipleReplaceHint": "Mehrfach ersetzen {0}", "spellCheckHint": "Untertitel auf Rechtschreibfehler prüfen {0}", "fixCommonErrorsHint": "Häufige Untertitelfehler beheben {0}", "removeTextForHiHint": "Text für Hörgeschädigte entfernen {0}", @@ -960,7 +978,7 @@ "beautifyTimeCodesHint": "Zeitstempel optimieren {0}", "burnInHint": "Untertitel in eine Videodatei einbrennen {0}", "autoTranslateHint": "Automatisch übersetzen {0}", - "speechToTextHint": "Sprache zu Text {0}", + "speechToTextHint": "Sprache-zu-Text {0}", "settingsHint": "Programmeinstellungen und Präferenzen anpassen {0}", "layoutHint": "Symbolleiste und Panel-Layout ändern {0}", "helpHint": "Hilfe-Website {0} öffnen", @@ -990,6 +1008,8 @@ "selectCurrentLineWhilePlayingHint": "Aktuellen Untertitel während der Wiedergabe auswählen {0}", "videoPosition": "Videoposition {0}", "videoPositionTextBox": "Textfeld für Videoposition {0}", + "toolbarLineBreakX": "Zeilenumbruch {0} (Rest der Symbolleiste wird in neuer Zeile fortgesetzt)", + "initialText": "Ursprünglicher Text der ausgewählten Zeile", "hideWaveformToolbar": "Symbolleiste ausblenden {0}", "resetZoomAndSpeed": "Zoom und Wiedergabegeschwindigkeit zurücksetzen {0}", "removeBlankLines": "Leere Zeilen entfernen {0}", @@ -1003,6 +1023,9 @@ "textPlayHint": "Aktuellen Untertitel abspielen und am Ende stoppen {0}", "textPauseHint": "Wiedergabe pausieren {0}", "textNextHint": "Nächsten Untertitel abspielen und am Ende stoppen {0}", + "moveSelectedLines": "Ausgewählte Zeilen verschieben (Zurück/Vorwärts Schaltflächen)", + "moveSelectedLinesAndFollowing": "Ausgewählte und nachfolgende Zeilen verschieben (Zurück/Vorwärts Schaltflächen)", + "moveAllLines": "Alle Zeilen verschieben (Zurück/Vorwärts Schaltflächen)", "guessStartNoLineSelected": "Start schätzen: keine Zeile ausgewählt", "guessStartTimeCodesLocked": "Start schätzen: Zeitstempel sind gesperrt", "guessStartNoWaveform": "Start schätzen: keine Wellenform", @@ -1018,7 +1041,7 @@ "guessEndNoSilenceFoundNearLineX": "Ende schätzen: keine Stille in der Nähe von Zeile {0} gefunden", "guessEndMovedLineXByYMs": "Ende schätzen: Ende von Zeile {0} um {1} ms verschoben" }, - "audioTrackIsNowX": "Audiospur ist jetzt „{0}“", + "audioTrackIsNowX": "Audiospur ist jetzt \"{0}\"", "audioTrackX": "Audiospur {0}", "autoBreakHint": "Ausgewählte Zeilen automatisch umbrechen", "charactersPerSecond": "Zeichen/Sek: {0}", @@ -1029,10 +1052,10 @@ "deleteTextAndShiftCellsUp": "Text löschen und Zellen nach oben verschieben", "endTimeMustBeAfterStartTime": "Die Endzeit muss nach der Startzeit liegen.", "errorLoad7Zip": "Diese Datei scheint eine komprimierte 7-Zip-Datei zu sein.\nSubtitle Edit kann keine komprimierten Dateien öffnen.", - "errorLoadBinaryZeroes": "Diese Datei enthält nur Nullzeichen.\n\nFalls Sie diese Datei mit Subtitle Edit bearbeitet haben, dürfte eine Sicherung vorliegen unter Menüpunkt „Datei -> Automatische Sicherung wiederherstellen...“\t\t", + "errorLoadBinaryZeroes": "Diese Datei enthält nur Nullzeichen.\n\nFalls Sie diese Datei mit Subtitle Edit bearbeitet haben, dürfte eine Sicherung vorliegen unter Menüpunkt \"Datei -> Automatische Sicherung wiederherstellen...\"\t\t", "errorLoadGZip": "Diese Datei scheint eine komprimierte GZip-Datei zu sein.\nSubtitle Edit kann keine komprimierten Dateien öffnen.", "errorLoadLargeFile": "Diese Datei ist zu groß, um als Untertiteldatei geöffnet zu werden.", - "errorLoadVideoFilePrompt": "Diese Datei scheint eine Videodatei zu sein:\n{0}\n\nSie können ein Video über „Video -> Video öffnen“ oder durch Ziehen und Ablegen eines Videos auf den Videoplayer öffnen.\n\nMöchten Sie sie als Videodatei öffnen?", + "errorLoadVideoFilePrompt": "Diese Datei scheint eine Videodatei zu sein:\n{0}\n\nSie können ein Video über \"Video -> Video öffnen\" oder durch Ziehen und Ablegen eines Videos auf den Videoplayer öffnen.\n\nMöchten Sie sie als Videodatei öffnen?", "errorLoadJpg": "Bei dieser Datei scheint es sich um eine JPG-Bilddatei zu handeln.\nUntertitel bearbeiten kann keine Bilddateien öffnen.", "errorLoadPng": "Bei dieser Datei handelt es sich offenbar um eine PNG-Bilddatei.\nUntertitel bearbeiten kann keine Bilddateien öffnen.", "errorLoadRar": "Diese Datei scheint eine komprimierte 7-Zip-Datei zu sein.\nSubtitle Edit kann keine komprimierten Dateien öffnen.", @@ -1049,11 +1072,11 @@ "insertEmptyTextAndShiftCellsDown": "Leertext einfügen und Zellen nach unten verschieben", "insertTextFromSubtitleDotDotDot": "Text aus Untertitel einfügen...", "copyTextFromOriginalToCurrent": "Text vom Original in aktuellen Untertitel kopieren", - "insertedXTextsFromSubtitleY": "{0} Texte aus der Untertiteldatei „{1}“ eingefügt", + "insertedXTextsFromSubtitleY": "{0} Texte aus der Untertiteldatei \"{1}\" eingefügt", "italicHint": "Ausgewählte Zeilen/Text kursiv", "joinedSubtitleLoaded": "Verbundener Untertitel geladen", "lineXTextAndTimingChanged": "Zeile {0}: Text und Timing geändert", - "lineXTextChangedFromYToZ": "Zeile {0}: Text von „{1}“ in „{2}“ geändert", + "lineXTextChangedFromYToZ": "Zeile {0}: Text von \"{1}\" in \"{2}\" geändert", "lineXTimingChanged": "Zeile {0}: Timing geändert", "loadingWaveInfoFromCache": "Wave-Informationen werden aus dem Cache geladen...", "clickToGenerateWaveform": "Klicken, um Wellenform zu erzeugen", @@ -1072,14 +1095,14 @@ "redoPerformedXActionLeft": "Wiederhergestellt (verbleibende Aktionen: {0})", "removedUnicodeControlCharactersX": "Unicode-Steuerzeichen aus {0} Zeilen entfernt", "removedXBlankLines": "{0} Leerzeilen entfernt", - "replacedXWithYCountZ": "„{0}“ durch „{1}“ ersetzt ({2} Vorkommen)", + "replacedXWithYCountZ": "\"{0}\" durch \"{1}\" ersetzt ({2} Vorkommen)", "reversedStartAndEndingsForRightToLeftX": "Anfang und Ende für rechts-nach-links in {0} Zeilen getauscht", - "ruleProfileIsX": "Regelprofil ist jetzt „{0}“", + "ruleProfileIsX": "Regelprofil ist jetzt \"{0}\"", "saveLanguageFile": "Sprachdatei speichern", "saveXFileAs": "Speicher {0}-Datei unter", "shiftTextCellsDown": "Zellen nach untern verschieben", "singleLineLength": "Einzelzeilenlänge:", - "speedIsNowX": "Geschwindigkeit ist jetzt „{0}“", + "speedIsNowX": "Geschwindigkeit ist jetzt \"{0}\"", "subtitleGridFormattingX": "Rasterformatierung: {0}", "subtitleImportedFromMatroskaFile": "Untertitel importiert aus Matroska Datei", "textDown": "Text nach unten", @@ -1098,8 +1121,8 @@ "xLinesSelectedOfY": "{0} Zeilen ausgewählt von {1}", "xLinesSwitched": "{0} Zeilen gewechselt", "xShotChangedLoaded": "{0} Szenenwechsel geladen", - "youtubeDlDownloadedSuccessfully": "„yt-dlp“ erfolgreich heruntergeladen.", - "youtubeDlNotInstalledDownloadNow": "„yt-dlp“ ist nicht installiert und wird zum Abspielen von Online-Videos benötigt.\n\nJetzt herunterladen?", + "youtubeDlDownloadedSuccessfully": "\"yt-dlp\" erfolgreich heruntergeladen.", + "youtubeDlNotInstalledDownloadNow": "\"yt-dlp\" ist nicht installiert und wird zum Abspielen von Online-Videos benötigt.\n\nJetzt herunterladen?", "promptInsertSubtitleOverlap": "Die eingefügten Zeilen überlappen vorhandene Zeilen.\n\nTrotzdem einfügen?", "youtubeDlOutdatedDownloadNow": "\"yt-dlp\" ist veraltet und funktioniert möglicherweise nicht mit Online-Videos.\n\nDie aktuelle Version jetzt herunterladen?", "insertUnicodeSymbol": "Unicode-Symbol einfügen", @@ -1123,8 +1146,8 @@ "allowEditHintAllLines": "Das gesamte Original ist auf dem Bildschirm, beim Speichern bleiben also alle Zeilen erhalten.", "allowEditHintMatchingOnlyX": "Beim Speichern des Originals würden nur die angezeigten Zeilen geschrieben - die anderen {0} gingen verloren.", "videoOpenedChangeLayoutQuestion": "Eine Videodatei wurde geöffnet.\n\nMöchten Sie das Layout ändern, um das Videofenster anzuzeigen?", - "sortedByStartTime": "Nach „Einblenden“-Zeit sortiert", - "sortedByEndTime": "Nach „Ausblenden“-Zeit sortiert", + "sortedByStartTime": "Nach \"Einblenden\"-Zeit sortiert", + "sortedByEndTime": "Nach \"Ausblenden\"-Zeit sortiert", "sortedByNumber": "Nach Nummer sortiert", "sortedByDuration": "Nach Dauer sortiert", "sortedByGap": "Nach Abstand sortiert", @@ -1144,11 +1167,11 @@ "snappedXTimesToFrames": "{0} Zeitstempel bei {1:0.###} fps an Frames angepasst", "languageFileSavedToX": "Sprachdatei gespeichert unter {0}", "fileExportedInFormatXToY": "Datei im Format {0} exportiert nach {1}", - "fileExportedInFormatXToFileY": "Datei im Format „{0}“ exportiert in Datei „{1}“", + "fileExportedInFormatXToFileY": "Datei im Format \"{0}\" exportiert in Datei \"{1}\"", "fixedXLines": "{0} Zeilen korrigiert", "transcriptionCompletedWithXLines": "Transkription abgeschlossen mit {0} Zeilen", - "openSubtitleFileX": "Untertiteldatei „{0}“ öffnen?", - "replacedXOccurrences": "{0} Vorkommen ersetzt", + "openSubtitleFileX": "Untertiteldatei \"{0}\" öffnen?", + "replacedXOccurrences": "{0} Ersetzungen vorgenommen", "ffmpegDownloadedAndInstalledToX": "ffmpeg heruntergeladen und installiert unter {0}", "nothingToSave": "Nichts zu speichern", "formatLimitWarningTitle": "Formatgrenzen überschritten", @@ -1250,7 +1273,7 @@ "gapMs": "Abstand (ms)", "useFixedDuration": "Feste Dauer verwenden", "fixedDurationMs": "Feste Dauer (ms)", - "alignViaSpeechToText": "Zeitstempel per „Sprache zu Text“ ausrichten...", + "alignViaSpeechToText": "Zeitstempel per \"Sprache-zu-Text\" ausrichten...", "alignViaForcedAligner": "Zeitstempel per Forced Aligner ausrichten...", "forcedAlignerProgress": "Ausrichten... Fenster {0} von {1}, {2} von {3} Zeilen", "forcedAlignerSetupTitle": "Zeitstempel per Forced Aligner ausrichten", @@ -1289,7 +1312,7 @@ "titleExportCustomFormat": "Benutzerdefiniertes Textformat exportieren", "editCustomFormat": "Benutzerdefiniertes Textformat bearbeiten", "newCustomFormat": "Neues benutzerdefiniertes Textformat", - "deleteSelectedCustomTextFormatX": "Ausgewähltes benutzerdefiniertes Textformat „{0}“ löschen?", + "deleteSelectedCustomTextFormatX": "Ausgewähltes benutzerdefiniertes Textformat \"{0}\" löschen?", "timeCodeFormat": "Zeitstempelformat", "newLineFormat": "Neues Zeilenformat", "pleaseEnterNameForTheCustomFormat": "Bitte geben Sie einen Namen für das benutzerdefinierte Format ein", @@ -1407,7 +1430,7 @@ "xBackups": "{0} Sicherungen", "openAutoBackupFolder": "Auto-Sicherungen-Ordner öffnen", "restoreAutoBackupFile": "Auto-Sicherungsdatei wiederherstellen", - "restoreXFromY": "Möchten Sie „{0}“ von {1} wiederherstellen?", + "restoreXFromY": "Möchten Sie \"{0}\" von {1} wiederherstellen?", "deleteAllSubtitleBackups": "Möchten Sie alle Untertitel-Sicherungsdateien löschen?", "deleteAll": "Alle löschen" }, @@ -1476,7 +1499,52 @@ "ignoreFormatting": "Formatierung ignorieren", "ignoreFormattingHint": "Zeilen, die sich nur in Formatierungs-Tags wie oder {\\an8} unterscheiden, gelten nicht als unterschiedlich", "showOnlyDifferencesInText": "Nur nach Unterschieden im Text suchen", - "loadXFromFile": "Lade „{0}“ aus der Datei", + "ignoreNumbering": "Nummerierung ignorieren", + "ignoreNumberingHint": "Zeilen, die sich nur in ihrer Nummer unterscheiden, gelten nicht als unterschiedlich", + "ignoreTimeDifferenceMs": "Zeittoleranz (ms)", + "ignoreTimeDifferenceMsHint": "Start- und Endzeiten mit einer Abweichung von höchstens dieser Anzahl an Millisekunden gelten nicht als unterschiedlich", + "compareDifferences": "Unterschiede", + "compareTextDifferences": "Text-Unterschiede", + "compareCurrent": "Aktuell", + "compareReference": "Referenz", + "compareEditable": "Editierbar", + "compareEditableHint": "Hier vorgenommene Änderungen werden beim Klicken auf \"Anwenden\" in den Untertitel übernommen", + "compareReadOnly": "Schreibgeschützt", + "compareTakeFromReference": "Text und Zeiten von Referenzdatei übernehmen", + "compareInsertFromReference": "Diese Zeile in den aktuellen Untertitel einfügen", + "compareDeleteFromCurrent": "Diese Zeile aus dem aktuellen Untertitel löschen", + "compareTakeText": "Text übernehmen", + "compareTakeTiming": "Zeiten übernehmen", + "compareEditLine": "Zeile bearbeiten", + "compareEditHint": "Strg+Enter zum Speichern, Esc zum Abbrechen", + "compareEdited": "Geändert", + "compareOnePendingChange": "1 ausstehende Änderung", + "compareXPendingChanges": "{0} ausstehende Änderungen", + "compareDiscardXChanges": "Die im Vergleich vorgenommenen Änderungen ({0}) verwerfen?", + "compareXChangesApplied": "Vergleich: {0} Änderung(en) übernommen", + "compareChangeTextX": "#{0} Text aus der Referenz", + "compareChangeTimingX": "#{0} Zeiten aus der Referenz", + "compareChangeTextAndTimingX": "#{0} Text und Zeiten aus der Referenz", + "compareChangeEditedX": "#{0} geändert", + "compareChangeInsertedX": "#{0} eingefügt", + "compareChangeDeletedX": "#{0} gelöscht", + "compareSyncPoint": "Synchronisationspunkt", + "compareSyncPointHint": "Synchronisationspunkt – Diese beiden Zeilen werden immer als Paar angezeigt; die Zeilen darüber und darunter werden unabhängig voneinander ausgerichtet", + "compareSyncPickCurrent": "Synchronisationspunkt: Diese aktuelle Zeile verwenden", + "compareSyncPickReference": "Synchronisationspunkt: Diese Referenzzeile verwenden", + "compareSyncRemove": "Synchronisationspunkt entfernen", + "compareClearXSyncPoints": "Synchronisationspunkte löschen ({0})", + "compareSyncCurrentPickedX": "Aktuelle Zeile #{0} als Synchronisationspunkt gewählt – Wählen Sie die passende Referenzzeile und klicken Sie auf \"Synchronisieren\"", + "compareSyncReferencePickedX": "Referenzzeile #{0} als Synchronisationspunkt gewählt – Wählen Sie die passende aktuelle Zeile und klicken Sie auf \"Synchronisieren\"", + "compareSyncWithCurrentX": "Synchronisation mit aktueller Zeile #{0}", + "compareSyncWithReferenceX": "Synchronisation mit Referenzzeile #{0}", + "compareSyncApply": "Synchronisieren", + "compareSyncPointSetXY": "Synchronisationspunkt gesetzt: Aktuelle Zeile #{0} und Referenzzeile #{1} werden nun als Paar dargestellt", + "compareSyncAlreadyPairedXY": "Aktuelle Zeile #{0} und Referenzzeile #{1} werden bereits als Paar mit identischem Start angezeigt – keine Synchronisation erforderlich", + "compareSyncPointShiftedXYZW": "Synchronisationspunkt gesetzt: Aktuelle Zeilen #{0}–#{1} um {2} verschoben, um mit Referenzzeile #{3} zu beginnen", + "compareChangeSyncShiftXYZ": "#{0}–#{1} um {2} verschoben (Synchronisationspunkt)", + "compareTimingDeltaHint": "Wie viel später (+) oder früher (−) die Referenz im Vergleich zur aktuellen Zeile beginnt und endet", + "loadXFromFile": "Lade \"{0}\" aus der Datei", "saveCompareHtmlTitle": "Vergleichs-HTML-Datei speichern", "pickMatroskaTrackX": "Matroska-Track auswählen - {0}", "pickTransportStreamTrackX": "Transportstream-Spur wählen - {0}", @@ -1551,8 +1619,8 @@ "appliedRules": "Angewendete Regeln", "findRule": "Suchregel", "xLinesAffected": "{0:#,##0} Zeilen betroffen", - "deleteCategoryConfirm": "Kategorie „{0}“ löschen?", - "deleteRuleConfirm": "Regel „{0}“ löschen?", + "deleteCategoryConfirm": "Kategorie \"{0}\" löschen?", + "deleteRuleConfirm": "Regel \"{0}\" löschen?", "findWhat": "Suchen nach", "descriptionOptional": "Beschreibung (optional)", "invalidRegularExpressionX": "Ungültiger regulärer Ausdruck: {0}", @@ -1569,7 +1637,9 @@ "replaceIn": "Ersetzen/suchen in", "replaceInTextAndOriginal": "Text und Originaltext", "replaceInTextOnly": "Nur Text", - "replaceInOriginalOnly": "Nur Originaltext" + "replaceInOriginalOnly": "Nur Originaltext", + "replacedOneOccurrence": "1 Ersetzung vorgenommen", + "replacedXOccurrences": "{0} Ersetzungen vorgenommen" }, "regularExpressionContextMenu": { "wordBoundary": "Wortgrenze (\\b)", @@ -1598,7 +1668,7 @@ "xLinesYSubtitlesZErrors": "{0} Zeilen, {1} Untertitel, {2} Fehler", "couldNotParseAsX": "Konnte nicht als {0} gelesen werden", "selectedXCharactersYLines": "Ausgewählt: {0} Zeichen in {1} Zeile(n)", - "replacedXOccurrences": "{0} Vorkommen ersetzt", + "replacedXOccurrences": "{0} Ersetzungen vorgenommen", "invalidRegularExpression": "Ungültiger regulärer Ausdruck", "regularExpressionTooSlowX": "Der reguläre Ausdruck wurde nach {0} Sekunden abgebrochen - versuchen Sie ein einfacheres Muster", "moveLineUp": "Zeile nach oben verschieben", @@ -1632,7 +1702,23 @@ "largeChangeWarning": "Große Änderung - sieht nach einer Neuformulierung aus, sorgfältig prüfen", "mismatchWarning": "Ähnelt dem Original nicht - gehört möglicherweise zu einer anderen Zeile", "engineError": "Die KI-Engine konnte nicht erreicht werden: {0}", - "playCurrentLineHint": "Die ausgewählte Zeile im Videoplayer abspielen und am Ende pausieren (auch per Doppelklick auf eine Zeile)" + "playCurrentLineHint": "Die ausgewählte Zeile im Videoplayer abspielen und am Ende pausieren (auch per Doppelklick auf eine Zeile)", + "editAfterHint": "Text der gewählten Zeile anklicken, um die Anpassung vor der Übernahme zu bearbeiten (Enter speichert, Esc bricht ab).", + "template": "Vorlage", + "templatePlaceholder": "Vorlage auswählen, um den Prompt zu ersetzen", + "templateStandard": "Standard-Korrekturlesen", + "templateSpeechToText": "Sprache-zu-Text-Bereinigung", + "templateMachineTranslation": "Korrektur maschineller Übersetzung", + "templateSdh": "SDH / Für Hörgeschädigte", + "referenceContext": "Referenzkontext", + "referenceContextInfo": "Optional. Namen, Charaktere, Glossar, Zusammenfassung oder Stilhinweise – das Modell behandelt dies als maßgeblich für Namen und Begriffe. Wird mit jedem Batch gesendet.", + "referenceContextPlaceholder": "Namen: Kaito, Tante Mei, Dr. Hollis\nBegriffe: Shinkansen, Warpkern\nZusammenfassung: Kaito reist nach Tokio, um seinen vermissten Vater zu finden.", + "generateContext": "Mit KI generieren", + "generateContextHint": "Lässt die ausgewählte KI-Engine die Untertitel lesen und Entwürfe für Namen, Begriffe sowie eine Zusammenfassung erstellen – prüfen Sie das Ergebnis vor der Verwendung.", + "generatingContextPartXOfY": "Lese Teil {0} von {1}...", + "generatingContextSynopsis": "Zusammenfassung wird erstellt...", + "replaceContextQuestion": "Aktuellen Referenzkontext ersetzen?", + "noContextGenerated": "Die KI-Engine hat keine verwendbaren Referenzinformationen zurückgegeben." }, "aiAssistant": { "title": "KI-Assistent", @@ -1677,7 +1763,7 @@ "removeLineBreaksAll": "Zeilenumbrüche in kurzen Texten (außer Dialoge) entfernen", "removeLineBreaksPixelWidth": "Zeilenumbrüche aus Untertiteln entfernen, die in eine Zeile passen (Pixelbreite)", "fixUppercaseIInsideLowercaseWords": "Großes 'i' in klein geschriebenen Wörtern (OCR Fehler) korrigieren", - "fixDoubleApostrophes": "Doppelte Apostrophe ('' ) zu Anführungszeichen („“) korrigieren", + "fixDoubleApostrophes": "Doppelte Apostrophe ('' ) zu Anführungszeichen (\"\") korrigieren", "addPeriods": "Punkt am Ende einfügen, wenn nächste Zeile mit Großbuchstaben beginnt.", "startWithUppercaseLetterAfterParagraph": "Mit Großbuchstaben nach einem Punkt beginnen", "startWithUppercaseLetterAfterPeriodInsideParagraph": "Mit Großbuchstaben nach Punkten beginnen", @@ -1691,11 +1777,11 @@ "fixTurkishAnsi": "Wandle türkische ANSI Buchstaben (Isländisch) nach Unicode", "fixDanishLetterI": "Dänischen Buchstaben 'i' korrigieren", "fixSpanishInvertedQuestionAndExclamationMarks": "Spanische umgedrehte Frage- und Ausrufezeichen korrigieren", - "addMissingQuote": "Fehlendes Anführungszeichen hinzufügen („“)", - "addMissingQuotes": "Fehlende Anführungszeichen hinzufügen („“)", + "addMissingQuote": "Fehlendes Anführungszeichen hinzufügen (\"\")", + "addMissingQuotes": "Fehlende Anführungszeichen hinzufügen (\"\")", "removeHyphensSingleLine": "Dialogstriche in einzelnen Zeilen entfernen", "fixHyphensInDialogs": "Bindestrich in Dialogen über Stil korrigieren: {0}", - "addMissingQuotesExample": "„Wie geht's?“ -> „Wie geht's?“", + "addMissingQuotesExample": "\"Wie geht's?\" -> \"Wie geht's?\"", "removedEmptyLinesUnusedLineBreaksExample": "Hat nur eine gültige Zeile!
-> Hat nur eine gültige Zeile!", "fixCommasExample": ",, -> ,", "removeLineBreaksExample": "Foo
bar! -> Foo bar!", @@ -1715,7 +1801,7 @@ "xFixedBut": "{0} Problem(e) behoben, aber der Untertitel enthält weiterhin Fehler - siehe Protokoll für Details", "xCouldBeFixedBut": "{0} Problem(e) konnten behoben werden, aber der Untertitel enthält immer noch Fehler – Einzelheiten finden Sie im Protokoll", "numberOfImportantLogMessages": "{0} wichtige Logmeldungen!", - "fixedOkXY": "Korrigiert und OK - „{0}“: {1}", + "fixedOkXY": "Korrigiert und OK - \"{0}\": {1}", "xFixesApplied": "Korrekturen angewendet: {0}", "xFixesYSelected": "{0} Korrekturen - {1} ausgewählt", "fixFirstLetterToUppercaseAfterParagraph": "Ersten Buchstaben nach Punkt groß schreiben", @@ -1748,12 +1834,12 @@ "fixDoubleGreaterThan": "Entferne '>>'", "fixEllipsesStart": "Entferne '...' am Anfang", "fixMissingOpenBracket": "Korrigiere fehlende [", - "fixMusicNotation": "Musiksymbole „♫“ mit bevorzugten Symbolen ersetzen", - "fixDoubleDashExample": "„Hey-- das geht!“ -> „Hey... das geht!“", - "fixDoubleGreaterThanExample": "„>> Robert: Sehr gut!“ -> „Robert: Sehr gut!“", - "fixEllipsesStartExample": "„... und dann haben wir“ -> „und dann haben wir“", - "fixMissingOpenBracketExample": "„ruft] Pass auf!“ -> „[ruft] Pass auf!“", - "fixMusicNotationExample": "„♫ 99 Luftballons“ -> „♪ 99 Luftballons“", + "fixMusicNotation": "Musiksymbole \"♫\" mit bevorzugten Symbolen ersetzen", + "fixDoubleDashExample": "\"Hey-- das geht!\" -> \"Hey... das geht!\"", + "fixDoubleGreaterThanExample": "\">> Robert: Sehr gut!\" -> \"Robert: Sehr gut!\"", + "fixEllipsesStartExample": "\"... und dann haben wir\" -> \"und dann haben wir\"", + "fixMissingOpenBracketExample": "\"ruft] Pass auf!\" -> \"[ruft] Pass auf!\"", + "fixMusicNotationExample": "\"♫ 99 Luftballons\" -> \"♪ 99 Luftballons\"", "fixOcrErrorExample": "NIcht -> Nicht", "fixSpaceBetweenNumbersExample": "1 100 -> 1100", "fixDialogsOneLineExample": "Hallo John! - Hallo Ida! -> Hallo John!
- Hallo Ida!", @@ -1837,6 +1923,69 @@ "xTimeCodesLoaded": "{0} Zeitstempel geladen", "timeCodesExtractFailed": "Zeitstempel konnten nicht aus dem Video gelesen werden", "timeCodesHint": "Cues an die echten Frame-Zeiten des Videos anpassen, statt eine konstante Bildrate anzunehmen. Erfordert einmaliges Lesen des gesamten Videos; das Ergebnis wird zwischengespeichert." + }, + "improveTimeCodes": { + "title": "Zeitstempel optimieren (Forced Alignment)", + "aligner": "Aligner", + "alignerHint": "Das Modell, das jede Zeile in der Audiodatei analysiert. Die am besten passenden Optionen für die Sprache des Untertitels stehen an erster Stelle.", + "maxShift": "Max. Verschiebung (Sekunden)", + "maxShiftHint": "Gibt an, wie weit der Untertitel asynchron sein darf. Zeilen, deren Startzeitpunkt weiter verschoben werden müsste, bleiben unverändert und werden markiert. Unabhängig vom gewählten Wert wird eine einzelne Zeile, die um mehr als eine halbe Sekunde stärker abweicht als die umliegenden Zeilen, nicht blind verschoben: Sie folgt ihren Nachbarn, wenn diese sich alle verschoben haben, und wird Ihnen andernfalls abgewählt zur Überprüfung angeboten.", + "adjustStartTimes": "Startzeiten anpassen", + "adjustEndTimes": "Endzeiten anpassen", + "isolateSpeech": "Sprache zuerst isolieren (langsam)", + "isolateSpeechHint": "Entfernt Musik und Soundeffekte vor der Ausrichtung, sodass der Aligner nur den Dialog verarbeitet. Dies hilft bei Zeilen, die über lauter Musik oder Action gesprochen werden, dauert mit einer GPU jedoch etwa so lange wie die Audiodatei selbst – und ohne GPU um ein Vielfaches länger.", + "isolatingSpeech": "Sprache wird isoliert... (dies dauert einen Moment)", + "isolateSpeechFailed": "Die Sprache konnte nicht isoliert werden – die Ausrichtung erfolgte stattdessen über die Original-Audiodatei.", + "checkWithSpeechToText": "Mit Sprache-zu-Text prüfen", + "checkWithSpeechToTextHint": "Transkribiert zusätzlich die Audiodatei und prüft jede Verschiebung dahingehend, wo die Wörter tatsächlich zu hören waren. Große Verschiebungen, die durch die Sprache bestätigt werden, werden für Sie ausgewählt; Verschiebungen weg von der hörbaren Position bleiben zur Überprüfung abgewählt. Zeigt außerdem an, wie viel von jeder Zeile erkannt wurde – ein niedriger Wert deutet darauf hin, dass der Text vom Gesprochenen abweicht. Untertitel mit größerem Versatz als der eingestellte Maximalwert (konstanter Versatz, Drift durch abweichende Bildrate oder Sprünge durch Scenecuts) werden zuerst über die erkannten Wörter synchronisiert. Nutzt Crisp ASR Parakeet, was bei einem Spielfilm mit GPU einige Minuten dauert.", + "checkWithSpeechToTextNotAvailableX": "Speech-to-text check is not available for {0}", + "transcribingToCheck": "Audiodatei wird zur Überprüfung der Ausrichtung transkribiert...", + "speechToTextFailed": "Sprache-zu-Text fehlgeschlagen – Zeitstempel wurden nicht gegengeprüft", + "speechToTextX": "Sprache-zu-Text: {0}", + "speechToTextConfirmedXDisputedY": "Sprache: {0} bestätigt, {1} zweifelhaft", + "hardlyHeardX": "Die Spracherkennung hat nur {0} % der Wörter des Untertitels erkannt. Entspricht die Sprache des Untertitels der gesprochenen Sprache? Der Aligner kann keine Übersetzungen ausrichten.", + "heard": "Erkannt", + "heardHint": "Gibt an, wie viel der Zeile von der Spracherkennung erfasst wurde. Ein niedriger Wert bedeutet, dass der Text vom Gesprochenen abweicht – hier ist die Wahrscheinlichkeit für Fehler beim Aligner am höchsten.", + "statusConfirmedBySpeech": "Durch Spracherkennung bestätigt", + "statusDisputedBySpeech": "An Originalposition erkannt", + "statusMovedWithSync": "Mit Synchronisation verschoben", + "syncedFirstXY": "Zuerst synchronisiert: {0} s am Anfang, {1} s am Ende", + "showSpeechOnly": "Nur Sprache anzeigen", + "showSpeechOnlyHint": "Zeichnet die ausgerichtete Wellenform aus der Audiodatei ohne Musik und Soundeffekte; die ursprüngliche Wellenform bleibt zum Vergleich unverändert. Verfügbar, sobald die Sprache isoliert wurde – hier oder über \"Nur Sprache anzeigen\" in der Wellenform des Hauptfensters.", + "align": "Ausrichten", + "original": "Original", + "aligned": "Ausgerichtet", + "intro": "Wählen Sie einen Aligner aus und klicken Sie auf \"Ausrichten\", um jede Zeile in der Audiodatei abzugleichen und ihre Zeitstempel präzise anzupassen.", + "extractingAudio": "Extrahiere Audio...", + "aligningBatchXOfY": "Wird ausgerichtet... Batch {0} von {1}", + "extractAudioFailed": "Audiodatei konnte nicht mit FFmpeg extrahiert werden.", + "modelWillBeDownloaded": "{0} wird heruntergeladen", + "summaryXRetimedYKeptZSkipped": "Neu getimt: {0} · Behalten: {1} · Keine Sprache: {2}", + "meanShiftX": "Durchschnittliche Verschiebung: {0} ms", + "toCheckX": "Zu prüfen: {0}", + "largeMovesToCheckX": "{0} Zeile(n) würde(n) sich stark von selbst verschieben und ist/sind daher nicht ausgewählt. Hören Sie sich diese an (F5 spielt die ausgerichtete Zeile ab, Umschalt+F5 die ursprüngliche) und wählen Sie die korrekten aus.", + "linesToCheckX": "{0} Zeile(n) zur Überprüfung nicht ausgewählt – hören Sie sich diese an (F5 spielt die ausgerichtete Zeile ab, Umschalt+F5 die ursprüngliche) und wählen Sie die korrekten aus.", + "changeXOfY": "{0} von {1} ändern", + "previousChange": "Vorherige Änderung", + "nextChange": "Nächste Änderung", + "startShift": "Start", + "endShift": "Ende", + "statusRetimed": "Neu getimt", + "statusMovedWithNeighbours": "Mit benachbarten Zeilen verschoben", + "statusLargeMoveUnconfirmed": "Starke Verschiebung – anhören, zum Übernehmen anhaken", + "statusUnchanged": "Bereits an richtiger Position", + "statusNoSpeech": "Keine Sprache", + "statusShiftTooLarge": "Beibehalten – Verschiebung zu groß", + "statusFailed": "Beibehalten – Alignment fehlgeschlagen", + "statusAdjustedByHand": "Manuell angepasst", + "alignedWaveformHint": "Zeile zum Verschieben ziehen oder an den Rändern ziehen, um Start- oder Endzeit anzupassen.", + "playLine": "Zeile abspielen", + "undoAdjustment": "Anpassung rückgängig machen", + "playPause": "Abspielen / Pause", + "playAligned": "Ausgerichtet", + "playAlignedHint": "Ausgewählte Zeile mit ausgerichteten Zeitstempeln abspielen", + "apply": "Anwenden", + "applyHint": "Entfernen Sie das Häkchen einer Zeile, um ihre ursprünglichen Zeitstempel beizubehalten. Doppelklicken Sie auf eine Zeile, um sie abzuspielen. Zeilen können in der ausgerichteten Wellenform auch verschoben oder in der Größe angepasst werden." }, "beautifyTimeCodesProfile": { "title": "Profil zur Zeitstempeloptimierung bearbeiten", @@ -1844,6 +1993,11 @@ "presetDefault": "Standard", "presetNetflix": "Netflix", "presetSdi": "SDI", + "saveAsProfile": "Profil speichern...", + "deleteProfile": "Profil löschen", + "profileName": "Profilname", + "overwriteProfileX": "Ein Profil mit dem Namen \"{0}\" existiert bereits. Überschreiben?", + "deleteProfileX": "Profil \"{0}\" löschen?", "general": "Allgemein", "gap": "Abstand", "gapSuffix": "Frames (aktualisiert auch die unten stehenden Abstandsfelder ungleich null)", @@ -1957,10 +2111,10 @@ }, "changeFormatting": {}, "convertActors": { - "title": "Akteure konvertieren", - "convertActorFrom": "Akteur konvertieren von", - "convertActorTo": "Akteur konvertieren zu", - "inlineActorViaX": "Akteur inline über {0}", + "title": "Darsteller konvertieren", + "convertActorFrom": "Darsteller konvertieren von", + "convertActorTo": "Darsteller konvertieren zu", + "inlineActorViaX": "Darsteller inline über {0}", "setColor": "Farbe festlegen", "onlyNames": "Nur Namen", "numberOfConversionsX": "Anzahl Konvertierungen: {0}" @@ -2000,7 +2154,7 @@ "rebalanceOnlyLinesTooLongHint": "Vorhandene Zeilenumbrüche unangetastet lassen, wenn alle Zeilen bereits innerhalb der maximalen Einzelzeilenlänge liegen - ein absichtlich unausgeglichener Untertitel kann redaktionell korrekt sein", "splitLongLine": "Lange Zeile teilen", "rebalanceLongLine": "Lange Zeile neu ausgleichen", - "splitIntoXLines": "In {0} Zeilen teilen: „{1}“ → „{2}...“", + "splitIntoXLines": "In {0} Zeilen teilen: \"{1}\" → \"{2}...\"", "linesSplitX": "Geteilte Zeilen: {0}", "linesSplitXLinesRebalancedY": "Geteilte Zeilen: {0}, neu ausgeglichene Zeilen: {1}", "unbreakLinesShorterThanHint": "Text, der kürzer ist als dieser Wert, bleibt auf einer Zeile (oder wird dazu verbunden) - setzen Sie ihn auf die maximale Einzelzeilenlänge, damit jeder Text, der auf eine Zeile passt, ungebrochen bleibt" @@ -2081,7 +2235,7 @@ "dualSpeakersHyphenWithoutSpaceSecondOnly": "Zwei Sprecher: Bindestrich ohne Leerzeichen verwenden, um nur den zweiten Sprecher zu kennzeichnen", "minimumTwoFramesGapOverlapping": "Mindestens zwei Frames Abstand (Überlappend)", "minimumTwoFramesGap": "Mindestens zwei Frames Abstand", - "frameRateIsInvalidX": "Bildrate ist ungültig: „{0}“", + "frameRateIsInvalidX": "Bildrate ist ungültig: \"{0}\"", "frameRateIsInvalid": "Bildrate ist ungültig", "minimumDurationJapanese": "Mindestdauer: 0,5 Sekunden", "minimumDuration": "Mindestdauer: 5/6 Sekunde (833 ms)", @@ -2190,16 +2344,16 @@ "wordNotFound": "Wort nicht gefunden", "loadSourceImage": "Auch Quellbilder anzeigen...", "lineXofY": "Rechtschreibprüfung - Zeile {0} von {1}", - "changeWordFromXToY": "Wort von „{0}“ in „{1}“ ändern", - "changeAllWordsFromXToY": "Alle Wörter von „{0}“ in „{1}“ ändern", - "ignoreWordXOnce": "Wort „{0}“ einmal ignorieren", - "ignoreWordXAlways": "Wort „{0}“ immer ignorieren", - "wordXAddedToNamesList": "Wort „{0}“ zur Namensliste hinzugefügt", - "wordXAddedToUserDictionary": "Wort „{0}“ zum Benutzerwörterbuch hinzugefügt", - "useSuggestionX": "Vorschlag „{0}“ verwenden", - "useSuggestionXAlways": "Vorschlag „{0}“ immer verwenden", - "addXToUserDictionary": "„{0}“ zum Benutzerwörterbuch hinzufügen", - "ignoreAllX": "Alle „{0}“ ignorieren", + "changeWordFromXToY": "Wort von \"{0}\" in \"{1}\" ändern", + "changeAllWordsFromXToY": "Alle Wörter von \"{0}\" in \"{1}\" ändern", + "ignoreWordXOnce": "Wort \"{0}\" einmal ignorieren", + "ignoreWordXAlways": "Wort \"{0}\" immer ignorieren", + "wordXAddedToNamesList": "Wort \"{0}\" zur Namensliste hinzugefügt", + "wordXAddedToUserDictionary": "Wort \"{0}\" zum Benutzerwörterbuch hinzugefügt", + "useSuggestionX": "Vorschlag \"{0}\" verwenden", + "useSuggestionXAlways": "Vorschlag \"{0}\" immer verwenden", + "addXToUserDictionary": "\"{0}\" zum Benutzerwörterbuch hinzufügen", + "ignoreAllX": "Alle \"{0}\" ignorieren", "pickSpellCheckDictionaryDotDotDot": "Rechtschreibwörterbuch wählen...", "chooseSpellCheckDictionary": "Rechtschreibwörterbuch wählen", "pickLiveSpellCheckDictionary": "Live-Rechtschreibwörterbuch wählen", @@ -2309,7 +2463,7 @@ "addLanguageCodeToFileNameHint": "Erzeugte Untertitel als \"video.de.srt\" statt \"video.srt\" benennen" }, "textToSpeech": { - "title": "Text zu Sprache", + "title": "Text-zu-Sprache", "reviewAudioSegments": "TTS – Audiosegmente überprüfen", "reviewAudioSegmentsHistory": "TTS – Überprüfen Sie den Audioverlauf", "stability": "Stabilität", @@ -2342,7 +2496,7 @@ "preparingMergeDotDotDot": "Zusammenführung wird vorbereitet...", "importVoiceDotDotDot": "Stimme importieren...", "voiceImportSuccessTitle": "Stimme importiert", - "voiceXImported": "Stimme „{0}“ erfolgreich importiert", + "voiceXImported": "Stimme \"{0}\" erfolgreich importiert", "voiceXCouldNotBeImported": "Stimme '{0}' konnte nicht importiert werden - siehe Protokoll für Details", "importPiperVoiceTitle": "Piper-Stimmmodell öffnen (.onnx)", "piperVoiceConfigMissingTitle": "Konfigurationsdatei fehlt", @@ -2361,7 +2515,7 @@ "autoCastNeedsVideo": "Zum Finden der Stimmen wird ein geöffnetes Video benötigt, dem zugehört werden kann.", "autoCastNoSpeakersFound": "Es wurden keine Sprecher gefunden. Die Transkription muss von einer Sprache-zu-Text-Engine stammen, die Sprecher unterscheidet, wie Crisp ASR MOSS Diarize.", "autoCastCloningX": "{0} wird geklont...", - "autoCastDoneXVoices": "{0} Stimmen geklont - öffnen Sie \"Text zu Sprache\", um die Synchronisation zu erzeugen", + "autoCastDoneXVoices": "{0} Stimmen geklont - öffnen Sie \"Text-zu-Sprache\", um die Synchronisation zu erzeugen", "autoCastNothingCloned": "Keine der Stimmen konnte geklont werden - siehe Protokoll für Details.", "autoCastReplaceSubtitleQuestion": "Dies ersetzt den geöffneten Untertitel durch die Transkription des Videos. Fortfahren?", "cloneVoicePerLine": "Aus Video klonen (Stimme jeder Zeile)", @@ -2404,14 +2558,14 @@ "generationFolder": "Erzeugungsordner", "generationFolderDescription": "Wohin Clips während der Erzeugung geschrieben werden. Leer lassen für den temporären Systemordner. Jeder Durchlauf erhält seinen eigenen Unterordner.", "deleteTempFiles": "Erzeugte Clips beim Schließen löschen", - "deleteTempFilesDescription": "Löscht den Erzeugungsordner, wenn das Fenster \"Text zu Sprache\" geschlossen wird. Gespeicherte Audio- und Videodateien bleiben erhalten.", + "deleteTempFilesDescription": "Löscht den Erzeugungsordner, wenn das Fenster \"Text-zu-Sprache\" geschlossen wird. Gespeicherte Audio- und Videodateien bleiben erhalten.", "edgeTtsRate": "Edge-TTS-Rate", - "edgeTtsRateDescription": "Sprechgeschwindigkeit für Edge-TTS, z. B. „+50%“, „-30%“ oder „+0%“ für Standard.", + "edgeTtsRateDescription": "Sprechgeschwindigkeit für Edge-TTS, z. B. \"+50%\", \"-30%\" oder \"+0%\" für Standard.", "edgeTtsPitch": "Edge-TTS-Tonhöhe", - "edgeTtsPitchDescription": "Tonhöhenanpassung für Edge-TTS, z. B. „+10Hz“, „-5Hz“ oder „+0Hz“ für Standard.", + "edgeTtsPitchDescription": "Tonhöhenanpassung für Edge-TTS, z. B. \"+10Hz\", \"-5Hz\" oder \"+0Hz\" für Standard.", "edgeTtsVolume": "Edge-TTS-Lautstärke", - "edgeTtsVolumeDescription": "Lautstärkeanpassung für Edge-TTS, z. B. „+20%“, „-10%“ oder „+0%“ für Standard.", - "downloadPiperPrompt": "„Text zu Sprache“ erfordert Piper.\n\nPip er herunterladen und verwenden?", + "edgeTtsVolumeDescription": "Lautstärkeanpassung für Edge-TTS, z. B. \"+20%\", \"-10%\" oder \"+0%\" für Standard.", + "downloadPiperPrompt": "\"Text-zu-Sprache\" erfordert Piper.\n\nPip er herunterladen und verwenden?", "omniVoiceTtsSettings": "OmniVoice TTS-Einstellungen", "reDownloadOmniVoiceTts": "OmniVoice TTS erneut herunterladen", "selectTheBuildToDownload": "\nWählen Sie den herunterzuladenden Build:", @@ -2423,25 +2577,25 @@ "chatterboxTtsSettings": "Chatterbox TTS-Einstellungen", "piperSettings": "Piper-Einstellungen", "voiceInstruction": "Stimmgestaltung", - "voiceInstructionHint": "Optional - z. B. „Sprechen Sie in einem ruhigen und freundlichen Ton“", + "voiceInstructionHint": "Optional - z. B. \"Sprechen Sie in einem ruhigen und freundlichen Ton\"", "voiceGender": "Geschlecht", "voiceAge": "Alter", "voicePitch": "Tonhöhe", "voiceAccent": "Akzent", - "voiceInstructionClonedVoiceNote": "Die Stimmgestaltung betrifft nur die „Standard“-Stimme - eine geklonte Stimme behält ihre eigenen Eigenschaften.", + "voiceInstructionClonedVoiceNote": "Die Stimmgestaltung betrifft nur die \"Standard\"-Stimme - eine geklonte Stimme behält ihre eigenen Eigenschaften.", "actorVoicesTitle": "TTS - Besetzung", - "actorVoicesSubtitle": "Weisen Sie jedem Akteur oder jeder Stimme eine TTS-Stimme (und optional eine Stimmgestaltungsanweisung) zu.", + "actorVoicesSubtitle": "Weisen Sie jedem Darsteller oder jeder Stimme eine TTS-Stimme (und optional eine Stimmgestaltungsanweisung) zu.", "actorVoicesAssignedXOfY": "{0} von {1} zugewiesen", - "actorOrVoice": "Akteur / Stimme", + "actorOrVoice": "Darsteller / Stimme", "applyDefaultToAll": "Standard auf alle anwenden", "clearAllAssignmentsConfirm": "Alle Stimmzuweisungen löschen?", "setupCast": "Besetzung...", - "setupCastHint": "Weisen Sie jedem Akteur (ASSA) oder jeder Stimme (WebVTT) eine TTS-Stimme zu.", + "setupCastHint": "Weisen Sie jedem Darsteller (ASSA) oder jeder Stimme (WebVTT) eine TTS-Stimme zu.", "actorVoicesRowSettingsTitle": "TTS - Stimmeinstellungen", - "voiceSettingsForX": "Stimmeinstellungen für „{0}“", + "voiceSettingsForX": "Stimmeinstellungen für \"{0}\"", "voiceInstructionFreeTextHint": "Freier Text, der von der Engine verwendet wird, um den Ton der Stimme zu formen.", "voiceDesign": "Stimmgestaltung", - "noActorsFoundMessage": "Keine Akteure gefunden. Legen Sie zuerst das Akteur-Feld in den Untertitelzeilen fest.", + "noActorsFoundMessage": "Keine Darsteller gefunden. Legen Sie zuerst das Darsteller-Feld in den Untertitelzeilen fest.", "noWebVttVoicesFoundMessage": "Keine -Stimmen in der WebVTT-Datei gefunden.", "mergeContinuationLinesPromptTitle": "Fortsetzungszeilen zusammenführen?", "mergeContinuationLinesPromptMessage": "Einige Zeilen scheinen ein einzelner Satz zu sein, der über mehrere Untertitel aufgeteilt ist.\n\nWenn Sie sie vor der Erzeugung zusammenführen, kann die TTS-Engine jeden Gedanken als eine Atemeinheit sprechen, was normalerweise natürlicher klingt.\n\nZusammenführungen jetzt überprüfen und anwenden?", @@ -2658,8 +2812,8 @@ "guessTimeCodesScanBlockAverageMin": "Durchschnittliches Minimum des Scanblocks (% des Maximums):", "guessTimeCodesScanBlockAverageMax": "Durchschnittliches Maximum des Scanblocks (% des Maximums):", "guessTimeCodesSplitLongSubtitlesAt": "Lange Untertitel bei (ms) teilen:", - "speechToTextSelectedLinesDotDotDot": "Sprache zu Text für ausgewählte Zeilen...", - "speechToTextNewSelectionDotDotDot": "Sprache zu Text für neue Auswahl...", + "speechToTextSelectedLinesDotDotDot": "Sprache-zu-Text für ausgewählte Zeilen...", + "speechToTextNewSelectionDotDotDot": "Sprache-zu-Text für neue Auswahl...", "seekSilence": "Stille suchen", "minSilenceDurationSeconds": "Mindestlänge der Stille (Sekunden)", "maxSilenceVolume": "Max. Stille-Lautstärke (0,0 - 1,0):", @@ -2672,6 +2826,7 @@ "toggleShotChange": "Szenenwechsel umschalten", "resetWaveformZoomAndSpeed": "Wellenform-Zoom und -Geschwindigkeit zurücksetzen", "showOriginalSubtitle": "Original-Untertitel anzeigen", + "timelineGroupTracksBy": "Untertitelspuren gruppieren nach", "showOnlyWaveform": "Nur Wellenform anzeigen", "showOnlySpectrogram": "Nur Spektrogramm anzeigen", "showWaveformAndSpectrogram": "Wellenform und Spektrogramm anzeigen", @@ -2711,9 +2866,11 @@ "setSyncPointViaVideo": "Synchronisationspunkt über Video setzen...", "syncPointTimeCode": "Zeitstempel des Synchronisationspunkts", "syncPoints": "Synchronisierungspunkte", - "pointSync": "Punkt Anpassung...", - "pointSyncViaOther": "Punkt Anpassung über anderen Untertitel...", - "syncPointCandidateInfo": "Zeilen, die nach {0}+ Sekunden Stille beginnen, werden hervorgehoben - sie ergeben oft zuverlässige Synchronisationspunkte", + "pointSync": "Punkt-Synchronisation...", + "syncPointCandidateAfterInfo": "Lines followed by {0}+ seconds of silence are highlighted - they often make reliable sync points", + "pointSyncViaOther": "Punkt-Synchronisation über anderen Untertitel", + "gapAfter": "Pause danach", + "syncPointCandidateInfo": "Zeilen, auf die mindestens {0}+ Sekunden Stille folgt, werden hervorgehoben – diese eignen sich oft als zuverlässige Synchronisationspunkte.", "adjustAllShortcuts": "Tastenkürzel:\n\n• Umschalt + Links/Rechts: 10 ms verschieben\n• Strg + Links/Rechts: 100 ms verschieben\n• Alt + Links/Rechts: 500 ms verschieben", "adjustAllShortcutsFrames": "Tastenkombinationen:\n\n• Umschalt + Links/Rechts: 1 Frame verschieben\n• Strg + Links/Rechts: 10 Frames verschieben\n• Alt + Links/Rechts: 1 Sekunde verschieben", "offsetInSeconds": "Versatz in Sekunden", @@ -2827,9 +2984,9 @@ "subtitleGridLiveSpellCheck": "Live-Rechtschreibprüfung in der Untertiteltabelle", "subtitleGridCenterText": "Text im Untertitelraster zentrieren", "subtitleGridShowFormatting": "Formatierten (HTML/ASSA) Text in Untertiteltabelle anzeigen", - "showUpDownStartTime": "Auf-/Ab-Steuerelement für „Anzeigen“ anzeigen", - "showUpDownEndTime": "Auf-/Ab-Steuerelement für „Ausblenden“ anzeigen", - "showUpDownDuration": "Auf-/Ab-Steuerung für „Dauer“ anzeigen", + "showUpDownStartTime": "Auf-/Ab-Steuerelement für \"Anzeigen\" anzeigen", + "showUpDownEndTime": "Auf-/Ab-Steuerelement für \"Ausblenden\" anzeigen", + "showUpDownDuration": "Auf-/Ab-Steuerung für \"Dauer\" anzeigen", "showUpDownLabels": "Beschriftungen für Auf-/Ab-Steuerelemente anzeigen (Anzeigen/Ausblenden/Dauer)", "showButtonHints": "Schaltflächenhinweise anzeigen", "gridCompactMode": "Kompaktmodus für das Raster verwenden", @@ -2844,6 +3001,7 @@ "showGridLines": "Gitterlinien anzeigen", "showHorizontalLineAboveToolbar": "Horizontale Linie über der Symbolleiste anzeigen", "bookmarkColor": "Lesezeichenfarbe", + "spellCheckHighlightColor": "Farbe für Rechtschreibprüfung", "singleLineMaxLength": "Max. Einzelzeilenlänge", "optimalCharsPerSec": "Optimale Zeichen/Sek", "maxCharsPerSec": "Max. Zeichen/Sek.", @@ -2863,18 +3021,26 @@ "autoBreakCommaBreakEarly": "Früher automatisch umbrechen bei Komma", "autoBreakDashEarly": "Früher automatisch umbrechen bei Gedankenstrich (Dialoge)", "autoBreakUsePixelWidth": "Automatischer Umbruch nach Pixelbreite", - "autoBreakPreferBottomHeavy": "Automatischer Umbruch bevorzugt untenlastig", - "autoBreakPreferBottomPercent": "Prozentsatz für untenlastigen automatischen Umbruch", + "autoBreakPreferBottomHeavy": "Automatischer Umbruch: Untere Zeile bevorzugen", + "autoBreakPreferBottomPercent": "Automatischer Umbruch: Prozentsatz für untere Zeile", "useDoNotBreakAfterList": "Liste \"Nicht umbrechen nach\" verwenden", "newEmptyDefaultMs": "Standarddauer neuer Untertitel (ms)", - "timeCodeUpDownStepMs": "Schrittweite Zeit auf/ab (ms)", + "timeCodeUpDownStepMs": "Schrittweite für Zeit hoch/runter (ms)", + "moveSelectedLinesStepMs": "Schrittweite für Tastenkürzel \"Ausgewählte Zeilen verschieben\" (ms)", + "moveStartEndStepMs": "Schrittweite für Tastenkürzel \"Start/Ende verschieben\" (ms)", + "moveLinesShortenNeighbor": "Zeilen verschieben: Vorherige/Nächste Zeile kürzen anstatt zu überlappen", "promptBeforeDelete": "Vor dem Löschen fragen", "rememberPositionAndSize": "Fensterposition und Größe speichern", + "titleBarFullFileName": "Vollständigen Dateipfad in der Titelleiste anzeigen", + "showRecentFiles": "Zuletzt verwendete Dateien anzeigen (zum erneuten Öffnen)", "openLastFileOnStart": "Zuletzt verwendete Datei beim Start öffnen", "autoSave": "Automatisch speichern (geöffnete Datei beim Bearbeiten speichern)", - "autoBackupOn": "Auto-Sicherung", + "autoBackupOn": "Automatische Sicherung", "autoBackupIntervalMinutes": "Auto-Sicherungsintervall (Minuten)", "autoBackupDeleteAfterDays": "Sicherung entfernen nach (Tage)", + "settingsBackupOn": "Einstellungen für automatische Sicherung", + "settingsBackupIntervalDays": "Sicherungsintervall (Tage, 0 = bei jedem Start)", + "settingsBackupMaxCount": "Anzahl zu behaltender Sicherungen", "autoConvertToUtf8": "Beim Öffnen automatisch in UTF-8 konvertieren", "autoTrimWhiteSpace": "Leerzeichen automatisch entfernen", "removeBlankLinesWhenOpening": "Leerzeilen beim Öffnen von Untertiteln entfernen", @@ -2891,7 +3057,10 @@ "colorGapTooShort": "Abstand farblich markieren, falls zu kurz", "errorBackgroundColor": "Farbe Fehler", "waveformDrawGridLines": "Gitter anzeigen", + "waveformUseSkiaRenderer": "Experimentellen schnellen Renderer verwenden", "waveformCenterVideoPositionAlsoWhenPaused": "Videoposition auch im Pausenzustand zentrieren", + "waveformSelectCurrentSubtitleWhilePaused": "Aktuellen Untertitel auch bei Pause auswählen", + "waveformRememberZoom": "Zoomstufe merken (horizontal und vertikal)", "waveformShowToolbar": "Symbolleiste anzeigen", "waveformShowToolbarEdit": "Symbolleiste anpassen...", "waveformShowToolbarEditLabel": "Symbolleisten-Elemente", @@ -2939,18 +3108,27 @@ "webVttUseXTimestampMap": "WebVTT: X-TIMESTAMP-MAP verwenden (Timecodes beim Laden versetzen)", "assaAutoSetResolution": "ASSA: Auflösung (PlayResX/PlayResY) beim Öffnen eines Videos übernehmen", "assaAutoSetResolutionPrompt": "ASSA: Vor Anpassung der Auflösung an das Video nachfragen (sonst automatisch neu abtasten)", + "videoControls": "Video-Steuerelemente", + "videoControlsEdit": "Steuerelemente anpassen…", + "videoControlsPositionText": "Anzeige Position/Dauer", + "videoControlsVideoFileName": "Video-Dateiname", + "videoControlsPlayerName": "Videoplayer-Name", "showStopButton": "Stopp Knopf anzeigen", "showFullscreenButton": "Vollbild Knopf anzeigen", "fullscreenHideControls": "Bedienelemente bei Vollbild ausblenden", + "showSecondarySubtitleDialog": "Stil-Dialog beim Öffnen einer zweiten Untertiteldatei anzeigen", + "rememberSecondarySubtitleFile": "Zweite Untertiteldatei merken", "autoOpenVideoFile": "Videodatei beim Öffnen des Untertitels automatisch öffnen", "downloadMpv": "mpv herunterladen", "downloadVlc": "VLC herunterladen", "allowSingleLetterShortcutsInTextbox": "Einzelbuchstaben-Hotkeys im Textfeld erlauben", - "spellCheckEnglishTreatInApostropheAsIng": "Rechtschreibprüfung: Wortendung „in“ als „ing“ behandeln (nur Englisch)", + "allowTextNavigationShortcutsInTextbox": "Tastenkürzel auf Textnavigations-Tasten (Strg+Links/Rechts, Pos1/Ende) im Textfeld zulassen", + "spellCheckEnglishTreatInApostropheAsIng": "Rechtschreibprüfung: Wortendung \"in\" als \"ing\" behandeln (nur Englisch)", "goToLineNumberSetsVideoPosition": "Gehe-zu-Zeile legt auch die Videoposition fest", "adjustAllTimesRememberLineSelectionChoice": "Alle Zeiten anpassen, Auswahl der Zeilen merken", "mergeKeepEndTime": "Zeilen verbinden: Endzeit beibehalten (Überlappung mit nächstem Untertitel erlauben)", "mergeKeepEndTimeOnlyAssa": "Zeilen verbinden: Endzeit nur bei \"Advanced Sub Station Alpha\" beibehalten", + "mergeAutoBreak": "Zeilen zusammenfügen: Automatischer Zeilenumbruch (\"Aus\" behält ursprüngliche Umbrüche)", "filesAndLogs": "Dateien und Protokolle", "showErrorLogFile": "Fehlerprotokolldatei anzeigen", "showToolsLogFile": "Whisper-Protokolldatei anzeigen", @@ -2959,6 +3137,7 @@ "showAssaLayer": "ASSA-Ebenenfeld anzeigen", "waveformCursorColor": "Wellenform Cursorfarbe", "waveformShotChangeColor": "Wellenform Farbe für Szenenwechsel", + "waveformGridColor": "Wellenform-Gitterfarbe", "waveformParagraphLeftColor": "Wellenform Farbe des linken Randes", "waveformParagraphRightColor": "Wellenform Farbe des rechten Randes", "waveformFancyHighColor": "Wellenform Farbe für Frequenzspitzen", @@ -2986,13 +3165,17 @@ "resetSyntaxColoring": "Syntaxfärbung zurücksetzen", "resetWaveform": "Wellenform zurücksetzen", "resetRules": "Regeln zurücksetzen", + "timeCodeMode": "Zeitstempel-Modus", + "timeCodeModeTime": "Zeit (hh:mm:ss,zzz)", + "timeCodeModeFrames": "Frames (hh:mm:ss:ff)", + "timeCodeModeFrameNumbers": "Frame-Nummern", "useFrameMode": "Bild-Modus verwenden (hh.mm.ss.ff)", "textBoxLimitNewLines": "Anzahl der Zeilen im Textfeld begrenzen", "mpvOpenGl": "libmpv - OpenGL", "mpvSoftwareRendering": "libmpv – Software-Rendering (langsam)", "mpvWidRendering": "libmpv – Natives Fenster-ID-Rendering", "waveFormsAndSpectrogramFoldersContainsX": "Die Wellenform und Spektrogramm-Ordner enthalten {0}", - "deleteWaveformAndSpectrogramFoldersQuestion": "„Wellenform“- und „Spektrogramm“-Dateien löschen?", + "deleteWaveformAndSpectrogramFoldersQuestion": "\"Wellenform\"- und \"Spektrogramm\"-Dateien löschen?", "waveformGenerateSpectrogram": "Spektrogramm erstellen", "waveformAutoGenerate": "Wellenform beim Öffnen eines Videos automatisch erzeugen", "waveformSpectrogramMode": "Spektrogramm-Modus (funktioniert zur Generierungszeit)", @@ -3002,7 +3185,10 @@ "waveformExtractAudioSampleRate": "Audio-Abtastrate bei Extraktion", "waveformExtractAudioSampleRateOriginal": "Original (Quelle beibehalten)", "waveformExtractAudioBitRate": "Audio-Bitrate bei Extraktion (MP3/M4A)", + "ffmpegUseCenterChannelOnly": "Nur Center-Kanal für 5.1-Audio verwenden (FFmpeg)", "vlcWidRendering": "libVLC - Rendering über native Fenster-ID", + "ffmpegSoftwareRendering": "FFmpeg", + "downloadFfmpegLibs": "FFmpeg-Bibliotheken herunterladen (für den FFmpeg-Videoplayer)", "subtitleGridEnterKeyAction": "Aktion bei Enter im Untertitelraster", "subtitleSingleClickAction": "Einfachklick-Aktion im Untertitelraster", "subtitleDoubleClickAction": "Doppelklick-Aktion im Untertitelraster", @@ -3030,6 +3216,7 @@ "subtitleGridFormattingShowFormatting": "Formatierung anzeigen", "subtitleGridFormattingShowTags": "Tags anzeigen", "subtitleGridFormattingHideTags": "Tags ausblenden", + "subtitleGridFormattingShowFormattingKeepTags": "Formatierung anzeigen, nicht-visuelle Tags beibehalten", "waveformParagraphBackgroundColor": "Wellenform Untertitel Hintergrundfarbe", "waveformParagraphSelectedBackgroundColor": "Wellenform ausgewählter Untertitel Hintergrundfarbe", "waveformAllowOverlap": "Überlappung erlauben (beim Verschieben/Größe ändern)", @@ -3040,19 +3227,22 @@ "saveAsBehaviorUseSubtitleFileName": "Untertitel-Dateiname verwenden", "saveAsAppendLanguageCodeTwoLetter": "Zwei Buchstaben", "saveAsAppendLanguageCodeLanguageName": "Name der Sprache", - "splitOddLineActionWeightTop": "Weight top", - "splitOddLineActionWeightBottom": "Weight bottom", - "splitOddLinesAction": "Split odd lines action", - "ocrUseWordSplitList": "OCR: use word split list", - "ocrGuessUnknownWords": "OCR: try to guess unknown words", - "speechToTextSelectedLinesPromptFirstTimeOnly": "Speech to text: selected lines, prompt for language/engine first time only", - "multipleReplaceShowDotDotDotButtons": "Multiple replace: show context menu buttons", - "gridFocusTextboxAfterInsertNew": "Grid: focus text box after insert new subtitle", + "splitOddLineActionWeightTop": "Gewichtung: Oben", + "splitOddLineActionWeightBottom": "Gewichtung: Unten", + "splitOddLinesAction": "Aktion für das Teilen ungerader Zeilen", + "ocrUseWordSplitList": "OCR: Worttrennungsliste verwenden", + "ocrGuessUnknownWords": "OCR: Versuchen, unbekannte Wörter zu erraten", + "spellCheckRememberUseAlwaysList": "Rechtschreibprüfung: Liste \"Immer verwenden\" merken", + "fixShortDisplayTimesAllowMoveStartTime": "Häufige Fehler korrigieren: Beim Anpassen zu kurzer Anzeigedauern die Startzeit verschieben", + "speechToTextSelectedLinesPromptFirstTimeOnly": "Sprache-zu-Text: Ausgewählte Zeilen, Sprache/Engine nur beim ersten Mal abfragen", + "multipleReplaceShowDotDotDotButtons": "Mehrfach Ersetzen: Kontextmenü-Schaltflächen anzeigen", + "gridFocusTextboxAfterInsertNew": "Untertitelraster: Textfeld nach dem Einfügen neuer Untertitel fokussieren", + "undoRedoGoToChangedLine": "Rückgängig/Wiederholen: Zur geänderten Zeile (und Videoposition) springen", "musicSymbol": "Musik Symbol", "musicSymbolsToReplace": "Zu ersetzende Musiksymbole (getrennt durch Komma)", - "textToSpeechPromptMergeContinuationLines": "Text zu Sprache: Nachfrage zum Zusammenfügen von Folgezeilen", - "textToSpeechPromptSkipNoiseLines": "Text zu Sprache: Nachfrage zum Überspringen von Geräusch- und Musikzeilen", - "textToSpeechPromptDetectSpeakers": "Text zu Sprache: Nachfrage zur Erkennung von Sprechernamen", + "textToSpeechPromptMergeContinuationLines": "Text-zu-Sprache: Nachfrage zum Zusammenfügen von Folgezeilen", + "textToSpeechPromptSkipNoiseLines": "Text-zu-Sprache: Nachfrage zum Überspringen von Geräusch- und Musikzeilen", + "textToSpeechPromptDetectSpeakers": "Text-zu-Sprache: Nachfrage zur Erkennung von Sprechernamen", "useFocusedButtonBackgroundColor": "Hintergrundfarbe für fokussierte Schaltflächen verwenden", "focusedButtonBackgroundColor": "Hintergrundfarbe für fokussierte Schaltflächen", "forceCrLfOnSave": "CR+LF beim Speichern erzwingen (Textuntertitel)", @@ -3067,12 +3257,18 @@ "waveformDoubleClickAction": "Wellenform Doppelklick-Aktion (nach Einfachklick)", "allSettings": "Alle Einstellungen", "uiScale": "UI-Skalierung (%)", + "fontScale": "Schrift skalieren (%)", + "textSelectionOpacity": "Transparenz der Textauswahl (%)", "waveformToolbarItems": "Wellenform-Symbolleisten-Elemente", "matchIconColorToDarkTheme": "Symbolfarbe an Vordergrundfarbe des dunklen Designs anpassen", "subtitlePreviewProperties": "Untertitel-Vorschaueigenschaften", "usePositionFromSubtitleFile": "Position aus Untertiteldatei verwenden (TTML/PAC/EBU STL)", "marginIsPartOfSubtitleArea": "Rand ist Teil des Untertitelbereichs", "textJustify": "Zeilenausrichtung", + "deinterlace": "Video Deinterlacing", + "deinterlaceOff": "Aus", + "deinterlaceAuto": "Automatisch (nur Interlaced-Frames)", + "deinterlaceAlways": "Immer", "pixelWidthInfo": "Grüne Linien = Max.-Breite-Limit | Roter Bereich = Text überschreitet Limit", "spellCheckEngineHunSpelll": "Hunspell", "spellCheckEngineMsWord": "MS Word", @@ -3081,7 +3277,9 @@ "waveformColorTheme": "Farbthema:", "waveformSaveAsCustomTheme": "Als benutzerdefiniertes Thema speichern...", "waveformLoadThemeDotDotDot": "Thema laden...", - "waveformExportThemeDotDotDot": "Thema exportieren..." + "waveformExportThemeDotDotDot": "Thema exportieren...", + "waveformSaveCustomThemeTitle": "Benutzerdefiniertes Thema speichern", + "waveformThemeHighContrast": "Hoher Kontrast" }, "shortcuts": { "searchShortcuts": "Tastenkombinationen durchsuchen...", @@ -3147,7 +3345,7 @@ "togglePlayPause": "Abspielen/Pause", "toggleLockTimeCodes": "Sperrzeitcodes umschalten", "duplicateSelectedLines": "Zeile duplizieren", - "sourceView": "Quelltextansicht", + "sourceView": "Quelltext anzeigen", "addOrEditBookmark": "Lesezeichen hinzufügen oder bearbeiten", "toggleBookmark": "Lesezeichen umschalten (ausgewählte Zeilen, kein Text)", "toggleForcedSelectedLines": "Erzwungen umschalten (ausgewählte Zeilen)", @@ -3194,7 +3392,7 @@ "listViewColumnTextDown": "Spalte, Text nach unten", "autoTranslateSelectedLines": "Ausgewählte Zeilen automatisch übersetzen...", "setAssaResolution": "ASSA-Auflösung setzen (PlayResX/PlayResY)", - "setShortcutForX": "Tastenkombination für „{0}“ festlegen", + "setShortcutForX": "Tastenkombination für \"{0}\" festlegen", "commandFileNewKeepVideo": "Öffnen (Video behalten)", "fileOpenOriginal": "Original Untertitel öffnen", "fileCloseOriginal": "Original Untertitel schließen", @@ -3223,8 +3421,8 @@ "openVideo": "Videodatei öffnen", "openVideoFromUrl": "Video über URL öffnen", "closeVideo": "Videodatei schließen", - "speechToText": "Sprache zu Text (Whisper)", - "textToSpeech": "Text zu Sprache", + "speechToText": "Sprache-zu-Text (Whisper)", + "textToSpeech": "Text-zu-Sprache", "videoOcr": "OCR für eingebrannte Untertitel", "burnIn": "Video mit eingebrannten Untertiteln erstellen", "generateTransparent": "Transparentes Video mit Untertiteln erzeugen", @@ -3284,7 +3482,7 @@ "importFromSe4Title": "Tastenkombinationen aus Subtitle Edit 4 importieren", "importFromSe4XImportedYSkipped": "{0} Tastenkombinationen aus {1} importiert ({2} übersprungen – keine passende SE 5-Aktion)", "importImageSubtitleForEdit": "Bildbasierten Untertitel zum Bearbeiten importieren", - "showPointSyncViaOther": "Punkt-Synchronisierung über anderen Untertitel anzeigen", + "showPointSyncViaOther": "Punkt-Synchronisation über anderen Untertitel anzeigen", "showPointSync": "Punkt-Synchronisierung anzeigen", "showMediaInformation": "Medieninformationen anzeigen", "chooseSubtitleFormat": "Untertitelformat wählen", @@ -3299,8 +3497,8 @@ "copySubtitleOriginalPathToClipboard": "Untertitelpfad des Originals in Zwischenablage kopieren", "focusTextBox": "Textfeld fokussieren", "sortByNumber": "Nach Nummer sortieren", - "sortByStartTime": "Nach „Einblenden“-Zeit sortieren", - "sortByEndTime": "Nach „Ausblenden“-Zeit sortieren", + "sortByStartTime": "Nach \"Einblenden\"-Zeit sortieren", + "sortByEndTime": "Nach \"Ausblenden\"-Zeit sortieren", "sortByDuration": "Nach Dauer sortieren", "sortByGap": "Nach Abstand sortieren", "sortByText": "Nach Text sortieren - alphabetisch", @@ -3335,9 +3533,9 @@ "selectionToSentenceCase": "Textfeld: Auswahl in Satzschreibweise", "selectionToggleCasing": "Textfeld: Schreibweise umschalten (GROSS -> klein -> Titelschreibweise)", "googleIt": "Google es (ausgewählter Text)", - "setActorXY": "Akteur {0} setzen: {1}", - "setNewActor": "Neuen Akteur setzen...", - "setActorSlotXTitle": "Akteur-Slot {0}", + "setActorXY": "Darsteller {0} setzen: {1}", + "setNewActor": "Neuen Darsteller setzen...", + "setActorSlotXTitle": "Darsteller-Slot {0}", "toggleDialogDashes": "Dialog-Bindestriche umschalten", "mergeWithLineBeforeAsDialog": "Mit Zeile davor als Dialog zusammenführen", "mergeWithLineAfterAsDialog": "Mit Zeile danach als Dialog zusammenführen", @@ -3371,25 +3569,25 @@ "getPluginsTitle": "Plugins beziehen", "disabled": "Deaktiviert", "notSupportedOnThisOs": "Auf diesem Betriebssystem nicht unterstützt", - "removePluginXConfirm": "Plugin „{0}“ entfernen? Dadurch wird der Plugin-Ordner gelöscht:{1}", - "couldNotRemovePluginX": "Plugin „{0}“ konnte nicht entfernt werden: {1}", - "pluginXHasNoExecutable": "Plugin „{0}“ hat keine ausführbare Datei für dieses Betriebssystem.", - "pluginXFailed": "Plugin „{0}“ fehlgeschlagen.", - "pluginXReportedError": "Plugin „{0}“ hat einen Fehler gemeldet.", + "removePluginXConfirm": "Plugin \"{0}\" entfernen? Dadurch wird der Plugin-Ordner gelöscht:{1}", + "couldNotRemovePluginX": "Plugin \"{0}\" konnte nicht entfernt werden: {1}", + "pluginXHasNoExecutable": "Plugin \"{0}\" hat keine ausführbare Datei für dieses Betriebssystem.", + "pluginXFailed": "Plugin \"{0}\" fehlgeschlagen.", + "pluginXReportedError": "Plugin \"{0}\" hat einen Fehler gemeldet.", "pluginXDone": "{0} fertig", - "pluginXReturnedUnparsableSubtitle": "Plugin „{0}“ hat einen Untertitel zurückgegeben, der nicht verarbeitet werden konnte.", + "pluginXReturnedUnparsableSubtitle": "Plugin \"{0}\" hat einen Untertitel zurückgegeben, der nicht verarbeitet werden konnte.", "loadingPluginList": "Plugin-Liste wird geladen...", "noPluginsAvailable": "Keine Plugins verfügbar.", "xPluginsAvailable": "{0} Plugins verfügbar.", "couldNotLoadPluginList": "Plugin-Liste konnte nicht geladen werden: {0}", "downloadingXPercent": "Wird heruntergeladen {0}%...", - "pluginXInstalled": "„{0}“ installiert.", - "couldNotInstallPluginX": "„{0}“ konnte nicht installiert werden: {1}", + "pluginXInstalled": "\"{0}\" installiert.", + "couldNotInstallPluginX": "\"{0}\" konnte nicht installiert werden: {1}", "install": "Installieren", "reinstall": "Neu installieren", "installedX": "Installiert ({0})", "updateAvailableXToY": "Update verfügbar ({0} → {1})", - "applyPluginToWhichLinesX": "„{0}“ anwenden auf:", + "applyPluginToWhichLinesX": "\"{0}\" anwenden auf:", "applyToSelectedLinesX": "Ausgewählte Zeilen ({0})", "applyToAllLinesX": "Alle Zeilen ({0})", "updateAllXAvailable": "Alle aktualisieren ({0})", @@ -3476,15 +3674,17 @@ "oneColor": "Eine Farbe (weiß)", "darknessThreshold": "Dunkelheitsschwelle", "editExportDotDotDot": "Bearbeiten/exportieren...", - "editBinaryOcrDatabase": "„Binärer Bildvergleich“-Datenbank bearbeiten", - "binaryImageCompareDatabase": "„Binärer Bildvergleich“-Datenbank", - "removeXFromUnknownWordsList": "„{0}“ aus Liste unbekannter Wörter entfernen", + "editBinaryOcrDatabase": "\"Binärer Bildvergleich\"-Datenbank bearbeiten", + "binaryImageCompareDatabase": "\"Binärer Bildvergleich\"-Datenbank", + "removeXFromUnknownWordsList": "\"{0}\" aus Liste unbekannter Wörter entfernen", "downloadingPaddleOcrEngineDotDotDot": "Paddle OCR-Engine wird heruntergeladen...", "downloadingPaddleOcrModelsDotDotDot": "Paddle OCR-Modelle werden heruntergeladen...", "paddleOcr": "Paddle OCR", - "binaryImageCompareInspectImageMatches": "„Binärer Bildvergleich“ - Bildübereinstimmungen prüfen", + "binaryImageCompareInspectImageMatches": "\"Binärer Bildvergleich\" - Bildübereinstimmungen prüfen", "saveBlankTextTitle": "Leeren Text speichern?", "saveBlankTextPrompt": "Leeren Text für Bild speichern?", + "drawnLinesDoNotMatchTitle": "Linien stimmen nicht mit dem Bild überein", + "drawnLinesDoNotMatchPrompt": "Einige der gezeichneten Linien stimmen nicht mit dem Bild überein (grüne Linien müssen auf dem Zeichen liegen, rote außerhalb). Daher wird dieses Zeichen nicht erkannt.\n\nTrotzdem speichern?", "yesAndNeverAskAgain": "Ja und nie wieder fragen", "importTextFromSubtitleDotDotDot": "Text aus Untertitel importieren...", "importTextFromSubtitleOverwritePrompt": "Einige Zeilen haben bereits OCR-Text. Vorhandenen Text überschreiben?", @@ -3657,7 +3857,7 @@ "renameCategory": "Kategorie umbenennen...", "deleteCategory": "Kategorie löschen", "moveToCategoryDotDotDot": "In Kategorie verschieben...", - "deleteCategoryQuestion": "Kategorie „{0}“ löschen? Darin enthaltene Stile werden in die Standardkategorie verschoben.", + "deleteCategoryQuestion": "Kategorie \"{0}\" löschen? Darin enthaltene Stile werden in die Standardkategorie verschoben.", "stylesTitleX": "Stile – {0}", "propertiesTitleX": "Eigenschaften – {0}", "attachmentsTitleX": "Anhänge – {0}", diff --git a/src/ui/Assets/Languages/Italian.json b/src/ui/Assets/Languages/Italian.json index 4edc11848f3..d0044324487 100644 --- a/src/ui/Assets/Languages/Italian.json +++ b/src/ui/Assets/Languages/Italian.json @@ -1,7 +1,7 @@ { "title": "SubtitleEdit", - "version": "v. 5.3.0-beta22", - "translatedBy": "v. 05.10.2026 di bovirus e GitGianluc", + "version": "v. 5.3.0-beta26", + "translatedBy": "v. 09.10.2026 di bovirus e GitGianluc", "cultureName": "it-IT", "general": { "abort": "Annulla", @@ -443,6 +443,7 @@ "percent": "Percentuale", "pickLayer": "Imposta livello", "pickOllamaModel": "Scegli modello Ollama", + "pickModel": "Scegli modello", "llamaCppUseRemoteServer": "Usa server esterno (URL)", "llamaCppEngineSettings": "Impostazioni motore lama.cpp", "llamaCppEngineSettingsSubtitle": "Build server locale llama.cpp", @@ -655,12 +656,14 @@ "styleExaggeration": "Esagerazione stile", "styles": "Stili", "subtitleFile": "File sottotitoli", + "subtitleFileChangedOutsideX": "Il file '{0}' è stato modificato al di fuori di Subtitle Edit.\r\n\r\nVuoi ricaricarlo e perdere le modifiche?", "subtitleFileName": "Nome file sottotitoli", "subtitleFileSaved": "File sottotitoli salvato", "subtitleFileSavedToX": "File sottotitoli salvato in {0}", "subtitleFiles": "File sottotitoli", "subtitleFormats": "Formati sottotitoli", "subtitleLoadedX": "Sottotitoli caricati: {0}", + "subtitleReloadedChangedOutsideX": "Sottotitoli ricaricati (modificati al di fuori di Subtitle Edit): {0}", "suffix": "Suffisso:", "suggestions": "Suggerimenti", "sync": "Sincronizza", @@ -744,6 +747,14 @@ "videoCustom3ForwardX": "Video, millisecondi personalizzati ({0:#,###,##0}) avanti, 3", "videoCustom4BackX": "Video, millisecondi personalizzati ({0:#,###,##0}) indietro, 4", "videoCustom4ForwardX": "Video, millisecondi personalizzati ({0:#,###,##0}) avanti, 4", + "videoCustom1BackAndPauseX": "Video, millisecondi personalizzati ({0:#,###,##0}) indietro e in pausa, 1", + "videoCustom1ForwardAndPauseX": "Video, millisecondi personalizzati ({0:#,###,##0}) avanti e in pausa, 1", + "videoCustom2BackAndPauseX": "Video, millisecondi personalizzati ({0:#,###,##0}) indietro e in pausa, 2", + "videoCustom2ForwardAndPauseX": "Video, millisecondi personalizzati ({0:#,###,##0}) avanti e in pausa, 2", + "videoCustom3BackAndPauseX": "Video, millisecondi personalizzati ({0:#,###,##0}) indietro e in pausa, 3", + "videoCustom3ForwardAndPauseX": "Video, millisecondi personalizzati ({0:#,###,##0}) avanti e in pausa, 3", + "videoCustom4BackAndPauseX": "Video, millisecondi personalizzati ({0:#,###,##0}) indietro e in pausa, 4", + "videoCustom4ForwardAndPauseX": "Video, millisecondi personalizzati ({0:#,###,##0}) avanti e in pausa, 4", "videoExtension": "Estensione file video", "videoFile": "File video", "videoFileGenerated": "Generato file video", @@ -1163,6 +1174,7 @@ "originalIsReadOnlyNotSaved": "Il sottotitolo originale è in sola lettura come riferimento e non è stato salvato", "originalIsEmptyNotSaved": "Il sottotitolo originale non contiene testo e non è stato salvato", "originalIsReadOnlyReference": "Il sottotitolo originale è aperto in sola lettura come riferimento", + "closeTranslationQuestionXY": "Vuoi chiudere la traduzione '{0}'?\r\n\r\nIl sottotitolo originale '{1}' diventa il sottotitolo che modifichi. Il file traduzione non verrà modificato.", "allowEditOfOriginalSubtitle": "Consenti modifica sottotitolo originale", "showAllOriginalLinesX": "Visualizza tutte le righe originali ({0} non hanno qui corrispondenze)", "showAllOriginalLinesHint": "Le righe senza corrispondenza vengono visualizzate come righe aggiuntive per visualizzare le parti mancanti nella traduzione.", @@ -2179,6 +2191,18 @@ "addFolderRecursiveDotDotDot": "Aggiungi cartella ricorsiva...", "selectFolderToConvert": "Seleziona la cartella con i file da convertire", "includeSubfolders": "Quando aggiungi una cartella includi le sottocartelle", + "videoFilesWhenAddingFolder": "File video (mkv, mp4, ts...) quando si aggiunge una cartella", + "videoFilesAsk": "Chiedi", + "videoFilesInclude": "Aggiungi le loro tracce sottotitoli", + "videoFilesSkip": "Salta", + "folderContainsXVideoFiles": "La cartella contiene '{0}' file video (mkv, mp4, ts...).\r\n\r\nVuoi aggiungere anche le tracce sottotitoli incorporate o solo i file sottotitoli?", + "addVideoFiles": "Aggiungi file video", + "skipVideoFiles": "Solo file sottotitoli", + "doNotAskAgainVideoFiles": "Non chiedere più (modifica impostazioni conversione batch)", + "settingsSectionOutput": "Output", + "settingsSectionAddingFiles": "Aggiunta file", + "settingsSectionImageBasedSubtitles": "Sottotitoli basati su immagini (OCR)", + "settingsSectionOther": "Altro", "keepSourceFileTimestamp": "Usa data/ora file sorgente per i file destinazione", "preventSleepWhileConverting": "Impedisce al computer di andare in stop durante la conversione", "scanningFolderX": "Scansione {0}...", @@ -2194,7 +2218,20 @@ "transportStreamOnlyTeletext": "Solo televideo", "transportStreamGetSizeFromVideo": "Rileva dimensioni dal video...", "twoLetterLanguageCodeUppercase": "Codice lingua a due lettere (maiuscolo)", - "threeLetterLanguageCodeUppercase": "Codice lingua a tre lettere (maiuscolo)" + "threeLetterLanguageCodeUppercase": "Codice lingua a tre lettere (maiuscolo)", + "formatContainsDotDotDot": "Il formato contiene...", + "extensionIsDotDotDot": "L'estensione è...", + "statusIsError": "Lo stato è errore", + "forcedTracksOnly": "Solo tracce forzate", + "xConvertedYFailedInZ": "{0:#,###,##0} convertito, {1:#,###,##0} non riuscito in {2}", + "xNotProcessed": "{0:#,###,##0} non elaborato", + "removeUnicodeControlCharactersTitle": "Rimuovi caratteri di controllo Unicode", + "removeUnicodeControlCharactersInfo": "Rimuove dal testo i caratteri di controllo Unicode invisibili (LRM, RLM, LRE, RLE, PDF, LRO e RLO) e sostituisce gli spazi di non interruzione con spazi normali.", + "preset": "Profilo", + "savePresetDotDotDot": "Salva profilo...", + "deletePreset": "Elimina profilo", + "presetName": "Nome profilo", + "deletePresetX": "Vuoi eliminare il profilo '{0}'?" }, "changeCasing": { "fixNames": "Correggi nomi", @@ -2488,7 +2525,21 @@ "correctWordsX": "Parole corrette: {0}", "namesX": "Nomi: {0}", "addedToDictionaryX": "Aggiunto al dizionario: {0}", - "doNotShowThisAgain": "Non visualizzare più questo messaggio" + "doNotShowThisAgain": "Non visualizzare più questo messaggio", + "editUseAlwaysList": "Modifica elenco 'Usa sempre'...", + "useAlwaysListTitle": "Controllo ortografico: elenco 'Usa sempre'.", + "useAlwaysListHeader": "Elenco 'Usa sempre'.", + "useAlwaysListDescription": "Il controllo ortografico delle parole viene sostituito automaticamente, salvato da 'Modifica tutto' e 'Usa sempre'.\r\nUna coppia viene usata solo quando la parola è scritta in modo errato.", + "useAlwaysWord": "Parola errata", + "xPairs": "{0:#,##0} coppie", + "xUnusedPairs": "{0:#,##0} mai usata", + "useAlwaysUnusedHint": "Mai usata: questa parola è scritta correttamente (o è un nome o una parola utente), quindi il controllo ortografico non la contrassegna mai", + "removeUnused": "Rimuovi non usate", + "useAlwaysEmpty": "Per questa lingua ancora nessuna coppia 'Usa sempre'", + "useAlwaysEmptyHint": "Le coppie vengono aggiunte quando nel controllo ortografico fai clic su 'Modifica tutto' o 'Usa sempre'.", + "useAlwaysNoMatch": "Nessuna coppia corrisponde alla ricerca", + "useAlwaysSameWord": "La sostituzione deve essere diversa dalla parola.", + "useAlwaysRememberOff": "In Impostazioni è disattivato \"Ricorda elenco 'Usa sempre'\", quindi il controllo ortografico non usa né salva questo elenco." }, "video": { "burnIn": { @@ -2669,8 +2720,14 @@ "speakFromCurrentLineHint": "Legge ad alta voce le righe una dopo l'altra, iniziando dalla riga attuale.\r\nPremi Esc o fai clic su un'altra riga per interrompere.", "stopSpeaking": "Stop lettura", "speakingLineX": "Lettura riga '{0}'... ('Esc' per interrompere)", + "playWithSpeechTitle": "Riproduci con sintesi vocale", + "playWithSpeechDotDotDot": "Riproduci con sintesi vocale...", + "playWithSpeechHint": "Riproduce il video e pronuncia ogni riga quando il video la raggiunge. Mette in pausa e cerca come al solito; premi 'Esc' per interrompere.", + "lowerVideoVolumeWhileSpeaking": "Abbassa il volume del video durante il parlato", + "pauseVideoWhenSpeechRunsLate": "Mette in pausa il video quando il parlato passa alla riga successiva", + "waitingForSpeechLineX": "Generazione vocale per la riga {0}... ('Esc' per interrompere)", "voiceKindClone": "Voce clonata", - "voiceKindPreset": "Integrato", + "voiceKindPreset": "Integrata", "voiceKindModel": "Modello vocale", "voiceKindOnline": "Online", "transcript": "Trascrizione", @@ -2733,6 +2790,12 @@ "advancedTtsSettings": "Impostazioni TTS avanzate", "advancedTtsAudioProcessing": "Elaborazione audio", "advancedTtsOutput": "Output", + "advancedTtsVoiceCloning": "Clonazione vocale", + "cleanCloneReferences": "Riferimenti di cloni vocali puliti (Sidon)", + "cleanCloneReferencesDescription": "Rimuove musica, rumore e riverbero dall'audio da cui viene clonata una voce: clip prese dal video e registrazioni importate nel gestore vocale. Usa il modello di ripristino del parlato Sidon su audio.cpp (download circa 940 MB). La voce clonata non copia più lo sfondo, ma i discorsi molto bassi o sovrapposti possono risultare ovattati.", + "cleaningCloneReferencesDotDotDot": "Pulizia riferimenti clone vocale...", + "downloadSidonTitle": "Vuoi scaricare Sidone?", + "downloadSidonQuestionX": "La pulizia dei riferimenti del clone vocale richiede il modello di ripristino del parlato Sidon ({0}).\r\n\r\nVuoi scaricarlo? Senza di esso i riferimenti vengono usati così come sono.", "proAudioPostProcessing": "Post-elaborazione professionale audio", "proAudioPostProcessingDescription": "Applica a ciascun segmento EQ warmth, noise gate, compressione, normalizzazione del volume (-16 LUFS) e dissolvenza in entrata/uscita.", "generatingSpeechSegmentXOfY": "Generazione parlato: segmento '{0}' di '{1}'", @@ -3199,7 +3262,8 @@ "syncPoints": "Punti sincronizzazione", "pointSync": "Sincronizza punti", "pointSyncViaOther": "Sincronizzazione punti tramite altri sottotitoli", - "syncPointCandidateInfo": "Sono evidenziate le righe che iniziano dopo {0}+ secondi di silenzio : spesso costituiscono punti di sincronizzazione affidabili", + "syncPointCandidateAfterInfo": "Sono evidenziate le righe seguite da {0}+ secondi di silenzio : spesso costituiscono punti di sincronizzazione affidabili", + "gapAfter": "Gap dopo", "adjustAllShortcuts": "Tasti rapidi:\r\n\r\n• Maiuscolo + sinistra/destra: sposta di 10 ms\r\n• Ctrl + sinistra/destra: sposta di 100 ms\r\n• Alt + sinistra/destra: sposta di 500 ms", "adjustAllShortcutsFrames": "Tasti rapidi:\r\n\r\n• Maiusc + sinistra/destra: sposta di 1 fotogramma\r\n• Ctrl + sinistra/destra: sposta di 10 fotogrammi\r\n• Alt + sinistra/destra: sposta di 1 secondo", "offsetInSeconds": "Offset in secondi", @@ -3362,6 +3426,7 @@ "promptBeforeDelete": "Chiedi conferma prima dell'eliminazione", "rememberPositionAndSize": "Ricorda posizione/dimensioni finestra", "titleBarFullFileName": "Visualizza percorso completo file nella barra del titolo", + "showRecentFiles": "Visualizza file recenti (per la riapertura)", "openLastFileOnStart": "All'avvio apri il file più recente", "autoSave": "Salvataggio automatico (salva il file aperto durante la modifica)", "autoBackupOn": "Backup automatico", @@ -3389,6 +3454,7 @@ "waveformUseSkiaRenderer": "Usa renderer veloce sperimentale", "waveformCenterVideoPositionAlsoWhenPaused": "Centra posizione video anche durante la pausa", "waveformSelectCurrentSubtitleWhilePaused": "Seleziona il sottotitolo attuale durante la pausa", + "waveformRememberZoom": "Ricorda valore zoom (orizzontale e verticale)", "waveformShowToolbar": "Visualizza barra strumenti", "waveformShowToolbarEdit": "Modifica barra strumenti...", "waveformShowToolbarEditLabel": "Barra strumenti forma d'onda/spettrogramma", @@ -3454,6 +3520,7 @@ "adjustAllTimesRememberLineSelectionChoice": "Regola tutti i tempi, ricorda la scelta selezione riga", "mergeKeepEndTime": "Unisci righe: conserva tempistica finale (consenti sovrapposizione con il sottotitolo successivo)", "mergeKeepEndTimeOnlyAssa": "Unisci righe: conserva tempistica finale solo con 'Advanced Sub Station Alpha'", + "mergeAutoBreak": "Unisci righe: testo unito con interruzione automatica (se disattivato mantiene le interruzioni di riga proprie delle righe)", "filesAndLogs": "File e registri", "showErrorLogFile": "Visualizza file registro errori", "showToolsLogFile": "Visualizza file registro Whisper", @@ -3590,6 +3657,10 @@ "usePositionFromSubtitleFile": "Eredita posizione dal file sottotitoli (TTML/PAC/EBU STL)", "marginIsPartOfSubtitleArea": "Il margine è parte dell'area sottotitoli", "textJustify": "Giustifica righe", + "deinterlace": "Deinterlacciamento video", + "deinterlaceOff": "Off", + "deinterlaceAuto": "Automatico (solo fotogrammi interlacciati)", + "deinterlaceAlways": "Sempre", "pixelWidthInfo": "Linee verdi = limite larghezza massima | Area rossa = il testo supera il limite", "spellCheckEngineHunSpelll": "Hunspell", "spellCheckEngineMsWord": "MS Word", @@ -4171,6 +4242,8 @@ "drawZoomOut": "Zoom- (Ctrl -)", "drawResetView": "Ripristina vista (Ctrl+0)", "drawToggleGrid": "Attiva/disattiva griglia (Ctrl+G)", + "drawTogglePreview": "Attiva/disattiva anteprima (F9)", + "drawPreviewFailed": "Anteprima non riuscita: è richiesto ffmpeg con libass (filtro sottotitoli).", "drawCopyToClipboard": "Copia negli appunti (Ctrl+C)", "drawShapes": "Forme", "drawSelectedPoint": "Punto selezionato", diff --git a/src/ui/Assets/Languages/Japanese.json b/src/ui/Assets/Languages/Japanese.json index 30086cb3e72..424ad6fb810 100644 --- a/src/ui/Assets/Languages/Japanese.json +++ b/src/ui/Assets/Languages/Japanese.json @@ -1,6 +1,6 @@ { "title": "Subtitle Edit", - "version": "v5.3.0-beta23", + "version": "v5.3.0-beta26", "translatedBy": "", "cultureName": "jp-JP", "general": { @@ -372,6 +372,8 @@ "moveAllShotChangeOneFrameForward": "全てのカット切替を1フレーム前に移動", "multiMode": "マルチモード", "moveDown": "下に移動", + "increase": "増加", + "decrease": "減少", "moveUp": "上に移動", "moveToTop": "先頭に移動", "moveToBottom": "末尾に移動", @@ -654,12 +656,14 @@ "styleExaggeration": "スタイルの誇張", "styles": "スタイル", "subtitleFile": "字幕ファイル", + "subtitleFileChangedOutsideX": "ファイル「{0}」がSubtitle Edit以外で変更されました。\n\n再読み込みしますか?(現在の変更内容は失われます)", "subtitleFileName": "字幕ファイル名", "subtitleFileSaved": "字幕ファイルが保存されました", "subtitleFileSavedToX": "字幕ファイルが{0}に保存されました", "subtitleFiles": "字幕ファイル", "subtitleFormats": "字幕フォーマット", "subtitleLoadedX": "字幕が読み込まれました: {0}", + "subtitleReloadedChangedOutsideX": "外部で変更された字幕を再読み込みしました", "suffix": "接尾語", "suggestions": "候補", "sync": "同期", @@ -743,6 +747,14 @@ "videoCustom3ForwardX": "動画、カスタム移動({0:#,###,##0} ms)進む 3", "videoCustom4BackX": "動画、カスタム移動({0:#,###,##0} ms)戻る 4", "videoCustom4ForwardX": "動画、カスタム移動({0:#,###,##0} ms)進む 4", + "videoCustom1BackAndPauseX": "動画:指定したミリ秒({0:#,###,##0})戻って一時停止 1", + "videoCustom1ForwardAndPauseX": "動画:指定したミリ秒({0:#,###,##0})進んで一時停止 1", + "videoCustom2BackAndPauseX": "動画:指定したミリ秒({0:#,###,##0})戻って一時停止 2", + "videoCustom2ForwardAndPauseX": "動画:指定したミリ秒({0:#,###,##0})進んで一時停止 2", + "videoCustom3BackAndPauseX": "動画:指定したミリ秒({0:#,###,##0})戻って一時停止 3", + "videoCustom3ForwardAndPauseX": "動画:指定したミリ秒({0:#,###,##0})進んで一時停止 3", + "videoCustom4BackAndPauseX": "動画:指定したミリ秒({0:#,###,##0})戻って一時停止 4", + "videoCustom4ForwardAndPauseX": "動画:指定したミリ秒({0:#,###,##0})進んで一時停止 4", "videoExtension": "動画ファイルの拡張子", "videoFile": "動画ファイル", "videoFileGenerated": "動画ファイルを生成しました", @@ -1162,6 +1174,7 @@ "originalIsReadOnlyNotSaved": "別字幕は読み取り専用の参照として開かれ、保存されません", "originalIsEmptyNotSaved": "別字幕にテキストがないため、保存されませんでした", "originalIsReadOnlyReference": "別字幕を読み取り専用の参照として開いています", + "closeTranslationQuestionXY": "作業字幕「{0}」を閉じますか?\n\n別字幕「{1}」が編集字幕になります。", "allowEditOfOriginalSubtitle": "別字幕の編集を許可", "showAllOriginalLinesX": "別字幕の全行を表示({0}行は一致する行がありません)", "showAllOriginalLinesHint": "一致しない行は追加行として表示されるため、翻訳に不足している部分を確認できます。", @@ -2178,6 +2191,18 @@ "addFolderRecursiveDotDotDot": "サブフォルダーも含めて追加...", "selectFolderToConvert": "変換するファイルが入ったフォルダーを選択", "includeSubfolders": "フォルダーの追加時にサブフォルダーを含める", + "videoFilesWhenAddingFolder": "フォルダー追加時の動画ファイル(mkv、mp4、tsなど)", + "videoFilesAsk": "確認する", + "videoFilesInclude": "動画に含まれる字幕トラックも追加", + "videoFilesSkip": "スキップ", + "folderContainsXVideoFiles": "このフォルダーには{0}個の動画ファイル(mkv、mp4、tsなど)があります。\n\n動画に含まれる字幕トラックも追加しますか?それとも字幕ファイルのみ追加しますか?", + "addVideoFiles": "動画ファイルを追加", + "skipVideoFiles": "字幕ファイルのみ", + "doNotAskAgainVideoFiles": "次回から確認しない(Batch convertの設定で変更できます)", + "settingsSectionOutput": "出力", + "settingsSectionAddingFiles": "ファイルの追加", + "settingsSectionImageBasedSubtitles": "画像ベースの字幕(OCR)", + "settingsSectionOther": "その他", "keepSourceFileTimestamp": "出力ファイルの日付・時刻を元ファイルの日時に維持", "preventSleepWhileConverting": "変換中はコンピューターをスリープさせない", "scanningFolderX": "{0} をスキャン中...", @@ -2500,7 +2525,21 @@ "correctWordsX": "正しい単語: {0}", "namesX": "固有名詞: {0}", "addedToDictionaryX": "辞書に追加: {0}", - "doNotShowThisAgain": "今後このメッセージを表示しない" + "doNotShowThisAgain": "今後このメッセージを表示しない", + "editUseAlwaysList": "「常に使用」リストを編集...", + "useAlwaysListTitle": "スペルチェック - 「常に使用」リスト", + "useAlwaysListHeader": "「常に使用」リスト", + "useAlwaysListDescription": "スペルチェックで自動的に置換する単語です。「すべて変更」または「常に使用」で保存されます。ペアは、単語がスペルミスの場合のみ使用されます。", + "useAlwaysWord": "スペルミスの単語", + "xPairs": "{0:#,##0}件は未使用", + "xUnusedPairs": "{0:#,##0}件は未使用", + "useAlwaysUnusedHint": "未使用 - この単語は正しく綴られているか、名前やユーザー辞書の単語のため、スペルチェックでスペルミスとして検出されません", + "removeUnused": "未使用を削除", + "useAlwaysEmpty": "この言語には「常に使用」ペアがまだありません", + "useAlwaysEmptyHint": "スペルチェックで「すべて変更」または「常に使用」をクリックすると、ペアが追加されます。", + "useAlwaysNoMatch": "検索に一致するペアはありません", + "useAlwaysSameWord": "置換後の単語は元の単語と異なる必要があります。", + "useAlwaysRememberOff": "設定で「『常に使用』リストを記憶」がオフになっているため、スペルチェックではこのリストを使用または保存しません。" }, "video": { "burnIn": { @@ -2681,6 +2720,12 @@ "speakFromCurrentLineHint": "現在の行から順に字幕を読み上げます。停止するにはEscキーを押すか、別の行をクリックしてください。", "stopSpeaking": "読み上げを停止", "speakingLineX": "行 {0} を読み上げ中...(Escで停止)", + "playWithSpeechTitle": "読み上げ再生", + "playWithSpeechDotDotDot": "読み上げ再生...", + "playWithSpeechHint": "動画を再生し、各字幕行のタイミングでその行を読み上げます。一時停止やシークは通常どおり操作できます。停止するにはEscキーを押します。", + "lowerVideoVolumeWhileSpeaking": "読み上げ中は動画音量を下げる", + "pauseVideoWhenSpeechRunsLate": "読み上げが次の字幕行に遅れたら動画を一時停止", + "waitingForSpeechLineX": "字幕行 {0} の読み上げを生成中...(Escキーで停止)", "voiceKindClone": "クローン音声", "voiceKindPreset": "組み込み", "voiceKindModel": "音声モデル", @@ -2728,6 +2773,7 @@ "cloneVoicePerLinePreparing": "動画から各行の声を取得しています...", "cloneVoicePerLineNeedsVideo": "各行の声をクローンするには、その字幕に対応する動画が必要です。動画を開いてもう一度試してください。", "cloneVoicePerLineNoClips": "動画から音声を取得できなかったため、クローンする声がありません。動画に音声トラックがあることを確認してください。", + "cloneVoicePerLineTranscribeClipsXYZ": "{0}は、動画のその行で何が話されているか分かっている場合にのみ、その行の声をクローンできます。{2}行中{1}行について内容が分かっていません。通常は元の言語の字幕(ファイル > 別字幕を開く...)から取得されます。\n\nこれらの行を音声認識で動画から文字起こししますか?\n\nはい:動画で話されている言語と音声認識エンジンを選択し、読み上げを生成する前に参照クリップを文字起こしします。\nいいえ:そのまま生成します。この場合、該当する行は動画内の声ではなく通常の音声で読み上げられます。", "voiceCloneConsentTitle": "音声クローン - 始める前に", "voiceCloneConsentHeader": "音声をクローンしようとしています", "voiceCloneConsentIntro": "音声クローンは実在する人物の声を複製します。多くの地域でこれには規則があり、EUではSubtitle Editではなく、あなた自身に法的義務が生じます。必ず一度お読みください。", @@ -2744,6 +2790,12 @@ "advancedTtsSettings": "高度なTTS設定", "advancedTtsAudioProcessing": "音声処理", "advancedTtsOutput": "出力", + "advancedTtsVoiceCloning": "音声のクローン作成", + "cleanCloneReferences": "音声クローンの元音声をクリーニング(Sidon)", + "cleanCloneReferencesDescription": "動画から切り出した音声や、音声マネージャーに読み込んだ録音から、音楽・ノイズ・残響を除去します。audio.cpp の Sidon 音声復元モデルを使用します(940 MB のダウンロードが必要)。背景音まで含めて声が複製されるのを防ぎますが、非常に小さな声や複数の声が重なった部分は、こもった音になることがあります。", + "cleaningCloneReferencesDotDotDot": "音声クローンの元音声をクリーニング中...", + "downloadSidonTitle": "Sidonをダウンロードしますか?", + "downloadSidonQuestionX": "音声クローンの元音声をクリーニングするには、Sidon 音声復元モデル({0})が必要です。\n\n今すぐダウンロードしますか? ダウンロードしない場合は、元音声をそのまま使用します。", "proAudioPostProcessing": "プロ音声後処理", "proAudioPostProcessingDescription": "各セグメントにEQによる音質補正、ノイズゲート、コンプレッサー、ラウドネス正規化(-16 LUFS)、フェードイン/アウトを適用します", "generatingSpeechSegmentXOfY": "音声を生成中:{1}セグメント中{0}セグメント", @@ -3210,6 +3262,7 @@ "syncPoints": "同期ポイント", "pointSync": "ポイント同期", "pointSyncViaOther": "他の字幕を基準にポイント同期", + "syncPointCandidateAfterInfo": "後に{0}秒以上の無音が続く字幕行を強調表示します。信頼できる同期ポイントになりやすい行です", "gapAfter": "後ろの空白", "adjustAllShortcuts": "キーボード ショートカット:\r\n\r\n• Shift + 左/右: 10ミリ秒 移動\r\n• Ctrl + 左/右: 100ミリ秒 移動\r\n• Alt + 左/右: 500ミリ秒 移動", "adjustAllShortcutsFrames": "キーボードショートカット:\r\n\r\n• Shift + ←/→: 1フレーム移動\r\n• Ctrl + ←/→: 10フレーム移動\r\n• Alt + ←/→: 1秒移動", @@ -3373,6 +3426,7 @@ "promptBeforeDelete": "削除前に確認メッセージを表示する", "rememberPositionAndSize": "ウィンドウの位置とサイズを記憶する", "titleBarFullFileName": "タイトルバーにファイルのフルパスを表示", + "showRecentFiles": "最近使ったファイルを表示(再度開く)", "openLastFileOnStart": "起動時に最後に開いたファイルを開く", "autoSave": "自動保存(編集中のファイルを自動的に保存)", "autoBackupOn": "自動バックアップ", @@ -3400,6 +3454,7 @@ "waveformUseSkiaRenderer": "実験的な高速レンダラーを使用", "waveformCenterVideoPositionAlsoWhenPaused": "一時停止中も再生位置を中央に保つ", "waveformSelectCurrentSubtitleWhilePaused": "一時停止中も現在の字幕行を選択", + "waveformRememberZoom": "ズームを記憶(水平・垂直)", "waveformShowToolbar": "ツールバーを表示", "waveformShowToolbarEdit": "ツールバーを編集", "waveformShowToolbarEditLabel": "波形/スペクトログラムのツールバー", @@ -3602,6 +3657,10 @@ "usePositionFromSubtitleFile": "字幕ファイルの位置を使用(TTML/PAC/EBU STL)", "marginIsPartOfSubtitleArea": "余白は字幕領域の一部です", "textJustify": "行を均等配置", + "deinterlace": "動画のインターレース解除", + "deinterlaceOff": "オフ", + "deinterlaceAuto": "自動(インターレース映像のみ)", + "deinterlaceAlways": "常に有効", "pixelWidthInfo": "緑色の線 = 最大幅制限 | 赤色の領域 = テキストが制限を超えています", "spellCheckEngineHunSpelll": "Hunspell", "spellCheckEngineMsWord": "MS Word", @@ -4183,6 +4242,8 @@ "drawZoomOut": "縮小 (Ctrl+-)", "drawResetView": "ビューのリセット (Ctrl+0)", "drawToggleGrid": "グリッドの表示/非表示 (Ctrl+G)", + "drawTogglePreview": "プレビューの切り替え (F9)", + "drawPreviewFailed": "プレビューに失敗しました - libass(字幕フィルター)対応のffmpegが必要です", "drawCopyToClipboard": "クリップボードにコピー (Ctrl+C)", "drawShapes": "図形", "drawSelectedPoint": "選択点", @@ -4460,4 +4521,4 @@ "exportGeneratedX": "生成日時: {0}", "exportMadeWithSubtitleEdit": "Subtitle Editで作成" } -} \ No newline at end of file +} diff --git a/src/ui/Assets/Languages/Korean.json b/src/ui/Assets/Languages/Korean.json index 62e9927a76c..e8a86869c05 100644 --- a/src/ui/Assets/Languages/Korean.json +++ b/src/ui/Assets/Languages/Korean.json @@ -1,6 +1,6 @@ { "title": "Subtitle Edit", - "version": "v5.3.0-beta23", + "version": "v5.3.0-beta26", "translatedBy": "Hack茶ん, 12시27분(@12si27)", "cultureName": "ko-KR", "general": { @@ -656,12 +656,14 @@ "styleExaggeration": "과장된 스타일", "styles": "스타일", "subtitleFile": "자막 파일", + "subtitleFileChangedOutsideX": "파일 \"{0}\"이(가) Subtitle Edit 외부에서 변경되었습니다.\n\n현재 수정 내용을 버리고 다시 불러오시겠습니까?", "subtitleFileName": "자막 파일 이름", "subtitleFileSaved": "자막 파일 저장됨", "subtitleFileSavedToX": "자막 파일이 {0}에 저장", "subtitleFiles": "자막 파일", "subtitleFormats": "자막 형식", "subtitleLoadedX": "자막 불러옴: {0}", + "subtitleReloadedChangedOutsideX": "자막을 다시 불러옴 (Subtitle Edit 외부에서 변경됨): {0}", "suffix": "접미사", "suggestions": "제안", "sync": "동기화", @@ -745,6 +747,14 @@ "videoCustom3ForwardX": "비디오, 사용자 정의 밀리초({0:#,###,##0}) 뒤로, 3", "videoCustom4BackX": "비디오, 사용자 정의 밀리초({0:#,###,##0}) 앞으로, 4", "videoCustom4ForwardX": "비디오, 사용자 정의 밀리초({0:#,###,##0}) 뒤로, 4", + "videoCustom1BackAndPauseX": "비디오, 사용자 정의 밀리초({0:#,###,##0}) 뒤로 이동 후 일시 정지, 1", + "videoCustom1ForwardAndPauseX": "비디오, 사용자 정의 밀리초({0:#,###,##0}) 앞으로 이동 후 일시 정지, 1", + "videoCustom2BackAndPauseX": "비디오, 사용자 정의 밀리초({0:#,###,##0}) 뒤로 이동 후 일시 정지, 2", + "videoCustom2ForwardAndPauseX": "비디오, 사용자 정의 밀리초({0:#,###,##0}) 앞으로 이동 후 일시 정지, 2", + "videoCustom3BackAndPauseX": "비디오, 사용자 정의 밀리초({0:#,###,##0}) 뒤로 이동 후 일시 정지, 3", + "videoCustom3ForwardAndPauseX": "비디오, 사용자 정의 밀리초({0:#,###,##0}) 앞으로 이동 후 일시 정지, 3", + "videoCustom4BackAndPauseX": "비디오, 사용자 정의 밀리초({0:#,###,##0}) 뒤로 이동 후 일시 정지, 4", + "videoCustom4ForwardAndPauseX": "비디오, 사용자 정의 밀리초({0:#,###,##0}) 앞으로 이동 후 일시 정지, 4", "videoExtension": "비디오 파일 확장자", "videoFile": "비디오 파일", "videoFileGenerated": "비디오 파일 생성됨", @@ -1164,6 +1174,7 @@ "originalIsReadOnlyNotSaved": "원본 자막은 읽기 전용 참조이며 저장되지 않았습니다.", "originalIsEmptyNotSaved": "원본 자막에 텍스트가 전혀 없어 저장하지 않았습니다", "originalIsReadOnlyReference": "원본 자막은 읽기 전용 참조로 열려 있습니다.", + "closeTranslationQuestionXY": "번역본 \"{0}\"을(를) 닫으시겠습니까?\n\n원본 자막 \"{1}\"이(가) 편집 대상이 됩니다. 번역 파일 자체는 변경되지 않습니다.", "allowEditOfOriginalSubtitle": "원본 자막 편집 허용", "showAllOriginalLinesX": "모든 원본 줄 표시 ({0}개 미일치)", "showAllOriginalLinesHint": "일치하지 않는 줄은 추가 행으로 표시되므로 번역에서 누락된 내용을 확인할 수 있습니다.", @@ -2012,7 +2023,7 @@ "maxShiftHint": "자막의 싱크가 어긋났을 수 있는 최대 시간입니다. 시작 시간이 이 값보다 많이 이동해야 하는 줄은 그대로 두고 표시합니다. 이 값과 관계없이, 주변 줄보다 0.5초 넘게 더 이동해야 하는 줄은 바로 이동하지 않습니다. 주변 줄이 모두 이동했다면 그에 맞춰 이동하고, 그렇지 않으면 직접 확인할 수 있도록 체크를 해제한 상태로 표시합니다.", "adjustStartTimes": "시작 시간 조정", "adjustEndTimes": "종료 시간 조정", - "isolateSpeech": "먼저 음성 분리 (느림)", + "isolateSpeech": "음성 분리 후 작업 (느림)", "isolateSpeechHint": "정렬 전에 음악과 효과음을 제거하여 정렬 모델이 대사만 듣도록 합니다. 큰 음악이나 액션 장면 속 대사를 처리할 때 도움이 되지만, GPU를 사용해도 오디오 길이만큼 시간이 걸리며 GPU가 없으면 몇 배 더 오래 걸립니다.", "isolatingSpeech": "음성 분리 중... (시간이 다소 걸립니다)", "isolateSpeechFailed": "음성을 분리하지 못해 원본 오디오를 기준으로 정렬했습니다.", @@ -2180,6 +2191,18 @@ "addFolderRecursiveDotDotDot": "하위 폴더까지 추가...", "selectFolderToConvert": "변환할 파일이 있는 폴더를 선택하세요", "includeSubfolders": "폴더를 추가할 때 하위 폴더 포함", + "videoFilesWhenAddingFolder": "폴더 추가 시 비디오 파일(mkv, mp4, ts...) 처리", + "videoFilesAsk": "묻기", + "videoFilesInclude": "비디오의 자막 트랙 추가", + "videoFilesSkip": "건너뛰기", + "folderContainsXVideoFiles": "폴더에 비디오 파일(mkv, mp4, ts...)이 {0}개 있습니다.\n\n비디오에 포함된 자막 트랙도 추가하시겠습니까, 아니면 자막 파일만 추가하시겠습니까?", + "addVideoFiles": "비디오 파일 추가", + "skipVideoFiles": "자막 파일만 추가", + "doNotAskAgainVideoFiles": "다시 묻지 않기 (일괄 변환 설정에서 변경 가능)", + "settingsSectionOutput": "출력", + "settingsSectionAddingFiles": "파일 추가", + "settingsSectionImageBasedSubtitles": "이미지 기반 자막(OCR)", + "settingsSectionOther": "기타", "keepSourceFileTimestamp": "출력 파일에 원본 파일의 날짜/시간 유지", "preventSleepWhileConverting": "변환 중 절전 모드 진입 방지", "scanningFolderX": "{0} 스캔 중...", @@ -2502,7 +2525,21 @@ "correctWordsX": "올바른 단어: {0}", "namesX": "이름: {0}", "addedToDictionaryX": "사전에 추가됨: {0}", - "doNotShowThisAgain": "다시 보지 않기" + "doNotShowThisAgain": "다시 보지 않기", + "editUseAlwaysList": "\"항상 사용\" 목록 편집...", + "useAlwaysListTitle": "맞춤법 검사 - \"항상 사용\" 목록", + "useAlwaysListHeader": "\"항상 사용\" 목록", + "useAlwaysListDescription": "맞춤법 검사에서 \"모두 변경\"이나 \"항상 사용\"으로 저장한 자동 교체 목록입니다. 잘못된 단어와 교체할 단어가 쌍으로 저장되며, 해당 단어가 맞춤법 오류로 판단될 때만 교체됩니다.", + "useAlwaysWord": "잘못된 단어", + "xPairs": "{0:#,##0}쌍", + "xUnusedPairs": "미사용 항목 {0:#,##0}개", + "useAlwaysUnusedHint": "미사용됨 - 맞춤법이 올바르거나 이름 또는 사용자 사전에 등록된 단어여서 맞춤법 오류로 표시되지 않습니다", + "removeUnused": "미사용 항목 제거", + "useAlwaysEmpty": "이 언어에는 아직 \"항상 사용\" 목록에 등록된 단어 쌍이 없습니다", + "useAlwaysEmptyHint": "맞춤법 검사에서 \"모두 변경\"이나 \"항상 사용\"을 클릭하면 단어 쌍이 추가됩니다.", + "useAlwaysNoMatch": "검색 조건과 맞는 단어 쌍이 없습니다", + "useAlwaysSameWord": "교체할 단어는 원래 단어와 달라야 합니다.", + "useAlwaysRememberOff": "설정에서 '맞춤법 검사: \"항상 사용\" 목록 기억'이 꺼져 있어 이 목록을 사용하거나 저장하지 않습니다." }, "video": { "burnIn": { @@ -2680,9 +2717,15 @@ "voiceManagerMenuItem": "TTS 음성 관리자...", "speakFromCurrentLineTitle": "현재 줄부터 읽기", "speakFromCurrentLineDotDotDot": "현재 줄부터 읽기...", - "speakFromCurrentLineHint": "현재 줄부터 차례로 소리 내어 읽습니다. Escape를 누르거나 다른 줄을 클릭하면 중지합니다.", + "speakFromCurrentLineHint": "현재 줄부터 차례로 소리 내어 읽습니다. Esc를 누르거나 다른 줄을 클릭하면 중지합니다.", "stopSpeaking": "읽기 중지", - "speakingLineX": "{0}번 줄 읽는 중... (Escape로 중지)", + "speakingLineX": "{0}번 줄 읽는 중... (Esc로 중지)", + "playWithSpeechTitle": "자막을 읽으며 재생", + "playWithSpeechDotDotDot": "자막을 읽으며 재생...", + "playWithSpeechHint": "비디오를 재생하며 각 자막 줄의 시작 시간에 맞춰 소리 내어 읽습니다. 평소처럼 일시 정지하거나 재생 위치를 이동할 수 있으며, Esc를 누르면 중지합니다.", + "lowerVideoVolumeWhileSpeaking": "자막을 읽는 동안 비디오 음량 낮추기", + "pauseVideoWhenSpeechRunsLate": "다음 자막 줄이 시작되어도 읽기가 끝나지 않으면 비디오 일시 정지", + "waitingForSpeechLineX": "{0}번 줄 음성 생성 중... (Esc로 중지)", "voiceKindClone": "복제된 음성", "voiceKindPreset": "내장", "voiceKindModel": "음성 모델", @@ -2747,6 +2790,12 @@ "advancedTtsSettings": "고급 TTS 설정", "advancedTtsAudioProcessing": "오디오 처리", "advancedTtsOutput": "출력", + "advancedTtsVoiceCloning": "음성 복제", + "cleanCloneReferences": "음성 복제용 참조 오디오 정리 (Sidon)", + "cleanCloneReferencesDescription": "비디오에서 가져온 클립이나 음성 관리자에서 가져온 녹음 등 음성 복제에 사용하는 참조 오디오의 음악, 잡음, 잔향을 제거합니다. audio.cpp에서 Sidon 음성 복원 모델을 사용하며, 940 MB 다운로드가 필요합니다. 배경 소리까지 복제되는 것을 막지만, 아주 작은 목소리나 겹쳐 말하는 부분은 먹먹하게 들릴 수 있습니다.", + "cleaningCloneReferencesDotDotDot": "음성 복제용 참조 오디오 정리 중...", + "downloadSidonTitle": "Sidon을 다운로드하시겠습니까?", + "downloadSidonQuestionX": "음성 복제용 참조 오디오를 정리하려면 Sidon 음성 복원 모델({0})이 필요합니다.\n\n지금 다운로드하시겠습니까? 다운로드하지 않으면 참조 오디오를 그대로 사용합니다.", "proAudioPostProcessing": "프로 오디오 후처리", "proAudioPostProcessingDescription": "각 구간에 EQ 웜, 노이즈 게이트, 컴프레션, 라우드니스 정규화(-16 LUFS) 및 페이드 인/아웃을 적용합니다.", "generatingSpeechSegmentXOfY": "음성 생성 중: {1}개 구간 중 {0}번째", @@ -3377,6 +3426,7 @@ "promptBeforeDelete": "삭제 전에 확인 메시지 표시", "rememberPositionAndSize": "창의 위치와 크기 기억하기", "titleBarFullFileName": "제목 표시줄에 전체 파일 경로 표시", + "showRecentFiles": "최근 파일 표시 (다시 열기)", "openLastFileOnStart": "시작 시, 마지막으로 사용한 파일 열기", "autoSave": "자동 저장 (편집 중 열린 파일 저장)", "autoBackupOn": "자동 백업", @@ -3404,6 +3454,7 @@ "waveformUseSkiaRenderer": "실험적 고속 렌더러 사용", "waveformCenterVideoPositionAlsoWhenPaused": "일시정지 중에도 재생 위치 중앙에 맞추기", "waveformSelectCurrentSubtitleWhilePaused": "일시 중지 중에도 현재 자막 선택", + "waveformRememberZoom": "확대/축소 상태 기억 (수평 및 수직)", "waveformShowToolbar": "도구 모음 표시", "waveformShowToolbarEdit": "도구 모음 편집...", "waveformShowToolbarEditLabel": "도구 모음 항목", @@ -3606,6 +3657,10 @@ "usePositionFromSubtitleFile": "자막 파일(TTML/PAC/EBU STL)에서 위치 사용", "marginIsPartOfSubtitleArea": "여백을 자막 영역에 포함", "textJustify": "줄 양쪽 맞춤", + "deinterlace": "비디오 디인터레이스", + "deinterlaceOff": "끄기", + "deinterlaceAuto": "자동 (인터레이스된 프레임만)", + "deinterlaceAlways": "항상", "pixelWidthInfo": "녹색 선 = 최대 너비 제한 | 빨간색 영역 = 텍스트가 제한을 초과함", "spellCheckEngineHunSpelll": "Hunspell", "spellCheckEngineMsWord": "MS 워드", @@ -4187,6 +4242,8 @@ "drawZoomOut": "축소(Ctrl+-)", "drawResetView": "화면 초기화(Ctrl+0)", "drawToggleGrid": "격자 표시/숨기기(Ctrl+G)", + "drawTogglePreview": "미리 보기 표시/숨기기(F9)", + "drawPreviewFailed": "미리 보기 실패 - libass(subtitles 필터)를 지원하는 FFmpeg가 필요합니다", "drawCopyToClipboard": "클립보드에 복사(Ctrl+C)", "drawShapes": "도형", "drawSelectedPoint": "선택한 지점", diff --git a/src/ui/Assets/Languages/Polish.json b/src/ui/Assets/Languages/Polish.json index 64770441248..68781e38caa 100644 --- a/src/ui/Assets/Languages/Polish.json +++ b/src/ui/Assets/Languages/Polish.json @@ -1,7 +1,7 @@ { "title": "Subtitle Edit", - "version": "v5.3.0-beta23", - "translatedBy": "Adam Malich (ostatnia aktualizacja: 6.10.2026)", + "version": "v5.3.0-beta25", + "translatedBy": "Adam Malich (ostatnia aktualizacja: 8.10.2026)", "cultureName": "pl-PL", "general": { "abort": "Przerwij", @@ -72,7 +72,7 @@ "backend": "Backend", "background": "Tło", "backgroundColor": "Kolor tła", - "backward": "Do tyłu", + "backward": "przewiń do tyłu", "batchMode": "Tryb wsadowy", "bdnXml": "BDN/xml", "bdnXml8Bit": "BDN/xml 8-bit", @@ -266,7 +266,7 @@ "forced": "Wymuszone", "foreground": "Pierwszy plan", "format": "Format ", - "forward": "Do przodu", + "forward": "przewiń do przodu", "foundNoMatches": "Nie znaleziono żadnych dopasowań", "foundOneMatch": "Znaleziono jedno dopasowanie", "foundXInLineYZ": "Znaleziono \"{0}\" w linii {1}, pozycja {2}", @@ -726,7 +726,7 @@ "url": "Adres URL", "usages": "Użycia", "use": "Użyj", - "useAlways": "Używaj zawsze", + "useAlways": "Zawsze używaj", "useOnce": "Użyj raz", "useOutputFolder": "Użyj folderu wyjściowego", "useSourceFolder": "Użyj folderu źródłowego", @@ -737,14 +737,22 @@ "video100MsForward": "Wideo, 100 milisekund do przodu", "video500MsBack": "Wideo, 500 milisekund do tyłu", "video500MsForward": "Wideo, 500 milisekund do przodu", - "videoCustom1BackX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) do tyłu, 1", - "videoCustom1ForwardX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) do przodu, 1", - "videoCustom2BackX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) do tyłu, 2", - "videoCustom2ForwardX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) do przodu, 2", - "videoCustom3BackX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) do tyłu, 3", - "videoCustom3ForwardX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) do przodu, 3", - "videoCustom4BackX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) do tyłu, 4", - "videoCustom4ForwardX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) do przodu, 4", + "videoCustom1BackX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) przewiń do tyłu, 1", + "videoCustom1ForwardX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) przewiń do przodu, 1", + "videoCustom2BackX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) przewiń do tyłu, 2", + "videoCustom2ForwardX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) przewiń do przodu, 2", + "videoCustom3BackX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) przewiń do tyłu, 3", + "videoCustom3ForwardX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) przewiń do przodu, 3", + "videoCustom4BackX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) przewiń do tyłu, 4", + "videoCustom4ForwardX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) przewiń do przodu, 4", + "videoCustom1BackAndPauseX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) przewiń do tyłu i wstrzymaj, 1", + "videoCustom1ForwardAndPauseX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) przewiń do przodu i wstrzymaj, 1", + "videoCustom2BackAndPauseX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) przewiń do tyłu i wstrzymaj, 2", + "videoCustom2ForwardAndPauseX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) przewiń do przodu i wstrzymaj, 2", + "videoCustom3BackAndPauseX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) przewiń do tyłu i wstrzymaj, 3", + "videoCustom3ForwardAndPauseX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) przewiń do przodu i wstrzymaj, 3", + "videoCustom4BackAndPauseX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) przewiń do tyłu i wstrzymaj, 4", + "videoCustom4ForwardAndPauseX": "Wideo, niestandardowe milisekundy ({0:#,###,##0}) przewiń do przodu i wstrzymaj, 4", "videoExtension": "Rozszerzenie pliku wideo ", "videoFile": "Plik wideo", "videoFileGenerated": "Wygenerowano plik wideo", @@ -1164,6 +1172,7 @@ "originalIsReadOnlyNotSaved": "Oryginalne napisy są tylko odniesieniem do odczytu i nie zostały zapisane", "originalIsEmptyNotSaved": "Oryginalne napisy nie zawierają żadnego tekstu i nie zostały zapisane", "originalIsReadOnlyReference": "Oryginalne napisy są dostępne jako materiał referencyjny tylko do odczytu", + "closeTranslationQuestionXY": "Zamknąć tłumaczenie \"{0}\"?\n\nOryginalny napis \"{1}\" staje się napisem, który właśnie edytujesz. Plik tłumaczenia nie zostanie zmodyfikowany.", "allowEditOfOriginalSubtitle": "Zezwól na edycję oryginalnych napisów", "showAllOriginalLinesX": "Pokaż wszystkie oryginalne linie (tutaj nie ma żadnych dopasowań dla {0})", "showAllOriginalLinesHint": "Linie, dla których nie znaleziono dopasowania, są wyświetlane jako dodatkowe wiersze, dzięki czemu można zobaczyć, czego brakuje w tłumaczeniu.", @@ -2180,6 +2189,18 @@ "addFolderRecursiveDotDotDot": "Dodaj folder rekurencyjnie...", "selectFolderToConvert": "Wybierz folder z plikami do konwersji", "includeSubfolders": "Uwzględnij podfoldery podczas dodawania folderu", + "videoFilesWhenAddingFolder": "Pliki wideo (mkv, mp4, ts...) podczas dodawania folderu", + "videoFilesAsk": "Zapytaj", + "videoFilesInclude": "Dodaj ścieżki z napisami", + "videoFilesSkip": "Pomiń", + "folderContainsXVideoFiles": "Folder zawiera {0} plików wideo (mkv, mp4, ts...).\n\nCzy chcesz również uwzględnić wbudowane ścieżki napisów, czy tylko pliki z napisami?", + "addVideoFiles": "Dodaj pliki wideo", + "skipVideoFiles": "Tylko pliki z napisami", + "doNotAskAgainVideoFiles": "Nie pytaj ponownie (zmiana w ustawieniach konwersji wsadowej)", + "settingsSectionOutput": "Wyjście", + "settingsSectionAddingFiles": "Dodawanie plików", + "settingsSectionImageBasedSubtitles": "Napisy oparte na obrazie (OCR)", + "settingsSectionOther": "Inne", "keepSourceFileTimestamp": "Zachowaj datę/godzinę pliku źródłowego w plikach wyjściowych", "preventSleepWhileConverting": "Zapobiegaj przechodzeniu komputera w tryb uśpienia podczas konwersji", "scanningFolderX": "Skanowanie {0}...", @@ -2502,7 +2523,21 @@ "correctWordsX": "Poprawne słowa: {0}", "namesX": "Nazwy własne: {0}", "addedToDictionaryX": "Dodane do słownika: {0}", - "doNotShowThisAgain": "Nie pokazuj więcej tego komunikatu" + "doNotShowThisAgain": "Nie pokazuj więcej tego komunikatu", + "editUseAlwaysList": "Edytuj listę \"Zawsze używaj\"...", + "useAlwaysListTitle": "Sprawdzanie pisowni - lista \"Zawsze używaj\"", + "useAlwaysListHeader": "Lista \"Zawsze używaj\"", + "useAlwaysListDescription": "Automatyczna korekta pisowni wyrazów uwzględnia zmiany zapisane za pomocą opcji \"Zmień wszystko\" i \"Zawsze używaj\". Para jest używana tylko wtedy, gdy słowo jest błędnie napisane.", + "useAlwaysWord": "Błędnie napisane słowo", + "xPairs": "{0:#,##0} par", + "xUnusedPairs": "{0:#,##0} nigdy nieużywane", + "useAlwaysUnusedHint": "Nigdy nie używane – to słowo jest napisane poprawnie (lub jest imieniem, nazwiskiem albo terminem użytkownika), więc sprawdzanie pisowni nigdy go nie zaznacza", + "removeUnused": "Usuń nieużywane", + "useAlwaysEmpty": "Dla tego języka nie ma jeszcze par typu \"Zawsze używaj\"", + "useAlwaysEmptyHint": "Pary są dodawane po kliknięciu opcji \"Zmień wszystko\" albo \"Zawsze używaj\" w trakcie sprawdzania pisowni.", + "useAlwaysNoMatch": "Brak par pasujących do wyszukiwania", + "useAlwaysSameWord": "Zastępcze słowo musi różnić się od pierwotnego.", + "useAlwaysRememberOff": "Opcja \"Zapamiętaj listę 'Zawsze używaj'\" jest wyłączona w Ustawieniach, więc sprawdzanie pisowni nie korzysta z tej listy ani jej nie zapisuje." }, "video": { "burnIn": { @@ -2683,6 +2718,12 @@ "speakFromCurrentLineHint": "Odczytuje kolejne linie na głos, zaczynając od bieżącej linii. Naciśnij klawisz Escape lub kliknij inny wiersz, aby zatrzymać.", "stopSpeaking": "Przestań mówić", "speakingLineX": "Wypowiedź linii {0}... (Naciśnij klawisz Escape, aby przerwać)", + "playWithSpeechTitle": "Odtwórz za pomocą syntezy mowy", + "playWithSpeechDotDotDot": "Odtwórz za pomocą syntezy mowy...", + "playWithSpeechHint": "Odtwarza wideo i wypowiada każdą kwestię w momencie, gdy wideo do niej dotrze. Można wstrzymywać odtwarzanie i przewijać w typowy sposób; naciśnij klawisz Escape, aby zatrzymać.", + "lowerVideoVolumeWhileSpeaking": "Obniż głośność wideo podczas mówienia", + "pauseVideoWhenSpeechRunsLate": "Wstrzymaj odtwarzanie wideo, gdy wypowiadany tekst przechodzi do kolejnej linii", + "waitingForSpeechLineX": "Generowanie głosu dla linii {0}... (naciśnij Esc, aby zatrzymać)", "voiceKindClone": "Sklonowany głos", "voiceKindPreset": "Wbudowany", "voiceKindModel": "Model głosu", @@ -3377,6 +3418,7 @@ "promptBeforeDelete": "Pytaj przed usunięciem", "rememberPositionAndSize": "Zapamiętaj położenie i rozmiar okna", "titleBarFullFileName": "Pokaż pełną ścieżkę do pliku na pasku tytułu", + "showRecentFiles": "Pokaż ostatnio używane pliki (do ponownego otwarcia)", "openLastFileOnStart": "Otwórz ostatni otwierany plik przy uruchomieniu", "autoSave": "Automatyczne zapisywanie (zapisywanie otwartego pliku podczas edycji)", "autoBackupOn": "Automatyczna kopia zapasowa", @@ -3404,6 +3446,7 @@ "waveformUseSkiaRenderer": "Użyj eksperymentalnego szybkiego renderera", "waveformCenterVideoPositionAlsoWhenPaused": "Wyśrodkuj pozycję wideo również podczas wstrzymania", "waveformSelectCurrentSubtitleWhilePaused": "Zaznacz bieżące napisy również podczas wstrzymania", + "waveformRememberZoom": "Zapamiętaj powiększenie (poziome i pionowe)", "waveformShowToolbar": "Pokaż pasek narzędzi", "waveformShowToolbarEdit": "Edytuj pasek narzędzi...", "waveformShowToolbarEditLabel": "Elementy paska narzędzi", @@ -3469,7 +3512,7 @@ "adjustAllTimesRememberLineSelectionChoice": "Dostosuj wszystkie czasy, zapamiętaj wybór linii", "mergeKeepEndTime": "Połącz linie: zachowaj czas zakończenia (zezwól na nakładanie się na następny napis)", "mergeKeepEndTimeOnlyAssa": "Połącz linie: zachowaj czas zakończenia tylko dla \"Advanced Sub Station Alpha\"", - "mergeAutoBreak": "Połącz linie: automatyczne łamanie scalonego tekstu (wyłączenie tej opcji zachowuje oryginalne podziały linii)", + "mergeAutoBreak": "Połącz linie: scalanie tekstu z automatycznymi znakami podziału linii (jeśli opcja jest wyłączona, zachowywane są oryginalne podziały linii)", "filesAndLogs": "Pliki i dzienniki", "showErrorLogFile": "Pokaż plik dziennika błędów", "showToolsLogFile": "Pokaż plik dziennika narzędzi", @@ -3572,7 +3615,7 @@ "splitOddLinesAction": "Dzielenie nieparzystych linii", "ocrUseWordSplitList": "OCR: użyj listy podziału na słowa", "ocrGuessUnknownWords": "OCR: spróbuj odgadnąć nieznane słowa", - "spellCheckRememberUseAlwaysList": "Sprawdzanie pisowni: zapamiętaj listę \"Używaj zawsze\"", + "spellCheckRememberUseAlwaysList": "Sprawdzanie pisowni: zapamiętaj listę \"Zawsze używaj\"", "fixShortDisplayTimesAllowMoveStartTime": "Popraw często popełniane błędy: poprawienie zbyt krótkiego czasu wyświetlania może spowodować przesunięcie czasu rozpoczęcia", "speechToTextSelectedLinesPromptFirstTimeOnly": "Zamiana mowy na tekst: wybrane fragmenty, prośba o wybór języka/silnika pojawia się tylko przy pierwszym użyciu", "multipleReplaceShowDotDotDotButtons": "Wielokrotne zastąpienie: pokaż menu kontekstowe przycisków", diff --git a/src/ui/Controls/BidiRunIndexFixer.cs b/src/ui/Controls/BidiRunIndexFixer.cs deleted file mode 100644 index b0a230fe891..00000000000 --- a/src/ui/Controls/BidiRunIndexFixer.cs +++ /dev/null @@ -1,110 +0,0 @@ -using Avalonia.Media.TextFormatting; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Reflection; - -namespace Nikse.SubtitleEdit.Controls; - -/// -/// Works around an Avalonia 12.1.3 bug in BidiReorderer.BidiReorder: the final loop writes -/// indexedTextRuns[visualPosition].RunIndex = logicalIndex - the inverse of the mapping -/// TextLineImpl expects (indexedTextRuns[logicalIndex].RunIndex = visualPosition). -/// -/// The inverse equals the mapping itself for a plain reversal, so it only shows when the -/// reorder is not self-inverse - e.g. a Latin stretch split into two or more style runs inside -/// a right-to-left line, which is exactly what the tag coloring produces for -/// "أريد أن أقول...<font size=30>\N". Hit testing then measures the wrong runs: the -/// selection is drawn in the wrong place and GetTextBounds throws "Covered length must be -/// greater than zero" from pointer, selection and render code, freezing and crashing SE (#15531). -/// -/// The fix recomputes each RunIndex from the line's visual run order, so it is a no-op once -/// Avalonia is fixed. If Avalonia renames the internals, it silently does nothing -/// (the canary test BidiRunIndexFixerTests catches that at upgrade time). -/// -internal static class BidiRunIndexFixer -{ - private static readonly FieldInfo? IndexedTextRunsField; - private static readonly PropertyInfo? RunIndexProperty; - private static readonly PropertyInfo? TextRunProperty; - - static BidiRunIndexFixer() - { - var textLineImplType = typeof(TextLine).Assembly.GetType("Avalonia.Media.TextFormatting.TextLineImpl"); - var indexedTextRunType = typeof(TextLine).Assembly.GetType("Avalonia.Media.TextFormatting.IndexedTextRun"); - if (textLineImplType == null || indexedTextRunType == null) - { - return; - } - - IndexedTextRunsField = textLineImplType.GetField("_indexedTextRuns", BindingFlags.Instance | BindingFlags.NonPublic); - RunIndexProperty = indexedTextRunType.GetProperty("RunIndex", BindingFlags.Instance | BindingFlags.Public); - TextRunProperty = indexedTextRunType.GetProperty("TextRun", BindingFlags.Instance | BindingFlags.Public); - } - - /// - /// True when the Avalonia internals the fix relies on were found. - /// - internal static bool IsAvailable => - IndexedTextRunsField != null && - RunIndexProperty is { CanRead: true, CanWrite: true } && - TextRunProperty is { CanRead: true }; - - /// - /// Corrects the run indices of every line in . - /// - public static void Fix(TextLayout layout) - { - if (!IsAvailable) - { - return; - } - - foreach (var line in layout.TextLines) - { - Fix(line); - } - } - - private static void Fix(TextLine line) - { - var visualRuns = line.TextRuns; - if (visualRuns.Count < 3) - { - return; // two runs are always a plain reversal or identity - the inverse is the same - } - - if (!IsAvailable || IndexedTextRunsField!.DeclaringType?.IsInstanceOfType(line) != true || - IndexedTextRunsField.GetValue(line) is not IList indexedRuns) - { - return; - } - - foreach (var indexedRun in indexedRuns) - { - if (indexedRun == null || TextRunProperty!.GetValue(indexedRun) is not TextRun textRun) - { - continue; - } - - var visualIndex = IndexOfReference(visualRuns, textRun); - if (visualIndex >= 0 && !Equals(RunIndexProperty!.GetValue(indexedRun), visualIndex)) - { - RunIndexProperty.SetValue(indexedRun, visualIndex); - } - } - } - - private static int IndexOfReference(IReadOnlyList runs, TextRun run) - { - for (var i = 0; i < runs.Count; i++) - { - if (ReferenceEquals(runs[i], run)) - { - return i; - } - } - - return -1; - } -} diff --git a/src/ui/Controls/SyntaxHighlightingTextPresenter.cs b/src/ui/Controls/SyntaxHighlightingTextPresenter.cs index 86383816125..22826a6754d 100644 --- a/src/ui/Controls/SyntaxHighlightingTextPresenter.cs +++ b/src/ui/Controls/SyntaxHighlightingTextPresenter.cs @@ -202,13 +202,7 @@ protected override TextLayout CreateTextLayout() return CreateTextLayoutInternal(this, constraint, new string(PasswordChar, text?.Length ?? 0), typeface, textStyleOverrides); } - var layout = CreateTextLayoutInternal(this, constraint, text, typeface, textStyleOverrides); - - // The syntax spans split Latin tags into several runs; inside a right-to-left line - // Avalonia then maps runs to the wrong visual positions and hit testing throws (#15531). - BidiRunIndexFixer.Fix(layout); - - return layout; + return CreateTextLayoutInternal(this, constraint, text, typeface, textStyleOverrides); } /// diff --git a/src/ui/Controls/VideoPlayer/VideoPlayerControl.cs b/src/ui/Controls/VideoPlayer/VideoPlayerControl.cs index ff0f57976ff..bbd53cbfddc 100644 --- a/src/ui/Controls/VideoPlayer/VideoPlayerControl.cs +++ b/src/ui/Controls/VideoPlayer/VideoPlayerControl.cs @@ -193,6 +193,13 @@ public bool IsFullScreen private int _slowPollCounter; private IVideoPlayer _videoPlayerInstance; private string _videoFileName; + + // Duration ffmpeg reported for the open file, 0 when unknown. MPEG transport/program + // streams store no duration, so mpv estimates one and re-estimates it after seeks - + // a 1.5 h TV recording could jump to 8 h mid-scrub and squash the slider (#15740). + // When set, it replaces the player's value for the slider range and total time. + private double _probedDuration; + private int _durationProbeGeneration; private readonly Grid _gridProgress; // Reference to the controls grid private DispatcherTimer? _autoHideTimer; private DateTime _lastActivityTime; @@ -952,6 +959,7 @@ internal async Task Open(string videoFileName, double startPositionSeconds = 0) // the new Maximum — firing ValueChanged and seeking mpv to EOF. SetPositionDisplayOnly(0); Duration = 0; + StartDurationProbe(videoFileName); await _videoPlayerInstance.LoadFile(videoFileName, startPositionSeconds); @@ -1179,6 +1187,38 @@ internal void Close() _textBlockVideoFileName.Text = string.Empty; SetPositionDisplayOnly(0); Duration = 0; + StartDurationProbe(null); + } + + private static readonly HashSet DurationProbeExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".ts", ".m2ts", ".mts", ".m2t", ".tp", ".trp", ".mpg", ".mpeg", ".vob", + }; + + private void StartDurationProbe(string? videoFileName) + { + _probedDuration = 0; + var generation = ++_durationProbeGeneration; + if (string.IsNullOrEmpty(videoFileName) || + !DurationProbeExtensions.Contains(System.IO.Path.GetExtension(videoFileName))) + { + return; + } + + _ = Task.Run(() => + { + var seconds = Logic.Media.FfmpegMediaInfo2.TryGetDurationSeconds(videoFileName); + if (seconds is > 0) + { + Dispatcher.UIThread.Post(() => + { + if (generation == _durationProbeGeneration && !IsDisposed) + { + _probedDuration = seconds.Value; + } + }); + } + }); } /// @@ -1368,7 +1408,8 @@ private void StartPositionTimer() if (_slowPollCounter >= 5) { _slowPollCounter = 0; - Duration = _videoPlayerInstance.Duration; + var playerDuration = _videoPlayerInstance.Duration; + Duration = _probedDuration > 0 && playerDuration > 0 ? _probedDuration : playerDuration; SetPlayPauseIcon(_videoPlayerInstance.IsPlaying); // The ffmpeg player only knows its decoder (hardware vs. software) once the diff --git a/src/ui/DependencyInjectionExtensions.cs b/src/ui/DependencyInjectionExtensions.cs index e0de67330c5..90c61a90043 100644 --- a/src/ui/DependencyInjectionExtensions.cs +++ b/src/ui/DependencyInjectionExtensions.cs @@ -121,6 +121,7 @@ using Nikse.SubtitleEdit.Features.SpellCheck.FindDoubleLines; using Nikse.SubtitleEdit.Features.SpellCheck.FindDoubleWords; using Nikse.SubtitleEdit.Features.SpellCheck.GetDictionaries; +using Nikse.SubtitleEdit.Features.SpellCheck.UseAlwaysList; using Nikse.SubtitleEdit.Features.Sync.AdjustAllTimes; using Nikse.SubtitleEdit.Features.Sync.ChangeFrameRate; using Nikse.SubtitleEdit.Features.Sync.ChangeSpeed; @@ -326,6 +327,7 @@ public static void AddSubtitleEditServices(this IServiceCollection collection) collection.AddHttpClientWithProxy(); collection.AddHttpClientWithProxy(); collection.AddHttpClientWithProxy(); + collection.AddHttpClientWithProxy(); collection.AddHttpClientWithProxy(); collection.AddHttpClientWithProxy(); collection.AddHttpClientWithProxy(); @@ -447,6 +449,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/Assa/AssaAttachmentsViewModel.cs b/src/ui/Features/Assa/AssaAttachmentsViewModel.cs index aa90da22f8a..91f46f398e2 100644 --- a/src/ui/Features/Assa/AssaAttachmentsViewModel.cs +++ b/src/ui/Features/Assa/AssaAttachmentsViewModel.cs @@ -197,7 +197,7 @@ private async Task FileExport() } catch (Exception exception) { - await MessageBox.Show(Window, exception.Message, Se.Language.General.Error, MessageBoxButtons.OK, MessageBoxIcon.Error); + await MessageBox.Show(Window, Se.Language.General.Error, exception.Message, MessageBoxButtons.OK, MessageBoxIcon.Error); } } @@ -491,6 +491,16 @@ private void UpdatePreview() private void ShowImage(byte[] bytes) { using var skBitmap = SKBitmap.Decode(bytes); + if (skBitmap == null) + { + // Corrupt payload or a format Skia cannot decode. + PreviewTitle = $"{Se.Language.General.Image}: {SelectedAttachment?.FileName ?? "untitled"}"; + PreviewImage?.Dispose(); + PreviewImage = new SKBitmap(1, 1, true).ToAvaloniaBitmap(); + IsCopyFontnameToClipboardVisible = false; + return; + } + PreviewTitle = $"{Se.Language.General.Image}: {SelectedAttachment?.FileName ?? "untitled"}, {skBitmap.Width}x{skBitmap.Height}"; PreviewImage?.Dispose(); PreviewImage = skBitmap.ToAvaloniaBitmap(); diff --git a/src/ui/Features/Assa/AssaDraw/AssaDrawCanvas.cs b/src/ui/Features/Assa/AssaDraw/AssaDrawCanvas.cs index bbe66234e22..2530ab7e724 100644 --- a/src/ui/Features/Assa/AssaDraw/AssaDrawCanvas.cs +++ b/src/ui/Features/Assa/AssaDraw/AssaDrawCanvas.cs @@ -19,9 +19,36 @@ public class AssaDrawCanvas : Control private float _panY; private Point? _lastMousePosition; private bool _isPanning; - - private static readonly ImmutableSolidColorBrush CheckerBrush1 = new(Color.FromRgb(60, 60, 60)); - private static readonly ImmutableSolidColorBrush CheckerBrush2 = new(Color.FromRgb(80, 80, 80)); + private List? _dragShapes; + private DrawShape? _pressedShape; + private bool _dragShapeMoved; + private bool _pointDragStarted; + + // Scale/rotate handles: 0-7 scale (TL, T, TR, R, BR, B, BL, L), 8 rotate + private const int RotateHandle = 8; + private const double HandlePadding = 8; + private const double RotateHandleDistance = 26; + private int _transformHandle = -1; + private List? _transformShapes; + private List<(float X, float Y)[]>? _transformOriginal; + private (float Left, float Top, float Right, float Bottom) _transformBounds; + private Point _transformStart; + private bool _transformStarted; + private string? _transformLabel; + + private static readonly Cursor[] HandleCursors = + [ + new(StandardCursorType.TopLeftCorner), new(StandardCursorType.TopSide), new(StandardCursorType.TopRightCorner), + new(StandardCursorType.RightSide), new(StandardCursorType.BottomRightCorner), new(StandardCursorType.BottomSide), + new(StandardCursorType.BottomLeftCorner), new(StandardCursorType.LeftSide), new(StandardCursorType.Hand), + ]; + private static readonly Cursor MoveCursor = new(StandardCursorType.SizeAll); + + private static readonly ImmutableSolidColorBrush WorkspaceBrush = new(Color.FromRgb(22, 23, 27)); + private static readonly ImmutableSolidColorBrush CheckerBrush = new(Color.FromArgb(10, 255, 255, 255)); + private static readonly Color AccentColor = Color.FromRgb(76, 110, 245); + private static readonly Color HandleFillColor = Colors.White; + private static readonly Color GuideColor = Color.FromArgb(150, 160, 170, 190); private readonly Dictionary _brushCache = new(); private readonly Dictionary<(Color Color, double Thickness, PenLineCap LineCap, PenLineJoin LineJoin, bool Dashed), IPen> _penCache = new(); @@ -78,6 +105,21 @@ private IPen GetPen(Color color, double thickness, PenLineCap lineCap = PenLineC public static readonly StyledProperty CurrentYProperty = AvaloniaProperty.Register(nameof(CurrentY), float.MinValue); + public static readonly StyledProperty ShowPreviewProperty = + AvaloniaProperty.Register(nameof(ShowPreview)); + + /// + /// The drawing as libass renders it, at canvas (PlayRes) size with a transparent background. + /// + public static readonly StyledProperty PreviewImageProperty = + AvaloniaProperty.Register(nameof(PreviewImage)); + + /// + /// Video frame shown behind the preview. + /// + public static readonly StyledProperty BackgroundImageProperty = + AvaloniaProperty.Register(nameof(BackgroundImage)); + public static readonly StyledProperty CurrentToolProperty = AvaloniaProperty.Register(nameof(CurrentTool), DrawingTool.Line); @@ -135,6 +177,45 @@ public float CurrentY set => SetValue(CurrentYProperty, value); } + public bool ShowPreview + { + get => GetValue(ShowPreviewProperty); + set => SetValue(ShowPreviewProperty, value); + } + + public IImage? PreviewImage + { + get => GetValue(PreviewImageProperty); + set => SetValue(PreviewImageProperty, value); + } + + public IImage? BackgroundImage + { + get => GetValue(BackgroundImageProperty); + set => SetValue(BackgroundImageProperty, value); + } + + public static readonly StyledProperty BackgroundOpacityProperty = + AvaloniaProperty.Register(nameof(BackgroundOpacity), 1.0); + + public static readonly StyledProperty BackgroundStretchProperty = + AvaloniaProperty.Register(nameof(BackgroundStretch)); + + public double BackgroundOpacity + { + get => GetValue(BackgroundOpacityProperty); + set => SetValue(BackgroundOpacityProperty, value); + } + + /// + /// Stretch the background to the canvas; otherwise it keeps its aspect ratio, centered. + /// + public bool BackgroundStretch + { + get => GetValue(BackgroundStretchProperty); + set => SetValue(BackgroundStretchProperty, value); + } + public DrawingTool CurrentTool { get => GetValue(CurrentToolProperty); @@ -154,25 +235,88 @@ public float ZoomFactor public event EventHandler? CanvasClicked; public event EventHandler? CanvasMouseMoved; + public event EventHandler? PointSelected; public event EventHandler? PointDragged; public event EventHandler? ZoomChanged; + /// + /// Select tool click: the shape under the pointer, or null for empty canvas. + /// + public event EventHandler? ShapeClicked; + + /// + /// A shape was dragged to a new position with the select tool. + /// + public event EventHandler? ShapeMoved; + + /// + /// Right-click: what is under the pointer, so a context menu can be shown for it. + /// + public event EventHandler? ContextMenuRequested; + + /// + /// A point or shape drag is about to change the drawing (raised once per drag, for undo). + /// + public event EventHandler? EditStarting; + + static AssaDrawCanvas() + { + AffectsRender( + ShapesProperty, + ActiveShapeProperty, + SelectedShapeProperty, + SelectedShapesProperty, + ActivePointProperty, + CanvasWidthProperty, + CanvasHeightProperty, + ShowPreviewProperty, + PreviewImageProperty, + BackgroundImageProperty, + BackgroundOpacityProperty, + BackgroundStretchProperty); + } + public AssaDrawCanvas() { ClipToBounds = true; Focusable = true; } - public void ResetView() + /// + /// Zooms so the whole frame fits the visible area and centers it. + /// + public void FitToView(double padding = 20) { _isPanning = false; - _panX = 0; - _panY = 0; - ZoomFactor = 1.0f; + var availableWidth = Bounds.Width - padding * 2; + var availableHeight = Bounds.Height - padding * 2; + if (availableWidth < 1 || availableHeight < 1 || CanvasWidth < 1 || CanvasHeight < 1) + { + return; + } + + var zoom = Math.Clamp((float)Math.Min(availableWidth / CanvasWidth, availableHeight / CanvasHeight), 0.1f, 10f); + _panX = (float)(Bounds.Width - CanvasWidth * zoom) / 2f; + _panY = (float)(Bounds.Height - CanvasHeight * zoom) / 2f; + ZoomFactor = zoom; } - public void ZoomIn() => ZoomFactor += 0.02f; - public void ZoomOut() => ZoomFactor -= 0.02f; + // Same step as Ctrl+mouse wheel - the buttons used 2%, so a click barely changed anything. + public void ZoomIn() => ZoomAt(_zoomFactor * 1.2f, Bounds.Center); + public void ZoomOut() => ZoomAt(_zoomFactor / 1.2f, Bounds.Center); + + /// + /// Zooms keeping the canvas point under (control coordinates) in place. + /// + public void ZoomAt(float zoom, Point anchor) + { + var x = FromZoomFactorX((float)anchor.X); + var y = FromZoomFactorY((float)anchor.Y); + var newZoom = Math.Clamp(zoom, 0.1f, 10f); + _panX = (float)anchor.X - x * newZoom; + _panY = (float)anchor.Y - y * newZoom; + ZoomFactor = newZoom; + } private float ToZoomFactorX(float v) => v * _zoomFactor + _panX; private float ToZoomFactorY(float v) => v * _zoomFactor + _panY; @@ -187,12 +331,29 @@ public override void Render(DrawingContext context) base.Render(context); var bounds = Bounds; + context.FillRectangle(WorkspaceBrush, new Rect(bounds.Size)); - // Draw checkered background for transparency indication - DrawCheckerBackground(context, bounds); + var canvasRect = new Rect(_panX, _panY, CanvasWidth * _zoomFactor, CanvasHeight * _zoomFactor); + DrawFrameShadow(context, canvasRect); - // Draw the actual canvas area + // The frame itself: checkered so it reads as transparent, with the video frame or image on top DrawCanvasArea(context); + DrawCheckerBackground(context, canvasRect); + if (BackgroundImage is { } background && background.Size.Width > 0 && background.Size.Height > 0) + { + var target = canvasRect; + if (!BackgroundStretch) + { + var scale = Math.Min(canvasRect.Width / background.Size.Width, canvasRect.Height / background.Size.Height); + var size = new Size(background.Size.Width * scale, background.Size.Height * scale); + target = new Rect(canvasRect.Center.X - size.Width / 2, canvasRect.Center.Y - size.Height / 2, size.Width, size.Height); + } + + using (context.PushOpacity(Math.Clamp(BackgroundOpacity, 0.05, 1))) + { + context.DrawImage(background, target); + } + } // Draw grid if enabled if (DrawSettings.ShowGrid) @@ -200,46 +361,92 @@ public override void Render(DrawingContext context) DrawGrid(context); } + // Rendered result under the outlines, so points stay editable while previewing + if (ShowPreview && PreviewImage != null) + { + context.DrawImage(PreviewImage, canvasRect); + } + // Draw resolution border DrawResolutionBorder(context); + // Shapes are filled with their layer color, unless the libass render already shows them + var fillShapes = !ShowPreview || PreviewImage == null; + // Draw all shapes var shapes = Shapes; var selectedShapes = SelectedShapes; var selectedSet = selectedShapes.Count > 4 ? new HashSet(selectedShapes) : null; var activeShape = ActiveShape; var selectedShape = SelectedShape; - var activeShapeInList = false; + var activeShapeInList = activeShape != null && shapes.Contains(activeShape); + if (fillShapes) + { + DrawLayerFills(context, shapes); + } + for (var idx = 0; idx < shapes.Count; idx++) { var shape = shapes[idx]; - if (shape == activeShape) + if (shape.Hidden) { - activeShapeInList = true; + continue; } + var isSelected = selectedSet != null ? selectedSet.Contains(shape) : selectedShapes.Contains(shape); + var isActive = shape == activeShape || shape == selectedShape || isSelected; + DrawShape(context, shape, isActive, isSelected, isInShapes: true, fill: false); + } + + // Handles on top of every shape, so a later shape can't cover the selected one's points. + // A multi-selection (Ctrl+A, SVG import) gets one box around all of it and no point handles. + var isMultiSelection = selectedShapes.Count > 1; + for (var idx = 0; idx < shapes.Count; idx++) + { + var shape = shapes[idx]; if (shape.Hidden) { continue; } - var isSelected = selectedSet != null ? selectedSet.Contains(shape) : selectedShapes.Contains(shape); - var isActive = shape == activeShape || shape == selectedShape || isSelected; - DrawShape(context, shape, isActive, isSelected, isInShapes: true); - DrawShapePoints(context, shape); + var isActive = shape == activeShape || shape == selectedShape || + (!isMultiSelection && (selectedSet != null ? selectedSet.Contains(shape) : selectedShapes.Contains(shape))); + if (isActive) + { + DrawSelectionBox(context, shape.GetBounds(), withHandles: CurrentTool == DrawingTool.Select && !isMultiSelection); + } + + DrawShapePoints(context, shape, isActive); + } + + if (isMultiSelection) + { + var visible = selectedShapes.Where(s => !s.Hidden && s.Points.Count > 0).ToList(); + if (visible.Count > 0) + { + var selectionBounds = visible[0].GetBounds(); + foreach (var shape in visible.Skip(1)) + { + var b = shape.GetBounds(); + selectionBounds = (Math.Min(selectionBounds.Left, b.Left), Math.Min(selectionBounds.Top, b.Top), + Math.Max(selectionBounds.Right, b.Right), Math.Max(selectionBounds.Bottom, b.Bottom)); + } + + DrawSelectionBox(context, selectionBounds, withHandles: CurrentTool == DrawingTool.Select); + } } // Draw active shape being created (not yet in Shapes list) if (ActiveShape != null && !activeShapeInList) { - DrawShape(context, ActiveShape, true, false, isInShapes: false); - DrawShapePoints(context, ActiveShape); + DrawShape(context, ActiveShape, true, false, isInShapes: false, fill: false); + DrawShapePoints(context, ActiveShape, true); // Draw preview to current mouse position if (CurrentX > float.MinValue && CurrentY > float.MinValue && ActiveShape.Points.Count > 0) { var lastPoint = ActiveShape.Points[^1]; - var pen = GetPen(DrawSettings.ActiveShapeLineColor, 2, lineCap: PenLineCap.Round); + var pen = GetPen(AccentColor, 1.5, lineCap: PenLineCap.Round, dashed: true); if (CurrentTool == DrawingTool.Circle && ActiveShape.Points.Count == 1) { @@ -280,26 +487,43 @@ public override void Render(DrawingContext context) // Highlight active point if (ActivePoint != null) { - var pointColor = new Color(255, ActivePoint.PointColor.R, ActivePoint.PointColor.G, ActivePoint.PointColor.B); - var pen = GetPen(pointColor, 3); - var x = ToZoomFactorX(ActivePoint.X); - var y = ToZoomFactorY(ActivePoint.Y); - context.DrawLine(pen, new Point(x - 8, y), new Point(x + 8, y)); - context.DrawLine(pen, new Point(x, y - 8), new Point(x, y + 8)); + var center = ToZoomFactorPoint(ActivePoint); + context.DrawEllipse(GetBrush(AccentColor), GetPen(HandleFillColor, 2), center, 6, 6); + context.DrawEllipse(null, GetPen(Color.FromArgb(110, AccentColor.R, AccentColor.G, AccentColor.B), 2), center, 10, 10); } } - private void DrawCheckerBackground(DrawingContext context, Rect bounds) + private static void DrawFrameShadow(DrawingContext context, Rect canvasRect) { - const int size = 10; + for (var i = 1; i <= 6; i++) + { + var shadow = new ImmutableSolidColorBrush(Color.FromArgb((byte)(28 - i * 4), 0, 0, 0)); + context.FillRectangle(shadow, canvasRect.Inflate(i * 2).Translate(new Vector(0, i))); + } + } - for (var y = 0; y < bounds.Height; y += size) + private void DrawCheckerBackground(DrawingContext context, Rect canvasRect) + { + const double size = 16; + var visible = canvasRect.Intersect(new Rect(Bounds.Size)); + if (visible.Width <= 0 || visible.Height <= 0) { - for (var x = 0; x < bounds.Width; x += size) + return; + } + + using (context.PushClip(visible)) + { + var startColumn = (int)Math.Floor((visible.Left - canvasRect.Left) / size); + var startRow = (int)Math.Floor((visible.Top - canvasRect.Top) / size); + for (var row = startRow; canvasRect.Top + row * size < visible.Bottom; row++) { - var isEven = ((x / size) + (y / size)) % 2 == 0; - var brush = isEven ? CheckerBrush1 : CheckerBrush2; - context.FillRectangle(brush, new Rect(x, y, size, size)); + for (var column = startColumn; canvasRect.Left + column * size < visible.Right; column++) + { + if ((row + column) % 2 == 0) + { + context.FillRectangle(CheckerBrush, new Rect(canvasRect.Left + column * size, canvasRect.Top + row * size, size, size)); + } + } } } } @@ -313,8 +537,13 @@ private void DrawCanvasArea(DrawingContext context) private void DrawGrid(DrawingContext context) { - var pen = GetPen(DrawSettings.GridColor, 0.5); + var pen = GetPen(DrawSettings.GridColor, 1); var gridSize = DrawSettings.GridSize * _zoomFactor; + while (gridSize < 8) + { + // Zoomed far out the lines would merge into a solid sheet - draw every other one + gridSize *= 2; + } for (float x = _panX; x < _panX + CanvasWidth * _zoomFactor; x += gridSize) { @@ -329,108 +558,362 @@ private void DrawGrid(DrawingContext context) private void DrawResolutionBorder(DrawingContext context) { - var pen = GetPen(DrawSettings.ScreenSizeColor, 2); - var rect = new Rect(_panX - 1, _panY - 1, CanvasWidth * _zoomFactor + 2, CanvasHeight * _zoomFactor + 2); + var pen = GetPen(DrawSettings.ScreenSizeColor, 1); + var rect = new Rect(_panX - 0.5, _panY - 0.5, CanvasWidth * _zoomFactor + 1, CanvasHeight * _zoomFactor + 1); context.DrawRectangle(null, pen, rect); } - private void DrawShape(DrawingContext context, DrawShape shape, bool isActive, bool isSelected, bool isInShapes) + private void DrawShape(DrawingContext context, DrawShape shape, bool isActive, bool isSelected, bool isInShapes, bool fill) { if (shape.Points.Count == 0) { return; } - // Use different colors for eraser shapes to visually distinguish them - var color = isSelected ? Colors.Yellow : (isActive ? DrawSettings.ActiveShapeLineColor : DrawSettings.ShapeLineColor); + var isClosed = shape.Points.Count > 2 && isInShapes; + var geometry = BuildGeometry(shape, isClosed); + + IBrush? fillBrush = null; + if (fill && isClosed && !shape.IsEraser) + { + fillBrush = GetBrush(Color.FromArgb(215, shape.ForeColor.R, shape.ForeColor.G, shape.ForeColor.B)); + } + + // Eraser shapes (iclip masks) are dashed and orange so they read as "cut out", not drawn + Color color; if (shape.IsEraser && !isSelected) { - // Draw eraser shapes in red/orange to indicate they are iclip masks color = isActive ? Colors.OrangeRed : Colors.DarkOrange; } + else + { + color = isActive || isSelected ? AccentColor : DrawSettings.ShapeLineColor; + } - // Use dashed lines for eraser shapes - var pen = GetPen(color, 2, lineCap: PenLineCap.Round, dashed: shape.IsEraser); + var pen = GetPen(color, isActive || isSelected ? 2 : 1.5, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round, dashed: shape.IsEraser); + context.DrawGeometry(fillBrush, pen, geometry); - var i = 0; - while (i < shape.Points.Count) + // Bezier control point guides + if (isActive) { - var point = shape.Points[i]; - - if (point.DrawType == DrawCoordinateType.Line) + var guidePen = GetPen(GuideColor, 1); + for (var i = 1; i + 2 < shape.Points.Count; i++) { - if (i > 0) + if (shape.Points[i].DrawType == DrawCoordinateType.BezierCurveSupport1) { - var prev = shape.Points[i - 1]; - context.DrawLine(pen, ToZoomFactorPoint(prev), ToZoomFactorPoint(point)); + context.DrawLine(guidePen, ToZoomFactorPoint(shape.Points[i - 1]), ToZoomFactorPoint(shape.Points[i])); + context.DrawLine(guidePen, ToZoomFactorPoint(shape.Points[i + 2]), ToZoomFactorPoint(shape.Points[i + 1])); + i += 2; } + } + } + } - i++; + /// + /// Fills each layer as one geometry, like libass renders a layer's line: the shapes' contours + /// combine with non-zero winding (so holes of imported SVGs stay open) and the layer's eraser + /// shapes (\iclip) are cut out of it. + /// + private void DrawLayerFills(DrawingContext context, List shapes) + { + foreach (var layer in shapes.Where(s => !s.Hidden && s.Points.Count > 2).GroupBy(s => s.Layer).OrderBy(g => g.Key)) + { + var drawShapes = layer.Where(s => !s.IsEraser).ToList(); + if (drawShapes.Count == 0) + { + continue; } - else if (point.IsBeizer) + + Geometry geometry = BuildLayerGeometry(drawShapes); + var eraserShapes = layer.Where(s => s.IsEraser).ToList(); + if (eraserShapes.Count > 0) { - if (i > 0 && shape.Points.Count - i >= 3) - { - // Draw bezier curve - var startPoint = shape.Points[i - 1]; - var control1 = shape.Points[i]; - var control2 = shape.Points[i + 1]; - var endPoint = shape.Points[i + 2]; - - var geometry = new StreamGeometry(); - using (var ctx = geometry.Open()) - { - ctx.BeginFigure(ToZoomFactorPoint(startPoint), false); - ctx.CubicBezierTo( - ToZoomFactorPoint(control1), - ToZoomFactorPoint(control2), - ToZoomFactorPoint(endPoint)); - } + geometry = new CombinedGeometry(GeometryCombineMode.Exclude, geometry, BuildLayerGeometry(eraserShapes)); + } - context.DrawGeometry(null, pen, geometry); + var color = drawShapes[0].ForeColor; + var alpha = (byte)Math.Round(color.A * 215 / 255.0); + context.DrawGeometry(GetBrush(Color.FromArgb(alpha, color.R, color.G, color.B)), null, geometry); + } + } - // Draw control point lines (guides) - if (isActive) - { - var guidePen = GetPen(Colors.Gray, 1, lineJoin: PenLineJoin.Round, dashed: true); - context.DrawLine(guidePen, ToZoomFactorPoint(startPoint), ToZoomFactorPoint(control1)); - context.DrawLine(guidePen, ToZoomFactorPoint(endPoint), ToZoomFactorPoint(control2)); - } + private StreamGeometry BuildLayerGeometry(List shapes) + { + var geometry = new StreamGeometry(); + using var ctx = geometry.Open(); + ctx.SetFillRule(FillRule.NonZero); + foreach (var shape in shapes) + { + AddFigure(ctx, shape, true); + } - i += 3; - } - else - { - i++; - } + return geometry; + } + + private StreamGeometry BuildGeometry(DrawShape shape, bool isClosed) + { + var geometry = new StreamGeometry(); + using var ctx = geometry.Open(); + ctx.SetFillRule(FillRule.NonZero); + AddFigure(ctx, shape, isClosed); + return geometry; + } + + private void AddFigure(StreamGeometryContext ctx, DrawShape shape, bool isClosed) + { + ctx.BeginFigure(ToZoomFactorPoint(shape.Points[0]), isClosed); + var i = 1; + while (i < shape.Points.Count) + { + var point = shape.Points[i]; + if (point.DrawType == DrawCoordinateType.BezierCurveSupport1 && i + 2 < shape.Points.Count) + { + ctx.CubicBezierTo( + ToZoomFactorPoint(point), + ToZoomFactorPoint(shape.Points[i + 1]), + ToZoomFactorPoint(shape.Points[i + 2])); + i += 3; } else { + ctx.LineTo(ToZoomFactorPoint(point)); i++; } } - // Close the shape by drawing line from last to first point - if (shape.Points.Count > 2 && isInShapes) + ctx.EndFigure(isClosed); + } + + private void DrawSelectionBox(DrawingContext context, (float Left, float Top, float Right, float Bottom) bounds, bool withHandles) + { + var (left, top, right, bottom) = bounds; + if (right - left < 0.01f && bottom - top < 0.01f) + { + return; + } + + var rect = GetSelectionRect(bounds); + context.DrawRectangle(null, GetPen(AccentColor, 1, dashed: true), rect); + if (!withHandles) + { + return; + } + + // Scale/rotate handles only where they work: the select tool + var handles = GetHandlePositions(rect); + var handlePen = GetPen(AccentColor, 1.5); + var handleBrush = GetBrush(HandleFillColor); + context.DrawLine(GetPen(AccentColor, 1), new Point(rect.Center.X, rect.Top), handles[RotateHandle]); + for (var i = 0; i < RotateHandle; i++) { - var first = shape.Points[0]; - var last = shape.Points[^1]; - context.DrawLine(pen, ToZoomFactorPoint(last), ToZoomFactorPoint(first)); + context.DrawRectangle(handleBrush, handlePen, new Rect(handles[i].X - 3.5, handles[i].Y - 3.5, 7, 7)); + } + + context.DrawEllipse(handleBrush, handlePen, handles[RotateHandle], 5, 5); + + if (_transformLabel != null && _transformStarted) + { + var text = new FormattedText(_transformLabel, System.Globalization.CultureInfo.InvariantCulture, FlowDirection.LeftToRight, Typeface.Default, 12, GetBrush(Colors.White)); + var origin = new Point(rect.Center.X - text.Width / 2, rect.Bottom + 10); + context.DrawRectangle(GetBrush(AccentColor), null, new Rect(origin.X - 6, origin.Y - 3, text.Width + 12, text.Height + 6), 4, 4); + context.DrawText(text, origin); } } - private void DrawShapePoints(DrawingContext context, DrawShape shape) + private Rect GetSelectionRect((float Left, float Top, float Right, float Bottom) bounds) + { + return new Rect( + new Point(ToZoomFactorX(bounds.Left), ToZoomFactorY(bounds.Top)), + new Point(ToZoomFactorX(bounds.Right), ToZoomFactorY(bounds.Bottom))).Inflate(HandlePadding); + } + + private static Point[] GetHandlePositions(Rect rect) + { + var center = rect.Center; + return + [ + rect.TopLeft, new Point(center.X, rect.Top), rect.TopRight, new Point(rect.Right, center.Y), + rect.BottomRight, new Point(center.X, rect.Bottom), rect.BottomLeft, new Point(rect.Left, center.Y), + new Point(center.X, rect.Top - RotateHandleDistance), + ]; + } + + /// + /// What the handles act on: a multi-selection, or the single selected shape. + /// + private List GetTransformTargets() + { + var selected = SelectedShapes; + if (selected.Count > 1) + { + return selected.Where(s => !s.Hidden && s.Points.Count > 0).ToList(); + } + + var shape = SelectedShape ?? ActiveShape; + return shape != null && !shape.Hidden && shape.Points.Count > 0 && Shapes.Contains(shape) ? [shape] : []; + } + + private static (float Left, float Top, float Right, float Bottom) GetUnionBounds(List shapes) + { + var bounds = shapes[0].GetBounds(); + foreach (var shape in shapes.Skip(1)) + { + var b = shape.GetBounds(); + bounds = (Math.Min(bounds.Left, b.Left), Math.Min(bounds.Top, b.Top), Math.Max(bounds.Right, b.Right), Math.Max(bounds.Bottom, b.Bottom)); + } + + return bounds; + } + + private int HitTestHandle(Point screenPoint, out List targets) + { + targets = CurrentTool == DrawingTool.Select ? GetTransformTargets() : []; + if (targets.Count == 0) + { + return -1; + } + + var bounds = GetUnionBounds(targets); + if (bounds.Right - bounds.Left < 0.01f && bounds.Bottom - bounds.Top < 0.01f) + { + return -1; + } + + var handles = GetHandlePositions(GetSelectionRect(bounds)); + for (var i = handles.Length - 1; i >= 0; i--) + { + if (Math.Abs(handles[i].X - screenPoint.X) <= 7 && Math.Abs(handles[i].Y - screenPoint.Y) <= 7) + { + return i; + } + } + + return -1; + } + + private void ApplyTransform(Point screenPoint, KeyModifiers modifiers) + { + var (left, top, right, bottom) = _transformBounds; + var centerX = (left + right) / 2f; + var centerY = (top + bottom) / 2f; + + Func map; + if (_transformHandle == RotateHandle) + { + var startAngle = Math.Atan2(FromZoomFactorY((float)_transformStart.Y) - centerY, FromZoomFactorX((float)_transformStart.X) - centerX); + var angle = Math.Atan2(FromZoomFactorY((float)screenPoint.Y) - centerY, FromZoomFactorX((float)screenPoint.X) - centerX) - startAngle; + var degrees = angle * 180 / Math.PI; + degrees = (degrees + 540) % 360 - 180; + if (modifiers.HasFlag(KeyModifiers.Shift)) + { + degrees = Math.Round(degrees / 15) * 15; + } + + var radians = degrees * Math.PI / 180; + var cos = (float)Math.Cos(radians); + var sin = (float)Math.Sin(radians); + map = (x, y) => (centerX + (x - centerX) * cos - (y - centerY) * sin, centerY + (x - centerX) * sin + (y - centerY) * cos); + _transformLabel = $"{degrees:0}°"; + } + else + { + // The dragged edge follows the pointer, the opposite edge stays put + var dx = (float)(screenPoint.X - _transformStart.X) / _zoomFactor; + var dy = (float)(screenPoint.Y - _transformStart.Y) / _zoomFactor; + var h = _transformHandle; + var movesLeft = h is 0 or 6 or 7; + var movesRight = h is 2 or 3 or 4; + var movesTop = h is 0 or 1 or 2; + var movesBottom = h is 4 or 5 or 6; + var width = right - left; + var height = bottom - top; + + var anchorX = movesLeft ? right : left; + var anchorY = movesTop ? bottom : top; + var scaleX = 1f; + var scaleY = 1f; + if (width > 0.01f && (movesLeft || movesRight)) + { + scaleX = movesLeft ? (width - dx) / width : (width + dx) / width; + } + + if (height > 0.01f && (movesTop || movesBottom)) + { + scaleY = movesTop ? (height - dy) / height : (height + dy) / height; + } + + var isCorner = h is 0 or 2 or 4 or 6; + if (isCorner && modifiers.HasFlag(KeyModifiers.Shift)) + { + var uniform = Math.Abs(scaleX) > Math.Abs(scaleY) ? scaleX : scaleY; + scaleX = Math.Sign(scaleX) * Math.Abs(uniform); + scaleY = Math.Sign(scaleY) * Math.Abs(uniform); + } + + var sx = scaleX; + var sy = scaleY; + map = (x, y) => (anchorX + (x - anchorX) * sx, anchorY + (y - anchorY) * sy); + _transformLabel = $"{Math.Abs(width * sx):0} × {Math.Abs(height * sy):0}"; + } + + for (var i = 0; i < _transformShapes!.Count; i++) + { + var points = _transformShapes[i].Points; + var original = _transformOriginal![i]; + for (var j = 0; j < points.Count && j < original.Length; j++) + { + var (x, y) = map(original[j].X, original[j].Y); + points[j].X = x; + points[j].Y = y; + } + } + } + + private void DrawShapePoints(DrawingContext context, DrawShape shape, bool isActive) { foreach (var point in shape.Points) { - var pen = GetPen(point.PointColor, 2); - var x = ToZoomFactorX(point.X); - var y = ToZoomFactorY(point.Y); + var center = ToZoomFactorPoint(point); + var isControlPoint = point.DrawType is DrawCoordinateType.BezierCurveSupport1 or DrawCoordinateType.BezierCurveSupport2; + + if (!isActive) + { + // Small dots keep every point grabbable without cluttering unselected shapes + if (!isControlPoint) + { + context.DrawEllipse(GetBrush(point.PointColor), null, center, 2.5, 2.5); + } + + continue; + } + + if (isControlPoint) + { + context.DrawEllipse(GetBrush(HandleFillColor), GetPen(point.PointColor, 1.5), center, 3.5, 3.5); + } + else + { + context.DrawRectangle(GetBrush(HandleFillColor), GetPen(AccentColor, 1.5), new Rect(center.X - 4, center.Y - 4, 8, 8)); + } + } + } - // Draw cross marker - context.DrawLine(pen, new Point(x - 5, y), new Point(x + 5, y)); - context.DrawLine(pen, new Point(x, y - 5), new Point(x, y + 5)); + /// + /// The topmost visible shape under (x, y) in canvas coordinates. + /// + public DrawShape? HitTestShape(float x, float y) + { + var shapes = Shapes; + for (var i = shapes.Count - 1; i >= 0; i--) + { + var shape = shapes[i]; + if (!shape.Hidden && shape.HitTest(x, y, 5f / _zoomFactor)) + { + return shape; + } } + + return null; } protected override void OnPointerPressed(PointerPressedEventArgs e) @@ -441,9 +924,37 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) var point = e.GetPosition(this); var x = FromZoomFactorX((float)point.X); var y = FromZoomFactorY((float)point.Y); + var properties = e.GetCurrentPoint(this).Properties; + + if (properties.IsRightButtonPressed) + { + var targetPoint = GetClosePoint(x, y); + var targetShape = targetPoint == null ? HitTestShape(x, y) : FindShape(targetPoint); + ContextMenuRequested?.Invoke(this, new CanvasContextEventArgs(point, x, y, targetPoint, targetShape)); + e.Handled = true; + return; + } + + // Scale/rotate handles of the selection (select tool) + if (properties.IsLeftButtonPressed) + { + var handle = HitTestHandle(point, out var targets); + if (handle >= 0) + { + _transformHandle = handle; + _transformShapes = targets; + _transformOriginal = targets.Select(s => s.Points.Select(p => (p.X, p.Y)).ToArray()).ToList(); + _transformBounds = GetUnionBounds(targets); + _transformStart = point; + _transformStarted = false; + _transformLabel = null; + e.Handled = true; + return; + } + } - // Check for shift+click to pan - if (e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + // Shift+drag or middle mouse button pans + if (e.KeyModifiers.HasFlag(KeyModifiers.Shift) || properties.IsMiddleButtonPressed) { _isPanning = true; _lastMousePosition = point; @@ -457,6 +968,32 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) { ActivePoint = closePoint; _lastMousePosition = point; + PointSelected?.Invoke(this, closePoint); + InvalidateVisual(); + e.Handled = true; + return; + } + + if (CurrentTool == DrawingTool.Select) + { + // Select tool: pick the shape under the pointer, and drag it to move it + var shape = HitTestShape(x, y); + if (shape != null) + { + // Dragging a shape of a multi-selection moves the whole selection + _dragShapes = SelectedShapes.Count > 1 && SelectedShapes.Contains(shape) ? SelectedShapes.ToList() : [shape]; + _pressedShape = shape; + _dragShapeMoved = false; + _lastMousePosition = point; + if (_dragShapes.Count > 1) + { + InvalidateVisual(); + e.Handled = true; + return; + } + } + + ShapeClicked?.Invoke(this, shape); InvalidateVisual(); e.Handled = true; return; @@ -484,9 +1021,60 @@ protected override void OnPointerMoved(PointerEventArgs e) return; } + if (_transformShapes != null && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + if (!_transformStarted) + { + if (Math.Abs(point.X - _transformStart.X) < 1 && Math.Abs(point.Y - _transformStart.Y) < 1) + { + return; + } + + _transformStarted = true; + EditStarting?.Invoke(this, EventArgs.Empty); + } + + ApplyTransform(point, e.KeyModifiers); + InvalidateVisual(); + CanvasMouseMoved?.Invoke(this, new CanvasMouseEventArgs(x, y)); + return; + } + + // Dragging a whole shape + if (_dragShapes != null && _lastMousePosition.HasValue && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + var dx = (float)(point.X - _lastMousePosition.Value.X) / _zoomFactor; + var dy = (float)(point.Y - _lastMousePosition.Value.Y) / _zoomFactor; + if (dx != 0 || dy != 0) + { + if (!_dragShapeMoved) + { + EditStarting?.Invoke(this, EventArgs.Empty); + } + + foreach (var shape in _dragShapes) + { + shape.Offset(dx, dy); + } + + _dragShapeMoved = true; + _lastMousePosition = point; + InvalidateVisual(); + } + + CanvasMouseMoved?.Invoke(this, new CanvasMouseEventArgs(x, y)); + return; + } + // Dragging a point if (ActivePoint != null && _lastMousePosition.HasValue && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { + if (!_pointDragStarted) + { + _pointDragStarted = true; + EditStarting?.Invoke(this, EventArgs.Empty); + } + ActivePoint.X = x; ActivePoint.Y = y; PointDragged?.Invoke(this, ActivePoint); @@ -494,14 +1082,73 @@ protected override void OnPointerMoved(PointerEventArgs e) return; } + UpdateHoverCursor(point, x, y); CanvasMouseMoved?.Invoke(this, new CanvasMouseEventArgs(x, y)); } + private void UpdateHoverCursor(Point point, float x, float y) + { + Cursor? cursor = null; + if (CurrentTool == DrawingTool.Select) + { + var handle = HitTestHandle(point, out _); + if (handle >= 0) + { + cursor = HandleCursors[handle]; + } + else if (GetClosePoint(x, y) == null && HitTestShape(x, y) != null) + { + cursor = MoveCursor; + } + } + + if (Cursor != cursor) + { + Cursor = cursor; + } + } + protected override void OnPointerReleased(PointerReleasedEventArgs e) { base.OnPointerReleased(e); _isPanning = false; _lastMousePosition = null; + _pointDragStarted = false; + + var dragShapes = _dragShapes; + var moved = _dragShapeMoved; + _dragShapes = null; + _dragShapeMoved = false; + if (dragShapes != null && moved) + { + ShapeMoved?.Invoke(this, dragShapes[0]); + } + else if (dragShapes is { Count: > 1 } && _pressedShape != null) + { + // A click (no drag) on a shape of a multi-selection selects just that shape + ShapeClicked?.Invoke(this, _pressedShape); + } + + _pressedShape = null; + + var transformShapes = _transformShapes; + var transformed = _transformStarted; + _transformShapes = null; + _transformOriginal = null; + _transformHandle = -1; + _transformStarted = false; + _transformLabel = null; + if (transformShapes != null && transformed && transformShapes.Count > 0) + { + ShapeMoved?.Invoke(this, transformShapes[0]); + } + + InvalidateVisual(); + } + + private DrawShape? FindShape(DrawCoordinate point) + { + return point.DrawShape ?? Shapes.FirstOrDefault(s => s.Points.Contains(point)); } protected override void OnPointerWheelChanged(PointerWheelEventArgs e) @@ -511,8 +1158,8 @@ protected override void OnPointerWheelChanged(PointerWheelEventArgs e) if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) { // Zoom with Ctrl+Scroll - var delta = e.Delta.Y > 0 ? 0.1f : -0.1f; - ZoomFactor += delta; + // Zoom around the mouse pointer + ZoomAt(e.Delta.Y > 0 ? _zoomFactor * 1.1f : _zoomFactor / 1.1f, e.GetPosition(this)); e.Handled = true; } } @@ -565,3 +1212,25 @@ public CanvasMouseEventArgs(float x, float y) Y = y; } } + +public class CanvasContextEventArgs : EventArgs +{ + /// + /// Pointer position in control coordinates (where the menu opens). + /// + public Point Position { get; } + + public float X { get; } + public float Y { get; } + public DrawCoordinate? Point { get; } + public DrawShape? Shape { get; } + + public CanvasContextEventArgs(Point position, float x, float y, DrawCoordinate? point, DrawShape? shape) + { + Position = position; + X = x; + Y = y; + Point = point; + Shape = shape; + } +} diff --git a/src/ui/Features/Assa/AssaDraw/AssaDrawViewModel.cs b/src/ui/Features/Assa/AssaDraw/AssaDrawViewModel.cs index 743bd3d55d3..08f88bb083f 100644 --- a/src/ui/Features/Assa/AssaDraw/AssaDrawViewModel.cs +++ b/src/ui/Features/Assa/AssaDraw/AssaDrawViewModel.cs @@ -1,14 +1,17 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Media; +using Avalonia.Media.Imaging; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Nikse.SubtitleEdit.Core.Common; using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Features.Assa.AssaSetPosition; using Nikse.SubtitleEdit.Features.Main; using Nikse.SubtitleEdit.Features.Shared; using Nikse.SubtitleEdit.Features.Shared.PickLayer; +using Nikse.SubtitleEdit.Features.Video.GoToVideoPosition; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; using Nikse.SubtitleEdit.Logic.Media; @@ -18,6 +21,7 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -52,10 +56,29 @@ public partial class AssaDrawViewModel : ObservableObject [ObservableProperty] private bool _isShapeSelected; [ObservableProperty] private bool _shapeIsEraser; [ObservableProperty] private Color _layerColor = Colors.White; - [ObservableProperty] private bool _showGrid = true; + [ObservableProperty] private bool _showGrid = DrawSettings.ShowGrid; + [ObservableProperty] private bool _showPreview; + [ObservableProperty] private string _previewStatusText = string.Empty; [ObservableProperty] private ObservableCollection _shapeTreeItems = []; [ObservableProperty] private ShapeTreeItem? _selectedTreeItem; [ObservableProperty] private List _selectedShapes = []; + [ObservableProperty] private float _shapeX; + [ObservableProperty] private float _shapeY; + [ObservableProperty] private float _shapeWidth; + [ObservableProperty] private float _shapeHeight; + [ObservableProperty] private int _shapeLayer; + [ObservableProperty] private string _selectionText = string.Empty; + [ObservableProperty] private string _codePreview = string.Empty; + [ObservableProperty] private double _zoomPercent = 100; + [ObservableProperty] private double _backgroundOpacity = Math.Clamp(Se.Settings.Assa.DrawBackgroundOpacity, 0.1, 1); + [ObservableProperty] private bool _backgroundStretch = Se.Settings.Assa.DrawBackgroundStretch; + [ObservableProperty] private bool _hasBackground; + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(UndoCommand))] + private bool _canUndo; + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(RedoCommand))] + private bool _canRedo; public Subtitle ResultSubtitle { get; set; } = new Subtitle(); @@ -63,14 +86,31 @@ public partial class AssaDrawViewModel : ObservableObject private float _currentY = float.MinValue; private readonly Regex _regexStart = new(@"\{[^{]*\\p1[^}]*\}"); private readonly Regex _regexEnd = new(@"\{[^{]*\\p0[^}]*\}"); - private readonly Regex _regexIclip = new(@"\{\\iclip\(([^)]+)\)\}"); + private readonly Regex _regexIclip = new(@"\\iclip\(([^)]+)\)"); private readonly IFileHelper _fileHelper; + private readonly IWindowService _windowService; private string _fileName = string.Empty; private Subtitle? _subtitle; - - public AssaDrawViewModel(IFileHelper fileHelper) + private string? _videoFileName; + private double _videoSeconds; + private bool _backgroundRequested; + private int _backgroundVersion; + private DispatcherTimer? _previewTimer; + private string _previewSource = string.Empty; + private bool _previewBusy; + private bool _refreshingTree; + private bool _updatingShapeFields; + + private const int MaxUndoSteps = 100; + private readonly List _undoStack = []; + private readonly List _redoStack = []; + private string? _lastUndoKey; + private DateTime _lastUndoTime; + + public AssaDrawViewModel(IFileHelper fileHelper, IWindowService windowService) { _fileHelper = fileHelper; + _windowService = windowService; } public void Initialize() @@ -79,6 +119,11 @@ public void Initialize() ZoomToFitCurrentVideoResolution(); RefreshTreeView(); Canvas?.InvalidateVisual(); + + if (DrawSettings.ShowPreview) + { + _ = TogglePreview(); + } } private void ZoomToFitCurrentVideoResolution() @@ -91,40 +136,31 @@ private void ZoomToFitCurrentVideoResolution() // Wait a bit to ensure the canvas bounds are updated Dispatcher.UIThread.Post(() => { - if (Canvas == null || Canvas.Bounds.Width < 1 || Canvas.Bounds.Height < 1) - { - return; - } - - // Calculate zoom factor to fit the entire canvas (video resolution) in the visible area - var availableWidth = Canvas.Bounds.Width; - var availableHeight = Canvas.Bounds.Height; - - // Add some padding (e.g., 20 pixels on each side) - var padding = 40.0; - availableWidth -= padding; - availableHeight -= padding; - - // Calculate zoom factors for width and height - var zoomX = (float)(availableWidth / CanvasWidth); - var zoomY = (float)(availableHeight / CanvasHeight); - - // Use the smaller zoom factor to ensure the entire canvas fits - var newZoomFactor = Math.Min(zoomX, zoomY); - - // Clamp the zoom factor to reasonable bounds - newZoomFactor = Math.Clamp(newZoomFactor, 0.1f, 10f); - - // Set the zoom factor - Canvas.ZoomFactor = newZoomFactor; + Canvas?.FitToView(); UpdateZoomText(); }, DispatcherPriority.Background); } - public void Initialize(Subtitle subtitle, List selectedLines, int? width, int? height) + public void Initialize(Subtitle subtitle, List selectedLines, int? width, int? height, + string? videoFileName = null, double? videoPositionSeconds = null) { _subtitle = subtitle; - if (width.HasValue && height.HasValue && width.Value >= 0 && height.Value >= 0) + _videoFileName = videoFileName; + + // Same frame Set position uses: the paused player position when it is inside the line + _videoSeconds = selectedLines.Count > 0 + ? AssaSetPositionViewModel.GetScreenshotSeconds(selectedLines[0], videoPositionSeconds) + : videoPositionSeconds ?? 0; + + // Drawing coordinates live in script (PlayRes) space, so the script resolution wins. The main + // window writes the video size into the header before opening, so without a video this is the + // only place the real resolution comes from - the 1920x1080 default put shapes in the wrong spot. + if (TryGetPlayRes(subtitle.Header, out var playResX, out var playResY)) + { + CanvasWidth = playResX; + CanvasHeight = playResY; + } + else if (width is > 0 && height is > 0) { CanvasWidth = width.Value; CanvasHeight = height.Value; @@ -146,8 +182,8 @@ public void Initialize(Subtitle subtitle, List selectedLi ImportAssaDrawingFromText(clipCommands, line.Layer, color, true); } - // 2. Process \p1 Drawing - if (line.Text.Contains("{\\p1}")) + // 2. Process \p1 Drawing (the \p1 can share its tag block with \pos, \an etc.) + if (_regexStart.IsMatch(line.Text)) { // Remove all tags (anything inside curly braces) to get raw vector data string drawingOnly = Regex.Replace(line.Text, @"\{[^}]+\}", string.Empty).Trim(); @@ -170,16 +206,239 @@ public void SetCanvas(AssaDrawCanvas canvas) Canvas.CanvasWidth = CanvasWidth; Canvas.CanvasHeight = CanvasHeight; Canvas.CurrentTool = CurrentTool; + Canvas.BackgroundOpacity = BackgroundOpacity; + Canvas.BackgroundStretch = BackgroundStretch; Canvas.CanvasClicked += OnCanvasClicked; Canvas.CanvasMouseMoved += OnCanvasMouseMoved; + Canvas.PointSelected += OnPointSelected; Canvas.PointDragged += OnPointDragged; Canvas.ZoomChanged += OnZoomChanged; + Canvas.ShapeClicked += OnShapeClicked; + Canvas.ShapeMoved += OnShapeMoved; + Canvas.EditStarting += (_, _) => SaveUndo(); + } + + private void OnShapeClicked(object? sender, DrawShape? shape) + { + if (shape == null) + { + ClearSelection(); + return; + } + + SelectShape(shape); + } + + private void OnShapeMoved(object? sender, DrawShape shape) + { + // Point labels in the tree changed - rebuild once at the end of the drag, not on every move + RefreshTreeView(); + } + + /// + /// Right-click on the canvas: select what is under the pointer so the context menu acts on it. + /// + public void PrepareContextMenu(CanvasContextEventArgs e) + { + if (e.Point != null) + { + SelectPoint(e.Point); + } + else if (e.Shape != null) + { + SelectShape(e.Shape); + } + } + + public bool IsDrawing => ActiveShape != null && !Shapes.Contains(ActiveShape); + + /// + /// The finished shape the shape commands act on: the one picked in the tree or on the canvas, + /// or the shape of the selected point. + /// + public DrawShape? TargetShape + { + get + { + if (SelectedTreeItem?.Shape != null) + { + return SelectedTreeItem.Shape; + } + + if (SelectedTreeItem?.Point != null) + { + var pointShape = SelectedTreeItem.Point.DrawShape ?? Shapes.FirstOrDefault(s => s.Points.Contains(SelectedTreeItem.Point)); + if (pointShape != null && Shapes.Contains(pointShape)) + { + return pointShape; + } + } + + return ActiveShape != null && Shapes.Contains(ActiveShape) ? ActiveShape : null; + } + } + + /// + /// Layers in use, for the "Move to layer" menu. + /// + public List UsedLayers => Shapes.Select(s => s.Layer).Distinct().OrderBy(l => l).ToList(); + + public void SelectShape(DrawShape shape) + { + var item = FindTreeItem(null, shape, null); + if (item != null) + { + SelectedTreeItem = item; + } + } + + public void SelectPoint(DrawCoordinate point) + { + var item = FindTreeItem(point, null, null); + if (item != null) + { + SelectedTreeItem = item; + } + } + + private void ClearSelection() + { + if (SelectedTreeItem != null) + { + SelectedTreeItem = null; + } + + if (ActiveShape != null && Shapes.Contains(ActiveShape)) + { + ActiveShape = null; + } + + ActivePoint = null; + IsPointSelected = false; + SelectedShapes = []; + if (Canvas != null) + { + Canvas.SelectedShape = null; + Canvas.InvalidateVisual(); + } } private void OnZoomChanged(object? sender, float zoomFactor) { ZoomText = $"Zoom: {zoomFactor * 100:0}%"; + if (Math.Abs(ZoomPercent - zoomFactor * 100) > 0.5) + { + ZoomPercent = Math.Round(zoomFactor * 100); + } + } + + partial void OnZoomPercentChanged(double value) + { + // Zoom slider: zoom around the middle of the view + if (Canvas != null && Math.Abs(Canvas.ZoomFactor * 100 - value) > 0.5) + { + Canvas.ZoomAt((float)(value / 100), Canvas.Bounds.Center); + } + } + + /// + /// Fills the shape fields (position, size, layer), the status text and the code preview from the selection. + /// + private void UpdateSelectionInfo() + { + var shape = TargetShape; + _updatingShapeFields = true; + try + { + if (shape != null && shape.Points.Count > 0) + { + var (left, top, right, bottom) = shape.GetBounds(); + ShapeX = MathF.Round(left, 1); + ShapeY = MathF.Round(top, 1); + ShapeWidth = MathF.Round(right - left, 1); + ShapeHeight = MathF.Round(bottom - top, 1); + ShapeLayer = shape.Layer; + } + } + finally + { + _updatingShapeFields = false; + } + + var item = SelectedTreeItem; + SelectionText = SelectedShapes.Count > 1 + ? $"{SelectedShapes.Count} shapes" + : item?.Point != null + ? item.Name.Trim() + : shape != null + ? $"{string.Format(Se.Language.Assa.DrawLayerX, shape.Layer)} · {shape.Points.Count} points" + : item?.IsLayer == true + ? item.Name + : string.Empty; + + var code = GenerateAssaCode(); + CodePreview = code.Length > 400 ? code[..400] + "..." : code; + } + + partial void OnShapeXChanged(float value) => MoveTargetShape(value - (TargetShape?.GetBounds().Left ?? value), 0, "shape-x"); + + partial void OnShapeYChanged(float value) => MoveTargetShape(0, value - (TargetShape?.GetBounds().Top ?? value), "shape-y"); + + private void MoveTargetShape(float dx, float dy, string undoKey) + { + var shape = TargetShape; + if (_updatingShapeFields || shape == null || (Math.Abs(dx) < 0.001f && Math.Abs(dy) < 0.001f)) + { + return; + } + + SaveUndo($"{undoKey}-{RuntimeHelpers.GetHashCode(shape)}"); + shape.Offset(dx, dy); + RefreshTreeView(); + Canvas?.InvalidateVisual(); + } + + partial void OnShapeWidthChanged(float value) => ResizeTargetShape(value, null); + + partial void OnShapeHeightChanged(float value) => ResizeTargetShape(null, value); + + /// + /// Size fields: scale the shape from its top-left corner. + /// + private void ResizeTargetShape(float? width, float? height) + { + var shape = TargetShape; + if (_updatingShapeFields || shape == null) + { + return; + } + + var (left, top, right, bottom) = shape.GetBounds(); + var scaleX = width.HasValue && right - left > 0.01f && width.Value > 0.01f ? width.Value / (right - left) : 1f; + var scaleY = height.HasValue && bottom - top > 0.01f && height.Value > 0.01f ? height.Value / (bottom - top) : 1f; + if (Math.Abs(scaleX - 1) < 0.0001f && Math.Abs(scaleY - 1) < 0.0001f) + { + return; + } + + SaveUndo($"shape-size-{RuntimeHelpers.GetHashCode(shape)}"); + foreach (var point in shape.Points) + { + point.X = left + (point.X - left) * scaleX; + point.Y = top + (point.Y - top) * scaleY; + } + + RefreshTreeView(); + Canvas?.InvalidateVisual(); + } + + partial void OnShapeLayerChanged(int value) + { + if (!_updatingShapeFields && TargetShape != null && TargetShape.Layer != value && value >= 0) + { + MoveShapeToLayer(value); + } } private void OnCanvasClicked(object? sender, CanvasClickEventArgs e) @@ -200,6 +459,7 @@ private void OnCanvasClicked(object? sender, CanvasClickEventArgs e) ActivePoint = null; IsPointSelected = false; + SelectedShapes = []; // Continue drawing on existing shape if (ActiveShape != null && ActiveShape.Points.Count > 0 && !Shapes.Contains(ActiveShape)) @@ -248,6 +508,7 @@ private void AddPointToActiveShape(float x, float y) var radius = Math.Max(Math.Abs(x - start.X), Math.Abs(y - start.Y)); if (radius > 1) { + SaveUndo(); ActiveShape = CircleBezier.MakeCircle(start.X, start.Y, radius, ActiveShape.Layer, ActiveShape.ForeColor); Shapes.Add(ActiveShape); RefreshTreeView(); @@ -270,6 +531,7 @@ private void AddPointToActiveShape(float x, float y) if (ActiveShape.Points.Count == 1) { var start = ActiveShape.Points[0]; + SaveUndo(); ActiveShape = MakeRectangle(start.X, start.Y, x - start.X, y - start.Y, ActiveShape.Layer, ActiveShape.ForeColor); Shapes.Add(ActiveShape); RefreshTreeView(); @@ -328,11 +590,22 @@ private void OnCanvasMouseMoved(object? sender, CanvasMouseEventArgs e) } } + private void OnPointSelected(object? sender, DrawCoordinate point) + { + ActivePoint = point; + PointX = point.X; + PointY = point.Y; + IsPointSelected = true; + } + private void OnPointDragged(object? sender, DrawCoordinate point) { PointX = point.X; PointY = point.Y; - RefreshTreeView(); + + // Only the dragged point's label changes - rebuilding the whole tree on every mouse move + // was slow on big drawings and dropped the tree selection. + UpdateSelectedPointName(); } [RelayCommand] @@ -362,7 +635,9 @@ private void SetTool(DrawingTool tool) [RelayCommand] private void CloseShape() { - if (ActiveShape == null) + // A shape picked in the tree is already finished - closing it again appended a closing + // bezier to it (Bezier tool) or added a stray circle/rectangle copy (Circle/Rectangle tool). + if (ActiveShape == null || Shapes.Contains(ActiveShape)) { return; } @@ -414,6 +689,7 @@ private void CloseShape() if (!Shapes.Contains(ActiveShape)) { + SaveUndo(); Shapes.Add(ActiveShape); } @@ -445,10 +721,32 @@ private static DrawShape MakeRectangle(float x, float y, float width, float heig [RelayCommand] private void DeleteShape() { - if (ActiveShape != null) + var shape = IsDrawing ? ActiveShape : TargetShape; + if (shape != null) { - Shapes.Remove(ActiveShape); + if (ActivePoint != null && shape.Points.Contains(ActivePoint)) + { + ActivePoint = null; + IsPointSelected = false; + } + + if (Shapes.Contains(shape)) + { + SaveUndo(); + } + + Shapes.Remove(shape); + SelectedShapes = SelectedShapes.Where(s => s != shape).ToList(); ActiveShape = null; + _currentX = float.MinValue; + _currentY = float.MinValue; + if (Canvas != null) + { + Canvas.SelectedShape = null; + Canvas.CurrentX = float.MinValue; + Canvas.CurrentY = float.MinValue; + } + RefreshTreeView(); Canvas?.InvalidateVisual(); } @@ -487,6 +785,7 @@ private async Task ChangeLayer() } var newLayer = vm.Layer; + SaveUndo(); // Get color from existing shapes in the new layer (if any) var existingShapeInNewLayer = Shapes.FirstOrDefault(s => s.Layer == newLayer); @@ -519,123 +818,708 @@ private async Task ChangeLayer() } [RelayCommand] - private void ClearAll() + private void DuplicateShape() { - Shapes.Clear(); - ActiveShape = null; - ActivePoint = null; - _currentX = float.MinValue; - _currentY = float.MinValue; - RefreshTreeView(); - - if (Canvas != null) + var shape = TargetShape; + if (shape == null) { - Canvas.Shapes = Shapes; - Canvas.ActiveShape = null; - Canvas.ActivePoint = null; - Canvas.InvalidateVisual(); + return; } + + SaveUndo(); + var copy = shape.Clone(); + copy.Hidden = false; + copy.Offset(DrawSettings.GridSize, DrawSettings.GridSize); + Shapes.Insert(Shapes.IndexOf(shape) + 1, copy); + RefreshTreeView(); + SelectShape(copy); + Canvas?.InvalidateVisual(); } [RelayCommand] - private void ZoomIn() + private void FlipShapeHorizontal() { - Canvas?.ZoomIn(); - UpdateZoomText(); + var shape = TargetShape; + if (shape == null) + { + return; + } + + SaveUndo(); + shape.FlipHorizontal(); + RefreshTreeView(); + Canvas?.InvalidateVisual(); } [RelayCommand] - private void ZoomOut() - { - Canvas?.ZoomOut(); - UpdateZoomText(); - } + private void RotateShapeClockwise() => RotateShape(90); [RelayCommand] - private void ResetView() - { - Canvas?.ResetView(); - UpdateZoomText(); - } + private void RotateShapeCounterClockwise() => RotateShape(-90); - private void UpdateZoomText() + private void RotateShape(float degrees) { - if (Canvas != null) + var shape = TargetShape; + if (shape == null) { - ZoomText = $"Zoom: {Canvas.ZoomFactor * 100:0}%"; + return; } + + SaveUndo(); + shape.Rotate(degrees); + RefreshTreeView(); + Canvas?.InvalidateVisual(); } [RelayCommand] - private void ToggleGrid() + private void FlipShapeVertical() { - ShowGrid = !ShowGrid; - DrawSettings.ShowGrid = ShowGrid; + var shape = TargetShape; + if (shape == null) + { + return; + } + + SaveUndo(); + shape.FlipVertical(); + RefreshTreeView(); Canvas?.InvalidateVisual(); } [RelayCommand] - private async Task Save() + private void ToggleShapeEraser() { - if (Shapes.Count == 0) + var shape = TargetShape; + if (shape == null) { return; } - if (Window == null) + SaveUndo(); + shape.IsEraser = !shape.IsEraser; + if (shape == ActiveShape) { - return; + ShapeIsEraser = shape.IsEraser; } - var fileName = await _fileHelper.PickSaveFile( - Window, - ".assadraw", - string.IsNullOrEmpty(_fileName) ? "untitled.assadraw" : Path.GetFileName(_fileName), - "Save ASSA drawing"); + RefreshTreeView(); + Canvas?.InvalidateVisual(); + } - if (string.IsNullOrEmpty(fileName)) + [RelayCommand] + private void ToggleShapeVisibility() + { + var shape = TargetShape; + if (shape == null) { return; } - _fileName = fileName; + SaveUndo(); + shape.Hidden = !shape.Hidden; + RefreshTreeView(); + Canvas?.InvalidateVisual(); + } - try + [RelayCommand] + private void ToggleLayerVisibility() + { + if (SelectedTreeItem?.IsLayer != true) { - var subtitle = GenerateSubtitle(); - var format = new AdvancedSubStationAlpha(); - var text = format.ToText(subtitle, string.Empty); - await File.WriteAllTextAsync(fileName, text); + return; } - catch (Exception ex) + + var layerShapes = Shapes.Where(s => s.Layer == SelectedTreeItem.Layer).ToList(); + var hide = layerShapes.Any(s => !s.Hidden); + SaveUndo(); + foreach (var shape in layerShapes) { - await MessageBox.Show(Window, Se.Language.General.Error, ex.Message, MessageBoxButtons.OK, MessageBoxIcon.Error); + shape.Hidden = hide; } + + RefreshTreeView(); + Canvas?.InvalidateVisual(); } [RelayCommand] - private async Task Load() + private void DeleteLayer() { - if (Window == null) + if (SelectedTreeItem?.IsLayer != true) { return; } - var fileName = await _fileHelper.PickOpenFile( - Window, - "Open ASSA drawing", - "ASSA drawing files", - "*.assadraw", - "ASS files", - "*.ass"); - - if (string.IsNullOrEmpty(fileName)) + var layer = SelectedTreeItem.Layer; + SaveUndo(); + Shapes.RemoveAll(s => s.Layer == layer); + if (ActiveShape != null && ActiveShape.Layer == layer && !IsDrawing) { - return; + ActiveShape = null; } - try - { + ActivePoint = null; + IsPointSelected = false; + SelectedShapes = SelectedShapes.Where(s => s.Layer != layer).ToList(); + RefreshTreeView(); + Canvas?.InvalidateVisual(); + } + + [RelayCommand] + private void MoveShapeToLayer(int layer) + { + var shape = TargetShape; + if (shape == null || shape.Layer == layer) + { + return; + } + + SaveUndo(); + + // Same rule as Change layer: a shape joining a layer takes that layer's color + var existing = Shapes.FirstOrDefault(s => s.Layer == layer); + shape.Layer = layer; + if (existing != null) + { + shape.ForeColor = existing.ForeColor; + } + + RefreshTreeView(); + Canvas?.InvalidateVisual(); + } + + [RelayCommand] + private void DeletePoint() + { + var point = ActivePoint; + var shape = point == null ? null : point.DrawShape ?? Shapes.FirstOrDefault(s => s.Points.Contains(point)); + if (point == null || shape == null || point.DrawType is DrawCoordinateType.BezierCurveSupport1 or DrawCoordinateType.BezierCurveSupport2) + { + return; + } + + SaveUndo(); + if (!shape.RemovePoint(point)) + { + return; + } + + ActivePoint = null; + IsPointSelected = false; + if (shape.Points.Count == 0) + { + Shapes.Remove(shape); + if (ActiveShape == shape) + { + ActiveShape = null; + } + } + + RefreshTreeView(); + if (Shapes.Contains(shape)) + { + SelectShape(shape); + } + + Canvas?.InvalidateVisual(); + } + + [RelayCommand] + private void ConvertPointToCurve() + { + var point = ActivePoint; + var shape = point == null ? null : point.DrawShape ?? Shapes.FirstOrDefault(s => s.Points.Contains(point)); + if (point == null || shape == null || !shape.IsLineSegment(point)) + { + return; + } + + SaveUndo(); + shape.ConvertSegmentToCurve(point); + RefreshTreeView(); + Canvas?.InvalidateVisual(); + } + + [RelayCommand] + private void ConvertPointToLine() + { + var point = ActivePoint; + var shape = point == null ? null : point.DrawShape ?? Shapes.FirstOrDefault(s => s.Points.Contains(point)); + if (point == null || shape == null || !shape.IsCurveSegment(point)) + { + return; + } + + SaveUndo(); + var end = shape.GetSegmentEnd(point)!; + shape.ConvertSegmentToLine(point); + + // A removed control point can't stay selected - select the segment's end point instead + if (point != end) + { + ActivePoint = null; + IsPointSelected = false; + } + + RefreshTreeView(); + if (point != end) + { + SelectPoint(end); + } + + Canvas?.InvalidateVisual(); + } + + [RelayCommand] + private void ConvertShapeToCurves() + { + var shape = TargetShape; + if (shape == null || !shape.Points.Any(shape.IsLineSegment)) + { + return; + } + + SaveUndo(); + shape.ConvertAllLinesToCurves(); + RefreshTreeView(); + Canvas?.InvalidateVisual(); + } + + [RelayCommand] + private void ConvertShapeToLines() + { + var shape = TargetShape; + if (shape == null || !shape.Points.Any(shape.IsCurveSegment)) + { + return; + } + + SaveUndo(); + if (ActivePoint != null && ActivePoint.DrawType is DrawCoordinateType.BezierCurveSupport1 or DrawCoordinateType.BezierCurveSupport2) + { + ActivePoint = null; + IsPointSelected = false; + } + + shape.ConvertAllCurvesToLines(); + RefreshTreeView(); + Canvas?.InvalidateVisual(); + } + + [RelayCommand] + private void CancelDrawing() + { + if (!IsDrawing) + { + return; + } + + ActiveShape = null; + _currentX = float.MinValue; + _currentY = float.MinValue; + if (Canvas != null) + { + Canvas.ActiveShape = null; + Canvas.CurrentX = float.MinValue; + Canvas.CurrentY = float.MinValue; + Canvas.InvalidateVisual(); + } + } + + [RelayCommand] + private void ClearAll() + { + if (Shapes.Count > 0) + { + SaveUndo(); + } + + ClearAllShapes(); + } + + private void ClearAllShapes() + { + Shapes.Clear(); + ActiveShape = null; + ActivePoint = null; + IsPointSelected = false; + SelectedShapes = []; + _currentX = float.MinValue; + _currentY = float.MinValue; + RefreshTreeView(); + + if (Canvas != null) + { + Canvas.Shapes = Shapes; + Canvas.ActiveShape = null; + Canvas.ActivePoint = null; + Canvas.InvalidateVisual(); + } + } + + [RelayCommand] + private void ZoomIn() + { + Canvas?.ZoomIn(); + UpdateZoomText(); + } + + [RelayCommand] + private void ZoomOut() + { + Canvas?.ZoomOut(); + UpdateZoomText(); + } + + [RelayCommand] + private void ResetView() + { + // Back to the opening view: whole frame fitted and centered (was 100% at the top-left + // corner, which for a 1080p frame is mostly off screen). + Canvas?.FitToView(); + UpdateZoomText(); + } + + private void UpdateZoomText() + { + if (Canvas != null) + { + ZoomText = $"Zoom: {Canvas.ZoomFactor * 100:0}%"; + } + } + + [RelayCommand] + private void ToggleGrid() + { + ShowGrid = !ShowGrid; + DrawSettings.ShowGrid = ShowGrid; + Canvas?.InvalidateVisual(); + } + + [RelayCommand] + private async Task TogglePreview() + { + if (!ShowPreview && Window != null) + { + var ffmpegOk = await FfmpegRequirement.EnsureAsync( + Window, + async () => (await _windowService.ShowDialogAsync(Window)).FfmpegFileName); + if (!ffmpegOk) + { + return; + } + } + + ShowPreview = !ShowPreview; + DrawSettings.ShowPreview = ShowPreview; + } + + partial void OnShowPreviewChanged(bool value) + { + if (Canvas != null) + { + Canvas.ShowPreview = value; + } + + if (!value) + { + _previewTimer?.Stop(); + PreviewStatusText = string.Empty; + return; + } + + LoadPreviewBackground(); + _previewSource = string.Empty; + if (_previewTimer == null) + { + // Shapes change from many places (clicks, drags, tree edits, nudges, colors), so poll the + // generated script instead of hooking each one - ffmpeg only runs when it differs. + _previewTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) }; + _previewTimer.Tick += (_, _) => UpdatePreview(); + } + + _previewTimer.Start(); + UpdatePreview(); + } + + /// + /// The libass preview shows over the video frame, so the first preview loads it unless a + /// background was already picked (or removed) by hand. + /// + private void LoadPreviewBackground() + { + if (_backgroundRequested || !HasVideo) + { + return; + } + + LoadVideoFrameBackground(_videoSeconds); + } + + public bool HasVideo => !string.IsNullOrEmpty(_videoFileName) && File.Exists(_videoFileName); + + private void LoadVideoFrameBackground(double seconds) + { + if (!HasVideo) + { + return; + } + + _backgroundRequested = true; + var version = ++_backgroundVersion; + var videoFileName = _videoFileName!; + var position = seconds.ToString("0.###", CultureInfo.InvariantCulture); + _ = Task.Run(() => + { + var bitmap = LoadBitmapAndDelete(FfmpegGenerator.GetScreenShot(videoFileName, position)); + Dispatcher.UIThread.Post(() => SetBackground(bitmap, version)); + }); + } + + /// + /// Shows an image file behind the drawing. Returns false when it can't be read as an image. + /// + public bool LoadImageBackground(string fileName) + { + Bitmap bitmap; + try + { + using var stream = File.OpenRead(fileName); + bitmap = new Bitmap(stream); + } + catch (Exception exception) + { + Se.LogError(exception, "ASSA draw: could not load background image " + fileName); + return false; + } + + _backgroundRequested = true; + SetBackground(bitmap, ++_backgroundVersion); + return true; + } + + private void SetBackground(Bitmap? bitmap, int version) + { + // A slower ffmpeg grab must not replace a background picked after it was started + if (Canvas == null || version != _backgroundVersion) + { + bitmap?.Dispose(); + return; + } + + var old = Canvas.BackgroundImage as IDisposable; + Canvas.BackgroundImage = bitmap; + old?.Dispose(); + HasBackground = bitmap != null; + } + + [RelayCommand(CanExecute = nameof(HasVideo))] + private void BackgroundFromVideo() => LoadVideoFrameBackground(_videoSeconds); + + [RelayCommand(CanExecute = nameof(HasVideo))] + private async Task BackgroundFromVideoAt() + { + if (Window == null) + { + return; + } + + var vm = await _windowService.ShowDialogAsync( + Window, vm => vm.Time = TimeSpan.FromSeconds(_videoSeconds)); + if (!vm.OkPressed) + { + return; + } + + _videoSeconds = Math.Max(0, vm.Time.TotalSeconds); + LoadVideoFrameBackground(_videoSeconds); + } + + [RelayCommand] + private async Task BackgroundFromImage() + { + if (Window == null) + { + return; + } + + var fileName = await _fileHelper.PickOpenFile(Window, Se.Language.Assa.DrawBackgroundImage.TrimEnd('.'), + Se.Language.Assa.DrawImages, "*.png;*.jpg;*.jpeg;*.bmp;*.webp"); + if (!string.IsNullOrEmpty(fileName) && !LoadImageBackground(fileName)) + { + await MessageBox.Show(Window, Se.Language.General.Error, Path.GetFileName(fileName), MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + [RelayCommand] + private void RemoveBackground() + { + // Also keeps the preview from bringing the video frame back + _backgroundRequested = true; + SetBackground(null, ++_backgroundVersion); + } + + [RelayCommand] + private void ToggleBackgroundStretch() => BackgroundStretch = !BackgroundStretch; + + partial void OnBackgroundOpacityChanged(double value) + { + Se.Settings.Assa.DrawBackgroundOpacity = value; + if (Canvas != null) + { + Canvas.BackgroundOpacity = value; + } + } + + partial void OnBackgroundStretchChanged(bool value) + { + Se.Settings.Assa.DrawBackgroundStretch = value; + if (Canvas != null) + { + Canvas.BackgroundStretch = value; + } + } + + internal void UpdatePreview() + { + if (_previewBusy || Canvas == null || !ShowPreview) + { + return; + } + + var subtitle = GenerateSubtitle(); + var source = new AdvancedSubStationAlpha().ToText(subtitle, string.Empty) + CanvasWidth + "x" + CanvasHeight; + if (source == _previewSource) + { + return; + } + + _previewSource = source; + _previewBusy = true; + var width = CanvasWidth; + var height = CanvasHeight; + _ = Task.Run(() => + { + Bitmap? bitmap = null; + try + { + bitmap = subtitle.Paragraphs.Count == 0 + ? null + : LoadBitmapAndDelete(FfmpegGenerator.GetScreenShotWithSubtitle(subtitle, width, height)); + } + catch (Exception exception) + { + Se.LogError(exception, "ASSA draw preview failed"); + } + + var failed = bitmap == null && subtitle.Paragraphs.Count > 0; + Dispatcher.UIThread.Post(() => + { + _previewBusy = false; + PreviewStatusText = failed && ShowPreview ? Se.Language.Assa.DrawPreviewFailed : string.Empty; + if (Canvas == null) + { + bitmap?.Dispose(); + return; + } + + var old = Canvas.PreviewImage as IDisposable; + Canvas.PreviewImage = bitmap; + old?.Dispose(); + }); + }); + } + + private static Bitmap? LoadBitmapAndDelete(string? fileName) + { + if (string.IsNullOrEmpty(fileName) || !File.Exists(fileName)) + { + return null; + } + + try + { + using var stream = File.OpenRead(fileName); + return new Bitmap(stream); + } + catch + { + return null; + } + finally + { + try + { + File.Delete(fileName); + } + catch + { + // ignore cleanup errors + } + } + } + + [RelayCommand] + private async Task Save() + { + if (Shapes.Count == 0) + { + return; + } + + if (Window == null) + { + return; + } + + var fileName = await _fileHelper.PickSaveFile( + Window, + ".assadraw", + string.IsNullOrEmpty(_fileName) ? "untitled.assadraw" : Path.GetFileName(_fileName), + "Save ASSA drawing"); + + if (string.IsNullOrEmpty(fileName)) + { + return; + } + + _fileName = fileName; + + try + { + var subtitle = GenerateSubtitle(); + var format = new AdvancedSubStationAlpha(); + var text = format.ToText(subtitle, string.Empty); + await File.WriteAllTextAsync(fileName, text); + } + catch (Exception ex) + { + await MessageBox.Show(Window, Se.Language.General.Error, ex.Message, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + [RelayCommand] + private async Task Load() + { + if (Window == null) + { + return; + } + + var fileName = await _fileHelper.PickOpenFile( + Window, + "Open ASSA drawing", + "ASSA drawing files", + "*.assadraw", + "ASS files", + "*.ass"); + + if (string.IsNullOrEmpty(fileName)) + { + return; + } + + try + { var text = await File.ReadAllTextAsync(fileName); _fileName = fileName; LoadFromText(text); @@ -646,6 +1530,104 @@ private async Task Load() } } + [RelayCommand] + private async Task ImportSvg() + { + if (Window == null) + { + return; + } + + var fileName = await _fileHelper.PickOpenFile(Window, Se.Language.Assa.DrawImportSvg.TrimEnd('.'), Se.Language.Assa.DrawSvgImages, "*.svg"); + if (!string.IsNullOrEmpty(fileName)) + { + await ImportSvgFile(fileName); + } + } + + public async Task ImportSvgFile(string fileName) + { + try + { + var text = await File.ReadAllTextAsync(fileName); + if (!ImportSvgText(text) && Window != null) + { + await MessageBox.Show(Window, Se.Language.Assa.DrawImportSvg.TrimEnd('.'), Se.Language.Assa.DrawSvgNoShapes, MessageBoxButtons.OK, MessageBoxIcon.Information); + } + } + catch (Exception ex) when (ex is System.Xml.XmlException or IOException or UnauthorizedAccessException) + { + if (Window != null) + { + await MessageBox.Show(Window, Se.Language.General.Error, ex.Message, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + + /// + /// Adds the SVG's shapes on new layers above the existing drawing. Returns false when it had none. + /// + public bool ImportSvgText(string svgText) + { + var firstLayer = Shapes.Count == 0 ? 0 : Shapes.Max(s => s.Layer) + 1; + var result = SvgImporter.Import(svgText, CanvasWidth, CanvasHeight, firstLayer); + if (result.Shapes.Count == 0) + { + return false; + } + + CancelDrawing(); + SaveUndo(); + Shapes.AddRange(result.Shapes); + SelectedShapes = result.Shapes.ToList(); + RefreshTreeView(); + Canvas?.InvalidateVisual(); + return true; + } + + private static readonly string[] BackgroundImageExtensions = [".png", ".jpg", ".jpeg", ".bmp", ".webp"]; + + internal void OnDragOver(object? sender, DragEventArgs e) + { + e.DragEffects = GetDroppedFile(e) != null ? DragDropEffects.Copy : DragDropEffects.None; + e.Handled = true; + } + + /// + /// An .svg is imported as shapes, an image becomes the background. + /// + internal void OnDrop(object? sender, DragEventArgs e) + { + var fileName = GetDroppedFile(e); + if (fileName == null) + { + return; + } + + e.Handled = true; + if (fileName.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)) + { + Dispatcher.UIThread.Post(() => _ = ImportSvgFile(fileName)); + } + else + { + Dispatcher.UIThread.Post(() => LoadImageBackground(fileName)); + } + } + + private static string? GetDroppedFile(DragEventArgs e) + { + if (!e.DataTransfer.Contains(DataFormat.File)) + { + return null; + } + + return e.DataTransfer.TryGetFiles()? + .Select(f => f.Path.LocalPath) + .FirstOrDefault(f => f.EndsWith(".svg", StringComparison.OrdinalIgnoreCase) || + BackgroundImageExtensions.Any(x => f.EndsWith(x, StringComparison.OrdinalIgnoreCase))); + } + [RelayCommand] private async Task CopyToClipboard() { @@ -678,9 +1660,14 @@ private Subtitle GenerateSubtitle(bool includeAll = false) subtitle.Footer = _subtitle.Footer; } - // Update resolution in header - subtitle.Header = subtitle.Header.Replace("PlayResX: 384", $"PlayResX: {CanvasWidth}"); - subtitle.Header = subtitle.Header.Replace("PlayResY: 288", $"PlayResY: {CanvasHeight}"); + // Shapes are drawn in canvas coordinates, so a header without a resolution needs the canvas + // size - libass falls back to 384x288 and scaled the drawing far off screen. The default header + // has no PlayRes lines at all, so the old "PlayResX: 384" replace never matched. An existing + // resolution is kept: it positions every other line of the script too. + if (!TryGetPlayRes(subtitle.Header, out _, out _)) + { + subtitle.Header = AdvancedSubStationAlpha.SetResolution(subtitle.Header, CanvasWidth, CanvasHeight); + } // Collect unique colors from all layers var colorToStyleName = new Dictionary(); @@ -688,7 +1675,7 @@ private Subtitle GenerateSubtitle(bool includeAll = false) foreach (var layer in layers) { - var firstShape = layer.FirstOrDefault(p => !p.IsEraser || includeAll); + var firstShape = GetLayerStyleShape(layer); if (firstShape != null && !colorToStyleName.ContainsKey(firstShape.ForeColor)) { var color = firstShape.ForeColor; @@ -720,7 +1707,7 @@ private Subtitle GenerateSubtitle(bool includeAll = false) { sbDraw.Clear(); sbErase.Clear(); - var firstShape = layer.FirstOrDefault(p => !p.IsEraser); + var firstShape = GetLayerStyleShape(layer); // Collect draw shapes (normal shapes) foreach (var shape in layer.Where(p => !p.IsEraser)) @@ -777,7 +1764,36 @@ private Subtitle GenerateSubtitle(bool includeAll = false) return subtitle; } + /// + /// The shape whose color names the layer's style: the first draw shape, or for a layer of only + /// erase shapes (written as an \iclip-only line) the first erase shape. The style loop and the + /// line loop picked differently, so an erase-only layer got a line with an empty style name. + /// + private static DrawShape? GetLayerStyleShape(IEnumerable layer) + { + DrawShape? firstEraser = null; + foreach (var shape in layer) + { + if (!shape.IsEraser) + { + return shape; + } + + firstEraser ??= shape; + } + + return firstEraser; + } + private static string GetColorName(Color color) + { + // Translucent colors get the alpha in the name - otherwise e.g. a 60% white and an opaque + // white shared one style name and the second style replaced the first. + var name = GetRgbName(color); + return color.A < 255 ? $"{name}A{color.A:X2}" : name; + } + + private static string GetRgbName(Color color) { // Create a readable color name based on RGB values if (color is { R: 255, G: 255, B: 255 }) @@ -853,67 +1869,149 @@ private string GenerateAssaCode() var result = new StringBuilder(); if (!string.IsNullOrEmpty(eraseCode)) { - result.Append($"{{\\iclip({eraseCode})}}"); + result.Append($"{{\\iclip({eraseCode})}}"); + } + if (!string.IsNullOrEmpty(drawCode)) + { + result.Append($"{{\\p1}}{drawCode}{{\\p0}}"); + } + + return result.ToString(); + } + + [RelayCommand] + private void Ok() + { + AssaDrawingCode = GenerateAssaCode(); + ResultSubtitle = GenerateSubtitle(true); + OkPressed = true; + Window?.Close(); + } + + [RelayCommand] + private void Cancel() + { + Window?.Close(); + } + + private void RefreshTreeView() + { + var previous = SelectedTreeItem; + + _refreshingTree = true; + try + { + ShapeTreeItems.Clear(); + + var layers = Shapes.GroupBy(s => s.Layer).OrderBy(g => g.Key); + foreach (var layer in layers) + { + var layerShapes = layer.ToList(); + var layerItem = new ShapeTreeItem + { + Name = string.Format(Se.Language.Assa.DrawLayerX, layer.Key), + IsLayer = true, + Layer = layer.Key, + IsExpanded = true, + IconName = "fa-solid fa-layer-group", + Swatch = new SolidColorBrush(layerShapes[0].ForeColor), + IsHidden = layerShapes.All(s => s.Hidden), + Meta = layerShapes.Count.ToString(CultureInfo.InvariantCulture), + }; + + var shapeNumber = 0; + foreach (var shape in layerShapes) + { + shapeNumber++; + var shapeItem = new ShapeTreeItem + { + Name = $"Shape {shapeNumber} ({(shape.IsEraser ? "erase" : "draw")})", + Shape = shape, + IsExpanded = shape.Expanded, + IconName = shape.IsEraser ? "fa-solid fa-eraser" : "fa-solid fa-draw-polygon", + IsHidden = shape.Hidden, + Meta = shape.Points.Count.ToString(CultureInfo.InvariantCulture), + }; + + foreach (var point in shape.Points) + { + var isControlPoint = point.DrawType is DrawCoordinateType.BezierCurveSupport1 or DrawCoordinateType.BezierCurveSupport2; + shapeItem.Children.Add(new ShapeTreeItem + { + Name = point.GetText(point.X, point.Y), + Point = point, + IconName = isControlPoint ? "fa-regular fa-circle" : "fa-regular fa-square", + IsHidden = shape.Hidden, + }); + } + + layerItem.Children.Add(shapeItem); + } + + ShapeTreeItems.Add(layerItem); + } + } + finally + { + _refreshingTree = false; + } + + // Keep the selection across the rebuild - it used to vanish after every edit + var restored = previous == null + ? null + : FindTreeItem(previous.Point, previous.Shape, previous.IsLayer ? previous.Layer : null); + if (restored != null) + { + SelectedTreeItem = restored; + } + else if (SelectedTreeItem != null) + { + SelectedTreeItem = null; } - if (!string.IsNullOrEmpty(drawCode)) + else { - result.Append($"{{\\p1}}{drawCode}{{\\p0}}"); + ApplyTreeSelection(null); } - - return result.ToString(); - } - - [RelayCommand] - private void Ok() - { - AssaDrawingCode = GenerateAssaCode(); - ResultSubtitle = GenerateSubtitle(true); - OkPressed = true; - Window?.Close(); - } - - [RelayCommand] - private void Cancel() - { - Window?.Close(); } - private void RefreshTreeView() + /// + /// The tree item for a point, shape or layer, with its parents expanded so it can be shown selected. + /// + private ShapeTreeItem? FindTreeItem(DrawCoordinate? point, DrawShape? shape, int? layer) { - ShapeTreeItems.Clear(); - - var layers = Shapes.GroupBy(s => s.Layer).OrderBy(g => g.Key); - foreach (var layer in layers) + foreach (var layerItem in ShapeTreeItems) { - var layerItem = new ShapeTreeItem + if (layer.HasValue && point == null && shape == null && layerItem.Layer == layer.Value) { - Name = $"Layer {layer.Key}", - IsLayer = true, - Layer = layer.Key - }; + return layerItem; + } - foreach (var shape in layer) + foreach (var shapeItem in layerItem.Children) { - var shapeItem = new ShapeTreeItem + if (shape != null && point == null && shapeItem.Shape == shape) { - Name = $"Shape ({(shape.IsEraser ? "erase" : "draw")})", - Shape = shape - }; + layerItem.IsExpanded = true; + return shapeItem; + } - foreach (var point in shape.Points) + if (point == null) { - shapeItem.Children.Add(new ShapeTreeItem - { - Name = point.GetText(point.X, point.Y), - Point = point - }); + continue; } - layerItem.Children.Add(shapeItem); + foreach (var pointItem in shapeItem.Children) + { + if (pointItem.Point == point) + { + layerItem.IsExpanded = true; + shapeItem.IsExpanded = true; + return pointItem; + } + } } - - ShapeTreeItems.Add(layerItem); } + + return null; } private void ImportAssaDrawingFromText(string text, int layer, Color color, bool isEraser) @@ -998,24 +2096,38 @@ private void ImportAssaDrawingFromText(string text, int layer, Color color, bool } } + private static bool TryGetPlayRes(string? header, out int width, out int height) + { + width = 0; + height = 0; + if (string.IsNullOrEmpty(header)) + { + return false; + } + + var playResX = AdvancedSubStationAlpha.GetTagValueFromHeader("PlayResX", "[Script Info]", header); + var playResY = AdvancedSubStationAlpha.GetTagValueFromHeader("PlayResY", "[Script Info]", header); + return int.TryParse(playResX, NumberStyles.Integer, CultureInfo.InvariantCulture, out width) && width >= 125 && width <= 4096 && + int.TryParse(playResY, NumberStyles.Integer, CultureInfo.InvariantCulture, out height) && height >= 125 && height <= 4096; + } + private void LoadFromText(string text) { - ClearAll(); + if (Shapes.Count > 0) + { + SaveUndo(); + } + + ClearAllShapes(); var subtitle = new Subtitle(); var format = new AdvancedSubStationAlpha(); format.LoadSubtitle(subtitle, text.SplitToLines(), _fileName); // Read resolution from header - var playResX = AdvancedSubStationAlpha.GetTagValueFromHeader("PlayResX", "[Script Info]", subtitle.Header); - if (int.TryParse(playResX, out var width) && width >= 125 && width <= 4096) + if (TryGetPlayRes(subtitle.Header, out var width, out var height)) { CanvasWidth = width; - } - - var playResY = AdvancedSubStationAlpha.GetTagValueFromHeader("PlayResY", "[Script Info]", subtitle.Header); - if (int.TryParse(playResY, out var height) && height >= 125 && height <= 4096) - { CanvasHeight = height; } @@ -1040,8 +2152,8 @@ private void LoadFromText(string text) ImportAssaDrawingFromText(iclipMatch.Groups[1].Value, paragraph.Layer, color, true); } - // Handle \p1 Drawing - if (paragraph.Text.Contains("{\\p1}")) + // Handle \p1 Drawing (the \p1 can share its tag block with \pos, \an etc.) + if (_regexStart.IsMatch(paragraph.Text)) { // Strip tags to avoid parsing non-coordinate text string drawingOnly = Regex.Replace(paragraph.Text, @"\{[^}]+\}", string.Empty).Trim(); @@ -1060,19 +2172,9 @@ internal async void OnKeyDown(KeyEventArgs e) { if (e.Key == Key.Escape) { - if (ActiveShape != null && !Shapes.Contains(ActiveShape)) + if (IsDrawing) { - // Cancel current drawing - ActiveShape = null; - _currentX = float.MinValue; - _currentY = float.MinValue; - if (Canvas != null) - { - Canvas.ActiveShape = null; - Canvas.CurrentX = float.MinValue; - Canvas.CurrentY = float.MinValue; - Canvas.InvalidateVisual(); - } + CancelDrawing(); e.Handled = true; return; } @@ -1085,11 +2187,25 @@ internal async void OnKeyDown(KeyEventArgs e) CloseShape(); e.Handled = true; } - else if (e.Key == Key.Delete && ActiveShape != null) + else if (e.Key == Key.Delete && (IsDrawing || TargetShape != null)) { DeleteShape(); e.Handled = true; } + else if ((e.KeyModifiers & ~KeyModifiers.Shift) is KeyModifiers.Control or KeyModifiers.Meta && e.Key is Key.Z or Key.Y) + { + // Ctrl/Cmd+Z undo, Ctrl/Cmd+Shift+Z or Ctrl+Y redo + if (e.Key == Key.Y || e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + { + Redo(); + } + else + { + Undo(); + } + + e.Handled = true; + } else if (e.KeyModifiers.HasFlag(KeyModifiers.Alt) || (e.KeyModifiers.HasFlag(KeyModifiers.Control) && !e.KeyModifiers.HasFlag(KeyModifiers.Shift))) { @@ -1165,8 +2281,28 @@ internal async void OnKeyDown(KeyEventArgs e) e.Handled = true; } break; + case Key.D: + if (e.KeyModifiers.HasFlag(KeyModifiers.Control)) + { + DuplicateShape(); + e.Handled = true; + } + break; } } + else if (e.KeyModifiers == KeyModifiers.None && !IsTextInput(e) && e.Key is Key.V or Key.L or Key.B or Key.R or Key.C) + { + // Single-key tools like other drawing programs (not while typing in a number box) + SetTool(e.Key switch + { + Key.V => DrawingTool.Select, + Key.L => DrawingTool.Line, + Key.B => DrawingTool.Bezier, + Key.R => DrawingTool.Rectangle, + _ => DrawingTool.Circle, + }); + e.Handled = true; + } else if (e.Key == Key.F4) { LineTool(); @@ -1192,6 +2328,11 @@ internal async void OnKeyDown(KeyEventArgs e) CloseShape(); e.Handled = true; } + else if (e.Key == Key.F9) + { + e.Handled = true; + await TogglePreview(); + } else if (UiUtil.IsHelp(e)) { e.Handled = true; @@ -1199,8 +2340,22 @@ internal async void OnKeyDown(KeyEventArgs e) } } + private static bool IsTextInput(KeyEventArgs e) + { + return e.Source is Avalonia.Visual visual && + (visual is TextBox || Avalonia.VisualTree.VisualExtensions.FindAncestorOfType(visual) != null); + } + private void AdjustPosition(float xAdjust, float yAdjust) { + if (SelectedShapes.Count == 0 && SelectedTreeItem?.Shape == null && ActiveShape == null) + { + return; + } + + // Holding an arrow key is one undo step, not one per repeat + SaveUndo("nudge"); + // Check if multiple shapes are selected (Ctrl+A scenario) if (SelectedShapes.Count > 0) { @@ -1243,10 +2398,55 @@ private void AdjustPosition(float xAdjust, float yAdjust) } } + partial void OnCanvasWidthChanged(int value) + { + if (Canvas != null) + { + Canvas.CanvasWidth = value; + ZoomToFitCurrentVideoResolution(); + } + } + + partial void OnCanvasHeightChanged(int value) + { + if (Canvas != null) + { + Canvas.CanvasHeight = value; + ZoomToFitCurrentVideoResolution(); + } + } + + partial void OnActiveShapeChanged(DrawShape? value) + { + if (Canvas != null) + { + Canvas.ActiveShape = value; + } + + UndoCommand.NotifyCanExecuteChanged(); + } + + partial void OnActivePointChanged(DrawCoordinate? value) + { + if (Canvas != null) + { + Canvas.ActivePoint = value; + } + } + + partial void OnSelectedShapesChanged(List value) + { + if (Canvas != null) + { + Canvas.SelectedShapes = value; + } + } + partial void OnPointXChanged(float value) { if (ActivePoint != null && Math.Abs(ActivePoint.X - value) > 0.001f) { + SaveUndo($"point-x-{RuntimeHelpers.GetHashCode(ActivePoint)}"); ActivePoint.X = value; UpdateSelectedPointName(); Canvas?.InvalidateVisual(); @@ -1257,6 +2457,7 @@ partial void OnPointYChanged(float value) { if (ActivePoint != null && Math.Abs(ActivePoint.Y - value) > 0.001f) { + SaveUndo($"point-y-{RuntimeHelpers.GetHashCode(ActivePoint)}"); ActivePoint.Y = value; UpdateSelectedPointName(); Canvas?.InvalidateVisual(); @@ -1267,6 +2468,11 @@ partial void OnShapeIsEraserChanged(bool value) { if (ActiveShape != null && ActiveShape.IsEraser != value) { + if (Shapes.Contains(ActiveShape)) + { + SaveUndo(); + } + ActiveShape.IsEraser = value; RefreshTreeView(); Canvas?.InvalidateVisual(); @@ -1299,9 +2505,49 @@ private void UpdateSelectedPointName() partial void OnSelectedTreeItemChanged(ShapeTreeItem? value) { + if (_refreshingTree) + { + return; + } + + ApplyTreeSelection(value); + } + + private void ApplyTreeSelection(ShapeTreeItem? value) + { + if (value != null) + { + if (value.Point != null) + { + ActivePoint = value.Point; + PointX = value.Point.X; + PointY = value.Point.Y; + IsPointSelected = true; + } + else + { + ActivePoint = null; + IsPointSelected = false; + } + + // Active shape for canvas rendering and shape properties + if (value.Shape != null) + { + ActiveShape = value.Shape; + ShapeIsEraser = value.Shape.IsEraser; + LayerColor = value.Shape.ForeColor; + } + } + // Update layer selection state IsLayerSelected = value?.IsLayer == true; IsShapeSelected = value?.Shape != null; + + // A tree pick ends a Ctrl+A selection - otherwise the arrow keys kept nudging every shape. + if (value != null) + { + SelectedShapes = []; + } if (value?.IsLayer == true) { @@ -1315,41 +2561,249 @@ partial void OnSelectedTreeItemChanged(ShapeTreeItem? value) if (Canvas != null) { - Canvas.SelectedShape = value?.Shape; + Canvas.SelectedShape = value?.Shape ?? value?.Point?.DrawShape; Canvas.InvalidateVisual(); } + + UpdateSelectionInfo(); } partial void OnLayerColorChanged(Color value) { - if (SelectedTreeItem?.IsLayer == true) + // The color belongs to the layer: picked on a layer, or on a shape of it + int? layer = SelectedTreeItem?.IsLayer == true ? SelectedTreeItem.Layer : TargetShape?.Layer; + if (layer == null || !Shapes.Any(s => s.Layer == layer && s.ForeColor != value)) + { + return; + } + + SaveUndo("layer-color"); + foreach (var shape in Shapes.Where(s => s.Layer == layer)) + { + shape.ForeColor = value; + } + + var layerItem = FindTreeItem(null, null, layer); + if (layerItem != null) + { + layerItem.Swatch = new SolidColorBrush(value); + } + + Canvas?.InvalidateVisual(); + } + + [RelayCommand] + private void ToggleItemVisibility(ShapeTreeItem? item) + { + if (item == null) + { + return; + } + + var shapes = item.IsLayer + ? Shapes.Where(s => s.Layer == item.Layer).ToList() + : item.Shape != null ? [item.Shape] : []; + if (shapes.Count == 0) + { + return; + } + + SaveUndo(); + var hide = shapes.Any(s => !s.Hidden); + foreach (var shape in shapes) + { + shape.Hidden = hide; + } + + RefreshTreeView(); + Canvas?.InvalidateVisual(); + } + + /// + /// Remembers the drawing before a change. Changes with the same + /// less than a second apart (held arrow key, spinning a number box) share one undo step. + /// + private void SaveUndo(string? coalesceKey = null) + { + var now = DateTime.UtcNow; + if (coalesceKey != null && coalesceKey == _lastUndoKey && now - _lastUndoTime < TimeSpan.FromSeconds(1) && _undoStack.Count > 0) + { + _lastUndoTime = now; + return; + } + + _lastUndoKey = coalesceKey; + _lastUndoTime = now; + _undoStack.Add(CaptureState()); + if (_undoStack.Count > MaxUndoSteps) + { + _undoStack.RemoveAt(0); + } + + _redoStack.Clear(); + UpdateUndoState(); + } + + [RelayCommand(CanExecute = nameof(CanUndoOrRemovePoint))] + private void Undo() + { + // While drawing, undo takes back the last click + if (IsDrawing) + { + RemoveLastDrawnPoint(); + return; + } + + if (_undoStack.Count == 0) + { + return; + } + + _redoStack.Add(CaptureState()); + var state = _undoStack[^1]; + _undoStack.RemoveAt(_undoStack.Count - 1); + RestoreState(state); + } + + [RelayCommand(CanExecute = nameof(CanRedo))] + private void Redo() + { + if (_redoStack.Count == 0) { - // Update all shapes in the selected layer - foreach (var shape in Shapes.Where(s => s.Layer == SelectedTreeItem.Layer)) + return; + } + + CancelDrawing(); + _undoStack.Add(CaptureState()); + var state = _redoStack[^1]; + _redoStack.RemoveAt(_redoStack.Count - 1); + RestoreState(state); + } + + private bool CanUndoOrRemovePoint() => CanUndo || IsDrawing; + + private void RemoveLastDrawnPoint() + { + var points = ActiveShape!.Points; + var count = points.Count >= 4 && points[^1].DrawType == DrawCoordinateType.BezierCurve && + points[^2].DrawType == DrawCoordinateType.BezierCurveSupport2 + ? 3 + : 1; + points.RemoveRange(points.Count - count, count); + if (points.Count == 0) + { + CancelDrawing(); + } + + Canvas?.InvalidateVisual(); + UpdateUndoState(); + } + + private UndoState CaptureState() + { + var state = new UndoState(); + foreach (var shape in Shapes) + { + state.Shapes.Add(shape.Clone()); + } + + // Selection by position, so it can be restored on the cloned shapes + var item = SelectedTreeItem; + var selectedShape = item?.Shape ?? item?.Point?.DrawShape; + state.SelectedShapeIndex = selectedShape != null ? Shapes.IndexOf(selectedShape) : -1; + state.SelectedPointIndex = item?.Point != null && selectedShape != null ? selectedShape.Points.IndexOf(item.Point) : -1; + state.SelectedLayer = item?.IsLayer == true ? item.Layer : null; + return state; + } + + private void RestoreState(UndoState state) + { + ActivePoint = null; + IsPointSelected = false; + SelectedShapes = []; + if (ActiveShape != null && Shapes.Contains(ActiveShape)) + { + ActiveShape = null; + } + + // Same list instance - the canvas holds on to it + Shapes.Clear(); + foreach (var shape in state.Shapes) + { + Shapes.Add(shape.Clone()); + } + + if (SelectedTreeItem != null) + { + SelectedTreeItem = null; + } + + RefreshTreeView(); + + if (state.SelectedShapeIndex >= 0 && state.SelectedShapeIndex < Shapes.Count) + { + var shape = Shapes[state.SelectedShapeIndex]; + if (state.SelectedPointIndex >= 0 && state.SelectedPointIndex < shape.Points.Count) { - shape.ForeColor = value; + SelectPoint(shape.Points[state.SelectedPointIndex]); + } + else + { + SelectShape(shape); + } + } + else if (state.SelectedLayer.HasValue) + { + var layerItem = FindTreeItem(null, null, state.SelectedLayer.Value); + if (layerItem != null) + { + SelectedTreeItem = layerItem; } - Canvas?.InvalidateVisual(); } + + _lastUndoKey = null; + Canvas?.InvalidateVisual(); + UpdateUndoState(); + } + + private void UpdateUndoState() + { + CanUndo = _undoStack.Count > 0; + CanRedo = _redoStack.Count > 0; + UndoCommand.NotifyCanExecuteChanged(); } [RelayCommand] private void SelectAllShapes() { SelectedShapes = Shapes.ToList(); - if (Canvas != null) - { - Canvas.SelectedShapes = SelectedShapes; - } Canvas?.InvalidateVisual(); } public void OnClosing() { UiUtil.SaveWindowPosition(Window); + + _previewTimer?.Stop(); + if (Canvas != null) + { + (Canvas.PreviewImage as IDisposable)?.Dispose(); + (Canvas.BackgroundImage as IDisposable)?.Dispose(); + Canvas.PreviewImage = null; + Canvas.BackgroundImage = null; + Canvas = null; + } } } +internal sealed class UndoState +{ + public List Shapes { get; } = []; + public int SelectedShapeIndex { get; set; } = -1; + public int SelectedPointIndex { get; set; } = -1; + public int? SelectedLayer { get; set; } +} + /// /// Represents an item in the shape tree view. /// @@ -1357,11 +2811,28 @@ public partial class ShapeTreeItem : ObservableObject { [ObservableProperty] private string _name = string.Empty; [ObservableProperty] private ObservableCollection _children = []; + [ObservableProperty] private bool _isExpanded; + [ObservableProperty] private string _iconName = string.Empty; + [ObservableProperty] private IBrush? _swatch; + [ObservableProperty] private bool _isHidden; + [ObservableProperty] private string _meta = string.Empty; public bool IsLayer { get; set; } public int Layer { get; set; } public DrawShape? Shape { get; set; } public DrawCoordinate? Point { get; set; } + public bool HasSwatch => IsLayer; + public bool CanToggleVisibility => IsLayer || Shape != null; + + partial void OnIsExpandedChanged(bool value) + { + // Remembered on the shape, so a tree rebuild keeps its points open or closed + if (Shape != null) + { + Shape.Expanded = value; + } + } + public override string ToString() => Name; } \ No newline at end of file diff --git a/src/ui/Features/Assa/AssaDraw/AssaDrawWindow.cs b/src/ui/Features/Assa/AssaDraw/AssaDrawWindow.cs index ce518d4025f..d58af995df2 100644 --- a/src/ui/Features/Assa/AssaDraw/AssaDrawWindow.cs +++ b/src/ui/Features/Assa/AssaDraw/AssaDrawWindow.cs @@ -1,33 +1,73 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.RegularExpressions; using Avalonia; using Avalonia.Automation; using Avalonia.Controls; +using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Primitives.PopupPositioning; using Avalonia.Controls.Templates; using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Media.Immutable; +using Avalonia.Styling; using Nikse.SubtitleEdit.Logic; using Nikse.SubtitleEdit.Logic.Config; namespace Nikse.SubtitleEdit.Features.Assa.AssaDraw; +/// +/// ASSA draw - laid out like a vector editor: options bar on top, tool strip on the left, canvas, +/// shapes/properties panel on the right. Always dark, like most drawing programs, so the artwork +/// stands out; the colors below are the editor palette. +/// public class AssaDrawWindow : Window { + private static readonly IBrush WindowBrush = new ImmutableSolidColorBrush(Color.FromRgb(30, 31, 36)); + private static readonly IBrush PanelBrush = new ImmutableSolidColorBrush(Color.FromRgb(36, 38, 44)); + private static readonly IBrush StatusBrush = new ImmutableSolidColorBrush(Color.FromRgb(27, 28, 33)); + private static readonly IBrush ButtonBarBrush = new ImmutableSolidColorBrush(Color.FromRgb(32, 34, 39)); + private static readonly IBrush LineBrush = new ImmutableSolidColorBrush(Color.FromRgb(47, 50, 58)); + private static readonly IBrush SeparatorBrush = new ImmutableSolidColorBrush(Color.FromRgb(55, 58, 67)); + private static readonly IBrush DimTextBrush = new ImmutableSolidColorBrush(Color.FromRgb(140, 146, 160)); + private static readonly IBrush FaintTextBrush = new ImmutableSolidColorBrush(Color.FromRgb(111, 117, 131)); + private static readonly IBrush IconBrush = new ImmutableSolidColorBrush(Color.FromRgb(174, 180, 194)); + private static readonly IBrush HoverBrush = new ImmutableSolidColorBrush(Color.FromRgb(51, 54, 63)); + private static readonly IBrush PressedBrush = new ImmutableSolidColorBrush(Color.FromRgb(59, 62, 71)); + private static readonly IBrush AccentBrush = new ImmutableSolidColorBrush(Color.FromRgb(76, 110, 245)); + private static readonly IBrush AccentHoverBrush = new ImmutableSolidColorBrush(Color.FromRgb(92, 124, 250)); + private static readonly IBrush AccentSoftBrush = new ImmutableSolidColorBrush(Color.FromArgb(70, 76, 110, 245)); + private static readonly IBrush FieldBrush = new ImmutableSolidColorBrush(Color.FromRgb(27, 28, 33)); + private static readonly IBrush FieldBorderBrush = new ImmutableSolidColorBrush(Color.FromRgb(54, 57, 68)); + private static readonly IBrush MenuBrush = new ImmutableSolidColorBrush(Color.FromRgb(42, 44, 51)); + private static readonly IBrush MenuBorderBrush = new ImmutableSolidColorBrush(Color.FromRgb(66, 69, 79)); + private readonly AssaDrawViewModel _vm; private readonly AssaDrawCanvas _canvas; + private readonly ContextMenu _canvasMenu; public AssaDrawWindow(AssaDrawViewModel vm) { _vm = vm; UiUtil.InitializeWindow(this, GetType().Name); Title = Se.Language.Assa.AssaDraw; - Width = 1200; - Height = 800; + Width = 1280; + Height = 820; MinWidth = 900; MinHeight = 600; CanResize = true; + RequestedThemeVariant = ThemeVariant.Dark; + Background = WindowBrush; vm.Window = this; DataContext = vm; + AddEditorStyles(Styles); var mainGrid = new Grid { @@ -38,19 +78,14 @@ public AssaDrawWindow(AssaDrawViewModel vm) 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 = new Thickness(10), }; - // Toolbar + // Options bar var toolbar = CreateToolbar(vm); Grid.SetRow(toolbar, 0); mainGrid.Children.Add(toolbar); - // Main content area with canvas and side panel + // Tool strip, canvas and side panel var contentGrid = CreateContentArea(vm, out _canvas); Grid.SetRow(contentGrid, 1); mainGrid.Children.Add(contentGrid); @@ -60,15 +95,27 @@ public AssaDrawWindow(AssaDrawViewModel vm) Grid.SetRow(statusBar, 2); mainGrid.Children.Add(statusBar); - // Button bar - var buttonOk = UiUtil.MakeButtonOk(vm.OkCommand); - var buttonCancel = UiUtil.MakeButtonCancel(vm.CancelCommand); - var panelButtons = UiUtil.MakeButtonBar(buttonOk, buttonCancel); - Grid.SetRow(panelButtons, 3); - mainGrid.Children.Add(panelButtons); + // Code preview + OK/Cancel + var buttonBar = CreateButtonBar(vm); + Grid.SetRow(buttonBar, 3); + mainGrid.Children.Add(buttonBar); Content = mainGrid; + // Right-click on the canvas: the canvas reports what is under the pointer, the menu is built for it + _canvasMenu = new ContextMenu + { + Placement = PlacementMode.AnchorAndGravity, + PlacementAnchor = PopupAnchor.TopLeft, + PlacementGravity = PopupGravity.BottomRight, + }; + _canvas.ContextMenuRequested += OnCanvasContextMenuRequested; + + // Drop an .svg (import) or an image (background) anywhere on the window + DragDrop.SetAllowDrop(this, true); + AddHandler(DragDrop.DragOverEvent, vm.OnDragOver, RoutingStrategies.Bubble); + AddHandler(DragDrop.DropEvent, vm.OnDrop, RoutingStrategies.Bubble); + Loaded += OnLoaded; Closing += (_, e) => vm.OnClosing(); KeyDown += (_, e) => vm.OnKeyDown(e); @@ -81,168 +128,684 @@ private void OnLoaded(object? sender, global::Avalonia.Interactivity.RoutedEvent _vm.Initialize(); } - private static Border CreateToolbar(AssaDrawViewModel vm) + /// + /// Flat icon buttons with an accent "active" state, compact number fields and accent menus. + /// + private static void AddEditorStyles(Styles styles) { - var toolbarPanel = new StackPanel + static Style ToolPresenter(Func button, params (AvaloniaProperty Property, object Value)[] setters) { - Orientation = Orientation.Horizontal, - Spacing = 5, - }; + var style = new Style(x => button(x).Template().OfType().Name("PART_ContentPresenter")); + foreach (var (property, value) in setters) + { + style.Setters.Add(new Setter(property, value)); + } - // File actions - var saveButton = CreateToolButton("fa-solid fa-floppy-disk", "Save", vm.SaveCommand); - var loadButton = CreateToolButton("fa-solid fa-folder-open", "Load", vm.LoadCommand); - var copyButton = CreateToolButton("fa-solid fa-copy", Se.Language.Assa.DrawCopyToClipboard, vm.CopyToClipboardCommand); - vm.CopyToClipboardButton = copyButton; + return style; + } - toolbarPanel.Children.Add(saveButton); - toolbarPanel.Children.Add(loadButton); - toolbarPanel.Children.Add(copyButton); + styles.Add(new Style(x => x.OfType