| 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
|
|
|
|
|
|
|
|
|
| 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"
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
| 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}")
|
|
|
|
|
| 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
|
|
|
|
|
| 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)
|
|
|
|
|
| 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
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
| 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")
|
|
|
|
|
| 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() |