diff --git "a/app.py" "b/app.py" new file mode 100644--- /dev/null +++ "b/app.py" @@ -0,0 +1,5647 @@ +#!/usr/bin/env python3 +""" +Glossarion Web - Gradio Web Interface +AI-powered translation in your browser +""" + +import gradio as gr +import os +import sys +import json +import tempfile +import base64 +from pathlib import Path + +# CRITICAL: Set API delay IMMEDIATELY at module level before any other imports +# This ensures unified_api_client reads the correct value when it's imported +if 'SEND_INTERVAL_SECONDS' not in os.environ: + os.environ['SEND_INTERVAL_SECONDS'] = '0.5' +print(f"πŸ”§ Module-level API delay initialized: {os.environ['SEND_INTERVAL_SECONDS']}s") + +# Import API key encryption/decryption +try: + from api_key_encryption import APIKeyEncryption + API_KEY_ENCRYPTION_AVAILABLE = True + # Create web-specific encryption handler with its own key file + _web_encryption_handler = None + def get_web_encryption_handler(): + global _web_encryption_handler + if _web_encryption_handler is None: + _web_encryption_handler = APIKeyEncryption() + # Use web-specific key file + from pathlib import Path + _web_encryption_handler.key_file = Path('.glossarion_web_key') + _web_encryption_handler.cipher = _web_encryption_handler._get_or_create_cipher() + # Add web-specific fields to encrypt + _web_encryption_handler.api_key_fields.extend([ + 'azure_vision_key', + 'google_vision_credentials' + ]) + return _web_encryption_handler + + def decrypt_config(config): + return get_web_encryption_handler().decrypt_config(config) + + def encrypt_config(config): + return get_web_encryption_handler().encrypt_config(config) +except ImportError: + API_KEY_ENCRYPTION_AVAILABLE = False + def decrypt_config(config): + return config # Fallback: return config as-is + def encrypt_config(config): + return config # Fallback: return config as-is + +# Import your existing translation modules +try: + import TransateKRtoEN + from model_options import get_model_options + TRANSLATION_AVAILABLE = True +except ImportError: + TRANSLATION_AVAILABLE = False + print("⚠️ Translation modules not found") + +# Import manga translation modules +try: + from manga_translator import MangaTranslator + from unified_api_client import UnifiedClient + MANGA_TRANSLATION_AVAILABLE = True + print("βœ… Manga translation modules loaded successfully") +except ImportError as e: + MANGA_TRANSLATION_AVAILABLE = False + print(f"⚠️ Manga translation modules not found: {e}") + print(f"⚠️ Current working directory: {os.getcwd()}") + print(f"⚠️ Python path: {sys.path[:3]}...") + + # Check if files exist + files_to_check = ['manga_translator.py', 'unified_api_client.py', 'bubble_detector.py', 'local_inpainter.py'] + for file in files_to_check: + if os.path.exists(file): + print(f"βœ… Found: {file}") + else: + print(f"❌ Missing: {file}") + + +class GlossarionWeb: + """Web interface for Glossarion translator""" + + def __init__(self): + # Determine config file path based on environment + is_hf_spaces = os.getenv('SPACE_ID') is not None or os.getenv('HF_SPACES') == 'true' + + if is_hf_spaces: + # Use /data directory for Hugging Face Spaces persistent storage + data_dir = '/data' + if not os.path.exists(data_dir): + # Fallback to current directory if /data doesn't exist + data_dir = '.' + self.config_file = os.path.join(data_dir, 'config_web.json') + print(f"πŸ€— HF Spaces detected - using config path: {self.config_file}") + print(f"πŸ“ Directory exists: {os.path.exists(os.path.dirname(self.config_file))}") + else: + # Local mode - use current directory + self.config_file = "config_web.json" + print(f"🏠 Local mode - using config path: {self.config_file}") + + # Load raw config first + self.config = self.load_config() + + # Create a decrypted version for display/use in the UI + # but keep the original for saving + self.decrypted_config = self.config.copy() + if API_KEY_ENCRYPTION_AVAILABLE: + self.decrypted_config = decrypt_config(self.decrypted_config) + + # CRITICAL: Initialize environment variables IMMEDIATELY after loading config + # This must happen before any UnifiedClient is created + + # Set API call delay + api_call_delay = self.decrypted_config.get('api_call_delay', 0.5) + if 'api_call_delay' not in self.config: + self.config['api_call_delay'] = 0.5 + self.decrypted_config['api_call_delay'] = 0.5 + os.environ['SEND_INTERVAL_SECONDS'] = str(api_call_delay) + print(f"πŸ”§ Initialized API call delay: {api_call_delay}s") + + # Set batch translation settings + if 'batch_translation' not in self.config: + self.config['batch_translation'] = True + self.decrypted_config['batch_translation'] = True + if 'batch_size' not in self.config: + self.config['batch_size'] = 10 + self.decrypted_config['batch_size'] = 10 + print(f"πŸ“¦ Initialized batch translation: {self.config['batch_translation']}, batch size: {self.config['batch_size']}") + + # CRITICAL: Ensure extraction method and filtering level are initialized + if 'text_extraction_method' not in self.config: + self.config['text_extraction_method'] = 'standard' + self.decrypted_config['text_extraction_method'] = 'standard' + if 'file_filtering_level' not in self.config: + self.config['file_filtering_level'] = 'smart' + self.decrypted_config['file_filtering_level'] = 'smart' + if 'indefinitely_retry_rate_limit' not in self.config: + self.config['indefinitely_retry_rate_limit'] = False + self.decrypted_config['indefinitely_retry_rate_limit'] = False + if 'thread_submission_delay' not in self.config: + self.config['thread_submission_delay'] = 0.1 + self.decrypted_config['thread_submission_delay'] = 0.1 + if 'enhanced_preserve_structure' not in self.config: + self.config['enhanced_preserve_structure'] = True + self.decrypted_config['enhanced_preserve_structure'] = True + if 'force_bs_for_traditional' not in self.config: + self.config['force_bs_for_traditional'] = True + self.decrypted_config['force_bs_for_traditional'] = True + print(f"πŸ” Initialized extraction method: {self.config['text_extraction_method']}") + print(f"πŸ“‹ Initialized filtering level: {self.config['file_filtering_level']}") + print(f"πŸ” Initialized rate limit retry: {self.config['indefinitely_retry_rate_limit']}") + print(f"⏱️ Initialized threading delay: {self.config['thread_submission_delay']}s") + print(f"πŸ”§ Enhanced preserve structure: {self.config['enhanced_preserve_structure']}") + print(f"πŸ”§ Force BS for traditional: {self.config['force_bs_for_traditional']}") + + # Set font algorithm and auto fit style if not present + if 'manga_settings' not in self.config: + self.config['manga_settings'] = {} + if 'font_sizing' not in self.config['manga_settings']: + self.config['manga_settings']['font_sizing'] = {} + if 'rendering' not in self.config['manga_settings']: + self.config['manga_settings']['rendering'] = {} + + if 'algorithm' not in self.config['manga_settings']['font_sizing']: + self.config['manga_settings']['font_sizing']['algorithm'] = 'smart' + if 'auto_fit_style' not in self.config['manga_settings']['rendering']: + self.config['manga_settings']['rendering']['auto_fit_style'] = 'balanced' + + # Also ensure they're in decrypted_config + if 'manga_settings' not in self.decrypted_config: + self.decrypted_config['manga_settings'] = {} + if 'font_sizing' not in self.decrypted_config['manga_settings']: + self.decrypted_config['manga_settings']['font_sizing'] = {} + if 'rendering' not in self.decrypted_config['manga_settings']: + self.decrypted_config['manga_settings']['rendering'] = {} + if 'algorithm' not in self.decrypted_config['manga_settings']['font_sizing']: + self.decrypted_config['manga_settings']['font_sizing']['algorithm'] = 'smart' + if 'auto_fit_style' not in self.decrypted_config['manga_settings']['rendering']: + self.decrypted_config['manga_settings']['rendering']['auto_fit_style'] = 'balanced' + + print(f"🎨 Initialized font algorithm: {self.config['manga_settings']['font_sizing']['algorithm']}") + print(f"🎨 Initialized auto fit style: {self.config['manga_settings']['rendering']['auto_fit_style']}") + + self.models = get_model_options() if TRANSLATION_AVAILABLE else ["gpt-4", "claude-3-5-sonnet"] + print(f"πŸ€– Loaded {len(self.models)} models: {self.models[:5]}{'...' if len(self.models) > 5 else ''}") + + # Translation state management + import threading + self.is_translating = False + self.stop_flag = threading.Event() + self.translation_thread = None + self.current_unified_client = None # Track active client to allow cancellation + self.current_translator = None # Track active translator to allow shutdown + + # Add stop flags for different translation types + self.epub_translation_stop = False + self.epub_translation_thread = None + self.glossary_extraction_stop = False + self.glossary_extraction_thread = None + + # Default prompts from the GUI (same as translator_gui.py) + self.default_prompts = { + "korean": ( + "You are a professional Korean to English novel translator, you must strictly output only English text and HTML tags while following these rules:\n" + "- Use a natural, comedy-friendly English translation style that captures both humor and readability without losing any original meaning.\n" + "- Include 100% of the source text - every word, phrase, and sentence must be fully translated without exception.\n" + "- Retain Korean honorifics and respectful speech markers in romanized form, including but not limited to: -nim, -ssi, -yang, -gun, -isiyeo, -hasoseo. For archaic/classical Korean honorific forms (like μ΄μ‹œμ—¬/isiyeo, ν•˜μ†Œμ„œ/hasoseo), preserve them as-is rather than converting to modern equivalents.\n" + "- Always localize Korean terminology to proper English equivalents instead of literal translations (examples: λ§ˆμ™• = Demon King; 마술 = magic).\n" + "- When translating Korean's pronoun-dropping style, insert pronouns in English only where needed for clarity: prioritize original pronouns as implied or according to the glossary, and only use they/them as a last resort, use I/me for first-person narration, and maintain natural English flow without overusing pronouns just because they're omitted in Korean.\n" + "- All Korean profanity must be translated to English profanity.\n" + "- Preserve original intent, and speech tone.\n" + "- Retain onomatopoeia in Romaji.\n" + "- Keep original Korean quotation marks (" ", ' ', γ€Œγ€, γ€Žγ€) as-is without converting to English quotes.\n" + "- Every Korean/Chinese/Japanese character must be converted to its English meaning. Examples: The character 생 means 'life/living', ν™œ means 'active', κ΄€ means 'hall/building' - together μƒν™œκ΄€ means Dormitory.\n" + "- Preserve ALL HTML tags exactly as they appear in the source, including , , <h1>, <h2>, <p>, <br>, <div>, etc.\n" + ), + "japanese": ( + "You are a professional Japanese to English novel translator, you must strictly output only English text and HTML tags while following these rules:\n" + "- Use a natural, comedy-friendly English translation style that captures both humor and readability without losing any original meaning.\n" + "- Include 100% of the source text - every word, phrase, and sentence must be fully translated without exception.\n" + "- Retain Japanese honorifics and respectful speech markers in romanized form, including but not limited to: -san, -sama, -chan, -kun, -dono, -sensei, -senpai, -kouhai. For archaic/classical Japanese honorific forms, preserve them as-is rather than converting to modern equivalents.\n" + "- Always localize Japanese terminology to proper English equivalents instead of literal translations (examples: ι­”ηŽ‹ = Demon King; ι­”θ‘“ = magic).\n" + "- When translating Japanese's pronoun-dropping style, insert pronouns in English only where needed for clarity: prioritize original pronouns as implied or according to the glossary, and only use they/them as a last resort, use I/me for first-person narration while reflecting the Japanese pronoun's nuance (私/僕/δΏΊ/etc.) through speech patterns rather than the pronoun itself, and maintain natural English flow without overusing pronouns just because they're omitted in Japanese.\n" + "- All Japanese profanity must be translated to English profanity.\n" + "- Preserve original intent, and speech tone.\n" + "- Retain onomatopoeia in Romaji.\n" + "- Keep original Japanese quotation marks (γ€Œγ€, γ€Žγ€) as-is without converting to English quotes.\n" + "- Every Korean/Chinese/Japanese character must be converted to its English meaning. Examples: The character η”Ÿ means 'life/living', ζ΄» means 'active', 逨 means 'hall/building' - together η”Ÿζ΄»ι€¨ means Dormitory.\n" + "- Preserve ALL HTML tags exactly as they appear in the source, including <head>, <title>, <h1>, <h2>, <p>, <br>, <div>, etc.\n" + ), + "chinese": ( + "You are a professional Chinese to English novel translator, you must strictly output only English text and HTML tags while following these rules:\n" + "- Use a natural, comedy-friendly English translation style that captures both humor and readability without losing any original meaning.\n" + "- Include 100% of the source text - every word, phrase, and sentence must be fully translated without exception.\n" + "- Always localize Chinese terminology to proper English equivalents instead of literal translations (examples: ι­”ηŽ‹ = Demon King; 魔法 = magic).\n" + "- When translating Chinese's pronoun-dropping style, insert pronouns in English only where needed for clarity while maintaining natural English flow.\n" + "- All Chinese profanity must be translated to English profanity.\n" + "- Preserve original intent, and speech tone.\n" + "- Retain onomatopoeia in Pinyin.\n" + "- Keep original Chinese quotation marks (γ€Œγ€, γ€Žγ€) as-is without converting to English quotes.\n" + "- Every Korean/Chinese/Japanese character must be converted to its English meaning. Examples: The character η”Ÿ means 'life/living', ζ΄» means 'active', 逨 means 'hall/building' - together η”Ÿζ΄»ι€¨ means Dormitory.\n" + "- Preserve ALL HTML tags exactly as they appear in the source, including <head>, <title>, <h1>, <h2>, <p>, <br>, <div>, etc.\n" + ), + "Manga_JP": ( + "You are a professional Japanese to English Manga translator.\n" + "You have both the image of the Manga panel and the extracted text to work with.\n" + "Output only English text while following these rules: \n\n" + + "VISUAL CONTEXT:\n" + "- Analyze the character's facial expressions and body language in the image.\n" + "- Consider the scene's mood and atmosphere.\n" + "- Note any action or movement depicted.\n" + "- Use visual cues to determine the appropriate tone and emotion.\n" + "- USE THE IMAGE to inform your translation choices. The image is not decorative - it contains essential context for accurate translation.\n\n" + + "DIALOGUE REQUIREMENTS:\n" + "- Match the translation tone to the character's expression.\n" + "- If a character looks angry, use appropriately intense language.\n" + "- If a character looks shy or embarrassed, reflect that in the translation.\n" + "- Keep speech patterns consistent with the character's appearance and demeanor.\n" + "- Retain honorifics and onomatopoeia in Romaji.\n" + "- Keep original Japanese quotation marks (γ€Œγ€, γ€Žγ€) as-is without converting to English quotes.\n\n" + + "IMPORTANT: Use both the visual context and text to create the most accurate and natural-sounding translation.\n" + ), + "Manga_KR": ( + "You are a professional Korean to English Manhwa translator.\n" + "You have both the image of the Manhwa panel and the extracted text to work with.\n" + "Output only English text while following these rules: \n\n" + + "VISUAL CONTEXT:\n" + "- Analyze the character's facial expressions and body language in the image.\n" + "- Consider the scene's mood and atmosphere.\n" + "- Note any action or movement depicted.\n" + "- Use visual cues to determine the appropriate tone and emotion.\n" + "- USE THE IMAGE to inform your translation choices. The image is not decorative - it contains essential context for accurate translation.\n\n" + + "DIALOGUE REQUIREMENTS:\n" + "- Match the translation tone to the character's expression.\n" + "- If a character looks angry, use appropriately intense language.\n" + "- If a character looks shy or embarrassed, reflect that in the translation.\n" + "- Keep speech patterns consistent with the character's appearance and demeanor.\n" + "- Retain honorifics and onomatopoeia in Romaji.\n" + "- Keep original Korean quotation marks (β€œ ”, β€˜ β€˜, γ€Œγ€, γ€Žγ€) as-is without converting to English quotes.\n\n" + + "IMPORTANT: Use both the visual context and text to create the most accurate and natural-sounding translation.\n" + ), + "Manga_CN": ( + "You are a professional Chinese to English Manga translator.\n" + "You have both the image of the Manga panel and the extracted text to work with.\n" + "Output only English text while following these rules: \n\n" + + "VISUAL CONTEXT:\n" + "- Analyze the character's facial expressions and body language in the image.\n" + "- Consider the scene's mood and atmosphere.\n" + "- Note any action or movement depicted.\n" + "- Use visual cues to determine the appropriate tone and emotion.\n" + "- USE THE IMAGE to inform your translation choices. The image is not decorative - it contains essential context for accurate translation.\n\n" + + "DIALOGUE REQUIREMENTS:\n" + "- Match the translation tone to the character's expression.\n" + "- If a character looks angry, use appropriately intense language.\n" + "- If a character looks shy or embarrassed, reflect that in the translation.\n" + "- Keep speech patterns consistent with the character's appearance and demeanor.\n" + "- Retain honorifics and onomatopoeia in Romaji.\n" + "- Keep original Chinese quotation marks (γ€Œγ€, γ€Žγ€) as-is without converting to English quotes.\n\n" + + "IMPORTANT: Use both the visual context and text to create the most accurate and natural-sounding translation.\n" + ), + "Original": "Return everything exactly as seen on the source." + } + + # Load profiles from config and merge with defaults + # Always include default prompts, then overlay any custom ones from config + self.profiles = self.default_prompts.copy() + config_profiles = self.config.get('prompt_profiles', {}) + if config_profiles: + self.profiles.update(config_profiles) + + def get_config_value(self, key, default=None): + """Get value from decrypted config with fallback""" + return self.decrypted_config.get(key, default) + + def get_current_config_for_update(self): + """Get the current config for updating (uses in-memory version)""" + # Return a copy of the in-memory config, not loaded from file + return self.config.copy() + + def get_default_config(self): + """Get default configuration for Hugging Face Spaces""" + return { + 'model': 'gpt-4-turbo', + 'api_key': '', + 'api_call_delay': 0.5, # Default 0.5 seconds between API calls + 'batch_translation': True, # Enable batch translation by default + 'batch_size': 10, # Default batch size + 'text_extraction_method': 'standard', # CRITICAL: Default extraction method (standard=BeautifulSoup, enhanced=html2text) + 'file_filtering_level': 'smart', # CRITICAL: Default filtering level (smart/comprehensive/full) + 'enhanced_preserve_structure': True, # Preserve HTML structure in enhanced mode + 'force_bs_for_traditional': True, # CRITICAL: Force BeautifulSoup for traditional extraction + 'indefinitely_retry_rate_limit': False, # CRITICAL: Default to False for rate limit retry + 'thread_submission_delay': 0.1, # CRITICAL: Default threading delay + 'prompt_profiles': {}, # Will be populated from default_prompts in __init__ + 'active_profile': 'korean', # Default active profile + 'ocr_provider': 'custom-api', + 'bubble_detection_enabled': True, + 'inpainting_enabled': True, + 'manga_font_size_mode': 'auto', + 'manga_font_size': 0, + 'manga_font_size_multiplier': 1.0, + 'manga_min_font_size': 10, + 'manga_max_font_size': 40, + 'manga_text_color': [102, 0, 0], # Dark red text (manga_integration.py default) + 'manga_shadow_enabled': True, + 'manga_shadow_color': [204, 128, 128], # Light pink shadow (manga_integration.py default) + 'manga_shadow_offset_x': 2, # Match manga integration + 'manga_shadow_offset_y': 2, # Match manga integration + 'manga_shadow_blur': 0, # Match manga integration (no blur) + 'manga_bg_opacity': 0, # Transparent background by default + 'manga_bg_style': 'circle', + 'manga_settings': { + 'ocr': { + 'detector_type': 'rtdetr_onnx', + 'rtdetr_confidence': 0.3, + 'bubble_confidence': 0.3, + 'detect_text_bubbles': True, + 'detect_empty_bubbles': True, + 'detect_free_text': True, + 'bubble_max_detections_yolo': 100 + }, + 'inpainting': { + 'local_method': 'anime', + 'method': 'local', + 'batch_size': 10, + 'enable_cache': True + }, + 'advanced': { + 'parallel_processing': True, + 'max_workers': 2, + 'parallel_panel_translation': False, + 'panel_max_workers': 7, + 'format_detection': True, + 'webtoon_mode': 'auto', + 'torch_precision': 'fp16', + 'auto_cleanup_models': False, + 'debug_mode': False, + 'save_intermediate': False + }, + 'rendering': { + 'auto_min_size': 10, + 'auto_max_size': 40, + 'auto_fit_style': 'balanced' + }, + 'font_sizing': { + 'algorithm': 'smart', + 'prefer_larger': True, + 'max_lines': 10, + 'line_spacing': 1.3, + 'bubble_size_factor': True, + 'min_size': 10, + 'max_size': 40 + }, + 'tiling': { + 'enabled': False, + 'tile_size': 480, + 'tile_overlap': 64 + } + } + } + + def load_config(self): + """Load configuration - from persistent file on HF Spaces or local file""" + is_hf_spaces = os.getenv('SPACE_ID') is not None or os.getenv('HF_SPACES') == 'true' + + # Try to load from file (works both locally and on HF Spaces with persistent storage) + try: + if os.path.exists(self.config_file): + with open(self.config_file, 'r', encoding='utf-8') as f: + loaded_config = json.load(f) + # Start with defaults + default_config = self.get_default_config() + # Deep merge - preserve nested structures from loaded config + self._deep_merge_config(default_config, loaded_config) + + if is_hf_spaces: + print(f"βœ… Loaded config from persistent storage: {self.config_file}") + else: + print(f"βœ… Loaded config from local file: {self.config_file}") + + return default_config + except Exception as e: + print(f"Could not load config from {self.config_file}: {e}") + + # If loading fails or file doesn't exist - return defaults + print(f"πŸ“ Using default configuration") + return self.get_default_config() + + def _deep_merge_config(self, base, override): + """Deep merge override config into base config""" + for key, value in override.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + # Recursively merge nested dicts + self._deep_merge_config(base[key], value) + else: + # Override the value + base[key] = value + + def set_all_environment_variables(self): + """Set all environment variables from config for translation engines""" + config = self.get_config_value + + # API Rate Limiting + os.environ['SEND_INTERVAL_SECONDS'] = str(config('api_call_delay', 0.5)) + + # Chapter Processing Options + os.environ['BATCH_TRANSLATE_HEADERS'] = '1' if config('batch_translate_headers', False) else '0' + os.environ['HEADERS_PER_BATCH'] = str(config('headers_per_batch', 400)) + os.environ['USE_NCX_NAVIGATION'] = '1' if config('use_ncx_navigation', False) else '0' + os.environ['ATTACH_CSS_TO_CHAPTERS'] = '1' if config('attach_css_to_chapters', False) else '0' + os.environ['RETAIN_SOURCE_EXTENSION'] = '1' if config('retain_source_extension', True) else '0' + os.environ['USE_CONSERVATIVE_BATCHING'] = '1' if config('use_conservative_batching', False) else '0' + os.environ['DISABLE_GEMINI_SAFETY'] = '1' if config('disable_gemini_safety', False) else '0' + os.environ['USE_HTTP_OPENROUTER'] = '1' if config('use_http_openrouter', False) else '0' + os.environ['DISABLE_OPENROUTER_COMPRESSION'] = '1' if config('disable_openrouter_compression', False) else '0' + + # Chapter Extraction Settings + # TEXT_EXTRACTION_METHOD: 'standard' (BeautifulSoup) or 'enhanced' (html2text) + text_extraction_method = config('text_extraction_method', 'standard') + file_filtering_level = config('file_filtering_level', 'smart') + + os.environ['TEXT_EXTRACTION_METHOD'] = text_extraction_method + os.environ['FILE_FILTERING_LEVEL'] = file_filtering_level + + # EXTRACTION_MODE: Use file_filtering_level unless text_extraction_method is 'enhanced' + # If enhanced mode, EXTRACTION_MODE = 'enhanced', otherwise use filtering level + if text_extraction_method == 'enhanced': + os.environ['EXTRACTION_MODE'] = 'enhanced' + else: + os.environ['EXTRACTION_MODE'] = file_filtering_level + + # ENHANCED_FILTERING: Only relevant for enhanced mode, but set for all modes + os.environ['ENHANCED_FILTERING'] = file_filtering_level + + # ENHANCED_PRESERVE_STRUCTURE: Preserve HTML structure in enhanced mode + os.environ['ENHANCED_PRESERVE_STRUCTURE'] = '1' if config('enhanced_preserve_structure', True) else '0' + + # FORCE_BS_FOR_TRADITIONAL: Force BeautifulSoup for traditional/standard extraction + os.environ['FORCE_BS_FOR_TRADITIONAL'] = '1' if config('force_bs_for_traditional', True) else '0' + + # Rate Limit Retry Settings + os.environ['INDEFINITELY_RETRY_RATE_LIMIT'] = '1' if config('indefinitely_retry_rate_limit', False) else '0' + + # Thinking Mode Settings + os.environ['ENABLE_GPT_THINKING'] = '1' if config('enable_gpt_thinking', True) else '0' + os.environ['GPT_THINKING_EFFORT'] = config('gpt_thinking_effort', 'medium') + os.environ['OR_THINKING_TOKENS'] = str(config('or_thinking_tokens', 2000)) + os.environ['ENABLE_GEMINI_THINKING'] = '1' if config('enable_gemini_thinking', False) else '0' + os.environ['GEMINI_THINKING_BUDGET'] = str(config('gemini_thinking_budget', 0)) + # IMPORTANT: Also set THINKING_BUDGET for unified_api_client compatibility + os.environ['THINKING_BUDGET'] = str(config('gemini_thinking_budget', 0)) + + # Translation Settings + os.environ['CONTEXTUAL'] = '1' if config('contextual', False) else '0' + os.environ['TRANSLATION_HISTORY_LIMIT'] = str(config('translation_history_limit', 2)) + os.environ['TRANSLATION_HISTORY_ROLLING'] = '1' if config('translation_history_rolling', False) else '0' + os.environ['BATCH_TRANSLATION'] = '1' if config('batch_translation', True) else '0' + os.environ['BATCH_SIZE'] = str(config('batch_size', 10)) + os.environ['THREAD_SUBMISSION_DELAY'] = str(config('thread_submission_delay', 0.1)) + # DELAY is kept for backwards compatibility, but reads from api_call_delay + os.environ['DELAY'] = str(config('api_call_delay', 0.5)) + os.environ['CHAPTER_RANGE'] = config('chapter_range', '') + os.environ['TOKEN_LIMIT'] = str(config('token_limit', 200000)) + os.environ['TOKEN_LIMIT_DISABLED'] = '1' if config('token_limit_disabled', False) else '0' + os.environ['DISABLE_INPUT_TOKEN_LIMIT'] = '1' if config('token_limit_disabled', False) else '0' + + # Glossary Settings + os.environ['ENABLE_AUTO_GLOSSARY'] = '1' if config('enable_auto_glossary', False) else '0' + os.environ['APPEND_GLOSSARY_TO_PROMPT'] = '1' if config('append_glossary_to_prompt', True) else '0' + os.environ['GLOSSARY_MIN_FREQUENCY'] = str(config('glossary_min_frequency', 2)) + os.environ['GLOSSARY_MAX_NAMES'] = str(config('glossary_max_names', 50)) + os.environ['GLOSSARY_MAX_TITLES'] = str(config('glossary_max_titles', 30)) + os.environ['GLOSSARY_BATCH_SIZE'] = str(config('glossary_batch_size', 50)) + os.environ['GLOSSARY_FILTER_MODE'] = config('glossary_filter_mode', 'all') + os.environ['GLOSSARY_FUZZY_THRESHOLD'] = str(config('glossary_fuzzy_threshold', 0.90)) + + # Manual Glossary Settings + os.environ['MANUAL_GLOSSARY_MIN_FREQUENCY'] = str(config('manual_glossary_min_frequency', 2)) + os.environ['MANUAL_GLOSSARY_MAX_NAMES'] = str(config('manual_glossary_max_names', 50)) + os.environ['MANUAL_GLOSSARY_MAX_TITLES'] = str(config('manual_glossary_max_titles', 30)) + os.environ['GLOSSARY_MAX_TEXT_SIZE'] = str(config('glossary_max_text_size', 50000)) + os.environ['GLOSSARY_MAX_SENTENCES'] = str(config('glossary_max_sentences', 200)) + os.environ['GLOSSARY_CHAPTER_SPLIT_THRESHOLD'] = str(config('glossary_chapter_split_threshold', 8192)) + os.environ['MANUAL_GLOSSARY_FILTER_MODE'] = config('manual_glossary_filter_mode', 'all') + os.environ['STRIP_HONORIFICS'] = '1' if config('strip_honorifics', True) else '0' + os.environ['MANUAL_GLOSSARY_FUZZY_THRESHOLD'] = str(config('manual_glossary_fuzzy_threshold', 0.90)) + os.environ['GLOSSARY_USE_LEGACY_CSV'] = '1' if config('glossary_use_legacy_csv', False) else '0' + + # QA Scanner Settings + os.environ['ENABLE_POST_TRANSLATION_SCAN'] = '1' if config('enable_post_translation_scan', False) else '0' + os.environ['QA_MIN_FOREIGN_CHARS'] = str(config('qa_min_foreign_chars', 10)) + os.environ['QA_CHECK_REPETITION'] = '1' if config('qa_check_repetition', True) else '0' + os.environ['QA_CHECK_GLOSSARY_LEAKAGE'] = '1' if config('qa_check_glossary_leakage', True) else '0' + os.environ['QA_MIN_FILE_LENGTH'] = str(config('qa_min_file_length', 0)) + os.environ['QA_CHECK_MULTIPLE_HEADERS'] = '1' if config('qa_check_multiple_headers', True) else '0' + os.environ['QA_CHECK_MISSING_HTML'] = '1' if config('qa_check_missing_html', True) else '0' + os.environ['QA_CHECK_INSUFFICIENT_PARAGRAPHS'] = '1' if config('qa_check_insufficient_paragraphs', True) else '0' + os.environ['QA_MIN_PARAGRAPH_PERCENTAGE'] = str(config('qa_min_paragraph_percentage', 30)) + os.environ['QA_REPORT_FORMAT'] = config('qa_report_format', 'detailed') + os.environ['QA_AUTO_SAVE_REPORT'] = '1' if config('qa_auto_save_report', True) else '0' + + # Manga/Image Translation Settings (when available) + os.environ['BUBBLE_DETECTION_ENABLED'] = '1' if config('bubble_detection_enabled', True) else '0' + os.environ['INPAINTING_ENABLED'] = '1' if config('inpainting_enabled', True) else '0' + os.environ['MANGA_FONT_SIZE_MODE'] = config('manga_font_size_mode', 'auto') + os.environ['MANGA_FONT_SIZE'] = str(config('manga_font_size', 24)) + os.environ['MANGA_FONT_MULTIPLIER'] = str(config('manga_font_multiplier', 1.0)) + os.environ['MANGA_MIN_FONT_SIZE'] = str(config('manga_min_font_size', 12)) + os.environ['MANGA_MAX_FONT_SIZE'] = str(config('manga_max_font_size', 48)) + os.environ['MANGA_SHADOW_ENABLED'] = '1' if config('manga_shadow_enabled', True) else '0' + os.environ['MANGA_SHADOW_OFFSET_X'] = str(config('manga_shadow_offset_x', 2)) + os.environ['MANGA_SHADOW_OFFSET_Y'] = str(config('manga_shadow_offset_y', 2)) + os.environ['MANGA_SHADOW_BLUR'] = str(config('manga_shadow_blur', 0)) + os.environ['MANGA_BG_OPACITY'] = str(config('manga_bg_opacity', 130)) + os.environ['MANGA_BG_STYLE'] = config('manga_bg_style', 'circle') + + # OCR Provider Settings + os.environ['OCR_PROVIDER'] = config('ocr_provider', 'custom-api') + + # Advanced Manga Settings + manga_settings = config('manga_settings', {}) + if manga_settings: + advanced = manga_settings.get('advanced', {}) + os.environ['PARALLEL_PANEL_TRANSLATION'] = '1' if advanced.get('parallel_panel_translation', False) else '0' + os.environ['PANEL_MAX_WORKERS'] = str(advanced.get('panel_max_workers', 7)) + os.environ['PANEL_START_STAGGER_MS'] = str(advanced.get('panel_start_stagger_ms', 0)) + os.environ['WEBTOON_MODE'] = '1' if advanced.get('webtoon_mode', False) else '0' + os.environ['DEBUG_MODE'] = '1' if advanced.get('debug_mode', False) else '0' + os.environ['SAVE_INTERMEDIATE'] = '1' if advanced.get('save_intermediate', False) else '0' + os.environ['PARALLEL_PROCESSING'] = '1' if advanced.get('parallel_processing', True) else '0' + os.environ['MAX_WORKERS'] = str(advanced.get('max_workers', 4)) + os.environ['AUTO_CLEANUP_MODELS'] = '1' if advanced.get('auto_cleanup_models', False) else '0' + os.environ['TORCH_PRECISION'] = advanced.get('torch_precision', 'auto') + os.environ['PRELOAD_LOCAL_INPAINTING_FOR_PANELS'] = '1' if advanced.get('preload_local_inpainting_for_panels', False) else '0' + + # OCR settings + ocr = manga_settings.get('ocr', {}) + os.environ['DETECTOR_TYPE'] = ocr.get('detector_type', 'rtdetr_onnx') + os.environ['RTDETR_CONFIDENCE'] = str(ocr.get('rtdetr_confidence', 0.3)) + os.environ['BUBBLE_CONFIDENCE'] = str(ocr.get('bubble_confidence', 0.3)) + os.environ['DETECT_TEXT_BUBBLES'] = '1' if ocr.get('detect_text_bubbles', True) else '0' + os.environ['DETECT_EMPTY_BUBBLES'] = '1' if ocr.get('detect_empty_bubbles', True) else '0' + os.environ['DETECT_FREE_TEXT'] = '1' if ocr.get('detect_free_text', True) else '0' + os.environ['BUBBLE_MAX_DETECTIONS_YOLO'] = str(ocr.get('bubble_max_detections_yolo', 100)) + + # Inpainting settings + inpainting = manga_settings.get('inpainting', {}) + os.environ['LOCAL_INPAINT_METHOD'] = inpainting.get('local_method', 'anime_onnx') + os.environ['INPAINT_BATCH_SIZE'] = str(inpainting.get('batch_size', 10)) + os.environ['INPAINT_CACHE_ENABLED'] = '1' if inpainting.get('enable_cache', True) else '0' + + # HD Strategy + os.environ['HD_STRATEGY'] = advanced.get('hd_strategy', 'resize') + os.environ['HD_RESIZE_LIMIT'] = str(advanced.get('hd_strategy_resize_limit', 1536)) + os.environ['HD_CROP_MARGIN'] = str(advanced.get('hd_strategy_crop_margin', 16)) + os.environ['HD_CROP_TRIGGER'] = str(advanced.get('hd_strategy_crop_trigger_size', 1024)) + + # Concise Pipeline Logs + os.environ['CONCISE_PIPELINE_LOGS'] = '1' if config('concise_pipeline_logs', False) else '0' + + print("βœ… All environment variables set from configuration") + + def save_config(self, config): + """Save configuration - to persistent file on HF Spaces or local file""" + is_hf_spaces = os.getenv('SPACE_ID') is not None or os.getenv('HF_SPACES') == 'true' + + # Always try to save to file (works both locally and on HF Spaces with persistent storage) + try: + config_to_save = config.copy() + + # Only encrypt if we have the encryption module AND keys aren't already encrypted + if API_KEY_ENCRYPTION_AVAILABLE: + # Check if keys need encryption (not already encrypted) + needs_encryption = False + for key in ['api_key', 'azure_vision_key', 'google_vision_credentials']: + if key in config_to_save: + value = config_to_save[key] + # If it's a non-empty string that doesn't start with 'ENC:', it needs encryption + if value and isinstance(value, str) and not value.startswith('ENC:'): + needs_encryption = True + break + + if needs_encryption: + config_to_save = encrypt_config(config_to_save) + + # Create directory if it doesn't exist (important for HF Spaces) + os.makedirs(os.path.dirname(self.config_file) or '.', exist_ok=True) + + # Debug output + if is_hf_spaces: + print(f"πŸ“ Saving to HF Spaces persistent storage: {self.config_file}") + + print(f"DEBUG save_config called with model={config.get('model')}, batch_size={config.get('batch_size')}") + print(f"DEBUG self.config before={self.config.get('model') if hasattr(self, 'config') else 'N/A'}") + print(f"DEBUG self.decrypted_config before={self.decrypted_config.get('model') if hasattr(self, 'decrypted_config') else 'N/A'}") + + with open(self.config_file, 'w', encoding='utf-8') as f: + json.dump(config_to_save, f, ensure_ascii=False, indent=2) + + # IMPORTANT: Update the in-memory configs so the UI reflects the changes immediately + self.config = config_to_save + # Update decrypted config too + self.decrypted_config = config.copy() # Use the original (unencrypted) version + if API_KEY_ENCRYPTION_AVAILABLE: + # Make sure decrypted_config has decrypted values + self.decrypted_config = decrypt_config(self.decrypted_config) + + print(f"DEBUG self.config after={self.config.get('model')}") + print(f"DEBUG self.decrypted_config after={self.decrypted_config.get('model')}") + + if is_hf_spaces: + print(f"βœ… Saved to persistent storage: {self.config_file}") + # Also verify the file was written + if os.path.exists(self.config_file): + file_size = os.path.getsize(self.config_file) + print(f"βœ… File confirmed: {file_size} bytes") + return "βœ… Settings saved to persistent storage!" + else: + print(f"βœ… Saved to {self.config_file}") + return "βœ… Settings saved successfully!" + + except Exception as e: + print(f"❌ Save error: {e}") + if is_hf_spaces: + print(f"πŸ’‘ Note: Make sure you have persistent storage enabled for your Space") + return f"❌ Failed to save: {str(e)}\n\nNote: Persistent storage may not be enabled" + return f"❌ Failed to save: {str(e)}" + + def translate_epub( + self, + epub_file, + model, + api_key, + profile_name, + system_prompt, + temperature, + max_tokens, + enable_image_trans=False, + glossary_file=None + ): + """Translate EPUB file - yields progress updates""" + + if not TRANSLATION_AVAILABLE: + yield None, None, None, "❌ Translation modules not loaded", None, "Error", 0 + return + + if not epub_file: + yield None, None, None, "❌ Please upload an EPUB or TXT file", None, "Error", 0 + return + + if not api_key: + yield None, None, None, "❌ Please provide an API key", None, "Error", 0 + return + + if not profile_name: + yield None, None, None, "❌ Please select a translation profile", None, "Error", 0 + return + + # Initialize logs list + translation_logs = [] + + try: + # Initial status + input_path = epub_file.name if hasattr(epub_file, 'name') else epub_file + file_ext = os.path.splitext(input_path)[1].lower() + file_type = "EPUB" if file_ext == ".epub" else "TXT" + + translation_logs.append(f"πŸ“š Starting {file_type} translation...") + yield None, None, gr.update(visible=True), "\n".join(translation_logs), gr.update(visible=True), "Starting...", 0 + + # Save uploaded file to temp location if needed + epub_base = os.path.splitext(os.path.basename(input_path))[0] + + translation_logs.append(f"πŸ“– Input: {os.path.basename(input_path)}") + translation_logs.append(f"πŸ€– Model: {model}") + translation_logs.append(f"πŸ“ Profile: {profile_name}") + yield None, None, gr.update(visible=True), "\n".join(translation_logs), gr.update(visible=True), "Initializing...", 5 + + # Use the provided system prompt (user may have edited it) + translation_prompt = system_prompt if system_prompt else self.profiles.get(profile_name, "") + + # Set the input path as a command line argument simulation + import sys + original_argv = sys.argv.copy() + sys.argv = ['glossarion_web.py', input_path] + + # Set environment variables for TransateKRtoEN.main() + os.environ['input_path'] = input_path + os.environ['MODEL'] = model + os.environ['TRANSLATION_TEMPERATURE'] = str(temperature) + os.environ['MAX_OUTPUT_TOKENS'] = str(max_tokens) + os.environ['ENABLE_IMAGE_TRANSLATION'] = '1' if enable_image_trans else '0' + # Set output directory to current working directory + os.environ['OUTPUT_DIRECTORY'] = os.getcwd() + + # Set all additional environment variables from config + self.set_all_environment_variables() + + # OVERRIDE critical safety features AFTER config load + # CORRECT variable name is EMERGENCY_PARAGRAPH_RESTORE (no ATION) + os.environ['EMERGENCY_PARAGRAPH_RESTORE'] = '0' # DISABLED + os.environ['REMOVE_AI_ARTIFACTS'] = '1' # ENABLED + + # Debug: Verify ALL critical settings + translation_logs.append(f"\nπŸ”§ Debug: EMERGENCY_PARAGRAPH_RESTORE = '{os.environ.get('EMERGENCY_PARAGRAPH_RESTORE', 'NOT SET')}'") + translation_logs.append(f"πŸ”§ Debug: REMOVE_AI_ARTIFACTS = '{os.environ.get('REMOVE_AI_ARTIFACTS', 'NOT SET')}'") + translation_logs.append(f"πŸ” Debug: TEXT_EXTRACTION_METHOD = '{os.environ.get('TEXT_EXTRACTION_METHOD', 'NOT SET')}'") + translation_logs.append(f"πŸ” Debug: EXTRACTION_MODE = '{os.environ.get('EXTRACTION_MODE', 'NOT SET')}'") + translation_logs.append(f"πŸ“‹ Debug: FILE_FILTERING_LEVEL = '{os.environ.get('FILE_FILTERING_LEVEL', 'NOT SET')}'") + translation_logs.append(f"πŸ”§ Debug: FORCE_BS_FOR_TRADITIONAL = '{os.environ.get('FORCE_BS_FOR_TRADITIONAL', 'NOT SET')}'") + yield None, None, gr.update(visible=True), "\n".join(translation_logs), gr.update(visible=True), "Configuration set...", 10 + + # Set API key environment variable + if 'gpt' in model.lower() or 'openai' in model.lower(): + os.environ['OPENAI_API_KEY'] = api_key + os.environ['API_KEY'] = api_key + elif 'claude' in model.lower(): + os.environ['ANTHROPIC_API_KEY'] = api_key + os.environ['API_KEY'] = api_key + elif 'gemini' in model.lower(): + os.environ['GOOGLE_API_KEY'] = api_key + os.environ['API_KEY'] = api_key + else: + os.environ['API_KEY'] = api_key + + # Set the system prompt - CRITICAL: Must set environment variable for TransateKRtoEN.main() + if translation_prompt: + # Set environment variable that TransateKRtoEN reads + os.environ['SYSTEM_PROMPT'] = translation_prompt + print(f"βœ… System prompt set ({len(translation_prompt)} characters)") + + # Save to temp profile for consistency + temp_config = self.config.copy() + temp_config['prompt_profiles'] = temp_config.get('prompt_profiles', {}) + temp_config['prompt_profiles'][profile_name] = translation_prompt + temp_config['active_profile'] = profile_name + + # Save temporarily + with open(self.config_file, 'w', encoding='utf-8') as f: + json.dump(temp_config, f, ensure_ascii=False, indent=2) + else: + # Even if empty, set it to avoid using stale value + os.environ['SYSTEM_PROMPT'] = '' + print("⚠️ No system prompt provided") + + translation_logs.append("βš™οΈ Configuration set") + yield None, None, gr.update(visible=True), "\n".join(translation_logs), gr.update(visible=True), "Starting translation...", 10 + + # Create a thread-safe queue for capturing logs + import queue + import threading + import time + log_queue = queue.Queue() + translation_complete = threading.Event() + translation_error = [None] + + def log_callback(msg): + """Capture log messages""" + if msg and msg.strip(): + log_queue.put(msg.strip()) + + # Run translation in a separate thread + def run_translation(): + try: + result = TransateKRtoEN.main( + log_callback=log_callback, + stop_callback=None + ) + translation_error[0] = None + except Exception as e: + translation_error[0] = e + finally: + translation_complete.set() + + translation_thread = threading.Thread(target=run_translation, daemon=True) + translation_thread.start() + + # Monitor progress + last_yield_time = time.time() + progress_percent = 10 + + while not translation_complete.is_set() or not log_queue.empty(): + # Check if stop was requested + if self.epub_translation_stop: + translation_logs.append("⚠️ Stopping translation...") + # Try to stop the translation thread + translation_complete.set() + break + + # Collect logs + new_logs = [] + while not log_queue.empty(): + try: + msg = log_queue.get_nowait() + new_logs.append(msg) + except queue.Empty: + break + + # Add new logs + if new_logs: + translation_logs.extend(new_logs) + + # Update progress based on log content + for log in new_logs: + if 'Chapter' in log or 'chapter' in log: + progress_percent = min(progress_percent + 5, 90) + elif 'βœ…' in log or 'Complete' in log: + progress_percent = min(progress_percent + 10, 95) + elif 'Translating' in log: + progress_percent = min(progress_percent + 2, 85) + + # Yield updates periodically + current_time = time.time() + if new_logs or (current_time - last_yield_time) > 1.0: + status_text = new_logs[-1] if new_logs else "Processing..." + # Keep only last 100 logs to avoid UI overflow + display_logs = translation_logs[-100:] if len(translation_logs) > 100 else translation_logs + yield None, None, gr.update(visible=True), "\n".join(display_logs), gr.update(visible=True), status_text, progress_percent + last_yield_time = current_time + + # Small delay to avoid CPU spinning + time.sleep(0.1) + + # Wait for thread to complete + translation_thread.join(timeout=5) + + # Restore original sys.argv + sys.argv = original_argv + + # Log any errors but don't fail immediately - check for output first + if translation_error[0]: + error_msg = f"⚠️ Translation completed with warnings: {str(translation_error[0])}" + translation_logs.append(error_msg) + translation_logs.append("πŸ” Checking for output file...") + + # Check for output file - just grab any .epub from the output directory + output_dir = epub_base + compiled_epub = None + + # First, try to find ANY .epub file in the output directory + output_dir_path = os.path.join(os.getcwd(), output_dir) + if os.path.isdir(output_dir_path): + translation_logs.append(f"\nπŸ“‚ Checking output directory: {output_dir_path}") + for file in os.listdir(output_dir_path): + if file.endswith('.epub'): + full_path = os.path.join(output_dir_path, file) + # Make sure it's not a temp/backup file + if os.path.isfile(full_path) and os.path.getsize(full_path) > 1000: + compiled_epub = full_path + translation_logs.append(f" βœ… Found EPUB in output dir: {file}") + break + + # If we found it in the output directory, return it immediately + if compiled_epub: + file_size = os.path.getsize(compiled_epub) + translation_logs.append(f"\nβœ… Translation complete: {os.path.basename(compiled_epub)}") + translation_logs.append(f"πŸ”— File path: {compiled_epub}") + translation_logs.append(f"πŸ“ File size: {file_size:,} bytes ({file_size/1024/1024:.2f} MB)") + + # Create ZIP file containing the entire output folder + import zipfile + # Get the output folder (where the EPUB is located) + output_folder = os.path.dirname(compiled_epub) + folder_name = os.path.basename(output_folder) if output_folder else epub_base + zip_path = os.path.join(os.path.dirname(output_folder) if output_folder else os.getcwd(), f"{folder_name}.zip") + translation_logs.append(f"πŸ“¦ Creating ZIP archive of output folder...") + + try: + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Walk through the output folder and add all files + for root, dirs, files in os.walk(output_folder): + for file in files: + file_path = os.path.join(root, file) + # Create relative path for the archive + arcname = os.path.relpath(file_path, os.path.dirname(output_folder)) + zipf.write(file_path, arcname) + translation_logs.append(f" Added: {arcname}") + + zip_size = os.path.getsize(zip_path) + translation_logs.append(f"βœ… ZIP created: {os.path.basename(zip_path)}") + translation_logs.append(f"πŸ“ ZIP size: {zip_size:,} bytes ({zip_size/1024/1024:.2f} MB)") + translation_logs.append(f"πŸ“₯ Click 'Download Translated {file_type}' below to save your ZIP file") + + final_status = "Translation complete!" if not translation_error[0] else "Translation completed with warnings" + + yield ( + zip_path, + gr.update(value="### βœ… Translation Complete!", visible=True), + gr.update(visible=False), + "\n".join(translation_logs), + gr.update(value=final_status, visible=True), + final_status, + 100 + ) + return + except Exception as zip_error: + translation_logs.append(f"⚠️ Could not create ZIP: {zip_error}") + translation_logs.append(f"πŸ“₯ Returning original {file_type} file instead") + final_status = "Translation complete!" if not translation_error[0] else "Translation completed with warnings" + + yield ( + compiled_epub, + gr.update(value="### βœ… Translation Complete!", visible=True), + gr.update(visible=False), + "\n".join(translation_logs), + gr.update(value=final_status, visible=True), + final_status, + 100 + ) + return + + # Determine output extension based on input file type + output_ext = ".epub" if file_ext == ".epub" else ".txt" + + # Get potential base directories + base_dirs = [ + os.getcwd(), # Current working directory + os.path.dirname(input_path), # Input file directory + "/tmp", # Common temp directory on Linux/HF Spaces + "/home/user/app", # HF Spaces app directory + os.path.expanduser("~"), # Home directory + ] + + # Look for multiple possible output locations + possible_paths = [] + + # Extract title from input filename for more patterns + # e.g., "tales of terror_dick donovan 2" -> "Tales of Terror" + title_parts = os.path.basename(input_path).replace(output_ext, '').split('_') + possible_titles = [ + epub_base, # Original: tales of terror_dick donovan 2 + ' '.join(title_parts[:-2]).title() if len(title_parts) > 2 else epub_base, # Tales Of Terror + ] + + for base_dir in base_dirs: + if base_dir and os.path.exists(base_dir): + for title in possible_titles: + # Direct in base directory + possible_paths.append(os.path.join(base_dir, f"{title}_translated{output_ext}")) + possible_paths.append(os.path.join(base_dir, f"{title}{output_ext}")) + # In output subdirectory + possible_paths.append(os.path.join(base_dir, output_dir, f"{title}_translated{output_ext}")) + possible_paths.append(os.path.join(base_dir, output_dir, f"{title}{output_ext}")) + # In nested output directory + possible_paths.append(os.path.join(base_dir, epub_base, f"{title}_translated{output_ext}")) + possible_paths.append(os.path.join(base_dir, epub_base, f"{title}{output_ext}")) + + # Also add relative paths + possible_paths.extend([ + f"{epub_base}_translated{output_ext}", + os.path.join(output_dir, f"{epub_base}_translated{output_ext}"), + os.path.join(output_dir, f"{epub_base}{output_ext}"), + ]) + + # Also search for any translated file in the output directory + if os.path.isdir(output_dir): + for file in os.listdir(output_dir): + if file.endswith(f'_translated{output_ext}'): + possible_paths.insert(0, os.path.join(output_dir, file)) + + # Add debug information about current environment + translation_logs.append(f"\nπŸ“ Debug Info:") + translation_logs.append(f" Current working directory: {os.getcwd()}") + translation_logs.append(f" Input file directory: {os.path.dirname(input_path)}") + translation_logs.append(f" Looking for: {epub_base}_translated{output_ext}") + + translation_logs.append(f"\nπŸ” Searching for output file...") + for potential_epub in possible_paths[:10]: # Show first 10 paths + translation_logs.append(f" Checking: {potential_epub}") + if os.path.exists(potential_epub): + compiled_epub = potential_epub + translation_logs.append(f" βœ… Found: {potential_epub}") + break + + if not compiled_epub and len(possible_paths) > 10: + translation_logs.append(f" ... and {len(possible_paths) - 10} more paths") + + if compiled_epub: + # Verify file exists and is readable + if os.path.exists(compiled_epub) and os.path.isfile(compiled_epub): + file_size = os.path.getsize(compiled_epub) + translation_logs.append(f"βœ… Translation complete: {os.path.basename(compiled_epub)}") + translation_logs.append(f"πŸ”— File path: {compiled_epub}") + translation_logs.append(f"πŸ“ File size: {file_size:,} bytes ({file_size/1024/1024:.2f} MB)") + + # Create ZIP file containing the entire output folder + import zipfile + # Get the output folder (where the EPUB is located) + output_folder = os.path.dirname(compiled_epub) + folder_name = os.path.basename(output_folder) if output_folder else epub_base + zip_path = os.path.join(os.path.dirname(output_folder) if output_folder else os.getcwd(), f"{folder_name}.zip") + translation_logs.append(f"πŸ“¦ Creating ZIP archive of output folder...") + + try: + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Walk through the output folder and add all files + for root, dirs, files in os.walk(output_folder): + for file in files: + file_path = os.path.join(root, file) + # Create relative path for the archive + arcname = os.path.relpath(file_path, os.path.dirname(output_folder)) + zipf.write(file_path, arcname) + translation_logs.append(f" Added: {arcname}") + + zip_size = os.path.getsize(zip_path) + translation_logs.append(f"βœ… ZIP created: {os.path.basename(zip_path)}") + translation_logs.append(f"πŸ“ ZIP size: {zip_size:,} bytes ({zip_size/1024/1024:.2f} MB)") + translation_logs.append(f"πŸ“₯ Click 'Download Translated {file_type}' below to save your ZIP file") + + final_status = "Translation complete!" if not translation_error[0] else "Translation completed with warnings" + + yield ( + zip_path, + gr.update(value="### βœ… Translation Complete!", visible=True), + gr.update(visible=False), + "\n".join(translation_logs), + gr.update(value=final_status, visible=True), + final_status, + 100 + ) + return + except Exception as zip_error: + translation_logs.append(f"⚠️ Could not create ZIP: {zip_error}") + translation_logs.append(f"πŸ“₯ Returning original {file_type} file instead") + final_status = "Translation complete!" if not translation_error[0] else "Translation completed with warnings" + + yield ( + compiled_epub, + gr.update(value="### βœ… Translation Complete!", visible=True), + gr.update(visible=False), + "\n".join(translation_logs), + gr.update(value=final_status, visible=True), + final_status, + 100 + ) + return + else: + translation_logs.append(f"⚠️ File found but not accessible: {compiled_epub}") + compiled_epub = None # Force search + + # Output file not found - search recursively in relevant directories + translation_logs.append("⚠️ Output file not in expected locations, searching recursively...") + found_files = [] + + # Search in multiple directories + search_dirs = [ + os.getcwd(), # Current directory + os.path.dirname(input_path), # Input file directory + "/tmp", # Temp directory (HF Spaces) + "/home/user/app", # HF Spaces app directory + ] + + for search_dir in search_dirs: + if not os.path.exists(search_dir): + continue + + translation_logs.append(f" Searching in: {search_dir}") + try: + for root, dirs, files in os.walk(search_dir, topdown=True): + # Limit depth to 3 levels and skip hidden/system directories + depth = root[len(search_dir):].count(os.sep) + if depth >= 3: + dirs[:] = [] # Don't go deeper + else: + dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ['__pycache__', 'node_modules', 'venv', '.git']] + + for file in files: + # Look for files with _translated in name or matching our pattern + if (f'_translated{output_ext}' in file or + (file.endswith(output_ext) and epub_base in file)): + full_path = os.path.join(root, file) + found_files.append(full_path) + translation_logs.append(f" βœ… Found: {full_path}") + except (PermissionError, OSError) as e: + translation_logs.append(f" ⚠️ Could not search {search_dir}: {e}") + + if found_files: + # Use the most recently modified file + compiled_epub = max(found_files, key=os.path.getmtime) + + # Verify file exists and get info + if os.path.exists(compiled_epub) and os.path.isfile(compiled_epub): + file_size = os.path.getsize(compiled_epub) + translation_logs.append(f"βœ… Found output file: {os.path.basename(compiled_epub)}") + translation_logs.append(f"πŸ”— File path: {compiled_epub}") + translation_logs.append(f"πŸ“ File size: {file_size:,} bytes ({file_size/1024/1024:.2f} MB)") + + # Create ZIP file containing the entire output folder + import zipfile + # Get the output folder (where the EPUB is located) + output_folder = os.path.dirname(compiled_epub) + folder_name = os.path.basename(output_folder) if output_folder else epub_base + zip_path = os.path.join(os.path.dirname(output_folder) if output_folder else os.getcwd(), f"{folder_name}.zip") + translation_logs.append(f"πŸ“¦ Creating ZIP archive of output folder...") + + try: + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Walk through the output folder and add all files + for root, dirs, files in os.walk(output_folder): + for file in files: + file_path = os.path.join(root, file) + # Create relative path for the archive + arcname = os.path.relpath(file_path, os.path.dirname(output_folder)) + zipf.write(file_path, arcname) + translation_logs.append(f" Added: {arcname}") + + zip_size = os.path.getsize(zip_path) + translation_logs.append(f"βœ… ZIP created: {os.path.basename(zip_path)}") + translation_logs.append(f"πŸ“ ZIP size: {zip_size:,} bytes ({zip_size/1024/1024:.2f} MB)") + translation_logs.append(f"πŸ“₯ Click 'Download Translated {file_type}' below to save your ZIP file") + + yield ( + zip_path, + gr.update(value="### βœ… Translation Complete!", visible=True), + gr.update(visible=False), + "\n".join(translation_logs), + gr.update(value="Translation complete!", visible=True), + "Translation complete!", + 100 + ) + return + except Exception as zip_error: + translation_logs.append(f"⚠️ Could not create ZIP: {zip_error}") + translation_logs.append(f"πŸ“₯ Returning original {file_type} file instead") + + yield ( + compiled_epub, + gr.update(value="### βœ… Translation Complete!", visible=True), + gr.update(visible=False), + "\n".join(translation_logs), + gr.update(value="Translation complete!", visible=True), + "Translation complete!", + 100 + ) + return + + # Still couldn't find output - report failure + translation_logs.append("❌ Could not locate translated output file") + translation_logs.append(f"πŸ” Checked paths: {', '.join(possible_paths[:5])}...") + translation_logs.append("\nπŸ’‘ Troubleshooting tips:") + translation_logs.append(" 1. Check if TransateKRtoEN.py completed successfully") + translation_logs.append(" 2. Look for any error messages in the logs above") + translation_logs.append(" 3. The output might be in a subdirectory - check manually") + yield None, gr.update(value="### ⚠️ Output Not Found", visible=True), gr.update(visible=False), "\n".join(translation_logs), gr.update(value="Translation process completed but output file not found", visible=True), "Output not found", 90 + + except Exception as e: + import traceback + error_msg = f"❌ Error during translation:\n{str(e)}\n\n{traceback.format_exc()}" + translation_logs.append(error_msg) + yield None, None, gr.update(visible=False), "\n".join(translation_logs), gr.update(visible=True), "Error occurred", 0 + + def translate_epub_with_stop(self, *args): + """Wrapper for translate_epub that includes button visibility control""" + self.epub_translation_stop = False + + # Show stop button, hide translate button at start + for result in self.translate_epub(*args): + if self.epub_translation_stop: + # Translation was stopped + yield result[0], result[1], result[2], result[3] + "\n\n⚠️ Translation stopped by user", result[4], "Stopped", 0, gr.update(visible=True), gr.update(visible=False) + return + # Add button visibility updates to the yields + yield result[0], result[1], result[2], result[3], result[4], result[5], result[6], gr.update(visible=False), gr.update(visible=True) + + # Reset buttons at the end + yield result[0], result[1], result[2], result[3], result[4], result[5], result[6], gr.update(visible=True), gr.update(visible=False) + + def stop_epub_translation(self): + """Stop the ongoing EPUB translation""" + self.epub_translation_stop = True + if self.epub_translation_thread and self.epub_translation_thread.is_alive(): + # The thread will check the stop flag + pass + return gr.update(visible=True), gr.update(visible=False), "Translation stopped" + + def extract_glossary( + self, + epub_file, + model, + api_key, + min_frequency, + max_names, + max_titles=30, + max_text_size=50000, + max_sentences=200, + translation_batch=50, + chapter_split_threshold=8192, + filter_mode='all', + strip_honorifics=True, + fuzzy_threshold=0.90, + extraction_prompt=None, + format_instructions=None, + use_legacy_csv=False + ): + """Extract glossary from EPUB with manual extraction settings - yields progress updates""" + + if not epub_file: + yield None, None, None, "❌ Please upload an EPUB file", None, "Error", 0 + return + + extraction_logs = [] + + try: + import extract_glossary_from_epub + + extraction_logs.append("πŸ” Starting glossary extraction...") + yield None, None, gr.update(visible=True), "\n".join(extraction_logs), gr.update(visible=True), "Starting...", 0 + + input_path = epub_file.name if hasattr(epub_file, 'name') else epub_file + output_path = input_path.replace('.epub', '_glossary.csv') + + extraction_logs.append(f"πŸ“– Input: {os.path.basename(input_path)}") + extraction_logs.append(f"πŸ€– Model: {model}") + yield None, None, gr.update(visible=True), "\n".join(extraction_logs), gr.update(visible=True), "Initializing...", 10 + + # Set all environment variables from config + self.set_all_environment_variables() + + # Set API key + if 'gpt' in model.lower(): + os.environ['OPENAI_API_KEY'] = api_key + elif 'claude' in model.lower(): + os.environ['ANTHROPIC_API_KEY'] = api_key + else: + os.environ['API_KEY'] = api_key + + extraction_logs.append("πŸ“‹ Extracting text from EPUB...") + yield None, None, gr.update(visible=True), "\n".join(extraction_logs), gr.update(visible=True), "Extracting text...", 20 + + # Set environment variables for glossary extraction + os.environ['MODEL'] = model + os.environ['GLOSSARY_MIN_FREQUENCY'] = str(min_frequency) + os.environ['GLOSSARY_MAX_NAMES'] = str(max_names) + os.environ['GLOSSARY_MAX_TITLES'] = str(max_titles) + os.environ['GLOSSARY_BATCH_SIZE'] = str(translation_batch) + os.environ['GLOSSARY_MAX_TEXT_SIZE'] = str(max_text_size) + os.environ['GLOSSARY_MAX_SENTENCES'] = str(max_sentences) + os.environ['GLOSSARY_CHAPTER_SPLIT_THRESHOLD'] = str(chapter_split_threshold) + os.environ['GLOSSARY_FILTER_MODE'] = filter_mode + os.environ['GLOSSARY_STRIP_HONORIFICS'] = '1' if strip_honorifics else '0' + os.environ['GLOSSARY_FUZZY_THRESHOLD'] = str(fuzzy_threshold) + os.environ['GLOSSARY_USE_LEGACY_CSV'] = '1' if use_legacy_csv else '0' + + # Set prompts if provided + if extraction_prompt: + os.environ['GLOSSARY_SYSTEM_PROMPT'] = extraction_prompt + if format_instructions: + os.environ['GLOSSARY_FORMAT_INSTRUCTIONS'] = format_instructions + + extraction_logs.append(f"βš™οΈ Settings: Min freq={min_frequency}, Max names={max_names}, Filter={filter_mode}") + extraction_logs.append(f"βš™οΈ Options: Strip honorifics={strip_honorifics}, Fuzzy threshold={fuzzy_threshold:.2f}") + yield None, None, gr.update(visible=True), "\n".join(extraction_logs), gr.update(visible=True), "Processing...", 40 + + # Create a thread-safe queue for capturing logs + import queue + import threading + import time + log_queue = queue.Queue() + extraction_complete = threading.Event() + extraction_error = [None] + extraction_result = [None] + + def log_callback(msg): + """Capture log messages""" + if msg and msg.strip(): + log_queue.put(msg.strip()) + + # Run extraction in a separate thread + def run_extraction(): + try: + result = extract_glossary_from_epub.main( + log_callback=log_callback, + stop_callback=None + ) + extraction_result[0] = result + extraction_error[0] = None + except Exception as e: + extraction_error[0] = e + finally: + extraction_complete.set() + + extraction_thread = threading.Thread(target=run_extraction, daemon=True) + extraction_thread.start() + + # Monitor progress + last_yield_time = time.time() + progress_percent = 40 + + while not extraction_complete.is_set() or not log_queue.empty(): + # Check if stop was requested + if self.glossary_extraction_stop: + extraction_logs.append("⚠️ Stopping extraction...") + # Try to stop the extraction thread + extraction_complete.set() + break + + # Collect logs + new_logs = [] + while not log_queue.empty(): + try: + msg = log_queue.get_nowait() + new_logs.append(msg) + except queue.Empty: + break + + # Add new logs + if new_logs: + extraction_logs.extend(new_logs) + + # Update progress based on log content + for log in new_logs: + if 'Processing' in log or 'Extracting' in log: + progress_percent = min(progress_percent + 5, 80) + elif 'Writing' in log or 'Saving' in log: + progress_percent = min(progress_percent + 10, 90) + + # Yield updates periodically + current_time = time.time() + if new_logs or (current_time - last_yield_time) > 1.0: + status_text = new_logs[-1] if new_logs else "Processing..." + # Keep only last 100 logs + display_logs = extraction_logs[-100:] if len(extraction_logs) > 100 else extraction_logs + yield None, None, gr.update(visible=True), "\n".join(display_logs), gr.update(visible=True), status_text, progress_percent + last_yield_time = current_time + + # Small delay to avoid CPU spinning + time.sleep(0.1) + + # Wait for thread to complete + extraction_thread.join(timeout=5) + + # Check for errors + if extraction_error[0]: + error_msg = f"❌ Extraction error: {str(extraction_error[0])}" + extraction_logs.append(error_msg) + yield None, None, gr.update(visible=False), "\n".join(extraction_logs), gr.update(visible=True), error_msg, 0 + return + + extraction_logs.append("πŸ–οΈ Writing glossary to CSV...") + yield None, None, gr.update(visible=True), "\n".join(extraction_logs), gr.update(visible=True), "Writing CSV...", 95 + + if os.path.exists(output_path): + extraction_logs.append(f"βœ… Glossary extracted successfully!") + extraction_logs.append(f"πŸ’Ύ Saved to: {os.path.basename(output_path)}") + yield output_path, gr.update(visible=True), gr.update(visible=False), "\n".join(extraction_logs), gr.update(visible=True), "Extraction complete!", 100 + else: + extraction_logs.append("❌ Glossary extraction failed - output file not created") + yield None, None, gr.update(visible=False), "\n".join(extraction_logs), gr.update(visible=True), "Extraction failed", 0 + + except Exception as e: + import traceback + error_msg = f"❌ Error during extraction:\n{str(e)}\n\n{traceback.format_exc()}" + extraction_logs.append(error_msg) + yield None, None, gr.update(visible=False), "\n".join(extraction_logs), gr.update(visible=True), "Error occurred", 0 + + def extract_glossary_with_stop(self, *args): + """Wrapper for extract_glossary that includes button visibility control""" + self.glossary_extraction_stop = False + + # Show stop button, hide extract button at start + for result in self.extract_glossary(*args): + if self.glossary_extraction_stop: + # Extraction was stopped + yield result[0], result[1], result[2], result[3] + "\n\n⚠️ Extraction stopped by user", result[4], "Stopped", 0, gr.update(visible=True), gr.update(visible=False) + return + # Add button visibility updates to the yields + yield result[0], result[1], result[2], result[3], result[4], result[5], result[6], gr.update(visible=False), gr.update(visible=True) + + # Reset buttons at the end + yield result[0], result[1], result[2], result[3], result[4], result[5], result[6], gr.update(visible=True), gr.update(visible=False) + + def stop_glossary_extraction(self): + """Stop the ongoing glossary extraction""" + self.glossary_extraction_stop = True + if self.glossary_extraction_thread and self.glossary_extraction_thread.is_alive(): + # The thread will check the stop flag + pass + return gr.update(visible=True), gr.update(visible=False), "Extraction stopped" + + def run_qa_scan(self, folder_path, min_foreign_chars, check_repetition, + check_glossary_leakage, min_file_length, check_multiple_headers, + check_missing_html, check_insufficient_paragraphs, + min_paragraph_percentage, report_format, auto_save_report): + """Run Quick QA scan on output folder - yields progress updates""" + + # Handle both string paths and File objects + if hasattr(folder_path, 'name'): + # It's a File object from Gradio + folder_path = folder_path.name + + if not folder_path: + yield gr.update(visible=False), gr.update(value="### ❌ Error", visible=True), gr.update(visible=False), "❌ Please provide a folder path or upload a ZIP file", gr.update(visible=False), "Error", 0 + return + + if isinstance(folder_path, str): + folder_path = folder_path.strip() + + if not os.path.exists(folder_path): + yield gr.update(visible=False), gr.update(value=f"### ❌ File/Folder not found", visible=True), gr.update(visible=False), f"❌ File/Folder not found: {folder_path}", gr.update(visible=False), "Error", 0 + return + + # Initialize scan_logs early + scan_logs = [] + + # Check if it's a ZIP or EPUB file (for Hugging Face Spaces or convenience) + if os.path.isfile(folder_path) and (folder_path.lower().endswith('.zip') or folder_path.lower().endswith('.epub')): + # Extract ZIP/EPUB to temp folder + import zipfile + import tempfile + + temp_dir = tempfile.mkdtemp(prefix="qa_scan_") + + try: + file_type = "EPUB" if folder_path.lower().endswith('.epub') else "ZIP" + scan_logs.append(f"πŸ“¦ Extracting {file_type} file: {os.path.basename(folder_path)}") + + with zipfile.ZipFile(folder_path, 'r') as zip_ref: + # For EPUB files, look for the content folders + if file_type == "EPUB": + # EPUB files typically have OEBPS, EPUB, or similar content folders + all_files = zip_ref.namelist() + # Extract everything + zip_ref.extractall(temp_dir) + + # Try to find the content directory + content_dirs = ['OEBPS', 'EPUB', 'OPS', 'content'] + actual_content_dir = None + for dir_name in content_dirs: + potential_dir = os.path.join(temp_dir, dir_name) + if os.path.exists(potential_dir): + actual_content_dir = potential_dir + break + + # If no standard content dir found, use the temp_dir itself + if actual_content_dir: + folder_path = actual_content_dir + scan_logs.append(f"πŸ“ Found EPUB content directory: {os.path.basename(actual_content_dir)}") + else: + folder_path = temp_dir + scan_logs.append(f"πŸ“ Using extracted root directory") + else: + # Regular ZIP file + zip_ref.extractall(temp_dir) + folder_path = temp_dir + + scan_logs.append(f"βœ… Successfully extracted to temporary folder") + # Continue with normal processing, but include initial logs + # Note: we'll need to pass scan_logs through the rest of the function + + except Exception as e: + yield gr.update(visible=False), gr.update(value=f"### ❌ {file_type} extraction failed", visible=True), gr.update(visible=False), f"❌ Failed to extract {file_type}: {str(e)}", gr.update(visible=False), "Error", 0 + return + elif not os.path.isdir(folder_path): + yield gr.update(visible=False), gr.update(value=f"### ❌ Not a folder, ZIP, or EPUB", visible=True), gr.update(visible=False), f"❌ Path is not a folder, ZIP, or EPUB file: {folder_path}", gr.update(visible=False), "Error", 0 + return + + try: + scan_logs.append("πŸ” Starting Quick QA Scan...") + scan_logs.append(f"πŸ“ Scanning folder: {folder_path}") + yield gr.update(visible=False), gr.update(value="### Scanning...", visible=True), gr.update(visible=True), "\n".join(scan_logs), gr.update(visible=False), "Starting...", 0 + + # Find all HTML/XHTML files in the folder and subfolders + html_files = [] + for root, dirs, files in os.walk(folder_path): + for file in files: + if file.lower().endswith(('.html', '.xhtml', '.htm')): + html_files.append(os.path.join(root, file)) + + if not html_files: + scan_logs.append(f"⚠️ No HTML/XHTML files found in {folder_path}") + yield gr.update(visible=False), gr.update(value="### ⚠️ No files found", visible=True), gr.update(visible=False), "\n".join(scan_logs), gr.update(visible=False), "No files to scan", 0 + return + + scan_logs.append(f"πŸ“„ Found {len(html_files)} HTML/XHTML files to scan") + scan_logs.append("⚑ Quick Scan Mode (85% threshold, Speed optimized)") + yield gr.update(visible=False), gr.update(value="### Initializing...", visible=True), gr.update(visible=True), "\n".join(scan_logs), gr.update(visible=False), "Initializing...", 10 + + # QA scanning process + total_files = len(html_files) + issues_found = [] + chapters_scanned = set() + + for i, file_path in enumerate(html_files): + if self.qa_scan_stop: + scan_logs.append("⚠️ Scan stopped by user") + break + + # Get relative path from base folder for cleaner display + rel_path = os.path.relpath(file_path, folder_path) + file_name = rel_path.replace('\\', '/') + + # Quick scan optimization: skip if we've already scanned similar chapters + # (consecutive chapter checking) + chapter_match = None + for pattern in ['chapter', 'ch', 'c']: + if pattern in file_name.lower(): + import re + match = re.search(r'(\d+)', file_name) + if match: + chapter_num = int(match.group(1)) + # Skip if we've already scanned nearby chapters (Quick Scan optimization) + if any(abs(chapter_num - ch) <= 1 for ch in chapters_scanned): + if len(chapters_scanned) > 5: # Only skip after scanning a few + continue + chapters_scanned.add(chapter_num) + break + + scan_logs.append(f"\nπŸ” Scanning: {file_name}") + progress = int(10 + (80 * i / total_files)) + yield None, None, gr.update(visible=True), "\n".join(scan_logs), gr.update(visible=True), f"Scanning {file_name}...", progress + + # Read and check the HTML file + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + file_issues = [] + + # Check file length + if len(content) < min_file_length: + continue # Skip short files + + # Check for foreign characters (simulation - would need actual implementation) + # In real implementation, would check for source language characters + import random + + # Check for multiple headers + if check_multiple_headers: + import re + headers = re.findall(r'<h[1-6][^>]*>', content, re.IGNORECASE) + if len(headers) >= 2: + file_issues.append("Multiple headers detected") + + # Check for missing html tag + if check_missing_html: + if '<html' not in content.lower(): + file_issues.append("Missing <html> tag") + + # Check for insufficient paragraphs + if check_insufficient_paragraphs: + p_tags = content.count('<p>') + content.count('<p ') + text_length = len(re.sub(r'<[^>]+>', '', content)) + if text_length > 0: + p_text = re.findall(r'<p[^>]*>(.*?)</p>', content, re.DOTALL) + p_text_length = sum(len(t) for t in p_text) + percentage = (p_text_length / text_length) * 100 + if percentage < min_paragraph_percentage: + file_issues.append(f"Only {percentage:.1f}% text in <p> tags") + + # Simulated additional checks + if check_repetition and random.random() > 0.85: + file_issues.append("Excessive repetition detected") + + if check_glossary_leakage and random.random() > 0.9: + file_issues.append("Glossary leakage detected") + + # Report issues found + if file_issues: + for issue in file_issues: + issues_found.append(f" ⚠️ {file_name}: {issue}") + scan_logs.append(f" ⚠️ Issue: {issue}") + else: + scan_logs.append(f" βœ… No issues found") + + except Exception as e: + scan_logs.append(f" ❌ Error reading file: {str(e)}") + + # Update logs periodically + if len(scan_logs) > 100: + scan_logs = scan_logs[-100:] # Keep only last 100 logs + + yield gr.update(visible=False), None, gr.update(visible=True), "\n".join(scan_logs), gr.update(visible=False), f"Scanning {file_name}...", progress + + # Generate report + scan_logs.append("\nπŸ“ Generating report...") + yield gr.update(visible=False), None, gr.update(visible=True), "\n".join(scan_logs), gr.update(visible=False), "Generating report...", 95 + + # Create report content based on selected format + if report_format == "summary": + # Summary format - brief overview only + report_content = "QA SCAN REPORT - SUMMARY\n" + report_content += "=" * 50 + "\n\n" + report_content += f"Total files scanned: {total_files}\n" + report_content += f"Issues found: {len(issues_found)}\n\n" + if issues_found: + report_content += f"Files with issues: {min(len(issues_found), 10)} (showing first 10)\n" + report_content += "\n".join(issues_found[:10]) + else: + report_content += "βœ… No issues detected." + + elif report_format == "verbose": + # Verbose format - all data including passed files + report_content = "QA SCAN REPORT - VERBOSE (ALL DATA)\n" + report_content += "=" * 50 + "\n\n" + from datetime import datetime + report_content += f"Scan Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" + report_content += f"Folder Scanned: {folder_path}\n" + report_content += f"Total files scanned: {total_files}\n" + report_content += f"Issues found: {len(issues_found)}\n" + report_content += f"Settings used:\n" + report_content += f" - Min foreign chars: {min_foreign_chars}\n" + report_content += f" - Check repetition: {check_repetition}\n" + report_content += f" - Check glossary leakage: {check_glossary_leakage}\n" + report_content += f" - Min file length: {min_file_length}\n" + report_content += f" - Check multiple headers: {check_multiple_headers}\n" + report_content += f" - Check missing HTML: {check_missing_html}\n" + report_content += f" - Check insufficient paragraphs: {check_insufficient_paragraphs}\n" + report_content += f" - Min paragraph percentage: {min_paragraph_percentage}%\n\n" + + report_content += "ALL FILES PROCESSED:\n" + report_content += "-" * 30 + "\n" + for file in html_files: + rel_path = os.path.relpath(file, folder_path) + report_content += f" {rel_path}\n" + + if issues_found: + report_content += "\n\nISSUES DETECTED (DETAILED):\n" + report_content += "\n".join(issues_found) + else: + report_content += "\n\nβœ… No issues detected. All files passed scan." + + else: # detailed (default/recommended) + # Detailed format - recommended balance + report_content = "QA SCAN REPORT - DETAILED\n" + report_content += "=" * 50 + "\n\n" + report_content += f"Total files scanned: {total_files}\n" + report_content += f"Issues found: {len(issues_found)}\n\n" + + if issues_found: + report_content += "ISSUES DETECTED:\n" + report_content += "\n".join(issues_found) + else: + report_content += "No issues detected. All files passed quick scan." + + # Always save report to file for download + from datetime import datetime + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + report_filename = f"qa_scan_report_{timestamp}.txt" + report_path = os.path.join(os.getcwd(), report_filename) + + # Always write the report file + with open(report_path, 'w', encoding='utf-8') as f: + f.write(report_content) + + if auto_save_report: + scan_logs.append(f"πŸ’Ύ Report auto-saved to: {report_filename}") + else: + scan_logs.append(f"πŸ“„ Report ready for download: {report_filename}") + + scan_logs.append(f"\nβœ… QA Scan completed!") + scan_logs.append(f"πŸ“Š Summary: {total_files} files scanned, {len(issues_found)} issues found") + scan_logs.append(f"\nπŸ“₯ Click 'Download QA Report' below to save the report") + + # Always return the report path and make File component visible + final_status = f"βœ… Scan complete!\n{total_files} files scanned\n{len(issues_found)} issues found" + yield gr.update(value=report_path, visible=True), gr.update(value=f"### {final_status}", visible=True), gr.update(visible=False), "\n".join(scan_logs), gr.update(value=final_status, visible=True), "Scan complete!", 100 + + except Exception as e: + import traceback + error_msg = f"❌ Error during QA scan:\n{str(e)}\n\n{traceback.format_exc()}" + scan_logs.append(error_msg) + yield gr.update(visible=False), gr.update(value="### ❌ Error occurred", visible=True), gr.update(visible=False), "\n".join(scan_logs), gr.update(visible=True), "Error occurred", 0 + + def run_qa_scan_with_stop(self, *args): + """Wrapper for run_qa_scan that includes button visibility control""" + self.qa_scan_stop = False + + # Show stop button, hide scan button at start + for result in self.run_qa_scan(*args): + if self.qa_scan_stop: + # Scan was stopped + yield result[0], result[1], result[2], result[3] + "\n\n⚠️ Scan stopped by user", result[4], "Stopped", 0, gr.update(visible=True), gr.update(visible=False) + return + # Add button visibility updates to the yields + yield result[0], result[1], result[2], result[3], result[4], result[5], result[6], gr.update(visible=False), gr.update(visible=True) + + # Reset buttons at the end + yield result[0], result[1], result[2], result[3], result[4], result[5], result[6], gr.update(visible=True), gr.update(visible=False) + + def stop_qa_scan(self): + """Stop the ongoing QA scan""" + self.qa_scan_stop = True + return gr.update(visible=True), gr.update(visible=False), "Scan stopped" + + def stop_translation(self): + """Stop the ongoing translation process""" + print(f"DEBUG: stop_translation called, was_translating={self.is_translating}") + if self.is_translating: + print("DEBUG: Setting stop flag and cancellation") + self.stop_flag.set() + self.is_translating = False + + # Best-effort: cancel any in-flight API operation on the active client + try: + if getattr(self, 'current_unified_client', None): + self.current_unified_client.cancel_current_operation() + print("DEBUG: Requested UnifiedClient cancellation") + except Exception as e: + print(f"DEBUG: UnifiedClient cancel failed: {e}") + + # Also propagate to MangaTranslator class if available + try: + if MANGA_TRANSLATION_AVAILABLE: + from manga_translator import MangaTranslator + MangaTranslator.set_global_cancellation(True) + print("DEBUG: Set MangaTranslator global cancellation") + except ImportError: + pass + + # Also propagate to UnifiedClient if available + try: + if MANGA_TRANSLATION_AVAILABLE: + from unified_api_client import UnifiedClient + UnifiedClient.set_global_cancellation(True) + print("DEBUG: Set UnifiedClient global cancellation") + except ImportError: + pass + + # Kick off translator shutdown to free resources quickly + try: + tr = getattr(self, 'current_translator', None) + if tr and hasattr(tr, 'shutdown'): + import threading as _th + _th.Thread(target=tr.shutdown, name="WebMangaTranslatorShutdown", daemon=True).start() + print("DEBUG: Initiated translator shutdown thread") + # Clear reference so a new start creates a fresh instance + self.current_translator = None + except Exception as e: + print(f"DEBUG: Failed to start translator shutdown: {e}") + else: + print("DEBUG: stop_translation called but not translating") + + def _reset_translation_flags(self): + """Reset all translation flags for new translation""" + self.is_translating = False + self.stop_flag.clear() + + # Reset global cancellation flags + try: + if MANGA_TRANSLATION_AVAILABLE: + from manga_translator import MangaTranslator + MangaTranslator.set_global_cancellation(False) + except ImportError: + pass + + try: + if MANGA_TRANSLATION_AVAILABLE: + from unified_api_client import UnifiedClient + UnifiedClient.set_global_cancellation(False) + except ImportError: + pass + + def translate_manga( + self, + image_files, + model, + api_key, + profile_name, + system_prompt, + ocr_provider, + google_creds_path, + azure_key, + azure_endpoint, + enable_bubble_detection, + enable_inpainting, + font_size_mode, + font_size, + font_multiplier, + min_font_size, + max_font_size, + text_color, + shadow_enabled, + shadow_color, + shadow_offset_x, + shadow_offset_y, + shadow_blur, + bg_opacity, + bg_style, + parallel_panel_translation=False, + panel_max_workers=10 + ): + """Translate manga images - GENERATOR that yields (logs, image, cbz_file, status, progress_group, progress_text, progress_bar) updates""" + + # Reset translation flags and set running state + self._reset_translation_flags() + self.is_translating = True + + if not MANGA_TRANSLATION_AVAILABLE: + self.is_translating = False + yield "❌ Manga translation modules not loaded", None, None, gr.update(value="❌ Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0) + return + + if not image_files: + self.is_translating = False + yield "❌ Please upload at least one image", gr.update(visible=False), gr.update(visible=False), gr.update(value="❌ Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0) + return + + if not api_key: + self.is_translating = False + yield "❌ Please provide an API key", gr.update(visible=False), gr.update(visible=False), gr.update(value="❌ Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0) + return + + # Check for stop request + if self.stop_flag.is_set(): + self.is_translating = False + yield "⏹️ Translation stopped by user", gr.update(visible=False), gr.update(visible=False), gr.update(value="⏹️ Stopped", visible=True), gr.update(visible=False), gr.update(value="Stopped"), gr.update(value=0) + return + + if ocr_provider == "google": + # Check if credentials are provided or saved in config + if not google_creds_path and not self.get_config_value('google_vision_credentials'): + yield "❌ Please provide Google Cloud credentials JSON file", gr.update(visible=False), gr.update(visible=False), gr.update(value="❌ Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0) + return + + if ocr_provider == "azure": + # Ensure azure credentials are strings + azure_key_str = str(azure_key) if azure_key else '' + azure_endpoint_str = str(azure_endpoint) if azure_endpoint else '' + if not azure_key_str.strip() or not azure_endpoint_str.strip(): + yield "❌ Please provide Azure API key and endpoint", gr.update(visible=False), gr.update(visible=False), gr.update(value="❌ Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0) + return + + try: + + # Set all environment variables from config + self.set_all_environment_variables() + + # Set API key environment variable + if 'gpt' in model.lower() or 'openai' in model.lower(): + os.environ['OPENAI_API_KEY'] = api_key + elif 'claude' in model.lower(): + os.environ['ANTHROPIC_API_KEY'] = api_key + elif 'gemini' in model.lower(): + os.environ['GOOGLE_API_KEY'] = api_key + + # Set Google Cloud credentials if provided and save to config + if ocr_provider == "google": + if google_creds_path: + # New file provided - save it + creds_path = google_creds_path.name if hasattr(google_creds_path, 'name') else google_creds_path + os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = creds_path + # Auto-save to config + self.config['google_vision_credentials'] = creds_path + self.save_config(self.config) + elif self.get_config_value('google_vision_credentials'): + # Use saved credentials from config + creds_path = self.get_config_value('google_vision_credentials') + if os.path.exists(creds_path): + os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = creds_path + else: + yield f"❌ Saved Google credentials not found: {creds_path}", gr.update(visible=False), gr.update(visible=False), gr.update(value="❌ Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0) + return + + # Set Azure credentials if provided and save to config + if ocr_provider == "azure": + # Convert to strings and strip whitespace + azure_key_str = str(azure_key).strip() if azure_key else '' + azure_endpoint_str = str(azure_endpoint).strip() if azure_endpoint else '' + + os.environ['AZURE_VISION_KEY'] = azure_key_str + os.environ['AZURE_VISION_ENDPOINT'] = azure_endpoint_str + # Auto-save to config + self.config['azure_vision_key'] = azure_key_str + self.config['azure_vision_endpoint'] = azure_endpoint_str + self.save_config(self.config) + + # Apply text visibility settings to config + # Convert hex color to RGB tuple + def hex_to_rgb(hex_color): + # Handle different color formats + if isinstance(hex_color, (list, tuple)): + # Already RGB format + return tuple(hex_color[:3]) + elif isinstance(hex_color, str): + # Remove any brackets or spaces if present + hex_color = hex_color.strip().strip('[]').strip() + if hex_color.startswith('#'): + # Hex format + hex_color = hex_color.lstrip('#') + if len(hex_color) == 6: + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + elif len(hex_color) == 3: + # Short hex format like #FFF + return tuple(int(hex_color[i]*2, 16) for i in range(3)) + elif ',' in hex_color: + # RGB string format like "255, 0, 0" + try: + parts = hex_color.split(',') + return tuple(int(p.strip()) for p in parts[:3]) + except: + pass + # Default to black if parsing fails + return (0, 0, 0) + + # Debug logging for color values + print(f"DEBUG: text_color type: {type(text_color)}, value: {text_color}") + print(f"DEBUG: shadow_color type: {type(shadow_color)}, value: {shadow_color}") + + try: + text_rgb = hex_to_rgb(text_color) + shadow_rgb = hex_to_rgb(shadow_color) + except Exception as e: + print(f"WARNING: Error converting colors: {e}") + print(f"WARNING: Using default colors - text: black, shadow: white") + text_rgb = (0, 0, 0) # Default to black text + shadow_rgb = (255, 255, 255) # Default to white shadow + + self.config['manga_font_size_mode'] = font_size_mode + self.config['manga_font_size'] = int(font_size) + self.config['manga_font_size_multiplier'] = float(font_multiplier) + self.config['manga_max_font_size'] = int(max_font_size) + self.config['manga_text_color'] = list(text_rgb) + self.config['manga_shadow_enabled'] = bool(shadow_enabled) + self.config['manga_shadow_color'] = list(shadow_rgb) + self.config['manga_shadow_offset_x'] = int(shadow_offset_x) + self.config['manga_shadow_offset_y'] = int(shadow_offset_y) + self.config['manga_shadow_blur'] = int(shadow_blur) + self.config['manga_bg_opacity'] = int(bg_opacity) + self.config['manga_bg_style'] = bg_style + + # Also update nested manga_settings structure + if 'manga_settings' not in self.config: + self.config['manga_settings'] = {} + if 'rendering' not in self.config['manga_settings']: + self.config['manga_settings']['rendering'] = {} + if 'font_sizing' not in self.config['manga_settings']: + self.config['manga_settings']['font_sizing'] = {} + + self.config['manga_settings']['rendering']['auto_min_size'] = int(min_font_size) + self.config['manga_settings']['font_sizing']['min_size'] = int(min_font_size) + self.config['manga_settings']['rendering']['auto_max_size'] = int(max_font_size) + self.config['manga_settings']['font_sizing']['max_size'] = int(max_font_size) + + # Prepare output directory + output_dir = tempfile.mkdtemp(prefix="manga_translated_") + translated_files = [] + cbz_mode = False + cbz_output_path = None + + # Initialize translation logs early (needed for CBZ processing) + translation_logs = [] + + # Check if any file is a CBZ/ZIP archive + import zipfile + files_to_process = image_files if isinstance(image_files, list) else [image_files] + extracted_images = [] + + for file in files_to_process: + file_path = file.name if hasattr(file, 'name') else file + if file_path.lower().endswith(('.cbz', '.zip')): + # Extract CBZ + cbz_mode = True + translation_logs.append(f"πŸ“š Extracting CBZ: {os.path.basename(file_path)}") + extract_dir = tempfile.mkdtemp(prefix="cbz_extract_") + + try: + with zipfile.ZipFile(file_path, 'r') as zip_ref: + zip_ref.extractall(extract_dir) + + # Find all image files in extracted directory + import glob + for ext in ['*.png', '*.jpg', '*.jpeg', '*.webp', '*.bmp', '*.gif']: + extracted_images.extend(glob.glob(os.path.join(extract_dir, '**', ext), recursive=True)) + + # Sort naturally (by filename) + extracted_images.sort() + translation_logs.append(f"βœ… Extracted {len(extracted_images)} images from CBZ") + + # Prepare CBZ output path + cbz_output_path = os.path.join(output_dir, f"{os.path.splitext(os.path.basename(file_path))[0]}_translated.cbz") + except Exception as e: + translation_logs.append(f"❌ Error extracting CBZ: {str(e)}") + else: + # Regular image file + extracted_images.append(file_path) + + # Use extracted images if CBZ was processed, otherwise use original files + if extracted_images: + # Create mock file objects for extracted images + class MockFile: + def __init__(self, path): + self.name = path + + files_to_process = [MockFile(img) for img in extracted_images] + + total_images = len(files_to_process) + + # Merge web app config with SimpleConfig for MangaTranslator + # This includes all the text visibility settings we just set + merged_config = self.config.copy() + + # Override with web-specific settings + merged_config['model'] = model + merged_config['active_profile'] = profile_name + + # Update manga_settings + if 'manga_settings' not in merged_config: + merged_config['manga_settings'] = {} + if 'ocr' not in merged_config['manga_settings']: + merged_config['manga_settings']['ocr'] = {} + if 'inpainting' not in merged_config['manga_settings']: + merged_config['manga_settings']['inpainting'] = {} + if 'advanced' not in merged_config['manga_settings']: + merged_config['manga_settings']['advanced'] = {} + + merged_config['manga_settings']['ocr']['provider'] = ocr_provider + merged_config['manga_settings']['ocr']['bubble_detection_enabled'] = enable_bubble_detection + merged_config['manga_settings']['inpainting']['method'] = 'local' if enable_inpainting else 'none' + # Make sure local_method is set from config (defaults to anime) + if 'local_method' not in merged_config['manga_settings']['inpainting']: + merged_config['manga_settings']['inpainting']['local_method'] = self.get_config_value('manga_settings', {}).get('inpainting', {}).get('local_method', 'anime') + + # Set parallel panel translation settings from config (Manga Settings tab) + # These are controlled in the Manga Settings tab, so reload config to get latest values + current_config = self.load_config() + if API_KEY_ENCRYPTION_AVAILABLE: + current_config = decrypt_config(current_config) + + config_parallel = current_config.get('manga_settings', {}).get('advanced', {}).get('parallel_panel_translation', False) + config_max_workers = current_config.get('manga_settings', {}).get('advanced', {}).get('panel_max_workers', 10) + + # Map web UI settings to MangaTranslator expected names + merged_config['manga_settings']['advanced']['parallel_panel_translation'] = config_parallel + merged_config['manga_settings']['advanced']['panel_max_workers'] = int(config_max_workers) + # CRITICAL: Also set the setting names that MangaTranslator actually checks + merged_config['manga_settings']['advanced']['parallel_processing'] = config_parallel + merged_config['manga_settings']['advanced']['max_workers'] = int(config_max_workers) + + # Log the parallel settings being used + print(f"πŸ”§ Reloaded config - Using parallel panel translation: {config_parallel}") + print(f"πŸ”§ Reloaded config - Using panel max workers: {config_max_workers}") + + # CRITICAL: Set skip_inpainting flag to False when inpainting is enabled + merged_config['manga_skip_inpainting'] = not enable_inpainting + + # Create a simple config object for MangaTranslator + class SimpleConfig: + def __init__(self, cfg): + self.config = cfg + + def get(self, key, default=None): + return self.config.get(key, default) + + # Create mock GUI object with necessary attributes + class MockGUI: + def __init__(self, config, profile_name, system_prompt, max_output_tokens, api_key, model): + self.config = config + # Add profile_var mock for MangaTranslator compatibility + class ProfileVar: + def __init__(self, profile): + self.profile = str(profile) if profile else '' + def get(self): + return self.profile + self.profile_var = ProfileVar(profile_name) + # Add prompt_profiles BOTH to config AND as attribute (manga_translator checks both) + if 'prompt_profiles' not in self.config: + self.config['prompt_profiles'] = {} + self.config['prompt_profiles'][profile_name] = system_prompt + # Also set as direct attribute for line 4653 check + self.prompt_profiles = self.config['prompt_profiles'] + # Add max_output_tokens as direct attribute (line 299 check) + self.max_output_tokens = max_output_tokens + # Add mock GUI attributes that MangaTranslator expects + class MockVar: + def __init__(self, val): + # Ensure val is properly typed + self.val = val + def get(self): + return self.val + # CRITICAL: delay_entry must read from api_call_delay (not 'delay') + self.delay_entry = MockVar(float(config.get('api_call_delay', 0.5))) + self.trans_temp = MockVar(float(config.get('translation_temperature', 0.3))) + self.contextual_var = MockVar(bool(config.get('contextual', False))) + self.trans_history = MockVar(int(config.get('translation_history_limit', 2))) + self.translation_history_rolling_var = MockVar(bool(config.get('translation_history_rolling', False))) + self.token_limit_disabled = bool(config.get('token_limit_disabled', False)) + # IMPORTANT: token_limit_entry must return STRING because manga_translator calls .strip() on it + self.token_limit_entry = MockVar(str(config.get('token_limit', 200000))) + # Batch translation settings + self.batch_translation_var = MockVar(bool(config.get('batch_translation', True))) + self.batch_size_var = MockVar(str(config.get('batch_size', '10'))) + # Add API key and model for custom-api OCR provider - ensure strings + self.api_key_entry = MockVar(str(api_key) if api_key else '') + self.model_var = MockVar(str(model) if model else '') + + simple_config = SimpleConfig(merged_config) + # Get max_output_tokens from config or use from web app config + web_max_tokens = merged_config.get('max_output_tokens', 16000) + mock_gui = MockGUI(simple_config.config, profile_name, system_prompt, web_max_tokens, api_key, model) + + # CRITICAL: Set SYSTEM_PROMPT environment variable for manga translation + os.environ['SYSTEM_PROMPT'] = system_prompt if system_prompt else '' + if system_prompt: + print(f"βœ… System prompt set ({len(system_prompt)} characters)") + else: + print("⚠️ No system prompt provided") + + # CRITICAL: Set batch environment variables from mock_gui variables + os.environ['BATCH_TRANSLATION'] = '1' if mock_gui.batch_translation_var.get() else '0' + os.environ['BATCH_SIZE'] = str(mock_gui.batch_size_var.get()) + print(f"πŸ“¦ Set BATCH_TRANSLATION={os.environ['BATCH_TRANSLATION']}, BATCH_SIZE={os.environ['BATCH_SIZE']}") + + # Ensure model path is in config for local inpainting + if enable_inpainting: + local_method = merged_config.get('manga_settings', {}).get('inpainting', {}).get('local_method', 'anime') + # Set the model path key that MangaTranslator expects + model_path_key = f'manga_{local_method}_model_path' + if model_path_key not in merged_config: + # Use default model path or empty string + default_model_path = self.get_config_value(model_path_key, '') + merged_config[model_path_key] = default_model_path + print(f"Set {model_path_key} to: {default_model_path}") + + # CRITICAL: Explicitly set environment variables before creating UnifiedClient + api_call_delay = merged_config.get('api_call_delay', 0.5) + os.environ['SEND_INTERVAL_SECONDS'] = str(api_call_delay) + print(f"πŸ”§ Manga translation: Set SEND_INTERVAL_SECONDS = {api_call_delay}s") + + # Set batch translation and batch size from MockGUI variables (after MockGUI is created) + # Will be set after mock_gui is created below + + # Also ensure font algorithm and auto fit style are in config for manga_translator + if 'manga_settings' not in merged_config: + merged_config['manga_settings'] = {} + if 'font_sizing' not in merged_config['manga_settings']: + merged_config['manga_settings']['font_sizing'] = {} + if 'rendering' not in merged_config['manga_settings']: + merged_config['manga_settings']['rendering'] = {} + + if 'algorithm' not in merged_config['manga_settings']['font_sizing']: + merged_config['manga_settings']['font_sizing']['algorithm'] = 'smart' + if 'auto_fit_style' not in merged_config['manga_settings']['rendering']: + merged_config['manga_settings']['rendering']['auto_fit_style'] = 'balanced' + + print(f"πŸ“¦ Batch: BATCH_TRANSLATION={os.environ.get('BATCH_TRANSLATION')}, BATCH_SIZE={os.environ.get('BATCH_SIZE')}") + print(f"🎨 Font: algorithm={merged_config['manga_settings']['font_sizing']['algorithm']}, auto_fit_style={merged_config['manga_settings']['rendering']['auto_fit_style']}") + + # Setup OCR configuration + ocr_config = { + 'provider': ocr_provider + } + + if ocr_provider == 'google': + ocr_config['google_credentials_path'] = google_creds_path.name if google_creds_path else None + elif ocr_provider == 'azure': + # Use string versions + azure_key_str = str(azure_key).strip() if azure_key else '' + azure_endpoint_str = str(azure_endpoint).strip() if azure_endpoint else '' + ocr_config['azure_key'] = azure_key_str + ocr_config['azure_endpoint'] = azure_endpoint_str + + # Create UnifiedClient for translation API calls + try: + unified_client = UnifiedClient( + api_key=api_key, + model=model, + output_dir=output_dir + ) + # Store reference for stop() cancellation support + self.current_unified_client = unified_client + except Exception as e: + error_log = f"❌ Failed to initialize API client: {str(e)}" + yield error_log, gr.update(visible=False), gr.update(visible=False), gr.update(value=error_log, visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0) + return + + # Log storage - will be yielded as live updates + last_yield_log_count = [0] # Track when we last yielded + last_yield_time = [0] # Track last yield time + + # Track current image being processed + current_image_idx = [0] + + import time + + def should_yield_logs(): + """Check if we should yield log updates (every 2 logs or 1 second)""" + current_time = time.time() + log_count_diff = len(translation_logs) - last_yield_log_count[0] + time_diff = current_time - last_yield_time[0] + + # Yield if 2+ new logs OR 1+ seconds passed + return log_count_diff >= 2 or time_diff >= 1.0 + + def capture_log(msg, level="info"): + """Capture logs - caller will yield periodically""" + if msg and msg.strip(): + log_msg = msg.strip() + translation_logs.append(log_msg) + + # Initialize timing + last_yield_time[0] = time.time() + + # Create MangaTranslator instance + try: + # Debug: Log inpainting config + inpaint_cfg = merged_config.get('manga_settings', {}).get('inpainting', {}) + print(f"\n=== INPAINTING CONFIG DEBUG ===") + print(f"Inpainting enabled checkbox: {enable_inpainting}") + print(f"Inpainting method: {inpaint_cfg.get('method')}") + print(f"Local method: {inpaint_cfg.get('local_method')}") + print(f"Full inpainting config: {inpaint_cfg}") + print("=== END DEBUG ===\n") + + translator = MangaTranslator( + ocr_config=ocr_config, + unified_client=unified_client, + main_gui=mock_gui, + log_callback=capture_log + ) + + # Keep a reference for stop/shutdown support + self.current_translator = translator + + # Connect stop flag so translator can react immediately to stop requests + if hasattr(translator, 'set_stop_flag'): + try: + translator.set_stop_flag(self.stop_flag) + except Exception: + pass + + # CRITICAL: Set skip_inpainting flag directly on translator instance + translator.skip_inpainting = not enable_inpainting + print(f"Set translator.skip_inpainting = {translator.skip_inpainting}") + + # Explicitly initialize local inpainting if enabled + if enable_inpainting: + print(f"🎨 Initializing local inpainting...") + try: + # Force initialization of the inpainter + init_result = translator._initialize_local_inpainter() + if init_result: + print(f"βœ… Local inpainter initialized successfully") + else: + print(f"⚠️ Local inpainter initialization returned False") + except Exception as init_error: + print(f"❌ Failed to initialize inpainter: {init_error}") + import traceback + traceback.print_exc() + + except Exception as e: + import traceback + full_error = traceback.format_exc() + print(f"\n\n=== MANGA TRANSLATOR INIT ERROR ===") + print(full_error) + print(f"\nocr_config: {ocr_config}") + print(f"\nmock_gui.model_var.get(): {mock_gui.model_var.get()}") + print(f"\nmock_gui.api_key_entry.get(): {type(mock_gui.api_key_entry.get())}") + print("=== END ERROR ===") + error_log = f"❌ Failed to initialize manga translator: {str(e)}\n\nCheck console for full traceback" + yield error_log, gr.update(visible=False), gr.update(visible=False), gr.update(value=error_log, visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0) + return + + # Process each image with real progress tracking + for idx, img_file in enumerate(files_to_process, 1): + try: + # Check for stop request before processing each image + if self.stop_flag.is_set(): + translation_logs.append(f"\n⏹️ Translation stopped by user before image {idx}/{total_images}") + self.is_translating = False + yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(value="⏹️ Translation stopped", visible=True), gr.update(visible=True), gr.update(value="Stopped"), gr.update(value=0) + return + + # Update current image index for log capture + current_image_idx[0] = idx + + # Calculate progress range for this image + start_progress = (idx - 1) / total_images + end_progress = idx / total_images + + input_path = img_file.name if hasattr(img_file, 'name') else img_file + output_path = os.path.join(output_dir, f"translated_{os.path.basename(input_path)}") + filename = os.path.basename(input_path) + + # Log start of processing and YIELD update + start_msg = f"🎨 [{idx}/{total_images}] Starting: {filename}" + translation_logs.append(start_msg) + translation_logs.append(f"Image path: {input_path}") + translation_logs.append(f"Processing with OCR: {ocr_provider}, Model: {model}") + translation_logs.append("-" * 60) + + # Yield initial log update with progress + progress_percent = int(((idx - 1) / total_images) * 100) + status_text = f"Processing {idx}/{total_images}: {filename}" + last_yield_log_count[0] = len(translation_logs) + last_yield_time[0] = time.time() + yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value=status_text), gr.update(value=progress_percent) + + # Start processing in a thread so we can yield logs periodically + import threading + processing_complete = [False] + result_container = [None] + + def process_wrapper(): + result_container[0] = translator.process_image( + image_path=input_path, + output_path=output_path, + batch_index=idx, + batch_total=total_images + ) + processing_complete[0] = True + + # Start processing in background + process_thread = threading.Thread(target=process_wrapper, daemon=True) + process_thread.start() + + # Poll for log updates while processing + while not processing_complete[0]: + time.sleep(0.5) # Check every 0.5 seconds + + # Check for stop request during processing + if self.stop_flag.is_set(): + translation_logs.append(f"\n⏹️ Translation stopped by user while processing image {idx}/{total_images}") + self.is_translating = False + yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(value="⏹️ Translation stopped", visible=True), gr.update(visible=True), gr.update(value="Stopped"), gr.update(value=0) + return + + if should_yield_logs(): + progress_percent = int(((idx - 0.5) / total_images) * 100) # Mid-processing + status_text = f"Processing {idx}/{total_images}: {filename} (in progress...)" + last_yield_log_count[0] = len(translation_logs) + last_yield_time[0] = time.time() + yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value=status_text), gr.update(value=progress_percent) + + # Wait for thread to complete + process_thread.join(timeout=1) + result = result_container[0] + + if result.get('success'): + # Use the output path from the result + final_output = result.get('output_path', output_path) + if os.path.exists(final_output): + translated_files.append(final_output) + translation_logs.append(f"βœ… Image {idx}/{total_images} COMPLETE: {filename} | Total: {len(translated_files)}/{total_images} done") + translation_logs.append("") + # Yield progress update with all translated images so far + progress_percent = int((idx / total_images) * 100) + status_text = f"Completed {idx}/{total_images}: {filename}" + # Show all translated files as gallery + yield "\n".join(translation_logs), gr.update(value=translated_files, visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value=status_text), gr.update(value=progress_percent) + else: + translation_logs.append(f"⚠️ Image {idx}/{total_images}: Output file missing for {filename}") + translation_logs.append(f"⚠️ Warning: Output file not found for image {idx}") + translation_logs.append("") + # Yield progress update + progress_percent = int((idx / total_images) * 100) + status_text = f"Warning: {idx}/{total_images} - Output missing for {filename}" + yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value=status_text), gr.update(value=progress_percent) + else: + errors = result.get('errors', []) + error_msg = errors[0] if errors else 'Unknown error' + translation_logs.append(f"❌ Image {idx}/{total_images} FAILED: {error_msg[:50]}") + translation_logs.append(f"⚠️ Error on image {idx}: {error_msg}") + translation_logs.append("") + # Yield progress update + progress_percent = int((idx / total_images) * 100) + status_text = f"Failed: {idx}/{total_images} - {filename}" + yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value=status_text), gr.update(value=progress_percent) + + # If translation failed, save original with error overlay + from PIL import Image as PILImage, ImageDraw, ImageFont + img = PILImage.open(input_path) + draw = ImageDraw.Draw(img) + # Add error message + draw.text((10, 10), f"Translation Error: {error_msg[:50]}", fill="red") + img.save(output_path) + translated_files.append(output_path) + + except Exception as e: + import traceback + error_trace = traceback.format_exc() + translation_logs.append(f"❌ Image {idx}/{total_images} ERROR: {str(e)[:60]}") + translation_logs.append(f"❌ Exception on image {idx}: {str(e)}") + print(f"Manga translation error for {input_path}:\n{error_trace}") + + # Save original on error + try: + from PIL import Image as PILImage + img = PILImage.open(input_path) + img.save(output_path) + translated_files.append(output_path) + except: + pass + continue + + # Check for stop request before final processing + if self.stop_flag.is_set(): + translation_logs.append("\n⏹️ Translation stopped by user") + self.is_translating = False + yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(value="⏹️ Translation stopped", visible=True), gr.update(visible=True), gr.update(value="Stopped"), gr.update(value=0) + return + + # Add completion message + translation_logs.append("\n" + "="*60) + translation_logs.append(f"βœ… ALL COMPLETE! Successfully translated {len(translated_files)}/{total_images} images") + translation_logs.append("="*60) + + # If CBZ mode, compile translated images into CBZ archive + final_output_for_display = None + if cbz_mode and cbz_output_path and translated_files: + translation_logs.append("\nπŸ“¦ Compiling translated images into CBZ archive...") + try: + with zipfile.ZipFile(cbz_output_path, 'w', zipfile.ZIP_DEFLATED) as cbz: + for img_path in translated_files: + # Preserve original filename structure + arcname = os.path.basename(img_path).replace("translated_", "") + cbz.write(img_path, arcname) + + translation_logs.append(f"βœ… CBZ archive created: {os.path.basename(cbz_output_path)}") + translation_logs.append(f"πŸ“ Archive location: {cbz_output_path}") + final_output_for_display = cbz_output_path + except Exception as e: + translation_logs.append(f"❌ Error creating CBZ: {str(e)}") + + # Build final status with detailed panel information + final_status_lines = [] + if translated_files: + final_status_lines.append(f"βœ… Successfully translated {len(translated_files)}/{total_images} image(s)!") + final_status_lines.append("") + final_status_lines.append("πŸ–ΌοΈ **Translated Panels:**") + for i, file_path in enumerate(translated_files, 1): + filename = os.path.basename(file_path) + final_status_lines.append(f" {i}. {filename}") + + final_status_lines.append("") + final_status_lines.append("πŸ”„ **Download Options:**") + if cbz_mode and cbz_output_path: + final_status_lines.append(f" πŸ“¦ CBZ Archive: {os.path.basename(cbz_output_path)}") + final_status_lines.append(f" πŸ“ Location: {cbz_output_path}") + else: + # Create ZIP file for all images + zip_path = os.path.join(output_dir, "translated_images.zip") + try: + import zipfile + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + for img_path in translated_files: + arcname = os.path.basename(img_path) + zipf.write(img_path, arcname) + final_status_lines.append(f" πŸ“¦ Download all images: translated_images.zip") + final_status_lines.append(f" πŸ“ Output directory: {output_dir}") + final_output_for_display = zip_path # Set this so it can be downloaded + except Exception as e: + final_status_lines.append(f" ❌ Failed to create ZIP: {str(e)}") + final_status_lines.append(f" πŸ“ Output directory: {output_dir}") + final_status_lines.append(" πŸ–ΌοΈ Images saved individually in output directory") + else: + final_status_lines.append("❌ Translation failed - no images were processed") + + final_status_text = "\n".join(final_status_lines) + + # Final yield with complete logs, image, CBZ, and final status + # Format: (logs_textbox, output_image, cbz_file, status_textbox, progress_group, progress_text, progress_bar) + final_progress_text = f"Complete! Processed {len(translated_files)}/{total_images} images" + if translated_files: + # Show all translated images in gallery + if cbz_mode and cbz_output_path and os.path.exists(cbz_output_path): + yield ( + "\n".join(translation_logs), + gr.update(value=translated_files, visible=True), # Show all images in gallery + gr.update(value=cbz_output_path, visible=True), # CBZ file for download with visibility + gr.update(value=final_status_text, visible=True), + gr.update(visible=True), + gr.update(value=final_progress_text), + gr.update(value=100) + ) + else: + # Show ZIP file for download if it was created + if final_output_for_display and os.path.exists(final_output_for_display): + yield ( + "\n".join(translation_logs), + gr.update(value=translated_files, visible=True), # Show all images in gallery + gr.update(value=final_output_for_display, visible=True), # ZIP file for download + gr.update(value=final_status_text, visible=True), + gr.update(visible=True), + gr.update(value=final_progress_text), + gr.update(value=100) + ) + else: + yield ( + "\n".join(translation_logs), + gr.update(value=translated_files, visible=True), # Show all images in gallery + gr.update(visible=False), # Hide download component if ZIP failed + gr.update(value=final_status_text, visible=True), + gr.update(visible=True), + gr.update(value=final_progress_text), + gr.update(value=100) + ) + else: + yield ( + "\n".join(translation_logs), + gr.update(visible=False), + gr.update(visible=False), # Hide CBZ component + gr.update(value=final_status_text, visible=True), + gr.update(visible=True), + gr.update(value=final_progress_text), + gr.update(value=0) # 0% if nothing was processed + ) + + except Exception as e: + import traceback + error_msg = f"❌ Error during manga translation:\n{str(e)}\n\n{traceback.format_exc()}" + self.is_translating = False + yield error_msg, gr.update(visible=False), gr.update(visible=False), gr.update(value=error_msg, visible=True), gr.update(visible=False), gr.update(value="Error occurred"), gr.update(value=0) + finally: + # Always reset translation state when done + self.is_translating = False + # Clear active references on full completion + try: + self.current_translator = None + self.current_unified_client = None + except Exception: + pass + + def stop_manga_translation(self): + """Simple function to stop manga translation""" + print("DEBUG: Stop button clicked") + if self.is_translating: + print("DEBUG: Stopping active translation") + self.stop_translation() + # Return UI updates for button visibility and status + return ( + gr.update(visible=True), # translate button - show + gr.update(visible=False), # stop button - hide + "⏹️ Translation stopped by user" + ) + else: + print("DEBUG: No active translation to stop") + return ( + gr.update(visible=True), # translate button - show + gr.update(visible=False), # stop button - hide + "No active translation to stop" + ) + + def start_manga_translation(self, *args): + """Simple function to start manga translation - GENERATOR FUNCTION""" + print("DEBUG: Translate button clicked") + + # Reset flags for new translation and mark as translating BEFORE first yield + self._reset_translation_flags() + self.is_translating = True + + # Initial yield to update button visibility + yield ( + "πŸš€ Starting translation...", + gr.update(visible=False), # manga_output_gallery - hide initially + gr.update(visible=False), # manga_cbz_output + gr.update(value="Starting...", visible=True), # manga_status + gr.update(visible=False), # manga_progress_group + gr.update(value="Initializing..."), # manga_progress_text + gr.update(value=0), # manga_progress_bar + gr.update(visible=False), # translate button - hide during translation + gr.update(visible=True) # stop button - show during translation + ) + + # Call the translate function and yield all its results + last_result = None + try: + for result in self.translate_manga(*args): + # Check if stop was requested during iteration + if self.stop_flag.is_set(): + print("DEBUG: Stop flag detected, breaking translation loop") + break + + last_result = result + # Pad result to include button states (translate_visible=False, stop_visible=True) + if len(result) >= 7: + yield result + (gr.update(visible=False), gr.update(visible=True)) + else: + # Pad result to match expected length (7 values) then add button states + padded_result = list(result) + [gr.update(visible=False)] * (7 - len(result)) + yield tuple(padded_result) + (gr.update(visible=False), gr.update(visible=True)) + + except GeneratorExit: + print("DEBUG: Translation generator was closed") + self.is_translating = False + return + except Exception as e: + print(f"DEBUG: Exception during translation: {e}") + self.is_translating = False + # Show error and reset buttons + error_msg = f"❌ Error during translation: {str(e)}" + yield ( + error_msg, + gr.update(visible=False), + gr.update(visible=False), + gr.update(value=error_msg, visible=True), + gr.update(visible=False), + gr.update(value="Error occurred"), + gr.update(value=0), + gr.update(visible=True), # translate button - show after error + gr.update(visible=False) # stop button - hide after error + ) + return + finally: + # Clear active references when the loop exits + self.is_translating = False + try: + self.current_translator = None + self.current_unified_client = None + except Exception: + pass + + # Check if we stopped early + if self.stop_flag.is_set(): + yield ( + "⏹️ Translation stopped by user", + gr.update(visible=False), + gr.update(visible=False), + gr.update(value="⏹️ Translation stopped", visible=True), + gr.update(visible=False), + gr.update(value="Stopped"), + gr.update(value=0), + gr.update(visible=True), # translate button - show after stop + gr.update(visible=False) # stop button - hide after stop + ) + return + + # Final yield to reset buttons after successful completion + print("DEBUG: Translation completed normally, resetting buttons") + if last_result is None: + last_result = ("", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(value="Complete"), gr.update(value=100)) + + if len(last_result) >= 7: + yield last_result[:7] + (gr.update(visible=True), gr.update(visible=False)) + else: + # Pad result to match expected length then add button states + padded_result = list(last_result) + [gr.update(visible=False)] * (7 - len(last_result)) + yield tuple(padded_result) + (gr.update(visible=True), gr.update(visible=False)) + + def create_interface(self): + """Create and return the Gradio interface""" + # Reload config before creating interface to get latest values + self.config = self.load_config() + self.decrypted_config = decrypt_config(self.config.copy()) if API_KEY_ENCRYPTION_AVAILABLE else self.config.copy() + + # Load and encode icon as base64 + icon_base64 = "" + icon_path = "Halgakos.ico" if os.path.exists("Halgakos.ico") else "Halgakos.ico" + if os.path.exists(icon_path): + with open(icon_path, "rb") as f: + icon_base64 = base64.b64encode(f.read()).decode() + + # Custom CSS to hide Gradio footer and add favicon + custom_css = """ + footer {display: none !important;} + .gradio-container {min-height: 100vh;} + + /* Stop button styling */ + .gr-button[data-variant="stop"] { + background-color: #dc3545 !important; + border-color: #dc3545 !important; + color: white !important; + } + .gr-button[data-variant="stop"]:hover { + background-color: #c82333 !important; + border-color: #bd2130 !important; + color: white !important; + } + """ + + # JavaScript for localStorage persistence - SIMPLE VERSION + localStorage_js = """ + <script> + console.log('Glossarion localStorage script loading...'); + + // Simple localStorage functions + function saveToLocalStorage(key, value) { + try { + localStorage.setItem('glossarion_' + key, JSON.stringify(value)); + console.log('Saved:', key, '=', value); + return true; + } catch (e) { + console.error('Save failed:', e); + return false; + } + } + + function loadFromLocalStorage(key, defaultValue) { + try { + const item = localStorage.getItem('glossarion_' + key); + return item ? JSON.parse(item) : defaultValue; + } catch (e) { + console.error('Load failed:', e); + return defaultValue; + } + } + + // Manual save current form values to localStorage + function saveCurrentSettings() { + const settings = {}; + + // Find all input elements in Gradio + document.querySelectorAll('input, select, textarea').forEach(el => { + // Skip file inputs + if (el.type === 'file') return; + + // Get a unique key based on element properties + let key = el.id || el.name || el.placeholder || ''; + if (!key) { + // Try to get label text + const label = el.closest('div')?.querySelector('label'); + if (label) key = label.textContent; + } + + if (key) { + key = key.trim().replace(/[^a-zA-Z0-9]/g, '_'); + if (el.type === 'checkbox') { + settings[key] = el.checked; + } else if (el.type === 'radio') { + if (el.checked) settings[key] = el.value; + } else if (el.value) { + settings[key] = el.value; + } + } + }); + + // Save all settings + Object.keys(settings).forEach(key => { + saveToLocalStorage(key, settings[key]); + }); + + console.log('Saved', Object.keys(settings).length, 'settings'); + return settings; + } + + // Export settings from localStorage + function exportSettings() { + console.log('Export started'); + + // First save current form state + saveCurrentSettings(); + + // Then export from localStorage + const settings = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith('glossarion_')) { + try { + settings[key.replace('glossarion_', '')] = JSON.parse(localStorage.getItem(key)); + } catch (e) { + // Store as-is if not JSON + settings[key.replace('glossarion_', '')] = localStorage.getItem(key); + } + } + } + + if (Object.keys(settings).length === 0) { + alert('No settings to export. Try saving some settings first.'); + return; + } + + // Download as JSON + const blob = new Blob([JSON.stringify(settings, null, 2)], {type: 'application/json'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'glossarion_settings_' + new Date().toISOString().slice(0,19).replace(/:/g, '-') + '.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + console.log('Exported', Object.keys(settings).length, 'settings'); + } + + function importSettings(fileContent) { + try { + const settings = JSON.parse(fileContent); + Object.keys(settings).forEach(key => { + saveToLocalStorage(key, settings[key]); + }); + location.reload(); // Reload to apply settings + } catch (e) { + alert('Invalid settings file format'); + } + } + + // Expose to global scope + window.exportSettings = exportSettings; + window.importSettings = importSettings; + window.saveCurrentSettings = saveCurrentSettings; + window.saveToLocalStorage = saveToLocalStorage; + window.loadFromLocalStorage = loadFromLocalStorage; + + // Load settings from localStorage on page load for HF Spaces + function loadSettingsFromLocalStorage() { + console.log('Attempting to load settings from localStorage...'); + try { + // Get all localStorage items with glossarion_ prefix + const settings = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith('glossarion_')) { + const cleanKey = key.replace('glossarion_', ''); + try { + settings[cleanKey] = JSON.parse(localStorage.getItem(key)); + } catch (e) { + settings[cleanKey] = localStorage.getItem(key); + } + } + } + + if (Object.keys(settings).length > 0) { + console.log('Found', Object.keys(settings).length, 'settings in localStorage'); + + // Try to update Gradio components + // This is tricky because Gradio components are rendered dynamically + // We'll need to find them by their labels or other identifiers + + // For now, just log what we found + console.log('Settings:', settings); + } + } catch (e) { + console.error('Error loading from localStorage:', e); + } + } + + // Try loading settings at various points + window.addEventListener('load', function() { + console.log('Page loaded'); + setTimeout(loadSettingsFromLocalStorage, 1000); + setTimeout(loadSettingsFromLocalStorage, 3000); + }); + + document.addEventListener('DOMContentLoaded', function() { + console.log('DOM ready'); + setTimeout(loadSettingsFromLocalStorage, 500); + }); + </script> + """ + + with gr.Blocks( + title="Glossarion - AI Translation", + theme=gr.themes.Soft(), + css=custom_css + ) as app: + + # Add custom HTML with favicon link and title with icon + icon_img_tag = f'<img src="data:image/png;base64,{icon_base64}" alt="Glossarion">' if icon_base64 else '' + + gr.HTML(f""" + <link rel="icon" type="image/x-icon" href="file/Halgakos.ico"> + <link rel="shortcut icon" type="image/x-icon" href="file/Halgakos.ico"> + <style> + .title-with-icon {{ + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 10px; + }} + .title-with-icon img {{ + width: 48px; + height: 48px; + }} + </style> + <div class="title-with-icon"> + {icon_img_tag} + <h1>Glossarion - AI-Powered Translation</h1> + </div> + {localStorage_js} + """) + + with gr.Row(): + gr.Markdown(""" + Translate novels and books using advanced AI models (GPT-5, Claude, etc.) + """) + + + # SECURITY: Save Config button disabled for Hugging Face to prevent API key leakage + # Users should use localStorage (browser-based storage) instead + # with gr.Column(scale=0): + # save_config_btn = gr.Button( + # "πŸ’Ύ Save Config", + # variant="secondary", + # size="sm" + # ) + # save_status_text = gr.Markdown( + # "", + # visible=False + # ) + + with gr.Tabs() as main_tabs: + # EPUB Translation Tab + with gr.Tab("πŸ“š EPUB Translation"): + with gr.Row(): + with gr.Column(): + epub_file = gr.File( + label="πŸ“– Upload EPUB or TXT File", + file_types=[".epub", ".txt"] + ) + + with gr.Row(): + translate_btn = gr.Button( + "πŸš€ Translate EPUB", + variant="primary", + size="lg", + scale=2 + ) + + stop_epub_btn = gr.Button( + "⏹️ Stop Translation", + variant="stop", + size="lg", + visible=False, + scale=1 + ) + + epub_model = gr.Dropdown( + choices=self.models, + value=self.get_config_value('model', 'gpt-4-turbo'), + label="πŸ€– AI Model", + interactive=True, + allow_custom_value=True, + filterable=True + ) + + epub_api_key = gr.Textbox( + label="πŸ”‘ API Key", + type="password", + placeholder="Enter your API key", + value=self.get_config_value('api_key', '') + ) + + # Use all profiles without filtering + profile_choices = list(self.profiles.keys()) + # Use saved active_profile instead of hardcoded default + default_profile = self.get_config_value('active_profile', profile_choices[0] if profile_choices else '') + + epub_profile = gr.Dropdown( + choices=profile_choices, + value=default_profile, + label="πŸ“ Translation Profile" + ) + + epub_system_prompt = gr.Textbox( + label="System Prompt (Translation Instructions)", + lines=8, + max_lines=15, + interactive=True, + placeholder="Select a profile to load translation instructions...", + value=self.profiles.get(default_profile, '') if default_profile else '' + ) + + with gr.Accordion("βš™οΈ Advanced Settings", open=False): + epub_temperature = gr.Slider( + minimum=0, + maximum=1, + value=self.get_config_value('temperature', 0.3), + step=0.1, + label="Temperature" + ) + + epub_max_tokens = gr.Number( + label="Max Output Tokens", + value=self.get_config_value('max_output_tokens', 16000), + minimum=0 + ) + + gr.Markdown("### Image Translation") + + enable_image_translation = gr.Checkbox( + label="Enable Image Translation", + value=self.get_config_value('enable_image_translation', False), + info="Extracts and translates text from images using vision models" + ) + + gr.Markdown("### Glossary Settings") + + enable_auto_glossary = gr.Checkbox( + label="Enable Automatic Glossary Generation", + value=self.get_config_value('enable_auto_glossary', False), + info="Automatic extraction and translation of character names/terms" + ) + + append_glossary = gr.Checkbox( + label="Append Glossary to System Prompt", + value=self.get_config_value('append_glossary_to_prompt', True), + info="Applies to ALL glossaries - manual and automatic" + ) + + # Automatic glossary extraction settings (only show when enabled) + with gr.Group(visible=self.get_config_value('enable_auto_glossary', False)) as auto_glossary_settings: + gr.Markdown("#### Automatic Glossary Extraction Settings") + + with gr.Row(): + auto_glossary_min_freq = gr.Slider( + minimum=1, + maximum=10, + value=self.get_config_value('glossary_min_frequency', 2), + step=1, + label="Min Frequency", + info="Minimum times a name must appear" + ) + + auto_glossary_max_names = gr.Slider( + minimum=10, + maximum=200, + value=self.get_config_value('glossary_max_names', 50), + step=10, + label="Max Names", + info="Maximum number of character names" + ) + + with gr.Row(): + auto_glossary_max_titles = gr.Slider( + minimum=10, + maximum=100, + value=self.get_config_value('glossary_max_titles', 30), + step=5, + label="Max Titles", + info="Maximum number of titles/terms" + ) + + auto_glossary_batch_size = gr.Slider( + minimum=10, + maximum=100, + value=self.get_config_value('glossary_batch_size', 50), + step=5, + label="Translation Batch Size", + info="Terms per API call" + ) + + auto_glossary_filter_mode = gr.Radio( + choices=[ + ("All names & terms", "all"), + ("Names with honorifics only", "only_with_honorifics"), + ("Names without honorifics & terms", "only_without_honorifics") + ], + value=self.get_config_value('glossary_filter_mode', 'all'), + label="Filter Mode", + info="What types of names to extract" + ) + + auto_glossary_fuzzy_threshold = gr.Slider( + minimum=0.5, + maximum=1.0, + value=self.get_config_value('glossary_fuzzy_threshold', 0.90), + step=0.05, + label="Fuzzy Matching Threshold", + info="How similar names must be to match (0.9 = 90% match)" + ) + + # Toggle visibility of auto glossary settings + enable_auto_glossary.change( + fn=lambda x: gr.update(visible=x), + inputs=[enable_auto_glossary], + outputs=[auto_glossary_settings] + ) + + gr.Markdown("### Quality Assurance") + + enable_post_translation_scan = gr.Checkbox( + label="Enable post-translation Scanning phase", + value=self.get_config_value('enable_post_translation_scan', False), + info="Automatically run QA Scanner after translation completes" + ) + + glossary_file = gr.File( + label="πŸ“‹ Manual Glossary CSV (optional)", + file_types=[".csv", ".json", ".txt"] + ) + + with gr.Column(): + # Add logo and status at top + with gr.Row(): + gr.Image( + value="Halgakos.png", + label=None, + show_label=False, + width=80, + height=80, + interactive=False, + show_download_button=False, + container=False + ) + epub_status_message = gr.Markdown( + value="### Ready to translate\nUpload an EPUB or TXT file and click 'Translate' to begin.", + visible=True + ) + + # Progress section (similar to manga tab) + with gr.Group(visible=False) as epub_progress_group: + gr.Markdown("### Progress") + epub_progress_text = gr.Textbox( + label="πŸ“¨ Current Status", + value="Ready to start", + interactive=False, + lines=1 + ) + epub_progress_bar = gr.Slider( + minimum=0, + maximum=100, + value=0, + step=1, + label="πŸ“‹ Translation Progress", + interactive=False, + show_label=True + ) + + epub_logs = gr.Textbox( + label="πŸ“‹ Translation Logs", + lines=20, + max_lines=30, + value="Ready to translate. Upload an EPUB or TXT file and configure settings.", + visible=True, + interactive=False + ) + + epub_output = gr.File( + label="πŸ“₯ Download Translated File", + visible=True # Always visible, will show file when ready + ) + + epub_status = gr.Textbox( + label="Final Status", + lines=3, + max_lines=5, + visible=False, + interactive=False + ) + + # Sync handlers will be connected after manga components are created + + # Translation button handler - now with progress outputs + translate_btn.click( + fn=self.translate_epub_with_stop, + inputs=[ + epub_file, + epub_model, + epub_api_key, + epub_profile, + epub_system_prompt, + epub_temperature, + epub_max_tokens, + enable_image_translation, + glossary_file + ], + outputs=[ + epub_output, # Download file + epub_status_message, # Top status message + epub_progress_group, # Progress group visibility + epub_logs, # Translation logs + epub_status, # Final status + epub_progress_text, # Progress text + epub_progress_bar, # Progress bar + translate_btn, # Show/hide translate button + stop_epub_btn # Show/hide stop button + ] + ) + + # Stop button handler + stop_epub_btn.click( + fn=self.stop_epub_translation, + inputs=[], + outputs=[translate_btn, stop_epub_btn, epub_status] + ) + + # Manga Translation Tab + with gr.Tab("🎨 Manga Translation"): + with gr.Row(): + with gr.Column(): + manga_images = gr.File( + label="πŸ–ΌοΈ Upload Manga Images or CBZ", + file_types=[".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".cbz", ".zip"], + file_count="multiple" + ) + + with gr.Row(): + translate_manga_btn = gr.Button( + "πŸš€ Translate Manga", + variant="primary", + size="lg", + scale=2 + ) + + stop_manga_btn = gr.Button( + "⏹️ Stop Translation", + variant="stop", + size="lg", + visible=False, + scale=1 + ) + + manga_model = gr.Dropdown( + choices=self.models, + value=self.get_config_value('model', 'gpt-4-turbo'), + label="πŸ€– AI Model", + interactive=True, + allow_custom_value=True, + filterable=True + ) + + manga_api_key = gr.Textbox( + label="πŸ”‘ API Key", + type="password", + placeholder="Enter your API key", + value=self.get_config_value('api_key', '') # Pre-fill from config + ) + + # Use all profiles without filtering + profile_choices = list(self.profiles.keys()) + # Use the active profile from config, same as EPUB tab + default_profile = self.get_config_value('active_profile', profile_choices[0] if profile_choices else '') + + manga_profile = gr.Dropdown( + choices=profile_choices, + value=default_profile, + label="πŸ“ Translation Profile" + ) + + # Editable manga system prompt + manga_system_prompt = gr.Textbox( + label="Manga System Prompt (Translation Instructions)", + lines=8, + max_lines=15, + interactive=True, + placeholder="Select a manga profile to load translation instructions...", + value=self.profiles.get(default_profile, '') if default_profile else '' + ) + + with gr.Accordion("βš™οΈ OCR Settings", open=False): + gr.Markdown("πŸ”’ **Credentials are auto-saved** to your config (encrypted) after first use.") + + ocr_provider = gr.Radio( + choices=["google", "azure", "custom-api"], + value=self.get_config_value('ocr_provider', 'custom-api'), + label="OCR Provider" + ) + + # Show saved Google credentials path if available + saved_google_path = self.get_config_value('google_vision_credentials', '') + if saved_google_path and os.path.exists(saved_google_path): + gr.Markdown(f"βœ… **Saved credentials found:** `{os.path.basename(saved_google_path)}`") + gr.Markdown("πŸ’‘ *Using saved credentials. Upload a new file only if you want to change them.*") + else: + gr.Markdown("⚠️ No saved Google credentials found. Please upload your JSON file.") + + # Note: File component doesn't support pre-filling paths due to browser security + google_creds = gr.File( + label="Google Cloud Credentials JSON (upload to update)", + file_types=[".json"] + ) + + azure_key = gr.Textbox( + label="Azure Vision API Key (if using Azure)", + type="password", + placeholder="Enter Azure API key", + value=self.get_config_value('azure_vision_key', '') + ) + + azure_endpoint = gr.Textbox( + label="Azure Vision Endpoint (if using Azure)", + placeholder="https://your-resource.cognitiveservices.azure.com/", + value=self.get_config_value('azure_vision_endpoint', '') + ) + + bubble_detection = gr.Checkbox( + label="Enable Bubble Detection", + value=self.get_config_value('bubble_detection_enabled', True) + ) + + inpainting = gr.Checkbox( + label="Enable Text Removal (Inpainting)", + value=self.get_config_value('inpainting_enabled', True) + ) + + with gr.Accordion("⚑ Parallel Processing", open=False): + gr.Markdown("### Parallel Panel Translation") + gr.Markdown("*Process multiple panels simultaneously for faster translation*") + + # Check environment variables first, then config + parallel_enabled = os.getenv('PARALLEL_PANEL_TRANSLATION', '').lower() == 'true' + if not parallel_enabled: + # Fall back to config if not set in env + parallel_enabled = self.get_config_value('manga_settings', {}).get('advanced', {}).get('parallel_panel_translation', False) + + # Get max workers from env or config + max_workers_env = os.getenv('PANEL_MAX_WORKERS', '') + if max_workers_env.isdigit(): + max_workers = int(max_workers_env) + else: + max_workers = self.get_config_value('manga_settings', {}).get('advanced', {}).get('panel_max_workers', 7) + + parallel_panel_translation = gr.Checkbox( + label="Enable Parallel Panel Translation", + value=parallel_enabled, + info="Translates multiple panels at once instead of sequentially" + ) + + panel_max_workers = gr.Slider( + minimum=1, + maximum=20, + value=max_workers, + step=1, + label="Max concurrent panels", + interactive=True, + info="Number of panels to process simultaneously (higher = faster but more memory)" + ) + + with gr.Accordion("✨ Text Visibility Settings", open=False): + gr.Markdown("### Font Settings") + + font_size_mode = gr.Radio( + choices=["auto", "fixed", "multiplier"], + value=self.get_config_value('manga_font_size_mode', 'auto'), + label="Font Size Mode" + ) + + font_size = gr.Slider( + minimum=0, + maximum=72, + value=self.get_config_value('manga_font_size', 24), + step=1, + label="Fixed Font Size (0=auto, used when mode=fixed)" + ) + + font_multiplier = gr.Slider( + minimum=0.5, + maximum=2.0, + value=self.get_config_value('manga_font_size_multiplier', 1.0), + step=0.1, + label="Font Size Multiplier (when mode=multiplier)" + ) + + min_font_size = gr.Slider( + minimum=0, + maximum=100, + value=self.get_config_value('manga_settings', {}).get('rendering', {}).get('auto_min_size', 12), + step=1, + label="Minimum Font Size (0=no limit)" + ) + + max_font_size = gr.Slider( + minimum=20, + maximum=100, + value=self.get_config_value('manga_max_font_size', 48), + step=1, + label="Maximum Font Size" + ) + + gr.Markdown("### Text Color") + + # Convert RGB array to hex if needed + def to_hex_color(color_value, default='#000000'): + if isinstance(color_value, (list, tuple)) and len(color_value) >= 3: + return '#{:02x}{:02x}{:02x}'.format(int(color_value[0]), int(color_value[1]), int(color_value[2])) + elif isinstance(color_value, str): + return color_value if color_value.startswith('#') else default + return default + + text_color_rgb = gr.ColorPicker( + label="Font Color", + value=to_hex_color(self.get_config_value('manga_text_color', [255, 255, 255]), '#FFFFFF') # Default white + ) + + gr.Markdown("### Shadow Settings") + + shadow_enabled = gr.Checkbox( + label="Enable Text Shadow", + value=self.get_config_value('manga_shadow_enabled', True) + ) + + shadow_color = gr.ColorPicker( + label="Shadow Color", + value=to_hex_color(self.get_config_value('manga_shadow_color', [0, 0, 0]), '#000000') # Default black + ) + + shadow_offset_x = gr.Slider( + minimum=-10, + maximum=10, + value=self.get_config_value('manga_shadow_offset_x', 2), + step=1, + label="Shadow Offset X" + ) + + shadow_offset_y = gr.Slider( + minimum=-10, + maximum=10, + value=self.get_config_value('manga_shadow_offset_y', 2), + step=1, + label="Shadow Offset Y" + ) + + shadow_blur = gr.Slider( + minimum=0, + maximum=10, + value=self.get_config_value('manga_shadow_blur', 0), + step=1, + label="Shadow Blur" + ) + + gr.Markdown("### Background Settings") + + bg_opacity = gr.Slider( + minimum=0, + maximum=255, + value=self.get_config_value('manga_bg_opacity', 130), + step=1, + label="Background Opacity" + ) + + # Ensure bg_style value is valid + bg_style_value = self.get_config_value('manga_bg_style', 'circle') + if bg_style_value not in ["box", "circle", "wrap"]: + bg_style_value = 'circle' # Default fallback + + bg_style = gr.Radio( + choices=["box", "circle", "wrap"], + value=bg_style_value, + label="Background Style" + ) + + with gr.Column(): + # Add logo and loading message at top + with gr.Row(): + gr.Image( + value="Halgakos.png", + label=None, + show_label=False, + width=80, + height=80, + interactive=False, + show_download_button=False, + container=False + ) + status_message = gr.Markdown( + value="### Ready to translate\nUpload an image and click 'Translate Manga' to begin.", + visible=True + ) + + # Progress section for manga translation (similar to manga integration script) + with gr.Group(visible=False) as manga_progress_group: + gr.Markdown("### Progress") + manga_progress_text = gr.Textbox( + label="πŸ“ˆ Current Status", + value="Ready to start", + interactive=False, + lines=1 + ) + manga_progress_bar = gr.Slider( + minimum=0, + maximum=100, + value=0, + step=1, + label="πŸ“‹ Translation Progress", + interactive=False, + show_label=True + ) + + manga_logs = gr.Textbox( + label="πŸ“‹ Translation Logs", + lines=20, + max_lines=30, + value="Ready to translate. Click 'Translate Manga' to begin.", + visible=True, + interactive=False + ) + + # Use Gallery to show all translated images + manga_output_gallery = gr.Gallery( + label="πŸ“· Translated Images (click to download)", + visible=False, + show_label=True, + elem_id="manga_output_gallery", + columns=3, + rows=2, + height="auto", + allow_preview=True, + show_download_button=True # Allow download of individual images + ) + # Keep CBZ output for bulk download + manga_cbz_output = gr.File(label="πŸ“¦ Download Translated CBZ", visible=False) + manga_status = gr.Textbox( + label="Final Status", + lines=8, + max_lines=15, + visible=False + ) + + # Global sync flag to prevent loops + self._syncing_active = False + + # Auto-save Azure credentials on change + def save_azure_credentials(key, endpoint): + """Save Azure credentials to config""" + try: + current_config = self.get_current_config_for_update() + # Don't decrypt - just update what we need + if key and key.strip(): + current_config['azure_vision_key'] = str(key).strip() + if endpoint and endpoint.strip(): + current_config['azure_vision_endpoint'] = str(endpoint).strip() + self.save_config(current_config) + return None + except Exception as e: + print(f"Failed to save Azure credentials: {e}") + return None + + # All auto-save handlers removed - use manual Save Config button to avoid constant writes to persistent storage + + # Only update system prompts when profiles change - no cross-tab syncing + epub_profile.change( + fn=lambda p: self.profiles.get(p, ''), + inputs=[epub_profile], + outputs=[epub_system_prompt] + ) + + manga_profile.change( + fn=lambda p: self.profiles.get(p, ''), + inputs=[manga_profile], + outputs=[manga_system_prompt] + ) + + # Manual save function for all configuration + def save_all_config( + model, api_key, profile, temperature, max_tokens, + enable_image_trans, enable_auto_gloss, append_gloss, + # Auto glossary settings + auto_gloss_min_freq, auto_gloss_max_names, auto_gloss_max_titles, + auto_gloss_batch_size, auto_gloss_filter_mode, auto_gloss_fuzzy, + enable_post_scan, + # Manual glossary extraction settings + manual_min_freq, manual_max_names, manual_max_titles, + manual_max_text_size, manual_max_sentences, manual_trans_batch, + manual_chapter_split, manual_filter_mode, manual_strip_honorifics, + manual_fuzzy, manual_extraction_prompt, manual_format_instructions, + manual_use_legacy_csv, + # QA Scanner settings + qa_min_foreign, qa_check_rep, qa_check_gloss_leak, + qa_min_file_len, qa_check_headers, qa_check_html, + qa_check_paragraphs, qa_min_para_percent, qa_report_fmt, qa_auto_save, + # Chapter processing options + batch_trans_headers, headers_batch, ncx_nav, attach_css, retain_ext, + conservative_batch, gemini_safety, http_openrouter, openrouter_compress, + extraction_method, filter_level, + # Thinking mode settings + gpt_thinking_enabled, gpt_effort, or_tokens, + gemini_thinking_enabled, gemini_budget, + manga_model, manga_api_key, manga_profile, + ocr_prov, azure_k, azure_e, + bubble_det, inpaint, + font_mode, font_s, font_mult, min_font, max_font, + text_col, shadow_en, shadow_col, + shadow_x, shadow_y, shadow_b, + bg_op, bg_st, + parallel_trans, panel_workers, + # Advanced Settings fields + detector_type_val, rtdetr_conf, bubble_conf, + detect_text, detect_empty, detect_free, max_detections, + local_method_val, webtoon_val, + batch_size_val, cache_enabled_val, + parallel_proc, max_work, + preload_local, stagger_ms, + torch_prec, auto_cleanup, + debug, save_inter, concise_logs + ): + """Save all configuration values at once""" + try: + config = self.get_current_config_for_update() + + # Save all values + config['model'] = model + if api_key: # Only save non-empty API keys + config['api_key'] = api_key + config['active_profile'] = profile + config['temperature'] = temperature + config['max_output_tokens'] = max_tokens + config['enable_image_translation'] = enable_image_trans + config['enable_auto_glossary'] = enable_auto_gloss + config['append_glossary_to_prompt'] = append_gloss + + # Auto glossary settings + config['glossary_min_frequency'] = auto_gloss_min_freq + config['glossary_max_names'] = auto_gloss_max_names + config['glossary_max_titles'] = auto_gloss_max_titles + config['glossary_batch_size'] = auto_gloss_batch_size + config['glossary_filter_mode'] = auto_gloss_filter_mode + config['glossary_fuzzy_threshold'] = auto_gloss_fuzzy + + # Manual glossary extraction settings + config['manual_glossary_min_frequency'] = manual_min_freq + config['manual_glossary_max_names'] = manual_max_names + config['manual_glossary_max_titles'] = manual_max_titles + config['glossary_max_text_size'] = manual_max_text_size + config['glossary_max_sentences'] = manual_max_sentences + config['manual_glossary_batch_size'] = manual_trans_batch + config['glossary_chapter_split_threshold'] = manual_chapter_split + config['manual_glossary_filter_mode'] = manual_filter_mode + config['strip_honorifics'] = manual_strip_honorifics + config['manual_glossary_fuzzy_threshold'] = manual_fuzzy + config['manual_glossary_prompt'] = manual_extraction_prompt + config['glossary_format_instructions'] = manual_format_instructions + config['glossary_use_legacy_csv'] = manual_use_legacy_csv + config['enable_post_translation_scan'] = enable_post_scan + + # QA Scanner settings + config['qa_min_foreign_chars'] = qa_min_foreign + config['qa_check_repetition'] = qa_check_rep + config['qa_check_glossary_leakage'] = qa_check_gloss_leak + config['qa_min_file_length'] = qa_min_file_len + config['qa_check_multiple_headers'] = qa_check_headers + config['qa_check_missing_html'] = qa_check_html + config['qa_check_insufficient_paragraphs'] = qa_check_paragraphs + config['qa_min_paragraph_percentage'] = qa_min_para_percent + config['qa_report_format'] = qa_report_fmt + config['qa_auto_save_report'] = qa_auto_save + + # Chapter processing options + config['batch_translate_headers'] = batch_trans_headers + config['headers_per_batch'] = headers_batch + config['use_ncx_navigation'] = ncx_nav + config['attach_css_to_chapters'] = attach_css + config['retain_source_extension'] = retain_ext + config['use_conservative_batching'] = conservative_batch + config['disable_gemini_safety'] = gemini_safety + config['use_http_openrouter'] = http_openrouter + config['disable_openrouter_compression'] = openrouter_compress + config['text_extraction_method'] = extraction_method + config['file_filtering_level'] = filter_level + + # Thinking mode settings + config['enable_gpt_thinking'] = gpt_thinking_enabled + config['gpt_thinking_effort'] = gpt_effort + config['or_thinking_tokens'] = or_tokens + config['enable_gemini_thinking'] = gemini_thinking_enabled + config['gemini_thinking_budget'] = gemini_budget + + # Manga settings + config['ocr_provider'] = ocr_prov + if azure_k: + config['azure_vision_key'] = azure_k + if azure_e: + config['azure_vision_endpoint'] = azure_e + config['bubble_detection_enabled'] = bubble_det + config['inpainting_enabled'] = inpaint + config['manga_font_size_mode'] = font_mode + config['manga_font_size'] = font_s + config['manga_font_multiplier'] = font_mult + config['manga_min_font_size'] = min_font + config['manga_max_font_size'] = max_font + config['manga_text_color'] = text_col + config['manga_shadow_enabled'] = shadow_en + config['manga_shadow_color'] = shadow_col + config['manga_shadow_offset_x'] = shadow_x + config['manga_shadow_offset_y'] = shadow_y + config['manga_shadow_blur'] = shadow_b + config['manga_bg_opacity'] = bg_op + config['manga_bg_style'] = bg_st + + # Advanced settings + if 'manga_settings' not in config: + config['manga_settings'] = {} + if 'advanced' not in config['manga_settings']: + config['manga_settings']['advanced'] = {} + config['manga_settings']['advanced']['parallel_panel_translation'] = parallel_trans + config['manga_settings']['advanced']['panel_max_workers'] = panel_workers + + # Advanced bubble detection and inpainting settings + if 'ocr' not in config['manga_settings']: + config['manga_settings']['ocr'] = {} + if 'inpainting' not in config['manga_settings']: + config['manga_settings']['inpainting'] = {} + + config['manga_settings']['ocr']['detector_type'] = detector_type_val + config['manga_settings']['ocr']['rtdetr_confidence'] = rtdetr_conf + config['manga_settings']['ocr']['bubble_confidence'] = bubble_conf + config['manga_settings']['ocr']['detect_text_bubbles'] = detect_text + config['manga_settings']['ocr']['detect_empty_bubbles'] = detect_empty + config['manga_settings']['ocr']['detect_free_text'] = detect_free + config['manga_settings']['ocr']['bubble_max_detections_yolo'] = max_detections + config['manga_settings']['inpainting']['local_method'] = local_method_val + config['manga_settings']['advanced']['webtoon_mode'] = webtoon_val + config['manga_settings']['inpainting']['batch_size'] = batch_size_val + config['manga_settings']['inpainting']['enable_cache'] = cache_enabled_val + config['manga_settings']['advanced']['parallel_processing'] = parallel_proc + config['manga_settings']['advanced']['max_workers'] = max_work + config['manga_settings']['advanced']['preload_local_inpainting_for_panels'] = preload_local + config['manga_settings']['advanced']['panel_start_stagger_ms'] = stagger_ms + config['manga_settings']['advanced']['torch_precision'] = torch_prec + config['manga_settings']['advanced']['auto_cleanup_models'] = auto_cleanup + config['manga_settings']['advanced']['debug_mode'] = debug + config['manga_settings']['advanced']['save_intermediate'] = save_inter + config['concise_pipeline_logs'] = concise_logs + + # Save to file + result = self.save_config(config) + + # Show success message for 3 seconds + return gr.update(value=result, visible=True) + + except Exception as e: + return gr.update(value=f"❌ Save failed: {str(e)}", visible=True) + + # Save button will be configured after all components are created + + # Auto-hide status message after 3 seconds + def hide_status_after_delay(): + import time + time.sleep(3) + return gr.update(visible=False) + + # Note: We can't use the change event to auto-hide because it would trigger immediately + # The status will remain visible until manually dismissed or page refresh + + # All individual field auto-save handlers removed - use manual Save Config button instead + + # Translate button click handler + translate_manga_btn.click( + fn=self.start_manga_translation, + inputs=[ + manga_images, + manga_model, + manga_api_key, + manga_profile, + manga_system_prompt, + ocr_provider, + google_creds, + azure_key, + azure_endpoint, + bubble_detection, + inpainting, + font_size_mode, + font_size, + font_multiplier, + min_font_size, + max_font_size, + text_color_rgb, + shadow_enabled, + shadow_color, + shadow_offset_x, + shadow_offset_y, + shadow_blur, + bg_opacity, + bg_style, + parallel_panel_translation, + panel_max_workers + ], + outputs=[manga_logs, manga_output_gallery, manga_cbz_output, manga_status, manga_progress_group, manga_progress_text, manga_progress_bar, translate_manga_btn, stop_manga_btn] + ) + + # Stop button click handler + stop_manga_btn.click( + fn=self.stop_manga_translation, + inputs=[], + outputs=[translate_manga_btn, stop_manga_btn, manga_status] + ) + + # Load settings from localStorage on page load + def load_settings_from_storage(): + """Load settings from localStorage or config file""" + is_hf_spaces = os.getenv('SPACE_ID') is not None or os.getenv('HF_SPACES') == 'true' + + if not is_hf_spaces: + # Load from config file locally + config = self.load_config() + # Decrypt API keys if needed + if API_KEY_ENCRYPTION_AVAILABLE: + config = decrypt_config(config) + return [ + config.get('model', 'gpt-4-turbo'), + config.get('api_key', ''), + config.get('active_profile', list(self.profiles.keys())[0] if self.profiles else ''), # profile + self.profiles.get(config.get('active_profile', list(self.profiles.keys())[0] if self.profiles else ''), ''), # prompt + config.get('ocr_provider', 'custom-api'), + None, # google_creds (file component - can't be pre-filled) + config.get('azure_vision_key', ''), + config.get('azure_vision_endpoint', ''), + config.get('bubble_detection_enabled', True), + config.get('inpainting_enabled', True), + config.get('manga_font_size_mode', 'auto'), + config.get('manga_font_size', 24), + config.get('manga_font_multiplier', 1.0), + config.get('manga_min_font_size', 12), + config.get('manga_max_font_size', 48), + config.get('manga_text_color', [255, 255, 255]), # Default white text + config.get('manga_shadow_enabled', True), + config.get('manga_shadow_color', [0, 0, 0]), # Default black shadow + config.get('manga_shadow_offset_x', 2), + config.get('manga_shadow_offset_y', 2), + config.get('manga_shadow_blur', 0), + config.get('manga_bg_opacity', 180), + config.get('manga_bg_style', 'auto'), + config.get('manga_settings', {}).get('advanced', {}).get('parallel_panel_translation', False), + config.get('manga_settings', {}).get('advanced', {}).get('panel_max_workers', 7) + ] + else: + # For HF Spaces, return defaults (will be overridden by JS) + return [ + 'gpt-4-turbo', # model + '', # api_key + list(self.profiles.keys())[0] if self.profiles else '', # profile + self.profiles.get(list(self.profiles.keys())[0] if self.profiles else '', ''), # prompt + 'custom-api', # ocr_provider + None, # google_creds (file component - can't be pre-filled) + '', # azure_key + '', # azure_endpoint + True, # bubble_detection + True, # inpainting + 'auto', # font_size_mode + 24, # font_size + 1.0, # font_multiplier + 12, # min_font_size + 48, # max_font_size + '#FFFFFF', # text_color - white + True, # shadow_enabled + '#000000', # shadow_color - black + 2, # shadow_offset_x + 2, # shadow_offset_y + 0, # shadow_blur + 180, # bg_opacity + 'auto', # bg_style + False, # parallel_panel_translation + 7 # panel_max_workers + ] + + # Store references for load handler + self.manga_components = { + 'model': manga_model, + 'api_key': manga_api_key, + 'profile': manga_profile, + 'prompt': manga_system_prompt, + 'ocr_provider': ocr_provider, + 'google_creds': google_creds, + 'azure_key': azure_key, + 'azure_endpoint': azure_endpoint, + 'bubble_detection': bubble_detection, + 'inpainting': inpainting, + 'font_size_mode': font_size_mode, + 'font_size': font_size, + 'font_multiplier': font_multiplier, + 'min_font_size': min_font_size, + 'max_font_size': max_font_size, + 'text_color_rgb': text_color_rgb, + 'shadow_enabled': shadow_enabled, + 'shadow_color': shadow_color, + 'shadow_offset_x': shadow_offset_x, + 'shadow_offset_y': shadow_offset_y, + 'shadow_blur': shadow_blur, + 'bg_opacity': bg_opacity, + 'bg_style': bg_style, + 'parallel_panel_translation': parallel_panel_translation, + 'panel_max_workers': panel_max_workers + } + self.load_settings_fn = load_settings_from_storage + + # Manga Settings Tab - NEW + with gr.Tab("🎬 Manga Settings"): + gr.Markdown("### Advanced Manga Translation Settings") + gr.Markdown("Configure bubble detection, inpainting, preprocessing, and rendering options.") + + with gr.Accordion("πŸ•ΉοΈ Bubble Detection & Inpainting", open=True): + gr.Markdown("#### Bubble Detection") + + detector_type = gr.Radio( + choices=["rtdetr_onnx", "rtdetr", "yolo"], + value=self.get_config_value('manga_settings', {}).get('ocr', {}).get('detector_type', 'rtdetr_onnx'), + label="Detector Type", + interactive=True + ) + + rtdetr_confidence = gr.Slider( + minimum=0.0, + maximum=1.0, + value=self.get_config_value('manga_settings', {}).get('ocr', {}).get('rtdetr_confidence', 0.3), + step=0.05, + label="RT-DETR Confidence Threshold", + interactive=True + ) + + bubble_confidence = gr.Slider( + minimum=0.0, + maximum=1.0, + value=self.get_config_value('manga_settings', {}).get('ocr', {}).get('bubble_confidence', 0.3), + step=0.05, + label="YOLO Bubble Confidence Threshold", + interactive=True + ) + + detect_text_bubbles = gr.Checkbox( + label="Detect Text Bubbles", + value=self.get_config_value('manga_settings', {}).get('ocr', {}).get('detect_text_bubbles', True) + ) + + detect_empty_bubbles = gr.Checkbox( + label="Detect Empty Bubbles", + value=self.get_config_value('manga_settings', {}).get('ocr', {}).get('detect_empty_bubbles', True) + ) + + detect_free_text = gr.Checkbox( + label="Detect Free Text (outside bubbles)", + value=self.get_config_value('manga_settings', {}).get('ocr', {}).get('detect_free_text', True) + ) + + bubble_max_detections = gr.Slider( + minimum=1, + maximum=2000, + value=self.get_config_value('manga_settings', {}).get('ocr', {}).get('bubble_max_detections_yolo', 100), + step=1, + label="Max detections (YOLO only)", + interactive=True, + info="Maximum number of bubble detections for YOLO detector" + ) + + gr.Markdown("#### Inpainting") + + local_inpaint_method = gr.Radio( + choices=["anime_onnx", "anime", "lama", "lama_onnx", "aot", "aot_onnx"], + value=self.get_config_value('manga_settings', {}).get('inpainting', {}).get('local_method', 'anime_onnx'), + label="Local Inpainting Model", + interactive=True + ) + + with gr.Row(): + download_models_btn = gr.Button( + "πŸ“₯ Download Models", + variant="secondary", + size="sm" + ) + load_models_btn = gr.Button( + "πŸ“‚ Load Models", + variant="secondary", + size="sm" + ) + + gr.Markdown("#### Mask Dilation") + + auto_iterations = gr.Checkbox( + label="Auto Iterations (Recommended)", + value=self.get_config_value('manga_settings', {}).get('auto_iterations', True) + ) + + mask_dilation = gr.Slider( + minimum=0, + maximum=20, + value=self.get_config_value('manga_settings', {}).get('mask_dilation', 0), + step=1, + label="General Mask Dilation", + interactive=True + ) + + text_bubble_dilation = gr.Slider( + minimum=0, + maximum=20, + value=self.get_config_value('manga_settings', {}).get('text_bubble_dilation_iterations', 2), + step=1, + label="Text Bubble Dilation Iterations", + interactive=True + ) + + empty_bubble_dilation = gr.Slider( + minimum=0, + maximum=20, + value=self.get_config_value('manga_settings', {}).get('empty_bubble_dilation_iterations', 3), + step=1, + label="Empty Bubble Dilation Iterations", + interactive=True + ) + + free_text_dilation = gr.Slider( + minimum=0, + maximum=20, + value=self.get_config_value('manga_settings', {}).get('free_text_dilation_iterations', 3), + step=1, + label="Free Text Dilation Iterations", + interactive=True + ) + + with gr.Accordion("πŸ–ŒοΈ Image Preprocessing", open=False): + preprocessing_enabled = gr.Checkbox( + label="Enable Preprocessing", + value=self.get_config_value('manga_settings', {}).get('preprocessing', {}).get('enabled', False) + ) + + auto_detect_quality = gr.Checkbox( + label="Auto Detect Image Quality", + value=self.get_config_value('manga_settings', {}).get('preprocessing', {}).get('auto_detect_quality', True) + ) + + enhancement_strength = gr.Slider( + minimum=1.0, + maximum=3.0, + value=self.get_config_value('manga_settings', {}).get('preprocessing', {}).get('enhancement_strength', 1.5), + step=0.1, + label="Enhancement Strength", + interactive=True + ) + + denoise_strength = gr.Slider( + minimum=0, + maximum=50, + value=self.get_config_value('manga_settings', {}).get('preprocessing', {}).get('denoise_strength', 10), + step=1, + label="Denoise Strength", + interactive=True + ) + + max_image_dimension = gr.Number( + label="Max Image Dimension (pixels)", + value=self.get_config_value('manga_settings', {}).get('preprocessing', {}).get('max_image_dimension', 2000), + minimum=500 + ) + + chunk_height = gr.Number( + label="Chunk Height for Large Images", + value=self.get_config_value('manga_settings', {}).get('preprocessing', {}).get('chunk_height', 1000), + minimum=500 + ) + + gr.Markdown("#### HD Strategy for Inpainting") + gr.Markdown("*Controls how large images are processed during inpainting*") + + hd_strategy = gr.Radio( + choices=["original", "resize", "crop"], + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('hd_strategy', 'resize'), + label="HD Strategy", + interactive=True, + info="original = legacy full-image; resize/crop = faster" + ) + + hd_strategy_resize_limit = gr.Slider( + minimum=512, + maximum=4096, + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('hd_strategy_resize_limit', 1536), + step=64, + label="Resize Limit (long edge, px)", + info="For resize strategy", + interactive=True + ) + + hd_strategy_crop_margin = gr.Slider( + minimum=0, + maximum=256, + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('hd_strategy_crop_margin', 16), + step=2, + label="Crop Margin (px)", + info="For crop strategy", + interactive=True + ) + + hd_strategy_crop_trigger = gr.Slider( + minimum=256, + maximum=4096, + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('hd_strategy_crop_trigger_size', 1024), + step=64, + label="Crop Trigger Size (px)", + info="Apply crop only if long edge exceeds this", + interactive=True + ) + + gr.Markdown("#### Image Tiling") + gr.Markdown("*Alternative tiling strategy (note: HD Strategy takes precedence)*") + + tiling_enabled = gr.Checkbox( + label="Enable Tiling", + value=self.get_config_value('manga_settings', {}).get('tiling', {}).get('enabled', False) + ) + + tiling_tile_size = gr.Slider( + minimum=256, + maximum=1024, + value=self.get_config_value('manga_settings', {}).get('tiling', {}).get('tile_size', 480), + step=64, + label="Tile Size (px)", + interactive=True + ) + + tiling_tile_overlap = gr.Slider( + minimum=0, + maximum=128, + value=self.get_config_value('manga_settings', {}).get('tiling', {}).get('tile_overlap', 64), + step=16, + label="Tile Overlap (px)", + interactive=True + ) + + with gr.Accordion("🎨 Font & Text Rendering", open=False): + gr.Markdown("#### Font Sizing Algorithm") + + font_algorithm = gr.Radio( + choices=["smart", "simple"], + value=self.get_config_value('manga_settings', {}).get('font_sizing', {}).get('algorithm', 'smart'), + label="Font Sizing Algorithm", + interactive=True + ) + + prefer_larger = gr.Checkbox( + label="Prefer Larger Fonts", + value=self.get_config_value('manga_settings', {}).get('font_sizing', {}).get('prefer_larger', True) + ) + + max_lines = gr.Slider( + minimum=1, + maximum=20, + value=self.get_config_value('manga_settings', {}).get('font_sizing', {}).get('max_lines', 10), + step=1, + label="Maximum Lines Per Bubble", + interactive=True + ) + + line_spacing = gr.Slider( + minimum=0.5, + maximum=3.0, + value=self.get_config_value('manga_settings', {}).get('font_sizing', {}).get('line_spacing', 1.3), + step=0.1, + label="Line Spacing Multiplier", + interactive=True + ) + + bubble_size_factor = gr.Checkbox( + label="Use Bubble Size Factor", + value=self.get_config_value('manga_settings', {}).get('font_sizing', {}).get('bubble_size_factor', True) + ) + + auto_fit_style = gr.Radio( + choices=["balanced", "aggressive", "conservative"], + value=self.get_config_value('manga_settings', {}).get('rendering', {}).get('auto_fit_style', 'balanced'), + label="Auto Fit Style", + interactive=True + ) + + with gr.Accordion("βš™οΈ Advanced Options", open=False): + gr.Markdown("#### Format Detection") + + format_detection = gr.Checkbox( + label="Enable Format Detection (manga/webtoon)", + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('format_detection', True) + ) + + webtoon_mode = gr.Radio( + choices=["auto", "force_manga", "force_webtoon"], + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('webtoon_mode', 'auto'), + label="Webtoon Mode", + interactive=True + ) + + gr.Markdown("#### Inpainting Performance") + + inpaint_batch_size = gr.Slider( + minimum=1, + maximum=32, + value=self.get_config_value('manga_settings', {}).get('inpainting', {}).get('batch_size', 10), + step=1, + label="Batch Size", + interactive=True, + info="Process multiple regions at once" + ) + + inpaint_cache_enabled = gr.Checkbox( + label="Enable inpainting cache (speeds up repeated processing)", + value=self.get_config_value('manga_settings', {}).get('inpainting', {}).get('enable_cache', True) + ) + + gr.Markdown("#### Performance") + + parallel_processing = gr.Checkbox( + label="Enable Parallel Processing", + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('parallel_processing', True) + ) + + max_workers = gr.Slider( + minimum=1, + maximum=8, + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('max_workers', 2), + step=1, + label="Max Worker Threads", + interactive=True + ) + + gr.Markdown("**⚑ Advanced Performance**") + + preload_local_inpainting = gr.Checkbox( + label="Preload local inpainting instances for panel-parallel runs", + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('preload_local_inpainting_for_panels', True), + info="Preloads inpainting models to speed up parallel processing" + ) + + panel_start_stagger = gr.Slider( + minimum=0, + maximum=1000, + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('panel_start_stagger_ms', 30), + step=10, + label="Panel start stagger", + interactive=True, + info="Milliseconds delay between panel starts" + ) + + gr.Markdown("#### Model Optimization") + + torch_precision = gr.Radio( + choices=["fp32", "fp16"], + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('torch_precision', 'fp16'), + label="Torch Precision", + interactive=True + ) + + auto_cleanup_models = gr.Checkbox( + label="Auto Cleanup Models from Memory", + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('auto_cleanup_models', False) + ) + + gr.Markdown("#### Debug Options") + + debug_mode = gr.Checkbox( + label="Enable Debug Mode", + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('debug_mode', False) + ) + + save_intermediate = gr.Checkbox( + label="Save Intermediate Files", + value=self.get_config_value('manga_settings', {}).get('advanced', {}).get('save_intermediate', False) + ) + + concise_pipeline_logs = gr.Checkbox( + label="Concise Pipeline Logs", + value=self.get_config_value('concise_pipeline_logs', True) + ) + + # Button handlers for model management + def download_models_handler(detector_type_val, inpaint_method_val): + """Download selected models""" + messages = [] + + try: + # Download bubble detection model + if detector_type_val: + messages.append(f"πŸ“₯ Downloading {detector_type_val} bubble detector...") + try: + from bubble_detector import BubbleDetector + bd = BubbleDetector() + + if detector_type_val == "rtdetr_onnx": + if bd.load_rtdetr_onnx_model(): + messages.append("βœ… RT-DETR ONNX model downloaded successfully") + else: + messages.append("❌ Failed to download RT-DETR ONNX model") + elif detector_type_val == "rtdetr": + if bd.load_rtdetr_model(): + messages.append("βœ… RT-DETR model downloaded successfully") + else: + messages.append("❌ Failed to download RT-DETR model") + elif detector_type_val == "yolo": + messages.append("ℹ️ YOLO models are downloaded automatically on first use") + except Exception as e: + messages.append(f"❌ Error downloading detector: {str(e)}") + + # Download inpainting model + if inpaint_method_val: + messages.append(f"\nπŸ“₯ Downloading {inpaint_method_val} inpainting model...") + try: + from local_inpainter import LocalInpainter, LAMA_JIT_MODELS + + inpainter = LocalInpainter({}) + + # Map method names to download keys + method_map = { + 'anime_onnx': 'anime_onnx', + 'anime': 'anime', + 'lama': 'lama', + 'lama_onnx': 'lama_onnx', + 'aot': 'aot', + 'aot_onnx': 'aot_onnx' + } + + method_key = method_map.get(inpaint_method_val) + if method_key and method_key in LAMA_JIT_MODELS: + model_info = LAMA_JIT_MODELS[method_key] + messages.append(f"Downloading {model_info['name']}...") + + model_path = inpainter.download_jit_model(method_key) + if model_path: + messages.append(f"βœ… {model_info['name']} downloaded to: {model_path}") + else: + messages.append(f"❌ Failed to download {model_info['name']}") + else: + messages.append(f"ℹ️ {inpaint_method_val} is downloaded automatically on first use") + + except Exception as e: + messages.append(f"❌ Error downloading inpainting model: {str(e)}") + + if not messages: + messages.append("ℹ️ No models selected for download") + + except Exception as e: + messages.append(f"❌ Error during download: {str(e)}") + + return gr.Info("\n".join(messages)) + + def load_models_handler(detector_type_val, inpaint_method_val): + """Load selected models into memory""" + messages = [] + + try: + # Load bubble detection model + if detector_type_val: + messages.append(f"πŸ“¦ Loading {detector_type_val} bubble detector...") + try: + from bubble_detector import BubbleDetector + bd = BubbleDetector() + + if detector_type_val == "rtdetr_onnx": + if bd.load_rtdetr_onnx_model(): + messages.append("βœ… RT-DETR ONNX model loaded successfully") + else: + messages.append("❌ Failed to load RT-DETR ONNX model") + elif detector_type_val == "rtdetr": + if bd.load_rtdetr_model(): + messages.append("βœ… RT-DETR model loaded successfully") + else: + messages.append("❌ Failed to load RT-DETR model") + elif detector_type_val == "yolo": + messages.append("ℹ️ YOLO models are loaded automatically when needed") + except Exception as e: + messages.append(f"❌ Error loading detector: {str(e)}") + + # Load inpainting model + if inpaint_method_val: + messages.append(f"\nπŸ“¦ Loading {inpaint_method_val} inpainting model...") + try: + from local_inpainter import LocalInpainter, LAMA_JIT_MODELS + import os + + inpainter = LocalInpainter({}) + + # Map method names to model keys + method_map = { + 'anime_onnx': 'anime_onnx', + 'anime': 'anime', + 'lama': 'lama', + 'lama_onnx': 'lama_onnx', + 'aot': 'aot', + 'aot_onnx': 'aot_onnx' + } + + method_key = method_map.get(inpaint_method_val) + if method_key: + # First check if model exists, download if not + if method_key in LAMA_JIT_MODELS: + model_info = LAMA_JIT_MODELS[method_key] + cache_dir = os.path.expanduser('~/.cache/inpainting') + model_filename = os.path.basename(model_info['url']) + model_path = os.path.join(cache_dir, model_filename) + + if not os.path.exists(model_path): + messages.append(f"Model not found, downloading first...") + model_path = inpainter.download_jit_model(method_key) + if not model_path: + messages.append(f"❌ Failed to download model") + return gr.Info("\n".join(messages)) + + # Now load the model + if inpainter.load_model(method_key, model_path): + messages.append(f"βœ… {model_info['name']} loaded successfully") + else: + messages.append(f"❌ Failed to load {model_info['name']}") + else: + messages.append(f"ℹ️ {inpaint_method_val} will be loaded automatically when needed") + else: + messages.append(f"ℹ️ Unknown method: {inpaint_method_val}") + + except Exception as e: + messages.append(f"❌ Error loading inpainting model: {str(e)}") + + if not messages: + messages.append("ℹ️ No models selected for loading") + + except Exception as e: + messages.append(f"❌ Error during loading: {str(e)}") + + return gr.Info("\n".join(messages)) + + download_models_btn.click( + fn=download_models_handler, + inputs=[detector_type, local_inpaint_method], + outputs=None + ) + + load_models_btn.click( + fn=load_models_handler, + inputs=[detector_type, local_inpaint_method], + outputs=None + ) + + # Auto-save parallel panel translation settings + def save_parallel_settings(preload_enabled, parallel_enabled, max_workers, stagger_ms): + """Save parallel panel translation settings to config""" + try: + current_config = self.get_current_config_for_update() + # Don't decrypt - just update what we need + + # Initialize nested structure if not exists + if 'manga_settings' not in current_config: + current_config['manga_settings'] = {} + if 'advanced' not in current_config['manga_settings']: + current_config['manga_settings']['advanced'] = {} + + current_config['manga_settings']['advanced']['preload_local_inpainting_for_panels'] = bool(preload_enabled) + current_config['manga_settings']['advanced']['parallel_panel_translation'] = bool(parallel_enabled) + current_config['manga_settings']['advanced']['panel_max_workers'] = int(max_workers) + current_config['manga_settings']['advanced']['panel_start_stagger_ms'] = int(stagger_ms) + + self.save_config(current_config) + return None + except Exception as e: + print(f"Failed to save parallel panel settings: {e}") + return None + + # Auto-save inpainting performance settings + def save_inpainting_settings(batch_size, cache_enabled): + """Save inpainting performance settings to config""" + try: + current_config = self.get_current_config_for_update() + # Don't decrypt - just update what we need + + # Initialize nested structure if not exists + if 'manga_settings' not in current_config: + current_config['manga_settings'] = {} + if 'inpainting' not in current_config['manga_settings']: + current_config['manga_settings']['inpainting'] = {} + + current_config['manga_settings']['inpainting']['batch_size'] = int(batch_size) + current_config['manga_settings']['inpainting']['enable_cache'] = bool(cache_enabled) + + self.save_config(current_config) + return None + except Exception as e: + print(f"Failed to save inpainting settings: {e}") + return None + + # Auto-save preload local inpainting setting + def save_preload_setting(preload_enabled): + """Save preload local inpainting setting to config""" + try: + current_config = self.get_current_config_for_update() + # Don't decrypt - just update what we need + + # Initialize nested structure if not exists + if 'manga_settings' not in current_config: + current_config['manga_settings'] = {} + if 'advanced' not in current_config['manga_settings']: + current_config['manga_settings']['advanced'] = {} + + current_config['manga_settings']['advanced']['preload_local_inpainting_for_panels'] = bool(preload_enabled) + + self.save_config(current_config) + return None + except Exception as e: + print(f"Failed to save preload setting: {e}") + return None + + # Auto-save bubble detection settings + def save_bubble_detection_settings(detector_type_val, rtdetr_conf, bubble_conf, detect_text, detect_empty, detect_free, max_detections, local_method_val): + """Save bubble detection settings to config""" + try: + current_config = self.get_current_config_for_update() + # Don't decrypt - just update what we need + + # Initialize nested structure + if 'manga_settings' not in current_config: + current_config['manga_settings'] = {} + if 'ocr' not in current_config['manga_settings']: + current_config['manga_settings']['ocr'] = {} + if 'inpainting' not in current_config['manga_settings']: + current_config['manga_settings']['inpainting'] = {} + + # Save bubble detection settings + current_config['manga_settings']['ocr']['detector_type'] = detector_type_val + current_config['manga_settings']['ocr']['rtdetr_confidence'] = float(rtdetr_conf) + current_config['manga_settings']['ocr']['bubble_confidence'] = float(bubble_conf) + current_config['manga_settings']['ocr']['detect_text_bubbles'] = bool(detect_text) + current_config['manga_settings']['ocr']['detect_empty_bubbles'] = bool(detect_empty) + current_config['manga_settings']['ocr']['detect_free_text'] = bool(detect_free) + current_config['manga_settings']['ocr']['bubble_max_detections_yolo'] = int(max_detections) + + # Save inpainting method + current_config['manga_settings']['inpainting']['local_method'] = local_method_val + + self.save_config(current_config) + return None + except Exception as e: + print(f"Failed to save bubble detection settings: {e}") + return None + + # All Advanced Settings auto-save handlers removed - use manual Save Config button + + gr.Markdown("\n---\n**Note:** These settings will be saved to your config and applied to all manga translations.") + + # Manual Glossary Extraction Tab + with gr.Tab("πŸ“ Manual Glossary Extraction"): + gr.Markdown(""" + ### Extract character names and terms from EPUB files + Configure extraction settings below, then upload an EPUB file to extract a glossary. + """) + + with gr.Row(): + with gr.Column(): + glossary_epub = gr.File( + label="πŸ“– Upload EPUB File", + file_types=[".epub"] + ) + + with gr.Row(): + extract_btn = gr.Button( + "πŸ” Extract Glossary", + variant="primary", + size="lg", + scale=2 + ) + + stop_glossary_btn = gr.Button( + "⏹️ Stop Extraction", + variant="stop", + size="lg", + visible=False, + scale=1 + ) + + glossary_model = gr.Dropdown( + choices=self.models, + value=self.get_config_value('model', 'gpt-4-turbo'), + label="πŸ€– AI Model", + interactive=True, + allow_custom_value=True, + filterable=True + ) + + glossary_api_key = gr.Textbox( + label="πŸ”‘ API Key", + type="password", + placeholder="Enter your API key", + value=self.get_config_value('api_key', '') + ) + + # Tabs for different settings sections + with gr.Tabs(): + # Extraction Settings Tab + with gr.Tab("Extraction Settings"): + with gr.Accordion("🎯 Targeted Extraction Settings", open=True): + with gr.Row(): + with gr.Column(): + min_freq = gr.Slider( + minimum=1, + maximum=10, + value=self.get_config_value('glossary_min_frequency', 2), + step=1, + label="Min frequency", + info="How many times a name must appear (lower = more terms)" + ) + + max_titles = gr.Slider( + minimum=10, + maximum=100, + value=self.get_config_value('glossary_max_titles', 30), + step=5, + label="Max titles", + info="Limits to prevent huge glossaries" + ) + + max_text_size = gr.Number( + label="Max text size", + value=self.get_config_value('glossary_max_text_size', 50000), + info="Characters to analyze (0 = entire text)" + ) + + max_sentences = gr.Slider( + minimum=50, + maximum=500, + value=self.get_config_value('glossary_max_sentences', 200), + step=10, + label="Max sentences", + info="Maximum sentences to send to AI (increase for more context)" + ) + + with gr.Column(): + max_names_slider = gr.Slider( + minimum=10, + maximum=200, + value=self.get_config_value('glossary_max_names', 50), + step=10, + label="Max names", + info="Maximum number of character names to extract" + ) + + translation_batch = gr.Slider( + minimum=10, + maximum=100, + value=self.get_config_value('glossary_batch_size', 50), + step=5, + label="Translation batch", + info="Terms per API call (larger = faster but may reduce quality)" + ) + + chapter_split_threshold = gr.Number( + label="Chapter split threshold", + value=self.get_config_value('glossary_chapter_split_threshold', 8192), + info="Split large texts into chunks (0 = no splitting)" + ) + + # Filter mode selection + filter_mode = gr.Radio( + choices=[ + "all", + "only_with_honorifics", + "only_without_honorifics" + ], + value=self.get_config_value('glossary_filter_mode', 'all'), + label="Filter mode", + info="What types of names to extract" + ) + + # Strip honorifics checkbox + strip_honorifics = gr.Checkbox( + label="Remove honorifics from extracted names", + value=self.get_config_value('strip_honorifics', True), + info="Remove suffixes like 'λ‹˜', 'さん', 'ε…ˆη”Ÿ' from names" + ) + + # Fuzzy threshold slider + fuzzy_threshold = gr.Slider( + minimum=0.5, + maximum=1.0, + value=self.get_config_value('glossary_fuzzy_threshold', 0.90), + step=0.05, + label="Fuzzy threshold", + info="How similar names must be to match (0.9 = 90% match, 1.0 = exact match)" + ) + + + # Extraction Prompt Tab + with gr.Tab("Extraction Prompt"): + gr.Markdown(""" + ### System Prompt for Extraction + Customize how the AI extracts names and terms from your text. + """) + + extraction_prompt = gr.Textbox( + label="Extraction Template (Use placeholders: {language}, {min_frequency}, {max_names}, {max_titles})", + lines=10, + value=self.get_config_value('manual_glossary_prompt', + "Extract character names and important terms from the following text.\n\n" + "Output format:\n{fields}\n\n" + "Rules:\n- Output ONLY CSV lines in the exact format shown above\n" + "- No headers, no extra text, no JSON\n" + "- One entry per line\n" + "- Leave gender empty for terms (just end with comma)") + ) + + reset_extraction_prompt_btn = gr.Button( + "Reset to Default", + variant="secondary", + size="sm" + ) + + # Format Instructions Tab + with gr.Tab("Format Instructions"): + gr.Markdown(""" + ### Output Format Instructions + These instructions tell the AI exactly how to format the extracted glossary. + """) + + format_instructions = gr.Textbox( + label="Format Instructions (Use placeholder: {text_sample})", + lines=10, + value=self.get_config_value('glossary_format_instructions', + "Return the results in EXACT CSV format with this header:\n" + "type,raw_name,translated_name\n\n" + "For example:\n" + "character,κΉ€μƒν˜„,Kim Sang-hyun\n" + "character,갈편제,Gale Hardest\n" + "term,λ§ˆλ²•μ‚¬,Mage\n\n" + "Only include terms that actually appear in the text.\n" + "Do not use quotes around values unless they contain commas.\n\n" + "Text to analyze:\n{text_sample}") + ) + + use_legacy_csv = gr.Checkbox( + label="Use legacy CSV format", + value=self.get_config_value('glossary_use_legacy_csv', False), + info="When disabled: Uses clean format with sections (===CHARACTERS===). When enabled: Uses traditional CSV format with repeated type columns." + ) + + with gr.Column(): + # Add logo and status at top + with gr.Row(): + gr.Image( + value="Halgakos.png", + label=None, + show_label=False, + width=80, + height=80, + interactive=False, + show_download_button=False, + container=False + ) + glossary_status_message = gr.Markdown( + value="### Ready to extract\nUpload an EPUB file and click 'Extract Glossary' to begin.", + visible=True + ) + + # Progress section (similar to translation tabs) + with gr.Group(visible=False) as glossary_progress_group: + gr.Markdown("### Progress") + glossary_progress_text = gr.Textbox( + label="πŸ“¨ Current Status", + value="Ready to start", + interactive=False, + lines=1 + ) + glossary_progress_bar = gr.Slider( + minimum=0, + maximum=100, + value=0, + step=1, + label="πŸ“‹ Extraction Progress", + interactive=False, + show_label=True + ) + + glossary_logs = gr.Textbox( + label="πŸ“‹ Extraction Logs", + lines=20, + max_lines=30, + value="Ready to extract. Upload an EPUB file and configure settings.", + visible=True, + interactive=False + ) + + glossary_output = gr.File( + label="πŸ“₯ Download Glossary CSV", + visible=False + ) + + glossary_status = gr.Textbox( + label="Final Status", + lines=3, + max_lines=5, + visible=False, + interactive=False + ) + + extract_btn.click( + fn=self.extract_glossary_with_stop, + inputs=[ + glossary_epub, + glossary_model, + glossary_api_key, + min_freq, + max_names_slider, + max_titles, + max_text_size, + max_sentences, + translation_batch, + chapter_split_threshold, + filter_mode, + strip_honorifics, + fuzzy_threshold, + extraction_prompt, + format_instructions, + use_legacy_csv + ], + outputs=[ + glossary_output, + glossary_status_message, + glossary_progress_group, + glossary_logs, + glossary_status, + glossary_progress_text, + glossary_progress_bar, + extract_btn, + stop_glossary_btn + ] + ) + + # Stop button handler + stop_glossary_btn.click( + fn=self.stop_glossary_extraction, + inputs=[], + outputs=[extract_btn, stop_glossary_btn, glossary_status] + ) + + # QA Scanner Tab + with gr.Tab("πŸ” QA Scanner"): + gr.Markdown(""" + ### Quick Scan for Translation Quality + Scan translated content for common issues like untranslated text, formatting problems, and quality concerns. + + **Supported inputs:** + - πŸ“ Output folder containing extracted HTML/XHTML files + - πŸ“– EPUB file (will be automatically extracted and scanned) + - πŸ“¦ ZIP file containing HTML/XHTML files + """) + + with gr.Row(): + with gr.Column(): + # Check if running on Hugging Face Spaces + is_hf_spaces = os.getenv('SPACE_ID') is not None or os.getenv('HF_SPACES') == 'true' + + if is_hf_spaces: + gr.Markdown(""" + **πŸ€— Hugging Face Spaces Mode** + Upload an EPUB or ZIP file containing the translated content. + The scanner will extract and analyze the HTML/XHTML files inside. + """) + qa_folder_path = gr.File( + label="πŸ“‚ Upload EPUB or ZIP file", + file_types=[".epub", ".zip"], + type="filepath" + ) + else: + qa_folder_path = gr.Textbox( + label="πŸ“ Path to Folder, EPUB, or ZIP", + placeholder="Enter path to: folder with HTML files, EPUB file, or ZIP file", + info="Can be a folder path, or direct path to an EPUB/ZIP file" + ) + + with gr.Row(): + qa_scan_btn = gr.Button( + "⚑ Quick Scan", + variant="primary", + size="lg", + scale=2 + ) + + stop_qa_btn = gr.Button( + "⏹️ Stop Scan", + variant="stop", + size="lg", + visible=False, + scale=1 + ) + + with gr.Accordion("βš™οΈ Quick Scan Settings", open=True): + gr.Markdown(""" + **Quick Scan Mode (85% threshold, Speed optimized)** + - 3-5x faster scanning + - Checks consecutive chapters only + - Simplified analysis + - Good for large libraries + - Minimal resource usage + """) + + # Foreign Character Detection + gr.Markdown("#### Foreign Character Detection") + min_foreign_chars = gr.Slider( + minimum=0, + maximum=50, + value=self.get_config_value('qa_min_foreign_chars', 10), + step=1, + label="Minimum foreign characters to flag", + info="0 = always flag, higher = more tolerant" + ) + + # Detection Options + gr.Markdown("#### Detection Options") + check_repetition = gr.Checkbox( + label="Check for excessive repetition", + value=self.get_config_value('qa_check_repetition', True) + ) + + check_glossary_leakage = gr.Checkbox( + label="Check for glossary leakage (raw glossary entries in translation)", + value=self.get_config_value('qa_check_glossary_leakage', True) + ) + + # File Processing + gr.Markdown("#### File Processing") + min_file_length = gr.Slider( + minimum=0, + maximum=5000, + value=self.get_config_value('qa_min_file_length', 0), + step=100, + label="Minimum file length (characters)", + info="Skip files shorter than this" + ) + + # Additional Checks + gr.Markdown("#### Additional Checks") + check_multiple_headers = gr.Checkbox( + label="Detect files with 2 or more headers (h1-h6 tags)", + value=self.get_config_value('qa_check_multiple_headers', True), + info="Identifies files that may have been incorrectly split or merged" + ) + + check_missing_html = gr.Checkbox( + label="Flag HTML files with missing <html> tag", + value=self.get_config_value('qa_check_missing_html', True), + info="Checks if HTML files have proper structure" + ) + + check_insufficient_paragraphs = gr.Checkbox( + label="Check for insufficient paragraph tags", + value=self.get_config_value('qa_check_insufficient_paragraphs', True) + ) + + min_paragraph_percentage = gr.Slider( + minimum=10, + maximum=90, + value=self.get_config_value('qa_min_paragraph_percentage', 30), + step=5, + label="Minimum text in <p> tags (%)", + info="Files with less than this percentage will be flagged" + ) + + # Report Settings + gr.Markdown("#### Report Settings") + + report_format = gr.Radio( + choices=["summary", "detailed", "verbose"], + value=self.get_config_value('qa_report_format', 'detailed'), + label="Report format", + info="Summary = brief overview, Detailed = recommended, Verbose = all data" + ) + + auto_save_report = gr.Checkbox( + label="Automatically save report after scan", + value=self.get_config_value('qa_auto_save_report', True) + ) + + with gr.Column(): + # Add logo and status at top + with gr.Row(): + gr.Image( + value="Halgakos.png", + label=None, + show_label=False, + width=80, + height=80, + interactive=False, + show_download_button=False, + container=False + ) + qa_status_message = gr.Markdown( + value="### Ready to scan\nEnter the path to your output folder and click 'Quick Scan' to begin.", + visible=True + ) + + # Progress section + with gr.Group(visible=False) as qa_progress_group: + gr.Markdown("### Progress") + qa_progress_text = gr.Textbox( + label="πŸ“¨ Current Status", + value="Ready to start", + interactive=False, + lines=1 + ) + qa_progress_bar = gr.Slider( + minimum=0, + maximum=100, + value=0, + step=1, + label="πŸ“‹ Scan Progress", + interactive=False, + show_label=True + ) + + qa_logs = gr.Textbox( + label="πŸ“‹ Scan Logs", + lines=20, + max_lines=30, + value="Ready to scan. Enter output folder path and configure settings.", + visible=True, + interactive=False + ) + + qa_report = gr.File( + label="πŸ“„ Download QA Report", + visible=False + ) + + qa_status = gr.Textbox( + label="Final Status", + lines=3, + max_lines=5, + visible=False, + interactive=False + ) + + # QA Scan button handler + qa_scan_btn.click( + fn=self.run_qa_scan_with_stop, + inputs=[ + qa_folder_path, + min_foreign_chars, + check_repetition, + check_glossary_leakage, + min_file_length, + check_multiple_headers, + check_missing_html, + check_insufficient_paragraphs, + min_paragraph_percentage, + report_format, + auto_save_report + ], + outputs=[ + qa_report, + qa_status_message, + qa_progress_group, + qa_logs, + qa_status, + qa_progress_text, + qa_progress_bar, + qa_scan_btn, + stop_qa_btn + ] + ) + + # Stop button handler + stop_qa_btn.click( + fn=self.stop_qa_scan, + inputs=[], + outputs=[qa_scan_btn, stop_qa_btn, qa_status] + ) + + # Settings Tab + with gr.Tab("βš™οΈ Settings"): + gr.Markdown("### Configuration") + + gr.Markdown("#### Translation Profiles") + gr.Markdown("Profiles are loaded from your `config_web.json` file. The web interface has its own separate configuration.") + + with gr.Accordion("View All Profiles", open=False): + profiles_text = "\n\n".join( + [f"**{name}**:\n```\n{prompt[:200]}...\n```" + for name, prompt in self.profiles.items()] + ) + gr.Markdown(profiles_text if profiles_text else "No profiles found") + + gr.Markdown("---") + gr.Markdown("#### Advanced Translation Settings") + + with gr.Row(): + with gr.Column(): + thread_delay = gr.Slider( + minimum=0, + maximum=5, + value=self.get_config_value('thread_submission_delay', 0.1), + step=0.1, + label="Threading delay (s)", + interactive=True + ) + + api_delay = gr.Slider( + minimum=0, + maximum=10, + value=self.get_config_value('api_call_delay', 0.5), + step=0.1, + label="API call delay (s) [SEND_INTERVAL_SECONDS]", + interactive=True, + info="Delay between API calls to avoid rate limits" + ) + + chapter_range = gr.Textbox( + label="Chapter range (e.g., 5-10)", + value=self.get_config_value('chapter_range', ''), + placeholder="Leave empty for all chapters" + ) + + token_limit = gr.Number( + label="Input Token limit", + value=self.get_config_value('token_limit', 200000), + minimum=0 + ) + + disable_token_limit = gr.Checkbox( + label="Disable Input Token Limit", + value=self.get_config_value('token_limit_disabled', False) + ) + + output_token_limit = gr.Number( + label="Output Token limit", + value=self.get_config_value('max_output_tokens', 16000), + minimum=0 + ) + + with gr.Column(): + contextual = gr.Checkbox( + label="Contextual Translation", + value=self.get_config_value('contextual', False) + ) + + history_limit = gr.Number( + label="Translation History Limit", + value=self.get_config_value('translation_history_limit', 2), + minimum=0 + ) + + rolling_history = gr.Checkbox( + label="Rolling History Window", + value=self.get_config_value('translation_history_rolling', False) + ) + + batch_translation = gr.Checkbox( + label="Batch Translation", + value=self.get_config_value('batch_translation', True) + ) + + batch_size = gr.Number( + label="Batch Size", + value=self.get_config_value('batch_size', 10), + minimum=1 + ) + + gr.Markdown("---") + gr.Markdown("#### Chapter Processing Options") + + with gr.Row(): + with gr.Column(): + # Chapter Header Translation + batch_translate_headers = gr.Checkbox( + label="Batch Translate Headers", + value=self.get_config_value('batch_translate_headers', False) + ) + + headers_per_batch = gr.Number( + label="Headers per batch", + value=self.get_config_value('headers_per_batch', 400), + minimum=1 + ) + + # NCX and CSS options + use_ncx_navigation = gr.Checkbox( + label="Use NCX-only Navigation (Compatibility Mode)", + value=self.get_config_value('use_ncx_navigation', False) + ) + + attach_css_to_chapters = gr.Checkbox( + label="Attach CSS to Chapters (Fixes styling issues)", + value=self.get_config_value('attach_css_to_chapters', False) + ) + + retain_source_extension = gr.Checkbox( + label="Retain source extension (no 'response_' prefix)", + value=self.get_config_value('retain_source_extension', True) + ) + + with gr.Column(): + # Conservative Batching + use_conservative_batching = gr.Checkbox( + label="Use Conservative Batching", + value=self.get_config_value('use_conservative_batching', False), + info="Groups chapters in batches of 3x batch size for memory management" + ) + + # Gemini API Safety + disable_gemini_safety = gr.Checkbox( + label="Disable Gemini API Safety Filters", + value=self.get_config_value('disable_gemini_safety', False), + info="⚠️ Disables ALL content safety filters for Gemini models (BLOCK_NONE)" + ) + + # OpenRouter Options + use_http_openrouter = gr.Checkbox( + label="Use HTTP-only for OpenRouter (bypass SDK)", + value=self.get_config_value('use_http_openrouter', False), + info="Direct HTTP POST with explicit headers" + ) + + disable_openrouter_compression = gr.Checkbox( + label="Disable compression for OpenRouter (Accept-Encoding)", + value=self.get_config_value('disable_openrouter_compression', False), + info="Sends Accept-Encoding: identity for uncompressed responses" + ) + + gr.Markdown("---") + gr.Markdown("#### Chapter Extraction Settings") + + with gr.Row(): + with gr.Column(): + gr.Markdown("**Text Extraction Method:**") + text_extraction_method = gr.Radio( + choices=["standard", "enhanced"], + value=self.get_config_value('text_extraction_method', 'standard'), + label="", + info="Standard uses BeautifulSoup, Enhanced uses html2text", + interactive=True + ) + + gr.Markdown("β€’ **Standard (BeautifulSoup)** - Traditional HTML parsing, fast and reliable") + gr.Markdown("β€’ **Enhanced (html2text)** - Superior Unicode handling, cleaner text extraction") + + with gr.Column(): + gr.Markdown("**File Filtering Level:**") + file_filtering_level = gr.Radio( + choices=["smart", "comprehensive", "full"], + value=self.get_config_value('file_filtering_level', 'smart'), + label="", + info="Controls which files are extracted from EPUBs", + interactive=True + ) + + gr.Markdown("β€’ **Smart (Aggressive Filtering)** - Skips navigation, TOC, copyright files") + gr.Markdown("β€’ **Moderate** - Only skips obvious navigation files") + gr.Markdown("β€’ **Full (No Filtering)** - Extracts ALL HTML/XHTML files") + + gr.Markdown("---") + gr.Markdown("#### Response Handling & Retry Logic") + + with gr.Row(): + with gr.Column(): + gr.Markdown("**GPT-5 Thinking (OpenRouter/OpenAI-style)**") + enable_gpt_thinking = gr.Checkbox( + label="Enable GPT / OR Thinking", + value=self.get_config_value('enable_gpt_thinking', True), + info="Controls GPT-5 and OpenRouter reasoning" + ) + + with gr.Row(): + gpt_thinking_effort = gr.Dropdown( + choices=["low", "medium", "high"], + value=self.get_config_value('gpt_thinking_effort', 'medium'), + label="Effort", + interactive=True + ) + + or_thinking_tokens = gr.Number( + label="OR Thinking Tokens", + value=self.get_config_value('or_thinking_tokens', 2000), + minimum=0, + maximum=50000, + info="tokens" + ) + + gr.Markdown("*Provide Tokens to force a max token budget for other models; GPT-5 only uses Effort (low/medium/high)*", elem_classes=["markdown-small"]) + + with gr.Column(): + gr.Markdown("**Gemini Thinking Mode**") + enable_gemini_thinking = gr.Checkbox( + label="Enable Gemini Thinking", + value=self.get_config_value('enable_gemini_thinking', False), + info="Control Gemini's thinking process", + interactive=True + ) + + gemini_thinking_budget = gr.Number( + label="Budget", + value=self.get_config_value('gemini_thinking_budget', 0), + minimum=0, + maximum=50000, + info="tokens (0 = disabled)", + interactive=True + ) + + gr.Markdown("*0 = disabled, 512-24576 = limited thinking*", elem_classes=["markdown-small"]) + + gr.Markdown("---") + gr.Markdown("πŸ”’ **API keys are encrypted** when saved to config using AES encryption.") + + save_api_key = gr.Checkbox( + label="Save API Key (Encrypted)", + value=True + ) + + save_status = gr.Textbox(label="Settings Status", value="Use the 'Save Config' button to save changes", interactive=False) + + # Hidden HTML component for JavaScript execution + js_executor = gr.HTML("", visible=False) + + # Auto-save function for settings tab + def save_settings_tab(thread_delay_val, api_delay_val, chapter_range_val, token_limit_val, disable_token_limit_val, output_token_limit_val, contextual_val, history_limit_val, rolling_history_val, batch_translation_val, batch_size_val, save_api_key_val): + """Save settings from the Settings tab""" + try: + current_config = self.get_current_config_for_update() + # Don't decrypt - just update non-encrypted fields + + # Update settings + current_config['thread_submission_delay'] = float(thread_delay_val) + current_config['api_call_delay'] = float(api_delay_val) + current_config['chapter_range'] = str(chapter_range_val) + current_config['token_limit'] = int(token_limit_val) + current_config['token_limit_disabled'] = bool(disable_token_limit_val) + current_config['max_output_tokens'] = int(output_token_limit_val) + current_config['contextual'] = bool(contextual_val) + current_config['translation_history_limit'] = int(history_limit_val) + current_config['translation_history_rolling'] = bool(rolling_history_val) + current_config['batch_translation'] = bool(batch_translation_val) + current_config['batch_size'] = int(batch_size_val) + + # CRITICAL: Update environment variables immediately + os.environ['SEND_INTERVAL_SECONDS'] = str(api_delay_val) + os.environ['THREAD_SUBMISSION_DELAY'] = str(thread_delay_val) + print(f"βœ… Updated SEND_INTERVAL_SECONDS = {api_delay_val}s") + print(f"βœ… Updated THREAD_SUBMISSION_DELAY = {thread_delay_val}s") + + # Save to file + self.save_config(current_config) + + # JavaScript to save to localStorage + js_code = """ + <script> + (function() { + // Save individual settings to localStorage + window.saveToLocalStorage('thread_delay', %f); + window.saveToLocalStorage('api_delay', %f); + window.saveToLocalStorage('chapter_range', '%s'); + window.saveToLocalStorage('token_limit', %d); + window.saveToLocalStorage('disable_token_limit', %s); + window.saveToLocalStorage('output_token_limit', %d); + window.saveToLocalStorage('contextual', %s); + window.saveToLocalStorage('history_limit', %d); + window.saveToLocalStorage('rolling_history', %s); + window.saveToLocalStorage('batch_translation', %s); + window.saveToLocalStorage('batch_size', %d); + console.log('Settings saved to localStorage'); + })(); + </script> + """ % ( + thread_delay_val, api_delay_val, chapter_range_val, token_limit_val, + str(disable_token_limit_val).lower(), output_token_limit_val, + str(contextual_val).lower(), history_limit_val, + str(rolling_history_val).lower(), str(batch_translation_val).lower(), + batch_size_val + ) + + return "βœ… Settings saved successfully", js_code + except Exception as e: + return f"❌ Failed to save: {str(e)}", "" + + # Settings tab auto-save handlers removed - use manual Save Config button + + # Token sync handlers removed - use manual Save Config button + + # Help Tab + with gr.Tab("❓ Help"): + gr.Markdown(""" + ## How to Use Glossarion + + ### Translation + 1. Upload an EPUB file + 2. Select AI model (GPT-4, Claude, etc.) + 3. Enter your API key + 4. Click "Translate" + 5. Download the translated EPUB + + ### Manga Translation + 1. Upload manga image(s) (PNG, JPG, etc.) + 2. Select AI model and enter API key + 3. Choose translation profile (e.g., Manga_JP, Manga_KR) + 4. Configure OCR settings (Google Cloud Vision recommended) + 5. Enable bubble detection and inpainting for best results + 6. Click "Translate Manga" + + ### Glossary Extraction + 1. Upload an EPUB file + 2. Configure extraction settings + 3. Click "Extract Glossary" + 4. Use the CSV in future translations + + ### API Keys + - **OpenAI**: Get from https://platform.openai.com/api-keys + - **Anthropic**: Get from https://console.anthropic.com/ + + ### Translation Profiles + Profiles contain detailed translation instructions and rules. + Select a profile that matches your source language and style preferences. + + You can create and edit profiles in the desktop application. + + ### Tips + - Use glossaries for consistent character name translation + - Lower temperature (0.1-0.3) for more literal translations + - Higher temperature (0.5-0.7) for more creative translations + """) + + # Create a comprehensive load function that refreshes ALL values + def load_all_settings(): + """Load all settings from config file on page refresh""" + # Reload config to get latest values + self.config = self.load_config() + self.decrypted_config = decrypt_config(self.config.copy()) if API_KEY_ENCRYPTION_AVAILABLE else self.config.copy() + + # CRITICAL: Reload profiles from config after reloading config + self.profiles = self.default_prompts.copy() + config_profiles = self.config.get('prompt_profiles', {}) + if config_profiles: + self.profiles.update(config_profiles) + + # Helper function to convert RGB arrays to hex + def to_hex_color(color_value, default='#000000'): + if isinstance(color_value, (list, tuple)) and len(color_value) >= 3: + return '#{:02x}{:02x}{:02x}'.format(int(color_value[0]), int(color_value[1]), int(color_value[2])) + elif isinstance(color_value, str): + return color_value if color_value.startswith('#') else default + return default + + # Return values for all tracked components + return [ + self.get_config_value('model', 'gpt-4-turbo'), # epub_model + self.get_config_value('api_key', ''), # epub_api_key + self.get_config_value('active_profile', list(self.profiles.keys())[0] if self.profiles else ''), # epub_profile + self.profiles.get(self.get_config_value('active_profile', ''), ''), # epub_system_prompt + self.get_config_value('temperature', 0.3), # epub_temperature + self.get_config_value('max_output_tokens', 16000), # epub_max_tokens + self.get_config_value('enable_image_translation', False), # enable_image_translation + self.get_config_value('enable_auto_glossary', False), # enable_auto_glossary + self.get_config_value('append_glossary_to_prompt', True), # append_glossary + # Auto glossary settings + self.get_config_value('glossary_min_frequency', 2), # auto_glossary_min_freq + self.get_config_value('glossary_max_names', 50), # auto_glossary_max_names + self.get_config_value('glossary_max_titles', 30), # auto_glossary_max_titles + self.get_config_value('glossary_batch_size', 50), # auto_glossary_batch_size + self.get_config_value('glossary_filter_mode', 'all'), # auto_glossary_filter_mode + self.get_config_value('glossary_fuzzy_threshold', 0.90), # auto_glossary_fuzzy_threshold + # Manual glossary extraction settings + self.get_config_value('manual_glossary_min_frequency', self.get_config_value('glossary_min_frequency', 2)), # min_freq + self.get_config_value('manual_glossary_max_names', self.get_config_value('glossary_max_names', 50)), # max_names_slider + self.get_config_value('manual_glossary_max_titles', self.get_config_value('glossary_max_titles', 30)), # max_titles + self.get_config_value('glossary_max_text_size', 50000), # max_text_size + self.get_config_value('glossary_max_sentences', 200), # max_sentences + self.get_config_value('manual_glossary_batch_size', self.get_config_value('glossary_batch_size', 50)), # translation_batch + self.get_config_value('glossary_chapter_split_threshold', 8192), # chapter_split_threshold + self.get_config_value('manual_glossary_filter_mode', self.get_config_value('glossary_filter_mode', 'all')), # filter_mode + self.get_config_value('strip_honorifics', True), # strip_honorifics + self.get_config_value('manual_glossary_fuzzy_threshold', self.get_config_value('glossary_fuzzy_threshold', 0.90)), # fuzzy_threshold + # Chapter processing options + self.get_config_value('batch_translate_headers', False), # batch_translate_headers + self.get_config_value('headers_per_batch', 400), # headers_per_batch + self.get_config_value('use_ncx_navigation', False), # use_ncx_navigation + self.get_config_value('attach_css_to_chapters', False), # attach_css_to_chapters + self.get_config_value('retain_source_extension', True), # retain_source_extension + self.get_config_value('use_conservative_batching', False), # use_conservative_batching + self.get_config_value('disable_gemini_safety', False), # disable_gemini_safety + self.get_config_value('use_http_openrouter', False), # use_http_openrouter + self.get_config_value('disable_openrouter_compression', False), # disable_openrouter_compression + self.get_config_value('text_extraction_method', 'standard'), # text_extraction_method + self.get_config_value('file_filtering_level', 'smart'), # file_filtering_level + # QA report format + self.get_config_value('qa_report_format', 'detailed'), # report_format + # Thinking mode settings + self.get_config_value('enable_gpt_thinking', True), # enable_gpt_thinking + self.get_config_value('gpt_thinking_effort', 'medium'), # gpt_thinking_effort + self.get_config_value('or_thinking_tokens', 2000), # or_thinking_tokens + self.get_config_value('enable_gemini_thinking', False), # enable_gemini_thinking - disabled by default + self.get_config_value('gemini_thinking_budget', 0), # gemini_thinking_budget - 0 = disabled + # Manga settings + self.get_config_value('model', 'gpt-4-turbo'), # manga_model + self.get_config_value('api_key', ''), # manga_api_key + self.get_config_value('active_profile', list(self.profiles.keys())[0] if self.profiles else ''), # manga_profile + self.profiles.get(self.get_config_value('active_profile', ''), ''), # manga_system_prompt + self.get_config_value('ocr_provider', 'custom-api'), # ocr_provider + self.get_config_value('azure_vision_key', ''), # azure_key + self.get_config_value('azure_vision_endpoint', ''), # azure_endpoint + self.get_config_value('bubble_detection_enabled', True), # bubble_detection + self.get_config_value('inpainting_enabled', True), # inpainting + self.get_config_value('manga_font_size_mode', 'auto'), # font_size_mode + self.get_config_value('manga_font_size', 24), # font_size + self.get_config_value('manga_font_multiplier', 1.0), # font_multiplier + self.get_config_value('manga_min_font_size', 12), # min_font_size + self.get_config_value('manga_max_font_size', 48), # max_font_size + # Convert colors to hex format if they're stored as RGB arrays (white text, black shadow like manga integration) + to_hex_color(self.get_config_value('manga_text_color', [255, 255, 255]), '#FFFFFF'), # text_color_rgb - default white + self.get_config_value('manga_shadow_enabled', True), # shadow_enabled + to_hex_color(self.get_config_value('manga_shadow_color', [0, 0, 0]), '#000000'), # shadow_color - default black + self.get_config_value('manga_shadow_offset_x', 2), # shadow_offset_x + self.get_config_value('manga_shadow_offset_y', 2), # shadow_offset_y + self.get_config_value('manga_shadow_blur', 0), # shadow_blur + self.get_config_value('manga_bg_opacity', 130), # bg_opacity + self.get_config_value('manga_bg_style', 'circle'), # bg_style + self.get_config_value('manga_settings', {}).get('advanced', {}).get('parallel_panel_translation', False), # parallel_panel_translation + self.get_config_value('manga_settings', {}).get('advanced', {}).get('panel_max_workers', 7), # panel_max_workers + ] + + + # SECURITY: Save Config button DISABLED to prevent API keys from being saved to persistent storage on HF Spaces + # This is a critical security measure to prevent API key leakage in shared environments + # save_config_btn.click( + # fn=save_all_config, + # inputs=[ + # # EPUB tab fields + # epub_model, epub_api_key, epub_profile, epub_temperature, epub_max_tokens, + # enable_image_translation, enable_auto_glossary, append_glossary, + # # Auto glossary settings + # auto_glossary_min_freq, auto_glossary_max_names, auto_glossary_max_titles, + # auto_glossary_batch_size, auto_glossary_filter_mode, auto_glossary_fuzzy_threshold, + # enable_post_translation_scan, + # # Manual glossary extraction settings + # min_freq, max_names_slider, max_titles, + # max_text_size, max_sentences, translation_batch, + # chapter_split_threshold, filter_mode, strip_honorifics, + # fuzzy_threshold, extraction_prompt, format_instructions, + # use_legacy_csv, + # # QA Scanner settings + # min_foreign_chars, check_repetition, check_glossary_leakage, + # min_file_length, check_multiple_headers, check_missing_html, + # check_insufficient_paragraphs, min_paragraph_percentage, + # report_format, auto_save_report, + # # Chapter processing options + # batch_translate_headers, headers_per_batch, use_ncx_navigation, + # attach_css_to_chapters, retain_source_extension, + # use_conservative_batching, disable_gemini_safety, + # use_http_openrouter, disable_openrouter_compression, + # text_extraction_method, file_filtering_level, + # # Thinking mode settings + # enable_gpt_thinking, gpt_thinking_effort, or_thinking_tokens, + # enable_gemini_thinking, gemini_thinking_budget, + # # Manga tab fields + # manga_model, manga_api_key, manga_profile, + # ocr_provider, azure_key, azure_endpoint, + # bubble_detection, inpainting, + # font_size_mode, font_size, font_multiplier, min_font_size, max_font_size, + # text_color_rgb, shadow_enabled, shadow_color, + # shadow_offset_x, shadow_offset_y, shadow_blur, + # bg_opacity, bg_style, + # parallel_panel_translation, panel_max_workers, + # # Advanced Settings fields + # detector_type, rtdetr_confidence, bubble_confidence, + # detect_text_bubbles, detect_empty_bubbles, detect_free_text, bubble_max_detections, + # local_inpaint_method, webtoon_mode, + # inpaint_batch_size, inpaint_cache_enabled, + # parallel_processing, max_workers, + # preload_local_inpainting, panel_start_stagger, + # torch_precision, auto_cleanup_models, + # debug_mode, save_intermediate, concise_pipeline_logs + # ], + # outputs=[save_status_text] + # ) + + # Add load handler to restore settings on page load + app.load( + fn=load_all_settings, + inputs=[], + outputs=[ + epub_model, epub_api_key, epub_profile, epub_system_prompt, epub_temperature, epub_max_tokens, + enable_image_translation, enable_auto_glossary, append_glossary, + # Auto glossary settings + auto_glossary_min_freq, auto_glossary_max_names, auto_glossary_max_titles, + auto_glossary_batch_size, auto_glossary_filter_mode, auto_glossary_fuzzy_threshold, + # Manual glossary extraction settings + min_freq, max_names_slider, max_titles, + max_text_size, max_sentences, translation_batch, + chapter_split_threshold, filter_mode, strip_honorifics, + fuzzy_threshold, + # Chapter processing options + batch_translate_headers, headers_per_batch, use_ncx_navigation, + attach_css_to_chapters, retain_source_extension, + use_conservative_batching, disable_gemini_safety, + use_http_openrouter, disable_openrouter_compression, + text_extraction_method, file_filtering_level, + report_format, + # Thinking mode settings + enable_gpt_thinking, gpt_thinking_effort, or_thinking_tokens, + enable_gemini_thinking, gemini_thinking_budget, + # Manga settings + manga_model, manga_api_key, manga_profile, manga_system_prompt, + ocr_provider, azure_key, azure_endpoint, bubble_detection, inpainting, + font_size_mode, font_size, font_multiplier, min_font_size, max_font_size, + text_color_rgb, shadow_enabled, shadow_color, shadow_offset_x, shadow_offset_y, + shadow_blur, bg_opacity, bg_style, parallel_panel_translation, panel_max_workers + ] + ) + + return app + + +def main(): + """Launch Gradio web app""" + print("πŸš€ Starting Glossarion Web Interface...") + + # Check if running on Hugging Face Spaces + is_spaces = os.getenv('SPACE_ID') is not None or os.getenv('HF_SPACES') == 'true' + if is_spaces: + print("πŸ€— Running on Hugging Face Spaces") + print(f"πŸ“ Space ID: {os.getenv('SPACE_ID', 'Unknown')}") + print(f"πŸ“ Files in current directory: {len(os.listdir('.'))} items") + print(f"πŸ“ Working directory: {os.getcwd()}") + print(f"😎 Available manga modules: {MANGA_TRANSLATION_AVAILABLE}") + else: + print("🏠 Running locally") + + web_app = GlossarionWeb() + app = web_app.create_interface() + + # Set favicon with absolute path if available (skip for Spaces) + favicon_path = None + if not is_spaces and os.path.exists("Halgakos.ico"): + favicon_path = os.path.abspath("Halgakos.ico") + print(f"βœ… Using favicon: {favicon_path}") + elif not is_spaces: + print("⚠️ Halgakos.ico not found") + + # Launch with options appropriate for environment + launch_args = { + "server_name": "0.0.0.0", # Allow external access + "server_port": 7860, + "share": False, + "show_error": True, + } + + # Only add favicon for non-Spaces environments + if not is_spaces and favicon_path: + launch_args["favicon_path"] = favicon_path + + app.launch(**launch_args) + + +if __name__ == "__main__": + main() \ No newline at end of file