| 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 |
|
|
| |
| def get_sys(): |
| import sys |
| return sys |
|
|
| |
| 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() |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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" |
|
|
| |
| 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"]) |
|
|
| |
| 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 β") |
|
|
| |
| 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}") |
|
|
| |
| 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") |
|
|
| |
| 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 |
| |
| 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) |
|
|
|
|
| |
| 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 = 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 = 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) |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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("<<ComboboxSelected>>", 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("<<ComboboxSelected>>", 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() |
|
|
| |
| 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)) |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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)) |
|
|
| |
| 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) |
|
|
| |
| 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() |
|
|
| |
| 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β¦") |
|
|
| |
| 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) |
|
|
| |
| 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 ''}") |
|
|
| |
| 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() |