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
,,
,
,
,
,
,
') + content.count('
]+>', '', content)) + if text_length > 0: + p_text = re.findall(r'
]*>(.*?)
', content, re.DOTALL) + p_text_length = sum(len(t) for t in p_text) + percentage = (p_text_length / text_length) * 100 + if percentage < min_paragraph_percentage: + file_issues.append(f"Only {percentage:.1f}% text in tags")
+
+ # Simulated additional checks
+ if check_repetition and random.random() > 0.85:
+ file_issues.append("Excessive repetition detected")
+
+ if check_glossary_leakage and random.random() > 0.9:
+ file_issues.append("Glossary leakage detected")
+
+ # Report issues found
+ if file_issues:
+ for issue in file_issues:
+ issues_found.append(f" β οΈ {file_name}: {issue}")
+ scan_logs.append(f" β οΈ Issue: {issue}")
+ else:
+ scan_logs.append(f" β
No issues found")
+
+ except Exception as e:
+ scan_logs.append(f" β Error reading file: {str(e)}")
+
+ # Update logs periodically
+ if len(scan_logs) > 100:
+ scan_logs = scan_logs[-100:] # Keep only last 100 logs
+
+ yield gr.update(visible=False), None, gr.update(visible=True), "\n".join(scan_logs), gr.update(visible=False), f"Scanning {file_name}...", progress
+
+ # Generate report
+ scan_logs.append("\nπ Generating report...")
+ yield gr.update(visible=False), None, gr.update(visible=True), "\n".join(scan_logs), gr.update(visible=False), "Generating report...", 95
+
+ # Create report content based on selected format
+ if report_format == "summary":
+ # Summary format - brief overview only
+ report_content = "QA SCAN REPORT - SUMMARY\n"
+ report_content += "=" * 50 + "\n\n"
+ report_content += f"Total files scanned: {total_files}\n"
+ report_content += f"Issues found: {len(issues_found)}\n\n"
+ if issues_found:
+ report_content += f"Files with issues: {min(len(issues_found), 10)} (showing first 10)\n"
+ report_content += "\n".join(issues_found[:10])
+ else:
+ report_content += "β
No issues detected."
+
+ elif report_format == "verbose":
+ # Verbose format - all data including passed files
+ report_content = "QA SCAN REPORT - VERBOSE (ALL DATA)\n"
+ report_content += "=" * 50 + "\n\n"
+ from datetime import datetime
+ report_content += f"Scan Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
+ report_content += f"Folder Scanned: {folder_path}\n"
+ report_content += f"Total files scanned: {total_files}\n"
+ report_content += f"Issues found: {len(issues_found)}\n"
+ report_content += f"Settings used:\n"
+ report_content += f" - Min foreign chars: {min_foreign_chars}\n"
+ report_content += f" - Check repetition: {check_repetition}\n"
+ report_content += f" - Check glossary leakage: {check_glossary_leakage}\n"
+ report_content += f" - Min file length: {min_file_length}\n"
+ report_content += f" - Check multiple headers: {check_multiple_headers}\n"
+ report_content += f" - Check missing HTML: {check_missing_html}\n"
+ report_content += f" - Check insufficient paragraphs: {check_insufficient_paragraphs}\n"
+ report_content += f" - Min paragraph percentage: {min_paragraph_percentage}%\n\n"
+
+ report_content += "ALL FILES PROCESSED:\n"
+ report_content += "-" * 30 + "\n"
+ for file in html_files:
+ rel_path = os.path.relpath(file, folder_path)
+ report_content += f" {rel_path}\n"
+
+ if issues_found:
+ report_content += "\n\nISSUES DETECTED (DETAILED):\n"
+ report_content += "\n".join(issues_found)
+ else:
+ report_content += "\n\nβ
No issues detected. All files passed scan."
+
+ else: # detailed (default/recommended)
+ # Detailed format - recommended balance
+ report_content = "QA SCAN REPORT - DETAILED\n"
+ report_content += "=" * 50 + "\n\n"
+ report_content += f"Total files scanned: {total_files}\n"
+ report_content += f"Issues found: {len(issues_found)}\n\n"
+
+ if issues_found:
+ report_content += "ISSUES DETECTED:\n"
+ report_content += "\n".join(issues_found)
+ else:
+ report_content += "No issues detected. All files passed quick scan."
+
+ # Always save report to file for download
+ from datetime import datetime
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ report_filename = f"qa_scan_report_{timestamp}.txt"
+ report_path = os.path.join(os.getcwd(), report_filename)
+
+ # Always write the report file
+ with open(report_path, 'w', encoding='utf-8') as f:
+ f.write(report_content)
+
+ if auto_save_report:
+ scan_logs.append(f"πΎ Report auto-saved to: {report_filename}")
+ else:
+ scan_logs.append(f"π Report ready for download: {report_filename}")
+
+ scan_logs.append(f"\nβ
QA Scan completed!")
+ scan_logs.append(f"π Summary: {total_files} files scanned, {len(issues_found)} issues found")
+ scan_logs.append(f"\nπ₯ Click 'Download QA Report' below to save the report")
+
+ # Always return the report path and make File component visible
+ final_status = f"β
Scan complete!\n{total_files} files scanned\n{len(issues_found)} issues found"
+ yield gr.update(value=report_path, visible=True), gr.update(value=f"### {final_status}", visible=True), gr.update(visible=False), "\n".join(scan_logs), gr.update(value=final_status, visible=True), "Scan complete!", 100
+
+ except Exception as e:
+ import traceback
+ error_msg = f"β Error during QA scan:\n{str(e)}\n\n{traceback.format_exc()}"
+ scan_logs.append(error_msg)
+ yield gr.update(visible=False), gr.update(value="### β Error occurred", visible=True), gr.update(visible=False), "\n".join(scan_logs), gr.update(visible=True), "Error occurred", 0
+
+ def run_qa_scan_with_stop(self, *args):
+ """Wrapper for run_qa_scan that includes button visibility control"""
+ self.qa_scan_stop = False
+
+ # Show stop button, hide scan button at start
+ for result in self.run_qa_scan(*args):
+ if self.qa_scan_stop:
+ # Scan was stopped
+ yield result[0], result[1], result[2], result[3] + "\n\nβ οΈ Scan stopped by user", result[4], "Stopped", 0, gr.update(visible=True), gr.update(visible=False)
+ return
+ # Add button visibility updates to the yields
+ yield result[0], result[1], result[2], result[3], result[4], result[5], result[6], gr.update(visible=False), gr.update(visible=True)
+
+ # Reset buttons at the end
+ yield result[0], result[1], result[2], result[3], result[4], result[5], result[6], gr.update(visible=True), gr.update(visible=False)
+
+ def stop_qa_scan(self):
+ """Stop the ongoing QA scan"""
+ self.qa_scan_stop = True
+ return gr.update(visible=True), gr.update(visible=False), "Scan stopped"
+
+ def stop_translation(self):
+ """Stop the ongoing translation process"""
+ print(f"DEBUG: stop_translation called, was_translating={self.is_translating}")
+ if self.is_translating:
+ print("DEBUG: Setting stop flag and cancellation")
+ self.stop_flag.set()
+ self.is_translating = False
+
+ # Best-effort: cancel any in-flight API operation on the active client
+ try:
+ if getattr(self, 'current_unified_client', None):
+ self.current_unified_client.cancel_current_operation()
+ print("DEBUG: Requested UnifiedClient cancellation")
+ except Exception as e:
+ print(f"DEBUG: UnifiedClient cancel failed: {e}")
+
+ # Also propagate to MangaTranslator class if available
+ try:
+ if MANGA_TRANSLATION_AVAILABLE:
+ from manga_translator import MangaTranslator
+ MangaTranslator.set_global_cancellation(True)
+ print("DEBUG: Set MangaTranslator global cancellation")
+ except ImportError:
+ pass
+
+ # Also propagate to UnifiedClient if available
+ try:
+ if MANGA_TRANSLATION_AVAILABLE:
+ from unified_api_client import UnifiedClient
+ UnifiedClient.set_global_cancellation(True)
+ print("DEBUG: Set UnifiedClient global cancellation")
+ except ImportError:
+ pass
+
+ # Kick off translator shutdown to free resources quickly
+ try:
+ tr = getattr(self, 'current_translator', None)
+ if tr and hasattr(tr, 'shutdown'):
+ import threading as _th
+ _th.Thread(target=tr.shutdown, name="WebMangaTranslatorShutdown", daemon=True).start()
+ print("DEBUG: Initiated translator shutdown thread")
+ # Clear reference so a new start creates a fresh instance
+ self.current_translator = None
+ except Exception as e:
+ print(f"DEBUG: Failed to start translator shutdown: {e}")
+ else:
+ print("DEBUG: stop_translation called but not translating")
+
+ def _reset_translation_flags(self):
+ """Reset all translation flags for new translation"""
+ self.is_translating = False
+ self.stop_flag.clear()
+
+ # Reset global cancellation flags
+ try:
+ if MANGA_TRANSLATION_AVAILABLE:
+ from manga_translator import MangaTranslator
+ MangaTranslator.set_global_cancellation(False)
+ except ImportError:
+ pass
+
+ try:
+ if MANGA_TRANSLATION_AVAILABLE:
+ from unified_api_client import UnifiedClient
+ UnifiedClient.set_global_cancellation(False)
+ except ImportError:
+ pass
+
+ def translate_manga(
+ self,
+ image_files,
+ model,
+ api_key,
+ profile_name,
+ system_prompt,
+ ocr_provider,
+ google_creds_path,
+ azure_key,
+ azure_endpoint,
+ enable_bubble_detection,
+ enable_inpainting,
+ font_size_mode,
+ font_size,
+ font_multiplier,
+ min_font_size,
+ max_font_size,
+ text_color,
+ shadow_enabled,
+ shadow_color,
+ shadow_offset_x,
+ shadow_offset_y,
+ shadow_blur,
+ bg_opacity,
+ bg_style,
+ parallel_panel_translation=False,
+ panel_max_workers=10
+ ):
+ """Translate manga images - GENERATOR that yields (logs, image, cbz_file, status, progress_group, progress_text, progress_bar) updates"""
+
+ # Reset translation flags and set running state
+ self._reset_translation_flags()
+ self.is_translating = True
+
+ if not MANGA_TRANSLATION_AVAILABLE:
+ self.is_translating = False
+ yield "β Manga translation modules not loaded", None, None, gr.update(value="β Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0)
+ return
+
+ if not image_files:
+ self.is_translating = False
+ yield "β Please upload at least one image", gr.update(visible=False), gr.update(visible=False), gr.update(value="β Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0)
+ return
+
+ if not api_key:
+ self.is_translating = False
+ yield "β Please provide an API key", gr.update(visible=False), gr.update(visible=False), gr.update(value="β Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0)
+ return
+
+ # Check for stop request
+ if self.stop_flag.is_set():
+ self.is_translating = False
+ yield "βΉοΈ Translation stopped by user", gr.update(visible=False), gr.update(visible=False), gr.update(value="βΉοΈ Stopped", visible=True), gr.update(visible=False), gr.update(value="Stopped"), gr.update(value=0)
+ return
+
+ if ocr_provider == "google":
+ # Check if credentials are provided or saved in config
+ if not google_creds_path and not self.get_config_value('google_vision_credentials'):
+ yield "β Please provide Google Cloud credentials JSON file", gr.update(visible=False), gr.update(visible=False), gr.update(value="β Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0)
+ return
+
+ if ocr_provider == "azure":
+ # Ensure azure credentials are strings
+ azure_key_str = str(azure_key) if azure_key else ''
+ azure_endpoint_str = str(azure_endpoint) if azure_endpoint else ''
+ if not azure_key_str.strip() or not azure_endpoint_str.strip():
+ yield "β Please provide Azure API key and endpoint", gr.update(visible=False), gr.update(visible=False), gr.update(value="β Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0)
+ return
+
+ try:
+
+ # Set all environment variables from config
+ self.set_all_environment_variables()
+
+ # Set API key environment variable
+ if 'gpt' in model.lower() or 'openai' in model.lower():
+ os.environ['OPENAI_API_KEY'] = api_key
+ elif 'claude' in model.lower():
+ os.environ['ANTHROPIC_API_KEY'] = api_key
+ elif 'gemini' in model.lower():
+ os.environ['GOOGLE_API_KEY'] = api_key
+
+ # Set Google Cloud credentials if provided and save to config
+ if ocr_provider == "google":
+ if google_creds_path:
+ # New file provided - save it
+ creds_path = google_creds_path.name if hasattr(google_creds_path, 'name') else google_creds_path
+ os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = creds_path
+ # Auto-save to config
+ self.config['google_vision_credentials'] = creds_path
+ self.save_config(self.config)
+ elif self.get_config_value('google_vision_credentials'):
+ # Use saved credentials from config
+ creds_path = self.get_config_value('google_vision_credentials')
+ if os.path.exists(creds_path):
+ os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = creds_path
+ else:
+ yield f"β Saved Google credentials not found: {creds_path}", gr.update(visible=False), gr.update(visible=False), gr.update(value="β Error", visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0)
+ return
+
+ # Set Azure credentials if provided and save to config
+ if ocr_provider == "azure":
+ # Convert to strings and strip whitespace
+ azure_key_str = str(azure_key).strip() if azure_key else ''
+ azure_endpoint_str = str(azure_endpoint).strip() if azure_endpoint else ''
+
+ os.environ['AZURE_VISION_KEY'] = azure_key_str
+ os.environ['AZURE_VISION_ENDPOINT'] = azure_endpoint_str
+ # Auto-save to config
+ self.config['azure_vision_key'] = azure_key_str
+ self.config['azure_vision_endpoint'] = azure_endpoint_str
+ self.save_config(self.config)
+
+ # Apply text visibility settings to config
+ # Convert hex color to RGB tuple
+ def hex_to_rgb(hex_color):
+ # Handle different color formats
+ if isinstance(hex_color, (list, tuple)):
+ # Already RGB format
+ return tuple(hex_color[:3])
+ elif isinstance(hex_color, str):
+ # Remove any brackets or spaces if present
+ hex_color = hex_color.strip().strip('[]').strip()
+ if hex_color.startswith('#'):
+ # Hex format
+ hex_color = hex_color.lstrip('#')
+ if len(hex_color) == 6:
+ return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
+ elif len(hex_color) == 3:
+ # Short hex format like #FFF
+ return tuple(int(hex_color[i]*2, 16) for i in range(3))
+ elif ',' in hex_color:
+ # RGB string format like "255, 0, 0"
+ try:
+ parts = hex_color.split(',')
+ return tuple(int(p.strip()) for p in parts[:3])
+ except:
+ pass
+ # Default to black if parsing fails
+ return (0, 0, 0)
+
+ # Debug logging for color values
+ print(f"DEBUG: text_color type: {type(text_color)}, value: {text_color}")
+ print(f"DEBUG: shadow_color type: {type(shadow_color)}, value: {shadow_color}")
+
+ try:
+ text_rgb = hex_to_rgb(text_color)
+ shadow_rgb = hex_to_rgb(shadow_color)
+ except Exception as e:
+ print(f"WARNING: Error converting colors: {e}")
+ print(f"WARNING: Using default colors - text: black, shadow: white")
+ text_rgb = (0, 0, 0) # Default to black text
+ shadow_rgb = (255, 255, 255) # Default to white shadow
+
+ self.config['manga_font_size_mode'] = font_size_mode
+ self.config['manga_font_size'] = int(font_size)
+ self.config['manga_font_size_multiplier'] = float(font_multiplier)
+ self.config['manga_max_font_size'] = int(max_font_size)
+ self.config['manga_text_color'] = list(text_rgb)
+ self.config['manga_shadow_enabled'] = bool(shadow_enabled)
+ self.config['manga_shadow_color'] = list(shadow_rgb)
+ self.config['manga_shadow_offset_x'] = int(shadow_offset_x)
+ self.config['manga_shadow_offset_y'] = int(shadow_offset_y)
+ self.config['manga_shadow_blur'] = int(shadow_blur)
+ self.config['manga_bg_opacity'] = int(bg_opacity)
+ self.config['manga_bg_style'] = bg_style
+
+ # Also update nested manga_settings structure
+ if 'manga_settings' not in self.config:
+ self.config['manga_settings'] = {}
+ if 'rendering' not in self.config['manga_settings']:
+ self.config['manga_settings']['rendering'] = {}
+ if 'font_sizing' not in self.config['manga_settings']:
+ self.config['manga_settings']['font_sizing'] = {}
+
+ self.config['manga_settings']['rendering']['auto_min_size'] = int(min_font_size)
+ self.config['manga_settings']['font_sizing']['min_size'] = int(min_font_size)
+ self.config['manga_settings']['rendering']['auto_max_size'] = int(max_font_size)
+ self.config['manga_settings']['font_sizing']['max_size'] = int(max_font_size)
+
+ # Prepare output directory
+ output_dir = tempfile.mkdtemp(prefix="manga_translated_")
+ translated_files = []
+ cbz_mode = False
+ cbz_output_path = None
+
+ # Initialize translation logs early (needed for CBZ processing)
+ translation_logs = []
+
+ # Check if any file is a CBZ/ZIP archive
+ import zipfile
+ files_to_process = image_files if isinstance(image_files, list) else [image_files]
+ extracted_images = []
+
+ for file in files_to_process:
+ file_path = file.name if hasattr(file, 'name') else file
+ if file_path.lower().endswith(('.cbz', '.zip')):
+ # Extract CBZ
+ cbz_mode = True
+ translation_logs.append(f"π Extracting CBZ: {os.path.basename(file_path)}")
+ extract_dir = tempfile.mkdtemp(prefix="cbz_extract_")
+
+ try:
+ with zipfile.ZipFile(file_path, 'r') as zip_ref:
+ zip_ref.extractall(extract_dir)
+
+ # Find all image files in extracted directory
+ import glob
+ for ext in ['*.png', '*.jpg', '*.jpeg', '*.webp', '*.bmp', '*.gif']:
+ extracted_images.extend(glob.glob(os.path.join(extract_dir, '**', ext), recursive=True))
+
+ # Sort naturally (by filename)
+ extracted_images.sort()
+ translation_logs.append(f"β
Extracted {len(extracted_images)} images from CBZ")
+
+ # Prepare CBZ output path
+ cbz_output_path = os.path.join(output_dir, f"{os.path.splitext(os.path.basename(file_path))[0]}_translated.cbz")
+ except Exception as e:
+ translation_logs.append(f"β Error extracting CBZ: {str(e)}")
+ else:
+ # Regular image file
+ extracted_images.append(file_path)
+
+ # Use extracted images if CBZ was processed, otherwise use original files
+ if extracted_images:
+ # Create mock file objects for extracted images
+ class MockFile:
+ def __init__(self, path):
+ self.name = path
+
+ files_to_process = [MockFile(img) for img in extracted_images]
+
+ total_images = len(files_to_process)
+
+ # Merge web app config with SimpleConfig for MangaTranslator
+ # This includes all the text visibility settings we just set
+ merged_config = self.config.copy()
+
+ # Override with web-specific settings
+ merged_config['model'] = model
+ merged_config['active_profile'] = profile_name
+
+ # Update manga_settings
+ if 'manga_settings' not in merged_config:
+ merged_config['manga_settings'] = {}
+ if 'ocr' not in merged_config['manga_settings']:
+ merged_config['manga_settings']['ocr'] = {}
+ if 'inpainting' not in merged_config['manga_settings']:
+ merged_config['manga_settings']['inpainting'] = {}
+ if 'advanced' not in merged_config['manga_settings']:
+ merged_config['manga_settings']['advanced'] = {}
+
+ merged_config['manga_settings']['ocr']['provider'] = ocr_provider
+ merged_config['manga_settings']['ocr']['bubble_detection_enabled'] = enable_bubble_detection
+ merged_config['manga_settings']['inpainting']['method'] = 'local' if enable_inpainting else 'none'
+ # Make sure local_method is set from config (defaults to anime)
+ if 'local_method' not in merged_config['manga_settings']['inpainting']:
+ merged_config['manga_settings']['inpainting']['local_method'] = self.get_config_value('manga_settings', {}).get('inpainting', {}).get('local_method', 'anime')
+
+ # Set parallel panel translation settings from config (Manga Settings tab)
+ # These are controlled in the Manga Settings tab, so reload config to get latest values
+ current_config = self.load_config()
+ if API_KEY_ENCRYPTION_AVAILABLE:
+ current_config = decrypt_config(current_config)
+
+ config_parallel = current_config.get('manga_settings', {}).get('advanced', {}).get('parallel_panel_translation', False)
+ config_max_workers = current_config.get('manga_settings', {}).get('advanced', {}).get('panel_max_workers', 10)
+
+ # Map web UI settings to MangaTranslator expected names
+ merged_config['manga_settings']['advanced']['parallel_panel_translation'] = config_parallel
+ merged_config['manga_settings']['advanced']['panel_max_workers'] = int(config_max_workers)
+ # CRITICAL: Also set the setting names that MangaTranslator actually checks
+ merged_config['manga_settings']['advanced']['parallel_processing'] = config_parallel
+ merged_config['manga_settings']['advanced']['max_workers'] = int(config_max_workers)
+
+ # Log the parallel settings being used
+ print(f"π§ Reloaded config - Using parallel panel translation: {config_parallel}")
+ print(f"π§ Reloaded config - Using panel max workers: {config_max_workers}")
+
+ # CRITICAL: Set skip_inpainting flag to False when inpainting is enabled
+ merged_config['manga_skip_inpainting'] = not enable_inpainting
+
+ # Create a simple config object for MangaTranslator
+ class SimpleConfig:
+ def __init__(self, cfg):
+ self.config = cfg
+
+ def get(self, key, default=None):
+ return self.config.get(key, default)
+
+ # Create mock GUI object with necessary attributes
+ class MockGUI:
+ def __init__(self, config, profile_name, system_prompt, max_output_tokens, api_key, model):
+ self.config = config
+ # Add profile_var mock for MangaTranslator compatibility
+ class ProfileVar:
+ def __init__(self, profile):
+ self.profile = str(profile) if profile else ''
+ def get(self):
+ return self.profile
+ self.profile_var = ProfileVar(profile_name)
+ # Add prompt_profiles BOTH to config AND as attribute (manga_translator checks both)
+ if 'prompt_profiles' not in self.config:
+ self.config['prompt_profiles'] = {}
+ self.config['prompt_profiles'][profile_name] = system_prompt
+ # Also set as direct attribute for line 4653 check
+ self.prompt_profiles = self.config['prompt_profiles']
+ # Add max_output_tokens as direct attribute (line 299 check)
+ self.max_output_tokens = max_output_tokens
+ # Add mock GUI attributes that MangaTranslator expects
+ class MockVar:
+ def __init__(self, val):
+ # Ensure val is properly typed
+ self.val = val
+ def get(self):
+ return self.val
+ # CRITICAL: delay_entry must read from api_call_delay (not 'delay')
+ self.delay_entry = MockVar(float(config.get('api_call_delay', 0.5)))
+ self.trans_temp = MockVar(float(config.get('translation_temperature', 0.3)))
+ self.contextual_var = MockVar(bool(config.get('contextual', False)))
+ self.trans_history = MockVar(int(config.get('translation_history_limit', 2)))
+ self.translation_history_rolling_var = MockVar(bool(config.get('translation_history_rolling', False)))
+ self.token_limit_disabled = bool(config.get('token_limit_disabled', False))
+ # IMPORTANT: token_limit_entry must return STRING because manga_translator calls .strip() on it
+ self.token_limit_entry = MockVar(str(config.get('token_limit', 200000)))
+ # Batch translation settings
+ self.batch_translation_var = MockVar(bool(config.get('batch_translation', True)))
+ self.batch_size_var = MockVar(str(config.get('batch_size', '10')))
+ # Add API key and model for custom-api OCR provider - ensure strings
+ self.api_key_entry = MockVar(str(api_key) if api_key else '')
+ self.model_var = MockVar(str(model) if model else '')
+
+ simple_config = SimpleConfig(merged_config)
+ # Get max_output_tokens from config or use from web app config
+ web_max_tokens = merged_config.get('max_output_tokens', 16000)
+ mock_gui = MockGUI(simple_config.config, profile_name, system_prompt, web_max_tokens, api_key, model)
+
+ # CRITICAL: Set SYSTEM_PROMPT environment variable for manga translation
+ os.environ['SYSTEM_PROMPT'] = system_prompt if system_prompt else ''
+ if system_prompt:
+ print(f"β
System prompt set ({len(system_prompt)} characters)")
+ else:
+ print("β οΈ No system prompt provided")
+
+ # CRITICAL: Set batch environment variables from mock_gui variables
+ os.environ['BATCH_TRANSLATION'] = '1' if mock_gui.batch_translation_var.get() else '0'
+ os.environ['BATCH_SIZE'] = str(mock_gui.batch_size_var.get())
+ print(f"π¦ Set BATCH_TRANSLATION={os.environ['BATCH_TRANSLATION']}, BATCH_SIZE={os.environ['BATCH_SIZE']}")
+
+ # Ensure model path is in config for local inpainting
+ if enable_inpainting:
+ local_method = merged_config.get('manga_settings', {}).get('inpainting', {}).get('local_method', 'anime')
+ # Set the model path key that MangaTranslator expects
+ model_path_key = f'manga_{local_method}_model_path'
+ if model_path_key not in merged_config:
+ # Use default model path or empty string
+ default_model_path = self.get_config_value(model_path_key, '')
+ merged_config[model_path_key] = default_model_path
+ print(f"Set {model_path_key} to: {default_model_path}")
+
+ # CRITICAL: Explicitly set environment variables before creating UnifiedClient
+ api_call_delay = merged_config.get('api_call_delay', 0.5)
+ os.environ['SEND_INTERVAL_SECONDS'] = str(api_call_delay)
+ print(f"π§ Manga translation: Set SEND_INTERVAL_SECONDS = {api_call_delay}s")
+
+ # Set batch translation and batch size from MockGUI variables (after MockGUI is created)
+ # Will be set after mock_gui is created below
+
+ # Also ensure font algorithm and auto fit style are in config for manga_translator
+ if 'manga_settings' not in merged_config:
+ merged_config['manga_settings'] = {}
+ if 'font_sizing' not in merged_config['manga_settings']:
+ merged_config['manga_settings']['font_sizing'] = {}
+ if 'rendering' not in merged_config['manga_settings']:
+ merged_config['manga_settings']['rendering'] = {}
+
+ if 'algorithm' not in merged_config['manga_settings']['font_sizing']:
+ merged_config['manga_settings']['font_sizing']['algorithm'] = 'smart'
+ if 'auto_fit_style' not in merged_config['manga_settings']['rendering']:
+ merged_config['manga_settings']['rendering']['auto_fit_style'] = 'balanced'
+
+ print(f"π¦ Batch: BATCH_TRANSLATION={os.environ.get('BATCH_TRANSLATION')}, BATCH_SIZE={os.environ.get('BATCH_SIZE')}")
+ print(f"π¨ Font: algorithm={merged_config['manga_settings']['font_sizing']['algorithm']}, auto_fit_style={merged_config['manga_settings']['rendering']['auto_fit_style']}")
+
+ # Setup OCR configuration
+ ocr_config = {
+ 'provider': ocr_provider
+ }
+
+ if ocr_provider == 'google':
+ ocr_config['google_credentials_path'] = google_creds_path.name if google_creds_path else None
+ elif ocr_provider == 'azure':
+ # Use string versions
+ azure_key_str = str(azure_key).strip() if azure_key else ''
+ azure_endpoint_str = str(azure_endpoint).strip() if azure_endpoint else ''
+ ocr_config['azure_key'] = azure_key_str
+ ocr_config['azure_endpoint'] = azure_endpoint_str
+
+ # Create UnifiedClient for translation API calls
+ try:
+ unified_client = UnifiedClient(
+ api_key=api_key,
+ model=model,
+ output_dir=output_dir
+ )
+ # Store reference for stop() cancellation support
+ self.current_unified_client = unified_client
+ except Exception as e:
+ error_log = f"β Failed to initialize API client: {str(e)}"
+ yield error_log, gr.update(visible=False), gr.update(visible=False), gr.update(value=error_log, visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0)
+ return
+
+ # Log storage - will be yielded as live updates
+ last_yield_log_count = [0] # Track when we last yielded
+ last_yield_time = [0] # Track last yield time
+
+ # Track current image being processed
+ current_image_idx = [0]
+
+ import time
+
+ def should_yield_logs():
+ """Check if we should yield log updates (every 2 logs or 1 second)"""
+ current_time = time.time()
+ log_count_diff = len(translation_logs) - last_yield_log_count[0]
+ time_diff = current_time - last_yield_time[0]
+
+ # Yield if 2+ new logs OR 1+ seconds passed
+ return log_count_diff >= 2 or time_diff >= 1.0
+
+ def capture_log(msg, level="info"):
+ """Capture logs - caller will yield periodically"""
+ if msg and msg.strip():
+ log_msg = msg.strip()
+ translation_logs.append(log_msg)
+
+ # Initialize timing
+ last_yield_time[0] = time.time()
+
+ # Create MangaTranslator instance
+ try:
+ # Debug: Log inpainting config
+ inpaint_cfg = merged_config.get('manga_settings', {}).get('inpainting', {})
+ print(f"\n=== INPAINTING CONFIG DEBUG ===")
+ print(f"Inpainting enabled checkbox: {enable_inpainting}")
+ print(f"Inpainting method: {inpaint_cfg.get('method')}")
+ print(f"Local method: {inpaint_cfg.get('local_method')}")
+ print(f"Full inpainting config: {inpaint_cfg}")
+ print("=== END DEBUG ===\n")
+
+ translator = MangaTranslator(
+ ocr_config=ocr_config,
+ unified_client=unified_client,
+ main_gui=mock_gui,
+ log_callback=capture_log
+ )
+
+ # Keep a reference for stop/shutdown support
+ self.current_translator = translator
+
+ # Connect stop flag so translator can react immediately to stop requests
+ if hasattr(translator, 'set_stop_flag'):
+ try:
+ translator.set_stop_flag(self.stop_flag)
+ except Exception:
+ pass
+
+ # CRITICAL: Set skip_inpainting flag directly on translator instance
+ translator.skip_inpainting = not enable_inpainting
+ print(f"Set translator.skip_inpainting = {translator.skip_inpainting}")
+
+ # Explicitly initialize local inpainting if enabled
+ if enable_inpainting:
+ print(f"π¨ Initializing local inpainting...")
+ try:
+ # Force initialization of the inpainter
+ init_result = translator._initialize_local_inpainter()
+ if init_result:
+ print(f"β
Local inpainter initialized successfully")
+ else:
+ print(f"β οΈ Local inpainter initialization returned False")
+ except Exception as init_error:
+ print(f"β Failed to initialize inpainter: {init_error}")
+ import traceback
+ traceback.print_exc()
+
+ except Exception as e:
+ import traceback
+ full_error = traceback.format_exc()
+ print(f"\n\n=== MANGA TRANSLATOR INIT ERROR ===")
+ print(full_error)
+ print(f"\nocr_config: {ocr_config}")
+ print(f"\nmock_gui.model_var.get(): {mock_gui.model_var.get()}")
+ print(f"\nmock_gui.api_key_entry.get(): {type(mock_gui.api_key_entry.get())}")
+ print("=== END ERROR ===")
+ error_log = f"β Failed to initialize manga translator: {str(e)}\n\nCheck console for full traceback"
+ yield error_log, gr.update(visible=False), gr.update(visible=False), gr.update(value=error_log, visible=True), gr.update(visible=False), gr.update(value="Error"), gr.update(value=0)
+ return
+
+ # Process each image with real progress tracking
+ for idx, img_file in enumerate(files_to_process, 1):
+ try:
+ # Check for stop request before processing each image
+ if self.stop_flag.is_set():
+ translation_logs.append(f"\nβΉοΈ Translation stopped by user before image {idx}/{total_images}")
+ self.is_translating = False
+ yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(value="βΉοΈ Translation stopped", visible=True), gr.update(visible=True), gr.update(value="Stopped"), gr.update(value=0)
+ return
+
+ # Update current image index for log capture
+ current_image_idx[0] = idx
+
+ # Calculate progress range for this image
+ start_progress = (idx - 1) / total_images
+ end_progress = idx / total_images
+
+ input_path = img_file.name if hasattr(img_file, 'name') else img_file
+ output_path = os.path.join(output_dir, f"translated_{os.path.basename(input_path)}")
+ filename = os.path.basename(input_path)
+
+ # Log start of processing and YIELD update
+ start_msg = f"π¨ [{idx}/{total_images}] Starting: {filename}"
+ translation_logs.append(start_msg)
+ translation_logs.append(f"Image path: {input_path}")
+ translation_logs.append(f"Processing with OCR: {ocr_provider}, Model: {model}")
+ translation_logs.append("-" * 60)
+
+ # Yield initial log update with progress
+ progress_percent = int(((idx - 1) / total_images) * 100)
+ status_text = f"Processing {idx}/{total_images}: {filename}"
+ last_yield_log_count[0] = len(translation_logs)
+ last_yield_time[0] = time.time()
+ yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value=status_text), gr.update(value=progress_percent)
+
+ # Start processing in a thread so we can yield logs periodically
+ import threading
+ processing_complete = [False]
+ result_container = [None]
+
+ def process_wrapper():
+ result_container[0] = translator.process_image(
+ image_path=input_path,
+ output_path=output_path,
+ batch_index=idx,
+ batch_total=total_images
+ )
+ processing_complete[0] = True
+
+ # Start processing in background
+ process_thread = threading.Thread(target=process_wrapper, daemon=True)
+ process_thread.start()
+
+ # Poll for log updates while processing
+ while not processing_complete[0]:
+ time.sleep(0.5) # Check every 0.5 seconds
+
+ # Check for stop request during processing
+ if self.stop_flag.is_set():
+ translation_logs.append(f"\nβΉοΈ Translation stopped by user while processing image {idx}/{total_images}")
+ self.is_translating = False
+ yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(value="βΉοΈ Translation stopped", visible=True), gr.update(visible=True), gr.update(value="Stopped"), gr.update(value=0)
+ return
+
+ if should_yield_logs():
+ progress_percent = int(((idx - 0.5) / total_images) * 100) # Mid-processing
+ status_text = f"Processing {idx}/{total_images}: {filename} (in progress...)"
+ last_yield_log_count[0] = len(translation_logs)
+ last_yield_time[0] = time.time()
+ yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value=status_text), gr.update(value=progress_percent)
+
+ # Wait for thread to complete
+ process_thread.join(timeout=1)
+ result = result_container[0]
+
+ if result.get('success'):
+ # Use the output path from the result
+ final_output = result.get('output_path', output_path)
+ if os.path.exists(final_output):
+ translated_files.append(final_output)
+ translation_logs.append(f"β
Image {idx}/{total_images} COMPLETE: {filename} | Total: {len(translated_files)}/{total_images} done")
+ translation_logs.append("")
+ # Yield progress update with all translated images so far
+ progress_percent = int((idx / total_images) * 100)
+ status_text = f"Completed {idx}/{total_images}: {filename}"
+ # Show all translated files as gallery
+ yield "\n".join(translation_logs), gr.update(value=translated_files, visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value=status_text), gr.update(value=progress_percent)
+ else:
+ translation_logs.append(f"β οΈ Image {idx}/{total_images}: Output file missing for {filename}")
+ translation_logs.append(f"β οΈ Warning: Output file not found for image {idx}")
+ translation_logs.append("")
+ # Yield progress update
+ progress_percent = int((idx / total_images) * 100)
+ status_text = f"Warning: {idx}/{total_images} - Output missing for {filename}"
+ yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value=status_text), gr.update(value=progress_percent)
+ else:
+ errors = result.get('errors', [])
+ error_msg = errors[0] if errors else 'Unknown error'
+ translation_logs.append(f"β Image {idx}/{total_images} FAILED: {error_msg[:50]}")
+ translation_logs.append(f"β οΈ Error on image {idx}: {error_msg}")
+ translation_logs.append("")
+ # Yield progress update
+ progress_percent = int((idx / total_images) * 100)
+ status_text = f"Failed: {idx}/{total_images} - {filename}"
+ yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value=status_text), gr.update(value=progress_percent)
+
+ # If translation failed, save original with error overlay
+ from PIL import Image as PILImage, ImageDraw, ImageFont
+ img = PILImage.open(input_path)
+ draw = ImageDraw.Draw(img)
+ # Add error message
+ draw.text((10, 10), f"Translation Error: {error_msg[:50]}", fill="red")
+ img.save(output_path)
+ translated_files.append(output_path)
+
+ except Exception as e:
+ import traceback
+ error_trace = traceback.format_exc()
+ translation_logs.append(f"β Image {idx}/{total_images} ERROR: {str(e)[:60]}")
+ translation_logs.append(f"β Exception on image {idx}: {str(e)}")
+ print(f"Manga translation error for {input_path}:\n{error_trace}")
+
+ # Save original on error
+ try:
+ from PIL import Image as PILImage
+ img = PILImage.open(input_path)
+ img.save(output_path)
+ translated_files.append(output_path)
+ except:
+ pass
+ continue
+
+ # Check for stop request before final processing
+ if self.stop_flag.is_set():
+ translation_logs.append("\nβΉοΈ Translation stopped by user")
+ self.is_translating = False
+ yield "\n".join(translation_logs), gr.update(visible=False), gr.update(visible=False), gr.update(value="βΉοΈ Translation stopped", visible=True), gr.update(visible=True), gr.update(value="Stopped"), gr.update(value=0)
+ return
+
+ # Add completion message
+ translation_logs.append("\n" + "="*60)
+ translation_logs.append(f"β
ALL COMPLETE! Successfully translated {len(translated_files)}/{total_images} images")
+ translation_logs.append("="*60)
+
+ # If CBZ mode, compile translated images into CBZ archive
+ final_output_for_display = None
+ if cbz_mode and cbz_output_path and translated_files:
+ translation_logs.append("\nπ¦ Compiling translated images into CBZ archive...")
+ try:
+ with zipfile.ZipFile(cbz_output_path, 'w', zipfile.ZIP_DEFLATED) as cbz:
+ for img_path in translated_files:
+ # Preserve original filename structure
+ arcname = os.path.basename(img_path).replace("translated_", "")
+ cbz.write(img_path, arcname)
+
+ translation_logs.append(f"β
CBZ archive created: {os.path.basename(cbz_output_path)}")
+ translation_logs.append(f"π Archive location: {cbz_output_path}")
+ final_output_for_display = cbz_output_path
+ except Exception as e:
+ translation_logs.append(f"β Error creating CBZ: {str(e)}")
+
+ # Build final status with detailed panel information
+ final_status_lines = []
+ if translated_files:
+ final_status_lines.append(f"β
Successfully translated {len(translated_files)}/{total_images} image(s)!")
+ final_status_lines.append("")
+ final_status_lines.append("πΌοΈ **Translated Panels:**")
+ for i, file_path in enumerate(translated_files, 1):
+ filename = os.path.basename(file_path)
+ final_status_lines.append(f" {i}. {filename}")
+
+ final_status_lines.append("")
+ final_status_lines.append("π **Download Options:**")
+ if cbz_mode and cbz_output_path:
+ final_status_lines.append(f" π¦ CBZ Archive: {os.path.basename(cbz_output_path)}")
+ final_status_lines.append(f" π Location: {cbz_output_path}")
+ else:
+ # Create ZIP file for all images
+ zip_path = os.path.join(output_dir, "translated_images.zip")
+ try:
+ import zipfile
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
+ for img_path in translated_files:
+ arcname = os.path.basename(img_path)
+ zipf.write(img_path, arcname)
+ final_status_lines.append(f" π¦ Download all images: translated_images.zip")
+ final_status_lines.append(f" π Output directory: {output_dir}")
+ final_output_for_display = zip_path # Set this so it can be downloaded
+ except Exception as e:
+ final_status_lines.append(f" β Failed to create ZIP: {str(e)}")
+ final_status_lines.append(f" π Output directory: {output_dir}")
+ final_status_lines.append(" πΌοΈ Images saved individually in output directory")
+ else:
+ final_status_lines.append("β Translation failed - no images were processed")
+
+ final_status_text = "\n".join(final_status_lines)
+
+ # Final yield with complete logs, image, CBZ, and final status
+ # Format: (logs_textbox, output_image, cbz_file, status_textbox, progress_group, progress_text, progress_bar)
+ final_progress_text = f"Complete! Processed {len(translated_files)}/{total_images} images"
+ if translated_files:
+ # Show all translated images in gallery
+ if cbz_mode and cbz_output_path and os.path.exists(cbz_output_path):
+ yield (
+ "\n".join(translation_logs),
+ gr.update(value=translated_files, visible=True), # Show all images in gallery
+ gr.update(value=cbz_output_path, visible=True), # CBZ file for download with visibility
+ gr.update(value=final_status_text, visible=True),
+ gr.update(visible=True),
+ gr.update(value=final_progress_text),
+ gr.update(value=100)
+ )
+ else:
+ # Show ZIP file for download if it was created
+ if final_output_for_display and os.path.exists(final_output_for_display):
+ yield (
+ "\n".join(translation_logs),
+ gr.update(value=translated_files, visible=True), # Show all images in gallery
+ gr.update(value=final_output_for_display, visible=True), # ZIP file for download
+ gr.update(value=final_status_text, visible=True),
+ gr.update(visible=True),
+ gr.update(value=final_progress_text),
+ gr.update(value=100)
+ )
+ else:
+ yield (
+ "\n".join(translation_logs),
+ gr.update(value=translated_files, visible=True), # Show all images in gallery
+ gr.update(visible=False), # Hide download component if ZIP failed
+ gr.update(value=final_status_text, visible=True),
+ gr.update(visible=True),
+ gr.update(value=final_progress_text),
+ gr.update(value=100)
+ )
+ else:
+ yield (
+ "\n".join(translation_logs),
+ gr.update(visible=False),
+ gr.update(visible=False), # Hide CBZ component
+ gr.update(value=final_status_text, visible=True),
+ gr.update(visible=True),
+ gr.update(value=final_progress_text),
+ gr.update(value=0) # 0% if nothing was processed
+ )
+
+ except Exception as e:
+ import traceback
+ error_msg = f"β Error during manga translation:\n{str(e)}\n\n{traceback.format_exc()}"
+ self.is_translating = False
+ yield error_msg, gr.update(visible=False), gr.update(visible=False), gr.update(value=error_msg, visible=True), gr.update(visible=False), gr.update(value="Error occurred"), gr.update(value=0)
+ finally:
+ # Always reset translation state when done
+ self.is_translating = False
+ # Clear active references on full completion
+ try:
+ self.current_translator = None
+ self.current_unified_client = None
+ except Exception:
+ pass
+
+ def stop_manga_translation(self):
+ """Simple function to stop manga translation"""
+ print("DEBUG: Stop button clicked")
+ if self.is_translating:
+ print("DEBUG: Stopping active translation")
+ self.stop_translation()
+ # Return UI updates for button visibility and status
+ return (
+ gr.update(visible=True), # translate button - show
+ gr.update(visible=False), # stop button - hide
+ "βΉοΈ Translation stopped by user"
+ )
+ else:
+ print("DEBUG: No active translation to stop")
+ return (
+ gr.update(visible=True), # translate button - show
+ gr.update(visible=False), # stop button - hide
+ "No active translation to stop"
+ )
+
+ def start_manga_translation(self, *args):
+ """Simple function to start manga translation - GENERATOR FUNCTION"""
+ print("DEBUG: Translate button clicked")
+
+ # Reset flags for new translation and mark as translating BEFORE first yield
+ self._reset_translation_flags()
+ self.is_translating = True
+
+ # Initial yield to update button visibility
+ yield (
+ "π Starting translation...",
+ gr.update(visible=False), # manga_output_gallery - hide initially
+ gr.update(visible=False), # manga_cbz_output
+ gr.update(value="Starting...", visible=True), # manga_status
+ gr.update(visible=False), # manga_progress_group
+ gr.update(value="Initializing..."), # manga_progress_text
+ gr.update(value=0), # manga_progress_bar
+ gr.update(visible=False), # translate button - hide during translation
+ gr.update(visible=True) # stop button - show during translation
+ )
+
+ # Call the translate function and yield all its results
+ last_result = None
+ try:
+ for result in self.translate_manga(*args):
+ # Check if stop was requested during iteration
+ if self.stop_flag.is_set():
+ print("DEBUG: Stop flag detected, breaking translation loop")
+ break
+
+ last_result = result
+ # Pad result to include button states (translate_visible=False, stop_visible=True)
+ if len(result) >= 7:
+ yield result + (gr.update(visible=False), gr.update(visible=True))
+ else:
+ # Pad result to match expected length (7 values) then add button states
+ padded_result = list(result) + [gr.update(visible=False)] * (7 - len(result))
+ yield tuple(padded_result) + (gr.update(visible=False), gr.update(visible=True))
+
+ except GeneratorExit:
+ print("DEBUG: Translation generator was closed")
+ self.is_translating = False
+ return
+ except Exception as e:
+ print(f"DEBUG: Exception during translation: {e}")
+ self.is_translating = False
+ # Show error and reset buttons
+ error_msg = f"β Error during translation: {str(e)}"
+ yield (
+ error_msg,
+ gr.update(visible=False),
+ gr.update(visible=False),
+ gr.update(value=error_msg, visible=True),
+ gr.update(visible=False),
+ gr.update(value="Error occurred"),
+ gr.update(value=0),
+ gr.update(visible=True), # translate button - show after error
+ gr.update(visible=False) # stop button - hide after error
+ )
+ return
+ finally:
+ # Clear active references when the loop exits
+ self.is_translating = False
+ try:
+ self.current_translator = None
+ self.current_unified_client = None
+ except Exception:
+ pass
+
+ # Check if we stopped early
+ if self.stop_flag.is_set():
+ yield (
+ "βΉοΈ Translation stopped by user",
+ gr.update(visible=False),
+ gr.update(visible=False),
+ gr.update(value="βΉοΈ Translation stopped", visible=True),
+ gr.update(visible=False),
+ gr.update(value="Stopped"),
+ gr.update(value=0),
+ gr.update(visible=True), # translate button - show after stop
+ gr.update(visible=False) # stop button - hide after stop
+ )
+ return
+
+ # Final yield to reset buttons after successful completion
+ print("DEBUG: Translation completed normally, resetting buttons")
+ if last_result is None:
+ last_result = ("", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(value="Complete"), gr.update(value=100))
+
+ if len(last_result) >= 7:
+ yield last_result[:7] + (gr.update(visible=True), gr.update(visible=False))
+ else:
+ # Pad result to match expected length then add button states
+ padded_result = list(last_result) + [gr.update(visible=False)] * (7 - len(last_result))
+ yield tuple(padded_result) + (gr.update(visible=True), gr.update(visible=False))
+
+ def create_interface(self):
+ """Create and return the Gradio interface"""
+ # Reload config before creating interface to get latest values
+ self.config = self.load_config()
+ self.decrypted_config = decrypt_config(self.config.copy()) if API_KEY_ENCRYPTION_AVAILABLE else self.config.copy()
+
+ # Load and encode icon as base64
+ icon_base64 = ""
+ icon_path = "Halgakos.ico" if os.path.exists("Halgakos.ico") else "Halgakos.ico"
+ if os.path.exists(icon_path):
+ with open(icon_path, "rb") as f:
+ icon_base64 = base64.b64encode(f.read()).decode()
+
+ # Custom CSS to hide Gradio footer and add favicon
+ custom_css = """
+ footer {display: none !important;}
+ .gradio-container {min-height: 100vh;}
+
+ /* Stop button styling */
+ .gr-button[data-variant="stop"] {
+ background-color: #dc3545 !important;
+ border-color: #dc3545 !important;
+ color: white !important;
+ }
+ .gr-button[data-variant="stop"]:hover {
+ background-color: #c82333 !important;
+ border-color: #bd2130 !important;
+ color: white !important;
+ }
+ """
+
+ # JavaScript for localStorage persistence - SIMPLE VERSION
+ localStorage_js = """
+
+ """
+
+ with gr.Blocks(
+ title="Glossarion - AI Translation",
+ theme=gr.themes.Soft(),
+ css=custom_css
+ ) as app:
+
+ # Add custom HTML with favicon link and title with icon
+ icon_img_tag = f'' if icon_base64 else ''
+
+ gr.HTML(f"""
+
+
+
+
tags (%)", + info="Files with less than this percentage will be flagged" + ) + + # Report Settings + gr.Markdown("#### Report Settings") + + report_format = gr.Radio( + choices=["summary", "detailed", "verbose"], + value=self.get_config_value('qa_report_format', 'detailed'), + label="Report format", + info="Summary = brief overview, Detailed = recommended, Verbose = all data" + ) + + auto_save_report = gr.Checkbox( + label="Automatically save report after scan", + value=self.get_config_value('qa_auto_save_report', True) + ) + + with gr.Column(): + # Add logo and status at top + with gr.Row(): + gr.Image( + value="Halgakos.png", + label=None, + show_label=False, + width=80, + height=80, + interactive=False, + show_download_button=False, + container=False + ) + qa_status_message = gr.Markdown( + value="### Ready to scan\nEnter the path to your output folder and click 'Quick Scan' to begin.", + visible=True + ) + + # Progress section + with gr.Group(visible=False) as qa_progress_group: + gr.Markdown("### Progress") + qa_progress_text = gr.Textbox( + label="π¨ Current Status", + value="Ready to start", + interactive=False, + lines=1 + ) + qa_progress_bar = gr.Slider( + minimum=0, + maximum=100, + value=0, + step=1, + label="π Scan Progress", + interactive=False, + show_label=True + ) + + qa_logs = gr.Textbox( + label="π Scan Logs", + lines=20, + max_lines=30, + value="Ready to scan. Enter output folder path and configure settings.", + visible=True, + interactive=False + ) + + qa_report = gr.File( + label="π Download QA Report", + visible=False + ) + + qa_status = gr.Textbox( + label="Final Status", + lines=3, + max_lines=5, + visible=False, + interactive=False + ) + + # QA Scan button handler + qa_scan_btn.click( + fn=self.run_qa_scan_with_stop, + inputs=[ + qa_folder_path, + min_foreign_chars, + check_repetition, + check_glossary_leakage, + min_file_length, + check_multiple_headers, + check_missing_html, + check_insufficient_paragraphs, + min_paragraph_percentage, + report_format, + auto_save_report + ], + outputs=[ + qa_report, + qa_status_message, + qa_progress_group, + qa_logs, + qa_status, + qa_progress_text, + qa_progress_bar, + qa_scan_btn, + stop_qa_btn + ] + ) + + # Stop button handler + stop_qa_btn.click( + fn=self.stop_qa_scan, + inputs=[], + outputs=[qa_scan_btn, stop_qa_btn, qa_status] + ) + + # Settings Tab + with gr.Tab("βοΈ Settings"): + gr.Markdown("### Configuration") + + gr.Markdown("#### Translation Profiles") + gr.Markdown("Profiles are loaded from your `config_web.json` file. The web interface has its own separate configuration.") + + with gr.Accordion("View All Profiles", open=False): + profiles_text = "\n\n".join( + [f"**{name}**:\n```\n{prompt[:200]}...\n```" + for name, prompt in self.profiles.items()] + ) + gr.Markdown(profiles_text if profiles_text else "No profiles found") + + gr.Markdown("---") + gr.Markdown("#### Advanced Translation Settings") + + with gr.Row(): + with gr.Column(): + thread_delay = gr.Slider( + minimum=0, + maximum=5, + value=self.get_config_value('thread_submission_delay', 0.1), + step=0.1, + label="Threading delay (s)", + interactive=True + ) + + api_delay = gr.Slider( + minimum=0, + maximum=10, + value=self.get_config_value('api_call_delay', 0.5), + step=0.1, + label="API call delay (s) [SEND_INTERVAL_SECONDS]", + interactive=True, + info="Delay between API calls to avoid rate limits" + ) + + chapter_range = gr.Textbox( + label="Chapter range (e.g., 5-10)", + value=self.get_config_value('chapter_range', ''), + placeholder="Leave empty for all chapters" + ) + + token_limit = gr.Number( + label="Input Token limit", + value=self.get_config_value('token_limit', 200000), + minimum=0 + ) + + disable_token_limit = gr.Checkbox( + label="Disable Input Token Limit", + value=self.get_config_value('token_limit_disabled', False) + ) + + output_token_limit = gr.Number( + label="Output Token limit", + value=self.get_config_value('max_output_tokens', 16000), + minimum=0 + ) + + with gr.Column(): + contextual = gr.Checkbox( + label="Contextual Translation", + value=self.get_config_value('contextual', False) + ) + + history_limit = gr.Number( + label="Translation History Limit", + value=self.get_config_value('translation_history_limit', 2), + minimum=0 + ) + + rolling_history = gr.Checkbox( + label="Rolling History Window", + value=self.get_config_value('translation_history_rolling', False) + ) + + batch_translation = gr.Checkbox( + label="Batch Translation", + value=self.get_config_value('batch_translation', True) + ) + + batch_size = gr.Number( + label="Batch Size", + value=self.get_config_value('batch_size', 10), + minimum=1 + ) + + gr.Markdown("---") + gr.Markdown("#### Chapter Processing Options") + + with gr.Row(): + with gr.Column(): + # Chapter Header Translation + batch_translate_headers = gr.Checkbox( + label="Batch Translate Headers", + value=self.get_config_value('batch_translate_headers', False) + ) + + headers_per_batch = gr.Number( + label="Headers per batch", + value=self.get_config_value('headers_per_batch', 400), + minimum=1 + ) + + # NCX and CSS options + use_ncx_navigation = gr.Checkbox( + label="Use NCX-only Navigation (Compatibility Mode)", + value=self.get_config_value('use_ncx_navigation', False) + ) + + attach_css_to_chapters = gr.Checkbox( + label="Attach CSS to Chapters (Fixes styling issues)", + value=self.get_config_value('attach_css_to_chapters', False) + ) + + retain_source_extension = gr.Checkbox( + label="Retain source extension (no 'response_' prefix)", + value=self.get_config_value('retain_source_extension', True) + ) + + with gr.Column(): + # Conservative Batching + use_conservative_batching = gr.Checkbox( + label="Use Conservative Batching", + value=self.get_config_value('use_conservative_batching', False), + info="Groups chapters in batches of 3x batch size for memory management" + ) + + # Gemini API Safety + disable_gemini_safety = gr.Checkbox( + label="Disable Gemini API Safety Filters", + value=self.get_config_value('disable_gemini_safety', False), + info="β οΈ Disables ALL content safety filters for Gemini models (BLOCK_NONE)" + ) + + # OpenRouter Options + use_http_openrouter = gr.Checkbox( + label="Use HTTP-only for OpenRouter (bypass SDK)", + value=self.get_config_value('use_http_openrouter', False), + info="Direct HTTP POST with explicit headers" + ) + + disable_openrouter_compression = gr.Checkbox( + label="Disable compression for OpenRouter (Accept-Encoding)", + value=self.get_config_value('disable_openrouter_compression', False), + info="Sends Accept-Encoding: identity for uncompressed responses" + ) + + gr.Markdown("---") + gr.Markdown("#### Chapter Extraction Settings") + + with gr.Row(): + with gr.Column(): + gr.Markdown("**Text Extraction Method:**") + text_extraction_method = gr.Radio( + choices=["standard", "enhanced"], + value=self.get_config_value('text_extraction_method', 'standard'), + label="", + info="Standard uses BeautifulSoup, Enhanced uses html2text", + interactive=True + ) + + gr.Markdown("β’ **Standard (BeautifulSoup)** - Traditional HTML parsing, fast and reliable") + gr.Markdown("β’ **Enhanced (html2text)** - Superior Unicode handling, cleaner text extraction") + + with gr.Column(): + gr.Markdown("**File Filtering Level:**") + file_filtering_level = gr.Radio( + choices=["smart", "comprehensive", "full"], + value=self.get_config_value('file_filtering_level', 'smart'), + label="", + info="Controls which files are extracted from EPUBs", + interactive=True + ) + + gr.Markdown("β’ **Smart (Aggressive Filtering)** - Skips navigation, TOC, copyright files") + gr.Markdown("β’ **Moderate** - Only skips obvious navigation files") + gr.Markdown("β’ **Full (No Filtering)** - Extracts ALL HTML/XHTML files") + + gr.Markdown("---") + gr.Markdown("#### Response Handling & Retry Logic") + + with gr.Row(): + with gr.Column(): + gr.Markdown("**GPT-5 Thinking (OpenRouter/OpenAI-style)**") + enable_gpt_thinking = gr.Checkbox( + label="Enable GPT / OR Thinking", + value=self.get_config_value('enable_gpt_thinking', True), + info="Controls GPT-5 and OpenRouter reasoning" + ) + + with gr.Row(): + gpt_thinking_effort = gr.Dropdown( + choices=["low", "medium", "high"], + value=self.get_config_value('gpt_thinking_effort', 'medium'), + label="Effort", + interactive=True + ) + + or_thinking_tokens = gr.Number( + label="OR Thinking Tokens", + value=self.get_config_value('or_thinking_tokens', 2000), + minimum=0, + maximum=50000, + info="tokens" + ) + + gr.Markdown("*Provide Tokens to force a max token budget for other models; GPT-5 only uses Effort (low/medium/high)*", elem_classes=["markdown-small"]) + + with gr.Column(): + gr.Markdown("**Gemini Thinking Mode**") + enable_gemini_thinking = gr.Checkbox( + label="Enable Gemini Thinking", + value=self.get_config_value('enable_gemini_thinking', False), + info="Control Gemini's thinking process", + interactive=True + ) + + gemini_thinking_budget = gr.Number( + label="Budget", + value=self.get_config_value('gemini_thinking_budget', 0), + minimum=0, + maximum=50000, + info="tokens (0 = disabled)", + interactive=True + ) + + gr.Markdown("*0 = disabled, 512-24576 = limited thinking*", elem_classes=["markdown-small"]) + + gr.Markdown("---") + gr.Markdown("π **API keys are encrypted** when saved to config using AES encryption.") + + save_api_key = gr.Checkbox( + label="Save API Key (Encrypted)", + value=True + ) + + save_status = gr.Textbox(label="Settings Status", value="Use the 'Save Config' button to save changes", interactive=False) + + # Hidden HTML component for JavaScript execution + js_executor = gr.HTML("", visible=False) + + # Auto-save function for settings tab + def save_settings_tab(thread_delay_val, api_delay_val, chapter_range_val, token_limit_val, disable_token_limit_val, output_token_limit_val, contextual_val, history_limit_val, rolling_history_val, batch_translation_val, batch_size_val, save_api_key_val): + """Save settings from the Settings tab""" + try: + current_config = self.get_current_config_for_update() + # Don't decrypt - just update non-encrypted fields + + # Update settings + current_config['thread_submission_delay'] = float(thread_delay_val) + current_config['api_call_delay'] = float(api_delay_val) + current_config['chapter_range'] = str(chapter_range_val) + current_config['token_limit'] = int(token_limit_val) + current_config['token_limit_disabled'] = bool(disable_token_limit_val) + current_config['max_output_tokens'] = int(output_token_limit_val) + current_config['contextual'] = bool(contextual_val) + current_config['translation_history_limit'] = int(history_limit_val) + current_config['translation_history_rolling'] = bool(rolling_history_val) + current_config['batch_translation'] = bool(batch_translation_val) + current_config['batch_size'] = int(batch_size_val) + + # CRITICAL: Update environment variables immediately + os.environ['SEND_INTERVAL_SECONDS'] = str(api_delay_val) + os.environ['THREAD_SUBMISSION_DELAY'] = str(thread_delay_val) + print(f"β Updated SEND_INTERVAL_SECONDS = {api_delay_val}s") + print(f"β Updated THREAD_SUBMISSION_DELAY = {thread_delay_val}s") + + # Save to file + self.save_config(current_config) + + # JavaScript to save to localStorage + js_code = """ + + """ % ( + thread_delay_val, api_delay_val, chapter_range_val, token_limit_val, + str(disable_token_limit_val).lower(), output_token_limit_val, + str(contextual_val).lower(), history_limit_val, + str(rolling_history_val).lower(), str(batch_translation_val).lower(), + batch_size_val + ) + + return "β Settings saved successfully", js_code + except Exception as e: + return f"β Failed to save: {str(e)}", "" + + # Settings tab auto-save handlers removed - use manual Save Config button + + # Token sync handlers removed - use manual Save Config button + + # Help Tab + with gr.Tab("β Help"): + gr.Markdown(""" + ## How to Use Glossarion + + ### Translation + 1. Upload an EPUB file + 2. Select AI model (GPT-4, Claude, etc.) + 3. Enter your API key + 4. Click "Translate" + 5. Download the translated EPUB + + ### Manga Translation + 1. Upload manga image(s) (PNG, JPG, etc.) + 2. Select AI model and enter API key + 3. Choose translation profile (e.g., Manga_JP, Manga_KR) + 4. Configure OCR settings (Google Cloud Vision recommended) + 5. Enable bubble detection and inpainting for best results + 6. Click "Translate Manga" + + ### Glossary Extraction + 1. Upload an EPUB file + 2. Configure extraction settings + 3. Click "Extract Glossary" + 4. Use the CSV in future translations + + ### API Keys + - **OpenAI**: Get from https://platform.openai.com/api-keys + - **Anthropic**: Get from https://console.anthropic.com/ + + ### Translation Profiles + Profiles contain detailed translation instructions and rules. + Select a profile that matches your source language and style preferences. + + You can create and edit profiles in the desktop application. + + ### Tips + - Use glossaries for consistent character name translation + - Lower temperature (0.1-0.3) for more literal translations + - Higher temperature (0.5-0.7) for more creative translations + """) + + # Create a comprehensive load function that refreshes ALL values + def load_all_settings(): + """Load all settings from config file on page refresh""" + # Reload config to get latest values + self.config = self.load_config() + self.decrypted_config = decrypt_config(self.config.copy()) if API_KEY_ENCRYPTION_AVAILABLE else self.config.copy() + + # CRITICAL: Reload profiles from config after reloading config + self.profiles = self.default_prompts.copy() + config_profiles = self.config.get('prompt_profiles', {}) + if config_profiles: + self.profiles.update(config_profiles) + + # Helper function to convert RGB arrays to hex + def to_hex_color(color_value, default='#000000'): + if isinstance(color_value, (list, tuple)) and len(color_value) >= 3: + return '#{:02x}{:02x}{:02x}'.format(int(color_value[0]), int(color_value[1]), int(color_value[2])) + elif isinstance(color_value, str): + return color_value if color_value.startswith('#') else default + return default + + # Return values for all tracked components + return [ + self.get_config_value('model', 'gpt-4-turbo'), # epub_model + self.get_config_value('api_key', ''), # epub_api_key + self.get_config_value('active_profile', list(self.profiles.keys())[0] if self.profiles else ''), # epub_profile + self.profiles.get(self.get_config_value('active_profile', ''), ''), # epub_system_prompt + self.get_config_value('temperature', 0.3), # epub_temperature + self.get_config_value('max_output_tokens', 16000), # epub_max_tokens + self.get_config_value('enable_image_translation', False), # enable_image_translation + self.get_config_value('enable_auto_glossary', False), # enable_auto_glossary + self.get_config_value('append_glossary_to_prompt', True), # append_glossary + # Auto glossary settings + self.get_config_value('glossary_min_frequency', 2), # auto_glossary_min_freq + self.get_config_value('glossary_max_names', 50), # auto_glossary_max_names + self.get_config_value('glossary_max_titles', 30), # auto_glossary_max_titles + self.get_config_value('glossary_batch_size', 50), # auto_glossary_batch_size + self.get_config_value('glossary_filter_mode', 'all'), # auto_glossary_filter_mode + self.get_config_value('glossary_fuzzy_threshold', 0.90), # auto_glossary_fuzzy_threshold + # Manual glossary extraction settings + self.get_config_value('manual_glossary_min_frequency', self.get_config_value('glossary_min_frequency', 2)), # min_freq + self.get_config_value('manual_glossary_max_names', self.get_config_value('glossary_max_names', 50)), # max_names_slider + self.get_config_value('manual_glossary_max_titles', self.get_config_value('glossary_max_titles', 30)), # max_titles + self.get_config_value('glossary_max_text_size', 50000), # max_text_size + self.get_config_value('glossary_max_sentences', 200), # max_sentences + self.get_config_value('manual_glossary_batch_size', self.get_config_value('glossary_batch_size', 50)), # translation_batch + self.get_config_value('glossary_chapter_split_threshold', 8192), # chapter_split_threshold + self.get_config_value('manual_glossary_filter_mode', self.get_config_value('glossary_filter_mode', 'all')), # filter_mode + self.get_config_value('strip_honorifics', True), # strip_honorifics + self.get_config_value('manual_glossary_fuzzy_threshold', self.get_config_value('glossary_fuzzy_threshold', 0.90)), # fuzzy_threshold + # Chapter processing options + self.get_config_value('batch_translate_headers', False), # batch_translate_headers + self.get_config_value('headers_per_batch', 400), # headers_per_batch + self.get_config_value('use_ncx_navigation', False), # use_ncx_navigation + self.get_config_value('attach_css_to_chapters', False), # attach_css_to_chapters + self.get_config_value('retain_source_extension', True), # retain_source_extension + self.get_config_value('use_conservative_batching', False), # use_conservative_batching + self.get_config_value('disable_gemini_safety', False), # disable_gemini_safety + self.get_config_value('use_http_openrouter', False), # use_http_openrouter + self.get_config_value('disable_openrouter_compression', False), # disable_openrouter_compression + self.get_config_value('text_extraction_method', 'standard'), # text_extraction_method + self.get_config_value('file_filtering_level', 'smart'), # file_filtering_level + # QA report format + self.get_config_value('qa_report_format', 'detailed'), # report_format + # Thinking mode settings + self.get_config_value('enable_gpt_thinking', True), # enable_gpt_thinking + self.get_config_value('gpt_thinking_effort', 'medium'), # gpt_thinking_effort + self.get_config_value('or_thinking_tokens', 2000), # or_thinking_tokens + self.get_config_value('enable_gemini_thinking', False), # enable_gemini_thinking - disabled by default + self.get_config_value('gemini_thinking_budget', 0), # gemini_thinking_budget - 0 = disabled + # Manga settings + self.get_config_value('model', 'gpt-4-turbo'), # manga_model + self.get_config_value('api_key', ''), # manga_api_key + self.get_config_value('active_profile', list(self.profiles.keys())[0] if self.profiles else ''), # manga_profile + self.profiles.get(self.get_config_value('active_profile', ''), ''), # manga_system_prompt + self.get_config_value('ocr_provider', 'custom-api'), # ocr_provider + self.get_config_value('azure_vision_key', ''), # azure_key + self.get_config_value('azure_vision_endpoint', ''), # azure_endpoint + self.get_config_value('bubble_detection_enabled', True), # bubble_detection + self.get_config_value('inpainting_enabled', True), # inpainting + self.get_config_value('manga_font_size_mode', 'auto'), # font_size_mode + self.get_config_value('manga_font_size', 24), # font_size + self.get_config_value('manga_font_multiplier', 1.0), # font_multiplier + self.get_config_value('manga_min_font_size', 12), # min_font_size + self.get_config_value('manga_max_font_size', 48), # max_font_size + # Convert colors to hex format if they're stored as RGB arrays (white text, black shadow like manga integration) + to_hex_color(self.get_config_value('manga_text_color', [255, 255, 255]), '#FFFFFF'), # text_color_rgb - default white + self.get_config_value('manga_shadow_enabled', True), # shadow_enabled + to_hex_color(self.get_config_value('manga_shadow_color', [0, 0, 0]), '#000000'), # shadow_color - default black + self.get_config_value('manga_shadow_offset_x', 2), # shadow_offset_x + self.get_config_value('manga_shadow_offset_y', 2), # shadow_offset_y + self.get_config_value('manga_shadow_blur', 0), # shadow_blur + self.get_config_value('manga_bg_opacity', 130), # bg_opacity + self.get_config_value('manga_bg_style', 'circle'), # bg_style + self.get_config_value('manga_settings', {}).get('advanced', {}).get('parallel_panel_translation', False), # parallel_panel_translation + self.get_config_value('manga_settings', {}).get('advanced', {}).get('panel_max_workers', 7), # panel_max_workers + ] + + + # SECURITY: Save Config button DISABLED to prevent API keys from being saved to persistent storage on HF Spaces + # This is a critical security measure to prevent API key leakage in shared environments + # save_config_btn.click( + # fn=save_all_config, + # inputs=[ + # # EPUB tab fields + # epub_model, epub_api_key, epub_profile, epub_temperature, epub_max_tokens, + # enable_image_translation, enable_auto_glossary, append_glossary, + # # Auto glossary settings + # auto_glossary_min_freq, auto_glossary_max_names, auto_glossary_max_titles, + # auto_glossary_batch_size, auto_glossary_filter_mode, auto_glossary_fuzzy_threshold, + # enable_post_translation_scan, + # # Manual glossary extraction settings + # min_freq, max_names_slider, max_titles, + # max_text_size, max_sentences, translation_batch, + # chapter_split_threshold, filter_mode, strip_honorifics, + # fuzzy_threshold, extraction_prompt, format_instructions, + # use_legacy_csv, + # # QA Scanner settings + # min_foreign_chars, check_repetition, check_glossary_leakage, + # min_file_length, check_multiple_headers, check_missing_html, + # check_insufficient_paragraphs, min_paragraph_percentage, + # report_format, auto_save_report, + # # Chapter processing options + # batch_translate_headers, headers_per_batch, use_ncx_navigation, + # attach_css_to_chapters, retain_source_extension, + # use_conservative_batching, disable_gemini_safety, + # use_http_openrouter, disable_openrouter_compression, + # text_extraction_method, file_filtering_level, + # # Thinking mode settings + # enable_gpt_thinking, gpt_thinking_effort, or_thinking_tokens, + # enable_gemini_thinking, gemini_thinking_budget, + # # Manga tab fields + # manga_model, manga_api_key, manga_profile, + # ocr_provider, azure_key, azure_endpoint, + # bubble_detection, inpainting, + # font_size_mode, font_size, font_multiplier, min_font_size, max_font_size, + # text_color_rgb, shadow_enabled, shadow_color, + # shadow_offset_x, shadow_offset_y, shadow_blur, + # bg_opacity, bg_style, + # parallel_panel_translation, panel_max_workers, + # # Advanced Settings fields + # detector_type, rtdetr_confidence, bubble_confidence, + # detect_text_bubbles, detect_empty_bubbles, detect_free_text, bubble_max_detections, + # local_inpaint_method, webtoon_mode, + # inpaint_batch_size, inpaint_cache_enabled, + # parallel_processing, max_workers, + # preload_local_inpainting, panel_start_stagger, + # torch_precision, auto_cleanup_models, + # debug_mode, save_intermediate, concise_pipeline_logs + # ], + # outputs=[save_status_text] + # ) + + # Add load handler to restore settings on page load + app.load( + fn=load_all_settings, + inputs=[], + outputs=[ + epub_model, epub_api_key, epub_profile, epub_system_prompt, epub_temperature, epub_max_tokens, + enable_image_translation, enable_auto_glossary, append_glossary, + # Auto glossary settings + auto_glossary_min_freq, auto_glossary_max_names, auto_glossary_max_titles, + auto_glossary_batch_size, auto_glossary_filter_mode, auto_glossary_fuzzy_threshold, + # Manual glossary extraction settings + min_freq, max_names_slider, max_titles, + max_text_size, max_sentences, translation_batch, + chapter_split_threshold, filter_mode, strip_honorifics, + fuzzy_threshold, + # Chapter processing options + batch_translate_headers, headers_per_batch, use_ncx_navigation, + attach_css_to_chapters, retain_source_extension, + use_conservative_batching, disable_gemini_safety, + use_http_openrouter, disable_openrouter_compression, + text_extraction_method, file_filtering_level, + report_format, + # Thinking mode settings + enable_gpt_thinking, gpt_thinking_effort, or_thinking_tokens, + enable_gemini_thinking, gemini_thinking_budget, + # Manga settings + manga_model, manga_api_key, manga_profile, manga_system_prompt, + ocr_provider, azure_key, azure_endpoint, bubble_detection, inpainting, + font_size_mode, font_size, font_multiplier, min_font_size, max_font_size, + text_color_rgb, shadow_enabled, shadow_color, shadow_offset_x, shadow_offset_y, + shadow_blur, bg_opacity, bg_style, parallel_panel_translation, panel_max_workers + ] + ) + + return app + + +def main(): + """Launch Gradio web app""" + print("π Starting Glossarion Web Interface...") + + # Check if running on Hugging Face Spaces + is_spaces = os.getenv('SPACE_ID') is not None or os.getenv('HF_SPACES') == 'true' + if is_spaces: + print("π€ Running on Hugging Face Spaces") + print(f"π Space ID: {os.getenv('SPACE_ID', 'Unknown')}") + print(f"π Files in current directory: {len(os.listdir('.'))} items") + print(f"π Working directory: {os.getcwd()}") + print(f"π Available manga modules: {MANGA_TRANSLATION_AVAILABLE}") + else: + print("π Running locally") + + web_app = GlossarionWeb() + app = web_app.create_interface() + + # Set favicon with absolute path if available (skip for Spaces) + favicon_path = None + if not is_spaces and os.path.exists("Halgakos.ico"): + favicon_path = os.path.abspath("Halgakos.ico") + print(f"β Using favicon: {favicon_path}") + elif not is_spaces: + print("β οΈ Halgakos.ico not found") + + # Launch with options appropriate for environment + launch_args = { + "server_name": "0.0.0.0", # Allow external access + "server_port": 7860, + "share": False, + "show_error": True, + } + + # Only add favicon for non-Spaces environments + if not is_spaces and favicon_path: + launch_args["favicon_path"] = favicon_path + + app.launch(**launch_args) + + +if __name__ == "__main__": + main() \ No newline at end of file