diff --git a/Assets/License.txt b/Assets/License.txt deleted file mode 100644 index 82d0d38..0000000 --- a/Assets/License.txt +++ /dev/null @@ -1,116 +0,0 @@ -SOFTWARE LICENSE AGREEMENT AND TERMS OF USE FOR WARLOCK-STUDIO - ---- - -PREAMBLE - -This Software License Agreement ("Agreement") constitutes a legally binding -contract between you, either as an individual or on behalf of an entity -("USER"), and Iván Eduardo Chavez Ayub ("AUTHOR"), regarding the -Warlock-Studio software and all its associated files, documentation, -and materials (collectively, the "SOFTWARE"). -By installing, copying, downloading, accessing, or otherwise using the -SOFTWARE, the USER expressly consents to and agrees to be bound by all -terms and conditions stipulated in this Agreement. -IF THE USER DOES NOT AGREE WITH ALL THE TERMS OF THIS AGREEMENT, THEY MUST -NOT INSTALL, USE, OR COPY THE SOFTWARE AND MUST IMMEDIATELY CANCEL THE -INSTALLATION PROCESS. - ---- - -SECTION I: THE MIT LICENSE - -The original and legally binding text of the MIT License is presented below. -This text governs the use of the core SOFTWARE. - -MIT License - -Copyright (c) 2025 Iván Eduardo Chavez Ayub - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - ---- - -SECTION II: ADDITIONAL TERMS, NOTICES, AND ACKNOWLEDGEMENTS - -1. Project Description - Warlock-Studio is a software application developed by Iván Eduardo Chavez - Ayub (GitHub profile: @Ivan-Ayub97). - The project is based on open-source - tools such as QualityScaler, FluidFrames, and RealScaler, originally - developed by Djdefrag (GitHub profile: @Djdefrag). - The main objective of - Warlock-Studio is to provide an intuitive graphical interface for - enhancing and upscaling image resolution through the use of artificial - intelligence models. -2. 🧩 THIRD-PARTY COMPONENTS AND APPLICABLE LICENSES - The SOFTWARE integrates various third-party technologies and components. - The use of the SOFTWARE is conditioned not only on compliance with this - Agreement but also with the license terms of each of these components. - The following is a list of components and their respective licenses: - - - QualityScaler, RealScaler, FluidFrames: MIT License (Djdefrag) - - RIFE: Apache 2.0 License (hzwer, Megvii Research) - - Real-ESRGAN, RealESRGAN-G, RealESR-Anime, RealESR-Net: BSD 3-Clause / Apache 2.0 License (Xintao Wang) - - GFPGAN: MIT License (TencentARC, Xintao Wang) - - BSRGAN: Apache 2.0 License (Kai Zhang) - - IRCNN: BSD / Mixed License (Kai Zhang) - - ONNX Runtime: MIT License (Microsoft) - - FFmpeg: LGPL-2.1 / GPL License (FFmpeg Team) - - ExifTool: Perl Artistic License (Phil Harvey) - - DirectML: MIT License (Microsoft) - - Python: PSF License (Python Software Foundation) - - Inno Setup: Custom Inno License (Jordan Russell) - -3. ⚖️ LIMITATION OF LIABILITY CLAUSE (EXTENDED) - TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL THE - AUTHOR OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES; INCLUDING, BUT NOT LIMITED - TO, LOSS OF DATA, BUSINESS INTERRUPTION, OR HARDWARE OR SYSTEM FAILURE - ARISING FROM THE USE, MISUSE, OR INABILITY TO USE THE SOFTWARE. - THIS SOFTWARE IS PROVIDED FOR EDUCATIONAL, CREATIVE, AND RESEARCH PURPOSES. - IT IS NOT CERTIFIED FOR IMPLEMENTATION IN CRITICAL OR COMMERCIAL - INFRASTRUCTURES WITHOUT PRIOR INDEPENDENT VALIDATION. - THE USER ASSUMES - ALL RISK ASSOCIATED WITH ITS USE. -4. 💼 INTELLECTUAL PROPERTY NOTICE - The brand, the name "Warlock-Studio" and its associated logos are the - exclusive intellectual property of Iván Eduardo Chavez Ayub. -The use of - these elements for commercial purposes is strictly prohibited without the - prior written consent of the AUTHOR. - Any redistribution or modification of - the source code must preserve the original copyright notices and all - references to the licenses contained herein. -5. ✍️ ACCEPTANCE OF TERMS - By proceeding with the installation and by using the SOFTWARE, the USER - acknowledges having read, understood, and accepted all the terms set forth - in the MIT License (Section I) and the Additional Terms (Section II) of - this Agreement. - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -📧 CONTACT INFORMATION - -For any inquiries or communications related to this Agreement or the SOFTWARE, -you may contact the Author: - -Name: Iván Eduardo Chavez Ayub -Email: negroayub97@gmail.com -GitHub: [https://github.com/Ivan-Ayub97](https://github.com/Ivan-Ayub97) diff --git a/Assets/Warlock-Studio_Manual.pdf b/Assets/Warlock-Studio_Manual.pdf deleted file mode 100644 index f58679b..0000000 Binary files a/Assets/Warlock-Studio_Manual.pdf and /dev/null differ diff --git a/Assets/banner.png b/Assets/banner.png deleted file mode 100644 index 2ada1f6..0000000 Binary files a/Assets/banner.png and /dev/null differ diff --git a/Assets/banner2.png b/Assets/banner2.png deleted file mode 100644 index a5cbe2a..0000000 Binary files a/Assets/banner2.png and /dev/null differ diff --git a/Assets/clear_icon.png b/Assets/clear_icon.png deleted file mode 100644 index 02efe63..0000000 Binary files a/Assets/clear_icon.png and /dev/null differ diff --git a/Assets/info_icon.png b/Assets/info_icon.png deleted file mode 100644 index 320cbb7..0000000 Binary files a/Assets/info_icon.png and /dev/null differ diff --git a/Assets/logo.ico b/Assets/logo.ico deleted file mode 100644 index f1983d0..0000000 Binary files a/Assets/logo.ico and /dev/null differ diff --git a/Assets/logo.png b/Assets/logo.png deleted file mode 100644 index 99f0acc..0000000 Binary files a/Assets/logo.png and /dev/null differ diff --git a/Assets/stop_icon.png b/Assets/stop_icon.png deleted file mode 100644 index fba0768..0000000 Binary files a/Assets/stop_icon.png and /dev/null differ diff --git a/Assets/upscale_icon.png b/Assets/upscale_icon.png deleted file mode 100644 index 19119c4..0000000 Binary files a/Assets/upscale_icon.png and /dev/null differ diff --git a/Assets/wizard-image.bmp b/Assets/wizard-image.bmp deleted file mode 100644 index f8b4493..0000000 Binary files a/Assets/wizard-image.bmp and /dev/null differ diff --git a/Assets/wizard-small.bmp b/Assets/wizard-small.bmp deleted file mode 100644 index 83dea3a..0000000 Binary files a/Assets/wizard-small.bmp and /dev/null differ diff --git a/CHANGELOG.md b/CHANGELOG.md index c923fee..4fc80cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,67 @@ Warlock-Studio | Changelog History +# Version 6.0 + +**Release date:** February 13, 2026 +**Kernel Version:** 6.0.0 (Codename: Chainweaver) +**Architecture:** Orchestrated Processing Chain System + +### Processing Chain Orchestrator (Step-Based Pipelines) + +Introduces a first-class pipeline system where each file is processed through an ordered list of steps. Every step consumes the previous step’s output automatically, enabling complex multi-stage workflows without manual intervention. + +- **Sequential Step Execution:** Images/videos flow through a chain, with explicit output-to-input handover between steps. +- **Scoped Output Routing:** Intermediate artifacts are written to dedicated temporary folders; only the final step targets the user-selected output directory. +- **Per-Step Configuration:** Each step can define model, GPU, resize factors, blending, output extension and video codec independently. +- **In-App Status Trace:** Clear progress messages per step (load, process, interpolate, encode), aiding auditing and troubleshooting. + +### Interpolation Integration (RIFE as a Chain Step) + +RIFE (and RIFE Lite) interpolation is now usable directly as a step within chains, harmonizing with upscaling and restoration operations. + +- **Video-Only Validation:** If an interpolation step receives an image, the step is gracefully skipped and logged. +- **Generation Factors:** Supports x2, x4, x8 and Slowmotion variants, with automatic container selection for intermediate outputs. +- **Codec Awareness:** Respects per-step codec selection; falls back to sane defaults when omitted. + +### Model Discovery & Step Editor Enhancements + +The step editor exposes a combobox sourced from auto-discovered ONNX models within the `AI-onnx/` directory. + +- **Auto-Discovery:** Normalizes common naming schemes (e.g., GFPGAN variants collapse to “GFPGAN”). +- **Resilient UI:** If a saved model name is not present, it is injected as a selectable value to preserve existing chains. +- **Consistent UX:** Aligns with the overall Preferences/Theme system, retaining established layout and style. + +### Output Type Safety & Smart Corrections + +Prevents invalid output combinations through proactive checks and automatic corrections. + +- **Extension Guardrails:** Enforces valid image extensions on image steps and valid containers on video steps; incorrect selections are corrected (e.g., `.mp4` for video, `.png` for image). +- **Graceful Skips:** Incompatible steps do not break chains; they emit a clear status message and continue. + +### Reliability & Memory Hygiene + +Improves robustness under constrained hardware conditions and long-running chains. + +- **Adaptive Tiles per Step:** VRAM limits are applied per-model using a multiplier table to derive safe tile resolutions. +- **Inter-Step Cleanup:** Instances are freed between steps and memory buffers are compacted to avoid fragmentation. +- **Stable Precision Paths:** Face restoration enforces float32 processing with safe alpha-channel recomposition. + +### UI/UX Modernization & Visual Identity + +Refines the application’s look and feel to a modern, polished aesthetic while preserving the Warlock palette (black, red, yellow, white, gray). + +- **Version Surfacing:** The application now displays version 6.0 consistently across splash, header, and preferences (About). +- **Rounded Corners:** Frames, buttons, entries, option menus, and the splash progress bar adopt rounded corners. The corner radius is configurable under Preferences and applied across the UI. +- **Vibrant Accents:** Warlock Gold and primary accents are tuned for higher vibrancy (#FFD700 title, #FFC107 accent); button hover uses a richer red for clearer affordance without sacrificing readability. +- **Consistency:** The main window, drop zone, and controls use the same corner radius and accent logic, ensuring a unified visual language throughout. + +### Documentation & Guidance + +- **README Updated:** Adds “New in v6.0 — Process Chaining” and updates the version badge to 6.0, detailing chain creation, model auto-discovery, and smart output handling. +- **Manual Updated:** The engineering manual (LaTeX) reflects v6.0 in metadata and includes a new section, “The Processing Chain Editor,” explaining step creation, interpolation constraints, output routing, and reliability tips. + +--- + # Version 5.1.1 **Release date:** December 26, 2025 diff --git a/Manual/Warlock-Studio_Manual.tex b/Manual/Warlock-Studio_Manual.tex index 52e95c4..9dd9178 100644 --- a/Manual/Warlock-Studio_Manual.tex +++ b/Manual/Warlock-Studio_Manual.tex @@ -59,7 +59,7 @@ filecolor=WarlockGold, urlcolor=WarlockBlue, citecolor=WarlockGray, - pdftitle={Warlock-Studio v5.1.1 Master Manual}, + pdftitle={Warlock-Studio v6.0 Master Manual}, pdfauthor={Ivan-Ayub97} } @@ -294,7 +294,7 @@ {\sffamily\bfseries\fontsize{24}{30}\selectfont MASTER OPERATING MANUAL} \\[0.5cm] \begin{tcolorbox}[colback=WarlockRed, colframe=WarlockRed, width=4cm, arc=5mm, boxrule=0pt, halign=center] - \sffamily\bfseries\textcolor{WarlockText}{Version 5.1.1} + \sffamily\bfseries\textcolor{WarlockText}{Version 6.0} \end{tcolorbox} \vspace{2cm} @@ -467,6 +467,37 @@ New in v5.1.1. It provides a live feed of the backend operations. \item \textbf{[FFMPEG]}: Raw encoding logs from the video engine. \end{itemize} +\section{5. The Processing Chain Editor} +This module lets you build multi-step workflows. Each step runs sequentially; the output of a step becomes the input to the next step automatically. + +\subsection{Adding Steps} +\begin{itemize} + \item Click \textbf{Add Step}. Choose \textbf{Model Name} from the combobox. Values are auto-discovered from \texttt{AI-onnx/}. + \item Configure \textbf{GPU}, \textbf{Input/Output Resize}, \textbf{Blending}, \textbf{VRAM Limit}, \textbf{Extension}, and \textbf{Video Codec}. + \item Reorder with \textbf{Up/Down}. Disable a step with \textbf{Bypass} to skip it temporarily. +\end{itemize} + +\subsection{Interpolation Steps (RIFE)} +\begin{itemize} + \item Use \textbf{Frame Generation} to select \textit{x2, x4, x8} or \textit{Slowmotion} variants. + \item Interpolation requires \textbf{video input}. If the current file is an image, the step is skipped and logged. + \item Intermediate containers default to \texttt{.mp4} unless the last step targets a different format. +\end{itemize} + +\subsection{Output Routing} +\begin{itemize} + \item Intermediate results go to a temporary folder per step. The final step writes to the chosen output directory. + \item Extensions and codecs are corrected automatically based on media type to prevent invalid outputs. + \item Optional: keep extracted frames on the final step for inspection. +\end{itemize} + +\subsection{Reliability \& Performance} +\begin{itemize} + \item VRAM-aware tiling is applied per step to avoid memory errors on large inputs. + \item Memory is cleaned between steps to maintain stability over long chains. + \item Face restoration uses stable float32 precision with proper alpha handling. +\end{itemize} + % ========================================================= % CHAPTER 3: THE MODEL LIBRARY % ========================================================= @@ -744,7 +775,7 @@ The system will detect the existing frames and verify the \texttt{.checkpoint} f \sffamily \textcolor{WarlockGray!50}{\rule{0.7\linewidth}{1pt}} \\ \vspace{0.8cm} - \textcolor{WarlockGray}{\textit{Warlock-Studio v5.1.1 | Engineering Documentation}} + \textcolor{WarlockGray}{\textit{Warlock-Studio v6.0 | Engineering Documentation}} \end{center} \end{document} diff --git a/README.md b/README.md index a9cec16..d213bfe 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ![License: MIT](https://img.shields.io/badge/License-MIT-0A3B1E?style=for-the-badge&logo=open-source-initiative&logoColor=FFD43B&labelColor=1B1818) [![Last Commit](https://img.shields.io/github/last-commit/Ivan-Ayub97/Warlock-Studio?style=for-the-badge&logo=git&color=6A1B9A&logoColor=FFD43B&labelColor=1B1818)](https://github.com/Ivan-Ayub97/Warlock-Studio/commits/main) -![Version 5.1.1](https://img.shields.io/badge/Version-5.1.1-660066?style=for-the-badge&logo=tag&logoColor=FFD43B&labelColor=1B1818) +![Version 6](https://img.shields.io/badge/Version-6.0-660066?style=for-the-badge&logo=tag&logoColor=FFD43B&labelColor=1B1818) [![Downloads Total](https://img.shields.io/github/downloads/Ivan-Ayub97/Warlock-Studio/total?style=for-the-badge&logo=github&color=2E2E2E&labelColor=1B1818&logoColor=FFD43B)](https://github.com/Ivan-Ayub97/Warlock-Studio/releases) [![SF Downloads](https://img.shields.io/sourceforge/dt/warlock-studio?style=for-the-badge&logo=sourceforge&color=C45500&logoColor=FFD43B&labelColor=1B1818)](https://sourceforge.net/projects/warlock-studio/) @@ -35,7 +35,7 @@ It is inspired by and based on [Djdefrag](https://github.com/Djdefrag) tools suc - + Download from GitHub @@ -57,8 +57,6 @@ It is inspired by and based on [Djdefrag](https://github.com/Djdefrag) tools suc ![UICapture](rsc/Capture2.png) ![UICapture](rsc/Capture3.png) -![UICapture](rsc/Capture4.png) -![UICapture](rsc/Capture5.png) --- @@ -77,6 +75,7 @@ It is inspired by and based on [Djdefrag](https://github.com/Djdefrag) tools suc - **AI Upscaling & Restoration** – Utilize **Real-ESRGAN, BSRGAN, RealESRNet, RealESR_Animex4, and IRCNN** models for denoising, super-resolution, and detail recovery. - **Face Restoration (GFPGAN)** – Recover facial details from low-resolution or blurry images and video frames. - **Frame Interpolation (RIFE)** – Smooth motion or generate slow-motion content with **2×, 4×, or 8× interpolation**. +- **Process Chaining** – Build sequential workflows by chaining steps. Mix **upscaling**, **face restoration**, and **interpolation**; each step’s output becomes the next step’s input automatically. Includes model auto-discovery, per-step GPU/codec settings, and smart validation (e.g., RIFE requires video). - **Advanced Hardware Acceleration** – Intelligent provider selection prioritizes **CUDA**, falls back to **DirectML**, and finally **CPU** for maximum compatibility. - **Batch Processing** – Process multiple media files simultaneously, saving time and effort. - **Custom Workflows** – Fine-grained control over models, resolution, output formats, and quality parameters. @@ -84,6 +83,17 @@ It is inspired by and based on [Djdefrag](https://github.com/Djdefrag) tools suc --- +## 🆕 New in v6.0 — Process Chaining + +- Create multi-step pipelines; order steps to run sequentially per file. +- RIFE interpolation integrates as a chain step for video sources (graceful skip on images). +- Per-step model selection via a combobox fed by auto-discovered ONNX models in `AI-onnx/`. +- Automatic output routing: intermediate steps use temp folders; the final step writes to your chosen output path. +- Smart extension/codec correction by media type to prevent invalid outputs. +- Memory-safe execution with per-step VRAM tile sizing and cleanup between steps. + +--- + ## 🖥️ System Requirements
@@ -154,14 +164,3 @@ We welcome contributions from the community. | **Python** | PSF License | Python Software Foundation | [Official Site](https://www.python.org) | | **PyInstaller** | GPLv2+ | PyInstaller Team | [GitHub](https://github.com/pyinstaller/pyinstaller) | | **Inno Setup** | Custom | Jordan Russell | [Official Site](http://www.jrsoftware.org/isinfo.php) | - - - - - - - - - - - diff --git a/Setup.iss b/Setup.iss index 3f369c4..c54d72c 100644 --- a/Setup.iss +++ b/Setup.iss @@ -13,7 +13,7 @@ ; DEFINICIONES ; --------------------------------------------------------- #define AppName "Warlock-Studio" -#define AppVersion "5.1.1" +#define AppVersion "6.0" #define AppPublisher "Ivan-Ayub97" #define AppURL "https://github.com/Ivan-Ayub97/Warlock-Studio" #define AppExeName "Warlock-Studio.exe" @@ -31,7 +31,7 @@ AppPublisherURL={#AppURL} AppSupportURL={#AppURL} AppUpdatesURL={#AppURL} -AppId={{***************************************} +AppId={{D1168ED1-6227-441F-8B88-EE6DBD45F336} ; ---- INSTALACIÓN EN DOCUMENTOS ---- DefaultDirName={userdocs}\{#AppName} @@ -53,10 +53,10 @@ SolidCompression=yes SetupLogging=yes ; ---- ESTÉTICA ---- -SetupIconFile={#SourcePath}\logo.ico +SetupIconFile= C:\Users\negro\Desktop\Warlock-Studio-main\Warlock-Studio\logo.ico UninstallDisplayIcon={app}\{#AppExeName} -WizardImageFile={#SourcePath}\Assets\wizard-image.bmp -WizardSmallImageFile={#SourcePath}\Assets\wizard-small.bmp +WizardImageFile= C:\Users\negro\Desktop\Warlock-Studio-main\Warlock-Studio\Assets\wizard-image.bmp +WizardSmallImageFile=C:\Users\negro\Desktop\Warlock-Studio-main\Warlock-Studio\Assets\wizard-small.bmp ; ---- FIRMA ---- SignTool=MySignTool @@ -65,8 +65,7 @@ SignTool=MySignTool ; LANGUAGES (SEGURO) ; ========================================================= [Languages] -Name: "english"; MessagesFile: "compiler:Default.isl"; LicenseFile: "{#SourcePath}\Assets\License.txt" -; Name: "spanish"; MessagesFile: "compiler:Languages\Spanish.isl"; LicenseFile: "{#SourcePath}\Assets\License.txt" +Name: "english"; MessagesFile: "compiler:Default.isl"; LicenseFile: "C:\Users\negro\Desktop\Warlock-Studio-main\Warlock-Studio\Assets\License.txt" ; ========================================================= ; TASKS (OPCIONES DEL USUARIO) @@ -81,13 +80,13 @@ Name: "userdata"; Description: "Create user data folder in Documents"; GroupDesc ; ========================================================= [Files] -Source: "{#SourcePath}\{#AppExeName}"; DestDir: "{app}"; Flags: ignoreversion -Source: "{#SourcePath}\logo.ico"; DestDir: "{app}"; Flags: ignoreversion +Source: "C:\Users\negro\Desktop\Warlock-Studio-main\Warlock-Studio\{#AppExeName}"; DestDir: "{app}"; Flags: ignoreversion +Source: "C:\Users\negro\Desktop\Warlock-Studio-main\Warlock-Studio\logo.ico"; DestDir: "{app}"; Flags: ignoreversion -Source: "{#SourcePath}\_internal\*"; DestDir: "{app}\_internal"; \ +Source: "C:\Users\negro\Desktop\Warlock-Studio-main\Warlock-Studio\_internal\*"; DestDir: "{app}\_internal"; \ Flags: ignoreversion recursesubdirs createallsubdirs -Source: "{#SourcePath}\Assets\*"; DestDir: "{app}\Assets"; \ +Source: "C:\Users\negro\Desktop\Warlock-Studio-main\Warlock-Studio\Assets\*"; DestDir: "{app}\Assets"; \ Flags: ignoreversion recursesubdirs createallsubdirs ; ========================================================= diff --git a/Warlock-Studio.py b/Warlock-Studio.py index a7fa5e2..9b5ccf1 100644 --- a/Warlock-Studio.py +++ b/Warlock-Studio.py @@ -7,6 +7,7 @@ import shutil import signal import subprocess import sys +import time import traceback import warnings from contextlib import contextmanager @@ -109,6 +110,7 @@ from console import IntegratedConsole, console 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 processing_chain import ChainManager, ProcessingStep from splash_screen import SplashScreen from warlock_preferences import ConfigManager, PreferencesButton @@ -118,6 +120,10 @@ console.setup_redirection() # Suppress specific warnings to keep console clean warnings.filterwarnings("ignore", category=UserWarning) +# Variable global para mantener la instancia (o la lista) +chain_window = None +current_chain_steps = [] # Aquí se guardarán los pasos si el usuario usa la cadena + # Handle PyInstaller or Normal Execution Path @@ -132,7 +138,7 @@ def find_by_relative_path(relative_path: str) -> str: app_name = "Warlock-Studio" -version = "5.1.1" +version = "6.0" # Supported File Extensions supported_image_extensions = [".jpg", ".jpeg", @@ -151,18 +157,18 @@ supported_file_extensions = supported_image_extensions + supported_video_extensi # Fondo: Negro casi puro, igual que el fondo del banner para máximo contraste background_color = "#000000" -# Nombre de la app: Plata metálico, inspirado en el texto "STUDIO" -app_name_color = "#FBC02D" +# Nombre de la app: Amarillo intenso para alto contraste +app_name_color = "#FFD700" # Paneles: Gris oscuro neutro, permite que el rojo y dorado resalten sin competir widget_background_color = "#1A1A1A" # Texto principal: Blanco puro para legibilidad máxima text_color = "#F5F5F5" # Texto secundario: Dorado pálido/desaturado, para no cansar la vista pero mantener la identidad secondary_text_color = "#9E9E9E" -# Acento: El amarillo dorado brillante del sombrero y los destellos (Sparkles) +# Acento: Amarillo/ámbar vibrante (alto contraste) accent_color = "#FFC107" # Hover de botones: El rojo vibrante del relleno del texto "WARLOCK" -button_hover_color = "#C62828" +button_hover_color = "#D32F2F" # Bordes: Un dorado oscuro muy sutil, imitando el borde del logo sin ser chillón border_color = "#2D2D2D" # Botones info/secundarios: El rojo sangre oscuro del fondo del círculo del logo @@ -234,6 +240,7 @@ USER_PREFERENCE_PATH = find_by_relative_path( # --- INTEGRACIÓN DE PREFERENCIAS: RUTAS PERSONALIZADAS --- _app_config = ConfigManager.load_config() +CORNER_RADIUS = _app_config.get("corner_radius", 10) # Lógica FFmpeg _custom_ffmpeg = _app_config.get("custom_ffmpeg_path", "") @@ -470,17 +477,15 @@ def create_onnx_session(model_path: str, selected_gpu: str) -> InferenceSession: class AI_upscale: - # ------------------------------------------------------------------------- # CLASS INIT # ------------------------------------------------------------------------- - def __init__( self, AI_model_name: str, directml_gpu: str, - input_resize_factor: int, - output_resize_factor: int, + input_resize_factor: float, + output_resize_factor: float, max_resolution: int ): # Parámetros recibidos @@ -490,13 +495,39 @@ class AI_upscale: self.output_resize_factor = output_resize_factor self.max_resolution = max_resolution - # Variables calculadas - self.AI_model_path = find_by_relative_path( + # --- OPTIMIZACIÓN: Padding y Batch Size --- + # Padding: Píxeles extra alrededor del tile para evitar cortes visibles (seamless). + # 32 es un buen equilibrio para la mayoría de modelos SRGAN/ESRGAN. + self.tile_padding = 32 + + # Batch Size: Cantidad de tiles procesados simultáneamente. + # Aumentar esto acelera el proceso pero consume más VRAM. + # 4 es un valor conservador y rápido para la mayoría de GPUs modernas. + self.batch_size = 4 + + # --- CORRECCIÓN DE RUTAS (FP16 / FP32) --- + # Definimos las posibles rutas del modelo + path_fp16 = find_by_relative_path( f"AI-onnx{os_separator}{self.AI_model_name}_fp16.onnx") + path_fp32 = find_by_relative_path( + f"AI-onnx{os_separator}{self.AI_model_name}_fp32.onnx") + path_clean = find_by_relative_path( + f"AI-onnx{os_separator}{self.AI_model_name}.onnx") + + # Verificamos cuál existe y asignamos + if os_path_exists(path_fp16): + self.AI_model_path = path_fp16 + elif os_path_exists(path_fp32): + self.AI_model_path = path_fp32 + elif os_path_exists(path_clean): + self.AI_model_path = path_clean + else: + # Si no encuentra ninguno, dejamos el por defecto (fp16) + self.AI_model_path = path_fp16 + 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. + # La sesión se carga bajo demanda self.inferenceSession = None def _get_upscale_factor(self) -> int: @@ -507,14 +538,12 @@ class AI_upscale: 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.""" + """Carga la sesión de inferencia utilizando la función centralizada.""" if self.inferenceSession is not None: return - try: self.inferenceSession = create_onnx_session( self.AI_model_path, self.directml_gpu) @@ -526,13 +555,9 @@ class AI_upscale: # ------------------------------------------------------------------------- # 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: @@ -543,466 +568,340 @@ class AI_upscale: 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 + return image.shape[0], image.shape[1] # 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 + h, w = self.get_image_resolution(image) + return h * self.upscale_factor, w * self.upscale_factor 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) + 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_width = int(old_width * self.input_resize_factor) - new_height = int(old_height * self.input_resize_factor) + new_w = max(2, self._ensure_even_dimensions(new_w)) + new_h = max(2, self._ensure_even_dimensions(new_h)) - # 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): + if self.input_resize_factor == 1.0 and (new_w == old_w and new_h == old_h): return image - interpolation = INTER_CUBIC if self.input_resize_factor > 1 else INTER_AREA - return opencv_resize(image, (new_width, new_height), interpolation=interpolation) + # Optimización: Lanczos es mejor para reducir (downscale), Cubic para ampliar + interpolation = INTER_CUBIC if self.input_resize_factor > 1 else cv2.INTER_LANCZOS4 + return opencv_resize(image, (new_w, new_h), 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) + 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_width = int(old_width * self.output_resize_factor) - new_height = int(old_height * self.output_resize_factor) + new_w = max(2, self._ensure_even_dimensions(new_w)) + new_h = max(2, self._ensure_even_dimensions(new_h)) - # 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): + if self.output_resize_factor == 1.0 and (new_w == old_w and new_h == old_h): 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 - # ------------------------------------------------------------------------- + interpolation = INTER_CUBIC if self.output_resize_factor > 1 else cv2.INTER_LANCZOS4 + return opencv_resize(image, (new_w, new_h), interpolation=interpolation) 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) + # Usar una estimación rápida si la imagen es gigante + if frame is None: + return 1 - height, width = self.get_image_resolution(resized_frame) - image_pixels = height * width + # Simular el resize de entrada + h, w = frame.shape[:2] + input_h = int(h * self.input_resize_factor) + input_w = int(w * self.input_resize_factor) - # max_resolution se usa como lado de un cuadrado para estimar área soportada - max_supported_pixels = self.max_resolution * self.max_resolution + image_pixels = input_h * input_w + max_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.") + # Cálculo conservador + frames = max_pixels // image_pixels + return max(1, min(frames, 16)) + except: return 1 # ------------------------------------------------------------------------- - # TILING (MOSAICO) FUNCTIONS + # CORE PROCESSING (Optimized Batch & Tiling) # ------------------------------------------------------------------------- - - 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 + # Optimización: Vectorización directa 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 + return (image.astype(float32) / 255.0), 255.0 + elif numpy_max(image) > 1.0: + return (image.astype(float32) / 255.0), 255.0 else: - # Fallback seguro - max_val = 255.0 + return image.astype(float32), 1.0 - if max_val == 1.0: - return image.astype(float32), 1 + def de_normalize_image(self, image: numpy_ndarray, range_val: float) -> numpy_ndarray: + image = image * range_val + return numpy_clip(image, 0, 255).astype(uint8) - normalized = image.astype(float32) / max_val - return normalized, int(max_val) + def preprocess_image_batch(self, images_list: list) -> numpy_ndarray: + """Convierte una lista de imágenes HWC a un batch NCHW.""" + # Stack convierte lista de arrays (H,W,C) en (N, H, W, C) + batch = numpy_stack(images_list, axis=0) + # Transponer a (N, C, H, W) + batch = numpy_transpose(batch, (0, 3, 1, 2)) + return numpy_ascontiguousarray(batch) - 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.""" + def onnx_inference_batch(self, batch_input: numpy_ndarray) -> numpy_ndarray: if self.inferenceSession is None: self._load_inferenceSession() input_name = self.inferenceSession.get_inputs()[0].name - onnx_input = {input_name: image} + # Ejecutar inferencia + try: + results = self.inferenceSession.run( + None, {input_name: batch_input})[0] + except Exception as e: + # Fallback a CPU si falla GPU por memoria en lote grande + logging.warning( + f"Batch inference failed: {e}. Retrying individually might be needed.") + raise e - # Ejecutar (run devuelve una lista, tomamos el primer output) - onnx_output = self.inferenceSession.run(None, onnx_input)[0] - return onnx_output + return results - 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 _process_tile_batch(self, batch_images: list, range_val: float) -> list: + """Helper para procesar un lote de tiles RGB.""" + if not batch_images: + return [] - 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) + # 1. Normalizar (asumiendo que ya vienen en float32 0-1 o procesar aqui) + # Para velocidad, asumiremos que batch_images ya son recortes del 'image_norm' (float32) - 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) + # 2. Preprocesar (HWC -> NCHW) + batch_nchw = self.preprocess_image_batch(batch_images) + + # 3. Inferencia + output_nchw = self.onnx_inference_batch(batch_nchw) + + # 4. Post-proceso (NCHW -> HWC) + # Transponer de vuelta a (N, H, W, C) + output_nhwc = numpy_transpose(output_nchw, (0, 2, 3, 1)) + + # 5. Denormalizar y convertir a lista + processed_batch = [] + for i in range(output_nhwc.shape[0]): + img_out = self.de_normalize_image(output_nhwc[i], range_val) + processed_batch.append(img_out) + + return processed_batch # ------------------------------------------------------------------------- - # CORE UPSCALE LOGIC + # MAIN 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") + # Manejo de memoria y formato image = numpy_ascontiguousarray(image) - height, width = image.shape[:2] + mode = self.get_image_mode(image) - # --- MANEJO ROBUSTO DE CANALES (Fix 7) --- + # --- SEPARACIÓN DE CANAL ALPHA --- + has_alpha = False + channel_alpha = None + rgb_image = image - # 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 + if mode == "RGBA": has_alpha = True - is_grayscale_output = False + channel_alpha = image[:, :, 3] + rgb_image = image[:, :, :3] # Extraer RGB + elif mode == "Grayscale": + # Convertir a RGB para el modelo + rgb_image = cv2.cvtColor(image, cv2.COLOR_GRAY2RGB) - # Caso 3: RGB Estándar + # --- PREPARACIÓN RGB --- + # Normalizar imagen completa + image_norm, range_val = self.normalize_image(rgb_image) + + h, w = image_norm.shape[:2] + + # Comprobar Tiling + # Usamos max_resolution - padding * 2 para asegurar que el contenido útil cabe + if (h * w) > (self.max_resolution ** 2): + upscaled_rgb = self._upscale_with_tiling_optimized( + image_norm, range_val) else: - has_alpha = False - is_grayscale_output = False + # Proceso directo (Single Image) + upscaled_rgb = self._process_tile_batch( + [image_norm], range_val)[0] - # --- 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 + # --- RECONSTRUCCIÓN ALPHA Y SALIDA --- if has_alpha: + # Escalar Alpha con Bicubic/Lanczos (muy rápido y buena calidad para máscaras) 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 + upscaled_alpha = cv2.resize( + channel_alpha, (target_w, target_h), interpolation=cv2.INTER_LANCZOS4) + + # Unir 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) + final_image = numpy_concatenate( + (upscaled_rgb, upscaled_alpha), axis=2) + elif mode == "Grayscale": + final_image = cv2.cvtColor(upscaled_rgb, cv2.COLOR_RGB2GRAY) 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 - ) + final_image = upscaled_rgb return final_image except Exception as e: - logging.error(f"Tiling Error: {str(e)}") - raise RuntimeError(f"Tiled upscaling failed: {str(e)}") + logging.error(f"AI Upscale Error: {str(e)}") + raise RuntimeError(f"Upscaling failed: {str(e)}") + + def _upscale_with_tiling_optimized(self, image_norm: numpy_ndarray, range_val: float) -> numpy_ndarray: + """ + Sistema de Tiling Avanzado con Overlap (Superposición) y Batch Processing. + Elimina las costuras visibles (seams) y acelera con lotes. + """ + h, w = image_norm.shape[:2] + tile_size = self.max_resolution + padding = self.tile_padding + scale = self.upscale_factor + + # Coordenadas de los tiles (sin padding todavía) + # Se generan pasos de (tile_size - 2*padding) para asegurar cobertura limpia + step = tile_size - (2 * padding) + if step <= 0: + step = tile_size // 2 # Fallback si el padding es enorme + + y_points = list(range(0, h, step)) + x_points = list(range(0, w, step)) + + # Calcular tamaño final + target_h, target_w = h * scale, w * scale + + # Lienzo final pre-alocado (Más rápido que append) + final_image = numpy_zeros((target_h, target_w, 3), dtype=uint8) + + batch_tiles = [] + # (y_start_out, y_end_out, x_start_out, x_end_out, crop_y1, crop_y2, crop_x1, crop_x2) + batch_coords = [] + + print( + f"[AI] Advanced Tiling: Processing grid {len(y_points)}x{len(x_points)} with Batch Size {self.batch_size}") + + for y in y_points: + for x in x_points: + # 1. Definir coordenadas del tile de entrada con padding + # Intentamos tomar padding extra, pero clampamos a los bordes de la imagen + y_start_in = max(0, y - padding) + y_end_in = min(h, y + step + padding) + x_start_in = max(0, x - padding) + x_end_in = min(w, x + step + padding) + + # Extraer tile + tile = image_norm[y_start_in:y_end_in, x_start_in:x_end_in] + + # Guardar tile para batch + batch_tiles.append(tile) + + # 2. Calcular dónde va este tile en la imagen de SALIDA + # La zona "valid" es la que no es padding (excepto en los bordes de la imagen original) + + # Offsets relativos dentro del tile extraído (para recortar el padding después del upscale) + res_y_start = (y - y_start_in) * scale + res_x_start = (x - x_start_in) * scale + + # Dimensiones de la zona válida a pegar en el canvas final + # El ancho/alto "útil" es min(step, lo que quede de imagen) * scale + valid_h = min(step, h - y) * scale + valid_w = min(step, w - x) * scale + + res_y_end = res_y_start + valid_h + res_x_end = res_x_start + valid_w + + # Coordenadas absolutas en el canvas final + abs_y = y * scale + abs_x = x * scale + + # Guardamos las coordenadas de recorte y pegado + batch_coords.append({ + "crop": (int(res_y_start), int(res_y_end), int(res_x_start), int(res_x_end)), + "place": (int(abs_y), int(abs_y + valid_h), int(abs_x), int(abs_x + valid_w)) + }) + + # --- PROCESAR BATCH SI ESTÁ LLENO --- + if len(batch_tiles) >= self.batch_size: + self._process_and_stitch_batch( + batch_tiles, batch_coords, final_image, range_val) + batch_tiles = [] + batch_coords = [] + gc.collect() # Mantener RAM a raya + + # Procesar remanentes + if batch_tiles: + self._process_and_stitch_batch( + batch_tiles, batch_coords, final_image, range_val) + gc.collect() + + return final_image + + def _process_and_stitch_batch(self, tiles, coords, final_image, range_val): + """Procesa un batch y pega los resultados en el canvas global.""" + # Nota: Los tiles pueden tener tamaños distintos en los bordes. + # Si los tamaños son distintos, no se puede hacer batching directo en un tensor único NCHW. + # Verificamos si todos tienen mismo tamaño. Si no, procesamos 1 a 1 (fallback del batch). + + shapes = [t.shape for t in tiles] + if len(set(shapes)) > 1: + # Tamaños mixtos (pasa en bordes), procesar uno a uno + upscaled_tiles = [] + for t in tiles: + upscaled_tiles.extend(self._process_tile_batch([t], range_val)) + else: + # Todos iguales, batch real + upscaled_tiles = self._process_tile_batch(tiles, range_val) + + # Pegado (Stitching) + for img_out, coord in zip(upscaled_tiles, coords): + cy1, cy2, cx1, cx2 = coord["crop"] + py1, py2, px1, px2 = coord["place"] + + # Recortar zona válida (quitando padding escalado) + valid_content = img_out[cy1:cy2, cx1:cx2] + + # Pegar en imagen final + # Asegurar dimensiones exactas (por redondeos) + fh, fw = final_image[py1:py2, px1:px2].shape[:2] + if valid_content.shape[0] != fh or valid_content.shape[1] != fw: + valid_content = valid_content[:fh, :fw] + + final_image[py1:py2, px1:px2] = valid_content # ------------------------------------------------------------------------- # 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 + # 1. Redimensionar Entrada (Input Resize) + image = self.resize_with_input_factor(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) + # 2. Upscaling (Core AI) + upscaled_image = self.AI_upscale(image) - # 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 + # 3. Redimensionar Salida (Output Resize) + final_image = self.resize_with_output_factor(upscaled_image) return final_image @@ -1787,7 +1686,8 @@ class MessageBox(CTkToplevel): fg_color=widget_background_color, text_color=secondary_text_color, border_color=accent_color, - hover_color=button_hover_color + hover_color=button_hover_color, + corner_radius=CORNER_RADIUS ) self._ctkwidgets_index += 1 @@ -1851,14 +1751,14 @@ def create_option_background(): bg_color=background_color, fg_color=widget_background_color, height=46, - corner_radius=10 + corner_radius=CORNER_RADIUS ) def create_info_button(command: Callable, text: str, width: int = 200) -> CTkFrame: frame = CTkFrame( - master=window, fg_color=widget_background_color, height=25) + master=window, fg_color=widget_background_color, height=25, corner_radius=CORNER_RADIUS) button = CTkButton( master=frame, @@ -1872,7 +1772,7 @@ def create_info_button(command: Callable, text: str, width: int = 200) -> CTkFra text_color=text_color, width=23, height=15, - corner_radius=1 + corner_radius=CORNER_RADIUS ) button.grid(row=0, column=0, padx=(0, 7), pady=2, sticky="w") @@ -1920,7 +1820,7 @@ def create_option_menu( width=total_width, height=total_height, border_width=0, - corner_radius=1, + corner_radius=CORNER_RADIUS, ) option_menu = CTkOptionMenu( @@ -1929,7 +1829,7 @@ def create_option_menu( values=values, width=width, height=height, - corner_radius=0, + corner_radius=max(CORNER_RADIUS - 2, 0), dropdown_font=bold12, font=bold11, anchor="center", @@ -1954,7 +1854,7 @@ def create_text_box(textvariable: StringVar, width: int) -> CTkEntry: return CTkEntry( master=window, textvariable=textvariable, - corner_radius=1, + corner_radius=CORNER_RADIUS, width=width, height=28, font=bold11, @@ -1971,7 +1871,7 @@ def create_text_box_output_path(textvariable: StringVar) -> CTkEntry: return CTkEntry( master=window, textvariable=textvariable, - corner_radius=1, + corner_radius=CORNER_RADIUS, width=250, height=28, font=bold11, @@ -2006,7 +1906,7 @@ def create_active_button( height=height, font=bold11, border_width=1, - corner_radius=1, + corner_radius=CORNER_RADIUS, fg_color=widget_background_color, text_color=text_color, border_color=border_color, @@ -2691,8 +2591,10 @@ def copy_file_metadata(original_file_path: str, upscaled_file_path: str) -> None upscaled_file_path ] + # CORRECCIÓN: Agregar errors='replace' y encoding='utf-8' (o dejar encoding default pero con replace) result = subprocess_run(exiftool_cmd, check=True, - shell=False, capture_output=True, text=True) + shell=False, capture_output=True, text=True, + encoding='utf-8', errors='replace') # <--- CAMBIO AQUÍ print(f"[ExifTool] Successfully copied metadata") except CalledProcessError as e: @@ -3264,6 +3166,9 @@ def video_encoding( """ try: + # ... (El código de preparación de rutas y FPS se mantiene igual) ... + # Copia todo el inicio de la función hasta llegar a la parte de subprocess + # --- Preparación de rutas temporales --- base_name = os_path_splitext(video_output_path)[0] txt_path = f"{base_name}_frames.txt" @@ -3278,47 +3183,35 @@ def video_encoding( print( f"[WARNING] Temporary file could not be deleted {temp_file}: {e}") - # --- Obtener FPS del video original --- + # --- Obtener FPS --- 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 --- + # --- Crear lista de frames --- 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( @@ -3327,18 +3220,17 @@ def video_encoding( process_status_q.put( f"[LOG] [FFMPEG] Attempt {attempt+1}: Encoding with {codec_settings['codec']}") - # --- Ejecutar FFmpeg --- + # --- Ejecutar FFmpeg (CORREGIDO CON ERRORS='REPLACE') --- process = subprocess.Popen( encoding_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8', - errors='replace', + errors='replace', # <--- ESTO YA ESTABA, PERO ASEGÚRATE QUE ESTÉ PRESENTE creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0 ) - # Leer salida for line in process.stdout: line = line.strip() if line: @@ -3346,39 +3238,25 @@ def video_encoding( 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 not os_path_exists(no_audio_path) or os_path_getsize(no_audio_path) < 1024: if attempt == 0: - raise RuntimeError("Output file missing") + raise RuntimeError("Output file missing or too small") else: raise RuntimeError( - "Output file missing after fallback") + "Output file missing or too small 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)") + process_status_q.put(f"[LOG] [FFMPEG] Encoding complete") encoding_success = True - break # Salir del bucle de intentos + break 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...") @@ -3387,22 +3265,20 @@ def video_encoding( os_remove(no_audio_path) except: pass - continue # Continúa al siguiente ciclo del for (fallback) + continue 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 + # DETECTAR AUDIO (AQUÍ ES DONDE SUELE FALLAR EL 0xe1) # ============================================================================== - - # --- Detección de audio --- process_status_q.put( "[LOG] [FFMPEG] Checking audio track of original video...") + # ... (Código de búsqueda de ffprobe se mantiene igual) ... ffprobe_path = None try: ffprobe_guess = FFMPEG_EXE_PATH.replace( @@ -3414,7 +3290,7 @@ def video_encoding( "ffmpeg.exe", "ffprobe") if os_path_exists(ffprobe_guess2): ffprobe_path = ffprobe_guess2 - except Exception: + except: ffprobe_path = None has_audio = False @@ -3427,8 +3303,9 @@ def video_encoding( "-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) + # CORRECCIÓN: Agregar errors='replace' + probe = subprocess_run(probe_cmd, capture_output=True, text=True, + encoding='utf-8', errors='replace', timeout=30) # <--- CAMBIO audio_codec = probe.stdout.strip() has_audio = bool(audio_codec) except Exception as e: @@ -3438,8 +3315,9 @@ def video_encoding( 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) + # CORRECCIÓN: Agregar errors='replace' + probe = subprocess_run(probe_cmd, capture_output=True, text=True, + encoding='utf-8', errors='replace', timeout=20) # <--- CAMBIO stderr_output = probe.stderr or probe.stdout or "" if "Audio:" in stderr_output: has_audio = True @@ -3455,14 +3333,14 @@ def video_encoding( "-i", video_path, "-i", no_audio_path, "-c:v", "copy", "-c:a", "copy", "-map", "1:v:0", "-map", "0:a", - "-shortest", - video_output_path + "-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) + # CORRECCIÓN: Agregar errors='replace' + subprocess_run(audio_copy_cmd, check=True, capture_output=True, text=True, + encoding='utf-8', errors='replace') # <--- CAMBIO if os_path_exists(no_audio_path): os_remove(no_audio_path) @@ -3478,12 +3356,13 @@ def video_encoding( "-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 + "-shortest", video_output_path ] try: - subprocess_run(audio_reencode_cmd, check=True, - capture_output=True, text=True) + # CORRECCIÓN: Agregar errors='replace' + subprocess_run(audio_reencode_cmd, check=True, capture_output=True, text=True, + encoding='utf-8', errors='replace') # <--- CAMBIO + if os_path_exists(no_audio_path): os_remove(no_audio_path) process_status_q.put( @@ -3793,60 +3672,141 @@ def upscale_button_command() -> None: global selected_frame_generation_option global process_upscale_orchestrator global stop_thread_flag - global process_upscale_orchestrator - global stop_thread_flag + global chain_window # Necesario para acceder al gestor de cadenas - # --- AGREGAR CONFIRMACIÓN --- + # 1. Confirmación de seguridad 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 + # 2. Limpiar bandera de parada stop_thread_flag.clear() + # 3. Validar entradas del usuario if user_input_checks(): info_message.set("Loading") - cpu_number = int(os_cpu_count()/2) + cpu_number = int(os_cpu_count() / 2) + + # --- LÓGICA DE ENCADENAMIENTO (CHAINING) --- + active_chain = [] + + # Verificar si la ventana de cadena existe y tiene pasos + if 'chain_window' in globals() and chain_window is not None: + try: + # Verificar si la ventana no ha sido destruida + if chain_window.winfo_exists(): + active_chain = chain_window.get_chain() + except Exception: + # Si la ventana fue cerrada, chain_window puede quedar con referencia rota + pass + + # Determinar si vamos a usar cadena o proceso simple + is_chain_active = len(active_chain) > 0 + + # --- VISUALIZACIÓN DE LOGS (Consola) --- print("=" * 50) - print(f"> Starting:") + print(f"> Starting Process:") 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" Output path: {selected_output_path.get()}") print(f" CPU threads: {cpu_number}") - print(f" Save frames: {selected_keep_frames}") + + if is_chain_active: + print( + f" [MODE] CHAIN PROCESSING ACTIVE ({len(active_chain)} Steps)") + for i, step in enumerate(active_chain): + print(f" Step {i+1}: {step}") + else: + print(f" [MODE] SINGLE PROCESSING") + print(f" Selected AI model: {selected_AI_model}") + print(f" Selected GPU: {selected_gpu}") + print(f" Input resize: {int(input_resize_factor * 100)}%") + print(f" Output resize: {int(output_resize_factor * 100)}%") + print(f" Frame Gen: {selected_frame_generation_option}") + print(f" VRAM/Tiles: {tiles_resolution}x{tiles_resolution}px") + print("=" * 50) + + # Colocar botón de STOP 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: + # --- SELECCIÓN DE PROCESO (ORCHESTRATOR) --- + + if is_chain_active: + # CASO A: CADENA ACTIVA + # Usamos el upscale_orchestrator actualizado, pasando la lista de pasos 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,) + args=( + process_status_q, + selected_file_list, + selected_output_path.get(), + # Se ignora si hay chain, pero se pasa por compatibilidad posicional + selected_AI_model, + selected_AI_multithreading, + input_resize_factor, # Se ignora si hay chain + output_resize_factor, # Se ignora si hay chain + selected_gpu, # Se ignora si hay chain + tiles_resolution, + selected_blending_factor, # Se ignora si hay chain + selected_keep_frames, + selected_image_extension, + selected_video_extension, + selected_video_codec, + cpu_number, + active_chain # <--- AQUÍ PASAMOS LA CADENA + ) ) process_upscale_orchestrator.start() + elif selected_AI_model in RIFE_models_list: + # CASO B: RIFE / INTERPOLACIÓN (Sin Cadena) + # FluidFrames usa su propio pipeline específico + 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: + # CASO C: UPSCALE / RESTAURACIÓN NORMAL (Sin Cadena) + # Pasamos None en el último argumento para que el orquestador cree el paso único + 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, + None # <--- SIN CADENA + ) + ) + process_upscale_orchestrator.start() + + # Iniciar hilo de monitoreo de la interfaz thread_wait = Thread(target=check_upscale_steps) thread_wait.start() @@ -4065,8 +4025,8 @@ def upscale_orchestrator( selected_output_path: str, selected_AI_model: str, selected_AI_multithreading: int, - input_resize_factor: int, - output_resize_factor: int, + input_resize_factor: float, + output_resize_factor: float, selected_gpu: str, tiles_resolution: int, selected_blending_factor: float, @@ -4075,74 +4035,316 @@ def upscale_orchestrator( selected_video_extension: str, selected_video_codec: str, cpu_number: int, + # NUEVO ARGUMENTO: Lista de pasos para encadenamiento + chain_steps: list = None ) -> None: global global_status_lock global_status_lock = Lock() - try: - write_process_status(process_status_q, f"Loading AI model") + # Lista para rastrear carpetas temporales y limpiarlas al final + temp_folders_created = [] - # 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) - ] + try: + # --------------------------------------------------------------------- + # 1. NORMALIZACIÓN DE LA CADENA + # Si no hay cadena (uso normal), creamos un paso único con los args recibidos. + # --------------------------------------------------------------------- + # --------------------------------------------------------------------- + # 1. NORMALIZACIÓN DE LA CADENA + # --------------------------------------------------------------------- + if not chain_steps: + # Crea un paso único si no se usa el gestor de cadenas + # Nota: vram_limit se pasa tal cual, el orquestador calculará los tiles + try: + vram_val = float(selected_VRAM_limiter.get()) + except: + vram_val = 0.0 + + initial_step = ProcessingStep( + model_name=selected_AI_model, + input_resize=input_resize_factor, + output_resize=output_resize_factor, + blending=selected_blending_factor, + vram_limit=vram_val, + extension=selected_image_extension if not check_if_file_is_video( + selected_file_list[0]) else selected_video_extension, + video_codec=selected_video_codec, + frame_gen="OFF", + keep_frames=selected_keep_frames, + gpu=selected_gpu + ) + chain_steps = [initial_step] 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( + # --------------------------------------------------------------------- + # 2. BUCLE PRINCIPAL DE ARCHIVOS + # --------------------------------------------------------------------- + for file_number in range(how_many_files): + # El archivo de entrada inicial + original_input_path = selected_file_list[file_number] + current_input_path = original_input_path + + # Nombre base para logs + base_filename = os_path_basename(original_input_path) + display_number = file_number + 1 + + # ----------------------------------------------------------------- + # 3. BUCLE DE LA CADENA DE PROCESOS (STEPS) + # ----------------------------------------------------------------- + for step_index, step in enumerate(chain_steps): + + # Identificar si es el último paso + is_last_step = (step_index == len(chain_steps) - 1) + step_display = f"[Step {step_index + 1}/{len(chain_steps)}]" + + # --- CONFIGURACIÓN DEL PASO ACTUAL --- + current_model = step.model_name + current_input_resize = step.input_resize + current_output_resize = step.output_resize + current_blending = step.blending + current_gpu = step.gpu + + # Determinar extensión de salida para este paso + is_video = check_if_file_is_video(current_input_path) + if is_video: + # Temp video container + current_extension = selected_video_extension if is_last_step else ".mp4" + current_codec = step.video_codec if step.video_codec else selected_video_codec + else: + current_extension = step.extension if step.extension else selected_image_extension + try: + if is_video and current_extension.lower() not in [".mp4", ".mkv", ".avi", ".mov", ".webm"]: + current_extension = ".mp4" + if (not is_video) and current_extension.lower() not in [".png", ".jpg", ".bmp", ".tiff", ".webp"]: + current_extension = ".png" + except Exception: + pass + + # --- GESTIÓN DE RUTAS DE SALIDA --- + if is_last_step: + # El último paso va a la carpeta seleccionada por el usuario + current_output_dir = selected_output_path + else: + # Pasos intermedios van a una carpeta temporal única + current_output_dir = os_path_join( + os_path_dirname(original_input_path), + f"warlock_temp_step_{step_index}_{int(time.time())}" + ) + if not os_path_exists(current_output_dir): + create_dir(current_output_dir) + temp_folders_created.append(current_output_dir) + + # --- LOG DE ESTADO --- + write_process_status( 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 + f"{display_number}. {step_display} Loading {current_model}..." ) - else: - upscale_image( + + is_interpolation_step = (current_model in RIFE_models_list) or (step.frame_gen and step.frame_gen != "OFF") + + if is_interpolation_step: + # Validar que la entrada actual sea video + if not is_video: + write_process_status( + process_status_q, + f"{display_number}. {step_display} Skipping: Interpolation requires a video input" + ) + # Mantener current_input_path sin cambios y continuar con el siguiente paso + continue + + # Resolver factor de generación y modo slowmotion a partir de frame_gen del paso + frame_gen_factor, slowmotion = check_frame_generation_option(step.frame_gen) + + try: + AI_interp = AI_interpolation( + current_model, + frame_gen_factor, + current_gpu, + current_input_resize, + current_output_resize + ) + except Exception as e: + raise RuntimeError(f"Error loading interpolation model {current_model}: {e}") + + write_process_status( + process_status_q, + f"{display_number}. {step_display} Interpolating video..." + ) + + video_container_ext = current_extension if is_last_step else ".mp4" + codec_to_use = current_codec + + try: + fluidframes_video_interpolate( + process_status_q, + current_input_path, + display_number, + current_output_dir, + AI_interp, + current_model, + frame_gen_factor, + slowmotion, + ".png", # extracción temporal siempre PNG + video_container_ext, + codec_to_use, + current_input_resize, + current_output_resize, + cpu_number, + step.keep_frames if is_last_step else False + ) + except Exception as e: + raise RuntimeError(f"Interpolation failed: {e}") + + expected_filename = prepare_output_video_filename( + current_input_path, + current_output_dir, + current_model, + frame_gen_factor, + slowmotion, + current_input_resize, + current_output_resize, + video_container_ext + ) + else: + # --- INSTANCIACIÓN DEL MODELO (UPSCALE/RESTORE) --- + # Recalcular tiles si hay límite de VRAM en el paso + current_tiles_resolution = tiles_resolution + if step.vram_limit > 0: + vram_multiplier = VRAM_model_usage.get(current_model, 1.0) + current_tiles_resolution = int( + (vram_multiplier * step.vram_limit) * 100) + + # Crear instancias + AI_instances = [] + try: + # Determinar clase (Face Restore vs Upscale Genérico) + if current_model in Face_restoration_models_list: + AI_instances = [ + AI_face_restoration( + current_model, + current_gpu, + current_input_resize, + current_output_resize, + current_tiles_resolution + ) for _ in range(selected_AI_multithreading) + ] + else: + AI_instances = [ + AI_upscale( + current_model, + current_gpu, + current_input_resize, + current_output_resize, + current_tiles_resolution + ) for _ in range(selected_AI_multithreading) + ] + except Exception as e: + raise RuntimeError( + f"Error loading model {current_model}: {e}") + + # --- EJECUCIÓN DEL PROCESAMIENTO --- + write_process_status( 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 + f"{display_number}. {step_display} Processing {base_filename}..." ) + # Predecir el nombre del archivo de salida para poder pasarlo al siguiente paso + if not is_interpolation_step: + if is_video: + # Lógica para Video + upscale_video( + process_status_q, + current_input_path, + display_number, # Mantiene el número original + current_output_dir, + AI_instances, + current_model, + current_input_resize, + current_output_resize, + cpu_number, + current_extension, + current_blending, + selected_AI_multithreading, + # Solo guardar frames si es el último y el usuario quiere + step.keep_frames if is_last_step else False, + current_codec + ) + + # Calcular cuál fue el archivo resultante para usarlo de input en el siguiente paso + expected_filename = prepare_output_video_filename( + current_input_path, + # Importante: Buscar en el dir actual (temp o final) + current_output_dir, + current_model, + 1, # frame_gen factor (upscale es 1) + False, # slowmo + current_input_resize, + current_output_resize, + current_extension + ) + + else: + # Lógica para Imagen + # Nota: upscale_image solo procesa una instancia, pasamos la primera + upscale_image( + process_status_q, + current_input_path, + display_number, + current_output_dir, + AI_instances[0], + current_model, + current_extension, + current_input_resize, + current_output_resize, + current_blending + ) + + # Calcular archivo resultante + expected_filename = prepare_output_image_filename( + current_input_path, + current_output_dir, + current_model, + current_input_resize, + current_output_resize, + current_extension, + current_blending + ) + + # --- PREPARACIÓN PARA EL SIGUIENTE PASO --- + # Verificar que el archivo se creó correctamente + if not os_path_exists(expected_filename): + raise FileNotFoundError( + f"Step {step_index+1} failed. Output file not found: {expected_filename}") + + # El output de este paso es el input del siguiente + current_input_path = expected_filename + + # Limpiar memoria VRAM/RAM entre pasos + try: + del AI_instances + except: + pass + optimize_memory_usage() + + # Fin del bucle de pasos para este archivo + + # --------------------------------------------------------------------- + # 4. LIMPIEZA FINAL + # --------------------------------------------------------------------- + + # Eliminar carpetas temporales de encadenamiento + for temp_dir in temp_folders_created: + if os_path_exists(temp_dir): + try: + # Usamos shutil.rmtree para borrar carpeta y contenido + import shutil + shutil.rmtree(temp_dir) + except Exception as e: + print( + f"Warning: Could not remove temp chain dir {temp_dir}: {e}") + write_process_status(process_status_q, f"{COMPLETED_STATUS}") except Exception as exception: @@ -4150,21 +4352,28 @@ def upscale_orchestrator( # Enviamos el error a la consola visual write_process_status( - process_status_q, f"[LOG] [ERROR] Detalle técnico: {error_message}") + process_status_q, f"[LOG] [ERROR] Technical detail: {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." + friendly_msg = "GPU Driver Timeout. Try restarting without keeping 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 %'.") + process_status_q, f"{ERROR_STATUS}Insufficient VRAM. Lower 'Tiles resolution' or 'Input %'.") else: write_process_status( process_status_q, f"{ERROR_STATUS}{error_message}") - # Mantener impresión en consola terminal por si acaso + # Limpiar temporales en caso de error también + for temp_dir in temp_folders_created: + if os_path_exists(temp_dir): + try: + import shutil + shutil.rmtree(temp_dir) + except: + pass + print(f"[ORCHESTRATOR ERROR] {error_message}") # ==== IMAGE PROCESSING SECTION ==== @@ -4954,7 +5163,7 @@ def place_loadFile_section(): # --- 1. Crear el Frame de la Drop Zone (Inicialmente Visible) --- drop_zone_frame = CTkFrame( - master=window, fg_color=background_color, corner_radius=1) + master=window, fg_color=background_color, corner_radius=CORNER_RADIUS) text_drop = (" SUPPORTED FILES \n\n " + "IMAGES • jpg, jpeg, png, bmp, tiff, tif, webp \n " @@ -4970,7 +5179,7 @@ def place_loadFile_section(): height=150, font=bold13, anchor="center", - corner_radius=10 + corner_radius=CORNER_RADIUS ) input_file_button = CTkButton( @@ -4981,7 +5190,7 @@ def place_loadFile_section(): height=30, font=bold12, border_width=1, - corner_radius=1, + corner_radius=CORNER_RADIUS, fg_color=widget_background_color, text_color=text_color, border_color=accent_color, @@ -5012,7 +5221,7 @@ def place_loadFile_section(): def place_app_name(): background = CTkFrame( - master=window, fg_color=background_color, corner_radius=1) + master=window, fg_color=background_color, corner_radius=CORNER_RADIUS) app_name_label = CTkLabel( master=window, text=app_name + " " + version, @@ -5581,25 +5790,74 @@ def place_stop_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) + width=200, # Ajustado a 200 (igual que el nuevo Make Magic) + height=28, # Altura delgada 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") + # Posición: rely=0.77 y relx=0.83 (Misma que el nuevo Make Magic) + stop_button.place(relx=0.83, rely=0.77, anchor="center") + +# En Warlock-Studio.py, busca donde creas los botones principales + + +def open_chain_manager(): + global chain_window + + # Callback para capturar la configuración actual de la GUI + def capture_current_settings(): + # Capturamos las variables globales actuales de la GUI + return { + 'model': selected_AI_model, + 'input_resize': float(selected_input_resize_factor.get()) / 100.0, + 'output_resize': float(selected_output_resize_factor.get()) / 100.0, + 'blending': selected_blending_factor, + 'vram': float(selected_VRAM_limiter.get()), + 'ext_img': selected_image_extension, + 'ext_vid': selected_video_extension, + 'codec': selected_video_codec, + 'frame_gen': selected_frame_generation_option, + 'keep_frames': selected_keep_frames, + 'gpu': selected_gpu + } + + if chain_window is None or not chain_window.winfo_exists(): + chain_window = ChainManager(window, capture_current_settings) + else: + chain_window.lift() + +# --- Añadir el botón en la GUI (ejemplo: al lado de settings) --- +# Puedes añadir esto en la función `place_upscale_button` o crear `place_chain_button` + + +def place_chain_button(): + """ + Coloca el botón Chain a la izquierda del botón Magic, en la misma fila. + """ + btn_chain = create_active_button( + command=open_chain_manager, + text="⛓ Chain", + width=110, # Ancho suficiente para el texto + height=28 # Misma altura que el botón Magic para consistencia + ) + # POSICIÓN: A la izquierda (0.65) + btn_chain.place(relx=0.65, rely=0.77, anchor="center") + btn_chain.lift() def place_upscale_button(): + """ + Coloca el botón Magic desplazado a la derecha para dar espacio al Chain. + """ 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) + width=200, # Reducido ligeramente de 240 a 200 para equilibrar el espacio + height=28 ) - - # Posición: Exactamente la misma que el botón de Stop - upscale_button.place(relx=0.75, rely=0.77, anchor="center") + # POSICIÓN: Desplazado a la derecha (0.83) + # Antes estaba en 0.75 (centro absoluto del panel derecho). + upscale_button.place(relx=0.83, rely=0.77, anchor="center") # ==== MAIN APPLICATION SECTION ==== @@ -5983,6 +6241,6 @@ if __name__ == "__main__": manual_btn.place(relx=0.88, rely=0.05, anchor="center") manual_btn.lift() # ------------------------------- - + place_chain_button() # Iniciar Bucle Principal window.mainloop() diff --git a/Warlock-Studio.spec b/Warlock-Studio.spec index 1bc2e5f..6918af6 100644 --- a/Warlock-Studio.spec +++ b/Warlock-Studio.spec @@ -4,7 +4,7 @@ block_cipher = None a = Analysis( # 1. Aquí agregamos los dos archivos nuevos a la lista de scripts - ['Warlock-Studio.py', 'drag_drop.py', 'console.py', 'warlock_preferences.py', 'file_queue_manager.py', 'splash_screen.py', 'warlock_theme.py'], + ['Warlock-Studio.py', 'drag_drop.py', 'console.py', 'warlock_preferences.py','processing_chain.py', 'file_queue_manager.py', 'splash_screen.py', 'warlock_theme.py'], pathex=[], binaries=[], datas=[ diff --git a/drag_drop.py b/drag_drop.py index be48409..2368dfe 100644 --- a/drag_drop.py +++ b/drag_drop.py @@ -1,37 +1,20 @@ -# drag_drop.py -from customtkinter import CTk -from tkinterdnd2 import DND_ALL, TkinterDnD - -# 1. Clase envoltorio que combina CustomTkinter con TkinterDnD - - -class DnDCTk(CTk, TkinterDnD.DnDWrapper): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.TkdndVersion = TkinterDnD._require(self) - -# 2. Función para registrar widgets y conectar tu lógica - - -def enable_drag_and_drop(window, target_widgets, callback_function): - """ - Activa Drag & Drop en los widgets especificados. - - :param window: La ventana principal (debe ser instancia de DnDCTk) - :param target_widgets: Lista de widgets (botones, labels) donde se pueden soltar archivos. - :param callback_function: La función de tu app principal que recibe la lista de archivos. - """ - - def _internal_drop_event(event): - # TkinterDnD a veces devuelve las rutas con llaves {} si tienen espacios - # window.tk.splitlist se encarga de limpiarlas correctamente - if event.data: - files = window.tk.splitlist(event.data) - # Llamamos a tu función principal pasando la lista limpia - callback_function(files) - - for widget in target_widgets: - # Registramos el widget para aceptar cualquier cosa (archivos) - widget.drop_target_register(DND_ALL) - # Conectamos el evento 'Drop' con nuestra función interna - widget.dnd_bind('<>', _internal_drop_event) +from customtkinter import CTk +from tkinterdnd2 import DND_FILES, TkinterDnD + + +class DnDCTk(CTk, TkinterDnD.DnDWrapper): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + TkinterDnD.DnDWrapper.__init__(self) + self.TkdndVersion = TkinterDnD._require(self) + + +def enable_drag_and_drop(window, target_widgets, callback_function): + def _internal_drop_event(event): + if event.data: + files = window.tk.splitlist(event.data) + callback_function(files) + + for widget in target_widgets: + widget.drop_target_register(DND_FILES) + widget.dnd_bind('<>', _internal_drop_event) diff --git a/processing_chain.py b/processing_chain.py new file mode 100644 index 0000000..d605390 --- /dev/null +++ b/processing_chain.py @@ -0,0 +1,697 @@ +import copy +import json +import logging +import os +import sys +import uuid +import tkinter as tk +from dataclasses import asdict, dataclass, field +from tkinter import filedialog, messagebox +from typing import Any, Callable, Dict, List, Optional, Tuple + +import customtkinter as ctk + +# ----------------------------------------------------------------------------- +# CONFIGURACIÓN Y LOGGING +# ----------------------------------------------------------------------------- +logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level=logging.INFO +) +logger = logging.getLogger("ChainManager") + +# Constantes Globales +GPU_OPTIONS = ["Auto", "GPU 1", "GPU 2", "GPU 3", "GPU 4"] +FRAME_GEN_OPTIONS = ["OFF", "x2", "x4", "x8", + "Slowmotion x2", "Slowmotion x4", "Slowmotion x8"] +# Lista combinada de códecs +VIDEO_CODECS = ["x264", "x265", "h264_nvenc", "hevc_nvenc", + "h264_amf", "hevc_amf", "h264_qsv", "hevc_qsv"] +IMG_EXTENSIONS = [".png", ".jpg", ".bmp", ".tiff", ".webp"] +VID_EXTENSIONS = [".mp4", ".mkv", ".avi", ".mov", ".webm"] + +# Colores del Tema (Coincidentes con Warlock Studio) +COLOR_BG = "#000000" +COLOR_WIDGET = "#1A1A1A" +COLOR_ACCENT = "#FFC107" +COLOR_TEXT = "#F5F5F5" +COLOR_TEXT_SEC = "#9E9E9E" +COLOR_BTN_HOVER = "#C62828" +COLOR_BORDER = "#2D2D2D" +COLOR_SUCCESS = "#00C853" +COLOR_ERROR = "#B71C1C" + +# ----------------------------------------------------------------------------- +# 0. HELPERS +# ----------------------------------------------------------------------------- + +def _resolve_asset_path(filename: str) -> str: + """Busca robustamente el archivo en la carpeta Assets (Soporte PyInstaller).""" + try: + base_path = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) + candidates = [ + os.path.join(base_path, "Assets", filename), + os.path.join(base_path, "..", "Assets", filename), + os.path.join(os.getcwd(), "Assets", filename) + ] + for path in candidates: + if os.path.exists(path): + return path + return "" + except Exception as e: + logger.warning(f"Error resolving asset path: {e}") + return "" + +def _resolve_folder_path(foldername: str) -> str: + try: + base_path = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) + candidates = [ + os.path.join(base_path, foldername), + os.path.join(base_path, "..", foldername), + os.path.join(os.getcwd(), foldername) + ] + for path in candidates: + if os.path.isdir(path): + return path + return "" + except Exception as e: + logger.warning(f"Error resolving folder path: {e}") + return "" + +def list_available_models() -> List[str]: + ai_dir = _resolve_folder_path("AI-onnx") + if not ai_dir: + return [] + candidates = [] + try: + for fname in os.listdir(ai_dir): + if not fname.lower().endswith(".onnx"): + continue + name = fname[:-5] # remove .onnx + # remove common precision suffixes + for suf in ["_fp16", "_fp32", ".fp16", ".fp32"]: + if name.endswith(suf): + name = name[: -len(suf)] + # map GFPGAN variants to base + if name.startswith("GFPGAN"): + name = "GFPGAN" + candidates.append(name) + # Make unique while preserving order + seen = set() + result = [] + for n in candidates: + if n not in seen: + seen.add(n) + result.append(n) + return result + except Exception as e: + logger.warning(f"Error listing models: {e}") + return [] + +# ----------------------------------------------------------------------------- +# 1. DATA MODEL (MODELO DE DATOS) +# ----------------------------------------------------------------------------- + +@dataclass +class ProcessingStep: + """ + Representa un nodo de procesamiento en la cadena. + """ + model_name: str + input_resize: float + output_resize: float + blending: float + vram_limit: float + extension: str + video_codec: Optional[str] = None + frame_gen: str = "OFF" + keep_frames: bool = False + gpu: str = "Auto" + + # Metadatos internos + id: str = field(default_factory=lambda: str(uuid.uuid4())) + enabled: bool = True + expanded: bool = False + + # Versionado para migraciones futuras + version: int = 1 + + @property + def is_video_operation(self) -> bool: + """Determina si el paso es intrínsecamente de video.""" + is_rife = "RIFE" in self.model_name.upper() + has_interpolation = self.frame_gen != "OFF" + is_vid_ext = self.extension.lower() in VID_EXTENSIONS + return is_rife or has_interpolation or is_vid_ext + + @property + def estimated_scale_factor(self) -> float: + """Calcula el factor de escala total del paso (In * AI_Factor * Out).""" + # Estimación básica del modelo + model_factor = 1.0 + name = self.model_name.upper() + if "X2" in name: model_factor = 2.0 + elif "X4" in name: model_factor = 4.0 + elif "X8" in name: model_factor = 8.0 + + return self.input_resize * model_factor * self.output_resize + + def validate(self) -> List[str]: + """Devuelve una lista de advertencias si la configuración es sospechosa.""" + warnings = [] + if self.is_video_operation and self.extension not in VID_EXTENSIONS: + warnings.append(f"Video operation '{self.model_name}' has image extension '{self.extension}'.") + if not self.is_video_operation and self.extension in VID_EXTENSIONS: + # Esto podría ser válido (crear video de imagen), pero es raro como paso intermedio + warnings.append(f"Image operation exporting directly to video container.") + if self.input_resize <= 0 or self.output_resize <= 0: + warnings.append("Resize factors must be > 0.") + return warnings + + def get_summary(self) -> str: + icon = "🎬" if self.is_video_operation else "🖼️" + state = "" if self.enabled else "(BYPASS)" + + details = [] + if self.input_resize != 1.0: details.append(f"In:{int(self.input_resize*100)}%") + + # Detectar escala del modelo + scale_txt = "" + if "X2" in self.model_name.upper(): scale_txt = " (x2)" + elif "X4" in self.model_name.upper(): scale_txt = " (x4)" + + if self.output_resize != 1.0: details.append(f"Out:{int(self.output_resize*100)}%") + if self.frame_gen != "OFF": details.append(f"Gen:{self.frame_gen}") + + detail_str = f"| {', '.join(details)}" if details else "" + return f"{icon} {self.model_name}{scale_txt} {detail_str} {state}" + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + @staticmethod + def from_dict(data: Dict[str, Any]) -> 'ProcessingStep': + known_keys = ProcessingStep.__annotations__.keys() + filtered = {k: v for k, v in data.items() if k in known_keys} + if 'id' not in filtered: filtered['id'] = str(uuid.uuid4()) + return ProcessingStep(**filtered) + +# ----------------------------------------------------------------------------- +# 2. UI COMPONENTS +# ----------------------------------------------------------------------------- + +class ToolTip: + """Tooltip nativo para Tkinter.""" + def __init__(self, widget, text): + self.widget = widget + self.text = text + self.tip_window = None + widget.bind("", self.show_tip) + widget.bind("", self.hide_tip) + + def show_tip(self, event=None): + if self.tip_window or not self.text: return + x, y, _, _ = self.widget.bbox("insert") + x += self.widget.winfo_rootx() + 25 + y += self.widget.winfo_rooty() + 25 + self.tip_window = tw = tk.Toplevel(self.widget) + tw.wm_overrideredirect(True) + tw.wm_geometry(f"+{x}+{y}") + label = tk.Label(tw, text=self.text, justify=tk.LEFT, + background="#333333", foreground="#FFFFFF", relief=tk.SOLID, borderwidth=1, + font=("Arial", "9", "normal")) + label.pack(ipadx=5, ipady=2) + + def hide_tip(self, event=None): + if self.tip_window: + self.tip_window.destroy() + self.tip_window = None + +class ValidatedEntry(ctk.CTkEntry): + def __init__(self, master, is_float=True, min_val=0.0, max_val=9999.0, **kwargs): + super().__init__(master, **kwargs) + self.is_float = is_float + self.min_val = min_val + self.max_val = max_val + vcmd = (self.register(self._validate), '%P') + self.configure(validate="key", validatecommand=vcmd) + self.bind("", self._on_focus_out) + + def _validate(self, new_value): + if new_value == "": return True + try: + val = float(new_value) if self.is_float else int(new_value) + return True + except ValueError: + return False + + def _on_focus_out(self, event): + val = self.get() + if not val: return + try: + num = float(val) + if num < self.min_val: self.delete(0, "end"); self.insert(0, str(self.min_val)) + elif num > self.max_val: self.delete(0, "end"); self.insert(0, str(self.max_val)) + except: pass + +class StepEditorDialog(ctk.CTkToplevel): + """Editor modal avanzado.""" + def __init__(self, parent, step: ProcessingStep, on_save_callback: Callable): + super().__init__(parent) + self.title(f"Edit Step") + self.geometry("450x650") + self.resizable(False, False) + self.configure(fg_color=COLOR_BG) + + icon_path = _resolve_asset_path("logo.ico") + if icon_path: self.after(200, lambda: self.iconbitmap(icon_path)) + + self.step = step + self.on_save = on_save_callback + self.widgets = {} + + self.transient(parent) + self.grab_set() + + self.main_frame = ctk.CTkScrollableFrame(self, fg_color=COLOR_WIDGET, label_text="Step Configuration") + self.main_frame.pack(fill="both", expand=True, padx=10, pady=10) + + self._build_ui() + self._populate_values() + self._build_buttons() + + def _build_ui(self): + row = 0 + # Model & Hardware + self._header("AI Model & Hardware", row); row += 1 + + self._label("Model Name:", row) + try: + model_values = list_available_models() + except Exception: + model_values = [] + if not model_values: + model_values = ["RealESR_Gx4", "RealESR_Animex4", "BSRGANx4", "BSRGANx2", "RealESRGANx4", "RealESRNetx4", "IRCNN_Mx1", "IRCNN_Lx1", "GFPGAN", "RIFE", "RIFE_Lite"] + self.widgets['model_name'] = ctk.CTkComboBox(self.main_frame, values=model_values, width=220) + self.widgets['model_name'].grid(row=row, column=1, sticky="e", padx=5, pady=5) + row += 1 + + self._label("Compute Device:", row) + self.widgets['gpu'] = ctk.CTkComboBox(self.main_frame, values=GPU_OPTIONS, width=220) + self.widgets['gpu'].grid(row=row, column=1, sticky="e", padx=5, pady=5) + row += 1 + + # Scaling + self._header("Resolution & Scaling", row); row += 1 + + self._label("Input Resize (0.1 - 1.0):", row) + self.widgets['input_resize'] = ValidatedEntry(self.main_frame, min_val=0.1, max_val=1.0, width=220) + self.widgets['input_resize'].grid(row=row, column=1, sticky="e", padx=5, pady=5) + row += 1 + + self._label("Output Resize (0.1 - 8.0):", row) + self.widgets['output_resize'] = ValidatedEntry(self.main_frame, min_val=0.1, max_val=8.0, width=220) + self.widgets['output_resize'].grid(row=row, column=1, sticky="e", padx=5, pady=5) + row += 1 + + # Advanced + self._header("Advanced Processing", row); row += 1 + + self._label("Blending (0.0 - 1.0):", row) + self.widgets['blending'] = ValidatedEntry(self.main_frame, min_val=0.0, max_val=1.0, width=220) + self.widgets['blending'].grid(row=row, column=1, sticky="e", padx=5, pady=5) + row += 1 + + self._label("VRAM Limit (GB):", row) + self.widgets['vram_limit'] = ValidatedEntry(self.main_frame, min_val=0.0, max_val=24.0, width=220) + self.widgets['vram_limit'].grid(row=row, column=1, sticky="e", padx=5, pady=5) + row += 1 + + # Output + self._header("Output Format", row); row += 1 + + self._label("Extension:", row) + exts = list(set(IMG_EXTENSIONS + VID_EXTENSIONS)); exts.sort() + self.widgets['extension'] = ctk.CTkComboBox(self.main_frame, values=exts, width=220) + self.widgets['extension'].grid(row=row, column=1, sticky="e", padx=5, pady=5) + row += 1 + + self._label("Video Codec:", row) + self.widgets['video_codec'] = ctk.CTkComboBox(self.main_frame, values=[""] + VIDEO_CODECS, width=220) + self.widgets['video_codec'].grid(row=row, column=1, sticky="e", padx=5, pady=5) + row += 1 + + self._label("Frame Generation:", row) + self.widgets['frame_gen'] = ctk.CTkComboBox(self.main_frame, values=FRAME_GEN_OPTIONS, width=220) + self.widgets['frame_gen'].grid(row=row, column=1, sticky="e", padx=5, pady=5) + row += 1 + + self.widgets['keep_frames'] = ctk.CTkCheckBox(self.main_frame, text="Keep Intermediate Frames", text_color=COLOR_TEXT) + self.widgets['keep_frames'].grid(row=row, column=0, columnspan=2, pady=15) + + def _header(self, text, r): + lbl = ctk.CTkLabel(self.main_frame, text=text, font=("Roboto", 13, "bold"), text_color=COLOR_ACCENT, anchor="w") + lbl.grid(row=r, column=0, columnspan=2, sticky="ew", pady=(15, 5), padx=5) + # Separator + sep = ctk.CTkFrame(self.main_frame, height=2, fg_color=COLOR_BORDER) + sep.grid(row=r, column=0, columnspan=2, sticky="ews", pady=(0, 0)) + + def _label(self, text, r): + ctk.CTkLabel(self.main_frame, text=text, text_color=COLOR_TEXT_SEC, anchor="w").grid(row=r, column=0, sticky="w", padx=10) + + def _populate_values(self): + s = self.step + # Para ComboBox, usar set() + try: + self.widgets['model_name'].set(s.model_name) + except Exception: + # Fallback: si no está en lista, agregarlo dinámicamente + current_vals = list(self.widgets['model_name'].cget("values")) + if s.model_name and s.model_name not in current_vals: + current_vals = [s.model_name] + current_vals + self.widgets['model_name'].configure(values=current_vals) + self.widgets['model_name'].set(s.model_name) + self.widgets['gpu'].set(s.gpu) + self.widgets['input_resize'].insert(0, str(s.input_resize)) + self.widgets['output_resize'].insert(0, str(s.output_resize)) + self.widgets['blending'].insert(0, str(s.blending)) + self.widgets['vram_limit'].insert(0, str(s.vram_limit)) + + if s.extension not in self.widgets['extension'].cget("values"): + self.widgets['extension'].set(s.extension) + else: + self.widgets['extension'].set(s.extension) + + self.widgets['video_codec'].set(s.video_codec or "") + self.widgets['frame_gen'].set(s.frame_gen) + if s.keep_frames: self.widgets['keep_frames'].select() + + def _build_buttons(self): + btn_frame = ctk.CTkFrame(self, fg_color="transparent") + btn_frame.pack(fill="x", padx=20, pady=20) + + ctk.CTkButton(btn_frame, text="Cancel", fg_color="#424242", hover_color="#616161", + command=self.destroy).pack(side="left", expand=True, padx=5) + ctk.CTkButton(btn_frame, text="Save Changes", fg_color=COLOR_SUCCESS, hover_color="#00E676", text_color="white", + command=self.save).pack(side="right", expand=True, padx=5) + + def save(self): + try: + self.step.model_name = self.widgets['model_name'].get() + self.step.gpu = self.widgets['gpu'].get() + self.step.input_resize = float(self.widgets['input_resize'].get()) + self.step.output_resize = float(self.widgets['output_resize'].get()) + self.step.blending = float(self.widgets['blending'].get()) + self.step.vram_limit = float(self.widgets['vram_limit'].get()) + self.step.extension = self.widgets['extension'].get() + + codec = self.widgets['video_codec'].get() + self.step.video_codec = codec if codec.strip() else None + self.step.frame_gen = self.widgets['frame_gen'].get() + self.step.keep_frames = bool(self.widgets['keep_frames'].get()) + + self.on_save(self.step) + self.destroy() + except ValueError as e: + messagebox.showerror("Validation Error", f"Check numeric fields: {e}") + +class ModernStepCard(ctk.CTkFrame): + """Tarjeta visual que representa un paso.""" + def __init__(self, master, step: ProcessingStep, index: int, total_steps: int, callbacks: Dict): + + color_bg = COLOR_WIDGET if step.enabled else "#111111" + color_border = COLOR_BORDER if step.enabled else "#222222" + + super().__init__(master, corner_radius=8, border_width=1, fg_color=color_bg, border_color=color_border) + + self.step = step + self.callbacks = callbacks + self.index = index + + self.grid_columnconfigure(1, weight=1) + + # --- HEADER --- + header = ctk.CTkFrame(self, fg_color="transparent") + header.pack(fill="x", padx=8, pady=8) + + # Índice y Icono + icon = "🎬" if step.is_video_operation else "🖼️" + ctk.CTkLabel(header, text=f"{index+1}", font=("Arial", 16, "bold"), text_color=COLOR_ACCENT, width=25).pack(side="left") + ctk.CTkLabel(header, text=icon, font=("Arial", 16)).pack(side="left", padx=(0, 5)) + + # Título + title_txt = step.model_name if step.expanded else step.get_summary() + title_col = COLOR_TEXT if step.enabled else "gray" + self.lbl_title = ctk.CTkLabel(header, text=title_txt, font=("Roboto", 13, "bold"), text_color=title_col, anchor="w") + self.lbl_title.pack(side="left", fill="x", expand=True, padx=5) + + # Validación Warning + warnings = step.validate() + if warnings and step.enabled: + warn_lbl = ctk.CTkLabel(header, text="⚠️", text_color=COLOR_ACCENT) + warn_lbl.pack(side="right", padx=5) + ToolTip(warn_lbl, "\n".join(warnings)) + + # Switch y Expand + self.switch = ctk.CTkSwitch(header, text="", width=35, height=20, command=self._on_toggle, + onvalue=True, offvalue=False, progress_color=COLOR_SUCCESS) + if step.enabled: self.switch.select() + else: self.switch.deselect() + self.switch.pack(side="right", padx=5) + + btn_exp = ctk.CTkButton(header, text="▼" if not step.expanded else "▲", width=25, height=25, + fg_color="transparent", text_color="gray", hover_color="#333333", + command=self._toggle_expand) + btn_exp.pack(side="right") + + # --- BODY EXPANDIDO --- + if step.expanded and step.enabled: + body = ctk.CTkFrame(self, fg_color="transparent") + body.pack(fill="x", padx=10, pady=(0, 10)) + + # Info Grid + self._info_row(body, 0, "Input Scale:", f"{int(step.input_resize*100)}%", "Output Scale:", f"{int(step.output_resize*100)}%") + self._info_row(body, 1, "VRAM Limit:", f"{step.vram_limit} GB", "Blending:", str(step.blending)) + self._info_row(body, 2, "GPU:", step.gpu, "Extension:", step.extension) + if step.frame_gen != "OFF": + self._info_row(body, 3, "Frame Gen:", step.frame_gen, "Codec:", step.video_codec or "Auto") + + # Action Buttons + actions = ctk.CTkFrame(body, fg_color="transparent", height=30) + actions.grid(row=99, column=0, columnspan=4, sticky="ew", pady=(15, 0)) + + # Move + if index > 0: + self._btn(actions, "⬆", lambda: callbacks['move'](step.id, -1), "left") + if index < total_steps - 1: + self._btn(actions, "⬇", lambda: callbacks['move'](step.id, 1), "left") + + # CRUD + self._btn(actions, "🗑 Delete", lambda: callbacks['delete'](step.id), "right", COLOR_ERROR, "#D32F2F") + self._btn(actions, "⧉ Clone", lambda: callbacks['clone'](step.id), "right", "#5E35B1", "#7E57C2") + self._btn(actions, "✎ Edit", lambda: callbacks['edit'](step.id), "right", "#1976D2", "#42A5F5") + + def _info_row(self, master, r, t1, v1, t2, v2): + self._cell(master, r, 0, t1, v1) + self._cell(master, r, 1, t2, v2) + + def _cell(self, master, r, c, title, value): + f = ctk.CTkFrame(master, fg_color="transparent") + f.grid(row=r, column=c, sticky="w", padx=5, pady=2) + ctk.CTkLabel(f, text=title, font=("Arial", 11), text_color=COLOR_TEXT_SEC).pack(side="left") + ctk.CTkLabel(f, text=str(value), font=("Arial", 11, "bold"), text_color=COLOR_TEXT).pack(side="left", padx=5) + + def _btn(self, master, txt, cmd, side, col="#424242", hov="#616161"): + ctk.CTkButton(master, text=txt, width=50, height=24, fg_color=col, hover_color=hov, + font=("Arial", 11), command=cmd).pack(side=side, padx=2) + + def _on_toggle(self): + self.step.enabled = bool(self.switch.get()) + self.callbacks['refresh']() + + def _toggle_expand(self): + self.step.expanded = not self.step.expanded + self.callbacks['refresh']() + +# ----------------------------------------------------------------------------- +# 3. MANAGER CONTROLLER +# ----------------------------------------------------------------------------- + +class ChainManager(ctk.CTkToplevel): + def __init__(self, parent, get_current_settings_callback: Callable): + super().__init__(parent) + self.title("Workflow Chain Manager") + self.geometry("500x750") + self.minsize(450, 500) + self.configure(fg_color=COLOR_BG) + self.transient(parent) + + icon_path = _resolve_asset_path("logo.ico") + if icon_path: self.after(200, lambda: self.iconbitmap(icon_path)) + + self.get_current_settings = get_current_settings_callback + self.steps: List[ProcessingStep] = [] + + # Layout + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=1) + + self._build_toolbar() + + # Scroll Area + self.scroll_frame = ctk.CTkScrollableFrame(self, fg_color="#121212", label_text="Processing Pipeline") + self.scroll_frame.grid(row=1, column=0, sticky="nsew", padx=10, pady=5) + + self._build_bottom_panel() + self.refresh_ui() + + def _build_toolbar(self): + tb = ctk.CTkFrame(self, height=50, fg_color="transparent") + tb.grid(row=0, column=0, sticky="ew", padx=10, pady=10) + + ctk.CTkLabel(tb, text="Active Chain", font=("Roboto", 18, "bold"), text_color=COLOR_ACCENT).pack(side="left", padx=5) + + self.lbl_stats = ctk.CTkLabel(tb, text="", font=("Arial", 11), text_color="gray") + self.lbl_stats.pack(side="left", padx=15) + + self._tb_btn(tb, "🗑 Clear", self.clear_chain, COLOR_ERROR, "#D32F2F") + self._tb_btn(tb, "💾 Save", self.save_preset, "#455A64", "#607D8B") + self._tb_btn(tb, "📂 Load", self.load_preset, "#455A64", "#607D8B") + + def _tb_btn(self, master, txt, cmd, col, hov): + ctk.CTkButton(master, text=txt, width=60, height=28, fg_color=col, hover_color=hov, + font=("Arial", 11, "bold"), command=cmd).pack(side="right", padx=2) + + def _build_bottom_panel(self): + panel = ctk.CTkFrame(self, height=80, fg_color=COLOR_WIDGET) + panel.grid(row=2, column=0, sticky="ew") + + self.btn_add = ctk.CTkButton(panel, text="➕ APPEND CURRENT SETTINGS AS STEP", + command=self.add_step_from_gui, + fg_color="#00695C", hover_color="#00897B", + height=45, font=("Roboto", 13, "bold")) + self.btn_add.pack(padx=20, pady=15, fill="x") + + def add_step_from_gui(self): + try: + s = self.get_current_settings() + if not s['model'] or "•••" in s['model']: + messagebox.showerror("Error", "Please select a valid AI Model in the main window first.") + return + + # Inferencia automática de tipo de extensión + is_rife = "RIFE" in s['model'].upper() + has_gen = s.get('frame_gen', "OFF") != "OFF" + + # Si es RIFE o tiene FrameGen, forzamos modo Video si la extensión no es explícita + # Prioridad: Extensión de video si está seleccionada, sino .mp4 + if is_rife or has_gen: + ext = s.get('ext_vid') if s.get('ext_vid') in VID_EXTENSIONS else ".mp4" + else: + # Upscalers normales usan extensión de imagen a menos que el usuario haya seleccionado explicitamente video en main + # Pero en la cadena, paso intermedio suele ser imagen a menos que sea el final. + # Por defecto tomamos la configuración visual actual. + ext = s.get('ext_img') if s.get('ext_img') in IMG_EXTENSIONS else ".png" + + new_step = ProcessingStep( + model_name=s['model'], + input_resize=s['input_resize'], + output_resize=s['output_resize'], + blending=s['blending'], + vram_limit=s['vram'], + extension=ext, + video_codec=s.get('codec'), + frame_gen=s.get('frame_gen', "OFF"), + keep_frames=s.get('keep_frames', False), + gpu=s.get('gpu', "Auto") + ) + + self.steps.append(new_step) + self.refresh_ui() + self.after(100, lambda: self.scroll_frame._parent_canvas.yview_moveto(1.0)) + + except Exception as e: + messagebox.showerror("Error", f"Could not capture settings: {e}") + + # --- Lógica CRUD --- + + def _move(self, uid, direction): + idx = next((i for i, s in enumerate(self.steps) if s.id == uid), -1) + if idx == -1: return + n_idx = idx + direction + if 0 <= n_idx < len(self.steps): + self.steps[idx], self.steps[n_idx] = self.steps[n_idx], self.steps[idx] + self.refresh_ui() + + def _delete(self, uid): + self.steps = [s for s in self.steps if s.id != uid] + self.refresh_ui() + + def _clone(self, uid): + idx = next((i for i, s in enumerate(self.steps) if s.id == uid), -1) + if idx != -1: + cloned = copy.deepcopy(self.steps[idx]) + cloned.id = str(uuid.uuid4()) + self.steps.insert(idx + 1, cloned) + self.refresh_ui() + + def _edit(self, uid): + step = next((s for s in self.steps if s.id == uid), None) + if step: + StepEditorDialog(self, step, lambda x: self.refresh_ui()) + + def clear_chain(self): + if self.steps and messagebox.askyesno("Confirm", "Clear entire chain?"): + self.steps = [] + self.refresh_ui() + + def get_chain(self) -> List[ProcessingStep]: + return [s for s in self.steps if s.enabled] + + # --- Rendering --- + + def refresh_ui(self): + for w in self.scroll_frame.winfo_children(): w.destroy() + + if not self.steps: + f = ctk.CTkFrame(self.scroll_frame, fg_color="transparent") + f.pack(pady=60) + ctk.CTkLabel(f, text="Workflow is Empty", font=("Arial", 16, "bold"), text_color="gray").pack() + ctk.CTkLabel(f, text="Configure main window settings\nand click 'Append' below.", text_color="#555").pack(pady=5) + self.lbl_stats.configure(text="") + else: + cbs = {'move': self._move, 'delete': self._delete, 'clone': self._clone, 'edit': self._edit, 'refresh': self.refresh_ui} + + total_scale = 1.0 + for i, step in enumerate(self.steps): + if step.enabled: total_scale *= step.estimated_scale_factor + ModernStepCard(self.scroll_frame, step, i, len(self.steps), cbs).pack(fill="x", pady=6, padx=5) + + self.lbl_stats.configure(text=f"Est. Scale: x{total_scale:.2f}") + + # --- JSON Persistence --- + + def save_preset(self): + if not self.steps: return + path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("Warlock Preset", "*.json")]) + if path: + try: + data = {"version": 1, "steps": [s.to_dict() for s in self.steps]} + with open(path, 'w', encoding='utf-8') as f: json.dump(data, f, indent=4) + messagebox.showinfo("Saved", "Workflow saved.") + except Exception as e: + messagebox.showerror("Error", f"Save failed: {e}") + + def load_preset(self): + path = filedialog.askopenfilename(filetypes=[("Warlock Preset", "*.json")]) + if path: + try: + with open(path, 'r', encoding='utf-8') as f: data = json.load(f) + + # Soporte legacy (si el json es una lista directa) + items = data if isinstance(data, list) else data.get("steps", []) + + self.steps = [ProcessingStep.from_dict(i) for i in items] + self.refresh_ui() + except Exception as e: + messagebox.showerror("Error", f"Load failed: {e}") diff --git a/rsc/Capture2.png b/rsc/Capture2.png index c88a635..afca116 100644 Binary files a/rsc/Capture2.png and b/rsc/Capture2.png differ diff --git a/rsc/Capture3.png b/rsc/Capture3.png index 2d43054..43ef7a5 100644 Binary files a/rsc/Capture3.png and b/rsc/Capture3.png differ diff --git a/rsc/Capture4.png b/rsc/Capture4.png deleted file mode 100644 index 6ffbf03..0000000 Binary files a/rsc/Capture4.png and /dev/null differ diff --git a/rsc/Capture5.png b/rsc/Capture5.png deleted file mode 100644 index 8d55d77..0000000 Binary files a/rsc/Capture5.png and /dev/null differ diff --git a/splash_screen.py b/splash_screen.py index 0a5350d..964a97f 100644 --- a/splash_screen.py +++ b/splash_screen.py @@ -122,7 +122,7 @@ class SplashScreen(ctk.CTkToplevel): self, width=300, height=8, - corner_radius=4, + corner_radius=8, progress_color=self.theme['accent'], fg_color=self.theme['widget_bg'], border_width=0 diff --git a/warlock_theme.py b/warlock_theme.py index 81879ff..29f48b6 100644 --- a/warlock_theme.py +++ b/warlock_theme.py @@ -27,7 +27,7 @@ class WarlockColors: # --- IDENTIDAD & ACENTOS --- # "Oro Metálico". Elegante, legible y define la marca Warlock. - APP_TITLE = "#FBC02D" + APP_TITLE = "#FFD700" # "Ámbar Intenso". Color principal de interacción (Checkboxes, Switches, Sliders). ACCENT = "#FFC107" @@ -50,7 +50,7 @@ class WarlockColors: # --- INTERACCIÓN (BOTONES) --- # "Rojo Sangre/Rubí". Para Hover en botones principales o acciones destructivas. - BUTTON_HOVER = "#C62828" + BUTTON_HOVER = "#D32F2F" # "Vino Tinto". Para botones secundarios, info o estados inactivos pero visibles. BUTTON_SECONDARY = "#7F1500"