875 lines
44 KiB
Python
875 lines
44 KiB
Python
# -*- 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: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("<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)
|