Files
Iván Eduardo Chavez Ayub 08a897e38f Update 5.1.1
2025-12-31 23:47:43 -06:00

515 lines
20 KiB
Python

import logging
import os
import queue
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List, Optional, Tuple
import customtkinter as ctk
import cv2
import numpy as np
from PIL import Image
# --- CONFIGURACIÓN Y ESTILOS ---
class Theme:
BG_COLOR = "#101010" # Fondo general
ITEM_BG = "#3A3A3A" # Fondo de la tarjeta (sin procesar)
# Nuevo: Colores para la barra de progreso y estados
PROGRESS_BG = "#2E7D32" # Verde oscuro para el fondo de progreso (permite leer texto blanco)
COMPLETED_BG = "#1B5E20" # Verde más oscuro para completado
PROCESSING_BORDER = "#F7F7F7" # Borde blanco/dorado cuando está procesando
TEXT_MAIN = "#FFFFFF"
TEXT_SEC = "#FDEF2F" # Amarillo (info secundaria)
TEXT_DONE = "#AAAAAA" # Gris para archivos completados
ACCENT = "#F7F7F7" # Dorado/Blanco principal
ERROR = "#CF6679" # Rojo error
BTN_HOVER_DANGER = "#B00020"
BTN_HOVER_NORMAL = "#4A4A4A"
BORDER_COLOR = "#555555"
FONT_MAIN = ("Roboto Medium", 13)
FONT_SUB = ("Roboto", 11)
FONT_MONO = ("Consolas", 10)
# Configuración de logging
logging.basicConfig(level=logging.ERROR,
format='%(asctime)s - %(levelname)s - %(message)s')
class QueueItem:
"""Clase de datos para manejar el estado de cada archivo individualmente."""
def __init__(self, path: str):
self.path = path
self.name = os.path.basename(path)
self.info_text = "Cargando info..."
self.thumbnail: Optional[ctk.CTkImage] = None
# Estado
self.is_loaded = False
self.has_error = False
self.is_completed = False
self.progress = 0.0 # 0.0 a 1.0
self.id = id(self) # Identificador único
class FileQueueManager(ctk.CTkScrollableFrame):
def __init__(self, master, clear_icon=None, on_queue_empty_callback=None, **kwargs):
super().__init__(master, fg_color=Theme.BG_COLOR, **kwargs)
self.on_queue_empty_callback = on_queue_empty_callback
self.clear_icon = clear_icon
# Estado interno
self.queue_items: List[QueueItem] = []
# Mapa para acceder a widgets por ID de item o por PATH
self._widget_refs: Dict[int, dict] = {}
self._path_to_id: Dict[str, int] = {}
# Factores de redimensionado
self.upscale_factor = 1
self.input_resize_factor = 0
self.output_resize_factor = 0
# Sistema de hilos
self.executor = ThreadPoolExecutor(max_workers=2)
self.msg_queue = queue.Queue()
# Grid layout básico
self.grid_columnconfigure(0, weight=1)
# Iniciar loop de verificación
self._check_queue()
def _check_queue(self):
"""Revisa la cola de mensajes para actualizar la UI desde hilos secundarios."""
try:
while True:
task_type, item_id, data = self.msg_queue.get_nowait()
if task_type == "UPDATE_ITEM_DATA":
self._update_item_ui_data(item_id, data)
elif task_type == "UPDATE_PROGRESS":
self._render_progress_update(item_id, data)
except queue.Empty:
pass
finally:
self.after(50, self._check_queue) # Revisar cada 50ms para fluidez
def add_files(self, file_paths: List[str]):
"""Agrega archivos y lanza la carga en segundo plano."""
added_any = False
existing_paths = {item.path for item in self.queue_items}
for path in file_paths:
norm_path = os.path.normpath(path)
if norm_path not in existing_paths and os.path.exists(path):
new_item = QueueItem(norm_path)
self.queue_items.append(new_item)
# Mapeo rápido Path -> ID
self._path_to_id[norm_path] = new_item.id
added_any = True
# Renderizar placeholder inmediatamente
self._render_item(new_item, index=len(self.queue_items)-1)
# Lanzar tarea pesada
self.executor.submit(self._worker_load_info, new_item)
if added_any:
self._update_layout_indices()
def _worker_load_info(self, item: QueueItem):
"""Worker en segundo plano para leer metadatos e imágenes."""
try:
info_text, pil_img = self._extract_file_data(item.path)
# Pre-calcular CTkImage en el hilo principal enviando el PIL
self.msg_queue.put(("UPDATE_ITEM_DATA", item.id, {
"info": info_text,
"img_pil": pil_img,
"is_loaded": True
}))
except Exception as e:
logging.error(f"Error procesando {item.path}: {e}")
self.msg_queue.put(("UPDATE_ITEM_DATA", item.id, {"error": True}))
def _update_item_ui_data(self, item_id, data):
"""Actualiza metadatos (texto/icono) tras la carga."""
widgets = self._widget_refs.get(item_id)
item = next((x for x in self.queue_items if x.id == item_id), None)
if not widgets or not item:
return
if "error" in data:
item.has_error = True
widgets['lbl_info'].configure(text="⚠️ Error de lectura", text_color=Theme.ERROR)
widgets['card'].configure(border_color=Theme.ERROR)
return
# Actualizar datos del objeto
if "info" in data:
item.info_text = data["info"]
widgets['lbl_info'].configure(text=data["info"])
if "img_pil" in data and data["img_pil"]:
# Crear CTkImage en el hilo principal (obligatorio por Tkinter)
ctk_img = ctk.CTkImage(data["img_pil"], size=data["img_pil"].size)
item.thumbnail = ctk_img
widgets['lbl_icon'].configure(image=ctk_img, text="")
item.is_loaded = True
# --- RENDERIZADO VISUAL ---
def _render_item(self, item: QueueItem, index: int):
"""Dibuja la tarjeta del archivo con soporte para barra de progreso de fondo."""
row_idx = index + 1
# 1. Frame contenedor (Tarjeta)
card = ctk.CTkFrame(self, fg_color=Theme.ITEM_BG, corner_radius=8,
border_width=1, border_color=Theme.BORDER_COLOR)
card.grid(row=row_idx, column=0, sticky="ew", padx=10, pady=5)
card.grid_columnconfigure(1, weight=1)
# 2. Barra de Progreso (Fondo dinámico)
# Se coloca con .place() para estar "detrás" del contenido del grid,
# pero para que funcione, los widgets encima deben tener fg_color="transparent".
progress_bar = ctk.CTkFrame(card, fg_color=Theme.PROGRESS_BG, corner_radius=8, height=0)
# Inicialmente ancho 0. relheight=1 ocupa toda la altura de la tarjeta.
progress_bar.place(relx=0, rely=0, relheight=1, relwidth=item.progress)
# 3. Contenido (Grid)
# Icono / Thumbnail
lbl_icon = ctk.CTkLabel(card, text="⏳", width=64, height=64,
fg_color="#222", corner_radius=6)
lbl_icon.grid(row=0, column=0, rowspan=2, padx=8, pady=8)
if item.thumbnail:
lbl_icon.configure(image=item.thumbnail, text="")
elif item.is_completed:
lbl_icon.configure(text="✓")
# Nombre
lbl_name = ctk.CTkLabel(card, text=item.name, font=Theme.FONT_MAIN,
text_color=Theme.TEXT_MAIN, anchor="w", fg_color="transparent")
lbl_name.grid(row=0, column=1, sticky="w", padx=5, pady=(8, 0))
# Info técnica
lbl_info = ctk.CTkLabel(card, text=item.info_text, font=Theme.FONT_MONO,
text_color=Theme.TEXT_SEC, justify="left", anchor="w", fg_color="transparent")
lbl_info.grid(row=1, column=1, sticky="w", padx=5, pady=(0, 8))
# 4. Controles (Botones)
ctrl_frame = ctk.CTkFrame(card, fg_color="transparent")
ctrl_frame.grid(row=0, column=2, rowspan=2, padx=8, sticky="e")
# Botón Subir
btn_up = ctk.CTkButton(ctrl_frame, text="▲", width=28, height=28,
fg_color="transparent", border_width=1, border_color=Theme.TEXT_SEC,
hover_color=Theme.BTN_HOVER_NORMAL, text_color=Theme.TEXT_MAIN,
command=lambda: self.move_up(item))
btn_up.pack(side="left", padx=2)
# Botón Eliminar
btn_del = ctk.CTkButton(ctrl_frame, text="✕", width=28, height=28,
fg_color="transparent", border_width=1, border_color=Theme.ERROR,
text_color=Theme.ERROR, hover_color=Theme.BTN_HOVER_DANGER,
font=("Arial", 12, "bold"),
command=lambda: self.remove_item(item))
btn_del.pack(side="left", padx=2)
# 5. Guardar referencias
self._widget_refs[item.id] = {
"card": card,
"progress_bar": progress_bar,
"lbl_info": lbl_info,
"lbl_name": lbl_name,
"lbl_icon": lbl_icon,
"btn_del": btn_del,
"btn_up": btn_up
}
# Aplicar estado visual si ya estaba completado (útil al redibujar)
if item.is_completed:
self._apply_completed_style(item.id)
# Renderizar encabezado si es el primero
if index == 0:
self._render_header()
def _render_header(self):
"""Dibuja el botón de limpiar todo."""
for w in self.grid_slaves(row=0):
w.destroy()
if self.queue_items:
header_frame = ctk.CTkFrame(self, fg_color="transparent")
header_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=(5, 0))
ctk.CTkLabel(header_frame, text=f"Queue: {len(self.queue_items)} Files",
font=Theme.FONT_SUB, text_color=Theme.TEXT_SEC).pack(side="left")
ctk.CTkButton(header_frame, text="CLEAN ALL", image=self.clear_icon,
font=Theme.FONT_SUB, height=24, width=100,
fg_color="transparent", border_width=1, border_color=Theme.ACCENT, text_color=Theme.ACCENT,
hover_color=Theme.BTN_HOVER_NORMAL,
command=self.clean_file_list).pack(side="right")
def _update_layout_indices(self):
"""Redibuja toda la lista para mantener orden visual."""
for widget in self.winfo_children():
widget.destroy()
self._widget_refs.clear()
if self.queue_items:
self._render_header()
for i, item in enumerate(self.queue_items):
self._render_item(item, i)
else:
if self.on_queue_empty_callback:
self.on_queue_empty_callback()
# --- API PÚBLICA PARA ACTUALIZACIÓN DE PROGRESO (Desde Warlock-Studio) ---
def update_file_progress_by_path(self, file_path: str, progress: float):
"""
Actualiza la barra de progreso de un archivo específico.
:param file_path: Ruta del archivo.
:param progress: Float de 0.0 a 100.0 (o 0.0 a 1.0).
"""
# Normalizar path
norm_path = os.path.normpath(file_path)
item_id = self._path_to_id.get(norm_path)
if item_id:
# Normalizar a rango 0.0 - 1.0
if progress > 1.0:
progress = progress / 100.0
# Enviar a la cola del hilo principal
self.msg_queue.put(("UPDATE_PROGRESS", item_id, {"progress": progress}))
def mark_file_processing(self, file_path: str):
"""Marca visualmente que el archivo se está procesando actualmente."""
norm_path = os.path.normpath(file_path)
item_id = self._path_to_id.get(norm_path)
if item_id:
self.msg_queue.put(("UPDATE_PROGRESS", item_id, {"status": "processing"}))
def mark_file_completed(self, file_path: str):
"""Marca el archivo como completado (barra llena, checkmark)."""
norm_path = os.path.normpath(file_path)
item_id = self._path_to_id.get(norm_path)
if item_id:
self.msg_queue.put(("UPDATE_PROGRESS", item_id, {"status": "completed"}))
def mark_file_error(self, file_path: str):
"""Marca el archivo con error."""
norm_path = os.path.normpath(file_path)
item_id = self._path_to_id.get(norm_path)
if item_id:
self.msg_queue.put(("UPDATE_PROGRESS", item_id, {"status": "error"}))
# --- LÓGICA INTERNA DE ACTUALIZACIÓN VISUAL ---
def _render_progress_update(self, item_id, data):
"""Ejecutado por _check_queue en el hilo principal."""
widgets = self._widget_refs.get(item_id)
item = next((x for x in self.queue_items if x.id == item_id), None)
if not widgets or not item:
return
# Actualizar barra de progreso
if "progress" in data:
prog = max(0.0, min(1.0, data["progress"]))
item.progress = prog
# Actualizamos ancho relativo (0 a 1)
widgets['progress_bar'].place(relwidth=prog)
# Actualizar Estados
if "status" in data:
status = data["status"]
if status == "processing":
widgets['card'].configure(border_color=Theme.PROCESSING_BORDER, border_width=2)
# Asegurar que la barra sea visible
widgets['progress_bar'].place(relwidth=max(0.05, item.progress))
elif status == "completed":
item.progress = 1.0
item.is_completed = True
self._apply_completed_style(item_id)
elif status == "error":
item.has_error = True
widgets['card'].configure(border_color=Theme.ERROR)
widgets['lbl_info'].configure(text_color=Theme.ERROR)
def _apply_completed_style(self, item_id):
"""Aplica el estilo 'Completado' a los widgets."""
widgets = self._widget_refs.get(item_id)
if not widgets:
return
# Barra llena en verde oscuro
widgets['progress_bar'].configure(fg_color=Theme.COMPLETED_BG)
widgets['progress_bar'].place(relwidth=1.0)
# Borde normal
widgets['card'].configure(border_color=Theme.COMPLETED_BG, border_width=1)
# Icono Check
widgets['lbl_icon'].configure(image=None, text="✓", font=("Arial", 24, "bold"), text_color="#FFF")
# Texto atenuado
widgets['lbl_name'].configure(text_color=Theme.TEXT_DONE)
# Deshabilitar controles
widgets['btn_up'].configure(state="disabled")
# --- GESTIÓN DE LISTA ---
def move_up(self, item: QueueItem):
try:
idx = self.queue_items.index(item)
if idx > 0:
self.queue_items[idx], self.queue_items[idx - 1] = \
self.queue_items[idx - 1], self.queue_items[idx]
self._update_layout_indices()
except ValueError:
pass
def remove_item(self, item: QueueItem):
if item in self.queue_items:
self.queue_items.remove(item)
if item.id in self._widget_refs:
del self._widget_refs[item.id]
# Limpiar mapa de paths
if item.path in self._path_to_id:
del self._path_to_id[item.path]
self._update_layout_indices()
def clean_file_list(self):
self.queue_items.clear()
self._widget_refs.clear()
self._path_to_id.clear()
self._update_layout_indices()
def get_selected_file_list(self) -> List[str]:
return [item.path for item in self.queue_items if not item.has_error]
def regenerate_all_info(self):
"""Recalcula info para actualizaciones masivas de configuración."""
for item in self.queue_items:
if not item.has_error and item.is_loaded and not item.is_completed:
self.executor.submit(self._worker_load_info, item)
# --- SETTERS ---
def set_upscale_factor(self, f):
self.upscale_factor = f
def set_input_resize_factor(self, f):
self.input_resize_factor = f
def set_output_resize_factor(self, f):
self.output_resize_factor = f
# --- UTILIDADES DE IMAGEN/VIDEO ---
def _extract_file_data(self, file_path) -> Tuple[str, Optional[Image.Image]]:
"""Extrae info y thumbnail. Seguro para threads."""
info_str = "Info no disponible"
pil_image = None
try:
exts_video = ['.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm']
is_video = any(file_path.lower().endswith(ext) for ext in exts_video)
width, height = 0, 0
if is_video:
cap = cv2.VideoCapture(file_path)
if cap.isOpened():
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
fps = cap.get(cv2.CAP_PROP_FPS)
# Thumbnail del frame central
try:
if frames > 100:
cap.set(cv2.CAP_PROP_POS_FRAMES, min(frames//2, 50))
ret, frame = cap.read()
if ret:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
pil_image = self._process_thumbnail(frame)
except:
pass
dur = frames/fps if fps > 0 else 0
m, s = divmod(int(dur), 60)
info_str = f"Video: {m}m:{s:02d}s • {frames} frames • {width}x{height}\n"
cap.release()
else:
img_array = np.fromfile(file_path, np.uint8)
img = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
if img is not None:
height, width = img.shape[:2]
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
pil_image = self._process_thumbnail(img)
info_str = f"Image: {width}x{height}\n"
if width > 0 and height > 0:
info_str += self._calculate_resize_text(width, height)
except Exception as e:
logging.error(f"Error interno extract: {e}")
raise e
return info_str, pil_image
def _process_thumbnail(self, img_array) -> Image.Image:
h, w = img_array.shape[:2]
target_size = 64
scale = target_size / max(h, w)
new_w, new_h = int(w * scale), int(h * scale)
resized = cv2.resize(img_array, (new_w, new_h), interpolation=cv2.INTER_AREA)
return Image.fromarray(resized)
def _calculate_resize_text(self, w, h) -> str:
if self.upscale_factor <= 0:
return ""
iw = int(w * (self.input_resize_factor/100)) if self.input_resize_factor else w
ih = int(h * (self.input_resize_factor/100)) if self.input_resize_factor else h
uw, uh = int(iw * self.upscale_factor), int(ih * self.upscale_factor)
ow = int(uw * (self.output_resize_factor/100)) if self.output_resize_factor else uw
oh = int(uh * (self.output_resize_factor/100)) if self.output_resize_factor else uh
txt = ""
if self.input_resize_factor:
txt += f"In: {int(self.input_resize_factor)}% ➜ {iw}x{ih}\n"
txt += f"AI x{self.upscale_factor}{uw}x{uh}"
if self.output_resize_factor:
txt += f"\nOut: {int(self.output_resize_factor)}% ➜ {ow}x{oh}"
return txt