plis2 / render.py
CineMax's picture
Update render.py
683b474 verified
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 (LÓGICA CORREGIDA) ────────────────────────
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_full = tmp / f"{file_name}.mp4"
# ─── 1. COMANDO PRINCIPAL: VIDEO + TODOS LOS AUDIOS ───
cmd_base = ["ffmpeg", "-y"] + extra + ["-i", source]
# Mapeo: Video + TODOS los audios
cmd_map = ["-map", "0:v:0"]
if info["audio"]:
for i in range(len(info["audio"])):
cmd_map.extend(["-map", f"0:a:{i}"])
else:
cmd_map.append("-an")
# Códigos
# Aplicamos codecs generales (-c:a) para que afecten a TODOS los audios mapeados
if mode == "Copy + MP3":
cmd_codecs = ["-c:v", "copy", "-c:a", "libmp3lame", "-b:a", "320k"]
elif mode == "Copy + FLAC":
cmd_codecs = ["-c:v", "copy", "-c:a", "flac"]
elif mode == "Copy + AAC 256k":
cmd_codecs = ["-c:v", "copy", "-c:a", "aac", "-b:a", "256k"]
elif mode == "H264 1080p + MP3":
cmd_codecs = ["-c:v", "libx264", "-vf", "scale=-2:1080", "-preset", "fast", "-crf", "18", "-c:a", "libmp3lame", "-b:a", "320k"]
elif mode == "H264 720p + MP3":
cmd_codecs = ["-c:v", "libx264", "-vf", "scale=-2:720", "-preset", "fast", "-crf", "20", "-c:a", "libmp3lame", "-b:a", "320k"]
elif mode == "H264 480p + MP3":
cmd_codecs = ["-c:v", "libx264", "-vf", "scale=-2:480", "-preset", "fast", "-crf", "23", "-c:a", "libmp3lame", "-b:a", "192k"]
elif mode == "H265 1080p + MP3":
cmd_codecs = ["-c:v", "libx265", "-vf", "scale=-2:1080", "-preset", "fast", "-crf", "22", "-c:a", "libmp3lame", "-b:a", "320k"]
elif mode == "Solo Video (sin audio)":
cmd_codecs = ["-c:v", "copy", "-an"]
elif mode == "Copy Todo":
# Copia todo (video y todos los audios) sin recodificar
cmd_codecs = ["-c", "copy"]
else:
cmd_codecs = ["-c:v", "copy", "-c:a", "copy"]
cmd = cmd_base + cmd_map + cmd_codecs + ["-map_metadata", "0", "-movflags", "+faststart", str(out_mp4_full)]
log_cb(f"⚙ Modo: {mode}")
log_cb(f"⚙ Renderizando Video + Todos los audios…")
ok = run_ffmpeg(cmd, dur, log_cb, progress_cb, "Renderizando")
if not ok:
log_cb("✗ Renderizado fallido")
shutil.rmtree(tmp, ignore_errors=True)
done_cb(False, None)
return
progress_cb(100, "Renderizado OK ✓")
# ─── 2. EXTRACCIÓN: VIDEO + AUDIO SELECCIONADO (DEL ARCHIVO YA RENDERIZADO) ───
# Aquí generamos el segundo archivo de video con solo el audio elegido
if gen_single and info["audio"] and audio_idx < len(info["audio"]):
out_mp4_single = tmp / f"{file_name}_Aud{audio_idx}.mp4"
log_cb(f"⚙ Generando versión específica (Audio #{audio_idx})...")
# Comando: Tomamos el archivo renderizado, mapeamos video + audio específico
sc = ["ffmpeg", "-y", "-i", str(out_mp4_full),
"-map", "0:v:0", # Video del archivo renderizado
"-map", f"0:a:{audio_idx}", # El audio específico seleccionado
"-c", "copy", # Copiamos sin recodificar (rápido)
str(out_mp4_single)]
run_ffmpeg(sc, dur, log_cb, progress_cb, f"Generando Video Single Audio")
# ─── 3. 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("<<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()
# ─── 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 (Video+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()