This commit is contained in:
stirelshka8 2026-03-24 14:38:40 +03:00
parent 6b9042bee1
commit b8211b5220
5 changed files with 565 additions and 58 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
build/
BUILDS/
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/

View File

@ -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

View File

@ -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 = {

View File

@ -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:000:10, 0:200:40, 0:501:00, склеенные подряд.
3. НАСТРОЙКИ ВЫВОДА
4. НАСТРОЙКИ ВЫВОДА
Профиль: «Свои настройки», «YouTube» или «VK Video» подставляет рекомендуемые параметры для загрузки на эти платформы (MP4, 1080p, битрейт и т.д.). После выбора профиля параметры можно менять вручную.
Формат: MP4, AVI, MOV, MKV.
Качество: low / medium / high (влияет на битрейт).
FPS: Исходный или 24, 25, 30, 60.
Разрешение: Исходное, 720p или 1080p (масштабирование по высоте).
Общий вырез для всех файлов: можно задать один промежуток времени, который будет удалён из каждого видео (например, удалить интро 00:00:0000:00:15 у всех файлов сразу).
Кодек (preset): от ultrafast (быстро) до veryslow (медленнее, меньше размер).
Аудио битрейт: 128k320k.
Имя файла: префикс для итогового файла (к нему добавится дата и время).
«📁 Папка для сохранения» куда сохранить результат.
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)

View File

@ -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()