UPD
This commit is contained in:
parent
6b9042bee1
commit
b8211b5220
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1,5 @@
|
||||
build/
|
||||
BUILDS/
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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("<Configure>", 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("<Configure>", lambda e: canvas.itemconfigure(self._canvas_window_id, width=e.width))
|
||||
|
||||
def _on_mousewheel(event):
|
||||
canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
|
||||
@ -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("<Button-1>", lambda _e: self._open_url(project_url))
|
||||
|
||||
ttk.Button(win, text="Закрыть", command=win.destroy).pack(pady=(0, 12))
|
||||
|
||||
@staticmethod
|
||||
def _open_url(url):
|
||||
try:
|
||||
import webbrowser
|
||||
webbrowser.open_new_tab(url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _poll_progress(self):
|
||||
"""Обработка очереди прогресса из фонового потока (только в main thread)."""
|
||||
@ -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)
|
||||
|
||||
@ -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("<Enter>", self._on_hover_enter, add="+")
|
||||
widget.bind("<Leave>", 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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user