import os import ctypes import subprocess import threading import shutil import re import json import uuid import urllib.request import urllib.parse import tkinter as tk from tkinter import ttk, filedialog, messagebox from pathlib import Path # ─── IMPORTS DEFERIDOS ────────────────────────────────────── def get_sys(): import sys return sys # ─── AUTO-ELEVACIÓN ADMINISTRADOR ─────────────────────────── def is_admin(): try: return ctypes.windll.shell32.IsUserAnAdmin() except: return False def elevate(): if not is_admin(): sys = get_sys() ctypes.windll.shell32.ShellExecuteW( None, "runas", sys.executable, " ".join(sys.argv), None, 1 ) sys.exit() # ─── ESTILOS Y COLORES ────────────────────────────────────── BG = "#0f172a" CARD_BG = "#1e293b" ACCENT = "#3b82f6" ACCENT_H = "#2563eb" GREEN = "#22c55e" RED = "#ef4444" YELLOW = "#f59e0b" ORANGE = "#f97316" FG = "#f1f5f9" FG2 = "#94a3b8" BORDER = "#334155" FONT_MAIN = ("Segoe UI", 10) FONT_BOLD = ("Segoe UI", 10, "bold") FONT_TITLE= ("Segoe UI", 16, "bold") FONT_SUB = ("Segoe UI", 9) # ─── FUNCIONES UTILITARIAS ────────────────────────────────── def safe_name(s, maxlen=120): if not s: return "video" return re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', s).strip()[:maxlen] def to_leet(text): if not text: return "video" rep = { 'a': '4', 'A': '4', 'i': '1', 'I': '1', 't': '7', 'T': '7', 'o': '0', 'O': '0', 'e': '3', 'E': '3', 's': '5', 'S': '5', 'g': '9', 'G': '9', } res = "".join(rep.get(c, c) for c in text) return safe_name(res) def fmt_size(bytes_val): for unit in ['B', 'KB', 'MB', 'GB']: if bytes_val < 1024.0: return f"{bytes_val:.1f} {unit}" bytes_val /= 1024.0 return f"{bytes_val:.1f} TB" def fmt_dur(secs): s = int(secs) return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}" def fetch_tmdb(url): req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) with urllib.request.urlopen(req) as resp: return json.loads(resp.read().decode()) def probe(source): info = {"audio": [], "subs": [], "video": {}, "duration": 0.0, "title": "", "size": 0, "bitrate": 0} try: r = subprocess.run( ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", source], capture_output=True, text=True, timeout=90 ) if r.returncode != 0: return info data = json.loads(r.stdout) fmt = data.get("format", {}) tags = fmt.get("tags", {}) info["title"] = (tags.get("title") or tags.get("TITLE") or "").strip() info["duration"] = float(fmt.get("duration", 0) or 0) info["size"] = int(fmt.get("size", 0) or 0) info["bitrate"] = int(fmt.get("bit_rate", 0) or 0) ai = si = 0 for s in data.get("streams", []): t = s.get("tags", {}) ct = s.get("codec_type", "") if ct == "video" and not info["video"]: info["video"] = { "codec": s.get("codec_name", "?"), "width": s.get("width", 0), "height": s.get("height", 0), "fps": s.get("r_frame_rate", "?"), "pix_fmt": s.get("pix_fmt", "?"), } elif ct == "audio": info["audio"].append({ "idx": ai, "codec": s.get("codec_name", "?"), "lang": t.get("language", "und"), "ch": s.get("channels", 2), "title": t.get("title", ""), "sample_rate": s.get("sample_rate", "?"), "bit_depth": s.get("bits_per_sample", 0), }) ai += 1 elif ct == "subtitle": info["subs"].append({ "idx": si, "codec": s.get("codec_name", "?"), "lang": t.get("language", "und"), "title": t.get("title", ""), "forced": s.get("disposition", {}).get("forced", 0) == 1, }) si += 1 except Exception: pass return info NET_ARGS = [ "-user_agent", "Mozilla/5.0", "-headers", "Referer: https://google.com\r\n", "-timeout", "180000000", "-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_at_eof", "1", "-reconnect_delay_max", "30", "-rw_timeout", "180000000", "-multiple_requests", "1", ] def run_ffmpeg(cmd, total, log_cb, progress_cb, label): try: proc = subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, universal_newlines=True, bufsize=1 ) pat = re.compile(r"time=(\d+):(\d+):(\d+)\.(\d+)") for line in proc.stderr: m = pat.search(line) if m: h, mi, s, cs = map(int, m.groups()) cur = h*3600 + mi*60 + s + cs/100 pct = min(99, int(cur / max(total, 1) * 100)) progress_cb(pct, f"{label}: {pct}% [{fmt_dur(cur)} / {fmt_dur(total)}]") proc.wait() return proc.returncode == 0 except Exception as e: log_cb(f" ✗ ffmpeg: {e}") return False def check_ffmpeg(): try: r = subprocess.run(["ffmpeg", "-version"], capture_output=True, timeout=10) return r.returncode == 0 except: return False def check_ffprobe(): try: r = subprocess.run(["ffprobe", "-version"], capture_output=True, timeout=10) return r.returncode == 0 except: return False # ─── RENDERIZADOR PRINCIPAL ──────────────────────────────── def render_video(source, is_url, mode, audio_idx, gen_single, extract_sub, sub_idx, folder_name, file_name, output_dir, log_cb, progress_cb, done_cb): try: extra = NET_ARGS if is_url else [] log_cb(f"⟳ Analizando: {source[:80]}{'...' if len(source)>80 else ''}") info = probe(source) dur = info["duration"] if info["duration"] > 0 else 1 uid = str(uuid.uuid4())[:8] tmp = Path(output_dir) / f"temp_{uid}" tmp.mkdir(exist_ok=True, parents=True) out_mp4 = tmp / f"{file_name}.mp4" # ─── CONSTRUIR COMANDO FFMPEG ─── cmd = ["ffmpeg", "-y"] + extra + ["-i", source, "-map", "0:v:0"] if info["audio"]: for i in range(len(info["audio"])): cmd.extend(["-map", f"0:a:{i}"]) else: cmd.extend(["-an"]) # Modo de conversión if mode == "Copy + MP3": cmd += ["-c:v", "copy"] for i in range(len(info["audio"])): cmd += [f"-c:a:{i}", "libmp3lame", f"-b:a:{i}", "320k"] elif mode == "Copy + FLAC": cmd += ["-c:v", "copy"] for i in range(len(info["audio"])): cmd += [f"-c:a:{i}", "flac"] elif mode == "Copy + AAC 256k": cmd += ["-c:v", "copy"] for i in range(len(info["audio"])): cmd += [f"-c:a:{i}", "aac", f"-b:a:{i}", "256k"] elif mode == "H264 1080p + MP3": cmd += ["-c:v", "libx264", "-vf", "scale=-2:1080", "-preset", "fast", "-crf", "18"] for i in range(len(info["audio"])): cmd += [f"-c:a:{i}", "libmp3lame", f"-b:a:{i}", "320k"] elif mode == "H264 720p + MP3": cmd += ["-c:v", "libx264", "-vf", "scale=-2:720", "-preset", "fast", "-crf", "20"] for i in range(len(info["audio"])): cmd += [f"-c:a:{i}", "libmp3lame", f"-b:a:{i}", "320k"] elif mode == "H264 480p + MP3": cmd += ["-c:v", "libx264", "-vf", "scale=-2:480", "-preset", "fast", "-crf", "23"] for i in range(len(info["audio"])): cmd += [f"-c:a:{i}", "libmp3lame", f"-b:a:{i}", "192k"] elif mode == "H265 1080p + MP3": cmd += ["-c:v", "libx265", "-vf", "scale=-2:1080", "-preset", "fast", "-crf", "22"] for i in range(len(info["audio"])): cmd += [f"-c:a:{i}", "libmp3lame", f"-b:a:{i}", "320k"] elif mode == "Solo Video (sin audio)": cmd += ["-c:v", "copy", "-an"] else: cmd += ["-c:v", "copy"] for i in range(len(info["audio"])): cmd += [f"-c:a:{i}", "copy"] cmd += ["-map_metadata", "0", "-movflags", "+faststart", str(out_mp4)] log_cb(f"⚙ Modo: {mode}") log_cb(f"⚙ Convirtiendo…") ok = run_ffmpeg(cmd, dur, log_cb, progress_cb, "Convirtiendo") if not ok: log_cb("✗ Conversión fallida") shutil.rmtree(tmp, ignore_errors=True) done_cb(False, None) return progress_cb(100, "Conversión OK ✓") # ─── EXTRAER AUDIO INDIVIDUAL ─── if gen_single and info["audio"] and audio_idx < len(info["audio"]): ext = "mp3" if "mp3" in mode.lower() else "flac" if "flac" in mode.lower() else "aac" if "aac" in mode.lower() else "mp3" sp = tmp / f"{file_name}_aud{audio_idx}.{ext}" sc = ["ffmpeg", "-y", "-i", str(out_mp4), "-map", f"0:a:{audio_idx}", "-vn", "-c:a", "libmp3lame" if ext == "mp3" else "flac" if ext == "flac" else "aac", "-b:a" if ext != "flac" else "-compression_level", "320k" if ext != "flac" else "5", str(sp)] run_ffmpeg(sc, dur, log_cb, progress_cb, f"Audio → {ext}") # ─── EXTRAER SUBTÍTULO ─── if extract_sub and info["subs"] and sub_idx < len(info["subs"]): vtt = tmp / f"{file_name}_sub{sub_idx}.vtt" sc = ["ffmpeg", "-y"] + extra + [ "-i", source, "-map", f"0:s:{sub_idx}", "-c:s", "webvtt", str(vtt)] r = subprocess.run(sc, capture_output=True, text=True, check=False) if r.returncode == 0: log_cb(f" ✓ Subtítulo extraído") else: log_cb(f" ✗ Error extrayendo subtítulo") # ─── MOVER A CARPETA FINAL ─── final_dir = Path(output_dir) / folder_name final_dir.mkdir(exist_ok=True, parents=True) result_files = [] for f in tmp.iterdir(): if f.is_file(): dest = final_dir / f.name # Evitar sobreescribir si ya existe if dest.exists(): base = dest.stem ext = dest.suffix c = 1 while dest.exists(): dest = final_dir / f"{base}_{c}{ext}" c += 1 shutil.move(str(f), str(dest)) result_files.append(str(dest)) size = dest.stat().st_size log_cb(f" ✓ {dest.name} ({fmt_size(size)})") shutil.rmtree(tmp, ignore_errors=True) log_cb(f"📂 Carpeta: {final_dir}") done_cb(True, result_files) except Exception as e: import traceback log_cb(f"✗ Error: {e}\n{traceback.format_exc()}") done_cb(False, None) # ─── APLICACIÓN TKINTER ──────────────────────────────────── class App(tk.Tk): def __init__(self): super().__init__() self.title("Video Renderer Pro") self.geometry("1200x880") self.configure(bg=BG) self.resizable(True, True) self.minsize(900, 650) self._queue = [] self._total_queue = 0 self._current_idx = 0 self._processed_count = 0 self._failed_count = 0 self._tmdb_id_map = {} self._tmdb_episodes = [] self._build() def _build(self): style = ttk.Style(self) style.theme_use("clam") style.configure(".", background=BG, foreground=FG, font=FONT_MAIN, borderwidth=0) style.configure("TFrame", background=BG) style.configure("TLabel", background=BG, foreground=FG, font=FONT_MAIN) style.configure("Card.TFrame", background=CARD_BG) style.configure("Horizontal.TProgressbar", troughcolor=BG, background=ACCENT, thickness=10) style.configure("TCheckbutton", background=CARD_BG, foreground=FG, font=FONT_MAIN) style.configure("TRadiobutton", background=CARD_BG, foreground=FG, font=FONT_MAIN) style.map("TCheckbutton", background=[("active", CARD_BG)]) style.map("TRadiobutton", background=[("active", CARD_BG)]) main = tk.Frame(self, bg=BG) main.pack(fill="both", expand=True, padx=16, pady=16) # ─── HEADER ─── header = tk.Frame(main, bg=BG) header.pack(fill="x", pady=(0, 16)) tk.Label(header, text="🎬 VIDEO RENDERER PRO", bg=BG, fg=ACCENT, font=FONT_TITLE).pack(side="left") self._lbl_ffmpeg = tk.Label(header, text="", bg=BG, fg=FG2, font=FONT_SUB) self._lbl_ffmpeg.pack(side="right", padx=(10, 0)) self._lbl_status = tk.Label(header, text="● Listo", bg=BG, fg=GREEN, font=FONT_MAIN) self._lbl_status.pack(side="right") self._check_tools() # ─── BODY ─── body = tk.Frame(main, bg=BG) body.pack(fill="both", expand=True) body.columnconfigure(0, weight=1, uniform="col") body.columnconfigure(1, weight=1, uniform="col") body.rowconfigure(0, weight=1) # Panel izquierdo left = tk.Frame(body, bg=CARD_BG, padx=14, pady=14) left.grid(row=0, column=0, sticky="nsew", padx=(0, 8)) self._build_tmdb_section(left) self._build_options_section(left) self._build_output_section(left) # Panel derecho right = tk.Frame(body, bg=CARD_BG, padx=14, pady=14) right.grid(row=0, column=1, sticky="nsew", padx=(8, 0)) self._build_source_section(right) self._build_tracks_section(right) self._build_log_section(right) def _check_tools(self): ff = check_ffmpeg() fp = check_ffprobe() if ff and fp: self._lbl_ffmpeg.configure(text="✓ ffmpeg + ffprobe", fg=GREEN) elif ff: self._lbl_ffmpeg.configure(text="⚠ ffprobe no encontrado", fg=YELLOW) else: self._lbl_ffmpeg.configure(text="✗ ffmpeg no encontrado", fg=RED) def _section(self, parent, title): f = tk.Frame(parent, bg=CARD_BG, pady=2) f.pack(fill="x", pady=(0, 8)) tk.Label(f, text=title.upper(), bg=CARD_BG, fg=FG2, font=("Segoe UI", 8, "bold")).pack(anchor="w") tk.Frame(f, bg=BORDER, height=1).pack(fill="x", pady=(4, 8)) return f # ─── TMDb ─── def _build_tmdb_section(self, parent): s = self._section(parent, "Metadatos TMDb (Opcional)") self._use_tmdb = tk.BooleanVar(value=False) ttk.Checkbutton(s, text="Generar nombres usando TMDb", variable=self._use_tmdb).pack(anchor="w", pady=(0, 6)) tk.Label(s, text="API Key TMDb:", bg=CARD_BG, fg=FG2, font=FONT_SUB).pack(anchor="w") self._tmdb_key = tk.StringVar() tk.Entry(s, textvariable=self._tmdb_key, bg=BG, fg=FG, insertbackground=FG, relief="flat", show="*", font=FONT_MAIN).pack(fill="x", ipady=3, pady=(0, 6)) f_type = tk.Frame(s, bg=CARD_BG) f_type.pack(fill="x", pady=(0, 6)) self._tmdb_type = tk.StringVar(value="serie") ttk.Radiobutton(f_type, text="Serie", variable=self._tmdb_type, value="serie", command=self._tmdb_clear).pack(side="left") ttk.Radiobutton(f_type, text="Película", variable=self._tmdb_type, value="pelicula", command=self._tmdb_clear).pack(side="left", padx=(8, 8)) f_search = tk.Frame(s, bg=CARD_BG) f_search.pack(fill="x", pady=(0, 6)) self._tmdb_query = tk.StringVar() tk.Entry(f_search, textvariable=self._tmdb_query, bg=BG, fg=FG, insertbackground=FG, relief="flat", font=FONT_MAIN).pack(side="left", fill="x", expand=True, ipady=3) tk.Button(f_search, text="🔍 Buscar", bg=BG, fg=ACCENT, bd=0, padx=10, font=FONT_MAIN, cursor="hand2", command=self._search_tmdb).pack(side="right", padx=(6, 0)) self._tmdb_res_cb = ttk.Combobox(s, state="readonly", font=FONT_MAIN) self._tmdb_res_cb.pack(fill="x", pady=(0, 6)) self._tmdb_res_cb.bind("<>", self._on_tmdb_res_select) f_ep = tk.Frame(s, bg=CARD_BG) f_ep.pack(fill="x") self._tmdb_season_cb = ttk.Combobox(f_ep, state="disabled", font=FONT_MAIN, width=16) self._tmdb_season_cb.pack(side="left", padx=(0, 6)) self._tmdb_season_cb.bind("<>", self._on_tmdb_season_select) self._tmdb_ep_cb = ttk.Combobox(f_ep, state="disabled", font=FONT_MAIN) self._tmdb_ep_cb.pack(side="left", fill="x", expand=True) def _tmdb_clear(self, *a): self._tmdb_res_cb.set("") self._tmdb_res_cb.configure(values=[]) self._tmdb_season_cb.set("") self._tmdb_season_cb.configure(state="disabled") self._tmdb_ep_cb.set("") self._tmdb_ep_cb.configure(state="disabled") def _search_tmdb(self): k, q = self._tmdb_key.get().strip(), self._tmdb_query.get().strip() if not k or not q: return t = "movie" if self._tmdb_type.get() == "pelicula" else "tv" url = f"https://api.themoviedb.org/3/search/{t}?api_key={k}&query={urllib.parse.quote(q)}&language=es-MX" def _go(): try: data = fetch_tmdb(url) res, self._tmdb_id_map = [], {} for r in data.get('results', [])[:15]: title = r.get('title') or r.get('name') dt = r.get('release_date') or r.get('first_air_date') or "" yr = dt.split('-')[0] if dt else "N/A" lbl = f"{title} ({yr})" res.append(lbl) self._tmdb_id_map[lbl] = r.get('id') self.after(0, lambda: self._tmdb_res_cb.configure(values=res)) if res: self.after(0, lambda: self._tmdb_res_cb.set(res[0])) self.after(0, self._on_tmdb_res_select) except Exception as e: self.after(0, lambda: self._log(f"✗ TMDb: {e}")) threading.Thread(target=_go, daemon=True).start() def _on_tmdb_res_select(self, *a): if self._tmdb_type.get() == "pelicula": self._tmdb_season_cb.configure(state="disabled") self._tmdb_ep_cb.configure(state="disabled") return self._tmdb_season_cb.configure(state="readonly") tid = self._tmdb_id_map.get(self._tmdb_res_cb.get()) if not tid: return url = f"https://api.themoviedb.org/3/tv/{tid}?api_key={self._tmdb_key.get().strip()}&language=es-MX" def _go(): try: data = fetch_tmdb(url) seasons = [f"Temporada {s['season_number']}" for s in data.get('seasons', []) if s['season_number'] > 0] self.after(0, lambda: self._tmdb_season_cb.configure(values=seasons)) if seasons: self.after(0, lambda: self._tmdb_season_cb.set(seasons[0])) self.after(0, self._on_tmdb_season_select) except Exception as e: self.after(0, lambda: self._log(f"✗ TMDb: {e}")) threading.Thread(target=_go, daemon=True).start() def _on_tmdb_season_select(self, *a): self._tmdb_ep_cb.configure(state="readonly") tid = self._tmdb_id_map.get(self._tmdb_res_cb.get()) if not tid: return s_num = self._tmdb_season_cb.get().split(" ")[1] if self._tmdb_season_cb.get() else "1" url = f"https://api.themoviedb.org/3/tv/{tid}/season/{s_num}?api_key={self._tmdb_key.get().strip()}&language=es-MX" def _go(): try: data = fetch_tmdb(url) self._tmdb_episodes = [{'num': e['episode_number'], 'name': e['name']} for e in data.get('episodes', [])] ep_strs = [f"Ep {e['num']:02d}: {e['name']}" for e in self._tmdb_episodes] self.after(0, lambda: self._tmdb_ep_cb.configure(values=ep_strs)) if ep_strs: self.after(0, lambda: self._tmdb_ep_cb.set(ep_strs[0])) except Exception as e: self.after(0, lambda: self._log(f"✗ TMDb: {e}")) threading.Thread(target=_go, daemon=True).start() # ─── OPCIONES DE CONVERSIÓN ─── def _build_options_section(self, parent): s = self._section(parent, "Modo de Renderizado") self._mode = tk.StringVar(value="Copy + MP3") modes = [ ("Copy Video + MP3 320k", "Copy + MP3"), ("Copy Video + FLAC", "Copy + FLAC"), ("Copy Video + AAC 256k", "Copy + AAC 256k"), ("H264 1080p + MP3", "H264 1080p + MP3"), ("H264 720p + MP3", "H264 720p + MP3"), ("H264 480p + MP3", "H264 480p + MP3"), ("H265 1080p + MP3", "H265 1080p + MP3"), ("Solo Video (sin audio)", "Solo Video (sin audio)"), ("Copy Todo (sin recodificar)", "Copy Todo"), ] f_grid = tk.Frame(s, bg=CARD_BG) f_grid.pack(fill="x") for i, (label, val) in enumerate(modes): r, c = divmod(i, 2) ttk.Radiobutton(f_grid, text=label, variable=self._mode, value=val).grid(row=r, column=c, sticky="w", padx=(0, 12), pady=2) tk.Frame(s, bg=BORDER, height=1).pack(fill="x", pady=(8, 8)) self._gen_single = tk.BooleanVar(value=False) self._ext_sub = tk.BooleanVar(value=False) opts = tk.Frame(s, bg=CARD_BG) opts.pack(fill="x") ttk.Checkbutton(opts, text="Extract pista de audio", variable=self._gen_single).pack(side="left") ttk.Checkbutton(opts, text="Extract subtítulo (.vtt)", variable=self._ext_sub).pack(side="left", padx=(12, 0)) # ─── SALIDA ─── def _build_output_section(self, parent): s = self._section(parent, "Nombre y Salida") tk.Label(s, text="Nombre manual (si no usas TMDb):", bg=CARD_BG, fg=FG2, font=FONT_SUB).pack(anchor="w") self._manual_name = tk.StringVar() tk.Entry(s, textvariable=self._manual_name, bg=BG, fg=FG, insertbackground=FG, relief="flat", font=FONT_MAIN).pack(fill="x", ipady=3, pady=(0, 10)) self._use_leet = tk.BooleanVar(value=True) ttk.Checkbutton(s, text="Aplicar estilo L33T a nombres", variable=self._use_leet).pack(anchor="w", pady=(0, 8)) tk.Label(s, text="Carpeta de salida:", bg=CARD_BG, fg=FG2, font=FONT_SUB).pack(anchor="w") f_out = tk.Frame(s, bg=CARD_BG) f_out.pack(fill="x") sys = get_sys() default_out = str(Path(sys.argv[0]).parent.resolve() / "Videos_Renderizados") self._output_dir = tk.StringVar(value=default_out) tk.Entry(f_out, textvariable=self._output_dir, bg=BG, fg=FG, insertbackground=FG, relief="flat", font=FONT_MAIN).pack(side="left", fill="x", expand=True, ipady=3) tk.Button(f_out, text="📁", bg=BG, fg=ACCENT, bd=0, padx=8, font=FONT_MAIN, cursor="hand2", command=self._browse_output).pack(side="right", padx=(6, 0)) tk.Button(f_out, text="Abrir", bg=BG, fg=GREEN, bd=0, padx=8, font=FONT_SUB, cursor="hand2", command=self._open_output).pack(side="right", padx=(0, 4)) def _browse_output(self): p = filedialog.askdirectory(initialdir=self._output_dir.get()) if p: self._output_dir.set(p) def _open_output(self): d = self._output_dir.get() Path(d).mkdir(exist_ok=True, parents=True) os.startfile(d) # ─── FUENTE ─── def _build_source_section(self, parent): s = self._section(parent, "Fuente de Video") tabs = tk.Frame(s, bg=CARD_BG) tabs.pack(fill="x", pady=(0, 8)) self._src_mode = tk.StringVar(value="url") self._btn_url = tk.Button(tabs, text="🌐 URLs", bg=ACCENT, fg="white", bd=0, padx=12, pady=5, font=FONT_BOLD, cursor="hand2", command=lambda: self._switch_src("url")) self._btn_url.pack(side="left") self._btn_file = tk.Button(tabs, text="📁 Archivo(s)", bg=BG, fg=FG2, bd=0, padx=12, pady=5, font=FONT_BOLD, cursor="hand2", command=lambda: self._switch_src("file")) self._btn_file.pack(side="left", padx=4) self._url_frame = tk.Frame(s, bg=CARD_BG) self._url_text = tk.Text(self._url_frame, bg=BG, fg=FG, insertbackground=FG, font=("Consolas", 9), relief="flat", height=6) sb = ttk.Scrollbar(self._url_frame, command=self._url_text.yview) self._url_text.configure(yscrollcommand=sb.set) self._url_text.pack(side="left", fill="both", expand=True, pady=2) sb.pack(side="right", fill="y", pady=2) self._file_frame = tk.Frame(s, bg=CARD_BG) self._file_var = tk.StringVar() tk.Entry(self._file_frame, textvariable=self._file_var, bg=BG, fg=FG, insertbackground=FG, font=FONT_MAIN, relief="flat").pack(side="left", fill="x", expand=True, ipady=3, padx=(0, 4)) tk.Button(self._file_frame, text="Buscar", bg=BG, fg=ACCENT, bd=0, padx=8, pady=3, font=FONT_MAIN, cursor="hand2", command=self._browse_file).pack(side="right") self._multi_file = tk.BooleanVar(value=False) ttk.Checkbutton(self._file_frame, text="Múltiples", variable=self._multi_file).pack(side="right", padx=(0, 8)) self._lbl_info = tk.Label(s, text="", bg=BG, fg=FG2, font=FONT_SUB, anchor="w", padx=8, pady=4, wraplength=500, justify="left") self._lbl_info.pack(fill="x", pady=(6, 0)) btns = tk.Frame(s, bg=CARD_BG) btns.pack(fill="x", pady=(8, 0)) tk.Button(btns, text="🔍 ANALIZAR", bg=BG, fg=ACCENT, bd=0, padx=12, pady=7, font=FONT_BOLD, cursor="hand2", command=self._do_analyze).pack(side="left") self._btn_proc = tk.Button(btns, text="▶ PROCESAR COLA", bg=GREEN, fg="white", bd=0, padx=12, pady=7, font=FONT_BOLD, cursor="hand2", command=self._do_process) self._btn_proc.pack(side="right", fill="x", expand=True, padx=(8, 0)) self._btn_cancel = tk.Button(btns, text="✕", bg=RED, fg="white", bd=0, padx=10, pady=7, font=FONT_BOLD, cursor="hand2", command=self._cancel_queue, state="disabled") self._btn_cancel.pack(side="right") self._switch_src("url") def _switch_src(self, mode): self._src_mode.set(mode) if mode == "file": self._file_frame.pack(fill="x") self._url_frame.pack_forget() self._btn_file.configure(bg=ACCENT, fg="white") self._btn_url.configure(bg=BG, fg=FG2) else: self._url_frame.pack(fill="x") self._file_frame.pack_forget() self._btn_url.configure(bg=ACCENT, fg="white") self._btn_file.configure(bg=BG, fg=FG2) def _browse_file(self): if self._multi_file.get(): files = filedialog.askopenfilenames(filetypes=[("Video", "*.mp4 *.mkv *.avi *.mov *.ts *.m4v *.webm"), ("Todos", "*.*")]) if files: self._file_var.set("\n".join(files)) else: p = filedialog.askopenfilename(filetypes=[("Video", "*.mp4 *.mkv *.avi *.mov *.ts *.m4v *.webm"), ("Todos", "*.*")]) if p: self._file_var.set(p) def _get_sources(self): if self._src_mode.get() == "file": raw = self._file_var.get().strip() lines = [l.strip() for l in raw.split("\n") if l.strip()] return lines, False else: srcs = [ln.strip() for ln in self._url_text.get("1.0", "end").split("\n") if ln.strip()] return srcs, True # ─── PISTAS ─── def _build_tracks_section(self, parent): s = self._section(parent, "Pistas") f = tk.Frame(s, bg=CARD_BG) f.pack(fill="x") tk.Label(f, text="Audio:", bg=CARD_BG, fg=FG2, font=FONT_SUB).pack(side="left") self._aud_cb = ttk.Combobox(f, state="readonly", font=FONT_MAIN, width=18) self._aud_cb.pack(side="left", fill="x", expand=True, padx=(4, 12)) tk.Label(f, text="Sub:", bg=CARD_BG, fg=FG2, font=FONT_SUB).pack(side="left") self._sub_cb = ttk.Combobox(f, state="readonly", font=FONT_MAIN, width=18) self._sub_cb.pack(side="left", fill="x", expand=True, padx=(4, 0)) # ─── LOG ─── def _build_log_section(self, parent): s = self._section(parent, "Progreso") self._lbl_prog = tk.Label(s, text="Esperando...", bg=CARD_BG, fg=FG2, anchor="w", font=FONT_SUB) self._lbl_prog.pack(fill="x") self._pbar = ttk.Progressbar(s, mode="determinate", style="Horizontal.TProgressbar") self._pbar.pack(fill="x", pady=(4, 8)) log_f = tk.Frame(s, bg=BG, padx=1, pady=1) log_f.pack(fill="both", expand=True) self._log_txt = tk.Text(log_f, bg="#0c1222", fg="#38bdf8", font=("Consolas", 9), bd=0, relief="flat", state="disabled", wrap="word") sb = ttk.Scrollbar(log_f, command=self._log_txt.yview) self._log_txt.configure(yscrollcommand=sb.set) sb.pack(side="right", fill="y") self._log_txt.pack(fill="both", expand=True) # Tags de colores self._log_txt.tag_configure("ok", foreground=GREEN) self._log_txt.tag_configure("err", foreground=RED) self._log_txt.tag_configure("warn", foreground=YELLOW) self._log_txt.tag_configure("info", foreground=FG2) def _log(self, msg): self._log_txt.configure(state="normal") tag = "info" if "✓" in msg: tag = "ok" elif "✗" in msg: tag = "err" elif "⚠" in msg: tag = "warn" self._log_txt.insert("end", msg + "\n", tag) self._log_txt.see("end") self._log_txt.configure(state="disabled") def _set_progress(self, pct, label=""): self._pbar["value"] = pct self._lbl_prog.configure(text=label) self.update_idletasks() # ─── ACCIONES ─── def _do_analyze(self): sources, is_url = self._get_sources() if not sources: messagebox.showwarning("Sin fuente", "Agrega al menos una URL o archivo.") return def _go(): self._log("⟳ Analizando fuentes…") # Analizar primer elemento para mostrar pistas src = sources[0] info = probe(src) ac = [f"[{t['idx']}] {t['lang']} · {t['codec']} · {t.get('sample_rate','?')}Hz · {t['ch']}ch" for t in info["audio"]] sc = [f"[{t['idx']}] {t['lang']} · {t['codec']}" + (" [FORCED]" if t['forced'] else "") for t in info["subs"]] self.after(0, lambda: self._aud_cb.configure(values=ac)) self.after(0, lambda: self._sub_cb.configure(values=sc)) if ac: self.after(0, lambda: self._aud_cb.set(ac[0])) if sc: self.after(0, lambda: self._sub_cb.set(sc[0])) v = info["video"] v_info = f"{v.get('width','?')}x{v.get('height','?')} {v.get('codec','?')} {v.get('pix_fmt','?')}" if v else "Sin video" parts = [] if len(sources) > 1: parts.append(f"📦 {len(sources)} elementos en cola") else: parts.append(f"🎬 {info['title'] or 'Sin título'}") parts.append(f"⏱ {fmt_dur(info['duration'])}") parts.append(f"📐 {v_info}") parts.append(f"🔊 {len(ac)} pistas") parts.append(f"💬 {len(sc)} subs") if info["size"]: parts.append(f"💾 {fmt_size(info['size'])}") txt = " | ".join(parts) self.after(0, lambda: self._lbl_info.configure(text=txt, fg=GREEN)) self._log(txt) # Si hay múltiples, analizar todos para detectar duración total if len(sources) > 1: total_dur = info["duration"] failed = 0 for s2 in sources[1:]: i2 = probe(s2) if i2["duration"] > 0: total_dur += i2["duration"] else: failed += 1 self._log(f"⏱ Duración total estimada: {fmt_dur(total_dur)}" + (f" ({failed} sin duración)" if failed else "")) threading.Thread(target=_go, daemon=True).start() def _cancel_queue(self): self._queue.clear() self._log("⚠ Cola cancelada por el usuario.") self._btn_proc.configure(state="normal", bg=GREEN, text="▶ PROCESAR COLA") self._btn_cancel.configure(state="disabled") self._lbl_status.configure(text="● Cancelado", fg=RED) def _do_process(self): sources, is_url = self._get_sources() if not sources: return messagebox.showwarning("Sin fuente", "Agrega al menos una URL o archivo.") out_dir = self._output_dir.get().strip() if not out_dir: return messagebox.showwarning("Sin carpeta", "Selecciona una carpeta de salida.") Path(out_dir).mkdir(exist_ok=True, parents=True) self._queue = sources.copy() self._total_queue = len(self._queue) self._current_idx = 0 self._processed_count = 0 self._failed_count = 0 self._btn_proc.configure(state="disabled", bg=FG2) self._btn_cancel.configure(state="normal") self._log_txt.configure(state="normal") self._log_txt.delete("1.0", "end") self._log_txt.configure(state="disabled") self._log(f"🚀 Iniciando cola: {self._total_queue} elementos") self._log(f"📂 Salida: {out_dir}") self._log(f"⚙ Modo: {self._mode.get()}") self._log("─" * 50) self._process_next() def _process_next(self): if not self._queue: self._btn_proc.configure(state="normal", bg=GREEN, text="▶ PROCESAR COLA") self._btn_cancel.configure(state="disabled") color = GREEN if self._failed_count == 0 else YELLOW self._lbl_status.configure(text=f"● {self._processed_count} OK / {self._failed_count} Error", fg=color) self._set_progress(100, f"Completado: {self._processed_count} exitosos, {self._failed_count} errores") self._log("═" * 50) self._log(f"✓ COLA FINALIZADA: {self._processed_count} OK, {self._failed_count} errores") return src = self._queue.pop(0) self._current_idx += 1 self._lbl_status.configure(text=f"● Procesando {self._current_idx}/{self._total_queue}…", fg=YELLOW) self._log(f"\n▶ [{self._current_idx}/{self._total_queue}] {src[:60]}{'...' if len(src)>60 else ''}") # ─── LÓGICA DE NOMBRES ─── folder_name, file_name = "Render", f"Video_{self._current_idx:03d}" if self._use_tmdb.get() and self._tmdb_res_cb.get(): base_lbl = self._tmdb_res_cb.get().split(" (")[0] if self._tmdb_type.get() == "pelicula": raw_folder = base_lbl raw_file = f"{base_lbl} Parte {self._current_idx}" if self._total_queue > 1 else base_lbl else: s_num = self._tmdb_season_cb.get().split(" ")[1] if self._tmdb_season_cb.get() else "1" raw_folder = f"{base_lbl} S{int(s_num):02d}" ep_idx = self._tmdb_ep_cb.current() if self._tmdb_ep_cb.current() >= 0 else 0 tgt = ep_idx + self._current_idx - 1 if tgt < len(self._tmdb_episodes): ep = self._tmdb_episodes[tgt] raw_file = f"{base_lbl} S{int(s_num):02d}E{ep['num']:02d} {ep['name']}" else: raw_file = f"{base_lbl} S{int(s_num):02d}E{(tgt+1):02d}" else: m_name = self._manual_name.get().strip() if m_name: raw_folder = m_name raw_file = f"{m_name} {self._current_idx:03d}" if self._total_queue > 1 else m_name else: if self._src_mode.get() == "file": base = Path(src).stem raw_folder = base raw_file = base if self._total_queue == 1 else f"{base}_{self._current_idx:03d}" else: raw_folder = "URL_Render" raw_file = f"URL_{self._current_idx:03d}" if self._use_leet.get(): folder_name = to_leet(raw_folder) file_name = to_leet(raw_file) else: folder_name = safe_name(raw_folder) file_name = safe_name(raw_file) ai, si = 0, 0 try: if self._aud_cb.get(): ai = int(self._aud_cb.get().split("]")[0].strip("[")) if self._sub_cb.get(): si = int(self._sub_cb.get().split("]")[0].strip("[")) except: pass _, is_url_flag = self._get_sources() def on_done(ok, files): if ok: self._processed_count += 1 self._log(f" ✅ Completado") else: self._failed_count += 1 self._log(f" ❌ Falló") self.after(0, self._process_next) threading.Thread( target=render_video, args=(src, is_url_flag, self._mode.get(), ai, self._gen_single.get(), self._ext_sub.get(), si, folder_name, file_name, self._output_dir.get().strip(), lambda m: self.after(0, self._log, m), lambda p, l: self.after(0, self._set_progress, p, l), on_done), daemon=True ).start() if __name__ == "__main__": app = App() app.mainloop()