pll / job.py
CineMax's picture
Upload 11 files
182cdfb verified
import sys
import os
import subprocess
import threading
import re
import json
import tempfile
from pathlib import Path
from typing import Optional, List
import tkinter as tk
from tkinter import filedialog, messagebox, scrolledtext
# ============================================================================
# TEMA
# ============================================================================
class Theme:
BG = "#0f0f1a"
BG_CARD = "#1a1a2e"
BG_INPUT = "#252542"
BG_HOVER = "#2d2d4a"
ACCENT = "#6366f1"
ACCENT_HOVER = "#818cf8"
DANGER = "#ef4444"
SUCCESS = "#22c55e"
TEXT = "#f8fafc"
TEXT_DIM = "#94a3b8"
# ============================================================================
# CLASES DE DATOS
# ============================================================================
class AudioTrack:
def __init__(self, index, stream_index, codec, language, channels, title=""):
self.index = index
self.stream_index = stream_index
self.codec = codec
self.language = language
self.channels = channels
self.title = title
def display_name(self):
lang = self.language if self.language != "und" else "Unknown"
ch = f"{self.channels}ch" if self.channels else ""
title_part = f" - {self.title}" if self.title else ""
return f"[{self.index}] {lang} | {self.codec} {ch}{title_part}"
class SubtitleTrack:
def __init__(self, index, stream_index, codec, language, title="", forced=False):
self.index = index
self.stream_index = stream_index
self.codec = codec
self.language = language
self.title = title
self.forced = forced
def display_name(self):
lang = self.language if self.language != "und" else "Unknown"
forced_tag = " (Forced)" if self.forced else ""
title_part = f" - {self.title}" if self.title else ""
return f"[{self.index}] {lang} | {self.codec}{forced_tag}{title_part}"
class MediaInfo:
def __init__(self, path):
self.path = path
self.audio_tracks: List[AudioTrack] = []
self.subtitle_tracks: List[SubtitleTrack] = []
self.duration = 0.0
self.title: Optional[str] = None
# ============================================================================
# MOTOR DE CONVERSIÓN
# ============================================================================
class MediaAnalyzer:
@staticmethod
def get_media_info(source: str) -> MediaInfo:
info = MediaInfo(path=source)
try:
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", source]
creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, creationflags=creationflags)
if result.returncode != 0:
return info
data = json.loads(result.stdout)
format_tags = data.get("format", {}).get("tags", {})
info.title = format_tags.get("title") or format_tags.get("TITLE")
if info.title:
info.title = info.title.strip()
if "format" in data and "duration" in data["format"]:
info.duration = float(data["format"]["duration"])
audio_idx = 0
sub_idx = 0
for stream in data.get("streams", []):
codec_type = stream.get("codec_type", "")
tags = stream.get("tags", {})
if codec_type == "audio":
track = AudioTrack(
index=audio_idx,
stream_index=stream.get("index", 0),
codec=stream.get("codec_name", "unknown"),
language=tags.get("language", "und"),
channels=stream.get("channels", 2),
title=tags.get("title", "")
)
info.audio_tracks.append(track)
audio_idx += 1
elif codec_type == "subtitle":
disposition = stream.get("disposition", {})
track = SubtitleTrack(
index=sub_idx,
stream_index=stream.get("index", 0),
codec=stream.get("codec_name", "unknown"),
language=tags.get("language", "und"),
title=tags.get("title", ""),
forced=disposition.get("forced", 0) == 1
)
info.subtitle_tracks.append(track)
sub_idx += 1
except Exception as e:
print(f"Error analyzing: {e}")
return info
@staticmethod
def extract_subtitle_preview(source: str, subtitle_index: int) -> str:
try:
with tempfile.NamedTemporaryFile(mode='w', suffix='.vtt', delete=False) as tmp:
tmp_path = tmp.name
cmd = ["ffmpeg", "-y", "-i", source, "-map", f"0:s:{subtitle_index}", "-t", "300", tmp_path]
creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
subprocess.run(cmd, capture_output=True, timeout=30, creationflags=creationflags)
if os.path.exists(tmp_path):
with open(tmp_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
os.unlink(tmp_path)
lines = [l.strip() for l in content.split('\n') if l.strip() and not l.strip().startswith('WEBVTT') and '-->' not in l]
return '\n'.join(lines[:30])
except Exception as e:
return f"Error: {e}"
return "No se pudo cargar"
class VideoConverter:
def __init__(self):
self.current_process = None
self.cancelled = False
def cancel(self):
self.cancelled = True
if self.current_process:
try:
self.current_process.terminate()
except:
pass
def run_ffmpeg(self, cmd: list, duration: float, progress_cb, status_cb, msg: str) -> bool:
try:
creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
self.current_process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True, creationflags=creationflags
)
pattern = re.compile(r"time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})")
for line in self.current_process.stderr:
if self.cancelled:
self.current_process.terminate()
return False
match = pattern.search(line)
if match and duration > 0:
h, m, s, ms = map(int, match.groups())
current = h * 3600 + m * 60 + s + ms / 100
progress = min(100, (current / duration) * 100)
if progress_cb:
progress_cb(progress)
if status_cb:
status_cb(f"{msg}: {progress:.0f}%")
self.current_process.wait()
return self.current_process.returncode == 0
except Exception as e:
print(f"FFmpeg error: {e}")
return False
def convert(self, source: str, is_url: bool, mode: str, output_dir: str,
selected_audio: int, generate_single: bool, extract_sub: bool, sub_index: int,
progress_cb, status_cb) -> bool:
self.cancelled = False
try:
# Headers para URLs directas (mejora compatibilidad)
extra_input_options = []
if is_url:
extra_input_options = [
"-user_agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"-headers", "Referer: https://google.com\r\n"
]
info = MediaAnalyzer.get_media_info(source)
duration = info.duration if info.duration > 0 else 1
base_name = info.title if info.title else (Path(source).stem if not is_url else "video_directo")
if not base_name or base_name.strip() == "":
base_name = "video_sin_nombre"
folder_name = re.sub(r'[<>:"/\\|?*]', '_', base_name)
folder_name = re.sub(r'\s+', ' ', folder_name).strip()
folder_name = folder_name.replace(' ', '_')
for old, new in [("a", "4"), ("A", "4"), ("i", "1"), ("I", "1"), ("o", "0"), ("O", "0")]:
folder_name = folder_name.replace(old, new)
folder_name = folder_name[:150]
if not folder_name:
folder_name = "video_sin_nombre"
out_folder = os.path.join(output_dir, folder_name)
os.makedirs(out_folder, exist_ok=True)
if status_cb:
status_cb(f"Carpeta creada: {out_folder}")
# PASO 1: Archivo principal con todos los audios
if status_cb:
status_cb(f"[1/{'3' if generate_single else '2'}] Convirtiendo todos los audios: {base_name}")
all_audio_path = os.path.join(out_folder, f"{folder_name}_all_audio.mp4")
cmd = ["ffmpeg", "-y"] + extra_input_options + ["-i", source, "-map", "0:v:0", "-map", "0:a?"]
if mode == "Video Copy + Audio MP3":
cmd.extend(["-c:v", "copy", "-c:a", "libmp3lame", "-b:a", "320k"])
elif mode == "Video Copy + Audio FLAC":
cmd.extend(["-c:v", "copy", "-c:a", "flac", "-compression_level", "0"])
else:
cmd.extend(["-c:v", "libx264", "-vf", "scale=-2:1080", "-preset", "slow",
"-crf", "18", "-c:a", "libmp3lame", "-b:a", "320k"])
cmd.extend([
"-map_metadata", "0",
"-metadata", "copyright=Power Prods",
"-metadata", "description=Power Prods",
all_audio_path
])
if progress_cb:
progress_cb(0)
success_all = self.run_ffmpeg(cmd, duration, progress_cb, status_cb, f"[1/{'3' if generate_single else '2'}] Todos los audios")
if not success_all or self.cancelled:
if status_cb:
status_cb("Error en conversión principal")
return False
# PASO 2: Extraer subtítulo
if extract_sub:
if status_cb:
status_cb(f"[2/{'3' if generate_single else '2'}] Extrayendo subtítulo")
vtt_path = os.path.join(out_folder, f"{folder_name}_sub_{sub_index}.vtt")
sub_cmd = ["ffmpeg", "-y"] + extra_input_options + ["-i", source, "-map", f"0:s:{sub_index}", "-c:s", "webvtt", vtt_path]
creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
subprocess.run(sub_cmd, capture_output=True, creationflags=creationflags)
# PASO OPCIONAL: Versión adicional
if generate_single:
if status_cb:
status_cb(f"[{'3' if extract_sub else '2'}/3] Generando versión audio seleccionado")
single_path = os.path.join(out_folder, f"{folder_name}_audio_{selected_audio}.mp4")
single_cmd = [
"ffmpeg", "-y", "-i", all_audio_path,
"-map", "0:v:0", "-map", f"0:a:{selected_audio}",
"-c:v", "copy", "-c:a", "copy",
"-map_metadata", "0",
"-metadata", "copyright=Power Prods",
"-metadata", "description=Power Prods",
single_path
]
success_single = self.run_ffmpeg(single_cmd, duration, progress_cb, status_cb, "[3/3] Audio seleccionado")
if not success_single or self.cancelled:
return False
# FINAL
if not self.cancelled:
if not is_url:
try:
os.remove(source)
if status_cb:
status_cb("Original local borrado")
except Exception as e:
if status_cb:
status_cb(f"No borrado original: {e}")
if progress_cb:
progress_cb(100)
files_created = f"{folder_name}_all_audio.mp4"
if generate_single:
files_created += f" + {folder_name}_audio_{selected_audio}.mp4"
if status_cb:
status_cb(f"¡COMPLETADO! Carpeta: {out_folder}")
status_cb(f"Archivos: {files_created}")
return True
except Exception as e:
if status_cb:
status_cb(f"Error crítico: {e}")
return False
# ============================================================================
# COMPONENTES UI
# ============================================================================
class AnimatedProgress(tk.Canvas):
def __init__(self, parent, **kwargs):
super().__init__(parent, height=14, bg=Theme.BG_INPUT, highlightthickness=0, **kwargs)
self.progress = 0
self.pulse_offset = 0
self.animating = False
self.bind("<Configure>", lambda e: self.draw())
def set_progress(self, value: float):
self.progress = max(0, min(100, value))
self.draw()
def draw(self):
self.delete("all")
w = self.winfo_width() or 400
h = self.winfo_height() or 14
self.create_rectangle(0, 0, w, h, fill=Theme.BG_INPUT, outline="")
if self.progress > 0:
pw = (self.progress / 100) * w
self.create_rectangle(0, 0, pw, h, fill=Theme.ACCENT, outline="")
if self.animating and pw > 0:
for i in range(3):
px = ((self.pulse_offset + i * 40) % int(pw + 80)) - 40
if 0 < px < pw:
self.create_rectangle(px, 0, min(px + 40, pw), h, fill=Theme.ACCENT_HOVER, outline="")
def start_pulse(self):
self.animating = True
self._pulse()
def stop_pulse(self):
self.animating = False
def _pulse(self):
if self.animating:
self.pulse_offset += 4
self.draw()
self.after(50, self._pulse)
# ============================================================================
# APLICACIÓN PRINCIPAL
# ============================================================================
class VideoConverterApp:
def __init__(self):
self.root = tk.Tk()
self.root.title("Video Converter Pro")
self.root.geometry("600x900")
self.root.minsize(500, 700)
self.root.config(bg=Theme.BG)
self.root.update_idletasks()
x = (self.root.winfo_screenwidth() - 600) // 2
y = (self.root.winfo_screenheight() - 900) // 2
self.root.geometry(f"+{x}+{y}")
self.converter = VideoConverter()
self.is_converting = False
self.mode_var = tk.StringVar(value="Video Copy + Audio MP3")
self.extract_subs_var = tk.BooleanVar(value=False)
self.generate_single_var = tk.BooleanVar(value=False)
# Carpeta de salida predeterminada: donde está el .py
default_output = os.path.dirname(os.path.abspath(__file__))
self.output_dir_var = tk.StringVar(value=default_output)
self.files: List[str] = []
self.urls: List[str] = []
self.current_media_info: Optional[MediaInfo] = None
self._build_ui()
def _build_ui(self):
container = tk.Frame(self.root, bg=Theme.BG)
container.pack(fill="both", expand=True)
canvas = tk.Canvas(container, bg=Theme.BG, highlightthickness=0)
scrollbar = tk.Scrollbar(container, orient="vertical", command=canvas.yview)
self.main = tk.Frame(canvas, bg=Theme.BG)
canvas.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(side="right", fill="y")
canvas.pack(side="left", fill="both", expand=True)
canvas_window = canvas.create_window((0, 0), window=self.main, anchor="nw")
def on_configure(e):
canvas.configure(scrollregion=canvas.bbox("all"))
canvas.itemconfig(canvas_window, width=e.width)
self.main.bind("<Configure>", on_configure)
canvas.bind("<Configure>", lambda e: canvas.itemconfig(canvas_window, width=e.width))
canvas.bind_all("<MouseWheel>", lambda e: canvas.yview_scroll(int(-1*(e.delta/120)), "units"))
header = tk.Frame(self.main, bg=Theme.BG)
header.pack(fill="x", padx=20, pady=(20, 16))
tk.Label(header, text="Video Converter Pro", font=("Segoe UI", 20, "bold"),
bg=Theme.BG, fg=Theme.TEXT).pack(anchor="w")
tk.Label(header, text="Convierte videos locales o desde links directos",
font=("Segoe UI", 10), bg=Theme.BG, fg=Theme.TEXT_DIM).pack(anchor="w")
# SECCIÓN CARPETA DE SALIDA
self._label("Carpeta de Salida")
output_card = tk.Frame(self.main, bg=Theme.BG_CARD)
output_card.pack(fill="x", padx=20, pady=(0, 10))
tk.Label(output_card, text="Selecciona donde guardar los videos convertidos (predeterminado: carpeta del programa):",
font=("Segoe UI", 10), bg=Theme.BG_CARD, fg=Theme.TEXT).pack(anchor="w", padx=10, pady=(10, 5))
dir_frame = tk.Frame(output_card, bg=Theme.BG_CARD)
dir_frame.pack(fill="x", padx=10, pady=(0, 10))
self.output_entry = tk.Entry(dir_frame, textvariable=self.output_dir_var, font=("Segoe UI", 9),
bg=Theme.BG_INPUT, fg=Theme.TEXT, relief="flat")
self.output_entry.pack(side="left", fill="x", expand=True, ipady=6)
tk.Button(dir_frame, text="Cambiar carpeta", font=("Segoe UI", 10, "bold"),
bg=Theme.ACCENT, fg=Theme.TEXT, relief="flat", bd=0, cursor="hand2",
command=self._choose_output_dir).pack(side="right", ipadx=10, ipady=6)
self._label("Archivos Locales")
files_card = tk.Frame(self.main, bg=Theme.BG_CARD)
files_card.pack(fill="x", padx=20, pady=(0, 10))
self.files_list = tk.Listbox(files_card, font=("Segoe UI", 9), bg=Theme.BG_INPUT, fg=Theme.TEXT,
selectbackground=Theme.ACCENT, height=4, relief="flat", bd=0,
highlightthickness=0, exportselection=False)
self.files_list.pack(fill="x", padx=10, pady=10)
self.files_list.bind("<<ListboxSelect>>", self._on_file_select)
self.files_list.bind("<Double-Button-1>", self._remove_file)
btn_row = tk.Frame(files_card, bg=Theme.BG_CARD)
btn_row.pack(fill="x", padx=10, pady=(0, 10))
tk.Button(btn_row, text="Seleccionar archivos", font=("Segoe UI", 10, "bold"),
bg=Theme.ACCENT, fg=Theme.TEXT, activebackground=Theme.ACCENT_HOVER,
activeforeground=Theme.TEXT, relief="flat", bd=0, cursor="hand2",
command=self._browse_files).pack(side="left", fill="x", expand=True, ipady=8, padx=(0, 4))
tk.Button(btn_row, text="Limpiar", font=("Segoe UI", 10),
bg=Theme.BG_INPUT, fg=Theme.TEXT, activebackground=Theme.BG_HOVER,
relief="flat", bd=0, cursor="hand2",
command=self._clear_files).pack(side="left", fill="x", expand=True, ipady=8, padx=(4, 0))
self.files_count = tk.Label(files_card, text="0 archivos", font=("Segoe UI", 9),
bg=Theme.BG_CARD, fg=Theme.TEXT_DIM)
self.files_count.pack(anchor="e", padx=10, pady=(0, 6))
self._label("Pistas de Audio (del archivo seleccionado)")
audio_card = tk.Frame(self.main, bg=Theme.BG_CARD)
audio_card.pack(fill="x", padx=20, pady=(0, 10))
self.audio_list = tk.Listbox(audio_card, font=("Segoe UI", 9), bg=Theme.BG_INPUT, fg=Theme.TEXT,
selectbackground=Theme.ACCENT, height=3, relief="flat", bd=0,
highlightthickness=0, exportselection=False)
self.audio_list.pack(fill="x", padx=10, pady=10)
self.audio_info = tk.Label(audio_card, text="Selecciona un archivo para ver sus pistas",
font=("Segoe UI", 9), bg=Theme.BG_CARD, fg=Theme.TEXT_DIM)
self.audio_info.pack(anchor="w", padx=10, pady=(0, 4))
single_frame = tk.Frame(audio_card, bg=Theme.BG_CARD)
single_frame.pack(fill="x", padx=10, pady=(0, 10))
tk.Checkbutton(single_frame, text="Generar versión ADICIONAL con solo el audio seleccionado",
variable=self.generate_single_var, font=("Segoe UI", 10),
bg=Theme.BG_CARD, fg=Theme.TEXT, selectcolor=Theme.BG_INPUT,
activebackground=Theme.BG_CARD).pack(anchor="w")
self._label("Subtitulos")
sub_card = tk.Frame(self.main, bg=Theme.BG_CARD)
sub_card.pack(fill="x", padx=20, pady=(0, 10))
check_frame = tk.Frame(sub_card, bg=Theme.BG_CARD)
check_frame.pack(fill="x", padx=10, pady=(10, 4))
tk.Checkbutton(check_frame, text="Extraer subtitulo seleccionado a VTT",
variable=self.extract_subs_var, font=("Segoe UI", 10),
bg=Theme.BG_CARD, fg=Theme.TEXT, selectcolor=Theme.BG_INPUT,
activebackground=Theme.BG_CARD).pack(anchor="w")
self.sub_list = tk.Listbox(sub_card, font=("Segoe UI", 9), bg=Theme.BG_INPUT, fg=Theme.TEXT,
selectbackground=Theme.ACCENT, height=3, relief="flat", bd=0,
highlightthickness=0, exportselection=False)
self.sub_list.pack(fill="x", padx=10, pady=4)
tk.Button(sub_card, text="Preview subtitulo", font=("Segoe UI", 10),
bg=Theme.BG_INPUT, fg=Theme.TEXT, activebackground=Theme.BG_HOVER,
relief="flat", bd=0, cursor="hand2",
command=self._preview_subtitle).pack(fill="x", padx=10, pady=(4, 10), ipady=6)
self._label("Links Directos (URLs de video)")
url_card = tk.Frame(self.main, bg=Theme.BG_CARD)
url_card.pack(fill="x", padx=20, pady=(0, 10))
url_input = tk.Frame(url_card, bg=Theme.BG_CARD)
url_input.pack(fill="x", padx=10, pady=10)
self.url_entry = tk.Entry(url_input, font=("Segoe UI", 10), bg=Theme.BG_INPUT, fg=Theme.TEXT,
insertbackground=Theme.TEXT, relief="flat", bd=0)
self.url_entry.pack(side="left", fill="x", expand=True, ipady=10, padx=(0, 8))
self.url_entry.insert(0, "Pegar link directo de video (.mp4, .mkv, etc.)")
self.url_entry.bind("<FocusIn>", lambda e: self.url_entry.delete(0, tk.END) if "Pegar link" in self.url_entry.get() else None)
self.url_entry.bind("<Return>", lambda e: self._add_url())
tk.Button(url_input, text="+ Agregar", font=("Segoe UI", 10, "bold"),
bg=Theme.ACCENT, fg=Theme.TEXT, relief="flat", bd=0, cursor="hand2",
command=self._add_url).pack(side="right", ipady=6, ipadx=12)
self.url_list = tk.Listbox(url_card, font=("Segoe UI", 9), bg=Theme.BG_INPUT, fg=Theme.TEXT,
selectbackground=Theme.ACCENT, height=3, relief="flat", bd=0,
highlightthickness=0)
self.url_list.pack(fill="x", padx=10, pady=(0, 4))
self.url_list.bind("<Double-Button-1>", self._remove_url)
self.urls_count = tk.Label(url_card, text="0 URLs", font=("Segoe UI", 9),
bg=Theme.BG_CARD, fg=Theme.TEXT_DIM)
self.urls_count.pack(anchor="e", padx=10, pady=(0, 8))
self._label("Modo de Conversion")
mode_card = tk.Frame(self.main, bg=Theme.BG_CARD)
mode_card.pack(fill="x", padx=20, pady=(0, 10))
modes = ["Video Copy + Audio MP3", "Video Copy + Audio FLAC", "Video H264 1080p + Audio MP3"]
for mode in modes:
tk.Radiobutton(mode_card, text=mode, variable=self.mode_var, value=mode,
font=("Segoe UI", 10), bg=Theme.BG_CARD, fg=Theme.TEXT,
selectcolor=Theme.BG_INPUT, activebackground=Theme.BG_CARD,
highlightthickness=0, cursor="hand2").pack(anchor="w", padx=14, pady=4)
self._label("Progreso")
progress_card = tk.Frame(self.main, bg=Theme.BG_CARD)
progress_card.pack(fill="x", padx=20, pady=(0, 10))
self.progress_label = tk.Label(progress_card, text="0%", font=("Segoe UI", 24, "bold"),
bg=Theme.BG_CARD, fg=Theme.ACCENT)
self.progress_label.pack(anchor="w", padx=14, pady=(10, 4))
self.progress_bar = AnimatedProgress(progress_card)
self.progress_bar.pack(fill="x", padx=14, pady=4)
self.status_label = tk.Label(progress_card, text="Listo para convertir", font=("Segoe UI", 10),
bg=Theme.BG_CARD, fg=Theme.TEXT_DIM, anchor="w")
self.status_label.pack(fill="x", padx=14, pady=4)
self.log_text = tk.Text(progress_card, font=("Consolas", 9), bg=Theme.BG_INPUT, fg=Theme.TEXT_DIM,
height=8, relief="flat", bd=0, state="disabled", wrap="word", padx=10, pady=8)
self.log_text.pack(fill="x", padx=14, pady=(4, 14))
action_frame = tk.Frame(self.main, bg=Theme.BG)
action_frame.pack(fill="x", padx=20, pady=(6, 20))
self.btn_start = tk.Button(action_frame, text="INICIAR CONVERSION", font=("Segoe UI", 12, "bold"),
bg=Theme.ACCENT, fg=Theme.TEXT, activebackground=Theme.ACCENT_HOVER,
activeforeground=Theme.TEXT, relief="flat", bd=0, cursor="hand2",
command=self._start_conversion)
self.btn_start.pack(fill="x", ipady=14, pady=(0, 8))
self.btn_cancel = tk.Button(action_frame, text="CANCELAR", font=("Segoe UI", 11, "bold"),
bg=Theme.DANGER, fg=Theme.TEXT, relief="flat", bd=0, cursor="hand2",
state="disabled", command=self._cancel_conversion)
self.btn_cancel.pack(fill="x", ipady=10)
def _label(self, text):
frame = tk.Frame(self.main, bg=Theme.BG)
frame.pack(fill="x", padx=20, pady=(12, 6))
tk.Label(frame, text=text, font=("Segoe UI", 11, "bold"),
bg=Theme.BG, fg=Theme.ACCENT).pack(anchor="w")
def _choose_output_dir(self):
dir_path = filedialog.askdirectory(title="Seleccionar carpeta donde guardar los videos convertidos")
if dir_path:
self.output_dir_var.set(dir_path)
self._log(f"Carpeta de salida cambiada a: {dir_path}")
def _browse_files(self):
files = filedialog.askopenfilenames(
title="Seleccionar videos",
filetypes=[("Videos", "*.mp4 *.mkv *.avi *.mov *.webm *.ts"), ("Todos", "*.*")]
)
if files:
for f in files:
if f not in self.files:
self.files.append(f)
self._update_files_ui()
def _update_files_ui(self):
self.files_list.delete(0, tk.END)
for f in self.files:
self.files_list.insert(tk.END, f" {os.path.basename(f)}")
self.files_count.config(text=f"{len(self.files)} archivo{'s' if len(self.files) != 1 else ''}")
if self.files:
self.files_list.selection_set(0)
self._analyze_file(self.files[0])
def _on_file_select(self, e):
sel = self.files_list.curselection()
if sel and self.files:
self._analyze_file(self.files[sel[0]])
def _analyze_file(self, filepath: str):
self.status_label.config(text=f"Analizando: {os.path.basename(filepath)}...")
self.root.update_idletasks()
def analyze():
info = MediaAnalyzer.get_media_info(filepath)
self.root.after(0, lambda: self._show_tracks(info))
threading.Thread(target=analyze, daemon=True).start()
def _show_tracks(self, info: MediaInfo):
self.current_media_info = info
self.audio_list.delete(0, tk.END)
for track in info.audio_tracks:
self.audio_list.insert(tk.END, f" {track.display_name()}")
if info.audio_tracks:
self.audio_info.config(text=f"{len(info.audio_tracks)} pista(s) de audio disponible(s)")
else:
self.audio_info.config(text="Sin pistas de audio")
self.sub_list.delete(0, tk.END)
for track in info.subtitle_tracks:
self.sub_list.insert(tk.END, f" {track.display_name()}")
if info.subtitle_tracks:
self.sub_list.selection_set(0)
self.status_label.config(text="Listo para convertir")
def _preview_subtitle(self):
if not self.current_media_info or not self.current_media_info.subtitle_tracks:
messagebox.showinfo("Info", "No hay subtítulos")
return
sel = self.sub_list.curselection()
idx = sel[0] if sel else 0
self.status_label.config(text="Cargando preview...")
self.root.update_idletasks()
def load():
content = MediaAnalyzer.extract_subtitle_preview(self.current_media_info.path, idx)
self.root.after(0, lambda: self._show_preview(content))
threading.Thread(target=load, daemon=True).start()
def _show_preview(self, content):
self.status_label.config(text="Listo")
win = tk.Toplevel(self.root)
win.title("Preview Subtitulo")
win.geometry("500x400")
win.config(bg=Theme.BG)
text = scrolledtext.ScrolledText(win, font=("Consolas", 10), bg=Theme.BG_INPUT, fg=Theme.TEXT)
text.pack(fill="both", expand=True, padx=16, pady=16)
text.insert("1.0", content)
text.config(state="disabled")
def _remove_file(self, e):
sel = self.files_list.curselection()
if sel:
del self.files[sel[0]]
self._update_files_ui()
def _clear_files(self):
self.files = []
self._update_files_ui()
self.audio_list.delete(0, tk.END)
self.sub_list.delete(0, tk.END)
self.audio_info.config(text="Selecciona un archivo para ver sus pistas")
self.current_media_info = None
def _add_url(self):
url = self.url_entry.get().strip()
if url and url.startswith("http"):
if url not in self.urls:
self.urls.append(url)
display = url if len(url) <= 60 else url[:57] + "..."
self.url_list.insert(tk.END, f" {display}")
self.urls_count.config(text=f"{len(self.urls)} URL{'s' if len(self.urls) != 1 else ''}")
self.url_entry.delete(0, tk.END)
def _remove_url(self, e):
sel = self.url_list.curselection()
if sel:
del self.urls[sel[0]]
self.url_list.delete(sel[0])
self.urls_count.config(text=f"{len(self.urls)} URL{'s' if len(self.urls) != 1 else ''}")
def _log(self, msg):
self.log_text.config(state="normal")
self.log_text.insert(tk.END, f"{msg}\n")
self.log_text.see(tk.END)
self.log_text.config(state="disabled")
def _update_progress(self, value):
self.progress_bar.set_progress(value)
self.progress_label.config(text=f"{int(value)}%")
def _update_status(self, text):
self.status_label.config(text=text)
self._log(text)
def _start_conversion(self):
if not self.files and not self.urls:
messagebox.showwarning("Nada que convertir", "Agrega archivos locales o links directos")
return
output_dir = self.output_dir_var.get()
if not os.path.isdir(output_dir):
messagebox.showerror("Carpeta inválida", "La carpeta de salida no existe o no es válida")
return
self._log(f"Carpeta de salida seleccionada: {output_dir}")
mode = self.mode_var.get()
audio_sel = self.audio_list.curselection()
audio_track = audio_sel[0] if audio_sel else 0
if self.generate_single_var.get():
if not audio_sel:
messagebox.showwarning("Audio requerido", "Selecciona una pista de audio para la versión adicional")
return
extract_sub = self.extract_subs_var.get()
sub_sel = self.sub_list.curselection()
sub_track = sub_sel[0] if sub_sel else 0
self.is_converting = True
self.btn_start.config(state="disabled")
self.btn_cancel.config(state="normal")
self.progress_bar.start_pulse()
thread = threading.Thread(target=self._conversion_thread, args=(
self.files.copy(), self.urls.copy(), mode, output_dir, audio_track,
self.generate_single_var.get(), extract_sub, sub_track
))
thread.daemon = True
thread.start()
def _conversion_thread(self, files, urls, mode, output_dir, audio_track, generate_single, extract_sub, sub_track):
all_sources = [(f, False) for f in files] + [(u, True) for u in urls]
total = len(all_sources)
completed = 0
for source, is_url in all_sources:
if not self.is_converting:
break
self.root.after(0, lambda: self._update_progress(0))
self.converter.convert(
source=source, is_url=is_url, mode=mode, output_dir=output_dir,
selected_audio=audio_track, generate_single=generate_single,
extract_sub=extract_sub and not is_url, sub_index=sub_track,
progress_cb=lambda p: self.root.after(0, lambda: self._update_progress(p)),
status_cb=lambda s: self.root.after(0, lambda: self._update_status(s))
)
completed += 1
self.root.after(0, lambda: self._update_status(f"Progreso total: {completed}/{total}"))
self.root.after(0, self._conversion_finished)
def _conversion_finished(self):
self.is_converting = False
self.btn_start.config(state="normal")
self.btn_cancel.config(state="disabled")
self.progress_bar.stop_pulse()
self._update_progress(100)
self._update_status("¡TODAS LAS CONVERSIONES COMPLETADAS!")
messagebox.showinfo("Éxito", f"Los videos convertidos están en la carpeta seleccionada:\n{self.output_dir_var.get()}")
def _cancel_conversion(self):
self.is_converting = False
self.converter.cancel()
self.progress_bar.stop_pulse()
self.btn_start.config(state="normal")
self.btn_cancel.config(state="disabled")
self._update_status("Conversión cancelada")
def run(self):
self.root.mainloop()
if __name__ == "__main__":
try:
creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
result = subprocess.run(["ffmpeg", "-version"], capture_output=True, creationflags=creationflags)
if result.returncode != 0:
raise Exception()
except:
root = tk.Tk()
root.withdraw()
messagebox.showerror("FFmpeg Requerido",
"FFmpeg no está instalado o no está en el PATH.\n\n"
"Descarga FFmpeg desde: https://ffmpeg.org/download.html")
sys.exit(1)
app = VideoConverterApp()
app.run()