feat(v5.1): Asynchronous architecture, Preferences redesign, and RIFE padding fix.
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
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 un poco más oscuro
|
||||
ITEM_BG = "#3A3A3A" # Fondo de cada tarjeta
|
||||
TEXT_MAIN = "#FFFFFF"
|
||||
TEXT_SEC = "#FDEF2F"
|
||||
ACCENT = "#F7F7F7" # Dorado original
|
||||
ERROR = "#CF6679" # Rojo suave para errores
|
||||
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 para debug
|
||||
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
|
||||
self.is_loaded = False
|
||||
self.has_error = False
|
||||
self.id = id(self) # Identificador único para referenciar widgets
|
||||
|
||||
|
||||
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
|
||||
self._widget_refs: Dict[int, dict] = {}
|
||||
|
||||
# Factores de redimensionado
|
||||
self.upscale_factor = 1
|
||||
self.input_resize_factor = 0
|
||||
self.output_resize_factor = 0
|
||||
|
||||
# Sistema de hilos para no congelar la GUI
|
||||
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 de mensajes (para actualizaciones desde hilos)
|
||||
self._check_queue()
|
||||
|
||||
def _check_queue(self):
|
||||
"""Revisa la cola de mensajes para actualizar la UI desde el hilo principal."""
|
||||
try:
|
||||
while True:
|
||||
task_type, item_id, data = self.msg_queue.get_nowait()
|
||||
if task_type == "UPDATE_ITEM":
|
||||
self._update_item_ui(item_id, data)
|
||||
except queue.Empty:
|
||||
pass
|
||||
finally:
|
||||
self.after(100, self._check_queue)
|
||||
|
||||
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)
|
||||
added_any = True
|
||||
|
||||
# Renderizar placeholder inmediatamente
|
||||
self._render_item(new_item, index=len(self.queue_items)-1)
|
||||
|
||||
# Lanzar tarea pesada en background
|
||||
self.executor.submit(self._worker_load_info, new_item)
|
||||
|
||||
if added_any:
|
||||
self._update_layout_indices()
|
||||
|
||||
def _worker_load_info(self, item: QueueItem):
|
||||
"""Método que corre en otro hilo (Thread) para procesar OpenCV."""
|
||||
try:
|
||||
info_text, pil_img = self._extract_file_data(item.path)
|
||||
|
||||
# Crear CTkImage debe hacerse preferiblemente al volver al main,
|
||||
# pero PIL Image es seguro pasarlo.
|
||||
item.info_text = info_text
|
||||
item.is_loaded = True
|
||||
|
||||
# Enviamos resultados al hilo principal
|
||||
self.msg_queue.put(
|
||||
("UPDATE_ITEM", item.id, {"info": info_text, "img": pil_img}))
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error procesando {item.path}: {e}")
|
||||
item.has_error = True
|
||||
item.info_text = "Error al leer archivo"
|
||||
self.msg_queue.put(("UPDATE_ITEM", item.id, {"error": True}))
|
||||
|
||||
def _update_item_ui(self, item_id, data):
|
||||
"""Actualiza un widget específico una vez que el hilo terminó."""
|
||||
widgets = self._widget_refs.get(item_id)
|
||||
if not widgets:
|
||||
return
|
||||
|
||||
if "error" in data:
|
||||
widgets['lbl_info'].configure(
|
||||
text="⚠️ Archivo corrupto o ilegible", text_color=Theme.ERROR)
|
||||
return
|
||||
|
||||
# Actualizar texto
|
||||
widgets['lbl_info'].configure(text=data["info"])
|
||||
|
||||
# Actualizar imagen si existe
|
||||
if data["img"]:
|
||||
ctk_img = ctk.CTkImage(data["img"], size=data["img"].size)
|
||||
# Guardamos referencia en el objeto item para que no se pierda
|
||||
item = next((x for x in self.queue_items if x.id == item_id), None)
|
||||
if item:
|
||||
item.thumbnail = ctk_img
|
||||
widgets['lbl_icon'].configure(
|
||||
image=ctk_img, text="") # Quitar texto placeholder
|
||||
|
||||
def _render_item(self, item: QueueItem, index: int):
|
||||
"""Dibuja una tarjeta para el archivo."""
|
||||
row_idx = index + 1 # +1 para dejar espacio al botón Clean arriba si fuera necesario
|
||||
|
||||
# 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)
|
||||
|
||||
# 1. 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)
|
||||
|
||||
# Si ya teníamos la imagen (ej: reordenando lista), la ponemos directo
|
||||
if item.thumbnail:
|
||||
lbl_icon.configure(image=item.thumbnail, text="")
|
||||
|
||||
# 2. Información
|
||||
lbl_name = ctk.CTkLabel(
|
||||
card, text=item.name, font=Theme.FONT_MAIN, text_color=Theme.ACCENT, anchor="w")
|
||||
lbl_name.grid(row=0, column=1, sticky="w", padx=5, pady=(8, 0))
|
||||
|
||||
lbl_info = ctk.CTkLabel(card, text=item.info_text, font=Theme.FONT_MONO,
|
||||
text_color=Theme.TEXT_SEC, justify="left", anchor="w")
|
||||
lbl_info.grid(row=1, column=1, sticky="w", padx=5, pady=(0, 8))
|
||||
|
||||
# 3. Controles (Frame derecho)
|
||||
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,
|
||||
font=("Arial", 12),
|
||||
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)
|
||||
|
||||
# Guardar referencias para actualizaciones asíncronas
|
||||
self._widget_refs[item.id] = {
|
||||
"card": card,
|
||||
"lbl_info": lbl_info,
|
||||
"lbl_icon": lbl_icon,
|
||||
"btn_up": btn_up # Guardamos por si queremos deshabilitar el primero
|
||||
}
|
||||
|
||||
# Renderizar encabezado si es el primer elemento
|
||||
if index == 0:
|
||||
self._render_header()
|
||||
|
||||
def _render_header(self):
|
||||
"""Dibuja el botón de limpiar todo si hay elementos."""
|
||||
# Limpiar header anterior si existe
|
||||
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):
|
||||
"""Re-dibuja toda la lista. Útil para reordenar o borrar."""
|
||||
# Nota: En una app muy grande esto sería ineficiente, pero para <100 archivos está bien
|
||||
# destruir y recrear widgets garantiza orden visual correcto.
|
||||
|
||||
# Limpiar UI actual
|
||||
for widget in self.winfo_children():
|
||||
widget.destroy()
|
||||
|
||||
self._widget_refs.clear()
|
||||
|
||||
# Redibujar
|
||||
if self.queue_items:
|
||||
self._render_header()
|
||||
for i, item in enumerate(self.queue_items):
|
||||
self._render_item(item, i)
|
||||
else:
|
||||
# Lista vacía
|
||||
if self.on_queue_empty_callback:
|
||||
self.on_queue_empty_callback()
|
||||
|
||||
# --- LÓGICA DE DATOS ---
|
||||
|
||||
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)
|
||||
# Eliminar referencias para liberar memoria
|
||||
if item.id in self._widget_refs:
|
||||
del self._widget_refs[item.id]
|
||||
self._update_layout_indices()
|
||||
|
||||
def clean_file_list(self):
|
||||
self.queue_items.clear()
|
||||
self._widget_refs.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 el texto de redimensionado sin recargar imágenes."""
|
||||
for item in self.queue_items:
|
||||
if not item.has_error and item.is_loaded:
|
||||
# Recalculamos solo el texto basado en lo que ya sabemos (o recargamos dims rápidas)
|
||||
# Para optimizar, asumimos que podemos volver a lanzar el worker o
|
||||
# simplemente recalcular si guardáramos width/height en el objeto.
|
||||
# Por simplicidad y robustez, relanzamos el worker ligero.
|
||||
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 ESTÁTICAS (Lógica pura) ---
|
||||
|
||||
def _extract_file_data(self, file_path) -> Tuple[str, Optional[Image.Image]]:
|
||||
"""Extrae info y genera thumbnail PIL (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)
|
||||
|
||||
# Leer frame central para thumbnail
|
||||
try:
|
||||
if frames > 100:
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, 50)
|
||||
ret, frame = cap.read()
|
||||
if ret:
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
pil_image = self._process_thumbnail(frame)
|
||||
except:
|
||||
pass # Fallo silencioso solo en thumbnail
|
||||
|
||||
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:
|
||||
# Imagen
|
||||
# cv2.imdecode es mejor para rutas con caracteres especiales en Windows
|
||||
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"
|
||||
|
||||
# Añadir datos de redimensionado calculado
|
||||
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:
|
||||
"""Redimensiona y recorta imagen para thumbnail."""
|
||||
h, w = img_array.shape[:2]
|
||||
target_size = 64
|
||||
|
||||
# Mantener aspect ratio dentro de 64x64
|
||||
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:
|
||||
"""Lógica pura de cálculo de dimensiones."""
|
||||
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"Input AI ({int(self.input_resize_factor)}%) ➜ {iw}x{ih}\n"
|
||||
|
||||
txt += f"Output AI (x{self.upscale_factor}) ➜ {uw}x{uh}"
|
||||
|
||||
if self.output_resize_factor:
|
||||
txt += f"\nFinal Output ({int(self.output_resize_factor)}%) ➜ {ow}x{oh}"
|
||||
|
||||
return txt
|
||||
Reference in New Issue
Block a user