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