Video Tutorial

Documentación

Cómo instalar el convertidor de imágenes gratis en tu PC

Esta herramienta es completamente gratuita y funciona en Windows, macOS y Linux. No necesitas saber programar para instalarla. A continuación te explicamos cómo hacerlo paso a paso según tu sistema operativo.

¿Qué necesito antes de instalar?

  • Tener Python 3.10 o superior instalado en tu computadora
  • Conexión a internet durante la instalación (solo la primera vez)
  • El archivo del programa descargado desde este sitio

¿No tienes Python? No te preocupes — es gratuito y fácil de instalar. Descárgalo desde python.org. Durante la instalación, asegúrate de marcar la opción "Add Python to PATH".


🪟 Instalación en Windows — La forma más fácil

En Windows solo necesitas hacer doble clic en un archivo. El instalador automático instalador.bat hace todo el trabajo por ti: verifica Python, actualiza las herramientas internas e instala todo lo necesario.

Tambien puedes instalarlo mediante requeriments.txt como se explica en el video usando:py -m pip install -r requirements.txt

Pasos para instalar en Windows

  1. Descarga y descomprime el archivo del programa en una carpeta de tu elección.
  2. Abre la carpeta del programa en el Explorador de archivos.
  3. Haz doble clic sobre el archivo instalador.bat.
  4. Se abrirá una ventana negra (terminal). Espera a que termine — verás mensajes de progreso.
  5. Cuando aparezca el mensaje de instalación completada, presiona cualquier tecla para cerrar.
  6. Ahora puedes ejecutar el programa haciendo doble clic en ImageConverter.py, o abriendo una terminal en esa carpeta y escribiendo:
py ImageConverter.py

¿Qué instala el instalador.bat? Instala automáticamente todo lo necesario: Pillow, customtkinter, piexif, tkinterdnd2 y soporte opcional para fotos de iPhone (HEIC/HEIF).

¿La ventana negra se cierra sola sin terminar?

  • Haz clic derecho sobre instalador.bat y selecciona "Ejecutar como administrador".
  • Asegúrate de haber marcado "Add Python to PATH" al instalar Python.

🍎 Instalación en macOS

En Mac usamos el archivo requirements.txt incluido en el programa. Este archivo le dice a Python exactamente qué herramientas necesita instalar.

Pasos para instalar en macOS

  1. Abre la aplicación Terminal (atajo: Cmd + Espacio → escribe "Terminal" → Enter).
  2. Comprueba que tienes Python 3.10 o superior instalado:
python3 --version

Si no tienes Python o la versión es muy antigua, instálalo desde python.org. También puedes usar Homebrew si ya lo tienes instalado:

brew install python
  1. En la terminal, navega hasta la carpeta donde descargaste el programa:
cd ~/Downloads/ImageConverterPro
  1. Instala todas las dependencias con este comando:
pip3 install -r requirements.txt
  1. Cuando termine, ejecuta el programa:
python3 ImageConverter.py

Nota: El soporte para fotos HEIC de iPhone puede requerir pasos adicionales en Mac. Si falla esa parte, el programa seguirá funcionando perfectamente con todos los demás formatos.


🐧 Instalación en Linux

En Linux el proceso es muy similar al de macOS. Solo necesitas abrir una terminal e instalar las dependencias con el archivo requirements.txt.

Pasos para instalar en Linux

  1. Abre una terminal.
  2. Primero, asegúrate de tener Python y Tkinter instalados. En Ubuntu/Debian:
sudo apt update && sudo apt install python3 python3-pip python3-tk

En Fedora/RHEL/CentOS:

sudo dnf install python3 python3-pip python3-tkinter
  1. Navega a la carpeta del programa:
cd ~/Descargas/ImageConverterPro
  1. Instala las dependencias:
pip3 install -r requirements.txt
  1. Ejecuta el programa:
python3 ImageConverter.py

Importante para Linux: Si la interfaz gráfica no se abre, es casi siempre por falta de python3-tk. Instálalo con el comando de tu distribución y vuelve a intentarlo.


✅ ¿Cómo sé que funcionó bien?

Si la instalación fue exitosa, al ejecutar el programa se abrirá una ventana con interfaz gráfica. Desde ahí podrás arrastrar imágenes, elegir el formato de salida y convertirlas con un clic.

Formatos que puedes convertir

  • Fotos de iPhone: HEIC → JPG o HEIC → PNG
  • Imágenes web: WEBP → JPG o WEBP → PNG
  • Alta calidad: TIFF → JPG o PNG → WEBP
  • Íconos: PNG → ICO
  • Y muchos más: BMP, GIF, PCX, TGA, PPM

¿Algo no funciona? Revisa esto primero

  • ✅ Python está instalado y en el PATH del sistema
  • ✅ Usaste el instalador o ejecutaste pip install -r requirements.txt
  • ✅ En Linux, tienes instalado python3-tk
  • ✅ En Windows, ejecutaste el .bat como Administrador si hubo errores

Librerías Necesarias

py -m pip install Pillow>=10.0.0
py -m pip install customtkinter>=5.2.0
py -m piexif>=1.1.3
py -m pip install tkinterdnd2>=0.5.0
py -m pip install pillow-heif>=0.13.0

Código Fuente Principal

#!/usr/bin/env python3
"""
ImageConverter Pro - Convertidor Universal de Imágenes
========================================================

Una aplicación moderna de escritorio para convertir imágenes entre múltiples formatos.
Interfaz elegante tipo Adobe Lightroom, procesamiento paralelo y máxima calidad.

Características:
- Soporte para 15+ formatos de imagen (PNG, JPG, WEBP, GIF, TIFF, etc.)
- Conversión de archivos individuales y batch
- Procesamiento paralelo de múltiples imágenes
- Interfaz moderna con tema oscuro
- Drag & Drop de archivos
- Vista previa de imagen
- Información de resolución y metadatos EXIF
- Barra de progreso y logging detallado
- Opciones avanzadas (calidad, redimensionamiento, etc.)
- Soporte para animaciones GIF

Author: Senior Python Developer
Version: 1.0.0
Python: 3.10+
"""

import sys
import os
from pathlib import Path
from typing import Optional, Dict, List, Tuple, Any
import threading
import logging
from logging.handlers import RotatingFileHandler
from dataclasses import dataclass
from enum import Enum
from datetime import datetime
import json
from PIL import Image, ImageOps, ImageEnhance
import customtkinter as ctk
from tkinter import filedialog, messagebox, scrolledtext
import tkinter as tk
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
import traceback


import pillow_heif
pillow_heif.register_heif_opener()
HEIF_SUPPORT = True

AVIF_SUPPORT = False

import piexif
EXIF_SUPPORT = True


# ============================================================================
# CONFIGURACIÓN CENTRALIZADA
# ============================================================================

