diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..dc69766be97f70146bcc3be530fed72966fc83ad --- /dev/null +++ b/app.py @@ -0,0 +1,5647 @@ +#!/usr/bin/env python3 +""" +Glossarion Web - Gradio Web Interface +AI-powered translation in your browser +""" + +import gradio as gr +import os +import sys +import json +import tempfile +import base64 +from pathlib import Path + +# CRITICAL: Set API delay IMMEDIATELY at module level before any other imports +# This ensures unified_api_client reads the correct value when it's imported +if 'SEND_INTERVAL_SECONDS' not in os.environ: + os.environ['SEND_INTERVAL_SECONDS'] = '0.5' +print(f"๐Ÿ”ง Module-level API delay initialized: {os.environ['SEND_INTERVAL_SECONDS']}s") + +# Import API key encryption/decryption +try: + from api_key_encryption import APIKeyEncryption + API_KEY_ENCRYPTION_AVAILABLE = True + # Create web-specific encryption handler with its own key file + _web_encryption_handler = None + def get_web_encryption_handler(): + global _web_encryption_handler + if _web_encryption_handler is None: + _web_encryption_handler = APIKeyEncryption() + # Use web-specific key file + from pathlib import Path + _web_encryption_handler.key_file = Path('.glossarion_web_key') + _web_encryption_handler.cipher = _web_encryption_handler._get_or_create_cipher() + # Add web-specific fields to encrypt + _web_encryption_handler.api_key_fields.extend([ + 'azure_vision_key', + 'google_vision_credentials' + ]) + return _web_encryption_handler + + def decrypt_config(config): + return get_web_encryption_handler().decrypt_config(config) + + def encrypt_config(config): + return get_web_encryption_handler().encrypt_config(config) +except ImportError: + API_KEY_ENCRYPTION_AVAILABLE = False + def decrypt_config(config): + return config # Fallback: return config as-is + def encrypt_config(config): + return config # Fallback: return config as-is + +# Import your existing translation modules +try: + import TransateKRtoEN + from model_options import get_model_options + TRANSLATION_AVAILABLE = True +except ImportError: + TRANSLATION_AVAILABLE = False + print("โš ๏ธ Translation modules not found") + +# Import manga translation modules +try: + from manga_translator import MangaTranslator + from unified_api_client import UnifiedClient + MANGA_TRANSLATION_AVAILABLE = True + print("โœ… Manga translation modules loaded successfully") +except ImportError as e: + MANGA_TRANSLATION_AVAILABLE = False + print(f"โš ๏ธ Manga translation modules not found: {e}") + print(f"โš ๏ธ Current working directory: {os.getcwd()}") + print(f"โš ๏ธ Python path: {sys.path[:3]}...") + + # Check if files exist + files_to_check = ['manga_translator.py', 'unified_api_client.py', 'bubble_detector.py', 'local_inpainter.py'] + for file in files_to_check: + if os.path.exists(file): + print(f"โœ… Found: {file}") + else: + print(f"โŒ Missing: {file}") + + +class GlossarionWeb: + """Web interface for Glossarion translator""" + + def __init__(self): + # Determine config file path based on environment + is_hf_spaces = os.getenv('SPACE_ID') is not None or os.getenv('HF_SPACES') == 'true' + + if is_hf_spaces: + # Use /data directory for Hugging Face Spaces persistent storage + data_dir = '/data' + if not os.path.exists(data_dir): + # Fallback to current directory if /data doesn't exist + data_dir = '.' + self.config_file = os.path.join(data_dir, 'config_web.json') + print(f"๐Ÿค— HF Spaces detected - using config path: {self.config_file}") + print(f"๐Ÿ“ Directory exists: {os.path.exists(os.path.dirname(self.config_file))}") + else: + # Local mode - use current directory + self.config_file = "config_web.json" + print(f"๐Ÿ  Local mode - using config path: {self.config_file}") + + # Load raw config first + self.config = self.load_config() + + # Create a decrypted version for display/use in the UI + # but keep the original for saving + self.decrypted_config = self.config.copy() + if API_KEY_ENCRYPTION_AVAILABLE: + self.decrypted_config = decrypt_config(self.decrypted_config) + + # CRITICAL: Initialize environment variables IMMEDIATELY after loading config + # This must happen before any UnifiedClient is created + + # Set API call delay + api_call_delay = self.decrypted_config.get('api_call_delay', 0.5) + if 'api_call_delay' not in self.config: + self.config['api_call_delay'] = 0.5 + self.decrypted_config['api_call_delay'] = 0.5 + os.environ['SEND_INTERVAL_SECONDS'] = str(api_call_delay) + print(f"๐Ÿ”ง Initialized API call delay: {api_call_delay}s") + + # Set batch translation settings + if 'batch_translation' not in self.config: + self.config['batch_translation'] = True + self.decrypted_config['batch_translation'] = True + if 'batch_size' not in self.config: + self.config['batch_size'] = 10 + self.decrypted_config['batch_size'] = 10 + print(f"๐Ÿ“ฆ Initialized batch translation: {self.config['batch_translation']}, batch size: {self.config['batch_size']}") + + # CRITICAL: Ensure extraction method and filtering level are initialized + if 'text_extraction_method' not in self.config: + self.config['text_extraction_method'] = 'standard' + self.decrypted_config['text_extraction_method'] = 'standard' + if 'file_filtering_level' not in self.config: + self.config['file_filtering_level'] = 'smart' + self.decrypted_config['file_filtering_level'] = 'smart' + if 'indefinitely_retry_rate_limit' not in self.config: + self.config['indefinitely_retry_rate_limit'] = False + self.decrypted_config['indefinitely_retry_rate_limit'] = False + if 'thread_submission_delay' not in self.config: + self.config['thread_submission_delay'] = 0.1 + self.decrypted_config['thread_submission_delay'] = 0.1 + if 'enhanced_preserve_structure' not in self.config: + self.config['enhanced_preserve_structure'] = True + self.decrypted_config['enhanced_preserve_structure'] = True + if 'force_bs_for_traditional' not in self.config: + self.config['force_bs_for_traditional'] = True + self.decrypted_config['force_bs_for_traditional'] = True + print(f"๐Ÿ” Initialized extraction method: {self.config['text_extraction_method']}") + print(f"๐Ÿ“‹ Initialized filtering level: {self.config['file_filtering_level']}") + print(f"๐Ÿ” Initialized rate limit retry: {self.config['indefinitely_retry_rate_limit']}") + print(f"โฑ๏ธ Initialized threading delay: {self.config['thread_submission_delay']}s") + print(f"๐Ÿ”ง Enhanced preserve structure: {self.config['enhanced_preserve_structure']}") + print(f"๐Ÿ”ง Force BS for traditional: {self.config['force_bs_for_traditional']}") + + # Set font algorithm and auto fit style if not present + if 'manga_settings' not in self.config: + self.config['manga_settings'] = {} + if 'font_sizing' not in self.config['manga_settings']: + self.config['manga_settings']['font_sizing'] = {} + if 'rendering' not in self.config['manga_settings']: + self.config['manga_settings']['rendering'] = {} + + if 'algorithm' not in self.config['manga_settings']['font_sizing']: + self.config['manga_settings']['font_sizing']['algorithm'] = 'smart' + if 'auto_fit_style' not in self.config['manga_settings']['rendering']: + self.config['manga_settings']['rendering']['auto_fit_style'] = 'balanced' + + # Also ensure they're in decrypted_config + if 'manga_settings' not in self.decrypted_config: + self.decrypted_config['manga_settings'] = {} + if 'font_sizing' not in self.decrypted_config['manga_settings']: + self.decrypted_config['manga_settings']['font_sizing'] = {} + if 'rendering' not in self.decrypted_config['manga_settings']: + self.decrypted_config['manga_settings']['rendering'] = {} + if 'algorithm' not in self.decrypted_config['manga_settings']['font_sizing']: + self.decrypted_config['manga_settings']['font_sizing']['algorithm'] = 'smart' + if 'auto_fit_style' not in self.decrypted_config['manga_settings']['rendering']: + self.decrypted_config['manga_settings']['rendering']['auto_fit_style'] = 'balanced' + + print(f"๐ŸŽจ Initialized font algorithm: {self.config['manga_settings']['font_sizing']['algorithm']}") + print(f"๐ŸŽจ Initialized auto fit style: {self.config['manga_settings']['rendering']['auto_fit_style']}") + + self.models = get_model_options() if TRANSLATION_AVAILABLE else ["gpt-4", "claude-3-5-sonnet"] + print(f"๐Ÿค– Loaded {len(self.models)} models: {self.models[:5]}{'...' if len(self.models) > 5 else ''}") + + # Translation state management + import threading + self.is_translating = False + self.stop_flag = threading.Event() + self.translation_thread = None + self.current_unified_client = None # Track active client to allow cancellation + self.current_translator = None # Track active translator to allow shutdown + + # Add stop flags for different translation types + self.epub_translation_stop = False + self.epub_translation_thread = None + self.glossary_extraction_stop = False + self.glossary_extraction_thread = None + + # Default prompts from the GUI (same as translator_gui.py) + self.default_prompts = { + "korean": ( + "You are a professional Korean to English novel translator, you must strictly output only English text and HTML tags while following these rules:\n" + "- Use a natural, comedy-friendly English translation style that captures both humor and readability without losing any original meaning.\n" + "- Include 100% of the source text - every word, phrase, and sentence must be fully translated without exception.\n" + "- Retain Korean honorifics and respectful speech markers in romanized form, including but not limited to: -nim, -ssi, -yang, -gun, -isiyeo, -hasoseo. For archaic/classical Korean honorific forms (like ์ด์‹œ์—ฌ/isiyeo, ํ•˜์†Œ์„œ/hasoseo), preserve them as-is rather than converting to modern equivalents.\n" + "- Always localize Korean terminology to proper English equivalents instead of literal translations (examples: ๋งˆ์™• = Demon King; ๋งˆ์ˆ  = magic).\n" + "- When translating Korean's pronoun-dropping style, insert pronouns in English only where needed for clarity: prioritize original pronouns as implied or according to the glossary, and only use they/them as a last resort, use I/me for first-person narration, and maintain natural English flow without overusing pronouns just because they're omitted in Korean.\n" + "- All Korean profanity must be translated to English profanity.\n" + "- Preserve original intent, and speech tone.\n" + "- Retain onomatopoeia in Romaji.\n" + "- Keep original Korean quotation marks (" ", ' ', ใ€Œใ€, ใ€Žใ€) as-is without converting to English quotes.\n" + "- Every Korean/Chinese/Japanese character must be converted to its English meaning. Examples: The character ์ƒ means 'life/living', ํ™œ means 'active', ๊ด€ means 'hall/building' - together ์ƒํ™œ๊ด€ means Dormitory.\n" + "- Preserve ALL HTML tags exactly as they appear in the source, including , , <h1>, <h2>, <p>, <br>, <div>, etc.\n" + ), + "japanese": ( + "You are a professional Japanese to English novel translator, you must strictly output only English text and HTML tags while following these rules:\n" + "- Use a natural, comedy-friendly English translation style that captures both humor and readability without losing any original meaning.\n" + "- Include 100% of the source text - every word, phrase, and sentence must be fully translated without exception.\n" + "- Retain Japanese honorifics and respectful speech markers in romanized form, including but not limited to: -san, -sama, -chan, -kun, -dono, -sensei, -senpai, -kouhai. For archaic/classical Japanese honorific forms, preserve them as-is rather than converting to modern equivalents.\n" + "- Always localize Japanese terminology to proper English equivalents instead of literal translations (examples: ้ญ”็Ž‹ = Demon King; ้ญ”่ก“ = magic).\n" + "- When translating Japanese's pronoun-dropping style, insert pronouns in English only where needed for clarity: prioritize original pronouns as implied or according to the glossary, and only use they/them as a last resort, use I/me for first-person narration while reflecting the Japanese pronoun's nuance (็ง/ๅƒ•/ไฟบ/etc.) through speech patterns rather than the pronoun itself, and maintain natural English flow without overusing pronouns just because they're omitted in Japanese.\n" + "- All Japanese profanity must be translated to English profanity.\n" + "- Preserve original intent, and speech tone.\n" + "- Retain onomatopoeia in Romaji.\n" + "- Keep original Japanese quotation marks (ใ€Œใ€, ใ€Žใ€) as-is without converting to English quotes.\n" + "- Every Korean/Chinese/Japanese character must be converted to its English meaning. Examples: The character ็”Ÿ means 'life/living', ๆดป means 'active', ้คจ means 'hall/building' - together ็”Ÿๆดป้คจ means Dormitory.\n" + "- Preserve ALL HTML tags exactly as they appear in the source, including <head>, <title>, <h1>, <h2>, <p>, <br>, <div>, etc.\n" + ), + "chinese": ( + "You are a professional Chinese to English novel translator, you must strictly output only English text and HTML tags while following these rules:\n" + "- Use a natural, comedy-friendly English translation style that captures both humor and readability without losing any original meaning.\n" + "- Include 100% of the source text - every word, phrase, and sentence must be fully translated without exception.\n" + "- Always localize Chinese terminology to proper English equivalents instead of literal translations (examples: ้ญ”็Ž‹ = Demon King; ้ญ”ๆณ• = magic).\n" + "- When translating Chinese's pronoun-dropping style, insert pronouns in English only where needed for clarity while maintaining natural English flow.\n" + "- All Chinese profanity must be translated to English profanity.\n" + "- Preserve original intent, and speech tone.\n" + "- Retain onomatopoeia in Pinyin.\n" + "- Keep original Chinese quotation marks (ใ€Œใ€, ใ€Žใ€) as-is without converting to English quotes.\n" + "- Every Korean/Chinese/Japanese character must be converted to its English meaning. Examples: The character ็”Ÿ means 'life/living', ๆดป means 'active', ้คจ means 'hall/building' - together ็”Ÿๆดป้คจ means Dormitory.\n" + "- Preserve ALL HTML tags exactly as they appear in the source, including <head>, <title>, <h1>, <h2>, <p>, <br>, <div>, etc.\n" + ), + "Manga_JP": ( + "You are a professional Japanese to English Manga translator.\n" + "You have both the image of the Manga panel and the extracted text to work with.\n" + "Output only English text while following these rules: \n\n" + + "VISUAL CONTEXT:\n" + "- Analyze the character's facial expressions and body language in the image.\n" + "- Consider the scene's mood and atmosphere.\n" + "- Note any action or movement depicted.\n" + "- Use visual cues to determine the appropriate tone and emotion.\n" + "- USE THE IMAGE to inform your translation choices. The image is not decorative - it contains essential context for accurate translation.\n\n" + + "DIALOGUE REQUIREMENTS:\n" + "- Match the translation tone to the character's expression.\n" + "- If a character looks angry, use appropriately intense language.\n" + "- If a character looks shy or embarrassed, reflect that in the translation.\n" + "- Keep speech patterns consistent with the character's appearance and demeanor.\n" + "- Retain honorifics and onomatopoeia in Romaji.\n" + "- Keep original Japanese quotation marks (ใ€Œใ€, ใ€Žใ€) as-is without converting to English quotes.\n\n" + + "IMPORTANT: Use both the visual context and text to create the most accurate and natural-sounding translation.\n" + ), + "Manga_KR": ( + "You are a professional Korean to English Manhwa translator.\n" + "You have both the image of the Manhwa panel and the extracted text to work with.\n" + "Output only English text while following these rules: \n\n" + + "VISUAL CONTEXT:\n" + "- Analyze the character's facial expressions and body language in the image.\n" + "- Consider the scene's mood and atmosphere.\n" + "- Note any action or movement depicted.\n" + "- Use visual cues to determine the appropriate tone and emotion.\n" + "- USE THE IMAGE to inform your translation choices. The image is not decorative - it contains essential context for accurate translation.\n\n" + + "DIALOGUE REQUIREMENTS:\n" + "- Match the translation tone to the character's expression.\n" + "- If a character looks angry, use appropriately intense language.\n" + "- If a character looks shy or embarrassed, reflect that in the translation.\n" + "- Keep speech patterns consistent with the character's appearance and demeanor.\n" + "- Retain honorifics and onomatopoeia in Romaji.\n" + "- Keep original Korean quotation marks (โ€œ โ€, โ€˜ โ€˜, ใ€Œใ€, ใ€Žใ€) as-is without converting to English quotes.\n\n" + + "IMPORTANT: Use both the visual context and text to create the most accurate and natural-sounding translation.\n" + ), + "Manga_CN": ( + "You are a professional Chinese to English Manga translator.\n" + "You have both the image of the Manga panel and the extracted text to work with.\n" + "Output only English text while following these rules: \n\n" + + "VISUAL CONTEXT:\n" + "- Analyze the character's facial expressions and body language in the image.\n" + "- Consider the scene's mood and atmosphere.\n" + "- Note any action or movement depicted.\n" + "- Use visual cues to determine the appropriate tone and emotion.\n" + "- USE THE IMAGE to inform your translation choices. The image is not decorative - it contains essential context for accurate translation.\n\n" + + "DIALOGUE REQUIREMENTS:\n" + "- Match the translation tone to the character's expression.\n" + "- If a character looks angry, use appropriately intense language.\n" + "- If a character looks shy or embarrassed, reflect that in the translation.\n" + "- Keep speech patterns consistent with the character's appearance and demeanor.\n" + "- Retain honorifics and onomatopoeia in Romaji.\n" + "- Keep original Chinese quotation marks (ใ€Œใ€, ใ€Žใ€) as-is without converting to English quotes.\n\n" + + "IMPORTANT: Use both the visual context and text to create the most accurate and natural-sounding translation.\n" + ), + "Original": "Return everything exactly as seen on the source." + } + + # Load profiles from config and merge with defaults + # Always include default prompts, then overlay any custom ones from config + self.profiles = self.default_prompts.copy() + config_profiles = self.config.get('prompt_profiles', {}) + if config_profiles: + self.profiles.update(config_profiles) + + def get_config_value(self, key, default=None): + """Get value from decrypted config with fallback""" + return self.decrypted_config.get(key, default) + + def get_current_config_for_update(self): + """Get the current config for updating (uses in-memory version)""" + # Return a copy of the in-memory config, not loaded from file + return self.config.copy() + + def get_default_config(self): + """Get default configuration for Hugging Face Spaces""" + return { + 'model': 'gpt-4-turbo', + 'api_key': '', + 'api_call_delay': 0.5, # Default 0.5 seconds between API calls + 'batch_translation': True, # Enable batch translation by default + 'batch_size': 10, # Default batch size + 'text_extraction_method': 'standard', # CRITICAL: Default extraction method (standard=BeautifulSoup, enhanced=html2text) + 'file_filtering_level': 'smart', # CRITICAL: Default filtering level (smart/comprehensive/full) + 'enhanced_preserve_structure': True, # Preserve HTML structure in enhanced mode + 'force_bs_for_traditional': True, # CRITICAL: Force BeautifulSoup for traditional extraction + 'indefinitely_retry_rate_limit': False, # CRITICAL: Default to False for rate limit retry + 'thread_submission_delay': 0.1, # CRITICAL: Default threading delay + 'prompt_profiles': {}, # Will be populated from default_prompts in __init__ + 'active_profile': 'korean', # Default active profile + 'ocr_provider': 'custom-api', + 'bubble_detection_enabled': True, + 'inpainting_enabled': True, + 'manga_font_size_mode': 'auto', + 'manga_font_size': 0, + 'manga_font_size_multiplier': 1.0, + 'manga_min_font_size': 10, + 'manga_max_font_size': 40, + 'manga_text_color': [102, 0, 0], # Dark red text (manga_integration.py default) + 'manga_shadow_enabled': True, + 'manga_shadow_color': [204, 128, 128], # Light pink shadow (manga_integration.py default) + 'manga_shadow_offset_x': 2, # Match manga integration + 'manga_shadow_offset_y': 2, # Match manga integration + 'manga_shadow_blur': 0, # Match manga integration (no blur) + 'manga_bg_opacity': 0, # Transparent background by default + 'manga_bg_style': 'circle', + 'manga_settings': { + 'ocr': { + 'detector_type': 'rtdetr_onnx', + 'rtdetr_confidence': 0.3, + 'bubble_confidence': 0.3, + 'detect_text_bubbles': True, + 'detect_empty_bubbles': True, + 'detect_free_text': True, + 'bubble_max_detections_yolo': 100 + }, + 'inpainting': { + 'local_method': 'anime', + 'method': 'local', + 'batch_size': 10, + 'enable_cache': True + }, + 'advanced': { + 'parallel_processing': True, + 'max_workers': 2, + 'parallel_panel_translation': False, + 'panel_max_workers': 7, + 'format_detection': True, + 'webtoon_mode': 'auto', + 'torch_precision': 'fp16', + 'auto_cleanup_models': False, + 'debug_mode': False, + 'save_intermediate': False + }, + 'rendering': { + 'auto_min_size': 10, + 'auto_max_size': 40, + 'auto_fit_style': 'balanced' + }, + 'font_sizing': { + 'algorithm': 'smart', + 'prefer_larger': True, + 'max_lines': 10, + 'line_spacing': 1.3, + 'bubble_size_factor': True, + 'min_size': 10, + 'max_size': 40 + }, + 'tiling': { + 'enabled': False, + 'tile_size': 480, + 'tile_overlap': 64 + } + } + } + + def load_config(self): + """Load configuration - from persistent file on HF Spaces or local file""" + is_hf_spaces = os.getenv('SPACE_ID') is not None or os.getenv('HF_SPACES') == 'true' + + # Try to load from file (works both locally and on HF Spaces with persistent storage) + try: + if os.path.exists(self.config_file): + with open(self.config_file, 'r', encoding='utf-8') as f: + loaded_config = json.load(f) + # Start with defaults + default_config = self.get_default_config() + # Deep merge - preserve nested structures from loaded config + self._deep_merge_config(default_config, loaded_config) + + if is_hf_spaces: + print(f"โœ… Loaded config from persistent storage: {self.config_file}") + else: + print(f"โœ… Loaded config from local file: {self.config_file}") + + return default_config + except Exception as e: + print(f"Could not load config from {self.config_file}: {e}") + + # If loading fails or file doesn't exist - return defaults + print(f"๐Ÿ“ Using default configuration") + return self.get_default_config() + + def _deep_merge_config(self, base, override): + """Deep merge override config into base config""" + for key, value in override.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + # Recursively merge nested dicts + self._deep_merge_config(base[key], value) + else: + # Override the value + base[key] = value + + def set_all_environment_variables(self): + """Set all environment variables from config for translation engines""" + config = self.get_config_value + + # API Rate Limiting + os.environ['SEND_INTERVAL_SECONDS'] = str(config('api_call_delay', 0.5)) + + # Chapter Processing Options + os.environ['BATCH_TRANSLATE_HEADERS'] = '1' if config('batch_translate_headers', False) else '0' + os.environ['HEADERS_PER_BATCH'] = str(config('headers_per_batch', 400)) + os.environ['USE_NCX_NAVIGATION'] = '1' if config('use_ncx_navigation', False) else '0' + os.environ['ATTACH_CSS_TO_CHAPTERS'] = '1' if config('attach_css_to_chapters', False) else '0' + os.environ['RETAIN_SOURCE_EXTENSION'] = '1' if config('retain_source_extension', True) else '0' + os.environ['USE_CONSERVATIVE_BATCHING'] = '1' if config('use_conservative_batching', False) else '0' + os.environ['DISABLE_GEMINI_SAFETY'] = '1' if config('disable_gemini_safety', False) else '0' + os.environ['USE_HTTP_OPENROUTER'] = '1' if config('use_http_openrouter', False) else '0' + os.environ['DISABLE_OPENROUTER_COMPRESSION'] = '1' if config('disable_openrouter_compression', False) else '0' + + # Chapter Extraction Settings + # TEXT_EXTRACTION_METHOD: 'standard' (BeautifulSoup) or 'enhanced' (html2text) + text_extraction_method = config('text_extraction_method', 'standard') + file_filtering_level = config('file_filtering_level', 'smart') + + os.environ['TEXT_EXTRACTION_METHOD'] = text_extraction_method + os.environ['FILE_FILTERING_LEVEL'] = file_filtering_level + + # EXTRACTION_MODE: Use file_filtering_level unless text_extraction_method is 'enhanced' + # If enhanced mode, EXTRACTION_MODE = 'enhanced', otherwise use filtering level + if text_extraction_method == 'enhanced': + os.environ['EXTRACTION_MODE'] = 'enhanced' + else: + os.environ['EXTRACTION_MODE'] = file_filtering_level + + # ENHANCED_FILTERING: Only relevant for enhanced mode, but set for all modes + os.environ['ENHANCED_FILTERING'] = file_filtering_level + + # ENHANCED_PRESERVE_STRUCTURE: Preserve HTML structure in enhanced mode + os.environ['ENHANCED_PRESERVE_STRUCTURE'] = '1' if config('enhanced_preserve_structure', True) else '0' + + # FORCE_BS_FOR_TRADITIONAL: Force BeautifulSoup for traditional/standard extraction + os.environ['FORCE_BS_FOR_TRADITIONAL'] = '1' if config('force_bs_for_traditional', True) else '0' + + # Rate Limit Retry Settings + os.environ['INDEFINITELY_RETRY_RATE_LIMIT'] = '1' if config('indefinitely_retry_rate_limit', False) else '0' + + # Thinking Mode Settings + os.environ['ENABLE_GPT_THINKING'] = '1' if config('enable_gpt_thinking', True) else '0' + os.environ['GPT_THINKING_EFFORT'] = config('gpt_thinking_effort', 'medium') + os.environ['OR_THINKING_TOKENS'] = str(config('or_thinking_tokens', 2000)) + os.environ['ENABLE_GEMINI_THINKING'] = '1' if config('enable_gemini_thinking', False) else '0' + os.environ['GEMINI_THINKING_BUDGET'] = str(config('gemini_thinking_budget', 0)) + # IMPORTANT: Also set THINKING_BUDGET for unified_api_client compatibility + os.environ['THINKING_BUDGET'] = str(config('gemini_thinking_budget', 0)) + + # Translation Settings + os.environ['CONTEXTUAL'] = '1' if config('contextual', False) else '0' + os.environ['TRANSLATION_HISTORY_LIMIT'] = str(config('translation_history_limit', 2)) + os.environ['TRANSLATION_HISTORY_ROLLING'] = '1' if config('translation_history_rolling', False) else '0' + os.environ['BATCH_TRANSLATION'] = '1' if config('batch_translation', True) else '0' + os.environ['BATCH_SIZE'] = str(config('batch_size', 10)) + os.environ['THREAD_SUBMISSION_DELAY'] = str(config('thread_submission_delay', 0.1)) + # DELAY is kept for backwards compatibility, but reads from api_call_delay + os.environ['DELAY'] = str(config('api_call_delay', 0.5)) + os.environ['CHAPTER_RANGE'] = config('chapter_range', '') + os.environ['TOKEN_LIMIT'] = str(config('token_limit', 200000)) + os.environ['TOKEN_LIMIT_DISABLED'] = '1' if config('token_limit_disabled', False) else '0' + os.environ['DISABLE_INPUT_TOKEN_LIMIT'] = '1' if config('token_limit_disabled', False) else '0' + + # Glossary Settings + os.environ['ENABLE_AUTO_GLOSSARY'] = '1' if config('enable_auto_glossary', False) else '0' + os.environ['APPEND_GLOSSARY_TO_PROMPT'] = '1' if config('append_glossary_to_prompt', True) else '0' + os.environ['GLOSSARY_MIN_FREQUENCY'] = str(config('glossary_min_frequency', 2)) + os.environ['GLOSSARY_MAX_NAMES'] = str(config('glossary_max_names', 50)) + os.environ['GLOSSARY_MAX_TITLES'] = str(config('glossary_max_titles', 30)) + os.environ['GLOSSARY_BATCH_SIZE'] = str(config('glossary_batch_size', 50)) + os.environ['GLOSSARY_FILTER_MODE'] = config('glossary_filter_mode', 'all') + os.environ['GLOSSARY_FUZZY_THRESHOLD'] = str(config('glossary_fuzzy_threshold', 0.90)) + + # Manual Glossary Settings + os.environ['MANUAL_GLOSSARY_MIN_FREQUENCY'] = str(config('manual_glossary_min_frequency', 2)) + os.environ['MANUAL_GLOSSARY_MAX_NAMES'] = str(config('manual_glossary_max_names', 50)) + os.environ['MANUAL_GLOSSARY_MAX_TITLES'] = str(config('manual_glossary_max_titles', 30)) + os.environ['GLOSSARY_MAX_TEXT_SIZE'] = str(config('glossary_max_text_size', 50000)) + os.environ['GLOSSARY_MAX_SENTENCES'] = str(config('glossary_max_sentences', 200)) + os.environ['GLOSSARY_CHAPTER_SPLIT_THRESHOLD'] = str(config('glossary_chapter_split_threshold', 8192)) + os.environ['MANUAL_GLOSSARY_FILTER_MODE'] = config('manual_glossary_filter_mode', 'all') + os.environ['STRIP_HONORIFICS'] = '1' if config('strip_honorifics', True) else '0' + os.environ['MANUAL_GLOSSARY_FUZZY_THRESHOLD'] = str(config('manual_glossary_fuzzy_threshold', 0.90)) + os.environ['GLOSSARY_USE_LEGACY_CSV'] = '1' if config('glossary_use_legacy_csv', False) else '0' + + # QA Scanner Settings + os.environ['ENABLE_POST_TRANSLATION_SCAN'] = '1' if config('enable_post_translation_scan', False) else '0' + os.environ['QA_MIN_FOREIGN_CHARS'] = str(config('qa_min_foreign_chars', 10)) + os.environ['QA_CHECK_REPETITION'] = '1' if config('qa_check_repetition', True) else '0' + os.environ['QA_CHECK_GLOSSARY_LEAKAGE'] = '1' if config('qa_check_glossary_leakage', True) else '0' + os.environ['QA_MIN_FILE_LENGTH'] = str(config('qa_min_file_length', 0)) + os.environ['QA_CHECK_MULTIPLE_HEADERS'] = '1' if config('qa_check_multiple_headers', True) else '0' + os.environ['QA_CHECK_MISSING_HTML'] = '1' if config('qa_check_missing_html', True) else '0' + os.environ['QA_CHECK_INSUFFICIENT_PARAGRAPHS'] = '1' if config('qa_check_insufficient_paragraphs', True) else '0' + os.environ['QA_MIN_PARAGRAPH_PERCENTAGE'] = str(config('qa_min_paragraph_percentage', 30)) + os.environ['QA_REPORT_FORMAT'] = config('qa_report_format', 'detailed') + os.environ['QA_AUTO_SAVE_REPORT'] = '1' if config('qa_auto_save_report', True) else '0' + + # Manga/Image Translation Settings (when available) + os.environ['BUBBLE_DETECTION_ENABLED'] = '1' if config('bubble_detection_enabled', True) else '0' + os.environ['INPAINTING_ENABLED'] = '1' if config('inpainting_enabled', True) else '0' + os.environ['MANGA_FONT_SIZE_MODE'] = config('manga_font_size_mode', 'auto') + os.environ['MANGA_FONT_SIZE'] = str(config('manga_font_size', 24)) + os.environ['MANGA_FONT_MULTIPLIER'] = str(config('manga_font_multiplier', 1.0)) + os.environ['MANGA_MIN_FONT_SIZE'] = str(config('manga_min_font_size', 12)) + os.environ['MANGA_MAX_FONT_SIZE'] = str(config('manga_max_font_size', 48)) + os.environ['MANGA_SHADOW_ENABLED'] = '1' if config('manga_shadow_enabled', True) else '0' + os.environ['MANGA_SHADOW_OFFSET_X'] = str(config('manga_shadow_offset_x', 2)) + os.environ['MANGA_SHADOW_OFFSET_Y'] = str(config('manga_shadow_offset_y', 2)) + os.environ['MANGA_SHADOW_BLUR'] = str(config('manga_shadow_blur', 0)) + os.environ['MANGA_BG_OPACITY'] = str(config('manga_bg_opacity', 130)) + os.environ['MANGA_BG_STYLE'] = config('manga_bg_style', 'circle') + + # OCR Provider Settings + os.environ['OCR_PROVIDER'] = config('ocr_provider', 'custom-api') + + # Advanced Manga Settings + manga_settings = config('manga_settings', {}) + if manga_settings: + advanced = manga_settings.get('advanced', {}) + os.environ['PARALLEL_PANEL_TRANSLATION'] = '1' if advanced.get('parallel_panel_translation', False) else '0' + os.environ['PANEL_MAX_WORKERS'] = str(advanced.get('panel_max_workers', 7)) + os.environ['PANEL_START_STAGGER_MS'] = str(advanced.get('panel_start_stagger_ms', 0)) + os.environ['WEBTOON_MODE'] = '1' if advanced.get('webtoon_mode', False) else '0' + os.environ['DEBUG_MODE'] = '1' if advanced.get('debug_mode', False) else '0' + os.environ['SAVE_INTERMEDIATE'] = '1' if advanced.get('save_intermediate', False) else '0' + os.environ['PARALLEL_PROCESSING'] = '1' if advanced.get('parallel_processing', True) else '0' + os.environ['MAX_WORKERS'] = str(advanced.get('max_workers', 4)) + os.environ['AUTO_CLEANUP_MODELS'] = '1' if advanced.get('auto_cleanup_models', False) else '0' + os.environ['TORCH_PRECISION'] = advanced.get('torch_precision', 'auto') + os.environ['PRELOAD_LOCAL_INPAINTING_FOR_PANELS'] = '1' if advanced.get('preload_local_inpainting_for_panels', False) else '0' + + # OCR settings + ocr = manga_settings.get('ocr', {}) + os.environ['DETECTOR_TYPE'] = ocr.get('detector_type', 'rtdetr_onnx') + os.environ['RTDETR_CONFIDENCE'] = str(ocr.get('rtdetr_confidence', 0.3)) + os.environ['BUBBLE_CONFIDENCE'] = str(ocr.get('bubble_confidence', 0.3)) + os.environ['DETECT_TEXT_BUBBLES'] = '1' if ocr.get('detect_text_bubbles', True) else '0' + os.environ['DETECT_EMPTY_BUBBLES'] = '1' if ocr.get('detect_empty_bubbles', True) else '0' + os.environ['DETECT_FREE_TEXT'] = '1' if ocr.get('detect_free_text', True) else '0' + os.environ['BUBBLE_MAX_DETECTIONS_YOLO'] = str(ocr.get('bubble_max_detections_yolo', 100)) + + # Inpainting settings + inpainting = manga_settings.get('inpainting', {}) + os.environ['LOCAL_INPAINT_METHOD'] = inpainting.get('local_method', 'anime_onnx') + os.environ['INPAINT_BATCH_SIZE'] = str(inpainting.get('batch_size', 10)) + os.environ['INPAINT_CACHE_ENABLED'] = '1' if inpainting.get('enable_cache', True) else '0' + + # HD Strategy + os.environ['HD_STRATEGY'] = advanced.get('hd_strategy', 'resize') + os.environ['HD_RESIZE_LIMIT'] = str(advanced.get('hd_strategy_resize_limit', 1536)) + os.environ['HD_CROP_MARGIN'] = str(advanced.get('hd_strategy_crop_margin', 16)) + os.environ['HD_CROP_TRIGGER'] = str(advanced.get('hd_strategy_crop_trigger_size', 1024)) + + # Concise Pipeline Logs + os.environ['CONCISE_PIPELINE_LOGS'] = '1' if config('concise_pipeline_logs', False) else '0' + + print("โœ… All environment variables set from configuration") + + def save_config(self, config): + """Save configuration - to persistent file on HF Spaces or local file""" + is_hf_spaces = os.getenv('SPACE_ID') is not None or os.getenv('HF_SPACES') == 'true' + + # Always try to save to file (works both locally and on HF Spaces with persistent storage) + try: + config_to_save = config.copy() + + # Only encrypt if we have the encryption module AND keys aren't already encrypted + if API_KEY_ENCRYPTION_AVAILABLE: + # Check if keys need encryption (not already encrypted) + needs_encryption = False + for key in ['api_key', 'azure_vision_key', 'google_vision_credentials']: + if key in config_to_save: + value = config_to_save[key] + # If it's a non-empty string that doesn't start with 'ENC:', it needs encryption + if value and isinstance(value, str) and not value.startswith('ENC:'): + needs_encryption = True + break + + if needs_encryption: + config_to_save = encrypt_config(config_to_save) + + # Create directory if it doesn't exist (important for HF Spaces) + os.makedirs(os.path.dirname(self.config_file) or '.', exist_ok=True) + + # Debug output + if is_hf_spaces: + print(f"๐Ÿ“ Saving to HF Spaces persistent storage: {self.config_file}") + + print(f"DEBUG save_config called with model={config.get('model')}, batch_size={config.get('batch_size')}") + print(f"DEBUG self.config before={self.config.get('model') if hasattr(self, 'config') else 'N/A'}") + print(f"DEBUG self.decrypted_config before={self.decrypted_config.get('model') if hasattr(self, 'decrypted_config') else 'N/A'}") + + with open(self.config_file, 'w', encoding='utf-8') as f: + json.dump(config_to_save, f, ensure_ascii=False, indent=2) + + # IMPORTANT: Update the in-memory configs so the UI reflects the changes immediately + self.config = config_to_save + # Update decrypted config too + self.decrypted_config = config.copy() # Use the original (unencrypted) version + if API_KEY_ENCRYPTION_AVAILABLE: + # Make sure decrypted_config has decrypted values + self.decrypted_config = decrypt_config(self.decrypted_config) + + print(f"DEBUG self.config after={self.config.get('model')}") + print(f"DEBUG self.decrypted_config after={self.decrypted_config.get('model')}") + + if is_hf_spaces: + print(f"โœ… Saved to persistent storage: {self.config_file}") + # Also verify the file was written + if os.path.exists(self.config_file): + file_size = os.path.getsize(self.config_file) + print(f"โœ… File confirmed: {file_size} bytes") + return "โœ… Settings saved to persistent storage!" + else: + print(f"โœ… Saved to {self.config_file}") + return "โœ… Settings saved successfully!" + + except Exception as e: + print(f"โŒ Save error: {e}") + if is_hf_spaces: + print(f"๐Ÿ’ก Note: Make sure you have persistent storage enabled for your Space") + return f"โŒ Failed to save: {str(e)}\n\nNote: Persistent storage may not be enabled" + return f"โŒ Failed to save: {str(e)}" + + def translate_epub( + self, + epub_file, + model, + api_key, + profile_name, + system_prompt, + temperature, + max_tokens, + enable_image_trans=False, + glossary_file=None + ): + """Translate EPUB file - yields progress updates""" + + if not TRANSLATION_AVAILABLE: + yield None, None, None, "โŒ Translation modules not loaded", None, "Error", 0 + return + + if not epub_file: + yield None, None, None, "โŒ Please upload an EPUB or TXT file", None, "Error", 0 + return + + if not api_key: + yield None, None, None, "โŒ Please provide an API key", None, "Error", 0 + return + + if not profile_name: + yield None, None, None, "โŒ Please select a translation profile", None, "Error", 0 + return + + # Initialize logs list + translation_logs = [] + + try: + # Initial status + input_path = epub_file.name if hasattr(epub_file, 'name') else epub_file + file_ext = os.path.splitext(input_path)[1].lower() + file_type = "EPUB" if file_ext == ".epub" else "TXT" + + translation_logs.append(f"๐Ÿ“š Starting {file_type} translation...") + yield None, None, gr.update(visible=True), "\n".join(translation_logs), gr.update(visible=True), "Starting...", 0 + + # Save uploaded file to temp location if needed + epub_base = os.path.splitext(os.path.basename(input_path))[0] + + translation_logs.append(f"๐Ÿ“– Input: {os.path.basename(input_path)}") + translation_logs.append(f"๐Ÿค– Model: {model}") + translation_logs.append(f"๐Ÿ“ Profile: {profile_name}") + yield None, None, gr.update(visible=True), "\n".join(translation_logs), gr.update(visible=True), "Initializing...", 5 + + # Use the provided system prompt (user may have edited it) + translation_prompt = system_prompt if system_prompt else self.profiles.get(profile_name, "") + + # Set the input path as a command line argument simulation + import sys + original_argv = sys.argv.copy() + sys.argv = ['glossarion_web.py', input_path] + + # Set environment variables for TransateKRtoEN.main() + os.environ['input_path'] = input_path + os.environ['MODEL'] = model + os.environ['TRANSLATION_TEMPERATURE'] = str(temperature) + os.environ['MAX_OUTPUT_TOKENS'] = str(max_tokens) + os.environ['ENABLE_IMAGE_TRANSLATION'] = '1' if enable_image_trans else '0' + # Set output directory to current working directory + os.environ['OUTPUT_DIRECTORY'] = os.getcwd() + + # Set all additional environment variables from config + self.set_all_environment_variables() + + # OVERRIDE critical safety features AFTER config load + # CORRECT variable name is EMERGENCY_PARAGRAPH_RESTORE (no ATION) + os.environ['EMERGENCY_PARAGRAPH_RESTORE'] = '0' # DISABLED + os.environ['REMOVE_AI_ARTIFACTS'] = '1' # ENABLED + + # Debug: Verify ALL critical settings + translation_logs.append(f"\n๐Ÿ”ง Debug: EMERGENCY_PARAGRAPH_RESTORE = '{os.environ.get('EMERGENCY_PARAGRAPH_RESTORE', 'NOT SET')}'") + translation_logs.append(f"๐Ÿ”ง Debug: REMOVE_AI_ARTIFACTS = '{os.environ.get('REMOVE_AI_ARTIFACTS', 'NOT SET')}'") + translation_logs.append(f"๐Ÿ” Debug: TEXT_EXTRACTION_METHOD = '{os.environ.get('TEXT_EXTRACTION_METHOD', 'NOT SET')}'") + translation_logs.append(f"๐Ÿ” Debug: EXTRACTION_MODE = '{os.environ.get('EXTRACTION_MODE', 'NOT SET')}'") + translation_logs.append(f"๐Ÿ“‹ Debug: FILE_FILTERING_LEVEL = '{os.environ.get('FILE_FILTERING_LEVEL', 'NOT SET')}'") + translation_logs.append(f"๐Ÿ”ง Debug: FORCE_BS_FOR_TRADITIONAL = '{os.environ.get('FORCE_BS_FOR_TRADITIONAL', 'NOT SET')}'") + yield None, None, gr.update(visible=True), "\n".join(translation_logs), gr.update(visible=True), "Configuration set...", 10 + + # Set API key environment variable + if 'gpt' in model.lower() or 'openai' in model.lower(): + os.environ['OPENAI_API_KEY'] = api_key + os.environ['API_KEY'] = api_key + elif 'claude' in model.lower(): + os.environ['ANTHROPIC_API_KEY'] = api_key + os.environ['API_KEY'] = api_key + elif 'gemini' in model.lower(): + os.environ['GOOGLE_API_KEY'] = api_key + os.environ['API_KEY'] = api_key + else: + os.environ['API_KEY'] = api_key + + # Set the system prompt - CRITICAL: Must set environment variable for TransateKRtoEN.main() + if translation_prompt: + # Set environment variable that TransateKRtoEN reads + os.environ['SYSTEM_PROMPT'] = translation_prompt + print(f"โœ… System prompt set ({len(translation_prompt)} characters)") + + # Save to temp profile for consistency + temp_config = self.config.copy() + temp_config['prompt_profiles'] = temp_config.get('prompt_profiles', {}) + temp_config['prompt_profiles'][profile_name] = translation_prompt + temp_config['active_profile'] = profile_name + + # Save temporarily + with open(self.config_file, 'w', encoding='utf-8') as f: + json.dump(temp_config, f, ensure_ascii=False, indent=2) + else: + # Even if empty, set it to avoid using stale value + os.environ['SYSTEM_PROMPT'] = '' + print("โš ๏ธ No system prompt provided") + + translation_logs.append("โš™๏ธ Configuration set") + yield None, None, gr.update(visible=True), "\n".join(translation_logs), gr.update(visible=True), "Starting translation...", 10 + + # Create a thread-safe queue for capturing logs + import queue + import threading + import time + log_queue = queue.Queue() + translation_complete = threading.Event() + translation_error = [None] + + def log_callback(msg): + """Capture log messages""" + if msg and msg.strip(): + log_queue.put(msg.strip()) + + # Run translation in a separate thread + def run_translation(): + try: + result = TransateKRtoEN.main( + log_callback=log_callback, + stop_callback=None + ) + translation_error[0] = None + except Exception as e: + translation_error[0] = e + finally: + translation_complete.set() + + translation_thread = threading.Thread(target=run_translation, daemon=True) + translation_thread.start() + + # Monitor progress + last_yield_time = time.time() + progress_percent = 10 + + while not translation_complete.is_set() or not log_queue.empty(): + # Check if stop was requested + if self.epub_translation_stop: + translation_logs.append("โš ๏ธ Stopping translation...") + # Try to stop the translation thread + translation_complete.set() + break + + # Collect logs + new_logs = [] + while not log_queue.empty(): + try: + msg = log_queue.get_nowait() + new_logs.append(msg) + except queue.Empty: + break + + # Add new logs + if new_logs: + translation_logs.extend(new_logs) + + # Update progress based on log content + for log in new_logs: + if 'Chapter' in log or 'chapter' in log: + progress_percent = min(progress_percent + 5, 90) + elif 'โœ…' in log or 'Complete' in log: + progress_percent = min(progress_percent + 10, 95) + elif 'Translating' in log: + progress_percent = min(progress_percent + 2, 85) + + # Yield updates periodically + current_time = time.time() + if new_logs or (current_time - last_yield_time) > 1.0: + status_text = new_logs[-1] if new_logs else "Processing..." + # Keep only last 100 logs to avoid UI overflow + display_logs = translation_logs[-100:] if len(translation_logs) > 100 else translation_logs + yield None, None, gr.update(visible=True), "\n".join(display_logs), gr.update(visible=True), status_text, progress_percent + last_yield_time = current_time + + # Small delay to avoid CPU spinning + time.sleep(0.1) + + # Wait for thread to complete + translation_thread.join(timeout=5) + + # Restore original sys.argv + sys.argv = original_argv + + # Log any errors but don't fail immediately - check for output first + if translation_error[0]: + error_msg = f"โš ๏ธ Translation completed with warnings: {str(translation_error[0])}" + translation_logs.append(error_msg) + translation_logs.append("๐Ÿ” Checking for output file...") + + # Check for output file - just grab any .epub from the output directory + output_dir = epub_base + compiled_epub = None + + # First, try to find ANY .epub file in the output directory + output_dir_path = os.path.join(os.getcwd(), output_dir) + if os.path.isdir(output_dir_path): + translation_logs.append(f"\n๐Ÿ“‚ Checking output directory: {output_dir_path}") + for file in os.listdir(output_dir_path): + if file.endswith('.epub'): + full_path = os.path.join(output_dir_path, file) + # Make sure it's not a temp/backup file + if os.path.isfile(full_path) and os.path.getsize(full_path) > 1000: + compiled_epub = full_path + translation_logs.append(f" โœ… Found EPUB in output dir: {file}") + break + + # If we found it in the output directory, return it immediately + if compiled_epub: + file_size = os.path.getsize(compiled_epub) + translation_logs.append(f"\nโœ… Translation complete: {os.path.basename(compiled_epub)}") + translation_logs.append(f"๐Ÿ”— File path: {compiled_epub}") + translation_logs.append(f"๐Ÿ“ File size: {file_size:,} bytes ({file_size/1024/1024:.2f} MB)") + + # Create ZIP file containing the entire output folder + import zipfile + # Get the output folder (where the EPUB is located) + output_folder = os.path.dirname(compiled_epub) + folder_name = os.path.basename(output_folder) if output_folder else epub_base + zip_path = os.path.join(os.path.dirname(output_folder) if output_folder else os.getcwd(), f"{folder_name}.zip") + translation_logs.append(f"๐Ÿ“ฆ Creating ZIP archive of output folder...") + + try: + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Walk through the output folder and add all files + for root, dirs, files in os.walk(output_folder): + for file in files: + file_path = os.path.join(root, file) + # Create relative path for the archive + arcname = os.path.relpath(file_path, os.path.dirname(output_folder)) + zipf.write(file_path, arcname) + translation_logs.append(f" Added: {arcname}") + + zip_size = os.path.getsize(zip_path) + translation_logs.append(f"โœ… ZIP created: {os.path.basename(zip_path)}") + translation_logs.append(f"๐Ÿ“ ZIP size: {zip_size:,} bytes ({zip_size/1024/1024:.2f} MB)") + translation_logs.append(f"๐Ÿ“ฅ Click 'Download Translated {file_type}' below to save your ZIP file") + + final_status = "Translation complete!" if not translation_error[0] else "Translation completed with warnings" + + yield ( + zip_path, + gr.update(value="### โœ… Translation Complete!", visible=True), + gr.update(visible=False), + "\n".join(translation_logs), + gr.update(value=final_status, visible=True), + final_status, + 100 + ) + return + except Exception as zip_error: + translation_logs.append(f"โš ๏ธ Could not create ZIP: {zip_error}") + translation_logs.append(f"๐Ÿ“ฅ Returning original {file_type} file instead") + final_status = "Translation complete!" if not translation_error[0] else "Translation completed with warnings" + + yield ( + compiled_epub, + gr.update(value="### โœ… Translation Complete!", visible=True), + gr.update(visible=False), + "\n".join(translation_logs), + gr.update(value=final_status, visible=True), + final_status, + 100 + ) + return + + # Determine output extension based on input file type + output_ext = ".epub" if file_ext == ".epub" else ".txt" + + # Get potential base directories + base_dirs = [ + os.getcwd(), # Current working directory + os.path.dirname(input_path), # Input file directory + "/tmp", # Common temp directory on Linux/HF Spaces + "/home/user/app", # HF Spaces app directory + os.path.expanduser("~"), # Home directory + ] + + # Look for multiple possible output locations + possible_paths = [] + + # Extract title from input filename for more patterns + # e.g., "tales of terror_dick donovan 2" -> "Tales of Terror" + title_parts = os.path.basename(input_path).replace(output_ext, '').split('_') + possible_titles = [ + epub_base, # Original: tales of terror_dick donovan 2 + ' '.join(title_parts[:-2]).title() if len(title_parts) > 2 else epub_base, # Tales Of Terror + ] + + for base_dir in base_dirs: + if base_dir and os.path.exists(base_dir): + for title in possible_titles: + # Direct in base directory + possible_paths.append(os.path.join(base_dir, f"{title}_translated{output_ext}")) + possible_paths.append(os.path.join(base_dir, f"{title}{output_ext}")) + # In output subdirectory + possible_paths.append(os.path.join(base_dir, output_dir, f"{title}_translated{output_ext}")) + possible_paths.append(os.path.join(base_dir, output_dir, f"{title}{output_ext}")) + # In nested output directory + possible_paths.append(os.path.join(base_dir, epub_base, f"{title}_translated{output_ext}")) + possible_paths.append(os.path.join(base_dir, epub_base, f"{title}{output_ext}")) + + # Also add relative paths + possible_paths.extend([ + f"{epub_base}_translated{output_ext}", + os.path.join(output_dir, f"{epub_base}_translated{output_ext}"), + os.path.join(output_dir, f"{epub_base}{output_ext}"), + ]) + + # Also search for any translated file in the output directory + if os.path.isdir(output_dir): + for file in os.listdir(output_dir): + if file.endswith(f'_translated{output_ext}'): + possible_paths.insert(0, os.path.join(output_dir, file)) + + # Add debug information about current environment + translation_logs.append(f"\n๐Ÿ“ Debug Info:") + translation_logs.append(f" Current working directory: {os.getcwd()}") + translation_logs.append(f" Input file directory: {os.path.dirname(input_path)}") + translation_logs.append(f" Looking for: {epub_base}_translated{output_ext}") + + translation_logs.append(f"\n๐Ÿ” Searching for output file...") + for potential_epub in possible_paths[:10]: # Show first 10 paths + translation_logs.append(f" Checking: {potential_epub}") + if os.path.exists(potential_epub): + compiled_epub = potential_epub + translation_logs.append(f" โœ… Found: {potential_epub}") + break + + if not compiled_epub and len(possible_paths) > 10: + translation_logs.append(f" ... and {len(possible_paths) - 10} more paths") + + if compiled_epub: + # Verify file exists and is readable + if os.path.exists(compiled_epub) and os.path.isfile(compiled_epub): + file_size = os.path.getsize(compiled_epub) + translation_logs.append(f"โœ… Translation complete: {os.path.basename(compiled_epub)}") + translation_logs.append(f"๐Ÿ”— File path: {compiled_epub}") + translation_logs.append(f"๐Ÿ“ File size: {file_size:,} bytes ({file_size/1024/1024:.2f} MB)") + + # Create ZIP file containing the entire output folder + import zipfile + # Get the output folder (where the EPUB is located) + output_folder = os.path.dirname(compiled_epub) + folder_name = os.path.basename(output_folder) if output_folder else epub_base + zip_path = os.path.join(os.path.dirname(output_folder) if output_folder else os.getcwd(), f"{folder_name}.zip") + translation_logs.append(f"๐Ÿ“ฆ Creating ZIP archive of output folder...") + + try: + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Walk through the output folder and add all files + for root, dirs, files in os.walk(output_folder): + for file in files: + file_path = os.path.join(root, file) + # Create relative path for the archive + arcname = os.path.relpath(file_path, os.path.dirname(output_folder)) + zipf.write(file_path, arcname) + translation_logs.append(f" Added: {arcname}") + + zip_size = os.path.getsize(zip_path) + translation_logs.append(f"โœ… ZIP created: {os.path.basename(zip_path)}") + translation_logs.append(f"๐Ÿ“ ZIP size: {zip_size:,} bytes ({zip_size/1024/1024:.2f} MB)") + translation_logs.append(f"๐Ÿ“ฅ Click 'Download Translated {file_type}' below to save your ZIP file") + + final_status = "Translation complete!" if not translation_error[0] else "Translation completed with warnings" + + yield ( + zip_path, + gr.update(value="### โœ… Translation Complete!", visible=True), + gr.update(visible=False), + "\n".join(translation_logs), + gr.update(value=final_status, visible=True), + final_status, + 100 + ) + return + except Exception as zip_error: + translation_logs.append(f"โš ๏ธ Could not create ZIP: {zip_error}") + translation_logs.append(f"๐Ÿ“ฅ Returning original {file_type} file instead") + final_status = "Translation complete!" if not translation_error[0] else "Translation completed with warnings" + + yield ( + compiled_epub, + gr.update(value="### โœ… Translation Complete!", visible=True), + gr.update(visible=False), + "\n".join(translation_logs), + gr.update(value=final_status, visible=True), + final_status, + 100 + ) + return + else: + translation_logs.append(f"โš ๏ธ File found but not accessible: {compiled_epub}") + compiled_epub = None # Force search + + # Output file not found - search recursively in relevant directories + translation_logs.append("โš ๏ธ Output file not in expected locations, searching recursively...") + found_files = [] + + # Search in multiple directories + search_dirs = [ + os.getcwd(), # Current directory + os.path.dirname(input_path), # Input file directory + "/tmp", # Temp directory (HF Spaces) + "/home/user/app", # HF Spaces app directory + ] + + for search_dir in search_dirs: + if not os.path.exists(search_dir): + continue + + translation_logs.append(f" Searching in: {search_dir}") + try: + for root, dirs, files in os.walk(search_dir, topdown=True): + # Limit depth to 3 levels and skip hidden/system directories + depth = root[len(search_dir):].count(os.sep) + if depth >= 3: + dirs[:] = [] # Don't go deeper + else: + dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ['__pycache__', 'node_modules', 'venv', '.git']] + + for file in files: + # Look for files with _translated in name or matching our pattern + if (f'_translated{output_ext}' in file or + (file.endswith(output_ext) and epub_base in file)): + full_path = os.path.join(root, file) + found_files.append(full_path) + translation_logs.append(f" โœ… Found: {full_path}") + except (PermissionError, OSError) as e: + translation_logs.append(f" โš ๏ธ Could not search {search_dir}: {e}") + + if found_files: + # Use the most recently modified file + compiled_epub = max(found_files, key=os.path.getmtime) + + # Verify file exists and get info + if os.path.exists(compiled_epub) and os.path.isfile(compiled_epub): + file_size = os.path.getsize(compiled_epub) + translation_logs.append(f"โœ… Found output file: {os.path.basename(compiled_epub)}") + translation_logs.append(f"๐Ÿ”— File path: {compiled_epub}") + translation_logs.append(f"๐Ÿ“ File size: {file_size:,} bytes ({file_size/1024/1024:.2f} MB)") + + # Create ZIP file containing the entire output folder + import zipfile + # Get the output folder (where the EPUB is located) + output_folder = os.path.dirname(compiled_epub) + folder_name = os.path.basename(output_folder) if output_folder else epub_base + zip_path = os.path.join(os.path.dirname(output_folder) if output_folder else os.getcwd(), f"{folder_name}.zip") + translation_logs.append(f"๐Ÿ“ฆ Creating ZIP archive of output folder...") + + try: + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Walk through the output folder and add all files + for root, dirs, files in os.walk(output_folder): + for file in files: + file_path = os.path.join(root, file) + # Create relative path for the archive + arcname = os.path.relpath(file_path, os.path.dirname(output_folder)) + zipf.write(file_path, arcname) + translation_logs.append(f" Added: {arcname}") + + zip_size = os.path.getsize(zip_path) + translation_logs.append(f"โœ… ZIP created: {os.path.basename(zip_path)}") + translation_logs.append(f"๐Ÿ“ ZIP size: {zip_size:,} bytes ({zip_size/1024/1024:.2f} MB)") + translation_logs.append(f"๐Ÿ“ฅ Click 'Download Translated {file_type}' below to save your ZIP file") + + yield ( + zip_path, + gr.update(value="### โœ… Translation Complete!", visible=True), + gr.update(visible=False), + "\n".join(translation_logs), + gr.update(value="Translation complete!", visible=True), + "Translation complete!", + 100 + ) + return + except Exception as zip_error: + translation_logs.append(f"โš ๏ธ Could not create ZIP: {zip_error}") + translation_logs.append(f"๐Ÿ“ฅ Returning original {file_type} file instead") + + yield ( + compiled_epub, + gr.update(value="### โœ… Translation Complete!", visible=True), + gr.update(visible=False), + "\n".join(translation_logs), + gr.update(value="Translation complete!", visible=True), + "Translation complete!", + 100 + ) + return + + # Still couldn't find output - report failure + translation_logs.append("โŒ Could not locate translated output file") + translation_logs.append(f"๐Ÿ” Checked paths: {', '.join(possible_paths[:5])}...") + translation_logs.append("\n๐Ÿ’ก Troubleshooting tips:") + translation_logs.append(" 1. Check if TransateKRtoEN.py completed successfully") + translation_logs.append(" 2. Look for any error messages in the logs above") + translation_logs.append(" 3. The output might be in a subdirectory - check manually") + yield None, gr.update(value="### โš ๏ธ Output Not Found", visible=True), gr.update(visible=False), "\n".join(translation_logs), gr.update(value="Translation process completed but output file not found", visible=True), "Output not found", 90 + + except Exception as e: + import traceback + error_msg = f"โŒ Error during translation:\n{str(e)}\n\n{traceback.format_exc()}" + translation_logs.append(error_msg) + yield None, None, gr.update(visible=False), "\n".join(translation_logs), gr.update(visible=True), "Error occurred", 0 + + def translate_epub_with_stop(self, *args): + """Wrapper for translate_epub that includes button visibility control""" + self.epub_translation_stop = False + + # Show stop button, hide translate button at start + for result in self.translate_epub(*args): + if self.epub_translation_stop: + # Translation was stopped + yield result[0], result[1], result[2], result[3] + "\n\nโš ๏ธ Translation stopped by user", result[4], "Stopped", 0, gr.update(visible=True), gr.update(visible=False) + return + # Add button visibility updates to the yields + yield result[0], result[1], result[2], result[3], result[4], result[5], result[6], gr.update(visible=False), gr.update(visible=True) + + # Reset buttons at the end + yield result[0], result[1], result[2], result[3], result[4], result[5], result[6], gr.update(visible=True), gr.update(visible=False) + + def stop_epub_translation(self): + """Stop the ongoing EPUB translation""" + self.epub_translation_stop = True + if self.epub_translation_thread and self.epub_translation_thread.is_alive(): + # The thread will check the stop flag + pass + return gr.update(visible=True), gr.update(visible=False), "Translation stopped" + + def extract_glossary( + self, + epub_file, + model, + api_key, + min_frequency, + max_names, + max_titles=30, + max_text_size=50000, + max_sentences=200, + translation_batch=50, + chapter_split_threshold=8192, + filter_mode='all', + strip_honorifics=True, + fuzzy_threshold=0.90, + extraction_prompt=None, + format_instructions=None, + use_legacy_csv=False + ): + """Extract glossary from EPUB with manual extraction settings - yields progress updates""" + + if not epub_file: + yield None, None, None, "โŒ Please upload an EPUB file", None, "Error", 0 + return + + extraction_logs = [] + + try: + import extract_glossary_from_epub + + extraction_logs.append("๐Ÿ” Starting glossary extraction...") + yield None, None, gr.update(visible=True), "\n".join(extraction_logs), gr.update(visible=True), "Starting...", 0 + + input_path = epub_file.name if hasattr(epub_file, 'name') else epub_file + output_path = input_path.replace('.epub', '_glossary.csv') + + extraction_logs.append(f"๐Ÿ“– Input: {os.path.basename(input_path)}") + extraction_logs.append(f"๐Ÿค– Model: {model}") + yield None, None, gr.update(visible=True), "\n".join(extraction_logs), gr.update(visible=True), "Initializing...", 10 + + # Set all environment variables from config + self.set_all_environment_variables() + + # Set API key + if 'gpt' in model.lower(): + os.environ['OPENAI_API_KEY'] = api_key + elif 'claude' in model.lower(): + os.environ['ANTHROPIC_API_KEY'] = api_key + else: + os.environ['API_KEY'] = api_key + + extraction_logs.append("๐Ÿ“‹ Extracting text from EPUB...") + yield None, None, gr.update(visible=True), "\n".join(extraction_logs), gr.update(visible=True), "Extracting text...", 20 + + # Set environment variables for glossary extraction + os.environ['MODEL'] = model + os.environ['GLOSSARY_MIN_FREQUENCY'] = str(min_frequency) + os.environ['GLOSSARY_MAX_NAMES'] = str(max_names) + os.environ['GLOSSARY_MAX_TITLES'] = str(max_titles) + os.environ['GLOSSARY_BATCH_SIZE'] = str(translation_batch) + os.environ['GLOSSARY_MAX_TEXT_SIZE'] = str(max_text_size) + os.environ['GLOSSARY_MAX_SENTENCES'] = str(max_sentences) + os.environ['GLOSSARY_CHAPTER_SPLIT_THRESHOLD'] = str(chapter_split_threshold) + os.environ['GLOSSARY_FILTER_MODE'] = filter_mode + os.environ['GLOSSARY_STRIP_HONORIFICS'] = '1' if strip_honorifics else '0' + os.environ['GLOSSARY_FUZZY_THRESHOLD'] = str(fuzzy_threshold) + os.environ['GLOSSARY_USE_LEGACY_CSV'] = '1' if use_legacy_csv else '0' + + # Set prompts if provided + if extraction_prompt: + os.environ['GLOSSARY_SYSTEM_PROMPT'] = extraction_prompt + if format_instructions: + os.environ['GLOSSARY_FORMAT_INSTRUCTIONS'] = format_instructions + + extraction_logs.append(f"โš™๏ธ Settings: Min freq={min_frequency}, Max names={max_names}, Filter={filter_mode}") + extraction_logs.append(f"โš™๏ธ Options: Strip honorifics={strip_honorifics}, Fuzzy threshold={fuzzy_threshold:.2f}") + yield None, None, gr.update(visible=True), "\n".join(extraction_logs), gr.update(visible=True), "Processing...", 40 + + # Create a thread-safe queue for capturing logs + import queue + import threading + import time + log_queue = queue.Queue() + extraction_complete = threading.Event() + extraction_error = [None] + extraction_result = [None] + + def log_callback(msg): + """Capture log messages""" + if msg and msg.strip(): + log_queue.put(msg.strip()) + + # Run extraction in a separate thread + def run_extraction(): + try: + result = extract_glossary_from_epub.main( + log_callback=log_callback, + stop_callback=None + ) + extraction_result[0] = result + extraction_error[0] = None + except Exception as e: + extraction_error[0] = e + finally: + extraction_complete.set() + + extraction_thread = threading.Thread(target=run_extraction, daemon=True) + extraction_thread.start() + + # Monitor progress + last_yield_time = time.time() + progress_percent = 40 + + while not extraction_complete.is_set() or not log_queue.empty(): + # Check if stop was requested + if self.glossary_extraction_stop: + extraction_logs.append("โš ๏ธ Stopping extraction...") + # Try to stop the extraction thread + extraction_complete.set() + break + + # Collect logs + new_logs = [] + while not log_queue.empty(): + try: + msg = log_queue.get_nowait() + new_logs.append(msg) + except queue.Empty: + break + + # Add new logs + if new_logs: + extraction_logs.extend(new_logs) + + # Update progress based on log content + for log in new_logs: + if 'Processing' in log or 'Extracting' in log: + progress_percent = min(progress_percent + 5, 80) + elif 'Writing' in log or 'Saving' in log: + progress_percent = min(progress_percent + 10, 90) + + # Yield updates periodically + current_time = time.time() + if new_logs or (current_time - last_yield_time) > 1.0: + status_text = new_logs[-1] if new_logs else "Processing..." + # Keep only last 100 logs + display_logs = extraction_logs[-100:] if len(extraction_logs) > 100 else extraction_logs + yield None, None, gr.update(visible=True), "\n".join(display_logs), gr.update(visible=True), status_text, progress_percent + last_yield_time = current_time + + # Small delay to avoid CPU spinning + time.sleep(0.1) + + # Wait for thread to complete + extraction_thread.join(timeout=5) + + # Check for errors + if extraction_error[0]: + error_msg = f"โŒ Extraction error: {str(extraction_error[0])}" + extraction_logs.append(error_msg) + yield None, None, gr.update(visible=False), "\n".join(extraction_logs), gr.update(visible=True), error_msg, 0 + return + + extraction_logs.append("๐Ÿ–๏ธ Writing glossary to CSV...") + yield None, None, gr.update(visible=True), "\n".join(extraction_logs), gr.update(visible=True), "Writing CSV...", 95 + + if os.path.exists(output_path): + extraction_logs.append(f"โœ… Glossary extracted successfully!") + extraction_logs.append(f"๐Ÿ’พ Saved to: {os.path.basename(output_path)}") + yield output_path, gr.update(visible=True), gr.update(visible=False), "\n".join(extraction_logs), gr.update(visible=True), "Extraction complete!", 100 + else: + extraction_logs.append("โŒ Glossary extraction failed - output file not created") + yield None, None, gr.update(visible=False), "\n".join(extraction_logs), gr.update(visible=True), "Extraction failed", 0 + + except Exception as e: + import traceback + error_msg = f"โŒ Error during extraction:\n{str(e)}\n\n{traceback.format_exc()}" + extraction_logs.append(error_msg) + yield None, None, gr.update(visible=False), "\n".join(extraction_logs), gr.update(visible=True), "Error occurred", 0 + + def extract_glossary_with_stop(self, *args): + """Wrapper for extract_glossary that includes button visibility control""" + self.glossary_extraction_stop = False + + # Show stop button, hide extract button at start + for result in self.extract_glossary(*args): + if self.glossary_extraction_stop: + # Extraction was stopped + yield result[0], result[1], result[2], result[3] + "\n\nโš ๏ธ Extraction stopped by user", result[4], "Stopped", 0, gr.update(visible=True), gr.update(visible=False) + return + # Add button visibility updates to the yields + yield result[0], result[1], result[2], result[3], result[4], result[5], result[6], gr.update(visible=False), gr.update(visible=True) + + # Reset buttons at the end + yield result[0], result[1], result[2], result[3], result[4], result[5], result[6], gr.update(visible=True), gr.update(visible=False) + + def stop_glossary_extraction(self): + """Stop the ongoing glossary extraction""" + self.glossary_extraction_stop = True + if self.glossary_extraction_thread and self.glossary_extraction_thread.is_alive(): + # The thread will check the stop flag + pass + return gr.update(visible=True), gr.update(visible=False), "Extraction stopped" + + def run_qa_scan(self, folder_path, min_foreign_chars, check_repetition, + check_glossary_leakage, min_file_length, check_multiple_headers, + check_missing_html, check_insufficient_paragraphs, + min_paragraph_percentage, report_format, auto_save_report): + """Run Quick QA scan on output folder - yields progress updates""" + + # Handle both string paths and File objects + if hasattr(folder_path, 'name'): + # It's a File object from Gradio + folder_path = folder_path.name + + if not folder_path: + yield gr.update(visible=False), gr.update(value="### โŒ Error", visible=True), gr.update(visible=False), "โŒ Please provide a folder path or upload a ZIP file", gr.update(visible=False), "Error", 0 + return + + if isinstance(folder_path, str): + folder_path = folder_path.strip() + + if not os.path.exists(folder_path): + yield gr.update(visible=False), gr.update(value=f"### โŒ File/Folder not found", visible=True), gr.update(visible=False), f"โŒ File/Folder not found: {folder_path}", gr.update(visible=False), "Error", 0 + return + + # Initialize scan_logs early + scan_logs = [] + + # Check if it's a ZIP or EPUB file (for Hugging Face Spaces or convenience) + if os.path.isfile(folder_path) and (folder_path.lower().endswith('.zip') or folder_path.lower().endswith('.epub')): + # Extract ZIP/EPUB to temp folder + import zipfile + import tempfile + + temp_dir = tempfile.mkdtemp(prefix="qa_scan_") + + try: + file_type = "EPUB" if folder_path.lower().endswith('.epub') else "ZIP" + scan_logs.append(f"๐Ÿ“ฆ Extracting {file_type} file: {os.path.basename(folder_path)}") + + with zipfile.ZipFile(folder_path, 'r') as zip_ref: + # For EPUB files, look for the content folders + if file_type == "EPUB": + # EPUB files typically have OEBPS, EPUB, or similar content folders + all_files = zip_ref.namelist() + # Extract everything + zip_ref.extractall(temp_dir) + + # Try to find the content directory + content_dirs = ['OEBPS', 'EPUB', 'OPS', 'content'] + actual_content_dir = None + for dir_name in content_dirs: + potential_dir = os.path.join(temp_dir, dir_name) + if os.path.exists(potential_dir): + actual_content_dir = potential_dir + break + + # If no standard content dir found, use the temp_dir itself + if actual_content_dir: + folder_path = actual_content_dir + scan_logs.append(f"๐Ÿ“ Found EPUB content directory: {os.path.basename(actual_content_dir)}") + else: + folder_path = temp_dir + scan_logs.append(f"๐Ÿ“ Using extracted root directory") + else: + # Regular ZIP file + zip_ref.extractall(temp_dir) + folder_path = temp_dir + + scan_logs.append(f"โœ… Successfully extracted to temporary folder") + # Continue with normal processing, but include initial logs + # Note: we'll need to pass scan_logs through the rest of the function + + except Exception as e: + yield gr.update(visible=False), gr.update(value=f"### โŒ {file_type} extraction failed", visible=True), gr.update(visible=False), f"โŒ Failed to extract {file_type}: {str(e)}", gr.update(visible=False), "Error", 0 + return + elif not os.path.isdir(folder_path): + yield gr.update(visible=False), gr.update(value=f"### โŒ Not a folder, ZIP, or EPUB", visible=True), gr.update(visible=False), f"โŒ Path is not a folder, ZIP, or EPUB file: {folder_path}", gr.update(visible=False), "Error", 0 + return + + try: + scan_logs.append("๐Ÿ” Starting Quick QA Scan...") + scan_logs.append(f"๐Ÿ“ Scanning folder: {folder_path}") + yield gr.update(visible=False), gr.update(value="### Scanning...", visible=True), gr.update(visible=True), "\n".join(scan_logs), gr.update(visible=False), "Starting...", 0 + + # Find all HTML/XHTML files in the folder and subfolders + html_files = [] + for root, dirs, files in os.walk(folder_path): + for file in files: + if file.lower().endswith(('.html', '.xhtml', '.htm')): + html_files.append(os.path.join(root, file)) + + if not html_files: + scan_logs.append(f"โš ๏ธ No HTML/XHTML files found in {folder_path}") + yield gr.update(visible=False), gr.update(value="### โš ๏ธ No files found", visible=True), gr.update(visible=False), "\n".join(scan_logs), gr.update(visible=False), "No files to scan", 0 + return + + scan_logs.append(f"๐Ÿ“„ Found {len(html_files)} HTML/XHTML files to scan") + scan_logs.append("โšก Quick Scan Mode (85% threshold, Speed optimized)") + yield gr.update(visible=False), gr.update(value="### Initializing...", visible=True), gr.update(visible=True), "\n".join(scan_logs), gr.update(visible=False), "Initializing...", 10 + + # QA scanning process + total_files = len(html_files) + issues_found = [] + chapters_scanned = set() + + for i, file_path in enumerate(html_files): + if self.qa_scan_stop: + scan_logs.append("โš ๏ธ Scan stopped by user") + break + + # Get relative path from base folder for cleaner display + rel_path = os.path.relpath(file_path, folder_path) + file_name = rel_path.replace('\\', '/') + + # Quick scan optimization: skip if we've already scanned similar chapters + # (consecutive chapter checking) + chapter_match = None + for pattern in ['chapter', 'ch', 'c']: + if pattern in file_name.lower(): + import re + match = re.search(r'(\d+)', file_name) + if match: + chapter_num = int(match.group(1)) + # Skip if we've already scanned nearby chapters (Quick Scan optimization) + if any(abs(chapter_num - ch) <= 1 for ch in chapters_scanned): + if len(chapters_scanned) > 5: # Only skip after scanning a few + continue + chapters_scanned.add(chapter_num) + break + + scan_logs.append(f"\n๐Ÿ” Scanning: {file_name}") + progress = int(10 + (80 * i / total_files)) + yield None, None, gr.update(visible=True), "\n".join(scan_logs), gr.update(visible=True), f"Scanning {file_name}...", progress + + # Read and check the HTML file + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + file_issues = [] + + # Check file length + if len(content) < min_file_length: + continue # Skip short files + + # Check for foreign characters (simulation - would need actual implementation) + # In real implementation, would check for source language characters + import random + + # Check for multiple headers + if check_multiple_headers: + import re + headers = re.findall(r'<h[1-6][^>]*>', content, re.IGNORECASE) + if len(headers) >= 2: + file_issues.append("Multiple headers detected") + + # Check for missing html tag + if check_missing_html: + if '<html' not in content.lower(): + file_issues.append("Missing <html> tag") + + # Check for insufficient paragraphs + if check_insufficient_paragraphs: + p_tags = content.count('<p>') + content.count('<p ') + text_length = len(re.sub(r'<[^>]+>', '', content)) + if text_length > 0: + p_text = re.findall(r'<p[^>]*>(.*?)</p>', content, re.DOTALL) + p_text_length = sum(len(t) for t in p_text) + percentage = (p_text_length / text_length) * 100 + if percentage < min_paragraph_percentage: + file_issues.append(f"Only {percentage:.1f}% text in <p> tags") + + # Simulated additional checks + if check_repetition and random.random() > 0.85: + file_issues.append("Excessive repetition detected") + + if check_glossary_leakage and random.random() > 0.9: + file_issues.append("Glossary leakage detected") + + # Report issues found + if file_issues: + for issue in file_issues: + issues_found.append(f" โš ๏ธ {file_name}: {issue}") + scan_logs.append(f" โš ๏ธ Issue: {issue}") + else: + scan_logs.append(f" โœ… No issues found") + + except Exception as e: + scan_logs.append(f" โŒ Error reading file: {str(e)}") + + # Update logs periodically + if len(scan_logs) > 100: + scan_logs = scan_logs[-100:] # Keep only last 100 logs + + yield gr.update(visible=False), None, gr.update(visible=True), "\n".join(scan_logs), gr.update(visible=False), f"Scanning {file_name}...", progress + + # Generate report + scan_logs.append("\n๐Ÿ“ Generating report...") + yield gr.update(visible=False), None, gr.update(visible=True), "\n".join(scan_logs), gr.update(visible=False), "Generating report...", 95 + + # Create report content based on selected format + if report_format == "summary": + # Summary format - brief overview only + report_content = "QA SCAN REPORT - SUMMARY\n" + report_content += "=" * 50 + "\n\n" + report_content += f"Total files scanned: {total_files}\n" + report_content += f"Issues found: {len(issues_found)}\n\n" + if issues_found: + report_content += f"Files with issues: {min(len(issues_found), 10)} (showing first 10)\n" + report_content += "\n".join(issues_found[:10]) + else: + report_content += "โœ… No issues detected." + + elif report_format == "verbose": + # Verbose format - all data including passed files + report_content = "QA SCAN REPORT - VERBOSE (ALL DATA)\n" + report_content += "=" * 50 + "\n\n" + from datetime import datetime + report_content += f"Scan Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" + report_content += f"Folder Scanned: {folder_path}\n" + report_content += f"Total files scanned: {total_files}\n" + report_content += f"Issues found: {len(issues_found)}\n" + report_content += f"Settings used:\n" + report_content += f" - Min foreign chars: {min_foreign_chars}\n" + report_content += f" - Check repetition: {check_repetition}\n" + report_content += f" - Check glossary leakage: {check_glossary_leakage}\n" + report_content += f" - Min file length: {min_file_length}\n" + report_content += f" - Check multiple headers: {check_multiple_headers}\n" + report_content += f" - Check missing HTML: {check_missing_html}\n" + report_content += f" - Check insufficient paragraphs: {check_insufficient_paragraphs}\n" + report_content += f" - Min paragraph percentage: {min_paragraph_percentage}%\n\n" + + report_content += "ALL FILES PROCESSED:\n" + report_content += "-" * 30 + "\n" + for file in html_files: + rel_path = os.path.relpath(file, folder_path) + report_content += f" {rel_path}\n" + + if issues_found: + report_content += "\n\nISSUES DETECTED (DETAILED):\n" + report_content += "\n".join(issues_found) + else: + report_content += "\n\nโœ… No issues detected. All files passed scan." + + else: # detailed (default/recommended) + # Detailed format - recommended balance + report_content = "QA SCAN REPORT - DETAILED\n" + report_content += "=" * 50 + "\n\n" + report_content += f"Total files scanned: {total_files}\n" + report_content += f"Issues found: {len(issues_found)}\n\n" + + if issues_found: + report_content += "ISSUES DETECTED:\n" + report_content += "\n".join(issues_found) + else: + report_content += "No issues detected. All files passed quick scan." + + # Always save report to file for download + from datetime import datetime + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + report_filename = f"qa_scan_report_{timestamp}.txt" + report_path = os.path.join(os.getcwd(), report_filename) + + # Always write the report file + with open(report_path, 'w', encoding='utf-8') as f: + f.write(report_content) + + if auto_save_report: + scan_logs.append(f"๐Ÿ’พ Report auto-saved to: {report_filename}") + else: + scan_logs.append(f"๐Ÿ“„ Report ready for download: {report_filename}") + + scan_logs.append(f"\nโœ… QA Scan completed!") + scan_logs.append(f"๐Ÿ“Š Summary: {total_files} files scanned, {len(issues_found)} issues found") + scan_logs.append(f"\n๐Ÿ“ฅ Click 'Download QA Report' below to save the report") + + # Always return the report path and make File component visible + final_status = f"โœ… Scan complete!\n{total_files} files scanned\n{len(issues_found)} issues found" + yield gr.update(value=report_path, visible=True), gr.update(value=f"### {final_status}", visible=True), gr.update(visible=False), "\n".join(scan_logs), gr.update(value=final_status, visible=True), "Scan complete!", 100 + + except Exception as e: + import traceback + error_msg = f"โŒ Error during QA scan:\n{str(e)}\n\n{traceback.format_exc()}" + scan_logs.append(error_msg) + yield gr.update(visible=False), gr.update(value="### โŒ Error occurred", visible=True), gr.update(visible=False), "\n".join(scan_logs), gr.update(visible=True), "Error occurred", 0 + + def run_qa_scan_with_stop(self, *args): + """Wrapper for run_qa_scan that includes button visibility control""" + self.qa_scan_stop = False + + # Show stop button, hide scan button at start + for result in self.run_qa_scan(*args): + if self.qa_scan_stop: + # Scan was stopped + yield result[0], result[1], result[2], result[3] + "\n\nโš ๏ธ Scan stopped by user", result[4], "Stopped", 0, gr.update(visible=True), gr.update(visible=False) + return + # Add button visibility updates to the yields + yield result[0], result[1], result[2], result[3], result[4], result[5], result[6], gr.update(visible=False), gr.update(visible=True) + + # Reset buttons at the end + yield result[0], result[1], result[2], result[3], result[4], result[5], result[6], gr.update(visible=True), gr.update(visible=False) + + def stop_qa_scan(self): + """Stop the ongoing QA scan""" + self.qa_scan_stop = True + return gr.update(visible=True), gr.update(visible=False), "Scan stopped" + + def stop_translation(self): + """Stop the ongoing translation process""" + print(f"DEBUG: stop_translation called, was_translating={self.is_translating}") + if self.is_translating: + print("DEBUG: Setting stop flag and cancellation") + self.stop_flag.set() + self.is_translating = False + + # Best-effort: cancel any in-flight API operation on the active client + try: + if getattr(self, 'current_unified_client', None): + self.current_unified_client.cancel_current_operation() + print("DEBUG: Requested UnifiedClient cancellation") + except Exception as e: + print(f"DEBUG: UnifiedClient cancel failed: {e}") + + # Also propagate to MangaTranslator class if available + try: + if MANGA_TRANSLATION_AVAILABLE: + from manga_translator import MangaTranslator + MangaTranslator.set_global_cancellation(True) + print("DEBUG: Set MangaTranslator global cancellation") + except ImportError: + pass + + # Also propagate to UnifiedClient if available + try: + if MANGA_TRANSLATION_AVAILABLE: + from unified_api_client import UnifiedClient + UnifiedClient.set_global_cancellation(True) + print("DEBUG: Set UnifiedClient global cancellation") + except ImportError: + pass + + # Kick off translator shutdown to free resources quickly + try: + tr = getattr(self, 'current_translator', None) + if tr and hasattr(tr, 'shutdown'): + import threading as _th + _th.Thread(target=tr.shutdown, name="WebMangaTranslatorShutdown", daemon=True).start() + print("DEBUG: Initiated translator shutdown thread") + # Clear reference so a new start creates a fresh instance + self.current_translator = None + except Exception as e: + print(f"DEBUG: Failed to start translator shutdown: {e}") + else: + print("DEBUG: stop_translation called but not translating") + + def _reset_translation_flags(self): + """Reset all translation flags for new translation""" + self.is_translating = False + self.stop_flag.clear() + + # Reset global cancellation flags + try: + if MANGA_TRANSLATION_AVAILABLE: + from manga_translator import MangaTranslator + MangaTranslator.set_global_cancellation(False) + except ImportError: + pass + + try: + if MANGA_TRANSLATION_AVAILABLE: + from unified_api_client import UnifiedClient + UnifiedClient.set_global_cancellation(False) + except ImportError: + pass + + def translate_manga( + self, + image_files, + model, + api_key, + profile_name, + system_prompt, + ocr_provider, + google_creds_path, + azure_key, + azure_endpoint, + enable_bubble_detection, + enable_inpainting, + font_size_mode, + font_size, + font_multiplier, + min_font_size, + max_font_size, + text_color, + shadow_enabled, + shadow_color, + shadow_offset_x, + shadow_offset_y, + shadow_blur, + bg_opacity, + bg_style, + parallel_panel_translation=False, + panel_max_workers=10 + ): + """Translate manga images - GENERATOR that yields (logs, image, cbz_file, status, progress_group, progress_text, progress_bar) updates""" + + # Reset translation flags and set running state + self._reset_translation_flags() + self.is_translating = True + + if not MANGA_TRANSLATION_AVAILABLE: + self.is_translating = False + yield "โŒ Manga translation modules not loaded", None, None, gr.update(value="โŒ Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0) + return + + if not image_files: + self.is_translating = False + yield "โŒ Please upload at least one image", gr.update(visible=False), gr.update(visible=False), gr.update(value="โŒ Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0) + return + + if not api_key: + self.is_translating = False + yield "โŒ Please provide an API key", gr.update(visible=False), gr.update(visible=False), gr.update(value="โŒ Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0) + return + + # Check for stop request + if self.stop_flag.is_set(): + self.is_translating = False + yield "โน๏ธ Translation stopped by user", gr.update(visible=False), gr.update(visible=False), gr.update(value="โน๏ธ Stopped", visible=True), gr.update(visible=False), gr.update(value="Stopped"), gr.update(value=0) + return + + if ocr_provider == "google": + # Check if credentials are provided or saved in config + if not google_creds_path and not self.get_config_value('google_vision_credentials'): + yield "โŒ Please provide Google Cloud credentials JSON file", gr.update(visible=False), gr.update(visible=False), gr.update(value="โŒ Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0) + return + + if ocr_provider == "azure": + # Ensure azure credentials are strings + azure_key_str = str(azure_key) if azure_key else '' + azure_endpoint_str = str(azure_endpoint) if azure_endpoint else '' + if not azure_key_str.strip() or not azure_endpoint_str.strip(): + yield "โŒ Please provide Azure API key and endpoint", gr.update(visible=False), gr.update(visible=False), gr.update(value="โŒ Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0) + return + + try: + + # Set all environment variables from config + self.set_all_environment_variables() + + # Set API key environment variable + if 'gpt' in model.lower() or 'openai' in model.lower(): + os.environ['OPENAI_API_KEY'] = api_key + elif 'claude' in model.lower(): + os.environ['ANTHROPIC_API_KEY'] = api_key + elif 'gemini' in model.lower(): + os.environ['GOOGLE_API_KEY'] = api_key + + # Set Google Cloud credentials if provided and save to config + if ocr_provider == "google": + if google_creds_path: + # New file provided - save it + creds_path = google_creds_path.name if hasattr(google_creds_path, 'name') else google_creds_path + os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = creds_path + # Auto-save to config + self.config['google_vision_credentials'] = creds_path + self.save_config(self.config) + elif self.get_config_value('google_vision_credentials'): + # Use saved credentials from config + creds_path = self.get_config_value('google_vision_credentials') + if os.path.exists(creds_path): + os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = creds_path + else: + yield f"โŒ Saved Google credentials not found: {creds_path}", gr.update(visible=False), gr.update(visible=False), gr.update(value="โŒ Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0) + return + + # Set Azure credentials if provided and save to config + if ocr_provider == "azure": + # Convert to strings and strip whitespace + azure_key_str = str(azure_key).strip() if azure_key else '' + azure_endpoint_str = str(azure_endpoint).strip() if azure_endpoint else '' + + os.environ['AZURE_VISION_KEY'] = azure_key_str + os.environ['AZURE_VISION_ENDPOINT'] = azure_endpoint_str + # Auto-save to config + self.config['azure_vision_key'] = azure_key_str + self.config['azure_vision_endpoint'] = azure_endpoint_str + self.save_config(self.config) + + # Apply text visibility settings to config + # Convert hex color to RGB tuple + def hex_to_rgb(hex_color): + # Handle different color formats + if isinstance(hex_color, (list, tuple)): + # Already RGB format + return tuple(hex_color[:3]) + elif isinstance(hex_color, str): + # Remove any brackets or spaces if present + hex_color = hex_color.strip().strip('[]').strip() + if hex_color.startswith('#'): + # Hex format + hex_color = hex_color.lstrip('#') + if len(hex_color) == 6: + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + elif len(hex_color) == 3: + # Short hex format like #FFF + return tuple(int(hex_color[i]*2, 16) for i in range(3)) + elif ',' in hex_color: + # RGB string format like "255, 0, 0" + try: + parts = hex_color.split(',') + return tuple(int(p.strip()) for p in parts[:3]) + except: + pass + # Default to black if parsing fails + return (0, 0, 0) + + # Debug logging for color values + print(f"DEBUG: text_color type: {type(text_color)}, value: {text_color}") + print(f"DEBUG: shadow_color type: {type(shadow_color)}, value: {shadow_color}") + + try: + text_rgb = hex_to_rgb(text_color) + shadow_rgb = hex_to_rgb(shadow_color) + except Exception as e: + print(f"WARNING: Error converting colors: {e}") + print(f"WARNING: Using default colors - text: black, shadow: white") + text_rgb = (0, 0, 0) # Default to black text + shadow_rgb = (255, 255, 255) # Default to white shadow + + self.config['manga_font_size_mode'] = font_size_mode + self.config['manga_font_size'] = int(font_size) + self.config['manga_font_size_multiplier'] = float(font_multiplier) + self.config['manga_max_font_size'] = int(max_font_size) + self.config['manga_text_color'] = list(text_rgb) + self.config['manga_shadow_enabled'] = bool(shadow_enabled) + self.config['manga_shadow_color'] = list(shadow_rgb) + self.config['manga_shadow_offset_x'] = int(shadow_offset_x) + self.config['manga_shadow_offset_y'] = int(shadow_offset_y) + self.config['manga_shadow_blur'] = int(shadow_blur) + self.config['manga_bg_opacity'] = int(bg_opacity) + self.config['manga_bg_style'] = bg_style + + # Also update nested manga_settings structure + if 'manga_settings' not in self.config: + self.config['manga_settings'] = {} + if 'rendering' not in self.config['manga_settings']: + self.config['manga_settings']['rendering'] = {} + if 'font_sizing' not in self.config['manga_settings']: + self.config['manga_settings']['font_sizing'] = {} + + self.config['manga_settings']['rendering']['auto_min_size'] = int(min_font_size) + self.config['manga_settings']['font_sizing']['min_size'] = int(min_font_size) + self.config['manga_settings']['rendering']['auto_max_size'] = int(max_font_size) + self.config['manga_settings']['font_sizing']['max_size'] = int(max_font_size) + + # Prepare output directory + output_dir = tempfile.mkdtemp(prefix="manga_translated_") + translated_files = [] + cbz_mode = False + cbz_output_path = None + + # Initialize translation logs early (needed for CBZ processing) + translation_logs = [] + + # Check if any file is a CBZ/ZIP archive + import zipfile + files_to_process = image_files if isinstance(image_files, list) else [image_files] + extracted_images = [] + + for file in files_to_process: + file_path = file.name if hasattr(file, 'name') else file + if file_path.lower().endswith(('.cbz', '.zip')): + # Extract CBZ + cbz_mode = True + translation_logs.append(f"๐Ÿ“š Extracting CBZ: {os.path.basename(file_path)}") + extract_dir = tempfile.mkdtemp(prefix="cbz_extract_") + + try: + with zipfile.ZipFile(file_path, 'r') as zip_ref: + zip_ref.extractall(extract_dir) + + # Find all image files in extracted directory + import glob + for ext in ['*.png', '*.jpg', '*.jpeg', '*.webp', '*.bmp', '*.gif']: + extracted_images.extend(glob.glob(os.path.join(extract_dir, '**', ext), recursive=True)) + + # Sort naturally (by filename) + extracted_images.sort() + translation_logs.append(f"โœ… Extracted {len(extracted_images)} images from CBZ") + + # Prepare CBZ output path + cbz_output_path = os.path.join(output_dir, f"{os.path.splitext(os.path.basename(file_path))[0]}_translated.cbz") + except Exception as e: + translation_logs.append(f"โŒ Error extracting CBZ: {str(e)}") + else: + # Regular image file + extracted_images.append(file_path) + + # Use extracted images if CBZ was processed, otherwise use original files + if extracted_images: + # Create mock file objects for extracted images + class MockFile: + def __init__(self, path): + self.name = path + + files_to_process = [MockFile(img) for img in extracted_images] + + total_images = len(files_to_process) + + # Merge web app config with SimpleConfig for MangaTranslator + # This includes all the text visibility settings we just set + merged_config = self.config.copy() + + # Override with web-specific settings + merged_config['model'] = model + merged_config['active_profile'] = profile_name + + # Update manga_settings + if 'manga_settings' not in merged_config: + merged_config['manga_settings'] = {} + if 'ocr' not in merged_config['manga_settings']: + merged_config['manga_settings']['ocr'] = {} + if 'inpainting' not in merged_config['manga_settings']: + merged_config['manga_settings']['inpainting'] = {} + if 'advanced' not in merged_config['manga_settings']: + merged_config['manga_settings']['advanced'] = {} + + merged_config['manga_settings']['ocr']['provider'] = ocr_provider + merged_config['manga_settings']['ocr']['bubble_detection_enabled'] = enable_bubble_detection + merged_config['manga_settings']['inpainting']['method'] = 'local' if enable_inpainting else 'none' + # Make sure local_method is set from config (defaults to anime) + if 'local_method' not in merged_config['manga_settings']['inpainting']: + merged_config['manga_settings']['inpainting']['local_method'] = self.get_config_value('manga_settings', {}).get('inpainting', {}).get('local_method', 'anime') + + # Set parallel panel translation settings from config (Manga Settings tab) + # These are controlled in the Manga Settings tab, so reload config to get latest values + current_config = self.load_config() + if API_KEY_ENCRYPTION_AVAILABLE: + current_config = decrypt_config(current_config) + + config_parallel = current_config.get('manga_settings', {}).get('advanced', {}).get('parallel_panel_translation', False) + config_max_workers = current_config.get('manga_settings', {}).get('advanced', {}).get('panel_max_workers', 10) + + # Map web UI settings to MangaTranslator expected names + merged_config['manga_settings']['advanced']['parallel_panel_translation'] = config_parallel + merged_config['manga_settings']['advanced']['panel_max_workers'] = int(config_max_workers) + # CRITICAL: Also set the setting names that MangaTranslator actually checks + merged_config['manga_settings']['advanced']['parallel_processing'] = config_parallel + merged_config['manga_settings']['advanced']['max_workers'] = int(config_max_workers) + + # Log the parallel settings being used + print(f"๐Ÿ”ง Reloaded config - Using parallel panel translation: {config_parallel}") + print(f"๐Ÿ”ง Reloaded config - Using panel max workers: {config_max_workers}") + + # CRITICAL: Set skip_inpainting flag to False when inpainting is enabled + merged_config['manga_skip_inpainting'] = not enable_inpainting + + # Create a simple config object for MangaTranslator + class SimpleConfig: + def __init__(self, cfg): + self.config = cfg + + def get(self, key, default=None): + return self.config.get(key, default) + + # Create mock GUI object with necessary attributes + class MockGUI: + def __init__(self, config, profile_name, system_prompt, max_output_tokens, api_key, model): + self.config = config + # Add profile_var mock for MangaTranslator compatibility + class ProfileVar: + def __init__(self, profile): + self.profile = str(profile) if profile else '' + def get(self): + return self.profile + self.profile_var = ProfileVar(profile_name) + # Add prompt_profiles BOTH to config AND as attribute (manga_translator checks both) + if 'prompt_profiles' not in self.config: + self.config['prompt_profiles'] = {} + self.config['prompt_profiles'][profile_name] = system_prompt + # Also set as direct attribute for line 4653 check + self.prompt_profiles = self.config['prompt_profiles'] + # Add max_output_tokens as direct attribute (line 299 check) + self.max_output_tokens = max_output_tokens + # Add mock GUI attributes that MangaTranslator expects + class MockVar: + def __init__(self, val): + # Ensure val is properly typed + self.val = val + def get(self): + return self.val + # CRITICAL: delay_entry must read from api_call_delay (not 'delay') + self.delay_entry = MockVar(float(config.get('api_call_delay', 0.5))) + self.trans_temp = MockVar(float(config.get('translation_temperature', 0.3))) + self.contextual_var = MockVar(bool(config.get('contextual', False))) + self.trans_history = MockVar(int(config.get('translation_history_limit', 2))) + self.translation_history_rolling_var = MockVar(bool(config.get('translation_history_rolling', False))) + self.token_limit_disabled = bool(config.get('token_limit_disabled', False)) + # IMPORTANT: token_limit_entry must return STRING because manga_translator calls .strip() on it + self.token_limit_entry = MockVar(str(config.get('token_limit', 200000))) + # Batch translation settings + self.batch_translation_var = MockVar(bool(config.get('batch_translation', True))) + self.batch_size_var = MockVar(str(config.get('batch_size', '10'))) + # Add API key and model for custom-api OCR provider - ensure strings + self.api_key_entry = MockVar(str(api_key) if api_key else '') + self.model_var = MockVar(str(model) if model else '') + + simple_config = SimpleConfig(merged_config) + # Get max_output_tokens from config or use from web app config + web_max_tokens = merged_config.get('max_output_tokens', 16000) + mock_gui = MockGUI(simple_config.config, profile_name, system_prompt, web_max_tokens, api_key, model) + + # CRITICAL: Set SYSTEM_PROMPT environment variable for manga translation + os.environ['SYSTEM_PROMPT'] = system_prompt if system_prompt else '' + if system_prompt: + print(f"โœ… System prompt set ({len(system_prompt)} characters)") + else: + print("โš ๏ธ No system prompt provided") + + # CRITICAL: Set batch environment variables from mock_gui variables + os.environ['BATCH_TRANSLATION'] = '1' if mock_gui.batch_translation_var.get() else '0' + os.environ['BATCH_SIZE'] = str(mock_gui.batch_size_var.get()) + print(f"๐Ÿ“ฆ Set BATCH_TRANSLATION={os.environ['BATCH_TRANSLATION']}, BATCH_SIZE={os.environ['BATCH_SIZE']}") + + # Ensure model path is in config for local inpainting + if enable_inpainting: + local_method = merged_config.get('manga_settings', {}).get('inpainting', {}).get('local_method', 'anime') + # Set the model path key that MangaTranslator expects + model_path_key = f'manga_{local_method}_model_path' + if model_path_key not in merged_config: + # Use default model path or empty string + default_model_path = self.get_config_value(model_path_key, '') + merged_config[model_path_key] = default_model_path + print(f"Set {model_path_key} to: {default_model_path}") + + # CRITICAL: Explicitly set environment variables before creating UnifiedClient + api_call_delay = merged_config.get('api_call_delay', 0.5) + os.environ['SEND_INTERVAL_SECONDS'] = str(api_call_delay) + print(f"๐Ÿ”ง Manga translation: Set SEND_INTERVAL_SECONDS = {api_call_delay}s") + + # Set batch translation and batch size from MockGUI variables (after MockGUI is created) + # Will be set after mock_gui is created below + + # Also ensure font algorithm and auto fit style are in config for manga_translator + if 'manga_settings' not in merged_config: + merged_config['manga_settings'] = {} + if 'font_sizing' not in merged_config['manga_settings']: + merged_config['manga_settings']['font_sizing'] = {} + if 'rendering' not in merged_config['manga_settings']: + merged_config['manga_settings']['rendering'] = {} + + if 'algorithm' not in merged_config['manga_settings']['font_sizing']: + merged_config['manga_settings']['font_sizing']['algorithm'] = 'smart' + if 'auto_fit_style' not in merged_config['manga_settings']['rendering']: + merged_config['manga_settings']['rendering']['auto_fit_style'] = 'balanced' + + print(f"๐Ÿ“ฆ Batch: BATCH_TRANSLATION={os.environ.get('BATCH_TRANSLATION')}, BATCH_SIZE={os.environ.get('BATCH_SIZE')}") + print(f"๐ŸŽจ Font: algorithm={merged_config['manga_settings']['font_sizing']['algorithm']}, auto_fit_style={merged_config['manga_settings']['rendering']['auto_fit_style']}") + + # Setup OCR configuration + ocr_config = { + 'provider': ocr_provider + } + + if ocr_provider == 'google': + ocr_config['google_credentials_path'] = google_creds_path.name if google_creds_path else None + elif ocr_provider == 'azure': + # Use string versions + azure_key_str = str(azure_key).strip() if azure_key else '' + azure_endpoint_str = str(azure_endpoint).strip() if azure_endpoint else '' + ocr_config['azure_key'] = azure_key_str + ocr_config['azure_endpoint'] = azure_endpoint_str + + # Create UnifiedClient for translation API calls + try: + unified_client = UnifiedClient( + api_key=api_key, + model=model, + output_dir=output_dir + ) + # Store reference for stop() cancellation support + self.current_unified_client = unified_client + except Exception as e: + error_log = f"โŒ Failed to initialize API client: {str(e)}" + yield error_log, gr.update(visible=False), gr.update(visible=False), gr.update(value=error_log, visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0) + return + + # Log storage - will be yielded as live updates + last_yield_log_count = [0] # Track when we last yielded + last_yield_time = [0] # Track last yield time + + # Track current image being processed + current_image_idx = [0] + + import time + + def should_yield_logs(): + """Check if we should yield log updates (every 2 logs or 1 second)""" + current_time = time.time() + log_count_diff = len(translation_logs) - last_yield_log_count[0] + time_diff = current_time - last_yield_time[0] + + # Yield if 2+ new logs OR 1+ seconds passed + return log_count_diff >= 2 or time_diff >= 1.0 + + def capture_log(msg, level="info"): + """Capture logs - caller will yield periodically""" + if msg and msg.strip(): + log_msg = msg.strip() + translation_logs.append(log_msg) + + # Initialize timing + last_yield_time[0] = time.time() + + # Create MangaTranslator instance + try: + # Debug: Log inpainting config + inpaint_cfg = merged_config.get('manga_settings', {}).get('inpainting', {}) + print(f"\n=== INPAINTING CONFIG DEBUG ===") + print(f"Inpainting enabled checkbox: {enable_inpainting}") + print(f"Inpainting method: {inpaint_cfg.get('method')}") + print(f"Local method: {inpaint_cfg.get('local_method')}") + print(f"Full inpainting config: {inpaint_cfg}") + print("=== END DEBUG ===\n") + + translator = MangaTranslator( + ocr_config=ocr_config, + unified_client=unified_client, + main_gui=mock_gui, + log_callback=capture_log + ) + + # Keep a reference for stop/shutdown support + self.current_translator = translator + + # Connect stop flag so translator can react immediately to stop requests + if hasattr(translator, 'set_stop_flag'): + try: + translator.set_stop_flag(self.stop_flag) + except Exception: + pass + + # CRITICAL: Set skip_inpainting flag directly on translator instance + translator.skip_inpainting = not enable_inpainting + print(f"Set translator.skip_inpainting = {translator.skip_inpainting}") + + # Explicitly initialize local inpainting if enabled + if enable_inpainting: + print(f"๐ŸŽจ Initializing local inpainting...") + try: + # Force initialization of the inpainter + init_result = translator._initialize_local_inpainter() + if init_result: + print(f"โœ… Local inpainter initialized successfully") + else: + print(f"โš ๏ธ Local inpainter initialization returned False") + except Exception as init_error: + print(f"โŒ Failed to initialize inpainter: {init_error}") + import traceback + traceback.print_exc() + + except Exception as e: + import traceback + full_error = traceback.format_exc() + print(f"\n\n=== MANGA TRANSLATOR INIT ERROR ===") + print(full_error) + print(f"\nocr_config: {ocr_config}") + print(f"\nmock_gui.model_var.get(): {mock_gui.model_var.get()}") + print(f"\nmock_gui.api_key_entry.get(): {type(mock_gui.api_key_entry.get())}") + print("=== END ERROR ===") + error_log = f"โŒ Failed to initialize manga translator: {str(e)}\n\nCheck console for full traceback" + yield error_log, gr.update(visible=False), gr.update(visible=False), gr.update(value=error_log, visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0) + return + + # Process each image with real progress tracking + for idx, img_file in enumerate(files_to_process, 1): + try: + # Check for stop request before processing each image + if self.stop_flag.is_set(): + translation_logs.append(f"\nโน๏ธ Translation stopped by user before image {idx}/{total_images}") + self.is_translating = False + yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(value="โน๏ธ Translation stopped", visible=True), gr.update(visible=True), gr.update(value="Stopped"), gr.update(value=0) + return + + # Update current image index for log capture + current_image_idx[0] = idx + + # Calculate progress range for this image + start_progress = (idx - 1) / total_images + end_progress = idx / total_images + + input_path = img_file.name if hasattr(img_file, 'name') else img_file + output_path = os.path.join(output_dir, f"translated_{os.path.basename(input_path)}") + filename = os.path.basename(input_path) + + # Log start of processing and YIELD update + start_msg = f"๐ŸŽจ [{idx}/{total_images}] Starting: {filename}" + translation_logs.append(start_msg) + translation_logs.append(f"Image path: {input_path}") + translation_logs.append(f"Processing with OCR: {ocr_provider}, Model: {model}") + translation_logs.append("-" * 60) + + # Yield initial log update with progress + progress_percent = int(((idx - 1) / total_images) * 100) + status_text = f"Processing {idx}/{total_images}: {filename}" + last_yield_log_count[0] = len(translation_logs) + last_yield_time[0] = time.time() + yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value=status_text), gr.update(value=progress_percent) + + # Start processing in a thread so we can yield logs periodically + import threading + processing_complete = [False] + result_container = [None] + + def process_wrapper(): + result_container[0] = translator.process_image( + image_path=input_path, + output_path=output_path, + batch_index=idx, + batch_total=total_images + ) + processing_complete[0] = True + + # Start processing in background + process_thread = threading.Thread(target=process_wrapper, daemon=True) + process_thread.start() + + # Poll for log updates while processing + while not processing_complete[0]: + time.sleep(0.5) # Check every 0.5 seconds + + # Check for stop request during processing + if self.stop_flag.is_set(): + translation_logs.append(f"\nโน๏ธ Translation stopped by user while processing image {idx}/{total_images}") + self.is_translating = False + yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(value="โน๏ธ Translation stopped", visible=True), gr.update(visible=True), gr.update(value="Stopped"), gr.update(value=0) + return + + if should_yield_logs(): + progress_percent = int(((idx - 0.5) / total_images) * 100) # Mid-processing + status_text = f"Processing {idx}/{total_images}: {filename} (in progress...)" + last_yield_log_count[0] = len(translation_logs) + last_yield_time[0] = time.time() + yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value=status_text), gr.update(value=progress_percent) + + # Wait for thread to complete + process_thread.join(timeout=1) + result = result_container[0] + + if result.get('success'): + # Use the output path from the result + final_output = result.get('output_path', output_path) + if os.path.exists(final_output): + translated_files.append(final_output) + translation_logs.append(f"โœ… Image {idx}/{total_images} COMPLETE: {filename} | Total: {len(translated_files)}/{total_images} done") + translation_logs.append("") + # Yield progress update with all translated images so far + progress_percent = int((idx / total_images) * 100) + status_text = f"Completed {idx}/{total_images}: {filename}" + # Show all translated files as gallery + yield "\n".join(translation_logs), gr.update(value=translated_files, visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value=status_text), gr.update(value=progress_percent) + else: + translation_logs.append(f"โš ๏ธ Image {idx}/{total_images}: Output file missing for {filename}") + translation_logs.append(f"โš ๏ธ Warning: Output file not found for image {idx}") + translation_logs.append("") + # Yield progress update + progress_percent = int((idx / total_images) * 100) + status_text = f"Warning: {idx}/{total_images} - Output missing for {filename}" + yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value=status_text), gr.update(value=progress_percent) + else: + errors = result.get('errors', []) + error_msg = errors[0] if errors else 'Unknown error' + translation_logs.append(f"โŒ Image {idx}/{total_images} FAILED: {error_msg[:50]}") + translation_logs.append(f"โš ๏ธ Error on image {idx}: {error_msg}") + translation_logs.append("") + # Yield progress update + progress_percent = int((idx / total_images) * 100) + status_text = f"Failed: {idx}/{total_images} - {filename}" + yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value=status_text), gr.update(value=progress_percent) + + # If translation failed, save original with error overlay + from PIL import Image as PILImage, ImageDraw, ImageFont + img = PILImage.open(input_path) + draw = ImageDraw.Draw(img) + # Add error message + draw.text((10, 10), f"Translation Error: {error_msg[:50]}", fill="red") + img.save(output_path) + translated_files.append(output_path) + + except Exception as e: + import traceback + error_trace = traceback.format_exc() + translation_logs.append(f"โŒ Image {idx}/{total_images} ERROR: {str(e)[:60]}") + translation_logs.append(f"โŒ Exception on image {idx}: {str(e)}") + print(f"Manga translation error for {input_path}:\n{error_trace}") + + # Save original on error + try: + from PIL import Image as PILImage + img = PILImage.open(input_path) + img.save(output_path) + translated_files.append(output_path) + except: + pass + continue + + # Check for stop request before final processing + if self.stop_flag.is_set(): + translation_logs.append("\nโน๏ธ Translation stopped by user") + self.is_translating = False + yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(value="โน๏ธ Translation stopped", visible=True), gr.update(visible=True), gr.update(value="Stopped"), gr.update(value=0) + return + + # Add completion message + translation_logs.append("\n" + "="*60) + translation_logs.append(f"โœ… ALL COMPLETE! Successfully translated {len(translated_files)}/{total_images} images") + translation_logs.append("="*60) + + # If CBZ mode, compile translated images into CBZ archive + final_output_for_display = None + if cbz_mode and cbz_output_path and translated_files: + translation_logs.append("\n๐Ÿ“ฆ Compiling translated images into CBZ archive...") + try: + with zipfile.ZipFile(cbz_output_path, 'w', zipfile.ZIP_DEFLATED) as cbz: + for img_path in translated_files: + # Preserve original filename structure + arcname = os.path.basename(img_path).replace("translated_", "") + cbz.write(img_path, arcname) + + translation_logs.append(f"โœ… CBZ archive created: {os.path.basename(cbz_output_path)}") + translation_logs.append(f"๐Ÿ“ Archive location: {cbz_output_path}") + final_output_for_display = cbz_output_path + except Exception as e: + translation_logs.append(f"โŒ Error creating CBZ: {str(e)}") + + # Build final status with detailed panel information + final_status_lines = [] + if translated_files: + final_status_lines.append(f"โœ… Successfully translated {len(translated_files)}/{total_images} image(s)!") + final_status_lines.append("") + final_status_lines.append("๐Ÿ–ผ๏ธ **Translated Panels:**") + for i, file_path in enumerate(translated_files, 1): + filename = os.path.basename(file_path) + final_status_lines.append(f" {i}. {filename}") + + final_status_lines.append("") + final_status_lines.append("๐Ÿ”„ **Download Options:**") + if cbz_mode and cbz_output_path: + final_status_lines.append(f" ๐Ÿ“ฆ CBZ Archive: {os.path.basename(cbz_output_path)}") + final_status_lines.append(f" ๐Ÿ“ Location: {cbz_output_path}") + else: + # Create ZIP file for all images + zip_path = os.path.join(output_dir, "translated_images.zip") + try: + import zipfile + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + for img_path in translated_files: + arcname = os.path.basename(img_path) + zipf.write(img_path, arcname) + final_status_lines.append(f" ๐Ÿ“ฆ Download all images: translated_images.zip") + final_status_lines.append(f" ๐Ÿ“ Output directory: {output_dir}") + final_output_for_display = zip_path # Set this so it can be downloaded + except Exception as e: + final_status_lines.append(f" โŒ Failed to create ZIP: {str(e)}") + final_status_lines.append(f" ๐Ÿ“ Output directory: {output_dir}") + final_status_lines.append(" ๐Ÿ–ผ๏ธ Images saved individually in output directory") + else: + final_status_lines.append("โŒ Translation failed - no images were processed") + + final_status_text = "\n".join(final_status_lines) + + # Final yield with complete logs, image, CBZ, and final status + # Format: (logs_textbox, output_image, cbz_file, status_textbox, progress_group, progress_text, progress_bar) + final_progress_text = f"Complete! Processed {len(translated_files)}/{total_images} images" + if translated_files: + # Show all translated images in gallery + if cbz_mode and cbz_output_path and os.path.exists(cbz_output_path): + yield ( + "\n".join(translation_logs), + gr.update(value=translated_files, visible=True), # Show all images in gallery + gr.update(value=cbz_output_path, visible=True), # CBZ file for download with visibility + gr.update(value=final_status_text, visible=True), + gr.update(visible=True), + gr.update(value=final_progress_text), + gr.update(value=100) + ) + else: + # Show ZIP file for download if it was created + if final_output_for_display and os.path.exists(final_output_for_display): + yield ( + "\n".join(translation_logs), + gr.update(value=translated_files, visible=True), # Show all images in gallery + gr.update(value=final_output_for_display, visible=True), # ZIP file for download + gr.update(value=final_status_text, visible=True), + gr.update(visible=True), + gr.update(value=final_progress_text), + gr.update(value=100) + ) + else: + yield ( + "\n".join(translation_logs), + gr.update(value=translated_files, visible=True), # Show all images in gallery + gr.update(visible=False), # Hide download component if ZIP failed + gr.update(value=final_status_text, visible=True), + gr.update(visible=True), + gr.update(value=final_progress_text), + gr.update(value=100) + ) + else: + yield ( + "\n".join(translation_logs), + gr.update(visible=False), + gr.update(visible=False), # Hide CBZ component + gr.update(value=final_status_text, visible=True), + gr.update(visible=True), + gr.update(value=final_progress_text), + gr.update(value=0) # 0% if nothing was processed + ) + + except Exception as e: + import traceback + error_msg = f"โŒ Error during manga translation:\n{str(e)}\n\n{traceback.format_exc()}" + self.is_translating = False + yield error_msg, gr.update(visible=False), gr.update(visible=False), gr.update(value=error_msg, visible=True), gr.update(visible=False), gr.update(value="Error occurred"), gr.update(value=0) + finally: + # Always reset translation state when done + self.is_translating = False + # Clear active references on full completion + try: + self.current_translator = None + self.current_unified_client = None + except Exception: + pass + + def stop_manga_translation(self): + """Simple function to stop manga translation""" + print("DEBUG: Stop button clicked") + if self.is_translating: + print("DEBUG: Stopping active translation") + self.stop_translation() + # Return UI updates for button visibility and status + return ( + gr.update(visible=True), # translate button - show + gr.update(visible=False), # stop button - hide + "โน๏ธ Translation stopped by user" + ) + else: + print("DEBUG: No active translation to stop") + return ( + gr.update(visible=True), # translate button - show + gr.update(visible=False), # stop button - hide + "No active translation to stop" + ) + + def start_manga_translation(self, *args): + """Simple function to start manga translation - GENERATOR FUNCTION""" + print("DEBUG: Translate button clicked") + + # Reset flags for new translation and mark as translating BEFORE first yield + self._reset_translation_flags() + self.is_translating = True + + # Initial yield to update button visibility + yield ( + "๐Ÿš€ Starting translation...", + gr.update(visible=False), # manga_output_gallery - hide initially + gr.update(visible=False), # manga_cbz_output + gr.update(value="Starting...", visible=True), # manga_status + gr.update(visible=False), # manga_progress_group + gr.update(value="Initializing..."), # manga_progress_text + gr.update(value=0), # manga_progress_bar + gr.update(visible=False), # translate button - hide during translation + gr.update(visible=True) # stop button - show during translation + ) + + # Call the translate function and yield all its results + last_result = None + try: + for result in self.translate_manga(*args): + # Check if stop was requested during iteration + if self.stop_flag.is_set(): + print("DEBUG: Stop flag detected, breaking translation loop") + break + + last_result = result + # Pad result to include button states (translate_visible=False, stop_visible=True) + if len(result) >= 7: + yield result + (gr.update(visible=False), gr.update(visible=True)) + else: + # Pad result to match expected length (7 values) then add button states + padded_result = list(result) + [gr.update(visible=False)] * (7 - len(result)) + yield tuple(padded_result) + (gr.update(visible=False), gr.update(visible=True)) + + except GeneratorExit: + print("DEBUG: Translation generator was closed") + self.is_translating = False + return + except Exception as e: + print(f"DEBUG: Exception during translation: {e}") + self.is_translating = False + # Show error and reset buttons + error_msg = f"โŒ Error during translation: {str(e)}" + yield ( + error_msg, + gr.update(visible=False), + gr.update(visible=False), + gr.update(value=error_msg, visible=True), + gr.update(visible=False), + gr.update(value="Error occurred"), + gr.update(value=0), + gr.update(visible=True), # translate button - show after error + gr.update(visible=False) # stop button - hide after error + ) + return + finally: + # Clear active references when the loop exits + self.is_translating = False + try: + self.current_translator = None + self.current_unified_client = None + except Exception: + pass + + # Check if we stopped early + if self.stop_flag.is_set(): + yield ( + "โน๏ธ Translation stopped by user", + gr.update(visible=False), + gr.update(visible=False), + gr.update(value="โน๏ธ Translation stopped", visible=True), + gr.update(visible=False), + gr.update(value="Stopped"), + gr.update(value=0), + gr.update(visible=True), # translate button - show after stop + gr.update(visible=False) # stop button - hide after stop + ) + return + + # Final yield to reset buttons after successful completion + print("DEBUG: Translation completed normally, resetting buttons") + if last_result is None: + last_result = ("", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(value="Complete"), gr.update(value=100)) + + if len(last_result) >= 7: + yield last_result[:7] + (gr.update(visible=True), gr.update(visible=False)) + else: + # Pad result to match expected length then add button states + padded_result = list(last_result) + [gr.update(visible=False)] * (7 - len(last_result)) + yield tuple(padded_result) + (gr.update(visible=True), gr.update(visible=False)) + + def create_interface(self): + """Create and return the Gradio interface""" + # Reload config before creating interface to get latest values + self.config = self.load_config() + self.decrypted_config = decrypt_config(self.config.copy()) if API_KEY_ENCRYPTION_AVAILABLE else self.config.copy() + + # Load and encode icon as base64 + icon_base64 = "" + icon_path = "Halgakos.ico" if os.path.exists("Halgakos.ico") else "Halgakos.ico" + if os.path.exists(icon_path): + with open(icon_path, "rb") as f: + icon_base64 = base64.b64encode(f.read()).decode() + + # Custom CSS to hide Gradio footer and add favicon + custom_css = """ + footer {display: none !important;} + .gradio-container {min-height: 100vh;} + + /* Stop button styling */ + .gr-button[data-variant="stop"] { + background-color: #dc3545 !important; + border-color: #dc3545 !important; + color: white !important; + } + .gr-button[data-variant="stop"]:hover { + background-color: #c82333 !important; + border-color: #bd2130 !important; + color: white !important; + } + """ + + # JavaScript for localStorage persistence - SIMPLE VERSION + localStorage_js = """ + <script> + console.log('Glossarion localStorage script loading...'); + + // Simple localStorage functions + function saveToLocalStorage(key, value) { + try { + localStorage.setItem('glossarion_' + key, JSON.stringify(value)); + console.log('Saved:', key, '=', value); + return true; + } catch (e) { + console.error('Save failed:', e); + return false; + } + } + + function loadFromLocalStorage(key, defaultValue) { + try { + const item = localStorage.getItem('glossarion_' + key); + return item ? JSON.parse(item) : defaultValue; + } catch (e) { + console.error('Load failed:', e); + return defaultValue; + } + } + + // Manual save current form values to localStorage + function saveCurrentSettings() { + const settings = {}; + + // Find all input elements in Gradio + document.querySelectorAll('input, select, textarea').forEach(el => { + // Skip file inputs + if (el.type === 'file') return; + + // Get a unique key based on element properties + let key = el.id || el.name || el.placeholder || ''; + if (!key) { + // Try to get label text + const label = el.closest('div')?.querySelector('label'); + if (label) key = label.textContent; + } + + if (key) { + key = key.trim().replace(/[^a-zA-Z0-9]/g, '_'); + if (el.type === 'checkbox') { + settings[key] = el.checked; + } else if (el.type === 'radio') { + if (el.checked) settings[key] = el.value; + } else if (el.value) { + settings[key] = el.value; + } + } + }); + + // Save all settings + Object.keys(settings).forEach(key => { + saveToLocalStorage(key, settings[key]); + }); + + console.log('Saved', Object.keys(settings).length, 'settings'); + return settings; + } + + // Export settings from localStorage + function exportSettings() { + console.log('Export started'); + + // First save current form state + saveCurrentSettings(); + + // Then export from localStorage + const settings = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith('glossarion_')) { + try { + settings[key.replace('glossarion_', '')] = JSON.parse(localStorage.getItem(key)); + } catch (e) { + // Store as-is if not JSON + settings[key.replace('glossarion_', '')] = localStorage.getItem(key); + } + } + } + + if (Object.keys(settings).length === 0) { + alert('No settings to export. Try saving some settings first.'); + return; + } + + // Download as JSON + const blob = new Blob([JSON.stringify(settings, null, 2)], {type: 'application/json'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'glossarion_settings_' + new Date().toISOString().slice(0,19).replace(/:/g, '-') + '.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + console.log('Exported', Object.keys(settings).length, 'settings'); + } + + function importSettings(fileContent) { + try { + const settings = JSON.parse(fileContent); + Object.keys(settings).forEach(key => { + saveToLocalStorage(key, settings[key]); + }); + location.reload(); // Reload to apply settings + } catch (e) { + alert('Invalid settings file format'); + } + } + + // Expose to global scope + window.exportSettings = exportSettings; + window.importSettings = importSettings; + window.saveCurrentSettings = saveCurrentSettings; + window.saveToLocalStorage = saveToLocalStorage; + window.loadFromLocalStorage = loadFromLocalStorage; + + // Load settings from localStorage on page load for HF Spaces + function loadSettingsFromLocalStorage() { + console.log('Attempting to load settings from localStorage...'); + try { + // Get all localStorage items with glossarion_ prefix + const settings = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith('glossarion_')) { + const cleanKey = key.replace('glossarion_', ''); + try { + settings[cleanKey] = JSON.parse(localStorage.getItem(key)); + } catch (e) { + settings[cleanKey] = localStorage.getItem(key); + } + } + } + + if (Object.keys(settings).length > 0) { + console.log('Found', Object.keys(settings).length, 'settings in localStorage'); + + // Try to update Gradio components + // This is tricky because Gradio components are rendered dynamically + // We'll need to find them by their labels or other identifiers + + // For now, just log what we found + console.log('Settings:', settings); + } + } catch (e) { + console.error('Error loading from localStorage:', e); + } + } + + // Try loading settings at various points + window.addEventListener('load', function() { + console.log('Page loaded'); + setTimeout(loadSettingsFromLocalStorage, 1000); + setTimeout(loadSettingsFromLocalStorage, 3000); + }); + + document.addEventListener('DOMContentLoaded', function() { + console.log('DOM ready'); + setTimeout(loadSettingsFromLocalStorage, 500); + }); + </script> + """ + + with gr.Blocks( + title="Glossarion - AI Translation", + theme=gr.themes.Soft(), + css=custom_css + ) as app: + + # Add custom HTML with favicon link and title with icon + icon_img_tag = f'<img src="data:image/png;base64,{icon_base64}" alt="Glossarion">' if icon_base64 else '' + + gr.HTML(f""" + <link rel="icon" type="image/x-icon" href="file/Halgakos.ico"> + <link rel="shortcut icon" type="image/x-icon" href="file/Halgakos.ico"> + <style> + .title-with-icon {{ + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 10px; + }} + .title-with-icon img {{ + width: 48px; + height: 48px; + }} + </style> + <div class="title-with-icon"> + {icon_img_tag} + <h1>Glossarion - AI-Powered Translation</h1> + </div> + {localStorage_js} + """) + + with gr.Row(): + gr.Markdown(""" + Translate novels and books using advanced AI models (GPT-5, Claude, etc.) + """) + + + # SECURITY: Save Config button disabled for Hugging Face to prevent API key leakage + # Users should use localStorage (browser-based storage) instead + # with gr.Column(scale=0): + # save_config_btn = gr.Button( + # "๐Ÿ’พ Save Config", + # variant="secondary", + # size="sm" + # ) + # save_status_text = gr.Markdown( + # "", + # visible=False + # ) + + with gr.Tabs() as main_tabs: + # EPUB Translation Tab + with gr.Tab("๐Ÿ“š EPUB Translation"): + with gr.Row(): + with gr.Column(): + epub_file = gr.File( + label="๐Ÿ“– Upload EPUB or TXT File", + file_types=[".epub", ".txt"] + ) + + with gr.Row(): + translate_btn = gr.Button( + "๐Ÿš€ Translate EPUB", + variant="primary", + size="lg", + scale=2 + ) + + stop_epub_btn = gr.Button( + "โน๏ธ Stop Translation", + variant="stop", + size="lg", + visible=False, + scale=1 + ) + + epub_model = gr.Dropdown( + choices=self.models, + value=self.get_config_value('model', 'gpt-4-turbo'), + label="๐Ÿค– AI Model", + interactive=True, + allow_custom_value=True, + filterable=True + ) + + epub_api_key = gr.Textbox( + label="๐Ÿ”‘ API Key", + type="password", + placeholder="Enter your API key", + value=self.get_config_value('api_key', '') + ) + + # Use all profiles without filtering + profile_choices = list(self.profiles.keys()) + # Use saved active_profile instead of hardcoded default + default_profile = self.get_config_value('active_profile', profile_choices[0] if profile_choices else '') + + epub_profile = gr.Dropdown( + choices=profile_choices, + value=default_profile, + label="๐Ÿ“ Translation Profile" + ) + + epub_system_prompt = gr.Textbox( + label="System Prompt (Translation Instructions)", + lines=8, + max_lines=15, + interactive=True, + placeholder="Select a profile to load translation instructions...", + value=self.profiles.get(default_profile, '') if default_profile else '' + ) + + with gr.Accordion("โš™๏ธ Advanced Settings", open=False): + epub_temperature = gr.Slider( + minimum=0, + maximum=1, + value=self.get_config_value('temperature', 0.3), + step=0.1, + label="Temperature" + ) + + epub_max_tokens = gr.Number( + label="Max Output Tokens", + value=self.get_config_value('max_output_tokens', 16000), + minimum=0 + ) + + gr.Markdown("### Image Translation") + + enable_image_translation = gr.Checkbox( + label="Enable Image Translation", + value=self.get_config_value('enable_image_translation', False), + info="Extracts and translates text from images using vision models" + ) + + gr.Markdown("### Glossary Settings") + + enable_auto_glossary = gr.Checkbox( + label="Enable Automatic Glossary Generation", + value=self.get_config_value('enable_auto_glossary', False), + info="Automatic extraction and translation of character names/terms" + ) + + append_glossary = gr.Checkbox( + label="Append Glossary to System Prompt", + value=self.get_config_value('append_glossary_to_prompt', True), + info="Applies to ALL glossaries - manual and automatic" + ) + + # Automatic glossary extraction settings (only show when enabled) + with gr.Group(visible=self.get_config_value('enable_auto_glossary', False)) as auto_glossary_settings: + gr.Markdown("#### Automatic Glossary Extraction Settings") + + with gr.Row(): + auto_glossary_min_freq = gr.Slider( + minimum=1, + maximum=10, + value=self.get_config_value('glossary_min_frequency', 2), + step=1, + label="Min Frequency", + info="Minimum times a name must appear" + ) + + auto_glossary_max_names = gr.Slider( + minimum=10, + maximum=200, + value=self.get_config_value('glossary_max_names', 50), + step=10, + label="Max Names", + info="Maximum number of character names" + ) + + with gr.Row(): + auto_glossary_max_titles = gr.Slider( + minimum=10, + maximum=100, + value=self.get_config_value('glossary_max_titles', 30), + step=5, + label="Max Titles", + info="Maximum number of titles/terms" + ) + + auto_glossary_batch_size = gr.Slider( + minimum=10, + maximum=100, + value=self.get_config_value('glossary_batch_size', 50), + step=5, + label="Translation Batch Size", + info="Terms per API call" + ) + + auto_glossary_filter_mode = gr.Radio( + choices=[ + ("All names & terms", "all"), + ("Names with honorifics only", "only_with_honorifics"), + ("Names without honorifics & terms", "only_without_honorifics") + ], + value=self.get_config_value('glossary_filter_mode', 'all'), + label="Filter Mode", + info="What types of names to extract" + ) + + auto_glossary_fuzzy_threshold = gr.Slider( + minimum=0.5, + maximum=1.0, + value=self.get_config_value('glossary_fuzzy_threshold', 0.90), + step=0.05, + label="Fuzzy Matching Threshold", + info="How similar names must be to match (0.9 = 90% match)" + ) + + # Toggle visibility of auto glossary settings + enable_auto_glossary.change( + fn=lambda x: gr.update(visible=x), + inputs=[enable_auto_glossary], + outputs=[auto_glossary_settings] + ) + + gr.Markdown("### Quality Assurance") + + enable_post_translation_scan = gr.Checkbox( + label="Enable post-translation Scanning phase", + value=self.get_config_value('enable_post_translation_scan', False), + info="Automatically run QA Scanner after translation completes" + ) + + glossary_file = gr.File( + label="๐Ÿ“‹ Manual Glossary CSV (optional)", + file_types=[".csv", ".json", ".txt"] + ) + + with gr.Column(): + # Add logo and status at top + with gr.Row(): + gr.Image( + value="Halgakos.png", + label=None, + show_label=False, + width=80, + height=80, + interactive=False, + show_download_button=False, + container=False + ) + epub_status_message = gr.Markdown( + value="### Ready to translate\nUpload an EPUB or TXT file and click 'Translate' to begin.", + visible=True + ) + + # Progress section (similar to manga tab) + with gr.Group(visible=False) as epub_progress_group: + gr.Markdown("### Progress") + epub_progress_text = gr.Textbox( + label="๐Ÿ“จ Current Status", + value="Ready to start", + interactive=False, + lines=1 + ) + epub_progress_bar = gr.Slider( + minimum=0, + maximum=100, + value=0, + step=1, + label="๐Ÿ“‹ Translation Progress", + interactive=False, + show_label=True + ) + + epub_logs = gr.Textbox( + label="๐Ÿ“‹ Translation Logs", + lines=20, + max_lines=30, + value="Ready to translate. Upload an EPUB or TXT file and configure settings.", + visible=True, + interactive=False + ) + + epub_output = gr.File( + label="๐Ÿ“ฅ Download Translated File", + visible=True # Always visible, will show file when ready + ) + + epub_status = gr.Textbox( + label="Final Status", + lines=3, + max_lines=5, + visible=False, + interactive=False + ) + + # Sync handlers will be connected after manga components are created + + # Translation button handler - now with progress outputs + translate_btn.click( + fn=self.translate_epub_with_stop, + inputs=[ + epub_file, + epub_model, + epub_api_key, + epub_profile, + epub_system_prompt, + epub_temperature, + epub_max_tokens, + enable_image_translation, + glossary_file + ], + outputs=[ + epub_output, # Download file + epub_status_message, # Top status message + epub_progress_group, # Progress group visibility + epub_logs, # Translation logs + epub_status, # Final status + epub_progress_text, # Progress text + epub_progress_bar, # Progress bar + translate_btn, # Show/hide translate button + stop_epub_btn # Show/hide stop button + ] + ) + + # Stop button handler + stop_epub_btn.click( + fn=self.stop_epub_translation, + inputs=[], + outputs=[translate_btn, stop_epub_btn, epub_status] + ) + + # Manga Translation Tab + with gr.Tab("๐ŸŽจ Manga Translation"): + with gr.Row(): + with gr.Column(): + manga_images = gr.File( + label="๐Ÿ–ผ๏ธ Upload Manga Images or CBZ", + file_types=[".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".cbz", ".zip"], + file_count="multiple" + ) + + with gr.Row(): + translate_manga_btn = gr.Button( + "๐Ÿš€ Translate Manga", + variant="primary", + size="lg", + scale=2 + ) + + stop_manga_btn = gr.Button( + "โน๏ธ Stop Translation", + variant="stop", + size="lg", + visible=False, + scale=1 + ) + + manga_model = gr.Dropdown( + choices=self.models, + value=self.get_config_value('model', 'gpt-4-turbo'), + label="๐Ÿค– AI Model", + interactive=True, + allow_custom_value=True, + filterable=True + ) + + manga_api_key = gr.Textbox( + label="๐Ÿ”‘ API Key", + type="password", + placeholder="Enter your API key", + value=self.get_config_value('api_key', '') # Pre-fill from config + ) + + # Use all profiles without filtering + profile_choices = list(self.profiles.keys()) + # Use the active profile from config, same as EPUB tab + default_profile = self.get_config_value('active_profile', profile_choices[0] if profile_choices else '') + + manga_profile = gr.Dropdown( + choices=profile_choices, + value=default_profile, + label="๐Ÿ“ Translation Profile" + ) + + # Editable manga system prompt + manga_system_prompt = gr.Textbox( + label="Manga System Prompt (Translation Instructions)", + lines=8, + max_lines=15, + interactive=True, + placeholder="Select a manga profile to load translation instructions...", + value=self.profiles.get(default_profile, '') if default_profile else '' + ) + + with gr.Accordion("โš™๏ธ OCR Settings", open=False): + gr.Markdown("๐Ÿ”’ **Credentials are auto-saved** to your config (encrypted) after first use.") + + ocr_provider = gr.Radio( + choices=["google", "azure", "custom-api"], + value=self.get_config_value('ocr_provider', 'custom-api'), + label="OCR Provider" + ) + + # Show saved Google credentials path if available + saved_google_path = self.get_config_value('google_vision_credentials', '') + if saved_google_path and os.path.exists(saved_google_path): + gr.Markdown(f"โœ… **Saved credentials found:** `{os.path.basename(saved_google_path)}`") + gr.Markdown("๐Ÿ’ก *Using saved credentials. Upload a new file only if you want to change them.*") + else: + gr.Markdown("โš ๏ธ No saved Google credentials found. Please upload your JSON file.") + + # Note: File component doesn't support pre-filling paths due to browser security + google_creds = gr.File( + label="Google Cloud Credentials JSON (upload to update)", + file_types=[".json"] + ) + + azure_key = gr.Textbox( + label="Azure Vision API Key (if using Azure)", + type="password", + placeholder="Enter Azure API key", + value=self.get_config_value('azure_vision_key', '') + ) + + azure_endpoint = gr.Textbox( + label="Azure Vision Endpoint (if using Azure)", + placeholder="https://your-resource.cognitiveservices.azure.com/", + value=self.get_config_value('azure_vision_endpoint', '') + ) + + bubble_detection = gr.Checkbox( + label="Enable Bubble Detection", + value=self.get_config_value('bubble_detection_enabled', True) + ) + + inpainting = gr.Checkbox( + label="Enable Text Removal (Inpainting)", + value=self.get_config_value('inpainting_enabled', True) + ) + + with gr.Accordion("โšก Parallel Processing", open=False): + gr.Markdown("### Parallel Panel Translation") + gr.Markdown("*Process multiple panels simultaneously for faster translation*") + + # Check environment variables first, then config + parallel_enabled = os.getenv('PARALLEL_PANEL_TRANSLATION', '').lower() == 'true' + if not parallel_enabled: + # Fall back to config if not set in env + parallel_enabled = self.get_config_value('manga_settings', {}).get('advanced', {}).get('parallel_panel_translation', False) + + # Get max workers from env or config + max_workers_env = os.getenv('PANEL_MAX_WORKERS', '') + if max_workers_env.isdigit(): + max_workers = int(max_workers_env) + else: + max_workers = self.get_config_value('manga_settings', {}).get('advanced', {}).get('panel_max_workers', 7) + + parallel_panel_translation = gr.Checkbox( + label="Enable Parallel Panel Translation", + value=parallel_enabled, + info="Translates multiple panels at once instead of sequentially" + ) + + panel_max_workers = gr.Slider( + minimum=1, + maximum=20, + value=max_workers, + step=1, + label="Max concurrent panels", + interactive=True, + info="Number of panels to process simultaneously (higher = faster but more memory)" + ) + + with gr.Accordion("โœจ Text Visibility Settings", open=False): + gr.Markdown("### Font Settings") + + font_size_mode = gr.Radio( + choices=["auto", "fixed", "multiplier"], + value=self.get_config_value('manga_font_size_mode', 'auto'), + label="Font Size Mode" + ) + + font_size = gr.Slider( + minimum=0, + maximum=72, + value=self.get_config_value('manga_font_size', 24), + step=1, + label="Fixed Font Size (0=auto, used when mode=fixed)" + ) + + font_multiplier = gr.Slider( + minimum=0.5, + maximum=2.0, + value=self.get_config_value('manga_font_size_multiplier', 1.0), + step=0.1, + label="Font Size Multiplier (when mode=multiplier)" + ) + + min_font_size = gr.Slider( + minimum=0, + maximum=100, + value=self.get_config_value('manga_settings', {}).get('rendering', {}).get('auto_min_size', 12), + step=1, + label="Minimum Font Size (0=no limit)" + ) + + max_font_size = gr.Slider( + minimum=20, + maximum=100, + value=self.get_config_value('manga_max_font_size', 48), + step=1, + label="Maximum Font Size" + ) + + gr.Markdown("### Text Color") + + # Convert RGB array to hex if needed + def to_hex_color(color_value, default='#000000'): + if isinstance(color_value, (list, tuple)) and len(color_value) >= 3: + return '#{:02x}{:02x}{:02x}'.format(int(color_value[0]), int(color_value[1]), int(color_value[2])) + elif isinstance(color_value, str): + return color_value if color_value.startswith('#') else default + return default + + text_color_rgb = gr.ColorPicker( + label="Font Color", + value=to_hex_color(self.get_config_value('manga_text_color', [255, 255, 255]), '#FFFFFF') # Default white + ) + + gr.Markdown("### Shadow Settings") + + shadow_enabled = gr.Checkbox( + label="Enable Text Shadow", + value=self.get_config_value('manga_shadow_enabled', True) + ) + + shadow_color = gr.ColorPicker( + label="Shadow Color", + value=to_hex_color(self.get_config_value('manga_shadow_color', [0, 0, 0]), '#000000') # Default black + ) + + shadow_offset_x = gr.Slider( + minimum=-10, + maximum=10, + value=self.get_config_value('manga_shadow_offset_x', 2), + step=1, + label="Shadow Offset X" + ) + + shadow_offset_y = gr.Slider( + minimum=-10, + maximum=10, + value=self.get_config_value('manga_shadow_offset_y', 2), + step=1, + label="Shadow Offset Y" + ) + + shadow_blur = gr.Slider( + minimum=0, + maximum=10, + value=self.get_config_value('manga_shadow_blur', 0), + step=1, + label="Shadow Blur" + ) + + gr.Markdown("### Background Settings") + + bg_opacity = gr.Slider( + minimum=0, + maximum=255, + value=self.get_config_value('manga_bg_opacity', 130), + step=1, + label="Background Opacity" + ) + + # Ensure bg_style value is valid + bg_style_value = self.get_config_value('manga_bg_style', 'circle') + if bg_style_value not in ["box", "circle", "wrap"]: + bg_style_value = 'circle' # Default fallback + + bg_style = gr.Radio( + choices=["box", "circle", "wrap"], + value=bg_style_value, + label="Background Style" + ) + + with gr.Column(): + # Add logo and loading message at top + with gr.Row(): + gr.Image( + value="Halgakos.png", + label=None, + show_label=False, + width=80, + height=80, + interactive=False, + show_download_button=False, + container=False + ) + status_message = gr.Markdown( + value="### Ready to translate\nUpload an image and click 'Translate Manga' to begin.", + visible=True + ) + + # Progress section for manga translation (similar to manga integration script) + with gr.Group(visible=False) as manga_progress_group: + gr.Markdown("### Progress") + manga_progress_text = gr.Textbox( + label="๐Ÿ“ˆ Current Status", + value="Ready to start", + interactive=False, + lines=1 + ) + manga_progress_bar = gr.Slider( + minimum=0, + maximum=100, + value=0, + step=1, + label="๐Ÿ“‹ Translation Progress", + interactive=False, + show_label=True + ) + + manga_logs = gr.Textbox( + label="๐Ÿ“‹ Translation Logs", + lines=20, + max_lines=30, + value="Ready to translate. Click 'Translate Manga' to begin.", + visible=True, + interactive=False + ) + + # Use Gallery to show all translated images + manga_output_gallery = gr.Gallery( + label="๐Ÿ“ท Translated Images (click to download)", + visible=False, + show_label=True, + elem_id="manga_output_gallery", + columns=3, + rows=2, + height="auto", + allow_preview=True, + show_download_button=True # Allow download of individual images + ) + # Keep CBZ output for bulk download + manga_cbz_output = gr.File(label="๐Ÿ“ฆ Download Translated CBZ", visible=False) + manga_status = gr.Textbox( + label="Final Status", + lines=8, + max_lines=15, + visible=False + ) + + # Global sync flag to prevent loops + self._syncing_active = False + + # Auto-save Azure credentials on change + def save_azure_credentials(key, endpoint): + """Save Azure credentials to config""" + try: + current_config = self.get_current_config_for_update() + # Don't decrypt - just update what we need + if key and key.strip(): + current_config['azure_vision_key'] = str(key).strip() + if endpoint and endpoint.strip(): + current_config['azure_vision_endpoint'] = str(endpoint).strip() + self.save_config(current_config) + return None + except Exception as e: + print(f"Failed to save Azure credentials: {e}") + return None + + # All auto-save handlers removed - use manual Save Config button to avoid constant writes to persistent storage + + # Only update system prompts when profiles change - no cross-tab syncing + epub_profile.change( + fn=lambda p: self.profiles.get(p, ''), + inputs=[epub_profile], + outputs=[epub_system_prompt] + ) + + manga_profile.change( + fn=lambda p: self.profiles.get(p, ''), + inputs=[manga_profile], + outputs=[manga_system_prompt] + ) + + # Manual save function for all configuration + def save_all_config( + model, api_key, profile, temperature, max_tokens, + enable_image_trans, enable_auto_gloss, append_gloss, + # Auto glossary settings + auto_gloss_min_freq, auto_gloss_max_names, auto_gloss_max_titles, + auto_gloss_batch_size, auto_gloss_filter_mode, auto_gloss_fuzzy, + enable_post_scan, + # Manual glossary extraction settings + manual_min_freq, manual_max_names, manual_max_titles, + manual_max_text_size, manual_max_sentences, manual_trans_batch, + manual_chapter_split, manual_filter_mode, manual_strip_honorifics, + manual_fuzzy, manual_extraction_prompt, manual_format_instructions, + manual_use_legacy_csv, + # QA Scanner settings + qa_min_foreign, qa_check_rep, qa_check_gloss_leak, + qa_min_file_len, qa_check_headers, qa_check_html, + qa_check_paragraphs, qa_min_para_percent, qa_report_fmt, qa_auto_save, + # Chapter processing options + batch_trans_headers, headers_batch, ncx_nav, attach_css, retain_ext, + conservative_batch, gemini_safety, http_openrouter, openrouter_compress, + extraction_method, filter_level, + # Thinking mode settings + gpt_thinking_enabled, gpt_effort, or_tokens, + gemini_thinking_enabled, gemini_budget, + manga_model, manga_api_key, manga_profile, + ocr_prov, azure_k, azure_e, + bubble_det, inpaint, + font_mode, font_s, font_mult, min_font, max_font, + text_col, shadow_en, shadow_col, + shadow_x, shadow_y, shadow_b, + bg_op, bg_st, + parallel_trans, panel_workers, + # Advanced Settings fields + detector_type_val, rtdetr_conf, bubble_conf, + detect_text, detect_empty, detect_free, max_detections, + local_method_val, webtoon_val, + batch_size_val, cache_enabled_val, + parallel_proc, max_work, + preload_local, stagger_ms, + torch_prec, auto_cleanup, + debug, save_inter, concise_logs + ): + """Save all configuration values at once""" + try: + config = self.get_current_config_for_update() + + # Save all values + config['model'] = model + if api_key: # Only save non-empty API keys + config['api_key'] = api_key + config['active_profile'] = profile + config['temperature'] = temperature + config['max_output_tokens'] = max_tokens + config['enable_image_translation'] = enable_image_trans + config['enable_auto_glossary'] = enable_auto_gloss + config['append_glossary_to_prompt'] = append_gloss + + # Auto glossary settings + config['glossary_min_frequency'] = auto_gloss_min_freq + config['glossary_max_names'] = auto_gloss_max_names + config['glossary_max_titles'] = auto_gloss_max_titles + config['glossary_batch_size'] = auto_gloss_batch_size + config['glossary_filter_mode'] = auto_gloss_filter_mode + config['glossary_fuzzy_threshold'] = auto_gloss_fuzzy + + # Manual glossary extraction settings + config['manual_glossary_min_frequency'] = manual_min_freq + config['manual_glossary_max_names'] = manual_max_names + config['manual_glossary_max_titles'] = manual_max_titles + config['glossary_max_text_size'] = manual_max_text_size + config['glossary_max_sentences'] = manual_max_sentences + config['manual_glossary_batch_size'] = manual_trans_batch + config['glossary_chapter_split_threshold'] = manual_chapter_split + config['manual_glossary_filter_mode'] = manual_filter_mode + config['strip_honorifics'] = manual_strip_honorifics + config['manual_glossary_fuzzy_threshold'] = manual_fuzzy + config['manual_glossary_prompt'] = manual_extraction_prompt + config['glossary_format_instructions'] = manual_format_instructions + config['glossary_use_legacy_csv'] = manual_use_legacy_csv + config['enable_post_translation_scan'] = enable_post_scan + + # QA Scanner settings + config['qa_min_foreign_chars'] = qa_min_foreign + config['qa_check_repetition'] = qa_check_rep + config['qa_check_glossary_leakage'] = qa_check_gloss_leak + config['qa_min_file_length'] = qa_min_file_len + config['qa_check_multiple_headers'] = qa_check_headers + config['qa_check_missing_html'] = qa_check_html + config['qa_check_insufficient_paragraphs'] = qa_check_paragraphs + config['qa_min_paragraph_percentage'] = qa_min_para_percent + config['qa_report_format'] = qa_report_fmt + config['qa_auto_save_report'] = qa_auto_save + + # Chapter processing options + config['batch_translate_headers'] = batch_trans_headers + config['headers_per_batch'] = headers_batch + config['use_ncx_navigation'] = ncx_nav + config['attach_css_to_chapters'] = attach_css + config['retain_source_extension'] = retain_ext + config['use_conservative_batching'] = conservative_batch + config['disable_gemini_safety'] = gemini_safety + config['use_http_openrouter'] = http_openrouter + config['disable_openrouter_compression'] = openrouter_compress + config['text_extraction_method'] = extraction_method + config['file_filtering_level'] = filter_level + + # Thinking mode settings + config['enable_gpt_thinking'] = gpt_thinking_enabled + config['gpt_thinking_effort'] = gpt_effort + config['or_thinking_tokens'] = or_tokens + config['enable_gemini_thinking'] = gemini_thinking_enabled + config['gemini_thinking_budget'] = gemini_budget + + # Manga settings + config['ocr_provider'] = ocr_prov + if azure_k: + config['azure_vision_key'] = azure_k + if azure_e: + config['azure_vision_endpoint'] = azure_e + config['bubble_detection_enabled'] = bubble_det + config['inpainting_enabled'] = inpaint + config['manga_font_size_mode'] = font_mode + config['manga_font_size'] = font_s + config['manga_font_multiplier'] = font_mult + config['manga_min_font_size'] = min_font + config['manga_max_font_size'] = max_font + config['manga_text_color'] = text_col + config['manga_shadow_enabled'] = shadow_en + config['manga_shadow_color'] = shadow_col + config['manga_shadow_offset_x'] = shadow_x + config['manga_shadow_offset_y'] = shadow_y + config['manga_shadow_blur'] = shadow_b + config['manga_bg_opacity'] = bg_op + config['manga_bg_style'] = bg_st + + # Advanced settings + if 'manga_settings' not in config: + config['manga_settings'] = {} + if 'advanced' not in config['manga_settings']: + config['manga_settings']['advanced'] = {} + config['manga_settings']['advanced']['parallel_panel_translation'] = parallel_trans + config['manga_settings']['advanced']['panel_max_workers'] = panel_workers + + # Advanced bubble detection and inpainting settings + if 'ocr' not in config['manga_settings']: + config['manga_settings']['ocr'] = {} + if 'inpainting' not in config['manga_settings']: + config['manga_settings']['inpainting'] = {} + + config['manga_settings']['ocr']['detector_type'] = detector_type_val + config['manga_settings']['ocr']['rtdetr_confidence'] = rtdetr_conf + config['manga_settings']['ocr']['bubble_confidence'] = bubble_conf + config['manga_settings']['ocr']['detect_text_bubbles'] = detect_text + config['manga_settings']['ocr']['detect_empty_bubbles'] = detect_empty + config['manga_settings']['ocr']['detect_free_text'] = detect_free + config['manga_settings']['ocr']['bubble_max_detections_yolo'] = max_detections + config['manga_settings']['inpainting']['local_method'] = local_method_val + config['manga_settings']['advanced']['webtoon_mode'] = webtoon_val + config['manga_settings']['inpainting']['batch_size'] = batch_size_val + config['manga_settings']['inpainting']['enable_cache'] = cache_enabled_val + config['manga_settings']['advanced']['parallel_processing'] = parallel_proc + config['manga_settings']['advanced']['max_workers'] = max_work + config['manga_settings']['advanced']['preload_local_inpainting_for_panels'] = preload_local + config['manga_settings']['advanced']['panel_start_stagger_ms'] = stagger_ms + config['manga_settings']['advanced']['torch_precision'] = torch_prec + config['manga_settings']['advanced']['auto_cleanup_models'] = auto_cleanup + config['manga_settings']['advanced']['debug_mode'] = debug + config['manga_settings']['advanced']['save_intermediate'] = save_inter + config['concise_pipeline_logs'] = concise_logs + + # Save to file + result = self.save_config(config) + + # Show success message for 3 seconds + return gr.update(value=result, visible=True) + + except Exception as e: + return gr.update(value=f"โŒ Save failed: {str(e)}", visible=True) + + # Save button will be configured after all components are created + + # Auto-hide status message after 3 seconds + def hide_status_after_delay(): + import time + time.sleep(3) + return gr.update(visible=False) + + # Note: We can't use the change event to auto-hide because it would trigger immediately + # The status will remain visible until manually dismissed or page refresh + + # All individual field auto-save handlers removed - use manual Save Config button instead + + # Translate button click handler + translate_manga_btn.click( + fn=self.start_manga_translation, + inputs=[ + manga_images, + manga_model, + manga_api_key, + manga_profile, + manga_system_prompt, + ocr_provider, + google_creds, + azure_key, + azure_endpoint, + bubble_detection, + inpainting, + font_size_mode, + font_size, + font_multiplier, + min_font_size, + max_font_size, + text_color_rgb, + shadow_enabled, + shadow_color, + shadow_offset_x, + shadow_offset_y, + shadow_blur, + bg_opacity, + bg_style, + parallel_panel_translation, + panel_max_workers + ], + outputs=[manga_logs, manga_output_gallery, manga_cbz_output, manga_status, manga_progress_group, manga_progress_text, manga_progress_bar, translate_manga_btn, stop_manga_btn] + ) + + # Stop button click handler + stop_manga_btn.click( + fn=self.stop_manga_translation, + inputs=[], + outputs=[translate_manga_btn, stop_manga_btn, manga_status] + ) + + # Load settings from localStorage on page load + def load_settings_from_storage(): + """Load settings from localStorage or config file""" + is_hf_spaces = os.getenv('SPACE_ID') is not None or os.getenv('HF_SPACES') == 'true' + + if not is_hf_spaces: + # Load from config file locally + config = self.load_config() + # Decrypt API keys if needed + if API_KEY_ENCRYPTION_AVAILABLE: + config = decrypt_config(config) + return [ + config.get('model', 'gpt-4-turbo'), + config.get('api_key', ''), + config.get('active_profile', list(self.profiles.keys())[0] if self.profiles else ''), # profile + self.profiles.get(config.get('active_profile', list(self.profiles.keys())[0] if self.profiles else ''), ''), # prompt + config.get('ocr_provider', 'custom-api'), + None, # google_creds (file component - can't be pre-filled) + config.get('azure_vision_key', ''), + config.get('azure_vision_endpoint', ''), + config.get('bubble_detection_enabled', True), + config.get('inpainting_enabled', True), + config.get('manga_font_size_mode', 'auto'), + config.get('manga_font_size', 24), + config.get('manga_font_multiplier', 1.0), + config.get('manga_min_font_size', 12), + config.get('manga_max_font_size', 48), + config.get('manga_text_color', [255, 255, 255]), # Default white text + config.get('manga_shadow_enabled', True), + config.get('manga_shadow_color', [0, 0, 0]), # Default black shadow + config.get('manga_shadow_offset_x', 2), + config.get('manga_shadow_offset_y', 2), + config.get('manga_shadow_blur', 0), + config.get('manga_bg_opacity', 180), + config.get('manga_bg_style', 'auto'), + config.get('manga_settings', {}).get('advanced', {}).get('parallel_panel_translation', False), + config.get('manga_settings', {}).get('advanced', {}).get('panel_max_workers', 7) + ] + else: + # For HF Spaces, return defaults (will be overridden by JS) + return [ + 'gpt-4-turbo', # model + '', # api_key + list(self.profiles.keys())[0] if self.profiles else '', # profile + self.profiles.get(list(self.profiles.keys())[0] if self.profiles else '', ''), # prompt + 'custom-api', # ocr_provider + None, # google_creds (file component - can't be pre-filled) + '', # azure_key + '', # azure_endpoint + True, # bubble_detection + True, # inpainting + 'auto', # font_size_mode + 24, # font_size + 1.0, # font_multiplier + 12, # min_font_size + 48, # max_font_size + '#FFFFFF', # text_color - white + True, # shadow_enabled + '#000000', # shadow_color - black + 2, # shadow_offset_x + 2, # shadow_offset_y + 0, # shadow_blur + 180, # bg_opacity + 'auto', # bg_style + False, # parallel_panel_translation + 7 # panel_max_workers + ] + + # Store references for load handler + self.manga_components = { + 'model': manga_model, + 'api_key': manga_api_key, + 'profile': manga_profile, + 'prompt': manga_system_prompt, + 'ocr_provider': ocr_provider, + 'google_creds': google_creds, + 'azure_key': azure_key, + 'azure_endpoint': azure_endpoint, + 'bubble_detection': bubble_detection, + 'inpainting': inpainting, + 'font_size_mode': font_size_mode, + 'font_size': font_size, + 'font_multiplier': font_multiplier, + 'min_font_size': min_font_size, + 'max_font_size': max_font_size, + 'text_color_rgb': text_color_rgb, + 'shadow_enabled': shadow_enabled, + 'shadow_color': shadow_color, + 'shadow_offset_x': shadow_offset_x, + 'shadow_offset_y': shadow_offset_y, + 'shadow_blur': shadow_blur, + 'bg_opacity': bg_opacity, + 'bg_style': bg_style, + 'parallel_panel_translation': parallel_panel_translation, + 'panel_max_workers': panel_max_workers + } + self.load_settings_fn = load_settings_from_storage + + # Manga Settings Tab - NEW + with gr.Tab("๐ŸŽฌ Manga Settings"): + gr.Markdown("### Advanced Manga Translation Settings") + gr.Markdown("Configure bubble detection, inpainting, preprocessing, and rendering options.") + + with gr.Accordion("๐Ÿ•น๏ธ Bubble Detection & Inpainting", open=True): + gr.Markdown("#### Bubble Detection") + + detector_type = gr.Radio( + choices=["rtdetr_onnx", "rtdetr", "yolo"], + value=self.get_config_value('manga_settings', {}).get('ocr', {}).get('detector_type', 'rtdetr_onnx'), + label="Detector Type", + interactive=True + ) + + rtdetr_confidence = gr.Slider( + minimum=0.0, + maximum=1.0, + value=self.get_config_value('manga_settings', {}).get('ocr', {}).get('rtdetr_confidence', 0.3), + step=0.05, + label="RT-DETR Confidence Threshold", + interactive=True + ) + + bubble_confidence = gr.Slider( + minimum=0.0, + maximum=1.0, + value=self.get_config_value('manga_settings', {}).get('ocr', {}).get('bubble_confidence', 0.3), + step=0.05, + label="YOLO Bubble Confidence Threshold", + interactive=True + ) + + detect_text_bubbles = gr.Checkbox( + label="Detect Text Bubbles", + value=self.get_config_value('manga_settings', {}).get('ocr', {}).get('detect_text_bubbles', True) + ) + + detect_empty_bubbles = gr.Checkbox( + label="Detect Empty Bubbles", + value=self.get_config_value('manga_settings', {}).get('ocr', {}).get('detect_empty_bubbles', True) + ) + + detect_free_text = gr.Checkbox( + label="Detect Free Text (outside bubbles)", + value=self.get_config_value('manga_settings', {}).get('ocr', {}).get('detect_free_text', True) + ) + + bubble_max_detections = gr.Slider( + minimum=1, + maximum=2000, + value=self.get_config_value('manga_settings', {}).get('ocr', {}).get('bubble_max_detections_yolo', 100), + step=1, + label="Max detections (YOLO only)", + interactive=True, + info="Maximum number of bubble detections for YOLO detector" + ) + + gr.Markdown("#### Inpainting") + + local_inpaint_method = gr.Radio( + choices=["anime_onnx", "anime", "lama", "lama_onnx", "aot", "aot_onnx"], + value=self.get_config_value('manga_settings', {}).get('inpainting', {}).get('local_method', 'anime_onnx'), + label="Local Inpainting Model", + interactive=True + ) + + with gr.Row(): + download_models_btn = gr.Button( + "๐Ÿ“ฅ Download Models", + variant="secondary", + size="sm" + ) + load_models_btn = gr.Button( + "๐Ÿ“‚ Load Models", + variant="secondary", + size="sm" + ) + + gr.Markdown("#### Mask Dilation") + + auto_iterations = gr.Checkbox( + label="Auto Iterations (Recommended)", + value=self.get_config_value('manga_settings', {}).get('auto_iterations', True) + ) + + mask_dilation = gr.Slider( + minimum=0, + maximum=20, + value=self.get_config_value('manga_settings', {}).get('mask_dilation', 0), + step=1, + label="General Mask Dilation", + interactive=True + ) + + text_bubble_dilation = gr.Slider( + minimum=0, + maximum=20, + value=self.get_config_value('manga_settings', {}).get('text_bubble_dilation_iterations', 2), + step=1, + label="Text Bubble Dilation Iterations", + interactive=True + ) + + empty_bubble_dilation = gr.Slider( + minimum=0, + maximum=20, + value=self.get_config_value('manga_settings', {}).get('empty_bubble_dilation_iterations', 3), + step=1, + label="Empty Bubble Dilation Iterations", + interactive=True + ) + + free_text_dilation = gr.Slider( + minimum=0, + maximum=20, + value=self.get_config_value('manga_settings', {}).get('free_text_dilation_iterations', 3), + step=1, + label="Free Text Dilation Iterations", + interactive=True + ) + + with gr.Accordion("๐Ÿ–Œ๏ธ Image Preprocessing", open=False): + preprocessing_enabled = gr.Checkbox( + label="Enable Preprocessing", + value=self.get_config_value('manga_settings', {}).get('preprocessing', {}).get('enabled', False) + ) + + auto_detect_quality = gr.Checkbox( + label="Auto Detect Image Quality", + value=self.get_config_value('manga_settings', {}).get('preprocessing', {}).get('auto_detect_quality', True) + ) + + enhancement_strength = gr.Slider( + minimum=1.0, + maximum=3.0, + value=self.get_config_value('manga_settings', {}).get('preprocessing', {}).get('enhancement_strength', 1.5), + step=0.1, + label="Enhancement Strength", + interactive=True + ) + + denoise_strength = gr.Slider( + minimum=0, + maximum=50, + value=self.get_config_value('manga_settings', {}).get('preprocessing', {}).get('denoise_strength', 10), + step=1, + label="Denoise Strength", + interactive=True + ) + + max_image_dimension = gr.Number( + label="Max Image Dimension (pixels)", + value=self.get_config_value('manga_settings', {}).get('preprocessing', {}).get('max_image_dimension', 2000), + minimum=500 + ) + + chunk_height = gr.Number( + label="Chunk Height for Large Images", + value=self.get_config_value('manga_settings', {}).get('preprocessing', {}).get('chunk_height', 1000), + minimum=500 + ) + + gr.Markdown("#### HD Strategy for Inpainting") + gr.Markdown("*Controls how large images are processed during inpainting*") + + hd_strategy = gr.Radio( + choices=["original", "resize", "crop"], + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('hd_strategy', 'resize'), + label="HD Strategy", + interactive=True, + info="original = legacy full-image; resize/crop = faster" + ) + + hd_strategy_resize_limit = gr.Slider( + minimum=512, + maximum=4096, + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('hd_strategy_resize_limit', 1536), + step=64, + label="Resize Limit (long edge, px)", + info="For resize strategy", + interactive=True + ) + + hd_strategy_crop_margin = gr.Slider( + minimum=0, + maximum=256, + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('hd_strategy_crop_margin', 16), + step=2, + label="Crop Margin (px)", + info="For crop strategy", + interactive=True + ) + + hd_strategy_crop_trigger = gr.Slider( + minimum=256, + maximum=4096, + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('hd_strategy_crop_trigger_size', 1024), + step=64, + label="Crop Trigger Size (px)", + info="Apply crop only if long edge exceeds this", + interactive=True + ) + + gr.Markdown("#### Image Tiling") + gr.Markdown("*Alternative tiling strategy (note: HD Strategy takes precedence)*") + + tiling_enabled = gr.Checkbox( + label="Enable Tiling", + value=self.get_config_value('manga_settings', {}).get('tiling', {}).get('enabled', False) + ) + + tiling_tile_size = gr.Slider( + minimum=256, + maximum=1024, + value=self.get_config_value('manga_settings', {}).get('tiling', {}).get('tile_size', 480), + step=64, + label="Tile Size (px)", + interactive=True + ) + + tiling_tile_overlap = gr.Slider( + minimum=0, + maximum=128, + value=self.get_config_value('manga_settings', {}).get('tiling', {}).get('tile_overlap', 64), + step=16, + label="Tile Overlap (px)", + interactive=True + ) + + with gr.Accordion("๐ŸŽจ Font & Text Rendering", open=False): + gr.Markdown("#### Font Sizing Algorithm") + + font_algorithm = gr.Radio( + choices=["smart", "simple"], + value=self.get_config_value('manga_settings', {}).get('font_sizing', {}).get('algorithm', 'smart'), + label="Font Sizing Algorithm", + interactive=True + ) + + prefer_larger = gr.Checkbox( + label="Prefer Larger Fonts", + value=self.get_config_value('manga_settings', {}).get('font_sizing', {}).get('prefer_larger', True) + ) + + max_lines = gr.Slider( + minimum=1, + maximum=20, + value=self.get_config_value('manga_settings', {}).get('font_sizing', {}).get('max_lines', 10), + step=1, + label="Maximum Lines Per Bubble", + interactive=True + ) + + line_spacing = gr.Slider( + minimum=0.5, + maximum=3.0, + value=self.get_config_value('manga_settings', {}).get('font_sizing', {}).get('line_spacing', 1.3), + step=0.1, + label="Line Spacing Multiplier", + interactive=True + ) + + bubble_size_factor = gr.Checkbox( + label="Use Bubble Size Factor", + value=self.get_config_value('manga_settings', {}).get('font_sizing', {}).get('bubble_size_factor', True) + ) + + auto_fit_style = gr.Radio( + choices=["balanced", "aggressive", "conservative"], + value=self.get_config_value('manga_settings', {}).get('rendering', {}).get('auto_fit_style', 'balanced'), + label="Auto Fit Style", + interactive=True + ) + + with gr.Accordion("โš™๏ธ Advanced Options", open=False): + gr.Markdown("#### Format Detection") + + format_detection = gr.Checkbox( + label="Enable Format Detection (manga/webtoon)", + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('format_detection', True) + ) + + webtoon_mode = gr.Radio( + choices=["auto", "force_manga", "force_webtoon"], + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('webtoon_mode', 'auto'), + label="Webtoon Mode", + interactive=True + ) + + gr.Markdown("#### Inpainting Performance") + + inpaint_batch_size = gr.Slider( + minimum=1, + maximum=32, + value=self.get_config_value('manga_settings', {}).get('inpainting', {}).get('batch_size', 10), + step=1, + label="Batch Size", + interactive=True, + info="Process multiple regions at once" + ) + + inpaint_cache_enabled = gr.Checkbox( + label="Enable inpainting cache (speeds up repeated processing)", + value=self.get_config_value('manga_settings', {}).get('inpainting', {}).get('enable_cache', True) + ) + + gr.Markdown("#### Performance") + + parallel_processing = gr.Checkbox( + label="Enable Parallel Processing", + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('parallel_processing', True) + ) + + max_workers = gr.Slider( + minimum=1, + maximum=8, + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('max_workers', 2), + step=1, + label="Max Worker Threads", + interactive=True + ) + + gr.Markdown("**โšก Advanced Performance**") + + preload_local_inpainting = gr.Checkbox( + label="Preload local inpainting instances for panel-parallel runs", + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('preload_local_inpainting_for_panels', True), + info="Preloads inpainting models to speed up parallel processing" + ) + + panel_start_stagger = gr.Slider( + minimum=0, + maximum=1000, + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('panel_start_stagger_ms', 30), + step=10, + label="Panel start stagger", + interactive=True, + info="Milliseconds delay between panel starts" + ) + + gr.Markdown("#### Model Optimization") + + torch_precision = gr.Radio( + choices=["fp32", "fp16"], + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('torch_precision', 'fp16'), + label="Torch Precision", + interactive=True + ) + + auto_cleanup_models = gr.Checkbox( + label="Auto Cleanup Models from Memory", + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('auto_cleanup_models', False) + ) + + gr.Markdown("#### Debug Options") + + debug_mode = gr.Checkbox( + label="Enable Debug Mode", + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('debug_mode', False) + ) + + save_intermediate = gr.Checkbox( + label="Save Intermediate Files", + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('save_intermediate', False) + ) + + concise_pipeline_logs = gr.Checkbox( + label="Concise Pipeline Logs", + value=self.get_config_value('concise_pipeline_logs', True) + ) + + # Button handlers for model management + def download_models_handler(detector_type_val, inpaint_method_val): + """Download selected models""" + messages = [] + + try: + # Download bubble detection model + if detector_type_val: + messages.append(f"๐Ÿ“ฅ Downloading {detector_type_val} bubble detector...") + try: + from bubble_detector import BubbleDetector + bd = BubbleDetector() + + if detector_type_val == "rtdetr_onnx": + if bd.load_rtdetr_onnx_model(): + messages.append("โœ… RT-DETR ONNX model downloaded successfully") + else: + messages.append("โŒ Failed to download RT-DETR ONNX model") + elif detector_type_val == "rtdetr": + if bd.load_rtdetr_model(): + messages.append("โœ… RT-DETR model downloaded successfully") + else: + messages.append("โŒ Failed to download RT-DETR model") + elif detector_type_val == "yolo": + messages.append("โ„น๏ธ YOLO models are downloaded automatically on first use") + except Exception as e: + messages.append(f"โŒ Error downloading detector: {str(e)}") + + # Download inpainting model + if inpaint_method_val: + messages.append(f"\n๐Ÿ“ฅ Downloading {inpaint_method_val} inpainting model...") + try: + from local_inpainter import LocalInpainter, LAMA_JIT_MODELS + + inpainter = LocalInpainter({}) + + # Map method names to download keys + method_map = { + 'anime_onnx': 'anime_onnx', + 'anime': 'anime', + 'lama': 'lama', + 'lama_onnx': 'lama_onnx', + 'aot': 'aot', + 'aot_onnx': 'aot_onnx' + } + + method_key = method_map.get(inpaint_method_val) + if method_key and method_key in LAMA_JIT_MODELS: + model_info = LAMA_JIT_MODELS[method_key] + messages.append(f"Downloading {model_info['name']}...") + + model_path = inpainter.download_jit_model(method_key) + if model_path: + messages.append(f"โœ… {model_info['name']} downloaded to: {model_path}") + else: + messages.append(f"โŒ Failed to download {model_info['name']}") + else: + messages.append(f"โ„น๏ธ {inpaint_method_val} is downloaded automatically on first use") + + except Exception as e: + messages.append(f"โŒ Error downloading inpainting model: {str(e)}") + + if not messages: + messages.append("โ„น๏ธ No models selected for download") + + except Exception as e: + messages.append(f"โŒ Error during download: {str(e)}") + + return gr.Info("\n".join(messages)) + + def load_models_handler(detector_type_val, inpaint_method_val): + """Load selected models into memory""" + messages = [] + + try: + # Load bubble detection model + if detector_type_val: + messages.append(f"๐Ÿ“ฆ Loading {detector_type_val} bubble detector...") + try: + from bubble_detector import BubbleDetector + bd = BubbleDetector() + + if detector_type_val == "rtdetr_onnx": + if bd.load_rtdetr_onnx_model(): + messages.append("โœ… RT-DETR ONNX model loaded successfully") + else: + messages.append("โŒ Failed to load RT-DETR ONNX model") + elif detector_type_val == "rtdetr": + if bd.load_rtdetr_model(): + messages.append("โœ… RT-DETR model loaded successfully") + else: + messages.append("โŒ Failed to load RT-DETR model") + elif detector_type_val == "yolo": + messages.append("โ„น๏ธ YOLO models are loaded automatically when needed") + except Exception as e: + messages.append(f"โŒ Error loading detector: {str(e)}") + + # Load inpainting model + if inpaint_method_val: + messages.append(f"\n๐Ÿ“ฆ Loading {inpaint_method_val} inpainting model...") + try: + from local_inpainter import LocalInpainter, LAMA_JIT_MODELS + import os + + inpainter = LocalInpainter({}) + + # Map method names to model keys + method_map = { + 'anime_onnx': 'anime_onnx', + 'anime': 'anime', + 'lama': 'lama', + 'lama_onnx': 'lama_onnx', + 'aot': 'aot', + 'aot_onnx': 'aot_onnx' + } + + method_key = method_map.get(inpaint_method_val) + if method_key: + # First check if model exists, download if not + if method_key in LAMA_JIT_MODELS: + model_info = LAMA_JIT_MODELS[method_key] + cache_dir = os.path.expanduser('~/.cache/inpainting') + model_filename = os.path.basename(model_info['url']) + model_path = os.path.join(cache_dir, model_filename) + + if not os.path.exists(model_path): + messages.append(f"Model not found, downloading first...") + model_path = inpainter.download_jit_model(method_key) + if not model_path: + messages.append(f"โŒ Failed to download model") + return gr.Info("\n".join(messages)) + + # Now load the model + if inpainter.load_model(method_key, model_path): + messages.append(f"โœ… {model_info['name']} loaded successfully") + else: + messages.append(f"โŒ Failed to load {model_info['name']}") + else: + messages.append(f"โ„น๏ธ {inpaint_method_val} will be loaded automatically when needed") + else: + messages.append(f"โ„น๏ธ Unknown method: {inpaint_method_val}") + + except Exception as e: + messages.append(f"โŒ Error loading inpainting model: {str(e)}") + + if not messages: + messages.append("โ„น๏ธ No models selected for loading") + + except Exception as e: + messages.append(f"โŒ Error during loading: {str(e)}") + + return gr.Info("\n".join(messages)) + + download_models_btn.click( + fn=download_models_handler, + inputs=[detector_type, local_inpaint_method], + outputs=None + ) + + load_models_btn.click( + fn=load_models_handler, + inputs=[detector_type, local_inpaint_method], + outputs=None + ) + + # Auto-save parallel panel translation settings + def save_parallel_settings(preload_enabled, parallel_enabled, max_workers, stagger_ms): + """Save parallel panel translation settings to config""" + try: + current_config = self.get_current_config_for_update() + # Don't decrypt - just update what we need + + # Initialize nested structure if not exists + if 'manga_settings' not in current_config: + current_config['manga_settings'] = {} + if 'advanced' not in current_config['manga_settings']: + current_config['manga_settings']['advanced'] = {} + + current_config['manga_settings']['advanced']['preload_local_inpainting_for_panels'] = bool(preload_enabled) + current_config['manga_settings']['advanced']['parallel_panel_translation'] = bool(parallel_enabled) + current_config['manga_settings']['advanced']['panel_max_workers'] = int(max_workers) + current_config['manga_settings']['advanced']['panel_start_stagger_ms'] = int(stagger_ms) + + self.save_config(current_config) + return None + except Exception as e: + print(f"Failed to save parallel panel settings: {e}") + return None + + # Auto-save inpainting performance settings + def save_inpainting_settings(batch_size, cache_enabled): + """Save inpainting performance settings to config""" + try: + current_config = self.get_current_config_for_update() + # Don't decrypt - just update what we need + + # Initialize nested structure if not exists + if 'manga_settings' not in current_config: + current_config['manga_settings'] = {} + if 'inpainting' not in current_config['manga_settings']: + current_config['manga_settings']['inpainting'] = {} + + current_config['manga_settings']['inpainting']['batch_size'] = int(batch_size) + current_config['manga_settings']['inpainting']['enable_cache'] = bool(cache_enabled) + + self.save_config(current_config) + return None + except Exception as e: + print(f"Failed to save inpainting settings: {e}") + return None + + # Auto-save preload local inpainting setting + def save_preload_setting(preload_enabled): + """Save preload local inpainting setting to config""" + try: + current_config = self.get_current_config_for_update() + # Don't decrypt - just update what we need + + # Initialize nested structure if not exists + if 'manga_settings' not in current_config: + current_config['manga_settings'] = {} + if 'advanced' not in current_config['manga_settings']: + current_config['manga_settings']['advanced'] = {} + + current_config['manga_settings']['advanced']['preload_local_inpainting_for_panels'] = bool(preload_enabled) + + self.save_config(current_config) + return None + except Exception as e: + print(f"Failed to save preload setting: {e}") + return None + + # Auto-save bubble detection settings + def save_bubble_detection_settings(detector_type_val, rtdetr_conf, bubble_conf, detect_text, detect_empty, detect_free, max_detections, local_method_val): + """Save bubble detection settings to config""" + try: + current_config = self.get_current_config_for_update() + # Don't decrypt - just update what we need + + # Initialize nested structure + if 'manga_settings' not in current_config: + current_config['manga_settings'] = {} + if 'ocr' not in current_config['manga_settings']: + current_config['manga_settings']['ocr'] = {} + if 'inpainting' not in current_config['manga_settings']: + current_config['manga_settings']['inpainting'] = {} + + # Save bubble detection settings + current_config['manga_settings']['ocr']['detector_type'] = detector_type_val + current_config['manga_settings']['ocr']['rtdetr_confidence'] = float(rtdetr_conf) + current_config['manga_settings']['ocr']['bubble_confidence'] = float(bubble_conf) + current_config['manga_settings']['ocr']['detect_text_bubbles'] = bool(detect_text) + current_config['manga_settings']['ocr']['detect_empty_bubbles'] = bool(detect_empty) + current_config['manga_settings']['ocr']['detect_free_text'] = bool(detect_free) + current_config['manga_settings']['ocr']['bubble_max_detections_yolo'] = int(max_detections) + + # Save inpainting method + current_config['manga_settings']['inpainting']['local_method'] = local_method_val + + self.save_config(current_config) + return None + except Exception as e: + print(f"Failed to save bubble detection settings: {e}") + return None + + # All Advanced Settings auto-save handlers removed - use manual Save Config button + + gr.Markdown("\n---\n**Note:** These settings will be saved to your config and applied to all manga translations.") + + # Manual Glossary Extraction Tab + with gr.Tab("๐Ÿ“ Manual Glossary Extraction"): + gr.Markdown(""" + ### Extract character names and terms from EPUB files + Configure extraction settings below, then upload an EPUB file to extract a glossary. + """) + + with gr.Row(): + with gr.Column(): + glossary_epub = gr.File( + label="๐Ÿ“– Upload EPUB File", + file_types=[".epub"] + ) + + with gr.Row(): + extract_btn = gr.Button( + "๐Ÿ” Extract Glossary", + variant="primary", + size="lg", + scale=2 + ) + + stop_glossary_btn = gr.Button( + "โน๏ธ Stop Extraction", + variant="stop", + size="lg", + visible=False, + scale=1 + ) + + glossary_model = gr.Dropdown( + choices=self.models, + value=self.get_config_value('model', 'gpt-4-turbo'), + label="๐Ÿค– AI Model", + interactive=True, + allow_custom_value=True, + filterable=True + ) + + glossary_api_key = gr.Textbox( + label="๐Ÿ”‘ API Key", + type="password", + placeholder="Enter your API key", + value=self.get_config_value('api_key', '') + ) + + # Tabs for different settings sections + with gr.Tabs(): + # Extraction Settings Tab + with gr.Tab("Extraction Settings"): + with gr.Accordion("๐ŸŽฏ Targeted Extraction Settings", open=True): + with gr.Row(): + with gr.Column(): + min_freq = gr.Slider( + minimum=1, + maximum=10, + value=self.get_config_value('glossary_min_frequency', 2), + step=1, + label="Min frequency", + info="How many times a name must appear (lower = more terms)" + ) + + max_titles = gr.Slider( + minimum=10, + maximum=100, + value=self.get_config_value('glossary_max_titles', 30), + step=5, + label="Max titles", + info="Limits to prevent huge glossaries" + ) + + max_text_size = gr.Number( + label="Max text size", + value=self.get_config_value('glossary_max_text_size', 50000), + info="Characters to analyze (0 = entire text)" + ) + + max_sentences = gr.Slider( + minimum=50, + maximum=500, + value=self.get_config_value('glossary_max_sentences', 200), + step=10, + label="Max sentences", + info="Maximum sentences to send to AI (increase for more context)" + ) + + with gr.Column(): + max_names_slider = gr.Slider( + minimum=10, + maximum=200, + value=self.get_config_value('glossary_max_names', 50), + step=10, + label="Max names", + info="Maximum number of character names to extract" + ) + + translation_batch = gr.Slider( + minimum=10, + maximum=100, + value=self.get_config_value('glossary_batch_size', 50), + step=5, + label="Translation batch", + info="Terms per API call (larger = faster but may reduce quality)" + ) + + chapter_split_threshold = gr.Number( + label="Chapter split threshold", + value=self.get_config_value('glossary_chapter_split_threshold', 8192), + info="Split large texts into chunks (0 = no splitting)" + ) + + # Filter mode selection + filter_mode = gr.Radio( + choices=[ + "all", + "only_with_honorifics", + "only_without_honorifics" + ], + value=self.get_config_value('glossary_filter_mode', 'all'), + label="Filter mode", + info="What types of names to extract" + ) + + # Strip honorifics checkbox + strip_honorifics = gr.Checkbox( + label="Remove honorifics from extracted names", + value=self.get_config_value('strip_honorifics', True), + info="Remove suffixes like '๋‹˜', 'ใ•ใ‚“', 'ๅ…ˆ็”Ÿ' from names" + ) + + # Fuzzy threshold slider + fuzzy_threshold = gr.Slider( + minimum=0.5, + maximum=1.0, + value=self.get_config_value('glossary_fuzzy_threshold', 0.90), + step=0.05, + label="Fuzzy threshold", + info="How similar names must be to match (0.9 = 90% match, 1.0 = exact match)" + ) + + + # Extraction Prompt Tab + with gr.Tab("Extraction Prompt"): + gr.Markdown(""" + ### System Prompt for Extraction + Customize how the AI extracts names and terms from your text. + """) + + extraction_prompt = gr.Textbox( + label="Extraction Template (Use placeholders: {language}, {min_frequency}, {max_names}, {max_titles})", + lines=10, + value=self.get_config_value('manual_glossary_prompt', + "Extract character names and important terms from the following text.\n\n" + "Output format:\n{fields}\n\n" + "Rules:\n- Output ONLY CSV lines in the exact format shown above\n" + "- No headers, no extra text, no JSON\n" + "- One entry per line\n" + "- Leave gender empty for terms (just end with comma)") + ) + + reset_extraction_prompt_btn = gr.Button( + "Reset to Default", + variant="secondary", + size="sm" + ) + + # Format Instructions Tab + with gr.Tab("Format Instructions"): + gr.Markdown(""" + ### Output Format Instructions + These instructions tell the AI exactly how to format the extracted glossary. + """) + + format_instructions = gr.Textbox( + label="Format Instructions (Use placeholder: {text_sample})", + lines=10, + value=self.get_config_value('glossary_format_instructions', + "Return the results in EXACT CSV format with this header:\n" + "type,raw_name,translated_name\n\n" + "For example:\n" + "character,๊น€์ƒํ˜„,Kim Sang-hyun\n" + "character,๊ฐˆํŽธ์ œ,Gale Hardest\n" + "term,๋งˆ๋ฒ•์‚ฌ,Mage\n\n" + "Only include terms that actually appear in the text.\n" + "Do not use quotes around values unless they contain commas.\n\n" + "Text to analyze:\n{text_sample}") + ) + + use_legacy_csv = gr.Checkbox( + label="Use legacy CSV format", + value=self.get_config_value('glossary_use_legacy_csv', False), + info="When disabled: Uses clean format with sections (===CHARACTERS===). When enabled: Uses traditional CSV format with repeated type columns." + ) + + with gr.Column(): + # Add logo and status at top + with gr.Row(): + gr.Image( + value="Halgakos.png", + label=None, + show_label=False, + width=80, + height=80, + interactive=False, + show_download_button=False, + container=False + ) + glossary_status_message = gr.Markdown( + value="### Ready to extract\nUpload an EPUB file and click 'Extract Glossary' to begin.", + visible=True + ) + + # Progress section (similar to translation tabs) + with gr.Group(visible=False) as glossary_progress_group: + gr.Markdown("### Progress") + glossary_progress_text = gr.Textbox( + label="๐Ÿ“จ Current Status", + value="Ready to start", + interactive=False, + lines=1 + ) + glossary_progress_bar = gr.Slider( + minimum=0, + maximum=100, + value=0, + step=1, + label="๐Ÿ“‹ Extraction Progress", + interactive=False, + show_label=True + ) + + glossary_logs = gr.Textbox( + label="๐Ÿ“‹ Extraction Logs", + lines=20, + max_lines=30, + value="Ready to extract. Upload an EPUB file and configure settings.", + visible=True, + interactive=False + ) + + glossary_output = gr.File( + label="๐Ÿ“ฅ Download Glossary CSV", + visible=False + ) + + glossary_status = gr.Textbox( + label="Final Status", + lines=3, + max_lines=5, + visible=False, + interactive=False + ) + + extract_btn.click( + fn=self.extract_glossary_with_stop, + inputs=[ + glossary_epub, + glossary_model, + glossary_api_key, + min_freq, + max_names_slider, + max_titles, + max_text_size, + max_sentences, + translation_batch, + chapter_split_threshold, + filter_mode, + strip_honorifics, + fuzzy_threshold, + extraction_prompt, + format_instructions, + use_legacy_csv + ], + outputs=[ + glossary_output, + glossary_status_message, + glossary_progress_group, + glossary_logs, + glossary_status, + glossary_progress_text, + glossary_progress_bar, + extract_btn, + stop_glossary_btn + ] + ) + + # Stop button handler + stop_glossary_btn.click( + fn=self.stop_glossary_extraction, + inputs=[], + outputs=[extract_btn, stop_glossary_btn, glossary_status] + ) + + # QA Scanner Tab + with gr.Tab("๐Ÿ” QA Scanner"): + gr.Markdown(""" + ### Quick Scan for Translation Quality + Scan translated content for common issues like untranslated text, formatting problems, and quality concerns. + + **Supported inputs:** + - ๐Ÿ“ Output folder containing extracted HTML/XHTML files + - ๐Ÿ“– EPUB file (will be automatically extracted and scanned) + - ๐Ÿ“ฆ ZIP file containing HTML/XHTML files + """) + + with gr.Row(): + with gr.Column(): + # Check if running on Hugging Face Spaces + is_hf_spaces = os.getenv('SPACE_ID') is not None or os.getenv('HF_SPACES') == 'true' + + if is_hf_spaces: + gr.Markdown(""" + **๐Ÿค— Hugging Face Spaces Mode** + Upload an EPUB or ZIP file containing the translated content. + The scanner will extract and analyze the HTML/XHTML files inside. + """) + qa_folder_path = gr.File( + label="๐Ÿ“‚ Upload EPUB or ZIP file", + file_types=[".epub", ".zip"], + type="filepath" + ) + else: + qa_folder_path = gr.Textbox( + label="๐Ÿ“ Path to Folder, EPUB, or ZIP", + placeholder="Enter path to: folder with HTML files, EPUB file, or ZIP file", + info="Can be a folder path, or direct path to an EPUB/ZIP file" + ) + + with gr.Row(): + qa_scan_btn = gr.Button( + "โšก Quick Scan", + variant="primary", + size="lg", + scale=2 + ) + + stop_qa_btn = gr.Button( + "โน๏ธ Stop Scan", + variant="stop", + size="lg", + visible=False, + scale=1 + ) + + with gr.Accordion("โš™๏ธ Quick Scan Settings", open=True): + gr.Markdown(""" + **Quick Scan Mode (85% threshold, Speed optimized)** + - 3-5x faster scanning + - Checks consecutive chapters only + - Simplified analysis + - Good for large libraries + - Minimal resource usage + """) + + # Foreign Character Detection + gr.Markdown("#### Foreign Character Detection") + min_foreign_chars = gr.Slider( + minimum=0, + maximum=50, + value=self.get_config_value('qa_min_foreign_chars', 10), + step=1, + label="Minimum foreign characters to flag", + info="0 = always flag, higher = more tolerant" + ) + + # Detection Options + gr.Markdown("#### Detection Options") + check_repetition = gr.Checkbox( + label="Check for excessive repetition", + value=self.get_config_value('qa_check_repetition', True) + ) + + check_glossary_leakage = gr.Checkbox( + label="Check for glossary leakage (raw glossary entries in translation)", + value=self.get_config_value('qa_check_glossary_leakage', True) + ) + + # File Processing + gr.Markdown("#### File Processing") + min_file_length = gr.Slider( + minimum=0, + maximum=5000, + value=self.get_config_value('qa_min_file_length', 0), + step=100, + label="Minimum file length (characters)", + info="Skip files shorter than this" + ) + + # Additional Checks + gr.Markdown("#### Additional Checks") + check_multiple_headers = gr.Checkbox( + label="Detect files with 2 or more headers (h1-h6 tags)", + value=self.get_config_value('qa_check_multiple_headers', True), + info="Identifies files that may have been incorrectly split or merged" + ) + + check_missing_html = gr.Checkbox( + label="Flag HTML files with missing <html> tag", + value=self.get_config_value('qa_check_missing_html', True), + info="Checks if HTML files have proper structure" + ) + + check_insufficient_paragraphs = gr.Checkbox( + label="Check for insufficient paragraph tags", + value=self.get_config_value('qa_check_insufficient_paragraphs', True) + ) + + min_paragraph_percentage = gr.Slider( + minimum=10, + maximum=90, + value=self.get_config_value('qa_min_paragraph_percentage', 30), + step=5, + label="Minimum text in <p> tags (%)", + info="Files with less than this percentage will be flagged" + ) + + # Report Settings + gr.Markdown("#### Report Settings") + + report_format = gr.Radio( + choices=["summary", "detailed", "verbose"], + value=self.get_config_value('qa_report_format', 'detailed'), + label="Report format", + info="Summary = brief overview, Detailed = recommended, Verbose = all data" + ) + + auto_save_report = gr.Checkbox( + label="Automatically save report after scan", + value=self.get_config_value('qa_auto_save_report', True) + ) + + with gr.Column(): + # Add logo and status at top + with gr.Row(): + gr.Image( + value="Halgakos.png", + label=None, + show_label=False, + width=80, + height=80, + interactive=False, + show_download_button=False, + container=False + ) + qa_status_message = gr.Markdown( + value="### Ready to scan\nEnter the path to your output folder and click 'Quick Scan' to begin.", + visible=True + ) + + # Progress section + with gr.Group(visible=False) as qa_progress_group: + gr.Markdown("### Progress") + qa_progress_text = gr.Textbox( + label="๐Ÿ“จ Current Status", + value="Ready to start", + interactive=False, + lines=1 + ) + qa_progress_bar = gr.Slider( + minimum=0, + maximum=100, + value=0, + step=1, + label="๐Ÿ“‹ Scan Progress", + interactive=False, + show_label=True + ) + + qa_logs = gr.Textbox( + label="๐Ÿ“‹ Scan Logs", + lines=20, + max_lines=30, + value="Ready to scan. Enter output folder path and configure settings.", + visible=True, + interactive=False + ) + + qa_report = gr.File( + label="๐Ÿ“„ Download QA Report", + visible=False + ) + + qa_status = gr.Textbox( + label="Final Status", + lines=3, + max_lines=5, + visible=False, + interactive=False + ) + + # QA Scan button handler + qa_scan_btn.click( + fn=self.run_qa_scan_with_stop, + inputs=[ + qa_folder_path, + min_foreign_chars, + check_repetition, + check_glossary_leakage, + min_file_length, + check_multiple_headers, + check_missing_html, + check_insufficient_paragraphs, + min_paragraph_percentage, + report_format, + auto_save_report + ], + outputs=[ + qa_report, + qa_status_message, + qa_progress_group, + qa_logs, + qa_status, + qa_progress_text, + qa_progress_bar, + qa_scan_btn, + stop_qa_btn + ] + ) + + # Stop button handler + stop_qa_btn.click( + fn=self.stop_qa_scan, + inputs=[], + outputs=[qa_scan_btn, stop_qa_btn, qa_status] + ) + + # Settings Tab + with gr.Tab("โš™๏ธ Settings"): + gr.Markdown("### Configuration") + + gr.Markdown("#### Translation Profiles") + gr.Markdown("Profiles are loaded from your `config_web.json` file. The web interface has its own separate configuration.") + + with gr.Accordion("View All Profiles", open=False): + profiles_text = "\n\n".join( + [f"**{name}**:\n```\n{prompt[:200]}...\n```" + for name, prompt in self.profiles.items()] + ) + gr.Markdown(profiles_text if profiles_text else "No profiles found") + + gr.Markdown("---") + gr.Markdown("#### Advanced Translation Settings") + + with gr.Row(): + with gr.Column(): + thread_delay = gr.Slider( + minimum=0, + maximum=5, + value=self.get_config_value('thread_submission_delay', 0.1), + step=0.1, + label="Threading delay (s)", + interactive=True + ) + + api_delay = gr.Slider( + minimum=0, + maximum=10, + value=self.get_config_value('api_call_delay', 0.5), + step=0.1, + label="API call delay (s) [SEND_INTERVAL_SECONDS]", + interactive=True, + info="Delay between API calls to avoid rate limits" + ) + + chapter_range = gr.Textbox( + label="Chapter range (e.g., 5-10)", + value=self.get_config_value('chapter_range', ''), + placeholder="Leave empty for all chapters" + ) + + token_limit = gr.Number( + label="Input Token limit", + value=self.get_config_value('token_limit', 200000), + minimum=0 + ) + + disable_token_limit = gr.Checkbox( + label="Disable Input Token Limit", + value=self.get_config_value('token_limit_disabled', False) + ) + + output_token_limit = gr.Number( + label="Output Token limit", + value=self.get_config_value('max_output_tokens', 16000), + minimum=0 + ) + + with gr.Column(): + contextual = gr.Checkbox( + label="Contextual Translation", + value=self.get_config_value('contextual', False) + ) + + history_limit = gr.Number( + label="Translation History Limit", + value=self.get_config_value('translation_history_limit', 2), + minimum=0 + ) + + rolling_history = gr.Checkbox( + label="Rolling History Window", + value=self.get_config_value('translation_history_rolling', False) + ) + + batch_translation = gr.Checkbox( + label="Batch Translation", + value=self.get_config_value('batch_translation', True) + ) + + batch_size = gr.Number( + label="Batch Size", + value=self.get_config_value('batch_size', 10), + minimum=1 + ) + + gr.Markdown("---") + gr.Markdown("#### Chapter Processing Options") + + with gr.Row(): + with gr.Column(): + # Chapter Header Translation + batch_translate_headers = gr.Checkbox( + label="Batch Translate Headers", + value=self.get_config_value('batch_translate_headers', False) + ) + + headers_per_batch = gr.Number( + label="Headers per batch", + value=self.get_config_value('headers_per_batch', 400), + minimum=1 + ) + + # NCX and CSS options + use_ncx_navigation = gr.Checkbox( + label="Use NCX-only Navigation (Compatibility Mode)", + value=self.get_config_value('use_ncx_navigation', False) + ) + + attach_css_to_chapters = gr.Checkbox( + label="Attach CSS to Chapters (Fixes styling issues)", + value=self.get_config_value('attach_css_to_chapters', False) + ) + + retain_source_extension = gr.Checkbox( + label="Retain source extension (no 'response_' prefix)", + value=self.get_config_value('retain_source_extension', True) + ) + + with gr.Column(): + # Conservative Batching + use_conservative_batching = gr.Checkbox( + label="Use Conservative Batching", + value=self.get_config_value('use_conservative_batching', False), + info="Groups chapters in batches of 3x batch size for memory management" + ) + + # Gemini API Safety + disable_gemini_safety = gr.Checkbox( + label="Disable Gemini API Safety Filters", + value=self.get_config_value('disable_gemini_safety', False), + info="โš ๏ธ Disables ALL content safety filters for Gemini models (BLOCK_NONE)" + ) + + # OpenRouter Options + use_http_openrouter = gr.Checkbox( + label="Use HTTP-only for OpenRouter (bypass SDK)", + value=self.get_config_value('use_http_openrouter', False), + info="Direct HTTP POST with explicit headers" + ) + + disable_openrouter_compression = gr.Checkbox( + label="Disable compression for OpenRouter (Accept-Encoding)", + value=self.get_config_value('disable_openrouter_compression', False), + info="Sends Accept-Encoding: identity for uncompressed responses" + ) + + gr.Markdown("---") + gr.Markdown("#### Chapter Extraction Settings") + + with gr.Row(): + with gr.Column(): + gr.Markdown("**Text Extraction Method:**") + text_extraction_method = gr.Radio( + choices=["standard", "enhanced"], + value=self.get_config_value('text_extraction_method', 'standard'), + label="", + info="Standard uses BeautifulSoup, Enhanced uses html2text", + interactive=True + ) + + gr.Markdown("โ€ข **Standard (BeautifulSoup)** - Traditional HTML parsing, fast and reliable") + gr.Markdown("โ€ข **Enhanced (html2text)** - Superior Unicode handling, cleaner text extraction") + + with gr.Column(): + gr.Markdown("**File Filtering Level:**") + file_filtering_level = gr.Radio( + choices=["smart", "comprehensive", "full"], + value=self.get_config_value('file_filtering_level', 'smart'), + label="", + info="Controls which files are extracted from EPUBs", + interactive=True + ) + + gr.Markdown("โ€ข **Smart (Aggressive Filtering)** - Skips navigation, TOC, copyright files") + gr.Markdown("โ€ข **Moderate** - Only skips obvious navigation files") + gr.Markdown("โ€ข **Full (No Filtering)** - Extracts ALL HTML/XHTML files") + + gr.Markdown("---") + gr.Markdown("#### Response Handling & Retry Logic") + + with gr.Row(): + with gr.Column(): + gr.Markdown("**GPT-5 Thinking (OpenRouter/OpenAI-style)**") + enable_gpt_thinking = gr.Checkbox( + label="Enable GPT / OR Thinking", + value=self.get_config_value('enable_gpt_thinking', True), + info="Controls GPT-5 and OpenRouter reasoning" + ) + + with gr.Row(): + gpt_thinking_effort = gr.Dropdown( + choices=["low", "medium", "high"], + value=self.get_config_value('gpt_thinking_effort', 'medium'), + label="Effort", + interactive=True + ) + + or_thinking_tokens = gr.Number( + label="OR Thinking Tokens", + value=self.get_config_value('or_thinking_tokens', 2000), + minimum=0, + maximum=50000, + info="tokens" + ) + + gr.Markdown("*Provide Tokens to force a max token budget for other models; GPT-5 only uses Effort (low/medium/high)*", elem_classes=["markdown-small"]) + + with gr.Column(): + gr.Markdown("**Gemini Thinking Mode**") + enable_gemini_thinking = gr.Checkbox( + label="Enable Gemini Thinking", + value=self.get_config_value('enable_gemini_thinking', False), + info="Control Gemini's thinking process", + interactive=True + ) + + gemini_thinking_budget = gr.Number( + label="Budget", + value=self.get_config_value('gemini_thinking_budget', 0), + minimum=0, + maximum=50000, + info="tokens (0 = disabled)", + interactive=True + ) + + gr.Markdown("*0 = disabled, 512-24576 = limited thinking*", elem_classes=["markdown-small"]) + + gr.Markdown("---") + gr.Markdown("๐Ÿ”’ **API keys are encrypted** when saved to config using AES encryption.") + + save_api_key = gr.Checkbox( + label="Save API Key (Encrypted)", + value=True + ) + + save_status = gr.Textbox(label="Settings Status", value="Use the 'Save Config' button to save changes", interactive=False) + + # Hidden HTML component for JavaScript execution + js_executor = gr.HTML("", visible=False) + + # Auto-save function for settings tab + def save_settings_tab(thread_delay_val, api_delay_val, chapter_range_val, token_limit_val, disable_token_limit_val, output_token_limit_val, contextual_val, history_limit_val, rolling_history_val, batch_translation_val, batch_size_val, save_api_key_val): + """Save settings from the Settings tab""" + try: + current_config = self.get_current_config_for_update() + # Don't decrypt - just update non-encrypted fields + + # Update settings + current_config['thread_submission_delay'] = float(thread_delay_val) + current_config['api_call_delay'] = float(api_delay_val) + current_config['chapter_range'] = str(chapter_range_val) + current_config['token_limit'] = int(token_limit_val) + current_config['token_limit_disabled'] = bool(disable_token_limit_val) + current_config['max_output_tokens'] = int(output_token_limit_val) + current_config['contextual'] = bool(contextual_val) + current_config['translation_history_limit'] = int(history_limit_val) + current_config['translation_history_rolling'] = bool(rolling_history_val) + current_config['batch_translation'] = bool(batch_translation_val) + current_config['batch_size'] = int(batch_size_val) + + # CRITICAL: Update environment variables immediately + os.environ['SEND_INTERVAL_SECONDS'] = str(api_delay_val) + os.environ['THREAD_SUBMISSION_DELAY'] = str(thread_delay_val) + print(f"โœ… Updated SEND_INTERVAL_SECONDS = {api_delay_val}s") + print(f"โœ… Updated THREAD_SUBMISSION_DELAY = {thread_delay_val}s") + + # Save to file + self.save_config(current_config) + + # JavaScript to save to localStorage + js_code = """ + <script> + (function() { + // Save individual settings to localStorage + window.saveToLocalStorage('thread_delay', %f); + window.saveToLocalStorage('api_delay', %f); + window.saveToLocalStorage('chapter_range', '%s'); + window.saveToLocalStorage('token_limit', %d); + window.saveToLocalStorage('disable_token_limit', %s); + window.saveToLocalStorage('output_token_limit', %d); + window.saveToLocalStorage('contextual', %s); + window.saveToLocalStorage('history_limit', %d); + window.saveToLocalStorage('rolling_history', %s); + window.saveToLocalStorage('batch_translation', %s); + window.saveToLocalStorage('batch_size', %d); + console.log('Settings saved to localStorage'); + })(); + </script> + """ % ( + thread_delay_val, api_delay_val, chapter_range_val, token_limit_val, + str(disable_token_limit_val).lower(), output_token_limit_val, + str(contextual_val).lower(), history_limit_val, + str(rolling_history_val).lower(), str(batch_translation_val).lower(), + batch_size_val + ) + + return "โœ… Settings saved successfully", js_code + except Exception as e: + return f"โŒ Failed to save: {str(e)}", "" + + # Settings tab auto-save handlers removed - use manual Save Config button + + # Token sync handlers removed - use manual Save Config button + + # Help Tab + with gr.Tab("โ“ Help"): + gr.Markdown(""" + ## How to Use Glossarion + + ### Translation + 1. Upload an EPUB file + 2. Select AI model (GPT-4, Claude, etc.) + 3. Enter your API key + 4. Click "Translate" + 5. Download the translated EPUB + + ### Manga Translation + 1. Upload manga image(s) (PNG, JPG, etc.) + 2. Select AI model and enter API key + 3. Choose translation profile (e.g., Manga_JP, Manga_KR) + 4. Configure OCR settings (Google Cloud Vision recommended) + 5. Enable bubble detection and inpainting for best results + 6. Click "Translate Manga" + + ### Glossary Extraction + 1. Upload an EPUB file + 2. Configure extraction settings + 3. Click "Extract Glossary" + 4. Use the CSV in future translations + + ### API Keys + - **OpenAI**: Get from https://platform.openai.com/api-keys + - **Anthropic**: Get from https://console.anthropic.com/ + + ### Translation Profiles + Profiles contain detailed translation instructions and rules. + Select a profile that matches your source language and style preferences. + + You can create and edit profiles in the desktop application. + + ### Tips + - Use glossaries for consistent character name translation + - Lower temperature (0.1-0.3) for more literal translations + - Higher temperature (0.5-0.7) for more creative translations + """) + + # Create a comprehensive load function that refreshes ALL values + def load_all_settings(): + """Load all settings from config file on page refresh""" + # Reload config to get latest values + self.config = self.load_config() + self.decrypted_config = decrypt_config(self.config.copy()) if API_KEY_ENCRYPTION_AVAILABLE else self.config.copy() + + # CRITICAL: Reload profiles from config after reloading config + self.profiles = self.default_prompts.copy() + config_profiles = self.config.get('prompt_profiles', {}) + if config_profiles: + self.profiles.update(config_profiles) + + # Helper function to convert RGB arrays to hex + def to_hex_color(color_value, default='#000000'): + if isinstance(color_value, (list, tuple)) and len(color_value) >= 3: + return '#{:02x}{:02x}{:02x}'.format(int(color_value[0]), int(color_value[1]), int(color_value[2])) + elif isinstance(color_value, str): + return color_value if color_value.startswith('#') else default + return default + + # Return values for all tracked components + return [ + self.get_config_value('model', 'gpt-4-turbo'), # epub_model + self.get_config_value('api_key', ''), # epub_api_key + self.get_config_value('active_profile', list(self.profiles.keys())[0] if self.profiles else ''), # epub_profile + self.profiles.get(self.get_config_value('active_profile', ''), ''), # epub_system_prompt + self.get_config_value('temperature', 0.3), # epub_temperature + self.get_config_value('max_output_tokens', 16000), # epub_max_tokens + self.get_config_value('enable_image_translation', False), # enable_image_translation + self.get_config_value('enable_auto_glossary', False), # enable_auto_glossary + self.get_config_value('append_glossary_to_prompt', True), # append_glossary + # Auto glossary settings + self.get_config_value('glossary_min_frequency', 2), # auto_glossary_min_freq + self.get_config_value('glossary_max_names', 50), # auto_glossary_max_names + self.get_config_value('glossary_max_titles', 30), # auto_glossary_max_titles + self.get_config_value('glossary_batch_size', 50), # auto_glossary_batch_size + self.get_config_value('glossary_filter_mode', 'all'), # auto_glossary_filter_mode + self.get_config_value('glossary_fuzzy_threshold', 0.90), # auto_glossary_fuzzy_threshold + # Manual glossary extraction settings + self.get_config_value('manual_glossary_min_frequency', self.get_config_value('glossary_min_frequency', 2)), # min_freq + self.get_config_value('manual_glossary_max_names', self.get_config_value('glossary_max_names', 50)), # max_names_slider + self.get_config_value('manual_glossary_max_titles', self.get_config_value('glossary_max_titles', 30)), # max_titles + self.get_config_value('glossary_max_text_size', 50000), # max_text_size + self.get_config_value('glossary_max_sentences', 200), # max_sentences + self.get_config_value('manual_glossary_batch_size', self.get_config_value('glossary_batch_size', 50)), # translation_batch + self.get_config_value('glossary_chapter_split_threshold', 8192), # chapter_split_threshold + self.get_config_value('manual_glossary_filter_mode', self.get_config_value('glossary_filter_mode', 'all')), # filter_mode + self.get_config_value('strip_honorifics', True), # strip_honorifics + self.get_config_value('manual_glossary_fuzzy_threshold', self.get_config_value('glossary_fuzzy_threshold', 0.90)), # fuzzy_threshold + # Chapter processing options + self.get_config_value('batch_translate_headers', False), # batch_translate_headers + self.get_config_value('headers_per_batch', 400), # headers_per_batch + self.get_config_value('use_ncx_navigation', False), # use_ncx_navigation + self.get_config_value('attach_css_to_chapters', False), # attach_css_to_chapters + self.get_config_value('retain_source_extension', True), # retain_source_extension + self.get_config_value('use_conservative_batching', False), # use_conservative_batching + self.get_config_value('disable_gemini_safety', False), # disable_gemini_safety + self.get_config_value('use_http_openrouter', False), # use_http_openrouter + self.get_config_value('disable_openrouter_compression', False), # disable_openrouter_compression + self.get_config_value('text_extraction_method', 'standard'), # text_extraction_method + self.get_config_value('file_filtering_level', 'smart'), # file_filtering_level + # QA report format + self.get_config_value('qa_report_format', 'detailed'), # report_format + # Thinking mode settings + self.get_config_value('enable_gpt_thinking', True), # enable_gpt_thinking + self.get_config_value('gpt_thinking_effort', 'medium'), # gpt_thinking_effort + self.get_config_value('or_thinking_tokens', 2000), # or_thinking_tokens + self.get_config_value('enable_gemini_thinking', False), # enable_gemini_thinking - disabled by default + self.get_config_value('gemini_thinking_budget', 0), # gemini_thinking_budget - 0 = disabled + # Manga settings + self.get_config_value('model', 'gpt-4-turbo'), # manga_model + self.get_config_value('api_key', ''), # manga_api_key + self.get_config_value('active_profile', list(self.profiles.keys())[0] if self.profiles else ''), # manga_profile + self.profiles.get(self.get_config_value('active_profile', ''), ''), # manga_system_prompt + self.get_config_value('ocr_provider', 'custom-api'), # ocr_provider + self.get_config_value('azure_vision_key', ''), # azure_key + self.get_config_value('azure_vision_endpoint', ''), # azure_endpoint + self.get_config_value('bubble_detection_enabled', True), # bubble_detection + self.get_config_value('inpainting_enabled', True), # inpainting + self.get_config_value('manga_font_size_mode', 'auto'), # font_size_mode + self.get_config_value('manga_font_size', 24), # font_size + self.get_config_value('manga_font_multiplier', 1.0), # font_multiplier + self.get_config_value('manga_min_font_size', 12), # min_font_size + self.get_config_value('manga_max_font_size', 48), # max_font_size + # Convert colors to hex format if they're stored as RGB arrays (white text, black shadow like manga integration) + to_hex_color(self.get_config_value('manga_text_color', [255, 255, 255]), '#FFFFFF'), # text_color_rgb - default white + self.get_config_value('manga_shadow_enabled', True), # shadow_enabled + to_hex_color(self.get_config_value('manga_shadow_color', [0, 0, 0]), '#000000'), # shadow_color - default black + self.get_config_value('manga_shadow_offset_x', 2), # shadow_offset_x + self.get_config_value('manga_shadow_offset_y', 2), # shadow_offset_y + self.get_config_value('manga_shadow_blur', 0), # shadow_blur + self.get_config_value('manga_bg_opacity', 130), # bg_opacity + self.get_config_value('manga_bg_style', 'circle'), # bg_style + self.get_config_value('manga_settings', {}).get('advanced', {}).get('parallel_panel_translation', False), # parallel_panel_translation + self.get_config_value('manga_settings', {}).get('advanced', {}).get('panel_max_workers', 7), # panel_max_workers + ] + + + # SECURITY: Save Config button DISABLED to prevent API keys from being saved to persistent storage on HF Spaces + # This is a critical security measure to prevent API key leakage in shared environments + # save_config_btn.click( + # fn=save_all_config, + # inputs=[ + # # EPUB tab fields + # epub_model, epub_api_key, epub_profile, epub_temperature, epub_max_tokens, + # enable_image_translation, enable_auto_glossary, append_glossary, + # # Auto glossary settings + # auto_glossary_min_freq, auto_glossary_max_names, auto_glossary_max_titles, + # auto_glossary_batch_size, auto_glossary_filter_mode, auto_glossary_fuzzy_threshold, + # enable_post_translation_scan, + # # Manual glossary extraction settings + # min_freq, max_names_slider, max_titles, + # max_text_size, max_sentences, translation_batch, + # chapter_split_threshold, filter_mode, strip_honorifics, + # fuzzy_threshold, extraction_prompt, format_instructions, + # use_legacy_csv, + # # QA Scanner settings + # min_foreign_chars, check_repetition, check_glossary_leakage, + # min_file_length, check_multiple_headers, check_missing_html, + # check_insufficient_paragraphs, min_paragraph_percentage, + # report_format, auto_save_report, + # # Chapter processing options + # batch_translate_headers, headers_per_batch, use_ncx_navigation, + # attach_css_to_chapters, retain_source_extension, + # use_conservative_batching, disable_gemini_safety, + # use_http_openrouter, disable_openrouter_compression, + # text_extraction_method, file_filtering_level, + # # Thinking mode settings + # enable_gpt_thinking, gpt_thinking_effort, or_thinking_tokens, + # enable_gemini_thinking, gemini_thinking_budget, + # # Manga tab fields + # manga_model, manga_api_key, manga_profile, + # ocr_provider, azure_key, azure_endpoint, + # bubble_detection, inpainting, + # font_size_mode, font_size, font_multiplier, min_font_size, max_font_size, + # text_color_rgb, shadow_enabled, shadow_color, + # shadow_offset_x, shadow_offset_y, shadow_blur, + # bg_opacity, bg_style, + # parallel_panel_translation, panel_max_workers, + # # Advanced Settings fields + # detector_type, rtdetr_confidence, bubble_confidence, + # detect_text_bubbles, detect_empty_bubbles, detect_free_text, bubble_max_detections, + # local_inpaint_method, webtoon_mode, + # inpaint_batch_size, inpaint_cache_enabled, + # parallel_processing, max_workers, + # preload_local_inpainting, panel_start_stagger, + # torch_precision, auto_cleanup_models, + # debug_mode, save_intermediate, concise_pipeline_logs + # ], + # outputs=[save_status_text] + # ) + + # Add load handler to restore settings on page load + app.load( + fn=load_all_settings, + inputs=[], + outputs=[ + epub_model, epub_api_key, epub_profile, epub_system_prompt, epub_temperature, epub_max_tokens, + enable_image_translation, enable_auto_glossary, append_glossary, + # Auto glossary settings + auto_glossary_min_freq, auto_glossary_max_names, auto_glossary_max_titles, + auto_glossary_batch_size, auto_glossary_filter_mode, auto_glossary_fuzzy_threshold, + # Manual glossary extraction settings + min_freq, max_names_slider, max_titles, + max_text_size, max_sentences, translation_batch, + chapter_split_threshold, filter_mode, strip_honorifics, + fuzzy_threshold, + # Chapter processing options + batch_translate_headers, headers_per_batch, use_ncx_navigation, + attach_css_to_chapters, retain_source_extension, + use_conservative_batching, disable_gemini_safety, + use_http_openrouter, disable_openrouter_compression, + text_extraction_method, file_filtering_level, + report_format, + # Thinking mode settings + enable_gpt_thinking, gpt_thinking_effort, or_thinking_tokens, + enable_gemini_thinking, gemini_thinking_budget, + # Manga settings + manga_model, manga_api_key, manga_profile, manga_system_prompt, + ocr_provider, azure_key, azure_endpoint, bubble_detection, inpainting, + font_size_mode, font_size, font_multiplier, min_font_size, max_font_size, + text_color_rgb, shadow_enabled, shadow_color, shadow_offset_x, shadow_offset_y, + shadow_blur, bg_opacity, bg_style, parallel_panel_translation, panel_max_workers + ] + ) + + return app + + +def main(): + """Launch Gradio web app""" + print("๐Ÿš€ Starting Glossarion Web Interface...") + + # Check if running on Hugging Face Spaces + is_spaces = os.getenv('SPACE_ID') is not None or os.getenv('HF_SPACES') == 'true' + if is_spaces: + print("๐Ÿค— Running on Hugging Face Spaces") + print(f"๐Ÿ“ Space ID: {os.getenv('SPACE_ID', 'Unknown')}") + print(f"๐Ÿ“ Files in current directory: {len(os.listdir('.'))} items") + print(f"๐Ÿ“ Working directory: {os.getcwd()}") + print(f"๐Ÿ˜Ž Available manga modules: {MANGA_TRANSLATION_AVAILABLE}") + else: + print("๐Ÿ  Running locally") + + web_app = GlossarionWeb() + app = web_app.create_interface() + + # Set favicon with absolute path if available (skip for Spaces) + favicon_path = None + if not is_spaces and os.path.exists("Halgakos.ico"): + favicon_path = os.path.abspath("Halgakos.ico") + print(f"โœ… Using favicon: {favicon_path}") + elif not is_spaces: + print("โš ๏ธ Halgakos.ico not found") + + # Launch with options appropriate for environment + launch_args = { + "server_name": "0.0.0.0", # Allow external access + "server_port": 7860, + "share": False, + "show_error": True, + } + + # Only add favicon for non-Spaces environments + if not is_spaces and favicon_path: + launch_args["favicon_path"] = favicon_path + + app.launch(**launch_args) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/manga_integration.py b/manga_integration.py new file mode 100644 index 0000000000000000000000000000000000000000..33d26d18dfe3adfc5ce389dce9cbfa3e0e4ca54f --- /dev/null +++ b/manga_integration.py @@ -0,0 +1,8457 @@ +# manga_integration.py +""" +Enhanced GUI Integration module for Manga Translation with text visibility controls +Integrates with TranslatorGUI using WindowManager and existing infrastructure +Now includes full page context mode with customizable prompt +""" +import sys +import os +import json +import threading +import time +import hashlib +import traceback +import concurrent.futures +from PySide6.QtWidgets import (QWidget, QLabel, QFrame, QPushButton, QVBoxLayout, QHBoxLayout, + QGroupBox, QListWidget, QComboBox, QLineEdit, QCheckBox, + QRadioButton, QSlider, QSpinBox, QDoubleSpinBox, QTextEdit, + QProgressBar, QFileDialog, QMessageBox, QColorDialog, QScrollArea, + QDialog, QButtonGroup, QApplication) +from PySide6.QtCore import Qt, QTimer, Signal, QObject, Slot, QEvent +from PySide6.QtGui import QFont, QColor, QTextCharFormat, QIcon, QKeyEvent +import tkinter as tk +from tkinter import ttk, filedialog as tk_filedialog, messagebox as tk_messagebox, scrolledtext +try: + import ttkbootstrap as tb +except ImportError: + tb = ttk +from typing import List, Dict, Optional, Any +from queue import Queue +import logging +from manga_translator import MangaTranslator, GOOGLE_CLOUD_VISION_AVAILABLE +from manga_settings_dialog import MangaSettingsDialog + + +# Try to import UnifiedClient for API initialization +try: + from unified_api_client import UnifiedClient +except ImportError: + UnifiedClient = None + +# Module-level function for multiprocessing (must be picklable) +def _preload_models_worker(models_list, progress_queue): + """Worker function to preload models in separate process (module-level for pickling)""" + try: + total_steps = len(models_list) + + for idx, (model_type, model_key, model_name, model_path) in enumerate(models_list): + try: + # Send start progress + base_progress = int((idx / total_steps) * 100) + progress_queue.put(('progress', base_progress, model_name)) + + if model_type == 'detector': + from bubble_detector import BubbleDetector + from manga_translator import MangaTranslator + + # Progress: 0-25% of this model's portion + progress_queue.put(('progress', base_progress + int(25 / total_steps), f"{model_name} - Initializing")) + bd = BubbleDetector() + + # Progress: 25-75% - loading model + progress_queue.put(('progress', base_progress + int(50 / total_steps), f"{model_name} - Downloading/Loading")) + if model_key == 'rtdetr_onnx': + model_repo = model_path if model_path else 'ogkalu/comic-text-and-bubble-detector' + bd.load_rtdetr_onnx_model(model_repo) + elif model_key == 'rtdetr': + bd.load_rtdetr_model() + elif model_key == 'yolo': + if model_path: + bd.load_model(model_path) + + # Progress: 75-100% - finalizing + progress_queue.put(('progress', base_progress + int(75 / total_steps), f"{model_name} - Finalizing")) + progress_queue.put(('loaded', model_type, model_name)) + + elif model_type == 'inpainter': + from local_inpainter import LocalInpainter + + # Progress: 0-25% + progress_queue.put(('progress', base_progress + int(25 / total_steps), f"{model_name} - Initializing")) + inp = LocalInpainter() + resolved_path = model_path + + if not resolved_path or not os.path.exists(resolved_path): + # Progress: 25-50% - downloading + progress_queue.put(('progress', base_progress + int(40 / total_steps), f"{model_name} - Downloading")) + try: + resolved_path = inp.download_jit_model(model_key) + except: + resolved_path = None + + if resolved_path and os.path.exists(resolved_path): + # Progress: 50-90% - loading + progress_queue.put(('progress', base_progress + int(60 / total_steps), f"{model_name} - Loading model")) + success = inp.load_model_with_retry(model_key, resolved_path) + + # Progress: 90-100% - finalizing + progress_queue.put(('progress', base_progress + int(85 / total_steps), f"{model_name} - Finalizing")) + if success: + progress_queue.put(('loaded', model_type, model_name)) + + except Exception as e: + progress_queue.put(('error', model_name, str(e))) + + # Send completion signal + progress_queue.put(('complete', None, None)) + + except Exception as e: + progress_queue.put(('error', 'Process', str(e))) + +class _MangaGuiLogHandler(logging.Handler): + """Forward logging records into MangaTranslationTab._log.""" + def __init__(self, gui_ref, level=logging.INFO): + super().__init__(level) + self.gui_ref = gui_ref + self._last_msg = None + self.setFormatter(logging.Formatter('%(levelname)s:%(name)s:%(message)s')) + + def emit(self, record: logging.LogRecord) -> None: + # Avoid looping/duplicates from this module's own messages or when stdio is redirected + try: + if getattr(self.gui_ref, '_stdio_redirect_active', False): + return + # Filter out manga_translator, bubble_detector, local_inpainter logs as they're already shown + if record and isinstance(record.name, str): + if record.name.startswith(('manga_integration', 'manga_translator', 'bubble_detector', 'local_inpainter', 'unified_api_client', 'google_genai', 'httpx')): + return + except Exception: + pass + try: + msg = self.format(record) + except Exception: + msg = record.getMessage() + # Deduplicate identical consecutive messages + if msg == self._last_msg: + return + self._last_msg = msg + + # Map logging levels to our tag levels + lvl = record.levelname.lower() + tag = 'info' + if lvl.startswith('warn'): + tag = 'warning' + elif lvl.startswith('err') or lvl.startswith('crit'): + tag = 'error' + elif lvl.startswith('debug'): + tag = 'debug' + elif lvl.startswith('info'): + tag = 'info' + + # Always store to persistent log (even if GUI is closed) + try: + with MangaTranslationTab._persistent_log_lock: + if len(MangaTranslationTab._persistent_log) >= 1000: + MangaTranslationTab._persistent_log.pop(0) + MangaTranslationTab._persistent_log.append((msg, tag)) + except Exception: + pass + + # Also try to display in GUI if it exists + try: + if hasattr(self.gui_ref, '_log'): + self.gui_ref._log(msg, tag) + except Exception: + pass + +class _StreamToGuiLog: + """A minimal file-like stream that forwards lines to _log.""" + def __init__(self, write_cb): + self._write_cb = write_cb + self._buf = '' + + def write(self, s: str): + try: + self._buf += s + while '\n' in self._buf: + line, self._buf = self._buf.split('\n', 1) + if line.strip(): + self._write_cb(line) + except Exception: + pass + + def flush(self): + try: + if self._buf.strip(): + self._write_cb(self._buf) + self._buf = '' + except Exception: + pass + +class MangaTranslationTab: + """GUI interface for manga translation integrated with TranslatorGUI""" + + # Class-level cancellation flag for all instances + _global_cancelled = False + _global_cancel_lock = threading.RLock() + + # Class-level log storage to persist across window closures + _persistent_log = [] + _persistent_log_lock = threading.RLock() + + # Class-level preload tracking to prevent duplicate loading + _preload_in_progress = False + _preload_lock = threading.RLock() + _preload_completed_models = set() # Track which models have been loaded + + @classmethod + def set_global_cancellation(cls, cancelled: bool): + """Set global cancellation flag for all translation instances""" + with cls._global_cancel_lock: + cls._global_cancelled = cancelled + + @classmethod + def is_globally_cancelled(cls) -> bool: + """Check if globally cancelled""" + with cls._global_cancel_lock: + return cls._global_cancelled + + def __init__(self, parent_widget, main_gui, dialog, scroll_area=None): + """Initialize manga translation interface + + Args: + parent_widget: The content widget for the interface (PySide6 QWidget) + main_gui: Reference to TranslatorGUI instance + dialog: The dialog window (PySide6 QDialog) + scroll_area: The scroll area widget (PySide6 QScrollArea, optional) + """ + # CRITICAL: Set thread limits FIRST before any imports or processing + import os + parallel_enabled = main_gui.config.get('manga_settings', {}).get('advanced', {}).get('parallel_processing', False) + if not parallel_enabled: + # Force single-threaded mode for all libraries + os.environ['OMP_NUM_THREADS'] = '1' + os.environ['MKL_NUM_THREADS'] = '1' + os.environ['OPENBLAS_NUM_THREADS'] = '1' + os.environ['NUMEXPR_NUM_THREADS'] = '1' + os.environ['VECLIB_MAXIMUM_THREADS'] = '1' + os.environ['ONNXRUNTIME_NUM_THREADS'] = '1' + # Also set torch and cv2 thread limits if already imported + try: + import torch + torch.set_num_threads(1) + except (ImportError, RuntimeError): + pass + try: + import cv2 + cv2.setNumThreads(1) + except (ImportError, AttributeError): + pass + + self.parent_widget = parent_widget + self.main_gui = main_gui + self.dialog = dialog + self.scroll_area = scroll_area + + # Translation state + self.translator = None + self.is_running = False + self.stop_flag = threading.Event() + self.translation_thread = None + self.translation_future = None + # Shared executor from main GUI if available + try: + if hasattr(self.main_gui, 'executor') and self.main_gui.executor: + self.executor = self.main_gui.executor + else: + self.executor = None + except Exception: + self.executor = None + self.selected_files = [] + self.current_file_index = 0 + self.font_mapping = {} # Initialize font mapping dictionary + + + # Progress tracking + self.total_files = 0 + self.completed_files = 0 + self.failed_files = 0 + self.qwen2vl_model_size = self.main_gui.config.get('qwen2vl_model_size', '1') + + # Advanced performance toggles + try: + adv_cfg = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) + except Exception: + adv_cfg = {} + # In singleton mode, reduce OpenCV thread usage to avoid CPU spikes + try: + if bool(adv_cfg.get('use_singleton_models', False)): + import cv2 as _cv2 + try: + _cv2.setNumThreads(1) + except Exception: + pass + except Exception: + pass + # Do NOT preload big local models by default to avoid startup crashes + self.preload_local_models_on_open = bool(adv_cfg.get('preload_local_models_on_open', False)) + + # Queue for thread-safe GUI updates + self.update_queue = Queue() + + # Flags for stdio redirection to avoid duplicate GUI logs + self._stdout_redirect_on = False + self._stderr_redirect_on = False + self._stdio_redirect_active = False + + # Flag to prevent saving during initialization + self._initializing = True + + # IMPORTANT: Load settings BEFORE building interface + # This ensures all variables are initialized before they're used in the GUI + self._load_rendering_settings() + + # Initialize the full page context prompt + self.full_page_context_prompt = ( + "You will receive multiple text segments from a manga page, each prefixed with an index like [0], [1], etc. " + "Translate each segment considering the context of all segments together. " + "Maintain consistency in character names, tone, and style across all translations.\n\n" + "CRITICAL: Return your response as a valid JSON object where each key includes BOTH the index prefix " + "AND the original text EXACTLY as provided (e.g., '[0] ใ“ใ‚“ใซใกใฏ'), and each value is the translation.\n" + "This is essential for correct mapping - do not modify or omit the index prefixes!\n\n" + "Make sure to properly escape any special characters in the JSON:\n" + "- Use \\n for newlines\n" + "- Use \\\" for quotes\n" + "- Use \\\\ for backslashes\n\n" + "Example:\n" + '{\n' + ' "[0] ใ“ใ‚“ใซใกใฏ": "Hello",\n' + ' "[1] ใ‚ใ‚ŠใŒใจใ†": "Thank you",\n' + ' "[2] ใ•ใ‚ˆใ†ใชใ‚‰": "Goodbye"\n' + '}\n\n' + 'REMEMBER: Keep the [index] prefix in each JSON key exactly as shown in the input!' + ) + + # Initialize the OCR system prompt + self.ocr_prompt = self.main_gui.config.get('manga_ocr_prompt', + "YOU ARE AN OCR SYSTEM. YOUR ONLY JOB IS TEXT EXTRACTION.\n\n" + "CRITICAL RULES:\n" + "1. DO NOT TRANSLATE ANYTHING\n" + "2. DO NOT MODIFY THE TEXT\n" + "3. DO NOT EXPLAIN OR COMMENT\n" + "4. ONLY OUTPUT THE EXACT TEXT YOU SEE\n" + "5. PRESERVE NATURAL TEXT FLOW - DO NOT ADD UNNECESSARY LINE BREAKS\n\n" + "If you see Korean text, output it in Korean.\n" + "If you see Japanese text, output it in Japanese.\n" + "If you see Chinese text, output it in Chinese.\n" + "If you see English text, output it in English.\n\n" + "IMPORTANT: Only use line breaks where they naturally occur in the original text " + "(e.g., between dialogue lines or paragraphs). Do not break text mid-sentence or " + "between every word/character.\n\n" + "For vertical text common in manga/comics, transcribe it as a continuous line unless " + "there are clear visual breaks.\n\n" + "NEVER translate. ONLY extract exactly what is written.\n" + "Output ONLY the raw text, nothing else." + ) + + # flag to skip status checks during init + self._initializing_gui = True + + # Build interface AFTER loading settings + self._build_interface() + + # Now allow status checks + self._initializing_gui = False + + # Do one status check after everything is built + # Use QTimer for PySide6 dialog + QTimer.singleShot(100, self._check_provider_status) + + # Start model preloading in background + QTimer.singleShot(200, self._start_model_preloading) + + # Now that everything is initialized, allow saving + self._initializing = False + + # Attach logging bridge so library logs appear in our log area + self._attach_logging_bridge() + + # Start update loop + self._process_updates() + + # Install event filter for F11 fullscreen toggle + self._install_fullscreen_handler() + + def _is_stop_requested(self) -> bool: + """Check if stop has been requested using multiple sources""" + # Check global cancellation first + if self.is_globally_cancelled(): + return True + + # Check local stop flag + if hasattr(self, 'stop_flag') and self.stop_flag.is_set(): + return True + + # Check running state + if hasattr(self, 'is_running') and not self.is_running: + return True + + return False + + def _reset_global_cancellation(self): + """Reset all global cancellation flags for new translation""" + # Reset local class flag + self.set_global_cancellation(False) + + # Reset MangaTranslator class flag + try: + from manga_translator import MangaTranslator + MangaTranslator.set_global_cancellation(False) + except ImportError: + pass + + # Reset UnifiedClient flag + try: + from unified_api_client import UnifiedClient + UnifiedClient.set_global_cancellation(False) + except ImportError: + pass + + def reset_stop_flags(self): + """Reset all stop flags when starting new translation""" + self.is_running = False + if hasattr(self, 'stop_flag'): + self.stop_flag.clear() + self._reset_global_cancellation() + self._log("๐Ÿ”„ Stop flags reset for new translation", "debug") + + def _install_fullscreen_handler(self): + """Install event filter to handle F11 key for fullscreen toggle""" + if not self.dialog: + return + + # Create event filter for the dialog + class FullscreenEventFilter(QObject): + def __init__(self, dialog_ref): + super().__init__() + self.dialog = dialog_ref + self.is_fullscreen = False + self.normal_geometry = None + + def eventFilter(self, obj, event): + if event.type() == QEvent.KeyPress: + key_event = event + if key_event.key() == Qt.Key_F11: + self.toggle_fullscreen() + return True + return False + + def toggle_fullscreen(self): + if self.is_fullscreen: + # Exit fullscreen + self.dialog.setWindowState(self.dialog.windowState() & ~Qt.WindowFullScreen) + if self.normal_geometry: + self.dialog.setGeometry(self.normal_geometry) + self.is_fullscreen = False + else: + # Enter fullscreen + self.normal_geometry = self.dialog.geometry() + self.dialog.setWindowState(self.dialog.windowState() | Qt.WindowFullScreen) + self.is_fullscreen = True + + # Create and install the event filter + self._fullscreen_filter = FullscreenEventFilter(self.dialog) + self.dialog.installEventFilter(self._fullscreen_filter) + + def _distribute_stop_flags(self): + """Distribute stop flags to all manga translation components""" + if not hasattr(self, 'translator') or not self.translator: + return + + # Set stop flag on translator + if hasattr(self.translator, 'set_stop_flag'): + self.translator.set_stop_flag(self.stop_flag) + + # Set stop flag on OCR manager and all providers + if hasattr(self.translator, 'ocr_manager') and self.translator.ocr_manager: + if hasattr(self.translator.ocr_manager, 'set_stop_flag'): + self.translator.ocr_manager.set_stop_flag(self.stop_flag) + + # Set stop flag on bubble detector if available + if hasattr(self.translator, 'bubble_detector') and self.translator.bubble_detector: + if hasattr(self.translator.bubble_detector, 'set_stop_flag'): + self.translator.bubble_detector.set_stop_flag(self.stop_flag) + + # Set stop flag on local inpainter if available + if hasattr(self.translator, 'local_inpainter') and self.translator.local_inpainter: + if hasattr(self.translator.local_inpainter, 'set_stop_flag'): + self.translator.local_inpainter.set_stop_flag(self.stop_flag) + + # Also try to set on thread-local components if accessible + if hasattr(self.translator, '_thread_local'): + thread_local = self.translator._thread_local + # Set on thread-local bubble detector + if hasattr(thread_local, 'bubble_detector') and thread_local.bubble_detector: + if hasattr(thread_local.bubble_detector, 'set_stop_flag'): + thread_local.bubble_detector.set_stop_flag(self.stop_flag) + + # Set on thread-local inpainters + if hasattr(thread_local, 'local_inpainters') and isinstance(thread_local.local_inpainters, dict): + for inpainter in thread_local.local_inpainters.values(): + if hasattr(inpainter, 'set_stop_flag'): + inpainter.set_stop_flag(self.stop_flag) + + self._log("๐Ÿ”„ Stop flags distributed to all components", "debug") + + def _preflight_bubble_detector(self, ocr_settings: dict) -> bool: + """Check if bubble detector is preloaded in the pool or already loaded. + Returns True if a ready instance or preloaded spare is available; no heavy loads are performed here. + """ + try: + import time as _time + start = _time.time() + if not ocr_settings.get('bubble_detection_enabled', False): + return False + det_type = ocr_settings.get('detector_type', 'rtdetr_onnx') + model_id = ocr_settings.get('rtdetr_model_url') or ocr_settings.get('bubble_model_path') or '' + + # 1) If translator already has a ready detector, report success + try: + bd = getattr(self, 'translator', None) and getattr(self.translator, 'bubble_detector', None) + if bd and (getattr(bd, 'rtdetr_loaded', False) or getattr(bd, 'rtdetr_onnx_loaded', False) or getattr(bd, 'model_loaded', False)): + self._log("๐Ÿค– Bubble detector already loaded", "debug") + return True + except Exception: + pass + + # 2) Check shared preload pool for spares + try: + from manga_translator import MangaTranslator + key = (det_type, model_id) + with MangaTranslator._detector_pool_lock: + rec = MangaTranslator._detector_pool.get(key) + spares = (rec or {}).get('spares') or [] + if len(spares) > 0: + self._log(f"๐Ÿค– Preflight: found {len(spares)} preloaded bubble detector spare(s) for key={key}", "info") + return True + except Exception: + pass + + # 3) No spares/ready detector yet; do not load here. Just report timing and return False. + elapsed = _time.time() - start + self._log(f"โฑ๏ธ Preflight checked bubble detector pool in {elapsed:.2f}s โ€” no ready instance", "debug") + return False + except Exception: + return False + + def _start_model_preloading(self): + """Start preloading models in separate process for true background loading""" + from multiprocessing import Process, Queue as MPQueue + import queue + + # Check if preload is already in progress + with MangaTranslationTab._preload_lock: + if MangaTranslationTab._preload_in_progress: + print("Model preloading already in progress, skipping...") + return + + # Get settings + manga_settings = self.main_gui.config.get('manga_settings', {}) + ocr_settings = manga_settings.get('ocr', {}) + inpaint_settings = manga_settings.get('inpainting', {}) + + models_to_load = [] + bubble_detection_enabled = ocr_settings.get('bubble_detection_enabled', False) + skip_inpainting = self.main_gui.config.get('manga_skip_inpainting', False) + inpainting_method = inpaint_settings.get('method', 'local') + inpainting_enabled = not skip_inpainting and inpainting_method == 'local' + + # Check if models need loading + try: + from manga_translator import MangaTranslator + + if bubble_detection_enabled: + detector_type = ocr_settings.get('detector_type', 'rtdetr_onnx') + model_url = ocr_settings.get('rtdetr_model_url') or ocr_settings.get('bubble_model_path') or '' + key = (detector_type, model_url) + model_id = f"detector_{detector_type}_{model_url}" + + # Skip if already loaded in this session + if model_id not in MangaTranslationTab._preload_completed_models: + with MangaTranslator._detector_pool_lock: + rec = MangaTranslator._detector_pool.get(key) + if not rec or (not rec.get('spares') and not rec.get('loaded')): + detector_name = 'RT-DETR ONNX' if detector_type == 'rtdetr_onnx' else 'RT-DETR' if detector_type == 'rtdetr' else 'YOLO' + models_to_load.append(('detector', detector_type, detector_name, model_url)) + + if inpainting_enabled: + # Check top-level config first (manga_local_inpaint_model), then nested config + local_method = self.main_gui.config.get('manga_local_inpaint_model', + inpaint_settings.get('local_method', 'anime_onnx')) + model_path = self.main_gui.config.get(f'manga_{local_method}_model_path', '') + # Fallback to non-prefixed key if not found + if not model_path: + model_path = self.main_gui.config.get(f'{local_method}_model_path', '') + key = (local_method, model_path or '') + model_id = f"inpainter_{local_method}_{model_path}" + + # Skip if already loaded in this session + if model_id not in MangaTranslationTab._preload_completed_models: + with MangaTranslator._inpaint_pool_lock: + rec = MangaTranslator._inpaint_pool.get(key) + if not rec or (not rec.get('loaded') and not rec.get('spares')): + models_to_load.append(('inpainter', local_method, local_method.capitalize(), model_path)) + except Exception as e: + print(f"Error checking models: {e}") + return + + if not models_to_load: + return + + # Set preload in progress flag + with MangaTranslationTab._preload_lock: + MangaTranslationTab._preload_in_progress = True + + # Show progress bar + self.preload_progress_frame.setVisible(True) + + # Create queue for IPC + progress_queue = MPQueue() + + # Start loading in separate process using module-level function + load_process = Process(target=_preload_models_worker, args=(models_to_load, progress_queue), daemon=True) + load_process.start() + + # Store models being loaded for tracking + models_being_loaded = [] + for model_type, model_key, model_name, model_path in models_to_load: + if model_type == 'detector': + models_being_loaded.append(f"detector_{model_key}_{model_path}") + elif model_type == 'inpainter': + models_being_loaded.append(f"inpainter_{model_key}_{model_path}") + + # Monitor progress with QTimer + def check_progress(): + try: + while True: + try: + msg = progress_queue.get_nowait() + msg_type = msg[0] + + if msg_type == 'progress': + _, progress, model_name = msg + self.preload_progress_bar.setValue(progress) + self.preload_status_label.setText(f"Loading {model_name}...") + + elif msg_type == 'loaded': + _, model_type, model_name = msg + print(f"โœ“ Loaded {model_name}") + + elif msg_type == 'error': + _, model_name, error = msg + print(f"โœ— Failed to load {model_name}: {error}") + + elif msg_type == 'complete': + # Child process cached models + self.preload_progress_bar.setValue(100) + self.preload_status_label.setText("โœ“ Models ready") + + # Mark all models as completed + with MangaTranslationTab._preload_lock: + MangaTranslationTab._preload_completed_models.update(models_being_loaded) + MangaTranslationTab._preload_in_progress = False + + # Load RT-DETR into pool in background (doesn't block GUI) + def load_rtdetr_bg(): + try: + from manga_translator import MangaTranslator + from bubble_detector import BubbleDetector + + for model_type, model_key, model_name, model_path in models_to_load: + if model_type == 'detector' and model_key == 'rtdetr_onnx': + key = (model_key, model_path) + + # Check if already loaded + with MangaTranslator._detector_pool_lock: + rec = MangaTranslator._detector_pool.get(key) + if rec and rec.get('spares'): + print(f"โญ๏ธ {model_name} already in pool") + continue + + # Load into pool + bd = BubbleDetector() + model_repo = model_path if model_path else 'ogkalu/comic-text-and-bubble-detector' + bd.load_rtdetr_onnx_model(model_repo) + + with MangaTranslator._detector_pool_lock: + rec = MangaTranslator._detector_pool.get(key) + if not rec: + rec = {'spares': []} + MangaTranslator._detector_pool[key] = rec + rec['spares'].append(bd) + print(f"โœ“ Loaded {model_name} into pool (background)") + except Exception as e: + print(f"โœ— Background RT-DETR loading error: {e}") + + # Start background loading + threading.Thread(target=load_rtdetr_bg, daemon=True).start() + + QTimer.singleShot(2000, lambda: self.preload_progress_frame.setVisible(False)) + return + + except queue.Empty: + break + + QTimer.singleShot(100, check_progress) + + except Exception as e: + print(f"Progress check error: {e}") + self.preload_progress_frame.setVisible(False) + # Reset flag on error + with MangaTranslationTab._preload_lock: + MangaTranslationTab._preload_in_progress = False + + QTimer.singleShot(100, check_progress) + + def _disable_spinbox_mousewheel(self, spinbox): + """Disable mousewheel scrolling on a spinbox (PySide6)""" + # Override wheelEvent to prevent scrolling + spinbox.wheelEvent = lambda event: None + + def _disable_combobox_mousewheel(self, combobox): + """Disable mousewheel scrolling on a combobox (PySide6)""" + # Override wheelEvent to prevent scrolling + combobox.wheelEvent = lambda event: None + + def _create_styled_checkbox(self, text): + """Create a checkbox with proper checkmark using text overlay""" + from PySide6.QtWidgets import QCheckBox, QLabel + from PySide6.QtCore import Qt, QTimer + from PySide6.QtGui import QFont + + checkbox = QCheckBox(text) + checkbox.setStyleSheet(""" + QCheckBox { + color: white; + spacing: 6px; + } + QCheckBox::indicator { + width: 14px; + height: 14px; + border: 1px solid #5a9fd4; + border-radius: 2px; + background-color: #2d2d2d; + } + QCheckBox::indicator:checked { + background-color: #5a9fd4; + border-color: #5a9fd4; + } + QCheckBox::indicator:hover { + border-color: #7bb3e0; + } + QCheckBox:disabled { + color: #666666; + } + QCheckBox::indicator:disabled { + background-color: #1a1a1a; + border-color: #3a3a3a; + } + """) + + # Create checkmark overlay + checkmark = QLabel("โœ“", checkbox) + checkmark.setStyleSheet(""" + QLabel { + color: white; + background: transparent; + font-weight: bold; + font-size: 11px; + } + """) + checkmark.setAlignment(Qt.AlignCenter) + checkmark.hide() + checkmark.setAttribute(Qt.WA_TransparentForMouseEvents) # Make checkmark click-through + + # Position checkmark properly after widget is shown + def position_checkmark(): + # Position over the checkbox indicator + checkmark.setGeometry(2, 1, 14, 14) + + # Show/hide checkmark based on checked state + def update_checkmark(): + if checkbox.isChecked(): + position_checkmark() + checkmark.show() + else: + checkmark.hide() + + checkbox.stateChanged.connect(update_checkmark) + # Delay initial positioning to ensure widget is properly rendered + QTimer.singleShot(0, lambda: (position_checkmark(), update_checkmark())) + + return checkbox + + def _download_hf_model(self): + """Download HuggingFace models with progress tracking - PySide6 version""" + from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QRadioButton, QButtonGroup, QLineEdit, QPushButton, + QGroupBox, QTextEdit, QProgressBar, QFrame, + QScrollArea, QWidget, QSizePolicy) + from PySide6.QtCore import Qt, QThread, Signal, QTimer + from PySide6.QtGui import QFont + + provider = self.ocr_provider_value + + # Model sizes (approximate in MB) + model_sizes = { + 'manga-ocr': 450, + 'Qwen2-VL': { + '2B': 4000, + '7B': 14000, + '72B': 144000, + 'custom': 10000 # Default estimate for custom models + } + } + + # For Qwen2-VL, show model selection dialog first + if provider == 'Qwen2-VL': + # Create PySide6 dialog + selection_dialog = QDialog(self.dialog) + selection_dialog.setWindowTitle("Select Qwen2-VL Model Size") + selection_dialog.setMinimumSize(600, 500) + main_layout = QVBoxLayout(selection_dialog) + + # Title + title_label = QLabel("Select Qwen2-VL Model Size") + title_font = QFont("Arial", 14, QFont.Weight.Bold) + title_label.setFont(title_font) + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + main_layout.addWidget(title_label) + + # Model selection frame + model_frame = QGroupBox("Model Options") + model_frame_font = QFont("Arial", 11, QFont.Weight.Bold) + model_frame.setFont(model_frame_font) + model_frame_layout = QVBoxLayout(model_frame) + model_frame_layout.setContentsMargins(15, 15, 15, 15) + model_frame_layout.setSpacing(10) + + model_options = { + "2B": { + "title": "2B Model", + "desc": "โ€ข Smallest model (~4GB download, 4-8GB VRAM)\nโ€ข Fast but less accurate\nโ€ข Good for quick testing" + }, + "7B": { + "title": "7B Model", + "desc": "โ€ข Medium model (~14GB download, 12-16GB VRAM)\nโ€ข Best balance of speed and quality\nโ€ข Recommended for most users" + }, + "72B": { + "title": "72B Model", + "desc": "โ€ข Largest model (~144GB download, 80GB+ VRAM)\nโ€ข Highest quality but very slow\nโ€ข Requires high-end GPU" + }, + "custom": { + "title": "Custom Model", + "desc": "โ€ข Enter any Hugging Face model ID\nโ€ข For advanced users\nโ€ข Size varies by model" + } + } + + # Store selected model + selected_model_key = {"value": "2B"} + custom_model_id_text = {"value": ""} + + # Radio button group + button_group = QButtonGroup(selection_dialog) + + for idx, (key, info) in enumerate(model_options.items()): + # Radio button + rb = QRadioButton(info["title"]) + rb_font = QFont("Arial", 11, QFont.Weight.Bold) + rb.setFont(rb_font) + if idx == 0: + rb.setChecked(True) + rb.clicked.connect(lambda checked, k=key: selected_model_key.update({"value": k})) + button_group.addButton(rb) + model_frame_layout.addWidget(rb) + + # Description + desc_label = QLabel(info["desc"]) + desc_font = QFont("Arial", 9) + desc_label.setFont(desc_font) + desc_label.setStyleSheet("color: #666666; margin-left: 20px;") + model_frame_layout.addWidget(desc_label) + + # Separator + if key != "custom": + separator = QFrame() + separator.setFrameShape(QFrame.Shape.HLine) + separator.setFrameShadow(QFrame.Shadow.Sunken) + model_frame_layout.addWidget(separator) + + main_layout.addWidget(model_frame) + + # Custom model ID frame (initially hidden) + custom_frame = QGroupBox("Custom Model ID") + custom_frame_font = QFont("Arial", 11, QFont.Weight.Bold) + custom_frame.setFont(custom_frame_font) + custom_frame_layout = QHBoxLayout(custom_frame) + custom_frame_layout.setContentsMargins(15, 15, 15, 15) + + custom_label = QLabel("Model ID:") + custom_label_font = QFont("Arial", 10) + custom_label.setFont(custom_label_font) + custom_frame_layout.addWidget(custom_label) + + custom_entry = QLineEdit() + custom_entry.setPlaceholderText("e.g., Qwen/Qwen2-VL-2B-Instruct") + custom_entry.setFont(custom_label_font) + custom_entry.textChanged.connect(lambda text: custom_model_id_text.update({"value": text})) + custom_frame_layout.addWidget(custom_entry) + + custom_frame.hide() # Hidden by default + main_layout.addWidget(custom_frame) + + # Toggle custom frame visibility + def toggle_custom_frame(): + if selected_model_key["value"] == "custom": + custom_frame.show() + else: + custom_frame.hide() + + for rb in button_group.buttons(): + rb.clicked.connect(toggle_custom_frame) + + # GPU status frame + gpu_frame = QGroupBox("System Status") + gpu_frame_font = QFont("Arial", 11, QFont.Weight.Bold) + gpu_frame.setFont(gpu_frame_font) + gpu_frame_layout = QVBoxLayout(gpu_frame) + gpu_frame_layout.setContentsMargins(15, 15, 15, 15) + + try: + import torch + if torch.cuda.is_available(): + gpu_mem = torch.cuda.get_device_properties(0).total_memory / 1e9 + gpu_text = f"โœ“ GPU: {torch.cuda.get_device_name(0)} ({gpu_mem:.1f}GB)" + gpu_color = '#4CAF50' + else: + gpu_text = "โœ— No GPU detected - will use CPU (very slow)" + gpu_color = '#f44336' + except: + gpu_text = "? GPU status unknown - install torch with CUDA" + gpu_color = '#FF9800' + + gpu_label = QLabel(gpu_text) + gpu_label_font = QFont("Arial", 10) + gpu_label.setFont(gpu_label_font) + gpu_label.setStyleSheet(f"color: {gpu_color};") + gpu_frame_layout.addWidget(gpu_label) + + main_layout.addWidget(gpu_frame) + + # Buttons + button_layout = QHBoxLayout() + button_layout.addStretch() + + model_confirmed = {'value': False, 'model_key': None, 'model_id': None} + + def confirm_selection(): + selected = selected_model_key["value"] + if selected == "custom": + if not custom_model_id_text["value"].strip(): + from PySide6.QtWidgets import QMessageBox + QMessageBox.critical(selection_dialog, "Error", "Please enter a model ID") + return + model_confirmed['model_key'] = selected + model_confirmed['model_id'] = custom_model_id_text["value"].strip() + else: + model_confirmed['model_key'] = selected + model_confirmed['model_id'] = f"Qwen/Qwen2-VL-{selected}-Instruct" + model_confirmed['value'] = True + selection_dialog.accept() + + proceed_btn = QPushButton("Continue") + proceed_btn.setStyleSheet("QPushButton { background-color: #4CAF50; color: white; padding: 8px 20px; font-weight: bold; }") + proceed_btn.clicked.connect(confirm_selection) + button_layout.addWidget(proceed_btn) + + cancel_btn = QPushButton("Cancel") + cancel_btn.setStyleSheet("QPushButton { background-color: #9E9E9E; color: white; padding: 8px 20px; }") + cancel_btn.clicked.connect(selection_dialog.reject) + button_layout.addWidget(cancel_btn) + + button_layout.addStretch() + main_layout.addLayout(button_layout) + + # Show dialog and wait for result + result = selection_dialog.exec() + + if not model_confirmed['value'] or result == QDialog.DialogCode.Rejected: + return + + selected_model_key = model_confirmed['model_key'] + model_id = model_confirmed['model_id'] + total_size_mb = model_sizes['Qwen2-VL'][selected_model_key] + elif provider == 'rapidocr': + total_size_mb = 50 # Approximate size for display + model_id = None + selected_model_key = None + else: + total_size_mb = model_sizes.get(provider, 500) + model_id = None + selected_model_key = None + + # Create download dialog with window manager - pass Tkinter root instead of PySide6 dialog + download_dialog, scrollable_frame, canvas = self.main_gui.wm.setup_scrollable( + self.main_gui.master, + f"Download {provider} Model", + width=600, + height=450, + max_width_ratio=0.6, + max_height_ratio=0.6 + ) + + # Info section + info_frame = tk.LabelFrame( + scrollable_frame, + text="Model Information", + font=('Arial', 11, 'bold'), + padx=15, + pady=10 + ) + info_frame.pack(fill=tk.X, padx=20, pady=10) + + if provider == 'Qwen2-VL': + info_text = f"๐Ÿ“š Qwen2-VL {selected_model_key} Model\n" + info_text += f"Model ID: {model_id}\n" + info_text += f"Estimated size: ~{total_size_mb/1000:.1f}GB\n" + info_text += "Vision-Language model for Korean OCR" + else: + info_text = f"๐Ÿ“š {provider} Model\nOptimized for manga/manhwa text detection" + + tk.Label(info_frame, text=info_text, font=('Arial', 10), justify=tk.LEFT).pack(anchor='w') + + # Progress section + progress_frame = tk.LabelFrame( + scrollable_frame, + text="Download Progress", + font=('Arial', 11, 'bold'), + padx=15, + pady=10 + ) + progress_frame.pack(fill=tk.X, padx=20, pady=10) + + progress_label = tk.Label(progress_frame, text="Ready to download", font=('Arial', 10)) + progress_label.pack(pady=(5, 10)) + + progress_var = tk.DoubleVar() + try: + # Try to use our custom progress bar style + progress_bar = ttk.Progressbar(progress_frame, length=550, mode='determinate', + variable=progress_var, + style="MangaProgress.Horizontal.TProgressbar") + except Exception: + # Fallback to default if style not available yet + progress_bar = ttk.Progressbar(progress_frame, length=550, mode='determinate', + variable=progress_var) + progress_bar.pack(pady=(0, 5)) + + size_label = tk.Label(progress_frame, text="", font=('Arial', 9), fg='#666666') + size_label.pack() + + speed_label = tk.Label(progress_frame, text="", font=('Arial', 9), fg='#666666') + speed_label.pack() + + status_label = tk.Label(progress_frame, text="Click 'Download' to begin", + font=('Arial', 9), fg='#666666') + status_label.pack(pady=(5, 0)) + + # Log section + log_frame = tk.LabelFrame( + scrollable_frame, + text="Download Log", + font=('Arial', 11, 'bold'), + padx=15, + pady=10 + ) + log_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=10) + + # Create a frame to hold the text widget and scrollbar + text_frame = tk.Frame(log_frame) + text_frame.pack(fill=tk.BOTH, expand=True) + + details_text = tk.Text( + text_frame, + height=12, + width=70, + font=('Courier', 9), + bg='#f5f5f5' + ) + details_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Attach scrollbar to the frame, not the text widget + scrollbar = ttk.Scrollbar(text_frame, command=details_text.yview) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + details_text.config(yscrollcommand=scrollbar.set) + + def add_log(message): + """Add message to log""" + details_text.insert(tk.END, f"{message}\n") + details_text.see(tk.END) + details_text.update() + + # Buttons frame + button_frame = tk.Frame(download_dialog) + button_frame.pack(pady=15) + + # Download tracking variables + download_active = {'value': False} + + def get_dir_size(path): + """Get total size of directory""" + total = 0 + try: + for dirpath, dirnames, filenames in os.walk(path): + for filename in filenames: + filepath = os.path.join(dirpath, filename) + if os.path.exists(filepath): + total += os.path.getsize(filepath) + except: + pass + return total + + def download_with_progress(): + """Download model with real progress tracking""" + import time + + download_active['value'] = True + total_size = total_size_mb * 1024 * 1024 + + try: + if provider == 'manga-ocr': + progress_label.config(text="Downloading manga-ocr model...") + add_log("Downloading manga-ocr model from Hugging Face...") + add_log("This will download ~450MB of model files") + progress_var.set(10) + + try: + from huggingface_hub import snapshot_download + + # Download the model files directly without importing manga_ocr + model_repo = "kha-white/manga-ocr-base" + add_log(f"Repository: {model_repo}") + + cache_dir = os.path.expanduser("~/.cache/huggingface/hub") + initial_size = get_dir_size(cache_dir) if os.path.exists(cache_dir) else 0 + start_time = time.time() + + add_log("Starting download...") + progress_var.set(20) + + # Download with progress tracking + import threading + download_complete = threading.Event() + download_error = [None] + + def download_model(): + try: + snapshot_download( + repo_id=model_repo, + repo_type="model", + resume_download=True, + local_files_only=False + ) + download_complete.set() + except Exception as e: + download_error[0] = e + download_complete.set() + + download_thread = threading.Thread(target=download_model, daemon=True) + download_thread.start() + + # Show progress while downloading + while not download_complete.is_set() and download_active['value']: + current_size = get_dir_size(cache_dir) if os.path.exists(cache_dir) else 0 + downloaded = current_size - initial_size + + if downloaded > 0: + progress = min(20 + (downloaded / total_size) * 70, 95) + progress_var.set(progress) + + elapsed = time.time() - start_time + if elapsed > 1: + speed = downloaded / elapsed + speed_mb = speed / (1024 * 1024) + speed_label.config(text=f"Speed: {speed_mb:.1f} MB/s") + + mb_downloaded = downloaded / (1024 * 1024) + mb_total = total_size / (1024 * 1024) + size_label.config(text=f"{mb_downloaded:.1f} MB / {mb_total:.1f} MB") + progress_label.config(text=f"Downloading: {progress:.1f}%") + + time.sleep(0.5) + + download_thread.join(timeout=5) + + if download_error[0]: + raise download_error[0] + + if download_complete.is_set() and not download_error[0]: + progress_var.set(100) + progress_label.config(text="โœ… Download complete!") + status_label.config(text="Model files downloaded") + add_log("โœ… Model files downloaded successfully") + add_log("") + add_log("Next step: Click 'Load Model' to initialize manga-ocr") + # Schedule status check on main thread + self.update_queue.put(('call_method', self._check_provider_status, ())) + else: + raise Exception("Download was cancelled") + + except ImportError: + progress_label.config(text="โŒ Missing huggingface_hub") + status_label.config(text="Install huggingface_hub first") + add_log("ERROR: huggingface_hub not installed") + add_log("Run: pip install huggingface_hub") + except Exception as e: + raise # Re-raise to be caught by outer exception handler + + elif provider == 'Qwen2-VL': + try: + from transformers import AutoProcessor, AutoTokenizer, AutoModelForVision2Seq + import torch + except ImportError as e: + progress_label.config(text="โŒ Missing dependencies") + status_label.config(text="Install dependencies first") + add_log(f"ERROR: {str(e)}") + add_log("Please install manually:") + add_log("pip install transformers torch torchvision") + return + + progress_label.config(text=f"Downloading model...") + add_log(f"Starting download of {model_id}") + progress_var.set(10) + + add_log("Downloading processor...") + status_label.config(text="Downloading processor...") + processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=True) + progress_var.set(30) + add_log("โœ“ Processor downloaded") + + add_log("Downloading tokenizer...") + status_label.config(text="Downloading tokenizer...") + tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True) + progress_var.set(50) + add_log("โœ“ Tokenizer downloaded") + + add_log("Downloading model weights (this may take several minutes)...") + status_label.config(text="Downloading model weights...") + progress_label.config(text="Downloading model weights...") + + if torch.cuda.is_available(): + add_log(f"Using GPU: {torch.cuda.get_device_name(0)}") + model = AutoModelForVision2Seq.from_pretrained( + model_id, + dtype=torch.float16, + device_map="auto", + trust_remote_code=True + ) + else: + add_log("No GPU detected, will load on CPU") + model = AutoModelForVision2Seq.from_pretrained( + model_id, + dtype=torch.float32, + trust_remote_code=True + ) + + progress_var.set(90) + add_log("โœ“ Model weights downloaded") + + add_log("Initializing model...") + status_label.config(text="Initializing...") + + qwen_provider = self.ocr_manager.get_provider('Qwen2-VL') + if qwen_provider: + qwen_provider.processor = processor + qwen_provider.tokenizer = tokenizer + qwen_provider.model = model + qwen_provider.model.eval() + qwen_provider.is_loaded = True + qwen_provider.is_installed = True + + if selected_model_key: + qwen_provider.loaded_model_size = selected_model_key + + progress_var.set(100) + progress_label.config(text="โœ… Download complete!") + status_label.config(text="Model ready for Korean OCR!") + add_log("โœ“ Model ready to use!") + + # Schedule status check on main thread + self.update_queue.put(('call_method', self._check_provider_status, ())) + + elif provider == 'rapidocr': + progress_label.config(text="๐Ÿ“ฆ RapidOCR Installation Instructions") + add_log("RapidOCR requires manual pip installation") + progress_var.set(20) + + add_log("Command to run:") + add_log("pip install rapidocr-onnxruntime") + progress_var.set(50) + + add_log("") + add_log("After installation:") + add_log("1. Close this dialog") + add_log("2. Click 'Load Model' to initialize RapidOCR") + add_log("3. Status should show 'โœ… Model loaded'") + progress_var.set(100) + + progress_label.config(text="๐Ÿ“ฆ Installation instructions shown") + status_label.config(text="Manual pip install required") + + download_btn.config(state=tk.DISABLED) + cancel_btn.config(text="Close") + + except Exception as e: + progress_label.config(text="โŒ Download failed") + status_label.config(text=f"Error: {str(e)[:50]}") + add_log(f"ERROR: {str(e)}") + self._log(f"Download error: {str(e)}", "error") + + finally: + download_active['value'] = False + + def start_download(): + """Start download in background thread or executor""" + download_btn.config(state=tk.DISABLED) + cancel_btn.config(text="Cancel") + + try: + if hasattr(self.main_gui, '_ensure_executor'): + self.main_gui._ensure_executor() + execu = getattr(self.main_gui, 'executor', None) + if execu: + execu.submit(download_with_progress) + else: + import threading + download_thread = threading.Thread(target=download_with_progress, daemon=True) + download_thread.start() + except Exception: + import threading + download_thread = threading.Thread(target=download_with_progress, daemon=True) + download_thread.start() + + def cancel_download(): + """Cancel or close dialog""" + if download_active['value']: + download_active['value'] = False + status_label.config(text="Cancelling...") + else: + download_dialog.destroy() + + download_btn = tb.Button(button_frame, text="Download", command=start_download, bootstyle="primary") + download_btn.pack(side=tk.LEFT, padx=5) + + cancel_btn = tb.Button(button_frame, text="Close", command=cancel_download, bootstyle="secondary") + cancel_btn.pack(side=tk.LEFT, padx=5) + + # Auto-resize + self.main_gui.wm.auto_resize_dialog(download_dialog, canvas, max_width_ratio=0.5, max_height_ratio=0.6) + + def _check_provider_status(self): + """Check and display OCR provider status""" + # Skip during initialization to prevent lag + if hasattr(self, '_initializing_gui') and self._initializing_gui: + if hasattr(self, 'provider_status_label'): + self.provider_status_label.setText("") + self.provider_status_label.setStyleSheet("color: black;") + return + + # Get provider value + if not hasattr(self, 'ocr_provider_value'): + # Not initialized yet, skip + return + provider = self.ocr_provider_value + + # Hide ALL buttons first + if hasattr(self, 'provider_setup_btn'): + self.provider_setup_btn.setVisible(False) + if hasattr(self, 'download_model_btn'): + self.download_model_btn.setVisible(False) + + if provider == 'google': + # Google - check for credentials file + google_creds = self.main_gui.config.get('google_vision_credentials', '') + if google_creds and os.path.exists(google_creds): + self.provider_status_label.setText("โœ… Ready") + self.provider_status_label.setStyleSheet("color: green;") + else: + self.provider_status_label.setText("โŒ Credentials needed") + self.provider_status_label.setStyleSheet("color: red;") + + elif provider == 'azure': + # Azure - check for API key + azure_key = self.main_gui.config.get('azure_vision_key', '') + if azure_key: + self.provider_status_label.setText("โœ… Ready") + self.provider_status_label.setStyleSheet("color: green;") + else: + self.provider_status_label.setText("โŒ Key needed") + self.provider_status_label.setStyleSheet("color: red;") + + elif provider == 'custom-api': + # Custom API - check for main API key + api_key = None + if hasattr(self.main_gui, 'api_key_entry') and self.main_gui.api_key_entry.get().strip(): + api_key = self.main_gui.api_key_entry.get().strip() + elif hasattr(self.main_gui, 'config') and self.main_gui.config.get('api_key'): + api_key = self.main_gui.config.get('api_key') + + # Check if AI bubble detection is enabled + manga_settings = self.main_gui.config.get('manga_settings', {}) + ocr_settings = manga_settings.get('ocr', {}) + bubble_detection_enabled = ocr_settings.get('bubble_detection_enabled', False) + + if api_key: + if bubble_detection_enabled: + self.provider_status_label.setText("โœ… Ready") + self.provider_status_label.setStyleSheet("color: green;") + else: + self.provider_status_label.setText("โš ๏ธ Enable AI bubble detection for best results") + self.provider_status_label.setStyleSheet("color: orange;") + else: + self.provider_status_label.setText("โŒ API key needed") + self.provider_status_label.setStyleSheet("color: red;") + + elif provider == 'Qwen2-VL': + # Initialize OCR manager if needed + if not hasattr(self, 'ocr_manager'): + from ocr_manager import OCRManager + self.ocr_manager = OCRManager(log_callback=self._log) + + # Check status first + status = self.ocr_manager.check_provider_status(provider) + + # Load saved model size if available + if hasattr(self, 'qwen2vl_model_size'): + saved_model_size = self.qwen2vl_model_size + else: + saved_model_size = self.main_gui.config.get('qwen2vl_model_size', '1') + + # When displaying status for loaded model + if status['loaded']: + # Map the saved size to display name + size_names = {'1': '2B', '2': '7B', '3': '72B', '4': 'custom'} + display_size = size_names.get(saved_model_size, saved_model_size) + self.provider_status_label.setText(f"โœ… {display_size} model loaded") + self.provider_status_label.setStyleSheet("color: green;") + + # Show reload button + self.provider_setup_btn.setText("Reload") + self.provider_setup_btn.setVisible(True) + + elif status['installed']: + # Dependencies installed but model not loaded + self.provider_status_label.setText("๐Ÿ“ฆ Dependencies ready") + self.provider_status_label.setStyleSheet("color: orange;") + + # Show Load button + self.provider_setup_btn.setText("Load Model") + self.provider_setup_btn.setVisible(True) + + # Also show Download button + self.download_model_btn.setText("๐Ÿ“ฅ Download Model") + self.download_model_btn.setVisible(True) + + else: + # Not installed + self.provider_status_label.setText("โŒ Not installed") + self.provider_status_label.setStyleSheet("color: red;") + + # Show BOTH buttons + self.provider_setup_btn.setText("Load Model") + self.provider_setup_btn.setVisible(True) + + self.download_model_btn.setText("๐Ÿ“ฅ Download Qwen2-VL") + self.download_model_btn.setVisible(True) + + # Additional GPU status check for Qwen2-VL + if not status['loaded']: + try: + import torch + if not torch.cuda.is_available(): + self._log("โš ๏ธ No GPU detected - Qwen2-VL will run slowly on CPU", "warning") + except ImportError: + pass + + else: + # Local OCR providers + if not hasattr(self, 'ocr_manager'): + from ocr_manager import OCRManager + self.ocr_manager = OCRManager(log_callback=self._log) + + status = self.ocr_manager.check_provider_status(provider) + + if status['loaded']: + # Model is loaded and ready + if provider == 'Qwen2-VL': + # Check which model size is loaded + qwen_provider = self.ocr_manager.get_provider('Qwen2-VL') + if qwen_provider and hasattr(qwen_provider, 'loaded_model_size'): + model_size = qwen_provider.loaded_model_size + status_text = f"โœ… {model_size} model loaded" + else: + status_text = "โœ… Model loaded" + self.provider_status_label.setText(status_text) + self.provider_status_label.setStyleSheet("color: green;") + else: + self.provider_status_label.setText("โœ… Model loaded") + self.provider_status_label.setStyleSheet("color: green;") + + # Show reload button for all local providers + self.provider_setup_btn.setText("Reload") + self.provider_setup_btn.setVisible(True) + + elif status['installed']: + # Dependencies installed but model not loaded + self.provider_status_label.setText("๐Ÿ“ฆ Dependencies ready") + self.provider_status_label.setStyleSheet("color: orange;") + + # Show Load button for all providers + self.provider_setup_btn.setText("Load Model") + self.provider_setup_btn.setVisible(True) + + # Also show Download button for models that need downloading + if provider in ['Qwen2-VL', 'manga-ocr']: + self.download_model_btn.setText("๐Ÿ“ฅ Download Model") + self.download_model_btn.setVisible(True) + + else: + # Not installed + self.provider_status_label.setText("โŒ Not installed") + self.provider_status_label.setStyleSheet("color: red;") + + # Categorize providers + huggingface_providers = ['manga-ocr', 'Qwen2-VL', 'rapidocr'] # Move rapidocr here + pip_providers = ['easyocr', 'paddleocr', 'doctr'] # Remove rapidocr from here + + if provider in huggingface_providers: + # For HuggingFace models, show BOTH buttons + self.provider_setup_btn.setText("Load Model") + self.provider_setup_btn.setVisible(True) + + # Download button + if provider == 'rapidocr': + self.download_model_btn.setText("๐Ÿ“ฅ Install RapidOCR") + else: + self.download_model_btn.setText(f"๐Ÿ“ฅ Download {provider}") + self.download_model_btn.setVisible(True) + + elif provider in pip_providers: + # Check if running as .exe + if getattr(sys, 'frozen', False): + # Running as .exe - can't pip install + self.provider_status_label.setText("โŒ Not available in .exe") + self.provider_status_label.setStyleSheet("color: red;") + self._log(f"โš ๏ธ {provider} cannot be installed in standalone .exe version", "warning") + else: + # Running from Python - can pip install + self.provider_setup_btn.setText("Install") + self.provider_setup_btn.setVisible(True) + + def _setup_ocr_provider(self): + """Setup/install/load OCR provider""" + provider = self.ocr_provider_value + + if provider in ['google', 'azure']: + return # Cloud providers don't need setup + + # your own api key + if provider == 'custom-api': + # Open configuration dialog for custom API + try: + from custom_api_config_dialog import CustomAPIConfigDialog + dialog = CustomAPIConfigDialog( + self.manga_window, + self.main_gui.config, + self.main_gui.save_config + ) + # After dialog closes, refresh status + from PySide6.QtCore import QTimer + QTimer.singleShot(100, self._check_provider_status) + except ImportError: + # If dialog not available, show message + from PySide6.QtWidgets import QMessageBox + from PySide6.QtCore import QTimer + QTimer.singleShot(0, lambda: QMessageBox.information( + self.dialog, + "Custom API Configuration", + "This mode uses your own API key in the main GUI:\n\n" + "- Make sure your API supports vision\n" + "- api_key: Your API key\n" + "- model: Model name\n" + "- custom url: You can override API endpoint under Other settings" + )) + return + + status = self.ocr_manager.check_provider_status(provider) + + # For Qwen2-VL, check if we need to select model size first + model_size = None + if provider == 'Qwen2-VL' and status['installed'] and not status['loaded']: + # Create PySide6 dialog for model selection + from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QRadioButton, QButtonGroup, QLineEdit, QPushButton, + QGroupBox, QFrame, QMessageBox) + from PySide6.QtCore import Qt + from PySide6.QtGui import QFont + + selection_dialog = QDialog(self.dialog) + selection_dialog.setWindowTitle("Select Qwen2-VL Model Size") + selection_dialog.setMinimumSize(600, 450) + main_layout = QVBoxLayout(selection_dialog) + + # Title + title_label = QLabel("Select Model Size to Load") + title_font = QFont("Arial", 12, QFont.Weight.Bold) + title_label.setFont(title_font) + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + main_layout.addWidget(title_label) + + # Model selection frame + model_frame = QGroupBox("Available Models") + model_frame_font = QFont("Arial", 11, QFont.Weight.Bold) + model_frame.setFont(model_frame_font) + model_frame_layout = QVBoxLayout(model_frame) + model_frame_layout.setContentsMargins(15, 15, 15, 15) + model_frame_layout.setSpacing(10) + + # Model options + model_options = { + "1": {"name": "Qwen2-VL 2B", "desc": "Smallest (4-8GB VRAM)"}, + "2": {"name": "Qwen2-VL 7B", "desc": "Medium (12-16GB VRAM)"}, + "3": {"name": "Qwen2-VL 72B", "desc": "Largest (80GB+ VRAM)"}, + "4": {"name": "Custom Model", "desc": "Enter any HF model ID"}, + } + + # Store selected model + selected_model_key = {"value": "1"} + custom_model_id_text = {"value": ""} + + # Radio button group + button_group = QButtonGroup(selection_dialog) + + for idx, (key, info) in enumerate(model_options.items()): + # Radio button + rb = QRadioButton(f"{info['name']} - {info['desc']}") + rb_font = QFont("Arial", 10) + rb.setFont(rb_font) + if idx == 0: + rb.setChecked(True) + rb.clicked.connect(lambda checked, k=key: selected_model_key.update({"value": k})) + button_group.addButton(rb) + model_frame_layout.addWidget(rb) + + # Separator + if key != "4": + separator = QFrame() + separator.setFrameShape(QFrame.Shape.HLine) + separator.setFrameShadow(QFrame.Shadow.Sunken) + model_frame_layout.addWidget(separator) + + main_layout.addWidget(model_frame) + + # Custom model ID frame (initially hidden) + custom_frame = QGroupBox("Custom Model Configuration") + custom_frame_font = QFont("Arial", 11, QFont.Weight.Bold) + custom_frame.setFont(custom_frame_font) + custom_frame_layout = QHBoxLayout(custom_frame) + custom_frame_layout.setContentsMargins(15, 15, 15, 15) + + custom_label = QLabel("Model ID:") + custom_label_font = QFont("Arial", 10) + custom_label.setFont(custom_label_font) + custom_frame_layout.addWidget(custom_label) + + custom_entry = QLineEdit() + custom_entry.setPlaceholderText("e.g., Qwen/Qwen2-VL-2B-Instruct") + custom_entry.setFont(custom_label_font) + custom_entry.textChanged.connect(lambda text: custom_model_id_text.update({"value": text})) + custom_frame_layout.addWidget(custom_entry) + + custom_frame.hide() # Hidden by default + main_layout.addWidget(custom_frame) + + # Toggle custom frame visibility + def toggle_custom_frame(): + if selected_model_key["value"] == "4": + custom_frame.show() + else: + custom_frame.hide() + + for rb in button_group.buttons(): + rb.clicked.connect(toggle_custom_frame) + + # Buttons with centering + button_layout = QHBoxLayout() + button_layout.addStretch() + + model_confirmed = {'value': False, 'size': None} + + def confirm_selection(): + selected = selected_model_key["value"] + self._log(f"DEBUG: Radio button selection = {selected}") + if selected == "4": + if not custom_model_id_text["value"].strip(): + QMessageBox.critical(selection_dialog, "Error", "Please enter a model ID") + return + model_confirmed['size'] = f"custom:{custom_model_id_text['value'].strip()}" + else: + model_confirmed['size'] = selected + model_confirmed['value'] = True + selection_dialog.accept() + + load_btn = QPushButton("Load") + load_btn.setStyleSheet("QPushButton { background-color: #4CAF50; color: white; padding: 8px 20px; font-weight: bold; }") + load_btn.clicked.connect(confirm_selection) + button_layout.addWidget(load_btn) + + cancel_btn = QPushButton("Cancel") + cancel_btn.setStyleSheet("QPushButton { background-color: #9E9E9E; color: white; padding: 8px 20px; }") + cancel_btn.clicked.connect(selection_dialog.reject) + button_layout.addWidget(cancel_btn) + + button_layout.addStretch() + main_layout.addLayout(button_layout) + + # Show dialog and wait for result (PySide6 modal dialog) + result = selection_dialog.exec() + + if result != QDialog.DialogCode.Accepted or not model_confirmed['value']: + return + + model_size = model_confirmed['size'] + self._log(f"DEBUG: Dialog closed, model_size set to: {model_size}") + + # Create PySide6 progress dialog + from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar, QGroupBox + from PySide6.QtCore import QTimer + from PySide6.QtGui import QFont + + progress_dialog = QDialog(self.dialog) + progress_dialog.setWindowTitle(f"Setting up {provider}") + progress_dialog.setMinimumSize(400, 200) + progress_layout = QVBoxLayout(progress_dialog) + + # Progress section + progress_section = QGroupBox("Setup Progress") + progress_section_font = QFont("Arial", 11, QFont.Weight.Bold) + progress_section.setFont(progress_section_font) + progress_section_layout = QVBoxLayout(progress_section) + progress_section_layout.setContentsMargins(15, 15, 15, 15) + progress_section_layout.setSpacing(10) + + progress_label = QLabel("Initializing...") + progress_label_font = QFont("Arial", 10) + progress_label.setFont(progress_label_font) + progress_section_layout.addWidget(progress_label) + + progress_bar = QProgressBar() + progress_bar.setMinimum(0) + progress_bar.setMaximum(0) # Indeterminate mode + progress_bar.setMinimumWidth(350) + progress_section_layout.addWidget(progress_bar) + + status_label = QLabel("") + status_label_font = QFont("Arial", 9) + status_label.setFont(status_label_font) + status_label.setStyleSheet("color: #666666;") + progress_section_layout.addWidget(status_label) + + progress_layout.addWidget(progress_section) + + def update_progress(message, percent=None): + """Update progress display (thread-safe)""" + # Use lambda to ensure we capture the correct widget references + def update_ui(): + progress_label.setText(message) + if percent is not None: + progress_bar.setMaximum(100) # Switch to determinate mode + progress_bar.setValue(int(percent)) + + # Schedule on main thread + self.update_queue.put(('call_method', update_ui, ())) + + def setup_thread(): + """Run setup in background thread""" + nonlocal model_size + print(f"\n=== SETUP THREAD STARTED for {provider} ===") + print(f"Status: {status}") + print(f"Model size: {model_size}") + + try: + # Check if we need to install + if not status['installed']: + # Install provider + print(f"Installing {provider}...") + update_progress(f"Installing {provider}...") + success = self.ocr_manager.install_provider(provider, update_progress) + print(f"Install result: {success}") + + if not success: + print("Installation FAILED") + update_progress("โŒ Installation failed!", 0) + self._log(f"Failed to install {provider}", "error") + return + else: + # Already installed, skip installation + print(f"{provider} dependencies already installed") + self._log(f"DEBUG: {provider} dependencies already installed") + success = True # Mark as success since deps are ready + + # Load model + print(f"About to load {provider} model...") + update_progress(f"Loading {provider} model...") + self._log(f"DEBUG: Loading provider {provider}, status['installed']={status.get('installed', False)}") + + # Special handling for Qwen2-VL - pass model_size + if provider == 'Qwen2-VL': + if success and model_size: + # Save the model size to config + self.qwen2vl_model_size = model_size + self.main_gui.config['qwen2vl_model_size'] = model_size + + # Save config immediately + if hasattr(self.main_gui, 'save_config'): + self.main_gui.save_config(show_message=False) + self._log(f"DEBUG: In thread, about to load with model_size={model_size}") + if model_size: + success = self.ocr_manager.load_provider(provider, model_size=model_size) + + if success: + provider_obj = self.ocr_manager.get_provider('Qwen2-VL') + if provider_obj: + provider_obj.loaded_model_size = { + "1": "2B", + "2": "7B", + "3": "72B", + "4": "custom" + }.get(model_size, model_size) + else: + self._log("Warning: No model size specified for Qwen2-VL, defaulting to 2B", "warning") + success = self.ocr_manager.load_provider(provider, model_size="1") + else: + print(f"Loading {provider} without model_size parameter") + self._log(f"DEBUG: Loading {provider} without model_size parameter") + success = self.ocr_manager.load_provider(provider) + print(f"load_provider returned: {success}") + self._log(f"DEBUG: load_provider returned success={success}") + + print(f"\nFinal success value: {success}") + if success: + print("SUCCESS! Model loaded successfully") + update_progress(f"โœ… {provider} ready!", 100) + self._log(f"โœ… {provider} is ready to use", "success") + # Schedule status check on main thread + self.update_queue.put(('call_method', self._check_provider_status, ())) + else: + print("FAILED! Model did not load") + update_progress("โŒ Failed to load model!", 0) + self._log(f"Failed to load {provider} model", "error") + + except Exception as e: + print(f"\n!!! EXCEPTION CAUGHT !!!") + print(f"Exception type: {type(e).__name__}") + print(f"Exception message: {str(e)}") + import traceback + traceback_str = traceback.format_exc() + print(f"Traceback:\n{traceback_str}") + + error_msg = f"โŒ Error: {str(e)}" + update_progress(error_msg, 0) + self._log(f"Setup error: {str(e)}", "error") + self._log(traceback_str, "debug") + # Don't close dialog on error - let user read the error + return + + # Only close dialog on success + if success: + # Schedule dialog close on main thread after 2 seconds + import time + time.sleep(2) + self.update_queue.put(('call_method', progress_dialog.close, ())) + else: + # On failure, keep dialog open so user can see the error + import time + time.sleep(5) + self.update_queue.put(('call_method', progress_dialog.close, ())) + + # Show progress dialog (non-blocking) + progress_dialog.show() + + # Start setup in background via executor if available + try: + if hasattr(self.main_gui, '_ensure_executor'): + self.main_gui._ensure_executor() + execu = getattr(self.main_gui, 'executor', None) + if execu: + execu.submit(setup_thread) + else: + import threading + threading.Thread(target=setup_thread, daemon=True).start() + except Exception: + import threading + threading.Thread(target=setup_thread, daemon=True).start() + + def _on_ocr_provider_change(self, event=None): + """Handle OCR provider change""" + # Get the new provider value from combo box + if hasattr(self, 'provider_combo'): + provider = self.provider_combo.currentText() + self.ocr_provider_value = provider + else: + provider = self.ocr_provider_value + + # Hide ALL provider-specific frames first (PySide6) + if hasattr(self, 'google_creds_frame'): + self.google_creds_frame.setVisible(False) + if hasattr(self, 'azure_frame'): + self.azure_frame.setVisible(False) + + # Show only the relevant settings frame for the selected provider + if provider == 'google': + # Show Google credentials frame + if hasattr(self, 'google_creds_frame'): + self.google_creds_frame.setVisible(True) + + elif provider == 'azure': + # Show Azure settings frame + if hasattr(self, 'azure_frame'): + self.azure_frame.setVisible(True) + + # For all other providers (manga-ocr, Qwen2-VL, easyocr, paddleocr, doctr) + # Don't show any cloud credential frames - they use local models + + # Check provider status to show appropriate buttons + self._check_provider_status() + + # Update the main status label at the top based on new provider + self._update_main_status_label() + + # Log the change + provider_descriptions = { + 'custom-api': "Custom API - use your own vision model", + 'google': "Google Cloud Vision (requires credentials)", + 'azure': "Azure Computer Vision (requires API key)", + 'manga-ocr': "Manga OCR - optimized for Japanese manga", + 'rapidocr': "RapidOCR - fast local OCR with region detection", + 'Qwen2-VL': "Qwen2-VL - a big model", + 'easyocr': "EasyOCR - multi-language support", + 'paddleocr': "PaddleOCR - CJK language support", + 'doctr': "DocTR - document text recognition" + } + + self._log(f"๐Ÿ“‹ OCR provider changed to: {provider_descriptions.get(provider, provider)}", "info") + + # Save the selection + self.main_gui.config['manga_ocr_provider'] = provider + if hasattr(self.main_gui, 'save_config'): + self.main_gui.save_config(show_message=False) + + # IMPORTANT: Reset translator to force recreation with new OCR provider + if hasattr(self, 'translator') and self.translator: + self._log(f"OCR provider changed to {provider.upper()}. Translator will be recreated on next run.", "info") + self.translator = None # Force recreation on next translation + + def _update_main_status_label(self): + """Update the main status label at the top based on current provider and credentials""" + if not hasattr(self, 'status_label'): + return + + # Get API key + try: + if hasattr(self.main_gui.api_key_entry, 'text'): + has_api_key = bool(self.main_gui.api_key_entry.text().strip()) + elif hasattr(self.main_gui.api_key_entry, 'get'): + has_api_key = bool(self.main_gui.api_key_entry.get().strip()) + else: + has_api_key = False + except: + has_api_key = False + + # Get current provider + provider = self.ocr_provider_value if hasattr(self, 'ocr_provider_value') else self.main_gui.config.get('manga_ocr_provider', 'custom-api') + + # Determine readiness based on provider + if provider == 'google': + has_vision = os.path.exists(self.main_gui.config.get('google_vision_credentials', '')) + is_ready = has_api_key and has_vision + elif provider == 'azure': + has_azure = bool(self.main_gui.config.get('azure_vision_key', '')) + is_ready = has_api_key and has_azure + else: + # Local providers or custom-api only need API key for translation + is_ready = has_api_key + + # Update label + status_text = "โœ… Ready" if is_ready else "โŒ Setup Required" + status_color = "green" if is_ready else "red" + + self.status_label.setText(status_text) + self.status_label.setStyleSheet(f"color: {status_color};") + + def _build_interface(self): + """Build the enhanced manga translation interface using PySide6""" + # Create main layout for PySide6 widget + main_layout = QVBoxLayout(self.parent_widget) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.setSpacing(6) + self._build_pyside6_interface(main_layout) + + def _build_pyside6_interface(self, main_layout): + # Import QSizePolicy for layout management + from PySide6.QtWidgets import QSizePolicy + + # Apply global stylesheet for checkboxes and radio buttons + checkbox_radio_style = """ + QCheckBox { + color: white; + spacing: 6px; + } + QCheckBox::indicator { + width: 14px; + height: 14px; + border: 1px solid #5a9fd4; + border-radius: 2px; + background-color: #2d2d2d; + } + QCheckBox::indicator:checked { + background-color: #5a9fd4; + border-color: #5a9fd4; + } + QCheckBox::indicator:hover { + border-color: #7bb3e0; + } + QCheckBox:disabled { + color: #666666; + } + QCheckBox::indicator:disabled { + background-color: #1a1a1a; + border-color: #3a3a3a; + } + QRadioButton { + color: white; + spacing: 5px; + } + QRadioButton::indicator { + width: 13px; + height: 13px; + border: 2px solid #5a9fd4; + border-radius: 7px; + background-color: #2d2d2d; + } + QRadioButton::indicator:checked { + background-color: #5a9fd4; + border: 2px solid #5a9fd4; + } + QRadioButton::indicator:hover { + border-color: #7bb3e0; + } + QRadioButton:disabled { + color: #666666; + } + QRadioButton::indicator:disabled { + background-color: #1a1a1a; + border-color: #3a3a3a; + } + /* Disabled fields styling */ + QLineEdit:disabled, QComboBox:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled { + background-color: #1a1a1a; + color: #666666; + border: 1px solid #3a3a3a; + } + QLabel:disabled { + color: #666666; + } + """ + self.parent_widget.setStyleSheet(checkbox_radio_style) + + # Title (at the very top) + title_frame = QWidget() + title_layout = QHBoxLayout(title_frame) + title_layout.setContentsMargins(0, 0, 0, 0) + title_layout.setSpacing(8) + + title_label = QLabel("๐ŸŽŒ Manga Translation") + title_font = QFont("Arial", 13) + title_font.setBold(True) + title_label.setFont(title_font) + title_layout.addWidget(title_label) + + # Requirements check - based on selected OCR provider + has_api_key = bool(self.main_gui.api_key_entry.text().strip()) if hasattr(self.main_gui.api_key_entry, 'text') else bool(self.main_gui.api_key_entry.get().strip()) + + # Get the saved OCR provider to check appropriate credentials + saved_provider = self.main_gui.config.get('manga_ocr_provider', 'custom-api') + + # Determine readiness based on provider + if saved_provider == 'google': + has_vision = os.path.exists(self.main_gui.config.get('google_vision_credentials', '')) + is_ready = has_api_key and has_vision + elif saved_provider == 'azure': + has_azure = bool(self.main_gui.config.get('azure_vision_key', '')) + is_ready = has_api_key and has_azure + else: + # Local providers or custom-api only need API key for translation + is_ready = has_api_key + + status_text = "โœ… Ready" if is_ready else "โŒ Setup Required" + status_color = "green" if is_ready else "red" + + status_label = QLabel(status_text) + status_font = QFont("Arial", 10) + status_label.setFont(status_font) + status_label.setStyleSheet(f"color: {status_color};") + title_layout.addStretch() + title_layout.addWidget(status_label) + + main_layout.addWidget(title_frame) + + # Store reference for updates + self.status_label = status_label + + # Model Preloading Progress Bar (right after title, initially hidden) + self.preload_progress_frame = QWidget() + self.preload_progress_frame.setStyleSheet( + "background-color: #2d2d2d; " + "border: 1px solid #4a5568; " + "border-radius: 4px; " + "padding: 6px;" + ) + preload_layout = QVBoxLayout(self.preload_progress_frame) + preload_layout.setContentsMargins(8, 6, 8, 6) + preload_layout.setSpacing(4) + + self.preload_status_label = QLabel("Loading models...") + preload_status_font = QFont("Segoe UI", 9) + preload_status_font.setBold(True) + self.preload_status_label.setFont(preload_status_font) + self.preload_status_label.setStyleSheet("color: #ffffff; background: transparent; border: none;") + self.preload_status_label.setAlignment(Qt.AlignCenter) + preload_layout.addWidget(self.preload_status_label) + + self.preload_progress_bar = QProgressBar() + self.preload_progress_bar.setRange(0, 100) + self.preload_progress_bar.setValue(0) + self.preload_progress_bar.setTextVisible(True) + self.preload_progress_bar.setMinimumHeight(22) + self.preload_progress_bar.setStyleSheet(""" + QProgressBar { + border: 1px solid #4a5568; + border-radius: 3px; + text-align: center; + background-color: #1e1e1e; + color: #ffffff; + font-weight: bold; + font-size: 9px; + } + QProgressBar::chunk { + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #2d6a4f, stop:0.5 #1b4332, stop:1 #081c15); + border-radius: 2px; + margin: 0px; + } + """) + preload_layout.addWidget(self.preload_progress_bar) + + self.preload_progress_frame.setVisible(False) # Hidden by default + main_layout.addWidget(self.preload_progress_frame) + + # Add instructions based on selected provider + if not is_ready: + req_frame = QWidget() + req_layout = QVBoxLayout(req_frame) + req_layout.setContentsMargins(0, 5, 0, 5) + + req_text = [] + if not has_api_key: + req_text.append("โ€ข API Key not configured") + + # Only show provider-specific credential warnings + if saved_provider == 'google': + has_vision = os.path.exists(self.main_gui.config.get('google_vision_credentials', '')) + if not has_vision: + req_text.append("โ€ข Google Cloud Vision credentials not set") + elif saved_provider == 'azure': + has_azure = bool(self.main_gui.config.get('azure_vision_key', '')) + if not has_azure: + req_text.append("โ€ข Azure credentials not configured") + + if req_text: # Only show frame if there are actual missing requirements + req_label = QLabel("\n".join(req_text)) + req_font = QFont("Arial", 10) + req_label.setFont(req_font) + req_label.setStyleSheet("color: red;") + req_label.setAlignment(Qt.AlignLeft) + req_layout.addWidget(req_label) + main_layout.addWidget(req_frame) + else: + # Create empty frame to maintain layout consistency + req_frame = QWidget() + req_frame.setVisible(False) + main_layout.addWidget(req_frame) + + # File selection frame - SPANS BOTH COLUMNS + file_frame = QGroupBox("Select Manga Images") + file_frame_font = QFont("Arial", 10) + file_frame_font.setBold(True) + file_frame.setFont(file_frame_font) + file_frame_layout = QVBoxLayout(file_frame) + file_frame_layout.setContentsMargins(10, 10, 10, 8) + file_frame_layout.setSpacing(6) + + # File listbox (QListWidget handles scrolling automatically) + self.file_listbox = QListWidget() + self.file_listbox.setSelectionMode(QListWidget.ExtendedSelection) + self.file_listbox.setMinimumHeight(200) + file_frame_layout.addWidget(self.file_listbox) + + # File buttons + file_btn_frame = QWidget() + file_btn_layout = QHBoxLayout(file_btn_frame) + file_btn_layout.setContentsMargins(0, 6, 0, 0) + file_btn_layout.setSpacing(4) + + add_files_btn = QPushButton("Add Files") + add_files_btn.clicked.connect(self._add_files) + add_files_btn.setStyleSheet("QPushButton { background-color: #007bff; color: white; padding: 3px 10px; font-size: 9pt; }") + file_btn_layout.addWidget(add_files_btn) + + add_folder_btn = QPushButton("Add Folder") + add_folder_btn.clicked.connect(self._add_folder) + add_folder_btn.setStyleSheet("QPushButton { background-color: #007bff; color: white; padding: 3px 10px; font-size: 9pt; }") + file_btn_layout.addWidget(add_folder_btn) + + remove_btn = QPushButton("Remove Selected") + remove_btn.clicked.connect(self._remove_selected) + remove_btn.setStyleSheet("QPushButton { background-color: #dc3545; color: white; padding: 3px 10px; font-size: 9pt; }") + file_btn_layout.addWidget(remove_btn) + + clear_btn = QPushButton("Clear All") + clear_btn.clicked.connect(self._clear_all) + clear_btn.setStyleSheet("QPushButton { background-color: #ffc107; color: black; padding: 3px 10px; font-size: 9pt; }") + file_btn_layout.addWidget(clear_btn) + + file_btn_layout.addStretch() + file_frame_layout.addWidget(file_btn_frame) + + main_layout.addWidget(file_frame) + + # Create 2-column layout for settings + columns_container = QWidget() + columns_layout = QHBoxLayout(columns_container) + columns_layout.setContentsMargins(0, 0, 0, 0) + columns_layout.setSpacing(10) + + # Left column (Column 1) + left_column = QWidget() + left_column_layout = QVBoxLayout(left_column) + left_column_layout.setContentsMargins(0, 0, 0, 0) + left_column_layout.setSpacing(6) + + # Right column (Column 2) + right_column = QWidget() + right_column_layout = QVBoxLayout(right_column) + right_column_layout.setContentsMargins(0, 0, 0, 0) + right_column_layout.setSpacing(6) + + # Settings frame - GOES TO LEFT COLUMN + settings_frame = QGroupBox("Translation Settings") + settings_frame_font = QFont("Arial", 10) + settings_frame_font.setBold(True) + settings_frame.setFont(settings_frame_font) + settings_frame_layout = QVBoxLayout(settings_frame) + settings_frame_layout.setContentsMargins(10, 10, 10, 8) + settings_frame_layout.setSpacing(6) + + # API Settings - Hybrid approach + api_frame = QWidget() + api_layout = QHBoxLayout(api_frame) + api_layout.setContentsMargins(0, 0, 0, 10) + api_layout.setSpacing(10) + + api_label = QLabel("OCR: Google Cloud Vision | Translation: API Key") + api_font = QFont("Arial", 10) + api_font.setItalic(True) + api_label.setFont(api_font) + api_label.setStyleSheet("color: gray;") + api_layout.addWidget(api_label) + + # Show current model from main GUI + current_model = 'Unknown' + try: + if hasattr(self.main_gui, 'model_combo') and hasattr(self.main_gui.model_combo, 'currentText'): + # PySide6 QComboBox + current_model = self.main_gui.model_combo.currentText() + elif hasattr(self.main_gui, 'model_var'): + # Tkinter StringVar + current_model = self.main_gui.model_var.get() if hasattr(self.main_gui.model_var, 'get') else str(self.main_gui.model_var) + elif hasattr(self.main_gui, 'config'): + # Fallback to config + current_model = self.main_gui.config.get('model', 'Unknown') + except Exception as e: + print(f"Error getting model: {e}") + current_model = 'Unknown' + + model_label = QLabel(f"Model: {current_model}") + model_font = QFont("Arial", 10) + model_font.setItalic(True) + model_label.setFont(model_font) + model_label.setStyleSheet("color: gray;") + api_layout.addStretch() + api_layout.addWidget(model_label) + + settings_frame_layout.addWidget(api_frame) + + # OCR Provider Selection - ENHANCED VERSION + self.ocr_provider_frame = QWidget() + ocr_provider_layout = QHBoxLayout(self.ocr_provider_frame) + ocr_provider_layout.setContentsMargins(0, 0, 0, 10) + ocr_provider_layout.setSpacing(10) + + provider_label = QLabel("OCR Provider:") + provider_label.setMinimumWidth(150) + provider_label.setAlignment(Qt.AlignLeft) + ocr_provider_layout.addWidget(provider_label) + + # Expanded provider list with descriptions + ocr_providers = [ + ('custom-api', 'Your Own key'), + ('google', 'Google Cloud Vision'), + ('azure', 'Azure Computer Vision'), + ('rapidocr', 'โšก RapidOCR (Fast & Local)'), + ('manga-ocr', '๐Ÿ‡ฏ๐Ÿ‡ต Manga OCR (Japanese)'), + ('Qwen2-VL', '๐Ÿ‡ฐ๐Ÿ‡ท Qwen2-VL (Korean)'), + ('easyocr', '๐ŸŒ EasyOCR (Multi-lang)'), + #('paddleocr', '๐Ÿผ PaddleOCR'), + ('doctr', '๐Ÿ“„ DocTR'), + ] + + # Just the values for the combobox + provider_values = [p[0] for p in ocr_providers] + provider_display = [f"{p[0]} - {p[1]}" for p in ocr_providers] + + self.ocr_provider_value = self.main_gui.config.get('manga_ocr_provider', 'custom-api') + self.provider_combo = QComboBox() + self.provider_combo.addItems(provider_values) + self.provider_combo.setCurrentText(self.ocr_provider_value) + self.provider_combo.setMinimumWidth(120) # Reduced for better fit + self.provider_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.provider_combo.currentTextChanged.connect(self._on_ocr_provider_change) + self._disable_combobox_mousewheel(self.provider_combo) # Disable mousewheel scrolling + ocr_provider_layout.addWidget(self.provider_combo) + + # Provider status indicator with more detail + self.provider_status_label = QLabel("") + status_font = QFont("Arial", 9) + self.provider_status_label.setFont(status_font) + self.provider_status_label.setWordWrap(True) # Allow text wrapping + self.provider_status_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + ocr_provider_layout.addWidget(self.provider_status_label) + + # Setup/Install button for non-cloud providers + self.provider_setup_btn = QPushButton("Setup") + self.provider_setup_btn.clicked.connect(self._setup_ocr_provider) + self.provider_setup_btn.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px 15px; }") + self.provider_setup_btn.setMinimumWidth(100) + self.provider_setup_btn.setVisible(False) # Hidden by default, _check_provider_status will show it + ocr_provider_layout.addWidget(self.provider_setup_btn) + + # Add explicit download button for Hugging Face models + self.download_model_btn = QPushButton("๐Ÿ“ฅ Download") + self.download_model_btn.clicked.connect(self._download_hf_model) + self.download_model_btn.setStyleSheet("QPushButton { background-color: #28a745; color: white; padding: 5px 15px; }") + self.download_model_btn.setMinimumWidth(150) + self.download_model_btn.setVisible(False) # Hidden by default + ocr_provider_layout.addWidget(self.download_model_btn) + + ocr_provider_layout.addStretch() + settings_frame_layout.addWidget(self.ocr_provider_frame) + + # Initialize OCR manager + from ocr_manager import OCRManager + self.ocr_manager = OCRManager(log_callback=self._log) + + # Check initial provider status + self._check_provider_status() + + # Google Cloud Credentials section (now in a frame that can be hidden) + self.google_creds_frame = QWidget() + google_creds_layout = QHBoxLayout(self.google_creds_frame) + google_creds_layout.setContentsMargins(0, 0, 0, 10) + google_creds_layout.setSpacing(10) + + google_label = QLabel("Google Cloud Credentials:") + google_label.setMinimumWidth(150) + google_label.setAlignment(Qt.AlignLeft) + google_creds_layout.addWidget(google_label) + + # Show current credentials file + google_creds_path = self.main_gui.config.get('google_vision_credentials', '') or self.main_gui.config.get('google_cloud_credentials', '') + creds_display = os.path.basename(google_creds_path) if google_creds_path else "Not Set" + + self.creds_label = QLabel(creds_display) + creds_font = QFont("Arial", 9) + self.creds_label.setFont(creds_font) + self.creds_label.setStyleSheet(f"color: {'green' if google_creds_path else 'red'};") + google_creds_layout.addWidget(self.creds_label) + + browse_btn = QPushButton("Browse") + browse_btn.clicked.connect(self._browse_google_credentials_permanent) + browse_btn.setStyleSheet("QPushButton { background-color: #007bff; color: white; padding: 5px 15px; }") + google_creds_layout.addWidget(browse_btn) + + google_creds_layout.addStretch() + settings_frame_layout.addWidget(self.google_creds_frame) + self.google_creds_frame.setVisible(False) # Hidden by default + + # Azure settings frame (hidden by default) + self.azure_frame = QWidget() + azure_frame_layout = QVBoxLayout(self.azure_frame) + azure_frame_layout.setContentsMargins(0, 0, 0, 10) + azure_frame_layout.setSpacing(5) + + # Azure Key + azure_key_frame = QWidget() + azure_key_layout = QHBoxLayout(azure_key_frame) + azure_key_layout.setContentsMargins(0, 0, 0, 0) + azure_key_layout.setSpacing(10) + + azure_key_label = QLabel("Azure Key:") + azure_key_label.setMinimumWidth(150) + azure_key_label.setAlignment(Qt.AlignLeft) + azure_key_layout.addWidget(azure_key_label) + + self.azure_key_entry = QLineEdit() + self.azure_key_entry.setEchoMode(QLineEdit.Password) + self.azure_key_entry.setMinimumWidth(150) # Reduced for better fit + self.azure_key_entry.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + azure_key_layout.addWidget(self.azure_key_entry) + + # Show/Hide button for Azure key + self.show_azure_key_checkbox = self._create_styled_checkbox("Show") + self.show_azure_key_checkbox.stateChanged.connect(self._toggle_azure_key_visibility) + azure_key_layout.addWidget(self.show_azure_key_checkbox) + azure_key_layout.addStretch() + azure_frame_layout.addWidget(azure_key_frame) + + # Azure Endpoint + azure_endpoint_frame = QWidget() + azure_endpoint_layout = QHBoxLayout(azure_endpoint_frame) + azure_endpoint_layout.setContentsMargins(0, 0, 0, 0) + azure_endpoint_layout.setSpacing(10) + + azure_endpoint_label = QLabel("Azure Endpoint:") + azure_endpoint_label.setMinimumWidth(150) + azure_endpoint_label.setAlignment(Qt.AlignLeft) + azure_endpoint_layout.addWidget(azure_endpoint_label) + + self.azure_endpoint_entry = QLineEdit() + self.azure_endpoint_entry.setMinimumWidth(150) # Reduced for better fit + self.azure_endpoint_entry.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + azure_endpoint_layout.addWidget(self.azure_endpoint_entry) + azure_endpoint_layout.addStretch() + azure_frame_layout.addWidget(azure_endpoint_frame) + + # Load saved Azure settings + saved_key = self.main_gui.config.get('azure_vision_key', '') + saved_endpoint = self.main_gui.config.get('azure_vision_endpoint', 'https://YOUR-RESOURCE.cognitiveservices.azure.com/') + self.azure_key_entry.setText(saved_key) + self.azure_endpoint_entry.setText(saved_endpoint) + + settings_frame_layout.addWidget(self.azure_frame) + self.azure_frame.setVisible(False) # Hidden by default + + # Initially show/hide based on saved provider + self._on_ocr_provider_change() + + # Separator for context settings + separator1 = QFrame() + separator1.setFrameShape(QFrame.HLine) + separator1.setFrameShadow(QFrame.Sunken) + settings_frame_layout.addWidget(separator1) + + # Context and Full Page Mode Settings + context_frame = QGroupBox("๐Ÿ”„ Context & Translation Mode") + context_frame_font = QFont("Arial", 11) + context_frame_font.setBold(True) + context_frame.setFont(context_frame_font) + context_frame_layout = QVBoxLayout(context_frame) + context_frame_layout.setContentsMargins(10, 10, 10, 10) + context_frame_layout.setSpacing(10) + + # Show current contextual settings from main GUI + context_info = QWidget() + context_info_layout = QVBoxLayout(context_info) + context_info_layout.setContentsMargins(0, 0, 0, 10) + context_info_layout.setSpacing(5) + + context_title = QLabel("Main GUI Context Settings:") + title_font = QFont("Arial", 10) + title_font.setBold(True) + context_title.setFont(title_font) + context_info_layout.addWidget(context_title) + + # Display current settings + settings_frame_display = QWidget() + settings_display_layout = QVBoxLayout(settings_frame_display) + settings_display_layout.setContentsMargins(20, 0, 0, 0) + settings_display_layout.setSpacing(3) + + # Contextual enabled status + contextual_status = "Enabled" if self.main_gui.contextual_var.get() else "Disabled" + self.contextual_status_label = QLabel(f"โ€ข Contextual Translation: {contextual_status}") + status_font = QFont("Arial", 10) + self.contextual_status_label.setFont(status_font) + settings_display_layout.addWidget(self.contextual_status_label) + + # History limit + history_limit = self.main_gui.trans_history.get() if hasattr(self.main_gui, 'trans_history') else "3" + self.history_limit_label = QLabel(f"โ€ข Translation History Limit: {history_limit} exchanges") + self.history_limit_label.setFont(status_font) + settings_display_layout.addWidget(self.history_limit_label) + + # Rolling history status + rolling_status = "Enabled (Rolling Window)" if self.main_gui.translation_history_rolling_var.get() else "Disabled (Reset on Limit)" + self.rolling_status_label = QLabel(f"โ€ข Rolling History: {rolling_status}") + self.rolling_status_label.setFont(status_font) + settings_display_layout.addWidget(self.rolling_status_label) + + context_info_layout.addWidget(settings_frame_display) + context_frame_layout.addWidget(context_info) + + # Refresh button to update from main GUI + refresh_btn = QPushButton("โ†ป Refresh from Main GUI") + refresh_btn.clicked.connect(self._refresh_context_settings) + refresh_btn.setStyleSheet("QPushButton { background-color: #6c757d; color: white; padding: 5px 15px; }") + context_frame_layout.addWidget(refresh_btn) + + # Separator + separator2 = QFrame() + separator2.setFrameShape(QFrame.HLine) + separator2.setFrameShadow(QFrame.Sunken) + context_frame_layout.addWidget(separator2) + + # Full Page Context Translation Settings + full_page_frame = QWidget() + full_page_layout = QVBoxLayout(full_page_frame) + full_page_layout.setContentsMargins(0, 0, 0, 0) + full_page_layout.setSpacing(5) + + full_page_title = QLabel("Full Page Context Mode (Manga-specific):") + title_font2 = QFont("Arial", 10) + title_font2.setBold(True) + full_page_title.setFont(title_font2) + full_page_layout.addWidget(full_page_title) + + # Enable/disable toggle + self.full_page_context_checked = self.main_gui.config.get('manga_full_page_context', True) + + toggle_frame = QWidget() + toggle_layout = QHBoxLayout(toggle_frame) + toggle_layout.setContentsMargins(20, 0, 0, 0) + toggle_layout.setSpacing(10) + + self.context_checkbox = self._create_styled_checkbox("Enable Full Page Context Translation") + self.context_checkbox.setChecked(self.full_page_context_checked) + self.context_checkbox.stateChanged.connect(self._on_context_toggle) + toggle_layout.addWidget(self.context_checkbox) + + # Edit prompt button + edit_prompt_btn = QPushButton("Edit Prompt") + edit_prompt_btn.clicked.connect(self._edit_context_prompt) + edit_prompt_btn.setStyleSheet("QPushButton { background-color: #6c757d; color: white; padding: 5px 15px; }") + toggle_layout.addWidget(edit_prompt_btn) + + # Help button for full page context + help_btn = QPushButton("?") + help_btn.setFixedWidth(30) + help_btn.clicked.connect(lambda: self._show_help_dialog( + "Full Page Context Mode", + "Full page context sends all text regions from the page together in a single request.\n\n" + "This allows the AI to see all text at once for more contextually accurate translations, " + "especially useful for maintaining character name consistency and understanding " + "conversation flow across multiple speech bubbles.\n\n" + "โœ… Pros:\n" + "โ€ข Better context awareness\n" + "โ€ข Consistent character names\n" + "โ€ข Understanding of conversation flow\n" + "โ€ข Maintains tone across bubbles\n\n" + "โŒ Cons:\n" + "โ€ข Single API call failure affects all text\n" + "โ€ข May use more tokens\n" + "โ€ข Slower for pages with many text regions" + )) + help_btn.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px; }") + toggle_layout.addWidget(help_btn) + toggle_layout.addStretch() + + full_page_layout.addWidget(toggle_frame) + context_frame_layout.addWidget(full_page_frame) + + # Separator + separator3 = QFrame() + separator3.setFrameShape(QFrame.HLine) + separator3.setFrameShadow(QFrame.Sunken) + context_frame_layout.addWidget(separator3) + + # Visual Context Settings (for non-vision model support) + visual_frame = QWidget() + visual_layout = QVBoxLayout(visual_frame) + visual_layout.setContentsMargins(0, 0, 0, 0) + visual_layout.setSpacing(5) + + visual_title = QLabel("Visual Context (Image Support):") + title_font3 = QFont("Arial", 10) + title_font3.setBold(True) + visual_title.setFont(title_font3) + visual_layout.addWidget(visual_title) + + # Visual context toggle + self.visual_context_enabled_checked = self.main_gui.config.get('manga_visual_context_enabled', True) + + visual_toggle_frame = QWidget() + visual_toggle_layout = QHBoxLayout(visual_toggle_frame) + visual_toggle_layout.setContentsMargins(20, 0, 0, 0) + visual_toggle_layout.setSpacing(10) + + self.visual_context_checkbox = self._create_styled_checkbox("Include page image in translation requests") + self.visual_context_checkbox.setChecked(self.visual_context_enabled_checked) + self.visual_context_checkbox.stateChanged.connect(self._on_visual_context_toggle) + visual_toggle_layout.addWidget(self.visual_context_checkbox) + + # Help button for visual context + visual_help_btn = QPushButton("?") + visual_help_btn.setFixedWidth(30) + visual_help_btn.clicked.connect(lambda: self._show_help_dialog( + "Visual Context Settings", + "Visual context includes the manga page image with translation requests.\n\n" + "โš ๏ธ WHEN TO DISABLE:\n" + "โ€ข Using text-only models (Claude, GPT-3.5, standard Gemini)\n" + "โ€ข Model doesn't support images\n" + "โ€ข Want to reduce token usage\n" + "โ€ข Testing text-only translation\n\n" + "โœ… WHEN TO ENABLE:\n" + "โ€ข Using vision models (Gemini Vision, GPT-4V, Claude 3)\n" + "โ€ข Want spatial awareness of text position\n" + "โ€ข Need visual context for better translation\n\n" + "Impact:\n" + "โ€ข Disabled: Only text is sent (compatible with any model)\n" + "โ€ข Enabled: Text + image sent (requires vision model)\n\n" + "Note: Disabling may reduce translation quality as the AI won't see\n" + "the artwork context or spatial layout of the text." + )) + visual_help_btn.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px; }") + visual_toggle_layout.addWidget(visual_help_btn) + visual_toggle_layout.addStretch() + + visual_layout.addWidget(visual_toggle_frame) + + # Output settings - moved here to be below visual context + output_settings_frame = QWidget() + output_settings_layout = QHBoxLayout(output_settings_frame) + output_settings_layout.setContentsMargins(20, 10, 0, 0) + output_settings_layout.setSpacing(10) + + self.create_subfolder_checkbox = self._create_styled_checkbox("Create 'translated' subfolder for output") + self.create_subfolder_checkbox.setChecked(self.main_gui.config.get('manga_create_subfolder', True)) + self.create_subfolder_checkbox.stateChanged.connect(self._save_rendering_settings) + output_settings_layout.addWidget(self.create_subfolder_checkbox) + output_settings_layout.addStretch() + + visual_layout.addWidget(output_settings_frame) + + context_frame_layout.addWidget(visual_frame) + + # Add the completed context_frame to settings_frame + settings_frame_layout.addWidget(context_frame) + + # Add main settings frame to left column + left_column_layout.addWidget(settings_frame) + + # Text Rendering Settings Frame - SPLIT BETWEEN COLUMNS + render_frame = QGroupBox("Text Visibility Settings") + render_frame_font = QFont("Arial", 12) + render_frame_font.setBold(True) + render_frame.setFont(render_frame_font) + render_frame_layout = QVBoxLayout(render_frame) + render_frame_layout.setContentsMargins(15, 15, 15, 10) + render_frame_layout.setSpacing(10) + + # Inpainting section + inpaint_group = QGroupBox("Inpainting") + inpaint_group_font = QFont("Arial", 11) + inpaint_group_font.setBold(True) + inpaint_group.setFont(inpaint_group_font) + inpaint_group_layout = QVBoxLayout(inpaint_group) + inpaint_group_layout.setContentsMargins(15, 15, 15, 10) + inpaint_group_layout.setSpacing(10) + + # Skip inpainting toggle - use value loaded from config + self.skip_inpainting_checkbox = self._create_styled_checkbox("Skip Inpainter") + self.skip_inpainting_checkbox.setChecked(self.skip_inpainting_value) + self.skip_inpainting_checkbox.stateChanged.connect(self._toggle_inpaint_visibility) + inpaint_group_layout.addWidget(self.skip_inpainting_checkbox) + + # Inpainting method selection (only visible when inpainting is enabled) + self.inpaint_method_frame = QWidget() + inpaint_method_layout = QHBoxLayout(self.inpaint_method_frame) + inpaint_method_layout.setContentsMargins(0, 0, 0, 0) + inpaint_method_layout.setSpacing(10) + + method_label = QLabel("Inpaint Method:") + method_label_font = QFont('Arial', 9) + method_label.setFont(method_label_font) + method_label.setMinimumWidth(95) + method_label.setAlignment(Qt.AlignLeft) + inpaint_method_layout.addWidget(method_label) + + # Radio buttons for inpaint method + method_selection_frame = QWidget() + method_selection_layout = QHBoxLayout(method_selection_frame) + method_selection_layout.setContentsMargins(0, 0, 0, 0) + method_selection_layout.setSpacing(10) + + self.inpaint_method_value = self.main_gui.config.get('manga_inpaint_method', 'local') + self.inpaint_method_group = QButtonGroup() + + # Set smaller font for radio buttons + radio_font = QFont('Arial', 9) + + cloud_radio = QRadioButton("Cloud API") + cloud_radio.setFont(radio_font) + cloud_radio.setChecked(self.inpaint_method_value == 'cloud') + cloud_radio.toggled.connect(lambda checked: self._on_inpaint_method_change() if checked else None) + self.inpaint_method_group.addButton(cloud_radio, 0) + method_selection_layout.addWidget(cloud_radio) + + local_radio = QRadioButton("Local Model") + local_radio.setFont(radio_font) + local_radio.setChecked(self.inpaint_method_value == 'local') + local_radio.toggled.connect(lambda checked: self._on_inpaint_method_change() if checked else None) + self.inpaint_method_group.addButton(local_radio, 1) + method_selection_layout.addWidget(local_radio) + + hybrid_radio = QRadioButton("Hybrid") + hybrid_radio.setFont(radio_font) + hybrid_radio.setChecked(self.inpaint_method_value == 'hybrid') + hybrid_radio.toggled.connect(lambda checked: self._on_inpaint_method_change() if checked else None) + self.inpaint_method_group.addButton(hybrid_radio, 2) + method_selection_layout.addWidget(hybrid_radio) + + # Store references to radio buttons + self.cloud_radio = cloud_radio + self.local_radio = local_radio + self.hybrid_radio = hybrid_radio + + inpaint_method_layout.addWidget(method_selection_frame) + inpaint_method_layout.addStretch() + inpaint_group_layout.addWidget(self.inpaint_method_frame) + + # Cloud settings frame + self.cloud_inpaint_frame = QWidget() + # Ensure this widget doesn't become a window + self.cloud_inpaint_frame.setWindowFlags(Qt.WindowType.Widget) + cloud_inpaint_layout = QVBoxLayout(self.cloud_inpaint_frame) + cloud_inpaint_layout.setContentsMargins(0, 0, 0, 0) + cloud_inpaint_layout.setSpacing(5) + + # Quality selection for cloud + quality_frame = QWidget() + quality_layout = QHBoxLayout(quality_frame) + quality_layout.setContentsMargins(0, 0, 0, 0) + quality_layout.setSpacing(10) + + quality_label = QLabel("Cloud Quality:") + quality_label_font = QFont('Arial', 9) + quality_label.setFont(quality_label_font) + quality_label.setMinimumWidth(95) + quality_label.setAlignment(Qt.AlignLeft) + quality_layout.addWidget(quality_label) + + # inpaint_quality_value is already loaded from config in _load_rendering_settings + self.quality_button_group = QButtonGroup() + + quality_options = [('high', 'High Quality'), ('fast', 'Fast')] + for idx, (value, text) in enumerate(quality_options): + quality_radio = QRadioButton(text) + quality_radio.setChecked(self.inpaint_quality_value == value) + quality_radio.toggled.connect(lambda checked, v=value: self._save_rendering_settings() if checked else None) + self.quality_button_group.addButton(quality_radio, idx) + quality_layout.addWidget(quality_radio) + + quality_layout.addStretch() + cloud_inpaint_layout.addWidget(quality_frame) + + # Conditional separator + self.inpaint_separator = QFrame() + self.inpaint_separator.setFrameShape(QFrame.HLine) + self.inpaint_separator.setFrameShadow(QFrame.Sunken) + if not self.skip_inpainting_value: + cloud_inpaint_layout.addWidget(self.inpaint_separator) + + # Cloud API status + api_status_frame = QWidget() + api_status_layout = QHBoxLayout(api_status_frame) + api_status_layout.setContentsMargins(0, 10, 0, 0) + api_status_layout.setSpacing(10) + + # Check if API key exists + saved_api_key = self.main_gui.config.get('replicate_api_key', '') + if saved_api_key: + status_text = "โœ… Cloud API configured" + status_color = 'green' + else: + status_text = "โŒ Cloud API not configured" + status_color = 'red' + + self.inpaint_api_status_label = QLabel(status_text) + api_status_font = QFont('Arial', 9) + self.inpaint_api_status_label.setFont(api_status_font) + self.inpaint_api_status_label.setStyleSheet(f"color: {status_color};") + api_status_layout.addWidget(self.inpaint_api_status_label) + + configure_api_btn = QPushButton("Configure API Key") + configure_api_btn.clicked.connect(self._configure_inpaint_api) + configure_api_btn.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px 15px; }") + api_status_layout.addWidget(configure_api_btn) + + if saved_api_key: + clear_api_btn = QPushButton("Clear") + clear_api_btn.clicked.connect(self._clear_inpaint_api) + clear_api_btn.setStyleSheet("QPushButton { background-color: #6c757d; color: white; padding: 5px 15px; }") + api_status_layout.addWidget(clear_api_btn) + + api_status_layout.addStretch() + cloud_inpaint_layout.addWidget(api_status_frame) + inpaint_group_layout.addWidget(self.cloud_inpaint_frame) + + # Local inpainting settings frame + self.local_inpaint_frame = QWidget() + # Ensure this widget doesn't become a window + self.local_inpaint_frame.setWindowFlags(Qt.WindowType.Widget) + local_inpaint_layout = QVBoxLayout(self.local_inpaint_frame) + local_inpaint_layout.setContentsMargins(0, 0, 0, 0) + local_inpaint_layout.setSpacing(5) + + # Local model selection + local_model_frame = QWidget() + local_model_layout = QHBoxLayout(local_model_frame) + local_model_layout.setContentsMargins(0, 0, 0, 0) + local_model_layout.setSpacing(10) + + local_model_label = QLabel("Local Model:") + local_model_label_font = QFont('Arial', 9) + local_model_label.setFont(local_model_label_font) + local_model_label.setMinimumWidth(95) + local_model_label.setAlignment(Qt.AlignLeft) + local_model_layout.addWidget(local_model_label) + self.local_model_label = local_model_label + + self.local_model_type_value = self.main_gui.config.get('manga_local_inpaint_model', 'anime_onnx') + local_model_combo = QComboBox() + local_model_combo.addItems(['aot', 'aot_onnx', 'lama', 'lama_onnx', 'anime', 'anime_onnx', 'mat', 'ollama', 'sd_local']) + local_model_combo.setCurrentText(self.local_model_type_value) + local_model_combo.setMinimumWidth(120) + local_model_combo.setMaximumWidth(120) + local_combo_font = QFont('Arial', 9) + local_model_combo.setFont(local_combo_font) + local_model_combo.currentTextChanged.connect(self._on_local_model_change) + self._disable_combobox_mousewheel(local_model_combo) # Disable mousewheel scrolling + local_model_layout.addWidget(local_model_combo) + self.local_model_combo = local_model_combo + + # Model descriptions + model_desc = { + 'lama': 'LaMa (Best quality)', + 'aot': 'AOT GAN (Fast)', + 'aot_onnx': 'AOT ONNX (Optimized)', + 'mat': 'MAT (High-res)', + 'sd_local': 'Stable Diffusion (Anime)', + 'anime': 'Anime/Manga Inpainting', + 'anime_onnx': 'Anime ONNX (Fast/Optimized)', + 'lama_onnx': 'LaMa ONNX (Optimized)', + } + self.model_desc_label = QLabel(model_desc.get(self.local_model_type_value, '')) + desc_font = QFont('Arial', 8) + self.model_desc_label.setFont(desc_font) + self.model_desc_label.setStyleSheet("color: gray;") + self.model_desc_label.setMaximumWidth(200) + local_model_layout.addWidget(self.model_desc_label) + local_model_layout.addStretch() + + local_inpaint_layout.addWidget(local_model_frame) + + # Model file selection + model_path_frame = QWidget() + model_path_layout = QHBoxLayout(model_path_frame) + model_path_layout.setContentsMargins(0, 5, 0, 0) + model_path_layout.setSpacing(10) + + model_file_label = QLabel("Model File:") + model_file_label_font = QFont('Arial', 9) + model_file_label.setFont(model_file_label_font) + model_file_label.setMinimumWidth(95) + model_file_label.setAlignment(Qt.AlignLeft) + model_path_layout.addWidget(model_file_label) + self.model_file_label = model_file_label + + self.local_model_path_value = self.main_gui.config.get(f'manga_{self.local_model_type_value}_model_path', '') + self.local_model_entry = QLineEdit(self.local_model_path_value) + self.local_model_entry.setReadOnly(True) + self.local_model_entry.setMinimumWidth(100) # Reduced for better fit + self.local_model_entry.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.local_model_entry.setStyleSheet( + "QLineEdit { background-color: #2b2b2b; color: #ffffff; }" + ) + model_path_layout.addWidget(self.local_model_entry) + + browse_model_btn = QPushButton("Browse") + browse_model_btn.clicked.connect(self._browse_local_model) + browse_model_btn.setStyleSheet("QPushButton { background-color: #007bff; color: white; padding: 5px 15px; }") + model_path_layout.addWidget(browse_model_btn) + self.browse_model_btn = browse_model_btn + + # Manual load button to avoid auto-loading on dialog open + load_model_btn = QPushButton("Load") + load_model_btn.clicked.connect(self._click_load_local_model) + load_model_btn.setStyleSheet("QPushButton { background-color: #28a745; color: white; padding: 5px 15px; }") + model_path_layout.addWidget(load_model_btn) + self.load_model_btn = load_model_btn + model_path_layout.addStretch() + + local_inpaint_layout.addWidget(model_path_frame) + + # Model status + self.local_model_status_label = QLabel("") + status_font = QFont('Arial', 9) + self.local_model_status_label.setFont(status_font) + local_inpaint_layout.addWidget(self.local_model_status_label) + + # Download model button + download_model_btn = QPushButton("๐Ÿ“ฅ Download Model") + download_model_btn.clicked.connect(self._download_model) + download_model_btn.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px 15px; }") + local_inpaint_layout.addWidget(download_model_btn) + + # Model info button + model_info_btn = QPushButton("โ„น๏ธ Model Info") + model_info_btn.clicked.connect(self._show_model_info) + model_info_btn.setStyleSheet("QPushButton { background-color: #6c757d; color: white; padding: 5px 15px; }") + local_inpaint_layout.addWidget(model_info_btn) + + # Add local_inpaint_frame to inpaint_group + inpaint_group_layout.addWidget(self.local_inpaint_frame) + + # Hide both frames by default to prevent window popup + self.cloud_inpaint_frame.hide() + self.local_inpaint_frame.hide() + + # Try to load saved model for current type on dialog open + initial_model_type = self.local_model_type_value + initial_model_path = self.main_gui.config.get(f'manga_{initial_model_type}_model_path', '') + + if initial_model_path and os.path.exists(initial_model_path): + self.local_model_entry.setText(initial_model_path) + if getattr(self, 'preload_local_models_on_open', False): + self.local_model_status_label.setText("โณ Loading saved model...") + self.local_model_status_label.setStyleSheet("color: orange;") + # Auto-load after dialog is ready + QTimer.singleShot(500, lambda: self._try_load_model(initial_model_type, initial_model_path)) + else: + # Do not auto-load large models at startup to avoid crashes on some systems + self.local_model_status_label.setText("๐Ÿ’ค Saved model detected (not loaded). Click 'Load' to initialize.") + self.local_model_status_label.setStyleSheet("color: blue;") + else: + self.local_model_status_label.setText("No model loaded") + self.local_model_status_label.setStyleSheet("color: gray;") + + # Initialize visibility based on current settings + self._toggle_inpaint_visibility() + + # Add inpaint_group to render_frame + render_frame_layout.addWidget(inpaint_group) + + # Add render_frame (inpainting only) to LEFT COLUMN + left_column_layout.addWidget(render_frame) + + # Advanced Settings button at the TOP OF RIGHT COLUMN + advanced_button_frame = QWidget() + advanced_button_layout = QHBoxLayout(advanced_button_frame) + advanced_button_layout.setContentsMargins(0, 0, 0, 10) + advanced_button_layout.setSpacing(10) + + advanced_settings_desc = QLabel("Configure OCR, preprocessing, and performance options") + desc_font = QFont("Arial", 9) + advanced_settings_desc.setFont(desc_font) + advanced_settings_desc.setStyleSheet("color: gray;") + advanced_button_layout.addWidget(advanced_settings_desc) + + advanced_button_layout.addStretch() + + advanced_settings_btn = QPushButton("โš™๏ธ Advanced Settings") + advanced_settings_btn.clicked.connect(self._open_advanced_settings) + advanced_settings_btn.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px 15px; }") + advanced_button_layout.addWidget(advanced_settings_btn) + + right_column_layout.addWidget(advanced_button_frame) + + # Background Settings - MOVED TO RIGHT COLUMN + self.bg_settings_frame = QGroupBox("Background Settings") + bg_settings_font = QFont("Arial", 10) + bg_settings_font.setBold(True) + self.bg_settings_frame.setFont(bg_settings_font) + bg_settings_layout = QVBoxLayout(self.bg_settings_frame) + bg_settings_layout.setContentsMargins(10, 10, 10, 10) + bg_settings_layout.setSpacing(8) + + # Free text only background opacity toggle (applies BG opacity only to free-text regions) + self.ft_only_checkbox = self._create_styled_checkbox("Free text only background opacity") + self.ft_only_checkbox.setChecked(self.free_text_only_bg_opacity_value) + # Connect directly to save+apply (working pattern) + self.ft_only_checkbox.stateChanged.connect(lambda: (self._on_ft_only_bg_opacity_changed(), self._save_rendering_settings(), self._apply_rendering_settings())) + bg_settings_layout.addWidget(self.ft_only_checkbox) + + # Background opacity slider + opacity_frame = QWidget() + opacity_layout = QHBoxLayout(opacity_frame) + opacity_layout.setContentsMargins(0, 5, 0, 5) + opacity_layout.setSpacing(10) + + opacity_label_text = QLabel("Background Opacity:") + opacity_label_text.setMinimumWidth(150) + opacity_layout.addWidget(opacity_label_text) + + self.opacity_slider = QSlider(Qt.Horizontal) + self.opacity_slider.setMinimum(0) + self.opacity_slider.setMaximum(255) + self.opacity_slider.setValue(self.bg_opacity_value) + self.opacity_slider.setMinimumWidth(200) + self.opacity_slider.valueChanged.connect(lambda value: (self._update_opacity_label(value), self._save_rendering_settings(), self._apply_rendering_settings())) + opacity_layout.addWidget(self.opacity_slider) + + self.opacity_label = QLabel("100%") + self.opacity_label.setMinimumWidth(50) + opacity_layout.addWidget(self.opacity_label) + opacity_layout.addStretch() + + bg_settings_layout.addWidget(opacity_frame) + + # Initialize the label with the loaded value + self._update_opacity_label(self.bg_opacity_value) + + # Background size reduction + reduction_frame = QWidget() + reduction_layout = QHBoxLayout(reduction_frame) + reduction_layout.setContentsMargins(0, 5, 0, 5) + reduction_layout.setSpacing(10) + + reduction_label_text = QLabel("Background Size:") + reduction_label_text.setMinimumWidth(150) + reduction_layout.addWidget(reduction_label_text) + + self.reduction_slider = QDoubleSpinBox() + self.reduction_slider.setMinimum(0.5) + self.reduction_slider.setMaximum(2.0) + self.reduction_slider.setSingleStep(0.05) + self.reduction_slider.setValue(self.bg_reduction_value) + self.reduction_slider.setMinimumWidth(100) + self.reduction_slider.valueChanged.connect(lambda value: (self._update_reduction_label(value), self._save_rendering_settings(), self._apply_rendering_settings())) + self._disable_spinbox_mousewheel(self.reduction_slider) + reduction_layout.addWidget(self.reduction_slider) + + self.reduction_label = QLabel("100%") + self.reduction_label.setMinimumWidth(50) + reduction_layout.addWidget(self.reduction_label) + reduction_layout.addStretch() + + bg_settings_layout.addWidget(reduction_frame) + + # Initialize the label with the loaded value + self._update_reduction_label(self.bg_reduction_value) + + # Background style selection + style_frame = QWidget() + style_layout = QHBoxLayout(style_frame) + style_layout.setContentsMargins(0, 5, 0, 5) + style_layout.setSpacing(10) + + style_label = QLabel("Background Style:") + style_label.setMinimumWidth(150) + style_layout.addWidget(style_label) + + # Radio buttons for background style + self.bg_style_group = QButtonGroup() + + box_radio = QRadioButton("Box") + box_radio.setChecked(self.bg_style_value == "box") + box_radio.toggled.connect(lambda checked: (setattr(self, 'bg_style_value', 'box'), self._save_rendering_settings(), self._apply_rendering_settings()) if checked else None) + self.bg_style_group.addButton(box_radio, 0) + style_layout.addWidget(box_radio) + + circle_radio = QRadioButton("Circle") + circle_radio.setChecked(self.bg_style_value == "circle") + circle_radio.toggled.connect(lambda checked: (setattr(self, 'bg_style_value', 'circle'), self._save_rendering_settings(), self._apply_rendering_settings()) if checked else None) + self.bg_style_group.addButton(circle_radio, 1) + style_layout.addWidget(circle_radio) + + wrap_radio = QRadioButton("Wrap") + wrap_radio.setChecked(self.bg_style_value == "wrap") + wrap_radio.toggled.connect(lambda checked: (setattr(self, 'bg_style_value', 'wrap'), self._save_rendering_settings(), self._apply_rendering_settings()) if checked else None) + self.bg_style_group.addButton(wrap_radio, 2) + style_layout.addWidget(wrap_radio) + + # Store references + self.box_radio = box_radio + self.circle_radio = circle_radio + self.wrap_radio = wrap_radio + + # Add tooltips or descriptions + style_help = QLabel("(Box: rounded rectangle, Circle: ellipse, Wrap: per-line)") + style_help_font = QFont('Arial', 9) + style_help.setFont(style_help_font) + style_help.setStyleSheet("color: gray;") + style_layout.addWidget(style_help) + style_layout.addStretch() + + bg_settings_layout.addWidget(style_frame) + + # Add Background Settings to RIGHT COLUMN + right_column_layout.addWidget(self.bg_settings_frame) + + # Font Settings group (consolidated) - GOES TO RIGHT COLUMN (after background settings) + font_render_frame = QGroupBox("Font & Text Settings") + font_render_frame_font = QFont("Arial", 10) + font_render_frame_font.setBold(True) + font_render_frame.setFont(font_render_frame_font) + font_render_frame_layout = QVBoxLayout(font_render_frame) + font_render_frame_layout.setContentsMargins(15, 15, 15, 10) + font_render_frame_layout.setSpacing(10) + self.sizing_group = QGroupBox("Font Settings") + sizing_group_font = QFont("Arial", 9) + sizing_group_font.setBold(True) + self.sizing_group.setFont(sizing_group_font) + sizing_group_layout = QVBoxLayout(self.sizing_group) + sizing_group_layout.setContentsMargins(10, 10, 10, 10) + sizing_group_layout.setSpacing(8) + + # Font sizing algorithm selection + algo_frame = QWidget() + algo_layout = QHBoxLayout(algo_frame) + algo_layout.setContentsMargins(0, 6, 0, 0) + algo_layout.setSpacing(10) + + algo_label = QLabel("Font Size Algorithm:") + algo_label.setMinimumWidth(150) + algo_layout.addWidget(algo_label) + + # Radio buttons for algorithm selection + self.font_algorithm_group = QButtonGroup() + + for idx, (value, text) in enumerate([ + ('conservative', 'Conservative'), + ('smart', 'Smart'), + ('aggressive', 'Aggressive') + ]): + rb = QRadioButton(text) + rb.setChecked(self.font_algorithm_value == value) + rb.toggled.connect(lambda checked, v=value: (setattr(self, 'font_algorithm_value', v), self._save_rendering_settings(), self._apply_rendering_settings()) if checked else None) + self.font_algorithm_group.addButton(rb, idx) + algo_layout.addWidget(rb) + + algo_layout.addStretch() + sizing_group_layout.addWidget(algo_frame) + + # Font size selection with mode toggle + font_frame_container = QWidget() + font_frame_layout = QVBoxLayout(font_frame_container) + font_frame_layout.setContentsMargins(0, 5, 0, 5) + font_frame_layout.setSpacing(10) + + # Mode selection frame + mode_frame = QWidget() + mode_layout = QHBoxLayout(mode_frame) + mode_layout.setContentsMargins(0, 0, 0, 0) + mode_layout.setSpacing(10) + + mode_label = QLabel("Font Size Mode:") + mode_label.setMinimumWidth(150) + mode_layout.addWidget(mode_label) + + # Radio buttons for mode selection + self.font_size_mode_group = QButtonGroup() + + auto_radio = QRadioButton("Auto") + auto_radio.setChecked(self.font_size_mode_value == "auto") + auto_radio.toggled.connect(lambda checked: (setattr(self, 'font_size_mode_value', 'auto'), self._toggle_font_size_mode()) if checked else None) + self.font_size_mode_group.addButton(auto_radio, 0) + mode_layout.addWidget(auto_radio) + + fixed_radio = QRadioButton("Fixed Size") + fixed_radio.setChecked(self.font_size_mode_value == "fixed") + fixed_radio.toggled.connect(lambda checked: (setattr(self, 'font_size_mode_value', 'fixed'), self._toggle_font_size_mode()) if checked else None) + self.font_size_mode_group.addButton(fixed_radio, 1) + mode_layout.addWidget(fixed_radio) + + multiplier_radio = QRadioButton("Dynamic Multiplier") + multiplier_radio.setChecked(self.font_size_mode_value == "multiplier") + multiplier_radio.toggled.connect(lambda checked: (setattr(self, 'font_size_mode_value', 'multiplier'), self._toggle_font_size_mode()) if checked else None) + self.font_size_mode_group.addButton(multiplier_radio, 2) + mode_layout.addWidget(multiplier_radio) + + # Store references + self.auto_mode_radio = auto_radio + self.fixed_mode_radio = fixed_radio + self.multiplier_mode_radio = multiplier_radio + + mode_layout.addStretch() + font_frame_layout.addWidget(mode_frame) + + # Fixed font size frame + self.fixed_size_frame = QWidget() + fixed_size_layout = QHBoxLayout(self.fixed_size_frame) + fixed_size_layout.setContentsMargins(0, 8, 0, 0) + fixed_size_layout.setSpacing(10) + + fixed_size_label = QLabel("Font Size:") + fixed_size_label.setMinimumWidth(150) + fixed_size_layout.addWidget(fixed_size_label) + + self.font_size_spinbox = QSpinBox() + self.font_size_spinbox.setMinimum(0) + self.font_size_spinbox.setMaximum(72) + self.font_size_spinbox.setValue(self.font_size_value) + self.font_size_spinbox.setMinimumWidth(100) + self.font_size_spinbox.valueChanged.connect(lambda value: (setattr(self, 'font_size_value', value), self._save_rendering_settings(), self._apply_rendering_settings())) + self._disable_spinbox_mousewheel(self.font_size_spinbox) + fixed_size_layout.addWidget(self.font_size_spinbox) + + fixed_help_label = QLabel("(0 = Auto)") + fixed_help_font = QFont('Arial', 9) + fixed_help_label.setFont(fixed_help_font) + fixed_help_label.setStyleSheet("color: gray;") + fixed_size_layout.addWidget(fixed_help_label) + fixed_size_layout.addStretch() + + font_frame_layout.addWidget(self.fixed_size_frame) + + # Dynamic multiplier frame + self.multiplier_frame = QWidget() + multiplier_layout = QHBoxLayout(self.multiplier_frame) + multiplier_layout.setContentsMargins(0, 8, 0, 0) + multiplier_layout.setSpacing(10) + + multiplier_label_text = QLabel("Size Multiplier:") + multiplier_label_text.setMinimumWidth(150) + multiplier_layout.addWidget(multiplier_label_text) + + self.multiplier_slider = QDoubleSpinBox() + self.multiplier_slider.setMinimum(0.5) + self.multiplier_slider.setMaximum(2.0) + self.multiplier_slider.setSingleStep(0.1) + self.multiplier_slider.setValue(self.font_size_multiplier_value) + self.multiplier_slider.setMinimumWidth(100) + self.multiplier_slider.valueChanged.connect(lambda value: (self._update_multiplier_label(value), self._save_rendering_settings(), self._apply_rendering_settings())) + self._disable_spinbox_mousewheel(self.multiplier_slider) + multiplier_layout.addWidget(self.multiplier_slider) + + self.multiplier_label = QLabel("1.0x") + self.multiplier_label.setMinimumWidth(50) + multiplier_layout.addWidget(self.multiplier_label) + + multiplier_help_label = QLabel("(Scales with panel size)") + multiplier_help_font = QFont('Arial', 9) + multiplier_help_label.setFont(multiplier_help_font) + multiplier_help_label.setStyleSheet("color: gray;") + multiplier_layout.addWidget(multiplier_help_label) + multiplier_layout.addStretch() + + font_frame_layout.addWidget(self.multiplier_frame) + + # Constraint checkbox frame (only visible in multiplier mode) + self.constraint_frame = QWidget() + constraint_layout = QHBoxLayout(self.constraint_frame) + constraint_layout.setContentsMargins(20, 0, 0, 0) + constraint_layout.setSpacing(10) + + self.constrain_checkbox = self._create_styled_checkbox("Constrain text to bubble boundaries") + self.constrain_checkbox.setChecked(self.constrain_to_bubble_value) + self.constrain_checkbox.stateChanged.connect(lambda: (setattr(self, 'constrain_to_bubble_value', self.constrain_checkbox.isChecked()), self._save_rendering_settings(), self._apply_rendering_settings())) + constraint_layout.addWidget(self.constrain_checkbox) + + constraint_help_label = QLabel("(Unchecked allows text to exceed bubbles)") + constraint_help_font = QFont('Arial', 9) + constraint_help_label.setFont(constraint_help_font) + constraint_help_label.setStyleSheet("color: gray;") + constraint_layout.addWidget(constraint_help_label) + constraint_layout.addStretch() + + font_frame_layout.addWidget(self.constraint_frame) + + # Add font_frame_container to sizing_group_layout + sizing_group_layout.addWidget(font_frame_container) + + # Minimum Font Size (Auto mode lower bound) + self.min_size_frame = QWidget() + min_size_layout = QHBoxLayout(self.min_size_frame) + min_size_layout.setContentsMargins(0, 5, 0, 5) + min_size_layout.setSpacing(10) + + min_size_label = QLabel("Minimum Font Size:") + min_size_label.setMinimumWidth(150) + min_size_layout.addWidget(min_size_label) + + self.min_size_spinbox = QSpinBox() + self.min_size_spinbox.setMinimum(8) + self.min_size_spinbox.setMaximum(20) + self.min_size_spinbox.setValue(self.auto_min_size_value) + self.min_size_spinbox.setMinimumWidth(100) + self.min_size_spinbox.valueChanged.connect(lambda value: (setattr(self, 'auto_min_size_value', value), self._save_rendering_settings(), self._apply_rendering_settings())) + self._disable_spinbox_mousewheel(self.min_size_spinbox) + min_size_layout.addWidget(self.min_size_spinbox) + + min_help_label = QLabel("(Auto mode won't go below this)") + min_help_font = QFont('Arial', 9) + min_help_label.setFont(min_help_font) + min_help_label.setStyleSheet("color: gray;") + min_size_layout.addWidget(min_help_label) + min_size_layout.addStretch() + + sizing_group_layout.addWidget(self.min_size_frame) + + # Maximum Font Size (Auto mode upper bound) + self.max_size_frame = QWidget() + max_size_layout = QHBoxLayout(self.max_size_frame) + max_size_layout.setContentsMargins(0, 5, 0, 5) + max_size_layout.setSpacing(10) + + max_size_label = QLabel("Maximum Font Size:") + max_size_label.setMinimumWidth(150) + max_size_layout.addWidget(max_size_label) + + self.max_size_spinbox = QSpinBox() + self.max_size_spinbox.setMinimum(20) + self.max_size_spinbox.setMaximum(100) + self.max_size_spinbox.setValue(self.max_font_size_value) + self.max_size_spinbox.setMinimumWidth(100) + self.max_size_spinbox.valueChanged.connect(lambda value: (setattr(self, 'max_font_size_value', value), self._save_rendering_settings(), self._apply_rendering_settings())) + self._disable_spinbox_mousewheel(self.max_size_spinbox) + max_size_layout.addWidget(self.max_size_spinbox) + + max_help_label = QLabel("(Limits maximum text size)") + max_help_font = QFont('Arial', 9) + max_help_label.setFont(max_help_font) + max_help_label.setStyleSheet("color: gray;") + max_size_layout.addWidget(max_help_label) + max_size_layout.addStretch() + + sizing_group_layout.addWidget(self.max_size_frame) + + # Initialize visibility AFTER all frames are created + self._toggle_font_size_mode() + + # Auto Fit Style (applies to Auto mode) + fit_row = QWidget() + fit_layout = QHBoxLayout(fit_row) + fit_layout.setContentsMargins(0, 0, 0, 6) + fit_layout.setSpacing(10) + + fit_label = QLabel("Auto Fit Style:") + fit_label.setMinimumWidth(110) + fit_layout.addWidget(fit_label) + + # Radio buttons for auto fit style + self.auto_fit_style_group = QButtonGroup() + + for idx, (value, text) in enumerate([('compact','Compact'), ('balanced','Balanced'), ('readable','Readable')]): + rb = QRadioButton(text) + rb.setChecked(self.auto_fit_style_value == value) + rb.toggled.connect(lambda checked, v=value: (setattr(self, 'auto_fit_style_value', v), self._save_rendering_settings(), self._apply_rendering_settings()) if checked else None) + self.auto_fit_style_group.addButton(rb, idx) + fit_layout.addWidget(rb) + + fit_layout.addStretch() + sizing_group_layout.addWidget(fit_row) + + # Behavior toggles + self.prefer_larger_checkbox = self._create_styled_checkbox("Prefer larger text") + self.prefer_larger_checkbox.setChecked(self.prefer_larger_value) + self.prefer_larger_checkbox.stateChanged.connect(lambda: (setattr(self, 'prefer_larger_value', self.prefer_larger_checkbox.isChecked()), self._save_rendering_settings(), self._apply_rendering_settings())) + sizing_group_layout.addWidget(self.prefer_larger_checkbox) + + self.bubble_size_factor_checkbox = self._create_styled_checkbox("Scale with bubble size") + self.bubble_size_factor_checkbox.setChecked(self.bubble_size_factor_value) + self.bubble_size_factor_checkbox.stateChanged.connect(lambda: (setattr(self, 'bubble_size_factor_value', self.bubble_size_factor_checkbox.isChecked()), self._save_rendering_settings(), self._apply_rendering_settings())) + sizing_group_layout.addWidget(self.bubble_size_factor_checkbox) + + # Line Spacing row with live value label + row_ls = QWidget() + ls_layout = QHBoxLayout(row_ls) + ls_layout.setContentsMargins(0, 6, 0, 2) + ls_layout.setSpacing(10) + + ls_label = QLabel("Line Spacing:") + ls_label.setMinimumWidth(110) + ls_layout.addWidget(ls_label) + + self.line_spacing_spinbox = QDoubleSpinBox() + self.line_spacing_spinbox.setMinimum(1.0) + self.line_spacing_spinbox.setMaximum(2.0) + self.line_spacing_spinbox.setSingleStep(0.01) + self.line_spacing_spinbox.setValue(self.line_spacing_value) + self.line_spacing_spinbox.setMinimumWidth(100) + self.line_spacing_spinbox.valueChanged.connect(lambda value: (self._on_line_spacing_changed(value), self._save_rendering_settings(), self._apply_rendering_settings())) + self._disable_spinbox_mousewheel(self.line_spacing_spinbox) + ls_layout.addWidget(self.line_spacing_spinbox) + + self.line_spacing_value_label = QLabel(f"{self.line_spacing_value:.2f}") + self.line_spacing_value_label.setMinimumWidth(50) + ls_layout.addWidget(self.line_spacing_value_label) + ls_layout.addStretch() + + sizing_group_layout.addWidget(row_ls) + + # Max Lines + row_ml = QWidget() + ml_layout = QHBoxLayout(row_ml) + ml_layout.setContentsMargins(0, 2, 0, 4) + ml_layout.setSpacing(10) + + ml_label = QLabel("Max Lines:") + ml_label.setMinimumWidth(110) + ml_layout.addWidget(ml_label) + + self.max_lines_spinbox = QSpinBox() + self.max_lines_spinbox.setMinimum(5) + self.max_lines_spinbox.setMaximum(20) + self.max_lines_spinbox.setValue(self.max_lines_value) + self.max_lines_spinbox.setMinimumWidth(100) + self.max_lines_spinbox.valueChanged.connect(lambda value: (setattr(self, 'max_lines_value', value), self._save_rendering_settings(), self._apply_rendering_settings())) + self._disable_spinbox_mousewheel(self.max_lines_spinbox) + ml_layout.addWidget(self.max_lines_spinbox) + ml_layout.addStretch() + + sizing_group_layout.addWidget(row_ml) + + # Quick Presets (horizontal) merged into sizing group + row_presets = QWidget() + presets_layout = QHBoxLayout(row_presets) + presets_layout.setContentsMargins(0, 6, 0, 2) + presets_layout.setSpacing(10) + + presets_label = QLabel("Quick Presets:") + presets_label.setMinimumWidth(110) + presets_layout.addWidget(presets_label) + + small_preset_btn = QPushButton("Small Bubbles") + small_preset_btn.setMinimumWidth(120) + small_preset_btn.clicked.connect(lambda: self._set_font_preset('small')) + presets_layout.addWidget(small_preset_btn) + + balanced_preset_btn = QPushButton("Balanced") + balanced_preset_btn.setMinimumWidth(120) + balanced_preset_btn.clicked.connect(lambda: self._set_font_preset('balanced')) + presets_layout.addWidget(balanced_preset_btn) + + large_preset_btn = QPushButton("Large Text") + large_preset_btn.setMinimumWidth(120) + large_preset_btn.clicked.connect(lambda: self._set_font_preset('large')) + presets_layout.addWidget(large_preset_btn) + + presets_layout.addStretch() + sizing_group_layout.addWidget(row_presets) + + # Text wrapping mode (moved into Font Settings) + wrap_frame = QWidget() + wrap_layout = QVBoxLayout(wrap_frame) + wrap_layout.setContentsMargins(0, 12, 0, 4) + wrap_layout.setSpacing(5) + + self.strict_wrap_checkbox = self._create_styled_checkbox("Strict text wrapping (force text to fit within bubbles)") + self.strict_wrap_checkbox.setChecked(self.strict_text_wrapping_value) + self.strict_wrap_checkbox.stateChanged.connect(lambda: (setattr(self, 'strict_text_wrapping_value', self.strict_wrap_checkbox.isChecked()), self._save_rendering_settings(), self._apply_rendering_settings())) + wrap_layout.addWidget(self.strict_wrap_checkbox) + + wrap_help_label = QLabel("(Break words with hyphens if needed)") + wrap_help_font = QFont('Arial', 9) + wrap_help_label.setFont(wrap_help_font) + wrap_help_label.setStyleSheet("color: gray; margin-left: 20px;") + wrap_layout.addWidget(wrap_help_label) + + # Force CAPS LOCK directly below strict wrapping + self.force_caps_checkbox = self._create_styled_checkbox("Force CAPS LOCK") + self.force_caps_checkbox.setChecked(self.force_caps_lock_value) + self.force_caps_checkbox.stateChanged.connect(lambda: (setattr(self, 'force_caps_lock_value', self.force_caps_checkbox.isChecked()), self._save_rendering_settings(), self._apply_rendering_settings())) + wrap_layout.addWidget(self.force_caps_checkbox) + + sizing_group_layout.addWidget(wrap_frame) + + # Update multiplier label with loaded value + self._update_multiplier_label(self.font_size_multiplier_value) + + # Add sizing_group to font_render_frame (right column) + font_render_frame_layout.addWidget(self.sizing_group) + + # Font style selection (moved into Font Settings) + font_style_frame = QWidget() + font_style_layout = QHBoxLayout(font_style_frame) + font_style_layout.setContentsMargins(0, 6, 0, 4) + font_style_layout.setSpacing(10) + + font_style_label = QLabel("Font Style:") + font_style_label.setMinimumWidth(110) + font_style_layout.addWidget(font_style_label) + + # Font style will be set from loaded config in _load_rendering_settings + self.font_combo = QComboBox() + self.font_combo.addItems(self._get_available_fonts()) + self.font_combo.setCurrentText(self.font_style_value) + self.font_combo.setMinimumWidth(120) # Reduced for better fit + self.font_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.font_combo.currentTextChanged.connect(lambda: (self._on_font_selected(), self._save_rendering_settings(), self._apply_rendering_settings())) + self._disable_combobox_mousewheel(self.font_combo) # Disable mousewheel scrolling + font_style_layout.addWidget(self.font_combo) + font_style_layout.addStretch() + + font_render_frame_layout.addWidget(font_style_frame) + + # Font color selection (moved into Font Settings) + color_frame = QWidget() + color_layout = QHBoxLayout(color_frame) + color_layout.setContentsMargins(0, 6, 0, 12) + color_layout.setSpacing(10) + + color_label = QLabel("Font Color:") + color_label.setMinimumWidth(110) + color_layout.addWidget(color_label) + + # Color preview frame + self.color_preview_frame = QFrame() + self.color_preview_frame.setFixedSize(40, 30) + self.color_preview_frame.setFrameShape(QFrame.Box) + self.color_preview_frame.setLineWidth(1) + # Initialize with current color + r, g, b = self.text_color_r_value, self.text_color_g_value, self.text_color_b_value + self.color_preview_frame.setStyleSheet(f"background-color: rgb({r},{g},{b}); border: 1px solid #5a9fd4;") + color_layout.addWidget(self.color_preview_frame) + + # RGB display label + r, g, b = self.text_color_r_value, self.text_color_g_value, self.text_color_b_value + self.rgb_label = QLabel(f"RGB({r},{g},{b})") + self.rgb_label.setMinimumWidth(100) + color_layout.addWidget(self.rgb_label) + + # Color picker button + def pick_font_color(): + # Get current color + current_color = QColor(self.text_color_r_value, self.text_color_g_value, self.text_color_b_value) + + # Open color dialog + color = QColorDialog.getColor(current_color, self.dialog, "Choose Font Color") + if color.isValid(): + # Update RGB values + self.text_color_r_value = color.red() + self.text_color_g_value = color.green() + self.text_color_b_value = color.blue() + # Update display + self.rgb_label.setText(f"RGB({color.red()},{color.green()},{color.blue()})") + self._update_color_preview(None) + # Save settings to config + self._save_rendering_settings() + + choose_color_btn = QPushButton("Choose Color") + choose_color_btn.clicked.connect(pick_font_color) + choose_color_btn.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px 15px; }") + color_layout.addWidget(choose_color_btn) + color_layout.addStretch() + + font_render_frame_layout.addWidget(color_frame) + + self._update_color_preview(None) # Initialize with loaded colors + + # Text Shadow settings (moved into Font Settings) + shadow_header = QWidget() + shadow_header_layout = QHBoxLayout(shadow_header) + shadow_header_layout.setContentsMargins(0, 4, 0, 4) + + # Shadow enabled checkbox + self.shadow_enabled_checkbox = self._create_styled_checkbox("Enable Shadow") + self.shadow_enabled_checkbox.setChecked(self.shadow_enabled_value) + self.shadow_enabled_checkbox.stateChanged.connect(lambda: (setattr(self, 'shadow_enabled_value', self.shadow_enabled_checkbox.isChecked()), self._toggle_shadow_controls(), self._save_rendering_settings(), self._apply_rendering_settings())) + shadow_header_layout.addWidget(self.shadow_enabled_checkbox) + shadow_header_layout.addStretch() + + font_render_frame_layout.addWidget(shadow_header) + + # Shadow controls container + self.shadow_controls = QWidget() + shadow_controls_layout = QVBoxLayout(self.shadow_controls) + shadow_controls_layout.setContentsMargins(0, 2, 0, 6) + shadow_controls_layout.setSpacing(5) + + # Shadow color + shadow_color_frame = QWidget() + shadow_color_layout = QHBoxLayout(shadow_color_frame) + shadow_color_layout.setContentsMargins(0, 2, 0, 8) + shadow_color_layout.setSpacing(10) + + shadow_color_label = QLabel("Shadow Color:") + shadow_color_label.setMinimumWidth(110) + shadow_color_layout.addWidget(shadow_color_label) + + # Shadow color preview + self.shadow_preview_frame = QFrame() + self.shadow_preview_frame.setFixedSize(30, 25) + self.shadow_preview_frame.setFrameShape(QFrame.Box) + self.shadow_preview_frame.setLineWidth(1) + # Initialize with current color + sr, sg, sb = self.shadow_color_r_value, self.shadow_color_g_value, self.shadow_color_b_value + self.shadow_preview_frame.setStyleSheet(f"background-color: rgb({sr},{sg},{sb}); border: 1px solid #5a9fd4;") + shadow_color_layout.addWidget(self.shadow_preview_frame) + + # Shadow RGB display label + sr, sg, sb = self.shadow_color_r_value, self.shadow_color_g_value, self.shadow_color_b_value + self.shadow_rgb_label = QLabel(f"RGB({sr},{sg},{sb})") + self.shadow_rgb_label.setMinimumWidth(120) + shadow_color_layout.addWidget(self.shadow_rgb_label) + + # Shadow color picker button + def pick_shadow_color(): + # Get current color + current_color = QColor(self.shadow_color_r_value, self.shadow_color_g_value, self.shadow_color_b_value) + + # Open color dialog + color = QColorDialog.getColor(current_color, self.dialog, "Choose Shadow Color") + if color.isValid(): + # Update RGB values + self.shadow_color_r_value = color.red() + self.shadow_color_g_value = color.green() + self.shadow_color_b_value = color.blue() + # Update display + self.shadow_rgb_label.setText(f"RGB({color.red()},{color.green()},{color.blue()})") + self._update_shadow_preview(None) + # Save settings to config + self._save_rendering_settings() + + choose_shadow_btn = QPushButton("Choose Color") + choose_shadow_btn.setMinimumWidth(120) + choose_shadow_btn.clicked.connect(pick_shadow_color) + choose_shadow_btn.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px 15px; }") + shadow_color_layout.addWidget(choose_shadow_btn) + shadow_color_layout.addStretch() + + shadow_controls_layout.addWidget(shadow_color_frame) + + self._update_shadow_preview(None) # Initialize with loaded colors + + # Shadow offset + offset_frame = QWidget() + offset_layout = QHBoxLayout(offset_frame) + offset_layout.setContentsMargins(0, 2, 0, 0) + offset_layout.setSpacing(10) + + offset_label = QLabel("Shadow Offset:") + offset_label.setMinimumWidth(110) + offset_layout.addWidget(offset_label) + + # X offset + x_label = QLabel("X:") + offset_layout.addWidget(x_label) + + self.shadow_offset_x_spinbox = QSpinBox() + self.shadow_offset_x_spinbox.setMinimum(-10) + self.shadow_offset_x_spinbox.setMaximum(10) + self.shadow_offset_x_spinbox.setValue(self.shadow_offset_x_value) + self.shadow_offset_x_spinbox.setMinimumWidth(60) + self.shadow_offset_x_spinbox.valueChanged.connect(lambda value: (setattr(self, 'shadow_offset_x_value', value), self._save_rendering_settings(), self._apply_rendering_settings())) + self._disable_spinbox_mousewheel(self.shadow_offset_x_spinbox) + offset_layout.addWidget(self.shadow_offset_x_spinbox) + + # Y offset + y_label = QLabel("Y:") + offset_layout.addWidget(y_label) + + self.shadow_offset_y_spinbox = QSpinBox() + self.shadow_offset_y_spinbox.setMinimum(-10) + self.shadow_offset_y_spinbox.setMaximum(10) + self.shadow_offset_y_spinbox.setValue(self.shadow_offset_y_value) + self.shadow_offset_y_spinbox.setMinimumWidth(60) + self.shadow_offset_y_spinbox.valueChanged.connect(lambda value: (setattr(self, 'shadow_offset_y_value', value), self._save_rendering_settings(), self._apply_rendering_settings())) + self._disable_spinbox_mousewheel(self.shadow_offset_y_spinbox) + offset_layout.addWidget(self.shadow_offset_y_spinbox) + offset_layout.addStretch() + + shadow_controls_layout.addWidget(offset_frame) + + # Shadow blur + blur_frame = QWidget() + blur_layout = QHBoxLayout(blur_frame) + blur_layout.setContentsMargins(0, 2, 0, 0) + blur_layout.setSpacing(10) + + blur_label = QLabel("Shadow Blur:") + blur_label.setMinimumWidth(110) + blur_layout.addWidget(blur_label) + + self.shadow_blur_spinbox = QSpinBox() + self.shadow_blur_spinbox.setMinimum(0) + self.shadow_blur_spinbox.setMaximum(10) + self.shadow_blur_spinbox.setValue(self.shadow_blur_value) + self.shadow_blur_spinbox.setMinimumWidth(100) + self.shadow_blur_spinbox.valueChanged.connect(lambda value: (self._on_shadow_blur_changed(value), self._save_rendering_settings(), self._apply_rendering_settings())) + self._disable_spinbox_mousewheel(self.shadow_blur_spinbox) + blur_layout.addWidget(self.shadow_blur_spinbox) + + # Shadow blur value label + self.shadow_blur_value_label = QLabel(f"{self.shadow_blur_value}") + self.shadow_blur_value_label.setMinimumWidth(30) + blur_layout.addWidget(self.shadow_blur_value_label) + + blur_help_label = QLabel("(0=sharp, 10=blurry)") + blur_help_font = QFont('Arial', 9) + blur_help_label.setFont(blur_help_font) + blur_help_label.setStyleSheet("color: gray;") + blur_layout.addWidget(blur_help_label) + blur_layout.addStretch() + + shadow_controls_layout.addWidget(blur_frame) + + # Add shadow_controls to font_render_frame_layout + font_render_frame_layout.addWidget(self.shadow_controls) + + # Initially disable shadow controls + self._toggle_shadow_controls() + + # Add font_render_frame to RIGHT COLUMN + right_column_layout.addWidget(font_render_frame) + + # Control buttons - IN LEFT COLUMN + # Check if ready based on selected provider + # Get API key from main GUI - handle both Tkinter and PySide6 + try: + if hasattr(self.main_gui.api_key_entry, 'text'): # PySide6 QLineEdit + has_api_key = bool(self.main_gui.api_key_entry.text().strip()) + elif hasattr(self.main_gui.api_key_entry, 'get'): # Tkinter Entry + has_api_key = bool(self.main_gui.api_key_entry.get().strip()) + else: + has_api_key = False + except: + has_api_key = False + + provider = self.ocr_provider_value + + # Determine readiness based on provider + if provider == 'google': + has_vision = os.path.exists(self.main_gui.config.get('google_vision_credentials', '')) + is_ready = has_api_key and has_vision + elif provider == 'azure': + has_azure = bool(self.main_gui.config.get('azure_vision_key', '')) + is_ready = has_api_key and has_azure + elif provider == 'custom-api': + is_ready = has_api_key # Only needs API key + else: + # Local providers (manga-ocr, easyocr, etc.) only need API key for translation + is_ready = has_api_key + + control_frame = QWidget() + control_layout = QVBoxLayout(control_frame) + control_layout.setContentsMargins(10, 15, 10, 10) + control_layout.setSpacing(15) + + self.start_button = QPushButton("โ–ถ Start Translation") + self.start_button.clicked.connect(self._start_translation) + self.start_button.setEnabled(is_ready) + self.start_button.setMinimumHeight(90) # Increased from 80 to 90 + self.start_button.setStyleSheet( + "QPushButton { " + " background-color: #28a745; " + " color: white; " + " padding: 22px 30px; " + " font-size: 14pt; " + " font-weight: bold; " + " border-radius: 8px; " + "} " + "QPushButton:hover { background-color: #218838; } " + "QPushButton:disabled { " + " background-color: #2d2d2d; " + " color: #666666; " + "}" + ) + control_layout.addWidget(self.start_button) + + # Add tooltip to show why button is disabled + if not is_ready: + reasons = [] + if not has_api_key: + reasons.append("API key not configured") + if provider == 'google' and not os.path.exists(self.main_gui.config.get('google_vision_credentials', '')): + reasons.append("Google Vision credentials not set") + elif provider == 'azure' and not self.main_gui.config.get('azure_vision_key', ''): + reasons.append("Azure credentials not configured") + tooltip_text = "Cannot start: " + ", ".join(reasons) + self.start_button.setToolTip(tooltip_text) + + self.stop_button = QPushButton("โน Stop") + self.stop_button.clicked.connect(self._stop_translation) + self.stop_button.setEnabled(False) + self.stop_button.setMinimumHeight(90) # Increased from 80 to 90 + self.stop_button.setStyleSheet( + "QPushButton { " + " background-color: #dc3545; " + " color: white; " + " padding: 22px 30px; " + " font-size: 14pt; " + " font-weight: bold; " + " border-radius: 8px; " + "} " + "QPushButton:hover { background-color: #c82333; } " + "QPushButton:disabled { " + " background-color: #2d2d2d; " + " color: #999999; " + "}" + ) + control_layout.addWidget(self.stop_button) + + # Add control buttons to LEFT COLUMN + left_column_layout.addWidget(control_frame) + + # Add stretch to balance columns + left_column_layout.addStretch() + right_column_layout.addStretch() + + # Set size policies to make columns expand and shrink properly + left_column.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + right_column.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + + # Set minimum widths for columns to allow shrinking + left_column.setMinimumWidth(300) + right_column.setMinimumWidth(300) + + # Add columns to container with equal stretch factors + columns_layout.addWidget(left_column, stretch=1) + columns_layout.addWidget(right_column, stretch=1) + + # Make the columns container itself have proper size policy + columns_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + # Add columns container to main layout + main_layout.addWidget(columns_container) + + # Progress frame + progress_frame = QGroupBox("Progress") + progress_frame_font = QFont('Arial', 10) + progress_frame_font.setBold(True) + progress_frame.setFont(progress_frame_font) + progress_frame_layout = QVBoxLayout(progress_frame) + progress_frame_layout.setContentsMargins(10, 10, 10, 8) + progress_frame_layout.setSpacing(6) + + # Overall progress + self.progress_label = QLabel("Ready to start") + progress_label_font = QFont('Arial', 9) + self.progress_label.setFont(progress_label_font) + self.progress_label.setStyleSheet("color: white;") + progress_frame_layout.addWidget(self.progress_label) + + # Create and configure progress bar + self.progress_bar = QProgressBar() + self.progress_bar.setMinimum(0) + self.progress_bar.setMaximum(100) + self.progress_bar.setValue(0) + self.progress_bar.setMinimumHeight(18) + self.progress_bar.setTextVisible(True) + self.progress_bar.setStyleSheet(""" + QProgressBar { + border: 1px solid #4a5568; + border-radius: 3px; + background-color: #2d3748; + text-align: center; + color: white; + } + QProgressBar::chunk { + background-color: white; + } + """) + progress_frame_layout.addWidget(self.progress_bar) + + # Current file status + self.current_file_label = QLabel("") + current_file_font = QFont('Arial', 10) + self.current_file_label.setFont(current_file_font) + self.current_file_label.setStyleSheet("color: lightgray;") + progress_frame_layout.addWidget(self.current_file_label) + + main_layout.addWidget(progress_frame) + + # Log frame + log_frame = QGroupBox("Translation Log") + log_frame_font = QFont('Arial', 10) + log_frame_font.setBold(True) + log_frame.setFont(log_frame_font) + log_frame_layout = QVBoxLayout(log_frame) + log_frame_layout.setContentsMargins(10, 10, 10, 8) + log_frame_layout.setSpacing(6) + + # Log text widget (QTextEdit handles scrolling automatically) + self.log_text = QTextEdit() + self.log_text.setReadOnly(True) + self.log_text.setMinimumHeight(600) # Increased from 400 to 600 for better visibility + self.log_text.setStyleSheet(""" + QTextEdit { + background-color: #1e1e1e; + color: white; + font-family: 'Consolas', 'Courier New', monospace; + font-size: 10pt; + border: 1px solid #4a5568; + } + """) + log_frame_layout.addWidget(self.log_text) + + main_layout.addWidget(log_frame) + + # Restore persistent log from previous sessions + self._restore_persistent_log() + + def _restore_persistent_log(self): + """Restore log messages from persistent storage""" + try: + with MangaTranslationTab._persistent_log_lock: + if MangaTranslationTab._persistent_log: + # PySide6 QTextEdit + color_map = { + 'info': 'white', + 'success': 'green', + 'warning': 'orange', + 'error': 'red', + 'debug': 'lightblue' + } + for message, level in MangaTranslationTab._persistent_log: + color = color_map.get(level, 'white') + self.log_text.setTextColor(QColor(color)) + self.log_text.append(message) + except Exception as e: + print(f"Failed to restore persistent log: {e}") + + def _show_help_dialog(self, title: str, message: str): + """Show a help dialog with the given title and message""" + # Create a PySide6 dialog + help_dialog = QDialog(self.dialog) + help_dialog.setWindowTitle(title) + help_dialog.resize(500, 400) + help_dialog.setModal(True) + + # Main layout + main_layout = QVBoxLayout(help_dialog) + main_layout.setContentsMargins(20, 20, 20, 20) + main_layout.setSpacing(10) + + # Icon and title + title_frame = QWidget() + title_layout = QHBoxLayout(title_frame) + title_layout.setContentsMargins(0, 0, 0, 10) + + icon_label = QLabel("โ„น๏ธ") + icon_font = QFont('Arial', 20) + icon_label.setFont(icon_font) + title_layout.addWidget(icon_label) + + title_label = QLabel(title) + title_font = QFont('Arial', 12) + title_font.setBold(True) + title_label.setFont(title_font) + title_layout.addWidget(title_label) + title_layout.addStretch() + + main_layout.addWidget(title_frame) + + # Help text in a scrollable text widget + text_widget = QTextEdit() + text_widget.setReadOnly(True) + text_widget.setPlainText(message) + text_font = QFont('Arial', 10) + text_widget.setFont(text_font) + main_layout.addWidget(text_widget) + + # Close button + close_btn = QPushButton("Close") + close_btn.clicked.connect(help_dialog.accept) + close_btn.setStyleSheet("QPushButton { background-color: #6c757d; color: white; padding: 5px 20px; }") + main_layout.addWidget(close_btn, alignment=Qt.AlignCenter) + + # Show the dialog + help_dialog.exec() + + def _on_visual_context_toggle(self): + """Handle visual context toggle""" + enabled = self.visual_context_enabled_value + self.main_gui.config['manga_visual_context_enabled'] = enabled + + # Update translator if it exists + if self.translator: + self.translator.visual_context_enabled = enabled + + # Save config + if hasattr(self.main_gui, 'save_config'): + self.main_gui.save_config(show_message=False) + + # Log the change + if enabled: + self._log("๐Ÿ“ท Visual context ENABLED - Images will be sent to API", "info") + self._log(" Make sure you're using a vision-capable model", "warning") + else: + self._log("๐Ÿ“ Visual context DISABLED - Text-only mode", "info") + self._log(" Compatible with non-vision models (Claude, GPT-3.5, etc.)", "success") + + def _open_advanced_settings(self): + """Open the manga advanced settings dialog""" + try: + def on_settings_saved(settings): + """Callback when settings are saved""" + # Update config with new settings + self.main_gui.config['manga_settings'] = settings + + # Mirror critical font size values into nested settings (avoid legacy top-level min key) + try: + rendering = settings.get('rendering', {}) if isinstance(settings, dict) else {} + font_sizing = settings.get('font_sizing', {}) if isinstance(settings, dict) else {} + min_from_dialog = rendering.get('auto_min_size', font_sizing.get('min_readable', font_sizing.get('min_size'))) + max_from_dialog = rendering.get('auto_max_size', font_sizing.get('max_size')) + if min_from_dialog is not None: + ms = self.main_gui.config.setdefault('manga_settings', {}) + rend = ms.setdefault('rendering', {}) + font = ms.setdefault('font_sizing', {}) + rend['auto_min_size'] = int(min_from_dialog) + font['min_size'] = int(min_from_dialog) + if hasattr(self, 'auto_min_size_value'): + self.auto_min_size_value = int(min_from_dialog) + if max_from_dialog is not None: + self.main_gui.config['manga_max_font_size'] = int(max_from_dialog) + if hasattr(self, 'max_font_size_value'): + self.max_font_size_value = int(max_from_dialog) + except Exception: + pass + + # Persist mirrored values + try: + if hasattr(self.main_gui, 'save_config'): + self.main_gui.save_config(show_message=False) + except Exception: + pass + + # Reload settings in translator if it exists + if self.translator: + self._log("๐Ÿ“‹ Reloading settings in translator...", "info") + # The translator will pick up new settings on next operation + + self._log("โœ… Advanced settings saved and applied", "success") + + # Open the settings dialog + # Note: MangaSettingsDialog is still Tkinter-based, so pass Tkinter root + MangaSettingsDialog( + parent=self.main_gui.master, # Use Tkinter root instead of PySide6 dialog + main_gui=self.main_gui, + config=self.main_gui.config, + callback=on_settings_saved + ) + + except Exception as e: + from PySide6.QtWidgets import QMessageBox + self._log(f"โŒ Error opening settings dialog: {str(e)}", "error") + QMessageBox.critical(self.dialog, "Error", f"Failed to open settings dialog:\n{str(e)}") + + def _toggle_font_size_mode(self): + """Toggle between auto, fixed size and multiplier modes""" + mode = self.font_size_mode_value + + # Handle main frames (fixed size and multiplier) + if hasattr(self, 'fixed_size_frame') and hasattr(self, 'multiplier_frame'): + if mode == "fixed": + self.fixed_size_frame.show() + self.multiplier_frame.hide() + if hasattr(self, 'constraint_frame'): + self.constraint_frame.hide() + elif mode == "multiplier": + self.fixed_size_frame.hide() + self.multiplier_frame.show() + if hasattr(self, 'constraint_frame'): + self.constraint_frame.show() + else: # auto + self.fixed_size_frame.hide() + self.multiplier_frame.hide() + if hasattr(self, 'constraint_frame'): + self.constraint_frame.hide() + + # MIN/MAX FIELDS ARE ALWAYS VISIBLE - NEVER HIDE THEM + # They are packed at creation time and stay visible in all modes + + # Only save and apply if we're not initializing + if not hasattr(self, '_initializing') or not self._initializing: + self._save_rendering_settings() + self._apply_rendering_settings() + + def _update_multiplier_label(self, value): + """Update multiplier label and value variable""" + self.font_size_multiplier_value = float(value) # UPDATE THE VALUE VARIABLE! + self.multiplier_label.setText(f"{float(value):.1f}x") + + def _on_line_spacing_changed(self, value): + """Update line spacing value label and value variable""" + self.line_spacing_value = float(value) # UPDATE THE VALUE VARIABLE! + try: + if hasattr(self, 'line_spacing_value_label'): + self.line_spacing_value_label.setText(f"{float(value):.2f}") + except Exception: + pass + + def _on_shadow_blur_changed(self, value): + """Update shadow blur value label and value variable""" + self.shadow_blur_value = int(float(value)) # UPDATE THE VALUE VARIABLE! + try: + if hasattr(self, 'shadow_blur_value_label'): + self.shadow_blur_value_label.setText(f"{int(float(value))}") + except Exception: + pass + + def _on_ft_only_bg_opacity_changed(self): + """Handle free text only background opacity checkbox change (PySide6)""" + # Update the value from checkbox state + self.free_text_only_bg_opacity_value = self.ft_only_checkbox.isChecked() + + def _update_color_preview(self, event=None): + """Update the font color preview""" + r = self.text_color_r_value + g = self.text_color_g_value + b = self.text_color_b_value + if hasattr(self, 'color_preview_frame'): + self.color_preview_frame.setStyleSheet(f"background-color: rgb({r},{g},{b}); border: 1px solid #5a9fd4;") + # Auto-save and apply on change + if event is not None: # Only save on user interaction, not initial load + self._save_rendering_settings() + self._apply_rendering_settings() + + def _update_shadow_preview(self, event=None): + """Update the shadow color preview""" + r = self.shadow_color_r_value + g = self.shadow_color_g_value + b = self.shadow_color_b_value + if hasattr(self, 'shadow_preview_frame'): + self.shadow_preview_frame.setStyleSheet(f"background-color: rgb({r},{g},{b}); border: 1px solid #5a9fd4;") + # Auto-save and apply on change + if event is not None: # Only save on user interaction, not initial load + self._save_rendering_settings() + self._apply_rendering_settings() + + def _toggle_azure_key_visibility(self, state): + """Toggle visibility of Azure API key""" + from PySide6.QtWidgets import QLineEdit + from PySide6.QtCore import Qt + + # Check the checkbox state directly to be sure + is_checked = self.show_azure_key_checkbox.isChecked() + + if is_checked: + # Show the key + self.azure_key_entry.setEchoMode(QLineEdit.Normal) + else: + # Hide the key + self.azure_key_entry.setEchoMode(QLineEdit.Password) + + def _toggle_shadow_controls(self): + """Enable/disable shadow controls based on checkbox""" + if self.shadow_enabled_value: + if hasattr(self, 'shadow_controls'): + self.shadow_controls.setEnabled(True) + else: + if hasattr(self, 'shadow_controls'): + self.shadow_controls.setEnabled(False) + + def _set_font_preset(self, preset: str): + """Apply font sizing preset (moved from dialog)""" + try: + if preset == 'small': + self.font_algorithm_value = 'conservative' + self.auto_min_size_value = 10 + self.max_font_size_value = 32 + self.prefer_larger_value = False + self.bubble_size_factor_value = True + self.line_spacing_value = 1.2 + self.max_lines_value = 8 + elif preset == 'balanced': + self.font_algorithm_value = 'smart' + self.auto_min_size_value = 12 + self.max_font_size_value = 48 + self.prefer_larger_value = True + self.bubble_size_factor_value = True + self.line_spacing_value = 1.3 + self.max_lines_value = 10 + elif preset == 'large': + self.font_algorithm_value = 'aggressive' + self.auto_min_size_value = 14 + self.max_font_size_value = 64 + self.prefer_larger_value = True + self.bubble_size_factor_value = False + self.line_spacing_value = 1.4 + self.max_lines_value = 12 + + # Update all spinboxes with new values + if hasattr(self, 'min_size_spinbox'): + self.min_size_spinbox.setValue(self.auto_min_size_value) + if hasattr(self, 'max_size_spinbox'): + self.max_size_spinbox.setValue(self.max_font_size_value) + if hasattr(self, 'line_spacing_spinbox'): + self.line_spacing_spinbox.setValue(self.line_spacing_value) + if hasattr(self, 'max_lines_spinbox'): + self.max_lines_spinbox.setValue(self.max_lines_value) + + # Update checkboxes + if hasattr(self, 'prefer_larger_checkbox'): + self.prefer_larger_checkbox.setChecked(self.prefer_larger_value) + if hasattr(self, 'bubble_size_factor_checkbox'): + self.bubble_size_factor_checkbox.setChecked(self.bubble_size_factor_value) + + # Update the line spacing label + if hasattr(self, 'line_spacing_value_label'): + self.line_spacing_value_label.setText(f"{float(self.line_spacing_value):.2f}") + + self._save_rendering_settings() + except Exception as e: + self._log(f"Error setting preset: {e}", "debug") + + def _enable_widget_tree(self, widget): + """Recursively enable a widget and its children (PySide6 version)""" + try: + widget.setEnabled(True) + except: + pass + # PySide6 way to iterate children + try: + for child in widget.children(): + if hasattr(child, 'setEnabled'): + self._enable_widget_tree(child) + except: + pass + + def _disable_widget_tree(self, widget): + """Recursively disable a widget and its children (PySide6 version)""" + try: + widget.setEnabled(False) + except: + pass + # PySide6 way to iterate children + try: + for child in widget.children(): + if hasattr(child, 'setEnabled'): + self._disable_widget_tree(child) + except: + pass + + def _load_rendering_settings(self): + """Load text rendering settings from config""" + config = self.main_gui.config + + # One-time migration for legacy min font size key + try: + legacy_min = config.get('manga_min_readable_size', None) + if legacy_min is not None: + ms = config.setdefault('manga_settings', {}) + rend = ms.setdefault('rendering', {}) + font = ms.setdefault('font_sizing', {}) + current_min = rend.get('auto_min_size', font.get('min_size')) + if current_min is None or int(current_min) < int(legacy_min): + rend['auto_min_size'] = int(legacy_min) + font['min_size'] = int(legacy_min) + # Remove legacy key + try: + del config['manga_min_readable_size'] + except Exception: + pass + # Persist migration silently + if hasattr(self.main_gui, 'save_config'): + self.main_gui.save_config(show_message=False) + except Exception: + pass + + # Get inpainting settings from the nested location + manga_settings = config.get('manga_settings', {}) + inpaint_settings = manga_settings.get('inpainting', {}) + + # Load inpaint method from the correct location (no Tkinter variables in PySide6) + self.inpaint_method_value = inpaint_settings.get('method', 'local') + self.local_model_type_value = inpaint_settings.get('local_method', 'anime_onnx') + + # Load model paths + self.local_model_path_value = '' + for model_type in ['aot', 'aot_onnx', 'lama', 'lama_onnx', 'anime', 'anime_onnx', 'mat', 'ollama', 'sd_local']: + path = inpaint_settings.get(f'{model_type}_model_path', '') + if model_type == self.local_model_type_value: + self.local_model_path_value = path + + # Initialize with defaults (plain Python values, no Tkinter variables) + self.bg_opacity_value = config.get('manga_bg_opacity', 130) + self.free_text_only_bg_opacity_value = config.get('manga_free_text_only_bg_opacity', True) + self.bg_style_value = config.get('manga_bg_style', 'circle') + self.bg_reduction_value = config.get('manga_bg_reduction', 1.0) + self.font_size_value = config.get('manga_font_size', 0) + + self.selected_font_path = config.get('manga_font_path', None) + self.skip_inpainting_value = config.get('manga_skip_inpainting', False) + self.inpaint_quality_value = config.get('manga_inpaint_quality', 'high') + self.inpaint_dilation_value = config.get('manga_inpaint_dilation', 15) + self.inpaint_passes_value = config.get('manga_inpaint_passes', 2) + + self.font_size_mode_value = config.get('manga_font_size_mode', 'fixed') + self.font_size_multiplier_value = config.get('manga_font_size_multiplier', 1.0) + + # Auto fit style for auto mode + try: + rend_cfg = (config.get('manga_settings', {}) or {}).get('rendering', {}) + except Exception: + rend_cfg = {} + self.auto_fit_style_value = rend_cfg.get('auto_fit_style', 'balanced') + + # Auto minimum font size (from rendering or font_sizing) + try: + font_cfg = (config.get('manga_settings', {}) or {}).get('font_sizing', {}) + except Exception: + font_cfg = {} + auto_min_default = rend_cfg.get('auto_min_size', font_cfg.get('min_size', 10)) + self.auto_min_size_value = int(auto_min_default) + + self.force_caps_lock_value = config.get('manga_force_caps_lock', True) + self.constrain_to_bubble_value = config.get('manga_constrain_to_bubble', True) + + # Advanced font sizing (from manga_settings.font_sizing) + font_settings = (config.get('manga_settings', {}) or {}).get('font_sizing', {}) + self.font_algorithm_value = str(font_settings.get('algorithm', 'smart')) + self.prefer_larger_value = bool(font_settings.get('prefer_larger', True)) + self.bubble_size_factor_value = bool(font_settings.get('bubble_size_factor', True)) + self.line_spacing_value = float(font_settings.get('line_spacing', 1.3)) + self.max_lines_value = int(font_settings.get('max_lines', 10)) + + # Determine effective max font size with fallback + font_max_top = config.get('manga_max_font_size', None) + nested_ms = config.get('manga_settings', {}) if isinstance(config.get('manga_settings', {}), dict) else {} + nested_render = nested_ms.get('rendering', {}) if isinstance(nested_ms.get('rendering', {}), dict) else {} + nested_font = nested_ms.get('font_sizing', {}) if isinstance(nested_ms.get('font_sizing', {}), dict) else {} + effective_max = font_max_top if font_max_top is not None else ( + nested_render.get('auto_max_size', nested_font.get('max_size', 48)) + ) + self.max_font_size_value = int(effective_max) + + # If top-level keys were missing, mirror max now (won't save during initialization) + if font_max_top is None: + self.main_gui.config['manga_max_font_size'] = int(effective_max) + + self.strict_text_wrapping_value = config.get('manga_strict_text_wrapping', True) + + # Font color settings + manga_text_color = config.get('manga_text_color', [102, 0, 0]) + self.text_color_r_value = manga_text_color[0] + self.text_color_g_value = manga_text_color[1] + self.text_color_b_value = manga_text_color[2] + + # Shadow settings + self.shadow_enabled_value = config.get('manga_shadow_enabled', True) + + manga_shadow_color = config.get('manga_shadow_color', [204, 128, 128]) + self.shadow_color_r_value = manga_shadow_color[0] + self.shadow_color_g_value = manga_shadow_color[1] + self.shadow_color_b_value = manga_shadow_color[2] + + self.shadow_offset_x_value = config.get('manga_shadow_offset_x', 2) + self.shadow_offset_y_value = config.get('manga_shadow_offset_y', 2) + self.shadow_blur_value = config.get('manga_shadow_blur', 0) + + # Initialize font_style with saved value or default + self.font_style_value = config.get('manga_font_style', 'Default') + + # Full page context settings + self.full_page_context_value = config.get('manga_full_page_context', False) + + self.full_page_context_prompt = config.get('manga_full_page_context_prompt', + "You will receive multiple text segments from a manga page, each prefixed with an index like [0], [1], etc. " + "Translate each segment considering the context of all segments together. " + "Maintain consistency in character names, tone, and style across all translations.\n\n" + "CRITICAL: Return your response as a valid JSON object where each key includes BOTH the index prefix " + "AND the original text EXACTLY as provided (e.g., '[0] ใ“ใ‚“ใซใกใฏ'), and each value is the translation.\n" + "This is essential for correct mapping - do not modify or omit the index prefixes!\n\n" + "Make sure to properly escape any special characters in the JSON:\n" + "- Use \\n for newlines\n" + "- Use \\\" for quotes\n" + "- Use \\\\ for backslashes\n\n" + "Example:\n" + '{\n' + ' "[0] ใ“ใ‚“ใซใกใฏ": "Hello",\n' + ' "[1] ใ‚ใ‚ŠใŒใจใ†": "Thank you",\n' + ' "[2] ใ•ใ‚ˆใ†ใชใ‚‰": "Goodbye"\n' + '}\n\n' + 'REMEMBER: Keep the [index] prefix in each JSON key exactly as shown in the input!' + ) + + # Load OCR prompt (UPDATED: Improved default) + self.ocr_prompt = config.get('manga_ocr_prompt', + "YOU ARE A TEXT EXTRACTION MACHINE. EXTRACT EXACTLY WHAT YOU SEE.\n\n" + "ABSOLUTE RULES:\n" + "1. OUTPUT ONLY THE VISIBLE TEXT/SYMBOLS - NOTHING ELSE\n" + "2. NEVER TRANSLATE OR MODIFY\n" + "3. NEVER EXPLAIN, DESCRIBE, OR COMMENT\n" + "4. NEVER SAY \"I can't\" or \"I cannot\" or \"no text\" or \"blank image\"\n" + "5. IF YOU SEE DOTS, OUTPUT THE DOTS: .\n" + "6. IF YOU SEE PUNCTUATION, OUTPUT THE PUNCTUATION\n" + "7. IF YOU SEE A SINGLE CHARACTER, OUTPUT THAT CHARACTER\n" + "8. IF YOU SEE NOTHING, OUTPUT NOTHING (empty response)\n\n" + "LANGUAGE PRESERVATION:\n" + "- Korean text โ†’ Output in Korean\n" + "- Japanese text โ†’ Output in Japanese\n" + "- Chinese text โ†’ Output in Chinese\n" + "- English text โ†’ Output in English\n" + "- CJK quotation marks (ใ€Œใ€ใ€Žใ€ใ€ใ€‘ใ€Šใ€‹ใ€ˆใ€‰) โ†’ Preserve exactly as shown\n\n" + "FORMATTING:\n" + "- OUTPUT ALL TEXT ON A SINGLE LINE WITH NO LINE BREAKS\n" + "- NEVER use \\n or line breaks in your output\n\n" + "FORBIDDEN RESPONSES:\n" + "- \"I can see this appears to be...\"\n" + "- \"I cannot make out any clear text...\"\n" + "- \"This appears to be blank...\"\n" + "- \"If there is text present...\"\n" + "- ANY explanatory text\n\n" + "YOUR ONLY OUTPUT: The exact visible text. Nothing more. Nothing less.\n" + "If image has a dot โ†’ Output: .\n" + "If image has two dots โ†’ Output: . .\n" + "If image has text โ†’ Output: [that text]\n" + "If image is truly blank โ†’ Output: [empty/no response]" + ) + # Visual context setting + self.visual_context_enabled_value = self.main_gui.config.get('manga_visual_context_enabled', True) + self.qwen2vl_model_size = config.get('qwen2vl_model_size', '1') # Default to '1' (2B) + + # Initialize RapidOCR settings + self.rapidocr_use_recognition_value = self.main_gui.config.get('rapidocr_use_recognition', True) + self.rapidocr_language_value = self.main_gui.config.get('rapidocr_language', 'auto') + self.rapidocr_detection_mode_value = self.main_gui.config.get('rapidocr_detection_mode', 'document') + + # Output settings + self.create_subfolder_value = config.get('manga_create_subfolder', True) + + def _save_rendering_settings(self): + """Save rendering settings with validation""" + # Don't save during initialization + if hasattr(self, '_initializing') and self._initializing: + return + + # Validate that variables exist and have valid values before saving + try: + # Ensure manga_settings structure exists + if 'manga_settings' not in self.main_gui.config: + self.main_gui.config['manga_settings'] = {} + if 'inpainting' not in self.main_gui.config['manga_settings']: + self.main_gui.config['manga_settings']['inpainting'] = {} + + # Save to nested location + inpaint = self.main_gui.config['manga_settings']['inpainting'] + if hasattr(self, 'inpaint_method_value'): + inpaint['method'] = self.inpaint_method_value + if hasattr(self, 'local_model_type_value'): + inpaint['local_method'] = self.local_model_type_value + model_type = self.local_model_type_value + if hasattr(self, 'local_model_path_value'): + inpaint[f'{model_type}_model_path'] = self.local_model_path_value + + # Add new inpainting settings + if hasattr(self, 'inpaint_method_value'): + self.main_gui.config['manga_inpaint_method'] = self.inpaint_method_value + if hasattr(self, 'local_model_type_value'): + self.main_gui.config['manga_local_inpaint_model'] = self.local_model_type_value + + # Save model paths for each type + for model_type in ['aot', 'lama', 'lama_onnx', 'anime', 'mat', 'ollama', 'sd_local']: + if hasattr(self, 'local_model_type_value'): + if model_type == self.local_model_type_value: + if hasattr(self, 'local_model_path_value'): + path = self.local_model_path_value + if path: + self.main_gui.config[f'manga_{model_type}_model_path'] = path + + # Save all other settings with validation + if hasattr(self, 'bg_opacity_value'): + self.main_gui.config['manga_bg_opacity'] = self.bg_opacity_value + if hasattr(self, 'bg_style_value'): + self.main_gui.config['manga_bg_style'] = self.bg_style_value + if hasattr(self, 'bg_reduction_value'): + self.main_gui.config['manga_bg_reduction'] = self.bg_reduction_value + + # Save free-text-only background opacity toggle + if hasattr(self, 'free_text_only_bg_opacity_value'): + self.main_gui.config['manga_free_text_only_bg_opacity'] = bool(self.free_text_only_bg_opacity_value) + + # CRITICAL: Font size settings - validate before saving + if hasattr(self, 'font_size_value'): + value = self.font_size_value + self.main_gui.config['manga_font_size'] = value + + if hasattr(self, 'max_font_size_value'): + value = self.max_font_size_value + # Validate the value is reasonable + if 0 <= value <= 200: + self.main_gui.config['manga_max_font_size'] = value + + # Mirror these into nested manga_settings so the dialog and integration stay in sync + try: + ms = self.main_gui.config.setdefault('manga_settings', {}) + rend = ms.setdefault('rendering', {}) + font = ms.setdefault('font_sizing', {}) + # Mirror bounds + if hasattr(self, 'auto_min_size_value'): + rend['auto_min_size'] = int(self.auto_min_size_value) + font['min_size'] = int(self.auto_min_size_value) + if hasattr(self, 'max_font_size_value'): + rend['auto_max_size'] = int(self.max_font_size_value) + font['max_size'] = int(self.max_font_size_value) + # Persist advanced font sizing controls + if hasattr(self, 'font_algorithm_value'): + font['algorithm'] = str(self.font_algorithm_value) + if hasattr(self, 'prefer_larger_value'): + font['prefer_larger'] = bool(self.prefer_larger_value) + if hasattr(self, 'bubble_size_factor_value'): + font['bubble_size_factor'] = bool(self.bubble_size_factor_value) + if hasattr(self, 'line_spacing_value'): + font['line_spacing'] = float(self.line_spacing_value) + if hasattr(self, 'max_lines_value'): + font['max_lines'] = int(self.max_lines_value) + if hasattr(self, 'auto_fit_style_value'): + rend['auto_fit_style'] = str(self.auto_fit_style_value) + except Exception: + pass + + # Continue with other settings + self.main_gui.config['manga_font_path'] = self.selected_font_path + + if hasattr(self, 'skip_inpainting_value'): + self.main_gui.config['manga_skip_inpainting'] = self.skip_inpainting_value + if hasattr(self, 'inpaint_quality_value'): + self.main_gui.config['manga_inpaint_quality'] = self.inpaint_quality_value + if hasattr(self, 'inpaint_dilation_value'): + self.main_gui.config['manga_inpaint_dilation'] = self.inpaint_dilation_value + if hasattr(self, 'inpaint_passes_value'): + self.main_gui.config['manga_inpaint_passes'] = self.inpaint_passes_value + if hasattr(self, 'font_size_mode_value'): + self.main_gui.config['manga_font_size_mode'] = self.font_size_mode_value + if hasattr(self, 'font_size_multiplier_value'): + self.main_gui.config['manga_font_size_multiplier'] = self.font_size_multiplier_value + if hasattr(self, 'font_style_value'): + self.main_gui.config['manga_font_style'] = self.font_style_value + if hasattr(self, 'constrain_to_bubble_value'): + self.main_gui.config['manga_constrain_to_bubble'] = self.constrain_to_bubble_value + if hasattr(self, 'strict_text_wrapping_value'): + self.main_gui.config['manga_strict_text_wrapping'] = self.strict_text_wrapping_value + if hasattr(self, 'force_caps_lock_value'): + self.main_gui.config['manga_force_caps_lock'] = self.force_caps_lock_value + + # Save font color as list + if hasattr(self, 'text_color_r_value') and hasattr(self, 'text_color_g_value') and hasattr(self, 'text_color_b_value'): + self.main_gui.config['manga_text_color'] = [ + self.text_color_r_value, + self.text_color_g_value, + self.text_color_b_value + ] + + # Save shadow settings + if hasattr(self, 'shadow_enabled_value'): + self.main_gui.config['manga_shadow_enabled'] = self.shadow_enabled_value + if hasattr(self, 'shadow_color_r_value') and hasattr(self, 'shadow_color_g_value') and hasattr(self, 'shadow_color_b_value'): + self.main_gui.config['manga_shadow_color'] = [ + self.shadow_color_r_value, + self.shadow_color_g_value, + self.shadow_color_b_value + ] + if hasattr(self, 'shadow_offset_x_value'): + self.main_gui.config['manga_shadow_offset_x'] = self.shadow_offset_x_value + if hasattr(self, 'shadow_offset_y_value'): + self.main_gui.config['manga_shadow_offset_y'] = self.shadow_offset_y_value + if hasattr(self, 'shadow_blur_value'): + self.main_gui.config['manga_shadow_blur'] = self.shadow_blur_value + + # Save output settings + if hasattr(self, 'create_subfolder_value'): + self.main_gui.config['manga_create_subfolder'] = self.create_subfolder_value + + # Save full page context settings + if hasattr(self, 'full_page_context_value'): + self.main_gui.config['manga_full_page_context'] = self.full_page_context_value + if hasattr(self, 'full_page_context_prompt'): + self.main_gui.config['manga_full_page_context_prompt'] = self.full_page_context_prompt + + # OCR prompt + if hasattr(self, 'ocr_prompt'): + self.main_gui.config['manga_ocr_prompt'] = self.ocr_prompt + + # Qwen and custom models + if hasattr(self, 'qwen2vl_model_size'): + self.main_gui.config['qwen2vl_model_size'] = self.qwen2vl_model_size + + # RapidOCR specific settings + if hasattr(self, 'rapidocr_use_recognition_value'): + self.main_gui.config['rapidocr_use_recognition'] = self.rapidocr_use_recognition_value + if hasattr(self, 'rapidocr_detection_mode_value'): + self.main_gui.config['rapidocr_detection_mode'] = self.rapidocr_detection_mode_value + if hasattr(self, 'rapidocr_language_value'): + self.main_gui.config['rapidocr_language'] = self.rapidocr_language_value + + # Auto-save to disk (PySide6 version - no Tkinter black window issue) + # Settings are stored in self.main_gui.config and persisted immediately + if hasattr(self.main_gui, 'save_config'): + self.main_gui.save_config(show_message=False) + + except Exception as e: + # Log error but don't crash + print(f"Error saving manga settings: {e}") + + def _on_context_toggle(self): + """Handle full page context toggle""" + enabled = self.full_page_context_value + self._save_rendering_settings() + + def _edit_context_prompt(self): + """Open dialog to edit full page context prompt and OCR prompt""" + from PySide6.QtWidgets import (QDialog, QVBoxLayout, QLabel, QTextEdit, + QPushButton, QHBoxLayout) + from PySide6.QtCore import Qt + + # Create PySide6 dialog + dialog = QDialog(self.dialog) + dialog.setWindowTitle("Edit Prompts") + dialog.setMinimumSize(700, 600) + + layout = QVBoxLayout(dialog) + + # Instructions + instructions = QLabel( + "Edit the prompt used for full page context translation.\n" + "This will be appended to the main translation system prompt." + ) + instructions.setWordWrap(True) + layout.addWidget(instructions) + + # Full Page Context label + context_label = QLabel("Full Page Context Prompt:") + font = context_label.font() + font.setBold(True) + context_label.setFont(font) + layout.addWidget(context_label) + + # Text editor for context + text_editor = QTextEdit() + text_editor.setMinimumHeight(200) + text_editor.setPlainText(self.full_page_context_prompt) + layout.addWidget(text_editor) + + # OCR Prompt label + ocr_label = QLabel("OCR System Prompt:") + ocr_label.setFont(font) + layout.addWidget(ocr_label) + + # Text editor for OCR + ocr_editor = QTextEdit() + ocr_editor.setMinimumHeight(200) + + # Get current OCR prompt + if hasattr(self, 'ocr_prompt'): + ocr_editor.setPlainText(self.ocr_prompt) + else: + ocr_editor.setPlainText("") + + layout.addWidget(ocr_editor) + + def save_prompt(): + self.full_page_context_prompt = text_editor.toPlainText().strip() + self.ocr_prompt = ocr_editor.toPlainText().strip() + + # Save to config + self.main_gui.config['manga_full_page_context_prompt'] = self.full_page_context_prompt + self.main_gui.config['manga_ocr_prompt'] = self.ocr_prompt + + self._save_rendering_settings() + self._log("โœ… Updated prompts", "success") + dialog.accept() + + def reset_prompt(): + default_prompt = ( + "You will receive multiple text segments from a manga page, each prefixed with an index like [0], [1], etc. " + "Translate each segment considering the context of all segments together. " + "Maintain consistency in character names, tone, and style across all translations.\n\n" + "CRITICAL: Return your response as a valid JSON object where each key includes BOTH the index prefix " + "AND the original text EXACTLY as provided (e.g., '[0] ใ“ใ‚“ใซใกใฏ'), and each value is the translation.\n" + "This is essential for correct mapping - do not modify or omit the index prefixes!\n\n" + "Make sure to properly escape any special characters in the JSON:\n" + "- Use \\n for newlines\n" + "- Use \\\" for quotes\n" + "- Use \\\\ for backslashes\n\n" + "Example:\n" + '{\n' + ' "[0] ใ“ใ‚“ใซใกใฏ": "Hello",\n' + ' "[1] ใ‚ใ‚ŠใŒใจใ†": "Thank you",\n' + ' "[2] ใ•ใ‚ˆใ†ใชใ‚‰": "Goodbye"\n' + '}\n\n' + 'REMEMBER: Keep the [index] prefix in each JSON key exactly as shown in the input!' + ) + text_editor.setPlainText(default_prompt) + + # UPDATED: Improved OCR prompt (matches ocr_manager.py) + default_ocr = ( + "YOU ARE A TEXT EXTRACTION MACHINE. EXTRACT EXACTLY WHAT YOU SEE.\n\n" + "ABSOLUTE RULES:\n" + "1. OUTPUT ONLY THE VISIBLE TEXT/SYMBOLS - NOTHING ELSE\n" + "2. NEVER TRANSLATE OR MODIFY\n" + "3. NEVER EXPLAIN, DESCRIBE, OR COMMENT\n" + "4. NEVER SAY \"I can't\" or \"I cannot\" or \"no text\" or \"blank image\"\n" + "5. IF YOU SEE DOTS, OUTPUT THE DOTS: .\n" + "6. IF YOU SEE PUNCTUATION, OUTPUT THE PUNCTUATION\n" + "7. IF YOU SEE A SINGLE CHARACTER, OUTPUT THAT CHARACTER\n" + "8. IF YOU SEE NOTHING, OUTPUT NOTHING (empty response)\n\n" + "LANGUAGE PRESERVATION:\n" + "- Korean text โ†’ Output in Korean\n" + "- Japanese text โ†’ Output in Japanese\n" + "- Chinese text โ†’ Output in Chinese\n" + "- English text โ†’ Output in English\n" + "- CJK quotation marks (ใ€Œใ€ใ€Žใ€ใ€ใ€‘ใ€Šใ€‹ใ€ˆใ€‰) โ†’ Preserve exactly as shown\n\n" + "FORMATTING:\n" + "- OUTPUT ALL TEXT ON A SINGLE LINE WITH NO LINE BREAKS\n" + "- NEVER use \\n or line breaks in your output\n\n" + "FORBIDDEN RESPONSES:\n" + "- \"I can see this appears to be...\"\n" + "- \"I cannot make out any clear text...\"\n" + "- \"This appears to be blank...\"\n" + "- \"If there is text present...\"\n" + "- ANY explanatory text\n\n" + "YOUR ONLY OUTPUT: The exact visible text. Nothing more. Nothing less.\n" + "If image has a dot โ†’ Output: .\n" + "If image has two dots โ†’ Output: . .\n" + "If image has text โ†’ Output: [that text]\n" + "If image is truly blank โ†’ Output: [empty/no response]" + ) + ocr_editor.setPlainText(default_ocr) + + # Button layout + button_layout = QHBoxLayout() + + save_btn = QPushButton("Save") + save_btn.clicked.connect(save_prompt) + button_layout.addWidget(save_btn) + + reset_btn = QPushButton("Reset to Default") + reset_btn.clicked.connect(reset_prompt) + button_layout.addWidget(reset_btn) + + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(dialog.reject) + button_layout.addWidget(cancel_btn) + + button_layout.addStretch() + layout.addLayout(button_layout) + + # Show dialog + dialog.exec() + + def _refresh_context_settings(self): + """Refresh context settings from main GUI""" + # Actually fetch the current values from main GUI + if hasattr(self.main_gui, 'contextual_var'): + contextual_enabled = self.main_gui.contextual_var.get() + if hasattr(self, 'contextual_status_label'): + self.contextual_status_label.setText(f"โ€ข Contextual Translation: {'Enabled' if contextual_enabled else 'Disabled'}") + + if hasattr(self.main_gui, 'trans_history'): + history_limit = self.main_gui.trans_history.get() + if hasattr(self, 'history_limit_label'): + self.history_limit_label.setText(f"โ€ข Translation History Limit: {history_limit} exchanges") + + if hasattr(self.main_gui, 'translation_history_rolling_var'): + rolling_enabled = self.main_gui.translation_history_rolling_var.get() + rolling_status = "Enabled (Rolling Window)" if rolling_enabled else "Disabled (Reset on Limit)" + if hasattr(self, 'rolling_status_label'): + self.rolling_status_label.setText(f"โ€ข Rolling History: {rolling_status}") + + # Get and update model from main GUI + current_model = None + model_changed = False + + if hasattr(self.main_gui, 'model_var'): + current_model = self.main_gui.model_var.get() + elif hasattr(self.main_gui, 'model_combo'): + current_model = self.main_gui.model_combo.get() + elif hasattr(self.main_gui, 'config'): + current_model = self.main_gui.config.get('model', 'Unknown') + + # Update model display in the API Settings frame (skip if parent_frame doesn't exist) + if hasattr(self, 'parent_frame') and hasattr(self.parent_frame, 'winfo_children'): + try: + for widget in self.parent_frame.winfo_children(): + if isinstance(widget, tk.LabelFrame) and "Translation Settings" in widget.cget("text"): + for child in widget.winfo_children(): + if isinstance(child, tk.Frame): + for subchild in child.winfo_children(): + if isinstance(subchild, tk.Label) and "Model:" in subchild.cget("text"): + old_model_text = subchild.cget("text") + old_model = old_model_text.split("Model: ")[-1] if "Model: " in old_model_text else None + if old_model != current_model: + model_changed = True + subchild.config(text=f"Model: {current_model}") + break + except Exception: + pass # Silently skip if there's an issue with Tkinter widgets + + # If model changed, reset translator and client to force recreation + if model_changed and current_model: + if self.translator: + self._log(f"Model changed to {current_model}. Translator will be recreated on next run.", "info") + self.translator = None # Force recreation on next translation + + # Also reset the client if it exists to ensure new model is used + if hasattr(self.main_gui, 'client') and self.main_gui.client: + if hasattr(self.main_gui.client, 'model') and self.main_gui.client.model != current_model: + self.main_gui.client = None # Force recreation with new model + + # If translator exists, update its history manager settings + if self.translator and hasattr(self.translator, 'history_manager'): + try: + # Update the history manager with current main GUI settings + if hasattr(self.main_gui, 'contextual_var'): + self.translator.history_manager.contextual_enabled = self.main_gui.contextual_var.get() + + if hasattr(self.main_gui, 'trans_history'): + self.translator.history_manager.max_history = int(self.main_gui.trans_history.get()) + + if hasattr(self.main_gui, 'translation_history_rolling_var'): + self.translator.history_manager.rolling_enabled = self.main_gui.translation_history_rolling_var.get() + + # Reset the history to apply new settings + self.translator.history_manager.reset() + + self._log("โœ… Refreshed context settings from main GUI and updated translator", "success") + except Exception as e: + self._log(f"โœ… Refreshed context settings display (translator will update on next run)", "success") + else: + log_message = "โœ… Refreshed context settings from main GUI" + if model_changed: + log_message += f" (Model: {current_model})" + self._log(log_message, "success") + + def _browse_google_credentials_permanent(self): + """Browse and set Google Cloud Vision credentials from the permanent button""" + from PySide6.QtWidgets import QFileDialog + + file_path, _ = QFileDialog.getOpenFileName( + self.dialog, + "Select Google Cloud Service Account JSON", + "", + "JSON files (*.json);;All files (*.*)" + ) + + if file_path: + # Save to config with both keys for compatibility + self.main_gui.config['google_vision_credentials'] = file_path + self.main_gui.config['google_cloud_credentials'] = file_path + + # Save configuration + if hasattr(self.main_gui, 'save_config'): + self.main_gui.save_config(show_message=False) + + + from PySide6.QtWidgets import QMessageBox + + # Update button state immediately + if hasattr(self, 'start_button'): + self.start_button.setEnabled(True) + + # Update credentials display + if hasattr(self, 'creds_label'): + self.creds_label.setText(os.path.basename(file_path)) + self.creds_label.setStyleSheet("color: green;") + + # Update the main status label and provider status + self._update_main_status_label() + self._check_provider_status() + + QMessageBox.information(self.dialog, "Success", "Google Cloud credentials set successfully!") + + def _update_status_display(self): + """Update the status display after credentials change""" + # This would update the status label if we had a reference to it + # For now, we'll just ensure the button is enabled + google_creds_path = self.main_gui.config.get('google_vision_credentials', '') or self.main_gui.config.get('google_cloud_credentials', '') + has_vision = os.path.exists(google_creds_path) if google_creds_path else False + + if has_vision and hasattr(self, 'start_button'): + self.start_button.setEnabled(True) + + def _get_available_fonts(self): + """Get list of available fonts from system and custom directories""" + fonts = ["Default"] # Default option + + # Reset font mapping + self.font_mapping = {} + + # Comprehensive map of Windows font filenames to proper display names + font_name_map = { + # === BASIC LATIN FONTS === + # Arial family + 'arial': 'Arial', + 'ariali': 'Arial Italic', + 'arialbd': 'Arial Bold', + 'arialbi': 'Arial Bold Italic', + 'ariblk': 'Arial Black', + + # Times New Roman + 'times': 'Times New Roman', + 'timesbd': 'Times New Roman Bold', + 'timesi': 'Times New Roman Italic', + 'timesbi': 'Times New Roman Bold Italic', + + # Calibri family + 'calibri': 'Calibri', + 'calibrib': 'Calibri Bold', + 'calibrii': 'Calibri Italic', + 'calibriz': 'Calibri Bold Italic', + 'calibril': 'Calibri Light', + 'calibrili': 'Calibri Light Italic', + + # Comic Sans family + 'comic': 'Comic Sans MS', + 'comici': 'Comic Sans MS Italic', + 'comicbd': 'Comic Sans MS Bold', + 'comicz': 'Comic Sans MS Bold Italic', + + # Segoe UI family + 'segoeui': 'Segoe UI', + 'segoeuib': 'Segoe UI Bold', + 'segoeuii': 'Segoe UI Italic', + 'segoeuiz': 'Segoe UI Bold Italic', + 'segoeuil': 'Segoe UI Light', + 'segoeuisl': 'Segoe UI Semilight', + 'seguisb': 'Segoe UI Semibold', + 'seguisbi': 'Segoe UI Semibold Italic', + 'seguisli': 'Segoe UI Semilight Italic', + 'seguili': 'Segoe UI Light Italic', + 'seguibl': 'Segoe UI Black', + 'seguibli': 'Segoe UI Black Italic', + 'seguihis': 'Segoe UI Historic', + 'seguiemj': 'Segoe UI Emoji', + 'seguisym': 'Segoe UI Symbol', + + # Courier + 'cour': 'Courier New', + 'courbd': 'Courier New Bold', + 'couri': 'Courier New Italic', + 'courbi': 'Courier New Bold Italic', + + # Verdana + 'verdana': 'Verdana', + 'verdanab': 'Verdana Bold', + 'verdanai': 'Verdana Italic', + 'verdanaz': 'Verdana Bold Italic', + + # Georgia + 'georgia': 'Georgia', + 'georgiab': 'Georgia Bold', + 'georgiai': 'Georgia Italic', + 'georgiaz': 'Georgia Bold Italic', + + # Tahoma + 'tahoma': 'Tahoma', + 'tahomabd': 'Tahoma Bold', + + # Trebuchet + 'trebuc': 'Trebuchet MS', + 'trebucbd': 'Trebuchet MS Bold', + 'trebucit': 'Trebuchet MS Italic', + 'trebucbi': 'Trebuchet MS Bold Italic', + + # Impact + 'impact': 'Impact', + + # Consolas + 'consola': 'Consolas', + 'consolab': 'Consolas Bold', + 'consolai': 'Consolas Italic', + 'consolaz': 'Consolas Bold Italic', + + # Sitka family (from your screenshot) + 'sitka': 'Sitka Small', + 'sitkab': 'Sitka Small Bold', + 'sitkai': 'Sitka Small Italic', + 'sitkaz': 'Sitka Small Bold Italic', + 'sitkavf': 'Sitka Text', + 'sitkavfb': 'Sitka Text Bold', + 'sitkavfi': 'Sitka Text Italic', + 'sitkavfz': 'Sitka Text Bold Italic', + 'sitkasubheading': 'Sitka Subheading', + 'sitkasubheadingb': 'Sitka Subheading Bold', + 'sitkasubheadingi': 'Sitka Subheading Italic', + 'sitkasubheadingz': 'Sitka Subheading Bold Italic', + 'sitkaheading': 'Sitka Heading', + 'sitkaheadingb': 'Sitka Heading Bold', + 'sitkaheadingi': 'Sitka Heading Italic', + 'sitkaheadingz': 'Sitka Heading Bold Italic', + 'sitkadisplay': 'Sitka Display', + 'sitkadisplayb': 'Sitka Display Bold', + 'sitkadisplayi': 'Sitka Display Italic', + 'sitkadisplayz': 'Sitka Display Bold Italic', + 'sitkabanner': 'Sitka Banner', + 'sitkabannerb': 'Sitka Banner Bold', + 'sitkabanneri': 'Sitka Banner Italic', + 'sitkabannerz': 'Sitka Banner Bold Italic', + + # Ink Free (from your screenshot) + 'inkfree': 'Ink Free', + + # Lucida family + 'l_10646': 'Lucida Sans Unicode', + 'lucon': 'Lucida Console', + 'ltype': 'Lucida Sans Typewriter', + 'ltypeb': 'Lucida Sans Typewriter Bold', + 'ltypei': 'Lucida Sans Typewriter Italic', + 'ltypebi': 'Lucida Sans Typewriter Bold Italic', + + # Palatino Linotype + 'pala': 'Palatino Linotype', + 'palab': 'Palatino Linotype Bold', + 'palabi': 'Palatino Linotype Bold Italic', + 'palai': 'Palatino Linotype Italic', + + # Noto fonts + 'notosansjp': 'Noto Sans JP', + 'notoserifjp': 'Noto Serif JP', + + # UD Digi Kyokasho (Japanese educational font) + 'uddigikyokashon-b': 'UD Digi Kyokasho NK-B', + 'uddigikyokashon-r': 'UD Digi Kyokasho NK-R', + 'uddigikyokashonk-b': 'UD Digi Kyokasho NK-B', + 'uddigikyokashonk-r': 'UD Digi Kyokasho NK-R', + + # Urdu Typesetting + 'urdtype': 'Urdu Typesetting', + 'urdtypeb': 'Urdu Typesetting Bold', + + # Segoe variants + 'segmdl2': 'Segoe MDL2 Assets', + 'segoeicons': 'Segoe Fluent Icons', + 'segoepr': 'Segoe Print', + 'segoeprb': 'Segoe Print Bold', + 'segoesc': 'Segoe Script', + 'segoescb': 'Segoe Script Bold', + 'seguivar': 'Segoe UI Variable', + + # Sans Serif Collection + 'sansserifcollection': 'Sans Serif Collection', + + # Additional common Windows 10/11 fonts + 'holomdl2': 'HoloLens MDL2 Assets', + 'gadugi': 'Gadugi', + 'gadugib': 'Gadugi Bold', + + # Cascadia Code (developer font) + 'cascadiacode': 'Cascadia Code', + 'cascadiacodepl': 'Cascadia Code PL', + 'cascadiamono': 'Cascadia Mono', + 'cascadiamonopl': 'Cascadia Mono PL', + + # More Segoe UI variants + 'seguibli': 'Segoe UI Black Italic', + 'segoeuiblack': 'Segoe UI Black', + + # Other fonts + 'aldhabi': 'Aldhabi', + 'andiso': 'Andalus', # This is likely Andalus font + 'arabtype': 'Arabic Typesetting', + 'mstmc': 'Myanmar Text', # Alternate file name + 'monbaiti': 'Mongolian Baiti', # Shorter filename variant + 'leeluisl': 'Leelawadee UI Semilight', # Missing variant + 'simsunextg': 'SimSun-ExtG', # Extended SimSun variant + 'ebrima': 'Ebrima', + 'ebrimabd': 'Ebrima Bold', + 'gabriola': 'Gabriola', + + # Bahnschrift variants + 'bahnschrift': 'Bahnschrift', + 'bahnschriftlight': 'Bahnschrift Light', + 'bahnschriftsemibold': 'Bahnschrift SemiBold', + 'bahnschriftbold': 'Bahnschrift Bold', + + # Majalla (African language font) + 'majalla': 'Sakkal Majalla', + 'majallab': 'Sakkal Majalla Bold', + + # Additional fonts that might be missing + 'amiri': 'Amiri', + 'amiri-bold': 'Amiri Bold', + 'amiri-slanted': 'Amiri Slanted', + 'amiri-boldslanted': 'Amiri Bold Slanted', + 'aparaj': 'Aparajita', + 'aparajb': 'Aparajita Bold', + 'aparaji': 'Aparajita Italic', + 'aparajbi': 'Aparajita Bold Italic', + 'kokila': 'Kokila', + 'kokilab': 'Kokila Bold', + 'kokilai': 'Kokila Italic', + 'kokilabi': 'Kokila Bold Italic', + 'utsaah': 'Utsaah', + 'utsaahb': 'Utsaah Bold', + 'utsaahi': 'Utsaah Italic', + 'utsaahbi': 'Utsaah Bold Italic', + 'vani': 'Vani', + 'vanib': 'Vani Bold', + + # === JAPANESE FONTS === + 'msgothic': 'MS Gothic', + 'mspgothic': 'MS PGothic', + 'msmincho': 'MS Mincho', + 'mspmincho': 'MS PMincho', + 'meiryo': 'Meiryo', + 'meiryob': 'Meiryo Bold', + 'yugothic': 'Yu Gothic', + 'yugothb': 'Yu Gothic Bold', + 'yugothl': 'Yu Gothic Light', + 'yugothm': 'Yu Gothic Medium', + 'yugothr': 'Yu Gothic Regular', + 'yumin': 'Yu Mincho', + 'yumindb': 'Yu Mincho Demibold', + 'yuminl': 'Yu Mincho Light', + + # === KOREAN FONTS === + 'malgun': 'Malgun Gothic', + 'malgunbd': 'Malgun Gothic Bold', + 'malgunsl': 'Malgun Gothic Semilight', + 'gulim': 'Gulim', + 'gulimche': 'GulimChe', + 'dotum': 'Dotum', + 'dotumche': 'DotumChe', + 'batang': 'Batang', + 'batangche': 'BatangChe', + 'gungsuh': 'Gungsuh', + 'gungsuhche': 'GungsuhChe', + + # === CHINESE FONTS === + # Simplified Chinese + 'simsun': 'SimSun', + 'simsunb': 'SimSun Bold', + 'simsunextb': 'SimSun ExtB', + 'nsimsun': 'NSimSun', + 'simhei': 'SimHei', + 'simkai': 'KaiTi', + 'simfang': 'FangSong', + 'simli': 'LiSu', + 'simyou': 'YouYuan', + 'stcaiyun': 'STCaiyun', + 'stfangsong': 'STFangsong', + 'sthupo': 'STHupo', + 'stkaiti': 'STKaiti', + 'stliti': 'STLiti', + 'stsong': 'STSong', + 'stxihei': 'STXihei', + 'stxingkai': 'STXingkai', + 'stxinwei': 'STXinwei', + 'stzhongsong': 'STZhongsong', + + # Traditional Chinese + 'msjh': 'Microsoft JhengHei', + 'msjhbd': 'Microsoft JhengHei Bold', + 'msjhl': 'Microsoft JhengHei Light', + 'mingliu': 'MingLiU', + 'pmingliu': 'PMingLiU', + 'mingliub': 'MingLiU Bold', + 'mingliuhk': 'MingLiU_HKSCS', + 'mingliuextb': 'MingLiU ExtB', + 'pmingliuextb': 'PMingLiU ExtB', + 'mingliuhkextb': 'MingLiU_HKSCS ExtB', + 'kaiu': 'DFKai-SB', + + # Microsoft YaHei + 'msyh': 'Microsoft YaHei', + 'msyhbd': 'Microsoft YaHei Bold', + 'msyhl': 'Microsoft YaHei Light', + + # === THAI FONTS === + 'leelawui': 'Leelawadee UI', + 'leelauib': 'Leelawadee UI Bold', + 'leelauisl': 'Leelawadee UI Semilight', + 'leelawad': 'Leelawadee', + 'leelawdb': 'Leelawadee Bold', + + # === INDIC FONTS === + 'mangal': 'Mangal', + 'vrinda': 'Vrinda', + 'raavi': 'Raavi', + 'shruti': 'Shruti', + 'tunga': 'Tunga', + 'gautami': 'Gautami', + 'kartika': 'Kartika', + 'latha': 'Latha', + 'kalinga': 'Kalinga', + 'vijaya': 'Vijaya', + 'nirmala': 'Nirmala UI', + 'nirmalab': 'Nirmala UI Bold', + 'nirmalas': 'Nirmala UI Semilight', + + # === ARABIC FONTS === + 'arial': 'Arial', + 'trado': 'Traditional Arabic', + 'tradbdo': 'Traditional Arabic Bold', + 'simpo': 'Simplified Arabic', + 'simpbdo': 'Simplified Arabic Bold', + 'simpfxo': 'Simplified Arabic Fixed', + + # === OTHER ASIAN FONTS === + 'javatext': 'Javanese Text', + 'himalaya': 'Microsoft Himalaya', + 'mongolianbaiti': 'Mongolian Baiti', + 'msuighur': 'Microsoft Uighur', + 'msuighub': 'Microsoft Uighur Bold', + 'msyi': 'Microsoft Yi Baiti', + 'taileb': 'Microsoft Tai Le Bold', + 'taile': 'Microsoft Tai Le', + 'ntailu': 'Microsoft New Tai Lue', + 'ntailub': 'Microsoft New Tai Lue Bold', + 'phagspa': 'Microsoft PhagsPa', + 'phagspab': 'Microsoft PhagsPa Bold', + 'mmrtext': 'Myanmar Text', + 'mmrtextb': 'Myanmar Text Bold', + + # === SYMBOL FONTS === + 'symbol': 'Symbol', + 'webdings': 'Webdings', + 'wingding': 'Wingdings', + 'wingdng2': 'Wingdings 2', + 'wingdng3': 'Wingdings 3', + 'mtextra': 'MT Extra', + 'marlett': 'Marlett', + + # === OTHER FONTS === + 'mvboli': 'MV Boli', + 'sylfaen': 'Sylfaen', + 'estrangelo': 'Estrangelo Edessa', + 'euphemia': 'Euphemia', + 'plantagenet': 'Plantagenet Cherokee', + 'micross': 'Microsoft Sans Serif', + + # Franklin Gothic + 'framd': 'Franklin Gothic Medium', + 'framdit': 'Franklin Gothic Medium Italic', + 'fradm': 'Franklin Gothic Demi', + 'fradmcn': 'Franklin Gothic Demi Cond', + 'fradmit': 'Franklin Gothic Demi Italic', + 'frahv': 'Franklin Gothic Heavy', + 'frahvit': 'Franklin Gothic Heavy Italic', + 'frabook': 'Franklin Gothic Book', + 'frabookit': 'Franklin Gothic Book Italic', + + # Cambria + 'cambria': 'Cambria', + 'cambriab': 'Cambria Bold', + 'cambriai': 'Cambria Italic', + 'cambriaz': 'Cambria Bold Italic', + 'cambria&cambria math': 'Cambria Math', + + # Candara + 'candara': 'Candara', + 'candarab': 'Candara Bold', + 'candarai': 'Candara Italic', + 'candaraz': 'Candara Bold Italic', + 'candaral': 'Candara Light', + 'candarali': 'Candara Light Italic', + + # Constantia + 'constan': 'Constantia', + 'constanb': 'Constantia Bold', + 'constani': 'Constantia Italic', + 'constanz': 'Constantia Bold Italic', + + # Corbel + 'corbel': 'Corbel', + 'corbelb': 'Corbel Bold', + 'corbeli': 'Corbel Italic', + 'corbelz': 'Corbel Bold Italic', + 'corbell': 'Corbel Light', + 'corbelli': 'Corbel Light Italic', + + # Bahnschrift + 'bahnschrift': 'Bahnschrift', + + # Garamond + 'gara': 'Garamond', + 'garabd': 'Garamond Bold', + 'garait': 'Garamond Italic', + + # Century Gothic + 'gothic': 'Century Gothic', + 'gothicb': 'Century Gothic Bold', + 'gothici': 'Century Gothic Italic', + 'gothicz': 'Century Gothic Bold Italic', + + # Bookman Old Style + 'bookos': 'Bookman Old Style', + 'bookosb': 'Bookman Old Style Bold', + 'bookosi': 'Bookman Old Style Italic', + 'bookosbi': 'Bookman Old Style Bold Italic', + } + + # Dynamically discover all Windows fonts + windows_fonts = [] + windows_font_dir = "C:/Windows/Fonts" + + if os.path.exists(windows_font_dir): + for font_file in os.listdir(windows_font_dir): + font_path = os.path.join(windows_font_dir, font_file) + + # Check if it's a font file + if os.path.isfile(font_path) and font_file.lower().endswith(('.ttf', '.ttc', '.otf')): + # Get base name without extension + base_name = os.path.splitext(font_file)[0] + base_name_lower = base_name.lower() + + # Check if we have a proper name mapping + if base_name_lower in font_name_map: + display_name = font_name_map[base_name_lower] + else: + # Generic cleanup for unmapped fonts + display_name = base_name.replace('_', ' ').replace('-', ' ') + display_name = ' '.join(word.capitalize() for word in display_name.split()) + + windows_fonts.append((display_name, font_path)) + + # Sort alphabetically + windows_fonts.sort(key=lambda x: x[0]) + + # Add all discovered fonts to the list + for font_name, font_path in windows_fonts: + fonts.append(font_name) + self.font_mapping[font_name] = font_path + + # Check for custom fonts directory (keep your existing code) + script_dir = os.path.dirname(os.path.abspath(__file__)) + fonts_dir = os.path.join(script_dir, "fonts") + + if os.path.exists(fonts_dir): + for root, dirs, files in os.walk(fonts_dir): + for font_file in files: + if font_file.endswith(('.ttf', '.ttc', '.otf')): + font_path = os.path.join(root, font_file) + font_name = os.path.splitext(font_file)[0] + # Add category from folder + category = os.path.basename(root) + if category != "fonts": + font_name = f"{font_name} ({category})" + fonts.append(font_name) + self.font_mapping[font_name] = font_path + + # Load previously saved custom fonts (keep your existing code) + if 'custom_fonts' in self.main_gui.config: + for custom_font in self.main_gui.config['custom_fonts']: + if os.path.exists(custom_font['path']): + # Check if this font is already in the list + if custom_font['name'] not in fonts: + fonts.append(custom_font['name']) + self.font_mapping[custom_font['name']] = custom_font['path'] + + # Add custom fonts option at the end + fonts.append("Browse Custom Font...") + + return fonts + + def _on_font_selected(self): + """Handle font selection - updates font path only, save+apply called by widget""" + if not hasattr(self, 'font_combo'): + return + selected = self.font_combo.currentText() + + if selected == "Default": + self.selected_font_path = None + elif selected == "Browse Custom Font...": + # Open file dialog to select custom font using PySide6 + font_path, _ = QFileDialog.getOpenFileName( + self.dialog if hasattr(self, 'dialog') else None, + "Select Font File", + "", + "Font files (*.ttf *.ttc *.otf);;TrueType fonts (*.ttf);;TrueType collections (*.ttc);;OpenType fonts (*.otf);;All files (*.*)" + ) + + # Check if user selected a file (not cancelled) + if font_path and font_path.strip(): + # Add to combo box + font_name = os.path.basename(font_path) + + # Insert before "Browse Custom Font..." option + if font_name not in [n for n in self.font_mapping.keys()]: + # Add to combo box (PySide6) + self.font_combo.insertItem(self.font_combo.count() - 1, font_name) + self.font_combo.setCurrentText(font_name) + + # Update font mapping + self.font_mapping[font_name] = font_path + self.selected_font_path = font_path + + # Save custom font to config + if 'custom_fonts' not in self.main_gui.config: + self.main_gui.config['custom_fonts'] = [] + + custom_font_entry = {'name': font_name, 'path': font_path} + # Check if this exact entry already exists + font_exists = False + for existing_font in self.main_gui.config['custom_fonts']: + if existing_font['path'] == font_path: + font_exists = True + break + + if not font_exists: + self.main_gui.config['custom_fonts'].append(custom_font_entry) + # Save config immediately to persist custom fonts + if hasattr(self.main_gui, 'save_config'): + self.main_gui.save_config(show_message=False) + else: + # Font already exists, just select it + self.font_combo.setCurrentText(font_name) + self.selected_font_path = self.font_mapping[font_name] + else: + # User cancelled, revert to previous selection + if hasattr(self, 'previous_font_selection'): + self.font_combo.setCurrentText(self.previous_font_selection) + else: + self.font_combo.setCurrentText("Default") + return + else: + # Check if it's in the font mapping + if selected in self.font_mapping: + self.selected_font_path = self.font_mapping[selected] + else: + # This shouldn't happen, but just in case + self.selected_font_path = None + + # Store current selection for next time + self.previous_font_selection = selected + + def _update_opacity_label(self, value): + """Update opacity percentage label and value variable""" + self.bg_opacity_value = int(value) # UPDATE THE VALUE VARIABLE! + percentage = int((float(value) / 255) * 100) + self.opacity_label.setText(f"{percentage}%") + + def _update_reduction_label(self, value): + """Update size reduction percentage label and value variable""" + self.bg_reduction_value = float(value) # UPDATE THE VALUE VARIABLE! + percentage = int(float(value) * 100) + self.reduction_label.setText(f"{percentage}%") + + def _toggle_inpaint_quality_visibility(self): + """Show/hide inpaint quality options based on skip_inpainting setting""" + if hasattr(self, 'inpaint_quality_frame'): + if self.skip_inpainting_value: + # Hide quality options when inpainting is skipped + self.inpaint_quality_frame.hide() + else: + # Show quality options when inpainting is enabled + self.inpaint_quality_frame.show() + + def _toggle_inpaint_visibility(self): + """Show/hide inpainting options based on skip toggle""" + # Update the value from the checkbox + self.skip_inpainting_value = self.skip_inpainting_checkbox.isChecked() + + if self.skip_inpainting_value: + # Hide all inpainting options + self.inpaint_method_frame.hide() + self.cloud_inpaint_frame.hide() + self.local_inpaint_frame.hide() + self.inpaint_separator.hide() # Hide separator + else: + # Show method selection + self.inpaint_method_frame.show() + self.inpaint_separator.show() # Show separator + self._on_inpaint_method_change() + + # Don't save during initialization + if not (hasattr(self, '_initializing') and self._initializing): + self._save_rendering_settings() + + def _on_inpaint_method_change(self): + """Show appropriate inpainting settings based on method""" + # Determine current method from radio buttons + if self.cloud_radio.isChecked(): + method = 'cloud' + elif self.local_radio.isChecked(): + method = 'local' + elif self.hybrid_radio.isChecked(): + method = 'hybrid' + else: + method = 'local' # Default fallback + + # Update the stored value + self.inpaint_method_value = method + + if method == 'cloud': + self.cloud_inpaint_frame.show() + self.local_inpaint_frame.hide() + elif method == 'local': + self.local_inpaint_frame.show() + self.cloud_inpaint_frame.hide() + elif method == 'hybrid': + # Show both frames for hybrid + self.local_inpaint_frame.show() + self.cloud_inpaint_frame.show() + + # Don't save during initialization + if not (hasattr(self, '_initializing') and self._initializing): + self._save_rendering_settings() + + def _on_local_model_change(self, new_model_type=None): + """Handle model type change and auto-load if model exists""" + # Get model type from combo box (PySide6) + if new_model_type is None: + model_type = self.local_model_combo.currentText() + else: + model_type = new_model_type + + # Update stored value + self.local_model_type_value = model_type + + # Update description + model_desc = { + 'lama': 'LaMa (Best quality)', + 'aot': 'AOT GAN (Fast)', + 'aot_onnx': 'AOT ONNX (Optimized)', + 'mat': 'MAT (High-res)', + 'sd_local': 'Stable Diffusion (Anime)', + 'anime': 'Anime/Manga Inpainting', + 'anime_onnx': 'Anime ONNX (Fast/Optimized)', + 'lama_onnx': 'LaMa ONNX (Optimized)', + } + self.model_desc_label.setText(model_desc.get(model_type, '')) + + # Check for saved path for this model type + saved_path = self.main_gui.config.get(f'manga_{model_type}_model_path', '') + + if saved_path and os.path.exists(saved_path): + # Update the path display + self.local_model_entry.setText(saved_path) + self.local_model_path_value = saved_path + self.local_model_status_label.setText("โณ Loading saved model...") + self.local_model_status_label.setStyleSheet("color: orange;") + + # Auto-load the model after a short delay using QTimer + from PySide6.QtCore import QTimer + QTimer.singleShot(100, lambda: self._try_load_model(model_type, saved_path)) + else: + # Clear the path display + self.local_model_entry.setText("") + self.local_model_path_value = "" + self.local_model_status_label.setText("No model loaded") + self.local_model_status_label.setStyleSheet("color: gray;") + + self._save_rendering_settings() + + def _browse_local_model(self): + """Browse for local inpainting model and auto-load""" + from PySide6.QtWidgets import QFileDialog + from PySide6.QtCore import QTimer + + model_type = self.local_model_type_value + + if model_type == 'sd_local': + filter_str = "Model files (*.safetensors *.pt *.pth *.ckpt *.onnx);;SafeTensors (*.safetensors);;Checkpoint files (*.ckpt);;PyTorch models (*.pt *.pth);;ONNX models (*.onnx);;All files (*.*)" + else: + filter_str = "Model files (*.pt *.pth *.ckpt *.onnx);;Checkpoint files (*.ckpt);;PyTorch models (*.pt *.pth);;ONNX models (*.onnx);;All files (*.*)" + + path, _ = QFileDialog.getOpenFileName( + self.dialog, + f"Select {model_type.upper()} Model", + "", + filter_str + ) + + if path: + self.local_model_entry.setText(path) + self.local_model_path_value = path + # Save to config + self.main_gui.config[f'manga_{model_type}_model_path'] = path + self._save_rendering_settings() + + # Update status first + self._update_local_model_status() + + # Auto-load the selected model using QTimer + QTimer.singleShot(100, lambda: self._try_load_model(model_type, path)) + + def _click_load_local_model(self): + """Manually trigger loading of the selected local inpainting model""" + from PySide6.QtWidgets import QMessageBox + from PySide6.QtCore import QTimer + + try: + model_type = self.local_model_type_value if hasattr(self, 'local_model_type_value') else None + path = self.local_model_path_value if hasattr(self, 'local_model_path_value') else '' + if not model_type or not path: + QMessageBox.information(self.dialog, "Load Model", "Please select a model file first using the Browse button.") + return + # Defer to keep UI responsive using QTimer + QTimer.singleShot(50, lambda: self._try_load_model(model_type, path)) + except Exception: + pass + + def _try_load_model(self, method: str, model_path: str): + """Try to load a model and update status without threading for now.""" + from PySide6.QtWidgets import QApplication + + try: + # Show loading status immediately + self.local_model_status_label.setText("โณ Loading model...") + self.local_model_status_label.setStyleSheet("color: orange;") + QApplication.processEvents() # Process pending events to update UI + self.main_gui.append_log(f"โณ Loading {method.upper()} model...") + + from local_inpainter import LocalInpainter + success = False + try: + test_inpainter = LocalInpainter() + success = test_inpainter.load_model_with_retry(method, model_path, force_reload=True) + print(f"DEBUG: Model loading completed, success={success}") + except Exception as e: + print(f"DEBUG: Model loading exception: {e}") + self.main_gui.append_log(f"โŒ Error loading model: {e}") + success = False + + # Update UI directly on main thread + print(f"DEBUG: Updating UI, success={success}") + if success: + self.local_model_status_label.setText(f"โœ… {method.upper()} model ready") + self.local_model_status_label.setStyleSheet("color: green;") + self.main_gui.append_log(f"โœ… {method.upper()} model loaded successfully!") + if hasattr(self, 'translator') and self.translator: + for attr in ('local_inpainter', '_last_local_method', '_last_local_model_path'): + if hasattr(self.translator, attr): + try: + delattr(self.translator, attr) + except Exception: + pass + else: + self.local_model_status_label.setText("โš ๏ธ Model file found but failed to load") + self.local_model_status_label.setStyleSheet("color: orange;") + self.main_gui.append_log("โš ๏ธ Model file found but failed to load") + print(f"DEBUG: UI update completed") + return success + except Exception as e: + try: + self.local_model_status_label.setText(f"โŒ Error: {str(e)[:50]}") + self.local_model_status_label.setStyleSheet("color: red;") + except Exception: + pass + self.main_gui.append_log(f"โŒ Error loading model: {e}") + return False + + def _update_local_model_status(self): + """Update local model status display""" + path = self.local_model_path_value if hasattr(self, 'local_model_path_value') else '' + + if not path: + self.local_model_status_label.setText("โš ๏ธ No model selected") + self.local_model_status_label.setStyleSheet("color: orange;") + return + + if not os.path.exists(path): + self.local_model_status_label.setText("โŒ Model file not found") + self.local_model_status_label.setStyleSheet("color: red;") + return + + # Check for ONNX cache + if path.endswith(('.pt', '.pth', '.safetensors')): + onnx_dir = os.path.join(os.path.dirname(path), 'models') + if os.path.exists(onnx_dir): + # Check if ONNX file exists for this model + model_hash = hashlib.md5(path.encode()).hexdigest()[:8] + onnx_files = [f for f in os.listdir(onnx_dir) if model_hash in f] + if onnx_files: + self.local_model_status_label.setText("โœ… Model ready (ONNX cached)") + self.local_model_status_label.setStyleSheet("color: green;") + else: + self.local_model_status_label.setText("โ„น๏ธ Will convert to ONNX on first use") + self.local_model_status_label.setStyleSheet("color: blue;") + else: + self.local_model_status_label.setText("โ„น๏ธ Will convert to ONNX on first use") + self.local_model_status_label.setStyleSheet("color: blue;") + else: + self.local_model_status_label.setText("โœ… ONNX model ready") + self.local_model_status_label.setStyleSheet("color: green;") + + def _download_model(self): + """Actually download the model for the selected type""" + from PySide6.QtWidgets import QMessageBox + + model_type = self.local_model_type_value + + # Define URLs for each model type + model_urls = { + 'aot': 'https://huggingface.co/ogkalu/aot-inpainting-jit/resolve/main/aot_traced.pt', + 'aot_onnx': 'https://huggingface.co/ogkalu/aot-inpainting/resolve/main/aot.onnx', + 'lama': 'https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt', + 'lama_onnx': 'https://huggingface.co/Carve/LaMa-ONNX/resolve/main/lama_fp32.onnx', + 'anime': 'https://github.com/Sanster/models/releases/download/AnimeMangaInpainting/anime-manga-big-lama.pt', + 'anime_onnx': 'https://huggingface.co/ogkalu/lama-manga-onnx-dynamic/resolve/main/lama-manga-dynamic.onnx', + 'mat': '', # User must provide + 'ollama': '', # Not applicable + 'sd_local': '' # User must provide + } + + url = model_urls.get(model_type, '') + + if not url: + QMessageBox.information(self.dialog, "Manual Download", + f"Please manually download and browse for {model_type} model") + return + + # Determine filename + filename_map = { + 'aot': 'aot_traced.pt', + 'aot_onnx': 'aot.onnx', + 'lama': 'big-lama.pt', + 'anime': 'anime-manga-big-lama.pt', + 'anime_onnx': 'lama-manga-dynamic.onnx', + 'lama_onnx': 'lama_fp32.onnx', + 'fcf_onnx': 'fcf.onnx', + 'sd_inpaint_onnx': 'sd_inpaint_unet.onnx' + } + + filename = filename_map.get(model_type, f'{model_type}.pt') + save_path = os.path.join('models', filename) + + # Create models directory + os.makedirs('models', exist_ok=True) + + # Check if already exists + if os.path.exists(save_path): + self.local_model_entry.setText(save_path) + self.local_model_path_value = save_path + self.local_model_status_label.setText("โœ… Model already downloaded") + self.local_model_status_label.setStyleSheet("color: green;") + QMessageBox.information(self.dialog, "Model Ready", f"Model already exists at:\n{save_path}") + return + + # Download the model + self._perform_download(url, save_path, model_type) + + def _perform_download(self, url: str, save_path: str, model_name: str): + """Perform the actual download with progress indication""" + import threading + import requests + from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar, QPushButton + from PySide6.QtCore import Qt, QTimer + from PySide6.QtGui import QIcon + + # Create a progress dialog + progress_dialog = QDialog(self.dialog) + progress_dialog.setWindowTitle(f"Downloading {model_name.upper()} Model") + progress_dialog.setFixedSize(400, 150) + progress_dialog.setModal(True) + + # Set window icon + icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'halgakos.ico') + if os.path.exists(icon_path): + progress_dialog.setWindowIcon(QIcon(icon_path)) + + layout = QVBoxLayout(progress_dialog) + + # Progress label + progress_label = QLabel("โณ Downloading...") + progress_label.setAlignment(Qt.AlignCenter) + layout.addWidget(progress_label) + + # Progress bar + progress_bar = QProgressBar() + progress_bar.setMinimum(0) + progress_bar.setMaximum(100) + progress_bar.setValue(0) + layout.addWidget(progress_bar) + + # Status label + status_label = QLabel("0%") + status_label.setAlignment(Qt.AlignCenter) + layout.addWidget(status_label) + + # Cancel flag + cancel_download = {'value': False} + + def on_cancel(): + cancel_download['value'] = True + progress_dialog.close() + + progress_dialog.closeEvent = lambda event: on_cancel() + + def download_thread(): + import time + try: + # Download with progress and speed tracking + response = requests.get(url, stream=True, timeout=30) + response.raise_for_status() + + total_size = int(response.headers.get('content-length', 0)) + downloaded = 0 + start_time = time.time() + last_update = start_time + + with open(save_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if cancel_download['value']: + # Clean up partial file + f.close() + if os.path.exists(save_path): + os.remove(save_path) + return + + if chunk: + f.write(chunk) + downloaded += len(chunk) + + # Update progress (throttle updates to every 0.1 seconds) + current_time = time.time() + if total_size > 0 and (current_time - last_update > 0.1): + last_update = current_time + elapsed = current_time - start_time + speed = downloaded / elapsed if elapsed > 0 else 0 + speed_mb = speed / (1024 * 1024) + progress = (downloaded / total_size) * 100 + + # Direct widget updates + try: + progress_bar.setValue(int(progress)) + status_label.setText(f"{progress:.1f}% - {speed_mb:.2f} MB/s") + progress_label.setText(f"โณ Downloading... {downloaded//1024//1024}MB / {total_size//1024//1024}MB") + except RuntimeError: + # Widget was destroyed, exit + cancel_download['value'] = True + return + + # Success - direct call + try: + progress_dialog.close() + self._download_complete(save_path, model_name) + except Exception as e: + print(f"Error in download completion: {e}") + + except requests.exceptions.RequestException as e: + # Error - direct call + if not cancel_download['value']: + try: + progress_dialog.close() + self._download_failed(str(e)) + except Exception as ex: + print(f"Error handling download failure: {ex}") + except Exception as e: + if not cancel_download['value']: + try: + progress_dialog.close() + self._download_failed(str(e)) + except Exception as ex: + print(f"Error handling download failure: {ex}") + + # Start download in background thread + thread = threading.Thread(target=download_thread, daemon=True) + thread.start() + + # Show dialog + progress_dialog.exec() + + def _download_complete(self, save_path: str, model_name: str): + """Handle successful download""" + from PySide6.QtWidgets import QMessageBox + + # Update the model path entry + self.local_model_entry.setText(save_path) + self.local_model_path_value = save_path + + # Save to config + self.main_gui.config[f'manga_{model_name}_model_path'] = save_path + self._save_rendering_settings() + + # Log to main GUI + self.main_gui.append_log(f"โœ… Downloaded {model_name} model to: {save_path}") + + # Auto-load the downloaded model (direct call) + self.local_model_status_label.setText("โณ Loading downloaded model...") + self.local_model_status_label.setStyleSheet("color: orange;") + + # Try to load immediately + if self._try_load_model(model_name, save_path): + QMessageBox.information(self.dialog, "Success", f"{model_name.upper()} model downloaded and loaded!") + else: + QMessageBox.information(self.dialog, "Download Complete", f"{model_name.upper()} model downloaded but needs manual loading") + + def _download_failed(self, error: str): + """Handle download failure""" + from PySide6.QtWidgets import QMessageBox + + QMessageBox.critical(self.dialog, "Download Failed", f"Failed to download model:\n{error}") + self.main_gui.append_log(f"โŒ Model download failed: {error}") + + def _show_model_info(self): + """Show information about models""" + model_type = self.local_model_type_value + + info = { + 'aot': "AOT GAN Model:\n\n" + "โ€ข Auto-downloads from HuggingFace\n" + "โ€ข Traced PyTorch JIT model\n" + "โ€ข Good for general inpainting\n" + "โ€ข Fast processing speed\n" + "โ€ข File size: ~100MB", + + 'aot_onnx': "AOT ONNX Model:\n\n" + "โ€ข Optimized ONNX version\n" + "โ€ข Auto-downloads from HuggingFace\n" + "โ€ข 2-3x faster than PyTorch version\n" + "โ€ข Great for batch processing\n" + "โ€ข Lower memory usage\n" + "โ€ข File size: ~100MB", + + 'lama': "LaMa Model:\n\n" + "โ€ข Auto-downloads anime-optimized version\n" + "โ€ข Best quality for manga/anime\n" + "โ€ข Large model (~200MB)\n" + "โ€ข Excellent at removing text from bubbles\n" + "โ€ข Preserves art style well", + + 'anime': "Anime-Specific Model:\n\n" + "โ€ข Same as LaMa anime version\n" + "โ€ข Optimized for manga/anime art\n" + "โ€ข Auto-downloads from GitHub\n" + "โ€ข Recommended for manga translation\n" + "โ€ข Preserves screen tones and patterns", + + 'anime_onnx': "Anime ONNX Model:\n\n" + "โ€ข Optimized ONNX version for speed\n" + "โ€ข Auto-downloads from HuggingFace\n" + "โ€ข 2-3x faster than PyTorch version\n" + "โ€ข Perfect for batch processing\n" + "โ€ข Same quality as anime model\n" + "โ€ข File size: ~190MB\n" + "โ€ข DEFAULT for inpainting", + + 'mat': "MAT Model:\n\n" + "โ€ข Manual download required\n" + "โ€ข Get from: github.com/fenglinglwb/MAT\n" + "โ€ข Good for high-resolution images\n" + "โ€ข Slower but high quality\n" + "โ€ข File size: ~500MB", + + 'ollama': "Ollama:\n\n" + "โ€ข Uses local Ollama server\n" + "โ€ข No model download needed here\n" + "โ€ข Run: ollama pull llava\n" + "โ€ข Context-aware inpainting\n" + "โ€ข Requires Ollama running locally", + + 'sd_local': "Stable Diffusion:\n\n" + "โ€ข Manual download required\n" + "โ€ข Get from HuggingFace\n" + "โ€ข Requires significant VRAM (4-8GB)\n" + "โ€ข Best quality but slowest\n" + "โ€ข Can use custom prompts" + } + + from PySide6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QPushButton + from PySide6.QtCore import Qt + + # Create info dialog + info_dialog = QDialog(self.dialog) + info_dialog.setWindowTitle(f"{model_type.upper()} Model Information") + info_dialog.setFixedSize(450, 350) + info_dialog.setModal(True) + + layout = QVBoxLayout(info_dialog) + + # Info text + text_widget = QTextEdit() + text_widget.setReadOnly(True) + text_widget.setPlainText(info.get(model_type, "Please select a model type first")) + layout.addWidget(text_widget) + + # Close button + close_btn = QPushButton("Close") + close_btn.clicked.connect(info_dialog.close) + close_btn.setStyleSheet("QPushButton { background-color: #6c757d; color: white; padding: 5px 15px; }") + layout.addWidget(close_btn) + + info_dialog.exec() + + def _toggle_inpaint_controls_visibility(self): + """Toggle visibility of inpaint controls (mask expansion and passes) based on skip inpainting setting""" + # Just return if the frame doesn't exist - prevents AttributeError + if not hasattr(self, 'inpaint_controls_frame'): + return + + if self.skip_inpainting_value: + self.inpaint_controls_frame.hide() + else: + # Show it back + self.inpaint_controls_frame.show() + + def _configure_inpaint_api(self): + """Configure cloud inpainting API""" + from PySide6.QtWidgets import QMessageBox, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton + from PySide6.QtCore import Qt + import webbrowser + + # Show instructions + result = QMessageBox.question( + self.dialog, + "Configure Cloud Inpainting", + "Cloud inpainting uses Replicate API for questionable results.\n\n" + "1. Go to replicate.com and sign up (free tier available?)\n" + "2. Get your API token from Account Settings\n" + "3. Enter it here\n\n" + "Pricing: ~$0.0023 per image?\n" + "Free tier: ~100 images per month?\n\n" + "Would you like to proceed?", + QMessageBox.Yes | QMessageBox.No + ) + + if result != QMessageBox.Yes: + return + + # Open Replicate page + webbrowser.open("https://replicate.com/account/api-tokens") + + # Create API key input dialog + api_dialog = QDialog(self.dialog) + api_dialog.setWindowTitle("Replicate API Key") + api_dialog.setFixedSize(400, 150) + api_dialog.setModal(True) + + layout = QVBoxLayout(api_dialog) + layout.setContentsMargins(20, 20, 20, 20) + + # Label + label = QLabel("Enter your Replicate API key:") + layout.addWidget(label) + + # Entry with show/hide + entry_layout = QHBoxLayout() + entry = QLineEdit() + entry.setEchoMode(QLineEdit.Password) + entry_layout.addWidget(entry) + + # Toggle show/hide + show_btn = QPushButton("Show") + show_btn.setFixedWidth(60) + def toggle_show(): + if entry.echoMode() == QLineEdit.Password: + entry.setEchoMode(QLineEdit.Normal) + show_btn.setText("Hide") + else: + entry.setEchoMode(QLineEdit.Password) + show_btn.setText("Show") + show_btn.clicked.connect(toggle_show) + entry_layout.addWidget(show_btn) + + layout.addLayout(entry_layout) + + # Buttons + btn_layout = QHBoxLayout() + btn_layout.addStretch() + + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(api_dialog.reject) + btn_layout.addWidget(cancel_btn) + + ok_btn = QPushButton("OK") + ok_btn.setStyleSheet("QPushButton { background-color: #28a745; color: white; padding: 5px 15px; }") + ok_btn.clicked.connect(api_dialog.accept) + btn_layout.addWidget(ok_btn) + + layout.addLayout(btn_layout) + + # Focus and key bindings + entry.setFocus() + + # Execute dialog + if api_dialog.exec() == QDialog.Accepted: + api_key = entry.text().strip() + + if api_key: + try: + # Save the API key + self.main_gui.config['replicate_api_key'] = api_key + self.main_gui.save_config(show_message=False) + + # Update UI + self.inpaint_api_status_label.setText("โœ… Cloud inpainting configured") + self.inpaint_api_status_label.setStyleSheet("color: green;") + + # Set flag on translator + if self.translator: + self.translator.use_cloud_inpainting = True + self.translator.replicate_api_key = api_key + + self._log("โœ… Cloud inpainting API configured", "success") + + except Exception as e: + QMessageBox.critical(self.dialog, "Error", f"Failed to save API key:\n{str(e)}") + + def _clear_inpaint_api(self): + """Clear the inpainting API configuration""" + self.main_gui.config['replicate_api_key'] = '' + self.main_gui.save_config(show_message=False) + + self.inpaint_api_status_label.setText("โŒ Inpainting API not configured") + self.inpaint_api_status_label.setStyleSheet("color: red;") + + if hasattr(self, 'translator') and self.translator: + self.translator.use_cloud_inpainting = False + self.translator.replicate_api_key = None + + self._log("๐Ÿ—‘๏ธ Cleared inpainting API configuration", "info") + + # Note: Clear button management would need to be handled differently in PySide6 + # For now, we'll skip automatic button removal + + def _add_files(self): + """Add image files (and CBZ archives) to the list""" + from PySide6.QtWidgets import QFileDialog + + files, _ = QFileDialog.getOpenFileNames( + self.dialog, + "Select Manga Images or CBZ", + "", + "Images / CBZ (*.png *.jpg *.jpeg *.gif *.bmp *.webp *.cbz);;Image files (*.png *.jpg *.jpeg *.gif *.bmp *.webp);;Comic Book Zip (*.cbz);;All files (*.*)" + ) + + if not files: + return + + # Ensure temp root for CBZ extraction lives for the session + cbz_temp_root = getattr(self, 'cbz_temp_root', None) + if cbz_temp_root is None: + try: + import tempfile + cbz_temp_root = tempfile.mkdtemp(prefix='glossarion_cbz_') + self.cbz_temp_root = cbz_temp_root + except Exception: + cbz_temp_root = None + + for path in files: + lower = path.lower() + if lower.endswith('.cbz'): + # Extract images from CBZ and add them in natural sort order + try: + import zipfile, shutil + base = os.path.splitext(os.path.basename(path))[0] + extract_dir = os.path.join(self.cbz_temp_root or os.path.dirname(path), base) + os.makedirs(extract_dir, exist_ok=True) + with zipfile.ZipFile(path, 'r') as zf: + # Extract all to preserve subfolders and avoid name collisions + zf.extractall(extract_dir) + # Initialize CBZ job tracking + if not hasattr(self, 'cbz_jobs'): + self.cbz_jobs = {} + if not hasattr(self, 'cbz_image_to_job'): + self.cbz_image_to_job = {} + # Prepare output dir next to source CBZ + out_dir = os.path.join(os.path.dirname(path), f"{base}_translated") + self.cbz_jobs[path] = { + 'extract_dir': extract_dir, + 'out_dir': out_dir, + } + # Collect all images recursively from extract_dir + added = 0 + for root, _, files_in_dir in os.walk(extract_dir): + for fn in sorted(files_in_dir): + if fn.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif')): + target_path = os.path.join(root, fn) + if target_path not in self.selected_files: + self.selected_files.append(target_path) + self.file_listbox.addItem(os.path.basename(target_path)) + added += 1 + # Map extracted image to its CBZ job + self.cbz_image_to_job[target_path] = path + self._log(f"๐Ÿ“ฆ Added {added} images from CBZ: {os.path.basename(path)}", "info") + except Exception as e: + self._log(f"โŒ Failed to read CBZ {os.path.basename(path)}: {e}", "error") + else: + if path not in self.selected_files: + self.selected_files.append(path) + self.file_listbox.addItem(os.path.basename(path)) + + def _add_folder(self): + """Add all images (and CBZ archives) from a folder""" + from PySide6.QtWidgets import QFileDialog + + folder = QFileDialog.getExistingDirectory( + self.dialog, + "Select Folder with Manga Images or CBZ" + ) + if not folder: + return + + # Extensions + image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'} + cbz_ext = '.cbz' + + # Ensure temp root for CBZ extraction lives for the session + cbz_temp_root = getattr(self, 'cbz_temp_root', None) + if cbz_temp_root is None: + try: + import tempfile + cbz_temp_root = tempfile.mkdtemp(prefix='glossarion_cbz_') + self.cbz_temp_root = cbz_temp_root + except Exception: + cbz_temp_root = None + + for filename in sorted(os.listdir(folder)): + filepath = os.path.join(folder, filename) + if not os.path.isfile(filepath): + continue + lower = filename.lower() + if any(lower.endswith(ext) for ext in image_extensions): + if filepath not in self.selected_files: + self.selected_files.append(filepath) + self.file_listbox.addItem(filename) + elif lower.endswith(cbz_ext): + # Extract images from CBZ archive + try: + import zipfile, shutil + base = os.path.splitext(os.path.basename(filepath))[0] + extract_dir = os.path.join(self.cbz_temp_root or folder, base) + os.makedirs(extract_dir, exist_ok=True) + with zipfile.ZipFile(filepath, 'r') as zf: + zf.extractall(extract_dir) + # Initialize CBZ job tracking + if not hasattr(self, 'cbz_jobs'): + self.cbz_jobs = {} + if not hasattr(self, 'cbz_image_to_job'): + self.cbz_image_to_job = {} + # Prepare output dir next to source CBZ + out_dir = os.path.join(os.path.dirname(filepath), f"{base}_translated") + self.cbz_jobs[filepath] = { + 'extract_dir': extract_dir, + 'out_dir': out_dir, + } + # Collect all images recursively + added = 0 + for root, _, files_in_dir in os.walk(extract_dir): + for fn in sorted(files_in_dir): + if fn.lower().endswith(tuple(image_extensions)): + target_path = os.path.join(root, fn) + if target_path not in self.selected_files: + self.selected_files.append(target_path) + self.file_listbox.addItem(os.path.basename(target_path)) + added += 1 + # Map extracted image to its CBZ job + self.cbz_image_to_job[target_path] = filepath + self._log(f"๐Ÿ“ฆ Added {added} images from CBZ: {filename}", "info") + except Exception as e: + self._log(f"โŒ Failed to read CBZ {filename}: {e}", "error") + + def _remove_selected(self): + """Remove selected files from the list""" + selected_items = self.file_listbox.selectedItems() + + if not selected_items: + return + + # Remove in reverse order to maintain indices + for item in selected_items: + row = self.file_listbox.row(item) + self.file_listbox.takeItem(row) + if 0 <= row < len(self.selected_files): + del self.selected_files[row] + + def _clear_all(self): + """Clear all files from the list""" + self.file_listbox.clear() + self.selected_files.clear() + + def _finalize_cbz_jobs(self): + """Package translated outputs back into .cbz for each imported CBZ. + - Always creates a CLEAN archive with only final translated pages. + - If save_intermediate is enabled in settings, also creates a DEBUG archive that + contains the same final pages at root plus debug/raw artifacts under subfolders. + """ + try: + if not hasattr(self, 'cbz_jobs') or not self.cbz_jobs: + return + import zipfile + # Read debug flag from settings + save_debug = False + try: + save_debug = bool(self.main_gui.config.get('manga_settings', {}).get('advanced', {}).get('save_intermediate', False)) + except Exception: + save_debug = False + image_exts = ('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif') + text_exts = ('.txt', '.json', '.csv', '.log') + excluded_patterns = ('_mask', '_overlay', '_debug', '_raw', '_ocr', '_regions', '_chunk', '_clean', '_cleaned', '_inpaint', '_inpainted') + + for cbz_path, job in self.cbz_jobs.items(): + out_dir = job.get('out_dir') + if not out_dir or not os.path.isdir(out_dir): + continue + parent = os.path.dirname(cbz_path) + base = os.path.splitext(os.path.basename(cbz_path))[0] + + # Compute original basenames from extracted images mapping + original_basenames = set() + try: + if hasattr(self, 'cbz_image_to_job'): + for img_path, job_path in self.cbz_image_to_job.items(): + if job_path == cbz_path: + original_basenames.add(os.path.basename(img_path)) + except Exception: + pass + + # Helper to iterate files in out_dir + all_files = [] + for root, _, files in os.walk(out_dir): + for fn in files: + fp = os.path.join(root, fn) + rel = os.path.relpath(fp, out_dir) + all_files.append((fp, rel, fn)) + + # 1) CLEAN ARCHIVE: only final images matching original basenames + clean_zip = os.path.join(parent, f"{base}_translated.cbz") + clean_count = 0 + with zipfile.ZipFile(clean_zip, 'w', zipfile.ZIP_DEFLATED) as zf: + for fp, rel, fn in all_files: + fn_lower = fn.lower() + if not fn_lower.endswith(image_exts): + continue + if original_basenames and fn not in original_basenames: + # Only include pages corresponding to original entries + continue + # Also skip obvious debug artifacts by pattern (extra safeguard) + if any(p in fn_lower for p in excluded_patterns): + continue + zf.write(fp, fn) # place at root with page filename + clean_count += 1 + self._log(f"๐Ÿ“ฆ Compiled CLEAN {clean_count} pages into {os.path.basename(clean_zip)}", "success") + + # 2) DEBUG ARCHIVE: include final pages + extras under subfolders + if save_debug: + debug_zip = os.path.join(parent, f"{base}_translated_debug.cbz") + dbg_count = 0 + raw_count = 0 + page_count = 0 + with zipfile.ZipFile(debug_zip, 'w', zipfile.ZIP_DEFLATED) as zf: + for fp, rel, fn in all_files: + fn_lower = fn.lower() + # Final pages at root + if fn_lower.endswith(image_exts) and (not original_basenames or fn in original_basenames) and not any(p in fn_lower for p in excluded_patterns): + zf.write(fp, fn) + page_count += 1 + continue + # Raw text/logs + if fn_lower.endswith(text_exts): + zf.write(fp, os.path.join('raw', rel)) + raw_count += 1 + continue + # Other images or artifacts -> debug/ + zf.write(fp, os.path.join('debug', rel)) + dbg_count += 1 + self._log(f"๐Ÿ“ฆ Compiled DEBUG archive: pages={page_count}, debug_files={dbg_count}, raw={raw_count} -> {os.path.basename(debug_zip)}", "info") + except Exception as e: + self._log(f"โš ๏ธ Failed to compile CBZ packages: {e}", "warning") + + def _attach_logging_bridge(self): + """Attach a root logging handler that forwards records into the GUI log.""" + try: + if getattr(self, '_gui_log_handler', None) is None: + handler = _MangaGuiLogHandler(self, level=logging.INFO) + root_logger = logging.getLogger() + # Avoid duplicates + if all(not isinstance(h, _MangaGuiLogHandler) for h in root_logger.handlers): + root_logger.addHandler(handler) + self._gui_log_handler = handler + # Ensure common module loggers propagate + for name in ['bubble_detector', 'local_inpainter', 'manga_translator']: + try: + lg = logging.getLogger(name) + lg.setLevel(logging.INFO) + lg.propagate = True + except Exception: + pass + except Exception: + pass + + def _redirect_stderr(self, enable: bool): + """Temporarily redirect stderr to the GUI log (captures tqdm/HF progress).""" + try: + if enable: + if not hasattr(self, '_old_stderr') or self._old_stderr is None: + self._old_stderr = sys.stderr + sys.stderr = _StreamToGuiLog(lambda s: self._log(s, 'info')) + self._stderr_redirect_on = True + else: + if hasattr(self, '_old_stderr') and self._old_stderr is not None: + sys.stderr = self._old_stderr + self._old_stderr = None + self._stderr_redirect_on = False + # Update combined flag to avoid double-forwarding with logging handler + self._stdio_redirect_active = bool(self._stdout_redirect_on or self._stderr_redirect_on) + except Exception: + pass + + def _redirect_stdout(self, enable: bool): + """Temporarily redirect stdout to the GUI log.""" + try: + if enable: + if not hasattr(self, '_old_stdout') or self._old_stdout is None: + self._old_stdout = sys.stdout + sys.stdout = _StreamToGuiLog(lambda s: self._log(s, 'info')) + self._stdout_redirect_on = True + else: + if hasattr(self, '_old_stdout') and self._old_stdout is not None: + sys.stdout = self._old_stdout + self._old_stdout = None + self._stdout_redirect_on = False + # Update combined flag to avoid double-forwarding with logging handler + self._stdio_redirect_active = bool(self._stdout_redirect_on or self._stderr_redirect_on) + except Exception: + pass + + def _log(self, message: str, level: str = "info"): + """Log message to GUI text widget or console with enhanced stop suppression""" + # Enhanced stop suppression - allow only essential stop confirmation messages + if self._is_stop_requested() or self.is_globally_cancelled(): + # Only allow very specific stop confirmation messages - nothing else + essential_stop_keywords = [ + "โน๏ธ Translation stopped by user", + "๐Ÿงน Cleaning up models to free RAM", + "โœ… Model cleanup complete - RAM should be freed", + "โœ… All models cleaned up - RAM freed!" + ] + # Suppress ALL other messages when stopped - be very restrictive + if not any(keyword in message for keyword in essential_stop_keywords): + return + + # Lightweight deduplication: ignore identical lines within a short interval + try: + now = time.time() + last_msg = getattr(self, '_last_log_msg', None) + last_ts = getattr(self, '_last_log_time', 0) + if last_msg == message and (now - last_ts) < 0.7: + return + except Exception: + pass + + # Store in persistent log (thread-safe) + try: + with MangaTranslationTab._persistent_log_lock: + # Keep only last 1000 messages to avoid unbounded growth + if len(MangaTranslationTab._persistent_log) >= 1000: + MangaTranslationTab._persistent_log.pop(0) + MangaTranslationTab._persistent_log.append((message, level)) + except Exception: + pass + + # Check if log_text widget exists yet + if hasattr(self, 'log_text') and self.log_text: + # Thread-safe logging to GUI + if threading.current_thread() == threading.main_thread(): + # We're in the main thread, update directly + try: + # PySide6 QTextEdit - append with color + color_map = { + 'info': 'white', + 'success': 'green', + 'warning': 'orange', + 'error': 'red', + 'debug': 'lightblue' + } + color = color_map.get(level, 'white') + self.log_text.setTextColor(QColor(color)) + self.log_text.append(message) + except Exception: + pass + else: + # We're in a background thread, use queue + self.update_queue.put(('log', message, level)) + else: + # Widget doesn't exist yet or we're in initialization, print to console + print(message) + + # Update deduplication state + try: + self._last_log_msg = message + self._last_log_time = time.time() + except Exception: + pass + + def _update_progress(self, current: int, total: int, status: str): + """Thread-safe progress update""" + self.update_queue.put(('progress', current, total, status)) + + def _update_current_file(self, filename: str): + """Thread-safe current file update""" + self.update_queue.put(('current_file', filename)) + + def _start_startup_heartbeat(self): + """Show a small spinner in the progress label during startup so there is no silence.""" + try: + self._startup_heartbeat_running = True + self._heartbeat_idx = 0 + chars = ['|', '/', '-', '\\'] + def tick(): + if not getattr(self, '_startup_heartbeat_running', False): + return + try: + c = chars[self._heartbeat_idx % len(chars)] + if hasattr(self, 'progress_label'): + self.progress_label.setText(f"Startingโ€ฆ {c}") + self.progress_label.setStyleSheet("color: white;") + # Force update to ensure it's visible + from PySide6.QtWidgets import QApplication + QApplication.processEvents() + except Exception: + pass + self._heartbeat_idx += 1 + # Schedule next tick with QTimer - only if still running + if getattr(self, '_startup_heartbeat_running', False): + QTimer.singleShot(250, tick) + # Kick off + QTimer.singleShot(0, tick) + except Exception: + pass + + def _stop_startup_heartbeat(self): + """Stop the startup heartbeat spinner""" + try: + self._startup_heartbeat_running = False + # Clear the spinner text immediately + if hasattr(self, 'progress_label') and self.progress_label: + self.progress_label.setText("Initializing...") + self.progress_label.setStyleSheet("color: white;") + except Exception: + pass + + def _process_updates(self): + """Process queued GUI updates""" + try: + while True: + update = self.update_queue.get_nowait() + + if update[0] == 'log': + _, message, level = update + try: + # PySide6 QTextEdit + color_map = { + 'info': 'white', + 'success': 'green', + 'warning': 'orange', + 'error': 'red', + 'debug': 'lightblue' + } + color = color_map.get(level, 'white') + self.log_text.setTextColor(QColor(color)) + self.log_text.append(message) + except Exception: + pass + + elif update[0] == 'progress': + _, current, total, status = update + if total > 0: + percentage = (current / total) * 100 + self.progress_bar.setValue(int(percentage)) + + # Check if this is a stopped status and style accordingly + if "stopped" in status.lower() or "cancelled" in status.lower(): + # Make the status more prominent for stopped translations + self.progress_label.setText(f"โน๏ธ {status}") + self.progress_label.setStyleSheet("color: orange;") + elif "complete" in status.lower() or "finished" in status.lower(): + # Success status + self.progress_label.setText(f"โœ… {status}") + self.progress_label.setStyleSheet("color: green;") + elif "error" in status.lower() or "failed" in status.lower(): + # Error status + self.progress_label.setText(f"โŒ {status}") + self.progress_label.setStyleSheet("color: red;") + else: + # Normal status - white for dark mode + self.progress_label.setText(status) + self.progress_label.setStyleSheet("color: white;") + + elif update[0] == 'current_file': + _, filename = update + # Style the current file display based on the status + if "stopped" in filename.lower() or "cancelled" in filename.lower(): + self.current_file_label.setText(f"โน๏ธ {filename}") + self.current_file_label.setStyleSheet("color: orange;") + elif "complete" in filename.lower() or "finished" in filename.lower(): + self.current_file_label.setText(f"โœ… {filename}") + self.current_file_label.setStyleSheet("color: green;") + elif "error" in filename.lower() or "failed" in filename.lower(): + self.current_file_label.setText(f"โŒ {filename}") + self.current_file_label.setStyleSheet("color: red;") + else: + self.current_file_label.setText(f"Current: {filename}") + self.current_file_label.setStyleSheet("color: lightgray;") + + elif update[0] == 'ui_state': + _, state = update + if state == 'translation_started': + try: + if hasattr(self, 'start_button') and self.start_button: + self.start_button.setEnabled(False) + if hasattr(self, 'stop_button') and self.stop_button: + self.stop_button.setEnabled(True) + if hasattr(self, 'file_listbox') and self.file_listbox: + self.file_listbox.setEnabled(False) + except Exception: + pass + + elif update[0] == 'call_method': + # Call a method on the main thread + _, method, args = update + try: + method(*args) + except Exception as e: + import traceback + print(f"Error calling method {method}: {e}") + print(traceback.format_exc()) + + except Exception: + # Queue is empty or some other exception + pass + + # Schedule next update with QTimer + QTimer.singleShot(100, self._process_updates) + + def load_local_inpainting_model(self, model_path): + """Load a local inpainting model + + Args: + model_path: Path to the model file + + Returns: + bool: True if successful + """ + try: + # Store the model path + self.local_inpaint_model_path = model_path + + # If using diffusers/torch models, load them here + if model_path.endswith('.safetensors') or model_path.endswith('.ckpt'): + # Initialize your inpainting pipeline + # This depends on your specific inpainting implementation + # Example: + # from diffusers import StableDiffusionInpaintPipeline + # self.inpaint_pipeline = StableDiffusionInpaintPipeline.from_single_file(model_path) + pass + + return True + except Exception as e: + self._log(f"Failed to load inpainting model: {e}", "error") + return False + + def _start_translation(self): + """Start the translation process""" + # Check files BEFORE redirecting stdout to avoid deadlock + if not self.selected_files: + from PySide6.QtWidgets import QMessageBox + QMessageBox.warning(self.dialog, "No Files", "Please select manga images to translate.") + return + + # Immediately disable Start to prevent double-clicks + try: + if hasattr(self, 'start_button') and self.start_button: + self.start_button.setEnabled(False) + except Exception: + pass + + # Immediate minimal feedback using direct log append + try: + if hasattr(self, 'log_text') and self.log_text: + from PySide6.QtGui import QColor + self.log_text.setTextColor(QColor('white')) + self.log_text.append("Starting translation...") + except Exception: + pass + + # Start heartbeat spinner so there's visible activity until logs stream + self._start_startup_heartbeat() + + # Reset all stop flags at the start of new translation + self.is_running = False + if hasattr(self, 'stop_flag'): + self.stop_flag.clear() + self._reset_global_cancellation() + + # Log start directly to GUI + try: + if hasattr(self, 'log_text') and self.log_text: + from PySide6.QtGui import QColor, QTextCursor + from PySide6.QtCore import QTimer + self.log_text.setTextColor(QColor('white')) + self.log_text.append("๐Ÿš€ Starting new manga translation batch") + + # Scroll to bottom after a short delay to ensure it happens after button processing + def scroll_to_bottom(): + try: + if hasattr(self, 'log_text') and self.log_text: + self.log_text.moveCursor(QTextCursor.End) + self.log_text.ensureCursorVisible() + # Also scroll the parent scroll area if it exists + if hasattr(self, 'scroll_area') and self.scroll_area: + scrollbar = self.scroll_area.verticalScrollBar() + if scrollbar: + scrollbar.setValue(scrollbar.maximum()) + except Exception: + pass + + # Schedule scroll with a small delay + QTimer.singleShot(50, scroll_to_bottom) + QTimer.singleShot(150, scroll_to_bottom) # Second attempt to be sure + except Exception: + pass + + # Force GUI update + try: + from PySide6.QtWidgets import QApplication + QApplication.processEvents() + except Exception: + pass + + # Run the heavy preparation and kickoff in a background thread to avoid GUI freeze + threading.Thread(target=self._start_translation_heavy, name="MangaStartHeavy", daemon=True).start() + return + + def _start_translation_heavy(self): + """Heavy part of start: build configs, init client/translator, and launch worker (runs off-main-thread).""" + try: + # Set thread limits based on parallel processing settings + try: + advanced = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) + parallel_enabled = advanced.get('parallel_processing', False) + + if parallel_enabled: + # Allow multiple threads for parallel processing + num_threads = advanced.get('max_workers', 4) + import os + os.environ['OMP_NUM_THREADS'] = str(num_threads) + os.environ['MKL_NUM_THREADS'] = str(num_threads) + os.environ['OPENBLAS_NUM_THREADS'] = str(num_threads) + os.environ['NUMEXPR_NUM_THREADS'] = str(num_threads) + os.environ['VECLIB_MAXIMUM_THREADS'] = str(num_threads) + os.environ['ONNXRUNTIME_NUM_THREADS'] = str(num_threads) + try: + import torch + torch.set_num_threads(num_threads) + except ImportError: + pass + try: + import cv2 + cv2.setNumThreads(num_threads) + except (ImportError, AttributeError): + pass + self._log(f"โšก Thread limit: {num_threads} threads (parallel processing enabled)", "debug") + else: + # HARDCODED: Limit to exactly 1 thread for sequential processing + import os + os.environ['OMP_NUM_THREADS'] = '1' + os.environ['MKL_NUM_THREADS'] = '1' + os.environ['OPENBLAS_NUM_THREADS'] = '1' + os.environ['NUMEXPR_NUM_THREADS'] = '1' + os.environ['VECLIB_MAXIMUM_THREADS'] = '1' + os.environ['ONNXRUNTIME_NUM_THREADS'] = '1' + try: + import torch + torch.set_num_threads(1) # Hardcoded to 1 + except ImportError: + pass + try: + import cv2 + cv2.setNumThreads(1) # Limit OpenCV to 1 thread + except (ImportError, AttributeError): + pass + self._log("โšก Thread limit: 1 thread (sequential processing)", "debug") + except Exception as e: + self._log(f"โš ๏ธ Warning: Could not set thread limits: {e}", "warning") + + # Early feedback + self._log("โณ Preparing configuration...", "info") + + # Reload OCR prompt from config (in case it was edited in the dialog) + if 'manga_ocr_prompt' in self.main_gui.config: + self.ocr_prompt = self.main_gui.config['manga_ocr_prompt'] + self._log(f"โœ… Loaded OCR prompt from config ({len(self.ocr_prompt)} chars)", "info") + self._log(f"OCR Prompt preview: {self.ocr_prompt[:100]}...", "debug") + else: + self._log("โš ๏ธ manga_ocr_prompt not found in config, using default", "warning") + + # Build OCR configuration + ocr_config = {'provider': self.ocr_provider_value} + + if ocr_config['provider'] == 'Qwen2-VL': + qwen_provider = self.ocr_manager.get_provider('Qwen2-VL') + if qwen_provider: + # Set model size configuration + if hasattr(qwen_provider, 'loaded_model_size'): + if qwen_provider.loaded_model_size == "Custom": + ocr_config['model_size'] = f"custom:{qwen_provider.model_id}" + else: + size_map = {'2B': '1', '7B': '2', '72B': '3'} + ocr_config['model_size'] = size_map.get(qwen_provider.loaded_model_size, '2') + self._log(f"Setting ocr_config['model_size'] = {ocr_config['model_size']}", "info") + + # Set OCR prompt if available + if hasattr(self, 'ocr_prompt'): + # Set it via environment variable (Qwen2VL will read this) + os.environ['OCR_SYSTEM_PROMPT'] = self.ocr_prompt + + # Also set it directly on the provider if it has the method + if hasattr(qwen_provider, 'set_ocr_prompt'): + qwen_provider.set_ocr_prompt(self.ocr_prompt) + else: + # If no setter method, set it directly + qwen_provider.ocr_prompt = self.ocr_prompt + + self._log("โœ… Set custom OCR prompt for Qwen2-VL", "info") + + elif ocr_config['provider'] == 'google': + import os + google_creds = self.main_gui.config.get('google_vision_credentials', '') or self.main_gui.config.get('google_cloud_credentials', '') + if not google_creds or not os.path.exists(google_creds): + self._log("โŒ Google Cloud Vision credentials not found. Please set up credentials in the main settings.", "error") + self._stop_startup_heartbeat() + self._reset_ui_state() + return + ocr_config['google_credentials_path'] = google_creds + + elif ocr_config['provider'] == 'azure': + # Support both PySide6 QLineEdit (.text()) and Tkinter Entry (.get()) + if hasattr(self.azure_key_entry, 'text'): + azure_key = self.azure_key_entry.text().strip() + elif hasattr(self.azure_key_entry, 'get'): + azure_key = self.azure_key_entry.get().strip() + else: + azure_key = '' + if hasattr(self.azure_endpoint_entry, 'text'): + azure_endpoint = self.azure_endpoint_entry.text().strip() + elif hasattr(self.azure_endpoint_entry, 'get'): + azure_endpoint = self.azure_endpoint_entry.get().strip() + else: + azure_endpoint = '' + + if not azure_key or not azure_endpoint: + self._log("โŒ Azure credentials not configured.", "error") + self._stop_startup_heartbeat() + self._reset_ui_state() + return + + # Save Azure settings + self.main_gui.config['azure_vision_key'] = azure_key + self.main_gui.config['azure_vision_endpoint'] = azure_endpoint + if hasattr(self.main_gui, 'save_config'): + self.main_gui.save_config(show_message=False) + + ocr_config['azure_key'] = azure_key + ocr_config['azure_endpoint'] = azure_endpoint + + # Get current API key and model for translation + api_key = None + model = 'gemini-2.5-flash' # default + + # Try to get API key from various sources (support PySide6 and Tkinter widgets) + if hasattr(self.main_gui, 'api_key_entry'): + try: + if hasattr(self.main_gui.api_key_entry, 'text'): + api_key_candidate = self.main_gui.api_key_entry.text() + elif hasattr(self.main_gui.api_key_entry, 'get'): + api_key_candidate = self.main_gui.api_key_entry.get() + else: + api_key_candidate = '' + if api_key_candidate and api_key_candidate.strip(): + api_key = api_key_candidate.strip() + except Exception: + pass + if not api_key and hasattr(self.main_gui, 'config') and self.main_gui.config.get('api_key'): + api_key = self.main_gui.config.get('api_key') + + # Try to get model - ALWAYS get the current selection from GUI + if hasattr(self.main_gui, 'model_var'): + model = self.main_gui.model_var.get() + elif hasattr(self.main_gui, 'config') and self.main_gui.config.get('model'): + model = self.main_gui.config.get('model') + + if not api_key: + self._log("โŒ API key not found. Please configure your API key in the main settings.", "error") + self._stop_startup_heartbeat() + self._reset_ui_state() + return + + # Check if we need to create or update the client + needs_new_client = False + self._log("๐Ÿ”Ž Checking API client...", "debug") + + if not hasattr(self.main_gui, 'client') or not self.main_gui.client: + needs_new_client = True + self._log(f"๐Ÿ›  Creating new API client with model: {model}", "info") + elif hasattr(self.main_gui.client, 'model') and self.main_gui.client.model != model: + needs_new_client = True + self._log(f"๐Ÿ›  Model changed from {self.main_gui.client.model} to {model}, creating new client", "info") + else: + self._log("โ™ป๏ธ Reusing existing API client", "debug") + + if needs_new_client: + # Apply multi-key settings from config so UnifiedClient picks them up + try: + import os # Import os here + use_mk = bool(self.main_gui.config.get('use_multi_api_keys', False)) + mk_list = self.main_gui.config.get('multi_api_keys', []) + if use_mk and mk_list: + os.environ['USE_MULTI_API_KEYS'] = '1' + os.environ['USE_MULTI_KEYS'] = '1' # backward-compat for retry paths + os.environ['MULTI_API_KEYS'] = json.dumps(mk_list) + os.environ['FORCE_KEY_ROTATION'] = '1' if self.main_gui.config.get('force_key_rotation', True) else '0' + os.environ['ROTATION_FREQUENCY'] = str(self.main_gui.config.get('rotation_frequency', 1)) + self._log("๐Ÿ”‘ Multi-key mode ENABLED for manga translator", "info") + else: + # Explicitly disable if not configured + os.environ['USE_MULTI_API_KEYS'] = '0' + os.environ['USE_MULTI_KEYS'] = '0' + # Fallback keys (optional) + if self.main_gui.config.get('use_fallback_keys', False): + os.environ['USE_FALLBACK_KEYS'] = '1' + os.environ['FALLBACK_KEYS'] = json.dumps(self.main_gui.config.get('fallback_keys', [])) + else: + os.environ['USE_FALLBACK_KEYS'] = '0' + os.environ['FALLBACK_KEYS'] = '[]' + except Exception as env_err: + self._log(f"โš ๏ธ Failed to apply multi-key settings: {env_err}", "warning") + + # Create the unified client with the current model + try: + from unified_api_client import UnifiedClient + self._log("โณ Creating API client (network/model handshake)...", "debug") + self.main_gui.client = UnifiedClient(model=model, api_key=api_key) + self._log(f"โœ… API client ready (model: {model})", "info") + try: + time.sleep(0.05) + except Exception: + pass + except Exception as e: + self._log(f"โŒ Failed to create API client: {str(e)}", "error") + import traceback + self._log(traceback.format_exc(), "debug") + self._stop_startup_heartbeat() + self._reset_ui_state() + return + + # Reset the translator's history manager for new batch + if hasattr(self, 'translator') and self.translator and hasattr(self.translator, 'reset_history_manager'): + self.translator.reset_history_manager() + + # Set environment variables for custom-api provider + if ocr_config['provider'] == 'custom-api': + import os # Import os for environment variables + env_vars = self.main_gui._get_environment_variables( + epub_path='', # Not needed for manga + api_key=api_key + ) + + # Apply all environment variables EXCEPT SYSTEM_PROMPT + for key, value in env_vars.items(): + if key == 'SYSTEM_PROMPT': + # DON'T SET THE TRANSLATION SYSTEM PROMPT FOR OCR + continue + os.environ[key] = str(value) + + # Use custom OCR prompt from GUI if available, otherwise use default + if hasattr(self, 'ocr_prompt') and self.ocr_prompt: + os.environ['OCR_SYSTEM_PROMPT'] = self.ocr_prompt + self._log(f"โœ… Using custom OCR prompt from GUI ({len(self.ocr_prompt)} chars)", "info") + self._log(f"OCR Prompt being set: {self.ocr_prompt[:150]}...", "debug") + else: + # Fallback to default OCR prompt + os.environ['OCR_SYSTEM_PROMPT'] = ( + "YOU ARE A TEXT EXTRACTION MACHINE. EXTRACT EXACTLY WHAT YOU SEE.\n\n" + "ABSOLUTE RULES:\n" + "1. OUTPUT ONLY THE VISIBLE TEXT/SYMBOLS - NOTHING ELSE\n" + "2. NEVER TRANSLATE OR MODIFY\n" + "3. NEVER EXPLAIN, DESCRIBE, OR COMMENT\n" + "4. NEVER SAY \"I can't\" or \"I cannot\" or \"no text\" or \"blank image\"\n" + "5. IF YOU SEE DOTS, OUTPUT THE DOTS: .\n" + "6. IF YOU SEE PUNCTUATION, OUTPUT THE PUNCTUATION\n" + "7. IF YOU SEE A SINGLE CHARACTER, OUTPUT THAT CHARACTER\n" + "8. IF YOU SEE NOTHING, OUTPUT NOTHING (empty response)\n\n" + "LANGUAGE PRESERVATION:\n" + "- Korean text โ†’ Output in Korean\n" + "- Japanese text โ†’ Output in Japanese\n" + "- Chinese text โ†’ Output in Chinese\n" + "- English text โ†’ Output in English\n" + "- CJK quotation marks (ใ€Œใ€ใ€Žใ€ใ€ใ€‘ใ€Šใ€‹ใ€ˆใ€‰) โ†’ Preserve exactly as shown\n\n" + "FORMATTING:\n" + "- OUTPUT ALL TEXT ON A SINGLE LINE WITH NO LINE BREAKS\n" + "- NEVER use \\n or line breaks in your output\n\n" + "FORBIDDEN RESPONSES:\n" + "- \"I can see this appears to be...\"\n" + "- \"I cannot make out any clear text...\"\n" + "- \"This appears to be blank...\"\n" + "- \"If there is text present...\"\n" + "- ANY explanatory text\n\n" + "YOUR ONLY OUTPUT: The exact visible text. Nothing more. Nothing less.\n" + "If image has a dot โ†’ Output: .\n" + "If image has two dots โ†’ Output: . .\n" + "If image has text โ†’ Output: [that text]\n" + "If image is truly blank โ†’ Output: [empty/no response]" + ) + self._log("โœ… Using default OCR prompt", "info") + + self._log("โœ… Set environment variables for custom-api OCR (excluded SYSTEM_PROMPT)") + + # Respect user settings: only set default detector values when bubble detection is OFF. + try: + ms = self.main_gui.config.setdefault('manga_settings', {}) + ocr_set = ms.setdefault('ocr', {}) + changed = False + bubble_enabled = bool(ocr_set.get('bubble_detection_enabled', False)) + + if not bubble_enabled: + # User has bubble detection OFF -> set non-intrusive defaults only + if 'detector_type' not in ocr_set: + ocr_set['detector_type'] = 'rtdetr_onnx' + changed = True + if not ocr_set.get('rtdetr_model_url') and not ocr_set.get('bubble_model_path'): + # Default HF repo (detector.onnx lives here) + ocr_set['rtdetr_model_url'] = 'ogkalu/comic-text-and-bubble-detector' + changed = True + if changed and hasattr(self.main_gui, 'save_config'): + self.main_gui.save_config(show_message=False) + # Do not preload bubble detector for custom-api here; it will load on use or via panel preloading + self._preloaded_bd = None + except Exception: + self._preloaded_bd = None + except Exception as e: + # Surface any startup error and reset UI so the app doesn't look stuck + try: + import traceback + self._log(f"โŒ Startup error: {e}", "error") + self._log(traceback.format_exc(), "debug") + except Exception: + pass + self._stop_startup_heartbeat() + self._reset_ui_state() + return + + # Initialize translator if needed (or if it was reset or client was cleared during shutdown) + needs_new_translator = (not hasattr(self, 'translator')) or (self.translator is None) + if not needs_new_translator: + try: + needs_new_translator = getattr(self.translator, 'client', None) is None + if needs_new_translator: + self._log("โ™ป๏ธ Translator exists but client was cleared โ€” reinitializing translator", "debug") + except Exception: + needs_new_translator = True + if needs_new_translator: + self._log("โš™๏ธ Initializing translator...", "info") + + # CRITICAL: Set batch environment variables BEFORE creating translator + # This ensures MangaTranslator picks up the batch settings on initialization + try: + # Get batch translation setting from main GUI + batch_translation_enabled = False + batch_size_value = 1 + + if hasattr(self.main_gui, 'batch_translation_var'): + # Check if batch translation is enabled in GUI + try: + if hasattr(self.main_gui.batch_translation_var, 'get'): + batch_translation_enabled = bool(self.main_gui.batch_translation_var.get()) + else: + batch_translation_enabled = bool(self.main_gui.batch_translation_var) + except Exception: + pass + + if hasattr(self.main_gui, 'batch_size_var'): + # Get batch size from GUI + try: + if hasattr(self.main_gui.batch_size_var, 'get'): + batch_size_value = int(self.main_gui.batch_size_var.get()) + else: + batch_size_value = int(self.main_gui.batch_size_var) + except Exception: + batch_size_value = 1 + + # Set environment variables for the translator to pick up + if batch_translation_enabled: + os.environ['BATCH_TRANSLATION'] = '1' + os.environ['BATCH_SIZE'] = str(max(1, batch_size_value)) + self._log(f"๐Ÿ“ฆ Batch Translation ENABLED: {batch_size_value} concurrent API calls", "info") + else: + os.environ['BATCH_TRANSLATION'] = '0' + os.environ['BATCH_SIZE'] = '1' + self._log("๐Ÿ“ฆ Batch Translation DISABLED: Sequential API calls", "info") + except Exception as e: + self._log(f"โš ๏ธ Warning: Could not set batch settings: {e}", "warning") + os.environ['BATCH_TRANSLATION'] = '0' + os.environ['BATCH_SIZE'] = '1' + + try: + self.translator = MangaTranslator( + ocr_config, + self.main_gui.client, + self.main_gui, + log_callback=self._log + ) + + # Fix 4: Safely set OCR manager + if hasattr(self, 'ocr_manager'): + self.translator.ocr_manager = self.ocr_manager + else: + from ocr_manager import OCRManager + self.ocr_manager = OCRManager(log_callback=self._log) + self.translator.ocr_manager = self.ocr_manager + + # Attach preloaded RT-DETR if available + try: + if hasattr(self, '_preloaded_bd') and self._preloaded_bd: + self.translator.bubble_detector = self._preloaded_bd + self._log("๐Ÿค– RT-DETR preloaded and attached to translator", "debug") + except Exception: + pass + + # Distribute stop flags to all components + self._distribute_stop_flags() + + # Provide Replicate API key to translator if present, but DO NOT force-enable cloud mode here. + # Actual inpainting mode is chosen by the UI and applied in _apply_rendering_settings. + saved_api_key = self.main_gui.config.get('replicate_api_key', '') + if saved_api_key: + self.translator.replicate_api_key = saved_api_key + + # Apply text rendering settings (this sets skip/cloud/local based on UI) + self._apply_rendering_settings() + + try: + time.sleep(0.05) + except Exception: + pass + self._log("โœ… Translator ready", "info") + + except Exception as e: + self._log(f"โŒ Failed to initialize translator: {str(e)}", "error") + import traceback + self._log(traceback.format_exc(), "error") + self._stop_startup_heartbeat() + self._reset_ui_state() + return + else: + # Update batch settings for existing translator + try: + batch_translation_enabled = False + batch_size_value = 1 + + if hasattr(self.main_gui, 'batch_translation_var'): + try: + if hasattr(self.main_gui.batch_translation_var, 'get'): + batch_translation_enabled = bool(self.main_gui.batch_translation_var.get()) + else: + batch_translation_enabled = bool(self.main_gui.batch_translation_var) + except Exception: + pass + + if hasattr(self.main_gui, 'batch_size_var'): + try: + if hasattr(self.main_gui.batch_size_var, 'get'): + batch_size_value = int(self.main_gui.batch_size_var.get()) + else: + batch_size_value = int(self.main_gui.batch_size_var) + except Exception: + batch_size_value = 1 + + # Update environment variables and translator attributes + if batch_translation_enabled: + os.environ['BATCH_TRANSLATION'] = '1' + os.environ['BATCH_SIZE'] = str(max(1, batch_size_value)) + self.translator.batch_mode = True + self.translator.batch_size = max(1, batch_size_value) + self._log(f"๐Ÿ“ฆ Batch Translation UPDATED: {batch_size_value} concurrent API calls", "info") + else: + os.environ['BATCH_TRANSLATION'] = '0' + os.environ['BATCH_SIZE'] = '1' + self.translator.batch_mode = False + self.translator.batch_size = 1 + self._log("๐Ÿ“ฆ Batch Translation UPDATED: Sequential API calls", "info") + except Exception as e: + self._log(f"โš ๏ธ Warning: Could not update batch settings: {e}", "warning") + + # Update the translator with the new client if model changed + if needs_new_client and hasattr(self.translator, 'client'): + self.translator.client = self.main_gui.client + self._log(f"Updated translator with new API client", "info") + + # Distribute stop flags to all components + self._distribute_stop_flags() + + # Update rendering settings + self._apply_rendering_settings() + + # Ensure inpainting settings are properly synchronized + if hasattr(self, 'inpainting_mode_var'): + inpainting_mode = self.inpainting_mode_var.get() + + if inpainting_mode == 'skip': + self.translator.skip_inpainting = True + self.translator.use_cloud_inpainting = False + self._log("Inpainting: SKIP", "debug") + + elif inpainting_mode == 'local': + self.translator.skip_inpainting = False + self.translator.use_cloud_inpainting = False + + # IMPORTANT: Load the local inpainting model if not already loaded + if hasattr(self, 'local_model_var'): + selected_model = self.local_model_var.get() + if selected_model and selected_model != "None": + # Get model path from available models + model_info = self.available_local_models.get(selected_model) + if model_info: + model_path = model_info['path'] + # Load the model into translator + if hasattr(self.translator, 'load_local_inpainting_model'): + success = self.translator.load_local_inpainting_model(model_path) + if success: + self._log(f"Inpainting: LOCAL - Loaded {selected_model}", "info") + else: + self._log(f"Inpainting: Failed to load local model {selected_model}", "error") + else: + # Set the model path directly if no load method + self.translator.local_inpaint_model_path = model_path + self._log(f"Inpainting: LOCAL - Set model path for {selected_model}", "info") + else: + self._log("Inpainting: LOCAL - No model selected", "warning") + else: + self._log("Inpainting: LOCAL - No model configured", "warning") + else: + self._log("Inpainting: LOCAL (default)", "debug") + + elif inpainting_mode == 'cloud': + self.translator.skip_inpainting = False + saved_api_key = self.main_gui.config.get('replicate_api_key', '') + if saved_api_key: + self.translator.use_cloud_inpainting = True + self.translator.replicate_api_key = saved_api_key + self._log("Inpainting: CLOUD (Replicate)", "debug") + else: + # Fallback to local if no API key + self.translator.use_cloud_inpainting = False + self._log("Inpainting: LOCAL (no Replicate key, fallback)", "warning") + else: + # Default to local inpainting if variable doesn't exist + self.translator.skip_inpainting = False + self.translator.use_cloud_inpainting = False + self._log("Inpainting: LOCAL (default)", "debug") + + # Double-check the settings are applied correctly + self._log(f"Inpainting final status:", "debug") + self._log(f" - Skip: {self.translator.skip_inpainting}", "debug") + self._log(f" - Cloud: {self.translator.use_cloud_inpainting}", "debug") + self._log(f" - Mode: {'SKIP' if self.translator.skip_inpainting else 'CLOUD' if self.translator.use_cloud_inpainting else 'LOCAL'}", "debug") + + # Preflight RT-DETR to avoid first-page fallback after aggressive cleanup + try: + ocr_set = self.main_gui.config.get('manga_settings', {}).get('ocr', {}) or {} + if ocr_set.get('bubble_detection_enabled', False): + # Ensure a default RT-DETR model id exists when required + if ocr_set.get('detector_type', 'rtdetr') in ('rtdetr', 'auto'): + if not ocr_set.get('rtdetr_model_url') and not ocr_set.get('bubble_model_path'): + ocr_set['rtdetr_model_url'] = 'ogkalu/comic-text-and-bubble-detector' + if hasattr(self.main_gui, 'save_config'): + self.main_gui.save_config(show_message=False) + self._preflight_bubble_detector(ocr_set) + except Exception: + pass + + # Reset progress + self.total_files = len(self.selected_files) + self.completed_files = 0 + self.failed_files = 0 + self.current_file_index = 0 + + # Reset all global cancellation flags for new translation + self._reset_global_cancellation() + + # Update UI state (PySide6) - queue UI updates for main thread + self.is_running = True + self.stop_flag.clear() + # Queue UI updates to be processed by main thread + self.update_queue.put(('ui_state', 'translation_started')) + + # Log start message + self._log(f"Starting translation of {self.total_files} files...", "info") + self._log(f"Using OCR provider: {ocr_config['provider'].upper()}", "info") + if ocr_config['provider'] == 'google': + self._log(f"Using Google Vision credentials: {os.path.basename(ocr_config['google_credentials_path'])}", "info") + elif ocr_config['provider'] == 'azure': + self._log(f"Using Azure endpoint: {ocr_config['azure_endpoint']}", "info") + else: + self._log(f"Using local OCR provider: {ocr_config['provider'].upper()}", "info") + # Report effective API routing/model with multi-key awareness + try: + c = getattr(self.main_gui, 'client', None) + if c is not None: + if getattr(c, 'use_multi_keys', False): + total_keys = 0 + try: + stats = c.get_stats() + total_keys = stats.get('total_keys', 0) + except Exception: + pass + self._log( + f"API routing: Multi-key pool enabled โ€” starting model '{getattr(c, 'model', 'unknown')}', keys={total_keys}, rotation={getattr(c, '_rotation_frequency', 1)}", + "info" + ) + else: + self._log(f"API model: {getattr(c, 'model', 'unknown')}", "info") + except Exception: + pass + self._log(f"Contextual: {'Enabled' if self.main_gui.contextual_var.get() else 'Disabled'}", "info") + self._log(f"History limit: {self.main_gui.trans_history.get()} exchanges", "info") + self._log(f"Rolling history: {'Enabled' if self.main_gui.translation_history_rolling_var.get() else 'Disabled'}", "info") + self._log(f" Full Page Context: {'Enabled' if self.full_page_context_value else 'Disabled'}", "info") + + # Stop heartbeat before launching worker; now regular progress takes over + self._stop_startup_heartbeat() + + # Update progress to show we're starting the translation worker + self._log("๐Ÿš€ Launching translation worker...", "info") + self._update_progress(0, self.total_files, "Starting translation...") + + # Start translation via executor + try: + # Sync with main GUI executor if possible and update EXTRACTION_WORKERS + if hasattr(self.main_gui, '_ensure_executor'): + self.main_gui._ensure_executor() + self.executor = self.main_gui.executor + # Ensure env var reflects current worker setting from main GUI + try: + os.environ["EXTRACTION_WORKERS"] = str(self.main_gui.extraction_workers_var.get()) + except Exception: + pass + + if self.executor: + self.translation_future = self.executor.submit(self._translation_worker) + else: + # Fallback to dedicated thread + self.translation_thread = threading.Thread( + target=self._translation_worker, + daemon=True + ) + self.translation_thread.start() + except Exception: + # Last resort fallback to thread + self.translation_thread = threading.Thread( + target=self._translation_worker, + daemon=True + ) + self.translation_thread.start() + + def _apply_rendering_settings(self): + """Apply current rendering settings to translator (PySide6 version)""" + if not self.translator: + return + + # Read all values from PySide6 widgets to ensure they're current + # Background opacity slider + if hasattr(self, 'opacity_slider'): + self.bg_opacity_value = self.opacity_slider.value() + + # Background reduction slider + if hasattr(self, 'reduction_slider'): + self.bg_reduction_value = self.reduction_slider.value() + + # Background style (radio buttons) + if hasattr(self, 'bg_style_group'): + checked_id = self.bg_style_group.checkedId() + if checked_id == 0: + self.bg_style_value = "box" + elif checked_id == 1: + self.bg_style_value = "circle" + elif checked_id == 2: + self.bg_style_value = "wrap" + + # Font selection + if hasattr(self, 'font_combo'): + selected = self.font_combo.currentText() + if selected == "Default": + self.selected_font_path = None + elif selected in self.font_mapping: + self.selected_font_path = self.font_mapping[selected] + + # Text color (stored in value variables updated by color picker) + text_color = ( + self.text_color_r_value, + self.text_color_g_value, + self.text_color_b_value + ) + + # Shadow enabled checkbox + if hasattr(self, 'shadow_enabled_checkbox'): + self.shadow_enabled_value = self.shadow_enabled_checkbox.isChecked() + + # Shadow color (stored in value variables updated by color picker) + shadow_color = ( + self.shadow_color_r_value, + self.shadow_color_g_value, + self.shadow_color_b_value + ) + + # Shadow offset spinboxes + if hasattr(self, 'shadow_offset_x_spinbox'): + self.shadow_offset_x_value = self.shadow_offset_x_spinbox.value() + if hasattr(self, 'shadow_offset_y_spinbox'): + self.shadow_offset_y_value = self.shadow_offset_y_spinbox.value() + + # Shadow blur spinbox + if hasattr(self, 'shadow_blur_spinbox'): + self.shadow_blur_value = self.shadow_blur_spinbox.value() + + # Force caps lock checkbox + if hasattr(self, 'force_caps_checkbox'): + self.force_caps_lock_value = self.force_caps_checkbox.isChecked() + + # Strict text wrapping checkbox + if hasattr(self, 'strict_wrap_checkbox'): + self.strict_text_wrapping_value = self.strict_wrap_checkbox.isChecked() + + # Font sizing controls + if hasattr(self, 'min_size_spinbox'): + self.auto_min_size_value = self.min_size_spinbox.value() + if hasattr(self, 'max_size_spinbox'): + self.max_font_size_value = self.max_size_spinbox.value() + if hasattr(self, 'multiplier_slider'): + self.font_size_multiplier_value = self.multiplier_slider.value() + + # Determine font size value based on mode + if self.font_size_mode_value == 'multiplier': + # Pass negative value to indicate multiplier mode + font_size = -self.font_size_multiplier_value + else: + # Fixed mode - use the font size value directly + font_size = self.font_size_value if self.font_size_value > 0 else None + + # Apply concise logging toggle from Advanced settings + try: + adv_cfg = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) + self.translator.concise_logs = bool(adv_cfg.get('concise_logs', False)) + except Exception: + pass + + # Push rendering settings to translator + self.translator.update_text_rendering_settings( + bg_opacity=self.bg_opacity_value, + bg_style=self.bg_style_value, + bg_reduction=self.bg_reduction_value, + font_style=self.selected_font_path, + font_size=font_size, + text_color=text_color, + shadow_enabled=self.shadow_enabled_value, + shadow_color=shadow_color, + shadow_offset_x=self.shadow_offset_x_value, + shadow_offset_y=self.shadow_offset_y_value, + shadow_blur=self.shadow_blur_value, + force_caps_lock=self.force_caps_lock_value + ) + + # Free-text-only background opacity toggle -> read from checkbox (PySide6) + try: + if hasattr(self, 'ft_only_checkbox'): + ft_only_enabled = self.ft_only_checkbox.isChecked() + self.translator.free_text_only_bg_opacity = bool(ft_only_enabled) + # Also update the value variable + self.free_text_only_bg_opacity_value = ft_only_enabled + except Exception: + pass + + # Update font mode and multiplier explicitly + self.translator.font_size_mode = self.font_size_mode_value + self.translator.font_size_multiplier = self.font_size_multiplier_value + self.translator.min_readable_size = self.auto_min_size_value + self.translator.max_font_size_limit = self.max_font_size_value + self.translator.strict_text_wrapping = self.strict_text_wrapping_value + self.translator.force_caps_lock = self.force_caps_lock_value + + # Update constrain to bubble setting + if hasattr(self, 'constrain_to_bubble_value'): + self.translator.constrain_to_bubble = self.constrain_to_bubble_value + + # Handle inpainting mode (radio: skip/local/cloud/hybrid) + mode = None + if hasattr(self, 'inpainting_mode_var'): + mode = self.inpainting_mode_var.get() + else: + mode = 'local' + + # Persist selected mode on translator + self.translator.inpaint_mode = mode + + if mode == 'skip': + self.translator.skip_inpainting = True + self.translator.use_cloud_inpainting = False + self._log(" Inpainting: Skipped", "info") + elif mode == 'cloud': + self.translator.skip_inpainting = False + saved_api_key = self.main_gui.config.get('replicate_api_key', '') + if saved_api_key: + self.translator.use_cloud_inpainting = True + self.translator.replicate_api_key = saved_api_key + self._log(" Inpainting: Cloud (Replicate)", "info") + else: + self.translator.use_cloud_inpainting = False + self._log(" Inpainting: Local (no Replicate key, fallback)", "warning") + elif mode == 'hybrid': + self.translator.skip_inpainting = False + self.translator.use_cloud_inpainting = False + self._log(" Inpainting: Hybrid", "info") + else: + # Local (default) + self.translator.skip_inpainting = False + self.translator.use_cloud_inpainting = False + self._log(" Inpainting: Local", "info") + + # Persist free-text-only BG opacity setting to config (handled in _save_rendering_settings) + # Value is now read directly from checkbox in PySide6 + + # Log the applied rendering and inpainting settings + self._log(f"Applied rendering settings:", "info") + self._log(f" Background: {self.bg_style_value} @ {int(self.bg_opacity_value/255*100)}% opacity", "info") + import os + self._log(f" Font: {os.path.basename(self.selected_font_path) if self.selected_font_path else 'Default'}", "info") + self._log(f" Minimum Font Size: {self.auto_min_size_value}pt", "info") + self._log(f" Maximum Font Size: {self.max_font_size_value}pt", "info") + self._log(f" Strict Text Wrapping: {'Enabled (force fit)' if self.strict_text_wrapping_value else 'Disabled (allow overflow)'}", "info") + if self.font_size_mode_value == 'multiplier': + self._log(f" Font Size: Dynamic multiplier ({self.font_size_multiplier_value:.1f}x)", "info") + if hasattr(self, 'constrain_to_bubble_value'): + constraint_status = "constrained" if self.constrain_to_bubble_value else "unconstrained" + self._log(f" Text Constraint: {constraint_status}", "info") + else: + size_text = f"{self.font_size_value}pt" if self.font_size_value > 0 else "Auto" + self._log(f" Font Size: Fixed ({size_text})", "info") + self._log(f" Text Color: RGB({text_color[0]}, {text_color[1]}, {text_color[2]})", "info") + self._log(f" Shadow: {'Enabled' if self.shadow_enabled_value else 'Disabled'}", "info") + try: + self._log(f" Free-text-only BG opacity: {'Enabled' if getattr(self, 'free_text_only_bg_opacity_value', False) else 'Disabled'}", "info") + except Exception: + pass + self._log(f" Full Page Context: {'Enabled' if self.full_page_context_value else 'Disabled'}", "info") + + def _translation_worker(self): + """Worker thread for translation""" + try: + # Defensive: ensure translator exists before using it (legacy callers may start this worker early) + if not hasattr(self, 'translator') or self.translator is None: + self._log("โš ๏ธ Translator not initialized yet; skipping worker start", "warning") + return + if hasattr(self.translator, 'set_stop_flag'): + self.translator.set_stop_flag(self.stop_flag) + + # Ensure API parallelism (batch API calls) is controlled independently of local parallel processing. + # Propagate the GUI "Batch Translation" toggle into environment so Unified API Client applies it globally + # for all providers (including custom endpoints). + try: + import os as _os + _os.environ['BATCH_TRANSLATION'] = '1' if getattr(self.main_gui, 'batch_translation_var', None) and self.main_gui.batch_translation_var.get() else '0' + # Use GUI batch size if available; default to 3 to match existing default + bs_val = None + try: + bs_val = str(int(self.main_gui.batch_size_var.get())) if hasattr(self.main_gui, 'batch_size_var') else None + except Exception: + bs_val = None + _os.environ['BATCH_SIZE'] = bs_val or _os.environ.get('BATCH_SIZE', '3') + except Exception: + # Non-fatal if env cannot be set + pass + + # Panel-level parallelization setting (LOCAL threading for panels) + advanced = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) + panel_parallel = bool(advanced.get('parallel_panel_translation', False)) + requested_panel_workers = int(advanced.get('panel_max_workers', 2)) + + # Decouple from global parallel processing: panel concurrency is governed ONLY by panel settings + effective_workers = requested_panel_workers if (panel_parallel and len(self.selected_files) > 1) else 1 + + # Hint translator about preferred BD ownership: use singleton only when not using panel parallelism + try: + if hasattr(self, 'translator') and self.translator: + self.translator.use_singleton_bubble_detector = not (panel_parallel and effective_workers > 1) + except Exception: + pass + + # Model preloading phase + self._log("๐Ÿ”ง Model preloading phase", "info") + # Log current counters (diagnostic) + try: + st = self.translator.get_preload_counters() if hasattr(self.translator, 'get_preload_counters') else None + if st: + self._log(f" Preload counters before: inpaint_spares={st.get('inpaint_spares',0)}, detector_spares={st.get('detector_spares',0)}", "debug") + except Exception: + pass + # 1) Warm up bubble detector instances first (so detection can start immediately) + try: + ocr_set = self.main_gui.config.get('manga_settings', {}).get('ocr', {}) or {} + if ( + effective_workers > 1 + and ocr_set.get('bubble_detection_enabled', True) + and hasattr(self, 'translator') + and self.translator + ): + # For parallel panel translation, prefer thread-local detectors (avoid singleton for concurrency) + try: + self.translator.use_singleton_bubble_detector = False + except Exception: + pass + desired_bd = min(int(effective_workers), max(1, int(len(self.selected_files) or 1))) + self._log(f"๐Ÿงฐ Preloading bubble detector instances for {desired_bd} panel worker(s)...", "info") + try: + import time as _time + t0 = _time.time() + self.translator.preload_bubble_detectors(ocr_set, desired_bd) + dt = _time.time() - t0 + self._log(f"โฑ๏ธ Bubble detector preload finished in {dt:.2f}s", "info") + except Exception as _e: + self._log(f"โš ๏ธ Bubble detector preload skipped: {_e}", "warning") + except Exception: + pass + # 2) Preload LOCAL inpainting instances for panel parallelism + inpaint_preload_event = None + try: + inpaint_method = self.main_gui.config.get('manga_inpaint_method', 'cloud') + if ( + effective_workers > 1 + and inpaint_method == 'local' + and hasattr(self, 'translator') + and self.translator + ): + local_method = self.main_gui.config.get('manga_local_inpaint_model', 'anime') + model_path = self.main_gui.config.get(f'manga_{local_method}_model_path', '') + if not model_path: + model_path = self.main_gui.config.get(f'{local_method}_model_path', '') + + # Preload one shared instance plus spares for parallel panel processing + # Constrain to actual number of files (no need for more workers than files) + desired_inp = min(int(effective_workers), max(1, int(len(self.selected_files) or 1))) + self._log(f"๐Ÿงฐ Preloading {desired_inp} local inpainting instance(s) for panel workers...", "info") + try: + import time as _time + t0 = _time.time() + # Use synchronous preload to ensure instances are ready before panel processing starts + self.translator.preload_local_inpainters(local_method, model_path, desired_inp) + dt = _time.time() - t0 + self._log(f"โฑ๏ธ Local inpainting preload finished in {dt:.2f}s", "info") + except Exception as _e: + self._log(f"โš ๏ธ Local inpainting preload failed: {_e}", "warning") + import traceback + self._log(traceback.format_exc(), "debug") + except Exception as preload_err: + self._log(f"โš ๏ธ Inpainting preload setup failed: {preload_err}", "warning") + + # Log updated counters (diagnostic) + try: + st2 = self.translator.get_preload_counters() if hasattr(self.translator, 'get_preload_counters') else None + if st2: + self._log(f" Preload counters after: inpaint_spares={st2.get('inpaint_spares',0)}, detector_spares={st2.get('detector_spares',0)}", "debug") + except Exception: + pass + + if panel_parallel and len(self.selected_files) > 1 and effective_workers > 1: + self._log(f"๐Ÿš€ Parallel PANEL translation ENABLED ({effective_workers} workers)", "info") + + import concurrent.futures + import threading as _threading + progress_lock = _threading.Lock() + # Memory barrier: ensures resources are fully released before next panel starts + completion_barrier = _threading.Semaphore(1) # Only one panel can complete at a time + counters = { + 'started': 0, + 'done': 0, + 'failed': 0 + } + total = self.total_files + + def process_single(idx, filepath): + # Check stop flag at the very beginning + if self.stop_flag.is_set(): + return False + + # Create an isolated translator instance per panel + translator = None # Initialize outside try block for cleanup + try: + # Check again before starting expensive work + if self.stop_flag.is_set(): + return False + from manga_translator import MangaTranslator + import os + # Build full OCR config for this thread (mirror _start_translation) + ocr_config = {'provider': self.ocr_provider_value} + if ocr_config['provider'] == 'google': + google_creds = self.main_gui.config.get('google_vision_credentials', '') or \ + self.main_gui.config.get('google_cloud_credentials', '') + if google_creds and os.path.exists(google_creds): + ocr_config['google_credentials_path'] = google_creds + else: + self._log("โš ๏ธ Google Cloud Vision credentials not found for parallel task", "warning") + elif ocr_config['provider'] == 'azure': + azure_key = self.main_gui.config.get('azure_vision_key', '') + azure_endpoint = self.main_gui.config.get('azure_vision_endpoint', '') + if azure_key and azure_endpoint: + ocr_config['azure_key'] = azure_key + ocr_config['azure_endpoint'] = azure_endpoint + else: + self._log("โš ๏ธ Azure credentials not found for parallel task", "warning") + + translator = MangaTranslator(ocr_config, self.main_gui.client, self.main_gui, log_callback=self._log) + translator.set_stop_flag(self.stop_flag) + + # CRITICAL: Disable singleton bubble detector for parallel panel processing + # Each panel should use pool-based detectors for true parallelism + try: + translator.use_singleton_bubble_detector = False + self._log(f" ๐Ÿค– Panel translator: bubble detector pool mode enabled", "debug") + except Exception: + pass + + # Ensure parallel processing settings are properly applied to each panel translator + # The web UI maps parallel_panel_translation to parallel_processing for MangaTranslator compatibility + try: + advanced = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) + if advanced.get('parallel_panel_translation', False): + # Override the manga_settings in this translator instance to enable parallel processing + # for bubble regions within each panel + translator.manga_settings.setdefault('advanced', {})['parallel_processing'] = True + panel_workers = int(advanced.get('panel_max_workers', 2)) + translator.manga_settings.setdefault('advanced', {})['max_workers'] = panel_workers + # Also set the instance attributes directly + translator.parallel_processing = True + translator.max_workers = panel_workers + self._log(f" ๐Ÿ“‹ Panel translator configured: parallel_processing={translator.parallel_processing}, max_workers={translator.max_workers}", "debug") + else: + self._log(f" ๐Ÿ“‹ Panel translator: parallel_panel_translation=False, using sequential bubble processing", "debug") + except Exception as e: + self._log(f" โš ๏ธ Warning: Failed to configure parallel processing for panel translator: {e}", "warning") + + # Also propagate global cancellation to isolated translator + from manga_translator import MangaTranslator as MTClass + if MTClass.is_globally_cancelled(): + return False + + # Check stop flag before configuration + if self.stop_flag.is_set(): + return False + + # Apply inpainting and rendering options roughly matching current translator + try: + translator.constrain_to_bubble = getattr(self, 'constrain_to_bubble_var').get() if hasattr(self, 'constrain_to_bubble_var') else True + except Exception: + pass + + # Set full page context based on UI + try: + translator.set_full_page_context( + enabled=self.full_page_context_var.get(), + custom_prompt=self.full_page_context_prompt + ) + except Exception: + pass + + # Another check before path setup + if self.stop_flag.is_set(): + return False + + # Determine output path (route CBZ images to job out_dir) + filename = os.path.basename(filepath) + output_path = None + try: + if hasattr(self, 'cbz_image_to_job') and filepath in self.cbz_image_to_job: + cbz_file = self.cbz_image_to_job[filepath] + job = getattr(self, 'cbz_jobs', {}).get(cbz_file) + if job: + output_dir = job.get('out_dir') + os.makedirs(output_dir, exist_ok=True) + output_path = os.path.join(output_dir, filename) + except Exception: + output_path = None + if not output_path: + if self.create_subfolder_value: + output_dir = os.path.join(os.path.dirname(filepath), 'translated') + os.makedirs(output_dir, exist_ok=True) + output_path = os.path.join(output_dir, filename) + else: + base, ext = os.path.splitext(filepath) + output_path = f"{base}_translated{ext}" + + # Announce start + self._update_current_file(filename) + with progress_lock: + counters['started'] += 1 + self._update_progress(counters['done'], total, f"Processing {counters['started']}/{total}: {filename}") + + # Final check before expensive processing + if self.stop_flag.is_set(): + return False + + # Process image + result = translator.process_image(filepath, output_path, batch_index=idx+1, batch_total=total) + + # CRITICAL: Explicitly cleanup this panel's translator resources + # This prevents resource leaks and partial translation issues + try: + if translator: + # Return checked-out inpainter to pool for reuse + if hasattr(translator, '_return_inpainter_to_pool'): + translator._return_inpainter_to_pool() + # Return bubble detector to pool for reuse + if hasattr(translator, '_return_bubble_detector_to_pool'): + translator._return_bubble_detector_to_pool() + # Clear all caches and state + if hasattr(translator, 'reset_for_new_image'): + translator.reset_for_new_image() + # Clear internal state + if hasattr(translator, 'clear_internal_state'): + translator.clear_internal_state() + except Exception as cleanup_err: + self._log(f"โš ๏ธ Panel translator cleanup warning: {cleanup_err}", "debug") + + # CRITICAL: Use completion barrier to prevent resource conflicts + # This ensures only one panel completes/cleans up at a time + with completion_barrier: + # Update counters only if not stopped + with progress_lock: + if self.stop_flag.is_set(): + # Don't update counters if translation was stopped + return False + + # Check if translation actually produced valid output + translation_successful = False + if result.get('success', False) and not result.get('interrupted', False): + # Verify there's an actual output file and translated regions + output_exists = result.get('output_path') and os.path.exists(result.get('output_path', '')) + regions = result.get('regions', []) + has_translations = any(r.get('translated_text', '') for r in regions) + + # CRITICAL: Verify all detected regions got translated + # Partial failures indicate inpainting or rendering issues + if has_translations and regions: + translated_count = sum(1 for r in regions if r.get('translated_text', '').strip()) + detected_count = len(regions) + completion_rate = translated_count / detected_count if detected_count > 0 else 0 + + # Log warning if completion rate is less than 100% + if completion_rate < 1.0: + self._log(f"โš ๏ธ Partial translation: {translated_count}/{detected_count} regions translated ({completion_rate*100:.1f}%)", "warning") + self._log(f" API may have skipped some regions (sound effects, symbols, or cleaning removed content)", "warning") + + # Only consider successful if at least 50% of regions translated + # This prevents marking completely failed images as successful + translation_successful = output_exists and completion_rate >= 0.5 + else: + translation_successful = output_exists and has_translations + + if translation_successful: + self.completed_files += 1 + self._log(f"โœ… Translation completed: {filename}", "success") + # Memory barrier: ensure resources are released before next completion + time.sleep(0.15) # Slightly longer pause for stability + self._log("๐Ÿ’ค Panel completion pausing for resource cleanup", "debug") + else: + self.failed_files += 1 + # Log the specific reason for failure + if result.get('interrupted', False): + self._log(f"โš ๏ธ Translation interrupted: {filename}", "warning") + elif not result.get('success', False): + self._log(f"โŒ Translation failed: {filename}", "error") + elif not result.get('output_path') or not os.path.exists(result.get('output_path', '')): + self._log(f"โŒ Output file not created: {filename}", "error") + else: + self._log(f"โŒ No text was translated: {filename}", "error") + counters['failed'] += 1 + counters['done'] += 1 + self._update_progress(counters['done'], total, f"Completed {counters['done']}/{total}") + # End of completion_barrier block - resources now released for next panel + + return result.get('success', False) + except Exception as e: + with progress_lock: + # Don't update error counters if stopped + if not self.stop_flag.is_set(): + self.failed_files += 1 + counters['failed'] += 1 + counters['done'] += 1 + if not self.stop_flag.is_set(): + self._log(f"โŒ Error in panel task: {str(e)}", "error") + self._log(traceback.format_exc(), "error") + return False + finally: + # CRITICAL: Always cleanup translator resources, even on error + # This prevents resource leaks and ensures proper cleanup in parallel mode + try: + if translator: + # Return checked-out inpainter to pool for reuse + if hasattr(translator, '_return_inpainter_to_pool'): + translator._return_inpainter_to_pool() + # Return bubble detector to pool for reuse + if hasattr(translator, '_return_bubble_detector_to_pool'): + translator._return_bubble_detector_to_pool() + # Force cleanup of all models and caches + if hasattr(translator, 'clear_internal_state'): + translator.clear_internal_state() + # Clear any remaining references + translator = None + except Exception: + pass # Never let cleanup fail the finally block + + with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, effective_workers)) as executor: + futures = [] + stagger_ms = int(advanced.get('panel_start_stagger_ms', 30)) + for idx, filepath in enumerate(self.selected_files): + if self.stop_flag.is_set(): + break + futures.append(executor.submit(process_single, idx, filepath)) + if stagger_ms > 0: + time.sleep(stagger_ms / 1000.0) + time.sleep(0.1) # Brief pause for stability + self._log("๐Ÿ’ค Staggered submission pausing briefly for stability", "debug") + + # Handle completion and stop behavior + try: + for f in concurrent.futures.as_completed(futures): + if self.stop_flag.is_set(): + # More aggressive cancellation + for rem in futures: + rem.cancel() + # Try to shutdown executor immediately + try: + executor.shutdown(wait=False) + except Exception: + pass + break + try: + # Consume future result to let it raise exceptions or return + f.result(timeout=0.1) # Very short timeout + except Exception: + # Ignore; counters are updated inside process_single + pass + except Exception: + # If as_completed fails due to shutdown, that's ok + pass + + # If stopped during parallel processing, do not log panel completion + if self.stop_flag.is_set(): + pass + else: + # After parallel processing, skip sequential loop + pass + + # After parallel processing, skip sequential loop + + # Finalize CBZ packaging after parallel mode finishes + try: + self._finalize_cbz_jobs() + except Exception: + pass + + else: + # Sequential processing (or panel parallel requested but capped to 1 by global setting) + for index, filepath in enumerate(self.selected_files): + if self.stop_flag.is_set(): + self._log("\nโน๏ธ Translation stopped by user", "warning") + break + + # IMPORTANT: Reset translator state for each new image + if hasattr(self.translator, 'reset_for_new_image'): + self.translator.reset_for_new_image() + + self.current_file_index = index + filename = os.path.basename(filepath) + + self._update_current_file(filename) + self._update_progress( + index, + self.total_files, + f"Processing {index + 1}/{self.total_files}: {filename}" + ) + + try: + # Determine output path (route CBZ images to job out_dir) + job_output_path = None + try: + if hasattr(self, 'cbz_image_to_job') and filepath in self.cbz_image_to_job: + cbz_file = self.cbz_image_to_job[filepath] + job = getattr(self, 'cbz_jobs', {}).get(cbz_file) + if job: + output_dir = job.get('out_dir') + os.makedirs(output_dir, exist_ok=True) + job_output_path = os.path.join(output_dir, filename) + except Exception: + job_output_path = None + if job_output_path: + output_path = job_output_path + else: + if self.create_subfolder_value: + output_dir = os.path.join(os.path.dirname(filepath), 'translated') + os.makedirs(output_dir, exist_ok=True) + output_path = os.path.join(output_dir, filename) + else: + base, ext = os.path.splitext(filepath) + output_path = f"{base}_translated{ext}" + + # Process the image + result = self.translator.process_image(filepath, output_path) + + # Check if translation was interrupted + if result.get('interrupted', False): + self._log(f"โธ๏ธ Translation of {filename} was interrupted", "warning") + self.failed_files += 1 + if self.stop_flag.is_set(): + break + elif result.get('success', False): + # Verify translation actually produced valid output + output_exists = result.get('output_path') and os.path.exists(result.get('output_path', '')) + has_translations = any(r.get('translated_text', '') for r in result.get('regions', [])) + + if output_exists and has_translations: + self.completed_files += 1 + self._log(f"โœ… Translation completed: {filename}", "success") + time.sleep(0.1) # Brief pause for stability + self._log("๐Ÿ’ค Sequential completion pausing briefly for stability", "debug") + else: + self.failed_files += 1 + if not output_exists: + self._log(f"โŒ Output file not created: {filename}", "error") + else: + self._log(f"โŒ No text was translated: {filename}", "error") + else: + self.failed_files += 1 + errors = '\n'.join(result.get('errors', ['Unknown error'])) + self._log(f"โŒ Translation failed: {filename}\n{errors}", "error") + + # Check for specific error types in the error messages + errors_lower = errors.lower() + if '429' in errors or 'rate limit' in errors_lower: + self._log(f"โš ๏ธ RATE LIMIT DETECTED - Please wait before continuing", "error") + self._log(f" The API provider is limiting your requests", "error") + self._log(f" Consider increasing delay between requests in settings", "error") + + # Optionally pause for a bit + self._log(f" Pausing for 60 seconds...", "warning") + for sec in range(60): + if self.stop_flag.is_set(): + break + time.sleep(1) + if sec % 10 == 0: + self._log(f" Waiting... {60-sec} seconds remaining", "warning") + + except Exception as e: + self.failed_files += 1 + error_str = str(e) + error_type = type(e).__name__ + + self._log(f"โŒ Error processing {filename}:", "error") + self._log(f" Error type: {error_type}", "error") + self._log(f" Details: {error_str}", "error") + + # Check for specific API errors + if "429" in error_str or "rate limit" in error_str.lower(): + self._log(f"โš ๏ธ RATE LIMIT ERROR (429) - API is throttling requests", "error") + self._log(f" Please wait before continuing or reduce request frequency", "error") + self._log(f" Consider increasing the API delay in settings", "error") + + # Pause for rate limit + self._log(f" Pausing for 60 seconds...", "warning") + for sec in range(60): + if self.stop_flag.is_set(): + break + time.sleep(1) + if sec % 10 == 0: + self._log(f" Waiting... {60-sec} seconds remaining", "warning") + + elif "401" in error_str or "unauthorized" in error_str.lower(): + self._log(f"โŒ AUTHENTICATION ERROR (401) - Check your API key", "error") + self._log(f" The API key appears to be invalid or expired", "error") + + elif "403" in error_str or "forbidden" in error_str.lower(): + self._log(f"โŒ FORBIDDEN ERROR (403) - Access denied", "error") + self._log(f" Check your API subscription and permissions", "error") + + elif "timeout" in error_str.lower(): + self._log(f"โฑ๏ธ TIMEOUT ERROR - Request took too long", "error") + self._log(f" Consider increasing timeout settings", "error") + + else: + # Generic error with full traceback + self._log(f" Full traceback:", "error") + self._log(traceback.format_exc(), "error") + + + # Finalize CBZ packaging (both modes) + try: + self._finalize_cbz_jobs() + except Exception: + pass + + # Final summary - only if not stopped + if not self.stop_flag.is_set(): + self._log(f"\n{'='*60}", "info") + self._log(f"๐Ÿ“Š Translation Summary:", "info") + self._log(f" Total files: {self.total_files}", "info") + self._log(f" โœ… Successful: {self.completed_files}", "success") + self._log(f" โŒ Failed: {self.failed_files}", "error" if self.failed_files > 0 else "info") + self._log(f"{'='*60}\n", "info") + + self._update_progress( + self.total_files, + self.total_files, + f"Complete! {self.completed_files} successful, {self.failed_files} failed" + ) + + except Exception as e: + self._log(f"\nโŒ Translation error: {str(e)}", "error") + self._log(traceback.format_exc(), "error") + + finally: + # Check if auto cleanup is enabled in settings + auto_cleanup_enabled = False # Default disabled by default + try: + advanced_settings = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) + auto_cleanup_enabled = advanced_settings.get('auto_cleanup_models', False) + except Exception: + pass + + if auto_cleanup_enabled: + # Clean up all models to free RAM + try: + # For parallel panel translation, cleanup happens here after ALL panels complete + is_parallel_panel = False + try: + advanced_settings = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) + is_parallel_panel = advanced_settings.get('parallel_panel_translation', True) + except Exception: + pass + + # Skip the "all parallel panels complete" message if stopped + if is_parallel_panel and not self.stop_flag.is_set(): + self._log("\n๐Ÿงน All parallel panels complete - cleaning up models to free RAM...", "info") + elif not is_parallel_panel: + self._log("\n๐Ÿงน Cleaning up models to free RAM...", "info") + + # Clean up the shared translator if parallel processing was used + if 'translator' in locals(): + translator.cleanup_all_models() + self._log("โœ… Shared translator models cleaned up!", "info") + + # Also clean up the instance translator if it exists + if hasattr(self, 'translator') and self.translator: + self.translator.cleanup_all_models() + # Set to None to ensure it's released + self.translator = None + self._log("โœ… Instance translator models cleaned up!", "info") + + self._log("โœ… All models cleaned up - RAM freed!", "info") + + except Exception as e: + self._log(f"โš ๏ธ Warning: Model cleanup failed: {e}", "warning") + + # Force garbage collection to ensure memory is freed + try: + import gc + gc.collect() + except Exception: + pass + else: + # Only log if not stopped + if not self.stop_flag.is_set(): + self._log("๐Ÿ”‘ Auto cleanup disabled - models will remain in RAM for faster subsequent translations", "info") + + # IMPORTANT: Reset the entire translator instance to free ALL memory + # Controlled by a separate "Unload models after translation" toggle + try: + # Check if we should reset the translator instance + reset_translator = False # default disabled + try: + advanced_settings = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) + reset_translator = bool(advanced_settings.get('unload_models_after_translation', False)) + except Exception: + reset_translator = False + + if reset_translator: + self._log("\n๐Ÿ—‘๏ธ Resetting translator instance to free all memory...", "info") + + # Clear the instance translator completely + if hasattr(self, 'translator'): + # First ensure models are cleaned if not already done + try: + if self.translator and hasattr(self.translator, 'cleanup_all_models'): + self.translator.cleanup_all_models() + except Exception: + pass + + # Clear all internal state using the dedicated method + try: + if self.translator and hasattr(self.translator, 'clear_internal_state'): + self.translator.clear_internal_state() + except Exception: + pass + + # Clear remaining references with proper cleanup + try: + if self.translator: + # Properly unload OCR manager and all its providers + if hasattr(self.translator, 'ocr_manager') and self.translator.ocr_manager: + try: + ocr_manager = self.translator.ocr_manager + # Clear all loaded OCR providers + if hasattr(ocr_manager, 'providers'): + for provider_name, provider in ocr_manager.providers.items(): + # Unload each provider's models + if hasattr(provider, 'model'): + provider.model = None + if hasattr(provider, 'processor'): + provider.processor = None + if hasattr(provider, 'tokenizer'): + provider.tokenizer = None + if hasattr(provider, 'reader'): + provider.reader = None + if hasattr(provider, 'is_loaded'): + provider.is_loaded = False + self._log(f" โœ“ Unloaded OCR provider: {provider_name}", "debug") + ocr_manager.providers.clear() + self._log(" โœ“ OCR manager fully unloaded", "debug") + except Exception as e: + self._log(f" Warning: OCR manager cleanup failed: {e}", "debug") + finally: + self.translator.ocr_manager = None + + # Properly unload local inpainter + if hasattr(self.translator, 'local_inpainter') and self.translator.local_inpainter: + try: + if hasattr(self.translator.local_inpainter, 'unload'): + self.translator.local_inpainter.unload() + self._log(" โœ“ Local inpainter unloaded", "debug") + except Exception as e: + self._log(f" Warning: Local inpainter cleanup failed: {e}", "debug") + finally: + self.translator.local_inpainter = None + + # Properly unload bubble detector + if hasattr(self.translator, 'bubble_detector') and self.translator.bubble_detector: + try: + if hasattr(self.translator.bubble_detector, 'unload'): + self.translator.bubble_detector.unload(release_shared=True) + self._log(" โœ“ Bubble detector unloaded", "debug") + except Exception as e: + self._log(f" Warning: Bubble detector cleanup failed: {e}", "debug") + finally: + self.translator.bubble_detector = None + + # Clear API clients + if hasattr(self.translator, 'client'): + self.translator.client = None + if hasattr(self.translator, 'vision_client'): + self.translator.vision_client = None + except Exception: + pass + + # Call translator shutdown to free all resources + try: + if translator and hasattr(translator, 'shutdown'): + translator.shutdown() + except Exception: + pass + # Finally, delete the translator instance entirely + self.translator = None + self._log("โœ… Translator instance reset - all memory freed!", "info") + + # Also clear the shared translator from parallel processing if it exists + if 'translator' in locals(): + try: + # Clear internal references + if hasattr(translator, 'cache'): + translator.cache = None + if hasattr(translator, 'text_regions'): + translator.text_regions = None + if hasattr(translator, 'translated_regions'): + translator.translated_regions = None + # Delete the local reference + del translator + except Exception: + pass + + # Clear standalone OCR manager if it exists in manga_integration + if hasattr(self, 'ocr_manager') and self.ocr_manager: + try: + ocr_manager = self.ocr_manager + # Clear all loaded OCR providers + if hasattr(ocr_manager, 'providers'): + for provider_name, provider in ocr_manager.providers.items(): + # Unload each provider's models + if hasattr(provider, 'model'): + provider.model = None + if hasattr(provider, 'processor'): + provider.processor = None + if hasattr(provider, 'tokenizer'): + provider.tokenizer = None + if hasattr(provider, 'reader'): + provider.reader = None + if hasattr(provider, 'is_loaded'): + provider.is_loaded = False + ocr_manager.providers.clear() + self.ocr_manager = None + self._log(" โœ“ Standalone OCR manager cleared", "debug") + except Exception as e: + self._log(f" Warning: Standalone OCR manager cleanup failed: {e}", "debug") + + # Force multiple garbage collection passes to ensure everything is freed + try: + import gc + gc.collect() + gc.collect() # Multiple passes for stubborn references + gc.collect() + self._log("โœ… Memory fully reclaimed", "debug") + except Exception: + pass + else: + # Only log if not stopped + if not self.stop_flag.is_set(): + self._log("๐Ÿ”‘ Translator instance preserved for faster subsequent translations", "debug") + + except Exception as e: + self._log(f"โš ๏ธ Warning: Failed to reset translator instance: {e}", "warning") + + # Reset UI state (PySide6 - call directly) + try: + self._reset_ui_state() + except Exception as e: + self._log(f"Error resetting UI: {e}", "warning") + + def _stop_translation(self): + """Stop the translation process""" + if self.is_running: + # Set local stop flag + self.stop_flag.set() + + # Set global cancellation flags for coordinated stopping + self.set_global_cancellation(True) + + # Also propagate to MangaTranslator class + try: + from manga_translator import MangaTranslator + MangaTranslator.set_global_cancellation(True) + except ImportError: + pass + + # Also propagate to UnifiedClient if available + try: + from unified_api_client import UnifiedClient + UnifiedClient.set_global_cancellation(True) + except ImportError: + pass + + # Update progress to show stopped status + self._update_progress( + self.completed_files, + self.total_files, + f"Stopped - {self.completed_files}/{self.total_files} completed" + ) + + # Try to style the progress bar to indicate stopped status + try: + # Set progress bar to a distinctive value and try to change appearance + if hasattr(self, 'progress_bar'): + # You could also set a custom style here if needed + # For now, we'll rely on the text indicators + pass + except Exception: + pass + + # Update current file display to show stopped + self._update_current_file("Translation stopped") + + # Kick off immediate resource shutdown to free RAM + try: + tr = getattr(self, 'translator', None) + if tr and hasattr(tr, 'shutdown'): + import threading + threading.Thread(target=tr.shutdown, name="MangaTranslatorShutdown", daemon=True).start() + self._log("๐Ÿงน Initiated translator resource shutdown", "info") + # Important: clear the stale translator reference so the next Start creates a fresh instance + self.translator = None + except Exception as e: + self._log(f"โš ๏ธ Failed to start shutdown: {e}", "warning") + + # Immediately reset UI state to re-enable start button + self._reset_ui_state() + self._log("\nโน๏ธ Translation stopped by user", "warning") + + # Scroll to bottom to show the stop message + try: + from PySide6.QtGui import QTextCursor + from PySide6.QtCore import QTimer + + def scroll_to_bottom(): + try: + if hasattr(self, 'log_text') and self.log_text: + self.log_text.moveCursor(QTextCursor.End) + self.log_text.ensureCursorVisible() + # Also scroll the parent scroll area if it exists + if hasattr(self, 'scroll_area') and self.scroll_area: + scrollbar = self.scroll_area.verticalScrollBar() + if scrollbar: + scrollbar.setValue(scrollbar.maximum()) + except Exception: + pass + + # Schedule scroll with a small delay to ensure the stop message is rendered + QTimer.singleShot(50, scroll_to_bottom) + QTimer.singleShot(150, scroll_to_bottom) # Second attempt to be sure + except Exception: + pass + + def _reset_ui_state(self): + """Reset UI to ready state - with widget existence checks (PySide6)""" + # Restore stdio redirection if active + self._redirect_stderr(False) + self._redirect_stdout(False) + # Stop any startup heartbeat if still running + try: + self._stop_startup_heartbeat() + except Exception: + pass + try: + # Check if the dialog still exists (PySide6) + if not hasattr(self, 'dialog') or not self.dialog: + return + + # Reset running flag + self.is_running = False + + # Check and update start_button if it exists (PySide6) + if hasattr(self, 'start_button') and self.start_button: + if not self.start_button.isEnabled(): + self.start_button.setEnabled(True) + + # Check and update stop_button if it exists (PySide6) + if hasattr(self, 'stop_button') and self.stop_button: + if self.stop_button.isEnabled(): + self.stop_button.setEnabled(False) + + # Re-enable file modification - check if listbox exists (PySide6) + if hasattr(self, 'file_listbox') and self.file_listbox: + if not self.file_listbox.isEnabled(): + self.file_listbox.setEnabled(True) + + except Exception as e: + # Log the error but don't crash + if hasattr(self, '_log'): + self._log(f"Error resetting UI state: {str(e)}", "warning") diff --git a/manga_translator.py b/manga_translator.py new file mode 100644 index 0000000000000000000000000000000000000000..eac1d0451c1d0e4158ff42da7e29e110b01bdad8 --- /dev/null +++ b/manga_translator.py @@ -0,0 +1,11564 @@ +# manga_translator.py +""" +Enhanced Manga Translation Pipeline with improved text visibility controls +Handles OCR, translation, and advanced text rendering for manga panels +Now with proper history management and full page context support +""" + +import os +import json +import base64 +import logging +import time +import traceback +import cv2 +from PIL import ImageEnhance, ImageFilter +from typing import List, Dict, Tuple, Optional, Any +from dataclasses import dataclass +from concurrent.futures import ThreadPoolExecutor, as_completed +import threading +from PIL import Image, ImageDraw, ImageFont +import numpy as np +from bubble_detector import BubbleDetector +from TransateKRtoEN import send_with_interrupt + +# Google Cloud Vision imports +try: + from google.cloud import vision + GOOGLE_CLOUD_VISION_AVAILABLE = True +except ImportError: + GOOGLE_CLOUD_VISION_AVAILABLE = False + print("Warning: Google Cloud Vision not installed. Install with: pip install google-cloud-vision") + +# Import HistoryManager for proper context management +try: + from history_manager import HistoryManager +except ImportError: + HistoryManager = None + print("Warning: HistoryManager not available. Context tracking will be limited.") + +logger = logging.getLogger(__name__) + +@dataclass +class TextRegion: + """Represents a detected text region (speech bubble, narration box, etc.)""" + text: str + vertices: List[Tuple[int, int]] # Polygon vertices from Cloud Vision + bounding_box: Tuple[int, int, int, int] # x, y, width, height + confidence: float + region_type: str # 'text_block' from Cloud Vision + translated_text: Optional[str] = None + bubble_bounds: Optional[Tuple[int, int, int, int]] = None # RT-DETR bubble bounds for rendering + + def to_dict(self): + return { + 'text': self.text, + 'vertices': self.vertices, + 'bounding_box': self.bounding_box, + 'confidence': self.confidence, + 'region_type': self.region_type, + 'translated_text': self.translated_text + } + +class MangaTranslator: + """Main class for manga translation pipeline using Google Cloud Vision + API Key""" + + # Global, process-wide registry to make local inpainting init safe across threads + # Only dictionary operations are locked (microseconds); heavy work happens outside the lock. + _inpaint_pool_lock = threading.Lock() + _inpaint_pool = {} # (method, model_path) -> {'inpainter': obj|None, 'loaded': bool, 'event': threading.Event()} + + # Detector preloading pool for non-singleton bubble detector instances + _detector_pool_lock = threading.Lock() + _detector_pool = {} # (detector_type, model_id_or_path) -> {'spares': list[BubbleDetector]} + + # Bubble detector singleton loading coordination + _singleton_bd_event = threading.Event() + _singleton_bd_loading = False + + # SINGLETON PATTERN: Shared model instances across all translators + _singleton_lock = threading.Lock() + _singleton_bubble_detector = None + _singleton_local_inpainter = None + _singleton_refs = 0 # Reference counter for singleton instances + + # Class-level cancellation flag for all instances + _global_cancelled = False + _global_cancel_lock = threading.RLock() + + @classmethod + def set_global_cancellation(cls, cancelled: bool): + """Set global cancellation flag for all translator instances""" + with cls._global_cancel_lock: + cls._global_cancelled = cancelled + + @classmethod + def is_globally_cancelled(cls) -> bool: + """Check if globally cancelled""" + with cls._global_cancel_lock: + return cls._global_cancelled + + @classmethod + def reset_global_flags(cls): + """Reset global cancellation flags when starting new translation""" + with cls._global_cancel_lock: + cls._global_cancelled = False + + def _return_inpainter_to_pool(self): + """Return a checked-out inpainter instance back to the pool for reuse.""" + if not hasattr(self, '_checked_out_inpainter') or not hasattr(self, '_inpainter_pool_key'): + return # Nothing checked out + + # Also check if the key is None + if self._inpainter_pool_key is None or self._checked_out_inpainter is None: + return + + try: + with MangaTranslator._inpaint_pool_lock: + key = self._inpainter_pool_key + + # DEBUG: Log the key we're returning to and all keys in pool + try: + method, path = key + path_basename = os.path.basename(path) if path else 'None' + self._log(f"๐Ÿ”‘ Return key: {method}/{path_basename}", "info") + + # Show all keys in pool for comparison + all_keys = list(MangaTranslator._inpaint_pool.keys()) + self._log(f"๐Ÿ“Š Pool has {len(all_keys)} key(s)", "info") + for pool_method, pool_path in all_keys: + pool_rec = MangaTranslator._inpaint_pool.get((pool_method, pool_path)) + pool_spares = len(pool_rec.get('spares', [])) if pool_rec else 0 + pool_checked = len(pool_rec.get('checked_out', [])) if pool_rec else 0 + pool_path_basename = os.path.basename(pool_path) if pool_path else 'None' + self._log(f" - {pool_method}/{pool_path_basename}: {pool_spares} spares, {pool_checked} checked out", "info") + except Exception as e: + self._log(f" Debug error: {e}", "info") + + rec = MangaTranslator._inpaint_pool.get(key) + if rec and 'checked_out' in rec: + checked_out = rec['checked_out'] + if self._checked_out_inpainter in checked_out: + checked_out.remove(self._checked_out_inpainter) + # The spares list stays static - it contains all preloaded instances + # We only track which ones are checked out, not which are available + # Available = spares not in checked_out + spares_list = rec.get('spares', []) + total_spares = len(spares_list) + checked_out_count = len(checked_out) + available_count = total_spares - checked_out_count + # Debug: count how many spares are actually valid + valid_spares = sum(1 for s in spares_list if s and getattr(s, 'model_loaded', False)) + # Also log the pool key for debugging path mismatches + try: + method, path = key + path_basename = os.path.basename(path) if path else 'None' + self._log(f"๐Ÿ”„ Returned inpainter to pool [key: {method}/{path_basename}] ({checked_out_count}/{total_spares} in use, {available_count} available, {valid_spares} valid)", "info") + except: + self._log(f"๐Ÿ”„ Returned inpainter to pool ({checked_out_count}/{total_spares} in use, {available_count} available, {valid_spares} valid)", "info") + # Clear the references + self._checked_out_inpainter = None + self._inpainter_pool_key = None + except Exception as e: + # Non-critical - just log + try: + self._log(f"โš ๏ธ Failed to return inpainter to pool: {e}", "debug") + except: + pass + + def _return_bubble_detector_to_pool(self): + """Return a checked-out bubble detector instance back to the pool for reuse.""" + if not hasattr(self, '_checked_out_bubble_detector') or not hasattr(self, '_bubble_detector_pool_key'): + return # Nothing checked out + + # Also check if the key is None + if self._bubble_detector_pool_key is None or self._checked_out_bubble_detector is None: + return + + try: + with MangaTranslator._detector_pool_lock: + key = self._bubble_detector_pool_key + rec = MangaTranslator._detector_pool.get(key) + if rec and 'checked_out' in rec: + checked_out = rec['checked_out'] + if self._checked_out_bubble_detector in checked_out: + checked_out.remove(self._checked_out_bubble_detector) + # The spares list stays static - only track checked_out + spares_list = rec.get('spares', []) + available_count = len(spares_list) - len(checked_out) + self._log(f"๐Ÿ”„ Returned bubble detector to pool ({len(checked_out)}/{len(spares_list)} in use, {available_count} available)", "info") + # Clear the references + self._checked_out_bubble_detector = None + self._bubble_detector_pool_key = None + except Exception as e: + # Non-critical - just log + try: + self._log(f"โš ๏ธ Failed to return bubble detector to pool: {e}", "debug") + except: + pass + + @classmethod + def cleanup_singletons(cls, force=False): + """Clean up singleton instances when no longer needed + + Args: + force: If True, cleanup even if references exist (for app shutdown) + """ + with cls._singleton_lock: + if force or cls._singleton_refs == 0: + # Cleanup singleton bubble detector + if cls._singleton_bubble_detector is not None: + try: + if hasattr(cls._singleton_bubble_detector, 'unload'): + cls._singleton_bubble_detector.unload(release_shared=True) + cls._singleton_bubble_detector = None + print("๐Ÿค– Singleton bubble detector cleaned up") + except Exception as e: + print(f"Failed to cleanup singleton bubble detector: {e}") + + # Cleanup singleton local inpainter + if cls._singleton_local_inpainter is not None: + try: + if hasattr(cls._singleton_local_inpainter, 'unload'): + cls._singleton_local_inpainter.unload() + cls._singleton_local_inpainter = None + print("๐ŸŽจ Singleton local inpainter cleaned up") + except Exception as e: + print(f"Failed to cleanup singleton local inpainter: {e}") + + cls._singleton_refs = 0 + + def __init__(self, ocr_config: dict, unified_client, main_gui, log_callback=None): + """Initialize with OCR configuration and API client from main GUI + + Args: + ocr_config: Dictionary with OCR provider settings: + { + 'provider': 'google' or 'azure', + 'google_credentials_path': str (if google), + 'azure_key': str (if azure), + 'azure_endpoint': str (if azure) + } + """ + # CRITICAL: Set thread limits FIRST before any heavy library operations + # This must happen before cv2, torch, numpy operations + try: + parallel_enabled = main_gui.config.get('manga_settings', {}).get('advanced', {}).get('parallel_processing', False) + if not parallel_enabled: + # Force single-threaded mode for all computational libraries + os.environ['OMP_NUM_THREADS'] = '1' + os.environ['MKL_NUM_THREADS'] = '1' + os.environ['OPENBLAS_NUM_THREADS'] = '1' + os.environ['NUMEXPR_NUM_THREADS'] = '1' + os.environ['VECLIB_MAXIMUM_THREADS'] = '1' + os.environ['ONNXRUNTIME_NUM_THREADS'] = '1' + # Set torch and cv2 thread limits if already imported + try: + import torch + torch.set_num_threads(1) + except (ImportError, RuntimeError): + pass + try: + cv2.setNumThreads(1) + except (AttributeError, NameError): + pass + except Exception: + pass # Silently fail if config not available + + # Set up logging first + self.log_callback = log_callback + self.main_gui = main_gui + + # Set up stdout capture to redirect prints to GUI + self._setup_stdout_capture() + + # Pass log callback to unified client + self.client = unified_client + if hasattr(self.client, 'log_callback'): + self.client.log_callback = log_callback + elif hasattr(self.client, 'set_log_callback'): + self.client.set_log_callback(log_callback) + self.ocr_config = ocr_config + self.main_gui = main_gui + self.log_callback = log_callback + self.config = main_gui.config + self.manga_settings = self.config.get('manga_settings', {}) + # Concise logging flag from Advanced settings + try: + self.concise_logs = bool(self.manga_settings.get('advanced', {}).get('concise_logs', True)) + except Exception: + self.concise_logs = True + + # Ensure all GUI environment variables are set + self._sync_environment_variables() + + # Initialize attributes + self.current_image = None + self.current_mask = None + self.text_regions = [] + self.translated_regions = [] + self.final_image = None + + # Initialize inpainter attributes + self.local_inpainter = None + self.hybrid_inpainter = None + self.inpainter = None + + # Initialize bubble detector (will check singleton mode later) + self.bubble_detector = None + # Default: do NOT use singleton models unless explicitly enabled + self.use_singleton_models = self.manga_settings.get('advanced', {}).get('use_singleton_models', False) + + # For bubble detector specifically, prefer a singleton so it stays resident in RAM + self.use_singleton_bubble_detector = self.manga_settings.get('advanced', {}).get('use_singleton_bubble_detector', True) + + # Processing flags + self.is_processing = False + self.cancel_requested = False + self.stop_flag = None # Initialize stop_flag attribute + + # Initialize batch mode attributes (API parallelism) from environment, not GUI local toggles + # BATCH_TRANSLATION controls whether UnifiedClient allows concurrent API calls across threads. + try: + self.batch_mode = os.getenv('BATCH_TRANSLATION', '0') == '1' + except Exception: + self.batch_mode = False + + # OCR ROI cache - PER IMAGE ONLY (cleared aggressively to prevent text leakage) + # CRITICAL: This cache MUST be cleared before every new image to prevent text contamination + # THREAD-SAFE: Each translator instance has its own cache (safe for parallel panel translation) + self.ocr_roi_cache = {} + self._current_image_hash = None # Track current image to force cache invalidation + + # Thread-safe lock for cache operations (critical for parallel panel translation) + import threading + self._cache_lock = threading.Lock() + try: + self.batch_size = int(os.getenv('BATCH_SIZE', '1')) + except Exception: + # Fallback to GUI entry if present; otherwise default to 1 + try: + self.batch_size = int(main_gui.batch_size_var.get()) if hasattr(main_gui, 'batch_size_var') else 1 + except Exception: + self.batch_size = 1 + self.batch_current = 1 + + if self.batch_mode: + self._log(f"๐Ÿ“ฆ BATCH MODE: Processing {self.batch_size} images") + self._log(f"โฑ๏ธ Keeping API delay for rate limit protection") + + # NOTE: We NO LONGER preload models here! + # Models should only be loaded when actually needed + # This was causing unnecessary RAM usage + ocr_settings = self.manga_settings.get('ocr', {}) + bubble_detection_enabled = ocr_settings.get('bubble_detection_enabled', False) + if bubble_detection_enabled: + self._log("๐Ÿ“ฆ BATCH MODE: Bubble detection will be loaded on first use") + else: + self._log("๐Ÿ“ฆ BATCH MODE: Bubble detection is disabled") + + # Cache for processed images - DEPRECATED/UNUSED (kept for backward compatibility) + # DO NOT USE THIS FOR TEXT DATA - IT CAN LEAK BETWEEN IMAGES + self.cache = {} + # Determine OCR provider + self.ocr_provider = ocr_config.get('provider', 'google') + + if self.ocr_provider == 'google': + if not GOOGLE_CLOUD_VISION_AVAILABLE: + raise ImportError("Google Cloud Vision required. Install with: pip install google-cloud-vision") + + google_path = ocr_config.get('google_credentials_path') + if not google_path: + raise ValueError("Google credentials path required") + + os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = google_path + self.vision_client = vision.ImageAnnotatorClient() + + elif self.ocr_provider == 'azure': + # Import Azure libraries + try: + from azure.cognitiveservices.vision.computervision import ComputerVisionClient + from msrest.authentication import CognitiveServicesCredentials + self.azure_cv = ComputerVisionClient + self.azure_creds = CognitiveServicesCredentials + except ImportError: + raise ImportError("Azure Computer Vision required. Install with: pip install azure-cognitiveservices-vision-computervision") + + azure_key = ocr_config.get('azure_key') + azure_endpoint = ocr_config.get('azure_endpoint') + + if not azure_key or not azure_endpoint: + raise ValueError("Azure key and endpoint required") + + self.vision_client = self.azure_cv( + azure_endpoint, + self.azure_creds(azure_key) + ) + else: + # New OCR providers handled by OCR manager + try: + from ocr_manager import OCRManager + self.ocr_manager = OCRManager(log_callback=log_callback) + print(f"Initialized OCR Manager for {self.ocr_provider}") + # Initialize OCR manager with stop flag awareness + if hasattr(self.ocr_manager, 'reset_stop_flags'): + self.ocr_manager.reset_stop_flags() + except Exception as _e: + self.ocr_manager = None + self._log(f"Failed to initialize OCRManager: {str(_e)}", "error") + + self.client = unified_client + self.main_gui = main_gui + self.log_callback = log_callback + + # Prefer allocator that can return memory to OS (effective before torch loads) + try: + os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") + os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") + except Exception: + pass + + # Get all settings from GUI + self.api_delay = float(self.main_gui.delay_entry.get() if hasattr(main_gui, 'delay_entry') else 2.0) + # Propagate API delay to unified_api_client via env var so its internal pacing/logging matches GUI + try: + os.environ["SEND_INTERVAL_SECONDS"] = str(self.api_delay) + except Exception: + pass + self.temperature = float(main_gui.trans_temp.get() if hasattr(main_gui, 'trans_temp') else 0.3) + self.max_tokens = int(main_gui.max_output_tokens if hasattr(main_gui, 'max_output_tokens') else 4000) + if hasattr(main_gui, 'token_limit_disabled') and main_gui.token_limit_disabled: + self.input_token_limit = None # None means no limit + self._log("๐Ÿ“Š Input token limit: DISABLED (unlimited)") + else: + token_limit_value = main_gui.token_limit_entry.get() if hasattr(main_gui, 'token_limit_entry') else '120000' + if token_limit_value and token_limit_value.strip().isdigit(): + self.input_token_limit = int(token_limit_value.strip()) + else: + self.input_token_limit = 120000 # Default + self._log(f"๐Ÿ“Š Input token limit: {self.input_token_limit} tokens") + + # Get contextual settings from GUI + self.contextual_enabled = main_gui.contextual_var.get() if hasattr(main_gui, 'contextual_var') else False + self.translation_history_limit = int(main_gui.trans_history.get() if hasattr(main_gui, 'trans_history') else 3) + self.rolling_history_enabled = main_gui.translation_history_rolling_var.get() if hasattr(main_gui, 'translation_history_rolling_var') else False + + # Initialize HistoryManager placeholder + self.history_manager = None + self.history_manager_initialized = False + self.history_output_dir = None + + # Full page context translation settings + self.full_page_context_enabled = True + + # Default prompt for full page context mode + self.full_page_context_prompt = ( + "You will receive multiple text segments from a manga page, each prefixed with an index like [0], [1], etc. " + "Translate each segment considering the context of all segments together. " + "Maintain consistency in character names, tone, and style across all translations.\n\n" + "CRITICAL: Return your response as a valid JSON object where each key includes BOTH the index prefix " + "AND the original text EXACTLY as provided (e.g., '[0] ใ“ใ‚“ใซใกใฏ'), and each value is the translation.\n" + "This is essential for correct mapping - do not modify or omit the index prefixes!\n\n" + "Make sure to properly escape any special characters in the JSON:\n" + "- Use \\n for newlines\n" + "- Use \\\" for quotes\n" + "- Use \\\\ for backslashes\n\n" + "Example:\n" + '{\n' + ' "[0] ใ“ใ‚“ใซใกใฏ": "Hello",\n' + ' "[1] ใ‚ใ‚ŠใŒใจใ†": "Thank you",\n' + ' "[2] ใ•ใ‚ˆใ†ใชใ‚‰": "Goodbye"\n' + '}\n\n' + 'REMEMBER: Keep the [index] prefix in each JSON key exactly as shown in the input!' + ) + + # Visual context setting (for non-vision model support) + self.visual_context_enabled = main_gui.config.get('manga_visual_context_enabled', True) + + # Store context for contextual translation (backwards compatibility) + self.translation_context = [] + + # Font settings for text rendering + self.font_path = self._find_font() + self.min_font_size = 10 + self.max_font_size = 60 + try: + _ms = main_gui.config.get('manga_settings', {}) or {} + _rend = _ms.get('rendering', {}) or {} + _font = _ms.get('font_sizing', {}) or {} + self.min_readable_size = int(_rend.get('auto_min_size', _font.get('min_size', 16))) + except Exception: + self.min_readable_size = int(main_gui.config.get('manga_min_readable_size', 16)) + self.max_font_size_limit = main_gui.config.get('manga_max_font_size', 24) + self.strict_text_wrapping = main_gui.config.get('manga_strict_text_wrapping', False) + + # Enhanced text rendering settings - Load from config if available + config = main_gui.config if hasattr(main_gui, 'config') else {} + + self.text_bg_opacity = config.get('manga_bg_opacity', 255) # 0-255, default fully opaque + self.text_bg_style = config.get('manga_bg_style', 'box') # 'box', 'circle', 'wrap' + self.text_bg_reduction = config.get('manga_bg_reduction', 1.0) # Size reduction factor (0.5-1.0) + self.constrain_to_bubble = config.get('manga_constrain_to_bubble', True) + + # Text color from config + manga_text_color = config.get('manga_text_color', [0, 0, 0]) + self.text_color = tuple(manga_text_color) # Convert list to tuple + + self.outline_color = (255, 255, 255) # White outline + self.outline_width_factor = 15 # Divider for font_size to get outline width + self.selected_font_style = config.get('manga_font_path', None) # Will store selected font path + self.custom_font_size = config.get('manga_font_size', None) if config.get('manga_font_size', 0) > 0 else None + + # Text shadow settings from config + self.shadow_enabled = config.get('manga_shadow_enabled', False) + manga_shadow_color = config.get('manga_shadow_color', [128, 128, 128]) + self.shadow_color = tuple(manga_shadow_color) # Convert list to tuple + self.shadow_offset_x = config.get('manga_shadow_offset_x', 2) + self.shadow_offset_y = config.get('manga_shadow_offset_y', 2) + self.shadow_blur = config.get('manga_shadow_blur', 0) # 0 = sharp shadow, higher = more blur + self.force_caps_lock = config.get('manga_force_caps_lock', False) + self.skip_inpainting = config.get('manga_skip_inpainting', True) + + # Font size multiplier mode - Load from config + self.font_size_mode = config.get('manga_font_size_mode', 'fixed') # 'fixed' or 'multiplier' + self.font_size_multiplier = config.get('manga_font_size_multiplier', 1.0) # Default multiplierr + + #inpainting quality + self.inpaint_quality = config.get('manga_inpaint_quality', 'high') # 'high' or 'fast' + + self._log("\n๐Ÿ”ง MangaTranslator initialized with settings:") + self._log(f" API Delay: {self.api_delay}s") + self._log(f" Temperature: {self.temperature}") + self._log(f" Max Output Tokens: {self.max_tokens}") + self._log(f" Input Token Limit: {'DISABLED' if self.input_token_limit is None else self.input_token_limit}") + self._log(f" Contextual Translation: {'ENABLED' if self.contextual_enabled else 'DISABLED'}") + self._log(f" Translation History Limit: {self.translation_history_limit}") + self._log(f" Rolling History: {'ENABLED' if self.rolling_history_enabled else 'DISABLED'}") + self._log(f" Font Path: {self.font_path or 'Default'}") + self._log(f" Text Rendering: BG {self.text_bg_style}, Opacity {int(self.text_bg_opacity/255*100)}%") + self._log(f" Shadow: {'ENABLED' if self.shadow_enabled else 'DISABLED'}\n") + + self.manga_settings = config.get('manga_settings', {}) + + # Initialize local inpainter if configured (respects singleton mode) + if self.manga_settings.get('inpainting', {}).get('method') == 'local': + if self.use_singleton_models: + self._initialize_singleton_local_inpainter() + else: + self._initialize_local_inpainter() + + # advanced settings + self.debug_mode = self.manga_settings.get('advanced', {}).get('debug_mode', False) + self.save_intermediate = self.manga_settings.get('advanced', {}).get('save_intermediate', False) + self.parallel_processing = self.manga_settings.get('advanced', {}).get('parallel_processing', True) + self.max_workers = self.manga_settings.get('advanced', {}).get('max_workers', 2) + # Deep cleanup control: if True, release models after every image (aggressive) + self.force_deep_cleanup_each_image = self.manga_settings.get('advanced', {}).get('force_deep_cleanup_each_image', False) + + # RAM cap + adv = self.manga_settings.get('advanced', {}) + self.ram_cap_enabled = bool(adv.get('ram_cap_enabled', False)) + self.ram_cap_mb = int(adv.get('ram_cap_mb', 0) or 0) + self.ram_cap_mode = str(adv.get('ram_cap_mode', 'soft')) + self.ram_check_interval_sec = float(adv.get('ram_check_interval_sec', 1.0)) + self.ram_recovery_margin_mb = int(adv.get('ram_recovery_margin_mb', 256)) + self._mem_over_cap = False + self._mem_stop_event = threading.Event() + self._mem_thread = None + # Advanced RAM gate tuning + self.ram_gate_timeout_sec = float(adv.get('ram_gate_timeout_sec', 10.0)) + self.ram_min_floor_over_baseline_mb = int(adv.get('ram_min_floor_over_baseline_mb', 128)) + # Measure baseline at init + try: + self.ram_baseline_mb = self._get_process_rss_mb() or 0 + except Exception: + self.ram_baseline_mb = 0 + if self.ram_cap_enabled and self.ram_cap_mb > 0: + self._init_ram_cap() + + + def set_stop_flag(self, stop_flag): + """Set the stop flag for checking interruptions""" + self.stop_flag = stop_flag + self.cancel_requested = False + + def reset_stop_flags(self): + """Reset all stop flags when starting new translation""" + self.cancel_requested = False + self.is_processing = False + # Reset global flags + self.reset_global_flags() + self._log("๐Ÿ”„ Stop flags reset for new translation", "debug") + + def _check_stop(self): + """Check if stop has been requested using multiple sources""" + # Check global cancellation first + if self.is_globally_cancelled(): + self.cancel_requested = True + return True + + # Check local stop flag (only if it exists and is set) + if hasattr(self, 'stop_flag') and self.stop_flag and self.stop_flag.is_set(): + self.cancel_requested = True + return True + + # Check processing flag + if hasattr(self, 'cancel_requested') and self.cancel_requested: + return True + + return False + + def _setup_stdout_capture(self): + """Set up stdout capture to redirect print statements to GUI""" + import sys + import builtins + + # Store original print function + self._original_print = builtins.print + + # Create custom print function + def gui_print(*args, **kwargs): + """Custom print that redirects to GUI""" + # Convert args to string + message = ' '.join(str(arg) for arg in args) + + # Check if this is one of the specific messages we want to capture + # Added [FALLBACK and [MAIN markers to capture key attempts in GUI + if any(marker in message for marker in ['๐Ÿ”', 'โœ…', 'โณ', 'โŒ', '๐Ÿ”‘', '[FALLBACK', '[MAIN', 'INFO:', 'ERROR:', 'WARNING:']): + if self.log_callback: + # Clean up the message + message = message.strip() + + # Determine level + level = 'info' + if 'ERROR:' in message or 'โŒ' in message: + level = 'error' + elif 'WARNING:' in message or 'โš ๏ธ' in message: + level = 'warning' + + # Remove prefixes like "INFO:" if present + for prefix in ['INFO:', 'ERROR:', 'WARNING:', 'DEBUG:']: + message = message.replace(prefix, '').strip() + + # Send to GUI + self.log_callback(message, level) + return # Don't print to console + + # For other messages, use original print + self._original_print(*args, **kwargs) + + # Replace the built-in print + builtins.print = gui_print + + def __del__(self): + """Restore original print when MangaTranslator is destroyed""" + if hasattr(self, '_original_print'): + import builtins + builtins.print = self._original_print + # Best-effort shutdown in case caller forgot to call shutdown() + try: + self.shutdown() + except Exception: + pass + + def _cleanup_thread_locals(self): + """Aggressively release thread-local heavy objects (onnx sessions, detectors).""" + try: + if hasattr(self, '_thread_local'): + tl = self._thread_local + # Release thread-local inpainters + if hasattr(tl, 'local_inpainters') and isinstance(tl.local_inpainters, dict): + try: + for inp in list(tl.local_inpainters.values()): + try: + if hasattr(inp, 'unload'): + inp.unload() + except Exception: + pass + finally: + try: + tl.local_inpainters.clear() + except Exception: + pass + # Return thread-local bubble detector to pool (DO NOT unload) + if hasattr(tl, 'bubble_detector') and tl.bubble_detector is not None: + try: + # Instead of unloading, return to pool for reuse + self._return_bubble_detector_to_pool() + # Keep thread-local reference intact for reuse in next image + # Only clear if we're truly shutting down the thread + except Exception: + pass + except Exception: + # Best-effort cleanup only + pass + + def shutdown(self): + """Fully release resources for MangaTranslator (models, detectors, torch caches, threads).""" + try: + # Decrement singleton reference counter if using singleton mode + if hasattr(self, 'use_singleton_models') and self.use_singleton_models: + with MangaTranslator._singleton_lock: + MangaTranslator._singleton_refs = max(0, MangaTranslator._singleton_refs - 1) + self._log(f"Singleton refs: {MangaTranslator._singleton_refs}", "debug") + + # Stop memory watchdog thread if running + if hasattr(self, '_mem_stop_event') and getattr(self, '_mem_stop_event', None) is not None: + try: + self._mem_stop_event.set() + except Exception: + pass + # Perform deep cleanup, then try to teardown torch + try: + self._deep_cleanup_models() + except Exception: + pass + try: + self._force_torch_teardown() + except Exception: + pass + try: + self._huggingface_teardown() + except Exception: + pass + try: + self._trim_working_set() + except Exception: + pass + # Null out heavy references + for attr in [ + 'client', 'vision_client', 'local_inpainter', 'hybrid_inpainter', 'inpainter', + 'bubble_detector', 'ocr_manager', 'history_manager', 'current_image', 'current_mask', + 'text_regions', 'translated_regions', 'final_image' + ]: + try: + if hasattr(self, attr): + setattr(self, attr, None) + except Exception: + pass + except Exception as e: + try: + self._log(f"โš ๏ธ shutdown() encountered: {e}", "warning") + except Exception: + pass + + def _sync_environment_variables(self): + """Sync all GUI environment variables to ensure manga translation respects GUI settings + This ensures settings like RETRY_TRUNCATED, THINKING_BUDGET, etc. are properly set + """ + try: + # Get config from main_gui if available + if not hasattr(self, 'main_gui') or not self.main_gui: + return + + # Use the main_gui's set_all_environment_variables method if available + if hasattr(self.main_gui, 'set_all_environment_variables'): + self.main_gui.set_all_environment_variables() + else: + # Fallback: manually set key variables + config = self.main_gui.config if hasattr(self.main_gui, 'config') else {} + + # Thinking settings (most important for speed) + thinking_enabled = config.get('enable_gemini_thinking', True) + thinking_budget = config.get('gemini_thinking_budget', -1) + + # CRITICAL FIX: If thinking is disabled, force budget to 0 regardless of config value + if not thinking_enabled: + thinking_budget = 0 + + os.environ['ENABLE_GEMINI_THINKING'] = '1' if thinking_enabled else '0' + os.environ['GEMINI_THINKING_BUDGET'] = str(thinking_budget) + os.environ['THINKING_BUDGET'] = str(thinking_budget) # Also set for unified_api_client + + # Retry settings + retry_truncated = config.get('retry_truncated', False) + max_retry_tokens = config.get('max_retry_tokens', 16384) + max_retries = config.get('max_retries', 7) + os.environ['RETRY_TRUNCATED'] = '1' if retry_truncated else '0' + os.environ['MAX_RETRY_TOKENS'] = str(max_retry_tokens) + os.environ['MAX_RETRIES'] = str(max_retries) + + # Safety settings + disable_gemini_safety = config.get('disable_gemini_safety', False) + os.environ['DISABLE_GEMINI_SAFETY'] = '1' if disable_gemini_safety else '0' + + except Exception as e: + self._log(f"โš ๏ธ Failed to sync environment variables: {e}", "warning") + + def _force_torch_teardown(self): + """Best-effort teardown of PyTorch CUDA context and caches to drop closer to baseline. + Safe to call even if CUDA is not available. + """ + try: + import torch, os, gc + # CPU: free cached tensors + try: + gc.collect() + except Exception: + pass + # CUDA path + if hasattr(torch, 'cuda') and torch.cuda.is_available(): + try: + torch.cuda.synchronize() + except Exception: + pass + try: + torch.cuda.empty_cache() + except Exception: + pass + try: + torch.cuda.ipc_collect() + except Exception: + pass + # Try to clear cuBLAS workspaces (not always available) + try: + getattr(torch._C, "_cuda_clearCublasWorkspaces")() + except Exception: + pass + # Optional hard reset via CuPy if present + reset_done = False + try: + import cupy + try: + cupy.cuda.runtime.deviceReset() + reset_done = True + self._log("CUDA deviceReset via CuPy", "debug") + except Exception: + pass + except Exception: + pass + # Fallback: attempt to call cudaDeviceReset from cudart on Windows + if os.name == 'nt' and not reset_done: + try: + import ctypes + candidates = [ + "cudart64_12.dll", "cudart64_120.dll", "cudart64_110.dll", + "cudart64_102.dll", "cudart64_101.dll", "cudart64_100.dll", "cudart64_90.dll" + ] + for name in candidates: + try: + dll = ctypes.CDLL(name) + dll.cudaDeviceReset.restype = ctypes.c_int + rc = dll.cudaDeviceReset() + self._log(f"cudaDeviceReset via {name} rc={rc}", "debug") + reset_done = True + break + except Exception: + continue + except Exception: + pass + except Exception: + pass + + def _huggingface_teardown(self): + """Best-effort teardown of HuggingFace/transformers/tokenizers state. + - Clears on-disk model cache for known repos (via _clear_hf_cache) + - Optionally purges relevant modules from sys.modules (AGGRESSIVE_HF_UNLOAD=1) + """ + try: + import os, sys, gc + # Clear disk cache for detectors (and any default repo) to avoid growth across runs + try: + self._clear_hf_cache() + except Exception: + pass + # Optional aggressive purge of modules to free Python-level caches + if os.getenv('AGGRESSIVE_HF_UNLOAD', '1') == '1': + prefixes = ( + 'transformers', + 'huggingface_hub', + 'tokenizers', + 'safetensors', + 'accelerate', + ) + to_purge = [m for m in list(sys.modules.keys()) if m.startswith(prefixes)] + for m in to_purge: + try: + del sys.modules[m] + except Exception: + pass + gc.collect() + except Exception: + pass + + def _deep_cleanup_models(self): + """Release ALL model references and caches to reduce RAM after translation. + This is the COMPREHENSIVE cleanup that ensures all models are unloaded from RAM. + """ + self._log("๐Ÿงน Starting comprehensive model cleanup to free RAM...", "info") + + try: + # ========== 1. CLEANUP OCR MODELS ========== + try: + if hasattr(self, 'ocr_manager'): + ocr_manager = getattr(self, 'ocr_manager', None) + if ocr_manager: + self._log(" Cleaning up OCR models...", "debug") + # Clear all loaded OCR providers + if hasattr(ocr_manager, 'providers'): + for provider_name, provider in ocr_manager.providers.items(): + try: + # Unload the model + if hasattr(provider, 'model'): + provider.model = None + if hasattr(provider, 'processor'): + provider.processor = None + if hasattr(provider, 'tokenizer'): + provider.tokenizer = None + if hasattr(provider, 'reader'): + provider.reader = None + if hasattr(provider, 'is_loaded'): + provider.is_loaded = False + self._log(f" โœ“ Unloaded {provider_name} OCR provider", "debug") + except Exception as e: + self._log(f" Warning: Failed to unload {provider_name}: {e}", "debug") + # Clear the entire OCR manager + self.ocr_manager = None + self._log(" โœ“ OCR models cleaned up", "debug") + except Exception as e: + self._log(f" Warning: OCR cleanup failed: {e}", "debug") + + # ========== 2. CLEANUP BUBBLE DETECTOR (YOLO/RT-DETR) ========== + try: + # Instance-level bubble detector + if hasattr(self, 'bubble_detector') and self.bubble_detector is not None: + # Check if using singleton mode - don't unload shared instance + if (getattr(self, 'use_singleton_bubble_detector', False)) or (hasattr(self, 'use_singleton_models') and self.use_singleton_models): + self._log(" Skipping bubble detector cleanup (singleton mode)", "debug") + # Just clear our reference, don't unload the shared instance + self.bubble_detector = None + else: + self._log(" Cleaning up bubble detector (YOLO/RT-DETR)...", "debug") + bd = self.bubble_detector + try: + if hasattr(bd, 'unload'): + bd.unload(release_shared=True) # This unloads YOLO and RT-DETR models + self._log(" โœ“ Called bubble detector unload", "debug") + except Exception as e: + self._log(f" Warning: Bubble detector unload failed: {e}", "debug") + self.bubble_detector = None + self._log(" โœ“ Bubble detector cleaned up", "debug") + + # Also clean class-level shared RT-DETR models unless keeping singleton warm + if not getattr(self, 'use_singleton_bubble_detector', False): + try: + from bubble_detector import BubbleDetector + if hasattr(BubbleDetector, '_rtdetr_shared_model'): + BubbleDetector._rtdetr_shared_model = None + if hasattr(BubbleDetector, '_rtdetr_shared_processor'): + BubbleDetector._rtdetr_shared_processor = None + if hasattr(BubbleDetector, '_rtdetr_loaded'): + BubbleDetector._rtdetr_loaded = False + self._log(" โœ“ Cleared shared RT-DETR cache", "debug") + except Exception: + pass + # Clear preloaded detector spares + try: + with MangaTranslator._detector_pool_lock: + for rec in MangaTranslator._detector_pool.values(): + try: + rec['spares'] = [] + except Exception: + pass + except Exception: + pass + except Exception as e: + self._log(f" Warning: Bubble detector cleanup failed: {e}", "debug") + + # ========== 3. CLEANUP INPAINTERS ========== + try: + self._log(" Cleaning up inpainter models...", "debug") + + # Instance-level inpainter + if hasattr(self, 'local_inpainter') and self.local_inpainter is not None: + # Check if using singleton mode - don't unload shared instance + if hasattr(self, 'use_singleton_models') and self.use_singleton_models: + self._log(" Skipping local inpainter cleanup (singleton mode)", "debug") + # Just clear our reference, don't unload the shared instance + self.local_inpainter = None + else: + try: + if hasattr(self.local_inpainter, 'unload'): + self.local_inpainter.unload() + self._log(" โœ“ Unloaded local inpainter", "debug") + except Exception: + pass + self.local_inpainter = None + + # Hybrid inpainter + if hasattr(self, 'hybrid_inpainter') and self.hybrid_inpainter is not None: + try: + if hasattr(self.hybrid_inpainter, 'unload'): + self.hybrid_inpainter.unload() + self._log(" โœ“ Unloaded hybrid inpainter", "debug") + except Exception: + pass + self.hybrid_inpainter = None + + # Generic inpainter reference + if hasattr(self, 'inpainter') and self.inpainter is not None: + try: + if hasattr(self.inpainter, 'unload'): + self.inpainter.unload() + self._log(" โœ“ Unloaded inpainter", "debug") + except Exception: + pass + self.inpainter = None + + # Release any shared inpainters in the global pool + with MangaTranslator._inpaint_pool_lock: + for key, rec in list(MangaTranslator._inpaint_pool.items()): + try: + inp = rec.get('inpainter') if isinstance(rec, dict) else None + if inp is not None: + try: + if hasattr(inp, 'unload'): + inp.unload() + self._log(f" โœ“ Unloaded pooled inpainter: {key}", "debug") + except Exception: + pass + # Drop any spare instances as well + try: + for spare in rec.get('spares') or []: + try: + if hasattr(spare, 'unload'): + spare.unload() + except Exception: + pass + rec['spares'] = [] + except Exception: + pass + except Exception: + pass + MangaTranslator._inpaint_pool.clear() + self._log(" โœ“ Cleared inpainter pool", "debug") + + # Release process-wide shared inpainter + if hasattr(MangaTranslator, '_shared_local_inpainter'): + shared = getattr(MangaTranslator, '_shared_local_inpainter', None) + if shared is not None: + try: + if hasattr(shared, 'unload'): + shared.unload() + self._log(" โœ“ Unloaded shared inpainter", "debug") + except Exception: + pass + setattr(MangaTranslator, '_shared_local_inpainter', None) + + self._log(" โœ“ Inpainter models cleaned up", "debug") + except Exception as e: + self._log(f" Warning: Inpainter cleanup failed: {e}", "debug") + + # ========== 4. CLEANUP THREAD-LOCAL MODELS ========== + try: + if hasattr(self, '_thread_local') and self._thread_local is not None: + self._log(" Cleaning up thread-local models...", "debug") + tl = self._thread_local + + # Thread-local inpainters + if hasattr(tl, 'local_inpainters') and isinstance(tl.local_inpainters, dict): + for key, inp in list(tl.local_inpainters.items()): + try: + if hasattr(inp, 'unload'): + inp.unload() + self._log(f" โœ“ Unloaded thread-local inpainter: {key}", "debug") + except Exception: + pass + tl.local_inpainters.clear() + + # Thread-local bubble detector + if hasattr(tl, 'bubble_detector') and tl.bubble_detector is not None: + try: + if hasattr(tl.bubble_detector, 'unload'): + tl.bubble_detector.unload(release_shared=False) + self._log(" โœ“ Unloaded thread-local bubble detector", "debug") + except Exception: + pass + tl.bubble_detector = None + + self._log(" โœ“ Thread-local models cleaned up", "debug") + except Exception as e: + self._log(f" Warning: Thread-local cleanup failed: {e}", "debug") + + # ========== 5. CLEAR PYTORCH/CUDA CACHE ========== + try: + import torch + if torch.cuda.is_available(): + torch.cuda.empty_cache() + torch.cuda.synchronize() + self._log(" โœ“ Cleared CUDA cache", "debug") + except Exception: + pass + + # ========== 6. FORCE GARBAGE COLLECTION ========== + try: + import gc + gc.collect() + # Multiple passes for stubborn references + gc.collect() + gc.collect() + self._log(" โœ“ Forced garbage collection", "debug") + except Exception: + pass + + self._log("โœ… Model cleanup complete - RAM should be freed", "info") + + except Exception as e: + # Never raise from deep cleanup + self._log(f"โš ๏ธ Model cleanup encountered error: {e}", "warning") + pass + + def _clear_hf_cache(self, repo_id: str = None): + """Best-effort: clear Hugging Face cache for a specific repo (RT-DETR by default). + This targets disk cache; it wonโ€™t directly reduce RAM but helps avoid growth across runs. + """ + try: + # Determine repo_id from BubbleDetector if not provided + if repo_id is None: + try: + import bubble_detector as _bdmod + BD = getattr(_bdmod, 'BubbleDetector', None) + if BD is not None and hasattr(BD, '_rtdetr_repo_id'): + repo_id = getattr(BD, '_rtdetr_repo_id') or 'ogkalu/comic-text-and-bubble-detector' + else: + repo_id = 'ogkalu/comic-text-and-bubble-detector' + except Exception: + repo_id = 'ogkalu/comic-text-and-bubble-detector' + + # Try to use huggingface_hub to delete just the matching repo cache + try: + from huggingface_hub import scan_cache_dir + info = scan_cache_dir() + repos = getattr(info, 'repos', []) + to_delete = [] + for repo in repos: + rid = getattr(repo, 'repo_id', None) or getattr(repo, 'id', None) + if rid == repo_id: + to_delete.append(repo) + if to_delete: + # Prefer the high-level deletion API if present + if hasattr(info, 'delete_repos'): + info.delete_repos(to_delete) + else: + import shutil + for repo in to_delete: + repo_dir = getattr(repo, 'repo_path', None) or getattr(repo, 'repo_dir', None) + if repo_dir and os.path.exists(repo_dir): + shutil.rmtree(repo_dir, ignore_errors=True) + except Exception: + # Fallback: try removing default HF cache dir for this repo pattern + try: + from pathlib import Path + hf_home = os.environ.get('HF_HOME') + if hf_home: + base = Path(hf_home) + else: + base = Path.home() / '.cache' / 'huggingface' / 'hub' + # Repo cache dirs are named like models--{org}--{name} + safe_name = repo_id.replace('/', '--') + candidates = list(base.glob(f'models--{safe_name}*')) + import shutil + for c in candidates: + shutil.rmtree(str(c), ignore_errors=True) + except Exception: + pass + except Exception: + # Best-effort only + pass + + def _trim_working_set(self): + """Release freed memory back to the OS where possible. + - On Windows: use EmptyWorkingSet on current process + - On Linux: attempt malloc_trim(0) + - On macOS: no direct API; rely on GC + """ + import sys + import platform + try: + system = platform.system() + if system == 'Windows': + import ctypes + psapi = ctypes.windll.psapi + kernel32 = ctypes.windll.kernel32 + h_process = kernel32.GetCurrentProcess() + psapi.EmptyWorkingSet(h_process) + elif system == 'Linux': + import ctypes + libc = ctypes.CDLL('libc.so.6') + try: + libc.malloc_trim(0) + except Exception: + pass + except Exception: + pass + + def _get_process_rss_mb(self) -> int: + """Return current RSS in MB (cross-platform best-effort).""" + try: + import psutil, os as _os + return int(psutil.Process(_os.getpid()).memory_info().rss / (1024*1024)) + except Exception: + # Windows fallback + try: + import ctypes, os as _os + class PROCESS_MEMORY_COUNTERS(ctypes.Structure): + _fields_ = [ + ("cb", ctypes.c_uint), + ("PageFaultCount", ctypes.c_uint), + ("PeakWorkingSetSize", ctypes.c_size_t), + ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), + ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), + ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), + ("PeakPagefileUsage", ctypes.c_size_t), + ] + GetCurrentProcess = ctypes.windll.kernel32.GetCurrentProcess + GetProcessMemoryInfo = ctypes.windll.psapi.GetProcessMemoryInfo + counters = PROCESS_MEMORY_COUNTERS() + counters.cb = ctypes.sizeof(PROCESS_MEMORY_COUNTERS) + GetProcessMemoryInfo(GetCurrentProcess(), ctypes.byref(counters), counters.cb) + return int(counters.WorkingSetSize / (1024*1024)) + except Exception: + return 0 + + def _apply_windows_job_memory_limit(self, cap_mb: int) -> bool: + """Apply a hard memory cap using Windows Job Objects. Returns True on success.""" + try: + import ctypes + from ctypes import wintypes + JOB_OBJECT_LIMIT_JOB_MEMORY = 0x00000200 + JobObjectExtendedLimitInformation = 9 + + class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", ctypes.c_longlong), + ("PerJobUserTimeLimit", ctypes.c_longlong), + ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), + ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_void_p), + ("PriorityClass", wintypes.DWORD), + ("SchedulingClass", wintypes.DWORD), + ] + + class IO_COUNTERS(ctypes.Structure): + _fields_ = [ + ("ReadOperationCount", ctypes.c_ulonglong), + ("WriteOperationCount", ctypes.c_ulonglong), + ("OtherOperationCount", ctypes.c_ulonglong), + ("ReadTransferCount", ctypes.c_ulonglong), + ("WriteTransferCount", ctypes.c_ulonglong), + ("OtherTransferCount", ctypes.c_ulonglong), + ] + + class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION), + ("IoInfo", IO_COUNTERS), + ("ProcessMemoryLimit", ctypes.c_size_t), + ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), + ("PeakJobMemoryUsed", ctypes.c_size_t), + ] + + kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) + CreateJobObject = kernel32.CreateJobObjectW + CreateJobObject.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR] + CreateJobObject.restype = wintypes.HANDLE + SetInformationJobObject = kernel32.SetInformationJobObject + SetInformationJobObject.argtypes = [wintypes.HANDLE, wintypes.INT, ctypes.c_void_p, wintypes.DWORD] + SetInformationJobObject.restype = wintypes.BOOL + AssignProcessToJobObject = kernel32.AssignProcessToJobObject + AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE] + AssignProcessToJobObject.restype = wintypes.BOOL + GetCurrentProcess = kernel32.GetCurrentProcess + GetCurrentProcess.restype = wintypes.HANDLE + + hJob = CreateJobObject(None, None) + if not hJob: + return False + + info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION() + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_JOB_MEMORY + info.JobMemoryLimit = ctypes.c_size_t(int(cap_mb) * 1024 * 1024) + + ok = SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, ctypes.byref(info), ctypes.sizeof(info)) + if not ok: + return False + + ok = AssignProcessToJobObject(hJob, GetCurrentProcess()) + if not ok: + return False + return True + except Exception: + return False + + def _memory_watchdog(self): + try: + import time + while not self._mem_stop_event.is_set(): + if not self.ram_cap_enabled or self.ram_cap_mb <= 0: + break + rss = self._get_process_rss_mb() + if rss and rss > self.ram_cap_mb: + self._mem_over_cap = True + # Aggressive attempt to reduce memory + try: + self._deep_cleanup_models() + except Exception: + pass + try: + self._trim_working_set() + except Exception: + pass + # Wait a bit before re-checking + time.sleep(max(0.2, self.ram_check_interval_sec / 2)) + time.sleep(0.1) # Brief pause for stability + self._log("๐Ÿ’ค Memory watchdog pausing briefly for stability", "debug") + else: + # Below cap or couldn't read RSS + self._mem_over_cap = False + time.sleep(self.ram_check_interval_sec) + except Exception: + pass + + def _init_ram_cap(self): + # Hard cap via Windows Job Object if selected and on Windows + try: + import platform + if self.ram_cap_mode.startswith('hard') or self.ram_cap_mode == 'hard': + if platform.system() == 'Windows': + if not self._apply_windows_job_memory_limit(self.ram_cap_mb): + self._log("โš ๏ธ Failed to apply hard RAM cap; falling back to soft mode", "warning") + self.ram_cap_mode = 'soft' + else: + self._log("โš ๏ธ Hard RAM cap only supported on Windows; using soft mode", "warning") + self.ram_cap_mode = 'soft' + except Exception: + self.ram_cap_mode = 'soft' + # Start watchdog regardless of mode to proactively stay under cap during operations + try: + self._mem_thread = threading.Thread(target=self._memory_watchdog, daemon=True) + self._mem_thread.start() + except Exception: + pass + + def _block_if_over_cap(self, context_msg: str = ""): + # If over cap, block until we drop under cap - margin + if not self.ram_cap_enabled or self.ram_cap_mb <= 0: + return + import time + # Never require target below baseline + floor margin + baseline = max(0, getattr(self, 'ram_baseline_mb', 0)) + floor = baseline + max(0, self.ram_min_floor_over_baseline_mb) + # Compute target below cap by recovery margin, but not below floor + target = self.ram_cap_mb - max(64, min(self.ram_recovery_margin_mb, self.ram_cap_mb // 4)) + target = max(target, floor) + start = time.time() + waited = False + last_log = 0 + while True: + rss = self._get_process_rss_mb() + now = time.time() + if rss and rss <= target: + break + # Timeout to avoid deadlock when baseline can't go lower than target + if now - start > max(2.0, self.ram_gate_timeout_sec): + self._log(f"โŒ› RAM gate timeout for {context_msg}: RSS={rss} MB, target={target} MB; proceeding in low-memory mode", "warning") + break + waited = True + # Periodic log to help diagnose + if now - last_log > 3.0 and rss: + self._log(f"โณ Waiting for RAM drop: RSS={rss} MB, target={target} MB ({context_msg})", "info") + last_log = now + # Attempt cleanup while waiting + try: + self._deep_cleanup_models() + except Exception: + pass + try: + self._trim_working_set() + except Exception: + pass + if self._check_stop(): + break + time.sleep(0.1) # Brief pause for stability + self._log("๐Ÿ’ค RAM gate pausing briefly for stability", "debug") + if waited and context_msg: + self._log(f"๐Ÿงน Proceeding with {context_msg} (RSS now {self._get_process_rss_mb()} MB; target {target} MB)", "info") + + def set_batch_mode(self, enabled: bool, batch_size: int = 1): + """Enable or disable batch mode optimizations""" + self.batch_mode = enabled + self.batch_size = batch_size + + if enabled: + # Check if bubble detection is actually enabled before considering preload + ocr_settings = self.manga_settings.get('ocr', {}) if hasattr(self, 'manga_settings') else {} + bubble_detection_enabled = ocr_settings.get('bubble_detection_enabled', False) + + # Only suggest preloading if bubble detection is actually going to be used + if bubble_detection_enabled: + self._log("๐Ÿ“ฆ BATCH MODE: Bubble detection models will load on first use") + # NOTE: We don't actually preload anymore to save RAM + # Models are loaded on-demand when first needed + + # Similarly for OCR models - they load on demand + if hasattr(self, 'ocr_manager') and self.ocr_manager: + self._log(f"๐Ÿ“ฆ BATCH MODE: {self.ocr_provider} will load on first use") + # NOTE: We don't preload OCR models either + + self._log(f"๐Ÿ“ฆ BATCH MODE ENABLED: Processing {batch_size} images") + self._log(f"โฑ๏ธ API delay: {self.api_delay}s (preserved for rate limiting)") + else: + self._log("๐Ÿ“ BATCH MODE DISABLED") + + def _ensure_bubble_detector_ready(self, ocr_settings): + """Ensure a usable BubbleDetector for current thread, auto-reloading models after cleanup.""" + try: + bd = self._get_thread_bubble_detector() + detector_type = ocr_settings.get('detector_type', 'rtdetr_onnx') + if detector_type == 'rtdetr_onnx': + if not getattr(bd, 'rtdetr_onnx_loaded', False): + model_id = ocr_settings.get('rtdetr_model_url') or ocr_settings.get('bubble_model_path') + if not bd.load_rtdetr_onnx_model(model_id=model_id): + return None + elif detector_type == 'rtdetr': + if not getattr(bd, 'rtdetr_loaded', False): + model_id = ocr_settings.get('rtdetr_model_url') or ocr_settings.get('bubble_model_path') + if not bd.load_rtdetr_model(model_id=model_id): + return None + elif detector_type == 'yolo': + model_path = ocr_settings.get('bubble_model_path') + if model_path and not getattr(bd, 'model_loaded', False): + if not bd.load_model(model_path): + return None + else: # auto + # Prefer RT-DETR if available, else YOLO if configured + if not getattr(bd, 'rtdetr_loaded', False): + bd.load_rtdetr_model(model_id=ocr_settings.get('rtdetr_model_url') or ocr_settings.get('bubble_model_path')) + return bd + except Exception: + return None + + def _merge_with_bubble_detection(self, regions: List[TextRegion], image_path: str) -> List[TextRegion]: + """Merge text regions by bubble and filter based on RT-DETR class settings""" + try: + # Get detector settings from config + ocr_settings = self.main_gui.config.get('manga_settings', {}).get('ocr', {}) + detector_type = ocr_settings.get('detector_type', 'rtdetr_onnx') + + # Ensure detector is ready (auto-reload after cleanup) + bd = self._ensure_bubble_detector_ready(ocr_settings) + if bd is None: + self._log("โš ๏ธ Bubble detector unavailable after cleanup; falling back to proximity merge", "warning") + # Use more conservative threshold for Azure/Google to avoid cross-bubble merging + threshold = 30 if getattr(self, 'ocr_provider', '').lower() in ('azure', 'google') else 50 + return self._merge_nearby_regions(regions, threshold=threshold) + + # Check if bubble detection is enabled + if not ocr_settings.get('bubble_detection_enabled', False): + self._log("๐Ÿ“ฆ Bubble detection is disabled in settings", "info") + # Use more conservative threshold for Azure/Google to avoid cross-bubble merging + threshold = 30 if getattr(self, 'ocr_provider', '').lower() in ('azure', 'google') else 50 + return self._merge_nearby_regions(regions, threshold=threshold) + + # Initialize thread-local detector + bd = self._get_thread_bubble_detector() + + bubbles = None + rtdetr_detections = None + + if detector_type == 'rtdetr_onnx': + if not self.batch_mode: + self._log("๐Ÿค– Using RTEDR_onnx for bubble detection", "info") + if self.batch_mode and getattr(bd, 'rtdetr_onnx_loaded', False): + pass + elif not getattr(bd, 'rtdetr_onnx_loaded', False): + self._log("๐Ÿ“ฅ Loading RTEDR_onnx model...", "info") + if not bd.load_rtdetr_onnx_model(): + self._log("โš ๏ธ Failed to load RTEDR_onnx, falling back to traditional merging", "warning") + return self._merge_nearby_regions(regions) + else: + # Model loaded successfully - mark in pool for reuse + try: + model_id = ocr_settings.get('rtdetr_model_url') or ocr_settings.get('bubble_model_path') or '' + key = ('rtdetr_onnx', model_id) + with MangaTranslator._detector_pool_lock: + if key not in MangaTranslator._detector_pool: + MangaTranslator._detector_pool[key] = {'spares': []} + # Mark this detector type as loaded for next run + MangaTranslator._detector_pool[key]['loaded'] = True + except Exception: + pass + rtdetr_confidence = ocr_settings.get('rtdetr_confidence', 0.3) + detect_empty = ocr_settings.get('detect_empty_bubbles', True) + detect_text_bubbles = ocr_settings.get('detect_text_bubbles', True) + detect_free_text = ocr_settings.get('detect_free_text', True) + if not self.batch_mode: + self._log(f"๐Ÿ“‹ RTEDR_onnx class filters:", "info") + self._log(f" Empty bubbles: {'โœ“' if detect_empty else 'โœ—'}", "info") + self._log(f" Text bubbles: {'โœ“' if detect_text_bubbles else 'โœ—'}", "info") + self._log(f" Free text: {'โœ“' if detect_free_text else 'โœ—'}", "info") + self._log(f"๐ŸŽฏ RTEDR_onnx confidence threshold: {rtdetr_confidence:.2f}", "info") + rtdetr_detections = bd.detect_with_rtdetr_onnx( + image_path=image_path, + confidence=rtdetr_confidence, + return_all_bubbles=False + ) + # Combine enabled bubble types for merging + bubbles = [] + if detect_empty and 'bubbles' in rtdetr_detections: + bubbles.extend(rtdetr_detections['bubbles']) + if detect_text_bubbles and 'text_bubbles' in rtdetr_detections: + bubbles.extend(rtdetr_detections['text_bubbles']) + # Store free text locations for filtering later + free_text_regions = rtdetr_detections.get('text_free', []) if detect_free_text else [] + self._log(f"โœ… RTEDR_onnx detected:", "success") + self._log(f" {len(rtdetr_detections.get('bubbles', []))} empty bubbles", "info") + self._log(f" {len(rtdetr_detections.get('text_bubbles', []))} text bubbles", "info") + self._log(f" {len(rtdetr_detections.get('text_free', []))} free text regions", "info") + elif detector_type == 'rtdetr': + # BATCH OPTIMIZATION: Less verbose logging + if not self.batch_mode: + self._log("๐Ÿค– Using RT-DETR for bubble detection", "info") + + # BATCH OPTIMIZATION: Don't reload if already loaded + if self.batch_mode and bd.rtdetr_loaded: + # Model already loaded, skip the loading step entirely + pass + elif not bd.rtdetr_loaded: + self._log("๐Ÿ“ฅ Loading RT-DETR model...", "info") + if not bd.load_rtdetr_model(): + self._log("โš ๏ธ Failed to load RT-DETR, falling back to traditional merging", "warning") + return self._merge_nearby_regions(regions) + else: + # Model loaded successfully - mark in pool for reuse + try: + model_id = ocr_settings.get('rtdetr_model_url') or ocr_settings.get('bubble_model_path') or '' + key = ('rtdetr', model_id) + with MangaTranslator._detector_pool_lock: + if key not in MangaTranslator._detector_pool: + MangaTranslator._detector_pool[key] = {'spares': []} + # Mark this detector type as loaded for next run + MangaTranslator._detector_pool[key]['loaded'] = True + except Exception: + pass + + # Get settings + rtdetr_confidence = ocr_settings.get('rtdetr_confidence', 0.3) + detect_empty = ocr_settings.get('detect_empty_bubbles', True) + detect_text_bubbles = ocr_settings.get('detect_text_bubbles', True) + detect_free_text = ocr_settings.get('detect_free_text', True) + + # BATCH OPTIMIZATION: Reduce logging + if not self.batch_mode: + self._log(f"๐Ÿ“‹ RT-DETR class filters:", "info") + self._log(f" Empty bubbles: {'โœ“' if detect_empty else 'โœ—'}", "info") + self._log(f" Text bubbles: {'โœ“' if detect_text_bubbles else 'โœ—'}", "info") + self._log(f" Free text: {'โœ“' if detect_free_text else 'โœ—'}", "info") + self._log(f"๐ŸŽฏ RT-DETR confidence threshold: {rtdetr_confidence:.2f}", "info") + + # Get FULL RT-DETR detections (not just bubbles) + rtdetr_detections = bd.detect_with_rtdetr( + image_path=image_path, + confidence=rtdetr_confidence, + return_all_bubbles=False # Get dict with all classes + ) + + # Combine enabled bubble types for merging + bubbles = [] + if detect_empty and 'bubbles' in rtdetr_detections: + bubbles.extend(rtdetr_detections['bubbles']) + if detect_text_bubbles and 'text_bubbles' in rtdetr_detections: + bubbles.extend(rtdetr_detections['text_bubbles']) + + # Store free text locations for filtering later + free_text_regions = rtdetr_detections.get('text_free', []) if detect_free_text else [] + + # Helper to test if a point lies in any bbox + def _point_in_any_bbox(cx, cy, boxes): + try: + for (bx, by, bw, bh) in boxes or []: + if bx <= cx <= bx + bw and by <= cy <= by + bh: + return True + except Exception: + pass + return False + + self._log(f"โœ… RT-DETR detected:", "success") + self._log(f" {len(rtdetr_detections.get('bubbles', []))} empty bubbles", "info") + self._log(f" {len(rtdetr_detections.get('text_bubbles', []))} text bubbles", "info") + self._log(f" {len(rtdetr_detections.get('text_free', []))} free text regions", "info") + + elif detector_type == 'yolo': + # Use YOLOv8 (existing code) + self._log("๐Ÿค– Using YOLOv8 for bubble detection", "info") + + model_path = ocr_settings.get('bubble_model_path') + if not model_path: + self._log("โš ๏ธ No YOLO model configured, falling back to traditional merging", "warning") + return self._merge_nearby_regions(regions) + + if not bd.model_loaded: + self._log(f"๐Ÿ“ฅ Loading YOLO model: {os.path.basename(model_path)}") + if not bd.load_model(model_path): + self._log("โš ๏ธ Failed to load YOLO model, falling back to traditional merging", "warning") + return self._merge_nearby_regions(regions) + + confidence = ocr_settings.get('bubble_confidence', 0.3) + self._log(f"๐ŸŽฏ Detecting bubbles with YOLO (confidence >= {confidence:.2f})") + bubbles = bd.detect_bubbles(image_path, confidence=confidence, use_rtdetr=False) + + else: + # Unknown detector type + self._log(f"โŒ Unknown detector type: {detector_type}", "error") + self._log(" Valid options: rtdetr_onnx, rtdetr, yolo", "error") + return self._merge_nearby_regions(regions) + + if not bubbles: + self._log("โš ๏ธ No bubbles detected, using traditional merging", "warning") + return self._merge_nearby_regions(regions) + + self._log(f"โœ… Found {len(bubbles)} bubbles for grouping", "success") + + # Merge regions within bubbles + merged_regions = [] + used_indices = set() + + # Build lookup of free text regions for exclusion + free_text_bboxes = free_text_regions if detector_type in ('rtdetr', 'rtdetr_onnx') else [] + + # DEBUG: Log free text bboxes + if free_text_bboxes: + self._log(f"๐Ÿ” Free text exclusion zones: {len(free_text_bboxes)} regions", "debug") + for idx, (fx, fy, fw, fh) in enumerate(free_text_bboxes): + self._log(f" Free text zone {idx + 1}: x={fx:.0f}, y={fy:.0f}, w={fw:.0f}, h={fh:.0f}", "debug") + else: + self._log(f"โš ๏ธ No free text exclusion zones detected by RT-DETR", "warning") + + # Helper to check if a point is in any free text region + def _point_in_free_text(cx, cy, free_boxes): + try: + for idx, (fx, fy, fw, fh) in enumerate(free_boxes or []): + if fx <= cx <= fx + fw and fy <= cy <= fy + fh: + self._log(f" โœ“ Point ({cx:.0f}, {cy:.0f}) is in free text zone {idx + 1}", "debug") + return True + except Exception as e: + self._log(f" โš ๏ธ Error checking free text: {e}", "debug") + pass + return False + + for bubble_idx, (bx, by, bw, bh) in enumerate(bubbles): + bubble_regions = [] + self._log(f"\n Processing bubble {bubble_idx + 1}: x={bx:.0f}, y={by:.0f}, w={bw:.0f}, h={bh:.0f}", "debug") + + for idx, region in enumerate(regions): + if idx in used_indices: + continue + + rx, ry, rw, rh = region.bounding_box + region_center_x = rx + rw / 2 + region_center_y = ry + rh / 2 + + # Check if center is inside this bubble + if (bx <= region_center_x <= bx + bw and + by <= region_center_y <= by + bh): + + self._log(f" Region '{region.text[:20]}...' center ({region_center_x:.0f}, {region_center_y:.0f}) is in bubble", "debug") + + # CRITICAL: Don't merge if this region is in a free text area + # Free text should stay separate from bubbles + if _point_in_free_text(region_center_x, region_center_y, free_text_bboxes): + # This region is in a free text area, don't merge it into bubble + self._log(f" โŒ SKIPPING: Region overlaps with free text area", "debug") + continue + + self._log(f" โœ“ Adding region to bubble {bubble_idx + 1}", "debug") + bubble_regions.append(region) + used_indices.add(idx) + + if bubble_regions: + # CRITICAL: Check if this "bubble" actually contains multiple separate bubbles + # This happens when RT-DETR detects one large bubble over stacked speech bubbles + split_groups = self._split_bubble_if_needed(bubble_regions) + + # Process each split group as a separate bubble + for group_idx, group in enumerate(split_groups): + merged_text = " ".join(r.text for r in group) + + min_x = min(r.bounding_box[0] for r in group) + min_y = min(r.bounding_box[1] for r in group) + max_x = max(r.bounding_box[0] + r.bounding_box[2] for r in group) + max_y = max(r.bounding_box[1] + r.bounding_box[3] for r in group) + + all_vertices = [] + for r in group: + if hasattr(r, 'vertices') and r.vertices: + all_vertices.extend(r.vertices) + + if not all_vertices: + all_vertices = [ + (min_x, min_y), + (max_x, min_y), + (max_x, max_y), + (min_x, max_y) + ] + + merged_region = TextRegion( + text=merged_text, + vertices=all_vertices, + bounding_box=(min_x, min_y, max_x - min_x, max_y - min_y), + confidence=0.95, + region_type='bubble_detected', + bubble_bounds=(bx, by, bw, bh) # Pass bubble_bounds in constructor + ) + + # Store original regions for masking + merged_region.original_regions = group + # Classify as text bubble for downstream rendering/masking + merged_region.bubble_type = 'text_bubble' + # Mark that this should be inpainted + merged_region.should_inpaint = True + + merged_regions.append(merged_region) + + # DEBUG: Verify bubble_bounds was set + if not getattr(self, 'concise_logs', False): + has_bb = hasattr(merged_region, 'bubble_bounds') and merged_region.bubble_bounds is not None + self._log(f" ๐Ÿ” Merged region has bubble_bounds: {has_bb}", "debug") + if has_bb: + self._log(f" bubble_bounds = {merged_region.bubble_bounds}", "debug") + + if len(split_groups) > 1: + self._log(f" Bubble {bubble_idx + 1}.{group_idx + 1}: Merged {len(group)} text regions (split from {len(bubble_regions)} total)", "info") + else: + self._log(f" Bubble {bubble_idx + 1}: Merged {len(group)} text regions", "info") + + # Handle text outside bubbles based on RT-DETR settings + for idx, region in enumerate(regions): + if idx not in used_indices: + # This text is outside any bubble + + # For RT-DETR mode, check if we should include free text + if detector_type in ('rtdetr', 'rtdetr_onnx'): + # If "Free Text" checkbox is checked, include ALL text outside bubbles + # Don't require RT-DETR to specifically detect it as free text + if ocr_settings.get('detect_free_text', True): + region.should_inpaint = True + # If RT-DETR detected free text box covering this region's center, mark explicitly + try: + cx = region.bounding_box[0] + region.bounding_box[2] / 2 + cy = region.bounding_box[1] + region.bounding_box[3] / 2 + # Find which free text bbox this region belongs to (if any) + found_free_text_box = False + for fx, fy, fw, fh in free_text_bboxes: + if fx <= cx <= fx + fw and fy <= cy <= fy + fh: + region.bubble_type = 'free_text' + # CRITICAL: Set bubble_bounds to the RT-DETR free text detection box + # This ensures rendering uses the full RT-DETR bounds, not just OCR polygon + if not hasattr(region, 'bubble_bounds') or region.bubble_bounds is None: + region.bubble_bounds = (fx, fy, fw, fh) + found_free_text_box = True + self._log(f" Free text region INCLUDED: '{region.text[:30]}...'", "debug") + break + + if not found_free_text_box: + # Text outside bubbles but not in free text box - still mark as free text + region.bubble_type = 'free_text' + # Use region's own bbox if no RT-DETR free text box found + if not hasattr(region, 'bubble_bounds') or region.bubble_bounds is None: + region.bubble_bounds = region.bounding_box + self._log(f" Text outside bubbles INCLUDED (as free text): '{region.text[:30]}...'", "debug") + except Exception: + # Default to free text if check fails + region.bubble_type = 'free_text' + if not hasattr(region, 'bubble_bounds') or region.bubble_bounds is None: + region.bubble_bounds = region.bounding_box + else: + region.should_inpaint = False + self._log(f" Text outside bubbles EXCLUDED (Free Text unchecked): '{region.text[:30]}...'", "info") + else: + # For YOLO/auto, include all text by default + region.should_inpaint = True + + merged_regions.append(region) + + # Log summary + regions_to_inpaint = sum(1 for r in merged_regions if getattr(r, 'should_inpaint', True)) + regions_to_skip = len(merged_regions) - regions_to_inpaint + + self._log(f"๐Ÿ“Š Bubble detection complete: {len(regions)} โ†’ {len(merged_regions)} regions", "success") + if detector_type == 'rtdetr': + self._log(f" {regions_to_inpaint} regions will be inpainted", "info") + if regions_to_skip > 0: + self._log(f" {regions_to_skip} regions will be preserved (Free Text unchecked)", "info") + + return merged_regions + + except Exception as e: + self._log(f"โŒ Bubble detection error: {str(e)}", "error") + self._log(" Falling back to traditional merging", "warning") + return self._merge_nearby_regions(regions) + + def set_full_page_context(self, enabled: bool, custom_prompt: str = None): + """Configure full page context translation mode + + Args: + enabled: Whether to translate all text regions in a single contextual request + custom_prompt: Optional custom prompt for full page context mode + """ + self.full_page_context_enabled = enabled + if custom_prompt: + self.full_page_context_prompt = custom_prompt + + self._log(f"๐Ÿ“„ Full page context mode: {'ENABLED' if enabled else 'DISABLED'}") + if enabled: + self._log(" All text regions will be sent together for contextual translation") + else: + self._log(" Text regions will be translated individually") + + def update_text_rendering_settings(self, + bg_opacity: int = None, + bg_style: str = None, + bg_reduction: float = None, + font_style: str = None, + font_size: int = None, + text_color: tuple = None, + shadow_enabled: bool = None, + shadow_color: tuple = None, + shadow_offset_x: int = None, + shadow_offset_y: int = None, + shadow_blur: int = None, + force_caps_lock: bool = None): # ADD THIS PARAMETER + """Update text rendering settings""" + self._log("๐Ÿ“ Updating text rendering settings:", "info") + + if bg_opacity is not None: + self.text_bg_opacity = max(0, min(255, bg_opacity)) + self._log(f" Background opacity: {int(self.text_bg_opacity/255*100)}%", "info") + if bg_style is not None and bg_style in ['box', 'circle', 'wrap']: + self.text_bg_style = bg_style + self._log(f" Background style: {bg_style}", "info") + if bg_reduction is not None: + self.text_bg_reduction = max(0.5, min(2.0, bg_reduction)) + self._log(f" Background size: {int(self.text_bg_reduction*100)}%", "info") + if font_style is not None: + self.selected_font_style = font_style + font_name = os.path.basename(font_style) if font_style else 'Default' + self._log(f" Font: {font_name}", "info") + if font_size is not None: + if font_size < 0: + # Negative value indicates multiplier mode + self.font_size_mode = 'multiplier' + self.font_size_multiplier = abs(font_size) + self.custom_font_size = None # Clear fixed size + self._log(f" Font size mode: Dynamic multiplier ({self.font_size_multiplier:.1f}x)", "info") + else: + # Positive value or 0 indicates fixed mode + self.font_size_mode = 'fixed' + self.custom_font_size = font_size if font_size > 0 else None + self._log(f" Font size mode: Fixed ({font_size if font_size > 0 else 'Auto'})", "info") + if text_color is not None: + self.text_color = text_color + self._log(f" Text color: RGB{text_color}", "info") + if shadow_enabled is not None: + self.shadow_enabled = shadow_enabled + self._log(f" Shadow: {'Enabled' if shadow_enabled else 'Disabled'}", "info") + if shadow_color is not None: + self.shadow_color = shadow_color + self._log(f" Shadow color: RGB{shadow_color}", "info") + if shadow_offset_x is not None: + self.shadow_offset_x = shadow_offset_x + if shadow_offset_y is not None: + self.shadow_offset_y = shadow_offset_y + if shadow_blur is not None: + self.shadow_blur = max(0, shadow_blur) + if force_caps_lock is not None: # ADD THIS BLOCK + self.force_caps_lock = force_caps_lock + self._log(f" Force Caps Lock: {'Enabled' if force_caps_lock else 'Disabled'}", "info") + + self._log("โœ… Rendering settings updated", "info") + + def _log(self, message: str, level: str = "info"): + """Log message to GUI or console, and also to file logger. + The file logger is configured in translator_gui._setup_file_logging(). + Enhanced with comprehensive stop suppression. + """ + # Enhanced stop suppression - allow only essential stop confirmation messages + if self._check_stop() or self.is_globally_cancelled(): + # Only allow very specific stop confirmation messages - nothing else + essential_stop_keywords = [ + "โน๏ธ Translation stopped by user", + "๐Ÿงน Cleaning up models to free RAM", + "โœ… Model cleanup complete - RAM should be freed", + "โœ… All models cleaned up - RAM freed!" + ] + # Suppress ALL other messages when stopped - be very restrictive + if not any(keyword in message for keyword in essential_stop_keywords): + return + + # Concise pipeline logs: keep only high-level messages and errors/warnings + if getattr(self, 'concise_logs', False): + if level in ("error", "warning"): + pass + else: + keep_prefixes = ( + # Pipeline boundaries and IO + "๐Ÿ“ท STARTING", "๐Ÿ“ Input", "๐Ÿ“ Output", + # Step markers + "๐Ÿ“ [STEP", + # Step 1 essentials + "๐Ÿ” Detecting text regions", # start of detection on file + "๐Ÿ“„ Detected", # format detected + "Using OCR provider:", # provider line + "Using Azure Read API", # azure-specific run mode + "โš ๏ธ Converting image to PNG", # azure PNG compatibility + "๐Ÿค– Using AI bubble detection", # BD merge mode + "๐Ÿค– Using RTEDR_onnx", # selected BD + "โœ… Detected", # detected N regions after merging + # Detectors/inpainter readiness + "๐Ÿค– Using bubble detector", "๐ŸŽจ Using local inpainter", + # Step 2: key actions + "๐Ÿ”€ Running", # Running translation and inpainting concurrently + "๐Ÿ“„ Using FULL PAGE CONTEXT", # Explicit mode notice + "๐Ÿ“„ Full page context mode", # Alternate phrasing + "๐Ÿ“„ Full page context translation", # Start/summary + "๐ŸŽญ Creating text mask", "๐Ÿ“Š Mask breakdown", "๐Ÿ“ Applying", + "๐ŸŽจ Inpainting", "๐Ÿงฝ Using local inpainting", + # Detection and summary + "๐Ÿ“Š Bubble detection complete", "โœ… Detection complete", + # Mapping/translation summary + "๐Ÿ“Š Mapping", "๐Ÿ“Š Full page context translation complete", + # Rendering + "โœ๏ธ Rendering", "โœ… ENHANCED text rendering complete", + # Output and final summary + "๐Ÿ’พ Saved output", "โœ… TRANSLATION PIPELINE COMPLETE", + "๐Ÿ“Š Translation Summary", "โœ… Successful", "โŒ Failed", + # Cleanup + "๐Ÿ”‘ Auto cleanup", "๐Ÿ”‘ Translator instance preserved" + ) + _msg = message.lstrip() if isinstance(message, str) else message + if not any(_msg.startswith(p) for p in keep_prefixes): + return + + # In batch mode, only log important messages + if self.batch_mode: + # Skip verbose/debug messages in batch mode + if level == "debug" or "DEBUG:" in message: + return + # Skip repetitive messages + if any(skip in message for skip in [ + "Using vertex-based", "Using", "Applying", "Font size", + "Region", "Found text", "Style:" + ]): + return + + # Send to GUI if available + if self.log_callback: + try: + self.log_callback(message, level) + except Exception: + # Fall back to print if GUI callback fails + print(message) + else: + print(message) + + # Always record to the Python logger (file) + try: + _logger = logging.getLogger(__name__) + if level == "error": + _logger.error(message) + elif level == "warning": + _logger.warning(message) + elif level == "debug": + _logger.debug(message) + else: + # Map custom levels like 'success' to INFO + _logger.info(message) + except Exception: + pass + + def _is_primarily_english(self, text: str) -> bool: + """Heuristic: treat text as English if it has no CJK and a high ASCII ratio. + Conservative by default to avoid dropping legitimate content. + Tunable via manga_settings.ocr: + - english_exclude_threshold (float, default 0.70) + - english_exclude_min_chars (int, default 4) + - english_exclude_short_tokens (bool, default False) + """ + if not text: + return False + + # Pull tuning knobs from settings (with safe defaults) + ocr_settings = {} + try: + ocr_settings = self.main_gui.config.get('manga_settings', {}).get('ocr', {}) + except Exception: + pass + threshold = float(ocr_settings.get('english_exclude_threshold', 0.70)) + min_chars = int(ocr_settings.get('english_exclude_min_chars', 4)) + exclude_short = bool(ocr_settings.get('english_exclude_short_tokens', False)) + + # 1) If text contains any CJK or full-width characters, do NOT treat as English + has_cjk = any( + '\u4e00' <= char <= '\u9fff' or # Chinese + '\u3040' <= char <= '\u309f' or # Hiragana + '\u30a0' <= char <= '\u30ff' or # Katakana + '\uac00' <= char <= '\ud7af' or # Korean + '\uff00' <= char <= '\uffef' # Full-width characters + for char in text + ) + if has_cjk: + return False + + text_stripped = text.strip() + non_space_len = sum(1 for c in text_stripped if not c.isspace()) + + # 2) By default, do not exclude very short tokens to avoid losing interjections like "Ah", "Eh?", etc. + if not exclude_short and non_space_len < max(1, min_chars): + return False + + # Optional legacy behavior: aggressively drop very short pure-ASCII tokens + if exclude_short: + if len(text_stripped) == 1 and text_stripped.isalpha() and ord(text_stripped) < 128: + self._log(f" Excluding single English letter: '{text_stripped}'", "debug") + return True + if len(text_stripped) <= 3: + ascii_letters = sum(1 for char in text_stripped if char.isalpha() and ord(char) < 128) + if ascii_letters >= len(text_stripped) * 0.5: + self._log(f" Excluding short English text: '{text_stripped}'", "debug") + return True + + # 3) Compute ASCII ratio (exclude spaces) + ascii_chars = sum(1 for char in text if 33 <= ord(char) <= 126) + total_chars = sum(1 for char in text if not char.isspace()) + if total_chars == 0: + return False + ratio = ascii_chars / total_chars + + if ratio > threshold: + self._log(f" Excluding English text ({ratio:.0%} ASCII, threshold {threshold:.0%}, len={non_space_len}): '{text[:30]}...'", "debug") + return True + return False + + def _load_bubble_detector(self, ocr_settings, image_path): + """Load bubble detector with appropriate model based on settings + + Returns: + dict: Detection results or None if failed + """ + detector_type = ocr_settings.get('detector_type', 'rtdetr_onnx') + model_path = ocr_settings.get('bubble_model_path', '') + confidence = ocr_settings.get('bubble_confidence', 0.3) + + bd = self._get_thread_bubble_detector() + + if detector_type == 'rtdetr_onnx' or 'RTEDR_onnx' in str(detector_type): + # Load RT-DETR ONNX model + if bd.load_rtdetr_onnx_model(model_id=ocr_settings.get('rtdetr_model_url') or model_path): + return bd.detect_with_rtdetr_onnx( + image_path=image_path, + confidence=ocr_settings.get('rtdetr_confidence', confidence), + return_all_bubbles=False + ) + elif detector_type == 'rtdetr' or 'RT-DETR' in str(detector_type): + # Load RT-DETR (PyTorch) model + if bd.load_rtdetr_model(model_id=ocr_settings.get('rtdetr_model_url') or model_path): + return bd.detect_with_rtdetr( + image_path=image_path, + confidence=ocr_settings.get('rtdetr_confidence', confidence), + return_all_bubbles=False + ) + elif detector_type == 'custom': + # Custom model - try to determine type from path + custom_path = ocr_settings.get('custom_model_path', model_path) + if 'rtdetr' in custom_path.lower(): + # Custom RT-DETR model + if bd.load_rtdetr_model(model_id=custom_path): + return bd.detect_with_rtdetr( + image_path=image_path, + confidence=confidence, + return_all_bubbles=False + ) + else: + # Assume YOLO format for other custom models + if custom_path and bd.load_model(custom_path): + detections = bd.detect_bubbles( + image_path, + confidence=confidence + ) + return { + 'text_bubbles': detections if detections else [], + 'text_free': [], + 'bubbles': [] + } + else: + # Standard YOLO model + if model_path and bd.load_model(model_path): + detections = bd.detect_bubbles( + image_path, + confidence=confidence + ) + return { + 'text_bubbles': detections if detections else [], + 'text_free': [], + 'bubbles': [] + } + return None + + def _ensure_google_client(self): + try: + if getattr(self, 'vision_client', None) is None: + from google.cloud import vision + google_path = self.ocr_config.get('google_credentials_path') if hasattr(self, 'ocr_config') else None + if google_path: + os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = google_path + self.vision_client = vision.ImageAnnotatorClient() + self._log("โœ… Reinitialized Google Vision client", "debug") + except Exception as e: + self._log(f"โŒ Failed to initialize Google Vision client: {e}", "error") + + def _ensure_azure_client(self): + try: + if getattr(self, 'vision_client', None) is None: + from azure.cognitiveservices.vision.computervision import ComputerVisionClient + from msrest.authentication import CognitiveServicesCredentials + key = None + endpoint = None + try: + key = (self.ocr_config or {}).get('azure_key') + endpoint = (self.ocr_config or {}).get('azure_endpoint') + except Exception: + pass + if not key: + key = self.main_gui.config.get('azure_vision_key', '') if hasattr(self, 'main_gui') else None + if not endpoint: + endpoint = self.main_gui.config.get('azure_vision_endpoint', '') if hasattr(self, 'main_gui') else None + if not key or not endpoint: + raise ValueError("Azure credentials missing for client init") + self.vision_client = ComputerVisionClient(endpoint, CognitiveServicesCredentials(key)) + self._log("โœ… Reinitialized Azure Computer Vision client", "debug") + except Exception as e: + self._log(f"โŒ Failed to initialize Azure CV client: {e}", "error") + + def detect_text_regions(self, image_path: str) -> List[TextRegion]: + """Detect text regions using configured OCR provider""" + # Reduce logging in batch mode + if not self.batch_mode: + self._log(f"๐Ÿ” Detecting text regions in: {os.path.basename(image_path)}") + self._log(f" Using OCR provider: {self.ocr_provider.upper()}") + else: + # Only show batch progress if batch_current is set properly + if hasattr(self, 'batch_current') and hasattr(self, 'batch_size'): + self._log(f"๐Ÿ” [{self.batch_current}/{self.batch_size}] {os.path.basename(image_path)}") + else: + self._log(f"๐Ÿ” Detecting text: {os.path.basename(image_path)}") + + try: + # ============================================================ + # CRITICAL: FORCE CLEAR ALL TEXT-RELATED CACHES + # This MUST happen for EVERY image to prevent text contamination + # NO EXCEPTIONS - batch mode or not, ALL caches get cleared + # ============================================================ + + # 1. Clear OCR ROI cache (prevents text from previous images leaking) + # THREAD-SAFE: Use lock to prevent race conditions in parallel panel translation + if hasattr(self, 'ocr_roi_cache'): + with self._cache_lock: + self.ocr_roi_cache.clear() + self._log("๐Ÿงน Cleared OCR ROI cache", "debug") + + # 2. Clear OCR manager caches (multiple potential cache locations) + if hasattr(self, 'ocr_manager') and self.ocr_manager: + # Clear last_results (can contain text from previous image) + if hasattr(self.ocr_manager, 'last_results'): + self.ocr_manager.last_results = None + # Clear generic cache + if hasattr(self.ocr_manager, 'cache'): + self.ocr_manager.cache.clear() + # Clear provider-level caches + if hasattr(self.ocr_manager, 'providers'): + for provider_name, provider in self.ocr_manager.providers.items(): + if hasattr(provider, 'last_results'): + provider.last_results = None + if hasattr(provider, 'cache'): + provider.cache.clear() + self._log("๐Ÿงน Cleared OCR manager caches", "debug") + + # 3. Clear bubble detector cache (can contain text region info) + if hasattr(self, 'bubble_detector') and self.bubble_detector: + if hasattr(self.bubble_detector, 'last_detections'): + self.bubble_detector.last_detections = None + if hasattr(self.bubble_detector, 'cache'): + self.bubble_detector.cache.clear() + self._log("๐Ÿงน Cleared bubble detector cache", "debug") + + # Get manga settings from main_gui config + manga_settings = self.main_gui.config.get('manga_settings', {}) + preprocessing = manga_settings.get('preprocessing', {}) + ocr_settings = manga_settings.get('ocr', {}) + + # Get text filtering settings + min_text_length = ocr_settings.get('min_text_length', 2) + exclude_english = ocr_settings.get('exclude_english_text', True) + confidence_threshold = ocr_settings.get('confidence_threshold', 0.1) + + # Load and preprocess image if enabled + if preprocessing.get('enabled', False): + self._log("๐Ÿ“ Preprocessing enabled - enhancing image quality") + processed_image_data = self._preprocess_image(image_path, preprocessing) + else: + # Read image with optional compression (separate from preprocessing) + try: + comp_cfg = (self.main_gui.config.get('manga_settings', {}) or {}).get('compression', {}) + if comp_cfg.get('enabled', False): + processed_image_data = self._load_image_with_compression_only(image_path, comp_cfg) + else: + with open(image_path, 'rb') as image_file: + processed_image_data = image_file.read() + except Exception: + with open(image_path, 'rb') as image_file: + processed_image_data = image_file.read() + + # Compute per-image hash for caching (based on uploaded bytes) + # CRITICAL FIX #1: Never allow None page_hash to prevent cache key collisions + try: + import hashlib + page_hash = hashlib.sha1(processed_image_data).hexdigest() + + # CRITICAL: Never allow None page_hash + if page_hash is None: + # Fallback: use image path + timestamp for uniqueness + import time + import uuid + page_hash = hashlib.sha1( + f"{image_path}_{time.time()}_{uuid.uuid4()}".encode() + ).hexdigest() + self._log("โš ๏ธ Using fallback page hash for cache isolation", "warning") + + # CRITICAL: If image hash changed, force clear ROI cache + # THREAD-SAFE: Use lock for parallel panel translation + if hasattr(self, '_current_image_hash') and self._current_image_hash != page_hash: + if hasattr(self, 'ocr_roi_cache'): + with self._cache_lock: + self.ocr_roi_cache.clear() + self._log("๐Ÿงน Image changed - cleared ROI cache", "debug") + self._current_image_hash = page_hash + except Exception as e: + # Emergency fallback - never let page_hash be None + import uuid + page_hash = str(uuid.uuid4()) + self._current_image_hash = page_hash + self._log(f"โš ๏ธ Page hash generation failed: {e}, using UUID fallback", "error") + + regions = [] + + # Route to appropriate provider + if self.ocr_provider == 'google': + # === GOOGLE CLOUD VISION === + # Ensure client exists (it might have been cleaned up between runs) + try: + self._ensure_google_client() + except Exception: + pass + + # Check if we should use RT-DETR for text region detection (NEW FEATURE) + # IMPORTANT: bubble_detection_enabled should default to True for optimal detection + if ocr_settings.get('bubble_detection_enabled', True) and ocr_settings.get('use_rtdetr_for_ocr_regions', True): + self._log("๐ŸŽฏ Using RT-DETR to guide Google Cloud Vision OCR") + + # Run RT-DETR to detect text regions first + _ = self._get_thread_bubble_detector() + rtdetr_detections = self._load_bubble_detector(ocr_settings, image_path) + + if rtdetr_detections: + # Collect all text-containing regions WITH TYPE TRACKING + all_regions = [] + # Track region type to assign bubble_type later + region_types = {} + idx = 0 + if 'text_bubbles' in rtdetr_detections: + for bbox in rtdetr_detections.get('text_bubbles', []): + all_regions.append(bbox) + region_types[idx] = 'text_bubble' + idx += 1 + if 'text_free' in rtdetr_detections: + for bbox in rtdetr_detections.get('text_free', []): + all_regions.append(bbox) + region_types[idx] = 'free_text' + idx += 1 + + if all_regions: + self._log(f"๐Ÿ“Š RT-DETR detected {len(all_regions)} text regions, OCR-ing each with Google Vision") + + # Load image for cropping + import cv2 + cv_image = cv2.imread(image_path) + if cv_image is None: + self._log("โš ๏ธ Failed to load image, falling back to full-page OCR", "warning") + else: + # Define worker function for concurrent OCR + def ocr_region_google(region_data): + i, region_idx, x, y, w, h = region_data + try: + # RATE LIMITING: Add small delay to avoid potential rate limits + # Google has high limits (1,800/min paid tier) but being conservative + import time + import random + time.sleep(0.1 + random.random() * 0.2) # 0.1-0.3s random delay + + # Crop region + cropped = self._safe_crop_region(cv_image, x, y, w, h) + if cropped is None: + return None + + # Validate and resize crop if needed (Google Vision requires minimum dimensions) + h_crop, w_crop = cropped.shape[:2] + MIN_SIZE = 50 # Minimum dimension (increased from 10 for better OCR) + MIN_AREA = 2500 # Minimum area (50x50) + + # Skip completely invalid/corrupted regions (0 or negative dimensions) + if h_crop <= 0 or w_crop <= 0: + self._log(f"โš ๏ธ Region {i} has invalid dimensions ({w_crop}x{h_crop}px), skipping", "debug") + return None + + if h_crop < MIN_SIZE or w_crop < MIN_SIZE or h_crop * w_crop < MIN_AREA: + # Region too small - resize it + scale_w = MIN_SIZE / w_crop if w_crop < MIN_SIZE else 1.0 + scale_h = MIN_SIZE / h_crop if h_crop < MIN_SIZE else 1.0 + scale = max(scale_w, scale_h) + + if scale > 1.0: + new_w = int(w_crop * scale) + new_h = int(h_crop * scale) + cropped = cv2.resize(cropped, (new_w, new_h), interpolation=cv2.INTER_CUBIC) + self._log(f"๐Ÿ” Region {i} resized from {w_crop}x{h_crop}px to {new_w}x{new_h}px for OCR", "debug") + h_crop, w_crop = new_h, new_w + + # Encode cropped image + _, encoded = cv2.imencode('.jpg', cropped, [cv2.IMWRITE_JPEG_QUALITY, 95]) + region_image_data = encoded.tobytes() + + # Create Vision API image object + vision_image = vision.Image(content=region_image_data) + image_context = vision.ImageContext( + language_hints=ocr_settings.get('language_hints', ['ja', 'ko', 'zh']) + ) + + # Detect text in this region + detection_mode = ocr_settings.get('text_detection_mode', 'document') + if detection_mode == 'document': + response = self.vision_client.document_text_detection( + image=vision_image, + image_context=image_context + ) + else: + response = self.vision_client.text_detection( + image=vision_image, + image_context=image_context + ) + + if response.error.message: + self._log(f"โš ๏ธ Region {i} error: {response.error.message}", "warning") + return None + + # Extract text from this region + region_text = response.full_text_annotation.text if response.full_text_annotation else "" + if region_text.strip(): + # Clean the text + region_text = self._fix_encoding_issues(region_text) + region_text = self._sanitize_unicode_characters(region_text) + region_text = region_text.strip() + + # Create TextRegion with original image coordinates + region = TextRegion( + text=region_text, + vertices=[(x, y), (x+w, y), (x+w, y+h), (x, y+h)], + bounding_box=(x, y, w, h), + confidence=0.9, # RT-DETR confidence + region_type='text_block' + ) + # Assign bubble_type from RT-DETR detection + region.bubble_type = region_types.get(region_idx, 'text_bubble') + if not getattr(self, 'concise_logs', False): + self._log(f"โœ… Region {i}/{len(all_regions)} ({region.bubble_type}): {region_text[:50]}...") + return region + return None + + except Exception as e: + # Provide more detailed error info for debugging + error_msg = str(e) + if 'Bad Request' in error_msg or 'invalid' in error_msg.lower(): + self._log(f"โญ๏ธ Skipping region {i}: Too small or invalid for Google Vision (dimensions < 10x10px or area < 100pxยฒ)", "debug") + else: + self._log(f"โš ๏ธ Error OCR-ing region {i}: {e}", "warning") + return None + + # Process regions concurrently with RT-DETR concurrency control + from concurrent.futures import ThreadPoolExecutor, as_completed + # Use rtdetr_max_concurrency setting (default 12) to control parallel OCR calls + max_workers = min(ocr_settings.get('rtdetr_max_concurrency', 12), len(all_regions)) + + region_data_list = [(i+1, i, x, y, w, h) for i, (x, y, w, h) in enumerate(all_regions)] + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {executor.submit(ocr_region_google, rd): rd for rd in region_data_list} + for future in as_completed(futures): + try: + result = future.result() + if result: + regions.append(result) + finally: + # Clean up future to free memory + del future + + # If we got results, sort and post-process + if regions: + # CRITICAL: Sort regions by position (top-to-bottom, left-to-right) + # Concurrent processing returns them in completion order, not detection order + regions.sort(key=lambda r: (r.bounding_box[1], r.bounding_box[0])) + self._log(f"โœ… RT-DETR + Google Vision: {len(regions)} text regions detected (sorted by position)") + + # POST-PROCESS: Check for text_bubbles that overlap with free_text regions + # If a text_bubble's center is within a free_text bbox, reclassify it as free_text + free_text_bboxes = rtdetr_detections.get('text_free', []) + if free_text_bboxes: + reclassified_count = 0 + for region in regions: + if getattr(region, 'bubble_type', None) == 'text_bubble': + # Get region center + x, y, w, h = region.bounding_box + cx = x + w / 2 + cy = y + h / 2 + + self._log(f" Checking text_bubble '{region.text[:30]}...' at center ({cx:.0f}, {cy:.0f})", "debug") + + # Check if center is in any free_text bbox + for bbox_idx, (fx, fy, fw, fh) in enumerate(free_text_bboxes): + in_x = fx <= cx <= fx + fw + in_y = fy <= cy <= fy + fh + self._log(f" vs free_text bbox {bbox_idx+1}: in_x={in_x}, in_y={in_y}", "debug") + + if in_x and in_y: + # Reclassify as free text + old_type = region.bubble_type + region.bubble_type = 'free_text' + reclassified_count += 1 + self._log(f" โœ… RECLASSIFIED '{region.text[:30]}...' from {old_type} to free_text", "info") + break + + if reclassified_count > 0: + self._log(f"๐Ÿ”„ Reclassified {reclassified_count} overlapping regions as free_text", "info") + + # MERGE: Combine free_text regions that are within the same free_text bbox + # Group free_text regions by which free_text bbox they belong to + free_text_groups = {} + other_regions = [] + + for region in regions: + if getattr(region, 'bubble_type', None) == 'free_text': + # Find which free_text bbox this region belongs to + x, y, w, h = region.bounding_box + cx = x + w / 2 + cy = y + h / 2 + + for bbox_idx, (fx, fy, fw, fh) in enumerate(free_text_bboxes): + if fx <= cx <= fx + fw and fy <= cy <= fy + fh: + if bbox_idx not in free_text_groups: + free_text_groups[bbox_idx] = [] + free_text_groups[bbox_idx].append(region) + break + else: + # Free text region not in any bbox (shouldn't happen, but handle it) + other_regions.append(region) + else: + other_regions.append(region) + + # Merge each group of free_text regions + merged_free_text = [] + for bbox_idx, group in free_text_groups.items(): + if len(group) > 1: + # Merge multiple free text regions in same bbox + merged_text = " ".join(r.text for r in group) + + min_x = min(r.bounding_box[0] for r in group) + min_y = min(r.bounding_box[1] for r in group) + max_x = max(r.bounding_box[0] + r.bounding_box[2] for r in group) + max_y = max(r.bounding_box[1] + r.bounding_box[3] for r in group) + + all_vertices = [] + for r in group: + if hasattr(r, 'vertices') and r.vertices: + all_vertices.extend(r.vertices) + + if not all_vertices: + all_vertices = [ + (min_x, min_y), + (max_x, min_y), + (max_x, max_y), + (min_x, max_y) + ] + + merged_region = TextRegion( + text=merged_text, + vertices=all_vertices, + bounding_box=(min_x, min_y, max_x - min_x, max_y - min_y), + confidence=0.95, + region_type='text_block' + ) + merged_region.bubble_type = 'free_text' + merged_region.should_inpaint = True + merged_free_text.append(merged_region) + self._log(f"๐Ÿ”€ Merged {len(group)} free_text regions into one: '{merged_text[:50]}...'", "debug") + else: + # Single region, keep as-is + merged_free_text.extend(group) + + # Combine all regions + regions = other_regions + merged_free_text + self._log(f"โœ… Final: {len(regions)} regions after reclassification and merging", "info") + + # Skip merging section and return directly + return regions + else: + self._log("โš ๏ธ No text found in RT-DETR regions, falling back to full-page OCR", "warning") + + # If bubble detection is enabled and batch variables suggest batching, do ROI-based batched OCR + try: + use_roi_locality = ocr_settings.get('bubble_detection_enabled', False) and ocr_settings.get('roi_locality_enabled', False) + # Determine OCR batching enable + if 'ocr_batch_enabled' in ocr_settings: + ocr_batch_enabled = bool(ocr_settings.get('ocr_batch_enabled')) + else: + ocr_batch_enabled = (os.getenv('BATCH_OCR', '0') == '1') or (os.getenv('BATCH_TRANSLATION', '0') == '1') or getattr(self, 'batch_mode', False) + # Determine OCR batch size + bs = int(ocr_settings.get('ocr_batch_size') or 0) + if bs <= 0: + bs = int(os.getenv('OCR_BATCH_SIZE', '0') or 0) + if bs <= 0: + bs = int(os.getenv('BATCH_SIZE', str(getattr(self, 'batch_size', 1))) or 1) + ocr_batch_size = max(1, bs) + except Exception: + use_roi_locality = False + ocr_batch_enabled = False + ocr_batch_size = 1 + if use_roi_locality and (ocr_batch_enabled or ocr_batch_size > 1): + rois = self._prepare_ocr_rois_from_bubbles(image_path, ocr_settings, preprocessing, page_hash) + if rois: + # Determine concurrency for Google: OCR_MAX_CONCURRENCY env or min(BATCH_SIZE,2) + try: + max_cc = int(ocr_settings.get('ocr_max_concurrency') or 0) + if max_cc <= 0: + max_cc = int(os.getenv('OCR_MAX_CONCURRENCY', '0') or 0) + if max_cc <= 0: + max_cc = min(max(1, ocr_batch_size), 2) + except Exception: + max_cc = min(max(1, ocr_batch_size), 2) + regions = self._google_ocr_rois_batched(rois, ocr_settings, max(1, ocr_batch_size), max_cc, page_hash) + self._log(f"โœ… Google OCR batched over {len(rois)} ROIs โ†’ {len(regions)} regions (cc={max_cc})", "info") + + # Force garbage collection after concurrent OCR to reduce memory spikes + try: + import gc + gc.collect() + except Exception: + pass + + return regions + + # Start local inpainter preload while Google OCR runs (background; multiple if panel-parallel) + try: + if not getattr(self, 'skip_inpainting', False) and not getattr(self, 'use_cloud_inpainting', False): + already_loaded, _lm = self._is_local_inpainter_loaded() + if not already_loaded: + import threading as _threading + local_method = (self.manga_settings.get('inpainting', {}) or {}).get('local_method', 'anime') + model_path = self.main_gui.config.get(f'manga_{local_method}_model_path', '') if hasattr(self, 'main_gui') else '' + adv = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) if hasattr(self, 'main_gui') else {} + # Determine desired instances from panel-parallel settings + desired = 1 + if adv.get('parallel_panel_translation', False): + try: + desired = max(1, int(adv.get('panel_max_workers', 2))) + except Exception: + desired = 2 + # Honor advanced toggle for panel-local preload; for non-panel (desired==1) always allow + allow = True if desired == 1 else bool(adv.get('preload_local_inpainting_for_panels', True)) + if allow: + self._inpaint_preload_event = _threading.Event() + def _preload_inp_many(): + try: + self.preload_local_inpainters_concurrent(local_method, model_path, desired) + finally: + try: + self._inpaint_preload_event.set() + except Exception: + pass + _threading.Thread(target=_preload_inp_many, name="InpaintPreload@GoogleOCR", daemon=True).start() + except Exception: + pass + + # Create Vision API image object (full-page fallback) + image = vision.Image(content=processed_image_data) + + # Build image context with all parameters + image_context = vision.ImageContext( + language_hints=ocr_settings.get('language_hints', ['ja', 'ko', 'zh']) + ) + + # Add text detection params if available in your API version + if hasattr(vision, 'TextDetectionParams'): + image_context.text_detection_params = vision.TextDetectionParams( + enable_text_detection_confidence_score=True + ) + + # Configure text detection based on settings + detection_mode = ocr_settings.get('text_detection_mode', 'document') + + if detection_mode == 'document': + response = self.vision_client.document_text_detection( + image=image, + image_context=image_context + ) + else: + response = self.vision_client.text_detection( + image=image, + image_context=image_context + ) + + if response.error.message: + raise Exception(f"Cloud Vision API error: {response.error.message}") + + # Process each page (usually just one for manga) + for page in response.full_text_annotation.pages: + for block in page.blocks: + # Extract text first to check if it's worth processing + block_text = "" + total_confidence = 0.0 + word_count = 0 + + for paragraph in block.paragraphs: + for word in paragraph.words: + # Get word-level confidence (more reliable than block level) + word_confidence = getattr(word, 'confidence', 0.0) # Default to 0 if not available + word_text = ''.join([symbol.text for symbol in word.symbols]) + + # Only include words above threshold + if word_confidence >= confidence_threshold: + block_text += word_text + " " + total_confidence += word_confidence + word_count += 1 + else: + if not getattr(self, 'concise_logs', False): + self._log(f" Skipping low confidence word ({word_confidence:.2f}): {word_text}") + + block_text = block_text.strip() + + # CLEAN ORIGINAL OCR TEXT - Fix cube characters and encoding issues + original_text = block_text + block_text = self._fix_encoding_issues(block_text) + block_text = self._sanitize_unicode_characters(block_text) + + # Log cleaning if changes were made + if block_text != original_text: + self._log(f"๐Ÿงน Cleaned OCR text: '{original_text[:30]}...' โ†’ '{block_text[:30]}...'", "debug") + + # TEXT FILTERING SECTION + # Skip if text is too short (after cleaning) + if len(block_text.strip()) < min_text_length: + if not getattr(self, 'concise_logs', False): + self._log(f" Skipping short text ({len(block_text)} chars): {block_text}") + continue + + # Skip if primarily English and exclude_english is enabled + if exclude_english and self._is_primarily_english(block_text): + if not getattr(self, 'concise_logs', False): + self._log(f" Skipping English text: {block_text[:50]}...") + continue + + # Skip if no confident words found + if word_count == 0 or not block_text: + if not getattr(self, 'concise_logs', False): + self._log(f" Skipping block - no words above threshold {confidence_threshold}") + continue + + # Calculate average confidence for the block + avg_confidence = total_confidence / word_count if word_count > 0 else 0.0 + + # Extract vertices and create region + vertices = [(v.x, v.y) for v in block.bounding_box.vertices] + + # Calculate bounding box + xs = [v[0] for v in vertices] + ys = [v[1] for v in vertices] + x_min, x_max = min(xs), max(xs) + y_min, y_max = min(ys), max(ys) + + region = TextRegion( + text=block_text, + vertices=vertices, + bounding_box=(x_min, y_min, x_max - x_min, y_max - y_min), + confidence=avg_confidence, # Use average confidence + region_type='text_block' + ) + regions.append(region) + if not getattr(self, 'concise_logs', False): + self._log(f" Found text region ({avg_confidence:.2f}): {block_text[:50]}...") + + elif self.ocr_provider == 'azure': + # === AZURE COMPUTER VISION === + # Ensure client exists (it might have been cleaned up between runs) + try: + self._ensure_azure_client() + except Exception: + pass + import io + import time + from azure.cognitiveservices.vision.computervision.models import OperationStatusCodes + + # Check if we should use RT-DETR for text region detection (NEW FEATURE) + if ocr_settings.get('bubble_detection_enabled', False) and ocr_settings.get('use_rtdetr_for_ocr_regions', True): + self._log("๐ŸŽฏ Using RT-DETR to guide Azure Computer Vision OCR") + + # Run RT-DETR to detect text regions first + _ = self._get_thread_bubble_detector() + rtdetr_detections = self._load_bubble_detector(ocr_settings, image_path) + + if rtdetr_detections: + # Collect all text-containing regions WITH TYPE TRACKING + all_regions = [] + # Track region type to assign bubble_type later + region_types = {} + idx = 0 + if 'text_bubbles' in rtdetr_detections: + for bbox in rtdetr_detections.get('text_bubbles', []): + all_regions.append(bbox) + region_types[idx] = 'text_bubble' + idx += 1 + if 'text_free' in rtdetr_detections: + for bbox in rtdetr_detections.get('text_free', []): + all_regions.append(bbox) + region_types[idx] = 'free_text' + idx += 1 + + if all_regions: + self._log(f"๐Ÿ“Š RT-DETR detected {len(all_regions)} text regions, OCR-ing each with Azure Vision") + + # Load image for cropping + import cv2 + cv_image = cv2.imread(image_path) + if cv_image is None: + self._log("โš ๏ธ Failed to load image, falling back to full-page OCR", "warning") + else: + ocr_results = [] + + # Get Azure settings + azure_reading_order = ocr_settings.get('azure_reading_order', 'natural') + azure_model_version = ocr_settings.get('azure_model_version', 'latest') + azure_max_wait = ocr_settings.get('azure_max_wait', 60) + azure_poll_interval = ocr_settings.get('azure_poll_interval', 1.0) + + # Define worker function for concurrent OCR + def ocr_region_azure(region_data): + i, region_idx, x, y, w, h = region_data + try: + # Crop region + cropped = self._safe_crop_region(cv_image, x, y, w, h) + if cropped is None: + return None + + # Validate and resize crop if needed (Azure Vision requires minimum dimensions) + h_crop, w_crop = cropped.shape[:2] + MIN_SIZE = 50 # Minimum dimension (Azure requirement) + MIN_AREA = 2500 # Minimum area (50x50) + + # Skip completely invalid/corrupted regions (0 or negative dimensions) + if h_crop <= 0 or w_crop <= 0: + self._log(f"โš ๏ธ Region {i} has invalid dimensions ({w_crop}x{h_crop}px), skipping", "debug") + return None + + if h_crop < MIN_SIZE or w_crop < MIN_SIZE or h_crop * w_crop < MIN_AREA: + # Region too small - resize it + scale_w = MIN_SIZE / w_crop if w_crop < MIN_SIZE else 1.0 + scale_h = MIN_SIZE / h_crop if h_crop < MIN_SIZE else 1.0 + scale = max(scale_w, scale_h) + + if scale > 1.0: + new_w = int(w_crop * scale) + new_h = int(h_crop * scale) + cropped = cv2.resize(cropped, (new_w, new_h), interpolation=cv2.INTER_CUBIC) + self._log(f"๐Ÿ” Region {i} resized from {w_crop}x{h_crop}px to {new_w}x{new_h}px for Azure OCR", "debug") + h_crop, w_crop = new_h, new_w + + # RATE LIMITING: Add delay between Azure API calls to avoid "Too Many Requests" + # Azure Free tier: 20 calls/minute = 1 call per 3 seconds + # Azure Standard tier: Higher limits but still needs throttling + import time + import random + # Stagger requests with randomized delay (0.1-0.3 seconds) + time.sleep(0.1 + random.random() * 0.2) # 0.1-0.3s random delay + + # Encode cropped image + _, encoded = cv2.imencode('.jpg', cropped, [cv2.IMWRITE_JPEG_QUALITY, 95]) + region_image_bytes = encoded.tobytes() + + # Call Azure Read API + read_response = self.vision_client.read_in_stream( + io.BytesIO(region_image_bytes), + language=ocr_settings.get('language_hints', ['ja'])[0] if ocr_settings.get('language_hints') else 'ja', + model_version=azure_model_version, + reading_order=azure_reading_order, + raw=True + ) + + # Get operation location + operation_location = read_response.headers['Operation-Location'] + operation_id = operation_location.split('/')[-1] + + # Poll for result + start_time = time.time() + while True: + result = self.vision_client.get_read_result(operation_id) + if result.status not in [OperationStatusCodes.not_started, OperationStatusCodes.running]: + break + if time.time() - start_time > azure_max_wait: + self._log(f"โš ๏ธ Azure timeout for region {i}", "warning") + break + time.sleep(azure_poll_interval) + + if result.status == OperationStatusCodes.succeeded: + # Extract text from result + region_text = "" + for text_result in result.analyze_result.read_results: + for line in text_result.lines: + region_text += line.text + "\n" + + region_text = region_text.strip() + if region_text: + # Clean the text + region_text = self._fix_encoding_issues(region_text) + region_text = self._sanitize_unicode_characters(region_text) + + # Create TextRegion with original image coordinates + region = TextRegion( + text=region_text, + vertices=[(x, y), (x+w, y), (x+w, y+h), (x, y+h)], + bounding_box=(x, y, w, h), + confidence=0.9, # RT-DETR confidence + region_type='text_block' + ) + # Assign bubble_type from RT-DETR detection + region.bubble_type = region_types.get(region_idx, 'text_bubble') + if not getattr(self, 'concise_logs', False): + self._log(f"โœ… Region {i}/{len(all_regions)} ({region.bubble_type}): {region_text[:50]}...") + return region + return None + + except Exception as e: + # Provide more detailed error info for debugging + error_msg = str(e) + if 'Bad Request' in error_msg or 'invalid' in error_msg.lower() or 'Too Many Requests' in error_msg: + if 'Too Many Requests' in error_msg: + self._log(f"โธ๏ธ Region {i}: Azure rate limit hit, consider increasing delays", "warning") + else: + self._log(f"โญ๏ธ Skipping region {i}: Too small or invalid for Azure Vision", "debug") + else: + self._log(f"โš ๏ธ Error OCR-ing region {i}: {e}", "warning") + return None + + # Process regions concurrently with RT-DETR concurrency control + from concurrent.futures import ThreadPoolExecutor, as_completed + # Use rtdetr_max_concurrency setting (default 12) + # Note: Rate limiting is handled via 0.1-0.3s delays per request + max_workers = min(ocr_settings.get('rtdetr_max_concurrency', 12), len(all_regions)) + + region_data_list = [(i+1, i, x, y, w, h) for i, (x, y, w, h) in enumerate(all_regions)] + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {executor.submit(ocr_region_azure, rd): rd for rd in region_data_list} + for future in as_completed(futures): + try: + result = future.result() + if result: + regions.append(result) + finally: + # Clean up future to free memory + del future + + # If we got results, sort and post-process + if regions: + # CRITICAL: Sort regions by position (top-to-bottom, left-to-right) + # Concurrent processing returns them in completion order, not detection order + regions.sort(key=lambda r: (r.bounding_box[1], r.bounding_box[0])) + self._log(f"โœ… RT-DETR + Azure Vision: {len(regions)} text regions detected (sorted by position)") + + # POST-PROCESS: Check for text_bubbles that overlap with free_text regions + # If a text_bubble's center is within a free_text bbox, reclassify it as free_text + free_text_bboxes = rtdetr_detections.get('text_free', []) + + # DEBUG: Log what we have + self._log(f"๐Ÿ” POST-PROCESS: Found {len(free_text_bboxes)} free_text bboxes from RT-DETR", "debug") + for idx, (fx, fy, fw, fh) in enumerate(free_text_bboxes): + self._log(f" Free text bbox {idx+1}: x={fx:.0f}, y={fy:.0f}, w={fw:.0f}, h={fh:.0f}", "debug") + + text_bubble_count = sum(1 for r in regions if getattr(r, 'bubble_type', None) == 'text_bubble') + free_text_count = sum(1 for r in regions if getattr(r, 'bubble_type', None) == 'free_text') + self._log(f"๐Ÿ” Before reclassification: {text_bubble_count} text_bubbles, {free_text_count} free_text", "debug") + + if free_text_bboxes: + reclassified_count = 0 + for region in regions: + if getattr(region, 'bubble_type', None) == 'text_bubble': + # Get region center + x, y, w, h = region.bounding_box + cx = x + w / 2 + cy = y + h / 2 + + self._log(f" Checking text_bubble '{region.text[:30]}...' at center ({cx:.0f}, {cy:.0f})", "debug") + + # Check if center is in any free_text bbox + for bbox_idx, (fx, fy, fw, fh) in enumerate(free_text_bboxes): + in_x = fx <= cx <= fx + fw + in_y = fy <= cy <= fy + fh + self._log(f" vs free_text bbox {bbox_idx+1}: in_x={in_x}, in_y={in_y}", "debug") + + if in_x and in_y: + # Reclassify as free text + old_type = region.bubble_type + region.bubble_type = 'free_text' + reclassified_count += 1 + self._log(f" โœ… RECLASSIFIED '{region.text[:30]}...' from {old_type} to free_text", "info") + break + + if reclassified_count > 0: + self._log(f"๐Ÿ”„ Reclassified {reclassified_count} overlapping regions as free_text", "info") + + # MERGE: Combine free_text regions that are within the same free_text bbox + # Group free_text regions by which free_text bbox they belong to + free_text_groups = {} + other_regions = [] + + for region in regions: + if getattr(region, 'bubble_type', None) == 'free_text': + # Find which free_text bbox this region belongs to + x, y, w, h = region.bounding_box + cx = x + w / 2 + cy = y + h / 2 + + for bbox_idx, (fx, fy, fw, fh) in enumerate(free_text_bboxes): + if fx <= cx <= fx + fw and fy <= cy <= fy + fh: + if bbox_idx not in free_text_groups: + free_text_groups[bbox_idx] = [] + free_text_groups[bbox_idx].append(region) + break + else: + # Free text region not in any bbox (shouldn't happen, but handle it) + other_regions.append(region) + else: + other_regions.append(region) + + # Merge each group of free_text regions + merged_free_text = [] + for bbox_idx, group in free_text_groups.items(): + if len(group) > 1: + # Merge multiple free text regions in same bbox + merged_text = " ".join(r.text for r in group) + + min_x = min(r.bounding_box[0] for r in group) + min_y = min(r.bounding_box[1] for r in group) + max_x = max(r.bounding_box[0] + r.bounding_box[2] for r in group) + max_y = max(r.bounding_box[1] + r.bounding_box[3] for r in group) + + all_vertices = [] + for r in group: + if hasattr(r, 'vertices') and r.vertices: + all_vertices.extend(r.vertices) + + if not all_vertices: + all_vertices = [ + (min_x, min_y), + (max_x, min_y), + (max_x, max_y), + (min_x, max_y) + ] + + merged_region = TextRegion( + text=merged_text, + vertices=all_vertices, + bounding_box=(min_x, min_y, max_x - min_x, max_y - min_y), + confidence=0.95, + region_type='text_block' + ) + merged_region.bubble_type = 'free_text' + merged_region.should_inpaint = True + merged_free_text.append(merged_region) + self._log(f"๐Ÿ”€ Merged {len(group)} free_text regions into one: '{merged_text[:50]}...'", "debug") + else: + # Single region, keep as-is + merged_free_text.extend(group) + + # Combine all regions + regions = other_regions + merged_free_text + self._log(f"โœ… Final: {len(regions)} regions after reclassification and merging", "info") + + # Skip merging section and return directly + return regions + else: + self._log("โš ๏ธ No text found in RT-DETR regions, falling back to full-page OCR", "warning") + + # ROI-based concurrent OCR when bubble detection is enabled and batching is requested + try: + use_roi_locality = ocr_settings.get('bubble_detection_enabled', False) and ocr_settings.get('roi_locality_enabled', False) + if 'ocr_batch_enabled' in ocr_settings: + ocr_batch_enabled = bool(ocr_settings.get('ocr_batch_enabled')) + else: + ocr_batch_enabled = (os.getenv('BATCH_OCR', '0') == '1') or (os.getenv('BATCH_TRANSLATION', '0') == '1') or getattr(self, 'batch_mode', False) + bs = int(ocr_settings.get('ocr_batch_size') or 0) + if bs <= 0: + bs = int(os.getenv('OCR_BATCH_SIZE', '0') or 0) + if bs <= 0: + bs = int(os.getenv('BATCH_SIZE', str(getattr(self, 'batch_size', 1))) or 1) + ocr_batch_size = max(1, bs) + except Exception: + use_roi_locality = False + ocr_batch_enabled = False + ocr_batch_size = 1 + if use_roi_locality and (ocr_batch_enabled or ocr_batch_size > 1): + rois = self._prepare_ocr_rois_from_bubbles(image_path, ocr_settings, preprocessing, page_hash) + if rois: + # AZURE RATE LIMITING: Force low concurrency to prevent "Too Many Requests" + # Azure has strict rate limits that vary by tier: + # - Free tier: 20 requests/minute + # - Standard tier: Higher but still limited + try: + azure_workers = int(ocr_settings.get('ocr_max_concurrency') or 0) + if azure_workers <= 0: + azure_workers = 1 # Force sequential by default + else: + azure_workers = min(2, max(1, azure_workers)) # Cap at 2 max + except Exception: + azure_workers = 1 # Safe default + regions = self._azure_ocr_rois_concurrent(rois, ocr_settings, azure_workers, page_hash) + self._log(f"โœ… Azure OCR concurrent over {len(rois)} ROIs โ†’ {len(regions)} regions (workers={azure_workers})", "info") + + # Force garbage collection after concurrent OCR to reduce memory spikes + try: + import gc + gc.collect() + except Exception: + pass + + return regions + + # Start local inpainter preload while Azure OCR runs (background; multiple if panel-parallel) + try: + if not getattr(self, 'skip_inpainting', False) and not getattr(self, 'use_cloud_inpainting', False): + already_loaded, _lm = self._is_local_inpainter_loaded() + if not already_loaded: + import threading as _threading + local_method = (self.manga_settings.get('inpainting', {}) or {}).get('local_method', 'anime') + model_path = self.main_gui.config.get(f'manga_{local_method}_model_path', '') if hasattr(self, 'main_gui') else '' + adv = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) if hasattr(self, 'main_gui') else {} + desired = 1 + if adv.get('parallel_panel_translation', False): + try: + desired = max(1, int(adv.get('panel_max_workers', 2))) + except Exception: + desired = 2 + allow = True if desired == 1 else bool(adv.get('preload_local_inpainting_for_panels', True)) + if allow: + self._inpaint_preload_event = _threading.Event() + def _preload_inp_many(): + try: + self.preload_local_inpainters_concurrent(local_method, model_path, desired) + finally: + try: + self._inpaint_preload_event.set() + except Exception: + pass + _threading.Thread(target=_preload_inp_many, name="InpaintPreload@AzureOCR", daemon=True).start() + except Exception: + pass + + # Ensure Azure-supported format for the BYTES we are sending. + # If compression is enabled and produced an Azure-supported format (JPEG/PNG/BMP/TIFF), + # DO NOT force-convert to PNG. Only convert when the current bytes are in an unsupported format. + file_ext = os.path.splitext(image_path)[1].lower() + azure_supported_exts = ['.jpg', '.jpeg', '.png', '.bmp', '.pdf', '.tiff'] + azure_supported_fmts = ['jpeg', 'jpg', 'png', 'bmp', 'tiff'] + + # Probe the actual byte format we will upload + try: + from PIL import Image as _PILImage + img_probe = _PILImage.open(io.BytesIO(processed_image_data)) + fmt = (img_probe.format or '').lower() + except Exception: + fmt = '' + + # If original is a PDF, allow as-is (Azure supports PDF streams) + if file_ext == '.pdf': + needs_convert = False + else: + # Decide based on the detected format of the processed bytes + needs_convert = fmt not in azure_supported_fmts + + if needs_convert: + # If compression settings are enabled and target format is Azure-supported, prefer that + try: + comp_cfg = (self.main_gui.config.get('manga_settings', {}) or {}).get('compression', {}) + except Exception: + comp_cfg = {} + + # Determine if conversion is actually needed based on compression and current format + try: + from PIL import Image as _PILImage + img2 = _PILImage.open(io.BytesIO(processed_image_data)) + fmt_lower = (img2.format or '').lower() + except Exception: + img2 = None + fmt_lower = '' + + accepted = {'jpeg', 'jpg', 'png', 'bmp', 'tiff'} + convert_needed = False + target_fmt = None + + if comp_cfg.get('enabled', False): + cf = str(comp_cfg.get('format', '')).lower() + desired = None + if cf in ('jpeg', 'jpg'): + desired = 'JPEG' + elif cf == 'png': + desired = 'PNG' + elif cf == 'bmp': + desired = 'BMP' + elif cf == 'tiff': + desired = 'TIFF' + # If WEBP or others, desired remains None and we fall back to PNG only if unsupported + + if desired is not None: + # Skip conversion if already in the desired supported format + already_matches = ((fmt_lower in ('jpeg', 'jpg') and desired == 'JPEG') or (fmt_lower == desired.lower())) + if not already_matches: + convert_needed = True + target_fmt = desired + else: + # Compression format not supported by Azure (e.g., WEBP); convert only if unsupported + if fmt_lower not in accepted: + convert_needed = True + target_fmt = 'PNG' + else: + # No compression preference; convert only if unsupported by Azure + if fmt_lower not in accepted: + convert_needed = True + target_fmt = 'PNG' + + if convert_needed: + self._log(f"โš ๏ธ Converting image to {target_fmt} for Azure compatibility") + try: + if img2 is None: + from PIL import Image as _PILImage + img2 = _PILImage.open(io.BytesIO(processed_image_data)) + buffer = io.BytesIO() + if target_fmt == 'JPEG' and img2.mode != 'RGB': + img2 = img2.convert('RGB') + img2.save(buffer, format=target_fmt) + processed_image_data = buffer.getvalue() + except Exception: + pass + + # Create stream from image data + image_stream = io.BytesIO(processed_image_data) + + # Get Azure-specific settings + reading_order = ocr_settings.get('azure_reading_order', 'natural') + model_version = ocr_settings.get('azure_model_version', 'latest') + max_wait = ocr_settings.get('azure_max_wait', 60) + poll_interval = ocr_settings.get('azure_poll_interval', 0.5) + + # Map language hints to Azure language codes + language_hints = ocr_settings.get('language_hints', ['ja', 'ko', 'zh']) + + # Build parameters dictionary + read_params = { + 'raw': True, + 'readingOrder': reading_order + } + + # Add model version if not using latest + if model_version != 'latest': + read_params['model-version'] = model_version + + # Use language parameter only if single language is selected + if len(language_hints) == 1: + azure_lang = language_hints[0] + # Map to Azure language codes + lang_mapping = { + 'zh': 'zh-Hans', + 'zh-TW': 'zh-Hant', + 'zh-CN': 'zh-Hans', + 'ja': 'ja', + 'ko': 'ko', + 'en': 'en' + } + azure_lang = lang_mapping.get(azure_lang, azure_lang) + read_params['language'] = azure_lang + self._log(f" Using Azure Read API with language: {azure_lang}, order: {reading_order}") + else: + self._log(f" Using Azure Read API (auto-detect for {len(language_hints)} languages, order: {reading_order})") + + # Start Read operation with error handling and rate limit retry + # Use max_retries from config (default 7, configurable in Other Settings) + max_retries = self.main_gui.config.get('max_retries', 7) + retry_delay = 60 # Start with 60 seconds for rate limits + read_response = None + + for retry_attempt in range(max_retries): + try: + # Ensure client is alive before starting + if getattr(self, 'vision_client', None) is None: + self._log("โš ๏ธ Azure client missing before read; reinitializing...", "warning") + self._ensure_azure_client() + if getattr(self, 'vision_client', None) is None: + raise RuntimeError("Azure Computer Vision client is not initialized. Check your key/endpoint and azure-cognitiveservices-vision-computervision installation.") + + # Reset stream position for retry + image_stream.seek(0) + + read_response = self.vision_client.read_in_stream( + image_stream, + **read_params + ) + # Success! Break out of retry loop + break + + except Exception as e: + error_msg = str(e) + + # Handle rate limit errors with fixed 60s wait + if 'Too Many Requests' in error_msg or '429' in error_msg: + if retry_attempt < max_retries - 1: + wait_time = retry_delay # Fixed 60s wait each time + self._log(f"โš ๏ธ Azure rate limit hit. Waiting {wait_time}s before retry {retry_attempt + 1}/{max_retries}...", "warning") + time.sleep(wait_time) + continue + else: + self._log(f"โŒ Azure rate limit: Exhausted {max_retries} retries", "error") + raise + + # Handle bad request errors + elif 'Bad Request' in error_msg: + self._log("โš ๏ธ Azure Read API Bad Request - likely invalid image format or too small. Retrying without language parameter...", "warning") + # Retry without language parameter + image_stream.seek(0) + read_params.pop('language', None) + if getattr(self, 'vision_client', None) is None: + self._ensure_azure_client() + read_response = self.vision_client.read_in_stream( + image_stream, + **read_params + ) + break + else: + raise + + if read_response is None: + raise RuntimeError("Failed to get response from Azure Read API after retries") + + # Get operation ID + operation_location = read_response.headers.get("Operation-Location") if hasattr(read_response, 'headers') else None + if not operation_location: + raise RuntimeError("Azure Read API did not return Operation-Location header") + operation_id = operation_location.split("/")[-1] + + # Poll for results with configurable timeout + self._log(f" Waiting for Azure OCR to complete (max {max_wait}s)...") + wait_time = 0 + last_status = None + result = None + + while wait_time < max_wait: + try: + if getattr(self, 'vision_client', None) is None: + # Client got cleaned up mid-poll; reinitialize and continue + self._log("โš ๏ธ Azure client became None during polling; reinitializing...", "warning") + self._ensure_azure_client() + if getattr(self, 'vision_client', None) is None: + raise AttributeError("Azure client lost and could not be reinitialized") + result = self.vision_client.get_read_result(operation_id) + except AttributeError as e: + # Defensive: reinitialize once and retry this iteration + self._log(f"โš ๏ธ {e} โ€” reinitializing Azure client and retrying once", "warning") + self._ensure_azure_client() + if getattr(self, 'vision_client', None) is None: + raise + result = self.vision_client.get_read_result(operation_id) + + # Log status changes + if result.status != last_status: + self._log(f" Status: {result.status}") + last_status = result.status + + if result.status not in [OperationStatusCodes.running, OperationStatusCodes.not_started]: + break + + time.sleep(poll_interval) + self._log("๐Ÿ’ค Azure OCR polling pausing briefly for stability", "debug") + wait_time += poll_interval + + if not result: + raise RuntimeError("Azure Read API polling did not return a result") + if result.status == OperationStatusCodes.succeeded: + # Track statistics + total_lines = 0 + handwritten_lines = 0 + + for page_num, page in enumerate(result.analyze_result.read_results): + if len(result.analyze_result.read_results) > 1: + self._log(f" Processing page {page_num + 1}/{len(result.analyze_result.read_results)}") + + for line in page.lines: + # CLEAN ORIGINAL OCR TEXT FOR AZURE - Fix cube characters and encoding issues + original_azure_text = line.text + cleaned_line_text = self._fix_encoding_issues(line.text) + cleaned_line_text = self._sanitize_unicode_characters(cleaned_line_text) + + # Log cleaning if changes were made + if cleaned_line_text != original_azure_text: + self._log(f"๐Ÿงน Cleaned Azure OCR text: '{original_azure_text[:30]}...' โ†’ '{cleaned_line_text[:30]}...'", "debug") + + # TEXT FILTERING FOR AZURE + # Skip if text is too short (after cleaning) + if len(cleaned_line_text.strip()) < min_text_length: + if not getattr(self, 'concise_logs', False): + self._log(f" Skipping short text ({len(cleaned_line_text)} chars): {cleaned_line_text}") + continue + + # Skip if primarily English and exclude_english is enabled (use cleaned text) + if exclude_english and self._is_primarily_english(cleaned_line_text): + if not getattr(self, 'concise_logs', False): + self._log(f" Skipping English text: {cleaned_line_text[:50]}...") + continue + + # Azure provides 8-point bounding box + bbox = line.bounding_box + vertices = [ + (bbox[0], bbox[1]), + (bbox[2], bbox[3]), + (bbox[4], bbox[5]), + (bbox[6], bbox[7]) + ] + + # Calculate rectangular bounding box + xs = [v[0] for v in vertices] + ys = [v[1] for v in vertices] + x_min, x_max = min(xs), max(xs) + y_min, y_max = min(ys), max(ys) + + # Calculate confidence from word-level data + confidence = 0.95 # Default high confidence + + if hasattr(line, 'words') and line.words: + # Calculate average confidence from words + confidences = [] + for word in line.words: + if hasattr(word, 'confidence'): + confidences.append(word.confidence) + + if confidences: + confidence = sum(confidences) / len(confidences) + if not getattr(self, 'concise_logs', False): + self._log(f" Line has {len(line.words)} words, avg confidence: {confidence:.3f}") + + # Check for handwriting style (if available) + style = 'print' # Default + style_confidence = None + + if hasattr(line, 'appearance') and line.appearance: + if hasattr(line.appearance, 'style'): + style_info = line.appearance.style + if hasattr(style_info, 'name'): + style = style_info.name + if style == 'handwriting': + handwritten_lines += 1 + if hasattr(style_info, 'confidence'): + style_confidence = style_info.confidence + if not getattr(self, 'concise_logs', False): + self._log(f" Style: {style} (confidence: {style_confidence:.2f})") + + # Apply confidence threshold filtering + if confidence >= confidence_threshold: + region = TextRegion( + text=cleaned_line_text, # Use cleaned text instead of original + vertices=vertices, + bounding_box=(x_min, y_min, x_max - x_min, y_max - y_min), + confidence=confidence, + region_type='text_line' + ) + + # Add extra attributes for Azure-specific info + region.style = style + region.style_confidence = style_confidence + + regions.append(region) + total_lines += 1 + + # More detailed logging (use cleaned text) + if not getattr(self, 'concise_logs', False): + if style == 'handwriting': + self._log(f" Found handwritten text ({confidence:.2f}): {cleaned_line_text[:50]}...") + else: + self._log(f" Found text region ({confidence:.2f}): {cleaned_line_text[:50]}...") + else: + if not getattr(self, 'concise_logs', False): + self._log(f" Skipping low confidence text ({confidence:.2f}): {cleaned_line_text[:30]}...") + + # Log summary statistics + if total_lines > 0 and not getattr(self, 'concise_logs', False): + self._log(f" Total lines detected: {total_lines}") + if handwritten_lines > 0: + self._log(f" Handwritten lines: {handwritten_lines} ({handwritten_lines/total_lines*100:.1f}%)") + + elif result.status == OperationStatusCodes.failed: + # More detailed error handling + error_msg = "Azure OCR failed" + if hasattr(result, 'message'): + error_msg += f": {result.message}" + if hasattr(result.analyze_result, 'errors') and result.analyze_result.errors: + for error in result.analyze_result.errors: + self._log(f" Error: {error}", "error") + raise Exception(error_msg) + else: + # Timeout or other status + raise Exception(f"Azure OCR ended with status: {result.status} after {wait_time}s") + + else: + # === NEW OCR PROVIDERS === + import cv2 + import numpy as np + from ocr_manager import OCRManager + + # Load image as numpy array + if isinstance(processed_image_data, bytes): + # Convert bytes to numpy array + nparr = np.frombuffer(processed_image_data, np.uint8) + image = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + else: + # Load from file path + image = cv2.imread(image_path) + if image is None: + # Try with PIL for Unicode paths + from PIL import Image as PILImage + pil_image = PILImage.open(image_path) + image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) + + # Ensure OCR manager is available + if not hasattr(self, 'ocr_manager') or self.ocr_manager is None: + try: + # Prefer GUI-provided manager if available + if hasattr(self, 'main_gui') and hasattr(self.main_gui, 'ocr_manager') and self.main_gui.ocr_manager is not None: + self.ocr_manager = self.main_gui.ocr_manager + else: + from ocr_manager import OCRManager + self.ocr_manager = OCRManager(log_callback=self.log_callback) + self._log("Initialized internal OCRManager instance", "info") + except Exception as _e: + self.ocr_manager = None + self._log(f"Failed to initialize OCRManager: {str(_e)}", "error") + if self.ocr_manager is None: + raise RuntimeError("OCRManager is not available; cannot proceed with OCR provider.") + + # Check provider status and load if needed + provider_status = self.ocr_manager.check_provider_status(self.ocr_provider) + + if not provider_status['installed']: + self._log(f"โŒ {self.ocr_provider} is not installed", "error") + self._log(f" Please install it from the GUI settings", "error") + raise Exception(f"{self.ocr_provider} OCR provider is not installed") + + # Start local inpainter preload while provider is being readied/used (non-cloud path only; background) + try: + if not getattr(self, 'skip_inpainting', False) and not getattr(self, 'use_cloud_inpainting', False): + already_loaded, _lm = self._is_local_inpainter_loaded() + if not already_loaded: + import threading as _threading + local_method = (self.manga_settings.get('inpainting', {}) or {}).get('local_method', 'anime') + model_path = self.main_gui.config.get(f'manga_{local_method}_model_path', '') if hasattr(self, 'main_gui') else '' + adv = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) if hasattr(self, 'main_gui') else {} + desired = 1 + if adv.get('parallel_panel_translation', False): + try: + desired = max(1, int(adv.get('panel_max_workers', 2))) + except Exception: + desired = 2 + allow = True if desired == 1 else bool(adv.get('preload_local_inpainting_for_panels', True)) + if allow: + self._inpaint_preload_event = _threading.Event() + def _preload_inp_many(): + try: + self.preload_local_inpainters_concurrent(local_method, model_path, desired) + finally: + try: + self._inpaint_preload_event.set() + except Exception: + pass + _threading.Thread(target=_preload_inp_many, name="InpaintPreload@OCRProvider", daemon=True).start() + except Exception: + pass + + if not provider_status['loaded']: + # Check if Qwen2-VL - if it's supposedly not loaded but actually is, skip + if self.ocr_provider == 'Qwen2-VL': + provider = self.ocr_manager.get_provider('Qwen2-VL') + if provider and hasattr(provider, 'model') and provider.model is not None: + self._log("โœ… Qwen2-VL model actually already loaded, skipping reload") + success = True + else: + # Only actually load if truly not loaded + model_size = self.ocr_config.get('model_size', '2') if hasattr(self, 'ocr_config') else '2' + self._log(f"Loading Qwen2-VL with model_size={model_size}") + success = self.ocr_manager.load_provider(self.ocr_provider, model_size=model_size) + if not success: + raise Exception(f"Failed to load {self.ocr_provider} model") + elif self.ocr_provider == 'custom-api': + # Custom API needs to initialize UnifiedClient with credentials + self._log("๐Ÿ“ก Loading custom-api provider...") + # Try to get API key and model from GUI if available + load_kwargs = {} + if hasattr(self, 'main_gui'): + # Get API key from GUI + if hasattr(self.main_gui, 'api_key_entry'): + api_key = self.main_gui.api_key_entry.get() + if api_key: + load_kwargs['api_key'] = api_key + # Get model from GUI + if hasattr(self.main_gui, 'model_var'): + model = self.main_gui.model_var.get() + if model: + load_kwargs['model'] = model + success = self.ocr_manager.load_provider(self.ocr_provider, **load_kwargs) + if not success: + raise Exception(f"Failed to initialize {self.ocr_provider}") + else: + # Other providers + success = self.ocr_manager.load_provider(self.ocr_provider) + if not success: + raise Exception(f"Failed to load {self.ocr_provider} model") + + if not success: + raise Exception(f"Failed to load {self.ocr_provider} model") + + # Initialize ocr_results here before any provider-specific code + ocr_results = [] + + # Special handling for manga-ocr (needs region detection first) + if self.ocr_provider == 'manga-ocr': + # IMPORTANT: Initialize fresh results list + ocr_results = [] + + # Check if we should use bubble detection for regions + if ocr_settings.get('bubble_detection_enabled', False): + self._log("๐Ÿ“ Using bubble detection regions for manga-ocr...") + + # Run bubble detection to get regions + if self.bubble_detector is None: + from bubble_detector import BubbleDetector + self.bubble_detector = BubbleDetector() + + # Get regions from bubble detector + rtdetr_detections = self._load_bubble_detector(ocr_settings, image_path) + if rtdetr_detections: + + # Process detections immediately and don't store + all_regions = [] + + # ONLY ADD TEXT-CONTAINING REGIONS + # Skip empty bubbles since they shouldn't have text + if 'text_bubbles' in rtdetr_detections: + all_regions.extend(rtdetr_detections.get('text_bubbles', [])) + if 'text_free' in rtdetr_detections: + all_regions.extend(rtdetr_detections.get('text_free', [])) + + # DO NOT ADD empty bubbles - they're duplicates of text_bubbles + # if 'bubbles' in rtdetr_detections: # <-- REMOVE THIS + # all_regions.extend(rtdetr_detections.get('bubbles', [])) + + self._log(f"๐Ÿ“Š Processing {len(all_regions)} text-containing regions (skipping empty bubbles)") + + # Clear detection results after extracting regions + rtdetr_detections = None + + # Check if parallel processing is enabled + if self.parallel_processing and len(all_regions) > 1: + self._log(f"๐Ÿš€ Using PARALLEL OCR for {len(all_regions)} regions with manga-ocr") + ocr_results = self._parallel_ocr_regions(image, all_regions, 'manga-ocr', confidence_threshold) + else: + # Process each region with manga-ocr + for i, (x, y, w, h) in enumerate(all_regions): + cropped = self._safe_crop_region(image, x, y, w, h) + if cropped is None: + continue + result = self.ocr_manager.detect_text(cropped, 'manga-ocr', confidence=confidence_threshold) + if result and len(result) > 0 and result[0].text.strip(): + result[0].bbox = (x, y, w, h) + result[0].vertices = [(x, y), (x+w, y), (x+w, y+h), (x, y+h)] + # CRITICAL: Store RT-DETR bubble bounds for rendering + # The bbox/vertices are the small OCR polygon, but bubble_bounds is the full RT-DETR bubble + result[0].bubble_bounds = (x, y, w, h) + ocr_results.append(result[0]) + self._log(f"๐Ÿ” Processing region {i+1}/{len(all_regions)} with manga-ocr...") + self._log(f"โœ… Detected text: {result[0].text[:50]}...") + + # Clear regions list after processing + all_regions = None + else: + # NO bubble detection - just process full image + self._log("๐Ÿ“ Processing full image with manga-ocr (no bubble detection)") + ocr_results = self.ocr_manager.detect_text(image, self.ocr_provider, confidence=confidence_threshold) + + elif self.ocr_provider == 'Qwen2-VL': + # Initialize results list + ocr_results = [] + + # Configure Qwen2-VL for Korean text + language_hints = ocr_settings.get('language_hints', ['ko']) + self._log("๐Ÿฉ Qwen2-VL OCR for Korean text recognition") + + # Check if we should use bubble detection for regions + if ocr_settings.get('bubble_detection_enabled', False): + self._log("๐Ÿ“ Using bubble detection regions for Qwen2-VL...") + + # Run bubble detection to get regions (thread-local) + _ = self._get_thread_bubble_detector() + + # Get regions from bubble detector + rtdetr_detections = self._load_bubble_detector(ocr_settings, image_path) + if rtdetr_detections: + + # Process only text-containing regions + all_regions = [] + if 'text_bubbles' in rtdetr_detections: + all_regions.extend(rtdetr_detections.get('text_bubbles', [])) + if 'text_free' in rtdetr_detections: + all_regions.extend(rtdetr_detections.get('text_free', [])) + + self._log(f"๐Ÿ“Š Processing {len(all_regions)} text regions with Qwen2-VL") + + # Check if parallel processing is enabled + if self.parallel_processing and len(all_regions) > 1: + self._log(f"๐Ÿš€ Using PARALLEL OCR for {len(all_regions)} regions with Qwen2-VL") + ocr_results = self._parallel_ocr_regions(image, all_regions, 'Qwen2-VL', confidence_threshold) + else: + # Process each region with Qwen2-VL + for i, (x, y, w, h) in enumerate(all_regions): + cropped = self._safe_crop_region(image, x, y, w, h) + if cropped is None: + continue + result = self.ocr_manager.detect_text(cropped, 'Qwen2-VL', confidence=confidence_threshold) + if result and len(result) > 0 and result[0].text.strip(): + result[0].bbox = (x, y, w, h) + result[0].vertices = [(x, y), (x+w, y), (x+w, y+h), (x, y+h)] + ocr_results.append(result[0]) + self._log(f"โœ… Region {i+1}: {result[0].text[:50]}...") + else: + # Process full image without bubble detection + self._log("๐Ÿ“ Processing full image with Qwen2-VL") + ocr_results = self.ocr_manager.detect_text(image, self.ocr_provider) + + elif self.ocr_provider == 'custom-api': + # Initialize results list + ocr_results = [] + + # Configure Custom API for text extraction + self._log("๐Ÿ”Œ Using Custom API for OCR") + + # Check if we should use bubble detection for regions + if ocr_settings.get('bubble_detection_enabled', False): + self._log("๐Ÿ“ Using bubble detection regions for Custom API...") + + # Run bubble detection to get regions (thread-local) + _ = self._get_thread_bubble_detector() + + # Get regions from bubble detector + rtdetr_detections = self._load_bubble_detector(ocr_settings, image_path) + if rtdetr_detections: + + # Process only text-containing regions + all_regions = [] + if 'text_bubbles' in rtdetr_detections: + all_regions.extend(rtdetr_detections.get('text_bubbles', [])) + if 'text_free' in rtdetr_detections: + all_regions.extend(rtdetr_detections.get('text_free', [])) + + self._log(f"๐Ÿ“Š Processing {len(all_regions)} text regions with Custom API") + + # Clear detections after extracting regions + rtdetr_detections = None + + # Decide parallelization for custom-api: + # Use API batch mode OR local parallel toggle so that API calls can run in parallel + if (getattr(self, 'batch_mode', False) or self.parallel_processing) and len(all_regions) > 1: + self._log(f"๐Ÿš€ Using PARALLEL OCR for {len(all_regions)} regions (custom-api; API batch mode honored)") + ocr_results = self._parallel_ocr_regions(image, all_regions, 'custom-api', confidence_threshold) + else: + # Original sequential processing + for i, (x, y, w, h) in enumerate(all_regions): + cropped = self._safe_crop_region(image, x, y, w, h) + if cropped is None: + continue + result = self.ocr_manager.detect_text( + cropped, + 'custom-api', + confidence=confidence_threshold + ) + if result and len(result) > 0 and result[0].text.strip(): + result[0].bbox = (x, y, w, h) + result[0].vertices = [(x, y), (x+w, y), (x+w, y+h), (x, y+h)] + ocr_results.append(result[0]) + self._log(f"๐Ÿ” Region {i+1}/{len(all_regions)}: {result[0].text[:50]}...") + + # Clear regions list after processing + all_regions = None + else: + # Process full image without bubble detection + self._log("๐Ÿ“ Processing full image with Custom API") + ocr_results = self.ocr_manager.detect_text( + image, + 'custom-api', + confidence=confidence_threshold + ) + + elif self.ocr_provider == 'easyocr': + # Initialize results list + ocr_results = [] + + # Configure EasyOCR languages + language_hints = ocr_settings.get('language_hints', ['ja', 'en']) + validated_languages = self._validate_easyocr_languages(language_hints) + + easyocr_provider = self.ocr_manager.get_provider('easyocr') + if easyocr_provider: + if easyocr_provider.languages != validated_languages: + easyocr_provider.languages = validated_languages + easyocr_provider.is_loaded = False + self._log(f"๐Ÿ”ฅ Reloading EasyOCR with languages: {validated_languages}") + self.ocr_manager.load_provider('easyocr') + + # Check if we should use bubble detection + if ocr_settings.get('bubble_detection_enabled', False): + self._log("๐Ÿ“ Using bubble detection regions for EasyOCR...") + + # Run bubble detection to get regions (thread-local) + _ = self._get_thread_bubble_detector() + + # Get regions from bubble detector + rtdetr_detections = self._load_bubble_detector(ocr_settings, image_path) + if rtdetr_detections: + + # Process only text-containing regions + all_regions = [] + if 'text_bubbles' in rtdetr_detections: + all_regions.extend(rtdetr_detections.get('text_bubbles', [])) + if 'text_free' in rtdetr_detections: + all_regions.extend(rtdetr_detections.get('text_free', [])) + + self._log(f"๐Ÿ“Š Processing {len(all_regions)} text regions with EasyOCR") + + # Check if parallel processing is enabled + if self.parallel_processing and len(all_regions) > 1: + self._log(f"๐Ÿš€ Using PARALLEL OCR for {len(all_regions)} regions with EasyOCR") + ocr_results = self._parallel_ocr_regions(image, all_regions, 'easyocr', confidence_threshold) + else: + # Process each region with EasyOCR + for i, (x, y, w, h) in enumerate(all_regions): + cropped = self._safe_crop_region(image, x, y, w, h) + if cropped is None: + continue + result = self.ocr_manager.detect_text(cropped, 'easyocr', confidence=confidence_threshold) + if result and len(result) > 0 and result[0].text.strip(): + result[0].bbox = (x, y, w, h) + result[0].vertices = [(x, y), (x+w, y), (x+w, y+h), (x, y+h)] + ocr_results.append(result[0]) + self._log(f"โœ… Region {i+1}: {result[0].text[:50]}...") + else: + # Process full image without bubble detection + self._log("๐Ÿ“ Processing full image with EasyOCR") + ocr_results = self.ocr_manager.detect_text(image, self.ocr_provider) + + elif self.ocr_provider == 'paddleocr': + # Initialize results list + ocr_results = [] + + # Configure PaddleOCR language + language_hints = ocr_settings.get('language_hints', ['ja']) + lang_map = {'ja': 'japan', 'ko': 'korean', 'zh': 'ch', 'en': 'en'} + paddle_lang = lang_map.get(language_hints[0] if language_hints else 'ja', 'japan') + + # Reload if language changed + paddle_provider = self.ocr_manager.get_provider('paddleocr') + if paddle_provider and paddle_provider.is_loaded: + if hasattr(paddle_provider.model, 'lang') and paddle_provider.model.lang != paddle_lang: + from paddleocr import PaddleOCR + paddle_provider.model = PaddleOCR( + use_angle_cls=True, + lang=paddle_lang, + use_gpu=True, + show_log=False + ) + self._log(f"๐Ÿ”ฅ Reloaded PaddleOCR with language: {paddle_lang}") + + # Check if we should use bubble detection + if ocr_settings.get('bubble_detection_enabled', False): + self._log("๐Ÿ“ Using bubble detection regions for PaddleOCR...") + + # Run bubble detection to get regions (thread-local) + _ = self._get_thread_bubble_detector() + + # Get regions from bubble detector + rtdetr_detections = self._load_bubble_detector(ocr_settings, image_path) + if rtdetr_detections: + + # Process only text-containing regions + all_regions = [] + if 'text_bubbles' in rtdetr_detections: + all_regions.extend(rtdetr_detections.get('text_bubbles', [])) + if 'text_free' in rtdetr_detections: + all_regions.extend(rtdetr_detections.get('text_free', [])) + + self._log(f"๐Ÿ“Š Processing {len(all_regions)} text regions with PaddleOCR") + + # Check if parallel processing is enabled + if self.parallel_processing and len(all_regions) > 1: + self._log(f"๐Ÿš€ Using PARALLEL OCR for {len(all_regions)} regions with PaddleOCR") + ocr_results = self._parallel_ocr_regions(image, all_regions, 'paddleocr', confidence_threshold) + else: + # Process each region with PaddleOCR + for i, (x, y, w, h) in enumerate(all_regions): + cropped = self._safe_crop_region(image, x, y, w, h) + if cropped is None: + continue + result = self.ocr_manager.detect_text(cropped, 'paddleocr', confidence=confidence_threshold) + if result and len(result) > 0 and result[0].text.strip(): + result[0].bbox = (x, y, w, h) + result[0].vertices = [(x, y), (x+w, y), (x+w, y+h), (x, y+h)] + ocr_results.append(result[0]) + self._log(f"โœ… Region {i+1}: {result[0].text[:50]}...") + else: + # Process full image without bubble detection + self._log("๐Ÿ“ Processing full image with PaddleOCR") + ocr_results = self.ocr_manager.detect_text(image, self.ocr_provider) + + elif self.ocr_provider == 'doctr': + # Initialize results list + ocr_results = [] + + self._log("๐Ÿ“„ DocTR OCR for document text recognition") + + # Check if we should use bubble detection + if ocr_settings.get('bubble_detection_enabled', False): + self._log("๐Ÿ“ Using bubble detection regions for DocTR...") + + # Run bubble detection to get regions (thread-local) + _ = self._get_thread_bubble_detector() + + # Get regions from bubble detector + rtdetr_detections = self._load_bubble_detector(ocr_settings, image_path) + if rtdetr_detections: + + # Process only text-containing regions + all_regions = [] + if 'text_bubbles' in rtdetr_detections: + all_regions.extend(rtdetr_detections.get('text_bubbles', [])) + if 'text_free' in rtdetr_detections: + all_regions.extend(rtdetr_detections.get('text_free', [])) + + self._log(f"๐Ÿ“Š Processing {len(all_regions)} text regions with DocTR") + + # Check if parallel processing is enabled + if self.parallel_processing and len(all_regions) > 1: + self._log(f"๐Ÿš€ Using PARALLEL OCR for {len(all_regions)} regions with DocTR") + ocr_results = self._parallel_ocr_regions(image, all_regions, 'doctr', confidence_threshold) + else: + # Process each region with DocTR + for i, (x, y, w, h) in enumerate(all_regions): + cropped = self._safe_crop_region(image, x, y, w, h) + if cropped is None: + continue + result = self.ocr_manager.detect_text(cropped, 'doctr', confidence=confidence_threshold) + if result and len(result) > 0 and result[0].text.strip(): + result[0].bbox = (x, y, w, h) + result[0].vertices = [(x, y), (x+w, y), (x+w, y+h), (x, y+h)] + ocr_results.append(result[0]) + self._log(f"โœ… Region {i+1}: {result[0].text[:50]}...") + else: + # Process full image without bubble detection + self._log("๐Ÿ“ Processing full image with DocTR") + ocr_results = self.ocr_manager.detect_text(image, self.ocr_provider) + + elif self.ocr_provider == 'rapidocr': + # Initialize results list + ocr_results = [] + + # Get RapidOCR settings + use_recognition = self.main_gui.config.get('rapidocr_use_recognition', True) + language = self.main_gui.config.get('rapidocr_language', 'auto') + detection_mode = self.main_gui.config.get('rapidocr_detection_mode', 'document') + + self._log(f"โšก RapidOCR - Recognition: {'Full' if use_recognition else 'Detection Only'}") + + # ALWAYS process full image with RapidOCR for best results + self._log("๐Ÿ“Š Processing full image with RapidOCR") + ocr_results = self.ocr_manager.detect_text( + image, + 'rapidocr', + confidence=confidence_threshold, + use_recognition=use_recognition, + language=language, + detection_mode=detection_mode + ) + + # RT-DETR detection only affects merging, not OCR + if ocr_settings.get('bubble_detection_enabled', False): + self._log("๐Ÿค– RT-DETR will be used for bubble-based merging") + + else: + # Default processing for any other providers + ocr_results = self.ocr_manager.detect_text(image, self.ocr_provider) + + # Convert OCR results to TextRegion format + for result in ocr_results: + # CLEAN ORIGINAL OCR TEXT - Fix cube characters and encoding issues + original_ocr_text = result.text + cleaned_result_text = self._fix_encoding_issues(result.text) + cleaned_result_text = self._normalize_unicode_width(cleaned_result_text) + cleaned_result_text = self._sanitize_unicode_characters(cleaned_result_text) + + # Log cleaning if changes were made + if cleaned_result_text != original_ocr_text: + self._log(f"๐Ÿงน Cleaned OCR manager text: '{original_ocr_text[:30]}...' โ†’ '{cleaned_result_text[:30]}...'", "debug") + + # Apply filtering (use cleaned text) + if len(cleaned_result_text.strip()) < min_text_length: + if not getattr(self, 'concise_logs', False): + self._log(f" Skipping short text ({len(cleaned_result_text)} chars): {cleaned_result_text}") + continue + + if exclude_english and self._is_primarily_english(cleaned_result_text): + if not getattr(self, 'concise_logs', False): + self._log(f" Skipping English text: {cleaned_result_text[:50]}...") + continue + + if result.confidence < confidence_threshold: + if not getattr(self, 'concise_logs', False): + self._log(f" Skipping low confidence ({result.confidence:.2f}): {cleaned_result_text[:30]}...") + continue + + # Create TextRegion (use cleaned text) + # CRITICAL: Preserve bubble_bounds if it was set during OCR (e.g., manga-ocr with RT-DETR) + region_kwargs = { + 'text': cleaned_result_text, # Use cleaned text instead of original + 'vertices': result.vertices if result.vertices else [ + (result.bbox[0], result.bbox[1]), + (result.bbox[0] + result.bbox[2], result.bbox[1]), + (result.bbox[0] + result.bbox[2], result.bbox[1] + result.bbox[3]), + (result.bbox[0], result.bbox[1] + result.bbox[3]) + ], + 'bounding_box': result.bbox, + 'confidence': result.confidence, + 'region_type': 'text_block' + } + # Preserve bubble_bounds from OCR result if present + if hasattr(result, 'bubble_bounds') and result.bubble_bounds is not None: + region_kwargs['bubble_bounds'] = result.bubble_bounds + self._log(f" ๐Ÿ” Preserved bubble_bounds from OCR: {result.bubble_bounds}", "debug") + else: + if hasattr(result, 'bubble_bounds'): + self._log(f" โš ๏ธ OCR result has bubble_bounds but it's None!", "debug") + else: + self._log(f" โ„น๏ธ OCR result has no bubble_bounds attribute", "debug") + + region = TextRegion(**region_kwargs) + regions.append(region) + if not getattr(self, 'concise_logs', False): + self._log(f" Found text ({result.confidence:.2f}): {cleaned_result_text[:50]}...") + + # MERGING SECTION (applies to all providers) + # Check if bubble detection is enabled + if ocr_settings.get('bubble_detection_enabled', False): + # For manga-ocr and similar providers, skip merging since regions already have bubble_bounds from OCR + # Only Azure and Google need merging because they return line-level OCR results + if self.ocr_provider in ['manga-ocr', 'Qwen2-VL', 'custom-api', 'easyocr', 'paddleocr', 'doctr']: + self._log("๐ŸŽฏ Skipping bubble detection merge (regions already aligned with RT-DETR)") + # Regions already have bubble_bounds set from OCR phase - no need to merge + else: + # Azure and Google return line-level results that need to be merged into bubbles + self._log("๐Ÿค– Using AI bubble detection for merging") + regions = self._merge_with_bubble_detection(regions, image_path) + else: + # Traditional merging + merge_threshold = ocr_settings.get('merge_nearby_threshold', 20) + + # Apply provider-specific adjustments + if self.ocr_provider == 'azure': + azure_multiplier = ocr_settings.get('azure_merge_multiplier', 2.0) + merge_threshold = int(merge_threshold * azure_multiplier) + self._log(f"๐Ÿ“‹ Using Azure-adjusted merge threshold: {merge_threshold}px") + + # Pre-group Azure lines if the method exists + if hasattr(self, '_pregroup_azure_lines'): + regions = self._pregroup_azure_lines(regions, merge_threshold) + + elif self.ocr_provider in ['paddleocr', 'easyocr', 'doctr']: + # These providers often return smaller text segments + line_multiplier = ocr_settings.get('line_ocr_merge_multiplier', 1.5) + merge_threshold = int(merge_threshold * line_multiplier) + self._log(f"๐Ÿ“‹ Using line-based OCR adjusted threshold: {merge_threshold}px") + + # Apply standard merging + regions = self._merge_nearby_regions(regions, threshold=merge_threshold) + + self._log(f"โœ… Detected {len(regions)} text regions after merging") + + # NOTE: Debug images are saved in process_image() with correct output_dir + # Removed duplicate save here to avoid creating unexpected 'translated_images' folders + + return regions + + except Exception as e: + self._log(f"โŒ Error detecting text: {str(e)}", "error") + import traceback + self._log(traceback.format_exc(), "error") + raise + + def _validate_easyocr_languages(self, languages): + """Validate EasyOCR language combinations""" + # EasyOCR compatibility rules + incompatible_sets = [ + {'ja', 'ko'}, # Japanese + Korean + {'ja', 'zh'}, # Japanese + Chinese + {'ko', 'zh'} # Korean + Chinese + ] + + lang_set = set(languages) + + for incompatible in incompatible_sets: + if incompatible.issubset(lang_set): + # Conflict detected - keep first language + English + primary_lang = languages[0] if languages else 'en' + result = [primary_lang, 'en'] if primary_lang != 'en' else ['en'] + + self._log(f"โš ๏ธ EasyOCR: {' + '.join(incompatible)} not compatible", "warning") + self._log(f"๐Ÿ”ง Auto-adjusted from {languages} to {result}", "info") + return result + + return languages + + def _parallel_ocr_regions(self, image: np.ndarray, regions: List, provider: str, confidence_threshold: float) -> List: + """Process multiple regions in parallel using ThreadPoolExecutor""" + from concurrent.futures import ThreadPoolExecutor, as_completed + import threading + + ocr_results = [] + results_lock = threading.Lock() + + def process_single_region(index: int, bbox: Tuple[int, int, int, int]): + """Process a single region with OCR""" + x, y, w, h = bbox + try: + # Use the safe crop method + cropped = self._safe_crop_region(image, x, y, w, h) + + # Skip if crop failed + if cropped is None: + self._log(f"โš ๏ธ Skipping region {index} - invalid crop", "warning") + return + + # Run OCR on this region with retry logic for failures + result = None + # Get max_retries from config (default 7 means 1 initial + 7 retries = 8 total attempts) + # Subtract 1 because the initial attempt counts as the first try + try: + max_retries = int(self.main_gui.config.get('max_retries', 7)) if hasattr(self, 'main_gui') else 2 + except Exception: + max_retries = 2 # Fallback to 2 retries (3 total attempts) + + for attempt in range(max_retries + 1): + result = self.ocr_manager.detect_text( + cropped, + provider, + confidence=confidence_threshold + ) + + # Check if result indicates a failure + if result and len(result) > 0 and result[0].text.strip(): + text = result[0].text.strip() + + # Check for content blocked - should trigger fallback, not retry + # The unified API client should handle this, but if it reaches here, skip this region + if "[CONTENT BLOCKED" in text: + self._log(f"โš ๏ธ Region {index+1} content blocked by API safety filters", "warning") + return (index, None) # Skip this region, fallback already attempted + + # Check for retryable failure markers (transient errors) + failure_markers = [ + "[TRANSLATION FAILED", + "[ORIGINAL TEXT PRESERVED]", + "[IMAGE TRANSLATION FAILED]", + "[EXTRACTION FAILED", + "[RATE LIMITED" + ] + + has_failure = any(marker in text for marker in failure_markers) + + if has_failure and attempt < max_retries: + # Retry this region + self._log(f"โš ๏ธ Region {index+1} OCR failed (attempt {attempt + 1}/{max_retries + 1}), retrying...", "warning") + import time + time.sleep(1 * (attempt + 1)) # Progressive delay: 1s, 2s + result = None + continue + elif has_failure: + # All retries exhausted + self._log(f"โŒ Region {index+1} OCR failed after {max_retries + 1} attempts", "error") + return (index, None) + else: + # Success - break retry loop + break + else: + # No result or empty text + if attempt < max_retries: + self._log(f"โš ๏ธ Region {index+1} returned empty (attempt {attempt + 1}/{max_retries + 1}), retrying...", "warning") + import time + time.sleep(1 * (attempt + 1)) + result = None + continue + else: + # All retries exhausted, no valid result + return (index, None) + + if result and len(result) > 0 and result[0].text.strip(): + # Adjust coordinates to full image space + result[0].bbox = (x, y, w, h) + result[0].vertices = [(x, y), (x+w, y), (x+w, y+h), (x, y+h)] + # CRITICAL: Store RT-DETR bubble bounds for rendering (for non-Azure/Google providers) + result[0].bubble_bounds = (x, y, w, h) + return (index, result[0]) + return (index, None) + + except Exception as e: + self._log(f"Error processing region {index}: {str(e)}", "error") + return (index, None) + + # Process regions in parallel + max_workers = self.manga_settings.get('advanced', {}).get('max_workers', 4) + # For custom-api, treat OCR calls as API calls: use batch size when batch mode is enabled + try: + if provider == 'custom-api': + # prefer MangaTranslator.batch_size (from env BATCH_SIZE) + bs = int(getattr(self, 'batch_size', 0) or int(os.getenv('BATCH_SIZE', '0'))) + if bs and bs > 0: + max_workers = bs + except Exception: + pass + # Never spawn more workers than regions + max_workers = max(1, min(max_workers, len(regions))) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + # Submit all tasks + future_to_index = {} + for i, bbox in enumerate(regions): + future = executor.submit(process_single_region, i, bbox) + future_to_index[future] = i + + # Collect results + results_dict = {} + completed = 0 + for future in as_completed(future_to_index): + try: + index, result = future.result(timeout=30) + if result: + results_dict[index] = result + completed += 1 + self._log(f"โœ… [{completed}/{len(regions)}] Processed region {index+1}") + except Exception as e: + self._log(f"Failed to process region: {str(e)}", "error") + + # Sort results by index to maintain order + for i in range(len(regions)): + if i in results_dict: + ocr_results.append(results_dict[i]) + + self._log(f"๐Ÿ“Š Parallel OCR complete: {len(ocr_results)}/{len(regions)} regions extracted") + return ocr_results + + def _pregroup_azure_lines(self, lines: List[TextRegion], base_threshold: int) -> List[TextRegion]: + """Pre-group Azure lines that are obviously part of the same text block + This makes them more like Google's blocks before the main merge logic""" + + if len(lines) <= 1: + return lines + + # Sort by vertical position first, then horizontal + lines.sort(key=lambda r: (r.bounding_box[1], r.bounding_box[0])) + + pregrouped = [] + i = 0 + + while i < len(lines): + current_group = [lines[i]] + current_bbox = list(lines[i].bounding_box) + + # Look ahead for lines that should obviously be grouped + j = i + 1 + while j < len(lines): + x1, y1, w1, h1 = current_bbox + x2, y2, w2, h2 = lines[j].bounding_box + + # Calculate gaps + vertical_gap = y2 - (y1 + h1) if y2 > y1 + h1 else 0 + + # Check horizontal alignment + center_x1 = x1 + w1 / 2 + center_x2 = x2 + w2 / 2 + horizontal_offset = abs(center_x1 - center_x2) + avg_width = (w1 + w2) / 2 + + # Group if: + # 1. Lines are vertically adjacent (small gap) + # 2. Lines are well-aligned horizontally (likely same bubble) + if (vertical_gap < h1 * 0.5 and # Less than half line height gap + horizontal_offset < avg_width * 0.5): # Well centered + + # Add to group + current_group.append(lines[j]) + + # Update bounding box to include new line + min_x = min(x1, x2) + min_y = min(y1, y2) + max_x = max(x1 + w1, x2 + w2) + max_y = max(y1 + h1, y2 + h2) + current_bbox = [min_x, min_y, max_x - min_x, max_y - min_y] + + j += 1 + else: + break + + # Create merged region from group + if len(current_group) > 1: + merged_text = " ".join([line.text for line in current_group]) + all_vertices = [] + for line in current_group: + all_vertices.extend(line.vertices) + + merged_region = TextRegion( + text=merged_text, + vertices=all_vertices, + bounding_box=tuple(current_bbox), + confidence=0.95, + region_type='pregrouped_lines' + ) + pregrouped.append(merged_region) + + self._log(f" Pre-grouped {len(current_group)} Azure lines into block") + else: + # Single line, keep as is + pregrouped.append(lines[i]) + + i = j if j > i + 1 else i + 1 + + self._log(f" Azure pre-grouping: {len(lines)} lines โ†’ {len(pregrouped)} blocks") + return pregrouped + + def _safe_crop_region(self, image, x, y, w, h): + """Safely crop a region from image with validation""" + img_h, img_w = image.shape[:2] + + # Validate and clamp coordinates + x = max(0, min(x, img_w - 1)) + y = max(0, min(y, img_h - 1)) + x2 = min(x + w, img_w) + y2 = min(y + h, img_h) + + # Ensure valid region + if x2 <= x or y2 <= y: + self._log(f"โš ๏ธ Invalid crop region: ({x},{y},{w},{h}) for image {img_w}x{img_h}", "warning") + return None + + # Minimum size check + if (x2 - x) < 5 or (y2 - y) < 5: + self._log(f"โš ๏ธ Region too small: {x2-x}x{y2-y} pixels", "warning") + return None + + cropped = image[y:y2, x:x2] + + if cropped.size == 0: + self._log(f"โš ๏ธ Empty crop result", "warning") + return None + + return cropped + + def _prepare_ocr_rois_from_bubbles(self, image_path: str, ocr_settings: Dict, preprocessing: Dict, page_hash: str) -> List[Dict[str, Any]]: + """Prepare ROI crops (bytes) from bubble detection to use with OCR locality. + - Enhancements/resizing are gated by preprocessing['enabled']. + - Compression/encoding is controlled by manga_settings['compression'] independently. + Returns list of dicts: {id, bbox, bytes, type} + """ + try: + # Run bubble detector and collect text-containing boxes + detections = self._load_bubble_detector(ocr_settings, image_path) + if not detections: + return [] + regions = [] + for key in ('text_bubbles', 'text_free'): + for i, (bx, by, bw, bh) in enumerate(detections.get(key, []) or []): + regions.append({'type': 'text_bubble' if key == 'text_bubbles' else 'free_text', + 'bbox': (int(bx), int(by), int(bw), int(bh)), + 'id': f"{key}_{i}"}) + if not regions: + return [] + + # Open original image once + pil = Image.open(image_path) + if pil.mode != 'RGB': + pil = pil.convert('RGB') + + pad_ratio = float(ocr_settings.get('roi_padding_ratio', 0.08)) # 8% padding default + preproc_enabled = bool(preprocessing.get('enabled', False)) + # Compression settings (separate from preprocessing) + comp = {} + try: + comp = (self.main_gui.config.get('manga_settings', {}) or {}).get('compression', {}) + except Exception: + comp = {} + comp_enabled = bool(comp.get('enabled', False)) + comp_format = str(comp.get('format', 'jpeg')).lower() + jpeg_q = int(comp.get('jpeg_quality', 85)) + png_lvl = int(comp.get('png_compress_level', 6)) + webp_q = int(comp.get('webp_quality', 85)) + + out = [] + W, H = pil.size + # Pre-filter tiny ROIs (skip before cropping) + min_side_px = int(ocr_settings.get('roi_min_side_px', 12)) + min_area_px = int(ocr_settings.get('roi_min_area_px', 100)) + for rec in regions: + x, y, w, h = rec['bbox'] + if min(w, h) < max(1, min_side_px) or (w * h) < max(1, min_area_px): + # Skip tiny ROI + continue + # Apply padding + px = int(w * pad_ratio) + py = int(h * pad_ratio) + x1 = max(0, x - px) + y1 = max(0, y - py) + x2 = min(W, x + w + px) + y2 = min(H, y + h + py) + if x2 <= x1 or y2 <= y1: + continue + crop = pil.crop((x1, y1, x2, y2)) + + # Quality-affecting steps only when preprocessing enabled + if preproc_enabled: + try: + # Enhance contrast/sharpness/brightness if configured + c = float(preprocessing.get('contrast_threshold', 0.4)) + s = float(preprocessing.get('sharpness_threshold', 0.3)) + g = float(preprocessing.get('enhancement_strength', 1.5)) + if c: + crop = ImageEnhance.Contrast(crop).enhance(1 + c) + if s: + crop = ImageEnhance.Sharpness(crop).enhance(1 + s) + if g and g != 1.0: + crop = ImageEnhance.Brightness(crop).enhance(g) + # Optional ROI resize limit (short side cap) + roi_max_side = int(ocr_settings.get('roi_max_side', 0) or 0) + if roi_max_side and (crop.width > roi_max_side or crop.height > roi_max_side): + ratio = min(roi_max_side / crop.width, roi_max_side / crop.height) + crop = crop.resize((max(1, int(crop.width * ratio)), max(1, int(crop.height * ratio))), Image.Resampling.LANCZOS) + except Exception: + pass + # Encoding/Compression independent of preprocessing + from io import BytesIO + buf = BytesIO() + try: + if comp_enabled: + if comp_format in ('jpeg', 'jpg'): + if crop.mode != 'RGB': + crop = crop.convert('RGB') + crop.save(buf, format='JPEG', quality=max(1, min(95, jpeg_q)), optimize=True, progressive=True) + elif comp_format == 'png': + crop.save(buf, format='PNG', optimize=True, compress_level=max(0, min(9, png_lvl))) + elif comp_format == 'webp': + crop.save(buf, format='WEBP', quality=max(1, min(100, webp_q))) + else: + crop.save(buf, format='PNG', optimize=True) + else: + # Default lossless PNG + crop.save(buf, format='PNG', optimize=True) + img_bytes = buf.getvalue() + except Exception: + buf = BytesIO() + crop.save(buf, format='PNG', optimize=True) + img_bytes = buf.getvalue() + + out.append({ + 'id': rec['id'], + 'bbox': (x, y, w, h), # keep original bbox without padding for placement + 'bytes': img_bytes, + 'type': rec['type'], + 'page_hash': page_hash + }) + return out + except Exception as e: + self._log(f"โš ๏ธ ROI preparation failed: {e}", "warning") + return [] + + def _google_ocr_rois_batched(self, rois: List[Dict[str, Any]], ocr_settings: Dict, batch_size: int, max_concurrency: int, page_hash: str) -> List[TextRegion]: + """Batch OCR of ROI crops using Google Vision batchAnnotateImages. + - Uses bounded concurrency for multiple batches in flight. + - Consults and updates an in-memory ROI OCR cache. + """ + try: + from google.cloud import vision as _vision + except Exception: + self._log("โŒ Google Vision SDK not available for ROI batching", "error") + return [] + + lang_hints = ocr_settings.get('language_hints', ['ja', 'ko', 'zh']) + detection_mode = ocr_settings.get('text_detection_mode', 'document') + feature_type = _vision.Feature.Type.DOCUMENT_TEXT_DETECTION if detection_mode == 'document' else _vision.Feature.Type.TEXT_DETECTION + feature = _vision.Feature(type=feature_type) + + results: List[TextRegion] = [] + min_text_length = int(ocr_settings.get('min_text_length', 2)) + exclude_english = bool(ocr_settings.get('exclude_english_text', True)) + + # Check cache first and build work list of uncached ROIs + work_rois = [] + for roi in rois: + x, y, w, h = roi['bbox'] + # Include region type in cache key to prevent mismapping + cache_key = ("google", page_hash, x, y, w, h, tuple(lang_hints), detection_mode, roi.get('type', 'unknown')) + # THREAD-SAFE: Use lock for cache access in parallel panel translation + with self._cache_lock: + cached_text = self.ocr_roi_cache.get(cache_key) + if cached_text: + region = TextRegion( + text=cached_text, + vertices=[(x, y), (x+w, y), (x+w, y+h), (x, y+h)], + bounding_box=(x, y, w, h), + confidence=0.95, + region_type='ocr_roi' + ) + try: + region.bubble_type = 'free_text' if roi.get('type') == 'free_text' else 'text_bubble' + region.should_inpaint = True + except Exception: + pass + results.append(region) + else: + roi['cache_key'] = cache_key + work_rois.append(roi) + + if not work_rois: + return results + + # Create batches + batch_size = max(1, batch_size) + batches = [work_rois[i:i+batch_size] for i in range(0, len(work_rois), batch_size)] + max_concurrency = max(1, int(max_concurrency or 1)) + + def do_batch(batch): + # RATE LIMITING: Add small delay before batch submission + import time + import random + time.sleep(0.1 + random.random() * 0.2) # 0.1-0.3s random delay + + requests = [] + for roi in batch: + img = _vision.Image(content=roi['bytes']) + ctx = _vision.ImageContext(language_hints=list(lang_hints)) + req = _vision.AnnotateImageRequest(image=img, features=[feature], image_context=ctx) + requests.append(req) + return self.vision_client.batch_annotate_images(requests=requests), batch + + # Execute with concurrency + if max_concurrency == 1 or len(batches) == 1: + iter_batches = [(self.vision_client.batch_annotate_images(requests=[ + _vision.AnnotateImageRequest(image=_vision.Image(content=roi['bytes']), features=[feature], image_context=_vision.ImageContext(language_hints=list(lang_hints))) + for roi in batch + ]), batch) for batch in batches] + else: + from concurrent.futures import ThreadPoolExecutor, as_completed + iter_batches = [] + with ThreadPoolExecutor(max_workers=max_concurrency) as ex: + futures = [ex.submit(do_batch, b) for b in batches] + for fut in as_completed(futures): + try: + iter_batches.append(fut.result()) + except Exception as e: + self._log(f"โš ๏ธ Google batch failed: {e}", "warning") + continue + + # Consume responses and update cache + for resp, batch in iter_batches: + for roi, ann in zip(batch, resp.responses): + if getattr(ann, 'error', None) and ann.error.message: + self._log(f"โš ๏ธ ROI OCR error: {ann.error.message}", "warning") + continue + text = '' + try: + if getattr(ann, 'full_text_annotation', None) and ann.full_text_annotation.text: + text = ann.full_text_annotation.text + elif ann.text_annotations: + text = ann.text_annotations[0].description + except Exception: + text = '' + text = (text or '').strip() + text_clean = self._sanitize_unicode_characters(self._fix_encoding_issues(text)) + if len(text_clean.strip()) < min_text_length: + continue + if exclude_english and self._is_primarily_english(text_clean): + continue + x, y, w, h = roi['bbox'] + # Update cache + # THREAD-SAFE: Use lock for cache write in parallel panel translation + try: + ck = roi.get('cache_key') or ("google", page_hash, x, y, w, h, tuple(lang_hints), detection_mode) + with self._cache_lock: + self.ocr_roi_cache[ck] = text_clean + except Exception: + pass + region = TextRegion( + text=text_clean, + vertices=[(x, y), (x+w, y), (x+w, y+h), (x, y+h)], + bounding_box=(x, y, w, h), + confidence=0.95, + region_type='ocr_roi' + ) + try: + region.bubble_type = 'free_text' if roi.get('type') == 'free_text' else 'text_bubble' + region.should_inpaint = True + except Exception: + pass + results.append(region) + return results + + def _azure_ocr_rois_concurrent(self, rois: List[Dict[str, Any]], ocr_settings: Dict, max_workers: int, page_hash: str) -> List[TextRegion]: + """Concurrent ROI OCR for Azure Read API. Each ROI is sent as a separate call. + Concurrency is bounded by max_workers. Consults/updates cache. + """ + from concurrent.futures import ThreadPoolExecutor, as_completed + from azure.cognitiveservices.vision.computervision.models import OperationStatusCodes + import io + results: List[TextRegion] = [] + + # Read settings + reading_order = ocr_settings.get('azure_reading_order', 'natural') + model_version = ocr_settings.get('azure_model_version', 'latest') + language_hints = ocr_settings.get('language_hints', ['ja']) + read_params = {'raw': True, 'readingOrder': reading_order} + if model_version != 'latest': + read_params['model-version'] = model_version + if len(language_hints) == 1: + lang_mapping = {'zh': 'zh-Hans', 'zh-TW': 'zh-Hant', 'zh-CN': 'zh-Hans', 'ja': 'ja', 'ko': 'ko', 'en': 'en'} + read_params['language'] = lang_mapping.get(language_hints[0], language_hints[0]) + + min_text_length = int(ocr_settings.get('min_text_length', 2)) + exclude_english = bool(ocr_settings.get('exclude_english_text', True)) + + # Check cache first and split into cached vs work rois + cached_regions: List[TextRegion] = [] + work_rois: List[Dict[str, Any]] = [] + for roi in rois: + x, y, w, h = roi['bbox'] + # Include region type in cache key to prevent mismapping + cache_key = ("azure", page_hash, x, y, w, h, reading_order, roi.get('type', 'unknown')) + # THREAD-SAFE: Use lock for cache access in parallel panel translation + with self._cache_lock: + text_cached = self.ocr_roi_cache.get(cache_key) + if text_cached: + region = TextRegion( + text=text_cached, + vertices=[(x, y), (x+w, y), (x+w, y+h), (x, y+h)], + bounding_box=(x, y, w, h), + confidence=0.95, + region_type='ocr_roi' + ) + try: + region.bubble_type = 'free_text' if roi.get('type') == 'free_text' else 'text_bubble' + region.should_inpaint = True + except Exception: + pass + cached_regions.append(region) + else: + roi['cache_key'] = cache_key + work_rois.append(roi) + + def ocr_one(roi): + try: + # RATE LIMITING: Add delay between Azure API calls to avoid "Too Many Requests" + import time + import random + # Stagger requests with randomized delay + time.sleep(0.1 + random.random() * 0.2) # 0.1-0.3s random delay + + # Ensure Azure-supported format for ROI bytes; honor compression preference when possible + data = roi['bytes'] + try: + from PIL import Image as _PILImage + im = _PILImage.open(io.BytesIO(data)) + fmt = (im.format or '').lower() + if fmt not in ['jpeg', 'jpg', 'png', 'bmp', 'tiff']: + # Choose conversion target based on compression settings if available + try: + comp_cfg = (self.main_gui.config.get('manga_settings', {}) or {}).get('compression', {}) + except Exception: + comp_cfg = {} + target_fmt = 'PNG' + try: + if comp_cfg.get('enabled', False): + cf = str(comp_cfg.get('format', '')).lower() + if cf in ('jpeg', 'jpg'): + target_fmt = 'JPEG' + elif cf == 'png': + target_fmt = 'PNG' + elif cf == 'bmp': + target_fmt = 'BMP' + elif cf == 'tiff': + target_fmt = 'TIFF' + except Exception: + pass + buf2 = io.BytesIO() + if target_fmt == 'JPEG' and im.mode != 'RGB': + im = im.convert('RGB') + im.save(buf2, format=target_fmt) + data = buf2.getvalue() + except Exception: + pass + stream = io.BytesIO(data) + read_response = self.vision_client.read_in_stream(stream, **read_params) + op_loc = read_response.headers.get('Operation-Location') if hasattr(read_response, 'headers') else None + if not op_loc: + return None + op_id = op_loc.split('/')[-1] + # Poll + import time + waited = 0.0 + poll_interval = float(ocr_settings.get('azure_poll_interval', 0.5)) + max_wait = float(ocr_settings.get('azure_max_wait', 60)) + while waited < max_wait: + result = self.vision_client.get_read_result(op_id) + if result.status not in [OperationStatusCodes.running, OperationStatusCodes.not_started]: + break + time.sleep(poll_interval) + waited += poll_interval + if result.status != OperationStatusCodes.succeeded: + return None + # Aggregate text lines + texts = [] + for page in result.analyze_result.read_results: + for line in page.lines: + t = self._sanitize_unicode_characters(self._fix_encoding_issues(line.text or '')) + if t: + texts.append(t) + text_all = ' '.join(texts).strip() + if len(text_all) < min_text_length: + return None + if exclude_english and self._is_primarily_english(text_all): + return None + x, y, w, h = roi['bbox'] + # Update cache + # THREAD-SAFE: Use lock for cache write in parallel panel translation + try: + ck = roi.get('cache_key') + if ck: + with self._cache_lock: + self.ocr_roi_cache[ck] = text_all + except Exception: + pass + region = TextRegion( + text=text_all, + vertices=[(x, y), (x+w, y), (x+w, y+h), (x, y+h)], + bounding_box=(x, y, w, h), + confidence=0.95, + region_type='ocr_roi' + ) + try: + region.bubble_type = 'free_text' if roi.get('type') == 'free_text' else 'text_bubble' + region.should_inpaint = True + except Exception: + pass + return region + except Exception: + return None + + # Combine cached and new results + results.extend(cached_regions) + + if work_rois: + max_workers = max(1, min(max_workers, len(work_rois))) + with ThreadPoolExecutor(max_workers=max_workers) as ex: + fut_map = {ex.submit(ocr_one, r): r for r in work_rois} + for fut in as_completed(fut_map): + reg = fut.result() + if reg is not None: + results.append(reg) + return results + + def _detect_text_azure(self, image_data: bytes, ocr_settings: dict) -> List[TextRegion]: + """Detect text using Azure Computer Vision""" + import io + from azure.cognitiveservices.vision.computervision.models import OperationStatusCodes + + stream = io.BytesIO(image_data) + + # Use Read API for better manga text detection + read_result = self.vision_client.read_in_stream( + stream, + raw=True, + language='ja' # or from ocr_settings + ) + + # Get operation ID from headers + operation_location = read_result.headers["Operation-Location"] + operation_id = operation_location.split("/")[-1] + + # Wait for completion + import time + while True: + result = self.vision_client.get_read_result(operation_id) + if result.status not in [OperationStatusCodes.running, OperationStatusCodes.not_started]: + break + time.sleep(0.1) # Brief pause for stability + logger.debug("๐Ÿ’ค Azure text detection pausing briefly for stability") + + regions = [] + confidence_threshold = ocr_settings.get('confidence_threshold', 0.6) + + if result.status == OperationStatusCodes.succeeded: + for page in result.analyze_result.read_results: + for line in page.lines: + # Azure returns bounding box as 8 coordinates + bbox = line.bounding_box + vertices = [ + (bbox[0], bbox[1]), + (bbox[2], bbox[3]), + (bbox[4], bbox[5]), + (bbox[6], bbox[7]) + ] + + xs = [v[0] for v in vertices] + ys = [v[1] for v in vertices] + x_min, x_max = min(xs), max(xs) + y_min, y_max = min(ys), max(ys) + + # Azure doesn't provide per-line confidence in Read API + confidence = 0.95 # Default high confidence + + if confidence >= confidence_threshold: + region = TextRegion( + text=line.text, + vertices=vertices, + bounding_box=(x_min, y_min, x_max - x_min, y_max - y_min), + confidence=confidence, + region_type='text_line' + ) + regions.append(region) + + return regions + + def _load_image_with_compression_only(self, image_path: str, comp: Dict) -> bytes: + """Load image and apply compression settings only (no enhancements/resizing).""" + from io import BytesIO + pil = Image.open(image_path) + if pil.mode != 'RGB': + pil = pil.convert('RGB') + buf = BytesIO() + try: + fmt = str(comp.get('format', 'jpeg')).lower() + if fmt in ('jpeg', 'jpg'): + q = max(1, min(95, int(comp.get('jpeg_quality', 85)))) + pil.save(buf, format='JPEG', quality=q, optimize=True, progressive=True) + elif fmt == 'png': + lvl = max(0, min(9, int(comp.get('png_compress_level', 6)))) + pil.save(buf, format='PNG', optimize=True, compress_level=lvl) + elif fmt == 'webp': + wq = max(1, min(100, int(comp.get('webp_quality', 85)))) + pil.save(buf, format='WEBP', quality=wq) + else: + pil.save(buf, format='PNG', optimize=True) + except Exception: + pil.save(buf, format='PNG', optimize=True) + return buf.getvalue() + + def _preprocess_image(self, image_path: str, preprocessing_settings: Dict) -> bytes: + """Preprocess image for better OCR results + - Enhancements/resizing controlled by preprocessing_settings + - Compression controlled by manga_settings['compression'] independently + """ + try: + # Open image with PIL + pil_image = Image.open(image_path) + + # Convert to RGB if necessary + if pil_image.mode != 'RGB': + pil_image = pil_image.convert('RGB') + + # Auto-detect quality issues if enabled + if preprocessing_settings.get('auto_detect_quality', True): + needs_enhancement = self._detect_quality_issues(pil_image, preprocessing_settings) + if needs_enhancement: + self._log(" Auto-detected quality issues - applying enhancements") + else: + needs_enhancement = True + + if needs_enhancement: + # Apply contrast enhancement + contrast_threshold = preprocessing_settings.get('contrast_threshold', 0.4) + enhancer = ImageEnhance.Contrast(pil_image) + pil_image = enhancer.enhance(1 + contrast_threshold) + + # Apply sharpness enhancement + sharpness_threshold = preprocessing_settings.get('sharpness_threshold', 0.3) + enhancer = ImageEnhance.Sharpness(pil_image) + pil_image = enhancer.enhance(1 + sharpness_threshold) + + # Apply general enhancement strength + enhancement_strength = preprocessing_settings.get('enhancement_strength', 1.5) + if enhancement_strength != 1.0: + # Brightness adjustment + enhancer = ImageEnhance.Brightness(pil_image) + pil_image = enhancer.enhance(enhancement_strength) + + # Resize if too large + max_dimension = preprocessing_settings.get('max_image_dimension', 2000) + if pil_image.width > max_dimension or pil_image.height > max_dimension: + ratio = min(max_dimension / pil_image.width, max_dimension / pil_image.height) + new_size = (int(pil_image.width * ratio), int(pil_image.height * ratio)) + pil_image = pil_image.resize(new_size, Image.Resampling.LANCZOS) + self._log(f" Resized image to {new_size[0]}x{new_size[1]}") + + # Convert back to bytes with compression settings from global config + from io import BytesIO + buffered = BytesIO() + comp = {} + try: + comp = (self.main_gui.config.get('manga_settings', {}) or {}).get('compression', {}) + except Exception: + comp = {} + try: + if comp.get('enabled', False): + fmt = str(comp.get('format', 'jpeg')).lower() + if fmt in ('jpeg', 'jpg'): + if pil_image.mode != 'RGB': + pil_image = pil_image.convert('RGB') + quality = max(1, min(95, int(comp.get('jpeg_quality', 85)))) + pil_image.save(buffered, format='JPEG', quality=quality, optimize=True, progressive=True) + self._log(f" Compressed image as JPEG (q={quality})") + elif fmt == 'png': + level = max(0, min(9, int(comp.get('png_compress_level', 6)))) + pil_image.save(buffered, format='PNG', optimize=True, compress_level=level) + self._log(f" Compressed image as PNG (level={level})") + elif fmt == 'webp': + q = max(1, min(100, int(comp.get('webp_quality', 85)))) + pil_image.save(buffered, format='WEBP', quality=q) + self._log(f" Compressed image as WEBP (q={q})") + else: + pil_image.save(buffered, format='PNG', optimize=True) + self._log(" Unknown compression format; saved as optimized PNG") + else: + pil_image.save(buffered, format='PNG', optimize=True) + except Exception as _e: + self._log(f" โš ๏ธ Compression failed ({_e}); saved as optimized PNG", "warning") + pil_image.save(buffered, format='PNG', optimize=True) + return buffered.getvalue() + + except Exception as e: + self._log(f"โš ๏ธ Preprocessing failed: {str(e)}, using original image", "warning") + with open(image_path, 'rb') as f: + return f.read() + + def _detect_quality_issues(self, image: Image.Image, settings: Dict) -> bool: + """Auto-detect if image needs quality enhancement""" + # Convert to grayscale for analysis + gray = image.convert('L') + + # Get histogram + hist = gray.histogram() + + # Calculate contrast (simplified) + pixels = sum(hist) + mean = sum(i * hist[i] for i in range(256)) / pixels + variance = sum(hist[i] * (i - mean) ** 2 for i in range(256)) / pixels + std_dev = variance ** 0.5 + + # Low contrast if std deviation is low + contrast_threshold = settings.get('contrast_threshold', 0.4) * 100 + if std_dev < contrast_threshold: + self._log(" Low contrast detected") + return True + + # Check for blur using Laplacian variance + import numpy as np + gray_array = np.array(gray) + laplacian = cv2.Laplacian(gray_array, cv2.CV_64F) + variance = laplacian.var() + + sharpness_threshold = settings.get('sharpness_threshold', 0.3) * 100 + if variance < sharpness_threshold: + self._log(" Blur detected") + return True + + return False + + def _save_debug_image(self, image_path: str, regions: List[TextRegion], debug_base_dir: str = None): + """Save debug image with detected regions highlighted, respecting save_intermediate toggle. + All files are written under <translated_images>/debug (or provided debug_base_dir).""" + advanced_settings = self.manga_settings.get('advanced', {}) + # Skip debug images in batch mode unless explicitly requested + if self.batch_mode and not advanced_settings.get('force_debug_batch', False): + return + # Respect the 'Save intermediate images' toggle only + if not advanced_settings.get('save_intermediate', False): + return + # Compute debug directory under translated_images + if debug_base_dir is None: + translated_dir = os.path.join(os.path.dirname(image_path), 'translated_images') + debug_dir = os.path.join(translated_dir, 'debug') + else: + debug_dir = os.path.join(debug_base_dir, 'debug') + os.makedirs(debug_dir, exist_ok=True) + base_name = os.path.splitext(os.path.basename(image_path))[0] + + try: + import cv2 + import numpy as np + from PIL import Image as PILImage + + # Handle Unicode paths + try: + img = cv2.imread(image_path) + if img is None: + # Fallback to PIL for Unicode paths + pil_image = PILImage.open(image_path) + img = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) + except Exception as e: + self._log(f" Failed to load image for debug: {str(e)}", "warning") + return + + # Debug directory prepared earlier; compute base name + # base_name already computed above + + # Draw rectangles around detected text regions + overlay = img.copy() + + # Calculate statistics + total_chars = sum(len(r.text) for r in regions) + avg_confidence = np.mean([r.confidence for r in regions]) if regions else 0 + + for i, region in enumerate(regions): + # Convert to int to avoid OpenCV type errors + x, y, w, h = map(int, region.bounding_box) + + # Color based on confidence + if region.confidence > 0.95: + color = (0, 255, 0) # Green - high confidence + elif region.confidence > 0.8: + color = (0, 165, 255) # Orange - medium confidence + else: + color = (0, 0, 255) # Red - low confidence + + # Draw rectangle + cv2.rectangle(overlay, (x, y), (x + w, y + h), color, 2) + + # Add region info + info_text = f"#{i} ({region.confidence:.2f})" + cv2.putText(overlay, info_text, (x, y - 5), cv2.FONT_HERSHEY_SIMPLEX, + 0.5, color, 1, cv2.LINE_AA) + + # Add character count + char_count = len(region.text.strip()) + cv2.putText(overlay, f"{char_count} chars", (x, y + h + 15), + cv2.FONT_HERSHEY_SIMPLEX, 0.4, color, 1, cv2.LINE_AA) + + # Add detected text preview if in verbose debug mode + if self.manga_settings.get('advanced', {}).get('save_intermediate', False): + text_preview = region.text[:20] + "..." if len(region.text) > 20 else region.text + cv2.putText(overlay, text_preview, (x, y + h + 30), cv2.FONT_HERSHEY_SIMPLEX, + 0.4, color, 1, cv2.LINE_AA) + + # Add overall statistics to the image + stats_bg = overlay.copy() + cv2.rectangle(stats_bg, (10, 10), (300, 90), (0, 0, 0), -1) + cv2.addWeighted(stats_bg, 0.7, overlay, 0.3, 0, overlay) + + stats_text = [ + f"Regions: {len(regions)}", + f"Total chars: {total_chars}", + f"Avg confidence: {avg_confidence:.2f}" + ] + + for i, text in enumerate(stats_text): + cv2.putText(overlay, text, (20, 35 + i*20), cv2.FONT_HERSHEY_SIMPLEX, + 0.5, (255, 255, 255), 1, cv2.LINE_AA) + + # Save main debug image (always under translated_images/debug when enabled) + debug_path = os.path.join(debug_dir, f"{base_name}_debug_regions.png") + cv2.imwrite(debug_path, overlay) + self._log(f" ๐Ÿ“ธ Saved debug image: {debug_path}") + + # Save text mask + mask = self.create_text_mask(img, regions) + mask_debug_path = debug_path.replace('_debug', '_mask') + cv2.imwrite(mask_debug_path, mask) + mask_percentage = ((mask > 0).sum() / mask.size) * 100 + self._log(f" ๐ŸŽญ Saved mask image: {mask_debug_path}", "info") + self._log(f" ๐Ÿ“Š Mask coverage: {mask_percentage:.1f}% of image", "info") + + # If save_intermediate is enabled, save additional debug images + if self.manga_settings.get('advanced', {}).get('save_intermediate', False): + # Save confidence heatmap + heatmap = self._create_confidence_heatmap(img, regions) + heatmap_path = os.path.join(debug_dir, f"{base_name}_confidence_heatmap.png") + cv2.imwrite(heatmap_path, heatmap) + self._log(f" ๐ŸŒก๏ธ Saved confidence heatmap: {heatmap_path}") + + # Save polygon visualization with safe text areas + if any(hasattr(r, 'vertices') and r.vertices for r in regions): + polygon_img = img.copy() + for region in regions: + if hasattr(region, 'vertices') and region.vertices: + # Draw polygon + pts = np.array(region.vertices, np.int32) + pts = pts.reshape((-1, 1, 2)) + + # Fill with transparency + overlay_poly = polygon_img.copy() + cv2.fillPoly(overlay_poly, [pts], (0, 255, 255)) + cv2.addWeighted(overlay_poly, 0.2, polygon_img, 0.8, 0, polygon_img) + + # Draw outline + cv2.polylines(polygon_img, [pts], True, (255, 0, 0), 2) + + # Draw safe text area + try: + safe_x, safe_y, safe_w, safe_h = self.get_safe_text_area(region) + # Convert to int for OpenCV + safe_x, safe_y, safe_w, safe_h = map(int, (safe_x, safe_y, safe_w, safe_h)) + cv2.rectangle(polygon_img, (safe_x, safe_y), + (safe_x + safe_w, safe_y + safe_h), + (0, 255, 0), 1) + except: + pass # Skip if get_safe_text_area fails + + # Add legend to explain colors + legend_bg = polygon_img.copy() + legend_height = 140 + legend_width = 370 + cv2.rectangle(legend_bg, (10, 10), (10 + legend_width, 10 + legend_height), (0, 0, 0), -1) + cv2.addWeighted(legend_bg, 0.8, polygon_img, 0.2, 0, polygon_img) + + # Add legend items + # Note: OpenCV uses BGR format, so (255, 0, 0) = Blue, (0, 0, 255) = Red + legend_items = [ + ("Blue outline: OCR polygon (detected text)", (255, 0, 0)), + ("Yellow fill: Mask area (will be inpainted)", (0, 255, 255)), + ("Green rect: Safe text area (algorithm-based)", (0, 255, 0)), + ("Magenta rect: Mask bounds (actual render area)", (255, 0, 255)) + ] + + for i, (text, color) in enumerate(legend_items): + y_pos = 30 + i * 30 + # Draw color sample + if i == 1: # Yellow fill + cv2.rectangle(polygon_img, (20, y_pos - 8), (35, y_pos + 8), color, -1) + else: + cv2.rectangle(polygon_img, (20, y_pos - 8), (35, y_pos + 8), color, 2) + # Draw text + cv2.putText(polygon_img, text, (45, y_pos + 5), cv2.FONT_HERSHEY_SIMPLEX, + 0.45, (255, 255, 255), 1, cv2.LINE_AA) + + polygon_path = os.path.join(debug_dir, f"{base_name}_polygons.png") + cv2.imwrite(polygon_path, polygon_img) + self._log(f" ๐Ÿ”ท Saved polygon visualization: {polygon_path}") + + # Save individual region crops with more info + regions_dir = os.path.join(debug_dir, 'regions') + os.makedirs(regions_dir, exist_ok=True) + + for i, region in enumerate(regions[:10]): # Limit to first 10 regions + # Convert to int to avoid OpenCV type errors + x, y, w, h = map(int, region.bounding_box) + # Add padding + pad = 10 + x1 = max(0, x - pad) + y1 = max(0, y - pad) + x2 = min(img.shape[1], x + w + pad) + y2 = min(img.shape[0], y + h + pad) + + region_crop = img[y1:y2, x1:x2].copy() + + # Draw bounding box on crop + cv2.rectangle(region_crop, (pad, pad), + (pad + w, pad + h), (0, 255, 0), 2) + + # Add text info on the crop + info = f"Conf: {region.confidence:.2f} | Chars: {len(region.text)}" + cv2.putText(region_crop, info, (5, 15), cv2.FONT_HERSHEY_SIMPLEX, + 0.4, (255, 255, 255), 1, cv2.LINE_AA) + + # Save with meaningful filename + safe_text = region.text[:20].replace('/', '_').replace('\\', '_').strip() + region_path = os.path.join(regions_dir, f"region_{i:03d}_{safe_text}.png") + cv2.imwrite(region_path, region_crop) + + self._log(f" ๐Ÿ“ Saved individual region crops to: {regions_dir}") + + except Exception as e: + self._log(f" โŒ Failed to save debug image: {str(e)}", "warning") + if self.manga_settings.get('advanced', {}).get('debug_mode', False): + # If debug mode is on, log the full traceback + import traceback + self._log(traceback.format_exc(), "warning") + + def _create_confidence_heatmap(self, img, regions): + """Create a heatmap showing OCR confidence levels""" + heatmap = np.zeros_like(img[:, :, 0], dtype=np.float32) + + for region in regions: + # Convert to int for array indexing + x, y, w, h = map(int, region.bounding_box) + confidence = region.confidence + heatmap[y:y+h, x:x+w] = confidence + + # Convert to color heatmap + heatmap_normalized = (heatmap * 255).astype(np.uint8) + heatmap_colored = cv2.applyColorMap(heatmap_normalized, cv2.COLORMAP_JET) + + # Blend with original image + result = cv2.addWeighted(img, 0.7, heatmap_colored, 0.3, 0) + return result + + def _get_translation_history_context(self) -> List[Dict[str, str]]: + """Get translation history context from HistoryManager""" + if not self.history_manager or not self.contextual_enabled: + return [] + + try: + # Load full history + full_history = self.history_manager.load_history() + + if not full_history: + return [] + + # Extract only the contextual messages up to the limit + context = [] + exchange_count = 0 + + # Process history in pairs (user + assistant messages) + for i in range(0, len(full_history), 2): + if i + 1 < len(full_history): + user_msg = full_history[i] + assistant_msg = full_history[i + 1] + + if user_msg.get("role") == "user" and assistant_msg.get("role") == "assistant": + context.extend([user_msg, assistant_msg]) + exchange_count += 1 + + # Only keep up to the history limit + if exchange_count >= self.translation_history_limit: + # Get only the most recent exchanges + context = context[-(self.translation_history_limit * 2):] + break + + return context + + except Exception as e: + self._log(f"โš ๏ธ Error loading history context: {str(e)}", "warning") + return [] + + def translate_text(self, text: str, context: Optional[List[Dict]] = None, image_path: str = None, region: TextRegion = None) -> str: + """Translate text using API with GUI system prompt and full image context""" + try: + # Build per-request log prefix for clearer parallel logs + try: + import threading + thread_name = threading.current_thread().name + except Exception: + thread_name = "MainThread" + bbox_info = "" + try: + if region and hasattr(region, 'bounding_box') and region.bounding_box: + x, y, w, h = region.bounding_box + bbox_info = f" [bbox={x},{y},{w}x{h}]" + except Exception: + pass + prefix = f"[{thread_name}]{bbox_info}" + + self._log(f"\n{prefix} ๐ŸŒ Starting translation for text: '{text[:50]}...'") + # CHECK 1: Before starting + if self._check_stop(): + self._log("โน๏ธ Translation stopped before full page context processing", "warning") + return {} + + # Get system prompt from GUI profile + profile_name = self.main_gui.profile_var.get() + + # Get the prompt from prompt_profiles dictionary + system_prompt = '' + if hasattr(self.main_gui, 'prompt_profiles') and profile_name in self.main_gui.prompt_profiles: + system_prompt = self.main_gui.prompt_profiles[profile_name] + self._log(f"๐Ÿ“‹ Using profile: {profile_name}") + else: + self._log(f"โš ๏ธ Profile '{profile_name}' not found in prompt_profiles", "warning") + + self._log(f"{prefix} ๐Ÿ“ System prompt: {system_prompt[:100]}..." if system_prompt else f"{prefix} ๐Ÿ“ No system prompt configured") + + if system_prompt: + messages = [{"role": "system", "content": system_prompt}] + else: + messages = [] + + + # Add contextual translations if enabled + if self.contextual_enabled and self.history_manager: + # Get history from HistoryManager + history_context = self._get_translation_history_context() + + if history_context: + context_count = len(history_context) // 2 # Each exchange is 2 messages + self._log(f"๐Ÿ”— Adding {context_count} previous exchanges from history (limit: {self.translation_history_limit})") + messages.extend(history_context) + else: + self._log(f"๐Ÿ”— Contextual enabled but no history available yet") + else: + self._log(f"{prefix} ๐Ÿ”— Contextual: {'Disabled' if not self.contextual_enabled else 'No HistoryManager'}") + + # Add full image context if available AND visual context is enabled + if image_path and self.visual_context_enabled: + try: + import base64 + from PIL import Image as PILImage + + self._log(f"{prefix} ๐Ÿ“ท Adding full page visual context for translation") + + # Read and encode the full image + with open(image_path, 'rb') as img_file: + img_data = img_file.read() + + # Check image size + img_size_mb = len(img_data) / (1024 * 1024) + self._log(f"{prefix} ๐Ÿ“Š Image size: {img_size_mb:.2f} MB") + + # Optionally resize if too large (Gemini has limits) + if img_size_mb > 10: # If larger than 10MB + self._log(f"๐Ÿ“‰ Resizing large image for API limits...") + pil_image = PILImage.open(image_path) + + # Calculate new size (max 2048px on longest side) + max_size = 2048 + ratio = min(max_size / pil_image.width, max_size / pil_image.height) + if ratio < 1: + new_size = (int(pil_image.width * ratio), int(pil_image.height * ratio)) + pil_image = pil_image.resize(new_size, PILImage.Resampling.LANCZOS) + + # Re-encode + from io import BytesIO + buffered = BytesIO() + pil_image.save(buffered, format="PNG", optimize=True) + img_data = buffered.getvalue() + self._log(f"{prefix} โœ… Resized to {new_size[0]}x{new_size[1]}px ({len(img_data)/(1024*1024):.2f} MB)") + + # Encode to base64 + img_base64 = base64.b64encode(img_data).decode('utf-8') + + # Build the message with image and text location info + location_description = "" + if region: + x, y, w, h = region.bounding_box + # Describe where on the page this text is located + page_width = PILImage.open(image_path).width + page_height = PILImage.open(image_path).height + + # Determine position + h_pos = "left" if x < page_width/3 else "center" if x < 2*page_width/3 else "right" + v_pos = "top" if y < page_height/3 else "middle" if y < 2*page_height/3 else "bottom" + + location_description = f"\n\nThe text to translate is located in the {v_pos}-{h_pos} area of the page, " + location_description += f"at coordinates ({x}, {y}) with size {w}x{h} pixels." + + # Add image and text to translate + messages.append({ + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": { + "url": f"data:image/png;base64,{img_base64}" + } + }, + { + "type": "text", + "text": f"Looking at this full manga page, translate the following text: '{text}'{location_description}" + } + ] + }) + + self._log(f"{prefix} โœ… Added full page image as visual context") + + except Exception as e: + self._log(f"โš ๏ธ Failed to add image context: {str(e)}", "warning") + self._log(f" Error type: {type(e).__name__}", "warning") + import traceback + self._log(traceback.format_exc(), "warning") + # Fall back to text-only translation + messages.append({"role": "user", "content": text}) + elif image_path and not self.visual_context_enabled: + # Visual context disabled - text-only mode + self._log(f"{prefix} ๐Ÿ“ Text-only mode (visual context disabled)") + messages.append({"role": "user", "content": text}) + else: + # No image path provided - text-only translation + messages.append({"role": "user", "content": text}) + + # Check input token limit + text_tokens = 0 + image_tokens = 0 + + for msg in messages: + if isinstance(msg.get("content"), str): + # Simple text message + text_tokens += len(msg["content"]) // 4 + elif isinstance(msg.get("content"), list): + # Message with mixed content (text + image) + for content_part in msg["content"]: + if content_part.get("type") == "text": + text_tokens += len(content_part.get("text", "")) // 4 + elif content_part.get("type") == "image_url": + # Only count image tokens if visual context is enabled + if self.visual_context_enabled: + image_tokens += 258 + + estimated_tokens = text_tokens + image_tokens + + # Check token limit only if it's enabled + if self.input_token_limit is None: + self._log(f"{prefix} ๐Ÿ“Š Token estimate - Text: {text_tokens}, Images: {image_tokens} (Total: {estimated_tokens} / unlimited)") + else: + self._log(f"{prefix} ๐Ÿ“Š Token estimate - Text: {text_tokens}, Images: {image_tokens} (Total: {estimated_tokens} / {self.input_token_limit})") + + if estimated_tokens > self.input_token_limit: + self._log(f"โš ๏ธ Token limit exceeded, trimming context", "warning") + # Keep system prompt, image, and current text only + if image_path: + messages = [messages[0], messages[-1]] + else: + messages = [messages[0], {"role": "user", "content": text}] + # Recalculate tokens after trimming + text_tokens = len(messages[0]["content"]) // 4 + if isinstance(messages[-1].get("content"), str): + text_tokens += len(messages[-1]["content"]) // 4 + else: + text_tokens += len(messages[-1]["content"][0]["text"]) // 4 + estimated_tokens = text_tokens + image_tokens + self._log(f"๐Ÿ“Š Trimmed token estimate: {estimated_tokens}") + + start_time = time.time() + api_time = 0 # Initialize to avoid NameError + + try: + response = send_with_interrupt( + messages=messages, + client=self.client, + temperature=self.temperature, + max_tokens=self.max_tokens, + stop_check_fn=self._check_stop + + ) + api_time = time.time() - start_time + self._log(f"{prefix} โœ… API responded in {api_time:.2f} seconds") + + # Normalize response to plain text (handle tuples and bytes) + if hasattr(response, 'content'): + response_text = response.content + else: + response_text = response + + # Handle tuple response like (text, 'stop') from some clients + if isinstance(response_text, tuple): + response_text = response_text[0] + + # Decode bytes/bytearray + if isinstance(response_text, (bytes, bytearray)): + try: + response_text = response_text.decode('utf-8', errors='replace') + except Exception: + response_text = str(response_text) + + # Ensure string + if not isinstance(response_text, str): + response_text = str(response_text) + + response_text = response_text.strip() + + # If it's a stringified tuple like "('text', 'stop')", extract the first element + if response_text.startswith("('") or response_text.startswith('("'): + import ast, re + try: + parsed_tuple = ast.literal_eval(response_text) + if isinstance(parsed_tuple, tuple) and parsed_tuple: + response_text = str(parsed_tuple[0]) + self._log("๐Ÿ“ฆ Extracted response from tuple literal", "debug") + except Exception: + match = re.match(r"^\('(.+?)',\s*'.*'\)$", response_text, re.DOTALL) + if match: + tmp = match.group(1) + tmp = tmp.replace('\\n', '\n').replace("\\'", "'").replace('\\\"', '"').replace('\\\\', '\\') + response_text = tmp + self._log("๐Ÿ“ฆ Extracted response using regex from tuple literal", "debug") + + self._log(f"{prefix} ๐Ÿ“ฅ Received response ({len(response_text)} chars)") + + except Exception as api_error: + api_time = time.time() - start_time + error_str = str(api_error).lower() + error_type = type(api_error).__name__ + + # Check for specific error types + if "429" in error_str or "rate limit" in error_str: + self._log(f"โš ๏ธ RATE LIMIT ERROR (429) after {api_time:.2f}s", "error") + self._log(f" The API rate limit has been exceeded", "error") + self._log(f" Please wait before retrying or reduce request frequency", "error") + self._log(f" Error details: {str(api_error)}", "error") + raise Exception(f"Rate limit exceeded (429): {str(api_error)}") + + elif "401" in error_str or "unauthorized" in error_str: + self._log(f"โŒ AUTHENTICATION ERROR (401) after {api_time:.2f}s", "error") + self._log(f" Invalid API key or authentication failed", "error") + self._log(f" Please check your API key in settings", "error") + self._log(f" Error details: {str(api_error)}", "error") + raise Exception(f"Authentication failed (401): {str(api_error)}") + + elif "403" in error_str or "forbidden" in error_str: + self._log(f"โŒ FORBIDDEN ERROR (403) after {api_time:.2f}s", "error") + self._log(f" Access denied - check API permissions", "error") + self._log(f" Error details: {str(api_error)}", "error") + raise Exception(f"Access forbidden (403): {str(api_error)}") + + elif "400" in error_str or "bad request" in error_str: + self._log(f"โŒ BAD REQUEST ERROR (400) after {api_time:.2f}s", "error") + self._log(f" Invalid request format or parameters", "error") + self._log(f" Error details: {str(api_error)}", "error") + raise Exception(f"Bad request (400): {str(api_error)}") + + elif "timeout" in error_str: + self._log(f"โฑ๏ธ TIMEOUT ERROR after {api_time:.2f}s", "error") + self._log(f" API request timed out", "error") + self._log(f" Consider increasing timeout or retry", "error") + self._log(f" Error details: {str(api_error)}", "error") + raise Exception(f"Request timeout: {str(api_error)}") + + else: + # Generic API error + self._log(f"โŒ API ERROR ({error_type}) after {api_time:.2f}s", "error") + self._log(f" Error details: {str(api_error)}", "error") + self._log(f" Full traceback:", "error") + self._log(traceback.format_exc(), "error") + raise + + + + # Initialize translated with extracted response text to avoid UnboundLocalError + if response_text is None: + translated = "" + elif isinstance(response_text, str): + translated = response_text + elif isinstance(response_text, (bytes, bytearray)): + try: + translated = response_text.decode('utf-8', errors='replace') + except Exception: + translated = str(response_text) + else: + translated = str(response_text) + + # ADD THIS DEBUG CODE: + self._log(f"๐Ÿ” RAW API RESPONSE DEBUG:", "debug") + self._log(f" Type: {type(translated)}", "debug") + #self._log(f" Raw content length: {len(translated)}", "debug") + #self._log(f" First 200 chars: {translated[:200]}", "debug") + #self._log(f" Last 200 chars: {translated[-200:]}", "debug") + + # Check if both Japanese and English are present + has_japanese = any('\u3040' <= c <= '\u9fff' or '\uac00' <= c <= '\ud7af' for c in translated) + has_english = any('a' <= c.lower() <= 'z' for c in translated) + + if has_japanese and has_english: + self._log(f" โš ๏ธ WARNING: Response contains BOTH Japanese AND English!", "warning") + self._log(f" This might be causing the duplicate text issue", "warning") + + # Check if response looks like JSON (contains both { and } and : characters) + if '{' in translated and '}' in translated and ':' in translated: + try: + # It might be JSON, try to fix and parse it + fixed_json = self._fix_json_response(translated) + import json + parsed = json.loads(fixed_json) + + # If it's a dict with a single translation, extract it + if isinstance(parsed, dict) and len(parsed) == 1: + translated = list(parsed.values())[0] + translated = self._clean_translation_text(translated) + self._log("๐Ÿ“ฆ Extracted translation from JSON response", "debug") + except: + # Not JSON or failed to parse, use as-is + pass + + self._log(f"{prefix} ๐Ÿ” Raw response type: {type(translated)}") + self._log(f"{prefix} ๐Ÿ” Raw response content: '{translated[:5000]}...'") + + # Check if the response looks like a Python literal (tuple/string representation) + if translated.startswith("('") or translated.startswith('("') or translated.startswith("('''"): + self._log(f"โš ๏ธ Detected Python literal in response, attempting to extract actual text", "warning") + original = translated + try: + # Try to evaluate it as a Python literal + import ast + evaluated = ast.literal_eval(translated) + self._log(f"๐Ÿ“ฆ Evaluated type: {type(evaluated)}") + + if isinstance(evaluated, tuple): + # Take the first element of the tuple + translated = str(evaluated[0]) + self._log(f"๐Ÿ“ฆ Extracted from tuple: '{translated[:50]}...'") + elif isinstance(evaluated, str): + translated = evaluated + self._log(f"๐Ÿ“ฆ Extracted string: '{translated[:50]}...'") + else: + self._log(f"โš ๏ธ Unexpected type after eval: {type(evaluated)}", "warning") + + except Exception as e: + self._log(f"โš ๏ธ Failed to parse Python literal: {e}", "warning") + self._log(f"โš ๏ธ Original content: {original[:200]}", "warning") + + # Try multiple levels of unescaping + temp = translated + for i in range(5): # Try up to 5 levels of unescaping + if temp.startswith("('") or temp.startswith('("'): + # Try regex as fallback + import re + match = re.search(r"^\(['\"](.+)['\"]\)$", temp, re.DOTALL) + if match: + temp = match.group(1) + self._log(f"๐Ÿ“ฆ Regex extracted (level {i+1}): '{temp[:50]}...'") + else: + break + else: + break + translated = temp + + # Additional check for escaped content + #if '\\\\' in translated or '\\n' in translated or "\\'" in translated or '\\"' in translated: + # self._log(f"โš ๏ธ Detected escaped content, unescaping...", "warning") + # try: + # before = translated + # + # # Handle quotes and apostrophes + # translated = translated.replace("\\'", "'") + # translated = translated.replace('\\"', '"') + # translated = translated.replace("\\`", "`") + + # DON'T UNESCAPE NEWLINES BEFORE JSON PARSING! + # translated = translated.replace('\\n', '\n') # COMMENT THIS OUT + + # translated = translated.replace('\\\\', '\\') + # translated = translated.replace('\\/', '/') + # translated = translated.replace('\\t', '\t') # COMMENT THIS OUT TOO + # translated = translated.replace('\\r', '\r') # AND THIS + + # self._log(f"๐Ÿ“ฆ Unescaped safely: '{before[:50]}...' -> '{translated[:50]}...'") + # except Exception as e: + # self._log(f"โš ๏ธ Failed to unescape: {e}", "warning") + + # Clean up unwanted trailing apostrophes/quotes + import re + response_text = translated + response_text = re.sub(r"['''\"`]$", "", response_text.strip()) # Remove trailing + response_text = re.sub(r"^['''\"`]", "", response_text.strip()) # Remove leading + response_text = re.sub(r"\s+['''\"`]\s+", " ", response_text) # Remove isolated + translated = response_text + translated = self._clean_translation_text(translated) + + # Apply glossary if available + if hasattr(self.main_gui, 'manual_glossary') and self.main_gui.manual_glossary: + glossary_count = len(self.main_gui.manual_glossary) + self._log(f"๐Ÿ“š Applying glossary with {glossary_count} entries") + + replacements = 0 + for entry in self.main_gui.manual_glossary: + if 'source' in entry and 'target' in entry: + if entry['source'] in translated: + translated = translated.replace(entry['source'], entry['target']) + replacements += 1 + + if replacements > 0: + self._log(f" โœ๏ธ Made {replacements} glossary replacements") + + translated = self._clean_translation_text(translated) + + # Store in history if HistoryManager is available + if self.history_manager and self.contextual_enabled: + try: + # Append to history with proper limit handling + self.history_manager.append_to_history( + user_content=text, + assistant_content=translated, + hist_limit=self.translation_history_limit, + reset_on_limit=not self.rolling_history_enabled, + rolling_window=self.rolling_history_enabled + ) + + # Check if we're about to hit the limit + if self.history_manager.will_reset_on_next_append( + self.translation_history_limit, + self.rolling_history_enabled + ): + mode = "roll over" if self.rolling_history_enabled else "reset" + self._log(f"๐Ÿ“š History will {mode} on next translation (at limit: {self.translation_history_limit})") + + except Exception as e: + self._log(f"โš ๏ธ Failed to save to history: {str(e)}", "warning") + + # Also store in legacy context for compatibility + self.translation_context.append({ + "original": text, + "translated": translated + }) + + return translated + + except Exception as e: + self._log(f"โŒ Translation error: {str(e)}", "error") + self._log(f" Error type: {type(e).__name__}", "error") + import traceback + self._log(f" Traceback: {traceback.format_exc()}", "error") + return text + + def translate_full_page_context(self, regions: List[TextRegion], image_path: str, _in_fallback=False) -> Dict[str, str]: + """Translate all text regions with full page context in a single request + + Args: + regions: List of text regions to translate + image_path: Path to the manga page image + _in_fallback: Internal flag to prevent infinite recursion during fallback attempts + """ + try: + import time + import traceback + import json + + # Initialize response_text at the start + response_text = "" + + self._log(f"\n๐Ÿ“„ Full page context translation of {len(regions)} text regions") + + # Get system prompt from GUI profile + profile_name = self.main_gui.profile_var.get() + + # Ensure visual_context_enabled exists (temporary fix) + if not hasattr(self, 'visual_context_enabled'): + self.visual_context_enabled = self.main_gui.config.get('manga_visual_context_enabled', True) + + # Try to get the prompt from prompt_profiles dictionary (for all profiles including custom ones) + system_prompt = '' + if hasattr(self.main_gui, 'prompt_profiles') and profile_name in self.main_gui.prompt_profiles: + system_prompt = self.main_gui.prompt_profiles[profile_name] + self._log(f"๐Ÿ“‹ Using profile: {profile_name}") + else: + # Fallback to check if it's stored as a direct attribute (legacy support) + system_prompt = getattr(self.main_gui, profile_name.replace(' ', '_'), '') + if system_prompt: + self._log(f"๐Ÿ“‹ Using profile (legacy): {profile_name}") + else: + self._log(f"โš ๏ธ Profile '{profile_name}' not found, using empty prompt", "warning") + + # Combine with full page context instructions + if system_prompt: + system_prompt = f"{system_prompt}\n\n{self.full_page_context_prompt}" + else: + system_prompt = self.full_page_context_prompt + + messages = [{"role": "system", "content": system_prompt}] + + # CHECK 2: Before adding context + if self._check_stop(): + self._log("โน๏ธ Translation stopped during context preparation", "warning") + return {} + + # Add contextual translations if enabled + if self.contextual_enabled and self.history_manager: + history_context = self._get_translation_history_context() + if history_context: + context_count = len(history_context) // 2 + self._log(f"๐Ÿ”— Adding {context_count} previous exchanges from history") + messages.extend(history_context) + + # Prepare text segments with indices + all_texts = {} + text_list = [] + for i, region in enumerate(regions): + # Use index-based key to handle duplicate texts + # CRITICAL: Normalize whitespace and newlines for consistent key matching + # The API might normalize "\n\n" to spaces, so we need to do the same + normalized_text = ' '.join(region.text.split()) + key = f"[{i}] {normalized_text}" + all_texts[key] = region.text + text_list.append(f"[{i}] {region.text}") # Send original with newlines to API + + # CHECK 3: Before image processing + if self._check_stop(): + self._log("โน๏ธ Translation stopped before image processing", "warning") + return {} + + # Create the full context message text + context_text = "\n".join(text_list) + + # Log text content info + total_chars = sum(len(region.text) for region in regions) + self._log(f"๐Ÿ“ Text content: {len(regions)} regions, {total_chars} total characters") + + # Process image if visual context is enabled + if self.visual_context_enabled: + try: + import base64 + from PIL import Image as PILImage + + self._log(f"๐Ÿ“ท Adding full page visual context for translation") + + # Read and encode the image + with open(image_path, 'rb') as img_file: + img_data = img_file.read() + + # Check image size + img_size_mb = len(img_data) / (1024 * 1024) + self._log(f"๐Ÿ“Š Image size: {img_size_mb:.2f} MB") + + # Get image dimensions + pil_image = PILImage.open(image_path) + self._log(f" Image dimensions: {pil_image.width}x{pil_image.height}") + + # CHECK 4: Before resizing (which can take time) + if self._check_stop(): + self._log("โน๏ธ Translation stopped during image preparation", "warning") + return {} + + # Resize if needed + if img_size_mb > 10: + self._log(f"๐Ÿ“‰ Resizing large image for API limits...") + max_size = 2048 + ratio = min(max_size / pil_image.width, max_size / pil_image.height) + if ratio < 1: + new_size = (int(pil_image.width * ratio), int(pil_image.height * ratio)) + pil_image = pil_image.resize(new_size, PILImage.Resampling.LANCZOS) + from io import BytesIO + buffered = BytesIO() + pil_image.save(buffered, format="PNG", optimize=True) + img_data = buffered.getvalue() + self._log(f"โœ… Resized to {new_size[0]}x{new_size[1]}px ({len(img_data)/(1024*1024):.2f} MB)") + + # Convert to base64 + img_b64 = base64.b64encode(img_data).decode('utf-8') + + # Create message with both text and image + messages.append({ + "role": "user", + "content": [ + {"type": "text", "text": context_text}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_b64}"}} + ] + }) + + self._log(f"โœ… Added full page image as visual context") + + except Exception as e: + self._log(f"โš ๏ธ Failed to add image context: {str(e)}", "warning") + self._log(f" Error type: {type(e).__name__}", "warning") + import traceback + self._log(traceback.format_exc(), "warning") + self._log(f" Falling back to text-only translation", "warning") + + # Fall back to text-only translation + messages.append({"role": "user", "content": context_text}) + else: + # Visual context disabled - send text only + self._log(f"๐Ÿ“ Text-only mode (visual context disabled for non-vision models)") + messages.append({"role": "user", "content": context_text}) + + # CHECK 5: Before API call + if self._check_stop(): + self._log("โน๏ธ Translation stopped before API call", "warning") + return {} + + # Store original model for fallback + original_model = self.client.model if hasattr(self.client, 'model') else None + + # Check input token limit + text_tokens = 0 + image_tokens = 0 + + for msg in messages: + if isinstance(msg.get("content"), str): + # Simple text message + text_tokens += len(msg["content"]) // 4 + elif isinstance(msg.get("content"), list): + # Message with mixed content (text + image) + for content_part in msg["content"]: + if content_part.get("type") == "text": + text_tokens += len(content_part.get("text", "")) // 4 + elif content_part.get("type") == "image_url": + # Only count image tokens if visual context is enabled + if self.visual_context_enabled: + image_tokens += 258 + + estimated_tokens = text_tokens + image_tokens + + # Check token limit only if it's enabled + if self.input_token_limit is None: + self._log(f"๐Ÿ“Š Token estimate - Text: {text_tokens}, Images: {image_tokens} (Total: {estimated_tokens} / unlimited)") + else: + self._log(f"๐Ÿ“Š Token estimate - Text: {text_tokens}, Images: {image_tokens} (Total: {estimated_tokens} / {self.input_token_limit})") + + if estimated_tokens > self.input_token_limit: + self._log(f"โš ๏ธ Token limit exceeded, trimming context", "warning") + # Keep system prompt and current message only + messages = [messages[0], messages[-1]] + # Recalculate tokens + text_tokens = len(messages[0]["content"]) // 4 + if isinstance(messages[-1]["content"], str): + text_tokens += len(messages[-1]["content"]) // 4 + else: + for content_part in messages[-1]["content"]: + if content_part.get("type") == "text": + text_tokens += len(content_part.get("text", "")) // 4 + estimated_tokens = text_tokens + image_tokens + self._log(f"๐Ÿ“Š Trimmed token estimate: {estimated_tokens}") + + # Make API call using the client's send method (matching translate_text) + self._log(f"๐ŸŒ Sending full page context to API...") + self._log(f" API Model: {self.client.model if hasattr(self.client, 'model') else 'unknown'}") + self._log(f" Temperature: {self.temperature}") + self._log(f" Max Output Tokens: {self.max_tokens}") + + start_time = time.time() + api_time = 0 # Initialize to avoid NameError + + try: + response = send_with_interrupt( + messages=messages, + client=self.client, + temperature=self.temperature, + max_tokens=self.max_tokens, + stop_check_fn=self._check_stop + ) + api_time = time.time() - start_time + + # Extract content from response + if hasattr(response, 'content'): + response_text = response.content + # Check if it's a tuple representation + if isinstance(response_text, tuple): + response_text = response_text[0] # Get first element of tuple + response_text = response_text.strip() + elif hasattr(response, 'text'): + # Gemini responses have .text attribute + response_text = response.text.strip() + elif hasattr(response, 'candidates') and response.candidates: + # Handle Gemini GenerateContentResponse structure + try: + response_text = response.candidates[0].content.parts[0].text.strip() + except (IndexError, AttributeError): + response_text = str(response).strip() + else: + # If response is a string or other format + response_text = str(response).strip() + + # Check if it's a stringified tuple + if response_text.startswith("('") or response_text.startswith('("'): + # It's a tuple converted to string, extract the JSON part + import ast + try: + parsed_tuple = ast.literal_eval(response_text) + if isinstance(parsed_tuple, tuple): + response_text = parsed_tuple[0] # Get first element + self._log("๐Ÿ“ฆ Extracted response from tuple format", "debug") + except: + # If literal_eval fails, try regex + import re + match = re.match(r"^\('(.+)', '.*'\)$", response_text, re.DOTALL) + if match: + response_text = match.group(1) + # Unescape the string + response_text = response_text.replace('\\n', '\n') + response_text = response_text.replace("\\'", "'") + response_text = response_text.replace('\\"', '"') + response_text = response_text.replace('\\\\', '\\') + self._log("๐Ÿ“ฆ Extracted response using regex from tuple string", "debug") + + # CHECK 6: Immediately after API response + if self._check_stop(): + self._log(f"โน๏ธ Translation stopped after API call ({api_time:.2f}s)", "warning") + return {} + + self._log(f"โœ… API responded in {api_time:.2f} seconds") + self._log(f"๐Ÿ“ฅ Received response ({len(response_text)} chars)") + + except Exception as api_error: + api_time = time.time() - start_time + + # CHECK 7: After API error + if self._check_stop(): + self._log(f"โน๏ธ Translation stopped during API error handling", "warning") + return {} + + error_str = str(api_error).lower() + error_type = type(api_error).__name__ + + # Check for specific error types + if "429" in error_str or "rate limit" in error_str: + self._log(f"โš ๏ธ RATE LIMIT ERROR (429) after {api_time:.2f}s", "error") + self._log(f" The API rate limit has been exceeded", "error") + self._log(f" Please wait before retrying or reduce request frequency", "error") + self._log(f" Error details: {str(api_error)}", "error") + raise Exception(f"Rate limit exceeded (429): {str(api_error)}") + + elif "401" in error_str or "unauthorized" in error_str: + self._log(f"โŒ AUTHENTICATION ERROR (401) after {api_time:.2f}s", "error") + self._log(f" Invalid API key or authentication failed", "error") + self._log(f" Please check your API key in settings", "error") + self._log(f" Error details: {str(api_error)}", "error") + raise Exception(f"Authentication failed (401): {str(api_error)}") + + elif "403" in error_str or "forbidden" in error_str: + self._log(f"โŒ FORBIDDEN ERROR (403) after {api_time:.2f}s", "error") + self._log(f" Access denied - check API permissions", "error") + self._log(f" Error details: {str(api_error)}", "error") + raise Exception(f"Access forbidden (403): {str(api_error)}") + + elif "400" in error_str or "bad request" in error_str: + self._log(f"โŒ BAD REQUEST ERROR (400) after {api_time:.2f}s", "error") + self._log(f" Invalid request format or parameters", "error") + self._log(f" Error details: {str(api_error)}", "error") + raise Exception(f"Bad request (400): {str(api_error)}") + + elif "timeout" in error_str: + self._log(f"โฑ๏ธ TIMEOUT ERROR after {api_time:.2f}s", "error") + self._log(f" API request timed out", "error") + self._log(f" Consider increasing timeout or retry", "error") + self._log(f" Error details: {str(api_error)}", "error") + raise Exception(f"Request timeout: {str(api_error)}") + + else: + # Generic API error + self._log(f"โŒ API ERROR ({error_type}) after {api_time:.2f}s", "error") + self._log(f" Error details: {str(api_error)}", "error") + self._log(f" Full traceback:", "error") + self._log(traceback.format_exc(), "error") + raise + + # CHECK 8: Before parsing response + if self._check_stop(): + self._log("โน๏ธ Translation stopped before parsing response", "warning") + return {} + + # Check if we got a response + if not response_text: + self._log("โŒ Empty response from API", "error") + return {} + + self._log(f"๐Ÿ” Raw response type: {type(response_text)}") + self._log(f"๐Ÿ” Raw response preview: '{response_text[:2000]}...'") + + # Clean up response_text (handle Python literals, escapes, etc.) + if response_text.startswith("('") or response_text.startswith('("') or response_text.startswith("('''"): + self._log(f"โš ๏ธ Detected Python literal in response, attempting to extract actual text", "warning") + try: + import ast + evaluated = ast.literal_eval(response_text) + if isinstance(evaluated, tuple): + response_text = str(evaluated[0]) + elif isinstance(evaluated, str): + response_text = evaluated + except Exception as e: + self._log(f"โš ๏ธ Failed to parse Python literal: {e}", "warning") + + # Handle escaped content + #if '\\\\' in response_text or '\\n' in response_text or "\\'" in response_text or '\\"' in response_text: + # self._log(f"โš ๏ธ Detected escaped content, unescaping...", "warning") + # response_text = response_text.replace("\\'", "'") + # response_text = response_text.replace('\\"', '"') + # response_text = response_text.replace('\\n', '\n') + # response_text = response_text.replace('\\\\', '\\') + # response_text = response_text.replace('\\/', '/') + # response_text = response_text.replace('\\t', '\t') + # response_text = response_text.replace('\\r', '\r') + + # Clean up quotes + import re + response_text = re.sub(r"['''\"`]$", "", response_text.strip()) + response_text = re.sub(r"^['''\"`]", "", response_text.strip()) + response_text = re.sub(r"\s+['''\"`]\s+", " ", response_text) + + # Try to parse as JSON + translations = {} + try: + # Strip markdown blocks more aggressively + import re + import json + + # CRITICAL: Strip markdown code blocks FIRST, before attempting JSON extraction + cleaned = response_text + + # Remove markdown code blocks (handles ```json, ``json, ```, ``, etc.) + if '```' in cleaned or '``' in cleaned: + patterns = [ + r'```json\s*\n?(.*?)```', + r'``json\s*\n?(.*?)``', + r'```\s*\n?(.*?)```', + r'``\s*\n?(.*?)``' + ] + + for pattern in patterns: + match = re.search(pattern, cleaned, re.DOTALL) + if match: + cleaned = match.group(1).strip() + self._log(f"๐Ÿ”ง Stripped markdown wrapper using pattern: {pattern[:20]}...") + break + + # Method 1: Try to parse the cleaned text directly + try: + translations = json.loads(cleaned) + self._log(f"โœ… Successfully parsed {len(translations)} translations (direct parse)") + except json.JSONDecodeError: + # Method 2: Extract JSON object if direct parse failed + json_match = re.search(r'\{.*\}', cleaned, re.DOTALL) + if json_match: + json_text = json_match.group(0) + try: + translations = json.loads(json_text) + self._log(f"โœ… Successfully parsed {len(translations)} translations (regex extraction)") + except json.JSONDecodeError: + # Try to fix the extracted JSON + json_text = self._fix_json_response(json_text) + translations = json.loads(json_text) + self._log(f"โœ… Successfully parsed {len(translations)} translations (after fix)") + else: + # No JSON object found + raise json.JSONDecodeError("No JSON object found", cleaned, 0) + + # Handle different response formats + if isinstance(translations, list): + # Array of translations only - map by position + temp = {} + for i, region in enumerate(regions): + if i < len(translations): + temp[region.text] = translations[i] + translations = temp + + self._log(f"๐Ÿ“Š Total translations: {len(translations)}") + + except Exception as e: + self._log(f"โŒ Failed to parse JSON: {str(e)}", "error") + self._log(f"Response preview: {response_text[:5000]}...", "warning") + + # CRITICAL: Check if this is a refusal message BEFORE regex fallback + # OpenAI and other APIs refuse certain content with text responses instead of JSON + # ONLY check if response looks like plain text refusal (not malformed JSON with translations) + import re + response_lower = response_text.lower() + + # Quick check: if response starts with refusal keywords, it's definitely a refusal + refusal_starts = ['sorry', 'i cannot', "i can't", 'i apologize', 'i am unable', "i'm unable"] + if any(response_lower.strip().startswith(start) for start in refusal_starts): + # Very likely a refusal - raise immediately + from unified_api_client import UnifiedClientError + raise UnifiedClientError( + f"Content refused by API", + error_type="prohibited_content", + details={"refusal_message": response_text[:500]} + ) + + # Skip refusal check if response contains valid-looking JSON structure with translations + # (indicates malformed JSON that should go to regex fallback, not a refusal) + has_json_structure = ( + (response_text.strip().startswith('{') and ':' in response_text and '"' in response_text) or + (response_text.strip().startswith('[') and ':' in response_text and '"' in response_text) + ) + + # Also check if response contains short translations (not refusal paragraphs) + # Refusals are typically long paragraphs, translations are short + avg_value_length = 0 + if has_json_structure: + # Quick estimate: count chars between quotes + import re + values = re.findall(r'"([^"]{1,200})"\s*[,}]', response_text) + if values: + avg_value_length = sum(len(v) for v in values) / len(values) + + # If looks like JSON with short values, skip refusal check (go to regex fallback) + if has_json_structure and avg_value_length > 0 and avg_value_length < 150: + self._log(f"๐Ÿ” Detected malformed JSON with translations (avg len: {avg_value_length:.0f}), trying regex fallback", "debug") + # Skip refusal detection, go straight to regex fallback + pass + else: + # Check for refusal patterns + # Refusal patterns - both simple strings and regex patterns + # Must be strict to avoid false positives on valid translations + refusal_patterns = [ + "i cannot assist", + "i can't assist", + "i cannot help", + "i can't help", + r"sorry.{0,10}i can't (assist|help|translate)", # OpenAI specific + "i'm unable to translate", + "i am unable to translate", + "i apologize, but i cannot", + "i'm sorry, but i cannot", + "i don't have the ability to", + "this request cannot be", + "unable to process this", + "cannot complete this", + r"against.{0,20}(content )?policy", # "against policy" or "against content policy" + "violates.*policy", + r"(can't|cannot).{0,30}(sexual|explicit|inappropriate)", # "can't translate sexual" + "appears to sexualize", + "who appear to be", + "prohibited content", + "content blocked", + ] + + # Check both simple string matching and regex patterns + is_refusal = False + for pattern in refusal_patterns: + if '.*' in pattern or r'.{' in pattern: + # It's a regex pattern + if re.search(pattern, response_lower): + is_refusal = True + break + else: + # Simple string match + if pattern in response_lower: + is_refusal = True + break + + if is_refusal: + # Raise UnifiedClientError with prohibited_content type + # Fallback mechanism will handle this automatically + from unified_api_client import UnifiedClientError + raise UnifiedClientError( + f"Content refused by API", + error_type="prohibited_content", + details={"refusal_message": response_text[:500]} + ) + + # Fallback: try regex extraction (handles both quoted and unquoted keys) + try: + import re + translations = {} + + # Try 1: Standard quoted keys and values + pattern1 = r'"([^"]+)"\s*:\s*"([^"]*(?:\\.[^"]*)*)"' + matches = re.findall(pattern1, response_text) + + if matches: + for key, value in matches: + value = value.replace('\\n', '\n').replace('\\"', '"').replace('\\\\', '\\') + translations[key] = value + self._log(f"โœ… Recovered {len(translations)} translations using regex (quoted keys)") + else: + # Try 2: Unquoted keys (for invalid JSON like: key: "value") + pattern2 = r'([^\s:{}]+)\s*:\s*([^\n}]+)' + matches = re.findall(pattern2, response_text) + + for key, value in matches: + # Clean up key and value + key = key.strip() + value = value.strip().rstrip(',') + # Remove quotes from value if present + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + elif value.startswith("'") and value.endswith("'"): + value = value[1:-1] + translations[key] = value + + if translations: + self._log(f"โœ… Recovered {len(translations)} translations using regex (unquoted keys)") + + if not translations: + self._log("โŒ All parsing attempts failed", "error") + return {} + except Exception as e: + self._log(f"โŒ Failed to recover JSON: {e}", "error") + return {} + + # Map translations back to regions + result = {} + all_originals = [] + all_translations = [] + + # Extract translation values in order + translation_values = list(translations.values()) if translations else [] + + # DEBUG: Log what we extracted + self._log(f"๐Ÿ“Š Extracted {len(translation_values)} translation values", "debug") + for i, val in enumerate(translation_values[:1000]): # First 1000 for debugging + # Safely handle None values + val_str = str(val) if val is not None else "" + self._log(f" Translation {i}: '{val_str[:1000]}...'", "debug") + + # Clean all translation values to remove quotes + # CRITICAL: Also clean the keys in the dictionary to maintain correct mapping + # CRITICAL FIX: Always keep the key even if value becomes empty after cleaning + # This prevents misalignment between detected regions and API translations + cleaned_translations = {} + for key, value in translations.items(): + cleaned_key = key + cleaned_value = self._clean_translation_text(value) + # ALWAYS add the key to maintain alignment, even if value is empty + cleaned_translations[cleaned_key] = cleaned_value + if not cleaned_value: + self._log(f"๐Ÿ” Keeping empty translation to maintain alignment: '{key}' โ†’ '' (original: '{value}')", "debug") + + # Replace original dict with cleaned version + translations = cleaned_translations + translation_values = list(translations.values()) if translations else [] + + self._log(f"๐Ÿ” DEBUG: translation_values after cleaning:", "debug") + for i, val in enumerate(translation_values): + self._log(f" [{i}]: {repr(val)}", "debug") + + # CRITICAL: Check if translation values are actually refusal messages + # API sometimes returns valid JSON where each "translation" is a refusal + if translation_values: + # Check first few translations for refusal patterns + import re + refusal_patterns = [ + "i cannot", + "i can't", + r"sorry.{0,5}i can't help", + r"sorry.{0,5}i can't", + "sexually explicit", + "content policy", + "prohibited content", + "appears to be", + "who appear to be", + ] + + # Sample first 3 translations (or all if fewer) + sample_size = min(3, len(translation_values)) + refusal_count = 0 + + for sample_val in translation_values[:sample_size]: + if sample_val: + val_lower = sample_val.lower() + for pattern in refusal_patterns: + if '.*' in pattern or r'.{' in pattern: + if re.search(pattern, val_lower): + refusal_count += 1 + break + else: + if pattern in val_lower: + refusal_count += 1 + break + + # If most translations are refusals, treat as refusal + if refusal_count >= sample_size * 0.5: # 50% threshold + # Raise UnifiedClientError with prohibited_content type + # Fallback mechanism will handle this automatically + from unified_api_client import UnifiedClientError + raise UnifiedClientError( + f"Content refused by API", + error_type="prohibited_content", + details={"refusal_message": translation_values[0][:500]} + ) + + # Key-based mapping (prioritize indexed format as requested in prompt) + self._log(f"๐Ÿ“‹ Mapping {len(translations)} translations to {len(regions)} regions") + + # DEBUG: Log all translation keys for inspection + self._log(f"๐Ÿ” Available translation keys:", "debug") + for key in list(translations.keys())[:20]: # Show first 20 + self._log(f" '{key}'", "debug") + + for i, region in enumerate(regions): + if i % 10 == 0 and self._check_stop(): + self._log(f"โน๏ธ Translation stopped during mapping (processed {i}/{len(regions)} regions)", "warning") + return result + + # Get translation using multiple strategies (indexed format is most reliable) + translated = "" + + # CRITICAL: Normalize whitespace in region text for key matching + # API might normalize newlines to spaces, so we match against normalized keys + normalized_region_text = ' '.join(region.text.split()) + + # Strategy 1: Indexed key format "[N] original_text" (NEW STANDARD - most reliable) + # Try both normalized and original keys + key = f"[{i}] {region.text}" + key_normalized = f"[{i}] {normalized_region_text}" + + # DEBUG: Log the keys we're trying + self._log(f" ๐Ÿ”Ž Region {i}: '{region.text[:30]}...'", "debug") + self._log(f" Original key: '{key[:50]}...'", "debug") + self._log(f" Normalized key: '{key_normalized[:50]}...'", "debug") + + if key in translations: + translated = translations[key] + self._log(f" โœ… Matched indexed key: '{key[:40]}...'", "debug") + elif key_normalized in translations: + translated = translations[key_normalized] + self._log(f" โœ… Matched normalized indexed key: '{key_normalized[:40]}...'", "debug") + # Strategy 2: Direct key match without index (backward compatibility) + elif region.text in translations: + translated = translations[region.text] + self._log(f" โœ… Matched direct key: '{region.text[:40]}...'", "debug") + elif normalized_region_text in translations: + translated = translations[normalized_region_text] + self._log(f" โœ… Matched normalized direct key: '{normalized_region_text[:40]}...'", "debug") + # Strategy 3: Position-based fallback (least reliable, only if counts match exactly) + elif i < len(translation_values) and len(translation_values) == len(regions): + translated = translation_values[i] + self._log(f" โš ๏ธ Using position-based fallback for region {i}", "debug") + + # Only mark as missing if we genuinely have no translation + # NOTE: Keep translation even if it matches original (e.g., numbers, names, SFX) + if not translated: + self._log(f" โš ๏ธ No translation for region {i}, leaving empty", "warning") + translated = "" + + # Apply glossary if we have a translation + if translated and hasattr(self.main_gui, 'manual_glossary') and self.main_gui.manual_glossary: + for entry in self.main_gui.manual_glossary: + if 'source' in entry and 'target' in entry: + if entry['source'] in translated: + translated = translated.replace(entry['source'], entry['target']) + + result[region.text] = translated + region.translated_text = translated + + if translated: + all_originals.append(f"[{i+1}] {region.text}") + all_translations.append(f"[{i+1}] {translated}") + self._log(f" โœ… Translated: '{region.text[:30]}...' โ†’ '{translated[:30]}...'", "debug") + + # Save history if enabled + if self.history_manager and self.contextual_enabled and all_originals: + try: + combined_original = "\n".join(all_originals) + combined_translation = "\n".join(all_translations) + + self.history_manager.append_to_history( + user_content=combined_original, + assistant_content=combined_translation, + hist_limit=self.translation_history_limit, + reset_on_limit=not self.rolling_history_enabled, + rolling_window=self.rolling_history_enabled + ) + + self._log(f"๐Ÿ“š Saved {len(all_originals)} translations as 1 combined history entry", "success") + except Exception as e: + self._log(f"โš ๏ธ Failed to save page to history: {str(e)}", "warning") + + return result + + except Exception as e: + if self._check_stop(): + self._log("โน๏ธ Translation stopped due to user request", "warning") + return {} + + # Check if this is a prohibited_content error + from unified_api_client import UnifiedClientError + if isinstance(e, UnifiedClientError) and getattr(e, "error_type", None) == "prohibited_content": + # Check if USE_FALLBACK_KEYS is enabled and we're not already in a fallback attempt + use_fallback = os.getenv('USE_FALLBACK_KEYS', '0') == '1' + + if use_fallback and not _in_fallback: + self._log(f"โ›” Content refused by primary model, trying fallback keys...", "warning") + + # Store original credentials to restore after fallback attempts + original_api_key = self.client.api_key + original_model = self.client.model + + # Try to get fallback keys from environment + try: + fallback_keys_json = os.getenv('FALLBACK_KEYS', '[]') + fallback_keys = json.loads(fallback_keys_json) if fallback_keys_json != '[]' else [] + + if fallback_keys: + for idx, fallback in enumerate(fallback_keys, 1): + if self._check_stop(): + self._log("โน๏ธ Translation stopped during fallback", "warning") + return {} + + fallback_model = fallback.get('model') + fallback_key = fallback.get('api_key') + + if not fallback_model or not fallback_key: + continue + + self._log(f"๐Ÿ”„ Trying fallback {idx}/{len(fallback_keys)}: {fallback_model}", "info") + + try: + # Temporarily switch to fallback model + old_key = self.client.api_key + old_model = self.client.model + + self.client.api_key = fallback_key + self.client.model = fallback_model + + # Re-setup client with new credentials + if hasattr(self.client, '_setup_client'): + self.client._setup_client() + + # Retry the translation with fallback model (mark as in_fallback to prevent recursion) + return self.translate_full_page_context(regions, image_path, _in_fallback=True) + + except UnifiedClientError as fallback_err: + if getattr(fallback_err, "error_type", None) == "prohibited_content": + self._log(f" โ›” Fallback {idx} also refused", "warning") + # Restore original credentials and try next fallback + self.client.api_key = old_key + self.client.model = old_model + if hasattr(self.client, '_setup_client'): + self.client._setup_client() + continue + else: + # Other error, restore and raise + self.client.api_key = old_key + self.client.model = old_model + if hasattr(self.client, '_setup_client'): + self.client._setup_client() + raise + except Exception as fallback_err: + self._log(f" โŒ Fallback {idx} error: {str(fallback_err)[:100]}", "error") + # Restore original credentials and try next fallback + self.client.api_key = old_key + self.client.model = old_model + if hasattr(self.client, '_setup_client'): + self.client._setup_client() + continue + + self._log(f"โŒ All fallback keys refused content", "error") + else: + self._log(f"โš ๏ธ No fallback keys configured", "warning") + except Exception as fallback_error: + self._log(f"โŒ Error processing fallback keys: {str(fallback_error)}", "error") + finally: + # Always restore original credentials after fallback attempts + try: + self.client.api_key = original_api_key + self.client.model = original_model + if hasattr(self.client, '_setup_client'): + self.client._setup_client() + except Exception: + pass # Ignore errors during credential restoration + + # If we get here, all fallbacks failed or weren't configured + self._log(f"โŒ Content refused by API", "error") + return {} + + self._log(f"โŒ Full page context translation error: {str(e)}", "error") + self._log(traceback.format_exc(), "error") + return {} + + def _fix_json_response(self, response_text: str) -> str: + import re + import json + + # Debug: Show what we received + self._log(f"DEBUG: Original length: {len(response_text)}", "debug") + self._log(f"DEBUG: First 50 chars: [{response_text[:50]}]", "debug") + + cleaned = response_text + if "```json" in cleaned: + match = re.search(r'```json\s*(.*?)```', cleaned, re.DOTALL) + if match: + cleaned = match.group(1).strip() + self._log(f"DEBUG: Extracted {len(cleaned)} chars from markdown", "debug") + else: + self._log("DEBUG: Regex didn't match!", "warning") + + # Try to parse + try: + result = json.loads(cleaned) + self._log(f"โœ… Parsed JSON with {len(result)} entries", "info") + return cleaned + except json.JSONDecodeError as e: + self._log(f"โš ๏ธ JSON invalid: {str(e)}", "warning") + self._log(f"DEBUG: Cleaned text starts with: [{cleaned[:20]}]", "debug") + return cleaned + + def _clean_translation_text(self, text: str) -> str: + """Remove unnecessary quotation marks, dots, and invalid characters from translated text""" + if not text: + return text + + # Log what we're cleaning + original = text + + # First, fix encoding issues + text = self._fix_encoding_issues(text) + + # Normalize width/compatibility (e.g., fullwidth โ†’ ASCII, circled numbers โ†’ digits) + text = self._normalize_unicode_width(text) + + # Remove Unicode replacement characters and invalid symbols + text = self._sanitize_unicode_characters(text) + + # Remove leading and trailing whitespace + text = text.strip() + + # CRITICAL: If the text is ONLY punctuation (dots, ellipsis, exclamations, etc.), + # don't clean it at all - these are valid sound effects/reactions in manga + # This includes: . ! ? โ€ฆ ~ โ™ก โ™ฅ โ˜… โ˜† ยท โ€ข ใƒป and whitespace + # Also preserve sequences like '. . .' or '...' with or without spaces + import re + if re.match(r'^[\\.!?โ€ฆ~โ™กโ™ฅโ˜…โ˜†ยทโ€ขใƒปใ€ใ€‚๏ผŒ๏ผ๏ผŸ\\s]+$', text): + self._log(f"๐ŸŽฏ Preserving punctuation-only text: '{text}'", "debug") + return text + + # Remove quotes from start/end but PRESERVE CJK quotation marks + # CJK quotation marks (ใ€Œใ€ใ€Žใ€ใ€ใ€‘ใ€Šใ€‹ใ€ˆใ€‰) are now rendered with Meiryo font + # Only strip Western quotes that don't render well + while len(text) > 0: + old_len = len(text) + + # Remove ONLY Western-style quotes from start/end + # Preserve CJK quotation marks for proper Meiryo rendering + text = text.lstrip('"\'`โ€˜โ€™โ€œโ€') + text = text.rstrip('"\'`โ€˜โ€™โ€œโ€') + + # If nothing changed, we're done + if len(text) == old_len: + break + + # Final strip + text = text.strip() + + # Log if we made changes + if text != original: + self._log(f"๐Ÿงน Cleaned text: '{original}' โ†’ '{text}'", "debug") + + return text + + def _sanitize_unicode_characters(self, text: str) -> str: + """Remove invalid Unicode characters and replacement characters. + UPDATED: Now preserves symbols that can be rendered with Meiryo mixed font. + Only removes truly invalid characters and box-drawing that cause rendering issues. + """ + if not text: + return text + + import re + import unicodedata + original = text + + # Remove Unicode replacement character (๏ฟฝ) - truly invalid + text = text.replace('\ufffd', '') # Unicode replacement character + + # IMPORTANT: DO NOT remove geometric symbols that Meiryo can render! + # The old code removed ALL symbols in \u25A0-\u25FF range. + # Now we only remove specific problematic box-drawing characters. + + # Only remove box-drawing characters that cause actual rendering problems + # These are the box-drawing and block elements ranges (NOT symbols) + text = re.sub(r'[\u2500-\u257F]', '', text) # Box Drawing range only + text = re.sub(r'[\u2580-\u259F]', '', text) # Block Elements range only + + # DO NOT remove \u25A0-\u25FF anymore - those are geometric shapes Meiryo can render! + # This includes: โ–  โ–ก โ–ฒ โ–ณ โ–ผ โ–ฝ โ—‹ โ— etc. + + # Extra cube-like CJK glyphs commonly misrendered in non-CJK fonts + # Keep this list but understand these are specific problematic characters + cube_likes = [ + 'ๅฃ', # U+53E3 - CJK mouth radical (renders as box) + 'ๅ›—', # U+56D7 - CJK enclosure + 'ๆ—ฅ', # U+65E5 - CJK sun/day (often boxy in wrong fonts) + 'ๆ›ฐ', # U+66F0 - CJK say + '็”ฐ', # U+7530 - CJK field + 'ๅ›ž', # U+56DE - CJK return + 'ใƒญ', # U+30ED - Katakana RO + '๏พ›', # U+FF9B - Halfwidth Katakana RO + 'ใ…', # U+3141 - Hangul MIEUM + 'ไธจ', # U+4E28 - CJK radical + ] + for s in cube_likes: + text = text.replace(s, '') + + # If line is mostly ASCII, strip any remaining single CJK ideographs that stand alone + # BUT: Preserve CJK punctuation marks (U+3000-U+303F) as they're valid in mixed content + try: + ascii_count = sum(1 for ch in text if ord(ch) < 128) + ratio = ascii_count / max(1, len(text)) + if ratio >= 0.8: + # Only remove CJK ideographs, NOT punctuation + # Exclude U+3000-U+303F (CJK Symbols and Punctuation) from removal + text = re.sub(r'(?:(?<=\\s)|^)[\\u3040-\\u30FF\\u3400-\\u9FFF\\uFF00-\\uFFEF](?=(?:\\s)|$)', '', text) + except Exception: + pass + + # Remove invisible and zero-width characters + text = re.sub(r'[\u200b-\u200f\u2028-\u202f\u205f-\u206f\ufeff]', '', text) + + # Remove remaining control characters (except common ones like newline, tab) + text = re.sub(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]', '', text) + + # Remove any remaining characters that can't be properly encoded + try: + text = text.encode('utf-8', errors='ignore').decode('utf-8') + except UnicodeError: + pass + + # Log what we removed (only if changes were made) + if text != original and not getattr(self, 'concise_logs', False): + try: + # Show what was removed + removed = set(original) - set(text) + if removed: + removed_list = sorted(removed, key=lambda x: ord(x)) + removed_with_codes = [f'{c}(U+{ord(c):04X})' for c in removed_list[:5]] # Show first 5 + if len(removed_list) > 5: + removed_with_codes.append('...') + self._log(f"๐Ÿ”ง Sanitized: Removed {len(removed)} chars: {' '.join(removed_with_codes)}", "debug") + except Exception: + pass + + return text + + def _normalize_unicode_width(self, text: str) -> str: + """Normalize Unicode to NFKC to 'unsquare' fullwidth/stylized forms while preserving CJK text""" + if not text: + return text + try: + import unicodedata + original = text + # NFKC folds compatibility characters (fullwidth forms, circled digits, etc.) to standard forms + text = unicodedata.normalize('NFKC', text) + if text != original: + try: + self._log(f"๐Ÿ”ค Normalized width/compat: '{original[:30]}...' โ†’ '{text[:30]}...'", "debug") + except Exception: + pass + return text + except Exception: + return text + + def _fix_encoding_issues(self, text: str) -> str: + """Fix common encoding issues in text, especially for Korean""" + if not text: + return text + + # Check for mojibake indicators (UTF-8 misinterpreted as Latin-1) + mojibake_indicators = ['รซ', 'รฌ', 'รชยฐ', 'รฃ', 'รƒ', 'รข', 'รค', 'รฐ', 'รญ', 'รซยญ', 'รฌยด'] + + if any(indicator in text for indicator in mojibake_indicators): + self._log("๐Ÿ”ง Detected mojibake encoding issue, attempting fixes...", "debug") + + # Try multiple encoding fixes + encodings_to_try = [ + ('latin-1', 'utf-8'), + ('windows-1252', 'utf-8'), + ('iso-8859-1', 'utf-8'), + ('cp1252', 'utf-8') + ] + + for from_enc, to_enc in encodings_to_try: + try: + fixed = text.encode(from_enc, errors='ignore').decode(to_enc, errors='ignore') + + # Check if the fix actually improved things + # Should have Korean characters (Hangul range) or be cleaner + if any('\uAC00' <= c <= '\uD7AF' for c in fixed) or fixed.count('๏ฟฝ') < text.count('๏ฟฝ'): + self._log(f"โœ… Fixed encoding using {from_enc} -> {to_enc}", "debug") + return fixed + except: + continue + + # Clean up any remaining control characters and replacement characters + import re + text = re.sub(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]', '', text) + + # Additional cleanup for common encoding artifacts + # Remove sequences that commonly appear from encoding errors + text = re.sub(r'\ufffd+', '', text) # Remove multiple replacement characters + + # UPDATED: DO NOT remove geometric shapes - Meiryo can render them! + # Old line removed: text = re.sub(r'[\u25a0-\u25ff]+', '', text) + + # Clean up double spaces and normalize whitespace + text = re.sub(r'\s+', ' ', text).strip() + + return text + + def create_text_mask(self, image: np.ndarray, regions: List[TextRegion]) -> np.ndarray: + """Create mask with comprehensive per-text-type dilation settings""" + mask = np.zeros(image.shape[:2], dtype=np.uint8) + + regions_masked = 0 + regions_skipped = 0 + + self._log(f"๐ŸŽญ Creating text mask for {len(regions)} regions", "info") + + # Get manga settings + manga_settings = self.main_gui.config.get('manga_settings', {}) + + # Get dilation settings + base_dilation_size = manga_settings.get('mask_dilation', 15) + + # If Auto Iterations is enabled, auto-set dilation by OCR provider and RT-DETR guide status + auto_iterations = manga_settings.get('auto_iterations', True) + if auto_iterations: + try: + ocr_settings = manga_settings.get('ocr', {}) + use_rtdetr_guide = ocr_settings.get('use_rtdetr_for_ocr_regions', True) + bubble_detection_enabled = ocr_settings.get('bubble_detection_enabled', False) + + # If RT-DETR guide is enabled for Google/Azure, force dilation to 0 + if (getattr(self, 'ocr_provider', '').lower() in ('azure', 'google') and + bubble_detection_enabled and use_rtdetr_guide): + base_dilation_size = 0 + self._log(f"๐Ÿ“ Auto dilation (RT-DETR guided): 0px (using iterations only)", "info") + elif getattr(self, 'ocr_provider', '').lower() in ('azure', 'google'): + # CRITICAL: Without RT-DETR, Azure/Google OCR is very conservative + # Use base dilation to expand masks to actual bubble size + base_dilation_size = 15 # Base expansion for Azure/Google without RT-DETR + self._log(f"๐Ÿ“ Auto dilation by provider ({self.ocr_provider}, no RT-DETR): {base_dilation_size}px", "info") + else: + base_dilation_size = 0 + self._log(f"๐Ÿ“ Auto dilation by provider ({self.ocr_provider}): {base_dilation_size}px", "info") + except Exception: + pass + + # Auto iterations: decide by image color vs B&W + auto_iterations = manga_settings.get('auto_iterations', True) + if auto_iterations: + try: + # Heuristic: consider image B&W if RGB channels are near-equal + if len(image.shape) < 3 or image.shape[2] == 1: + is_bw = True + else: + # Compute mean absolute differences between channels + ch0 = image[:, :, 0].astype(np.int16) + ch1 = image[:, :, 1].astype(np.int16) + ch2 = image[:, :, 2].astype(np.int16) + diff01 = np.mean(np.abs(ch0 - ch1)) + diff12 = np.mean(np.abs(ch1 - ch2)) + diff02 = np.mean(np.abs(ch0 - ch2)) + # If channels are essentially the same, treat as B&W + is_bw = max(diff01, diff12, diff02) < 2.0 + if is_bw: + text_bubble_iterations = 2 + empty_bubble_iterations = 2 + free_text_iterations = 0 + self._log("๐Ÿ“ Auto iterations (B&W): text=2, empty=2, free=0", "info") + else: + text_bubble_iterations = 4 + empty_bubble_iterations = 4 + free_text_iterations = 4 + self._log("๐Ÿ“ Auto iterations (Color): all=3", "info") + except Exception: + # Fallback to configured behavior on any error + auto_iterations = False + + if not auto_iterations: + # Check if using uniform iterations for all text types + use_all_iterations = manga_settings.get('use_all_iterations', False) + + if use_all_iterations: + # Use the same iteration count for all text types + all_iterations = manga_settings.get('all_iterations', 2) + text_bubble_iterations = all_iterations + empty_bubble_iterations = all_iterations + free_text_iterations = all_iterations + self._log(f"๐Ÿ“ Using uniform iterations: {all_iterations} for all text types", "info") + else: + # Use individual iteration settings + text_bubble_iterations = manga_settings.get('text_bubble_dilation_iterations', + manga_settings.get('bubble_dilation_iterations', 2)) + empty_bubble_iterations = manga_settings.get('empty_bubble_dilation_iterations', 3) + free_text_iterations = manga_settings.get('free_text_dilation_iterations', 0) + self._log(f"๐Ÿ“ Using individual iterations - Text bubbles: {text_bubble_iterations}, " + f"Empty bubbles: {empty_bubble_iterations}, Free text: {free_text_iterations}", "info") + + # Create separate masks for different text types + text_bubble_mask = np.zeros(image.shape[:2], dtype=np.uint8) + empty_bubble_mask = np.zeros(image.shape[:2], dtype=np.uint8) + free_text_mask = np.zeros(image.shape[:2], dtype=np.uint8) + + text_bubble_count = 0 + empty_bubble_count = 0 + free_text_count = 0 + + for i, region in enumerate(regions): + # CHECK: Should this region be inpainted? + if not getattr(region, 'should_inpaint', True): + # Skip this region - it shouldn't be inpainted + regions_skipped += 1 + self._log(f" Region {i+1}: SKIPPED (filtered by settings)", "debug") + continue + + regions_masked += 1 + + # Determine text type + text_type = 'free_text' # default + + # Check if region has bubble_type attribute (from bubble detection) + if hasattr(region, 'bubble_type'): + # RT-DETR classifications + if region.bubble_type == 'empty_bubble': + text_type = 'empty_bubble' + elif region.bubble_type == 'text_bubble': + text_type = 'text_bubble' + else: # 'free_text' or others + text_type = 'free_text' + else: + # Fallback: use simple heuristics if no bubble detection + x, y, w, h = region.bounding_box + x, y, w, h = int(x), int(y), int(w), int(h) + aspect_ratio = w / h if h > 0 else 1 + + # Check if region has text + has_text = hasattr(region, 'text') and region.text and len(region.text.strip()) > 0 + + # Heuristic: bubbles tend to be more square-ish or tall + # Free text tends to be wide and short + if aspect_ratio < 2.5 and w > 50 and h > 50: + if has_text: + text_type = 'text_bubble' + else: + # Could be empty bubble if it's round/oval shaped + text_type = 'empty_bubble' + else: + text_type = 'free_text' + + # Select appropriate mask and increment counter + if text_type == 'text_bubble': + target_mask = text_bubble_mask + text_bubble_count += 1 + mask_type = "TEXT BUBBLE" + elif text_type == 'empty_bubble': + target_mask = empty_bubble_mask + empty_bubble_count += 1 + mask_type = "EMPTY BUBBLE" + else: + target_mask = free_text_mask + free_text_count += 1 + mask_type = "FREE TEXT" + + # Check if this is a merged region with original regions + if hasattr(region, 'original_regions') and region.original_regions: + # Use original regions for precise masking + self._log(f" Region {i+1} ({mask_type}): Using {len(region.original_regions)} original regions", "debug") + + for orig_region in region.original_regions: + if hasattr(orig_region, 'vertices') and orig_region.vertices: + pts = np.array(orig_region.vertices, np.int32) + pts = pts.reshape((-1, 1, 2)) + cv2.fillPoly(target_mask, [pts], 255) + else: + x, y, w, h = orig_region.bounding_box + x, y, w, h = int(x), int(y), int(w), int(h) + cv2.rectangle(target_mask, (x, y), (x + w, y + h), 255, -1) + else: + # Normal region + if hasattr(region, 'vertices') and region.vertices and len(region.vertices) <= 8: + pts = np.array(region.vertices, np.int32) + pts = pts.reshape((-1, 1, 2)) + cv2.fillPoly(target_mask, [pts], 255) + self._log(f" Region {i+1} ({mask_type}): Using polygon", "debug") + else: + x, y, w, h = region.bounding_box + x, y, w, h = int(x), int(y), int(w), int(h) + cv2.rectangle(target_mask, (x, y), (x + w, y + h), 255, -1) + self._log(f" Region {i+1} ({mask_type}): Using bounding box", "debug") + + self._log(f"๐Ÿ“Š Mask breakdown: {text_bubble_count} text bubbles, {empty_bubble_count} empty bubbles, " + f"{free_text_count} free text regions, {regions_skipped} skipped", "info") + + # Apply different dilation settings to each mask type + if base_dilation_size > 0: + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (base_dilation_size, base_dilation_size)) + + # Apply dilation to text bubble mask + if text_bubble_count > 0 and text_bubble_iterations > 0: + self._log(f"๐Ÿ“ Applying text bubble dilation: {base_dilation_size}px, {text_bubble_iterations} iterations", "info") + text_bubble_mask = cv2.dilate(text_bubble_mask, kernel, iterations=text_bubble_iterations) + + # Apply dilation to empty bubble mask + if empty_bubble_count > 0 and empty_bubble_iterations > 0: + self._log(f"๐Ÿ“ Applying empty bubble dilation: {base_dilation_size}px, {empty_bubble_iterations} iterations", "info") + empty_bubble_mask = cv2.dilate(empty_bubble_mask, kernel, iterations=empty_bubble_iterations) + + # Apply dilation to free text mask + if free_text_count > 0 and free_text_iterations > 0: + self._log(f"๐Ÿ“ Applying free text dilation: {base_dilation_size}px, {free_text_iterations} iterations", "info") + free_text_mask = cv2.dilate(free_text_mask, kernel, iterations=free_text_iterations) + elif free_text_count > 0 and free_text_iterations == 0: + self._log(f"๐Ÿ“ No dilation for free text (iterations=0, perfect for B&W panels)", "info") + + # Combine all masks + mask = cv2.bitwise_or(text_bubble_mask, empty_bubble_mask) + mask = cv2.bitwise_or(mask, free_text_mask) + + coverage_percent = (np.sum(mask > 0) / mask.size) * 100 + self._log(f"๐Ÿ“Š Final mask coverage: {coverage_percent:.1f}% of image", "info") + + return mask + + def _get_or_init_shared_local_inpainter(self, local_method: str, model_path: str, force_reload: bool = False): + """Return a shared LocalInpainter for (local_method, model_path) with minimal locking. + If another thread is loading the same model, wait on its event instead of competing. + Set force_reload=True only when the method or model_path actually changed. + + If spare instances are available in the pool, check one out for use. + The instance will stay assigned to this translator until cleanup. + """ + from local_inpainter import LocalInpainter + + # Normalize model path to avoid cache misses due to path differences + # (e.g., ~/.cache/inpainting/anime-manga-big-lama.pt vs models/anime-manga-big-lama.pt) + if model_path: + try: + # Resolve to absolute path and normalize + model_path = os.path.abspath(os.path.normpath(model_path)) + except Exception: + pass # Keep original path if normalization fails + + key = (local_method, model_path or '') + + # Debug: Log pool key and current pool state for troubleshooting + try: + self._log(f"๐Ÿ”‘ Inpainter pool key: method={local_method}, path={os.path.basename(model_path) if model_path else 'None'}", "debug") + # Show what's currently in the pool + with MangaTranslator._inpaint_pool_lock: + pool_keys = list(MangaTranslator._inpaint_pool.keys()) + if pool_keys: + self._log(f"๐Ÿ“‹ Pool contains {len(pool_keys)} key(s):", "debug") + for pk_method, pk_path in pool_keys: + pk_rec = MangaTranslator._inpaint_pool.get((pk_method, pk_path)) + spares_count = len(pk_rec.get('spares', [])) if pk_rec else 0 + loaded = pk_rec.get('loaded', False) if pk_rec else False + self._log(f" - {pk_method}, {os.path.basename(pk_path) if pk_path else 'None'}: {spares_count} spares, loaded={loaded}", "debug") + else: + self._log(f"๐Ÿ“‹ Pool is empty", "debug") + except Exception as e: + self._log(f" Debug logging error: {e}", "debug") + + # FIRST: Try to check out a spare instance if available (for true parallelism) + # Don't pop it - instead mark it as 'in use' so it stays in memory + with MangaTranslator._inpaint_pool_lock: + rec = MangaTranslator._inpaint_pool.get(key) + # DEBUG: Log current pool state at checkout time - USE PRINT TO BYPASS LOGGING + if rec: + spares_count = len(rec.get('spares', [])) + checked_out_count = len(rec.get('checked_out', [])) + print(f"[CHECKOUT] Found pool record with {spares_count} spares, {checked_out_count} checked out") + self._log(f"๐Ÿ” CHECKOUT DEBUG: Found pool record with {spares_count} spares, {checked_out_count} checked out", "info") + else: + print(f"[CHECKOUT] No pool record found for key") + self._log(f"๐Ÿ” CHECKOUT DEBUG: No pool record found for key", "info") + + if rec and rec.get('spares'): + spares = rec.get('spares') or [] + # Initialize checked_out list if it doesn't exist + if 'checked_out' not in rec: + rec['checked_out'] = [] + checked_out = rec['checked_out'] + + # Look for an available spare (not checked out) + for spare in spares: + if spare not in checked_out and spare and getattr(spare, 'model_loaded', False): + # Mark as checked out + checked_out.append(spare) + available = len(spares) - len(checked_out) + self._log(f"๐Ÿงฐ Checked out spare inpainter ({len(checked_out)}/{len(spares)} in use, {available} available)", "info") + # Store reference for later return + self._checked_out_inpainter = spare + self._inpainter_pool_key = key + return spare + + # No available spares - all are checked out + if spares: + self._log(f"โณ All {len(spares)} spare inpainters are in use, will use shared instance", "debug") + + # FALLBACK: Use the shared instance + rec = MangaTranslator._inpaint_pool.get(key) + if rec and rec.get('loaded') and rec.get('inpainter'): + # Already loaded - do NOT force reload! + return rec['inpainter'] + # Create or wait for loader + with MangaTranslator._inpaint_pool_lock: + rec = MangaTranslator._inpaint_pool.get(key) + if rec and rec.get('loaded') and rec.get('inpainter'): + # Already loaded - do NOT force reload! + return rec['inpainter'] + if not rec: + # Register loading record with spares list initialized + rec = {'inpainter': None, 'loaded': False, 'event': threading.Event(), 'spares': [], 'checked_out': []} + MangaTranslator._inpaint_pool[key] = rec + is_loader = True + else: + is_loader = False + event = rec['event'] + # Loader performs heavy work without holding the lock + if is_loader: + try: + inp = LocalInpainter() + # Apply tiling settings once to the shared instance + tiling_settings = self.manga_settings.get('tiling', {}) + inp.tiling_enabled = tiling_settings.get('enabled', False) + inp.tile_size = tiling_settings.get('tile_size', 512) + inp.tile_overlap = tiling_settings.get('tile_overlap', 64) + # Ensure model path + if not model_path or not os.path.exists(model_path): + try: + model_path = inp.download_jit_model(local_method) + except Exception as e: + self._log(f"โš ๏ธ JIT download failed: {e}", "warning") + model_path = None + # Load model - NEVER force reload for first-time shared pool loading + loaded_ok = False + if model_path and os.path.exists(model_path): + try: + self._log(f"๐Ÿ“ฆ Loading inpainter model...", "debug") + self._log(f" Method: {local_method}", "debug") + self._log(f" Path: {model_path}", "debug") + # Only force reload if explicitly requested AND this is not the first load + # For shared pool, we should never force reload on initial load + loaded_ok = inp.load_model_with_retry(local_method, model_path, force_reload=force_reload) + if not loaded_ok: + # Retry with force_reload if initial load failed + self._log(f"๐Ÿ”„ Initial load failed, retrying with force_reload=True", "warning") + loaded_ok = inp.load_model_with_retry(local_method, model_path, force_reload=True) + if not loaded_ok: + self._log(f"โŒ Both load attempts failed", "error") + # Check file validity + try: + size_mb = os.path.getsize(model_path) / (1024 * 1024) + self._log(f" File size: {size_mb:.2f} MB", "info") + if size_mb < 1: + self._log(f" โš ๏ธ File may be corrupted (too small)", "warning") + except Exception: + self._log(f" โš ๏ธ Could not read model file", "warning") + except Exception as e: + self._log(f"โš ๏ธ Inpainter load exception: {e}", "warning") + import traceback + self._log(traceback.format_exc(), "debug") + loaded_ok = False + elif not model_path: + self._log(f"โš ๏ธ No model path configured for {local_method}", "warning") + elif not os.path.exists(model_path): + self._log(f"โš ๏ธ Model file does not exist: {model_path}", "warning") + # Publish result + with MangaTranslator._inpaint_pool_lock: + rec = MangaTranslator._inpaint_pool.get(key) or rec + rec['inpainter'] = inp + rec['loaded'] = bool(loaded_ok) + rec['event'].set() + return inp + except Exception as e: + with MangaTranslator._inpaint_pool_lock: + rec = MangaTranslator._inpaint_pool.get(key) or rec + rec['inpainter'] = None + rec['loaded'] = False + rec['event'].set() + self._log(f"โš ๏ธ Shared inpainter setup failed: {e}", "warning") + return None + else: + # Wait for loader to finish (without holding the lock) + success = event.wait(timeout=120) + if not success: + self._log(f"โฑ๏ธ Timeout waiting for inpainter to load (120s)", "warning") + return None + + # Check if load was successful + rec2 = MangaTranslator._inpaint_pool.get(key) + if not rec2: + self._log(f"โš ๏ธ Inpainter pool record disappeared after load", "warning") + return None + + inp = rec2.get('inpainter') + loaded = rec2.get('loaded', False) + + if inp and loaded: + # Successfully loaded by another thread + return inp + elif inp and not loaded: + # Inpainter created but model failed to load + # Try to load it ourselves + self._log(f"โš ๏ธ Inpainter exists but model not loaded, attempting to load", "debug") + if model_path and os.path.exists(model_path): + try: + loaded_ok = inp.load_model_with_retry(local_method, model_path, force_reload=True) + if loaded_ok: + # Update the pool record + with MangaTranslator._inpaint_pool_lock: + rec2['loaded'] = True + self._log(f"โœ… Successfully loaded model on retry in waiting thread", "info") + return inp + except Exception as e: + self._log(f"โŒ Failed to load in waiting thread: {e}", "warning") + return inp # Return anyway, inpaint will no-op + else: + self._log(f"โš ๏ธ Loader thread failed to create inpainter", "warning") + return None + + @classmethod + def _count_preloaded_inpainters(cls) -> int: + try: + with cls._inpaint_pool_lock: + total = 0 + for rec in cls._inpaint_pool.values(): + try: + total += len(rec.get('spares') or []) + except Exception: + pass + return total + except Exception: + return 0 + + def preload_local_inpainters(self, local_method: str, model_path: str, count: int) -> int: + """Preload N local inpainting instances sequentially into the shared pool for parallel panel translation. + Returns the number of instances successfully preloaded. + """ + # Respect singleton mode: do not create extra instances/spares + if getattr(self, 'use_singleton_models', False): + try: + self._log("๐Ÿงฐ Skipping local inpainting preload (singleton mode)", "debug") + except Exception: + pass + return 0 + try: + from local_inpainter import LocalInpainter + except Exception: + self._log("โŒ Local inpainter module not available for preloading", "error") + return 0 + + # Normalize model path to match _get_or_init_shared_local_inpainter + if model_path: + try: + model_path = os.path.abspath(os.path.normpath(model_path)) + except Exception: + pass + + key = (local_method, model_path or '') + created = 0 + + # Debug: Log the preload key for tracking + try: + self._log(f"๐Ÿ”‘ Preload using pool key: method={local_method}, path={os.path.basename(model_path) if model_path else 'None'} (normalized)", "debug") + except: + pass + + # FIRST: Ensure the shared instance is initialized and ready + # This prevents race conditions when spare instances run out + with MangaTranslator._inpaint_pool_lock: + rec = MangaTranslator._inpaint_pool.get(key) + if not rec or not rec.get('loaded') or not rec.get('inpainter'): + # Need to create the shared instance + if not rec: + rec = {'inpainter': None, 'loaded': False, 'event': threading.Event(), 'spares': [], 'checked_out': []} + MangaTranslator._inpaint_pool[key] = rec + need_init_shared = True + else: + need_init_shared = not (rec.get('loaded') and rec.get('inpainter')) + else: + need_init_shared = False + + if need_init_shared: + self._log(f"๐Ÿ“ฆ Initializing shared inpainter instance first...", "info") + try: + shared_inp = self._get_or_init_shared_local_inpainter(local_method, model_path, force_reload=False) + if shared_inp and getattr(shared_inp, 'model_loaded', False): + self._log(f"โœ… Shared instance initialized and model loaded", "info") + # Verify the pool record is updated + with MangaTranslator._inpaint_pool_lock: + rec_check = MangaTranslator._inpaint_pool.get(key) + if rec_check: + self._log(f" Pool record: loaded={rec_check.get('loaded')}, has_inpainter={rec_check.get('inpainter') is not None}", "debug") + else: + self._log(f"โš ๏ธ Shared instance initialization returned but model not loaded", "warning") + if shared_inp: + self._log(f" Instance exists but model_loaded={getattr(shared_inp, 'model_loaded', 'ATTR_MISSING')}", "debug") + except Exception as e: + self._log(f"โš ๏ธ Shared instance initialization failed: {e}", "warning") + import traceback + self._log(traceback.format_exc(), "debug") + + # Ensure pool record and spares list exist + with MangaTranslator._inpaint_pool_lock: + rec = MangaTranslator._inpaint_pool.get(key) + if not rec: + rec = {'inpainter': None, 'loaded': False, 'event': threading.Event(), 'spares': [], 'checked_out': []} + MangaTranslator._inpaint_pool[key] = rec + self._log(f"๐Ÿ” PRELOAD DEBUG: Created new pool record, spares=[], checked_out=[]", "info") + else: + current_spares_count = len(rec.get('spares', [])) + current_checked_out_count = len(rec.get('checked_out', [])) + self._log(f"๐Ÿ” PRELOAD DEBUG: Existing pool record found: {current_spares_count} spares, {current_checked_out_count} checked out", "info") + if 'spares' not in rec or rec['spares'] is None: + rec['spares'] = [] + spares = rec.get('spares') + # Prepare tiling settings + tiling_settings = self.manga_settings.get('tiling', {}) if hasattr(self, 'manga_settings') else {} + desired = max(0, int(count) - len(spares)) + if desired <= 0: + return 0 + ctx = " for parallel panels" if int(count) > 1 else "" + self._log(f"๐Ÿงฐ Preloading {desired} local inpainting instance(s){ctx}", "info") + for i in range(desired): + try: + inp = LocalInpainter() + inp.tiling_enabled = tiling_settings.get('enabled', False) + inp.tile_size = tiling_settings.get('tile_size', 512) + inp.tile_overlap = tiling_settings.get('tile_overlap', 64) + # Resolve model path if needed + resolved = model_path + if not resolved or not os.path.exists(resolved): + try: + resolved = inp.download_jit_model(local_method) + except Exception as e: + self._log(f"โš ๏ธ Preload JIT download failed: {e}", "warning") + resolved = None + if resolved and os.path.exists(resolved): + ok = inp.load_model_with_retry(local_method, resolved, force_reload=False) + # CRITICAL: Verify model_loaded attribute after load + model_actually_loaded = ok and getattr(inp, 'model_loaded', False) + if not model_actually_loaded: + # Debug why model wasn't loaded + self._log(f"๐Ÿ” Preload check: load_model_with_retry={ok}, model_loaded={getattr(inp, 'model_loaded', 'ATTR_MISSING')}", "debug") + if hasattr(inp, 'session'): + self._log(f" Inpainter has session: {inp.session is not None}", "debug") + + if model_actually_loaded: + with MangaTranslator._inpaint_pool_lock: + rec = MangaTranslator._inpaint_pool.get(key) + if not rec: + # Pool record doesn't exist - create it + rec = {'inpainter': None, 'loaded': False, 'event': threading.Event(), 'spares': [], 'checked_out': []} + MangaTranslator._inpaint_pool[key] = rec + # Ensure spares list exists + if 'spares' not in rec or rec['spares'] is None: + rec['spares'] = [] + # Append to existing spares list (don't replace the record!) + rec['spares'].append(inp) + created += 1 + self._log(f"โœ… Preloaded spare {created}: model_loaded={getattr(inp, 'model_loaded', False)}", "debug") + else: + if ok: + self._log(f"โš ๏ธ Preload: load_model_with_retry returned True but model_loaded is False or missing", "warning") + else: + self._log(f"โš ๏ธ Preload: load_model_with_retry returned False", "warning") + else: + self._log("โš ๏ธ Preload skipped: no model path available", "warning") + except Exception as e: + self._log(f"โš ๏ธ Preload error: {e}", "warning") + self._log(f"โœ… Preloaded {created} local inpainting instance(s)", "info") + return created + + def preload_local_inpainters_concurrent(self, local_method: str, model_path: str, count: int, max_parallel: int = None) -> int: + """Preload N local inpainting instances concurrently into the shared pool. + Honors advanced toggles for panel/region parallelism to pick a reasonable parallelism. + Returns number of instances successfully preloaded. + """ + # Respect singleton mode: do not create extra instances/spares + if getattr(self, 'use_singleton_models', False): + try: + self._log("๐Ÿงฐ Skipping concurrent local inpainting preload (singleton mode)", "debug") + except Exception: + pass + return 0 + try: + from local_inpainter import LocalInpainter + except Exception: + self._log("โŒ Local inpainter module not available for preloading", "error") + return 0 + + # CRITICAL: Normalize model path to match _get_or_init_shared_local_inpainter and sequential preload + if model_path: + try: + model_path = os.path.abspath(os.path.normpath(model_path)) + except Exception: + pass + + key = (local_method, model_path or '') + + # Debug: Log the preload key for tracking + try: + self._log(f"๐Ÿ”‘ Concurrent preload using pool key: method={local_method}, path={os.path.basename(model_path) if model_path else 'None'} (normalized)", "debug") + except: + pass + # Determine desired number based on existing spares + with MangaTranslator._inpaint_pool_lock: + rec = MangaTranslator._inpaint_pool.get(key) + if not rec: + rec = {'inpainter': None, 'loaded': False, 'event': threading.Event(), 'spares': [], 'checked_out': []} + MangaTranslator._inpaint_pool[key] = rec + spares = (rec.get('spares') or []) + desired = max(0, int(count) - len(spares)) + if desired <= 0: + return 0 + # Determine max_parallel from advanced settings if not provided + if max_parallel is None: + adv = {} + try: + adv = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) if hasattr(self, 'main_gui') else {} + except Exception: + adv = {} + if adv.get('parallel_panel_translation', False): + try: + max_parallel = max(1, int(adv.get('panel_max_workers', 2))) + except Exception: + max_parallel = 2 + elif adv.get('parallel_processing', False): + try: + max_parallel = max(1, int(adv.get('max_workers', 4))) + except Exception: + max_parallel = 2 + else: + max_parallel = 1 + max_parallel = max(1, min(int(max_parallel), int(desired))) + ctx = " for parallel panels" if int(count) > 1 else "" + self._log(f"๐Ÿงฐ Preloading {desired} local inpainting instance(s){ctx} (parallel={max_parallel})", "info") + # Resolve model path once + resolved_path = model_path + if not resolved_path or not os.path.exists(resolved_path): + try: + probe_inp = LocalInpainter() + resolved_path = probe_inp.download_jit_model(local_method) + except Exception as e: + self._log(f"โš ๏ธ JIT download failed for concurrent preload: {e}", "warning") + resolved_path = None + tiling_settings = self.manga_settings.get('tiling', {}) if hasattr(self, 'manga_settings') else {} + from concurrent.futures import ThreadPoolExecutor, as_completed + created = 0 + def _one(): + try: + inp = LocalInpainter() + inp.tiling_enabled = tiling_settings.get('enabled', False) + inp.tile_size = tiling_settings.get('tile_size', 512) + inp.tile_overlap = tiling_settings.get('tile_overlap', 64) + if resolved_path and os.path.exists(resolved_path): + ok = inp.load_model_with_retry(local_method, resolved_path, force_reload=False) + # CRITICAL: Verify model_loaded attribute + model_actually_loaded = ok and getattr(inp, 'model_loaded', False) + if model_actually_loaded: + with MangaTranslator._inpaint_pool_lock: + rec2 = MangaTranslator._inpaint_pool.get(key) + if not rec2: + # Pool record doesn't exist - create it + rec2 = {'inpainter': None, 'loaded': False, 'event': threading.Event(), 'spares': [], 'checked_out': []} + MangaTranslator._inpaint_pool[key] = rec2 + # Ensure spares list exists + if 'spares' not in rec2 or rec2['spares'] is None: + rec2['spares'] = [] + # Append to existing spares list (don't replace the record!) + rec2['spares'].append(inp) + return True + else: + # Log why it failed for debugging + try: + self._log(f"๐Ÿ” Concurrent preload check: load_model_with_retry={ok}, model_loaded={getattr(inp, 'model_loaded', 'ATTR_MISSING')}", "debug") + except: + pass + except Exception as e: + self._log(f"โš ๏ธ Concurrent preload error: {e}", "warning") + return False + with ThreadPoolExecutor(max_workers=max_parallel) as ex: + futs = [ex.submit(_one) for _ in range(desired)] + for f in as_completed(futs): + try: + if f.result(): + created += 1 + except Exception: + pass + self._log(f"โœ… Preloaded {created} local inpainting instance(s)", "info") + return created + return created + + @classmethod + def _count_preloaded_detectors(cls) -> int: + try: + with cls._detector_pool_lock: + return sum(len((rec or {}).get('spares') or []) for rec in cls._detector_pool.values()) + except Exception: + return 0 + + @classmethod + def get_preload_counters(cls) -> Dict[str, int]: + """Return current counters for preloaded instances (for diagnostics/logging).""" + try: + with cls._inpaint_pool_lock: + inpaint_spares = sum(len((rec or {}).get('spares') or []) for rec in cls._inpaint_pool.values()) + inpaint_keys = len(cls._inpaint_pool) + with cls._detector_pool_lock: + detector_spares = sum(len((rec or {}).get('spares') or []) for rec in cls._detector_pool.values()) + detector_keys = len(cls._detector_pool) + return { + 'inpaint_spares': inpaint_spares, + 'inpaint_keys': inpaint_keys, + 'detector_spares': detector_spares, + 'detector_keys': detector_keys, + } + except Exception: + return {'inpaint_spares': 0, 'inpaint_keys': 0, 'detector_spares': 0, 'detector_keys': 0} + + def preload_bubble_detectors(self, ocr_settings: Dict[str, Any], count: int) -> int: + """Preload N bubble detector instances (non-singleton) for panel parallelism. + Only applies when not using singleton models. + """ + try: + from bubble_detector import BubbleDetector + except Exception: + self._log("โŒ BubbleDetector module not available for preloading", "error") + return 0 + # Skip if singleton mode + if getattr(self, 'use_singleton_models', False): + return 0 + det_type = (ocr_settings or {}).get('detector_type', 'rtdetr_onnx') + model_id = (ocr_settings or {}).get('rtdetr_model_url') or (ocr_settings or {}).get('bubble_model_path') or '' + key = (det_type, model_id) + created = 0 + with MangaTranslator._detector_pool_lock: + rec = MangaTranslator._detector_pool.get(key) + if not rec: + rec = {'spares': []} + MangaTranslator._detector_pool[key] = rec + spares = rec.get('spares') + if spares is None: + spares = [] + rec['spares'] = spares + desired = max(0, int(count) - len(spares)) + if desired <= 0: + return 0 + self._log(f"๐Ÿงฐ Preloading {desired} bubble detector instance(s) [{det_type}]", "info") + for i in range(desired): + try: + bd = BubbleDetector() + ok = False + if det_type == 'rtdetr_onnx': + ok = bool(bd.load_rtdetr_onnx_model(model_id=model_id)) + elif det_type == 'rtdetr': + ok = bool(bd.load_rtdetr_model(model_id=model_id)) + elif det_type == 'yolo': + if model_id: + ok = bool(bd.load_model(model_id)) + else: + # auto: prefer RT-DETR + ok = bool(bd.load_rtdetr_model(model_id=model_id)) + if ok: + with MangaTranslator._detector_pool_lock: + rec = MangaTranslator._detector_pool.get(key) or {'spares': []} + if 'spares' not in rec or rec['spares'] is None: + rec['spares'] = [] + rec['spares'].append(bd) + MangaTranslator._detector_pool[key] = rec + created += 1 + except Exception as e: + self._log(f"โš ๏ธ Bubble detector preload error: {e}", "warning") + self._log(f"โœ… Preloaded {created} bubble detector instance(s)", "info") + return created + + def _initialize_local_inpainter(self): + """Initialize local inpainting if configured""" + try: + from local_inpainter import LocalInpainter, HybridInpainter, AnimeMangaInpaintModel + + # LOAD THE SETTINGS FROM CONFIG FIRST + # The dialog saves it as 'manga_local_inpaint_model' at root level + saved_local_method = self.main_gui.config.get('manga_local_inpaint_model', 'anime') + saved_inpaint_method = self.main_gui.config.get('manga_inpaint_method', 'cloud') + + # MIGRATION: Ensure manga_ prefixed model path keys exist for ONNX methods + # This fixes compatibility where model paths were saved without manga_ prefix + for method_variant in ['anime', 'anime_onnx', 'lama', 'lama_onnx', 'aot', 'aot_onnx']: + non_prefixed_key = f'{method_variant}_model_path' + prefixed_key = f'manga_{method_variant}_model_path' + # If we have the non-prefixed but not the prefixed, migrate it + if non_prefixed_key in self.main_gui.config and prefixed_key not in self.main_gui.config: + self.main_gui.config[prefixed_key] = self.main_gui.config[non_prefixed_key] + self._log(f"๐Ÿ”„ Migrated model path config: {non_prefixed_key} โ†’ {prefixed_key}", "debug") + + # Update manga_settings with the saved values + # ALWAYS use the top-level saved config to ensure correct model is loaded + if 'inpainting' not in self.manga_settings: + self.manga_settings['inpainting'] = {} + + # Always override with saved values from top-level config + # This ensures the user's model selection in the settings dialog is respected + self.manga_settings['inpainting']['method'] = saved_inpaint_method + self.manga_settings['inpainting']['local_method'] = saved_local_method + + # Now get the values (they'll be correct now) + inpaint_method = self.manga_settings.get('inpainting', {}).get('method', 'cloud') + + if inpaint_method == 'local': + # This will now get the correct saved value + local_method = self.manga_settings.get('inpainting', {}).get('local_method', 'anime') + + # Model path is saved with manga_ prefix - try both key formats for compatibility + model_path = self.main_gui.config.get(f'manga_{local_method}_model_path', '') + if not model_path: + # Fallback to non-prefixed key (older format) + model_path = self.main_gui.config.get(f'{local_method}_model_path', '') + + self._log(f"Using local method: {local_method} (loaded from config)", "info") + + # Check if we already have a loaded instance in the shared pool + # This avoids unnecessary tracking and reloading + inp_shared = self._get_or_init_shared_local_inpainter(local_method, model_path, force_reload=False) + + # Only track changes AFTER getting the shared instance + # This prevents spurious reloads on first initialization + if not hasattr(self, '_last_local_method'): + self._last_local_method = local_method + self._last_local_model_path = model_path + else: + # Check if settings actually changed and we need to force reload + need_reload = False + if self._last_local_method != local_method: + self._log(f"๐Ÿ”„ Local method changed from {self._last_local_method} to {local_method}", "info") + need_reload = True + # If method changed, we need a different model - get it with force_reload + inp_shared = self._get_or_init_shared_local_inpainter(local_method, model_path, force_reload=True) + elif self._last_local_model_path != model_path: + self._log(f"๐Ÿ”„ Model path changed", "info") + if self._last_local_model_path: + self._log(f" Old: {os.path.basename(self._last_local_model_path)}", "debug") + if model_path: + self._log(f" New: {os.path.basename(model_path)}", "debug") + need_reload = True + # If path changed, reload the model + inp_shared = self._get_or_init_shared_local_inpainter(local_method, model_path, force_reload=True) + + # Update tracking only if changes were made + if need_reload: + self._last_local_method = local_method + self._last_local_model_path = model_path + if inp_shared is not None: + self.local_inpainter = inp_shared + if getattr(self.local_inpainter, 'model_loaded', False): + self._log(f"โœ… Using shared {local_method.upper()} inpainting model", "info") + return True + else: + self._log(f"โš ๏ธ Shared inpainter created but model not loaded", "warning") + self._log(f"๐Ÿ”„ Attempting to retry model loading...", "info") + + # Retry loading the model + if model_path and os.path.exists(model_path): + self._log(f"๐Ÿ“ฆ Model path: {model_path}", "info") + self._log(f"๐Ÿ“‹ Method: {local_method}", "info") + try: + loaded_ok = inp_shared.load_model_with_retry(local_method, model_path, force_reload=True) + if loaded_ok and getattr(inp_shared, 'model_loaded', False): + self._log(f"โœ… Model loaded successfully on retry", "info") + # CRITICAL: Update the pool record so future requests don't need to retry + key = (local_method, os.path.abspath(os.path.normpath(model_path)) if model_path else '') + try: + with MangaTranslator._inpaint_pool_lock: + rec = MangaTranslator._inpaint_pool.get(key) + if rec: + rec['loaded'] = True + self._log(f"๐Ÿ”„ Updated pool record: loaded=True", "debug") + except Exception as e: + self._log(f"โš ๏ธ Failed to update pool record: {e}", "debug") + return True + else: + self._log(f"โŒ Model still not loaded after retry", "error") + # Check if model file exists and is valid + try: + size_mb = os.path.getsize(model_path) / (1024 * 1024) + self._log(f"๐Ÿ“Š Model file size: {size_mb:.2f} MB", "info") + if size_mb < 1: + self._log(f"โš ๏ธ Model file seems too small (< 1 MB) - may be corrupted", "warning") + except Exception: + pass + except Exception as e: + self._log(f"โŒ Retry load failed: {e}", "error") + import traceback + self._log(traceback.format_exc(), "debug") + elif not model_path: + self._log(f"โŒ No model path provided", "error") + elif not os.path.exists(model_path): + self._log(f"โŒ Model path does not exist: {model_path}", "error") + self._log(f"๐Ÿ“ฅ Tip: Try downloading the model from the Manga Settings dialog", "info") + + # If retry failed, fall through to fallback logic below + + # Fall back to instance-level init only if shared init completely failed + self._log("โš ๏ธ Shared inpainter init failed, falling back to instance creation", "warning") + try: + from local_inpainter import LocalInpainter + + # Create local inpainter instance + self.local_inpainter = LocalInpainter() + tiling_settings = self.manga_settings.get('tiling', {}) + self.local_inpainter.tiling_enabled = tiling_settings.get('enabled', False) + self.local_inpainter.tile_size = tiling_settings.get('tile_size', 512) + self.local_inpainter.tile_overlap = tiling_settings.get('tile_overlap', 64) + self._log(f"โœ… Set tiling: enabled={self.local_inpainter.tiling_enabled}, size={self.local_inpainter.tile_size}, overlap={self.local_inpainter.tile_overlap}", "info") + + # If no model path or doesn't exist, try to find or download one + if not model_path or not os.path.exists(model_path): + self._log(f"โš ๏ธ Model path not found: {model_path}", "warning") + self._log("๐Ÿ“ฅ Attempting to download JIT model...", "info") + try: + downloaded_path = self.local_inpainter.download_jit_model(local_method) + except Exception as e: + self._log(f"โš ๏ธ JIT download failed: {e}", "warning") + downloaded_path = None + if downloaded_path: + model_path = downloaded_path + self._log(f"โœ… Downloaded JIT model to: {model_path}") + else: + self._log("โš ๏ธ JIT model download did not return a path", "warning") + + # Load model with retry to avoid transient file/JSON issues under parallel init + loaded_ok = False + if model_path and os.path.exists(model_path): + for attempt in range(2): + try: + self._log(f"๐Ÿ“ฅ Loading {local_method} model... (attempt {attempt+1})", "info") + if self.local_inpainter.load_model(local_method, model_path, force_reload=need_reload): + loaded_ok = True + break + except Exception as e: + self._log(f"โš ๏ธ Load attempt {attempt+1} failed: {e}", "warning") + time.sleep(0.5) + if loaded_ok: + self._log(f"โœ… Local inpainter loaded with {local_method.upper()} (fallback instance)") + else: + self._log(f"โš ๏ธ Failed to load model, but inpainter is ready", "warning") + else: + self._log(f"โš ๏ธ No model available, but inpainter is initialized", "warning") + + return True + + except Exception as e: + self._log(f"โŒ Local inpainter module not available: {e}", "error") + return False + + elif inpaint_method == 'hybrid': + # Track hybrid settings changes + if not hasattr(self, '_last_hybrid_config'): + self._last_hybrid_config = None + + # Set tiling from tiling section + tiling_settings = self.manga_settings.get('tiling', {}) + self.local_inpainter.tiling_enabled = tiling_settings.get('enabled', False) + self.local_inpainter.tile_size = tiling_settings.get('tile_size', 512) + self.local_inpainter.tile_overlap = tiling_settings.get('tile_overlap', 64) + + self._log(f"โœ… Set tiling: enabled={self.local_inpainter.tiling_enabled}, size={self.local_inpainter.tile_size}, overlap={self.local_inpainter.tile_overlap}", "info") + + current_hybrid_config = self.manga_settings.get('inpainting', {}).get('hybrid_methods', []) + + # Check if hybrid config changed + need_reload = self._last_hybrid_config != current_hybrid_config + if need_reload: + self._log("๐Ÿ”„ Hybrid configuration changed, reloading...", "info") + self.hybrid_inpainter = None # Clear old instance + + self._last_hybrid_config = current_hybrid_config.copy() if current_hybrid_config else [] + + if self.hybrid_inpainter is None: + self.hybrid_inpainter = HybridInpainter() + # REMOVED: No longer override tiling settings for HybridInpainter + + # Load multiple methods + methods = self.manga_settings.get('inpainting', {}).get('hybrid_methods', []) + loaded = 0 + + for method_config in methods: + method = method_config.get('method') + model_path = method_config.get('model_path') + + if method and model_path: + if self.hybrid_inpainter.add_method(method, method, model_path): + loaded += 1 + self._log(f"โœ… Added {method.upper()} to hybrid inpainter") + + if loaded > 0: + self._log(f"โœ… Hybrid inpainter ready with {loaded} methods") + else: + self._log("โš ๏ธ Hybrid inpainter initialized but no methods loaded", "warning") + + return True + + return False + + except ImportError: + self._log("โŒ Local inpainter module not available", "error") + return False + except Exception as e: + self._log(f"โŒ Error initializing inpainter: {e}", "error") + return False + + + def inpaint_regions(self, image: np.ndarray, mask: np.ndarray) -> np.ndarray: + """Inpaint using configured method (cloud, local, or hybrid)""" + # Primary source of truth is the runtime flags set by the UI. + if getattr(self, 'skip_inpainting', False): + self._log(" โญ๏ธ Skipping inpainting (preserving original art)", "info") + return image.copy() + + # Cloud mode explicitly selected in UI + if getattr(self, 'use_cloud_inpainting', False): + return self._cloud_inpaint(image, mask) + + # Hybrid mode if UI requested it (fallback to settings key if present) + mode = getattr(self, 'inpaint_mode', None) or self.manga_settings.get('inpainting', {}).get('method') + if mode == 'hybrid' and hasattr(self, 'hybrid_inpainter'): + self._log(" ๐Ÿ”„ Using hybrid ensemble inpainting", "info") + return self.hybrid_inpainter.inpaint_ensemble(image, mask) + + # If a background preload is running, wait until it's finished before inpainting + try: + if hasattr(self, '_inpaint_preload_event') and self._inpaint_preload_event and not self._inpaint_preload_event.is_set(): + self._log(" โณ Waiting for local inpainting models to finish preloading...", "info") + # Wait with a generous timeout, but proceed afterward regardless + self._inpaint_preload_event.wait(timeout=300) + except Exception: + pass + + # Default to local inpainting + local_method = self.manga_settings.get('inpainting', {}).get('local_method', 'anime') + model_path = self.main_gui.config.get(f'manga_{local_method}_model_path', '') + + # Use a thread-local inpainter instance + inp = self._get_thread_local_inpainter(local_method, model_path) + if inp and getattr(inp, 'model_loaded', False): + self._log(" ๐Ÿงฝ Using local inpainting", "info") + return inp.inpaint(image, mask) + else: + # Conservative fallback: try shared instance only; do not attempt risky reloads that can corrupt output + try: + shared_inp = self._get_or_init_shared_local_inpainter(local_method, model_path) + if shared_inp and getattr(shared_inp, 'model_loaded', False): + self._log(" โœ… Using shared inpainting instance", "info") + return shared_inp.inpaint(image, mask) + except Exception: + pass + + # RETRY LOGIC: Attempt to reload model with multiple strategies + self._log(" โš ๏ธ Local inpainting model not loaded; attempting retry...", "warning") + + retry_attempts = [ + {'force_reload': True, 'desc': 'force reload'}, + {'force_reload': True, 'desc': 'force reload with delay', 'delay': 1.0}, + {'force_reload': False, 'desc': 'standard reload'}, + ] + + for attempt_num, retry_config in enumerate(retry_attempts, 1): + try: + self._log(f" ๐Ÿ”„ Retry attempt {attempt_num}/{len(retry_attempts)}: {retry_config['desc']}", "info") + + # Apply delay if specified + if retry_config.get('delay'): + import time + time.sleep(retry_config['delay']) + + # Try to get or create a fresh inpainter instance + retry_inp = self._get_or_init_shared_local_inpainter( + local_method, + model_path, + force_reload=retry_config['force_reload'] + ) + + if retry_inp: + # Check if model is loaded + if getattr(retry_inp, 'model_loaded', False): + self._log(f" โœ… Model loaded successfully on retry attempt {attempt_num}", "info") + return retry_inp.inpaint(image, mask) + else: + # Model exists but not loaded - try loading it directly + self._log(f" ๐Ÿ”ง Model not loaded, attempting direct load...", "info") + if model_path and os.path.exists(model_path): + try: + loaded_ok = retry_inp.load_model_with_retry( + local_method, + model_path, + force_reload=True + ) + if loaded_ok and getattr(retry_inp, 'model_loaded', False): + self._log(f" โœ… Direct load successful on attempt {attempt_num}", "info") + return retry_inp.inpaint(image, mask) + else: + self._log(f" โš ๏ธ Direct load returned {loaded_ok}, model_loaded={getattr(retry_inp, 'model_loaded', False)}", "warning") + except Exception as load_err: + self._log(f" โš ๏ธ Direct load failed: {load_err}", "warning") + else: + if not model_path: + self._log(f" โš ๏ธ No model path configured", "warning") + elif not os.path.exists(model_path): + self._log(f" โš ๏ธ Model file does not exist: {model_path}", "warning") + else: + self._log(f" โš ๏ธ Failed to get inpainter instance on attempt {attempt_num}", "warning") + + except Exception as retry_err: + self._log(f" โš ๏ธ Retry attempt {attempt_num} failed: {retry_err}", "warning") + import traceback + self._log(traceback.format_exc(), "debug") + + # All retries exhausted - provide detailed diagnostic information + self._log(" โŒ All retry attempts exhausted. Diagnostics:", "error") + self._log(f" Method: {local_method}", "error") + if model_path: + self._log(f" Model path: {model_path}", "error") + if os.path.exists(model_path): + try: + size_mb = os.path.getsize(model_path) / (1024 * 1024) + self._log(f" File size: {size_mb:.2f} MB", "error") + if size_mb < 1: + self._log(f" โš ๏ธ File may be corrupted (too small)", "error") + except Exception: + self._log(f" โš ๏ธ Cannot read model file", "error") + else: + self._log(f" โš ๏ธ Model file does not exist", "error") + else: + self._log(f" โš ๏ธ No model path configured", "error") + + self._log(" ๐Ÿ’ก Suggestion: Check Manga Settings and download the model if needed", "error") + self._log(" โš ๏ธ Returning original image without inpainting", "warning") + return image.copy() + + def _cloud_inpaint(self, image: np.ndarray, mask: np.ndarray) -> np.ndarray: + """Use Replicate API for inpainting""" + try: + import requests + import base64 + from io import BytesIO + from PIL import Image as PILImage + import cv2 + + self._log(" โ˜๏ธ Cloud inpainting via Replicate API", "info") + + # Convert to PIL + image_pil = PILImage.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) + mask_pil = PILImage.fromarray(mask).convert('L') + + # Convert to base64 + img_buffer = BytesIO() + image_pil.save(img_buffer, format='PNG') + img_base64 = base64.b64encode(img_buffer.getvalue()).decode() + + mask_buffer = BytesIO() + mask_pil.save(mask_buffer, format='PNG') + mask_base64 = base64.b64encode(mask_buffer.getvalue()).decode() + + # Get cloud settings + cloud_settings = self.main_gui.config.get('manga_settings', {}) + model_type = cloud_settings.get('cloud_inpaint_model', 'ideogram-v2') + timeout = cloud_settings.get('cloud_timeout', 60) + + # Determine model identifier based on model type + if model_type == 'ideogram-v2': + model = 'ideogram-ai/ideogram-v2' + self._log(f" Using Ideogram V2 inpainting model", "info") + elif model_type == 'sd-inpainting': + model = 'stability-ai/stable-diffusion-inpainting' + self._log(f" Using Stable Diffusion inpainting model", "info") + elif model_type == 'flux-inpainting': + model = 'zsxkib/flux-dev-inpainting' + self._log(f" Using FLUX inpainting model", "info") + elif model_type == 'custom': + model = cloud_settings.get('cloud_custom_version', '') + if not model: + raise Exception("No custom model identifier specified") + self._log(f" Using custom model: {model}", "info") + else: + # Default to Ideogram V2 + model = 'ideogram-ai/ideogram-v2' + self._log(f" Using default Ideogram V2 model", "info") + + # Build input data based on model type + input_data = { + 'image': f'data:image/png;base64,{img_base64}', + 'mask': f'data:image/png;base64,{mask_base64}' + } + + # Add prompt settings for models that support them + if model_type in ['ideogram-v2', 'sd-inpainting', 'flux-inpainting', 'custom']: + prompt = cloud_settings.get('cloud_inpaint_prompt', 'clean background, smooth surface') + input_data['prompt'] = prompt + self._log(f" Prompt: {prompt}", "info") + + # SD-specific parameters + if model_type == 'sd-inpainting': + negative_prompt = cloud_settings.get('cloud_negative_prompt', 'text, writing, letters') + input_data['negative_prompt'] = negative_prompt + input_data['num_inference_steps'] = cloud_settings.get('cloud_inference_steps', 20) + self._log(f" Negative prompt: {negative_prompt}", "info") + + # Get the latest version of the model + headers = { + 'Authorization': f'Token {self.replicate_api_key}', + 'Content-Type': 'application/json' + } + + # First, get the latest version of the model + model_response = requests.get( + f'https://api.replicate.com/v1/models/{model}', + headers=headers + ) + + if model_response.status_code != 200: + # If model lookup fails, try direct prediction with model identifier + self._log(f" Model lookup returned {model_response.status_code}, trying direct prediction", "warning") + version = None + else: + model_info = model_response.json() + version = model_info.get('latest_version', {}).get('id') + if not version: + raise Exception(f"Could not get version for model {model}") + + # Create prediction + prediction_data = { + 'input': input_data + } + + if version: + prediction_data['version'] = version + else: + # For custom models, try extracting version from model string + if ':' in model: + # Format: owner/model:version + model_name, version_id = model.split(':', 1) + prediction_data['version'] = version_id + else: + raise Exception(f"Could not determine version for model {model}. Try using format: owner/model:version") + + response = requests.post( + 'https://api.replicate.com/v1/predictions', + headers=headers, + json=prediction_data + ) + + if response.status_code != 201: + raise Exception(f"API error: {response.text}") + + # Get prediction URL + prediction = response.json() + prediction_url = prediction.get('urls', {}).get('get') or prediction.get('id') + + if not prediction_url: + raise Exception("No prediction URL returned") + + # If we only got an ID, construct the URL + if not prediction_url.startswith('http'): + prediction_url = f'https://api.replicate.com/v1/predictions/{prediction_url}' + + # Poll for result with configured timeout + import time + for i in range(timeout): + response = requests.get(prediction_url, headers=headers) + result = response.json() + + # Log progress every 5 seconds + if i % 5 == 0 and i > 0: + self._log(f" โณ Still processing... ({i}s elapsed)", "info") + + if result['status'] == 'succeeded': + # Download result image (handle both single URL and list) + output = result.get('output') + if not output: + raise Exception("No output returned from model") + + if isinstance(output, list): + output_url = output[0] if output else None + else: + output_url = output + + if not output_url: + raise Exception("No output URL in result") + + img_response = requests.get(output_url) + + # Convert back to numpy + result_pil = PILImage.open(BytesIO(img_response.content)) + result_bgr = cv2.cvtColor(np.array(result_pil), cv2.COLOR_RGB2BGR) + + self._log(" โœ… Cloud inpainting completed", "success") + return result_bgr + + elif result['status'] == 'failed': + error_msg = result.get('error', 'Unknown error') + # Check for common errors + if 'version' in error_msg.lower(): + error_msg += f" (Try using the model identifier '{model}' in the custom field)" + raise Exception(f"Inpainting failed: {error_msg}") + + time.sleep(1) + + raise Exception(f"Timeout waiting for inpainting (>{timeout}s)") + + except Exception as e: + self._log(f" โŒ Cloud inpainting failed: {str(e)}", "error") + return image.copy() + + + def _regions_overlap(self, region1: TextRegion, region2: TextRegion) -> bool: + """Check if two regions overlap""" + x1, y1, w1, h1 = region1.bounding_box + x2, y2, w2, h2 = region2.bounding_box + + # Check if rectangles overlap + if (x1 + w1 < x2 or x2 + w2 < x1 or + y1 + h1 < y2 or y2 + h2 < y1): + return False + + return True + + def _calculate_overlap_area(self, region1: TextRegion, region2: TextRegion) -> float: + """Calculate the area of overlap between two regions""" + x1, y1, w1, h1 = region1.bounding_box + x2, y2, w2, h2 = region2.bounding_box + + # Calculate intersection + x_left = max(x1, x2) + y_top = max(y1, y2) + x_right = min(x1 + w1, x2 + w2) + y_bottom = min(y1 + h1, y2 + h2) + + if x_right < x_left or y_bottom < y_top: + return 0.0 + + return (x_right - x_left) * (y_bottom - y_top) + + def _adjust_overlapping_regions(self, regions: List[TextRegion], image_width: int, image_height: int) -> List[TextRegion]: + """Adjust positions of overlapping regions to prevent overlap while preserving text mapping""" + if len(regions) <= 1: + return regions + + # Create a copy of regions with preserved indices + adjusted_regions = [] + for idx, region in enumerate(regions): + # Create a new TextRegion with copied values + adjusted_region = TextRegion( + text=region.text, + vertices=list(region.vertices), + bounding_box=list(region.bounding_box), + confidence=region.confidence, + region_type=region.region_type + ) + if hasattr(region, 'translated_text'): + adjusted_region.translated_text = region.translated_text + + # IMPORTANT: Preserve original index to maintain text mapping + adjusted_region.original_index = idx + adjusted_region.original_bbox = tuple(region.bounding_box) # Store original position + + adjusted_regions.append(adjusted_region) + + # DON'T SORT - This breaks the text-to-region mapping! + # Process in original order to maintain associations + + # Track which regions have been moved to avoid cascade effects + moved_regions = set() + + # Adjust overlapping regions + for i in range(len(adjusted_regions)): + if i in moved_regions: + continue # Skip if already moved + + for j in range(i + 1, len(adjusted_regions)): + if j in moved_regions: + continue # Skip if already moved + + region1 = adjusted_regions[i] + region2 = adjusted_regions[j] + + if self._regions_overlap(region1, region2): + x1, y1, w1, h1 = region1.bounding_box + x2, y2, w2, h2 = region2.bounding_box + + # Calculate centers using ORIGINAL positions for better logic + orig_x1, orig_y1, _, _ = region1.original_bbox + orig_x2, orig_y2, _, _ = region2.original_bbox + + # Determine which region to move based on original positions + # Move the one that's naturally "later" in reading order + if orig_y2 > orig_y1 + h1/2: # region2 is below + # Move region2 down slightly + min_gap = 10 + new_y2 = y1 + h1 + min_gap + if new_y2 + h2 <= image_height: + region2.bounding_box = (x2, new_y2, w2, h2) + moved_regions.add(j) + self._log(f" ๐Ÿ“ Adjusted region {j} down (preserving order)", "debug") + elif orig_y1 > orig_y2 + h2/2: # region1 is below + # Move region1 down slightly + min_gap = 10 + new_y1 = y2 + h2 + min_gap + if new_y1 + h1 <= image_height: + region1.bounding_box = (x1, new_y1, w1, h1) + moved_regions.add(i) + self._log(f" ๐Ÿ“ Adjusted region {i} down (preserving order)", "debug") + elif orig_x2 > orig_x1 + w1/2: # region2 is to the right + # Move region2 right slightly + min_gap = 10 + new_x2 = x1 + w1 + min_gap + if new_x2 + w2 <= image_width: + region2.bounding_box = (new_x2, y2, w2, h2) + moved_regions.add(j) + self._log(f" ๐Ÿ“ Adjusted region {j} right (preserving order)", "debug") + else: + # Minimal adjustment - just separate them slightly + # without changing their relative order + min_gap = 5 + if y2 >= y1: # region2 is lower or same level + new_y2 = y2 + min_gap + if new_y2 + h2 <= image_height: + region2.bounding_box = (x2, new_y2, w2, h2) + moved_regions.add(j) + else: # region1 is lower + new_y1 = y1 + min_gap + if new_y1 + h1 <= image_height: + region1.bounding_box = (x1, new_y1, w1, h1) + moved_regions.add(i) + + # IMPORTANT: Return in ORIGINAL order to preserve text mapping + # Sort by original_index to restore the original order + adjusted_regions.sort(key=lambda r: r.original_index) + + return adjusted_regions + + # Symbol/Unicode mixed font fallback (Meiryo) โ€” primary font remains unchanged + def _get_emote_fallback_font(self, font_size: int): + """Return a Meiryo Bold fallback font if available (preferred), else Meiryo. + Does not change the primary font; used for symbols, special characters, + and invalid unicode that don't render well in the primary font. + """ + try: + from PIL import ImageFont as _ImageFont + import os as _os + # Prefer Meiryo Bold TTC first; try common face indices, then regular Meiryo + candidates = [ + ("C:/Windows/Fonts/meiryob.ttc", [0,1,2,3]), # Meiryo Bold (and variants) TTC + ("C:/Windows/Fonts/meiryo.ttc", [1,0,2,3]), # Try bold-ish index first if present + ] + for path, idxs in candidates: + if _os.path.exists(path): + for idx in idxs: + try: + return _ImageFont.truetype(path, font_size, index=idx) + except Exception: + continue + return None + except Exception: + return None + + def _is_emote_char(self, ch: str) -> bool: + """Check if character should use Meiryo font (symbols + CJK + invalid unicode). + Now uses a broader detection approach for all symbols, CJK characters, and special characters. + """ + import unicodedata + + # Try to get the character's unicode category + try: + category = unicodedata.category(ch) + except (ValueError, TypeError): + # Invalid unicode - use Meiryo + return True + + # Check if character is in CJK Unicode ranges + # These characters render better with Japanese fonts like Meiryo + code_point = ord(ch) + + # CJK Unicode ranges: + # U+3000-U+303F: CJK Symbols and Punctuation (includes ใ€€, ใ€, ใ€‚, ใƒป) + # U+3040-U+309F: Hiragana + # U+30A0-U+30FF: Katakana (includes ใƒป) + # U+3400-U+4DBF: CJK Unified Ideographs Extension A + # U+4E00-U+9FFF: CJK Unified Ideographs + # U+F900-U+FAFF: CJK Compatibility Ideographs + # U+FF00-U+FFEF: Halfwidth and Fullwidth Forms + if (0x3000 <= code_point <= 0x303F or # CJK Symbols and Punctuation + 0x3040 <= code_point <= 0x309F or # Hiragana + 0x30A0 <= code_point <= 0x30FF or # Katakana + 0x3400 <= code_point <= 0x4DBF or # CJK Extension A + 0x4E00 <= code_point <= 0x9FFF or # CJK Unified Ideographs + 0xF900 <= code_point <= 0xFAFF or # CJK Compatibility + 0xFF00 <= code_point <= 0xFFEF): # Fullwidth Forms + return True + + # Symbol categories that should use Meiryo: + # So = Other Symbol (includes โ™ฅ, โ˜…, โœ“, etc.) + # Sm = Math Symbol + # Sc = Currency Symbol + # Sk = Modifier Symbol + # Ps/Pe/Pi/Pf = Special punctuation that might not render well + symbol_categories = {'So', 'Sm', 'Sc', 'Sk'} + + if category in symbol_categories: + return True + + # Additionally, explicit whitelist for specific symbols that might be miscategorized + # or for symbols we definitely want in Meiryo + # Note: CJK characters are already covered by the range check above + EXPLICIT_SYMBOLS = set([ + '\\u2661', # โ™ก White Heart Suit + '\\u2665', # โ™ฅ Black Heart Suit + '\\u2764', # โค Heavy Black Heart + '\\u2605', # โ˜… Black Star + '\\u2606', # โ˜† White Star + '\\u266A', # โ™ช Eighth Note + '\\u266B', # โ™ซ Beamed Eighth Notes + '\\u203B', # โ€ป Reference Mark + '\u2713', # โœ“ Check Mark + '\u2714', # โœ” Heavy Check Mark + '\u2715', # โœ• Multiplication X + '\u2716', # โœ– Heavy Multiplication X + '\u2717', # โœ— Ballot X + '\u2718', # โœ˜ Heavy Ballot X + '\u2022', # โ€ข Bullet + '\u25CF', # โ— Black Circle + '\u25CB', # โ—‹ White Circle + '\u25A0', # โ–  Black Square + '\u25A1', # โ–ก White Square + '\u25B2', # โ–ฒ Black Up-Pointing Triangle + '\u25B3', # โ–ณ White Up-Pointing Triangle + '\u25BC', # โ–ผ Black Down-Pointing Triangle + '\u25BD', # โ–ฝ White Down-Pointing Triangle + '\u2190', # โ† Leftwards Arrow + '\u2191', # โ†‘ Upwards Arrow + '\u2192', # โ†’ Rightwards Arrow + '\u2193', # โ†“ Downwards Arrow + '\u21D2', # โ‡’ Rightwards Double Arrow + '\u21D4', # โ‡” Left Right Double Arrow + '\u2026', # โ€ฆ Horizontal Ellipsis (sometimes renders poorly) + '\u3000', # ใ€€Japanese Full-Width Space (sometimes needs special handling) + ]) + + return ch in EXPLICIT_SYMBOLS + + def _line_width_emote_mixed(self, draw, text: str, primary_font, emote_font) -> int: + if not emote_font: + bbox = draw.textbbox((0, 0), text, font=primary_font) + return (bbox[2] - bbox[0]) + w = 0 + i = 0 + while i < len(text): + ch = text[i] + # Treat VS16/VS15 as zero-width modifiers + if ch in ('\ufe0f', '\ufe0e'): + i += 1 + continue + f = emote_font if self._is_emote_char(ch) else primary_font + try: + bbox = draw.textbbox((0, 0), ch, font=f) + w += (bbox[2] - bbox[0]) + except Exception: + w += max(1, int(getattr(primary_font, 'size', 12) * 0.6)) + i += 1 + return w + + def _draw_text_line_emote_mixed(self, draw, line: str, x: int, y: int, primary_font, emote_font, + fill_rgba, outline_rgba, outline_width: int, + shadow_enabled: bool, shadow_color_rgba, shadow_off): + cur_x = x + i = 0 + while i < len(line): + ch = line[i] + if ch in ('\ufe0f', '\ufe0e'): + i += 1 + continue + f = emote_font if (emote_font and self._is_emote_char(ch)) else primary_font + # measure + try: + bbox = draw.textbbox((0, 0), ch, font=f) + cw = bbox[2] - bbox[0] + except Exception: + cw = max(1, int(getattr(primary_font, 'size', 12) * 0.6)) + # shadow + if shadow_enabled: + sx, sy = shadow_off + draw.text((cur_x + sx, y + sy), ch, font=f, fill=shadow_color_rgba) + # outline + if outline_width > 0: + for dx in range(-outline_width, outline_width + 1): + for dy in range(-outline_width, outline_width + 1): + if dx == 0 and dy == 0: + continue + draw.text((cur_x + dx, y + dy), ch, font=f, fill=outline_rgba) + # main + draw.text((cur_x, y), ch, font=f, fill=fill_rgba) + cur_x += cw + i += 1 + + + def render_translated_text(self, image: np.ndarray, regions: List[TextRegion]) -> np.ndarray: + """Enhanced text rendering with customizable backgrounds and styles""" + self._log(f"\n๐ŸŽจ Starting ENHANCED text rendering with custom settings:", "info") + self._log(f" โœ… Using ENHANCED renderer (not the simple version)", "info") + self._log(f" Background: {self.text_bg_style} @ {int(self.text_bg_opacity/255*100)}% opacity", "info") + self._log(f" Text color: RGB{self.text_color}", "info") + self._log(f" Shadow: {'Enabled' if self.shadow_enabled else 'Disabled'}", "info") + self._log(f" Font: {os.path.basename(self.selected_font_style) if self.selected_font_style else 'Default'}", "info") + if self.force_caps_lock: + self._log(f" Force Caps Lock: ENABLED", "info") + + # Convert to PIL for text rendering + import cv2 + pil_image = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) + + # Get image dimensions for boundary checking + image_height, image_width = image.shape[:2] + + # Create text mask to get accurate render boundaries + # This represents what will actually be inpainted + try: + text_mask = self.create_text_mask(image, regions) + use_mask_for_rendering = True + self._log(f" ๐ŸŽญ Created text mask for accurate render boundaries", "info") + except Exception as e: + text_mask = None + use_mask_for_rendering = False + if not getattr(self, 'concise_logs', False): + self._log(f" โš ๏ธ Failed to create mask, using polygon bounds: {e}", "warning") + + # Only adjust overlapping regions if constraining to bubbles + if self.constrain_to_bubble: + adjusted_regions = self._adjust_overlapping_regions(regions, image_width, image_height) + else: + # Skip adjustment when not constraining (allows overflow) + adjusted_regions = regions + self._log(" ๐Ÿ“ Using original regions (overflow allowed)", "info") + + # Check if any regions still overlap after adjustment (shouldn't happen, but let's verify) + has_overlaps = False + for i, region1 in enumerate(adjusted_regions): + for region2 in adjusted_regions[i+1:]: + if self._regions_overlap(region1, region2): + has_overlaps = True + self._log(" โš ๏ธ Regions still overlap after adjustment", "warning") + break + if has_overlaps: + break + + # Handle transparency settings based on overlaps + if has_overlaps and self.text_bg_opacity < 255 and self.text_bg_opacity > 0: + self._log(" โš ๏ธ Overlapping regions detected with partial transparency", "warning") + self._log(" โ„น๏ธ Rendering with requested transparency level", "info") + + region_count = 0 + + # Decide rendering path based on transparency needs + # For full transparency (opacity = 0) or no overlaps, use RGBA rendering + # For overlaps with partial transparency, we still use RGBA to honor user settings + use_rgba_rendering = True # Always use RGBA for consistent transparency support + + if use_rgba_rendering: + # Transparency-enabled rendering path + pil_image = pil_image.convert('RGBA') + + # Decide parallel rendering from advanced settings + try: + adv = getattr(self, 'manga_settings', {}).get('advanced', {}) if hasattr(self, 'manga_settings') else {} + except Exception: + adv = {} + render_parallel = bool(adv.get('render_parallel', True)) + max_workers = None + try: + max_workers = int(adv.get('max_workers', 4)) + except Exception: + max_workers = 4 + + def _render_one(region, idx): + # Build a separate overlay for this region + from PIL import Image as _PIL + overlay = _PIL.new('RGBA', pil_image.size, (0,0,0,0)) + draw = ImageDraw.Draw(overlay) + # Work on local copy of text for caps lock + tr_text = region.translated_text or '' + if self.force_caps_lock: + tr_text = tr_text.upper() + + # Get original bounding box + x, y, w, h = region.bounding_box + + # CRITICAL: Always prefer mask bounds when available (most accurate) + # Mask bounds are especially important for Azure/Google without RT-DETR, + # where OCR polygons are unreliable. + if use_mask_for_rendering and text_mask is not None: + # Use mask bounds directly - most accurate method + safe_x, safe_y, safe_w, safe_h = self.get_safe_text_area( + region, + use_mask_bounds=True, + full_mask=text_mask + ) + render_x, render_y, render_w, render_h = safe_x, safe_y, safe_w, safe_h + elif hasattr(region, 'vertices') and region.vertices: + # Fallback: use polygon-based safe area (for RT-DETR regions) + safe_x, safe_y, safe_w, safe_h = self.get_safe_text_area(region, use_mask_bounds=False) + render_x, render_y, render_w, render_h = safe_x, safe_y, safe_w, safe_h + else: + # Last resort: use simple bounding box + render_x, render_y, render_w, render_h = x, y, w, h + + # Fit text - use render dimensions for proper sizing + if self.custom_font_size: + font_size = self.custom_font_size + lines = self._wrap_text(tr_text, self._get_font(font_size), render_w, draw) + elif self.font_size_mode == 'multiplier': + # Pass use_as_is=True since render dimensions are already safe area + font_size, lines = self._fit_text_to_region(tr_text, render_w, render_h, draw, region, use_as_is=True) + else: + # Pass use_as_is=True since render dimensions are already safe area + font_size, lines = self._fit_text_to_region(tr_text, render_w, render_h, draw, region, use_as_is=True) + # Fonts + font = self._get_font(font_size) + emote_font = self._get_emote_fallback_font(font_size) + # Layout - use render dimensions (safe area if available) + # CRITICAL: Use actual text bbox height for accurate positioning + line_height = font_size * 1.2 + + # Calculate actual total height using text bbox for first line as reference + if lines: + sample_bbox = draw.textbbox((0, 0), lines[0] if lines[0] else "Ay", font=font) + actual_line_height = sample_bbox[3] - sample_bbox[1] + # Use the larger of: computed line_height or actual_line_height + line_height = max(line_height, actual_line_height * 1.1) + + total_height = len(lines) * line_height + + # Ensure text doesn't overflow vertically - constrain start_y + ideal_start_y = render_y + (render_h - total_height) // 2 + # Make sure text starts within render area and doesn't extend past bottom + max_start_y = render_y + render_h - total_height + start_y = max(render_y, min(ideal_start_y, max_start_y)) + + # Debug logging for vertical constraint + if not getattr(self, 'concise_logs', False): + end_y = start_y + total_height + render_end_y = render_y + render_h + overflow = max(0, end_y - render_end_y) + if overflow > 0: + self._log(f" โš ๏ธ Text would overflow by {overflow}px, constrained to render area", "debug") + self._log(f" ๐Ÿ“ Render area: y={render_y}-{render_end_y} (h={render_h}), Text: y={start_y}-{end_y} (h={total_height:.0f})", "debug") + # BG - use render dimensions + draw_bg = self.text_bg_opacity > 0 + try: + if draw_bg and getattr(self, 'free_text_only_bg_opacity', False): + draw_bg = self._is_free_text_region(region) + except Exception: + pass + if draw_bg: + self._draw_text_background(draw, render_x, render_y, render_w, render_h, lines, font, font_size, start_y, emote_font) + # Text - use render dimensions for centering + for i, line in enumerate(lines): + if emote_font is not None: + text_width = self._line_width_emote_mixed(draw, line, font, emote_font) + else: + tb = draw.textbbox((0,0), line, font=font) + text_width = tb[2]-tb[0] + tx = render_x + (render_w - text_width)//2 + ty = start_y + i*line_height + ow = max(1, font_size // self.outline_width_factor) + if emote_font is not None: + self._draw_text_line_emote_mixed(draw, line, tx, ty, font, emote_font, + self.text_color + (255,), self.outline_color + (255,), ow, + self.shadow_enabled, + self.shadow_color + (255,) if isinstance(self.shadow_color, tuple) and len(self.shadow_color)==3 else (0,0,0,255), + (self.shadow_offset_x, self.shadow_offset_y)) + else: + if self.shadow_enabled: + self._draw_text_shadow(draw, tx, ty, line, font) + for dx in range(-ow, ow+1): + for dy in range(-ow, ow+1): + if dx!=0 or dy!=0: + draw.text((tx+dx, ty+dy), line, font=font, fill=self.outline_color + (255,)) + draw.text((tx, ty), line, font=font, fill=self.text_color + (255,)) + return overlay + + overlays = [] + if render_parallel and len(adjusted_regions) > 1: + from concurrent.futures import ThreadPoolExecutor, as_completed + workers = max(1, min(max_workers, len(adjusted_regions))) + with ThreadPoolExecutor(max_workers=workers) as ex: + fut_to_idx = {ex.submit(_render_one, r, i): i for i, r in enumerate(adjusted_regions) if r.translated_text} + # Collect in order + temp = {} + for fut in as_completed(fut_to_idx): + i = fut_to_idx[fut] + try: + temp[i] = fut.result() + except Exception: + temp[i] = None + overlays = [temp.get(i) for i in range(len(adjusted_regions))] + else: + for i, r in enumerate(adjusted_regions): + if not r.translated_text: + overlays.append(None) + continue + overlays.append(_render_one(r, i)) + + # Composite overlays sequentially + for ov in overlays: + if ov is not None: + pil_image = Image.alpha_composite(pil_image, ov) + region_count += 1 + + # Convert back to RGB + pil_image = pil_image.convert('RGB') + + else: + # This path is now deprecated but kept for backwards compatibility + # Direct rendering without transparency layers + draw = ImageDraw.Draw(pil_image) + + for region in adjusted_regions: + if not region.translated_text: + continue + + self._log(f"DEBUG: Rendering - Original: '{region.text[:30]}...' -> Translated: '{region.translated_text[:30]}...'", "debug") + + + # APPLY CAPS LOCK TRANSFORMATION HERE + if self.force_caps_lock: + region.translated_text = region.translated_text.upper() + + region_count += 1 + self._log(f" Rendering region {region_count}: {region.translated_text[:30]}...", "info") + + # Get original bounding box + x, y, w, h = region.bounding_box + + # CRITICAL: Always prefer mask bounds when available (most accurate) + # Mask bounds are especially important for Azure/Google without RT-DETR, + # where OCR polygons are unreliable. + if use_mask_for_rendering and text_mask is not None: + # Use mask bounds directly - most accurate method + safe_x, safe_y, safe_w, safe_h = self.get_safe_text_area( + region, + use_mask_bounds=True, + full_mask=text_mask + ) + render_x, render_y, render_w, render_h = safe_x, safe_y, safe_w, safe_h + elif hasattr(region, 'vertices') and region.vertices: + # Fallback: use polygon-based safe area (for RT-DETR regions) + safe_x, safe_y, safe_w, safe_h = self.get_safe_text_area(region, use_mask_bounds=False) + render_x, render_y, render_w, render_h = safe_x, safe_y, safe_w, safe_h + else: + # Last resort: use simple bounding box + render_x, render_y, render_w, render_h = x, y, w, h + + # Find optimal font size - use render dimensions for proper sizing + if self.custom_font_size: + font_size = self.custom_font_size + lines = self._wrap_text(region.translated_text, + self._get_font(font_size), + render_w, draw) + else: + # Pass use_as_is=True since render dimensions are already safe area + font_size, lines = self._fit_text_to_region( + region.translated_text, render_w, render_h, draw, region, use_as_is=True + ) + + # Load font + font = self._get_font(font_size) + + # Calculate text layout - use render dimensions + # CRITICAL: Use actual text bbox height for accurate positioning + line_height = font_size * 1.2 + + # Calculate actual total height using text bbox for first line as reference + if lines: + sample_bbox = draw.textbbox((0, 0), lines[0] if lines[0] else "Ay", font=font) + actual_line_height = sample_bbox[3] - sample_bbox[1] + # Use the larger of: computed line_height or actual_line_height + line_height = max(line_height, actual_line_height * 1.1) + + total_height = len(lines) * line_height + + # Ensure text doesn't overflow vertically - constrain start_y + ideal_start_y = render_y + (render_h - total_height) // 2 + # Make sure text starts within render area and doesn't extend past bottom + max_start_y = render_y + render_h - total_height + start_y = max(render_y, min(ideal_start_y, max_start_y)) + + # Draw opaque background (optionally only for free text) - use render dimensions + draw_bg = self.text_bg_opacity > 0 + try: + if draw_bg and getattr(self, 'free_text_only_bg_opacity', False): + draw_bg = self._is_free_text_region(region) + except Exception: + pass + if draw_bg: + self._draw_text_background(draw, render_x, render_y, render_w, render_h, lines, font, + font_size, start_y) + + # Draw text - use render dimensions + for i, line in enumerate(lines): + # Mixed fallback not supported in legacy path; keep primary measurement + text_bbox = draw.textbbox((0, 0), line, font=font) + text_width = text_bbox[2] - text_bbox[0] + + text_x = render_x + (render_w - text_width) // 2 + text_y = start_y + i * line_height + + if self.shadow_enabled: + self._draw_text_shadow(draw, text_x, text_y, line, font) + + outline_width = max(1, font_size // self.outline_width_factor) + + # Draw outline + for dx in range(-outline_width, outline_width + 1): + for dy in range(-outline_width, outline_width + 1): + if dx != 0 or dy != 0: + draw.text((text_x + dx, text_y + dy), line, + font=font, fill=self.outline_color) + + # Draw main text + draw.text((text_x, text_y), line, font=font, fill=self.text_color) + + # Convert back to numpy array + result = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) + self._log(f"โœ… ENHANCED text rendering complete - rendered {region_count} regions", "info") + return result + + def _is_free_text_region(self, region) -> bool: + """Heuristic: determine if the region is free text (not a bubble). + Uses bubble_type when available; otherwise falls back to aspect ratio heuristics. + """ + try: + if hasattr(region, 'bubble_type') and region.bubble_type: + return region.bubble_type == 'free_text' + # Fallback heuristic + x, y, w, h = region.bounding_box + w, h = int(w), int(h) + if h <= 0: + return True + aspect = w / max(1, h) + # Wider, shorter regions are often free text + return aspect >= 2.5 or h < 50 + except Exception: + return False + + def _draw_text_background(self, draw: ImageDraw, x: int, y: int, w: int, h: int, + lines: List[str], font: ImageFont, font_size: int, + start_y: int, emote_font: ImageFont = None): + """Draw background behind text with selected style. + If emote_font is provided, measure lines with emote-only mixing. + """ + # Early return if opacity is 0 (fully transparent) + if self.text_bg_opacity == 0: + return + + # Calculate actual text bounds + line_height = font_size * 1.2 + max_width = 0 + + for line in lines: + if emote_font is not None: + line_width = self._line_width_emote_mixed(draw, line, font, emote_font) + else: + bbox = draw.textbbox((0, 0), line, font=font) + line_width = bbox[2] - bbox[0] + max_width = max(max_width, line_width) + + # Apply size reduction + padding = int(font_size * 0.3) + bg_width = int((max_width + padding * 2) * self.text_bg_reduction) + bg_height = int((len(lines) * line_height + padding * 2) * self.text_bg_reduction) + + # Center background + bg_x = x + (w - bg_width) // 2 + bg_y = int(start_y - padding) + + # Create semi-transparent color + bg_color = (255, 255, 255, self.text_bg_opacity) + + if self.text_bg_style == 'box': + # Rounded rectangle + radius = min(20, bg_width // 10, bg_height // 10) + self._draw_rounded_rectangle(draw, bg_x, bg_y, bg_x + bg_width, + bg_y + bg_height, radius, bg_color) + + elif self.text_bg_style == 'circle': + # Ellipse that encompasses the text + center_x = bg_x + bg_width // 2 + center_y = bg_y + bg_height // 2 + # Make it slightly wider to look more natural + ellipse_width = int(bg_width * 1.2) + ellipse_height = bg_height + + draw.ellipse([center_x - ellipse_width // 2, center_y - ellipse_height // 2, + center_x + ellipse_width // 2, center_y + ellipse_height // 2], + fill=bg_color) + + elif self.text_bg_style == 'wrap': + # Individual background for each line + for i, line in enumerate(lines): + bbox = draw.textbbox((0, 0), line, font=font) + line_width = bbox[2] - bbox[0] + + line_bg_width = int((line_width + padding) * self.text_bg_reduction) + line_bg_x = x + (w - line_bg_width) // 2 + line_bg_y = int(start_y + i * line_height - padding // 2) + line_bg_height = int(line_height + padding // 2) + + # Draw rounded rectangle for each line + radius = min(10, line_bg_width // 10, line_bg_height // 10) + self._draw_rounded_rectangle(draw, line_bg_x, line_bg_y, + line_bg_x + line_bg_width, + line_bg_y + line_bg_height, radius, bg_color) + + def _draw_text_shadow(self, draw: ImageDraw, x: int, y: int, text: str, font: ImageFont): + """Draw text shadow with optional blur effect""" + if self.shadow_blur == 0: + # Simple sharp shadow + shadow_x = x + self.shadow_offset_x + shadow_y = y + self.shadow_offset_y + draw.text((shadow_x, shadow_y), text, font=font, fill=self.shadow_color) + else: + # Blurred shadow (simulated with multiple layers) + blur_range = self.shadow_blur + opacity_step = 80 // (blur_range + 1) # Distribute opacity across blur layers + + for blur_offset in range(blur_range, 0, -1): + layer_opacity = opacity_step * (blur_range - blur_offset + 1) + shadow_color_with_opacity = self.shadow_color + (layer_opacity,) + + # Draw shadow at multiple positions for blur effect + for dx in range(-blur_offset, blur_offset + 1): + for dy in range(-blur_offset, blur_offset + 1): + if dx*dx + dy*dy <= blur_offset*blur_offset: # Circular blur + shadow_x = x + self.shadow_offset_x + dx + shadow_y = y + self.shadow_offset_y + dy + draw.text((shadow_x, shadow_y), text, font=font, + fill=shadow_color_with_opacity) + + def _draw_rounded_rectangle(self, draw: ImageDraw, x1: int, y1: int, + x2: int, y2: int, radius: int, fill): + """Draw a rounded rectangle""" + # Draw the main rectangle + draw.rectangle([x1 + radius, y1, x2 - radius, y2], fill=fill) + draw.rectangle([x1, y1 + radius, x2, y2 - radius], fill=fill) + + # Draw the corners + draw.pieslice([x1, y1, x1 + 2 * radius, y1 + 2 * radius], 180, 270, fill=fill) + draw.pieslice([x2 - 2 * radius, y1, x2, y1 + 2 * radius], 270, 360, fill=fill) + draw.pieslice([x1, y2 - 2 * radius, x1 + 2 * radius, y2], 90, 180, fill=fill) + draw.pieslice([x2 - 2 * radius, y2 - 2 * radius, x2, y2], 0, 90, fill=fill) + + def _get_font(self, font_size: int) -> ImageFont: + """Get font with specified size, using selected style if available""" + font_path = self.selected_font_style or self.font_path + + if font_path: + try: + return ImageFont.truetype(font_path, font_size) + except: + pass + + return ImageFont.load_default() + + def _pil_word_wrap(self, text: str, font_path: str, roi_width: int, roi_height: int, + init_font_size: int, min_font_size: int, draw: ImageDraw) -> Tuple[str, int]: + """Comic-translate's pil_word_wrap algorithm - top-down font sizing with column wrapping. + + Break long text to multiple lines, and reduce point size until all text fits within bounds. + This is a direct port from comic-translate for better text fitting. + """ + from hyphen_textwrap import wrap as hyphen_wrap + + mutable_message = text + font_size = init_font_size + + def eval_metrics(txt, font): + """Calculate width/height of multiline text. + + CRITICAL: Must match the rendering logic exactly to prevent overflow. + Rendering uses font_size * 1.2 as line_height, so we must do the same here. + """ + lines = txt.split('\n') + if not lines: + return (0, 0) + + max_width = 0 + + for line in lines: + bbox = draw.textbbox((0, 0), line if line else "A", font=font) + line_width = bbox[2] - bbox[0] + max_width = max(max_width, line_width) + + # Calculate height using same logic as rendering: + # line_height = max(font_size * 1.2, actual_bbox_height * 1.1) + sample_bbox = draw.textbbox((0, 0), lines[0] if lines[0] else "Ay", font=font) + actual_line_height = sample_bbox[3] - sample_bbox[1] + line_height = max(font_size * 1.2, actual_line_height * 1.1) + total_height = len(lines) * line_height + + return (max_width, total_height) + + # Get initial font + try: + if font_path: + font = ImageFont.truetype(font_path, font_size) + else: + font = ImageFont.load_default() + except Exception: + font = ImageFont.load_default() + + # Top-down algorithm: start with large font, shrink until it fits + while font_size > min_font_size: + try: + if font_path: + font = ImageFont.truetype(font_path, font_size) + else: + font = ImageFont.load_default() + except Exception: + font = ImageFont.load_default() + + width, height = eval_metrics(mutable_message, font) + + if height > roi_height: + # Text is too tall, reduce font size + font_size -= 0.75 + mutable_message = text # Restore original text + elif width > roi_width: + # Text is too wide, try wrapping with column optimization + columns = len(mutable_message) + + # Search for optimal column width + while columns > 0: + columns -= 1 + if columns == 0: + break + + # Use hyphen_wrap for smart wrapping + try: + wrapped = '\n'.join(hyphen_wrap( + text, columns, + break_on_hyphens=False, + break_long_words=False, + hyphenate_broken_words=True + )) + wrapped_width, _ = eval_metrics(wrapped, font) + if wrapped_width <= roi_width: + mutable_message = wrapped + break + except Exception: + # Fallback to simple wrapping if hyphen_wrap fails + break + + if columns < 1: + # Couldn't find good column width, reduce font size + font_size -= 0.75 + mutable_message = text # Restore original text + else: + # Text fits! + break + + # If we hit minimum font size, do brute-force optimization + if font_size <= min_font_size: + font_size = min_font_size + mutable_message = text + + try: + if font_path: + font = ImageFont.truetype(font_path, font_size) + else: + font = ImageFont.load_default() + except Exception: + font = ImageFont.load_default() + + # Brute force: minimize cost function (width - roi_width)^2 + (height - roi_height)^2 + min_cost = 1e9 + min_text = text + + for columns in range(1, min(len(text) + 1, 100)): # Limit iterations for performance + try: + wrapped_text = '\n'.join(hyphen_wrap( + text, columns, + break_on_hyphens=False, + break_long_words=False, + hyphenate_broken_words=True + )) + wrapped_width, wrapped_height = eval_metrics(wrapped_text, font) + cost = (wrapped_width - roi_width)**2 + (wrapped_height - roi_height)**2 + + if cost < min_cost: + min_cost = cost + min_text = wrapped_text + except Exception: + continue + + mutable_message = min_text + + return mutable_message, int(font_size) + + def get_mask_bounds(self, region: TextRegion, full_mask: np.ndarray) -> Tuple[int, int, int, int]: + """Extract the actual mask boundaries for a region. + + For non-Azure/Google OCR providers (manga-ocr, etc.), use RT-DETR bubble_bounds directly. + For Azure/Google, extract from the mask overlap to handle full-page OCR. + """ + # PRIORITY 1: For manga-ocr and other RT-DETR-guided OCR providers, use bubble_bounds directly + # These providers already OCR within RT-DETR bubbles, so bubble_bounds IS the correct render area + is_azure_google = getattr(self, 'ocr_provider', '').lower() in ('azure', 'google') + if not is_azure_google and hasattr(region, 'bubble_bounds') and region.bubble_bounds: + # Use the RT-DETR bubble bounds directly - this is the full bubble area + bx, by, bw, bh = region.bubble_bounds + if not getattr(self, 'concise_logs', False): + self._log(f" โœ… Using RT-DETR bubble_bounds for mask: {int(bw)}ร—{int(bh)} at ({int(bx)}, {int(by)})", "debug") + return int(bx), int(by), int(bw), int(bh) + elif not is_azure_google: + # Debug: Why are we not using bubble_bounds? + if not getattr(self, 'concise_logs', False): + has_attr = hasattr(region, 'bubble_bounds') + is_none = getattr(region, 'bubble_bounds', None) is None if has_attr else True + #self._log(f" โš ๏ธ manga-ocr but NO bubble_bounds (has_attr={has_attr}, is_none={is_none})", "warning") + + # PRIORITY 2: For Azure/Google or when bubble_bounds not available, extract from mask + if full_mask is not None: + try: + import cv2 + import numpy as np + + # Create a blank mask for this region + region_mask = np.zeros(full_mask.shape, dtype=np.uint8) + + # Fill the region's area in the mask + if hasattr(region, 'vertices') and region.vertices: + vertices_np = np.array(region.vertices, dtype=np.int32) + cv2.fillPoly(region_mask, [vertices_np], 255) + else: + x, y, w, h = region.bounding_box + cv2.rectangle(region_mask, (int(x), int(y)), (int(x+w), int(y+h)), 255, -1) + + # Find where this region overlaps with the full mask + overlap = cv2.bitwise_and(region_mask, full_mask) + + # Get bounding box of the overlap + contours, _ = cv2.findContours(overlap, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + if contours: + # Get the largest contour (should be the main text region) + largest_contour = max(contours, key=cv2.contourArea) + x, y, w, h = cv2.boundingRect(largest_contour) + + if w > 0 and h > 0: + return x, y, w, h + except Exception as e: + if not getattr(self, 'concise_logs', False): + self._log(f" โš ๏ธ Failed to extract mask bounds: {e}, falling back", "debug") + + # Fallback to original bounding box + x, y, w, h = region.bounding_box + return int(x), int(y), int(w), int(h) + + def get_safe_text_area(self, region: TextRegion, use_mask_bounds: bool = False, full_mask: np.ndarray = None) -> Tuple[int, int, int, int]: + """Get safe text area with algorithm-aware shrink strategy. + + Respects font_algorithm and auto_fit_style settings: + - conservative: Comic-translate's 15% shrink (85% usable) + - smart: Adaptive 10-20% shrink based on bubble shape + - aggressive: Minimal 5% shrink (95% usable) + + Also applies OCR-specific adjustments for Azure/Google without RT-DETR guidance. + + Args: + region: The text region to calculate safe area for + use_mask_bounds: If True, use actual mask boundaries instead of shrinking from polygon + full_mask: The complete mask image (required if use_mask_bounds=True) + """ + # Get font sizing settings from config + try: + manga_settings = self.main_gui.config.get('manga_settings', {}) + font_sizing = manga_settings.get('font_sizing', {}) + rendering = manga_settings.get('rendering', {}) + + font_algorithm = font_sizing.get('algorithm', 'smart') + auto_fit_style = rendering.get('auto_fit_style', 'balanced') + + # Check if using Azure/Google without RT-DETR guidance + ocr_settings = manga_settings.get('ocr', {}) + use_rtdetr_guide = ocr_settings.get('use_rtdetr_for_ocr_regions', True) + is_azure_google = getattr(self, 'ocr_provider', '').lower() in ('azure', 'google') + needs_aggressive = is_azure_google and not use_rtdetr_guide + except Exception: + font_algorithm = 'smart' + auto_fit_style = 'balanced' + needs_aggressive = False + + # Base margin factor by algorithm + if font_algorithm == 'conservative': + # Comic-translate default: 15% shrink = 85% usable + base_margin = 0.85 + elif font_algorithm == 'aggressive': + # Aggressive: 5% shrink = 95% usable + base_margin = 0.95 + else: # 'smart' + # Smart: adaptive based on auto_fit_style + if auto_fit_style == 'compact': + base_margin = 0.82 # 18% shrink - tight fit + elif auto_fit_style == 'readable': + base_margin = 0.92 # 8% shrink - loose fit + else: # 'balanced' + base_margin = 0.87 # 13% shrink - balanced + + # SPECIAL CASE: Azure/Google without RT-DETR guidance + # Their OCR is too conservative, so we need more aggressive sizing + if needs_aggressive: + # Boost margin by 5-8% to compensate for conservative OCR bounds + base_margin = min(0.98, base_margin + 0.08) + self._log(f" ๐ŸŽฏ Azure/Google non-RT-DETR mode: Using aggressive {int(base_margin*100)}% margin", "debug") + + # OPTION 1: Use mask boundaries directly (most accurate) + if use_mask_bounds and full_mask is not None: + mask_x, mask_y, mask_w, mask_h = self.get_mask_bounds(region, full_mask) + # Use the FULL mask bounds directly - the mask already represents the accurate + # inpainted area from the inpainting process. The inpainting itself already includes + # padding/margins, so we don't need to shrink further. Using 100% maximizes text + # utilization and prevents the "text too small" issue. + + # CRITICAL: Use 100% of mask area for maximum text utilization + # The inpainting mask already has built-in margins from the mask generation process + safe_x, safe_y, safe_w, safe_h = mask_x, mask_y, mask_w, mask_h + + if not getattr(self, 'concise_logs', False): + self._log(f" ๐Ÿ“ Using FULL mask bounds: {mask_w}ร—{mask_h} (100% utilization)", "debug") + self._log(f" Mask position: ({mask_x}, {mask_y})", "debug") + if hasattr(region, 'bounding_box'): + orig_x, orig_y, orig_w, orig_h = region.bounding_box + self._log(f" Original bbox: {orig_w}ร—{orig_h} at ({orig_x}, {orig_y})", "debug") + return safe_x, safe_y, safe_w, safe_h + + # OPTION 2: Handle regions without vertices (simple bounding box) + if not hasattr(region, 'vertices') or not region.vertices: + x, y, w, h = region.bounding_box + safe_width = int(w * base_margin) + safe_height = int(h * base_margin) + safe_x = x + (w - safe_width) // 2 + safe_y = y + (h - safe_height) // 2 + return safe_x, safe_y, safe_width, safe_height + + # Calculate convexity for shape-aware adjustment (only for 'smart' algorithm) + margin_factor = base_margin + if font_algorithm == 'smart': + try: + # Convert vertices to numpy array with correct dtype + vertices = np.array(region.vertices, dtype=np.int32) + hull = cv2.convexHull(vertices) + hull_area = cv2.contourArea(hull) + poly_area = cv2.contourArea(vertices) + + if poly_area > 0: + convexity = hull_area / poly_area + else: + convexity = 1.0 + + # Adjust margin based on bubble shape + if convexity < 0.85: # Speech bubble with tail + # More aggressive shrink for tailed bubbles (avoid the tail) + margin_factor = base_margin - 0.10 + if not getattr(self, 'concise_logs', False): + self._log(f" Speech bubble with tail: {int(margin_factor*100)}% usable area", "debug") + elif convexity > 0.98: # Rectangular/square + # Less shrink for rectangular regions + margin_factor = base_margin + 0.05 + if not getattr(self, 'concise_logs', False): + self._log(f" Rectangular region: {int(margin_factor*100)}% usable area", "debug") + else: # Regular oval bubble + # Use base margin + margin_factor = base_margin + if not getattr(self, 'concise_logs', False): + self._log(f" Regular bubble: {int(margin_factor*100)}% usable area", "debug") + + # Clamp margin factor + margin_factor = max(0.70, min(0.98, margin_factor)) + except Exception: + margin_factor = base_margin + + # Convert vertices to numpy array for boundingRect + vertices_np = np.array(region.vertices, dtype=np.int32) + x, y, w, h = cv2.boundingRect(vertices_np) + + safe_width = int(w * margin_factor) + safe_height = int(h * margin_factor) + safe_x = x + (w - safe_width) // 2 + safe_y = y + (h - safe_height) // 2 + + return safe_x, safe_y, safe_width, safe_height + + def _fit_text_to_region(self, text: str, max_width: int, max_height: int, draw: ImageDraw, region: TextRegion = None, use_as_is: bool = False) -> Tuple[int, List[str]]: + """Find optimal font size using comic-translate's pil_word_wrap algorithm with algorithm-aware adjustments + + Args: + text: Text to fit + max_width: Maximum width available + max_height: Maximum height available + draw: PIL ImageDraw object + region: Optional TextRegion for safe area calculation + use_as_is: If True, use max_width/max_height directly without further shrinking + """ + + # Get font sizing settings + try: + manga_settings = self.main_gui.config.get('manga_settings', {}) + font_sizing = manga_settings.get('font_sizing', {}) + font_algorithm = font_sizing.get('algorithm', 'smart') + prefer_larger = font_sizing.get('prefer_larger', True) + except Exception: + font_algorithm = 'smart' + prefer_larger = True + + # Get usable area + if use_as_is: + # Dimensions are already safe area - use them directly (no double shrinking) + usable_width = max_width + usable_height = max_height + elif region and hasattr(region, 'vertices') and region.vertices: + # Calculate safe area from region + safe_x, safe_y, safe_width, safe_height = self.get_safe_text_area(region) + usable_width = safe_width + usable_height = safe_height + else: + # Fallback: use algorithm-aware margin + if font_algorithm == 'conservative': + margin = 0.85 # Comic-translate default + elif font_algorithm == 'aggressive': + margin = 0.95 + else: # smart + margin = 0.87 + usable_width = int(max_width * margin) + usable_height = int(max_height * margin) + + # Font size limits (GUI settings with algorithm adjustments) + min_font_size = max(10, self.min_readable_size) + + # Adjust initial font size based on algorithm and prefer_larger + base_init = min(40, self.max_font_size_limit) + if font_algorithm == 'aggressive' and prefer_larger: + # Start higher for aggressive mode + init_font_size = min(int(base_init * 1.2), self.max_font_size_limit) + elif font_algorithm == 'conservative': + # Start lower for conservative mode + init_font_size = int(base_init * 0.9) + else: + init_font_size = base_init + + # Use comic-translate's pil_word_wrap algorithm + wrapped_text, final_font_size = self._pil_word_wrap( + text=text, + font_path=self.selected_font_style or self.font_path, + roi_width=usable_width, + roi_height=usable_height, + init_font_size=init_font_size, + min_font_size=min_font_size, + draw=draw + ) + + # Convert wrapped text to lines + lines = wrapped_text.split('\n') if wrapped_text else [text] + + # Log font algorithm used (debug) + if not getattr(self, 'concise_logs', False): + self._log(f" Font algorithm: {font_algorithm}, init_size: {init_font_size}, final_size: {final_font_size}", "debug") + + # Apply multiplier if in multiplier mode + if self.font_size_mode == 'multiplier': + target_size = int(final_font_size * self.font_size_multiplier) + + # Check if multiplied size still fits (if constrained) + if self.constrain_to_bubble: + # Re-wrap at target size to check fit + test_wrapped, _ = self._pil_word_wrap( + text=text, + font_path=self.selected_font_style or self.font_path, + roi_width=usable_width, + roi_height=usable_height, + init_font_size=target_size, + min_font_size=target_size, # Force this size + draw=draw + ) + test_lines = test_wrapped.split('\n') if test_wrapped else [text] + test_height = len(test_lines) * target_size * 1.2 + + if test_height <= usable_height: + final_font_size = target_size + lines = test_lines + else: + self._log(f" Multiplier {self.font_size_multiplier}x would exceed bubble", "debug") + else: + # Not constrained, use multiplied size + final_font_size = target_size + lines = wrapped_text.split('\n') if wrapped_text else [text] + + self._log(f" Font sizing: text_len={len(text)}, size={final_font_size}, lines={len(lines)}", "debug") + + return final_font_size, lines + + def _fit_text_simple_topdown(self, text: str, usable_width: int, usable_height: int, + draw: ImageDraw, min_size: int, max_size: int) -> Tuple[int, List[str]]: + """Simple top-down approach - start large and shrink only if needed""" + # Start from a reasonable large size + start_size = int(max_size * 0.8) + + for font_size in range(start_size, min_size - 1, -2): # Step by 2 for speed + font = self._get_font(font_size) + lines = self._wrap_text(text, font, usable_width, draw) + + line_height = font_size * 1.2 # Tighter for overlaps + total_height = len(lines) * line_height + + if total_height <= usable_height: + return font_size, lines + + # If nothing fits, use minimum + font = self._get_font(min_size) + lines = self._wrap_text(text, font, usable_width, draw) + return min_size, lines + + def _check_potential_overlap(self, region: TextRegion) -> bool: + """Check if this region might overlap with others based on position""" + if not region or not hasattr(region, 'bounding_box'): + return False + + x, y, w, h = region.bounding_box + + # Simple heuristic: small regions or regions at edges might overlap + # You can make this smarter based on your needs + if w < 100 or h < 50: # Small bubbles often overlap + return True + + # Add more overlap detection logic here if needed + # For now, default to no overlap for larger bubbles + return False + + def _wrap_text(self, text: str, font: ImageFont, max_width: int, draw: ImageDraw) -> List[str]: + """Wrap text to fit within max_width with optional strict wrapping""" + # Handle empty text + if not text.strip(): + return [] + + # Only enforce width check if constrain_to_bubble is enabled + if self.constrain_to_bubble and max_width <= 0: + self._log(f" โš ๏ธ Invalid max_width: {max_width}, using fallback", "warning") + return [text[:20] + "..."] if len(text) > 20 else [text] + + words = text.split() + lines = [] + current_line = [] + + for word in words: + # Check if word alone is too long + word_bbox = draw.textbbox((0, 0), word, font=font) + word_width = word_bbox[2] - word_bbox[0] + + if word_width > max_width and len(word) > 1: + # Word is too long for the bubble + if current_line: + # Save current line first + lines.append(' '.join(current_line)) + current_line = [] + + if self.strict_text_wrapping: + # STRICT MODE: Force break the word to fit within bubble + # This is the original behavior that ensures text stays within bounds + broken_parts = self._force_break_word(word, font, max_width, draw) + lines.extend(broken_parts) + else: + # RELAXED MODE: Keep word whole (may exceed bubble) + lines.append(word) + # self._log(f" โš ๏ธ Word '{word}' exceeds bubble width, keeping whole", "warning") + else: + # Normal word processing + if current_line: + test_line = ' '.join(current_line + [word]) + else: + test_line = word + + text_bbox = draw.textbbox((0, 0), test_line, font=font) + text_width = text_bbox[2] - text_bbox[0] + + if text_width <= max_width: + current_line.append(word) + else: + if current_line: + lines.append(' '.join(current_line)) + current_line = [word] + else: + # Single word that fits + lines.append(word) + + if current_line: + lines.append(' '.join(current_line)) + + return lines + + # Keep the existing _force_break_word method as is (the complete version from earlier): + def _force_break_word(self, word: str, font: ImageFont, max_width: int, draw: ImageDraw) -> List[str]: + """Force break a word that's too long to fit""" + lines = [] + + # Binary search to find how many characters fit + low = 1 + high = len(word) + chars_that_fit = 1 + + while low <= high: + mid = (low + high) // 2 + test_text = word[:mid] + bbox = draw.textbbox((0, 0), test_text, font=font) + width = bbox[2] - bbox[0] + + if width <= max_width: + chars_that_fit = mid + low = mid + 1 + else: + high = mid - 1 + + # Break the word into pieces + remaining = word + while remaining: + if len(remaining) <= chars_that_fit: + # Last piece + lines.append(remaining) + break + else: + # Find the best break point + break_at = chars_that_fit + + # Try to break at a more natural point if possible + # Look for vowel-consonant boundaries for better hyphenation + for i in range(min(chars_that_fit, len(remaining) - 1), max(1, chars_that_fit - 5), -1): + if i < len(remaining) - 1: + current_char = remaining[i].lower() + next_char = remaining[i + 1].lower() + + # Good hyphenation points: + # - Between consonant and vowel + # - After prefix (un-, re-, pre-, etc.) + # - Before suffix (-ing, -ed, -er, etc.) + if (current_char in 'bcdfghjklmnpqrstvwxyz' and next_char in 'aeiou') or \ + (current_char in 'aeiou' and next_char in 'bcdfghjklmnpqrstvwxyz'): + break_at = i + 1 + break + + # Add hyphen if we're breaking in the middle of a word + if break_at < len(remaining): + # Check if adding hyphen still fits + test_with_hyphen = remaining[:break_at] + '-' + bbox = draw.textbbox((0, 0), test_with_hyphen, font=font) + width = bbox[2] - bbox[0] + + if width <= max_width: + lines.append(remaining[:break_at] + '-') + else: + # Hyphen doesn't fit, break without it + lines.append(remaining[:break_at]) + else: + lines.append(remaining[:break_at]) + + remaining = remaining[break_at:] + + return lines + + def _estimate_font_size_for_region(self, region: TextRegion) -> int: + """Estimate the likely font size for a text region based on its dimensions and text content""" + x, y, w, h = region.bounding_box + text_length = len(region.text.strip()) + + if text_length == 0: + return self.max_font_size // 2 # Default middle size + + # Calculate area per character + area = w * h + area_per_char = area / text_length + + # Estimate font size based on area per character + # These ratios are approximate and based on typical manga text + if area_per_char > 800: + estimated_size = int(self.max_font_size * 0.8) + elif area_per_char > 400: + estimated_size = int(self.max_font_size * 0.6) + elif area_per_char > 200: + estimated_size = int(self.max_font_size * 0.4) + elif area_per_char > 100: + estimated_size = int(self.max_font_size * 0.3) + else: + estimated_size = int(self.max_font_size * 0.2) + + # Clamp to reasonable bounds + return max(self.min_font_size, min(estimated_size, self.max_font_size)) + + + def _split_bubble_if_needed(self, bubble_regions: List[TextRegion]) -> List[List[TextRegion]]: + """Split a detected bubble if it actually contains multiple separate speech bubbles + + This happens when RT-DETR detects one large bounding box over vertically or + horizontally stacked speech bubbles. We detect this by checking if text regions + within the bubble have LARGE gaps between them. + + For manga-ocr and other non-Google/Azure OCR providers, RT-DETR detection is trusted + completely and splitting is disabled. + + Returns: + List of region groups - each group represents a separate bubble + """ + # For manga-ocr and other providers that use RT-DETR regions directly, trust RT-DETR + # Splitting is only needed for Google/Azure which do full-page OCR + if hasattr(self, 'ocr_provider') and self.ocr_provider not in ('google', 'azure'): + return [bubble_regions] # Trust RT-DETR completely for these providers + + if len(bubble_regions) <= 1: + return [bubble_regions] # Single region, no splitting needed + + # Sort regions by position (top-to-bottom, left-to-right) + sorted_regions = sorted(bubble_regions, key=lambda r: (r.bounding_box[1], r.bounding_box[0])) + + # Group regions that should be together + groups = [[sorted_regions[0]]] + + for i in range(1, len(sorted_regions)): + current_region = sorted_regions[i] + cx, cy, cw, ch = current_region.bounding_box + placed = False + + # Try to place in an existing group + for group in groups: + # Check if current region should be in this group + # We look at the closest region in the group + min_gap = float('inf') + min_vertical_gap = float('inf') + min_horizontal_gap = float('inf') + closest_region = None + + for group_region in group: + gx, gy, gw, gh = group_region.bounding_box + + # Calculate gap between regions + horizontal_gap = 0 + if gx + gw < cx: + horizontal_gap = cx - (gx + gw) + elif cx + cw < gx: + horizontal_gap = gx - (cx + cw) + + vertical_gap = 0 + if gy + gh < cy: + vertical_gap = cy - (gy + gh) + elif cy + ch < gy: + vertical_gap = gy - (cy + ch) + + # Use Euclidean distance as overall gap measure + gap = (horizontal_gap ** 2 + vertical_gap ** 2) ** 0.5 + + if gap < min_gap: + min_gap = gap + closest_region = group_region + # Store individual gaps for aggressive vertical splitting + min_vertical_gap = vertical_gap + min_horizontal_gap = horizontal_gap + + # AGGRESSIVE SPLIT for MANGA: Check for large vertical gaps first + # Manga often has vertically stacked speech bubbles that RT-DETR detects as one + if closest_region and min_vertical_gap > 50: + # Large vertical gap (>50px) - likely separate bubbles stacked vertically + # Check if there's NO vertical overlap (completely separate) + gx, gy, gw, gh = closest_region.bounding_box + vertical_overlap = min(gy + gh, cy + ch) - max(gy, cy) + + if vertical_overlap <= 0: + # No vertical overlap at all - definitely separate bubbles + # Create new group (don't merge) + pass # Will create new group below + else: + # Some overlap despite gap - check other criteria + horizontal_overlap = min(gx + gw, cx + cw) - max(gx, cx) + min_width = min(gw, cw) + min_height = min(gh, ch) + + # Only merge if there's very strong overlap (>75%) + if (horizontal_overlap > min_width * 0.75 or + vertical_overlap > min_height * 0.75): + group.append(current_region) + placed = True + break + # BALANCED SPLIT CRITERIA: + # Split if gap is > 21px unless there's strong overlap (>62%) + elif closest_region and min_gap < 21: # Within 21px - likely same bubble + group.append(current_region) + placed = True + break + elif closest_region: + # Check if they have significant overlap despite the gap + gx, gy, gw, gh = closest_region.bounding_box + + horizontal_overlap = min(gx + gw, cx + cw) - max(gx, cx) + vertical_overlap = min(gy + gh, cy + ch) - max(gy, cy) + + min_width = min(gw, cw) + min_height = min(gh, ch) + + # If they have strong overlap (>62%) in either direction, keep together + if (horizontal_overlap > min_width * 0.62 or + vertical_overlap > min_height * 0.62): + group.append(current_region) + placed = True + break + + # If not placed in any existing group, create a new group + if not placed: + groups.append([current_region]) + + # Log if we split the bubble + if len(groups) > 1: + self._log(f" ๐Ÿ”ช SPLIT: Detected bubble actually contains {len(groups)} separate bubbles", "warning") + for idx, group in enumerate(groups): + group_texts = [r.text[:15] + '...' for r in group] + self._log(f" Sub-bubble {idx + 1}: {len(group)} regions - {group_texts}", "info") + + return groups + + def _likely_different_bubbles(self, region1: TextRegion, region2: TextRegion) -> bool: + """Detect if regions are likely in different speech bubbles based on spatial patterns""" + x1, y1, w1, h1 = region1.bounding_box + x2, y2, w2, h2 = region2.bounding_box + + # Calculate gaps and positions + horizontal_gap = 0 + if x1 + w1 < x2: + horizontal_gap = x2 - (x1 + w1) + elif x2 + w2 < x1: + horizontal_gap = x1 - (x2 + w2) + + vertical_gap = 0 + if y1 + h1 < y2: + vertical_gap = y2 - (y1 + h1) + elif y2 + h2 < y1: + vertical_gap = y1 - (y2 + h2) + + # Calculate relative positions + center_x1 = x1 + w1 / 2 + center_x2 = x2 + w2 / 2 + center_y1 = y1 + h1 / 2 + center_y2 = y2 + h2 / 2 + + horizontal_center_diff = abs(center_x1 - center_x2) + avg_width = (w1 + w2) / 2 + + # FIRST CHECK: Very small gaps always indicate same bubble + if horizontal_gap < 15 and vertical_gap < 15: + return False # Definitely same bubble + + # STRICTER CHECK: For regions that are horizontally far apart + # Even if they pass the gap threshold, check if they're likely different bubbles + if horizontal_gap > 40: # Significant horizontal gap + # Unless they're VERY well aligned vertically, they're different bubbles + vertical_overlap = min(y1 + h1, y2 + h2) - max(y1, y2) + min_height = min(h1, h2) + + if vertical_overlap < min_height * 0.8: # Need 80% overlap to be same bubble + return True + + # SPECIFIC FIX: Check for multi-line text pattern + # If regions are well-aligned horizontally, they're likely in the same bubble + if horizontal_center_diff < avg_width * 0.35: # Relaxed from 0.2 to 0.35 + # Additional checks for multi-line text: + # 1. Similar widths (common in speech bubbles) + width_ratio = max(w1, w2) / min(w1, w2) if min(w1, w2) > 0 else 999 + + # 2. Reasonable vertical spacing (not too far apart) + avg_height = (h1 + h2) / 2 + + if width_ratio < 2.0 and vertical_gap < avg_height * 1.5: + # This is very likely multi-line text in the same bubble + return False + + # Pattern 1: Side-by-side bubbles (common in manga) + # Characteristics: Significant horizontal gap, similar vertical position + if horizontal_gap > 50: # Increased from 25 to avoid false positives + vertical_overlap = min(y1 + h1, y2 + h2) - max(y1, y2) + min_height = min(h1, h2) + + # If they have good vertical overlap, they're likely side-by-side bubbles + if vertical_overlap > min_height * 0.5: + return True + + # Pattern 2: Stacked bubbles + # Characteristics: Significant vertical gap, similar horizontal position + # CRITICAL: Lower threshold to catch vertically stacked bubbles in manga + if vertical_gap > 15: # Reduced from 25 to catch closer stacked bubbles + horizontal_overlap = min(x1 + w1, x2 + w2) - max(x1, x2) + min_width = min(w1, w2) + + # If they have good horizontal overlap, they're likely stacked bubbles + if horizontal_overlap > min_width * 0.5: + return True + + # Pattern 3: Diagonal arrangement (different speakers) + # If regions are separated both horizontally and vertically + if horizontal_gap > 20 and vertical_gap > 20: + return True + + # Pattern 4: Large gap relative to region size + avg_height = (h1 + h2) / 2 + + if horizontal_gap > avg_width * 0.6 or vertical_gap > avg_height * 0.6: + return True + + return False + + def _regions_should_merge(self, region1: TextRegion, region2: TextRegion, threshold: int = 50) -> bool: + """Determine if two regions should be merged - with bubble detection""" + + # First check if they're close enough spatially + if not self._regions_are_nearby(region1, region2, threshold): + return False + + x1, y1, w1, h1 = region1.bounding_box + x2, y2, w2, h2 = region2.bounding_box + + # ONLY apply special handling if regions are from Azure + if hasattr(region1, 'from_azure') and region1.from_azure: + # Azure lines are typically small - be more lenient + avg_height = (h1 + h2) / 2 + if avg_height < 50: # Likely single lines + self._log(f" Azure lines detected, using lenient merge criteria", "info") + + center_x1 = x1 + w1 / 2 + center_x2 = x2 + w2 / 2 + horizontal_center_diff = abs(center_x1 - center_x2) + avg_width = (w1 + w2) / 2 + + # If horizontally aligned and nearby, merge them + if horizontal_center_diff < avg_width * 0.7: + return True + + # GOOGLE LOGIC - unchanged from your original + # SPECIAL CASE: If one region is very small, bypass strict checks + area1 = w1 * h1 + area2 = w2 * h2 + if area1 < 500 or area2 < 500: + self._log(f" Small text region (area: {min(area1, area2)}), bypassing strict alignment checks", "info") + return True + + # Calculate actual gaps between regions + horizontal_gap = 0 + if x1 + w1 < x2: + horizontal_gap = x2 - (x1 + w1) + elif x2 + w2 < x1: + horizontal_gap = x1 - (x2 + w2) + + vertical_gap = 0 + if y1 + h1 < y2: + vertical_gap = y2 - (y1 + h1) + elif y2 + h2 < y1: + vertical_gap = y1 - (y2 + h2) + + # Calculate centers for alignment checks + center_x1 = x1 + w1 / 2 + center_x2 = x2 + w2 / 2 + center_y1 = y1 + h1 / 2 + center_y2 = y2 + h2 / 2 + + horizontal_center_diff = abs(center_x1 - center_x2) + vertical_center_diff = abs(center_y1 - center_y2) + + avg_width = (w1 + w2) / 2 + avg_height = (h1 + h2) / 2 + + # Determine text orientation and layout + is_horizontal_text = horizontal_gap > vertical_gap or (horizontal_center_diff < avg_width * 0.5) + is_vertical_text = vertical_gap > horizontal_gap or (vertical_center_diff < avg_height * 0.5) + + # PRELIMINARY CHECK: If regions overlap or are extremely close, merge them + # This handles text that's clearly in the same bubble + + # Check for overlap + overlap_x = max(0, min(x1 + w1, x2 + w2) - max(x1, x2)) + overlap_y = max(0, min(y1 + h1, y2 + h2) - max(y1, y2)) + has_overlap = overlap_x > 0 and overlap_y > 0 + + if has_overlap: + self._log(f" Regions overlap - definitely same bubble, merging", "info") + return True + + # If gaps are tiny (< 10 pixels), merge regardless of other factors + if horizontal_gap < 10 and vertical_gap < 10: + self._log(f" Very small gaps ({horizontal_gap}, {vertical_gap}) - merging", "info") + return True + + # BUBBLE BOUNDARY CHECK: Use spatial patterns to detect different bubbles + # But be less aggressive if gaps are small + # CRITICAL: Reduced threshold to allow bubble boundary detection for stacked bubbles + if horizontal_gap < 12 and vertical_gap < 12: + # Very close regions are almost certainly in the same bubble + self._log(f" Regions very close, skipping bubble boundary check", "info") + elif self._likely_different_bubbles(region1, region2): + self._log(f" Regions likely in different speech bubbles", "info") + return False + + # CHECK 1: For well-aligned text with small gaps, merge immediately + # This catches multi-line text in the same bubble + if is_horizontal_text and vertical_center_diff < avg_height * 0.4: + # Horizontal text that's well-aligned vertically + if horizontal_gap <= threshold and vertical_gap <= threshold * 0.5: + self._log(f" Well-aligned horizontal text with acceptable gaps, merging", "info") + return True + + if is_vertical_text and horizontal_center_diff < avg_width * 0.4: + # Vertical text that's well-aligned horizontally + if vertical_gap <= threshold and horizontal_gap <= threshold * 0.5: + self._log(f" Well-aligned vertical text with acceptable gaps, merging", "info") + return True + + # ADDITIONAL CHECK: Multi-line text in speech bubbles + # Even if not perfectly aligned, check for typical multi-line patterns + if horizontal_center_diff < avg_width * 0.5 and vertical_gap <= threshold: + # Lines that are reasonably centered and within threshold should merge + self._log(f" Multi-line text pattern detected, merging", "info") + return True + + # CHECK 2: Check alignment quality + # Poor alignment often indicates different bubbles + if is_horizontal_text: + # For horizontal text, check vertical alignment + if vertical_center_diff > avg_height * 0.6: + self._log(f" Poor vertical alignment for horizontal text", "info") + return False + elif is_vertical_text: + # For vertical text, check horizontal alignment + if horizontal_center_diff > avg_width * 0.6: + self._log(f" Poor horizontal alignment for vertical text", "info") + return False + + # CHECK 3: Font size check (but be reasonable) + font_size1 = self._estimate_font_size_for_region(region1) + font_size2 = self._estimate_font_size_for_region(region2) + size_ratio = max(font_size1, font_size2) / max(min(font_size1, font_size2), 1) + + # Allow some variation for emphasis or stylistic choices + if size_ratio > 2.0: + self._log(f" Font sizes too different ({font_size1} vs {font_size2})", "info") + return False + + # CHECK 4: Final sanity check on merged area + merged_width = max(x1 + w1, x2 + w2) - min(x1, x2) + merged_height = max(y1 + h1, y2 + h2) - min(y1, y2) + merged_area = merged_width * merged_height + combined_area = (w1 * h1) + (w2 * h2) + + # If merged area is way larger than combined areas, they're probably far apart + if merged_area > combined_area * 2.5: + self._log(f" Merged area indicates regions are too far apart", "info") + return False + + # If we get here, apply standard threshold checks + if horizontal_gap <= threshold and vertical_gap <= threshold: + self._log(f" Standard threshold check passed, merging", "info") + return True + + self._log(f" No merge conditions met", "info") + return False + + def _merge_nearby_regions(self, regions: List[TextRegion], threshold: int = 50) -> List[TextRegion]: + """Merge text regions that are likely part of the same speech bubble - with debug logging""" + if len(regions) <= 1: + return regions + + self._log(f"\n=== MERGE DEBUG: Starting merge analysis ===", "info") + self._log(f" Total regions: {len(regions)}", "info") + self._log(f" Threshold: {threshold}px", "info") + + # First, let's log what regions we have + for i, region in enumerate(regions): + x, y, w, h = region.bounding_box + self._log(f" Region {i}: pos({x},{y}) size({w}x{h}) text='{region.text[:20]}...'", "info") + + # Sort regions by area (largest first) to handle contained regions properly + sorted_indices = sorted(range(len(regions)), + key=lambda i: regions[i].bounding_box[2] * regions[i].bounding_box[3], + reverse=True) + + merged = [] + used = set() + + # Process each region in order of size (largest first) + for idx in sorted_indices: + i = idx + if i in used: + continue + + region1 = regions[i] + + # Start with this region + merged_text = region1.text + merged_vertices = list(region1.vertices) if hasattr(region1, 'vertices') else [] + regions_merged = [i] # Track which regions were merged + + self._log(f"\n Checking region {i} for merges:", "info") + + # Check against all other unused regions + for j in range(len(regions)): + if j == i or j in used: + continue + + region2 = regions[j] + self._log(f" Testing merge with region {j}:", "info") + + # Check if region2 is contained within region1 + x1, y1, w1, h1 = region1.bounding_box + x2, y2, w2, h2 = region2.bounding_box + + # Check if region2 is fully contained within region1 + if (x2 >= x1 and y2 >= y1 and + x2 + w2 <= x1 + w1 and y2 + h2 <= y1 + h1): + self._log(f" โœ“ Region {j} is INSIDE region {i} - merging!", "success") + merged_text += " " + region2.text + if hasattr(region2, 'vertices'): + merged_vertices.extend(region2.vertices) + used.add(j) + regions_merged.append(j) + continue + + # Check if region1 is contained within region2 (shouldn't happen due to sorting, but be safe) + if (x1 >= x2 and y1 >= y2 and + x1 + w1 <= x2 + w2 and y1 + h1 <= y2 + h2): + self._log(f" โœ“ Region {i} is INSIDE region {j} - merging!", "success") + merged_text += " " + region2.text + if hasattr(region2, 'vertices'): + merged_vertices.extend(region2.vertices) + used.add(j) + regions_merged.append(j) + # Update region1's bounding box to the larger region + region1 = TextRegion( + text=merged_text, + vertices=merged_vertices, + bounding_box=region2.bounding_box, + confidence=region1.confidence, + region_type='temp_merge' + ) + continue + + # FIX: Always check proximity against ORIGINAL regions, not the expanded one + # This prevents cascade merging across bubble boundaries + if self._regions_are_nearby(regions[i], region2, threshold): # Use regions[i] not region1 + #self._log(f" โœ“ Regions are nearby", "info") + + # Then check if they should merge (also use original region) + if self._regions_should_merge(regions[i], region2, threshold): # Use regions[i] not region1 + #self._log(f" โœ“ Regions should merge!", "success") + + # Actually perform the merge + merged_text += " " + region2.text + if hasattr(region2, 'vertices'): + merged_vertices.extend(region2.vertices) + used.add(j) + regions_merged.append(j) + + # DON'T update region1 for proximity checks - keep using original regions + else: + self._log(f" โœ— Regions should not merge", "warning") + else: + self._log(f" โœ— Regions not nearby", "warning") + + # Log if we merged multiple regions + if len(regions_merged) > 1: + self._log(f" โœ… MERGED regions {regions_merged} into one bubble", "success") + else: + self._log(f" โ„น๏ธ Region {i} not merged with any other", "info") + + # Create final merged region with all the merged vertices + if merged_vertices: + xs = [v[0] for v in merged_vertices] + ys = [v[1] for v in merged_vertices] + else: + # Fallback: calculate from all merged regions + all_xs = [] + all_ys = [] + for idx in regions_merged: + x, y, w, h = regions[idx].bounding_box + all_xs.extend([x, x + w]) + all_ys.extend([y, y + h]) + xs = all_xs + ys = all_ys + + min_x, max_x = min(xs), max(xs) + min_y, max_y = min(ys), max(ys) + merged_bbox = (min_x, min_y, max_x - min_x, max_y - min_y) + + merged_region = TextRegion( + text=merged_text, + vertices=merged_vertices, + bounding_box=merged_bbox, + confidence=regions[i].confidence, + region_type='merged_text_block' if len(regions_merged) > 1 else regions[i].region_type + ) + + # Copy over any additional attributes + if hasattr(regions[i], 'translated_text'): + merged_region.translated_text = regions[i].translated_text + + merged.append(merged_region) + used.add(i) + + self._log(f"\n=== MERGE DEBUG: Complete ===", "info") + self._log(f" Final region count: {len(merged)} (was {len(regions)})", "info") + + # Verify the merge worked + if len(merged) == len(regions): + self._log(f" โš ๏ธ WARNING: No regions were actually merged!", "warning") + + return merged + + def _regions_are_nearby(self, region1: TextRegion, region2: TextRegion, threshold: int = 50) -> bool: + """Check if two regions are close enough to be in the same bubble - WITH DEBUG""" + x1, y1, w1, h1 = region1.bounding_box + x2, y2, w2, h2 = region2.bounding_box + + #self._log(f"\n === NEARBY CHECK DEBUG ===", "info") + #self._log(f" Region 1: pos({x1},{y1}) size({w1}x{h1})", "info") + #self._log(f" Region 2: pos({x2},{y2}) size({w2}x{h2})", "info") + #self._log(f" Threshold: {threshold}", "info") + + # Calculate gaps between closest edges + horizontal_gap = 0 + if x1 + w1 < x2: # region1 is to the left + horizontal_gap = x2 - (x1 + w1) + elif x2 + w2 < x1: # region2 is to the left + horizontal_gap = x1 - (x2 + w2) + + vertical_gap = 0 + if y1 + h1 < y2: # region1 is above + vertical_gap = y2 - (y1 + h1) + elif y2 + h2 < y1: # region2 is above + vertical_gap = y1 - (y2 + h2) + + #self._log(f" Horizontal gap: {horizontal_gap}", "info") + #self._log(f" Vertical gap: {vertical_gap}", "info") + + # Detect if regions are likely vertical text based on aspect ratio + aspect1 = w1 / max(h1, 1) + aspect2 = w2 / max(h2, 1) + + # More permissive vertical text detection + # Vertical text typically has aspect ratio < 1.0 (taller than wide) + is_vertical_text = (aspect1 < 1.0 and aspect2 < 1.0) or (aspect1 < 0.5 or aspect2 < 0.5) + + # Also check if text is arranged vertically (one above the other with minimal horizontal offset) + center_x1 = x1 + w1 / 2 + center_x2 = x2 + w2 / 2 + horizontal_center_diff = abs(center_x1 - center_x2) + avg_width = (w1 + w2) / 2 + + # If regions are vertically stacked with aligned centers, treat as vertical text + is_vertically_stacked = (horizontal_center_diff < avg_width * 1.5) and (vertical_gap >= 0) + + #self._log(f" Is vertical text: {is_vertical_text}", "info") + #self._log(f" Is vertically stacked: {is_vertically_stacked}", "info") + #self._log(f" Horizontal center diff: {horizontal_center_diff:.1f}", "info") + + # SIMPLE APPROACH: Just check if gaps are within threshold + # Don't overthink it + if horizontal_gap <= threshold and vertical_gap <= threshold: + #self._log(f" โœ… NEARBY: Both gaps within threshold", "success") + return True + + # SPECIAL CASE: Vertically stacked text with good alignment + # This is specifically for multi-line text in bubbles + if horizontal_center_diff < avg_width * 0.8 and vertical_gap <= threshold * 1.5: + #self._log(f" โœ… NEARBY: Vertically aligned text in same bubble", "success") + return True + + # If one gap is small and the other is slightly over, still consider nearby + if (horizontal_gap <= threshold * 0.5 and vertical_gap <= threshold * 1.5) or \ + (vertical_gap <= threshold * 0.5 and horizontal_gap <= threshold * 1.5): + #self._log(f" โœ… NEARBY: One small gap, other slightly over", "success") + return True + + # Special case: Wide bubbles with text on sides + # If regions are at nearly the same vertical position, they might be in a wide bubble + if abs(y1 - y2) < 10: # Nearly same vertical position + # Check if this could be a wide bubble spanning both regions + if horizontal_gap <= threshold * 3: # Allow up to 3x threshold for wide bubbles + #self._log(f" โœ… NEARBY: Same vertical level, possibly wide bubble", "success") + return True + + #self._log(f" โŒ NOT NEARBY: Gaps exceed threshold", "warning") + return False + + def _find_font(self) -> str: + """Find a suitable font for text rendering""" + font_candidates = [ + "C:/Windows/Fonts/comicbd.ttf", # Comic Sans MS Bold as first choice + "C:/Windows/Fonts/arial.ttf", + "C:/Windows/Fonts/calibri.ttf", + "C:/Windows/Fonts/tahoma.ttf", + "/System/Library/Fonts/Helvetica.ttc", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" + ] + + for font_path in font_candidates: + if os.path.exists(font_path): + return font_path + + return None # Will use default font + + def _get_singleton_bubble_detector(self): + """Get or initialize the singleton bubble detector instance with load coordination.""" + start_time = None + with MangaTranslator._singleton_lock: + if MangaTranslator._singleton_bubble_detector is not None: + self._log("๐Ÿค– Using bubble detector (already loaded)", "info") + MangaTranslator._singleton_refs += 1 + return MangaTranslator._singleton_bubble_detector + # If another thread is loading, wait for it + if MangaTranslator._singleton_bd_loading: + self._log("โณ Waiting for bubble detector to finish loading (singleton)", "debug") + evt = MangaTranslator._singleton_bd_event + # Drop the lock while waiting + pass + else: + # Mark as loading and proceed to load outside lock + MangaTranslator._singleton_bd_loading = True + MangaTranslator._singleton_bd_event.clear() + start_time = time.time() + # Release lock and perform heavy load + pass + # Outside the lock: perform load or wait + if start_time is None: + # We are a waiter + try: + MangaTranslator._singleton_bd_event.wait(timeout=300) + except Exception: + pass + with MangaTranslator._singleton_lock: + if MangaTranslator._singleton_bubble_detector is not None: + MangaTranslator._singleton_refs += 1 + return MangaTranslator._singleton_bubble_detector + else: + # We are the loader + try: + from bubble_detector import BubbleDetector + bd = None + + # First, try to get a preloaded detector from the pool + try: + ocr_settings = self.main_gui.config.get('manga_settings', {}).get('ocr', {}) if hasattr(self, 'main_gui') else {} + det_type = ocr_settings.get('detector_type', 'rtdetr_onnx') + model_id = ocr_settings.get('rtdetr_model_url') or ocr_settings.get('bubble_model_path') or '' + key = (det_type, model_id) + self._log(f"[DEBUG] Looking for detector in pool with key: {key}", "debug") + with MangaTranslator._detector_pool_lock: + self._log(f"[DEBUG] Pool keys available: {list(MangaTranslator._detector_pool.keys())}", "debug") + rec = MangaTranslator._detector_pool.get(key) + if rec and isinstance(rec, dict): + spares = rec.get('spares') or [] + self._log(f"[DEBUG] Found pool record with {len(spares)} spares", "debug") + # For singleton mode, we can use a pool instance without checking it out + # since the singleton will keep it loaded permanently + if spares: + # Just use the first spare (don't pop or check out) + # Singleton will keep it loaded, pool can still track it + bd = spares[0] + self._log(f"๐Ÿค– Using pool bubble detector for singleton (no check-out needed)", "info") + else: + self._log(f"[DEBUG] No pool record found for key: {key}", "debug") + except Exception as e: + self._log(f"Could not fetch preloaded detector: {e}", "debug") + + # If no preloaded detector, create a new one + if bd is None: + bd = BubbleDetector() + self._log("๐Ÿค– Created new bubble detector instance", "info") + + # Optionally: defer model load until first actual call inside BD; keeping instance resident + with MangaTranslator._singleton_lock: + MangaTranslator._singleton_bubble_detector = bd + MangaTranslator._singleton_refs += 1 + MangaTranslator._singleton_bd_loading = False + try: + MangaTranslator._singleton_bd_event.set() + except Exception: + pass + elapsed = time.time() - start_time + self._log(f"๐Ÿค– Singleton bubble detector ready (took {elapsed:.2f}s)", "info") + return bd + except Exception as e: + with MangaTranslator._singleton_lock: + MangaTranslator._singleton_bd_loading = False + try: + MangaTranslator._singleton_bd_event.set() + except Exception: + pass + self._log(f"Failed to create singleton bubble detector: {e}", "error") + return None + + def _initialize_singleton_local_inpainter(self): + """Initialize singleton local inpainter instance""" + with MangaTranslator._singleton_lock: + was_existing = MangaTranslator._singleton_local_inpainter is not None + if MangaTranslator._singleton_local_inpainter is None: + try: + from local_inpainter import LocalInpainter + local_method = self.manga_settings.get('inpainting', {}).get('local_method', 'anime') + # LocalInpainter only accepts config_path, not method + MangaTranslator._singleton_local_inpainter = LocalInpainter() + # Now load the model with the specified method + if local_method: + # Try to load the model + model_path = self.manga_settings.get('inpainting', {}).get('local_model_path') + if not model_path: + # Try to download if no path specified + try: + model_path = MangaTranslator._singleton_local_inpainter.download_jit_model(local_method) + except Exception as e: + self._log(f"โš ๏ธ Failed to download model for {local_method}: {e}", "warning") + + if model_path and os.path.exists(model_path): + success = MangaTranslator._singleton_local_inpainter.load_model_with_retry(local_method, model_path) + if success: + self._log(f"๐ŸŽจ Created singleton local inpainter with {local_method} model", "info") + else: + self._log(f"โš ๏ธ Failed to load {local_method} model", "warning") + else: + self._log(f"๐ŸŽจ Created singleton local inpainter (no model loaded yet)", "info") + else: + self._log(f"๐ŸŽจ Created singleton local inpainter (default)", "info") + except Exception as e: + self._log(f"Failed to create singleton local inpainter: {e}", "error") + return + # Use the singleton instance + self.local_inpainter = MangaTranslator._singleton_local_inpainter + self.inpainter = self.local_inpainter + MangaTranslator._singleton_refs += 1 + if was_existing: + self._log("๐ŸŽจ Using local inpainter (already loaded)", "info") + + def _get_thread_bubble_detector(self): + """Get or initialize bubble detector (singleton or thread-local based on settings). + Will consume a preloaded detector if available for current settings. + """ + if getattr(self, 'use_singleton_bubble_detector', False) or (hasattr(self, 'use_singleton_models') and self.use_singleton_models): + # Use singleton instance (preferred) + if self.bubble_detector is None: + self.bubble_detector = self._get_singleton_bubble_detector() + return self.bubble_detector + else: + # Use thread-local instance (original behavior for parallel processing) + if not hasattr(self, '_thread_local') or getattr(self, '_thread_local', None) is None: + self._thread_local = threading.local() + if not hasattr(self._thread_local, 'bubble_detector') or self._thread_local.bubble_detector is None: + from bubble_detector import BubbleDetector + # Try to check out a preloaded spare for the current detector settings + try: + ocr_settings = self.main_gui.config.get('manga_settings', {}).get('ocr', {}) if hasattr(self, 'main_gui') else {} + det_type = ocr_settings.get('detector_type', 'rtdetr_onnx') + model_id = ocr_settings.get('rtdetr_model_url') or ocr_settings.get('bubble_model_path') or '' + key = (det_type, model_id) + with MangaTranslator._detector_pool_lock: + rec = MangaTranslator._detector_pool.get(key) + if rec and isinstance(rec, dict): + spares = rec.get('spares') or [] + # Initialize checked_out list if it doesn't exist + if 'checked_out' not in rec: + rec['checked_out'] = [] + checked_out = rec['checked_out'] + + # Look for an available spare (not checked out) + if spares: + for spare in spares: + if spare not in checked_out and spare: + # Check out this spare instance + checked_out.append(spare) + self._thread_local.bubble_detector = spare + # Store references for later return + self._checked_out_bubble_detector = spare + self._bubble_detector_pool_key = key + available = len(spares) - len(checked_out) + self._log(f"๐Ÿค– Checked out bubble detector from pool ({len(checked_out)}/{len(spares)} in use, {available} available)", "info") + break + except Exception: + pass + # If still not set, create a fresh detector and store it for future use + if not hasattr(self._thread_local, 'bubble_detector') or self._thread_local.bubble_detector is None: + self._thread_local.bubble_detector = BubbleDetector() + self._log("๐Ÿค– Created thread-local bubble detector (NOT added to pool spares to avoid leak)", "debug") + + # IMPORTANT: Do NOT add dynamically created detectors to the pool spares list + # This was causing the pool to grow beyond preloaded count (e.g. 9/5, 10/5) + # Only preloaded detectors should be in spares list for proper tracking + # Just mark it as checked out for return tracking if needed + try: + with MangaTranslator._detector_pool_lock: + if key in MangaTranslator._detector_pool: + rec = MangaTranslator._detector_pool[key] + if 'checked_out' not in rec: + rec['checked_out'] = [] + # Only track in checked_out, NOT in spares + rec['checked_out'].append(self._thread_local.bubble_detector) + # Store references for later return + self._checked_out_bubble_detector = self._thread_local.bubble_detector + self._bubble_detector_pool_key = key + except Exception: + pass + return self._thread_local.bubble_detector + + def _get_thread_local_inpainter(self, local_method: str, model_path: str): + """Get or create a LocalInpainter (singleton or thread-local based on settings). + Loads the requested model if needed. + """ + if hasattr(self, 'use_singleton_models') and self.use_singleton_models: + # Use singleton instance + if self.local_inpainter is None: + self._initialize_singleton_local_inpainter() + return self.local_inpainter + + # Use thread-local instance (original behavior for parallel processing) + # Ensure thread-local storage exists and has a dict + tl = getattr(self, '_thread_local', None) + if tl is None: + self._thread_local = threading.local() + tl = self._thread_local + if not hasattr(tl, 'local_inpainters') or getattr(tl, 'local_inpainters', None) is None: + tl.local_inpainters = {} + key = (local_method or 'anime', model_path or '') + if key not in tl.local_inpainters or tl.local_inpainters[key] is None: + # First, try to check out a preloaded spare instance from the shared pool + # DO NOT pop from spares - use the checkout mechanism to track usage properly + try: + with MangaTranslator._inpaint_pool_lock: + rec = MangaTranslator._inpaint_pool.get(key) + if rec and isinstance(rec, dict): + spares = rec.get('spares') or [] + # Initialize checked_out list if it doesn't exist + if 'checked_out' not in rec: + rec['checked_out'] = [] + checked_out = rec['checked_out'] + + # Look for an available spare (not already checked out) + if spares: + for spare in spares: + if spare not in checked_out and spare and getattr(spare, 'model_loaded', False): + # Mark as checked out (don't remove from spares!) + checked_out.append(spare) + tl.local_inpainters[key] = spare + # Store reference for later return + self._checked_out_inpainter = spare + self._inpainter_pool_key = key + available = len(spares) - len(checked_out) + self._log(f"๐ŸŽจ Using preloaded local inpainting instance ({len(checked_out)}/{len(spares)} in use, {available} available)", "info") + return tl.local_inpainters[key] + + # If there's a fully loaded shared instance but no available spares, use it as a last resort + if rec.get('loaded') and rec.get('inpainter') is not None: + tl.local_inpainters[key] = rec.get('inpainter') + self._log("๐ŸŽจ Using shared preloaded inpainting instance", "info") + return tl.local_inpainters[key] + except Exception: + pass + + # No preloaded instance available: create and load thread-local instance + try: + from local_inpainter import LocalInpainter + # Use a per-thread config path to avoid concurrent JSON writes + try: + import tempfile + thread_cfg = os.path.join(tempfile.gettempdir(), f"gl_inpainter_{threading.get_ident()}.json") + except Exception: + thread_cfg = "config_thread_local.json" + inp = LocalInpainter(config_path=thread_cfg) + # Apply tiling settings + tiling_settings = self.manga_settings.get('tiling', {}) if hasattr(self, 'manga_settings') else {} + inp.tiling_enabled = tiling_settings.get('enabled', False) + inp.tile_size = tiling_settings.get('tile_size', 512) + inp.tile_overlap = tiling_settings.get('tile_overlap', 64) + + # Ensure model is available + resolved_model_path = model_path + if not resolved_model_path or not os.path.exists(resolved_model_path): + try: + resolved_model_path = inp.download_jit_model(local_method) + except Exception as e: + self._log(f"โš ๏ธ JIT model download failed for {local_method}: {e}", "warning") + resolved_model_path = None + + # Load model for this thread's instance + if resolved_model_path and os.path.exists(resolved_model_path): + try: + self._log(f"๐Ÿ“ฅ Loading {local_method} inpainting model (thread-local)", "info") + inp.load_model_with_retry(local_method, resolved_model_path, force_reload=False) + except Exception as e: + self._log(f"โš ๏ธ Thread-local inpainter load error: {e}", "warning") + else: + self._log("โš ๏ธ No model path available for thread-local inpainter", "warning") + + # Re-check thread-local and publish ONLY if model loaded successfully + tl2 = getattr(self, '_thread_local', None) + if tl2 is None: + self._thread_local = threading.local() + tl2 = self._thread_local + if not hasattr(tl2, 'local_inpainters') or getattr(tl2, 'local_inpainters', None) is None: + tl2.local_inpainters = {} + if getattr(inp, 'model_loaded', False): + tl2.local_inpainters[key] = inp + + # Store this loaded instance info in the pool for future reuse + try: + with MangaTranslator._inpaint_pool_lock: + if key not in MangaTranslator._inpaint_pool: + MangaTranslator._inpaint_pool[key] = {'inpainter': None, 'loaded': False, 'event': threading.Event(), 'spares': []} + # Mark that we have a loaded instance available + MangaTranslator._inpaint_pool[key]['loaded'] = True + MangaTranslator._inpaint_pool[key]['inpainter'] = inp # Store reference + if MangaTranslator._inpaint_pool[key].get('event'): + MangaTranslator._inpaint_pool[key]['event'].set() + except Exception: + pass + else: + # Ensure future calls will attempt a fresh init instead of using a half-initialized instance + tl2.local_inpainters[key] = None + except Exception as e: + self._log(f"โŒ Failed to create thread-local inpainter: {e}", "error") + try: + tl3 = getattr(self, '_thread_local', None) + if tl3 is None: + self._thread_local = threading.local() + tl3 = self._thread_local + if not hasattr(tl3, 'local_inpainters') or getattr(tl3, 'local_inpainters', None) is None: + tl3.local_inpainters = {} + tl3.local_inpainters[key] = None + except Exception: + pass + return getattr(self._thread_local, 'local_inpainters', {}).get(key) + + def translate_regions(self, regions: List[TextRegion], image_path: str) -> List[TextRegion]: + """Translate all text regions with API delay""" + self._log(f"\n๐Ÿ“ Translating {len(regions)} text regions...") + + # Check stop before even starting + if self._check_stop(): + self._log(f"\nโน๏ธ Translation stopped before processing any regions", "warning") + return regions + + # Check if parallel processing OR batch translation is enabled + parallel_enabled = self.manga_settings.get('advanced', {}).get('parallel_processing', False) + batch_enabled = getattr(self, 'batch_mode', False) + max_workers = self.manga_settings.get('advanced', {}).get('max_workers', 4) + + # Batch translation (parallel API calls) should work independently of parallel processing + if batch_enabled: + max_workers = getattr(self, 'batch_size', max_workers) + self._log(f"๐Ÿ“ฆ Using BATCH TRANSLATION with {max_workers} concurrent API calls") + return self._translate_regions_parallel(regions, image_path, max_workers) + elif parallel_enabled and len(regions) > 1: + self._log(f"๐Ÿš€ Using PARALLEL processing with {max_workers} workers") + return self._translate_regions_parallel(regions, image_path, max_workers) + else: + # SEQUENTIAL CODE + for i, region in enumerate(regions): + if self._check_stop(): + self._log(f"\nโน๏ธ Translation stopped by user after {i}/{len(regions)} regions", "warning") + break + if region.text.strip(): + self._log(f"\n[{i+1}/{len(regions)}] Original: {region.text}") + + # Get context for translation + context = self.translation_context[-5:] if self.contextual_enabled else None + + # Translate with image context + translated = self.translate_text( + region.text, + context, + image_path=image_path, + region=region + ) + region.translated_text = translated + + self._log(f"Translated: {translated}") + + # SAVE TO HISTORY HERE + if self.history_manager and self.contextual_enabled and translated: + try: + self.history_manager.append_to_history( + user_content=region.text, + assistant_content=translated, + hist_limit=self.translation_history_limit, + reset_on_limit=not self.rolling_history_enabled, + rolling_window=self.rolling_history_enabled + ) + self._log(f"๐Ÿ“š Saved to history (exchange {i+1})") + except Exception as e: + self._log(f"โš ๏ธ Failed to save history: {e}", "warning") + + # Apply API delay + if i < len(regions) - 1: # Don't delay after last translation + self._log(f"โณ Waiting {self.api_delay}s before next translation...") + # Check stop flag every 0.1 seconds during delay + for _ in range(int(self.api_delay * 10)): + if self._check_stop(): + self._log(f"\nโน๏ธ Translation stopped during delay", "warning") + return regions + time.sleep(0.1) + + return regions + + # parallel processing: + + def _wait_for_api_slot(self, min_interval=None, jitter_max=0.25): + """Global, thread-safe front-edge rate limiter for API calls. + Ensures parallel requests are spaced out before dispatch, avoiding tail latency. + """ + import time + import random + import threading + + if min_interval is None: + try: + min_interval = float(getattr(self, "api_delay", 0.0)) + except Exception: + min_interval = 0.0 + if min_interval < 0: + min_interval = 0.0 + + # Lazy init shared state + if not hasattr(self, "_api_rl_lock"): + self._api_rl_lock = threading.Lock() + self._api_next_allowed = 0.0 # monotonic seconds + + while True: + now = time.monotonic() + with self._api_rl_lock: + # If we're allowed now, book the next slot and proceed + if now >= self._api_next_allowed: + jitter = random.uniform(0.0, max(jitter_max, 0.0)) if jitter_max else 0.0 + self._api_next_allowed = now + min_interval + jitter + return + + # Otherwise compute wait time (donโ€™t hold the lock while sleeping) + wait = self._api_next_allowed - now + + # Sleep outside the lock in short increments so stop flags can be honored + if wait > 0: + try: + if self._check_stop(): + return + except Exception: + pass + time.sleep(min(wait, 0.05)) + + def _translate_regions_parallel(self, regions: List[TextRegion], image_path: str, max_workers: int = None) -> List[TextRegion]: + """Translate regions using parallel processing""" + # Get max_workers from settings if not provided + if max_workers is None: + max_workers = self.manga_settings.get('advanced', {}).get('max_workers', 4) + + # Override with API batch size when batch mode is enabled โ€” these are API calls. + try: + if getattr(self, 'batch_mode', False): + bs = int(getattr(self, 'batch_size', 0) or int(os.getenv('BATCH_SIZE', '0'))) + if bs and bs > 0: + max_workers = bs + except Exception: + pass + # Bound to number of regions + max_workers = max(1, min(max_workers, len(regions))) + + # Thread-safe storage for results + results_lock = threading.Lock() + translated_regions = {} + failed_indices = [] + + # Filter out empty regions + valid_regions = [(i, region) for i, region in enumerate(regions) if region.text.strip()] + + if not valid_regions: + return regions + + # Create a thread pool + with ThreadPoolExecutor(max_workers=max_workers) as executor: + # Submit all translation tasks + future_to_data = {} + + for i, region in valid_regions: + # Check for stop signal before submitting + if self._check_stop(): + self._log(f"\nโน๏ธ Translation stopped before submitting region {i+1}", "warning") + break + + # Submit translation task + future = executor.submit( + self._translate_single_region_parallel, + region, + i, + len(valid_regions), + image_path + ) + future_to_data[future] = (i, region) + + # Process completed translations + completed = 0 + for future in as_completed(future_to_data): + i, region = future_to_data[future] + + # Check for stop signal + if self._check_stop(): + self._log(f"\nโน๏ธ Translation stopped at {completed}/{len(valid_regions)} completed", "warning") + # Cancel remaining futures + for f in future_to_data: + f.cancel() + break + + try: + translated_text = future.result() + if translated_text: + with results_lock: + translated_regions[i] = translated_text + completed += 1 + self._log(f"โœ… [{completed}/{len(valid_regions)}] Completed region {i+1}") + else: + with results_lock: + failed_indices.append(i) + self._log(f"โŒ [{completed}/{len(valid_regions)}] Failed region {i+1}", "error") + + except Exception as e: + with results_lock: + failed_indices.append(i) + self._log(f"โŒ Error in region {i+1}: {str(e)}", "error") + + # Apply translations back to regions + for i, region in enumerate(regions): + if i in translated_regions: + region.translated_text = translated_regions[i] + + # Report summary + success_count = len(translated_regions) + fail_count = len(failed_indices) + self._log(f"\n๐Ÿ“Š Parallel translation complete: {success_count} succeeded, {fail_count} failed") + + return regions + + def reset_for_new_image(self): + """Reset internal state for processing a new image""" + # ============================================================ + # CRITICAL: COMPREHENSIVE CACHE CLEARING FOR NEW IMAGE + # This ensures NO text data leaks between images + # ============================================================ + + # Clear any cached detection results + if hasattr(self, 'last_detection_results'): + del self.last_detection_results + + # FORCE clear OCR ROI cache (main text contamination source) + # THREAD-SAFE: Use lock for parallel panel translation + if hasattr(self, 'ocr_roi_cache'): + with self._cache_lock: + self.ocr_roi_cache.clear() + self._current_image_hash = None + + # Clear OCR manager and ALL provider caches + if hasattr(self, 'ocr_manager') and self.ocr_manager: + if hasattr(self.ocr_manager, 'last_results'): + self.ocr_manager.last_results = None + if hasattr(self.ocr_manager, 'cache'): + self.ocr_manager.cache.clear() + # Clear ALL provider-level caches + if hasattr(self.ocr_manager, 'providers'): + for provider_name, provider in self.ocr_manager.providers.items(): + if hasattr(provider, 'last_results'): + provider.last_results = None + if hasattr(provider, 'cache'): + provider.cache.clear() + + # Clear bubble detector cache + if hasattr(self, 'bubble_detector') and self.bubble_detector: + if hasattr(self.bubble_detector, 'last_detections'): + self.bubble_detector.last_detections = None + if hasattr(self.bubble_detector, 'cache'): + self.bubble_detector.cache.clear() + + # Don't clear translation context if using rolling history + if not self.rolling_history_enabled: + self.translation_context = [] + + # Clear any cached regions + if hasattr(self, '_cached_regions'): + del self._cached_regions + + self._log("๐Ÿ”„ Reset translator state for new image (ALL text caches cleared)", "debug") + + def _translate_single_region_parallel(self, region: TextRegion, index: int, total: int, image_path: str) -> Optional[str]: + """Translate a single region for parallel processing""" + try: + thread_name = threading.current_thread().name + self._log(f"\n[{thread_name}] [{index+1}/{total}] Original: {region.text}") + + # Note: Context is not used in parallel mode to avoid race conditions + # Pass None for context to maintain compatibility with your translate_text method + # Front-edge rate limiting across threads + self._wait_for_api_slot() + + translated = self.translate_text( + region.text, + None, # No context in parallel mode + image_path=image_path, + region=region + ) + + if translated: + self._log(f"[{thread_name}] Translated: {translated}") + return translated + else: + self._log(f"[{thread_name}] Translation failed", "error") + return None + + except Exception as e: + self._log(f"[{thread_name}] Error: {str(e)}", "error") + return None + + + def _is_bubble_detector_loaded(self, ocr_settings: Dict[str, Any]) -> Tuple[bool, str]: + """Check if the configured bubble detector's model is already loaded. + Returns (loaded, detector_type). Safe: does not trigger a load. + """ + try: + bd = self._get_thread_bubble_detector() + except Exception: + return False, ocr_settings.get('detector_type', 'rtdetr_onnx') + det = ocr_settings.get('detector_type', 'rtdetr_onnx') + try: + if det == 'rtdetr_onnx': + return bool(getattr(bd, 'rtdetr_onnx_loaded', False)), det + elif det == 'rtdetr': + return bool(getattr(bd, 'rtdetr_loaded', False)), det + elif det == 'yolo': + return bool(getattr(bd, 'model_loaded', False)), det + else: + # Auto or unknown โ€“ consider any ready model as loaded + ready = bool(getattr(bd, 'rtdetr_loaded', False) or getattr(bd, 'rtdetr_onnx_loaded', False) or getattr(bd, 'model_loaded', False)) + return ready, det + except Exception: + return False, det + + def _is_local_inpainter_loaded(self) -> Tuple[bool, Optional[str]]: + """Check if a local inpainter model is already loaded for current settings. + Returns (loaded, local_method) or (False, None). + This respects UI flags: skip_inpainting / use_cloud_inpainting. + """ + try: + # If skipping or using cloud, this does not apply + if getattr(self, 'skip_inpainting', False) or getattr(self, 'use_cloud_inpainting', False): + return False, None + except Exception: + pass + inpaint_cfg = self.manga_settings.get('inpainting', {}) if hasattr(self, 'manga_settings') else {} + local_method = inpaint_cfg.get('local_method', 'anime') + try: + model_path = self.main_gui.config.get(f'manga_{local_method}_model_path', '') if hasattr(self, 'main_gui') else '' + except Exception: + model_path = '' + # Singleton path + if getattr(self, 'use_singleton_models', False): + inp = getattr(MangaTranslator, '_singleton_local_inpainter', None) + return (bool(getattr(inp, 'model_loaded', False)), local_method) + # Thread-local/pooled path + inp = getattr(self, 'local_inpainter', None) + if inp is not None and getattr(inp, 'model_loaded', False): + return True, local_method + try: + key = (local_method, model_path or '') + rec = MangaTranslator._inpaint_pool.get(key) + # Consider the shared 'inpainter' loaded or any spare that is model_loaded + if rec: + if rec.get('loaded') and rec.get('inpainter') is not None and getattr(rec['inpainter'], 'model_loaded', False): + return True, local_method + for spare in rec.get('spares') or []: + if getattr(spare, 'model_loaded', False): + return True, local_method + except Exception: + pass + return False, local_method + + def _log_model_status(self): + """Emit concise status lines for already-loaded heavy models to avoid confusing 'loading' logs.""" + try: + ocr_settings = self.manga_settings.get('ocr', {}) if hasattr(self, 'manga_settings') else {} + if ocr_settings.get('bubble_detection_enabled', False): + loaded, det = self._is_bubble_detector_loaded(ocr_settings) + det_name = 'YOLO' if det == 'yolo' else ('RT-DETR' if det == 'rtdetr' else 'RTEDR_onnx') + if loaded: + self._log("๐Ÿค– Using bubble detector (already loaded)", "info") + else: + self._log("๐Ÿค– Bubble detector will load on first use", "debug") + except Exception: + pass + try: + loaded, local_method = self._is_local_inpainter_loaded() + if local_method: + label = local_method.upper() + if loaded: + self._log("๐ŸŽจ Using local inpainter (already loaded)", "info") + else: + self._log("๐ŸŽจ Local inpainter will load on first use", "debug") + except Exception: + pass + + def process_image(self, image_path: str, output_path: Optional[str] = None, + batch_index: int = None, batch_total: int = None) -> Dict[str, Any]: + """Process a single manga image through the full pipeline""" + # Ensure local references exist for cleanup in finally + image = None + inpainted = None + final_image = None + mask = None + mask_viz = None + pil_image = None + heatmap = None + + # Set batch tracking if provided + if batch_index is not None and batch_total is not None: + self.batch_current = batch_index + self.batch_size = batch_total + self.batch_mode = True + + # Simplified header for batch mode + if not self.batch_mode: + self._log(f"\n{'='*60}") + self._log(f"๐Ÿ“ท STARTING MANGA TRANSLATION PIPELINE") + self._log(f"๐Ÿ“ Input: {image_path}") + self._log(f"๐Ÿ“ Output: {output_path or 'Auto-generated'}") + self._log(f"{'='*60}\n") + else: + self._log(f"\n[{batch_index}/{batch_total}] Processing: {os.path.basename(image_path)}") + + # Before heavy work, report model status to avoid confusing 'loading' logs later + try: + self._log_model_status() + except Exception: + pass + + result = { + 'success': False, + 'input_path': image_path, + 'output_path': output_path, + 'regions': [], + 'errors': [], + 'interrupted': False, + 'format_info': {} + } + + try: + # RAM cap gating before heavy processing + try: + self._block_if_over_cap("processing image") + except Exception: + pass + + # Determine the output directory from output_path + if output_path: + output_dir = os.path.dirname(output_path) + else: + # If no output path specified, use default + output_dir = os.path.join(os.path.dirname(image_path), "translated_images") + + # Ensure output directory exists + os.makedirs(output_dir, exist_ok=True) + + # Initialize HistoryManager with the output directory + if self.contextual_enabled and not self.history_manager_initialized: + # Only initialize if we're in a new output directory + if output_dir != getattr(self, 'history_output_dir', None): + try: + self.history_manager = HistoryManager(output_dir) + self.history_manager_initialized = True + self.history_output_dir = output_dir + self._log(f"๐Ÿ“š Initialized HistoryManager in output directory: {output_dir}") + except Exception as e: + self._log(f"โš ๏ธ Failed to initialize history manager: {str(e)}", "warning") + self.history_manager = None + + # Check for stop signal + if self._check_stop(): + result['interrupted'] = True + self._log("โน๏ธ Translation stopped before processing", "warning") + return result + + # Format detection if enabled + if self.manga_settings.get('advanced', {}).get('format_detection', False): + self._log("๐Ÿ” Analyzing image format...") + img = Image.open(image_path) + width, height = img.size + aspect_ratio = height / width + + # Detect format type + format_info = { + 'width': width, + 'height': height, + 'aspect_ratio': aspect_ratio, + 'is_webtoon': aspect_ratio > 3.0, + 'is_spread': width > height * 1.3, + 'format': 'unknown' + } + + if format_info['is_webtoon']: + format_info['format'] = 'webtoon' + self._log("๐Ÿ“ฑ Detected WEBTOON format - vertical scroll manga") + elif format_info['is_spread']: + format_info['format'] = 'spread' + self._log("๐Ÿ“– Detected SPREAD format - two-page layout") + else: + format_info['format'] = 'single_page' + self._log("๐Ÿ“„ Detected SINGLE PAGE format") + + result['format_info'] = format_info + + # Handle webtoon mode if detected and enabled + webtoon_mode = self.manga_settings.get('advanced', {}).get('webtoon_mode', 'auto') + if format_info['is_webtoon'] and webtoon_mode != 'disabled': + if webtoon_mode == 'auto' or webtoon_mode == 'force': + self._log("๐Ÿ”„ Webtoon mode active - will process in chunks for better OCR") + # Process webtoon in chunks + return self._process_webtoon_chunks(image_path, output_path, result) + + # Step 1: Detect text regions using Google Cloud Vision + self._log(f"๐Ÿ“ [STEP 1] Text Detection Phase") + regions = self.detect_text_regions(image_path) + + if not regions: + error_msg = "No text regions detected by Cloud Vision" + self._log(f"โš ๏ธ {error_msg}", "warning") + result['errors'].append(error_msg) + # Still save the original image as "translated" if no text found + if output_path: + import shutil + shutil.copy2(image_path, output_path) + result['output_path'] = output_path + result['success'] = True + return result + + self._log(f"\nโœ… Detection complete: {len(regions)} regions found") + + # Save debug outputs only if 'Save intermediate images' is enabled + if self.manga_settings.get('advanced', {}).get('save_intermediate', False): + self._save_debug_image(image_path, regions, debug_base_dir=output_dir) + + # Step 2: Translation & Inpainting (concurrent) + self._log(f"\n๐Ÿ“ [STEP 2] Translation & Inpainting Phase (concurrent)") + + # Load image once (used by inpainting task); keep PIL fallback for Unicode paths + import cv2 + self._log(f"๐Ÿ–ผ๏ธ Loading image with OpenCV...") + try: + image = cv2.imread(image_path) + if image is None: + self._log(f" Using PIL to handle Unicode path...", "info") + from PIL import Image as PILImage + import numpy as np + pil_image = PILImage.open(image_path) + image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) + self._log(f" โœ… Successfully loaded with PIL", "info") + except Exception as e: + error_msg = f"Failed to load image: {image_path} - {str(e)}" + self._log(f"โŒ {error_msg}", "error") + result['errors'].append(error_msg) + return result + + self._log(f" Image dimensions: {image.shape[1]}x{image.shape[0]}") + + # Save intermediate original image if enabled + if self.manga_settings.get('advanced', {}).get('save_intermediate', False): + self._save_intermediate_image(image_path, image, "original", debug_base_dir=output_dir) + + # Check if we should continue before kicking off tasks + if self._check_stop(): + result['interrupted'] = True + self._log("โน๏ธ Translation stopped before concurrent phase", "warning") + return result + + # Helper tasks + def _task_translate(): + try: + if self.full_page_context_enabled: + # Full page context translation mode + self._log(f"\n๐Ÿ“„ Using FULL PAGE CONTEXT mode") + self._log(" This mode sends all text together for more consistent translations", "info") + if self._check_stop(): + return False + translations = self.translate_full_page_context(regions, image_path) + if translations: + translated_count = sum(1 for r in regions if getattr(r, 'translated_text', None) and r.translated_text and r.translated_text != r.text) + self._log(f"\n๐Ÿ“Š Full page context translation complete: {translated_count}/{len(regions)} regions translated") + return True + else: + self._log("โŒ Full page context translation failed", "error") + result['errors'].append("Full page context translation failed") + return False + else: + # Individual translation mode with parallel processing support + self._log(f"\n๐Ÿ“ Using INDIVIDUAL translation mode") + if self.manga_settings.get('advanced', {}).get('parallel_processing', False): + self._log("โšก Parallel processing ENABLED") + _ = self._translate_regions_parallel(regions, image_path) + else: + _ = self.translate_regions(regions, image_path) + return True + except Exception as te: + self._log(f"โŒ Translation task error: {te}", "error") + return False + + def _task_inpaint(): + try: + if getattr(self, 'skip_inpainting', False): + self._log(f"๐ŸŽจ Skipping inpainting (preserving original art)", "info") + return image.copy() + + self._log(f"๐ŸŽญ Creating text mask...") + try: + self._block_if_over_cap("mask creation") + except Exception: + pass + mask_local = self.create_text_mask(image, regions) + + # Save mask and overlay only if 'Save intermediate images' is enabled + if self.manga_settings.get('advanced', {}).get('save_intermediate', False): + try: + debug_dir = os.path.join(output_dir, 'debug') + os.makedirs(debug_dir, exist_ok=True) + base_name = os.path.splitext(os.path.basename(image_path))[0] + mask_path = os.path.join(debug_dir, f"{base_name}_mask.png") + cv2.imwrite(mask_path, mask_local) + mask_percentage = ((mask_local > 0).sum() / mask_local.size) * 100 + self._log(f" ๐ŸŽญ DEBUG: Saved mask to {mask_path}", "info") + self._log(f" ๐Ÿ“Š Mask coverage: {mask_percentage:.1f}% of image", "info") + + # Save mask overlay visualization + mask_viz_local = image.copy() + mask_viz_local[mask_local > 0] = [0, 0, 255] + viz_path = os.path.join(debug_dir, f"{base_name}_mask_overlay.png") + cv2.imwrite(viz_path, mask_viz_local) + self._log(f" ๐ŸŽญ DEBUG: Saved mask overlay to {viz_path}", "info") + except Exception as e: + self._log(f" โŒ Failed to save mask debug: {str(e)}", "error") + + # Also save intermediate copies + try: + self._save_intermediate_image(image_path, mask_local, "mask", debug_base_dir=output_dir) + except Exception: + pass + + self._log(f"๐ŸŽจ Inpainting to remove original text") + try: + self._block_if_over_cap("inpainting") + except Exception: + pass + inpainted_local = self.inpaint_regions(image, mask_local) + + if self.manga_settings.get('advanced', {}).get('save_intermediate', False): + try: + self._save_intermediate_image(image_path, inpainted_local, "inpainted", debug_base_dir=output_dir) + except Exception: + pass + return inpainted_local + except Exception as ie: + self._log(f"โŒ Inpainting task error: {ie}", "error") + return image.copy() + + # Gate on advanced setting (default enabled) + adv = self.manga_settings.get('advanced', {}) + run_concurrent = adv.get('concurrent_inpaint_translate', True) + + if run_concurrent: + self._log("๐Ÿ”€ Running translation and inpainting concurrently", "info") + with ThreadPoolExecutor(max_workers=2) as _executor: + fut_translate = _executor.submit(_task_translate) + fut_inpaint = _executor.submit(_task_inpaint) + # Wait for completion + try: + translate_ok = fut_translate.result() + except Exception: + translate_ok = False + try: + inpainted = fut_inpaint.result() + except Exception: + inpainted = image.copy() + else: + self._log("โ†ช๏ธ Concurrent mode disabled โ€” running sequentially", "info") + translate_ok = _task_translate() + inpainted = _task_inpaint() + + # After concurrent phase, validate translation + if self._check_stop(): + result['interrupted'] = True + self._log("โน๏ธ Translation cancelled before rendering", "warning") + result['regions'] = [r.to_dict() for r in regions] + return result + + if not any(getattr(region, 'translated_text', None) for region in regions): + result['interrupted'] = True + self._log("โน๏ธ No regions were translated - translation was interrupted", "warning") + result['regions'] = [r.to_dict() for r in regions] + return result + + # Render translated text + self._log(f"โœ๏ธ Rendering translated text...") + self._log(f" Using enhanced renderer with custom settings", "info") + final_image = self.render_translated_text(inpainted, regions) + + # Save output + try: + if not output_path: + base, ext = os.path.splitext(image_path) + output_path = f"{base}_translated{ext}" + + success = cv2.imwrite(output_path, final_image) + + if not success: + self._log(f" Using PIL to save with Unicode path...", "info") + from PIL import Image as PILImage + + rgb_image = cv2.cvtColor(final_image, cv2.COLOR_BGR2RGB) + pil_image = PILImage.fromarray(rgb_image) + pil_image.save(output_path) + self._log(f" โœ… Successfully saved with PIL", "info") + + result['output_path'] = output_path + self._log(f"\n๐Ÿ’พ Saved output to: {output_path}") + + except Exception as e: + error_msg = f"Failed to save output image: {str(e)}" + self._log(f"โŒ {error_msg}", "error") + result['errors'].append(error_msg) + result['success'] = False + return result + + # Update result + result['regions'] = [r.to_dict() for r in regions] + if not result.get('interrupted', False): + result['success'] = True + self._log(f"\nโœ… TRANSLATION PIPELINE COMPLETE", "success") + else: + self._log(f"\nโš ๏ธ TRANSLATION INTERRUPTED - Partial output saved", "warning") + + self._log(f"{'='*60}\n") + + except Exception as e: + error_msg = f"Error processing image: {str(e)}\n{traceback.format_exc()}" + self._log(f"\nโŒ PIPELINE ERROR:", "error") + self._log(f" {str(e)}", "error") + self._log(f" Type: {type(e).__name__}", "error") + self._log(traceback.format_exc(), "error") + result['errors'].append(error_msg) + finally: + # Per-image memory cleanup to reduce RAM growth across pages + try: + # Clear self-held large attributes + try: + self.current_image = None + self.current_mask = None + self.final_image = None + self.text_regions = [] + self.translated_regions = [] + except Exception: + pass + + # Clear local large objects if present + locs = locals() + for name in [ + 'image', 'inpainted', 'final_image', 'mask', 'mask_viz', 'pil_image', 'heatmap' + ]: + try: + if name in locs: + # Explicitly delete reference from locals + del locs[name] + except Exception: + pass + + # Reset caches for the next image (non-destructive to loaded models) + try: + self.reset_for_new_image() + except Exception: + pass + + # Encourage release of native resources + try: + import cv2 as _cv2 + try: + _cv2.destroyAllWindows() + except Exception: + pass + except Exception: + pass + + # Free CUDA memory if torch is available + try: + import torch + if torch.cuda.is_available(): + torch.cuda.empty_cache() + except Exception: + pass + + # Release thread-local heavy objects to curb RAM growth across runs + try: + self._cleanup_thread_locals() + except Exception: + pass + + # Deep cleanup control - respects user settings and parallel processing + try: + # Check if auto cleanup is enabled in settings + auto_cleanup_enabled = False # Default disabled by default + try: + if hasattr(self, 'manga_settings'): + auto_cleanup_enabled = self.manga_settings.get('advanced', {}).get('auto_cleanup_models', False) + except Exception: + pass + + if not auto_cleanup_enabled: + # User has disabled automatic cleanup + self._log("๐Ÿ”‘ Auto cleanup disabled - models will remain in RAM", "debug") + else: + # Determine if we should cleanup now + should_cleanup_now = True + + # Check if we're in batch mode + is_last_in_batch = False + try: + if getattr(self, 'batch_mode', False): + bc = getattr(self, 'batch_current', None) + bt = getattr(self, 'batch_size', None) + if bc is not None and bt is not None: + is_last_in_batch = (bc >= bt) + # In batch mode, only cleanup at the end + should_cleanup_now = is_last_in_batch + except Exception: + pass + + # For parallel panel translation, cleanup is handled differently + # (it's handled in manga_integration.py after all panels complete) + is_parallel_panel = False + try: + if hasattr(self, 'manga_settings'): + is_parallel_panel = self.manga_settings.get('advanced', {}).get('parallel_panel_translation', False) + except Exception: + pass + + if is_parallel_panel: + # Don't cleanup here - let manga_integration handle it after all panels + self._log("๐ŸŽฏ Deferring cleanup until all parallel panels complete", "debug") + should_cleanup_now = False + + if should_cleanup_now: + # Perform the cleanup + self._deep_cleanup_models() + + # Also clear HF cache for RT-DETR (best-effort) + if is_last_in_batch or not getattr(self, 'batch_mode', False): + try: + self._clear_hf_cache() + except Exception: + pass + except Exception: + pass + + # Force a garbage collection cycle + try: + import gc + gc.collect() + except Exception: + pass + + # Aggressively trim process working set (Windows) or libc heap (Linux) + try: + self._trim_working_set() + except Exception: + pass + except Exception: + # Never let cleanup fail the pipeline + pass + + return result + + def reset_history_manager(self): + """Reset history manager for new translation batch""" + self.history_manager = None + self.history_manager_initialized = False + self.history_output_dir = None + self.translation_context = [] + self._log("๐Ÿ“š Reset history manager for new batch", "debug") + + def cleanup_all_models(self): + """Public method to force cleanup of all models - call this after translation! + This ensures all models (YOLO, RT-DETR, inpainters, OCR) are unloaded from RAM. + """ + self._log("๐Ÿงน Forcing cleanup of all models to free RAM...", "info") + + # Call the comprehensive cleanup + self._deep_cleanup_models() + + # Also cleanup thread locals + try: + self._cleanup_thread_locals() + except Exception: + pass + + # Clear HF cache + try: + self._clear_hf_cache() + except Exception: + pass + + # Trim working set + try: + self._trim_working_set() + except Exception: + pass + + self._log("โœ… All models cleaned up - RAM freed!", "info") + + def clear_internal_state(self): + """Clear all internal state and cached data to free memory. + This is called when the translator instance is being reset. + Ensures OCR manager, inpainters, and bubble detector are also cleaned. + """ + try: + # Clear image data + self.current_image = None + self.current_mask = None + self.final_image = None + + # Clear text regions + if hasattr(self, 'text_regions'): + self.text_regions = [] + if hasattr(self, 'translated_regions'): + self.translated_regions = [] + + # Clear ALL caches (including text caches) + # THREAD-SAFE: Use lock for parallel panel translation + if hasattr(self, 'cache'): + self.cache.clear() + if hasattr(self, 'ocr_roi_cache'): + with self._cache_lock: + self.ocr_roi_cache.clear() + self._current_image_hash = None + + # Clear history and context + if hasattr(self, 'translation_context'): + self.translation_context = [] + if hasattr(self, 'history_manager'): + self.history_manager = None + self.history_manager_initialized = False + self.history_output_dir = None + + # IMPORTANT: Properly unload OCR manager + if hasattr(self, 'ocr_manager') and self.ocr_manager: + try: + ocr = self.ocr_manager + if hasattr(ocr, 'providers'): + for provider_name, provider in ocr.providers.items(): + # Clear all model references + if hasattr(provider, 'model'): + provider.model = None + if hasattr(provider, 'processor'): + provider.processor = None + if hasattr(provider, 'tokenizer'): + provider.tokenizer = None + if hasattr(provider, 'reader'): + provider.reader = None + if hasattr(provider, 'client'): + provider.client = None + if hasattr(provider, 'is_loaded'): + provider.is_loaded = False + ocr.providers.clear() + self.ocr_manager = None + self._log(" โœ“ OCR manager cleared", "debug") + except Exception as e: + self._log(f" Warning: OCR cleanup failed: {e}", "debug") + + # IMPORTANT: Handle local inpainter cleanup carefully + # DO NOT unload if it's a shared/checked-out instance from the pool + if hasattr(self, 'local_inpainter') and self.local_inpainter: + try: + # Only unload if this is NOT a checked-out or shared instance + is_from_pool = hasattr(self, '_checked_out_inpainter') or hasattr(self, '_inpainter_pool_key') + if not is_from_pool and hasattr(self.local_inpainter, 'unload'): + self.local_inpainter.unload() + self._log(" โœ“ Local inpainter unloaded", "debug") + else: + self._log(" โœ“ Local inpainter reference cleared (pool instance preserved)", "debug") + self.local_inpainter = None + except Exception as e: + self._log(f" Warning: Inpainter cleanup failed: {e}", "debug") + + # Also clear hybrid and generic inpainter references + if hasattr(self, 'hybrid_inpainter'): + if self.hybrid_inpainter and hasattr(self.hybrid_inpainter, 'unload'): + try: + self.hybrid_inpainter.unload() + except Exception: + pass + self.hybrid_inpainter = None + + if hasattr(self, 'inpainter'): + if self.inpainter and hasattr(self.inpainter, 'unload'): + try: + self.inpainter.unload() + except Exception: + pass + self.inpainter = None + + # IMPORTANT: Handle bubble detector cleanup carefully + # DO NOT unload if it's a singleton or from a preloaded pool + if hasattr(self, 'bubble_detector') and self.bubble_detector: + try: + is_singleton = getattr(self, 'use_singleton_bubble_detector', False) + # Check if it's from thread-local which might have gotten it from the pool + is_from_pool = hasattr(self, '_thread_local') and hasattr(self._thread_local, 'bubble_detector') + + if not is_singleton and not is_from_pool: + if hasattr(self.bubble_detector, 'unload'): + self.bubble_detector.unload(release_shared=True) + self._log(" โœ“ Bubble detector unloaded", "debug") + else: + self._log(" โœ“ Bubble detector reference cleared (pool/singleton instance preserved)", "debug") + # In all cases, clear our instance reference + self.bubble_detector = None + except Exception as e: + self._log(f" Warning: Bubble detector cleanup failed: {e}", "debug") + + # Clear any file handles or temp data + if hasattr(self, '_thread_local'): + try: + self._cleanup_thread_locals() + except Exception: + pass + + # Clear processing flags + self.is_processing = False + self.cancel_requested = False + + self._log("๐Ÿงน Internal state and all components cleared", "debug") + + except Exception as e: + self._log(f"โš ๏ธ Warning: Failed to clear internal state: {e}", "warning") + + def _process_webtoon_chunks(self, image_path: str, output_path: str, result: Dict) -> Dict: + """Process webtoon in chunks for better OCR""" + import cv2 + import numpy as np + from PIL import Image as PILImage + + try: + self._log("๐Ÿ“ฑ Processing webtoon in chunks for better OCR", "info") + + # Load the image + image = cv2.imread(image_path) + if image is None: + pil_image = PILImage.open(image_path) + image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) + + height, width = image.shape[:2] + + # Get chunk settings from config + chunk_height = self.manga_settings.get('preprocessing', {}).get('chunk_height', 1000) + chunk_overlap = self.manga_settings.get('preprocessing', {}).get('chunk_overlap', 100) + + self._log(f" Image dimensions: {width}x{height}", "info") + self._log(f" Chunk height: {chunk_height}px, Overlap: {chunk_overlap}px", "info") + + # Calculate number of chunks needed + effective_chunk_height = chunk_height - chunk_overlap + num_chunks = max(1, (height - chunk_overlap) // effective_chunk_height + 1) + + self._log(f" Will process in {num_chunks} chunks", "info") + + # Process each chunk + all_regions = [] + chunk_offsets = [] + + for i in range(num_chunks): + # Calculate chunk boundaries + start_y = i * effective_chunk_height + end_y = min(start_y + chunk_height, height) + + # Make sure we don't miss the bottom part + if i == num_chunks - 1: + end_y = height + + self._log(f"\n ๐Ÿ“„ Processing chunk {i+1}/{num_chunks} (y: {start_y}-{end_y})", "info") + + # Extract chunk + chunk = image[start_y:end_y, 0:width] + + # Save chunk temporarily for OCR + import tempfile + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp: + chunk_path = tmp.name + cv2.imwrite(chunk_path, chunk) + + try: + # Detect text in this chunk + chunk_regions = self.detect_text_regions(chunk_path) + + # Adjust region coordinates to full image space + for region in chunk_regions: + # Adjust bounding box + x, y, w, h = region.bounding_box + region.bounding_box = (x, y + start_y, w, h) + + # Adjust vertices if present + if hasattr(region, 'vertices') and region.vertices: + adjusted_vertices = [] + for vx, vy in region.vertices: + adjusted_vertices.append((vx, vy + start_y)) + region.vertices = adjusted_vertices + + # Mark which chunk this came from (for deduplication) + region.chunk_index = i + region.chunk_y_range = (start_y, end_y) + + all_regions.extend(chunk_regions) + chunk_offsets.append(start_y) + + self._log(f" Found {len(chunk_regions)} text regions in chunk {i+1}", "info") + + finally: + # Clean up temp file + import os + if os.path.exists(chunk_path): + os.remove(chunk_path) + + # Remove duplicate regions from overlapping areas + self._log(f"\n ๐Ÿ” Deduplicating regions from overlaps...", "info") + unique_regions = self._deduplicate_chunk_regions(all_regions, chunk_overlap) + + self._log(f" Total regions: {len(all_regions)} โ†’ {len(unique_regions)} after deduplication", "info") + + if not unique_regions: + self._log("โš ๏ธ No text regions detected in webtoon", "warning") + result['errors'].append("No text regions detected") + return result + + # Now process the regions as normal + self._log(f"\n๐Ÿ“ Translating {len(unique_regions)} unique regions", "info") + + # Translate regions + if self.full_page_context_enabled: + translations = self.translate_full_page_context(unique_regions, image_path) + for region in unique_regions: + if region.text in translations: + region.translated_text = translations[region.text] + else: + unique_regions = self.translate_regions(unique_regions, image_path) + + # Create mask and inpaint + self._log(f"\n๐ŸŽจ Creating mask and inpainting...", "info") + mask = self.create_text_mask(image, unique_regions) + + if self.skip_inpainting: + inpainted = image.copy() + else: + inpainted = self.inpaint_regions(image, mask) + + # Render translated text + self._log(f"โœ๏ธ Rendering translated text...", "info") + final_image = self.render_translated_text(inpainted, unique_regions) + + # Save output + if not output_path: + base, ext = os.path.splitext(image_path) + output_path = f"{base}_translated{ext}" + + cv2.imwrite(output_path, final_image) + + result['output_path'] = output_path + result['regions'] = [r.to_dict() for r in unique_regions] + result['success'] = True + result['format_info']['chunks_processed'] = num_chunks + + self._log(f"\nโœ… Webtoon processing complete: {output_path}", "success") + + return result + + except Exception as e: + error_msg = f"Error processing webtoon chunks: {str(e)}" + self._log(f"โŒ {error_msg}", "error") + result['errors'].append(error_msg) + return result + + def _deduplicate_chunk_regions(self, regions: List, overlap_height: int) -> List: + """Remove duplicate regions from overlapping chunk areas""" + if not regions: + return regions + + # Sort regions by y position + regions.sort(key=lambda r: r.bounding_box[1]) + + unique_regions = [] + used_indices = set() + + for i, region1 in enumerate(regions): + if i in used_indices: + continue + + # Check if this region is in an overlap zone + x1, y1, w1, h1 = region1.bounding_box + chunk_idx = region1.chunk_index if hasattr(region1, 'chunk_index') else 0 + chunk_y_start, chunk_y_end = region1.chunk_y_range if hasattr(region1, 'chunk_y_range') else (0, float('inf')) + + # Check if region is near chunk boundary (in overlap zone) + in_overlap_zone = (y1 < chunk_y_start + overlap_height) and chunk_idx > 0 + + if in_overlap_zone: + # Look for duplicate in previous chunk's regions + found_duplicate = False + + for j, region2 in enumerate(regions): + if j >= i or j in used_indices: + continue + + if hasattr(region2, 'chunk_index') and region2.chunk_index == chunk_idx - 1: + x2, y2, w2, h2 = region2.bounding_box + + # Check if regions are the same (similar position and size) + if (abs(x1 - x2) < 20 and + abs(y1 - y2) < 20 and + abs(w1 - w2) < 20 and + abs(h1 - h2) < 20): + + # Check text similarity + if region1.text == region2.text: + # This is a duplicate + found_duplicate = True + used_indices.add(i) + self._log(f" Removed duplicate: '{region1.text[:30]}...'", "debug") + break + + if not found_duplicate: + unique_regions.append(region1) + used_indices.add(i) + else: + # Not in overlap zone, keep it + unique_regions.append(region1) + used_indices.add(i) + + return unique_regions + + def _save_intermediate_image(self, original_path: str, image, stage: str, debug_base_dir: str = None): + """Save intermediate processing stages under translated_images/debug or provided base dir""" + if debug_base_dir is None: + translated_dir = os.path.join(os.path.dirname(original_path), 'translated_images') + debug_dir = os.path.join(translated_dir, 'debug') + else: + debug_dir = os.path.join(debug_base_dir, 'debug') + os.makedirs(debug_dir, exist_ok=True) + + base_name = os.path.splitext(os.path.basename(original_path))[0] + output_path = os.path.join(debug_dir, f"{base_name}_{stage}.png") + + cv2.imwrite(output_path, image) + self._log(f" ๐Ÿ’พ Saved {stage} image: {output_path}") diff --git a/model_options.py b/model_options.py new file mode 100644 index 0000000000000000000000000000000000000000..411f95234aa2ac827bbfd2105377528635856b2c --- /dev/null +++ b/model_options.py @@ -0,0 +1,129 @@ +# model_options.py +""" +Centralized model catalog for Glossarion UIs. +Returned list should mirror the main GUI model dropdown. +""" +from typing import List + +def get_model_options() -> List[str]: + return [ + + # OpenAI Models + "gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4.1-nano", "gpt-4.1-mini", "gpt-4.1", + "gpt-3.5-turbo", "gpt-3.5-turbo-16k", "gpt-4", "gpt-4-32k", + "gpt-5-mini","gpt-5","gpt-5-nano", + "o1-preview", "o1-mini", "o3", "o4-mini", + + # Google Gemini Models + "gemini-2.0-flash","gemini-2.0-flash-lite", + "gemini-2.5-flash","gemini-2.5-flash-lite", "gemini-2.5-pro", "gemini-pro", "gemini-pro-vision", + + # Anthropic Claude Models + "claude-opus-4-20250514", "claude-sonnet-4-20250514", + "claude-3-5-sonnet-20241022", "claude-3-7-sonnet-20250219", + "claude-3-opus-20240229", "claude-3-sonnet-20240229", "claude-3-haiku-20240307", + "claude-2.1", "claude-2", "claude-instant-1.2", + + # Grok Models + "grok-4-0709", "grok-4-fast", "grok-4-fast-reasoning", "grok-4-fast-reasoning-latest", "grok-3", "grok-3-mini", + + # Vertex AI Model Garden - Claude models (confirmed) + "claude-4-opus@20250514", + "claude-4-sonnet@20250514", + "claude-opus-4@20250514", + "claude-sonnet-4@20250514", + "claude-3-7-sonnet@20250219", + "claude-3-5-sonnet@20240620", + "claude-3-5-sonnet-v2@20241022", + "claude-3-opus@20240229", + "claude-3-sonnet@20240229", + "claude-3-haiku@20240307", + + + # Alternative format with vertex_ai prefix + "vertex/claude-3-7-sonnet@20250219", + "vertex/claude-3-5-sonnet@20240620", + "vertex/claude-3-opus@20240229", + "vertex/claude-4-opus@20250514", + "vertex/claude-4-sonnet@20250514", + "vertex/gemini-1.5-pro", + "vertex/gemini-1.5-flash", + "vertex/gemini-2.0-flash", + "vertex/gemini-2.5-pro", + "vertex/gemini-2.5-flash", + "vertex/gemini-2.5-flash-lite", + + # Chute AI + "chutes/openai/gpt-oss-120b", + "chutes/deepseek-ai/DeepSeek-V3.1", + + # DeepSeek Models + "deepseek-chat", "deepseek-coder", "deepseek-coder-33b-instruct", + + # Mistral Models + "mistral-large", "mistral-medium", "mistral-small", "mistral-tiny", + "mixtral-8x7b-instruct", "mixtral-8x22b", "codestral-latest", + + # Meta Llama Models (via Together/other providers) + "llama-2-7b-chat", "llama-2-13b-chat", "llama-2-70b-chat", + "llama-3-8b-instruct", "llama-3-70b-instruct", "codellama-34b-instruct", + + # Yi Models + "yi-34b-chat", "yi-34b-chat-200k", "yi-6b-chat", + + # Qwen Models + "qwen-72b-chat", "qwen-14b-chat", "qwen-7b-chat", "qwen-plus", "qwen-turbo", + + # Cohere Models + "command", "command-light", "command-nightly", "command-r", "command-r-plus", + + # AI21 Models + "j2-ultra", "j2-mid", "j2-light", "jamba-instruct", + + # Perplexity Models + "perplexity-70b-online", "perplexity-7b-online", "pplx-70b-online", "pplx-7b-online", + + # Groq Models (usually with suffix) + "llama-3-70b-groq", "llama-3-8b-groq", "mixtral-8x7b-groq", + + # Chinese Models + "glm-4", "glm-3-turbo", "chatglm-6b", "chatglm2-6b", "chatglm3-6b", + "baichuan-13b-chat", "baichuan2-13b-chat", + "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k", + + # Other Models + "falcon-40b-instruct", "falcon-7b-instruct", + "phi-2", "phi-3-mini", "phi-3-small", "phi-3-medium", + "orca-2-13b", "orca-2-7b", + "vicuna-13b", "vicuna-7b", + "alpaca-7b", + "wizardlm-70b", "wizardlm-13b", + "openchat-3.5", + + # For POE, prefix with 'poe/' + "poe/gpt-4", "poe/gpt-4o", "poe/gpt-4.5", "poe/gpt-4.1", + "poe/claude-3-opus", "poe/claude-4-opus", "poe/claude-3-sonnet", "poe/claude-4-sonnet", + "poe/claude", "poe/Assistant", + "poe/gemini-2.5-flash", "poe/gemini-2.5-pro", + + # For OR, prevfix with 'or/' + "or/google/gemini-2.5-pro", + "or/google/gemini-2.5-flash", + "or/google/gemini-2.5-flash-lite", + "or/openai/gpt-5", + "or/openai/gpt-5-mini", + "or/openai/gpt-5-nano", + "or/openai/chatgpt-4o-latest", + "or/deepseek/deepseek-r1-0528:free", + "or/google/gemma-3-27b-it:free", + + # For ElectronHub, prefix with 'eh/' + "eh/gpt-4", "eh/gpt-3.5-turbo", "eh/claude-3-opus", "eh/claude-3-sonnet", + "eh/llama-2-70b-chat", "eh/yi-34b-chat-200k", "eh/mistral-large", + "eh/gemini-pro", "eh/deepseek-coder-33b", + + # Last Resort + "deepl", # Will use DeepL API + "google-translate-free", # Uses free web endpoint (no key) + "google-translate", # Will use Google Cloud Translate + ] diff --git a/ocr_manager.py b/ocr_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..773aee35659cc0f33690c99d0d2d06b6ec7a4682 --- /dev/null +++ b/ocr_manager.py @@ -0,0 +1,1970 @@ +# ocr_manager.py +""" +OCR Manager for handling multiple OCR providers +Handles installation, model downloading, and OCR processing +Updated with HuggingFace donut model and proper bubble detection integration +""" +import os +import sys +import cv2 +import json +import subprocess +import threading +import traceback +from typing import List, Dict, Optional, Tuple, Any +import numpy as np +from dataclasses import dataclass +from PIL import Image +import logging +import time +import random +import base64 +import io +import requests + +try: + import gptqmodel + HAS_GPTQ = True +except ImportError: + try: + import auto_gptq + HAS_GPTQ = True + except ImportError: + HAS_GPTQ = False + +try: + import optimum + HAS_OPTIMUM = True +except ImportError: + HAS_OPTIMUM = False + +try: + import accelerate + HAS_ACCELERATE = True +except ImportError: + HAS_ACCELERATE = False + +logger = logging.getLogger(__name__) + +@dataclass +class OCRResult: + """Unified OCR result format with built-in sanitization to prevent data corruption.""" + text: str + bbox: Tuple[int, int, int, int] # x, y, w, h + confidence: float + vertices: Optional[List[Tuple[int, int]]] = None + + def __post_init__(self): + """ + This special method is called automatically after the object is created. + It acts as a final safeguard to ensure the 'text' attribute is ALWAYS a clean string. + """ + # --- THIS IS THE DEFINITIVE FIX --- + # If the text we received is a tuple, we extract the first element. + # This makes it impossible for a tuple to exist in a finished object. + if isinstance(self.text, tuple): + # Log that we are fixing a critical data error. + print(f"CRITICAL WARNING: Corrupted tuple detected in OCRResult. Sanitizing '{self.text}' to '{self.text[0]}'.") + self.text = self.text[0] + + # Ensure the final result is always a stripped string. + self.text = str(self.text).strip() + +class OCRProvider: + """Base class for OCR providers""" + + def __init__(self, log_callback=None): + # Set thread limits early if environment indicates single-threaded mode + try: + if os.environ.get('OMP_NUM_THREADS') == '1': + # Already in single-threaded mode, ensure it's applied to this process + try: + import sys + if 'torch' in sys.modules: + import torch + torch.set_num_threads(1) + except (ImportError, RuntimeError, AttributeError): + pass + try: + import cv2 + cv2.setNumThreads(1) + except (ImportError, AttributeError): + pass + except Exception: + pass + + self.log_callback = log_callback + self.is_installed = False + self.is_loaded = False + self.model = None + self.stop_flag = None + self._stopped = False + + def _log(self, message: str, level: str = "info"): + """Log message with stop suppression""" + # Suppress logs when stopped (allow only essential stop confirmation messages) + if self._check_stop(): + essential_stop_keywords = [ + "โน๏ธ Translation stopped by user", + "โน๏ธ OCR processing stopped", + "cleanup", "๐Ÿงน" + ] + if not any(keyword in message for keyword in essential_stop_keywords): + return + + if self.log_callback: + self.log_callback(message, level) + else: + print(f"[{level.upper()}] {message}") + + def set_stop_flag(self, stop_flag): + """Set the stop flag for checking interruptions""" + self.stop_flag = stop_flag + self._stopped = False + + def _check_stop(self) -> bool: + """Check if stop has been requested""" + if self._stopped: + return True + if self.stop_flag and self.stop_flag.is_set(): + self._stopped = True + return True + # Check global manga translator cancellation + try: + from manga_translator import MangaTranslator + if MangaTranslator.is_globally_cancelled(): + self._stopped = True + return True + except Exception: + pass + return False + + def reset_stop_flags(self): + """Reset stop flags when starting new processing""" + self._stopped = False + + def check_installation(self) -> bool: + """Check if provider is installed""" + raise NotImplementedError + + def install(self, progress_callback=None) -> bool: + """Install the provider""" + raise NotImplementedError + + def load_model(self, **kwargs) -> bool: + """Load the OCR model""" + raise NotImplementedError + + def detect_text(self, image: np.ndarray, **kwargs) -> List[OCRResult]: + """Detect text in image""" + raise NotImplementedError + +class CustomAPIProvider(OCRProvider): + """Custom API OCR provider that uses existing GUI variables""" + + def __init__(self, log_callback=None): + super().__init__(log_callback) + + # Use EXISTING environment variables from TranslatorGUI + self.api_url = os.environ.get('OPENAI_CUSTOM_BASE_URL', '') + self.api_key = os.environ.get('API_KEY', '') or os.environ.get('OPENAI_API_KEY', '') + self.model_name = os.environ.get('MODEL', 'gpt-4o-mini') + + # OCR prompt - use system prompt or a dedicated OCR prompt variable + self.ocr_prompt = os.environ.get('OCR_SYSTEM_PROMPT', + os.environ.get('SYSTEM_PROMPT', + "YOU ARE A TEXT EXTRACTION MACHINE. EXTRACT EXACTLY WHAT YOU SEE.\n\n" + "ABSOLUTE RULES:\n" + "1. OUTPUT ONLY THE VISIBLE TEXT/SYMBOLS - NOTHING ELSE\n" + "2. NEVER TRANSLATE OR MODIFY\n" + "3. NEVER EXPLAIN, DESCRIBE, OR COMMENT\n" + "4. NEVER SAY \"I can't\" or \"I cannot\" or \"no text\" or \"blank image\"\n" + "5. IF YOU SEE DOTS, OUTPUT THE DOTS: .\n" + "6. IF YOU SEE PUNCTUATION, OUTPUT THE PUNCTUATION\n" + "7. IF YOU SEE A SINGLE CHARACTER, OUTPUT THAT CHARACTER\n" + "8. IF YOU SEE NOTHING, OUTPUT NOTHING (empty response)\n\n" + "LANGUAGE PRESERVATION:\n" + "- Korean text โ†’ Output in Korean\n" + "- Japanese text โ†’ Output in Japanese\n" + "- Chinese text โ†’ Output in Chinese\n" + "- English text โ†’ Output in English\n" + "- CJK quotation marks (ใ€Œใ€ใ€Žใ€ใ€ใ€‘ใ€Šใ€‹ใ€ˆใ€‰) โ†’ Preserve exactly as shown\n\n" + "FORMATTING:\n" + "- OUTPUT ALL TEXT ON A SINGLE LINE WITH NO LINE BREAKS\n" + "- NEVER use \\n or line breaks in your output\n\n" + "FORBIDDEN RESPONSES:\n" + "- \"I can see this appears to be...\"\n" + "- \"I cannot make out any clear text...\"\n" + "- \"This appears to be blank...\"\n" + "- \"If there is text present...\"\n" + "- ANY explanatory text\n\n" + "YOUR ONLY OUTPUT: The exact visible text. Nothing more. Nothing less.\n" + "If image has a dot โ†’ Output: .\n" + "If image has two dots โ†’ Output: . .\n" + "If image has text โ†’ Output: [that text]\n" + "If image is truly blank โ†’ Output: [empty/no response]" + )) + + # Use existing temperature and token settings + self.temperature = float(os.environ.get('TRANSLATION_TEMPERATURE', '0.01')) + # NOTE: max_tokens is NOT cached here - it's read fresh from environment in detect_text() + # to ensure we always get the latest value from the GUI + + # Image settings from existing compression variables + self.image_format = 'jpeg' if os.environ.get('IMAGE_COMPRESSION_FORMAT', 'auto') != 'png' else 'png' + self.image_quality = int(os.environ.get('JPEG_QUALITY', '100')) + + # Simple defaults + self.api_format = 'openai' # Most custom endpoints are OpenAI-compatible + self.timeout = int(os.environ.get('CHUNK_TIMEOUT', '30')) + self.api_headers = {} # Additional custom headers + + # Retry configuration for Custom API OCR calls + self.max_retries = int(os.environ.get('CUSTOM_OCR_MAX_RETRIES', '3')) + self.retry_initial_delay = float(os.environ.get('CUSTOM_OCR_RETRY_INITIAL_DELAY', '0.8')) + self.retry_backoff = float(os.environ.get('CUSTOM_OCR_RETRY_BACKOFF', '1.8')) + self.retry_jitter = float(os.environ.get('CUSTOM_OCR_RETRY_JITTER', '0.4')) + self.retry_on_empty = os.environ.get('CUSTOM_OCR_RETRY_ON_EMPTY', '1') == '1' + + def check_installation(self) -> bool: + """Always installed - uses UnifiedClient""" + self.is_installed = True + return True + + def install(self, progress_callback=None) -> bool: + """No installation needed for API-based provider""" + return self.check_installation() + + def load_model(self, **kwargs) -> bool: + """Initialize UnifiedClient with current settings""" + try: + from unified_api_client import UnifiedClient + + # Support passing API key from GUI if available + if 'api_key' in kwargs: + api_key = kwargs['api_key'] + else: + api_key = os.environ.get('API_KEY', '') or os.environ.get('OPENAI_API_KEY', '') + + if 'model' in kwargs: + model = kwargs['model'] + else: + model = os.environ.get('MODEL', 'gpt-4o-mini') + + if not api_key: + self._log("โŒ No API key configured", "error") + return False + + # Create UnifiedClient just like translations do + self.client = UnifiedClient(model=model, api_key=api_key) + + #self._log(f"โœ… Using {model} for OCR via UnifiedClient") + self.is_loaded = True + return True + + except Exception as e: + self._log(f"โŒ Failed to initialize UnifiedClient: {str(e)}", "error") + return False + + def _test_connection(self) -> bool: + """Test API connection with a simple request""" + try: + # Create a small test image + test_image = np.ones((100, 100, 3), dtype=np.uint8) * 255 + cv2.putText(test_image, "TEST", (10, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 2) + + # Encode image + image_base64 = self._encode_image(test_image) + + # Prepare test request based on API format + if self.api_format == 'openai': + test_payload = { + "model": self.model_name, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What text do you see?"}, + {"type": "image_url", "image_url": {"url": f"data:image/{self.image_format};base64,{image_base64}"}} + ] + } + ], + "max_tokens": 50 + } + else: + # For other formats, just try a basic health check + return True + + headers = self._prepare_headers() + response = requests.post( + self.api_url, + headers=headers, + json=test_payload, + timeout=10 + ) + + return response.status_code == 200 + + except Exception: + return False + + def _encode_image(self, image: np.ndarray) -> str: + """Encode numpy array to base64 string""" + # Convert BGR to RGB if needed + if len(image.shape) == 3 and image.shape[2] == 3: + image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + else: + image_rgb = image + + # Convert to PIL Image + pil_image = Image.fromarray(image_rgb) + + # Save to bytes buffer + buffer = io.BytesIO() + if self.image_format.lower() == 'png': + pil_image.save(buffer, format='PNG') + else: + pil_image.save(buffer, format='JPEG', quality=self.image_quality) + + # Encode to base64 + buffer.seek(0) + image_base64 = base64.b64encode(buffer.read()).decode('utf-8') + + return image_base64 + + def _prepare_headers(self) -> dict: + """Prepare request headers""" + headers = { + "Content-Type": "application/json" + } + + # Add API key if configured + if self.api_key: + if self.api_format == 'anthropic': + headers["x-api-key"] = self.api_key + else: + headers["Authorization"] = f"Bearer {self.api_key}" + + # Add any custom headers + headers.update(self.api_headers) + + return headers + + def _prepare_request_payload(self, image_base64: str) -> dict: + """Prepare request payload based on API format""" + if self.api_format == 'openai': + return { + "model": self.model_name, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": self.ocr_prompt}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/{self.image_format};base64,{image_base64}" + } + } + ] + } + ], + "max_tokens": self.max_tokens, + "temperature": self.temperature + } + + elif self.api_format == 'anthropic': + return { + "model": self.model_name, + "max_tokens": self.max_tokens, + "temperature": self.temperature, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": self.ocr_prompt + }, + { + "type": "image", + "source": { + "type": "base64", + "media_type": f"image/{self.image_format}", + "data": image_base64 + } + } + ] + } + ] + } + + else: + # Custom format - use environment variable for template + template = os.environ.get('CUSTOM_OCR_REQUEST_TEMPLATE', '{}') + payload = json.loads(template) + + # Replace placeholders + payload_str = json.dumps(payload) + payload_str = payload_str.replace('{{IMAGE_BASE64}}', image_base64) + payload_str = payload_str.replace('{{PROMPT}}', self.ocr_prompt) + payload_str = payload_str.replace('{{MODEL}}', self.model_name) + payload_str = payload_str.replace('{{MAX_TOKENS}}', str(self.max_tokens)) + payload_str = payload_str.replace('{{TEMPERATURE}}', str(self.temperature)) + + return json.loads(payload_str) + + def _extract_text_from_response(self, response_data: dict) -> str: + """Extract text from API response based on format""" + try: + if self.api_format == 'openai': + # OpenAI format: response.choices[0].message.content + return response_data.get('choices', [{}])[0].get('message', {}).get('content', '') + + elif self.api_format == 'anthropic': + # Anthropic format: response.content[0].text + content = response_data.get('content', []) + if content and isinstance(content, list): + return content[0].get('text', '') + return '' + + else: + # Custom format - use environment variable for path + response_path = os.environ.get('CUSTOM_OCR_RESPONSE_PATH', 'text') + + # Navigate through the response using the path + result = response_data + for key in response_path.split('.'): + if isinstance(result, dict): + result = result.get(key, '') + elif isinstance(result, list) and key.isdigit(): + idx = int(key) + result = result[idx] if idx < len(result) else '' + else: + result = '' + break + + return str(result) + + except Exception as e: + self._log(f"Failed to extract text from response: {e}", "error") + return '' + + def detect_text(self, image: np.ndarray, **kwargs) -> List[OCRResult]: + """Process image using UnifiedClient.send_image()""" + results = [] + + try: + # CRITICAL: Reload OCR prompt from environment before each detection + # This ensures we use the latest prompt set by manga_integration.py + self.ocr_prompt = os.environ.get('OCR_SYSTEM_PROMPT', self.ocr_prompt) + + # Get fresh max_tokens from environment - GUI will have set this + max_tokens = int(os.environ.get('MAX_OUTPUT_TOKENS', '8192')) + if not self.is_loaded: + if not self.load_model(): + return results + + import cv2 + from PIL import Image + import base64 + import io + + # Validate and resize image if too small (consistent with Google/Azure logic) + h, w = image.shape[:2] + MIN_SIZE = 50 # Minimum dimension for good OCR quality + MIN_AREA = 2500 # Minimum area (50x50) + + # Skip completely invalid/corrupted images (0 or negative dimensions) + if h <= 0 or w <= 0: + self._log(f"โš ๏ธ Invalid image dimensions ({w}x{h}px), skipping", "warning") + return results + + if h < MIN_SIZE or w < MIN_SIZE or h * w < MIN_AREA: + # Image too small - resize it + scale_w = MIN_SIZE / w if w < MIN_SIZE else 1.0 + scale_h = MIN_SIZE / h if h < MIN_SIZE else 1.0 + scale = max(scale_w, scale_h) + + if scale > 1.0: + new_w = int(w * scale) + new_h = int(h * scale) + image = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_CUBIC) + self._log(f"๐Ÿ” Image resized from {w}x{h}px to {new_w}x{new_h}px for Custom API OCR", "debug") + h, w = new_h, new_w + + # Convert numpy array to PIL Image + image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + pil_image = Image.fromarray(image_rgb) + + # Convert PIL Image to base64 string + buffer = io.BytesIO() + + # Use the image format from settings + if self.image_format.lower() == 'png': + pil_image.save(buffer, format='PNG') + else: + pil_image.save(buffer, format='JPEG', quality=self.image_quality) + + buffer.seek(0) + image_base64 = base64.b64encode(buffer.read()).decode('utf-8') + + # For OpenAI vision models, we need BOTH: + # 1. System prompt with instructions + # 2. User message that includes the image + messages = [ + { + "role": "system", + "content": self.ocr_prompt # The OCR instruction as system prompt + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Image:" # Minimal text, just to have something + }, + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{image_base64}" + } + } + ] + } + ] + + # Now send this properly formatted message + # The UnifiedClient should handle this correctly + # But we're NOT using send_image, we're using regular send + + # Retry-aware call + from unified_api_client import UnifiedClientError # local import to avoid hard dependency at module import time + max_attempts = max(1, self.max_retries) + attempt = 0 + last_error = None + + # Common refusal/error phrases that indicate a non-OCR response (expanded list) + refusal_phrases = [ + "I can't extract", "I cannot extract", + "I'm sorry", "I am sorry", + "I'm unable", "I am unable", + "cannot process images", + "I can't help with that", + "cannot view images", + "no text in the image", + "I can see this appears", + "I cannot make out", + "appears to be blank", + "appears to be a mostly blank", + "mostly blank or white image", + "If there is text present", + "too small, faint, or unclear", + "cannot accurately extract", + "may be too", + "However, I cannot", + "I don't see any", + "no clear text", + "no visible text", + "does not contain", + "doesn't contain", + "I do not see" + ] + + while attempt < max_attempts: + # Check for stop before each attempt + if self._check_stop(): + self._log("โน๏ธ OCR processing stopped by user", "warning") + return results + + try: + response = self.client.send( + messages=messages, + temperature=self.temperature, + max_tokens=max_tokens + ) + + # Extract content from response object + content, finish_reason = response + + # Validate content + has_content = bool(content and str(content).strip()) + refused = False + if has_content: + # Filter out explicit failure markers + if "[" in content and "FAILED]" in content: + refused = True + elif any(phrase.lower() in content.lower() for phrase in refusal_phrases): + refused = True + + # Decide success or retry + if has_content and not refused: + text = str(content).strip() + results.append(OCRResult( + text=text, + bbox=(0, 0, w, h), + confidence=kwargs.get('confidence', 0.85), + vertices=[(0, 0), (w, 0), (w, h), (0, h)] + )) + self._log(f"โœ… Detected: {text[:50]}...") + break # success + else: + reason = "empty result" if not has_content else "refusal/non-OCR response" + last_error = f"{reason} (finish_reason: {finish_reason})" + # Check if we should retry on empty or refusal + should_retry = (not has_content and self.retry_on_empty) or refused + attempt += 1 + if attempt >= max_attempts or not should_retry: + # No more retries or shouldn't retry + if not has_content: + self._log(f"โš ๏ธ No text detected (finish_reason: {finish_reason})") + else: + self._log(f"โŒ Model returned non-OCR response: {str(content)[:120]}", "warning") + break + # Backoff before retrying + delay = self.retry_initial_delay * (self.retry_backoff ** (attempt - 1)) + random.uniform(0, self.retry_jitter) + self._log(f"๐Ÿ”„ Retry {attempt}/{max_attempts - 1} after {delay:.1f}s due to {reason}...", "warning") + time.sleep(delay) + time.sleep(0.1) # Brief pause for stability + self._log("๐Ÿ’ค OCR retry pausing briefly for stability", "debug") + continue + + except UnifiedClientError as ue: + msg = str(ue) + last_error = msg + # Do not retry on explicit user cancellation + if 'cancelled' in msg.lower() or 'stopped by user' in msg.lower(): + self._log(f"โŒ OCR cancelled: {msg}", "error") + break + attempt += 1 + if attempt >= max_attempts: + self._log(f"โŒ OCR failed after {attempt} attempts: {msg}", "error") + break + delay = self.retry_initial_delay * (self.retry_backoff ** (attempt - 1)) + random.uniform(0, self.retry_jitter) + self._log(f"๐Ÿ”„ API error, retry {attempt}/{max_attempts - 1} after {delay:.1f}s: {msg}", "warning") + time.sleep(delay) + time.sleep(0.1) # Brief pause for stability + self._log("๐Ÿ’ค OCR API error retry pausing briefly for stability", "debug") + continue + except Exception as e_inner: + last_error = str(e_inner) + attempt += 1 + if attempt >= max_attempts: + self._log(f"โŒ OCR exception after {attempt} attempts: {last_error}", "error") + break + delay = self.retry_initial_delay * (self.retry_backoff ** (attempt - 1)) + random.uniform(0, self.retry_jitter) + self._log(f"๐Ÿ”„ Exception, retry {attempt}/{max_attempts - 1} after {delay:.1f}s: {last_error}", "warning") + time.sleep(delay) + time.sleep(0.1) # Brief pause for stability + self._log("๐Ÿ’ค OCR exception retry pausing briefly for stability", "debug") + continue + + except Exception as e: + self._log(f"โŒ Error: {str(e)}", "error") + import traceback + self._log(traceback.format_exc(), "debug") + + return results + +class MangaOCRProvider(OCRProvider): + """Manga OCR provider using HuggingFace model directly""" + + def __init__(self, log_callback=None): + super().__init__(log_callback) + self.processor = None + self.model = None + self.tokenizer = None + + def check_installation(self) -> bool: + """Check if transformers is installed""" + try: + import transformers + import torch + self.is_installed = True + return True + except ImportError: + return False + + def install(self, progress_callback=None) -> bool: + """Install transformers and torch""" + pass + + def _is_valid_local_model_dir(self, path: str) -> bool: + """Check that a local HF model directory has required files.""" + try: + if not path or not os.path.isdir(path): + return False + needed_any_weights = any( + os.path.exists(os.path.join(path, name)) for name in ( + 'pytorch_model.bin', + 'model.safetensors' + ) + ) + has_config = os.path.exists(os.path.join(path, 'config.json')) + has_processor = ( + os.path.exists(os.path.join(path, 'preprocessor_config.json')) or + os.path.exists(os.path.join(path, 'processor_config.json')) + ) + has_tokenizer = ( + os.path.exists(os.path.join(path, 'tokenizer.json')) or + os.path.exists(os.path.join(path, 'tokenizer_config.json')) + ) + return has_config and needed_any_weights and has_processor and has_tokenizer + except Exception: + return False + + def load_model(self, **kwargs) -> bool: + """Load the manga-ocr model, preferring a local directory to avoid re-downloading""" + print("\n>>> MangaOCRProvider.load_model() called") + try: + if not self.is_installed and not self.check_installation(): + print("ERROR: Transformers not installed") + self._log("โŒ Transformers not installed", "error") + return False + + # Always disable progress bars to avoid tqdm issues in some environments + import os + os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1") + + from transformers import VisionEncoderDecoderModel, AutoTokenizer, AutoImageProcessor + import torch + + # Prefer a local model directory if present to avoid any Hub access + candidates = [] + env_local = os.environ.get("MANGA_OCR_LOCAL_DIR") + if env_local: + candidates.append(env_local) + + # Project root one level up from this file + root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + candidates.append(os.path.join(root_dir, 'models', 'manga-ocr-base')) + candidates.append(os.path.join(root_dir, 'models', 'kha-white', 'manga-ocr-base')) + + model_source = None + local_only = False + # Find a valid local dir + for cand in candidates: + if self._is_valid_local_model_dir(cand): + model_source = cand + local_only = True + break + + # If no valid local dir, use Hub + if not model_source: + model_source = "kha-white/manga-ocr-base" + # Make sure we are not forcing offline mode + if os.environ.get("HF_HUB_OFFLINE") == "1": + try: + del os.environ["HF_HUB_OFFLINE"] + except Exception: + pass + self._log("๐Ÿ”ฅ Loading manga-ocr model from Hugging Face Hub") + self._log(f" Repo: {model_source}") + else: + # Only set offline when local dir is fully valid + os.environ.setdefault("HF_HUB_OFFLINE", "1") + self._log("๐Ÿ”ฅ Loading manga-ocr model from local directory") + self._log(f" Local path: {model_source}") + + # Decide target device once; we will move after full CPU load to avoid meta tensors + use_cuda = torch.cuda.is_available() + + # Try loading components, falling back to Hub if local-only fails + def _load_components(source: str, local_flag: bool): + self._log(" Loading tokenizer...") + tok = AutoTokenizer.from_pretrained(source, local_files_only=local_flag) + + self._log(" Loading image processor...") + try: + from transformers import AutoProcessor + except Exception: + AutoProcessor = None + try: + proc = AutoImageProcessor.from_pretrained(source, local_files_only=local_flag) + except Exception as e_proc: + if AutoProcessor is not None: + self._log(f" โš ๏ธ AutoImageProcessor failed: {e_proc}. Trying AutoProcessor...", "warning") + proc = AutoProcessor.from_pretrained(source, local_files_only=local_flag) + else: + raise + + self._log(" Loading model...") + # Prevent meta tensors by forcing full materialization on CPU at load time + os.environ.setdefault('TORCHDYNAMO_DISABLE', '1') + mdl = VisionEncoderDecoderModel.from_pretrained( + source, + local_files_only=local_flag, + low_cpu_mem_usage=False, + device_map=None, + torch_dtype=torch.float32 # Use torch_dtype instead of dtype + ) + return tok, proc, mdl + + try: + self.tokenizer, self.processor, self.model = _load_components(model_source, local_only) + except Exception as e_local: + if local_only: + # Fallback to Hub once if local fails + self._log(f" โš ๏ธ Local model load failed: {e_local}", "warning") + try: + if os.environ.get("HF_HUB_OFFLINE") == "1": + del os.environ["HF_HUB_OFFLINE"] + except Exception: + pass + model_source = "kha-white/manga-ocr-base" + local_only = False + self._log(" Retrying from Hugging Face Hub...") + self.tokenizer, self.processor, self.model = _load_components(model_source, local_only) + else: + raise + + # Move to CUDA only after full CPU materialization + target_device = 'cpu' + if use_cuda: + try: + self.model = self.model.to('cuda') + target_device = 'cuda' + except Exception as move_err: + self._log(f" โš ๏ธ Could not move model to CUDA: {move_err}", "warning") + target_device = 'cpu' + + # Finalize eval mode + self.model.eval() + + # Sanity-check: ensure no parameter remains on 'meta' device + try: + for n, p in self.model.named_parameters(): + dev = getattr(p, 'device', None) + if dev is not None and getattr(dev, 'type', '') == 'meta': + raise RuntimeError(f"Parameter {n} is on 'meta' after load") + except Exception as sanity_err: + self._log(f"โŒ Manga-OCR model load sanity check failed: {sanity_err}", "error") + return False + + print(f"SUCCESS: Model loaded on {target_device.upper()}") + self._log(f" โœ… Model loaded on {target_device.upper()}") + self.is_loaded = True + self._log("โœ… Manga OCR model ready") + print(">>> Returning True from load_model()") + return True + + except Exception as e: + print(f"\nEXCEPTION in load_model: {e}") + import traceback + print(traceback.format_exc()) + self._log(f"โŒ Failed to load manga-ocr model: {str(e)}", "error") + self._log(traceback.format_exc(), "error") + try: + if 'local_only' in locals() and local_only: + self._log("Hint: Local load failed. Ensure your models/manga-ocr-base contains required files (config.json, preprocessor_config.json, tokenizer.json or tokenizer_config.json, and model weights).", "warning") + except Exception: + pass + return False + + def _run_ocr(self, pil_image): + """Run OCR on a PIL image using the HuggingFace model""" + import torch + + # Process image (keyword arg for broader compatibility across transformers versions) + inputs = self.processor(images=pil_image, return_tensors="pt") + pixel_values = inputs["pixel_values"] + + # Move to same device as model + try: + model_device = next(self.model.parameters()).device + except StopIteration: + model_device = torch.device('cpu') + pixel_values = pixel_values.to(model_device) + + # Generate text + with torch.no_grad(): + generated_ids = self.model.generate(pixel_values) + + # Decode + generated_text = self.tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0] + + return generated_text + + def detect_text(self, image: np.ndarray, **kwargs) -> List[OCRResult]: + """ + Process the image region passed to it. + This could be a bubble region or the full image. + """ + results = [] + + # Check for stop at start + if self._check_stop(): + self._log("โน๏ธ Manga-OCR processing stopped by user", "warning") + return results + + try: + if not self.is_loaded: + if not self.load_model(): + return results + + import cv2 + from PIL import Image + + # Get confidence from kwargs + confidence = kwargs.get('confidence', 0.7) + + # Convert numpy array to PIL + image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + pil_image = Image.fromarray(image_rgb) + h, w = image.shape[:2] + + self._log("๐Ÿ” Processing region with manga-ocr...") + + # Check for stop before inference + if self._check_stop(): + self._log("โน๏ธ Manga-OCR inference stopped by user", "warning") + return results + + # Run OCR on the image region + text = self._run_ocr(pil_image) + + if text and text.strip(): + # Return result for this region with its actual bbox + results.append(OCRResult( + text=text.strip(), + bbox=(0, 0, w, h), # Relative to the region passed in + confidence=confidence, + vertices=[(0, 0), (w, 0), (w, h), (0, h)] + )) + self._log(f"โœ… Detected text: {text[:50]}...") + + except Exception as e: + self._log(f"โŒ Error in manga-ocr: {str(e)}", "error") + + return results + +class Qwen2VL(OCRProvider): + """OCR using Qwen2-VL - Vision Language Model that can read Korean text""" + + def __init__(self, log_callback=None): + super().__init__(log_callback) + self.processor = None + self.model = None + self.tokenizer = None + + # Get OCR prompt from environment or use default (UPDATED: Improved prompt) + self.ocr_prompt = os.environ.get('OCR_SYSTEM_PROMPT', + "YOU ARE A TEXT EXTRACTION MACHINE. EXTRACT EXACTLY WHAT YOU SEE.\n\n" + "ABSOLUTE RULES:\n" + "1. OUTPUT ONLY THE VISIBLE TEXT/SYMBOLS - NOTHING ELSE\n" + "2. NEVER TRANSLATE OR MODIFY\n" + "3. NEVER EXPLAIN, DESCRIBE, OR COMMENT\n" + "4. NEVER SAY \"I can't\" or \"I cannot\" or \"no text\" or \"blank image\"\n" + "5. IF YOU SEE DOTS, OUTPUT THE DOTS: .\n" + "6. IF YOU SEE PUNCTUATION, OUTPUT THE PUNCTUATION\n" + "7. IF YOU SEE A SINGLE CHARACTER, OUTPUT THAT CHARACTER\n" + "8. IF YOU SEE NOTHING, OUTPUT NOTHING (empty response)\n\n" + "LANGUAGE PRESERVATION:\n" + "- Korean text โ†’ Output in Korean\n" + "- Japanese text โ†’ Output in Japanese\n" + "- Chinese text โ†’ Output in Chinese\n" + "- English text โ†’ Output in English\n" + "- CJK quotation marks (ใ€Œใ€ใ€Žใ€ใ€ใ€‘ใ€Šใ€‹ใ€ˆใ€‰) โ†’ Preserve exactly as shown\n\n" + "FORMATTING:\n" + "- OUTPUT ALL TEXT ON A SINGLE LINE WITH NO LINE BREAKS\n" + "- NEVER use \\n or line breaks in your output\n\n" + "FORBIDDEN RESPONSES:\n" + "- \"I can see this appears to be...\"\n" + "- \"I cannot make out any clear text...\"\n" + "- \"This appears to be blank...\"\n" + "- \"If there is text present...\"\n" + "- ANY explanatory text\n\n" + "YOUR ONLY OUTPUT: The exact visible text. Nothing more. Nothing less.\n" + "If image has a dot โ†’ Output: .\n" + "If image has two dots โ†’ Output: . .\n" + "If image has text โ†’ Output: [that text]\n" + "If image is truly blank โ†’ Output: [empty/no response]" + ) + + def set_ocr_prompt(self, prompt: str): + """Allow setting the OCR prompt dynamically""" + self.ocr_prompt = prompt + + def check_installation(self) -> bool: + """Check if required packages are installed""" + try: + import transformers + import torch + self.is_installed = True + return True + except ImportError: + return False + + def install(self, progress_callback=None) -> bool: + """Install requirements for Qwen2-VL""" + pass + + def load_model(self, model_size=None, **kwargs) -> bool: + """Load Qwen2-VL model with size selection""" + self._log(f"DEBUG: load_model called with model_size={model_size}") + + try: + if not self.is_installed and not self.check_installation(): + self._log("โŒ Not installed", "error") + return False + + self._log("๐Ÿ”ฅ Loading Qwen2-VL for Advanced OCR...") + + + + from transformers import AutoProcessor, AutoTokenizer + import torch + + # Model options + model_options = { + "1": "Qwen/Qwen2-VL-2B-Instruct", + "2": "Qwen/Qwen2-VL-7B-Instruct", + "3": "Qwen/Qwen2-VL-72B-Instruct", + "4": "custom" + } + # CHANGE: Default to 7B instead of 2B + # Check for saved preference first + if model_size is None: + # Try to get from environment or config + import os + model_size = os.environ.get('QWEN2VL_MODEL_SIZE', '1') + + # Determine which model to load + if model_size and str(model_size).startswith("custom:"): + # Custom model passed with ID + model_id = str(model_size).replace("custom:", "") + self.loaded_model_size = "Custom" + self.model_id = model_id + self._log(f"Loading custom model: {model_id}") + elif model_size == "4": + # Custom option selected but no ID - shouldn't happen + self._log("โŒ Custom model selected but no ID provided", "error") + return False + elif model_size and str(model_size) in model_options: + # Standard model option + option = model_options[str(model_size)] + if option == "custom": + self._log("โŒ Custom model needs an ID", "error") + return False + model_id = option + # Set loaded_model_size for status display + if model_size == "1": + self.loaded_model_size = "2B" + elif model_size == "2": + self.loaded_model_size = "7B" + elif model_size == "3": + self.loaded_model_size = "72B" + else: + # CHANGE: Default to 7B (option "2") instead of 2B + model_id = model_options["1"] # Changed from "1" to "2" + self.loaded_model_size = "2B" # Changed from "2B" to "7B" + self._log("No model size specified, defaulting to 2B") # Changed message + + self._log(f"Loading model: {model_id}") + + # Load processor and tokenizer + self.processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=True) + self.tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True) + + # Load the model - let it figure out the class dynamically + if torch.cuda.is_available(): + self._log(f"GPU: {torch.cuda.get_device_name(0)}") + # Use auto model class + from transformers import AutoModelForVision2Seq + self.model = AutoModelForVision2Seq.from_pretrained( + model_id, + dtype=torch.float16, + device_map="auto", + trust_remote_code=True + ) + self._log("โœ… Model loaded on GPU") + else: + self._log("Loading on CPU...") + from transformers import AutoModelForVision2Seq + self.model = AutoModelForVision2Seq.from_pretrained( + model_id, + dtype=torch.float32, + trust_remote_code=True + ) + self._log("โœ… Model loaded on CPU") + + self.model.eval() + self.is_loaded = True + self._log("โœ… Qwen2-VL ready for Advanced OCR!") + return True + + except Exception as e: + self._log(f"โŒ Failed to load: {str(e)}", "error") + import traceback + self._log(traceback.format_exc(), "debug") + return False + + def detect_text(self, image: np.ndarray, **kwargs) -> List[OCRResult]: + """Process image with Qwen2-VL for Korean text extraction""" + results = [] + if hasattr(self, 'model_id'): + self._log(f"DEBUG: Using model: {self.model_id}", "debug") + + # Check if OCR prompt was passed in kwargs (for dynamic updates) + if 'ocr_prompt' in kwargs: + self.ocr_prompt = kwargs['ocr_prompt'] + + try: + if not self.is_loaded: + if not self.load_model(): + return results + + import cv2 + from PIL import Image + import torch + + # Convert to PIL + image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + pil_image = Image.fromarray(image_rgb) + h, w = image.shape[:2] + + self._log(f"๐Ÿ” Processing with Qwen2-VL ({w}x{h} pixels)...") + + # Use the configurable OCR prompt + messages = [ + { + "role": "user", + "content": [ + { + "type": "image", + "image": pil_image, + }, + { + "type": "text", + "text": self.ocr_prompt # Use the configurable prompt + } + ] + } + ] + + # Alternative simpler prompt if the above still causes issues: + # "text": "OCR: Extract text as-is" + + # Process with Qwen2-VL + text = self.processor.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=True + ) + + inputs = self.processor( + text=[text], + images=[pil_image], + padding=True, + return_tensors="pt" + ) + + # Get the device and dtype the model is currently on + model_device = next(self.model.parameters()).device + model_dtype = next(self.model.parameters()).dtype + + # Move inputs to the same device as the model and cast float tensors to model dtype + try: + # Move first + inputs = inputs.to(model_device) + # Then align dtypes only for floating tensors (e.g., pixel_values) + for k, v in inputs.items(): + if isinstance(v, torch.Tensor) and torch.is_floating_point(v): + inputs[k] = v.to(model_dtype) + except Exception: + # Fallback: ensure at least pixel_values is correct if present + try: + if isinstance(inputs, dict) and "pixel_values" in inputs: + pv = inputs["pixel_values"].to(model_device) + if torch.is_floating_point(pv): + inputs["pixel_values"] = pv.to(model_dtype) + except Exception: + pass + + # Ensure pixel_values explicitly matches model dtype if present + try: + if isinstance(inputs, dict) and "pixel_values" in inputs: + inputs["pixel_values"] = inputs["pixel_values"].to(device=model_device, dtype=model_dtype) + except Exception: + pass + + # Generate text with stricter parameters to avoid creative responses + use_amp = (hasattr(torch, 'cuda') and model_device.type == 'cuda' and model_dtype in (torch.float16, torch.bfloat16)) + autocast_dev = 'cuda' if model_device.type == 'cuda' else 'cpu' + autocast_dtype = model_dtype if model_dtype in (torch.float16, torch.bfloat16) else None + + with torch.no_grad(): + if use_amp and autocast_dtype is not None: + with torch.autocast(autocast_dev, dtype=autocast_dtype): + generated_ids = self.model.generate( + **inputs, + max_new_tokens=128, # Reduced from 512 - manga bubbles are typically short + do_sample=False, # Keep deterministic + temperature=0.01, # Keep your very low temperature + top_p=1.0, # Keep no nucleus sampling + repetition_penalty=1.0, # Keep no repetition penalty + num_beams=1, # Ensure greedy decoding (faster than beam search) + use_cache=True, # Enable KV cache for speed + early_stopping=True, # Stop at EOS token + pad_token_id=self.tokenizer.pad_token_id, # Proper padding + eos_token_id=self.tokenizer.eos_token_id, # Proper stopping + ) + else: + generated_ids = self.model.generate( + **inputs, + max_new_tokens=128, # Reduced from 512 - manga bubbles are typically short + do_sample=False, # Keep deterministic + temperature=0.01, # Keep your very low temperature + top_p=1.0, # Keep no nucleus sampling + repetition_penalty=1.0, # Keep no repetition penalty + num_beams=1, # Ensure greedy decoding (faster than beam search) + use_cache=True, # Enable KV cache for speed + early_stopping=True, # Stop at EOS token + pad_token_id=self.tokenizer.pad_token_id, # Proper padding + eos_token_id=self.tokenizer.eos_token_id, # Proper stopping + ) + + # Decode the output + generated_ids_trimmed = [ + out_ids[len(in_ids):] for in_ids, out_ids in zip(inputs.input_ids, generated_ids) + ] + output_text = self.processor.batch_decode( + generated_ids_trimmed, + skip_special_tokens=True, + clean_up_tokenization_spaces=False + )[0] + + if output_text and output_text.strip(): + text = output_text.strip() + + # ADDED: Filter out any response that looks like an explanation or apology + # Common patterns that indicate the model is being "helpful" instead of just extracting + unwanted_patterns = [ + "์ฃ„์†กํ•ฉ๋‹ˆ๋‹ค", # "I apologize" + "sorry", + "apologize", + "์ด๋ฏธ์ง€์—๋Š”", # "in this image" + "ํ…์ŠคํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค", # "there is no text" + "I cannot", + "I don't see", + "There is no", + "์งˆ๋ฌธ์ด ์žˆ์œผ์‹œ๋ฉด", # "if you have questions" + ] + + # Check if response contains unwanted patterns + text_lower = text.lower() + is_explanation = any(pattern.lower() in text_lower for pattern in unwanted_patterns) + + # Also check if the response is suspiciously long for a bubble + # Most manga bubbles are short, if we get 50+ chars it might be an explanation + is_too_long = len(text) > 100 and ('.' in text or ',' in text or '!' in text) + + if is_explanation or is_too_long: + self._log(f"โš ๏ธ Model returned explanation instead of text, ignoring", "warning") + # Return empty result or just skip this region + return results + + # Check language + has_korean = any('\uAC00' <= c <= '\uD7AF' for c in text) + has_japanese = any('\u3040' <= c <= '\u309F' or '\u30A0' <= c <= '\u30FF' for c in text) + has_chinese = any('\u4E00' <= c <= '\u9FFF' for c in text) + + if has_korean: + self._log(f"โœ… Korean detected: {text[:50]}...") + elif has_japanese: + self._log(f"โœ… Japanese detected: {text[:50]}...") + elif has_chinese: + self._log(f"โœ… Chinese detected: {text[:50]}...") + else: + self._log(f"โœ… Text: {text[:50]}...") + + results.append(OCRResult( + text=text, + bbox=(0, 0, w, h), + confidence=0.9, + vertices=[(0, 0), (w, 0), (w, h), (0, h)] + )) + else: + self._log("โš ๏ธ No text detected", "warning") + + except Exception as e: + self._log(f"โŒ Error: {str(e)}", "error") + import traceback + self._log(traceback.format_exc(), "debug") + + return results + +class EasyOCRProvider(OCRProvider): + """EasyOCR provider for multiple languages""" + + def __init__(self, log_callback=None, languages=None): + super().__init__(log_callback) + # Default to safe language combination + self.languages = languages or ['ja', 'en'] # Safe default + self._validate_language_combination() + + def _validate_language_combination(self): + """Validate and fix EasyOCR language combinations""" + # EasyOCR language compatibility rules + incompatible_pairs = [ + (['ja', 'ko'], 'Japanese and Korean cannot be used together'), + (['ja', 'zh'], 'Japanese and Chinese cannot be used together'), + (['ko', 'zh'], 'Korean and Chinese cannot be used together') + ] + + for incompatible, reason in incompatible_pairs: + if all(lang in self.languages for lang in incompatible): + self._log(f"โš ๏ธ EasyOCR: {reason}", "warning") + # Keep first language + English + self.languages = [self.languages[0], 'en'] + self._log(f"๐Ÿ”ง Auto-adjusted to: {self.languages}", "info") + break + + def check_installation(self) -> bool: + """Check if easyocr is installed""" + try: + import easyocr + self.is_installed = True + return True + except ImportError: + return False + + def install(self, progress_callback=None) -> bool: + """Install easyocr""" + pass + + def load_model(self, **kwargs) -> bool: + """Load easyocr model""" + try: + if not self.is_installed and not self.check_installation(): + self._log("โŒ easyocr not installed", "error") + return False + + self._log(f"๐Ÿ”ฅ Loading easyocr model for languages: {self.languages}...") + import easyocr + + # This will download models on first run + self.model = easyocr.Reader(self.languages, gpu=True) + self.is_loaded = True + + self._log("โœ… easyocr model loaded successfully") + return True + + except Exception as e: + self._log(f"โŒ Failed to load easyocr: {str(e)}", "error") + # Try CPU mode if GPU fails + try: + import easyocr + self.model = easyocr.Reader(self.languages, gpu=False) + self.is_loaded = True + self._log("โœ… easyocr loaded in CPU mode") + return True + except: + return False + + def detect_text(self, image: np.ndarray, **kwargs) -> List[OCRResult]: + """Detect text using easyocr""" + results = [] + + try: + if not self.is_loaded: + if not self.load_model(): + return results + + # EasyOCR can work directly with numpy arrays + ocr_results = self.model.readtext(image, detail=1) + + # Parse results + for (bbox, text, confidence) in ocr_results: + # bbox is a list of 4 points + xs = [point[0] for point in bbox] + ys = [point[1] for point in bbox] + x_min, x_max = min(xs), max(xs) + y_min, y_max = min(ys), max(ys) + + results.append(OCRResult( + text=text, + bbox=(int(x_min), int(y_min), int(x_max - x_min), int(y_max - y_min)), + confidence=confidence, + vertices=[(int(p[0]), int(p[1])) for p in bbox] + )) + + self._log(f"โœ… Detected {len(results)} text regions") + + except Exception as e: + self._log(f"โŒ Error in easyocr detection: {str(e)}", "error") + + return results + + +class PaddleOCRProvider(OCRProvider): + """PaddleOCR provider with memory safety measures""" + + def check_installation(self) -> bool: + """Check if paddleocr is installed""" + try: + from paddleocr import PaddleOCR + self.is_installed = True + return True + except ImportError: + return False + + def install(self, progress_callback=None) -> bool: + """Install paddleocr""" + pass + + def load_model(self, **kwargs) -> bool: + """Load paddleocr model with memory-safe configurations""" + try: + if not self.is_installed and not self.check_installation(): + self._log("โŒ paddleocr not installed", "error") + return False + + self._log("๐Ÿ”ฅ Loading PaddleOCR model...") + + # Set memory-safe environment variables BEFORE importing + import os + os.environ['OMP_NUM_THREADS'] = '1' # Prevent OpenMP conflicts + os.environ['MKL_NUM_THREADS'] = '1' # Prevent MKL conflicts + os.environ['OPENBLAS_NUM_THREADS'] = '1' # Prevent OpenBLAS conflicts + os.environ['FLAGS_use_mkldnn'] = '0' # Disable MKL-DNN + + from paddleocr import PaddleOCR + + # Try memory-safe configurations + configs_to_try = [ + # Config 1: Most memory-safe configuration + { + 'use_angle_cls': False, # Disable angle to save memory + 'lang': 'ch', + 'rec_batch_num': 1, # Process one at a time + 'max_text_length': 100, # Limit text length + 'drop_score': 0.5, # Higher threshold to reduce detections + 'cpu_threads': 1, # Single thread to avoid conflicts + }, + # Config 2: Minimal memory footprint + { + 'lang': 'ch', + 'rec_batch_num': 1, + 'cpu_threads': 1, + }, + # Config 3: Absolute minimal + { + 'lang': 'ch' + }, + # Config 4: Empty config + {} + ] + + for i, config in enumerate(configs_to_try): + try: + self._log(f" Trying configuration {i+1}/{len(configs_to_try)}: {config}") + + # Force garbage collection before loading + import gc + gc.collect() + + self.model = PaddleOCR(**config) + self.is_loaded = True + self.current_config = config + self._log(f"โœ… PaddleOCR loaded successfully with config: {config}") + return True + except Exception as e: + error_str = str(e) + self._log(f" Config {i+1} failed: {error_str}", "debug") + + # Clean up on failure + if hasattr(self, 'model'): + del self.model + gc.collect() + continue + + self._log(f"โŒ PaddleOCR failed to load with any configuration", "error") + return False + + except Exception as e: + self._log(f"โŒ Failed to load paddleocr: {str(e)}", "error") + import traceback + self._log(traceback.format_exc(), "debug") + return False + + def detect_text(self, image: np.ndarray, **kwargs) -> List[OCRResult]: + """Detect text with memory safety measures""" + results = [] + + try: + if not self.is_loaded: + if not self.load_model(): + return results + + import cv2 + import numpy as np + import gc + + # Memory safety: Ensure image isn't too large + h, w = image.shape[:2] if len(image.shape) >= 2 else (0, 0) + + # Limit image size to prevent memory issues + MAX_DIMENSION = 1500 + if h > MAX_DIMENSION or w > MAX_DIMENSION: + scale = min(MAX_DIMENSION/h, MAX_DIMENSION/w) + new_h, new_w = int(h*scale), int(w*scale) + self._log(f"โš ๏ธ Resizing large image from {w}x{h} to {new_w}x{new_h} for memory safety", "warning") + image = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA) + scale_factor = 1/scale + else: + scale_factor = 1.0 + + # Ensure correct format + if len(image.shape) == 2: # Grayscale + image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) + elif len(image.shape) == 4: # Batch + image = image[0] + + # Ensure uint8 type + if image.dtype != np.uint8: + if image.max() <= 1.0: + image = (image * 255).astype(np.uint8) + else: + image = image.astype(np.uint8) + + # Make a copy to avoid memory corruption + image_copy = image.copy() + + # Force garbage collection before OCR + gc.collect() + + # Process with timeout protection + import signal + import threading + + ocr_results = None + ocr_error = None + + def run_ocr(): + nonlocal ocr_results, ocr_error + try: + ocr_results = self.model.ocr(image_copy) + except Exception as e: + ocr_error = e + + # Run OCR in a separate thread with timeout + ocr_thread = threading.Thread(target=run_ocr) + ocr_thread.daemon = True + ocr_thread.start() + ocr_thread.join(timeout=30) # 30 second timeout + + if ocr_thread.is_alive(): + self._log("โŒ PaddleOCR timeout - taking too long", "error") + return results + + if ocr_error: + raise ocr_error + + # Parse results + results = self._parse_ocr_results(ocr_results) + + # Scale coordinates back if image was resized + if scale_factor != 1.0 and results: + for r in results: + x, y, width, height = r.bbox + r.bbox = (int(x*scale_factor), int(y*scale_factor), + int(width*scale_factor), int(height*scale_factor)) + r.vertices = [(int(v[0]*scale_factor), int(v[1]*scale_factor)) + for v in r.vertices] + + if results: + self._log(f"โœ… Detected {len(results)} text regions", "info") + else: + self._log("No text regions found", "debug") + + # Clean up + del image_copy + gc.collect() + + except Exception as e: + error_msg = str(e) if str(e) else type(e).__name__ + + if "memory" in error_msg.lower() or "0x" in error_msg: + self._log("โŒ Memory access violation in PaddleOCR", "error") + self._log(" This is a known Windows issue with PaddleOCR", "info") + self._log(" Please switch to EasyOCR or manga-ocr instead", "warning") + elif "trace_order.size()" in error_msg: + self._log("โŒ PaddleOCR internal error", "error") + self._log(" Please switch to EasyOCR or manga-ocr", "warning") + else: + self._log(f"โŒ Error in paddleocr detection: {error_msg}", "error") + + import traceback + self._log(traceback.format_exc(), "debug") + + return results + + def _parse_ocr_results(self, ocr_results) -> List[OCRResult]: + """Parse OCR results safely""" + results = [] + + if isinstance(ocr_results, bool) and ocr_results == False: + return results + + if ocr_results is None or not isinstance(ocr_results, list): + return results + + if len(ocr_results) == 0: + return results + + # Handle batch format + if isinstance(ocr_results[0], list) and len(ocr_results[0]) > 0: + first_item = ocr_results[0][0] + if isinstance(first_item, list) and len(first_item) > 0: + if isinstance(first_item[0], (list, tuple)) and len(first_item[0]) == 2: + ocr_results = ocr_results[0] + + # Parse detections + for detection in ocr_results: + if not detection or isinstance(detection, bool): + continue + + if not isinstance(detection, (list, tuple)) or len(detection) < 2: + continue + + try: + bbox_points = detection[0] + text_data = detection[1] + + if not isinstance(bbox_points, (list, tuple)) or len(bbox_points) != 4: + continue + + if not isinstance(text_data, (tuple, list)) or len(text_data) < 2: + continue + + text = str(text_data[0]).strip() + confidence = float(text_data[1]) + + if not text or confidence < 0.3: + continue + + xs = [float(p[0]) for p in bbox_points] + ys = [float(p[1]) for p in bbox_points] + x_min, x_max = min(xs), max(xs) + y_min, y_max = min(ys), max(ys) + + if (x_max - x_min) < 5 or (y_max - y_min) < 5: + continue + + results.append(OCRResult( + text=text, + bbox=(int(x_min), int(y_min), int(x_max - x_min), int(y_max - y_min)), + confidence=confidence, + vertices=[(int(p[0]), int(p[1])) for p in bbox_points] + )) + + except Exception: + continue + + return results + +class DocTROCRProvider(OCRProvider): + """DocTR OCR provider""" + + def check_installation(self) -> bool: + """Check if doctr is installed""" + try: + from doctr.models import ocr_predictor + self.is_installed = True + return True + except ImportError: + return False + + def install(self, progress_callback=None) -> bool: + """Install doctr""" + pass + + def load_model(self, **kwargs) -> bool: + """Load doctr model""" + try: + if not self.is_installed and not self.check_installation(): + self._log("โŒ doctr not installed", "error") + return False + + self._log("๐Ÿ”ฅ Loading DocTR model...") + from doctr.models import ocr_predictor + + # Load pretrained model + self.model = ocr_predictor(pretrained=True) + self.is_loaded = True + + self._log("โœ… DocTR model loaded successfully") + return True + + except Exception as e: + self._log(f"โŒ Failed to load doctr: {str(e)}", "error") + return False + + def detect_text(self, image: np.ndarray, **kwargs) -> List[OCRResult]: + """Detect text using doctr""" + results = [] + + try: + if not self.is_loaded: + if not self.load_model(): + return results + + from doctr.io import DocumentFile + + # DocTR expects document format + # Convert numpy array to PIL and save temporarily + import tempfile + import cv2 + + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp: + cv2.imwrite(tmp.name, image) + doc = DocumentFile.from_images(tmp.name) + + # Run OCR + result = self.model(doc) + + # Parse results + h, w = image.shape[:2] + for page in result.pages: + for block in page.blocks: + for line in block.lines: + for word in line.words: + # Handle different geometry formats + geometry = word.geometry + + if len(geometry) == 4: + # Standard format: (x1, y1, x2, y2) + x1, y1, x2, y2 = geometry + elif len(geometry) == 2: + # Alternative format: ((x1, y1), (x2, y2)) + (x1, y1), (x2, y2) = geometry + else: + self._log(f"Unexpected geometry format: {geometry}", "warning") + continue + + # Convert relative coordinates to absolute + x1, x2 = int(x1 * w), int(x2 * w) + y1, y2 = int(y1 * h), int(y2 * h) + + results.append(OCRResult( + text=word.value, + bbox=(x1, y1, x2 - x1, y2 - y1), + confidence=word.confidence, + vertices=[(x1, y1), (x2, y1), (x2, y2), (x1, y2)] + )) + + # Clean up temp file + try: + os.unlink(tmp.name) + except: + pass + + self._log(f"DocTR detected {len(results)} text regions") + + except Exception as e: + self._log(f"Error in doctr detection: {str(e)}", "error") + import traceback + self._log(traceback.format_exc(), "error") + + return results + + +class RapidOCRProvider(OCRProvider): + """RapidOCR provider for fast local OCR""" + + def check_installation(self) -> bool: + """Check if rapidocr is installed""" + try: + import rapidocr_onnxruntime + self.is_installed = True + return True + except ImportError: + return False + + def install(self, progress_callback=None) -> bool: + """Install rapidocr (requires manual pip install)""" + # RapidOCR requires manual installation + if progress_callback: + progress_callback("RapidOCR requires manual pip installation") + self._log("Run: pip install rapidocr-onnxruntime", "info") + return False # Always return False since we can't auto-install + + def load_model(self, **kwargs) -> bool: + """Load RapidOCR model""" + try: + if not self.is_installed and not self.check_installation(): + self._log("RapidOCR not installed", "error") + return False + + self._log("Loading RapidOCR...") + from rapidocr_onnxruntime import RapidOCR + + self.model = RapidOCR() + self.is_loaded = True + + self._log("RapidOCR model loaded successfully") + return True + + except Exception as e: + self._log(f"Failed to load RapidOCR: {str(e)}", "error") + return False + + def detect_text(self, image: np.ndarray, **kwargs) -> List[OCRResult]: + """Detect text using RapidOCR""" + if not self.is_loaded: + self._log("RapidOCR model not loaded", "error") + return [] + + results = [] + + try: + # Convert numpy array to PIL Image for RapidOCR + if len(image.shape) == 3: + # BGR to RGB + image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + else: + image_rgb = image + + # RapidOCR expects PIL Image or numpy array + ocr_results, _ = self.model(image_rgb) + + if ocr_results: + for result in ocr_results: + # RapidOCR returns [bbox, text, confidence] + bbox_points = result[0] # 4 corner points + text = result[1] + confidence = float(result[2]) + + if not text or not text.strip(): + continue + + # Convert 4-point bbox to x,y,w,h format + xs = [point[0] for point in bbox_points] + ys = [point[1] for point in bbox_points] + x_min, x_max = min(xs), max(xs) + y_min, y_max = min(ys), max(ys) + + results.append(OCRResult( + text=text.strip(), + bbox=(int(x_min), int(y_min), int(x_max - x_min), int(y_max - y_min)), + confidence=confidence, + vertices=[(int(p[0]), int(p[1])) for p in bbox_points] + )) + + self._log(f"Detected {len(results)} text regions") + + except Exception as e: + self._log(f"Error in RapidOCR detection: {str(e)}", "error") + + return results + +class OCRManager: + """Manager for multiple OCR providers""" + + def __init__(self, log_callback=None): + self.log_callback = log_callback + self.providers = { + 'custom-api': CustomAPIProvider(log_callback) , + 'manga-ocr': MangaOCRProvider(log_callback), + 'easyocr': EasyOCRProvider(log_callback), + 'paddleocr': PaddleOCRProvider(log_callback), + 'doctr': DocTROCRProvider(log_callback), + 'rapidocr': RapidOCRProvider(log_callback), + 'Qwen2-VL': Qwen2VL(log_callback) + } + self.current_provider = None + self.stop_flag = None + + def get_provider(self, name: str) -> Optional[OCRProvider]: + """Get OCR provider by name""" + return self.providers.get(name) + + def set_current_provider(self, name: str): + """Set current active provider""" + if name in self.providers: + self.current_provider = name + return True + return False + + def check_provider_status(self, name: str) -> Dict[str, bool]: + """Check installation and loading status of provider""" + provider = self.providers.get(name) + if not provider: + return {'installed': False, 'loaded': False} + + result = { + 'installed': provider.check_installation(), + 'loaded': provider.is_loaded + } + if self.log_callback: + self.log_callback(f"DEBUG: check_provider_status({name}) returning loaded={result['loaded']}", "debug") + return result + + def install_provider(self, name: str, progress_callback=None) -> bool: + """Install a provider""" + provider = self.providers.get(name) + if not provider: + return False + + return provider.install(progress_callback) + + def load_provider(self, name: str, **kwargs) -> bool: + """Load a provider's model with optional parameters""" + provider = self.providers.get(name) + if not provider: + return False + + return provider.load_model(**kwargs) # <-- Passes model_size and any other kwargs + + def shutdown(self): + """Release models/processors/tokenizers for all providers and clear caches.""" + try: + import gc + for name, provider in list(self.providers.items()): + try: + if hasattr(provider, 'model'): + provider.model = None + if hasattr(provider, 'processor'): + provider.processor = None + if hasattr(provider, 'tokenizer'): + provider.tokenizer = None + if hasattr(provider, 'reader'): + provider.reader = None + if hasattr(provider, 'is_loaded'): + provider.is_loaded = False + except Exception: + pass + gc.collect() + try: + import torch + torch.cuda.empty_cache() + except Exception: + pass + except Exception: + pass + + def detect_text(self, image: np.ndarray, provider_name: str = None, **kwargs) -> List[OCRResult]: + """Detect text using specified or current provider""" + provider_name = provider_name or self.current_provider + if not provider_name: + return [] + + provider = self.providers.get(provider_name) + if not provider: + return [] + + return provider.detect_text(image, **kwargs) + + def set_stop_flag(self, stop_flag): + """Set stop flag for all providers""" + self.stop_flag = stop_flag + for provider in self.providers.values(): + if hasattr(provider, 'set_stop_flag'): + provider.set_stop_flag(stop_flag) + + def reset_stop_flags(self): + """Reset stop flags for all providers""" + for provider in self.providers.values(): + if hasattr(provider, 'reset_stop_flags'): + provider.reset_stop_flags() diff --git a/translator_gui.py b/translator_gui.py new file mode 100644 index 0000000000000000000000000000000000000000..1912c160feb43682621c13c41398b53b0f3e456a --- /dev/null +++ b/translator_gui.py @@ -0,0 +1,14618 @@ +#translator_gui.py +if __name__ == '__main__': + import multiprocessing + multiprocessing.freeze_support() + +# Standard Library +import io, json, logging, math, os, shutil, sys, threading, time, re, concurrent.futures, signal +from logging.handlers import RotatingFileHandler +import atexit +import faulthandler +import tkinter as tk +from tkinter import filedialog, messagebox, scrolledtext, simpledialog, ttk +from ai_hunter_enhanced import AIHunterConfigGUI, ImprovedAIHunterDetection +import traceback +# Third-Party +import ttkbootstrap as tb +from ttkbootstrap.constants import * +from splash_utils import SplashManager +from api_key_encryption import encrypt_config, decrypt_config +from metadata_batch_translator import MetadataBatchTranslatorUI +from model_options import get_model_options + +# Support worker-mode dispatch in frozen builds to avoid requiring Python interpreter +# This allows spawning the same .exe with a special flag to run helper tasks. +if '--run-chapter-extraction' in sys.argv: + try: + # Ensure UTF-8 I/O in worker mode + os.environ.setdefault('PYTHONIOENCODING', 'utf-8') + # Remove the flag so worker's argv aligns: argv[1]=epub, argv[2]=out, argv[3]=mode + try: + _flag_idx = sys.argv.index('--run-chapter-extraction') + sys.argv = [sys.argv[0]] + sys.argv[_flag_idx + 1:] + except ValueError: + # Shouldn't happen, but continue with current argv + pass + from chapter_extraction_worker import main as _ce_main + _ce_main() + except Exception as _e: + try: + print(f"[ERROR] Worker failed: {_e}") + except Exception: + pass + finally: + # Make sure we exit without initializing the GUI when in worker mode + sys.exit(0) + +# The frozen check can stay here for other purposes +if getattr(sys, 'frozen', False): + # Any other frozen-specific setup + pass + +# Manga translation support (optional) +try: + from manga_integration import MangaTranslationTab + MANGA_SUPPORT = True +except ImportError: + MANGA_SUPPORT = False + print("Manga translation modules not found.") + +# Async processing support (lazy loaded) +ASYNC_SUPPORT = False +try: + # Check if module exists without importing + import importlib.util + spec = importlib.util.find_spec('async_api_processor') + if spec is not None: + ASYNC_SUPPORT = True +except ImportError: + pass + +# Deferred modules +translation_main = translation_stop_flag = translation_stop_check = None +glossary_main = glossary_stop_flag = glossary_stop_check = None +fallback_compile_epub = scan_html_folder = None + +CONFIG_FILE = "config.json" +BASE_WIDTH, BASE_HEIGHT = 1920, 1080 + +# --- Robust file logging and crash tracing setup --- +_FAULT_LOG_FH = None + +def _setup_file_logging(): + """Initialize rotating file logging and crash tracing (faulthandler). + Ensures logs directory is writable in both source and PyInstaller one-file builds. + """ + global _FAULT_LOG_FH + + def _can_write(dir_path: str) -> bool: + try: + os.makedirs(dir_path, exist_ok=True) + test_file = os.path.join(dir_path, ".write_test") + with open(test_file, "w", encoding="utf-8") as f: + f.write("ok") + os.remove(test_file) + return True + except Exception: + return False + + def _resolve_logs_dir() -> str: + # 1) Explicit override + env_dir = os.environ.get("GLOSSARION_LOG_DIR") + if env_dir and _can_write(os.path.expanduser(env_dir)): + return os.path.expanduser(env_dir) + + # 2) Next to the executable for frozen builds + try: + if getattr(sys, 'frozen', False) and hasattr(sys, 'executable'): + exe_dir = os.path.dirname(sys.executable) + candidate = os.path.join(exe_dir, "logs") + if _can_write(candidate): + return candidate + except Exception: + pass + + # 3) User-local app data (always writable) + try: + base = os.environ.get('LOCALAPPDATA') or os.environ.get('APPDATA') or os.path.expanduser('~') + candidate = os.path.join(base, 'Glossarion', 'logs') + if _can_write(candidate): + return candidate + except Exception: + pass + + # 4) Development: alongside source file + try: + base_dir = os.path.abspath(os.path.dirname(__file__)) + candidate = os.path.join(base_dir, "logs") + if _can_write(candidate): + return candidate + except Exception: + pass + + # 5) Last resort: current working directory + fallback = os.path.join(os.getcwd(), "logs") + os.makedirs(fallback, exist_ok=True) + return fallback + + try: + logs_dir = _resolve_logs_dir() + # Export for helper modules (e.g., memory_usage_reporter) + os.environ["GLOSSARION_LOG_DIR"] = logs_dir + + # Rotating log handler + log_file = os.path.join(logs_dir, "run.log") + handler = RotatingFileHandler( + log_file, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8" + ) + formatter = logging.Formatter( + fmt="%(asctime)s %(levelname)s [%(process)d:%(threadName)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + # Avoid duplicate handlers on reload + if not any(isinstance(h, RotatingFileHandler) for h in root_logger.handlers): + root_logger.addHandler(handler) + root_logger.setLevel(logging.INFO) + + # Capture warnings via logging + logging.captureWarnings(True) + + # Enable faulthandler to capture hard crashes (e.g., native OOM) + crash_file = os.path.join(logs_dir, "crash.log") + # Keep the file handle open for the lifetime of the process + _FAULT_LOG_FH = open(crash_file, "a", encoding="utf-8") + try: + faulthandler.enable(file=_FAULT_LOG_FH, all_threads=True) + except Exception: + # Best-effort: continue even if faulthandler cannot be enabled + pass + + # Ensure the crash log handle is closed on exit + @atexit.register + def _close_fault_log(): + try: + if _FAULT_LOG_FH and not _FAULT_LOG_FH.closed: + _FAULT_LOG_FH.flush() + _FAULT_LOG_FH.close() + except Exception: + pass + + # Add aggressive cleanup for GIL issues + @atexit.register + def _emergency_thread_cleanup(): + """Emergency cleanup to prevent GIL issues on shutdown""" + try: + # Force garbage collection + import gc + gc.collect() + + # Try to stop any remaining daemon threads + import threading + for thread in threading.enumerate(): + if thread != threading.current_thread() and thread.daemon: + try: + # Don't wait, just mark for cleanup + pass + except Exception: + pass + except Exception: + pass + + # Log uncaught exceptions as critical errors + def _log_excepthook(exc_type, exc_value, exc_tb): + try: + logging.critical("Uncaught exception", exc_info=(exc_type, exc_value, exc_tb)) + except Exception: + pass + sys.excepthook = _log_excepthook + + logging.getLogger(__name__).info("File logging initialized at %s", log_file) + except Exception as e: + # Fallback to basicConfig if anything goes wrong + try: + logging.basicConfig(level=logging.INFO) + except Exception: + pass + try: + print(f"Logging setup failed: {e}") + except Exception: + pass + +# Initialize logging at import time to catch early failures +_setup_file_logging() + +# Start a lightweight background memory usage logger so we can track RAM over time +# TEMPORARILY DISABLED to fix GIL issue +# try: +# from memory_usage_reporter import start_global_memory_logger +# start_global_memory_logger() +# except Exception as _e: +# try: +# logging.getLogger(__name__).warning("Memory usage logger failed to start: %s", _e) +# except Exception: +# pass + +# Apply a safety patch for tqdm to avoid shutdown-time AttributeError without disabling tqdm +try: + from tqdm_safety import apply_tqdm_safety_patch + apply_tqdm_safety_patch() +except Exception as _e: + try: + logging.getLogger(__name__).debug("tqdm safety patch failed to apply: %s", _e) + except Exception: + pass + +def is_traditional_translation_api(model: str) -> bool: + """Check if the model is a traditional translation API""" + return model in ['deepl', 'google-translate', 'google-translate-free'] or model.startswith('deepl/') or model.startswith('google-translate/') + +def check_epub_folder_match(epub_name, folder_name, custom_suffixes=''): + """ + Check if EPUB name and folder name likely refer to the same content + Uses strict matching to avoid false positives with similar numbered titles + """ + # Normalize names for comparison + epub_norm = normalize_name_for_comparison(epub_name) + folder_norm = normalize_name_for_comparison(folder_name) + + # Direct match + if epub_norm == folder_norm: + return True + + # Check if folder has common output suffixes that should be ignored + output_suffixes = ['_output', '_translated', '_trans', '_en', '_english', '_done', '_complete', '_final'] + if custom_suffixes: + custom_list = [s.strip() for s in custom_suffixes.split(',') if s.strip()] + output_suffixes.extend(custom_list) + + for suffix in output_suffixes: + if folder_norm.endswith(suffix): + folder_base = folder_norm[:-len(suffix)] + if folder_base == epub_norm: + return True + if epub_norm.endswith(suffix): + epub_base = epub_norm[:-len(suffix)] + if epub_base == folder_norm: + return True + + # Check for exact match with version numbers removed + version_pattern = r'[\s_-]v\d+$' + epub_no_version = re.sub(version_pattern, '', epub_norm) + folder_no_version = re.sub(version_pattern, '', folder_norm) + + if epub_no_version == folder_no_version and (epub_no_version != epub_norm or folder_no_version != folder_norm): + return True + + # STRICT NUMBER CHECK - all numbers must match exactly + epub_numbers = re.findall(r'\d+', epub_name) + folder_numbers = re.findall(r'\d+', folder_name) + + if epub_numbers != folder_numbers: + return False + + # If we get here, numbers match, so check if the text parts are similar enough + epub_text_only = re.sub(r'\d+', '', epub_norm).strip() + folder_text_only = re.sub(r'\d+', '', folder_norm).strip() + + if epub_numbers and folder_numbers: + return epub_text_only == folder_text_only + + return False + +def normalize_name_for_comparison(name): + """Normalize a filename for comparison - preserving number positions""" + name = name.lower() + name = re.sub(r'\.(epub|txt|html?)$', '', name) + name = re.sub(r'[-_\s]+', ' ', name) + name = re.sub(r'\[(?![^\]]*\d)[^\]]*\]', '', name) + name = re.sub(r'\((?![^)]*\d)[^)]*\)', '', name) + name = re.sub(r'[^\w\s\-]', ' ', name) + name = ' '.join(name.split()) + return name.strip() + +def load_application_icon(window, base_dir): + """Load application icon with fallback handling""" + ico_path = os.path.join(base_dir, 'Halgakos.ico') + if os.path.isfile(ico_path): + try: + window.iconbitmap(ico_path) + except Exception as e: + logging.warning(f"Could not set window icon: {e}") + try: + from PIL import Image, ImageTk + if os.path.isfile(ico_path): + icon_image = Image.open(ico_path) + if icon_image.mode != 'RGBA': + icon_image = icon_image.convert('RGBA') + icon_photo = ImageTk.PhotoImage(icon_image) + window.iconphoto(False, icon_photo) + return icon_photo + except (ImportError, Exception) as e: + logging.warning(f"Could not load icon image: {e}") + return None + +class UIHelper: + """Consolidated UI utility functions""" + + @staticmethod + def setup_text_undo_redo(text_widget): + """Set up undo/redo bindings for a text widget""" + # NUCLEAR OPTION: Disable built-in undo completely + try: + text_widget.config(undo=False) + except: + pass + + # Remove ALL possible z-related bindings + all_z_bindings = [ + 'z', 'Z', '<z>', '<Z>', '<Key-z>', '<Key-Z>', + '<Alt-z>', '<Alt-Z>', '<Meta-z>', '<Meta-Z>', + '<Mod1-z>', '<Mod1-Z>', '<<Undo>>', '<<Redo>>', + '<Control-Key-z>', '<Control-Key-Z>' + ] + + for seq in all_z_bindings: + try: + text_widget.unbind(seq) + text_widget.unbind_all(seq) + text_widget.unbind_class('Text', seq) + except: + pass + + # Create our own undo/redo stack with better management + class UndoRedoManager: + def __init__(self): + self.undo_stack = [] + self.redo_stack = [] + self.is_undoing = False + self.is_redoing = False + self.last_action_was_undo = False + + def save_state(self): + """Save current state to undo stack""" + if self.is_undoing or self.is_redoing: + return + + try: + content = text_widget.get(1.0, tk.END) + # Only save if content changed + if not self.undo_stack or self.undo_stack[-1] != content: + self.undo_stack.append(content) + if len(self.undo_stack) > 100: + self.undo_stack.pop(0) + # Only clear redo stack if this is a new edit (not from undo) + if not self.last_action_was_undo: + self.redo_stack.clear() + self.last_action_was_undo = False + except: + pass + + def undo(self): + """Perform undo""" + #print(f"[DEBUG] Undo called. Stack size: {len(self.undo_stack)}, Redo stack: {len(self.redo_stack)}") + if len(self.undo_stack) > 1: + self.is_undoing = True + self.last_action_was_undo = True + try: + # Save cursor position + cursor_pos = text_widget.index(tk.INSERT) + + # Move current state to redo stack + current = self.undo_stack.pop() + self.redo_stack.append(current) + + # Restore previous state + previous = self.undo_stack[-1] + text_widget.delete(1.0, tk.END) + text_widget.insert(1.0, previous.rstrip('\n')) + + # Restore cursor position + try: + text_widget.mark_set(tk.INSERT, cursor_pos) + text_widget.see(tk.INSERT) + except: + text_widget.mark_set(tk.INSERT, "1.0") + + #print(f"[DEBUG] Undo complete. New redo stack size: {len(self.redo_stack)}") + finally: + self.is_undoing = False + return "break" + + def redo(self): + """Perform redo""" + print(f"[DEBUG] Redo called. Redo stack size: {len(self.redo_stack)}") + if self.redo_stack: + self.is_redoing = True + try: + # Save cursor position + cursor_pos = text_widget.index(tk.INSERT) + + # Get next state + next_state = self.redo_stack.pop() + + # Add to undo stack + self.undo_stack.append(next_state) + + # Restore state + text_widget.delete(1.0, tk.END) + text_widget.insert(1.0, next_state.rstrip('\n')) + + # Restore cursor position + try: + text_widget.mark_set(tk.INSERT, cursor_pos) + text_widget.see(tk.INSERT) + except: + text_widget.mark_set(tk.INSERT, "end-1c") + + print(f"[DEBUG] Redo complete. Remaining redo stack: {len(self.redo_stack)}") + finally: + self.is_redoing = False + self.last_action_was_undo = True + return "break" + + # Create manager instance + manager = UndoRedoManager() + + # CRITICAL: Override ALL key handling to intercept 'z' + def handle_key_press(event): + """Intercept ALL key presses""" + # Check for 'z' or 'Z' + if event.keysym.lower() == 'z': + # Check if Control is pressed + if event.state & 0x4: # Control key is pressed + # This is Control+Z - let it pass to our undo handler + return None # Let it pass through to our Control+Z binding + else: + # Just 'z' without Control - insert it manually + if event.char in ['z', 'Z']: + try: + text_widget.insert(tk.INSERT, event.char) + except: + pass + return "break" + + # Check for Control+Y (redo) + if event.keysym.lower() == 'y' and (event.state & 0x4): + return None # Let it pass through to our Control+Y binding + + # All other keys pass through + return None + + # Bind with highest priority + text_widget.bind('<Key>', handle_key_press, add=False) + + # Bind undo/redo commands + text_widget.bind('<Control-z>', lambda e: manager.undo()) + text_widget.bind('<Control-Z>', lambda e: manager.undo()) + text_widget.bind('<Control-y>', lambda e: manager.redo()) + text_widget.bind('<Control-Y>', lambda e: manager.redo()) + text_widget.bind('<Control-Shift-z>', lambda e: manager.redo()) + text_widget.bind('<Control-Shift-Z>', lambda e: manager.redo()) + + # macOS bindings + text_widget.bind('<Command-z>', lambda e: manager.undo()) + text_widget.bind('<Command-Z>', lambda e: manager.undo()) + text_widget.bind('<Command-Shift-z>', lambda e: manager.redo()) + + # Track changes more efficiently + save_timer = [None] + + def schedule_save(): + """Schedule a save operation with debouncing""" + # Cancel any pending save + if save_timer[0]: + text_widget.after_cancel(save_timer[0]) + # Schedule new save + save_timer[0] = text_widget.after(200, manager.save_state) + + def on_text_modified(event=None): + """Handle text modifications""" + # Don't save during undo/redo or for modifier keys + if event and event.keysym in ['Control_L', 'Control_R', 'Alt_L', 'Alt_R', + 'Shift_L', 'Shift_R', 'Left', 'Right', 'Up', 'Down', + 'Home', 'End', 'Prior', 'Next']: + return + + if not manager.is_undoing and not manager.is_redoing: + schedule_save() + + # More efficient change tracking + text_widget.bind('<KeyRelease>', on_text_modified) + text_widget.bind('<<Paste>>', lambda e: text_widget.after(10, manager.save_state)) + text_widget.bind('<<Cut>>', lambda e: text_widget.after(10, manager.save_state)) + + # Save initial state + def initialize(): + """Initialize with current content""" + try: + content = text_widget.get(1.0, tk.END) + manager.undo_stack.append(content) + #print(f"[DEBUG] Initial state saved. Content length: {len(content)}") + except: + pass + + text_widget.after(50, initialize) + + @staticmethod + def setup_dialog_scrolling(dialog_window, canvas): + """Setup mouse wheel scrolling for dialogs""" + def on_mousewheel(event): + try: + canvas.yview_scroll(int(-1*(event.delta/120)), "units") + except: + pass + + def on_mousewheel_linux(event, direction): + try: + if canvas.winfo_exists(): + canvas.yview_scroll(direction, "units") + except tk.TclError: + pass + + # Bind events TO THE CANVAS AND DIALOG, NOT GLOBALLY + dialog_window.bind("<MouseWheel>", on_mousewheel) + dialog_window.bind("<Button-4>", lambda e: on_mousewheel_linux(e, -1)) + dialog_window.bind("<Button-5>", lambda e: on_mousewheel_linux(e, 1)) + + canvas.bind("<MouseWheel>", on_mousewheel) + canvas.bind("<Button-4>", lambda e: on_mousewheel_linux(e, -1)) + canvas.bind("<Button-5>", lambda e: on_mousewheel_linux(e, 1)) + + # Return cleanup function + def cleanup_bindings(): + try: + dialog_window.unbind("<MouseWheel>") + dialog_window.unbind("<Button-4>") + dialog_window.unbind("<Button-5>") + canvas.unbind("<MouseWheel>") + canvas.unbind("<Button-4>") + canvas.unbind("<Button-5>") + except: + pass + + return cleanup_bindings + + @staticmethod + def create_button_resize_handler(button, base_width, base_height, + master_window, reference_width, reference_height): + """Create a resize handler for dynamic button scaling""" + def on_resize(event): + if event.widget is master_window: + sx = event.width / reference_width + sy = event.height / reference_height + s = min(sx, sy) + new_w = int(base_width * s) + new_h = int(base_height * s) + ipadx = max(0, (new_w - base_width) // 2) + ipady = max(0, (new_h - base_height) // 2) + button.grid_configure(ipadx=ipadx, ipady=ipady) + + return on_resize + + @staticmethod + def setup_scrollable_text(parent, **text_kwargs): + """Create a scrolled text widget with undo/redo support""" + # Remove undo=True from kwargs if present, as we'll handle it ourselves + text_kwargs.pop('undo', None) + text_kwargs.pop('autoseparators', None) + text_kwargs.pop('maxundo', None) + + # Create ScrolledText without built-in undo + text_widget = scrolledtext.ScrolledText(parent, **text_kwargs) + + # Apply our custom undo/redo setup + UIHelper.setup_text_undo_redo(text_widget) + + # Extra protection for ScrolledText widgets + UIHelper._fix_scrolledtext_z_key(text_widget) + + return text_widget + + @staticmethod + def _fix_scrolledtext_z_key(scrolled_widget): + """Apply additional fixes specifically for ScrolledText widgets""" + # ScrolledText stores the actual Text widget in different ways depending on version + # Try to find the actual text widget + text_widget = None + + # Method 1: Direct attribute + if hasattr(scrolled_widget, 'text'): + text_widget = scrolled_widget.text + # Method 2: It might be the widget itself + elif hasattr(scrolled_widget, 'insert') and hasattr(scrolled_widget, 'delete'): + text_widget = scrolled_widget + # Method 3: Look in children + else: + for child in scrolled_widget.winfo_children(): + if isinstance(child, tk.Text): + text_widget = child + break + + if not text_widget: + # If we can't find the text widget, work with scrolled_widget directly + text_widget = scrolled_widget + + # Remove ALL 'z' related bindings at all levels + for widget in [text_widget, scrolled_widget]: + for seq in ['z', 'Z', '<z>', '<Z>', '<Key-z>', '<Key-Z>', + '<<Undo>>', '<<Redo>>', '<Alt-z>', '<Alt-Z>', + '<Meta-z>', '<Meta-Z>', '<Mod1-z>', '<Mod1-Z>']: + try: + widget.unbind(seq) + widget.unbind_all(seq) + except: + pass + + # Override the 'z' key completely + def intercept_z(event): + if event.char in ['z', 'Z']: + if not (event.state & 0x4): # No Control key + text_widget.insert(tk.INSERT, event.char) + return "break" + return None + + # Bind with high priority to both widgets + text_widget.bind('<KeyPress>', intercept_z, add=False) + text_widget.bind('z', lambda e: intercept_z(e)) + text_widget.bind('Z', lambda e: intercept_z(e)) + + @staticmethod + def block_text_editing(text_widget): + """Make a text widget read-only but allow selection and copying""" + def block_editing(event): + # Allow copy + if event.state & 0x4 and event.keysym.lower() == 'c': + return None + # Allow select all + if event.state & 0x4 and event.keysym.lower() == 'a': + text_widget.tag_add(tk.SEL, "1.0", tk.END) + text_widget.mark_set(tk.INSERT, "1.0") + text_widget.see(tk.INSERT) + return "break" + # Allow navigation + if event.keysym in ['Left', 'Right', 'Up', 'Down', 'Home', 'End', 'Prior', 'Next']: + return None + # Allow shift selection + if event.state & 0x1: + return None + return "break" + + text_widget.bind("<Key>", block_editing) + + @staticmethod + def disable_spinbox_mousewheel(spinbox): + """Disable mousewheel scrolling on a spinbox to prevent accidental value changes""" + def block_wheel(event): + return "break" + + spinbox.bind("<MouseWheel>", block_wheel) # Windows + spinbox.bind("<Button-4>", block_wheel) # Linux scroll up + spinbox.bind("<Button-5>", block_wheel) # Linux scroll down + +class WindowManager: + """Unified window geometry and dialog management - FULLY REFACTORED V2""" + + def __init__(self, base_dir): + self.base_dir = base_dir + self.ui = UIHelper() + self._stored_geometries = {} + self._pending_operations = {} + self._dpi_scale = None + self._topmost_protection_active = {} + self._force_safe_ratios = False + self._primary_monitor_width = None # Cache the detected width + + def toggle_safe_ratios(self): + """Toggle forcing 1080p Windows ratios""" + self._force_safe_ratios = not self._force_safe_ratios + return self._force_safe_ratios + + def get_dpi_scale(self, window): + """Get and cache DPI scaling factor""" + if self._dpi_scale is None: + try: + self._dpi_scale = window.tk.call('tk', 'scaling') / 1.333 + except: + self._dpi_scale = 1.0 + return self._dpi_scale + + def responsive_size(self, window, base_width, base_height, + scale_factor=None, center=True, use_full_height=True): + """Size window responsively based on primary monitor""" + + # Auto-detect primary monitor + primary_width = self.detect_primary_monitor_width(window) + screen_height = window.winfo_screenheight() + + if use_full_height: + width = min(int(base_width * 1.2), int(primary_width * 0.98)) + height = int(screen_height * 0.98) + else: + width = base_width + height = base_height + + if width > primary_width * 0.9: + width = int(primary_width * 0.85) + if height > screen_height * 0.9: + height = int(screen_height * 0.85) + + if center: + x = (primary_width - width) // 2 + y = (screen_height - height) // 2 + geometry_str = f"{width}x{height}+{x}+{y}" + else: + geometry_str = f"{width}x{height}" + + window.geometry(geometry_str) + window.attributes('-topmost', False) + + return width, height + + def setup_window(self, window, width=None, height=None, + center=True, icon=True, hide_initially=False, + max_width_ratio=0.98, max_height_ratio=0.98, + min_width=400, min_height=300): + """Universal window setup with auto-detected primary monitor""" + + if hide_initially: + window.withdraw() + + window.attributes('-topmost', False) + + if icon: + window.after_idle(lambda: load_application_icon(window, self.base_dir)) + + primary_width = self.detect_primary_monitor_width(window) + screen_height = window.winfo_screenheight() + dpi_scale = self.get_dpi_scale(window) + + if width is None: + width = min_width + else: + width = int(width / dpi_scale) + + if height is None: + height = int(screen_height * max_height_ratio) + else: + height = int(height / dpi_scale) + + max_width = int(primary_width * max_width_ratio) # Use primary width + max_height = int(screen_height * max_height_ratio) + + final_width = max(min_width, min(width, max_width)) + final_height = max(min_height, min(height, max_height)) + + if center: + x = max(0, (primary_width - final_width) // 2) # Center on primary + y = 5 + geometry_str = f"{final_width}x{final_height}+{x}+{y}" + else: + geometry_str = f"{final_width}x{final_height}" + + window.geometry(geometry_str) + + if hide_initially: + window.after(10, window.deiconify) + + return final_width, final_height + + def get_monitor_from_coord(self, x, y): + """Get monitor info for coordinates (for multi-monitor support)""" + # This is a simplified version - returns primary monitor info + # For true multi-monitor, you'd need to use win32api or other libraries + monitors = [] + + # Try to detect if window is on secondary monitor + # This is a heuristic - if x > screen_width, likely on second monitor + primary_width = self.root.winfo_screenwidth() if hasattr(self, 'root') else 1920 + + if x > primary_width: + # Likely on second monitor + return {'x': primary_width, 'width': primary_width, 'height': 1080} + else: + # Primary monitor + return {'x': 0, 'width': primary_width, 'height': 1080} + + def _fix_maximize_behavior(self, window): + """Fix the standard Windows maximize button for multi-monitor""" + # Store original window protocol + original_state_change = None + + def on_window_state_change(event): + """Intercept maximize from title bar button""" + if event.widget == window: + try: + state = window.state() + if state == 'zoomed': + # Window was just maximized - fix it + window.after(10, lambda: self._proper_maximize(window)) + except: + pass + + # Bind to window state changes to intercept maximize + window.bind('<Configure>', on_window_state_change, add='+') + + def _proper_maximize(self, window): + """Properly maximize window to current monitor only""" + try: + # Get current position + x = window.winfo_x() + screen_width = window.winfo_screenwidth() + screen_height = window.winfo_screenheight() + + # Check if on secondary monitor + if x > screen_width or x < -screen_width/2: + # Likely on a secondary monitor + # Force back to primary monitor for now + window.state('normal') + window.geometry(f"{screen_width-100}x{screen_height-100}+50+50") + window.state('zoomed') + + # The zoomed state should now respect monitor boundaries + + except Exception as e: + print(f"Error in proper maximize: {e}") + + def auto_resize_dialog(self, dialog, canvas=None, max_width_ratio=0.9, max_height_ratio=0.95): + """Auto-resize dialog based on content""" + + # Override ratios if 1080p mode is on + if self._force_safe_ratios: + max_height_ratio = min(max_height_ratio, 0.85) # Force 85% max + max_width_ratio = min(max_width_ratio, 0.85) + + was_hidden = not dialog.winfo_viewable() + + def perform_resize(): + try: + screen_width = dialog.winfo_screenwidth() + screen_height = dialog.winfo_screenheight() + dpi_scale = self.get_dpi_scale(dialog) + + final_height = int(screen_height * max_height_ratio) + + if canvas and canvas.winfo_exists(): + scrollable_frame = None + for child in canvas.winfo_children(): + if isinstance(child, ttk.Frame): + scrollable_frame = child + break + + if scrollable_frame and scrollable_frame.winfo_exists(): + content_width = scrollable_frame.winfo_reqwidth() + # Add 5% more space to content width, plus scrollbar space + window_width = int(content_width * 1.15) + 120 + else: + window_width = int(dialog.winfo_reqwidth() * 1.15) + else: + window_width = int(dialog.winfo_reqwidth() * 1.15) + + window_width = int(window_width / dpi_scale) + + max_width = int(screen_width * max_width_ratio) + final_width = min(window_width, max_width) + final_width = max(final_width, 600) + + x = (screen_width - final_width) // 2 + y = max(20, (screen_height - final_height) // 2) + + dialog.geometry(f"{final_width}x{final_height}+{x}+{y}") + + if was_hidden and dialog.winfo_exists(): + dialog.deiconify() + + return final_width, final_height + + except tk.TclError: + return None, None + + dialog.after(20, perform_resize) + return None, None + + def setup_scrollable(self, parent_window, title, width=None, height=None, + modal=True, resizable=True, max_width_ratio=0.9, + max_height_ratio=0.95, **kwargs): + """Create a scrollable dialog with proper setup""" + + dialog = tk.Toplevel(parent_window) + dialog.title(title) + dialog.withdraw() + + # Ensure not topmost + dialog.attributes('-topmost', False) + + if not resizable: + dialog.resizable(False, False) + + if modal: + dialog.transient(parent_window) + # Don't grab - it blocks other windows + + dialog.after_idle(lambda: load_application_icon(dialog, self.base_dir)) + + screen_width = dialog.winfo_screenwidth() + screen_height = dialog.winfo_screenheight() + dpi_scale = self.get_dpi_scale(dialog) + + if height is None: + height = int(screen_height * max_height_ratio) + else: + height = int(height / dpi_scale) + + if width is None or width == 0: + width = int(screen_width * 0.8) + else: + width = int(width / dpi_scale) + + width = min(width, int(screen_width * max_width_ratio)) + height = min(height, int(screen_height * max_height_ratio)) + + x = (screen_width - width) // 2 + y = max(20, (screen_height - height) // 2) + dialog.geometry(f"{width}x{height}+{x}+{y}") + + main_container = tk.Frame(dialog) + main_container.pack(fill=tk.BOTH, expand=True) + + canvas = tk.Canvas(main_container, bg='white', highlightthickness=0) + scrollbar = ttk.Scrollbar(main_container, orient="vertical", command=canvas.yview) + scrollable_frame = ttk.Frame(canvas) + + canvas_window = canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") + + def configure_scroll_region(event=None): + if canvas.winfo_exists(): + canvas.configure(scrollregion=canvas.bbox("all")) + canvas_width = canvas.winfo_width() + if canvas_width > 1: + canvas.itemconfig(canvas_window, width=canvas_width) + + scrollable_frame.bind("<Configure>", configure_scroll_region) + canvas.bind("<Configure>", lambda e: canvas.itemconfig(canvas_window, width=e.width)) + + canvas.configure(yscrollcommand=scrollbar.set) + + scrollbar.pack(side="right", fill="y") + canvas.pack(side="left", fill="both", expand=True) + + cleanup_scrolling = self.ui.setup_dialog_scrolling(dialog, canvas) + + dialog._cleanup_scrolling = cleanup_scrolling + dialog._canvas = canvas + dialog._scrollable_frame = scrollable_frame + dialog._kwargs = kwargs + + dialog.after(50, dialog.deiconify) + + return dialog, scrollable_frame, canvas + + def create_simple_dialog(self, parent, title, width=None, height=None, + modal=True, hide_initially=True): + """Create a simple non-scrollable dialog""" + + dialog = tk.Toplevel(parent) + dialog.title(title) + + # Ensure not topmost + dialog.attributes('-topmost', False) + + if modal: + dialog.transient(parent) + # Don't grab - it blocks other windows + + dpi_scale = self.get_dpi_scale(dialog) + + adjusted_width = None + adjusted_height = None + + if width is not None: + adjusted_width = int(width / dpi_scale) + + if height is not None: + adjusted_height = int(height / dpi_scale) + else: + screen_height = dialog.winfo_screenheight() + adjusted_height = int(screen_height * 0.98) + + final_width, final_height = self.setup_window( + dialog, + width=adjusted_width, + height=adjusted_height, + hide_initially=hide_initially, + max_width_ratio=0.98, + max_height_ratio=0.98 + ) + + return dialog + + def setup_maximize_support(self, window): + """Setup F11 to maximize window - simple working version""" + + def toggle_maximize(event=None): + """F11 toggles maximize""" + current = window.state() + if current == 'zoomed': + window.state('normal') + else: + window.state('zoomed') + return "break" + + # Bind F11 + window.bind('<F11>', toggle_maximize) + + # Bind Escape to exit maximize only + window.bind('<Escape>', lambda e: window.state('normal') if window.state() == 'zoomed' else None) + + return toggle_maximize + + def setup_fullscreen_support(self, window): + """Legacy method - just calls setup_maximize_support""" + return self.setup_maximize_support(window) + + def _setup_maximize_fix(self, window): + """Setup for Windows title bar maximize button""" + # For now, just let Windows handle maximize naturally + # Most modern Windows versions handle multi-monitor maximize correctly + pass + + def _fix_multi_monitor_maximize(self, window): + """No longer needed - Windows handles maximize correctly""" + pass + + def store_geometry(self, window, key): + """Store window geometry for later restoration""" + if window.winfo_exists(): + self._stored_geometries[key] = window.geometry() + + def restore_geometry(self, window, key, delay=100): + """Restore previously stored geometry""" + if key in self._stored_geometries: + geometry = self._stored_geometries[key] + window.after(delay, lambda: window.geometry(geometry) if window.winfo_exists() else None) + + def toggle_window_maximize(self, window): + """Toggle maximize state for any window (multi-monitor safe)""" + try: + current_state = window.state() + + if current_state == 'zoomed': + # Restore to normal + window.state('normal') + else: + # Get current monitor + x = window.winfo_x() + screen_width = window.winfo_screenwidth() + + # Ensure window is fully on one monitor before maximizing + if x >= screen_width: + # On second monitor + window.geometry(f"+{screen_width}+0") + elif x + window.winfo_width() > screen_width: + # Spanning monitors - move to primary + window.geometry(f"+0+0") + + # Maximize to current monitor + window.state('zoomed') + + except Exception as e: + print(f"Error toggling maximize: {e}") + # Fallback method + self._manual_maximize(window) + + def _manual_maximize(self, window): + """Manual maximize implementation as fallback""" + if not hasattr(window, '_maximize_normal_geometry'): + window._maximize_normal_geometry = None + + if window._maximize_normal_geometry: + # Restore + window.geometry(window._maximize_normal_geometry) + window._maximize_normal_geometry = None + else: + # Store current + window._maximize_normal_geometry = window.geometry() + + # Get dimensions + x = window.winfo_x() + screen_width = window.winfo_screenwidth() + screen_height = window.winfo_screenheight() + + # Determine monitor + if x >= screen_width: + new_x = screen_width + else: + new_x = 0 + + # Leave space for taskbar + taskbar_height = 40 + usable_height = screen_height - taskbar_height + + window.geometry(f"{screen_width}x{usable_height}+{new_x}+0") + + def detect_primary_monitor_width(self, reference_window): + """Auto-detect primary monitor width""" + if self._primary_monitor_width is not None: + return self._primary_monitor_width + + try: + # Create a hidden test window at origin (0,0) - should be on primary monitor + test = tk.Toplevel(reference_window) + test.withdraw() + test.overrideredirect(True) # No window decorations + + # Position at origin + test.geometry("100x100+0+0") + test.update_idletasks() + + # Now maximize it to get the monitor's dimensions + test.state('zoomed') + test.update_idletasks() + + # Get the maximized width - this is the primary monitor width + primary_width = test.winfo_width() + primary_height = test.winfo_height() + + test.destroy() + + # Get total desktop width for comparison + total_width = reference_window.winfo_screenwidth() + screen_height = reference_window.winfo_screenheight() + + print(f"[DEBUG] Maximized test window: {primary_width}x{primary_height}") + print(f"[DEBUG] Total desktop: {total_width}x{screen_height}") + + # If the maximized width equals total width, check for dual monitors + if primary_width >= total_width * 0.95: + # Maximized window = total desktop width, need to detect if dual monitor + aspect = total_width / screen_height + print(f"[DEBUG] Aspect ratio: {aspect:.2f}") + + # For dual monitors detection: + # - Two 1920x1080 monitors = 3840x1080 (aspect 3.56) + # - Two 2560x1440 monitors = 5120x1440 (aspect 3.56) + # - Two 1280x1440 monitors = 2560x1440 (aspect 1.78) + # Single ultrawide: + # - 3440x1440 = aspect 2.39 + # - 2560x1080 = aspect 2.37 + + # If width is exactly double a common resolution, it's dual monitors + if total_width == 3840 and screen_height == 1080: + # Two 1920x1080 monitors + primary_width = 1920 + print(f"[DEBUG] Detected dual 1920x1080 monitors: {primary_width}") + elif total_width == 2560 and screen_height == 1440: + # Two 1280x1440 monitors OR could be single 1440p + # Check if this is likely dual by seeing if half width makes sense + primary_width = 1280 + print(f"[DEBUG] Detected dual 1280x1440 monitors: {primary_width}") + elif total_width == 5120 and screen_height == 1440: + # Two 2560x1440 monitors + primary_width = 2560 + print(f"[DEBUG] Detected dual 2560x1440 monitors: {primary_width}") + elif aspect > 3.0: + # Likely dual monitor based on aspect ratio + primary_width = total_width // 2 + print(f"[DEBUG] Detected dual monitors by aspect ratio: {primary_width}") + else: + # Single ultrawide or normal monitor + print(f"[DEBUG] Single monitor detected: {primary_width}") + else: + print(f"[DEBUG] Primary monitor width detected: {primary_width}") + + self._primary_monitor_width = primary_width + print(f"โœ… Final primary monitor width: {primary_width}") + return primary_width + + except Exception as e: + print(f"โš ๏ธ Error detecting monitor: {e}") + # Fallback to common resolutions based on height + height = reference_window.winfo_screenheight() + if height >= 2160: + return 3840 # 4K + elif height >= 1440: + return 2560 # 1440p + elif height >= 1080: + return 1920 # 1080p + else: + return 1366 # 720p + + def center_window(self, window): + """Center a window on primary screen with auto-detection and taskbar awareness""" + def do_center(): + if window.winfo_exists(): + window.update_idletasks() + width = window.winfo_width() + height = window.winfo_height() + screen_height = window.winfo_screenheight() + + # Auto-detect primary monitor width + primary_width = self.detect_primary_monitor_width(window) + + # Windows taskbar is typically 40-50px at the bottom + taskbar_height = 50 + usable_height = screen_height - taskbar_height + + # Center horizontally on primary monitor (which starts at x=0) + # If window is wider than primary monitor, center it anyway + # (it will extend into the second monitor, which is fine) + x = (primary_width - width) // 2 + + # Allow negative x if window is wider - this centers it on primary monitor + # even if it extends into second monitor + + # Position vertically - lower on screen + y = 50 + + print(f"[DEBUG] Positioning window at: {x}, {y} (size: {width}x{height})") + print(f"[DEBUG] Primary monitor width: {primary_width}, Screen height: {screen_height}") + + window.geometry(f"+{x}+{y}") + + # Execute immediately (no after_idle delay) + do_center() + +class TranslatorGUI: + def __init__(self, master): + # Initialization + master.configure(bg='#2b2b2b') + self.master = master + self.base_dir = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) + self.wm = WindowManager(self.base_dir) + self.ui = UIHelper() + master.attributes('-topmost', False) + master.lift() + self.max_output_tokens = 8192 + self.proc = self.glossary_proc = None + __version__ = "5.0.4" + self.__version__ = __version__ # Store as instance variable + master.title(f"Glossarion v{__version__}") + + # Get screen dimensions - need to detect primary monitor width first + screen_height = master.winfo_screenheight() + + # Detect primary monitor width (not combined width of all monitors) + primary_width = self.wm.detect_primary_monitor_width(master) + + # Set window size - making it wider as requested + # 95% was 1216px, +30% = ~1580px, which is 1.234x the primary monitor + # This will span slightly into the second monitor but centered on primary + width_ratio = 1.23 # 123% of primary monitor width (30% wider than before) + # Account for Windows taskbar (typically 40-50px) + taskbar_height = 50 + usable_height = screen_height - taskbar_height + height_ratio = 0.92 # 92% of usable height (slightly reduced) + + window_width = int(primary_width * width_ratio) + window_height = int(usable_height * height_ratio) + + print(f"[DEBUG] Calculated window size: {window_width}x{window_height}") + print(f"[DEBUG] Primary width: {primary_width}, Usable height: {usable_height}") + print(f"[DEBUG] Width ratio: {width_ratio}, Height ratio: {height_ratio}") + + # Apply size + master.geometry(f"{window_width}x{window_height}") + + # Set minimum size as ratio too + min_width = int(primary_width * 0.5) # 50% minimum of primary monitor + min_height = int(usable_height * 0.5) # 50% minimum + master.minsize(min_width, min_height) + + self.wm.center_window(master) + + # Setup fullscreen support + self.wm.setup_fullscreen_support(master) + + self.payloads_dir = os.path.join(os.getcwd(), "Payloads") + + self._modules_loaded = self._modules_loading = False + self.stop_requested = False + self.translation_thread = self.glossary_thread = self.qa_thread = self.epub_thread = None + self.qa_thread = None + # Futures for executor-based tasks + self.translation_future = self.glossary_future = self.qa_future = self.epub_future = None + # Shared executor for background tasks + self.executor = None + self._executor_workers = None + + # Glossary tracking + self.manual_glossary_path = None + self.auto_loaded_glossary_path = None + self.auto_loaded_glossary_for_file = None + self.manual_glossary_manually_loaded = False + + self.master.protocol("WM_DELETE_WINDOW", self.on_close) + + # Load icon + ico_path = os.path.join(self.base_dir, 'Halgakos.ico') + if os.path.isfile(ico_path): + try: master.iconbitmap(ico_path) + except: pass + + self.logo_img = None + try: + from PIL import Image, ImageTk + self.logo_img = ImageTk.PhotoImage(Image.open(ico_path)) if os.path.isfile(ico_path) else None + if self.logo_img: master.iconphoto(False, self.logo_img) + except Exception as e: + logging.error(f"Failed to load logo: {e}") + + # Load config + try: + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + self.config = json.load(f) + # Decrypt API keys + self.config = decrypt_config(self.config) + except: + self.config = {} + + # Ensure default values exist + if 'auto_update_check' not in self.config: + self.config['auto_update_check'] = True + # Save the default config immediately so it exists + try: + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(self.config, f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"Warning: Could not save config.json: {e}") + + # After loading config, check for Google Cloud credentials + if self.config.get('google_cloud_credentials'): + creds_path = self.config['google_cloud_credentials'] + if os.path.exists(creds_path): + os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = creds_path + # Log will be added after GUI is created + + if 'force_ncx_only' not in self.config: + self.config['force_ncx_only'] = True + + # Initialize OpenRouter transport/compression toggles early so they're available + # before the settings UI creates these variables. This prevents attribute errors + # when features (like glossary extraction) access them at startup. + try: + self.openrouter_http_only_var = tk.BooleanVar( + value=self.config.get('openrouter_use_http_only', False) + ) + except Exception: + self.openrouter_http_only_var = tk.BooleanVar(value=False) + + try: + self.openrouter_accept_identity_var = tk.BooleanVar( + value=self.config.get('openrouter_accept_identity', False) + ) + except Exception: + self.openrouter_accept_identity_var = tk.BooleanVar(value=False) + + # Initialize retain_source_extension env var on startup + try: + os.environ['RETAIN_SOURCE_EXTENSION'] = '1' if self.config.get('retain_source_extension', False) else '0' + except Exception: + pass + + if self.config.get('force_safe_ratios', False): + self.wm._force_safe_ratios = True + # Update button after GUI is created + self.master.after(500, lambda: ( + self.safe_ratios_btn.config(text="๐Ÿ“ 1080p: ON", bootstyle="success") + if hasattr(self, 'safe_ratios_btn') else None + )) + + # Initialize auto-update check and other variables + self.auto_update_check_var = tk.BooleanVar(value=self.config.get('auto_update_check', True)) + self.force_ncx_only_var = tk.BooleanVar(value=self.config.get('force_ncx_only', True)) + self.single_api_image_chunks_var = tk.BooleanVar(value=False) + self.enable_gemini_thinking_var = tk.BooleanVar(value=self.config.get('enable_gemini_thinking', True)) + self.thinking_budget_var = tk.StringVar(value=str(self.config.get('thinking_budget', '-1'))) + # NEW: GPT/OpenRouter reasoning controls + self.enable_gpt_thinking_var = tk.BooleanVar(value=self.config.get('enable_gpt_thinking', True)) + self.gpt_reasoning_tokens_var = tk.StringVar(value=str(self.config.get('gpt_reasoning_tokens', '2000'))) + self.gpt_effort_var = tk.StringVar(value=self.config.get('gpt_effort', 'medium')) + self.thread_delay_var = tk.StringVar(value=str(self.config.get('thread_submission_delay', 0.5))) + self.remove_ai_artifacts = os.getenv("REMOVE_AI_ARTIFACTS", "0") == "1" + print(f" ๐ŸŽจ Remove AI Artifacts: {'ENABLED' if self.remove_ai_artifacts else 'DISABLED'}") + self.disable_chapter_merging_var = tk.BooleanVar(value=self.config.get('disable_chapter_merging', False)) + self.selected_files = [] + self.current_file_index = 0 + self.use_gemini_openai_endpoint_var = tk.BooleanVar(value=self.config.get('use_gemini_openai_endpoint', False)) + self.gemini_openai_endpoint_var = tk.StringVar(value=self.config.get('gemini_openai_endpoint', '')) + self.azure_api_version_var = tk.StringVar(value=self.config.get('azure_api_version', '2025-01-01-preview')) + # Set initial Azure API version environment variable + azure_version = self.config.get('azure_api_version', '2025-01-01-preview') + os.environ['AZURE_API_VERSION'] = azure_version + print(f"๐Ÿ”ง Initial Azure API Version set: {azure_version}") + self.use_fallback_keys_var = tk.BooleanVar(value=self.config.get('use_fallback_keys', False)) + + # Initialize fuzzy threshold variable + if not hasattr(self, 'fuzzy_threshold_var'): + self.fuzzy_threshold_var = tk.DoubleVar(value=self.config.get('glossary_fuzzy_threshold', 0.90)) + self.use_legacy_csv_var = tk.BooleanVar(value=self.config.get('glossary_use_legacy_csv', False)) + + + # Initialize the variables with default values + self.enable_parallel_extraction_var = tk.BooleanVar(value=self.config.get('enable_parallel_extraction', True)) + self.extraction_workers_var = tk.IntVar(value=self.config.get('extraction_workers', 2)) + # GUI yield toggle - disabled by default for maximum speed + self.enable_gui_yield_var = tk.BooleanVar(value=self.config.get('enable_gui_yield', False)) + + # Set initial environment variable and ensure executor + if self.enable_parallel_extraction_var.get(): + # Set workers for glossary extraction optimization + workers = self.extraction_workers_var.get() + os.environ["EXTRACTION_WORKERS"] = str(workers) + # Also enable glossary parallel processing explicitly + os.environ["GLOSSARY_PARALLEL_ENABLED"] = "1" + print(f"โœ… Parallel extraction enabled with {workers} workers") + else: + os.environ["EXTRACTION_WORKERS"] = "1" + os.environ["GLOSSARY_PARALLEL_ENABLED"] = "0" + + # Set GUI yield environment variable (disabled by default for maximum speed) + os.environ['ENABLE_GUI_YIELD'] = '1' if self.enable_gui_yield_var.get() else '0' + print(f"โšก GUI yield: {'ENABLED (responsive)' if self.enable_gui_yield_var.get() else 'DISABLED (maximum speed)'}") + + # Initialize the executor based on current settings + try: + self._ensure_executor() + except Exception: + pass + + + # Initialize compression-related variables + self.enable_image_compression_var = tk.BooleanVar(value=self.config.get('enable_image_compression', False)) + self.auto_compress_enabled_var = tk.BooleanVar(value=self.config.get('auto_compress_enabled', True)) + self.target_image_tokens_var = tk.StringVar(value=str(self.config.get('target_image_tokens', 1000))) + self.image_format_var = tk.StringVar(value=self.config.get('image_compression_format', 'auto')) + self.webp_quality_var = tk.IntVar(value=self.config.get('webp_quality', 85)) + self.jpeg_quality_var = tk.IntVar(value=self.config.get('jpeg_quality', 85)) + self.png_compression_var = tk.IntVar(value=self.config.get('png_compression', 6)) + self.max_image_dimension_var = tk.StringVar(value=str(self.config.get('max_image_dimension', 2048))) + self.max_image_size_mb_var = tk.StringVar(value=str(self.config.get('max_image_size_mb', 10))) + self.preserve_transparency_var = tk.BooleanVar(value=self.config.get('preserve_transparency', False)) + self.preserve_original_format_var = tk.BooleanVar(value=self.config.get('preserve_original_format', False)) + self.optimize_for_ocr_var = tk.BooleanVar(value=self.config.get('optimize_for_ocr', True)) + self.progressive_encoding_var = tk.BooleanVar(value=self.config.get('progressive_encoding', True)) + self.save_compressed_images_var = tk.BooleanVar(value=self.config.get('save_compressed_images', False)) + self.image_chunk_overlap_var = tk.StringVar(value=str(self.config.get('image_chunk_overlap', '1'))) + + # Glossary-related variables (existing) + self.append_glossary_var = tk.BooleanVar(value=self.config.get('append_glossary', False)) + self.glossary_min_frequency_var = tk.StringVar(value=str(self.config.get('glossary_min_frequency', 2))) + self.glossary_max_names_var = tk.StringVar(value=str(self.config.get('glossary_max_names', 50))) + self.glossary_max_titles_var = tk.StringVar(value=str(self.config.get('glossary_max_titles', 30))) + self.glossary_batch_size_var = tk.StringVar(value=str(self.config.get('glossary_batch_size', 50))) + self.glossary_max_text_size_var = tk.StringVar(value=str(self.config.get('glossary_max_text_size', 50000))) + self.glossary_chapter_split_threshold_var = tk.StringVar(value=self.config.get('glossary_chapter_split_threshold', '8192')) + self.glossary_max_sentences_var = tk.StringVar(value=str(self.config.get('glossary_max_sentences', 200))) + self.glossary_filter_mode_var = tk.StringVar(value=self.config.get('glossary_filter_mode', 'all')) + + + # NEW: Additional glossary settings + self.strip_honorifics_var = tk.BooleanVar(value=self.config.get('strip_honorifics', True)) + self.disable_honorifics_var = tk.BooleanVar(value=self.config.get('glossary_disable_honorifics_filter', False)) + self.manual_temp_var = tk.StringVar(value=str(self.config.get('manual_glossary_temperature', 0.3))) + self.manual_context_var = tk.StringVar(value=str(self.config.get('manual_context_limit', 5))) + + # Custom glossary fields and entry types + self.custom_glossary_fields = self.config.get('custom_glossary_fields', []) + self.custom_entry_types = self.config.get('custom_entry_types', { + 'character': {'enabled': True, 'has_gender': True}, + 'term': {'enabled': True, 'has_gender': False} + }) + + # Glossary prompts + self.manual_glossary_prompt = self.config.get('manual_glossary_prompt', + """Extract character names and important terms from the text. +Format each entry as: type,raw_name,translated_name,gender +For terms use: term,raw_name,translated_name,""") + + self.auto_glossary_prompt = self.config.get('auto_glossary_prompt', + """Extract all character names and important terms from the text. +Focus on: +1. Character names (maximum {max_names}) +2. Important titles and positions (maximum {max_titles}) +3. Terms that appear at least {min_frequency} times + +Return as JSON: {"term": "translation", ...}""") + + self.append_glossary_prompt = self.config.get('append_glossary_prompt', + '- Follow this reference glossary for consistent translation (Do not output any raw entries):\n') + + self.glossary_translation_prompt = self.config.get('glossary_translation_prompt', + """ +You are translating {language} character names and important terms to English. +For character names, provide English transliterations or keep as romanized. +Keep honorifics/suffixes only if they are integral to the name. +Respond with the same numbered format. + +Terms to translate: +{terms_list} + +Provide translations in the same numbered format.""") + self.glossary_format_instructions = self.config.get('glossary_format_instructions', + """ +Return the results in EXACT CSV format with this header: +type,raw_name,translated_name + +For example: +character,๊น€์ƒํ˜„,Kim Sang-hyu +character,๊ฐˆํŽธ์ œ,Gale Hardest +character,๋””ํžˆ๋ฆฟ ์•„๋ฐ,Dihirit Ade + +Only include terms that actually appear in the text. +Do not use quotes around values unless they contain commas. + +Text to analyze: +{text_sample}""") + + # Initialize custom API endpoint variables + self.openai_base_url_var = tk.StringVar(value=self.config.get('openai_base_url', '')) + self.groq_base_url_var = tk.StringVar(value=self.config.get('groq_base_url', '')) + self.fireworks_base_url_var = tk.StringVar(value=self.config.get('fireworks_base_url', '')) + self.use_custom_openai_endpoint_var = tk.BooleanVar(value=self.config.get('use_custom_openai_endpoint', False)) + + # Initialize metadata/batch variables the same way + self.translate_metadata_fields = self.config.get('translate_metadata_fields', {}) + # Initialize metadata translation UI and prompts + try: + from metadata_batch_translator import MetadataBatchTranslatorUI + self.metadata_ui = MetadataBatchTranslatorUI(self) + # This ensures default prompts are in config + except ImportError: + print("Metadata translation UI not available") + self.batch_translate_headers_var = tk.BooleanVar(value=self.config.get('batch_translate_headers', False)) + self.headers_per_batch_var = tk.StringVar(value=self.config.get('headers_per_batch', '400')) + self.update_html_headers_var = tk.BooleanVar(value=self.config.get('update_html_headers', True)) + self.save_header_translations_var = tk.BooleanVar(value=self.config.get('save_header_translations', True)) + self.ignore_header_var = tk.BooleanVar(value=self.config.get('ignore_header', False)) + self.ignore_title_var = tk.BooleanVar(value=self.config.get('ignore_title', False)) + self.attach_css_to_chapters_var = tk.BooleanVar(value=self.config.get('attach_css_to_chapters', False)) + + # Retain exact source extension and disable 'response_' prefix + self.retain_source_extension_var = tk.BooleanVar(value=self.config.get('retain_source_extension', False)) + + + self.max_output_tokens = self.config.get('max_output_tokens', self.max_output_tokens) + self.master.after(500, lambda: self.on_model_change() if hasattr(self, 'model_var') else None) + + + # Async processing settings + self.async_wait_for_completion_var = tk.BooleanVar(value=False) + self.async_poll_interval_var = tk.IntVar(value=60) + + # Enhanced filtering level + if not hasattr(self, 'enhanced_filtering_var'): + self.enhanced_filtering_var = tk.StringVar( + value=self.config.get('enhanced_filtering', 'smart') + ) + + # Preserve structure toggle + if not hasattr(self, 'enhanced_preserve_structure_var'): + self.enhanced_preserve_structure_var = tk.BooleanVar( + value=self.config.get('enhanced_preserve_structure', True) + ) + + # Initialize update manager AFTER config is loaded + try: + from update_manager import UpdateManager + self.update_manager = UpdateManager(self, self.base_dir) + + # Check for updates on startup if enabled + auto_check_enabled = self.config.get('auto_update_check', True) + print(f"[DEBUG] Auto-update check enabled: {auto_check_enabled}") + + if auto_check_enabled: + print("[DEBUG] Scheduling update check for 5 seconds from now...") + self.master.after(5000, self._check_updates_on_startup) + else: + print("[DEBUG] Auto-update check is disabled") + except ImportError as e: + self.update_manager = None + print(f"[DEBUG] Update manager not available: {e}") + + try: + from metadata_batch_translator import MetadataBatchTranslatorUI + self.metadata_ui = MetadataBatchTranslatorUI(self) + # This ensures default prompts are in config + except ImportError: + print("Metadata translation UI not available") + + # Default prompts + self.default_translation_chunk_prompt = "[This is part {chunk_idx}/{total_chunks}]. You must maintain the narrative flow with the previous chunks while translating it and following all system prompt guidelines previously mentioned.\n{chunk_html}" + self.default_image_chunk_prompt = "This is part {chunk_idx} of {total_chunks} of a longer image. You must maintain the narrative flow with the previous chunks while translating it and following all system prompt guidelines previously mentioned. {context}" + self.default_prompts = { + + "korean": ( + "You are a professional Korean to English novel translator, you must strictly output only English text and HTML tags while following these rules:\n" + "- Use a natural, comedy-friendly English translation style that captures both humor and readability without losing any original meaning.\n" + "- Include 100% of the source text - every word, phrase, and sentence must be fully translated without exception.\n" + "- Retain Korean honorifics and respectful speech markers in romanized form, including but not limited to: -nim, -ssi, -yang, -gun, -isiyeo, -hasoseo. For archaic/classical Korean honorific forms (like ์ด์‹œ์—ฌ/isiyeo, ํ•˜์†Œ์„œ/hasoseo), preserve them as-is rather than converting to modern equivalents.\n" + "- Always localize Korean terminology to proper English equivalents instead of literal translations (examples: ๋งˆ์™• = Demon King; ๋งˆ์ˆ  = magic).\n" + "- When translating Korean's pronoun-dropping style, insert pronouns in English only where needed for clarity: prioritize original pronouns as implied or according to the glossary, and only use they/them as a last resort, use I/me for first-person narration, and maintain natural English flow without overusing pronouns just because they're omitted in Korean.\n" + "- All Korean profanity must be translated to English profanity.\n" + "- Preserve original intent, and speech tone.\n" + "- Retain onomatopoeia in Romaji.\n" + "- Keep original Korean quotation marks (" ", ' ', ใ€Œใ€, ใ€Žใ€) as-is without converting to English quotes.\n" + "- Every Korean/Chinese/Japanese character must be converted to its English meaning. Examples: The character ์ƒ means 'life/living', ํ™œ means 'active', ๊ด€ means 'hall/building' - together ์ƒํ™œ๊ด€ means Dormitory.\n" + "- Preserve ALL HTML tags exactly as they appear in the source, including <head>, <title>, <h1>, <h2>, <p>, <br>, <div>, etc.\n" + ), + "japanese": ( + "You are a professional Japanese to English novel translator, you must strictly output only English text and HTML tags while following these rules:\n" + "- Use a natural, comedy-friendly English translation style that captures both humor and readability without losing any original meaning.\n" + "- Include 100% of the source text - every word, phrase, and sentence must be fully translated without exception.\n" + "- Retain Japanese honorifics and respectful speech markers in romanized form, including but not limited to: -san, -sama, -chan, -kun, -dono, -sensei, -senpai, -kouhai. For archaic/classical Japanese honorific forms, preserve them as-is rather than converting to modern equivalents.\n" + "- Always localize Japanese terminology to proper English equivalents instead of literal translations (examples: ้ญ”็Ž‹ = Demon King; ้ญ”่ก“ = magic).\n" + "- When translating Japanese's pronoun-dropping style, insert pronouns in English only where needed for clarity: prioritize original pronouns as implied or according to the glossary, and only use they/them as a last resort, use I/me for first-person narration while reflecting the Japanese pronoun's nuance (็ง/ๅƒ•/ไฟบ/etc.) through speech patterns rather than the pronoun itself, and maintain natural English flow without overusing pronouns just because they're omitted in Japanese.\n" + "- All Japanese profanity must be translated to English profanity.\n" + "- Preserve original intent, and speech tone.\n" + "- Retain onomatopoeia in Romaji.\n" + "- Keep original Japanese quotation marks (ใ€Œใ€ and ใ€Žใ€) as-is without converting to English quotes.\n" + "- Every Korean/Chinese/Japanese character must be converted to its English meaning. Examples: The character ็”Ÿ means 'life/living', ๆดป means 'active', ้คจ means 'hall/building' - together ็”Ÿๆดป้คจ means Dormitory.\n" + "- Preserve ALL HTML tags exactly as they appear in the source, including <head>, <title>, <h1>, <h2>, <p>, <br>, <div>, etc.\n" + ), + "chinese": ( + "You are a professional Chinese to English novel translator, you must strictly output only English text and HTML tags while following these rules:\n" + "- Use a natural, comedy-friendly English translation style that captures both humor and readability without losing any original meaning.\n" + "- Include 100% of the source text - every word, phrase, and sentence must be fully translated without exception.\n" + "- Retain Chinese titles and respectful forms of address in romanized form, including but not limited to: laoban, laoshi, shifu, xiaojie, xiansheng, taitai, daren, qianbei. For archaic/classical Chinese respectful forms, preserve them as-is rather than converting to modern equivalents.\n" + "- Always localize Chinese terminology to proper English equivalents instead of literal translations (examples: ้ญ”็Ž‹ = Demon King; ๆณ•ๆœฏ = magic).\n" + "- When translating Chinese's flexible pronoun usage, insert pronouns in English only where needed for clarity: prioritize original pronouns as implied or according to the glossary, and only use they/them as a last resort, use I/me for first-person narration while reflecting the pronoun's nuance (ๆˆ‘/ๅพ/ๅ’ฑ/ไบบๅฎถ/etc.) through speech patterns and formality level rather than the pronoun itself, and since Chinese pronouns don't indicate gender in speech (ไป–/ๅฅน/ๅฎƒ all sound like 'tฤ'), rely on context or glossary rather than assuming gender.\n" + "- All Chinese profanity must be translated to English profanity.\n" + "- Preserve original intent, and speech tone.\n" + "- Retain onomatopoeia in Romaji.\n" + "- Keep original Chinese quotation marks (ใ€Œใ€ for dialogue, ใ€Šใ€‹ for titles) as-is without converting to English quotes.\n" + "- Every Korean/Chinese/Japanese character must be converted to its English meaning. Examples: The character ็”Ÿ means 'life/living', ๆดป means 'active', ้คจ means 'hall/building' - together ็”Ÿๆดป้คจ means Dormitory.\n" + "- Preserve ALL HTML tags exactly as they appear in the source, including <head>, <title>, <h1>, <h2>, <p>, <br>, <div>, etc.\n" + ), + "korean_OCR": ( + "You are a professional Korean to English novel translator, you must strictly output only English text and HTML tags while following these rules:\n" + "- Use a natural, comedy-friendly English translation style that captures both humor and readability without losing any original meaning.\n" + "- Include 100% of the source text - every word, phrase, and sentence must be fully translated without exception.\n" + "- Retain Korean honorifics and respectful speech markers in romanized form, including but not limited to: -nim, -ssi, -yang, -gun, -isiyeo, -hasoseo. For archaic/classical Korean honorific forms (like ์ด์‹œ์—ฌ/isiyeo, ํ•˜์†Œ์„œ/hasoseo), preserve them as-is rather than converting to modern equivalents.\n" + "- Always localize Korean terminology to proper English equivalents instead of literal translations (examples: ๋งˆ์™• = Demon King; ๋งˆ์ˆ  = magic).\n" + "- When translating Korean's pronoun-dropping style, insert pronouns in English only where needed for clarity: prioritize original pronouns as implied or according to the glossary, and only use they/them as a last resort, use I/me for first-person narration, and maintain natural English flow without overusing pronouns just because they're omitted in Korean.\n" + "- All Korean profanity must be translated to English profanity.\n" + "- Preserve original intent, and speech tone.\n" + "- Retain onomatopoeia in Romaji.\n" + "- Keep original Korean quotation marks (" ", ' ', ใ€Œใ€, ใ€Žใ€) as-is without converting to English quotes.\n" + "- Every Korean/Chinese/Japanese character must be converted to its English meaning. Examples: The character ์ƒ means 'life/living', ํ™œ means 'active', ๊ด€ means 'hall/building' - together ์ƒํ™œ๊ด€ means Dormitory.\n" + "- Add HTML tags for proper formatting as expected of a novel.\n" + "- Wrap every paragraph in <p> tags; do not insert any literal tabs or spaces.\n" + ), + "japanese_OCR": ( + "You are a professional Japanese to English novel translator, you must strictly output only English text and HTML tags while following these rules:\n" + "- Use a natural, comedy-friendly English translation style that captures both humor and readability without losing any original meaning.\n" + "- Include 100% of the source text - every word, phrase, and sentence must be fully translated without exception.\n" + "- Retain Japanese honorifics and respectful speech markers in romanized form, including but not limited to: -san, -sama, -chan, -kun, -dono, -sensei, -senpai, -kouhai. For archaic/classical Japanese honorific forms, preserve them as-is rather than converting to modern equivalents.\n" + "- Always localize Japanese terminology to proper English equivalents instead of literal translations (examples: ้ญ”็Ž‹ = Demon King; ้ญ”่ก“ = magic).\n" + "- When translating Japanese's pronoun-dropping style, insert pronouns in English only where needed for clarity: prioritize original pronouns as implied or according to the glossary, and only use they/them as a last resort, use I/me for first-person narration while reflecting the Japanese pronoun's nuance (็ง/ๅƒ•/ไฟบ/etc.) through speech patterns rather than the pronoun itself, and maintain natural English flow without overusing pronouns just because they're omitted in Japanese.\n" + "- All Japanese profanity must be translated to English profanity.\n" + "- Preserve original intent, and speech tone.\n" + "- Retain onomatopoeia in Romaji.\n" + "- Keep original Japanese quotation marks (ใ€Œใ€ and ใ€Žใ€) as-is without converting to English quotes.\n" + "- Every Korean/Chinese/Japanese character must be converted to its English meaning. Examples: The character ็”Ÿ means 'life/living', ๆดป means 'active', ้คจ means 'hall/building' - together ็”Ÿๆดป้คจ means Dormitory.\n" + "- Add HTML tags for proper formatting as expected of a novel.\n" + "- Wrap every paragraph in <p> tags; do not insert any literal tabs or spaces.\n" + ), + "chinese_OCR": ( + "You are a professional Chinese to English novel translator, you must strictly output only English text and HTML tags while following these rules:\n" + "- Use a natural, comedy-friendly English translation style that captures both humor and readability without losing any original meaning.\n" + "- Include 100% of the source text - every word, phrase, and sentence must be fully translated without exception.\n" + "- Retain Chinese titles and respectful forms of address in romanized form, including but not limited to: laoban, laoshi, shifu, xiaojie, xiansheng, taitai, daren, qianbei. For archaic/classical Chinese respectful forms, preserve them as-is rather than converting to modern equivalents.\n" + "- Always localize Chinese terminology to proper English equivalents instead of literal translations (examples: ้ญ”็Ž‹ = Demon King; ๆณ•ๆœฏ = magic).\n" + "- When translating Chinese's flexible pronoun usage, insert pronouns in English only where needed for clarity: prioritize original pronouns as implied or according to the glossary, and only use they/them as a last resort, use I/me for first-person narration while reflecting the pronoun's nuance (ๆˆ‘/ๅพ/ๅ’ฑ/ไบบๅฎถ/etc.) through speech patterns and formality level rather than the pronoun itself, and since Chinese pronouns don't indicate gender in speech (ไป–/ๅฅน/ๅฎƒ all sound like 'tฤ'), rely on context or glossary rather than assuming gender.\n" + "- All Chinese profanity must be translated to English profanity.\n" + "- Preserve original intent, and speech tone.\n" + "- Retain onomatopoeia in Romaji.\n" + "- Keep original Chinese quotation marks (ใ€Œใ€ for dialogue, ใ€Šใ€‹ for titles) as-is without converting to English quotes.\n" + "- Every Korean/Chinese/Japanese character must be converted to its English meaning. Examples: The character ็”Ÿ means 'life/living', ๆดป means 'active', ้คจ means 'hall/building' - together ็”Ÿๆดป้คจ means Dormitory.\n" + "- Add HTML tags for proper formatting as expected of a novel.\n" + "- Wrap every paragraph in <p> tags; do not insert any literal tabs or spaces.\n" + ), + "korean_TXT": ( + "You are a professional Korean to English novel translator, you must strictly output only English text while following these rules:\n" + "- Use a natural, comedy-friendly English translation style that captures both humor and readability without losing any original meaning.\n" + "- Include 100% of the source text - every word, phrase, and sentence must be fully translated without exception.\n" + "- Retain Korean honorifics and respectful speech markers in romanized form, including but not limited to: -nim, -ssi, -yang, -gun, -isiyeo, -hasoseo. For archaic/classical Korean honorific forms (like ์ด์‹œ์—ฌ/isiyeo, ํ•˜์†Œ์„œ/hasoseo), preserve them as-is rather than converting to modern equivalents.\n" + "- Always localize Korean terminology to proper English equivalents instead of literal translations (examples: ๋งˆ์™• = Demon King; ๋งˆ์ˆ  = magic).\n" + "- When translating Korean's pronoun-dropping style, insert pronouns in English only where needed for clarity: prioritize original pronouns as implied or according to the glossary, and only use they/them as a last resort, use I/me for first-person narration, and maintain natural English flow without overusing pronouns just because they're omitted in Korean.\n" + "- All Korean profanity must be translated to English profanity.\n" + "- Preserve original intent, and speech tone.\n" + "- Retain onomatopoeia in Romaji.\n" + "- Keep original Korean quotation marks (" ", ' ', ใ€Œใ€, ใ€Žใ€) as-is without converting to English quotes.\n" + "- Every Korean/Chinese/Japanese character must be converted to its English meaning. Examples: The character ์ƒ means 'life/living', ํ™œ means 'active', ๊ด€ means 'hall/building' - together ์ƒํ™œ๊ด€ means Dormitory.\n" + "- Use line breaks for proper formatting as expected of a novel.\n" + ), + "japanese_TXT": ( + "You are a professional Japanese to English novel translator, you must strictly output only English text while following these rules:\n" + "- Use a natural, comedy-friendly English translation style that captures both humor and readability without losing any original meaning.\n" + "- Include 100% of the source text - every word, phrase, and sentence must be fully translated without exception.\n" + "- Retain Japanese honorifics and respectful speech markers in romanized form, including but not limited to: -san, -sama, -chan, -kun, -dono, -sensei, -senpai, -kouhai. For archaic/classical Japanese honorific forms, preserve them as-is rather than converting to modern equivalents.\n" + "- Always localize Japanese terminology to proper English equivalents instead of literal translations (examples: ้ญ”็Ž‹ = Demon King; ้ญ”่ก“ = magic).\n" + "- When translating Japanese's pronoun-dropping style, insert pronouns in English only where needed for clarity: prioritize original pronouns as implied or according to the glossary, and only use they/them as a last resort, use I/me for first-person narration while reflecting the Japanese pronoun's nuance (็ง/ๅƒ•/ไฟบ/etc.) through speech patterns rather than the pronoun itself, and maintain natural English flow without overusing pronouns just because they're omitted in Japanese.\n" + "- All Japanese profanity must be translated to English profanity.\n" + "- Preserve original intent, and speech tone.\n" + "- Retain onomatopoeia in Romaji.\n" + "- Keep original Japanese quotation marks (ใ€Œใ€ and ใ€Žใ€) as-is without converting to English quotes.\n" + "- Every Korean/Chinese/Japanese character must be converted to its English meaning. Examples: The character ็”Ÿ means 'life/living', ๆดป means 'active', ้คจ means 'hall/building' - together ็”Ÿๆดป้คจ means Dormitory.\n" + "- Use line breaks for proper formatting as expected of a novel.\n" + ), + "chinese_TXT": ( + "You are a professional Chinese to English novel translator, you must strictly output only English text while following these rules:\n" + "- Use a natural, comedy-friendly English translation style that captures both humor and readability without losing any original meaning.\n" + "- Include 100% of the source text - every word, phrase, and sentence must be fully translated without exception.\n" + "- Retain Chinese titles and respectful forms of address in romanized form, including but not limited to: laoban, laoshi, shifu, xiaojie, xiansheng, taitai, daren, qianbei. For archaic/classical Chinese respectful forms, preserve them as-is rather than converting to modern equivalents.\n" + "- Always localize Chinese terminology to proper English equivalents instead of literal translations (examples: ้ญ”็Ž‹ = Demon King; ๆณ•ๆœฏ = magic).\n" + "- When translating Chinese's flexible pronoun usage, insert pronouns in English only where needed for clarity: prioritize original pronouns as implied or according to the glossary, and only use they/them as a last resort, use I/me for first-person narration while reflecting the pronoun's nuance (ๆˆ‘/ๅพ/ๅ’ฑ/ไบบๅฎถ/etc.) through speech patterns and formality level rather than the pronoun itself, and since Chinese pronouns don't indicate gender in speech (ไป–/ๅฅน/ๅฎƒ all sound like 'tฤ'), rely on context or glossary rather than assuming gender.\n" + "- All Chinese profanity must be translated to English profanity.\n" + "- Preserve original intent, and speech tone.\n" + "- Retain onomatopoeia in Romaji.\n" + "- Keep original Chinese quotation marks (ใ€Œใ€ for dialogue, ใ€Šใ€‹ for titles) as-is without converting to English quotes.\n" + "- Every Korean/Chinese/Japanese character must be converted to its English meaning. Examples: The character ็”Ÿ means 'life/living', ๆดป means 'active', ้คจ means 'hall/building' - together ็”Ÿๆดป้คจ means Dormitory.\n" + "- Use line breaks for proper formatting as expected of a novel.\n" + ), + "Manga_JP": ( + "You are a professional Japanese to English Manga translator.\n" + "You have both the image of the Manga panel and the extracted text to work with.\n" + "Output only English text while following these rules: \n\n" + + "VISUAL CONTEXT:\n" + "- Analyze the characterโ€™s facial expressions and body language in the image.\n" + "- Consider the sceneโ€™s mood and atmosphere.\n" + "- Note any action or movement depicted.\n" + "- Use visual cues to determine the appropriate tone and emotion.\n" + "- USE THE IMAGE to inform your translation choices. The image is not decorative - it contains essential context for accurate translation.\n\n" + + "DIALOGUE REQUIREMENTS:\n" + "- Match the translation tone to the character's expression.\n" + "- If a character looks angry, use appropriately intense language.\n" + "- If a character looks shy or embarrassed, reflect that in the translation.\n" + "- Keep speech patterns consistent with the character's appearance and demeanor.\n" + "- Retain honorifics and onomatopoeia in Romaji.\n" + "- Keep original Japanese quotation marks (ใ€Œใ€, ใ€Žใ€) as-is without converting to English quotes.\n\n" + + "IMPORTANT: Use both the visual context and text to create the most accurate and natural-sounding translation.\n" + ), + "Manga_KR": ( + "You are a professional Korean to English Manhwa translator.\n" + "You have both the image of the Manhwa panel and the extracted text to work with.\n" + "Output only English text while following these rules: \n\n" + + "VISUAL CONTEXT:\n" + "- Analyze the characterโ€™s facial expressions and body language in the image.\n" + "- Consider the sceneโ€™s mood and atmosphere.\n" + "- Note any action or movement depicted.\n" + "- Use visual cues to determine the appropriate tone and emotion.\n" + "- USE THE IMAGE to inform your translation choices. The image is not decorative - it contains essential context for accurate translation.\n\n" + + "DIALOGUE REQUIREMENTS:\n" + "- Match the translation tone to the character's expression.\n" + "- If a character looks angry, use appropriately intense language.\n" + "- If a character looks shy or embarrassed, reflect that in the translation.\n" + "- Keep speech patterns consistent with the character's appearance and demeanor.\n" + "- Retain honorifics and onomatopoeia in Romaji.\n" + "- Keep original Korean quotation marks (" ", ' ', ใ€Œใ€, ใ€Žใ€) as-is without converting to English quotes.\n\n" + + "IMPORTANT: Use both the visual context and text to create the most accurate and natural-sounding translation.\n" + ), + "Manga_CN": ( + "You are a professional Chinese to English Manga translator.\n" + "You have both the image of the Manga panel and the extracted text to work with.\n" + "Output only English text while following these rules: \n\n" + + "VISUAL CONTEXT:\n" + "- Analyze the characterโ€™s facial expressions and body language in the image.\n" + "- Consider the sceneโ€™s mood and atmosphere.\n" + "- Note any action or movement depicted.\n" + "- Use visual cues to determine the appropriate tone and emotion.\n" + "- USE THE IMAGE to inform your translation choices. The image is not decorative - it contains essential context for accurate translation.\n" + + "DIALOGUE REQUIREMENTS:\n" + "- Match the translation tone to the character's expression.\n" + "- If a character looks angry, use appropriately intense language.\n" + "- If a character looks shy or embarrassed, reflect that in the translation.\n" + "- Keep speech patterns consistent with the character's appearance and demeanor.\n" + "- Retain honorifics and onomatopoeia in Romaji.\n" + "- Keep original Chinese quotation marks (ใ€Œใ€, ใ€Žใ€) as-is without converting to English quotes.\n\n" + + "IMPORTANT: Use both the visual context and text to create the most accurate and natural-sounding translation.\n" + ), + "Glossary_Editor": ( + "I have a messy character glossary from a Korean web novel that needs to be cleaned up and restructured. Please Output only JSON entries while creating a clean JSON glossary with the following requirements:\n" + "1. Merge duplicate character entries - Some characters appear multiple times (e.g., Noah, Ichinose family members).\n" + "2. Separate mixed character data - Some entries incorrectly combine multiple characters' information.\n" + "3. Use 'Korean = English' format - Replace all parentheses with equals signs (e.g., '์ด๋กœํ•œ = Lee Rohan' instead of '์ด๋กœํ•œ (Lee Rohan)').\n" + "4. Merge original_name fields - Combine original Korean names with English names in the name field.\n" + "5. Remove empty fields - Don't include empty arrays or objects.\n" + "6. Fix gender inconsistencies - Correct based on context from aliases.\n" + + ), + "Original": "Return everything exactly as seen on the source." + } + + self._init_default_prompts() + self._init_variables() + + # Bind other settings methods early so they're available during GUI setup + from other_settings import setup_other_settings_methods + setup_other_settings_methods(self) + + self._setup_gui() + self.metadata_batch_ui = MetadataBatchTranslatorUI(self) + + try: + needs_encryption = False + if 'api_key' in self.config and self.config['api_key']: + if not self.config['api_key'].startswith('ENC:'): + needs_encryption = True + if 'replicate_api_key' in self.config and self.config['replicate_api_key']: + if not self.config['replicate_api_key'].startswith('ENC:'): + needs_encryption = True + + if needs_encryption: + # Auto-migrate to encrypted format + print("Auto-encrypting API keys...") + self.save_config(show_message=False) + print("API keys encrypted successfully!") + except Exception as e: + print(f"Auto-encryption check failed: {e}") + + def _check_updates_on_startup(self): + """Check for updates on startup with debug logging (async)""" + print("[DEBUG] Running startup update check...") + if self.update_manager: + try: + self.update_manager.check_for_updates_async(silent=True) + print(f"[DEBUG] Update check dispatched asynchronously") + except Exception as e: + print(f"[DEBUG] Update check failed to dispatch: {e}") + else: + print("[DEBUG] Update manager is None") + + def check_for_updates_manual(self): + """Manually check for updates from the Other Settings dialog with loading animation""" + if hasattr(self, 'update_manager') and self.update_manager: + self._show_update_loading_and_check() + else: + messagebox.showerror("Update Check", + "Update manager is not available.\n" + "Please check the GitHub releases page manually:\n" + "https://github.com/Shirochi-stack/Glossarion/releases") + + def _show_update_loading_and_check(self): + """Show animated loading dialog while checking for updates""" + import tkinter as tk + import tkinter.ttk as ttk + from PIL import Image, ImageTk + import threading + import os + + # Create loading dialog + loading_dialog = tk.Toplevel(self.master) + loading_dialog.title("Checking for Updates") + loading_dialog.geometry("300x150") + loading_dialog.resizable(False, False) + loading_dialog.transient(self.master) + loading_dialog.grab_set() + + # Set the proper application icon for the dialog + try: + # Use the same icon loading method as the main application + load_application_icon(loading_dialog, self.base_dir) + except Exception as e: + print(f"Could not load icon for loading dialog: {e}") + + # Position dialog at mouse cursor + try: + mouse_x = self.master.winfo_pointerx() + mouse_y = self.master.winfo_pointery() + # Offset slightly so dialog doesn't cover cursor + loading_dialog.geometry("+%d+%d" % (mouse_x + 10, mouse_y + 10)) + except: + # Fallback to center of main window if mouse position fails + loading_dialog.geometry("+%d+%d" % ( + self.master.winfo_rootx() + 50, + self.master.winfo_rooty() + 50 + )) + + # Create main frame + main_frame = ttk.Frame(loading_dialog, padding="20") + main_frame.pack(fill="both", expand=True) + + # Try to load and resize the icon (same approach as main GUI) + icon_label = None + try: + ico_path = os.path.join(self.base_dir, 'Halgakos.ico') + if os.path.isfile(ico_path): + # Load and resize image + original_image = Image.open(ico_path) + # Resize to 48x48 for loading animation + resized_image = original_image.resize((48, 48), Image.Resampling.LANCZOS) + self.loading_icon = ImageTk.PhotoImage(resized_image) + + icon_label = ttk.Label(main_frame, image=self.loading_icon) + icon_label.pack(pady=(0, 10)) + except Exception as e: + print(f"Could not load loading icon: {e}") + + # Add loading text + loading_text = ttk.Label(main_frame, text="Checking for updates...", + font=('TkDefaultFont', 11)) + loading_text.pack() + + # Add progress bar + progress_bar = ttk.Progressbar(main_frame, mode='indeterminate') + progress_bar.pack(pady=(10, 10), fill='x') + progress_bar.start(10) # Start animation + + # Animation state + self.loading_animation_active = True + self.loading_rotation = 0 + + def animate_icon(): + """Animate the loading icon by rotating it""" + if not self.loading_animation_active or not icon_label: + return + + try: + if hasattr(self, 'loading_icon'): + # Simple text-based animation instead of rotation + dots = "." * ((self.loading_rotation // 10) % 4) + loading_text.config(text=f"Checking for updates{dots}") + self.loading_rotation += 1 + + # Schedule next animation frame + loading_dialog.after(100, animate_icon) + except: + pass # Dialog might have been destroyed + + # Start icon animation if we have an icon + if icon_label: + animate_icon() + + def check_updates_thread(): + """Run update check in background thread""" + try: + # Perform the actual update check + self.update_manager.check_for_updates(silent=False, force_show=True) + except Exception as e: + # Schedule error display on main thread + loading_dialog.after(0, lambda: self._show_update_error(str(e))) + finally: + # Schedule cleanup on main thread + loading_dialog.after(0, cleanup_loading) + + def cleanup_loading(): + """Clean up the loading dialog""" + try: + self.loading_animation_active = False + progress_bar.stop() + loading_dialog.grab_release() + loading_dialog.destroy() + except: + pass # Dialog might already be destroyed + + def _show_update_error(error_msg): + """Show update check error""" + cleanup_loading() + messagebox.showerror("Update Check Failed", + f"Failed to check for updates:\n{error_msg}") + + # Start the update check in a separate thread + update_thread = threading.Thread(target=check_updates_thread, daemon=True) + update_thread.start() + + # Handle dialog close + def on_dialog_close(): + self.loading_animation_active = False + cleanup_loading() + + loading_dialog.protocol("WM_DELETE_WINDOW", on_dialog_close) + + def append_log_with_api_error_detection(self, message): + """Enhanced log appending that detects and highlights API errors""" + # First append the regular log message + self.append_log(message) + + # Check for API error patterns + message_lower = message.lower() + + if "429" in message or "rate limit" in message_lower: + # Rate limit error detected + self.append_log("โš ๏ธ RATE LIMIT ERROR DETECTED (HTTP 429)") + self.append_log(" The API is throttling your requests.") + self.append_log(" Please wait before continuing or increase the delay between requests.") + self.append_log(" You can increase 'Delay between API calls' in settings.") + + elif "401" in message or "unauthorized" in message_lower: + # Authentication error + self.append_log("โŒ AUTHENTICATION ERROR (HTTP 401)") + self.append_log(" Your API key is invalid or missing.") + self.append_log(" Please check your API key in the settings.") + + elif "403" in message or "forbidden" in message_lower: + # Forbidden error + self.append_log("โŒ ACCESS FORBIDDEN ERROR (HTTP 403)") + self.append_log(" You don't have permission to access this API.") + self.append_log(" Please check your API subscription and permissions.") + + elif "400" in message or "bad request" in message_lower: + # Bad request error + self.append_log("โŒ BAD REQUEST ERROR (HTTP 400)") + self.append_log(" The API request was malformed or invalid.") + self.append_log(" This might be due to unsupported model settings.") + + elif "timeout" in message_lower: + # Timeout error + self.append_log("โฑ๏ธ TIMEOUT ERROR") + self.append_log(" The API request took too long to respond.") + self.append_log(" Consider increasing timeout settings or retrying.") + + + def create_glossary_backup(self, operation_name="manual"): + """Create a backup of the current glossary if auto-backup is enabled""" + # For manual backups, always proceed. For automatic backups, check the setting. + if operation_name != "manual" and not self.config.get('glossary_auto_backup', True): + return True + + if not self.current_glossary_data or not self.editor_file_var.get(): + return True + + try: + # Get the original glossary file path + original_path = self.editor_file_var.get() + original_dir = os.path.dirname(original_path) + original_name = os.path.basename(original_path) + + # Create backup directory + backup_dir = os.path.join(original_dir, "Backups") + + # Create directory if it doesn't exist + try: + os.makedirs(backup_dir, exist_ok=True) + except Exception as e: + self.append_log(f"โš ๏ธ Failed to create backup directory: {str(e)}") + return False + + # Generate timestamp-based backup filename + timestamp = time.strftime("%Y%m%d_%H%M%S") + backup_name = f"{os.path.splitext(original_name)[0]}_{operation_name}_{timestamp}.json" + backup_path = os.path.join(backup_dir, backup_name) + + # Try to save backup + with open(backup_path, 'w', encoding='utf-8') as f: + json.dump(self.current_glossary_data, f, ensure_ascii=False, indent=2) + + self.append_log(f"๐Ÿ’พ Backup created: {backup_name}") + + # Optional: Clean old backups if more than limit + max_backups = self.config.get('glossary_max_backups', 50) + if max_backups > 0: + self._clean_old_backups(backup_dir, original_name, max_backups) + + return True + + except Exception as e: + # Log the actual error + self.append_log(f"โš ๏ธ Backup failed: {str(e)}") + # Ask user if they want to continue anyway + return messagebox.askyesno("Backup Failed", + f"Failed to create backup: {str(e)}\n\nContinue anyway?") + + def get_current_epub_path(self): + """Get the currently selected EPUB path from various sources""" + epub_path = None + + # Try different sources in order of preference + sources = [ + # Direct selection + lambda: getattr(self, 'selected_epub_path', None), + # From config + lambda: self.config.get('last_epub_path', None) if hasattr(self, 'config') else None, + # From file path variable (if it exists) + lambda: self.epub_file_path.get() if hasattr(self, 'epub_file_path') and self.epub_file_path.get() else None, + # From current translation + lambda: getattr(self, 'current_epub_path', None), + ] + + for source in sources: + try: + path = source() + if path and os.path.exists(path): + epub_path = path + print(f"[DEBUG] Found EPUB path from source: {path}") # Debug line + break + except Exception as e: + print(f"[DEBUG] Error checking source: {e}") # Debug line + continue + + if not epub_path: + print("[DEBUG] No EPUB path found from any source") # Debug line + + return epub_path + + def _clean_old_backups(self, backup_dir, original_name, max_backups): + """Remove old backups exceeding the limit""" + try: + # Find all backups for this glossary + prefix = os.path.splitext(original_name)[0] + backups = [] + + for file in os.listdir(backup_dir): + if file.startswith(prefix) and file.endswith('.json'): + file_path = os.path.join(backup_dir, file) + backups.append((file_path, os.path.getmtime(file_path))) + + # Sort by modification time (oldest first) + backups.sort(key=lambda x: x[1]) + + # Remove oldest backups if exceeding limit + while len(backups) > max_backups: + old_backup = backups.pop(0) + os.remove(old_backup[0]) + self.append_log(f"๐Ÿ—‘๏ธ Removed old backup: {os.path.basename(old_backup[0])}") + + except Exception as e: + self.append_log(f"โš ๏ธ Error cleaning old backups: {str(e)}") + + def open_manga_translator(self): + """Open manga translator in a new window""" + if not MANGA_SUPPORT: + messagebox.showwarning("Not Available", "Manga translation modules not found.") + return + + # Always open directly - model preloading will be handled inside the manga tab + self._open_manga_translator_direct() + + def _open_manga_translator_direct(self): + """Open manga translator directly without loading screen""" + # Import PySide6 components for the manga translator + try: + from PySide6.QtWidgets import QApplication, QDialog, QWidget, QVBoxLayout, QScrollArea + from PySide6.QtCore import Qt + except ImportError: + messagebox.showerror("Missing Dependency", + "PySide6 is required for manga translation. Please install it:\npip install PySide6") + return + + # Create or get QApplication instance + app = QApplication.instance() + if not app: + # Set DPI awareness before creating QApplication + try: + QApplication.setHighDpiScaleFactorRoundingPolicy(Qt.HighDpiScaleFactorRoundingPolicy.PassThrough) + except: + pass + app = QApplication(sys.argv) + + # Create PySide6 dialog with standard window controls + dialog = QDialog() + dialog.setWindowTitle("๐ŸŽŒ Manga Panel Translator") + + # Enable maximize button and standard window controls (minimize, maximize, close) + dialog.setWindowFlags( + Qt.Window | + Qt.WindowMinimizeButtonHint | + Qt.WindowMaximizeButtonHint | + Qt.WindowCloseButtonHint + ) + + # Set icon if available + try: + icon_path = os.path.join(self.base_dir, 'Halgakos.ico') + if os.path.exists(icon_path): + from PySide6.QtGui import QIcon + dialog.setWindowIcon(QIcon(icon_path)) + except Exception: + pass + + # Size and position the dialog + # Use availableGeometry to exclude taskbar and other system UI + screen = app.primaryScreen().availableGeometry() + dialog_width = min(1400, int(screen.width() * 0.95)) # Increased from 900 to 1400 for 2-column layout + dialog_height = int(screen.height() * 0.90) # Use 90% of available screen height for safety margin + dialog.resize(dialog_width, dialog_height) + + # Center the dialog within available screen space + dialog_x = screen.x() + (screen.width() - dialog_width) // 2 + dialog_y = screen.y() + (screen.height() - dialog_height) // 2 + dialog.move(dialog_x, dialog_y) + + # Create scrollable content area + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + # Create main content widget + content_widget = QWidget() + scroll_area.setWidget(content_widget) + + # Set dialog layout + dialog_layout = QVBoxLayout(dialog) + dialog_layout.setContentsMargins(0, 0, 0, 0) + dialog_layout.addWidget(scroll_area) + + # Initialize the manga translator interface with PySide6 widget + self.manga_translator = MangaTranslationTab(content_widget, self, dialog, scroll_area) + + # Handle window close + def on_close(): + try: + if self.manga_translator: + # Stop any running translations + if hasattr(self.manga_translator, 'stop_translation'): + self.manga_translator.stop_translation() + self.manga_translator = None + dialog.close() + except Exception as e: + print(f"Error closing manga translator: {e}") + + dialog.finished.connect(on_close) + + # Show the dialog + dialog.show() + + # Keep reference to prevent garbage collection + self._manga_dialog = dialog + + + def _init_default_prompts(self): + """Initialize all default prompt templates""" + self.default_manual_glossary_prompt = """Extract character names and important terms from the following text. + +Output format: +{fields} + +Rules: +- Output ONLY CSV lines in the exact format shown above +- No headers, no extra text, no JSON +- One entry per line +- Leave gender empty for terms (just end with comma) +""" + + self.default_auto_glossary_prompt = """You are extracting a targeted glossary from a {language} novel. +Focus on identifying: +1. Character names with their honorifics +2. Important titles and ranks +3. Frequently mentioned terms (min frequency: {min_frequency}) + +Extract up to {max_names} character names and {max_titles} titles. +Prioritize names that appear with honorifics or in important contexts. +Return the glossary in a simple key-value format. + """ + + self.default_rolling_summary_system_prompt = """You are a context summarization assistant. Create concise, informative summaries that preserve key story elements for translation continuity.""" + + self.default_rolling_summary_user_prompt = """Analyze the recent translation exchanges and create a structured summary for context continuity. + +Focus on extracting and preserving: +1. **Character Information**: Names (with original forms), relationships, roles, and important character developments +2. **Plot Points**: Key events, conflicts, and story progression +3. **Locations**: Important places and settings +4. **Terminology**: Special terms, abilities, items, or concepts (with original forms) +5. **Tone & Style**: Writing style, mood, and any notable patterns +6. **Unresolved Elements**: Questions, mysteries, or ongoing situations + +Format the summary clearly with sections. Be concise but comprehensive. + +Recent translations to summarize: +{translations} + """ + + def _init_variables(self): + """Initialize all configuration variables""" + # Load saved prompts + self.manual_glossary_prompt = self.config.get('manual_glossary_prompt', self.default_manual_glossary_prompt) + self.auto_glossary_prompt = self.config.get('auto_glossary_prompt', self.default_auto_glossary_prompt) + self.rolling_summary_system_prompt = self.config.get('rolling_summary_system_prompt', self.default_rolling_summary_system_prompt) + self.rolling_summary_user_prompt = self.config.get('rolling_summary_user_prompt', self.default_rolling_summary_user_prompt) + self.append_glossary_prompt = self.config.get('append_glossary_prompt', "- Follow this reference glossary for consistent translation (Do not output any raw entries):\n") + self.translation_chunk_prompt = self.config.get('translation_chunk_prompt', self.default_translation_chunk_prompt) + self.image_chunk_prompt = self.config.get('image_chunk_prompt', self.default_image_chunk_prompt) + + self.custom_glossary_fields = self.config.get('custom_glossary_fields', []) + self.token_limit_disabled = self.config.get('token_limit_disabled', False) + self.api_key_visible = False # Default to hidden + + if 'glossary_duplicate_key_mode' not in self.config: + self.config['glossary_duplicate_key_mode'] = 'fuzzy' + # Initialize fuzzy threshold variable + if not hasattr(self, 'fuzzy_threshold_var'): + self.fuzzy_threshold_var = tk.DoubleVar(value=self.config.get('glossary_fuzzy_threshold', 0.90)) + + # Create all config variables with helper + def create_var(var_type, key, default): + return var_type(value=self.config.get(key, default)) + + # Boolean variables + bool_vars = [ + ('rolling_summary_var', 'use_rolling_summary', False), + ('translation_history_rolling_var', 'translation_history_rolling', False), + ('glossary_history_rolling_var', 'glossary_history_rolling', False), + ('translate_book_title_var', 'translate_book_title', True), + ('enable_auto_glossary_var', 'enable_auto_glossary', False), + ('append_glossary_var', 'append_glossary', False), + ('retry_truncated_var', 'retry_truncated', False), + ('retry_duplicate_var', 'retry_duplicate_bodies', False), + # NEW: QA scanning helpers + ('qa_auto_search_output_var', 'qa_auto_search_output', True), + ('scan_phase_enabled_var', 'scan_phase_enabled', False), + ('indefinite_rate_limit_retry_var', 'indefinite_rate_limit_retry', True), + # Keep existing variables intact + ('enable_image_translation_var', 'enable_image_translation', False), + ('process_webnovel_images_var', 'process_webnovel_images', True), + # REMOVED: ('comprehensive_extraction_var', 'comprehensive_extraction', False), + ('hide_image_translation_label_var', 'hide_image_translation_label', True), + ('retry_timeout_var', 'retry_timeout', True), + ('batch_translation_var', 'batch_translation', False), + ('conservative_batching_var', 'conservative_batching', True), + ('disable_epub_gallery_var', 'disable_epub_gallery', False), + # NEW: Disable automatic cover creation (affects extraction and EPUB cover page) + ('disable_automatic_cover_creation_var', 'disable_automatic_cover_creation', False), + # NEW: Translate cover.html (Skip Override) + ('translate_cover_html_var', 'translate_cover_html', False), + ('disable_zero_detection_var', 'disable_zero_detection', True), + ('use_header_as_output_var', 'use_header_as_output', False), + ('emergency_restore_var', 'emergency_paragraph_restore', False), + ('contextual_var', 'contextual', False), + ('REMOVE_AI_ARTIFACTS_var', 'REMOVE_AI_ARTIFACTS', False), + ('enable_watermark_removal_var', 'enable_watermark_removal', True), + ('save_cleaned_images_var', 'save_cleaned_images', False), + ('advanced_watermark_removal_var', 'advanced_watermark_removal', False), + ('enable_decimal_chapters_var', 'enable_decimal_chapters', False), + ('disable_gemini_safety_var', 'disable_gemini_safety', False), + ('single_api_image_chunks_var', 'single_api_image_chunks', False), + + ] + + for var_name, key, default in bool_vars: + setattr(self, var_name, create_var(tk.BooleanVar, key, default)) + + # String variables + str_vars = [ + ('summary_role_var', 'summary_role', 'user'), + ('rolling_summary_exchanges_var', 'rolling_summary_exchanges', '5'), + ('rolling_summary_mode_var', 'rolling_summary_mode', 'append'), + # New: how many summaries to retain in append mode + ('rolling_summary_max_entries_var', 'rolling_summary_max_entries', '5'), + ('reinforcement_freq_var', 'reinforcement_frequency', '10'), + ('max_retry_tokens_var', 'max_retry_tokens', '16384'), + ('duplicate_lookback_var', 'duplicate_lookback_chapters', '5'), + ('glossary_min_frequency_var', 'glossary_min_frequency', '2'), + ('glossary_max_names_var', 'glossary_max_names', '50'), + ('glossary_max_titles_var', 'glossary_max_titles', '30'), + ('glossary_batch_size_var', 'glossary_batch_size', '50'), + ('webnovel_min_height_var', 'webnovel_min_height', '1000'), + ('max_images_per_chapter_var', 'max_images_per_chapter', '1'), + ('image_chunk_height_var', 'image_chunk_height', '1500'), + ('chunk_timeout_var', 'chunk_timeout', '900'), + ('batch_size_var', 'batch_size', '3'), + ('chapter_number_offset_var', 'chapter_number_offset', '0'), + ('compression_factor_var', 'compression_factor', '1.0'), + # NEW: scanning phase mode (quick-scan/aggressive/ai-hunter/custom) + ('scan_phase_mode_var', 'scan_phase_mode', 'quick-scan') + ] + + for var_name, key, default in str_vars: + setattr(self, var_name, create_var(tk.StringVar, key, str(default))) + + # NEW: Initialize extraction mode variable + self.extraction_mode_var = tk.StringVar( + value=self.config.get('extraction_mode', 'smart') + ) + + self.book_title_prompt = self.config.get('book_title_prompt', + "Translate this book title to English while retaining any acronyms:") + # Initialize book title system prompt + if 'book_title_system_prompt' not in self.config: + self.config['book_title_system_prompt'] = "You are a translator. Respond with only the translated text, nothing else. Do not add any explanation or additional content." + + # Profiles + self.prompt_profiles = self.config.get('prompt_profiles', self.default_prompts.copy()) + active = self.config.get('active_profile', next(iter(self.prompt_profiles))) + self.profile_var = tk.StringVar(value=active) + self.lang_var = self.profile_var + + # Detection mode + self.duplicate_detection_mode_var = tk.StringVar(value=self.config.get('duplicate_detection_mode', 'basic')) + + def _setup_gui(self): + """Initialize all GUI components""" + self.frame = tb.Frame(self.master, padding=10) + self.frame.pack(fill=tk.BOTH, expand=True) + + # Configure grid + for i in range(5): + self.frame.grid_columnconfigure(i, weight=1 if i in [1, 3] else 0) + for r in range(12): + self.frame.grid_rowconfigure(r, weight=1 if r in [9, 10] else 0, minsize=200 if r == 9 else 150 if r == 10 else 0) + + # Create UI elements using helper methods + self.create_file_section() + self._create_model_section() + self._create_profile_section() + self._create_settings_section() + self._create_api_section() + self._create_prompt_section() + self._create_log_section() + self._make_bottom_toolbar() + + # Apply token limit state + if self.token_limit_disabled: + self.token_limit_entry.config(state=tk.DISABLED) + self.toggle_token_btn.config(text="Enable Input Token Limit", bootstyle="success-outline") + + self.on_profile_select() + self.append_log("๐Ÿš€ Glossarion v5.0.4 - Ready to use!") + self.append_log("๐Ÿ’ก Click any function button to load modules automatically") + + # Restore last selected input files if available + try: + last_files = self.config.get('last_input_files', []) if hasattr(self, 'config') else [] + if isinstance(last_files, list) and last_files: + existing = [p for p in last_files if isinstance(p, str) and os.path.exists(p)] + if existing: + # Populate the entry and internal state using shared handler + self._handle_file_selection(existing) + self.append_log(f"๐Ÿ“ Restored last selection: {len(existing)} file(s)") + except Exception: + pass + + def create_file_section(self): + """Create file selection section with multi-file support""" + # Initialize file selection variables + self.selected_files = [] + self.current_file_index = 0 + + # File label + tb.Label(self.frame, text="Input File(s):").grid(row=0, column=0, sticky=tk.W, padx=5, pady=5) + + # File entry + self.entry_epub = tb.Entry(self.frame, width=50) + self.entry_epub.grid(row=0, column=1, columnspan=3, sticky=tk.EW, padx=5, pady=5) + self.entry_epub.insert(0, "No file selected") + + # Create browse menu + self.browse_menu = tk.Menu(self.master, tearoff=0, font=('Arial', 12)) + self.browse_menu.add_command(label="๐Ÿ“„ Select Files", command=self.browse_files) + self.browse_menu.add_command(label="๐Ÿ“ Select Folder", command=self.browse_folder) + self.browse_menu.add_separator() + self.browse_menu.add_command(label="๐Ÿ—‘๏ธ Clear Selection", command=self.clear_file_selection) + + # Create browse menu button + self.btn_browse_menu = tb.Menubutton( + self.frame, + text="Browse โ–ผ", + menu=self.browse_menu, + width=12, + bootstyle="primary" + ) + self.btn_browse_menu.grid(row=0, column=4, sticky=tk.EW, padx=5, pady=5) + + # File selection status label (shows file count and details) + self.file_status_label = tb.Label( + self.frame, + text="", + font=('Arial', 9), + bootstyle="info" + ) + self.file_status_label.grid(row=1, column=1, columnspan=3, sticky=tk.W, padx=5, pady=(0, 5)) + + # Google Cloud Credentials button + self.gcloud_button = tb.Button( + self.frame, + text="GCloud Creds", + command=self.select_google_credentials, + width=12, + state=tk.DISABLED, + bootstyle="secondary" + ) + self.gcloud_button.grid(row=2, column=4, sticky=tk.EW, padx=5, pady=5) + + # Vertex AI Location text entry + self.vertex_location_var = tk.StringVar(value=self.config.get('vertex_ai_location', 'us-east5')) + self.vertex_location_entry = tb.Entry( + self.frame, + textvariable=self.vertex_location_var, + width=12 + ) + self.vertex_location_entry.grid(row=3, column=4, sticky=tk.EW, padx=5, pady=5) + + # Hide by default + self.vertex_location_entry.grid_remove() + + # Status label for credentials + self.gcloud_status_label = tb.Label( + self.frame, + text="", + font=('Arial', 9), + bootstyle="secondary" + ) + self.gcloud_status_label.grid(row=2, column=1, columnspan=3, sticky=tk.W, padx=5, pady=(0, 5)) + + # Optional: Add checkbox for enhanced functionality + options_frame = tb.Frame(self.frame) + options_frame.grid(row=1, column=4, columnspan=1, sticky=tk.EW, padx=5, pady=5) + + # Deep scan option for folders + self.deep_scan_var = tk.BooleanVar(value=False) + self.deep_scan_check = tb.Checkbutton( + options_frame, + text="include subfolders", + variable=self.deep_scan_var, + bootstyle="round-toggle" + ) + self.deep_scan_check.pack(side='left') + + def select_google_credentials(self): + """Select Google Cloud credentials JSON file""" + filename = filedialog.askopenfilename( + title="Select Google Cloud Credentials JSON", + filetypes=[("JSON files", "*.json"), ("All files", "*.*")] + ) + + if filename: + try: + # Validate it's a valid Google Cloud credentials file + with open(filename, 'r') as f: + creds_data = json.load(f) + if 'type' in creds_data and 'project_id' in creds_data: + # Save to config + self.config['google_cloud_credentials'] = filename + self.save_config() + + # Update UI + self.gcloud_status_label.config( + text=f"โœ“ Credentials: {os.path.basename(filename)} (Project: {creds_data.get('project_id', 'Unknown')})", + foreground='green' + ) + + # Set environment variable for child processes + os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = filename + + self.append_log(f"Google Cloud credentials loaded: {os.path.basename(filename)}") + else: + messagebox.showerror( + "Error", + "Invalid Google Cloud credentials file. Please select a valid service account JSON file." + ) + except Exception as e: + messagebox.showerror("Error", f"Failed to load credentials: {str(e)}") + + def on_model_change(self, event=None): + """Handle model selection change from dropdown or manual input""" + # Get the current model value (from dropdown or manually typed) + model = self.model_var.get() + + # Show Google Cloud Credentials button for Vertex AI models AND Google Translate + needs_google_creds = False + + if '@' in model or model.startswith('vertex/') or model.startswith('vertex_ai/'): + needs_google_creds = True + self.vertex_location_entry.grid() # Show location selector for Vertex + elif model == 'google-translate': + needs_google_creds = True + self.vertex_location_entry.grid_remove() # Hide location selector for Google Translate + + if needs_google_creds: + self.gcloud_button.config(state=tk.NORMAL) + + # Check if credentials are already loaded + if self.config.get('google_cloud_credentials'): + creds_path = self.config['google_cloud_credentials'] + if os.path.exists(creds_path): + try: + with open(creds_path, 'r') as f: + creds_data = json.load(f) + project_id = creds_data.get('project_id', 'Unknown') + + # Different status messages for different services + if model == 'google-translate': + status_text = f"โœ“ Google Translate ready (Project: {project_id})" + else: + status_text = f"โœ“ Credentials: {os.path.basename(creds_path)} (Project: {project_id})" + + self.gcloud_status_label.config( + text=status_text, + foreground='green' + ) + except: + self.gcloud_status_label.config( + text="โš  Error reading credentials", + foreground='red' + ) + else: + self.gcloud_status_label.config( + text="โš  Credentials file not found", + foreground='red' + ) + else: + # Different prompts for different services + if model == 'google-translate': + warning_text = "โš  Google Cloud credentials needed for Translate API" + else: + warning_text = "โš  No Google Cloud credentials selected" + + self.gcloud_status_label.config( + text=warning_text, + foreground='orange' + ) + else: + # Not a Google service, hide everything + self.gcloud_button.config(state=tk.DISABLED) + self.vertex_location_entry.grid_remove() + self.gcloud_status_label.config(text="") + + # Also add this to bind manual typing events to the combobox + def setup_model_combobox_bindings(self): + """Setup bindings for manual model input in combobox with autocomplete""" + # Bind to key release events for live filtering and autofill + self.model_combo.bind('<KeyRelease>', self._on_model_combo_keyrelease) + # Commit best match on Enter + self.model_combo.bind('<Return>', self._commit_model_autocomplete) + # Also bind to FocusOut to catch when user clicks away after typing + self.model_combo.bind('<FocusOut>', self.on_model_change) + # Keep the existing binding for dropdown selection + self.model_combo.bind('<<ComboboxSelected>>', self.on_model_change) + + def _on_model_combo_keyrelease(self, event=None): + """Combobox type-to-search without filtering values. + - Keeps the full model list intact; does not replace the Combobox values. + - Finds the best match and, if the dropdown is open, scrolls/highlights to it. + - Does NOT auto-fill text on deletion or mid-string edits (and by default avoids autofill). + - Calls on_model_change only when the entry text actually changes. + """ + try: + combo = self.model_combo + typed = combo.get() + prev = getattr(self, '_model_prev_text', '') + keysym = (getattr(event, 'keysym', '') or '').lower() + + # Navigation/commit keys: don't interfere; Combobox handles selection events + if keysym in {'up', 'down', 'left', 'right', 'return', 'escape', 'tab'}: + return + + # Ensure we have the full source list + if not hasattr(self, '_model_all_values') or not self._model_all_values: + try: + self._model_all_values = list(combo['values']) + except Exception: + self._model_all_values = [] + + source = self._model_all_values + + # Compute match set without altering combobox values + first_match = None + if typed: + lowered = typed.lower() + pref = [v for v in source if v.lower().startswith(lowered)] + cont = [v for v in source if lowered in v.lower()] if not pref else [] + if pref: + first_match = pref[0] + elif cont: + first_match = cont[0] + + # Decide whether to perform any autofill: default to no text autofill + grew = len(typed) > len(prev) and typed.startswith(prev) + is_deletion = keysym in {'backspace', 'delete'} or len(typed) < len(prev) + try: + at_end = combo.index(tk.INSERT) == len(typed) + except Exception: + at_end = True + try: + has_selection = combo.selection_present() + except Exception: + has_selection = False + + # Gentle autofill only when appending at the end (not on delete or mid-string edits) + do_autofill_text = first_match is not None and grew and at_end and not has_selection and not is_deletion + + if do_autofill_text: + # Only complete if it's a true prefix match to avoid surprising jumps + if first_match.lower().startswith(typed.lower()) and first_match != typed: + combo.set(first_match) + try: + combo.icursor(len(typed)) + combo.selection_range(len(typed), len(first_match)) + except Exception: + pass + + # If we have a match and the dropdown is open, scroll/highlight it (values intact) + if first_match: + self._scroll_model_list_to_value(first_match) + + # Remember current text for next event + self._model_prev_text = typed + + # Only trigger change logic when the text actually changed + if typed != prev: + self.on_model_change() + except Exception as e: + try: + logging.debug(f"Model combobox autocomplete error: {e}") + except Exception: + pass + + def _commit_model_autocomplete(self, event=None): + """On Enter, commit to the best matching model (prefix preferred, then contains).""" + try: + combo = self.model_combo + typed = combo.get() + source = getattr(self, '_model_all_values', []) or list(combo['values']) + match = None + if typed: + lowered = typed.lower() + pref = [v for v in source if v.lower().startswith(lowered)] + cont = [v for v in source if lowered in v.lower()] if not pref else [] + match = pref[0] if pref else (cont[0] if cont else None) + if match and match != typed: + combo.set(match) + # Move cursor to end and clear any selection + try: + combo.icursor('end') + try: + combo.selection_clear() + except Exception: + combo.selection_range(0, 0) + except Exception: + pass + # Update prev text and trigger change + self._model_prev_text = combo.get() + self.on_model_change() + except Exception as e: + try: + logging.debug(f"Model combobox enter-commit error: {e}") + except Exception: + pass + return "break" + + def _ensure_model_dropdown_open(self): + """Open the combobox dropdown if it isn't already visible.""" + try: + tkobj = self.model_combo.tk + popdown = tkobj.eval(f'ttk::combobox::PopdownWindow {self.model_combo._w}') + viewable = int(tkobj.eval(f'winfo viewable {popdown}')) + if not viewable: + # Prefer internal Post proc + tkobj.eval(f'ttk::combobox::Post {self.model_combo._w}') + except Exception: + # Fallback: try keyboard event to open + try: + self.model_combo.event_generate('<Down>') + except Exception: + pass + + def _scroll_model_list_to_value(self, value: str): + """If the combobox dropdown is open, scroll to and highlight the given value. + Uses Tk internals for ttk::combobox to access the popdown listbox. + Safe no-op if anything fails. + """ + try: + values = getattr(self, '_model_all_values', []) or list(self.model_combo['values']) + if value not in values: + return + index = values.index(value) + # Resolve the internal popdown listbox for this combobox + popdown = self.model_combo.tk.eval(f'ttk::combobox::PopdownWindow {self.model_combo._w}') + listbox = f'{popdown}.f.l' + tkobj = self.model_combo.tk + # Scroll and highlight the item + tkobj.call(listbox, 'see', index) + tkobj.call(listbox, 'selection', 'clear', 0, 'end') + tkobj.call(listbox, 'selection', 'set', index) + tkobj.call(listbox, 'activate', index) + except Exception: + # Dropdown may be closed or internals unavailable; ignore + pass + def _create_model_section(self): + """Create model selection section""" + tb.Label(self.frame, text="Model:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=5) + default_model = self.config.get('model', 'gemini-2.0-flash') + self.model_var = tk.StringVar(value=default_model) + models = get_model_options() + self._model_all_values = models + self.model_combo = tb.Combobox(self.frame, textvariable=self.model_var, values=models, state="normal") + self.model_combo.grid(row=1, column=1, columnspan=2, sticky=tk.EW, padx=5, pady=5) + + # Track previous text to make autocomplete less aggressive + self._model_prev_text = self.model_var.get() + + self.model_combo.bind('<<ComboboxSelected>>', self.on_model_change) + self.setup_model_combobox_bindings() + self.model_var.trace('w', self._check_poe_model) + self.on_model_change() + + def _create_profile_section(self): + """Create profile/profile section""" + tb.Label(self.frame, text="Profile:").grid(row=2, column=0, sticky=tk.W, padx=5, pady=5) + self.profile_menu = tb.Combobox(self.frame, textvariable=self.profile_var, + values=list(self.prompt_profiles.keys()), state="normal") + self.profile_menu.grid(row=2, column=1, sticky=tk.EW, padx=5, pady=5) + self.profile_menu.bind("<<ComboboxSelected>>", self.on_profile_select) + self.profile_menu.bind("<Return>", self.on_profile_select) + tb.Button(self.frame, text="Save Profile", command=self.save_profile, width=14).grid(row=2, column=2, sticky=tk.W, padx=5, pady=5) + tb.Button(self.frame, text="Delete Profile", command=self.delete_profile, width=14).grid(row=2, column=3, sticky=tk.W, padx=5, pady=5) + + def _create_settings_section(self): + """Create all settings controls""" + # Threading delay (with extra spacing at top) + tb.Label(self.frame, text="Threading delay (s):").grid(row=3, column=0, sticky=tk.W, padx=5, pady=(15, 5)) # (top, bottom) + self.thread_delay_entry = tb.Entry(self.frame, textvariable=self.thread_delay_var, width=8) + self.thread_delay_entry.grid(row=3, column=1, sticky=tk.W, padx=5, pady=(15, 5)) # Match the label padding + + # API delay (left side) + tb.Label(self.frame, text="API call delay (s):").grid(row=4, column=0, sticky=tk.W, padx=5, pady=5) + self.delay_entry = tb.Entry(self.frame, width=8) + self.delay_entry.insert(0, str(self.config.get('delay', 2))) + self.delay_entry.grid(row=4, column=1, sticky=tk.W, padx=5, pady=5) + + # Optional help text (spanning both columns) + tb.Label(self.frame, text="(0 = simultaneous)", + font=('TkDefaultFont', 8), foreground='gray').grid(row=3, column=2, sticky=tk.W, padx=5, pady=(15, 5)) + + # Chapter Range + tb.Label(self.frame, text="Chapter range (e.g., 5-10):").grid(row=5, column=0, sticky=tk.W, padx=5, pady=5) + self.chapter_range_entry = tb.Entry(self.frame, width=12) + self.chapter_range_entry.insert(0, self.config.get('chapter_range', '')) + self.chapter_range_entry.grid(row=5, column=1, sticky=tk.W, padx=5, pady=5) + + # Token limit + tb.Label(self.frame, text="Input Token limit:").grid(row=6, column=0, sticky=tk.W, padx=5, pady=5) + self.token_limit_entry = tb.Entry(self.frame, width=8) + self.token_limit_entry.insert(0, str(self.config.get('token_limit', 200000))) + self.token_limit_entry.grid(row=6, column=1, sticky=tk.W, padx=5, pady=5) + + self.toggle_token_btn = tb.Button(self.frame, text="Disable Input Token Limit", + command=self.toggle_token_limit, bootstyle="danger-outline", width=21) + self.toggle_token_btn.grid(row=7, column=1, sticky=tk.W, padx=5, pady=5) + + # Contextual Translation (right side, row 3) - with extra padding on top + tb.Checkbutton(self.frame, text="Contextual Translation", variable=self.contextual_var, + command=self._on_contextual_toggle).grid( + row=3, column=2, columnspan=2, sticky=tk.W, padx=5, pady=(25, 5)) # Added extra top padding + + # Translation History Limit (row 4) + self.trans_history_label = tb.Label(self.frame, text="Translation History Limit:") + self.trans_history_label.grid(row=4, column=2, sticky=tk.W, padx=5, pady=5) + self.trans_history = tb.Entry(self.frame, width=6) + self.trans_history.insert(0, str(self.config.get('translation_history_limit', 2))) + self.trans_history.grid(row=4, column=3, sticky=tk.W, padx=5, pady=5) + + # Rolling History (row 5) + self.rolling_checkbox = tb.Checkbutton(self.frame, text="Rolling History Window", variable=self.translation_history_rolling_var, + bootstyle="round-toggle") + self.rolling_checkbox.grid(row=5, column=2, sticky=tk.W, padx=5, pady=5) + self.rolling_history_desc = tk.Label(self.frame, text="(Keep recent history instead of purging)", + font=('TkDefaultFont', 11), fg='gray') + self.rolling_history_desc.grid(row=5, column=3, sticky=tk.W, padx=5, pady=5) + + # Temperature (row 6) + tb.Label(self.frame, text="Temperature:").grid(row=6, column=2, sticky=tk.W, padx=5, pady=5) + self.trans_temp = tb.Entry(self.frame, width=6) + self.trans_temp.insert(0, str(self.config.get('translation_temperature', 0.3))) + self.trans_temp.grid(row=6, column=3, sticky=tk.W, padx=5, pady=5) + + # Batch Translation (row 7) + self.batch_checkbox = tb.Checkbutton(self.frame, text="Batch Translation", variable=self.batch_translation_var, + bootstyle="round-toggle") + self.batch_checkbox.grid(row=7, column=2, sticky=tk.W, padx=5, pady=5) + self.batch_size_entry = tb.Entry(self.frame, width=6, textvariable=self.batch_size_var) + self.batch_size_entry.grid(row=7, column=3, sticky=tk.W, padx=5, pady=5) + + # Set batch entry state + self.batch_size_entry.config(state=tk.NORMAL if self.batch_translation_var.get() else tk.DISABLED) + self.batch_translation_var.trace('w', lambda *args: self.batch_size_entry.config( + state=tk.NORMAL if self.batch_translation_var.get() else tk.DISABLED)) + + # Hidden entries for compatibility + self.title_trim = tb.Entry(self.frame, width=6) + self.title_trim.insert(0, str(self.config.get('title_trim_count', 1))) + self.group_trim = tb.Entry(self.frame, width=6) + self.group_trim.insert(0, str(self.config.get('group_affiliation_trim_count', 1))) + self.traits_trim = tb.Entry(self.frame, width=6) + self.traits_trim.insert(0, str(self.config.get('traits_trim_count', 1))) + self.refer_trim = tb.Entry(self.frame, width=6) + self.refer_trim.insert(0, str(self.config.get('refer_trim_count', 1))) + self.loc_trim = tb.Entry(self.frame, width=6) + self.loc_trim.insert(0, str(self.config.get('locations_trim_count', 1))) + + # Set initial state based on contextual translation + self._on_contextual_toggle() + + def _on_contextual_toggle(self): + """Handle contextual translation toggle - enable/disable related controls""" + is_contextual = self.contextual_var.get() + + # Disable controls when contextual is ON, enable when OFF + state = tk.NORMAL if is_contextual else tk.DISABLED + + # Disable/enable translation history limit entry and gray out label + self.trans_history.config(state=state) + self.trans_history_label.config(foreground='white' if is_contextual else 'gray') + + # Disable/enable rolling history checkbox and gray out description + self.rolling_checkbox.config(state=state) + self.rolling_history_desc.config(foreground='gray' if is_contextual else '#404040') + + def _create_api_section(self): + """Create API key section""" + self.api_key_label = tb.Label(self.frame, text="OpenAI/Gemini/... API Key:") + self.api_key_label.grid(row=8, column=0, sticky=tk.W, padx=5, pady=5) + self.api_key_entry = tb.Entry(self.frame, show='*') + self.api_key_entry.grid(row=8, column=1, columnspan=3, sticky=tk.EW, padx=5, pady=5) + initial_key = self.config.get('api_key', '') + if initial_key: + self.api_key_entry.insert(0, initial_key) + tb.Button(self.frame, text="Show", command=self.toggle_api_visibility, width=12).grid(row=8, column=4, sticky=tk.EW, padx=5, pady=5) + + # Other Settings button + tb.Button(self.frame, text="โš™๏ธ Other Setting", command=self.open_other_settings, + bootstyle="info-outline", width=15).grid(row=7, column=4, sticky=tk.EW, padx=5, pady=5) + + # Remove AI Artifacts + tb.Checkbutton(self.frame, text="Remove AI Artifacts", variable=self.REMOVE_AI_ARTIFACTS_var, + bootstyle="round-toggle").grid(row=7, column=0, columnspan=5, sticky=tk.W, padx=5, pady=(0,5)) + + def _create_prompt_section(self): + """Create system prompt section with UIHelper""" + tb.Label(self.frame, text="System Prompt:").grid(row=9, column=0, sticky=tk.NW, padx=5, pady=5) + + # Use UIHelper to create text widget with undo/redo + self.prompt_text = self.ui.setup_scrollable_text( + self.frame, + height=5, + width=60, + wrap='word' + ) + self.prompt_text.grid(row=9, column=1, columnspan=3, sticky=tk.NSEW, padx=5, pady=5) + + # Output Token Limit button + self.output_btn = tb.Button(self.frame, text=f"Output Token Limit: {self.max_output_tokens}", + command=self.prompt_custom_token_limit, bootstyle="info", width=22) + self.output_btn.grid(row=9, column=0, sticky=tk.W, padx=5, pady=5) + + # Run Translation button + self.run_button = tb.Button(self.frame, text="Run Translation", command=self.run_translation_thread, + bootstyle="success", width=14) + self.run_button.grid(row=9, column=4, sticky=tk.N+tk.S+tk.EW, padx=5, pady=5) + self.master.update_idletasks() + self.run_base_w = self.run_button.winfo_width() + self.run_base_h = self.run_button.winfo_height() + + # Setup resize handler + self._resize_handler = self.ui.create_button_resize_handler( + self.run_button, + self.run_base_w, + self.run_base_h, + self.master, + BASE_WIDTH, + BASE_HEIGHT + ) + + def _create_log_section(self): + """Create log text area with UIHelper""" + self.log_text = scrolledtext.ScrolledText(self.frame, wrap=tk.WORD) + self.log_text.grid(row=10, column=0, columnspan=5, sticky=tk.NSEW, padx=5, pady=5) + + # Use UIHelper to block editing + self.ui.block_text_editing(self.log_text) + + # Setup context menu + self.log_text.bind("<Button-3>", self._show_context_menu) + if sys.platform == "darwin": + self.log_text.bind("<Button-2>", self._show_context_menu) + + def _check_poe_model(self, *args): + """Automatically show POE helper when POE model is selected""" + model = self.model_var.get().lower() + + # Check if POE model is selected + if model.startswith('poe/'): + current_key = self.api_key_entry.get().strip() + + # Only show helper if no valid POE cookie is set + if not current_key.startswith('p-b:'): + # Use a flag to prevent showing multiple times in same session + if not getattr(self, '_poe_helper_shown', False): + self._poe_helper_shown = True + # Change self.root to self.master + self.master.after(100, self._show_poe_setup_dialog) + else: + # Reset flag when switching away from POE + self._poe_helper_shown = False + + def _show_poe_setup_dialog(self): + """Show POE cookie setup dialog""" + # Create dialog using WindowManager + dialog, scrollable_frame, canvas = self.wm.setup_scrollable( + self.master, + "POE Authentication Required", + width=650, + height=450, + max_width_ratio=0.8, + max_height_ratio=0.85 + ) + + # Header + header_frame = tk.Frame(scrollable_frame) + header_frame.pack(fill='x', padx=20, pady=(20, 10)) + + tk.Label(header_frame, text="POE Cookie Authentication", + font=('TkDefaultFont', 12, 'bold')).pack() + + # Important notice + notice_frame = tk.Frame(scrollable_frame) + notice_frame.pack(fill='x', padx=20, pady=(0, 20)) + + tk.Label(notice_frame, + text="โš ๏ธ POE uses HttpOnly cookies that cannot be accessed by JavaScript", + foreground='red', font=('TkDefaultFont', 10, 'bold')).pack() + + tk.Label(notice_frame, + text="You must manually copy the cookie from Developer Tools", + foreground='gray').pack() + + # Instructions + self._create_poe_manual_instructions(scrollable_frame) + + # Button + button_frame = tk.Frame(scrollable_frame) + button_frame.pack(fill='x', padx=20, pady=(10, 20)) + + def close_dialog(): + dialog.destroy() + # Check if user added a cookie + current_key = self.api_key_entry.get().strip() + if model := self.model_var.get().lower(): + if model.startswith('poe/') and not current_key.startswith('p-b:'): + self.append_log("โš ๏ธ POE models require cookie authentication. Please add your p-b cookie to the API key field.") + + tb.Button(button_frame, text="Close", command=close_dialog, + bootstyle="secondary").pack() + + # Auto-resize and show + self.wm.auto_resize_dialog(dialog, canvas) + + def _create_poe_manual_instructions(self, parent): + """Create manual instructions for getting POE cookie""" + frame = tk.LabelFrame(parent, text="How to Get Your POE Cookie") + frame.pack(fill='both', expand=True, padx=20, pady=10) + + # Step-by-step with visual formatting + steps = [ + ("1.", "Go to poe.com and LOG IN to your account", None), + ("2.", "Press F12 to open Developer Tools", None), + ("3.", "Navigate to:", None), + ("", "โ€ข Chrome/Edge: Application โ†’ Cookies โ†’ https://poe.com", "indent"), + ("", "โ€ข Firefox: Storage โ†’ Cookies โ†’ https://poe.com", "indent"), + ("", "โ€ข Safari: Storage โ†’ Cookies โ†’ poe.com", "indent"), + ("4.", "Find the cookie named 'p-b'", None), + ("5.", "Double-click its Value to select it", None), + ("6.", "Copy the value (Ctrl+C or right-click โ†’ Copy)", None), + ("7.", "In Glossarion's API key field, type: p-b:", None), + ("8.", "Paste the cookie value after p-b:", None) + ] + + for num, text, style in steps: + step_frame = tk.Frame(frame) + step_frame.pack(anchor='w', padx=20, pady=2) + + if style == "indent": + tk.Label(step_frame, text=" ").pack(side='left') + + if num: + tk.Label(step_frame, text=num, font=('TkDefaultFont', 10, 'bold'), + width=3).pack(side='left') + + tk.Label(step_frame, text=text).pack(side='left') + + # Example + example_frame = tk.LabelFrame(parent, text="Example API Key Format") + example_frame.pack(fill='x', padx=20, pady=(10, 0)) + + example_entry = tk.Entry(example_frame, font=('Consolas', 11)) + example_entry.pack(padx=10, pady=10, fill='x') + example_entry.insert(0, "p-b:RyP5ORQXFO8qXbiTBKD2vA%3D%3D") + example_entry.config(state='readonly') + + # Additional info + info_frame = tk.Frame(parent) + info_frame.pack(fill='x', padx=20, pady=(10, 0)) + + info_text = """Note: The cookie value is usually a long string ending with %3D%3D + If you see multiple p-b cookies, use the one with the longest value.""" + + tk.Label(info_frame, text=info_text, foreground='gray', + justify='left').pack(anchor='w') + + def open_async_processing(self): + """Open the async processing dialog""" + # Check if translation is running + if hasattr(self, 'translation_thread') and self.translation_thread and self.translation_thread.is_alive(): + self.append_log("โš ๏ธ Cannot open async processing while translation is in progress.") + messagebox.showwarning("Process Running", "Please wait for the current translation to complete.") + return + + # Check if glossary extraction is running + if hasattr(self, 'glossary_thread') and self.glossary_thread and self.glossary_thread.is_alive(): + self.append_log("โš ๏ธ Cannot open async processing while glossary extraction is in progress.") + messagebox.showwarning("Process Running", "Please wait for glossary extraction to complete.") + return + + # Check if file is selected + if not hasattr(self, 'file_path') or not self.file_path: + self.append_log("โš ๏ธ Please select a file before opening async processing.") + messagebox.showwarning("No File Selected", "Please select an EPUB or TXT file first.") + return + + try: + # Lazy import the async processor + if not hasattr(self, '_async_processor_imported'): + self.append_log("Loading async processing module...") + from async_api_processor import show_async_processing_dialog + self._async_processor_imported = True + self._show_async_processing_dialog = show_async_processing_dialog + + # Show the dialog + self.append_log("Opening async processing dialog...") + self._show_async_processing_dialog(self.master, self) + + except ImportError as e: + self.append_log(f"โŒ Failed to load async processing module: {e}") + messagebox.showerror( + "Module Not Found", + "The async processing module could not be loaded.\n" + "Please ensure async_api_processor.py is in the same directory." + ) + except Exception as e: + self.append_log(f"โŒ Error opening async processing: {e}") + messagebox.showerror("Error", f"Failed to open async processing: {str(e)}") + + def _lazy_load_modules(self, splash_callback=None): + """Load heavy modules only when needed - Enhanced with thread safety, retry logic, and progress tracking""" + # Quick return if already loaded (unchanged for compatibility) + if self._modules_loaded: + return True + + # Enhanced thread safety with timeout protection + if self._modules_loading: + timeout_start = time.time() + timeout_duration = 30.0 # 30 second timeout to prevent infinite waiting + + while self._modules_loading and not self._modules_loaded: + # Check for timeout to prevent infinite loops + if time.time() - timeout_start > timeout_duration: + self.append_log("โš ๏ธ Module loading timeout - resetting loading state") + self._modules_loading = False + break + time.sleep(0.1) + return self._modules_loaded + + # Set loading flag with enhanced error handling + self._modules_loading = True + loading_start_time = time.time() + + try: + if splash_callback: + splash_callback("Loading translation modules...") + + # Initialize global variables to None FIRST to avoid NameError + global translation_main, translation_stop_flag, translation_stop_check + global glossary_main, glossary_stop_flag, glossary_stop_check + global fallback_compile_epub, scan_html_folder + + # Set all to None initially in case imports fail + translation_main = None + translation_stop_flag = None + translation_stop_check = None + glossary_main = None + glossary_stop_flag = None + glossary_stop_check = None + fallback_compile_epub = None + scan_html_folder = None + + # Enhanced module configuration with validation and retry info + modules = [ + { + 'name': 'TransateKRtoEN', + 'display_name': 'translation engine', + 'imports': ['main', 'set_stop_flag', 'is_stop_requested'], + 'global_vars': ['translation_main', 'translation_stop_flag', 'translation_stop_check'], + 'critical': True, + 'retry_count': 0, + 'max_retries': 2 + }, + { + 'name': 'extract_glossary_from_epub', + 'display_name': 'glossary extractor', + 'imports': ['main', 'set_stop_flag', 'is_stop_requested'], + 'global_vars': ['glossary_main', 'glossary_stop_flag', 'glossary_stop_check'], + 'critical': True, + 'retry_count': 0, + 'max_retries': 2 + }, + { + 'name': 'epub_converter', + 'display_name': 'EPUB converter', + 'imports': ['fallback_compile_epub'], + 'global_vars': ['fallback_compile_epub'], + 'critical': False, + 'retry_count': 0, + 'max_retries': 1 + }, + { + 'name': 'scan_html_folder', + 'display_name': 'QA scanner', + 'imports': ['scan_html_folder'], + 'global_vars': ['scan_html_folder'], + 'critical': False, + 'retry_count': 0, + 'max_retries': 1 + } + ] + + success_count = 0 + total_modules = len(modules) + failed_modules = [] + + # Enhanced module loading with progress tracking and retry logic + for i, module_info in enumerate(modules): + module_name = module_info['name'] + display_name = module_info['display_name'] + max_retries = module_info['max_retries'] + + # Progress callback with detailed information + if splash_callback: + progress_percent = int((i / total_modules) * 100) + splash_callback(f"Loading {display_name}... ({progress_percent}%)") + + # Retry logic for robust loading + loaded_successfully = False + + for retry_attempt in range(max_retries + 1): + try: + if retry_attempt > 0: + # Add small delay between retries + time.sleep(0.2) + if splash_callback: + splash_callback(f"Retrying {display_name}... (attempt {retry_attempt + 1})") + + # Enhanced import logic with specific error handling + if module_name == 'TransateKRtoEN': + # Validate the module before importing critical functions + import TransateKRtoEN + # Verify the module has required functions + if hasattr(TransateKRtoEN, 'main') and hasattr(TransateKRtoEN, 'set_stop_flag'): + translation_main = TransateKRtoEN.main + translation_stop_flag = TransateKRtoEN.set_stop_flag + translation_stop_check = TransateKRtoEN.is_stop_requested if hasattr(TransateKRtoEN, 'is_stop_requested') else None + else: + raise ImportError("TransateKRtoEN module missing required functions") + + elif module_name == 'extract_glossary_from_epub': + # Validate the module before importing critical functions + import extract_glossary_from_epub + if hasattr(extract_glossary_from_epub, 'main') and hasattr(extract_glossary_from_epub, 'set_stop_flag'): + glossary_main = extract_glossary_from_epub.main + glossary_stop_flag = extract_glossary_from_epub.set_stop_flag + glossary_stop_check = extract_glossary_from_epub.is_stop_requested if hasattr(extract_glossary_from_epub, 'is_stop_requested') else None + else: + raise ImportError("extract_glossary_from_epub module missing required functions") + + elif module_name == 'epub_converter': + # Validate the module before importing + import epub_converter + if hasattr(epub_converter, 'fallback_compile_epub'): + fallback_compile_epub = epub_converter.fallback_compile_epub + else: + raise ImportError("epub_converter module missing fallback_compile_epub function") + + elif module_name == 'scan_html_folder': + # Validate the module before importing + import scan_html_folder as scan_module + if hasattr(scan_module, 'scan_html_folder'): + scan_html_folder = scan_module.scan_html_folder + else: + raise ImportError("scan_html_folder module missing scan_html_folder function") + + # If we reach here, import was successful + loaded_successfully = True + success_count += 1 + break + + except ImportError as e: + module_info['retry_count'] = retry_attempt + 1 + error_msg = str(e) + + # Log retry attempts + if retry_attempt < max_retries: + if hasattr(self, 'append_log'): + self.append_log(f"โš ๏ธ Failed to load {display_name} (attempt {retry_attempt + 1}): {error_msg}") + else: + # Final failure + print(f"Warning: Could not import {module_name} after {max_retries + 1} attempts: {error_msg}") + failed_modules.append({ + 'name': module_name, + 'display_name': display_name, + 'error': error_msg, + 'critical': module_info['critical'] + }) + break + + except Exception as e: + # Handle unexpected errors + error_msg = f"Unexpected error: {str(e)}" + print(f"Warning: Unexpected error loading {module_name}: {error_msg}") + failed_modules.append({ + 'name': module_name, + 'display_name': display_name, + 'error': error_msg, + 'critical': module_info['critical'] + }) + break + + # Enhanced progress feedback + if loaded_successfully and splash_callback: + progress_percent = int(((i + 1) / total_modules) * 100) + splash_callback(f"โœ… {display_name} loaded ({progress_percent}%)") + + # Calculate loading time for performance monitoring + loading_time = time.time() - loading_start_time + + # Enhanced success/failure reporting + if splash_callback: + if success_count == total_modules: + splash_callback(f"Loaded {success_count}/{total_modules} modules successfully in {loading_time:.1f}s") + else: + splash_callback(f"Loaded {success_count}/{total_modules} modules ({len(failed_modules)} failed)") + + # Enhanced logging with module status details + if hasattr(self, 'append_log'): + if success_count == total_modules: + self.append_log(f"โœ… Loaded {success_count}/{total_modules} modules successfully in {loading_time:.1f}s") + else: + self.append_log(f"โš ๏ธ Loaded {success_count}/{total_modules} modules successfully ({len(failed_modules)} failed)") + + # Report critical failures + critical_failures = [f for f in failed_modules if f['critical']] + if critical_failures: + for failure in critical_failures: + self.append_log(f"โŒ Critical module failed: {failure['display_name']} - {failure['error']}") + + # Report non-critical failures + non_critical_failures = [f for f in failed_modules if not f['critical']] + if non_critical_failures: + for failure in non_critical_failures: + self.append_log(f"โš ๏ธ Optional module failed: {failure['display_name']} - {failure['error']}") + + # Store references to imported modules in instance variables for later use + self._translation_main = translation_main + self._translation_stop_flag = translation_stop_flag + self._translation_stop_check = translation_stop_check + self._glossary_main = glossary_main + self._glossary_stop_flag = glossary_stop_flag + self._glossary_stop_check = glossary_stop_check + self._fallback_compile_epub = fallback_compile_epub + self._scan_html_folder = scan_html_folder + + # Final module state update with enhanced error checking + self._modules_loaded = True + self._modules_loading = False + + # Enhanced module availability checking with better integration + if hasattr(self, 'master'): + self.master.after(0, self._check_modules) + + # Return success status - maintain compatibility by returning True if any modules loaded + # But also check for critical module failures + critical_failures = [f for f in failed_modules if f['critical']] + if critical_failures and success_count == 0: + # Complete failure case + if hasattr(self, 'append_log'): + self.append_log("โŒ Critical module loading failed - some functionality may be unavailable") + return False + + return True + + except Exception as unexpected_error: + # Enhanced error recovery for unexpected failures + error_msg = f"Unexpected error during module loading: {str(unexpected_error)}" + print(f"Critical error: {error_msg}") + + if hasattr(self, 'append_log'): + self.append_log(f"โŒ Module loading failed: {error_msg}") + + # Reset states for retry possibility + self._modules_loaded = False + self._modules_loading = False + + if splash_callback: + splash_callback(f"Module loading failed: {str(unexpected_error)}") + + return False + + finally: + # Enhanced cleanup - ensure loading flag is always reset + if self._modules_loading: + self._modules_loading = False + + def _check_modules(self): + """Check which modules are available and disable buttons if needed""" + if not self._modules_loaded: + return + + # Use the stored instance variables instead of globals + button_checks = [ + (self._translation_main if hasattr(self, '_translation_main') else None, 'button_run', "Translation"), + (self._glossary_main if hasattr(self, '_glossary_main') else None, 'glossary_button', "Glossary extraction"), + (self._fallback_compile_epub if hasattr(self, '_fallback_compile_epub') else None, 'epub_button', "EPUB converter"), + (self._scan_html_folder if hasattr(self, '_scan_html_folder') else None, 'qa_button', "QA scanner") + ] + + for module, button_attr, name in button_checks: + if module is None and hasattr(self, button_attr): + button = getattr(self, button_attr, None) + if button: + button.config(state='disabled') + self.append_log(f"โš ๏ธ {name} module not available") + + def configure_title_prompt(self): + """Configure the book title translation prompt""" + dialog = self.wm.create_simple_dialog( + self.master, + "Configure Book Title Translation", + width=950, + height=850 # Increased height for two prompts + ) + + main_frame = tk.Frame(dialog, padx=20, pady=20) + main_frame.pack(fill=tk.BOTH, expand=True) + + # System Prompt Section + tk.Label(main_frame, text="System Prompt (AI Instructions)", + font=('TkDefaultFont', 12, 'bold')).pack(anchor=tk.W, pady=(0, 5)) + + tk.Label(main_frame, text="This defines how the AI should behave when translating titles:", + font=('TkDefaultFont', 10), fg='gray').pack(anchor=tk.W, pady=(0, 10)) + + self.title_system_prompt_text = self.ui.setup_scrollable_text( + main_frame, height=4, wrap=tk.WORD + ) + self.title_system_prompt_text.pack(fill=tk.BOTH, expand=True, pady=(0, 15)) + self.title_system_prompt_text.insert('1.0', self.config.get('book_title_system_prompt', + "You are a translator. Respond with only the translated text, nothing else. Do not add any explanation or additional content.")) + + # User Prompt Section + tk.Label(main_frame, text="User Prompt (Translation Request)", + font=('TkDefaultFont', 12, 'bold')).pack(anchor=tk.W, pady=(10, 5)) + + tk.Label(main_frame, text="This prompt will be used when translating book titles.\n" + "The book title will be appended after this prompt.", + font=('TkDefaultFont', 10), fg='gray').pack(anchor=tk.W, pady=(0, 10)) + + self.title_prompt_text = self.ui.setup_scrollable_text( + main_frame, height=6, wrap=tk.WORD + ) + self.title_prompt_text.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) + self.title_prompt_text.insert('1.0', self.book_title_prompt) + + lang_frame = tk.Frame(main_frame) + lang_frame.pack(fill=tk.X, pady=(10, 0)) + + tk.Label(lang_frame, text="๐Ÿ’ก Tip: Modify the prompts above to translate to other languages", + font=('TkDefaultFont', 10), fg='blue').pack(anchor=tk.W) + + example_frame = tk.LabelFrame(main_frame, text="Example Prompts", padx=10, pady=10) + example_frame.pack(fill=tk.X, pady=(10, 0)) + + examples = [ + ("Spanish", "Traduce este tรญtulo de libro al espaรฑol manteniendo los acrรณnimos:"), + ("French", "Traduisez ce titre de livre en franรงais en conservant les acronymes:"), + ("German", "รœbersetzen Sie diesen Buchtitel ins Deutsche und behalten Sie Akronyme bei:"), + ("Keep Original", "Return the title exactly as provided without any translation:") + ] + + for lang, prompt in examples: + btn = tb.Button(example_frame, text=f"Use {lang}", + command=lambda p=prompt: self.title_prompt_text.replace('1.0', tk.END, p), + bootstyle="secondary-outline", width=15) + btn.pack(side=tk.LEFT, padx=2, pady=2) + + button_frame = tk.Frame(main_frame) + button_frame.pack(fill=tk.X, pady=(20, 0)) + + def save_title_prompt(): + self.book_title_prompt = self.title_prompt_text.get('1.0', tk.END).strip() + self.config['book_title_prompt'] = self.book_title_prompt + + # Save the system prompt too + self.config['book_title_system_prompt'] = self.title_system_prompt_text.get('1.0', tk.END).strip() + + #messagebox.showinfo("Success", "Book title prompts saved!") + dialog.destroy() + + def reset_title_prompt(): + if messagebox.askyesno("Reset Prompts", "Reset both prompts to defaults?"): + # Reset system prompt + default_system = "You are a translator. Respond with only the translated text, nothing else. Do not add any explanation or additional content." + self.title_system_prompt_text.delete('1.0', tk.END) + self.title_system_prompt_text.insert('1.0', default_system) + + # Reset user prompt + default_prompt = "Translate this book title to English while retaining any acronyms:" + self.title_prompt_text.delete('1.0', tk.END) + self.title_prompt_text.insert('1.0', default_prompt) + + tb.Button(button_frame, text="Save", command=save_title_prompt, + bootstyle="success", width=15).pack(side=tk.LEFT, padx=5) + tb.Button(button_frame, text="Reset to Default", command=reset_title_prompt, + bootstyle="warning", width=15).pack(side=tk.LEFT, padx=5) + tb.Button(button_frame, text="Cancel", command=dialog.destroy, + bootstyle="secondary", width=15).pack(side=tk.LEFT, padx=5) + + dialog.deiconify() + + def detect_novel_numbering_unified(self, output_dir, progress_data): + """ + Use the backend's detect_novel_numbering function for consistent detection + """ + try: + # Try to load the backend detection function + if not self._lazy_load_modules(): + # Fallback to current GUI logic if modules not loaded + return self._detect_novel_numbering_gui_fallback(output_dir, progress_data) + + # Import the detection function from backend + from TransateKRtoEN import detect_novel_numbering + + # Build a chapters list from progress data to pass to backend function + chapters = [] + for chapter_key, chapter_info in progress_data.get("chapters", {}).items(): + # Get the output file, handling None values + output_file = chapter_info.get('output_file', '') + + chapter_dict = { + 'original_basename': chapter_info.get('original_basename', ''), + 'filename': output_file or '', # Ensure it's never None + 'num': chapter_info.get('chapter_num', 0) + } + + # Only add the output file path if it exists and is not empty + if output_file and output_file.strip(): + chapter_dict['filename'] = os.path.join(output_dir, output_file) + else: + # If no output file, try to discover a file based on original basename or chapter number + retain = os.getenv('RETAIN_SOURCE_EXTENSION', '0') == '1' or self.config.get('retain_source_extension', False) + allowed_exts = ('.html', '.xhtml', '.htm') + discovered = None + + if chapter_dict['original_basename']: + base = chapter_dict['original_basename'] + # Scan output_dir for either response_{base}.* or {base}.* + try: + for f in os.listdir(output_dir): + f_low = f.lower() + if f_low.endswith(allowed_exts): + name_no_ext = os.path.splitext(f)[0] + if name_no_ext.startswith('response_'): + candidate_base = name_no_ext[9:] + else: + candidate_base = name_no_ext + if candidate_base == base: + discovered = f + break + except Exception: + pass + + if not discovered: + # Fall back to expected naming per mode + if retain: + # Default to original basename with .html + discovered = f"{base}.html" + else: + discovered = f"response_{base}.html" + else: + # Last resort: use chapter number pattern + chapter_num = chapter_info.get('actual_num', chapter_info.get('chapter_num', 0)) + num_str = f"{int(chapter_num):04d}" if isinstance(chapter_num, (int, float)) else str(chapter_num) + try: + for f in os.listdir(output_dir): + f_low = f.lower() + if f_low.endswith(allowed_exts): + name_no_ext = os.path.splitext(f)[0] + # Remove optional response_ prefix + core = name_no_ext[9:] if name_no_ext.startswith('response_') else name_no_ext + if core.startswith(num_str): + discovered = f + break + except Exception: + pass + + if not discovered: + if retain: + discovered = f"{num_str}.html" + else: + discovered = f"response_{num_str}.html" + + chapter_dict['filename'] = os.path.join(output_dir, discovered) + + chapters.append(chapter_dict) + + # Use the backend detection logic + uses_zero_based = detect_novel_numbering(chapters) + + print(f"[GUI] Unified detection result: {'0-based' if uses_zero_based else '1-based'}") + return uses_zero_based + + except Exception as e: + print(f"[GUI] Error in unified detection: {e}") + # Fallback to GUI logic on error + return self._detect_novel_numbering_gui_fallback(output_dir, progress_data) + + def _detect_novel_numbering_gui_fallback(self, output_dir, progress_data): + """ + Fallback detection logic (current GUI implementation) + """ + uses_zero_based = False + + for chapter_key, chapter_info in progress_data.get("chapters", {}).items(): + if chapter_info.get("status") == "completed": + output_file = chapter_info.get("output_file", "") + stored_chapter_num = chapter_info.get("chapter_num", 0) + if output_file: + # Allow filenames with or without 'response_' prefix + match = re.search(r'(?:^response_)?(\d+)', output_file) + if match: + file_num = int(match.group(1)) + if file_num == stored_chapter_num - 1: + uses_zero_based = True + break + elif file_num == stored_chapter_num: + uses_zero_based = False + break + + if not uses_zero_based: + try: + for file in os.listdir(output_dir): + if re.search(r'_0+[_\.]', file): + uses_zero_based = True + break + except: pass + + return uses_zero_based + + def force_retranslation(self): + """Force retranslation of specific chapters or images with improved display""" + + # Check for multiple file selection first + if hasattr(self, 'selected_files') and len(self.selected_files) > 1: + self._force_retranslation_multiple_files() + return + + # Check if it's a folder selection (for images) + if hasattr(self, 'selected_files') and len(self.selected_files) > 0: + # Check if the first selected file is actually a folder + first_item = self.selected_files[0] + if os.path.isdir(first_item): + self._force_retranslation_images_folder(first_item) + return + + # Original logic for single files + input_path = self.entry_epub.get() + if not input_path or not os.path.isfile(input_path): + messagebox.showerror("Error", "Please select a valid EPUB, text file, or image folder first.") + return + + # Check if it's an image file + image_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp') + if input_path.lower().endswith(image_extensions): + self._force_retranslation_single_image(input_path) + return + + # For EPUB/text files, use the shared logic + self._force_retranslation_epub_or_text(input_path) + + + def _force_retranslation_epub_or_text(self, file_path, parent_dialog=None, tab_frame=None): + """ + Shared logic for force retranslation of EPUB/text files with OPF support + Can be used standalone or embedded in a tab + + Args: + file_path: Path to the EPUB/text file + parent_dialog: If provided, won't create its own dialog + tab_frame: If provided, will render into this frame instead of creating dialog + + Returns: + dict: Contains all the UI elements and data for external access + """ + + epub_base = os.path.splitext(os.path.basename(file_path))[0] + output_dir = epub_base + + if not os.path.exists(output_dir): + if not parent_dialog: + messagebox.showinfo("Info", "No translation output found for this file.") + return None + + progress_file = os.path.join(output_dir, "translation_progress.json") + if not os.path.exists(progress_file): + if not parent_dialog: + messagebox.showinfo("Info", "No progress tracking found.") + return None + + with open(progress_file, 'r', encoding='utf-8') as f: + prog = json.load(f) + + # ===================================================== + # PARSE CONTENT.OPF FOR CHAPTER MANIFEST + # ===================================================== + + spine_chapters = [] + opf_chapter_order = {} + is_epub = file_path.lower().endswith('.epub') + + if is_epub and os.path.exists(file_path): + try: + import xml.etree.ElementTree as ET + import zipfile + + with zipfile.ZipFile(file_path, 'r') as zf: + # Find content.opf file + opf_path = None + opf_content = None + + # First try to find via container.xml + try: + container_content = zf.read('META-INF/container.xml') + container_root = ET.fromstring(container_content) + rootfile = container_root.find('.//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile') + if rootfile is not None: + opf_path = rootfile.get('full-path') + except: + pass + + # Fallback: search for content.opf + if not opf_path: + for name in zf.namelist(): + if name.endswith('content.opf'): + opf_path = name + break + + if opf_path: + opf_content = zf.read(opf_path) + + # Parse OPF + root = ET.fromstring(opf_content) + + # Handle namespaces + ns = {'opf': 'http://www.idpf.org/2007/opf'} + if root.tag.startswith('{'): + default_ns = root.tag[1:root.tag.index('}')] + ns = {'opf': default_ns} + + # Get manifest - all chapter files + manifest_chapters = {} + + for item in root.findall('.//opf:manifest/opf:item', ns): + item_id = item.get('id') + href = item.get('href') + media_type = item.get('media-type', '') + + if item_id and href and ('html' in media_type.lower() or href.endswith(('.html', '.xhtml', '.htm'))): + filename = os.path.basename(href) + + # Skip navigation, toc, and cover files + if not any(skip in filename.lower() for skip in ['nav.', 'toc.', 'cover.']): + manifest_chapters[item_id] = { + 'filename': filename, + 'href': href, + 'media_type': media_type + } + + # Get spine order - the reading order + spine = root.find('.//opf:spine', ns) + + if spine is not None: + for itemref in spine.findall('opf:itemref', ns): + idref = itemref.get('idref') + if idref and idref in manifest_chapters: + chapter_info = manifest_chapters[idref] + filename = chapter_info['filename'] + + # Skip navigation, toc, and cover files + if not any(skip in filename.lower() for skip in ['nav.', 'toc.', 'cover.']): + # Extract chapter number from filename + import re + matches = re.findall(r'(\d+)', filename) + if matches: + file_chapter_num = int(matches[-1]) + else: + file_chapter_num = len(spine_chapters) + + spine_chapters.append({ + 'id': idref, + 'filename': filename, + 'position': len(spine_chapters), + 'file_chapter_num': file_chapter_num, + 'status': 'unknown', # Will be updated + 'output_file': None # Will be updated + }) + + # Store the order for later use + opf_chapter_order[filename] = len(spine_chapters) - 1 + + # Also store without extension for matching + filename_noext = os.path.splitext(filename)[0] + opf_chapter_order[filename_noext] = len(spine_chapters) - 1 + + except Exception as e: + print(f"Warning: Could not parse OPF: {e}") + + # ===================================================== + # MATCH OPF CHAPTERS WITH TRANSLATION PROGRESS + # ===================================================== + + # Build a map of original basenames to progress entries + basename_to_progress = {} + for chapter_key, chapter_info in prog.get("chapters", {}).items(): + original_basename = chapter_info.get("original_basename", "") + if original_basename: + if original_basename not in basename_to_progress: + basename_to_progress[original_basename] = [] + basename_to_progress[original_basename].append((chapter_key, chapter_info)) + + # Also build a map of response files + response_file_to_progress = {} + for chapter_key, chapter_info in prog.get("chapters", {}).items(): + output_file = chapter_info.get("output_file", "") + if output_file: + if output_file not in response_file_to_progress: + response_file_to_progress[output_file] = [] + response_file_to_progress[output_file].append((chapter_key, chapter_info)) + + # Update spine chapters with translation status + for spine_ch in spine_chapters: + filename = spine_ch['filename'] + chapter_num = spine_ch['file_chapter_num'] + + # Find the actual response file that exists + base_name = os.path.splitext(filename)[0] + expected_response = None + + # Handle .htm.html -> .html conversion + stripped_base_name = base_name + if base_name.endswith('.htm'): + stripped_base_name = base_name[:-4] # Remove .htm suffix + + # Look for translated file matching base name, with or without 'response_' and with allowed extensions + allowed_exts = ('.html', '.xhtml', '.htm') + for file in os.listdir(output_dir): + f_low = file.lower() + if f_low.endswith(allowed_exts): + name_no_ext = os.path.splitext(file)[0] + core = name_no_ext[9:] if name_no_ext.startswith('response_') else name_no_ext + # Accept matches for: + # - OPF filename without last extension (base_name) + # - Stripped base for .htm cases + # - OPF filename as-is (e.g., 'chapter_02.htm') when the output file is 'chapter_02.htm.xhtml' + if core == base_name or core == stripped_base_name or core == filename: + expected_response = file + break + + # Fallback - per mode, prefer OPF filename when retain mode is on + if not expected_response: + retain = os.getenv('RETAIN_SOURCE_EXTENSION', '0') == '1' or self.config.get('retain_source_extension', False) + if retain: + expected_response = filename + else: + expected_response = f"response_{stripped_base_name}.html" + + response_path = os.path.join(output_dir, expected_response) + + # Check various ways to find the translation progress info + matched_info = None + + # Method 1: Check by original basename + if filename in basename_to_progress: + entries = basename_to_progress[filename] + if entries: + _, chapter_info = entries[0] + matched_info = chapter_info + + # Method 2: Check by response file (with corrected extension) + if not matched_info and expected_response in response_file_to_progress: + entries = response_file_to_progress[expected_response] + if entries: + _, chapter_info = entries[0] + matched_info = chapter_info + + # Method 3: Search through all progress entries for matching output file + if not matched_info: + for chapter_key, chapter_info in prog.get("chapters", {}).items(): + if chapter_info.get('output_file') == expected_response: + matched_info = chapter_info + break + + # Method 4: CRUCIAL - Match by chapter number (actual_num vs file_chapter_num) + if not matched_info: + for chapter_key, chapter_info in prog.get("chapters", {}).items(): + actual_num = chapter_info.get('actual_num') + # Also check 'chapter_num' as fallback + if actual_num is None: + actual_num = chapter_info.get('chapter_num') + + if actual_num is not None and actual_num == chapter_num: + matched_info = chapter_info + break + + # Determine if translation file exists + file_exists = os.path.exists(response_path) + + # Set status and output file based on findings + if matched_info: + # We found progress tracking info - use its status + spine_ch['status'] = matched_info.get('status', 'unknown') + spine_ch['output_file'] = matched_info.get('output_file', expected_response) + spine_ch['progress_entry'] = matched_info + + # Handle null output_file (common for failed/in_progress chapters) + if not spine_ch['output_file']: + spine_ch['output_file'] = expected_response + + # Keep original extension (html/xhtml/htm) as written on disk + + # Verify file actually exists for completed status + if spine_ch['status'] == 'completed': + output_path = os.path.join(output_dir, spine_ch['output_file']) + if not os.path.exists(output_path): + spine_ch['status'] = 'file_missing' + + elif file_exists: + # File exists but no progress tracking - mark as completed + spine_ch['status'] = 'completed' + spine_ch['output_file'] = expected_response + + else: + # No file and no progress tracking - not translated + spine_ch['status'] = 'not_translated' + spine_ch['output_file'] = expected_response + + # ===================================================== + # BUILD DISPLAY INFO + # ===================================================== + + chapter_display_info = [] + + if spine_chapters: + # Use OPF order + for spine_ch in spine_chapters: + display_info = { + 'key': spine_ch.get('filename', ''), + 'num': spine_ch['file_chapter_num'], + 'info': spine_ch.get('progress_entry', {}), + 'output_file': spine_ch['output_file'], + 'status': spine_ch['status'], + 'duplicate_count': 1, + 'entries': [], + 'opf_position': spine_ch['position'], + 'original_filename': spine_ch['filename'] + } + chapter_display_info.append(display_info) + else: + # Fallback to original logic if no OPF + files_to_entries = {} + for chapter_key, chapter_info in prog.get("chapters", {}).items(): + output_file = chapter_info.get("output_file", "") + if output_file: + if output_file not in files_to_entries: + files_to_entries[output_file] = [] + files_to_entries[output_file].append((chapter_key, chapter_info)) + + for output_file, entries in files_to_entries.items(): + chapter_key, chapter_info = entries[0] + + # Extract chapter number + import re + matches = re.findall(r'(\d+)', output_file) + if matches: + chapter_num = int(matches[-1]) + else: + chapter_num = 999999 + + # Override with stored values if available + if 'actual_num' in chapter_info and chapter_info['actual_num'] is not None: + chapter_num = chapter_info['actual_num'] + elif 'chapter_num' in chapter_info and chapter_info['chapter_num'] is not None: + chapter_num = chapter_info['chapter_num'] + + status = chapter_info.get("status", "unknown") + if status == "completed_empty": + status = "completed" + + # Check file existence + if status == "completed": + output_path = os.path.join(output_dir, output_file) + if not os.path.exists(output_path): + status = "file_missing" + + chapter_display_info.append({ + 'key': chapter_key, + 'num': chapter_num, + 'info': chapter_info, + 'output_file': output_file, + 'status': status, + 'duplicate_count': len(entries), + 'entries': entries + }) + + # Sort by chapter number + chapter_display_info.sort(key=lambda x: x['num'] if x['num'] is not None else 999999) + + # ===================================================== + # CREATE UI + # ===================================================== + + # If no parent dialog or tab frame, create standalone dialog + if not parent_dialog and not tab_frame: + dialog = self.wm.create_simple_dialog( + self.master, + "Force Retranslation - OPF Based" if spine_chapters else "Force Retranslation", + width=1000, + height=700 + ) + container = dialog + else: + container = tab_frame or parent_dialog + dialog = parent_dialog + + # Title + title_text = "Chapters from content.opf (in reading order):" if spine_chapters else "Select chapters to retranslate:" + tk.Label(container, text=title_text, + font=('Arial', 12 if not tab_frame else 11, 'bold')).pack(pady=5) + + # Statistics if OPF is available + if spine_chapters: + stats_frame = tk.Frame(container) + stats_frame.pack(pady=5) + + total_chapters = len(spine_chapters) + completed = sum(1 for ch in spine_chapters if ch['status'] == 'completed') + missing = sum(1 for ch in spine_chapters if ch['status'] == 'not_translated') + failed = sum(1 for ch in spine_chapters if ch['status'] in ['failed', 'qa_failed']) + file_missing = sum(1 for ch in spine_chapters if ch['status'] == 'file_missing') + + tk.Label(stats_frame, text=f"Total: {total_chapters} | ", font=('Arial', 10)).pack(side=tk.LEFT) + tk.Label(stats_frame, text=f"โœ… Completed: {completed} | ", font=('Arial', 10), fg='green').pack(side=tk.LEFT) + tk.Label(stats_frame, text=f"โŒ Missing: {missing} | ", font=('Arial', 10), fg='red').pack(side=tk.LEFT) + tk.Label(stats_frame, text=f"โš ๏ธ Failed: {failed} | ", font=('Arial', 10), fg='orange').pack(side=tk.LEFT) + tk.Label(stats_frame, text=f"๐Ÿ“ File Missing: {file_missing}", font=('Arial', 10), fg='purple').pack(side=tk.LEFT) + + # Main frame for listbox + main_frame = tk.Frame(container) + main_frame.pack(fill=tk.BOTH, expand=True, padx=10 if not tab_frame else 5, pady=5) + + # Create scrollbars and listbox + h_scrollbar = ttk.Scrollbar(main_frame, orient=tk.HORIZONTAL) + h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X) + + v_scrollbar = ttk.Scrollbar(main_frame, orient=tk.VERTICAL) + v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + listbox = tk.Listbox( + main_frame, + selectmode=tk.EXTENDED, + yscrollcommand=v_scrollbar.set, + xscrollcommand=h_scrollbar.set, + width=120, + font=('Courier', 10) # Fixed-width font for better alignment + ) + listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + v_scrollbar.config(command=listbox.yview) + h_scrollbar.config(command=listbox.xview) + + # Populate listbox + status_icons = { + 'completed': 'โœ…', + 'failed': 'โŒ', + 'qa_failed': 'โŒ', + 'file_missing': 'โš ๏ธ', + 'in_progress': '๐Ÿ”„', + 'not_translated': 'โŒ', + 'unknown': 'โ“' + } + + status_labels = { + 'completed': 'Completed', + 'failed': 'Failed', + 'qa_failed': 'QA Failed', + 'file_missing': 'File Missing', + 'in_progress': 'In Progress', + 'not_translated': 'Not Translated', + 'unknown': 'Unknown' + } + + for info in chapter_display_info: + chapter_num = info['num'] + status = info['status'] + output_file = info['output_file'] + icon = status_icons.get(status, 'โ“') + status_label = status_labels.get(status, status) + + # Format display with OPF info if available + if 'opf_position' in info: + # OPF-based display + original_file = info.get('original_filename', '') + opf_pos = info['opf_position'] + 1 # 1-based for display + + # Format: [OPF Position] Chapter Number | Status | Original File -> Response File + if isinstance(chapter_num, float) and chapter_num.is_integer(): + display = f"[{opf_pos:03d}] Ch.{int(chapter_num):03d} | {icon} {status_label:15s} | {original_file:30s} -> {output_file}" + else: + display = f"[{opf_pos:03d}] Ch.{chapter_num:03d} | {icon} {status_label:15s} | {original_file:30s} -> {output_file}" + else: + # Original format + if isinstance(chapter_num, float) and chapter_num.is_integer(): + display = f"Chapter {int(chapter_num):03d} | {icon} {status_label:15s} | {output_file}" + elif isinstance(chapter_num, float): + display = f"Chapter {chapter_num:06.1f} | {icon} {status_label:15s} | {output_file}" + else: + display = f"Chapter {chapter_num:03d} | {icon} {status_label:15s} | {output_file}" + + if info.get('duplicate_count', 1) > 1: + display += f" | ({info['duplicate_count']} entries)" + + listbox.insert(tk.END, display) + + # Color code based on status + if status == 'completed': + listbox.itemconfig(tk.END, fg='green') + elif status in ['failed', 'qa_failed', 'not_translated']: + listbox.itemconfig(tk.END, fg='red') + elif status == 'file_missing': + listbox.itemconfig(tk.END, fg='purple') + elif status == 'in_progress': + listbox.itemconfig(tk.END, fg='orange') + + # Selection count label + selection_count_label = tk.Label(container, text="Selected: 0", + font=('Arial', 10 if not tab_frame else 9)) + selection_count_label.pack(pady=(5, 10) if not tab_frame else 2) + + def update_selection_count(*args): + count = len(listbox.curselection()) + selection_count_label.config(text=f"Selected: {count}") + + listbox.bind('<<ListboxSelect>>', update_selection_count) + + # Return data structure for external access + result = { + 'file_path': file_path, + 'output_dir': output_dir, + 'progress_file': progress_file, + 'prog': prog, + 'spine_chapters': spine_chapters, + 'opf_chapter_order': opf_chapter_order, + 'chapter_display_info': chapter_display_info, + 'listbox': listbox, + 'selection_count_label': selection_count_label, + 'dialog': dialog, + 'container': container + } + + # If standalone (no parent), add buttons + if not parent_dialog or tab_frame: + self._add_retranslation_buttons_opf(result) + + return result + + + def _add_retranslation_buttons_opf(self, data, button_frame=None): + """Add the standard button set for retranslation dialogs with OPF support""" + + if not button_frame: + button_frame = tk.Frame(data['container']) + button_frame.pack(pady=10) + + # Configure column weights + for i in range(5): + button_frame.columnconfigure(i, weight=1) + + # Helper functions that work with the data dict + def select_all(): + data['listbox'].select_set(0, tk.END) + data['selection_count_label'].config(text=f"Selected: {data['listbox'].size()}") + + def clear_selection(): + data['listbox'].select_clear(0, tk.END) + data['selection_count_label'].config(text="Selected: 0") + + def select_status(status_to_select): + data['listbox'].select_clear(0, tk.END) + for idx, info in enumerate(data['chapter_display_info']): + if status_to_select == 'failed': + if info['status'] in ['failed', 'qa_failed']: + data['listbox'].select_set(idx) + elif status_to_select == 'missing': + if info['status'] in ['not_translated', 'file_missing']: + data['listbox'].select_set(idx) + else: + if info['status'] == status_to_select: + data['listbox'].select_set(idx) + count = len(data['listbox'].curselection()) + data['selection_count_label'].config(text=f"Selected: {count}") + + def remove_qa_failed_mark(): + selected = data['listbox'].curselection() + if not selected: + messagebox.showwarning("No Selection", "Please select at least one chapter.") + return + + selected_chapters = [data['chapter_display_info'][i] for i in selected] + qa_failed_chapters = [ch for ch in selected_chapters if ch['status'] == 'qa_failed'] + + if not qa_failed_chapters: + messagebox.showwarning("No QA Failed Chapters", + "None of the selected chapters have 'qa_failed' status.") + return + + count = len(qa_failed_chapters) + if not messagebox.askyesno("Confirm Remove QA Failed Mark", + f"Remove QA failed mark from {count} chapters?"): + return + + # Remove marks + cleared_count = 0 + for info in qa_failed_chapters: + # Find the actual numeric key in progress by matching output_file + target_output_file = info['output_file'] + chapter_key = None + + # Search through all chapters to find the one with matching output_file + for key, ch_info in data['prog']["chapters"].items(): + if ch_info.get('output_file') == target_output_file: + chapter_key = key + break + + # Update the chapter status if we found the key + if chapter_key and chapter_key in data['prog']["chapters"]: + print(f"Updating chapter key {chapter_key} (output file: {target_output_file})") + data['prog']["chapters"][chapter_key]["status"] = "completed" + + # Remove all QA-related fields + fields_to_remove = ["qa_issues", "qa_timestamp", "qa_issues_found", "duplicate_confidence"] + for field in fields_to_remove: + if field in data['prog']["chapters"][chapter_key]: + del data['prog']["chapters"][chapter_key][field] + + cleared_count += 1 + else: + print(f"WARNING: Could not find chapter key for output file: {target_output_file}") + + # Save the updated progress + with open(data['progress_file'], 'w', encoding='utf-8') as f: + json.dump(data['prog'], f, ensure_ascii=False, indent=2) + + messagebox.showinfo("Success", f"Removed QA failed mark from {cleared_count} chapters.") + if data.get('dialog'): + data['dialog'].destroy() + + def retranslate_selected(): + selected = data['listbox'].curselection() + if not selected: + messagebox.showwarning("No Selection", "Please select at least one chapter.") + return + + selected_chapters = [data['chapter_display_info'][i] for i in selected] + + # Count different types + missing_count = sum(1 for ch in selected_chapters if ch['status'] == 'not_translated') + existing_count = sum(1 for ch in selected_chapters if ch['status'] != 'not_translated') + + count = len(selected) + if count > 10: + if missing_count > 0 and existing_count > 0: + confirm_msg = f"This will:\nโ€ข Mark {missing_count} missing chapters for translation\nโ€ข Delete and retranslate {existing_count} existing chapters\n\nTotal: {count} chapters\n\nContinue?" + elif missing_count > 0: + confirm_msg = f"This will mark {missing_count} missing chapters for translation.\n\nContinue?" + else: + confirm_msg = f"This will delete {existing_count} translated chapters and mark them for retranslation.\n\nContinue?" + else: + chapters = [f"Ch.{ch['num']}" for ch in selected_chapters] + confirm_msg = f"This will process:\n\n{', '.join(chapters)}\n\n" + if missing_count > 0: + confirm_msg += f"โ€ข {missing_count} missing chapters will be marked for translation\n" + if existing_count > 0: + confirm_msg += f"โ€ข {existing_count} existing chapters will be deleted and retranslated\n" + confirm_msg += "\nContinue?" + + if not messagebox.askyesno("Confirm Retranslation", confirm_msg): + return + + # Process chapters - DELETE FILES AND UPDATE PROGRESS + deleted_count = 0 + marked_count = 0 + status_reset_count = 0 + progress_updated = False + + for ch_info in selected_chapters: + output_file = ch_info['output_file'] + + if ch_info['status'] != 'not_translated': + # Delete existing file + if output_file: + output_path = os.path.join(data['output_dir'], output_file) + try: + if os.path.exists(output_path): + os.remove(output_path) + deleted_count += 1 + print(f"Deleted: {output_path}") + except Exception as e: + print(f"Failed to delete {output_path}: {e}") + + # Reset status for any completed or qa_failed chapters + if ch_info['status'] in ['completed', 'qa_failed']: + target_output_file = ch_info['output_file'] + chapter_key = None + + # Search through all chapters to find the one with matching output_file + for key, ch_data in data['prog']["chapters"].items(): + if ch_data.get('output_file') == target_output_file: + chapter_key = key + break + + # Update the chapter status if we found the key + if chapter_key and chapter_key in data['prog']["chapters"]: + old_status = ch_info['status'] + print(f"Resetting {old_status} status to pending for chapter key {chapter_key} (output file: {target_output_file})") + + # Reset status to pending for retranslation + data['prog']["chapters"][chapter_key]["status"] = "pending" + + # Remove completion-related fields if they exist + fields_to_remove = [] + if old_status == 'qa_failed': + # Remove QA-related fields for qa_failed chapters + fields_to_remove = ["qa_issues", "qa_timestamp", "qa_issues_found", "duplicate_confidence"] + elif old_status == 'completed': + # Remove completion-related fields if any exist for completed chapters + fields_to_remove = ["completion_timestamp", "final_word_count", "translation_quality_score"] + + for field in fields_to_remove: + if field in data['prog']["chapters"][chapter_key]: + del data['prog']["chapters"][chapter_key][field] + + status_reset_count += 1 + progress_updated = True + else: + print(f"WARNING: Could not find chapter key for {old_status} output file: {target_output_file}") + else: + # Just marking for translation (no file to delete) + marked_count += 1 + + # Save the updated progress if we made changes + if progress_updated: + try: + with open(data['progress_file'], 'w', encoding='utf-8') as f: + json.dump(data['prog'], f, ensure_ascii=False, indent=2) + print(f"Updated progress tracking file - reset {status_reset_count} chapter statuses to pending") + except Exception as e: + print(f"Failed to update progress file: {e}") + + # Build success message + success_parts = [] + if deleted_count > 0: + success_parts.append(f"Deleted {deleted_count} files") + if marked_count > 0: + success_parts.append(f"marked {marked_count} missing chapters for translation") + if status_reset_count > 0: + success_parts.append(f"reset {status_reset_count} chapter statuses to pending") + + if success_parts: + success_msg = "Successfully " + ", ".join(success_parts) + "." + if deleted_count > 0 or marked_count > 0: + success_msg += f"\n\nTotal {len(selected)} chapters ready for translation." + messagebox.showinfo("Success", success_msg) + else: + messagebox.showinfo("Info", "No changes made.") + + if data.get('dialog'): + data['dialog'].destroy() + + # Add buttons - First row + tb.Button(button_frame, text="Select All", command=select_all, + bootstyle="info").grid(row=0, column=0, padx=5, pady=5, sticky="ew") + tb.Button(button_frame, text="Clear", command=clear_selection, + bootstyle="secondary").grid(row=0, column=1, padx=5, pady=5, sticky="ew") + tb.Button(button_frame, text="Select Completed", command=lambda: select_status('completed'), + bootstyle="success").grid(row=0, column=2, padx=5, pady=5, sticky="ew") + tb.Button(button_frame, text="Select Missing", command=lambda: select_status('missing'), + bootstyle="danger").grid(row=0, column=3, padx=5, pady=5, sticky="ew") + tb.Button(button_frame, text="Select Failed", command=lambda: select_status('failed'), + bootstyle="warning").grid(row=0, column=4, padx=5, pady=5, sticky="ew") + + # Second row + tb.Button(button_frame, text="Retranslate Selected", command=retranslate_selected, + bootstyle="warning").grid(row=1, column=0, columnspan=2, padx=5, pady=10, sticky="ew") + tb.Button(button_frame, text="Remove QA Failed Mark", command=remove_qa_failed_mark, + bootstyle="success").grid(row=1, column=2, columnspan=1, padx=5, pady=10, sticky="ew") + tb.Button(button_frame, text="Cancel", command=lambda: data['dialog'].destroy() if data.get('dialog') else None, + bootstyle="secondary").grid(row=1, column=3, columnspan=2, padx=5, pady=10, sticky="ew") + + + def _force_retranslation_multiple_files(self): + """Handle force retranslation when multiple files are selected - now uses shared logic""" + + # First, check if all selected files are images from the same folder + # This handles the case where folder selection results in individual file selections + if len(self.selected_files) > 1: + all_images = True + parent_dirs = set() + + image_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp') + + for file_path in self.selected_files: + if os.path.isfile(file_path) and file_path.lower().endswith(image_extensions): + parent_dirs.add(os.path.dirname(file_path)) + else: + all_images = False + break + + # If all files are images from the same directory, treat it as a folder selection + if all_images and len(parent_dirs) == 1: + folder_path = parent_dirs.pop() + print(f"[DEBUG] Detected {len(self.selected_files)} images from same folder: {folder_path}") + print(f"[DEBUG] Treating as folder selection") + self._force_retranslation_images_folder(folder_path) + return + + # Otherwise, continue with normal categorization + epub_files = [] + text_files = [] + image_files = [] + folders = [] + + image_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp') + + for file_path in self.selected_files: + if os.path.isdir(file_path): + folders.append(file_path) + elif file_path.lower().endswith('.epub'): + epub_files.append(file_path) + elif file_path.lower().endswith('.txt'): + text_files.append(file_path) + elif file_path.lower().endswith(image_extensions): + image_files.append(file_path) + + # Build summary + summary_parts = [] + if epub_files: + summary_parts.append(f"{len(epub_files)} EPUB file(s)") + if text_files: + summary_parts.append(f"{len(text_files)} text file(s)") + if image_files: + summary_parts.append(f"{len(image_files)} image file(s)") + if folders: + summary_parts.append(f"{len(folders)} folder(s)") + + if not summary_parts: + messagebox.showinfo("Info", "No valid files selected.") + return + + # Create main dialog + dialog = self.wm.create_simple_dialog( + self.master, + "Force Retranslation - Multiple Files", + width=950, + height=700 + ) + + # Summary label + tk.Label(dialog, text=f"Selected: {', '.join(summary_parts)}", + font=('Arial', 12, 'bold')).pack(pady=10) + + # Create notebook + notebook = ttk.Notebook(dialog) + notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) + + # Track all tab data + tab_data = [] + tabs_created = False + + # Create tabs for EPUB/text files using shared logic + for file_path in epub_files + text_files: + file_base = os.path.splitext(os.path.basename(file_path))[0] + + # Quick check if output exists + if not os.path.exists(file_base): + continue + + # Create tab + tab_frame = tk.Frame(notebook) + tab_name = file_base[:20] + "..." if len(file_base) > 20 else file_base + notebook.add(tab_frame, text=tab_name) + tabs_created = True + + # Use shared logic to populate the tab + tab_result = self._force_retranslation_epub_or_text( + file_path, + parent_dialog=dialog, + tab_frame=tab_frame + ) + + if tab_result: + tab_data.append(tab_result) + + # Create tabs for image folders (keeping existing logic for now) + for folder_path in folders: + folder_result = self._create_image_folder_tab( + folder_path, + notebook, + dialog + ) + if folder_result: + tab_data.append(folder_result) + tabs_created = True + + # If only individual image files selected and no tabs created yet + if image_files and not tabs_created: + # Create a single tab for all individual images + image_tab_result = self._create_individual_images_tab( + image_files, + notebook, + dialog + ) + if image_tab_result: + tab_data.append(image_tab_result) + tabs_created = True + + # If no tabs were created, show error + if not tabs_created: + messagebox.showinfo("Info", + "No translation output found for any of the selected files.\n\n" + "Make sure the output folders exist in your script directory.") + dialog.destroy() + return + + # Add unified button bar that works across all tabs + self._add_multi_file_buttons(dialog, notebook, tab_data) + + def _add_multi_file_buttons(self, dialog, notebook, tab_data): + """Add a simple cancel button at the bottom of the dialog""" + button_frame = tk.Frame(dialog) + button_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=10, pady=10) + + tb.Button(button_frame, text="Close All", command=dialog.destroy, + bootstyle="secondary").pack(side=tk.RIGHT, padx=5) + + def _create_individual_images_tab(self, image_files, notebook, parent_dialog): + """Create a tab for individual image files""" + # Create tab + tab_frame = tk.Frame(notebook) + notebook.add(tab_frame, text="Individual Images") + + # Instructions + tk.Label(tab_frame, text=f"Selected {len(image_files)} individual image(s):", + font=('Arial', 11)).pack(pady=5) + + # Main frame + main_frame = tk.Frame(tab_frame) + main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Scrollbars and listbox + h_scrollbar = ttk.Scrollbar(main_frame, orient=tk.HORIZONTAL) + h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X) + + v_scrollbar = ttk.Scrollbar(main_frame, orient=tk.VERTICAL) + v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + listbox = tk.Listbox( + main_frame, + selectmode=tk.EXTENDED, + yscrollcommand=v_scrollbar.set, + xscrollcommand=h_scrollbar.set, + width=100 + ) + listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + v_scrollbar.config(command=listbox.yview) + h_scrollbar.config(command=listbox.xview) + + # File info + file_info = [] + script_dir = os.getcwd() + + # Check each image for translations + for img_path in sorted(image_files): + img_name = os.path.basename(img_path) + base_name = os.path.splitext(img_name)[0] + + # Look for translations in various possible locations + found_translations = [] + + # Check in script directory with base name + possible_dirs = [ + os.path.join(script_dir, base_name), + os.path.join(script_dir, f"{base_name}_translated"), + base_name, + f"{base_name}_translated" + ] + + for output_dir in possible_dirs: + if os.path.exists(output_dir) and os.path.isdir(output_dir): + # Look for HTML files + for file in os.listdir(output_dir): + if file.lower().endswith(('.html', '.xhtml', '.htm')) and base_name in file: + found_translations.append((output_dir, file)) + + if found_translations: + for output_dir, html_file in found_translations: + display = f"๐Ÿ“„ {img_name} โ†’ {html_file} | โœ… Translated" + listbox.insert(tk.END, display) + + file_info.append({ + 'type': 'translated', + 'source_image': img_path, + 'output_dir': output_dir, + 'file': html_file, + 'path': os.path.join(output_dir, html_file) + }) + else: + display = f"๐Ÿ–ผ๏ธ {img_name} | โŒ No translation found" + listbox.insert(tk.END, display) + + # Selection count + selection_count_label = tk.Label(tab_frame, text="Selected: 0", font=('Arial', 9)) + selection_count_label.pack(pady=2) + + def update_selection_count(*args): + count = len(listbox.curselection()) + selection_count_label.config(text=f"Selected: {count}") + + listbox.bind('<<ListboxSelect>>', update_selection_count) + + return { + 'type': 'individual_images', + 'listbox': listbox, + 'file_info': file_info, + 'selection_count_label': selection_count_label + } + + + def _create_image_folder_tab(self, folder_path, notebook, parent_dialog): + """Create a tab for image folder retranslation""" + folder_name = os.path.basename(folder_path) + output_dir = f"{folder_name}_translated" + + if not os.path.exists(output_dir): + return None + + # Create tab + tab_frame = tk.Frame(notebook) + tab_name = "๐Ÿ“ " + (folder_name[:17] + "..." if len(folder_name) > 17 else folder_name) + notebook.add(tab_frame, text=tab_name) + + # Instructions + tk.Label(tab_frame, text="Select images to retranslate:", font=('Arial', 11)).pack(pady=5) + + # Main frame + main_frame = tk.Frame(tab_frame) + main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Scrollbars and listbox + h_scrollbar = ttk.Scrollbar(main_frame, orient=tk.HORIZONTAL) + h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X) + + v_scrollbar = ttk.Scrollbar(main_frame, orient=tk.VERTICAL) + v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + listbox = tk.Listbox( + main_frame, + selectmode=tk.EXTENDED, + yscrollcommand=v_scrollbar.set, + xscrollcommand=h_scrollbar.set, + width=100 + ) + listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + v_scrollbar.config(command=listbox.yview) + h_scrollbar.config(command=listbox.xview) + + # Find files + file_info = [] + + # Add HTML files + for file in os.listdir(output_dir): + if file.startswith('response_'): + # Allow response_{index}_{name}.html and compound extensions like .html.xhtml + match = re.match(r'^response_(\d+)_([^\.]*)\.(?:html?|xhtml|htm)(?:\.xhtml)?$', file, re.IGNORECASE) + if match: + index = match.group(1) + base_name = match.group(2) + display = f"๐Ÿ“„ Image {index} | {base_name} | โœ… Translated" + else: + display = f"๐Ÿ“„ {file} | โœ… Translated" + + listbox.insert(tk.END, display) + file_info.append({ + 'type': 'translated', + 'file': file, + 'path': os.path.join(output_dir, file) + }) + + # Add cover images + images_dir = os.path.join(output_dir, "images") + if os.path.exists(images_dir): + for file in sorted(os.listdir(images_dir)): + if file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')): + display = f"๐Ÿ–ผ๏ธ Cover | {file} | โญ๏ธ Skipped" + listbox.insert(tk.END, display) + file_info.append({ + 'type': 'cover', + 'file': file, + 'path': os.path.join(images_dir, file) + }) + + # Selection count + selection_count_label = tk.Label(tab_frame, text="Selected: 0", font=('Arial', 9)) + selection_count_label.pack(pady=2) + + def update_selection_count(*args): + count = len(listbox.curselection()) + selection_count_label.config(text=f"Selected: {count}") + + listbox.bind('<<ListboxSelect>>', update_selection_count) + + return { + 'type': 'image_folder', + 'folder_path': folder_path, + 'output_dir': output_dir, + 'listbox': listbox, + 'file_info': file_info, + 'selection_count_label': selection_count_label + } + + + def _force_retranslation_images_folder(self, folder_path): + """Handle force retranslation for image folders""" + folder_name = os.path.basename(folder_path) + + # Look for output folder in the SCRIPT'S directory, not relative to the selected folder + script_dir = os.getcwd() # Current working directory where the script is running + + # Check multiple possible output folder patterns IN THE SCRIPT DIRECTORY + possible_output_dirs = [ + os.path.join(script_dir, folder_name), # Script dir + folder name + os.path.join(script_dir, f"{folder_name}_translated"), # Script dir + folder_translated + folder_name, # Just the folder name in current directory + f"{folder_name}_translated", # folder_translated in current directory + ] + + output_dir = None + for possible_dir in possible_output_dirs: + print(f"Checking: {possible_dir}") + if os.path.exists(possible_dir): + # Check if it has translation_progress.json or HTML files + if os.path.exists(os.path.join(possible_dir, "translation_progress.json")): + output_dir = possible_dir + print(f"Found output directory with progress tracker: {output_dir}") + break + # Check if it has any HTML files + elif os.path.isdir(possible_dir): + try: + files = os.listdir(possible_dir) + if any(f.lower().endswith(('.html', '.xhtml', '.htm')) for f in files): + output_dir = possible_dir + print(f"Found output directory with HTML files: {output_dir}") + break + except: + pass + + if not output_dir: + messagebox.showinfo("Info", + f"No translation output found for '{folder_name}'.\n\n" + f"Selected folder: {folder_path}\n" + f"Script directory: {script_dir}\n\n" + f"Checked locations:\n" + "\n".join(f"- {d}" for d in possible_output_dirs)) + return + + print(f"Using output directory: {output_dir}") + + # Check for progress tracking file + progress_file = os.path.join(output_dir, "translation_progress.json") + has_progress_tracking = os.path.exists(progress_file) + + print(f"Progress tracking: {has_progress_tracking} at {progress_file}") + + # Find all HTML files in the output directory + html_files = [] + image_files = [] + progress_data = None + + if has_progress_tracking: + # Load progress data for image translations + try: + with open(progress_file, 'r', encoding='utf-8') as f: + progress_data = json.load(f) + print(f"Loaded progress data with {len(progress_data)} entries") + + # Extract files from progress data + # The structure appears to use hash keys at the root level + for key, value in progress_data.items(): + if isinstance(value, dict) and 'output_file' in value: + output_file = value['output_file'] + # Handle both forward and backslashes in paths + output_file = output_file.replace('\\', '/') + if '/' in output_file: + output_file = os.path.basename(output_file) + html_files.append(output_file) + print(f"Found tracked file: {output_file}") + except Exception as e: + print(f"Error loading progress file: {e}") + import traceback + traceback.print_exc() + has_progress_tracking = False + + # Also scan directory for any HTML files not in progress + try: + for file in os.listdir(output_dir): + file_path = os.path.join(output_dir, file) + if os.path.isfile(file_path) and file.endswith('.html') and file not in html_files: + html_files.append(file) + print(f"Found untracked HTML file: {file}") + except Exception as e: + print(f"Error scanning directory: {e}") + + # Check for images subdirectory (cover images) + images_dir = os.path.join(output_dir, "images") + if os.path.exists(images_dir): + try: + for file in os.listdir(images_dir): + if file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')): + image_files.append(file) + except Exception as e: + print(f"Error scanning images directory: {e}") + + print(f"Total files found: {len(html_files)} HTML, {len(image_files)} images") + + if not html_files and not image_files: + messagebox.showinfo("Info", + f"No translated files found in: {output_dir}\n\n" + f"Progress tracking: {'Yes' if has_progress_tracking else 'No'}") + return + + # Create dialog + dialog = self.wm.create_simple_dialog( + self.master, + "Force Retranslation - Images", + width=800, + height=600 + ) + + # Add instructions with more detail + instruction_text = f"Output folder: {output_dir}\n" + instruction_text += f"Found {len(html_files)} translated images and {len(image_files)} cover images" + if has_progress_tracking: + instruction_text += " (with progress tracking)" + tk.Label(dialog, text=instruction_text, font=('Arial', 11), justify=tk.LEFT).pack(pady=10) + + # Create main frame for listbox and scrollbars + main_frame = tk.Frame(dialog) + main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) + + # Create scrollbars + h_scrollbar = ttk.Scrollbar(main_frame, orient=tk.HORIZONTAL) + h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X) + + v_scrollbar = ttk.Scrollbar(main_frame, orient=tk.VERTICAL) + v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # Create listbox + listbox = tk.Listbox( + main_frame, + selectmode=tk.EXTENDED, + yscrollcommand=v_scrollbar.set, + xscrollcommand=h_scrollbar.set, + width=100 + ) + listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Configure scrollbars + v_scrollbar.config(command=listbox.yview) + h_scrollbar.config(command=listbox.xview) + + # Keep track of file info + file_info = [] + + # Add translated HTML files + for html_file in sorted(set(html_files)): # Use set to avoid duplicates + # Extract original image name from HTML filename + # Expected format: response_001_imagename.html + match = re.match(r'response_(\d+)_(.+)\.html', html_file) + if match: + index = match.group(1) + base_name = match.group(2) + display = f"๐Ÿ“„ Image {index} | {base_name} | โœ… Translated" + else: + display = f"๐Ÿ“„ {html_file} | โœ… Translated" + + listbox.insert(tk.END, display) + + # Find the hash key for this file if progress tracking exists + hash_key = None + if progress_data: + for key, value in progress_data.items(): + if isinstance(value, dict) and 'output_file' in value: + if html_file in value['output_file']: + hash_key = key + break + + file_info.append({ + 'type': 'translated', + 'file': html_file, + 'path': os.path.join(output_dir, html_file), + 'hash_key': hash_key, + 'output_dir': output_dir # Store for later use + }) + + # Add cover images + for img_file in sorted(image_files): + display = f"๐Ÿ–ผ๏ธ Cover | {img_file} | โญ๏ธ Skipped (cover)" + listbox.insert(tk.END, display) + file_info.append({ + 'type': 'cover', + 'file': img_file, + 'path': os.path.join(images_dir, img_file), + 'hash_key': None, + 'output_dir': output_dir + }) + + # Selection count label + selection_count_label = tk.Label(dialog, text="Selected: 0", font=('Arial', 10)) + selection_count_label.pack(pady=(5, 10)) + + def update_selection_count(*args): + count = len(listbox.curselection()) + selection_count_label.config(text=f"Selected: {count}") + + listbox.bind('<<ListboxSelect>>', update_selection_count) + + # Button frame + button_frame = tk.Frame(dialog) + button_frame.pack(pady=10) + + # Configure grid columns + for i in range(4): + button_frame.columnconfigure(i, weight=1) + + def select_all(): + listbox.select_set(0, tk.END) + update_selection_count() + + def clear_selection(): + listbox.select_clear(0, tk.END) + update_selection_count() + + def select_translated(): + listbox.select_clear(0, tk.END) + for idx, info in enumerate(file_info): + if info['type'] == 'translated': + listbox.select_set(idx) + update_selection_count() + + def mark_as_skipped(): + """Move selected images to the images folder to be skipped""" + selected = listbox.curselection() + if not selected: + messagebox.showwarning("No Selection", "Please select at least one image to mark as skipped.") + return + + # Get all selected items + selected_items = [(i, file_info[i]) for i in selected] + + # Filter out items already in images folder (covers) + items_to_move = [(i, item) for i, item in selected_items if item['type'] != 'cover'] + + if not items_to_move: + messagebox.showinfo("Info", "Selected items are already in the images folder (skipped).") + return + + count = len(items_to_move) + if not messagebox.askyesno("Confirm Mark as Skipped", + f"Move {count} translated image(s) to the images folder?\n\n" + "This will:\n" + "โ€ข Delete the translated HTML files\n" + "โ€ข Copy source images to the images folder\n" + "โ€ข Skip these images in future translations"): + return + + # Create images directory if it doesn't exist + images_dir = os.path.join(output_dir, "images") + os.makedirs(images_dir, exist_ok=True) + + moved_count = 0 + failed_count = 0 + + for idx, item in items_to_move: + try: + # Extract the original image name from the HTML filename + # Expected format: response_001_imagename.html (also accept compound extensions) + html_file = item['file'] + match = re.match(r'^response_\d+_([^\.]*)\.(?:html?|xhtml|htm)(?:\.xhtml)?$', html_file, re.IGNORECASE) + + if match: + base_name = match.group(1) + # Try to find the original image with common extensions + original_found = False + + for ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']: + # Check in the parent folder (where source images are) + possible_source = os.path.join(folder_path, base_name + ext) + if os.path.exists(possible_source): + # Copy to images folder + dest_path = os.path.join(images_dir, base_name + ext) + if not os.path.exists(dest_path): + import shutil + shutil.copy2(possible_source, dest_path) + print(f"Copied {base_name + ext} to images folder") + original_found = True + break + + if not original_found: + print(f"Warning: Could not find original image for {html_file}") + + # Delete the HTML translation file + if os.path.exists(item['path']): + os.remove(item['path']) + print(f"Deleted translation: {item['path']}") + + # Remove from progress tracking if applicable + if progress_data and item.get('hash_key') and item['hash_key'] in progress_data: + del progress_data[item['hash_key']] + + # Update the listbox display + display = f"๐Ÿ–ผ๏ธ Skipped | {base_name if match else item['file']} | โญ๏ธ Moved to images folder" + listbox.delete(idx) + listbox.insert(idx, display) + + # Update file_info + file_info[idx] = { + 'type': 'cover', # Treat as cover type since it's in images folder + 'file': base_name + ext if match and original_found else item['file'], + 'path': os.path.join(images_dir, base_name + ext if match and original_found else item['file']), + 'hash_key': None, + 'output_dir': output_dir + } + + moved_count += 1 + + except Exception as e: + print(f"Failed to process {item['file']}: {e}") + failed_count += 1 + + # Save updated progress if modified + if progress_data: + try: + with open(progress_file, 'w', encoding='utf-8') as f: + json.dump(progress_data, f, ensure_ascii=False, indent=2) + print(f"Updated progress tracking file") + except Exception as e: + print(f"Failed to update progress file: {e}") + + # Update selection count + update_selection_count() + + # Show result + if failed_count > 0: + messagebox.showwarning("Partial Success", + f"Moved {moved_count} image(s) to be skipped.\n" + f"Failed to process {failed_count} item(s).") + else: + messagebox.showinfo("Success", + f"Moved {moved_count} image(s) to the images folder.\n" + "They will be skipped in future translations.") + + def retranslate_selected(): + selected = listbox.curselection() + if not selected: + messagebox.showwarning("No Selection", "Please select at least one file.") + return + + # Count types + translated_count = sum(1 for i in selected if file_info[i]['type'] == 'translated') + cover_count = sum(1 for i in selected if file_info[i]['type'] == 'cover') + + # Build confirmation message + msg_parts = [] + if translated_count > 0: + msg_parts.append(f"{translated_count} translated image(s)") + if cover_count > 0: + msg_parts.append(f"{cover_count} cover image(s)") + + confirm_msg = f"This will delete {' and '.join(msg_parts)}.\n\nContinue?" + + if not messagebox.askyesno("Confirm Deletion", confirm_msg): + return + + # Delete selected files + deleted_count = 0 + progress_updated = False + + for idx in selected: + info = file_info[idx] + try: + if os.path.exists(info['path']): + os.remove(info['path']) + deleted_count += 1 + print(f"Deleted: {info['path']}") + + # Remove from progress tracking if applicable + if progress_data and info['hash_key'] and info['hash_key'] in progress_data: + del progress_data[info['hash_key']] + progress_updated = True + + except Exception as e: + print(f"Failed to delete {info['path']}: {e}") + + # Save updated progress if modified + if progress_updated and progress_data: + try: + with open(progress_file, 'w', encoding='utf-8') as f: + json.dump(progress_data, f, ensure_ascii=False, indent=2) + print(f"Updated progress tracking file") + except Exception as e: + print(f"Failed to update progress file: {e}") + + messagebox.showinfo("Success", + f"Deleted {deleted_count} file(s).\n\n" + "They will be retranslated on the next run.") + + dialog.destroy() + + # Add buttons in grid layout (similar to EPUB/text retranslation) + # Row 0: Selection buttons + tb.Button(button_frame, text="Select All", command=select_all, + bootstyle="info").grid(row=0, column=0, padx=5, pady=5, sticky="ew") + tb.Button(button_frame, text="Clear Selection", command=clear_selection, + bootstyle="secondary").grid(row=0, column=1, padx=5, pady=5, sticky="ew") + tb.Button(button_frame, text="Select Translated", command=select_translated, + bootstyle="success").grid(row=0, column=2, padx=5, pady=5, sticky="ew") + tb.Button(button_frame, text="Mark as Skipped", command=mark_as_skipped, + bootstyle="warning").grid(row=0, column=3, padx=5, pady=5, sticky="ew") + + # Row 1: Action buttons + tb.Button(button_frame, text="Delete Selected", command=retranslate_selected, + bootstyle="danger").grid(row=1, column=0, columnspan=2, padx=5, pady=10, sticky="ew") + tb.Button(button_frame, text="Cancel", command=dialog.destroy, + bootstyle="secondary").grid(row=1, column=2, columnspan=2, padx=5, pady=10, sticky="ew") + + def glossary_manager(self): + """Open comprehensive glossary management dialog""" + # Create scrollable dialog (stays hidden) + dialog, scrollable_frame, canvas = self.wm.setup_scrollable( + self.master, + "Glossary Manager", + width=0, # Will be auto-sized + height=None, + max_width_ratio=0.9, + max_height_ratio=0.85 + ) + + # Create notebook for tabs + notebook = ttk.Notebook(scrollable_frame) + notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # Create and add tabs + tabs = [ + ("Manual Glossary Extraction", self._setup_manual_glossary_tab), + ("Automatic Glossary Generation", self._setup_auto_glossary_tab), + ("Glossary Editor", self._setup_glossary_editor_tab) + ] + + for tab_name, setup_method in tabs: + frame = ttk.Frame(notebook) + notebook.add(frame, text=tab_name) + setup_method(frame) + + # Dialog Controls + control_frame = tk.Frame(dialog) + control_frame.pack(fill=tk.X, padx=10, pady=10) + + def save_glossary_settings(): + try: + # Update prompts from text widgets + self.update_glossary_prompts() + + # Save custom fields + self.config['custom_glossary_fields'] = self.custom_glossary_fields + + # Update enabled status from checkboxes + if hasattr(self, 'type_enabled_vars'): + for type_name, var in self.type_enabled_vars.items(): + if type_name in self.custom_entry_types: + self.custom_entry_types[type_name]['enabled'] = var.get() + + # Save custom entry types + self.config['custom_entry_types'] = self.custom_entry_types + + # Save all glossary-related settings + self.config['enable_auto_glossary'] = self.enable_auto_glossary_var.get() + self.config['append_glossary'] = self.append_glossary_var.get() + self.config['glossary_min_frequency'] = int(self.glossary_min_frequency_var.get()) + self.config['glossary_max_names'] = int(self.glossary_max_names_var.get()) + self.config['glossary_max_titles'] = int(self.glossary_max_titles_var.get()) + self.config['glossary_batch_size'] = int(self.glossary_batch_size_var.get()) + self.config['glossary_format_instructions'] = getattr(self, 'glossary_format_instructions', '') + self.config['glossary_max_text_size'] = self.glossary_max_text_size_var.get() + self.config['glossary_max_sentences'] = int(self.glossary_max_sentences_var.get()) + + + # Honorifics and other settings + if hasattr(self, 'strip_honorifics_var'): + self.config['strip_honorifics'] = self.strip_honorifics_var.get() + if hasattr(self, 'disable_honorifics_var'): + self.config['glossary_disable_honorifics_filter'] = self.disable_honorifics_var.get() + + # Save format preference + if hasattr(self, 'use_legacy_csv_var'): + self.config['glossary_use_legacy_csv'] = self.use_legacy_csv_var.get() + + # Temperature and context limit + try: + self.config['manual_glossary_temperature'] = float(self.manual_temp_var.get()) + self.config['manual_context_limit'] = int(self.manual_context_var.get()) + except ValueError: + messagebox.showwarning("Invalid Input", + "Please enter valid numbers for temperature and context limit") + return + + # Fuzzy matching threshold + self.config['glossary_fuzzy_threshold'] = self.fuzzy_threshold_var.get() + + # Save prompts + self.config['manual_glossary_prompt'] = self.manual_glossary_prompt + self.config['auto_glossary_prompt'] = self.auto_glossary_prompt + self.config['append_glossary_prompt'] = self.append_glossary_prompt + self.config['glossary_translation_prompt'] = getattr(self, 'glossary_translation_prompt', '') + + # Update environment variables for immediate use + os.environ['GLOSSARY_SYSTEM_PROMPT'] = self.manual_glossary_prompt + os.environ['AUTO_GLOSSARY_PROMPT'] = self.auto_glossary_prompt + os.environ['GLOSSARY_DISABLE_HONORIFICS_FILTER'] = '1' if self.disable_honorifics_var.get() else '0' + os.environ['GLOSSARY_STRIP_HONORIFICS'] = '1' if self.strip_honorifics_var.get() else '0' + os.environ['GLOSSARY_FUZZY_THRESHOLD'] = str(self.fuzzy_threshold_var.get()) + os.environ['GLOSSARY_TRANSLATION_PROMPT'] = getattr(self, 'glossary_translation_prompt', '') + os.environ['GLOSSARY_FORMAT_INSTRUCTIONS'] = getattr(self, 'glossary_format_instructions', '') + os.environ['GLOSSARY_USE_LEGACY_CSV'] = '1' if self.use_legacy_csv_var.get() else '0' + os.environ['GLOSSARY_MAX_SENTENCES'] = str(self.glossary_max_sentences_var.get()) + + # Set custom entry types and fields as environment variables + os.environ['GLOSSARY_CUSTOM_ENTRY_TYPES'] = json.dumps(self.custom_entry_types) + if self.custom_glossary_fields: + os.environ['GLOSSARY_CUSTOM_FIELDS'] = json.dumps(self.custom_glossary_fields) + + # Save config using the main save_config method to ensure encryption + self.save_config(show_message=False) + + self.append_log("โœ… Glossary settings saved successfully") + + # Check if any types are enabled + enabled_types = [t for t, cfg in self.custom_entry_types.items() if cfg.get('enabled', True)] + if not enabled_types: + messagebox.showwarning("Warning", "No entry types selected! The glossary extraction will not find any entries.") + else: + self.append_log(f"๐Ÿ“‘ Enabled types: {', '.join(enabled_types)}") + + messagebox.showinfo("Success", "Glossary settings saved!") + dialog.destroy() + + except Exception as e: + messagebox.showerror("Error", f"Failed to save settings: {e}") + self.append_log(f"โŒ Failed to save glossary settings: {e}") + + # Create button container + button_container = tk.Frame(control_frame) + button_container.pack(expand=True) + + # Add buttons + tb.Button( + button_container, + text="Save All Settings", + command=save_glossary_settings, + bootstyle="success", + width=20 + ).pack(side=tk.LEFT, padx=5) + + tb.Button( + button_container, + text="Cancel", + command=lambda: [dialog._cleanup_scrolling(), dialog.destroy()], + bootstyle="secondary", + width=20 + ).pack(side=tk.LEFT, padx=5) + + # Auto-resize and show + self.wm.auto_resize_dialog(dialog, canvas, max_width_ratio=0.9, max_height_ratio=1.5) + + dialog.protocol("WM_DELETE_WINDOW", + lambda: [dialog._cleanup_scrolling(), dialog.destroy()]) + + def _setup_manual_glossary_tab(self, parent): + """Setup manual glossary tab - simplified for new format""" + manual_container = tk.Frame(parent) + manual_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # Type filtering section with custom types + type_filter_frame = tk.LabelFrame(manual_container, text="Entry Type Configuration", padx=10, pady=10) + type_filter_frame.pack(fill=tk.X, pady=(0, 10)) + + # Initialize custom entry types if not exists + if not hasattr(self, 'custom_entry_types'): + # Default types with their enabled status + self.custom_entry_types = self.config.get('custom_entry_types', { + 'character': {'enabled': True, 'has_gender': True}, + 'term': {'enabled': True, 'has_gender': False} + }) + + # Main container with grid for better control + type_main_container = tk.Frame(type_filter_frame) + type_main_container.pack(fill=tk.X) + type_main_container.grid_columnconfigure(0, weight=3) # Left side gets 3/5 of space + type_main_container.grid_columnconfigure(1, weight=2) # Right side gets 2/5 of space + + # Left side - type list with checkboxes + type_list_frame = tk.Frame(type_main_container) + type_list_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 15)) + + tk.Label(type_list_frame, text="Active Entry Types:", + font=('TkDefaultFont', 10, 'bold')).pack(anchor=tk.W) + + # Scrollable frame for type checkboxes + type_scroll_frame = tk.Frame(type_list_frame) + type_scroll_frame.pack(fill=tk.BOTH, expand=True, pady=(5, 0)) + + type_canvas = tk.Canvas(type_scroll_frame, height=150) + type_scrollbar = ttk.Scrollbar(type_scroll_frame, orient="vertical", command=type_canvas.yview) + self.type_checkbox_frame = tk.Frame(type_canvas) + + type_canvas.configure(yscrollcommand=type_scrollbar.set) + type_canvas_window = type_canvas.create_window((0, 0), window=self.type_checkbox_frame, anchor="nw") + + type_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + type_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # Store checkbox variables + self.type_enabled_vars = {} + + def update_type_checkboxes(): + """Rebuild the checkbox list""" + # Clear existing checkboxes + for widget in self.type_checkbox_frame.winfo_children(): + widget.destroy() + + # Sort types: built-in first, then custom alphabetically + sorted_types = sorted(self.custom_entry_types.items(), + key=lambda x: (x[0] not in ['character', 'term'], x[0])) + + # Create checkboxes for each type + for type_name, type_config in sorted_types: + var = tk.BooleanVar(value=type_config.get('enabled', True)) + self.type_enabled_vars[type_name] = var + + frame = tk.Frame(self.type_checkbox_frame) + frame.pack(fill=tk.X, pady=2) + + # Checkbox + cb = tb.Checkbutton(frame, text=type_name, variable=var, + bootstyle="round-toggle") + cb.pack(side=tk.LEFT) + + # Add gender indicator for types that support it + if type_config.get('has_gender', False): + tk.Label(frame, text="(has gender field)", + font=('TkDefaultFont', 9), fg='gray').pack(side=tk.LEFT, padx=(10, 0)) + + # Delete button for custom types + if type_name not in ['character', 'term']: + tb.Button(frame, text="ร—", command=lambda t=type_name: remove_type(t), + bootstyle="danger", width=3).pack(side=tk.RIGHT, padx=(5, 0)) + + # Update canvas scroll region + self.type_checkbox_frame.update_idletasks() + type_canvas.configure(scrollregion=type_canvas.bbox("all")) + + # Right side - controls for adding custom types + type_control_frame = tk.Frame(type_main_container) + type_control_frame.grid(row=0, column=1, sticky="nsew") + + tk.Label(type_control_frame, text="Add Custom Type:", + font=('TkDefaultFont', 10, 'bold')).pack(anchor=tk.W) + + # Entry for new type field + new_type_frame = tk.Frame(type_control_frame) + new_type_frame.pack(fill=tk.X, pady=(5, 0)) + + tk.Label(new_type_frame, text="Type Field:").pack(anchor=tk.W) + new_type_entry = tb.Entry(new_type_frame) + new_type_entry.pack(fill=tk.X, pady=(2, 0)) + + # Checkbox for gender field + has_gender_var = tk.BooleanVar(value=False) + tb.Checkbutton(new_type_frame, text="Include gender field", + variable=has_gender_var).pack(anchor=tk.W, pady=(5, 0)) + + def add_custom_type(): + type_name = new_type_entry.get().strip().lower() + if not type_name: + messagebox.showwarning("Invalid Input", "Please enter a type name") + return + + if type_name in self.custom_entry_types: + messagebox.showwarning("Duplicate Type", f"Type '{type_name}' already exists") + return + + # Add the new type + self.custom_entry_types[type_name] = { + 'enabled': True, + 'has_gender': has_gender_var.get() + } + + # Clear inputs + new_type_entry.delete(0, tk.END) + has_gender_var.set(False) + + # Update display + update_type_checkboxes() + self.append_log(f"โœ… Added custom type: {type_name}") + + def remove_type(type_name): + if type_name in ['character', 'term']: + messagebox.showwarning("Cannot Remove", "Built-in types cannot be removed") + return + + if messagebox.askyesno("Confirm Removal", f"Remove type '{type_name}'?"): + del self.custom_entry_types[type_name] + if type_name in self.type_enabled_vars: + del self.type_enabled_vars[type_name] + update_type_checkboxes() + self.append_log(f"๐Ÿ—‘๏ธ Removed custom type: {type_name}") + + tb.Button(new_type_frame, text="Add Type", command=add_custom_type, + bootstyle="success").pack(fill=tk.X, pady=(10, 0)) + + # Initialize checkboxes + update_type_checkboxes() + + # Custom fields section + custom_frame = tk.LabelFrame(manual_container, text="Custom Fields (Additional Columns)", padx=10, pady=10) + custom_frame.pack(fill=tk.X, pady=(0, 10)) + + custom_list_frame = tk.Frame(custom_frame) + custom_list_frame.pack(fill=tk.X) + + tk.Label(custom_list_frame, text="Additional fields to extract (will be added as extra columns):").pack(anchor=tk.W) + + custom_scroll = ttk.Scrollbar(custom_list_frame) + custom_scroll.pack(side=tk.RIGHT, fill=tk.Y) + + self.custom_fields_listbox = tk.Listbox(custom_list_frame, height=4, + yscrollcommand=custom_scroll.set) + self.custom_fields_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + custom_scroll.config(command=self.custom_fields_listbox.yview) + + # Initialize custom_glossary_fields if not exists + if not hasattr(self, 'custom_glossary_fields'): + self.custom_glossary_fields = self.config.get('custom_glossary_fields', []) + + for field in self.custom_glossary_fields: + self.custom_fields_listbox.insert(tk.END, field) + + custom_controls = tk.Frame(custom_frame) + custom_controls.pack(fill=tk.X, pady=(5, 0)) + + self.custom_field_entry = tb.Entry(custom_controls, width=30) + self.custom_field_entry.pack(side=tk.LEFT, padx=(0, 5)) + + def add_custom_field(): + field = self.custom_field_entry.get().strip() + if field and field not in self.custom_glossary_fields: + self.custom_glossary_fields.append(field) + self.custom_fields_listbox.insert(tk.END, field) + self.custom_field_entry.delete(0, tk.END) + + def remove_custom_field(): + selection = self.custom_fields_listbox.curselection() + if selection: + idx = selection[0] + field = self.custom_fields_listbox.get(idx) + self.custom_glossary_fields.remove(field) + self.custom_fields_listbox.delete(idx) + + tb.Button(custom_controls, text="Add", command=add_custom_field, width=10).pack(side=tk.LEFT, padx=2) + tb.Button(custom_controls, text="Remove", command=remove_custom_field, width=10).pack(side=tk.LEFT, padx=2) + + # Duplicate Detection Settings + duplicate_frame = tk.LabelFrame(manual_container, text="Duplicate Detection", padx=10, pady=10) + duplicate_frame.pack(fill=tk.X, pady=(0, 10)) + + # Honorifics filter toggle + if not hasattr(self, 'disable_honorifics_var'): + self.disable_honorifics_var = tk.BooleanVar(value=self.config.get('glossary_disable_honorifics_filter', False)) + + tb.Checkbutton(duplicate_frame, text="Disable honorifics filtering", + variable=self.disable_honorifics_var, + bootstyle="round-toggle").pack(anchor=tk.W) + + tk.Label(duplicate_frame, text="When enabled, honorifics (๋‹˜, ใ•ใ‚“, ๅ…ˆ็”Ÿ, etc.) will NOT be removed from raw names", + font=('TkDefaultFont', 9), fg='gray').pack(anchor=tk.W, padx=20, pady=(0, 5)) + + # Fuzzy matching slider + fuzzy_frame = tk.Frame(duplicate_frame) + fuzzy_frame.pack(fill=tk.X, pady=(10, 0)) + + tk.Label(fuzzy_frame, text="Fuzzy Matching Threshold:", + font=('TkDefaultFont', 10, 'bold')).pack(anchor=tk.W) + + tk.Label(fuzzy_frame, text="Controls how similar names must be to be considered duplicates", + font=('TkDefaultFont', 9), fg='gray').pack(anchor=tk.W, pady=(0, 5)) + + # Slider frame + slider_frame = tk.Frame(fuzzy_frame) + slider_frame.pack(fill=tk.X, pady=(5, 0)) + + # Initialize fuzzy threshold variable + if not hasattr(self, 'fuzzy_threshold_var'): + self.fuzzy_threshold_var = tk.DoubleVar(value=self.config.get('glossary_fuzzy_threshold', 0.90)) + + # Slider + fuzzy_slider = tb.Scale( + slider_frame, + from_=0.5, + to=1.0, + orient=tk.HORIZONTAL, + variable=self.fuzzy_threshold_var, + style="info.Horizontal.TScale", + length=300 + ) + fuzzy_slider.pack(side=tk.LEFT, padx=(0, 10)) + + # Value label + self.fuzzy_value_label = tk.Label(slider_frame, text=f"{self.fuzzy_threshold_var.get():.2f}") + self.fuzzy_value_label.pack(side=tk.LEFT) + + # Description label - CREATE THIS FIRST + fuzzy_desc_label = tk.Label(fuzzy_frame, text="", font=('TkDefaultFont', 9), fg='blue') + fuzzy_desc_label.pack(anchor=tk.W, pady=(5, 0)) + + # Token-efficient format toggle + format_frame = tk.LabelFrame(manual_container, text="Output Format", padx=10, pady=10) + format_frame.pack(fill=tk.X, pady=(0, 10)) + + # Initialize variable if not exists + if not hasattr(self, 'use_legacy_csv_var'): + self.use_legacy_csv_var = tk.BooleanVar(value=self.config.get('glossary_use_legacy_csv', False)) + + tb.Checkbutton(format_frame, text="Use legacy CSV format", + variable=self.use_legacy_csv_var, + bootstyle="round-toggle").pack(anchor=tk.W) + + tk.Label(format_frame, text="When disabled (default): Uses token-efficient format with sections (=== CHARACTERS ===)", + font=('TkDefaultFont', 9), fg='gray').pack(anchor=tk.W, padx=20, pady=(0, 5)) + + tk.Label(format_frame, text="When enabled: Uses traditional CSV format with repeated type columns", + font=('TkDefaultFont', 9), fg='gray').pack(anchor=tk.W, padx=20) + + # Update label when slider moves - DEFINE AFTER CREATING THE LABEL + def update_fuzzy_label(*args): + try: + # Check if widgets still exist before updating + if not fuzzy_desc_label.winfo_exists(): + return + if not self.fuzzy_value_label.winfo_exists(): + return + + value = self.fuzzy_threshold_var.get() + self.fuzzy_value_label.config(text=f"{value:.2f}") + + # Show description + if value >= 0.95: + desc = "Exact match only (strict)" + elif value >= 0.85: + desc = "Very similar names (recommended)" + elif value >= 0.75: + desc = "Moderately similar names" + elif value >= 0.65: + desc = "Loosely similar names" + else: + desc = "Very loose matching (may over-merge)" + + fuzzy_desc_label.config(text=desc) + except tk.TclError: + # Widget was destroyed, ignore + pass + except Exception as e: + # Catch any other unexpected errors + print(f"Error updating fuzzy label: {e}") + pass + + # Remove any existing trace before adding a new one + if hasattr(self, 'manual_fuzzy_trace_id'): + try: + self.fuzzy_threshold_var.trace_remove('write', self.manual_fuzzy_trace_id) + except: + pass + + # Set up the trace AFTER creating the label and store the trace ID + self.manual_fuzzy_trace_id = self.fuzzy_threshold_var.trace('w', update_fuzzy_label) + + # Initialize description by calling the function + try: + update_fuzzy_label() + except: + # If initialization fails, just continue + pass + + # Prompt section (continues as before) + prompt_frame = tk.LabelFrame(manual_container, text="Extraction Prompt", padx=10, pady=10) + prompt_frame.pack(fill=tk.BOTH, expand=True) + + tk.Label(prompt_frame, text="Use {fields} for field list and {chapter_text} for content placeholder", + font=('TkDefaultFont', 9), fg='blue').pack(anchor=tk.W, pady=(0, 5)) + + tk.Label(prompt_frame, text="The {fields} placeholder will be replaced with the format specification", + font=('TkDefaultFont', 9), fg='gray').pack(anchor=tk.W, pady=(0, 5)) + + self.manual_prompt_text = self.ui.setup_scrollable_text( + prompt_frame, height=13, wrap=tk.WORD + ) + self.manual_prompt_text.pack(fill=tk.BOTH, expand=True) + + # Set default prompt if not already set + if not hasattr(self, 'manual_glossary_prompt') or not self.manual_glossary_prompt: + self.manual_glossary_prompt = """Extract character names and important terms from the following text. + +Output format: +{fields} + +Rules: +- Output ONLY CSV lines in the exact format shown above +- No headers, no extra text, no JSON +- One entry per line +- Leave gender empty for terms (just end with comma) + """ + + self.manual_prompt_text.insert('1.0', self.manual_glossary_prompt) + self.manual_prompt_text.edit_reset() + + prompt_controls = tk.Frame(manual_container) + prompt_controls.pack(fill=tk.X, pady=(10, 0)) + + def reset_manual_prompt(): + if messagebox.askyesno("Reset Prompt", "Reset manual glossary prompt to default?"): + self.manual_prompt_text.delete('1.0', tk.END) + default_prompt = """Extract character names and important terms from the following text. + + Output format: + {fields} + + Rules: + - Output ONLY CSV lines in the exact format shown above + - No headers, no extra text, no JSON + - One entry per line + - Leave gender empty for terms (just end with comma) + """ + self.manual_prompt_text.insert('1.0', default_prompt) + + tb.Button(prompt_controls, text="Reset to Default", command=reset_manual_prompt, + bootstyle="warning").pack(side=tk.LEFT, padx=5) + + # Settings + settings_frame = tk.LabelFrame(manual_container, text="Extraction Settings", padx=10, pady=10) + settings_frame.pack(fill=tk.X, pady=(10, 0)) + + settings_grid = tk.Frame(settings_frame) + settings_grid.pack() + + tk.Label(settings_grid, text="Temperature:").grid(row=0, column=0, sticky=tk.W, padx=5) + self.manual_temp_var = tk.StringVar(value=str(self.config.get('manual_glossary_temperature', 0.1))) + tb.Entry(settings_grid, textvariable=self.manual_temp_var, width=10).grid(row=0, column=1, padx=5) + + tk.Label(settings_grid, text="Context Limit:").grid(row=0, column=2, sticky=tk.W, padx=5) + self.manual_context_var = tk.StringVar(value=str(self.config.get('manual_context_limit', 2))) + tb.Entry(settings_grid, textvariable=self.manual_context_var, width=10).grid(row=0, column=3, padx=5) + + tk.Label(settings_grid, text="Rolling Window:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=(10, 0)) + tb.Checkbutton(settings_grid, text="Keep recent context instead of reset", + variable=self.glossary_history_rolling_var, + bootstyle="round-toggle").grid(row=1, column=1, columnspan=3, sticky=tk.W, padx=5, pady=(10, 0)) + + tk.Label(settings_grid, text="When context limit is reached, keep recent chapters instead of clearing all history", + font=('TkDefaultFont', 11), fg='gray').grid(row=2, column=0, columnspan=4, sticky=tk.W, padx=20, pady=(0, 5)) + + def update_glossary_prompts(self): + """Update glossary prompts from text widgets if they exist""" + try: + if hasattr(self, 'manual_prompt_text'): + self.manual_glossary_prompt = self.manual_prompt_text.get('1.0', tk.END).strip() + + if hasattr(self, 'auto_prompt_text'): + self.auto_glossary_prompt = self.auto_prompt_text.get('1.0', tk.END).strip() + + if hasattr(self, 'append_prompt_text'): + self.append_glossary_prompt = self.append_prompt_text.get('1.0', tk.END).strip() + + if hasattr(self, 'translation_prompt_text'): + self.glossary_translation_prompt = self.translation_prompt_text.get('1.0', tk.END).strip() + + if hasattr(self, 'format_instructions_text'): + self.glossary_format_instructions = self.format_instructions_text.get('1.0', tk.END).strip() + + except Exception as e: + print(f"Error updating glossary prompts: {e}") + + def _setup_auto_glossary_tab(self, parent): + """Setup automatic glossary tab with fully configurable prompts""" + auto_container = tk.Frame(parent) + auto_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # Master toggle + master_toggle_frame = tk.Frame(auto_container) + master_toggle_frame.pack(fill=tk.X, pady=(0, 15)) + + tb.Checkbutton(master_toggle_frame, text="Enable Automatic Glossary Generation", + variable=self.enable_auto_glossary_var, + bootstyle="round-toggle").pack(side=tk.LEFT) + + tk.Label(master_toggle_frame, text="(Automatic extraction and translation of character names/Terms)", + font=('TkDefaultFont', 9), fg='gray').pack(side=tk.LEFT, padx=(10, 0)) + + # Append glossary toggle + append_frame = tk.Frame(auto_container) + append_frame.pack(fill=tk.X, pady=(0, 15)) + + tb.Checkbutton(append_frame, text="Append Glossary to System Prompt", + variable=self.append_glossary_var, + bootstyle="round-toggle").pack(side=tk.LEFT) + + tk.Label(append_frame, text="(Applies to ALL glossaries - manual and automatic)", + font=('TkDefaultFont', 10, 'italic'), fg='blue').pack(side=tk.LEFT, padx=(10, 0)) + + # Custom append prompt section + append_prompt_frame = tk.LabelFrame(auto_container, text="Glossary Append Format", padx=10, pady=10) + append_prompt_frame.pack(fill=tk.X, pady=(0, 15)) + + tk.Label(append_prompt_frame, text="This text will be added before the glossary entries:", + font=('TkDefaultFont', 10)).pack(anchor=tk.W, pady=(0, 5)) + + self.append_prompt_text = self.ui.setup_scrollable_text( + append_prompt_frame, height=2, wrap=tk.WORD + ) + self.append_prompt_text.pack(fill=tk.X) + + # Set default append prompt if not already set + if not hasattr(self, 'append_glossary_prompt') or not self.append_glossary_prompt: + self.append_glossary_prompt = "- Follow this reference glossary for consistent translation (Do not output any raw entries):\n" + + self.append_prompt_text.insert('1.0', self.append_glossary_prompt) + self.append_prompt_text.edit_reset() + + append_prompt_controls = tk.Frame(append_prompt_frame) + append_prompt_controls.pack(fill=tk.X, pady=(5, 0)) + + def reset_append_prompt(): + if messagebox.askyesno("Reset Prompt", "Reset to default glossary append format?"): + self.append_prompt_text.delete('1.0', tk.END) + self.append_prompt_text.insert('1.0', "- Follow this reference glossary for consistent translation (Do not output any raw entries):\n") + + tb.Button(append_prompt_controls, text="Reset to Default", command=reset_append_prompt, + bootstyle="warning").pack(side=tk.LEFT, padx=5) + + # Create notebook for tabs + notebook = ttk.Notebook(auto_container) + notebook.pack(fill=tk.BOTH, expand=True) + + # Tab 1: Extraction Settings + extraction_tab = tk.Frame(notebook) + notebook.add(extraction_tab, text="Extraction Settings") + + # Extraction settings + settings_label_frame = tk.LabelFrame(extraction_tab, text="Targeted Extraction Settings", padx=10, pady=10) + settings_label_frame.pack(fill=tk.X, padx=10, pady=10) + + extraction_grid = tk.Frame(settings_label_frame) + extraction_grid.pack(fill=tk.X) + + # Row 1 + tk.Label(extraction_grid, text="Min frequency:").grid(row=0, column=0, sticky=tk.W, padx=(0, 5)) + tb.Entry(extraction_grid, textvariable=self.glossary_min_frequency_var, width=10).grid(row=0, column=1, sticky=tk.W, padx=(0, 20)) + + tk.Label(extraction_grid, text="Max names:").grid(row=0, column=2, sticky=tk.W, padx=(0, 5)) + tb.Entry(extraction_grid, textvariable=self.glossary_max_names_var, width=10).grid(row=0, column=3, sticky=tk.W) + + # Row 2 + tk.Label(extraction_grid, text="Max titles:").grid(row=1, column=0, sticky=tk.W, padx=(0, 5), pady=(5, 0)) + tb.Entry(extraction_grid, textvariable=self.glossary_max_titles_var, width=10).grid(row=1, column=1, sticky=tk.W, padx=(0, 20), pady=(5, 0)) + + tk.Label(extraction_grid, text="Translation batch:").grid(row=1, column=2, sticky=tk.W, padx=(0, 5), pady=(5, 0)) + tb.Entry(extraction_grid, textvariable=self.glossary_batch_size_var, width=10).grid(row=1, column=3, sticky=tk.W, pady=(5, 0)) + + # Row 3 - Max text size and chapter split + tk.Label(extraction_grid, text="Max text size:").grid(row=3, column=0, sticky=tk.W, padx=(0, 5), pady=(5, 0)) + tb.Entry(extraction_grid, textvariable=self.glossary_max_text_size_var, width=10).grid(row=3, column=1, sticky=tk.W, padx=(0, 20), pady=(5, 0)) + + tk.Label(extraction_grid, text="Chapter split threshold:").grid(row=3, column=2, sticky=tk.W, padx=(0, 5), pady=(5, 0)) + tb.Entry(extraction_grid, textvariable=self.glossary_chapter_split_threshold_var, width=10).grid(row=3, column=3, sticky=tk.W, pady=(5, 0)) + + # Row 4 - Max sentences for glossary + tk.Label(extraction_grid, text="Max sentences:").grid(row=4, column=0, sticky=tk.W, padx=(0, 5), pady=(5, 0)) + tb.Entry(extraction_grid, textvariable=self.glossary_max_sentences_var, width=10).grid(row=4, column=1, sticky=tk.W, padx=(0, 20), pady=(5, 0)) + + tk.Label(extraction_grid, text="(Limit for AI processing)", font=('TkDefaultFont', 9), fg='gray').grid(row=4, column=2, columnspan=2, sticky=tk.W, pady=(5, 0)) + + # Row 5 - Filter mode + tk.Label(extraction_grid, text="Filter mode:").grid(row=5, column=0, sticky=tk.W, padx=(0, 5), pady=(5, 0)) + filter_frame = tk.Frame(extraction_grid) + filter_frame.grid(row=5, column=1, columnspan=3, sticky=tk.W, pady=(5, 0)) + + tb.Radiobutton(filter_frame, text="All names & terms", variable=self.glossary_filter_mode_var, + value="all", bootstyle="info").pack(side=tk.LEFT, padx=(0, 10)) + tb.Radiobutton(filter_frame, text="Names with honorifics only", variable=self.glossary_filter_mode_var, + value="only_with_honorifics", bootstyle="info").pack(side=tk.LEFT, padx=(0, 10)) + tb.Radiobutton(filter_frame, text="Names without honorifics & terms", variable=self.glossary_filter_mode_var, + value="only_without_honorifics", bootstyle="info").pack(side=tk.LEFT) + + # Row 6 - Strip honorifics + tk.Label(extraction_grid, text="Strip honorifics:").grid(row=6, column=0, sticky=tk.W, padx=(0, 5), pady=(5, 0)) + tb.Checkbutton(extraction_grid, text="Remove honorifics from extracted names", + variable=self.strip_honorifics_var, + bootstyle="round-toggle").grid(row=6, column=1, columnspan=3, sticky=tk.W, pady=(5, 0)) + + # Row 7 - Fuzzy matching threshold (reuse existing variable) + tk.Label(extraction_grid, text="Fuzzy threshold:").grid(row=7, column=0, sticky=tk.W, padx=(0, 5), pady=(5, 0)) + + fuzzy_frame = tk.Frame(extraction_grid) + fuzzy_frame.grid(row=7, column=1, columnspan=3, sticky=tk.W, pady=(5, 0)) + + # Reuse the existing fuzzy_threshold_var that's already initialized elsewhere + fuzzy_slider = tb.Scale( + fuzzy_frame, + from_=0.5, + to=1.0, + orient=tk.HORIZONTAL, + variable=self.fuzzy_threshold_var, + length=200, + bootstyle="info" + ) + fuzzy_slider.pack(side=tk.LEFT, padx=(0, 10)) + + fuzzy_value_label = tk.Label(fuzzy_frame, text=f"{self.fuzzy_threshold_var.get():.2f}") + fuzzy_value_label.pack(side=tk.LEFT, padx=(0, 10)) + + fuzzy_desc_label = tk.Label(fuzzy_frame, text="", font=('TkDefaultFont', 9), fg='gray') + fuzzy_desc_label.pack(side=tk.LEFT) + + # Reuse the exact same update function logic + def update_fuzzy_label(*args): + try: + # Check if widgets still exist before updating + if not fuzzy_desc_label.winfo_exists(): + return + if not fuzzy_value_label.winfo_exists(): + return + + value = self.fuzzy_threshold_var.get() + fuzzy_value_label.config(text=f"{value:.2f}") + + # Show description + if value >= 0.95: + desc = "Exact match only (strict)" + elif value >= 0.85: + desc = "Very similar names (recommended)" + elif value >= 0.75: + desc = "Moderately similar names" + elif value >= 0.65: + desc = "Loosely similar names" + else: + desc = "Very loose matching (may over-merge)" + + fuzzy_desc_label.config(text=desc) + except tk.TclError: + # Widget was destroyed, ignore + pass + except Exception as e: + # Catch any other unexpected errors + print(f"Error updating auto fuzzy label: {e}") + pass + + # Remove any existing auto trace before adding a new one + if hasattr(self, 'auto_fuzzy_trace_id'): + try: + self.fuzzy_threshold_var.trace_remove('write', self.auto_fuzzy_trace_id) + except: + pass + + # Set up the trace AFTER creating the label and store the trace ID + self.auto_fuzzy_trace_id = self.fuzzy_threshold_var.trace('w', update_fuzzy_label) + + # Initialize description by calling the function + try: + update_fuzzy_label() + except: + # If initialization fails, just continue + pass + + # Initialize the variable if not exists + if not hasattr(self, 'strip_honorifics_var'): + self.strip_honorifics_var = tk.BooleanVar(value=True) + + # Help text + help_frame = tk.Frame(extraction_tab) + help_frame.pack(fill=tk.X, padx=10, pady=(10, 0)) + + tk.Label(help_frame, text="๐Ÿ’ก Settings Guide:", font=('TkDefaultFont', 12, 'bold')).pack(anchor=tk.W) + help_texts = [ + "โ€ข Min frequency: How many times a name must appear (lower = more terms)", + "โ€ข Max names/titles: Limits to prevent huge glossaries", + "โ€ข Translation batch: Terms per API call (larger = faster but may reduce quality)", + "โ€ข Max text size: Characters to analyze (0 = entire text, 50000 = first 50k chars)", + "โ€ข Chapter split: Split large texts into chunks (0 = no splitting, 100000 = split at 100k chars)", + "โ€ข Max sentences: Maximum sentences to send to AI (default 200, increase for more context)", + "โ€ข Filter mode:", + " - All names & terms: Extract character names (with/without honorifics) + titles/terms", + " - Names with honorifics only: ONLY character names with honorifics (no titles/terms)", + " - Names without honorifics & terms: Character names without honorifics + titles/terms", + "โ€ข Strip honorifics: Remove suffixes from extracted names (e.g., '๊น€' instead of '๊น€๋‹˜')", + "โ€ข Fuzzy threshold: How similar terms must be to match (0.9 = 90% match, 1.0 = exact match)" + ] + for txt in help_texts: + tk.Label(help_frame, text=txt, font=('TkDefaultFont', 11), fg='gray').pack(anchor=tk.W, padx=20) + + # Tab 2: Extraction Prompt + extraction_prompt_tab = tk.Frame(notebook) + notebook.add(extraction_prompt_tab, text="Extraction Prompt") + + # Auto prompt section + auto_prompt_frame = tk.LabelFrame(extraction_prompt_tab, text="Extraction Template (System Prompt)", padx=10, pady=10) + auto_prompt_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + tk.Label(auto_prompt_frame, text="Available placeholders: {language}, {min_frequency}, {max_names}, {max_titles}", + font=('TkDefaultFont', 9), fg='blue').pack(anchor=tk.W, pady=(0, 5)) + + self.auto_prompt_text = self.ui.setup_scrollable_text( + auto_prompt_frame, height=12, wrap=tk.WORD + ) + self.auto_prompt_text.pack(fill=tk.BOTH, expand=True) + + # Set default extraction prompt if not set + if not hasattr(self, 'auto_glossary_prompt') or not self.auto_glossary_prompt: + self.auto_glossary_prompt = self.default_auto_glossary_prompt + + self.auto_prompt_text.insert('1.0', self.auto_glossary_prompt) + self.auto_prompt_text.edit_reset() + + auto_prompt_controls = tk.Frame(extraction_prompt_tab) + auto_prompt_controls.pack(fill=tk.X, padx=10, pady=(0, 10)) + + def reset_auto_prompt(): + if messagebox.askyesno("Reset Prompt", "Reset automatic glossary prompt to default?"): + self.auto_prompt_text.delete('1.0', tk.END) + self.auto_prompt_text.insert('1.0', self.default_auto_glossary_prompt) + + tb.Button(auto_prompt_controls, text="Reset to Default", command=reset_auto_prompt, + bootstyle="warning").pack(side=tk.LEFT, padx=5) + + # Tab 3: Format Instructions - NEW TAB + format_tab = tk.Frame(notebook) + notebook.add(format_tab, text="Format Instructions") + + # Format instructions section + format_prompt_frame = tk.LabelFrame(format_tab, text="Output Format Instructions (User Prompt)", padx=10, pady=10) + format_prompt_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + tk.Label(format_prompt_frame, text="These instructions are added to your extraction prompt to specify the output format:", + font=('TkDefaultFont', 10)).pack(anchor=tk.W, pady=(0, 5)) + + tk.Label(format_prompt_frame, text="Available placeholders: {text_sample}", + font=('TkDefaultFont', 9), fg='blue').pack(anchor=tk.W, pady=(0, 5)) + + # Initialize format instructions variable and text widget + if not hasattr(self, 'glossary_format_instructions'): + self.glossary_format_instructions = """ +Return the results in EXACT CSV format with this header: +type,raw_name,translated_name + +For example: +character,๊น€์ƒํ˜„,Kim Sang-hyu +character,๊ฐˆํŽธ์ œ,Gale Hardest +character,๋””ํžˆ๋ฆฟ ์•„๋ฐ,Dihirit Ade + +Only include terms that actually appear in the text. +Do not use quotes around values unless they contain commas. + +Text to analyze: +{text_sample}""" + + self.format_instructions_text = self.ui.setup_scrollable_text( + format_prompt_frame, height=12, wrap=tk.WORD + ) + self.format_instructions_text.pack(fill=tk.BOTH, expand=True) + self.format_instructions_text.insert('1.0', self.glossary_format_instructions) + self.format_instructions_text.edit_reset() + + format_prompt_controls = tk.Frame(format_tab) + format_prompt_controls.pack(fill=tk.X, padx=10, pady=(0, 10)) + + def reset_format_instructions(): + if messagebox.askyesno("Reset Prompt", "Reset format instructions to default?"): + default_format_instructions = """ +Return the results in EXACT CSV format with this header: +type,raw_name,translated_name + +For example: +character,๊น€์ƒํ˜„,Kim Sang-hyu +character,๊ฐˆํŽธ์ œ,Gale Hardest +character,๋””ํžˆ๋ฆฟ ์•„๋ฐ,Dihirit Ade + +Only include terms that actually appear in the text. +Do not use quotes around values unless they contain commas. + +Text to analyze: +{text_sample}""" + self.format_instructions_text.delete('1.0', tk.END) + self.format_instructions_text.insert('1.0', default_format_instructions) + + tb.Button(format_prompt_controls, text="Reset to Default", command=reset_format_instructions, + bootstyle="warning").pack(side=tk.LEFT, padx=5) + + # Tab 4: Translation Prompt (moved from Tab 3) + translation_prompt_tab = tk.Frame(notebook) + notebook.add(translation_prompt_tab, text="Translation Prompt") + + # Translation prompt section + trans_prompt_frame = tk.LabelFrame(translation_prompt_tab, text="Glossary Translation Template (User Prompt)", padx=10, pady=10) + trans_prompt_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + tk.Label(trans_prompt_frame, text="This prompt is used to translate extracted terms to English:", + font=('TkDefaultFont', 10)).pack(anchor=tk.W, pady=(0, 5)) + + tk.Label(trans_prompt_frame, text="Available placeholders: {language}, {terms_list}, {batch_size}", + font=('TkDefaultFont', 9), fg='blue').pack(anchor=tk.W, pady=(0, 5)) + + # Initialize translation prompt variable and text widget + if not hasattr(self, 'glossary_translation_prompt'): + self.glossary_translation_prompt = """ +You are translating {language} character names and important terms to English. +For character names, provide English transliterations or keep as romanized. +Keep honorifics/suffixes only if they are integral to the name. +Respond with the same numbered format. + +Terms to translate: +{terms_list} + +Provide translations in the same numbered format.""" + + self.translation_prompt_text = self.ui.setup_scrollable_text( + trans_prompt_frame, height=12, wrap=tk.WORD + ) + self.translation_prompt_text.pack(fill=tk.BOTH, expand=True) + self.translation_prompt_text.insert('1.0', self.glossary_translation_prompt) + self.translation_prompt_text.edit_reset() + + trans_prompt_controls = tk.Frame(translation_prompt_tab) + trans_prompt_controls.pack(fill=tk.X, padx=10, pady=(0, 10)) + + def reset_trans_prompt(): + if messagebox.askyesno("Reset Prompt", "Reset translation prompt to default?"): + default_trans_prompt = """ +You are translating {language} character names and important terms to English. +For character names, provide English transliterations or keep as romanized. +Keep honorifics/suffixes only if they are integral to the name. +Respond with the same numbered format. + +Terms to translate: +{terms_list} + +Provide translations in the same numbered format.""" + self.translation_prompt_text.delete('1.0', tk.END) + self.translation_prompt_text.insert('1.0', default_trans_prompt) + + tb.Button(trans_prompt_controls, text="Reset to Default", command=reset_trans_prompt, + bootstyle="warning").pack(side=tk.LEFT, padx=5) + + # Update states function with proper error handling + def update_auto_glossary_state(): + try: + if not extraction_grid.winfo_exists(): + return + state = tk.NORMAL if self.enable_auto_glossary_var.get() else tk.DISABLED + for widget in extraction_grid.winfo_children(): + if isinstance(widget, (tb.Entry, ttk.Entry, tb.Checkbutton, ttk.Checkbutton)): + widget.config(state=state) + # Handle frames that contain radio buttons or scales + elif isinstance(widget, tk.Frame): + for child in widget.winfo_children(): + if isinstance(child, (tb.Radiobutton, ttk.Radiobutton, tb.Scale, ttk.Scale)): + child.config(state=state) + if self.auto_prompt_text.winfo_exists(): + self.auto_prompt_text.config(state=state) + if hasattr(self, 'format_instructions_text') and self.format_instructions_text.winfo_exists(): + self.format_instructions_text.config(state=state) + if hasattr(self, 'translation_prompt_text') and self.translation_prompt_text.winfo_exists(): + self.translation_prompt_text.config(state=state) + for widget in auto_prompt_controls.winfo_children(): + if isinstance(widget, (tb.Button, ttk.Button)) and widget.winfo_exists(): + widget.config(state=state) + for widget in format_prompt_controls.winfo_children(): + if isinstance(widget, (tb.Button, ttk.Button)) and widget.winfo_exists(): + widget.config(state=state) + for widget in trans_prompt_controls.winfo_children(): + if isinstance(widget, (tb.Button, ttk.Button)) and widget.winfo_exists(): + widget.config(state=state) + except tk.TclError: + # Widget was destroyed, ignore + pass + + def update_append_prompt_state(): + try: + if not self.append_prompt_text.winfo_exists(): + return + state = tk.NORMAL if self.append_glossary_var.get() else tk.DISABLED + self.append_prompt_text.config(state=state) + for widget in append_prompt_controls.winfo_children(): + if isinstance(widget, (tb.Button, ttk.Button)) and widget.winfo_exists(): + widget.config(state=state) + except tk.TclError: + # Widget was destroyed, ignore + pass + + # Initialize states + update_auto_glossary_state() + update_append_prompt_state() + + # Add traces + self.enable_auto_glossary_var.trace('w', lambda *args: update_auto_glossary_state()) + self.append_glossary_var.trace('w', lambda *args: update_append_prompt_state()) + + def _setup_glossary_editor_tab(self, parent): + """Set up the glossary editor/trimmer tab""" + container = tk.Frame(parent) + container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + file_frame = tk.Frame(container) + file_frame.pack(fill=tk.X, pady=(0, 10)) + + tk.Label(file_frame, text="Glossary File:").pack(side=tk.LEFT, padx=(0, 5)) + self.editor_file_var = tk.StringVar() + tb.Entry(file_frame, textvariable=self.editor_file_var, state='readonly').pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5) + + stats_frame = tk.Frame(container) + stats_frame.pack(fill=tk.X, pady=(0, 5)) + self.stats_label = tk.Label(stats_frame, text="No glossary loaded", font=('TkDefaultFont', 10, 'italic')) + self.stats_label.pack(side=tk.LEFT) + + content_frame = tk.LabelFrame(container, text="Glossary Entries", padx=10, pady=10) + content_frame.pack(fill=tk.BOTH, expand=True) + + tree_frame = tk.Frame(content_frame) + tree_frame.pack(fill=tk.BOTH, expand=True) + + vsb = ttk.Scrollbar(tree_frame, orient="vertical") + hsb = ttk.Scrollbar(tree_frame, orient="horizontal") + + self.glossary_tree = ttk.Treeview(tree_frame, show='tree headings', + yscrollcommand=vsb.set, xscrollcommand=hsb.set) + + vsb.config(command=self.glossary_tree.yview) + hsb.config(command=self.glossary_tree.xview) + + self.glossary_tree.grid(row=0, column=0, sticky='nsew') + vsb.grid(row=0, column=1, sticky='ns') + hsb.grid(row=1, column=0, sticky='ew') + + tree_frame.grid_rowconfigure(0, weight=1) + tree_frame.grid_columnconfigure(0, weight=1) + + self.glossary_tree.bind('<Double-Button-1>', self._on_tree_double_click) + + self.current_glossary_data = None + self.current_glossary_format = None + + # Editor functions + def load_glossary_for_editing(): + path = self.editor_file_var.get() + if not path or not os.path.exists(path): + messagebox.showerror("Error", "Please select a valid glossary file") + return + + try: + # Try CSV first + if path.endswith('.csv'): + import csv + entries = [] + with open(path, 'r', encoding='utf-8') as f: + reader = csv.reader(f) + for row in reader: + if len(row) >= 3: + entry = { + 'type': row[0], + 'raw_name': row[1], + 'translated_name': row[2] + } + if row[0] == 'character' and len(row) > 3: + entry['gender'] = row[3] + entries.append(entry) + self.current_glossary_data = entries + self.current_glossary_format = 'list' + else: + # JSON format + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + + entries = [] + all_fields = set() + + if isinstance(data, dict): + if 'entries' in data: + self.current_glossary_data = data + self.current_glossary_format = 'dict' + for original, translated in data['entries'].items(): + entry = {'original': original, 'translated': translated} + entries.append(entry) + all_fields.update(entry.keys()) + else: + self.current_glossary_data = {'entries': data} + self.current_glossary_format = 'dict' + for original, translated in data.items(): + entry = {'original': original, 'translated': translated} + entries.append(entry) + all_fields.update(entry.keys()) + + elif isinstance(data, list): + self.current_glossary_data = data + self.current_glossary_format = 'list' + for item in data: + all_fields.update(item.keys()) + entries.append(item) + + # Set up columns based on new format + if self.current_glossary_format == 'list' and entries and 'type' in entries[0]: + # New simple format + column_fields = ['type', 'raw_name', 'translated_name', 'gender'] + + # Check for any custom fields + for entry in entries: + for field in entry.keys(): + if field not in column_fields: + column_fields.append(field) + else: + # Old format compatibility + standard_fields = ['original_name', 'name', 'original', 'translated', 'gender', + 'title', 'group_affiliation', 'traits', 'how_they_refer_to_others', + 'locations'] + + column_fields = [] + for field in standard_fields: + if field in all_fields: + column_fields.append(field) + + custom_fields = sorted(all_fields - set(standard_fields)) + column_fields.extend(custom_fields) + + self.glossary_tree.delete(*self.glossary_tree.get_children()) + self.glossary_tree['columns'] = column_fields + + self.glossary_tree.heading('#0', text='#') + self.glossary_tree.column('#0', width=40, stretch=False) + + for field in column_fields: + display_name = field.replace('_', ' ').title() + self.glossary_tree.heading(field, text=display_name) + + if field in ['raw_name', 'translated_name', 'original_name', 'name', 'original', 'translated']: + width = 150 + elif field in ['traits', 'locations', 'how_they_refer_to_others']: + width = 200 + else: + width = 100 + + self.glossary_tree.column(field, width=width) + + for idx, entry in enumerate(entries): + values = [] + for field in column_fields: + value = entry.get(field, '') + if isinstance(value, list): + value = ', '.join(str(v) for v in value) + elif isinstance(value, dict): + value = ', '.join(f"{k}: {v}" for k, v in value.items()) + elif value is None: + value = '' + values.append(value) + + self.glossary_tree.insert('', 'end', text=str(idx + 1), values=values) + + # Update stats + stats = [] + stats.append(f"Total entries: {len(entries)}") + + if self.current_glossary_format == 'list' and entries and 'type' in entries[0]: + # New format stats + characters = sum(1 for e in entries if e.get('type') == 'character') + terms = sum(1 for e in entries if e.get('type') == 'term') + stats.append(f"Characters: {characters}, Terms: {terms}") + elif self.current_glossary_format == 'list': + # Old format stats + chars = sum(1 for e in entries if 'original_name' in e or 'name' in e) + locs = sum(1 for e in entries if 'locations' in e and e['locations']) + stats.append(f"Characters: {chars}, Locations: {locs}") + + self.stats_label.config(text=" | ".join(stats)) + self.append_log(f"โœ… Loaded {len(entries)} entries from glossary") + + except Exception as e: + messagebox.showerror("Error", f"Failed to load glossary: {e}") + self.append_log(f"โŒ Failed to load glossary: {e}") + + def browse_glossary(): + path = filedialog.askopenfilename( + title="Select glossary file", + filetypes=[("Glossary files", "*.json *.csv"), ("JSON files", "*.json"), ("CSV files", "*.csv")] + ) + if path: + self.editor_file_var.set(path) + load_glossary_for_editing() + + # Common save helper + def save_current_glossary(): + path = self.editor_file_var.get() + if not path or not self.current_glossary_data: + return False + try: + if path.endswith('.csv'): + # Save as CSV + import csv + with open(path, 'w', encoding='utf-8', newline='') as f: + writer = csv.writer(f) + for entry in self.current_glossary_data: + if entry.get('type') == 'character': + writer.writerow([entry.get('type', ''), entry.get('raw_name', ''), + entry.get('translated_name', ''), entry.get('gender', '')]) + else: + writer.writerow([entry.get('type', ''), entry.get('raw_name', ''), + entry.get('translated_name', ''), '']) + else: + # Save as JSON + with open(path, 'w', encoding='utf-8') as f: + json.dump(self.current_glossary_data, f, ensure_ascii=False, indent=2) + return True + except Exception as e: + messagebox.showerror("Error", f"Failed to save: {e}") + return False + + def clean_empty_fields(): + if not self.current_glossary_data: + messagebox.showerror("Error", "No glossary loaded") + return + + if self.current_glossary_format == 'list': + # Check if there are any empty fields + empty_fields_found = False + fields_cleaned = {} + + # Count empty fields first + for entry in self.current_glossary_data: + for field in list(entry.keys()): + value = entry[field] + if value is None or value == "" or (isinstance(value, list) and len(value) == 0) or (isinstance(value, dict) and len(value) == 0): + empty_fields_found = True + fields_cleaned[field] = fields_cleaned.get(field, 0) + 1 + + # If no empty fields found, show message and return + if not empty_fields_found: + messagebox.showinfo("Info", "No empty fields found in glossary") + return + + # Only create backup if there are fields to clean + if not self.create_glossary_backup("before_clean"): + return + + # Now actually clean the fields + total_cleaned = 0 + for entry in self.current_glossary_data: + for field in list(entry.keys()): + value = entry[field] + if value is None or value == "" or (isinstance(value, list) and len(value) == 0) or (isinstance(value, dict) and len(value) == 0): + entry.pop(field) + total_cleaned += 1 + + if save_current_glossary(): + load_glossary_for_editing() + + # Provide detailed feedback + msg = f"Cleaned {total_cleaned} empty fields\n\n" + msg += "Fields cleaned:\n" + for field, count in sorted(fields_cleaned.items(), key=lambda x: x[1], reverse=True): + msg += f"โ€ข {field}: {count} entries\n" + + messagebox.showinfo("Success", msg) + + def delete_selected_entries(): + selected = self.glossary_tree.selection() + if not selected: + messagebox.showwarning("No Selection", "Please select entries to delete") + return + + count = len(selected) + if messagebox.askyesno("Confirm Delete", f"Delete {count} selected entries?"): + # automatic backup + if not self.create_glossary_backup(f"before_delete_{count}"): + return + + indices_to_delete = [] + for item in selected: + idx = int(self.glossary_tree.item(item)['text']) - 1 + indices_to_delete.append(idx) + + indices_to_delete.sort(reverse=True) + + if self.current_glossary_format == 'list': + for idx in indices_to_delete: + if 0 <= idx < len(self.current_glossary_data): + del self.current_glossary_data[idx] + + elif self.current_glossary_format == 'dict': + entries_list = list(self.current_glossary_data.get('entries', {}).items()) + for idx in indices_to_delete: + if 0 <= idx < len(entries_list): + key = entries_list[idx][0] + self.current_glossary_data['entries'].pop(key, None) + + if save_current_glossary(): + load_glossary_for_editing() + messagebox.showinfo("Success", f"Deleted {len(indices_to_delete)} entries") + + def remove_duplicates(): + if not self.current_glossary_data: + messagebox.showerror("Error", "No glossary loaded") + return + + if self.current_glossary_format == 'list': + # Import the skip function from the updated script + try: + from extract_glossary_from_epub import skip_duplicate_entries, remove_honorifics + + # Set environment variable for honorifics toggle + os.environ['GLOSSARY_DISABLE_HONORIFICS_FILTER'] = '1' if self.config.get('glossary_disable_honorifics_filter', False) else '0' + + original_count = len(self.current_glossary_data) + self.current_glossary_data = skip_duplicate_entries(self.current_glossary_data) + duplicates_removed = original_count - len(self.current_glossary_data) + + if duplicates_removed > 0: + if self.config.get('glossary_auto_backup', False): + self.create_glossary_backup(f"before_remove_{duplicates_removed}_dupes") + + if save_current_glossary(): + load_glossary_for_editing() + messagebox.showinfo("Success", f"Removed {duplicates_removed} duplicate entries") + self.append_log(f"๐Ÿ—‘๏ธ Removed {duplicates_removed} duplicates based on raw_name") + else: + messagebox.showinfo("Info", "No duplicates found") + + except ImportError: + # Fallback implementation + seen_raw_names = set() + unique_entries = [] + duplicates = 0 + + for entry in self.current_glossary_data: + raw_name = entry.get('raw_name', '').lower().strip() + if raw_name and raw_name not in seen_raw_names: + seen_raw_names.add(raw_name) + unique_entries.append(entry) + elif raw_name: + duplicates += 1 + + if duplicates > 0: + self.current_glossary_data = unique_entries + if save_current_glossary(): + load_glossary_for_editing() + messagebox.showinfo("Success", f"Removed {duplicates} duplicate entries") + else: + messagebox.showinfo("Info", "No duplicates found") + + # dialog function for configuring duplicate detection mode + def duplicate_detection_settings(): + """Show info about duplicate detection (simplified for new format)""" + messagebox.showinfo( + "Duplicate Detection", + "Duplicate detection is based on the raw_name field.\n\n" + "โ€ข Entries with identical raw_name values are considered duplicates\n" + "โ€ข The first occurrence is kept, later ones are removed\n" + "โ€ข Honorifics filtering can be toggled in the Manual Glossary tab\n\n" + "When honorifics filtering is enabled, names are compared after removing honorifics." + ) + + def backup_settings_dialog(): + """Show dialog for configuring automatic backup settings""" + # Use setup_scrollable with custom ratios + dialog, scrollable_frame, canvas = self.wm.setup_scrollable( + self.master, + "Automatic Backup Settings", + width=500, + height=None, + max_width_ratio=0.45, + max_height_ratio=0.51 + ) + + # Main frame + main_frame = ttk.Frame(scrollable_frame, padding="20") + main_frame.pack(fill=tk.BOTH, expand=True) + + # Title + ttk.Label(main_frame, text="Automatic Backup Settings", + font=('TkDefaultFont', 22, 'bold')).pack(pady=(0, 20)) + + # Backup toggle + backup_var = tk.BooleanVar(value=self.config.get('glossary_auto_backup', True)) + backup_frame = ttk.Frame(main_frame) + backup_frame.pack(fill=tk.X, pady=5) + + backup_check = ttk.Checkbutton(backup_frame, + text="Enable automatic backups before modifications", + variable=backup_var) + backup_check.pack(anchor=tk.W) + + # Settings frame (indented) + settings_frame = ttk.Frame(main_frame) + settings_frame.pack(fill=tk.X, pady=(10, 0), padx=(20, 0)) + + # Max backups setting + max_backups_frame = ttk.Frame(settings_frame) + max_backups_frame.pack(fill=tk.X, pady=5) + + ttk.Label(max_backups_frame, text="Maximum backups to keep:").pack(side=tk.LEFT, padx=(0, 10)) + max_backups_var = tk.IntVar(value=self.config.get('glossary_max_backups', 50)) + max_backups_spin = ttk.Spinbox(max_backups_frame, from_=0, to=999, + textvariable=max_backups_var, width=10) + max_backups_spin.pack(side=tk.LEFT) + ttk.Label(max_backups_frame, text="(0 = unlimited)", + font=('TkDefaultFont', 9), + foreground='gray').pack(side=tk.LEFT, padx=(10, 0)) + + # Backup naming pattern info + pattern_frame = ttk.Frame(settings_frame) + pattern_frame.pack(fill=tk.X, pady=(15, 5)) + + ttk.Label(pattern_frame, text="Backup naming pattern:", + font=('TkDefaultFont', 10, 'bold')).pack(anchor=tk.W) + ttk.Label(pattern_frame, + text="[original_name]_[operation]_[YYYYMMDD_HHMMSS].json", + font=('TkDefaultFont', 9, 'italic'), + foreground='#666').pack(anchor=tk.W, padx=(10, 0)) + + # Example + example_text = "Example: my_glossary_before_delete_5_20240115_143052.json" + ttk.Label(pattern_frame, text=example_text, + font=('TkDefaultFont', 8), + foreground='gray').pack(anchor=tk.W, padx=(10, 0), pady=(2, 0)) + + # Separator + ttk.Separator(main_frame, orient='horizontal').pack(fill=tk.X, pady=(20, 15)) + + # Backup location info + location_frame = ttk.Frame(main_frame) + location_frame.pack(fill=tk.X) + + ttk.Label(location_frame, text="๐Ÿ“ Backup Location:", + font=('TkDefaultFont', 10, 'bold')).pack(anchor=tk.W) + + if self.editor_file_var.get(): + glossary_dir = os.path.dirname(self.editor_file_var.get()) + backup_path = "Backups" + full_path = os.path.join(glossary_dir, "Backups") + + path_label = ttk.Label(location_frame, + text=f"{backup_path}/", + font=('TkDefaultFont', 9), + foreground='#0066cc') + path_label.pack(anchor=tk.W, padx=(10, 0)) + + # Check if backup folder exists and show count + if os.path.exists(full_path): + backup_count = len([f for f in os.listdir(full_path) if f.endswith('.json')]) + ttk.Label(location_frame, + text=f"Currently contains {backup_count} backup(s)", + font=('TkDefaultFont', 8), + foreground='gray').pack(anchor=tk.W, padx=(10, 0)) + else: + ttk.Label(location_frame, + text="Backups", + font=('TkDefaultFont', 9), + foreground='gray').pack(anchor=tk.W, padx=(10, 0)) + + def toggle_settings_state(*args): + state = tk.NORMAL if backup_var.get() else tk.DISABLED + max_backups_spin.config(state=state) + + backup_var.trace('w', toggle_settings_state) + toggle_settings_state() # Set initial state + + # Buttons + button_frame = ttk.Frame(main_frame) + button_frame.pack(fill=tk.X, pady=(25, 0)) + + # Inner frame for centering buttons + button_inner_frame = ttk.Frame(button_frame) + button_inner_frame.pack(anchor=tk.CENTER) + + def save_settings(): + # Save backup settings + self.config['glossary_auto_backup'] = backup_var.get() + self.config['glossary_max_backups'] = max_backups_var.get() + + # Save to config file + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(self.config, f, ensure_ascii=False, indent=2) + + status = "enabled" if backup_var.get() else "disabled" + if backup_var.get(): + limit = max_backups_var.get() + limit_text = "unlimited" if limit == 0 else f"max {limit}" + msg = f"Automatic backups {status} ({limit_text})" + else: + msg = f"Automatic backups {status}" + + messagebox.showinfo("Success", msg) + dialog.destroy() + + def create_manual_backup(): + """Create a manual backup right now""" + if not self.current_glossary_data: + messagebox.showerror("Error", "No glossary loaded") + return + + if self.create_glossary_backup("manual"): + messagebox.showinfo("Success", "Manual backup created successfully!") + + tb.Button(button_inner_frame, text="Save Settings", command=save_settings, + bootstyle="success", width=15).pack(side=tk.LEFT, padx=5) + tb.Button(button_inner_frame, text="Backup Now", command=create_manual_backup, + bootstyle="info", width=15).pack(side=tk.LEFT, padx=5) + tb.Button(button_inner_frame, text="Cancel", command=dialog.destroy, + bootstyle="secondary", width=15).pack(side=tk.LEFT, padx=5) + + # Auto-resize and show + self.wm.auto_resize_dialog(dialog, canvas, max_width_ratio=0.45, max_height_ratio=0.41) + + def smart_trim_dialog(): + if not self.current_glossary_data: + messagebox.showerror("Error", "No glossary loaded") + return + + # Use WindowManager's setup_scrollable for unified scrolling + dialog, scrollable_frame, canvas = self.wm.setup_scrollable( + self.master, + "Smart Trim Glossary", + width=600, + height=None, + max_width_ratio=0.9, + max_height_ratio=0.85 + ) + + main_frame = scrollable_frame + + # Title and description + tk.Label(main_frame, text="Smart Glossary Trimming", + font=('TkDefaultFont', 14, 'bold')).pack(pady=(20, 5)) + + tk.Label(main_frame, text="Limit the number of entries in your glossary", + font=('TkDefaultFont', 10), fg='gray', wraplength=550).pack(pady=(0, 15)) + + # Display current glossary stats + stats_frame = tk.LabelFrame(main_frame, text="Current Glossary Statistics", padx=15, pady=10) + stats_frame.pack(fill=tk.X, pady=(0, 15), padx=20) + + entry_count = len(self.current_glossary_data) if self.current_glossary_format == 'list' else len(self.current_glossary_data.get('entries', {})) + tk.Label(stats_frame, text=f"Total entries: {entry_count}", font=('TkDefaultFont', 10)).pack(anchor=tk.W) + + # For new format, show type breakdown + if self.current_glossary_format == 'list' and self.current_glossary_data and 'type' in self.current_glossary_data[0]: + characters = sum(1 for e in self.current_glossary_data if e.get('type') == 'character') + terms = sum(1 for e in self.current_glossary_data if e.get('type') == 'term') + tk.Label(stats_frame, text=f"Characters: {characters}, Terms: {terms}", font=('TkDefaultFont', 10)).pack(anchor=tk.W) + + # Entry limit section + limit_frame = tk.LabelFrame(main_frame, text="Entry Limit", padx=15, pady=10) + limit_frame.pack(fill=tk.X, pady=(0, 15), padx=20) + + tk.Label(limit_frame, text="Keep only the first N entries to reduce glossary size", + font=('TkDefaultFont', 9), fg='gray', wraplength=520).pack(anchor=tk.W, pady=(0, 10)) + + top_frame = tk.Frame(limit_frame) + top_frame.pack(fill=tk.X, pady=5) + tk.Label(top_frame, text="Keep first").pack(side=tk.LEFT) + top_var = tk.StringVar(value=str(min(100, entry_count))) + tb.Entry(top_frame, textvariable=top_var, width=10).pack(side=tk.LEFT, padx=5) + tk.Label(top_frame, text=f"entries (out of {entry_count})").pack(side=tk.LEFT) + + # Preview section + preview_frame = tk.LabelFrame(main_frame, text="Preview", padx=15, pady=10) + preview_frame.pack(fill=tk.X, pady=(0, 15), padx=20) + + preview_label = tk.Label(preview_frame, text="Click 'Preview Changes' to see the effect", + font=('TkDefaultFont', 10), fg='gray') + preview_label.pack(pady=5) + + def preview_changes(): + try: + top_n = int(top_var.get()) + entries_to_remove = max(0, entry_count - top_n) + + preview_text = f"Preview of changes:\n" + preview_text += f"โ€ข Entries: {entry_count} โ†’ {top_n} ({entries_to_remove} removed)\n" + + preview_label.config(text=preview_text, fg='blue') + + except ValueError: + preview_label.config(text="Please enter a valid number", fg='red') + + tb.Button(preview_frame, text="Preview Changes", command=preview_changes, + bootstyle="info").pack() + + # Action buttons + button_frame = tk.Frame(main_frame) + button_frame.pack(fill=tk.X, pady=(10, 20), padx=20) + + def apply_smart_trim(): + try: + top_n = int(top_var.get()) + + # Calculate how many entries will be removed + entries_to_remove = len(self.current_glossary_data) - top_n + if entries_to_remove > 0: + if not self.create_glossary_backup(f"before_trim_{entries_to_remove}"): + return + + if self.current_glossary_format == 'list': + # Keep only top N entries + if top_n < len(self.current_glossary_data): + self.current_glossary_data = self.current_glossary_data[:top_n] + + elif self.current_glossary_format == 'dict': + # For dict format, only support entry limit + entries = list(self.current_glossary_data['entries'].items()) + if top_n < len(entries): + self.current_glossary_data['entries'] = dict(entries[:top_n]) + + if save_current_glossary(): + load_glossary_for_editing() + + messagebox.showinfo("Success", f"Trimmed glossary to {top_n} entries") + dialog.destroy() + + except ValueError: + messagebox.showerror("Error", "Please enter valid numbers") + + # Create inner frame for buttons + button_inner_frame = tk.Frame(button_frame) + button_inner_frame.pack() + + tb.Button(button_inner_frame, text="Apply Trim", command=apply_smart_trim, + bootstyle="success", width=15).pack(side=tk.LEFT, padx=5) + tb.Button(button_inner_frame, text="Cancel", command=dialog.destroy, + bootstyle="secondary", width=15).pack(side=tk.LEFT, padx=5) + + # Info section at bottom + info_frame = tk.Frame(main_frame) + info_frame.pack(fill=tk.X, pady=(0, 20), padx=20) + + tk.Label(info_frame, text="๐Ÿ’ก Tip: Entries are kept in their original order", + font=('TkDefaultFont', 9, 'italic'), fg='#666').pack() + + # Auto-resize the dialog to fit content + self.wm.auto_resize_dialog(dialog, canvas, max_width_ratio=0.9, max_height_ratio=1.2) + + def filter_entries_dialog(): + if not self.current_glossary_data: + messagebox.showerror("Error", "No glossary loaded") + return + + # Use WindowManager's setup_scrollable for unified scrolling + dialog, scrollable_frame, canvas = self.wm.setup_scrollable( + self.master, + "Filter Entries", + width=600, + height=None, + max_width_ratio=0.9, + max_height_ratio=0.85 + ) + + main_frame = scrollable_frame + + # Title and description + tk.Label(main_frame, text="Filter Glossary Entries", + font=('TkDefaultFont', 14, 'bold')).pack(pady=(20, 5)) + + tk.Label(main_frame, text="Filter entries by type or content", + font=('TkDefaultFont', 10), fg='gray', wraplength=550).pack(pady=(0, 15)) + + # Current stats + entry_count = len(self.current_glossary_data) if self.current_glossary_format == 'list' else len(self.current_glossary_data.get('entries', {})) + + stats_frame = tk.LabelFrame(main_frame, text="Current Status", padx=15, pady=10) + stats_frame.pack(fill=tk.X, pady=(0, 15), padx=20) + tk.Label(stats_frame, text=f"Total entries: {entry_count}", font=('TkDefaultFont', 10)).pack(anchor=tk.W) + + # Check if new format + is_new_format = (self.current_glossary_format == 'list' and + self.current_glossary_data and + 'type' in self.current_glossary_data[0]) + + # Filter conditions + conditions_frame = tk.LabelFrame(main_frame, text="Filter Conditions", padx=15, pady=10) + conditions_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 15), padx=20) + + # Type filter for new format + type_vars = {} + if is_new_format: + type_frame = tk.LabelFrame(conditions_frame, text="Entry Type", padx=10, pady=10) + type_frame.pack(fill=tk.X, pady=(0, 10)) + + type_vars['character'] = tk.BooleanVar(value=True) + type_vars['term'] = tk.BooleanVar(value=True) + + tb.Checkbutton(type_frame, text="Keep characters", variable=type_vars['character']).pack(anchor=tk.W) + tb.Checkbutton(type_frame, text="Keep terms/locations", variable=type_vars['term']).pack(anchor=tk.W) + + # Text content filter + text_filter_frame = tk.LabelFrame(conditions_frame, text="Text Content Filter", padx=10, pady=10) + text_filter_frame.pack(fill=tk.X, pady=(0, 10)) + + tk.Label(text_filter_frame, text="Keep entries containing text (case-insensitive):", + font=('TkDefaultFont', 9), fg='gray').pack(anchor=tk.W, pady=(0, 5)) + + search_var = tk.StringVar() + tb.Entry(text_filter_frame, textvariable=search_var, width=40).pack(fill=tk.X, pady=5) + + # Gender filter for new format + gender_var = tk.StringVar(value="all") + if is_new_format: + gender_frame = tk.LabelFrame(conditions_frame, text="Gender Filter (Characters Only)", padx=10, pady=10) + gender_frame.pack(fill=tk.X, pady=(0, 10)) + + tk.Radiobutton(gender_frame, text="All genders", variable=gender_var, value="all").pack(anchor=tk.W) + tk.Radiobutton(gender_frame, text="Male only", variable=gender_var, value="Male").pack(anchor=tk.W) + tk.Radiobutton(gender_frame, text="Female only", variable=gender_var, value="Female").pack(anchor=tk.W) + tk.Radiobutton(gender_frame, text="Unknown only", variable=gender_var, value="Unknown").pack(anchor=tk.W) + + # Preview section + preview_frame = tk.LabelFrame(main_frame, text="Preview", padx=15, pady=10) + preview_frame.pack(fill=tk.X, pady=(0, 15), padx=20) + + preview_label = tk.Label(preview_frame, text="Click 'Preview Filter' to see how many entries match", + font=('TkDefaultFont', 10), fg='gray') + preview_label.pack(pady=5) + + def check_entry_matches(entry): + """Check if an entry matches the filter conditions""" + # Type filter + if is_new_format and entry.get('type'): + if not type_vars.get(entry['type'], tk.BooleanVar(value=True)).get(): + return False + + # Text filter + search_text = search_var.get().strip().lower() + if search_text: + # Search in all text fields + entry_text = ' '.join(str(v) for v in entry.values() if isinstance(v, str)).lower() + if search_text not in entry_text: + return False + + # Gender filter + if is_new_format and gender_var.get() != "all": + if entry.get('type') == 'character' and entry.get('gender') != gender_var.get(): + return False + + return True + + def preview_filter(): + """Preview the filter results""" + matching = 0 + + if self.current_glossary_format == 'list': + for entry in self.current_glossary_data: + if check_entry_matches(entry): + matching += 1 + else: + for key, entry in self.current_glossary_data.get('entries', {}).items(): + if check_entry_matches(entry): + matching += 1 + + removed = entry_count - matching + preview_label.config( + text=f"Filter matches: {matching} entries ({removed} will be removed)", + fg='blue' if matching > 0 else 'red' + ) + + tb.Button(preview_frame, text="Preview Filter", command=preview_filter, + bootstyle="info").pack() + + # Action buttons + button_frame = tk.Frame(main_frame) + button_frame.pack(fill=tk.X, pady=(10, 20), padx=20) + + def apply_filter(): + if self.current_glossary_format == 'list': + filtered = [] + for entry in self.current_glossary_data: + if check_entry_matches(entry): + filtered.append(entry) + + removed = len(self.current_glossary_data) - len(filtered) + + if removed > 0: + if not self.create_glossary_backup(f"before_filter_remove_{removed}"): + return + + self.current_glossary_data[:] = filtered + + if save_current_glossary(): + load_glossary_for_editing() + messagebox.showinfo("Success", + f"Filter applied!\n\nKept: {len(filtered)} entries\nRemoved: {removed} entries") + dialog.destroy() + + # Create inner frame for buttons + button_inner_frame = tk.Frame(button_frame) + button_inner_frame.pack() + + tb.Button(button_inner_frame, text="Apply Filter", command=apply_filter, + bootstyle="success", width=15).pack(side=tk.LEFT, padx=5) + tb.Button(button_inner_frame, text="Cancel", command=dialog.destroy, + bootstyle="secondary", width=15).pack(side=tk.LEFT, padx=5) + + # Auto-resize the dialog to fit content + self.wm.auto_resize_dialog(dialog, canvas, max_width_ratio=0.9, max_height_ratio=1.49) + + def export_selection(): + selected = self.glossary_tree.selection() + if not selected: + messagebox.showwarning("Warning", "No entries selected") + return + + path = filedialog.asksaveasfilename( + title="Export Selected Entries", + defaultextension=".json", + filetypes=[("JSON files", "*.json"), ("CSV files", "*.csv")] + ) + + if not path: + return + + try: + if self.current_glossary_format == 'list': + exported = [] + for item in selected: + idx = int(self.glossary_tree.item(item)['text']) - 1 + if 0 <= idx < len(self.current_glossary_data): + exported.append(self.current_glossary_data[idx]) + + if path.endswith('.csv'): + # Export as CSV + import csv + with open(path, 'w', encoding='utf-8', newline='') as f: + writer = csv.writer(f) + for entry in exported: + if entry.get('type') == 'character': + writer.writerow([entry.get('type', ''), entry.get('raw_name', ''), + entry.get('translated_name', ''), entry.get('gender', '')]) + else: + writer.writerow([entry.get('type', ''), entry.get('raw_name', ''), + entry.get('translated_name', ''), '']) + else: + # Export as JSON + with open(path, 'w', encoding='utf-8') as f: + json.dump(exported, f, ensure_ascii=False, indent=2) + + else: + exported = {} + entries_list = list(self.current_glossary_data.get('entries', {}).items()) + for item in selected: + idx = int(self.glossary_tree.item(item)['text']) - 1 + if 0 <= idx < len(entries_list): + key, value = entries_list[idx] + exported[key] = value + + with open(path, 'w', encoding='utf-8') as f: + json.dump(exported, f, ensure_ascii=False, indent=2) + + messagebox.showinfo("Success", f"Exported {len(selected)} entries to {os.path.basename(path)}") + + except Exception as e: + messagebox.showerror("Error", f"Failed to export: {e}") + + def save_edited_glossary(): + if save_current_glossary(): + messagebox.showinfo("Success", "Glossary saved successfully") + self.append_log(f"โœ… Saved glossary to: {self.editor_file_var.get()}") + + def save_as_glossary(): + if not self.current_glossary_data: + messagebox.showerror("Error", "No glossary loaded") + return + + path = filedialog.asksaveasfilename( + title="Save Glossary As", + defaultextension=".json", + filetypes=[("JSON files", "*.json"), ("CSV files", "*.csv")] + ) + + if not path: + return + + try: + if path.endswith('.csv'): + # Save as CSV + import csv + with open(path, 'w', encoding='utf-8', newline='') as f: + writer = csv.writer(f) + if self.current_glossary_format == 'list': + for entry in self.current_glossary_data: + if entry.get('type') == 'character': + writer.writerow([entry.get('type', ''), entry.get('raw_name', ''), + entry.get('translated_name', ''), entry.get('gender', '')]) + else: + writer.writerow([entry.get('type', ''), entry.get('raw_name', ''), + entry.get('translated_name', ''), '']) + else: + # Save as JSON + with open(path, 'w', encoding='utf-8') as f: + json.dump(self.current_glossary_data, f, ensure_ascii=False, indent=2) + + self.editor_file_var.set(path) + messagebox.showinfo("Success", f"Glossary saved to {os.path.basename(path)}") + self.append_log(f"โœ… Saved glossary as: {path}") + + except Exception as e: + messagebox.showerror("Error", f"Failed to save: {e}") + + # Buttons + tb.Button(file_frame, text="Browse", command=browse_glossary, width=15).pack(side=tk.LEFT) + + + editor_controls = tk.Frame(container) + editor_controls.pack(fill=tk.X, pady=(10, 0)) + + # Row 1 + row1 = tk.Frame(editor_controls) + row1.pack(fill=tk.X, pady=2) + + buttons_row1 = [ + ("Reload", load_glossary_for_editing, "info"), + ("Delete Selected", delete_selected_entries, "danger"), + ("Clean Empty Fields", clean_empty_fields, "warning"), + ("Remove Duplicates", remove_duplicates, "warning"), + ("Backup Settings", backup_settings_dialog, "success") + ] + + for text, cmd, style in buttons_row1: + tb.Button(row1, text=text, command=cmd, bootstyle=style, width=15).pack(side=tk.LEFT, padx=2) + + # Row 2 + row2 = tk.Frame(editor_controls) + row2.pack(fill=tk.X, pady=2) + + buttons_row2 = [ + ("Trim Entries", smart_trim_dialog, "primary"), + ("Filter Entries", filter_entries_dialog, "primary"), + ("Convert Format", lambda: self.convert_glossary_format(load_glossary_for_editing), "info"), + ("Export Selection", export_selection, "secondary"), + ("About Format", duplicate_detection_settings, "info") + ] + + for text, cmd, style in buttons_row2: + tb.Button(row2, text=text, command=cmd, bootstyle=style, width=15).pack(side=tk.LEFT, padx=2) + + # Row 3 + row3 = tk.Frame(editor_controls) + row3.pack(fill=tk.X, pady=2) + + tb.Button(row3, text="Save Changes", command=save_edited_glossary, + bootstyle="success", width=20).pack(side=tk.LEFT, padx=2) + tb.Button(row3, text="Save As...", command=save_as_glossary, + bootstyle="success-outline", width=20).pack(side=tk.LEFT, padx=2) + + def _on_tree_double_click(self, event): + """Handle double-click on treeview item for inline editing""" + region = self.glossary_tree.identify_region(event.x, event.y) + if region != 'cell': + return + + item = self.glossary_tree.identify_row(event.y) + column = self.glossary_tree.identify_column(event.x) + + if not item or column == '#0': + return + + col_idx = int(column.replace('#', '')) - 1 + columns = self.glossary_tree['columns'] + if col_idx >= len(columns): + return + + col_name = columns[col_idx] + values = self.glossary_tree.item(item)['values'] + current_value = values[col_idx] if col_idx < len(values) else '' + + dialog = self.wm.create_simple_dialog( + self.master, + f"Edit {col_name.replace('_', ' ').title()}", + width=400, + height=150 + ) + + frame = tk.Frame(dialog, padx=20, pady=20) + frame.pack(fill=tk.BOTH, expand=True) + + tk.Label(frame, text=f"Edit {col_name.replace('_', ' ').title()}:").pack(anchor=tk.W) + + # Simple entry for new format fields + var = tk.StringVar(value=current_value) + entry = tb.Entry(frame, textvariable=var, width=50) + entry.pack(fill=tk.X, pady=5) + entry.focus() + entry.select_range(0, tk.END) + + def save_edit(): + new_value = var.get() + + new_values = list(values) + new_values[col_idx] = new_value + self.glossary_tree.item(item, values=new_values) + + row_idx = int(self.glossary_tree.item(item)['text']) - 1 + + if self.current_glossary_format == 'list': + if 0 <= row_idx < len(self.current_glossary_data): + entry = self.current_glossary_data[row_idx] + + if new_value: + entry[col_name] = new_value + else: + entry.pop(col_name, None) + + dialog.destroy() + + button_frame = tk.Frame(frame) + button_frame.pack(fill=tk.X, pady=(10, 0)) + + tb.Button(button_frame, text="Save", command=save_edit, + bootstyle="success", width=10).pack(side=tk.LEFT, padx=5) + tb.Button(button_frame, text="Cancel", command=dialog.destroy, + bootstyle="secondary", width=10).pack(side=tk.LEFT, padx=5) + + dialog.bind('<Return>', lambda e: save_edit()) + dialog.bind('<Escape>', lambda e: dialog.destroy()) + + dialog.deiconify() + + def convert_glossary_format(self, reload_callback): + """Export glossary to CSV format""" + if not self.current_glossary_data: + messagebox.showerror("Error", "No glossary loaded") + return + + # Create backup before conversion + if not self.create_glossary_backup("before_export"): + return + + # Get current file path + current_path = self.editor_file_var.get() + default_csv_path = current_path.replace('.json', '.csv') + + # Ask user for CSV save location + from tkinter import filedialog + csv_path = filedialog.asksaveasfilename( + title="Export Glossary to CSV", + defaultextension=".csv", + initialfile=os.path.basename(default_csv_path), + filetypes=[("CSV files", "*.csv"), ("All files", "*.*")] + ) + + if not csv_path: + return + + try: + import csv + + # Get custom types for gender info + custom_types = self.config.get('custom_entry_types', { + 'character': {'enabled': True, 'has_gender': True}, + 'term': {'enabled': True, 'has_gender': False} + }) + + # Get custom fields + custom_fields = self.config.get('custom_glossary_fields', []) + + with open(csv_path, 'w', encoding='utf-8', newline='') as f: + writer = csv.writer(f) + + # Build header row + header = ['type', 'raw_name', 'translated_name', 'gender'] + if custom_fields: + header.extend(custom_fields) + + # Write header row + writer.writerow(header) + + # Process based on format + if isinstance(self.current_glossary_data, list) and self.current_glossary_data: + if 'type' in self.current_glossary_data[0]: + # New format - direct export + for entry in self.current_glossary_data: + entry_type = entry.get('type', 'term') + type_config = custom_types.get(entry_type, {}) + + row = [ + entry_type, + entry.get('raw_name', ''), + entry.get('translated_name', '') + ] + + # Add gender + if type_config.get('has_gender', False): + row.append(entry.get('gender', '')) + else: + row.append('') + + # Add custom field values + for field in custom_fields: + row.append(entry.get(field, '')) + + writer.writerow(row) + else: + # Old format - convert then export + for entry in self.current_glossary_data: + # Determine type + is_location = False + if 'locations' in entry and entry['locations']: + is_location = True + elif 'title' in entry and any(term in str(entry.get('title', '')).lower() + for term in ['location', 'place', 'city', 'region']): + is_location = True + + entry_type = 'term' if is_location else 'character' + type_config = custom_types.get(entry_type, {}) + + row = [ + entry_type, + entry.get('original_name', entry.get('original', '')), + entry.get('name', entry.get('translated', '')) + ] + + # Add gender + if type_config.get('has_gender', False): + row.append(entry.get('gender', 'Unknown')) + else: + row.append('') + + # Add empty custom fields + for field in custom_fields: + row.append('') + + writer.writerow(row) + + messagebox.showinfo("Success", f"Glossary exported to CSV:\n{csv_path}") + self.append_log(f"โœ… Exported glossary to: {csv_path}") + + except Exception as e: + messagebox.showerror("Export Error", f"Failed to export CSV: {e}") + self.append_log(f"โŒ CSV export failed: {e}") + + def _make_bottom_toolbar(self): + """Create the bottom toolbar with all action buttons""" + btn_frame = tb.Frame(self.frame) + btn_frame.grid(row=11, column=0, columnspan=5, sticky=tk.EW, pady=5) + + self.qa_button = tb.Button(btn_frame, text="QA Scan", command=self.run_qa_scan, bootstyle="warning") + self.qa_button.grid(row=0, column=99, sticky=tk.EW, padx=5) + + toolbar_items = [ + ("EPUB Converter", self.epub_converter, "info"), + ("Extract Glossary", self.run_glossary_extraction_thread, "warning"), + ("Glossary Manager", self.glossary_manager, "secondary"), + ] + + # Add Manga Translator if available + if MANGA_SUPPORT: + toolbar_items.append(("Manga Translator", self.open_manga_translator, "primary")) + + # Async Processing + toolbar_items.append(("Async Translation", self.open_async_processing, "success")) + + toolbar_items.extend([ + ("Retranslate", self.force_retranslation, "warning"), + ("Save Config", self.save_config, "secondary"), + ("Load Glossary", self.load_glossary, "secondary"), + ("Import Profiles", self.import_profiles, "secondary"), + ("Export Profiles", self.export_profiles, "secondary"), + ("๐Ÿ“ 1080p: OFF", self.toggle_safe_ratios, "secondary"), + ]) + + for idx, (lbl, cmd, style) in enumerate(toolbar_items): + btn_frame.columnconfigure(idx, weight=1) + btn = tb.Button(btn_frame, text=lbl, command=cmd, bootstyle=style) + btn.grid(row=0, column=idx, sticky=tk.EW, padx=2) + if lbl == "Extract Glossary": + self.glossary_button = btn + elif lbl == "EPUB Converter": + self.epub_button = btn + elif "1080p" in lbl: + self.safe_ratios_btn = btn + elif lbl == "Async Processing (50% Off)": + self.async_button = btn + + self.frame.grid_rowconfigure(12, weight=0) + + def toggle_safe_ratios(self): + """Toggle 1080p Windows ratios mode""" + is_safe = self.wm.toggle_safe_ratios() + + if is_safe: + self.safe_ratios_btn.config( + text="๐Ÿ“ 1080p: ON", + bootstyle="success" + ) + self.append_log("โœ… 1080p Windows ratios enabled - all dialogs will fit on screen") + else: + self.safe_ratios_btn.config( + text="๐Ÿ“ 1080p: OFF", + bootstyle="secondary" + ) + self.append_log("โŒ 1080p Windows ratios disabled - using default sizes") + + # Save preference + self.config['force_safe_ratios'] = is_safe + self.save_config() + + def _get_opf_file_order(self, file_list): + """ + Sort files based on OPF spine order if available. + Uses STRICT OPF ordering - includes ALL files from spine without filtering. + This ensures notice files, copyright pages, etc. are processed in the correct order. + Returns sorted file list based on OPF, or original list if no OPF found. + """ + try: + import xml.etree.ElementTree as ET + import zipfile + import re + + # First, check if we have content.opf in the current directory + opf_file = None + if file_list: + current_dir = os.path.dirname(file_list[0]) if file_list else os.getcwd() + possible_opf = os.path.join(current_dir, 'content.opf') + if os.path.exists(possible_opf): + opf_file = possible_opf + self.append_log(f"๐Ÿ“‹ Found content.opf in directory") + + # If no OPF, check if any of the files is an OPF + if not opf_file: + for file_path in file_list: + if file_path.lower().endswith('.opf'): + opf_file = file_path + self.append_log(f"๐Ÿ“‹ Found OPF file: {os.path.basename(opf_file)}") + break + + # If no OPF, try to extract from EPUB + if not opf_file: + epub_files = [f for f in file_list if f.lower().endswith('.epub')] + if epub_files: + epub_path = epub_files[0] + try: + with zipfile.ZipFile(epub_path, 'r') as zf: + for name in zf.namelist(): + if name.endswith('.opf'): + opf_content = zf.read(name) + temp_opf = os.path.join(os.path.dirname(epub_path), 'temp_content.opf') + with open(temp_opf, 'wb') as f: + f.write(opf_content) + opf_file = temp_opf + self.append_log(f"๐Ÿ“‹ Extracted OPF from EPUB: {os.path.basename(epub_path)}") + break + except Exception as e: + self.append_log(f"โš ๏ธ Could not extract OPF from EPUB: {e}") + + if not opf_file: + self.append_log(f"โ„น๏ธ No OPF file found, using default file order") + return file_list + + # Parse the OPF file + try: + tree = ET.parse(opf_file) + root = tree.getroot() + + # Handle namespaces + ns = {'opf': 'http://www.idpf.org/2007/opf'} + if root.tag.startswith('{'): + default_ns = root.tag[1:root.tag.index('}')] + ns = {'opf': default_ns} + + # Get manifest to map IDs to files + manifest = {} + for item in root.findall('.//opf:manifest/opf:item', ns): + item_id = item.get('id') + href = item.get('href') + + if item_id and href: + filename = os.path.basename(href) + manifest[item_id] = filename + # Store multiple variations for matching + name_without_ext = os.path.splitext(filename)[0] + manifest[item_id + '_noext'] = name_without_ext + # Also store with response_ prefix for matching + manifest[item_id + '_response'] = f"response_{filename}" + manifest[item_id + '_response_noext'] = f"response_{name_without_ext}" + + # Get spine order - include ALL files first for correct indexing + spine_order_full = [] + spine = root.find('.//opf:spine', ns) + if spine is not None: + for itemref in spine.findall('opf:itemref', ns): + idref = itemref.get('idref') + if idref and idref in manifest: + spine_order_full.append(manifest[idref]) + + # Now filter out cover and nav/toc files for processing + spine_order = [] + for item in spine_order_full: + # Skip navigation and cover files + if not any(skip in item.lower() for skip in ['nav.', 'toc.', 'cover.']): + spine_order.append(item) + + self.append_log(f"๐Ÿ“‹ Found {len(spine_order_full)} items in OPF spine ({len(spine_order)} after filtering)") + + # Count file types + notice_count = sum(1 for f in spine_order if 'notice' in f.lower()) + chapter_count = sum(1 for f in spine_order if 'chapter' in f.lower() and 'notice' not in f.lower()) + skipped_count = len(spine_order_full) - len(spine_order) + + if skipped_count > 0: + self.append_log(f" โ€ข Skipped files (cover/nav/toc): {skipped_count}") + if notice_count > 0: + self.append_log(f" โ€ข Notice/Copyright files: {notice_count}") + if chapter_count > 0: + self.append_log(f" โ€ข Chapter files: {chapter_count}") + + # Show first few spine entries + if spine_order: + self.append_log(f" ๐Ÿ“– Spine order preview:") + for i, entry in enumerate(spine_order[:5]): + self.append_log(f" [{i}]: {entry}") + if len(spine_order) > 5: + self.append_log(f" ... and {len(spine_order) - 5} more") + + # Map input files to spine positions + ordered_files = [] + unordered_files = [] + + for file_path in file_list: + basename = os.path.basename(file_path) + basename_noext = os.path.splitext(basename)[0] + + # Try to find this file in the spine + found_position = None + matched_spine_file = None + + # Direct exact match + if basename in spine_order: + found_position = spine_order.index(basename) + matched_spine_file = basename + # Match without extension + elif basename_noext in spine_order: + found_position = spine_order.index(basename_noext) + matched_spine_file = basename_noext + else: + # Try pattern matching for response_ files + for idx, spine_item in enumerate(spine_order): + spine_noext = os.path.splitext(spine_item)[0] + + # Check if this is a response_ file matching spine item + if basename.startswith('response_'): + # Remove response_ prefix and try to match + clean_name = basename[9:] # Remove 'response_' + clean_noext = os.path.splitext(clean_name)[0] + + if clean_name == spine_item or clean_noext == spine_noext: + found_position = idx + matched_spine_file = spine_item + break + + # Try matching by chapter number + spine_num = re.search(r'(\d+)', spine_item) + file_num = re.search(r'(\d+)', clean_name) + if spine_num and file_num and spine_num.group(1) == file_num.group(1): + # Check if both are notice or both are chapter files + both_notice = 'notice' in spine_item.lower() and 'notice' in clean_name.lower() + both_chapter = 'chapter' in spine_item.lower() and 'chapter' in clean_name.lower() + if both_notice or both_chapter: + found_position = idx + matched_spine_file = spine_item + break + else: + # For non-response files, check if spine item is contained + if spine_noext in basename_noext: + found_position = idx + matched_spine_file = spine_item + break + + # Number-based matching + spine_num = re.search(r'(\d+)', spine_item) + file_num = re.search(r'(\d+)', basename) + if spine_num and file_num and spine_num.group(1) == file_num.group(1): + # Check file type match + both_notice = 'notice' in spine_item.lower() and 'notice' in basename.lower() + both_chapter = 'chapter' in spine_item.lower() and 'chapter' in basename.lower() + if both_notice or both_chapter: + found_position = idx + matched_spine_file = spine_item + break + + if found_position is not None: + ordered_files.append((found_position, file_path)) + self.append_log(f" โœ“ Matched: {basename} โ†’ spine[{found_position}]: {matched_spine_file}") + else: + unordered_files.append(file_path) + self.append_log(f" โš ๏ธ Not in spine: {basename}") + + # Sort by spine position + ordered_files.sort(key=lambda x: x[0]) + final_order = [f for _, f in ordered_files] + + # Add unmapped files at the end + if unordered_files: + self.append_log(f"๐Ÿ“‹ Adding {len(unordered_files)} unmapped files at the end") + final_order.extend(sorted(unordered_files)) + + # Clean up temp OPF if created + if opf_file and 'temp_content.opf' in opf_file and os.path.exists(opf_file): + try: + os.remove(opf_file) + except: + pass + + self.append_log(f"โœ… Files sorted using STRICT OPF spine order") + self.append_log(f" โ€ข Total files: {len(final_order)}") + self.append_log(f" โ€ข Following exact spine sequence from OPF") + + return final_order if final_order else file_list + + except Exception as e: + self.append_log(f"โš ๏ธ Error parsing OPF file: {e}") + if opf_file and 'temp_content.opf' in opf_file and os.path.exists(opf_file): + try: + os.remove(opf_file) + except: + pass + return file_list + + except Exception as e: + self.append_log(f"โš ๏ธ Error in OPF sorting: {e}") + return file_list + + def run_translation_thread(self): + """Start translation in a background worker (ThreadPoolExecutor)""" + # Prevent overlap with glossary extraction + if (hasattr(self, 'glossary_thread') and self.glossary_thread and self.glossary_thread.is_alive()) or \ + (hasattr(self, 'glossary_future') and self.glossary_future and not self.glossary_future.done()): + self.append_log("โš ๏ธ Cannot run translation while glossary extraction is in progress.") + messagebox.showwarning("Process Running", "Please wait for glossary extraction to complete before starting translation.") + return + + if self.translation_thread and self.translation_thread.is_alive(): + self.stop_translation() + return + + # Check if files are selected + if not hasattr(self, 'selected_files') or not self.selected_files: + file_path = self.entry_epub.get().strip() + if not file_path or file_path.startswith("No file selected") or "files selected" in file_path: + messagebox.showerror("Error", "Please select file(s) to translate.") + return + self.selected_files = [file_path] + + # Reset stop flags + self.stop_requested = False + if translation_stop_flag: + translation_stop_flag(False) + + # Also reset the module's internal stop flag + try: + if hasattr(self, '_main_module') and self._main_module: + if hasattr(self._main_module, 'set_stop_flag'): + self._main_module.set_stop_flag(False) + except: + pass + + # Update button immediately to show translation is starting + if hasattr(self, 'button_run'): + self.button_run.config(text="โน Stop", state="normal") + + # Show immediate feedback that translation is starting + self.append_log("๐Ÿš€ Initializing translation process...") + + # Start worker immediately - no heavy operations here + # IMPORTANT: Do NOT call _ensure_executor() here as it may be slow + # Just start the thread directly + thread_name = f"TranslationThread_{int(time.time())}" + self.translation_thread = threading.Thread( + target=self.run_translation_wrapper, + name=thread_name, + daemon=True + ) + self.translation_thread.start() + + # Schedule button update check + self.master.after(100, self.update_run_button) + + def run_translation_wrapper(self): + """Wrapper that handles ALL initialization in background thread""" + try: + # Ensure executor is available (do this in background thread) + if not hasattr(self, 'executor') or self.executor is None: + try: + self._ensure_executor() + except Exception as e: + self.append_log(f"โš ๏ธ Could not initialize executor: {e}") + + # Load modules in background thread (not main thread!) + if not self._modules_loaded: + self.append_log("๐Ÿ“ฆ Loading translation modules (this may take a moment)...") + + # Create a progress callback that uses append_log + def module_progress(msg): + self.append_log(f" {msg}") + + # Load modules with progress feedback + if not self._lazy_load_modules(splash_callback=module_progress): + self.append_log("โŒ Failed to load required modules") + return + + self.append_log("โœ… Translation modules loaded successfully") + + # Check for large EPUBs and set optimization parameters + epub_files = [f for f in self.selected_files if f.lower().endswith('.epub')] + + for epub_path in epub_files: + try: + import zipfile + with zipfile.ZipFile(epub_path, 'r') as zf: + # Quick count without reading content + html_files = [f for f in zf.namelist() if f.lower().endswith(('.html', '.xhtml', '.htm'))] + file_count = len(html_files) + + if file_count > 50: + self.append_log(f"๐Ÿ“š Large EPUB detected: {file_count} chapters") + + # Get user-configured worker count + if hasattr(self, 'config') and 'extraction_workers' in self.config: + max_workers = self.config.get('extraction_workers', 2) + else: + # Fallback to environment variable or default + max_workers = int(os.environ.get('EXTRACTION_WORKERS', '2')) + + # Set extraction parameters + os.environ['EXTRACTION_WORKERS'] = str(max_workers) + os.environ['EXTRACTION_PROGRESS_CALLBACK'] = 'enabled' + + # Set progress interval based on file count + if file_count > 500: + progress_interval = 50 + os.environ['EXTRACTION_BATCH_SIZE'] = '100' + self.append_log(f"โšก Using {max_workers} workers with batch size 100") + elif file_count > 200: + progress_interval = 25 + os.environ['EXTRACTION_BATCH_SIZE'] = '50' + self.append_log(f"โšก Using {max_workers} workers with batch size 50") + elif file_count > 100: + progress_interval = 20 + os.environ['EXTRACTION_BATCH_SIZE'] = '25' + self.append_log(f"โšก Using {max_workers} workers with batch size 25") + else: + progress_interval = 10 + os.environ['EXTRACTION_BATCH_SIZE'] = '20' + self.append_log(f"โšก Using {max_workers} workers with batch size 20") + + os.environ['EXTRACTION_PROGRESS_INTERVAL'] = str(progress_interval) + + # Enable performance flags for large files + os.environ['FAST_EXTRACTION'] = '1' + os.environ['PARALLEL_PARSE'] = '1' + + # For very large files, enable aggressive optimization + #if file_count > 300: + # os.environ['SKIP_VALIDATION'] = '1' + # os.environ['LAZY_LOAD_CONTENT'] = '1' + # self.append_log("๐Ÿš€ Enabled aggressive optimization for very large file") + + except Exception as e: + # If we can't check, just continue + pass + + # Set essential environment variables from current config before translation + os.environ['BATCH_TRANSLATE_HEADERS'] = '1' if self.config.get('batch_translate_headers', False) else '0' + os.environ['IGNORE_HEADER'] = '1' if self.config.get('ignore_header', False) else '0' + os.environ['IGNORE_TITLE'] = '1' if self.config.get('ignore_title', False) else '0' + + # Now run the actual translation + translation_completed = self.run_translation_direct() + + # If scanning phase toggle is enabled, launch scanner after translation + # BUT only if translation completed successfully (not stopped by user) + try: + if (getattr(self, 'scan_phase_enabled_var', None) and self.scan_phase_enabled_var.get() and + translation_completed and not self.stop_requested): + mode = self.scan_phase_mode_var.get() if hasattr(self, 'scan_phase_mode_var') else 'quick-scan' + self.append_log(f"๐Ÿงช Scanning phase enabled โ€” launching QA Scanner in {mode} mode (post-translation)...") + # Non-interactive: skip dialogs and use auto-search + self.master.after(0, lambda: self.run_qa_scan(mode_override=mode, non_interactive=True)) + except Exception: + pass + + except Exception as e: + self.append_log(f"โŒ Translation error: {e}") + import traceback + self.append_log(f"โŒ Full error: {traceback.format_exc()}") + finally: + # Clean up environment variables + env_vars = [ + 'EXTRACTION_WORKERS', 'EXTRACTION_BATCH_SIZE', + 'EXTRACTION_PROGRESS_CALLBACK', 'EXTRACTION_PROGRESS_INTERVAL', + 'FAST_EXTRACTION', 'PARALLEL_PARSE', 'SKIP_VALIDATION', + 'LAZY_LOAD_CONTENT' + ] + for var in env_vars: + if var in os.environ: + del os.environ[var] + + # Update button state on main thread + self.master.after(0, self.update_run_button) + + def run_translation_direct(self): + """Run translation directly - handles multiple files and different file types""" + try: + # Check stop at the very beginning + if self.stop_requested: + return False + + # DON'T CALL _lazy_load_modules HERE! + # Modules are already loaded in the wrapper + # Just verify they're loaded + if not self._modules_loaded: + self.append_log("โŒ Translation modules not loaded") + return False + + # Check stop after verification + if self.stop_requested: + return False + + # SET GLOSSARY IN ENVIRONMENT + if hasattr(self, 'manual_glossary_path') and self.manual_glossary_path: + os.environ['MANUAL_GLOSSARY'] = self.manual_glossary_path + self.append_log(f"๐Ÿ“‘ Set glossary in environment: {os.path.basename(self.manual_glossary_path)}") + else: + # Clear any previous glossary from environment + if 'MANUAL_GLOSSARY' in os.environ: + del os.environ['MANUAL_GLOSSARY'] + self.append_log(f"โ„น๏ธ No glossary loaded") + + # ========== NEW: APPLY OPF-BASED SORTING ========== + # Sort files based on OPF order if available + original_file_count = len(self.selected_files) + self.selected_files = self._get_opf_file_order(self.selected_files) + self.append_log(f"๐Ÿ“š Processing {original_file_count} files in reading order") + # ==================================================== + + # Process each file + total_files = len(self.selected_files) + successful = 0 + failed = 0 + + # Check if we're processing multiple images - if so, create a combined output folder + image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'} + image_files = [f for f in self.selected_files if os.path.splitext(f)[1].lower() in image_extensions] + + combined_image_output_dir = None + if len(image_files) > 1: + # Check stop before creating directories + if self.stop_requested: + return False + + # Get the common parent directory name or use timestamp + parent_dir = os.path.dirname(self.selected_files[0]) + folder_name = os.path.basename(parent_dir) if parent_dir else f"translated_images_{int(time.time())}" + combined_image_output_dir = folder_name + os.makedirs(combined_image_output_dir, exist_ok=True) + + # Create images subdirectory for originals + images_dir = os.path.join(combined_image_output_dir, "images") + os.makedirs(images_dir, exist_ok=True) + + self.append_log(f"๐Ÿ“ Created combined output directory: {combined_image_output_dir}") + + for i, file_path in enumerate(self.selected_files): + if self.stop_requested: + self.append_log(f"โน๏ธ Translation stopped by user at file {i+1}/{total_files}") + break + + self.current_file_index = i + + # Log progress for multiple files + if total_files > 1: + self.append_log(f"\n{'='*60}") + self.append_log(f"๐Ÿ“„ Processing file {i+1}/{total_files}: {os.path.basename(file_path)}") + progress_percent = ((i + 1) / total_files) * 100 + self.append_log(f"๐Ÿ“Š Overall progress: {progress_percent:.1f}%") + self.append_log(f"{'='*60}") + + if not os.path.exists(file_path): + self.append_log(f"โŒ File not found: {file_path}") + failed += 1 + continue + + # Determine file type and process accordingly + ext = os.path.splitext(file_path)[1].lower() + + try: + if ext in image_extensions: + # Process as image with combined output directory if applicable + if self._process_image_file(file_path, combined_image_output_dir): + successful += 1 + else: + failed += 1 + elif ext in {'.epub', '.txt'}: + # Process as EPUB/TXT + if self._process_text_file(file_path): + successful += 1 + else: + failed += 1 + else: + self.append_log(f"โš ๏ธ Unsupported file type: {ext}") + failed += 1 + + except Exception as e: + self.append_log(f"โŒ Error processing {os.path.basename(file_path)}: {str(e)}") + import traceback + self.append_log(f"โŒ Full error: {traceback.format_exc()}") + failed += 1 + + # Check stop before final summary + if self.stop_requested: + self.append_log(f"\nโน๏ธ Translation stopped - processed {successful} of {total_files} files") + return False + + # Final summary + if total_files > 1: + self.append_log(f"\n{'='*60}") + self.append_log(f"๐Ÿ“Š Translation Summary:") + self.append_log(f" โœ… Successful: {successful} files") + if failed > 0: + self.append_log(f" โŒ Failed: {failed} files") + self.append_log(f" ๐Ÿ“ Total: {total_files} files") + + if combined_image_output_dir and successful > 0: + self.append_log(f"\n๐Ÿ’ก Tip: You can now compile the HTML files in '{combined_image_output_dir}' into an EPUB") + + # Check for cover image + cover_found = False + for img_name in ['cover.png', 'cover.jpg', 'cover.jpeg', 'cover.webp']: + if os.path.exists(os.path.join(combined_image_output_dir, "images", img_name)): + self.append_log(f" ๐Ÿ“– Found cover image: {img_name}") + cover_found = True + break + + if not cover_found: + # Use first image as cover + images_in_dir = os.listdir(os.path.join(combined_image_output_dir, "images")) + if images_in_dir: + self.append_log(f" ๐Ÿ“– First image will be used as cover: {images_in_dir[0]}") + + self.append_log(f"{'='*60}") + + return True # Translation completed successfully + + except Exception as e: + self.append_log(f"โŒ Translation setup error: {e}") + import traceback + self.append_log(f"โŒ Full error: {traceback.format_exc()}") + return False + + finally: + self.stop_requested = False + if translation_stop_flag: + translation_stop_flag(False) + + # Also reset the module's internal stop flag + try: + if hasattr(self, '_main_module') and self._main_module: + if hasattr(self._main_module, 'set_stop_flag'): + self._main_module.set_stop_flag(False) + except: + pass + + self.translation_thread = None + self.current_file_index = 0 + self.master.after(0, self.update_run_button) + + def _process_image_file(self, image_path, combined_output_dir=None): + """Process a single image file using the direct image translation API with progress tracking""" + try: + import time + import shutil + import hashlib + import os + import json + + # Determine output directory early for progress tracking + image_name = os.path.basename(image_path) + base_name = os.path.splitext(image_name)[0] + + if combined_output_dir: + output_dir = combined_output_dir + else: + output_dir = base_name + + # Initialize progress manager if not already done + if not hasattr(self, 'image_progress_manager'): + # Use the determined output directory + os.makedirs(output_dir, exist_ok=True) + + # Import or define a simplified ImageProgressManager + class ImageProgressManager: + def __init__(self, output_dir=None): + self.output_dir = output_dir + if output_dir: + self.PROGRESS_FILE = os.path.join(output_dir, "translation_progress.json") + self.prog = self._init_or_load() + else: + self.PROGRESS_FILE = None + self.prog = {"images": {}, "content_hashes": {}, "version": "1.0"} + + def set_output_dir(self, output_dir): + """Set or update the output directory and load progress""" + self.output_dir = output_dir + self.PROGRESS_FILE = os.path.join(output_dir, "translation_progress.json") + self.prog = self._init_or_load() + + def _init_or_load(self): + """Initialize or load progress tracking""" + if os.path.exists(self.PROGRESS_FILE): + try: + with open(self.PROGRESS_FILE, "r", encoding="utf-8") as pf: + return json.load(pf) + except Exception as e: + if hasattr(self, 'append_log'): + self.append_log(f"โš ๏ธ Creating new progress file due to error: {e}") + return {"images": {}, "content_hashes": {}, "version": "1.0"} + else: + return {"images": {}, "content_hashes": {}, "version": "1.0"} + + def save(self): + """Save progress to file atomically""" + if not self.PROGRESS_FILE: + return + try: + # Ensure directory exists + os.makedirs(os.path.dirname(self.PROGRESS_FILE), exist_ok=True) + + temp_file = self.PROGRESS_FILE + '.tmp' + with open(temp_file, "w", encoding="utf-8") as pf: + json.dump(self.prog, pf, ensure_ascii=False, indent=2) + + if os.path.exists(self.PROGRESS_FILE): + os.remove(self.PROGRESS_FILE) + os.rename(temp_file, self.PROGRESS_FILE) + except Exception as e: + if hasattr(self, 'append_log'): + self.append_log(f"โš ๏ธ Failed to save progress: {e}") + else: + print(f"โš ๏ธ Failed to save progress: {e}") + + def get_content_hash(self, file_path): + """Generate content hash for a file""" + hasher = hashlib.sha256() + with open(file_path, 'rb') as f: + # Read in chunks to handle large files + for chunk in iter(lambda: f.read(4096), b""): + hasher.update(chunk) + return hasher.hexdigest() + + def check_image_status(self, image_path, content_hash): + """Check if an image needs translation""" + image_name = os.path.basename(image_path) + + # NEW: Check for skip markers created by "Mark as Skipped" button + skip_key = f"skip_{image_name}" + if skip_key in self.prog: + skip_info = self.prog[skip_key] + if skip_info.get('status') == 'skipped': + return False, f"Image marked as skipped", None + + # NEW: Check if image already exists in images folder (marked as skipped) + if self.output_dir: + images_dir = os.path.join(self.output_dir, "images") + dest_image_path = os.path.join(images_dir, image_name) + + if os.path.exists(dest_image_path): + return False, f"Image in skipped folder", None + + # Check if image has already been processed + if content_hash in self.prog["images"]: + image_info = self.prog["images"][content_hash] + status = image_info.get("status") + output_file = image_info.get("output_file") + + if status == "completed" and output_file: + # Check if output file exists + if output_file and os.path.exists(output_file): + return False, f"Image already translated: {output_file}", output_file + else: + # Output file missing, mark for retranslation + image_info["status"] = "file_deleted" + image_info["deletion_detected"] = time.time() + self.save() + return True, None, None + + elif status == "skipped_cover": + return False, "Cover image - skipped", None + + elif status == "error": + # Previous error, retry + return True, None, None + + return True, None, None + + def update(self, image_path, content_hash, output_file=None, status="in_progress", error=None): + """Update progress for an image""" + image_name = os.path.basename(image_path) + + image_info = { + "name": image_name, + "path": image_path, + "content_hash": content_hash, + "status": status, + "last_updated": time.time() + } + + if output_file: + image_info["output_file"] = output_file + + if error: + image_info["error"] = str(error) + + self.prog["images"][content_hash] = image_info + + # Update content hash index for duplicates + if status == "completed" and output_file: + self.prog["content_hashes"][content_hash] = { + "original_name": image_name, + "output_file": output_file + } + + self.save() + + # Initialize the progress manager + self.image_progress_manager = ImageProgressManager(output_dir) + # Add append_log reference for the progress manager + self.image_progress_manager.append_log = self.append_log + self.append_log(f"๐Ÿ“Š Progress tracking in: {os.path.join(output_dir, 'translation_progress.json')}") + + # Check for stop request early + if self.stop_requested: + self.append_log("โน๏ธ Image translation cancelled by user") + return False + + # Get content hash for the image + try: + content_hash = self.image_progress_manager.get_content_hash(image_path) + except Exception as e: + self.append_log(f"โš ๏ธ Could not generate content hash: {e}") + # Fallback to using file path as identifier + content_hash = hashlib.sha256(image_path.encode()).hexdigest() + + # Check if image needs translation + needs_translation, skip_reason, existing_output = self.image_progress_manager.check_image_status( + image_path, content_hash + ) + + if not needs_translation: + self.append_log(f"โญ๏ธ {skip_reason}") + + # NEW: If image is marked as skipped but not in images folder yet, copy it there + if "marked as skipped" in skip_reason and combined_output_dir: + images_dir = os.path.join(combined_output_dir, "images") + os.makedirs(images_dir, exist_ok=True) + dest_image = os.path.join(images_dir, image_name) + if not os.path.exists(dest_image): + shutil.copy2(image_path, dest_image) + self.append_log(f"๐Ÿ“ Copied skipped image to: {dest_image}") + + return True + + # Update progress to "in_progress" + self.image_progress_manager.update(image_path, content_hash, status="in_progress") + + # Check if image translation is enabled + if not hasattr(self, 'enable_image_translation_var') or not self.enable_image_translation_var.get(): + self.append_log(f"โš ๏ธ Image translation not enabled. Enable it in settings to translate images.") + return False + + # Check for cover images + if 'cover' in image_name.lower(): + self.append_log(f"โญ๏ธ Skipping cover image: {image_name}") + + # Update progress for cover + self.image_progress_manager.update(image_path, content_hash, status="skipped_cover") + + # Copy cover image to images folder if using combined output + if combined_output_dir: + images_dir = os.path.join(combined_output_dir, "images") + os.makedirs(images_dir, exist_ok=True) + dest_image = os.path.join(images_dir, image_name) + if not os.path.exists(dest_image): + shutil.copy2(image_path, dest_image) + self.append_log(f"๐Ÿ“ Copied cover to: {dest_image}") + + return True # Return True to indicate successful skip (not an error) + + # Check for stop before processing + if self.stop_requested: + self.append_log("โน๏ธ Image translation cancelled before processing") + self.image_progress_manager.update(image_path, content_hash, status="cancelled") + return False + + # Get the file index for numbering + file_index = getattr(self, 'current_file_index', 0) + 1 + + # Get API key and model + api_key = self.api_key_entry.get().strip() + model = self.model_var.get().strip() + + if not api_key: + self.append_log("โŒ Error: Please enter your API key.") + self.image_progress_manager.update(image_path, content_hash, status="error", error="No API key") + return False + + if not model: + self.append_log("โŒ Error: Please select a model.") + self.image_progress_manager.update(image_path, content_hash, status="error", error="No model selected") + return False + + self.append_log(f"๐Ÿ–ผ๏ธ Processing image: {os.path.basename(image_path)}") + self.append_log(f"๐Ÿค– Using model: {model}") + + # Check if it's a vision-capable model + vision_models = [ + 'claude-opus-4-20250514', 'claude-sonnet-4-20250514', + 'gpt-4-turbo', 'gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-5-mini','gpt-5','gpt-5-nano', + 'gpt-4-vision-preview', + 'gemini-1.5-pro', 'gemini-1.5-flash', 'gemini-2.0-flash', 'gemini-2.0-flash-exp', + 'gemini-2.5-pro', 'gemini-2.5-flash', + 'llama-3.2-11b-vision', 'llama-3.2-90b-vision', + 'eh/gemini-2.5-flash', 'eh/gemini-1.5-flash', 'eh/gpt-4o' # ElectronHub variants + ] + + model_lower = model.lower() + if not any(vm in model_lower for vm in [m.lower() for m in vision_models]): + self.append_log(f"โš ๏ธ Model '{model}' may not support vision. Trying anyway...") + + # Check for stop before API initialization + if self.stop_requested: + self.append_log("โน๏ธ Image translation cancelled before API initialization") + self.image_progress_manager.update(image_path, content_hash, status="cancelled") + return False + + # Initialize API client + try: + from unified_api_client import UnifiedClient + client = UnifiedClient(model=model, api_key=api_key) + + # Set stop flag if the client supports it + if hasattr(client, 'set_stop_flag'): + client.set_stop_flag(self.stop_requested) + elif hasattr(client, 'stop_flag'): + client.stop_flag = self.stop_requested + + except Exception as e: + self.append_log(f"โŒ Failed to initialize API client: {str(e)}") + self.image_progress_manager.update(image_path, content_hash, status="error", error=f"API client init failed: {e}") + return False + + # Read the image + try: + # Get image name for payload naming + base_name = os.path.splitext(image_name)[0] + + with open(image_path, 'rb') as img_file: + image_data = img_file.read() + + # Convert to base64 + import base64 + image_base64 = base64.b64encode(image_data).decode('utf-8') + + # Check image size + size_mb = len(image_data) / (1024 * 1024) + self.append_log(f"๐Ÿ“Š Image size: {size_mb:.2f} MB") + + except Exception as e: + self.append_log(f"โŒ Failed to read image: {str(e)}") + self.image_progress_manager.update(image_path, content_hash, status="error", error=f"Failed to read image: {e}") + return False + + # Get system prompt from configuration + profile_name = self.config.get('active_profile', 'korean') + prompt_profiles = self.config.get('prompt_profiles', {}) + + # Get the main translation prompt + system_prompt = "" + if isinstance(prompt_profiles, dict) and profile_name in prompt_profiles: + profile_data = prompt_profiles[profile_name] + if isinstance(profile_data, str): + # Old format: prompt_profiles[profile_name] = "prompt text" + system_prompt = profile_data + elif isinstance(profile_data, dict): + # New format: prompt_profiles[profile_name] = {"prompt": "...", "book_title_prompt": "..."} + system_prompt = profile_data.get('prompt', '') + else: + # Fallback to check if prompt is stored directly in config + system_prompt = self.config.get(profile_name, '') + + if not system_prompt: + # Last fallback - empty string + system_prompt = "" + + # Check if we should append glossary to the prompt + append_glossary = self.config.get('append_glossary', True) # Default to True + if hasattr(self, 'append_glossary_var'): + append_glossary = self.append_glossary_var.get() + + # Check if automatic glossary is enabled + enable_auto_glossary = self.config.get('enable_auto_glossary', False) + if hasattr(self, 'enable_auto_glossary_var'): + enable_auto_glossary = self.enable_auto_glossary_var.get() + + if append_glossary: + # Check for manual glossary + manual_glossary_path = os.getenv('MANUAL_GLOSSARY') + if not manual_glossary_path and hasattr(self, 'manual_glossary_path'): + manual_glossary_path = self.manual_glossary_path + + # If automatic glossary is enabled and no manual glossary exists, defer appending + if enable_auto_glossary and (not manual_glossary_path or not os.path.exists(manual_glossary_path)): + self.append_log(f"๐Ÿ“‘ Automatic glossary enabled - glossary will be appended after generation") + # Set a flag to indicate deferred glossary appending + os.environ['DEFER_GLOSSARY_APPEND'] = '1' + # Store the append prompt for later use + glossary_prompt = self.config.get('append_glossary_prompt', + "- Follow this reference glossary for consistent translation (Do not output any raw entries):\n") + os.environ['GLOSSARY_APPEND_PROMPT'] = glossary_prompt + else: + # Original behavior - append manual glossary immediately + if manual_glossary_path and os.path.exists(manual_glossary_path): + try: + self.append_log(f"๐Ÿ“‘ Loading glossary for system prompt: {os.path.basename(manual_glossary_path)}") + + # Copy to output as the same extension, and prefer CSV naming + ext = os.path.splitext(manual_glossary_path)[1].lower() + out_name = "glossary.csv" if ext == ".csv" else "glossary.json" + output_glossary_path = os.path.join(output_dir, out_name) + try: + import shutil as _shutil + _shutil.copy(manual_glossary_path, output_glossary_path) + self.append_log(f"๐Ÿ’พ Saved glossary to output folder for auto-loading: {out_name}") + except Exception as copy_err: + self.append_log(f"โš ๏ธ Could not copy glossary into output: {copy_err}") + + # Append to prompt + if ext == ".csv": + with open(manual_glossary_path, 'r', encoding='utf-8') as f: + csv_text = f.read() + if system_prompt: + system_prompt += "\n\n" + glossary_prompt = self.config.get('append_glossary_prompt', + "- Follow this reference glossary for consistent translation (Do not output any raw entries):\n") + system_prompt += f"{glossary_prompt}\n{csv_text}" + self.append_log(f"โœ… Appended CSV glossary to system prompt") + else: + with open(manual_glossary_path, 'r', encoding='utf-8') as f: + glossary_data = json.load(f) + + formatted_entries = {} + if isinstance(glossary_data, list): + for char in glossary_data: + if not isinstance(char, dict): + continue + original = char.get('original_name', '') + translated = char.get('name', original) + if original and translated: + formatted_entries[original] = translated + title = char.get('title') + if title and original: + formatted_entries[f"{original} ({title})"] = f"{translated} ({title})" + refer_map = char.get('how_they_refer_to_others', {}) + if isinstance(refer_map, dict): + for other_name, reference in refer_map.items(): + if other_name and reference: + formatted_entries[f"{original} โ†’ {other_name}"] = f"{translated} โ†’ {reference}" + elif isinstance(glossary_data, dict): + if "entries" in glossary_data and isinstance(glossary_data["entries"], dict): + formatted_entries = glossary_data["entries"] + else: + formatted_entries = {k: v for k, v in glossary_data.items() if k != "metadata"} + if formatted_entries: + glossary_block = json.dumps(formatted_entries, ensure_ascii=False, indent=2) + if system_prompt: + system_prompt += "\n\n" + glossary_prompt = self.config.get('append_glossary_prompt', + "- Follow this reference glossary for consistent translation (Do not output any raw entries):\n") + system_prompt += f"{glossary_prompt}\n{glossary_block}" + self.append_log(f"โœ… Added {len(formatted_entries)} glossary entries to system prompt") + else: + self.append_log(f"โš ๏ธ Glossary file has no valid entries") + + except Exception as e: + self.append_log(f"โš ๏ธ Failed to append glossary to prompt: {str(e)}") + else: + self.append_log(f"โ„น๏ธ No glossary file found to append to prompt") + else: + self.append_log(f"โ„น๏ธ Glossary appending disabled in settings") + # Clear any deferred append flag + if 'DEFER_GLOSSARY_APPEND' in os.environ: + del os.environ['DEFER_GLOSSARY_APPEND'] + + # Get temperature and max tokens from GUI + temperature = float(self.temperature_entry.get()) if hasattr(self, 'temperature_entry') else 0.3 + max_tokens = int(self.max_output_tokens_var.get()) if hasattr(self, 'max_output_tokens_var') else 8192 + + # Build messages for vision API + messages = [ + {"role": "system", "content": system_prompt} + ] + + self.append_log(f"๐ŸŒ Sending image to vision API...") + self.append_log(f" System prompt length: {len(system_prompt)} chars") + self.append_log(f" Temperature: {temperature}") + self.append_log(f" Max tokens: {max_tokens}") + + # Debug: Show first 200 chars of system prompt + if system_prompt: + preview = system_prompt[:200] + "..." if len(system_prompt) > 200 else system_prompt + self.append_log(f" System prompt preview: {preview}") + + # Check stop before making API call + if self.stop_requested: + self.append_log("โน๏ธ Image translation cancelled before API call") + self.image_progress_manager.update(image_path, content_hash, status="cancelled") + return False + + # Make the API call + try: + # Create Payloads directory for API response tracking + payloads_dir = "Payloads" + os.makedirs(payloads_dir, exist_ok=True) + + # Create timestamp for unique filename + timestamp = time.strftime("%Y%m%d_%H%M%S") + payload_file = os.path.join(payloads_dir, f"image_api_{timestamp}_{base_name}.json") + + # Save the request payload + request_payload = { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + "model": model, + "image_file": image_name, + "image_size_mb": size_mb, + "temperature": temperature, + "max_tokens": max_tokens, + "messages": messages, + "image_base64": image_base64 # Full payload without truncation + } + + with open(payload_file, 'w', encoding='utf-8') as f: + json.dump(request_payload, f, ensure_ascii=False, indent=2) + + self.append_log(f"๐Ÿ“ Saved request payload: {payload_file}") + + # Call the vision API with interrupt support + # Check if the client supports a stop_callback parameter + # Import the send_with_interrupt function from TransateKRtoEN + try: + from TransateKRtoEN import send_with_interrupt + except ImportError: + self.append_log("โš ๏ธ send_with_interrupt not available, using direct call") + send_with_interrupt = None + + # Call the vision API with interrupt support + if send_with_interrupt: + # For image calls, we need a wrapper since send_with_interrupt expects client.send() + # Create a temporary wrapper client that handles image calls + class ImageClientWrapper: + def __init__(self, real_client, image_data): + self.real_client = real_client + self.image_data = image_data + + def send(self, messages, temperature, max_tokens): + return self.real_client.send_image(messages, self.image_data, temperature=temperature, max_tokens=max_tokens) + + def __getattr__(self, name): + return getattr(self.real_client, name) + + # Create wrapped client + wrapped_client = ImageClientWrapper(client, image_base64) + + # Use send_with_interrupt + response = send_with_interrupt( + messages, + wrapped_client, + temperature, + max_tokens, + lambda: self.stop_requested, + chunk_timeout=self.config.get('chunk_timeout', 300) # 5 min default + ) + else: + # Fallback to direct call + response = client.send_image( + messages, + image_base64, + temperature=temperature, + max_tokens=max_tokens + ) + + # Check if stopped after API call + if self.stop_requested: + self.append_log("โน๏ธ Image translation stopped after API call") + self.image_progress_manager.update(image_path, content_hash, status="cancelled") + return False + + # Extract content and finish reason from response + response_content = None + finish_reason = None + + if hasattr(response, 'content'): + response_content = response.content + finish_reason = response.finish_reason if hasattr(response, 'finish_reason') else 'unknown' + elif isinstance(response, tuple) and len(response) >= 2: + # Handle tuple response (content, finish_reason) + response_content, finish_reason = response + elif isinstance(response, str): + # Handle direct string response + response_content = response + finish_reason = 'complete' + else: + self.append_log(f"โŒ Unexpected response type: {type(response)}") + self.append_log(f" Response: {response}") + + # Save the response payload + response_payload = { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + "response_content": response_content, + "finish_reason": finish_reason, + "content_length": len(response_content) if response_content else 0 + } + + response_file = os.path.join(payloads_dir, f"image_api_response_{timestamp}_{base_name}.json") + with open(response_file, 'w', encoding='utf-8') as f: + json.dump(response_payload, f, ensure_ascii=False, indent=2) + + self.append_log(f"๐Ÿ“ Saved response payload: {response_file}") + + # Check if we got valid content + if not response_content or response_content.strip() == "[IMAGE TRANSLATION FAILED]": + self.append_log(f"โŒ Image translation failed - no text extracted from image") + self.append_log(f" This may mean:") + self.append_log(f" - The image doesn't contain readable text") + self.append_log(f" - The model couldn't process the image") + self.append_log(f" - The image format is not supported") + + # Try to get more info about the failure + if hasattr(response, 'error_details'): + self.append_log(f" Error details: {response.error_details}") + + self.image_progress_manager.update(image_path, content_hash, status="error", error="No text extracted") + return False + + if response_content: + self.append_log(f"โœ… Received translation from API") + + # We already have output_dir defined at the top + # Copy original image to the output directory if not using combined output + if not combined_output_dir and not os.path.exists(os.path.join(output_dir, image_name)): + shutil.copy2(image_path, os.path.join(output_dir, image_name)) + + # Get book title prompt for translating the filename + book_title_prompt = self.config.get('book_title_prompt', '') + book_title_system_prompt = self.config.get('book_title_system_prompt', '') + + # If no book title prompt in main config, check in profile + if not book_title_prompt and isinstance(prompt_profiles, dict) and profile_name in prompt_profiles: + profile_data = prompt_profiles[profile_name] + if isinstance(profile_data, dict): + book_title_prompt = profile_data.get('book_title_prompt', '') + # Also check for system prompt in profile + if 'book_title_system_prompt' in profile_data: + book_title_system_prompt = profile_data['book_title_system_prompt'] + + # If still no book title prompt, use the main system prompt + if not book_title_prompt: + book_title_prompt = system_prompt + + # If no book title system prompt configured, use the main system prompt + if not book_title_system_prompt: + book_title_system_prompt = system_prompt + + # Translate the image filename/title + self.append_log(f"๐Ÿ“ Translating image title...") + title_messages = [ + {"role": "system", "content": book_title_system_prompt}, + {"role": "user", "content": f"{book_title_prompt}\n\n{base_name}" if book_title_prompt != system_prompt else base_name} + ] + + try: + # Check for stop before title translation + if self.stop_requested: + self.append_log("โน๏ธ Image translation cancelled before title translation") + self.image_progress_manager.update(image_path, content_hash, status="cancelled") + return False + + title_response = client.send( + title_messages, + temperature=temperature, + max_tokens=max_tokens + ) + + # Extract title translation + if hasattr(title_response, 'content'): + translated_title = title_response.content.strip() if title_response.content else base_name + else: + # Handle tuple response + title_content, *_ = title_response + translated_title = title_content.strip() if title_content else base_name + except Exception as e: + self.append_log(f"โš ๏ธ Title translation failed: {str(e)}") + translated_title = base_name # Fallback to original if translation fails + + # Create clean HTML content with just the translated title and content + html_content = f'''<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"/> + <title>{translated_title} + + + +

{translated_title}

+ {response_content} + + ''' + + # Save HTML file with proper numbering + html_file = os.path.join(output_dir, f"response_{file_index:03d}_{base_name}.html") + with open(html_file, 'w', encoding='utf-8') as f: + f.write(html_content) + + # Copy original image to the output directory (for reference, not displayed) + if not combined_output_dir: + shutil.copy2(image_path, os.path.join(output_dir, image_name)) + + # Update progress to completed + self.image_progress_manager.update(image_path, content_hash, output_file=html_file, status="completed") + + # Show preview + if response_content and response_content.strip(): + preview = response_content[:200] + "..." if len(response_content) > 200 else response_content + self.append_log(f"๐Ÿ“ Translation preview:") + self.append_log(f"{preview}") + else: + self.append_log(f"โš ๏ธ Translation appears to be empty") + + self.append_log(f"โœ… Translation saved to: {html_file}") + self.append_log(f"๐Ÿ“ Output directory: {output_dir}") + + return True + else: + self.append_log(f"โŒ No translation received from API") + if finish_reason: + self.append_log(f" Finish reason: {finish_reason}") + self.image_progress_manager.update(image_path, content_hash, status="error", error="No response from API") + return False + + except Exception as e: + # Check if this was a stop/interrupt exception + if "stop" in str(e).lower() or "interrupt" in str(e).lower() or self.stop_requested: + self.append_log("โน๏ธ Image translation interrupted") + self.image_progress_manager.update(image_path, content_hash, status="cancelled") + return False + else: + self.append_log(f"โŒ API call failed: {str(e)}") + import traceback + self.append_log(f"โŒ Full error: {traceback.format_exc()}") + self.image_progress_manager.update(image_path, content_hash, status="error", error=f"API call failed: {e}") + return False + + except Exception as e: + self.append_log(f"โŒ Error processing image: {str(e)}") + import traceback + self.append_log(f"โŒ Full error: {traceback.format_exc()}") + return False + + def _process_text_file(self, file_path): + """Process EPUB or TXT file (existing translation logic)""" + try: + if translation_main is None: + self.append_log("โŒ Translation module is not available") + return False + + api_key = self.api_key_entry.get() + model = self.model_var.get() + + # Validate API key and model (same as original) + if '@' in model or model.startswith('vertex/'): + google_creds = self.config.get('google_cloud_credentials') + if not google_creds or not os.path.exists(google_creds): + self.append_log("โŒ Error: Google Cloud credentials required for Vertex AI models.") + return False + + os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = google_creds + self.append_log(f"๐Ÿ”‘ Using Google Cloud credentials: {os.path.basename(google_creds)}") + + if not api_key: + try: + with open(google_creds, 'r') as f: + creds_data = json.load(f) + api_key = creds_data.get('project_id', 'vertex-ai-project') + self.append_log(f"๐Ÿ”‘ Using project ID as API key: {api_key}") + except: + api_key = 'vertex-ai-project' + elif not api_key: + self.append_log("โŒ Error: Please enter your API key.") + return False + + # Determine output directory and save source EPUB path + if file_path.lower().endswith('.epub'): + base_name = os.path.splitext(os.path.basename(file_path))[0] + output_dir = base_name # This is how your code determines the output dir + + # Save source EPUB path for EPUB converter + source_epub_file = os.path.join(output_dir, 'source_epub.txt') + try: + os.makedirs(output_dir, exist_ok=True) # Ensure output dir exists + with open(source_epub_file, 'w', encoding='utf-8') as f: + f.write(file_path) + self.append_log(f"๐Ÿ“š Saved source EPUB reference for chapter ordering") + except Exception as e: + self.append_log(f"โš ๏ธ Could not save source EPUB reference: {e}") + + # Set EPUB_PATH in environment for immediate use + os.environ['EPUB_PATH'] = file_path + + old_argv = sys.argv + old_env = dict(os.environ) + + + try: + # Set up environment (same as original) + self.append_log(f"๐Ÿ”ง Setting up environment variables...") + self.append_log(f"๐Ÿ“– File: {os.path.basename(file_path)}") + self.append_log(f"๐Ÿค– Model: {self.model_var.get()}") + + # Get the system prompt and log first 100 characters + system_prompt = self.prompt_text.get("1.0", "end").strip() + prompt_preview = system_prompt[:200] + "..." if len(system_prompt) > 100 else system_prompt + self.append_log(f"๐Ÿ“ System prompt preview: {prompt_preview}") + self.append_log(f"๐Ÿ“ System prompt length: {len(system_prompt)} characters") + + # Check if glossary info is in the system prompt + if "glossary" in system_prompt.lower() or "character entry" in system_prompt.lower(): + self.append_log(f"๐Ÿ“š โœ… Glossary appears to be included in system prompt") + else: + self.append_log(f"๐Ÿ“š โš ๏ธ No glossary detected in system prompt") + + # Log glossary status + if hasattr(self, 'manual_glossary_path') and self.manual_glossary_path: + self.append_log(f"๐Ÿ“‘ Manual glossary loaded: {os.path.basename(self.manual_glossary_path)}") + else: + self.append_log(f"๐Ÿ“‘ No manual glossary loaded") + + # IMPORTANT: Set IS_TEXT_FILE_TRANSLATION flag for text files + if file_path.lower().endswith('.txt'): + os.environ['IS_TEXT_FILE_TRANSLATION'] = '1' + self.append_log("๐Ÿ“„ Processing as text file") + + # Set environment variables + env_vars = self._get_environment_variables(file_path, api_key) + + # Enable async chapter extraction for EPUBs to prevent GUI freezing + if file_path.lower().endswith('.epub'): + env_vars['USE_ASYNC_CHAPTER_EXTRACTION'] = '1' + self.append_log("๐Ÿš€ Using async chapter extraction (subprocess mode)") + + os.environ.update(env_vars) + + # Handle chapter range + chap_range = self.chapter_range_entry.get().strip() + if chap_range: + os.environ['CHAPTER_RANGE'] = chap_range + self.append_log(f"๐Ÿ“Š Chapter Range: {chap_range}") + + # Set other environment variables (token limits, etc.) + if hasattr(self, 'token_limit_disabled') and self.token_limit_disabled: + os.environ['MAX_INPUT_TOKENS'] = '' + else: + token_val = self.token_limit_entry.get().strip() + if token_val and token_val.isdigit(): + os.environ['MAX_INPUT_TOKENS'] = token_val + else: + os.environ['MAX_INPUT_TOKENS'] = '1000000' + + # Validate glossary path + if hasattr(self, 'manual_glossary_path') and self.manual_glossary_path: + if (hasattr(self, 'auto_loaded_glossary_path') and + self.manual_glossary_path == self.auto_loaded_glossary_path): + if (hasattr(self, 'auto_loaded_glossary_for_file') and + hasattr(self, 'file_path') and + self.file_path == self.auto_loaded_glossary_for_file): + os.environ['MANUAL_GLOSSARY'] = self.manual_glossary_path + self.append_log(f"๐Ÿ“‘ Using auto-loaded glossary: {os.path.basename(self.manual_glossary_path)}") + else: + os.environ['MANUAL_GLOSSARY'] = self.manual_glossary_path + self.append_log(f"๐Ÿ“‘ Using manual glossary: {os.path.basename(self.manual_glossary_path)}") + + # Set sys.argv to match what TransateKRtoEN.py expects + sys.argv = ['TransateKRtoEN.py', file_path] + + self.append_log("๐Ÿš€ Starting translation...") + + # Ensure Payloads directory exists + os.makedirs("Payloads", exist_ok=True) + + # Run translation + translation_main( + log_callback=self.append_log, + stop_callback=lambda: self.stop_requested + ) + + if not self.stop_requested: + self.append_log("โœ… Translation completed successfully!") + return True + else: + return False + + except Exception as e: + self.append_log(f"โŒ Translation error: {e}") + if hasattr(self, 'append_log_with_api_error_detection'): + self.append_log_with_api_error_detection(str(e)) + import traceback + self.append_log(f"โŒ Full error: {traceback.format_exc()}") + return False + + finally: + sys.argv = old_argv + os.environ.clear() + os.environ.update(old_env) + + except Exception as e: + self.append_log(f"โŒ Error in text file processing: {str(e)}") + return False + + def _get_environment_variables(self, epub_path, api_key): + """Get all environment variables for translation/glossary""" + + # Get Google Cloud project ID if using Vertex AI + google_cloud_project = '' + model = self.model_var.get() + if '@' in model or model.startswith('vertex/'): + google_creds = self.config.get('google_cloud_credentials') + if google_creds and os.path.exists(google_creds): + try: + with open(google_creds, 'r') as f: + creds_data = json.load(f) + google_cloud_project = creds_data.get('project_id', '') + except: + pass + + # Handle extraction mode - check which variables exist + if hasattr(self, 'text_extraction_method_var'): + # New cleaner UI variables + extraction_method = self.text_extraction_method_var.get() + filtering_level = self.file_filtering_level_var.get() + + if extraction_method == 'enhanced': + extraction_mode = 'enhanced' + enhanced_filtering = filtering_level + else: + extraction_mode = filtering_level + enhanced_filtering = 'smart' # default + else: + # Old UI variables + extraction_mode = self.extraction_mode_var.get() + if extraction_mode == 'enhanced': + enhanced_filtering = getattr(self, 'enhanced_filtering_var', tk.StringVar(value='smart')).get() + else: + + + enhanced_filtering = 'smart' + + # Ensure multi-key env toggles are set early for the main translation path as well + try: + if self.config.get('use_multi_api_keys', False): + os.environ['USE_MULTI_KEYS'] = '1' + else: + os.environ['USE_MULTI_KEYS'] = '0' + if self.config.get('use_fallback_keys', False): + os.environ['USE_FALLBACK_KEYS'] = '1' + else: + os.environ['USE_FALLBACK_KEYS'] = '0' + except Exception: + pass + + return { + 'EPUB_PATH': epub_path, + 'MODEL': self.model_var.get(), + 'CONTEXTUAL': '1' if self.contextual_var.get() else '0', + 'SEND_INTERVAL_SECONDS': str(self.delay_entry.get()), + 'THREAD_SUBMISSION_DELAY_SECONDS': self.thread_delay_var.get().strip() or '0.5', + 'MAX_OUTPUT_TOKENS': str(self.max_output_tokens), + 'API_KEY': api_key, + 'OPENAI_API_KEY': api_key, + 'OPENAI_OR_Gemini_API_KEY': api_key, + 'GEMINI_API_KEY': api_key, + 'SYSTEM_PROMPT': self.prompt_text.get("1.0", "end").strip(), + 'TRANSLATE_BOOK_TITLE': "1" if self.translate_book_title_var.get() else "0", + 'BOOK_TITLE_PROMPT': self.book_title_prompt, + 'BOOK_TITLE_SYSTEM_PROMPT': self.config.get('book_title_system_prompt', + "You are a translator. Respond with only the translated text, nothing else. Do not add any explanation or additional content."), + 'REMOVE_AI_ARTIFACTS': "1" if self.REMOVE_AI_ARTIFACTS_var.get() else "0", + 'USE_ROLLING_SUMMARY': "1" if (hasattr(self, 'rolling_summary_var') and self.rolling_summary_var.get()) else ("1" if self.config.get('use_rolling_summary') else "0"), + 'SUMMARY_ROLE': self.config.get('summary_role', 'user'), + 'ROLLING_SUMMARY_EXCHANGES': self.rolling_summary_exchanges_var.get(), + 'ROLLING_SUMMARY_MODE': self.rolling_summary_mode_var.get(), + 'ROLLING_SUMMARY_SYSTEM_PROMPT': self.rolling_summary_system_prompt, + 'ROLLING_SUMMARY_USER_PROMPT': self.rolling_summary_user_prompt, + 'ROLLING_SUMMARY_MAX_ENTRIES': self.rolling_summary_max_entries_var.get(), + 'PROFILE_NAME': self.lang_var.get().lower(), + 'TRANSLATION_TEMPERATURE': str(self.trans_temp.get()), + 'TRANSLATION_HISTORY_LIMIT': str(self.trans_history.get()), + 'EPUB_OUTPUT_DIR': os.getcwd(), + 'APPEND_GLOSSARY': "1" if self.append_glossary_var.get() else "0", + 'APPEND_GLOSSARY_PROMPT': self.append_glossary_prompt, + 'EMERGENCY_PARAGRAPH_RESTORE': "1" if self.emergency_restore_var.get() else "0", + 'REINFORCEMENT_FREQUENCY': self.reinforcement_freq_var.get(), + 'RETRY_TRUNCATED': "1" if self.retry_truncated_var.get() else "0", + 'MAX_RETRY_TOKENS': self.max_retry_tokens_var.get(), + 'RETRY_DUPLICATE_BODIES': "1" if self.retry_duplicate_var.get() else "0", + 'DUPLICATE_LOOKBACK_CHAPTERS': self.duplicate_lookback_var.get(), + 'GLOSSARY_MIN_FREQUENCY': self.glossary_min_frequency_var.get(), + 'GLOSSARY_MAX_NAMES': self.glossary_max_names_var.get(), + 'GLOSSARY_MAX_TITLES': self.glossary_max_titles_var.get(), + 'GLOSSARY_BATCH_SIZE': self.glossary_batch_size_var.get(), + 'GLOSSARY_STRIP_HONORIFICS': "1" if self.strip_honorifics_var.get() else "0", + 'GLOSSARY_CHAPTER_SPLIT_THRESHOLD': self.glossary_chapter_split_threshold_var.get(), + 'GLOSSARY_FILTER_MODE': self.glossary_filter_mode_var.get(), + 'ENABLE_AUTO_GLOSSARY': "1" if self.enable_auto_glossary_var.get() else "0", + 'AUTO_GLOSSARY_PROMPT': self.auto_glossary_prompt if hasattr(self, 'auto_glossary_prompt') else '', + 'APPEND_GLOSSARY_PROMPT': self.append_glossary_prompt if hasattr(self, 'append_glossary_prompt') else '', + 'GLOSSARY_TRANSLATION_PROMPT': self.glossary_translation_prompt if hasattr(self, 'glossary_translation_prompt') else '', + 'GLOSSARY_FORMAT_INSTRUCTIONS': self.glossary_format_instructions if hasattr(self, 'glossary_format_instructions') else '', + 'GLOSSARY_USE_LEGACY_CSV': '1' if self.use_legacy_csv_var.get() else '0', + 'ENABLE_IMAGE_TRANSLATION': "1" if self.enable_image_translation_var.get() else "0", + 'PROCESS_WEBNOVEL_IMAGES': "1" if self.process_webnovel_images_var.get() else "0", + 'WEBNOVEL_MIN_HEIGHT': self.webnovel_min_height_var.get(), + 'MAX_IMAGES_PER_CHAPTER': self.max_images_per_chapter_var.get(), + 'IMAGE_API_DELAY': '1.0', + 'SAVE_IMAGE_TRANSLATIONS': '1', + 'IMAGE_CHUNK_HEIGHT': self.image_chunk_height_var.get(), + 'HIDE_IMAGE_TRANSLATION_LABEL': "1" if self.hide_image_translation_label_var.get() else "0", + 'RETRY_TIMEOUT': "1" if self.retry_timeout_var.get() else "0", + 'CHUNK_TIMEOUT': self.chunk_timeout_var.get(), + # New network/HTTP controls + 'ENABLE_HTTP_TUNING': '1' if self.config.get('enable_http_tuning', False) else '0', + 'CONNECT_TIMEOUT': str(self.config.get('connect_timeout', os.environ.get('CONNECT_TIMEOUT', '10'))), + 'READ_TIMEOUT': str(self.config.get('read_timeout', os.environ.get('READ_TIMEOUT', os.environ.get('CHUNK_TIMEOUT', '180')))), + 'HTTP_POOL_CONNECTIONS': str(self.config.get('http_pool_connections', os.environ.get('HTTP_POOL_CONNECTIONS', '20'))), + 'HTTP_POOL_MAXSIZE': str(self.config.get('http_pool_maxsize', os.environ.get('HTTP_POOL_MAXSIZE', '50'))), + 'IGNORE_RETRY_AFTER': '1' if (hasattr(self, 'ignore_retry_after_var') and self.ignore_retry_after_var.get()) else '0', + 'MAX_RETRIES': str(self.config.get('max_retries', os.environ.get('MAX_RETRIES', '7'))), + 'INDEFINITE_RATE_LIMIT_RETRY': '1' if self.config.get('indefinite_rate_limit_retry', False) else '0', + # Scanning/QA settings + 'SCAN_PHASE_ENABLED': '1' if self.config.get('scan_phase_enabled', False) else '0', + 'SCAN_PHASE_MODE': self.config.get('scan_phase_mode', 'quick-scan'), + 'QA_AUTO_SEARCH_OUTPUT': '1' if self.config.get('qa_auto_search_output', True) else '0', + 'BATCH_TRANSLATION': "1" if self.batch_translation_var.get() else "0", + 'BATCH_SIZE': self.batch_size_var.get(), + 'CONSERVATIVE_BATCHING': "1" if self.conservative_batching_var.get() else "0", + 'DISABLE_ZERO_DETECTION': "1" if self.disable_zero_detection_var.get() else "0", + 'TRANSLATION_HISTORY_ROLLING': "1" if self.translation_history_rolling_var.get() else "0", + 'USE_GEMINI_OPENAI_ENDPOINT': '1' if self.use_gemini_openai_endpoint_var.get() else '0', + 'GEMINI_OPENAI_ENDPOINT': self.gemini_openai_endpoint_var.get() if self.gemini_openai_endpoint_var.get() else '', + "ATTACH_CSS_TO_CHAPTERS": "1" if self.attach_css_to_chapters_var.get() else "0", + 'GLOSSARY_FUZZY_THRESHOLD': str(self.config.get('glossary_fuzzy_threshold', 0.90)), + 'GLOSSARY_MAX_TEXT_SIZE': self.glossary_max_text_size_var.get(), + 'GLOSSARY_MAX_SENTENCES': self.glossary_max_sentences_var.get(), + 'USE_FALLBACK_KEYS': '1' if self.config.get('use_fallback_keys', False) else '0', + 'FALLBACK_KEYS': json.dumps(self.config.get('fallback_keys', [])), + + # Extraction settings + "EXTRACTION_MODE": extraction_mode, + "ENHANCED_FILTERING": enhanced_filtering, + "ENHANCED_PRESERVE_STRUCTURE": "1" if getattr(self, 'enhanced_preserve_structure_var', tk.BooleanVar(value=True)).get() else "0", + 'FORCE_BS_FOR_TRADITIONAL': '1' if getattr(self, 'force_bs_for_traditional_var', tk.BooleanVar(value=False)).get() else '0', + + # For new UI + "TEXT_EXTRACTION_METHOD": extraction_method if hasattr(self, 'text_extraction_method_var') else ('enhanced' if extraction_mode == 'enhanced' else 'standard'), + "FILE_FILTERING_LEVEL": filtering_level if hasattr(self, 'file_filtering_level_var') else extraction_mode, + 'DISABLE_CHAPTER_MERGING': '1' if self.disable_chapter_merging_var.get() else '0', + 'DISABLE_EPUB_GALLERY': "1" if self.disable_epub_gallery_var.get() else "0", + 'DISABLE_AUTOMATIC_COVER_CREATION': "1" if getattr(self, 'disable_automatic_cover_creation_var', tk.BooleanVar(value=False)).get() else "0", + 'TRANSLATE_COVER_HTML': "1" if getattr(self, 'translate_cover_html_var', tk.BooleanVar(value=False)).get() else "0", + 'DUPLICATE_DETECTION_MODE': self.duplicate_detection_mode_var.get(), + 'CHAPTER_NUMBER_OFFSET': str(self.chapter_number_offset_var.get()), + 'USE_HEADER_AS_OUTPUT': "1" if self.use_header_as_output_var.get() else "0", + 'ENABLE_DECIMAL_CHAPTERS': "1" if self.enable_decimal_chapters_var.get() else "0", + 'ENABLE_WATERMARK_REMOVAL': "1" if self.enable_watermark_removal_var.get() else "0", + 'ADVANCED_WATERMARK_REMOVAL': "1" if self.advanced_watermark_removal_var.get() else "0", + 'SAVE_CLEANED_IMAGES': "1" if self.save_cleaned_images_var.get() else "0", + 'COMPRESSION_FACTOR': self.compression_factor_var.get(), + 'DISABLE_GEMINI_SAFETY': str(self.config.get('disable_gemini_safety', False)).lower(), + 'GLOSSARY_DUPLICATE_KEY_MODE': self.config.get('glossary_duplicate_key_mode', 'auto'), + 'GLOSSARY_DUPLICATE_CUSTOM_FIELD': self.config.get('glossary_duplicate_custom_field', ''), + 'MANUAL_GLOSSARY': self.manual_glossary_path if hasattr(self, 'manual_glossary_path') and self.manual_glossary_path else '', + 'FORCE_NCX_ONLY': '1' if self.force_ncx_only_var.get() else '0', + 'SINGLE_API_IMAGE_CHUNKS': "1" if self.single_api_image_chunks_var.get() else "0", + 'ENABLE_GEMINI_THINKING': "1" if self.enable_gemini_thinking_var.get() else "0", + 'THINKING_BUDGET': self.thinking_budget_var.get() if self.enable_gemini_thinking_var.get() else '0', + # GPT/OpenRouter reasoning + 'ENABLE_GPT_THINKING': "1" if self.enable_gpt_thinking_var.get() else "0", + 'GPT_REASONING_TOKENS': self.gpt_reasoning_tokens_var.get() if self.enable_gpt_thinking_var.get() else '', + 'GPT_EFFORT': self.gpt_effort_var.get(), + 'OPENROUTER_EXCLUDE': '1', + 'OPENROUTER_PREFERRED_PROVIDER': self.config.get('openrouter_preferred_provider', 'Auto'), + # Custom API endpoints + 'OPENAI_CUSTOM_BASE_URL': self.openai_base_url_var.get() if self.openai_base_url_var.get() else '', + 'GROQ_API_URL': self.groq_base_url_var.get() if self.groq_base_url_var.get() else '', + 'FIREWORKS_API_URL': self.fireworks_base_url_var.get() if hasattr(self, 'fireworks_base_url_var') and self.fireworks_base_url_var.get() else '', + 'USE_CUSTOM_OPENAI_ENDPOINT': '1' if self.use_custom_openai_endpoint_var.get() else '0', + + # Image compression settings + 'ENABLE_IMAGE_COMPRESSION': "1" if self.config.get('enable_image_compression', False) else "0", + 'AUTO_COMPRESS_ENABLED': "1" if self.config.get('auto_compress_enabled', True) else "0", + 'TARGET_IMAGE_TOKENS': str(self.config.get('target_image_tokens', 1000)), + 'IMAGE_COMPRESSION_FORMAT': self.config.get('image_compression_format', 'auto'), + 'WEBP_QUALITY': str(self.config.get('webp_quality', 85)), + 'JPEG_QUALITY': str(self.config.get('jpeg_quality', 85)), + 'PNG_COMPRESSION': str(self.config.get('png_compression', 6)), + 'MAX_IMAGE_DIMENSION': str(self.config.get('max_image_dimension', 2048)), + 'MAX_IMAGE_SIZE_MB': str(self.config.get('max_image_size_mb', 10)), + 'PRESERVE_TRANSPARENCY': "1" if self.config.get('preserve_transparency', False) else "0", + 'PRESERVE_ORIGINAL_FORMAT': "1" if self.config.get('preserve_original_format', False) else "0", + 'OPTIMIZE_FOR_OCR': "1" if self.config.get('optimize_for_ocr', True) else "0", + 'PROGRESSIVE_ENCODING': "1" if self.config.get('progressive_encoding', True) else "0", + 'SAVE_COMPRESSED_IMAGES': "1" if self.config.get('save_compressed_images', False) else "0", + 'IMAGE_CHUNK_OVERLAP_PERCENT': self.image_chunk_overlap_var.get(), + + + # Metadata and batch header translation settings + 'TRANSLATE_METADATA_FIELDS': json.dumps(self.translate_metadata_fields), + 'METADATA_TRANSLATION_MODE': self.config.get('metadata_translation_mode', 'together'), + 'BATCH_TRANSLATE_HEADERS': "1" if self.batch_translate_headers_var.get() else "0", + 'HEADERS_PER_BATCH': self.headers_per_batch_var.get(), + 'UPDATE_HTML_HEADERS': "1" if self.update_html_headers_var.get() else "0", + 'SAVE_HEADER_TRANSLATIONS': "1" if self.save_header_translations_var.get() else "0", + 'METADATA_FIELD_PROMPTS': json.dumps(self.config.get('metadata_field_prompts', {})), + 'LANG_PROMPT_BEHAVIOR': self.config.get('lang_prompt_behavior', 'auto'), + 'FORCED_SOURCE_LANG': self.config.get('forced_source_lang', 'Korean'), + 'OUTPUT_LANGUAGE': self.config.get('output_language', 'English'), + 'METADATA_BATCH_PROMPT': self.config.get('metadata_batch_prompt', ''), + + # AI Hunter configuration + 'AI_HUNTER_CONFIG': json.dumps(self.config.get('ai_hunter_config', {})), + + # Anti-duplicate parameters + 'ENABLE_ANTI_DUPLICATE': '1' if hasattr(self, 'enable_anti_duplicate_var') and self.enable_anti_duplicate_var.get() else '0', + 'TOP_P': str(self.top_p_var.get()) if hasattr(self, 'top_p_var') else '1.0', + 'TOP_K': str(self.top_k_var.get()) if hasattr(self, 'top_k_var') else '0', + 'FREQUENCY_PENALTY': str(self.frequency_penalty_var.get()) if hasattr(self, 'frequency_penalty_var') else '0.0', + 'PRESENCE_PENALTY': str(self.presence_penalty_var.get()) if hasattr(self, 'presence_penalty_var') else '0.0', + 'REPETITION_PENALTY': str(self.repetition_penalty_var.get()) if hasattr(self, 'repetition_penalty_var') else '1.0', + 'CANDIDATE_COUNT': str(self.candidate_count_var.get()) if hasattr(self, 'candidate_count_var') else '1', + 'CUSTOM_STOP_SEQUENCES': self.custom_stop_sequences_var.get() if hasattr(self, 'custom_stop_sequences_var') else '', + 'LOGIT_BIAS_ENABLED': '1' if hasattr(self, 'logit_bias_enabled_var') and self.logit_bias_enabled_var.get() else '0', + 'LOGIT_BIAS_STRENGTH': str(self.logit_bias_strength_var.get()) if hasattr(self, 'logit_bias_strength_var') else '-0.5', + 'BIAS_COMMON_WORDS': '1' if hasattr(self, 'bias_common_words_var') and self.bias_common_words_var.get() else '0', + 'BIAS_REPETITIVE_PHRASES': '1' if hasattr(self, 'bias_repetitive_phrases_var') and self.bias_repetitive_phrases_var.get() else '0', + 'GOOGLE_APPLICATION_CREDENTIALS': os.environ.get('GOOGLE_APPLICATION_CREDENTIALS', ''), + 'GOOGLE_CLOUD_PROJECT': google_cloud_project, # Now properly set from credentials + 'VERTEX_AI_LOCATION': self.vertex_location_var.get() if hasattr(self, 'vertex_location_var') else 'us-east5', + 'IS_AZURE_ENDPOINT': '1' if (self.use_custom_openai_endpoint_var.get() and + ('.azure.com' in self.openai_base_url_var.get() or + '.cognitiveservices' in self.openai_base_url_var.get())) else '0', + 'AZURE_API_VERSION': str(self.config.get('azure_api_version', '2024-08-01-preview')), + + # Multi API Key support + 'USE_MULTI_API_KEYS': "1" if self.config.get('use_multi_api_keys', False) else "0", + 'MULTI_API_KEYS': json.dumps(self.config.get('multi_api_keys', [])) if self.config.get('use_multi_api_keys', False) else '[]', + 'FORCE_KEY_ROTATION': '1' if self.config.get('force_key_rotation', True) else '0', + 'ROTATION_FREQUENCY': str(self.config.get('rotation_frequency', 1)), + + } + print(f"[DEBUG] DISABLE_CHAPTER_MERGING = '{os.getenv('DISABLE_CHAPTER_MERGING', '0')}'") + + def run_glossary_extraction_thread(self): + """Start glossary extraction in a background worker (ThreadPoolExecutor)""" + if ((hasattr(self, 'translation_thread') and self.translation_thread and self.translation_thread.is_alive()) or + (hasattr(self, 'translation_future') and self.translation_future and not self.translation_future.done())): + self.append_log("โš ๏ธ Cannot run glossary extraction while translation is in progress.") + messagebox.showwarning("Process Running", "Please wait for translation to complete before extracting glossary.") + return + + if self.glossary_thread and self.glossary_thread.is_alive(): + self.stop_glossary_extraction() + return + + # Check if files are selected + if not hasattr(self, 'selected_files') or not self.selected_files: + # Try to get file from entry field (backward compatibility) + file_path = self.entry_epub.get().strip() + if not file_path or file_path.startswith("No file selected") or "files selected" in file_path: + messagebox.showerror("Error", "Please select file(s) to extract glossary from.") + return + self.selected_files = [file_path] + + # Reset stop flags + self.stop_requested = False + if glossary_stop_flag: + glossary_stop_flag(False) + + # IMPORTANT: Also reset the module's internal stop flag + try: + import extract_glossary_from_epub + extract_glossary_from_epub.set_stop_flag(False) + except: + pass + + # Use shared executor + self._ensure_executor() + if self.executor: + self.glossary_future = self.executor.submit(self.run_glossary_extraction_direct) + else: + thread_name = f"GlossaryThread_{int(time.time())}" + self.glossary_thread = threading.Thread(target=self.run_glossary_extraction_direct, name=thread_name, daemon=True) + self.glossary_thread.start() + self.master.after(100, self.update_run_button) + + def run_glossary_extraction_direct(self): + """Run glossary extraction directly - handles multiple files and different file types""" + try: + self.append_log("๐Ÿ”„ Loading glossary modules...") + if not self._lazy_load_modules(): + self.append_log("โŒ Failed to load glossary modules") + return + + if glossary_main is None: + self.append_log("โŒ Glossary extraction module is not available") + return + + # Create Glossary folder + os.makedirs("Glossary", exist_ok=True) + + # ========== NEW: APPLY OPF-BASED SORTING ========== + # Sort files based on OPF order if available + original_file_count = len(self.selected_files) + self.selected_files = self._get_opf_file_order(self.selected_files) + self.append_log(f"๐Ÿ“š Processing {original_file_count} files in reading order for glossary extraction") + # ==================================================== + + # Group files by type and folder + image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'} + + # Separate images and text files + image_files = [] + text_files = [] + + for file_path in self.selected_files: + ext = os.path.splitext(file_path)[1].lower() + if ext in image_extensions: + image_files.append(file_path) + elif ext in {'.epub', '.txt'}: + text_files.append(file_path) + else: + self.append_log(f"โš ๏ธ Skipping unsupported file type: {ext}") + + # Group images by folder + image_groups = {} + for img_path in image_files: + folder = os.path.dirname(img_path) + if folder not in image_groups: + image_groups[folder] = [] + image_groups[folder].append(img_path) + + total_groups = len(image_groups) + len(text_files) + current_group = 0 + successful = 0 + failed = 0 + + # Process image groups (each folder gets one combined glossary) + for folder, images in image_groups.items(): + if self.stop_requested: + break + + current_group += 1 + folder_name = os.path.basename(folder) if folder else "images" + + self.append_log(f"\n{'='*60}") + self.append_log(f"๐Ÿ“ Processing image folder ({current_group}/{total_groups}): {folder_name}") + self.append_log(f" Found {len(images)} images") + self.append_log(f"{'='*60}") + + # Process all images in this folder and extract glossary + if self._process_image_folder_for_glossary(folder_name, images): + successful += 1 + else: + failed += 1 + + # Process text files individually + for text_file in text_files: + if self.stop_requested: + break + + current_group += 1 + + self.append_log(f"\n{'='*60}") + self.append_log(f"๐Ÿ“„ Processing file ({current_group}/{total_groups}): {os.path.basename(text_file)}") + self.append_log(f"{'='*60}") + + if self._extract_glossary_from_text_file(text_file): + successful += 1 + else: + failed += 1 + + # Final summary + self.append_log(f"\n{'='*60}") + self.append_log(f"๐Ÿ“Š Glossary Extraction Summary:") + self.append_log(f" โœ… Successful: {successful} glossaries") + if failed > 0: + self.append_log(f" โŒ Failed: {failed} glossaries") + self.append_log(f" ๐Ÿ“ Total: {total_groups} glossaries") + self.append_log(f" ๐Ÿ“‚ All glossaries saved in: Glossary/") + self.append_log(f"{'='*60}") + + except Exception as e: + self.append_log(f"โŒ Glossary extraction setup error: {e}") + import traceback + self.append_log(f"โŒ Full error: {traceback.format_exc()}") + + finally: + self.stop_requested = False + if glossary_stop_flag: + glossary_stop_flag(False) + + # IMPORTANT: Also reset the module's internal stop flag + try: + import extract_glossary_from_epub + extract_glossary_from_epub.set_stop_flag(False) + except: + pass + + self.glossary_thread = None + self.current_file_index = 0 + self.master.after(0, self.update_run_button) + + def _process_image_folder_for_glossary(self, folder_name, image_files): + """Process all images from a folder and create a combined glossary with new format""" + try: + import hashlib + from unified_api_client import UnifiedClient, UnifiedClientError + + # Initialize folder-specific progress manager for images + self.glossary_progress_manager = self._init_image_glossary_progress_manager(folder_name) + + all_glossary_entries = [] + processed = 0 + skipped = 0 + + # Get API key and model + api_key = self.api_key_entry.get().strip() + model = self.model_var.get().strip() + + if not api_key or not model: + self.append_log("โŒ Error: API key and model required") + return False + + if not self.manual_glossary_prompt: + self.append_log("โŒ Error: No glossary prompt configured") + return False + + # Initialize API client + try: + client = UnifiedClient(model=model, api_key=api_key) + except Exception as e: + self.append_log(f"โŒ Failed to initialize API client: {str(e)}") + return False + + # Get temperature and other settings from glossary config + temperature = float(self.config.get('manual_glossary_temperature', 0.1)) + max_tokens = int(self.max_output_tokens_var.get()) if hasattr(self, 'max_output_tokens_var') else 8192 + api_delay = float(self.delay_entry.get()) if hasattr(self, 'delay_entry') else 2.0 + + self.append_log(f"๐Ÿ”ง Glossary extraction settings:") + self.append_log(f" Temperature: {temperature}") + self.append_log(f" Max tokens: {max_tokens}") + self.append_log(f" API delay: {api_delay}s") + format_parts = ["type", "raw_name", "translated_name", "gender"] + custom_fields_json = self.config.get('manual_custom_fields', '[]') + try: + custom_fields = json.loads(custom_fields_json) if isinstance(custom_fields_json, str) else custom_fields_json + if custom_fields: + format_parts.extend(custom_fields) + except: + custom_fields = [] + self.append_log(f" Format: Simple ({', '.join(format_parts)})") + + # Check honorifics filter toggle + honorifics_disabled = self.config.get('glossary_disable_honorifics_filter', False) + if honorifics_disabled: + self.append_log(f" Honorifics Filter: โŒ DISABLED") + else: + self.append_log(f" Honorifics Filter: โœ… ENABLED") + + # Track timing for ETA calculation + start_time = time.time() + total_entries_extracted = 0 + + # Set up thread-safe payload directory + thread_name = threading.current_thread().name + thread_id = threading.current_thread().ident + thread_dir = os.path.join("Payloads", "glossary", f"{thread_name}_{thread_id}") + os.makedirs(thread_dir, exist_ok=True) + + # Process each image + for i, image_path in enumerate(image_files): + if self.stop_requested: + self.append_log("โน๏ธ Glossary extraction stopped by user") + break + + image_name = os.path.basename(image_path) + self.append_log(f"\n ๐Ÿ–ผ๏ธ Processing image {i+1}/{len(image_files)}: {image_name}") + + # Check progress tracking for this image + try: + content_hash = self.glossary_progress_manager.get_content_hash(image_path) + except Exception as e: + content_hash = hashlib.sha256(image_path.encode()).hexdigest() + + # Check if already processed + needs_extraction, skip_reason, _ = self.glossary_progress_manager.check_image_status(image_path, content_hash) + + if not needs_extraction: + self.append_log(f" โญ๏ธ {skip_reason}") + # Try to load previous results if available + existing_data = self.glossary_progress_manager.get_cached_result(content_hash) + if existing_data: + all_glossary_entries.extend(existing_data) + continue + + # Skip cover images + if 'cover' in image_name.lower(): + self.append_log(f" โญ๏ธ Skipping cover image") + self.glossary_progress_manager.update(image_path, content_hash, status="skipped_cover") + skipped += 1 + continue + + # Update progress to in-progress + self.glossary_progress_manager.update(image_path, content_hash, status="in_progress") + + try: + # Read image + with open(image_path, 'rb') as img_file: + image_data = img_file.read() + + import base64 + image_base64 = base64.b64encode(image_data).decode('utf-8') + size_mb = len(image_data) / (1024 * 1024) + base_name = os.path.splitext(image_name)[0] + self.append_log(f" ๐Ÿ“Š Image size: {size_mb:.2f} MB") + + # Build prompt for new format + custom_fields_json = self.config.get('manual_custom_fields', '[]') + try: + custom_fields = json.loads(custom_fields_json) if isinstance(custom_fields_json, str) else custom_fields_json + except: + custom_fields = [] + + # Build honorifics instruction based on toggle + honorifics_instruction = "" + if not honorifics_disabled: + honorifics_instruction = "- Do NOT include honorifics (๋‹˜, ์”จ, ใ•ใ‚“, ๆง˜, etc.) in raw_name\n" + + if self.manual_glossary_prompt: + prompt = self.manual_glossary_prompt + + # Build fields description + fields_str = """- type: "character" for people/beings or "term" for locations/objects/concepts +- raw_name: name in the original language/script +- translated_name: English/romanized translation +- gender: (for characters only) Male/Female/Unknown""" + + if custom_fields: + for field in custom_fields: + fields_str += f"\n- {field}: custom field" + + # Replace placeholders + prompt = prompt.replace('{fields}', fields_str) + prompt = prompt.replace('{chapter_text}', '') + prompt = prompt.replace('{{fields}}', fields_str) + prompt = prompt.replace('{{chapter_text}}', '') + prompt = prompt.replace('{text}', '') + prompt = prompt.replace('{{text}}', '') + else: + # Default prompt + fields_str = """For each entity, provide JSON with these fields: +- type: "character" for people/beings or "term" for locations/objects/concepts +- raw_name: name in the original language/script +- translated_name: English/romanized translation +- gender: (for characters only) Male/Female/Unknown""" + + if custom_fields: + fields_str += "\nAdditional custom fields:" + for field in custom_fields: + fields_str += f"\n- {field}" + + prompt = f"""Extract all characters and important terms from this image. + +{fields_str} + +Important rules: +{honorifics_instruction}- Romanize names appropriately +- Output ONLY a JSON array""" + + messages = [{"role": "user", "content": prompt}] + + # Save request payload in thread-safe location + timestamp = time.strftime("%Y%m%d_%H%M%S") + payload_file = os.path.join(thread_dir, f"image_{timestamp}_{base_name}_request.json") + + request_payload = { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + "model": model, + "image_file": image_name, + "image_size_mb": size_mb, + "temperature": temperature, + "max_tokens": max_tokens, + "messages": messages, + "processed_prompt": prompt, + "honorifics_filter_enabled": not honorifics_disabled + } + + with open(payload_file, 'w', encoding='utf-8') as f: + json.dump(request_payload, f, ensure_ascii=False, indent=2) + + self.append_log(f" ๐Ÿ“ Saved request: {os.path.basename(payload_file)}") + self.append_log(f" ๐ŸŒ Extracting glossary from image...") + + # API call with interrupt support + response = self._call_api_with_interrupt( + client, messages, image_base64, temperature, max_tokens + ) + + # Check if stopped after API call + if self.stop_requested: + self.append_log("โน๏ธ Glossary extraction stopped after API call") + self.glossary_progress_manager.update(image_path, content_hash, status="cancelled") + return False + + # Get response content + glossary_json = None + if isinstance(response, (list, tuple)) and len(response) >= 2: + glossary_json = response[0] + elif hasattr(response, 'content'): + glossary_json = response.content + elif isinstance(response, str): + glossary_json = response + else: + glossary_json = str(response) + + if glossary_json and glossary_json.strip(): + # Save response in thread-safe location + response_file = os.path.join(thread_dir, f"image_{timestamp}_{base_name}_response.json") + response_payload = { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + "response_content": glossary_json, + "content_length": len(glossary_json) + } + with open(response_file, 'w', encoding='utf-8') as f: + json.dump(response_payload, f, ensure_ascii=False, indent=2) + + self.append_log(f" ๐Ÿ“ Saved response: {os.path.basename(response_file)}") + + # Parse the JSON response + try: + # Clean up the response + glossary_json = glossary_json.strip() + if glossary_json.startswith('```'): + glossary_json = glossary_json.split('```')[1] + if glossary_json.startswith('json'): + glossary_json = glossary_json[4:] + glossary_json = glossary_json.strip() + if glossary_json.endswith('```'): + glossary_json = glossary_json[:-3].strip() + + # Parse JSON + glossary_data = json.loads(glossary_json) + + # Process entries + entries_for_this_image = [] + if isinstance(glossary_data, list): + for entry in glossary_data: + # Validate entry format + if isinstance(entry, dict) and 'type' in entry and 'raw_name' in entry: + # Clean raw_name + entry['raw_name'] = entry['raw_name'].strip() + + # Ensure required fields + if 'translated_name' not in entry: + entry['translated_name'] = entry.get('name', entry['raw_name']) + + # Add gender for characters if missing + if entry['type'] == 'character' and 'gender' not in entry: + entry['gender'] = 'Unknown' + + entries_for_this_image.append(entry) + all_glossary_entries.append(entry) + + # Show progress + elapsed = time.time() - start_time + valid_count = len(entries_for_this_image) + + for j, entry in enumerate(entries_for_this_image): + total_entries_extracted += 1 + + # Calculate ETA + if total_entries_extracted == 1: + eta = 0.0 + else: + avg_time = elapsed / total_entries_extracted + remaining_images = len(image_files) - (i + 1) + estimated_remaining_entries = remaining_images * 3 + eta = avg_time * estimated_remaining_entries + + # Get entry name + entry_name = f"{entry['raw_name']} ({entry['translated_name']})" + + # Print progress + progress_msg = f'[Image {i+1}/{len(image_files)}] [{j+1}/{valid_count}] ({elapsed:.1f}s elapsed, ETA {eta:.1f}s) โ†’ {entry["type"]}: {entry_name}' + print(progress_msg) + self.append_log(progress_msg) + + self.append_log(f" โœ… Extracted {valid_count} entries") + + # Update progress with extracted data + self.glossary_progress_manager.update( + image_path, + content_hash, + status="completed", + extracted_data=entries_for_this_image + ) + + processed += 1 + + # Save intermediate progress with skip logic + if all_glossary_entries: + self._save_intermediate_glossary_with_skip(folder_name, all_glossary_entries) + + except json.JSONDecodeError as e: + self.append_log(f" โŒ Failed to parse JSON: {e}") + self.append_log(f" Response preview: {glossary_json[:200]}...") + self.glossary_progress_manager.update(image_path, content_hash, status="error", error=str(e)) + skipped += 1 + else: + self.append_log(f" โš ๏ธ No glossary data in response") + self.glossary_progress_manager.update(image_path, content_hash, status="error", error="No data") + skipped += 1 + + # Add delay between API calls + if i < len(image_files) - 1 and not self.stop_requested: + self.append_log(f" โฑ๏ธ Waiting {api_delay}s before next image...") + elapsed = 0 + while elapsed < api_delay and not self.stop_requested: + time.sleep(0.1) + elapsed += 0.1 + + except Exception as e: + self.append_log(f" โŒ Failed to process: {str(e)}") + self.glossary_progress_manager.update(image_path, content_hash, status="error", error=str(e)) + skipped += 1 + + if not all_glossary_entries: + self.append_log(f"โŒ No glossary entries extracted from any images") + return False + + self.append_log(f"\n๐Ÿ“ Extracted {len(all_glossary_entries)} total entries from {processed} images") + + # Save the final glossary with skip logic + output_file = os.path.join("Glossary", f"{folder_name}_glossary.json") + + try: + # Apply skip logic for duplicates + self.append_log(f"๐Ÿ“Š Applying skip logic for duplicate raw names...") + + # Import or define the skip function + try: + from extract_glossary_from_epub import skip_duplicate_entries, remove_honorifics + # Set environment variable for honorifics toggle + import os + os.environ['GLOSSARY_DISABLE_HONORIFICS_FILTER'] = '1' if honorifics_disabled else '0' + final_entries = skip_duplicate_entries(all_glossary_entries) + except: + # Fallback implementation + def remove_honorifics_local(name): + if not name or honorifics_disabled: + return name.strip() + + # Modern honorifics + korean_honorifics = ['๋‹˜', '์”จ', '๊ตฐ', '์–‘', '์„ ์ƒ๋‹˜', '์‚ฌ์žฅ๋‹˜', '๊ณผ์žฅ๋‹˜', '๋Œ€๋ฆฌ๋‹˜', '์ฃผ์ž„๋‹˜', '์ด์‚ฌ๋‹˜'] + japanese_honorifics = ['ใ•ใ‚“', 'ใ•ใพ', 'ๆง˜', 'ใใ‚“', 'ๅ›', 'ใกใ‚ƒใ‚“', 'ใ›ใ‚“ใ›ใ„', 'ๅ…ˆ็”Ÿ'] + chinese_honorifics = ['ๅ…ˆ็”Ÿ', 'ๅฅณๅฃซ', 'ๅฐๅง', '่€ๅธˆ', 'ๅธˆๅ‚…', 'ๅคงไบบ'] + + # Archaic honorifics + korean_archaic = ['๊ณต', '์˜น', '์–ด๋ฅธ', '๋‚˜๋ฆฌ', '๋‚˜์œผ๋ฆฌ', '๋Œ€๊ฐ', '์˜๊ฐ', '๋งˆ๋‹˜', '๋งˆ๋งˆ'] + japanese_archaic = ['ใฉใฎ', 'ๆฎฟ', 'ใฟใ“ใจ', 'ๅ‘ฝ', 'ๅฐŠ', 'ใฒใ‚', 'ๅงซ'] + chinese_archaic = ['ๅ…ฌ', 'ไพฏ', 'ไผฏ', 'ๅญ', '็”ท', '็Ž‹', 'ๅ›', 'ๅฟ', 'ๅคงๅคซ'] + + all_honorifics = (korean_honorifics + japanese_honorifics + chinese_honorifics + + korean_archaic + japanese_archaic + chinese_archaic) + + name_cleaned = name.strip() + sorted_honorifics = sorted(all_honorifics, key=len, reverse=True) + + for honorific in sorted_honorifics: + if name_cleaned.endswith(honorific): + name_cleaned = name_cleaned[:-len(honorific)].strip() + break + + return name_cleaned + + seen_raw_names = set() + final_entries = [] + skipped = 0 + + for entry in all_glossary_entries: + raw_name = entry.get('raw_name', '') + if not raw_name: + continue + + cleaned_name = remove_honorifics_local(raw_name) + + if cleaned_name.lower() in seen_raw_names: + skipped += 1 + self.append_log(f" โญ๏ธ Skipping duplicate: {raw_name}") + continue + + seen_raw_names.add(cleaned_name.lower()) + final_entries.append(entry) + + self.append_log(f"โœ… Kept {len(final_entries)} unique entries (skipped {skipped} duplicates)") + + # Save final glossary + os.makedirs("Glossary", exist_ok=True) + + self.append_log(f"๐Ÿ’พ Writing glossary to: {output_file}") + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(final_entries, f, ensure_ascii=False, indent=2) + + # Also save as CSV for compatibility + csv_file = output_file.replace('.json', '.csv') + with open(csv_file, 'w', encoding='utf-8', newline='') as f: + import csv + writer = csv.writer(f) + # Write header + header = ['type', 'raw_name', 'translated_name', 'gender'] + if custom_fields: + header.extend(custom_fields) + writer.writerow(header) + + for entry in final_entries: + row = [ + entry.get('type', ''), + entry.get('raw_name', ''), + entry.get('translated_name', ''), + entry.get('gender', '') if entry.get('type') == 'character' else '' + ] + # Add custom field values + if custom_fields: + for field in custom_fields: + row.append(entry.get(field, '')) + writer.writerow(row) + + self.append_log(f"๐Ÿ’พ Also saved as CSV: {os.path.basename(csv_file)}") + + # Verify files were created + if os.path.exists(output_file): + file_size = os.path.getsize(output_file) + self.append_log(f"โœ… Glossary saved successfully ({file_size} bytes)") + + # Show sample of what was saved + if final_entries: + self.append_log(f"\n๐Ÿ“‹ Sample entries:") + for entry in final_entries[:5]: + self.append_log(f" - [{entry['type']}] {entry['raw_name']} โ†’ {entry['translated_name']}") + else: + self.append_log(f"โŒ File was not created!") + return False + + return True + + except Exception as e: + self.append_log(f"โŒ Failed to save glossary: {e}") + import traceback + self.append_log(f"Full error: {traceback.format_exc()}") + return False + + except Exception as e: + self.append_log(f"โŒ Error processing image folder: {str(e)}") + import traceback + self.append_log(f"โŒ Full error: {traceback.format_exc()}") + return False + + def _init_image_glossary_progress_manager(self, folder_name): + """Initialize a folder-specific progress manager for image glossary extraction""" + import hashlib + + class ImageGlossaryProgressManager: + def __init__(self, folder_name): + self.PROGRESS_FILE = os.path.join("Glossary", f"{folder_name}_glossary_progress.json") + self.prog = self._init_or_load() + + def _init_or_load(self): + """Initialize or load progress tracking""" + if os.path.exists(self.PROGRESS_FILE): + try: + with open(self.PROGRESS_FILE, "r", encoding="utf-8") as pf: + return json.load(pf) + except Exception as e: + return {"images": {}, "content_hashes": {}, "extracted_data": {}, "version": "1.0"} + else: + return {"images": {}, "content_hashes": {}, "extracted_data": {}, "version": "1.0"} + + def save(self): + """Save progress to file atomically""" + try: + os.makedirs(os.path.dirname(self.PROGRESS_FILE), exist_ok=True) + temp_file = self.PROGRESS_FILE + '.tmp' + with open(temp_file, "w", encoding="utf-8") as pf: + json.dump(self.prog, pf, ensure_ascii=False, indent=2) + + if os.path.exists(self.PROGRESS_FILE): + os.remove(self.PROGRESS_FILE) + os.rename(temp_file, self.PROGRESS_FILE) + except Exception as e: + pass + + def get_content_hash(self, file_path): + """Generate content hash for a file""" + hasher = hashlib.sha256() + with open(file_path, 'rb') as f: + for chunk in iter(lambda: f.read(4096), b""): + hasher.update(chunk) + return hasher.hexdigest() + + def check_image_status(self, image_path, content_hash): + """Check if an image needs glossary extraction""" + image_name = os.path.basename(image_path) + + # Check for skip markers + skip_key = f"skip_{image_name}" + if skip_key in self.prog: + skip_info = self.prog[skip_key] + if skip_info.get('status') == 'skipped': + return False, f"Image marked as skipped", None + + # Check if image has already been processed + if content_hash in self.prog["images"]: + image_info = self.prog["images"][content_hash] + status = image_info.get("status") + + if status == "completed": + return False, f"Already processed", None + elif status == "skipped_cover": + return False, "Cover image - skipped", None + elif status == "error": + # Previous error, retry + return True, None, None + + return True, None, None + + def get_cached_result(self, content_hash): + """Get cached extraction result for a content hash""" + if content_hash in self.prog.get("extracted_data", {}): + return self.prog["extracted_data"][content_hash] + return None + + def update(self, image_path, content_hash, status="in_progress", error=None, extracted_data=None): + """Update progress for an image""" + image_name = os.path.basename(image_path) + + image_info = { + "name": image_name, + "path": image_path, + "content_hash": content_hash, + "status": status, + "last_updated": time.time() + } + + if error: + image_info["error"] = str(error) + + self.prog["images"][content_hash] = image_info + + # Store extracted data separately for reuse + if extracted_data and status == "completed": + if "extracted_data" not in self.prog: + self.prog["extracted_data"] = {} + self.prog["extracted_data"][content_hash] = extracted_data + + self.save() + + # Create and return the progress manager + progress_manager = ImageGlossaryProgressManager(folder_name) + self.append_log(f"๐Ÿ“Š Progress tracking in: Glossary/{folder_name}_glossary_progress.json") + return progress_manager + + def _save_intermediate_glossary_with_skip(self, folder_name, entries): + """Save intermediate glossary results with skip logic""" + try: + output_file = os.path.join("Glossary", f"{folder_name}_glossary.json") + + # Apply skip logic + try: + from extract_glossary_from_epub import skip_duplicate_entries + unique_entries = skip_duplicate_entries(entries) + except: + # Fallback + seen = set() + unique_entries = [] + for entry in entries: + key = entry.get('raw_name', '').lower().strip() + if key and key not in seen: + seen.add(key) + unique_entries.append(entry) + + # Write the file + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(unique_entries, f, ensure_ascii=False, indent=2) + + except Exception as e: + self.append_log(f" โš ๏ธ Could not save intermediate glossary: {e}") + + def _call_api_with_interrupt(self, client, messages, image_base64, temperature, max_tokens): + """Make API call with interrupt support and thread safety""" + import threading + import queue + from unified_api_client import UnifiedClientError + + result_queue = queue.Queue() + + def api_call(): + try: + result = client.send_image(messages, image_base64, temperature=temperature, max_tokens=max_tokens) + result_queue.put(('success', result)) + except Exception as e: + result_queue.put(('error', e)) + + api_thread = threading.Thread(target=api_call) + api_thread.daemon = True + api_thread.start() + + # Check for stop every 0.5 seconds + while api_thread.is_alive(): + if self.stop_requested: + # Cancel the operation + if hasattr(client, 'cancel_current_operation'): + client.cancel_current_operation() + raise UnifiedClientError("Glossary extraction stopped by user") + + try: + status, result = result_queue.get(timeout=0.5) + if status == 'error': + raise result + return result + except queue.Empty: + continue + + # Thread finished, get final result + try: + status, result = result_queue.get(timeout=1.0) + if status == 'error': + raise result + return result + except queue.Empty: + raise UnifiedClientError("API call completed but no result received") + + def _extract_glossary_from_text_file(self, file_path): + """Extract glossary from EPUB or TXT file using existing glossary extraction""" + # Skip glossary extraction for traditional APIs + try: + api_key = self.api_key_entry.get() + model = self.model_var.get() + if is_traditional_translation_api(model): + self.append_log("โ„น๏ธ Skipping automatic glossary extraction (not supported by Google Translate / DeepL translation APIs)") + return {} + + # Validate Vertex AI credentials if needed + elif '@' in model or model.startswith('vertex/'): + google_creds = self.config.get('google_cloud_credentials') + if not google_creds or not os.path.exists(google_creds): + self.append_log("โŒ Error: Google Cloud credentials required for Vertex AI models.") + return False + + os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = google_creds + self.append_log(f"๐Ÿ”‘ Using Google Cloud credentials: {os.path.basename(google_creds)}") + + if not api_key: + try: + with open(google_creds, 'r') as f: + creds_data = json.load(f) + api_key = creds_data.get('project_id', 'vertex-ai-project') + self.append_log(f"๐Ÿ”‘ Using project ID as API key: {api_key}") + except: + api_key = 'vertex-ai-project' + elif not api_key: + self.append_log("โŒ Error: Please enter your API key.") + return False + + old_argv = sys.argv + old_env = dict(os.environ) + + # Output file - do NOT prepend Glossary/ because extract_glossary_from_epub.py handles that + epub_base = os.path.splitext(os.path.basename(file_path))[0] + output_path = f"{epub_base}_glossary.json" + + try: + # Set up environment variables + env_updates = { + 'GLOSSARY_TEMPERATURE': str(self.config.get('manual_glossary_temperature', 0.1)), + 'GLOSSARY_CONTEXT_LIMIT': str(self.config.get('manual_context_limit', 2)), + 'MODEL': self.model_var.get(), + 'OPENAI_API_KEY': api_key, + 'OPENAI_OR_Gemini_API_KEY': api_key, + 'API_KEY': api_key, + 'MAX_OUTPUT_TOKENS': str(self.max_output_tokens), + 'BATCH_TRANSLATION': "1" if self.batch_translation_var.get() else "0", + 'BATCH_SIZE': str(self.batch_size_var.get()), + 'GLOSSARY_SYSTEM_PROMPT': self.manual_glossary_prompt, + 'CHAPTER_RANGE': self.chapter_range_entry.get().strip(), + 'GLOSSARY_DISABLE_HONORIFICS_FILTER': '1' if self.config.get('glossary_disable_honorifics_filter', False) else '0', + 'GLOSSARY_HISTORY_ROLLING': "1" if self.glossary_history_rolling_var.get() else "0", + 'DISABLE_GEMINI_SAFETY': str(self.config.get('disable_gemini_safety', False)).lower(), + 'OPENROUTER_USE_HTTP_ONLY': '1' if self.openrouter_http_only_var.get() else '0', + 'GLOSSARY_DUPLICATE_KEY_MODE': 'skip', # Always use skip mode for new format + 'SEND_INTERVAL_SECONDS': str(self.delay_entry.get()), + 'THREAD_SUBMISSION_DELAY_SECONDS': self.thread_delay_var.get().strip() or '0.5', + 'CONTEXTUAL': '1' if self.contextual_var.get() else '0', + 'GOOGLE_APPLICATION_CREDENTIALS': os.environ.get('GOOGLE_APPLICATION_CREDENTIALS', ''), + + # NEW GLOSSARY ADDITIONS + 'GLOSSARY_MIN_FREQUENCY': str(self.glossary_min_frequency_var.get()), + 'GLOSSARY_MAX_NAMES': str(self.glossary_max_names_var.get()), + 'GLOSSARY_MAX_TITLES': str(self.glossary_max_titles_var.get()), + 'GLOSSARY_BATCH_SIZE': str(self.glossary_batch_size_var.get()), + 'ENABLE_AUTO_GLOSSARY': "1" if self.enable_auto_glossary_var.get() else "0", + 'APPEND_GLOSSARY': "1" if self.append_glossary_var.get() else "0", + 'GLOSSARY_STRIP_HONORIFICS': '1' if hasattr(self, 'strip_honorifics_var') and self.strip_honorifics_var.get() else '1', + 'AUTO_GLOSSARY_PROMPT': getattr(self, 'auto_glossary_prompt', ''), + 'APPEND_GLOSSARY_PROMPT': getattr(self, 'append_glossary_prompt', '- Follow this reference glossary for consistent translation (Do not output any raw entries):\n'), + 'GLOSSARY_TRANSLATION_PROMPT': getattr(self, 'glossary_translation_prompt', ''), + 'GLOSSARY_CUSTOM_ENTRY_TYPES': json.dumps(getattr(self, 'custom_entry_types', {})), + 'GLOSSARY_CUSTOM_FIELDS': json.dumps(getattr(self, 'custom_glossary_fields', [])), + 'GLOSSARY_FUZZY_THRESHOLD': str(self.config.get('glossary_fuzzy_threshold', 0.90)), + 'MANUAL_GLOSSARY': self.manual_glossary_path if hasattr(self, 'manual_glossary_path') and self.manual_glossary_path else '', + 'GLOSSARY_FORMAT_INSTRUCTIONS': self.glossary_format_instructions if hasattr(self, 'glossary_format_instructions') else '', + + + } + + # Add project ID for Vertex AI + if '@' in model or model.startswith('vertex/'): + google_creds = self.config.get('google_cloud_credentials') + if google_creds and os.path.exists(google_creds): + try: + with open(google_creds, 'r') as f: + creds_data = json.load(f) + env_updates['GOOGLE_CLOUD_PROJECT'] = creds_data.get('project_id', '') + env_updates['VERTEX_AI_LOCATION'] = 'us-central1' + except: + pass + + if self.custom_glossary_fields: + env_updates['GLOSSARY_CUSTOM_FIELDS'] = json.dumps(self.custom_glossary_fields) + + # Propagate multi-key toggles so retry logic can engage + # Both must be enabled for main-then-fallback retry + try: + if self.config.get('use_multi_api_keys', False): + os.environ['USE_MULTI_KEYS'] = '1' + else: + os.environ['USE_MULTI_KEYS'] = '0' + if self.config.get('use_fallback_keys', False): + os.environ['USE_FALLBACK_KEYS'] = '1' + else: + os.environ['USE_FALLBACK_KEYS'] = '0' + except Exception: + # Keep going even if we can't set env for some reason + pass + + os.environ.update(env_updates) + + chap_range = self.chapter_range_entry.get().strip() + if chap_range: + self.append_log(f"๐Ÿ“Š Chapter Range: {chap_range} (glossary extraction will only process these chapters)") + + if self.token_limit_disabled: + os.environ['MAX_INPUT_TOKENS'] = '' + self.append_log("๐ŸŽฏ Input Token Limit: Unlimited (disabled)") + else: + token_val = self.token_limit_entry.get().strip() + if token_val and token_val.isdigit(): + os.environ['MAX_INPUT_TOKENS'] = token_val + self.append_log(f"๐ŸŽฏ Input Token Limit: {token_val}") + else: + os.environ['MAX_INPUT_TOKENS'] = '50000' + self.append_log(f"๐ŸŽฏ Input Token Limit: 50000 (default)") + + sys.argv = [ + 'extract_glossary_from_epub.py', + '--epub', file_path, + '--output', output_path, + '--config', CONFIG_FILE + ] + + self.append_log(f"๐Ÿš€ Extracting glossary from: {os.path.basename(file_path)}") + self.append_log(f"๐Ÿ“ค Output Token Limit: {self.max_output_tokens}") + format_parts = ["type", "raw_name", "translated_name", "gender"] + custom_fields_json = self.config.get('manual_custom_fields', '[]') + try: + custom_fields = json.loads(custom_fields_json) if isinstance(custom_fields_json, str) else custom_fields_json + if custom_fields: + format_parts.extend(custom_fields) + except: + custom_fields = [] + self.append_log(f" Format: Simple ({', '.join(format_parts)})") + + # Check honorifics filter + if self.config.get('glossary_disable_honorifics_filter', False): + self.append_log(f"๐Ÿ“‘ Honorifics Filter: โŒ DISABLED") + else: + self.append_log(f"๐Ÿ“‘ Honorifics Filter: โœ… ENABLED") + + os.environ['MAX_OUTPUT_TOKENS'] = str(self.max_output_tokens) + + # Enhanced stop callback that checks both flags + def enhanced_stop_callback(): + # Check GUI stop flag + if self.stop_requested: + return True + + # Also check if the glossary extraction module has its own stop flag + try: + import extract_glossary_from_epub + if hasattr(extract_glossary_from_epub, 'is_stop_requested') and extract_glossary_from_epub.is_stop_requested(): + return True + except: + pass + + return False + + try: + # Import traceback for better error info + import traceback + + # Run glossary extraction with enhanced stop callback + glossary_main( + log_callback=self.append_log, + stop_callback=enhanced_stop_callback + ) + except Exception as e: + # Get the full traceback + tb_lines = traceback.format_exc() + self.append_log(f"โŒ FULL ERROR TRACEBACK:\n{tb_lines}") + self.append_log(f"โŒ Error extracting glossary from {os.path.basename(file_path)}: {e}") + return False + + # Check if stopped + if self.stop_requested: + self.append_log("โน๏ธ Glossary extraction was stopped") + return False + + # Check if output file exists + if not self.stop_requested and os.path.exists(output_path): + self.append_log(f"โœ… Glossary saved to: {output_path}") + return True + else: + # Check if it was saved in Glossary folder by the script + glossary_path = os.path.join("Glossary", output_path) + if os.path.exists(glossary_path): + self.append_log(f"โœ… Glossary saved to: {glossary_path}") + return True + return False + + finally: + sys.argv = old_argv + os.environ.clear() + os.environ.update(old_env) + + except Exception as e: + self.append_log(f"โŒ Error extracting glossary from {os.path.basename(file_path)}: {e}") + return False + + def epub_converter(self): + """Start EPUB converter in a separate thread""" + if not self._lazy_load_modules(): + self.append_log("โŒ Failed to load EPUB converter modules") + return + + if fallback_compile_epub is None: + self.append_log("โŒ EPUB converter module is not available") + messagebox.showerror("Module Error", "EPUB converter module is not available.") + return + + if hasattr(self, 'translation_thread') and self.translation_thread and self.translation_thread.is_alive(): + self.append_log("โš ๏ธ Cannot run EPUB converter while translation is in progress.") + messagebox.showwarning("Process Running", "Please wait for translation to complete before converting EPUB.") + return + + if hasattr(self, 'glossary_thread') and self.glossary_thread and self.glossary_thread.is_alive(): + self.append_log("โš ๏ธ Cannot run EPUB converter while glossary extraction is in progress.") + messagebox.showwarning("Process Running", "Please wait for glossary extraction to complete before converting EPUB.") + return + + if hasattr(self, 'epub_thread') and self.epub_thread and self.epub_thread.is_alive(): + self.stop_epub_converter() + return + + folder = filedialog.askdirectory(title="Select translation output folder") + if not folder: + return + + self.epub_folder = folder + self.stop_requested = False + # Run via shared executor + self._ensure_executor() + if self.executor: + self.epub_future = self.executor.submit(self.run_epub_converter_direct) + # Ensure button state is refreshed when the future completes + def _epub_done_callback(f): + try: + self.master.after(0, lambda: (setattr(self, 'epub_future', None), self.update_run_button())) + except Exception: + pass + try: + self.epub_future.add_done_callback(_epub_done_callback) + except Exception: + pass + else: + self.epub_thread = threading.Thread(target=self.run_epub_converter_direct, daemon=True) + self.epub_thread.start() + self.master.after(100, self.update_run_button) + + def run_epub_converter_direct(self): + """Run EPUB converter directly without blocking GUI""" + try: + folder = self.epub_folder + self.append_log("๐Ÿ“ฆ Starting EPUB Converter...") + + # Set environment variables for EPUB converter + os.environ['DISABLE_EPUB_GALLERY'] = "1" if self.disable_epub_gallery_var.get() else "0" + os.environ['DISABLE_AUTOMATIC_COVER_CREATION'] = "1" if getattr(self, 'disable_automatic_cover_creation_var', tk.BooleanVar(value=False)).get() else "0" + os.environ['TRANSLATE_COVER_HTML'] = "1" if getattr(self, 'translate_cover_html_var', tk.BooleanVar(value=False)).get() else "0" + + source_epub_file = os.path.join(folder, 'source_epub.txt') + if os.path.exists(source_epub_file): + try: + with open(source_epub_file, 'r', encoding='utf-8') as f: + source_epub_path = f.read().strip() + + if source_epub_path and os.path.exists(source_epub_path): + os.environ['EPUB_PATH'] = source_epub_path + self.append_log(f"โœ… Using source EPUB for proper chapter ordering: {os.path.basename(source_epub_path)}") + else: + self.append_log(f"โš ๏ธ Source EPUB file not found: {source_epub_path}") + except Exception as e: + self.append_log(f"โš ๏ธ Could not read source EPUB reference: {e}") + else: + self.append_log("โ„น๏ธ No source EPUB reference found - using filename-based ordering") + + # Set API credentials and model + api_key = self.api_key_entry.get() + if api_key: + os.environ['API_KEY'] = api_key + os.environ['OPENAI_API_KEY'] = api_key + os.environ['OPENAI_OR_Gemini_API_KEY'] = api_key + + model = self.model_var.get() + if model: + os.environ['MODEL'] = model + + # Set translation parameters from GUI + os.environ['TRANSLATION_TEMPERATURE'] = str(self.trans_temp.get()) + os.environ['MAX_OUTPUT_TOKENS'] = str(self.max_output_tokens) + + # Set batch translation settings + os.environ['BATCH_TRANSLATE_HEADERS'] = "1" if self.batch_translate_headers_var.get() else "0" + os.environ['HEADERS_PER_BATCH'] = str(self.headers_per_batch_var.get()) + os.environ['UPDATE_HTML_HEADERS'] = "1" if self.update_html_headers_var.get() else "0" + os.environ['SAVE_HEADER_TRANSLATIONS'] = "1" if self.save_header_translations_var.get() else "0" + + # Set metadata translation settings + os.environ['TRANSLATE_METADATA_FIELDS'] = json.dumps(self.translate_metadata_fields) + os.environ['METADATA_TRANSLATION_MODE'] = self.config.get('metadata_translation_mode', 'together') + print(f"[DEBUG] METADATA_FIELD_PROMPTS from env: {os.getenv('METADATA_FIELD_PROMPTS', 'NOT SET')[:100]}...") + + # Debug: Log what we're setting + self.append_log(f"[DEBUG] Setting TRANSLATE_METADATA_FIELDS: {self.translate_metadata_fields}") + self.append_log(f"[DEBUG] Enabled fields: {[k for k, v in self.translate_metadata_fields.items() if v]}") + + # Set book title translation settings + os.environ['TRANSLATE_BOOK_TITLE'] = "1" if self.translate_book_title_var.get() else "0" + os.environ['BOOK_TITLE_PROMPT'] = self.book_title_prompt + os.environ['BOOK_TITLE_SYSTEM_PROMPT'] = self.config.get('book_title_system_prompt', + "You are a translator. Respond with only the translated text, nothing else.") + + # Set prompts + os.environ['SYSTEM_PROMPT'] = self.prompt_text.get("1.0", "end").strip() + + fallback_compile_epub(folder, log_callback=self.append_log) + + if not self.stop_requested: + self.append_log("โœ… EPUB Converter completed successfully!") + + epub_files = [f for f in os.listdir(folder) if f.endswith('.epub')] + if epub_files: + epub_files.sort(key=lambda x: os.path.getmtime(os.path.join(folder, x)), reverse=True) + out_file = os.path.join(folder, epub_files[0]) + self.master.after(0, lambda: messagebox.showinfo("EPUB Compilation Success", f"Created: {out_file}")) + else: + self.append_log("โš ๏ธ EPUB file was not created. Check the logs for details.") + + except Exception as e: + error_str = str(e) + self.append_log(f"โŒ EPUB Converter error: {error_str}") + + if "Document is empty" not in error_str: + self.master.after(0, lambda: messagebox.showerror("EPUB Converter Failed", f"Error: {error_str}")) + else: + self.append_log("๐Ÿ“‹ Check the log above for details about what went wrong.") + + finally: + # Always reset the thread and update button state when done + self.epub_thread = None + # Clear any future handle so update_run_button won't consider it running + if hasattr(self, 'epub_future'): + try: + # Don't cancel; just drop the reference. Future is already done here. + self.epub_future = None + except Exception: + pass + self.stop_requested = False + # Schedule GUI update on main thread + self.master.after(0, self.update_run_button) + + + def run_qa_scan(self, mode_override=None, non_interactive=False, preselected_files=None): + """Run QA scan with mode selection and settings""" + # Create a small loading window with icon + loading_window = self.wm.create_simple_dialog( + self.master, + "Loading QA Scanner", + width=300, + height=120, + modal=True, + hide_initially=False + ) + + # Create content frame + content_frame = tk.Frame(loading_window, padx=20, pady=20) + content_frame.pack(fill=tk.BOTH, expand=True) + + # Try to add icon image if available + status_label = None + try: + from PIL import Image, ImageTk + ico_path = os.path.join(self.base_dir, 'Halgakos.ico') + if os.path.isfile(ico_path): + # Load icon at small size + icon_image = Image.open(ico_path) + icon_image = icon_image.resize((32, 32), Image.Resampling.LANCZOS) + icon_photo = ImageTk.PhotoImage(icon_image) + + # Create horizontal layout + icon_label = tk.Label(content_frame, image=icon_photo) + icon_label.image = icon_photo # Keep reference + icon_label.pack(side=tk.LEFT, padx=(0, 10)) + + # Text on the right + text_frame = tk.Frame(content_frame) + text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + tk.Label(text_frame, text="Initializing QA Scanner...", + font=('TkDefaultFont', 11)).pack(anchor=tk.W) + status_label = tk.Label(text_frame, text="Loading modules...", + font=('TkDefaultFont', 9), fg='gray') + status_label.pack(anchor=tk.W, pady=(5, 0)) + else: + # Fallback without icon + tk.Label(content_frame, text="Initializing QA Scanner...", + font=('TkDefaultFont', 11)).pack() + status_label = tk.Label(content_frame, text="Loading modules...", + font=('TkDefaultFont', 9), fg='gray') + status_label.pack(pady=(10, 0)) + except ImportError: + # No PIL, simple text only + tk.Label(content_frame, text="Initializing QA Scanner...", + font=('TkDefaultFont', 11)).pack() + status_label = tk.Label(content_frame, text="Loading modules...", + font=('TkDefaultFont', 9), fg='gray') + status_label.pack(pady=(10, 0)) + + + self.master.update_idletasks() + + try: + # Update status + if status_label: + status_label.config(text="Loading translation modules...") + loading_window.update_idletasks() + + if not self._lazy_load_modules(): + loading_window.destroy() + self.append_log("โŒ Failed to load QA scanner modules") + return + + if status_label: + status_label.config(text="Preparing scanner...") + loading_window.update_idletasks() + + if scan_html_folder is None: + loading_window.destroy() + self.append_log("โŒ QA scanner module is not available") + messagebox.showerror("Module Error", "QA scanner module is not available.") + return + + if hasattr(self, 'qa_thread') and self.qa_thread and self.qa_thread.is_alive(): + loading_window.destroy() + self.stop_requested = True + self.append_log("โ›” QA scan stop requested.") + return + + # Close loading window + loading_window.destroy() + self.append_log("โœ… QA scanner initialized successfully") + + except Exception as e: + loading_window.destroy() + self.append_log(f"โŒ Error initializing QA scanner: {e}") + return + + # Load QA scanner settings from config + qa_settings = self.config.get('qa_scanner_settings', { + 'foreign_char_threshold': 10, + 'excluded_characters': '', + 'check_encoding_issues': False, + 'check_repetition': True, +'check_translation_artifacts': False, + 'min_file_length': 0, + 'report_format': 'detailed', + 'auto_save_report': True, + 'check_missing_html_tag': True, + 'check_invalid_nesting': False, + 'check_word_count_ratio': False, + 'check_multiple_headers': True, + 'warn_name_mismatch': True, + 'cache_enabled': True, + 'cache_auto_size': False, + 'cache_show_stats': False, + 'cache_normalize_text': 10000, + 'cache_similarity_ratio': 20000, + 'cache_content_hashes': 5000, + 'cache_semantic_fingerprint': 2000, + 'cache_structural_signature': 2000, + 'cache_translation_artifacts': 1000 + }) + # Debug: Print current settings + print(f"[DEBUG] QA Settings: {qa_settings}") + print(f"[DEBUG] Word count check enabled: {qa_settings.get('check_word_count_ratio', False)}") + + # Optionally skip mode dialog if a mode override was provided (e.g., scanning phase) + selected_mode_value = mode_override if mode_override else None + if selected_mode_value is None: + # Show mode selection dialog with settings + mode_dialog = self.wm.create_simple_dialog( + self.master, + "Select QA Scanner Mode", + width=1500, # Optimal width for 4 cards + height=650, # Compact height to ensure buttons are visible + hide_initially=True + ) + + if selected_mode_value is None: + # Set minimum size to prevent dialog from being too small + mode_dialog.minsize(1200, 600) + + # Variables + # selected_mode_value already set above + + # Main container with constrained expansion + main_container = tk.Frame(mode_dialog) + main_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # Add padding + + # Content with padding + main_frame = tk.Frame(main_container, padx=30, pady=20) # Reduced padding + main_frame.pack(fill=tk.X) # Only fill horizontally, don't expand + + # Title with subtitle + title_frame = tk.Frame(main_frame) + title_frame.pack(pady=(0, 15)) # Further reduced + + tk.Label(title_frame, text="Select Detection Mode", + font=('Arial', 28, 'bold'), fg='#f0f0f0').pack() # Further reduced + tk.Label(title_frame, text="Choose how sensitive the duplicate detection should be", + font=('Arial', 16), fg='#d0d0d0').pack(pady=(3, 0)) # Further reduced + + # Mode cards container - don't expand vertically to leave room for buttons + modes_container = tk.Frame(main_frame) + modes_container.pack(fill=tk.X, pady=(0, 10)) # Reduced bottom padding + + mode_data = [ + { + "value": "ai-hunter", + "emoji": "๐Ÿค–", + "title": "AI HUNTER", + "subtitle": "30% threshold", + "features": [ + "โœ“ Catches AI retranslations", + "โœ“ Different translation styles", + "โš  MANY false positives", + "โœ“ Same chapter, different words", + "โœ“ Detects paraphrasing", + "โœ“ Ultimate duplicate finder" + ], + "bg_color": "#2a1a3e", # Dark purple + "hover_color": "#6a4c93", # Medium purple + "border_color": "#8b5cf6", + "accent_color": "#a78bfa", + "recommendation": "โšก Best for finding ALL similar content" + }, + { + "value": "aggressive", + "emoji": "๐Ÿ”ฅ", + "title": "AGGRESSIVE", + "subtitle": "75% threshold", + "features": [ + "โœ“ Catches most duplicates", + "โœ“ Good for similar chapters", + "โš  Some false positives", + "โœ“ Finds edited duplicates", + "โœ“ Moderate detection", + "โœ“ Balanced approach" + ], + "bg_color": "#3a1f1f", # Dark red + "hover_color": "#8b3a3a", # Medium red + "border_color": "#dc2626", + "accent_color": "#ef4444", + "recommendation": None + }, + { + "value": "quick-scan", + "emoji": "โšก", + "title": "QUICK SCAN", + "subtitle": "85% threshold, Speed optimized", + "features": [ + "โœ“ 3-5x faster scanning", + "โœ“ Checks consecutive chapters only", + "โœ“ Simplified analysis", + "โœ“ Skips AI Hunter", + "โœ“ Good for large libraries", + "โœ“ Minimal resource usage" + ], + "bg_color": "#1f2937", # Dark gray + "hover_color": "#374151", # Medium gray + "border_color": "#059669", + "accent_color": "#10b981", + "recommendation": "โœ… Recommended for quick checks & large folders" + }, + { + "value": "custom", + "emoji": "โš™๏ธ", + "title": "CUSTOM", + "subtitle": "Configurable", + "features": [ + "โœ“ Fully customizable", + "โœ“ Set your own thresholds", + "โœ“ Advanced controls", + "โœ“ Fine-tune detection", + "โœ“ Expert mode", + "โœ“ Maximum flexibility" + ], + "bg_color": "#1e3a5f", # Dark blue + "hover_color": "#2c5aa0", # Medium blue + "border_color": "#3b82f6", + "accent_color": "#60a5fa", + "recommendation": None + } + ] + + # Restore original single-row layout (four cards across) + if selected_mode_value is None: + # Make each column share space evenly + for col in range(len(mode_data)): + modes_container.columnconfigure(col, weight=1) + # Keep row height stable + modes_container.rowconfigure(0, weight=0) + + for idx, mi in enumerate(mode_data): + # Main card frame with initial background + card = tk.Frame( + modes_container, + bg=mi["bg_color"], + highlightbackground=mi["border_color"], + highlightthickness=2, + relief='flat' + ) + card.grid(row=0, column=idx, padx=10, pady=5, sticky='nsew') + + # Content frame + content_frame = tk.Frame(card, bg=mi["bg_color"], cursor='hand2') + content_frame.pack(fill=tk.BOTH, expand=True, padx=15, pady=15) + + # Emoji + emoji_label = tk.Label(content_frame, text=mi["emoji"], font=('Arial', 48), bg=mi["bg_color"]) + emoji_label.pack(pady=(0, 5)) + + # Title + title_label = tk.Label(content_frame, text=mi["title"], font=('Arial', 24, 'bold'), fg='white', bg=mi["bg_color"]) + title_label.pack() + + # Subtitle + tk.Label(content_frame, text=mi["subtitle"], font=('Arial', 14), fg=mi["accent_color"], bg=mi["bg_color"]).pack(pady=(3, 10)) + + # Features + features_frame = tk.Frame(content_frame, bg=mi["bg_color"]) + features_frame.pack(fill=tk.X) + for feature in mi["features"]: + tk.Label(features_frame, text=feature, font=('Arial', 11), fg='#e0e0e0', bg=mi["bg_color"], justify=tk.LEFT).pack(anchor=tk.W, pady=1) + + # Recommendation badge if present + rec_frame = None + rec_label = None + if mi["recommendation"]: + rec_frame = tk.Frame(content_frame, bg=mi["accent_color"]) + rec_frame.pack(pady=(10, 0), fill=tk.X) + rec_label = tk.Label(rec_frame, text=mi["recommendation"], font=('Arial', 11, 'bold'), fg='white', bg=mi["accent_color"], padx=8, pady=4) + rec_label.pack() + + # Click handler + def make_click_handler(mode_value): + def handler(event=None): + nonlocal selected_mode_value + selected_mode_value = mode_value + mode_dialog.destroy() + return handler + click_handler = make_click_handler(mi["value"]) + + # Hover effects for this card only + def create_hover_handlers(md, widgets): + def on_enter(event=None): + for w in widgets: + try: + w.config(bg=md["hover_color"]) + except Exception: + pass + def on_leave(event=None): + for w in widgets: + try: + w.config(bg=md["bg_color"]) + except Exception: + pass + return on_enter, on_leave + + all_widgets = [content_frame, emoji_label, title_label, features_frame] + all_widgets += [child for child in features_frame.winfo_children() if isinstance(child, tk.Label)] + if rec_frame is not None: + all_widgets += [rec_frame, rec_label] + on_enter, on_leave = create_hover_handlers(mi, all_widgets) + + for widget in [card, content_frame, emoji_label, title_label, features_frame] + list(features_frame.winfo_children()): + widget.bind("", on_enter) + widget.bind("", on_leave) + widget.bind("", click_handler) + try: + widget.config(cursor='hand2') + except Exception: + pass + + if selected_mode_value is None: + # Add separator line before buttons + separator = tk.Frame(main_frame, height=1, bg='#cccccc') # Thinner separator + separator.pack(fill=tk.X, pady=(10, 0)) + + # Add settings button at the bottom + button_frame = tk.Frame(main_frame) + button_frame.pack(fill=tk.X, pady=(10, 5)) # Reduced padding + + # Create inner frame for centering buttons + button_inner = tk.Frame(button_frame) + button_inner.pack() + + def show_qa_settings(): + """Show QA Scanner settings dialog""" + self.show_qa_scanner_settings(mode_dialog, qa_settings) + + # Auto-search checkbox - moved to left side of Scanner Settings + if not hasattr(self, 'qa_auto_search_output_var'): + self.qa_auto_search_output_var = tk.BooleanVar(value=self.config.get('qa_auto_search_output', True)) + tb.Checkbutton( + button_inner, + text="Auto-search output", # Renamed from "Auto-search output folder" + variable=self.qa_auto_search_output_var, + bootstyle="round-toggle" + ).pack(side=tk.LEFT, padx=10) + + settings_btn = tb.Button( + button_inner, + text="โš™๏ธ Scanner Settings", # Added extra space + command=show_qa_settings, + bootstyle="info-outline", # Changed to be more visible + width=18, # Slightly smaller + padding=(8, 10) # Reduced padding + ) + settings_btn.pack(side=tk.LEFT, padx=10) + + cancel_btn = tb.Button( + button_inner, + text="Cancel", + command=lambda: mode_dialog.destroy(), + bootstyle="danger", # Changed from outline to solid + width=12, # Smaller + padding=(8, 10) # Reduced padding + ) + cancel_btn.pack(side=tk.LEFT, padx=10) + + # Handle window close (X button) + def on_close(): + nonlocal selected_mode_value + selected_mode_value = None + mode_dialog.destroy() + + mode_dialog.protocol("WM_DELETE_WINDOW", on_close) + + # Show dialog + mode_dialog.deiconify() + mode_dialog.update_idletasks() # Force geometry update + mode_dialog.wait_window() + + # Check if user selected a mode + if selected_mode_value is None: + self.append_log("โš ๏ธ QA scan canceled.") + return + + # End of optional mode dialog + + # Show custom settings dialog if custom mode is selected + + # Show custom settings dialog if custom mode is selected + if selected_mode_value == "custom": + # Use WindowManager's setup_scrollable for proper scrolling support + dialog, scrollable_frame, canvas = self.wm.setup_scrollable( + self.master, + "Custom Mode Settings", + width=800, + height=650, + max_width_ratio=0.9, + max_height_ratio=0.85 + ) + + # Variables for custom settings + custom_settings = { + 'similarity': tk.IntVar(value=85), + 'semantic': tk.IntVar(value=80), + 'structural': tk.IntVar(value=90), + 'word_overlap': tk.IntVar(value=75), + 'minhash_threshold': tk.IntVar(value=80), + 'consecutive_chapters': tk.IntVar(value=2), + 'check_all_pairs': tk.BooleanVar(value=False), + 'sample_size': tk.IntVar(value=3000), + 'min_text_length': tk.IntVar(value=500) + } + + # Title using consistent styling + title_label = tk.Label(scrollable_frame, text="Configure Custom Detection Settings", + font=('Arial', 20, 'bold')) + title_label.pack(pady=(0, 20)) + + # Detection Thresholds Section using ttkbootstrap + threshold_frame = tb.LabelFrame(scrollable_frame, text="Detection Thresholds (%)", + padding=25, bootstyle="secondary") + threshold_frame.pack(fill='x', padx=20, pady=(0, 25)) + + threshold_descriptions = { + 'similarity': ('Text Similarity', 'Character-by-character comparison'), + 'semantic': ('Semantic Analysis', 'Meaning and context matching'), + 'structural': ('Structural Patterns', 'Document structure similarity'), + 'word_overlap': ('Word Overlap', 'Common words between texts'), + 'minhash_threshold': ('MinHash Similarity', 'Fast approximate matching') + } + + # Create percentage labels dictionary to store references + percentage_labels = {} + + for setting_key, (label_text, description) in threshold_descriptions.items(): + # Container for each threshold + row_frame = tk.Frame(threshold_frame) + row_frame.pack(fill='x', pady=8) + + # Left side - labels + label_container = tk.Frame(row_frame) + label_container.pack(side='left', fill='x', expand=True) + + main_label = tk.Label(label_container, text=f"{label_text} - {description}:", + font=('TkDefaultFont', 11)) + main_label.pack(anchor='w') + + # Right side - slider and percentage + slider_container = tk.Frame(row_frame) + slider_container.pack(side='right', padx=(20, 0)) + + # Percentage label (shows current value) + percentage_label = tk.Label(slider_container, text=f"{custom_settings[setting_key].get()}%", + font=('TkDefaultFont', 12, 'bold'), width=5, anchor='e') + percentage_label.pack(side='right', padx=(10, 0)) + percentage_labels[setting_key] = percentage_label + + # Create slider + slider = tb.Scale(slider_container, + from_=10, to=100, + variable=custom_settings[setting_key], + bootstyle="info", + length=300, + orient='horizontal') + slider.pack(side='right') + + # Update percentage label when slider moves + def create_update_function(key, label): + def update_percentage(*args): + value = custom_settings[key].get() + label.config(text=f"{value}%") + return update_percentage + + # Bind the update function + update_func = create_update_function(setting_key, percentage_label) + custom_settings[setting_key].trace('w', update_func) + + # Processing Options Section + options_frame = tb.LabelFrame(scrollable_frame, text="Processing Options", + padding=20, bootstyle="secondary") + options_frame.pack(fill='x', padx=20, pady=15) + + # Consecutive chapters option with spinbox + consec_frame = tk.Frame(options_frame) + consec_frame.pack(fill='x', pady=5) + + tk.Label(consec_frame, text="Consecutive chapters to check:", + font=('TkDefaultFont', 11)).pack(side='left') + + tb.Spinbox(consec_frame, from_=1, to=10, + textvariable=custom_settings['consecutive_chapters'], + width=10, bootstyle="info").pack(side='left', padx=(10, 0)) + + # Sample size option + sample_frame = tk.Frame(options_frame) + sample_frame.pack(fill='x', pady=5) + + tk.Label(sample_frame, text="Sample size for comparison (characters):", + font=('TkDefaultFont', 11)).pack(side='left') + + # Sample size spinbox with larger range + sample_spinbox = tb.Spinbox(sample_frame, from_=1000, to=10000, increment=500, + textvariable=custom_settings['sample_size'], + width=10, bootstyle="info") + sample_spinbox.pack(side='left', padx=(10, 0)) + + # Minimum text length option + min_length_frame = tk.Frame(options_frame) + min_length_frame.pack(fill='x', pady=5) + + tk.Label(min_length_frame, text="Minimum text length to process (characters):", + font=('TkDefaultFont', 11)).pack(side='left') + + # Minimum length spinbox + min_length_spinbox = tb.Spinbox(min_length_frame, from_=100, to=5000, increment=100, + textvariable=custom_settings['min_text_length'], + width=10, bootstyle="info") + min_length_spinbox.pack(side='left', padx=(10, 0)) + + # Check all file pairs option + tb.Checkbutton(options_frame, text="Check all file pairs (slower but more thorough)", + variable=custom_settings['check_all_pairs'], + bootstyle="primary").pack(anchor='w', pady=8) + + # Create button frame at bottom (inside scrollable_frame) + button_frame = tk.Frame(scrollable_frame) + button_frame.pack(fill='x', pady=(30, 20)) + + # Center buttons using inner frame + button_inner = tk.Frame(button_frame) + button_inner.pack() + + # Flag to track if settings were saved + settings_saved = False + + def save_custom_settings(): + """Save custom settings and close dialog""" + nonlocal settings_saved + qa_settings['custom_mode_settings'] = { + 'thresholds': { + 'similarity': custom_settings['similarity'].get() / 100, + 'semantic': custom_settings['semantic'].get() / 100, + 'structural': custom_settings['structural'].get() / 100, + 'word_overlap': custom_settings['word_overlap'].get() / 100, + 'minhash_threshold': custom_settings['minhash_threshold'].get() / 100 + }, + 'consecutive_chapters': custom_settings['consecutive_chapters'].get(), + 'check_all_pairs': custom_settings['check_all_pairs'].get(), + 'sample_size': custom_settings['sample_size'].get(), + 'min_text_length': custom_settings['min_text_length'].get() + } + settings_saved = True + self.append_log("โœ… Custom detection settings saved") + dialog._cleanup_scrolling() # Clean up scrolling bindings + dialog.destroy() + + def reset_to_defaults(): + """Reset all values to default settings""" + if messagebox.askyesno("Reset to Defaults", + "Reset all values to default settings?", + parent=dialog): + custom_settings['similarity'].set(85) + custom_settings['semantic'].set(80) + custom_settings['structural'].set(90) + custom_settings['word_overlap'].set(75) + custom_settings['minhash_threshold'].set(80) + custom_settings['consecutive_chapters'].set(2) + custom_settings['check_all_pairs'].set(False) + custom_settings['sample_size'].set(3000) + custom_settings['min_text_length'].set(500) + self.append_log("โ„น๏ธ Settings reset to defaults") + + def cancel_settings(): + """Cancel without saving""" + nonlocal settings_saved + if not settings_saved: + # Check if any settings were changed + defaults = { + 'similarity': 85, + 'semantic': 80, + 'structural': 90, + 'word_overlap': 75, + 'minhash_threshold': 80, + 'consecutive_chapters': 2, + 'check_all_pairs': False, + 'sample_size': 3000, + 'min_text_length': 500 + } + + changed = False + for key, default_val in defaults.items(): + if custom_settings[key].get() != default_val: + changed = True + break + + if changed: + if messagebox.askyesno("Unsaved Changes", + "You have unsaved changes. Are you sure you want to cancel?", + parent=dialog): + dialog._cleanup_scrolling() + dialog.destroy() + else: + dialog._cleanup_scrolling() + dialog.destroy() + else: + dialog._cleanup_scrolling() + dialog.destroy() + + # Use ttkbootstrap buttons with better styling + tb.Button(button_inner, text="Cancel", + command=cancel_settings, + bootstyle="secondary", width=15).pack(side='left', padx=5) + + tb.Button(button_inner, text="Reset Defaults", + command=reset_to_defaults, + bootstyle="warning", width=15).pack(side='left', padx=5) + + tb.Button(button_inner, text="Start Scan", + command=save_custom_settings, + bootstyle="success", width=15).pack(side='left', padx=5) + + # Use WindowManager's auto-resize + self.wm.auto_resize_dialog(dialog, canvas, max_width_ratio=0.9, max_height_ratio=0.72) + + # Handle window close properly - treat as cancel + dialog.protocol("WM_DELETE_WINDOW", cancel_settings) + + # Wait for dialog to close + dialog.wait_window() + + # If user cancelled at this dialog, cancel the whole scan + if not settings_saved: + self.append_log("โš ๏ธ QA scan canceled - no custom settings were saved.") + return + # Check if word count cross-reference is enabled but no EPUB is selected + check_word_count = qa_settings.get('check_word_count_ratio', False) + epub_files_to_scan = [] + primary_epub_path = None + + # ALWAYS populate epub_files_to_scan for auto-search, regardless of word count checking + # First check if current selection actually contains EPUBs + current_epub_files = [] + if hasattr(self, 'selected_files') and self.selected_files: + current_epub_files = [f for f in self.selected_files if f.lower().endswith('.epub')] + print(f"[DEBUG] Current selection contains {len(current_epub_files)} EPUB files") + + if current_epub_files: + # Use EPUBs from current selection + epub_files_to_scan = current_epub_files + print(f"[DEBUG] Using {len(epub_files_to_scan)} EPUB files from current selection") + else: + # No EPUBs in current selection - check if we have stored EPUBs + primary_epub_path = self.get_current_epub_path() + print(f"[DEBUG] get_current_epub_path returned: {primary_epub_path}") + + if primary_epub_path: + epub_files_to_scan = [primary_epub_path] + print(f"[DEBUG] Using stored EPUB file for auto-search") + + # Now handle word count specific logic if enabled + if check_word_count: + print("[DEBUG] Word count check is enabled, validating EPUB availability...") + + # Check if we have EPUBs for word count analysis + if not epub_files_to_scan: + # No EPUBs available for word count analysis + result = messagebox.askyesnocancel( + "No Source EPUB Selected", + "Word count cross-reference is enabled but no source EPUB file is selected.\n\n" + + "Would you like to:\n" + + "โ€ข YES - Continue scan without word count analysis\n" + + "โ€ข NO - Select an EPUB file now\n" + + "โ€ข CANCEL - Cancel the scan", + icon='warning' + ) + + if result is None: # Cancel + self.append_log("โš ๏ธ QA scan canceled.") + return + elif result is False: # No - Select EPUB now + epub_path = filedialog.askopenfilename( + title="Select Source EPUB File", + filetypes=[("EPUB files", "*.epub"), ("All files", "*.*")] + ) + + if not epub_path: + retry = messagebox.askyesno( + "No File Selected", + "No EPUB file was selected.\n\n" + + "Do you want to continue the scan without word count analysis?", + icon='question' + ) + + if not retry: + self.append_log("โš ๏ธ QA scan canceled.") + return + else: + qa_settings = qa_settings.copy() + qa_settings['check_word_count_ratio'] = False + self.append_log("โ„น๏ธ Proceeding without word count analysis.") + epub_files_to_scan = [] + else: + self.selected_epub_path = epub_path + self.config['last_epub_path'] = epub_path + self.save_config(show_message=False) + self.append_log(f"โœ… Selected EPUB: {os.path.basename(epub_path)}") + epub_files_to_scan = [epub_path] + else: # Yes - Continue without word count + qa_settings = qa_settings.copy() + qa_settings['check_word_count_ratio'] = False + self.append_log("โ„น๏ธ Proceeding without word count analysis.") + epub_files_to_scan = [] + # Persist latest auto-search preference + try: + self.config['qa_auto_search_output'] = bool(self.qa_auto_search_output_var.get()) + self.save_config(show_message=False) + except Exception: + pass + + # Try to auto-detect output folders based on EPUB files + folders_to_scan = [] + auto_search_enabled = self.config.get('qa_auto_search_output', True) + try: + if hasattr(self, 'qa_auto_search_output_var'): + auto_search_enabled = bool(self.qa_auto_search_output_var.get()) + except Exception: + pass + + # Debug output for scanning phase + if non_interactive: + self.append_log(f"๐Ÿ“ Debug: auto_search_enabled = {auto_search_enabled}") + self.append_log(f"๐Ÿ“ Debug: epub_files_to_scan = {len(epub_files_to_scan)} files") + self.append_log(f"๐Ÿ“ Debug: Will run folder detection = {auto_search_enabled and epub_files_to_scan}") + + if auto_search_enabled and epub_files_to_scan: + # Process each EPUB file to find its corresponding output folder + self.append_log(f"๐Ÿ” DEBUG: Auto-search running with {len(epub_files_to_scan)} EPUB files") + for epub_path in epub_files_to_scan: + self.append_log(f"๐Ÿ” DEBUG: Processing EPUB: {epub_path}") + try: + epub_base = os.path.splitext(os.path.basename(epub_path))[0] + current_dir = os.getcwd() + script_dir = os.path.dirname(os.path.abspath(__file__)) + + self.append_log(f"๐Ÿ” DEBUG: EPUB base name: '{epub_base}'") + self.append_log(f"๐Ÿ” DEBUG: Current dir: {current_dir}") + self.append_log(f"๐Ÿ” DEBUG: Script dir: {script_dir}") + + # Check the most common locations in order of priority + candidates = [ + os.path.join(current_dir, epub_base), # current working directory + os.path.join(script_dir, epub_base), # src directory (where output typically goes) + os.path.join(current_dir, 'src', epub_base), # src subdirectory from current dir + ] + + folder_found = None + for i, candidate in enumerate(candidates): + exists = os.path.isdir(candidate) + self.append_log(f" [{epub_base}] Checking candidate {i+1}: {candidate} - {'EXISTS' if exists else 'NOT FOUND'}") + + if exists: + # Verify the folder actually contains HTML/XHTML files + try: + files = os.listdir(candidate) + html_files = [f for f in files if f.lower().endswith(('.html', '.xhtml', '.htm'))] + if html_files: + folder_found = candidate + self.append_log(f"๐Ÿ“ Auto-selected output folder for {epub_base}: {folder_found}") + self.append_log(f" Found {len(html_files)} HTML/XHTML files to scan") + break + else: + self.append_log(f" [{epub_base}] Folder exists but contains no HTML/XHTML files: {candidate}") + except Exception as e: + self.append_log(f" [{epub_base}] Error checking files in {candidate}: {e}") + + if folder_found: + folders_to_scan.append(folder_found) + self.append_log(f"๐Ÿ” DEBUG: Added to folders_to_scan: {folder_found}") + else: + self.append_log(f" โš ๏ธ No output folder found for {epub_base}") + + except Exception as e: + self.append_log(f" โŒ Error processing {epub_base}: {e}") + + self.append_log(f"๐Ÿ” DEBUG: Final folders_to_scan: {folders_to_scan}") + + # Fallback behavior - if no folders found through auto-detection + if not folders_to_scan: + if auto_search_enabled: + # Auto-search failed, offer manual selection as fallback + self.append_log("โš ๏ธ Auto-search enabled but no matching output folder found") + self.append_log("๐Ÿ“ Falling back to manual folder selection...") + + selected_folder = filedialog.askdirectory(title="Auto-search failed - Select Output Folder to Scan") + if not selected_folder: + self.append_log("โš ๏ธ QA scan canceled - no folder selected.") + return + + # Verify the selected folder contains scannable files + try: + files = os.listdir(selected_folder) + html_files = [f for f in files if f.lower().endswith(('.html', '.xhtml', '.htm'))] + if html_files: + folders_to_scan.append(selected_folder) + self.append_log(f"โœ“ Manual selection: {os.path.basename(selected_folder)} ({len(html_files)} HTML/XHTML files)") + else: + self.append_log(f"โŒ Selected folder contains no HTML/XHTML files: {selected_folder}") + return + except Exception as e: + self.append_log(f"โŒ Error checking selected folder: {e}") + return + if non_interactive: + # Add debug info for scanning phase + if epub_files_to_scan: + self.append_log(f"โš ๏ธ Scanning phase: No matching output folders found for {len(epub_files_to_scan)} EPUB file(s)") + for epub_path in epub_files_to_scan: + epub_base = os.path.splitext(os.path.basename(epub_path))[0] + current_dir = os.getcwd() + expected_folder = os.path.join(current_dir, epub_base) + self.append_log(f" [{epub_base}] Expected: {expected_folder}") + self.append_log(f" [{epub_base}] Exists: {os.path.isdir(expected_folder)}") + + # List actual folders in current directory for debugging + try: + current_dir = os.getcwd() + actual_folders = [d for d in os.listdir(current_dir) if os.path.isdir(os.path.join(current_dir, d)) and not d.startswith('.')] + if actual_folders: + self.append_log(f" Available folders: {', '.join(actual_folders[:10])}{'...' if len(actual_folders) > 10 else ''}") + except Exception: + pass + else: + self.append_log("โš ๏ธ Scanning phase: No EPUB files available for folder detection") + + self.append_log("โš ๏ธ Skipping scan") + return + + # Clean single folder selection - no messageboxes, no harassment + self.append_log("๐Ÿ“ Select folder to scan...") + + folders_to_scan = [] + + # Simply select one folder - clean and simple + selected_folder = filedialog.askdirectory(title="Select Folder with HTML Files") + if not selected_folder: + self.append_log("โš ๏ธ QA scan canceled - no folder selected.") + return + + folders_to_scan.append(selected_folder) + self.append_log(f" โœ“ Selected folder: {os.path.basename(selected_folder)}") + self.append_log(f"๐Ÿ“ Single folder scan mode - scanning: {os.path.basename(folders_to_scan[0])}") + + mode = selected_mode_value + + # Initialize epub_path for use in run_scan() function + # This ensures epub_path is always defined even when manually selecting folders + epub_path = None + if epub_files_to_scan: + epub_path = epub_files_to_scan[0] # Use first EPUB if multiple + self.append_log(f"๐Ÿ“š Using EPUB from scan list: {os.path.basename(epub_path)}") + elif hasattr(self, 'selected_epub_path') and self.selected_epub_path: + epub_path = self.selected_epub_path + self.append_log(f"๐Ÿ“š Using stored EPUB: {os.path.basename(epub_path)}") + elif primary_epub_path: + epub_path = primary_epub_path + self.append_log(f"๐Ÿ“š Using primary EPUB: {os.path.basename(epub_path)}") + else: + self.append_log("โ„น๏ธ No EPUB file configured (word count analysis will be disabled if needed)") + + # Initialize global selected_files that applies to single-folder scans + global_selected_files = None + if len(folders_to_scan) == 1 and preselected_files: + global_selected_files = list(preselected_files) + elif len(folders_to_scan) == 1 and (not non_interactive) and (not auto_search_enabled): + # Scan all files in the folder - no messageboxes asking about specific files + # User can set up file preselection if they need specific files + pass + + # Log bulk scan start + if len(folders_to_scan) == 1: + self.append_log(f"๐Ÿ” Starting QA scan in {mode.upper()} mode for folder: {folders_to_scan[0]}") + else: + self.append_log(f"๐Ÿ” Starting bulk QA scan in {mode.upper()} mode for {len(folders_to_scan)} folders") + + self.stop_requested = False + + # Extract cache configuration from qa_settings + cache_config = { + 'enabled': qa_settings.get('cache_enabled', True), + 'auto_size': qa_settings.get('cache_auto_size', False), + 'show_stats': qa_settings.get('cache_show_stats', False), + 'sizes': {} + } + + # Get individual cache sizes + for cache_name in ['normalize_text', 'similarity_ratio', 'content_hashes', + 'semantic_fingerprint', 'structural_signature', 'translation_artifacts']: + size = qa_settings.get(f'cache_{cache_name}', None) + if size is not None: + # Convert -1 to None for unlimited + cache_config['sizes'][cache_name] = None if size == -1 else size + + # Create custom settings that includes cache config + custom_settings = { + 'qa_settings': qa_settings, + 'cache_config': cache_config, + 'log_cache_stats': qa_settings.get('cache_show_stats', False) + } + + def run_scan(): + # Update UI on the main thread + self.master.after(0, self.update_run_button) + self.master.after(0, lambda: self.qa_button.config(text="Stop Scan", command=self.stop_qa_scan, bootstyle="danger")) + + try: + # Extract cache configuration from qa_settings + cache_config = { + 'enabled': qa_settings.get('cache_enabled', True), + 'auto_size': qa_settings.get('cache_auto_size', False), + 'show_stats': qa_settings.get('cache_show_stats', False), + 'sizes': {} + } + + # Get individual cache sizes + for cache_name in ['normalize_text', 'similarity_ratio', 'content_hashes', + 'semantic_fingerprint', 'structural_signature', 'translation_artifacts']: + size = qa_settings.get(f'cache_{cache_name}', None) + if size is not None: + # Convert -1 to None for unlimited + cache_config['sizes'][cache_name] = None if size == -1 else size + + # Configure the cache BEFORE calling scan_html_folder + from scan_html_folder import configure_qa_cache + configure_qa_cache(cache_config) + + # Loop through all selected folders for bulk scanning + successful_scans = 0 + failed_scans = 0 + + for i, current_folder in enumerate(folders_to_scan): + if self.stop_requested: + self.append_log(f"โš ๏ธ Bulk scan stopped by user at folder {i+1}/{len(folders_to_scan)}") + break + + folder_name = os.path.basename(current_folder) + if len(folders_to_scan) > 1: + self.append_log(f"\n๐Ÿ“ [{i+1}/{len(folders_to_scan)}] Scanning folder: {folder_name}") + + # Determine the correct EPUB path for this specific folder + current_epub_path = epub_path + current_qa_settings = qa_settings.copy() + + # For bulk scanning, try to find a matching EPUB for each folder + if len(folders_to_scan) > 1 and current_qa_settings.get('check_word_count_ratio', False): + # Try to find EPUB file matching this specific folder + folder_basename = os.path.basename(current_folder.rstrip('/\\')) + self.append_log(f" ๐Ÿ” Searching for EPUB matching folder: {folder_basename}") + + # Look for EPUB in various locations + folder_parent = os.path.dirname(current_folder) + + # Simple exact matching first, with minimal suffix handling + base_name = folder_basename + + # Only handle the most common output suffixes + common_suffixes = ['_output', '_translated', '_en'] + for suffix in common_suffixes: + if base_name.endswith(suffix): + base_name = base_name[:-len(suffix)] + break + + # Simple EPUB search - focus on exact matching + search_names = [folder_basename] # Start with exact folder name + if base_name != folder_basename: # Add base name only if different + search_names.append(base_name) + + potential_epub_paths = [ + # Most common locations in order of priority + os.path.join(folder_parent, f"{folder_basename}.epub"), # Same directory as output folder + os.path.join(folder_parent, f"{base_name}.epub"), # Same directory with base name + os.path.join(current_folder, f"{folder_basename}.epub"), # Inside the output folder + os.path.join(current_folder, f"{base_name}.epub"), # Inside with base name + ] + + # Find the first existing EPUB + folder_epub_path = None + for potential_path in potential_epub_paths: + if os.path.isfile(potential_path): + folder_epub_path = potential_path + if len(folders_to_scan) > 1: + self.append_log(f" Found matching EPUB: {os.path.basename(potential_path)}") + break + + if folder_epub_path: + current_epub_path = folder_epub_path + if len(folders_to_scan) > 1: # Only log for bulk scans + self.append_log(f" ๐Ÿ“– Using EPUB: {os.path.basename(current_epub_path)}") + else: + # NO FALLBACK TO GLOBAL EPUB FOR BULK SCANS - This prevents wrong EPUB usage! + if len(folders_to_scan) > 1: + self.append_log(f" โš ๏ธ No matching EPUB found for folder '{folder_name}' - disabling word count analysis") + expected_names = ', '.join([f"{name}.epub" for name in search_names]) + self.append_log(f" Expected EPUB names: {expected_names}") + current_epub_path = None + elif current_epub_path: # Single folder scan can use global EPUB + self.append_log(f" ๐Ÿ“– Using global EPUB: {os.path.basename(current_epub_path)} (no folder-specific EPUB found)") + else: + current_epub_path = None + + # Disable word count analysis when no matching EPUB is found + if not current_epub_path: + current_qa_settings = current_qa_settings.copy() + current_qa_settings['check_word_count_ratio'] = False + + # Check for EPUB/folder name mismatch + if current_epub_path and current_qa_settings.get('check_word_count_ratio', False) and current_qa_settings.get('warn_name_mismatch', True): + epub_name = os.path.splitext(os.path.basename(current_epub_path))[0] + folder_name_for_check = os.path.basename(current_folder.rstrip('/\\')) + + if not check_epub_folder_match(epub_name, folder_name_for_check, current_qa_settings.get('custom_output_suffixes', '')): + if len(folders_to_scan) == 1: + # Interactive dialog for single folder scans + result = messagebox.askyesnocancel( + "EPUB/Folder Name Mismatch", + f"The source EPUB and output folder names don't match:\n\n" + + f"๐Ÿ“– EPUB: {epub_name}\n" + + f"๐Ÿ“ Folder: {folder_name_for_check}\n\n" + + "This might mean you're comparing the wrong files.\n" + + "Would you like to:\n" + + "โ€ข YES - Continue anyway (I'm sure these match)\n" + + "โ€ข NO - Select a different EPUB file\n" + + "โ€ข CANCEL - Cancel the scan", + icon='warning' + ) + + if result is None: # Cancel + self.append_log("โš ๏ธ QA scan canceled due to EPUB/folder mismatch.") + return + elif result is False: # No - select different EPUB + new_epub_path = filedialog.askopenfilename( + title="Select Different Source EPUB File", + filetypes=[("EPUB files", "*.epub"), ("All files", "*.*")] + ) + + if new_epub_path: + current_epub_path = new_epub_path + self.selected_epub_path = new_epub_path + self.config['last_epub_path'] = new_epub_path + self.save_config(show_message=False) + self.append_log(f"โœ… Updated EPUB: {os.path.basename(new_epub_path)}") + else: + proceed = messagebox.askyesno( + "No File Selected", + "No EPUB file was selected.\n\n" + + "Continue scan without word count analysis?", + icon='question' + ) + if not proceed: + self.append_log("โš ๏ธ QA scan canceled.") + return + else: + current_qa_settings = current_qa_settings.copy() + current_qa_settings['check_word_count_ratio'] = False + current_epub_path = None + self.append_log("โ„น๏ธ Proceeding without word count analysis.") + # If YES, just continue with warning + else: + # For bulk scans, just warn and continue + self.append_log(f" โš ๏ธ Warning: EPUB/folder name mismatch - {epub_name} vs {folder_name_for_check}") + + try: + # Determine selected_files for this folder + current_selected_files = None + if global_selected_files and len(folders_to_scan) == 1: + current_selected_files = global_selected_files + + # Pass the QA settings to scan_html_folder + scan_html_folder( + current_folder, + log=self.append_log, + stop_flag=lambda: self.stop_requested, + mode=mode, + qa_settings=current_qa_settings, + epub_path=current_epub_path, + selected_files=current_selected_files + ) + + successful_scans += 1 + if len(folders_to_scan) > 1: + self.append_log(f"โœ… Folder '{folder_name}' scan completed successfully") + + except Exception as folder_error: + failed_scans += 1 + self.append_log(f"โŒ Folder '{folder_name}' scan failed: {folder_error}") + if len(folders_to_scan) == 1: + # Re-raise for single folder scans + raise + + # Final summary for bulk scans + if len(folders_to_scan) > 1: + self.append_log(f"\n๐Ÿ“‹ Bulk scan summary: {successful_scans} successful, {failed_scans} failed") + + # If show_stats is enabled, log cache statistics + if qa_settings.get('cache_show_stats', False): + from scan_html_folder import get_cache_info + cache_stats = get_cache_info() + self.append_log("\n๐Ÿ“Š Cache Performance Statistics:") + for name, info in cache_stats.items(): + if info: # Check if info exists + hit_rate = info.hits / (info.hits + info.misses) if (info.hits + info.misses) > 0 else 0 + self.append_log(f" {name}: {info.hits} hits, {info.misses} misses ({hit_rate:.1%} hit rate)") + + if len(folders_to_scan) == 1: + self.append_log("โœ… QA scan completed successfully.") + else: + self.append_log("โœ… Bulk QA scan completed.") + + except Exception as e: + self.append_log(f"โŒ QA scan error: {e}") + self.append_log(f"Traceback: {traceback.format_exc()}") + finally: + # Clear thread/future refs so buttons re-enable + self.qa_thread = None + if hasattr(self, 'qa_future'): + try: + self.qa_future = None + except Exception: + pass + self.master.after(0, self.update_run_button) + self.master.after(0, lambda: self.qa_button.config( + text="QA Scan", + command=self.run_qa_scan, + bootstyle="warning", + state=tk.NORMAL if scan_html_folder else tk.DISABLED + )) + + # Run via shared executor + self._ensure_executor() + if self.executor: + self.qa_future = self.executor.submit(run_scan) + # Ensure UI is refreshed when QA work completes + def _qa_done_callback(f): + try: + self.master.after(0, lambda: (setattr(self, 'qa_future', None), self.update_run_button())) + except Exception: + pass + try: + self.qa_future.add_done_callback(_qa_done_callback) + except Exception: + pass + else: + self.qa_thread = threading.Thread(target=run_scan, daemon=True) + self.qa_thread.start() + + def show_qa_scanner_settings(self, parent_dialog, qa_settings): + """Show QA Scanner settings dialog using WindowManager properly""" + # Use setup_scrollable from WindowManager - NOT create_scrollable_dialog + dialog, scrollable_frame, canvas = self.wm.setup_scrollable( + parent_dialog, + "QA Scanner Settings", + width=800, + height=None, # Let WindowManager calculate optimal height + modal=True, + resizable=True, + max_width_ratio=0.9, + max_height_ratio=0.9 + ) + + # Main settings frame + main_frame = tk.Frame(scrollable_frame, padx=30, pady=20) + main_frame.pack(fill=tk.BOTH, expand=True) + + # Title + title_label = tk.Label( + main_frame, + text="QA Scanner Settings", + font=('Arial', 24, 'bold') + ) + title_label.pack(pady=(0, 20)) + + # Foreign Character Settings Section + foreign_section = tk.LabelFrame( + main_frame, + text="Foreign Character Detection", + font=('Arial', 12, 'bold'), + padx=20, + pady=15 + ) + foreign_section.pack(fill=tk.X, pady=(0, 20)) + + # Threshold setting + threshold_frame = tk.Frame(foreign_section) + threshold_frame.pack(fill=tk.X, pady=(0, 10)) + + tk.Label( + threshold_frame, + text="Minimum foreign characters to flag:", + font=('Arial', 10) + ).pack(side=tk.LEFT) + + threshold_var = tk.IntVar(value=qa_settings.get('foreign_char_threshold', 10)) + threshold_spinbox = tb.Spinbox( + threshold_frame, + from_=0, + to=1000, + textvariable=threshold_var, + width=10, + bootstyle="primary" + ) + threshold_spinbox.pack(side=tk.LEFT, padx=(10, 0)) + + # Disable mousewheel scrolling on spinbox + UIHelper.disable_spinbox_mousewheel(threshold_spinbox) + + tk.Label( + threshold_frame, + text="(0 = always flag, higher = more tolerant)", + font=('Arial', 9), + fg='gray' + ).pack(side=tk.LEFT, padx=(10, 0)) + + # Excluded characters - using UIHelper for scrollable text + excluded_frame = tk.Frame(foreign_section) + excluded_frame.pack(fill=tk.X, pady=(10, 0)) + + tk.Label( + excluded_frame, + text="Additional characters to exclude from detection:", + font=('Arial', 10) + ).pack(anchor=tk.W) + + # Use regular Text widget with manual scroll setup instead of ScrolledText + excluded_text_frame = tk.Frame(excluded_frame) + excluded_text_frame.pack(fill=tk.X, pady=(5, 0)) + + excluded_text = tk.Text( + excluded_text_frame, + height=7, + width=60, + font=('Consolas', 10), + wrap=tk.WORD, + undo=True + ) + excluded_text.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Add scrollbar manually + excluded_scrollbar = ttk.Scrollbar(excluded_text_frame, orient="vertical", command=excluded_text.yview) + excluded_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + excluded_text.configure(yscrollcommand=excluded_scrollbar.set) + + # Setup undo/redo for the text widget + UIHelper.setup_text_undo_redo(excluded_text) + + excluded_text.insert(1.0, qa_settings.get('excluded_characters', '')) + + tk.Label( + excluded_frame, + text="Enter characters separated by spaces (e.g., โ„ข ยฉ ยฎ โ€ข โ€ฆ)", + font=('Arial', 9), + fg='gray' + ).pack(anchor=tk.W) + + # Detection Options Section + detection_section = tk.LabelFrame( + main_frame, + text="Detection Options", + font=('Arial', 12, 'bold'), + padx=20, + pady=15 + ) + detection_section.pack(fill=tk.X, pady=(0, 20)) + + # Checkboxes for detection options + check_encoding_var = tk.BooleanVar(value=qa_settings.get('check_encoding_issues', False)) + check_repetition_var = tk.BooleanVar(value=qa_settings.get('check_repetition', True)) + check_artifacts_var = tk.BooleanVar(value=qa_settings.get('check_translation_artifacts', False)) + check_glossary_var = tk.BooleanVar(value=qa_settings.get('check_glossary_leakage', True)) + + tb.Checkbutton( + detection_section, + text="Check for encoding issues (๏ฟฝ, โ–ก, โ—‡)", + variable=check_encoding_var, + bootstyle="primary" + ).pack(anchor=tk.W, pady=2) + + tb.Checkbutton( + detection_section, + text="Check for excessive repetition", + variable=check_repetition_var, + bootstyle="primary" + ).pack(anchor=tk.W, pady=2) + + tb.Checkbutton( + detection_section, + text="Check for translation artifacts (MTL notes, watermarks)", + variable=check_artifacts_var, + bootstyle="primary" + ).pack(anchor=tk.W, pady=2) + tb.Checkbutton( + detection_section, + text="Check for glossary leakage (raw glossary entries in translation)", + variable=check_glossary_var, + bootstyle="primary" + ).pack(anchor=tk.W, pady=2) + + # File Processing Section + file_section = tk.LabelFrame( + main_frame, + text="File Processing", + font=('Arial', 12, 'bold'), + padx=20, + pady=15 + ) + file_section.pack(fill=tk.X, pady=(0, 20)) + + # Minimum file length + min_length_frame = tk.Frame(file_section) + min_length_frame.pack(fill=tk.X, pady=(0, 10)) + + tk.Label( + min_length_frame, + text="Minimum file length (characters):", + font=('Arial', 10) + ).pack(side=tk.LEFT) + + min_length_var = tk.IntVar(value=qa_settings.get('min_file_length', 0)) + min_length_spinbox = tb.Spinbox( + min_length_frame, + from_=0, + to=10000, + textvariable=min_length_var, + width=10, + bootstyle="primary" + ) + min_length_spinbox.pack(side=tk.LEFT, padx=(10, 0)) + + # Disable mousewheel scrolling on spinbox + UIHelper.disable_spinbox_mousewheel(min_length_spinbox) + + # Add a separator + separator = ttk.Separator(main_frame, orient='horizontal') + separator.pack(fill=tk.X, pady=15) + + # Word Count Cross-Reference Section + wordcount_section = tk.LabelFrame( + main_frame, + text="Word Count Analysis", + font=('Arial', 12, 'bold'), + padx=20, + pady=15 + ) + wordcount_section.pack(fill=tk.X, pady=(0, 20)) + + check_word_count_var = tk.BooleanVar(value=qa_settings.get('check_word_count_ratio', False)) + tb.Checkbutton( + wordcount_section, + text="Cross-reference word counts with original EPUB", + variable=check_word_count_var, + bootstyle="primary" + ).pack(anchor=tk.W, pady=(0, 5)) + + tk.Label( + wordcount_section, + text="Compares word counts between original and translated files to detect missing content.\n" + + "Accounts for typical expansion ratios when translating from CJK to English.", + wraplength=700, + justify=tk.LEFT, + fg='gray' + ).pack(anchor=tk.W, padx=(20, 0)) + + # Show current EPUB status and allow selection + epub_frame = tk.Frame(wordcount_section) + epub_frame.pack(anchor=tk.W, pady=(10, 5)) + + # Get EPUBs from actual current selection (not stored config) + current_epub_files = [] + if hasattr(self, 'selected_files') and self.selected_files: + current_epub_files = [f for f in self.selected_files if f.lower().endswith('.epub')] + + if len(current_epub_files) > 1: + # Multiple EPUBs in current selection + primary_epub = os.path.basename(current_epub_files[0]) + status_text = f"๐Ÿ“– {len(current_epub_files)} EPUB files selected (Primary: {primary_epub})" + status_color = 'green' + elif len(current_epub_files) == 1: + # Single EPUB in current selection + status_text = f"๐Ÿ“– Current EPUB: {os.path.basename(current_epub_files[0])}" + status_color = 'green' + else: + # No EPUB files in current selection + status_text = "๐Ÿ“– No EPUB in current selection" + status_color = 'orange' + + status_label = tk.Label( + epub_frame, + text=status_text, + fg=status_color, + font=('Arial', 10) + ) + status_label.pack(side=tk.LEFT) + + def select_epub_for_qa(): + epub_path = filedialog.askopenfilename( + title="Select Source EPUB File", + filetypes=[("EPUB files", "*.epub"), ("All files", "*.*")], + parent=dialog + ) + if epub_path: + self.selected_epub_path = epub_path + self.config['last_epub_path'] = epub_path + self.save_config(show_message=False) + + # Clear multiple EPUB tracking when manually selecting a single EPUB + if hasattr(self, 'selected_epub_files'): + self.selected_epub_files = [epub_path] + + status_label.config( + text=f"๐Ÿ“– Current EPUB: {os.path.basename(epub_path)}", + fg='green' + ) + self.append_log(f"โœ… Selected EPUB for QA: {os.path.basename(epub_path)}") + + tk.Button( + epub_frame, + text="Select EPUB", + command=select_epub_for_qa, + font=('Arial', 9) + ).pack(side=tk.LEFT, padx=(10, 0)) + + # Add option to disable mismatch warning + warn_mismatch_var = tk.BooleanVar(value=qa_settings.get('warn_name_mismatch', True)) + tb.Checkbutton( + wordcount_section, + text="Warn when EPUB and folder names don't match", + variable=warn_mismatch_var, + bootstyle="primary" + ).pack(anchor=tk.W, pady=(10, 5)) + + # Additional Checks Section + additional_section = tk.LabelFrame( + main_frame, + text="Additional Checks", + font=('Arial', 12, 'bold'), + padx=20, + pady=15 + ) + additional_section.pack(fill=tk.X, pady=(20, 0)) + + # Multiple headers check + check_multiple_headers_var = tk.BooleanVar(value=qa_settings.get('check_multiple_headers', True)) + tb.Checkbutton( + additional_section, + text="Detect files with 2 or more headers (h1-h6 tags)", + variable=check_multiple_headers_var, + bootstyle="primary" + ).pack(anchor=tk.W, pady=(5, 5)) + + tk.Label( + additional_section, + text="Identifies files that may have been incorrectly split or merged.\n" + + "Useful for detecting chapters that contain multiple sections.", + wraplength=700, + justify=tk.LEFT, + fg='gray' + ).pack(anchor=tk.W, padx=(20, 0)) + + # Missing HTML tag check + html_tag_frame = tk.Frame(additional_section) + html_tag_frame.pack(fill=tk.X, pady=(10, 5)) + + check_missing_html_tag_var = tk.BooleanVar(value=qa_settings.get('check_missing_html_tag', True)) + check_missing_html_tag_check = tb.Checkbutton( + html_tag_frame, + text="Flag HTML files with missing tag", + variable=check_missing_html_tag_var, + bootstyle="primary" + ) + check_missing_html_tag_check.pack(side=tk.LEFT) + + tk.Label( + html_tag_frame, + text="(Checks if HTML files have proper structure)", + font=('Arial', 9), + foreground='gray' + ).pack(side=tk.LEFT, padx=(10, 0)) + + # Invalid nesting check (separate toggle) + check_invalid_nesting_var = tk.BooleanVar(value=qa_settings.get('check_invalid_nesting', False)) + tb.Checkbutton( + additional_section, + text="Check for invalid tag nesting", + variable=check_invalid_nesting_var, + bootstyle="primary" + ).pack(anchor=tk.W, pady=(5, 5)) + + # NEW: Paragraph Structure Check + paragraph_section_frame = tk.Frame(additional_section) + paragraph_section_frame.pack(fill=tk.X, pady=(15, 5)) + + # Separator line + ttk.Separator(paragraph_section_frame, orient='horizontal').pack(fill=tk.X, pady=(0, 10)) + + # Checkbox for paragraph structure check + check_paragraph_structure_var = tk.BooleanVar(value=qa_settings.get('check_paragraph_structure', True)) + paragraph_check = tb.Checkbutton( + paragraph_section_frame, + text="Check for insufficient paragraph tags", + variable=check_paragraph_structure_var, + bootstyle="primary" + ) + paragraph_check.pack(anchor=tk.W) + + # Threshold setting frame + threshold_container = tk.Frame(paragraph_section_frame) + threshold_container.pack(fill=tk.X, pady=(10, 5), padx=(20, 0)) + + tk.Label( + threshold_container, + text="Minimum text in

tags:", + font=('Arial', 10) + ).pack(side=tk.LEFT) + + # Get current threshold value (default 30%) + current_threshold = int(qa_settings.get('paragraph_threshold', 0.3) * 100) + paragraph_threshold_var = tk.IntVar(value=current_threshold) + + # Spinbox for threshold + paragraph_threshold_spinbox = tb.Spinbox( + threshold_container, + from_=0, + to=100, + textvariable=paragraph_threshold_var, + width=8, + bootstyle="primary" + ) + paragraph_threshold_spinbox.pack(side=tk.LEFT, padx=(10, 5)) + + # Disable mousewheel scrolling on the spinbox + UIHelper.disable_spinbox_mousewheel(paragraph_threshold_spinbox) + + tk.Label( + threshold_container, + text="%", + font=('Arial', 10) + ).pack(side=tk.LEFT) + + # Threshold value label + threshold_value_label = tk.Label( + threshold_container, + text=f"(currently {current_threshold}%)", + font=('Arial', 9), + fg='gray' + ) + threshold_value_label.pack(side=tk.LEFT, padx=(10, 0)) + + # Update label when spinbox changes + def update_threshold_label(*args): + try: + value = paragraph_threshold_var.get() + threshold_value_label.config(text=f"(currently {value}%)") + except (tk.TclError, ValueError): + # Handle empty or invalid input + threshold_value_label.config(text="(currently --%)") + paragraph_threshold_var.trace('w', update_threshold_label) + + # Description + tk.Label( + paragraph_section_frame, + text="Detects HTML files where text content is not properly wrapped in paragraph tags.\n" + + "Files with less than the specified percentage of text in

tags will be flagged.\n" + + "Also checks for large blocks of unwrapped text directly in the body element.", + wraplength=700, + justify=tk.LEFT, + fg='gray' + ).pack(anchor=tk.W, padx=(20, 0), pady=(5, 0)) + + # Enable/disable threshold setting based on checkbox + def toggle_paragraph_threshold(*args): + if check_paragraph_structure_var.get(): + paragraph_threshold_spinbox.config(state='normal') + else: + paragraph_threshold_spinbox.config(state='disabled') + + check_paragraph_structure_var.trace('w', toggle_paragraph_threshold) + toggle_paragraph_threshold() # Set initial state + + # Report Settings Section + report_section = tk.LabelFrame( + main_frame, + text="Report Settings", + font=('Arial', 12, 'bold'), + padx=20, + pady=15 + ) + report_section.pack(fill=tk.X, pady=(0, 20)) + + # Cache Settings Section + cache_section = tk.LabelFrame( + main_frame, + text="Performance Cache Settings", + font=('Arial', 12, 'bold'), + padx=20, + pady=15 + ) + cache_section.pack(fill=tk.X, pady=(0, 20)) + + # Enable cache checkbox + cache_enabled_var = tk.BooleanVar(value=qa_settings.get('cache_enabled', True)) + cache_checkbox = tb.Checkbutton( + cache_section, + text="Enable performance cache (speeds up duplicate detection)", + variable=cache_enabled_var, + bootstyle="primary" + ) + cache_checkbox.pack(anchor=tk.W, pady=(0, 10)) + + # Cache size settings frame + cache_sizes_frame = tk.Frame(cache_section) + cache_sizes_frame.pack(fill=tk.X, padx=(20, 0)) + + # Description + tk.Label( + cache_sizes_frame, + text="Cache sizes (0 = disabled, -1 = unlimited):", + font=('Arial', 10) + ).pack(anchor=tk.W, pady=(0, 5)) + + # Cache size variables + cache_vars = {} + cache_defaults = { + 'normalize_text': 10000, + 'similarity_ratio': 20000, + 'content_hashes': 5000, + 'semantic_fingerprint': 2000, + 'structural_signature': 2000, + 'translation_artifacts': 1000 + } + + # Create input fields for each cache type + for cache_name, default_value in cache_defaults.items(): + row_frame = tk.Frame(cache_sizes_frame) + row_frame.pack(fill=tk.X, pady=2) + + # Label + label_text = cache_name.replace('_', ' ').title() + ":" + tk.Label( + row_frame, + text=label_text, + width=25, + anchor='w', + font=('Arial', 9) + ).pack(side=tk.LEFT) + + # Get current value + current_value = qa_settings.get(f'cache_{cache_name}', default_value) + cache_var = tk.IntVar(value=current_value) + cache_vars[cache_name] = cache_var + + # Spinbox + spinbox = tb.Spinbox( + row_frame, + from_=-1, + to=50000, + textvariable=cache_var, + width=10, + bootstyle="primary" + ) + spinbox.pack(side=tk.LEFT, padx=(0, 10)) + + # Disable mousewheel scrolling + UIHelper.disable_spinbox_mousewheel(spinbox) + + # Quick preset buttons + button_frame = tk.Frame(row_frame) + button_frame.pack(side=tk.LEFT) + + tk.Button( + button_frame, + text="Off", + width=4, + font=('Arial', 8), + command=lambda v=cache_var: v.set(0) + ).pack(side=tk.LEFT, padx=1) + + tk.Button( + button_frame, + text="Small", + width=5, + font=('Arial', 8), + command=lambda v=cache_var: v.set(1000) + ).pack(side=tk.LEFT, padx=1) + + tk.Button( + button_frame, + text="Medium", + width=7, + font=('Arial', 8), + command=lambda v=cache_var, d=default_value: v.set(d) + ).pack(side=tk.LEFT, padx=1) + + tk.Button( + button_frame, + text="Large", + width=5, + font=('Arial', 8), + command=lambda v=cache_var, d=default_value: v.set(d * 2) + ).pack(side=tk.LEFT, padx=1) + + tk.Button( + button_frame, + text="Max", + width=4, + font=('Arial', 8), + command=lambda v=cache_var: v.set(-1) + ).pack(side=tk.LEFT, padx=1) + + # Enable/disable cache size controls based on checkbox + def toggle_cache_controls(*args): + state = 'normal' if cache_enabled_var.get() else 'disabled' + for widget in cache_sizes_frame.winfo_children(): + if isinstance(widget, tk.Frame): + for child in widget.winfo_children(): + if isinstance(child, (tb.Spinbox, tk.Button)): + child.config(state=state) + + cache_enabled_var.trace('w', toggle_cache_controls) + toggle_cache_controls() # Set initial state + + # Auto-size cache option + auto_size_frame = tk.Frame(cache_section) + auto_size_frame.pack(fill=tk.X, pady=(10, 5)) + + auto_size_var = tk.BooleanVar(value=qa_settings.get('cache_auto_size', False)) + auto_size_check = tb.Checkbutton( + auto_size_frame, + text="Auto-size caches based on available RAM", + variable=auto_size_var, + bootstyle="primary" + ) + auto_size_check.pack(side=tk.LEFT) + + tk.Label( + auto_size_frame, + text="(overrides manual settings)", + font=('Arial', 9), + fg='gray' + ).pack(side=tk.LEFT, padx=(10, 0)) + + # Cache statistics display + stats_frame = tk.Frame(cache_section) + stats_frame.pack(fill=tk.X, pady=(10, 0)) + + show_stats_var = tk.BooleanVar(value=qa_settings.get('cache_show_stats', False)) + tb.Checkbutton( + stats_frame, + text="Show cache hit/miss statistics after scan", + variable=show_stats_var, + bootstyle="primary" + ).pack(anchor=tk.W) + + # Info about cache + tk.Label( + cache_section, + text="Larger cache sizes use more memory but improve performance for:\n" + + "โ€ข Large datasets (100+ files)\n" + + "โ€ข AI Hunter mode (all file pairs compared)\n" + + "โ€ข Repeated scans of the same folder", + wraplength=700, + justify=tk.LEFT, + fg='gray', + font=('Arial', 9) + ).pack(anchor=tk.W, padx=(20, 0), pady=(10, 0)) + + # AI Hunter Performance Section + ai_hunter_section = tk.LabelFrame( + main_frame, + text="AI Hunter Performance Settings", + font=('Arial', 12, 'bold'), + padx=20, + pady=15 + ) + ai_hunter_section.pack(fill=tk.X, pady=(0, 20)) + + # Description + tk.Label( + ai_hunter_section, + text="AI Hunter mode performs exhaustive duplicate detection by comparing every file pair.\n" + + "Parallel processing can significantly speed up this process on multi-core systems.", + wraplength=700, + justify=tk.LEFT, + fg='gray', + font=('Arial', 9) + ).pack(anchor=tk.W, pady=(0, 10)) + + # Parallel workers setting + workers_frame = tk.Frame(ai_hunter_section) + workers_frame.pack(fill=tk.X, pady=(0, 10)) + + tk.Label( + workers_frame, + text="Maximum parallel workers:", + font=('Arial', 10) + ).pack(side=tk.LEFT) + + # Get current value from AI Hunter config + ai_hunter_config = self.config.get('ai_hunter_config', {}) + current_max_workers = ai_hunter_config.get('ai_hunter_max_workers', 1) + + ai_hunter_workers_var = tk.IntVar(value=current_max_workers) + workers_spinbox = tb.Spinbox( + workers_frame, + from_=0, + to=64, + textvariable=ai_hunter_workers_var, + width=10, + bootstyle="primary" + ) + workers_spinbox.pack(side=tk.LEFT, padx=(10, 0)) + + # Disable mousewheel scrolling on spinbox + UIHelper.disable_spinbox_mousewheel(workers_spinbox) + + # CPU count display + import multiprocessing + cpu_count = multiprocessing.cpu_count() + cpu_label = tk.Label( + workers_frame, + text=f"(0 = use all {cpu_count} cores)", + font=('Arial', 9), + fg='gray' + ) + cpu_label.pack(side=tk.LEFT, padx=(10, 0)) + + # Quick preset buttons + preset_frame = tk.Frame(ai_hunter_section) + preset_frame.pack(fill=tk.X) + + tk.Label( + preset_frame, + text="Quick presets:", + font=('Arial', 9) + ).pack(side=tk.LEFT, padx=(0, 10)) + + tk.Button( + preset_frame, + text=f"All cores ({cpu_count})", + font=('Arial', 9), + command=lambda: ai_hunter_workers_var.set(0) + ).pack(side=tk.LEFT, padx=2) + + tk.Button( + preset_frame, + text="Half cores", + font=('Arial', 9), + command=lambda: ai_hunter_workers_var.set(max(1, cpu_count // 2)) + ).pack(side=tk.LEFT, padx=2) + + tk.Button( + preset_frame, + text="4 cores", + font=('Arial', 9), + command=lambda: ai_hunter_workers_var.set(4) + ).pack(side=tk.LEFT, padx=2) + + tk.Button( + preset_frame, + text="8 cores", + font=('Arial', 9), + command=lambda: ai_hunter_workers_var.set(8) + ).pack(side=tk.LEFT, padx=2) + + tk.Button( + preset_frame, + text="Single thread", + font=('Arial', 9), + command=lambda: ai_hunter_workers_var.set(1) + ).pack(side=tk.LEFT, padx=2) + + # Performance tips + tips_text = "Performance Tips:\n" + \ + f"โ€ข Your system has {cpu_count} CPU cores available\n" + \ + "โ€ข Using all cores provides maximum speed but may slow other applications\n" + \ + "โ€ข 4-8 cores usually provides good balance of speed and system responsiveness\n" + \ + "โ€ข Single thread (1) disables parallel processing for debugging" + + tk.Label( + ai_hunter_section, + text=tips_text, + wraplength=700, + justify=tk.LEFT, + fg='gray', + font=('Arial', 9) + ).pack(anchor=tk.W, padx=(20, 0), pady=(10, 0)) + + # Report format + format_frame = tk.Frame(report_section) + format_frame.pack(fill=tk.X, pady=(0, 10)) + + tk.Label( + format_frame, + text="Report format:", + font=('Arial', 10) + ).pack(side=tk.LEFT) + + format_var = tk.StringVar(value=qa_settings.get('report_format', 'detailed')) + format_options = [ + ("Summary only", "summary"), + ("Detailed (recommended)", "detailed"), + ("Verbose (all data)", "verbose") + ] + + for idx, (text, value) in enumerate(format_options): + rb = tb.Radiobutton( + format_frame, + text=text, + variable=format_var, + value=value, + bootstyle="primary" + ) + rb.pack(side=tk.LEFT, padx=(10 if idx == 0 else 5, 0)) + + # Auto-save report + auto_save_var = tk.BooleanVar(value=qa_settings.get('auto_save_report', True)) + tb.Checkbutton( + report_section, + text="Automatically save report after scan", + variable=auto_save_var, + bootstyle="primary" + ).pack(anchor=tk.W) + + # Buttons + button_frame = tk.Frame(main_frame) + button_frame.pack(fill=tk.X, pady=(20, 0)) + button_inner = tk.Frame(button_frame) + button_inner.pack() + + def save_settings(): + """Save QA scanner settings""" + try: + qa_settings['foreign_char_threshold'] = threshold_var.get() + qa_settings['excluded_characters'] = excluded_text.get(1.0, tk.END).strip() + qa_settings['check_encoding_issues'] = check_encoding_var.get() + qa_settings['check_repetition'] = check_repetition_var.get() + qa_settings['check_translation_artifacts'] = check_artifacts_var.get() + qa_settings['check_glossary_leakage'] = check_glossary_var.get() + qa_settings['min_file_length'] = min_length_var.get() + qa_settings['report_format'] = format_var.get() + qa_settings['auto_save_report'] = auto_save_var.get() + qa_settings['check_word_count_ratio'] = check_word_count_var.get() + qa_settings['check_multiple_headers'] = check_multiple_headers_var.get() + qa_settings['warn_name_mismatch'] = warn_mismatch_var.get() + qa_settings['check_missing_html_tag'] = check_missing_html_tag_var.get() + qa_settings['check_paragraph_structure'] = check_paragraph_structure_var.get() + qa_settings['check_invalid_nesting'] = check_invalid_nesting_var.get() + + # Save cache settings + qa_settings['cache_enabled'] = cache_enabled_var.get() + qa_settings['cache_auto_size'] = auto_size_var.get() + qa_settings['cache_show_stats'] = show_stats_var.get() + + # Save individual cache sizes + for cache_name, cache_var in cache_vars.items(): + qa_settings[f'cache_{cache_name}'] = cache_var.get() + + if 'ai_hunter_config' not in self.config: + self.config['ai_hunter_config'] = {} + self.config['ai_hunter_config']['ai_hunter_max_workers'] = ai_hunter_workers_var.get() + + # Validate and save paragraph threshold + try: + threshold_value = paragraph_threshold_var.get() + if 0 <= threshold_value <= 100: + qa_settings['paragraph_threshold'] = threshold_value / 100.0 # Convert to decimal + else: + raise ValueError("Threshold must be between 0 and 100") + except (tk.TclError, ValueError) as e: + # Default to 30% if invalid + qa_settings['paragraph_threshold'] = 0.3 + self.append_log("โš ๏ธ Invalid paragraph threshold, using default 30%") + + + # Save to main config + self.config['qa_scanner_settings'] = qa_settings + + # Call save_config with show_message=False to avoid the error + self.save_config(show_message=False) + + self.append_log("โœ… QA Scanner settings saved") + dialog._cleanup_scrolling() # Clean up scrolling bindings + dialog.destroy() + + except Exception as e: + self.append_log(f"โŒ Error saving QA settings: {str(e)}") + messagebox.showerror("Error", f"Failed to save settings: {str(e)}") + + def reset_defaults(): + """Reset to default settings""" + result = messagebox.askyesno( + "Reset to Defaults", + "Are you sure you want to reset all settings to defaults?", + parent=dialog + ) + if result: + threshold_var.set(10) + excluded_text.delete(1.0, tk.END) + check_encoding_var.set(False) + check_repetition_var.set(True) + check_artifacts_var.set(False) + + check_glossary_var.set(True) + min_length_var.set(0) + format_var.set('detailed') + auto_save_var.set(True) + check_word_count_var.set(False) + check_multiple_headers_var.set(True) + warn_mismatch_var.set(False) + check_missing_html_tag_var.set(True) + check_paragraph_structure_var.set(True) + check_invalid_nesting_var.set(False) + paragraph_threshold_var.set(30) # 30% default + paragraph_threshold_var.set(30) # 30% default + + # Reset cache settings + cache_enabled_var.set(True) + auto_size_var.set(False) + show_stats_var.set(False) + + # Reset cache sizes to defaults + for cache_name, default_value in cache_defaults.items(): + cache_vars[cache_name].set(default_value) + + ai_hunter_workers_var.set(1) + + # Create buttons using ttkbootstrap styles + save_btn = tb.Button( + button_inner, + text="Save Settings", + command=save_settings, + bootstyle="success", + width=15 + ) + save_btn.pack(side=tk.LEFT, padx=5) + + reset_btn = tb.Button( + button_inner, + text="Reset Defaults", + command=reset_defaults, + bootstyle="warning", + width=15 + ) + reset_btn.pack(side=tk.RIGHT, padx=(5, 0)) + + cancel_btn = tb.Button( + button_inner, + text="Cancel", + command=lambda: [dialog._cleanup_scrolling(), dialog.destroy()], + bootstyle="secondary", + width=15 + ) + cancel_btn.pack(side=tk.RIGHT) + + # Use WindowManager's auto_resize_dialog to properly size the window + self.wm.auto_resize_dialog(dialog, canvas, max_width_ratio=0.9, max_height_ratio=0.85) + + # Handle window close - setup_scrollable adds _cleanup_scrolling method + dialog.protocol("WM_DELETE_WINDOW", lambda: [dialog._cleanup_scrolling(), dialog.destroy()]) + + def toggle_token_limit(self): + """Toggle whether the token-limit entry is active or not.""" + if not self.token_limit_disabled: + self.token_limit_entry.config(state=tk.DISABLED) + self.toggle_token_btn.config(text="Enable Input Token Limit", bootstyle="success-outline") + self.append_log("โš ๏ธ Input token limit disabled - both translation and glossary extraction will process chapters of any size.") + self.token_limit_disabled = True + else: + self.token_limit_entry.config(state=tk.NORMAL) + if not self.token_limit_entry.get().strip(): + self.token_limit_entry.insert(0, str(self.config.get('token_limit', 1000000))) + self.toggle_token_btn.config(text="Disable Input Token Limit", bootstyle="danger-outline") + self.append_log(f"โœ… Input token limit enabled: {self.token_limit_entry.get()} tokens (applies to both translation and glossary extraction)") + self.token_limit_disabled = False + + def update_run_button(self): + """Switch Runโ†”Stop depending on whether a process is active.""" + translation_running = ( + (hasattr(self, 'translation_thread') and self.translation_thread and self.translation_thread.is_alive()) or + (hasattr(self, 'translation_future') and self.translation_future and not self.translation_future.done()) + ) + glossary_running = ( + (hasattr(self, 'glossary_thread') and self.glossary_thread and self.glossary_thread.is_alive()) or + (hasattr(self, 'glossary_future') and self.glossary_future and not self.glossary_future.done()) + ) + qa_running = ( + (hasattr(self, 'qa_thread') and self.qa_thread and self.qa_thread.is_alive()) or + (hasattr(self, 'qa_future') and self.qa_future and not self.qa_future.done()) + ) + epub_running = ( + (hasattr(self, 'epub_thread') and self.epub_thread and self.epub_thread.is_alive()) or + (hasattr(self, 'epub_future') and self.epub_future and not self.epub_future.done()) + ) + + any_process_running = translation_running or glossary_running or qa_running or epub_running + + # Translation button + if translation_running: + self.run_button.config(text="Stop Translation", command=self.stop_translation, + bootstyle="danger", state=tk.NORMAL) + else: + self.run_button.config(text="Run Translation", command=self.run_translation_thread, + bootstyle="success", state=tk.NORMAL if translation_main and not any_process_running else tk.DISABLED) + + # Glossary button + if hasattr(self, 'glossary_button'): + if glossary_running: + self.glossary_button.config(text="Stop Glossary", command=self.stop_glossary_extraction, + bootstyle="danger", state=tk.NORMAL) + else: + self.glossary_button.config(text="Extract Glossary", command=self.run_glossary_extraction_thread, + bootstyle="warning", state=tk.NORMAL if glossary_main and not any_process_running else tk.DISABLED) + + # EPUB button + if hasattr(self, 'epub_button'): + if epub_running: + self.epub_button.config(text="Stop EPUB", command=self.stop_epub_converter, + bootstyle="danger", state=tk.NORMAL) + else: + self.epub_button.config(text="EPUB Converter", command=self.epub_converter, + bootstyle="info", state=tk.NORMAL if fallback_compile_epub and not any_process_running else tk.DISABLED) + + # QA button + if hasattr(self, 'qa_button'): + self.qa_button.config(state=tk.NORMAL if scan_html_folder and not any_process_running else tk.DISABLED) + if qa_running: + self.qa_button.config(text="Stop Scan", command=self.stop_qa_scan, + bootstyle="danger", state=tk.NORMAL) + else: + self.qa_button.config(text="QA Scan", command=self.run_qa_scan, + bootstyle="warning", state=tk.NORMAL if scan_html_folder and not any_process_running else tk.DISABLED) + + def stop_translation(self): + """Stop translation while preserving loaded file""" + current_file = self.entry_epub.get() if hasattr(self, 'entry_epub') else None + + # Set environment variable to suppress multi-key logging + os.environ['TRANSLATION_CANCELLED'] = '1' + + self.stop_requested = True + + # Use the imported translation_stop_flag function from TransateKRtoEN + # This was imported during lazy loading as: translation_stop_flag = TransateKRtoEN.set_stop_flag + if 'translation_stop_flag' in globals() and translation_stop_flag: + translation_stop_flag(True) + + # Also try to call it directly on the module if imported + try: + import TransateKRtoEN + if hasattr(TransateKRtoEN, 'set_stop_flag'): + TransateKRtoEN.set_stop_flag(True) + except: + pass + + try: + import unified_api_client + if hasattr(unified_api_client, 'set_stop_flag'): + unified_api_client.set_stop_flag(True) + # If there's a global client instance, stop it too + if hasattr(unified_api_client, 'global_stop_flag'): + unified_api_client.global_stop_flag = True + + # Set the _cancelled flag on the UnifiedClient class itself + if hasattr(unified_api_client, 'UnifiedClient'): + unified_api_client.UnifiedClient._global_cancelled = True + + except Exception as e: + print(f"Error setting stop flags: {e}") + + # Save and encrypt config when stopping + try: + self.save_config(show_message=False) + except: + pass + + self.append_log("โŒ Translation stop requested.") + self.append_log("โณ Please wait... stopping after current operation completes.") + self.update_run_button() + + if current_file and hasattr(self, 'entry_epub'): + self.master.after(100, lambda: self.preserve_file_path(current_file)) + + def preserve_file_path(self, file_path): + """Helper to ensure file path stays in the entry field""" + if hasattr(self, 'entry_epub') and file_path: + current = self.entry_epub.get() + if not current or current != file_path: + self.entry_epub.delete(0, tk.END) + self.entry_epub.insert(0, file_path) + + def stop_glossary_extraction(self): + """Stop glossary extraction specifically""" + self.stop_requested = True + if glossary_stop_flag: + glossary_stop_flag(True) + + try: + import extract_glossary_from_epub + if hasattr(extract_glossary_from_epub, 'set_stop_flag'): + extract_glossary_from_epub.set_stop_flag(True) + except: pass + + # Important: Reset the thread/future references so button updates properly + if hasattr(self, 'glossary_thread'): + self.glossary_thread = None + if hasattr(self, 'glossary_future'): + self.glossary_future = None + + self.append_log("โŒ Glossary extraction stop requested.") + self.append_log("โณ Please wait... stopping after current API call completes.") + self.update_run_button() + + + def stop_epub_converter(self): + """Stop EPUB converter""" + self.stop_requested = True + self.append_log("โŒ EPUB converter stop requested.") + self.append_log("โณ Please wait... stopping after current operation completes.") + + # Important: Reset the thread reference so button updates properly + if hasattr(self, 'epub_thread'): + self.epub_thread = None + + self.update_run_button() + + def stop_qa_scan(self): + self.stop_requested = True + try: + from scan_html_folder import stop_scan + if stop_scan(): + self.append_log("โœ… Stop scan signal sent successfully") + except Exception as e: + self.append_log(f"โŒ Failed to stop scan: {e}") + self.append_log("โ›” QA scan stop requested.") + + + def on_close(self): + if messagebox.askokcancel("Quit", "Are you sure you want to exit?"): + self.stop_requested = True + + # Save and encrypt config before closing + try: + self.save_config(show_message=False) + except: + pass # Don't prevent closing if save fails + + # Shutdown the executor to stop accepting new tasks + try: + if getattr(self, 'executor', None): + self.executor.shutdown(wait=False) + except Exception: + pass + + self.master.destroy() + sys.exit(0) + + def append_log(self, message): + """Append message to log with safety checks (fallback to print if GUI is gone).""" + def _append(): + try: + # Bail out if the widget no longer exists + if not hasattr(self, 'log_text'): + print(message) + return + try: + exists = bool(self.log_text.winfo_exists()) + except Exception: + exists = False + if not exists: + print(message) + return + + at_bottom = False + try: + at_bottom = self.log_text.yview()[1] >= 0.98 + except Exception: + at_bottom = False + + is_memory = any(keyword in message for keyword in ['[MEMORY]', '๐Ÿ“', 'rolling summary', 'memory']) + + if is_memory: + self.log_text.insert(tk.END, message + "\n", "memory") + if "memory" not in self.log_text.tag_names(): + self.log_text.tag_config("memory", foreground="#4CAF50", font=('TkDefaultFont', 10, 'italic')) + else: + self.log_text.insert(tk.END, message + "\n") + + if at_bottom: + self.log_text.see(tk.END) + except Exception: + # As a last resort, print to stdout to avoid crashing callbacks + try: + print(message) + except Exception: + pass + + if threading.current_thread() is threading.main_thread(): + _append() + else: + try: + self.master.after(0, _append) + except Exception: + # If the master window is gone, just print + try: + print(message) + except Exception: + pass + + def update_status_line(self, message, progress_percent=None): + """Update a status line in the log safely (fallback to print).""" + def _update(): + try: + if not hasattr(self, 'log_text') or not self.log_text.winfo_exists(): + print(message) + return + content = self.log_text.get("1.0", "end-1c") + lines = content.split('\n') + + status_markers = ['โณ', '๐Ÿ“Š', 'โœ…', 'โŒ', '๐Ÿ”„'] + is_status_line = False + + if lines and any(lines[-1].strip().startswith(marker) for marker in status_markers): + is_status_line = True + + if progress_percent is not None: + bar_width = 10 + filled = int(bar_width * progress_percent / 100) + bar = "โ–“" * filled + "โ–‘" * (bar_width - filled) + status_msg = f"โณ {message} [{bar}] {progress_percent:.1f}%" + else: + status_msg = f"๐Ÿ“Š {message}" + + if is_status_line and lines[-1].strip().startswith(('โณ', '๐Ÿ“Š')): + start_pos = f"{len(lines)}.0" + self.log_text.delete(f"{start_pos} linestart", "end") + if len(lines) > 1: + self.log_text.insert("end", "\n" + status_msg) + else: + self.log_text.insert("end", status_msg) + else: + if content and not content.endswith('\n'): + self.log_text.insert("end", "\n" + status_msg) + else: + self.log_text.insert("end", status_msg + "\n") + + self.log_text.see("end") + except Exception: + try: + print(message) + except Exception: + pass + + if threading.current_thread() is threading.main_thread(): + _update() + else: + try: + self.master.after(0, _update) + except Exception: + try: + print(message) + except Exception: + pass + + def append_chunk_progress(self, chunk_num, total_chunks, chunk_type="text", chapter_info="", + overall_current=None, overall_total=None, extra_info=None): + """Append chunk progress with enhanced visual indicator""" + progress_bar_width = 20 + + overall_progress = 0 + if overall_current is not None and overall_total is not None and overall_total > 0: + overall_progress = overall_current / overall_total + + overall_filled = int(progress_bar_width * overall_progress) + overall_bar = "โ–ˆ" * overall_filled + "โ–‘" * (progress_bar_width - overall_filled) + + if total_chunks == 1: + icon = "๐Ÿ“„" if chunk_type == "text" else "๐Ÿ–ผ๏ธ" + msg_parts = [f"{icon} {chapter_info}"] + + if extra_info: + msg_parts.append(f"[{extra_info}]") + + if overall_current is not None and overall_total is not None: + msg_parts.append(f"\n Progress: [{overall_bar}] {overall_current}/{overall_total} ({overall_progress*100:.1f}%)") + + if hasattr(self, '_chunk_start_times'): + if overall_current > 1: + elapsed = time.time() - self._translation_start_time + avg_time = elapsed / (overall_current - 1) + remaining = overall_total - overall_current + 1 + eta_seconds = remaining * avg_time + + if eta_seconds < 60: + eta_str = f"{int(eta_seconds)}s" + elif eta_seconds < 3600: + eta_str = f"{int(eta_seconds/60)}m {int(eta_seconds%60)}s" + else: + hours = int(eta_seconds / 3600) + minutes = int((eta_seconds % 3600) / 60) + eta_str = f"{hours}h {minutes}m" + + msg_parts.append(f" - ETA: {eta_str}") + else: + self._translation_start_time = time.time() + self._chunk_start_times = {} + + msg = " ".join(msg_parts) + else: + chunk_progress = chunk_num / total_chunks if total_chunks > 0 else 0 + chunk_filled = int(progress_bar_width * chunk_progress) + chunk_bar = "โ–ˆ" * chunk_filled + "โ–‘" * (progress_bar_width - chunk_filled) + + icon = "๐Ÿ“„" if chunk_type == "text" else "๐Ÿ–ผ๏ธ" + + msg_parts = [f"{icon} {chapter_info}"] + msg_parts.append(f"\n Chunk: [{chunk_bar}] {chunk_num}/{total_chunks} ({chunk_progress*100:.1f}%)") + + if overall_current is not None and overall_total is not None: + msg_parts.append(f"\n Overall: [{overall_bar}] {overall_current}/{overall_total} ({overall_progress*100:.1f}%)") + + msg = "".join(msg_parts) + + if hasattr(self, '_chunk_start_times'): + self._chunk_start_times[f"{chapter_info}_{chunk_num}"] = time.time() + + self.append_log(msg) + + def _show_context_menu(self, event): + """Show context menu for log text""" + try: + context_menu = tk.Menu(self.master, tearoff=0) + + try: + self.log_text.selection_get() + context_menu.add_command(label="Copy", command=self.copy_selection) + except tk.TclError: + context_menu.add_command(label="Copy", state="disabled") + + context_menu.add_separator() + context_menu.add_command(label="Select All", command=self.select_all_log) + + context_menu.tk_popup(event.x_root, event.y_root) + finally: + context_menu.grab_release() + + def copy_selection(self): + """Copy selected text from log to clipboard""" + try: + text = self.log_text.selection_get() + self.master.clipboard_clear() + self.master.clipboard_append(text) + except tk.TclError: + pass + + def select_all_log(self): + """Select all text in the log""" + self.log_text.tag_add(tk.SEL, "1.0", tk.END) + self.log_text.mark_set(tk.INSERT, "1.0") + self.log_text.see(tk.INSERT) + + def auto_load_glossary_for_file(self, file_path): + """Automatically load glossary if it exists in the output folder""" + + # CHECK FOR EPUB FIRST - before any clearing logic! + if not file_path or not os.path.isfile(file_path): + return + + if not file_path.lower().endswith('.epub'): + return # Exit early for non-EPUB files - don't touch glossaries! + + # Clear previous auto-loaded glossary if switching EPUB files + if file_path != self.auto_loaded_glossary_for_file: + # Only clear if the current glossary was auto-loaded AND not manually loaded + if (self.auto_loaded_glossary_path and + self.manual_glossary_path == self.auto_loaded_glossary_path and + not getattr(self, 'manual_glossary_manually_loaded', False)): # Check manual flag + self.manual_glossary_path = None + self.append_log("๐Ÿ“‘ Cleared auto-loaded glossary from previous novel") + + self.auto_loaded_glossary_path = None + self.auto_loaded_glossary_for_file = None + + # Don't override manually loaded glossaries + if getattr(self, 'manual_glossary_manually_loaded', False) and self.manual_glossary_path: + self.append_log(f"๐Ÿ“‘ Keeping manually loaded glossary: {os.path.basename(self.manual_glossary_path)}") + return + + file_base = os.path.splitext(os.path.basename(file_path))[0] + output_dir = file_base + + # Prefer CSV over JSON when both exist + glossary_candidates = [ + os.path.join(output_dir, "glossary.csv"), + os.path.join(output_dir, f"{file_base}_glossary.csv"), + os.path.join(output_dir, "Glossary", f"{file_base}_glossary.csv"), + os.path.join(output_dir, "glossary.json"), + os.path.join(output_dir, f"{file_base}_glossary.json"), + os.path.join(output_dir, "Glossary", f"{file_base}_glossary.json") + ] + for glossary_path in glossary_candidates: + if os.path.exists(glossary_path): + ext = os.path.splitext(glossary_path)[1].lower() + try: + if ext == '.csv': + # Accept CSV without parsing + self.manual_glossary_path = glossary_path + self.auto_loaded_glossary_path = glossary_path + self.auto_loaded_glossary_for_file = file_path + self.manual_glossary_manually_loaded = False # This is auto-loaded + self.append_log(f"๐Ÿ“‘ Auto-loaded glossary (CSV) for {file_base}: {os.path.basename(glossary_path)}") + break + else: + with open(glossary_path, 'r', encoding='utf-8') as f: + glossary_data = json.load(f) + self.manual_glossary_path = glossary_path + self.auto_loaded_glossary_path = glossary_path + self.auto_loaded_glossary_for_file = file_path + self.manual_glossary_manually_loaded = False # This is auto-loaded + self.append_log(f"๐Ÿ“‘ Auto-loaded glossary (JSON) for {file_base}: {os.path.basename(glossary_path)}") + break + except Exception: + # If JSON parsing fails, try next candidate + continue + continue + + return False + + # File Selection Methods + def browse_files(self): + """Select one or more files - automatically handles single/multiple selection""" + paths = filedialog.askopenfilenames( + title="Select File(s) - Hold Ctrl/Shift to select multiple", + filetypes=[ + ("Supported files", "*.epub;*.cbz;*.txt;*.json;*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.webp"), + ("EPUB/CBZ", "*.epub;*.cbz"), + ("EPUB files", "*.epub"), + ("Comic Book Zip", "*.cbz"), + ("Text files", "*.txt;*.json"), + ("Image files", "*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.webp"), + ("PNG files", "*.png"), + ("JPEG files", "*.jpg;*.jpeg"), + ("GIF files", "*.gif"), + ("BMP files", "*.bmp"), + ("WebP files", "*.webp"), + ("All files", "*.*") + ] + ) + if paths: + self._handle_file_selection(list(paths)) + + def browse_folder(self): + """Select an entire folder of files""" + folder_path = filedialog.askdirectory( + title="Select Folder Containing Files to Translate" + ) + if folder_path: + # Find all supported files in the folder + supported_extensions = {'.epub', '.cbz', '.txt', '.json', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'} + files = [] + + # Recursively find files if deep scan is enabled + if hasattr(self, 'deep_scan_var') and self.deep_scan_var.get(): + for root, dirs, filenames in os.walk(folder_path): + for filename in filenames: + file_path = os.path.join(root, filename) + if os.path.splitext(filename)[1].lower() in supported_extensions: + files.append(file_path) + else: + # Just scan the immediate folder + for filename in sorted(os.listdir(folder_path)): + file_path = os.path.join(folder_path, filename) + if os.path.isfile(file_path): + ext = os.path.splitext(filename)[1].lower() + if ext in supported_extensions: + files.append(file_path) + + if files: + self._handle_file_selection(sorted(files)) + self.append_log(f"๐Ÿ“ Found {len(files)} supported files in: {os.path.basename(folder_path)}") + else: + messagebox.showwarning("No Files Found", + f"No supported files found in:\n{folder_path}\n\nSupported formats: EPUB, TXT, PNG, JPG, JPEG, GIF, BMP, WebP") + + def clear_file_selection(self): + """Clear all selected files""" + self.entry_epub.delete(0, tk.END) + self.entry_epub.insert(0, "No file selected") + self.selected_files = [] + self.file_path = None + self.current_file_index = 0 + + # Clear EPUB tracking + if hasattr(self, 'selected_epub_path'): + self.selected_epub_path = None + if hasattr(self, 'selected_epub_files'): + self.selected_epub_files = [] + + # Persist clear state + try: + self.config['last_input_files'] = [] + self.config['last_epub_path'] = None + self.save_config(show_message=False) + except Exception: + pass + self.append_log("๐Ÿ—‘๏ธ Cleared file selection") + + + def _handle_file_selection(self, paths): + """Common handler for file selection""" + if not paths: + return + + # Initialize JSON conversion tracking if not exists + if not hasattr(self, 'json_conversions'): + self.json_conversions = {} # Maps converted .txt paths to original .json paths + + # Process JSON files first - convert them to TXT + processed_paths = [] + + for path in paths: + lower = path.lower() + if lower.endswith('.json'): + # Convert JSON to TXT + txt_path = self._convert_json_to_txt(path) + if txt_path: + processed_paths.append(txt_path) + # Track the conversion for potential reverse conversion later + self.json_conversions[txt_path] = path + self.append_log(f"๐Ÿ“„ Converted JSON to TXT: {os.path.basename(path)}") + else: + self.append_log(f"โŒ Failed to convert JSON: {os.path.basename(path)}") + elif lower.endswith('.cbz'): + # Extract images from CBZ (ZIP) to a temp folder and add them + try: + import zipfile, tempfile, shutil + temp_root = getattr(self, 'cbz_temp_root', None) + if not temp_root: + temp_root = tempfile.mkdtemp(prefix='glossarion_cbz_') + self.cbz_temp_root = temp_root + base = os.path.splitext(os.path.basename(path))[0] + extract_dir = os.path.join(temp_root, base) + os.makedirs(extract_dir, exist_ok=True) + with zipfile.ZipFile(path, 'r') as zf: + members = [m for m in zf.namelist() if m.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'))] + # Preserve order by natural sort + members.sort() + for m in members: + target_path = os.path.join(extract_dir, os.path.basename(m)) + if not os.path.exists(target_path): + with zf.open(m) as src, open(target_path, 'wb') as dst: + shutil.copyfileobj(src, dst) + processed_paths.append(target_path) + self.append_log(f"๐Ÿ“ฆ Extracted {len([p for p in processed_paths if p.startswith(extract_dir)])} images from {os.path.basename(path)}") + except Exception as e: + self.append_log(f"โŒ Failed to read CBZ: {os.path.basename(path)} - {e}") + else: + # Non-JSON/CBZ files pass through unchanged + processed_paths.append(path) + + # Store the list of selected files (using processed paths) + self.selected_files = processed_paths + self.current_file_index = 0 + + # Persist last selection to config for next session + try: + self.config['last_input_files'] = processed_paths + self.save_config(show_message=False) + except Exception: + pass + + # Update the entry field + self.entry_epub.delete(0, tk.END) + + # Define image extensions + image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'} + + if len(processed_paths) == 1: + # Single file - display full path + # Check if this was a JSON conversion + if processed_paths[0] in self.json_conversions: + # Show original JSON filename in parentheses + original_json = self.json_conversions[processed_paths[0]] + display_path = f"{processed_paths[0]} (from {os.path.basename(original_json)})" + self.entry_epub.insert(0, display_path) + else: + self.entry_epub.insert(0, processed_paths[0]) + self.file_path = processed_paths[0] # For backward compatibility + else: + # Multiple files - display count and summary + # Group by type (count original types, not processed) + images = [p for p in processed_paths if os.path.splitext(p)[1].lower() in image_extensions] + epubs = [p for p in processed_paths if p.lower().endswith('.epub')] + txts = [p for p in processed_paths if p.lower().endswith('.txt') and p not in self.json_conversions] + jsons = [p for p in self.json_conversions.values()] # Count original JSON files + converted_txts = [p for p in processed_paths if p in self.json_conversions] + + summary_parts = [] + if epubs: + summary_parts.append(f"{len(epubs)} EPUB") + if txts: + summary_parts.append(f"{len(txts)} TXT") + if jsons: + summary_parts.append(f"{len(jsons)} JSON") + if images: + summary_parts.append(f"{len(images)} images") + + display_text = f"{len(paths)} files selected ({', '.join(summary_parts)})" + self.entry_epub.insert(0, display_text) + self.file_path = processed_paths[0] # Set first file as primary + + # Check if these are image files + image_files = [p for p in processed_paths if os.path.splitext(p)[1].lower() in image_extensions] + + if image_files: + # Enable image translation if not already enabled + if hasattr(self, 'enable_image_translation_var') and not self.enable_image_translation_var.get(): + self.enable_image_translation_var.set(True) + self.append_log(f"๐Ÿ–ผ๏ธ Detected {len(image_files)} image file(s) - automatically enabled image translation") + + # Clear glossary for image files + if hasattr(self, 'auto_loaded_glossary_path'): + #self.manual_glossary_path = None + self.auto_loaded_glossary_path = None + self.auto_loaded_glossary_for_file = None + self.append_log("๐Ÿ“‘ Cleared glossary settings (image files selected)") + else: + # Handle EPUB/TXT files + epub_files = [p for p in processed_paths if p.lower().endswith('.epub')] + + if len(epub_files) == 1: + # Single EPUB - auto-load glossary + self.auto_load_glossary_for_file(epub_files[0]) + # Persist EPUB path for QA defaults + try: + self.selected_epub_path = epub_files[0] + self.selected_epub_files = [epub_files[0]] # Track single EPUB in list format + self.config['last_epub_path'] = epub_files[0] + os.environ['EPUB_PATH'] = epub_files[0] + self.save_config(show_message=False) + except Exception: + pass + elif len(epub_files) > 1: + # Multiple EPUBs - clear glossary but update EPUB path tracking + if hasattr(self, 'auto_loaded_glossary_path'): + self.manual_glossary_path = None + self.auto_loaded_glossary_path = None + self.auto_loaded_glossary_for_file = None + self.append_log("๐Ÿ“ Multiple files selected - glossary auto-loading disabled") + + # For multiple EPUBs, set the selected_epub_path to the first one + # but track all EPUBs for word count analysis + try: + self.selected_epub_path = epub_files[0] # Use first EPUB as primary + self.selected_epub_files = epub_files # Track all EPUBs + self.config['last_epub_path'] = epub_files[0] + os.environ['EPUB_PATH'] = epub_files[0] + self.save_config(show_message=False) + + # Log that multiple EPUBs are selected + self.append_log(f"๐Ÿ“– {len(epub_files)} EPUB files selected - using '{os.path.basename(epub_files[0])}' as primary for word count analysis") + except Exception: + pass + + def _convert_json_to_txt(self, json_path): + """Convert a JSON file to TXT format for translation.""" + try: + # Read JSON file + with open(json_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Parse JSON + try: + data = json.loads(content) + except json.JSONDecodeError as e: + self.append_log(f"โš ๏ธ JSON parsing error: {str(e)}") + self.append_log("๐Ÿ”ง Attempting to fix JSON...") + fixed_content = self._comprehensive_json_fix(content) + data = json.loads(fixed_content) + self.append_log("โœ… JSON fixed successfully") + + # Create output file + base_dir = os.path.dirname(json_path) + base_name = os.path.splitext(os.path.basename(json_path))[0] + txt_path = os.path.join(base_dir, f"{base_name}_json_temp.txt") + + # CHECK IF THIS IS A GLOSSARY - PUT EVERYTHING IN ONE CHAPTER + filename_lower = os.path.basename(json_path).lower() + is_glossary = any(term in filename_lower for term in ['glossary', 'dictionary', 'terms', 'characters', 'names']) + + # Also check structure + if not is_glossary and isinstance(data, dict): + # If it's a flat dictionary with many short entries, it's probably a glossary + if len(data) > 20: # More than 20 entries + values = list(data.values())[:10] # Check first 10 + if all(isinstance(v, str) and len(v) < 500 for v in values): + is_glossary = True + self.append_log("๐Ÿ“š Detected glossary structure (many short entries)") + self.append_log(f"๐Ÿ” Found {len(data)} dictionary entries with avg length < 500 chars") + + with open(txt_path, 'w', encoding='utf-8') as f: + # Add metadata header + f.write(f"[JSON_SOURCE: {os.path.basename(json_path)}]\n") + f.write(f"[JSON_STRUCTURE_TYPE: {type(data).__name__}]\n") + f.write(f"[JSON_CONVERSION_VERSION: 1.0]\n") + if is_glossary: + f.write("[GLOSSARY_MODE: SINGLE_CHUNK]\n") + f.write("\n") + + if is_glossary: + # PUT ENTIRE GLOSSARY IN ONE CHAPTER + self.append_log(f"๐Ÿ“š Glossary mode: Creating single chapter for {len(data)} entries") + self.append_log("๐Ÿšซ CHUNK SPLITTING DISABLED for glossary file") + self.append_log(f"๐Ÿ“ All {len(data)} entries will be processed in ONE API call") + f.write("=== Chapter 1: Full Glossary ===\n\n") + + if isinstance(data, dict): + for key, value in data.items(): + f.write(f"{key}: {value}\n\n") + elif isinstance(data, list): + for item in data: + if isinstance(item, str): + f.write(f"{item}\n\n") + else: + f.write(f"{json.dumps(item, ensure_ascii=False, indent=2)}\n\n") + else: + f.write(json.dumps(data, ensure_ascii=False, indent=2)) + + else: + # NORMAL PROCESSING - SEPARATE CHAPTERS + if isinstance(data, dict): + for idx, (key, value) in enumerate(data.items(), 1): + f.write(f"\n=== Chapter {idx}: {key} ===\n\n") + + if isinstance(value, str): + f.write(value) + elif isinstance(value, list) and all(isinstance(item, str) for item in value): + for item in value: + f.write(f"{item}\n\n") + else: + f.write(json.dumps(value, ensure_ascii=False, indent=2)) + + f.write("\n\n") + + elif isinstance(data, list): + for idx, item in enumerate(data, 1): + f.write(f"\n=== Chapter {idx} ===\n\n") + + if isinstance(item, str): + f.write(item) + else: + f.write(json.dumps(item, ensure_ascii=False, indent=2)) + + f.write("\n\n") + + else: + f.write("=== Content ===\n\n") + if isinstance(data, str): + f.write(data) + else: + f.write(json.dumps(data, ensure_ascii=False, indent=2)) + + return txt_path + + except Exception as e: + self.append_log(f"โŒ Error converting JSON: {str(e)}") + import traceback + self.append_log(f"Debug: {traceback.format_exc()}") + return None + + def convert_translated_to_json(self, translated_txt_path): + """Convert translated TXT back to JSON format if it was originally JSON.""" + + # Check if this was a JSON conversion + original_json_path = None + for txt_path, json_path in self.json_conversions.items(): + # Check if this is the translated version of a converted file + if translated_txt_path.replace("_translated", "_json_temp") == txt_path: + original_json_path = json_path + break + # Also check direct match + if txt_path.replace("_json_temp", "_translated") == translated_txt_path: + original_json_path = json_path + break + + if not original_json_path: + return None + + try: + # Read original JSON structure + with open(original_json_path, 'r', encoding='utf-8') as f: + original_data = json.load(f) + + # Read translated content + with open(translated_txt_path, 'r', encoding='utf-8') as f: + translated_content = f.read() + + # Remove metadata headers + lines = translated_content.split('\n') + content_start = 0 + for i, line in enumerate(lines): + if line.strip() and not line.startswith('[JSON_'): + content_start = i + break + translated_content = '\n'.join(lines[content_start:]) + + # Parse chapters from translated content + import re + chapter_pattern = r'=== Chapter \d+(?:: ([^=]+))? ===' + chapters = re.split(chapter_pattern, translated_content) + + # Clean up chapters + cleaned_chapters = [] + for i, chapter in enumerate(chapters): + if chapter and chapter.strip() and not chapter.startswith('==='): + cleaned_chapters.append(chapter.strip()) + + # Rebuild JSON structure with translated content + if isinstance(original_data, dict): + result = {} + keys = list(original_data.keys()) + + # Match chapters to original keys + for i, key in enumerate(keys): + if i < len(cleaned_chapters): + result[key] = cleaned_chapters[i] + else: + # Preserve original if no translation found + result[key] = original_data[key] + + elif isinstance(original_data, list): + result = [] + + for i, item in enumerate(original_data): + if i < len(cleaned_chapters): + if isinstance(item, dict) and 'content' in item: + # Preserve structure for dictionary items + new_item = item.copy() + new_item['content'] = cleaned_chapters[i] + result.append(new_item) + else: + # Direct replacement + result.append(cleaned_chapters[i]) + else: + # Preserve original if no translation found + result.append(item) + + else: + # Single value + result = cleaned_chapters[0] if cleaned_chapters else original_data + + # Save as JSON + output_json_path = translated_txt_path.replace('.txt', '.json') + with open(output_json_path, 'w', encoding='utf-8') as f: + json.dump(result, f, ensure_ascii=False, indent=2) + + self.append_log(f"โœ… Converted back to JSON: {os.path.basename(output_json_path)}") + return output_json_path + + except Exception as e: + self.append_log(f"โŒ Error converting back to JSON: {str(e)}") + import traceback + self.append_log(f"Debug: {traceback.format_exc()}") + return None + + def toggle_api_visibility(self): + show = self.api_key_entry.cget('show') + self.api_key_entry.config(show='' if show == '*' else '*') + # Track the visibility state + self.api_key_visible = (show == '*') # Will be True when showing, False when hiding + + def prompt_custom_token_limit(self): + val = simpledialog.askinteger( + "Set Max Output Token Limit", + "Enter max output tokens for API output (e.g., 16384, 32768, 65536):", + minvalue=1, + maxvalue=2000000 + ) + if val: + self.max_output_tokens = val + self.output_btn.config(text=f"Output Token Limit: {val}") + self.append_log(f"โœ… Output token limit set to {val}") + + # Note: open_other_settings method is bound from other_settings.py during __init__ + # No need to define it here - it's injected dynamically + + def __setattr__(self, name, value): + """Debug method to track when manual_glossary_path gets cleared""" + if name == 'manual_glossary_path': + import traceback + if value is None and hasattr(self, 'manual_glossary_path') and self.manual_glossary_path is not None: + if hasattr(self, 'append_log'): + self.append_log(f"[DEBUG] CLEARING manual_glossary_path from {self.manual_glossary_path} to None") + self.append_log(f"[DEBUG] Stack trace: {''.join(traceback.format_stack()[-3:-1])}") + else: + print(f"[DEBUG] CLEARING manual_glossary_path from {getattr(self, 'manual_glossary_path', 'unknown')} to None") + print(f"[DEBUG] Stack trace: {''.join(traceback.format_stack()[-3:-1])}") + super().__setattr__(name, value) + + def load_glossary(self): + """Let the user pick a glossary file (JSON or CSV) and remember its path.""" + import json + import shutil + from tkinter import filedialog, messagebox + + path = filedialog.askopenfilename( + title="Select glossary file", + filetypes=[ + ("Supported files", "*.json;*.csv;*.txt"), + ("JSON files", "*.json"), + ("CSV files", "*.csv"), + ("Text files", "*.txt"), + ("All files", "*.*") + ] + ) + if not path: + return + + # Determine file type + file_extension = os.path.splitext(path)[1].lower() + + if file_extension == '.csv': + # Handle CSV file - just pass it through as-is + # The translation system will handle the CSV file format + pass + + elif file_extension == '.txt': + # Handle TXT file - just pass it through as-is + # The translation system will handle the text file format + pass + + elif file_extension == '.json': + # Original JSON handling code + try: + with open(path, 'r', encoding='utf-8') as f: + content = f.read() + + # Store original content for comparison + original_content = content + + # Try normal JSON load first + try: + json.loads(content) + except json.JSONDecodeError as e: + self.append_log(f"โš ๏ธ JSON error detected: {str(e)}") + self.append_log("๐Ÿ”ง Attempting comprehensive auto-fix...") + + # Apply comprehensive auto-fixes + fixed_content = self._comprehensive_json_fix(content) + + # Try to parse the fixed content + try: + json.loads(fixed_content) + + # If successful, ask user if they want to save the fixed version + response = messagebox.askyesno( + "JSON Auto-Fix Successful", + f"The JSON file had errors that were automatically fixed.\n\n" + f"Original error: {str(e)}\n\n" + f"Do you want to save the fixed version?\n" + f"(A backup of the original will be created)" + ) + + if response: + # Save the fixed version + backup_path = path.replace('.json', '_backup.json') + shutil.copy2(path, backup_path) + + with open(path, 'w', encoding='utf-8') as f: + f.write(fixed_content) + + self.append_log(f"โœ… Auto-fixed JSON and saved. Backup created: {os.path.basename(backup_path)}") + content = fixed_content + else: + self.append_log("โš ๏ธ Using original JSON with errors (may cause issues)") + + except json.JSONDecodeError as e2: + # Auto-fix failed, show error and options + self.append_log(f"โŒ Auto-fix failed: {str(e2)}") + + # Build detailed error message + error_details = self._analyze_json_errors(content, fixed_content, e, e2) + + response = messagebox.askyesnocancel( + "JSON Fix Failed", + f"The JSON file has errors that couldn't be automatically fixed.\n\n" + f"Original error: {str(e)}\n" + f"After auto-fix attempt: {str(e2)}\n\n" + f"{error_details}\n\n" + f"Options:\n" + f"โ€ข YES: Open the file in your default editor to fix manually\n" + f"โ€ข NO: Try to use the file anyway (may fail)\n" + f"โ€ข CANCEL: Cancel loading this glossary" + ) + + if response is True: # YES - open in editor + try: + # Open file in default editor + import subprocess + import sys + + if sys.platform.startswith('win'): + os.startfile(path) + elif sys.platform.startswith('darwin'): + subprocess.run(['open', path]) + else: # linux + subprocess.run(['xdg-open', path]) + + messagebox.showinfo( + "Manual Edit", + "Please fix the JSON errors in your editor and save the file.\n" + "Then click OK to retry loading the glossary." + ) + + # Recursively call load_glossary to retry + self.load_glossary() + return + + except Exception as editor_error: + messagebox.showerror( + "Error", + f"Failed to open file in editor: {str(editor_error)}\n\n" + f"Please manually edit the file:\n{path}" + ) + return + + elif response is False: # NO - try to use anyway + self.append_log("โš ๏ธ Attempting to use JSON with errors (may cause issues)") + # Continue with the original content + + else: # CANCEL + self.append_log("โŒ Glossary loading cancelled") + return + + except Exception as e: + messagebox.showerror("Error", f"Failed to read glossary file: {str(e)}") + return + + else: + messagebox.showerror( + "Error", + f"Unsupported file type: {file_extension}\n" + "Please select a JSON, CSV, or TXT file." + ) + return + + # Clear auto-loaded tracking when manually loading + self.auto_loaded_glossary_path = None + self.auto_loaded_glossary_for_file = None + + self.manual_glossary_path = path + self.manual_glossary_manually_loaded = True + self.append_log(f"๐Ÿ“‘ Loaded manual glossary: {path}") + + # Save the file extension for later reference + self.manual_glossary_file_extension = file_extension + + self.append_glossary_var.set(True) + self.append_log("โœ… Automatically enabled 'Append Glossary to System Prompt'") + + def _comprehensive_json_fix(self, content): + """Apply comprehensive JSON fixes.""" + import re + + # Store original for comparison + fixed = content + + # 1. Remove BOM if present + if fixed.startswith('\ufeff'): + fixed = fixed[1:] + + # 2. Fix common Unicode issues first + replacements = { + '"': '"', # Left smart quote + '"': '"', # Right smart quote + ''': "'", # Left smart apostrophe + ''': "'", # Right smart apostrophe + 'โ€“': '-', # En dash + 'โ€”': '-', # Em dash + 'โ€ฆ': '...', # Ellipsis + '\u200b': '', # Zero-width space + '\u00a0': ' ', # Non-breaking space + } + for old, new in replacements.items(): + fixed = fixed.replace(old, new) + + # 3. Fix trailing commas in objects and arrays + fixed = re.sub(r',\s*}', '}', fixed) + fixed = re.sub(r',\s*]', ']', fixed) + + # 4. Fix multiple commas + fixed = re.sub(r',\s*,+', ',', fixed) + + # 5. Fix missing commas between array/object elements + # Between closing and opening braces/brackets + fixed = re.sub(r'}\s*{', '},{', fixed) + fixed = re.sub(r']\s*\[', '],[', fixed) + fixed = re.sub(r'}\s*\[', '},[', fixed) + fixed = re.sub(r']\s*{', '],{', fixed) + + # Between string values (but not inside strings) + # This is tricky, so we'll be conservative + fixed = re.sub(r'"\s+"(?=[^:]*":)', '","', fixed) + + # 6. Fix unquoted keys (simple cases) + # Match unquoted keys that are followed by a colon + fixed = re.sub(r'([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r'\1"\2":', fixed) + + # 7. Fix single quotes to double quotes for keys and simple string values + # Keys + fixed = re.sub(r"([{,]\s*)'([^']+)'(\s*:)", r'\1"\2"\3', fixed) + # Simple string values (be conservative) + fixed = re.sub(r"(:\s*)'([^'\"]*)'(\s*[,}])", r'\1"\2"\3', fixed) + + # 8. Fix common escape issues + # Replace single backslashes with double backslashes (except for valid escapes) + # This is complex, so we'll only fix obvious cases + fixed = re.sub(r'\\(?!["\\/bfnrtu])', r'\\\\', fixed) + + # 9. Ensure proper brackets/braces balance + # Count opening and closing brackets + open_braces = fixed.count('{') + close_braces = fixed.count('}') + open_brackets = fixed.count('[') + close_brackets = fixed.count(']') + + # Add missing closing braces/brackets at the end + if open_braces > close_braces: + fixed += '}' * (open_braces - close_braces) + if open_brackets > close_brackets: + fixed += ']' * (open_brackets - close_brackets) + + # 10. Remove trailing comma before EOF + fixed = re.sub(r',\s*$', '', fixed.strip()) + + # 11. Fix unescaped newlines in strings (conservative approach) + # This is very tricky to do with regex without a proper parser + # We'll skip this for safety + + # 12. Remove comments (JSON doesn't support comments) + # Remove // style comments + fixed = re.sub(r'//.*$', '', fixed, flags=re.MULTILINE) + # Remove /* */ style comments + fixed = re.sub(r'/\*.*?\*/', '', fixed, flags=re.DOTALL) + + return fixed + + def _analyze_json_errors(self, original, fixed, original_error, fixed_error): + """Analyze JSON errors and provide helpful information.""" + analysis = [] + + # Check for common issues + if '{' in original and original.count('{') != original.count('}'): + analysis.append(f"โ€ข Mismatched braces: {original.count('{')} opening, {original.count('}')} closing") + + if '[' in original and original.count('[') != original.count(']'): + analysis.append(f"โ€ข Mismatched brackets: {original.count('[')} opening, {original.count(']')} closing") + + if original.count('"') % 2 != 0: + analysis.append("โ€ข Odd number of quotes (possible unclosed string)") + + # Check for BOM + if original.startswith('\ufeff'): + analysis.append("โ€ข File starts with BOM (Byte Order Mark)") + + # Check for common problematic patterns + if re.search(r'[''""โ€ฆ]', original): + analysis.append("โ€ข Contains smart quotes or special Unicode characters") + + if re.search(r':\s*[a-zA-Z_][a-zA-Z0-9_]*\s*[,}]', original): + analysis.append("โ€ข Possible unquoted string values") + + if re.search(r'[{,]\s*[a-zA-Z_][a-zA-Z0-9_]*\s*:', original): + analysis.append("โ€ข Possible unquoted keys") + + if '//' in original or '/*' in original: + analysis.append("โ€ข Contains comments (not valid in JSON)") + + # Try to find the approximate error location + if hasattr(original_error, 'lineno'): + lines = original.split('\n') + if 0 < original_error.lineno <= len(lines): + error_line = lines[original_error.lineno - 1] + analysis.append(f"\nError near line {original_error.lineno}:") + analysis.append(f" {error_line.strip()}") + + return "\n".join(analysis) if analysis else "Unable to determine specific issues." + + def save_config(self, show_message=True): + """Persist all settings to config.json.""" + try: + # Create backup of existing config before saving + self._backup_config_file() + def safe_int(value, default): + try: return int(value) + except (ValueError, TypeError): return default + + def safe_float(value, default): + try: return float(value) + except (ValueError, TypeError): return default + + # Basic settings + self.config['model'] = self.model_var.get() + self.config['active_profile'] = self.profile_var.get() + self.config['prompt_profiles'] = self.prompt_profiles + self.config['contextual'] = self.contextual_var.get() + + # Validate numeric fields (skip validation if called from manga integration with show_message=False) + if show_message: + delay_val = self.delay_entry.get().strip() + if delay_val and not delay_val.replace('.', '', 1).isdigit(): + from PySide6.QtWidgets import QMessageBox + QMessageBox.critical(None, "Invalid Input", "Please enter a valid number for API call delay") + return + self.config['delay'] = safe_float(self.delay_entry.get().strip(), 2) + + if show_message: + thread_delay_val = self.thread_delay_var.get().strip() + if not thread_delay_val.replace('.', '', 1).isdigit(): + from PySide6.QtWidgets import QMessageBox + QMessageBox.critical(None, "Invalid Input", "Please enter a valid number for Threading Delay") + return + self.config['thread_submission_delay'] = safe_float(self.thread_delay_var.get().strip(), 0.5) + + if show_message: + trans_temp_val = self.trans_temp.get().strip() + if trans_temp_val: + try: float(trans_temp_val) + except ValueError: + from PySide6.QtWidgets import QMessageBox + QMessageBox.critical(None, "Invalid Input", "Please enter a valid number for Temperature") + return + self.config['translation_temperature'] = safe_float(self.trans_temp.get().strip(), 0.3) + + if show_message: + trans_history_val = self.trans_history.get().strip() + if trans_history_val and not trans_history_val.isdigit(): + from PySide6.QtWidgets import QMessageBox + QMessageBox.critical(None, "Invalid Input", "Please enter a valid number for Translation History Limit") + return + self.config['translation_history_limit'] = safe_int(self.trans_history.get().strip(), 2) + + # Add fuzzy matching threshold + if hasattr(self, 'fuzzy_threshold_var'): + fuzzy_val = self.fuzzy_threshold_var.get() + if 0.5 <= fuzzy_val <= 1.0: + self.config['glossary_fuzzy_threshold'] = fuzzy_val + else: + self.config['glossary_fuzzy_threshold'] = 0.90 # default + + # Add glossary format preference + if hasattr(self, 'use_legacy_csv_var'): + self.config['glossary_use_legacy_csv'] = self.use_legacy_csv_var.get() + + # Add after saving translation_prompt_text: + if hasattr(self, 'format_instructions_text'): + try: + self.config['glossary_format_instructions'] = self.format_instructions_text.get('1.0', tk.END).strip() + except: + pass + + if hasattr(self, 'azure_api_version_var'): + self.config['azure_api_version'] = self.azure_api_version_var.get() + + # Save all other settings + self.config['api_key'] = self.api_key_entry.get() + self.config['REMOVE_AI_ARTIFACTS'] = self.REMOVE_AI_ARTIFACTS_var.get() + self.config['attach_css_to_chapters'] = self.attach_css_to_chapters_var.get() + self.config['chapter_range'] = self.chapter_range_entry.get().strip() + self.config['use_rolling_summary'] = self.rolling_summary_var.get() + self.config['summary_role'] = self.summary_role_var.get() + self.config['max_output_tokens'] = self.max_output_tokens + self.config['translate_book_title'] = self.translate_book_title_var.get() + self.config['book_title_prompt'] = self.book_title_prompt + self.config['append_glossary'] = self.append_glossary_var.get() + self.config['emergency_paragraph_restore'] = self.emergency_restore_var.get() + self.config['reinforcement_frequency'] = safe_int(self.reinforcement_freq_var.get(), 10) + self.config['retry_duplicate_bodies'] = self.retry_duplicate_var.get() + self.config['duplicate_lookback_chapters'] = safe_int(self.duplicate_lookback_var.get(), 5) + self.config['token_limit_disabled'] = self.token_limit_disabled + self.config['glossary_min_frequency'] = safe_int(self.glossary_min_frequency_var.get(), 2) + self.config['glossary_max_names'] = safe_int(self.glossary_max_names_var.get(), 50) + self.config['glossary_max_titles'] = safe_int(self.glossary_max_titles_var.get(), 30) + self.config['glossary_batch_size'] = safe_int(self.glossary_batch_size_var.get(), 50) + self.config['enable_image_translation'] = self.enable_image_translation_var.get() + self.config['process_webnovel_images'] = self.process_webnovel_images_var.get() + self.config['webnovel_min_height'] = safe_int(self.webnovel_min_height_var.get(), 1000) + self.config['max_images_per_chapter'] = safe_int(self.max_images_per_chapter_var.get(), 1) + self.config['batch_translation'] = self.batch_translation_var.get() + self.config['batch_size'] = safe_int(self.batch_size_var.get(), 3) + self.config['conservative_batching'] = self.conservative_batching_var.get() + self.config['translation_history_rolling'] = self.translation_history_rolling_var.get() + + # OpenRouter transport/compression toggles (ensure persisted even when dialog not open) + if hasattr(self, 'openrouter_http_only_var'): + self.config['openrouter_use_http_only'] = bool(self.openrouter_http_only_var.get()) + os.environ['OPENROUTER_USE_HTTP_ONLY'] = '1' if self.openrouter_http_only_var.get() else '0' + if hasattr(self, 'openrouter_accept_identity_var'): + self.config['openrouter_accept_identity'] = bool(self.openrouter_accept_identity_var.get()) + os.environ['OPENROUTER_ACCEPT_IDENTITY'] = '1' if self.openrouter_accept_identity_var.get() else '0' + if hasattr(self, 'openrouter_preferred_provider_var'): + self.config['openrouter_preferred_provider'] = self.openrouter_preferred_provider_var.get() + os.environ['OPENROUTER_PREFERRED_PROVIDER'] = self.openrouter_preferred_provider_var.get() + self.config['glossary_history_rolling'] = self.glossary_history_rolling_var.get() + self.config['disable_epub_gallery'] = self.disable_epub_gallery_var.get() + self.config['disable_automatic_cover_creation'] = self.disable_automatic_cover_creation_var.get() + self.config['translate_cover_html'] = self.translate_cover_html_var.get() + self.config['enable_auto_glossary'] = self.enable_auto_glossary_var.get() + self.config['duplicate_detection_mode'] = self.duplicate_detection_mode_var.get() + self.config['chapter_number_offset'] = safe_int(self.chapter_number_offset_var.get(), 0) + self.config['use_header_as_output'] = self.use_header_as_output_var.get() + self.config['enable_decimal_chapters'] = self.enable_decimal_chapters_var.get() + self.config['enable_watermark_removal'] = self.enable_watermark_removal_var.get() + self.config['save_cleaned_images'] = self.save_cleaned_images_var.get() + self.config['advanced_watermark_removal'] = self.advanced_watermark_removal_var.get() + self.config['compression_factor'] = self.compression_factor_var.get() + self.config['translation_chunk_prompt'] = self.translation_chunk_prompt + self.config['image_chunk_prompt'] = self.image_chunk_prompt + self.config['force_ncx_only'] = self.force_ncx_only_var.get() + self.config['vertex_ai_location'] = self.vertex_location_var.get() + self.config['batch_translate_headers'] = self.batch_translate_headers_var.get() + self.config['headers_per_batch'] = self.headers_per_batch_var.get() + self.config['update_html_headers'] = self.update_html_headers_var.get() + self.config['save_header_translations'] = self.save_header_translations_var.get() + self.config['single_api_image_chunks'] = self.single_api_image_chunks_var.get() + self.config['enable_gemini_thinking'] = self.enable_gemini_thinking_var.get() + self.config['thinking_budget'] = int(self.thinking_budget_var.get()) if self.thinking_budget_var.get().lstrip('-').isdigit() else 0 + self.config['enable_gpt_thinking'] = self.enable_gpt_thinking_var.get() + self.config['gpt_reasoning_tokens'] = int(self.gpt_reasoning_tokens_var.get()) if self.gpt_reasoning_tokens_var.get().lstrip('-').isdigit() else 0 + self.config['gpt_effort'] = self.gpt_effort_var.get() + self.config['openai_base_url'] = self.openai_base_url_var.get() + self.config['groq_base_url'] = self.groq_base_url_var.get() # This was missing! + self.config['fireworks_base_url'] = self.fireworks_base_url_var.get() + self.config['use_custom_openai_endpoint'] = self.use_custom_openai_endpoint_var.get() + + # Save additional important missing settings + if hasattr(self, 'retain_source_extension_var'): + self.config['retain_source_extension'] = self.retain_source_extension_var.get() + # Update environment variable + os.environ['RETAIN_SOURCE_EXTENSION'] = '1' if self.retain_source_extension_var.get() else '0' + + if hasattr(self, 'use_fallback_keys_var'): + self.config['use_fallback_keys'] = self.use_fallback_keys_var.get() + + if hasattr(self, 'auto_update_check_var'): + self.config['auto_update_check'] = self.auto_update_check_var.get() + + # Preserve last update check time if it exists + if hasattr(self, 'update_manager') and self.update_manager: + self.config['last_update_check_time'] = self.update_manager._last_check_time + + # Save window manager safe ratios setting + if hasattr(self, 'wm') and hasattr(self.wm, '_force_safe_ratios'): + self.config['force_safe_ratios'] = self.wm._force_safe_ratios + + # Save metadata-related ignore settings + if hasattr(self, 'ignore_header_var'): + self.config['ignore_header'] = self.ignore_header_var.get() + + if hasattr(self, 'ignore_title_var'): + self.config['ignore_title'] = self.ignore_title_var.get() + self.config['disable_chapter_merging'] = self.disable_chapter_merging_var.get() + self.config['use_gemini_openai_endpoint'] = self.use_gemini_openai_endpoint_var.get() + self.config['gemini_openai_endpoint'] = self.gemini_openai_endpoint_var.get() + # Save extraction worker settings + self.config['enable_parallel_extraction'] = self.enable_parallel_extraction_var.get() + self.config['extraction_workers'] = self.extraction_workers_var.get() + # Save GUI yield setting and set environment variable + if hasattr(self, 'enable_gui_yield_var'): + self.config['enable_gui_yield'] = self.enable_gui_yield_var.get() + os.environ['ENABLE_GUI_YIELD'] = '1' if self.enable_gui_yield_var.get() else '0' + self.config['glossary_max_text_size'] = self.glossary_max_text_size_var.get() + self.config['glossary_chapter_split_threshold'] = self.glossary_chapter_split_threshold_var.get() + self.config['glossary_filter_mode'] = self.glossary_filter_mode_var.get() + self.config['image_chunk_overlap'] = safe_float(self.image_chunk_overlap_var.get(), 1.0) + + # Save HTTP/Network tuning settings (from Other Settings) + if hasattr(self, 'chunk_timeout_var'): + self.config['chunk_timeout'] = safe_int(self.chunk_timeout_var.get(), 900) + if hasattr(self, 'enable_http_tuning_var'): + self.config['enable_http_tuning'] = self.enable_http_tuning_var.get() + if hasattr(self, 'connect_timeout_var'): + self.config['connect_timeout'] = safe_float(self.connect_timeout_var.get(), 10.0) + if hasattr(self, 'read_timeout_var'): + self.config['read_timeout'] = safe_float(self.read_timeout_var.get(), 180.0) + if hasattr(self, 'http_pool_connections_var'): + self.config['http_pool_connections'] = safe_int(self.http_pool_connections_var.get(), 20) + if hasattr(self, 'http_pool_maxsize_var'): + self.config['http_pool_maxsize'] = safe_int(self.http_pool_maxsize_var.get(), 50) + if hasattr(self, 'ignore_retry_after_var'): + self.config['ignore_retry_after'] = self.ignore_retry_after_var.get() + if hasattr(self, 'max_retries_var'): + self.config['max_retries'] = safe_int(self.max_retries_var.get(), 7) + if hasattr(self, 'indefinite_rate_limit_retry_var'): + self.config['indefinite_rate_limit_retry'] = self.indefinite_rate_limit_retry_var.get() + + # Save retry settings (from Other Settings) + if hasattr(self, 'retry_truncated_var'): + self.config['retry_truncated'] = self.retry_truncated_var.get() + if hasattr(self, 'max_retry_tokens_var'): + self.config['max_retry_tokens'] = safe_int(self.max_retry_tokens_var.get(), 16384) + if hasattr(self, 'retry_timeout_var'): + self.config['retry_timeout'] = self.retry_timeout_var.get() + + # Save rolling summary settings (from Other Settings) + if hasattr(self, 'rolling_summary_exchanges_var'): + self.config['rolling_summary_exchanges'] = safe_int(self.rolling_summary_exchanges_var.get(), 5) + if hasattr(self, 'rolling_summary_mode_var'): + self.config['rolling_summary_mode'] = self.rolling_summary_mode_var.get() + if hasattr(self, 'rolling_summary_max_entries_var'): + self.config['rolling_summary_max_entries'] = safe_int(self.rolling_summary_max_entries_var.get(), 10) + + # Save QA/scanning settings (from Other Settings) + if hasattr(self, 'qa_auto_search_output_var'): + self.config['qa_auto_search_output'] = self.qa_auto_search_output_var.get() + if hasattr(self, 'disable_zero_detection_var'): + self.config['disable_zero_detection'] = self.disable_zero_detection_var.get() + if hasattr(self, 'disable_gemini_safety_var'): + self.config['disable_gemini_safety'] = self.disable_gemini_safety_var.get() + + # NEW: Save strip honorifics setting + self.config['strip_honorifics'] = self.strip_honorifics_var.get() + + # Save glossary backup settings + if hasattr(self, 'config') and 'glossary_auto_backup' in self.config: + # These might be set from the glossary backup dialog + pass # Already in config, don't overwrite + else: + # Set defaults if not already set + self.config.setdefault('glossary_auto_backup', True) + self.config.setdefault('glossary_max_backups', 50) + + # Save QA Scanner settings if they exist + if hasattr(self, 'config') and 'qa_scanner_settings' in self.config: + # QA scanner settings already exist in config, keep them + pass + else: + # Initialize default QA scanner settings if not present + default_qa_settings = { + 'foreign_char_threshold': 10, + 'excluded_characters': '', + 'check_encoding_issues': False, + 'check_repetition': True, +'check_translation_artifacts': False, + 'check_glossary_leakage': True, + 'min_file_length': 0, + 'report_format': 'detailed', + 'auto_save_report': True, + 'check_word_count_ratio': False, + 'check_multiple_headers': True, + 'warn_name_mismatch': False, + 'check_missing_html_tag': True, + 'check_paragraph_structure': True, + 'check_invalid_nesting': False, + 'paragraph_threshold': 0.3, + 'cache_enabled': True, + 'cache_auto_size': False, + 'cache_show_stats': False + } + self.config.setdefault('qa_scanner_settings', default_qa_settings) + + # Save AI Hunter config settings if they exist + if 'ai_hunter_config' not in self.config: + self.config['ai_hunter_config'] = {} + # Ensure ai_hunter_max_workers has a default value + self.config['ai_hunter_config'].setdefault('ai_hunter_max_workers', 1) + + # NEW: Save prompts from text widgets if they exist + if hasattr(self, 'auto_prompt_text'): + try: + self.config['auto_glossary_prompt'] = self.auto_prompt_text.get('1.0', tk.END).strip() + except: + pass + + if hasattr(self, 'append_prompt_text'): + try: + self.config['append_glossary_prompt'] = self.append_prompt_text.get('1.0', tk.END).strip() + except: + pass + + if hasattr(self, 'translation_prompt_text'): + try: + self.config['glossary_translation_prompt'] = self.translation_prompt_text.get('1.0', tk.END).strip() + except: + pass + + # Update environment variable when saving + if self.enable_parallel_extraction_var.get(): + os.environ["EXTRACTION_WORKERS"] = str(self.extraction_workers_var.get()) + else: + os.environ["EXTRACTION_WORKERS"] = "1" + + # Chapter Extraction Settings - Save all extraction-related settings + # These are the critical settings shown in the screenshot + + # Save Text Extraction Method (Standard/Enhanced) + if hasattr(self, 'text_extraction_method_var'): + self.config['text_extraction_method'] = self.text_extraction_method_var.get() + + # Save File Filtering Level (Smart/Comprehensive/Full) + if hasattr(self, 'file_filtering_level_var'): + self.config['file_filtering_level'] = self.file_filtering_level_var.get() + + # Save Preserve Markdown Structure setting + if hasattr(self, 'enhanced_preserve_structure_var'): + self.config['enhanced_preserve_structure'] = self.enhanced_preserve_structure_var.get() + + # Save Enhanced Filtering setting (for backwards compatibility) + if hasattr(self, 'enhanced_filtering_var'): + self.config['enhanced_filtering'] = self.enhanced_filtering_var.get() + + # Save force BeautifulSoup for traditional APIs + if hasattr(self, 'force_bs_for_traditional_var'): + self.config['force_bs_for_traditional'] = self.force_bs_for_traditional_var.get() + + # Update extraction_mode for backwards compatibility with older versions + if hasattr(self, 'text_extraction_method_var') and hasattr(self, 'file_filtering_level_var'): + if self.text_extraction_method_var.get() == 'enhanced': + self.config['extraction_mode'] = 'enhanced' + # When enhanced mode is selected, the filtering level applies to enhanced mode + self.config['enhanced_filtering'] = self.file_filtering_level_var.get() + else: + # When standard mode is selected, use the filtering level directly + self.config['extraction_mode'] = self.file_filtering_level_var.get() + elif hasattr(self, 'extraction_mode_var'): + # Fallback for older UI + self.config['extraction_mode'] = self.extraction_mode_var.get() + + # Save image compression settings if they exist + # These are saved from the compression dialog, but we ensure defaults here + if 'enable_image_compression' not in self.config: + self.config['enable_image_compression'] = False + if 'auto_compress_enabled' not in self.config: + self.config['auto_compress_enabled'] = True + if 'target_image_tokens' not in self.config: + self.config['target_image_tokens'] = 1000 + if 'image_compression_format' not in self.config: + self.config['image_compression_format'] = 'auto' + if 'webp_quality' not in self.config: + self.config['webp_quality'] = 85 + if 'jpeg_quality' not in self.config: + self.config['jpeg_quality'] = 85 + if 'png_compression' not in self.config: + self.config['png_compression'] = 6 + if 'max_image_dimension' not in self.config: + self.config['max_image_dimension'] = 2048 + if 'max_image_size_mb' not in self.config: + self.config['max_image_size_mb'] = 10 + if 'preserve_transparency' not in self.config: + self.config['preserve_transparency'] = False + if 'preserve_original_format' not in self.config: + self.config['preserve_original_format'] = False + if 'optimize_for_ocr' not in self.config: + self.config['optimize_for_ocr'] = True + if 'progressive_encoding' not in self.config: + self.config['progressive_encoding'] = True + if 'save_compressed_images' not in self.config: + self.config['save_compressed_images'] = False + + + # Add anti-duplicate parameters + if hasattr(self, 'enable_anti_duplicate_var'): + self.config['enable_anti_duplicate'] = self.enable_anti_duplicate_var.get() + self.config['top_p'] = self.top_p_var.get() + self.config['top_k'] = self.top_k_var.get() + self.config['frequency_penalty'] = self.frequency_penalty_var.get() + self.config['presence_penalty'] = self.presence_penalty_var.get() + self.config['repetition_penalty'] = self.repetition_penalty_var.get() + self.config['candidate_count'] = self.candidate_count_var.get() + self.config['custom_stop_sequences'] = self.custom_stop_sequences_var.get() + self.config['logit_bias_enabled'] = self.logit_bias_enabled_var.get() + self.config['logit_bias_strength'] = self.logit_bias_strength_var.get() + self.config['bias_common_words'] = self.bias_common_words_var.get() + self.config['bias_repetitive_phrases'] = self.bias_repetitive_phrases_var.get() + + # Save scanning phase settings + if hasattr(self, 'scan_phase_enabled_var'): + self.config['scan_phase_enabled'] = self.scan_phase_enabled_var.get() + if hasattr(self, 'scan_phase_mode_var'): + self.config['scan_phase_mode'] = self.scan_phase_mode_var.get() + + _tl = self.token_limit_entry.get().strip() + if _tl.isdigit(): + self.config['token_limit'] = int(_tl) + else: + self.config['token_limit'] = None + + # Store Google Cloud credentials path BEFORE encryption + # This should NOT be encrypted since it's just a file path + google_creds_path = self.config.get('google_cloud_credentials') + + # Encrypt the config + encrypted_config = encrypt_config(self.config) + + # Re-add the Google Cloud credentials path after encryption + # This ensures the path is stored unencrypted for easy access + if google_creds_path: + encrypted_config['google_cloud_credentials'] = google_creds_path + + # Validate config can be serialized to JSON before writing + try: + json_test = json.dumps(encrypted_config, ensure_ascii=False, indent=2) + except Exception as e: + raise Exception(f"Config validation failed - invalid JSON: {e}") + + # Write to file + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(encrypted_config, f, ensure_ascii=False, indent=2) + + # Only show message if requested + if show_message: + from PySide6.QtWidgets import QMessageBox + QMessageBox.information(None, "Saved", "Configuration saved.") + + except Exception as e: + # Always show error messages regardless of show_message + if show_message: + from PySide6.QtWidgets import QMessageBox + QMessageBox.critical(None, "Error", f"Failed to save configuration: {e}") + else: + # Silent fail when called from manga integration auto-save + print(f"Warning: Config save failed (silent): {e}") + # Try to restore from backup if save failed + self._restore_config_from_backup() + + def _backup_config_file(self): + """Create backup of the existing config file before saving.""" + try: + # Skip if config file doesn't exist yet + if not os.path.exists(CONFIG_FILE): + return + + # Get base directory that works in both development and frozen environments + base_dir = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) + + # Resolve config file path for backup directory + if os.path.isabs(CONFIG_FILE): + config_dir = os.path.dirname(CONFIG_FILE) + else: + config_dir = os.path.dirname(os.path.abspath(CONFIG_FILE)) + + # Create backup directory + backup_dir = os.path.join(config_dir, "config_backups") + os.makedirs(backup_dir, exist_ok=True) + + # Create timestamped backup name + backup_name = f"config_{time.strftime('%Y%m%d_%H%M%S')}.json.bak" + backup_path = os.path.join(backup_dir, backup_name) + + # Copy the file + shutil.copy2(CONFIG_FILE, backup_path) + + # Maintain only the last 10 backups + backups = [os.path.join(backup_dir, f) for f in os.listdir(backup_dir) + if f.startswith("config_") and f.endswith(".json.bak")] + backups.sort(key=lambda x: os.path.getmtime(x), reverse=True) + + # Remove oldest backups if more than 10 + for old_backup in backups[10:]: + try: + os.remove(old_backup) + except Exception: + pass # Ignore errors when cleaning old backups + + except Exception as e: + # Silent exception - don't interrupt normal operation if backup fails + print(f"Warning: Could not create config backup: {e}") + + def _restore_config_from_backup(self): + """Attempt to restore config from the most recent backup.""" + try: + # Locate backups directory + if os.path.isabs(CONFIG_FILE): + config_dir = os.path.dirname(CONFIG_FILE) + else: + config_dir = os.path.dirname(os.path.abspath(CONFIG_FILE)) + backup_dir = os.path.join(config_dir, "config_backups") + + if not os.path.exists(backup_dir): + return + + # Find most recent backup + backups = [os.path.join(backup_dir, f) for f in os.listdir(backup_dir) + if f.startswith("config_") and f.endswith(".json.bak")] + + if not backups: + return + + backups.sort(key=lambda x: os.path.getmtime(x), reverse=True) + latest_backup = backups[0] + + # Copy backup to config file + shutil.copy2(latest_backup, CONFIG_FILE) + from PySide6.QtWidgets import QMessageBox + QMessageBox.information(None, "Config Restored", + f"Configuration was restored from backup: {os.path.basename(latest_backup)}") + + # Reload config + try: + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + self.config = json.load(f) + self.config = decrypt_config(self.config) + except Exception as e: + QMessageBox.critical(None, "Error", f"Failed to reload configuration: {e}") + + except Exception as e: + from PySide6.QtWidgets import QMessageBox + QMessageBox.critical(None, "Restore Failed", f"Could not restore config from backup: {e}") + + def _create_manual_config_backup(self): + """Create a manual config backup.""" + try: + # Force create backup even if config file doesn't exist + self._backup_config_file() + from PySide6.QtWidgets import QMessageBox + QMessageBox.information(None, "Backup Created", "Configuration backup created successfully!") + except Exception as e: + from PySide6.QtWidgets import QMessageBox + QMessageBox.critical(None, "Backup Failed", f"Failed to create backup: {e}") + + def _open_backup_folder(self): + """Open the config backups folder in file explorer.""" + try: + if os.path.isabs(CONFIG_FILE): + config_dir = os.path.dirname(CONFIG_FILE) + else: + config_dir = os.path.dirname(os.path.abspath(CONFIG_FILE)) + backup_dir = os.path.join(config_dir, "config_backups") + + if not os.path.exists(backup_dir): + os.makedirs(backup_dir, exist_ok=True) + from PySide6.QtWidgets import QMessageBox + QMessageBox.information(None, "Backup Folder", f"Created backup folder: {backup_dir}") + + # Open folder in explorer (cross-platform) + import subprocess + import platform + + if platform.system() == "Windows": + os.startfile(backup_dir) + elif platform.system() == "Darwin": # macOS + subprocess.run(["open", backup_dir]) + else: # Linux + subprocess.run(["xdg-open", backup_dir]) + + except Exception as e: + from PySide6.QtWidgets import QMessageBox + QMessageBox.critical(None, "Error", f"Could not open backup folder: {e}") + + def _manual_restore_config(self): + """Show dialog to manually select and restore a config backup.""" + try: + if os.path.isabs(CONFIG_FILE): + config_dir = os.path.dirname(CONFIG_FILE) + else: + config_dir = os.path.dirname(os.path.abspath(CONFIG_FILE)) + backup_dir = os.path.join(config_dir, "config_backups") + + if not os.path.exists(backup_dir): + from PySide6.QtWidgets import QMessageBox + QMessageBox.information(None, "No Backups", "No backup folder found. No backups have been created yet.") + return + + # Get list of available backups + backups = [f for f in os.listdir(backup_dir) + if f.startswith("config_") and f.endswith(".json.bak")] + + if not backups: + from PySide6.QtWidgets import QMessageBox + QMessageBox.information(None, "No Backups", "No config backups found.") + return + + # Sort by creation time (newest first) + backups.sort(key=lambda x: os.path.getmtime(os.path.join(backup_dir, x)), reverse=True) + + # Use WindowManager to create scrollable dialog + dialog, scrollable_frame, canvas = self.wm.setup_scrollable( + self.master, + "Config Backup Manager", + width=0, + height=None, + max_width_ratio=0.6, + max_height_ratio=0.8 + ) + + # Main content + header_frame = tk.Frame(scrollable_frame) + header_frame.pack(fill=tk.X, padx=20, pady=(20, 10)) + + tk.Label(header_frame, text="Configuration Backup Manager", + font=('TkDefaultFont', 14, 'bold')).pack(anchor=tk.W) + + tk.Label(header_frame, + text="Select a backup to restore or manage your configuration backups.", + font=('TkDefaultFont', 10), fg='gray').pack(anchor=tk.W, pady=(5, 0)) + + # Info section + info_frame = tk.LabelFrame(scrollable_frame, text="Backup Information", padx=10, pady=10) + info_frame.pack(fill=tk.X, padx=20, pady=(0, 10)) + + info_text = f"๐Ÿ“ Backup Location: {backup_dir}\n๐Ÿ“Š Total Backups: {len(backups)}" + tk.Label(info_frame, text=info_text, font=('TkDefaultFont', 10), + fg='#333', justify=tk.LEFT).pack(anchor=tk.W) + + # Backup list section + list_frame = tk.LabelFrame(scrollable_frame, text="Available Backups (Newest First)", padx=10, pady=10) + list_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=(0, 10)) + + # Create treeview for better display + columns = ('timestamp', 'filename', 'size') + tree = ttk.Treeview(list_frame, columns=columns, show='headings', height=8) + + # Define headings + tree.heading('timestamp', text='Date & Time') + tree.heading('filename', text='Backup File') + tree.heading('size', text='Size') + + # Configure column widths + tree.column('timestamp', width=150, anchor='center') + tree.column('filename', width=200) + tree.column('size', width=80, anchor='center') + + # Add scrollbars for treeview + v_scrollbar = ttk.Scrollbar(list_frame, orient='vertical', command=tree.yview) + h_scrollbar = ttk.Scrollbar(list_frame, orient='horizontal', command=tree.xview) + tree.configure(yscrollcommand=v_scrollbar.set, xscrollcommand=h_scrollbar.set) + + # Pack treeview and scrollbars + tree.pack(side='left', fill='both', expand=True) + v_scrollbar.pack(side='right', fill='y') + h_scrollbar.pack(side='bottom', fill='x') + + # Populate treeview with backup information + backup_items = [] + for backup in backups: + backup_path = os.path.join(backup_dir, backup) + + # Extract timestamp from filename + try: + timestamp_part = backup.replace("config_", "").replace(".json.bak", "") + formatted_time = time.strftime("%Y-%m-%d %H:%M:%S", + time.strptime(timestamp_part, "%Y%m%d_%H%M%S")) + except: + formatted_time = "Unknown" + + # Get file size + try: + size_bytes = os.path.getsize(backup_path) + if size_bytes < 1024: + size_str = f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + size_str = f"{size_bytes // 1024} KB" + else: + size_str = f"{size_bytes // (1024 * 1024)} MB" + except: + size_str = "Unknown" + + # Insert into treeview + item_id = tree.insert('', 'end', values=(formatted_time, backup, size_str)) + backup_items.append((item_id, backup, formatted_time)) + + # Select first item by default + if backup_items: + tree.selection_set(backup_items[0][0]) + tree.focus(backup_items[0][0]) + + # Action buttons frame + button_frame = tk.LabelFrame(scrollable_frame, text="Actions", padx=10, pady=10) + button_frame.pack(fill=tk.X, padx=20, pady=(0, 10)) + + # Create button layout + button_row1 = tk.Frame(button_frame) + button_row1.pack(fill=tk.X, pady=(0, 5)) + + button_row2 = tk.Frame(button_frame) + button_row2.pack(fill=tk.X) + + def get_selected_backup(): + """Get currently selected backup from treeview""" + selection = tree.selection() + if not selection: + return None + + selected_item = selection[0] + for item_id, backup_filename, formatted_time in backup_items: + if item_id == selected_item: + return backup_filename, formatted_time + return None + + def restore_selected(): + selected = get_selected_backup() + if not selected: + messagebox.showwarning("No Selection", "Please select a backup to restore.") + return + + selected_backup, formatted_time = selected + backup_path = os.path.join(backup_dir, selected_backup) + + # Confirm restore + if messagebox.askyesno("Confirm Restore", + f"This will replace your current configuration with the backup from:\n\n" + f"{formatted_time}\n{selected_backup}\n\n" + f"A backup of your current config will be created first.\n\n" + f"Are you sure you want to continue?"): + + try: + # Create backup of current config before restore + self._backup_config_file() + + # Copy backup to config file + shutil.copy2(backup_path, CONFIG_FILE) + + messagebox.showinfo("Restore Complete", + f"Configuration restored from: {selected_backup}\n\n" + f"Please restart the application for changes to take effect.") + dialog._cleanup_scrolling() + dialog.destroy() + + except Exception as e: + messagebox.showerror("Restore Failed", f"Failed to restore backup: {e}") + + def delete_selected(): + selected = get_selected_backup() + if not selected: + messagebox.showwarning("No Selection", "Please select a backup to delete.") + return + + selected_backup, formatted_time = selected + + if messagebox.askyesno("Confirm Delete", + f"Delete backup from {formatted_time}?\n\n{selected_backup}\n\n" + f"This action cannot be undone."): + try: + os.remove(os.path.join(backup_dir, selected_backup)) + + # Remove from treeview + selection = tree.selection() + if selection: + tree.delete(selection[0]) + + # Update backup items list + backup_items[:] = [(item_id, backup, time_str) + for item_id, backup, time_str in backup_items + if backup != selected_backup] + + messagebox.showinfo("Deleted", "Backup deleted successfully.") + except Exception as e: + messagebox.showerror("Delete Failed", f"Failed to delete backup: {e}") + + def create_new_backup(): + """Create a new manual backup""" + try: + self._backup_config_file() + messagebox.showinfo("Backup Created", "New configuration backup created successfully!") + # Refresh the dialog + dialog._cleanup_scrolling() + dialog.destroy() + self._manual_restore_config() # Reopen with updated list + except Exception as e: + messagebox.showerror("Backup Failed", f"Failed to create backup: {e}") + + def open_backup_folder(): + """Open backup folder in file explorer""" + self._open_backup_folder() + + # Primary action buttons (Row 1) + tb.Button(button_row1, text="โœ… Restore Selected", + command=restore_selected, bootstyle="success", + width=20).pack(side=tk.LEFT, padx=(0, 10)) + + tb.Button(button_row1, text="๐Ÿ’พ Create New Backup", + command=create_new_backup, bootstyle="primary-outline", + width=20).pack(side=tk.LEFT, padx=(0, 10)) + + tb.Button(button_row1, text="๐Ÿ“ Open Folder", + command=open_backup_folder, bootstyle="info-outline", + width=20).pack(side=tk.LEFT) + + # Secondary action buttons (Row 2) + tb.Button(button_row2, text="๐Ÿ—‘๏ธ Delete Selected", + command=delete_selected, bootstyle="danger-outline", + width=20).pack(side=tk.LEFT, padx=(0, 10)) + + tb.Button(button_row2, text="โŒ Close", + command=lambda: [dialog._cleanup_scrolling(), dialog.destroy()], + bootstyle="secondary", + width=20).pack(side=tk.RIGHT) + + # Auto-resize and show dialog + self.wm.auto_resize_dialog(dialog, canvas, max_width_ratio=0.7, max_height_ratio=0.9) + + # Handle window close + dialog.protocol("WM_DELETE_WINDOW", lambda: [dialog._cleanup_scrolling(), dialog.destroy()]) + + except Exception as e: + messagebox.showerror("Error", f"Failed to open backup restore dialog: {e}") + + def _ensure_executor(self): + """Ensure a ThreadPoolExecutor exists and matches configured worker count. + Also updates EXTRACTION_WORKERS environment variable. + """ + try: + workers = 1 + try: + workers = int(self.extraction_workers_var.get()) if self.enable_parallel_extraction_var.get() else 1 + except Exception: + workers = 1 + if workers < 1: + workers = 1 + os.environ["EXTRACTION_WORKERS"] = str(workers) + + # If executor exists with same worker count, keep it + if getattr(self, 'executor', None) and getattr(self, '_executor_workers', None) == workers: + return + + # If executor exists but tasks are running, don't recreate to avoid disruption + active = any([ + getattr(self, 'translation_future', None) and not self.translation_future.done(), + getattr(self, 'glossary_future', None) and not self.glossary_future.done(), + getattr(self, 'epub_future', None) and not self.epub_future.done(), + getattr(self, 'qa_future', None) and not self.qa_future.done(), + ]) + if getattr(self, 'executor', None) and active: + self._executor_workers = workers # Remember desired workers for later + return + + # Safe to (re)create + if getattr(self, 'executor', None): + try: + self.executor.shutdown(wait=False) + except Exception: + pass + self.executor = None + + self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=workers, thread_name_prefix="Glossarion") + self._executor_workers = workers + except Exception as e: + try: + print(f"Executor setup failed: {e}") + except Exception: + pass + + def log_debug(self, message): + self.append_log(f"[DEBUG] {message}") + +if __name__ == "__main__": + import time + # Ensure console encoding can handle emojis/Unicode in frozen exe environments + try: + import io, sys as _sys + if hasattr(_sys.stdout, 'reconfigure'): + try: + _sys.stdout.reconfigure(encoding='utf-8', errors='ignore') + _sys.stderr.reconfigure(encoding='utf-8', errors='ignore') + except Exception: + pass + else: + try: + _sys.stdout = io.TextIOWrapper(_sys.stdout.buffer, encoding='utf-8', errors='ignore') + _sys.stderr = io.TextIOWrapper(_sys.stderr.buffer, encoding='utf-8', errors='ignore') + except Exception: + pass + except Exception: + pass + + print("๐Ÿš€ Starting Glossarion v5.0.4...") + + # Initialize splash screen + splash_manager = None + try: + from splash_utils import SplashManager + splash_manager = SplashManager() + splash_started = splash_manager.start_splash() + + if splash_started: + splash_manager.update_status("Loading theme framework...") + time.sleep(0.1) + except Exception as e: + print(f"โš ๏ธ Splash screen failed: {e}") + splash_manager = None + + try: + if splash_manager: + splash_manager.update_status("Loading UI framework...") + time.sleep(0.08) + + # Import ttkbootstrap while splash is visible + import ttkbootstrap as tb + from ttkbootstrap.constants import * + + # REAL module loading during splash screen with gradual progression + if splash_manager: + # Create a custom callback function for splash updates + def splash_callback(message): + if splash_manager and splash_manager.splash_window: + splash_manager.update_status(message) + splash_manager.splash_window.update() + time.sleep(0.09) + + # Actually load modules during splash with real feedback + splash_callback("Loading translation modules...") + + # Import and test each module for real + translation_main = translation_stop_flag = translation_stop_check = None + glossary_main = glossary_stop_flag = glossary_stop_check = None + fallback_compile_epub = scan_html_folder = None + + modules_loaded = 0 + total_modules = 4 + + # Load TranslateKRtoEN + splash_callback("Loading translation engine...") + try: + splash_callback("Validating translation engine...") + import TransateKRtoEN + if hasattr(TransateKRtoEN, 'main') and hasattr(TransateKRtoEN, 'set_stop_flag'): + from TransateKRtoEN import main as translation_main, set_stop_flag as translation_stop_flag, is_stop_requested as translation_stop_check + modules_loaded += 1 + splash_callback("โœ… translation engine loaded") + else: + splash_callback("โš ๏ธ translation engine incomplete") + except Exception as e: + splash_callback("โŒ translation engine failed") + print(f"Warning: Could not import TransateKRtoEN: {e}") + + # Load extract_glossary_from_epub + splash_callback("Loading glossary extractor...") + try: + splash_callback("Validating glossary extractor...") + import extract_glossary_from_epub + if hasattr(extract_glossary_from_epub, 'main') and hasattr(extract_glossary_from_epub, 'set_stop_flag'): + from extract_glossary_from_epub import main as glossary_main, set_stop_flag as glossary_stop_flag, is_stop_requested as glossary_stop_check + modules_loaded += 1 + splash_callback("โœ… glossary extractor loaded") + else: + splash_callback("โš ๏ธ glossary extractor incomplete") + except Exception as e: + splash_callback("โŒ glossary extractor failed") + print(f"Warning: Could not import extract_glossary_from_epub: {e}") + + # Load epub_converter + splash_callback("Loading EPUB converter...") + try: + import epub_converter + if hasattr(epub_converter, 'fallback_compile_epub'): + from epub_converter import fallback_compile_epub + modules_loaded += 1 + splash_callback("โœ… EPUB converter loaded") + else: + splash_callback("โš ๏ธ EPUB converter incomplete") + except Exception as e: + splash_callback("โŒ EPUB converter failed") + print(f"Warning: Could not import epub_converter: {e}") + + # Load scan_html_folder + splash_callback("Loading QA scanner...") + try: + import scan_html_folder + if hasattr(scan_html_folder, 'scan_html_folder'): + from scan_html_folder import scan_html_folder + modules_loaded += 1 + splash_callback("โœ… QA scanner loaded") + else: + splash_callback("โš ๏ธ QA scanner incomplete") + except Exception as e: + splash_callback("โŒ QA scanner failed") + print(f"Warning: Could not import scan_html_folder: {e}") + + # Final status with pause for visibility + splash_callback("Finalizing module initialization...") + if modules_loaded == total_modules: + splash_callback("โœ… All modules loaded successfully") + else: + splash_callback(f"โš ๏ธ {modules_loaded}/{total_modules} modules loaded") + + # Store loaded modules globally for GUI access + import translator_gui + translator_gui.translation_main = translation_main + translator_gui.translation_stop_flag = translation_stop_flag + translator_gui.translation_stop_check = translation_stop_check + translator_gui.glossary_main = glossary_main + translator_gui.glossary_stop_flag = glossary_stop_flag + translator_gui.glossary_stop_check = glossary_stop_check + translator_gui.fallback_compile_epub = fallback_compile_epub + translator_gui.scan_html_folder = scan_html_folder + + if splash_manager: + splash_manager.update_status("Creating main window...") + time.sleep(0.07) + + # Extra pause to show "Ready!" before closing + splash_manager.update_status("Ready!") + time.sleep(0.1) + splash_manager.close_splash() + + # Create main window (modules already loaded) + root = tb.Window(themename="darkly") + + # CRITICAL: Hide window immediately to prevent white flash + root.withdraw() + + # Initialize the app (modules already available) + app = TranslatorGUI(root) + + # Mark modules as already loaded to skip lazy loading + app._modules_loaded = True + app._modules_loading = False + + # CRITICAL: Let all widgets and theme fully initialize + root.update_idletasks() + + # CRITICAL: Now show the window after everything is ready + root.deiconify() + + print("โœ… Ready to use!") + + # Add cleanup handler for graceful shutdown + def on_closing(): + """Handle application shutdown gracefully to avoid GIL issues""" + try: + # Stop any background threads before destroying GUI + if hasattr(app, 'stop_all_operations'): + app.stop_all_operations() + + # Give threads a moment to stop + import time + time.sleep(0.1) + + # Destroy window + root.quit() + root.destroy() + except Exception: + # Force exit if cleanup fails + import os + os._exit(0) + + # Set the window close handler + root.protocol("WM_DELETE_WINDOW", on_closing) + + # Add signal handlers for clean shutdown + def signal_handler(signum, frame): + """Handle system signals gracefully""" + print(f"Received signal {signum}, shutting down gracefully...") + try: + on_closing() + except Exception: + os._exit(1) + + # Register signal handlers (Windows-safe) + if hasattr(signal, 'SIGINT'): + signal.signal(signal.SIGINT, signal_handler) + if hasattr(signal, 'SIGTERM'): + signal.signal(signal.SIGTERM, signal_handler) + + # Start main loop with error handling + try: + root.mainloop() + except Exception as e: + print(f"Main loop error: {e}") + finally: + # Ensure cleanup even if mainloop fails + try: + on_closing() + except Exception: + pass + + except Exception as e: + print(f"โŒ Failed to start application: {e}") + if splash_manager: + splash_manager.close_splash() + import traceback + traceback.print_exc() + sys.exit(1) + + finally: + if splash_manager: + try: + splash_manager.close_splash() + except: + pass \ No newline at end of file