# manga_integration.py """ Enhanced GUI Integration module for Manga Translation with text visibility controls Integrates with TranslatorGUI using WindowManager and existing infrastructure Now includes full page context mode with customizable prompt """ import sys import os import json import threading import time import hashlib import traceback import concurrent.futures from PySide6.QtWidgets import (QWidget, QLabel, QFrame, QPushButton, QVBoxLayout, QHBoxLayout, QGroupBox, QListWidget, QComboBox, QLineEdit, QCheckBox, QRadioButton, QSlider, QSpinBox, QDoubleSpinBox, QTextEdit, QProgressBar, QFileDialog, QMessageBox, QColorDialog, QScrollArea, QDialog, QButtonGroup, QApplication) from PySide6.QtCore import Qt, QTimer, Signal, QObject, Slot from PySide6.QtGui import QFont, QColor, QTextCharFormat, QIcon import tkinter as tk from tkinter import ttk, filedialog as tk_filedialog, messagebox as tk_messagebox, scrolledtext try: import ttkbootstrap as tb except ImportError: tb = ttk from typing import List, Dict, Optional, Any from queue import Queue import logging from manga_translator import MangaTranslator, GOOGLE_CLOUD_VISION_AVAILABLE from manga_settings_dialog import MangaSettingsDialog # Try to import UnifiedClient for API initialization try: from unified_api_client import UnifiedClient except ImportError: UnifiedClient = None # Module-level function for multiprocessing (must be picklable) def _preload_models_worker(models_list, progress_queue): """Worker function to preload models in separate process (module-level for pickling)""" try: total_steps = len(models_list) for idx, (model_type, model_key, model_name, model_path) in enumerate(models_list): try: # Send start progress base_progress = int((idx / total_steps) * 100) progress_queue.put(('progress', base_progress, model_name)) if model_type == 'detector': from bubble_detector import BubbleDetector from manga_translator import MangaTranslator # Progress: 0-25% of this model's portion progress_queue.put(('progress', base_progress + int(25 / total_steps), f"{model_name} - Initializing")) bd = BubbleDetector() # Progress: 25-75% - loading model progress_queue.put(('progress', base_progress + int(50 / total_steps), f"{model_name} - Downloading/Loading")) if model_key == 'rtdetr_onnx': model_repo = model_path if model_path else 'ogkalu/comic-text-and-bubble-detector' bd.load_rtdetr_onnx_model(model_repo) elif model_key == 'rtdetr': bd.load_rtdetr_model() elif model_key == 'yolo': if model_path: bd.load_model(model_path) # Progress: 75-100% - finalizing progress_queue.put(('progress', base_progress + int(75 / total_steps), f"{model_name} - Finalizing")) progress_queue.put(('loaded', model_type, model_name)) elif model_type == 'inpainter': from local_inpainter import LocalInpainter # Progress: 0-25% progress_queue.put(('progress', base_progress + int(25 / total_steps), f"{model_name} - Initializing")) inp = LocalInpainter() resolved_path = model_path if not resolved_path or not os.path.exists(resolved_path): # Progress: 25-50% - downloading progress_queue.put(('progress', base_progress + int(40 / total_steps), f"{model_name} - Downloading")) try: resolved_path = inp.download_jit_model(model_key) except: resolved_path = None if resolved_path and os.path.exists(resolved_path): # Progress: 50-90% - loading progress_queue.put(('progress', base_progress + int(60 / total_steps), f"{model_name} - Loading model")) success = inp.load_model_with_retry(model_key, resolved_path) # Progress: 90-100% - finalizing progress_queue.put(('progress', base_progress + int(85 / total_steps), f"{model_name} - Finalizing")) if success: progress_queue.put(('loaded', model_type, model_name)) except Exception as e: progress_queue.put(('error', model_name, str(e))) # Send completion signal progress_queue.put(('complete', None, None)) except Exception as e: progress_queue.put(('error', 'Process', str(e))) class _MangaGuiLogHandler(logging.Handler): """Forward logging records into MangaTranslationTab._log.""" def __init__(self, gui_ref, level=logging.INFO): super().__init__(level) self.gui_ref = gui_ref self._last_msg = None self.setFormatter(logging.Formatter('%(levelname)s:%(name)s:%(message)s')) def emit(self, record: logging.LogRecord) -> None: # Avoid looping/duplicates from this module's own messages or when stdio is redirected try: if getattr(self.gui_ref, '_stdio_redirect_active', False): return if record and isinstance(record.name, str) and record.name.startswith(('manga_integration',)): return except Exception: pass try: msg = self.format(record) except Exception: msg = record.getMessage() # Deduplicate identical consecutive messages if msg == self._last_msg: return self._last_msg = msg # Map logging levels to our tag levels lvl = record.levelname.lower() tag = 'info' if lvl.startswith('warn'): tag = 'warning' elif lvl.startswith('err') or lvl.startswith('crit'): tag = 'error' elif lvl.startswith('debug'): tag = 'debug' elif lvl.startswith('info'): tag = 'info' # Always store to persistent log (even if GUI is closed) try: with MangaTranslationTab._persistent_log_lock: if len(MangaTranslationTab._persistent_log) >= 1000: MangaTranslationTab._persistent_log.pop(0) MangaTranslationTab._persistent_log.append((msg, tag)) except Exception: pass # Also try to display in GUI if it exists try: if hasattr(self.gui_ref, '_log'): self.gui_ref._log(msg, tag) except Exception: pass class _StreamToGuiLog: """A minimal file-like stream that forwards lines to _log.""" def __init__(self, write_cb): self._write_cb = write_cb self._buf = '' def write(self, s: str): try: self._buf += s while '\n' in self._buf: line, self._buf = self._buf.split('\n', 1) if line.strip(): self._write_cb(line) except Exception: pass def flush(self): try: if self._buf.strip(): self._write_cb(self._buf) self._buf = '' except Exception: pass class MangaTranslationTab: """GUI interface for manga translation integrated with TranslatorGUI""" # Class-level cancellation flag for all instances _global_cancelled = False _global_cancel_lock = threading.RLock() # Class-level log storage to persist across window closures _persistent_log = [] _persistent_log_lock = threading.RLock() # Class-level preload tracking to prevent duplicate loading _preload_in_progress = False _preload_lock = threading.RLock() _preload_completed_models = set() # Track which models have been loaded @classmethod def set_global_cancellation(cls, cancelled: bool): """Set global cancellation flag for all translation instances""" with cls._global_cancel_lock: cls._global_cancelled = cancelled @classmethod def is_globally_cancelled(cls) -> bool: """Check if globally cancelled""" with cls._global_cancel_lock: return cls._global_cancelled def __init__(self, parent_widget, main_gui, dialog, scroll_area=None): """Initialize manga translation interface Args: parent_widget: The content widget for the interface (PySide6 QWidget) main_gui: Reference to TranslatorGUI instance dialog: The dialog window (PySide6 QDialog) scroll_area: The scroll area widget (PySide6 QScrollArea, optional) """ self.parent_widget = parent_widget self.main_gui = main_gui self.dialog = dialog self.scroll_area = scroll_area # Translation state self.translator = None self.is_running = False self.stop_flag = threading.Event() self.translation_thread = None self.translation_future = None # Shared executor from main GUI if available try: if hasattr(self.main_gui, 'executor') and self.main_gui.executor: self.executor = self.main_gui.executor else: self.executor = None except Exception: self.executor = None self.selected_files = [] self.current_file_index = 0 self.font_mapping = {} # Initialize font mapping dictionary # Progress tracking self.total_files = 0 self.completed_files = 0 self.failed_files = 0 self.qwen2vl_model_size = self.main_gui.config.get('qwen2vl_model_size', '1') # Advanced performance toggles try: adv_cfg = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) except Exception: adv_cfg = {} # In singleton mode, reduce OpenCV thread usage to avoid CPU spikes try: if bool(adv_cfg.get('use_singleton_models', False)): import cv2 as _cv2 try: _cv2.setNumThreads(1) except Exception: pass except Exception: pass # Do NOT preload big local models by default to avoid startup crashes self.preload_local_models_on_open = bool(adv_cfg.get('preload_local_models_on_open', False)) # Queue for thread-safe GUI updates self.update_queue = Queue() # Flags for stdio redirection to avoid duplicate GUI logs self._stdout_redirect_on = False self._stderr_redirect_on = False self._stdio_redirect_active = False # Flag to prevent saving during initialization self._initializing = True # IMPORTANT: Load settings BEFORE building interface # This ensures all variables are initialized before they're used in the GUI self._load_rendering_settings() # Initialize the full page context prompt self.full_page_context_prompt = ( "You will receive multiple text segments from a manga page. " "Translate each segment considering the context of all segments together. " "Maintain consistency in character names, tone, and style across all translations.\n\n" "IMPORTANT: Return your response as a valid JSON object where each key is the EXACT original text " "(without the [0], [1] index prefixes) and each value is the translation.\n" "Make sure to properly escape any special characters in the JSON:\n" "- Use \\n for newlines\n" "- Use \\\" for quotes\n" "- Use \\\\ for backslashes\n\n" "Example:\n" '{\n' ' こんにちは: Hello,\n' ' ありがとう: Thank you\n' '}\n\n' 'Do NOT include the [0], [1], etc. prefixes in the JSON keys.' ) # Initialize the OCR system prompt self.ocr_prompt = self.main_gui.config.get('manga_ocr_prompt', "YOU ARE AN OCR SYSTEM. YOUR ONLY JOB IS TEXT EXTRACTION.\n\n" "CRITICAL RULES:\n" "1. DO NOT TRANSLATE ANYTHING\n" "2. DO NOT MODIFY THE TEXT\n" "3. DO NOT EXPLAIN OR COMMENT\n" "4. ONLY OUTPUT THE EXACT TEXT YOU SEE\n" "5. PRESERVE NATURAL TEXT FLOW - DO NOT ADD UNNECESSARY LINE BREAKS\n\n" "If you see Korean text, output it in Korean.\n" "If you see Japanese text, output it in Japanese.\n" "If you see Chinese text, output it in Chinese.\n" "If you see English text, output it in English.\n\n" "IMPORTANT: Only use line breaks where they naturally occur in the original text " "(e.g., between dialogue lines or paragraphs). Do not break text mid-sentence or " "between every word/character.\n\n" "For vertical text common in manga/comics, transcribe it as a continuous line unless " "there are clear visual breaks.\n\n" "NEVER translate. ONLY extract exactly what is written.\n" "Output ONLY the raw text, nothing else." ) # flag to skip status checks during init self._initializing_gui = True # Build interface AFTER loading settings self._build_interface() # Now allow status checks self._initializing_gui = False # Do one status check after everything is built # Use QTimer for PySide6 dialog QTimer.singleShot(100, self._check_provider_status) # Start model preloading in background QTimer.singleShot(200, self._start_model_preloading) # Now that everything is initialized, allow saving self._initializing = False # Attach logging bridge so library logs appear in our log area self._attach_logging_bridge() # Start update loop self._process_updates() def _is_stop_requested(self) -> bool: """Check if stop has been requested using multiple sources""" # Check global cancellation first if self.is_globally_cancelled(): return True # Check local stop flag if hasattr(self, 'stop_flag') and self.stop_flag.is_set(): return True # Check running state if hasattr(self, 'is_running') and not self.is_running: return True return False def _reset_global_cancellation(self): """Reset all global cancellation flags for new translation""" # Reset local class flag self.set_global_cancellation(False) # Reset MangaTranslator class flag try: from manga_translator import MangaTranslator MangaTranslator.set_global_cancellation(False) except ImportError: pass # Reset UnifiedClient flag try: from unified_api_client import UnifiedClient UnifiedClient.set_global_cancellation(False) except ImportError: pass def reset_stop_flags(self): """Reset all stop flags when starting new translation""" self.is_running = False if hasattr(self, 'stop_flag'): self.stop_flag.clear() self._reset_global_cancellation() self._log("🔄 Stop flags reset for new translation", "debug") def _distribute_stop_flags(self): """Distribute stop flags to all manga translation components""" if not hasattr(self, 'translator') or not self.translator: return # Set stop flag on translator if hasattr(self.translator, 'set_stop_flag'): self.translator.set_stop_flag(self.stop_flag) # Set stop flag on OCR manager and all providers if hasattr(self.translator, 'ocr_manager') and self.translator.ocr_manager: if hasattr(self.translator.ocr_manager, 'set_stop_flag'): self.translator.ocr_manager.set_stop_flag(self.stop_flag) # Set stop flag on bubble detector if available if hasattr(self.translator, 'bubble_detector') and self.translator.bubble_detector: if hasattr(self.translator.bubble_detector, 'set_stop_flag'): self.translator.bubble_detector.set_stop_flag(self.stop_flag) # Set stop flag on local inpainter if available if hasattr(self.translator, 'local_inpainter') and self.translator.local_inpainter: if hasattr(self.translator.local_inpainter, 'set_stop_flag'): self.translator.local_inpainter.set_stop_flag(self.stop_flag) # Also try to set on thread-local components if accessible if hasattr(self.translator, '_thread_local'): thread_local = self.translator._thread_local # Set on thread-local bubble detector if hasattr(thread_local, 'bubble_detector') and thread_local.bubble_detector: if hasattr(thread_local.bubble_detector, 'set_stop_flag'): thread_local.bubble_detector.set_stop_flag(self.stop_flag) # Set on thread-local inpainters if hasattr(thread_local, 'local_inpainters') and isinstance(thread_local.local_inpainters, dict): for inpainter in thread_local.local_inpainters.values(): if hasattr(inpainter, 'set_stop_flag'): inpainter.set_stop_flag(self.stop_flag) self._log("🔄 Stop flags distributed to all components", "debug") def _preflight_bubble_detector(self, ocr_settings: dict) -> bool: """Check if bubble detector is preloaded in the pool or already loaded. Returns True if a ready instance or preloaded spare is available; no heavy loads are performed here. """ try: import time as _time start = _time.time() if not ocr_settings.get('bubble_detection_enabled', False): return False det_type = ocr_settings.get('detector_type', 'rtdetr_onnx') model_id = ocr_settings.get('rtdetr_model_url') or ocr_settings.get('bubble_model_path') or '' # 1) If translator already has a ready detector, report success try: bd = getattr(self, 'translator', None) and getattr(self.translator, 'bubble_detector', None) if bd and (getattr(bd, 'rtdetr_loaded', False) or getattr(bd, 'rtdetr_onnx_loaded', False) or getattr(bd, 'model_loaded', False)): self._log("🤖 Bubble detector already loaded", "debug") return True except Exception: pass # 2) Check shared preload pool for spares try: from manga_translator import MangaTranslator key = (det_type, model_id) with MangaTranslator._detector_pool_lock: rec = MangaTranslator._detector_pool.get(key) spares = (rec or {}).get('spares') or [] if len(spares) > 0: self._log(f"🤖 Preflight: found {len(spares)} preloaded bubble detector spare(s) for key={key}", "info") return True except Exception: pass # 3) No spares/ready detector yet; do not load here. Just report timing and return False. elapsed = _time.time() - start self._log(f"⏱️ Preflight checked bubble detector pool in {elapsed:.2f}s — no ready instance", "debug") return False except Exception: return False def _start_model_preloading(self): """Start preloading models in separate process for true background loading""" from multiprocessing import Process, Queue as MPQueue import queue # Check if preload is already in progress with MangaTranslationTab._preload_lock: if MangaTranslationTab._preload_in_progress: print("Model preloading already in progress, skipping...") return # Get settings manga_settings = self.main_gui.config.get('manga_settings', {}) ocr_settings = manga_settings.get('ocr', {}) inpaint_settings = manga_settings.get('inpainting', {}) models_to_load = [] bubble_detection_enabled = ocr_settings.get('bubble_detection_enabled', False) skip_inpainting = self.main_gui.config.get('manga_skip_inpainting', False) inpainting_method = inpaint_settings.get('method', 'local') inpainting_enabled = not skip_inpainting and inpainting_method == 'local' # Check if models need loading try: from manga_translator import MangaTranslator if bubble_detection_enabled: detector_type = ocr_settings.get('detector_type', 'rtdetr_onnx') model_url = ocr_settings.get('rtdetr_model_url') or ocr_settings.get('bubble_model_path') or '' key = (detector_type, model_url) model_id = f"detector_{detector_type}_{model_url}" # Skip if already loaded in this session if model_id not in MangaTranslationTab._preload_completed_models: with MangaTranslator._detector_pool_lock: rec = MangaTranslator._detector_pool.get(key) if not rec or (not rec.get('spares') and not rec.get('loaded')): detector_name = 'RT-DETR ONNX' if detector_type == 'rtdetr_onnx' else 'RT-DETR' if detector_type == 'rtdetr' else 'YOLO' models_to_load.append(('detector', detector_type, detector_name, model_url)) if inpainting_enabled: local_method = inpaint_settings.get('local_method', 'anime') model_path = self.main_gui.config.get(f'manga_{local_method}_model_path', '') key = (local_method, model_path or '') model_id = f"inpainter_{local_method}_{model_path}" # Skip if already loaded in this session if model_id not in MangaTranslationTab._preload_completed_models: with MangaTranslator._inpaint_pool_lock: rec = MangaTranslator._inpaint_pool.get(key) if not rec or (not rec.get('loaded') and not rec.get('spares')): models_to_load.append(('inpainter', local_method, local_method.capitalize(), model_path)) except Exception as e: print(f"Error checking models: {e}") return if not models_to_load: return # Set preload in progress flag with MangaTranslationTab._preload_lock: MangaTranslationTab._preload_in_progress = True # Show progress bar self.preload_progress_frame.setVisible(True) # Create queue for IPC progress_queue = MPQueue() # Start loading in separate process using module-level function load_process = Process(target=_preload_models_worker, args=(models_to_load, progress_queue), daemon=True) load_process.start() # Store models being loaded for tracking models_being_loaded = [] for model_type, model_key, model_name, model_path in models_to_load: if model_type == 'detector': models_being_loaded.append(f"detector_{model_key}_{model_path}") elif model_type == 'inpainter': models_being_loaded.append(f"inpainter_{model_key}_{model_path}") # Monitor progress with QTimer def check_progress(): try: while True: try: msg = progress_queue.get_nowait() msg_type = msg[0] if msg_type == 'progress': _, progress, model_name = msg self.preload_progress_bar.setValue(progress) self.preload_status_label.setText(f"Loading {model_name}...") elif msg_type == 'loaded': _, model_type, model_name = msg print(f"✓ Loaded {model_name}") elif msg_type == 'error': _, model_name, error = msg print(f"✗ Failed to load {model_name}: {error}") elif msg_type == 'complete': # Child process cached models self.preload_progress_bar.setValue(100) self.preload_status_label.setText("✓ Models ready") # Mark all models as completed with MangaTranslationTab._preload_lock: MangaTranslationTab._preload_completed_models.update(models_being_loaded) MangaTranslationTab._preload_in_progress = False # Load RT-DETR into pool in background (doesn't block GUI) def load_rtdetr_bg(): try: from manga_translator import MangaTranslator from bubble_detector import BubbleDetector for model_type, model_key, model_name, model_path in models_to_load: if model_type == 'detector' and model_key == 'rtdetr_onnx': key = (model_key, model_path) # Check if already loaded with MangaTranslator._detector_pool_lock: rec = MangaTranslator._detector_pool.get(key) if rec and rec.get('spares'): print(f"⏭️ {model_name} already in pool") continue # Load into pool bd = BubbleDetector() model_repo = model_path if model_path else 'ogkalu/comic-text-and-bubble-detector' bd.load_rtdetr_onnx_model(model_repo) with MangaTranslator._detector_pool_lock: rec = MangaTranslator._detector_pool.get(key) if not rec: rec = {'spares': []} MangaTranslator._detector_pool[key] = rec rec['spares'].append(bd) print(f"✓ Loaded {model_name} into pool (background)") except Exception as e: print(f"✗ Background RT-DETR loading error: {e}") # Start background loading threading.Thread(target=load_rtdetr_bg, daemon=True).start() QTimer.singleShot(2000, lambda: self.preload_progress_frame.setVisible(False)) return except queue.Empty: break QTimer.singleShot(100, check_progress) except Exception as e: print(f"Progress check error: {e}") self.preload_progress_frame.setVisible(False) # Reset flag on error with MangaTranslationTab._preload_lock: MangaTranslationTab._preload_in_progress = False QTimer.singleShot(100, check_progress) def _disable_spinbox_mousewheel(self, spinbox): """Disable mousewheel scrolling on a spinbox (PySide6)""" # Override wheelEvent to prevent scrolling spinbox.wheelEvent = lambda event: None def _disable_combobox_mousewheel(self, combobox): """Disable mousewheel scrolling on a combobox (PySide6)""" # Override wheelEvent to prevent scrolling combobox.wheelEvent = lambda event: None def _create_styled_checkbox(self, text): """Create a checkbox with proper checkmark using text overlay""" from PySide6.QtWidgets import QCheckBox, QLabel from PySide6.QtCore import Qt from PySide6.QtGui import QFont checkbox = QCheckBox(text) checkbox.setStyleSheet(""" QCheckBox { color: white; spacing: 6px; } QCheckBox::indicator { width: 14px; height: 14px; border: 1px solid #5a9fd4; border-radius: 2px; background-color: #2d2d2d; } QCheckBox::indicator:checked { background-color: #5a9fd4; border-color: #5a9fd4; } QCheckBox::indicator:hover { border-color: #7bb3e0; } QCheckBox:disabled { color: #666666; } QCheckBox::indicator:disabled { background-color: #1a1a1a; border-color: #3a3a3a; } """) # Create checkmark overlay checkmark = QLabel("✓", checkbox) checkmark.setStyleSheet(""" QLabel { color: white; background: transparent; font-weight: bold; font-size: 12px; } """) checkmark.setAlignment(Qt.AlignCenter) checkmark.setGeometry(1, 0, 16, 16) checkmark.hide() checkmark.setAttribute(Qt.WA_TransparentForMouseEvents) # Make checkmark click-through # Show/hide checkmark based on checked state def update_checkmark(): if checkbox.isChecked(): checkmark.show() else: checkmark.hide() checkbox.stateChanged.connect(update_checkmark) update_checkmark() # Initial state return checkbox def _download_hf_model(self): """Download HuggingFace models with progress tracking""" provider = self.ocr_provider_value # Model sizes (approximate in MB) model_sizes = { 'manga-ocr': 450, 'Qwen2-VL': { '2B': 4000, '7B': 14000, '72B': 144000, 'custom': 10000 # Default estimate for custom models } } # For Qwen2-VL, show model selection dialog first if provider == 'Qwen2-VL': # Use window manager from main_gui - pass Tkinter root instead of PySide6 dialog selection_dialog, scrollable_frame, canvas = self.main_gui.wm.setup_scrollable( self.main_gui.master, "Select Qwen2-VL Model Size", width=None, height=None, max_width_ratio=0.6, max_height_ratio=0.3 ) # Title title_frame = tk.Frame(scrollable_frame) title_frame.pack(fill=tk.X, pady=(10, 20)) tk.Label(title_frame, text="Select Qwen2-VL Model Size", font=('Arial', 14, 'bold')).pack() # Model selection frame model_frame = tk.LabelFrame( scrollable_frame, text="Model Options", font=('Arial', 11, 'bold'), padx=15, pady=10 ) model_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=10) model_options = { "2B": { "title": "2B Model", "desc": "• Smallest model (~4GB download, 4-8GB VRAM)\n• Fast but less accurate\n• Good for quick testing" }, "7B": { "title": "7B Model", "desc": "• Medium model (~14GB download, 12-16GB VRAM)\n• Best balance of speed and quality\n• Recommended for most users" }, "72B": { "title": "72B Model", "desc": "• Largest model (~144GB download, 80GB+ VRAM)\n• Highest quality but very slow\n• Requires high-end GPU" }, "custom": { "title": "Custom Model", "desc": "• Enter any Hugging Face model ID\n• For advanced users\n• Size varies by model" } } selected_model = tk.StringVar(value="2B") custom_model_id = tk.StringVar() for key, info in model_options.items(): option_frame = tk.Frame(model_frame) option_frame.pack(fill=tk.X, pady=5) rb = tk.Radiobutton(option_frame, text=info["title"], variable=selected_model, value=key, font=('Arial', 11, 'bold')) rb.pack(anchor='w') desc_label = tk.Label(option_frame, text=info["desc"], font=('Arial', 9), justify=tk.LEFT, fg='#666666') desc_label.pack(anchor='w', padx=(20, 0)) if key != "custom": ttk.Separator(option_frame, orient='horizontal').pack(fill=tk.X, pady=(5, 0)) # Custom model ID frame custom_frame = tk.LabelFrame( scrollable_frame, text="Custom Model ID", font=('Arial', 11, 'bold'), padx=15, pady=10 ) entry_frame = tk.Frame(custom_frame) entry_frame.pack(fill=tk.X, pady=5) tk.Label(entry_frame, text="Model ID:", font=('Arial', 10)).pack(side=tk.LEFT, padx=(0, 10)) custom_entry = tk.Entry(entry_frame, textvariable=custom_model_id, width=40, font=('Arial', 10)) custom_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) def toggle_custom_frame(*args): if selected_model.get() == "custom": custom_frame.pack(fill=tk.X, padx=20, pady=10, after=model_frame) else: custom_frame.pack_forget() selected_model.trace('w', toggle_custom_frame) # GPU status frame gpu_frame = tk.LabelFrame( scrollable_frame, text="System Status", font=('Arial', 11, 'bold'), padx=15, pady=10 ) gpu_frame.pack(fill=tk.X, padx=20, pady=10) try: import torch if torch.cuda.is_available(): gpu_mem = torch.cuda.get_device_properties(0).total_memory / 1e9 gpu_text = f"✓ GPU: {torch.cuda.get_device_name(0)} ({gpu_mem:.1f}GB)" gpu_color = '#4CAF50' else: gpu_text = "✗ No GPU detected - will use CPU (very slow)" gpu_color = '#f44336' except: gpu_text = "? GPU status unknown - install torch with CUDA" gpu_color = '#FF9800' tk.Label(gpu_frame, text=gpu_text, font=('Arial', 10), fg=gpu_color).pack(anchor='w') # Buttons button_frame = tk.Frame(scrollable_frame) button_frame.pack(fill=tk.X, pady=20) model_confirmed = {'value': False, 'model_key': None, 'model_id': None} def confirm_selection(): selected = selected_model.get() if selected == "custom": if not custom_model_id.get().strip(): from PySide6.QtWidgets import QMessageBox QMessageBox.critical(selection_dialog, "Error", "Please enter a model ID") return model_confirmed['model_key'] = selected model_confirmed['model_id'] = custom_model_id.get().strip() else: model_confirmed['model_key'] = selected model_confirmed['model_id'] = f"Qwen/Qwen2-VL-{selected}-Instruct" model_confirmed['value'] = True selection_dialog.destroy() # Center the buttons by creating an inner frame button_inner_frame = tk.Frame(button_frame) button_inner_frame.pack() proceed_btn = tk.Button( button_inner_frame, text="Continue", command=confirm_selection, bg='#4CAF50', fg='white', font=('Arial', 10, 'bold'), padx=20, pady=8, cursor='hand2' ) proceed_btn.pack(side=tk.LEFT, padx=5) cancel_btn = tk.Button( button_inner_frame, text="Cancel", command=selection_dialog.destroy, bg='#9E9E9E', fg='white', font=('Arial', 10), padx=20, pady=8, cursor='hand2' ) cancel_btn.pack(side=tk.LEFT, padx=5) # Auto-resize and wait self.main_gui.wm.auto_resize_dialog(selection_dialog, canvas, max_width_ratio=0.5, max_height_ratio=0.6) self.dialog.wait_window(selection_dialog) if not model_confirmed['value']: return selected_model_key = model_confirmed['model_key'] model_id = model_confirmed['model_id'] total_size_mb = model_sizes['Qwen2-VL'][selected_model_key] elif provider == 'rapidocr': total_size_mb = 50 # Approximate size for display model_id = None selected_model_key = None else: total_size_mb = model_sizes.get(provider, 500) model_id = None selected_model_key = None # Create download dialog with window manager - pass Tkinter root instead of PySide6 dialog download_dialog, scrollable_frame, canvas = self.main_gui.wm.setup_scrollable( self.main_gui.master, f"Download {provider} Model", width=600, height=450, max_width_ratio=0.6, max_height_ratio=0.6 ) # Info section info_frame = tk.LabelFrame( scrollable_frame, text="Model Information", font=('Arial', 11, 'bold'), padx=15, pady=10 ) info_frame.pack(fill=tk.X, padx=20, pady=10) if provider == 'Qwen2-VL': info_text = f"📚 Qwen2-VL {selected_model_key} Model\n" info_text += f"Model ID: {model_id}\n" info_text += f"Estimated size: ~{total_size_mb/1000:.1f}GB\n" info_text += "Vision-Language model for Korean OCR" else: info_text = f"📚 {provider} Model\nOptimized for manga/manhwa text detection" tk.Label(info_frame, text=info_text, font=('Arial', 10), justify=tk.LEFT).pack(anchor='w') # Progress section progress_frame = tk.LabelFrame( scrollable_frame, text="Download Progress", font=('Arial', 11, 'bold'), padx=15, pady=10 ) progress_frame.pack(fill=tk.X, padx=20, pady=10) progress_label = tk.Label(progress_frame, text="Ready to download", font=('Arial', 10)) progress_label.pack(pady=(5, 10)) progress_var = tk.DoubleVar() try: # Try to use our custom progress bar style progress_bar = ttk.Progressbar(progress_frame, length=550, mode='determinate', variable=progress_var, style="MangaProgress.Horizontal.TProgressbar") except Exception: # Fallback to default if style not available yet progress_bar = ttk.Progressbar(progress_frame, length=550, mode='determinate', variable=progress_var) progress_bar.pack(pady=(0, 5)) size_label = tk.Label(progress_frame, text="", font=('Arial', 9), fg='#666666') size_label.pack() speed_label = tk.Label(progress_frame, text="", font=('Arial', 9), fg='#666666') speed_label.pack() status_label = tk.Label(progress_frame, text="Click 'Download' to begin", font=('Arial', 9), fg='#666666') status_label.pack(pady=(5, 0)) # Log section log_frame = tk.LabelFrame( scrollable_frame, text="Download Log", font=('Arial', 11, 'bold'), padx=15, pady=10 ) log_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=10) # Create a frame to hold the text widget and scrollbar text_frame = tk.Frame(log_frame) text_frame.pack(fill=tk.BOTH, expand=True) details_text = tk.Text( text_frame, height=12, width=70, font=('Courier', 9), bg='#f5f5f5' ) details_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # Attach scrollbar to the frame, not the text widget scrollbar = ttk.Scrollbar(text_frame, command=details_text.yview) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) details_text.config(yscrollcommand=scrollbar.set) def add_log(message): """Add message to log""" details_text.insert(tk.END, f"{message}\n") details_text.see(tk.END) details_text.update() # Buttons frame button_frame = tk.Frame(download_dialog) button_frame.pack(pady=15) # Download tracking variables download_active = {'value': False} def get_dir_size(path): """Get total size of directory""" total = 0 try: for dirpath, dirnames, filenames in os.walk(path): for filename in filenames: filepath = os.path.join(dirpath, filename) if os.path.exists(filepath): total += os.path.getsize(filepath) except: pass return total def download_with_progress(): """Download model with real progress tracking""" import time download_active['value'] = True total_size = total_size_mb * 1024 * 1024 try: if provider == 'manga-ocr': progress_label.config(text="Loading manga-ocr model...") add_log("Initializing manga-ocr...") progress_var.set(10) from manga_ocr import MangaOcr cache_dir = os.path.expanduser("~/.cache/huggingface/hub") initial_size = get_dir_size(cache_dir) if os.path.exists(cache_dir) else 0 def init_model_with_progress(): start_time = time.time() import threading model_ready = threading.Event() model_instance = [None] def init_model(): model_instance[0] = MangaOcr() model_ready.set() init_thread = threading.Thread(target=init_model) init_thread.start() while not model_ready.is_set() and download_active['value']: current_size = get_dir_size(cache_dir) if os.path.exists(cache_dir) else 0 downloaded = current_size - initial_size if downloaded > 0: progress = min((downloaded / total_size) * 100, 99) progress_var.set(progress) elapsed = time.time() - start_time if elapsed > 0: speed = downloaded / elapsed speed_mb = speed / (1024 * 1024) speed_label.config(text=f"Speed: {speed_mb:.1f} MB/s") mb_downloaded = downloaded / (1024 * 1024) mb_total = total_size / (1024 * 1024) size_label.config(text=f"{mb_downloaded:.1f} MB / {mb_total:.1f} MB") progress_label.config(text=f"Downloading: {progress:.1f}%") time.sleep(0.5) init_thread.join(timeout=1) return model_instance[0] model = init_model_with_progress() if model: progress_var.set(100) size_label.config(text=f"{total_size_mb} MB / {total_size_mb} MB") progress_label.config(text="✅ Download complete!") status_label.config(text="Model ready to use!") self.ocr_manager.get_provider('manga-ocr').model = model self.ocr_manager.get_provider('manga-ocr').is_loaded = True self.ocr_manager.get_provider('manga-ocr').is_installed = True self.dialog.after(0, self._check_provider_status) elif provider == 'Qwen2-VL': try: from transformers import AutoProcessor, AutoTokenizer, AutoModelForVision2Seq import torch except ImportError as e: progress_label.config(text="❌ Missing dependencies") status_label.config(text="Install dependencies first") add_log(f"ERROR: {str(e)}") add_log("Please install manually:") add_log("pip install transformers torch torchvision") return progress_label.config(text=f"Downloading model...") add_log(f"Starting download of {model_id}") progress_var.set(10) add_log("Downloading processor...") status_label.config(text="Downloading processor...") processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=True) progress_var.set(30) add_log("✓ Processor downloaded") add_log("Downloading tokenizer...") status_label.config(text="Downloading tokenizer...") tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True) progress_var.set(50) add_log("✓ Tokenizer downloaded") add_log("Downloading model weights (this may take several minutes)...") status_label.config(text="Downloading model weights...") progress_label.config(text="Downloading model weights...") if torch.cuda.is_available(): add_log(f"Using GPU: {torch.cuda.get_device_name(0)}") model = AutoModelForVision2Seq.from_pretrained( model_id, dtype=torch.float16, device_map="auto", trust_remote_code=True ) else: add_log("No GPU detected, will load on CPU") model = AutoModelForVision2Seq.from_pretrained( model_id, dtype=torch.float32, trust_remote_code=True ) progress_var.set(90) add_log("✓ Model weights downloaded") add_log("Initializing model...") status_label.config(text="Initializing...") qwen_provider = self.ocr_manager.get_provider('Qwen2-VL') if qwen_provider: qwen_provider.processor = processor qwen_provider.tokenizer = tokenizer qwen_provider.model = model qwen_provider.model.eval() qwen_provider.is_loaded = True qwen_provider.is_installed = True if selected_model_key: qwen_provider.loaded_model_size = selected_model_key progress_var.set(100) progress_label.config(text="✅ Download complete!") status_label.config(text="Model ready for Korean OCR!") add_log("✓ Model ready to use!") self.dialog.after(0, self._check_provider_status) elif provider == 'rapidocr': progress_label.config(text="📦 RapidOCR Installation Instructions") add_log("RapidOCR requires manual pip installation") progress_var.set(20) add_log("Command to run:") add_log("pip install rapidocr-onnxruntime") progress_var.set(50) add_log("") add_log("After installation:") add_log("1. Close this dialog") add_log("2. Click 'Load Model' to initialize RapidOCR") add_log("3. Status should show '✅ Model loaded'") progress_var.set(100) progress_label.config(text="📦 Installation instructions shown") status_label.config(text="Manual pip install required") download_btn.config(state=tk.DISABLED) cancel_btn.config(text="Close") except Exception as e: progress_label.config(text="❌ Download failed") status_label.config(text=f"Error: {str(e)[:50]}") add_log(f"ERROR: {str(e)}") self._log(f"Download error: {str(e)}", "error") finally: download_active['value'] = False def start_download(): """Start download in background thread or executor""" download_btn.config(state=tk.DISABLED) cancel_btn.config(text="Cancel") try: if hasattr(self.main_gui, '_ensure_executor'): self.main_gui._ensure_executor() execu = getattr(self.main_gui, 'executor', None) if execu: execu.submit(download_with_progress) else: import threading download_thread = threading.Thread(target=download_with_progress, daemon=True) download_thread.start() except Exception: import threading download_thread = threading.Thread(target=download_with_progress, daemon=True) download_thread.start() def cancel_download(): """Cancel or close dialog""" if download_active['value']: download_active['value'] = False status_label.config(text="Cancelling...") else: download_dialog.destroy() download_btn = tb.Button(button_frame, text="Download", command=start_download, bootstyle="primary") download_btn.pack(side=tk.LEFT, padx=5) cancel_btn = tb.Button(button_frame, text="Close", command=cancel_download, bootstyle="secondary") cancel_btn.pack(side=tk.LEFT, padx=5) # Auto-resize self.main_gui.wm.auto_resize_dialog(download_dialog, canvas, max_width_ratio=0.5, max_height_ratio=0.6) def _check_provider_status(self): """Check and display OCR provider status""" # Skip during initialization to prevent lag if hasattr(self, '_initializing_gui') and self._initializing_gui: if hasattr(self, 'provider_status_label'): self.provider_status_label.setText("") self.provider_status_label.setStyleSheet("color: black;") return # Get provider value if not hasattr(self, 'ocr_provider_value'): # Not initialized yet, skip return provider = self.ocr_provider_value # Hide ALL buttons first if hasattr(self, 'provider_setup_btn'): self.provider_setup_btn.setVisible(False) if hasattr(self, 'download_model_btn'): self.download_model_btn.setVisible(False) if provider == 'google': # Google - check for credentials file google_creds = self.main_gui.config.get('google_vision_credentials', '') if google_creds and os.path.exists(google_creds): self.provider_status_label.setText("✅ Ready") self.provider_status_label.setStyleSheet("color: green;") else: self.provider_status_label.setText("❌ Credentials needed") self.provider_status_label.setStyleSheet("color: red;") elif provider == 'azure': # Azure - check for API key azure_key = self.main_gui.config.get('azure_vision_key', '') if azure_key: self.provider_status_label.setText("✅ Ready") self.provider_status_label.setStyleSheet("color: green;") else: self.provider_status_label.setText("❌ Key needed") self.provider_status_label.setStyleSheet("color: red;") elif provider == 'custom-api': # Custom API - check for main API key api_key = None if hasattr(self.main_gui, 'api_key_entry') and self.main_gui.api_key_entry.get().strip(): api_key = self.main_gui.api_key_entry.get().strip() elif hasattr(self.main_gui, 'config') and self.main_gui.config.get('api_key'): api_key = self.main_gui.config.get('api_key') # Check if AI bubble detection is enabled manga_settings = self.main_gui.config.get('manga_settings', {}) ocr_settings = manga_settings.get('ocr', {}) bubble_detection_enabled = ocr_settings.get('bubble_detection_enabled', False) if api_key: if bubble_detection_enabled: self.provider_status_label.setText("✅ Ready") self.provider_status_label.setStyleSheet("color: green;") else: self.provider_status_label.setText("⚠️ Enable AI bubble detection for best results") self.provider_status_label.setStyleSheet("color: orange;") else: self.provider_status_label.setText("❌ API key needed") self.provider_status_label.setStyleSheet("color: red;") elif provider == 'Qwen2-VL': # Initialize OCR manager if needed if not hasattr(self, 'ocr_manager'): from ocr_manager import OCRManager self.ocr_manager = OCRManager(log_callback=self._log) # Check status first status = self.ocr_manager.check_provider_status(provider) # Load saved model size if available if hasattr(self, 'qwen2vl_model_size'): saved_model_size = self.qwen2vl_model_size else: saved_model_size = self.main_gui.config.get('qwen2vl_model_size', '1') # When displaying status for loaded model if status['loaded']: # Map the saved size to display name size_names = {'1': '2B', '2': '7B', '3': '72B', '4': 'custom'} display_size = size_names.get(saved_model_size, saved_model_size) self.provider_status_label.setText(f"✅ {display_size} model loaded") self.provider_status_label.setStyleSheet("color: green;") # Show reload button self.provider_setup_btn.setText("Reload") self.provider_setup_btn.setVisible(True) elif status['installed']: # Dependencies installed but model not loaded self.provider_status_label.setText("📦 Dependencies ready") self.provider_status_label.setStyleSheet("color: orange;") # Show Load button self.provider_setup_btn.setText("Load Model") self.provider_setup_btn.setVisible(True) # Also show Download button self.download_model_btn.setText("📥 Download Model") self.download_model_btn.setVisible(True) else: # Not installed self.provider_status_label.setText("❌ Not installed") self.provider_status_label.setStyleSheet("color: red;") # Show BOTH buttons self.provider_setup_btn.setText("Load Model") self.provider_setup_btn.setVisible(True) self.download_model_btn.setText("📥 Download Qwen2-VL") self.download_model_btn.setVisible(True) # Additional GPU status check for Qwen2-VL if not status['loaded']: try: import torch if not torch.cuda.is_available(): self._log("⚠️ No GPU detected - Qwen2-VL will run slowly on CPU", "warning") except ImportError: pass else: # Local OCR providers if not hasattr(self, 'ocr_manager'): from ocr_manager import OCRManager self.ocr_manager = OCRManager(log_callback=self._log) status = self.ocr_manager.check_provider_status(provider) if status['loaded']: # Model is loaded and ready if provider == 'Qwen2-VL': # Check which model size is loaded qwen_provider = self.ocr_manager.get_provider('Qwen2-VL') if qwen_provider and hasattr(qwen_provider, 'loaded_model_size'): model_size = qwen_provider.loaded_model_size status_text = f"✅ {model_size} model loaded" else: status_text = "✅ Model loaded" self.provider_status_label.setText(status_text) self.provider_status_label.setStyleSheet("color: green;") else: self.provider_status_label.setText("✅ Model loaded") self.provider_status_label.setStyleSheet("color: green;") # Show reload button for all local providers self.provider_setup_btn.setText("Reload") self.provider_setup_btn.setVisible(True) elif status['installed']: # Dependencies installed but model not loaded self.provider_status_label.setText("📦 Dependencies ready") self.provider_status_label.setStyleSheet("color: orange;") # Show Load button for all providers self.provider_setup_btn.setText("Load Model") self.provider_setup_btn.setVisible(True) # Also show Download button for models that need downloading if provider in ['Qwen2-VL', 'manga-ocr']: self.download_model_btn.setText("📥 Download Model") self.download_model_btn.setVisible(True) else: # Not installed self.provider_status_label.setText("❌ Not installed") self.provider_status_label.setStyleSheet("color: red;") # Categorize providers huggingface_providers = ['manga-ocr', 'Qwen2-VL', 'rapidocr'] # Move rapidocr here pip_providers = ['easyocr', 'paddleocr', 'doctr'] # Remove rapidocr from here if provider in huggingface_providers: # For HuggingFace models, show BOTH buttons self.provider_setup_btn.setText("Load Model") self.provider_setup_btn.setVisible(True) # Download button if provider == 'rapidocr': self.download_model_btn.setText("📥 Install RapidOCR") else: self.download_model_btn.setText(f"📥 Download {provider}") self.download_model_btn.setVisible(True) elif provider in pip_providers: # Check if running as .exe if getattr(sys, 'frozen', False): # Running as .exe - can't pip install self.provider_status_label.setText("❌ Not available in .exe") self.provider_status_label.setStyleSheet("color: red;") self._log(f"⚠️ {provider} cannot be installed in standalone .exe version", "warning") else: # Running from Python - can pip install self.provider_setup_btn.setText("Install") self.provider_setup_btn.setVisible(True) def _setup_ocr_provider(self): """Setup/install/load OCR provider""" provider = self.ocr_provider_value if provider in ['google', 'azure']: return # Cloud providers don't need setup # your own api key if provider == 'custom-api': # Open configuration dialog for custom API try: from custom_api_config_dialog import CustomAPIConfigDialog dialog = CustomAPIConfigDialog( self.manga_window, self.main_gui.config, self.main_gui.save_config ) # After dialog closes, refresh status self.dialog.after(100, self._check_provider_status) except ImportError: # If dialog not available, show message from PySide6.QtWidgets import QMessageBox from PySide6.QtCore import QTimer QTimer.singleShot(0, lambda: QMessageBox.information( self.dialog, "Custom API Configuration", "This mode uses your own API key in the main GUI:\n\n" "- Make sure your API supports vision\n" "- api_key: Your API key\n" "- model: Model name\n" "- custom url: You can override API endpoint under Other settings" )) return status = self.ocr_manager.check_provider_status(provider) # For Qwen2-VL, check if we need to select model size first model_size = None if provider == 'Qwen2-VL' and status['installed'] and not status['loaded']: # Use window manager for dialog - pass Tkinter root instead of PySide6 dialog selection_dialog, scrollable_frame, canvas = self.main_gui.wm.setup_scrollable( self.main_gui.master, "Select Qwen2-VL Model Size", width=None, height=None, max_width_ratio=0.5, max_height_ratio=0.3 ) # Title title_frame = tk.Frame(scrollable_frame) title_frame.pack(fill=tk.X, pady=(10, 20)) tk.Label(title_frame, text="Select Model Size to Load", font=('Arial', 12, 'bold')).pack() # Model selection frame model_frame = tk.LabelFrame( scrollable_frame, text="Available Models", font=('Arial', 11, 'bold'), padx=15, pady=10 ) model_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=10) # Model options model_options = { "1": {"name": "Qwen2-VL 2B", "desc": "Smallest (4-8GB VRAM)"}, "2": {"name": "Qwen2-VL 7B", "desc": "Medium (12-16GB VRAM)"}, "3": {"name": "Qwen2-VL 72B", "desc": "Largest (80GB+ VRAM)"}, "4": {"name": "Custom Model", "desc": "Enter any HF model ID"}, } selected_model = tk.StringVar(value="1") custom_model_id = tk.StringVar() for key, info in model_options.items(): option_frame = tk.Frame(model_frame) option_frame.pack(fill=tk.X, pady=5) rb = tk.Radiobutton( option_frame, text=f"{info['name']} - {info['desc']}", variable=selected_model, value=key, font=('Arial', 10), anchor='w' ) rb.pack(anchor='w') if key != "4": ttk.Separator(option_frame, orient='horizontal').pack(fill=tk.X, pady=(5, 0)) # Custom model ID frame custom_frame = tk.LabelFrame( scrollable_frame, text="Custom Model Configuration", font=('Arial', 11, 'bold'), padx=15, pady=10 ) entry_frame = tk.Frame(custom_frame) entry_frame.pack(fill=tk.X, pady=5) tk.Label(entry_frame, text="Model ID:", font=('Arial', 10)).pack(side=tk.LEFT, padx=(0, 10)) custom_entry = tk.Entry(entry_frame, textvariable=custom_model_id, width=35, font=('Arial', 10)) custom_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) def toggle_custom_frame(*args): if selected_model.get() == "4": custom_frame.pack(fill=tk.X, padx=20, pady=10, after=model_frame) else: custom_frame.pack_forget() selected_model.trace('w', toggle_custom_frame) # Buttons with centering button_frame = tk.Frame(scrollable_frame) button_frame.pack(fill=tk.X, pady=20) button_inner_frame = tk.Frame(button_frame) button_inner_frame.pack() model_confirmed = {'value': False, 'size': None} def confirm_selection(): selected = selected_model.get() self._log(f"DEBUG: Radio button selection = {selected}") if selected == "4": if not custom_model_id.get().strip(): from PySide6.QtWidgets import QMessageBox QMessageBox.critical(selection_dialog, "Error", "Please enter a model ID") return model_confirmed['size'] = f"custom:{custom_model_id.get().strip()}" else: model_confirmed['size'] = selected model_confirmed['value'] = True selection_dialog.destroy() load_btn = tk.Button( button_inner_frame, text="Load", command=confirm_selection, bg='#4CAF50', fg='white', font=('Arial', 10, 'bold'), padx=20, pady=8, cursor='hand2', width=12 ) load_btn.pack(side=tk.LEFT, padx=5) cancel_btn = tk.Button( button_inner_frame, text="Cancel", command=selection_dialog.destroy, bg='#9E9E9E', fg='white', font=('Arial', 10), padx=20, pady=8, cursor='hand2', width=12 ) cancel_btn.pack(side=tk.LEFT, padx=5) # Auto-resize and wait self.main_gui.wm.auto_resize_dialog(selection_dialog, canvas, max_width_ratio=0.5, max_height_ratio=0.35) self.dialog.wait_window(selection_dialog) if not model_confirmed['value']: return model_size = model_confirmed['size'] self._log(f"DEBUG: Dialog closed, model_size set to: {model_size}") # Create progress dialog with window manager - pass Tkinter root instead of PySide6 dialog progress_dialog, progress_frame, canvas = self.main_gui.wm.setup_scrollable( self.main_gui.master, f"Setting up {provider}", width=400, height=200, max_width_ratio=0.4, max_height_ratio=0.3 ) # Progress section progress_section = tk.LabelFrame( progress_frame, text="Setup Progress", font=('Arial', 11, 'bold'), padx=15, pady=10 ) progress_section.pack(fill=tk.BOTH, expand=True, padx=20, pady=20) progress_label = tk.Label(progress_section, text="Initializing...", font=('Arial', 10)) progress_label.pack(pady=(10, 15)) try: # Try to use our custom progress bar style progress_bar = ttk.Progressbar( progress_section, length=350, mode='indeterminate', style="MangaProgress.Horizontal.TProgressbar" ) except Exception: # Fallback to default if style not available yet progress_bar = ttk.Progressbar( progress_section, length=350, mode='indeterminate' ) progress_bar.pack(pady=(0, 10)) progress_bar.start(10) status_label = tk.Label(progress_section, text="", font=('Arial', 9), fg='#666666') status_label.pack(pady=(0, 10)) def update_progress(message, percent=None): """Update progress display""" progress_label.config(text=message) if percent is not None: progress_bar.stop() progress_bar.config(mode='determinate', value=percent) def setup_thread(): """Run setup in background thread""" nonlocal model_size try: success = False if not status['installed']: # Install provider update_progress(f"Installing {provider}...") success = self.ocr_manager.install_provider(provider, update_progress) if not success: update_progress("❌ Installation failed!", 0) self._log(f"Failed to install {provider}", "error") return # Load model update_progress(f"Loading {provider} model...") # Special handling for Qwen2-VL - pass model_size if provider == 'Qwen2-VL': if success and model_size: # Save the model size to config self.qwen2vl_model_size = model_size self.main_gui.config['qwen2vl_model_size'] = model_size # Save config immediately if hasattr(self.main_gui, 'save_config'): self.main_gui.save_config(show_message=False) self._log(f"DEBUG: In thread, about to load with model_size={model_size}") if model_size: success = self.ocr_manager.load_provider(provider, model_size=model_size) if success: provider_obj = self.ocr_manager.get_provider('Qwen2-VL') if provider_obj: provider_obj.loaded_model_size = { "1": "2B", "2": "7B", "3": "72B", "4": "custom" }.get(model_size, model_size) else: self._log("Warning: No model size specified for Qwen2-VL, defaulting to 2B", "warning") success = self.ocr_manager.load_provider(provider, model_size="1") else: success = self.ocr_manager.load_provider(provider) if success: update_progress(f"✅ {provider} ready!", 100) self._log(f"✅ {provider} is ready to use", "success") self.dialog.after(0, self._check_provider_status) else: update_progress("❌ Failed to load model!", 0) self._log(f"Failed to load {provider} model", "error") except Exception as e: update_progress(f"❌ Error: {str(e)}", 0) self._log(f"Setup error: {str(e)}", "error") import traceback self._log(traceback.format_exc(), "debug") finally: self.dialog.after(2000, progress_dialog.destroy) # Auto-resize self.main_gui.wm.auto_resize_dialog(progress_dialog, canvas, max_width_ratio=0.4, max_height_ratio=0.3) # Start setup in background via executor if available try: if hasattr(self.main_gui, '_ensure_executor'): self.main_gui._ensure_executor() execu = getattr(self.main_gui, 'executor', None) if execu: execu.submit(setup_thread) else: import threading threading.Thread(target=setup_thread, daemon=True).start() except Exception: import threading threading.Thread(target=setup_thread, daemon=True).start() def _on_ocr_provider_change(self, event=None): """Handle OCR provider change""" # Get the new provider value from combo box if hasattr(self, 'provider_combo'): provider = self.provider_combo.currentText() self.ocr_provider_value = provider else: provider = self.ocr_provider_value # Hide ALL provider-specific frames first (PySide6) if hasattr(self, 'google_creds_frame'): self.google_creds_frame.setVisible(False) if hasattr(self, 'azure_frame'): self.azure_frame.setVisible(False) # Show only the relevant settings frame for the selected provider if provider == 'google': # Show Google credentials frame if hasattr(self, 'google_creds_frame'): self.google_creds_frame.setVisible(True) elif provider == 'azure': # Show Azure settings frame if hasattr(self, 'azure_frame'): self.azure_frame.setVisible(True) # For all other providers (manga-ocr, Qwen2-VL, easyocr, paddleocr, doctr) # Don't show any cloud credential frames - they use local models # Check provider status to show appropriate buttons self._check_provider_status() # Log the change provider_descriptions = { 'custom-api': "Custom API - use your own vision model", 'google': "Google Cloud Vision (requires credentials)", 'azure': "Azure Computer Vision (requires API key)", 'manga-ocr': "Manga OCR - optimized for Japanese manga", 'rapidocr': "RapidOCR - fast local OCR with region detection", 'Qwen2-VL': "Qwen2-VL - a big model", 'easyocr': "EasyOCR - multi-language support", 'paddleocr': "PaddleOCR - CJK language support", 'doctr': "DocTR - document text recognition" } self._log(f"📋 OCR provider changed to: {provider_descriptions.get(provider, provider)}", "info") # Save the selection self.main_gui.config['manga_ocr_provider'] = provider if hasattr(self.main_gui, 'save_config'): self.main_gui.save_config(show_message=False) # IMPORTANT: Reset translator to force recreation with new OCR provider if hasattr(self, 'translator') and self.translator: self._log(f"OCR provider changed to {provider.upper()}. Translator will be recreated on next run.", "info") self.translator = None # Force recreation on next translation def _build_interface(self): """Build the enhanced manga translation interface using PySide6""" # Create main layout for PySide6 widget main_layout = QVBoxLayout(self.parent_widget) main_layout.setContentsMargins(10, 10, 10, 10) main_layout.setSpacing(6) self._build_pyside6_interface(main_layout) def _build_pyside6_interface(self, main_layout): # Apply global stylesheet for checkboxes and radio buttons checkbox_radio_style = """ QCheckBox { color: white; spacing: 6px; } QCheckBox::indicator { width: 14px; height: 14px; border: 1px solid #5a9fd4; border-radius: 2px; background-color: #2d2d2d; } QCheckBox::indicator:checked { background-color: #5a9fd4; border-color: #5a9fd4; } QCheckBox::indicator:hover { border-color: #7bb3e0; } QCheckBox:disabled { color: #666666; } QCheckBox::indicator:disabled { background-color: #1a1a1a; border-color: #3a3a3a; } QRadioButton { color: white; spacing: 6px; } QRadioButton::indicator { width: 16px; height: 16px; border: 2px solid #5a9fd4; border-radius: 8px; background-color: #2d2d2d; } QRadioButton::indicator:checked { background-color: #5a9fd4; border: 2px solid #5a9fd4; } QRadioButton::indicator:hover { border-color: #7bb3e0; } QRadioButton:disabled { color: #666666; } QRadioButton::indicator:disabled { background-color: #1a1a1a; border-color: #3a3a3a; } /* Disabled fields styling */ QLineEdit:disabled, QComboBox:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled { background-color: #1a1a1a; color: #666666; border: 1px solid #3a3a3a; } QLabel:disabled { color: #666666; } """ self.parent_widget.setStyleSheet(checkbox_radio_style) # Title (at the very top) title_frame = QWidget() title_layout = QHBoxLayout(title_frame) title_layout.setContentsMargins(0, 0, 0, 0) title_layout.setSpacing(8) title_label = QLabel("🎌 Manga Translation") title_font = QFont("Arial", 13) title_font.setBold(True) title_label.setFont(title_font) title_layout.addWidget(title_label) # Requirements check has_api_key = bool(self.main_gui.api_key_entry.text().strip()) if hasattr(self.main_gui.api_key_entry, 'text') else bool(self.main_gui.api_key_entry.get().strip()) has_vision = os.path.exists(self.main_gui.config.get('google_vision_credentials', '')) status_text = "✅ Ready" if (has_api_key and has_vision) else "❌ Setup Required" status_color = "green" if (has_api_key and has_vision) else "red" status_label = QLabel(status_text) status_font = QFont("Arial", 10) status_label.setFont(status_font) status_label.setStyleSheet(f"color: {status_color};") title_layout.addStretch() title_layout.addWidget(status_label) main_layout.addWidget(title_frame) # Store reference for updates self.status_label = status_label # Model Preloading Progress Bar (right after title, initially hidden) self.preload_progress_frame = QWidget() self.preload_progress_frame.setStyleSheet( "background-color: #2d2d2d; " "border: 1px solid #4a5568; " "border-radius: 4px; " "padding: 6px;" ) preload_layout = QVBoxLayout(self.preload_progress_frame) preload_layout.setContentsMargins(8, 6, 8, 6) preload_layout.setSpacing(4) self.preload_status_label = QLabel("Loading models...") preload_status_font = QFont("Segoe UI", 9) preload_status_font.setBold(True) self.preload_status_label.setFont(preload_status_font) self.preload_status_label.setStyleSheet("color: #ffffff; background: transparent; border: none;") self.preload_status_label.setAlignment(Qt.AlignCenter) preload_layout.addWidget(self.preload_status_label) self.preload_progress_bar = QProgressBar() self.preload_progress_bar.setRange(0, 100) self.preload_progress_bar.setValue(0) self.preload_progress_bar.setTextVisible(True) self.preload_progress_bar.setMinimumHeight(22) self.preload_progress_bar.setStyleSheet(""" QProgressBar { border: 1px solid #4a5568; border-radius: 3px; text-align: center; background-color: #1e1e1e; color: #ffffff; font-weight: bold; font-size: 9px; } QProgressBar::chunk { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #2d6a4f, stop:0.5 #1b4332, stop:1 #081c15); border-radius: 2px; margin: 0px; } """) preload_layout.addWidget(self.preload_progress_bar) self.preload_progress_frame.setVisible(False) # Hidden by default main_layout.addWidget(self.preload_progress_frame) # Add instructions and Google Cloud setup if not (has_api_key and has_vision): req_frame = QWidget() req_layout = QVBoxLayout(req_frame) req_layout.setContentsMargins(0, 5, 0, 5) req_text = [] if not has_api_key: req_text.append("• API Key not configured") if not has_vision: req_text.append("• Google Cloud Vision credentials not set") req_label = QLabel("\n".join(req_text)) req_font = QFont("Arial", 10) req_label.setFont(req_font) req_label.setStyleSheet("color: red;") req_label.setAlignment(Qt.AlignLeft) req_layout.addWidget(req_label) main_layout.addWidget(req_frame) # File selection frame file_frame = QGroupBox("Select Manga Images") file_frame_font = QFont("Arial", 10) file_frame_font.setBold(True) file_frame.setFont(file_frame_font) file_frame_layout = QVBoxLayout(file_frame) file_frame_layout.setContentsMargins(10, 10, 10, 8) file_frame_layout.setSpacing(6) # File listbox (QListWidget handles scrolling automatically) self.file_listbox = QListWidget() self.file_listbox.setSelectionMode(QListWidget.ExtendedSelection) self.file_listbox.setMinimumHeight(200) file_frame_layout.addWidget(self.file_listbox) # File buttons file_btn_frame = QWidget() file_btn_layout = QHBoxLayout(file_btn_frame) file_btn_layout.setContentsMargins(0, 6, 0, 0) file_btn_layout.setSpacing(4) add_files_btn = QPushButton("Add Files") add_files_btn.clicked.connect(self._add_files) add_files_btn.setStyleSheet("QPushButton { background-color: #007bff; color: white; padding: 3px 10px; font-size: 9pt; }") file_btn_layout.addWidget(add_files_btn) add_folder_btn = QPushButton("Add Folder") add_folder_btn.clicked.connect(self._add_folder) add_folder_btn.setStyleSheet("QPushButton { background-color: #007bff; color: white; padding: 3px 10px; font-size: 9pt; }") file_btn_layout.addWidget(add_folder_btn) remove_btn = QPushButton("Remove Selected") remove_btn.clicked.connect(self._remove_selected) remove_btn.setStyleSheet("QPushButton { background-color: #dc3545; color: white; padding: 3px 10px; font-size: 9pt; }") file_btn_layout.addWidget(remove_btn) clear_btn = QPushButton("Clear All") clear_btn.clicked.connect(self._clear_all) clear_btn.setStyleSheet("QPushButton { background-color: #ffc107; color: black; padding: 3px 10px; font-size: 9pt; }") file_btn_layout.addWidget(clear_btn) file_btn_layout.addStretch() file_frame_layout.addWidget(file_btn_frame) main_layout.addWidget(file_frame) # Settings frame settings_frame = QGroupBox("Translation Settings") settings_frame_font = QFont("Arial", 10) settings_frame_font.setBold(True) settings_frame.setFont(settings_frame_font) settings_frame_layout = QVBoxLayout(settings_frame) settings_frame_layout.setContentsMargins(10, 10, 10, 8) settings_frame_layout.setSpacing(6) # API Settings - Hybrid approach api_frame = QWidget() api_layout = QHBoxLayout(api_frame) api_layout.setContentsMargins(0, 0, 0, 10) api_layout.setSpacing(10) api_label = QLabel("OCR: Google Cloud Vision | Translation: API Key") api_font = QFont("Arial", 10) api_font.setItalic(True) api_label.setFont(api_font) api_label.setStyleSheet("color: gray;") api_layout.addWidget(api_label) # Show current model from main GUI current_model = 'Unknown' try: if hasattr(self.main_gui, 'model_combo') and hasattr(self.main_gui.model_combo, 'currentText'): # PySide6 QComboBox current_model = self.main_gui.model_combo.currentText() elif hasattr(self.main_gui, 'model_var'): # Tkinter StringVar current_model = self.main_gui.model_var.get() if hasattr(self.main_gui.model_var, 'get') else str(self.main_gui.model_var) elif hasattr(self.main_gui, 'config'): # Fallback to config current_model = self.main_gui.config.get('model', 'Unknown') except Exception as e: print(f"Error getting model: {e}") current_model = 'Unknown' model_label = QLabel(f"Model: {current_model}") model_font = QFont("Arial", 10) model_font.setItalic(True) model_label.setFont(model_font) model_label.setStyleSheet("color: gray;") api_layout.addStretch() api_layout.addWidget(model_label) settings_frame_layout.addWidget(api_frame) # OCR Provider Selection - ENHANCED VERSION self.ocr_provider_frame = QWidget() ocr_provider_layout = QHBoxLayout(self.ocr_provider_frame) ocr_provider_layout.setContentsMargins(0, 0, 0, 10) ocr_provider_layout.setSpacing(10) provider_label = QLabel("OCR Provider:") provider_label.setMinimumWidth(150) provider_label.setAlignment(Qt.AlignLeft) ocr_provider_layout.addWidget(provider_label) # Expanded provider list with descriptions ocr_providers = [ ('custom-api', 'Your Own key'), ('google', 'Google Cloud Vision'), ('azure', 'Azure Computer Vision'), ('rapidocr', '⚡ RapidOCR (Fast & Local)'), ('manga-ocr', '🇯🇵 Manga OCR (Japanese)'), ('Qwen2-VL', '🇰🇷 Qwen2-VL (Korean)'), ('easyocr', '🌏 EasyOCR (Multi-lang)'), #('paddleocr', '🐼 PaddleOCR'), ('doctr', '📄 DocTR'), ] # Just the values for the combobox provider_values = [p[0] for p in ocr_providers] provider_display = [f"{p[0]} - {p[1]}" for p in ocr_providers] self.ocr_provider_value = self.main_gui.config.get('manga_ocr_provider', 'custom-api') self.provider_combo = QComboBox() self.provider_combo.addItems(provider_values) self.provider_combo.setCurrentText(self.ocr_provider_value) self.provider_combo.setMinimumWidth(150) self.provider_combo.currentTextChanged.connect(self._on_ocr_provider_change) self._disable_combobox_mousewheel(self.provider_combo) # Disable mousewheel scrolling ocr_provider_layout.addWidget(self.provider_combo) # Provider status indicator with more detail self.provider_status_label = QLabel("") status_font = QFont("Arial", 9) self.provider_status_label.setFont(status_font) self.provider_status_label.setMinimumWidth(300) ocr_provider_layout.addWidget(self.provider_status_label) # Setup/Install button for non-cloud providers self.provider_setup_btn = QPushButton("Setup") self.provider_setup_btn.clicked.connect(self._setup_ocr_provider) self.provider_setup_btn.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px 15px; }") self.provider_setup_btn.setMinimumWidth(100) self.provider_setup_btn.setVisible(False) # Hidden by default, _check_provider_status will show it ocr_provider_layout.addWidget(self.provider_setup_btn) # Add explicit download button for Hugging Face models self.download_model_btn = QPushButton("📥 Download") self.download_model_btn.clicked.connect(self._download_hf_model) self.download_model_btn.setStyleSheet("QPushButton { background-color: #28a745; color: white; padding: 5px 15px; }") self.download_model_btn.setMinimumWidth(150) self.download_model_btn.setVisible(False) # Hidden by default ocr_provider_layout.addWidget(self.download_model_btn) ocr_provider_layout.addStretch() settings_frame_layout.addWidget(self.ocr_provider_frame) # Initialize OCR manager from ocr_manager import OCRManager self.ocr_manager = OCRManager(log_callback=self._log) # Check initial provider status self._check_provider_status() # Google Cloud Credentials section (now in a frame that can be hidden) self.google_creds_frame = QWidget() google_creds_layout = QHBoxLayout(self.google_creds_frame) google_creds_layout.setContentsMargins(0, 0, 0, 10) google_creds_layout.setSpacing(10) google_label = QLabel("Google Cloud Credentials:") google_label.setMinimumWidth(150) google_label.setAlignment(Qt.AlignLeft) google_creds_layout.addWidget(google_label) # Show current credentials file google_creds_path = self.main_gui.config.get('google_vision_credentials', '') or self.main_gui.config.get('google_cloud_credentials', '') creds_display = os.path.basename(google_creds_path) if google_creds_path else "Not Set" self.creds_label = QLabel(creds_display) creds_font = QFont("Arial", 9) self.creds_label.setFont(creds_font) self.creds_label.setStyleSheet(f"color: {'green' if google_creds_path else 'red'};") google_creds_layout.addWidget(self.creds_label) browse_btn = QPushButton("Browse") browse_btn.clicked.connect(self._browse_google_credentials_permanent) browse_btn.setStyleSheet("QPushButton { background-color: #007bff; color: white; padding: 5px 15px; }") google_creds_layout.addWidget(browse_btn) google_creds_layout.addStretch() settings_frame_layout.addWidget(self.google_creds_frame) self.google_creds_frame.setVisible(False) # Hidden by default # Azure settings frame (hidden by default) self.azure_frame = QWidget() azure_frame_layout = QVBoxLayout(self.azure_frame) azure_frame_layout.setContentsMargins(0, 0, 0, 10) azure_frame_layout.setSpacing(5) # Azure Key azure_key_frame = QWidget() azure_key_layout = QHBoxLayout(azure_key_frame) azure_key_layout.setContentsMargins(0, 0, 0, 0) azure_key_layout.setSpacing(10) azure_key_label = QLabel("Azure Key:") azure_key_label.setMinimumWidth(150) azure_key_label.setAlignment(Qt.AlignLeft) azure_key_layout.addWidget(azure_key_label) self.azure_key_entry = QLineEdit() self.azure_key_entry.setEchoMode(QLineEdit.Password) self.azure_key_entry.setMinimumWidth(250) azure_key_layout.addWidget(self.azure_key_entry) # Show/Hide button for Azure key self.show_azure_key_checked = False show_azure_check = self._create_styled_checkbox("Show") show_azure_check.stateChanged.connect(lambda state: self.azure_key_entry.setEchoMode( QLineEdit.Normal if state == Qt.CheckState.Checked else QLineEdit.Password )) azure_key_layout.addWidget(show_azure_check) azure_key_layout.addStretch() azure_frame_layout.addWidget(azure_key_frame) # Azure Endpoint azure_endpoint_frame = QWidget() azure_endpoint_layout = QHBoxLayout(azure_endpoint_frame) azure_endpoint_layout.setContentsMargins(0, 0, 0, 0) azure_endpoint_layout.setSpacing(10) azure_endpoint_label = QLabel("Azure Endpoint:") azure_endpoint_label.setMinimumWidth(150) azure_endpoint_label.setAlignment(Qt.AlignLeft) azure_endpoint_layout.addWidget(azure_endpoint_label) self.azure_endpoint_entry = QLineEdit() self.azure_endpoint_entry.setMinimumWidth(350) azure_endpoint_layout.addWidget(self.azure_endpoint_entry) azure_endpoint_layout.addStretch() azure_frame_layout.addWidget(azure_endpoint_frame) # Load saved Azure settings saved_key = self.main_gui.config.get('azure_vision_key', '') saved_endpoint = self.main_gui.config.get('azure_vision_endpoint', 'https://YOUR-RESOURCE.cognitiveservices.azure.com/') self.azure_key_entry.setText(saved_key) self.azure_endpoint_entry.setText(saved_endpoint) settings_frame_layout.addWidget(self.azure_frame) self.azure_frame.setVisible(False) # Hidden by default # Initially show/hide based on saved provider self._on_ocr_provider_change() # Separator for context settings separator1 = QFrame() separator1.setFrameShape(QFrame.HLine) separator1.setFrameShadow(QFrame.Sunken) settings_frame_layout.addWidget(separator1) # Context and Full Page Mode Settings context_frame = QGroupBox("🔄 Context & Translation Mode") context_frame_font = QFont("Arial", 11) context_frame_font.setBold(True) context_frame.setFont(context_frame_font) context_frame_layout = QVBoxLayout(context_frame) context_frame_layout.setContentsMargins(10, 10, 10, 10) context_frame_layout.setSpacing(10) # Show current contextual settings from main GUI context_info = QWidget() context_info_layout = QVBoxLayout(context_info) context_info_layout.setContentsMargins(0, 0, 0, 10) context_info_layout.setSpacing(5) context_title = QLabel("Main GUI Context Settings:") title_font = QFont("Arial", 10) title_font.setBold(True) context_title.setFont(title_font) context_info_layout.addWidget(context_title) # Display current settings settings_frame_display = QWidget() settings_display_layout = QVBoxLayout(settings_frame_display) settings_display_layout.setContentsMargins(20, 0, 0, 0) settings_display_layout.setSpacing(3) # Contextual enabled status contextual_status = "Enabled" if self.main_gui.contextual_var.get() else "Disabled" self.contextual_status_label = QLabel(f"• Contextual Translation: {contextual_status}") status_font = QFont("Arial", 10) self.contextual_status_label.setFont(status_font) settings_display_layout.addWidget(self.contextual_status_label) # History limit history_limit = self.main_gui.trans_history.get() if hasattr(self.main_gui, 'trans_history') else "3" self.history_limit_label = QLabel(f"• Translation History Limit: {history_limit} exchanges") self.history_limit_label.setFont(status_font) settings_display_layout.addWidget(self.history_limit_label) # Rolling history status rolling_status = "Enabled (Rolling Window)" if self.main_gui.translation_history_rolling_var.get() else "Disabled (Reset on Limit)" self.rolling_status_label = QLabel(f"• Rolling History: {rolling_status}") self.rolling_status_label.setFont(status_font) settings_display_layout.addWidget(self.rolling_status_label) context_info_layout.addWidget(settings_frame_display) context_frame_layout.addWidget(context_info) # Refresh button to update from main GUI refresh_btn = QPushButton("↻ Refresh from Main GUI") refresh_btn.clicked.connect(self._refresh_context_settings) refresh_btn.setStyleSheet("QPushButton { background-color: #6c757d; color: white; padding: 5px 15px; }") context_frame_layout.addWidget(refresh_btn) # Separator separator2 = QFrame() separator2.setFrameShape(QFrame.HLine) separator2.setFrameShadow(QFrame.Sunken) context_frame_layout.addWidget(separator2) # Full Page Context Translation Settings full_page_frame = QWidget() full_page_layout = QVBoxLayout(full_page_frame) full_page_layout.setContentsMargins(0, 0, 0, 0) full_page_layout.setSpacing(5) full_page_title = QLabel("Full Page Context Mode (Manga-specific):") title_font2 = QFont("Arial", 10) title_font2.setBold(True) full_page_title.setFont(title_font2) full_page_layout.addWidget(full_page_title) # Enable/disable toggle self.full_page_context_checked = self.main_gui.config.get('manga_full_page_context', True) toggle_frame = QWidget() toggle_layout = QHBoxLayout(toggle_frame) toggle_layout.setContentsMargins(20, 0, 0, 0) toggle_layout.setSpacing(10) self.context_checkbox = self._create_styled_checkbox("Enable Full Page Context Translation") self.context_checkbox.setChecked(self.full_page_context_checked) self.context_checkbox.stateChanged.connect(self._on_context_toggle) toggle_layout.addWidget(self.context_checkbox) # Edit prompt button edit_prompt_btn = QPushButton("Edit Prompt") edit_prompt_btn.clicked.connect(self._edit_context_prompt) edit_prompt_btn.setStyleSheet("QPushButton { background-color: #6c757d; color: white; padding: 5px 15px; }") toggle_layout.addWidget(edit_prompt_btn) # Help button for full page context help_btn = QPushButton("?") help_btn.setFixedWidth(30) help_btn.clicked.connect(lambda: self._show_help_dialog( "Full Page Context Mode", "Full page context sends all text regions from the page together in a single request.\n\n" "This allows the AI to see all text at once for more contextually accurate translations, " "especially useful for maintaining character name consistency and understanding " "conversation flow across multiple speech bubbles.\n\n" "✅ Pros:\n" "• Better context awareness\n" "• Consistent character names\n" "• Understanding of conversation flow\n" "• Maintains tone across bubbles\n\n" "❌ Cons:\n" "• Single API call failure affects all text\n" "• May use more tokens\n" "• Slower for pages with many text regions" )) help_btn.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px; }") toggle_layout.addWidget(help_btn) toggle_layout.addStretch() full_page_layout.addWidget(toggle_frame) context_frame_layout.addWidget(full_page_frame) # Separator separator3 = QFrame() separator3.setFrameShape(QFrame.HLine) separator3.setFrameShadow(QFrame.Sunken) context_frame_layout.addWidget(separator3) # Visual Context Settings (for non-vision model support) visual_frame = QWidget() visual_layout = QVBoxLayout(visual_frame) visual_layout.setContentsMargins(0, 0, 0, 0) visual_layout.setSpacing(5) visual_title = QLabel("Visual Context (Image Support):") title_font3 = QFont("Arial", 10) title_font3.setBold(True) visual_title.setFont(title_font3) visual_layout.addWidget(visual_title) # Visual context toggle self.visual_context_enabled_checked = self.main_gui.config.get('manga_visual_context_enabled', True) visual_toggle_frame = QWidget() visual_toggle_layout = QHBoxLayout(visual_toggle_frame) visual_toggle_layout.setContentsMargins(20, 0, 0, 0) visual_toggle_layout.setSpacing(10) self.visual_context_checkbox = self._create_styled_checkbox("Include page image in translation requests") self.visual_context_checkbox.setChecked(self.visual_context_enabled_checked) self.visual_context_checkbox.stateChanged.connect(self._on_visual_context_toggle) visual_toggle_layout.addWidget(self.visual_context_checkbox) # Help button for visual context visual_help_btn = QPushButton("?") visual_help_btn.setFixedWidth(30) visual_help_btn.clicked.connect(lambda: self._show_help_dialog( "Visual Context Settings", "Visual context includes the manga page image with translation requests.\n\n" "⚠️ WHEN TO DISABLE:\n" "• Using text-only models (Claude, GPT-3.5, standard Gemini)\n" "• Model doesn't support images\n" "• Want to reduce token usage\n" "• Testing text-only translation\n\n" "✅ WHEN TO ENABLE:\n" "• Using vision models (Gemini Vision, GPT-4V, Claude 3)\n" "• Want spatial awareness of text position\n" "• Need visual context for better translation\n\n" "Impact:\n" "• Disabled: Only text is sent (compatible with any model)\n" "• Enabled: Text + image sent (requires vision model)\n\n" "Note: Disabling may reduce translation quality as the AI won't see\n" "the artwork context or spatial layout of the text." )) visual_help_btn.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px; }") visual_toggle_layout.addWidget(visual_help_btn) visual_toggle_layout.addStretch() visual_layout.addWidget(visual_toggle_frame) context_frame_layout.addWidget(visual_frame) # Add the completed context_frame to settings_frame settings_frame_layout.addWidget(context_frame) # Add main settings frame to main layout main_layout.addWidget(settings_frame) # Text Rendering Settings Frame render_frame = QGroupBox("Text Visibility Settings") render_frame_font = QFont("Arial", 12) render_frame_font.setBold(True) render_frame.setFont(render_frame_font) render_frame_layout = QVBoxLayout(render_frame) render_frame_layout.setContentsMargins(15, 15, 15, 10) render_frame_layout.setSpacing(10) # Advanced Settings button at the top of render_frame advanced_button_frame = QWidget() advanced_button_layout = QHBoxLayout(advanced_button_frame) advanced_button_layout.setContentsMargins(0, 0, 0, 10) advanced_button_layout.setSpacing(10) advanced_settings_desc = QLabel("Configure OCR, preprocessing, and performance options") desc_font = QFont("Arial", 9) advanced_settings_desc.setFont(desc_font) advanced_settings_desc.setStyleSheet("color: gray;") advanced_button_layout.addWidget(advanced_settings_desc) advanced_button_layout.addStretch() advanced_settings_btn = QPushButton("⚙️ Advanced Settings") advanced_settings_btn.clicked.connect(self._open_advanced_settings) advanced_settings_btn.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px 15px; }") advanced_button_layout.addWidget(advanced_settings_btn) render_frame_layout.addWidget(advanced_button_frame) # Inpainting section inpaint_group = QGroupBox("Inpainting") inpaint_group_layout = QVBoxLayout(inpaint_group) inpaint_group_layout.setContentsMargins(15, 15, 15, 10) inpaint_group_layout.setSpacing(10) # Skip inpainting toggle - use value loaded from config self.skip_inpainting_checkbox = self._create_styled_checkbox("Skip Inpainter") self.skip_inpainting_checkbox.setChecked(self.skip_inpainting_value) self.skip_inpainting_checkbox.stateChanged.connect(self._toggle_inpaint_visibility) inpaint_group_layout.addWidget(self.skip_inpainting_checkbox) # Inpainting method selection (only visible when inpainting is enabled) self.inpaint_method_frame = QWidget() inpaint_method_layout = QHBoxLayout(self.inpaint_method_frame) inpaint_method_layout.setContentsMargins(0, 0, 0, 0) inpaint_method_layout.setSpacing(10) method_label = QLabel("Inpaint Method:") method_label_font = QFont('Arial', 9) method_label.setFont(method_label_font) method_label.setMinimumWidth(95) method_label.setAlignment(Qt.AlignLeft) inpaint_method_layout.addWidget(method_label) # Radio buttons for inpaint method method_selection_frame = QWidget() method_selection_layout = QHBoxLayout(method_selection_frame) method_selection_layout.setContentsMargins(0, 0, 0, 0) method_selection_layout.setSpacing(10) self.inpaint_method_value = self.main_gui.config.get('manga_inpaint_method', 'local') self.inpaint_method_group = QButtonGroup() # Set smaller font for radio buttons radio_font = QFont('Arial', 9) cloud_radio = QRadioButton("Cloud API") cloud_radio.setFont(radio_font) cloud_radio.setChecked(self.inpaint_method_value == 'cloud') cloud_radio.toggled.connect(lambda checked: self._on_inpaint_method_change() if checked else None) self.inpaint_method_group.addButton(cloud_radio, 0) method_selection_layout.addWidget(cloud_radio) local_radio = QRadioButton("Local Model") local_radio.setFont(radio_font) local_radio.setChecked(self.inpaint_method_value == 'local') local_radio.toggled.connect(lambda checked: self._on_inpaint_method_change() if checked else None) self.inpaint_method_group.addButton(local_radio, 1) method_selection_layout.addWidget(local_radio) hybrid_radio = QRadioButton("Hybrid") hybrid_radio.setFont(radio_font) hybrid_radio.setChecked(self.inpaint_method_value == 'hybrid') hybrid_radio.toggled.connect(lambda checked: self._on_inpaint_method_change() if checked else None) self.inpaint_method_group.addButton(hybrid_radio, 2) method_selection_layout.addWidget(hybrid_radio) # Store references to radio buttons self.cloud_radio = cloud_radio self.local_radio = local_radio self.hybrid_radio = hybrid_radio inpaint_method_layout.addWidget(method_selection_frame) inpaint_method_layout.addStretch() inpaint_group_layout.addWidget(self.inpaint_method_frame) # Cloud settings frame self.cloud_inpaint_frame = QWidget() cloud_inpaint_layout = QVBoxLayout(self.cloud_inpaint_frame) cloud_inpaint_layout.setContentsMargins(0, 0, 0, 0) cloud_inpaint_layout.setSpacing(5) # Quality selection for cloud quality_frame = QWidget() quality_layout = QHBoxLayout(quality_frame) quality_layout.setContentsMargins(0, 0, 0, 0) quality_layout.setSpacing(10) quality_label = QLabel("Cloud Quality:") quality_label_font = QFont('Arial', 9) quality_label.setFont(quality_label_font) quality_label.setMinimumWidth(95) quality_label.setAlignment(Qt.AlignLeft) quality_layout.addWidget(quality_label) # inpaint_quality_value is already loaded from config in _load_rendering_settings self.quality_button_group = QButtonGroup() quality_options = [('high', 'High Quality'), ('fast', 'Fast')] for idx, (value, text) in enumerate(quality_options): quality_radio = QRadioButton(text) quality_radio.setChecked(self.inpaint_quality_value == value) quality_radio.toggled.connect(lambda checked, v=value: self._save_rendering_settings() if checked else None) self.quality_button_group.addButton(quality_radio, idx) quality_layout.addWidget(quality_radio) quality_layout.addStretch() cloud_inpaint_layout.addWidget(quality_frame) # Conditional separator self.inpaint_separator = QFrame() self.inpaint_separator.setFrameShape(QFrame.HLine) self.inpaint_separator.setFrameShadow(QFrame.Sunken) if not self.skip_inpainting_value: cloud_inpaint_layout.addWidget(self.inpaint_separator) # Cloud API status api_status_frame = QWidget() api_status_layout = QHBoxLayout(api_status_frame) api_status_layout.setContentsMargins(0, 10, 0, 0) api_status_layout.setSpacing(10) # Check if API key exists saved_api_key = self.main_gui.config.get('replicate_api_key', '') if saved_api_key: status_text = "✅ Cloud API configured" status_color = 'green' else: status_text = "❌ Cloud API not configured" status_color = 'red' self.inpaint_api_status_label = QLabel(status_text) api_status_font = QFont('Arial', 9) self.inpaint_api_status_label.setFont(api_status_font) self.inpaint_api_status_label.setStyleSheet(f"color: {status_color};") api_status_layout.addWidget(self.inpaint_api_status_label) configure_api_btn = QPushButton("Configure API Key") configure_api_btn.clicked.connect(self._configure_inpaint_api) configure_api_btn.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px 15px; }") api_status_layout.addWidget(configure_api_btn) if saved_api_key: clear_api_btn = QPushButton("Clear") clear_api_btn.clicked.connect(self._clear_inpaint_api) clear_api_btn.setStyleSheet("QPushButton { background-color: #6c757d; color: white; padding: 5px 15px; }") api_status_layout.addWidget(clear_api_btn) api_status_layout.addStretch() cloud_inpaint_layout.addWidget(api_status_frame) inpaint_group_layout.addWidget(self.cloud_inpaint_frame) # Local inpainting settings frame self.local_inpaint_frame = QWidget() local_inpaint_layout = QVBoxLayout(self.local_inpaint_frame) local_inpaint_layout.setContentsMargins(0, 0, 0, 0) local_inpaint_layout.setSpacing(5) # Local model selection local_model_frame = QWidget() local_model_layout = QHBoxLayout(local_model_frame) local_model_layout.setContentsMargins(0, 0, 0, 0) local_model_layout.setSpacing(10) local_model_label = QLabel("Local Model:") local_model_label_font = QFont('Arial', 9) local_model_label.setFont(local_model_label_font) local_model_label.setMinimumWidth(95) local_model_label.setAlignment(Qt.AlignLeft) local_model_layout.addWidget(local_model_label) self.local_model_label = local_model_label self.local_model_type_value = self.main_gui.config.get('manga_local_inpaint_model', 'anime_onnx') local_model_combo = QComboBox() local_model_combo.addItems(['aot', 'aot_onnx', 'lama', 'lama_onnx', 'anime', 'anime_onnx', 'mat', 'ollama', 'sd_local']) local_model_combo.setCurrentText(self.local_model_type_value) local_model_combo.setMinimumWidth(120) local_model_combo.setMaximumWidth(120) local_combo_font = QFont('Arial', 9) local_model_combo.setFont(local_combo_font) local_model_combo.currentTextChanged.connect(self._on_local_model_change) self._disable_combobox_mousewheel(local_model_combo) # Disable mousewheel scrolling local_model_layout.addWidget(local_model_combo) self.local_model_combo = local_model_combo # Model descriptions model_desc = { 'lama': 'LaMa (Best quality)', 'aot': 'AOT GAN (Fast)', 'aot_onnx': 'AOT ONNX (Optimized)', 'mat': 'MAT (High-res)', 'sd_local': 'Stable Diffusion (Anime)', 'anime': 'Anime/Manga Inpainting', 'anime_onnx': 'Anime ONNX (Fast/Optimized)', 'lama_onnx': 'LaMa ONNX (Optimized)', } self.model_desc_label = QLabel(model_desc.get(self.local_model_type_value, '')) desc_font = QFont('Arial', 8) self.model_desc_label.setFont(desc_font) self.model_desc_label.setStyleSheet("color: gray;") self.model_desc_label.setMaximumWidth(200) local_model_layout.addWidget(self.model_desc_label) local_model_layout.addStretch() local_inpaint_layout.addWidget(local_model_frame) # Model file selection model_path_frame = QWidget() model_path_layout = QHBoxLayout(model_path_frame) model_path_layout.setContentsMargins(0, 5, 0, 0) model_path_layout.setSpacing(10) model_file_label = QLabel("Model File:") model_file_label_font = QFont('Arial', 9) model_file_label.setFont(model_file_label_font) model_file_label.setMinimumWidth(95) model_file_label.setAlignment(Qt.AlignLeft) model_path_layout.addWidget(model_file_label) self.model_file_label = model_file_label self.local_model_path_value = self.main_gui.config.get(f'manga_{self.local_model_type_value}_model_path', '') self.local_model_entry = QLineEdit(self.local_model_path_value) self.local_model_entry.setReadOnly(True) self.local_model_entry.setMinimumWidth(300) self.local_model_entry.setStyleSheet( "QLineEdit { background-color: #2b2b2b; color: #ffffff; }" ) model_path_layout.addWidget(self.local_model_entry) browse_model_btn = QPushButton("Browse") browse_model_btn.clicked.connect(self._browse_local_model) browse_model_btn.setStyleSheet("QPushButton { background-color: #007bff; color: white; padding: 5px 15px; }") model_path_layout.addWidget(browse_model_btn) self.browse_model_btn = browse_model_btn # Manual load button to avoid auto-loading on dialog open load_model_btn = QPushButton("Load") load_model_btn.clicked.connect(self._click_load_local_model) load_model_btn.setStyleSheet("QPushButton { background-color: #28a745; color: white; padding: 5px 15px; }") model_path_layout.addWidget(load_model_btn) self.load_model_btn = load_model_btn model_path_layout.addStretch() local_inpaint_layout.addWidget(model_path_frame) # Model status self.local_model_status_label = QLabel("") status_font = QFont('Arial', 9) self.local_model_status_label.setFont(status_font) local_inpaint_layout.addWidget(self.local_model_status_label) # Download model button download_model_btn = QPushButton("📥 Download Model") download_model_btn.clicked.connect(self._download_model) download_model_btn.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px 15px; }") local_inpaint_layout.addWidget(download_model_btn) # Model info button model_info_btn = QPushButton("ℹ️ Model Info") model_info_btn.clicked.connect(self._show_model_info) model_info_btn.setStyleSheet("QPushButton { background-color: #6c757d; color: white; padding: 5px 15px; }") local_inpaint_layout.addWidget(model_info_btn) # Add local_inpaint_frame to inpaint_group inpaint_group_layout.addWidget(self.local_inpaint_frame) # Try to load saved model for current type on dialog open initial_model_type = self.local_model_type_value initial_model_path = self.main_gui.config.get(f'manga_{initial_model_type}_model_path', '') if initial_model_path and os.path.exists(initial_model_path): self.local_model_entry.setText(initial_model_path) if getattr(self, 'preload_local_models_on_open', False): self.local_model_status_label.setText("⏳ Loading saved model...") self.local_model_status_label.setStyleSheet("color: orange;") # Auto-load after dialog is ready QTimer.singleShot(500, lambda: self._try_load_model(initial_model_type, initial_model_path)) else: # Do not auto-load large models at startup to avoid crashes on some systems self.local_model_status_label.setText("💤 Saved model detected (not loaded). Click 'Load' to initialize.") self.local_model_status_label.setStyleSheet("color: blue;") else: self.local_model_status_label.setText("No model loaded") self.local_model_status_label.setStyleSheet("color: gray;") # Initialize visibility based on current settings self._toggle_inpaint_visibility() # Add inpaint_group to render_frame render_frame_layout.addWidget(inpaint_group) # Add render_frame to main layout main_layout.addWidget(render_frame) # Background Settings (moved into inpainting section) self.bg_settings_frame = QGroupBox("Background Settings") bg_settings_layout = QVBoxLayout(self.bg_settings_frame) bg_settings_layout.setContentsMargins(10, 10, 10, 10) bg_settings_layout.setSpacing(8) # Free text only background opacity toggle (applies BG opacity only to free-text regions) self.ft_only_checkbox = self._create_styled_checkbox("Free text only background opacity") self.ft_only_checkbox.setChecked(self.free_text_only_bg_opacity_value) self.ft_only_checkbox.stateChanged.connect(self._apply_rendering_settings) bg_settings_layout.addWidget(self.ft_only_checkbox) # Background opacity slider opacity_frame = QWidget() opacity_layout = QHBoxLayout(opacity_frame) opacity_layout.setContentsMargins(0, 5, 0, 5) opacity_layout.setSpacing(10) opacity_label_text = QLabel("Background Opacity:") opacity_label_text.setMinimumWidth(150) opacity_layout.addWidget(opacity_label_text) self.opacity_slider = QSlider(Qt.Horizontal) self.opacity_slider.setMinimum(0) self.opacity_slider.setMaximum(255) self.opacity_slider.setValue(self.bg_opacity_value) self.opacity_slider.setMinimumWidth(200) self.opacity_slider.valueChanged.connect(self._update_opacity_label) opacity_layout.addWidget(self.opacity_slider) self.opacity_label = QLabel("100%") self.opacity_label.setMinimumWidth(50) opacity_layout.addWidget(self.opacity_label) opacity_layout.addStretch() bg_settings_layout.addWidget(opacity_frame) # Initialize the label with the loaded value self._update_opacity_label(self.bg_opacity_value) # Background size reduction reduction_frame = QWidget() reduction_layout = QHBoxLayout(reduction_frame) reduction_layout.setContentsMargins(0, 5, 0, 5) reduction_layout.setSpacing(10) reduction_label_text = QLabel("Background Size:") reduction_label_text.setMinimumWidth(150) reduction_layout.addWidget(reduction_label_text) self.reduction_slider = QDoubleSpinBox() self.reduction_slider.setMinimum(0.5) self.reduction_slider.setMaximum(2.0) self.reduction_slider.setSingleStep(0.05) self.reduction_slider.setValue(self.bg_reduction_value) self.reduction_slider.setMinimumWidth(100) self.reduction_slider.valueChanged.connect(self._update_reduction_label) self._disable_spinbox_mousewheel(self.reduction_slider) reduction_layout.addWidget(self.reduction_slider) self.reduction_label = QLabel("100%") self.reduction_label.setMinimumWidth(50) reduction_layout.addWidget(self.reduction_label) reduction_layout.addStretch() bg_settings_layout.addWidget(reduction_frame) # Initialize the label with the loaded value self._update_reduction_label(self.bg_reduction_value) # Background style selection style_frame = QWidget() style_layout = QHBoxLayout(style_frame) style_layout.setContentsMargins(0, 5, 0, 5) style_layout.setSpacing(10) style_label = QLabel("Background Style:") style_label.setMinimumWidth(150) style_layout.addWidget(style_label) # Radio buttons for background style self.bg_style_group = QButtonGroup() box_radio = QRadioButton("Box") box_radio.setChecked(self.bg_style_value == "box") box_radio.toggled.connect(lambda checked: self._save_rendering_settings() if checked else None) self.bg_style_group.addButton(box_radio, 0) style_layout.addWidget(box_radio) circle_radio = QRadioButton("Circle") circle_radio.setChecked(self.bg_style_value == "circle") circle_radio.toggled.connect(lambda checked: self._save_rendering_settings() if checked else None) self.bg_style_group.addButton(circle_radio, 1) style_layout.addWidget(circle_radio) wrap_radio = QRadioButton("Wrap") wrap_radio.setChecked(self.bg_style_value == "wrap") wrap_radio.toggled.connect(lambda checked: self._save_rendering_settings() if checked else None) self.bg_style_group.addButton(wrap_radio, 2) style_layout.addWidget(wrap_radio) # Store references self.box_radio = box_radio self.circle_radio = circle_radio self.wrap_radio = wrap_radio # Add tooltips or descriptions style_help = QLabel("(Box: rounded rectangle, Circle: ellipse, Wrap: per-line)") style_help_font = QFont('Arial', 9) style_help.setFont(style_help_font) style_help.setStyleSheet("color: gray;") style_layout.addWidget(style_help) style_layout.addStretch() bg_settings_layout.addWidget(style_frame) # Add bg_settings_frame to render_frame_layout render_frame_layout.addWidget(self.bg_settings_frame) # Font Settings group (consolidated) self.sizing_group = QGroupBox("Font Settings") sizing_group_layout = QVBoxLayout(self.sizing_group) sizing_group_layout.setContentsMargins(10, 10, 10, 10) sizing_group_layout.setSpacing(8) # Font sizing algorithm selection algo_frame = QWidget() algo_layout = QHBoxLayout(algo_frame) algo_layout.setContentsMargins(0, 6, 0, 0) algo_layout.setSpacing(10) algo_label = QLabel("Font Size Algorithm:") algo_label.setMinimumWidth(150) algo_layout.addWidget(algo_label) # Radio buttons for algorithm selection self.font_algorithm_group = QButtonGroup() for idx, (value, text) in enumerate([ ('conservative', 'Conservative'), ('smart', 'Smart'), ('aggressive', 'Aggressive') ]): rb = QRadioButton(text) rb.setChecked(self.font_algorithm_value == value) rb.toggled.connect(lambda checked, v=value: self._save_rendering_settings() if checked else None) self.font_algorithm_group.addButton(rb, idx) algo_layout.addWidget(rb) algo_layout.addStretch() sizing_group_layout.addWidget(algo_frame) # Font size selection with mode toggle font_frame_container = QWidget() font_frame_layout = QVBoxLayout(font_frame_container) font_frame_layout.setContentsMargins(0, 5, 0, 5) font_frame_layout.setSpacing(5) # Mode selection frame mode_frame = QWidget() mode_layout = QHBoxLayout(mode_frame) mode_layout.setContentsMargins(0, 0, 0, 0) mode_layout.setSpacing(10) mode_label = QLabel("Font Size Mode:") mode_label.setMinimumWidth(150) mode_layout.addWidget(mode_label) # Radio buttons for mode selection self.font_size_mode_group = QButtonGroup() auto_radio = QRadioButton("Auto") auto_radio.setChecked(self.font_size_mode_value == "auto") auto_radio.toggled.connect(lambda checked: self._toggle_font_size_mode() if checked else None) self.font_size_mode_group.addButton(auto_radio, 0) mode_layout.addWidget(auto_radio) fixed_radio = QRadioButton("Fixed Size") fixed_radio.setChecked(self.font_size_mode_value == "fixed") fixed_radio.toggled.connect(lambda checked: self._toggle_font_size_mode() if checked else None) self.font_size_mode_group.addButton(fixed_radio, 1) mode_layout.addWidget(fixed_radio) multiplier_radio = QRadioButton("Dynamic Multiplier") multiplier_radio.setChecked(self.font_size_mode_value == "multiplier") multiplier_radio.toggled.connect(lambda checked: self._toggle_font_size_mode() if checked else None) self.font_size_mode_group.addButton(multiplier_radio, 2) mode_layout.addWidget(multiplier_radio) # Store references self.auto_mode_radio = auto_radio self.fixed_mode_radio = fixed_radio self.multiplier_mode_radio = multiplier_radio mode_layout.addStretch() font_frame_layout.addWidget(mode_frame) # Fixed font size frame self.fixed_size_frame = QWidget() fixed_size_layout = QHBoxLayout(self.fixed_size_frame) fixed_size_layout.setContentsMargins(0, 0, 0, 0) fixed_size_layout.setSpacing(10) fixed_size_label = QLabel("Font Size:") fixed_size_label.setMinimumWidth(150) fixed_size_layout.addWidget(fixed_size_label) self.font_size_spinbox = QSpinBox() self.font_size_spinbox.setMinimum(0) self.font_size_spinbox.setMaximum(72) self.font_size_spinbox.setValue(self.font_size_value) self.font_size_spinbox.setMinimumWidth(100) self.font_size_spinbox.valueChanged.connect(self._save_rendering_settings) self._disable_spinbox_mousewheel(self.font_size_spinbox) fixed_size_layout.addWidget(self.font_size_spinbox) fixed_help_label = QLabel("(0 = Auto)") fixed_help_font = QFont('Arial', 9) fixed_help_label.setFont(fixed_help_font) fixed_help_label.setStyleSheet("color: gray;") fixed_size_layout.addWidget(fixed_help_label) fixed_size_layout.addStretch() font_frame_layout.addWidget(self.fixed_size_frame) # Dynamic multiplier frame self.multiplier_frame = QWidget() multiplier_layout = QHBoxLayout(self.multiplier_frame) multiplier_layout.setContentsMargins(0, 0, 0, 0) multiplier_layout.setSpacing(10) multiplier_label_text = QLabel("Size Multiplier:") multiplier_label_text.setMinimumWidth(150) multiplier_layout.addWidget(multiplier_label_text) self.multiplier_slider = QDoubleSpinBox() self.multiplier_slider.setMinimum(0.5) self.multiplier_slider.setMaximum(2.0) self.multiplier_slider.setSingleStep(0.1) self.multiplier_slider.setValue(self.font_size_multiplier_value) self.multiplier_slider.setMinimumWidth(100) self.multiplier_slider.valueChanged.connect(self._update_multiplier_label) self._disable_spinbox_mousewheel(self.multiplier_slider) multiplier_layout.addWidget(self.multiplier_slider) self.multiplier_label = QLabel("1.0x") self.multiplier_label.setMinimumWidth(50) multiplier_layout.addWidget(self.multiplier_label) multiplier_help_label = QLabel("(Scales with panel size)") multiplier_help_font = QFont('Arial', 9) multiplier_help_label.setFont(multiplier_help_font) multiplier_help_label.setStyleSheet("color: gray;") multiplier_layout.addWidget(multiplier_help_label) multiplier_layout.addStretch() font_frame_layout.addWidget(self.multiplier_frame) # Constraint checkbox frame (only visible in multiplier mode) self.constraint_frame = QWidget() constraint_layout = QHBoxLayout(self.constraint_frame) constraint_layout.setContentsMargins(20, 0, 0, 0) constraint_layout.setSpacing(10) self.constrain_checkbox = self._create_styled_checkbox("Constrain text to bubble boundaries") self.constrain_checkbox.setChecked(self.constrain_to_bubble_value) self.constrain_checkbox.stateChanged.connect(self._save_rendering_settings) constraint_layout.addWidget(self.constrain_checkbox) constraint_help_label = QLabel("(Unchecked allows text to exceed bubbles)") constraint_help_font = QFont('Arial', 9) constraint_help_label.setFont(constraint_help_font) constraint_help_label.setStyleSheet("color: gray;") constraint_layout.addWidget(constraint_help_label) constraint_layout.addStretch() font_frame_layout.addWidget(self.constraint_frame) # Add font_frame_container to sizing_group_layout sizing_group_layout.addWidget(font_frame_container) # Minimum Font Size (Auto mode lower bound) self.min_size_frame = QWidget() min_size_layout = QHBoxLayout(self.min_size_frame) min_size_layout.setContentsMargins(0, 5, 0, 5) min_size_layout.setSpacing(10) min_size_label = QLabel("Minimum Font Size:") min_size_label.setMinimumWidth(150) min_size_layout.addWidget(min_size_label) self.min_size_spinbox = QSpinBox() self.min_size_spinbox.setMinimum(8) self.min_size_spinbox.setMaximum(20) self.min_size_spinbox.setValue(self.auto_min_size_value) self.min_size_spinbox.setMinimumWidth(100) self.min_size_spinbox.valueChanged.connect(self._save_rendering_settings) self._disable_spinbox_mousewheel(self.min_size_spinbox) min_size_layout.addWidget(self.min_size_spinbox) min_help_label = QLabel("(Auto mode won't go below this)") min_help_font = QFont('Arial', 9) min_help_label.setFont(min_help_font) min_help_label.setStyleSheet("color: gray;") min_size_layout.addWidget(min_help_label) min_size_layout.addStretch() sizing_group_layout.addWidget(self.min_size_frame) # Maximum Font Size (Auto mode upper bound) self.max_size_frame = QWidget() max_size_layout = QHBoxLayout(self.max_size_frame) max_size_layout.setContentsMargins(0, 5, 0, 5) max_size_layout.setSpacing(10) max_size_label = QLabel("Maximum Font Size:") max_size_label.setMinimumWidth(150) max_size_layout.addWidget(max_size_label) self.max_size_spinbox = QSpinBox() self.max_size_spinbox.setMinimum(20) self.max_size_spinbox.setMaximum(100) self.max_size_spinbox.setValue(self.max_font_size_value) self.max_size_spinbox.setMinimumWidth(100) self.max_size_spinbox.valueChanged.connect(self._save_rendering_settings) self._disable_spinbox_mousewheel(self.max_size_spinbox) max_size_layout.addWidget(self.max_size_spinbox) max_help_label = QLabel("(Limits maximum text size)") max_help_font = QFont('Arial', 9) max_help_label.setFont(max_help_font) max_help_label.setStyleSheet("color: gray;") max_size_layout.addWidget(max_help_label) max_size_layout.addStretch() sizing_group_layout.addWidget(self.max_size_frame) # Initialize visibility AFTER all frames are created self._toggle_font_size_mode() # Auto Fit Style (applies to Auto mode) fit_row = QWidget() fit_layout = QHBoxLayout(fit_row) fit_layout.setContentsMargins(0, 0, 0, 6) fit_layout.setSpacing(10) fit_label = QLabel("Auto Fit Style:") fit_label.setMinimumWidth(110) fit_layout.addWidget(fit_label) # Radio buttons for auto fit style self.auto_fit_style_group = QButtonGroup() for idx, (value, text) in enumerate([('compact','Compact'), ('balanced','Balanced'), ('readable','Readable')]): rb = QRadioButton(text) rb.setChecked(self.auto_fit_style_value == value) rb.toggled.connect(lambda checked, v=value: self._save_rendering_settings() if checked else None) self.auto_fit_style_group.addButton(rb, idx) fit_layout.addWidget(rb) fit_layout.addStretch() sizing_group_layout.addWidget(fit_row) # Behavior toggles self.prefer_larger_checkbox = self._create_styled_checkbox("Prefer larger text") self.prefer_larger_checkbox.setChecked(self.prefer_larger_value) self.prefer_larger_checkbox.stateChanged.connect(self._save_rendering_settings) sizing_group_layout.addWidget(self.prefer_larger_checkbox) self.bubble_size_factor_checkbox = self._create_styled_checkbox("Scale with bubble size") self.bubble_size_factor_checkbox.setChecked(self.bubble_size_factor_value) self.bubble_size_factor_checkbox.stateChanged.connect(self._save_rendering_settings) sizing_group_layout.addWidget(self.bubble_size_factor_checkbox) # Line Spacing row with live value label row_ls = QWidget() ls_layout = QHBoxLayout(row_ls) ls_layout.setContentsMargins(0, 6, 0, 2) ls_layout.setSpacing(10) ls_label = QLabel("Line Spacing:") ls_label.setMinimumWidth(110) ls_layout.addWidget(ls_label) self.line_spacing_spinbox = QDoubleSpinBox() self.line_spacing_spinbox.setMinimum(1.0) self.line_spacing_spinbox.setMaximum(2.0) self.line_spacing_spinbox.setSingleStep(0.01) self.line_spacing_spinbox.setValue(self.line_spacing_value) self.line_spacing_spinbox.setMinimumWidth(100) self.line_spacing_spinbox.valueChanged.connect(self._on_line_spacing_changed) self._disable_spinbox_mousewheel(self.line_spacing_spinbox) ls_layout.addWidget(self.line_spacing_spinbox) self.line_spacing_value_label = QLabel(f"{self.line_spacing_value:.2f}") self.line_spacing_value_label.setMinimumWidth(50) ls_layout.addWidget(self.line_spacing_value_label) ls_layout.addStretch() sizing_group_layout.addWidget(row_ls) # Max Lines row_ml = QWidget() ml_layout = QHBoxLayout(row_ml) ml_layout.setContentsMargins(0, 2, 0, 4) ml_layout.setSpacing(10) ml_label = QLabel("Max Lines:") ml_label.setMinimumWidth(110) ml_layout.addWidget(ml_label) self.max_lines_spinbox = QSpinBox() self.max_lines_spinbox.setMinimum(5) self.max_lines_spinbox.setMaximum(20) self.max_lines_spinbox.setValue(self.max_lines_value) self.max_lines_spinbox.setMinimumWidth(100) self.max_lines_spinbox.valueChanged.connect(self._save_rendering_settings) self._disable_spinbox_mousewheel(self.max_lines_spinbox) ml_layout.addWidget(self.max_lines_spinbox) ml_layout.addStretch() sizing_group_layout.addWidget(row_ml) # Quick Presets (horizontal) merged into sizing group row_presets = QWidget() presets_layout = QHBoxLayout(row_presets) presets_layout.setContentsMargins(0, 6, 0, 2) presets_layout.setSpacing(10) presets_label = QLabel("Quick Presets:") presets_label.setMinimumWidth(110) presets_layout.addWidget(presets_label) small_preset_btn = QPushButton("Small Bubbles") small_preset_btn.setMinimumWidth(120) small_preset_btn.clicked.connect(lambda: self._set_font_preset('small')) presets_layout.addWidget(small_preset_btn) balanced_preset_btn = QPushButton("Balanced") balanced_preset_btn.setMinimumWidth(120) balanced_preset_btn.clicked.connect(lambda: self._set_font_preset('balanced')) presets_layout.addWidget(balanced_preset_btn) large_preset_btn = QPushButton("Large Text") large_preset_btn.setMinimumWidth(120) large_preset_btn.clicked.connect(lambda: self._set_font_preset('large')) presets_layout.addWidget(large_preset_btn) presets_layout.addStretch() sizing_group_layout.addWidget(row_presets) # Text wrapping mode (moved into Font Settings) wrap_frame = QWidget() wrap_layout = QVBoxLayout(wrap_frame) wrap_layout.setContentsMargins(0, 12, 0, 4) wrap_layout.setSpacing(5) self.strict_wrap_checkbox = self._create_styled_checkbox("Strict text wrapping (force text to fit within bubbles)") self.strict_wrap_checkbox.setChecked(self.strict_text_wrapping_value) self.strict_wrap_checkbox.stateChanged.connect(self._save_rendering_settings) wrap_layout.addWidget(self.strict_wrap_checkbox) wrap_help_label = QLabel("(Break words with hyphens if needed)") wrap_help_font = QFont('Arial', 9) wrap_help_label.setFont(wrap_help_font) wrap_help_label.setStyleSheet("color: gray; margin-left: 20px;") wrap_layout.addWidget(wrap_help_label) # Force CAPS LOCK directly below strict wrapping self.force_caps_checkbox = self._create_styled_checkbox("Force CAPS LOCK") self.force_caps_checkbox.setChecked(self.force_caps_lock_value) self.force_caps_checkbox.stateChanged.connect(self._apply_rendering_settings) wrap_layout.addWidget(self.force_caps_checkbox) sizing_group_layout.addWidget(wrap_frame) # Update multiplier label with loaded value self._update_multiplier_label(self.font_size_multiplier_value) # Font style selection (moved into Font Settings) font_style_frame = QWidget() font_style_layout = QHBoxLayout(font_style_frame) font_style_layout.setContentsMargins(0, 6, 0, 4) font_style_layout.setSpacing(10) font_style_label = QLabel("Font Style:") font_style_label.setMinimumWidth(110) font_style_layout.addWidget(font_style_label) # Font style will be set from loaded config in _load_rendering_settings self.font_combo = QComboBox() self.font_combo.addItems(self._get_available_fonts()) self.font_combo.setCurrentText(self.font_style_value) self.font_combo.setMinimumWidth(300) self.font_combo.currentTextChanged.connect(self._on_font_selected) self._disable_combobox_mousewheel(self.font_combo) # Disable mousewheel scrolling font_style_layout.addWidget(self.font_combo) font_style_layout.addStretch() sizing_group_layout.addWidget(font_style_frame) # Add sizing_group to render_frame_layout render_frame_layout.addWidget(self.sizing_group) # Font color selection (moved into Font Settings) color_frame = QWidget() color_layout = QHBoxLayout(color_frame) color_layout.setContentsMargins(0, 6, 0, 12) color_layout.setSpacing(10) color_label = QLabel("Font Color:") color_label.setMinimumWidth(110) color_layout.addWidget(color_label) # Color preview frame self.color_preview_frame = QFrame() self.color_preview_frame.setFixedSize(40, 30) self.color_preview_frame.setFrameShape(QFrame.Box) self.color_preview_frame.setLineWidth(1) # Initialize with current color r, g, b = self.text_color_r_value, self.text_color_g_value, self.text_color_b_value self.color_preview_frame.setStyleSheet(f"background-color: rgb({r},{g},{b}); border: 1px solid #5a9fd4;") color_layout.addWidget(self.color_preview_frame) # RGB display label r, g, b = self.text_color_r_value, self.text_color_g_value, self.text_color_b_value self.rgb_label = QLabel(f"RGB({r},{g},{b})") self.rgb_label.setMinimumWidth(100) color_layout.addWidget(self.rgb_label) # Color picker button def pick_font_color(): # Get current color current_color = QColor(self.text_color_r_value, self.text_color_g_value, self.text_color_b_value) # Open color dialog color = QColorDialog.getColor(current_color, self.dialog, "Choose Font Color") if color.isValid(): # Update RGB values self.text_color_r_value = color.red() self.text_color_g_value = color.green() self.text_color_b_value = color.blue() # Update display self.rgb_label.setText(f"RGB({color.red()},{color.green()},{color.blue()})") self._update_color_preview(None) choose_color_btn = QPushButton("Choose Color") choose_color_btn.clicked.connect(pick_font_color) choose_color_btn.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px 15px; }") color_layout.addWidget(choose_color_btn) color_layout.addStretch() sizing_group_layout.addWidget(color_frame) self._update_color_preview(None) # Initialize with loaded colors # Text Shadow settings (moved into Font Settings) shadow_header = QWidget() shadow_header_layout = QHBoxLayout(shadow_header) shadow_header_layout.setContentsMargins(0, 4, 0, 4) # Shadow enabled checkbox self.shadow_enabled_checkbox = self._create_styled_checkbox("Enable Shadow") self.shadow_enabled_checkbox.setChecked(self.shadow_enabled_value) self.shadow_enabled_checkbox.stateChanged.connect(self._toggle_shadow_controls) shadow_header_layout.addWidget(self.shadow_enabled_checkbox) shadow_header_layout.addStretch() sizing_group_layout.addWidget(shadow_header) # Shadow controls container self.shadow_controls = QWidget() shadow_controls_layout = QVBoxLayout(self.shadow_controls) shadow_controls_layout.setContentsMargins(0, 2, 0, 6) shadow_controls_layout.setSpacing(5) # Shadow color shadow_color_frame = QWidget() shadow_color_layout = QHBoxLayout(shadow_color_frame) shadow_color_layout.setContentsMargins(0, 2, 0, 8) shadow_color_layout.setSpacing(10) shadow_color_label = QLabel("Shadow Color:") shadow_color_label.setMinimumWidth(110) shadow_color_layout.addWidget(shadow_color_label) # Shadow color preview self.shadow_preview_frame = QFrame() self.shadow_preview_frame.setFixedSize(30, 25) self.shadow_preview_frame.setFrameShape(QFrame.Box) self.shadow_preview_frame.setLineWidth(1) # Initialize with current color sr, sg, sb = self.shadow_color_r_value, self.shadow_color_g_value, self.shadow_color_b_value self.shadow_preview_frame.setStyleSheet(f"background-color: rgb({sr},{sg},{sb}); border: 1px solid #5a9fd4;") shadow_color_layout.addWidget(self.shadow_preview_frame) # Shadow RGB display label sr, sg, sb = self.shadow_color_r_value, self.shadow_color_g_value, self.shadow_color_b_value self.shadow_rgb_label = QLabel(f"RGB({sr},{sg},{sb})") self.shadow_rgb_label.setMinimumWidth(120) shadow_color_layout.addWidget(self.shadow_rgb_label) # Shadow color picker button def pick_shadow_color(): # Get current color current_color = QColor(self.shadow_color_r_value, self.shadow_color_g_value, self.shadow_color_b_value) # Open color dialog color = QColorDialog.getColor(current_color, self.dialog, "Choose Shadow Color") if color.isValid(): # Update RGB values self.shadow_color_r_value = color.red() self.shadow_color_g_value = color.green() self.shadow_color_b_value = color.blue() # Update display self.shadow_rgb_label.setText(f"RGB({color.red()},{color.green()},{color.blue()})") self._update_shadow_preview(None) choose_shadow_btn = QPushButton("Choose Color") choose_shadow_btn.setMinimumWidth(120) choose_shadow_btn.clicked.connect(pick_shadow_color) choose_shadow_btn.setStyleSheet("QPushButton { background-color: #17a2b8; color: white; padding: 5px 15px; }") shadow_color_layout.addWidget(choose_shadow_btn) shadow_color_layout.addStretch() shadow_controls_layout.addWidget(shadow_color_frame) self._update_shadow_preview(None) # Initialize with loaded colors # Shadow offset offset_frame = QWidget() offset_layout = QHBoxLayout(offset_frame) offset_layout.setContentsMargins(0, 2, 0, 0) offset_layout.setSpacing(10) offset_label = QLabel("Shadow Offset:") offset_label.setMinimumWidth(110) offset_layout.addWidget(offset_label) # X offset x_label = QLabel("X:") offset_layout.addWidget(x_label) self.shadow_offset_x_spinbox = QSpinBox() self.shadow_offset_x_spinbox.setMinimum(-10) self.shadow_offset_x_spinbox.setMaximum(10) self.shadow_offset_x_spinbox.setValue(self.shadow_offset_x_value) self.shadow_offset_x_spinbox.setMinimumWidth(60) self.shadow_offset_x_spinbox.valueChanged.connect(self._save_rendering_settings) self._disable_spinbox_mousewheel(self.shadow_offset_x_spinbox) offset_layout.addWidget(self.shadow_offset_x_spinbox) # Y offset y_label = QLabel("Y:") offset_layout.addWidget(y_label) self.shadow_offset_y_spinbox = QSpinBox() self.shadow_offset_y_spinbox.setMinimum(-10) self.shadow_offset_y_spinbox.setMaximum(10) self.shadow_offset_y_spinbox.setValue(self.shadow_offset_y_value) self.shadow_offset_y_spinbox.setMinimumWidth(60) self.shadow_offset_y_spinbox.valueChanged.connect(self._save_rendering_settings) self._disable_spinbox_mousewheel(self.shadow_offset_y_spinbox) offset_layout.addWidget(self.shadow_offset_y_spinbox) offset_layout.addStretch() shadow_controls_layout.addWidget(offset_frame) # Shadow blur blur_frame = QWidget() blur_layout = QHBoxLayout(blur_frame) blur_layout.setContentsMargins(0, 2, 0, 0) blur_layout.setSpacing(10) blur_label = QLabel("Shadow Blur:") blur_label.setMinimumWidth(110) blur_layout.addWidget(blur_label) self.shadow_blur_spinbox = QSpinBox() self.shadow_blur_spinbox.setMinimum(0) self.shadow_blur_spinbox.setMaximum(10) self.shadow_blur_spinbox.setValue(self.shadow_blur_value) self.shadow_blur_spinbox.setMinimumWidth(100) self.shadow_blur_spinbox.valueChanged.connect(self._on_shadow_blur_changed) self._disable_spinbox_mousewheel(self.shadow_blur_spinbox) blur_layout.addWidget(self.shadow_blur_spinbox) # Shadow blur value label self.shadow_blur_value_label = QLabel(f"{self.shadow_blur_value}") self.shadow_blur_value_label.setMinimumWidth(30) blur_layout.addWidget(self.shadow_blur_value_label) blur_help_label = QLabel("(0=sharp, 10=blurry)") blur_help_font = QFont('Arial', 9) blur_help_label.setFont(blur_help_font) blur_help_label.setStyleSheet("color: gray;") blur_layout.addWidget(blur_help_label) blur_layout.addStretch() shadow_controls_layout.addWidget(blur_frame) # Add shadow_controls to sizing_group_layout sizing_group_layout.addWidget(self.shadow_controls) # Initially disable shadow controls self._toggle_shadow_controls() # Output settings output_frame = QWidget() output_layout = QHBoxLayout(output_frame) output_layout.setContentsMargins(0, 5, 0, 0) self.create_subfolder_checkbox = self._create_styled_checkbox("Create 'translated' subfolder for output") self.create_subfolder_checkbox.setChecked(self.main_gui.config.get('manga_create_subfolder', True)) self.create_subfolder_checkbox.stateChanged.connect(self._save_rendering_settings) output_layout.addWidget(self.create_subfolder_checkbox) output_layout.addStretch() settings_frame_layout.addWidget(output_frame) # Control buttons control_frame = QWidget() control_layout = QHBoxLayout(control_frame) control_layout.setContentsMargins(10, 6, 10, 6) control_layout.setSpacing(6) # Check if ready based on selected provider # Get API key from main GUI - handle both Tkinter and PySide6 try: if hasattr(self.main_gui.api_key_entry, 'text'): # PySide6 QLineEdit has_api_key = bool(self.main_gui.api_key_entry.text().strip()) elif hasattr(self.main_gui.api_key_entry, 'get'): # Tkinter Entry has_api_key = bool(self.main_gui.api_key_entry.get().strip()) else: has_api_key = False except: has_api_key = False provider = self.ocr_provider_value # Determine readiness based on provider if provider == 'google': has_vision = os.path.exists(self.main_gui.config.get('google_vision_credentials', '')) is_ready = has_api_key and has_vision elif provider == 'azure': has_azure = bool(self.main_gui.config.get('azure_vision_key', '')) is_ready = has_api_key and has_azure elif provider == 'custom-api': is_ready = has_api_key # Only needs API key else: # Local providers (manga-ocr, easyocr, etc.) only need API key for translation is_ready = has_api_key self.start_button = QPushButton("▶ Start Translation") self.start_button.clicked.connect(self._start_translation) self.start_button.setEnabled(is_ready) self.start_button.setMinimumHeight(40) self.start_button.setMinimumWidth(160) self.start_button.setStyleSheet( "QPushButton { " " background-color: #28a745; " " color: white; " " padding: 10px 20px; " " font-size: 12pt; " " font-weight: bold; " " border-radius: 5px; " "} " "QPushButton:hover { background-color: #218838; } " "QPushButton:disabled { " " background-color: #2d2d2d; " " color: #666666; " "}" ) control_layout.addWidget(self.start_button) # Add tooltip to show why button is disabled if not is_ready: reasons = [] if not has_api_key: reasons.append("API key not configured") if provider == 'google' and not os.path.exists(self.main_gui.config.get('google_vision_credentials', '')): reasons.append("Google Vision credentials not set") elif provider == 'azure' and not self.main_gui.config.get('azure_vision_key', ''): reasons.append("Azure credentials not configured") tooltip_text = "Cannot start: " + ", ".join(reasons) self.start_button.setToolTip(tooltip_text) self.stop_button = QPushButton("⏹ Stop") self.stop_button.clicked.connect(self._stop_translation) self.stop_button.setEnabled(False) self.stop_button.setMinimumHeight(40) self.stop_button.setMinimumWidth(160) self.stop_button.setStyleSheet( "QPushButton { " " background-color: #dc3545; " " color: white; " " padding: 10px 20px; " " font-size: 12pt; " " font-weight: bold; " " border-radius: 5px; " "} " "QPushButton:hover { background-color: #c82333; } " "QPushButton:disabled { " " background-color: #2d2d2d; " " color: #999999; " "}" ) control_layout.addWidget(self.stop_button) control_layout.addStretch() main_layout.addWidget(control_frame) # Progress frame progress_frame = QGroupBox("Progress") progress_frame_font = QFont('Arial', 10) progress_frame_font.setBold(True) progress_frame.setFont(progress_frame_font) progress_frame_layout = QVBoxLayout(progress_frame) progress_frame_layout.setContentsMargins(10, 10, 10, 8) progress_frame_layout.setSpacing(6) # Overall progress self.progress_label = QLabel("Ready to start") progress_label_font = QFont('Arial', 9) self.progress_label.setFont(progress_label_font) self.progress_label.setStyleSheet("color: white;") progress_frame_layout.addWidget(self.progress_label) # Create and configure progress bar self.progress_bar = QProgressBar() self.progress_bar.setMinimum(0) self.progress_bar.setMaximum(100) self.progress_bar.setValue(0) self.progress_bar.setMinimumHeight(18) self.progress_bar.setTextVisible(True) self.progress_bar.setStyleSheet(""" QProgressBar { border: 1px solid #4a5568; border-radius: 3px; background-color: #2d3748; text-align: center; color: white; } QProgressBar::chunk { background-color: white; } """) progress_frame_layout.addWidget(self.progress_bar) # Current file status self.current_file_label = QLabel("") current_file_font = QFont('Arial', 10) self.current_file_label.setFont(current_file_font) self.current_file_label.setStyleSheet("color: lightgray;") progress_frame_layout.addWidget(self.current_file_label) main_layout.addWidget(progress_frame) # Log frame log_frame = QGroupBox("Translation Log") log_frame_font = QFont('Arial', 10) log_frame_font.setBold(True) log_frame.setFont(log_frame_font) log_frame_layout = QVBoxLayout(log_frame) log_frame_layout.setContentsMargins(10, 10, 10, 8) log_frame_layout.setSpacing(6) # Log text widget (QTextEdit handles scrolling automatically) self.log_text = QTextEdit() self.log_text.setReadOnly(True) self.log_text.setMinimumHeight(400) self.log_text.setStyleSheet(""" QTextEdit { background-color: #1e1e1e; color: white; font-family: 'Consolas', 'Courier New', monospace; font-size: 10pt; border: 1px solid #4a5568; } """) log_frame_layout.addWidget(self.log_text) main_layout.addWidget(log_frame) # Restore persistent log from previous sessions self._restore_persistent_log() def _restore_persistent_log(self): """Restore log messages from persistent storage""" try: with MangaTranslationTab._persistent_log_lock: if MangaTranslationTab._persistent_log: # PySide6 QTextEdit color_map = { 'info': 'white', 'success': 'green', 'warning': 'orange', 'error': 'red', 'debug': 'lightblue' } for message, level in MangaTranslationTab._persistent_log: color = color_map.get(level, 'white') self.log_text.setTextColor(QColor(color)) self.log_text.append(message) except Exception as e: print(f"Failed to restore persistent log: {e}") def _show_help_dialog(self, title: str, message: str): """Show a help dialog with the given title and message""" # Create a PySide6 dialog help_dialog = QDialog(self.dialog) help_dialog.setWindowTitle(title) help_dialog.resize(500, 400) help_dialog.setModal(True) # Main layout main_layout = QVBoxLayout(help_dialog) main_layout.setContentsMargins(20, 20, 20, 20) main_layout.setSpacing(10) # Icon and title title_frame = QWidget() title_layout = QHBoxLayout(title_frame) title_layout.setContentsMargins(0, 0, 0, 10) icon_label = QLabel("ℹ️") icon_font = QFont('Arial', 20) icon_label.setFont(icon_font) title_layout.addWidget(icon_label) title_label = QLabel(title) title_font = QFont('Arial', 12) title_font.setBold(True) title_label.setFont(title_font) title_layout.addWidget(title_label) title_layout.addStretch() main_layout.addWidget(title_frame) # Help text in a scrollable text widget text_widget = QTextEdit() text_widget.setReadOnly(True) text_widget.setPlainText(message) text_font = QFont('Arial', 10) text_widget.setFont(text_font) main_layout.addWidget(text_widget) # Close button close_btn = QPushButton("Close") close_btn.clicked.connect(help_dialog.accept) close_btn.setStyleSheet("QPushButton { background-color: #6c757d; color: white; padding: 5px 20px; }") main_layout.addWidget(close_btn, alignment=Qt.AlignCenter) # Show the dialog help_dialog.exec() def _on_visual_context_toggle(self): """Handle visual context toggle""" enabled = self.visual_context_enabled_value self.main_gui.config['manga_visual_context_enabled'] = enabled # Update translator if it exists if self.translator: self.translator.visual_context_enabled = enabled # Save config if hasattr(self.main_gui, 'save_config'): self.main_gui.save_config(show_message=False) # Log the change if enabled: self._log("📷 Visual context ENABLED - Images will be sent to API", "info") self._log(" Make sure you're using a vision-capable model", "warning") else: self._log("📝 Visual context DISABLED - Text-only mode", "info") self._log(" Compatible with non-vision models (Claude, GPT-3.5, etc.)", "success") def _open_advanced_settings(self): """Open the manga advanced settings dialog""" try: def on_settings_saved(settings): """Callback when settings are saved""" # Update config with new settings self.main_gui.config['manga_settings'] = settings # Mirror critical font size values into nested settings (avoid legacy top-level min key) try: rendering = settings.get('rendering', {}) if isinstance(settings, dict) else {} font_sizing = settings.get('font_sizing', {}) if isinstance(settings, dict) else {} min_from_dialog = rendering.get('auto_min_size', font_sizing.get('min_readable', font_sizing.get('min_size'))) max_from_dialog = rendering.get('auto_max_size', font_sizing.get('max_size')) if min_from_dialog is not None: ms = self.main_gui.config.setdefault('manga_settings', {}) rend = ms.setdefault('rendering', {}) font = ms.setdefault('font_sizing', {}) rend['auto_min_size'] = int(min_from_dialog) font['min_size'] = int(min_from_dialog) if hasattr(self, 'auto_min_size_value'): self.auto_min_size_value = int(min_from_dialog) if max_from_dialog is not None: self.main_gui.config['manga_max_font_size'] = int(max_from_dialog) if hasattr(self, 'max_font_size_value'): self.max_font_size_value = int(max_from_dialog) except Exception: pass # Persist mirrored values try: if hasattr(self.main_gui, 'save_config'): self.main_gui.save_config(show_message=False) except Exception: pass # Reload settings in translator if it exists if self.translator: self._log("📋 Reloading settings in translator...", "info") # The translator will pick up new settings on next operation self._log("✅ Advanced settings saved and applied", "success") # Open the settings dialog # Note: MangaSettingsDialog is still Tkinter-based, so pass Tkinter root MangaSettingsDialog( parent=self.main_gui.master, # Use Tkinter root instead of PySide6 dialog main_gui=self.main_gui, config=self.main_gui.config, callback=on_settings_saved ) except Exception as e: from PySide6.QtWidgets import QMessageBox self._log(f"❌ Error opening settings dialog: {str(e)}", "error") QMessageBox.critical(self.dialog, "Error", f"Failed to open settings dialog:\n{str(e)}") def _toggle_font_size_mode(self): """Toggle between auto, fixed size and multiplier modes""" mode = self.font_size_mode_value # Handle main frames (fixed size and multiplier) if hasattr(self, 'fixed_size_frame') and hasattr(self, 'multiplier_frame'): if mode == "fixed": self.fixed_size_frame.show() self.multiplier_frame.hide() if hasattr(self, 'constraint_frame'): self.constraint_frame.hide() elif mode == "multiplier": self.fixed_size_frame.hide() self.multiplier_frame.show() if hasattr(self, 'constraint_frame'): self.constraint_frame.show() else: # auto self.fixed_size_frame.hide() self.multiplier_frame.hide() if hasattr(self, 'constraint_frame'): self.constraint_frame.hide() # MIN/MAX FIELDS ARE ALWAYS VISIBLE - NEVER HIDE THEM # They are packed at creation time and stay visible in all modes # Only save if we're not initializing if not hasattr(self, '_initializing') or not self._initializing: self._save_rendering_settings() def _update_multiplier_label(self, value): """Update multiplier label""" self.multiplier_label.setText(f"{float(value):.1f}x") # Auto-save on change self._save_rendering_settings() def _on_line_spacing_changed(self, value): """Update line spacing value label and save""" try: if hasattr(self, 'line_spacing_value_label'): self.line_spacing_value_label.setText(f"{float(value):.2f}") except Exception: pass self._save_rendering_settings() def _on_shadow_blur_changed(self, value): """Update shadow blur value label and save""" try: if hasattr(self, 'shadow_blur_value_label'): self.shadow_blur_value_label.setText(f"{int(float(value))}") except Exception: pass self._save_rendering_settings() def _update_color_preview(self, event=None): """Update the font color preview""" r = self.text_color_r_value g = self.text_color_g_value b = self.text_color_b_value if hasattr(self, 'color_preview_frame'): self.color_preview_frame.setStyleSheet(f"background-color: rgb({r},{g},{b}); border: 1px solid #5a9fd4;") # Auto-save on change if event is not None: # Only save on user interaction, not initial load self._save_rendering_settings() def _update_shadow_preview(self, event=None): """Update the shadow color preview""" r = self.shadow_color_r_value g = self.shadow_color_g_value b = self.shadow_color_b_value if hasattr(self, 'shadow_preview_frame'): self.shadow_preview_frame.setStyleSheet(f"background-color: rgb({r},{g},{b}); border: 1px solid #5a9fd4;") # Auto-save on change if event is not None: # Only save on user interaction, not initial load self._save_rendering_settings() def _toggle_shadow_controls(self): """Enable/disable shadow controls based on checkbox""" if self.shadow_enabled_value: if hasattr(self, 'shadow_controls'): self.shadow_controls.setEnabled(True) else: if hasattr(self, 'shadow_controls'): self.shadow_controls.setEnabled(False) # Auto-save on change (but not during initialization) if not getattr(self, '_initializing', False): self._save_rendering_settings() def _set_font_preset(self, preset: str): """Apply font sizing preset (moved from dialog)""" try: if preset == 'small': self.font_algorithm_value = 'conservative' self.auto_min_size_value = 10 self.max_font_size_value = 32 self.prefer_larger_value = False self.bubble_size_factor_value = True self.line_spacing_value = 1.2 self.max_lines_value = 8 elif preset == 'balanced': self.font_algorithm_value = 'smart' self.auto_min_size_value = 12 self.max_font_size_value = 48 self.prefer_larger_value = True self.bubble_size_factor_value = True self.line_spacing_value = 1.3 self.max_lines_value = 10 elif preset == 'large': self.font_algorithm_value = 'aggressive' self.auto_min_size_value = 14 self.max_font_size_value = 64 self.prefer_larger_value = True self.bubble_size_factor_value = False self.line_spacing_value = 1.4 self.max_lines_value = 12 # Update all spinboxes with new values if hasattr(self, 'min_size_spinbox'): self.min_size_spinbox.setValue(self.auto_min_size_value) if hasattr(self, 'max_size_spinbox'): self.max_size_spinbox.setValue(self.max_font_size_value) if hasattr(self, 'line_spacing_spinbox'): self.line_spacing_spinbox.setValue(self.line_spacing_value) if hasattr(self, 'max_lines_spinbox'): self.max_lines_spinbox.setValue(self.max_lines_value) # Update checkboxes if hasattr(self, 'prefer_larger_checkbox'): self.prefer_larger_checkbox.setChecked(self.prefer_larger_value) if hasattr(self, 'bubble_size_factor_checkbox'): self.bubble_size_factor_checkbox.setChecked(self.bubble_size_factor_value) # Update the line spacing label if hasattr(self, 'line_spacing_value_label'): self.line_spacing_value_label.setText(f"{float(self.line_spacing_value):.2f}") self._save_rendering_settings() except Exception as e: self._log(f"Error setting preset: {e}", "debug") def _enable_widget_tree(self, widget): """Recursively enable a widget and its children (PySide6 version)""" try: widget.setEnabled(True) except: pass # PySide6 way to iterate children try: for child in widget.children(): if hasattr(child, 'setEnabled'): self._enable_widget_tree(child) except: pass def _disable_widget_tree(self, widget): """Recursively disable a widget and its children (PySide6 version)""" try: widget.setEnabled(False) except: pass # PySide6 way to iterate children try: for child in widget.children(): if hasattr(child, 'setEnabled'): self._disable_widget_tree(child) except: pass def _load_rendering_settings(self): """Load text rendering settings from config""" config = self.main_gui.config # One-time migration for legacy min font size key try: legacy_min = config.get('manga_min_readable_size', None) if legacy_min is not None: ms = config.setdefault('manga_settings', {}) rend = ms.setdefault('rendering', {}) font = ms.setdefault('font_sizing', {}) current_min = rend.get('auto_min_size', font.get('min_size')) if current_min is None or int(current_min) < int(legacy_min): rend['auto_min_size'] = int(legacy_min) font['min_size'] = int(legacy_min) # Remove legacy key try: del config['manga_min_readable_size'] except Exception: pass # Persist migration silently if hasattr(self.main_gui, 'save_config'): self.main_gui.save_config(show_message=False) except Exception: pass # Get inpainting settings from the nested location manga_settings = config.get('manga_settings', {}) inpaint_settings = manga_settings.get('inpainting', {}) # Load inpaint method from the correct location (no Tkinter variables in PySide6) self.inpaint_method_value = inpaint_settings.get('method', 'local') self.local_model_type_value = inpaint_settings.get('local_method', 'anime_onnx') # Load model paths self.local_model_path_value = '' for model_type in ['aot', 'aot_onnx', 'lama', 'lama_onnx', 'anime', 'anime_onnx', 'mat', 'ollama', 'sd_local']: path = inpaint_settings.get(f'{model_type}_model_path', '') if model_type == self.local_model_type_value: self.local_model_path_value = path # Initialize with defaults (plain Python values, no Tkinter variables) self.bg_opacity_value = config.get('manga_bg_opacity', 130) self.free_text_only_bg_opacity_value = config.get('manga_free_text_only_bg_opacity', True) self.bg_style_value = config.get('manga_bg_style', 'circle') self.bg_reduction_value = config.get('manga_bg_reduction', 1.0) self.font_size_value = config.get('manga_font_size', 0) self.selected_font_path = config.get('manga_font_path', None) self.skip_inpainting_value = config.get('manga_skip_inpainting', False) self.inpaint_quality_value = config.get('manga_inpaint_quality', 'high') self.inpaint_dilation_value = config.get('manga_inpaint_dilation', 15) self.inpaint_passes_value = config.get('manga_inpaint_passes', 2) self.font_size_mode_value = config.get('manga_font_size_mode', 'fixed') self.font_size_multiplier_value = config.get('manga_font_size_multiplier', 1.0) # Auto fit style for auto mode try: rend_cfg = (config.get('manga_settings', {}) or {}).get('rendering', {}) except Exception: rend_cfg = {} self.auto_fit_style_value = rend_cfg.get('auto_fit_style', 'balanced') # Auto minimum font size (from rendering or font_sizing) try: font_cfg = (config.get('manga_settings', {}) or {}).get('font_sizing', {}) except Exception: font_cfg = {} auto_min_default = rend_cfg.get('auto_min_size', font_cfg.get('min_size', 10)) self.auto_min_size_value = int(auto_min_default) self.force_caps_lock_value = config.get('manga_force_caps_lock', True) self.constrain_to_bubble_value = config.get('manga_constrain_to_bubble', True) # Advanced font sizing (from manga_settings.font_sizing) font_settings = (config.get('manga_settings', {}) or {}).get('font_sizing', {}) self.font_algorithm_value = str(font_settings.get('algorithm', 'smart')) self.prefer_larger_value = bool(font_settings.get('prefer_larger', True)) self.bubble_size_factor_value = bool(font_settings.get('bubble_size_factor', True)) self.line_spacing_value = float(font_settings.get('line_spacing', 1.3)) self.max_lines_value = int(font_settings.get('max_lines', 10)) # Determine effective max font size with fallback font_max_top = config.get('manga_max_font_size', None) nested_ms = config.get('manga_settings', {}) if isinstance(config.get('manga_settings', {}), dict) else {} nested_render = nested_ms.get('rendering', {}) if isinstance(nested_ms.get('rendering', {}), dict) else {} nested_font = nested_ms.get('font_sizing', {}) if isinstance(nested_ms.get('font_sizing', {}), dict) else {} effective_max = font_max_top if font_max_top is not None else ( nested_render.get('auto_max_size', nested_font.get('max_size', 48)) ) self.max_font_size_value = int(effective_max) # If top-level keys were missing, mirror max now (won't save during initialization) if font_max_top is None: self.main_gui.config['manga_max_font_size'] = int(effective_max) self.strict_text_wrapping_value = config.get('manga_strict_text_wrapping', False) # Font color settings manga_text_color = config.get('manga_text_color', [102, 0, 0]) self.text_color_r_value = manga_text_color[0] self.text_color_g_value = manga_text_color[1] self.text_color_b_value = manga_text_color[2] # Shadow settings self.shadow_enabled_value = config.get('manga_shadow_enabled', True) manga_shadow_color = config.get('manga_shadow_color', [204, 128, 128]) self.shadow_color_r_value = manga_shadow_color[0] self.shadow_color_g_value = manga_shadow_color[1] self.shadow_color_b_value = manga_shadow_color[2] self.shadow_offset_x_value = config.get('manga_shadow_offset_x', 2) self.shadow_offset_y_value = config.get('manga_shadow_offset_y', 2) self.shadow_blur_value = config.get('manga_shadow_blur', 0) # Initialize font_style with saved value or default self.font_style_value = config.get('manga_font_style', 'Default') # Full page context settings self.full_page_context_value = config.get('manga_full_page_context', False) self.full_page_context_prompt = config.get('manga_full_page_context_prompt', "You will receive multiple text segments from a manga page. " "Translate each segment considering the context of all segments together. " "Maintain consistency in character names, tone, and style across all translations.\n\n" "IMPORTANT: Return your response as a valid JSON object where each key is the EXACT original text " "(without the [0], [1] index prefixes) and each value is the translation.\n" "Make sure to properly escape any special characters in the JSON:\n" "- Use \\n for newlines\n" "- Use \\\" for quotes\n" "- Use \\\\ for backslashes\n\n" "Example:\n" '{\n' ' こんにちは: Hello,\n' ' ありがとう: Thank you\n' '}\n\n' 'Do NOT include the [0], [1], etc. prefixes in the JSON keys.' ) # Load OCR prompt self.ocr_prompt = config.get('manga_ocr_prompt', "YOU ARE AN OCR SYSTEM. YOUR ONLY JOB IS TEXT EXTRACTION.\n\n" "CRITICAL RULES:\n" "1. DO NOT TRANSLATE ANYTHING\n" "2. DO NOT MODIFY THE TEXT\n" "3. DO NOT EXPLAIN OR COMMENT\n" "4. ONLY OUTPUT THE EXACT TEXT YOU SEE\n" "5. PRESERVE NATURAL TEXT FLOW - DO NOT ADD UNNECESSARY LINE BREAKS\n\n" "If you see Korean text, output it in Korean.\n" "If you see Japanese text, output it in Japanese.\n" "If you see Chinese text, output it in Chinese.\n" "If you see English text, output it in English.\n\n" "IMPORTANT: Only use line breaks where they naturally occur in the original text " "(e.g., between dialogue lines or paragraphs). Do not break text mid-sentence or " "between every word/character.\n\n" "For vertical text common in manga/comics, transcribe it as a continuous line unless " "there are clear visual breaks.\n\n" "NEVER translate. ONLY extract exactly what is written.\n" "Output ONLY the raw text, nothing else." ) # Visual context setting self.visual_context_enabled_value = self.main_gui.config.get('manga_visual_context_enabled', True) self.qwen2vl_model_size = config.get('qwen2vl_model_size', '1') # Default to '1' (2B) # Initialize RapidOCR settings self.rapidocr_use_recognition_value = self.main_gui.config.get('rapidocr_use_recognition', True) self.rapidocr_language_value = self.main_gui.config.get('rapidocr_language', 'auto') self.rapidocr_detection_mode_value = self.main_gui.config.get('rapidocr_detection_mode', 'document') # Output settings self.create_subfolder_value = config.get('manga_create_subfolder', True) def _save_rendering_settings(self): """Save rendering settings with validation""" # Don't save during initialization if hasattr(self, '_initializing') and self._initializing: return # Validate that variables exist and have valid values before saving try: # Ensure manga_settings structure exists if 'manga_settings' not in self.main_gui.config: self.main_gui.config['manga_settings'] = {} if 'inpainting' not in self.main_gui.config['manga_settings']: self.main_gui.config['manga_settings']['inpainting'] = {} # Save to nested location inpaint = self.main_gui.config['manga_settings']['inpainting'] if hasattr(self, 'inpaint_method_value'): inpaint['method'] = self.inpaint_method_value if hasattr(self, 'local_model_type_value'): inpaint['local_method'] = self.local_model_type_value model_type = self.local_model_type_value if hasattr(self, 'local_model_path_value'): inpaint[f'{model_type}_model_path'] = self.local_model_path_value # Add new inpainting settings if hasattr(self, 'inpaint_method_value'): self.main_gui.config['manga_inpaint_method'] = self.inpaint_method_value if hasattr(self, 'local_model_type_value'): self.main_gui.config['manga_local_inpaint_model'] = self.local_model_type_value # Save model paths for each type for model_type in ['aot', 'lama', 'lama_onnx', 'anime', 'mat', 'ollama', 'sd_local']: if hasattr(self, 'local_model_type_value'): if model_type == self.local_model_type_value: if hasattr(self, 'local_model_path_value'): path = self.local_model_path_value if path: self.main_gui.config[f'manga_{model_type}_model_path'] = path # Save all other settings with validation if hasattr(self, 'bg_opacity_value'): self.main_gui.config['manga_bg_opacity'] = self.bg_opacity_value if hasattr(self, 'bg_style_value'): self.main_gui.config['manga_bg_style'] = self.bg_style_value if hasattr(self, 'bg_reduction_value'): self.main_gui.config['manga_bg_reduction'] = self.bg_reduction_value # Save free-text-only background opacity toggle if hasattr(self, 'free_text_only_bg_opacity_value'): self.main_gui.config['manga_free_text_only_bg_opacity'] = bool(self.free_text_only_bg_opacity_value) # CRITICAL: Font size settings - validate before saving if hasattr(self, 'font_size_value'): value = self.font_size_value self.main_gui.config['manga_font_size'] = value if hasattr(self, 'max_font_size_value'): value = self.max_font_size_value # Validate the value is reasonable if 0 <= value <= 200: self.main_gui.config['manga_max_font_size'] = value # Mirror these into nested manga_settings so the dialog and integration stay in sync try: ms = self.main_gui.config.setdefault('manga_settings', {}) rend = ms.setdefault('rendering', {}) font = ms.setdefault('font_sizing', {}) # Mirror bounds if hasattr(self, 'auto_min_size_value'): rend['auto_min_size'] = int(self.auto_min_size_value) font['min_size'] = int(self.auto_min_size_value) if hasattr(self, 'max_font_size_value'): rend['auto_max_size'] = int(self.max_font_size_value) font['max_size'] = int(self.max_font_size_value) # Persist advanced font sizing controls if hasattr(self, 'font_algorithm_value'): font['algorithm'] = str(self.font_algorithm_value) if hasattr(self, 'prefer_larger_value'): font['prefer_larger'] = bool(self.prefer_larger_value) if hasattr(self, 'bubble_size_factor_value'): font['bubble_size_factor'] = bool(self.bubble_size_factor_value) if hasattr(self, 'line_spacing_value'): font['line_spacing'] = float(self.line_spacing_value) if hasattr(self, 'max_lines_value'): font['max_lines'] = int(self.max_lines_value) if hasattr(self, 'auto_fit_style_value'): rend['auto_fit_style'] = str(self.auto_fit_style_value) except Exception: pass # Continue with other settings self.main_gui.config['manga_font_path'] = self.selected_font_path if hasattr(self, 'skip_inpainting_value'): self.main_gui.config['manga_skip_inpainting'] = self.skip_inpainting_value if hasattr(self, 'inpaint_quality_value'): self.main_gui.config['manga_inpaint_quality'] = self.inpaint_quality_value if hasattr(self, 'inpaint_dilation_value'): self.main_gui.config['manga_inpaint_dilation'] = self.inpaint_dilation_value if hasattr(self, 'inpaint_passes_value'): self.main_gui.config['manga_inpaint_passes'] = self.inpaint_passes_value if hasattr(self, 'font_size_mode_value'): self.main_gui.config['manga_font_size_mode'] = self.font_size_mode_value if hasattr(self, 'font_size_multiplier_value'): self.main_gui.config['manga_font_size_multiplier'] = self.font_size_multiplier_value if hasattr(self, 'font_style_value'): self.main_gui.config['manga_font_style'] = self.font_style_value if hasattr(self, 'constrain_to_bubble_value'): self.main_gui.config['manga_constrain_to_bubble'] = self.constrain_to_bubble_value if hasattr(self, 'strict_text_wrapping_value'): self.main_gui.config['manga_strict_text_wrapping'] = self.strict_text_wrapping_value if hasattr(self, 'force_caps_lock_value'): self.main_gui.config['manga_force_caps_lock'] = self.force_caps_lock_value # Save font color as list if hasattr(self, 'text_color_r_value') and hasattr(self, 'text_color_g_value') and hasattr(self, 'text_color_b_value'): self.main_gui.config['manga_text_color'] = [ self.text_color_r_value, self.text_color_g_value, self.text_color_b_value ] # Save shadow settings if hasattr(self, 'shadow_enabled_value'): self.main_gui.config['manga_shadow_enabled'] = self.shadow_enabled_value if hasattr(self, 'shadow_color_r_value') and hasattr(self, 'shadow_color_g_value') and hasattr(self, 'shadow_color_b_value'): self.main_gui.config['manga_shadow_color'] = [ self.shadow_color_r_value, self.shadow_color_g_value, self.shadow_color_b_value ] if hasattr(self, 'shadow_offset_x_value'): self.main_gui.config['manga_shadow_offset_x'] = self.shadow_offset_x_value if hasattr(self, 'shadow_offset_y_value'): self.main_gui.config['manga_shadow_offset_y'] = self.shadow_offset_y_value if hasattr(self, 'shadow_blur_value'): self.main_gui.config['manga_shadow_blur'] = self.shadow_blur_value # Save output settings if hasattr(self, 'create_subfolder_value'): self.main_gui.config['manga_create_subfolder'] = self.create_subfolder_value # Save full page context settings if hasattr(self, 'full_page_context_value'): self.main_gui.config['manga_full_page_context'] = self.full_page_context_value if hasattr(self, 'full_page_context_prompt'): self.main_gui.config['manga_full_page_context_prompt'] = self.full_page_context_prompt # OCR prompt if hasattr(self, 'ocr_prompt'): self.main_gui.config['manga_ocr_prompt'] = self.ocr_prompt # Qwen and custom models if hasattr(self, 'qwen2vl_model_size'): self.main_gui.config['qwen2vl_model_size'] = self.qwen2vl_model_size # RapidOCR specific settings if hasattr(self, 'rapidocr_use_recognition_value'): self.main_gui.config['rapidocr_use_recognition'] = self.rapidocr_use_recognition_value if hasattr(self, 'rapidocr_detection_mode_value'): self.main_gui.config['rapidocr_detection_mode'] = self.rapidocr_detection_mode_value if hasattr(self, 'rapidocr_language_value'): self.main_gui.config['rapidocr_language'] = self.rapidocr_language_value # Call main GUI's save_config to persist to file if hasattr(self.main_gui, 'save_config'): self.main_gui.save_config(show_message=False) except Exception as e: # Log error but don't crash print(f"Error saving manga settings: {e}") def _on_context_toggle(self): """Handle full page context toggle""" enabled = self.full_page_context_value self._save_rendering_settings() def _edit_context_prompt(self): """Open dialog to edit full page context prompt and OCR prompt""" from PySide6.QtWidgets import (QDialog, QVBoxLayout, QLabel, QTextEdit, QPushButton, QHBoxLayout) from PySide6.QtCore import Qt # Create PySide6 dialog dialog = QDialog(self.dialog) dialog.setWindowTitle("Edit Prompts") dialog.setMinimumSize(700, 600) layout = QVBoxLayout(dialog) # Instructions instructions = QLabel( "Edit the prompt used for full page context translation.\n" "This will be appended to the main translation system prompt." ) instructions.setWordWrap(True) layout.addWidget(instructions) # Full Page Context label context_label = QLabel("Full Page Context Prompt:") font = context_label.font() font.setBold(True) context_label.setFont(font) layout.addWidget(context_label) # Text editor for context text_editor = QTextEdit() text_editor.setMinimumHeight(200) text_editor.setPlainText(self.full_page_context_prompt) layout.addWidget(text_editor) # OCR Prompt label ocr_label = QLabel("OCR System Prompt:") ocr_label.setFont(font) layout.addWidget(ocr_label) # Text editor for OCR ocr_editor = QTextEdit() ocr_editor.setMinimumHeight(200) # Get current OCR prompt if hasattr(self, 'ocr_prompt'): ocr_editor.setPlainText(self.ocr_prompt) else: ocr_editor.setPlainText("") layout.addWidget(ocr_editor) def save_prompt(): self.full_page_context_prompt = text_editor.toPlainText().strip() self.ocr_prompt = ocr_editor.toPlainText().strip() # Save to config self.main_gui.config['manga_full_page_context_prompt'] = self.full_page_context_prompt self.main_gui.config['manga_ocr_prompt'] = self.ocr_prompt self._save_rendering_settings() self._log("✅ Updated prompts", "success") dialog.accept() def reset_prompt(): default_prompt = ( "You will receive multiple text segments from a manga page. " "Translate each segment considering the context of all segments together. " "Maintain consistency in character names, tone, and style across all translations.\n\n" "IMPORTANT: Return your response as a JSON object where each key is the EXACT original text " "(without the [0], [1] index prefixes) and each value is the translation. Example:\n" '{\n' ' こんにちは: Hello,\n' ' ありがとう: Thank you\n' '}\n\n' 'Do NOT include the [0], [1], etc. prefixes in the JSON keys.' ) text_editor.setPlainText(default_prompt) default_ocr = ( "YOU ARE AN OCR SYSTEM. YOUR ONLY JOB IS TEXT EXTRACTION.\n\n" "CRITICAL RULES:\n" "1. DO NOT TRANSLATE ANYTHING\n" "2. DO NOT MODIFY THE TEXT\n" "3. DO NOT EXPLAIN OR COMMENT\n" "4. ONLY OUTPUT THE EXACT TEXT YOU SEE\n" "5. PRESERVE NATURAL TEXT FLOW - DO NOT ADD UNNECESSARY LINE BREAKS\n\n" "If you see Korean text, output it in Korean.\n" "If you see Japanese text, output it in Japanese.\n" "If you see Chinese text, output it in Chinese.\n" "If you see English text, output it in English.\n\n" "IMPORTANT: Only use line breaks where they naturally occur in the original text " "(e.g., between dialogue lines or paragraphs). Do not break text mid-sentence or " "between every word/character.\n\n" "For vertical text common in manga/comics, transcribe it as a continuous line unless " "there are clear visual breaks.\n\n" "NEVER translate. ONLY extract exactly what is written.\n" "Output ONLY the raw text, nothing else." ) ocr_editor.setPlainText(default_ocr) # Button layout button_layout = QHBoxLayout() save_btn = QPushButton("Save") save_btn.clicked.connect(save_prompt) button_layout.addWidget(save_btn) reset_btn = QPushButton("Reset to Default") reset_btn.clicked.connect(reset_prompt) button_layout.addWidget(reset_btn) cancel_btn = QPushButton("Cancel") cancel_btn.clicked.connect(dialog.reject) button_layout.addWidget(cancel_btn) button_layout.addStretch() layout.addLayout(button_layout) # Show dialog dialog.exec() def _refresh_context_settings(self): """Refresh context settings from main GUI""" # Actually fetch the current values from main GUI if hasattr(self.main_gui, 'contextual_var'): contextual_enabled = self.main_gui.contextual_var.get() if hasattr(self, 'contextual_status_label'): self.contextual_status_label.setText(f"• Contextual Translation: {'Enabled' if contextual_enabled else 'Disabled'}") if hasattr(self.main_gui, 'trans_history'): history_limit = self.main_gui.trans_history.get() if hasattr(self, 'history_limit_label'): self.history_limit_label.setText(f"• Translation History Limit: {history_limit} exchanges") if hasattr(self.main_gui, 'translation_history_rolling_var'): rolling_enabled = self.main_gui.translation_history_rolling_var.get() rolling_status = "Enabled (Rolling Window)" if rolling_enabled else "Disabled (Reset on Limit)" if hasattr(self, 'rolling_status_label'): self.rolling_status_label.setText(f"• Rolling History: {rolling_status}") # Get and update model from main GUI current_model = None model_changed = False if hasattr(self.main_gui, 'model_var'): current_model = self.main_gui.model_var.get() elif hasattr(self.main_gui, 'model_combo'): current_model = self.main_gui.model_combo.get() elif hasattr(self.main_gui, 'config'): current_model = self.main_gui.config.get('model', 'Unknown') # Update model display in the API Settings frame (skip if parent_frame doesn't exist) if hasattr(self, 'parent_frame') and hasattr(self.parent_frame, 'winfo_children'): try: for widget in self.parent_frame.winfo_children(): if isinstance(widget, tk.LabelFrame) and "Translation Settings" in widget.cget("text"): for child in widget.winfo_children(): if isinstance(child, tk.Frame): for subchild in child.winfo_children(): if isinstance(subchild, tk.Label) and "Model:" in subchild.cget("text"): old_model_text = subchild.cget("text") old_model = old_model_text.split("Model: ")[-1] if "Model: " in old_model_text else None if old_model != current_model: model_changed = True subchild.config(text=f"Model: {current_model}") break except Exception: pass # Silently skip if there's an issue with Tkinter widgets # If model changed, reset translator and client to force recreation if model_changed and current_model: if self.translator: self._log(f"Model changed to {current_model}. Translator will be recreated on next run.", "info") self.translator = None # Force recreation on next translation # Also reset the client if it exists to ensure new model is used if hasattr(self.main_gui, 'client') and self.main_gui.client: if hasattr(self.main_gui.client, 'model') and self.main_gui.client.model != current_model: self.main_gui.client = None # Force recreation with new model # If translator exists, update its history manager settings if self.translator and hasattr(self.translator, 'history_manager'): try: # Update the history manager with current main GUI settings if hasattr(self.main_gui, 'contextual_var'): self.translator.history_manager.contextual_enabled = self.main_gui.contextual_var.get() if hasattr(self.main_gui, 'trans_history'): self.translator.history_manager.max_history = int(self.main_gui.trans_history.get()) if hasattr(self.main_gui, 'translation_history_rolling_var'): self.translator.history_manager.rolling_enabled = self.main_gui.translation_history_rolling_var.get() # Reset the history to apply new settings self.translator.history_manager.reset() self._log("✅ Refreshed context settings from main GUI and updated translator", "success") except Exception as e: self._log(f"✅ Refreshed context settings display (translator will update on next run)", "success") else: log_message = "✅ Refreshed context settings from main GUI" if model_changed: log_message += f" (Model: {current_model})" self._log(log_message, "success") def _browse_google_credentials_permanent(self): """Browse and set Google Cloud Vision credentials from the permanent button""" from PySide6.QtWidgets import QFileDialog file_path, _ = QFileDialog.getOpenFileName( self.dialog, "Select Google Cloud Service Account JSON", "", "JSON files (*.json);;All files (*.*)" ) if file_path: # Save to config with both keys for compatibility self.main_gui.config['google_vision_credentials'] = file_path self.main_gui.config['google_cloud_credentials'] = file_path # Save configuration if hasattr(self.main_gui, 'save_config'): self.main_gui.save_config(show_message=False) from PySide6.QtWidgets import QMessageBox # Update button state immediately if hasattr(self, 'start_button'): self.start_button.setEnabled(True) # Update credentials display if hasattr(self, 'creds_label'): self.creds_label.setText(os.path.basename(file_path)) self.creds_label.setStyleSheet("color: green;") # Update status if we have a reference if hasattr(self, 'status_label'): self.status_label.setText("✅ Ready") self.status_label.setStyleSheet("color: green;") QMessageBox.information(self.dialog, "Success", "Google Cloud credentials set successfully!") def _update_status_display(self): """Update the status display after credentials change""" # This would update the status label if we had a reference to it # For now, we'll just ensure the button is enabled google_creds_path = self.main_gui.config.get('google_vision_credentials', '') or self.main_gui.config.get('google_cloud_credentials', '') has_vision = os.path.exists(google_creds_path) if google_creds_path else False if has_vision and hasattr(self, 'start_button'): self.start_button.setEnabled(True) def _get_available_fonts(self): """Get list of available fonts from system and custom directories""" fonts = ["Default"] # Default option # Reset font mapping self.font_mapping = {} # Comprehensive map of Windows font filenames to proper display names font_name_map = { # === BASIC LATIN FONTS === # Arial family 'arial': 'Arial', 'ariali': 'Arial Italic', 'arialbd': 'Arial Bold', 'arialbi': 'Arial Bold Italic', 'ariblk': 'Arial Black', # Times New Roman 'times': 'Times New Roman', 'timesbd': 'Times New Roman Bold', 'timesi': 'Times New Roman Italic', 'timesbi': 'Times New Roman Bold Italic', # Calibri family 'calibri': 'Calibri', 'calibrib': 'Calibri Bold', 'calibrii': 'Calibri Italic', 'calibriz': 'Calibri Bold Italic', 'calibril': 'Calibri Light', 'calibrili': 'Calibri Light Italic', # Comic Sans family 'comic': 'Comic Sans MS', 'comici': 'Comic Sans MS Italic', 'comicbd': 'Comic Sans MS Bold', 'comicz': 'Comic Sans MS Bold Italic', # Segoe UI family 'segoeui': 'Segoe UI', 'segoeuib': 'Segoe UI Bold', 'segoeuii': 'Segoe UI Italic', 'segoeuiz': 'Segoe UI Bold Italic', 'segoeuil': 'Segoe UI Light', 'segoeuisl': 'Segoe UI Semilight', 'seguisb': 'Segoe UI Semibold', 'seguisbi': 'Segoe UI Semibold Italic', 'seguisli': 'Segoe UI Semilight Italic', 'seguili': 'Segoe UI Light Italic', 'seguibl': 'Segoe UI Black', 'seguibli': 'Segoe UI Black Italic', 'seguihis': 'Segoe UI Historic', 'seguiemj': 'Segoe UI Emoji', 'seguisym': 'Segoe UI Symbol', # Courier 'cour': 'Courier New', 'courbd': 'Courier New Bold', 'couri': 'Courier New Italic', 'courbi': 'Courier New Bold Italic', # Verdana 'verdana': 'Verdana', 'verdanab': 'Verdana Bold', 'verdanai': 'Verdana Italic', 'verdanaz': 'Verdana Bold Italic', # Georgia 'georgia': 'Georgia', 'georgiab': 'Georgia Bold', 'georgiai': 'Georgia Italic', 'georgiaz': 'Georgia Bold Italic', # Tahoma 'tahoma': 'Tahoma', 'tahomabd': 'Tahoma Bold', # Trebuchet 'trebuc': 'Trebuchet MS', 'trebucbd': 'Trebuchet MS Bold', 'trebucit': 'Trebuchet MS Italic', 'trebucbi': 'Trebuchet MS Bold Italic', # Impact 'impact': 'Impact', # Consolas 'consola': 'Consolas', 'consolab': 'Consolas Bold', 'consolai': 'Consolas Italic', 'consolaz': 'Consolas Bold Italic', # Sitka family (from your screenshot) 'sitka': 'Sitka Small', 'sitkab': 'Sitka Small Bold', 'sitkai': 'Sitka Small Italic', 'sitkaz': 'Sitka Small Bold Italic', 'sitkavf': 'Sitka Text', 'sitkavfb': 'Sitka Text Bold', 'sitkavfi': 'Sitka Text Italic', 'sitkavfz': 'Sitka Text Bold Italic', 'sitkasubheading': 'Sitka Subheading', 'sitkasubheadingb': 'Sitka Subheading Bold', 'sitkasubheadingi': 'Sitka Subheading Italic', 'sitkasubheadingz': 'Sitka Subheading Bold Italic', 'sitkaheading': 'Sitka Heading', 'sitkaheadingb': 'Sitka Heading Bold', 'sitkaheadingi': 'Sitka Heading Italic', 'sitkaheadingz': 'Sitka Heading Bold Italic', 'sitkadisplay': 'Sitka Display', 'sitkadisplayb': 'Sitka Display Bold', 'sitkadisplayi': 'Sitka Display Italic', 'sitkadisplayz': 'Sitka Display Bold Italic', 'sitkabanner': 'Sitka Banner', 'sitkabannerb': 'Sitka Banner Bold', 'sitkabanneri': 'Sitka Banner Italic', 'sitkabannerz': 'Sitka Banner Bold Italic', # Ink Free (from your screenshot) 'inkfree': 'Ink Free', # Lucida family 'l_10646': 'Lucida Sans Unicode', 'lucon': 'Lucida Console', 'ltype': 'Lucida Sans Typewriter', 'ltypeb': 'Lucida Sans Typewriter Bold', 'ltypei': 'Lucida Sans Typewriter Italic', 'ltypebi': 'Lucida Sans Typewriter Bold Italic', # Palatino Linotype 'pala': 'Palatino Linotype', 'palab': 'Palatino Linotype Bold', 'palabi': 'Palatino Linotype Bold Italic', 'palai': 'Palatino Linotype Italic', # Noto fonts 'notosansjp': 'Noto Sans JP', 'notoserifjp': 'Noto Serif JP', # UD Digi Kyokasho (Japanese educational font) 'uddigikyokashon-b': 'UD Digi Kyokasho NK-B', 'uddigikyokashon-r': 'UD Digi Kyokasho NK-R', 'uddigikyokashonk-b': 'UD Digi Kyokasho NK-B', 'uddigikyokashonk-r': 'UD Digi Kyokasho NK-R', # Urdu Typesetting 'urdtype': 'Urdu Typesetting', 'urdtypeb': 'Urdu Typesetting Bold', # Segoe variants 'segmdl2': 'Segoe MDL2 Assets', 'segoeicons': 'Segoe Fluent Icons', 'segoepr': 'Segoe Print', 'segoeprb': 'Segoe Print Bold', 'segoesc': 'Segoe Script', 'segoescb': 'Segoe Script Bold', 'seguivar': 'Segoe UI Variable', # Sans Serif Collection 'sansserifcollection': 'Sans Serif Collection', # Additional common Windows 10/11 fonts 'holomdl2': 'HoloLens MDL2 Assets', 'gadugi': 'Gadugi', 'gadugib': 'Gadugi Bold', # Cascadia Code (developer font) 'cascadiacode': 'Cascadia Code', 'cascadiacodepl': 'Cascadia Code PL', 'cascadiamono': 'Cascadia Mono', 'cascadiamonopl': 'Cascadia Mono PL', # More Segoe UI variants 'seguibli': 'Segoe UI Black Italic', 'segoeuiblack': 'Segoe UI Black', # Other fonts 'aldhabi': 'Aldhabi', 'andiso': 'Andalus', # This is likely Andalus font 'arabtype': 'Arabic Typesetting', 'mstmc': 'Myanmar Text', # Alternate file name 'monbaiti': 'Mongolian Baiti', # Shorter filename variant 'leeluisl': 'Leelawadee UI Semilight', # Missing variant 'simsunextg': 'SimSun-ExtG', # Extended SimSun variant 'ebrima': 'Ebrima', 'ebrimabd': 'Ebrima Bold', 'gabriola': 'Gabriola', # Bahnschrift variants 'bahnschrift': 'Bahnschrift', 'bahnschriftlight': 'Bahnschrift Light', 'bahnschriftsemibold': 'Bahnschrift SemiBold', 'bahnschriftbold': 'Bahnschrift Bold', # Majalla (African language font) 'majalla': 'Sakkal Majalla', 'majallab': 'Sakkal Majalla Bold', # Additional fonts that might be missing 'amiri': 'Amiri', 'amiri-bold': 'Amiri Bold', 'amiri-slanted': 'Amiri Slanted', 'amiri-boldslanted': 'Amiri Bold Slanted', 'aparaj': 'Aparajita', 'aparajb': 'Aparajita Bold', 'aparaji': 'Aparajita Italic', 'aparajbi': 'Aparajita Bold Italic', 'kokila': 'Kokila', 'kokilab': 'Kokila Bold', 'kokilai': 'Kokila Italic', 'kokilabi': 'Kokila Bold Italic', 'utsaah': 'Utsaah', 'utsaahb': 'Utsaah Bold', 'utsaahi': 'Utsaah Italic', 'utsaahbi': 'Utsaah Bold Italic', 'vani': 'Vani', 'vanib': 'Vani Bold', # === JAPANESE FONTS === 'msgothic': 'MS Gothic', 'mspgothic': 'MS PGothic', 'msmincho': 'MS Mincho', 'mspmincho': 'MS PMincho', 'meiryo': 'Meiryo', 'meiryob': 'Meiryo Bold', 'yugothic': 'Yu Gothic', 'yugothb': 'Yu Gothic Bold', 'yugothl': 'Yu Gothic Light', 'yugothm': 'Yu Gothic Medium', 'yugothr': 'Yu Gothic Regular', 'yumin': 'Yu Mincho', 'yumindb': 'Yu Mincho Demibold', 'yuminl': 'Yu Mincho Light', # === KOREAN FONTS === 'malgun': 'Malgun Gothic', 'malgunbd': 'Malgun Gothic Bold', 'malgunsl': 'Malgun Gothic Semilight', 'gulim': 'Gulim', 'gulimche': 'GulimChe', 'dotum': 'Dotum', 'dotumche': 'DotumChe', 'batang': 'Batang', 'batangche': 'BatangChe', 'gungsuh': 'Gungsuh', 'gungsuhche': 'GungsuhChe', # === CHINESE FONTS === # Simplified Chinese 'simsun': 'SimSun', 'simsunb': 'SimSun Bold', 'simsunextb': 'SimSun ExtB', 'nsimsun': 'NSimSun', 'simhei': 'SimHei', 'simkai': 'KaiTi', 'simfang': 'FangSong', 'simli': 'LiSu', 'simyou': 'YouYuan', 'stcaiyun': 'STCaiyun', 'stfangsong': 'STFangsong', 'sthupo': 'STHupo', 'stkaiti': 'STKaiti', 'stliti': 'STLiti', 'stsong': 'STSong', 'stxihei': 'STXihei', 'stxingkai': 'STXingkai', 'stxinwei': 'STXinwei', 'stzhongsong': 'STZhongsong', # Traditional Chinese 'msjh': 'Microsoft JhengHei', 'msjhbd': 'Microsoft JhengHei Bold', 'msjhl': 'Microsoft JhengHei Light', 'mingliu': 'MingLiU', 'pmingliu': 'PMingLiU', 'mingliub': 'MingLiU Bold', 'mingliuhk': 'MingLiU_HKSCS', 'mingliuextb': 'MingLiU ExtB', 'pmingliuextb': 'PMingLiU ExtB', 'mingliuhkextb': 'MingLiU_HKSCS ExtB', 'kaiu': 'DFKai-SB', # Microsoft YaHei 'msyh': 'Microsoft YaHei', 'msyhbd': 'Microsoft YaHei Bold', 'msyhl': 'Microsoft YaHei Light', # === THAI FONTS === 'leelawui': 'Leelawadee UI', 'leelauib': 'Leelawadee UI Bold', 'leelauisl': 'Leelawadee UI Semilight', 'leelawad': 'Leelawadee', 'leelawdb': 'Leelawadee Bold', # === INDIC FONTS === 'mangal': 'Mangal', 'vrinda': 'Vrinda', 'raavi': 'Raavi', 'shruti': 'Shruti', 'tunga': 'Tunga', 'gautami': 'Gautami', 'kartika': 'Kartika', 'latha': 'Latha', 'kalinga': 'Kalinga', 'vijaya': 'Vijaya', 'nirmala': 'Nirmala UI', 'nirmalab': 'Nirmala UI Bold', 'nirmalas': 'Nirmala UI Semilight', # === ARABIC FONTS === 'arial': 'Arial', 'trado': 'Traditional Arabic', 'tradbdo': 'Traditional Arabic Bold', 'simpo': 'Simplified Arabic', 'simpbdo': 'Simplified Arabic Bold', 'simpfxo': 'Simplified Arabic Fixed', # === OTHER ASIAN FONTS === 'javatext': 'Javanese Text', 'himalaya': 'Microsoft Himalaya', 'mongolianbaiti': 'Mongolian Baiti', 'msuighur': 'Microsoft Uighur', 'msuighub': 'Microsoft Uighur Bold', 'msyi': 'Microsoft Yi Baiti', 'taileb': 'Microsoft Tai Le Bold', 'taile': 'Microsoft Tai Le', 'ntailu': 'Microsoft New Tai Lue', 'ntailub': 'Microsoft New Tai Lue Bold', 'phagspa': 'Microsoft PhagsPa', 'phagspab': 'Microsoft PhagsPa Bold', 'mmrtext': 'Myanmar Text', 'mmrtextb': 'Myanmar Text Bold', # === SYMBOL FONTS === 'symbol': 'Symbol', 'webdings': 'Webdings', 'wingding': 'Wingdings', 'wingdng2': 'Wingdings 2', 'wingdng3': 'Wingdings 3', 'mtextra': 'MT Extra', 'marlett': 'Marlett', # === OTHER FONTS === 'mvboli': 'MV Boli', 'sylfaen': 'Sylfaen', 'estrangelo': 'Estrangelo Edessa', 'euphemia': 'Euphemia', 'plantagenet': 'Plantagenet Cherokee', 'micross': 'Microsoft Sans Serif', # Franklin Gothic 'framd': 'Franklin Gothic Medium', 'framdit': 'Franklin Gothic Medium Italic', 'fradm': 'Franklin Gothic Demi', 'fradmcn': 'Franklin Gothic Demi Cond', 'fradmit': 'Franklin Gothic Demi Italic', 'frahv': 'Franklin Gothic Heavy', 'frahvit': 'Franklin Gothic Heavy Italic', 'frabook': 'Franklin Gothic Book', 'frabookit': 'Franklin Gothic Book Italic', # Cambria 'cambria': 'Cambria', 'cambriab': 'Cambria Bold', 'cambriai': 'Cambria Italic', 'cambriaz': 'Cambria Bold Italic', 'cambria&cambria math': 'Cambria Math', # Candara 'candara': 'Candara', 'candarab': 'Candara Bold', 'candarai': 'Candara Italic', 'candaraz': 'Candara Bold Italic', 'candaral': 'Candara Light', 'candarali': 'Candara Light Italic', # Constantia 'constan': 'Constantia', 'constanb': 'Constantia Bold', 'constani': 'Constantia Italic', 'constanz': 'Constantia Bold Italic', # Corbel 'corbel': 'Corbel', 'corbelb': 'Corbel Bold', 'corbeli': 'Corbel Italic', 'corbelz': 'Corbel Bold Italic', 'corbell': 'Corbel Light', 'corbelli': 'Corbel Light Italic', # Bahnschrift 'bahnschrift': 'Bahnschrift', # Garamond 'gara': 'Garamond', 'garabd': 'Garamond Bold', 'garait': 'Garamond Italic', # Century Gothic 'gothic': 'Century Gothic', 'gothicb': 'Century Gothic Bold', 'gothici': 'Century Gothic Italic', 'gothicz': 'Century Gothic Bold Italic', # Bookman Old Style 'bookos': 'Bookman Old Style', 'bookosb': 'Bookman Old Style Bold', 'bookosi': 'Bookman Old Style Italic', 'bookosbi': 'Bookman Old Style Bold Italic', } # Dynamically discover all Windows fonts windows_fonts = [] windows_font_dir = "C:/Windows/Fonts" if os.path.exists(windows_font_dir): for font_file in os.listdir(windows_font_dir): font_path = os.path.join(windows_font_dir, font_file) # Check if it's a font file if os.path.isfile(font_path) and font_file.lower().endswith(('.ttf', '.ttc', '.otf')): # Get base name without extension base_name = os.path.splitext(font_file)[0] base_name_lower = base_name.lower() # Check if we have a proper name mapping if base_name_lower in font_name_map: display_name = font_name_map[base_name_lower] else: # Generic cleanup for unmapped fonts display_name = base_name.replace('_', ' ').replace('-', ' ') display_name = ' '.join(word.capitalize() for word in display_name.split()) windows_fonts.append((display_name, font_path)) # Sort alphabetically windows_fonts.sort(key=lambda x: x[0]) # Add all discovered fonts to the list for font_name, font_path in windows_fonts: fonts.append(font_name) self.font_mapping[font_name] = font_path # Check for custom fonts directory (keep your existing code) script_dir = os.path.dirname(os.path.abspath(__file__)) fonts_dir = os.path.join(script_dir, "fonts") if os.path.exists(fonts_dir): for root, dirs, files in os.walk(fonts_dir): for font_file in files: if font_file.endswith(('.ttf', '.ttc', '.otf')): font_path = os.path.join(root, font_file) font_name = os.path.splitext(font_file)[0] # Add category from folder category = os.path.basename(root) if category != "fonts": font_name = f"{font_name} ({category})" fonts.append(font_name) self.font_mapping[font_name] = font_path # Load previously saved custom fonts (keep your existing code) if 'custom_fonts' in self.main_gui.config: for custom_font in self.main_gui.config['custom_fonts']: if os.path.exists(custom_font['path']): # Check if this font is already in the list if custom_font['name'] not in fonts: fonts.append(custom_font['name']) self.font_mapping[custom_font['name']] = custom_font['path'] # Add custom fonts option at the end fonts.append("Browse Custom Font...") return fonts def _on_font_selected(self, event=None): """Handle font selection""" if not hasattr(self, 'font_combo'): return selected = self.font_combo.currentText() if selected == "Default": self.selected_font_path = None elif selected == "Browse Custom Font...": # Open file dialog to select custom font font_path = filedialog.askopenfilename( title="Select Font File", filetypes=[ ("Font files", "*.ttf *.ttc *.otf"), ("TrueType fonts", "*.ttf"), ("TrueType collections", "*.ttc"), ("OpenType fonts", "*.otf"), ("All files", "*.*") ] ) if font_path: # Add to combo box font_name = os.path.basename(font_path) # Insert before "Browse Custom Font..." option if font_name not in [n for n in self.font_mapping.keys()]: # Add to combo box (PySide6) self.font_combo.insertItem(self.font_combo.count() - 1, font_name) self.font_combo.setCurrentText(font_name) # Update font mapping self.font_mapping[font_name] = font_path self.selected_font_path = font_path # Save custom font to config if 'custom_fonts' not in self.main_gui.config: self.main_gui.config['custom_fonts'] = [] custom_font_entry = {'name': font_name, 'path': font_path} # Check if this exact entry already exists font_exists = False for existing_font in self.main_gui.config['custom_fonts']: if existing_font['path'] == font_path: font_exists = True break if not font_exists: self.main_gui.config['custom_fonts'].append(custom_font_entry) # Save config immediately to persist custom fonts if hasattr(self.main_gui, 'save_config'): self.main_gui.save_config(show_message=False) else: # Font already exists, just select it self.font_combo.setCurrentText(font_name) self.selected_font_path = self.font_mapping[font_name] else: # User cancelled, revert to previous selection if hasattr(self, 'previous_font_selection'): self.font_combo.setCurrentText(self.previous_font_selection) else: self.font_combo.setCurrentText("Default") return else: # Check if it's in the font mapping if selected in self.font_mapping: self.selected_font_path = self.font_mapping[selected] else: # This shouldn't happen, but just in case self.selected_font_path = None # Store current selection for next time self.previous_font_selection = selected # Auto-save on change self._save_rendering_settings() def _update_opacity_label(self, value): """Update opacity percentage label""" percentage = int((float(value) / 255) * 100) self.opacity_label.setText(f"{percentage}%") # Auto-save on change self._save_rendering_settings() def _update_reduction_label(self, value): """Update size reduction percentage label""" percentage = int(float(value) * 100) self.reduction_label.setText(f"{percentage}%") # Auto-save on change self._save_rendering_settings() def _toggle_inpaint_quality_visibility(self): """Show/hide inpaint quality options based on skip_inpainting setting""" if hasattr(self, 'inpaint_quality_frame'): if self.skip_inpainting_value: # Hide quality options when inpainting is skipped self.inpaint_quality_frame.pack_forget() else: # Show quality options when inpainting is enabled self.inpaint_quality_frame.pack(fill=tk.X, pady=5, after=self.skip_inpainting_checkbox) def _toggle_inpaint_visibility(self): """Show/hide inpainting options based on skip toggle""" # Update the value from the checkbox self.skip_inpainting_value = self.skip_inpainting_checkbox.isChecked() if self.skip_inpainting_value: # Hide all inpainting options self.inpaint_method_frame.hide() self.cloud_inpaint_frame.hide() self.local_inpaint_frame.hide() self.inpaint_separator.hide() # Hide separator else: # Show method selection self.inpaint_method_frame.show() self.inpaint_separator.show() # Show separator self._on_inpaint_method_change() self._save_rendering_settings() def _on_inpaint_method_change(self): """Show appropriate inpainting settings based on method""" # Determine current method from radio buttons if self.cloud_radio.isChecked(): method = 'cloud' elif self.local_radio.isChecked(): method = 'local' elif self.hybrid_radio.isChecked(): method = 'hybrid' else: method = 'local' # Default fallback # Update the stored value self.inpaint_method_value = method if method == 'cloud': self.cloud_inpaint_frame.show() self.local_inpaint_frame.hide() elif method == 'local': self.local_inpaint_frame.show() self.cloud_inpaint_frame.hide() elif method == 'hybrid': # Show both frames for hybrid self.local_inpaint_frame.show() self.cloud_inpaint_frame.show() self._save_rendering_settings() def _on_local_model_change(self, new_model_type=None): """Handle model type change and auto-load if model exists""" # Get model type from combo box (PySide6) if new_model_type is None: model_type = self.local_model_combo.currentText() else: model_type = new_model_type # Update stored value self.local_model_type_value = model_type # Update description model_desc = { 'lama': 'LaMa (Best quality)', 'aot': 'AOT GAN (Fast)', 'aot_onnx': 'AOT ONNX (Optimized)', 'mat': 'MAT (High-res)', 'sd_local': 'Stable Diffusion (Anime)', 'anime': 'Anime/Manga Inpainting', 'anime_onnx': 'Anime ONNX (Fast/Optimized)', 'lama_onnx': 'LaMa ONNX (Optimized)', } self.model_desc_label.setText(model_desc.get(model_type, '')) # Check for saved path for this model type saved_path = self.main_gui.config.get(f'manga_{model_type}_model_path', '') if saved_path and os.path.exists(saved_path): # Update the path display self.local_model_entry.setText(saved_path) self.local_model_path_value = saved_path self.local_model_status_label.setText("⏳ Loading saved model...") self.local_model_status_label.setStyleSheet("color: orange;") # Auto-load the model after a short delay using QTimer from PySide6.QtCore import QTimer QTimer.singleShot(100, lambda: self._try_load_model(model_type, saved_path)) else: # Clear the path display self.local_model_entry.setText("") self.local_model_path_value = "" self.local_model_status_label.setText("No model loaded") self.local_model_status_label.setStyleSheet("color: gray;") self._save_rendering_settings() def _browse_local_model(self): """Browse for local inpainting model and auto-load""" from PySide6.QtWidgets import QFileDialog from PySide6.QtCore import QTimer model_type = self.local_model_type_value if model_type == 'sd_local': filter_str = "Model files (*.safetensors *.pt *.pth *.ckpt *.onnx);;SafeTensors (*.safetensors);;Checkpoint files (*.ckpt);;PyTorch models (*.pt *.pth);;ONNX models (*.onnx);;All files (*.*)" else: filter_str = "Model files (*.pt *.pth *.ckpt *.onnx);;Checkpoint files (*.ckpt);;PyTorch models (*.pt *.pth);;ONNX models (*.onnx);;All files (*.*)" path, _ = QFileDialog.getOpenFileName( self.dialog, f"Select {model_type.upper()} Model", "", filter_str ) if path: self.local_model_entry.setText(path) self.local_model_path_value = path # Save to config self.main_gui.config[f'manga_{model_type}_model_path'] = path self._save_rendering_settings() # Update status first self._update_local_model_status() # Auto-load the selected model using QTimer QTimer.singleShot(100, lambda: self._try_load_model(model_type, path)) def _click_load_local_model(self): """Manually trigger loading of the selected local inpainting model""" from PySide6.QtWidgets import QMessageBox from PySide6.QtCore import QTimer try: model_type = self.local_model_type_value if hasattr(self, 'local_model_type_value') else None path = self.local_model_path_value if hasattr(self, 'local_model_path_value') else '' if not model_type or not path: QMessageBox.information(self.dialog, "Load Model", "Please select a model file first using the Browse button.") return # Defer to keep UI responsive using QTimer QTimer.singleShot(50, lambda: self._try_load_model(model_type, path)) except Exception: pass def _try_load_model(self, method: str, model_path: str): """Try to load a model and update status (runs loading on a background thread).""" from PySide6.QtCore import QTimer, QMetaObject, Qt, Q_ARG, QThread from PySide6.QtWidgets import QApplication try: # Show loading status immediately self.local_model_status_label.setText("⏳ Loading model...") self.local_model_status_label.setStyleSheet("color: orange;") QApplication.processEvents() # Process pending events to update UI self.main_gui.append_log(f"⏳ Loading {method.upper()} model...") def do_load(): from local_inpainter import LocalInpainter success = False try: test_inpainter = LocalInpainter() success = test_inpainter.load_model_with_retry(method, model_path, force_reload=True) print(f"DEBUG: Model loading completed, success={success}") except Exception as e: print(f"DEBUG: Model loading exception: {e}") self.main_gui.append_log(f"❌ Error loading model: {e}") success = False # Update UI directly from thread (works in PySide6/Qt6) print(f"DEBUG: Updating UI, success={success}") try: if success: self.local_model_status_label.setText(f"✅ {method.upper()} model ready") self.local_model_status_label.setStyleSheet("color: green;") self.main_gui.append_log(f"✅ {method.upper()} model loaded successfully!") if hasattr(self, 'translator') and self.translator: for attr in ('local_inpainter', '_last_local_method', '_last_local_model_path'): if hasattr(self.translator, attr): try: delattr(self.translator, attr) except Exception: pass else: self.local_model_status_label.setText("⚠️ Model file found but failed to load") self.local_model_status_label.setStyleSheet("color: orange;") self.main_gui.append_log("⚠️ Model file found but failed to load") print(f"DEBUG: UI update completed") except Exception as e: print(f"ERROR updating UI after load: {e}") import traceback traceback.print_exc() # Fire background loader threading.Thread(target=do_load, daemon=True).start() return True except Exception as e: try: self.local_model_status_label.setText(f"❌ Error: {str(e)[:50]}") self.local_model_status_label.setStyleSheet("color: red;") except Exception: pass self.main_gui.append_log(f"❌ Error loading model: {e}") return False def _update_local_model_status(self): """Update local model status display""" path = self.local_model_path_value if hasattr(self, 'local_model_path_value') else '' if not path: self.local_model_status_label.setText("⚠️ No model selected") self.local_model_status_label.setStyleSheet("color: orange;") return if not os.path.exists(path): self.local_model_status_label.setText("❌ Model file not found") self.local_model_status_label.setStyleSheet("color: red;") return # Check for ONNX cache if path.endswith(('.pt', '.pth', '.safetensors')): onnx_dir = os.path.join(os.path.dirname(path), 'models') if os.path.exists(onnx_dir): # Check if ONNX file exists for this model model_hash = hashlib.md5(path.encode()).hexdigest()[:8] onnx_files = [f for f in os.listdir(onnx_dir) if model_hash in f] if onnx_files: self.local_model_status_label.setText("✅ Model ready (ONNX cached)") self.local_model_status_label.setStyleSheet("color: green;") else: self.local_model_status_label.setText("ℹ️ Will convert to ONNX on first use") self.local_model_status_label.setStyleSheet("color: blue;") else: self.local_model_status_label.setText("ℹ️ Will convert to ONNX on first use") self.local_model_status_label.setStyleSheet("color: blue;") else: self.local_model_status_label.setText("✅ ONNX model ready") self.local_model_status_label.setStyleSheet("color: green;") def _download_model(self): """Actually download the model for the selected type""" from PySide6.QtWidgets import QMessageBox model_type = self.local_model_type_value # Define URLs for each model type model_urls = { 'aot': 'https://huggingface.co/ogkalu/aot-inpainting-jit/resolve/main/aot_traced.pt', 'aot_onnx': 'https://huggingface.co/ogkalu/aot-inpainting/resolve/main/aot.onnx', 'lama': 'https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt', 'lama_onnx': 'https://huggingface.co/Carve/LaMa-ONNX/resolve/main/lama_fp32.onnx', 'anime': 'https://github.com/Sanster/models/releases/download/AnimeMangaInpainting/anime-manga-big-lama.pt', 'anime_onnx': 'https://huggingface.co/ogkalu/lama-manga-onnx-dynamic/resolve/main/lama-manga-dynamic.onnx', 'mat': '', # User must provide 'ollama': '', # Not applicable 'sd_local': '' # User must provide } url = model_urls.get(model_type, '') if not url: QMessageBox.information(self.dialog, "Manual Download", f"Please manually download and browse for {model_type} model") return # Determine filename filename_map = { 'aot': 'aot_traced.pt', 'aot_onnx': 'aot.onnx', 'lama': 'big-lama.pt', 'anime': 'anime-manga-big-lama.pt', 'anime_onnx': 'lama-manga-dynamic.onnx', 'lama_onnx': 'lama_fp32.onnx', 'fcf_onnx': 'fcf.onnx', 'sd_inpaint_onnx': 'sd_inpaint_unet.onnx' } filename = filename_map.get(model_type, f'{model_type}.pt') save_path = os.path.join('models', filename) # Create models directory os.makedirs('models', exist_ok=True) # Check if already exists if os.path.exists(save_path): self.local_model_entry.setText(save_path) self.local_model_path_value = save_path self.local_model_status_label.setText("✅ Model already downloaded") self.local_model_status_label.setStyleSheet("color: green;") QMessageBox.information(self.dialog, "Model Ready", f"Model already exists at:\n{save_path}") return # Download the model self._perform_download(url, save_path, model_type) def _perform_download(self, url: str, save_path: str, model_name: str): """Perform the actual download with progress indication""" import threading import requests from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar, QPushButton from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QIcon # Create a progress dialog progress_dialog = QDialog(self.dialog) progress_dialog.setWindowTitle(f"Downloading {model_name.upper()} Model") progress_dialog.setFixedSize(400, 150) progress_dialog.setModal(True) # Set window icon icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'halgakos.ico') if os.path.exists(icon_path): progress_dialog.setWindowIcon(QIcon(icon_path)) layout = QVBoxLayout(progress_dialog) # Progress label progress_label = QLabel("⏳ Downloading...") progress_label.setAlignment(Qt.AlignCenter) layout.addWidget(progress_label) # Progress bar progress_bar = QProgressBar() progress_bar.setMinimum(0) progress_bar.setMaximum(100) progress_bar.setValue(0) layout.addWidget(progress_bar) # Status label status_label = QLabel("0%") status_label.setAlignment(Qt.AlignCenter) layout.addWidget(status_label) # Cancel flag cancel_download = {'value': False} def on_cancel(): cancel_download['value'] = True progress_dialog.close() progress_dialog.closeEvent = lambda event: on_cancel() def download_thread(): import time try: # Download with progress and speed tracking response = requests.get(url, stream=True, timeout=30) response.raise_for_status() total_size = int(response.headers.get('content-length', 0)) downloaded = 0 start_time = time.time() last_update = start_time with open(save_path, 'wb') as f: for chunk in response.iter_content(chunk_size=8192): if cancel_download['value']: # Clean up partial file f.close() if os.path.exists(save_path): os.remove(save_path) return if chunk: f.write(chunk) downloaded += len(chunk) # Update progress (throttle updates to every 0.1 seconds) current_time = time.time() if total_size > 0 and (current_time - last_update > 0.1): last_update = current_time elapsed = current_time - start_time speed = downloaded / elapsed if elapsed > 0 else 0 speed_mb = speed / (1024 * 1024) progress = (downloaded / total_size) * 100 # Direct widget updates work in PySide6 from threads try: progress_bar.setValue(int(progress)) status_label.setText(f"{progress:.1f}% - {speed_mb:.2f} MB/s") progress_label.setText(f"⏳ Downloading... {downloaded//1024//1024}MB / {total_size//1024//1024}MB") except RuntimeError: # Widget was destroyed, exit cancel_download['value'] = True return # Success - direct call (works in PySide6/Qt6) try: progress_dialog.close() self._download_complete(save_path, model_name) except Exception as e: print(f"Error in download completion: {e}") except requests.exceptions.RequestException as e: # Error - direct call if not cancel_download['value']: try: progress_dialog.close() self._download_failed(str(e)) except Exception as ex: print(f"Error handling download failure: {ex}") except Exception as e: if not cancel_download['value']: try: progress_dialog.close() self._download_failed(str(e)) except Exception as ex: print(f"Error handling download failure: {ex}") # Start download in background thread thread = threading.Thread(target=download_thread, daemon=True) thread.start() # Show dialog progress_dialog.exec() def _download_complete(self, save_path: str, model_name: str): """Handle successful download""" from PySide6.QtWidgets import QMessageBox # Update the model path entry self.local_model_entry.setText(save_path) self.local_model_path_value = save_path # Save to config self.main_gui.config[f'manga_{model_name}_model_path'] = save_path self._save_rendering_settings() # Log to main GUI self.main_gui.append_log(f"✅ Downloaded {model_name} model to: {save_path}") # Auto-load the downloaded model (direct call) self.local_model_status_label.setText("⏳ Loading downloaded model...") self.local_model_status_label.setStyleSheet("color: orange;") # Try to load immediately if self._try_load_model(model_name, save_path): QMessageBox.information(self.dialog, "Success", f"{model_name.upper()} model downloaded and loaded!") else: QMessageBox.information(self.dialog, "Download Complete", f"{model_name.upper()} model downloaded but needs manual loading") def _download_failed(self, error: str): """Handle download failure""" from PySide6.QtWidgets import QMessageBox QMessageBox.critical(self.dialog, "Download Failed", f"Failed to download model:\n{error}") self.main_gui.append_log(f"❌ Model download failed: {error}") def _show_model_info(self): """Show information about models""" model_type = self.local_model_type_value info = { 'aot': "AOT GAN Model:\n\n" "• Auto-downloads from HuggingFace\n" "• Traced PyTorch JIT model\n" "• Good for general inpainting\n" "• Fast processing speed\n" "• File size: ~100MB", 'aot_onnx': "AOT ONNX Model:\n\n" "• Optimized ONNX version\n" "• Auto-downloads from HuggingFace\n" "• 2-3x faster than PyTorch version\n" "• Great for batch processing\n" "• Lower memory usage\n" "• File size: ~100MB", 'lama': "LaMa Model:\n\n" "• Auto-downloads anime-optimized version\n" "• Best quality for manga/anime\n" "• Large model (~200MB)\n" "• Excellent at removing text from bubbles\n" "• Preserves art style well", 'anime': "Anime-Specific Model:\n\n" "• Same as LaMa anime version\n" "• Optimized for manga/anime art\n" "• Auto-downloads from GitHub\n" "• Recommended for manga translation\n" "• Preserves screen tones and patterns", 'anime_onnx': "Anime ONNX Model:\n\n" "• Optimized ONNX version for speed\n" "• Auto-downloads from HuggingFace\n" "• 2-3x faster than PyTorch version\n" "• Perfect for batch processing\n" "• Same quality as anime model\n" "• File size: ~190MB\n" "• DEFAULT for inpainting", 'mat': "MAT Model:\n\n" "• Manual download required\n" "• Get from: github.com/fenglinglwb/MAT\n" "• Good for high-resolution images\n" "• Slower but high quality\n" "• File size: ~500MB", 'ollama': "Ollama:\n\n" "• Uses local Ollama server\n" "• No model download needed here\n" "• Run: ollama pull llava\n" "• Context-aware inpainting\n" "• Requires Ollama running locally", 'sd_local': "Stable Diffusion:\n\n" "• Manual download required\n" "• Get from HuggingFace\n" "• Requires significant VRAM (4-8GB)\n" "• Best quality but slowest\n" "• Can use custom prompts" } from PySide6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QPushButton from PySide6.QtCore import Qt # Create info dialog info_dialog = QDialog(self.dialog) info_dialog.setWindowTitle(f"{model_type.upper()} Model Information") info_dialog.setFixedSize(450, 350) info_dialog.setModal(True) layout = QVBoxLayout(info_dialog) # Info text text_widget = QTextEdit() text_widget.setReadOnly(True) text_widget.setPlainText(info.get(model_type, "Please select a model type first")) layout.addWidget(text_widget) # Close button close_btn = QPushButton("Close") close_btn.clicked.connect(info_dialog.close) close_btn.setStyleSheet("QPushButton { background-color: #6c757d; color: white; padding: 5px 15px; }") layout.addWidget(close_btn) info_dialog.exec() def _toggle_inpaint_controls_visibility(self): """Toggle visibility of inpaint controls (mask expansion and passes) based on skip inpainting setting""" # Just return if the frame doesn't exist - prevents AttributeError if not hasattr(self, 'inpaint_controls_frame'): return if self.skip_inpainting_value: self.inpaint_controls_frame.hide() else: # Show it back self.inpaint_controls_frame.show() def _configure_inpaint_api(self): """Configure cloud inpainting API""" from PySide6.QtWidgets import QMessageBox, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton from PySide6.QtCore import Qt import webbrowser # Show instructions result = QMessageBox.question( self.dialog, "Configure Cloud Inpainting", "Cloud inpainting uses Replicate API for questionable results.\n\n" "1. Go to replicate.com and sign up (free tier available?)\n" "2. Get your API token from Account Settings\n" "3. Enter it here\n\n" "Pricing: ~$0.0023 per image?\n" "Free tier: ~100 images per month?\n\n" "Would you like to proceed?", QMessageBox.Yes | QMessageBox.No ) if result != QMessageBox.Yes: return # Open Replicate page webbrowser.open("https://replicate.com/account/api-tokens") # Create API key input dialog api_dialog = QDialog(self.dialog) api_dialog.setWindowTitle("Replicate API Key") api_dialog.setFixedSize(400, 150) api_dialog.setModal(True) layout = QVBoxLayout(api_dialog) layout.setContentsMargins(20, 20, 20, 20) # Label label = QLabel("Enter your Replicate API key:") layout.addWidget(label) # Entry with show/hide entry_layout = QHBoxLayout() entry = QLineEdit() entry.setEchoMode(QLineEdit.Password) entry_layout.addWidget(entry) # Toggle show/hide show_btn = QPushButton("Show") show_btn.setFixedWidth(60) def toggle_show(): if entry.echoMode() == QLineEdit.Password: entry.setEchoMode(QLineEdit.Normal) show_btn.setText("Hide") else: entry.setEchoMode(QLineEdit.Password) show_btn.setText("Show") show_btn.clicked.connect(toggle_show) entry_layout.addWidget(show_btn) layout.addLayout(entry_layout) # Buttons btn_layout = QHBoxLayout() btn_layout.addStretch() cancel_btn = QPushButton("Cancel") cancel_btn.clicked.connect(api_dialog.reject) btn_layout.addWidget(cancel_btn) ok_btn = QPushButton("OK") ok_btn.setStyleSheet("QPushButton { background-color: #28a745; color: white; padding: 5px 15px; }") ok_btn.clicked.connect(api_dialog.accept) btn_layout.addWidget(ok_btn) layout.addLayout(btn_layout) # Focus and key bindings entry.setFocus() # Execute dialog if api_dialog.exec() == QDialog.Accepted: api_key = entry.text().strip() if api_key: try: # Save the API key self.main_gui.config['replicate_api_key'] = api_key self.main_gui.save_config(show_message=False) # Update UI self.inpaint_api_status_label.setText("✅ Cloud inpainting configured") self.inpaint_api_status_label.setStyleSheet("color: green;") # Set flag on translator if self.translator: self.translator.use_cloud_inpainting = True self.translator.replicate_api_key = api_key self._log("✅ Cloud inpainting API configured", "success") except Exception as e: QMessageBox.critical(self.dialog, "Error", f"Failed to save API key:\n{str(e)}") def _clear_inpaint_api(self): """Clear the inpainting API configuration""" self.main_gui.config['replicate_api_key'] = '' self.main_gui.save_config(show_message=False) self.inpaint_api_status_label.setText("❌ Inpainting API not configured") self.inpaint_api_status_label.setStyleSheet("color: red;") if hasattr(self, 'translator') and self.translator: self.translator.use_cloud_inpainting = False self.translator.replicate_api_key = None self._log("🗑️ Cleared inpainting API configuration", "info") # Note: Clear button management would need to be handled differently in PySide6 # For now, we'll skip automatic button removal def _add_files(self): """Add image files (and CBZ archives) to the list""" from PySide6.QtWidgets import QFileDialog files, _ = QFileDialog.getOpenFileNames( self.dialog, "Select Manga Images or CBZ", "", "Images / CBZ (*.png *.jpg *.jpeg *.gif *.bmp *.webp *.cbz);;Image files (*.png *.jpg *.jpeg *.gif *.bmp *.webp);;Comic Book Zip (*.cbz);;All files (*.*)" ) if not files: return # Ensure temp root for CBZ extraction lives for the session cbz_temp_root = getattr(self, 'cbz_temp_root', None) if cbz_temp_root is None: try: import tempfile cbz_temp_root = tempfile.mkdtemp(prefix='glossarion_cbz_') self.cbz_temp_root = cbz_temp_root except Exception: cbz_temp_root = None for path in files: lower = path.lower() if lower.endswith('.cbz'): # Extract images from CBZ and add them in natural sort order try: import zipfile, shutil base = os.path.splitext(os.path.basename(path))[0] extract_dir = os.path.join(self.cbz_temp_root or os.path.dirname(path), base) os.makedirs(extract_dir, exist_ok=True) with zipfile.ZipFile(path, 'r') as zf: # Extract all to preserve subfolders and avoid name collisions zf.extractall(extract_dir) # Initialize CBZ job tracking if not hasattr(self, 'cbz_jobs'): self.cbz_jobs = {} if not hasattr(self, 'cbz_image_to_job'): self.cbz_image_to_job = {} # Prepare output dir next to source CBZ out_dir = os.path.join(os.path.dirname(path), f"{base}_translated") self.cbz_jobs[path] = { 'extract_dir': extract_dir, 'out_dir': out_dir, } # Collect all images recursively from extract_dir added = 0 for root, _, files_in_dir in os.walk(extract_dir): for fn in sorted(files_in_dir): if fn.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif')): target_path = os.path.join(root, fn) if target_path not in self.selected_files: self.selected_files.append(target_path) self.file_listbox.addItem(os.path.basename(target_path)) added += 1 # Map extracted image to its CBZ job self.cbz_image_to_job[target_path] = path self._log(f"📦 Added {added} images from CBZ: {os.path.basename(path)}", "info") except Exception as e: self._log(f"❌ Failed to read CBZ {os.path.basename(path)}: {e}", "error") else: if path not in self.selected_files: self.selected_files.append(path) self.file_listbox.addItem(os.path.basename(path)) def _add_folder(self): """Add all images (and CBZ archives) from a folder""" from PySide6.QtWidgets import QFileDialog folder = QFileDialog.getExistingDirectory( self.dialog, "Select Folder with Manga Images or CBZ" ) if not folder: return # Extensions image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'} cbz_ext = '.cbz' # Ensure temp root for CBZ extraction lives for the session cbz_temp_root = getattr(self, 'cbz_temp_root', None) if cbz_temp_root is None: try: import tempfile cbz_temp_root = tempfile.mkdtemp(prefix='glossarion_cbz_') self.cbz_temp_root = cbz_temp_root except Exception: cbz_temp_root = None for filename in sorted(os.listdir(folder)): filepath = os.path.join(folder, filename) if not os.path.isfile(filepath): continue lower = filename.lower() if any(lower.endswith(ext) for ext in image_extensions): if filepath not in self.selected_files: self.selected_files.append(filepath) self.file_listbox.addItem(filename) elif lower.endswith(cbz_ext): # Extract images from CBZ archive try: import zipfile, shutil base = os.path.splitext(os.path.basename(filepath))[0] extract_dir = os.path.join(self.cbz_temp_root or folder, base) os.makedirs(extract_dir, exist_ok=True) with zipfile.ZipFile(filepath, 'r') as zf: zf.extractall(extract_dir) # Initialize CBZ job tracking if not hasattr(self, 'cbz_jobs'): self.cbz_jobs = {} if not hasattr(self, 'cbz_image_to_job'): self.cbz_image_to_job = {} # Prepare output dir next to source CBZ out_dir = os.path.join(os.path.dirname(filepath), f"{base}_translated") self.cbz_jobs[filepath] = { 'extract_dir': extract_dir, 'out_dir': out_dir, } # Collect all images recursively added = 0 for root, _, files_in_dir in os.walk(extract_dir): for fn in sorted(files_in_dir): if fn.lower().endswith(tuple(image_extensions)): target_path = os.path.join(root, fn) if target_path not in self.selected_files: self.selected_files.append(target_path) self.file_listbox.addItem(os.path.basename(target_path)) added += 1 # Map extracted image to its CBZ job self.cbz_image_to_job[target_path] = filepath self._log(f"📦 Added {added} images from CBZ: {filename}", "info") except Exception as e: self._log(f"❌ Failed to read CBZ {filename}: {e}", "error") def _remove_selected(self): """Remove selected files from the list""" selected_items = self.file_listbox.selectedItems() if not selected_items: return # Remove in reverse order to maintain indices for item in selected_items: row = self.file_listbox.row(item) self.file_listbox.takeItem(row) if 0 <= row < len(self.selected_files): del self.selected_files[row] def _clear_all(self): """Clear all files from the list""" self.file_listbox.clear() self.selected_files.clear() def _finalize_cbz_jobs(self): """Package translated outputs back into .cbz for each imported CBZ. - Always creates a CLEAN archive with only final translated pages. - If save_intermediate is enabled in settings, also creates a DEBUG archive that contains the same final pages at root plus debug/raw artifacts under subfolders. """ try: if not hasattr(self, 'cbz_jobs') or not self.cbz_jobs: return import zipfile # Read debug flag from settings save_debug = False try: save_debug = bool(self.main_gui.config.get('manga_settings', {}).get('advanced', {}).get('save_intermediate', False)) except Exception: save_debug = False image_exts = ('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif') text_exts = ('.txt', '.json', '.csv', '.log') excluded_patterns = ('_mask', '_overlay', '_debug', '_raw', '_ocr', '_regions', '_chunk', '_clean', '_cleaned', '_inpaint', '_inpainted') for cbz_path, job in self.cbz_jobs.items(): out_dir = job.get('out_dir') if not out_dir or not os.path.isdir(out_dir): continue parent = os.path.dirname(cbz_path) base = os.path.splitext(os.path.basename(cbz_path))[0] # Compute original basenames from extracted images mapping original_basenames = set() try: if hasattr(self, 'cbz_image_to_job'): for img_path, job_path in self.cbz_image_to_job.items(): if job_path == cbz_path: original_basenames.add(os.path.basename(img_path)) except Exception: pass # Helper to iterate files in out_dir all_files = [] for root, _, files in os.walk(out_dir): for fn in files: fp = os.path.join(root, fn) rel = os.path.relpath(fp, out_dir) all_files.append((fp, rel, fn)) # 1) CLEAN ARCHIVE: only final images matching original basenames clean_zip = os.path.join(parent, f"{base}_translated.cbz") clean_count = 0 with zipfile.ZipFile(clean_zip, 'w', zipfile.ZIP_DEFLATED) as zf: for fp, rel, fn in all_files: fn_lower = fn.lower() if not fn_lower.endswith(image_exts): continue if original_basenames and fn not in original_basenames: # Only include pages corresponding to original entries continue # Also skip obvious debug artifacts by pattern (extra safeguard) if any(p in fn_lower for p in excluded_patterns): continue zf.write(fp, fn) # place at root with page filename clean_count += 1 self._log(f"📦 Compiled CLEAN {clean_count} pages into {os.path.basename(clean_zip)}", "success") # 2) DEBUG ARCHIVE: include final pages + extras under subfolders if save_debug: debug_zip = os.path.join(parent, f"{base}_translated_debug.cbz") dbg_count = 0 raw_count = 0 page_count = 0 with zipfile.ZipFile(debug_zip, 'w', zipfile.ZIP_DEFLATED) as zf: for fp, rel, fn in all_files: fn_lower = fn.lower() # Final pages at root if fn_lower.endswith(image_exts) and (not original_basenames or fn in original_basenames) and not any(p in fn_lower for p in excluded_patterns): zf.write(fp, fn) page_count += 1 continue # Raw text/logs if fn_lower.endswith(text_exts): zf.write(fp, os.path.join('raw', rel)) raw_count += 1 continue # Other images or artifacts -> debug/ zf.write(fp, os.path.join('debug', rel)) dbg_count += 1 self._log(f"📦 Compiled DEBUG archive: pages={page_count}, debug_files={dbg_count}, raw={raw_count} -> {os.path.basename(debug_zip)}", "info") except Exception as e: self._log(f"⚠️ Failed to compile CBZ packages: {e}", "warning") def _attach_logging_bridge(self): """Attach a root logging handler that forwards records into the GUI log.""" try: if getattr(self, '_gui_log_handler', None) is None: handler = _MangaGuiLogHandler(self, level=logging.INFO) root_logger = logging.getLogger() # Avoid duplicates if all(not isinstance(h, _MangaGuiLogHandler) for h in root_logger.handlers): root_logger.addHandler(handler) self._gui_log_handler = handler # Ensure common module loggers propagate for name in ['bubble_detector', 'local_inpainter', 'manga_translator']: try: lg = logging.getLogger(name) lg.setLevel(logging.INFO) lg.propagate = True except Exception: pass except Exception: pass def _redirect_stderr(self, enable: bool): """Temporarily redirect stderr to the GUI log (captures tqdm/HF progress).""" try: if enable: if not hasattr(self, '_old_stderr') or self._old_stderr is None: self._old_stderr = sys.stderr sys.stderr = _StreamToGuiLog(lambda s: self._log(s, 'info')) self._stderr_redirect_on = True else: if hasattr(self, '_old_stderr') and self._old_stderr is not None: sys.stderr = self._old_stderr self._old_stderr = None self._stderr_redirect_on = False # Update combined flag to avoid double-forwarding with logging handler self._stdio_redirect_active = bool(self._stdout_redirect_on or self._stderr_redirect_on) except Exception: pass def _redirect_stdout(self, enable: bool): """Temporarily redirect stdout to the GUI log.""" try: if enable: if not hasattr(self, '_old_stdout') or self._old_stdout is None: self._old_stdout = sys.stdout sys.stdout = _StreamToGuiLog(lambda s: self._log(s, 'info')) self._stdout_redirect_on = True else: if hasattr(self, '_old_stdout') and self._old_stdout is not None: sys.stdout = self._old_stdout self._old_stdout = None self._stdout_redirect_on = False # Update combined flag to avoid double-forwarding with logging handler self._stdio_redirect_active = bool(self._stdout_redirect_on or self._stderr_redirect_on) except Exception: pass def _log(self, message: str, level: str = "info"): """Log message to GUI text widget or console with enhanced stop suppression""" # Enhanced stop suppression - allow only essential stop confirmation messages if self._is_stop_requested() or self.is_globally_cancelled(): # Only allow very specific stop confirmation messages - nothing else essential_stop_keywords = [ "⏹️ Translation stopped by user", "🧹 Cleaning up models to free RAM", "✅ Model cleanup complete - RAM should be freed", "✅ All models cleaned up - RAM freed!" ] # Suppress ALL other messages when stopped - be very restrictive if not any(keyword in message for keyword in essential_stop_keywords): return # Lightweight deduplication: ignore identical lines within a short interval try: now = time.time() last_msg = getattr(self, '_last_log_msg', None) last_ts = getattr(self, '_last_log_time', 0) if last_msg == message and (now - last_ts) < 0.7: return except Exception: pass # Store in persistent log (thread-safe) try: with MangaTranslationTab._persistent_log_lock: # Keep only last 1000 messages to avoid unbounded growth if len(MangaTranslationTab._persistent_log) >= 1000: MangaTranslationTab._persistent_log.pop(0) MangaTranslationTab._persistent_log.append((message, level)) except Exception: pass # Check if log_text widget exists yet if hasattr(self, 'log_text') and self.log_text: # Thread-safe logging to GUI if threading.current_thread() == threading.main_thread(): # We're in the main thread, update directly try: # PySide6 QTextEdit - append with color color_map = { 'info': 'white', 'success': 'green', 'warning': 'orange', 'error': 'red', 'debug': 'lightblue' } color = color_map.get(level, 'white') self.log_text.setTextColor(QColor(color)) self.log_text.append(message) except Exception: pass else: # We're in a background thread, use queue self.update_queue.put(('log', message, level)) else: # Widget doesn't exist yet or we're in initialization, print to console print(message) # Update deduplication state try: self._last_log_msg = message self._last_log_time = time.time() except Exception: pass def _update_progress(self, current: int, total: int, status: str): """Thread-safe progress update""" self.update_queue.put(('progress', current, total, status)) def _update_current_file(self, filename: str): """Thread-safe current file update""" self.update_queue.put(('current_file', filename)) def _start_startup_heartbeat(self): """Show a small spinner in the progress label during startup so there is no silence.""" try: self._startup_heartbeat_running = True self._heartbeat_idx = 0 chars = ['|', '/', '-', '\\'] def tick(): if not getattr(self, '_startup_heartbeat_running', False): return try: c = chars[self._heartbeat_idx % len(chars)] if hasattr(self, 'progress_label'): self.progress_label.setText(f"Starting… {c}") self.progress_label.setStyleSheet("color: white;") except Exception: pass self._heartbeat_idx += 1 # Schedule next tick with QTimer QTimer.singleShot(250, tick) # Kick off QTimer.singleShot(0, tick) except Exception: pass def _stop_startup_heartbeat(self): try: self._startup_heartbeat_running = False except Exception: pass def _process_updates(self): """Process queued GUI updates""" try: while True: update = self.update_queue.get_nowait() if update[0] == 'log': _, message, level = update try: # PySide6 QTextEdit color_map = { 'info': 'white', 'success': 'green', 'warning': 'orange', 'error': 'red', 'debug': 'lightblue' } color = color_map.get(level, 'white') self.log_text.setTextColor(QColor(color)) self.log_text.append(message) except Exception: pass elif update[0] == 'progress': _, current, total, status = update if total > 0: percentage = (current / total) * 100 self.progress_bar.setValue(int(percentage)) # Check if this is a stopped status and style accordingly if "stopped" in status.lower() or "cancelled" in status.lower(): # Make the status more prominent for stopped translations self.progress_label.setText(f"⏹️ {status}") self.progress_label.setStyleSheet("color: orange;") elif "complete" in status.lower() or "finished" in status.lower(): # Success status self.progress_label.setText(f"✅ {status}") self.progress_label.setStyleSheet("color: green;") elif "error" in status.lower() or "failed" in status.lower(): # Error status self.progress_label.setText(f"❌ {status}") self.progress_label.setStyleSheet("color: red;") else: # Normal status - white for dark mode self.progress_label.setText(status) self.progress_label.setStyleSheet("color: white;") elif update[0] == 'current_file': _, filename = update # Style the current file display based on the status if "stopped" in filename.lower() or "cancelled" in filename.lower(): self.current_file_label.setText(f"⏹️ {filename}") self.current_file_label.setStyleSheet("color: orange;") elif "complete" in filename.lower() or "finished" in filename.lower(): self.current_file_label.setText(f"✅ {filename}") self.current_file_label.setStyleSheet("color: green;") elif "error" in filename.lower() or "failed" in filename.lower(): self.current_file_label.setText(f"❌ {filename}") self.current_file_label.setStyleSheet("color: red;") else: self.current_file_label.setText(f"Current: {filename}") self.current_file_label.setStyleSheet("color: lightgray;") except: pass # Schedule next update with QTimer QTimer.singleShot(100, self._process_updates) def load_local_inpainting_model(self, model_path): """Load a local inpainting model Args: model_path: Path to the model file Returns: bool: True if successful """ try: # Store the model path self.local_inpaint_model_path = model_path # If using diffusers/torch models, load them here if model_path.endswith('.safetensors') or model_path.endswith('.ckpt'): # Initialize your inpainting pipeline # This depends on your specific inpainting implementation # Example: # from diffusers import StableDiffusionInpaintPipeline # self.inpaint_pipeline = StableDiffusionInpaintPipeline.from_single_file(model_path) pass return True except Exception as e: self._log(f"Failed to load inpainting model: {e}", "error") return False def _start_translation(self): """Start the translation process""" print("DEBUG: _start_translation called") # Mirror console output to GUI during startup for immediate feedback self._redirect_stdout(True) self._redirect_stderr(True) if not self.selected_files: print("DEBUG: No files selected") from PySide6.QtWidgets import QMessageBox QMessageBox.warning(self.dialog, "No Files", "Please select manga images to translate.") return print(f"DEBUG: Selected {len(self.selected_files)} files") # Immediately disable Start to prevent double-clicks try: if hasattr(self, 'start_button') and self.start_button: self.start_button.setEnabled(False) print("DEBUG: Start button disabled") except Exception as e: print(f"DEBUG: Error disabling start button: {e}") # Don't automatically clear log - let users see previous session logs # Users can manually clear via Clear Log button if desired # try: # if hasattr(self, 'log_text') and self.log_text: # self.log_text.config(state='normal') # self.log_text.delete('1.0', tk.END) # self.log_text.config(state='disabled') # except Exception: # pass # Immediate minimal feedback self._log("starting translation", "info") print("DEBUG: Logged 'starting translation'") try: from PySide6.QtWidgets import QApplication QApplication.processEvents() print("DEBUG: Processed events after starting translation log") except Exception as e: print(f"DEBUG: Error processing events: {e}") # Start heartbeat spinner so there's visible activity until logs stream self._start_startup_heartbeat() # Reset all stop flags at the start of new translation self.reset_stop_flags() print("DEBUG: Reset stop flags") self._log("🚀 Starting new manga translation batch", "info") print("DEBUG: Logged starting batch message") try: # Let the GUI render the above log immediately from PySide6.QtWidgets import QApplication QApplication.processEvents() print("DEBUG: Processed events after batch start log") except Exception as e: print(f"DEBUG: Error processing events: {e}") # Run the heavy preparation and kickoff in a background thread to avoid GUI freeze print("DEBUG: Starting background thread for _start_translation_heavy") threading.Thread(target=self._start_translation_heavy, name="MangaStartHeavy", daemon=True).start() print("DEBUG: Background thread started") return def _start_translation_heavy(self): """Heavy part of start: build configs, init client/translator, and launch worker (runs off-main-thread).""" print("DEBUG: _start_translation_heavy entered") # Early feedback self._log("⏳ Preparing configuration...", "info") print("DEBUG: Logged 'Preparing configuration'") # Build OCR configuration ocr_config = {'provider': self.ocr_provider_value} if ocr_config['provider'] == 'Qwen2-VL': qwen_provider = self.ocr_manager.get_provider('Qwen2-VL') if qwen_provider: # Set model size configuration if hasattr(qwen_provider, 'loaded_model_size'): if qwen_provider.loaded_model_size == "Custom": ocr_config['model_size'] = f"custom:{qwen_provider.model_id}" else: size_map = {'2B': '1', '7B': '2', '72B': '3'} ocr_config['model_size'] = size_map.get(qwen_provider.loaded_model_size, '2') self._log(f"Setting ocr_config['model_size'] = {ocr_config['model_size']}", "info") # Set OCR prompt if available if hasattr(self, 'ocr_prompt'): # Set it via environment variable (Qwen2VL will read this) os.environ['OCR_SYSTEM_PROMPT'] = self.ocr_prompt # Also set it directly on the provider if it has the method if hasattr(qwen_provider, 'set_ocr_prompt'): qwen_provider.set_ocr_prompt(self.ocr_prompt) else: # If no setter method, set it directly qwen_provider.ocr_prompt = self.ocr_prompt self._log("✅ Set custom OCR prompt for Qwen2-VL", "info") elif ocr_config['provider'] == 'google': import os google_creds = self.main_gui.config.get('google_vision_credentials', '') or self.main_gui.config.get('google_cloud_credentials', '') if not google_creds or not os.path.exists(google_creds): from PySide6.QtWidgets import QMessageBox QMessageBox.critical(self.dialog, "Error", "Google Cloud Vision credentials not found.\nPlease set up credentials in the main settings.") self._stop_startup_heartbeat() self._reset_ui_state() return ocr_config['google_credentials_path'] = google_creds elif ocr_config['provider'] == 'azure': azure_key = self.azure_key_entry.get().strip() azure_endpoint = self.azure_endpoint_entry.get().strip() if not azure_key or not azure_endpoint: from PySide6.QtWidgets import QMessageBox from PySide6.QtCore import QTimer QMessageBox.critical(self.dialog, "Error", "Azure credentials not configured.") self._stop_startup_heartbeat() self._reset_ui_state() return # Save Azure settings self.main_gui.config['azure_vision_key'] = azure_key self.main_gui.config['azure_vision_endpoint'] = azure_endpoint if hasattr(self.main_gui, 'save_config'): self.main_gui.save_config(show_message=False) ocr_config['azure_key'] = azure_key ocr_config['azure_endpoint'] = azure_endpoint # Get current API key and model for translation api_key = None model = 'gemini-2.5-flash' # default # Try to get API key from various sources if hasattr(self.main_gui, 'api_key_entry') and self.main_gui.api_key_entry.get().strip(): api_key = self.main_gui.api_key_entry.get().strip() elif hasattr(self.main_gui, 'config') and self.main_gui.config.get('api_key'): api_key = self.main_gui.config.get('api_key') # Try to get model - ALWAYS get the current selection from GUI if hasattr(self.main_gui, 'model_var'): model = self.main_gui.model_var.get() elif hasattr(self.main_gui, 'config') and self.main_gui.config.get('model'): model = self.main_gui.config.get('model') if not api_key: from PySide6.QtWidgets import QMessageBox QMessageBox.critical(self.dialog, "Error", "API key not found.\nPlease configure your API key in the main settings.") self._stop_startup_heartbeat() self._reset_ui_state() return # Check if we need to create or update the client needs_new_client = False self._log("🔎 Checking API client...", "debug") if not hasattr(self.main_gui, 'client') or not self.main_gui.client: needs_new_client = True self._log(f"🛠 Creating new API client with model: {model}", "info") elif hasattr(self.main_gui.client, 'model') and self.main_gui.client.model != model: needs_new_client = True self._log(f"🛠 Model changed from {self.main_gui.client.model} to {model}, creating new client", "info") else: self._log("♻️ Reusing existing API client", "debug") if needs_new_client: # Apply multi-key settings from config so UnifiedClient picks them up try: import os # Import os here use_mk = bool(self.main_gui.config.get('use_multi_api_keys', False)) mk_list = self.main_gui.config.get('multi_api_keys', []) if use_mk and mk_list: os.environ['USE_MULTI_API_KEYS'] = '1' os.environ['USE_MULTI_KEYS'] = '1' # backward-compat for retry paths os.environ['MULTI_API_KEYS'] = json.dumps(mk_list) os.environ['FORCE_KEY_ROTATION'] = '1' if self.main_gui.config.get('force_key_rotation', True) else '0' os.environ['ROTATION_FREQUENCY'] = str(self.main_gui.config.get('rotation_frequency', 1)) self._log("🔑 Multi-key mode ENABLED for manga translator", "info") else: # Explicitly disable if not configured os.environ['USE_MULTI_API_KEYS'] = '0' os.environ['USE_MULTI_KEYS'] = '0' # Fallback keys (optional) if self.main_gui.config.get('use_fallback_keys', False): os.environ['USE_FALLBACK_KEYS'] = '1' os.environ['FALLBACK_KEYS'] = json.dumps(self.main_gui.config.get('fallback_keys', [])) else: os.environ['USE_FALLBACK_KEYS'] = '0' os.environ['FALLBACK_KEYS'] = '[]' except Exception as env_err: self._log(f"⚠️ Failed to apply multi-key settings: {env_err}", "warning") # Create the unified client with the current model try: from unified_api_client import UnifiedClient self._log("⏳ Creating API client (network/model handshake)...", "debug") self.main_gui.client = UnifiedClient(model=model, api_key=api_key) self._log(f"✅ API client ready (model: {model})", "info") try: time.sleep(0.05) except Exception: pass except Exception as e: from PySide6.QtWidgets import QMessageBox QMessageBox.critical(self.dialog, "Error", f"Failed to create API client:\n{str(e)}") self._stop_startup_heartbeat() self._reset_ui_state() return # Reset the translator's history manager for new batch if hasattr(self, 'translator') and self.translator and hasattr(self.translator, 'reset_history_manager'): self.translator.reset_history_manager() # Set environment variables for custom-api provider if ocr_config['provider'] == 'custom-api': import os # Import os for environment variables env_vars = self.main_gui._get_environment_variables( epub_path='', # Not needed for manga api_key=api_key ) # Apply all environment variables EXCEPT SYSTEM_PROMPT for key, value in env_vars.items(): if key == 'SYSTEM_PROMPT': # DON'T SET THE TRANSLATION SYSTEM PROMPT FOR OCR continue os.environ[key] = str(value) # Set a VERY EXPLICIT OCR prompt that OpenAI can't ignore os.environ['OCR_SYSTEM_PROMPT'] = ( "YOU ARE AN OCR SYSTEM. YOUR ONLY JOB IS TEXT EXTRACTION.\n\n" "CRITICAL RULES:\n" "1. DO NOT TRANSLATE ANYTHING\n" "2. DO NOT MODIFY THE TEXT\n" "3. DO NOT EXPLAIN OR COMMENT\n" "4. ONLY OUTPUT THE EXACT TEXT YOU SEE\n" "5. PRESERVE NATURAL TEXT FLOW - DO NOT ADD UNNECESSARY LINE BREAKS\n\n" "If you see Korean text, output it in Korean.\n" "If you see Japanese text, output it in Japanese.\n" "If you see Chinese text, output it in Chinese.\n" "If you see English text, output it in English.\n\n" "IMPORTANT: Only use line breaks where they naturally occur in the original text " "(e.g., between dialogue lines or paragraphs). Do not break text mid-sentence or " "between every word/character.\n\n" "For vertical text common in manga/comics, transcribe it as a continuous line unless " "there are clear visual breaks.\n\n" "NEVER translate. ONLY extract exactly what is written.\n" "Output ONLY the raw text, nothing else." ) self._log("✅ Set environment variables for custom-api OCR (excluded SYSTEM_PROMPT)") # Respect user settings: only set default detector values when bubble detection is OFF. try: ms = self.main_gui.config.setdefault('manga_settings', {}) ocr_set = ms.setdefault('ocr', {}) changed = False bubble_enabled = bool(ocr_set.get('bubble_detection_enabled', False)) if not bubble_enabled: # User has bubble detection OFF -> set non-intrusive defaults only if 'detector_type' not in ocr_set: ocr_set['detector_type'] = 'rtdetr_onnx' changed = True if not ocr_set.get('rtdetr_model_url') and not ocr_set.get('bubble_model_path'): # Default HF repo (detector.onnx lives here) ocr_set['rtdetr_model_url'] = 'ogkalu/comic-text-and-bubble-detector' changed = True if changed and hasattr(self.main_gui, 'save_config'): self.main_gui.save_config(show_message=False) # Do not preload bubble detector for custom-api here; it will load on use or via panel preloading self._preloaded_bd = None except Exception: self._preloaded_bd = None # Initialize translator if needed (or if it was reset or client was cleared during shutdown) needs_new_translator = (not hasattr(self, 'translator')) or (self.translator is None) if not needs_new_translator: try: needs_new_translator = getattr(self.translator, 'client', None) is None if needs_new_translator: self._log("♻️ Translator exists but client was cleared — reinitializing translator", "debug") except Exception: needs_new_translator = True if needs_new_translator: self._log("⚙️ Initializing translator...", "info") try: self.translator = MangaTranslator( ocr_config, self.main_gui.client, self.main_gui, log_callback=self._log ) # Fix 4: Safely set OCR manager if hasattr(self, 'ocr_manager'): self.translator.ocr_manager = self.ocr_manager else: from ocr_manager import OCRManager self.ocr_manager = OCRManager(log_callback=self._log) self.translator.ocr_manager = self.ocr_manager # Attach preloaded RT-DETR if available try: if hasattr(self, '_preloaded_bd') and self._preloaded_bd: self.translator.bubble_detector = self._preloaded_bd self._log("🤖 RT-DETR preloaded and attached to translator", "debug") except Exception: pass # Distribute stop flags to all components self._distribute_stop_flags() # Provide Replicate API key to translator if present, but DO NOT force-enable cloud mode here. # Actual inpainting mode is chosen by the UI and applied in _apply_rendering_settings. saved_api_key = self.main_gui.config.get('replicate_api_key', '') if saved_api_key: self.translator.replicate_api_key = saved_api_key # Apply text rendering settings (this sets skip/cloud/local based on UI) self._apply_rendering_settings() try: time.sleep(0.05) except Exception: pass self._log("✅ Translator ready", "info") except Exception as e: from PySide6.QtWidgets import QMessageBox QMessageBox.critical(self.dialog, "Error", f"Failed to initialize translator:\n{str(e)}") self._log(f"Initialization error: {str(e)}", "error") import traceback self._log(traceback.format_exc(), "error") self._stop_startup_heartbeat() self._reset_ui_state() return else: # Update the translator with the new client if model changed if needs_new_client and hasattr(self.translator, 'client'): self.translator.client = self.main_gui.client self._log(f"Updated translator with new API client", "info") # Distribute stop flags to all components self._distribute_stop_flags() # Update rendering settings self._apply_rendering_settings() # Ensure inpainting settings are properly synchronized if hasattr(self, 'inpainting_mode_var'): inpainting_mode = self.inpainting_mode_var.get() if inpainting_mode == 'skip': self.translator.skip_inpainting = True self.translator.use_cloud_inpainting = False self._log("Inpainting: SKIP", "debug") elif inpainting_mode == 'local': self.translator.skip_inpainting = False self.translator.use_cloud_inpainting = False # IMPORTANT: Load the local inpainting model if not already loaded if hasattr(self, 'local_model_var'): selected_model = self.local_model_var.get() if selected_model and selected_model != "None": # Get model path from available models model_info = self.available_local_models.get(selected_model) if model_info: model_path = model_info['path'] # Load the model into translator if hasattr(self.translator, 'load_local_inpainting_model'): success = self.translator.load_local_inpainting_model(model_path) if success: self._log(f"Inpainting: LOCAL - Loaded {selected_model}", "info") else: self._log(f"Inpainting: Failed to load local model {selected_model}", "error") else: # Set the model path directly if no load method self.translator.local_inpaint_model_path = model_path self._log(f"Inpainting: LOCAL - Set model path for {selected_model}", "info") else: self._log("Inpainting: LOCAL - No model selected", "warning") else: self._log("Inpainting: LOCAL - No model configured", "warning") else: self._log("Inpainting: LOCAL (default)", "debug") elif inpainting_mode == 'cloud': self.translator.skip_inpainting = False saved_api_key = self.main_gui.config.get('replicate_api_key', '') if saved_api_key: self.translator.use_cloud_inpainting = True self.translator.replicate_api_key = saved_api_key self._log("Inpainting: CLOUD (Replicate)", "debug") else: # Fallback to local if no API key self.translator.use_cloud_inpainting = False self._log("Inpainting: LOCAL (no Replicate key, fallback)", "warning") else: # Default to local inpainting if variable doesn't exist self.translator.skip_inpainting = False self.translator.use_cloud_inpainting = False self._log("Inpainting: LOCAL (default)", "debug") # Double-check the settings are applied correctly self._log(f"Inpainting final status:", "debug") self._log(f" - Skip: {self.translator.skip_inpainting}", "debug") self._log(f" - Cloud: {self.translator.use_cloud_inpainting}", "debug") self._log(f" - Mode: {'SKIP' if self.translator.skip_inpainting else 'CLOUD' if self.translator.use_cloud_inpainting else 'LOCAL'}", "debug") # Preflight RT-DETR to avoid first-page fallback after aggressive cleanup try: ocr_set = self.main_gui.config.get('manga_settings', {}).get('ocr', {}) or {} if ocr_set.get('bubble_detection_enabled', False): # Ensure a default RT-DETR model id exists when required if ocr_set.get('detector_type', 'rtdetr') in ('rtdetr', 'auto'): if not ocr_set.get('rtdetr_model_url') and not ocr_set.get('bubble_model_path'): ocr_set['rtdetr_model_url'] = 'ogkalu/comic-text-and-bubble-detector' if hasattr(self.main_gui, 'save_config'): self.main_gui.save_config(show_message=False) self._preflight_bubble_detector(ocr_set) except Exception: pass # Reset progress self.total_files = len(self.selected_files) self.completed_files = 0 self.failed_files = 0 self.current_file_index = 0 # Reset all global cancellation flags for new translation self._reset_global_cancellation() # Update UI state (PySide6) self.is_running = True self.stop_flag.clear() try: self.start_button.setEnabled(False) self.stop_button.setEnabled(True) self.file_listbox.setEnabled(False) except Exception as e: self._log(f"Failed to update UI state: {e}", "warning") # Log start message self._log(f"Starting translation of {self.total_files} files...", "info") self._log(f"Using OCR provider: {ocr_config['provider'].upper()}", "info") if ocr_config['provider'] == 'google': self._log(f"Using Google Vision credentials: {os.path.basename(ocr_config['google_credentials_path'])}", "info") elif ocr_config['provider'] == 'azure': self._log(f"Using Azure endpoint: {ocr_config['azure_endpoint']}", "info") else: self._log(f"Using local OCR provider: {ocr_config['provider'].upper()}", "info") # Report effective API routing/model with multi-key awareness try: c = getattr(self.main_gui, 'client', None) if c is not None: if getattr(c, 'use_multi_keys', False): total_keys = 0 try: stats = c.get_stats() total_keys = stats.get('total_keys', 0) except Exception: pass self._log( f"API routing: Multi-key pool enabled — starting model '{getattr(c, 'model', 'unknown')}', keys={total_keys}, rotation={getattr(c, '_rotation_frequency', 1)}", "info" ) else: self._log(f"API model: {getattr(c, 'model', 'unknown')}", "info") except Exception: pass self._log(f"Contextual: {'Enabled' if self.main_gui.contextual_var.get() else 'Disabled'}", "info") self._log(f"History limit: {self.main_gui.trans_history.get()} exchanges", "info") self._log(f"Rolling history: {'Enabled' if self.main_gui.translation_history_rolling_var.get() else 'Disabled'}", "info") self._log(f" Full Page Context: {'Enabled' if self.full_page_context_value else 'Disabled'}", "info") # Stop heartbeat before launching worker; now regular progress takes over self._stop_startup_heartbeat() # Start translation via executor try: # Sync with main GUI executor if possible and update EXTRACTION_WORKERS if hasattr(self.main_gui, '_ensure_executor'): self.main_gui._ensure_executor() self.executor = self.main_gui.executor # Ensure env var reflects current worker setting from main GUI try: os.environ["EXTRACTION_WORKERS"] = str(self.main_gui.extraction_workers_var.get()) except Exception: pass if self.executor: self.translation_future = self.executor.submit(self._translation_worker) else: # Fallback to dedicated thread self.translation_thread = threading.Thread( target=self._translation_worker, daemon=True ) self.translation_thread.start() except Exception: # Last resort fallback to thread self.translation_thread = threading.Thread( target=self._translation_worker, daemon=True ) self.translation_thread.start() def _apply_rendering_settings(self): """Apply current rendering settings to translator""" if not self.translator: return # Get text color and shadow color text_color = ( self.text_color_r_value, self.text_color_g_value, self.text_color_b_value ) shadow_color = ( self.shadow_color_r_value, self.shadow_color_g_value, self.shadow_color_b_value ) # Determine font size value based on mode if self.font_size_mode_value == 'multiplier': # Pass negative value to indicate multiplier mode font_size = -self.font_size_multiplier_value else: # Fixed mode - use the font size value directly font_size = self.font_size_value if self.font_size_value > 0 else None # Apply concise logging toggle from Advanced settings try: adv_cfg = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) self.translator.concise_logs = bool(adv_cfg.get('concise_logs', False)) except Exception: pass # Push rendering settings to translator self.translator.update_text_rendering_settings( bg_opacity=self.bg_opacity_value, bg_style=self.bg_style_value, bg_reduction=self.bg_reduction_value, font_style=self.selected_font_path, font_size=font_size, text_color=text_color, shadow_enabled=self.shadow_enabled_value, shadow_color=shadow_color, shadow_offset_x=self.shadow_offset_x_value, shadow_offset_y=self.shadow_offset_y_value, shadow_blur=self.shadow_blur_value, force_caps_lock=self.force_caps_lock_value ) # Free-text-only background opacity toggle -> pass through to translator try: if hasattr(self, 'free_text_only_bg_opacity_value'): self.translator.free_text_only_bg_opacity = bool(self.free_text_only_bg_opacity_value) except Exception: pass # Update font mode and multiplier explicitly self.translator.font_size_mode = self.font_size_mode_value self.translator.font_size_multiplier = self.font_size_multiplier_value self.translator.min_readable_size = self.auto_min_size_value self.translator.max_font_size_limit = self.max_font_size_value self.translator.strict_text_wrapping = self.strict_text_wrapping_value self.translator.force_caps_lock = self.force_caps_lock_value # Update constrain to bubble setting if hasattr(self, 'constrain_to_bubble_value'): self.translator.constrain_to_bubble = self.constrain_to_bubble_value # Handle inpainting mode (radio: skip/local/cloud/hybrid) mode = None if hasattr(self, 'inpainting_mode_var'): mode = self.inpainting_mode_var.get() else: mode = 'local' # Persist selected mode on translator self.translator.inpaint_mode = mode if mode == 'skip': self.translator.skip_inpainting = True self.translator.use_cloud_inpainting = False self._log(" Inpainting: Skipped", "info") elif mode == 'cloud': self.translator.skip_inpainting = False saved_api_key = self.main_gui.config.get('replicate_api_key', '') if saved_api_key: self.translator.use_cloud_inpainting = True self.translator.replicate_api_key = saved_api_key self._log(" Inpainting: Cloud (Replicate)", "info") else: self.translator.use_cloud_inpainting = False self._log(" Inpainting: Local (no Replicate key, fallback)", "warning") elif mode == 'hybrid': self.translator.skip_inpainting = False self.translator.use_cloud_inpainting = False self._log(" Inpainting: Hybrid", "info") else: # Local (default) self.translator.skip_inpainting = False self.translator.use_cloud_inpainting = False self._log(" Inpainting: Local", "info") # Persist free-text-only BG opacity setting to config as well try: if hasattr(self, 'free_text_only_bg_opacity_var'): self.main_gui.config['manga_free_text_only_bg_opacity'] = bool(self.free_text_only_bg_opacity_var.get()) except Exception: pass # Log the applied rendering and inpainting settings self._log(f"Applied rendering settings:", "info") self._log(f" Background: {self.bg_style_value} @ {int(self.bg_opacity_value/255*100)}% opacity", "info") import os self._log(f" Font: {os.path.basename(self.selected_font_path) if self.selected_font_path else 'Default'}", "info") self._log(f" Minimum Font Size: {self.auto_min_size_value}pt", "info") self._log(f" Maximum Font Size: {self.max_font_size_value}pt", "info") self._log(f" Strict Text Wrapping: {'Enabled (force fit)' if self.strict_text_wrapping_value else 'Disabled (allow overflow)'}", "info") if self.font_size_mode_value == 'multiplier': self._log(f" Font Size: Dynamic multiplier ({self.font_size_multiplier_value:.1f}x)", "info") if hasattr(self, 'constrain_to_bubble_value'): constraint_status = "constrained" if self.constrain_to_bubble_value else "unconstrained" self._log(f" Text Constraint: {constraint_status}", "info") else: size_text = f"{self.font_size_value}pt" if self.font_size_value > 0 else "Auto" self._log(f" Font Size: Fixed ({size_text})", "info") self._log(f" Text Color: RGB({text_color[0]}, {text_color[1]}, {text_color[2]})", "info") self._log(f" Shadow: {'Enabled' if self.shadow_enabled_value else 'Disabled'}", "info") try: self._log(f" Free-text-only BG opacity: {'Enabled' if getattr(self, 'free_text_only_bg_opacity_value', False) else 'Disabled'}", "info") except Exception: pass self._log(f" Full Page Context: {'Enabled' if self.full_page_context_value else 'Disabled'}", "info") def _translation_worker(self): """Worker thread for translation""" try: # Defensive: ensure translator exists before using it (legacy callers may start this worker early) if not hasattr(self, 'translator') or self.translator is None: self._log("⚠️ Translator not initialized yet; skipping worker start", "warning") return if hasattr(self.translator, 'set_stop_flag'): self.translator.set_stop_flag(self.stop_flag) # Ensure API parallelism (batch API calls) is controlled independently of local parallel processing. # Propagate the GUI "Batch Translation" toggle into environment so Unified API Client applies it globally # for all providers (including custom endpoints). try: import os as _os _os.environ['BATCH_TRANSLATION'] = '1' if getattr(self.main_gui, 'batch_translation_var', None) and self.main_gui.batch_translation_var.get() else '0' # Use GUI batch size if available; default to 3 to match existing default bs_val = None try: bs_val = str(int(self.main_gui.batch_size_var.get())) if hasattr(self.main_gui, 'batch_size_var') else None except Exception: bs_val = None _os.environ['BATCH_SIZE'] = bs_val or _os.environ.get('BATCH_SIZE', '3') except Exception: # Non-fatal if env cannot be set pass # Panel-level parallelization setting (LOCAL threading for panels) advanced = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) panel_parallel = bool(advanced.get('parallel_panel_translation', False)) requested_panel_workers = int(advanced.get('panel_max_workers', 2)) # Decouple from global parallel processing: panel concurrency is governed ONLY by panel settings effective_workers = requested_panel_workers if (panel_parallel and len(self.selected_files) > 1) else 1 # Hint translator about preferred BD ownership: use singleton only when not using panel parallelism try: if hasattr(self, 'translator') and self.translator: self.translator.use_singleton_bubble_detector = not (panel_parallel and effective_workers > 1) except Exception: pass # Model preloading phase self._log("🔧 Model preloading phase", "info") # Log current counters (diagnostic) try: st = self.translator.get_preload_counters() if hasattr(self.translator, 'get_preload_counters') else None if st: self._log(f" Preload counters before: inpaint_spares={st.get('inpaint_spares',0)}, detector_spares={st.get('detector_spares',0)}", "debug") except Exception: pass # 1) Warm up bubble detector instances first (so detection can start immediately) try: ocr_set = self.main_gui.config.get('manga_settings', {}).get('ocr', {}) or {} if ( effective_workers > 1 and ocr_set.get('bubble_detection_enabled', True) and hasattr(self, 'translator') and self.translator ): # For parallel panel translation, prefer thread-local detectors (avoid singleton for concurrency) try: self.translator.use_singleton_bubble_detector = False except Exception: pass desired_bd = min(int(effective_workers), max(1, int(len(self.selected_files) or 1))) self._log(f"🧰 Preloading bubble detector instances for {desired_bd} panel worker(s)...", "info") try: import time as _time t0 = _time.time() self.translator.preload_bubble_detectors(ocr_set, desired_bd) dt = _time.time() - t0 self._log(f"⏱️ Bubble detector preload finished in {dt:.2f}s", "info") except Exception as _e: self._log(f"⚠️ Bubble detector preload skipped: {_e}", "warning") except Exception: pass # 2) Skip up-front LOCAL inpainting preload — it will run in the OCR phase in the background inpaint_preload_event = None # Log updated counters (diagnostic) try: st2 = self.translator.get_preload_counters() if hasattr(self.translator, 'get_preload_counters') else None if st2: self._log(f" Preload counters after: inpaint_spares={st2.get('inpaint_spares',0)}, detector_spares={st2.get('detector_spares',0)}", "debug") except Exception: pass if panel_parallel and len(self.selected_files) > 1 and effective_workers > 1: self._log(f"🚀 Parallel PANEL translation ENABLED ({effective_workers} workers)", "info") import concurrent.futures import threading as _threading progress_lock = _threading.Lock() counters = { 'started': 0, 'done': 0, 'failed': 0 } total = self.total_files def process_single(idx, filepath): # Check stop flag at the very beginning if self.stop_flag.is_set(): return False # Create an isolated translator instance per panel try: # Check again before starting expensive work if self.stop_flag.is_set(): return False from manga_translator import MangaTranslator import os # Build full OCR config for this thread (mirror _start_translation) ocr_config = {'provider': self.ocr_provider_value} if ocr_config['provider'] == 'google': google_creds = self.main_gui.config.get('google_vision_credentials', '') or \ self.main_gui.config.get('google_cloud_credentials', '') if google_creds and os.path.exists(google_creds): ocr_config['google_credentials_path'] = google_creds else: self._log("⚠️ Google Cloud Vision credentials not found for parallel task", "warning") elif ocr_config['provider'] == 'azure': azure_key = self.main_gui.config.get('azure_vision_key', '') azure_endpoint = self.main_gui.config.get('azure_vision_endpoint', '') if azure_key and azure_endpoint: ocr_config['azure_key'] = azure_key ocr_config['azure_endpoint'] = azure_endpoint else: self._log("⚠️ Azure credentials not found for parallel task", "warning") translator = MangaTranslator(ocr_config, self.main_gui.client, self.main_gui, log_callback=self._log) translator.set_stop_flag(self.stop_flag) # Also propagate global cancellation to isolated translator from manga_translator import MangaTranslator as MTClass if MTClass.is_globally_cancelled(): return False # Check stop flag before configuration if self.stop_flag.is_set(): return False # Apply inpainting and rendering options roughly matching current translator try: translator.constrain_to_bubble = getattr(self, 'constrain_to_bubble_var').get() if hasattr(self, 'constrain_to_bubble_var') else True except Exception: pass # Set full page context based on UI try: translator.set_full_page_context( enabled=self.full_page_context_var.get(), custom_prompt=self.full_page_context_prompt ) except Exception: pass # Another check before path setup if self.stop_flag.is_set(): return False # Determine output path (route CBZ images to job out_dir) filename = os.path.basename(filepath) output_path = None try: if hasattr(self, 'cbz_image_to_job') and filepath in self.cbz_image_to_job: cbz_file = self.cbz_image_to_job[filepath] job = getattr(self, 'cbz_jobs', {}).get(cbz_file) if job: output_dir = job.get('out_dir') os.makedirs(output_dir, exist_ok=True) output_path = os.path.join(output_dir, filename) except Exception: output_path = None if not output_path: if self.create_subfolder_value: output_dir = os.path.join(os.path.dirname(filepath), 'translated') os.makedirs(output_dir, exist_ok=True) output_path = os.path.join(output_dir, filename) else: base, ext = os.path.splitext(filepath) output_path = f"{base}_translated{ext}" # Announce start self._update_current_file(filename) with progress_lock: counters['started'] += 1 self._update_progress(counters['done'], total, f"Processing {counters['started']}/{total}: {filename}") # Final check before expensive processing if self.stop_flag.is_set(): return False # Process image result = translator.process_image(filepath, output_path, batch_index=idx+1, batch_total=total) # Update counters only if not stopped with progress_lock: if self.stop_flag.is_set(): # Don't update counters if translation was stopped return False # Check if translation actually produced valid output translation_successful = False if result.get('success', False) and not result.get('interrupted', False): # Verify there's an actual output file and translated regions output_exists = result.get('output_path') and os.path.exists(result.get('output_path', '')) has_translations = any(r.get('translated_text', '') for r in result.get('regions', [])) translation_successful = output_exists and has_translations if translation_successful: self.completed_files += 1 self._log(f"✅ Translation completed: {filename}", "success") time.sleep(0.1) # Brief pause for stability self._log("💤 Panel completion pausing briefly for stability", "debug") else: self.failed_files += 1 # Log the specific reason for failure if result.get('interrupted', False): self._log(f"⚠️ Translation interrupted: {filename}", "warning") elif not result.get('success', False): self._log(f"❌ Translation failed: {filename}", "error") elif not result.get('output_path') or not os.path.exists(result.get('output_path', '')): self._log(f"❌ Output file not created: {filename}", "error") else: self._log(f"❌ No text was translated: {filename}", "error") counters['failed'] += 1 counters['done'] += 1 self._update_progress(counters['done'], total, f"Completed {counters['done']}/{total}") return result.get('success', False) except Exception as e: with progress_lock: # Don't update error counters if stopped if not self.stop_flag.is_set(): self.failed_files += 1 counters['failed'] += 1 counters['done'] += 1 if not self.stop_flag.is_set(): self._log(f"❌ Error in panel task: {str(e)}", "error") self._log(traceback.format_exc(), "error") return False with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, effective_workers)) as executor: futures = [] stagger_ms = int(advanced.get('panel_start_stagger_ms', 30)) for idx, filepath in enumerate(self.selected_files): if self.stop_flag.is_set(): break futures.append(executor.submit(process_single, idx, filepath)) if stagger_ms > 0: time.sleep(stagger_ms / 1000.0) time.sleep(0.1) # Brief pause for stability self._log("💤 Staggered submission pausing briefly for stability", "debug") # Handle completion and stop behavior try: for f in concurrent.futures.as_completed(futures): if self.stop_flag.is_set(): # More aggressive cancellation for rem in futures: rem.cancel() # Try to shutdown executor immediately try: executor.shutdown(wait=False) except Exception: pass break try: # Consume future result to let it raise exceptions or return f.result(timeout=0.1) # Very short timeout except Exception: # Ignore; counters are updated inside process_single pass except Exception: # If as_completed fails due to shutdown, that's ok pass # If stopped during parallel processing, do not log panel completion if self.stop_flag.is_set(): pass else: # After parallel processing, skip sequential loop pass # After parallel processing, skip sequential loop # Finalize CBZ packaging after parallel mode finishes try: self._finalize_cbz_jobs() except Exception: pass else: # Sequential processing (or panel parallel requested but capped to 1 by global setting) for index, filepath in enumerate(self.selected_files): if self.stop_flag.is_set(): self._log("\n⏹️ Translation stopped by user", "warning") break # IMPORTANT: Reset translator state for each new image if hasattr(self.translator, 'reset_for_new_image'): self.translator.reset_for_new_image() self.current_file_index = index filename = os.path.basename(filepath) self._update_current_file(filename) self._update_progress( index, self.total_files, f"Processing {index + 1}/{self.total_files}: {filename}" ) try: # Determine output path (route CBZ images to job out_dir) job_output_path = None try: if hasattr(self, 'cbz_image_to_job') and filepath in self.cbz_image_to_job: cbz_file = self.cbz_image_to_job[filepath] job = getattr(self, 'cbz_jobs', {}).get(cbz_file) if job: output_dir = job.get('out_dir') os.makedirs(output_dir, exist_ok=True) job_output_path = os.path.join(output_dir, filename) except Exception: job_output_path = None if job_output_path: output_path = job_output_path else: if self.create_subfolder_value: output_dir = os.path.join(os.path.dirname(filepath), 'translated') os.makedirs(output_dir, exist_ok=True) output_path = os.path.join(output_dir, filename) else: base, ext = os.path.splitext(filepath) output_path = f"{base}_translated{ext}" # Process the image result = self.translator.process_image(filepath, output_path) # Check if translation was interrupted if result.get('interrupted', False): self._log(f"⏸️ Translation of {filename} was interrupted", "warning") self.failed_files += 1 if self.stop_flag.is_set(): break elif result.get('success', False): # Verify translation actually produced valid output output_exists = result.get('output_path') and os.path.exists(result.get('output_path', '')) has_translations = any(r.get('translated_text', '') for r in result.get('regions', [])) if output_exists and has_translations: self.completed_files += 1 self._log(f"✅ Translation completed: {filename}", "success") time.sleep(0.1) # Brief pause for stability self._log("💤 Sequential completion pausing briefly for stability", "debug") else: self.failed_files += 1 if not output_exists: self._log(f"❌ Output file not created: {filename}", "error") else: self._log(f"❌ No text was translated: {filename}", "error") else: self.failed_files += 1 errors = '\n'.join(result.get('errors', ['Unknown error'])) self._log(f"❌ Translation failed: {filename}\n{errors}", "error") # Check for specific error types in the error messages errors_lower = errors.lower() if '429' in errors or 'rate limit' in errors_lower: self._log(f"⚠️ RATE LIMIT DETECTED - Please wait before continuing", "error") self._log(f" The API provider is limiting your requests", "error") self._log(f" Consider increasing delay between requests in settings", "error") # Optionally pause for a bit self._log(f" Pausing for 60 seconds...", "warning") for sec in range(60): if self.stop_flag.is_set(): break time.sleep(1) if sec % 10 == 0: self._log(f" Waiting... {60-sec} seconds remaining", "warning") except Exception as e: self.failed_files += 1 error_str = str(e) error_type = type(e).__name__ self._log(f"❌ Error processing {filename}:", "error") self._log(f" Error type: {error_type}", "error") self._log(f" Details: {error_str}", "error") # Check for specific API errors if "429" in error_str or "rate limit" in error_str.lower(): self._log(f"⚠️ RATE LIMIT ERROR (429) - API is throttling requests", "error") self._log(f" Please wait before continuing or reduce request frequency", "error") self._log(f" Consider increasing the API delay in settings", "error") # Pause for rate limit self._log(f" Pausing for 60 seconds...", "warning") for sec in range(60): if self.stop_flag.is_set(): break time.sleep(1) if sec % 10 == 0: self._log(f" Waiting... {60-sec} seconds remaining", "warning") elif "401" in error_str or "unauthorized" in error_str.lower(): self._log(f"❌ AUTHENTICATION ERROR (401) - Check your API key", "error") self._log(f" The API key appears to be invalid or expired", "error") elif "403" in error_str or "forbidden" in error_str.lower(): self._log(f"❌ FORBIDDEN ERROR (403) - Access denied", "error") self._log(f" Check your API subscription and permissions", "error") elif "timeout" in error_str.lower(): self._log(f"⏱️ TIMEOUT ERROR - Request took too long", "error") self._log(f" Consider increasing timeout settings", "error") else: # Generic error with full traceback self._log(f" Full traceback:", "error") self._log(traceback.format_exc(), "error") # Finalize CBZ packaging (both modes) try: self._finalize_cbz_jobs() except Exception: pass # Final summary - only if not stopped if not self.stop_flag.is_set(): self._log(f"\n{'='*60}", "info") self._log(f"📊 Translation Summary:", "info") self._log(f" Total files: {self.total_files}", "info") self._log(f" ✅ Successful: {self.completed_files}", "success") self._log(f" ❌ Failed: {self.failed_files}", "error" if self.failed_files > 0 else "info") self._log(f"{'='*60}\n", "info") self._update_progress( self.total_files, self.total_files, f"Complete! {self.completed_files} successful, {self.failed_files} failed" ) except Exception as e: self._log(f"\n❌ Translation error: {str(e)}", "error") self._log(traceback.format_exc(), "error") finally: # Check if auto cleanup is enabled in settings auto_cleanup_enabled = False # Default disabled by default try: advanced_settings = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) auto_cleanup_enabled = advanced_settings.get('auto_cleanup_models', False) except Exception: pass if auto_cleanup_enabled: # Clean up all models to free RAM try: # For parallel panel translation, cleanup happens here after ALL panels complete is_parallel_panel = False try: advanced_settings = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) is_parallel_panel = advanced_settings.get('parallel_panel_translation', True) except Exception: pass # Skip the "all parallel panels complete" message if stopped if is_parallel_panel and not self.stop_flag.is_set(): self._log("\n🧹 All parallel panels complete - cleaning up models to free RAM...", "info") elif not is_parallel_panel: self._log("\n🧹 Cleaning up models to free RAM...", "info") # Clean up the shared translator if parallel processing was used if 'translator' in locals(): translator.cleanup_all_models() self._log("✅ Shared translator models cleaned up!", "info") # Also clean up the instance translator if it exists if hasattr(self, 'translator') and self.translator: self.translator.cleanup_all_models() # Set to None to ensure it's released self.translator = None self._log("✅ Instance translator models cleaned up!", "info") self._log("✅ All models cleaned up - RAM freed!", "info") except Exception as e: self._log(f"⚠️ Warning: Model cleanup failed: {e}", "warning") # Force garbage collection to ensure memory is freed try: import gc gc.collect() except Exception: pass else: # Only log if not stopped if not self.stop_flag.is_set(): self._log("🔑 Auto cleanup disabled - models will remain in RAM for faster subsequent translations", "info") # IMPORTANT: Reset the entire translator instance to free ALL memory # Controlled by a separate "Unload models after translation" toggle try: # Check if we should reset the translator instance reset_translator = False # default disabled try: advanced_settings = self.main_gui.config.get('manga_settings', {}).get('advanced', {}) reset_translator = bool(advanced_settings.get('unload_models_after_translation', False)) except Exception: reset_translator = False if reset_translator: self._log("\n🗑️ Resetting translator instance to free all memory...", "info") # Clear the instance translator completely if hasattr(self, 'translator'): # First ensure models are cleaned if not already done try: if self.translator and hasattr(self.translator, 'cleanup_all_models'): self.translator.cleanup_all_models() except Exception: pass # Clear all internal state using the dedicated method try: if self.translator and hasattr(self.translator, 'clear_internal_state'): self.translator.clear_internal_state() except Exception: pass # Clear remaining references with proper cleanup try: if self.translator: # Properly unload OCR manager and all its providers if hasattr(self.translator, 'ocr_manager') and self.translator.ocr_manager: try: ocr_manager = self.translator.ocr_manager # Clear all loaded OCR providers if hasattr(ocr_manager, 'providers'): for provider_name, provider in ocr_manager.providers.items(): # Unload each provider's models if hasattr(provider, 'model'): provider.model = None if hasattr(provider, 'processor'): provider.processor = None if hasattr(provider, 'tokenizer'): provider.tokenizer = None if hasattr(provider, 'reader'): provider.reader = None if hasattr(provider, 'is_loaded'): provider.is_loaded = False self._log(f" ✓ Unloaded OCR provider: {provider_name}", "debug") ocr_manager.providers.clear() self._log(" ✓ OCR manager fully unloaded", "debug") except Exception as e: self._log(f" Warning: OCR manager cleanup failed: {e}", "debug") finally: self.translator.ocr_manager = None # Properly unload local inpainter if hasattr(self.translator, 'local_inpainter') and self.translator.local_inpainter: try: if hasattr(self.translator.local_inpainter, 'unload'): self.translator.local_inpainter.unload() self._log(" ✓ Local inpainter unloaded", "debug") except Exception as e: self._log(f" Warning: Local inpainter cleanup failed: {e}", "debug") finally: self.translator.local_inpainter = None # Properly unload bubble detector if hasattr(self.translator, 'bubble_detector') and self.translator.bubble_detector: try: if hasattr(self.translator.bubble_detector, 'unload'): self.translator.bubble_detector.unload(release_shared=True) self._log(" ✓ Bubble detector unloaded", "debug") except Exception as e: self._log(f" Warning: Bubble detector cleanup failed: {e}", "debug") finally: self.translator.bubble_detector = None # Clear API clients if hasattr(self.translator, 'client'): self.translator.client = None if hasattr(self.translator, 'vision_client'): self.translator.vision_client = None except Exception: pass # Call translator shutdown to free all resources try: if translator and hasattr(translator, 'shutdown'): translator.shutdown() except Exception: pass # Finally, delete the translator instance entirely self.translator = None self._log("✅ Translator instance reset - all memory freed!", "info") # Also clear the shared translator from parallel processing if it exists if 'translator' in locals(): try: # Clear internal references if hasattr(translator, 'cache'): translator.cache = None if hasattr(translator, 'text_regions'): translator.text_regions = None if hasattr(translator, 'translated_regions'): translator.translated_regions = None # Delete the local reference del translator except Exception: pass # Clear standalone OCR manager if it exists in manga_integration if hasattr(self, 'ocr_manager') and self.ocr_manager: try: ocr_manager = self.ocr_manager # Clear all loaded OCR providers if hasattr(ocr_manager, 'providers'): for provider_name, provider in ocr_manager.providers.items(): # Unload each provider's models if hasattr(provider, 'model'): provider.model = None if hasattr(provider, 'processor'): provider.processor = None if hasattr(provider, 'tokenizer'): provider.tokenizer = None if hasattr(provider, 'reader'): provider.reader = None if hasattr(provider, 'is_loaded'): provider.is_loaded = False ocr_manager.providers.clear() self.ocr_manager = None self._log(" ✓ Standalone OCR manager cleared", "debug") except Exception as e: self._log(f" Warning: Standalone OCR manager cleanup failed: {e}", "debug") # Force multiple garbage collection passes to ensure everything is freed try: import gc gc.collect() gc.collect() # Multiple passes for stubborn references gc.collect() self._log("✅ Memory fully reclaimed", "debug") except Exception: pass else: # Only log if not stopped if not self.stop_flag.is_set(): self._log("🔑 Translator instance preserved for faster subsequent translations", "debug") except Exception as e: self._log(f"⚠️ Warning: Failed to reset translator instance: {e}", "warning") # Reset UI state (PySide6 - call directly) try: self._reset_ui_state() except Exception as e: self._log(f"Error resetting UI: {e}", "warning") def _stop_translation(self): """Stop the translation process""" if self.is_running: # Set local stop flag self.stop_flag.set() # Set global cancellation flags for coordinated stopping self.set_global_cancellation(True) # Also propagate to MangaTranslator class try: from manga_translator import MangaTranslator MangaTranslator.set_global_cancellation(True) except ImportError: pass # Also propagate to UnifiedClient if available try: from unified_api_client import UnifiedClient UnifiedClient.set_global_cancellation(True) except ImportError: pass # Update progress to show stopped status self._update_progress( self.completed_files, self.total_files, f"Stopped - {self.completed_files}/{self.total_files} completed" ) # Try to style the progress bar to indicate stopped status try: # Set progress bar to a distinctive value and try to change appearance if hasattr(self, 'progress_bar'): # You could also set a custom style here if needed # For now, we'll rely on the text indicators pass except Exception: pass # Update current file display to show stopped self._update_current_file("Translation stopped") # Kick off immediate resource shutdown to free RAM try: tr = getattr(self, 'translator', None) if tr and hasattr(tr, 'shutdown'): import threading threading.Thread(target=tr.shutdown, name="MangaTranslatorShutdown", daemon=True).start() self._log("🧹 Initiated translator resource shutdown", "info") # Important: clear the stale translator reference so the next Start creates a fresh instance self.translator = None except Exception as e: self._log(f"⚠️ Failed to start shutdown: {e}", "warning") # Immediately reset UI state to re-enable start button self._reset_ui_state() self._log("\n⏹️ Translation stopped by user", "warning") def _reset_ui_state(self): """Reset UI to ready state - with widget existence checks (PySide6)""" # Restore stdio redirection if active self._redirect_stderr(False) self._redirect_stdout(False) # Stop any startup heartbeat if still running try: self._stop_startup_heartbeat() except Exception: pass try: # Check if the dialog still exists (PySide6) if not hasattr(self, 'dialog') or not self.dialog: return # Reset running flag self.is_running = False # Check and update start_button if it exists (PySide6) if hasattr(self, 'start_button') and self.start_button: if not self.start_button.isEnabled(): self.start_button.setEnabled(True) # Check and update stop_button if it exists (PySide6) if hasattr(self, 'stop_button') and self.stop_button: if self.stop_button.isEnabled(): self.stop_button.setEnabled(False) # Re-enable file modification - check if listbox exists (PySide6) if hasattr(self, 'file_listbox') and self.file_listbox: if not self.file_listbox.isEnabled(): self.file_listbox.setEnabled(True) except Exception as e: # Log the error but don't crash if hasattr(self, '_log'): self._log(f"Error resetting UI state: {str(e)}", "warning")