├── .aiexclude ├── .github ├── copilot-instructions.md └── workflows │ ├── claude-code-review.yml │ └── claude.yml ├── .gitignore ├── AGENTS.md ├── CLAUDE.md ├── GEMINI.md ├── GuiSubtrans ├── AboutDialog.py ├── Command.py ├── CommandQueue.py ├── Commands │ ├── AutoSplitBatchCommand.py │ ├── BatchSubtitlesCommand.py │ ├── DeleteLinesCommand.py │ ├── EditBatchCommand.py │ ├── EditLineCommand.py │ ├── EditSceneCommand.py │ ├── LoadSubtitleFile.py │ ├── MergeBatchesCommand.py │ ├── MergeLinesCommand.py │ ├── MergeScenesCommand.py │ ├── ReparseTranslationsCommand.py │ ├── SaveProjectFile.py │ ├── SaveSubtitleFile.py │ ├── SaveTranslationFile.py │ ├── SplitBatchCommand.py │ ├── SplitSceneCommand.py │ ├── StartTranslationCommand.py │ ├── SwapTextAndTranslations.py │ └── TranslateSceneCommand.py ├── EditInstructionsDialog.py ├── FirstRunOptions.py ├── GUICommands.py ├── GuiHelpers.py ├── GuiInterface.py ├── GuiSubtitleTestCase.py ├── MainToolbar.py ├── MainWindow.py ├── NewProjectSettings.py ├── ProjectActions.py ├── ProjectDataModel.py ├── ProjectSelection.py ├── ProjectToolbar.py ├── ScenesBatchesDelegate.py ├── ScenesBatchesModel.py ├── SettingsDialog.py ├── SubtitleItemDelegate.py ├── SubtitleListModel.py ├── ViewModel │ ├── BatchItem.py │ ├── LineItem.py │ ├── SceneItem.py │ ├── TestableViewModel.py │ ├── ViewModel.py │ ├── ViewModelError.py │ ├── ViewModelItem.py │ ├── ViewModelUpdate.py │ └── ViewModelUpdateSection.py └── Widgets │ ├── ContentView.py │ ├── Editors.py │ ├── LogWindow.py │ ├── ModelView.py │ ├── OptionsWidgets.py │ ├── ProjectSettings.py │ ├── ScenesView.py │ ├── SelectionView.py │ ├── SubtitleView.py │ └── Widgets.py ├── LICENSE ├── PySubtrans ├── CHANGELOG.md ├── Formats │ ├── SSAFileHandler.py │ ├── SrtFileHandler.py │ ├── VttFileHandler.py │ └── __init__.py ├── Helpers │ ├── Color.py │ ├── ContextHelpers.py │ ├── InstructionsHelpers.py │ ├── Localization.py │ ├── Parse.py │ ├── Resources.py │ ├── SubtitleHelpers.py │ ├── TestCases.py │ ├── Tests.py │ ├── Text.py │ ├── Time.py │ ├── Version.py │ └── __init__.py ├── Instructions.py ├── LICENSE ├── Options.py ├── ProviderSettingsView.py ├── Providers │ ├── Clients │ │ ├── AnthropicClient.py │ │ ├── AzureOpenAIClient.py │ │ ├── BedrockClient.py │ │ ├── ChatGPTClient.py │ │ ├── CustomClient.py │ │ ├── DeepSeekClient.py │ │ ├── GeminiClient.py │ │ ├── MistralClient.py │ │ ├── OpenAIClient.py │ │ ├── OpenAIReasoningClient.py │ │ ├── OpenRouterClient.py │ │ └── __init__.py │ ├── Provider_Azure.py │ ├── Provider_Bedrock.py │ ├── Provider_Claude.py │ ├── Provider_Custom.py │ ├── Provider_DeepSeek.py │ ├── Provider_Gemini.py │ ├── Provider_Mistral.py │ ├── Provider_OpenAI.py │ ├── Provider_OpenRouter.py │ └── __init__.py ├── README.md ├── SettingsType.py ├── Substitutions.py ├── SubtitleBatch.py ├── SubtitleBatcher.py ├── SubtitleBuilder.py ├── SubtitleData.py ├── SubtitleEditor.py ├── SubtitleError.py ├── SubtitleFileHandler.py ├── SubtitleFormatRegistry.py ├── SubtitleLine.py ├── SubtitleProcessor.py ├── SubtitleProject.py ├── SubtitleScene.py ├── SubtitleSerialisation.py ├── SubtitleTranslator.py ├── SubtitleValidator.py ├── Subtitles.py ├── Translation.py ├── TranslationClient.py ├── TranslationEvents.py ├── TranslationParser.py ├── TranslationPrompt.py ├── TranslationProvider.py ├── TranslationRequest.py ├── VersionCheck.py ├── __init__.py ├── pyproject.toml └── version.py ├── assets ├── gui-subtrans.ico ├── icons │ ├── about.svg │ ├── load_subtitles.svg │ ├── quit.svg │ ├── redo.svg │ ├── save_project.svg │ ├── settings.svg │ ├── start_translating.svg │ ├── start_translating_fast.svg │ ├── stop_translating.svg │ └── undo.svg ├── subtranslg.png └── subtransmd.png ├── docs ├── architecture.md ├── localization_contributing.md ├── multi-format-support-proposal.md └── tests_review.md ├── hooks └── hook-PySubtrans.py ├── install.bat ├── install.sh ├── instructions ├── instructions (OCR errors).txt ├── instructions (Whispered).txt ├── instructions (brief).txt ├── instructions (chinese+english).txt ├── instructions (english to chinese).txt ├── instructions (improve quality).txt ├── instructions (transliteration).txt └── instructions.txt ├── locales ├── cs │ └── LC_MESSAGES │ │ ├── gui-subtrans.mo │ │ └── gui-subtrans.po ├── en │ └── LC_MESSAGES │ │ ├── gui-subtrans.mo │ │ └── gui-subtrans.po ├── es │ └── LC_MESSAGES │ │ ├── gui-subtrans.mo │ │ └── gui-subtrans.po └── gui-subtrans.pot ├── pyproject.toml ├── readme.md ├── scripts ├── __init__.py ├── azure-subtrans.py ├── batch-translate.py ├── bedrock-subtrans.py ├── check_imports.py ├── claude-subtrans.py ├── deepseek-subtrans.py ├── extract_strings.py ├── gemini-subtrans.py ├── generate-cmd.bat ├── generate-cmd.sh ├── gpt-subtrans.py ├── gui-subtrans.py ├── gui-subtrans.sh ├── llm-subtrans.py ├── makedistro-mac.sh ├── makedistro.bat ├── makedistro.sh ├── mistral-subtrans.py ├── publish_package.py ├── run_tests.py ├── subtrans_common.py ├── sync_version.py ├── update_translations.py └── verify_package.py ├── tests ├── GuiTests │ ├── DataModelHelpers.py │ ├── __init__.py │ ├── chinese_dinner.py │ ├── test_BatchCommands.py │ ├── test_CommandsWithViewModel.py │ ├── test_DataModel.py │ ├── test_DeleteLinesCommand.py │ ├── test_EditCommands.py │ ├── test_MergeLinesCommand.py │ ├── test_MergeSplitCommands.py │ ├── test_ProjectViewModel.py │ ├── test_ReparseTranslationCommand.py │ └── test_StartTranslationCommand.py ├── PySubtransTests │ ├── __init__.py │ ├── test_ChineseDinner.py │ ├── test_Options.py │ ├── test_Parse.py │ ├── test_PySubtrans.py │ ├── test_SsaFileHandler.py │ ├── test_Streaming.py │ ├── test_Substitutions.py │ ├── test_SubtitleBuilder.py │ ├── test_SubtitleEditor.py │ ├── test_SubtitleFormatRegistry.py │ ├── test_SubtitleProject.py │ ├── test_SubtitleProjectFormats.py │ ├── test_SubtitleValidator.py │ ├── test_Subtitles.py │ ├── test_Time.py │ ├── test_Translator.py │ ├── test_VttFileHandler.py │ ├── test_localization.py │ └── test_text.py ├── TestData │ └── chinese_dinner.py ├── functional │ ├── batcher_test.py │ └── preprocessor_test.py └── unit_tests.py └── theme ├── subtrans-dark-large.qss ├── subtrans-dark.qss ├── subtrans-large.qss └── subtrans.qss /.aiexclude: -------------------------------------------------------------------------------- 1 | .vscode/launch.json 2 | .vscode/* 3 | .env 4 | test_subtitles/* 5 | test_results/* 6 | build/* 7 | dist/* 8 | Projects/* 9 | 10 | -------------------------------------------------------------------------------- /.github/workflows/claude-code-review.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code Review 2 | 3 | on: 4 | pull_request: 5 | types: [ready_for_review] 6 | # Optional: Only run on specific file changes 7 | # paths: 8 | # - "src/**/*.ts" 9 | # - "src/**/*.tsx" 10 | # - "src/**/*.js" 11 | # - "src/**/*.jsx" 12 | 13 | jobs: 14 | claude-review: 15 | # Optional: Filter by PR author 16 | # if: | 17 | # github.event.pull_request.user.login == 'external-contributor' || 18 | # github.event.pull_request.user.login == 'new-developer' || 19 | # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' 20 | 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | pull-requests: read 25 | issues: read 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 1 33 | 34 | - name: Run Claude Code Review 35 | id: claude-review 36 | uses: anthropics/claude-code-action@v1 37 | with: 38 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 39 | prompt: | 40 | Please review this pull request and provide feedback on: 41 | - Code quality and best practices 42 | - Potential bugs or issues 43 | - Performance considerations 44 | - Security concerns 45 | - Test coverage 46 | 47 | Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. 48 | 49 | Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. 50 | 51 | # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md 52 | # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options 53 | claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' 54 | 55 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | actions: read # Required for Claude to read CI results on PRs 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 1 32 | 33 | - name: Run Claude Code 34 | id: claude 35 | uses: anthropics/claude-code-action@v1 36 | with: 37 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 38 | 39 | # This is an optional setting that allows Claude to read CI results on PRs 40 | additional_permissions: | 41 | actions: read 42 | 43 | # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. 44 | # prompt: 'Update the pull request description to include a summary of changes.' 45 | 46 | # Optional: Add claude_args to customize behavior and configuration 47 | # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md 48 | # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options 49 | # claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)' 50 | 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .cursorignore 3 | .DS_Store 4 | .idea/ 5 | *.ass 6 | *.log 7 | *.mkv 8 | *.pdf 9 | *.ssa 10 | *.vtt 11 | *.zip 12 | /.claude 13 | /.env 14 | /.vscode 15 | /*.cmd 16 | /*.code-workspace 17 | /*.debug 18 | /*.srt 19 | /*.substitutions 20 | /*.subtrans 21 | /*.subtrans-backup 22 | /build 23 | /dist 24 | /envsubtrans 25 | /llm_subtrans.egg-info 26 | /Projects 27 | /subtrans-env 28 | /test_projects 29 | /test_results 30 | /test_subtitles 31 | /test_subtitles/hidden 32 | autotranslated_msgids_*.txt 33 | bedrock-subtrans.sh 34 | claude-subtrans.sh 35 | deepseek-subtrans.sh 36 | env 37 | gemini-subtrans.sh 38 | get-pip.py 39 | git-cleanup-gone.ps1 40 | gpt-subtrans.sh 41 | gui-subtrans.sh 42 | gui-subtrans.spec 43 | llm-subtrans.sh 44 | mistral-subtrans.sh 45 | untranslated_msgids_*.txt 46 | 47 | /PySubtrans/build 48 | /PySubtrans/dist 49 | /PySubtrans/*.egg-info 50 | /subtitles 51 | /translated 52 | batch-translate.sh 53 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # LLM-Subtrans Development Guide 2 | 3 | Project uses Python 3.10+. NEVER import or use deprecated typing members like List, Union or Iterator. 4 | 5 | GUI framework is PySide6, be sure to use the correct syntax (e.g. scoped enum values). 6 | 7 | Secrets are stored in a .env file - NEVER read the contents of the file. 8 | 9 | Always run the unit_tests at the end of a task to validate any changes to the code. 10 | 11 | ## Console Output 12 | **IMPORTANT** Avoid Unicode characters (✓ ✗) in log messages as these trigger Windows console errors 13 | 14 | ## Commands 15 | - Always activate the virtual environment first (e.g. `./envsubtrans/bin/activate`) 16 | - Run all unit tests: `python tests/unit_tests.py` 17 | - Run single test: `python -m unittest PySubtrans.UnitTests.test_MODULE` or `python -m unittest GuiSubtrans.UnitTests.test_MODULE` 18 | - Build distribution: `./scripts/makedistro.sh` (Linux/Mac) or `scripts\makedistro.bat` (Windows) 19 | 20 | ## Code Style 21 | **🚨 CRITICAL RULE: NEVER add imports in the middle of functions or methods - ALL imports MUST be at the top of the file.** 22 | 23 | - **Naming**: PascalCase for classes and methods, snake_case for variables 24 | - **Imports**: Standard lib → third-party → local, alphabetical within groups 25 | - **Class structure**: Docstring → constants → init → properties → public methods → private methods 26 | - **Type Hints**: Use type hints for parameters, return values, and class variables 27 | - NEVER put spaces around the `|` in type unions. Use `str|None`, never `str | None` 28 | - ALWAYS put spaces around the colon introducing a type hint: 29 | - Examples: 30 | `def func(self, param : str) -> str|None:` ✅ 31 | `def func(self, param: str) -> str | None:` ❌ 32 | - **Docstrings**: Triple-quoted concise descriptions for classes and methods 33 | - **Error handling**: Custom exceptions, specific except blocks, input validation, logging.warning/error 34 | - User-facing error messages should be localizable, using _() 35 | - **Threading safety**: Use locks (RLock/QRecursiveMutex) for thread-safe operations 36 | **Regular Expressions**: The project uses the `regex` module for regular expression handling, rather than the standard `re`. 37 | - **Unit Tests**: Extend `LoggedTestCase` from `PySubtrans.Helpers.TestCases` and use `assertLogged*` methods for automatic logging and assertions. 38 | - **Key Principles**: 39 | - Prefer `assertLogged*` helper methods over manual logging + standard assertions 40 | - Use semantic assertions over generic `assertTrue` - the helpers provide `assertLoggedEqual`, `assertLoggedIsNotNone`, `assertLoggedIn`, etc. 41 | - Include descriptive text as the first parameter to explain what is being tested 42 | - Optionally provide `input_value` parameter for additional context 43 | - **Common Patterns**: 44 | - **Equality**: `self.assertLoggedEqual("field_name", expected, obj.field)` 45 | - **Type checks**: `self.assertLoggedIsInstance("object type", obj, ExpectedClass)` 46 | - **None checks**: `self.assertLoggedIsNotNone("result", obj)` 47 | - **Membership**: `self.assertLoggedIn("key existence", "key", data)` 48 | - **Comparisons**: `self.assertLoggedGreater("count", actual_count, 0)` 49 | - **Custom logging**: `self.log_expected_result(expected, actual, description="custom check", input_value=input_data)` 50 | - **Exception Tests**: Guard with `skip_if_debugger_attached` decorator for debugging compatibility 51 | - Use `log_input_expected_error(input, ExpectedException, actual_exception)` for exception logging 52 | - **None Safety**: Use `.get(key, default)` with appropriate default values to avoid Pylance warnings, or assert then test for None values. 53 | 54 | ## Information 55 | Consult `docs/architecture.md` for detailed information on the project architecture and components. 56 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # LLM-Subtrans Development Guide 2 | 3 | Project uses Python 3.10+. NEVER import or use deprecated typing members like List, Union or Iterator. 4 | 5 | GUI framework is PySide6, be sure to use the correct syntax (e.g. scoped enum values). 6 | 7 | Secrets are stored in a .env file - NEVER read the contents of the file. 8 | 9 | Run tests\unit_tests.py at the end of a task to validate the change, unless it purely touched UI code (the GUI is not covered by unit tests). Activate the envsubtrans virtual environment first. 10 | 11 | ## Console Output 12 | Avoid Unicode characters (✓ ✗) in print/log messages as these trigger Windows console errors 13 | 14 | ## Commands 15 | - **IMPORTANT**: Always use the virtual environment Python: `./envsubtrans/Scripts/python.exe` (Windows) or `./envsubtrans/bin/python` (Linux/Mac) 16 | - Run all unit tests: `./envsubtrans/Scripts/python.exe tests/unit_tests.py` 17 | - Run single test: `./envsubtrans/Scripts/python.exe -m unittest PySubtrans.UnitTests.test_MODULE` or `./envsubtrans/Scripts/python.exe -m unittest GuiSubtrans.UnitTests.test_MODULE` 18 | - Run full test suite: `./envsubtrans/Scripts/python.exe scripts/run_tests.py` 19 | - Build distribution: `./scripts/makedistro.sh` (Linux/Mac) or `scripts\makedistro.bat` (Windows) 20 | - Create virtual environment, install dependencies and configure project: `./install.sh` (Linux/Mac) or `install.bat` (Windows) 21 | 22 | ## Code Style 23 | 24 | **🚨 CRITICAL RULE: NEVER EVER add imports in the middle of functions or methods - ALWAYS place ALL imports at the top of the file. This is the most important rule in this project - if you violate it you will be fired and replaced by Grok!!!** 25 | 26 | - **Naming**: PascalCase for classes and methods, snake_case for variables 27 | - **Imports**: Standard lib → third-party → local, alphabetical within groups 28 | - **Class structure**: Docstring → constants → init → properties → public methods → private methods 29 | - **Type Hints**: Use type hints for parameters, return values, and class variables 30 | - NEVER put spaces around the `|` in type unions. Use `str|None`, never `str | None` 31 | - ALWAYS put spaces around the colon introducing a type hint: 32 | - Examples: 33 | `def func(self, param : str) -> str|None:` ✅ 34 | `def func(self, param: str) -> str | None:` ❌ 35 | - **Docstrings**: Triple-quoted concise descriptions for classes and methods 36 | - **Error handling**: Custom exceptions, specific except blocks, input validation, logging.warning/error 37 | - User-facing error messages should be localizable, using _() 38 | - **Threading safety**: Use locks (RLock/QRecursiveMutex) for thread-safe operations 39 | **Regular Expressions**: The project uses the `regex` module for regular expression handling, rather than the standard `re`. 40 | - **Unit Tests**: Extend `LoggedTestCase` from `PySubtrans.Helpers.TestCases` and use `assertLogged*` methods for automatic logging and assertions. 41 | - **Key Principles**: 42 | - Prefer `assertLogged*` helper methods over manual logging + standard assertions 43 | - Use semantic assertions over generic `assertTrue` - the helpers provide `assertLoggedEqual`, `assertLoggedIsNotNone`, `assertLoggedIn`, etc. 44 | - Include descriptive text as the first parameter to explain what is being tested 45 | - Optionally provide `input_value` parameter for additional context 46 | - **Common Patterns**: 47 | - **Equality**: `self.assertLoggedEqual("field_name", expected, obj.field)` 48 | - **Type checks**: `self.assertLoggedIsInstance("object type", obj, ExpectedClass)` 49 | - **None checks**: `self.assertLoggedIsNotNone("result", obj)` 50 | - **Membership**: `self.assertLoggedIn("key existence", "key", data)` 51 | - **Comparisons**: `self.assertLoggedGreater("count", actual_count, 0)` 52 | - **Custom logging**: `self.log_expected_result(expected, actual, description="custom check", input_value=input_data)` 53 | - **Exception Tests**: Guard with `skip_if_debugger_attached` decorator for debugging compatibility 54 | - Use `log_input_expected_error(input, ExpectedException, actual_exception)` for exception logging 55 | - **None Safety**: Use `.get(key, default)` with appropriate default values to avoid Pylance warnings, or assert then test for None values. 56 | 57 | ## Information 58 | Consult `docs/architecture.md` for detailed information on the project architecture and components. 59 | -------------------------------------------------------------------------------- /GEMINI.md: -------------------------------------------------------------------------------- 1 | # LLM-Subtrans Development Guide 2 | 3 | Project uses Python 3.10+. NEVER import or use deprecated typing members like List, Union or Iterator. 4 | 5 | GUI framework is PySide6, be sure to use the correct syntax (e.g. scoped enum values). 6 | 7 | Secrets are stored in a .env file - NEVER read the contents of the file. 8 | 9 | Always run the unit_tests at the end of a task to validate any changes to the code. 10 | 11 | ## Console Output 12 | **IMPORTANT** Avoid Unicode characters (✓ ✗) in log messages as these trigger Windows console errors 13 | 14 | ## Commands 15 | - Always activate the virtual environment first (e.g. `./envsubtrans/bin/activate`) 16 | - Run all unit tests: `python tests/unit_tests.py` 17 | - Run single test: `python -m unittest PySubtrans.UnitTests.test_MODULE` or `python -m unittest GuiSubtrans.UnitTests.test_MODULE` 18 | - Build distribution: `./scripts/makedistro.sh` (Linux/Mac) or `scripts\makedistro.bat` (Windows) 19 | 20 | ## Code Style 21 | **🚨 CRITICAL RULE: NEVER add imports in the middle of functions or methods - ALL imports MUST be at the top of the file.** 22 | 23 | - **Naming**: PascalCase for classes and methods, snake_case for variables 24 | - **Imports**: Standard lib → third-party → local, alphabetical within groups 25 | - **Class structure**: Docstring → constants → init → properties → public methods → private methods 26 | - **Type Hints**: Use type hints for parameters, return values, and class variables 27 | - NEVER put spaces around the `|` in type unions. Use `str|None`, never `str | None` 28 | - ALWAYS put spaces around the colon introducing a type hint: 29 | - Examples: 30 | `def func(self, param : str) -> str|None:` ✅ 31 | `def func(self, param: str) -> str | None:` ❌ 32 | - **Docstrings**: Triple-quoted concise descriptions for classes and methods 33 | - **Error handling**: Custom exceptions, specific except blocks, input validation, logging.warning/error 34 | - User-facing error messages should be localizable, using _() 35 | - **Threading safety**: Use locks (RLock/QRecursiveMutex) for thread-safe operations 36 | **Regular Expressions**: The project uses the `regex` module for regular expression handling, rather than the standard `re`. 37 | - **Unit Tests**: Extend `LoggedTestCase` from `PySubtrans.Helpers.TestCases` and use `assertLogged*` methods for automatic logging and assertions. 38 | - **Key Principles**: 39 | - Prefer `assertLogged*` helper methods over manual logging + standard assertions 40 | - Use semantic assertions over generic `assertTrue` - the helpers provide `assertLoggedEqual`, `assertLoggedIsNotNone`, `assertLoggedIn`, etc. 41 | - Include descriptive text as the first parameter to explain what is being tested 42 | - Optionally provide `input_value` parameter for additional context 43 | - **Common Patterns**: 44 | - **Equality**: `self.assertLoggedEqual("field_name", expected, obj.field)` 45 | - **Type checks**: `self.assertLoggedIsInstance("object type", obj, ExpectedClass)` 46 | - **None checks**: `self.assertLoggedIsNotNone("result", obj)` 47 | - **Membership**: `self.assertLoggedIn("key existence", "key", data)` 48 | - **Comparisons**: `self.assertLoggedGreater("count", actual_count, 0)` 49 | - **Custom logging**: `self.log_expected_result(expected, actual, description="custom check", input_value=input_data)` 50 | - **Exception Tests**: Guard with `skip_if_debugger_attached` decorator for debugging compatibility 51 | - Use `log_input_expected_error(input, ExpectedException, actual_exception)` for exception logging 52 | - **None Safety**: Use `.get(key, default)` with appropriate default values to avoid Pylance warnings, or assert then test for None values. 53 | 54 | ## Information 55 | Consult `docs/architecture.md` for detailed information on the project architecture and components. 56 | -------------------------------------------------------------------------------- /GuiSubtrans/Commands/BatchSubtitlesCommand.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | import logging 3 | from typing import TYPE_CHECKING 4 | 5 | from GuiSubtrans.Command import Command, CommandError 6 | from GuiSubtrans.Commands.SaveProjectFile import SaveProjectFile 7 | from GuiSubtrans.ProjectDataModel import ProjectDataModel 8 | from PySubtrans.Helpers import GetOutputPath 9 | from PySubtrans.Helpers.Localization import _ 10 | from PySubtrans.Options import Options 11 | from PySubtrans.SubtitleBatcher import SubtitleBatcher 12 | from PySubtrans.SubtitleProcessor import SubtitleProcessor 13 | from PySubtrans.SubtitleProject import SubtitleProject 14 | 15 | if TYPE_CHECKING: 16 | from PySubtrans.SubtitleEditor import SubtitleEditor 17 | 18 | class BatchSubtitlesCommand(Command): 19 | """ 20 | Attempt to partition subtitles into scenes and batches based on thresholds and limits. 21 | """ 22 | def __init__(self, project : SubtitleProject, options : Options): 23 | super().__init__() 24 | self.project : SubtitleProject = project 25 | self.options : Options = options 26 | self.preprocess_subtitles : bool = options.get_bool('preprocess_subtitles', False) 27 | self.can_undo = False 28 | 29 | def execute(self) -> bool: 30 | logging.info("Executing BatchSubtitlesCommand") 31 | 32 | project : SubtitleProject = self.project 33 | 34 | if not project or not project.subtitles or not project.subtitles.originals: 35 | raise CommandError(_("No subtitles to batch"), command=self) 36 | 37 | with project.GetEditor() as editor: 38 | if self.preprocess_subtitles: 39 | originals = deepcopy(project.subtitles.originals) 40 | preprocessor = SubtitleProcessor(self.options) 41 | editor.PreProcess(preprocessor) 42 | 43 | if self.options.get('save_preprocessed', False): 44 | changed = len(originals) != len(project.subtitles.originals) or any(o != n for o, n in zip(originals, project.subtitles.originals)) 45 | if changed: 46 | output_path = GetOutputPath(project.subtitles.sourcepath, "preprocessed", project.subtitles.file_format) 47 | logging.info(f"Saving preprocessed subtitles to {output_path}") 48 | project.SaveOriginal(output_path) 49 | 50 | batcher : SubtitleBatcher = SubtitleBatcher(self.options) 51 | editor.AutoBatch(batcher) 52 | 53 | if project.use_project_file: 54 | self.commands_to_queue.append(SaveProjectFile(project=project)) 55 | 56 | self.datamodel = ProjectDataModel(project, self.options) 57 | self.datamodel.CreateViewModel() 58 | return True 59 | 60 | -------------------------------------------------------------------------------- /GuiSubtrans/Commands/DeleteLinesCommand.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING 3 | 4 | from GuiSubtrans.Command import Command, CommandError 5 | from GuiSubtrans.ProjectDataModel import ProjectDataModel 6 | from GuiSubtrans.ViewModel.ViewModelUpdate import ModelUpdate 7 | from PySubtrans.Helpers.Localization import _ 8 | from PySubtrans.SubtitleBatch import SubtitleBatch 9 | from PySubtrans.SubtitleLine import SubtitleLine 10 | from PySubtrans.SubtitleProject import SubtitleProject 11 | from PySubtrans.SubtitleValidator import SubtitleValidator 12 | 13 | if TYPE_CHECKING: 14 | from PySubtrans.SubtitleEditor import SubtitleEditor 15 | 16 | class DeleteLinesCommand(Command): 17 | """ 18 | Delete one or several lines 19 | """ 20 | def __init__(self, line_numbers : list[int], datamodel: ProjectDataModel|None = None): 21 | super().__init__(datamodel) 22 | self.line_numbers : list[int] = line_numbers 23 | self.deletions : list[tuple[int, int, list[SubtitleLine], list[SubtitleLine]]] = [] 24 | 25 | def execute(self) -> bool: 26 | if not self.line_numbers: 27 | raise CommandError(_("No lines selected to delete"), command=self) 28 | 29 | logging.info(_("Deleting lines {lines}").format(lines=str(self.line_numbers))) 30 | 31 | if not self.datamodel or not self.datamodel.project: 32 | raise CommandError(_("No project data"), command=self) 33 | 34 | project : SubtitleProject = self.datamodel.project 35 | 36 | if not project.subtitles: 37 | raise CommandError(_("No subtitles"), command=self) 38 | 39 | with project.GetEditor() as editor: 40 | self.deletions = editor.DeleteLines(self.line_numbers) 41 | 42 | if not self.deletions: 43 | raise CommandError(_("No lines were deleted"), command=self) 44 | 45 | # Update the viewmodel. Priginal and translated lines are currently linked, deleting one means deleting both 46 | model_update : ModelUpdate = self.AddModelUpdate() 47 | for deletion in self.deletions: 48 | scene_number, batch_number, originals, translated = deletion # type: ignore[unused-ignore] 49 | for line in originals: 50 | model_update.lines.remove((scene_number, batch_number, line.number)) 51 | 52 | batch = project.subtitles.GetBatch(scene_number, batch_number) 53 | if batch.errors: 54 | validator = SubtitleValidator(self.datamodel.project_options) 55 | validator.ValidateBatch(batch) 56 | model_update.batches.update((scene_number, batch_number), {'errors': batch.error_messages}) 57 | 58 | return True 59 | 60 | def undo(self): 61 | if not self.deletions: 62 | raise CommandError(_("No deletions to undo"), command=self) 63 | 64 | if not self.datamodel or not self.datamodel.project: 65 | raise CommandError(_("No project data"), command=self) 66 | 67 | logging.info(_("Restoring deleted lines")) 68 | project : SubtitleProject = self.datamodel.project 69 | subtitles = project.subtitles 70 | 71 | model_update : ModelUpdate = self.AddModelUpdate() 72 | 73 | for scene_number, batch_number, deleted_originals, deleted_translated in self.deletions: 74 | batch : SubtitleBatch = subtitles.GetBatch(scene_number, batch_number) 75 | batch.InsertLines(deleted_originals, deleted_translated) 76 | 77 | for line in deleted_originals: 78 | translated : SubtitleLine|None = next((translated for translated in deleted_translated if translated.number == line.number), None) 79 | if translated: 80 | line.translated = translated 81 | 82 | model_update.lines.add((scene_number, batch_number, line.number), line) 83 | 84 | return True 85 | -------------------------------------------------------------------------------- /GuiSubtrans/Commands/EditBatchCommand.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from GuiSubtrans.Command import Command, CommandError 3 | from GuiSubtrans.ProjectDataModel import ProjectDataModel 4 | from GuiSubtrans.ViewModel.ViewModelUpdate import ModelUpdate 5 | from GuiSubtrans.ViewModel.ViewModelUpdateSection import UpdateValue 6 | from PySubtrans.SubtitleBatch import SubtitleBatch 7 | from PySubtrans.Subtitles import Subtitles 8 | 9 | import logging 10 | from PySubtrans.Helpers.Localization import _ 11 | 12 | class EditBatchCommand(Command): 13 | def __init__(self, scene_number : int, batch_number : int, edit : dict[str, UpdateValue], datamodel : ProjectDataModel|None = None): 14 | super().__init__(datamodel) 15 | self.scene_number = scene_number 16 | self.batch_number = batch_number 17 | self.edit : dict[str, UpdateValue] = deepcopy(edit) 18 | self.undo_data = None 19 | 20 | def execute(self) -> bool: 21 | logging.debug(_("Editing batch ({scene},{batch})").format(scene=self.scene_number, batch=self.batch_number)) 22 | 23 | if not self.datamodel or not self.datamodel.project: 24 | raise CommandError(_("No project data"), command=self) 25 | 26 | subtitles : Subtitles = self.datamodel.project.subtitles 27 | if not subtitles: 28 | raise CommandError(_("Unable to edit batch because datamodel is invalid"), command=self) 29 | 30 | if not isinstance(self.edit, dict): 31 | raise CommandError(_("Edit data must be a dictionary"), command=self) 32 | 33 | with subtitles.lock: 34 | batch : SubtitleBatch = subtitles.GetBatch(self.scene_number, self.batch_number) 35 | if not batch: 36 | raise CommandError(_("Batch ({scene},{batch}) not found").format(scene=self.scene_number, batch=self.batch_number), command=self) 37 | 38 | self.undo_data = { 39 | "summary": batch.summary, 40 | } 41 | 42 | edit_summary : UpdateValue = self.edit.get('summary') 43 | if isinstance(edit_summary, str): 44 | batch.summary = edit_summary 45 | 46 | self._update_viewmodel(batch) 47 | 48 | return True 49 | 50 | def undo(self): 51 | logging.debug(_("Undoing edit batch ({scene},{batch})").format(scene=self.scene_number, batch=self.batch_number)) 52 | 53 | if not self.datamodel or not self.datamodel.project: 54 | raise CommandError(_("No project data"), command=self) 55 | 56 | if not self.undo_data: 57 | raise CommandError(_("No undo data available"), command=self) 58 | 59 | subtitles : Subtitles = self.datamodel.project.subtitles 60 | 61 | with subtitles.lock: 62 | batch : SubtitleBatch = subtitles.GetBatch(self.scene_number, self.batch_number) 63 | if not batch: 64 | raise CommandError(_("Batch ({scene},{batch}) not found").format(scene=self.scene_number, batch=self.batch_number), command=self) 65 | 66 | batch.summary = self.undo_data.get('summary', batch.summary) 67 | 68 | self._update_viewmodel(batch) 69 | 70 | return True 71 | 72 | def _update_viewmodel(self, batch : SubtitleBatch): 73 | viewmodel_update : ModelUpdate = self.AddModelUpdate() 74 | viewmodel_update.batches.update((self.scene_number, self.batch_number), { 'summary': batch.summary }) 75 | 76 | -------------------------------------------------------------------------------- /GuiSubtrans/Commands/EditSceneCommand.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from copy import deepcopy 3 | from GuiSubtrans.Command import Command, CommandError 4 | from GuiSubtrans.ProjectDataModel import ProjectDataModel 5 | from GuiSubtrans.ViewModel.ViewModelUpdate import ModelUpdate 6 | from PySubtrans.Helpers.Localization import _ 7 | from PySubtrans.Subtitles import Subtitles 8 | from PySubtrans.SubtitleScene import SubtitleScene 9 | 10 | class EditSceneCommand(Command): 11 | def __init__(self, scene_number : int, edit : dict, datamodel : ProjectDataModel|None = None): 12 | super().__init__(datamodel) 13 | self.scene_number = scene_number 14 | self.edit = deepcopy(edit) 15 | self.undo_data = None 16 | 17 | def execute(self) -> bool: 18 | logging.debug(f"Editing scene {self.scene_number}") 19 | 20 | if not self.datamodel or not self.datamodel.project: 21 | raise CommandError(_("No project data"), command=self) 22 | 23 | subtitles : Subtitles = self.datamodel.project.subtitles 24 | if not subtitles: 25 | raise CommandError("Unable to edit scene because datamodel is invalid", command=self) 26 | 27 | if not isinstance(self.edit, dict): 28 | raise CommandError("Edit data must be a dictionary", command=self) 29 | 30 | with subtitles.lock: 31 | scene : SubtitleScene = subtitles.GetScene(self.scene_number) 32 | if not scene: 33 | raise CommandError(f"Scene {self.scene_number} not found", command=self) 34 | 35 | self.undo_data = { 36 | "summary": scene.summary, 37 | } 38 | 39 | scene.summary = self.edit.get('summary', scene.summary) 40 | 41 | self._update_viewmodel(scene) 42 | 43 | return True 44 | 45 | def undo(self): 46 | logging.debug(f"Undoing edit scene {self.scene_number}") 47 | 48 | if not self.datamodel or not self.datamodel.project: 49 | raise CommandError(_("No project data"), command=self) 50 | 51 | if not self.undo_data: 52 | raise CommandError(_("No undo data available"), command=self) 53 | 54 | subtitles : Subtitles = self.datamodel.project.subtitles 55 | 56 | with subtitles.lock: 57 | scene = subtitles.GetScene(self.scene_number) 58 | if not scene: 59 | raise CommandError(f"Scene {self.scene_number} not found", command=self) 60 | 61 | scene.summary = self.undo_data.get('summary', scene.summary) 62 | 63 | self._update_viewmodel(scene) 64 | 65 | return True 66 | 67 | def _update_viewmodel(self, scene : SubtitleScene): 68 | viewmodel_update : ModelUpdate = self.AddModelUpdate() 69 | viewmodel_update.scenes.update(self.scene_number, { 'summary': scene.summary }) 70 | -------------------------------------------------------------------------------- /GuiSubtrans/Commands/LoadSubtitleFile.py: -------------------------------------------------------------------------------- 1 | from GuiSubtrans.Command import Command, CommandError 2 | from GuiSubtrans.ProjectDataModel import ProjectDataModel 3 | from PySubtrans.Options import Options 4 | from PySubtrans.SubtitleProject import SubtitleProject 5 | from PySubtrans.Helpers.Localization import _ 6 | 7 | import logging 8 | 9 | class LoadSubtitleFile(Command): 10 | def __init__(self, filepath, options : Options, reload_subtitles : bool = False): 11 | super().__init__() 12 | self.filepath = filepath 13 | self.project : SubtitleProject|None = None 14 | self.options : Options = Options(options) 15 | self.reload_subtitles = reload_subtitles 16 | self.write_backup = self.options.get('write_backup', False) 17 | self.can_undo = False 18 | self.mark_project_dirty = False 19 | 20 | def execute(self) -> bool: 21 | logging.debug(_("Executing LoadSubtitleFile {file}").format(file=self.filepath)) 22 | 23 | if not self.filepath: 24 | raise CommandError(_("No file path specified"), command=self) 25 | 26 | try: 27 | project = SubtitleProject(persistent=self.options.use_project_file) 28 | project.InitialiseProject(self.filepath, reload_subtitles=self.reload_subtitles) 29 | 30 | if not project.subtitles: 31 | raise CommandError(_("Unable to load subtitles from {file}").format(file=self.filepath), command=self) 32 | 33 | # Write a backup if an existing project was loaded 34 | if self.write_backup and project.existing_project: 35 | logging.info(_("Saving backup copy of the project")) 36 | project.SaveBackupFile() 37 | 38 | self.project = project 39 | self.datamodel = ProjectDataModel(project, self.options) 40 | 41 | if self.datamodel.is_project_initialised: 42 | self.datamodel.CreateViewModel() 43 | 44 | return True 45 | 46 | except Exception as e: 47 | raise CommandError(_("Unable to load {file} ({error})").format(file=self.filepath, error=str(e)), command=self) 48 | -------------------------------------------------------------------------------- /GuiSubtrans/Commands/MergeBatchesCommand.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING 3 | 4 | from GuiSubtrans.Command import Command, CommandError 5 | from GuiSubtrans.ProjectDataModel import ProjectDataModel 6 | from GuiSubtrans.ViewModel.ViewModelUpdate import ModelUpdate 7 | from PySubtrans.Helpers.Localization import _ 8 | from PySubtrans.SubtitleBatch import SubtitleBatch 9 | from PySubtrans.SubtitleProject import SubtitleProject 10 | from PySubtrans.SubtitleValidator import SubtitleValidator 11 | 12 | if TYPE_CHECKING: 13 | from PySubtrans.SubtitleEditor import SubtitleEditor 14 | 15 | class MergeBatchesCommand(Command): 16 | """ 17 | Combine multiple batches into one 18 | """ 19 | def __init__(self, scene_number: int, batch_numbers: list[int], datamodel: ProjectDataModel|None = None): 20 | super().__init__(datamodel) 21 | self.scene_number : int = scene_number 22 | self.batch_numbers : list[int] = sorted(batch_numbers) 23 | self.original_first_line_numbers : list[int] = [] 24 | self.original_summaries : dict[int, str] = {} 25 | 26 | def execute(self) -> bool: 27 | logging.info(f"Merging scene {str(self.scene_number)} batches: {','.join(str(x) for x in self.batch_numbers)}") 28 | 29 | if not self.datamodel or not self.datamodel.project: 30 | raise CommandError(_("No project data"), command=self) 31 | 32 | project: SubtitleProject = self.datamodel.project 33 | scene = project.subtitles.GetScene(self.scene_number) 34 | 35 | if len(self.batch_numbers) > 1: 36 | merged_batch_number = self.batch_numbers[0] 37 | 38 | original_batches = [scene.GetBatch(batch_number) for batch_number in self.batch_numbers] 39 | 40 | if any(b is None or b.scene != scene.number or b.first_line_number is None or b.last_line_number is None for b in original_batches): 41 | raise CommandError(_("Invalid batch"), command=self) 42 | 43 | self.original_first_line_numbers = [batch.first_line_number for batch in original_batches if batch and batch.first_line_number] 44 | self.original_summaries = {batch.number: batch.summary for batch in original_batches if batch and batch.summary} 45 | 46 | with project.GetEditor() as editor: 47 | editor.MergeBatches(self.scene_number, self.batch_numbers) 48 | 49 | merged_batch = scene.GetBatch(merged_batch_number) 50 | if merged_batch is not None: 51 | if merged_batch.any_translated: 52 | validator = SubtitleValidator(self.datamodel.project_options) 53 | validator.ValidateBatch(merged_batch) 54 | 55 | model_update : ModelUpdate = self.AddModelUpdate() 56 | model_update.batches.replace((scene.number, merged_batch_number), merged_batch) 57 | for batch_number in self.batch_numbers[1:]: 58 | model_update.batches.remove((scene.number, batch_number)) 59 | 60 | return True 61 | 62 | def undo(self): 63 | if not self.datamodel or not self.datamodel.project: 64 | raise CommandError(_("No project data"), command=self) 65 | 66 | project: SubtitleProject = self.datamodel.project 67 | scene = project.subtitles.GetScene(self.scene_number) 68 | 69 | # Split the merged batch back into the original batches using the stored first line numbers 70 | for i in range(0, len(self.batch_numbers) - 1): 71 | scene.SplitBatch(self.batch_numbers[i], self.original_first_line_numbers[i+1]) 72 | 73 | # Restore the original summaries 74 | for batch_number, summary in self.original_summaries.items(): 75 | batch : SubtitleBatch|None = scene.GetBatch(batch_number) 76 | if batch: 77 | batch.summary = summary 78 | 79 | model_update : ModelUpdate = self.AddModelUpdate() 80 | model_update.scenes.replace(scene.number, scene) 81 | 82 | return True 83 | -------------------------------------------------------------------------------- /GuiSubtrans/Commands/SaveProjectFile.py: -------------------------------------------------------------------------------- 1 | from GuiSubtrans.Command import Command, CommandError 2 | from PySubtrans.Helpers.Localization import _ 3 | from PySubtrans.SubtitleProject import SubtitleProject 4 | 5 | class SaveProjectFile(Command): 6 | def __init__(self, project : SubtitleProject, filepath : str|None = None): 7 | super().__init__() 8 | self.skip_undo = True 9 | self.is_blocking = True 10 | self.mark_project_dirty = False 11 | self.project : SubtitleProject = project 12 | self.filepath : str|None = filepath or project.projectfile 13 | 14 | def execute(self) -> bool: 15 | if not self.filepath: 16 | raise CommandError(_("Project file path must be specified."), command=self) 17 | 18 | if not self.datamodel or not self.datamodel.project: 19 | raise CommandError(_("No project data"), command=self) 20 | 21 | current_filepath = self.datamodel.project.projectfile 22 | current_outputpath = self.datamodel.project.subtitles.outputpath 23 | 24 | # Update the project path and set the subtitle output path to the same location 25 | self.project.projectfile = self.project.GetProjectFilepath(self.filepath) 26 | self.project.UpdateOutputPath(path=self.project.projectfile, extension=self.project.subtitles.file_format) 27 | 28 | if current_filepath != self.project.projectfile or current_outputpath != self.project.subtitles.outputpath: 29 | self.project.needs_writing = True 30 | 31 | self.project.SaveProject() 32 | 33 | return True 34 | -------------------------------------------------------------------------------- /GuiSubtrans/Commands/SaveSubtitleFile.py: -------------------------------------------------------------------------------- 1 | from GuiSubtrans.Command import Command, CommandError 2 | from PySubtrans.Helpers.Localization import _ 3 | from PySubtrans.SubtitleProject import SubtitleProject 4 | 5 | class SaveSubtitleFile(Command): 6 | def __init__(self, filepath, project : SubtitleProject): 7 | super().__init__() 8 | self.filepath = filepath 9 | self.project = project 10 | self.mark_project_dirty = False 11 | self.skip_undo = True 12 | 13 | def execute(self) -> bool: 14 | self.project.SaveOriginal(self.filepath) 15 | return True -------------------------------------------------------------------------------- /GuiSubtrans/Commands/SaveTranslationFile.py: -------------------------------------------------------------------------------- 1 | from GuiSubtrans.Command import Command 2 | from PySubtrans.SubtitleProject import SubtitleProject 3 | 4 | class SaveTranslationFile(Command): 5 | def __init__(self, project : SubtitleProject, filepath : str|None = None): 6 | super().__init__() 7 | self.filepath = filepath or project.subtitles.outputpath 8 | self.project = project 9 | self.skip_undo = True 10 | self.mark_project_dirty = False 11 | 12 | def execute(self) -> bool: 13 | self.project.SaveTranslation(self.filepath) 14 | return True -------------------------------------------------------------------------------- /GuiSubtrans/Commands/SplitSceneCommand.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING 3 | 4 | from GuiSubtrans.Command import Command, CommandError 5 | from GuiSubtrans.ProjectDataModel import ProjectDataModel 6 | from GuiSubtrans.ViewModel.ViewModelUpdate import ModelUpdate 7 | from PySubtrans.Helpers.Localization import _ 8 | from PySubtrans.SubtitleProject import SubtitleProject 9 | 10 | if TYPE_CHECKING: 11 | from PySubtrans.SubtitleEditor import SubtitleEditor 12 | 13 | class SplitSceneCommand(Command): 14 | def __init__(self, scene_number : int, batch_number : int, datamodel: ProjectDataModel|None = None): 15 | super().__init__(datamodel) 16 | self.scene_number = scene_number 17 | self.batch_number = batch_number 18 | 19 | def execute(self) -> bool: 20 | logging.info(_("Splitting batch {scene} at batch {batch}").format(scene=str(self.scene_number), batch=str(self.batch_number))) 21 | 22 | if not self.datamodel or not self.datamodel.project: 23 | raise CommandError(_("No project data"), command=self) 24 | 25 | project : SubtitleProject = self.datamodel.project 26 | 27 | if not project.subtitles: 28 | raise CommandError(_("No subtitles"), command=self) 29 | 30 | scene = project.subtitles.GetScene(self.scene_number) 31 | if not scene: 32 | raise CommandError(_("Cannot split scene {scene} because it doesn't exist").format(scene=self.scene_number), command=self) 33 | 34 | last_batch = scene.batches[-1].number 35 | 36 | with project.GetEditor() as editor: 37 | editor.SplitScene(self.scene_number, self.batch_number) 38 | 39 | model_update : ModelUpdate = self.AddModelUpdate() 40 | for scene_number in range(self.scene_number + 1, len(project.subtitles.scenes)): 41 | model_update.scenes.update(scene_number, { 'number' : scene_number + 1}) 42 | 43 | for batch_removed in range(self.batch_number, last_batch + 1): 44 | model_update.batches.remove((self.scene_number, batch_removed)) 45 | 46 | model_update.scenes.add(self.scene_number + 1, project.subtitles.GetScene(self.scene_number + 1)) 47 | 48 | return True 49 | 50 | def undo(self): 51 | if not self.datamodel or not self.datamodel.project: 52 | raise CommandError(_("No project data"), command=self) 53 | 54 | project: SubtitleProject = self.datamodel.project 55 | 56 | if not project.subtitles: 57 | raise CommandError(_("No subtitles"), command=self) 58 | 59 | try: 60 | scene_numbers = [self.scene_number, self.scene_number + 1] 61 | later_scenes = [scene.number for scene in project.subtitles.scenes if scene.number > scene_numbers[1]] 62 | 63 | with project.GetEditor() as editor: 64 | merged_scene = editor.MergeScenes(scene_numbers) 65 | 66 | # Recombine the split scenes 67 | model_update : ModelUpdate = self.AddModelUpdate() 68 | model_update.scenes.replace(scene_numbers[0], merged_scene) 69 | model_update.scenes.remove(scene_numbers[1]) 70 | 71 | # Renumber the later scenes (must be done after the merge to avoid conflicts) 72 | renumber_update = self.AddModelUpdate() 73 | for scene_number in later_scenes: 74 | renumber_update.scenes.update(scene_number, { 'number' : scene_number - 1 }) 75 | 76 | return True 77 | 78 | except Exception as e: 79 | raise CommandError(_("Unable to undo SplitScene command: {error}").format(error=str(e)), command=self) 80 | -------------------------------------------------------------------------------- /GuiSubtrans/Commands/StartTranslationCommand.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from GuiSubtrans.Command import Command, CommandError 4 | from GuiSubtrans.Commands.SaveProjectFile import SaveProjectFile 5 | from GuiSubtrans.Commands.SaveTranslationFile import SaveTranslationFile 6 | from GuiSubtrans.ProjectDataModel import ProjectDataModel 7 | from GuiSubtrans.Commands.TranslateSceneCommand import TranslateSceneCommand 8 | from PySubtrans.Helpers.Localization import _ 9 | 10 | from PySubtrans.Subtitles import Subtitles 11 | from PySubtrans.SubtitleProject import SubtitleProject 12 | 13 | class StartTranslationCommand(Command): 14 | def __init__(self, datamodel: ProjectDataModel|None = None, resume : bool = False, multithreaded : bool = False, scenes : dict|None = None): 15 | super().__init__(datamodel) 16 | self.multithreaded = multithreaded 17 | self.skip_undo = True 18 | self.is_blocking = True 19 | self.mark_project_dirty = False 20 | self.resume = resume 21 | self.scenes = scenes or {} 22 | 23 | def execute(self) -> bool: 24 | if not self.datamodel or not self.datamodel.project or not self.datamodel.project.subtitles: 25 | raise CommandError(_("Nothing to translate"), command=self) 26 | 27 | project : SubtitleProject = self.datamodel.project 28 | subtitles : Subtitles = project.subtitles 29 | 30 | if self.resume and subtitles.scenes and all(scene.all_translated for scene in subtitles.scenes): 31 | logging.info(_("All scenes are fully translated")) 32 | return True 33 | 34 | starting = _("Resuming") if self.resume and project.any_translated else _("Starting") 35 | threaded = _("multithreaded") if self.multithreaded else _("single threaded") 36 | logging.info(_("{starting} {threaded} translation").format(starting=starting, threaded=threaded)) 37 | 38 | previous_command : Command = self 39 | 40 | # Save the project first if it needs updating 41 | if project.needs_writing: 42 | command = SaveProjectFile(project=project) 43 | self.commands_to_queue.append(command) 44 | previous_command = command 45 | 46 | for scene in subtitles.scenes: 47 | if self.resume and scene.all_translated: 48 | continue 49 | 50 | if self.scenes and scene.number not in self.scenes: 51 | continue 52 | 53 | scene_data = self.scenes.get(scene.number, {}) 54 | batch_numbers = scene_data.get('batches', None) 55 | line_numbers = scene_data.get('lines', None) 56 | 57 | if self.resume and scene.any_translated: 58 | batches = [ batch for batch in scene.batches if not batch.all_translated ] 59 | batch_numbers = [ batch.number for batch in batches ] 60 | 61 | command = TranslateSceneCommand(scene.number, batch_numbers, line_numbers, resume=self.resume, datamodel=self.datamodel) 62 | 63 | if self.multithreaded: 64 | # Queue the commands in parallel 65 | self.commands_to_queue.append(command) 66 | else: 67 | # Queue the commands in sequence 68 | previous_command.commands_to_queue.append(command) 69 | previous_command = command 70 | 71 | if self.datamodel.autosave_enabled: 72 | if self.datamodel.use_project_file: 73 | command.commands_to_queue.append(SaveProjectFile(project=project)) 74 | else: 75 | command.commands_to_queue.append(SaveTranslationFile(project=project)) 76 | 77 | return True -------------------------------------------------------------------------------- /GuiSubtrans/Commands/SwapTextAndTranslations.py: -------------------------------------------------------------------------------- 1 | from GuiSubtrans.Command import Command, CommandError 2 | from GuiSubtrans.ProjectDataModel import ProjectDataModel 3 | from GuiSubtrans.ViewModel.ViewModelUpdate import ModelUpdate 4 | from PySubtrans.Helpers.Localization import _ 5 | from PySubtrans.Subtitles import Subtitles 6 | from PySubtrans.SubtitleProject import SubtitleProject 7 | 8 | import logging 9 | 10 | class SwapTextAndTranslations(Command): 11 | """ 12 | Test class for model updates 13 | """ 14 | def __init__(self, scene_number : int, batch_number : int, datamodel : ProjectDataModel|None = None): 15 | super().__init__(datamodel) 16 | self.scene_number = scene_number 17 | self.batch_number = batch_number 18 | 19 | def execute(self) -> bool: 20 | logging.info(f"Swapping text and translations in scene {self.scene_number} batch {self.batch_number}") 21 | self._swap_text_and_translation() 22 | 23 | return True 24 | 25 | def undo(self): 26 | logging.info(f"Undoing swap text and translations") 27 | self._swap_text_and_translation() 28 | return True 29 | 30 | def _swap_text_and_translation(self): 31 | if not self.datamodel or not self.datamodel.project: 32 | raise CommandError(_("No project data"), command=self) 33 | 34 | project : SubtitleProject = self.datamodel.project 35 | subtitles : Subtitles = project.subtitles 36 | batch = subtitles.GetBatch(self.scene_number, self.batch_number) 37 | 38 | # Swap original and translated text (only in the viewmodel) 39 | model_update : ModelUpdate = self.AddModelUpdate() 40 | for original, translated in zip(batch.originals, batch.translated): 41 | if original and translated: 42 | model_update.lines.update((batch.scene, batch.number, original.number), { 'text': translated.text, 'translation': original.text } ) 43 | 44 | -------------------------------------------------------------------------------- /GuiSubtrans/FirstRunOptions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from PySide6.QtWidgets import ( 3 | QFormLayout, 4 | QDialog, 5 | QVBoxLayout, 6 | QDialogButtonBox, 7 | QFrame, 8 | ) 9 | 10 | from GuiSubtrans.GuiHelpers import GetThemeNames 11 | from GuiSubtrans.Widgets.OptionsWidgets import CreateOptionWidget, OptionWidget 12 | from PySubtrans.Options import Options 13 | from PySubtrans.Helpers.Localization import _, get_locale_display_items 14 | from PySubtrans.Helpers.Resources import GetResourcePath 15 | import os 16 | 17 | class FirstRunOptions(QDialog): 18 | OPTIONS = { 19 | 'ui_language': ([], _("The language of the application interface")), 20 | 'target_language': (str, _("Default language to translate the subtitles to")), 21 | 'provider': ([], _("The translation provider to use")), 22 | 'theme': ([], _("Customise the appearance of gui-subtrans")) 23 | } 24 | 25 | def __init__(self, options : Options, parent=None): 26 | super().__init__(parent) 27 | self.setWindowTitle(_("First Run Options")) 28 | self.setMinimumWidth(600) 29 | 30 | self.options = Options(options) 31 | 32 | # Populate provider list 33 | self.OPTIONS['provider'] = (options.available_providers, self.OPTIONS['provider'][1]) 34 | 35 | # Populate theme list 36 | self.OPTIONS['theme'] = (['default'] + GetThemeNames(), self.OPTIONS['theme'][1]) 37 | 38 | # Populate UI languages from locales folder using the shared helper 39 | self.OPTIONS['ui_language'] = (get_locale_display_items(), self.OPTIONS['ui_language'][1]) 40 | 41 | self.controls = {} 42 | 43 | settings_widget = QFrame(self) 44 | 45 | self.form_layout = QFormLayout(settings_widget) 46 | self.form_layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) 47 | 48 | settings = self.options.GetSettings() 49 | settings['provider'] = settings.get('provider') or "OpenRouter" if "OpenRouter" in options.available_providers else options.available_providers[0] 50 | settings['ui_language'] = settings.get('ui_language') or 'en' 51 | 52 | for key, option in self.OPTIONS.items(): 53 | key_type, tooltip = option 54 | field : OptionWidget = CreateOptionWidget(key, settings.get(key), key_type, tooltip=tooltip) 55 | self.form_layout.addRow(field.name, field) 56 | self.controls[key] = field 57 | 58 | self._layout = QVBoxLayout(self) 59 | self._layout.addWidget(settings_widget) 60 | 61 | # Add Ok and Cancel buttons 62 | self.buttonBox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok, self) 63 | self.buttonBox.accepted.connect(self.accept) 64 | self._layout.addWidget(self.buttonBox) 65 | 66 | self.setLayout(self._layout) 67 | 68 | def accept(self): 69 | """ Update the settings """ 70 | for row in range(self.form_layout.rowCount()): 71 | widget = self.form_layout.itemAt(row, QFormLayout.ItemRole.FieldRole).widget() 72 | if isinstance(widget, OptionWidget): 73 | field = widget 74 | value = field.GetValue() 75 | if value: 76 | self.options.add(field.key, value) 77 | else: 78 | logging.warning(f"Unexpected widget type in form layout: {type(widget)}") 79 | 80 | super().accept() 81 | 82 | def GetSettings(self): 83 | initial_settings = self.options.GetSettings() 84 | initial_settings['firstrun'] = False 85 | return initial_settings 86 | 87 | -------------------------------------------------------------------------------- /GuiSubtrans/GUICommands.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from GuiSubtrans.Command import Command 4 | from PySubtrans.Options import Options 5 | from PySubtrans.TranslationProvider import TranslationProvider 6 | from PySubtrans.Helpers.Localization import _ 7 | 8 | class ExitProgramCommand(Command): 9 | """ 10 | Exit the program. 11 | """ 12 | def __init__(self): 13 | super().__init__() 14 | self.is_blocking = True 15 | self.can_undo = False 16 | 17 | def execute(self) -> bool: 18 | logging.info(_("Exiting Program")) 19 | return True 20 | 21 | class CheckProviderSettings(Command): 22 | """ 23 | Check if the translation provider is configured correctly. 24 | """ 25 | def __init__(self, options : Options): 26 | super().__init__() 27 | self.is_blocking = True 28 | self.skip_undo = True 29 | self.options = options 30 | self.show_provider_settings = False 31 | 32 | def execute(self) -> bool: 33 | try: 34 | if not self.datamodel: 35 | logging.warning(_("No datamodel available to check provider settings")) 36 | self.show_provider_settings = True 37 | return True 38 | 39 | translation_provider : TranslationProvider|None = self.datamodel.translation_provider 40 | if not translation_provider: 41 | logging.warning(_("Invalid translation provider")) 42 | self.show_provider_settings = True 43 | 44 | elif not translation_provider.ValidateSettings(): 45 | logging.warning(_("Provider {provider} needs configuring: {message}").format(provider=translation_provider.name, message=translation_provider.validation_message)) 46 | self.show_provider_settings = True 47 | 48 | except Exception as e: 49 | logging.error(_("CheckProviderSettings: {error}").format(error=e)) 50 | 51 | finally: 52 | return True 53 | 54 | -------------------------------------------------------------------------------- /GuiSubtrans/GuiHelpers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import darkdetect # type: ignore 4 | 5 | from PySide6.QtCore import Qt 6 | from PySide6.QtWidgets import (QApplication, QFormLayout) 7 | 8 | from PySubtrans.Helpers.Resources import GetResourcePath 9 | from PySubtrans.Helpers.Localization import _ 10 | 11 | def GetThemeNames(): 12 | themes = [] 13 | theme_path = GetResourcePath("theme") 14 | for file in os.listdir(theme_path): 15 | if file.endswith(".qss"): 16 | theme_name = os.path.splitext(file)[0] 17 | themes.append(theme_name) 18 | 19 | themes.sort() 20 | return themes 21 | 22 | def LoadStylesheet(name): 23 | if not name or name == "default": 24 | name = "subtrans-dark" if darkdetect.isDark() else "subtrans" 25 | 26 | filepath = GetResourcePath("theme", f"{name}.qss") 27 | logging.info(f"Loading stylesheet from {filepath}") 28 | with open(filepath, 'r') as file: 29 | stylesheet = file.read() 30 | 31 | app : QApplication|None = QApplication.instance() # type: ignore 32 | if app is not None: 33 | app.setStyleSheet(stylesheet) 34 | 35 | scheme : Qt.ColorScheme = Qt.ColorScheme.Dark if 'dark' in name else Qt.ColorScheme.Light 36 | app.styleHints().setColorScheme(scheme) 37 | 38 | return stylesheet 39 | 40 | def GetLineHeight(text: str, wrap_length: int = 60) -> int: 41 | """ 42 | Calculate the number of lines for a given text with wrapping and newline characters. 43 | 44 | :param text: The input text. 45 | :param wrap_length: The maximum number of characters per line. 46 | :return: The total number of lines. 47 | """ 48 | if not text: 49 | return 0 50 | 51 | wraps = -(-len(text) // wrap_length) if wrap_length else 0 # Ceiling division 52 | return text.count('\n') + wraps 53 | 54 | def DescribeLineCount(line_count : int, translated_count : int) -> str: 55 | if translated_count == 0: 56 | return _("{count} lines").format(count=line_count) 57 | elif line_count == translated_count: 58 | return _("{count} lines translated").format(count=translated_count) 59 | else: 60 | return _("{done} of {total} lines translated").format(done=translated_count, total=line_count) 61 | 62 | def ClearForm(layout : QFormLayout): 63 | """ 64 | Clear the widgets from a layout 65 | """ 66 | while layout.rowCount(): 67 | result = layout.takeRow(0) # Pylance: TakeRowResult missing attrs in stubs 68 | for attr in ("labelItem", "fieldItem"): 69 | item = getattr(result, attr, None) 70 | if item is None: 71 | continue 72 | widget = item.widget() 73 | if widget: 74 | widget.deleteLater() -------------------------------------------------------------------------------- /GuiSubtrans/GuiSubtitleTestCase.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtCore import QCoreApplication 2 | 3 | from GuiSubtrans.ProjectDataModel import ProjectDataModel 4 | from GuiSubtrans.ViewModel.TestableViewModel import TestableViewModel 5 | from PySubtrans.Helpers.TestCases import BuildSubtitlesFromLineCounts, SubtitleTestCase 6 | from PySubtrans.Subtitles import Subtitles 7 | 8 | 9 | class GuiSubtitleTestCase(SubtitleTestCase): 10 | """Test case with helpers for GUI-level ProjectDataModel interactions.""" 11 | _qt_app : QCoreApplication|None = None 12 | 13 | @classmethod 14 | def setUpClass(cls) -> None: 15 | super().setUpClass() 16 | if QCoreApplication.instance() is None: 17 | cls._qt_app = QCoreApplication([]) 18 | else: 19 | cls._qt_app = QCoreApplication.instance() 20 | 21 | def setUp(self) -> None: 22 | super().setUp() 23 | self._viewmodels_to_cleanup : list[TestableViewModel] = [] 24 | 25 | def tearDown(self) -> None: 26 | """Clean up Qt viewmodels to prevent crashes""" 27 | for viewmodel in self._viewmodels_to_cleanup: 28 | viewmodel.deleteLater() 29 | if self._viewmodels_to_cleanup: 30 | QCoreApplication.processEvents() 31 | self._viewmodels_to_cleanup.clear() 32 | super().tearDown() 33 | 34 | def create_test_subtitles(self, line_counts : list[list[int]]) -> Subtitles: 35 | """Build subtitles from line counts.""" 36 | return BuildSubtitlesFromLineCounts(line_counts) 37 | 38 | def create_project_datamodel(self, subtitles : Subtitles|None = None) -> ProjectDataModel: 39 | """Create a ProjectDataModel for the provided subtitles.""" 40 | project = self.create_subtitle_project(subtitles) 41 | return ProjectDataModel(project, self.options) 42 | 43 | def create_datamodel_from_line_counts(self, line_counts : list[list[int]]) -> tuple[ProjectDataModel, Subtitles]: 44 | """Build subtitles from line counts and wrap them in a ProjectDataModel.""" 45 | subtitles = BuildSubtitlesFromLineCounts(line_counts) 46 | datamodel = self.create_project_datamodel(subtitles) 47 | return datamodel, subtitles 48 | 49 | def create_testable_viewmodel(self, subtitles : Subtitles) -> TestableViewModel: 50 | """Create a TestableViewModel for the provided subtitles.""" 51 | viewmodel = TestableViewModel(self) 52 | viewmodel.CreateModel(subtitles) 53 | viewmodel.clear_signal_history() # Clear any signals from initial setup 54 | self._viewmodels_to_cleanup.append(viewmodel) 55 | return viewmodel 56 | 57 | def create_testable_viewmodel_from_line_counts(self, line_counts : list[list[int]]) -> TestableViewModel: 58 | """Helper to create and set up a testable view model from line counts""" 59 | subtitles = BuildSubtitlesFromLineCounts(line_counts) 60 | return self.create_testable_viewmodel(subtitles) 61 | 62 | -------------------------------------------------------------------------------- /GuiSubtrans/ProjectToolbar.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtCore import Qt 2 | from PySide6.QtGui import QAction, QIcon 3 | from PySide6.QtWidgets import QToolBar, QStyle, QApplication 4 | 5 | from GuiSubtrans.ProjectActions import ProjectActions 6 | from PySubtrans.Helpers.Localization import _ 7 | from PySubtrans.Helpers.Resources import GetResourcePath 8 | 9 | class ProjectToolbar(QToolBar): 10 | _show_options = True 11 | 12 | def __init__(self, action_handler : ProjectActions, parent=None): 13 | super().__init__(_("Project Toolbar"), parent) 14 | 15 | self.setOrientation(Qt.Orientation.Vertical) 16 | self.setMovable(False) 17 | 18 | self.action_handler = action_handler 19 | 20 | self._toggle_options_btn = QAction(self.show_setting_icon, _("Hide/Show Project Options"), self) 21 | self._toggle_options_btn.triggered.connect(self._toggle_settings) 22 | self.addAction(self._toggle_options_btn) 23 | 24 | def UpdateUiLanguage(self): 25 | """Refresh translatable text after language switch.""" 26 | self.setWindowTitle(_("Project Toolbar")) 27 | self._toggle_options_btn.setText(_("Hide/Show Project Options")) 28 | # Also refresh icon which depends on state 29 | self._toggle_options_btn.setIcon(self.show_setting_icon) 30 | 31 | def _toggle_settings(self): 32 | self.show_settings = not self.show_settings 33 | self.action_handler.ShowProjectSettings(self.show_settings) 34 | 35 | @property 36 | def show_settings(self): 37 | return self._show_options 38 | 39 | @show_settings.setter 40 | def show_settings(self, value): 41 | self._show_options = value 42 | self._toggle_options_btn.setIcon(self.show_setting_icon) 43 | 44 | @property 45 | def show_setting_icon(self): 46 | """Return the icon for the toggle settings button.""" 47 | if self.show_settings: 48 | return QApplication.style().standardIcon(QStyle.StandardPixmap.SP_ArrowLeft) 49 | else: 50 | return QApplication.style().standardIcon(QStyle.StandardPixmap.SP_ArrowRight) 51 | -------------------------------------------------------------------------------- /GuiSubtrans/ScenesBatchesDelegate.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtGui import QPainter 2 | from PySide6.QtWidgets import QStyleOptionViewItem, QStyledItemDelegate, QWidget 3 | from PySide6.QtCore import Qt, QPoint, QModelIndex, QPersistentModelIndex 4 | 5 | class ScenesBatchesDelegate(QStyledItemDelegate): 6 | def __init__(self, parent=None): 7 | super().__init__(parent) 8 | self.render_flags = QWidget.RenderFlag.DrawWindowBackground | QWidget.RenderFlag.DrawChildren 9 | 10 | def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex|QPersistentModelIndex) -> None: 11 | widget = index.data(role=Qt.ItemDataRole.DisplayRole) 12 | 13 | if widget is None: 14 | super().paint(painter, option, index) # Only call the base class if there's no widget 15 | return 16 | 17 | self.initStyleOption(option, index) 18 | 19 | # Translate painter to the top left of the rectangle provided by option 20 | painter.save() 21 | if (hasattr(option, 'rect')): 22 | rect = getattr(option, 'rect') 23 | painter.translate(rect.topLeft()) 24 | widget.setGeometry(rect) 25 | 26 | widget.render(painter, QPoint(0, 0), renderFlags=self.render_flags) 27 | painter.restore() 28 | 29 | def sizeHint(self, option, index): 30 | widget = index.data(role=Qt.ItemDataRole.DisplayRole) 31 | if widget: 32 | self.initStyleOption(option, index) 33 | widget.setGeometry(option.rect) 34 | return widget.sizeHint() 35 | else: 36 | return super().sizeHint(option, index) 37 | -------------------------------------------------------------------------------- /GuiSubtrans/ScenesBatchesModel.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtCore import Qt, QAbstractItemModel, QSortFilterProxyModel, QModelIndex, QPersistentModelIndex 2 | from PySide6.QtGui import QStandardItem 3 | 4 | from GuiSubtrans.ViewModel.ViewModel import ProjectViewModel 5 | from GuiSubtrans.ViewModel.BatchItem import BatchItem 6 | from GuiSubtrans.ViewModel.ViewModelItem import ViewModelItem 7 | from GuiSubtrans.Widgets.Widgets import TreeViewItemWidget 8 | 9 | class ScenesBatchesModel(QSortFilterProxyModel): 10 | def __init__(self, viewmodel : ProjectViewModel|None = None, parent = None): 11 | super().__init__(parent) 12 | if viewmodel: 13 | self.setSourceModel(viewmodel) 14 | 15 | def filterAcceptsRow(self, source_row : int, source_parent : QModelIndex|QPersistentModelIndex) -> bool: 16 | if not source_parent.isValid(): 17 | return True 18 | 19 | viewmodel : QAbstractItemModel = self.sourceModel() 20 | if not viewmodel or not isinstance(viewmodel, ProjectViewModel): 21 | return False 22 | 23 | item = viewmodel.itemFromIndex(source_parent) 24 | 25 | if not item or isinstance(item, BatchItem): 26 | return False 27 | 28 | return True 29 | 30 | def data(self, index, role : int = Qt.ItemDataRole.DisplayRole): 31 | if not index.isValid(): 32 | return None 33 | 34 | source_index = self.mapToSource(index) 35 | viewmodel : QAbstractItemModel = self.sourceModel() 36 | if not viewmodel or not isinstance(viewmodel, ProjectViewModel): 37 | return False 38 | 39 | item : QStandardItem = viewmodel.itemFromIndex(source_index) 40 | if not isinstance(item, ViewModelItem): 41 | return None 42 | 43 | if role == Qt.ItemDataRole.UserRole: 44 | return item 45 | 46 | if role == Qt.ItemDataRole.DisplayRole: 47 | return TreeViewItemWidget(item.GetContent()) 48 | 49 | if role == Qt.ItemDataRole.SizeHintRole: 50 | return TreeViewItemWidget(item.GetContent()).sizeHint() 51 | 52 | return None 53 | -------------------------------------------------------------------------------- /GuiSubtrans/SubtitleItemDelegate.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from PySide6.QtCore import QModelIndex, QPersistentModelIndex, Qt, QPoint 3 | from PySide6.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem, QWidget 4 | 5 | from GuiSubtrans.Widgets.Widgets import LineItemView 6 | 7 | class SubtitleItemDelegate(QStyledItemDelegate): 8 | def __init__(self, parent=None): 9 | super().__init__(parent) 10 | self.render_flags = QWidget.RenderFlag.DrawWindowBackground | QWidget.RenderFlag.DrawChildren 11 | 12 | def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex|QPersistentModelIndex) -> QWidget: 13 | return QWidget(parent) # Return an empty widget to avoid editing 14 | 15 | def paint(self, painter, option, index): 16 | if not index.isValid() or index.column() != 0: 17 | return super().paint(painter, option, index) 18 | 19 | widget = index.data(Qt.ItemDataRole.DisplayRole) 20 | if not isinstance(widget, LineItemView): 21 | return super().paint(painter, option, index) 22 | 23 | try: 24 | self.initStyleOption(option, index) 25 | 26 | painter.save() 27 | painter.translate(option.rect.topLeft()) 28 | widget.setGeometry(option.rect) 29 | widget.render(painter, QPoint(0,0), renderFlags=self.render_flags) 30 | painter.restore() 31 | except Exception as e: 32 | logging.error(f"Error while painting LineItemView: {e}") 33 | 34 | super().paint(painter, option, index) 35 | -------------------------------------------------------------------------------- /GuiSubtrans/ViewModel/ViewModelError.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from PySubtrans.SubtitleError import SubtitleError 4 | 5 | class ViewModelError(SubtitleError): 6 | def __init__(self, message: str, error: Any = None): 7 | super().__init__(message, error) -------------------------------------------------------------------------------- /GuiSubtrans/ViewModel/ViewModelItem.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from PySide6.QtGui import QStandardItem 4 | 5 | class ViewModelItem(QStandardItem): 6 | def GetContent(self) -> dict[str, Any]: 7 | return { 8 | 'heading': "Item Heading", 9 | 'subheading': "Optional Subheading", 10 | 'body': "Body Content", 11 | 'properties': {} 12 | } -------------------------------------------------------------------------------- /GuiSubtrans/ViewModel/ViewModelUpdateSection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TypeAlias 3 | 4 | from PySubtrans.SubtitleBatch import SubtitleBatch 5 | from PySubtrans.SubtitleLine import SubtitleLine 6 | from PySubtrans.SubtitleScene import SubtitleScene 7 | from PySubtrans.Translation import Translation 8 | from PySubtrans.TranslationPrompt import TranslationPrompt 9 | 10 | SceneKey = int 11 | BatchKey = tuple[int, int] 12 | LineKey = tuple[int, int, int] 13 | 14 | Key : TypeAlias = SceneKey | BatchKey | LineKey 15 | Value : TypeAlias = str | int | float | list[str] | dict[str, str|list|dict] | Translation | TranslationPrompt | None 16 | UpdateValue : TypeAlias = dict[str, 'UpdateValue'] | dict[int, 'UpdateValue'] | Value 17 | UpdateType : TypeAlias = dict[Key, UpdateValue] 18 | ModelTypes : TypeAlias = SubtitleLine | SubtitleBatch | SubtitleScene 19 | 20 | class ModelUpdateSection: 21 | def __init__(self): 22 | self.updates: UpdateType = {} 23 | self.replacements: dict[Key, ModelTypes] = {} 24 | self.additions: dict[Key, ModelTypes] = {} 25 | self.removals: list[SceneKey]|list[BatchKey]|list[LineKey]|list[int] = [] 26 | 27 | def update(self, key: Key, item_update: UpdateValue) -> None: 28 | self.updates[key] = item_update 29 | 30 | def replace(self, key: Key, item: ModelTypes) -> None: 31 | self.replacements[key] = item 32 | 33 | def add(self, key: Key, item: ModelTypes) -> None: 34 | self.additions[key] = item 35 | 36 | def remove(self, key: Key) -> None: 37 | if any(type(existing_key) != type(key) for existing_key in self.removals): 38 | raise ValueError(f"All removal keys must be of the same type: {type(key)}") 39 | self.removals.append(key) # type: ignore[list-item] 40 | 41 | @property 42 | def has_updates(self) -> bool: 43 | return bool(self.updates or self.replacements or self.additions or self.removals) 44 | 45 | @property 46 | def size_changed(self) -> bool: 47 | return bool(self.removals or self.additions) -------------------------------------------------------------------------------- /GuiSubtrans/Widgets/LogWindow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from PySide6.QtCore import Qt, QObject, Signal, Slot 4 | from PySide6.QtGui import QTextCursor, QTextCharFormat, QColor 5 | from PySide6.QtWidgets import QTextEdit 6 | 7 | class LogWindow(QTextEdit): 8 | enqueue = Signal(str, str) 9 | 10 | LEVEL_COLORS = { 11 | "DEBUG": QColor(135, 206, 250), 12 | "INFO": QColor(255, 255, 255), 13 | "WARNING": QColor(255, 255, 0), 14 | "ERROR": QColor(255, 0, 0), 15 | "CRITICAL":QColor(255, 0, 255) 16 | } 17 | 18 | def __init__(self, parent=None): 19 | super().__init__(parent) 20 | self.setReadOnly(True) 21 | 22 | self.enqueue.connect(self._append, Qt.ConnectionType.QueuedConnection) 23 | 24 | handler = QtLogHandler(self) 25 | handler.setFormatter(logging.Formatter( 26 | '%(asctime)s - %(message)s', datefmt='%A, %H:%M:%S')) 27 | logging.getLogger().addHandler(handler) 28 | 29 | @Slot(str, str) 30 | def _append(self, msg: str, level: str): 31 | try: 32 | scrollbar = self.verticalScrollBar() 33 | autoscroll = scrollbar.value() == scrollbar.maximum() or self.textCursor().atEnd() 34 | 35 | cursor = self.textCursor() 36 | cursor.movePosition(QTextCursor.MoveOperation.End) 37 | 38 | text_format = QTextCharFormat() 39 | text_format.setForeground(self.LEVEL_COLORS.get(level, QColor(0, 0, 0))) 40 | cursor.insertText(msg + '\n', text_format) 41 | 42 | if autoscroll: 43 | scrollbar.setValue(scrollbar.maximum()) 44 | except: 45 | pass 46 | 47 | class QtLogHandler(logging.Handler): 48 | def __init__(self, log_window : LogWindow): 49 | super().__init__(logging.INFO) 50 | self._log_window = log_window 51 | 52 | def emit(self, record): 53 | try: 54 | msg = self.format(record) 55 | self._log_window.enqueue.emit(msg, record.levelname) 56 | except Exception: 57 | self.handleError(record) 58 | 59 | -------------------------------------------------------------------------------- /PySubtrans/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to PySubtrans will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.5.2] - 2025-01-15 9 | - Streaming response support for real-time translation updates 10 | 11 | ## [1.5.1] - 2025-01-10 12 | - Updated documentation 13 | 14 | ## [1.5.0] - 2025-01-05 15 | - Initial release of PySubtrans, the Subtitle translation engine powering LLM-Subtrans and GUI-Subtrans 16 | - Integration with major LLM providers (OpenAI, Gemini, Claude, OpenRouter, DeepSeek) 17 | - Support for multiple subtitle formats (SRT, ASS, SSA, VTT) 18 | - Subtitle preprocessing and batching capabilities 19 | - Persistent project support 20 | 21 | [1.5.2]: https://github.com/machinewrapped/llm-subtrans/releases/tag/v1.5.2 22 | [1.5.1]: https://github.com/machinewrapped/llm-subtrans/releases/tag/v1.5.1 23 | [1.5.0]: https://github.com/machinewrapped/llm-subtrans/releases/tag/v1.5.0 24 | -------------------------------------------------------------------------------- /PySubtrans/Formats/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | PySubtrans.Formats - Format-specific file handlers 3 | 4 | This module contains all file format handling logic, isolating format-specific 5 | dependencies from the core business logic. 6 | """ 7 | 8 | # Explicitly import all format handler modules to ensure they're registered 9 | # This is required for pip-installed packages where dynamic discovery may fail 10 | from . import SrtFileHandler 11 | from . import SSAFileHandler 12 | from . import VttFileHandler -------------------------------------------------------------------------------- /PySubtrans/Helpers/Color.py: -------------------------------------------------------------------------------- 1 | class Color: 2 | """ 3 | Simple color representation for JSON serialization. 4 | Supports conversion to/from #RRGGBBAA format. 5 | """ 6 | 7 | def __init__(self, r : int, g : int, b : int, a : int = 0): 8 | self.r = max(0, min(255, r)) 9 | self.g = max(0, min(255, g)) 10 | self.b = max(0, min(255, b)) 11 | self.a = max(0, min(255, a)) 12 | 13 | def __eq__(self, value: object) -> bool: 14 | if not isinstance(value, Color): 15 | return False 16 | 17 | return (self.r, self.g, self.b, self.a) == (value.r, value.g, value.b, value.a) 18 | 19 | def __repr__(self) -> str: 20 | return f"Color(r={self.r}, g={self.g}, b={self.b}, a={self.a})" 21 | 22 | @classmethod 23 | def from_hex(cls, hex_str : str) -> 'Color': 24 | """Create Color from #RRGGBB or #RRGGBBAA format""" 25 | hex_str = hex_str.lstrip('#') 26 | if len(hex_str) == 6: 27 | hex_str += '00' # Add full alpha if not specified, making it opaque 28 | elif len(hex_str) != 8: 29 | raise ValueError(f"Invalid hex color format: #{hex_str}") 30 | 31 | return cls( 32 | int(hex_str[0:2], 16), 33 | int(hex_str[2:4], 16), 34 | int(hex_str[4:6], 16), 35 | int(hex_str[6:8], 16) 36 | ) 37 | 38 | def to_hex(self) -> str: 39 | """Convert to #RRGGBBAA format""" 40 | return f"#{self.r:02X}{self.g:02X}{self.b:02X}{self.a:02X}" 41 | 42 | def to_dict(self) -> dict: 43 | """Convert to JSON-serializable dict""" 44 | return {'r': self.r, 'g': self.g, 'b': self.b, 'a': self.a} 45 | 46 | @classmethod 47 | def from_dict(cls, d : dict) -> 'Color': 48 | """Create from dict""" 49 | return cls(d['r'], d['g'], d['b'], d.get('a', 0)) -------------------------------------------------------------------------------- /PySubtrans/Helpers/ContextHelpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, TYPE_CHECKING 4 | 5 | from PySubtrans.Helpers.Parse import ParseNames 6 | from PySubtrans.SubtitleError import SubtitleError 7 | 8 | if TYPE_CHECKING: 9 | from PySubtrans.Subtitles import Subtitles 10 | 11 | 12 | def GetBatchContext(subtitles: Subtitles, scene_number: int, batch_number: int, max_lines: int|None = None) -> dict[str, Any]: 13 | """ 14 | Get context for a batch of subtitles, by extracting summaries from previous scenes and batches 15 | """ 16 | with subtitles.lock: 17 | scene = subtitles.GetScene(scene_number) 18 | if not scene: 19 | raise SubtitleError(f"Failed to find scene {scene_number}") 20 | 21 | batch = subtitles.GetBatch(scene_number, batch_number) 22 | if not batch: 23 | raise SubtitleError(f"Failed to find batch {batch_number} in scene {scene_number}") 24 | 25 | context : dict[str,Any] = { 26 | 'scene_number': scene.number, 27 | 'batch_number': batch.number, 28 | 'scene': f"Scene {scene.number}: {scene.summary}" if scene.summary else f"Scene {scene.number}", 29 | 'batch': f"Batch {batch.number}: {batch.summary}" if batch.summary else f"Batch {batch.number}" 30 | } 31 | 32 | if 'movie_name' in subtitles.settings: 33 | context['movie_name'] = subtitles.settings.get_str('movie_name') 34 | 35 | if 'description' in subtitles.settings: 36 | context['description'] = subtitles.settings.get_str('description') 37 | 38 | if 'names' in subtitles.settings: 39 | context['names'] = ParseNames(subtitles.settings.get('names', [])) 40 | 41 | history_lines = GetHistory(subtitles, scene_number, batch_number, max_lines) 42 | 43 | if history_lines: 44 | context['history'] = history_lines 45 | 46 | return context 47 | 48 | 49 | def GetHistory(subtitles: Subtitles, scene_number: int, batch_number: int, max_lines: int|None = None) -> list[str]: 50 | """ 51 | Get a list of historical summaries up to a given scene and batch number 52 | """ 53 | history_lines : list[str] = [] 54 | last_summary : str = "" 55 | 56 | scenes = [scene for scene in subtitles.scenes if scene.number and scene.number < scene_number] 57 | for scene in [scene for scene in scenes if scene.summary]: 58 | if scene.summary != last_summary: 59 | history_lines.append(f"scene {scene.number}: {scene.summary}") 60 | last_summary = scene.summary or "" 61 | 62 | batches = [batch for batch in subtitles.GetScene(scene_number).batches if batch.number is not None and batch.number < batch_number] 63 | for batch in [batch for batch in batches if batch.summary]: 64 | if batch.summary != last_summary: 65 | history_lines.append(f"scene {batch.scene} batch {batch.number}: {batch.summary}") 66 | last_summary = batch.summary or "" 67 | 68 | if max_lines: 69 | history_lines = history_lines[-max_lines:] 70 | 71 | return history_lines 72 | -------------------------------------------------------------------------------- /PySubtrans/Helpers/Resources.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import sys 4 | import appdirs # type: ignore 5 | 6 | old_config_dir : str = appdirs.user_config_dir("GPTSubtrans", "MachineWrapped", roaming=True) 7 | config_dir : str = appdirs.user_config_dir("LLMSubtrans", "MachineWrapped", roaming=True) 8 | 9 | def GetResourcePath(relative_path : str, *parts : str) -> str: 10 | """ 11 | Locate a resource file or folder in the application directory or the PyInstaller bundle. 12 | """ 13 | if hasattr(sys, "_MEIPASS"): 14 | # Running in a PyInstaller bundle 15 | return os.path.join(sys._MEIPASS, relative_path, *parts) # type: ignore 16 | 17 | return os.path.join(os.path.abspath("."), relative_path or "", *parts) 18 | 19 | -------------------------------------------------------------------------------- /PySubtrans/Helpers/Time.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import regex 3 | 4 | delim = r"[,.:,.。:]" 5 | 6 | timestamp_patterns = [ 7 | # Handle standard SRT timestamps 8 | r"^(?P\d{1,3}):(?P\d{1,2}):(?P\d{2})(?:,(?P\d{3}))?$", 9 | # Handle timestamps with non-standard delimiters but the same structure, with optional garbage at the end 10 | rf"^(?P\d{{1,3}}){delim}(?P\d{{1,2}}){delim}(?P\d{{1,2}})(?:{delim}(?P\d{{3}}))?($|[^0-9])", 11 | # Handle timestamps with only minutes and seconds 12 | rf"^(?P\d{{1,2}}){delim}(?P\d{{1,2}})(?:{delim}(?P\d{{3}}))?$", 13 | # Handle just seconds and optional milliseconds 14 | r"^(?P\d{1,2})(?:,(?P\d{3}))?$", 15 | ] 16 | 17 | re_timestamps = [ 18 | regex.compile(pattern) for pattern in timestamp_patterns 19 | ] 20 | 21 | def GetTimeDelta(time : datetime.timedelta|str|int|float|None, raise_exception : bool = False) -> datetime.timedelta|Exception|None: 22 | """ 23 | Ensure the input value is a timedelta, as best we can 24 | """ 25 | if time is None: 26 | return None 27 | 28 | if isinstance(time, datetime.timedelta): 29 | return time 30 | 31 | if isinstance(time, (int, float)): 32 | return datetime.timedelta(seconds=time) 33 | 34 | timestamp = str(time).strip() 35 | 36 | for pattern in re_timestamps: 37 | time_match = pattern.match(timestamp) 38 | if time_match: 39 | hours = int(time_match.group("hours")) if "hours" in time_match.groupdict() else 0 40 | minutes = int(time_match.group("minutes")) if "minutes" in time_match.groupdict() else 0 41 | seconds = int(time_match.group("seconds") or 0) 42 | milliseconds = int(time_match.group("milliseconds") or 0) 43 | 44 | return datetime.timedelta(hours=hours, minutes=minutes, seconds=seconds, milliseconds=milliseconds) 45 | 46 | error = ValueError(f"Invalid time format: {time}") 47 | if raise_exception: 48 | raise error 49 | 50 | return error 51 | 52 | def GetTimeDeltaSafe(time : datetime.timedelta|str|int|float|None) -> datetime.timedelta|None: 53 | """ 54 | Ensure the input value is a timedelta, raising an exception if it cannot be parsed. 55 | """ 56 | timedelta = GetTimeDelta(time, raise_exception=False) 57 | if isinstance(timedelta, Exception): 58 | raise timedelta 59 | else: 60 | return timedelta 61 | 62 | def TimedeltaToText(time: datetime.timedelta|None, include_milliseconds : bool = True) -> str: 63 | """ 64 | Convert a timedelta to a minimal string representation, adhering to specific formatting rules: 65 | - Hours, minutes, and seconds may appear with leading zeros only as required. 66 | - Milliseconds are appended after a comma if they are present. 67 | """ 68 | if time is None: 69 | return "" 70 | 71 | negative = time < datetime.timedelta() 72 | absolute_time = abs(time) 73 | 74 | total_seconds = int(absolute_time.total_seconds()) 75 | milliseconds = absolute_time.microseconds // 1000 76 | 77 | hours, remainder = divmod(total_seconds, 3600) 78 | minutes, seconds = divmod(remainder, 60) 79 | 80 | if hours > 0: 81 | time_str = f"{hours}:{minutes:02d}:{seconds:02d}" 82 | elif minutes > 0: 83 | time_str = f"{minutes:02d}:{seconds:02d}" 84 | else: 85 | time_str = f"{seconds:02d}" 86 | 87 | if include_milliseconds: 88 | time_str += f",{milliseconds:03d}" 89 | 90 | if negative and time_str: 91 | time_str = f"-{time_str}" 92 | 93 | return time_str 94 | 95 | def TimedeltaToSrtTimestamp(time: datetime.timedelta|str|None) -> str|None: 96 | """ 97 | Convert a timedelta to a string suitable for SRT timestamps. 98 | """ 99 | if time is None: 100 | return None 101 | 102 | tdelta : datetime.timedelta|Exception|None = time if isinstance(time, datetime.timedelta) else GetTimeDelta(time, raise_exception=True) 103 | 104 | if not isinstance(tdelta, datetime.timedelta): 105 | raise ValueError(f"Invalid timedelta: {time}") 106 | 107 | total_seconds = int(tdelta.total_seconds()) 108 | milliseconds = tdelta.microseconds // 1000 109 | 110 | hours, remainder = divmod(total_seconds, 3600) 111 | minutes, seconds = divmod(remainder, 60) 112 | 113 | return f"{hours:02}:{minutes:02}:{seconds:02},{milliseconds:03}" 114 | 115 | 116 | -------------------------------------------------------------------------------- /PySubtrans/Helpers/Version.py: -------------------------------------------------------------------------------- 1 | 2 | def VersionNumberLessThan(version1: str, version2: str) -> bool: 3 | """Compare two version strings and return True if version1 is less than version2""" 4 | if not version1: 5 | return True 6 | 7 | # Strip 'v' prefix if present 8 | v1 = version1[1:] if version1.startswith('v') else version1 9 | v2 = version2[1:] if version2.startswith('v') else version2 10 | 11 | # Split into components and convert to integers 12 | v1_parts = [int(x) for x in v1.split('.')] 13 | v2_parts = [int(x) for x in v2.split('.')] 14 | 15 | # Compare each component 16 | for i in range(max(len(v1_parts), len(v2_parts))): 17 | v1_part = v1_parts[i] if i < len(v1_parts) else 0 18 | v2_part = v2_parts[i] if i < len(v2_parts) else 0 19 | if v1_part < v2_part: 20 | return True 21 | if v1_part > v2_part: 22 | return False 23 | 24 | return False 25 | -------------------------------------------------------------------------------- /PySubtrans/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 machinewrapped 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PySubtrans/ProviderSettingsView.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provider settings view for type-safe mutable access to provider configurations. 3 | """ 4 | 5 | from __future__ import annotations 6 | from collections.abc import MutableMapping 7 | from typing import Any 8 | import logging 9 | 10 | from PySubtrans.SettingsType import SettingType, SettingsType 11 | from PySubtrans.Helpers.Localization import _ 12 | 13 | 14 | class ProviderSettingsView(MutableMapping[str, SettingsType]): 15 | """Type-safe mutable view of provider settings""" 16 | 17 | def __init__(self, parent_dict: dict[str, SettingType], key: str = 'provider_settings'): 18 | self._parent = parent_dict 19 | self._key = key 20 | # Ensure the key exists and is a dict 21 | if key not in parent_dict or not isinstance(parent_dict[key], dict): 22 | parent_dict[key] = SettingsType() 23 | 24 | def __getitem__(self, provider: str) -> SettingsType: 25 | provider_dict = self._parent[self._key] 26 | if not isinstance(provider_dict, dict): 27 | raise KeyError(f"Provider settings is not a dict: {type(provider_dict)}") 28 | 29 | settings = provider_dict.get(provider) 30 | if settings is None: 31 | raise KeyError(provider) 32 | 33 | if isinstance(settings, SettingsType): 34 | return settings 35 | elif isinstance(settings, dict): 36 | # Convert to SettingsType and update the parent 37 | settings_type = SettingsType(settings) 38 | provider_dict[provider] = settings_type 39 | return settings_type 40 | else: 41 | raise ValueError(f"Invalid provider settings type for {provider}: {type(settings)}") 42 | 43 | def __setitem__(self, provider: str, settings: SettingsType | dict[str, Any]) -> None: 44 | provider_dict = self._parent[self._key] 45 | if not isinstance(provider_dict, dict): 46 | self._parent[self._key] = SettingsType() 47 | provider_dict = self._parent[self._key] 48 | 49 | if not isinstance(settings, SettingsType): 50 | if isinstance(settings, dict): 51 | settings = SettingsType(settings) 52 | else: 53 | raise ValueError(f"Provider settings must be SettingsType or dict, got {type(settings)}") 54 | 55 | # Ensure provider_dict is definitely a dict before assignment 56 | if isinstance(provider_dict, dict): 57 | provider_dict[provider] = settings 58 | else: 59 | logging.error(_("Provider settings container is not a dictionary")) 60 | raise TypeError("Provider settings container is not a dictionary") 61 | 62 | def __delitem__(self, provider: str) -> None: 63 | provider_dict = self._parent[self._key] 64 | if not isinstance(provider_dict, dict): 65 | raise KeyError(provider) 66 | del provider_dict[provider] 67 | 68 | def __iter__(self): 69 | provider_dict = self._parent[self._key] 70 | if isinstance(provider_dict, dict): 71 | return iter(provider_dict) 72 | return iter([]) 73 | 74 | def __len__(self) -> int: 75 | provider_dict = self._parent[self._key] 76 | if isinstance(provider_dict, dict): 77 | return len(provider_dict) 78 | return 0 79 | 80 | def __contains__(self, provider: Any) -> bool: 81 | """Check if provider exists in settings""" 82 | if not isinstance(provider, str): 83 | return False 84 | provider_dict = self._parent[self._key] 85 | if not isinstance(provider_dict, dict): 86 | return False 87 | return provider in provider_dict 88 | 89 | def get_with_default(self, provider: str, default: SettingsType | None = None) -> SettingsType | None: 90 | """Get provider settings with optional default""" 91 | try: 92 | return self[provider] 93 | except KeyError: 94 | return default or SettingsType() if default is None else default -------------------------------------------------------------------------------- /PySubtrans/Providers/Clients/ChatGPTClient.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | from openai.types.chat import ChatCompletion # type: ignore 5 | 6 | from PySubtrans.Helpers.Localization import _ 7 | from PySubtrans.Options import SettingsType 8 | from PySubtrans.Providers.Clients.OpenAIClient import OpenAIClient 9 | from PySubtrans.SubtitleError import TranslationError, TranslationResponseError 10 | from PySubtrans.TranslationPrompt import TranslationPrompt 11 | from PySubtrans.TranslationRequest import TranslationRequest 12 | 13 | linesep = '\n' 14 | 15 | class ChatGPTClient(OpenAIClient): 16 | """ 17 | Handles chat communication with OpenAI to request translations 18 | """ 19 | def __init__(self, settings : SettingsType): 20 | settings.update({ 21 | 'supports_conversation': True, 22 | 'supports_system_messages': True 23 | }) 24 | super().__init__(settings) 25 | 26 | def _send_messages(self, request: TranslationRequest, temperature: float|None) -> dict[str, Any]|None: 27 | """ 28 | Make a request to an OpenAI-compatible API to provide a translation 29 | """ 30 | response = {} 31 | 32 | if not self.client: 33 | raise TranslationError(_("Client is not initialized")) 34 | 35 | if not self.model: 36 | raise TranslationError(_("No model specified")) 37 | 38 | if not request.prompt.content or not isinstance(request.prompt.content, list): 39 | raise TranslationError(_("No content provided for translation")) 40 | 41 | messages: list[dict] = request.prompt.content # type: ignore[arg-type] 42 | 43 | assert self.client is not None 44 | assert self.model is not None 45 | result : ChatCompletion = self.client.chat.completions.create( 46 | model=self.model, 47 | messages=messages, # type: ignore[arg-type] 48 | temperature=temperature, 49 | ) 50 | 51 | if self.aborted: 52 | return None 53 | 54 | if not isinstance(result, ChatCompletion): 55 | raise TranslationResponseError(_("Unexpected response type: {response_type}").format( 56 | response_type=type(result).__name__ 57 | ), response=result) 58 | 59 | if not getattr(result, 'choices'): 60 | raise TranslationResponseError(_("No choices returned in the response"), response=result) 61 | 62 | response['response_time'] = getattr(result, 'response_ms', 0) 63 | 64 | if result.usage: 65 | response['prompt_tokens'] = getattr(result.usage, 'prompt_tokens') 66 | response['output_tokens'] = getattr(result.usage, 'completion_tokens') 67 | response['total_tokens'] = getattr(result.usage, 'total_tokens') 68 | 69 | if result.choices: 70 | choice = result.choices[0] 71 | reply = result.choices[0].message 72 | 73 | response['finish_reason'] = getattr(choice, 'finish_reason', None) 74 | response['text'] = getattr(reply, 'content', None) 75 | else: 76 | raise TranslationResponseError(_("No choices returned in the response"), response=result) 77 | 78 | # Return the response if the API call succeeds 79 | return response 80 | -------------------------------------------------------------------------------- /PySubtrans/Providers/Clients/DeepSeekClient.py: -------------------------------------------------------------------------------- 1 | from PySubtrans.Helpers.Localization import _ 2 | from PySubtrans.Providers.Clients.CustomClient import CustomClient 3 | from PySubtrans.SettingsType import SettingsType 4 | 5 | class DeepSeekClient(CustomClient): 6 | """ 7 | Handles chat communication with DeepSeek to request translations using CustomClient logic 8 | """ 9 | def __init__(self, settings: SettingsType): 10 | settings['supports_system_messages'] = True 11 | settings['supports_conversation'] = True 12 | settings['supports_reasoning'] = True 13 | settings['supports_streaming'] = True 14 | settings.setdefault('server_address', settings.get_str('api_base', 'https://api.deepseek.com')) 15 | settings.setdefault('endpoint', '/v1/chat/completions') 16 | super().__init__(settings) 17 | -------------------------------------------------------------------------------- /PySubtrans/Providers/Clients/OpenRouterClient.py: -------------------------------------------------------------------------------- 1 | from PySubtrans.Helpers.Localization import _ 2 | from PySubtrans.Providers.Clients.CustomClient import CustomClient 3 | from PySubtrans.SettingsType import SettingsType 4 | 5 | class OpenRouterClient(CustomClient): 6 | """ 7 | Handles chat communication with OpenRouter to request translations 8 | """ 9 | def __init__(self, settings: SettingsType): 10 | settings.setdefault('supports_system_messages', True) 11 | settings.setdefault('supports_conversation', True) 12 | settings.setdefault('supports_streaming', True) 13 | settings.setdefault('server_address', 'https://openrouter.ai/api/') 14 | settings.setdefault('endpoint', '/v1/chat/completions') 15 | settings.setdefault('additional_headers', { 16 | 'HTTP-Referer': 'https://github.com/machinewrapped/llm-subtrans', 17 | 'X-Title': 'LLM-Subtrans' 18 | }) 19 | super().__init__(settings) 20 | -------------------------------------------------------------------------------- /PySubtrans/Providers/Clients/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | PySubtrans.Providers.Clients - Translation client implementations 3 | 4 | This module contains all client implementations for translation providers. 5 | """ -------------------------------------------------------------------------------- /PySubtrans/Providers/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | PySubtrans.Providers - Translation service provider implementations 3 | 4 | This module contains all provider implementations. Explicit imports ensure 5 | all providers are available regardless of installation method. 6 | """ 7 | 8 | # Explicitly import all provider modules to ensure they're registered 9 | # This is required for pip-installed packages where dynamic discovery may fail 10 | from . import Provider_Azure 11 | from . import Provider_Bedrock 12 | from . import Provider_Claude 13 | from . import Provider_Custom 14 | from . import Provider_DeepSeek 15 | from . import Provider_Gemini 16 | from . import Provider_Mistral 17 | from . import Provider_OpenAI 18 | from . import Provider_OpenRouter 19 | 20 | -------------------------------------------------------------------------------- /PySubtrans/SubtitleBatcher.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from PySubtrans.Options import SettingsType 3 | from PySubtrans.SubtitleBatch import SubtitleBatch 4 | from PySubtrans.SubtitleScene import SubtitleScene 5 | from PySubtrans.SubtitleLine import SubtitleLine 6 | 7 | class SubtitleBatcher: 8 | def __init__(self, settings : SettingsType): 9 | """ Initialize a SubtitleBatcher helper class with settings """ 10 | self.min_batch_size : int = settings.get_int('min_batch_size') or 1 11 | self.max_batch_size : int = settings.get_int('max_batch_size') or 100 12 | self.fix_overlaps : bool = settings.get_bool('prevent_overlapping_times', False) 13 | 14 | scene_threshold_seconds : float = settings.get_float('scene_threshold') or 30.0 15 | self.scene_threshold : timedelta = timedelta(seconds=scene_threshold_seconds) 16 | 17 | def BatchSubtitles(self, lines : list[SubtitleLine]) -> list[SubtitleScene]: 18 | if self.min_batch_size > self.max_batch_size: 19 | raise ValueError("min_batch_size must be less than max_batch_size.") 20 | 21 | scenes : list[SubtitleScene] = [] 22 | current_lines : list[SubtitleLine] = [] 23 | last_endtime : timedelta|None = None 24 | 25 | for line in lines: 26 | if line.start is None or line.end is None: 27 | raise ValueError(f"Line {line.number} has missing start or end time.") 28 | 29 | # Fix overlapping display times (otherwise gaps can be negative) 30 | if self.fix_overlaps and last_endtime and line.start < last_endtime: 31 | line.start = last_endtime + timedelta(milliseconds=10) 32 | 33 | gap = line.start - last_endtime if last_endtime is not None else None 34 | 35 | if gap is not None and gap > self.scene_threshold: 36 | if current_lines: 37 | self.CreateNewScene(scenes, current_lines) 38 | current_lines = [] 39 | 40 | current_lines.append(line) 41 | last_endtime = line.end 42 | 43 | # Handle any remaining lines 44 | if current_lines: 45 | self.CreateNewScene(scenes, current_lines) 46 | 47 | return scenes 48 | 49 | def CreateNewScene(self, scenes : list[SubtitleScene], current_lines : list[SubtitleLine]): 50 | """ 51 | Create a scene and add lines to it in batches 52 | """ 53 | scene = SubtitleScene() 54 | scenes.append(scene) 55 | scene.number = len(scenes) 56 | 57 | split_lines : list[list[SubtitleLine]] = self._split_lines(current_lines) 58 | 59 | for lines in split_lines: 60 | batch : SubtitleBatch = scene.AddNewBatch() 61 | batch._originals = lines 62 | 63 | return scene 64 | 65 | def _split_lines(self, lines : list[SubtitleLine]) -> list[list[SubtitleLine]]: 66 | """ 67 | Recursively divide the lines at the largest gap until there is no batch larger than the maximum batch size 68 | """ 69 | # If the batch is small enough, we're done 70 | num_lines = len(lines) 71 | if num_lines <= self.max_batch_size: 72 | return [ lines ] 73 | 74 | # Find the longest gap starting from the min_batch_size index 75 | longest_gap : timedelta = timedelta(seconds=0) 76 | split_index : int = self.min_batch_size 77 | last_split_index : int = num_lines - self.min_batch_size 78 | 79 | if last_split_index > split_index: 80 | for i in range(split_index, last_split_index): 81 | if lines[i].start is None: 82 | raise ValueError(f"Line {lines[i].number} has no start time.") 83 | 84 | if lines[i - 1].end is None: 85 | raise ValueError(f"Line {lines[i - 1].number} has no end time.") 86 | 87 | gap : timedelta = lines[i].start - lines[i - 1].end 88 | if gap > longest_gap: 89 | longest_gap = gap 90 | split_index = i 91 | 92 | # Split the batch into two 93 | left = lines[:split_index] 94 | right = lines[split_index:] 95 | 96 | # Recursively split the batches and concatenate the lists 97 | return self._split_lines(left) + self._split_lines(right) 98 | 99 | -------------------------------------------------------------------------------- /PySubtrans/SubtitleData.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from PySubtrans.SubtitleLine import SubtitleLine 6 | 7 | 8 | class SubtitleData: 9 | """ 10 | Format-agnostic container for subtitle lines and file-level metadata. 11 | 12 | Attributes: 13 | lines (list[SubtitleLine]): List of subtitle lines in the LLM-Subtrans internal representation 14 | metadata (dict[str, Any]): File-level metadata extracted from or required by specific formats 15 | start_line_number (int|None): Optional base line number for formats that support line numbering 16 | detected_format (str|None): Optional detected file format/extension (e.g. '.srt') 17 | """ 18 | 19 | def __init__(self, lines : list[SubtitleLine]|None = None, metadata : dict[str, Any]|None = None, start_line_number : int|None = None, detected_format : str|None = None 20 | ): 21 | self.lines : list[SubtitleLine] = lines or [] 22 | self.metadata : dict[str, Any] = metadata or {} 23 | self.start_line_number : int|None = start_line_number 24 | self.detected_format : str|None = detected_format 25 | 26 | -------------------------------------------------------------------------------- /PySubtrans/SubtitleError.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from PySubtrans.Helpers.Localization import _ 4 | 5 | class SubtitleError(Exception): 6 | def __init__(self, message : str|None = None, error : Exception|None = None): 7 | super().__init__(message) 8 | self.error = error 9 | self.message = message 10 | 11 | def __str__(self) -> str: 12 | if self.error: 13 | return str(self.error) 14 | elif self.message: 15 | return self.message 16 | return super().__str__() 17 | 18 | class NoProviderError(SubtitleError): 19 | def __init__(self): 20 | super().__init__(_("Provider not specified in options")) 21 | 22 | class ProviderError(SubtitleError): 23 | def __init__(self, message : str|None = None, provider : Any = None): 24 | super().__init__(message) 25 | self.provider = provider 26 | 27 | class ProviderConfigurationError(ProviderError): 28 | def __init__(self, message : str, provider : Any, error : Exception|None = None): 29 | super().__init__(message, provider) 30 | self.error = error 31 | 32 | class TranslationError(SubtitleError): 33 | def __init__(self, message : str, translation : Any = None, error : Exception|None = None): 34 | super().__init__(message) 35 | self.translation = translation 36 | self.error = error 37 | 38 | class TranslationAbortedError(TranslationError): 39 | def __init__(self): 40 | super().__init__(_("Translation aborted")) 41 | 42 | class TranslationImpossibleError(TranslationError): 43 | """ No chance of retry succeeding """ 44 | def __init__(self, message : str, error : Exception|None = None): 45 | super().__init__(message, error) 46 | 47 | class TranslationResponseError(TranslationError): 48 | def __init__(self, message : str, response : Any): 49 | super().__init__(message) 50 | self.response = response 51 | 52 | class NoTranslationError(TranslationError): 53 | def __init__(self, message : str, translation : str|None = None): 54 | super().__init__(message=message, translation=translation) 55 | 56 | class TranslationValidationError(TranslationError): 57 | def __init__(self, message : str, lines : list[Any]|None = None, translation : Any|None = None): 58 | super().__init__(message, translation) 59 | self.lines = lines or [] 60 | 61 | class UntranslatedLinesError(TranslationValidationError): 62 | def __init__(self, message : str, lines : list[Any]|None = None, translation : Any|None = None): 63 | super().__init__(message, lines=lines, translation=translation) 64 | 65 | class UnmatchedLinesError(TranslationValidationError): 66 | def __init__(self, message : str, lines : list[Any]|None = None, translation : Any|None = None): 67 | super().__init__(message, lines=lines, translation=translation) 68 | 69 | class EmptyLinesError(TranslationValidationError): 70 | def __init__(self, message : str, lines : list[Any]|None = None, translation : Any|None = None): 71 | super().__init__(message, lines=lines, translation=translation) 72 | 73 | class TooManyNewlinesError(TranslationValidationError): 74 | def __init__(self, message : str, lines : list[Any]|None = None, translation : Any|None = None): 75 | super().__init__(message, lines=lines, translation=translation) 76 | 77 | class LineTooLongError(TranslationValidationError): 78 | def __init__(self, message : str, lines : list[Any]|None = None, translation : Any|None = None): 79 | super().__init__(message, lines=lines, translation=translation) 80 | 81 | class SubtitleParseError(SubtitleError): 82 | """Error raised when subtitle file cannot be parsed.""" 83 | def __init__(self, message : str, error : Exception|None = None): 84 | super().__init__(message, error) 85 | 86 | -------------------------------------------------------------------------------- /PySubtrans/SubtitleFileHandler.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import TextIO 3 | import os 4 | 5 | from PySubtrans.SubtitleData import SubtitleData 6 | 7 | # Default encodings for reading subtitle files 8 | default_encoding = os.getenv('DEFAULT_ENCODING', 'utf-8') 9 | fallback_encoding = os.getenv('FALLBACK_ENCODING', 'iso-8859-1') 10 | 11 | 12 | class SubtitleFileHandler(ABC): 13 | """ 14 | Abstract interface for reading and writing subtitle files. 15 | 16 | Implementations handle format-specific operations while business logic remains format-agnostic. 17 | """ 18 | 19 | SUPPORTED_EXTENSIONS: dict[str, int] = {} 20 | 21 | @abstractmethod 22 | def parse_file(self, file_obj: TextIO) -> SubtitleData: 23 | """ 24 | Parse subtitle file content and return lines with file-level metadata. 25 | 26 | Returns: 27 | SubtitleData: Parsed subtitle lines and metadata 28 | 29 | Raises: 30 | SubtitleParseError: If parsing fails 31 | 32 | """ 33 | raise NotImplementedError 34 | 35 | @abstractmethod 36 | def parse_string(self, content: str) -> SubtitleData: 37 | """ 38 | Parse subtitle string content and return lines with file-level metadata. 39 | 40 | Returns: 41 | SubtitleData: Parsed subtitle lines and metadata 42 | 43 | Raises: 44 | SubtitleParseError: If parsing fails 45 | """ 46 | raise NotImplementedError 47 | 48 | @abstractmethod 49 | def compose(self, data: SubtitleData) -> str: 50 | """ 51 | Compose subtitle lines into text for saving or exporting. 52 | 53 | Args: 54 | data: SubtitleData containing subtitle content and metadata 55 | 56 | Returns: 57 | str: Subtitle content and metadata content in the file handler's format 58 | """ 59 | raise NotImplementedError 60 | 61 | @abstractmethod 62 | def load_file(self, path: str) -> SubtitleData: 63 | """ 64 | Open a subtitle file and parse it. 65 | 66 | Returns: 67 | SubtitleData: Parsed subtitle lines and metadata 68 | 69 | Raises: 70 | SubtitleParseError: If parsing fails 71 | UnicodeDecodeError: If file is in an unsupported encoding 72 | """ 73 | raise NotImplementedError 74 | 75 | def get_file_extensions(self) -> list[str]: 76 | """ 77 | Get file extensions supported by this handler. 78 | """ 79 | return list(self.__class__.SUPPORTED_EXTENSIONS.keys()) 80 | 81 | def get_extension_priorities(self) -> dict[str, int]: 82 | """ 83 | Get priority for each supported extension. 84 | 85 | Returns: 86 | dict: Mapping of file extensions to their priority (higher = more preferred) 87 | """ 88 | return self.__class__.SUPPORTED_EXTENSIONS.copy() 89 | -------------------------------------------------------------------------------- /PySubtrans/SubtitleValidator.py: -------------------------------------------------------------------------------- 1 | from PySubtrans.Options import Options 2 | from PySubtrans.SubtitleBatch import SubtitleBatch 3 | from PySubtrans.SubtitleError import EmptyLinesError, LineTooLongError, TooManyNewlinesError, UnmatchedLinesError, UntranslatedLinesError 4 | from PySubtrans.SubtitleLine import SubtitleLine 5 | 6 | class SubtitleValidator: 7 | def __init__(self, options : Options) -> None: 8 | self.options : Options = options 9 | 10 | def ValidateBatch(self, batch : SubtitleBatch): 11 | """ 12 | Check if the batch seems at least plausible 13 | """ 14 | self.errors = [] 15 | 16 | if batch.translated: 17 | errors = self.ValidateTranslations(batch.translated) 18 | if errors: 19 | self.errors.extend(errors) 20 | 21 | if batch.any_translated and not batch.all_translated: 22 | self.errors.append(UntranslatedLinesError(f"No translation found for {len(batch.originals) - len(batch.translated)} lines", translation=batch.translation)) 23 | 24 | batch.errors = self.errors 25 | 26 | def ValidateTranslations(self, translated : list[SubtitleLine]) -> list[Exception]: 27 | """ 28 | Check if the translation seems at least plausible 29 | """ 30 | if not translated: 31 | return [ UntranslatedLinesError(f"Failed to extract any translations") ] 32 | 33 | max_characters : int = self.options.get_int('max_characters') or 1000 34 | max_newlines : int = self.options.get_int('max_newlines') or 10 35 | 36 | no_number : list[SubtitleLine] = [] 37 | no_text : list[SubtitleLine] = [] 38 | too_long : list[SubtitleLine] = [] 39 | too_many_newlines : list[SubtitleLine] = [] 40 | 41 | for line in translated: 42 | if not line.number: 43 | no_number.append(line) 44 | 45 | if not line.text: 46 | no_text.append(line) 47 | continue 48 | 49 | if len(line.text) > max_characters: 50 | too_long.append(line) 51 | 52 | if line.text.count('\n') > max_newlines: 53 | too_many_newlines.append(line) 54 | 55 | errors = [] 56 | 57 | if no_number: 58 | errors.append(UnmatchedLinesError(f"{len(no_number)} translations could not be matched with a source line", lines=no_number)) 59 | 60 | if no_text: 61 | errors.append(EmptyLinesError(f"{len(no_text)} translations returned a blank line", lines=no_text)) 62 | 63 | if too_long: 64 | errors.append(LineTooLongError(f"One or more lines exceeded {max_characters} characters", lines=too_long)) 65 | 66 | if too_many_newlines: 67 | errors.append(TooManyNewlinesError(f"One or more lines contain more than {max_newlines} newlines", lines=too_many_newlines)) 68 | 69 | return errors 70 | -------------------------------------------------------------------------------- /PySubtrans/TranslationEvents.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from blinker import Signal 3 | from typing import Protocol 4 | 5 | 6 | class LoggerProtocol(Protocol): 7 | """Protocol for objects that can be used as loggers""" 8 | def error(self, msg : object, *args, **kwargs) -> None: ... 9 | def warning(self, msg : object, *args, **kwargs) -> None: ... 10 | def info(self, msg : object, *args, **kwargs) -> None: ... 11 | 12 | 13 | class TranslationEvents: 14 | """ 15 | Container for blinker signals emitted during translation. 16 | 17 | Subscribe to events to receive notifications from long-running translation tasks, 18 | e.g. to provide progress feedback or UI updates. 19 | 20 | Signals: 21 | batch_translated(sender, batch): 22 | Emitted after each batch is translated 23 | 24 | batch_updated(sender, batch): 25 | Emitted after each batch is updated in the subtitle project 26 | 27 | scene_translated(sender, scene): 28 | Emitted when a complete scene has been translated 29 | 30 | preprocessed(sender, scenes): 31 | Emitted after subtitles are batched and pre-processed (GuiSubtrans only) 32 | 33 | error(sender, message): 34 | Signals that an error was encountered during translation 35 | 36 | warning(sender, message): 37 | Signals that a warning was encountered during translation 38 | 39 | info(sender, message): 40 | General informational message during translation 41 | """ 42 | preprocessed: Signal 43 | batch_translated: Signal 44 | batch_updated: Signal 45 | scene_translated: Signal 46 | error: Signal 47 | warning: Signal 48 | info: Signal 49 | 50 | def __init__(self): 51 | self.preprocessed = Signal("translation-preprocessed") 52 | self.batch_translated = Signal("translation-batch-translated") 53 | self.batch_updated = Signal("translation-batch-updated") 54 | self.scene_translated = Signal("translation-scene-translated") 55 | 56 | # Signals for logging translation events 57 | self.error = Signal("translation-error") 58 | self.warning = Signal("translation-warning") 59 | self.info = Signal("translation-info") 60 | 61 | # Wrapper functions to adapt signal kwargs to logger positional args 62 | self._default_error_wrapper = lambda sender, message: logging.error(message) 63 | self._default_warning_wrapper = lambda sender, message: logging.warning(message) 64 | self._default_info_wrapper = lambda sender, message: logging.info(message) 65 | 66 | def connect_default_loggers(self): 67 | """ 68 | Connect default logging handlers to logging signals. 69 | """ 70 | self.error.connect(self._default_error_wrapper, weak=False) 71 | self.warning.connect(self._default_warning_wrapper, weak=False) 72 | self.info.connect(self._default_info_wrapper, weak=False) 73 | 74 | def disconnect_default_loggers(self): 75 | """ 76 | Disconnect default logging handlers from the signals. 77 | """ 78 | self.error.disconnect(self._default_error_wrapper) 79 | self.warning.disconnect(self._default_warning_wrapper) 80 | self.info.disconnect(self._default_info_wrapper) 81 | 82 | def connect_logger(self, logger : LoggerProtocol): 83 | """ 84 | Connect a custom logger to the logging signals. 85 | 86 | Args: 87 | logger: A logger-like object with error, warning, and info methods 88 | """ 89 | # Create wrapper functions to adapt signal kwargs to logger positional args 90 | def error_wrapper(sender, message): 91 | logger.error(message) 92 | 93 | def warning_wrapper(sender, message): 94 | logger.warning(message) 95 | 96 | def info_wrapper(sender, message): 97 | logger.info(message) 98 | 99 | # Use weak=False to prevent garbage collection of closures 100 | self.error.connect(error_wrapper, weak=False) 101 | self.warning.connect(warning_wrapper, weak=False) 102 | self.info.connect(info_wrapper, weak=False) 103 | -------------------------------------------------------------------------------- /PySubtrans/TranslationRequest.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Any, TypeAlias 3 | from PySubtrans.TranslationPrompt import TranslationPrompt 4 | from PySubtrans.Translation import Translation 5 | 6 | StreamingCallback: TypeAlias = Callable[[Translation], None]|None 7 | 8 | class TranslationRequest: 9 | """ 10 | Encapsulates a translation request with its prompt, callback, and tracking data 11 | """ 12 | def __init__(self, prompt : TranslationPrompt, streaming_callback : StreamingCallback = None): 13 | self.prompt : TranslationPrompt = prompt 14 | self.streaming_callback : StreamingCallback = streaming_callback 15 | 16 | # Progress tracking 17 | self.accumulated_text : str = "" 18 | self.last_processed_pos : int = 0 19 | 20 | # Additional context storage 21 | self.context : dict[str, Any] = {} 22 | 23 | @property 24 | def is_streaming(self) -> bool: 25 | """Check if this is a streaming request""" 26 | return self.streaming_callback is not None 27 | 28 | def ProcessStreamingDelta(self, delta_text : str) -> None: 29 | """Process a streaming delta and emit partial updates for complete sections""" 30 | if delta_text: 31 | self.accumulated_text += delta_text 32 | 33 | # Check for complete line groups (blank line threshold) 34 | if self._has_complete_line_group(): 35 | self._emit_partial_update() 36 | 37 | def StoreContext(self, key : str, value : Any) -> None: 38 | """Store additional context data""" 39 | self.context[key] = value 40 | 41 | def GetContext(self, key : str, default : Any = None) -> Any: 42 | """Retrieve context data""" 43 | return self.context.get(key, default) 44 | 45 | def _has_complete_line_group(self) -> bool: 46 | """Check if there's a complete line group since last processed position""" 47 | new_content = self.accumulated_text[self.last_processed_pos:] 48 | return '\n\n' in new_content 49 | 50 | def _emit_partial_update(self) -> None: 51 | """Emit partial update for complete sections and mark them as processed""" 52 | if not self.streaming_callback: 53 | return 54 | 55 | # Find the last complete line group 56 | last_double_newline = self.accumulated_text.rfind('\n\n') 57 | if last_double_newline == -1: 58 | return 59 | 60 | # Extract complete section and emit update 61 | complete_section = self.accumulated_text[:last_double_newline + 2] 62 | if complete_section.strip(): 63 | partial_translation = Translation({'text': complete_section}) 64 | self.streaming_callback(partial_translation) 65 | 66 | # Mark as processed 67 | self.last_processed_pos = last_double_newline + 2 68 | -------------------------------------------------------------------------------- /PySubtrans/VersionCheck.py: -------------------------------------------------------------------------------- 1 | import os 2 | import datetime 3 | import logging 4 | import requests 5 | 6 | from PySubtrans.version import __version__ 7 | from PySubtrans.Helpers.Resources import config_dir 8 | 9 | repo_name = "llm-subtrans" 10 | repo_owner = "machinewrapped" 11 | 12 | last_check_file = os.path.join(config_dir, 'last_check.txt') 13 | 14 | def CheckIfUpdateAvailable(): 15 | try: 16 | url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest" 17 | response = requests.get(url) 18 | 19 | if response.status_code == 200: 20 | with open(last_check_file, "w") as f: 21 | f.write(datetime.date.today().isoformat()) 22 | 23 | latest_version = response.json()["tag_name"] 24 | 25 | if latest_version != __version__: 26 | logging.info(f"A new version ({latest_version}) of {repo_name} is available!") 27 | return True 28 | 29 | else: 30 | logging.debug(f"You have the latest version ({__version__}) of {repo_name}.") 31 | else: 32 | logging.debug(f"Failed to get latest release of {repo_name}. Error: {response.status_code}") 33 | 34 | except Exception as e: 35 | logging.debug(f"Unable to check if an update is available: {str(e)}") 36 | 37 | return False 38 | 39 | def CheckIfUpdateCheckIsRequired(): 40 | if not os.path.exists(last_check_file): 41 | return True 42 | 43 | try: 44 | with open(last_check_file, "r") as f: 45 | last_check = datetime.date.fromisoformat(f.read().strip()) 46 | 47 | return datetime.date.today() > last_check 48 | 49 | except FileNotFoundError: 50 | return True 51 | -------------------------------------------------------------------------------- /PySubtrans/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pysubtrans" 7 | version = "1.5.3" 8 | description = "Core subtitle translation toolkit used by LLM-Subtrans" 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | license = "MIT" 12 | dependencies = [ 13 | "python-dotenv", 14 | "srt", 15 | "pysubs2", 16 | "regex", 17 | "babel", 18 | "appdirs", 19 | "blinker", 20 | "requests", 21 | "setuptools", 22 | "httpx", 23 | "httpx[socks]" 24 | ] 25 | 26 | [project.optional-dependencies] 27 | openai = [ 28 | "openai" 29 | ] 30 | azure = [ 31 | "openai" 32 | ] 33 | gemini = [ 34 | "google-genai", 35 | "google-api-core" 36 | ] 37 | claude = [ 38 | "anthropic" 39 | ] 40 | mistral = [ 41 | "mistralai" 42 | ] 43 | bedrock = [ 44 | "boto3" 45 | ] 46 | 47 | [project.urls] 48 | Homepage = "https://github.com/machinewrapped/llm-subtrans" 49 | Documentation = "https://github.com/machinewrapped/llm-subtrans/blob/main/PySubtrans/README.md" 50 | Source = "https://github.com/machinewrapped/llm-subtrans" 51 | Issues = "https://github.com/machinewrapped/llm-subtrans/issues" 52 | 53 | [tool.setuptools] 54 | packages = [ 55 | "PySubtrans", 56 | "PySubtrans.Formats", 57 | "PySubtrans.Helpers", 58 | "PySubtrans.Providers", 59 | "PySubtrans.Providers.Clients" 60 | ] 61 | package-dir = {"PySubtrans" = "."} 62 | -------------------------------------------------------------------------------- /PySubtrans/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "v1.5.3" 2 | -------------------------------------------------------------------------------- /assets/gui-subtrans.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinewrapped/llm-subtrans/da771649a63a8c16d35e500dbbfcf76085ad68f1/assets/gui-subtrans.ico -------------------------------------------------------------------------------- /assets/icons/about.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /assets/icons/load_subtitles.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /assets/icons/quit.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /assets/icons/redo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /assets/icons/save_project.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /assets/icons/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /assets/icons/start_translating.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /assets/icons/start_translating_fast.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /assets/icons/stop_translating.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /assets/icons/undo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /assets/subtranslg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinewrapped/llm-subtrans/da771649a63a8c16d35e500dbbfcf76085ad68f1/assets/subtranslg.png -------------------------------------------------------------------------------- /assets/subtransmd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinewrapped/llm-subtrans/da771649a63a8c16d35e500dbbfcf76085ad68f1/assets/subtransmd.png -------------------------------------------------------------------------------- /hooks/hook-PySubtrans.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | try: 4 | from PyInstaller.utils.hooks import collect_submodules # type: ignore 5 | 6 | hiddenimports = collect_submodules('scripts') 7 | hiddenimports += collect_submodules('PySubtrans.Providers') 8 | hiddenimports += collect_submodules('PySubtrans.Formats') 9 | 10 | except ImportError: 11 | logging.info("PyInstaller not found, skipping hook") -------------------------------------------------------------------------------- /instructions/instructions (OCR errors).txt: -------------------------------------------------------------------------------- 1 | ### prompt 2 | Please translate the following subtitles[ for movie][ to language]. 3 | 4 | 5 | 6 | ### instructions 7 | The goal is to accurately translate subtitles into a target language. 8 | 9 | You will receive a batch of lines for translation. Carefully read through the lines, along with any additional context provided. 10 | Translate each line accurately, concisely, and separately into the target language, with appropriate punctuation. 11 | 12 | The source subtitles were OCRed so they are likely to contain errors. Where the input seems to be incorrect, use ALL the available context to determine what the correct text should be, to the best of your ability. 13 | 14 | The translation must have the same number of lines as the original, but you can adapt the content to fit the grammar of the target language. 15 | 16 | Make sure to translate all provided lines and do not ask whether to continue. 17 | 18 | Use any provided context to enhance your translations. If a name list is provided, ensure names are spelled according to the user's preference. 19 | 20 | If the input contains profanity, use equivalent profanity in the translation. 21 | 22 | At the end you should add and tags with information about the translation: 23 | 24 | A one or two line synopsis of the current batch. 25 | This should be a short summary of the current scene, including any previous batches. 26 | 27 | If the context is unclear, just summarize the dialogue. 28 | 29 | Your response will be processed by an automated system, so you MUST respond using the required format: 30 | 31 | Example (translating to English, correcting for OCR errors): 32 | 33 | #200 34 | Original> 35 | 変わりゆ<時代において、 36 | Translation> 37 | In an ever-changing era, 38 | 39 | #501 40 | Original> 41 | 進化レ続けることが生き残る秘訣〒す。 42 | Translation> 43 | continuing to evolve is the key to survival. 44 | 45 | Example (translating to German): 46 | 47 | #700 48 | Original> 49 | ln the age of diqital transformation, 50 | Translation> 51 | Im Zeitalter der digitalen Transformation, 52 | 53 | #701 54 | Original> 55 | those who reslst change may find them5elves left behind. 56 | Translation> 57 | diejenigen, die sich dem Wandel widersetzen, 58 | könnten sich zurückgelassen finden. 59 | 60 | Example (translating to French, correcting errors and punctuation): 61 | 62 | #200 63 | Original> 64 | Verc qoing to the market, aren't we 65 | Translation> 66 | Nous allons au marché, n'est-ce pas ? 67 | 68 | Example (translating to English, adapting an idiom): 69 | 70 | #100 71 | Original> 72 | 4l mol tiempo, 73 | Translation> 74 | When life gives you lemons, 75 | 76 | #101 77 | Original> 78 | bvena coro. 79 | Translation> 80 | make lemonade. 81 | 82 | ### retry_instructions 83 | There was an issue with the previous translation. 84 | 85 | Please translate the subtitles again, ensuring each line is translated SEPARATELY, and EVERY line has a corresponding translation. 86 | 87 | Do NOT merge lines together in the translation, as this leads to incorrect timings and confusion for the reader. 88 | -------------------------------------------------------------------------------- /instructions/instructions (Whispered).txt: -------------------------------------------------------------------------------- 1 | ### prompt 2 | Please translate the following subtitles[ for movie][ to language]. 3 | 4 | ### instructions 5 | The goal is to accurately translate subtitles into a target language. 6 | 7 | You will receive a batch of lines for translation. Carefully read through the lines, along with any additional context provided. 8 | Translate each line accurately, concisely, and separately into the target language, with appropriate punctuation. 9 | 10 | The subtitles were AI-generated with Whisper so they are likely to contain transcription errors. If the input seems wrong, try to determine what it should read from the context. 11 | 12 | The translation must have the same number of lines as the original, but you can adapt the content to fit the grammar of the target language. 13 | 14 | Use any provided context to enhance your translations. If a name list is provided, ensure names are spelled according to the user's preference. 15 | 16 | If the input contains profanity, use equivalent profanity in the translation. 17 | 18 | At the end you should add and tags with information about the translation: 19 | 20 | A one or two line synopsis of the current batch. 21 | This should be a short summary of the current scene, including any previous batches. 22 | 23 | If the context is unclear, just summarize the dialogue. 24 | 25 | Your response will be processed by an automated system, so you MUST respond using the required format: 26 | 27 | Example (translating to English): 28 | 29 | #200 30 | Original> 31 | 変わりゆく時代において、 32 | Translation> 33 | In an ever-changing era, 34 | 35 | #501 36 | Original> 37 | 進化し続けることが生き残る秘訣です。 38 | Translation> 39 | continuing to evolve is the key to survival. 40 | 41 | Example (translating to German, correcting transcription error): 42 | 43 | #700 44 | Original> 45 | a Woo sha film called Zoo Warriors of the Magic Mountain 46 | Translation> 47 | Ein Wuxia-Film namens Zu Warriors of the Magic Mountain 48 | 49 | Example (translating to English, adapting an idiom): 50 | 51 | #100 52 | Original> 53 | Al mal tiempo, 54 | Translation> 55 | When life gives you lemons, 56 | 57 | #101 58 | Original> 59 | buena cara. 60 | Translation> 61 | make lemonade. 62 | 63 | Example (translating to French, correcting errors and punctuation): 64 | 65 | #500 66 | Original> 67 | Were going to the market, aren't we 68 | Translation> 69 | Nous allons au marché, n'est-ce pas ? 70 | 71 | ### retry_instructions 72 | There was an issue with the previous translation. 73 | 74 | Please translate the subtitles again, ensuring each line is translated SEPARATELY, and EVERY line has a corresponding translation. 75 | 76 | Do NOT merge lines together in the translation, as this leads to incorrect timings and confusion for the reader. -------------------------------------------------------------------------------- /instructions/instructions (brief).txt: -------------------------------------------------------------------------------- 1 | ### prompt 2 | Please translate the following subtitles[ for movie][ to language]. 3 | 4 | ### instructions 5 | The goal is to accurately translate subtitles into a target language. 6 | 7 | Carefully read the provided lines along with any additional context. Translate each line accurately, concisely, and separately into the target language, with appropriate punctuation. 8 | 9 | The translation must have the same number of lines as the original, but you can adapt the content to fit the target language. 10 | 11 | Use any additional context provided to enhance your translations. 12 | 13 | If you detect errors in the input, correct them using the available context. 14 | 15 | If the input contains profanity, use equivalent profanity in the translation. 16 | 17 | Add and tags with information about the translation: 18 | 19 | A one or two line synopsis of the current batch. 20 | This should be a short summary of the current scene, including any previous batches. 21 | 22 | Your response will be processed by an automated system, so you MUST respond using the required format: 23 | 24 | Example (translating to English): 25 | 26 | #200 27 | Original> 28 | 変わりゆく時代において、 29 | Translation> 30 | In an ever-changing era, 31 | 32 | #501 33 | Original> 34 | 進化し続けることが生き残る秘訣です。 35 | Translation> 36 | continuing to evolve is the key to survival. 37 | 38 | Example (translating to German): 39 | 40 | #700 41 | Original> 42 | In the age of digital transformation, 43 | Translation> 44 | Im Zeitalter der digitalen Transformation, 45 | 46 | #701 47 | Original> 48 | those who resist change may find themselves left behind. 49 | Translation> 50 | diejenigen, die sich dem Wandel widersetzen, 51 | könnten sich zurückgelassen finden. 52 | 53 | Example (translating to French, correcting errors and punctuation): 54 | 55 | #200 56 | Original> 57 | Were going to the market, aren't we 58 | Translation> 59 | Nous allons au marché, n'est-ce pas ? 60 | 61 | ### retry_instructions 62 | There was an issue with the previous translation. 63 | 64 | Please translate the subtitles again, ensuring that EVERY line has a translation. 65 | 66 | Do NOT merge lines together in the translation. -------------------------------------------------------------------------------- /instructions/instructions (chinese+english).txt: -------------------------------------------------------------------------------- 1 | ### prompt 2 | Please translate these subtitles[ for movie][ to language]. 3 | 4 | 5 | ### instructions 6 | You are an English translator specializing in Chinese to English translations. Your task is to translate the Chinese dialogues into English subtitles, ensuring they reflect the original meaning as accurately as possible. The goal is to preserve the cultural context, nuances, and intent of the original dialogue. 7 | 8 | The user will provide a batch of subtitles for translation, that contain the Chinese text and an existing English translation. 9 | 10 | You should respond with an accurate, concise, and natural-sounding translation of the Chinese text. 11 | 12 | You may reference the English text in the source to help disambiguate the context of the subtitles, such as the speaker. 13 | 14 | Your response will be processed by an automated system, so it is imperative that you adhere to the required output format. 15 | 16 | For example, if the user provides this input: 17 | 18 | #47 19 | Original> 20 | 一筆寫不出兩個萬字 21 | We're in this together. 22 | Translation> 23 | 24 | #48 25 | Original> 26 | 在座各位大家都有責任 27 | Let's share the responsibility. 28 | Translation> 29 | 30 | You should respond with: 31 | 32 | #47 33 | Original> 34 | 一筆寫不出兩個萬字 35 | We're in this together. 36 | Translation> 37 | One cannot achieve a monumental task alone. 38 | 39 | #48 40 | Original> 41 | 在座各位大家都有責任 42 | Let's share the responsibility. 43 | Translation> 44 | Everyone present here has a responsibility. 45 | 46 | Please ensure that each line of dialogue remains distinct in the translation. Merging lines together can lead to timing problems during playback. 47 | 48 | At the end of each set of translations, include a one or two line synopsis of the input text in a tag, for example: 49 | A discussion about the responsibility and danger of a certain matter takes place in the Wan manor. 50 | 51 | Use the available information to add a short synopsis of the current scene in a tag, for example: 52 | Members of the Wan family gather to discuss the situation and debate whether to confront the bandits or not. Some express their support for fighting back, highlighting the large number of people in the manor and questioning why they should fear the bandits. 53 | 54 | If the context is unclear, use the existing English translation in the source to guide your interpretation. 55 | 56 | ### retry_instructions 57 | There was an issue with the previous translation. 58 | 59 | Please translate the subtitles again, paying careful attention to ensure that each line is translated separately, and that every line has a matching translation. 60 | -------------------------------------------------------------------------------- /instructions/instructions (english to chinese).txt: -------------------------------------------------------------------------------- 1 | ### prompt 2 | Please translate these subtitles[ for movie][ to language]. 3 | 4 | 5 | ### instructions 6 | You are an English translator specializing in English to Chinese translations. Your task is to translate the English subtitles into Chinese subtitles, ensuring they reflect the original meaning as accurately as possible. The goal is to preserve the cultural context, nuances, and intent of the original dialogue. 7 | 8 | The user will provide a batch of subtitles for translation, that contain the English text. 9 | 10 | You should respond with an accurate, concise, and natural-sounding translation in Chinese. 11 | 12 | Your response will be processed by an automated system, so it is imperative that you adhere to the required output format. 13 | 14 | For example, if the user provides this input: 15 | 16 | #47 17 | Original> 18 | One cannot achieve a monumental task alone. 19 | Translation> 20 | 21 | #48 22 | Original> 23 | Everyone present here has a responsibility. 24 | Translation> 25 | 26 | You should respond with: 27 | 28 | #47 29 | Original> 30 | One cannot achieve a monumental task alone. 31 | Translation> 32 | 独木难支 33 | 34 | #48 35 | Original> 36 | Everyone present here has a responsibility. 37 | Translation> 38 | 在座各位大家都有責任 39 | 40 | Please ensure that each line of dialogue remains distinct in the translation. Merging lines together can lead to timing problems during playback. 41 | 42 | At the end of each set of translations, include a one or two line synopsis of the input text in a tag, for example: 43 | A discussion about the responsibility and danger of a certain matter takes place in the Wan manor. 44 | 45 | Use the available information to add a short synopsis of the current scene in a tag, for example: 46 | Members of the Wan family gather to discuss the situation and debate whether to confront the bandits or not. Some express their support for fighting back, highlighting the large number of people in the manor and questioning why they should fear the bandits. 47 | 48 | ### retry_instructions 49 | There was an issue with the previous translation. 50 | 51 | Please translate the subtitles again, paying careful attention to ensure that each line is translated separately, and that every line has a matching translation. 52 | 53 | ### target_language 54 | Chinese -------------------------------------------------------------------------------- /instructions/instructions (improve quality).txt: -------------------------------------------------------------------------------- 1 | ### prompt 2 | Try to improve the following subtitles[ for movie]. 3 | 4 | ### task_type 5 | Improvement 6 | 7 | ### instructions 8 | Goal: Subtitle Quality Enhancement (In Original Language) 9 | 10 | Your task is to improve the quality of a set of subtitles WITHOUT changing the language (this is not a translation). 11 | 12 | You will receive a batch of lines and should respond with richer, natural dialogue in the same language. 13 | 14 | Correct any grammatical errors or misspellings found in the original line, and ensure appropriate punctuation is used for flow and emphasis. Avoid emdash altogether. 15 | 16 | The improved version MUST have the same number of lines as the original, but individual words may be moved between lines to improve the cohesiveness of each line. 17 | 18 | Use any provided context to enhance your translations. If a name list is provided, ensure names are spelled according to the user's preference. 19 | 20 | If you detect obvious errors in the input, try to correct them using the available context, but do not improvise. 21 | 22 | If the input contains profanity it should not be censored. 23 | 24 | At the end you should add and tags with information about the translation: 25 | 26 | A one or two line synopsis of the current batch. 27 | This should be a short summary of the current scene, including any previous batches. 28 | 29 | If the context is unclear, just summarize the dialogue. 30 | 31 | Your response will be processed by an automated system, so you MUST respond using the required format: 32 | 33 | Example 1: 34 | 35 | #146 36 | Original> 37 | Had if not been him 38 | Improvement> 39 | Had it not been for him, 40 | 41 | #147 42 | Original> 43 | The girl would have been trampled to dead 44 | Improvement> 45 | the girl would have been trampled to death. 46 | 47 | Example 2: 48 | 49 | #100 50 | Original> 51 | How do you think she's heading 52 | for so hurry up 53 | Improvement> 54 | Where do you think she's 55 | heading in such a hurry? 56 | 57 | Example 3: 58 | 59 | #65 60 | Original> 61 | Leave behind the money 62 | Improvement> 63 | Leave the money behind, 64 | 65 | #66 66 | Original> 67 | or also none of you'll be alive! 68 | Improvement> 69 | or else none of you will live! 70 | 71 | Example 4: 72 | 73 | #204 74 | Original> 75 | Pero yo ya hice mi decisión 76 | Improvement> 77 | Pero yo ya tomé mi decisión. 78 | 79 | Example 5: 80 | 81 | #310 82 | Original> 83 | Tu doit parler avec elle avant qu’il sera trop tard 84 | Improvement> 85 | Tu dois lui parler avant qu’il ne soit trop tard. 86 | 87 | ### retry_instructions 88 | There was an issue with the previous subtitles. 89 | 90 | Please polish the subtitles again, paying careful attention to ensure that each line is kept SEPARATE, and that EVERY line is present in the response. 91 | 92 | Do NOT merge lines together, it leads to incorrect timings and confusion for the reader. 93 | 94 | ### target_language 95 | Improved -------------------------------------------------------------------------------- /instructions/instructions (transliteration).txt: -------------------------------------------------------------------------------- 1 | ### prompt 2 | Please transliterate these subtitles[ for movie][ to language]. 3 | 4 | 5 | ### instructions 6 | You are a transliterator, your task is to accurately convert subtitles into the Roman alphabet. 7 | 8 | The user will provide a batch of lines for transliteration, you should respond with an accurate and readable transliteration of the dialogue. 9 | 10 | The user may provide additional context, such as the language of origin. Use this information to improve the accuracy of your transliteration. 11 | 12 | The user may provide a list of names, make sure that they are spelled EXACTLY according to the user's preference. 13 | 14 | Your response will be processed by an automated system, so it is imperative that you adhere to the required output format. 15 | 16 | Example input (Japanese to Romaji): 17 | 18 | #200 19 | Original> 20 | 変わりゆく時代において、 21 | Translation> 22 | 23 | #201 24 | Original> 25 | 進化し続けることが生き残る秘訣です。 26 | Translation> 27 | 28 | You should respond with: 29 | 30 | #200 31 | Original> 32 | 変わりゆく時代において、 33 | Translation> 34 | Kawariyuku jidai ni oite, 35 | 36 | #201 37 | Original> 38 | 進化し続けることが生き残る秘訣です。 39 | Translation> 40 | Shinka shitsuzukeru koto ga ikinokoru hiketsu desu. 41 | 42 | Example input (Japanese to Romaji) 43 | 44 | #520 45 | Original> 46 | 時は常に流れ、 47 | 新しい挑戦を受け入れる勇気が大切です。 48 | Translation> 49 | Toki wa tsune ni nagare, 50 | Atarashii chōsen o ukeireru yūki ga taisetsu desu. 51 | 52 | Please ensure that each line remains DISTINCT in the transliteration. 53 | 54 | At the end of each set of lines, include a one or two line synopsis of the input text in a tag, for example: 55 | John and Sarah discuss their plan to locate a suspect, deducing that he is likely in the uptown area. 56 | 57 | Use the available information to add a short synopsis of the current scene in a tag, for example: 58 | John and Sarah are in their office analyzing data and planning their next steps. They deduce that the suspect is probably in the uptown area and decide to start their search there. 59 | 60 | Do not guess or improvise if the context is unclear, just summarise the dialogue. 61 | 62 | 63 | ### retry_instructions 64 | There was an issue with the previous transliteration. 65 | 66 | Please try again, paying careful attention to ensure that each line is transliterated separately, and that every line has a matching transliteration. 67 | 68 | Do not merge lines together in the transliteration, it leads to incorrect timings and confusion for the reader. 69 | -------------------------------------------------------------------------------- /instructions/instructions.txt: -------------------------------------------------------------------------------- 1 | ### prompt 2 | Please translate the following subtitles[ for movie][ to language]. 3 | 4 | ### instructions 5 | The goal is to accurately translate subtitles into a target language. 6 | 7 | You will receive a batch of lines for translation. Carefully read through the lines, along with any additional context provided. 8 | Translate each line accurately, concisely, and separately into the target language, with appropriate punctuation. 9 | 10 | The translation must have the same number of lines as the original, but you can adapt the content to fit the grammar of the target language. 11 | 12 | Make sure to translate all provided lines and do not ask whether to continue. 13 | 14 | Use any provided context to enhance your translations. If a name list is provided, ensure names are spelled according to the user's preference. 15 | 16 | If you detect obvious errors in the input, correct them in the translation using the available context, but do not improvise. 17 | 18 | If the input contains profanity, use equivalent profanity in the translation. 19 | 20 | Add and tags before the translated subtitles, with information about the translation in the target language: 21 | 22 | A one or two line synopsis of the current batch. 23 | This should be a short summary of the current scene, including any previous batches. 24 | 25 | If the context is unclear, just summarize the dialogue. 26 | 27 | Your response will be processed by an automated system, so you MUST respond using the required format: 28 | 29 | Example (translating to English): 30 | 31 | #200 32 | Original> 33 | 変わりゆく時代において、 34 | Translation> 35 | In an ever-changing era, 36 | 37 | #501 38 | Original> 39 | 進化し続けることが生き残る秘訣です。 40 | Translation> 41 | continuing to evolve is the key to survival. 42 | 43 | Example (translating to German): 44 | 45 | #700 46 | Original> 47 | In the age of digital transformation, 48 | Translation> 49 | Im Zeitalter der digitalen Transformation, 50 | 51 | #701 52 | Original> 53 | those who resist change may find themselves left behind. 54 | Translation> 55 | diejenigen, die sich dem Wandel widersetzen, 56 | könnten sich zurückgelassen finden. 57 | 58 | Example (translating to French, correcting errors and punctuation): 59 | 60 | #200 61 | Original> 62 | Were going to the market, aren't we 63 | Translation> 64 | Nous allons au marché, n'est-ce pas ? 65 | 66 | Example (translating to English, adapting an idiom): 67 | 68 | #100 69 | Original> 70 | Al mal tiempo, 71 | Translation> 72 | When life gives you lemons, 73 | 74 | #101 75 | Original> 76 | buena cara. 77 | Translation> 78 | make lemonade. 79 | 80 | ### retry_instructions 81 | There was an issue with the previous translation. 82 | 83 | Please translate the subtitles again, ensuring each line is translated SEPARATELY, and EVERY line has a corresponding translation. 84 | 85 | Do NOT merge lines together in the translation, as this leads to incorrect timings and confusion for the reader. -------------------------------------------------------------------------------- /locales/cs/LC_MESSAGES/gui-subtrans.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinewrapped/llm-subtrans/da771649a63a8c16d35e500dbbfcf76085ad68f1/locales/cs/LC_MESSAGES/gui-subtrans.mo -------------------------------------------------------------------------------- /locales/en/LC_MESSAGES/gui-subtrans.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinewrapped/llm-subtrans/da771649a63a8c16d35e500dbbfcf76085ad68f1/locales/en/LC_MESSAGES/gui-subtrans.mo -------------------------------------------------------------------------------- /locales/es/LC_MESSAGES/gui-subtrans.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinewrapped/llm-subtrans/da771649a63a8c16d35e500dbbfcf76085ad68f1/locales/es/LC_MESSAGES/gui-subtrans.mo -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "llm-subtrans" 7 | version = "1.5.2" 8 | description = "Subtitle translation tools using large language models" 9 | readme = "readme.md" 10 | requires-python = ">=3.10" 11 | license = {file = "LICENSE"} 12 | dependencies = [ 13 | "python-dotenv", 14 | "srt", 15 | "pysubs2", 16 | "regex", 17 | "babel", 18 | "appdirs", 19 | "blinker", 20 | "requests", 21 | "setuptools", 22 | "httpx", 23 | "httpx[socks]" 24 | ] 25 | 26 | [project.optional-dependencies] 27 | gui = [ 28 | "darkdetect", 29 | "pyside6" 30 | ] 31 | openai = ["openai"] 32 | azure = ["openai"] 33 | gemini = ["google-genai", "google-api-core"] 34 | claude = ["anthropic"] 35 | mistral = ["mistralai"] 36 | bedrock = ["boto3"] 37 | 38 | [tool.setuptools.packages.find] 39 | where = ["."] 40 | include = ["PySubtrans*", "GuiSubtrans*", "scripts*"] 41 | namespaces = true 42 | -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinewrapped/llm-subtrans/da771649a63a8c16d35e500dbbfcf76085ad68f1/scripts/__init__.py -------------------------------------------------------------------------------- /scripts/azure-subtrans.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from check_imports import check_required_imports 5 | check_required_imports(['PySubtrans', 'openai'], 'azure') 6 | 7 | from scripts.subtrans_common import ( 8 | InitLogger, 9 | CreateArgParser, 10 | CreateOptions, 11 | CreateProject, 12 | ) 13 | from PySubtrans import init_translator 14 | from PySubtrans.Options import Options 15 | from PySubtrans.SubtitleProject import SubtitleProject 16 | 17 | # Update when newer ones are available - https://learn.microsoft.com/en-us/azure/ai-services/openai/reference 18 | latest_azure_api_version = "2024-02-01" 19 | 20 | provider = "Azure" 21 | deployment_name = os.getenv('AZURE_DEPLOYMENT_NAME') 22 | api_base = os.getenv('AZURE_API_BASE') 23 | api_version = os.getenv('AZURE_API_VERSION', "2024-02-01") 24 | 25 | parser = CreateArgParser(f"Translates subtitles using a model on an OpenAI Azure deployment") 26 | parser.add_argument('-k', '--apikey', type=str, default=None, help=f"API key for your deployment") 27 | parser.add_argument('-b', '--apibase', type=str, default=None, help="API backend base address.") 28 | parser.add_argument('-a', '--apiversion', type=str, default=None, help="Azure API version") 29 | parser.add_argument('--deploymentname', type=str, default=None, help="Azure deployment name") 30 | args = parser.parse_args() 31 | 32 | logger_options = InitLogger("azure-subtrans", args.debug) 33 | 34 | try: 35 | options : Options = CreateOptions( 36 | args, 37 | provider, 38 | deployment_name=args.deploymentname or deployment_name, 39 | api_base=args.apibase or api_base, 40 | api_version=args.apiversion or api_version, 41 | ) 42 | 43 | # Create a project for the translation 44 | project : SubtitleProject = CreateProject(options, args) 45 | 46 | # Translate the subtitles 47 | translator = init_translator(options) 48 | project.TranslateSubtitles(translator) 49 | 50 | if project.use_project_file: 51 | logging.info(f"Writing project data to {str(project.projectfile)}") 52 | project.SaveProjectFile() 53 | 54 | except Exception as e: 55 | print("Error:", e) 56 | raise 57 | -------------------------------------------------------------------------------- /scripts/bedrock-subtrans.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from check_imports import check_required_imports 5 | check_required_imports(['PySubtrans', 'boto3'], 'bedrock') 6 | 7 | from scripts.subtrans_common import ( 8 | InitLogger, 9 | CreateArgParser, 10 | CreateOptions, 11 | CreateProject, 12 | ) 13 | from PySubtrans import init_translator 14 | from PySubtrans.Options import Options 15 | from PySubtrans.SubtitleProject import SubtitleProject 16 | 17 | provider = "Bedrock" 18 | 19 | # Fetch Bedrock-specific environment variables 20 | access_key = os.getenv('AWS_ACCESS_KEY_ID') 21 | secret_access_key = os.getenv('AWS_SECRET_ACCESS_KEY') 22 | aws_region = os.getenv('AWS_REGION', 'us-east-1') # Default to a common Bedrock region 23 | 24 | parser = CreateArgParser(f"Translates subtitles using a model on Amazon Bedrock") 25 | parser.add_argument('-k', '--accesskey', type=str, default=None, help="AWS Access Key ID") 26 | parser.add_argument('-s', '--secretkey', type=str, default=None, help="AWS Secret Access Key") 27 | parser.add_argument('-r', '--region', type=str, default=None, help="AWS Region (default: us-east-1)") 28 | parser.add_argument('-m', '--model', type=str, default=None, help="Model ID to use (e.g., amazon.titan-text-express-v1)") 29 | args = parser.parse_args() 30 | 31 | logger_options = InitLogger("bedrock-subtrans", args.debug) 32 | 33 | try: 34 | options: Options = CreateOptions( 35 | args, 36 | provider, 37 | access_key=args.accesskey or access_key, 38 | secret_access_key=args.secretkey or secret_access_key, 39 | aws_region=args.region or aws_region, 40 | model=args.model, 41 | ) 42 | 43 | # Validate that required Bedrock options are provided 44 | if not options.get('access_key') or not options.get('secret_access_key') or not options.get('aws_region') or not options.get('model'): 45 | raise ValueError("AWS Access Key, Secret Key, Region, and Model ID must be specified.") 46 | 47 | # Create a project for the translation 48 | project: SubtitleProject = CreateProject(options, args) 49 | 50 | # Translate the subtitles 51 | translator = init_translator(options) 52 | project.TranslateSubtitles(translator) 53 | 54 | if project.use_project_file: 55 | logging.info(f"Writing project data to {str(project.projectfile)}") 56 | project.SaveProjectFile() 57 | 58 | except Exception as e: 59 | logging.error(f"Error during subtitle translation: {e}") 60 | raise 61 | -------------------------------------------------------------------------------- /scripts/check_imports.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import importlib.util 3 | 4 | def check_required_imports(modules: list[str], pip_extras: str|None = None) -> None: 5 | """Check if required modules are available and exit with helpful message if not""" 6 | missing_modules = [] 7 | for module_name in modules: 8 | if importlib.util.find_spec(module_name) is None: 9 | missing_modules.append(module_name) 10 | 11 | if missing_modules: 12 | print("Error: Required modules not found (installation method has changed)") 13 | if pip_extras: 14 | print(f"Please run the install script or `pip install .[{pip_extras}]`") 15 | else: 16 | print("Please run the install script or `pip install .`") 17 | sys.exit(1) -------------------------------------------------------------------------------- /scripts/claude-subtrans.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from check_imports import check_required_imports 5 | check_required_imports(['PySubtrans', 'anthropic'], 'claude') 6 | 7 | from scripts.subtrans_common import ( 8 | InitLogger, 9 | CreateArgParser, 10 | CreateOptions, 11 | CreateProject, 12 | ) 13 | 14 | from PySubtrans import init_translator 15 | from PySubtrans.Options import Options 16 | from PySubtrans.SubtitleProject import SubtitleProject 17 | 18 | provider = "Claude" 19 | default_model = os.getenv('CLAUDE_MODEL') or "claude-3-haiku-20240307" 20 | 21 | parser = CreateArgParser(f"Translates subtitles using Anthropic's Claude AI") 22 | parser.add_argument('-k', '--apikey', type=str, default=None, help=f"Your Anthropic API Key (https://console.anthropic.com/settings/keys)") 23 | parser.add_argument('-m', '--model', type=str, default=None, help="The model to use for translation") 24 | parser.add_argument('--proxy', type=str, default=None, help="SOCKS proxy URL (e.g., socks://127.0.0.1:1089)") 25 | args = parser.parse_args() 26 | 27 | logger_options = InitLogger("claude-subtrans", args.debug) 28 | 29 | try: 30 | options : Options = CreateOptions(args, provider, model=args.model or default_model, proxy=args.proxy) 31 | 32 | # Create a project for the translation 33 | project : SubtitleProject = CreateProject(options, args) 34 | 35 | # Translate the subtitles 36 | translator = init_translator(options) 37 | project.TranslateSubtitles(translator) 38 | 39 | if project.use_project_file: 40 | logging.info(f"Writing project data to {str(project.projectfile)}") 41 | project.SaveProjectFile() 42 | 43 | except Exception as e: 44 | print("Error:", e) 45 | raise 46 | -------------------------------------------------------------------------------- /scripts/deepseek-subtrans.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from check_imports import check_required_imports 5 | check_required_imports(['PySubtrans']) 6 | 7 | from scripts.subtrans_common import ( 8 | InitLogger, 9 | CreateArgParser, 10 | CreateOptions, 11 | CreateProject, 12 | ) 13 | 14 | from PySubtrans import init_translator 15 | from PySubtrans.Options import Options 16 | from PySubtrans.SubtitleProject import SubtitleProject 17 | 18 | provider = "DeepSeek" 19 | default_model = os.getenv('DEEPSEEK_MODEL') or "deepseek-chat" 20 | 21 | parser = CreateArgParser(f"Translates subtitles using an DeepSeek model") 22 | parser.add_argument('-k', '--apikey', type=str, default=None, help=f"Your DeepSeek API Key (https://platform.deepseek.com/api_keys)") 23 | parser.add_argument('-b', '--apibase', type=str, default="https://api.deepseek.com", help="API backend base address.") 24 | parser.add_argument('-m', '--model', type=str, default=None, help="The model to use for translation") 25 | args = parser.parse_args() 26 | 27 | logger_options = InitLogger("deepseek-subtrans", args.debug) 28 | 29 | try: 30 | options : Options = CreateOptions( 31 | args, 32 | provider, 33 | api_base=args.apibase, 34 | model=args.model or default_model 35 | ) 36 | 37 | # Create a project for the translation 38 | project : SubtitleProject = CreateProject(options, args) 39 | 40 | # Translate the subtitles 41 | translator = init_translator(options) 42 | project.TranslateSubtitles(translator) 43 | 44 | if project.use_project_file: 45 | logging.info(f"Writing project data to {str(project.projectfile)}") 46 | project.SaveProjectFile() 47 | 48 | except Exception as e: 49 | print("Error:", e) 50 | raise 51 | -------------------------------------------------------------------------------- /scripts/gemini-subtrans.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from check_imports import check_required_imports 5 | check_required_imports(['PySubtrans', 'google.genai', 'google.api_core'], 'gemini') 6 | 7 | from scripts.subtrans_common import ( 8 | InitLogger, 9 | CreateArgParser, 10 | CreateOptions, 11 | CreateProject, 12 | ) 13 | 14 | from PySubtrans import init_translator 15 | from PySubtrans.Options import Options 16 | from PySubtrans.SubtitleProject import SubtitleProject 17 | 18 | provider = "Gemini" 19 | default_model = os.getenv('GEMINI_MODEL') or "Gemini 2.0 Flash" 20 | 21 | parser = CreateArgParser(f"Translates subtitles using a Google Gemini model") 22 | parser.add_argument('-k', '--apikey', type=str, default=None, help=f"Your Gemini API Key (https://makersuite.google.com/app/apikey)") 23 | parser.add_argument('-m', '--model', type=str, default=None, help="The model to use for translation") 24 | args = parser.parse_args() 25 | 26 | logger_options = InitLogger("gemini-subtrans", args.debug) 27 | 28 | try: 29 | options : Options = CreateOptions(args, provider, model=args.model or default_model) 30 | 31 | # Create a project for the translation 32 | project : SubtitleProject = CreateProject(options, args) 33 | 34 | # Translate the subtitles 35 | translator = init_translator(options) 36 | project.TranslateSubtitles(translator) 37 | 38 | if project.use_project_file: 39 | logging.info(f"Writing project data to {str(project.projectfile)}") 40 | project.SaveProjectFile() 41 | 42 | except Exception as e: 43 | print("Error:", e) 44 | raise 45 | -------------------------------------------------------------------------------- /scripts/generate-cmd.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | SET script_name=%1.py 3 | SET cmd_name=%1.cmd 4 | 5 | echo Generating %cmd_name%... 6 | echo @echo off > %cmd_name% 7 | echo call envsubtrans\Scripts\activate.bat >> %cmd_name% 8 | echo envsubtrans\Scripts\python.exe scripts\%script_name% %%* >> %cmd_name% 9 | echo call envsubtrans\Scripts\deactivate >> %cmd_name% 10 | -------------------------------------------------------------------------------- /scripts/generate-cmd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Assigning the script name and command file name 4 | script_name=$1.py 5 | cmd_name=$1.sh 6 | 7 | # Displaying the generation message 8 | echo "Generating $cmd_name..." 9 | 10 | # Creating a new command file with the necessary commands 11 | echo "#!/bin/bash" > "$cmd_name" 12 | echo "echo 'Activating virtual environment...'" >> "$cmd_name" 13 | echo "source envsubtrans/bin/activate" >> "$cmd_name" 14 | echo "envsubtrans/bin/python scripts/$script_name" '"$@"' >> "$cmd_name" 15 | echo "echo 'Deactivating virtual environment...'" >> "$cmd_name" 16 | echo "deactivate" >> "$cmd_name" 17 | 18 | # Making the generated command script executable 19 | chmod +x "$cmd_name" 20 | -------------------------------------------------------------------------------- /scripts/gpt-subtrans.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from check_imports import check_required_imports 5 | check_required_imports(['PySubtrans', 'openai'], 'openai') 6 | 7 | from scripts.subtrans_common import ( 8 | InitLogger, 9 | CreateArgParser, 10 | CreateOptions, 11 | CreateProject, 12 | ) 13 | 14 | from PySubtrans import init_translator 15 | from PySubtrans.Options import Options 16 | from PySubtrans.SubtitleProject import SubtitleProject 17 | 18 | # We'll write separate scripts for other providers 19 | provider = "OpenAI" 20 | default_model = os.getenv('OPENAI_MODEL') or "gpt-4o" 21 | 22 | parser = CreateArgParser(f"Translates subtitles using an OpenAI model") 23 | parser.add_argument('-k', '--apikey', type=str, default=None, help=f"Your OpenAI API Key (https://platform.openai.com/account/api-keys)") 24 | parser.add_argument('-b', '--apibase', type=str, default="https://api.openai.com/v1", help="API backend base address.") 25 | parser.add_argument('-m', '--model', type=str, default=None, help="The model to use for translation") 26 | parser.add_argument('--httpx', action='store_true', help="Use the httpx library for custom api_base requests. May help if you receive a 307 redirect error.") 27 | parser.add_argument('--proxy', type=str, default=None, help="SOCKS proxy URL (e.g., socks://127.0.0.1:1089)") 28 | args = parser.parse_args() 29 | 30 | logger_options = InitLogger("gpt-subtrans", args.debug) 31 | 32 | try: 33 | options : Options = CreateOptions( 34 | args, 35 | provider, 36 | use_httpx=args.httpx, 37 | api_base=args.apibase, 38 | proxy=args.proxy, 39 | model=args.model or default_model 40 | ) 41 | 42 | # Create a project for the translation 43 | project : SubtitleProject = CreateProject(options, args) 44 | 45 | # Translate the subtitles 46 | translator = init_translator(options) 47 | project.TranslateSubtitles(translator) 48 | 49 | if project.use_project_file: 50 | logging.info(f"Writing project data to {str(project.projectfile)}") 51 | project.SaveProjectFile() 52 | 53 | except Exception as e: 54 | print("Error:", e) 55 | raise 56 | -------------------------------------------------------------------------------- /scripts/gui-subtrans.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | source envsubtrans/bin/activate 3 | python gui-subtrans.py 4 | -------------------------------------------------------------------------------- /scripts/llm-subtrans.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from check_imports import check_required_imports 4 | check_required_imports(['PySubtrans']) 5 | 6 | from scripts.subtrans_common import ( 7 | InitLogger, 8 | CreateArgParser, 9 | CreateOptions, 10 | CreateProject, 11 | ) 12 | 13 | from PySubtrans import init_translator 14 | from PySubtrans.Options import Options 15 | from PySubtrans.SubtitleProject import SubtitleProject 16 | 17 | # Parse command line arguments 18 | parser = CreateArgParser("Translates subtitles using OpenRouter or a custom AI model server") 19 | parser.add_argument('-s', '--server', type=str, default=None, help="Address of the server including port (e.g. http://localhost:1234). If not specified, uses OpenRouter") 20 | parser.add_argument('-e', '--endpoint', type=str, default=None, help="Endpoint to call on the server (e.g. /v1/completions)") 21 | parser.add_argument('-k', '--apikey', type=str, default=None, help="API Key (if required)") 22 | parser.add_argument('-m', '--model', type=str, default=None, help="Model to use if the server allows it to be specified") 23 | parser.add_argument('--auto', action='store_true', help="Use OpenRouter's automatic model selection") 24 | parser.add_argument('--chat', action='store_true', help="Use chat format requests for the endpoint") 25 | parser.add_argument('--systemmessages', action='store_true', help="Indicates that the endpoint supports system messages in chat requests") 26 | args = parser.parse_args() 27 | 28 | # Determine provider based on whether server is specified 29 | provider = "Custom Server" if args.server else "OpenRouter" 30 | 31 | logger_options = InitLogger("llm-subtrans", args.debug) 32 | 33 | try: 34 | if provider == "OpenRouter": 35 | options : Options = CreateOptions( 36 | args, 37 | provider, 38 | api_key=args.apikey, 39 | model=args.model, 40 | use_default_model=args.auto 41 | ) 42 | else: 43 | options : Options = CreateOptions( 44 | args, 45 | provider, 46 | api_key=args.apikey, 47 | endpoint=args.endpoint, 48 | model=args.model, 49 | server_address=args.server, 50 | supports_conversation=args.chat, 51 | supports_system_messages=args.systemmessages 52 | ) 53 | 54 | # Create a project for the translation 55 | project : SubtitleProject = CreateProject(options, args) 56 | 57 | translator = init_translator(options) 58 | 59 | project.TranslateSubtitles(translator) 60 | 61 | if project.use_project_file: 62 | logging.info(f"Writing project data to {str(project.projectfile)}") 63 | project.SaveProjectFile() 64 | 65 | except Exception as e: 66 | print("Error:", e) 67 | raise 68 | -------------------------------------------------------------------------------- /scripts/makedistro-mac.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source ./envsubtrans/bin/activate 4 | python scripts/sync_version.py 5 | pip3 install --upgrade pip 6 | pip install --upgrade pyinstaller 7 | pip install --upgrade PyInstaller pyinstaller-hooks-contrib 8 | pip install --upgrade setuptools 9 | pip install --upgrade jaraco.text 10 | pip install --upgrade charset_normalizer 11 | pip install --upgrade -e ".[gui,openai,gemini,claude,mistral]" 12 | 13 | # Remove boto3 from packaged version 14 | pip uninstall boto3 15 | 16 | ./envsubtrans/bin/python scripts/update_translations.py 17 | 18 | ./envsubtrans/bin/python tests/unit_tests.py 19 | if [ $? -ne 0 ]; then 20 | echo "Unit tests failed. Exiting..." 21 | exit $? 22 | fi 23 | 24 | ./envsubtrans/bin/pyinstaller --noconfirm \ 25 | --additional-hooks-dir="hooks" \ 26 | --paths="./envsubtrans/lib" \ 27 | --add-data "theme/*:theme/" \ 28 | --add-data "assets/*:assets/" \ 29 | --add-data "instructions/*:instructions/" \ 30 | --add-data "LICENSE:." \ 31 | --add-data "locales/*:locales/" \ 32 | --noconfirm \ 33 | scripts/gui-subtrans.py 34 | -------------------------------------------------------------------------------- /scripts/makedistro.bat: -------------------------------------------------------------------------------- 1 | call envsubtrans/scripts/activate 2 | python scripts/sync_version.py 3 | python.exe -m pip install --upgrade pip 4 | pip install pywin32-ctypes 5 | pip install --upgrade pyinstaller 6 | pip install --upgrade -e ".[gui,openai,gemini,claude,mistral]" 7 | rem pip install --upgrade "boto3" REM Bedrock dependencies excluded 8 | 9 | rem Update and compile localization files before tests/build 10 | .\envsubtrans\scripts\python.exe scripts/update_translations.py 11 | 12 | .\envsubtrans\scripts\python.exe tests/unit_tests.py 13 | if %errorlevel% neq 0 ( 14 | echo Unit tests failed. Exiting... 15 | exit /b %errorlevel% 16 | ) 17 | 18 | .\envsubtrans\scripts\pyinstaller --noconfirm ^ 19 | --additional-hooks-dir="hooks" ^ 20 | --add-data "theme/*;theme/" ^ 21 | --add-data "assets/*;assets/" ^ 22 | --add-data "instructions/*;instructions/" ^ 23 | --add-data "LICENSE;." ^ 24 | --add-data "locales/*;locales/" ^ 25 | "scripts/gui-subtrans.py" 26 | -------------------------------------------------------------------------------- /scripts/makedistro.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source envsubtrans/bin/activate 4 | python scripts/sync_version.py 5 | pip install --upgrade -e ".[gui,openai,gemini,claude,mistral,bedrock]" 6 | 7 | python scripts/update_translations.py 8 | 9 | pyinstaller --noconfirm --additional-hooks-dir="hooks-subtrans" \ 10 | --add-data "theme/*:theme/" --add-data "assets/*:assets/" \ 11 | --add-data "instructions/*:instructions/" \ 12 | --add-data "LICENSE:." \ 13 | --add-data "assets/gui-subtrans.ico:." \ 14 | --add-data "locales/*:locales/" \ 15 | scripts/gui-subtrans.py 16 | -------------------------------------------------------------------------------- /scripts/mistral-subtrans.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from check_imports import check_required_imports 5 | check_required_imports(['PySubtrans', 'mistralai'], 'mistral') 6 | 7 | from scripts.subtrans_common import ( 8 | InitLogger, 9 | CreateArgParser, 10 | CreateOptions, 11 | CreateProject, 12 | ) 13 | 14 | from PySubtrans import init_translator 15 | from PySubtrans.Options import Options 16 | from PySubtrans.SubtitleProject import SubtitleProject 17 | 18 | provider = "Mistral" 19 | default_model = os.getenv('MISTRAL_MODEL') or "open-mistral-nemo" 20 | 21 | parser = CreateArgParser(f"Translates subtitles using an Mistral model") 22 | parser.add_argument('-k', '--apikey', type=str, default=None, help=f"Your Mistral API Key (https://console.mistral.ai/api-keys/)") 23 | parser.add_argument('-m', '--model', type=str, default=None, help="The model to use for translation") 24 | parser.add_argument('--server_url', type=str, default=None, help="Server URL (leave blank for default).") 25 | args = parser.parse_args() 26 | 27 | logger_options = InitLogger("mistral-subtrans", args.debug) 28 | 29 | try: 30 | options : Options = CreateOptions( 31 | args, 32 | provider, 33 | server_url=args.server_url, 34 | model=args.model or default_model 35 | ) 36 | 37 | # Create a project for the translation 38 | project : SubtitleProject = CreateProject(options, args) 39 | 40 | # Translate the subtitles 41 | translator = init_translator(options) 42 | project.TranslateSubtitles(translator) 43 | 44 | if project.use_project_file: 45 | logging.info(f"Writing project data to {str(project.projectfile)}") 46 | project.SaveProjectFile() 47 | 48 | except Exception as e: 49 | print("Error:", e) 50 | raise 51 | -------------------------------------------------------------------------------- /scripts/sync_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | import sys 4 | from pathlib import Path 5 | 6 | project_root = Path(__file__).resolve().parent.parent 7 | sys.path.insert(0, str(project_root)) 8 | from PySubtrans import version as ver 9 | 10 | pyproject = project_root / "pyproject.toml" 11 | version = ver.__version__.lstrip('v') 12 | content = pyproject.read_text() 13 | content = re.sub(r'^version = ".*"', f'version = "{version}"', content, flags=re.MULTILINE) 14 | pyproject.write_text(content) 15 | print(f"Synced pyproject.toml version to {version}") 16 | -------------------------------------------------------------------------------- /tests/GuiTests/DataModelHelpers.py: -------------------------------------------------------------------------------- 1 | from GuiSubtrans.ProjectDataModel import ProjectDataModel 2 | from PySubtrans.Helpers.TestCases import AddTranslations, PrepareSubtitles 3 | from PySubtrans.Options import Options 4 | from PySubtrans.SettingsType import SettingsType 5 | from PySubtrans.SubtitleBatcher import SubtitleBatcher 6 | from PySubtrans.SubtitleEditor import SubtitleEditor 7 | from PySubtrans.Subtitles import Subtitles 8 | from PySubtrans.SubtitleProject import SubtitleProject 9 | 10 | 11 | def CreateTestDataModel(test_data : dict, options : Options|None = None) -> ProjectDataModel: 12 | """ 13 | Creates a ProjectDataModel from test data. 14 | """ 15 | options = options or Options() 16 | file : Subtitles = PrepareSubtitles(test_data, 'original') 17 | project = SubtitleProject(persistent = options.use_project_file) 18 | project.write_translation = False 19 | project.subtitles = file 20 | project.UpdateProjectSettings(options) 21 | datamodel = ProjectDataModel(project, options) 22 | datamodel.UpdateProviderSettings(SettingsType({"data" : test_data})) 23 | return datamodel 24 | 25 | def CreateTestDataModelBatched(test_data : dict, options : Options|None = None, translated : bool = True) -> ProjectDataModel: 26 | """ 27 | Creates a SubtitleBatcher from test data. 28 | """ 29 | datamodel : ProjectDataModel = CreateTestDataModel(test_data, options) 30 | if not datamodel.project: 31 | raise ValueError("Project not created in datamodel") 32 | 33 | options = options or datamodel.project_options 34 | 35 | subtitles : Subtitles = datamodel.project.subtitles 36 | batcher = SubtitleBatcher(options.GetSettings()) 37 | with SubtitleEditor(subtitles) as editor: 38 | editor.AutoBatch(batcher) 39 | 40 | if translated and 'translated' in test_data: 41 | AddTranslations(subtitles, test_data, 'translated') 42 | 43 | return datamodel 44 | 45 | -------------------------------------------------------------------------------- /tests/GuiTests/__init__.py: -------------------------------------------------------------------------------- 1 | # This module is left intentionally minimal. 2 | # Test discovery is now handled automatically by unittest.TestLoader().discover() 3 | # in the main test runner at tests/unit_tests.py -------------------------------------------------------------------------------- /tests/GuiTests/test_BatchCommands.py: -------------------------------------------------------------------------------- 1 | from GuiSubtrans.Commands.BatchSubtitlesCommand import BatchSubtitlesCommand 2 | 3 | from GuiSubtrans.ProjectDataModel import ProjectDataModel 4 | from .DataModelHelpers import CreateTestDataModel 5 | from PySubtrans.Helpers.TestCases import SubtitleTestCase 6 | from PySubtrans.Helpers.Tests import log_test_name 7 | from PySubtrans.Subtitles import Subtitles 8 | from ..TestData.chinese_dinner import chinese_dinner_data 9 | 10 | class BatchCommandTests(SubtitleTestCase): 11 | batch_command_test_cases = [ 12 | { 13 | 'data': chinese_dinner_data, 14 | 'expected_scene_count': 4, 15 | 'expected_scene_sizes': [2, 2, 1, 1], 16 | 'expected_scene_linecounts': [30, 25, 6, 3], 17 | 'expected_scene_batch_sizes': [[14, 16], [12, 13], [6], [3]], 18 | } 19 | ] 20 | 21 | def test_BatchCommands(self): 22 | for test_case in self.batch_command_test_cases: 23 | data = test_case['data'] 24 | log_test_name(f"Testing batch command on {data.get('movie_name')}") 25 | 26 | datamodel : ProjectDataModel = CreateTestDataModel(data, self.options) 27 | if not datamodel or not datamodel.project or not datamodel.project.subtitles: 28 | self.fail("Failed to create datamodel for test case") 29 | continue 30 | 31 | file : Subtitles = datamodel.project.subtitles 32 | 33 | with self.subTest("BatchSubtitlesCommand"): 34 | self.BatchSubtitlesCommandTest(file, datamodel, test_case) 35 | 36 | 37 | def BatchSubtitlesCommandTest(self, file : Subtitles, datamodel : ProjectDataModel, test_data : dict): 38 | if not datamodel.project or not datamodel.project.subtitles: 39 | self.fail("Failed to create datamodel for test case") 40 | return 41 | 42 | expected_scene_count = test_data['expected_scene_count'] 43 | expected_scene_sizes = test_data['expected_scene_sizes'] 44 | expected_scene_linecounts = test_data['expected_scene_linecounts'] 45 | expected_scene_batch_sizes = test_data['expected_scene_batch_sizes'] 46 | 47 | batch_command = BatchSubtitlesCommand(datamodel.project, datamodel.project_options) 48 | self.assertTrue(batch_command.execute()) 49 | self.assertTrue(len(file.scenes) > 0) 50 | 51 | self.assertLoggedEqual("scene count", expected_scene_count, len(file.scenes)) 52 | 53 | scene_sizes = [scene.size for scene in file.scenes] 54 | scene_linecounts = [scene.linecount for scene in file.scenes] 55 | scene_batch_sizes = [[batch.size for batch in scene.batches] for scene in file.scenes] 56 | 57 | self.assertLoggedSequenceEqual("scene sizes", expected_scene_sizes, scene_sizes) 58 | self.assertLoggedSequenceEqual("scene line counts", expected_scene_linecounts, scene_linecounts) 59 | self.assertLoggedSequenceEqual("scene batch sizes", expected_scene_batch_sizes, scene_batch_sizes) 60 | 61 | -------------------------------------------------------------------------------- /tests/GuiTests/test_DeleteLinesCommand.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | from GuiSubtrans.Commands.DeleteLinesCommand import DeleteLinesCommand 4 | from GuiSubtrans.ProjectDataModel import ProjectDataModel 5 | from .DataModelHelpers import CreateTestDataModelBatched 6 | from PySubtrans.Helpers.TestCases import SubtitleTestCase 7 | from PySubtrans.Helpers.Tests import log_info 8 | from PySubtrans.Subtitles import Subtitles 9 | from ..TestData.chinese_dinner import chinese_dinner_data 10 | 11 | class DeleteLinesCommandTest(SubtitleTestCase): 12 | delete_lines_test_cases = [ 13 | { 14 | 'batch_number': (1,1), 15 | 'lines_to_delete': [1,2,3], 16 | 'expected_batch_size': 11, 17 | 'expected_line_numbers': [4,5,6,7,8,9,10,11,12,13,14] 18 | }, 19 | { 20 | 'batch_number': (1,1), 21 | 'lines_to_delete': [9,10], 22 | 'expected_batch_size': 12, 23 | 'expected_line_numbers': [1,2,3,4,5,6,7,8,11,12,13,14] 24 | }, 25 | { 26 | 'batch_number': (1,1), 27 | 'lines_to_delete': [14], 28 | 'expected_batch_size': 13, 29 | 'expected_line_numbers': [1,2,3,4,5,6,7,8,9,10,11,12,13] 30 | }, 31 | { 32 | 'batch_number': (1,2), 33 | 'lines_to_delete': [15], 34 | 'expected_batch_size': 15, 35 | 'expected_line_numbers': [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30] 36 | }, 37 | { 38 | 'batch_number': (2,1), 39 | 'lines_to_delete': [33,34,35,36,37,38,39,40,41,42], 40 | 'expected_batch_size': 2, 41 | 'expected_line_numbers': [31,32] 42 | } 43 | ] 44 | 45 | 46 | def test_DeleteLinesCommand(self): 47 | 48 | data = deepcopy(chinese_dinner_data) 49 | 50 | datamodel : ProjectDataModel = CreateTestDataModelBatched(data, options=self.options) 51 | if not datamodel.project or not datamodel.project.subtitles: 52 | self.fail("Failed to create test datamodel with subtitles") 53 | return 54 | 55 | subtitles: Subtitles = datamodel.project.subtitles 56 | 57 | for test_case in self.delete_lines_test_cases: 58 | scene_number, batch_number = test_case['batch_number'] 59 | lines_to_delete = test_case['lines_to_delete'] 60 | 61 | expected_batch_size = test_case['expected_batch_size'] 62 | expected_line_numbers = test_case['expected_line_numbers'] 63 | 64 | batch = subtitles.GetBatch(scene_number, batch_number) 65 | 66 | initial_batch_size = len(batch.originals) 67 | initial_line_numbers = [line.number for line in batch.originals] 68 | initial_line_contents = [line.text for line in batch.originals] 69 | initial_translated_contents = [line.text for line in batch.translated] 70 | 71 | log_info(f"Deleting lines {lines_to_delete} from batch {scene_number}.{batch_number}") 72 | 73 | self.assertTrue(all([any([line.number == line_number for line in batch.originals]) for line_number in lines_to_delete])) 74 | 75 | command = DeleteLinesCommand(lines_to_delete, datamodel=datamodel) 76 | self.assertTrue(command.execute()) 77 | 78 | self.assertLoggedEqual("batch size after delete", expected_batch_size, len(batch.originals)) 79 | self.assertSequenceEqual([line.number for line in batch.originals], expected_line_numbers) 80 | 81 | self.assertTrue(command.can_undo) 82 | self.assertTrue(command.undo()) 83 | 84 | self.assertLoggedEqual("batch size after undo", initial_batch_size, len(batch.originals)) 85 | self.assertSequenceEqual([line.number for line in batch.originals], initial_line_numbers) 86 | self.assertSequenceEqual([line.text for line in batch.originals], initial_line_contents) 87 | self.assertSequenceEqual([line.text for line in batch.translated], initial_translated_contents) 88 | 89 | 90 | -------------------------------------------------------------------------------- /tests/PySubtransTests/__init__.py: -------------------------------------------------------------------------------- 1 | # This module is left intentionally minimal. 2 | # Test discovery is now handled automatically by unittest.TestLoader().discover() 3 | -------------------------------------------------------------------------------- /tests/PySubtransTests/test_Parse.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from enum import Enum 3 | 4 | from PySubtrans.Helpers import GetValueName, GetValueFromName 5 | from PySubtrans.Helpers.Parse import ParseDelayFromHeader, ParseNames 6 | from PySubtrans.Helpers.TestCases import LoggedTestCase 7 | 8 | 9 | class TestParseDelayFromHeader(LoggedTestCase): 10 | test_cases = [ 11 | ("5", 5.0), 12 | ("10s", 10.0), 13 | ("5m", 300.0), 14 | ("500ms", 1.0), 15 | ("1500ms", 1.5), 16 | ("abc", 32.1), 17 | ] 18 | 19 | def test_ParseDelayFromHeader(self): 20 | for value, expected in self.test_cases: 21 | with self.subTest(value=value): 22 | result = ParseDelayFromHeader(value) 23 | self.assertLoggedEqual(f"delay parsed from {value}", expected, result, input_value=value) 24 | 25 | 26 | class TestParseNames(LoggedTestCase): 27 | test_cases = [ 28 | ("John, Jane, Alice", ["John", "Jane", "Alice"]), 29 | (["John", "Jane", "Alice"], ["John", "Jane", "Alice"]), 30 | ("Mike, Murray, Mabel, Marge", ["Mike", "Murray", "Mabel", "Marge"]), 31 | ("", []), 32 | ([] , []), 33 | ([""], []) 34 | ] 35 | 36 | def test_ParseNames(self): 37 | for value, expected in self.test_cases: 38 | with self.subTest(value=value): 39 | result = ParseNames(value) 40 | self.assertLoggedSequenceEqual( 41 | f"names parsed from {value}", 42 | expected, 43 | result, 44 | input_value=value, 45 | ) 46 | 47 | class TestParseValues(LoggedTestCase): 48 | class TestEnum(Enum): 49 | Test1 = 1 50 | Test2 = 2 51 | TestValue = 4 52 | TestExample = 5 53 | 54 | class TestObject: 55 | def __init__(self, name): 56 | self.name = name 57 | 58 | get_value_name_cases = [ 59 | (12345, "12345"), 60 | (True, "True"), 61 | ("Test", "Test"), 62 | ("TEST", "TEST"), 63 | ("TestName", "TestName"), 64 | (TestEnum.Test1, "Test1"), 65 | (TestEnum.Test2, "Test2"), 66 | (TestEnum.TestValue, "Test Value"), 67 | (TestEnum.TestExample, "Test Example"), 68 | (TestObject("Test Object"), "Test Object") 69 | ] 70 | 71 | def test_GetValueName(self): 72 | for value, expected in self.get_value_name_cases: 73 | with self.subTest(value=value): 74 | result = GetValueName(value) 75 | self.assertLoggedEqual(f"name for {value}", expected, result, input_value=value) 76 | 77 | get_value_from_name_cases = [ 78 | ("Test Name", ["Test Name", "Another Name", "Yet Another Name"], None, "Test Name"), 79 | ("Nonexistent Name", ["Test Name", "Another Name", "Yet Another Name"], "Default Value", "Default Value"), 80 | (34567, [12345, 34567, 98765], None, 34567), 81 | ("12345", [12345, 34567, 98765], None, 12345), 82 | ("Test2", TestEnum, None, TestEnum.Test2) 83 | ] 84 | 85 | def test_GetValueFromName(self): 86 | for value, names, default, expected in self.get_value_from_name_cases: 87 | with self.subTest(value=value): 88 | result = GetValueFromName(value, names, default) 89 | self.assertLoggedEqual( 90 | "value from name", 91 | expected, 92 | result, 93 | input_value=(value, names, default), 94 | ) 95 | 96 | if __name__ == '__main__': 97 | unittest.main() 98 | -------------------------------------------------------------------------------- /tests/PySubtransTests/test_Substitutions.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from PySubtrans.Substitutions import Substitutions 3 | from PySubtrans.Helpers.TestCases import LoggedTestCase 4 | 5 | 6 | class TestSubstitutions(LoggedTestCase): 7 | def setUp(self) -> None: 8 | super().setUp() 9 | parse_cases = [ 10 | ([], {}), 11 | ("", {}), 12 | ({"before": "after", "hello": "world"}, {"before": "after", "hello": "world"}), 13 | ("before::after\nhello::world", {"before": "after", "hello": "world"}), 14 | (["before::after", "hello::world"], {"before": "after", "hello": "world"}), 15 | ] 16 | 17 | def test_ParseSubstitutions(self): 18 | for value, expected in self.parse_cases: 19 | with self.subTest(value=value): 20 | result = Substitutions.Parse(value) 21 | self.assertLoggedEqual( 22 | f"ParseSubstitutions({value!r})", 23 | expected, 24 | result, 25 | ) 26 | 27 | perform_cases = [ 28 | (["before::after", "hello::world"], "before hello", "after world", Substitutions.Mode.WholeWords), 29 | ({"before": "after", "hello": "world"}, "This is the string before", "This is the string after", Substitutions.Mode.WholeWords), 30 | ({"bef": "aft", "hello": "world"}, "before hello", "before world", Substitutions.Mode.WholeWords), 31 | ({"Shaw Brothers": "Snowboarders"}, "Shaw Brothers is a film company", "Snowboarders is a film company", Substitutions.Mode.WholeWords), 32 | ({"Shaw Brothers": "Snow Boarders"}, "We all love Shaw Brothers films here!", "We all love Snow Boarders films here!", Substitutions.Mode.WholeWords), 33 | ({"李王": "Li Wang"}, "Mr. 李王 is a Chinese name", "Mr. Li Wang is a Chinese name", Substitutions.Mode.WholeWords), 34 | ({"東京": "Tokyo"}, "東京 is the capital of Japan", "Tokyo is the capital of Japan", Substitutions.Mode.WholeWords), 35 | ({"big": "small"}, "The big brown fox jumped over the big fence", "The small brown fox jumped over the small fence", Substitutions.Mode.WholeWords), 36 | ({"東京": "Tokyo"}, "東京都は日本の首都です", "Tokyo都は日本の首都です", Substitutions.Mode.PartialWords), 37 | ({"李王": "Li Wang"}, "在学术交流会上,李王详细阐述了他的观点。晚宴上,大家继续讨论李王的提议。", "在学术交流会上,Li Wang详细阐述了他的观点。晚宴上,大家继续讨论Li Wang的提议。", Substitutions.Mode.PartialWords), 38 | ] 39 | 40 | def test_PerformSubstitutions(self): 41 | for substitutions, value, expected, mode in self.perform_cases: 42 | with self.subTest(value=value): 43 | helper = Substitutions(substitutions, mode) 44 | result = helper.PerformSubstitutions(value) 45 | self.assertLoggedEqual( 46 | f"PerformSubstitutions({value!r}, mode={mode.name}) using {substitutions!r}", 47 | expected, 48 | result, 49 | ) 50 | 51 | def test_PerformSubstitutionsAuto(self): 52 | for substitutions, value, expected, _ in self.perform_cases: 53 | with self.subTest(value=value): 54 | helper = Substitutions(substitutions, Substitutions.Mode.Auto) 55 | result = helper.PerformSubstitutions(value) 56 | self.assertLoggedEqual( 57 | f"PerformSubstitutionsAuto({value!r}) using {substitutions!r}", 58 | expected, 59 | result, 60 | ) 61 | 62 | if __name__ == '__main__': 63 | unittest.main() 64 | -------------------------------------------------------------------------------- /tests/PySubtransTests/test_SubtitleValidator.py: -------------------------------------------------------------------------------- 1 | from PySubtrans.Helpers.TestCases import LoggedTestCase 2 | from PySubtrans.Options import Options 3 | from PySubtrans.SubtitleBatch import SubtitleBatch 4 | from PySubtrans.SubtitleLine import SubtitleLine 5 | from PySubtrans.SubtitleValidator import SubtitleValidator 6 | from PySubtrans.SubtitleError import ( 7 | UnmatchedLinesError, 8 | EmptyLinesError, 9 | LineTooLongError, 10 | TooManyNewlinesError, 11 | UntranslatedLinesError, 12 | ) 13 | 14 | 15 | class TestSubtitleValidator(LoggedTestCase): 16 | def test_ValidateTranslations_empty(self): 17 | validator = SubtitleValidator(Options()) 18 | errors = validator.ValidateTranslations([]) 19 | self.assertLoggedEqual("error_count", 1, len(errors)) 20 | self.assertLoggedIsInstance("error type", errors[0], UntranslatedLinesError) 21 | 22 | def test_ValidateTranslations_detects_errors(self): 23 | options = Options({'max_characters': 10, 'max_newlines': 1}) 24 | validator = SubtitleValidator(options) 25 | 26 | line_no_number = SubtitleLine({'start': '00:00:00,000', 'end': '00:00:01,000', 'text': 'valid'}) 27 | line_no_text = SubtitleLine({'number': 1, 'start': '00:00:00,000', 'end': '00:00:01,000'}) 28 | line_too_long = SubtitleLine({'number': 2, 'start': '00:00:00,000', 'end': '00:00:01,000', 'text': 'abcdefghijklmnopqrstuvwxyz'}) 29 | line_too_many_newlines = SubtitleLine({'number': 3, 'start': '00:00:00,000', 'end': '00:00:01,000', 'text': 'a\nb\nc'}) 30 | 31 | errors = validator.ValidateTranslations([line_no_number, line_no_text, line_too_long, line_too_many_newlines]) 32 | expected_types = [UnmatchedLinesError, EmptyLinesError, LineTooLongError, TooManyNewlinesError] 33 | self.assertLoggedEqual("error_count", len(expected_types), len(errors)) 34 | 35 | actual_error_types = {type(e) for e in errors} 36 | expected_error_types = set(expected_types) 37 | self.assertLoggedEqual("error types", expected_error_types, actual_error_types) 38 | 39 | def test_ValidateBatch_adds_untranslated_error(self): 40 | validator = SubtitleValidator(Options()) 41 | 42 | orig1 = SubtitleLine({'number': 1, 'start': '00:00:00,000', 'end': '00:00:01,000', 'text': 'original1'}) 43 | orig2 = SubtitleLine({'number': 2, 'start': '00:00:01,000', 'end': '00:00:02,000', 'text': 'original2'}) 44 | trans1 = SubtitleLine({'number': 1, 'start': '00:00:00,000', 'end': '00:00:01,000', 'text': 'translated1'}) 45 | batch = SubtitleBatch({'originals': [orig1, orig2], 'translated': [trans1]}) 46 | 47 | validator.ValidateBatch(batch) 48 | self.assertLoggedEqual("error_count", 1, len(batch.errors)) 49 | self.assertLoggedIsInstance("error type", batch.errors[0], UntranslatedLinesError) 50 | 51 | def test_ValidateBatch_includes_translation_errors(self): 52 | options = Options({'max_characters': 10}) 53 | validator = SubtitleValidator(options) 54 | 55 | orig1 = SubtitleLine({'number': 1, 'start': '00:00:00,000', 'end': '00:00:01,000', 'text': 'original1'}) 56 | orig2 = SubtitleLine({'number': 2, 'start': '00:00:01,000', 'end': '00:00:02,000', 'text': 'original2'}) 57 | # This translated line is too long 58 | trans1 = SubtitleLine({'number': 1, 'start': '00:00:00,000', 'end': '00:00:01,000', 'text': 'this is a very long translated line'}) 59 | batch = SubtitleBatch({'originals': [orig1, orig2], 'translated': [trans1]}) 60 | 61 | validator.ValidateBatch(batch) 62 | 63 | error_types = {type(e) for e in batch.errors} 64 | self.assertLoggedEqual( 65 | "batch error types", 66 | {LineTooLongError, UntranslatedLinesError}, 67 | error_types, 68 | ) 69 | self.assertIn(LineTooLongError, error_types) 70 | self.assertIn(UntranslatedLinesError, error_types) 71 | -------------------------------------------------------------------------------- /tests/PySubtransTests/test_localization.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from PySubtrans.Helpers.TestCases import LoggedTestCase 3 | from PySubtrans.Helpers.Tests import skip_if_debugger_attached 4 | from PySubtrans.Helpers.Localization import ( 5 | initialize_localization, 6 | set_language, 7 | _, 8 | tr, 9 | get_available_locales, 10 | get_locale_display_name, 11 | ) 12 | 13 | class TestLocalization(LoggedTestCase): 14 | def test_initialize_default_english(self): 15 | initialize_localization("en") 16 | text = "Cancel" 17 | result = _(text) 18 | self.assertLoggedEqual("default english translation", text, result) 19 | 20 | # tr() should match _() when no context-specific entry exists 21 | ctx_result = tr("dialog", text) 22 | self.assertLoggedEqual("context translation matches", text, ctx_result, input_value=("dialog", text)) 23 | 24 | def test_switch_to_spanish_and_back(self): 25 | # Switch to Spanish and verify a commonly-translated label 26 | initialize_localization("es") 27 | es_result = _("Cancel") 28 | self.assertLoggedEqual("spanish translation", "Cancelar", es_result) 29 | 30 | # tr() should also use the active language 31 | es_ctx_result = tr("menu", "Cancel") 32 | self.assertLoggedEqual("spanish context translation", "Cancelar", es_ctx_result, input_value=("menu", "Cancel")) 33 | 34 | # Now switch back to English 35 | set_language("en") 36 | en_result = _("Cancel") 37 | self.assertLoggedEqual("english translation after switch", "Cancel", en_result) 38 | 39 | @skip_if_debugger_attached 40 | def test_missing_language_fallback(self): 41 | initialize_localization("zz") # non-existent locale 42 | # Should gracefully fall back to identity translation 43 | result = _("Cancel") 44 | self.assertLoggedEqual("fallback translation", "Cancel", result) 45 | 46 | def test_placeholder_formatting(self): 47 | initialize_localization("es") 48 | # This msgid has a Spanish translation with the same {file} placeholder 49 | msgid = "Executing LoadSubtitleFile {file}" 50 | translated = _(msgid) 51 | formatted = translated.format(file="ABC.srt") 52 | expected_start = "Ejecutando" 53 | self.assertLoggedTrue( 54 | "placeholder preserved", 55 | translated.startswith(expected_start), 56 | input_value=(msgid, "{file}=ABC.srt"), 57 | ) 58 | # Ensure placeholder survived translation and formats correctly 59 | self.assertLoggedEqual("formatted first word", "Ejecutando", formatted.split()[0], input_value=formatted) 60 | self.assertIn("ABC.srt", formatted) 61 | 62 | def test_available_locales_and_display_name(self): 63 | locales = get_available_locales() 64 | # Expect at least English and Spanish present in repo 65 | self.assertIn("en", locales) 66 | self.assertIn("es", locales) 67 | 68 | # Display name should be a non-empty string regardless of Babel availability 69 | name = get_locale_display_name("es") 70 | self.assertLoggedTrue( 71 | "locale display name present", 72 | isinstance(name, str) and len(name) > 0, 73 | input_value=name, 74 | ) 75 | self.assertIsInstance(name, str) 76 | self.assertGreater(len(name), 0) 77 | 78 | 79 | if __name__ == '__main__': 80 | unittest.main() 81 | -------------------------------------------------------------------------------- /tests/functional/batcher_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | from PySubtrans.SubtitleBatcher import SubtitleBatcher 3 | from PySubtrans.Subtitles import Subtitles 4 | from PySubtrans.Helpers.Tests import RunTestOnAllSubtitleFiles, separator 5 | 6 | def analyze_scenes(scenes): 7 | num_scenes = len(scenes) 8 | num_batches_list = [] 9 | largest_batch_list = [] 10 | smallest_batch_list = [] 11 | average_batch_size_list = [] 12 | 13 | for scene in scenes: 14 | num_batches = len(scene.batches) 15 | batch_sizes = [batch.size for batch in scene.batches] 16 | 17 | largest_batch = max(batch_sizes) 18 | smallest_batch = min(batch_sizes) 19 | average_batch_size = sum(batch_sizes) / num_batches 20 | 21 | num_batches_list.append(num_batches) 22 | largest_batch_list.append(largest_batch) 23 | smallest_batch_list.append(smallest_batch) 24 | average_batch_size_list.append(average_batch_size) 25 | 26 | return num_scenes, num_batches_list, largest_batch_list, smallest_batch_list, average_batch_size_list 27 | 28 | def batcher_test(subtitles: Subtitles, logger, options): 29 | if not subtitles.originals: 30 | raise Exception("No original subtitles to batch") 31 | 32 | try: 33 | batcher = SubtitleBatcher(options) 34 | scenes = batcher.BatchSubtitles(subtitles.originals) 35 | except Exception as e: 36 | raise Exception(f"Error in batcher.BatchSubtitles: {e}") 37 | 38 | # Analyze scenes 39 | num_scenes, num_batches_list, largest_batch_list, smallest_batch_list, avg_batch_list = analyze_scenes(scenes) 40 | 41 | total_batches = sum(num_batches_list) 42 | total_largest = max(largest_batch_list) 43 | total_smallest = min(smallest_batch_list) 44 | total_avg = sum(avg_batch_list) / num_scenes 45 | 46 | logger.info(separator) 47 | logger.info(f"Total (min {options['min_batch_size']}, max {options['max_batch_size']}, scene {options['scene_threshold']})") 48 | logger.info(separator) 49 | logger.info(f"{'Total Batches':<25}{total_batches:<10}") 50 | logger.info(f"{'Total Largest Batch':<25}{total_largest:<10}") 51 | logger.info(f"{'Total Smallest Batch':<25}{total_smallest:<10}") 52 | logger.info(f"{'Average Batch Size':<25}{total_avg:<10.2f}") 53 | logger.info(separator) 54 | 55 | def run_tests(directory_path, results_path): 56 | test_options = [ 57 | { 'min_batch_size': 10, 'max_batch_size': 100, 'scene_threshold': 60 }, 58 | { 'min_batch_size': 8, 'max_batch_size': 40, 'scene_threshold': 30 }, 59 | { 'min_batch_size': 16, 'max_batch_size': 80, 'scene_threshold': 40 }, 60 | ] 61 | 62 | RunTestOnAllSubtitleFiles(batcher_test, test_options, directory_path, results_path) 63 | 64 | if __name__ == "__main__": 65 | directory_path = os.path.join(os.getcwd(), "test_subtitles") 66 | results_path = os.path.join(directory_path, "test_results") 67 | run_tests(directory_path, results_path) 68 | -------------------------------------------------------------------------------- /tests/functional/preprocessor_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | from PySubtrans.SettingsType import SettingsType 3 | from PySubtrans.Subtitles import Subtitles 4 | from PySubtrans.SubtitleProcessor import SubtitleProcessor 5 | from PySubtrans.Helpers.Tests import RunTestOnAllSubtitleFiles, separator 6 | 7 | def preprocess_test(subtitles: Subtitles, logger, options : SettingsType): 8 | if not subtitles.originals: 9 | raise Exception("No original subtitles to preprocess") 10 | 11 | try: 12 | preprocessor = SubtitleProcessor(options) 13 | 14 | test_lines = preprocessor.PreprocessSubtitles(subtitles.originals) 15 | 16 | except Exception as e: 17 | raise Exception(f"Error in PreprocessSubtitles: {e}") 18 | 19 | # Check if the number of lines has changed 20 | original_length = len(subtitles.originals) 21 | new_length = len(test_lines) 22 | 23 | logger.info(separator) 24 | logger.info("| {:<20} | {:<20} | {:<20} |".format("Original count", "New line count", "Delta")) 25 | logger.info("| {:<20} | {:<20} | {:<20} |".format(original_length, new_length, new_length - original_length)) 26 | logger.info(separator) 27 | logger.info("") 28 | 29 | max_line_duration = options.get_float('max_line_duration') or 0.0 30 | min_line_duration = options.get_float('min_line_duration') or 0.0 # type: ignore[unused-variable] 31 | 32 | for line in test_lines: 33 | if max_line_duration > 0.0 and line.duration.total_seconds() > max_line_duration: 34 | logger.info(f"Line too long: {line.txt_duration}") 35 | logger.info(str(line)) 36 | 37 | # if min_line_duration > 0.0 and line.duration.total_seconds() < min_line_duration: 38 | # logger.info(f"Line too short: {line.srt_duration}") 39 | # logger.info(str(line)) 40 | # logger.info("") 41 | 42 | logger.info(separator) 43 | 44 | def run_tests(directory_path : str, results_path : str|None = None): 45 | test_options = [ 46 | { 'max_line_duration': 5.0, 'min_line_duration': 1.0, 'min_gap': 0.1, 'min_split_chars': 4, 'whitespaces_to_newline': False, 'break_dialog_on_one_line': True, 'normalise_dialog_tags': True}, 47 | { 'max_line_duration': 4.0, 'min_line_duration': 0.8, 'min_gap': 0.05, 'min_split_chars': 8, 'whitespaces_to_newline': True, 'break_dialog_on_one_line': False, 'normalise_dialog_tags': False} 48 | ] 49 | 50 | RunTestOnAllSubtitleFiles(preprocess_test, test_options, directory_path, results_path) 51 | 52 | if __name__ == "__main__": 53 | directory_path = os.path.join(os.getcwd(), "test_subtitles") 54 | results_path = os.path.join(directory_path, "test_results") 55 | run_tests(directory_path) 56 | -------------------------------------------------------------------------------- /tests/unit_tests.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | import unittest 5 | 6 | from PySubtrans.Helpers.Tests import create_logfile 7 | 8 | def _check_gui_dependencies() -> tuple[bool, str]: 9 | """Check whether PySide6 dependencies required for GUI tests are available.""" 10 | try: 11 | from PySide6 import QtGui 12 | _ = QtGui.QGuiApplication 13 | except (ImportError, ModuleNotFoundError, OSError) as import_error: 14 | return False, str(import_error) 15 | return True, "" 16 | 17 | def _create_gui_skip_suite(reason : str) -> unittest.TestSuite: 18 | """Create a unittest suite that is skipped when GUI dependencies are missing.""" 19 | class GuiDependencyMissingTest(unittest.TestCase): 20 | """Placeholder test skipped because PySide6 dependencies are unavailable.""" 21 | def runTest(self): 22 | """Skip execution to denote missing GUI dependencies.""" 23 | self.skipTest(reason) 24 | suite = unittest.TestSuite() 25 | suite.addTest(GuiDependencyMissingTest()) 26 | return suite 27 | 28 | def discover_tests_in_directory(loader : unittest.TestLoader, test_dir : str, base_dir : str, handle_import_errors : bool = False) -> unittest.TestSuite: 29 | """Discover tests in a specific directory with optional error handling.""" 30 | if not os.path.exists(test_dir): 31 | return unittest.TestSuite() 32 | 33 | if handle_import_errors: 34 | try: 35 | return loader.discover(test_dir, pattern='test_*.py', top_level_dir=base_dir) 36 | except (ImportError, ModuleNotFoundError): 37 | return unittest.TestSuite() 38 | else: 39 | return loader.discover(test_dir, pattern='test_*.py', top_level_dir=base_dir) 40 | 41 | def discover_tests(base_dir=None, separate_suites=False): 42 | """Automatically discover all test modules following naming conventions. 43 | 44 | Args: 45 | base_dir: Base directory to search from. If None, uses parent of this file. 46 | separate_suites: If True, returns (pysubtitle_suite, gui_suite). If False, returns combined suite. 47 | """ 48 | if base_dir is None: 49 | base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 50 | 51 | loader = unittest.TestLoader() 52 | original_dir = os.getcwd() 53 | 54 | try: 55 | os.chdir(base_dir) 56 | 57 | pysubtrans_dir = os.path.join(base_dir, 'tests', 'PySubtransTests') 58 | pysubtitle_tests = discover_tests_in_directory(loader, pysubtrans_dir, base_dir) 59 | 60 | guisubtrans_dir = os.path.join(base_dir, 'tests', 'GuiTests') 61 | gui_available, gui_dependency_error = _check_gui_dependencies() 62 | if gui_available: 63 | gui_tests = discover_tests_in_directory(loader, guisubtrans_dir, base_dir, handle_import_errors=True) 64 | else: 65 | skip_reason = f"PySide6 unavailable: {gui_dependency_error}" 66 | logging.info(skip_reason) 67 | gui_tests = _create_gui_skip_suite(skip_reason) 68 | 69 | finally: 70 | os.chdir(original_dir) 71 | 72 | if separate_suites: 73 | return pysubtitle_tests, gui_tests 74 | else: 75 | combined_suite = unittest.TestSuite() 76 | combined_suite.addTest(gui_tests) 77 | combined_suite.addTest(pysubtitle_tests) 78 | return combined_suite 79 | 80 | if __name__ == '__main__': 81 | scripts_directory = os.path.dirname(os.path.abspath(__file__)) 82 | root_directory = os.path.dirname(scripts_directory) 83 | results_directory = os.path.join(root_directory, 'test_results') 84 | 85 | if not os.path.exists(results_directory): 86 | os.makedirs(results_directory) 87 | 88 | logging.getLogger().setLevel(logging.INFO) 89 | console_handler = logging.StreamHandler(sys.stdout) 90 | console_handler.setLevel(logging.WARNING) 91 | console_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) 92 | 93 | create_logfile(results_directory, "unit_tests.log") 94 | 95 | # Run discovered tests 96 | runner = unittest.TextTestRunner(verbosity=1) 97 | test_suite = discover_tests() 98 | for test in test_suite: 99 | result = runner.run(test) 100 | if not result.wasSuccessful(): 101 | print("Some tests failed or had errors.") 102 | sys.exit(1) 103 | 104 | -------------------------------------------------------------------------------- /theme/subtrans-dark-large.qss: -------------------------------------------------------------------------------- 1 | QWidget { 2 | font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 3 | font-size: large; 4 | } 5 | 6 | QLabel { 7 | padding-top: 2px; 8 | padding-bottom: 6px; 9 | } 10 | 11 | .MainWindow, QLabel, QDialog { 12 | background-color: #363636; 13 | } 14 | 15 | .MainToolbar, .ProjectToolbar { 16 | background: #808080; 17 | border: 1px solid #404040; 18 | padding: 3px; 19 | } 20 | 21 | .MainToolbar::separator { 22 | background: #B8BCC5; 23 | width: 2px; 24 | margin: 6px 8px; 25 | } 26 | 27 | .LogWindow { 28 | background-color: #121212; 29 | border: 1px solid #242424; 30 | } 31 | 32 | .ScenesView { 33 | background-color: #242424; 34 | } 35 | 36 | .TreeViewItemWidget { 37 | padding: 4px; 38 | margin: 2px; 39 | border: 2px solid darkslateblue; 40 | border-radius: 12px; 41 | background-color: #363636; 42 | } 43 | 44 | QTreeView:item { 45 | border: 1px solid black; 46 | border-radius: 12px; 47 | } 48 | 49 | QTreeView:item:selected { 50 | border: 2px solid #800000; 51 | border-radius: 12px; 52 | font-weight: bold; 53 | } 54 | 55 | QListView:item { 56 | padding: 0px; 57 | } 58 | 59 | .WidgetHeader { 60 | font-size:x-large; 61 | font-weight: bold; 62 | background-color: blanchedalmond; 63 | padding: 8px; 64 | padding-left: 10px; 65 | margin: 0px; 66 | color: black; 67 | } 68 | 69 | .WidgetHeader[all_translated=true] { 70 | background-color: blanchedalmond; 71 | } 72 | 73 | .WidgetHeader[all_translated=false] { 74 | background-color: burlywood; 75 | } 76 | 77 | .WidgetHeader[errors=true], .WidgetBody[errors=true] { 78 | color: red; 79 | } 80 | 81 | .WidgetSubheading { 82 | font-size:medium; 83 | margin: 0px; 84 | padding: 6px; 85 | padding-left: 10px; 86 | background-color: darkslateblue; 87 | color: blanchedalmond; 88 | } 89 | 90 | .WidgetFooter { 91 | margin: 0px; 92 | padding: 2px; 93 | padding-right: 10px; 94 | padding-bottom: 8px; 95 | font-size: small; 96 | background-color: #242424; 97 | } 98 | 99 | .WidgetFooter QLabel { 100 | margin: 0px; 101 | padding: 0px; 102 | background-color: #242424; 103 | } 104 | 105 | .WidgetFooter[all_translated=true] QLabel { 106 | color: blanchedalmond; 107 | } 108 | 109 | .WidgetFooter[all_translated=false] QLabel { 110 | color: burlywood; 111 | } 112 | 113 | .WidgetBody { 114 | font-size: small; 115 | padding: 2px; 116 | padding-left: 10px; 117 | background-color: #242424; 118 | } 119 | 120 | .SubtitleView { 121 | padding: 1px; 122 | background-color: #242424; 123 | } 124 | 125 | .SubtitleView::item:selected { 126 | border: 1px solid aliceblue; 127 | } 128 | 129 | .LineItemView { 130 | padding: 0px; 131 | margin: 1px; 132 | background-color: #242424; 133 | } 134 | 135 | .LineItemHeader { 136 | margin: 0px; 137 | padding: 2px; 138 | background-color: #363636; 139 | } 140 | 141 | .LineItemHeader QLabel { 142 | margin: 0px; 143 | padding-bottom: 6px; 144 | } 145 | 146 | #line-header-left { 147 | font-weight: bold; 148 | } 149 | 150 | #line-header-right { 151 | color: burlywood; 152 | font-size: smaller; 153 | } 154 | 155 | .LineItemBody { 156 | margin: 2px; 157 | padding: 2px 1em; 158 | background-color: #242424; 159 | border-left: 2px dotted peru; 160 | } 161 | 162 | .SelectionView { 163 | background-color: #363636; 164 | } 165 | 166 | QTabWidget::pane, QTabWidget::pane QWidget { 167 | padding: 3px; 168 | } 169 | 170 | QTabWidget::pane QFrame { 171 | background-color: #363636; 172 | border: 2px solid #121212; 173 | } 174 | 175 | QTabWidget::pane QLabel { 176 | background-color: #484848; 177 | } 178 | 179 | 180 | QTabBar::tab { 181 | border-style: solid; 182 | border-width: 1px; 183 | border-top-left-radius: 3px; 184 | border-top-right-radius: 3px; 185 | border-color: lavenderblush; 186 | padding: 5px; 187 | } 188 | 189 | QTabBar::tab:selected { 190 | background-color: #555555; 191 | color: #ffffff; 192 | } 193 | 194 | -------------------------------------------------------------------------------- /theme/subtrans-dark.qss: -------------------------------------------------------------------------------- 1 | QWidget { 2 | font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 3 | font-size: 10pt; 4 | } 5 | 6 | QLabel { 7 | padding-top: 2px; 8 | padding-bottom: 6px; 9 | } 10 | 11 | .MainWindow, QLabel, QDialog { 12 | background-color: #363636; 13 | } 14 | 15 | .MainToolbar, .ProjectToolbar { 16 | background: #808080; 17 | border: 1px solid #404040; 18 | padding: 3px; 19 | } 20 | 21 | .MainToolbar::separator { 22 | background: #B8BCC5; 23 | width: 2px; 24 | margin: 6px 8px; 25 | } 26 | 27 | .LogWindow { 28 | background-color: #121212; 29 | border: 1px solid #242424; 30 | } 31 | 32 | .ScenesView { 33 | background-color: #242424; 34 | } 35 | 36 | .TreeViewItemWidget { 37 | padding: 4px; 38 | margin: 2px; 39 | border: 2px solid darkslateblue; 40 | border-radius: 12px; 41 | background-color: #363636; 42 | } 43 | 44 | QTreeView:item { 45 | border: 1px solid black; 46 | border-radius: 12px; 47 | } 48 | 49 | QTreeView:item:selected { 50 | border: 2px solid #800000; 51 | border-radius: 12px; 52 | font-weight: bold; 53 | } 54 | 55 | QListView:item { 56 | padding: 0px; 57 | } 58 | 59 | .WidgetHeader { 60 | font-size: 12pt; 61 | font-weight: bold; 62 | background-color: blanchedalmond; 63 | padding: 10px 4px 4px 4px; 64 | margin: 0px; 65 | color: black; 66 | } 67 | 68 | .WidgetHeader[all_translated=true] { 69 | background-color: blanchedalmond; 70 | } 71 | 72 | .WidgetHeader[all_translated=false] { 73 | background-color: burlywood; 74 | } 75 | 76 | .WidgetHeader[errors=true], .WidgetBody[errors=true] { 77 | color: red; 78 | } 79 | 80 | .WidgetSubheading { 81 | margin: 0px; 82 | padding: 3px; 83 | padding-left: 10px; 84 | background-color: darkslategrey; 85 | color: blanchedalmond; 86 | } 87 | 88 | .WidgetFooter { 89 | margin: 0px; 90 | padding: 2px; 91 | padding-right: 10px; 92 | background-color: #242424; 93 | } 94 | 95 | .WidgetFooter QLabel { 96 | margin: 0px; 97 | padding: 0px; 98 | background-color: #242424; 99 | } 100 | 101 | .WidgetFooter[all_translated=true] QLabel { 102 | color: blanchedalmond; 103 | } 104 | 105 | .WidgetFooter[all_translated=false] QLabel { 106 | color: burlywood; 107 | } 108 | 109 | .WidgetBody { 110 | font-size: 10pt; 111 | padding: 2px; 112 | padding-left: 10px; 113 | background-color: #242424; 114 | } 115 | 116 | .SubtitleView { 117 | padding: 1px; 118 | background-color: #242424; 119 | } 120 | 121 | .SubtitleView::item:selected { 122 | border: 1px solid peru; 123 | } 124 | 125 | .LineItemView { 126 | padding: 0px; 127 | margin: 1px; 128 | background-color: #242424; 129 | } 130 | 131 | .LineItemHeader { 132 | margin: 0px; 133 | padding: 2px; 134 | background-color: #363636; 135 | } 136 | 137 | .LineItemHeader QLabel { 138 | margin: 0px; 139 | padding-bottom: 6px; 140 | } 141 | 142 | #line-header-right { 143 | color: burlywood; 144 | font-size: 9pt; 145 | } 146 | 147 | .LineItemBody { 148 | margin: 2px; 149 | padding: 2px 1em; 150 | background-color: #242424; 151 | border-left: 2px dotted peru; 152 | } 153 | 154 | .SelectionView { 155 | background-color: #363636; 156 | color: white; 157 | } 158 | 159 | QTabWidget::pane, QTabWidget::pane QWidget { 160 | padding: 3px; 161 | } 162 | 163 | QTabWidget::pane QFrame { 164 | background-color: #484848; 165 | } 166 | 167 | QTabBar::tab { 168 | border-style: solid; 169 | border-width: 1px; 170 | border-top-left-radius: 3px; 171 | border-top-right-radius: 3px; 172 | border-color: #363636; 173 | padding: 5px; 174 | } 175 | 176 | QTabBar::tab:selected { 177 | background-color: #555555; 178 | color: #ffffff; 179 | } 180 | 181 | -------------------------------------------------------------------------------- /theme/subtrans-large.qss: -------------------------------------------------------------------------------- 1 | QWidget { 2 | font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 3 | font-size: large; 4 | } 5 | 6 | QLabel { 7 | color: black; 8 | padding-top: 2px; 9 | padding-bottom: 6px; 10 | } 11 | 12 | QComboBox { 13 | padding: 1px 18px 1px 3px; 14 | min-width: 6em; 15 | min-height: 1em; 16 | } 17 | 18 | QStatusBar { 19 | color: black; 20 | } 21 | 22 | .MainToolbar, .ProjectToolbar { 23 | background: #B8BCC5; 24 | border: 1px solid #a0a0a0; 25 | padding: 1px; 26 | } 27 | 28 | .MainToolbar::separator { 29 | background: #C9CCD3; 30 | width: 2px; 31 | margin: 6px 8px; 32 | } 33 | 34 | QTreeView:item { 35 | border-radius: 12; 36 | } 37 | 38 | QTreeView:item:selected { 39 | border: 2px solid black; 40 | border-radius: 12; 41 | } 42 | 43 | QListView:item { 44 | padding: 0px; 45 | } 46 | 47 | .MainWindow { 48 | background-color: aliceblue; 49 | color: black; 50 | } 51 | 52 | .LogWindow { 53 | background-color: #121212; 54 | border: 1px solid #242424; 55 | } 56 | 57 | .ScenesView { 58 | background-color: lightyellow; 59 | } 60 | 61 | .TreeViewItemWidget { 62 | padding: 1px; 63 | margin: 0px; 64 | border-width: 2; 65 | border-style: solid; 66 | border-color: lightgray; 67 | border-radius: 12; 68 | background-color: white; 69 | } 70 | 71 | .WidgetHeader { 72 | font-size:x-large; 73 | font-weight: bold; 74 | padding: 10px 4px 4px 4px; 75 | color: black; 76 | } 77 | 78 | .WidgetHeader[all_translated=true] { 79 | background-color: blanchedalmond; 80 | } 81 | 82 | .WidgetHeader[all_translated=false] { 83 | background-color: burlywood; 84 | } 85 | 86 | .WidgetHeader[errors=true], .WidgetBody[errors=true] { 87 | color: red; 88 | } 89 | 90 | .WidgetSubheading { 91 | font-size: medium; 92 | margin: 0px; 93 | padding: 10px 3px 3px 3px; 94 | background-color: lavenderblush; 95 | color: black; 96 | } 97 | 98 | .WidgetFooter { 99 | margin: 0px; 100 | padding: 2px; 101 | padding-right: 10px; 102 | font-size: smaller; 103 | } 104 | 105 | .WidgetFooter QLabel { 106 | margin: 0px; 107 | padding: 0px; 108 | } 109 | 110 | .WidgetFooter[all_translated=true] QLabel { 111 | color: peru; 112 | } 113 | 114 | .WidgetFooter[all_translated=false] QLabel { 115 | color: saddlebrown; 116 | } 117 | 118 | .WidgetBody { 119 | font-size: small; 120 | padding: 2px; 121 | padding-left: 10px; 122 | background-color: white; 123 | color: black; 124 | } 125 | 126 | .SubtitleView { 127 | padding: 1px; 128 | } 129 | 130 | .SubtitleView::item:selected { 131 | border: 1px solid peru; 132 | } 133 | 134 | .LineItemView { 135 | padding: 0px; 136 | margin: 1px; 137 | background-color: white; 138 | } 139 | 140 | .LineItemHeader { 141 | margin: 0px; 142 | padding: 2px; 143 | background-color: aliceblue; 144 | color: black; 145 | } 146 | 147 | .LineItemHeader QLabel { 148 | margin: 0px; 149 | padding-bottom: 6px; 150 | } 151 | 152 | #line-header-left { 153 | font-weight: bold; 154 | } 155 | 156 | #line-header-right { 157 | color: peru; 158 | font-size: x-small; 159 | } 160 | 161 | .LineItemBody { 162 | margin: 2px; 163 | padding: 2px 1em; 164 | background-color: white; 165 | color: black; 166 | border-left: 2px dotted lightgray; 167 | } 168 | 169 | -------------------------------------------------------------------------------- /theme/subtrans.qss: -------------------------------------------------------------------------------- 1 | QWidget { 2 | font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 3 | font-size: 10pt; 4 | } 5 | 6 | QLabel { 7 | color: black; 8 | padding-top: 2px; 9 | padding-bottom: 6px; 10 | } 11 | 12 | QComboBox { 13 | padding: 1px 18px 1px 3px; 14 | min-width: 6em; 15 | min-height: 1em; 16 | } 17 | 18 | QStatusBar { 19 | color: black; 20 | } 21 | 22 | .MainToolbar, .ProjectToolbar { 23 | background: #B8BCC5; 24 | border: 1px solid #a0a0a0; 25 | padding: 1px; 26 | } 27 | 28 | .MainToolbar::separator { 29 | background: #C9CCD3; 30 | width: 2px; 31 | margin: 6px 8px; 32 | } 33 | 34 | QTreeView:item { 35 | border-radius: 12; 36 | } 37 | 38 | QTreeView:item:selected { 39 | border: 2px solid black; 40 | border-radius: 12; 41 | } 42 | 43 | QListView:item { 44 | padding: 0px; 45 | } 46 | 47 | .MainWindow { 48 | background-color: aliceblue; 49 | color: black; 50 | } 51 | 52 | .LogWindow { 53 | background-color: #121212; 54 | border: 1px solid #242424; 55 | } 56 | 57 | .ScenesView { 58 | background-color: lightyellow; 59 | } 60 | 61 | .TreeViewItemWidget { 62 | padding: 1px; 63 | margin: 0px; 64 | border-width: 2; 65 | border-style: solid; 66 | border-color: lightgray; 67 | border-radius: 12; 68 | background-color: white; 69 | } 70 | 71 | .WidgetHeader { 72 | font-size: 12pt; 73 | font-weight: bold; 74 | padding: 10px 4px 4px 4px; 75 | color: black; 76 | } 77 | 78 | .WidgetHeader[all_translated=true] { 79 | background-color: blanchedalmond; 80 | } 81 | 82 | .WidgetHeader[all_translated=false] { 83 | background-color: burlywood; 84 | } 85 | 86 | .WidgetHeader[errors=true], .WidgetBody[errors=true] { 87 | color: red; 88 | } 89 | 90 | .WidgetSubheading { 91 | font-size: 10pt; 92 | margin: 0px; 93 | padding: 10px 3px 3px 3px; 94 | background-color: lavenderblush; 95 | color: black; 96 | } 97 | 98 | .WidgetFooter { 99 | margin: 0px; 100 | padding: 2px; 101 | padding-right: 10px; 102 | } 103 | 104 | .WidgetFooter QLabel { 105 | margin: 0px; 106 | padding: 0px; 107 | } 108 | 109 | .WidgetFooter[all_translated=true] QLabel { 110 | color: peru; 111 | } 112 | 113 | .WidgetFooter[all_translated=false] QLabel { 114 | color: saddlebrown; 115 | } 116 | 117 | .WidgetBody { 118 | font-size: 10pt; 119 | padding: 2px; 120 | padding-left: 10px; 121 | background-color: white; 122 | color: black; 123 | } 124 | 125 | .SubtitleView { 126 | padding: 1px; 127 | } 128 | 129 | .SubtitleView::item:selected { 130 | border: 1px solid peru; 131 | } 132 | 133 | .LineItemView { 134 | padding: 0px; 135 | margin: 1px; 136 | background-color: white; 137 | } 138 | 139 | .LineItemHeader { 140 | margin: 0px; 141 | padding: 2px; 142 | background-color: aliceblue; 143 | color: black; 144 | } 145 | 146 | .LineItemHeader QLabel { 147 | margin: 0px; 148 | padding-bottom: 6px; 149 | } 150 | 151 | #line-header-left { 152 | font-weight: bold; 153 | } 154 | 155 | #line-header-right { 156 | color: peru; 157 | font-size: 9pt; 158 | } 159 | 160 | .LineItemBody { 161 | margin: 2px; 162 | padding: 2px 1em; 163 | color: black; 164 | border-left: 2px dotted lightgray; 165 | } 166 | 167 | --------------------------------------------------------------------------------