From 324fa7440a162cd3a243032b3bf756c71855d7f4 Mon Sep 17 00:00:00 2001 From: stirelshka8-vivos Date: Sat, 14 Mar 2026 19:52:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.gitignore | 8 + .idea/AnimeVideoEditot.iml | 14 + .idea/inspectionProfiles/Project_Default.xml | 137 +++++ .../inspectionProfiles/profiles_settings.xml | 6 + .idea/material_theme_project_new.xml | 10 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + README.md | 4 +- core/video_processor.py | 435 +++++++++++-- gui/main_window.py | 578 +++++++++++++++--- gui/theme.py | 142 +++++ gui/video_item.py | 166 +++-- requirements.txt | 6 +- 14 files changed, 1296 insertions(+), 231 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/AnimeVideoEditot.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/material_theme_project_new.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 gui/theme.py 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