diff --git a/GUI/FileCommands.py b/GUI/FileCommands.py index 69fb858b..589bb384 100644 --- a/GUI/FileCommands.py +++ b/GUI/FileCommands.py @@ -59,7 +59,7 @@ def execute(self): if self.project.subtitles.translated: outputpath = GetOutputPath(self.project.projectfile) - self.project.subtitles.SaveTranslation(outputpath) + self.project.SaveSubtitles(outputpath) return True class SaveSubtitleFile(Command): @@ -79,5 +79,5 @@ def __init__(self, filepath, project : SubtitleProject): self.project = project def execute(self): - self.project.subtitles.SaveTranslation(self.filepath) + self.project.SaveSubtitles(self.filepath) return True diff --git a/GUI/SettingsDialog.py b/GUI/SettingsDialog.py index 9999ec46..2c0c69dc 100644 --- a/GUI/SettingsDialog.py +++ b/GUI/SettingsDialog.py @@ -11,32 +11,33 @@ class SettingsDialog(QDialog): 'autosave': bool, 'write_backup': bool, 'stop_on_error': bool, - 'max_threads': int + 'max_threads': (int, "Maximum number of simultaneous translation threads for fast translation") }, 'GPT': { - 'api_key': str, - 'api_base': str, + 'api_key': (str, "An OpenAI API key is required to use this program (https://platform.openai.com/account/api-keys)"), + 'api_base': (str, "The base URI to use for requests - leave as default unless you know you need something else"), 'gpt_model': str, - 'temperature': float, - 'rate_limit': float + 'temperature': (float, "Amount of random variance to add to translations. Generally speaking, none is best"), + 'rate_limit': (float, "Maximum OpenAI API requests per minute. Mainly useful if you are on the restricted free plan") }, 'Translation': { - 'gpt_prompt': str, + 'gpt_prompt': (str, "The (brief) instruction to give GPT for each batch of subtitles. [movie_name] and [to_language] are automatically filled in"), 'target_language': str, - 'instruction_file': str, - 'allow_retranslations': bool, - 'enforce_line_parity': bool + 'include_original': (bool, "Include original text in translated subtitles"), + 'instruction_file': (str, "Detailed instructions for GPT about how to approach translation, and the required response format"), + 'allow_retranslations': (bool, "If true, translations that fail validation will be sent to GPT again with a note to allow it to correct the mistake"), + 'enforce_line_parity': (bool, "Validator: If true, require one translated line for each source line") }, 'Advanced': { 'min_batch_size': int, 'max_batch_size': int, 'scene_threshold': float, 'batch_threshold': float, - 'match_partial_words': bool, - 'whitespaces_to_newline': bool, - 'max_context_summaries': int, - 'max_characters': int, - 'max_newlines': int, + 'match_partial_words': (bool, "Used with substitutions, required for some languages where word boundaries aren't detected"), + 'whitespaces_to_newline': (bool, "Convert blocks of whitespace and Chinese Commas to newlines"), + 'max_context_summaries': (int, "Limits the number of scene/batch summaries to include as context with each translation batch"), + 'max_characters': (int, "Validator: Maximum number of characters to allow in a single translated line"), + 'max_newlines': (int, "Validator: Maximum number of newlines to allow in a single translated line"), 'max_retries': int, 'backoff_time': float } @@ -82,7 +83,8 @@ def create_section_widget(self, settings, section_name): layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) for key, key_type in settings.items(): - field = CreateOptionWidget(key, self.options[key], key_type) + key_type, tooltip = key_type if isinstance(key_type, tuple) else (key_type, None) + field = CreateOptionWidget(key, self.options[key], key_type, tooltip=tooltip) layout.addRow(field.name, field) return section_widget diff --git a/GUI/Widgets/ProjectOptions.py b/GUI/Widgets/ProjectOptions.py index b17189e2..473a111f 100644 --- a/GUI/Widgets/ProjectOptions.py +++ b/GUI/Widgets/ProjectOptions.py @@ -1,6 +1,6 @@ import logging from PySide6.QtWidgets import QGroupBox, QVBoxLayout, QLabel, QLineEdit, QCheckBox, QPushButton, QDialog -from PySide6.QtCore import Signal +from PySide6.QtCore import Signal, QSignalBlocker from GUI.TranslatorOptions import TranslatorOptionsDialog from GUI.Widgets.Widgets import OptionsGrid, TextBoxEditor @@ -27,11 +27,12 @@ def __init__(self, options=None): # Add options self.AddSingleLineOption(0, "Movie Name", options, 'movie_name') self.AddSingleLineOption(1, "Target Language", options, 'target_language') - self.AddMultiLineOption(2, "Description", options, 'description') - self.AddMultiLineOption(3, "Characters", options, 'characters') - self.AddMultiLineOption(4, "Substitutions", options, 'substitutions') - self.AddCheckboxOption(5, "Match Partial Words", options, 'match_partial_words') - self.AddButtonOption(6, "", "GPT Settings", self._gpt_settings) + self.AddCheckboxOption(2, "Include Original Text", options, 'include_original') + self.AddMultiLineOption(3, "Description", options, 'description') + self.AddMultiLineOption(4, "Characters", options, 'characters') + self.AddMultiLineOption(5, "Substitutions", options, 'substitutions') + self.AddCheckboxOption(6, "Match Partial Words", options, 'match_partial_words') + self.AddButtonOption(7, "", "GPT Settings", self._gpt_settings) self.Populate(options) @@ -44,6 +45,7 @@ def GetOptions(self): options = { "movie_name": self.movie_name_input.text(), "target_language": self.target_language_input.text(), + "include_original": self.include_original_input.isChecked(), "description": self.description_input.toPlainText(), "characters": ParseCharacters(self.characters_input.toPlainText()), "substitutions": ParseSubstitutions(self.substitutions_input.toPlainText()), @@ -83,6 +85,7 @@ def AddCheckboxOption(self, row, label, options, key): input_widget = QCheckBox(self) value = options.get(key, False) input_widget.setChecked(value) + input_widget.stateChanged.connect(self._check_changed) self.grid_layout.addWidget(label_widget, row, 0) self.grid_layout.addWidget(input_widget, row, 1) @@ -113,22 +116,24 @@ def Populate(self, options): 'scene_threshold' : options.get('scene_threshold'), } - for key in options: - if hasattr(self, key + "_input"): - value = options.get(key) - self._setvalue(key, value) + with QSignalBlocker(self): + for key in options: + if hasattr(self, key + "_input"): + value = options.get(key) + self._setvalue(key, value) def Clear(self): - for key in ["movie_name", "description", "characters", "substitutions", "match_partial_words"]: - input = getattr(self, key + "_input") - if input: - if isinstance(input, QCheckBox): - input.setChecked(False) + with QSignalBlocker(self): + for key in ["movie_name", "description", "characters", "substitutions", "match_partial_words", "include_original"]: + input = getattr(self, key + "_input") + if input: + if isinstance(input, QCheckBox): + input.setChecked(False) + else: + input.clear() else: - input.clear() - else: - logging.error(f"No input found for {key}") - + logging.error(f"No input found for {key}") + def _setvalue(self, key, value): if isinstance(value, bool): getattr(self, key + "_input").setChecked(value or False) @@ -147,6 +152,10 @@ def _text_changed(self, text = None): options = self.GetOptions() self.optionsChanged.emit(options) + def _check_changed(self, int = None): + options = self.GetOptions() + self.optionsChanged.emit(options) + def _gpt_settings(self): dialog = TranslatorOptionsDialog(self._gpt_options, parent=self) result = dialog.exec() diff --git a/PySubtitleGPT/Options.py b/PySubtitleGPT/Options.py index fb8e89dd..56544495 100644 --- a/PySubtitleGPT/Options.py +++ b/PySubtitleGPT/Options.py @@ -36,6 +36,7 @@ def env_bool(key, default=False): 'gpt_prompt': os.getenv('GPT_PROMPT', "Please translate these subtitles[ for movie][ to language]."), 'instruction_file': os.getenv('INSTRUCTION_FILE', "instructions.txt"), 'target_language': os.getenv('TARGET_LANGUAGE', 'English'), + 'include_original': env_bool('INCLUDE_ORIGINAL', False), 'temperature': float(os.getenv('TEMPERATURE', 0.0)), 'allow_retranslations': env_bool('ALLOW_RETRANSLATIONS', True), 'scene_threshold': float(os.getenv('SCENE_THRESHOLD', 30.0)), diff --git a/PySubtitleGPT/SubtitleFile.py b/PySubtitleGPT/SubtitleFile.py index 2f4ca3e0..99234ab4 100644 --- a/PySubtitleGPT/SubtitleFile.py +++ b/PySubtitleGPT/SubtitleFile.py @@ -156,7 +156,7 @@ def SaveOriginals(self, path : str = None): with open(self.sourcepath, 'w', encoding=default_encoding) as f: f.write(srtfile) - def SaveTranslation(self, outputpath : str = None): + def SaveTranslation(self, outputpath : str = None, include_original : bool = False): """ Write translated subtitles to an SRT file """ @@ -181,6 +181,9 @@ def SaveTranslation(self, outputpath : str = None): if not translated: logging.error("No subtitles translated") return + + if include_original: + translated = self._merge_original_and_translated(originals, translated) logging.info(f"Saving translation to {str(outputpath)}") @@ -211,7 +214,8 @@ def UpdateContext(self, options): 'max_batch_size' : None, 'batch_threshold' : None, 'scene_threshold' : None, - 'match_partial_words': False + 'match_partial_words': False, + 'include_original': False } with self.lock: @@ -226,9 +230,9 @@ def UpdateContext(self, options): # Update the context dictionary with matching fields from options, and vice versa for key in context.keys(): - if options.get(key): + if key in options: context[key] = options[key] - elif context[key]: + elif key in context: options[key] = context[key] # Fill description from synopsis for backward compatibility @@ -414,3 +418,13 @@ def Sanitise(self): batch.translated = [line for line in batch.translated if line.number and line.text] self.scenes = [scene for scene in self.scenes if scene.batches] + + def _merge_original_and_translated(self, originals : list[SubtitleLine], translated : list[SubtitleLine]): + lines = {item.key: SubtitleLine(item.line) for item in originals if item.key} + + for item in translated: + if item.key in lines: + line = lines[item.key] + line.text = f"{line.text}\n{item.text}" + + return sorted(lines.values(), key=lambda item: item.key) \ No newline at end of file diff --git a/PySubtitleGPT/SubtitleProject.py b/PySubtitleGPT/SubtitleProject.py index 56111965..7bfd44f4 100644 --- a/PySubtitleGPT/SubtitleProject.py +++ b/PySubtitleGPT/SubtitleProject.py @@ -111,7 +111,7 @@ def TranslateSubtitles(self): translator.TranslateSubtitles() - self.subtitles.SaveTranslation() + self.SaveSubtitles() except TranslationAbortedError: logging.warning(f"Translation aborted") @@ -119,11 +119,18 @@ def TranslateSubtitles(self): except Exception as e: if self.subtitles and self.options.get('stop_on_error'): - self.subtitles.SaveTranslation() + self.SaveSubtitles() logging.error(f"Failed to translate subtitles") raise + def SaveSubtitles(self, outputpath : str = None): + """ + Write output file + """ + include_original = self.options.get('include_original', False) + self.subtitles.SaveTranslation(outputpath, include_original=include_original) + def TranslateScene(self, scene_number, batch_numbers = None, translator : SubtitleTranslator = None): """ Pass batches of subtitles to the translation engine. @@ -142,7 +149,7 @@ def TranslateScene(self, scene_number, batch_numbers = None, translator : Subtit translator.TranslateScene(scene, batch_numbers=batch_numbers) - self.subtitles.SaveTranslation() + self.SaveSubtitles() return scene @@ -151,7 +158,7 @@ def TranslateScene(self, scene_number, batch_numbers = None, translator : Subtit except Exception as e: if self.subtitles and self.options.get('stop_on_error'): - self.subtitles.SaveTranslation() + self.SaveSubtitles() logging.error(f"Failed to translate subtitles") raise @@ -278,7 +285,6 @@ def UpdateProjectOptions(self, options: dict): with self.lock: # Update "self.options" self.options.update(options) - self.options.Save() if self.subtitles: self.subtitles.UpdateContext(self.options) @@ -311,6 +317,6 @@ def _on_batch_translated(self, batch): def _on_scene_translated(self, scene): logging.debug("Scene translated") - self.subtitles.SaveTranslation() + self.SaveSubtitles() self.needsupdate = self.update_project self.events.scene_translated(scene) diff --git a/gpt-subtrans.py b/gpt-subtrans.py index 35791032..ff9d536b 100644 --- a/gpt-subtrans.py +++ b/gpt-subtrans.py @@ -32,6 +32,7 @@ parser.add_argument('--scenethreshold', type=float, default=None, help="Number of seconds between lines to consider a new scene") parser.add_argument('--maxlines', type=int, default=None, help="Maximum number of lines(subtitles) to process in this run") parser.add_argument('--matchpartialwords', action='store_true', help="Allow substitutions that do not match not on word boundaries") +parser.add_argument('--includeoriginal', action='store_true', help="Include the original text in the translated subtitles") args = parser.parse_args() @@ -47,6 +48,7 @@ 'characters': ParseCharacters(args.characters or args.character), 'substitutions': ParseSubstitutions(args.substitution), 'match_partial_words': args.matchpartialwords, + 'include_original': args.includeoriginal, 'instruction_file': args.instructionfile, 'instruction_args': args.instruction, 'min_batch_size': args.minbatchsize, diff --git a/gui-subtrans.py b/gui-subtrans.py index 1cb68feb..193858f6 100644 --- a/gui-subtrans.py +++ b/gui-subtrans.py @@ -30,6 +30,7 @@ def parse_arguments(): parser.add_argument('--maxlines', type=int, default=None, help="Maximum number of batches to process") parser.add_argument('--theme', type=str, default=None, help="Stylesheet to load") parser.add_argument('--firstrun', action='store_true', help="Show the first-run options dialog on launch") + parser.add_argument('--includeoriginal', action='store_true', help="Include the original text in the translated subtitles") try: args = parser.parse_args()