From b8211b522013a50ebc36b44cba82a48e09c3b3a5 Mon Sep 17 00:00:00 2001 From: stirelshka8 Date: Tue, 24 Mar 2026 14:38:40 +0300 Subject: [PATCH] UPD --- .gitignore | 2 + build_exe.ps1 | 15 +- core/video_processor.py | 52 +++++- gui/main_window.py | 357 +++++++++++++++++++++++++++++++++++++--- gui/video_item.py | 197 +++++++++++++++++++--- 5 files changed, 565 insertions(+), 58 deletions(-) diff --git a/.gitignore b/.gitignore index 5d381cc..a091090 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +build/ +BUILDS/ # ---> Python # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/build_exe.ps1 b/build_exe.ps1 index c5adbc7..4c13aea 100644 --- a/build_exe.ps1 +++ b/build_exe.ps1 @@ -4,6 +4,15 @@ $ErrorActionPreference = "Stop" Set-Location $PSScriptRoot +$buildRoot = Join-Path $PSScriptRoot "BUILDS" +$distPath = Join-Path $buildRoot "dist" +$workPath = Join-Path $buildRoot "build" +$exePath = Join-Path $distPath "AnimeVideoEditor.exe" + +if (-not (Test-Path $buildRoot)) { + New-Item -ItemType Directory -Path $buildRoot | Out-Null +} + Write-Host "Checking project dependencies..." -ForegroundColor Cyan pip install -r requirements.txt -q if (-not $?) { exit 1 } @@ -15,11 +24,11 @@ if ($LASTEXITCODE -ne 0) { } Write-Host "Building exe..." -ForegroundColor Cyan -pyinstaller --noconfirm AnimeVideoEditor.spec +pyinstaller --noconfirm AnimeVideoEditor.spec --distpath "$distPath" --workpath "$workPath" -if (Test-Path "dist\AnimeVideoEditor.exe") { +if (Test-Path $exePath) { Write-Host "" - Write-Host "Done: dist\AnimeVideoEditor.exe" -ForegroundColor Green + Write-Host "Done: $exePath" -ForegroundColor Green } else { Write-Host "" Write-Host "Build failed. Check output above." -ForegroundColor Red diff --git a/core/video_processor.py b/core/video_processor.py index ceab7c9..31b6edb 100644 --- a/core/video_processor.py +++ b/core/video_processor.py @@ -23,16 +23,20 @@ try: import proglog class WriteProgressLogger(proglog.ProgressBarLogger): """Логгер прогресса записи видео для передачи в окно выполнения.""" - def __init__(self, callback): + def __init__(self, callback=None, cancel_check=None, stage=STAGE_WRITE): super().__init__() self._callback = callback + self._cancel_check = cancel_check if callable(cancel_check) else (lambda: False) + self._stage = stage def bars_callback(self, bar, attr, value, old_value=None): + if self._cancel_check(): + raise CancelledError() 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)}%") + self._callback(self._stage, pct, f"Сохранение: {int(pct * 100)}%") _HAS_PROGLOG = True except Exception: _HAS_PROGLOG = False @@ -264,7 +268,6 @@ class VideoProcessor: try: keep = self._ranges_to_keep(remove_ranges, video.duration) if not keep: - current_step += 1 continue part_clips = [] for start, end in keep: @@ -279,13 +282,24 @@ class VideoProcessor: 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"} + # В оконной сборке stdout/stderr могут быть None; отключаем дефолтный логгер MoviePy. + write_kw = {"codec": "libx264", "bitrate": self.get_bitrate(quality), "audio_codec": "aac", "logger": None} if fps and fps > 0: write_kw["fps"] = fps + if _HAS_PROGLOG and WriteProgressLogger is not None: + write_kw["logger"] = WriteProgressLogger(cancel_check=cancel_check, stage=STAGE_PREPARE) 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.write_videofile( + temp_path, + codec="libx264", + bitrate=self.get_bitrate(quality), + audio_codec="aac", + logger=None, + ) + if cancel_check(): + raise CancelledError() part.close() temp_paths.append(temp_path) finally: @@ -353,20 +367,32 @@ class VideoProcessor: 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"} + write_kw = {"codec": "libx264", "bitrate": self.get_bitrate(quality), "audio_codec": "aac", "logger": None} 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) + if _HAS_PROGLOG and WriteProgressLogger is not None: + write_kw["logger"] = WriteProgressLogger( + callback=progress_callback, + cancel_check=check, + stage=STAGE_WRITE, + ) 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) + write_kw["logger"] = None to_write.write_videofile(output_path, **write_kw) + if check(): + raise CancelledError() + except CancelledError: + self._remove_if_exists(output_path) + raise + except Exception: + self._remove_if_exists(output_path) + raise if resized is not None: try: resized.close() @@ -382,6 +408,14 @@ class VideoProcessor: _write_video_metadata(output_path, meta, self.logger) return output_path + @staticmethod + def _remove_if_exists(path): + try: + if path and os.path.isfile(path): + os.remove(path) + except OSError: + pass + def get_bitrate(self, quality): """Определение битрейта в зависимости от качества""" quality_settings = { diff --git a/gui/main_window.py b/gui/main_window.py index 4db3130..051942d 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -4,12 +4,20 @@ from tkinter import ttk, filedialog, messagebox import os import threading from queue import Queue, Empty +import random from gui.theme import setup_theme, COLORS from gui.video_item import VideoItem from core.video_processor import VideoProcessor, CancelledError from core.logger import Logger import sys import subprocess +from moviepy import VideoFileClip +try: + from PIL import Image + _HAS_PIL = True +except Exception: + Image = None + _HAS_PIL = False def _icon_path(): @@ -44,6 +52,8 @@ class MainWindow: self._progress_close_btn = None self._progress_stop_btn = None self._cancel_event = None + self._importing = False + self._import_queue = Queue() self._setup_menu() self.setup_ui() self._poll_progress() @@ -95,8 +105,9 @@ class MainWindow: 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") + self._canvas_window_id = canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") canvas.configure(yscrollcommand=scrollbar.set) + canvas.bind("", lambda e: canvas.itemconfigure(self._canvas_window_id, width=e.width)) def _on_mousewheel(event): canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") @@ -165,8 +176,21 @@ class MainWindow: self.filename_prefix_var = tk.StringVar(value="edited_video") ttk.Entry(row2, textvariable=self.filename_prefix_var, width=18).grid(row=0, column=5, padx=(0, 16), pady=2, sticky="w") + row2b = ttk.Frame(out_card) + row2b.grid(row=3, column=0, sticky="ew", pady=(10, 0)) + row2b.columnconfigure(5, weight=1) + ttk.Label(row2b, text="Общий вырез для всех файлов: с").grid(row=0, column=0, padx=(0, 6), pady=2, sticky="w") + self.global_cut_start_var = tk.StringVar(value="") + ttk.Entry(row2b, textvariable=self.global_cut_start_var, width=10).grid(row=0, column=1, padx=(0, 10), pady=2, sticky="w") + ttk.Label(row2b, text="по").grid(row=0, column=2, padx=(0, 6), pady=2, sticky="w") + self.global_cut_end_var = tk.StringVar(value="") + ttk.Entry(row2b, textvariable=self.global_cut_end_var, width=10).grid(row=0, column=3, padx=(0, 12), pady=2, sticky="w") + ttk.Label(row2b, text="(формат: ЧЧ:ММ:СС / ММ:СС / секунды)", style="Subtext.TLabel").grid( + row=0, column=4, padx=(0, 6), pady=2, sticky="w" + ) + row3 = ttk.Frame(out_card) - row3.grid(row=3, column=0, sticky="ew", pady=(10, 0)) + row3.grid(row=4, column=0, sticky="ew", pady=(10, 0)) row3.columnconfigure(1, weight=1) ttk.Button(row3, text="📁 Папка для сохранения", command=self.select_output_dir).grid(row=0, column=0, padx=(0, 12), pady=4) @@ -177,7 +201,7 @@ class MainWindow: # Чекбоксы: открыть папку, закрыть окно выполнения, открыть лог row4 = ttk.Frame(out_card) - row4.grid(row=4, column=0, sticky="ew", pady=(10, 0)) + row4.grid(row=5, column=0, sticky="ew", pady=(10, 0)) self.open_folder_var = tk.BooleanVar(value=True) ttk.Checkbutton(row4, text="Открыть папку с итоговым файлом после завершения", variable=self.open_folder_var).grid(row=0, column=0, sticky="w", padx=(0, 20)) @@ -205,6 +229,9 @@ class MainWindow: ttk.Label(action_frame, textvariable=self.status_var, style="Subtext.TLabel").grid(row=2, column=0, sticky="w") def add_video_files(self): + if self._importing: + messagebox.showinfo("Импорт", "Импорт файлов уже выполняется.") + return files = filedialog.askopenfilenames( title="Выберите видео файлы", filetypes=[ @@ -212,11 +239,9 @@ class MainWindow: ("Все файлы", "*.*") ] ) - 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)}") + if not files: + return + self._start_import(files) def clear_all(self): for item in self.video_items: @@ -224,6 +249,52 @@ class MainWindow: self.video_items.clear() self.update_status("Список очищен") + def _remove_video_item(self, item): + if item in self.video_items: + self.video_items.remove(item) + item.destroy() + self._renumber_video_orders() + + def _renumber_video_orders(self): + for idx, item in enumerate(self.video_items, start=1): + try: + item.set_order(idx) + except Exception: + pass + + def _repack_video_items(self): + for item in self.video_items: + item.pack_forget() + item.pack(fill=tk.X, padx=4, pady=4) + + def _move_video_item_up(self, item): + idx = self.video_items.index(item) if item in self.video_items else -1 + if idx <= 0: + return + other = self.video_items[idx - 1] + self.video_items[idx - 1], self.video_items[idx] = self.video_items[idx], self.video_items[idx - 1] + self._renumber_video_orders() + self._repack_video_items() + try: + item.flash_reorder() + other.flash_reorder() + except Exception: + pass + + def _move_video_item_down(self, item): + idx = self.video_items.index(item) if item in self.video_items else -1 + if idx < 0 or idx >= len(self.video_items) - 1: + return + other = self.video_items[idx + 1] + self.video_items[idx + 1], self.video_items[idx] = self.video_items[idx], self.video_items[idx + 1] + self._renumber_video_orders() + self._repack_video_items() + try: + item.flash_reorder() + other.flash_reorder() + except Exception: + pass + def select_output_dir(self): d = filedialog.askdirectory(title="Папка для сохранения") if d: @@ -365,12 +436,23 @@ Anime Video Editor — объединение нескольких видео в 1. ДОБАВЛЕНИЕ ВИДЕО ══════════════════════════════════════════════════════════ • Нажмите «➕ Добавить файлы» и выберите один или несколько видео (MP4, AVI, MOV, MKV и др.). -• Файлы появятся в списке «Видео в проекте». Порядок в списке = порядок в итоговом ролике. +• При добавлении большого количества файлов запускается фоновый импорт с прогрессом (статус и шкала прогресса). +• Во время импорта приложение остаётся отзывчивым: миниатюры и длительность подтягиваются постепенно. +• Файлы появляются в списке «Видео в проекте». Слева отображается крупный случайный кадр из видео. • Удалить файл из проекта: кнопка «✕» справа от имени файла. • «Очистить всё» — удаляет все файлы из списка. ══════════════════════════════════════════════════════════ -2. ЧТО ВЫРЕЗАТЬ ИЗ КАЖДОГО ВИДЕО +2. ПОРЯДОК СКЛЕЙКИ +══════════════════════════════════════════════════════════ +• У каждого файла есть поле «Порядок». Меньшее число = раньше в итоговом ролике. +• Быстрое изменение порядка: кнопки «↑» и «↓» на карточке. +• При нажатии «↑/↓» файл перемещается в списке, а изменённые карточки подсвечиваются. +• При наведении мыши карточка подсвечивается для удобной навигации. +• Если номера порядка совпадают, склейка идёт в текущем порядке отображения списка. + +══════════════════════════════════════════════════════════ +3. ЧТО ВЫРЕЗАТЬ ИЗ КАЖДОГО ВИДЕО ══════════════════════════════════════════════════════════ • У каждого файла есть блок «Удалить из видео (исключить эти фрагменты)». • Укажите диапазоны времени, которые нужно ВЫРЕЗАТЬ (удалить). Всё остальное попадёт в итоговое видео. @@ -380,23 +462,25 @@ Anime Video Editor — объединение нескольких видео в • Пример: ролик 1 минута, удалить с 0:10 по 0:20 и с 0:40 по 0:50 → в итоге будут куски 0:00–0:10, 0:20–0:40, 0:50–1:00, склеенные подряд. ══════════════════════════════════════════════════════════ -3. НАСТРОЙКИ ВЫВОДА +4. НАСТРОЙКИ ВЫВОДА ══════════════════════════════════════════════════════════ • Профиль: «Свои настройки», «YouTube» или «VK Video» — подставляет рекомендуемые параметры для загрузки на эти платформы (MP4, 1080p, битрейт и т.д.). После выбора профиля параметры можно менять вручную. • Формат: MP4, AVI, MOV, MKV. • Качество: low / medium / high (влияет на битрейт). • FPS: Исходный или 24, 25, 30, 60. • Разрешение: Исходное, 720p или 1080p (масштабирование по высоте). +• Общий вырез для всех файлов: можно задать один промежуток времени, который будет удалён из каждого видео (например, удалить интро 00:00:00–00:00:15 у всех файлов сразу). • Кодек (preset): от ultrafast (быстро) до veryslow (медленнее, меньше размер). • Аудио битрейт: 128k–320k. • Имя файла: префикс для итогового файла (к нему добавится дата и время). • «📁 Папка для сохранения» — куда сохранить результат. ══════════════════════════════════════════════════════════ -4. ЗАПУСК ОБРАБОТКИ +5. ЗАПУСК ОБРАБОТКИ ══════════════════════════════════════════════════════════ • Нажмите «▶ Выполнить». • Обработка идёт в фоне — окно остаётся отзывчивым, можно смотреть прогресс. +• Кнопка «⏹ Остановить» прерывает процесс; незавершённые выходные файлы удаляются автоматически. • После завершения появится сообщение с путём к файлу. Рядом сохраняется лог (_log.txt). • При большом количестве файлов (больше 3) включается режим с меньшим потреблением памяти. """ @@ -423,14 +507,119 @@ Anime Video Editor — объединение нескольких видео в 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" + win = tk.Toplevel(self.root) + win.title("О программе") + win.geometry("560x420") + win.minsize(460, 340) + win.configure(bg=COLORS["bg"]) + win.transient(self.root) + win.grab_set() + + root_fr = tk.Frame(win, bg=COLORS["bg"], padx=14, pady=12) + root_fr.pack(fill=tk.BOTH, expand=True) + + card = tk.Frame( + root_fr, + bg=COLORS["surface"], + highlightthickness=1, + highlightbackground=COLORS["border"], + padx=14, + pady=12, ) + card.pack(fill=tk.BOTH, expand=True) + + tk.Label( + card, + text="🎬 Anime Video Editor", + font=("Tahoma", 14, "bold"), + fg=COLORS["accent"], + bg=COLORS["surface"], + ).pack(anchor=tk.W) + tk.Label( + card, + text="Версия 0.0.8", + font=("Tahoma", 10), + fg=COLORS["subtext"], + bg=COLORS["surface"], + ).pack(anchor=tk.W, pady=(2, 10)) + + items = [ + ("✨", "Склейка нескольких видео в один файл"), + ("✂️", "Вырезка фрагментов для каждого файла"), + ("🧩", "Общий интервал вырезки сразу для всех файлов"), + ("↕️", "Управление порядком через поле и кнопки ↑/↓"), + ("🖼️", "Крупные превью-кадры в списке файлов"), + ("⚙️", "Фоновая обработка, прогресс и остановка выполнения"), + ] + + tk.Label( + card, + text="Возможности", + font=("Tahoma", 11, "bold"), + fg=COLORS["text"], + bg=COLORS["surface"], + ).pack(anchor=tk.W, pady=(0, 6)) + + for icon, text in items: + row = tk.Frame(card, bg=COLORS["surface"]) + row.pack(fill=tk.X, pady=2) + tk.Label( + row, + text=icon, + font=("Tahoma", 11), + fg=COLORS["text"], + bg=COLORS["surface"], + width=2, + ).pack(side=tk.LEFT, anchor=tk.NW) + tk.Label( + row, + text=text, + font=("Tahoma", 10), + fg=COLORS["text"], + bg=COLORS["surface"], + justify=tk.LEFT, + anchor=tk.W, + wraplength=470, + ).pack(side=tk.LEFT, fill=tk.X, expand=True) + + tk.Label( + card, + text="👤 Автор: stirelshka8", + font=("Tahoma", 10), + fg=COLORS["subtext"], + bg=COLORS["surface"], + ).pack(anchor=tk.W, pady=(12, 2)) + + link_row = tk.Frame(card, bg=COLORS["surface"]) + link_row.pack(fill=tk.X, pady=(0, 4)) + tk.Label( + link_row, + text="🔗 Проект:", + font=("Tahoma", 10), + fg=COLORS["subtext"], + bg=COLORS["surface"], + ).pack(side=tk.LEFT) + project_url = "https://git.tuxops.ru/stirelshka8/AnimeVideoEditot" + link_lbl = tk.Label( + link_row, + text=project_url, + font=("Tahoma", 10, "underline"), + fg=COLORS["accent"], + bg=COLORS["surface"], + cursor="hand2", + ) + link_lbl.pack(side=tk.LEFT, padx=(6, 0)) + link_lbl.bind("", lambda _e: self._open_url(project_url)) + + ttk.Button(win, text="Закрыть", command=win.destroy).pack(pady=(0, 12)) + + @staticmethod + def _open_url(url): + try: + import webbrowser + webbrowser.open_new_tab(url) + except Exception: + pass def _poll_progress(self): """Обработка очереди прогресса из фонового потока (только в main thread).""" @@ -454,9 +643,33 @@ Anime Video Editor — объединение нескольких видео в self._append_progress_log(text) except Empty: pass + self._poll_import_queue() if self.root.winfo_exists(): self.root.after(200, self._poll_progress) + def _poll_import_queue(self): + try: + while True: + msg = self._import_queue.get_nowait() + if not isinstance(msg, tuple): + continue + kind = msg[0] + if kind == "import_progress": + _, done_count, total_count, text = msg + pct = (done_count / total_count) if total_count else 1.0 + self.progress_var.set(pct * 100) + self.status_var.set(text) + elif kind == "import_item": + _, item, duration, thumb_image, error_text = msg + item.set_media_info(duration=duration, thumb_image=thumb_image, error_text=error_text) + elif kind == "import_done": + _, total_count = msg + self._importing = False + self.progress_var.set(0) + self.update_status(f"Импорт завершён. Добавлено файлов: {total_count}") + except Empty: + pass + def _on_processing_done(self, success, payload): self._processing = False self._cancel_event = None @@ -497,15 +710,43 @@ Anime Video Editor — объединение нескольких видео в self.root.after(0, lambda: self.progress_var.set(0)) def process_videos(self): + if self._importing: + messagebox.showwarning("Импорт", "Дождитесь завершения импорта файлов.") + return if self._processing: return if not self.video_items: messagebox.showerror("Ошибка", "Добавьте хотя бы один видео файл") return + global_cut = self._get_global_cut_range() + if isinstance(global_cut, str): + messagebox.showerror("Ошибка", global_cut) + return + + ordered_items = [] + order_values = [] + for idx, item in enumerate(self.video_items): + try: + order = item.get_order() + except ValueError as e: + messagebox.showerror("Ошибка", f"Неверный порядок для файла {os.path.basename(item.file_path)}: {e}") + return + order_values.append(order) + ordered_items.append((order, idx, item)) + if len(set(order_values)) != len(order_values): + messagebox.showwarning( + "Внимание", + "Обнаружены одинаковые номера порядка. Файлы с одинаковым номером будут склеены в текущем порядке списка." + ) + ordered_items.sort(key=lambda x: (x[0], x[1])) + video_data = [] - for item in self.video_items: - video_data.append({"path": item.file_path, "time_ranges": item.get_time_ranges()}) + for _, _, item in ordered_items: + time_ranges = list(item.get_time_ranges()) + if global_cut is not None: + time_ranges.append(global_cut) + video_data.append({"path": item.file_path, "time_ranges": time_ranges}) fps_val = self.fps_var.get() fps = None if fps_val == "Исходный" else int(fps_val) @@ -557,3 +798,77 @@ Anime Video Editor — объединение нескольких видео в self._open_progress_window() t = threading.Thread(target=worker, daemon=True) t.start() + + def _start_import(self, files): + self._importing = True + start_order = len(self.video_items) + 1 + new_items = [] + for idx, path in enumerate(files): + item = VideoItem( + self.scrollable_frame, + path, + on_remove=self._remove_video_item, + on_move_up=self._move_video_item_up, + on_move_down=self._move_video_item_down, + order=start_order + idx, + ) + item.pack(fill=tk.X, padx=4, pady=4) + self.video_items.append(item) + new_items.append(item) + self.update_status(f"Импорт файлов: 0/{len(new_items)}") + self.progress_var.set(0) + + def importer_worker(items): + total = len(items) + for i, item in enumerate(items, start=1): + duration = 0 + thumb_image = None + error_text = None + try: + with VideoFileClip(item.file_path) as clip: + duration = float(clip.duration or 0) + if duration > 0: + sample_t = random.uniform(0.15, 0.85) * duration + frame = clip.get_frame(sample_t) + if _HAS_PIL: + thumb_image = Image.fromarray(frame).convert("RGB") + thumb_image.thumbnail((360, 202), Image.Resampling.LANCZOS) + else: + error_text = "Ошибка чтения видео" + except Exception: + error_text = "Ошибка чтения видео" + self._import_queue.put(("import_item", item, duration, thumb_image, error_text)) + self._import_queue.put(("import_progress", i, total, f"Импорт файлов: {i}/{total}")) + self._import_queue.put(("import_done", total)) + + threading.Thread(target=importer_worker, args=(new_items,), daemon=True).start() + + @staticmethod + def _parse_time_value(time_str): + value = (time_str or "").strip() + if not value: + return None + parts = value.split(":") + if len(parts) == 3: + return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2]) + if len(parts) == 2: + return int(parts[0]) * 60 + float(parts[1]) + return float(parts[0]) + + def _get_global_cut_range(self): + start_raw = self.global_cut_start_var.get() + end_raw = self.global_cut_end_var.get() + if not (start_raw or "").strip() and not (end_raw or "").strip(): + return None + try: + start = self._parse_time_value(start_raw) + end = self._parse_time_value(end_raw) + except (ValueError, TypeError): + return "Неверный формат общего интервала вырезки. Используйте ЧЧ:ММ:СС, ММ:СС или секунды." + if start is None or end is None: + return "Заполните оба поля общего интервала вырезки: начало и конец." + if start < 0 or end < 0: + return "Общий интервал вырезки не может быть отрицательным." + if start >= end: + return "Для общего интервала вырезки начало должно быть меньше конца." + return (start, end) diff --git a/gui/video_item.py b/gui/video_item.py index ac30cf4..7914b92 100644 --- a/gui/video_item.py +++ b/gui/video_item.py @@ -2,20 +2,35 @@ import tkinter as tk from tkinter import ttk import os -from moviepy import VideoFileClip from utils.file_utils import get_file_size from gui.theme import COLORS +try: + from PIL import Image, ImageTk + _HAS_PIL = True +except Exception: + Image = None + ImageTk = None + _HAS_PIL = False class VideoItem(ttk.Frame): """Карточка одного видео в списке с диапазонами и стильным оформлением.""" - def __init__(self, parent, file_path, on_remove=None): + def __init__(self, parent, file_path, on_remove=None, on_move_up=None, on_move_down=None, order=1): super().__init__(parent) self.file_path = file_path self.time_ranges = [] self._duration = 0 self.on_remove = on_remove + self.on_move_up = on_move_up + self.on_move_down = on_move_down + self.order_var = tk.StringVar(value=str(order)) + self._thumbnail_photo = None + self._duration_label = None + self._thumb_label = None + self._card = None + self._hovered = False + self._flash_job = None self.setup_ui() def setup_ui(self): @@ -23,14 +38,31 @@ class VideoItem(ttk.Frame): 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) + self._card = card 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)) + thumb_wrap = tk.Frame(top, bg=COLORS["overlay"], width=360, height=202, highlightthickness=1, highlightbackground=COLORS["border"]) + thumb_wrap.pack(side=tk.LEFT, padx=(0, 12), anchor=tk.N) + thumb_wrap.pack_propagate(False) + thumb_label = tk.Label( + thumb_wrap, + bg=COLORS["overlay"], + fg=COLORS["subtext"], + text="Загрузка...", + relief=tk.FLAT, + ) + thumb_label.pack(fill=tk.BOTH, expand=True) + self._thumb_label = thumb_label + + text_wrap = tk.Frame(top, bg=COLORS["surface"]) + text_wrap.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.N) + file_name = os.path.basename(self.file_path) try: size_str = get_file_size(self.file_path) @@ -38,27 +70,68 @@ class VideoItem(ttk.Frame): except Exception: file_info = file_name - lbl_name = tk.Label(top, text=file_info, font=("Tahoma", 10, "bold"), + lbl_name = tk.Label(text_wrap, text=file_info, font=("Tahoma", 10, "bold"), fg=COLORS["text"], bg=COLORS["surface"]) - lbl_name.pack(side=tk.LEFT) + lbl_name.pack(anchor=tk.W) - try: - with VideoFileClip(self.file_path) as clip: - 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) + lbl_dur = tk.Label(text_wrap, text="Длительность: ...", font=("Tahoma", 9), + fg=COLORS["subtext"], bg=COLORS["surface"]) + lbl_dur.pack(anchor=tk.W, pady=(2, 0)) + self._duration_label = lbl_dur - btn_remove = tk.Button(top, text=" ✕ ", font=("Tahoma", 9), fg=COLORS["text"], + controls_wrap = tk.Frame(top, bg=COLORS["surface"]) + controls_wrap.pack(side=tk.RIGHT, padx=(8, 0)) + + order_wrap = tk.Frame(controls_wrap, bg=COLORS["surface"]) + order_wrap.pack(anchor=tk.E, pady=(0, 4)) + tk.Label(order_wrap, text="Порядок:", font=("Tahoma", 8), fg=COLORS["subtext"], bg=COLORS["surface"]).pack(side=tk.LEFT, padx=(0, 4)) + tk.Entry( + order_wrap, + textvariable=self.order_var, + width=5, + font=("Consolas", 9), + bg=COLORS["overlay"], + fg=COLORS["text"], + insertbackground=COLORS["text"], + relief=tk.FLAT, + highlightthickness=1, + highlightbackground=COLORS["border"], + ).pack(side=tk.LEFT, ipady=3, ipadx=4) + + move_wrap = tk.Frame(controls_wrap, bg=COLORS["surface"]) + move_wrap.pack(anchor=tk.E) + tk.Button( + move_wrap, + text="↑", + font=("Tahoma", 9), + width=3, + fg=COLORS["text"], + bg=COLORS["overlay"], + activebackground=COLORS["accent"], + activeforeground=COLORS["text"], + relief=tk.FLAT, + cursor="hand2", + command=self._on_move_up_click, + ).pack(side=tk.LEFT, padx=(0, 4)) + tk.Button( + move_wrap, + text="↓", + font=("Tahoma", 9), + width=3, + fg=COLORS["text"], + bg=COLORS["overlay"], + activebackground=COLORS["accent"], + activeforeground=COLORS["text"], + relief=tk.FLAT, + cursor="hand2", + command=self._on_move_down_click, + ).pack(side=tk.LEFT, padx=(0, 4)) + + btn_remove = tk.Button(move_wrap, 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) + btn_remove.pack(side=tk.LEFT) # Блок диапазонов на удаление (остальное попадёт в итоговое видео) ranges_lbl = tk.Label(inner, text="Удалить из видео (исключить эти фрагменты)", font=("Tahoma", 9), @@ -71,6 +144,12 @@ class VideoItem(ttk.Frame): ttk.Button(inner, text="+ Добавить диапазон на удаление", command=self.add_time_range).pack(anchor=tk.W) self.columnconfigure(0, weight=1) + self._bind_hover(card) + self._bind_hover(inner) + self._bind_hover(top) + self._bind_hover(text_wrap) + self._bind_hover(thumb_wrap) + self._bind_hover(thumb_label) def format_time(self, seconds): h = int(seconds // 3600) @@ -136,11 +215,79 @@ class VideoItem(ttk.Frame): self.on_remove(self) self.destroy() + def _on_move_up_click(self): + if self.on_move_up: + self.on_move_up(self) + + def _on_move_down_click(self): + if self.on_move_down: + self.on_move_down(self) + def get_duration(self): - if self._duration > 0: - return self._duration - try: - with VideoFileClip(self.file_path) as clip: - return clip.duration - except Exception: - return 0 + return self._duration + + def get_order(self): + raw = (self.order_var.get() or "").strip() + if not raw: + raise ValueError("Поле порядка не заполнено.") + order = int(raw) + if order <= 0: + raise ValueError("Порядок должен быть целым числом больше 0.") + return order + + def set_order(self, value): + self.order_var.set(str(int(value))) + + def set_media_info(self, duration=None, thumb_image=None, error_text=None): + if duration is not None and duration > 0: + self._duration = duration + if self._duration_label is not None: + self._duration_label.config(text=f"Длительность: {self.format_time(duration)}", fg=COLORS["subtext"]) + elif error_text and self._duration_label is not None: + self._duration_label.config(text=error_text, fg=COLORS["red"]) + + if self._thumb_label is None: + return + if thumb_image is not None and _HAS_PIL: + try: + photo = ImageTk.PhotoImage(thumb_image) + self._thumbnail_photo = photo + self._thumb_label.config(image=photo, text="") + return + except Exception: + pass + if error_text: + self._thumb_label.config(text="Без превью") + + def flash_reorder(self): + if self._card is None: + return + if self._flash_job is not None: + try: + self.after_cancel(self._flash_job) + except Exception: + pass + self._card.config(highlightbackground=COLORS["accent"], highlightthickness=2) + self._flash_job = self.after(450, self._restore_highlight) + + def _restore_highlight(self): + self._flash_job = None + if self._card is None: + return + if self._hovered: + self._card.config(highlightbackground=COLORS["accent"], highlightthickness=2) + else: + self._card.config(highlightbackground=COLORS["border"], highlightthickness=1) + + def _bind_hover(self, widget): + widget.bind("", self._on_hover_enter, add="+") + widget.bind("", self._on_hover_leave, add="+") + + def _on_hover_enter(self, _event=None): + self._hovered = True + if self._card is not None: + self._card.config(highlightbackground=COLORS["accent"], highlightthickness=2) + + def _on_hover_leave(self, _event=None): + self._hovered = False + self._restore_highlight()