plis2 / render.py
CineMax's picture
Create render.py
ac5fb1e 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 ────────────────────────────────
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("<<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", 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()