class Config:
    """Configuración centralizada de la aplicación."""
    
    # Información de la aplicación
    APP_NAME = "ImageConverter Pro"
    APP_VERSION = "1.0.0"
    APP_DESCRIPTION = "Convertidor universal de imágenes con interfaz moderna"
    
    # Directorios
    BASE_DIR = Path(__file__).parent.resolve()
    LOGS_DIR = BASE_DIR / "logs"
    TEMP_DIR = Path(os.getenv("TEMP", ".temp"))
    
    # Crear directorios si no existen
    LOGS_DIR.mkdir(parents=True, exist_ok=True)
    TEMP_DIR.mkdir(parents=True, exist_ok=True)
    
    # Logging
    LOG_FILE = str(LOGS_DIR / "imageconverter.log")
    LOG_LEVEL = logging.INFO
    
    # Configuración de calidad (máxima por defecto)
    JPEG_QUALITY = 95
    JPEG_OPTIMIZE = True
    JPEG_PROGRESSIVE = True
    
    PNG_COMPRESS_LEVEL = 9
    PNG_OPTIMIZE = True
    
    WEBP_QUALITY = 95
    WEBP_METHOD = 6
    WEBP_EXACT = True
    
    TIFF_COMPRESSION = 'lzw'
    
    RESAMPLE_FILTER = Image.Resampling.LANCZOS
    
    # Procesamiento
    MAX_WORKERS = None  # None = número de CPUs
    CONVERSION_TIMEOUT = 60
    PREVIEW_MAX_SIZE = (400, 400)
    
    # Paleta de colores - Tema oscuro profesional
    class Colors:
        BG_PRIMARY = "#1a1a1a"
        BG_SECONDARY = "#2d2d2d"
        BG_TERTIARY = "#3a3a3a"
        BG_HOVER = "#404040"
        BG_SELECTED = "#4a4a4a"
        
        ACCENT_PRIMARY = "#0d8fd9"
        ACCENT_SECONDARY = "#17a2b8"
        
        TEXT_PRIMARY = "#e8e8e8"
        TEXT_SECONDARY = "#a0a0a0"
        TEXT_MUTED = "#7a7a7a"
        
        SUCCESS = "#28a745"
        WARNING = "#ffc107"
        ERROR = "#dc3545"
        INFO = "#0d8fd9"
        
        BORDER = "#404040"
    
    # Interfaz
    WINDOW_WIDTH = 1400
    WINDOW_HEIGHT = 900
    WINDOW_MIN_WIDTH = 1000
    WINDOW_MIN_HEIGHT = 700
    
    PADDING_LARGE = 20
    PADDING_MEDIUM = 12
    PADDING_SMALL = 8
    PADDING_TINY = 4
    
    CORNER_RADIUS = 8
    FONT_FAMILY = "Segoe UI" if os.name == "nt" else "Ubuntu"
    FONT_SIZE_TITLE = 20
    FONT_SIZE_HEADING = 14
    FONT_SIZE_NORMAL = 11
    FONT_SIZE_SMALL = 10
    FONT_SIZE_TINY = 9
    
    # Mejora de imagen
    ENHANCE_CONTRAST = True
    CONTRAST_FACTOR = 1.1
    ENHANCE_SHARPNESS = True
    SHARPNESS_FACTOR = 1.2
    
    # Formatos soportados
    SUPPORTED_FORMATS = {
        'PNG': 'image/png',
        'JPG': 'image/jpeg',
        'JPEG': 'image/jpeg',
        'BMP': 'image/bmp',
        'GIF': 'image/gif',
        'TIFF': 'image/tiff',
        'TIF': 'image/tiff',
        'WEBP': 'image/webp',
        'ICO': 'image/x-icon',
        'PPM': 'image/x-portable-pixmap',
        'TGA': 'image/x-tga',
        'PCX': 'image/x-pcx',
    }
    
    if HEIF_SUPPORT:
        SUPPORTED_FORMATS.update({'HEIF': 'image/heif', 'HEIC': 'image/heic'})
    
    if AVIF_SUPPORT:
        SUPPORTED_FORMATS.update({'AVIF': 'image/avif'})
    
    # Formatos con características especiales
    FORMATS_WITH_TRANSPARENCY = ['PNG', 'GIF', 'WEBP', 'ICO', 'TIFF']
    FORMATS_WITH_ANIMATION = ['GIF', 'WEBP']


# ============================================================================
# SISTEMA DE LOGGING
# ============================================================================

def setup_logger(name: str) -> logging.Logger:
    """
    Configura el logger de la aplicación.
    
    Args:
        name: Nombre del logger
        
    Returns:
        Logger configurado
    """
    logger = logging.getLogger(name)
    logger.setLevel(Config.LOG_LEVEL)
    
    # Handler de archivo con rotación
    file_handler = RotatingFileHandler(
        Config.LOG_FILE,
        maxBytes=10 * 1024 * 1024,  # 10 MB
        backupCount=5
    )
    file_handler.setLevel(Config.LOG_LEVEL)
    
    # Formato
    formatter = logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )
    file_handler.setFormatter(formatter)
    
    # Evitar handlers duplicados
    if not logger.handlers:
        logger.addHandler(file_handler)
    
    return logger


logger = setup_logger(__name__)

# Logger separado solo para conversiones
conversion_logger = logging.getLogger('conversions')
conversion_logger.setLevel(logging.INFO)

# Handler para conversiones
conversion_handler = RotatingFileHandler(
    Config.LOG_FILE,
    maxBytes=10 * 1024 * 1024,
    backupCount=5
)
conversion_handler.setLevel(logging.INFO)
conversion_formatter = logging.Formatter(
    '%(asctime)s - %(levelname)s - %(message)s',
    datefmt='%Y-%m-%d %H:%M:%S'
)
conversion_handler.setFormatter(conversion_formatter)

if not conversion_logger.handlers:
    conversion_logger.addHandler(conversion_handler)


def reset_conversion_log() -> None:
    """Borra el contenido del archivo de registros de conversiones."""
    try:
        # Cerrar el handler para liberar el archivo
        for handler in list(conversion_logger.handlers):
            conversion_logger.removeHandler(handler)
            handler.close()
        
        # Vaciar el archivo
        with open(Config.LOG_FILE, 'w', encoding='utf-8') as log_file:
            log_file.write('')
        
        # Recrear el handler
        new_handler = RotatingFileHandler(
            Config.LOG_FILE,
            maxBytes=10 * 1024 * 1024,
            backupCount=5
        )
        new_handler.setLevel(logging.INFO)
        new_handler.setFormatter(conversion_formatter)
        conversion_logger.addHandler(new_handler)
    except Exception as exc:
        logger.warning(f"No se pudo limpiar el registro: {exc}")


def parse_conversion_log_entry(log_line: str) -> Optional[Tuple[Path, str, str]]:
    """Parses a conversion log line and returns (image_path, input_format, output_format)."""
    if not log_line or not isinstance(log_line, str):
        return None

    line = log_line.strip()

    if ' - INFO - ' in line:
        line = line.split(' - INFO - ', 1)[1].strip()
    elif ' - ERROR - ' in line:
        line = line.split(' - ERROR - ', 1)[1].strip()

    if not line or '|' not in line:
        return None

    if '->' in line:
        path_text, formats_text = line.split('|', 1)
        if '->' not in formats_text:
            return None
        input_fmt, output_fmt = [part.strip() for part in formats_text.split('->', 1)]
        if not input_fmt or not output_fmt:
            return None
        return Path(path_text.strip()), input_fmt.upper(), output_fmt.upper()

    parts = [part.strip() for part in line.split('|')]
    if len(parts) != 3:
        return None

    path_text, input_fmt, output_fmt = parts
    if not path_text or not input_fmt or not output_fmt:
        return None

    return Path(path_text), input_fmt.upper(), output_fmt.upper()


def reset_conversion_log() -> None:
    """Borra el contenido del archivo de registros de conversiones."""
    try:
        with open(Config.LOG_FILE, 'w', encoding='utf-8') as log_file:
            log_file.write('')
    except Exception as exc:
        logger.warning(f"No se pudo limpiar el registro: {exc}")


# ============================================================================
# ENUMERACIONES Y CLASES DE DATOS
# ============================================================================

