# -*- coding: utf-8 -*- import tkinter as tk from tkinter import ttk, filedialog, messagebox import os import threading from queue import Queue, Empty from gui.theme import setup_theme, COLORS from gui.video_item import VideoItem from core.video_processor import VideoProcessor, CancelledError from core.logger import Logger import sys import subprocess class MainWindow: def __init__(self, root): self.root = root self.root.title("Anime Video Editor") self.root.geometry("1000x780") self.root.minsize(800, 600) self.root.configure(bg=COLORS["bg"]) setup_theme(root) self.video_items = [] self.logger = Logger() self._processing = False self._progress_queue = Queue() self._progress_win = None self._progress_text = None self._progress_bar_var = None self._progress_stage_var = None self._progress_close_btn = None self._progress_stop_btn = None self._cancel_event = None self._setup_menu() self.setup_ui() self._poll_progress() def _setup_menu(self): menubar = tk.Menu(self.root) self.root.config(menu=menubar) help_menu = tk.Menu(menubar, tearoff=0) menubar.add_cascade(label="Справка", menu=help_menu) help_menu.add_command(label="Инструкция", command=self.show_instruction) help_menu.add_separator() help_menu.add_command(label="О программе", command=self.show_about) def setup_ui(self): main = ttk.Frame(self.root, padding=(16, 12)) main.grid(row=0, column=0, sticky="nsew") self.root.columnconfigure(0, weight=1) self.root.rowconfigure(0, weight=1) main.columnconfigure(0, weight=1) main.rowconfigure(2, weight=1) # Заголовок header = ttk.Frame(main) header.grid(row=0, column=0, sticky="ew", pady=(0, 12)) header.columnconfigure(0, weight=1) ttk.Label(header, text="Anime Video Editor", style="Header.TLabel").grid(row=0, column=0, sticky="w") ttk.Label(header, text="Объединение и нарезка видео", style="Subtext.TLabel").grid(row=1, column=0, sticky="w") # Панель: добавить / очистить toolbar = ttk.Frame(main) toolbar.grid(row=1, column=0, sticky="ew", pady=(0, 8)) toolbar.columnconfigure(1, weight=1) ttk.Button(toolbar, text="➕ Добавить файлы", command=self.add_video_files).grid(row=0, column=0, padx=(0, 8)) ttk.Button(toolbar, text="Очистить всё", command=self.clear_all).grid(row=0, column=1, padx=(0, 8), sticky="w") ttk.Button(toolbar, text="❓ Справка", command=self.show_instruction).grid(row=0, column=2, sticky="w") # Список видео (прокрутка) list_card = ttk.LabelFrame(main, text=" Видео в проекте ", style="Card.TLabelframe", padding=(10, 8)) list_card.grid(row=2, column=0, sticky="nsew", pady=(0, 12)) list_card.columnconfigure(0, weight=1) list_card.rowconfigure(0, weight=1) canvas = tk.Canvas( list_card, bg=COLORS["surface"], highlightthickness=0, ) scrollbar = ttk.Scrollbar(list_card) self.scrollable_frame = tk.Frame(canvas, bg=COLORS["surface"]) self.scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") canvas.configure(yscrollcommand=scrollbar.set) def _on_mousewheel(event): canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") canvas.bind("", lambda e: canvas.bind_all("", _on_mousewheel)) canvas.bind("", lambda e: canvas.unbind_all("")) canvas.grid(row=0, column=0, sticky="nsew") scrollbar.grid(row=0, column=1, sticky="ns") # Настройки вывода (расширенные) out_card = ttk.LabelFrame(main, text=" Настройки вывода ", style="Card.TLabelframe", padding=(12, 10)) out_card.grid(row=3, column=0, sticky="ew", pady=(0, 12)) out_card.columnconfigure(0, weight=1) row0 = ttk.Frame(out_card) row0.grid(row=0, column=0, sticky="ew", pady=(0, 8)) row0.columnconfigure(1, weight=1) ttk.Label(row0, text="Профиль").grid(row=0, column=0, padx=(0, 6), pady=2, sticky="w") self.profile_var = tk.StringVar(value="custom") self._profile_combo = ttk.Combobox(row0, textvariable=self.profile_var, width=18, values=["Свои настройки", "YouTube", "VK Video"], state="readonly") self._profile_combo.grid(row=0, column=1, padx=(0, 16), pady=2, sticky="w") self._profile_combo.bind("<>", self._on_profile_changed) row1 = ttk.Frame(out_card) row1.grid(row=1, column=0, sticky="ew", pady=(0, 4)) row1.columnconfigure(1, weight=1) ttk.Label(row1, text="Формат").grid(row=0, column=0, padx=(0, 6), pady=2, sticky="w") self.format_var = tk.StringVar(value="mp4") fmt_combo = ttk.Combobox(row1, textvariable=self.format_var, width=8, values=["mp4", "avi", "mov", "mkv"], state="readonly") fmt_combo.grid(row=0, column=1, padx=(0, 16), pady=2, sticky="w") ttk.Label(row1, text="Качество").grid(row=0, column=2, padx=(0, 6), pady=2, sticky="w") self.quality_var = tk.StringVar(value="high") ttk.Combobox(row1, textvariable=self.quality_var, width=10, values=["low", "medium", "high"], state="readonly").grid(row=0, column=3, padx=(0, 16), pady=2, sticky="w") ttk.Label(row1, text="FPS").grid(row=0, column=4, padx=(0, 6), pady=2, sticky="w") self.fps_var = tk.StringVar(value="Исходный") ttk.Combobox(row1, textvariable=self.fps_var, width=10, values=["Исходный", "24", "25", "30", "60"], state="readonly").grid(row=0, column=5, padx=(0, 16), pady=2, sticky="w") ttk.Label(row1, text="Разрешение").grid(row=0, column=6, padx=(0, 6), pady=2, sticky="w") self.resolution_var = tk.StringVar(value="Исходное") ttk.Combobox(row1, textvariable=self.resolution_var, width=12, values=["Исходное", "720p", "1080p"], state="readonly").grid(row=0, column=7, padx=(0, 16), pady=2, sticky="w") row2 = ttk.Frame(out_card) row2.grid(row=2, column=0, sticky="ew", pady=(10, 0)) row2.columnconfigure(1, weight=1) ttk.Label(row2, text="Кодек (preset)").grid(row=0, column=0, padx=(0, 6), pady=2, sticky="w") self.preset_var = tk.StringVar(value="medium") ttk.Combobox(row2, textvariable=self.preset_var, width=12, values=["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"], state="readonly").grid(row=0, column=1, padx=(0, 16), pady=2, sticky="w") ttk.Label(row2, text="Аудио битрейт").grid(row=0, column=2, padx=(0, 6), pady=2, sticky="w") self.audio_bitrate_var = tk.StringVar(value="192k") self._audio_combo = ttk.Combobox(row2, textvariable=self.audio_bitrate_var, width=8, values=["128k", "192k", "256k", "320k", "384k"], state="readonly") self._audio_combo.grid(row=0, column=3, padx=(0, 16), pady=2, sticky="w") ttk.Label(row2, text="Имя файла").grid(row=0, column=4, padx=(0, 6), pady=2, sticky="w") self.filename_prefix_var = tk.StringVar(value="edited_video") ttk.Entry(row2, textvariable=self.filename_prefix_var, width=18).grid(row=0, column=5, padx=(0, 16), pady=2, sticky="w") row3 = ttk.Frame(out_card) row3.grid(row=3, column=0, sticky="ew", pady=(10, 0)) row3.columnconfigure(1, weight=1) ttk.Button(row3, text="📁 Папка для сохранения", command=self.select_output_dir).grid(row=0, column=0, padx=(0, 12), pady=4) self.output_dir_var = tk.StringVar(value=os.getcwd()) dir_label = ttk.Label(row3, textvariable=self.output_dir_var, style="Subtext.TLabel") dir_label.grid(row=0, column=1, sticky="w", pady=4) dir_label.configure(background=COLORS["surface"]) # Чекбоксы: открыть папку, закрыть окно выполнения, открыть лог row4 = ttk.Frame(out_card) row4.grid(row=4, column=0, sticky="ew", pady=(10, 0)) self.open_folder_var = tk.BooleanVar(value=True) ttk.Checkbutton(row4, text="Открыть папку с итоговым файлом после завершения", variable=self.open_folder_var).grid(row=0, column=0, sticky="w", padx=(0, 20)) self.close_progress_win_var = tk.BooleanVar(value=False) ttk.Checkbutton(row4, text="Закрыть окно выполнения после успеха", variable=self.close_progress_win_var).grid(row=0, column=1, sticky="w", padx=(0, 20)) self.open_log_var = tk.BooleanVar(value=False) ttk.Checkbutton(row4, text="Открыть папку с логом после завершения", variable=self.open_log_var).grid(row=0, column=2, sticky="w") # Кнопка выполнения и прогресс action_frame = ttk.Frame(main) action_frame.grid(row=4, column=0, sticky="ew", pady=(0, 8)) action_frame.columnconfigure(0, weight=1) self.run_button = ttk.Button(action_frame, text="▶ Выполнить", style="Accent.TButton", command=self.process_videos) self.run_button.grid(row=0, column=0, pady=(0, 8)) self.progress_var = tk.DoubleVar() self.progress_bar = ttk.Progressbar(action_frame, variable=self.progress_var, maximum=100) self.progress_bar.grid(row=1, column=0, sticky="ew", pady=(0, 4)) action_frame.columnconfigure(0, weight=1) self.status_var = tk.StringVar(value="Готов к работе") ttk.Label(action_frame, textvariable=self.status_var, style="Subtext.TLabel").grid(row=2, column=0, sticky="w") def add_video_files(self): files = filedialog.askopenfilenames( title="Выберите видео файлы", filetypes=[ ("Видео", "*.mp4 *.avi *.mov *.mkv *.wmv *.flv *.webm"), ("Все файлы", "*.*") ] ) for path in files: item = VideoItem(self.scrollable_frame, path, on_remove=lambda i: self.video_items.remove(i)) item.pack(fill=tk.X, padx=4, pady=4) self.video_items.append(item) self.update_status(f"Добавлено файлов: {len(files)}") def clear_all(self): for item in self.video_items: item.destroy() self.video_items.clear() self.update_status("Список очищен") def select_output_dir(self): d = filedialog.askdirectory(title="Папка для сохранения") if d: self.output_dir_var.set(d) # Рекомендуемые настройки для платформ (YouTube, VK Video) OUTPUT_PRESETS = { "YouTube": { "format": "mp4", "quality": "high", "fps": "30", "resolution": "1080p", "preset": "medium", "audio_bitrate": "384k", "filename_prefix": "youtube", }, "VK Video": { "format": "mp4", "quality": "high", "fps": "30", "resolution": "1080p", "preset": "medium", "audio_bitrate": "256k", "filename_prefix": "vk_video", }, } def _on_profile_changed(self, event=None): profile = self.profile_var.get() if profile == "Свои настройки" or profile not in self.OUTPUT_PRESETS: return preset = self.OUTPUT_PRESETS.get(profile) if not preset: return self.format_var.set(preset.get("format", self.format_var.get())) self.quality_var.set(preset.get("quality", self.quality_var.get())) self.fps_var.set(preset.get("fps", self.fps_var.get())) self.resolution_var.set(preset.get("resolution", self.resolution_var.get())) self.preset_var.set(preset.get("preset", self.preset_var.get())) self.audio_bitrate_var.set(preset.get("audio_bitrate", self.audio_bitrate_var.get())) self.filename_prefix_var.set(preset.get("filename_prefix", self.filename_prefix_var.get())) self.update_status(f"Применён профиль: {profile}") def update_status(self, msg): self.status_var.set(msg) self.root.update_idletasks() def _open_progress_window(self): """Открывает окно процесса выполнения (лог + прогресс).""" if self._progress_win is not None and self._progress_win.winfo_exists(): self._progress_win.destroy() win = tk.Toplevel(self.root) win.title("Выполнение") win.geometry("480x320") win.minsize(360, 240) win.configure(bg=COLORS["bg"]) win.transient(self.root) self._progress_win = win fr = tk.Frame(win, bg=COLORS["bg"], padx=12, pady=10) fr.pack(fill=tk.BOTH, expand=True) tk.Label(fr, text="Процесс выполнения", font=("Tahoma", 11, "bold"), fg=COLORS["accent"], bg=COLORS["bg"]).pack(anchor=tk.W) self._progress_stage_var = tk.StringVar(value="Этап 1/3: Ожидание...") tk.Label(fr, textvariable=self._progress_stage_var, font=("Tahoma", 9), fg=COLORS["text"], bg=COLORS["bg"]).pack(anchor=tk.W, pady=(2, 4)) self._progress_bar_var = tk.DoubleVar(value=0) pbar = ttk.Progressbar(fr, variable=self._progress_bar_var, maximum=100) pbar.pack(fill=tk.X, pady=(0, 8)) log_label = tk.Label(fr, text="Лог:", font=("Tahoma", 9), fg=COLORS["subtext"], bg=COLORS["bg"]) log_label.pack(anchor=tk.W) text_fr = tk.Frame(fr, bg=COLORS["surface"]) text_fr.pack(fill=tk.BOTH, expand=True, pady=(4, 8)) self._progress_text = tk.Text(text_fr, wrap=tk.WORD, font=("Consolas", 9), height=10, bg=COLORS["surface"], fg=COLORS["text"], insertbackground=COLORS["text"], relief=tk.FLAT, padx=8, pady=6) scroll = ttk.Scrollbar(text_fr) self._progress_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scroll.pack(side=tk.RIGHT, fill=tk.Y) self._progress_text.configure(yscrollcommand=scroll.set) scroll.configure(command=self._progress_text.yview) self._progress_text.insert(tk.END, "Старт обработки...\n") self._progress_text.config(state=tk.DISABLED) btn_fr = tk.Frame(fr, bg=COLORS["bg"]) btn_fr.pack(fill=tk.X) self._progress_stop_btn = ttk.Button(btn_fr, text="⏹ Остановить", command=self._request_stop) self._progress_stop_btn.pack(side=tk.LEFT, padx=(0, 12)) self._progress_close_btn = ttk.Button(btn_fr, text="Закрыть", state="disabled", command=self._close_progress_window) self._progress_close_btn.pack(side=tk.RIGHT) win.protocol("WM_DELETE_WINDOW", lambda: None) # запрет закрытия крестиком до конца def _append_progress_log(self, msg): if self._progress_text is None: return try: self._progress_text.config(state=tk.NORMAL) self._progress_text.insert(tk.END, msg + "\n") self._progress_text.see(tk.END) self._progress_text.config(state=tk.DISABLED) except Exception: pass def _request_stop(self): """Запрос остановки выполнения (устанавливает флаг для worker).""" if self._cancel_event is not None: self._cancel_event.set() if self._progress_stop_btn is not None: self._progress_stop_btn.config(state="disabled", text="Остановка...") def _open_folder(self, path): """Открыть папку в проводнике / файловом менеджере ОС.""" folder = os.path.dirname(os.path.abspath(path)) if not os.path.isdir(folder): return if sys.platform == "win32": os.startfile(folder) elif sys.platform == "darwin": subprocess.run(["open", folder], check=False) else: subprocess.run(["xdg-open", folder], check=False) def _close_progress_window(self): if self._progress_win is not None: try: self._progress_win.destroy() except Exception: pass self._progress_win = None self._progress_text = None self._progress_bar_var = None self._progress_stage_var = None self._progress_close_btn = None self._progress_stop_btn = None INSTRUCTION_TEXT = """ Anime Video Editor — объединение нескольких видео в один файл с возможностью вырезать ненужные фрагменты. ══════════════════════════════════════════════════════════ 1. ДОБАВЛЕНИЕ ВИДЕО ══════════════════════════════════════════════════════════ • Нажмите «➕ Добавить файлы» и выберите один или несколько видео (MP4, AVI, MOV, MKV и др.). • Файлы появятся в списке «Видео в проекте». Порядок в списке = порядок в итоговом ролике. • Удалить файл из проекта: кнопка «✕» справа от имени файла. • «Очистить всё» — удаляет все файлы из списка. ══════════════════════════════════════════════════════════ 2. ЧТО ВЫРЕЗАТЬ ИЗ КАЖДОГО ВИДЕО ══════════════════════════════════════════════════════════ • У каждого файла есть блок «Удалить из видео (исключить эти фрагменты)». • Укажите диапазоны времени, которые нужно ВЫРЕЗАТЬ (удалить). Всё остальное попадёт в итоговое видео. • Формат времени: ЧЧ:ММ:СС или ММ:СС или только секунды (например: 00:01:30, 1:30, 90). • Кнопка «+ Добавить диапазон на удаление» — добавить ещё один фрагмент на вырезку. • Если не добавлять ни одного диапазона — в результат попадёт всё видео целиком. • Пример: ролик 1 минута, удалить с 0:10 по 0:20 и с 0:40 по 0:50 → в итоге будут куски 0:00–0:10, 0:20–0:40, 0:50–1:00, склеенные подряд. ══════════════════════════════════════════════════════════ 3. НАСТРОЙКИ ВЫВОДА ══════════════════════════════════════════════════════════ • Профиль: «Свои настройки», «YouTube» или «VK Video» — подставляет рекомендуемые параметры для загрузки на эти платформы (MP4, 1080p, битрейт и т.д.). После выбора профиля параметры можно менять вручную. • Формат: MP4, AVI, MOV, MKV. • Качество: low / medium / high (влияет на битрейт). • FPS: Исходный или 24, 25, 30, 60. • Разрешение: Исходное, 720p или 1080p (масштабирование по высоте). • Кодек (preset): от ultrafast (быстро) до veryslow (медленнее, меньше размер). • Аудио битрейт: 128k–320k. • Имя файла: префикс для итогового файла (к нему добавится дата и время). • «📁 Папка для сохранения» — куда сохранить результат. ══════════════════════════════════════════════════════════ 4. ЗАПУСК ОБРАБОТКИ ══════════════════════════════════════════════════════════ • Нажмите «▶ Выполнить». • Обработка идёт в фоне — окно остаётся отзывчивым, можно смотреть прогресс. • После завершения появится сообщение с путём к файлу. Рядом сохраняется лог (_log.txt). • При большом количестве файлов (больше 3) включается режим с меньшим потреблением памяти. """ def show_instruction(self): win = tk.Toplevel(self.root) win.title("Инструкция — Anime Video Editor") win.geometry("560x520") win.minsize(400, 300) win.configure(bg=COLORS["bg"]) win.transient(self.root) win.grab_set() fr = tk.Frame(win, bg=COLORS["bg"], padx=12, pady=12) fr.pack(fill=tk.BOTH, expand=True) text = tk.Text(fr, wrap=tk.WORD, font=("Tahoma", 10), bg=COLORS["surface"], fg=COLORS["text"], insertbackground=COLORS["text"], relief=tk.FLAT, padx=10, pady=10) scroll = ttk.Scrollbar(fr) text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scroll.pack(side=tk.RIGHT, fill=tk.Y) text.configure(yscrollcommand=scroll.set) scroll.configure(command=text.yview) text.insert(tk.END, self.INSTRUCTION_TEXT.strip()) text.config(state=tk.DISABLED) ttk.Button(win, text="Закрыть", command=win.destroy).pack(pady=(0, 10)) def show_about(self): messagebox.showinfo( "О программе", "Anime Video Editor ver. 0.0.8\n\n" "Объединение и нарезка видео: несколько файлов в один с вырезкой указанных фрагментов.\n\n" "Обработка в фоне, поддержка больших и множества файлов." "Автор - stirelshka8" "Страница проекта - https://git.tuxops.ru/stirelshka8/AnimeVideoEditot" ) def _poll_progress(self): """Обработка очереди прогресса из фонового потока (только в main thread).""" try: while True: msg = self._progress_queue.get_nowait() if msg == "done": continue if isinstance(msg, tuple): if len(msg) == 3 and msg[0] == "result": _, success, payload = msg self._on_processing_done(success, payload) elif len(msg) == 3 and isinstance(msg[0], int): stage, p, text = msg self.progress_var.set(p * 100) self.status_var.set(text) if self._progress_bar_var is not None: self._progress_bar_var.set(p * 100) if self._progress_stage_var is not None: self._progress_stage_var.set(f"Этап {stage}/3: {text}") self._append_progress_log(text) except Empty: pass if self.root.winfo_exists(): self.root.after(200, self._poll_progress) def _on_processing_done(self, success, payload): self._processing = False self._cancel_event = None self.run_button.config(state="normal", text="▶ Выполнить") self.progress_var.set(100 if success else 0) if success: output_path = payload fmt = self.format_var.get() log_path = output_path.replace(f".{fmt}", "_log.txt") self.logger.save_log(log_path) self.status_var.set(f"Готово: {output_path}") self._append_progress_log("Готово. Файл: " + output_path) messagebox.showinfo("Успех", f"Обработка завершена.\nФайл: {output_path}") if self.open_folder_var.get() or self.open_log_var.get(): self._open_folder(output_path) if self.close_progress_win_var.get(): self._close_progress_window() else: err = payload self.status_var.set(err) if err == "Отменено пользователем": self.logger.log("INFO", err) self._append_progress_log(err) messagebox.showinfo("Остановлено", "Выполнение остановлено пользователем.") else: self.logger.log("ERROR", err) self._append_progress_log("Ошибка: " + err) messagebox.showerror("Ошибка", err) if self._progress_bar_var is not None: self._progress_bar_var.set(100 if success else 0) if self._progress_win is not None and self._progress_win.winfo_exists(): self._progress_win.protocol("WM_DELETE_WINDOW", self._close_progress_window) self._progress_win.title("Выполнение завершено") if self._progress_close_btn is not None: self._progress_close_btn.config(state="normal") if self._progress_stop_btn is not None: self._progress_stop_btn.config(state="disabled", text="⏹ Остановить") self.root.after(0, lambda: self.progress_var.set(0)) def process_videos(self): if self._processing: return if not self.video_items: messagebox.showerror("Ошибка", "Добавьте хотя бы один видео файл") return video_data = [] for item in self.video_items: video_data.append({"path": item.file_path, "time_ranges": item.get_time_ranges()}) fps_val = self.fps_var.get() fps = None if fps_val == "Исходный" else int(fps_val) res_val = self.resolution_var.get() target_height = 720 if res_val == "720p" else (1080 if res_val == "1080p" else None) output_dir = self.output_dir_var.get() output_format = self.format_var.get() quality = self.quality_var.get() preset = self.preset_var.get() audio_bitrate = self.audio_bitrate_var.get() filename_prefix = (self.filename_prefix_var.get() or "edited_video").strip() progress_queue = self._progress_queue logger = self.logger cancel_event = threading.Event() self._cancel_event = cancel_event def worker(): try: processor = VideoProcessor(logger=logger) def on_progress(stage, progress, message): progress_queue.put((stage, progress, message)) output_path = processor.process_videos( video_data=video_data, output_dir=output_dir, output_format=output_format, quality=quality, progress_callback=on_progress, fps=fps, preset=preset, audio_bitrate=audio_bitrate, target_height=target_height, filename_prefix=filename_prefix, cancel_check=cancel_event.is_set, ) progress_queue.put(("result", True, output_path)) except CancelledError: progress_queue.put(("result", False, "Отменено пользователем")) except Exception as e: progress_queue.put(("result", False, str(e))) finally: progress_queue.put("done") self._processing = True self.run_button.config(state="disabled", text="Работает...") self.status_var.set("Обработка...") self.progress_var.set(0) self._open_progress_window() t = threading.Thread(target=worker, daemon=True) t.start()