698 lines
30 KiB
Python
698 lines
30 KiB
Python
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("<Enter>", self.show_tip)
|
||
widget.bind("<Leave>", 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("<FocusOut>", 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}")
|