class ConversionMode(Enum):
    """Modos de conversión disponibles."""
    LOSSLESS = "lossless"
    LOSSY = "lossy"


class TransparencyMode(Enum):
    """Modos de manejo de transparencia."""
    PRESERVE = "preserve"
    REMOVE = "remove"
    WHITE_BG = "white_bg"
    BLACK_BG = "black_bg"


@dataclass
class ConversionResult:
    """Resultado de una conversión."""
    success: bool
    input_path: str
    output_path: str
    input_format: str
    output_format: str
    input_size: int
    output_size: int
    duration: float
    error_message: Optional[str] = None
    compression_ratio: float = 0.0


# ============================================================================
# UTILIDADES Y FUNCIONES AUXILIARES
# ============================================================================

class ImageUtils:
    """Utilidades para manejo de imágenes."""
    
    @staticmethod
    def detect_format(file_path: Path) -> Optional[str]:
        """
        Detecta el formato de una imagen por su extensión.
        
        Args:
            file_path: Ruta del archivo
            
        Returns:
            Formato detectado o None
        """
        ext = file_path.suffix.upper().lstrip('.')
        
        # Manejo de alias
        if ext == 'JPG':
            return 'JPEG'
        elif ext == 'TIF':
            return 'TIFF'
        
        if ext in Config.SUPPORTED_FORMATS:
            return ext
        
        return None
    
    @staticmethod
    def get_image_info(file_path: Path) -> Dict[str, Any]:
        """
        Obtiene información detallada de una imagen.
        
        Args:
            file_path: Ruta del archivo
            
        Returns:
            Diccionario con información de la imagen
        """
        info = {
            'filename': file_path.name,
            'file_size': file_path.stat().st_size,
            'file_size_mb': round(file_path.stat().st_size / (1024 * 1024), 2),
            'format': ImageUtils.detect_format(file_path),
            'width': 0,
            'height': 0,
            'color_mode': '',
            'has_alpha': False,
            'metadata': {}
        }
        
        try:
            with Image.open(file_path) as img:
                info['width'] = img.width
                info['height'] = img.height
                info['color_mode'] = img.mode
                info['has_alpha'] = img.mode in ('RGBA', 'LA', 'PA')
                
                # Intentar obtener EXIF
                if EXIF_SUPPORT and hasattr(img, '_getexif'):
                    try:
                        exif_data = piexif.load(str(file_path))
                        info['metadata'] = {
                            'has_exif': True,
                            'camera': exif_data.get('0th', {}).get(0x110),
                            'datetime': exif_data.get('0th', {}).get(0x132),
                        }
                    except Exception:
                        pass
        except Exception as e:
            logger.error(f"Error al obtener información de imagen: {e}")
        
        return info
    
    @staticmethod
    def enhance_image(image: Image.Image, enhance: bool = True) -> Image.Image:
        """
        Mejora la calidad de una imagen.
        
        Args:
            image: Imagen a mejorar
            enhance: Si True, aplica mejoras
            
        Returns:
            Imagen mejorada
        """
        if not enhance:
            return image
        
        try:
            # Mejorar contraste
            if Config.ENHANCE_CONTRAST:
                enhancer = ImageEnhance.Contrast(image)
                image = enhancer.enhance(Config.CONTRAST_FACTOR)
            
            # Mejorar nitidez
            if Config.ENHANCE_SHARPNESS:
                enhancer = ImageEnhance.Sharpness(image)
                image = enhancer.enhance(Config.SHARPNESS_FACTOR)
        
        except Exception as e:
            logger.warning(f"No se pudo mejorar imagen: {e}")
        
        return image
    
    @staticmethod
    def handle_transparency(
        image: Image.Image,
        output_format: str,
        mode: TransparencyMode = TransparencyMode.WHITE_BG
    ) -> Image.Image:
        """
        Maneja la transparencia según el formato de salida.
        
        Args:
            image: Imagen a procesar
            output_format: Formato de salida
            mode: Modo de manejo de transparencia
            
        Returns:
            Imagen procesada
        """
        # Si el formato soporta transparencia, retornar como está
        if output_format.upper() in Config.FORMATS_WITH_TRANSPARENCY:
            return image
        
        # Si la imagen tiene transparencia y el formato no la soporta
        if image.mode in ('RGBA', 'LA', 'PA'):
            if mode == TransparencyMode.REMOVE:
                return image.convert('RGB')
            elif mode == TransparencyMode.WHITE_BG:
                bg = Image.new('RGB', image.size, (255, 255, 255))
                bg.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None)
                return bg
            elif mode == TransparencyMode.BLACK_BG:
                bg = Image.new('RGB', image.size, (0, 0, 0))
                bg.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None)
                return bg
        
        return image


# ============================================================================
# MOTOR DE CONVERSIÓN
# ============================================================================

