AnimeVideoEditot/gui/main_window.py
stirelshka8 b8211b5220 UPD
2026-03-24 14:38:40 +03:00

875 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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("<Configure>", 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("<Configure>", 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("<Enter>", lambda e: canvas.bind_all("<MouseWheel>", _on_mousewheel))
canvas.bind("<Leave>", lambda e: canvas.unbind_all("<MouseWheel>"))
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("<<ComboboxSelected>>", 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:000:10, 0:200:40, 0:501:00, склеенные подряд.
══════════════════════════════════════════════════════════
4. НАСТРОЙКИ ВЫВОДА
══════════════════════════════════════════════════════════
• Профиль: «Свои настройки», «YouTube» или «VK Video» — подставляет рекомендуемые параметры для загрузки на эти платформы (MP4, 1080p, битрейт и т.д.). После выбора профиля параметры можно менять вручную.
• Формат: MP4, AVI, MOV, MKV.
• Качество: low / medium / high (влияет на битрейт).
• FPS: Исходный или 24, 25, 30, 60.
• Разрешение: Исходное, 720p или 1080p (масштабирование по высоте).
• Общий вырез для всех файлов: можно задать один промежуток времени, который будет удалён из каждого видео (например, удалить интро 00:00:0000:00:15 у всех файлов сразу).
• Кодек (preset): от ultrafast (быстро) до veryslow (медленнее, меньше размер).
• Аудио битрейт: 128k320k.
• Имя файла: префикс для итогового файла (к нему добавится дата и время).
• «📁 Папка для сохранения» — куда сохранить результат.
══════════════════════════════════════════════════════════
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("<Button-1>", 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)