diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..da112a1
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Файлы, игнорируемые по умолчанию
+/shelf/
+/workspace.xml
+# Запросы HTTP-клиента в редакторе
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/AnimeVideoEditot.iml b/.idea/AnimeVideoEditot.iml
new file mode 100644
index 0000000..8746ecd
--- /dev/null
+++ b/.idea/AnimeVideoEditot.iml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..007bbeb
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..105ce2d
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml
new file mode 100644
index 0000000..ec7f734
--- /dev/null
+++ b/.idea/material_theme_project_new.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..24f69a0
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..43099ed
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index bfa7902..0de00b1 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,3 @@
-# AnimeVideoEditot
+# Anime Video Editor
-Приложение для создания единого видео файла из разных.
\ No newline at end of file
+Приложение для объединения нескольких видео в один файл. Поддерживает выбор временных диапазонов для каждого ролика, настройку формата (MP4, AVI, MOV, MKV) и качества экспорта.
\ No newline at end of file
diff --git a/core/video_processor.py b/core/video_processor.py
index 0a2e0a8..2c96501 100644
--- a/core/video_processor.py
+++ b/core/video_processor.py
@@ -1,98 +1,377 @@
import os
-from moviepy.editor import VideoFileClip, concatenate_videoclips
+import subprocess
import tempfile
+import warnings
+from moviepy import VideoFileClip, concatenate_videoclips
+from moviepy.video.fx import Resize
from datetime import datetime
from core.logger import Logger
+from utils.file_utils import ensure_directory
+
+# Метаданные в итоговое видео
+APP_NAME = "Anime Video Editor"
+APP_TAG = "Anime Video Editor"
+
+# Этапы для отображения в окне выполнения
+STAGE_PREPARE = 1 # Обработка фрагментов
+STAGE_CONCAT = 2 # Объединение
+STAGE_WRITE = 3 # Сохранение (кодирование)
+
+try:
+ import proglog
+ class WriteProgressLogger(proglog.ProgressBarLogger):
+ """Логгер прогресса записи видео для передачи в окно выполнения."""
+ def __init__(self, callback):
+ super().__init__()
+ self._callback = callback
+ def bars_callback(self, bar, attr, value, old_value=None):
+ if not self._callback or bar not in self.bars:
+ return
+ total = self.bars[bar].get("total")
+ if total and total > 0:
+ pct = value / total
+ self._callback(STAGE_WRITE, pct, f"Сохранение: {int(pct * 100)}%")
+ _HAS_PROGLOG = True
+except Exception:
+ _HAS_PROGLOG = False
+ WriteProgressLogger = None
+
+# При числе файлов выше порога обрабатываем по одному во временные файлы, затем склеиваем — меньше пиков по памяти
+BATCH_THRESHOLD = 3
+
+
+class CancelledError(Exception):
+ """Остановка выполнения по запросу пользователя."""
+ pass
+
+# Убираем шумные предупреждения о последних кадрах в некоторых MP4 (reader подставляет последний валидный кадр)
+warnings.filterwarnings(
+ "ignore",
+ message=".*bytes wanted but 0 bytes read.*",
+ category=UserWarning,
+)
+
+
+def _get_ffmpeg_exe():
+ """Путь к FFmpeg (тот же, что использует MoviePy/imageio)."""
+ try:
+ import imageio_ffmpeg
+ return imageio_ffmpeg.get_ffmpeg_exe()
+ except Exception:
+ pass
+ try:
+ from moviepy.config import get_setting
+ return get_setting("FFMPEG_BINARY") or "ffmpeg"
+ except Exception:
+ pass
+ return "ffmpeg"
+
+
+def _write_video_metadata(file_path, metadata_dict, logger=None):
+ """
+ Добавляет метаданные в видеофайл через FFmpeg (stream copy, без перекодирования).
+ metadata_dict: {"comment": "...", "title": "...", "encoder": "..."} и т.д.
+ """
+ file_path = os.path.abspath(os.path.normpath(file_path))
+ if not metadata_dict or not os.path.isfile(file_path):
+ return
+ try:
+ ffmpeg_exe = _get_ffmpeg_exe()
+ out_dir = os.path.dirname(file_path)
+ if not out_dir:
+ out_dir = os.getcwd()
+ fd, temp_path = tempfile.mkstemp(suffix=os.path.splitext(file_path)[1], dir=out_dir)
+ os.close(fd)
+ cmd = [ffmpeg_exe, "-y", "-i", file_path, "-c", "copy"]
+ for key, value in metadata_dict.items():
+ if value is None or (isinstance(value, str) and not value.strip()):
+ continue
+ val = str(value).replace("\n", " ").replace("\r", "").strip()
+ if not val:
+ continue
+ cmd.extend(["-metadata", f"{key}={val}"])
+ cmd.append(temp_path)
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
+ if result.returncode == 0 and os.path.isfile(temp_path):
+ os.replace(temp_path, file_path)
+ if logger:
+ logger.log("INFO", "Метаданные записаны в файл")
+ else:
+ if os.path.isfile(temp_path):
+ try:
+ os.remove(temp_path)
+ except OSError:
+ pass
+ if logger:
+ logger.log("WARNING", "Не удалось записать метаданные (FFmpeg): " + (result.stderr or "")[:200])
+ except Exception as e:
+ if logger:
+ logger.log("WARNING", f"Метаданные не записаны: {e}")
class VideoProcessor:
- def __init__(self):
- self.logger = Logger()
+ def __init__(self, logger=None):
+ self.logger = logger or Logger()
def process_videos(self, video_data, output_dir, output_format="mp4",
- quality="high", progress_callback=None):
+ quality="high", progress_callback=None,
+ fps=None, preset="medium", audio_bitrate="192k",
+ target_height=None, filename_prefix="edited_video",
+ cancel_check=None):
"""
- Основной метод обработки видео
+ cancel_check: callable(), возвращает True если нужно остановить выполнение.
"""
self.logger.log("INFO", f"Начало обработки {len(video_data)} видео файлов")
+ check = cancel_check if callable(cancel_check) else lambda: False
+ use_batch = len(video_data) > BATCH_THRESHOLD
+ if use_batch:
+ self.logger.log("INFO", f"Режим пакетной обработки (файлов > {BATCH_THRESHOLD}), экономия памяти")
+ return self._process_videos_batch(
+ video_data, output_dir, output_format, quality, progress_callback,
+ fps, preset, audio_bitrate, target_height, filename_prefix, check,
+ )
+ return self._process_videos_memory(
+ video_data, output_dir, output_format, quality, progress_callback,
+ fps, preset, audio_bitrate, target_height, filename_prefix, check,
+ )
+
+ def _process_videos_memory(self, video_data, output_dir, output_format, quality,
+ progress_callback, fps, preset, audio_bitrate,
+ target_height, filename_prefix, cancel_check):
+ """Обработка всех клипов в памяти (подходит для малого числа файлов)."""
clips = []
- total_steps = sum(len(data['time_ranges']) for data in video_data) + 2
- current_step = 0
+ source_videos = []
+ all_keep_ranges = []
try:
- # Обработка каждого видео файла
- for i, data in enumerate(video_data):
+ for data in video_data:
+ if cancel_check():
+ raise CancelledError()
video_path = data['path']
- time_ranges = data['time_ranges']
+ remove_ranges = data['time_ranges']
+ self.logger.log("INFO", f"Файл: {os.path.basename(video_path)}")
+ video = VideoFileClip(video_path)
+ source_videos.append(video)
+ keep = self._ranges_to_keep(remove_ranges, video.duration)
+ all_keep_ranges.append(keep)
+ for s, e in keep:
+ self.logger.log("INFO", f" Оставляем: {self.format_time(s)} — {self.format_time(e)}")
- self.logger.log("INFO", f"Обработка файла: {os.path.basename(video_path)}")
+ total_clip_steps = sum(len(k) for k in all_keep_ranges)
+ current_step = 0
- with VideoFileClip(video_path) as video:
- # Вырезаем указанные диапазоны
- for start, end in time_ranges:
- if progress_callback:
- progress = current_step / total_steps
- progress_callback(progress,
- f"Вырезание фрагмента {i + 1}/{len(video_data)}")
-
- # Обрезаем видео по таймкодам
- clip = video.subclip(start, end)
- clips.append(clip)
- current_step += 1
-
- self.logger.log("INFO",
- f"Вырезан фрагмент: {self.format_time(start)} - {self.format_time(end)}")
+ for i in range(len(video_data)):
+ if cancel_check():
+ raise CancelledError()
+ video = source_videos[i]
+ keep_ranges = all_keep_ranges[i]
+ for start, end in keep_ranges:
+ if cancel_check():
+ raise CancelledError()
+ if progress_callback:
+ p = current_step / total_clip_steps if total_clip_steps else 1.0
+ progress_callback(STAGE_PREPARE, p, f"Обработка фрагмента {i + 1}/{len(video_data)}")
+ clip = video.subclipped(start, end)
+ clips.append(clip)
+ current_step += 1
if not clips:
raise ValueError("Нет видео фрагментов для объединения")
- # Объединение клипов
+ if cancel_check():
+ raise CancelledError()
if progress_callback:
- progress_callback(current_step / total_steps, "Объединение видео фрагментов")
-
+ progress_callback(STAGE_CONCAT, 1.0, "Объединение фрагментов")
self.logger.log("INFO", f"Объединение {len(clips)} фрагментов")
final_clip = concatenate_videoclips(clips)
-
- # Настройка качества
- bitrate = self.get_bitrate(quality)
-
- # Генерация имени выходного файла
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
- output_filename = f"edited_video_{timestamp}.{output_format}"
- output_path = os.path.join(output_dir, output_filename)
-
- # Сохранение результата
- if progress_callback:
- progress_callback((total_steps - 1) / total_steps, "Сохранение итогового файла")
-
- self.logger.log("INFO", f"Сохранение файла: {output_path}")
- final_clip.write_videofile(
- output_path,
- codec='libx264',
- bitrate=bitrate,
- audio_codec='aac',
- verbose=False,
- logger=None
+ if cancel_check():
+ raise CancelledError()
+ output_path = self._write_final(
+ final_clip, output_dir, output_format, quality, progress_callback,
+ fps, preset, audio_bitrate, target_height, filename_prefix, cancel_check,
)
-
- # Закрытие клипов
final_clip.close()
- for clip in clips:
- clip.close()
-
+ for c in clips:
+ try:
+ c.close()
+ except Exception:
+ pass
+ for v in source_videos:
+ try:
+ v.close()
+ except Exception:
+ pass
+ if progress_callback:
+ progress_callback(STAGE_WRITE, 1.0, "Готово")
self.logger.log("SUCCESS", f"Обработка завершена успешно: {output_path}")
return output_path
-
- except Exception as e:
- self.logger.log("ERROR", f"Ошибка обработки: {str(e)}")
- # Закрытие клипов в случае ошибки
- for clip in clips:
+ except CancelledError:
+ for c in clips:
try:
- clip.close()
- except:
+ c.close()
+ except Exception:
+ pass
+ for v in source_videos:
+ try:
+ v.close()
+ except Exception:
pass
raise
+ except Exception as e:
+ self.logger.log("ERROR", f"Ошибка обработки: {str(e)}")
+ for c in clips:
+ try:
+ c.close()
+ except Exception:
+ pass
+ for v in source_videos:
+ try:
+ v.close()
+ except Exception:
+ pass
+ raise
+
+ def _process_videos_batch(self, video_data, output_dir, output_format, quality,
+ progress_callback, fps, preset, audio_bitrate,
+ target_height, filename_prefix, cancel_check):
+ """Обработка по одному файлу во временные ролики, затем склейка — меньше пиков памяти."""
+ temp_paths = []
+ n_files = len(video_data)
+
+ try:
+ ensure_directory(output_dir)
+ with tempfile.TemporaryDirectory(prefix="ave_") as tmpdir:
+ for i, data in enumerate(video_data):
+ if cancel_check():
+ raise CancelledError()
+ if progress_callback:
+ p = (i + 1) / n_files if n_files else 1.0
+ progress_callback(STAGE_PREPARE, p, f"Файл {i + 1}/{n_files}")
+ video_path = data['path']
+ remove_ranges = data['time_ranges']
+ keep = None
+ video = VideoFileClip(video_path)
+ try:
+ keep = self._ranges_to_keep(remove_ranges, video.duration)
+ if not keep:
+ current_step += 1
+ continue
+ part_clips = []
+ for start, end in keep:
+ part_clips.append(video.subclipped(start, end))
+ if len(part_clips) == 1:
+ part = part_clips[0]
+ else:
+ part = concatenate_videoclips(part_clips)
+ for c in part_clips:
+ try:
+ c.close()
+ except Exception:
+ pass
+ temp_path = os.path.join(tmpdir, f"part_{i:04d}.mp4")
+ write_kw = {"codec": "libx264", "bitrate": self.get_bitrate(quality), "audio_codec": "aac"}
+ if fps and fps > 0:
+ write_kw["fps"] = fps
+ try:
+ part.write_videofile(temp_path, **write_kw)
+ except TypeError:
+ part.write_videofile(temp_path, codec="libx264", bitrate=self.get_bitrate(quality), audio_codec="aac")
+ part.close()
+ temp_paths.append(temp_path)
+ finally:
+ try:
+ video.close()
+ except Exception:
+ pass
+
+ if not temp_paths:
+ raise ValueError("Нет видео фрагментов для объединения")
+
+ if cancel_check():
+ raise CancelledError()
+ if progress_callback:
+ progress_callback(STAGE_CONCAT, 1.0, "Финальная склейка")
+ clip_list = [VideoFileClip(p) for p in temp_paths]
+ try:
+ final_clip = concatenate_videoclips(clip_list)
+ if cancel_check():
+ raise CancelledError()
+ output_path = self._write_final(
+ final_clip, output_dir, output_format, quality, progress_callback,
+ fps, preset, audio_bitrate, target_height, filename_prefix, cancel_check,
+ )
+ final_clip.close()
+ finally:
+ for c in clip_list:
+ try:
+ c.close()
+ except Exception:
+ pass
+ if progress_callback:
+ progress_callback(STAGE_WRITE, 1.0, "Готово")
+ self.logger.log("SUCCESS", f"Обработка завершена успешно: {output_path}")
+ return output_path
+ except CancelledError:
+ raise
+ except Exception as e:
+ self.logger.log("ERROR", f"Ошибка обработки: {str(e)}")
+ raise
+
+ def _write_final(self, final_clip, output_dir, output_format, quality, progress_callback,
+ fps, preset, audio_bitrate, target_height, filename_prefix, cancel_check=None):
+ """Масштабирование (если нужно) и запись итогового файла. final_clip закрывает вызывающий код."""
+ check = cancel_check if callable(cancel_check) else lambda: False
+ if check():
+ raise CancelledError()
+ to_write = final_clip
+ resized = None
+ if target_height and target_height > 0:
+ try:
+ if final_clip.h != target_height:
+ resized = final_clip.with_effects([Resize(height=target_height)])
+ to_write = resized
+ self.logger.log("INFO", f"Масштабирование до высоты {target_height}px")
+ except Exception as e:
+ self.logger.log("WARNING", f"Масштабирование пропущено: {e}")
+
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ safe_prefix = "".join(c for c in filename_prefix if c.isalnum() or c in " _-") or "edited_video"
+ output_filename = f"{safe_prefix}_{timestamp}.{output_format}"
+ output_path = os.path.join(output_dir, output_filename)
+ ensure_directory(output_dir)
+
+ if progress_callback:
+ progress_callback(STAGE_WRITE, 0.0, "Сохранение итогового файла...")
+ self.logger.log("INFO", f"Сохранение файла: {output_path}")
+ write_kw = {"codec": "libx264", "bitrate": self.get_bitrate(quality), "audio_codec": "aac"}
+ if fps is not None and fps > 0:
+ write_kw["fps"] = fps
+ write_kw["preset"] = preset
+ write_kw["audio_bitrate"] = audio_bitrate
+ if _HAS_PROGLOG and progress_callback and WriteProgressLogger is not None:
+ write_kw["logger"] = WriteProgressLogger(progress_callback)
+ try:
+ to_write.write_videofile(output_path, **write_kw)
+ except TypeError:
+ write_kw.pop("preset", None)
+ write_kw.pop("audio_bitrate", None)
+ write_kw.pop("logger", None)
+ to_write.write_videofile(output_path, **write_kw)
+ if resized is not None:
+ try:
+ resized.close()
+ except Exception:
+ pass
+ # Метаданные: программа, дата, название
+ meta = {
+ "comment": f"Отредактировано в {APP_NAME}. Дата: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}.",
+ "title": safe_prefix or "edited_video",
+ "encoder": APP_TAG,
+ "description": f"Создано в {APP_NAME} — объединение и нарезка видео.",
+ }
+ _write_video_metadata(output_path, meta, self.logger)
+ return output_path
def get_bitrate(self, quality):
"""Определение битрейта в зависимости от качества"""
@@ -107,5 +386,33 @@ class VideoProcessor:
"""Форматирование времени в читаемый вид"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
- seconds = int(seconds % 60)
- return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
\ No newline at end of file
+ secs = int(seconds % 60)
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}"
+
+ @staticmethod
+ def _ranges_to_keep(remove_ranges, duration):
+ """
+ По диапазонам на удаление и длительности возвращает диапазоны для сохранения.
+ remove_ranges: список (start, end) — что вырезать.
+ Возвращает список (start, end) — что оставить и склеить.
+ """
+ if not remove_ranges:
+ return [(0, duration)] if duration > 0 else []
+ # Сортируем и сливаем пересекающиеся
+ sorted_r = sorted((max(0, s), min(duration, e)) for s, e in remove_ranges if s < e)
+ merged = []
+ for s, e in sorted_r:
+ if merged and s <= merged[-1][1]:
+ merged[-1] = (merged[-1][0], max(merged[-1][1], e))
+ else:
+ merged.append((s, e))
+ # Обратное: оставляем всё, кроме merged
+ keep = []
+ t = 0
+ for rs, re in merged:
+ if t < rs:
+ keep.append((t, rs))
+ t = max(t, re)
+ if t < duration:
+ keep.append((t, duration))
+ return keep
\ No newline at end of file
diff --git a/gui/main_window.py b/gui/main_window.py
index eb19198..8d4df7e 100644
--- a/gui/main_window.py
+++ b/gui/main_window.py
@@ -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(
- "",
- 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("", 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("", lambda e: canvas.bind_all("", _on_mousewheel))
+ canvas.bind("", lambda e: canvas.unbind_all(""))
- # 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("<>", 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)
\ No newline at end of file
+ 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()
diff --git a/gui/theme.py b/gui/theme.py
new file mode 100644
index 0000000..d880f95
--- /dev/null
+++ b/gui/theme.py
@@ -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
diff --git a/gui/video_item.py b/gui/video_item.py
index f2de4c7..ac30cf4 100644
--- a/gui/video_item.py
+++ b/gui/video_item.py
@@ -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
\ No newline at end of file
+ except Exception:
+ return 0
diff --git a/requirements.txt b/requirements.txt
index e72adb6..de870a8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,4 @@
-moviepy==1.0.3
-Pillow==10.0.0
\ No newline at end of file
+# Видеоредактор — зависимости (Windows и Linux)
+# Используются версии с готовыми wheel-пакетами для обеих платформ.
+moviepy>=1.0.3
+Pillow>=10.1.0