6071 lines
228 KiB
Python
6071 lines
228 KiB
Python
# Standard library imports
|
|
import atexit
|
|
import gc
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import traceback
|
|
import warnings
|
|
from contextlib import contextmanager
|
|
from datetime import datetime
|
|
from functools import cache
|
|
from json import JSONDecodeError
|
|
from json import dumps as json_dumps
|
|
from json import load as json_load
|
|
from math import cos, pi
|
|
from multiprocessing import Process
|
|
from multiprocessing import Queue as multiprocessing_Queue
|
|
from multiprocessing import freeze_support
|
|
from multiprocessing.pool import ThreadPool
|
|
from os import cpu_count as os_cpu_count
|
|
from os import devnull as os_devnull
|
|
from os import listdir as os_listdir
|
|
from os import makedirs as os_makedirs
|
|
from os import remove as os_remove
|
|
from os import sep as os_separator
|
|
from os.path import abspath as os_path_abspath
|
|
from os.path import basename as os_path_basename
|
|
from os.path import dirname as os_path_dirname
|
|
from os.path import exists as os_path_exists
|
|
from os.path import expanduser as os_path_expanduser
|
|
from os.path import getsize as os_path_getsize
|
|
from os.path import join as os_path_join
|
|
from os.path import splitext as os_path_splitext
|
|
from shutil import copy2
|
|
from shutil import move as shutil_move
|
|
from shutil import rmtree as remove_directory
|
|
from subprocess import CalledProcessError
|
|
from subprocess import run as subprocess_run
|
|
from threading import Event, Lock, Thread
|
|
from time import sleep
|
|
from timeit import default_timer as timer
|
|
from tkinter import DISABLED, StringVar, messagebox
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
from webbrowser import open as open_browser
|
|
|
|
import customtkinter as ctk
|
|
import cv2
|
|
# ONNX Runtime imports
|
|
import onnxruntime
|
|
# GUI imports (CustomTkinter & TkinterDnD)
|
|
from customtkinter import (CTk, CTkButton, CTkEntry, CTkFont, CTkFrame,
|
|
CTkImage, CTkLabel, CTkOptionMenu, CTkProgressBar,
|
|
CTkScrollableFrame, CTkToplevel, filedialog,
|
|
set_appearance_mode, set_default_color_theme)
|
|
# OpenCV imports
|
|
from cv2 import (CAP_PROP_FPS, CAP_PROP_FRAME_COUNT, CAP_PROP_FRAME_HEIGHT,
|
|
CAP_PROP_FRAME_WIDTH, COLOR_BGR2RGB, COLOR_BGR2RGBA,
|
|
COLOR_BGRA2BGR, COLOR_GRAY2RGB, COLOR_RGB2GRAY,
|
|
IMREAD_UNCHANGED, INTER_AREA, INTER_CUBIC)
|
|
from cv2 import VideoCapture as opencv_VideoCapture
|
|
from cv2 import addWeighted as opencv_addWeighted
|
|
from cv2 import cvtColor as opencv_cvtColor
|
|
from cv2 import imdecode as opencv_imdecode
|
|
from cv2 import imencode as opencv_imencode
|
|
from cv2 import imread as image_read
|
|
from cv2 import resize as opencv_resize
|
|
# MoviePy imports
|
|
from moviepy.video.io import ImageSequenceClip
|
|
# Third-party library imports
|
|
from natsort import natsorted
|
|
# NumPy imports
|
|
from numpy import ascontiguousarray as numpy_ascontiguousarray
|
|
from numpy import clip as numpy_clip
|
|
from numpy import concatenate as numpy_concatenate
|
|
from numpy import expand_dims as numpy_expand_dims
|
|
from numpy import float16, float32
|
|
from numpy import frombuffer as numpy_frombuffer
|
|
from numpy import full as numpy_full
|
|
from numpy import max as numpy_max
|
|
from numpy import mean as numpy_mean
|
|
from numpy import min as numpy_min
|
|
from numpy import ndarray as numpy_ndarray
|
|
from numpy import repeat as numpy_repeat
|
|
from numpy import squeeze as numpy_squeeze
|
|
from numpy import stack as numpy_stack
|
|
from numpy import transpose as numpy_transpose
|
|
from numpy import uint8
|
|
from numpy import zeros as numpy_zeros
|
|
from onnxruntime import InferenceSession
|
|
# Necesitarás PIL para cargar el icono de limpieza que pide el constructor
|
|
from PIL import Image
|
|
from PIL.Image import fromarray as pillow_image_fromarray
|
|
from PIL.Image import open as pillow_image_open
|
|
from tkinterdnd2 import DND_ALL, TkinterDnD
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# CONFIGURATION & CONSTANTS
|
|
# ----------------------------------------------------------------------------
|
|
# -----------------------------------------------------------------------------
|
|
# INITIALIZE CONSOLE REDIRECTION EARLY
|
|
# -----------------------------------------------------------------------------
|
|
from console import IntegratedConsole, console
|
|
# Local imports
|
|
from drag_drop import DnDCTk, enable_drag_and_drop
|
|
# Importa la clase de tu archivo (asumiendo que se llama file_queue_manager.py)
|
|
from file_queue_manager import FileQueueManager
|
|
from warlock_preferences import PreferencesButton # Importación local
|
|
|
|
# Redirigir inmediatamente para capturar logs de importación
|
|
console.setup_redirection()
|
|
|
|
# Suppress specific warnings to keep console clean
|
|
warnings.filterwarnings("ignore", category=UserWarning)
|
|
|
|
# Handle PyInstaller or Normal Execution Path
|
|
|
|
|
|
def find_by_relative_path(relative_path: str) -> str:
|
|
"""
|
|
Resuelve rutas absolutas para recursos, funcionando tanto en desarrollo
|
|
como cuando el script está empaquetado con PyInstaller (--onefile).
|
|
"""
|
|
base_path = getattr(sys, '_MEIPASS', os_path_dirname(
|
|
os_path_abspath(__file__)))
|
|
return os_path_join(base_path, relative_path)
|
|
|
|
|
|
app_name = "Warlock-Studio"
|
|
version = "5.1"
|
|
|
|
# Supported File Extensions
|
|
supported_image_extensions = [".jpg", ".jpeg",
|
|
".png", ".bmp", ".tiff", ".tif", ".webp"]
|
|
supported_video_extensions = [".mp4", ".avi",
|
|
".mkv", ".mov", ".wmv", ".flv", ".webm"]
|
|
supported_file_extensions = supported_image_extensions + supported_video_extensions
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# THEME & COLORS
|
|
# -----------------------------------------------------------------------------
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# THEME & COLORS
|
|
# -----------------------------------------------------------------------------
|
|
|
|
# Fondo: Negro casi puro, igual que el fondo del banner para máximo contraste
|
|
background_color = "#0A0A0A"
|
|
# Nombre de la app: Plata metálico, inspirado en el texto "STUDIO"
|
|
app_name_color = "#FAF600"
|
|
# Paneles: Gris oscuro neutro, permite que el rojo y dorado resalten sin competir
|
|
widget_background_color = "#303030"
|
|
# Texto principal: Blanco puro para legibilidad máxima
|
|
text_color = "#FFFFFF"
|
|
# Texto secundario: Dorado pálido/desaturado, para no cansar la vista pero mantener la identidad
|
|
secondary_text_color = "#B5B4B4"
|
|
# Acento: El amarillo dorado brillante del sombrero y los destellos (Sparkles)
|
|
accent_color = "#FDEF2F"
|
|
# Hover de botones: El rojo vibrante del relleno del texto "WARLOCK"
|
|
button_hover_color = "#D41C1C"
|
|
# Bordes: Un dorado oscuro muy sutil, imitando el borde del logo sin ser chillón
|
|
border_color = "#E2340D"
|
|
# Botones info/secundarios: El rojo sangre oscuro del fondo del círculo del logo
|
|
info_button_color = "#770000"
|
|
# Advertencias: Naranja dorado, sacado del sombreado del sombrero
|
|
warning_color = "#FFA000"
|
|
# Éxito: Verde brillante, necesario para contraste funcional
|
|
success_color = "#00E676"
|
|
# Error: Rojo carmesí intenso, similar al borde de las letras "WARLOCK"
|
|
error_color = "#81091F"
|
|
# Resaltado: Amarillo luz, como el centro de los destellos (estrellas)
|
|
highlight_color = "#FFFF8D"
|
|
# Scrollbars: Rojo vino oscuro translúcido, para mantener la temática sin distraer
|
|
scrollbar_color = "#420505"
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# AI MODEL LISTS & CONFIGURATION
|
|
# -----------------------------------------------------------------------------
|
|
|
|
VRAM_model_usage = {
|
|
'RealESR_Gx4': 2.2,
|
|
'RealESR_Animex4': 2.2,
|
|
'RealESRNetx4': 2.2,
|
|
'BSRGANx4': 0.6,
|
|
'BSRGANx2': 0.7,
|
|
'RealESRGANx4': 0.6,
|
|
'IRCNN_Mx1': 4,
|
|
'IRCNN_Lx1': 4,
|
|
'GFPGAN': 1.8,
|
|
}
|
|
|
|
MENU_LIST_SEPARATOR = ["• • • • • • • • • • • •"]
|
|
SRVGGNetCompact_models_list = ["RealESR_Gx4", "RealESR_Animex4"]
|
|
BSRGAN_models_list = ["BSRGANx4", "BSRGANx2", "RealESRGANx4", "RealESRNetx4"]
|
|
IRCNN_models_list = ["IRCNN_Mx1", "IRCNN_Lx1"]
|
|
Face_restoration_models_list = ["GFPGAN"]
|
|
RIFE_models_list = ["RIFE", "RIFE_Lite"]
|
|
|
|
AI_models_list = (SRVGGNetCompact_models_list + MENU_LIST_SEPARATOR + BSRGAN_models_list +
|
|
MENU_LIST_SEPARATOR + IRCNN_models_list + MENU_LIST_SEPARATOR + Face_restoration_models_list +
|
|
MENU_LIST_SEPARATOR + RIFE_models_list)
|
|
|
|
frame_interpolation_models_list = RIFE_models_list
|
|
frame_generation_options_list = [
|
|
"x2", "x4", "x8", "Slowmotion x2", "Slowmotion x4", "Slowmotion x8"
|
|
]
|
|
AI_multithreading_list = ["OFF", "2 threads",
|
|
"4 threads", "6 threads", "8 threads"]
|
|
blending_list = ["OFF", "Low", "Medium", "High"]
|
|
gpus_list = ["Auto", "GPU 1", "GPU 2", "GPU 3", "GPU 4"]
|
|
keep_frames_list = ["OFF", "ON"]
|
|
image_extension_list = [".png", ".jpg", ".bmp", ".tiff"]
|
|
video_extension_list = [".mp4", ".mkv", ".avi", ".mov"]
|
|
video_codec_list = [
|
|
"x264", "x265", MENU_LIST_SEPARATOR[0],
|
|
"h264_nvenc", "hevc_nvenc", MENU_LIST_SEPARATOR[0],
|
|
"h264_amf", "hevc_amf", MENU_LIST_SEPARATOR[0],
|
|
"h264_qsv", "hevc_qsv",
|
|
]
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# PATHS & USER PREFERENCES
|
|
# -----------------------------------------------------------------------------
|
|
|
|
OUTPUT_PATH_CODED = "Same path as input files"
|
|
DOCUMENT_PATH = os_path_join(os_path_expanduser('~'), 'Documents')
|
|
USER_PREFERENCE_PATH = find_by_relative_path(
|
|
f"{DOCUMENT_PATH}{os_separator}{app_name}_{version}_UserPreference.json")
|
|
FFMPEG_EXE_PATH = find_by_relative_path(f"Assets{os_separator}ffmpeg.exe")
|
|
EXIFTOOL_EXE_PATH = find_by_relative_path(f"Assets{os_separator}exiftool.exe")
|
|
|
|
ECTRACTION_FRAMES_FOR_CPU = 30
|
|
MULTIPLE_FRAMES_TO_SAVE = 8
|
|
|
|
COMPLETED_STATUS = "Completed"
|
|
ERROR_STATUS = "Error"
|
|
STOP_STATUS = "Stop"
|
|
|
|
# Check External Tools
|
|
if os_path_exists(FFMPEG_EXE_PATH):
|
|
print(f"[{app_name}] ffmpeg.exe found")
|
|
else:
|
|
print(f"[{app_name}] WARNING: ffmpeg.exe not found. Video functionality will be limited.")
|
|
|
|
# Load User Preferences (with Error Handling)
|
|
# Default Values
|
|
default_AI_model = AI_models_list[0]
|
|
default_AI_multithreading = AI_multithreading_list[0]
|
|
default_gpu = gpus_list[0]
|
|
default_keep_frames = keep_frames_list[1]
|
|
default_image_extension = image_extension_list[0]
|
|
default_video_extension = video_extension_list[0]
|
|
default_video_codec = video_codec_list[0]
|
|
default_blending = blending_list[1]
|
|
default_output_path = OUTPUT_PATH_CODED
|
|
default_input_resize_factor = str(50)
|
|
default_output_resize_factor = str(100)
|
|
default_VRAM_limiter = str(4)
|
|
|
|
if os_path_exists(USER_PREFERENCE_PATH):
|
|
print(f"[{app_name}] Preference file exists")
|
|
try:
|
|
with open(USER_PREFERENCE_PATH, "r") as json_file:
|
|
json_data = json_load(json_file)
|
|
# Safe .get() calls to prevent KeyErrors if fields are missing in old config versions
|
|
default_AI_model = json_data.get(
|
|
"default_AI_model", default_AI_model)
|
|
default_AI_multithreading = json_data.get(
|
|
"default_AI_multithreading", default_AI_multithreading)
|
|
default_gpu = json_data.get("default_gpu", default_gpu)
|
|
default_keep_frames = json_data.get(
|
|
"default_keep_frames", default_keep_frames)
|
|
default_image_extension = json_data.get(
|
|
"default_image_extension", default_image_extension)
|
|
default_video_extension = json_data.get(
|
|
"default_video_extension", default_video_extension)
|
|
default_video_codec = json_data.get(
|
|
"default_video_codec", default_video_codec)
|
|
default_blending = json_data.get(
|
|
"default_blending", default_blending)
|
|
default_output_path = json_data.get(
|
|
"default_output_path", default_output_path)
|
|
default_input_resize_factor = json_data.get(
|
|
"default_input_resize_factor", default_input_resize_factor)
|
|
default_output_resize_factor = json_data.get(
|
|
"default_output_resize_factor", default_output_resize_factor)
|
|
default_VRAM_limiter = json_data.get(
|
|
"default_VRAM_limiter", default_VRAM_limiter)
|
|
except (JSONDecodeError, Exception) as e:
|
|
print(f"[{app_name}] Error reading preference file ({e}). Using defaults.")
|
|
else:
|
|
print(f"[{app_name}] Preference file does not exist, using default coded value")
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# GUI LAYOUT CONSTANTS
|
|
# -----------------------------------------------------------------------------
|
|
|
|
offset_y_options = 0.0825
|
|
row1 = 0.125
|
|
row2 = row1 + offset_y_options
|
|
row3 = row2 + offset_y_options
|
|
row4 = row3 + offset_y_options
|
|
row5 = row4 + offset_y_options
|
|
row6 = row5 + offset_y_options
|
|
row7 = row6 + offset_y_options
|
|
row8 = row7 + offset_y_options
|
|
row9 = row8 + offset_y_options
|
|
row10 = row9 + offset_y_options
|
|
|
|
column_offset = 0.2
|
|
column_info1 = 0.625
|
|
column_info2 = 0.858
|
|
column_1 = 0.66
|
|
column_2 = column_1 + column_offset
|
|
column_1_5 = column_info1 + 0.08
|
|
column_1_4 = column_1_5 - 0.0127
|
|
column_3 = column_info2 + 0.08
|
|
column_2_9 = column_3 - 0.0127
|
|
column_3_5 = column_2 + 0.0355
|
|
|
|
little_textbox_width = 74
|
|
little_menu_width = 98
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# ONNX SESSION HELPER
|
|
# -----------------------------------------------------------------------------
|
|
|
|
def create_onnx_session(model_path: str, selected_gpu: str) -> InferenceSession:
|
|
"""
|
|
Creates an ONNX inference session by selecting the best available provider.
|
|
Fixes: Correct type for device_id (int) and handles 'Auto' properly.
|
|
"""
|
|
if not os_path_exists(model_path):
|
|
raise FileNotFoundError(f"AI model file not found: {model_path}")
|
|
|
|
# Map the GUI selection to the numerical device_id (INTEGER)
|
|
# 'Auto' defaults to 0, but we handle it differently in logic
|
|
device_id_map = {'GPU 1': 0, 'GPU 2': 1, 'GPU 3': 2, 'GPU 4': 3}
|
|
|
|
target_device_id = device_id_map.get(selected_gpu, 0)
|
|
is_auto = selected_gpu == "Auto"
|
|
|
|
# Providers priority
|
|
providers_to_try = []
|
|
|
|
# 1. CUDA (NVIDIA)
|
|
cuda_options = {'device_id': target_device_id}
|
|
providers_to_try.append(('CUDAExecutionProvider', cuda_options))
|
|
|
|
# 2. DirectML (AMD/Intel/Windows)
|
|
# DirectML expects device_id as string in some versions, int in others.
|
|
# We use int standard here, usually works with modern ort-dml.
|
|
dml_options = {'device_id': target_device_id}
|
|
providers_to_try.append(('DmlExecutionProvider', dml_options))
|
|
|
|
# 3. CPU (Fallback)
|
|
providers_to_try.append(('CPUExecutionProvider', None))
|
|
|
|
available_providers = onnxruntime.get_available_providers()
|
|
|
|
for provider, options in providers_to_try:
|
|
if provider in available_providers:
|
|
try:
|
|
# Si es Auto y estamos en CUDA, intentamos sin forzar device_id
|
|
# a menos que sea explícitamente necesario, pero usualmente ID 0 es seguro.
|
|
# La corrección clave aquí es pasar options como diccionario, no lista de diccionario.
|
|
|
|
session_options = [options] if options else None
|
|
|
|
session = InferenceSession(
|
|
path_or_bytes=model_path,
|
|
providers=[provider],
|
|
provider_options=session_options
|
|
)
|
|
print(
|
|
f"[AI] Loaded model with provider: {provider} (Device ID: {target_device_id})")
|
|
return session
|
|
except Exception as e:
|
|
print(f"[AI WARNING] Failed to load {provider}: {e}")
|
|
continue
|
|
|
|
# Final fallback attempt without options (let ONNX decide)
|
|
try:
|
|
return InferenceSession(model_path, providers=['CPUExecutionProvider'])
|
|
except Exception as e:
|
|
raise RuntimeError(f"Critical: Failed to load AI model. Error: {e}")
|
|
|
|
# Enhanced Model Utilization and Error Handling
|
|
|
|
|
|
class AI_upscale:
|
|
|
|
# -------------------------------------------------------------------------
|
|
# CLASS INIT
|
|
# -------------------------------------------------------------------------
|
|
|
|
def __init__(
|
|
self,
|
|
AI_model_name: str,
|
|
directml_gpu: str,
|
|
input_resize_factor: int,
|
|
output_resize_factor: int,
|
|
max_resolution: int
|
|
):
|
|
# Parámetros recibidos
|
|
self.AI_model_name = AI_model_name
|
|
self.directml_gpu = directml_gpu
|
|
self.input_resize_factor = input_resize_factor
|
|
self.output_resize_factor = output_resize_factor
|
|
self.max_resolution = max_resolution
|
|
|
|
# Variables calculadas
|
|
self.AI_model_path = find_by_relative_path(
|
|
f"AI-onnx{os_separator}{self.AI_model_name}_fp16.onnx")
|
|
self.upscale_factor = self._get_upscale_factor()
|
|
|
|
# La sesión se carga bajo demanda o al iniciar, según la lógica del orquestador.
|
|
# Inicializamos en None para permitir una carga diferida si fuera necesario.
|
|
self.inferenceSession = None
|
|
|
|
def _get_upscale_factor(self) -> int:
|
|
"""Determina el factor de escala basado en el nombre del modelo."""
|
|
if "x1" in self.AI_model_name:
|
|
return 1
|
|
elif "x2" in self.AI_model_name:
|
|
return 2
|
|
elif "x4" in self.AI_model_name:
|
|
return 4
|
|
# Valor por defecto seguro
|
|
return 1
|
|
|
|
def _load_inferenceSession(self) -> None:
|
|
"""Carga la sesión de inferencia utilizando la función centralizada robusta."""
|
|
if self.inferenceSession is not None:
|
|
return
|
|
|
|
try:
|
|
self.inferenceSession = create_onnx_session(
|
|
self.AI_model_path, self.directml_gpu)
|
|
except Exception as e:
|
|
error_msg = f"Failed to load AI model {os_path_basename(self.AI_model_path)}: {str(e)}"
|
|
logging.error(f"[AI ERROR] {error_msg}")
|
|
raise RuntimeError(error_msg)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# IMAGE UTILS
|
|
# -------------------------------------------------------------------------
|
|
|
|
def get_image_mode(self, image: numpy_ndarray) -> str:
|
|
"""Determina el modo de la imagen (Grayscale, RGB, RGBA)."""
|
|
if image is None:
|
|
raise ValueError("Image is None")
|
|
|
|
# Validación de dimensiones
|
|
if image.ndim == 2:
|
|
return "Grayscale"
|
|
elif image.ndim == 3:
|
|
channels = image.shape[2]
|
|
if channels == 3:
|
|
return "RGB"
|
|
elif channels == 4:
|
|
return "RGBA"
|
|
elif channels == 1:
|
|
return "Grayscale"
|
|
|
|
raise ValueError(f"Unsupported image shape: {image.shape}")
|
|
|
|
def get_image_resolution(self, image: numpy_ndarray) -> tuple:
|
|
"""Retorna (alto, ancho)."""
|
|
height = image.shape[0]
|
|
width = image.shape[1]
|
|
return height, width
|
|
|
|
def calculate_target_resolution(self, image: numpy_ndarray) -> tuple:
|
|
"""Calcula la resolución esperada después del escalado por el modelo."""
|
|
height, width = self.get_image_resolution(image)
|
|
target_height = height * self.upscale_factor
|
|
target_width = width * self.upscale_factor
|
|
return target_height, target_width
|
|
|
|
def _ensure_even_dimensions(self, dim: int) -> int:
|
|
"""Asegura que una dimensión sea par (necesario para algunos modelos/codecs)."""
|
|
return dim if dim % 2 == 0 else dim + 1
|
|
|
|
def resize_with_input_factor(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
"""Redimensiona la imagen de entrada según el porcentaje configurado."""
|
|
old_height, old_width = self.get_image_resolution(image)
|
|
|
|
new_width = int(old_width * self.input_resize_factor)
|
|
new_height = int(old_height * self.input_resize_factor)
|
|
|
|
# Asegurar dimensiones mínimas y pares
|
|
new_width = max(2, self._ensure_even_dimensions(new_width))
|
|
new_height = max(2, self._ensure_even_dimensions(new_height))
|
|
|
|
if self.input_resize_factor == 1.0 and (new_width == old_width and new_height == old_height):
|
|
return image
|
|
|
|
interpolation = INTER_CUBIC if self.input_resize_factor > 1 else INTER_AREA
|
|
return opencv_resize(image, (new_width, new_height), interpolation=interpolation)
|
|
|
|
def resize_with_output_factor(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
"""Redimensiona la imagen de salida final según el porcentaje configurado."""
|
|
old_height, old_width = self.get_image_resolution(image)
|
|
|
|
new_width = int(old_width * self.output_resize_factor)
|
|
new_height = int(old_height * self.output_resize_factor)
|
|
|
|
# Asegurar dimensiones mínimas y pares
|
|
new_width = max(2, self._ensure_even_dimensions(new_width))
|
|
new_height = max(2, self._ensure_even_dimensions(new_height))
|
|
|
|
if self.output_resize_factor == 1.0 and (new_width == old_width and new_height == old_height):
|
|
return image
|
|
|
|
interpolation = INTER_CUBIC if self.output_resize_factor > 1 else INTER_AREA
|
|
return opencv_resize(image, (new_width, new_height), interpolation=interpolation)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# VIDEO UTILS
|
|
# -------------------------------------------------------------------------
|
|
|
|
def calculate_multiframes_supported_by_gpu(self, video_frame_path: str) -> int:
|
|
"""Calcula cuántos frames simultáneos caben en la VRAM basándose en max_resolution."""
|
|
try:
|
|
# Leer solo para obtener dimensiones, no decodificar todo si es muy grande
|
|
# Sin embargo, necesitamos aplicar el resize factor para ser precisos
|
|
frame = image_read(video_frame_path)
|
|
resized_frame = self.resize_with_input_factor(frame)
|
|
|
|
height, width = self.get_image_resolution(resized_frame)
|
|
image_pixels = height * width
|
|
|
|
# max_resolution se usa como lado de un cuadrado para estimar área soportada
|
|
max_supported_pixels = self.max_resolution * self.max_resolution
|
|
|
|
# Evitar división por cero
|
|
if image_pixels == 0:
|
|
return 1
|
|
|
|
frames_simultaneously = max_supported_pixels // image_pixels
|
|
|
|
# Limitar a un mínimo de 1 y un máximo seguro (ej. 8 o 16)
|
|
frames_simultaneously = max(1, min(frames_simultaneously, 16))
|
|
|
|
print(
|
|
f"[AI] Frames supported simultaneously by GPU estimate: {frames_simultaneously}")
|
|
return int(frames_simultaneously)
|
|
|
|
except Exception as e:
|
|
print(
|
|
f"[AI WARNING] Could not calculate multiframes support: {e}. Defaulting to 1.")
|
|
return 1
|
|
|
|
# -------------------------------------------------------------------------
|
|
# TILING (MOSAICO) FUNCTIONS
|
|
# -------------------------------------------------------------------------
|
|
|
|
def image_need_tilling(self, image: numpy_ndarray) -> bool:
|
|
height, width = self.get_image_resolution(image)
|
|
image_pixels = height * width
|
|
max_supported_pixels = self.max_resolution * self.max_resolution
|
|
return image_pixels > max_supported_pixels
|
|
|
|
def add_alpha_channel(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
"""Añade un canal alpha opaco (255) a una imagen RGB."""
|
|
if image.ndim == 3 and image.shape[2] == 3:
|
|
alpha = numpy_full(
|
|
(image.shape[0], image.shape[1], 1), 255, dtype=uint8)
|
|
return numpy_concatenate((image, alpha), axis=2)
|
|
return image
|
|
|
|
def calculate_tiles_number(self, image: numpy_ndarray) -> tuple:
|
|
height, width = self.get_image_resolution(image)
|
|
|
|
# Cálculo seguro de tiles redondeando hacia arriba
|
|
tiles_x = (width + self.max_resolution - 1) // self.max_resolution
|
|
tiles_y = (height + self.max_resolution - 1) // self.max_resolution
|
|
|
|
return tiles_x, tiles_y
|
|
|
|
def split_image_into_tiles(self, image: numpy_ndarray) -> list[tuple]:
|
|
"""
|
|
Divide la imagen guardando sus coordenadas originales.
|
|
Retorna: Lista de tuplas (tile_image, x, y)
|
|
"""
|
|
img_height, img_width = self.get_image_resolution(image)
|
|
|
|
tiles = []
|
|
# Calculamos los pasos asegurando que cubrimos toda la imagen
|
|
for y in range(0, img_height, self.max_resolution):
|
|
for x in range(0, img_width, self.max_resolution):
|
|
# Definir recorte asegurando no salirnos de la imagen
|
|
h_crop = min(self.max_resolution, img_height - y)
|
|
w_crop = min(self.max_resolution, img_width - x)
|
|
|
|
tile = image[y:y+h_crop, x:x+w_crop]
|
|
|
|
# Guardamos: (Imagen recortada, coordenada X original, coordenada Y original)
|
|
# Usamos copy() para asegurar que sea contiguo en memoria
|
|
tiles.append((tile.copy(), x, y))
|
|
|
|
return tiles
|
|
|
|
def combine_tiles_into_image(
|
|
self,
|
|
target_height: int,
|
|
target_width: int,
|
|
tiles_data: list[tuple],
|
|
output_channels: int
|
|
) -> numpy_ndarray:
|
|
"""
|
|
Recompone la imagen usando las coordenadas exactas escaladas.
|
|
"""
|
|
# Crear lienzo vacío
|
|
if output_channels == 1:
|
|
tiled_image = numpy_zeros(
|
|
(target_height, target_width), dtype=uint8)
|
|
else:
|
|
tiled_image = numpy_zeros(
|
|
(target_height, target_width, output_channels), dtype=uint8)
|
|
|
|
for tile, orig_x, orig_y in tiles_data:
|
|
# Calcular la nueva posición basada en el factor de escala
|
|
# Nota: Upscale factor puede ser float, así que convertimos a int
|
|
new_y = int(orig_y * self.upscale_factor)
|
|
new_x = int(orig_x * self.upscale_factor)
|
|
|
|
h, w = tile.shape[:2]
|
|
|
|
# Insertar tile en la posición calculada
|
|
# Verificamos límites por seguridad (aunque no debería pasar con matemáticas correctas)
|
|
end_y = min(new_y + h, target_height)
|
|
end_x = min(new_x + w, target_width)
|
|
|
|
# Ajustar recorte del tile si se sale del lienzo (clipping)
|
|
tile_h_crop = end_y - new_y
|
|
tile_w_crop = end_x - new_x
|
|
|
|
if output_channels > 1:
|
|
tiled_image[new_y:end_y, new_x:end_x,
|
|
:] = tile[:tile_h_crop, :tile_w_crop, :]
|
|
else:
|
|
tiled_image[new_y:end_y,
|
|
new_x:end_x] = tile[:tile_h_crop, :tile_w_crop]
|
|
|
|
return tiled_image
|
|
|
|
# -------------------------------------------------------------------------
|
|
# AI PROCESSING (PRE/INFERENCE/POST)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def normalize_image(self, image: numpy_ndarray) -> tuple:
|
|
"""Normaliza la imagen a rango 0-1 float32 de forma precisa."""
|
|
# Detección robusta de tipo
|
|
if image.dtype == uint8:
|
|
max_val = 255.0
|
|
elif image.dtype == numpy.uint16:
|
|
max_val = 65535.0
|
|
elif image.dtype == float32 or image.dtype == float16:
|
|
# Si ya es float, asumimos que podría estar en rango 0-1 o 0-255
|
|
# Verificación heurística simple
|
|
if numpy_max(image) > 1.0:
|
|
max_val = 255.0
|
|
else:
|
|
max_val = 1.0
|
|
else:
|
|
# Fallback seguro
|
|
max_val = 255.0
|
|
|
|
if max_val == 1.0:
|
|
return image.astype(float32), 1
|
|
|
|
normalized = image.astype(float32) / max_val
|
|
return normalized, int(max_val)
|
|
|
|
def preprocess_image(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
"""HWC (Height, Width, Channels) -> NCHW (Batch, Channels, Height, Width)."""
|
|
image = numpy_ascontiguousarray(image)
|
|
image = numpy_transpose(image, (2, 0, 1)) # HWC -> CHW
|
|
image = numpy_expand_dims(image, axis=0) # CHW -> NCHW
|
|
return image
|
|
|
|
def onnxruntime_inference(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
"""Ejecuta la inferencia ONNX."""
|
|
if self.inferenceSession is None:
|
|
self._load_inferenceSession()
|
|
|
|
input_name = self.inferenceSession.get_inputs()[0].name
|
|
onnx_input = {input_name: image}
|
|
|
|
# Ejecutar (run devuelve una lista, tomamos el primer output)
|
|
onnx_output = self.inferenceSession.run(None, onnx_input)[0]
|
|
return onnx_output
|
|
|
|
def postprocess_output(self, onnx_output: numpy_ndarray) -> numpy_ndarray:
|
|
"""NCHW -> HWC y Clip 0-1."""
|
|
onnx_output = numpy_squeeze(onnx_output, axis=0) # Remove batch dim
|
|
onnx_output = numpy_clip(onnx_output, 0, 1) # Ensure valid range
|
|
onnx_output = numpy_transpose(onnx_output, (1, 2, 0)) # CHW -> HWC
|
|
return onnx_output
|
|
|
|
def de_normalize_image(self, onnx_output: numpy_ndarray, max_range: int) -> numpy_ndarray:
|
|
"""Float 0-1 -> Uint8/16 0-MaxRange con redondeo bancario seguro."""
|
|
# Clip para evitar overflow por artefactos de IA
|
|
onnx_output = numpy_clip(onnx_output, 0.0, 1.0)
|
|
|
|
if max_range == 65535:
|
|
return (onnx_output * 65535.0).round().astype(numpy.uint16)
|
|
else:
|
|
# Default a 255 (uint8)
|
|
return (onnx_output * 255.0).round().astype(uint8)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# CORE UPSCALE LOGIC
|
|
# -------------------------------------------------------------------------
|
|
|
|
def AI_upscale(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
try:
|
|
if image is None or image.size == 0:
|
|
raise ValueError("Input image is empty")
|
|
|
|
image = numpy_ascontiguousarray(image)
|
|
height, width = image.shape[:2]
|
|
|
|
# --- MANEJO ROBUSTO DE CANALES (Fix 7) ---
|
|
|
|
# Caso 1: Escala de grises (2D) -> Convertir a 3D RGB
|
|
if image.ndim == 2:
|
|
image = opencv_cvtColor(image, COLOR_GRAY2RGB)
|
|
is_grayscale_output = True
|
|
has_alpha = False
|
|
|
|
# Caso 2: RGBA (4 Canales) -> Separar Alpha
|
|
elif image.ndim == 3 and image.shape[2] == 4:
|
|
channels_rgb = image[:, :, :3]
|
|
channel_alpha = image[:, :, 3]
|
|
image = channels_rgb # Procesaremos solo RGB con la IA
|
|
has_alpha = True
|
|
is_grayscale_output = False
|
|
|
|
# Caso 3: RGB Estándar
|
|
else:
|
|
has_alpha = False
|
|
is_grayscale_output = False
|
|
|
|
# --- INFERENCIA ---
|
|
# Normalizar RGB
|
|
image_norm, range_val = self.normalize_image(image)
|
|
processed_input = self.preprocess_image(image_norm)
|
|
|
|
# Ejecutar ONNX
|
|
onnx_output = self.onnxruntime_inference(processed_input)
|
|
post_output = self.postprocess_output(onnx_output)
|
|
|
|
# Desnormalizar
|
|
upscaled_rgb = self.de_normalize_image(post_output, range_val)
|
|
|
|
# --- RECONSTRUCCIÓN ---
|
|
|
|
# Si teníamos Alpha, lo escalamos por separado y lo unimos
|
|
if has_alpha:
|
|
target_h, target_w = upscaled_rgb.shape[:2]
|
|
# Usamos Lanczos o Cubic para el canal alpha (suavidad)
|
|
# Importante: cv2.resize usa (width, height)
|
|
upscaled_alpha = opencv_resize(
|
|
channel_alpha, (target_w,
|
|
target_h), interpolation=INTER_CUBIC
|
|
)
|
|
# Expandir dims si es necesario para concatenar
|
|
if upscaled_alpha.ndim == 2:
|
|
upscaled_alpha = numpy_expand_dims(upscaled_alpha, axis=2)
|
|
|
|
return numpy_concatenate((upscaled_rgb, upscaled_alpha), axis=2)
|
|
|
|
# Si era grayscale original, devolver grayscale
|
|
if is_grayscale_output:
|
|
return opencv_cvtColor(upscaled_rgb, COLOR_RGB2GRAY)
|
|
|
|
return upscaled_rgb
|
|
|
|
except Exception as e:
|
|
logging.error(f"AI Upscale Core Error: {str(e)}")
|
|
raise RuntimeError(f"Upscaling failed: {str(e)}")
|
|
try:
|
|
if image is None or image.size == 0:
|
|
raise ValueError("Input image is empty")
|
|
|
|
# Asegurar memoria contigua
|
|
image = numpy_ascontiguousarray(image)
|
|
image_mode = self.get_image_mode(image)
|
|
|
|
# Normalización inicial
|
|
image_norm, range_val = self.normalize_image(image)
|
|
|
|
if image_mode == "RGB":
|
|
processed_input = self.preprocess_image(image_norm)
|
|
onnx_output = self.onnxruntime_inference(processed_input)
|
|
post_output = self.postprocess_output(onnx_output)
|
|
return self.de_normalize_image(post_output, range_val)
|
|
|
|
elif image_mode == "Grayscale":
|
|
# Convertir a RGB para la IA, luego volver a gris
|
|
image_rgb = opencv_cvtColor(image, COLOR_GRAY2RGB)
|
|
image_rgb_norm, _ = self.normalize_image(image_rgb)
|
|
|
|
processed_input = self.preprocess_image(image_rgb_norm)
|
|
onnx_output = self.onnxruntime_inference(processed_input)
|
|
post_output = self.postprocess_output(onnx_output)
|
|
|
|
output_rgb = self.de_normalize_image(post_output, range_val)
|
|
return opencv_cvtColor(output_rgb, COLOR_RGB2GRAY)
|
|
|
|
elif image_mode == "RGBA":
|
|
# Estrategia Optimizada:
|
|
# 1. Separar RGB y Alpha
|
|
# 2. Escalar RGB con IA
|
|
# 3. Escalar Alpha con Bicubic (más rápido y evita artefactos de IA en máscaras)
|
|
|
|
# Separar canales (manteniendo uint8 original para alpha)
|
|
alpha_channel = image[:, :, 3]
|
|
rgb_channel = image[:, :, :3]
|
|
|
|
# Procesar RGB
|
|
# Llamada recursiva manejada como RGB simple para aprovechar la lógica existente
|
|
# Nota: Para evitar recursión infinita si hay bugs, usamos la lógica inline aquí
|
|
rgb_norm, r_val = self.normalize_image(rgb_channel)
|
|
rgb_input = self.preprocess_image(rgb_norm)
|
|
rgb_output_onnx = self.onnxruntime_inference(rgb_input)
|
|
rgb_output_post = self.postprocess_output(rgb_output_onnx)
|
|
upscaled_rgb = self.de_normalize_image(rgb_output_post, r_val)
|
|
|
|
# Procesar Alpha
|
|
# Calcular dimensiones objetivo basadas en la salida RGB
|
|
target_h, target_w = upscaled_rgb.shape[:2]
|
|
|
|
# Usar CUBIC para bordes suaves o AREA si fuera downscale (raro aquí)
|
|
interpolation = INTER_CUBIC if self.upscale_factor > 1 else INTER_AREA
|
|
upscaled_alpha = opencv_resize(
|
|
alpha_channel, (target_w, target_h), interpolation=interpolation)
|
|
|
|
# Asegurar dimensión extra para concatenar
|
|
if upscaled_alpha.ndim == 2:
|
|
upscaled_alpha = numpy_expand_dims(upscaled_alpha, axis=2)
|
|
|
|
# Recombinar
|
|
return numpy_concatenate((upscaled_rgb, upscaled_alpha), axis=2)
|
|
|
|
else:
|
|
raise ValueError(f"Unsupported image mode: {image_mode}")
|
|
|
|
except Exception as e:
|
|
logging.error(f"AI Upscale Core Error: {str(e)}")
|
|
raise RuntimeError(f"Upscaling failed: {str(e)}")
|
|
|
|
def AI_upscale_with_tilling(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
try:
|
|
target_h, target_w = self.calculate_target_resolution(image)
|
|
|
|
# 1. Obtener tiles con coordenadas
|
|
tiles_data = self.split_image_into_tiles(image)
|
|
processed_tiles = []
|
|
|
|
print(
|
|
f"[AI] Tiling enabled: processing {len(tiles_data)} tiles...")
|
|
|
|
# 2. Procesar cada tile
|
|
for tile_img, x, y in tiles_data:
|
|
upscaled_tile = self.AI_upscale(tile_img)
|
|
processed_tiles.append((upscaled_tile, x, y))
|
|
|
|
# Liberar memoria inmediata
|
|
del tile_img
|
|
|
|
del tiles_data
|
|
gc.collect()
|
|
|
|
# 3. Detectar canales de salida
|
|
sample = processed_tiles[0][0]
|
|
channels = sample.shape[2] if sample.ndim == 3 else 1
|
|
|
|
# 4. Recombinar con precisión
|
|
final_image = self.combine_tiles_into_image(
|
|
target_h, target_w, processed_tiles, channels
|
|
)
|
|
|
|
return final_image
|
|
|
|
except Exception as e:
|
|
logging.error(f"Tiling Error: {str(e)}")
|
|
raise RuntimeError(f"Tiled upscaling failed: {str(e)}")
|
|
|
|
# -------------------------------------------------------------------------
|
|
# ORCHESTRATION (PUBLIC API)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def AI_orchestration(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
"""Punto de entrada principal para procesar una imagen."""
|
|
if self.inferenceSession is None:
|
|
self._load_inferenceSession()
|
|
|
|
# 1. Redimensionar entrada (si aplica input % < 100)
|
|
try:
|
|
resized_input = self.resize_with_input_factor(image)
|
|
except Exception as e:
|
|
logging.warning(
|
|
f"Input resizing failed: {e}. Using original image.")
|
|
resized_input = image
|
|
|
|
# 2. Decidir si usar Tiling o Directo
|
|
# Si el modelo no escala (x1), el tiling se basa en tamaño puro.
|
|
# Si escala, hay que tener cuidado con la expansión de memoria.
|
|
if self.image_need_tilling(resized_input):
|
|
upscaled_image = self.AI_upscale_with_tilling(resized_input)
|
|
else:
|
|
upscaled_image = self.AI_upscale(resized_input)
|
|
|
|
# 3. Redimensionar salida (si aplica output % != 100)
|
|
try:
|
|
final_image = self.resize_with_output_factor(upscaled_image)
|
|
except Exception as e:
|
|
logging.warning(
|
|
f"Output resizing failed: {e}. Using upscaled image directly.")
|
|
final_image = upscaled_image
|
|
|
|
return final_image
|
|
|
|
|
|
class AI_interpolation:
|
|
|
|
# -------------------------------------------------------------------------
|
|
# CLASS INIT
|
|
# -------------------------------------------------------------------------
|
|
|
|
def __init__(
|
|
self,
|
|
AI_model_name: str,
|
|
frame_gen_factor: int,
|
|
directml_gpu: str,
|
|
input_resize_factor: int,
|
|
output_resize_factor: int,
|
|
):
|
|
# Passed variables
|
|
self.AI_model_name = AI_model_name
|
|
self.frame_gen_factor = frame_gen_factor
|
|
self.directml_gpu = directml_gpu
|
|
self.input_resize_factor = input_resize_factor
|
|
self.output_resize_factor = output_resize_factor
|
|
|
|
# Calculated variables
|
|
self.AI_model_path = find_by_relative_path(
|
|
f"AI-onnx{os_separator}{self.AI_model_name}_fp32.onnx")
|
|
|
|
# RIFE requiere múltiplos de 32 para evitar artefactos
|
|
self.divisor = 32
|
|
|
|
self.inferenceSession = self._load_inferenceSession()
|
|
|
|
def _load_inferenceSession(self) -> InferenceSession:
|
|
"""Carga la sesión de inferencia utilizando la función centralizada."""
|
|
try:
|
|
return create_onnx_session(self.AI_model_path, self.directml_gpu)
|
|
except Exception as e:
|
|
error_msg = f"Failed to load AI interpolation model {os_path_basename(self.AI_model_path)}: {str(e)}"
|
|
print(f"[AI ERROR] {error_msg}")
|
|
raise RuntimeError(error_msg)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# INTERNAL UTILS
|
|
# -------------------------------------------------------------------------
|
|
|
|
def get_image_mode(self, image: numpy_ndarray) -> str:
|
|
if image is None:
|
|
raise ValueError("Image is None")
|
|
shape = image.shape
|
|
if len(shape) == 2:
|
|
return "Grayscale"
|
|
elif len(shape) == 3 and shape[2] == 3:
|
|
return "RGB"
|
|
elif len(shape) == 3 and shape[2] == 4:
|
|
return "RGBA"
|
|
else:
|
|
raise ValueError(f"Unsupported image shape: {shape}")
|
|
|
|
def get_image_resolution(self, image: numpy_ndarray) -> tuple:
|
|
height = image.shape[0]
|
|
width = image.shape[1]
|
|
return height, width
|
|
|
|
def resize_with_input_factor(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
old_height, old_width = self.get_image_resolution(image)
|
|
|
|
new_width = int(old_width * self.input_resize_factor)
|
|
new_height = int(old_height * self.input_resize_factor)
|
|
|
|
# Mantenemos esto simple, el padding real se hace en la inferencia
|
|
new_width = new_width if new_width % 2 == 0 else new_width + 1
|
|
new_height = new_height if new_height % 2 == 0 else new_height + 1
|
|
|
|
if self.input_resize_factor > 1:
|
|
return opencv_resize(image, (new_width, new_height), interpolation=INTER_CUBIC)
|
|
elif self.input_resize_factor < 1:
|
|
return opencv_resize(image, (new_width, new_height), interpolation=INTER_AREA)
|
|
else:
|
|
return image
|
|
|
|
def resize_with_output_factor(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
old_height, old_width = self.get_image_resolution(image)
|
|
|
|
new_width = int(old_width * self.output_resize_factor)
|
|
new_height = int(old_height * self.output_resize_factor)
|
|
|
|
new_width = new_width if new_width % 2 == 0 else new_width + 1
|
|
new_height = new_height if new_height % 2 == 0 else new_height + 1
|
|
|
|
if self.output_resize_factor > 1:
|
|
return opencv_resize(image, (new_width, new_height), interpolation=INTER_CUBIC)
|
|
elif self.output_resize_factor < 1:
|
|
return opencv_resize(image, (new_width, new_height), interpolation=INTER_AREA)
|
|
else:
|
|
return image
|
|
|
|
# -------------------------------------------------------------------------
|
|
# PADDING & CROPPING (CRITICAL FIX FOR RIFE STRIPES)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def pad_image_to_divisor(self, image: numpy_ndarray) -> tuple[numpy_ndarray, int, int]:
|
|
"""
|
|
Añade bordes negros a la imagen para que sus dimensiones sean múltiplos de self.divisor (32).
|
|
Retorna la imagen con padding y las dimensiones del padding añadido.
|
|
"""
|
|
h, w = image.shape[:2]
|
|
|
|
# Calcular cuánto falta para llegar al siguiente múltiplo de 32
|
|
pad_h = (self.divisor - (h % self.divisor)) % self.divisor
|
|
pad_w = (self.divisor - (w % self.divisor)) % self.divisor
|
|
|
|
if pad_h == 0 and pad_w == 0:
|
|
return image, 0, 0
|
|
|
|
# Aplicar padding (top, bottom, left, right) -> Solo rellenamos abajo y derecha
|
|
# Usamos cv2.BORDER_REFLECT o BORDER_REPLICATE para reducir artefactos en bordes
|
|
image_padded = cv2.copyMakeBorder(
|
|
image, 0, pad_h, 0, pad_w, cv2.BORDER_REFLECT)
|
|
|
|
return image_padded, pad_h, pad_w
|
|
|
|
def crop_padding(self, image: numpy_ndarray, pad_h: int, pad_w: int) -> numpy_ndarray:
|
|
"""Recorta la imagen para eliminar el padding añadido previamente."""
|
|
if pad_h == 0 and pad_w == 0:
|
|
return image
|
|
|
|
h, w = image.shape[:2]
|
|
return image[:h-pad_h, :w-pad_w]
|
|
|
|
# -------------------------------------------------------------------------
|
|
# AI CORE FUNCTIONS
|
|
# -------------------------------------------------------------------------
|
|
|
|
def concatenate_images(self, image1: numpy_ndarray, image2: numpy_ndarray) -> numpy_ndarray:
|
|
# Optimización: Normalizar in-place para reducir uso de memoria
|
|
image1 = numpy_ascontiguousarray(image1, dtype=float32) / 255.0
|
|
image2 = numpy_ascontiguousarray(image2, dtype=float32) / 255.0
|
|
concateneted_image = numpy_concatenate((image1, image2), axis=2)
|
|
return concateneted_image
|
|
|
|
def preprocess_image(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
image = numpy_transpose(image, (2, 0, 1))
|
|
image = numpy_expand_dims(image, axis=0)
|
|
return image
|
|
|
|
def onnxruntime_inference(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
onnx_input = {self.inferenceSession.get_inputs()[0].name: image}
|
|
onnx_output = self.inferenceSession.run(None, onnx_input)[0]
|
|
return onnx_output
|
|
|
|
def postprocess_output(self, onnx_output: numpy_ndarray) -> numpy_ndarray:
|
|
onnx_output = numpy_squeeze(onnx_output, axis=0)
|
|
onnx_output = numpy_clip(onnx_output, 0, 1)
|
|
onnx_output = numpy_transpose(onnx_output, (1, 2, 0))
|
|
return onnx_output.astype(float32)
|
|
|
|
def de_normalize_image(self, onnx_output: numpy_ndarray, max_range: int) -> numpy_ndarray:
|
|
match max_range:
|
|
case 255: return (onnx_output * max_range).astype(uint8)
|
|
case 65535: return (onnx_output * max_range).round().astype(float32)
|
|
# Default fallback to 255
|
|
case _: return (onnx_output * 255).astype(uint8)
|
|
|
|
def AI_interpolation(self, image1: numpy_ndarray, image2: numpy_ndarray) -> numpy_ndarray:
|
|
"""
|
|
Ejecuta la interpolación asegurando dimensiones correctas.
|
|
"""
|
|
# 1. Aplicar Padding a ambas imágenes (Critical Fix)
|
|
img1_padded, pad_h, pad_w = self.pad_image_to_divisor(image1)
|
|
# Asumimos que img2 tiene el mismo tamaño que img1
|
|
img2_padded, _, _ = self.pad_image_to_divisor(image2)
|
|
|
|
# 2. Preprocesamiento estándar
|
|
image = self.concatenate_images(
|
|
img1_padded, img2_padded).astype(float32)
|
|
image = self.preprocess_image(image)
|
|
|
|
# 3. Inferencia
|
|
onnx_output = self.onnxruntime_inference(image)
|
|
|
|
# 4. Postprocesamiento
|
|
onnx_output = self.postprocess_output(onnx_output)
|
|
output_image_padded = self.de_normalize_image(onnx_output, 255)
|
|
|
|
# 5. Eliminar Padding (Critical Fix)
|
|
output_image = self.crop_padding(output_image_padded, pad_h, pad_w)
|
|
|
|
return output_image
|
|
|
|
# -------------------------------------------------------------------------
|
|
# ORCHESTRATION
|
|
# -------------------------------------------------------------------------
|
|
|
|
def AI_orchestration(self, image1: numpy_ndarray, image2: numpy_ndarray) -> List[numpy_ndarray]:
|
|
"""Generate interpolated frames between two input images."""
|
|
generated_images = []
|
|
|
|
# Optimización: Usar memoria contigua para las imágenes de entrada
|
|
image1 = numpy_ascontiguousarray(image1)
|
|
image2 = numpy_ascontiguousarray(image2)
|
|
|
|
# Generate 1 image [image1 / image_A / image2]
|
|
if self.frame_gen_factor == 2:
|
|
image_A = self.AI_interpolation(image1, image2)
|
|
generated_images.append(image_A)
|
|
|
|
# Generate 3 images [image1 / image_A / image_B / image_C / image2]
|
|
elif self.frame_gen_factor == 4:
|
|
image_B = self.AI_interpolation(image1, image2)
|
|
image_A = self.AI_interpolation(image1, image_B)
|
|
image_C = self.AI_interpolation(image_B, image2)
|
|
generated_images.append(image_A)
|
|
generated_images.append(image_B)
|
|
generated_images.append(image_C)
|
|
|
|
# Generate 7 images [image1 / image_A / image_B / image_C / image_D / image_E / image_F / image_G / image2]
|
|
elif self.frame_gen_factor == 8:
|
|
image_D = self.AI_interpolation(image1, image2)
|
|
image_B = self.AI_interpolation(image1, image_D)
|
|
image_A = self.AI_interpolation(image1, image_B)
|
|
image_C = self.AI_interpolation(image_B, image_D)
|
|
image_F = self.AI_interpolation(image_D, image2)
|
|
image_E = self.AI_interpolation(image_D, image_F)
|
|
image_G = self.AI_interpolation(image_F, image2)
|
|
generated_images.append(image_A)
|
|
generated_images.append(image_B)
|
|
generated_images.append(image_C)
|
|
generated_images.append(image_D)
|
|
generated_images.append(image_E)
|
|
generated_images.append(image_F)
|
|
generated_images.append(image_G)
|
|
|
|
return generated_images
|
|
|
|
|
|
# AI FACE RESTORATION for face enhancement -----------------
|
|
|
|
class AI_face_restoration:
|
|
"""
|
|
Clase para restauración facial (GFPGAN u otros modelos ONNX de face-restoration).
|
|
Reemplaza/actualiza la implementación anterior con:
|
|
- Preprocesado seguro (float32 por defecto)
|
|
- Conversión a float16 solo si la sesión ONNX realmente lo requiere
|
|
- Manejo de alpha channel y reescalados
|
|
- Logs diagnósticos y manejo robusto de errores
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
AI_model_name: str,
|
|
directml_gpu: str,
|
|
input_resize_factor: float,
|
|
output_resize_factor: float,
|
|
max_resolution: int
|
|
):
|
|
# Parámetros pasados
|
|
self.AI_model_name = AI_model_name
|
|
self.directml_gpu = directml_gpu
|
|
self.input_resize_factor = input_resize_factor
|
|
self.output_resize_factor = output_resize_factor
|
|
self.max_resolution = max_resolution
|
|
|
|
# Configuración por modelo (ajustable)
|
|
# GFPGAN suele usar 512x512; ajustar según tu modelo real
|
|
self.model_configs = {
|
|
"GFPGAN": {
|
|
"input_size": (512, 512),
|
|
"scale_factor": 1,
|
|
"description": "GFPGAN v1.4 for face restoration",
|
|
"fp16": True # indica que hay una variante fp16, pero no forzamos su uso
|
|
}
|
|
}
|
|
|
|
# Rutas y estado
|
|
self.AI_model_path = self._get_model_path()
|
|
self.model_config = self.model_configs.get(
|
|
AI_model_name, self.model_configs["GFPGAN"])
|
|
self.inferenceSession = None
|
|
|
|
# -------------------
|
|
# CARGA Y SESIÓN ONNX
|
|
# -------------------
|
|
def _get_model_path(self) -> str:
|
|
"""
|
|
Construye la ruta al archivo ONNX del modelo.
|
|
"""
|
|
# Prioriza la versión fp16 si nombre lo sugiere, si no existe cae en fp32
|
|
candidate_fp16 = find_by_relative_path(
|
|
f"AI-onnx{os_separator}{self.AI_model_name}_fp16.onnx")
|
|
candidate_fp32 = find_by_relative_path(
|
|
f"AI-onnx{os_separator}{self.AI_model_name}_fp32.onnx")
|
|
candidate_default = find_by_relative_path(
|
|
f"AI-onnx{os_separator}{self.AI_model_name}.onnx")
|
|
|
|
if os_path_exists(candidate_fp16):
|
|
return candidate_fp16
|
|
if os_path_exists(candidate_fp32):
|
|
return candidate_fp32
|
|
if os_path_exists(candidate_default):
|
|
return candidate_default
|
|
|
|
# Si no existe, retornamos la ruta esperada (la carga fallará y se informará)
|
|
return candidate_default
|
|
|
|
def _load_inferenceSession(self) -> None:
|
|
"""
|
|
Carga la sesión ONNX usando la función centralizada create_onnx_session.
|
|
Levanta RuntimeError si falla.
|
|
"""
|
|
try:
|
|
if not os_path_exists(self.AI_model_path):
|
|
raise FileNotFoundError(
|
|
f"AI model not found: {self.AI_model_path}")
|
|
self.inferenceSession = create_onnx_session(
|
|
self.AI_model_path, self.directml_gpu)
|
|
print(
|
|
f"[GFPGAN] Modelo cargado: {os_path_basename(self.AI_model_path)}")
|
|
except Exception as e:
|
|
error_msg = f"Failed to load face restoration model {os_path_basename(self.AI_model_path)}: {str(e)}"
|
|
print(f"[AI ERROR] {error_msg}")
|
|
raise RuntimeError(error_msg)
|
|
|
|
# -------------------
|
|
# UTILIDADES INTERNAS
|
|
# -------------------
|
|
def get_image_mode(self, image: numpy_ndarray) -> str:
|
|
"""
|
|
Devuelve 'Grayscale', 'RGB' o 'RGBA' según la forma del array.
|
|
"""
|
|
if image is None:
|
|
raise ValueError("Image is None")
|
|
shape = image.shape
|
|
if len(shape) == 2:
|
|
return "Grayscale"
|
|
elif len(shape) == 3 and shape[2] == 3:
|
|
return "RGB"
|
|
elif len(shape) == 3 and shape[2] == 4:
|
|
return "RGBA"
|
|
else:
|
|
raise ValueError(f"Unsupported image shape: {shape}")
|
|
|
|
def get_image_resolution(self, image: numpy_ndarray) -> tuple:
|
|
height = image.shape[0]
|
|
width = image.shape[1]
|
|
return height, width
|
|
|
|
def resize_with_input_factor(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
"""
|
|
Redimensiona la imagen según input_resize_factor y garantiza dimensiones pares.
|
|
"""
|
|
old_h, old_w = self.get_image_resolution(image)
|
|
new_w = int(old_w * self.input_resize_factor)
|
|
new_h = int(old_h * self.input_resize_factor)
|
|
new_w = new_w if new_w % 2 == 0 else new_w + 1
|
|
new_h = new_h if new_h % 2 == 0 else new_h + 1
|
|
|
|
if self.input_resize_factor > 1:
|
|
return opencv_resize(image, (new_w, new_h), interpolation=INTER_CUBIC)
|
|
elif self.input_resize_factor < 1:
|
|
return opencv_resize(image, (new_w, new_h), interpolation=INTER_AREA)
|
|
else:
|
|
return image
|
|
|
|
def resize_with_output_factor(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
"""
|
|
Redimensiona la imagen según output_resize_factor y garantiza dimensiones pares.
|
|
"""
|
|
old_h, old_w = self.get_image_resolution(image)
|
|
new_w = int(old_w * self.output_resize_factor)
|
|
new_h = int(old_h * self.output_resize_factor)
|
|
new_w = new_w if new_w % 2 == 0 else new_w + 1
|
|
new_h = new_h if new_h % 2 == 0 else new_h + 1
|
|
|
|
if self.output_resize_factor > 1:
|
|
return opencv_resize(image, (new_w, new_h), interpolation=INTER_CUBIC)
|
|
elif self.output_resize_factor < 1:
|
|
return opencv_resize(image, (new_w, new_h), interpolation=INTER_AREA)
|
|
else:
|
|
return image
|
|
|
|
def add_alpha_channel(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
"""
|
|
Asegura que la imagen tenga canal alpha (lo añade opaco si no).
|
|
"""
|
|
if len(image.shape) == 3 and image.shape[2] == 3:
|
|
alpha = numpy_full(
|
|
(image.shape[0], image.shape[1], 1), 255, dtype=uint8)
|
|
image = numpy_concatenate((image, alpha), axis=2)
|
|
return image
|
|
|
|
# -------------------
|
|
# PRE / POST PROCESS
|
|
# -------------------
|
|
def preprocess_face_image(self, image: numpy_ndarray) -> tuple[numpy_ndarray, bool]:
|
|
"""
|
|
Prepara la imagen para la inferencia de restauración facial.
|
|
Devuelve (preprocessed_image_float32, had_alpha_bool).
|
|
- Siempre devuelve float32 por defecto.
|
|
- El caller decidirá convertir a float16 justo antes de la inferencia si la sesión lo requiere.
|
|
"""
|
|
# Asegurar memoria contigua
|
|
image = numpy_ascontiguousarray(image)
|
|
|
|
# Detectar alpha
|
|
had_alpha = False
|
|
if len(image.shape) == 3 and image.shape[2] == 4:
|
|
had_alpha = True
|
|
# Guardamos alpha pero procesaremos solo BGR
|
|
# Convertir BGRA -> BGR para el modelo
|
|
try:
|
|
image = opencv_cvtColor(image, COLOR_BGRA2BGR)
|
|
except Exception:
|
|
# Fallback: eliminar canal alpha si cvtColor falla
|
|
image = image[:, :, :3]
|
|
|
|
# Asegurar que la imagen tenga 3 canales
|
|
if len(image.shape) == 2:
|
|
image = opencv_cvtColor(image, COLOR_GRAY2RGB)
|
|
elif len(image.shape) == 3 and image.shape[2] != 3:
|
|
# si hay más canales, recortar a 3
|
|
image = image[:, :, :3]
|
|
|
|
# Redimensionar a tamaño del modelo (input_size)
|
|
target_w, target_h = self.model_config["input_size"][1], self.model_config["input_size"][0]
|
|
try:
|
|
image_resized = opencv_resize(
|
|
image, (target_w, target_h), interpolation=INTER_AREA)
|
|
except Exception as e:
|
|
print(
|
|
f"[GFPGAN] Warning: resize failed: {e}. Using original size.")
|
|
image_resized = image
|
|
|
|
# Normalizar a float32 en rango [0,1]
|
|
preprocessed = numpy_ascontiguousarray(
|
|
image_resized, dtype=float32) / 255.0
|
|
|
|
# Transpose a NCHW
|
|
preprocessed = numpy_transpose(preprocessed, (2, 0, 1))
|
|
preprocessed = numpy_expand_dims(preprocessed, axis=0) # batch dim
|
|
|
|
return preprocessed, had_alpha
|
|
|
|
def postprocess_face_image(self, output: numpy_ndarray, original_size: tuple) -> numpy_ndarray:
|
|
"""
|
|
Postprocesa la salida del modelo:
|
|
- squeeze batch
|
|
- clamp [0,1]
|
|
- transpose a HWC
|
|
- convertir a uint8 y redimensionar a tamaño original
|
|
"""
|
|
# Squeeze batch
|
|
output = numpy_squeeze(output, axis=0)
|
|
|
|
# Clamp y asegurar tipo float32
|
|
output = numpy_clip(output, 0.0, 1.0)
|
|
|
|
# Transpose a HWC
|
|
output = numpy_transpose(output, (1, 2, 0))
|
|
|
|
# Convertir a uint8
|
|
output_uint8 = (output * 255.0).round().astype(uint8)
|
|
|
|
# Redimensionar a tamaño original (original_size es (h, w))
|
|
try:
|
|
if (original_size[0], original_size[1]) != (self.model_config["input_size"][0], self.model_config["input_size"][1]):
|
|
# opencv resize espera (width, height)
|
|
output_uint8 = opencv_resize(
|
|
output_uint8, (original_size[1], original_size[0]), interpolation=INTER_CUBIC)
|
|
except Exception as e:
|
|
print(f"[GFPGAN] Warning: postprocess resize failed: {e}")
|
|
|
|
return output_uint8
|
|
|
|
# -------------------
|
|
# LÓGICA PRINCIPAL
|
|
# -------------------
|
|
def face_restoration(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
"""
|
|
Aplica restauración facial.
|
|
CORREGIDO: Usa float32 estándar para máxima compatibilidad y estabilidad.
|
|
Eliminada la detección frágil de fp16.
|
|
"""
|
|
if self.inferenceSession is None:
|
|
self._load_inferenceSession()
|
|
|
|
# Guardar datos originales
|
|
original_h, original_w = self.get_image_resolution(image)
|
|
|
|
# Manejo de Alpha (similar a la corrección #7)
|
|
original_alpha = None
|
|
if len(image.shape) == 3 and image.shape[2] == 4:
|
|
original_alpha = image[:, :, 3]
|
|
image = image[:, :, :3] # Quedarse solo con RGB
|
|
|
|
# 1. Redimensionar entrada si el usuario lo pidió
|
|
resized_input = self.resize_with_input_factor(image)
|
|
|
|
# 2. Preprocesar (Normalizar a 0-1 float32 y HWC->NCHW)
|
|
preprocessed, _ = self.preprocess_face_image(resized_input)
|
|
|
|
# 3. Inferencia (Directa en float32)
|
|
try:
|
|
input_name = self.inferenceSession.get_inputs()[0].name
|
|
output_name = self.inferenceSession.get_outputs()[0].name
|
|
|
|
# Ejecutar sin castings extraños
|
|
output = self.inferenceSession.run(
|
|
[output_name], {input_name: preprocessed})[0]
|
|
|
|
except Exception as e:
|
|
raise RuntimeError(f"GFPGAN inference failed: {e}")
|
|
|
|
# 4. Postprocesar
|
|
# Nota: pasamos el tamaño del resized_input para que el modelo devuelva
|
|
# la cara restaurada en la escala de trabajo actual
|
|
current_h, current_w = resized_input.shape[:2]
|
|
restored_face = self.postprocess_face_image(
|
|
output, (current_h, current_w))
|
|
|
|
# 5. Restaurar Alpha si existía
|
|
if original_alpha is not None:
|
|
target_h, target_w = restored_face.shape[:2]
|
|
upscaled_alpha = opencv_resize(
|
|
original_alpha, (target_w, target_h), interpolation=INTER_CUBIC
|
|
)
|
|
if upscaled_alpha.ndim == 2:
|
|
upscaled_alpha = numpy_expand_dims(upscaled_alpha, axis=2)
|
|
restored_face = numpy_concatenate(
|
|
(restored_face, upscaled_alpha), axis=2)
|
|
|
|
# 6. Redimensionar salida final si el usuario lo pidió
|
|
final_image = self.resize_with_output_factor(restored_face)
|
|
|
|
return final_image
|
|
"""
|
|
Orquestador principal: aplica restauración facial usando el modelo ONNX.
|
|
Retorna la imagen restaurada (preservando alpha cuando sea necesario).
|
|
"""
|
|
if self.inferenceSession is None:
|
|
self._load_inferenceSession()
|
|
|
|
# Guardar tamaño original y alpha si existe
|
|
original_h, original_w = self.get_image_resolution(image)
|
|
original_alpha = None
|
|
if len(image.shape) == 3 and image.shape[2] == 4:
|
|
original_alpha = image[:, :, 3]
|
|
|
|
# Aplicar factor de input (si corresponde)
|
|
try:
|
|
resized_input = self.resize_with_input_factor(image)
|
|
except Exception as e:
|
|
print(f"[GFPGAN] Warning: resize_with_input_factor failed: {e}")
|
|
resized_input = image
|
|
|
|
# Preprocess -> float32
|
|
preprocessed, had_alpha = self.preprocess_face_image(resized_input)
|
|
|
|
# Detectar el dtype esperado por la sesión ONNX (si es posible)
|
|
session_input = None
|
|
input_type_str = None
|
|
try:
|
|
session_input = self.inferenceSession.get_inputs()[0]
|
|
# Algunos objetos tienen .type o .dtype, algunos no; usamos str() como fallback
|
|
if hasattr(session_input, 'type') and session_input.type:
|
|
input_type_str = str(session_input.type)
|
|
elif hasattr(session_input, 'dtype') and session_input.dtype:
|
|
input_type_str = str(session_input.dtype)
|
|
else:
|
|
# Intentar inspeccionar la información de la firma
|
|
try:
|
|
input_type_str = str(session_input) # puede contener info
|
|
except Exception:
|
|
input_type_str = None
|
|
except Exception:
|
|
input_type_str = None
|
|
|
|
print(
|
|
f"[GFPGAN] Pre-infer dtype(preprocessed)={preprocessed.dtype}, session_input_type={input_type_str}")
|
|
|
|
# Convertir a float16 SOLO si la sesión lo requiere explícitamente
|
|
run_input = preprocessed
|
|
try:
|
|
requires_fp16 = False
|
|
if input_type_str:
|
|
if 'float16' in input_type_str.lower() or 'fp16' in input_type_str.lower():
|
|
requires_fp16 = True
|
|
if requires_fp16:
|
|
# Convertimos sólo aquí, antes de pasar al modelo
|
|
run_input = preprocessed.astype(float16)
|
|
print(
|
|
"[GFPGAN] Convirtiendo input a float16 para la inferencia (según sesión).")
|
|
except Exception as e:
|
|
print(
|
|
f"[GFPGAN] Warning: no se pudo convertir a float16: {e}. Manteniendo float32.")
|
|
|
|
# Ejecutar la inferencia
|
|
try:
|
|
input_name = self.inferenceSession.get_inputs()[0].name
|
|
output_name = self.inferenceSession.get_outputs()[0].name
|
|
# Ejecutar la sesión (pasamos run_input)
|
|
output = self.inferenceSession.run(
|
|
[output_name], {input_name: run_input})[0]
|
|
except Exception as e:
|
|
raise RuntimeError(f"GFPGAN inference failed: {e}")
|
|
|
|
# Postprocess
|
|
restored_face = self.postprocess_face_image(
|
|
output, (resized_input.shape[0], resized_input.shape[1]))
|
|
|
|
# Restaurar canal alpha si era necesario
|
|
if had_alpha and original_alpha is not None:
|
|
try:
|
|
alpha_resized = opencv_resize(
|
|
original_alpha, (restored_face.shape[1], restored_face.shape[0]), interpolation=INTER_CUBIC)
|
|
if len(alpha_resized.shape) == 2:
|
|
alpha_resized = numpy_expand_dims(alpha_resized, axis=-1)
|
|
restored_face = numpy_concatenate(
|
|
(restored_face, alpha_resized), axis=2)
|
|
except Exception as e:
|
|
print(
|
|
f"[GFPGAN] Warning: failed to restore alpha channel: {e}")
|
|
|
|
# Aplicar factor de output (si corresponde)
|
|
try:
|
|
restored_face = self.resize_with_output_factor(restored_face)
|
|
except Exception as e:
|
|
print(f"[GFPGAN] Warning: resize_with_output_factor failed: {e}")
|
|
|
|
return restored_face
|
|
|
|
# -------------------
|
|
# Orquestador público
|
|
# -------------------
|
|
|
|
def AI_orchestration(self, image: numpy_ndarray) -> numpy_ndarray:
|
|
"""
|
|
Método público que otros módulos llaman para aplicar restauración facial.
|
|
Maneja errores críticos de memoria re-lanzándolos.
|
|
"""
|
|
try:
|
|
return self.face_restoration(image)
|
|
except Exception as e:
|
|
error_str = str(e).lower()
|
|
# --- CAMBIO CRÍTICO: Detectar errores de memoria ---
|
|
# Si es un error de memoria (CUDA OOM), lo relanzamos para que el
|
|
# orquestador principal active la reducción de tiles/resolución.
|
|
if any(k in error_str for k in ['memory', 'cuda', 'allocation', 'resource']):
|
|
raise e
|
|
|
|
# Si es otro error (ej. datos corruptos), logueamos y devolvemos original
|
|
print(f"[FACE RESTORATION ERROR] {str(e)}")
|
|
return image
|
|
|
|
|
|
# GUI utils ---------------------------
|
|
|
|
class MessageBox(CTkToplevel):
|
|
|
|
def __init__(
|
|
self,
|
|
messageType: str,
|
|
title: str,
|
|
subtitle: str,
|
|
default_value: str,
|
|
option_list: list,
|
|
) -> None:
|
|
|
|
super().__init__()
|
|
|
|
self._running: bool = False
|
|
|
|
self._messageType = messageType
|
|
self._title = title
|
|
self._subtitle = subtitle
|
|
self._default_value = default_value
|
|
self._option_list = option_list
|
|
self._ctkwidgets_index = 0
|
|
|
|
self.title('')
|
|
|
|
self.attributes("-topmost", True) # stay on top
|
|
|
|
self.protocol("WM_DELETE_WINDOW", self._on_closing)
|
|
|
|
# create widgets with slight delay, to avoid white flickering of background
|
|
self.after(10, self._create_widgets)
|
|
self.grab_set() # make other windows not clickable
|
|
|
|
# Set minimum and maximum window sizes for better scrolling
|
|
self.minsize(650, 500)
|
|
self.maxsize(900, 800)
|
|
|
|
# Set initial window size based on content
|
|
self.geometry("600x400")
|
|
|
|
def _ok_event(
|
|
self,
|
|
event=None
|
|
) -> None:
|
|
self.grab_release()
|
|
self.destroy()
|
|
|
|
def _on_closing(
|
|
self
|
|
) -> None:
|
|
self.grab_release()
|
|
self.destroy()
|
|
|
|
def createEmptyLabel(self) -> CTkLabel:
|
|
return CTkLabel(
|
|
master=self,
|
|
fg_color="transparent",
|
|
width=500,
|
|
height=17,
|
|
text=''
|
|
)
|
|
|
|
def placeInfoMessageTitleSubtitle(self) -> None:
|
|
|
|
spacingLabel1 = self.createEmptyLabel()
|
|
spacingLabel2 = self.createEmptyLabel()
|
|
|
|
if self._messageType == "info":
|
|
title_subtitle_text_color = accent_color # Amarillo dorado
|
|
elif self._messageType == "error":
|
|
title_subtitle_text_color = error_color # Rojo brillante
|
|
|
|
titleLabel = CTkLabel(
|
|
master=self,
|
|
width=500,
|
|
anchor='w',
|
|
justify="left",
|
|
fg_color="transparent",
|
|
text_color=title_subtitle_text_color,
|
|
font=bold22,
|
|
text=self._title
|
|
)
|
|
|
|
if self._default_value != None:
|
|
defaultLabel = CTkLabel(
|
|
master=self,
|
|
width=500,
|
|
anchor='w',
|
|
justify="left",
|
|
fg_color="transparent",
|
|
text_color=accent_color, # Amarillo dorado
|
|
font=bold17,
|
|
text=f"Default: {self._default_value}"
|
|
)
|
|
|
|
subtitleLabel = CTkLabel(
|
|
master=self,
|
|
width=500,
|
|
anchor='w',
|
|
justify="left",
|
|
fg_color="transparent",
|
|
text_color=title_subtitle_text_color,
|
|
font=bold14,
|
|
text=self._subtitle
|
|
)
|
|
|
|
spacingLabel1.grid(row=self._ctkwidgets_index, column=0,
|
|
columnspan=2, padx=0, pady=0, sticky="ew")
|
|
|
|
self._ctkwidgets_index += 1
|
|
titleLabel.grid(row=self._ctkwidgets_index, column=0,
|
|
columnspan=2, padx=25, pady=0, sticky="ew")
|
|
|
|
if self._default_value != None:
|
|
self._ctkwidgets_index += 1
|
|
defaultLabel.grid(row=self._ctkwidgets_index, column=0,
|
|
columnspan=2, padx=25, pady=0, sticky="ew")
|
|
|
|
self._ctkwidgets_index += 1
|
|
subtitleLabel.grid(row=self._ctkwidgets_index, column=0,
|
|
columnspan=2, padx=25, pady=0, sticky="ew")
|
|
|
|
self._ctkwidgets_index += 1
|
|
spacingLabel2.grid(row=self._ctkwidgets_index, column=0,
|
|
columnspan=2, padx=0, pady=0, sticky="ew")
|
|
|
|
def placeInfoMessageOptionsText(self) -> None:
|
|
# Create a scrollable frame for the options
|
|
from customtkinter import CTkScrollableFrame
|
|
|
|
self.scrollable_frame = CTkScrollableFrame(
|
|
master=self,
|
|
width=600,
|
|
height=300, # Fixed height to enable scrolling
|
|
fg_color="transparent",
|
|
corner_radius=10,
|
|
scrollbar_button_color=border_color,
|
|
scrollbar_button_hover_color=button_hover_color
|
|
)
|
|
|
|
self._ctkwidgets_index += 1
|
|
self.scrollable_frame.grid(row=self._ctkwidgets_index, column=0,
|
|
columnspan=2, padx=25, pady=10, sticky="ew")
|
|
|
|
# Add options to the scrollable frame
|
|
for i, option_text in enumerate(self._option_list):
|
|
optionLabel = CTkLabel(
|
|
master=self.scrollable_frame,
|
|
width=550, # Slightly smaller to account for scrollbar
|
|
anchor='w',
|
|
justify="left",
|
|
text_color=text_color,
|
|
fg_color=widget_background_color,
|
|
bg_color="transparent",
|
|
font=bold13,
|
|
text=option_text,
|
|
corner_radius=10,
|
|
wraplength=530 # Enable text wrapping
|
|
)
|
|
|
|
optionLabel.grid(row=i, column=0, padx=10, pady=4, sticky="ew")
|
|
|
|
# Configure grid weight for the scrollable frame
|
|
self.scrollable_frame.grid_columnconfigure(0, weight=1)
|
|
|
|
spacingLabel3 = self.createEmptyLabel()
|
|
|
|
self._ctkwidgets_index += 1
|
|
spacingLabel3.grid(row=self._ctkwidgets_index, column=0,
|
|
columnspan=2, padx=0, pady=0, sticky="ew")
|
|
|
|
def placeInfoMessageOkButton(
|
|
self
|
|
) -> None:
|
|
|
|
ok_button = CTkButton(
|
|
master=self,
|
|
command=self._ok_event,
|
|
text='OK',
|
|
width=125,
|
|
font=bold11,
|
|
border_width=1,
|
|
fg_color=widget_background_color,
|
|
text_color=secondary_text_color,
|
|
border_color=accent_color,
|
|
hover_color=button_hover_color
|
|
)
|
|
|
|
self._ctkwidgets_index += 1
|
|
ok_button.grid(row=self._ctkwidgets_index, column=1,
|
|
columnspan=1, padx=(10, 20), pady=(10, 20), sticky="e")
|
|
|
|
def _create_widgets(
|
|
self
|
|
) -> None:
|
|
|
|
self.grid_columnconfigure((0, 1), weight=1)
|
|
self.rowconfigure(0, weight=1)
|
|
|
|
self.placeInfoMessageTitleSubtitle()
|
|
self.placeInfoMessageOptionsText()
|
|
self.placeInfoMessageOkButton()
|
|
|
|
|
|
def get_values_for_file_widget() -> tuple:
|
|
# Upscale factor
|
|
upscale_factor = get_upscale_factor()
|
|
|
|
# Input resolution %
|
|
try:
|
|
input_resize_factor = int(
|
|
float(str(selected_input_resize_factor.get())))
|
|
except (ValueError, TypeError):
|
|
input_resize_factor = 0
|
|
|
|
# Output resolution %
|
|
try:
|
|
output_resize_factor = int(
|
|
float(str(selected_output_resize_factor.get())))
|
|
except (ValueError, TypeError):
|
|
output_resize_factor = 0
|
|
|
|
return upscale_factor, input_resize_factor, output_resize_factor
|
|
|
|
|
|
def update_file_widget(a, b, c) -> None:
|
|
# Si el widget no existe o no tiene archivos, no hacemos nada crítico,
|
|
# pero actualizamos los valores internos para cuando lleguen archivos.
|
|
if not file_widget:
|
|
return
|
|
|
|
upscale_factor, input_resize_factor, output_resize_factor = get_values_for_file_widget()
|
|
|
|
# Pasar valores al manager
|
|
file_widget.set_upscale_factor(upscale_factor)
|
|
file_widget.set_input_resize_factor(input_resize_factor)
|
|
file_widget.set_output_resize_factor(output_resize_factor)
|
|
|
|
# Regenerar textos de info en la lista si es necesario
|
|
if file_widget.queue_items:
|
|
file_widget.regenerate_all_info()
|
|
|
|
|
|
def create_option_background():
|
|
return CTkFrame(
|
|
master=window,
|
|
bg_color=background_color,
|
|
fg_color=widget_background_color,
|
|
height=46,
|
|
corner_radius=10
|
|
)
|
|
|
|
|
|
def create_info_button(command: Callable, text: str, width: int = 200) -> CTkFrame:
|
|
|
|
frame = CTkFrame(
|
|
master=window, fg_color=widget_background_color, height=25)
|
|
|
|
button = CTkButton(
|
|
master=frame,
|
|
command=command,
|
|
font=bold12,
|
|
text="?",
|
|
border_color=accent_color,
|
|
border_width=1,
|
|
fg_color=info_button_color,
|
|
hover_color=button_hover_color,
|
|
text_color=text_color,
|
|
width=23,
|
|
height=15,
|
|
corner_radius=1
|
|
)
|
|
button.grid(row=0, column=0, padx=(0, 7), pady=2, sticky="w")
|
|
|
|
label = CTkLabel(
|
|
master=frame,
|
|
text=text,
|
|
width=width,
|
|
height=22,
|
|
fg_color="transparent",
|
|
bg_color=widget_background_color,
|
|
text_color=text_color,
|
|
font=bold13,
|
|
anchor="w"
|
|
)
|
|
label.grid(row=0, column=1, sticky="w")
|
|
|
|
frame.grid_propagate(False)
|
|
frame.grid_columnconfigure(1, weight=1)
|
|
|
|
return frame
|
|
|
|
|
|
def create_option_menu(
|
|
command: Callable,
|
|
values: list,
|
|
default_value: str,
|
|
border_color: str = None,
|
|
border_width: int = 1,
|
|
width: int = 159
|
|
) -> CTkFrame:
|
|
|
|
width = width
|
|
height = 28
|
|
|
|
total_width = (width + 2 * border_width)
|
|
total_height = (height + 2 * border_width)
|
|
|
|
# Use default border color if none provided
|
|
if border_color is None:
|
|
border_color = accent_color
|
|
|
|
frame = CTkFrame(
|
|
master=window,
|
|
fg_color=border_color,
|
|
width=total_width,
|
|
height=total_height,
|
|
border_width=0,
|
|
corner_radius=1,
|
|
)
|
|
|
|
option_menu = CTkOptionMenu(
|
|
master=frame,
|
|
command=command,
|
|
values=values,
|
|
width=width,
|
|
height=height,
|
|
corner_radius=0,
|
|
dropdown_font=bold12,
|
|
font=bold11,
|
|
anchor="center",
|
|
text_color=text_color,
|
|
fg_color=widget_background_color,
|
|
button_color=widget_background_color,
|
|
button_hover_color=button_hover_color,
|
|
dropdown_fg_color=widget_background_color,
|
|
dropdown_text_color=text_color,
|
|
dropdown_hover_color=button_hover_color
|
|
)
|
|
|
|
option_menu.place(
|
|
x=(total_width - width) / 2,
|
|
y=(total_height - height) / 2
|
|
)
|
|
option_menu.set(default_value)
|
|
return frame
|
|
|
|
|
|
def create_text_box(textvariable: StringVar, width: int) -> CTkEntry:
|
|
return CTkEntry(
|
|
master=window,
|
|
textvariable=textvariable,
|
|
corner_radius=1,
|
|
width=width,
|
|
height=28,
|
|
font=bold11,
|
|
justify="center",
|
|
text_color=text_color,
|
|
fg_color=widget_background_color,
|
|
border_width=1,
|
|
border_color=accent_color,
|
|
placeholder_text_color=secondary_text_color
|
|
)
|
|
|
|
|
|
def create_text_box_output_path(textvariable: StringVar) -> CTkEntry:
|
|
return CTkEntry(
|
|
master=window,
|
|
textvariable=textvariable,
|
|
corner_radius=1,
|
|
width=250,
|
|
height=28,
|
|
font=bold11,
|
|
justify="center",
|
|
text_color=secondary_text_color,
|
|
fg_color=widget_background_color,
|
|
border_width=1,
|
|
border_color=border_color,
|
|
state=DISABLED
|
|
)
|
|
|
|
|
|
def create_active_button(
|
|
command: Callable,
|
|
text: str,
|
|
icon: CTkImage = None,
|
|
width: int = 140,
|
|
height: int = 30,
|
|
border_color: str = None
|
|
) -> CTkButton:
|
|
|
|
# Use default border color if none provided
|
|
if border_color is None:
|
|
border_color = accent_color
|
|
|
|
return CTkButton(
|
|
master=window,
|
|
command=command,
|
|
text=text,
|
|
image=icon,
|
|
width=width,
|
|
height=height,
|
|
font=bold11,
|
|
border_width=1,
|
|
corner_radius=1,
|
|
fg_color=widget_background_color,
|
|
text_color=text_color,
|
|
border_color=border_color,
|
|
hover_color=button_hover_color
|
|
)
|
|
|
|
|
|
# ==== ERROR HANDLING AND LOGGING SECTION ====
|
|
|
|
# Configure logging paths in Documents folder
|
|
LOG_FOLDER_PATH = os_path_join(DOCUMENT_PATH, f"{app_name}_{version}_Logs")
|
|
|
|
# Define log file names
|
|
MAIN_LOG_FILENAME = 'warlock_studio.log'
|
|
ERROR_LOG_FILENAME = 'error_log.txt'
|
|
|
|
try:
|
|
if not os_path_exists(LOG_FOLDER_PATH):
|
|
os_makedirs(LOG_FOLDER_PATH)
|
|
MAIN_LOG_PATH = os_path_join(LOG_FOLDER_PATH, MAIN_LOG_FILENAME)
|
|
ERROR_LOG_PATH = os_path_join(LOG_FOLDER_PATH, ERROR_LOG_FILENAME)
|
|
except Exception as e:
|
|
# Fallback to current directory if Documents folder is not accessible
|
|
print(f"[WARNING] Could not create logs folder in Documents: {str(e)}")
|
|
print(f"[WARNING] Using current directory for logs as fallback")
|
|
MAIN_LOG_PATH = MAIN_LOG_FILENAME
|
|
ERROR_LOG_PATH = ERROR_LOG_FILENAME
|
|
|
|
# Configure logging
|
|
# Ensure logging is set up with a backup/rotation mechanism for maintaining log length.
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
handlers=[
|
|
logging.FileHandler(MAIN_LOG_PATH, encoding='utf-8'),
|
|
logging.StreamHandler()
|
|
]
|
|
)
|
|
|
|
|
|
def log_and_report_error(msg: str) -> None:
|
|
"""Unified error logging and reporting function."""
|
|
logging.error(msg)
|
|
show_error_message(msg)
|
|
try:
|
|
with open(ERROR_LOG_PATH, "a", encoding="utf-8") as f:
|
|
f.write(f"{datetime.now()} - {msg}\n")
|
|
except Exception as e:
|
|
print(f"[ERROR] Could not write to error log file: {str(e)}")
|
|
|
|
|
|
@contextmanager
|
|
def safe_execution(operation_name: str):
|
|
"""Context manager for safe execution with error handling."""
|
|
try:
|
|
yield
|
|
except Exception as e:
|
|
error_msg = f"Error during {operation_name}: {str(e)}"
|
|
log_and_report_error(error_msg)
|
|
raise
|
|
|
|
|
|
def validate_environment() -> bool:
|
|
"""Validate the runtime environment before starting."""
|
|
try:
|
|
# Check Python version
|
|
if sys.version_info < (3, 8):
|
|
log_and_report_error("Python 3.8 or higher required")
|
|
return False
|
|
|
|
# Check required modules
|
|
required_modules = ['cv2', 'numpy',
|
|
'customtkinter', 'onnxruntime', 'PIL']
|
|
missing_modules = []
|
|
|
|
for module in required_modules:
|
|
try:
|
|
__import__(module)
|
|
except ImportError:
|
|
missing_modules.append(module)
|
|
|
|
if missing_modules:
|
|
log_and_report_error(
|
|
f"Missing required modules: {', '.join(missing_modules)}")
|
|
return False
|
|
|
|
# Check AI model directory
|
|
ai_model_dir = find_by_relative_path("AI-onnx")
|
|
if not os_path_exists(ai_model_dir):
|
|
log_and_report_error(
|
|
f"AI model directory not found: {ai_model_dir}")
|
|
return False
|
|
|
|
return True
|
|
except Exception as e:
|
|
log_and_report_error(f"Environment validation failed: {str(e)}")
|
|
return False
|
|
|
|
|
|
def cleanup_on_exit():
|
|
"""Cleanup function to run on application exit."""
|
|
try:
|
|
# Clean up temporary files
|
|
temp_files = [f for f in os_listdir('.') if f.endswith(
|
|
'.tmp') or f.endswith('.checkpoint')]
|
|
for temp_file in temp_files:
|
|
try:
|
|
os_remove(temp_file)
|
|
except Exception:
|
|
pass
|
|
|
|
# Stop any running processes
|
|
stop_upscale_process()
|
|
|
|
# Force garbage collection
|
|
gc.collect()
|
|
|
|
logging.info("Application cleanup completed")
|
|
except Exception as e:
|
|
logging.error(f"Error during cleanup: {str(e)}")
|
|
|
|
|
|
# Register cleanup function
|
|
atexit.register(cleanup_on_exit)
|
|
|
|
# Signal handlers for graceful shutdown
|
|
|
|
|
|
def signal_handler(signum, frame):
|
|
logging.info(f"Received signal {signum}, shutting down gracefully...")
|
|
cleanup_on_exit()
|
|
sys.exit(0)
|
|
|
|
|
|
try:
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
except AttributeError:
|
|
# Windows doesn't have all signals
|
|
pass
|
|
|
|
|
|
def create_checkpoint(video_path: str, completed_frames: list[str]) -> None:
|
|
"""Create checkpoint for video processing recovery."""
|
|
try:
|
|
checkpoint_path = f"{video_path}.checkpoint"
|
|
with open(checkpoint_path, 'w', encoding='utf-8') as f:
|
|
f.write(f"completed_frames={len(completed_frames)}\n")
|
|
for frame in completed_frames:
|
|
f.write(f"{frame}\n")
|
|
print(
|
|
f"[CHECKPOINT] Created checkpoint with {len(completed_frames)} completed frames")
|
|
except Exception as e:
|
|
print(f"[CHECKPOINT] Could not create checkpoint: {str(e)}")
|
|
|
|
|
|
def load_checkpoint(video_path: str) -> list[str]:
|
|
"""Load checkpoint for video processing recovery."""
|
|
try:
|
|
checkpoint_path = f"{video_path}.checkpoint"
|
|
if not os_path_exists(checkpoint_path):
|
|
return []
|
|
|
|
completed_frames = []
|
|
with open(checkpoint_path, 'r', encoding='utf-8') as f:
|
|
lines = f.readlines()
|
|
for line in lines[1:]: # Skip first line with count
|
|
frame = line.strip()
|
|
if frame and os_path_exists(frame):
|
|
completed_frames.append(frame)
|
|
|
|
print(
|
|
f"[CHECKPOINT] Loaded checkpoint with {len(completed_frames)} completed frames")
|
|
return completed_frames
|
|
except Exception as e:
|
|
print(f"[CHECKPOINT] Could not load checkpoint: {str(e)}")
|
|
return []
|
|
|
|
|
|
def cleanup_checkpoint(video_path: str) -> None:
|
|
"""Clean up checkpoint file after successful completion."""
|
|
try:
|
|
checkpoint_path = f"{video_path}.checkpoint"
|
|
if os_path_exists(checkpoint_path):
|
|
os_remove(checkpoint_path)
|
|
print(f"[CHECKPOINT] Cleaned up checkpoint file")
|
|
except Exception as e:
|
|
print(f"[CHECKPOINT] Could not cleanup checkpoint: {str(e)}")
|
|
|
|
|
|
def clean_directory(directory_path: str) -> None:
|
|
"""Remove all files in a directory."""
|
|
try:
|
|
if os_path_exists(directory_path):
|
|
for file in os_listdir(directory_path):
|
|
file_path = os_path_join(directory_path, file)
|
|
if os_path_exists(file_path):
|
|
os_remove(file_path)
|
|
except Exception as e:
|
|
logging.error(f"Failed to clean directory {directory_path}: {str(e)}")
|
|
|
|
|
|
def optimize_memory_usage() -> None:
|
|
"""Optimize memory usage by triggering garbage collection and clearing caches."""
|
|
try:
|
|
import gc
|
|
import sys
|
|
|
|
# Force garbage collection
|
|
gc.collect()
|
|
|
|
# Clear any cached frames or temporary data
|
|
if hasattr(sys, '_clear_type_cache'):
|
|
sys._clear_type_cache()
|
|
|
|
# Additional memory optimization for Windows
|
|
try:
|
|
import ctypes
|
|
if hasattr(ctypes, 'windll'):
|
|
ctypes.windll.kernel32.SetProcessWorkingSetSize(-1, -1, -1)
|
|
except Exception:
|
|
pass
|
|
|
|
except Exception as e:
|
|
logging.debug(f"Memory optimization warning: {str(e)}")
|
|
|
|
|
|
def validate_video_file(video_path: str) -> bool:
|
|
"""Validate video file integrity and readability."""
|
|
try:
|
|
if not os_path_exists(video_path):
|
|
return False
|
|
|
|
# Test if video can be opened
|
|
cap = opencv_VideoCapture(video_path)
|
|
if not cap.isOpened():
|
|
cap.release()
|
|
return False
|
|
|
|
# Try to read first frame
|
|
ret, frame = cap.read()
|
|
cap.release()
|
|
|
|
return ret and frame is not None
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def get_video_info(video_path: str) -> dict:
|
|
"""Get comprehensive video information."""
|
|
try:
|
|
cap = opencv_VideoCapture(video_path)
|
|
if not cap.isOpened():
|
|
raise ValueError(f"Cannot open video: {video_path}")
|
|
|
|
width = int(cap.get(CAP_PROP_FRAME_WIDTH))
|
|
height = int(cap.get(CAP_PROP_FRAME_HEIGHT))
|
|
fps = cap.get(CAP_PROP_FPS)
|
|
frame_count = int(cap.get(CAP_PROP_FRAME_COUNT))
|
|
duration = frame_count / fps if fps > 0 else 0
|
|
|
|
cap.release()
|
|
|
|
return {
|
|
'width': width,
|
|
'height': height,
|
|
'fps': fps,
|
|
'frame_count': frame_count,
|
|
'duration': duration,
|
|
'resolution': f"{width}x{height}",
|
|
'file_size': os_path_getsize(video_path) if os_path_exists(video_path) else 0
|
|
}
|
|
except Exception as e:
|
|
log_and_report_error(
|
|
f"Error getting video info for {video_path}: {str(e)}")
|
|
return {}
|
|
|
|
|
|
def estimate_processing_time(video_info: dict, ai_model: str) -> dict:
|
|
"""Estimate processing time based on video properties and AI model."""
|
|
try:
|
|
frame_count = video_info.get('frame_count', 0)
|
|
resolution = video_info.get(
|
|
'width', 1920) * video_info.get('height', 1080)
|
|
|
|
# Base processing time per frame (in seconds) - rough estimates
|
|
model_speeds = {
|
|
'RealESR_Gx4': 0.5,
|
|
'RealESR_Animex4': 0.5,
|
|
'RealESRNetx4': 1.0,
|
|
'BSRGANx4': 2.0,
|
|
'BSRGANx2': 1.5,
|
|
'RealESRGANx4': 2.0,
|
|
'IRCNN_Mx1': 0.3,
|
|
'IRCNN_Lx1': 0.3,
|
|
'RIFE': 0.8,
|
|
'RIFE_Lite': 0.6
|
|
}
|
|
|
|
base_time = model_speeds.get(ai_model, 1.0)
|
|
resolution_factor = resolution / (1920 * 1080) # Normalize to 1080p
|
|
|
|
estimated_time_per_frame = base_time * resolution_factor
|
|
total_estimated_time = estimated_time_per_frame * frame_count
|
|
|
|
return {
|
|
'time_per_frame': estimated_time_per_frame,
|
|
'total_time': total_estimated_time,
|
|
'total_time_formatted': format_time_duration(total_estimated_time)
|
|
}
|
|
except Exception:
|
|
return {'time_per_frame': 0, 'total_time': 0, 'total_time_formatted': 'Unknown'}
|
|
|
|
|
|
def format_time_duration(seconds: float) -> str:
|
|
"""Format time duration in human readable format."""
|
|
if seconds < 60:
|
|
return f"{int(seconds)}s"
|
|
elif seconds < 3600:
|
|
minutes = int(seconds // 60)
|
|
remaining_seconds = int(seconds % 60)
|
|
return f"{minutes}m {remaining_seconds}s"
|
|
else:
|
|
hours = int(seconds // 3600)
|
|
minutes = int((seconds % 3600) // 60)
|
|
return f"{hours}h {minutes}m"
|
|
|
|
|
|
def create_video_backup(video_path: str) -> str:
|
|
"""Create backup of original video before processing."""
|
|
try:
|
|
backup_path = f"{video_path}.backup"
|
|
if not os_path_exists(backup_path):
|
|
copy2(video_path, backup_path)
|
|
print(f"[BACKUP] Created backup: {backup_path}")
|
|
return backup_path
|
|
except Exception as e:
|
|
print(f"[BACKUP] Warning: Could not create backup: {str(e)}")
|
|
return video_path
|
|
|
|
|
|
def verify_frame_sequence(frame_paths: list[str]) -> bool:
|
|
"""Verify that frame sequence is complete and valid."""
|
|
try:
|
|
if not frame_paths:
|
|
return False
|
|
|
|
missing_frames = []
|
|
corrupted_frames = []
|
|
|
|
for frame_path in frame_paths:
|
|
if not os_path_exists(frame_path):
|
|
missing_frames.append(frame_path)
|
|
else:
|
|
try:
|
|
# Try to read frame to verify it's not corrupted
|
|
frame = image_read(frame_path)
|
|
if frame is None or frame.size == 0:
|
|
corrupted_frames.append(frame_path)
|
|
except Exception:
|
|
corrupted_frames.append(frame_path)
|
|
|
|
if missing_frames:
|
|
print(f"[FRAME_CHECK] Missing frames: {len(missing_frames)}")
|
|
if corrupted_frames:
|
|
print(f"[FRAME_CHECK] Corrupted frames: {len(corrupted_frames)}")
|
|
|
|
return len(missing_frames) == 0 and len(corrupted_frames) == 0
|
|
except Exception as e:
|
|
print(f"[FRAME_CHECK] Error verifying frame sequence: {str(e)}")
|
|
return False
|
|
|
|
|
|
def cleanup_incomplete_frames(target_directory: str, expected_count: int) -> None:
|
|
"""Clean up incomplete frame extraction."""
|
|
try:
|
|
if not os_path_exists(target_directory):
|
|
return
|
|
|
|
files = os_listdir(target_directory)
|
|
frame_files = [f for f in files if f.startswith(
|
|
'frame_') and f.endswith('.png')]
|
|
|
|
if len(frame_files) < expected_count:
|
|
print(
|
|
f"[CLEANUP] Removing incomplete frame extraction: {len(frame_files)}/{expected_count} frames")
|
|
for file in frame_files:
|
|
try:
|
|
os_remove(os_path_join(target_directory, file))
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
print(f"[CLEANUP] Error cleaning incomplete frames: {str(e)}")
|
|
|
|
|
|
def monitor_disk_space(required_space_gb: float = 5.0) -> bool:
|
|
"""Monitor available disk space during processing."""
|
|
try:
|
|
total, used, free = shutil.disk_usage(".")
|
|
free_gb = free / (1024**3)
|
|
|
|
if free_gb < required_space_gb:
|
|
log_and_report_error(
|
|
f"Low disk space: {free_gb:.1f}GB available, {required_space_gb}GB required")
|
|
return False
|
|
|
|
if free_gb < required_space_gb * 2: # Warning threshold
|
|
print(f"[WARNING] Low disk space: {free_gb:.1f}GB available")
|
|
|
|
return True
|
|
except Exception:
|
|
return True # Assume OK if we can't check
|
|
|
|
|
|
def create_frame_index(frame_paths: list[str]) -> dict:
|
|
"""Create index of frames for faster lookup."""
|
|
try:
|
|
frame_index = {}
|
|
for i, path in enumerate(frame_paths):
|
|
frame_number = extract_frame_number_from_path(path)
|
|
frame_index[frame_number] = {
|
|
'path': path,
|
|
'index': i,
|
|
'exists': os_path_exists(path)
|
|
}
|
|
return frame_index
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def extract_frame_number_from_path(frame_path: str) -> int:
|
|
"""Extract frame number from frame file path."""
|
|
try:
|
|
filename = os_path_basename(frame_path)
|
|
# Extract number from patterns like "frame_001.png"
|
|
import re
|
|
match = re.search(r'frame_(\d+)', filename)
|
|
if match:
|
|
return int(match.group(1))
|
|
return 0
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
def validate_ai_model_compatibility(ai_model: str, operation: str) -> bool:
|
|
"""Validate AI model compatibility with requested operation."""
|
|
try:
|
|
if operation == "upscaling":
|
|
return ai_model not in RIFE_models_list
|
|
elif operation == "interpolation":
|
|
return ai_model in RIFE_models_list
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def validate_file_paths(file_paths: list[str]) -> bool:
|
|
"""Validate that all file paths exist and are accessible."""
|
|
if not file_paths:
|
|
return False
|
|
|
|
missing_files = []
|
|
invalid_files = []
|
|
|
|
for path in file_paths:
|
|
if not os_path_exists(path):
|
|
missing_files.append(path)
|
|
else:
|
|
try:
|
|
# Test if file is readable
|
|
with open(path, 'rb') as f:
|
|
f.read(1)
|
|
except Exception as e:
|
|
invalid_files.append(f"{path}: {str(e)}")
|
|
|
|
if missing_files:
|
|
log_and_report_error(f"Missing files detected: {missing_files}")
|
|
if invalid_files:
|
|
log_and_report_error(f"Inaccessible files detected: {invalid_files}")
|
|
|
|
return len(missing_files) == 0 and len(invalid_files) == 0
|
|
|
|
|
|
def validate_output_path(output_path: str) -> bool:
|
|
"""Validate output path is writable."""
|
|
if output_path == OUTPUT_PATH_CODED:
|
|
return True
|
|
|
|
if not os_path_exists(output_path):
|
|
try:
|
|
os_makedirs(output_path, exist_ok=True)
|
|
except Exception as e:
|
|
log_and_report_error(
|
|
f"Cannot create output directory {output_path}: {str(e)}")
|
|
return False
|
|
|
|
# Test write permissions
|
|
test_file = os_path_join(output_path, "test_write_permissions.tmp")
|
|
try:
|
|
with open(test_file, 'w') as f:
|
|
f.write("test")
|
|
os_remove(test_file)
|
|
return True
|
|
except Exception as e:
|
|
log_and_report_error(
|
|
f"Output path not writable {output_path}: {str(e)}")
|
|
return False
|
|
|
|
|
|
def validate_system_requirements() -> bool:
|
|
"""Validate system requirements for processing."""
|
|
errors = []
|
|
|
|
# Check FFmpeg
|
|
if not os_path_exists(FFMPEG_EXE_PATH):
|
|
errors.append("FFmpeg executable not found")
|
|
|
|
# Check available disk space (enhanced check)
|
|
try:
|
|
import shutil
|
|
total, used, free = shutil.disk_usage(".")
|
|
if free < (1024 * 1024 * 1024): # Less than 1GB free
|
|
errors.append("Low disk space: less than 1GB available")
|
|
elif free < (2 * 1024 * 1024 * 1024): # Less than 2GB free
|
|
print(
|
|
f"[WARNING] Low disk space: {free // (1024*1024*1024):.1f}GB available")
|
|
except Exception:
|
|
pass # Ignore if we can't check disk space
|
|
|
|
# Check available RAM
|
|
try:
|
|
import psutil
|
|
memory = psutil.virtual_memory()
|
|
if memory.available < (2 * 1024 * 1024 * 1024): # Less than 2GB available
|
|
errors.append(
|
|
f"Low available RAM: {memory.available // (1024*1024*1024):.1f}GB")
|
|
except ImportError:
|
|
print("[WARNING] psutil not available, cannot check RAM")
|
|
except Exception:
|
|
pass
|
|
|
|
if errors:
|
|
for error in errors:
|
|
log_and_report_error(error)
|
|
return False
|
|
return True
|
|
|
|
# ==== FILE UTILITIES SECTION ====
|
|
|
|
|
|
def create_dir(name_dir: str) -> None:
|
|
"""
|
|
Crea un directorio si no existe.
|
|
CORREGIDO: Ya no borra el directorio si existe (evita pérdida de datos).
|
|
"""
|
|
try:
|
|
if not os_path_exists(name_dir):
|
|
os_makedirs(name_dir, exist_ok=True)
|
|
except Exception as e:
|
|
print(f"[ERROR] Could not create directory {name_dir}: {e}")
|
|
|
|
|
|
def stop_thread() -> None:
|
|
"""Notifica al hilo de monitoreo que debe detenerse de forma segura."""
|
|
global stop_thread_flag
|
|
stop_thread_flag.set()
|
|
|
|
|
|
def image_read(file_path: str) -> numpy_ndarray:
|
|
"""Enhanced image reading with comprehensive error handling and validation."""
|
|
try:
|
|
if not os_path_exists(file_path):
|
|
raise FileNotFoundError(f"Image file not found: {file_path}")
|
|
|
|
# Check file size
|
|
file_size = os_path_getsize(file_path)
|
|
if file_size == 0:
|
|
raise ValueError(f"Image file is empty: {file_path}")
|
|
|
|
# Limit maximum file size (500MB) to prevent memory issues
|
|
max_size = 500 * 1024 * 1024 # 500MB
|
|
if file_size > max_size:
|
|
raise ValueError(
|
|
f"Image file too large ({file_size / (1024*1024):.1f}MB > 500MB): {file_path}")
|
|
|
|
with open(file_path, 'rb') as file:
|
|
file_data = file.read()
|
|
|
|
# Validate file data
|
|
if len(file_data) == 0:
|
|
raise ValueError(f"Could not read image data: {file_path}")
|
|
|
|
# Decode image
|
|
buffer = numpy_ascontiguousarray(numpy_frombuffer(file_data, uint8))
|
|
image = opencv_imdecode(buffer, IMREAD_UNCHANGED)
|
|
|
|
if image is None:
|
|
raise ValueError(
|
|
f"Could not decode image (corrupted or unsupported format): {file_path}")
|
|
|
|
# Validate image properties
|
|
if image.size == 0:
|
|
raise ValueError(f"Decoded image has zero size: {file_path}")
|
|
|
|
# Check for reasonable dimensions
|
|
height, width = image.shape[:2]
|
|
if height <= 0 or width <= 0:
|
|
raise ValueError(
|
|
f"Invalid image dimensions ({width}x{height}): {file_path}")
|
|
|
|
# Check for extremely large dimensions that could cause memory issues
|
|
max_dimension = 32768 # 32K pixels per dimension
|
|
if height > max_dimension or width > max_dimension:
|
|
raise ValueError(
|
|
f"Image dimensions too large ({width}x{height} > {max_dimension}x{max_dimension}): {file_path}")
|
|
|
|
# Validate channel count
|
|
channels = len(image.shape) if len(
|
|
image.shape) == 2 else image.shape[2]
|
|
if len(image.shape) == 3 and channels not in [1, 3, 4]:
|
|
logging.warning(
|
|
f"Unusual channel count ({channels}) in image: {file_path}")
|
|
|
|
print(
|
|
f"[IMAGE] Successfully loaded: {width}x{height}x{channels if len(image.shape) > 2 else 1} - {file_size / 1024:.1f}KB")
|
|
return image
|
|
|
|
except Exception as e:
|
|
error_msg = f"Failed to read image {os_path_basename(file_path)}: {str(e)}"
|
|
logging.error(error_msg)
|
|
raise RuntimeError(error_msg)
|
|
|
|
|
|
def image_write(file_path: str, file_data: numpy_ndarray, file_extension: str = ".png") -> None:
|
|
"""
|
|
Escribe la imagen en disco de forma robusta, manejando rutas Unicode y buffer de escritura.
|
|
"""
|
|
try:
|
|
# 1. Codificar la imagen a memoria (buffer) usando la extensión correcta
|
|
success, buffer = opencv_imencode(file_extension, file_data)
|
|
|
|
if not success:
|
|
raise RuntimeError(
|
|
f"Could not encode image format: {file_extension}")
|
|
|
|
# 2. Escribir el buffer al disco usando manejo estándar de archivos
|
|
# Esto evita problemas con rutas non-ASCII que tiene cv2.imwrite
|
|
with open(file_path, "wb") as f:
|
|
f.write(buffer.tobytes())
|
|
|
|
except Exception as e:
|
|
# Loguear el error pero permitir que el proceso principal lo maneje si es necesario
|
|
logging.error(f"Failed to write image to {file_path}: {str(e)}")
|
|
raise RuntimeError(f"Failed to write image: {str(e)}")
|
|
|
|
|
|
def copy_file_metadata(original_file_path: str, upscaled_file_path: str) -> None:
|
|
try:
|
|
# Check if exiftool exists
|
|
if not os_path_exists(EXIFTOOL_EXE_PATH):
|
|
print("[ExifTool] ExifTool not found, skipping metadata copy")
|
|
return
|
|
|
|
# Check if files exist
|
|
if not os_path_exists(original_file_path):
|
|
print(f"[ExifTool] Original file not found: {original_file_path}")
|
|
return
|
|
|
|
if not os_path_exists(upscaled_file_path):
|
|
print(f"[ExifTool] Upscaled file not found: {upscaled_file_path}")
|
|
return
|
|
|
|
exiftool_cmd = [
|
|
EXIFTOOL_EXE_PATH,
|
|
'-fast',
|
|
'-TagsFromFile',
|
|
original_file_path,
|
|
'-overwrite_original',
|
|
'-all:all',
|
|
'-unsafe',
|
|
'-largetags',
|
|
upscaled_file_path
|
|
]
|
|
|
|
result = subprocess_run(exiftool_cmd, check=True,
|
|
shell=False, capture_output=True, text=True)
|
|
print(f"[ExifTool] Successfully copied metadata")
|
|
|
|
except CalledProcessError as e:
|
|
print(
|
|
f"[ExifTool] ExifTool failed: {e.stderr if e.stderr else str(e)}")
|
|
except Exception as e:
|
|
print(f"[ExifTool] Could not copy metadata: {str(e)}")
|
|
|
|
|
|
def prepare_output_image_filename(
|
|
image_path: str,
|
|
selected_output_path: str,
|
|
selected_AI_model: str,
|
|
input_resize_factor: int,
|
|
output_resize_factor: int,
|
|
selected_image_extension: str,
|
|
selected_blending_factor: float
|
|
) -> str:
|
|
|
|
if selected_output_path == OUTPUT_PATH_CODED:
|
|
file_path_no_extension, _ = os_path_splitext(image_path)
|
|
output_path = file_path_no_extension
|
|
else:
|
|
file_name = os_path_basename(image_path)
|
|
output_path = f"{selected_output_path}{os_separator}{file_name}"
|
|
|
|
# Selected AI model
|
|
to_append = f"_{selected_AI_model}"
|
|
|
|
# Selected input resize
|
|
to_append += f"_InputR-{str(int(input_resize_factor * 100))}"
|
|
|
|
# Selected output resize
|
|
to_append += f"_OutputR-{str(int(output_resize_factor * 100))}"
|
|
|
|
# Selected intepolation
|
|
match selected_blending_factor:
|
|
case 0.3:
|
|
to_append += "_Blending-Low"
|
|
case 0.5:
|
|
to_append += "_Blending-Medium"
|
|
case 0.7:
|
|
to_append += "_Blending-High"
|
|
|
|
# Selected image extension
|
|
to_append += f"{selected_image_extension}"
|
|
|
|
output_path += to_append
|
|
|
|
return output_path
|
|
|
|
|
|
def prepare_output_video_frame_filename(
|
|
frame_path: str,
|
|
selected_AI_model: str,
|
|
input_resize_factor: int,
|
|
output_resize_factor: int,
|
|
selected_blending_factor: float
|
|
) -> str:
|
|
|
|
file_path_no_extension, _ = os_path_splitext(frame_path)
|
|
output_path = file_path_no_extension
|
|
|
|
# Selected AI model
|
|
to_append = f"_{selected_AI_model}"
|
|
|
|
# Selected input resize
|
|
to_append += f"_InputR-{str(int(input_resize_factor * 100))}"
|
|
|
|
# Selected output resize
|
|
to_append += f"_OutputR-{str(int(output_resize_factor * 100))}"
|
|
|
|
# Selected intepolation
|
|
match selected_blending_factor:
|
|
case 0.3:
|
|
to_append += "_Blending-Low"
|
|
case 0.5:
|
|
to_append += "_Blending-Medium"
|
|
case 0.7:
|
|
to_append += "_Blending-High"
|
|
|
|
# Selected image extension
|
|
to_append += f".png"
|
|
|
|
output_path += to_append
|
|
|
|
return output_path
|
|
|
|
|
|
def prepare_output_video_filename(
|
|
video_path: str,
|
|
selected_output_path: str,
|
|
selected_AI_model: str,
|
|
frame_gen_factor: int,
|
|
slowmotion: bool,
|
|
input_resize_factor: int,
|
|
output_resize_factor: int,
|
|
selected_video_extension: str,
|
|
) -> str:
|
|
# FluidFrames-compatible signature and logic
|
|
if selected_output_path == OUTPUT_PATH_CODED:
|
|
file_path_no_extension, _ = os_path_splitext(video_path)
|
|
output_path = file_path_no_extension
|
|
else:
|
|
file_name = os_path_basename(video_path)
|
|
file_path_no_extension, _ = os_path_splitext(file_name)
|
|
output_path = f"{selected_output_path}{os_separator}{file_path_no_extension}"
|
|
|
|
# Selected AI model
|
|
to_append = f"_{selected_AI_model}x{str(frame_gen_factor)}"
|
|
# Slowmotion?
|
|
if slowmotion:
|
|
to_append += f"_slowmo"
|
|
# Selected input resize
|
|
to_append += f"_InputR-{str(int(input_resize_factor * 100))}"
|
|
# Selected output resize
|
|
to_append += f"_OutputR-{str(int(output_resize_factor * 100))}"
|
|
# Video extension
|
|
to_append += f"{selected_video_extension}"
|
|
|
|
output_path += to_append
|
|
|
|
return output_path
|
|
|
|
|
|
def prepare_output_video_directory_name(
|
|
video_path: str,
|
|
selected_output_path: str,
|
|
selected_AI_model: str,
|
|
frame_gen_factor: int,
|
|
slowmotion: bool,
|
|
input_resize_factor: int,
|
|
output_resize_factor: int,
|
|
) -> str:
|
|
# FluidFrames-style: compatible with interpolation models and upscalers
|
|
if selected_output_path == OUTPUT_PATH_CODED:
|
|
file_path_no_extension, _ = os_path_splitext(video_path)
|
|
output_path = file_path_no_extension
|
|
else:
|
|
file_name = os_path_basename(video_path)
|
|
file_path_no_extension, _ = os_path_splitext(file_name)
|
|
output_path = f"{selected_output_path}{os_separator}{file_path_no_extension}"
|
|
|
|
# Selected AI model
|
|
to_append = f"_{selected_AI_model}x{str(frame_gen_factor)}"
|
|
# Slowmotion?
|
|
if slowmotion:
|
|
to_append += f"_slowmo"
|
|
# Selected input resize
|
|
to_append += f"_InputR-{str(int(input_resize_factor * 100))}"
|
|
# Selected output resize
|
|
to_append += f"_OutputR-{str(int(output_resize_factor * 100))}"
|
|
output_path += to_append
|
|
return output_path
|
|
|
|
|
|
# ==== IMAGE/VIDEO UTILITIES SECTION ====
|
|
|
|
def get_video_fps(video_path: str) -> float:
|
|
"""Get video frame rate with proper validation."""
|
|
video_capture = opencv_VideoCapture(video_path)
|
|
|
|
if not video_capture.isOpened():
|
|
video_capture.release()
|
|
raise ValueError(f"Could not open video file: {video_path}")
|
|
|
|
frame_rate = video_capture.get(CAP_PROP_FPS)
|
|
video_capture.release()
|
|
|
|
if frame_rate <= 0 or frame_rate > 1000: # Sanity check
|
|
raise ValueError(
|
|
f"Invalid frame rate: {frame_rate} for video: {video_path}")
|
|
|
|
return frame_rate
|
|
|
|
|
|
def get_image_resolution(image: numpy_ndarray) -> tuple:
|
|
height = image.shape[0]
|
|
width = image.shape[1]
|
|
|
|
return height, width
|
|
|
|
|
|
def save_extracted_frames(
|
|
extracted_frames_paths: list[str],
|
|
extracted_frames: list[numpy_ndarray],
|
|
cpu_number: int
|
|
) -> None:
|
|
|
|
with ThreadPool(cpu_number) as pool:
|
|
pool.starmap(image_write, zip(
|
|
extracted_frames_paths, extracted_frames))
|
|
|
|
|
|
def extract_video_frames(
|
|
process_status_q: multiprocessing_Queue,
|
|
file_number: int,
|
|
target_directory: str,
|
|
AI_instance,
|
|
video_path: str,
|
|
cpu_number: int,
|
|
selected_image_extension: str
|
|
) -> List[str]:
|
|
"""Extract frames from video with proper error handling."""
|
|
try:
|
|
create_dir(target_directory)
|
|
|
|
# Check if video file exists
|
|
if not os_path_exists(video_path):
|
|
raise FileNotFoundError(f"Video file not found: {video_path}")
|
|
|
|
frames_number_to_save = cpu_number * ECTRACTION_FRAMES_FOR_CPU
|
|
video_capture = opencv_VideoCapture(video_path)
|
|
|
|
if not video_capture.isOpened():
|
|
raise ValueError(f"Could not open video file: {video_path}")
|
|
|
|
frame_count = int(video_capture.get(CAP_PROP_FRAME_COUNT))
|
|
|
|
# Check if frame count is valid
|
|
if frame_count <= 0:
|
|
raise ValueError(
|
|
f"Invalid frame count ({frame_count}) for video: {video_path}")
|
|
|
|
extracted_frames = []
|
|
extracted_frames_paths = []
|
|
video_frames_list = []
|
|
frame_index = 0
|
|
|
|
for frame_number in range(frame_count):
|
|
success, frame = video_capture.read()
|
|
if not success:
|
|
if frame_number == 0:
|
|
raise ValueError(
|
|
f"Could not read any frames from video: {video_path}")
|
|
print(
|
|
f"Warning: Could not read frame {frame_number}, stopping extraction")
|
|
break
|
|
|
|
try:
|
|
frame_path = f"{target_directory}{os_separator}frame_{frame_number:03d}{selected_image_extension}"
|
|
frame = AI_instance.resize_with_input_factor(frame)
|
|
extracted_frames.append(frame)
|
|
extracted_frames_paths.append(frame_path)
|
|
video_frames_list.append(frame_path)
|
|
except Exception as e:
|
|
print(
|
|
f"Warning: Error processing frame {frame_number}: {str(e)}")
|
|
continue
|
|
|
|
if len(extracted_frames) == frames_number_to_save:
|
|
percentage_extraction = (frame_number / frame_count) * 100
|
|
write_process_status(
|
|
process_status_q, f"{file_number}. Extracting video frames ({round(percentage_extraction, 2)}%)")
|
|
try:
|
|
save_extracted_frames(extracted_frames_paths,
|
|
extracted_frames, cpu_number)
|
|
except Exception as e:
|
|
print(f"Warning: Error saving frames batch: {str(e)}")
|
|
extracted_frames = []
|
|
extracted_frames_paths = []
|
|
|
|
frame_index += 1
|
|
|
|
video_capture.release()
|
|
|
|
if len(extracted_frames) > 0:
|
|
try:
|
|
save_extracted_frames(extracted_frames_paths,
|
|
extracted_frames, cpu_number)
|
|
except Exception as e:
|
|
print(f"Warning: Error saving final frames batch: {str(e)}")
|
|
|
|
if len(video_frames_list) == 0:
|
|
raise ValueError(
|
|
f"No frames were successfully extracted from video: {video_path}")
|
|
|
|
return video_frames_list
|
|
|
|
except Exception as e:
|
|
if 'video_capture' in locals():
|
|
video_capture.release()
|
|
write_process_status(
|
|
process_status_q, f"{ERROR_STATUS}Error extracting frames from {os_path_basename(video_path)}: {str(e)}")
|
|
raise
|
|
|
|
|
|
def validate_ffmpeg_executable() -> bool:
|
|
"""Validate FFmpeg executable and check its functionality."""
|
|
try:
|
|
if not os_path_exists(FFMPEG_EXE_PATH):
|
|
log_and_report_error(
|
|
"FFmpeg executable not found at expected path")
|
|
return False
|
|
|
|
# Test FFmpeg by getting version info
|
|
result = subprocess_run(
|
|
[FFMPEG_EXE_PATH, "-version"],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
log_and_report_error("FFmpeg executable test failed")
|
|
return False
|
|
|
|
print(f"[FFMPEG] Validation successful")
|
|
return True
|
|
except Exception as e:
|
|
log_and_report_error(f"FFmpeg validation error: {str(e)}")
|
|
return False
|
|
|
|
|
|
def get_video_codec_settings(selected_video_codec: str, video_info: dict) -> dict:
|
|
"""Get optimized codec settings based on video properties and user selection."""
|
|
width = video_info.get('width', 1920)
|
|
height = video_info.get('height', 1080)
|
|
|
|
# Base settings for different codecs
|
|
codec_settings = {
|
|
'x264': {
|
|
'codec': 'libx264',
|
|
'preset': 'medium',
|
|
'crf': '18',
|
|
'profile': 'high',
|
|
'level': '4.1',
|
|
'pix_fmt': 'yuv420p'
|
|
},
|
|
'x265': {
|
|
'codec': 'libx265',
|
|
'preset': 'medium',
|
|
'crf': '20',
|
|
'profile': 'main',
|
|
'pix_fmt': 'yuv420p'
|
|
},
|
|
'h264_nvenc': {
|
|
'codec': 'h264_nvenc',
|
|
'preset': 'p4',
|
|
'cq': '20',
|
|
'profile': 'high',
|
|
'level': '4.1',
|
|
'pix_fmt': 'yuv420p',
|
|
'rc': 'vbr'
|
|
},
|
|
'hevc_nvenc': {
|
|
'codec': 'hevc_nvenc',
|
|
'preset': 'p4',
|
|
'cq': '22',
|
|
'profile': 'main',
|
|
'pix_fmt': 'yuv420p',
|
|
'rc': 'vbr'
|
|
},
|
|
'h264_amf': {
|
|
'codec': 'h264_amf',
|
|
'quality': 'balanced',
|
|
'rc': 'cqp',
|
|
'qp_i': '20',
|
|
'qp_p': '22',
|
|
'qp_b': '24',
|
|
'profile': 'high'
|
|
},
|
|
'hevc_amf': {
|
|
'codec': 'hevc_amf',
|
|
'quality': 'balanced',
|
|
'rc': 'cqp',
|
|
'qp_i': '22',
|
|
'qp_p': '24',
|
|
'qp_b': '26',
|
|
'profile': 'main'
|
|
},
|
|
'h264_qsv': {
|
|
'codec': 'h264_qsv',
|
|
'preset': 'medium',
|
|
'global_quality': '20',
|
|
'profile': 'high',
|
|
'pix_fmt': 'nv12'
|
|
},
|
|
'hevc_qsv': {
|
|
'codec': 'hevc_qsv',
|
|
'preset': 'medium',
|
|
'global_quality': '22',
|
|
'profile': 'main',
|
|
'pix_fmt': 'nv12'
|
|
}
|
|
}
|
|
|
|
# Get base settings for the selected codec
|
|
settings = codec_settings.get(selected_video_codec, codec_settings['x264'])
|
|
|
|
# Adjust bitrate based on resolution
|
|
pixels = width * height
|
|
if pixels <= 720 * 480: # SD
|
|
bitrate = '2000k'
|
|
elif pixels <= 1280 * 720: # HD
|
|
bitrate = '5000k'
|
|
elif pixels <= 1920 * 1080: # FHD
|
|
bitrate = '8000k'
|
|
elif pixels <= 2560 * 1440: # QHD
|
|
bitrate = '12000k'
|
|
else: # 4K+
|
|
bitrate = '20000k'
|
|
|
|
settings['bitrate'] = bitrate
|
|
return settings
|
|
|
|
|
|
def test_codec_compatibility(codec_name: str) -> bool:
|
|
"""Test if a specific codec is available and working."""
|
|
try:
|
|
# Test encoding a single black frame
|
|
test_command = [
|
|
FFMPEG_EXE_PATH,
|
|
"-f", "lavfi",
|
|
"-i", "color=black:size=64x64:duration=0.1",
|
|
"-c:v", codec_name,
|
|
"-f", "null",
|
|
"-"
|
|
]
|
|
|
|
result = subprocess_run(
|
|
test_command,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10
|
|
)
|
|
|
|
return result.returncode == 0
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def build_encoding_command(
|
|
video_path: str,
|
|
txt_path: str,
|
|
no_audio_path: str,
|
|
codec_settings: dict,
|
|
video_fps: str
|
|
) -> list[str]:
|
|
"""Build FFmpeg encoding command with proper settings."""
|
|
|
|
base_command = [
|
|
FFMPEG_EXE_PATH,
|
|
"-y",
|
|
"-loglevel", "error",
|
|
"-stats",
|
|
"-f", "concat",
|
|
"-safe", "0",
|
|
"-r", video_fps,
|
|
"-i", txt_path,
|
|
"-c:v", codec_settings['codec']
|
|
]
|
|
|
|
# Add codec-specific parameters
|
|
codec = codec_settings['codec']
|
|
|
|
if 'libx264' in codec:
|
|
base_command.extend([
|
|
"-preset", codec_settings['preset'],
|
|
"-crf", codec_settings['crf'],
|
|
"-profile:v", codec_settings['profile'],
|
|
"-level:v", codec_settings['level'],
|
|
"-pix_fmt", codec_settings['pix_fmt'],
|
|
"-movflags", "+faststart"
|
|
])
|
|
elif 'libx265' in codec:
|
|
base_command.extend([
|
|
"-preset", codec_settings['preset'],
|
|
"-crf", codec_settings['crf'],
|
|
"-profile:v", codec_settings['profile'],
|
|
"-pix_fmt", codec_settings['pix_fmt'],
|
|
"-tag:v", "hvc1",
|
|
"-movflags", "+faststart"
|
|
])
|
|
elif 'nvenc' in codec:
|
|
base_command.extend([
|
|
"-preset", codec_settings['preset'],
|
|
"-rc", codec_settings['rc'],
|
|
"-cq", codec_settings['cq'],
|
|
"-profile:v", codec_settings['profile'],
|
|
"-pix_fmt", codec_settings['pix_fmt'],
|
|
"-movflags", "+faststart"
|
|
])
|
|
elif 'amf' in codec:
|
|
base_command.extend([
|
|
"-quality", codec_settings['quality'],
|
|
"-rc", codec_settings['rc'],
|
|
"-qp_i", codec_settings['qp_i'],
|
|
"-qp_p", codec_settings['qp_p'],
|
|
"-qp_b", codec_settings['qp_b'],
|
|
"-profile:v", codec_settings['profile']
|
|
])
|
|
elif 'qsv' in codec:
|
|
base_command.extend([
|
|
"-preset", codec_settings['preset'],
|
|
"-global_quality", codec_settings['global_quality'],
|
|
"-profile:v", codec_settings['profile'],
|
|
"-pix_fmt", codec_settings['pix_fmt']
|
|
])
|
|
else:
|
|
# Fallback for unknown codecs
|
|
base_command.extend([
|
|
"-b:v", codec_settings['bitrate'],
|
|
"-pix_fmt", "yuv420p",
|
|
"-movflags", "+faststart"
|
|
])
|
|
|
|
# Add output file
|
|
base_command.append(no_audio_path)
|
|
|
|
return base_command
|
|
|
|
|
|
def create_frame_list_file(frame_paths: list[str], txt_path: str) -> bool:
|
|
"""Create frame list file for FFmpeg concat demuxer with validation."""
|
|
try:
|
|
# Verify all frames exist and are readable
|
|
valid_frames = []
|
|
invalid_count = 0
|
|
|
|
for frame_path in frame_paths:
|
|
if os_path_exists(frame_path):
|
|
try:
|
|
# Quick file size check
|
|
if os_path_getsize(frame_path) > 0:
|
|
valid_frames.append(frame_path)
|
|
else:
|
|
invalid_count += 1
|
|
print(f"[WARNING] Empty frame file: {frame_path}")
|
|
except Exception:
|
|
invalid_count += 1
|
|
print(f"[WARNING] Cannot access frame file: {frame_path}")
|
|
else:
|
|
invalid_count += 1
|
|
print(f"[WARNING] Missing frame file: {frame_path}")
|
|
|
|
if invalid_count > 0:
|
|
print(
|
|
f"[WARNING] Found {invalid_count} invalid/missing frames out of {len(frame_paths)}")
|
|
|
|
if len(valid_frames) == 0:
|
|
raise ValueError("No valid frames found for video encoding")
|
|
|
|
# Create the frame list file
|
|
with open(txt_path, 'w', encoding='utf-8') as f:
|
|
for frame_path in valid_frames:
|
|
# Escape path for FFmpeg and use forward slashes
|
|
escaped_path = frame_path.replace(
|
|
'\\', '/').replace("'", "'\"'\"'")
|
|
f.write(f"file '{escaped_path}'\n")
|
|
|
|
print(f"[FFMPEG] Frame list created with {len(valid_frames)} frames")
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"[ERROR] Failed to create frame list file: {str(e)}")
|
|
return False
|
|
|
|
|
|
def video_encoding(
|
|
process_status_q: multiprocessing_Queue,
|
|
video_path: str,
|
|
video_output_path: str,
|
|
upscaled_frame_paths: list[str],
|
|
selected_video_codec: str,
|
|
fps_multiplier: int = 1,
|
|
) -> None:
|
|
"""
|
|
Video encoding function for Warlock-Studio.
|
|
INCLUDES AUTOMATIC FALLBACK TO X264.
|
|
"""
|
|
|
|
try:
|
|
# --- Preparación de rutas temporales ---
|
|
base_name = os_path_splitext(video_output_path)[0]
|
|
txt_path = f"{base_name}_frames.txt"
|
|
no_audio_path = f"{base_name}_no_audio{os_path_splitext(video_output_path)[1]}"
|
|
|
|
# Eliminar residuos previos
|
|
for temp_file in [txt_path, no_audio_path]:
|
|
if os_path_exists(temp_file):
|
|
try:
|
|
os_remove(temp_file)
|
|
except Exception as e:
|
|
print(
|
|
f"[WARNING] Temporary file could not be deleted {temp_file}: {e}")
|
|
|
|
# --- Obtener FPS del video original ---
|
|
try:
|
|
video_fps = get_video_fps(video_path)
|
|
if video_fps <= 0 or video_fps > 1000:
|
|
raise ValueError(f"FPS inválido: {video_fps}")
|
|
|
|
# Calcular FPS finales
|
|
final_fps = video_fps * fps_multiplier
|
|
video_fps_str = f"{final_fps:.6f}"
|
|
|
|
except Exception as e:
|
|
print(
|
|
f"[WARNING] Could not obtain FPS: {e}, using 30.0 by default")
|
|
video_fps_str = "30.000000"
|
|
|
|
# --- Crear lista de frames para FFmpeg ---
|
|
if not create_frame_list_file(upscaled_frame_paths, txt_path):
|
|
raise RuntimeError("Error creating the frames list file")
|
|
|
|
# ==============================================================================
|
|
# LOGICA DE FALLBACK (INTENTOS DE CODIFICACIÓN)
|
|
# ==============================================================================
|
|
|
|
# Intentaremos máximo 2 veces:
|
|
# Intento 0: El códec seleccionado por el usuario (ej. h264_nvenc)
|
|
# Intento 1: Fallback a CPU (x264) si el anterior falla
|
|
|
|
encoding_success = False
|
|
|
|
for attempt in range(2):
|
|
try:
|
|
# Determinar qué códec usar en este intento
|
|
current_codec = selected_video_codec
|
|
|
|
if attempt == 1:
|
|
# SI ESTAMOS EN EL INTENTO 1, ACTIVAMOS EL FALLBACK
|
|
process_status_q.put(
|
|
f"[LOG] [WARNING] Hardware encoding failed. Switching to CPU fallback (x264)...")
|
|
current_codec = 'x264'
|
|
|
|
# --- Configurar codificación ---
|
|
codec_settings = get_video_codec_settings(
|
|
current_codec, {'fps': video_fps_str})
|
|
encoding_command = build_encoding_command(
|
|
video_path, txt_path, no_audio_path, codec_settings, video_fps_str)
|
|
|
|
process_status_q.put(
|
|
f"[LOG] [FFMPEG] Attempt {attempt+1}: Encoding with {codec_settings['codec']}")
|
|
|
|
# --- Ejecutar FFmpeg ---
|
|
process = subprocess.Popen(
|
|
encoding_command,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
encoding='utf-8',
|
|
errors='replace',
|
|
creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
|
|
)
|
|
|
|
# Leer salida
|
|
for line in process.stdout:
|
|
line = line.strip()
|
|
if line:
|
|
process_status_q.put(f"[LOG] {line}")
|
|
|
|
process.wait()
|
|
|
|
# Verificar éxito
|
|
if process.returncode != 0:
|
|
# Si falló y es el primer intento, lanzamos excepción para que el 'except' la capture y active el fallback
|
|
if attempt == 0:
|
|
raise RuntimeError(
|
|
f"FFmpeg failed with code {process.returncode}")
|
|
else:
|
|
# Si falló el fallback (x264), ya es un error fatal
|
|
raise RuntimeError("FFmpeg CPU Fallback also failed.")
|
|
|
|
if not os_path_exists(no_audio_path):
|
|
if attempt == 0:
|
|
raise RuntimeError("Output file missing")
|
|
else:
|
|
raise RuntimeError(
|
|
"Output file missing after fallback")
|
|
|
|
# Si llegamos aquí, todo salió bien
|
|
output_size = os_path_getsize(no_audio_path)
|
|
if output_size < 1024:
|
|
if attempt == 0:
|
|
raise RuntimeError("Output file too small")
|
|
else:
|
|
raise RuntimeError(
|
|
"Output file too small after fallback")
|
|
|
|
process_status_q.put(
|
|
f"[LOG] [FFMPEG] Encoding complete ({output_size / (1024*1024):.1f} MB)")
|
|
encoding_success = True
|
|
break # Salir del bucle de intentos
|
|
|
|
except Exception as e:
|
|
# Si falló el intento 0 (Hardware), limpiamos y permitimos que el bucle continúe al intento 1 (CPU)
|
|
if attempt == 0:
|
|
process_status_q.put(
|
|
f"[LOG] [WARNING] Primary encoding failed: {e}. Preparing fallback...")
|
|
if os_path_exists(no_audio_path):
|
|
try:
|
|
os_remove(no_audio_path)
|
|
except:
|
|
pass
|
|
continue # Continúa al siguiente ciclo del for (fallback)
|
|
else:
|
|
# Si falló el intento 1, relanzamos el error final
|
|
raise e
|
|
|
|
if not encoding_success:
|
|
raise RuntimeError("Encoding failed after all attempts.")
|
|
|
|
# ==============================================================================
|
|
# FIN LOGICA DE FALLBACK - CONTINUA DETECCIÓN DE AUDIO
|
|
# ==============================================================================
|
|
|
|
# --- Detección de audio ---
|
|
process_status_q.put(
|
|
"[LOG] [FFMPEG] Checking audio track of original video...")
|
|
|
|
ffprobe_path = None
|
|
try:
|
|
ffprobe_guess = FFMPEG_EXE_PATH.replace(
|
|
"ffmpeg.exe", "ffprobe.exe")
|
|
if os_path_exists(ffprobe_guess):
|
|
ffprobe_path = ffprobe_guess
|
|
else:
|
|
ffprobe_guess2 = FFMPEG_EXE_PATH.replace(
|
|
"ffmpeg.exe", "ffprobe")
|
|
if os_path_exists(ffprobe_guess2):
|
|
ffprobe_path = ffprobe_guess2
|
|
except Exception:
|
|
ffprobe_path = None
|
|
|
|
has_audio = False
|
|
audio_codec = ""
|
|
|
|
if ffprobe_path:
|
|
try:
|
|
probe_cmd = [
|
|
ffprobe_path, "-v", "error", "-select_streams", "a",
|
|
"-show_entries", "stream=codec_name",
|
|
"-of", "default=noprint_wrappers=1:nokey=1", video_path
|
|
]
|
|
probe = subprocess_run(
|
|
probe_cmd, capture_output=True, text=True, timeout=30)
|
|
audio_codec = probe.stdout.strip()
|
|
has_audio = bool(audio_codec)
|
|
except Exception as e:
|
|
print(f"[WARNING] Audio probe failed: {e}")
|
|
has_audio = False
|
|
|
|
if not ffprobe_path or not has_audio:
|
|
try:
|
|
probe_cmd = [FFMPEG_EXE_PATH, "-i", video_path]
|
|
probe = subprocess_run(
|
|
probe_cmd, capture_output=True, text=True, timeout=20)
|
|
stderr_output = probe.stderr or probe.stdout or ""
|
|
if "Audio:" in stderr_output:
|
|
has_audio = True
|
|
except Exception:
|
|
pass
|
|
|
|
process_status_q.put(
|
|
f"[LOG] [FFMPEG] has_audio={has_audio}, codec={audio_codec}")
|
|
|
|
if has_audio:
|
|
audio_copy_cmd = [
|
|
FFMPEG_EXE_PATH, "-y", "-loglevel", "error",
|
|
"-i", video_path, "-i", no_audio_path,
|
|
"-c:v", "copy", "-c:a", "copy",
|
|
"-map", "1:v:0", "-map", "0:a",
|
|
"-shortest",
|
|
video_output_path
|
|
]
|
|
try:
|
|
process_status_q.put(
|
|
"[LOG] [FFMPEG] Trying to copy audio track...")
|
|
subprocess_run(audio_copy_cmd, check=True,
|
|
capture_output=True, text=True)
|
|
|
|
if os_path_exists(no_audio_path):
|
|
os_remove(no_audio_path)
|
|
process_status_q.put(
|
|
"[LOG] [FFMPEG] Audio copy completed successfully.")
|
|
return
|
|
except Exception as e:
|
|
process_status_q.put(
|
|
f"[LOG] [WARNING] Audio copy failed, re-encoding... ({e})")
|
|
|
|
audio_reencode_cmd = [
|
|
FFMPEG_EXE_PATH, "-y", "-loglevel", "error",
|
|
"-i", video_path, "-i", no_audio_path,
|
|
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
|
|
"-map", "1:v:0", "-map", "0:a",
|
|
"-shortest",
|
|
video_output_path
|
|
]
|
|
try:
|
|
subprocess_run(audio_reencode_cmd, check=True,
|
|
capture_output=True, text=True)
|
|
if os_path_exists(no_audio_path):
|
|
os_remove(no_audio_path)
|
|
process_status_q.put(
|
|
"[LOG] [FFMPEG] Audio re-encoding completed.")
|
|
return
|
|
except Exception as audio_error:
|
|
process_status_q.put(
|
|
f"[LOG] [WARNING] Audio re-encoding failed: {audio_error}")
|
|
|
|
try:
|
|
if os_path_exists(no_audio_path):
|
|
shutil_move(no_audio_path, video_output_path)
|
|
process_status_q.put(
|
|
"[LOG] [FFMPEG] Final video saved WITHOUT audio (fallback).")
|
|
return
|
|
except Exception as move_error:
|
|
raise RuntimeError(f"Could not move final file: {move_error}")
|
|
|
|
else:
|
|
try:
|
|
shutil_move(no_audio_path, video_output_path)
|
|
process_status_q.put(
|
|
"[LOG] [FFMPEG] Video saved (original was muted).")
|
|
return
|
|
except Exception as move_error:
|
|
raise RuntimeError(f"Video could not be saved: {move_error}")
|
|
|
|
except Exception as e:
|
|
error_msg = f"General error in video_encoding: {str(e)}"
|
|
log_and_report_error(error_msg)
|
|
write_process_status(process_status_q, f"{ERROR_STATUS}{error_msg}")
|
|
if 'txt_path' in locals() and os_path_exists(txt_path):
|
|
try:
|
|
os_remove(txt_path)
|
|
except:
|
|
pass
|
|
|
|
|
|
def check_video_upscaling_resume(
|
|
target_directory: str,
|
|
selected_AI_model: str
|
|
) -> bool:
|
|
|
|
if os_path_exists(target_directory):
|
|
directory_files = os_listdir(target_directory)
|
|
upscaled_frames_path = [
|
|
file for file in directory_files if selected_AI_model in file]
|
|
|
|
if len(upscaled_frames_path) > 1:
|
|
return True
|
|
else:
|
|
return False
|
|
else:
|
|
return False
|
|
|
|
|
|
def get_video_frames_for_upscaling_resume(
|
|
target_directory: str,
|
|
selected_AI_model: str,
|
|
) -> list[str]:
|
|
|
|
# Only file names
|
|
directory_files = os_listdir(target_directory)
|
|
original_frames_path = [
|
|
file for file in directory_files if file.endswith('.png')]
|
|
original_frames_path = [
|
|
file for file in original_frames_path if selected_AI_model not in file]
|
|
|
|
# Adding the complete path to file
|
|
original_frames_path = natsorted(
|
|
[os_path_join(target_directory, file) for file in original_frames_path])
|
|
|
|
return original_frames_path
|
|
|
|
|
|
def calculate_time_to_complete_video(
|
|
time_for_frame: float,
|
|
remaining_frames: int,
|
|
) -> str:
|
|
|
|
remaining_time = time_for_frame * remaining_frames
|
|
|
|
hours_left = remaining_time // 3600
|
|
minutes_left = (remaining_time % 3600) // 60
|
|
seconds_left = round((remaining_time % 3600) % 60)
|
|
|
|
time_left = ""
|
|
|
|
if int(hours_left) > 0:
|
|
time_left = f"{int(hours_left):02d}h"
|
|
|
|
if int(minutes_left) > 0:
|
|
time_left = f"{time_left}{int(minutes_left):02d}m"
|
|
|
|
if seconds_left > 0:
|
|
time_left = f"{time_left}{seconds_left:02d}s"
|
|
|
|
return time_left
|
|
|
|
|
|
def blend_images_and_save(
|
|
target_path: str,
|
|
starting_image: numpy_ndarray,
|
|
upscaled_image: numpy_ndarray,
|
|
starting_image_importance: float,
|
|
file_extension: str = ".png"
|
|
) -> None:
|
|
|
|
def add_alpha_channel(image: numpy_ndarray) -> numpy_ndarray:
|
|
if image.shape[2] == 3:
|
|
alpha = numpy_full(
|
|
(image.shape[0], image.shape[1], 1), 255, dtype=uint8)
|
|
image = numpy_concatenate((image, alpha), axis=2)
|
|
return image
|
|
|
|
def get_image_mode(image: numpy_ndarray) -> str:
|
|
shape = image.shape
|
|
if len(shape) == 2:
|
|
return "Grayscale"
|
|
elif len(shape) == 3 and shape[2] == 3:
|
|
return "RGB"
|
|
elif len(shape) == 3 and shape[2] == 4:
|
|
return "RGBA"
|
|
else:
|
|
return "Unknown"
|
|
|
|
upscaled_image_importance = 1 - starting_image_importance
|
|
starting_height, starting_width = get_image_resolution(starting_image)
|
|
target_height, target_width = get_image_resolution(upscaled_image)
|
|
|
|
starting_resolution = starting_height + starting_width
|
|
target_resolution = target_height + target_width
|
|
|
|
if starting_resolution > target_resolution:
|
|
starting_image = opencv_resize(
|
|
starting_image, (target_width, target_height), INTER_AREA)
|
|
else:
|
|
starting_image = opencv_resize(
|
|
starting_image, (target_width, target_height))
|
|
|
|
try:
|
|
starting_mode = get_image_mode(starting_image)
|
|
upscaled_mode = get_image_mode(upscaled_image)
|
|
|
|
if starting_mode == "RGBA" or upscaled_mode == "RGBA":
|
|
if starting_mode != "RGBA":
|
|
starting_image = add_alpha_channel(starting_image)
|
|
if upscaled_mode != "RGBA":
|
|
upscaled_image = add_alpha_channel(upscaled_image)
|
|
elif starting_mode == "RGB" and upscaled_mode != "RGB":
|
|
if upscaled_mode == "Grayscale":
|
|
upscaled_image = opencv_cvtColor(
|
|
upscaled_image, COLOR_GRAY2RGB)
|
|
elif upscaled_mode == "RGB" and starting_mode != "RGB":
|
|
if starting_mode == "Grayscale":
|
|
starting_image = opencv_cvtColor(
|
|
starting_image, COLOR_GRAY2RGB)
|
|
|
|
if starting_image.dtype != upscaled_image.dtype:
|
|
upscaled_image = upscaled_image.astype(starting_image.dtype)
|
|
|
|
interpolated_image = opencv_addWeighted(
|
|
starting_image, starting_image_importance, upscaled_image, upscaled_image_importance, 0)
|
|
image_write(target_path, interpolated_image, file_extension)
|
|
|
|
except Exception as e:
|
|
print(
|
|
f"[BLEND] Blending failed, saving original upscaled image: {str(e)}")
|
|
image_write(target_path, upscaled_image, file_extension)
|
|
|
|
|
|
# ==== CORE PROCESSING SECTION (CORREGIDO) ====
|
|
|
|
def check_upscale_steps() -> None:
|
|
global stop_thread_flag, app
|
|
|
|
# Limpiar flag al iniciar
|
|
stop_thread_flag.clear()
|
|
|
|
while not stop_thread_flag.is_set():
|
|
try:
|
|
# Lectura no bloqueante
|
|
actual_step = read_process_status()
|
|
|
|
if actual_step is None:
|
|
# Si no hay mensajes, verificar si el proceso sigue vivo
|
|
# Si el proceso murió inesperadamente sin mandar "Completed" o "Stop", salimos
|
|
global process_upscale_orchestrator
|
|
if process_upscale_orchestrator and not process_upscale_orchestrator.is_alive() and not stop_thread_flag.is_set():
|
|
# El proceso murió silenciosamente, forzamos el stop
|
|
actual_step = STOP_STATUS
|
|
else:
|
|
# Simplemente esperar y reintentar
|
|
sleep(0.1)
|
|
continue
|
|
|
|
# --- DETECTAR LOGS ---
|
|
if actual_step.startswith("[LOG]"):
|
|
log_text = actual_step.replace("[LOG] ", "")
|
|
console.write_log(log_text)
|
|
continue
|
|
|
|
# --- LÓGICA DE FINALIZACIÓN (Completado, Stop o Error) ---
|
|
if actual_step in [COMPLETED_STATUS, STOP_STATUS] or ERROR_STATUS in actual_step:
|
|
|
|
if actual_step == COMPLETED_STATUS:
|
|
info_message.set("All files completed!")
|
|
console.write_log(
|
|
"Process Completed Successfully", "SUCCESS")
|
|
elif actual_step == STOP_STATUS:
|
|
info_message.set("Process Stopped")
|
|
console.write_log("Process stopped by user", "WARNING")
|
|
elif ERROR_STATUS in actual_step:
|
|
err_msg = actual_step.replace(ERROR_STATUS, "")
|
|
info_message.set("Error occurred")
|
|
console.write_log(f"Error: {err_msg}", "ERROR")
|
|
show_error_message(err_msg)
|
|
|
|
# 1. Detener proceso físico
|
|
stop_upscale_process()
|
|
|
|
# 2. Romper el bucle
|
|
stop_thread_flag.set()
|
|
|
|
# 3. RESTAURAR UI (Regresar botón a "Make Magic")
|
|
# Usamos .after para asegurar que corra en el hilo principal de la UI
|
|
window.after(100, place_upscale_button)
|
|
break
|
|
|
|
# --- ESTADOS INTERMEDIOS ---
|
|
else:
|
|
info_message.set(actual_step)
|
|
# Opcional, puede ser mucho spam
|
|
console.write_log(f"Status: {actual_step}", "INFO")
|
|
|
|
except Exception as e:
|
|
print(f"[MONITOR ERROR] {e}")
|
|
stop_thread_flag.set()
|
|
window.after(100, place_upscale_button)
|
|
break
|
|
|
|
|
|
def read_process_status() -> str:
|
|
try:
|
|
# Intentar leer con un timeout muy corto (0.05s)
|
|
# Esto evita que la GUI se congele si no hay mensajes
|
|
return process_status_q.get(timeout=0.05)
|
|
except Exception:
|
|
# Si la cola está vacía (TimeOut), devolvemos None
|
|
return None
|
|
|
|
|
|
def write_process_status(process_status_q: multiprocessing_Queue, step: str) -> None:
|
|
# CORRECCIÓN CRITICA: NO VACIAR LA COLA ANTES DE ESCRIBIR
|
|
# Esto borraba los logs antes de que pudieran leerse.
|
|
print(f"[QUEUE] Put: {step}")
|
|
process_status_q.put(f"{step}")
|
|
|
|
|
|
def stop_upscale_process() -> None:
|
|
global process_upscale_orchestrator
|
|
try:
|
|
if 'process_upscale_orchestrator' in globals() and process_upscale_orchestrator:
|
|
if process_upscale_orchestrator.is_alive():
|
|
print("Terminating process...")
|
|
process_upscale_orchestrator.terminate()
|
|
# Esperar máx 1 segundo a que muera, si no, continuar
|
|
process_upscale_orchestrator.join(timeout=1.0)
|
|
if process_upscale_orchestrator.is_alive():
|
|
# Si sigue vivo (zombie), kill forzado (Python 3.7+)
|
|
try:
|
|
process_upscale_orchestrator.kill()
|
|
except:
|
|
pass
|
|
|
|
process_upscale_orchestrator = None
|
|
except Exception as e:
|
|
print(f"Error stopping process: {e}")
|
|
|
|
|
|
def stop_button_command() -> None:
|
|
# 1. Notificar visualmente inmediato
|
|
info_message.set("Stopping...")
|
|
|
|
# 2. Matar el proceso inmediatamente
|
|
stop_upscale_process()
|
|
|
|
# 3. Enviar señal a la cola para que el hilo de monitoreo (check_upscale_steps)
|
|
# sepa que debe salir y restaurar la UI.
|
|
write_process_status(process_status_q, STOP_STATUS)
|
|
|
|
|
|
def upscale_button_command() -> None:
|
|
# --- Unified upscaling/interpolation pipeline: FluidFrames integration ---
|
|
global selected_file_list
|
|
global selected_AI_model
|
|
global selected_gpu
|
|
global selected_keep_frames
|
|
global selected_AI_multithreading
|
|
global selected_blending_factor
|
|
global selected_image_extension
|
|
global selected_video_extension
|
|
global selected_video_codec
|
|
global tiles_resolution
|
|
global input_resize_factor
|
|
global output_resize_factor
|
|
global selected_frame_generation_option
|
|
global process_upscale_orchestrator
|
|
global stop_thread_flag
|
|
global process_upscale_orchestrator
|
|
global stop_thread_flag
|
|
|
|
# --- AGREGAR CONFIRMACIÓN ---
|
|
if not messagebox.askyesno("Start Processing", "Do you want to start the AI processing?"):
|
|
return
|
|
# ----------------------------
|
|
|
|
# Fix 2.2: Clear stop_thread_flag at the beginning of each execution
|
|
stop_thread_flag.clear()
|
|
|
|
if user_input_checks():
|
|
info_message.set("Loading")
|
|
cpu_number = int(os_cpu_count()/2)
|
|
print("=" * 50)
|
|
print(f"> Starting:")
|
|
print(f" Files to process: {len(selected_file_list)}")
|
|
print(f" Output path: {(selected_output_path.get())}")
|
|
print(f" Selected AI model: {selected_AI_model}")
|
|
print(
|
|
f" Selected frame generation option: {selected_frame_generation_option}")
|
|
print(f" Selected GPU: {selected_gpu}")
|
|
print(f" AI multithreading: {selected_AI_multithreading}")
|
|
print(f" Blending/factor: {selected_blending_factor}")
|
|
print(f" Selected image output extension: {selected_image_extension}")
|
|
print(f" Selected video output extension: {selected_video_extension}")
|
|
print(f" Selected video output codec: {selected_video_codec}")
|
|
print(
|
|
f" Tiles resolution (for GPU): {tiles_resolution}x{tiles_resolution}px")
|
|
print(f" Input resize: {int(input_resize_factor * 100)}%")
|
|
print(f" Output resize: {int(output_resize_factor * 100)}%")
|
|
print(f" CPU threads: {cpu_number}")
|
|
print(f" Save frames: {selected_keep_frames}")
|
|
print("=" * 50)
|
|
place_stop_button()
|
|
|
|
# Use FluidFrames' RIFE-based pipeline when relevant
|
|
if selected_AI_model in RIFE_models_list:
|
|
process_upscale_orchestrator = Process(
|
|
target=fluidframes_interpolation_pipeline,
|
|
args=(process_status_q, selected_file_list, selected_output_path.get(), selected_AI_model, selected_gpu,
|
|
selected_frame_generation_option, selected_image_extension, selected_video_extension, selected_video_codec,
|
|
input_resize_factor, output_resize_factor, cpu_number, selected_keep_frames)
|
|
)
|
|
process_upscale_orchestrator.start()
|
|
else:
|
|
process_upscale_orchestrator = Process(
|
|
target=upscale_orchestrator,
|
|
args=(process_status_q, selected_file_list, selected_output_path.get(), selected_AI_model, selected_AI_multithreading,
|
|
input_resize_factor, output_resize_factor, selected_gpu, tiles_resolution, selected_blending_factor,
|
|
selected_keep_frames, selected_image_extension, selected_video_extension, selected_video_codec, cpu_number,)
|
|
)
|
|
process_upscale_orchestrator.start()
|
|
|
|
thread_wait = Thread(target=check_upscale_steps)
|
|
thread_wait.start()
|
|
|
|
# --- Inserted: FluidFrames orchestration (minimal, reusing classes/logic copied from FluidFrames.py) ---
|
|
|
|
|
|
def fluidframes_interpolation_pipeline(
|
|
process_status_q, selected_file_list, selected_output_path, selected_AI_model, selected_gpu,
|
|
selected_generation_option, selected_image_extension, selected_video_extension, selected_video_codec,
|
|
input_resize_factor, output_resize_factor, cpu_number, selected_keep_frames):
|
|
'''
|
|
This function runs all the FluidFrames video/image interpolation generation logic in one go for Warlock-Studio.
|
|
'''
|
|
try:
|
|
frame_gen_factor, slowmotion = check_frame_generation_option(
|
|
selected_generation_option)
|
|
write_process_status(process_status_q, "Loading AI model")
|
|
AI_instance = AI_interpolation(
|
|
selected_AI_model, frame_gen_factor, selected_gpu, input_resize_factor, output_resize_factor)
|
|
how_many_files = len(selected_file_list)
|
|
for file_number in range(how_many_files):
|
|
file_path = selected_file_list[file_number]
|
|
current_file_number = file_number + 1
|
|
# Branch between video and image: only video gets interpolation
|
|
if check_if_file_is_video(file_path):
|
|
try:
|
|
fluidframes_video_interpolate(
|
|
process_status_q, file_path, current_file_number, selected_output_path, AI_instance,
|
|
selected_AI_model, frame_gen_factor, slowmotion, selected_image_extension, selected_video_extension,
|
|
selected_video_codec, input_resize_factor, output_resize_factor, cpu_number, selected_keep_frames
|
|
)
|
|
except Exception as file_error:
|
|
error_msg = f"Error processing {os_path_basename(file_path)}: {str(file_error)}"
|
|
log_and_report_error(error_msg)
|
|
write_process_status(
|
|
process_status_q, f"{ERROR_STATUS}{error_msg}")
|
|
continue # Continue with next file
|
|
else:
|
|
# If an image, just no-op/fail, or could add image interpolation, but that's not FluidFrames
|
|
write_process_status(
|
|
process_status_q, f"{current_file_number}. File is not a video; skipping interpolation for image files.")
|
|
write_process_status(process_status_q, f"{COMPLETED_STATUS}")
|
|
except Exception as exception:
|
|
error_msg = str(exception)
|
|
print(f"Error in FluidFrames interpolation pipeline: {error_msg}")
|
|
log_and_report_error(f"Interpolation error: {error_msg}")
|
|
write_process_status(
|
|
process_status_q, f"{ERROR_STATUS}Interpolation error: {error_msg}")
|
|
|
|
# Helper for generation options string -> factor/slowmotion
|
|
# (straight copy from FluidFrames.py, rename as needed)
|
|
|
|
|
|
def check_frame_generation_option(selected_generation_option):
|
|
slowmotion = False
|
|
frame_gen_factor = 0
|
|
if "Slowmotion" in selected_generation_option:
|
|
slowmotion = True
|
|
if "2" in selected_generation_option:
|
|
frame_gen_factor = 2
|
|
elif "4" in selected_generation_option:
|
|
frame_gen_factor = 4
|
|
elif "8" in selected_generation_option:
|
|
frame_gen_factor = 8
|
|
return frame_gen_factor, slowmotion
|
|
|
|
# Adapter: orchestration logic -- this wraps the full FluidFrames video flow
|
|
# (fluidframes_video_interpolate = mostly rename of video_frame_generation() + encoding etc; minimal adaptation)
|
|
|
|
|
|
def prepare_generated_frames_paths(
|
|
base_path: str,
|
|
selected_AI_model: str,
|
|
selected_image_extension: str,
|
|
frame_gen_factor: int
|
|
) -> list[str]:
|
|
generated_frames_paths = [
|
|
f"{base_path}_{selected_AI_model}_{i}{selected_image_extension}" for i in range(frame_gen_factor-1)]
|
|
return generated_frames_paths
|
|
|
|
|
|
def prepare_output_video_frame_filenames(
|
|
extracted_frames_paths: list[str],
|
|
selected_AI_model: str,
|
|
frame_gen_factor: int,
|
|
selected_image_extension: str,
|
|
) -> list[str]:
|
|
total_frames_paths = []
|
|
how_many_frames = len(extracted_frames_paths)
|
|
for index in range(how_many_frames - 1):
|
|
frame_path = extracted_frames_paths[index]
|
|
base_path = os_path_splitext(frame_path)[0]
|
|
generated_frames_paths = prepare_generated_frames_paths(
|
|
base_path, selected_AI_model, selected_image_extension, frame_gen_factor)
|
|
total_frames_paths.append(frame_path)
|
|
total_frames_paths.extend(generated_frames_paths)
|
|
total_frames_paths.append(extracted_frames_paths[-1])
|
|
return total_frames_paths
|
|
|
|
|
|
def prepare_output_video_frame_to_generate_filenames(
|
|
extracted_frames_paths: list[str],
|
|
selected_AI_model: str,
|
|
frame_gen_factor: int,
|
|
selected_image_extension: str,
|
|
) -> list[str]:
|
|
only_generated_frames_paths = []
|
|
how_many_frames = len(extracted_frames_paths)
|
|
for index in range(how_many_frames - 1):
|
|
frame_path = extracted_frames_paths[index]
|
|
base_path = os_path_splitext(frame_path)[0]
|
|
generated_frames_paths = prepare_generated_frames_paths(
|
|
base_path, selected_AI_model, selected_image_extension, frame_gen_factor)
|
|
only_generated_frames_paths.extend(generated_frames_paths)
|
|
return only_generated_frames_paths
|
|
|
|
|
|
def fluidframes_video_interpolate(
|
|
process_status_q, video_path, file_number, selected_output_path, AI_instance,
|
|
selected_AI_model, frame_gen_factor, slowmotion, selected_image_extension,
|
|
selected_video_extension, selected_video_codec, input_resize_factor, output_resize_factor, cpu_number, selected_keep_frames):
|
|
|
|
# Step 1. Setup output dirs
|
|
target_directory = prepare_output_video_directory_name(
|
|
video_path, selected_output_path, selected_AI_model, frame_gen_factor, slowmotion, input_resize_factor, output_resize_factor)
|
|
video_output_path = prepare_output_video_filename(
|
|
video_path, selected_output_path, selected_AI_model, frame_gen_factor, slowmotion, input_resize_factor, output_resize_factor, selected_video_extension)
|
|
|
|
# Step 2. Extract video frames
|
|
write_process_status(
|
|
process_status_q, f"{file_number}. Extracting video frames")
|
|
|
|
# Forzar .png para extracción temporal
|
|
temp_extraction_ext = ".png"
|
|
|
|
extracted_frames_paths = extract_video_frames(
|
|
process_status_q, file_number, target_directory, AI_instance, video_path, cpu_number, temp_extraction_ext)
|
|
|
|
# Step 3. Prepare output/gen frame names
|
|
total_frames_paths = prepare_output_video_frame_filenames(
|
|
extracted_frames_paths, selected_AI_model, frame_gen_factor, temp_extraction_ext)
|
|
|
|
# Step 4. Interpolated frames generation
|
|
write_process_status(
|
|
process_status_q, f"{file_number}. Video frame generation initializing...")
|
|
|
|
# --- LOGICA DE PROGRESO AÑADIDA ---
|
|
global global_processing_times_list
|
|
global_processing_times_list = []
|
|
|
|
total_pairs = len(extracted_frames_paths) - 1
|
|
|
|
for frame_index in range(total_pairs):
|
|
frame_1_path = extracted_frames_paths[frame_index]
|
|
frame_2_path = extracted_frames_paths[frame_index+1]
|
|
|
|
# Medir tiempo de carga e inferencia
|
|
start_timer = timer()
|
|
|
|
frame_1 = image_read(frame_1_path)
|
|
frame_2 = image_read(frame_2_path)
|
|
|
|
generated_frames = AI_instance.AI_orchestration(frame_1, frame_2)
|
|
|
|
# Save generated frames
|
|
generated_frames_paths = prepare_generated_frames_paths(
|
|
os_path_splitext(frame_1_path)[0], selected_AI_model, temp_extraction_ext, frame_gen_factor)
|
|
|
|
for i, gen_frame in enumerate(generated_frames):
|
|
image_write(generated_frames_paths[i], gen_frame)
|
|
|
|
end_timer = timer()
|
|
|
|
# --- CÁLCULO DE TIEMPO Y ACTUALIZACIÓN DE ESTADO ---
|
|
step_time = end_timer - start_timer
|
|
global_processing_times_list.append(step_time)
|
|
|
|
# Limitamos el tamaño de la lista de tiempos para mantener el promedio reciente
|
|
if len(global_processing_times_list) > 100:
|
|
global_processing_times_list.pop(0)
|
|
|
|
# Actualizar la interfaz cada 1% o cada frame si son pocos (evita saturar la GUI)
|
|
if total_pairs > 0 and (frame_index % max(1, int(total_pairs / 100)) == 0 or frame_index == total_pairs - 1):
|
|
avg_time = numpy_mean(global_processing_times_list)
|
|
remaining_frames = total_pairs - (frame_index + 1)
|
|
time_left = calculate_time_to_complete_video(
|
|
avg_time, remaining_frames)
|
|
percent_complete = ((frame_index + 1) / total_pairs) * 100
|
|
|
|
status_msg = f"{file_number}. Interpolating frames: {percent_complete:.1f}% completed ({time_left})"
|
|
write_process_status(process_status_q, status_msg)
|
|
|
|
# Step 6. Video encoding
|
|
write_process_status(
|
|
process_status_q, f"{file_number}. Encoding frame-generated video")
|
|
|
|
fps_multiplier = 1 if slowmotion else frame_gen_factor
|
|
|
|
video_encoding(
|
|
process_status_q, video_path, video_output_path, total_frames_paths, selected_video_codec, fps_multiplier)
|
|
|
|
# Step 7. Cleanup
|
|
if not selected_keep_frames and os_path_exists(target_directory):
|
|
try:
|
|
remove_directory(target_directory)
|
|
except Exception as e:
|
|
print(
|
|
f"Warning: Could not remove directory {target_directory}: {str(e)}")
|
|
|
|
# ==== ORCHESTRATOR SECTION ====
|
|
|
|
|
|
def upscale_orchestrator(
|
|
process_status_q: multiprocessing_Queue,
|
|
selected_file_list: list,
|
|
selected_output_path: str,
|
|
selected_AI_model: str,
|
|
selected_AI_multithreading: int,
|
|
input_resize_factor: int,
|
|
output_resize_factor: int,
|
|
selected_gpu: str,
|
|
tiles_resolution: int,
|
|
selected_blending_factor: float,
|
|
selected_keep_frames: bool,
|
|
selected_image_extension: str,
|
|
selected_video_extension: str,
|
|
selected_video_codec: str,
|
|
cpu_number: int,
|
|
) -> None:
|
|
|
|
global global_status_lock
|
|
global_status_lock = Lock()
|
|
|
|
try:
|
|
write_process_status(process_status_q, f"Loading AI model")
|
|
|
|
# Instanciar modelos
|
|
if selected_AI_model in Face_restoration_models_list:
|
|
AI_upscale_instance_list = [
|
|
AI_face_restoration(
|
|
selected_AI_model,
|
|
selected_gpu,
|
|
input_resize_factor,
|
|
output_resize_factor,
|
|
tiles_resolution
|
|
)
|
|
for _ in range(selected_AI_multithreading)
|
|
]
|
|
else:
|
|
AI_upscale_instance_list = [
|
|
AI_upscale(
|
|
selected_AI_model,
|
|
selected_gpu,
|
|
input_resize_factor,
|
|
output_resize_factor,
|
|
tiles_resolution
|
|
)
|
|
for _ in range(selected_AI_multithreading)
|
|
]
|
|
|
|
how_many_files = len(selected_file_list)
|
|
for file_number in range(how_many_files):
|
|
file_path = selected_file_list[file_number]
|
|
display_number = file_number + 1 # Fix index for display
|
|
|
|
if check_if_file_is_video(file_path):
|
|
upscale_video(
|
|
process_status_q,
|
|
file_path,
|
|
display_number,
|
|
selected_output_path,
|
|
AI_upscale_instance_list,
|
|
selected_AI_model,
|
|
input_resize_factor,
|
|
output_resize_factor,
|
|
cpu_number,
|
|
selected_video_extension,
|
|
selected_blending_factor,
|
|
selected_AI_multithreading,
|
|
selected_keep_frames,
|
|
selected_video_codec
|
|
)
|
|
else:
|
|
upscale_image(
|
|
process_status_q,
|
|
file_path,
|
|
display_number,
|
|
selected_output_path,
|
|
AI_upscale_instance_list[0],
|
|
selected_AI_model,
|
|
selected_image_extension,
|
|
input_resize_factor,
|
|
output_resize_factor,
|
|
selected_blending_factor
|
|
)
|
|
|
|
write_process_status(process_status_q, f"{COMPLETED_STATUS}")
|
|
|
|
except Exception as exception:
|
|
error_message = str(exception)
|
|
|
|
# Enviamos el error a la consola visual
|
|
write_process_status(
|
|
process_status_q, f"[LOG] [ERROR] Detalle técnico: {error_message}")
|
|
|
|
if "cannot convert float NaN to integer" in error_message:
|
|
friendly_msg = "Timeout del Driver de GPU. Intenta reiniciar sin borrar los frames."
|
|
write_process_status(
|
|
process_status_q, f"{ERROR_STATUS}{friendly_msg}")
|
|
elif "memory" in error_message.lower():
|
|
# Error específico de memoria
|
|
write_process_status(
|
|
process_status_q, f"{ERROR_STATUS}Memoria VRAM insuficiente. Baja la resolución de 'Tiles' o el 'Input %'.")
|
|
else:
|
|
write_process_status(
|
|
process_status_q, f"{ERROR_STATUS}{error_message}")
|
|
|
|
# Mantener impresión en consola terminal por si acaso
|
|
print(f"[ORCHESTRATOR ERROR] {error_message}")
|
|
|
|
# ==== IMAGE PROCESSING SECTION ====
|
|
|
|
|
|
def upscale_image(
|
|
process_status_q: multiprocessing_Queue,
|
|
image_path: str,
|
|
file_number: int,
|
|
selected_output_path: str,
|
|
AI_instance: AI_upscale,
|
|
selected_AI_model: str,
|
|
selected_image_extension: str,
|
|
input_resize_factor: int,
|
|
output_resize_factor: int,
|
|
selected_blending_factor: float
|
|
) -> None:
|
|
|
|
starting_image = image_read(image_path)
|
|
upscaled_image_path = prepare_output_image_filename(
|
|
image_path,
|
|
selected_output_path,
|
|
selected_AI_model,
|
|
input_resize_factor,
|
|
output_resize_factor,
|
|
selected_image_extension,
|
|
selected_blending_factor
|
|
)
|
|
|
|
write_process_status(
|
|
process_status_q, f"{file_number}. Enchanting your image. Be patient...")
|
|
upscaled_image = AI_instance.AI_orchestration(starting_image)
|
|
|
|
if selected_blending_factor > 0:
|
|
blend_images_and_save(
|
|
upscaled_image_path,
|
|
starting_image,
|
|
upscaled_image,
|
|
selected_blending_factor,
|
|
selected_image_extension
|
|
)
|
|
else:
|
|
image_write(
|
|
upscaled_image_path,
|
|
upscaled_image,
|
|
selected_image_extension
|
|
)
|
|
|
|
copy_file_metadata(image_path, upscaled_image_path)
|
|
|
|
# ==== VIDEO PROCESSING SECTION ====
|
|
|
|
|
|
def upscale_video(
|
|
process_status_q: multiprocessing_Queue,
|
|
video_path: str,
|
|
file_number: int,
|
|
selected_output_path: str,
|
|
AI_upscale_instance_list: list[AI_upscale],
|
|
selected_AI_model: str,
|
|
input_resize_factor: int,
|
|
output_resize_factor: int,
|
|
cpu_number: int,
|
|
selected_video_extension: str,
|
|
selected_blending_factor: float,
|
|
selected_AI_multithreading: int,
|
|
selected_keep_frames: bool,
|
|
selected_video_codec: str
|
|
) -> None:
|
|
|
|
# Internal functions
|
|
|
|
def update_process_status_videos(
|
|
process_status_q: multiprocessing_Queue,
|
|
file_number: int,
|
|
) -> None:
|
|
|
|
global global_upscaled_frames_paths
|
|
global global_processing_times_list
|
|
|
|
# Remaining frames
|
|
total_frames_counter = len(global_upscaled_frames_paths)
|
|
frames_already_upscaled_counter = len(
|
|
[path for path in global_upscaled_frames_paths if os_path_exists(path)])
|
|
frames_to_upscale_counter = len(
|
|
[path for path in global_upscaled_frames_paths if not os_path_exists(path)])
|
|
|
|
try:
|
|
average_processing_time = numpy_mean(global_processing_times_list)
|
|
except Exception:
|
|
average_processing_time = 0.0
|
|
|
|
remaining_frames = frames_to_upscale_counter
|
|
remaining_time = calculate_time_to_complete_video(
|
|
average_processing_time, remaining_frames)
|
|
if remaining_time != "":
|
|
percent_complete = (
|
|
frames_already_upscaled_counter / total_frames_counter) * 100
|
|
write_process_status(
|
|
process_status_q, f"{file_number}.Enchanting your video. Be patient... {percent_complete:.2f}% ({remaining_time})")
|
|
|
|
def save_multiple_upscaled_frame_async(
|
|
starting_frames_to_save: list[numpy_ndarray],
|
|
upscaled_frames_to_save: list[numpy_ndarray],
|
|
upscaled_frame_paths_to_save: list[str],
|
|
selected_blending_factor: float
|
|
) -> None:
|
|
|
|
for frame_index, _ in enumerate(upscaled_frames_to_save):
|
|
starting_frame = starting_frames_to_save[frame_index]
|
|
upscaled_frame = upscaled_frames_to_save[frame_index]
|
|
upscaled_frame_path = upscaled_frame_paths_to_save[frame_index]
|
|
|
|
if selected_blending_factor > 0:
|
|
blend_images_and_save(
|
|
upscaled_frame_path, starting_frame, upscaled_frame, selected_blending_factor)
|
|
else:
|
|
image_write(upscaled_frame_path, upscaled_frame)
|
|
|
|
def save_frames_on_disk(
|
|
starting_frames_to_save: list[numpy_ndarray],
|
|
upscaled_frames_to_save: list[numpy_ndarray],
|
|
upscaled_frame_paths_to_save: list[str],
|
|
selected_blending_factor: float
|
|
) -> None:
|
|
nonlocal writer_threads # Accedemos a la lista de hilos externa
|
|
|
|
# --- CORRECCIÓN: Limpieza de hilos muertos ---
|
|
# Antes de crear uno nuevo, eliminamos de la lista los que ya terminaron
|
|
writer_threads = [t for t in writer_threads if t.is_alive()]
|
|
# ---------------------------------------------
|
|
|
|
t = Thread(
|
|
target=save_multiple_upscaled_frame_async,
|
|
args=(
|
|
starting_frames_to_save,
|
|
upscaled_frames_to_save,
|
|
upscaled_frame_paths_to_save,
|
|
selected_blending_factor
|
|
)
|
|
)
|
|
writer_threads.append(t)
|
|
t.start()
|
|
|
|
def upscale_video_frames_async(
|
|
process_status_q: multiprocessing_Queue,
|
|
file_number: int,
|
|
threads_number: int,
|
|
AI_instance: AI_upscale,
|
|
extracted_frames_paths: list[str],
|
|
upscaled_frame_paths: list[str],
|
|
selected_blending_factor: float,
|
|
) -> None:
|
|
|
|
global global_processing_times_list
|
|
global global_can_i_update_status
|
|
global global_status_lock # Fix 2.3: Add thread lock for safe status updates
|
|
|
|
starting_frames_to_save = []
|
|
upscaled_frames_to_save = []
|
|
upscaled_frame_paths_to_save = []
|
|
consecutive_memory_errors = 0
|
|
max_memory_errors = 3
|
|
|
|
for frame_index in range(len(extracted_frames_paths)):
|
|
frame_path = extracted_frames_paths[frame_index]
|
|
upscaled_frame_path = upscaled_frame_paths[frame_index]
|
|
already_upscaled = os_path_exists(upscaled_frame_path)
|
|
|
|
if not already_upscaled:
|
|
start_timer = timer()
|
|
starting_frame = None
|
|
upscaled_frame = None
|
|
|
|
try:
|
|
# Read frame with error handling
|
|
starting_frame = image_read(frame_path)
|
|
if starting_frame is None or starting_frame.size == 0:
|
|
print(
|
|
f"[WARNING] Invalid frame data at {frame_path}, skipping")
|
|
continue
|
|
|
|
# Upscale frame with enhanced memory error handling
|
|
upscaled_frame = AI_instance.AI_orchestration(
|
|
starting_frame)
|
|
consecutive_memory_errors = 0 # Reset counter on success
|
|
|
|
except Exception as e:
|
|
error_msg = str(e).lower()
|
|
|
|
# Enhanced GPU memory error detection
|
|
if any(keyword in error_msg for keyword in ['memory', 'out of memory', 'allocation', 'cuda']):
|
|
consecutive_memory_errors += 1
|
|
print(
|
|
f"[GPU] Memory error #{consecutive_memory_errors} detected: {str(e)[:100]}...")
|
|
|
|
if consecutive_memory_errors >= max_memory_errors:
|
|
raise RuntimeError(
|
|
f"Too many consecutive GPU memory errors ({max_memory_errors}). Please reduce VRAM usage or batch size.")
|
|
|
|
# Progressive memory reduction strategy
|
|
original_tiles = AI_instance.max_resolution
|
|
# 2, 4, 8...
|
|
reduction_factor = 2 ** consecutive_memory_errors
|
|
new_resolution = max(
|
|
64, original_tiles // reduction_factor)
|
|
|
|
print(
|
|
f"[GPU] Reducing tiles resolution from {original_tiles} to {new_resolution} and retrying...")
|
|
AI_instance.max_resolution = new_resolution
|
|
|
|
# Force memory cleanup before retry
|
|
if starting_frame is not None:
|
|
del starting_frame
|
|
optimize_memory_usage()
|
|
|
|
try:
|
|
starting_frame = image_read(frame_path)
|
|
upscaled_frame = AI_instance.AI_orchestration(
|
|
starting_frame)
|
|
print(
|
|
f"[GPU] Retry successful with tiles resolution: {new_resolution}")
|
|
consecutive_memory_errors = 0 # Reset on successful retry
|
|
except Exception as retry_error:
|
|
# Restore original resolution if retry also fails
|
|
AI_instance.max_resolution = original_tiles
|
|
print(
|
|
f"[GPU] Retry failed: {str(retry_error)[:100]}...")
|
|
raise retry_error
|
|
else:
|
|
# Non-memory related error
|
|
logging.error(
|
|
f"Frame processing error at {frame_path}: {str(e)}")
|
|
raise e
|
|
|
|
# Validate upscaled frame
|
|
if upscaled_frame is None or upscaled_frame.size == 0:
|
|
print(
|
|
f"[WARNING] Upscaling produced invalid result for {frame_path}, skipping")
|
|
continue
|
|
|
|
# Adding frames in list to save
|
|
starting_frames_to_save.append(starting_frame)
|
|
upscaled_frames_to_save.append(upscaled_frame)
|
|
upscaled_frame_paths_to_save.append(upscaled_frame_path)
|
|
|
|
# Calculate processing time and update process status
|
|
end_timer = timer()
|
|
processing_time = (end_timer - start_timer)/threads_number
|
|
global_processing_times_list.append(processing_time)
|
|
|
|
# Fix 3.1: Write frames immediately to disk to reduce memory usage
|
|
if (frame_index + 1) % MULTIPLE_FRAMES_TO_SAVE == 0:
|
|
# Save frames present in RAM on disk
|
|
save_frames_on_disk(starting_frames_to_save, upscaled_frames_to_save,
|
|
upscaled_frame_paths_to_save, selected_blending_factor)
|
|
# Clear frame lists to free memory
|
|
starting_frames_to_save = []
|
|
upscaled_frames_to_save = []
|
|
upscaled_frame_paths_to_save = []
|
|
# Optimize memory usage
|
|
optimize_memory_usage()
|
|
|
|
# Fix 2.3: Use thread lock to safely modify status flag
|
|
with global_status_lock:
|
|
global_can_i_update_status = not global_can_i_update_status
|
|
if global_can_i_update_status:
|
|
update_process_status_videos(
|
|
process_status_q, file_number)
|
|
if len(global_processing_times_list) >= 100:
|
|
global_processing_times_list = []
|
|
|
|
if len(upscaled_frame_paths_to_save) > 0:
|
|
# Save frames still present in RAM on disk
|
|
save_frames_on_disk(starting_frames_to_save, upscaled_frames_to_save,
|
|
upscaled_frame_paths_to_save, selected_blending_factor)
|
|
starting_frames_to_save = []
|
|
upscaled_frames_to_save = []
|
|
upscaled_frame_paths_to_save = []
|
|
# Final memory optimization
|
|
optimize_memory_usage()
|
|
|
|
def upscale_video_frames(
|
|
process_status_q: multiprocessing_Queue,
|
|
file_number: int,
|
|
AI_upscale_instance_list: list[AI_upscale],
|
|
extracted_frames_paths: list[str],
|
|
upscaled_frame_paths: list[str],
|
|
threads_number: int,
|
|
selected_blending_factor: float,
|
|
) -> None:
|
|
|
|
global global_upscaled_frames_paths
|
|
global global_processing_times_list
|
|
global global_can_i_update_status
|
|
|
|
global_upscaled_frames_paths = upscaled_frame_paths
|
|
global_processing_times_list = []
|
|
global_can_i_update_status = False
|
|
|
|
chunk_size = len(extracted_frames_paths) // threads_number
|
|
extracted_frame_list_chunks = [extracted_frames_paths[i:i + chunk_size]
|
|
for i in range(0, len(extracted_frames_paths), chunk_size)]
|
|
upscaled_frame_list_chunks = [upscaled_frame_paths[i:i + chunk_size]
|
|
for i in range(0, len(upscaled_frame_paths), chunk_size)]
|
|
|
|
write_process_status(
|
|
process_status_q, f"{file_number}. Upscaling video. Be patient ({threads_number} threads)")
|
|
with ThreadPool(threads_number) as pool:
|
|
pool.starmap(
|
|
upscale_video_frames_async,
|
|
zip(
|
|
[process_status_q] * threads_number,
|
|
[file_number] * threads_number,
|
|
[threads_number] * threads_number,
|
|
AI_upscale_instance_list,
|
|
extracted_frame_list_chunks,
|
|
upscaled_frame_list_chunks,
|
|
[selected_blending_factor] * threads_number,
|
|
)
|
|
)
|
|
|
|
def check_forgotten_video_frames(
|
|
process_status_q: multiprocessing_Queue,
|
|
file_number: int,
|
|
AI_upscale_instance_list: AI_upscale,
|
|
extracted_frames_paths: list[str],
|
|
upscaled_frame_paths: list[str],
|
|
selected_blending_factor: float,
|
|
threads_number: int = 1,
|
|
):
|
|
|
|
sleep(1)
|
|
|
|
# Check if all the upscaled frames exist
|
|
frame_path_todo_list = []
|
|
upscaled_frame_path_todo_list = []
|
|
|
|
for frame_index in range(len(upscaled_frame_paths)):
|
|
extracted_frames_path = extracted_frames_paths[frame_index]
|
|
upscaled_frame_path = upscaled_frame_paths[frame_index]
|
|
|
|
if not os_path_exists(upscaled_frame_path):
|
|
frame_path_todo_list.append(extracted_frames_path)
|
|
upscaled_frame_path_todo_list.append(upscaled_frame_path)
|
|
|
|
if len(upscaled_frame_path_todo_list) > 0:
|
|
upscale_video_frames(
|
|
process_status_q,
|
|
file_number,
|
|
AI_upscale_instance_list,
|
|
extracted_frames_paths,
|
|
upscaled_frame_paths,
|
|
threads_number,
|
|
selected_blending_factor
|
|
)
|
|
|
|
# Main function
|
|
|
|
# Fix 2.1: Initialize writer_threads list to track frame writing threads
|
|
writer_threads = []
|
|
|
|
# 1.Preparation
|
|
target_directory = prepare_output_video_directory_name(
|
|
video_path, selected_output_path, selected_AI_model, 1, False, input_resize_factor, output_resize_factor)
|
|
video_output_path = prepare_output_video_filename(video_path, selected_output_path, selected_AI_model,
|
|
1, False, input_resize_factor, output_resize_factor, selected_video_extension)
|
|
|
|
# 2. Resume upscaling OR Extract video frames
|
|
video_upscale_continue = check_video_upscaling_resume(
|
|
target_directory, selected_AI_model)
|
|
if video_upscale_continue:
|
|
write_process_status(
|
|
process_status_q, f"{file_number}. Resume video upscaling")
|
|
extracted_frames_paths = get_video_frames_for_upscaling_resume(
|
|
target_directory, selected_AI_model)
|
|
else:
|
|
write_process_status(
|
|
process_status_q, f"{file_number}. Extracting video frames")
|
|
extracted_frames_paths = extract_video_frames(
|
|
process_status_q, file_number, target_directory, AI_upscale_instance_list[0], video_path, cpu_number, ".png")
|
|
|
|
upscaled_frame_paths = [prepare_output_video_frame_filename(
|
|
frame_path, selected_AI_model, input_resize_factor, output_resize_factor, selected_blending_factor) for frame_path in extracted_frames_paths]
|
|
|
|
# 3. Check if video need tiles OR video multithreading upscale
|
|
multiframes_supported_by_gpu = AI_upscale_instance_list[0].calculate_multiframes_supported_by_gpu(
|
|
extracted_frames_paths[0])
|
|
threads_number = min(multiframes_supported_by_gpu,
|
|
selected_AI_multithreading)
|
|
if threads_number <= 0:
|
|
threads_number = 1
|
|
|
|
# 4. Upscaling video frames
|
|
write_process_status(process_status_q, f"{file_number}. Upscaling video")
|
|
upscale_video_frames(process_status_q, file_number, AI_upscale_instance_list,
|
|
extracted_frames_paths, upscaled_frame_paths, threads_number, selected_blending_factor)
|
|
|
|
# 5. Check for forgotten video frames
|
|
check_forgotten_video_frames(process_status_q, file_number, AI_upscale_instance_list,
|
|
extracted_frames_paths, upscaled_frame_paths, selected_blending_factor)
|
|
|
|
# Fix 2.1: Wait for all writer threads to complete before encoding
|
|
for t in writer_threads:
|
|
t.join()
|
|
|
|
# 6. Video encoding
|
|
write_process_status(
|
|
process_status_q, f"{file_number}. Encoding upscaled video")
|
|
|
|
# --- CORRECCIÓN: Argumento fps_multiplier ---
|
|
# El upscaling no cambia FPS, por lo tanto multiplier es 1
|
|
video_encoding(process_status_q, video_path, video_output_path,
|
|
upscaled_frame_paths, selected_video_codec, fps_multiplier=1)
|
|
|
|
# 7. Delete frames folder
|
|
if not selected_keep_frames:
|
|
if os_path_exists(target_directory):
|
|
try:
|
|
remove_directory(target_directory)
|
|
except Exception as e:
|
|
print(
|
|
f"Warning: Could not remove directory {target_directory}: {str(e)}")
|
|
|
|
|
|
# ==== GUI UTILITIES SECTION ====
|
|
|
|
def check_if_file_is_video(file: str) -> bool:
|
|
return any(video_extension in file for video_extension in supported_video_extensions)
|
|
|
|
|
|
def validate_configuration() -> bool:
|
|
"""Comprehensive configuration validation."""
|
|
errors = []
|
|
|
|
# Check AI model compatibility
|
|
if selected_AI_model == MENU_LIST_SEPARATOR[0]:
|
|
errors.append("Invalid AI model selected")
|
|
|
|
# Check frame generation compatibility
|
|
if selected_AI_model in RIFE_models_list and selected_frame_generation_option == "OFF":
|
|
errors.append("Frame generation option required for RIFE models")
|
|
|
|
# Check system requirements
|
|
if not validate_system_requirements():
|
|
errors.append("System requirements not met")
|
|
|
|
if errors:
|
|
for error in errors:
|
|
log_and_report_error(error)
|
|
return False
|
|
return True
|
|
|
|
|
|
def user_input_checks() -> bool:
|
|
global selected_file_list
|
|
global selected_AI_model
|
|
global selected_image_extension
|
|
global tiles_resolution
|
|
global input_resize_factor
|
|
global output_resize_factor
|
|
|
|
# Enhanced file validation
|
|
try:
|
|
# Esto llama al método del nuevo FileQueueManager
|
|
selected_file_list = file_widget.get_selected_file_list()
|
|
except Exception:
|
|
info_message.set("Please select a file")
|
|
return False
|
|
|
|
if not selected_file_list or len(selected_file_list) <= 0:
|
|
info_message.set("Please select a file")
|
|
return False
|
|
|
|
# Enhanced file validation
|
|
try:
|
|
selected_file_list = file_widget.get_selected_file_list()
|
|
except Exception:
|
|
info_message.set("Please select a file")
|
|
return False
|
|
|
|
if not selected_file_list or len(selected_file_list) <= 0:
|
|
info_message.set("Please select a file")
|
|
return False
|
|
|
|
# Validate file paths and accessibility
|
|
if not validate_file_paths(selected_file_list):
|
|
info_message.set("File validation failed. Check log for details.")
|
|
return False
|
|
|
|
# Validate output path
|
|
if not validate_output_path(selected_output_path.get()):
|
|
info_message.set("Output path validation failed")
|
|
return False
|
|
|
|
# Additional configuration validation
|
|
if not validate_configuration():
|
|
info_message.set("Configuration validation failed")
|
|
return False
|
|
|
|
# AI model
|
|
if selected_AI_model == MENU_LIST_SEPARATOR[0]:
|
|
info_message.set("Please select the AI model")
|
|
return False
|
|
|
|
# --- FIX: STRICT NORMALIZATION OF RESIZE FACTORS ---
|
|
try:
|
|
# Obtener valor crudo (ej: "50" o "100")
|
|
raw_input = float(str(selected_input_resize_factor.get()))
|
|
# Convertir estrictamente a factor (ej: 0.5 o 1.0)
|
|
input_resize_factor = raw_input / 100.0
|
|
|
|
if input_resize_factor <= 0:
|
|
raise ValueError("Value must be > 0")
|
|
except (ValueError, TypeError):
|
|
info_message.set("Input resolution % must be a valid number > 0")
|
|
return False
|
|
|
|
try:
|
|
raw_output = float(str(selected_output_resize_factor.get()))
|
|
output_resize_factor = raw_output / 100.0
|
|
|
|
if output_resize_factor <= 0:
|
|
raise ValueError("Value must be > 0")
|
|
except (ValueError, TypeError):
|
|
info_message.set("Output resolution % must be a valid number > 0")
|
|
return False
|
|
# ---------------------------------------------------
|
|
|
|
# VRAM limiter
|
|
try:
|
|
vram_gb = float(str(selected_VRAM_limiter.get()))
|
|
if vram_gb <= 0:
|
|
info_message.set("GPU VRAM value must be a value > 0")
|
|
return False
|
|
|
|
vram_multiplier = VRAM_model_usage.get(selected_AI_model, 1.0)
|
|
|
|
# Cálculo corregido: VRAM (GB) * Multiplicador * 100 (Base tile size)
|
|
selected_vram_factor = vram_multiplier * vram_gb
|
|
tiles_resolution = int(selected_vram_factor * 100)
|
|
|
|
except (ValueError, TypeError):
|
|
info_message.set("GPU VRAM value must be a number")
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def show_error_message(exception: str) -> None:
|
|
try:
|
|
messageBox_title = "Upscale error"
|
|
messageBox_subtitle = "Please report the error on Github, SourceForge or write to us on negroayub97@gmail.com."
|
|
messageBox_text = f"\n {str(exception)} \n"
|
|
|
|
MessageBox(
|
|
messageType="error",
|
|
title=messageBox_title,
|
|
subtitle=messageBox_subtitle,
|
|
default_value=None,
|
|
option_list=[messageBox_text]
|
|
)
|
|
except Exception as e:
|
|
print(f"[ERROR] Could not show error message: {str(e)}")
|
|
print(f"[ERROR] Original error was: {exception}")
|
|
|
|
|
|
def get_upscale_factor() -> int:
|
|
global selected_AI_model
|
|
upscale_factor = 1 # Default value for most models
|
|
if MENU_LIST_SEPARATOR[0] in selected_AI_model:
|
|
upscale_factor = 0
|
|
elif 'x1' in selected_AI_model:
|
|
upscale_factor = 1
|
|
elif 'x2' in selected_AI_model:
|
|
upscale_factor = 2
|
|
elif 'x4' in selected_AI_model:
|
|
upscale_factor = 4
|
|
elif selected_AI_model in RIFE_models_list:
|
|
# RIFE interpolation models do not use upscaling; fallback to 1, not used
|
|
upscale_factor = 1
|
|
return upscale_factor
|
|
|
|
|
|
def open_files_action(files=None):
|
|
# Función auxiliar interna
|
|
def check_supported_selected_files(uploaded_file_list: list) -> list:
|
|
return [file for file in uploaded_file_list if any(supported_extension in file for supported_extension in supported_file_extensions)]
|
|
|
|
info_message.set("Processing files...")
|
|
|
|
if files:
|
|
# Caso A: Drag & Drop
|
|
uploaded_files_list = list(files)
|
|
else:
|
|
# Caso B: Botón manual
|
|
info_message.set("Selecting files")
|
|
uploaded_files_list = list(filedialog.askopenfilenames())
|
|
|
|
if not uploaded_files_list:
|
|
return
|
|
|
|
# Filtrar archivos
|
|
supported_files_list = check_supported_selected_files(uploaded_files_list)
|
|
|
|
if supported_files_list:
|
|
# 1. Configurar los factores actuales en el widget antes de añadir
|
|
upscale_factor, input_resize_factor, output_resize_factor = get_values_for_file_widget()
|
|
file_widget.set_upscale_factor(upscale_factor)
|
|
file_widget.set_input_resize_factor(input_resize_factor)
|
|
file_widget.set_output_resize_factor(output_resize_factor)
|
|
|
|
# 2. Añadir archivos (la carga pesada ocurre en segundo plano en el nuevo módulo)
|
|
file_widget.add_files(supported_files_list)
|
|
|
|
# 3. Cambiar vista
|
|
show_file_manager()
|
|
|
|
info_message.set("Ready to be enchanted!")
|
|
print(f"> Added {len(supported_files_list)} files to queue.")
|
|
else:
|
|
info_message.set("No supported files selected")
|
|
|
|
|
|
def open_output_path_action():
|
|
asked_selected_output_path = filedialog.askdirectory()
|
|
if asked_selected_output_path == "":
|
|
selected_output_path.set(OUTPUT_PATH_CODED)
|
|
else:
|
|
selected_output_path.set(asked_selected_output_path)
|
|
|
|
# ==== GUI MENU SELECTION SECTION ====
|
|
|
|
|
|
def select_AI_from_menu(selected_option: str) -> None:
|
|
global selected_AI_model
|
|
global selected_blending_factor # <-- AÑADIR
|
|
global selected_frame_generation_option # <-- AÑADIR
|
|
|
|
selected_AI_model = selected_option
|
|
update_file_widget(1, 2, 3)
|
|
|
|
# --- Improved: instant dynamic refresh for conditional FluidFrames menus ---
|
|
clear_dynamic_menus()
|
|
|
|
# FluidFrames/RIFE: Show frame generation menu, otherwise show blending
|
|
if selected_AI_model in RIFE_models_list:
|
|
place_frame_generation_menu()
|
|
# Restablecer el factor de blending a OFF cuando RIFE es elegido
|
|
selected_blending_factor = 0
|
|
else:
|
|
# Restablecer la generación de frames a OFF para cualquier modelo que no sea RIFE
|
|
selected_frame_generation_option = "OFF"
|
|
|
|
# Face restoration models don't need blending (they work differently)
|
|
if selected_AI_model not in Face_restoration_models_list:
|
|
place_AI_blending_menu()
|
|
# Los modelos de restauración facial (GFPGAN) tampoco usan blending
|
|
elif selected_AI_model in Face_restoration_models_list:
|
|
# Asegurarse de que el blending esté en OFF para GFPGAN
|
|
selected_blending_factor = 0
|
|
|
|
# Always restore other key controls
|
|
place_AI_multithreading_menu()
|
|
place_input_output_resolution_textboxs()
|
|
place_gpu_gpuVRAM_menus()
|
|
place_video_codec_keep_frames_menus()
|
|
place_image_video_output_menus()
|
|
place_output_path_textbox()
|
|
place_integrated_console()
|
|
place_upscale_button()
|
|
|
|
|
|
def clear_dynamic_menus() -> None:
|
|
"""Clear any existing dynamic menus from the interface"""
|
|
# This will be called to clear menus before placing new ones
|
|
try:
|
|
for widget in window.winfo_children():
|
|
widget_info = widget.place_info()
|
|
if widget_info and float(widget_info.get('rely', 0)) == row2:
|
|
widget.place_forget()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def select_AI_multithreading_from_menu(selected_option: str) -> None:
|
|
global selected_AI_multithreading
|
|
if selected_option == "OFF":
|
|
selected_AI_multithreading = 1
|
|
else:
|
|
selected_AI_multithreading = int(selected_option.split()[0])
|
|
|
|
|
|
def select_blending_from_menu(selected_option: str) -> None:
|
|
global selected_blending_factor
|
|
|
|
match selected_option:
|
|
case "OFF": selected_blending_factor = 0
|
|
case "Low": selected_blending_factor = 0.3
|
|
case "Medium": selected_blending_factor = 0.5
|
|
case "High": selected_blending_factor = 0.7
|
|
|
|
|
|
def select_gpu_from_menu(selected_option: str) -> None:
|
|
global selected_gpu
|
|
selected_gpu = selected_option
|
|
|
|
|
|
def select_save_frame_from_menu(selected_option: str):
|
|
global selected_keep_frames
|
|
if selected_option == "ON":
|
|
selected_keep_frames = True
|
|
elif selected_option == "OFF":
|
|
selected_keep_frames = False
|
|
|
|
|
|
def select_image_extension_from_menu(selected_option: str) -> None:
|
|
global selected_image_extension
|
|
selected_image_extension = selected_option
|
|
|
|
|
|
def select_video_extension_from_menu(selected_option: str) -> None:
|
|
global selected_video_extension
|
|
selected_video_extension = selected_option
|
|
|
|
|
|
def select_video_codec_from_menu(selected_option: str) -> None:
|
|
global selected_video_codec
|
|
selected_video_codec = selected_option
|
|
|
|
|
|
def select_frame_generation_from_menu(selected_option: str) -> None:
|
|
global selected_frame_generation_option
|
|
selected_frame_generation_option = selected_option
|
|
|
|
# ==== GUI LAYOUT SECTION ====
|
|
|
|
# --- FLUIDFRAMES: Handle Interpolator menus/logic ---
|
|
|
|
|
|
def is_rife_model_selected():
|
|
global selected_AI_model
|
|
return selected_AI_model in RIFE_models_list
|
|
|
|
|
|
def get_generation_options_list():
|
|
# Only show on RIFE-based
|
|
if is_rife_model_selected():
|
|
return frame_generation_options_list
|
|
return ["OFF"]
|
|
|
|
|
|
def place_dynamic_rife_interpolator():
|
|
clear_dynamic_menus()
|
|
if is_rife_model_selected():
|
|
place_frame_generation_menu()
|
|
else:
|
|
place_AI_blending_menu()
|
|
|
|
# END FLUIDFRAMES
|
|
|
|
|
|
# Variables globales para manejar las vistas
|
|
drop_zone_frame = None
|
|
file_widget = None # Esta será la instancia de FileQueueManager
|
|
|
|
|
|
def show_drop_zone():
|
|
"""Oculta la lista de archivos y muestra la zona de carga."""
|
|
if file_widget:
|
|
file_widget.place_forget()
|
|
if drop_zone_frame:
|
|
drop_zone_frame.place(relx=0.0, rely=0.0, relwidth=0.5, relheight=1.0)
|
|
info_message.set("No files selected")
|
|
|
|
|
|
def show_file_manager():
|
|
"""Oculta la zona de carga y muestra la lista de archivos."""
|
|
if drop_zone_frame:
|
|
drop_zone_frame.place_forget()
|
|
if file_widget:
|
|
file_widget.place(relx=0.0, rely=0.0, relwidth=0.5, relheight=1.0)
|
|
|
|
|
|
def place_loadFile_section():
|
|
global drop_zone_frame, file_widget
|
|
|
|
# --- 1. Crear el Frame de la Drop Zone (Inicialmente Visible) ---
|
|
drop_zone_frame = CTkFrame(
|
|
master=window, fg_color=background_color, corner_radius=1)
|
|
|
|
text_drop = (" SUPPORTED FILES \n\n "
|
|
+ "IMAGES • jpg, jpeg, png, bmp, tiff, tif, webp \n "
|
|
+ "VIDEOS • mp4, avi, mkv, mov, wmv, flv, webm ")
|
|
|
|
input_file_text = CTkLabel(
|
|
master=drop_zone_frame,
|
|
text=text_drop,
|
|
fg_color=widget_background_color,
|
|
bg_color=background_color,
|
|
text_color=secondary_text_color,
|
|
width=300,
|
|
height=150,
|
|
font=bold13,
|
|
anchor="center",
|
|
corner_radius=10
|
|
)
|
|
|
|
input_file_button = CTkButton(
|
|
master=drop_zone_frame,
|
|
command=open_files_action, # Llama a la función modificada abajo
|
|
text="Select Files or Drag & Drop",
|
|
width=150,
|
|
height=30,
|
|
font=bold12,
|
|
border_width=1,
|
|
corner_radius=1,
|
|
fg_color=widget_background_color,
|
|
text_color=text_color,
|
|
border_color=accent_color,
|
|
hover_color=button_hover_color
|
|
)
|
|
|
|
# Colocar elementos dentro del frame de Drop Zone
|
|
input_file_text.place(relx=0.5, rely=0.4, anchor="center")
|
|
input_file_button.place(relx=0.5, rely=0.5, anchor="center")
|
|
|
|
# Mostrar Drop Zone por defecto
|
|
drop_zone_frame.place(relx=0.0, rely=0.0, relwidth=0.5, relheight=1.0)
|
|
|
|
# --- 2. Instanciar FileQueueManager (Inicialmente Oculto) ---
|
|
# Usamos show_drop_zone como callback para cuando el usuario limpie la lista
|
|
file_widget = FileQueueManager(
|
|
master=window,
|
|
clear_icon=clear_icon,
|
|
on_queue_empty_callback=show_drop_zone,
|
|
width=300, # <-- Esto es un kwargs
|
|
)
|
|
|
|
# Habilitar Drag & Drop en AMBOS componentes (Drop Zone y File Manager)
|
|
# Esto permite arrastrar archivos incluso si ya hay una lista visible
|
|
enable_drag_and_drop(window, [
|
|
drop_zone_frame, input_file_button, input_file_text, file_widget], open_files_action)
|
|
|
|
|
|
def place_app_name():
|
|
background = CTkFrame(
|
|
master=window, fg_color=background_color, corner_radius=1)
|
|
app_name_label = CTkLabel(
|
|
master=window,
|
|
text=app_name + " " + version,
|
|
fg_color="transparent",
|
|
text_color=app_name_color,
|
|
font=bold20,
|
|
anchor="w"
|
|
)
|
|
background.place(relx=0.5, rely=0.0, relwidth=0.5, relheight=1.0)
|
|
app_name_label.place(relx=column_1 - 0.05, rely=0.04, anchor="center")
|
|
|
|
|
|
def place_AI_menu():
|
|
|
|
def open_info_AI_model():
|
|
option_list = [
|
|
"\n IRCNN_Mx1 | IRCNN_Lx1 \n"
|
|
"\n • Simple and lightweight AI models\n"
|
|
" • Year: 2017\n"
|
|
" • Function: Denoising\n",
|
|
|
|
"\n RealESR_Gx4 | RealESR_Animex4 \n"
|
|
"\n • Fast and lightweight AI models\n"
|
|
" • Year: 2022\n"
|
|
" • Function: Upscaling\n",
|
|
|
|
"\n BSRGANx2 | BSRGANx4 | RealESRGANx4 | RealESRNetx4 \n"
|
|
"\n • Complex and heavy AI models\n"
|
|
" • Year: 2020\n"
|
|
" • Function: High-quality upscaling\n",
|
|
|
|
"\n GFPGAN \n"
|
|
"\n • Generative Face Prior GAN for face restoration\n"
|
|
" • Year: 2021\n"
|
|
" • Function: Face restoration and enhancement\n"
|
|
" • Excellent for old/blurry photos\n",
|
|
|
|
"\n RIFE | RIFE Lite\n" +
|
|
" • The complete RIFE AI model & Lite version\n" +
|
|
" • Excellent frame generation quality\n" +
|
|
" • Lite is 10% faster than full model\n" +
|
|
" • Recommended for GPUs with VRAM < 4GB \n",
|
|
]
|
|
|
|
MessageBox(
|
|
messageType="info",
|
|
title="AI model",
|
|
subtitle="This widget allows to choose between different AI models for upscaling",
|
|
default_value=None,
|
|
option_list=option_list
|
|
)
|
|
|
|
widget_row = row1
|
|
background = create_option_background()
|
|
background.place(relx=0.75, rely=widget_row,
|
|
relwidth=0.48, anchor="center")
|
|
|
|
info_button = create_info_button(open_info_AI_model, "AI model")
|
|
option_menu = create_option_menu(
|
|
select_AI_from_menu, AI_models_list, default_AI_model)
|
|
|
|
info_button.place(relx=column_info1, rely=widget_row -
|
|
0.003, anchor="center")
|
|
option_menu.place(relx=column_3_5, rely=widget_row,
|
|
anchor="center")
|
|
|
|
|
|
def place_frame_generation_menu():
|
|
|
|
def open_info_frame_generation():
|
|
option_list = [
|
|
"\n FRAME GENERATION\n" +
|
|
" • x2 - doubles video framerate • 30fps => 60fps\n" +
|
|
" • x4 - quadruples video framerate • 30fps => 120fps\n" +
|
|
" • x8 - octuplicate video framerate • 30fps => 240fps\n",
|
|
|
|
"\n SLOWMOTION (no audio)\n" +
|
|
" • Slowmotion x2 - slowmotion effect by a factor of 2\n" +
|
|
" • Slowmotion x4 - slowmotion effect by a factor of 4\n" +
|
|
" • Slowmotion x8 - slowmotion effect by a factor of 8\n"
|
|
]
|
|
|
|
MessageBox(
|
|
messageType="info",
|
|
title="AI frame generation",
|
|
subtitle=" This widget allows to choose between different AI frame generation option",
|
|
default_value=None,
|
|
option_list=option_list
|
|
)
|
|
|
|
widget_row = row2
|
|
background = create_option_background()
|
|
background.place(relx=0.75, rely=widget_row,
|
|
relwidth=0.48, anchor="center")
|
|
|
|
info_button = create_info_button(
|
|
open_info_frame_generation, "Frame generation")
|
|
option_menu = create_option_menu(
|
|
select_frame_generation_from_menu, frame_generation_options_list, "OFF")
|
|
|
|
info_button.place(relx=column_info1, rely=widget_row -
|
|
0.003, anchor="center")
|
|
option_menu.place(relx=column_3_5, rely=widget_row, anchor="center")
|
|
|
|
|
|
def place_AI_blending_menu():
|
|
|
|
def open_info_AI_blending():
|
|
option_list = [
|
|
" Blending combines the upscaled image produced by AI with the original image",
|
|
|
|
" \n BLENDING OPTIONS\n" +
|
|
" • [OFF] No blending is applied\n" +
|
|
" • [Low] The result favors the upscaled image, with a slight touch of the original\n" +
|
|
" • [Medium] A balanced blend of the original and upscaled images\n" +
|
|
" • [High] The result favors the original image, with subtle enhancements from the upscaled version\n",
|
|
|
|
" \n NOTES\n" +
|
|
" • Can enhance the quality of the final result\n" +
|
|
" • Especially effective when using the tiling/merging function (useful for low VRAM)\n" +
|
|
" • Particularly helpful at low input resolution percentages (<50%)\n",
|
|
]
|
|
|
|
MessageBox(
|
|
messageType="info",
|
|
title="AI blending",
|
|
subtitle="This widget allows you to choose the blending between the upscaled and original image/frame",
|
|
default_value=None,
|
|
option_list=option_list
|
|
)
|
|
|
|
widget_row = row2
|
|
|
|
background = create_option_background()
|
|
background.place(relx=0.75, rely=widget_row,
|
|
relwidth=0.48, anchor="center")
|
|
|
|
info_button = create_info_button(open_info_AI_blending, "AI blending")
|
|
option_menu = create_option_menu(
|
|
select_blending_from_menu, blending_list, default_blending)
|
|
|
|
info_button.place(relx=column_info1, rely=widget_row -
|
|
0.003, anchor="center")
|
|
option_menu.place(relx=column_3_5, rely=widget_row,
|
|
anchor="center")
|
|
|
|
|
|
def place_AI_multithreading_menu():
|
|
|
|
def open_info_AI_multithreading():
|
|
option_list = [
|
|
" This option can enhance video upscaling performance, especially on powerful GPUs.",
|
|
|
|
" \n AI MULTITHREADING OPTIONS\n"
|
|
+ " • OFF - Processes one frame at a time.\n"
|
|
+ " • 2 threads - Processes two frames simultaneously.\n"
|
|
+ " • 4 threads - Processes four frames simultaneously.\n"
|
|
+ " • 6 threads - Processes six frames simultaneously.\n"
|
|
+ " • 8 threads - Processes eight frames simultaneously.\n",
|
|
|
|
" \n NOTES\n"
|
|
+ " • Higher thread counts increase CPU, GPU, and RAM usage.\n"
|
|
+ " • The GPU may be heavily stressed, potentially reaching high temperatures.\n"
|
|
+ " • Monitor your system's temperature to prevent overheating.\n"
|
|
+ " • If the chosen thread count exceeds GPU capacity, the app automatically selects an optimal value.\n",
|
|
]
|
|
|
|
MessageBox(
|
|
messageType="info",
|
|
title="AI multithreading (EXPERIMENTAL)",
|
|
subtitle="This widget allows to choose how many video frames are upscaled simultaneously",
|
|
default_value=None,
|
|
option_list=option_list
|
|
)
|
|
|
|
widget_row = row3
|
|
background = create_option_background()
|
|
background.place(relx=0.75, rely=widget_row,
|
|
relwidth=0.48, anchor="center")
|
|
|
|
info_button = create_info_button(
|
|
open_info_AI_multithreading, "AI multithreading")
|
|
option_menu = create_option_menu(
|
|
select_AI_multithreading_from_menu, AI_multithreading_list, default_AI_multithreading)
|
|
|
|
info_button.place(relx=column_info1, rely=widget_row -
|
|
0.003, anchor="center")
|
|
option_menu.place(relx=column_3_5, rely=widget_row,
|
|
anchor="center")
|
|
|
|
|
|
def place_input_output_resolution_textboxs():
|
|
|
|
def open_info_input_resolution():
|
|
option_list = [
|
|
" A high value (>70%) will create high quality photos/videos but will be slower",
|
|
" While a low value (<40%) will create good quality photos/videos but will much faster",
|
|
|
|
" \n For example, for a 1080p (1920x1080) image/video\n" +
|
|
" • Input resolution 25% => input to AI 270p (480x270)\n" +
|
|
" • Input resolution 50% => input to AI 540p (960x540)\n" +
|
|
" • Input resolution 75% => input to AI 810p (1440x810)\n" +
|
|
" • Input resolution 100% => input to AI 1080p (1920x1080) \n",
|
|
]
|
|
|
|
MessageBox(
|
|
messageType="info",
|
|
title="Input resolution %",
|
|
subtitle="This widget allows to choose the resolution input to the AI",
|
|
default_value=None,
|
|
option_list=option_list
|
|
)
|
|
|
|
def open_info_output_resolution():
|
|
option_list = [
|
|
" TBD ",
|
|
]
|
|
|
|
MessageBox(
|
|
messageType="info",
|
|
title="Output resolution %",
|
|
subtitle="This widget allows to choose upscaled files resolution",
|
|
default_value=None,
|
|
option_list=option_list
|
|
)
|
|
|
|
widget_row = row4
|
|
|
|
background = create_option_background()
|
|
background.place(relx=0.75, rely=widget_row,
|
|
relwidth=0.48, anchor="center")
|
|
|
|
# Input resolution %
|
|
info_button = create_info_button(
|
|
open_info_input_resolution, "Input resolution")
|
|
option_menu = create_text_box(
|
|
selected_input_resize_factor, width=little_textbox_width)
|
|
|
|
info_button.place(relx=column_info1, rely=widget_row -
|
|
0.003, anchor="center")
|
|
option_menu.place(relx=column_1_5, rely=widget_row,
|
|
anchor="center")
|
|
|
|
# Output resolution %
|
|
info_button = create_info_button(
|
|
open_info_output_resolution, "Output resolution")
|
|
option_menu = create_text_box(
|
|
selected_output_resize_factor, width=little_textbox_width)
|
|
|
|
info_button.place(relx=column_info2, rely=widget_row -
|
|
0.003, anchor="center")
|
|
option_menu.place(relx=column_3, rely=widget_row,
|
|
anchor="center")
|
|
|
|
|
|
def place_gpu_gpuVRAM_menus():
|
|
|
|
def open_info_gpu():
|
|
option_list = [
|
|
"\n It is possible to select up to 4 GPUs for AI processing\n" +
|
|
" • Auto (the app will select the most powerful GPU)\n" +
|
|
" • GPU 1 (GPU 0 in Task manager)\n" +
|
|
" • GPU 2 (GPU 1 in Task manager)\n" +
|
|
" • GPU 3 (GPU 2 in Task manager)\n" +
|
|
" • GPU 4 (GPU 3 in Task manager)\n",
|
|
|
|
"\n NOTES\n" +
|
|
" • Keep in mind that the more powerful the chosen gpu is, the faster the upscaling will be\n" +
|
|
" • For optimal performance, it is essential to regularly update your GPUs drivers\n" +
|
|
" • Selecting a GPU not present in the PC will cause the app to use the CPU for AI processing\n"
|
|
]
|
|
|
|
MessageBox(
|
|
messageType="info",
|
|
title="GPU",
|
|
subtitle="This widget allows to select the GPU for AI upscale",
|
|
default_value=None,
|
|
option_list=option_list
|
|
)
|
|
|
|
def open_info_vram_limiter():
|
|
option_list = [
|
|
" Make sure to enter the correct value based on the selected GPU's VRAM",
|
|
" Setting a value higher than the available VRAM may cause upscale failure",
|
|
" For integrated GPUs (Intel HD series • Vega 3, 5, 7), select 2 GB to avoid issues",
|
|
]
|
|
|
|
MessageBox(
|
|
messageType="info",
|
|
title="GPU VRAM (GB)",
|
|
subtitle="This widget allows to set a limit on the GPU VRAM memory usage",
|
|
default_value=None,
|
|
option_list=option_list
|
|
)
|
|
|
|
widget_row = row5
|
|
|
|
background = create_option_background()
|
|
background.place(relx=0.75, rely=widget_row,
|
|
relwidth=0.48, anchor="center")
|
|
|
|
# GPU
|
|
info_button = create_info_button(open_info_gpu, "GPU")
|
|
option_menu = create_option_menu(
|
|
select_gpu_from_menu, gpus_list, default_gpu, width=little_menu_width)
|
|
|
|
info_button.place(relx=column_info1,
|
|
rely=widget_row - 0.003, anchor="center")
|
|
option_menu.place(relx=column_1_4, rely=widget_row, anchor="center")
|
|
|
|
# GPU VRAM
|
|
info_button = create_info_button(open_info_vram_limiter, "GPU VRAM (GB)")
|
|
option_menu = create_text_box(
|
|
selected_VRAM_limiter, width=little_textbox_width)
|
|
|
|
info_button.place(relx=column_info2, rely=widget_row -
|
|
0.003, anchor="center")
|
|
option_menu.place(relx=column_3, rely=widget_row,
|
|
anchor="center")
|
|
|
|
|
|
def place_image_video_output_menus():
|
|
|
|
def open_info_image_output():
|
|
option_list = [
|
|
" \n PNG\n"
|
|
" • Very good quality\n"
|
|
" • Slow and heavy file\n"
|
|
" • Supports transparent images\n"
|
|
" • Lossless compression (no quality loss)\n"
|
|
" • Ideal for graphics, web images, and screenshots\n",
|
|
|
|
" \n JPG\n"
|
|
" • Good quality\n"
|
|
" • Fast and lightweight file\n"
|
|
" • Lossy compression (some quality loss)\n"
|
|
" • Ideal for photos and web images\n"
|
|
" • Does not support transparency\n",
|
|
|
|
" \n BMP\n"
|
|
" • Highest quality\n"
|
|
" • Slow and heavy file\n"
|
|
" • Uncompressed format (large file size)\n"
|
|
" • Ideal for raw images and high-detail graphics\n"
|
|
" • Does not support transparency\n",
|
|
|
|
" \n TIFF\n"
|
|
" • Highest quality\n"
|
|
" • Very slow and heavy file\n"
|
|
" • Supports both lossless and lossy compression\n"
|
|
" • Often used in professional photography and printing\n"
|
|
" • Supports multiple layers and transparency\n",
|
|
]
|
|
|
|
MessageBox(
|
|
messageType="info",
|
|
title="Image output",
|
|
subtitle="This widget allows to choose the extension of upscaled images",
|
|
default_value=None,
|
|
option_list=option_list
|
|
)
|
|
|
|
def open_info_video_extension():
|
|
option_list = [
|
|
" \n MP4\n"
|
|
" • Most widely supported format\n"
|
|
" • Good quality with efficient compression\n"
|
|
" • Fast and lightweight file\n"
|
|
" • Ideal for streaming and general use\n",
|
|
|
|
" \n MKV\n"
|
|
" • High-quality format with multiple audio and subtitle tracks support\n"
|
|
" • Larger file size compared to MP4\n"
|
|
" • Supports almost any codec\n"
|
|
" • Ideal for high-quality videos and archiving\n",
|
|
|
|
" \n AVI\n"
|
|
" • Older format with high compatibility\n"
|
|
" • Larger file size due to less efficient compression\n"
|
|
" • Supports multiple codecs but lacks modern features\n"
|
|
" • Ideal for older devices and raw video storage\n",
|
|
|
|
" \n MOV\n"
|
|
" • High-quality format developed by Apple\n"
|
|
" • Large file size due to less compression\n"
|
|
" • Best suited for editing and high-quality playback\n"
|
|
" • Compatible mainly with macOS and iOS devices\n",
|
|
]
|
|
|
|
MessageBox(
|
|
messageType="info",
|
|
title="Video output",
|
|
subtitle="This widget allows to choose the extension of the upscaled video",
|
|
default_value=None,
|
|
option_list=option_list
|
|
)
|
|
|
|
widget_row = row6
|
|
|
|
background = create_option_background()
|
|
background.place(relx=0.75, rely=widget_row,
|
|
relwidth=0.48, anchor="center")
|
|
|
|
# Image output
|
|
info_button = create_info_button(open_info_image_output, "Image output")
|
|
option_menu = create_option_menu(select_image_extension_from_menu,
|
|
image_extension_list, default_image_extension, width=little_menu_width)
|
|
info_button.place(relx=column_info1,
|
|
rely=widget_row - 0.003, anchor="center")
|
|
option_menu.place(relx=column_1_4, rely=widget_row,
|
|
anchor="center")
|
|
|
|
# Video output
|
|
info_button = create_info_button(open_info_video_extension, "Video output")
|
|
option_menu = create_option_menu(select_video_extension_from_menu,
|
|
video_extension_list, default_video_extension, width=little_menu_width)
|
|
info_button.place(relx=column_info2,
|
|
rely=widget_row - 0.003, anchor="center")
|
|
option_menu.place(relx=column_2_9, rely=widget_row,
|
|
anchor="center")
|
|
|
|
|
|
def place_video_codec_keep_frames_menus():
|
|
|
|
def open_info_video_codec():
|
|
option_list = [
|
|
" \n SOFTWARE ENCODING (CPU)\n"
|
|
" • x264 | H.264 software encoding\n"
|
|
" • x265 | HEVC (H.265) software encoding\n",
|
|
|
|
" \n NVIDIA GPU ENCODING (NVENC - Optimized for NVIDIA GPU)\n"
|
|
" • h264_nvenc | H.264 hardware encoding\n"
|
|
" • hevc_nvenc | HEVC (H.265) hardware encoding\n",
|
|
|
|
" \n AMD GPU ENCODING (AMF - Optimized for AMD GPU)\n"
|
|
" • h264_amf | H.264 hardware encoding\n"
|
|
" • hevc_amf | HEVC (H.265) hardware encoding\n",
|
|
|
|
" \n INTEL GPU ENCODING (QSV - Optimized for Intel GPU)\n"
|
|
" • h264_qsv | H.264 hardware encoding\n"
|
|
" • hevc_qsv | HEVC (H.265) hardware encoding\n"
|
|
]
|
|
|
|
MessageBox(
|
|
messageType="info",
|
|
title="Video codec",
|
|
subtitle="This widget allows to choose video codec for upscaled video",
|
|
default_value=None,
|
|
option_list=option_list
|
|
)
|
|
|
|
def open_info_keep_frames():
|
|
option_list = [
|
|
"\n ON \n" +
|
|
" The app does NOT delete the video frames after creating the upscaled video \n",
|
|
|
|
"\n OFF \n" +
|
|
" The app deletes the video frames after creating the upscaled video \n"
|
|
]
|
|
|
|
MessageBox(
|
|
messageType="info",
|
|
title="Keep video frames",
|
|
subtitle="This widget allows to choose to keep video frames",
|
|
default_value=None,
|
|
option_list=option_list
|
|
)
|
|
|
|
widget_row = row7
|
|
|
|
background = create_option_background()
|
|
background.place(relx=0.75, rely=widget_row,
|
|
relwidth=0.48, anchor="center")
|
|
|
|
# Video codec
|
|
info_button = create_info_button(open_info_video_codec, "Video codec")
|
|
option_menu = create_option_menu(
|
|
select_video_codec_from_menu, video_codec_list, default_video_codec, width=little_menu_width)
|
|
info_button.place(relx=column_info1,
|
|
rely=widget_row - 0.003, anchor="center")
|
|
option_menu.place(relx=column_1_4, rely=widget_row,
|
|
anchor="center")
|
|
|
|
# Keep frames
|
|
info_button = create_info_button(open_info_keep_frames, "Keep frames")
|
|
option_menu = create_option_menu(
|
|
select_save_frame_from_menu, keep_frames_list, default_keep_frames, width=little_menu_width)
|
|
info_button.place(relx=column_info2,
|
|
rely=widget_row - 0.003, anchor="center")
|
|
option_menu.place(relx=column_2_9, rely=widget_row,
|
|
anchor="center")
|
|
|
|
|
|
def place_output_path_textbox():
|
|
|
|
def open_info_output_path():
|
|
option_list = [
|
|
"\n The default path is defined by the input files."
|
|
+ "\n For example: selecting a file from the Download folder,"
|
|
+ "\n the app will save upscaled files in the Download folder \n",
|
|
|
|
" Otherwise it is possible to select the desired path using the SELECT button",
|
|
]
|
|
|
|
MessageBox(
|
|
messageType="info",
|
|
title="Output path",
|
|
subtitle="This widget allows to choose upscaled files path",
|
|
default_value=None,
|
|
option_list=option_list
|
|
)
|
|
|
|
background = create_option_background()
|
|
info_button = create_info_button(open_info_output_path, "Output path")
|
|
option_menu = create_text_box_output_path(selected_output_path)
|
|
active_button = create_active_button(
|
|
command=open_output_path_action, text="SELECT", width=60, height=25)
|
|
|
|
# --- CAMBIO: Usamos 'row8' en lugar de 'row10' para subirlo ---
|
|
background.place(relx=0.75, rely=row8,
|
|
relwidth=0.48, anchor="center")
|
|
info_button.place(relx=column_info1, rely=row8 -
|
|
0.003, anchor="center")
|
|
active_button.place(relx=column_info1 + 0.052,
|
|
rely=row8, anchor="center")
|
|
option_menu.place(relx=column_2 - 0.008, rely=row8,
|
|
anchor="center")
|
|
|
|
|
|
def place_integrated_console():
|
|
"""
|
|
Crea y coloca la consola integrada, desplazada hacia abajo
|
|
para dejar espacio a los botones superiores.
|
|
"""
|
|
|
|
# 1. Crear el widget
|
|
console_widget = IntegratedConsole(
|
|
master=window,
|
|
fg_color=widget_background_color,
|
|
border_color=accent_color,
|
|
border_width=1,
|
|
corner_radius=5
|
|
)
|
|
|
|
# 2. Posicionamiento
|
|
# rely=0.89: Bajamos la consola al fondo.
|
|
# relheight=0.18: Altura ajustada para no salirse de la pantalla.
|
|
console_widget.place(
|
|
relx=0.75,
|
|
rely=0.89,
|
|
relwidth=0.48,
|
|
relheight=0.18,
|
|
anchor="center"
|
|
)
|
|
|
|
# 3. Conexión lógica
|
|
console.set_widget(console_widget)
|
|
|
|
# Mensaje inicial
|
|
console.write_log(
|
|
f"[{app_name}] System initialized. Console ready.", "SUCCESS")
|
|
|
|
|
|
def place_stop_button():
|
|
stop_button = create_active_button(
|
|
command=stop_button_command,
|
|
text="STOP",
|
|
icon=stop_icon,
|
|
width=240, # Mismo ancho que el botón de inicio
|
|
height=28, # Altura delgada (antes 30 o 45)
|
|
border_color=error_color
|
|
)
|
|
# Posición: rely=0.77 lo pone ARRIBA de la consola
|
|
stop_button.place(relx=0.75, rely=0.77, anchor="center")
|
|
|
|
|
|
def place_upscale_button():
|
|
upscale_button = create_active_button(
|
|
command=upscale_button_command,
|
|
text="Make Magic",
|
|
icon=upscale_icon,
|
|
width=240, # Mismo ancho que Stop
|
|
height=28 # Altura delgada (antes 45)
|
|
)
|
|
|
|
# Posición: Exactamente la misma que el botón de Stop
|
|
upscale_button.place(relx=0.75, rely=0.77, anchor="center")
|
|
|
|
|
|
# ==== MAIN APPLICATION SECTION ====
|
|
|
|
def on_app_close() -> None:
|
|
# 1. Confirmación de salida
|
|
if not messagebox.askyesno("Exit Warlock-Studio", "Are you sure you want to close the application?"):
|
|
return
|
|
|
|
# 2. CAPTURAR ESTADO DE LA VENTANA (ANTES DE DESTRUIRLA)
|
|
# Soluciona el error: application has been destroyed
|
|
try:
|
|
is_topmost = window.attributes("-topmost")
|
|
except Exception:
|
|
is_topmost = False
|
|
|
|
# 3. Recopilar variables globales para guardar preferencias
|
|
global selected_AI_model
|
|
global selected_AI_multithreading
|
|
global selected_gpu
|
|
global selected_blending_factor
|
|
global selected_image_extension
|
|
global selected_video_extension
|
|
global selected_video_codec
|
|
global tiles_resolution
|
|
global input_resize_factor
|
|
|
|
AI_model_to_save = f"{selected_AI_model}"
|
|
gpu_to_save = selected_gpu
|
|
image_extension_to_save = selected_image_extension
|
|
video_extension_to_save = selected_video_extension
|
|
video_codec_to_save = selected_video_codec
|
|
blending_to_save = {0: "OFF", 0.3: "Low", 0.5: "Medium",
|
|
0.7: "High"}.get(selected_blending_factor)
|
|
|
|
keep_frames_to_save = "ON" if selected_keep_frames else "OFF"
|
|
|
|
if selected_AI_multithreading == 1:
|
|
AI_multithreading_to_save = "OFF"
|
|
else:
|
|
AI_multithreading_to_save = f"{selected_AI_multithreading} threads"
|
|
|
|
# 4. Construir diccionario de preferencias
|
|
user_preference = {
|
|
"default_AI_model": AI_model_to_save,
|
|
"default_AI_multithreading": AI_multithreading_to_save,
|
|
"default_gpu": gpu_to_save,
|
|
"default_keep_frames": keep_frames_to_save,
|
|
"default_image_extension": image_extension_to_save,
|
|
"default_video_extension": video_extension_to_save,
|
|
"default_video_codec": video_codec_to_save,
|
|
"default_blending": blending_to_save,
|
|
"default_output_path": selected_output_path.get(),
|
|
"default_input_resize_factor": str(selected_input_resize_factor.get()),
|
|
"default_output_resize_factor": str(selected_output_resize_factor.get()),
|
|
"default_VRAM_limiter": str(selected_VRAM_limiter.get()),
|
|
# Usamos la variable capturada al inicio
|
|
"keep_window_on_top": is_topmost
|
|
}
|
|
|
|
# 5. Guardar JSON en disco
|
|
try:
|
|
user_preference_json = json_dumps(user_preference)
|
|
with open(USER_PREFERENCE_PATH, "w") as preference_file:
|
|
preference_file.write(user_preference_json)
|
|
except Exception as e:
|
|
print(f"Error saving preferences: {e}")
|
|
|
|
# 6. Limpieza de procesos y logs
|
|
stop_upscale_process()
|
|
logging.shutdown()
|
|
|
|
# 7. DESTRUIR LA VENTANA (AL FINAL)
|
|
try:
|
|
window.grab_release()
|
|
window.destroy()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class App():
|
|
def __init__(self, window):
|
|
self.toplevel_window = None
|
|
window.protocol("WM_DELETE_WINDOW", on_app_close)
|
|
|
|
window.title(f"Warlock-Studio")
|
|
# Get screen width and height
|
|
screen_width = window.winfo_screenwidth()
|
|
screen_height = window.winfo_screenheight()
|
|
|
|
# --- CAMBIO REALIZADO AQUÍ (Aumentado a 85% para ser más grande) ---
|
|
# Set to 85% of the screen by default, centered
|
|
default_width = int(screen_width * 0.8)
|
|
default_height = int(screen_height * 0.87)
|
|
# ------------------------------------------------------------------
|
|
|
|
x_position = (screen_width - default_width) // 2
|
|
y_position = (screen_height - default_height) // 2
|
|
window.geometry(
|
|
f"{default_width}x{default_height}+{x_position}+{y_position}")
|
|
|
|
# --> AQUÍ ESTÁ EL CAMBIO
|
|
window.resizable(True, True)
|
|
|
|
window.iconbitmap(find_by_relative_path(
|
|
"Assets" + os_separator + "logo.ico"))
|
|
|
|
place_loadFile_section()
|
|
|
|
place_app_name()
|
|
place_output_path_textbox()
|
|
|
|
place_AI_menu()
|
|
place_AI_multithreading_menu()
|
|
|
|
# Show appropriate menu based on default AI model
|
|
if default_AI_model in RIFE_models_list:
|
|
place_frame_generation_menu()
|
|
else:
|
|
place_AI_blending_menu()
|
|
|
|
place_input_output_resolution_textboxs()
|
|
|
|
place_gpu_gpuVRAM_menus()
|
|
place_video_codec_keep_frames_menus()
|
|
|
|
place_image_video_output_menus()
|
|
|
|
place_integrated_console()
|
|
place_upscale_button()
|
|
|
|
|
|
class SplashScreen(CTkToplevel):
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
# Configure window
|
|
self.title("Warlock-Studio")
|
|
self.overrideredirect(True)
|
|
# Remove window decorations
|
|
self.attributes('-topmost', True)
|
|
|
|
# Calculate window position for center of screen
|
|
screen_width = self.winfo_screenwidth()
|
|
screen_height = self.winfo_screenheight()
|
|
default_width = int(screen_width * 0.4)
|
|
default_height = int(screen_height * 0.3)
|
|
self.geometry(f"{default_width}x{default_height}")
|
|
|
|
# Set default window size
|
|
window_width = 460
|
|
window_height = 340
|
|
|
|
# Try to load banner image
|
|
banner_path = find_by_relative_path(f"Assets{os_separator}banner.png")
|
|
try:
|
|
self.banner_image = CTkImage(
|
|
pillow_image_open(banner_path),
|
|
size=(450, 200) # Adjust size as needed
|
|
)
|
|
has_banner = True
|
|
except Exception as e:
|
|
print(f"[SPLASH] Could not load splash banner: {e}")
|
|
has_banner = False
|
|
window_height = 400 # Smaller height if no banner
|
|
|
|
# Center window
|
|
x = (screen_width - window_width) // 2
|
|
y = (screen_height - window_height) // 2
|
|
self.geometry(f"{window_width}x{window_height}+{x}+{y}")
|
|
|
|
# Configure appearance to match app
|
|
# Usar color de fondo definido
|
|
self.configure(fg_color=background_color)
|
|
|
|
# Create banner or title
|
|
if has_banner:
|
|
self.banner_label = CTkLabel(
|
|
self,
|
|
image=self.banner_image,
|
|
text=""
|
|
)
|
|
self.banner_label.pack(pady=(30, 15))
|
|
else:
|
|
# Fallback to text title if image not found
|
|
title_label = CTkLabel(
|
|
self,
|
|
text="Warlock-Studio",
|
|
font=CTkFont(family="Segoe UI", size=28, weight="bold"),
|
|
text_color=app_name_color # Usar color del nombre de la app
|
|
)
|
|
title_label.pack(pady=(50, 20))
|
|
|
|
# Create status frame with progress messages
|
|
status_frame = CTkFrame(
|
|
self,
|
|
fg_color=widget_background_color, # Usar color de widget definido
|
|
corner_radius=10
|
|
)
|
|
status_frame.pack(pady=10, padx=20, fill="x")
|
|
|
|
self.status_label = CTkLabel(
|
|
status_frame,
|
|
text="Loading AI-ONNX models...",
|
|
font=CTkFont(family="Segoe UI", size=12, weight="bold"),
|
|
text_color=accent_color # Usar color amarillo para el texto de estado
|
|
)
|
|
self.status_label.pack(pady=10, padx=10)
|
|
|
|
# Create version label
|
|
version_label = CTkLabel(
|
|
self,
|
|
text=f"Version {version} Developed by Ivan-Ayub97",
|
|
font=CTkFont(family="Segoe UI", size=10),
|
|
text_color=secondary_text_color # Usar color de texto secundario
|
|
)
|
|
version_label.pack(pady=(0, 10))
|
|
|
|
# Define enough messages to fill 15 seconds (~1.5s por mensaje)
|
|
self.messages = [
|
|
"Preparing environment...",
|
|
"Loading AI-ONNX models...",
|
|
"Initializing FFmpeg...",
|
|
"Almost ready..."
|
|
]
|
|
|
|
# Start loading animation
|
|
self._loading_step = 0
|
|
self.update_loading_text()
|
|
|
|
# Splash duration: 10 seconds
|
|
self.after(10000, self.start_fade_out)
|
|
|
|
def update_loading_text(self):
|
|
"""Update the loading message every 1.5 seconds"""
|
|
if self._loading_step < len(self.messages):
|
|
self.status_label.configure(text=self.messages[self._loading_step])
|
|
self._loading_step += 1
|
|
self.after(1500, self.update_loading_text)
|
|
|
|
def start_fade_out(self):
|
|
"""Start the fade out animation"""
|
|
self._fade_step = 1.0
|
|
self.fade_out()
|
|
|
|
def fade_out(self):
|
|
"""Smoothly fade out the splash screen"""
|
|
if self._fade_step > 0:
|
|
# Use cosine for smooth fade
|
|
opacity = cos((1.0 - self._fade_step) * pi/2)
|
|
self.attributes('-alpha', opacity)
|
|
self._fade_step -= 0.05
|
|
self.after(40, self.fade_out)
|
|
else:
|
|
self.destroy()
|
|
|
|
|
|
def log_startup_info():
|
|
"""
|
|
Imprime la información de inicio una vez que la consola gráfica está activa.
|
|
"""
|
|
# 1. Check FFmpeg
|
|
if os_path_exists(FFMPEG_EXE_PATH):
|
|
print(f"[{app_name}] ffmpeg.exe found")
|
|
else:
|
|
print(
|
|
f"[{app_name}] WARNING: ffmpeg.exe not found. Video functionality will be limited.")
|
|
|
|
# 2. Check Preferences
|
|
if os_path_exists(USER_PREFERENCE_PATH):
|
|
print(f"[{app_name}] Preference file exists")
|
|
else:
|
|
print(
|
|
f"[{app_name}] Preference file does not exist, using default coded value")
|
|
|
|
# 3. Check ONNX Providers
|
|
try:
|
|
from onnxruntime import get_available_providers
|
|
providers = get_available_providers()
|
|
print("Available ONNX Runtime Providers:")
|
|
for p in providers:
|
|
print(f"- {p}")
|
|
except ImportError as e:
|
|
print(f"Error: The onnxruntime library is not installed. {e}")
|
|
|
|
# --- CÓDIGO PARA EL BOTÓN DEL MANUAL (Pegar antes de if __name__ == "__main__":) ---
|
|
|
|
|
|
def open_manual_action():
|
|
"""Función para abrir el PDF del manual."""
|
|
manual_path = find_by_relative_path(
|
|
f"Assets{os_separator}Warlock-Studio_Manual.pdf")
|
|
|
|
if os_path_exists(manual_path):
|
|
try:
|
|
# Abre el PDF con el visor predeterminado del sistema
|
|
if os.name == 'nt': # Windows
|
|
os.startfile(manual_path)
|
|
else: # macOS / Linux
|
|
subprocess_run(['open' if sys.platform ==
|
|
'darwin' else 'xdg-open', manual_path])
|
|
console.write_log("Manual opened successfully", "SUCCESS")
|
|
except Exception as e:
|
|
log_and_report_error(f"Could not open manual: {e}")
|
|
else:
|
|
show_error_message("Manual file not found in Assets folder.")
|
|
|
|
|
|
class ManualButton(CTkButton):
|
|
"""Clase para el botón de ayuda/manual."""
|
|
|
|
def __init__(self, master, **kwargs):
|
|
# Intenta buscar el icono 'manual_icon' en las variables globales
|
|
# Si no existe, usa texto
|
|
icon_img = globals().get('manual_icon', None)
|
|
text_val = "" if icon_img else "📖 Help"
|
|
|
|
super().__init__(master, text=text_val, image=icon_img, width=40, height=28,
|
|
fg_color=widget_background_color, border_color=border_color, border_width=1,
|
|
hover_color=button_hover_color, text_color=text_color,
|
|
command=open_manual_action, **kwargs)
|
|
|
|
# -----------------------------------------------------------------------------------
|
|
|
|
|
|
if __name__ == "__main__":
|
|
freeze_support()
|
|
from warlock_preferences import ConfigManager, PreferencesButton
|
|
set_appearance_mode("Dark")
|
|
|
|
# Configurar tema visual
|
|
import customtkinter
|
|
customtkinter.set_default_color_theme("dark-blue")
|
|
|
|
# Aplicar overrides de tema para consistencia visual
|
|
try:
|
|
customtkinter.ThemeManager.theme["CTkFrame"]["fg_color"] = [
|
|
widget_background_color, widget_background_color]
|
|
customtkinter.ThemeManager.theme["CTkButton"]["fg_color"] = [
|
|
widget_background_color, widget_background_color]
|
|
customtkinter.ThemeManager.theme["CTkButton"]["hover_color"] = [
|
|
button_hover_color, button_hover_color]
|
|
customtkinter.ThemeManager.theme["CTkButton"]["text_color"] = [
|
|
text_color, text_color]
|
|
customtkinter.ThemeManager.theme["CTkButton"]["border_color"] = [
|
|
accent_color, accent_color]
|
|
customtkinter.ThemeManager.theme["CTkEntry"]["fg_color"] = [
|
|
widget_background_color, widget_background_color]
|
|
customtkinter.ThemeManager.theme["CTkEntry"]["text_color"] = [
|
|
text_color, text_color]
|
|
customtkinter.ThemeManager.theme["CTkEntry"]["border_color"] = [
|
|
accent_color, accent_color]
|
|
customtkinter.ThemeManager.theme["CTkOptionMenu"]["fg_color"] = [
|
|
widget_background_color, widget_background_color]
|
|
customtkinter.ThemeManager.theme["CTkOptionMenu"]["text_color"] = [
|
|
text_color, text_color]
|
|
customtkinter.ThemeManager.theme["CTkOptionMenu"]["button_hover_color"] = [
|
|
button_hover_color, button_hover_color]
|
|
customtkinter.ThemeManager.theme["CTkLabel"]["text_color"] = [
|
|
text_color, text_color]
|
|
except Exception as e:
|
|
print(f"[THEME] Could not apply custom theme: {e}")
|
|
|
|
process_status_q = multiprocessing_Queue(maxsize=1)
|
|
|
|
# Inicializar ventana principal (oculta para mostrar el splash primero)
|
|
window = DnDCTk()
|
|
window.withdraw()
|
|
|
|
# Imprimir la info de inicio (saldrá en la nueva consola integrada)
|
|
log_startup_info()
|
|
|
|
# Mostrar Splash Screen
|
|
splash = SplashScreen()
|
|
# Programar mostrar la ventana principal después del splash (11 segundos)
|
|
window.after(11000, window.deiconify)
|
|
|
|
# Inicialización de Variables de UI
|
|
info_message = StringVar()
|
|
selected_output_path = StringVar()
|
|
selected_input_resize_factor = StringVar()
|
|
selected_output_resize_factor = StringVar()
|
|
selected_VRAM_limiter = StringVar()
|
|
|
|
# Inicializar variables globales seleccionadas con los defaults cargados
|
|
selected_file_list = []
|
|
selected_AI_model = default_AI_model
|
|
selected_gpu = default_gpu
|
|
selected_image_extension = default_image_extension
|
|
selected_video_extension = default_video_extension
|
|
selected_video_codec = default_video_codec
|
|
|
|
if default_AI_multithreading == "OFF":
|
|
selected_AI_multithreading = 1
|
|
else:
|
|
selected_AI_multithreading = int(default_AI_multithreading.split()[0])
|
|
|
|
if default_keep_frames == "ON":
|
|
selected_keep_frames = True
|
|
else:
|
|
selected_keep_frames = False
|
|
|
|
selected_blending_factor = {"OFF": 0, "Low": 0.3,
|
|
"Medium": 0.5, "High": 0.7}.get(default_blending, 0)
|
|
selected_frame_generation_option = "OFF"
|
|
|
|
# Inicializar variables de control global
|
|
stop_thread_flag = Event()
|
|
global_processing_times_list = []
|
|
global_upscaled_frames_paths = []
|
|
global_can_i_update_status = False
|
|
output_resize_factor = 1.0
|
|
tiles_resolution = 800
|
|
|
|
# Asignar valores por defecto a los campos de texto
|
|
selected_input_resize_factor.set(default_input_resize_factor)
|
|
selected_output_resize_factor.set(default_output_resize_factor)
|
|
selected_VRAM_limiter.set(default_VRAM_limiter)
|
|
selected_output_path.set(default_output_path)
|
|
|
|
info_message.set("Ready for the show!")
|
|
# Añadir listeners para actualizar widgets cuando cambien los valores
|
|
selected_input_resize_factor.trace_add('write', update_file_widget)
|
|
selected_output_resize_factor.trace_add('write', update_file_widget)
|
|
|
|
# Definición de Fuentes e Iconos
|
|
font = "Consola"
|
|
bold8 = CTkFont(family=font, size=8, weight="bold")
|
|
bold9 = CTkFont(family=font, size=9, weight="bold")
|
|
bold10 = CTkFont(family=font, size=10, weight="bold")
|
|
bold11 = CTkFont(family=font, size=11, weight="bold")
|
|
bold12 = CTkFont(family=font, size=12, weight="bold")
|
|
bold13 = CTkFont(family=font, size=13, weight="bold")
|
|
bold14 = CTkFont(family=font, size=14, weight="bold")
|
|
bold16 = CTkFont(family=font, size=16, weight="bold")
|
|
bold17 = CTkFont(family=font, size=17, weight="bold")
|
|
bold18 = CTkFont(family=font, size=18, weight="bold")
|
|
bold19 = CTkFont(family=font, size=19, weight="bold")
|
|
bold20 = CTkFont(family=font, size=20, weight="bold")
|
|
bold21 = CTkFont(family=font, size=21, weight="bold")
|
|
bold22 = CTkFont(family=font, size=22, weight="bold")
|
|
bold23 = CTkFont(family=font, size=23, weight="bold")
|
|
bold24 = CTkFont(family=font, size=24, weight="bold")
|
|
|
|
# Cargar Iconos
|
|
stop_icon = CTkImage(pillow_image_open(find_by_relative_path(
|
|
f"Assets{os_separator}stop_icon.png")), size=(15, 15))
|
|
upscale_icon = CTkImage(pillow_image_open(find_by_relative_path(
|
|
f"Assets{os_separator}upscale_icon.png")), size=(15, 15))
|
|
clear_icon = CTkImage(pillow_image_open(find_by_relative_path(
|
|
f"Assets{os_separator}clear_icon.png")), size=(15, 15))
|
|
info_icon = CTkImage(pillow_image_open(find_by_relative_path(
|
|
f"Assets{os_separator}info_icon.png")), size=(18, 18))
|
|
|
|
# --- NUEVO: Carga del icono del manual con seguridad ---
|
|
try:
|
|
manual_icon = CTkImage(pillow_image_open(find_by_relative_path(
|
|
f"Assets{os_separator}manual_icon.png")), size=(20, 20))
|
|
except:
|
|
manual_icon = None # Fallback si no existe la imagen
|
|
# -------------------------------------------------------
|
|
|
|
# Inicializar la Aplicación Principal
|
|
app = App(window)
|
|
window.update()
|
|
|
|
# Inicializar Botón de Preferencias (AQUÍ SE CONECTA LA CONSOLA Y CONFIGURACIÓN)
|
|
from warlock_preferences import PreferencesButton
|
|
preferences_btn = PreferencesButton(
|
|
master=window,
|
|
current_version=version,
|
|
repo_owner="Ivan-Ayub97",
|
|
repo_name="Warlock-Studio"
|
|
)
|
|
# Posición en la esquina superior derecha
|
|
preferences_btn.place(relx=0.95, rely=0.05, anchor="center")
|
|
preferences_btn.lift()
|
|
|
|
# --- NUEVO: Botón del Manual ---
|
|
# Colocado a la izquierda del engranaje (relx=0.88)
|
|
manual_btn = ManualButton(master=window)
|
|
manual_btn.place(relx=0.88, rely=0.05, anchor="center")
|
|
manual_btn.lift()
|
|
# -------------------------------
|
|
|
|
# Iniciar Bucle Principal
|
|
window.mainloop()
|