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("", 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("", on_configure) canvas.bind("", lambda e: canvas.itemconfig(canvas_window, width=e.width)) canvas.bind_all("", 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("<>", self._on_file_select) self.files_list.bind("", 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("", lambda e: self.url_entry.delete(0, tk.END) if "Pegar link" in self.url_entry.get() else None) self.url_entry.bind("", 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("", 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()