Обновление

This commit is contained in:
stirelshka8-vivos
2026-03-14 19:52:44 +03:00
parent 41beee6283
commit 324fa7440a
14 changed files with 1296 additions and 231 deletions
+479 -99
View File
@@ -1,167 +1,547 @@
# -*- coding: utf-8 -*-
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import os
import threading
from queue import Queue, Empty
from gui.theme import setup_theme, COLORS
from gui.video_item import VideoItem
from core.video_processor import VideoProcessor
from core.video_processor import VideoProcessor, CancelledError
from core.logger import Logger
import sys
import subprocess
class MainWindow:
def __init__(self, root):
self.root = root
self.root.title("Video Editor Pro")
self.root.geometry("900x700")
self.root.title("Anime Video Editor")
self.root.geometry("1000x780")
self.root.minsize(800, 600)
self.root.configure(bg=COLORS["bg"])
setup_theme(root)
self.video_items = []
self.setup_ui()
self.logger = Logger()
self._processing = False
self._progress_queue = Queue()
self._progress_win = None
self._progress_text = None
self._progress_bar_var = None
self._progress_stage_var = None
self._progress_close_btn = None
self._progress_stop_btn = None
self._cancel_event = None
self._setup_menu()
self.setup_ui()
self._poll_progress()
def _setup_menu(self):
menubar = tk.Menu(self.root)
self.root.config(menu=menubar)
help_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Справка", menu=help_menu)
help_menu.add_command(label="Инструкция", command=self.show_instruction)
help_menu.add_separator()
help_menu.add_command(label="О программе", command=self.show_about)
def setup_ui(self):
# Main frame
main_frame = ttk.Frame(self.root, padding="10")
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
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)
# Add files section
files_frame = ttk.LabelFrame(main_frame, text="Видео файлы", padding="5")
files_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
# Заголовок
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")
ttk.Button(files_frame, text="Добавить файлы",
command=self.add_video_files).pack(side=tk.LEFT, padx=(0, 10))
ttk.Button(files_frame, text="Очистить все",
command=self.clear_all).pack(side=tk.LEFT)
# Панель: добавить / очистить
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")
# Scrollable frame for video items
canvas = tk.Canvas(main_frame)
scrollbar = ttk.Scrollbar(main_frame, orient="vertical", command=canvas.yview)
self.scrollable_frame = ttk.Frame(canvas)
# Список видео (прокрутка)
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)
self.scrollable_frame.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
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")))
canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S))
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>"))
# Output settings
output_frame = ttk.LabelFrame(main_frame, text="Настройки вывода", padding="5")
output_frame.grid(row=2, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(10, 0))
canvas.grid(row=0, column=0, sticky="nsew")
scrollbar.grid(row=0, column=1, sticky="ns")
ttk.Label(output_frame, text="Формат:").grid(row=0, column=0, padx=(0, 5))
# Настройки вывода (расширенные)
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")
format_combo = ttk.Combobox(output_frame, textvariable=self.format_var,
values=["mp4", "avi", "mov", "mkv"], state="readonly")
format_combo.grid(row=0, column=1, padx=(0, 20))
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(output_frame, text="Качество:").grid(row=0, column=2, padx=(0, 5))
ttk.Label(row1, text="Качество").grid(row=0, column=2, padx=(0, 6), pady=2, sticky="w")
self.quality_var = tk.StringVar(value="high")
quality_combo = ttk.Combobox(output_frame, textvariable=self.quality_var,
values=["low", "medium", "high"], state="readonly")
quality_combo.grid(row=0, column=3, padx=(0, 20))
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.Button(output_frame, text="Выбрать папку для сохранения",
command=self.select_output_dir).grid(row=0, column=4, padx=(0, 10))
ttk.Label(row1, text="FPS").grid(row=0, column=4, padx=(0, 6), pady=2, sticky="w")
self.fps_var = tk.StringVar(value="Исходный")
ttk.Combobox(row1, textvariable=self.fps_var, width=10,
values=["Исходный", "24", "25", "30", "60"], state="readonly").grid(row=0, column=5, padx=(0, 16), pady=2, sticky="w")
ttk.Label(row1, text="Разрешение").grid(row=0, column=6, padx=(0, 6), pady=2, sticky="w")
self.resolution_var = tk.StringVar(value="Исходное")
ttk.Combobox(row1, textvariable=self.resolution_var, width=12,
values=["Исходное", "720p", "1080p"], state="readonly").grid(row=0, column=7, padx=(0, 16), pady=2, sticky="w")
row2 = ttk.Frame(out_card)
row2.grid(row=2, column=0, sticky="ew", pady=(10, 0))
row2.columnconfigure(1, weight=1)
ttk.Label(row2, text="Кодек (preset)").grid(row=0, column=0, padx=(0, 6), pady=2, sticky="w")
self.preset_var = tk.StringVar(value="medium")
ttk.Combobox(row2, textvariable=self.preset_var, width=12,
values=["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"], state="readonly").grid(row=0, column=1, padx=(0, 16), pady=2, sticky="w")
ttk.Label(row2, text="Аудио битрейт").grid(row=0, column=2, padx=(0, 6), pady=2, sticky="w")
self.audio_bitrate_var = tk.StringVar(value="192k")
self._audio_combo = ttk.Combobox(row2, textvariable=self.audio_bitrate_var, width=8,
values=["128k", "192k", "256k", "320k", "384k"], state="readonly")
self._audio_combo.grid(row=0, column=3, padx=(0, 16), pady=2, sticky="w")
ttk.Label(row2, text="Имя файла").grid(row=0, column=4, padx=(0, 6), pady=2, sticky="w")
self.filename_prefix_var = tk.StringVar(value="edited_video")
ttk.Entry(row2, textvariable=self.filename_prefix_var, width=18).grid(row=0, column=5, padx=(0, 16), pady=2, sticky="w")
row3 = ttk.Frame(out_card)
row3.grid(row=3, column=0, sticky="ew", pady=(10, 0))
row3.columnconfigure(1, weight=1)
ttk.Button(row3, text="📁 Папка для сохранения", command=self.select_output_dir).grid(row=0, column=0, padx=(0, 12), pady=4)
self.output_dir_var = tk.StringVar(value=os.getcwd())
ttk.Label(output_frame, textvariable=self.output_dir_var).grid(row=0, column=5)
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"])
# Process button
ttk.Button(main_frame, text="Выполнить",
command=self.process_videos, style="Accent.TButton").grid(row=3, column=0, pady=20)
# Чекбоксы: открыть папку, закрыть окно выполнения, открыть лог
row4 = ttk.Frame(out_card)
row4.grid(row=4, column=0, sticky="ew", pady=(10, 0))
self.open_folder_var = tk.BooleanVar(value=True)
ttk.Checkbutton(row4, text="Открыть папку с итоговым файлом после завершения",
variable=self.open_folder_var).grid(row=0, column=0, sticky="w", padx=(0, 20))
self.close_progress_win_var = tk.BooleanVar(value=False)
ttk.Checkbutton(row4, text="Закрыть окно выполнения после успеха",
variable=self.close_progress_win_var).grid(row=0, column=1, sticky="w", padx=(0, 20))
self.open_log_var = tk.BooleanVar(value=False)
ttk.Checkbutton(row4, text="Открыть папку с логом после завершения",
variable=self.open_log_var).grid(row=0, column=2, sticky="w")
# Кнопка выполнения и прогресс
action_frame = ttk.Frame(main)
action_frame.grid(row=4, column=0, sticky="ew", pady=(0, 8))
action_frame.columnconfigure(0, weight=1)
self.run_button = ttk.Button(action_frame, text="▶ Выполнить", style="Accent.TButton", command=self.process_videos)
self.run_button.grid(row=0, column=0, pady=(0, 8))
# Progress bar
self.progress_var = tk.DoubleVar()
self.progress_bar = ttk.Progressbar(main_frame, variable=self.progress_var, maximum=100)
self.progress_bar.grid(row=4, column=0, columnspan=2, sticky=(tk.W, tk.E))
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)
# Status label
self.status_var = tk.StringVar(value="Готов к работе")
ttk.Label(main_frame, textvariable=self.status_var).grid(row=5, column=0, columnspan=2)
# Configure grid weights
main_frame.columnconfigure(0, weight=1)
main_frame.rowconfigure(1, weight=1)
ttk.Label(action_frame, textvariable=self.status_var, style="Subtext.TLabel").grid(row=2, column=0, sticky="w")
def add_video_files(self):
files = filedialog.askopenfilenames(
title="Выберите видео файлы",
filetypes=[
("Видео файлы", "*.mp4 *.avi *.mov *.mkv *.wmv *.flv *.webm"),
("Видео", "*.mp4 *.avi *.mov *.mkv *.wmv *.flv *.webm"),
("Все файлы", "*.*")
]
)
for file_path in files:
video_item = VideoItem(self.scrollable_frame, file_path)
video_item.pack(fill=tk.X, padx=5, pady=2)
self.video_items.append(video_item)
for path in files:
item = VideoItem(self.scrollable_frame, path, on_remove=lambda i: self.video_items.remove(i))
item.pack(fill=tk.X, padx=4, pady=4)
self.video_items.append(item)
self.update_status(f"Добавлено файлов: {len(files)}")
def clear_all(self):
for item in self.video_items:
item.destroy()
self.video_items.clear()
self.update_status("Все файлы удалены")
self.update_status("Список очищен")
def select_output_dir(self):
directory = filedialog.askdirectory(title="Выберите папку для сохранения")
if directory:
self.output_dir_var.set(directory)
d = filedialog.askdirectory(title="Папка для сохранения")
if d:
self.output_dir_var.set(d)
def update_status(self, message):
self.status_var.set(message)
self.root.update()
# Рекомендуемые настройки для платформ (YouTube, VK Video)
OUTPUT_PRESETS = {
"YouTube": {
"format": "mp4",
"quality": "high",
"fps": "30",
"resolution": "1080p",
"preset": "medium",
"audio_bitrate": "384k",
"filename_prefix": "youtube",
},
"VK Video": {
"format": "mp4",
"quality": "high",
"fps": "30",
"resolution": "1080p",
"preset": "medium",
"audio_bitrate": "256k",
"filename_prefix": "vk_video",
},
}
def _on_profile_changed(self, event=None):
profile = self.profile_var.get()
if profile == "Свои настройки" or profile not in self.OUTPUT_PRESETS:
return
preset = self.OUTPUT_PRESETS.get(profile)
if not preset:
return
self.format_var.set(preset.get("format", self.format_var.get()))
self.quality_var.set(preset.get("quality", self.quality_var.get()))
self.fps_var.set(preset.get("fps", self.fps_var.get()))
self.resolution_var.set(preset.get("resolution", self.resolution_var.get()))
self.preset_var.set(preset.get("preset", self.preset_var.get()))
self.audio_bitrate_var.set(preset.get("audio_bitrate", self.audio_bitrate_var.get()))
self.filename_prefix_var.set(preset.get("filename_prefix", self.filename_prefix_var.get()))
self.update_status(f"Применён профиль: {profile}")
def update_status(self, msg):
self.status_var.set(msg)
self.root.update_idletasks()
def _open_progress_window(self):
"""Открывает окно процесса выполнения (лог + прогресс)."""
if self._progress_win is not None and self._progress_win.winfo_exists():
self._progress_win.destroy()
win = tk.Toplevel(self.root)
win.title("Выполнение")
win.geometry("480x320")
win.minsize(360, 240)
win.configure(bg=COLORS["bg"])
win.transient(self.root)
self._progress_win = win
fr = tk.Frame(win, bg=COLORS["bg"], padx=12, pady=10)
fr.pack(fill=tk.BOTH, expand=True)
tk.Label(fr, text="Процесс выполнения", font=("Tahoma", 11, "bold"),
fg=COLORS["accent"], bg=COLORS["bg"]).pack(anchor=tk.W)
self._progress_stage_var = tk.StringVar(value="Этап 1/3: Ожидание...")
tk.Label(fr, textvariable=self._progress_stage_var, font=("Tahoma", 9),
fg=COLORS["text"], bg=COLORS["bg"]).pack(anchor=tk.W, pady=(2, 4))
self._progress_bar_var = tk.DoubleVar(value=0)
pbar = ttk.Progressbar(fr, variable=self._progress_bar_var, maximum=100)
pbar.pack(fill=tk.X, pady=(0, 8))
log_label = tk.Label(fr, text="Лог:", font=("Tahoma", 9), fg=COLORS["subtext"], bg=COLORS["bg"])
log_label.pack(anchor=tk.W)
text_fr = tk.Frame(fr, bg=COLORS["surface"])
text_fr.pack(fill=tk.BOTH, expand=True, pady=(4, 8))
self._progress_text = tk.Text(text_fr, wrap=tk.WORD, font=("Consolas", 9), height=10,
bg=COLORS["surface"], fg=COLORS["text"],
insertbackground=COLORS["text"], relief=tk.FLAT, padx=8, pady=6)
scroll = ttk.Scrollbar(text_fr)
self._progress_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scroll.pack(side=tk.RIGHT, fill=tk.Y)
self._progress_text.configure(yscrollcommand=scroll.set)
scroll.configure(command=self._progress_text.yview)
self._progress_text.insert(tk.END, "Старт обработки...\n")
self._progress_text.config(state=tk.DISABLED)
btn_fr = tk.Frame(fr, bg=COLORS["bg"])
btn_fr.pack(fill=tk.X)
self._progress_stop_btn = ttk.Button(btn_fr, text="⏹ Остановить", command=self._request_stop)
self._progress_stop_btn.pack(side=tk.LEFT, padx=(0, 12))
self._progress_close_btn = ttk.Button(btn_fr, text="Закрыть", state="disabled",
command=self._close_progress_window)
self._progress_close_btn.pack(side=tk.RIGHT)
win.protocol("WM_DELETE_WINDOW", lambda: None) # запрет закрытия крестиком до конца
def _append_progress_log(self, msg):
if self._progress_text is None:
return
try:
self._progress_text.config(state=tk.NORMAL)
self._progress_text.insert(tk.END, msg + "\n")
self._progress_text.see(tk.END)
self._progress_text.config(state=tk.DISABLED)
except Exception:
pass
def _request_stop(self):
"""Запрос остановки выполнения (устанавливает флаг для worker)."""
if self._cancel_event is not None:
self._cancel_event.set()
if self._progress_stop_btn is not None:
self._progress_stop_btn.config(state="disabled", text="Остановка...")
def _open_folder(self, path):
"""Открыть папку в проводнике / файловом менеджере ОС."""
folder = os.path.dirname(os.path.abspath(path))
if not os.path.isdir(folder):
return
if sys.platform == "win32":
os.startfile(folder)
elif sys.platform == "darwin":
subprocess.run(["open", folder], check=False)
else:
subprocess.run(["xdg-open", folder], check=False)
def _close_progress_window(self):
if self._progress_win is not None:
try:
self._progress_win.destroy()
except Exception:
pass
self._progress_win = None
self._progress_text = None
self._progress_bar_var = None
self._progress_stage_var = None
self._progress_close_btn = None
self._progress_stop_btn = None
INSTRUCTION_TEXT = """
Anime Video Editor — объединение нескольких видео в один файл с возможностью вырезать ненужные фрагменты.
══════════════════════════════════════════════════════════
1. ДОБАВЛЕНИЕ ВИДЕО
══════════════════════════════════════════════════════════
• Нажмите «➕ Добавить файлы» и выберите один или несколько видео (MP4, AVI, MOV, MKV и др.).
• Файлы появятся в списке «Видео в проекте». Порядок в списке = порядок в итоговом ролике.
• Удалить файл из проекта: кнопка «✕» справа от имени файла.
• «Очистить всё» — удаляет все файлы из списка.
══════════════════════════════════════════════════════════
2. ЧТО ВЫРЕЗАТЬ ИЗ КАЖДОГО ВИДЕО
══════════════════════════════════════════════════════════
• У каждого файла есть блок «Удалить из видео (исключить эти фрагменты)».
• Укажите диапазоны времени, которые нужно ВЫРЕЗАТЬ (удалить). Всё остальное попадёт в итоговое видео.
• Формат времени: ЧЧ:ММ:СС или ММ:СС или только секунды (например: 00:01:30, 1:30, 90).
• Кнопка «+ Добавить диапазон на удаление» — добавить ещё один фрагмент на вырезку.
• Если не добавлять ни одного диапазона — в результат попадёт всё видео целиком.
• Пример: ролик 1 минута, удалить с 0:10 по 0:20 и с 0:40 по 0:50 → в итоге будут куски 0:00–0:10, 0:20–0:40, 0:50–1:00, склеенные подряд.
══════════════════════════════════════════════════════════
3. НАСТРОЙКИ ВЫВОДА
══════════════════════════════════════════════════════════
• Профиль: «Свои настройки», «YouTube» или «VK Video» — подставляет рекомендуемые параметры для загрузки на эти платформы (MP4, 1080p, битрейт и т.д.). После выбора профиля параметры можно менять вручную.
• Формат: MP4, AVI, MOV, MKV.
• Качество: low / medium / high (влияет на битрейт).
• FPS: Исходный или 24, 25, 30, 60.
• Разрешение: Исходное, 720p или 1080p (масштабирование по высоте).
• Кодек (preset): от ultrafast (быстро) до veryslow (медленнее, меньше размер).
• Аудио битрейт: 128k–320k.
• Имя файла: префикс для итогового файла (к нему добавится дата и время).
• «📁 Папка для сохранения» — куда сохранить результат.
══════════════════════════════════════════════════════════
4. ЗАПУСК ОБРАБОТКИ
══════════════════════════════════════════════════════════
• Нажмите «▶ Выполнить».
• Обработка идёт в фоне — окно остаётся отзывчивым, можно смотреть прогресс.
• После завершения появится сообщение с путём к файлу. Рядом сохраняется лог (_log.txt).
• При большом количестве файлов (больше 3) включается режим с меньшим потреблением памяти.
"""
def show_instruction(self):
win = tk.Toplevel(self.root)
win.title("Инструкция — Anime Video Editor")
win.geometry("560x520")
win.minsize(400, 300)
win.configure(bg=COLORS["bg"])
win.transient(self.root)
win.grab_set()
fr = tk.Frame(win, bg=COLORS["bg"], padx=12, pady=12)
fr.pack(fill=tk.BOTH, expand=True)
text = tk.Text(fr, wrap=tk.WORD, font=("Tahoma", 10), bg=COLORS["surface"], fg=COLORS["text"],
insertbackground=COLORS["text"], relief=tk.FLAT, padx=10, pady=10)
scroll = ttk.Scrollbar(fr)
text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scroll.pack(side=tk.RIGHT, fill=tk.Y)
text.configure(yscrollcommand=scroll.set)
scroll.configure(command=text.yview)
text.insert(tk.END, self.INSTRUCTION_TEXT.strip())
text.config(state=tk.DISABLED)
ttk.Button(win, text="Закрыть", command=win.destroy).pack(pady=(0, 10))
def show_about(self):
messagebox.showinfo(
"О программе",
"Anime Video Editor ver. 0.0.8\n\n"
"Объединение и нарезка видео: несколько файлов в один с вырезкой указанных фрагментов.\n\n"
"Обработка в фоне, поддержка больших и множества файлов."
"Автор - stirelshka8"
"Страница проекта - https://git.tuxops.ru/stirelshka8/AnimeVideoEditot"
)
def _poll_progress(self):
"""Обработка очереди прогресса из фонового потока (только в main thread)."""
try:
while True:
msg = self._progress_queue.get_nowait()
if msg == "done":
continue
if isinstance(msg, tuple):
if len(msg) == 3 and msg[0] == "result":
_, success, payload = msg
self._on_processing_done(success, payload)
elif len(msg) == 3 and isinstance(msg[0], int):
stage, p, text = msg
self.progress_var.set(p * 100)
self.status_var.set(text)
if self._progress_bar_var is not None:
self._progress_bar_var.set(p * 100)
if self._progress_stage_var is not None:
self._progress_stage_var.set(f"Этап {stage}/3: {text}")
self._append_progress_log(text)
except Empty:
pass
if self.root.winfo_exists():
self.root.after(200, self._poll_progress)
def _on_processing_done(self, success, payload):
self._processing = False
self._cancel_event = None
self.run_button.config(state="normal", text="▶ Выполнить")
self.progress_var.set(100 if success else 0)
if success:
output_path = payload
fmt = self.format_var.get()
log_path = output_path.replace(f".{fmt}", "_log.txt")
self.logger.save_log(log_path)
self.status_var.set(f"Готово: {output_path}")
self._append_progress_log("Готово. Файл: " + output_path)
messagebox.showinfo("Успех", f"Обработка завершена.\nФайл: {output_path}")
if self.open_folder_var.get() or self.open_log_var.get():
self._open_folder(output_path)
if self.close_progress_win_var.get():
self._close_progress_window()
else:
err = payload
self.status_var.set(err)
if err == "Отменено пользователем":
self.logger.log("INFO", err)
self._append_progress_log(err)
messagebox.showinfo("Остановлено", "Выполнение остановлено пользователем.")
else:
self.logger.log("ERROR", err)
self._append_progress_log("Ошибка: " + err)
messagebox.showerror("Ошибка", err)
if self._progress_bar_var is not None:
self._progress_bar_var.set(100 if success else 0)
if self._progress_win is not None and self._progress_win.winfo_exists():
self._progress_win.protocol("WM_DELETE_WINDOW", self._close_progress_window)
self._progress_win.title("Выполнение завершено")
if self._progress_close_btn is not None:
self._progress_close_btn.config(state="normal")
if self._progress_stop_btn is not None:
self._progress_stop_btn.config(state="disabled", text="⏹ Остановить")
self.root.after(0, lambda: self.progress_var.set(0))
def process_videos(self):
if self._processing:
return
if not self.video_items:
messagebox.showerror("Ошибка", "Добавьте хотя бы один видео файл")
return
# Collect video data
video_data = []
for item in self.video_items:
time_ranges = item.get_time_ranges()
if not time_ranges: # Use entire video if no ranges specified
time_ranges = [(0, item.get_duration())]
video_data.append({
'path': item.file_path,
'time_ranges': time_ranges
})
video_data.append({"path": item.file_path, "time_ranges": item.get_time_ranges()})
output_format = self.format_var.get()
output_quality = self.quality_var.get()
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
try:
processor = VideoProcessor()
def worker():
try:
processor = VideoProcessor(logger=logger)
def progress_callback(progress, message):
self.progress_var.set(progress * 100)
self.update_status(message)
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=output_quality,
progress_callback=progress_callback
)
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")
# Save log
log_path = output_path.replace(f".{output_format}", "_log.txt")
self.logger.save_log(log_path)
self.update_status(f"Готово! Файл сохранен: {output_path}")
messagebox.showinfo("Успех", f"Обработка завершена!\nФайл: {output_path}")
except Exception as e:
error_msg = f"Ошибка обработки: {str(e)}"
self.update_status(error_msg)
self.logger.log("ERROR", error_msg)
messagebox.showerror("Ошибка", error_msg)
finally:
self.progress_var.set(0)
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()
+142
View File
@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
"""Стили и тема приложения — тёмная тема в стиле Catppuccin Mocha."""
import tkinter as tk
from tkinter import ttk
# Палитра Catppuccin Mocha
COLORS = {
"bg": "#1e1e2e", # Base
"surface": "#313244", # Surface0
"overlay": "#45475a", # Surface1
"text": "#cdd6f4", # Text
"subtext": "#a6adc8", # Subtext0
"accent": "#89b4fa", # Blue
"accent_hover": "#b4befe", # Lavender
"green": "#a6e3a1", # Green
"red": "#f38ba8", # Red
"yellow": "#f9e2af", # Yellow
"border": "#585b70", # Surface2
}
def setup_theme(root: tk.Tk):
"""Применяет тёмную тему к окну и настраивает ttk.Style."""
root.configure(bg=COLORS["bg"])
root.option_add("*Font", "Tahoma 10")
style = ttk.Style(root)
style.theme_use("clam")
# Общие
style.configure(
".",
background=COLORS["bg"],
foreground=COLORS["text"],
fieldbackground=COLORS["surface"],
troughcolor=COLORS["overlay"],
borderwidth=0,
)
style.map(".", background=[("active", COLORS["overlay"])])
# Frame / карточки
style.configure(
"Card.TFrame",
background=COLORS["surface"],
relief="flat",
)
style.configure(
"Card.TLabelframe",
background=COLORS["surface"],
foreground=COLORS["text"],
)
style.configure(
"Card.TLabelframe.Label",
background=COLORS["surface"],
foreground=COLORS["accent"],
font="Tahoma 10 bold",
)
# Кнопки
style.configure(
"TButton",
background=COLORS["overlay"],
foreground=COLORS["text"],
padding=(12, 6),
font="Tahoma 9",
)
style.map(
"TButton",
background=[("active", COLORS["border"]), ("pressed", COLORS["accent"])],
foreground=[("active", COLORS["text"])],
)
# Акцентная кнопка (Выполнить)
style.configure(
"Accent.TButton",
background=COLORS["accent"],
foreground=COLORS["bg"],
padding=(20, 10),
font="Tahoma 10 bold",
)
style.map(
"Accent.TButton",
background=[("active", COLORS["accent_hover"]), ("pressed", COLORS["overlay"])],
foreground=[("active", COLORS["bg"])],
)
# Поля ввода
style.configure(
"TEntry",
fieldbackground=COLORS["surface"],
foreground=COLORS["text"],
insertcolor=COLORS["text"],
padding=6,
)
# Combobox
style.configure(
"TCombobox",
fieldbackground=COLORS["surface"],
background=COLORS["overlay"],
foreground=COLORS["text"],
arrowcolor=COLORS["text"],
padding=6,
)
style.map("TCombobox", fieldbackground=[("readonly", COLORS["surface"])])
# Progressbar
style.configure(
"TProgressbar",
background=COLORS["accent"],
troughcolor=COLORS["overlay"],
borderwidth=0,
thickness=8,
)
# Scrollbar
style.configure(
"TScrollbar",
background=COLORS["overlay"],
troughcolor=COLORS["surface"],
borderwidth=0,
arrowcolor=COLORS["text"],
)
style.map("TScrollbar", background=[("active", COLORS["border"])])
# Label — шрифт одной строкой, иначе Tcl воспринимает "UI" как размер
style.configure("TLabel", background=COLORS["bg"], foreground=COLORS["text"])
style.configure(
"Header.TLabel",
background=COLORS["bg"],
foreground=COLORS["text"],
font="Tahoma 16 bold",
)
style.configure(
"Subtext.TLabel",
background=COLORS["surface"],
foreground=COLORS["subtext"],
font="Tahoma 9",
)
return style
+102 -64
View File
@@ -1,108 +1,146 @@
# -*- coding: utf-8 -*-
import tkinter as tk
from tkinter import ttk
import os
from moviepy.editor import VideoFileClip
from moviepy import VideoFileClip
from utils.file_utils import get_file_size
from gui.theme import COLORS
class VideoItem(ttk.Frame):
def __init__(self, parent, file_path):
"""Карточка одного видео в списке с диапазонами и стильным оформлением."""
def __init__(self, parent, file_path, on_remove=None):
super().__init__(parent)
self.file_path = file_path
self.time_ranges = []
self._duration = 0
self.on_remove = on_remove
self.setup_ui()
def setup_ui(self):
# File info
file_name = os.path.basename(self.file_path)
ttk.Label(self, text=file_name, font=('Arial', 9, 'bold')).grid(row=0, column=0, sticky=tk.W)
# Контейнер-карточка с фоном
self.configure(style="Card.TFrame")
card = tk.Frame(self, bg=COLORS["surface"], highlightbackground=COLORS["border"], highlightthickness=1)
card.pack(fill=tk.BOTH, expand=True, padx=0, pady=0)
inner = tk.Frame(card, bg=COLORS["surface"], padx=12, pady=10)
inner.pack(fill=tk.BOTH, expand=True)
# Верхняя строка: имя файла + размер, длительность, кнопка удалить
top = tk.Frame(inner, bg=COLORS["surface"])
top.pack(fill=tk.X, pady=(0, 6))
file_name = os.path.basename(self.file_path)
try:
size_str = get_file_size(self.file_path)
file_info = f"{file_name} · {size_str}"
except Exception:
file_info = file_name
lbl_name = tk.Label(top, text=file_info, font=("Tahoma", 10, "bold"),
fg=COLORS["text"], bg=COLORS["surface"])
lbl_name.pack(side=tk.LEFT)
# Duration info
try:
with VideoFileClip(self.file_path) as clip:
duration = clip.duration
duration_str = self.format_time(duration)
ttk.Label(self, text=f"Длительность: {duration_str}").grid(row=1, column=0, sticky=tk.W)
except:
duration_str = "Неизвестно"
ttk.Label(self, text="Ошибка чтения файла").grid(row=1, column=0, sticky=tk.W)
self._duration = clip.duration
duration_str = self.format_time(self._duration)
lbl_dur = tk.Label(top, text=f" · {duration_str}", font=("Tahoma", 9),
fg=COLORS["subtext"], bg=COLORS["surface"])
lbl_dur.pack(side=tk.LEFT)
except Exception:
lbl_err = tk.Label(top, text=" · Ошибка чтения", font=("Tahoma", 9),
fg=COLORS["red"], bg=COLORS["surface"])
lbl_err.pack(side=tk.LEFT)
# Time ranges frame
ranges_frame = ttk.LabelFrame(self, text="Временные диапазоны")
ranges_frame.grid(row=2, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5)
btn_remove = tk.Button(top, text=" ✕ ", font=("Tahoma", 9), fg=COLORS["text"],
bg=COLORS["overlay"], activebackground=COLORS["red"],
activeforeground=COLORS["text"], relief=tk.FLAT, cursor="hand2",
command=self._on_remove_click)
btn_remove.pack(side=tk.RIGHT)
self.ranges_container = ttk.Frame(ranges_frame)
self.ranges_container.pack(fill=tk.X, padx=5, pady=5)
# Блок диапазонов на удаление (остальное попадёт в итоговое видео)
ranges_lbl = tk.Label(inner, text="Удалить из видео (исключить эти фрагменты)", font=("Tahoma", 9),
fg=COLORS["subtext"], bg=COLORS["surface"])
ranges_lbl.pack(anchor=tk.W, pady=(4, 4))
# Add range button
ttk.Button(ranges_frame, text="+ Добавить диапазон",
command=self.add_time_range).pack(pady=5)
self.ranges_container = tk.Frame(inner, bg=COLORS["surface"])
self.ranges_container.pack(fill=tk.X, pady=(0, 6))
# Buttons
ttk.Button(self, text="Удалить",
command=self.destroy).grid(row=0, column=2, rowspan=2, padx=5)
ttk.Button(inner, text="+ Добавить диапазон на удаление", command=self.add_time_range).pack(anchor=tk.W)
self.columnconfigure(0, weight=1)
def format_time(self, seconds):
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
seconds = int(seconds % 60)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
return f"{h:02d}:{m:02d}:{s:02d}"
def add_time_range(self, start_time=0, end_time=0):
range_frame = ttk.Frame(self.ranges_container)
range_frame.pack(fill=tk.X, pady=2)
row = tk.Frame(self.ranges_container, bg=COLORS["surface"])
row.pack(fill=tk.X, pady=2)
ttk.Label(range_frame, text="От:").pack(side=tk.LEFT)
tk.Label(row, text="Удалить с", font=("Tahoma", 8), fg=COLORS["subtext"], bg=COLORS["surface"]).pack(side=tk.LEFT, padx=(0, 4))
start_var = tk.StringVar(value=self.format_time(start_time))
start_entry = ttk.Entry(range_frame, textvariable=start_var, width=8)
start_entry.pack(side=tk.LEFT, padx=(0, 10))
ent_start = tk.Entry(row, textvariable=start_var, width=10, font=("Consolas", 9),
bg=COLORS["overlay"], fg=COLORS["text"], insertbackground=COLORS["text"],
relief=tk.FLAT, highlightthickness=1, highlightbackground=COLORS["border"])
ent_start.pack(side=tk.LEFT, padx=(0, 12), ipady=4, ipadx=6)
ttk.Label(range_frame, text="До:").pack(side=tk.LEFT)
tk.Label(row, text="по", font=("Tahoma", 8), fg=COLORS["subtext"], bg=COLORS["surface"]).pack(side=tk.LEFT, padx=(0, 4))
end_var = tk.StringVar(value=self.format_time(end_time))
end_entry = ttk.Entry(range_frame, textvariable=end_var, width=8)
end_entry.pack(side=tk.LEFT, padx=(0, 10))
ent_end = tk.Entry(row, textvariable=end_var, width=10, font=("Consolas", 9),
bg=COLORS["overlay"], fg=COLORS["text"], insertbackground=COLORS["text"],
relief=tk.FLAT, highlightthickness=1, highlightbackground=COLORS["border"])
ent_end.pack(side=tk.LEFT, padx=(0, 8), ipady=4, ipadx=6)
def remove_range():
range_frame.destroy()
self.time_ranges = [r for r in self.time_ranges if r['frame'] != range_frame]
row.destroy()
self.time_ranges[:] = [r for r in self.time_ranges if r["frame"] != row]
ttk.Button(range_frame, text="×", width=3,
command=remove_range).pack(side=tk.LEFT)
btn_del = tk.Button(row, text="×", font=("Tahoma", 9), fg=COLORS["subtext"],
bg=COLORS["surface"], activebackground=COLORS["red"],
activeforeground=COLORS["text"], relief=tk.FLAT, cursor="hand2",
command=remove_range)
btn_del.pack(side=tk.LEFT)
self.time_ranges.append({
'frame': range_frame,
'start_var': start_var,
'end_var': end_var
})
self.time_ranges.append({"frame": row, "start_var": start_var, "end_var": end_var})
def get_time_ranges(self):
ranges = []
for range_data in self.time_ranges:
out = []
for r in self.time_ranges:
try:
start_time = self.parse_time(range_data['start_var'].get())
end_time = self.parse_time(range_data['end_var'].get())
if start_time < end_time:
ranges.append((start_time, end_time))
except:
start = self.parse_time(r["start_var"].get())
end = self.parse_time(r["end_var"].get())
if start < end:
out.append((start, end))
except (ValueError, TypeError):
continue
return ranges
return out
def parse_time(self, time_str):
parts = time_str.split(':')
if len(parts) == 3: # HH:MM:SS
hours, minutes, seconds = map(int, parts)
return hours * 3600 + minutes * 60 + seconds
elif len(parts) == 2: # MM:SS
minutes, seconds = map(int, parts)
return minutes * 60 + seconds
else: # SS
return int(time_str)
time_str = (time_str or "").strip()
if not time_str:
return 0
parts = time_str.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 _on_remove_click(self):
if self.on_remove:
self.on_remove(self)
self.destroy()
def get_duration(self):
if self._duration > 0:
return self._duration
try:
with VideoFileClip(self.file_path) as clip:
return clip.duration
except:
return 0
except Exception:
return 0