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
,,
,
,
,
,
,
') + content.count('
]+>', '', content)) + if text_length > 0: + p_text = re.findall(r'
]*>(.*?)
', 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 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 = """
+
+ """
+
+ 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'' if icon_base64 else ''
+
+ gr.HTML(f"""
+
+
+
+
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 = """
+
+ """ % (
+ 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 , , , 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 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 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(' 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
,
,
, ,
,
, ,
,
, {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("