class ImageConverter:
    """Motor de conversión de imágenes."""
    
    def __init__(self):
        """Inicializa el convertidor."""
        self.cancel_requested = False
        self.progress_callback = None
        self.log_callback = None
    
    def convert_image(
        self,
        input_path: Path,
        output_path: Path,
        output_format: str,
        quality: int = 95,
        preserve_transparency: bool = True,
        enhance: bool = True
    ) -> ConversionResult:
        """
        Convierte una imagen de un formato a otro.
        
        Args:
            input_path: Ruta de entrada
            output_path: Ruta de salida
            output_format: Formato de salida
            quality: Calidad (0-100)
            preserve_transparency: Preservar transparencia
            enhance: Mejorar calidad
            
        Returns:
            ConversionResult con información del resultado
        """
        start_time = time.time()
        
        try:
            # Validar entrada
            if not input_path.exists():
                return ConversionResult(
                    success=False,
                    input_path=str(input_path),
                    output_path=str(output_path),
                    input_format=ImageUtils.detect_format(input_path) or 'UNKNOWN',
                    output_format=output_format,
                    input_size=0,
                    output_size=0,
                    duration=0,
                    error_message=f"Archivo de entrada no encontrado: {input_path}"
                )
            
            input_format = ImageUtils.detect_format(input_path)
            input_size = input_path.stat().st_size
            
            # Abrir imagen
            image = Image.open(input_path)
            
            # Mejorar imagen si está habilitado
            image = ImageUtils.enhance_image(image, enhance)
            
            # Manejar transparencia
            if not preserve_transparency:
                image = ImageUtils.handle_transparency(
                    image,
                    output_format,
                    TransparencyMode.WHITE_BG
                )
            
            # Crear directorio de salida
            output_path.parent.mkdir(parents=True, exist_ok=True)
            
            # Opciones de guardado según formato
            save_kwargs = {'format': output_format.upper()}
            
            if output_format.upper() in ['JPEG', 'JPG']:
                save_kwargs['quality'] = quality
                save_kwargs['optimize'] = Config.JPEG_OPTIMIZE
                if Config.JPEG_PROGRESSIVE:
                    save_kwargs['progressive'] = True
            
            elif output_format.upper() == 'PNG':
                save_kwargs['compress_level'] = Config.PNG_COMPRESS_LEVEL
                save_kwargs['optimize'] = Config.PNG_OPTIMIZE
            
            elif output_format.upper() == 'WEBP':
                save_kwargs['quality'] = quality
                save_kwargs['method'] = Config.WEBP_METHOD
            
            elif output_format.upper() == 'TIFF':
                save_kwargs['compression'] = Config.TIFF_COMPRESSION
            
            # Guardar imagen
            image.save(str(output_path), **save_kwargs)
            
            output_size = output_path.stat().st_size
            duration = time.time() - start_time
            compression_ratio = (1 - output_size / input_size) * 100 if input_size > 0 else 0
            
            conversion_logger.info(
                f"{str(input_path.resolve())}|{input_format or 'UNKNOWN'}|{output_format.upper()}"
            )
            
            return ConversionResult(
                success=True,
                input_path=str(input_path),
                output_path=str(output_path),
                input_format=input_format or 'UNKNOWN',
                output_format=output_format,
                input_size=input_size,
                output_size=output_size,
                duration=duration,
                compression_ratio=compression_ratio
            )
        
        except Exception as e:
            error_msg = f"Error en conversión: {str(e)}"
            logger.error(f"{error_msg}\n{traceback.format_exc()}")
            
            return ConversionResult(
                success=False,
                input_path=str(input_path),
                output_path=str(output_path),
                input_format=ImageUtils.detect_format(input_path) or 'UNKNOWN',
                output_format=output_format,
                input_size=input_path.stat().st_size if input_path.exists() else 0,
                output_size=0,
                duration=time.time() - start_time,
                error_message=error_msg
            )
    
    def convert_batch(
        self,
        input_files: List[Path],
        output_dir: Path,
        output_format: str,
        quality: int = 95,
        preserve_transparency: bool = True,
        enhance: bool = True,
        progress_callback=None
    ) -> Tuple[List[ConversionResult], int]:
        """
        Convierte múltiples imágenes en paralelo.
        
        Args:
            input_files: Lista de rutas de entrada
            output_dir: Directorio de salida
            output_format: Formato de salida
            quality: Calidad
            preserve_transparency: Preservar transparencia
            enhance: Mejorar calidad
            progress_callback: Función callback para progreso
            
        Returns:
            Tupla (lista de resultados, cantidad de errores)
        """
        self.cancel_requested = False
        self.progress_callback = progress_callback
        results = []
        total = len(input_files)
        processed = 0
        
        try:
            with ThreadPoolExecutor(max_workers=Config.MAX_WORKERS) as executor:
                futures = {}
                
                for input_file in input_files:
                    if self.cancel_requested:
                        break
                    
                    output_path = output_dir / f"{input_file.stem}.{output_format.lower()}"
                    
                    future = executor.submit(
                        self.convert_image,
                        input_file,
                        output_path,
                        output_format,
                        quality,
                        preserve_transparency,
                        enhance
                    )
                    futures[future] = input_file
                
                for future in as_completed(futures):
                    if self.cancel_requested:
                        break
                    
                    processed += 1
                    result = future.result()
                    results.append(result)
                    
                    # Callback de progreso
                    if self.progress_callback:
                        self.progress_callback(processed, total, result)
        
        except Exception as e:
            logger.error(f"Error en conversión batch: {e}")
        
        # Contar errores
        errors = sum(1 for r in results if not r.success)
        
        return results, errors
    
    def cancel(self) -> None:
        """Solicita cancelación de conversión."""
        self.cancel_requested = True


# ============================================================================
# INTERFAZ GRÁFICA MODERNA - CustomTkinter
# ============================================================================

