# -*- coding: utf-8 -*- import tkinter as tk from tkinter import ttk, filedialog, messagebox import os import threading from queue import Queue, Empty import random 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 from moviepy import VideoFileClip try: from PIL import Image _HAS_PIL = True except Exception: Image = None _HAS_PIL = False def _icon_path(): if getattr(sys, "frozen", False) and getattr(sys, "_MEIPASS", None): return os.path.join(sys._MEIPASS, "asets", "icon.png") return os.path.join(os.path.dirname(os.path.dirname(__file__)), "asets", "icon.png") 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"]) try: _path = _icon_path() if os.path.isfile(_path): self.root.iconphoto(True, tk.PhotoImage(file=_path)) except Exception: pass 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._importing = False self._import_queue = Queue() 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"))) self._canvas_window_id = canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") canvas.configure(yscrollcommand=scrollbar.set) canvas.bind("", lambda e: canvas.itemconfigure(self._canvas_window_id, width=e.width)) 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") row2b = ttk.Frame(out_card) row2b.grid(row=3, column=0, sticky="ew", pady=(10, 0)) row2b.columnconfigure(5, weight=1) ttk.Label(row2b, text="Общий вырез для всех файлов: с").grid(row=0, column=0, padx=(0, 6), pady=2, sticky="w") self.global_cut_start_var = tk.StringVar(value="") ttk.Entry(row2b, textvariable=self.global_cut_start_var, width=10).grid(row=0, column=1, padx=(0, 10), pady=2, sticky="w") ttk.Label(row2b, text="по").grid(row=0, column=2, padx=(0, 6), pady=2, sticky="w") self.global_cut_end_var = tk.StringVar(value="") ttk.Entry(row2b, textvariable=self.global_cut_end_var, width=10).grid(row=0, column=3, padx=(0, 12), pady=2, sticky="w") ttk.Label(row2b, text="(формат: ЧЧ:ММ:СС / ММ:СС / секунды)", style="Subtext.TLabel").grid( row=0, column=4, padx=(0, 6), pady=2, sticky="w" ) row3 = ttk.Frame(out_card) row3.grid(row=4, 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=5, 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): if self._importing: messagebox.showinfo("Импорт", "Импорт файлов уже выполняется.") return files = filedialog.askopenfilenames( title="Выберите видео файлы", filetypes=[ ("Видео", "*.mp4 *.avi *.mov *.mkv *.wmv *.flv *.webm"), ("Все файлы", "*.*") ] ) if not files: return self._start_import(files) def clear_all(self): for item in self.video_items: item.destroy() self.video_items.clear() self.update_status("Список очищен") def _remove_video_item(self, item): if item in self.video_items: self.video_items.remove(item) item.destroy() self._renumber_video_orders() def _renumber_video_orders(self): for idx, item in enumerate(self.video_items, start=1): try: item.set_order(idx) except Exception: pass def _repack_video_items(self): for item in self.video_items: item.pack_forget() item.pack(fill=tk.X, padx=4, pady=4) def _move_video_item_up(self, item): idx = self.video_items.index(item) if item in self.video_items else -1 if idx <= 0: return other = self.video_items[idx - 1] self.video_items[idx - 1], self.video_items[idx] = self.video_items[idx], self.video_items[idx - 1] self._renumber_video_orders() self._repack_video_items() try: item.flash_reorder() other.flash_reorder() except Exception: pass def _move_video_item_down(self, item): idx = self.video_items.index(item) if item in self.video_items else -1 if idx < 0 or idx >= len(self.video_items) - 1: return other = self.video_items[idx + 1] self.video_items[idx + 1], self.video_items[idx] = self.video_items[idx], self.video_items[idx + 1] self._renumber_video_orders() self._repack_video_items() try: item.flash_reorder() other.flash_reorder() except Exception: pass 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. ПОРЯДОК СКЛЕЙКИ ══════════════════════════════════════════════════════════ • У каждого файла есть поле «Порядок». Меньшее число = раньше в итоговом ролике. • Быстрое изменение порядка: кнопки «↑» и «↓» на карточке. • При нажатии «↑/↓» файл перемещается в списке, а изменённые карточки подсвечиваются. • При наведении мыши карточка подсвечивается для удобной навигации. • Если номера порядка совпадают, склейка идёт в текущем порядке отображения списка. ══════════════════════════════════════════════════════════ 3. ЧТО ВЫРЕЗАТЬ ИЗ КАЖДОГО ВИДЕО ══════════════════════════════════════════════════════════ • У каждого файла есть блок «Удалить из видео (исключить эти фрагменты)». • Укажите диапазоны времени, которые нужно ВЫРЕЗАТЬ (удалить). Всё остальное попадёт в итоговое видео. • Формат времени: ЧЧ:ММ:СС или ММ:СС или только секунды (например: 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, склеенные подряд. ══════════════════════════════════════════════════════════ 4. НАСТРОЙКИ ВЫВОДА ══════════════════════════════════════════════════════════ • Профиль: «Свои настройки», «YouTube» или «VK Video» — подставляет рекомендуемые параметры для загрузки на эти платформы (MP4, 1080p, битрейт и т.д.). После выбора профиля параметры можно менять вручную. • Формат: MP4, AVI, MOV, MKV. • Качество: low / medium / high (влияет на битрейт). • FPS: Исходный или 24, 25, 30, 60. • Разрешение: Исходное, 720p или 1080p (масштабирование по высоте). • Общий вырез для всех файлов: можно задать один промежуток времени, который будет удалён из каждого видео (например, удалить интро 00:00:00–00:00:15 у всех файлов сразу). • Кодек (preset): от ultrafast (быстро) до veryslow (медленнее, меньше размер). • Аудио битрейт: 128k–320k. • Имя файла: префикс для итогового файла (к нему добавится дата и время). • «📁 Папка для сохранения» — куда сохранить результат. ══════════════════════════════════════════════════════════ 5. ЗАПУСК ОБРАБОТКИ ══════════════════════════════════════════════════════════ • Нажмите «▶ Выполнить». • Обработка идёт в фоне — окно остаётся отзывчивым, можно смотреть прогресс. • Кнопка «⏹ Остановить» прерывает процесс; незавершённые выходные файлы удаляются автоматически. • После завершения появится сообщение с путём к файлу. Рядом сохраняется лог (_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): win = tk.Toplevel(self.root) win.title("О программе") win.geometry("560x420") win.minsize(460, 340) win.configure(bg=COLORS["bg"]) win.transient(self.root) win.grab_set() root_fr = tk.Frame(win, bg=COLORS["bg"], padx=14, pady=12) root_fr.pack(fill=tk.BOTH, expand=True) card = tk.Frame( root_fr, bg=COLORS["surface"], highlightthickness=1, highlightbackground=COLORS["border"], padx=14, pady=12, ) card.pack(fill=tk.BOTH, expand=True) tk.Label( card, text="🎬 Anime Video Editor", font=("Tahoma", 14, "bold"), fg=COLORS["accent"], bg=COLORS["surface"], ).pack(anchor=tk.W) tk.Label( card, text="Версия 0.0.8", font=("Tahoma", 10), fg=COLORS["subtext"], bg=COLORS["surface"], ).pack(anchor=tk.W, pady=(2, 10)) items = [ ("✨", "Склейка нескольких видео в один файл"), ("✂️", "Вырезка фрагментов для каждого файла"), ("🧩", "Общий интервал вырезки сразу для всех файлов"), ("↕️", "Управление порядком через поле и кнопки ↑/↓"), ("🖼️", "Крупные превью-кадры в списке файлов"), ("⚙️", "Фоновая обработка, прогресс и остановка выполнения"), ] tk.Label( card, text="Возможности", font=("Tahoma", 11, "bold"), fg=COLORS["text"], bg=COLORS["surface"], ).pack(anchor=tk.W, pady=(0, 6)) for icon, text in items: row = tk.Frame(card, bg=COLORS["surface"]) row.pack(fill=tk.X, pady=2) tk.Label( row, text=icon, font=("Tahoma", 11), fg=COLORS["text"], bg=COLORS["surface"], width=2, ).pack(side=tk.LEFT, anchor=tk.NW) tk.Label( row, text=text, font=("Tahoma", 10), fg=COLORS["text"], bg=COLORS["surface"], justify=tk.LEFT, anchor=tk.W, wraplength=470, ).pack(side=tk.LEFT, fill=tk.X, expand=True) tk.Label( card, text="👤 Автор: stirelshka8", font=("Tahoma", 10), fg=COLORS["subtext"], bg=COLORS["surface"], ).pack(anchor=tk.W, pady=(12, 2)) link_row = tk.Frame(card, bg=COLORS["surface"]) link_row.pack(fill=tk.X, pady=(0, 4)) tk.Label( link_row, text="🔗 Проект:", font=("Tahoma", 10), fg=COLORS["subtext"], bg=COLORS["surface"], ).pack(side=tk.LEFT) project_url = "https://git.tuxops.ru/stirelshka8/AnimeVideoEditot" link_lbl = tk.Label( link_row, text=project_url, font=("Tahoma", 10, "underline"), fg=COLORS["accent"], bg=COLORS["surface"], cursor="hand2", ) link_lbl.pack(side=tk.LEFT, padx=(6, 0)) link_lbl.bind("", lambda _e: self._open_url(project_url)) ttk.Button(win, text="Закрыть", command=win.destroy).pack(pady=(0, 12)) @staticmethod def _open_url(url): try: import webbrowser webbrowser.open_new_tab(url) except Exception: pass 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 self._poll_import_queue() if self.root.winfo_exists(): self.root.after(200, self._poll_progress) def _poll_import_queue(self): try: while True: msg = self._import_queue.get_nowait() if not isinstance(msg, tuple): continue kind = msg[0] if kind == "import_progress": _, done_count, total_count, text = msg pct = (done_count / total_count) if total_count else 1.0 self.progress_var.set(pct * 100) self.status_var.set(text) elif kind == "import_item": _, item, duration, thumb_image, error_text = msg item.set_media_info(duration=duration, thumb_image=thumb_image, error_text=error_text) elif kind == "import_done": _, total_count = msg self._importing = False self.progress_var.set(0) self.update_status(f"Импорт завершён. Добавлено файлов: {total_count}") except Empty: pass 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._importing: messagebox.showwarning("Импорт", "Дождитесь завершения импорта файлов.") return if self._processing: return if not self.video_items: messagebox.showerror("Ошибка", "Добавьте хотя бы один видео файл") return global_cut = self._get_global_cut_range() if isinstance(global_cut, str): messagebox.showerror("Ошибка", global_cut) return ordered_items = [] order_values = [] for idx, item in enumerate(self.video_items): try: order = item.get_order() except ValueError as e: messagebox.showerror("Ошибка", f"Неверный порядок для файла {os.path.basename(item.file_path)}: {e}") return order_values.append(order) ordered_items.append((order, idx, item)) if len(set(order_values)) != len(order_values): messagebox.showwarning( "Внимание", "Обнаружены одинаковые номера порядка. Файлы с одинаковым номером будут склеены в текущем порядке списка." ) ordered_items.sort(key=lambda x: (x[0], x[1])) video_data = [] for _, _, item in ordered_items: time_ranges = list(item.get_time_ranges()) if global_cut is not None: time_ranges.append(global_cut) video_data.append({"path": item.file_path, "time_ranges": 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() def _start_import(self, files): self._importing = True start_order = len(self.video_items) + 1 new_items = [] for idx, path in enumerate(files): item = VideoItem( self.scrollable_frame, path, on_remove=self._remove_video_item, on_move_up=self._move_video_item_up, on_move_down=self._move_video_item_down, order=start_order + idx, ) item.pack(fill=tk.X, padx=4, pady=4) self.video_items.append(item) new_items.append(item) self.update_status(f"Импорт файлов: 0/{len(new_items)}") self.progress_var.set(0) def importer_worker(items): total = len(items) for i, item in enumerate(items, start=1): duration = 0 thumb_image = None error_text = None try: with VideoFileClip(item.file_path) as clip: duration = float(clip.duration or 0) if duration > 0: sample_t = random.uniform(0.15, 0.85) * duration frame = clip.get_frame(sample_t) if _HAS_PIL: thumb_image = Image.fromarray(frame).convert("RGB") thumb_image.thumbnail((360, 202), Image.Resampling.LANCZOS) else: error_text = "Ошибка чтения видео" except Exception: error_text = "Ошибка чтения видео" self._import_queue.put(("import_item", item, duration, thumb_image, error_text)) self._import_queue.put(("import_progress", i, total, f"Импорт файлов: {i}/{total}")) self._import_queue.put(("import_done", total)) threading.Thread(target=importer_worker, args=(new_items,), daemon=True).start() @staticmethod def _parse_time_value(time_str): value = (time_str or "").strip() if not value: return None parts = value.split(":") if len(parts) == 3: return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2]) if len(parts) == 2: return int(parts[0]) * 60 + float(parts[1]) return float(parts[0]) def _get_global_cut_range(self): start_raw = self.global_cut_start_var.get() end_raw = self.global_cut_end_var.get() if not (start_raw or "").strip() and not (end_raw or "").strip(): return None try: start = self._parse_time_value(start_raw) end = self._parse_time_value(end_raw) except (ValueError, TypeError): return "Неверный формат общего интервала вырезки. Используйте ЧЧ:ММ:СС, ММ:СС или секунды." if start is None or end is None: return "Заполните оба поля общего интервала вырезки: начало и конец." if start < 0 or end < 0: return "Общий интервал вырезки не может быть отрицательным." if start >= end: return "Для общего интервала вырезки начало должно быть меньше конца." return (start, end)