class ImageConverterGUI:
    """Interfaz gráfica moderna para ImageConverter."""
    
    def __init__(self, root: ctk.CTk):
        """Inicializa la interfaz gráfica."""
        self.root = root
        self.converter = ImageConverter()
        self.selected_files: List[Path] = []
        self.conversion_thread = None
        self.current_image_preview = None
        self.tooltip_window = None
        self.file_details = {}  # Almacenar información de archivos
        self.thumbnail_cache = {}  # Caché de miniaturas para el registro
        
        self._setup_window()
        self._create_ui()
        self._setup_drag_drop()
        self._bind_events()
    
    def _setup_window(self) -> None:
        """Configura la ventana principal."""
        self.root.title(Config.APP_NAME)
        self.root.geometry(f"{Config.WINDOW_WIDTH}x{Config.WINDOW_HEIGHT}")
        self.root.minsize(Config.WINDOW_MIN_WIDTH, Config.WINDOW_MIN_HEIGHT)
        
        # Tema
        ctk.set_appearance_mode("dark")
        ctk.set_default_color_theme("blue")
        
        # Color de fondo
        self.root.configure(fg_color=Config.Colors.BG_PRIMARY)
    
    def _create_ui(self) -> None:
        """Crea todos los elementos de la interfaz."""
        # Frame principal con grid
        main_frame = ctk.CTkFrame(self.root, fg_color=Config.Colors.BG_PRIMARY)
        main_frame.pack(fill="both", expand=True, padx=0, pady=0)
        main_frame.grid_rowconfigure(1, weight=1)
        main_frame.grid_columnconfigure(0, weight=1)
        
        # ===================== HEADER =======================
        header_frame = ctk.CTkFrame(main_frame, fg_color=Config.Colors.BG_SECONDARY, height=80)
        header_frame.grid(row=0, column=0, sticky="ew", padx=0, pady=0)
        header_frame.grid_propagate(False)
        
        title_label = ctk.CTkLabel(
            header_frame,
            text=Config.APP_NAME,
            font=(Config.FONT_FAMILY, Config.FONT_SIZE_TITLE, "bold"),
            text_color=Config.Colors.TEXT_PRIMARY
        )
        title_label.pack(padx=Config.PADDING_LARGE, pady=Config.PADDING_MEDIUM, anchor="w")
        
        # ===================== CONTENIDO PRINCIPAL =======================
        content_frame = ctk.CTkFrame(main_frame, fg_color=Config.Colors.BG_PRIMARY)
        content_frame.grid(row=1, column=0, sticky="nsew", padx=0, pady=0)
        content_frame.grid_columnconfigure(0, weight=1)
        content_frame.grid_columnconfigure(1, weight=1)
        content_frame.grid_rowconfigure(0, weight=1)
        
        # ===================== PANEL IZQUIERDO - ARCHIVOS =======================
        left_panel = ctk.CTkFrame(content_frame, fg_color=Config.Colors.BG_SECONDARY)
        left_panel.grid(row=0, column=0, sticky="nsew", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_MEDIUM)
        left_panel.grid_rowconfigure(4, weight=1)
        left_panel.grid_columnconfigure(0, weight=1)
        
        # Título sección
        section_title = ctk.CTkLabel(
            left_panel,
            text="📁 Archivos a Convertir",
            font=(Config.FONT_FAMILY, Config.FONT_SIZE_HEADING, "bold"),
            text_color=Config.Colors.TEXT_PRIMARY
        )
        section_title.grid(row=0, column=0, sticky="w", padx=Config.PADDING_MEDIUM, pady=(Config.PADDING_MEDIUM, Config.PADDING_SMALL))
        
        # Botones de selección
        buttons_frame = ctk.CTkFrame(left_panel, fg_color="transparent")
        buttons_frame.grid(row=1, column=0, sticky="ew", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_SMALL)
        buttons_frame.grid_columnconfigure(0, weight=1)
        buttons_frame.grid_columnconfigure(1, weight=1)
        
        btn_files = ctk.CTkButton(
            buttons_frame,
            text="📁 Archivos",
            command=self._select_files,
            fg_color=Config.Colors.ACCENT_PRIMARY,
            hover_color=Config.Colors.ACCENT_SECONDARY,
            text_color="white",
            font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL, "bold"),
            corner_radius=Config.CORNER_RADIUS
        )
        btn_files.grid(row=0, column=0, sticky="ew", padx=(0, Config.PADDING_SMALL))
        
        btn_folder = ctk.CTkButton(
            buttons_frame,
            text="📂 Carpeta",
            command=self._select_folder,
            fg_color=Config.Colors.ACCENT_PRIMARY,
            hover_color=Config.Colors.ACCENT_SECONDARY,
            text_color="white",
            font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL, "bold"),
            corner_radius=Config.CORNER_RADIUS
        )
        btn_folder.grid(row=0, column=1, sticky="ew", padx=Config.PADDING_SMALL)
        
        # Información de cantidad de archivos
        self.file_count_label = ctk.CTkLabel(
            left_panel,
            text="0 archivos seleccionados",
            font=(Config.FONT_FAMILY, Config.FONT_SIZE_SMALL),
            text_color=Config.Colors.TEXT_SECONDARY
        )
        self.file_count_label.grid(row=2, column=0, sticky="w", padx=Config.PADDING_MEDIUM, pady=(Config.PADDING_SMALL, Config.PADDING_SMALL))
        
        # Frame para lista con scrollbar
        list_container = ctk.CTkFrame(left_panel, fg_color=Config.Colors.BG_TERTIARY, corner_radius=Config.CORNER_RADIUS)
        list_container.grid(row=4, column=0, sticky="nsew", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_SMALL)
        list_container.grid_rowconfigure(0, weight=1)
        list_container.grid_columnconfigure(0, weight=1)
        
        # Listbox con items personalizados (frame)
        self.file_frame = ctk.CTkScrollableFrame(
            list_container,
            fg_color=Config.Colors.BG_TERTIARY,
            corner_radius=Config.CORNER_RADIUS
        )
        self.file_frame.grid(row=0, column=0, sticky="nsew", padx=0, pady=0)
        self.file_frame.grid_columnconfigure(0, weight=1)
        
        self.file_items = []  # Lista de widgets de archivo
        
        # Botón limpiar lista
        btn_clear = ctk.CTkButton(
            left_panel,
            text="🗑️ Limpiar Lista",
            command=self._clear_files,
            fg_color=Config.Colors.BG_HOVER,
            hover_color=Config.Colors.BG_SELECTED,
            text_color=Config.Colors.TEXT_PRIMARY,
            font=(Config.FONT_FAMILY, Config.FONT_SIZE_SMALL),
            corner_radius=Config.CORNER_RADIUS
        )
        btn_clear.grid(row=5, column=0, sticky="ew", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_SMALL)
        
        # ===================== PANEL DERECHO - CONFIGURACIÓN =======================
        right_panel = ctk.CTkFrame(content_frame, fg_color=Config.Colors.BG_SECONDARY)
        right_panel.grid(row=0, column=1, sticky="nsew", padx=(Config.PADDING_SMALL, Config.PADDING_MEDIUM), pady=Config.PADDING_MEDIUM)
        right_panel.grid_rowconfigure(7, weight=1)
        right_panel.grid_columnconfigure(0, weight=1)
        right_panel.grid_columnconfigure(1, weight=1)
        
        # Configuración de conversión
        config_title = ctk.CTkLabel(
            right_panel,
            text="⚙️ Configuración",
            font=(Config.FONT_FAMILY, Config.FONT_SIZE_HEADING, "bold"),
            text_color=Config.Colors.TEXT_PRIMARY
        )
        config_title.grid(row=0, column=0, columnspan=2, sticky="w", padx=Config.PADDING_MEDIUM, pady=(Config.PADDING_MEDIUM, Config.PADDING_SMALL))
        
        # Formato de salida
        format_label = ctk.CTkLabel(
            right_panel,
            text="Formato de Salida:",
            font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL),
            text_color=Config.Colors.TEXT_PRIMARY
        )
        format_label.grid(row=1, column=0, sticky="w", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_SMALL)
        
        self.format_var = tk.StringVar(value="PNG")
        format_menu = ctk.CTkOptionMenu(
            right_panel,
            variable=self.format_var,
            values=sorted(Config.SUPPORTED_FORMATS.keys()),
            fg_color=Config.Colors.BG_TERTIARY,
            button_color=Config.Colors.ACCENT_PRIMARY,
            dropdown_fg_color=Config.Colors.BG_TERTIARY,
            text_color=Config.Colors.TEXT_PRIMARY
        )
        format_menu.grid(row=1, column=1, sticky="ew", padx=(Config.PADDING_SMALL, Config.PADDING_MEDIUM), pady=Config.PADDING_SMALL)
        
        # Calidad
        quality_label = ctk.CTkLabel(
            right_panel,
            text="Calidad:",
            font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL),
            text_color=Config.Colors.TEXT_PRIMARY
        )
        quality_label.grid(row=2, column=0, sticky="w", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_SMALL)
        
        self.quality_var = tk.IntVar(value=95)
        quality_frame = ctk.CTkFrame(right_panel, fg_color="transparent")
        quality_frame.grid(row=2, column=1, sticky="ew", padx=(Config.PADDING_SMALL, Config.PADDING_MEDIUM), pady=Config.PADDING_SMALL)
        quality_frame.grid_columnconfigure(0, weight=1)
        
        self.quality_slider = ctk.CTkSlider(
            quality_frame,
            from_=50,
            to=100,
            number_of_steps=50,
            variable=self.quality_var,
            fg_color=Config.Colors.BG_TERTIARY,
            progress_color=Config.Colors.ACCENT_PRIMARY
        )
        self.quality_slider.grid(row=0, column=0, sticky="ew", padx=(0, Config.PADDING_SMALL))
        
        self.quality_value = ctk.CTkLabel(
            quality_frame,
            text="95%",
            font=(Config.FONT_FAMILY, Config.FONT_SIZE_SMALL),
            text_color=Config.Colors.ACCENT_PRIMARY,
            width=40
        )
        self.quality_value.grid(row=0, column=1, padx=0)
        
        def update_quality_label(val):
            self.quality_value.configure(text=f"{int(float(val))}%")
        
        if hasattr(self.quality_var, 'trace_add'):
            self.quality_var.trace_add('write', lambda *args: update_quality_label(self.quality_slider.get()))
        else:
            self.quality_var.trace('w', lambda *args: update_quality_label(self.quality_slider.get()))
        
        # Opciones checkbox
        self.transparency_var = tk.BooleanVar(value=True)
        transparency_check = ctk.CTkCheckBox(
            right_panel,
            text="Preservar Transparencia",
            variable=self.transparency_var,
            text_color=Config.Colors.TEXT_PRIMARY,
            fg_color=Config.Colors.ACCENT_PRIMARY,
            hover_color=Config.Colors.ACCENT_SECONDARY
        )
        transparency_check.grid(row=3, column=0, columnspan=2, sticky="w", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_SMALL)
        
        self.enhance_var = tk.BooleanVar(value=True)
        enhance_check = ctk.CTkCheckBox(
            right_panel,
            text="Mejorar Calidad (Contraste + Nitidez)",
            variable=self.enhance_var,
            text_color=Config.Colors.TEXT_PRIMARY,
            fg_color=Config.Colors.ACCENT_PRIMARY,
            hover_color=Config.Colors.ACCENT_SECONDARY
        )
        enhance_check.grid(row=4, column=0, columnspan=2, sticky="w", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_SMALL)
        
        # Carpeta de salida
        output_label = ctk.CTkLabel(
            right_panel,
            text="Carpeta de Salida:",
            font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL),
            text_color=Config.Colors.TEXT_PRIMARY
        )
        output_label.grid(row=5, column=0, columnspan=2, sticky="w", padx=Config.PADDING_MEDIUM, pady=(Config.PADDING_MEDIUM, Config.PADDING_SMALL))
        
        output_frame = ctk.CTkFrame(right_panel, fg_color="transparent")
        output_frame.grid(row=6, column=0, columnspan=2, sticky="ew", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_SMALL)
        output_frame.grid_columnconfigure(0, weight=1)
        
        self.output_dir = tk.StringVar(value="")
        output_entry = ctk.CTkEntry(
            output_frame,
            textvariable=self.output_dir,
            fg_color=Config.Colors.BG_TERTIARY,
            text_color=Config.Colors.TEXT_PRIMARY,
            border_color=Config.Colors.BORDER,
            border_width=1,
            state="readonly"
        )
        output_entry.grid(row=0, column=0, sticky="ew", padx=(0, Config.PADDING_SMALL))
        
        btn_output = ctk.CTkButton(
            output_frame,
            text="📂",
            command=self._select_output_folder,
            fg_color=Config.Colors.ACCENT_PRIMARY,
            hover_color=Config.Colors.ACCENT_SECONDARY,
            width=40,
            corner_radius=Config.CORNER_RADIUS
        )
        btn_output.grid(row=0, column=1, padx=0)
        
        # ===================== FOOTER CON BOTONES =======================
        footer_frame = ctk.CTkFrame(main_frame, fg_color=Config.Colors.BG_SECONDARY)
        footer_frame.grid(row=2, column=0, sticky="ew", padx=0, pady=0)
        
        # Barra de progreso
        self.progress_var = tk.DoubleVar(value=0)
        self.progress_bar = ctk.CTkProgressBar(
            footer_frame,
            variable=self.progress_var,
            fg_color=Config.Colors.BG_TERTIARY,
            progress_color=Config.Colors.ACCENT_PRIMARY,
            height=8
        )
        self.progress_bar.pack(fill="x", padx=0, pady=0)
        
        # Frame de botones
        button_frame = ctk.CTkFrame(footer_frame, fg_color=Config.Colors.BG_SECONDARY)
        button_frame.pack(fill="x", padx=Config.PADDING_LARGE, pady=Config.PADDING_MEDIUM)
        button_frame.grid_columnconfigure(0, weight=1)
        
        # Información de progreso
        self.progress_label = ctk.CTkLabel(
            button_frame,
            text="Listo",
            font=(Config.FONT_FAMILY, Config.FONT_SIZE_SMALL),
            text_color=Config.Colors.TEXT_SECONDARY
        )
        self.progress_label.grid(row=0, column=0, sticky="w", padx=0, pady=0)
        
        # Botones de acción
        self.btn_convert = ctk.CTkButton(
            button_frame,
            text="▶ Convertir",
            command=self._start_conversion,
            fg_color=Config.Colors.ACCENT_PRIMARY,
            hover_color=Config.Colors.ACCENT_SECONDARY,
            text_color="white",
            font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL, "bold"),
            corner_radius=Config.CORNER_RADIUS,
            width=150,
            state="disabled"
        )
        self.btn_convert.grid(row=0, column=1, padx=Config.PADDING_SMALL)
        
        self.btn_cancel = ctk.CTkButton(
            button_frame,
            text="⏹ Cancelar",
            command=self._cancel_conversion,
            fg_color=Config.Colors.ERROR,
            hover_color="#bb2d3b",
            text_color="white",
            font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL, "bold"),
            corner_radius=Config.CORNER_RADIUS,
            width=150,
            state="disabled"
        )
        self.btn_cancel.grid(row=0, column=2, padx=Config.PADDING_SMALL)
        
        btn_logs = ctk.CTkButton(
            button_frame,
            text="📋 Registros",
            command=self._show_logs,
            fg_color=Config.Colors.BG_HOVER,
            hover_color=Config.Colors.BG_SELECTED,
            text_color=Config.Colors.TEXT_PRIMARY,
            font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL),
            corner_radius=Config.CORNER_RADIUS,
            width=150
        )
        btn_logs.grid(row=0, column=3, padx=Config.PADDING_SMALL)
        
        btn_about = ctk.CTkButton(
            button_frame,
            text="ℹ️ Acerca de",
            command=self._show_about,
            fg_color=Config.Colors.BG_HOVER,
            hover_color=Config.Colors.BG_SELECTED,
            text_color=Config.Colors.TEXT_PRIMARY,
            font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL),
            corner_radius=Config.CORNER_RADIUS,
            width=150
        )
        btn_about.grid(row=0, column=4, padx=Config.PADDING_SMALL)
    
    def _setup_drag_drop(self) -> None:
        """Configura drag & drop."""
        try:
            from tkinterdnd2 import DND_FILES, DND_TEXT
            
            def drop(event):
                files = self.root.tk.splitlist(event.data)
                for file_path in files:
                    file_path = Path(file_path.strip('{}'))
                    if file_path.is_file() and ImageUtils.detect_format(file_path):
                        if file_path not in self.selected_files:
                            self.selected_files.append(file_path)
                self._update_file_list()
            
            if hasattr(self.root, 'drop_target_register'):
                self.root.drop_target_register(DND_FILES)
                self.root.dnd_bind('<>', drop)
            else:
                logger.info("Drag & Drop no está soportado por la ventana principal actual.")
        except ImportError:
            logger.info("tkinterdnd2 no disponible - Drag & Drop deshabilitado")
    
    def _bind_events(self) -> None:
        """Vincula eventos de la interfaz."""
        pass
    
    def _select_files(self) -> None:
        """Abre diálogo para seleccionar archivos."""
        filetypes = [
            ("Imágenes", ' '.join(f"*.{fmt.lower()}" for fmt in Config.SUPPORTED_FORMATS)),
            ("PNG", "*.png"),
            ("JPEG", "*.jpg *.jpeg"),
            ("WEBP", "*.webp"),
            ("GIF", "*.gif"),
            ("TIFF", "*.tiff *.tif"),
            ("Todos", "*.*")
        ]
        
        files = filedialog.askopenfilenames(filetypes=filetypes)
        
        for file in files:
            file_path = Path(file)
            if ImageUtils.detect_format(file_path) and file_path not in self.selected_files:
                self.selected_files.append(file_path)
        
        self._update_file_list()
    
    def _select_folder(self) -> None:
        """Abre diálogo para seleccionar carpeta."""
        folder = filedialog.askdirectory()
        
        if folder:
            folder_path = Path(folder)
            image_files = [
                f for f in folder_path.rglob("*")
                if f.is_file() and ImageUtils.detect_format(f)
            ]
            
            for file in image_files:
                if file not in self.selected_files:
                    self.selected_files.append(file)
            
            self._update_file_list()
    
    def _select_output_folder(self) -> None:
        """Abre diálogo para seleccionar carpeta de salida."""
        folder = filedialog.askdirectory()
        if folder:
            self.output_dir.set(folder)
            # Habilitar botón de conversión cuando se selecciona carpeta de salida
            self.btn_convert.configure(state="normal")
    
    def _clear_files(self) -> None:
        """Limpia la lista de archivos."""
        self.selected_files.clear()
        self._update_file_list()
    
    def _update_file_list(self) -> None:
        """Actualiza la lista visual de archivos con previsualizaciones."""
        # Limpiar items anteriores
        for item in self.file_items:
            item.destroy()
        self.file_items.clear()
        self.file_details.clear()
        
        # Actualizar contador
        self.file_count_label.configure(
            text=f"{len(self.selected_files)} archivo{'s' if len(self.selected_files) != 1 else ''} seleccionado{'s' if len(self.selected_files) != 1 else ''}"
        )
        
        # Crear items nuevos
        for idx, file_path in enumerate(self.selected_files):
            # Obtener información del archivo
            info = ImageUtils.get_image_info(file_path)
            self.file_details[str(file_path)] = info
            
            # Frame del item
            item_frame = ctk.CTkFrame(
                self.file_frame,
                fg_color=Config.Colors.BG_SECONDARY,
                corner_radius=Config.CORNER_RADIUS,
                height=100
            )
            item_frame.pack(fill="x", padx=Config.PADDING_SMALL, pady=Config.PADDING_TINY, expand=False)
            item_frame.grid_propagate(False)
            item_frame.grid_columnconfigure(1, weight=1)
            
            # Miniatura en la lista
            thumbnail_image = None
            try:
                if file_path.exists():
                    thumb = Image.open(file_path)
                    thumb.thumbnail((90, 90), Image.Resampling.LANCZOS)
                    thumbnail_image = ctk.CTkImage(light_image=thumb, size=(90, 90))
                    # Guardar en caché usando la ruta absoluta como clave
                    self.thumbnail_cache[str(file_path.resolve())] = thumbnail_image
            except Exception:
                thumbnail_image = None
            
            preview_label = ctk.CTkLabel(
                item_frame,
                image=thumbnail_image,
                text="" if thumbnail_image else "🖼️",
                fg_color=Config.Colors.BG_SECONDARY,
                text_color=Config.Colors.ACCENT_PRIMARY,
                width=100,
                height=100
            )
            preview_label.image = thumbnail_image
            preview_label.grid(row=0, column=0, padx=Config.PADDING_SMALL, pady=Config.PADDING_SMALL)
            
            # Información del archivo
            info_text = f"{file_path.name}\n{info['width']}x{info['height']} • {info['file_size_mb']} MB"
            info_label = ctk.CTkLabel(
                item_frame,
                text=info_text,
                font=(Config.FONT_FAMILY, Config.FONT_SIZE_SMALL),
                text_color=Config.Colors.TEXT_PRIMARY,
                justify="left",
                anchor="w"
            )
            info_label.grid(row=0, column=1, sticky="w", padx=Config.PADDING_SMALL, pady=Config.PADDING_SMALL)
            
            # Botón para eliminar
            btn_remove = ctk.CTkButton(
                item_frame,
                text="✕",
                font=(Config.FONT_FAMILY, 16, "bold"),
                fg_color=Config.Colors.ERROR,
                hover_color="#bb2d3b",
                text_color="white",
                width=40,
                height=40,
                corner_radius=Config.CORNER_RADIUS,
                command=lambda fp=file_path: self._remove_file(fp)
            )
            btn_remove.grid(row=0, column=2, padx=Config.PADDING_SMALL, pady=Config.PADDING_SMALL)
            
            # Evento hover para tooltip
            def on_enter(event, fp=file_path, info_d=info):
                self._show_tooltip(event, fp, info_d)
            
            def on_leave(event):
                self._hide_tooltip()
            
            item_frame.bind("", on_enter)
            item_frame.bind("", on_leave)
            preview_label.bind("", on_enter)
            preview_label.bind("", on_leave)
            info_label.bind("", on_enter)
            info_label.bind("", on_leave)
            
            self.file_items.append(item_frame)
    
    def _remove_file(self, file_path: Path) -> None:
        """Elimina un archivo de la lista."""
        if file_path in self.selected_files:
            self.selected_files.remove(file_path)
            self._update_file_list()
    
    def _show_tooltip(self, event, file_path: Path, info: Dict) -> None:
        """Muestra un tooltip con información y miniatura."""
        try:
            # Ocultar tooltip anterior
            self._hide_tooltip()
            
            # Crear ventana tooltip
            self.tooltip_window = tk.Toplevel()
            self.tooltip_window.wm_overrideredirect(True)
            self.tooltip_window.wm_geometry(f"+{event.x_root + 10}+{event.y_root + 10}")
            self.tooltip_window.configure(bg=Config.Colors.BG_TERTIARY, highlightthickness=1, 
                                         highlightcolor=Config.Colors.BORDER)
            
            # Frame principal
            tooltip_frame = ctk.CTkFrame(self.tooltip_window, fg_color=Config.Colors.BG_TERTIARY)
            tooltip_frame.pack(padx=Config.PADDING_SMALL, pady=Config.PADDING_SMALL)
            
            # Información formateada
            tooltip_text = f"""
📄 {file_path.name}
📐 Resolución: {info['width']}x{info['height']}
💾 Tamaño: {info['file_size_mb']} MB
🎨 Modo: {info['color_mode']}
🔄 Transparencia: {'Sí' if info['has_alpha'] else 'No'}
"""
            
            info_label = ctk.CTkLabel(
                tooltip_frame,
                text=tooltip_text,
                font=(Config.FONT_FAMILY, Config.FONT_SIZE_TINY),
                text_color=Config.Colors.TEXT_PRIMARY,
                justify="left"
            )
            info_label.pack(padx=Config.PADDING_MEDIUM, pady=Config.PADDING_MEDIUM)
            
            self.root.after(5000, self._hide_tooltip)
        
        except Exception as e:
            logger.warning(f"Error al mostrar tooltip: {e}")
    
    def _hide_tooltip(self) -> None:
        """Oculta el tooltip."""
        if self.tooltip_window:
            try:
                self.tooltip_window.destroy()
            except Exception:
                pass
            self.tooltip_window = None
    
    def _start_conversion(self) -> None:
        """Inicia la conversión de archivos."""
        if not self.selected_files:
            messagebox.showwarning("Advertencia", "Por favor selecciona al menos un archivo")
            return
        
        output_format = self.format_var.get()
        output_dir_str = self.output_dir.get().strip()
        
        # Validar que la carpeta de salida esté seleccionada
        if not output_dir_str:
            messagebox.showerror("Error", "Debes seleccionar una carpeta de salida antes de convertir")
            return
        
        output_dir = Path(output_dir_str)
        quality = self.quality_var.get()
        preserve_transparency = self.transparency_var.get()
        enhance = self.enhance_var.get()
        
        if not output_dir.exists():
            messagebox.showerror("Error", f"Carpeta de salida no existe: {output_dir}")
            return
        
        # Deshabilitar botón de convertir
        self.root.after(0, self._disable_controls)
        
        # Iniciar conversión en hilo separado
        self.conversion_thread = threading.Thread(
            target=self._perform_conversion,
            args=(output_format, output_dir, quality, preserve_transparency, enhance),
            daemon=True
        )
        self.conversion_thread.start()
    
    def _disable_controls(self) -> None:
        """Deshabilita controles durante conversión."""
        self.btn_convert.configure(state="disabled")
        self.btn_cancel.configure(state="normal")
    
    def _enable_controls(self) -> None:
        """Habilita controles después de conversión."""
        self.btn_convert.configure(state="normal")
        self.btn_cancel.configure(state="disabled")
    
    def _perform_conversion(
        self,
        output_format: str,
        output_dir: Path,
        quality: int,
        preserve_transparency: bool,
        enhance: bool
    ) -> None:
        """Realiza la conversión en hilo separado."""
        def progress_callback(current, total, result):
            percentage = (current / total) * 100
            self.progress_var.set(percentage / 100)
            
            status_msg = f"Convertido {current}/{total}"
            if result.error_message:
                status_msg += f" | Error: {result.error_message}"
            else:
                size_reduction = result.compression_ratio
                status_msg += f" | Reducción: {size_reduction:.1f}%"
            
            self.progress_label.configure(text=status_msg)
            self.root.update_idletasks()
        
        try:
            results, errors = self.converter.convert_batch(
                self.selected_files,
                output_dir,
                output_format,
                quality,
                preserve_transparency,
                enhance,
                progress_callback
            )
            
            # Mostrar resumen
            successful = len(results) - errors
            summary = f"Conversión completada:\n\n✓ {successful} archivos convertidos\n✗ {errors} errores"
            
            self.root.after(0, lambda: messagebox.showinfo("Éxito", summary))
            self.root.after(0, self._enable_controls)
            self.root.after(0, lambda: self.progress_var.set(0))
            self.root.after(0, lambda: self.progress_label.configure(text="Listo"))
            self.root.after(0, lambda: self.output_dir.set(""))
        
        except Exception as e:
            logger.error(f"Error en conversión: {e}")
            self.root.after(0, lambda: messagebox.showerror("Error", f"Error: {str(e)}"))
            self.root.after(0, self._enable_controls)
    
    def _cancel_conversion(self) -> None:
        """Cancela la conversión actual."""
        self.converter.cancel()
        self.root.after(0, self._enable_controls)
    
    def _show_logs(self) -> None:
        """Muestra ventana de registros."""
        log_window = ctk.CTkToplevel(self.root)
        log_window.title("Registros de Conversión")
        log_window.geometry("900x650")
        log_window.configure(fg_color=Config.Colors.BG_PRIMARY)
        
        # Configurar como ventana modal (siempre encima)
        log_window.attributes('-topmost', True)
        log_window.grab_set()
        
        # Centrar sobre la ventana principal
        log_window.update_idletasks()
        root_x = self.root.winfo_x()
        root_y = self.root.winfo_y()
        root_width = self.root.winfo_width()
        root_height = self.root.winfo_height()
        window_width = log_window.winfo_width()
        window_height = log_window.winfo_height()
        
        x = root_x + (root_width - window_width) // 2
        y = root_y + (root_height - window_height) // 2
        log_window.geometry(f"+{x}+{y}")
        
        # Leer archivo de log
        try:
            with open(Config.LOG_FILE, 'rb') as f:
                raw_content = f.read().decode('utf-8', errors='replace')
        except FileNotFoundError:
            raw_content = ""
        except Exception as e:
            raw_content = f"No se pudo leer el archivo de log: {e}"

        # Crear panel scrollable de resultados
        log_body = ctk.CTkScrollableFrame(log_window, fg_color=Config.Colors.BG_PRIMARY, corner_radius=Config.CORNER_RADIUS)
        log_body.pack(fill="both", expand=True, padx=Config.PADDING_MEDIUM, pady=(Config.PADDING_MEDIUM, 0))
        log_body.grid_columnconfigure(0, weight=1)
        
        # Almacenar referencias de imágenes en la ventana para evitar que se limpien
        if not hasattr(log_window, '_image_refs'):
            log_window._image_refs = []

        lines_found = 0
        for line in raw_content.splitlines():
            parsed_entry = parse_conversion_log_entry(line)
            if parsed_entry is None:
                continue

            image_path, input_fmt, output_fmt = parsed_entry
            filename = image_path.name
            conversion_text = f"{filename} | {input_fmt} -> {output_fmt}"

            item_frame = ctk.CTkFrame(log_body, fg_color=Config.Colors.BG_SECONDARY, corner_radius=Config.CORNER_RADIUS)
            item_frame.pack(fill="x", padx=Config.PADDING_SMALL, pady=Config.PADDING_SMALL)
            item_frame.grid_columnconfigure(1, weight=1)

            # Buscar la miniatura en caché usando ruta absoluta
            thumbnail_image = self.thumbnail_cache.get(str(image_path.resolve()))

            preview_label = ctk.CTkLabel(
                item_frame,
                image=thumbnail_image,
                text="" if thumbnail_image else "🖼️",
                fg_color=Config.Colors.BG_SECONDARY,
                text_color=Config.Colors.ACCENT_PRIMARY,
                width=100,
                height=100
            )
            preview_label.image = thumbnail_image
            preview_label.grid(row=0, column=0, padx=Config.PADDING_SMALL, pady=Config.PADDING_SMALL)

            info_label = ctk.CTkLabel(
                item_frame,
                text=conversion_text,
                font=(Config.FONT_FAMILY, Config.FONT_SIZE_SMALL),
                text_color=Config.Colors.TEXT_PRIMARY,
                justify="left"
            )
            info_label.grid(row=0, column=1, sticky="w", padx=Config.PADDING_SMALL, pady=Config.PADDING_SMALL)
            lines_found += 1

        if lines_found == 0:
            empty_label = ctk.CTkLabel(
                log_body,
                text="No hay registros de conversión.",
                font=(Config.FONT_FAMILY, Config.FONT_SIZE_SMALL),
                text_color=Config.Colors.TEXT_SECONDARY,
                fg_color=Config.Colors.BG_SECONDARY,
                corner_radius=Config.CORNER_RADIUS,
                anchor="w"
            )
            empty_label.pack(fill="x", padx=Config.PADDING_SMALL, pady=Config.PADDING_SMALL)
        
        # Botón cerrar
        close_btn = ctk.CTkButton(
            log_window,
            text="Cerrar",
            command=log_window.destroy,
            fg_color=Config.Colors.ACCENT_PRIMARY,
            corner_radius=Config.CORNER_RADIUS
        )
        close_btn.pack(pady=Config.PADDING_MEDIUM, padx=Config.PADDING_MEDIUM, fill="x")
    
    def _show_about(self) -> None:
        """Muestra diálogo de información."""
        about_text = f"""{Config.APP_NAME}
Versión: {Config.APP_VERSION}

{Config.APP_DESCRIPTION}

Características:
• Soporte para {len(Config.SUPPORTED_FORMATS)}+ formatos de imagen
• Conversión batch paralela
• Interfaz moderna y responsive
• Mejora automática de calidad
• Preservación de metadatos
• Soporte para GIF animados

Desarrollado con:
• Python 3.10+
• Pillow (PIL)
• CustomTkinter
• Threading/Concurrent.futures

© 2026 - Todos los derechos reservados
"""
        messagebox.showinfo(Config.APP_NAME, about_text)


# ============================================================================
# PUNTO DE ENTRADA
# ============================================================================

def main() -> None:
    """Función principal de la aplicación."""
    try:
        # Limpiar archivo de conversiones al iniciar
        reset_conversion_log()
        
        logger.info(f"Iniciando {Config.APP_NAME} v{Config.APP_VERSION}")
        
        # Crear ventana principal
        try:
            from tkinterdnd2 import TkinterDnD
            class CTkDnD(ctk.CTk, TkinterDnD.DnDWrapper):
                def __init__(self, *args, **kwargs):
                    super().__init__(*args, **kwargs)
                    self.TkdndVersion = TkinterDnD._require(self)
            root = CTkDnD()
        except ImportError:
            root = ctk.CTk()
        
        # Crear interfaz
        app = ImageConverterGUI(root)
        
        logger.info("Interfaz gráfica cargada exitosamente")
        
        # Ejecutar
        root.protocol("WM_DELETE_WINDOW", root.destroy)
        root.mainloop()
        
    except Exception as e:
        logger.error(f"Error fatal: {e}\n{traceback.format_exc()}")
        print(f"Error fatal: {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()