Обновление

This commit is contained in:
stirelshka8-vivos 2026-03-14 19:52:44 +03:00
parent 41beee6283
commit 324fa7440a
14 changed files with 1296 additions and 231 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# Файлы, игнорируемые по умолчанию
/shelf/
/workspace.xml
# Запросы HTTP-клиента в редакторе
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

14
.idea/AnimeVideoEditot.iml generated Normal file
View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 virtualenv at C:\Users\stirelshka8\PycharmProjects\AnimeVideoEditot\.venv" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

View File

@ -0,0 +1,137 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<Languages>
<language minSize="483" name="Python" />
</Languages>
</inspection_tool>
<inspection_tool class="PyInterpreterInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<list>
<option value="cups" />
<option value="asgiref" />
<option value="defusedxml" />
<option value="Django" />
<option value="django-constance" />
<option value="django-filter" />
<option value="django-qrcode" />
<option value="djangorestframework" />
<option value="et_xmlfile" />
<option value="Faker" />
<option value="fonttools" />
<option value="fpdf2" />
<option value="gunicorn" />
<option value="numpy" />
<option value="openpyxl" />
<option value="packaging" />
<option value="pandas" />
<option value="pillow" />
<option value="psycopg2-binary" />
<option value="pycups" />
<option value="python-barcode" />
<option value="python-dateutil" />
<option value="python-dotenv" />
<option value="pytz" />
<option value="setuptools" />
<option value="six" />
<option value="sqlparse" />
<option value="typing_extensions" />
<option value="tzdata" />
<option value="fastapi" />
<option value="uvicorn" />
<option value="sqlalchemy" />
<option value="alembic" />
<option value="python-jose" />
<option value="passlib" />
<option value="python-multipart" />
<option value="websockets" />
<option value="pydantic" />
<option value="pydantic-settings" />
<option value="email-validator" />
<option value="jinja2" />
<option value="aiofiles" />
<option value="redis" />
<option value="aioredis" />
<option value="pytest" />
<option value="pytest-asyncio" />
<option value="amqp" />
<option value="anyio" />
<option value="backports.tarfile" />
<option value="billiard" />
<option value="build" />
<option value="CacheControl" />
<option value="celery" />
<option value="certifi" />
<option value="cffi" />
<option value="charset-normalizer" />
<option value="cleo" />
<option value="click" />
<option value="click-didyoumean" />
<option value="click-plugins" />
<option value="click-repl" />
<option value="crashtest" />
<option value="cron_descriptor" />
<option value="cryptography" />
<option value="distlib" />
<option value="django-celery-beat" />
<option value="django-timezone-field" />
<option value="django_celery_results" />
<option value="dulwich" />
<option value="fastjsonschema" />
<option value="filelock" />
<option value="findpython" />
<option value="h11" />
<option value="httpcore" />
<option value="httpx" />
<option value="idna" />
<option value="importlib_metadata" />
<option value="installer" />
<option value="jaraco.classes" />
<option value="jaraco.context" />
<option value="jaraco.functools" />
<option value="jeepney" />
<option value="keyring" />
<option value="kombu" />
<option value="logger" />
<option value="more-itertools" />
<option value="msgpack" />
<option value="pbs-installer" />
<option value="pipenv" />
<option value="pkginfo" />
<option value="platformdirs" />
<option value="poetry" />
<option value="poetry-core" />
<option value="prompt_toolkit" />
<option value="mysqlclient" />
<option value="pycparser" />
<option value="pyproject_hooks" />
<option value="python-crontab" />
<option value="RapidFuzz" />
<option value="requests" />
<option value="requests-toolbelt" />
<option value="SecretStorage" />
<option value="shellingham" />
<option value="sniffio" />
<option value="tomlkit" />
<option value="trove-classifiers" />
<option value="urllib3" />
<option value="vine" />
<option value="virtualenv" />
<option value="wcwidth" />
<option value="zipp" />
<option value="zstandard" />
</list>
</option>
</inspection_tool>
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="N803" />
<option value="N802" />
</list>
</option>
</inspection_tool>
</profile>
</component>

View File

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

10
.idea/material_theme_project_new.xml generated Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="userId" value="620f319a:19ced018d5b:-7fda" />
</MTProjectMetadataState>
</option>
</component>
</project>

7
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.13 virtualenv at C:\Users\stirelshka8\PycharmProjects\AnimeVideoEditot\.venv" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 virtualenv at C:\Users\stirelshka8\PycharmProjects\AnimeVideoEditot\.venv" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/AnimeVideoEditot.iml" filepath="$PROJECT_DIR$/.idea/AnimeVideoEditot.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -1,3 +1,3 @@
# AnimeVideoEditot # Anime Video Editor
Приложение для создания единого видео файла из разных. Приложение для объединения нескольких видео в один файл. Поддерживает выбор временных диапазонов для каждого ролика, настройку формата (MP4, AVI, MOV, MKV) и качества экспорта.

View File

@ -1,98 +1,377 @@
import os import os
from moviepy.editor import VideoFileClip, concatenate_videoclips import subprocess
import tempfile import tempfile
import warnings
from moviepy import VideoFileClip, concatenate_videoclips
from moviepy.video.fx import Resize
from datetime import datetime from datetime import datetime
from core.logger import Logger from core.logger import Logger
from utils.file_utils import ensure_directory
# Метаданные в итоговое видео
APP_NAME = "Anime Video Editor"
APP_TAG = "Anime Video Editor"
# Этапы для отображения в окне выполнения
STAGE_PREPARE = 1 # Обработка фрагментов
STAGE_CONCAT = 2 # Объединение
STAGE_WRITE = 3 # Сохранение (кодирование)
try:
import proglog
class WriteProgressLogger(proglog.ProgressBarLogger):
"""Логгер прогресса записи видео для передачи в окно выполнения."""
def __init__(self, callback):
super().__init__()
self._callback = callback
def bars_callback(self, bar, attr, value, old_value=None):
if not self._callback or bar not in self.bars:
return
total = self.bars[bar].get("total")
if total and total > 0:
pct = value / total
self._callback(STAGE_WRITE, pct, f"Сохранение: {int(pct * 100)}%")
_HAS_PROGLOG = True
except Exception:
_HAS_PROGLOG = False
WriteProgressLogger = None
# При числе файлов выше порога обрабатываем по одному во временные файлы, затем склеиваем — меньше пиков по памяти
BATCH_THRESHOLD = 3
class CancelledError(Exception):
"""Остановка выполнения по запросу пользователя."""
pass
# Убираем шумные предупреждения о последних кадрах в некоторых MP4 (reader подставляет последний валидный кадр)
warnings.filterwarnings(
"ignore",
message=".*bytes wanted but 0 bytes read.*",
category=UserWarning,
)
def _get_ffmpeg_exe():
"""Путь к FFmpeg (тот же, что использует MoviePy/imageio)."""
try:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
except Exception:
pass
try:
from moviepy.config import get_setting
return get_setting("FFMPEG_BINARY") or "ffmpeg"
except Exception:
pass
return "ffmpeg"
def _write_video_metadata(file_path, metadata_dict, logger=None):
"""
Добавляет метаданные в видеофайл через FFmpeg (stream copy, без перекодирования).
metadata_dict: {"comment": "...", "title": "...", "encoder": "..."} и т.д.
"""
file_path = os.path.abspath(os.path.normpath(file_path))
if not metadata_dict or not os.path.isfile(file_path):
return
try:
ffmpeg_exe = _get_ffmpeg_exe()
out_dir = os.path.dirname(file_path)
if not out_dir:
out_dir = os.getcwd()
fd, temp_path = tempfile.mkstemp(suffix=os.path.splitext(file_path)[1], dir=out_dir)
os.close(fd)
cmd = [ffmpeg_exe, "-y", "-i", file_path, "-c", "copy"]
for key, value in metadata_dict.items():
if value is None or (isinstance(value, str) and not value.strip()):
continue
val = str(value).replace("\n", " ").replace("\r", "").strip()
if not val:
continue
cmd.extend(["-metadata", f"{key}={val}"])
cmd.append(temp_path)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode == 0 and os.path.isfile(temp_path):
os.replace(temp_path, file_path)
if logger:
logger.log("INFO", "Метаданные записаны в файл")
else:
if os.path.isfile(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
if logger:
logger.log("WARNING", "Не удалось записать метаданные (FFmpeg): " + (result.stderr or "")[:200])
except Exception as e:
if logger:
logger.log("WARNING", f"Метаданные не записаны: {e}")
class VideoProcessor: class VideoProcessor:
def __init__(self): def __init__(self, logger=None):
self.logger = Logger() self.logger = logger or Logger()
def process_videos(self, video_data, output_dir, output_format="mp4", def process_videos(self, video_data, output_dir, output_format="mp4",
quality="high", progress_callback=None): quality="high", progress_callback=None,
fps=None, preset="medium", audio_bitrate="192k",
target_height=None, filename_prefix="edited_video",
cancel_check=None):
""" """
Основной метод обработки видео cancel_check: callable(), возвращает True если нужно остановить выполнение.
""" """
self.logger.log("INFO", f"Начало обработки {len(video_data)} видео файлов") self.logger.log("INFO", f"Начало обработки {len(video_data)} видео файлов")
check = cancel_check if callable(cancel_check) else lambda: False
use_batch = len(video_data) > BATCH_THRESHOLD
if use_batch:
self.logger.log("INFO", f"Режим пакетной обработки (файлов > {BATCH_THRESHOLD}), экономия памяти")
return self._process_videos_batch(
video_data, output_dir, output_format, quality, progress_callback,
fps, preset, audio_bitrate, target_height, filename_prefix, check,
)
return self._process_videos_memory(
video_data, output_dir, output_format, quality, progress_callback,
fps, preset, audio_bitrate, target_height, filename_prefix, check,
)
def _process_videos_memory(self, video_data, output_dir, output_format, quality,
progress_callback, fps, preset, audio_bitrate,
target_height, filename_prefix, cancel_check):
"""Обработка всех клипов в памяти (подходит для малого числа файлов)."""
clips = [] clips = []
total_steps = sum(len(data['time_ranges']) for data in video_data) + 2 source_videos = []
current_step = 0 all_keep_ranges = []
try: try:
# Обработка каждого видео файла for data in video_data:
for i, data in enumerate(video_data): if cancel_check():
raise CancelledError()
video_path = data['path'] video_path = data['path']
time_ranges = data['time_ranges'] remove_ranges = data['time_ranges']
self.logger.log("INFO", f"Файл: {os.path.basename(video_path)}")
video = VideoFileClip(video_path)
source_videos.append(video)
keep = self._ranges_to_keep(remove_ranges, video.duration)
all_keep_ranges.append(keep)
for s, e in keep:
self.logger.log("INFO", f" Оставляем: {self.format_time(s)}{self.format_time(e)}")
self.logger.log("INFO", f"Обработка файла: {os.path.basename(video_path)}") total_clip_steps = sum(len(k) for k in all_keep_ranges)
current_step = 0
with VideoFileClip(video_path) as video: for i in range(len(video_data)):
# Вырезаем указанные диапазоны if cancel_check():
for start, end in time_ranges: raise CancelledError()
if progress_callback: video = source_videos[i]
progress = current_step / total_steps keep_ranges = all_keep_ranges[i]
progress_callback(progress, for start, end in keep_ranges:
f"Вырезание фрагмента {i + 1}/{len(video_data)}") if cancel_check():
raise CancelledError()
# Обрезаем видео по таймкодам if progress_callback:
clip = video.subclip(start, end) p = current_step / total_clip_steps if total_clip_steps else 1.0
clips.append(clip) progress_callback(STAGE_PREPARE, p, f"Обработка фрагмента {i + 1}/{len(video_data)}")
current_step += 1 clip = video.subclipped(start, end)
clips.append(clip)
self.logger.log("INFO", current_step += 1
f"Вырезан фрагмент: {self.format_time(start)} - {self.format_time(end)}")
if not clips: if not clips:
raise ValueError("Нет видео фрагментов для объединения") raise ValueError("Нет видео фрагментов для объединения")
# Объединение клипов if cancel_check():
raise CancelledError()
if progress_callback: if progress_callback:
progress_callback(current_step / total_steps, "Объединение видео фрагментов") progress_callback(STAGE_CONCAT, 1.0, "Объединение фрагментов")
self.logger.log("INFO", f"Объединение {len(clips)} фрагментов") self.logger.log("INFO", f"Объединение {len(clips)} фрагментов")
final_clip = concatenate_videoclips(clips) final_clip = concatenate_videoclips(clips)
if cancel_check():
# Настройка качества raise CancelledError()
bitrate = self.get_bitrate(quality) output_path = self._write_final(
final_clip, output_dir, output_format, quality, progress_callback,
# Генерация имени выходного файла fps, preset, audio_bitrate, target_height, filename_prefix, cancel_check,
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_filename = f"edited_video_{timestamp}.{output_format}"
output_path = os.path.join(output_dir, output_filename)
# Сохранение результата
if progress_callback:
progress_callback((total_steps - 1) / total_steps, "Сохранение итогового файла")
self.logger.log("INFO", f"Сохранение файла: {output_path}")
final_clip.write_videofile(
output_path,
codec='libx264',
bitrate=bitrate,
audio_codec='aac',
verbose=False,
logger=None
) )
# Закрытие клипов
final_clip.close() final_clip.close()
for clip in clips: for c in clips:
clip.close() try:
c.close()
except Exception:
pass
for v in source_videos:
try:
v.close()
except Exception:
pass
if progress_callback:
progress_callback(STAGE_WRITE, 1.0, "Готово")
self.logger.log("SUCCESS", f"Обработка завершена успешно: {output_path}") self.logger.log("SUCCESS", f"Обработка завершена успешно: {output_path}")
return output_path return output_path
except CancelledError:
except Exception as e: for c in clips:
self.logger.log("ERROR", f"Ошибка обработки: {str(e)}")
# Закрытие клипов в случае ошибки
for clip in clips:
try: try:
clip.close() c.close()
except: except Exception:
pass
for v in source_videos:
try:
v.close()
except Exception:
pass pass
raise raise
except Exception as e:
self.logger.log("ERROR", f"Ошибка обработки: {str(e)}")
for c in clips:
try:
c.close()
except Exception:
pass
for v in source_videos:
try:
v.close()
except Exception:
pass
raise
def _process_videos_batch(self, video_data, output_dir, output_format, quality,
progress_callback, fps, preset, audio_bitrate,
target_height, filename_prefix, cancel_check):
"""Обработка по одному файлу во временные ролики, затем склейка — меньше пиков памяти."""
temp_paths = []
n_files = len(video_data)
try:
ensure_directory(output_dir)
with tempfile.TemporaryDirectory(prefix="ave_") as tmpdir:
for i, data in enumerate(video_data):
if cancel_check():
raise CancelledError()
if progress_callback:
p = (i + 1) / n_files if n_files else 1.0
progress_callback(STAGE_PREPARE, p, f"Файл {i + 1}/{n_files}")
video_path = data['path']
remove_ranges = data['time_ranges']
keep = None
video = VideoFileClip(video_path)
try:
keep = self._ranges_to_keep(remove_ranges, video.duration)
if not keep:
current_step += 1
continue
part_clips = []
for start, end in keep:
part_clips.append(video.subclipped(start, end))
if len(part_clips) == 1:
part = part_clips[0]
else:
part = concatenate_videoclips(part_clips)
for c in part_clips:
try:
c.close()
except Exception:
pass
temp_path = os.path.join(tmpdir, f"part_{i:04d}.mp4")
write_kw = {"codec": "libx264", "bitrate": self.get_bitrate(quality), "audio_codec": "aac"}
if fps and fps > 0:
write_kw["fps"] = fps
try:
part.write_videofile(temp_path, **write_kw)
except TypeError:
part.write_videofile(temp_path, codec="libx264", bitrate=self.get_bitrate(quality), audio_codec="aac")
part.close()
temp_paths.append(temp_path)
finally:
try:
video.close()
except Exception:
pass
if not temp_paths:
raise ValueError("Нет видео фрагментов для объединения")
if cancel_check():
raise CancelledError()
if progress_callback:
progress_callback(STAGE_CONCAT, 1.0, "Финальная склейка")
clip_list = [VideoFileClip(p) for p in temp_paths]
try:
final_clip = concatenate_videoclips(clip_list)
if cancel_check():
raise CancelledError()
output_path = self._write_final(
final_clip, output_dir, output_format, quality, progress_callback,
fps, preset, audio_bitrate, target_height, filename_prefix, cancel_check,
)
final_clip.close()
finally:
for c in clip_list:
try:
c.close()
except Exception:
pass
if progress_callback:
progress_callback(STAGE_WRITE, 1.0, "Готово")
self.logger.log("SUCCESS", f"Обработка завершена успешно: {output_path}")
return output_path
except CancelledError:
raise
except Exception as e:
self.logger.log("ERROR", f"Ошибка обработки: {str(e)}")
raise
def _write_final(self, final_clip, output_dir, output_format, quality, progress_callback,
fps, preset, audio_bitrate, target_height, filename_prefix, cancel_check=None):
"""Масштабирование (если нужно) и запись итогового файла. final_clip закрывает вызывающий код."""
check = cancel_check if callable(cancel_check) else lambda: False
if check():
raise CancelledError()
to_write = final_clip
resized = None
if target_height and target_height > 0:
try:
if final_clip.h != target_height:
resized = final_clip.with_effects([Resize(height=target_height)])
to_write = resized
self.logger.log("INFO", f"Масштабирование до высоты {target_height}px")
except Exception as e:
self.logger.log("WARNING", f"Масштабирование пропущено: {e}")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_prefix = "".join(c for c in filename_prefix if c.isalnum() or c in " _-") or "edited_video"
output_filename = f"{safe_prefix}_{timestamp}.{output_format}"
output_path = os.path.join(output_dir, output_filename)
ensure_directory(output_dir)
if progress_callback:
progress_callback(STAGE_WRITE, 0.0, "Сохранение итогового файла...")
self.logger.log("INFO", f"Сохранение файла: {output_path}")
write_kw = {"codec": "libx264", "bitrate": self.get_bitrate(quality), "audio_codec": "aac"}
if fps is not None and fps > 0:
write_kw["fps"] = fps
write_kw["preset"] = preset
write_kw["audio_bitrate"] = audio_bitrate
if _HAS_PROGLOG and progress_callback and WriteProgressLogger is not None:
write_kw["logger"] = WriteProgressLogger(progress_callback)
try:
to_write.write_videofile(output_path, **write_kw)
except TypeError:
write_kw.pop("preset", None)
write_kw.pop("audio_bitrate", None)
write_kw.pop("logger", None)
to_write.write_videofile(output_path, **write_kw)
if resized is not None:
try:
resized.close()
except Exception:
pass
# Метаданные: программа, дата, название
meta = {
"comment": f"Отредактировано в {APP_NAME}. Дата: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}.",
"title": safe_prefix or "edited_video",
"encoder": APP_TAG,
"description": f"Создано в {APP_NAME} — объединение и нарезка видео.",
}
_write_video_metadata(output_path, meta, self.logger)
return output_path
def get_bitrate(self, quality): def get_bitrate(self, quality):
"""Определение битрейта в зависимости от качества""" """Определение битрейта в зависимости от качества"""
@ -107,5 +386,33 @@ class VideoProcessor:
"""Форматирование времени в читаемый вид""" """Форматирование времени в читаемый вид"""
hours = int(seconds // 3600) hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60) minutes = int((seconds % 3600) // 60)
seconds = int(seconds % 60) secs = int(seconds % 60)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}" return f"{hours:02d}:{minutes:02d}:{secs:02d}"
@staticmethod
def _ranges_to_keep(remove_ranges, duration):
"""
По диапазонам на удаление и длительности возвращает диапазоны для сохранения.
remove_ranges: список (start, end) что вырезать.
Возвращает список (start, end) что оставить и склеить.
"""
if not remove_ranges:
return [(0, duration)] if duration > 0 else []
# Сортируем и сливаем пересекающиеся
sorted_r = sorted((max(0, s), min(duration, e)) for s, e in remove_ranges if s < e)
merged = []
for s, e in sorted_r:
if merged and s <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
else:
merged.append((s, e))
# Обратное: оставляем всё, кроме merged
keep = []
t = 0
for rs, re in merged:
if t < rs:
keep.append((t, rs))
t = max(t, re)
if t < duration:
keep.append((t, duration))
return keep

View File

@ -1,167 +1,547 @@
# -*- coding: utf-8 -*-
import tkinter as tk import tkinter as tk
from tkinter import ttk, filedialog, messagebox from tkinter import ttk, filedialog, messagebox
import os import os
import threading
from queue import Queue, Empty
from gui.theme import setup_theme, COLORS
from gui.video_item import VideoItem from gui.video_item import VideoItem
from core.video_processor import VideoProcessor from core.video_processor import VideoProcessor, CancelledError
from core.logger import Logger from core.logger import Logger
import sys
import subprocess
class MainWindow: class MainWindow:
def __init__(self, root): def __init__(self, root):
self.root = root self.root = root
self.root.title("Video Editor Pro") self.root.title("Anime Video Editor")
self.root.geometry("900x700") self.root.geometry("1000x780")
self.root.minsize(800, 600)
self.root.configure(bg=COLORS["bg"])
setup_theme(root)
self.video_items = [] self.video_items = []
self.setup_ui()
self.logger = Logger() self.logger = Logger()
self._processing = False
self._progress_queue = Queue()
self._progress_win = None
self._progress_text = None
self._progress_bar_var = None
self._progress_stage_var = None
self._progress_close_btn = None
self._progress_stop_btn = None
self._cancel_event = None
self._setup_menu()
self.setup_ui()
self._poll_progress()
def _setup_menu(self):
menubar = tk.Menu(self.root)
self.root.config(menu=menubar)
help_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Справка", menu=help_menu)
help_menu.add_command(label="Инструкция", command=self.show_instruction)
help_menu.add_separator()
help_menu.add_command(label="О программе", command=self.show_about)
def setup_ui(self): def setup_ui(self):
# Main frame main = ttk.Frame(self.root, padding=(16, 12))
main_frame = ttk.Frame(self.root, padding="10") main.grid(row=0, column=0, sticky="nsew")
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
main.columnconfigure(0, weight=1)
main.rowconfigure(2, weight=1)
# Add files section # Заголовок
files_frame = ttk.LabelFrame(main_frame, text="Видео файлы", padding="5") header = ttk.Frame(main)
files_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10)) header.grid(row=0, column=0, sticky="ew", pady=(0, 12))
header.columnconfigure(0, weight=1)
ttk.Label(header, text="Anime Video Editor", style="Header.TLabel").grid(row=0, column=0, sticky="w")
ttk.Label(header, text="Объединение и нарезка видео", style="Subtext.TLabel").grid(row=1, column=0, sticky="w")
ttk.Button(files_frame, text="Добавить файлы", # Панель: добавить / очистить
command=self.add_video_files).pack(side=tk.LEFT, padx=(0, 10)) toolbar = ttk.Frame(main)
ttk.Button(files_frame, text="Очистить все", toolbar.grid(row=1, column=0, sticky="ew", pady=(0, 8))
command=self.clear_all).pack(side=tk.LEFT) toolbar.columnconfigure(1, weight=1)
ttk.Button(toolbar, text=" Добавить файлы", command=self.add_video_files).grid(row=0, column=0, padx=(0, 8))
ttk.Button(toolbar, text="Очистить всё", command=self.clear_all).grid(row=0, column=1, padx=(0, 8), sticky="w")
ttk.Button(toolbar, text="❓ Справка", command=self.show_instruction).grid(row=0, column=2, sticky="w")
# Scrollable frame for video items # Список видео (прокрутка)
canvas = tk.Canvas(main_frame) list_card = ttk.LabelFrame(main, text=" Видео в проекте ", style="Card.TLabelframe", padding=(10, 8))
scrollbar = ttk.Scrollbar(main_frame, orient="vertical", command=canvas.yview) list_card.grid(row=2, column=0, sticky="nsew", pady=(0, 12))
self.scrollable_frame = ttk.Frame(canvas) list_card.columnconfigure(0, weight=1)
list_card.rowconfigure(0, weight=1)
self.scrollable_frame.bind( canvas = tk.Canvas(
"<Configure>", list_card,
lambda e: canvas.configure(scrollregion=canvas.bbox("all")) bg=COLORS["surface"],
highlightthickness=0,
) )
scrollbar = ttk.Scrollbar(list_card)
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") canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set) canvas.configure(yscrollcommand=scrollbar.set)
canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) def _on_mousewheel(event):
scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S)) canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
canvas.bind("<Enter>", lambda e: canvas.bind_all("<MouseWheel>", _on_mousewheel))
canvas.bind("<Leave>", lambda e: canvas.unbind_all("<MouseWheel>"))
# Output settings canvas.grid(row=0, column=0, sticky="nsew")
output_frame = ttk.LabelFrame(main_frame, text="Настройки вывода", padding="5") scrollbar.grid(row=0, column=1, sticky="ns")
output_frame.grid(row=2, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(10, 0))
ttk.Label(output_frame, text="Формат:").grid(row=0, column=0, padx=(0, 5)) # Настройки вывода (расширенные)
out_card = ttk.LabelFrame(main, text=" Настройки вывода ", style="Card.TLabelframe", padding=(12, 10))
out_card.grid(row=3, column=0, sticky="ew", pady=(0, 12))
out_card.columnconfigure(0, weight=1)
row0 = ttk.Frame(out_card)
row0.grid(row=0, column=0, sticky="ew", pady=(0, 8))
row0.columnconfigure(1, weight=1)
ttk.Label(row0, text="Профиль").grid(row=0, column=0, padx=(0, 6), pady=2, sticky="w")
self.profile_var = tk.StringVar(value="custom")
self._profile_combo = ttk.Combobox(row0, textvariable=self.profile_var, width=18,
values=["Свои настройки", "YouTube", "VK Video"], state="readonly")
self._profile_combo.grid(row=0, column=1, padx=(0, 16), pady=2, sticky="w")
self._profile_combo.bind("<<ComboboxSelected>>", self._on_profile_changed)
row1 = ttk.Frame(out_card)
row1.grid(row=1, column=0, sticky="ew", pady=(0, 4))
row1.columnconfigure(1, weight=1)
ttk.Label(row1, text="Формат").grid(row=0, column=0, padx=(0, 6), pady=2, sticky="w")
self.format_var = tk.StringVar(value="mp4") self.format_var = tk.StringVar(value="mp4")
format_combo = ttk.Combobox(output_frame, textvariable=self.format_var, fmt_combo = ttk.Combobox(row1, textvariable=self.format_var, width=8,
values=["mp4", "avi", "mov", "mkv"], state="readonly") values=["mp4", "avi", "mov", "mkv"], state="readonly")
format_combo.grid(row=0, column=1, padx=(0, 20)) fmt_combo.grid(row=0, column=1, padx=(0, 16), pady=2, sticky="w")
ttk.Label(output_frame, text="Качество:").grid(row=0, column=2, padx=(0, 5)) ttk.Label(row1, text="Качество").grid(row=0, column=2, padx=(0, 6), pady=2, sticky="w")
self.quality_var = tk.StringVar(value="high") self.quality_var = tk.StringVar(value="high")
quality_combo = ttk.Combobox(output_frame, textvariable=self.quality_var, ttk.Combobox(row1, textvariable=self.quality_var, width=10,
values=["low", "medium", "high"], state="readonly") values=["low", "medium", "high"], state="readonly").grid(row=0, column=3, padx=(0, 16), pady=2, sticky="w")
quality_combo.grid(row=0, column=3, padx=(0, 20))
ttk.Button(output_frame, text="Выбрать папку для сохранения", ttk.Label(row1, text="FPS").grid(row=0, column=4, padx=(0, 6), pady=2, sticky="w")
command=self.select_output_dir).grid(row=0, column=4, padx=(0, 10)) self.fps_var = tk.StringVar(value="Исходный")
ttk.Combobox(row1, textvariable=self.fps_var, width=10,
values=["Исходный", "24", "25", "30", "60"], state="readonly").grid(row=0, column=5, padx=(0, 16), pady=2, sticky="w")
ttk.Label(row1, text="Разрешение").grid(row=0, column=6, padx=(0, 6), pady=2, sticky="w")
self.resolution_var = tk.StringVar(value="Исходное")
ttk.Combobox(row1, textvariable=self.resolution_var, width=12,
values=["Исходное", "720p", "1080p"], state="readonly").grid(row=0, column=7, padx=(0, 16), pady=2, sticky="w")
row2 = ttk.Frame(out_card)
row2.grid(row=2, column=0, sticky="ew", pady=(10, 0))
row2.columnconfigure(1, weight=1)
ttk.Label(row2, text="Кодек (preset)").grid(row=0, column=0, padx=(0, 6), pady=2, sticky="w")
self.preset_var = tk.StringVar(value="medium")
ttk.Combobox(row2, textvariable=self.preset_var, width=12,
values=["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"], state="readonly").grid(row=0, column=1, padx=(0, 16), pady=2, sticky="w")
ttk.Label(row2, text="Аудио битрейт").grid(row=0, column=2, padx=(0, 6), pady=2, sticky="w")
self.audio_bitrate_var = tk.StringVar(value="192k")
self._audio_combo = ttk.Combobox(row2, textvariable=self.audio_bitrate_var, width=8,
values=["128k", "192k", "256k", "320k", "384k"], state="readonly")
self._audio_combo.grid(row=0, column=3, padx=(0, 16), pady=2, sticky="w")
ttk.Label(row2, text="Имя файла").grid(row=0, column=4, padx=(0, 6), pady=2, sticky="w")
self.filename_prefix_var = tk.StringVar(value="edited_video")
ttk.Entry(row2, textvariable=self.filename_prefix_var, width=18).grid(row=0, column=5, padx=(0, 16), pady=2, sticky="w")
row3 = ttk.Frame(out_card)
row3.grid(row=3, column=0, sticky="ew", pady=(10, 0))
row3.columnconfigure(1, weight=1)
ttk.Button(row3, text="📁 Папка для сохранения", command=self.select_output_dir).grid(row=0, column=0, padx=(0, 12), pady=4)
self.output_dir_var = tk.StringVar(value=os.getcwd()) self.output_dir_var = tk.StringVar(value=os.getcwd())
ttk.Label(output_frame, textvariable=self.output_dir_var).grid(row=0, column=5) dir_label = ttk.Label(row3, textvariable=self.output_dir_var, style="Subtext.TLabel")
dir_label.grid(row=0, column=1, sticky="w", pady=4)
dir_label.configure(background=COLORS["surface"])
# Process button # Чекбоксы: открыть папку, закрыть окно выполнения, открыть лог
ttk.Button(main_frame, text="Выполнить", row4 = ttk.Frame(out_card)
command=self.process_videos, style="Accent.TButton").grid(row=3, column=0, pady=20) row4.grid(row=4, column=0, sticky="ew", pady=(10, 0))
self.open_folder_var = tk.BooleanVar(value=True)
ttk.Checkbutton(row4, text="Открыть папку с итоговым файлом после завершения",
variable=self.open_folder_var).grid(row=0, column=0, sticky="w", padx=(0, 20))
self.close_progress_win_var = tk.BooleanVar(value=False)
ttk.Checkbutton(row4, text="Закрыть окно выполнения после успеха",
variable=self.close_progress_win_var).grid(row=0, column=1, sticky="w", padx=(0, 20))
self.open_log_var = tk.BooleanVar(value=False)
ttk.Checkbutton(row4, text="Открыть папку с логом после завершения",
variable=self.open_log_var).grid(row=0, column=2, sticky="w")
# Кнопка выполнения и прогресс
action_frame = ttk.Frame(main)
action_frame.grid(row=4, column=0, sticky="ew", pady=(0, 8))
action_frame.columnconfigure(0, weight=1)
self.run_button = ttk.Button(action_frame, text="▶ Выполнить", style="Accent.TButton", command=self.process_videos)
self.run_button.grid(row=0, column=0, pady=(0, 8))
# Progress bar
self.progress_var = tk.DoubleVar() self.progress_var = tk.DoubleVar()
self.progress_bar = ttk.Progressbar(main_frame, variable=self.progress_var, maximum=100) self.progress_bar = ttk.Progressbar(action_frame, variable=self.progress_var, maximum=100)
self.progress_bar.grid(row=4, column=0, columnspan=2, sticky=(tk.W, tk.E)) self.progress_bar.grid(row=1, column=0, sticky="ew", pady=(0, 4))
action_frame.columnconfigure(0, weight=1)
# Status label
self.status_var = tk.StringVar(value="Готов к работе") self.status_var = tk.StringVar(value="Готов к работе")
ttk.Label(main_frame, textvariable=self.status_var).grid(row=5, column=0, columnspan=2) ttk.Label(action_frame, textvariable=self.status_var, style="Subtext.TLabel").grid(row=2, column=0, sticky="w")
# Configure grid weights
main_frame.columnconfigure(0, weight=1)
main_frame.rowconfigure(1, weight=1)
def add_video_files(self): def add_video_files(self):
files = filedialog.askopenfilenames( files = filedialog.askopenfilenames(
title="Выберите видео файлы", title="Выберите видео файлы",
filetypes=[ filetypes=[
("Видео файлы", "*.mp4 *.avi *.mov *.mkv *.wmv *.flv *.webm"), ("Видео", "*.mp4 *.avi *.mov *.mkv *.wmv *.flv *.webm"),
("Все файлы", "*.*") ("Все файлы", "*.*")
] ]
) )
for path in files:
for file_path in files: item = VideoItem(self.scrollable_frame, path, on_remove=lambda i: self.video_items.remove(i))
video_item = VideoItem(self.scrollable_frame, file_path) item.pack(fill=tk.X, padx=4, pady=4)
video_item.pack(fill=tk.X, padx=5, pady=2) self.video_items.append(item)
self.video_items.append(video_item)
self.update_status(f"Добавлено файлов: {len(files)}") self.update_status(f"Добавлено файлов: {len(files)}")
def clear_all(self): def clear_all(self):
for item in self.video_items: for item in self.video_items:
item.destroy() item.destroy()
self.video_items.clear() self.video_items.clear()
self.update_status("Все файлы удалены") self.update_status("Список очищен")
def select_output_dir(self): def select_output_dir(self):
directory = filedialog.askdirectory(title="Выберите папку для сохранения") d = filedialog.askdirectory(title="Папка для сохранения")
if directory: if d:
self.output_dir_var.set(directory) self.output_dir_var.set(d)
def update_status(self, message): # Рекомендуемые настройки для платформ (YouTube, VK Video)
self.status_var.set(message) OUTPUT_PRESETS = {
self.root.update() "YouTube": {
"format": "mp4",
"quality": "high",
"fps": "30",
"resolution": "1080p",
"preset": "medium",
"audio_bitrate": "384k",
"filename_prefix": "youtube",
},
"VK Video": {
"format": "mp4",
"quality": "high",
"fps": "30",
"resolution": "1080p",
"preset": "medium",
"audio_bitrate": "256k",
"filename_prefix": "vk_video",
},
}
def _on_profile_changed(self, event=None):
profile = self.profile_var.get()
if profile == "Свои настройки" or profile not in self.OUTPUT_PRESETS:
return
preset = self.OUTPUT_PRESETS.get(profile)
if not preset:
return
self.format_var.set(preset.get("format", self.format_var.get()))
self.quality_var.set(preset.get("quality", self.quality_var.get()))
self.fps_var.set(preset.get("fps", self.fps_var.get()))
self.resolution_var.set(preset.get("resolution", self.resolution_var.get()))
self.preset_var.set(preset.get("preset", self.preset_var.get()))
self.audio_bitrate_var.set(preset.get("audio_bitrate", self.audio_bitrate_var.get()))
self.filename_prefix_var.set(preset.get("filename_prefix", self.filename_prefix_var.get()))
self.update_status(f"Применён профиль: {profile}")
def update_status(self, msg):
self.status_var.set(msg)
self.root.update_idletasks()
def _open_progress_window(self):
"""Открывает окно процесса выполнения (лог + прогресс)."""
if self._progress_win is not None and self._progress_win.winfo_exists():
self._progress_win.destroy()
win = tk.Toplevel(self.root)
win.title("Выполнение")
win.geometry("480x320")
win.minsize(360, 240)
win.configure(bg=COLORS["bg"])
win.transient(self.root)
self._progress_win = win
fr = tk.Frame(win, bg=COLORS["bg"], padx=12, pady=10)
fr.pack(fill=tk.BOTH, expand=True)
tk.Label(fr, text="Процесс выполнения", font=("Tahoma", 11, "bold"),
fg=COLORS["accent"], bg=COLORS["bg"]).pack(anchor=tk.W)
self._progress_stage_var = tk.StringVar(value="Этап 1/3: Ожидание...")
tk.Label(fr, textvariable=self._progress_stage_var, font=("Tahoma", 9),
fg=COLORS["text"], bg=COLORS["bg"]).pack(anchor=tk.W, pady=(2, 4))
self._progress_bar_var = tk.DoubleVar(value=0)
pbar = ttk.Progressbar(fr, variable=self._progress_bar_var, maximum=100)
pbar.pack(fill=tk.X, pady=(0, 8))
log_label = tk.Label(fr, text="Лог:", font=("Tahoma", 9), fg=COLORS["subtext"], bg=COLORS["bg"])
log_label.pack(anchor=tk.W)
text_fr = tk.Frame(fr, bg=COLORS["surface"])
text_fr.pack(fill=tk.BOTH, expand=True, pady=(4, 8))
self._progress_text = tk.Text(text_fr, wrap=tk.WORD, font=("Consolas", 9), height=10,
bg=COLORS["surface"], fg=COLORS["text"],
insertbackground=COLORS["text"], relief=tk.FLAT, padx=8, pady=6)
scroll = ttk.Scrollbar(text_fr)
self._progress_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scroll.pack(side=tk.RIGHT, fill=tk.Y)
self._progress_text.configure(yscrollcommand=scroll.set)
scroll.configure(command=self._progress_text.yview)
self._progress_text.insert(tk.END, "Старт обработки...\n")
self._progress_text.config(state=tk.DISABLED)
btn_fr = tk.Frame(fr, bg=COLORS["bg"])
btn_fr.pack(fill=tk.X)
self._progress_stop_btn = ttk.Button(btn_fr, text="⏹ Остановить", command=self._request_stop)
self._progress_stop_btn.pack(side=tk.LEFT, padx=(0, 12))
self._progress_close_btn = ttk.Button(btn_fr, text="Закрыть", state="disabled",
command=self._close_progress_window)
self._progress_close_btn.pack(side=tk.RIGHT)
win.protocol("WM_DELETE_WINDOW", lambda: None) # запрет закрытия крестиком до конца
def _append_progress_log(self, msg):
if self._progress_text is None:
return
try:
self._progress_text.config(state=tk.NORMAL)
self._progress_text.insert(tk.END, msg + "\n")
self._progress_text.see(tk.END)
self._progress_text.config(state=tk.DISABLED)
except Exception:
pass
def _request_stop(self):
"""Запрос остановки выполнения (устанавливает флаг для worker)."""
if self._cancel_event is not None:
self._cancel_event.set()
if self._progress_stop_btn is not None:
self._progress_stop_btn.config(state="disabled", text="Остановка...")
def _open_folder(self, path):
"""Открыть папку в проводнике / файловом менеджере ОС."""
folder = os.path.dirname(os.path.abspath(path))
if not os.path.isdir(folder):
return
if sys.platform == "win32":
os.startfile(folder)
elif sys.platform == "darwin":
subprocess.run(["open", folder], check=False)
else:
subprocess.run(["xdg-open", folder], check=False)
def _close_progress_window(self):
if self._progress_win is not None:
try:
self._progress_win.destroy()
except Exception:
pass
self._progress_win = None
self._progress_text = None
self._progress_bar_var = None
self._progress_stage_var = None
self._progress_close_btn = None
self._progress_stop_btn = None
INSTRUCTION_TEXT = """
Anime Video Editor объединение нескольких видео в один файл с возможностью вырезать ненужные фрагменты.
1. ДОБАВЛЕНИЕ ВИДЕО
Нажмите « Добавить файлы» и выберите один или несколько видео (MP4, AVI, MOV, MKV и др.).
Файлы появятся в списке «Видео в проекте». Порядок в списке = порядок в итоговом ролике.
Удалить файл из проекта: кнопка «» справа от имени файла.
«Очистить всё» удаляет все файлы из списка.
2. ЧТО ВЫРЕЗАТЬ ИЗ КАЖДОГО ВИДЕО
У каждого файла есть блок «Удалить из видео (исключить эти фрагменты)».
Укажите диапазоны времени, которые нужно ВЫРЕЗАТЬ (удалить). Всё остальное попадёт в итоговое видео.
Формат времени: ЧЧ:ММ:СС или ММ:СС или только секунды (например: 00:01:30, 1:30, 90).
Кнопка «+ Добавить диапазон на удаление» добавить ещё один фрагмент на вырезку.
Если не добавлять ни одного диапазона в результат попадёт всё видео целиком.
Пример: ролик 1 минута, удалить с 0:10 по 0:20 и с 0:40 по 0:50 в итоге будут куски 0:000:10, 0:200:40, 0:501:00, склеенные подряд.
3. НАСТРОЙКИ ВЫВОДА
Профиль: «Свои настройки», «YouTube» или «VK Video» подставляет рекомендуемые параметры для загрузки на эти платформы (MP4, 1080p, битрейт и т.д.). После выбора профиля параметры можно менять вручную.
Формат: MP4, AVI, MOV, MKV.
Качество: low / medium / high (влияет на битрейт).
FPS: Исходный или 24, 25, 30, 60.
Разрешение: Исходное, 720p или 1080p (масштабирование по высоте).
Кодек (preset): от ultrafast (быстро) до veryslow (медленнее, меньше размер).
Аудио битрейт: 128k320k.
Имя файла: префикс для итогового файла (к нему добавится дата и время).
«📁 Папка для сохранения» куда сохранить результат.
4. ЗАПУСК ОБРАБОТКИ
Нажмите « Выполнить».
Обработка идёт в фоне окно остаётся отзывчивым, можно смотреть прогресс.
После завершения появится сообщение с путём к файлу. Рядом сохраняется лог (_log.txt).
При большом количестве файлов (больше 3) включается режим с меньшим потреблением памяти.
"""
def show_instruction(self):
win = tk.Toplevel(self.root)
win.title("Инструкция — Anime Video Editor")
win.geometry("560x520")
win.minsize(400, 300)
win.configure(bg=COLORS["bg"])
win.transient(self.root)
win.grab_set()
fr = tk.Frame(win, bg=COLORS["bg"], padx=12, pady=12)
fr.pack(fill=tk.BOTH, expand=True)
text = tk.Text(fr, wrap=tk.WORD, font=("Tahoma", 10), bg=COLORS["surface"], fg=COLORS["text"],
insertbackground=COLORS["text"], relief=tk.FLAT, padx=10, pady=10)
scroll = ttk.Scrollbar(fr)
text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scroll.pack(side=tk.RIGHT, fill=tk.Y)
text.configure(yscrollcommand=scroll.set)
scroll.configure(command=text.yview)
text.insert(tk.END, self.INSTRUCTION_TEXT.strip())
text.config(state=tk.DISABLED)
ttk.Button(win, text="Закрыть", command=win.destroy).pack(pady=(0, 10))
def show_about(self):
messagebox.showinfo(
"О программе",
"Anime Video Editor ver. 0.0.8\n\n"
"Объединение и нарезка видео: несколько файлов в один с вырезкой указанных фрагментов.\n\n"
"Обработка в фоне, поддержка больших и множества файлов."
"Автор - stirelshka8"
"Страница проекта - https://git.tuxops.ru/stirelshka8/AnimeVideoEditot"
)
def _poll_progress(self):
"""Обработка очереди прогресса из фонового потока (только в main thread)."""
try:
while True:
msg = self._progress_queue.get_nowait()
if msg == "done":
continue
if isinstance(msg, tuple):
if len(msg) == 3 and msg[0] == "result":
_, success, payload = msg
self._on_processing_done(success, payload)
elif len(msg) == 3 and isinstance(msg[0], int):
stage, p, text = msg
self.progress_var.set(p * 100)
self.status_var.set(text)
if self._progress_bar_var is not None:
self._progress_bar_var.set(p * 100)
if self._progress_stage_var is not None:
self._progress_stage_var.set(f"Этап {stage}/3: {text}")
self._append_progress_log(text)
except Empty:
pass
if self.root.winfo_exists():
self.root.after(200, self._poll_progress)
def _on_processing_done(self, success, payload):
self._processing = False
self._cancel_event = None
self.run_button.config(state="normal", text="▶ Выполнить")
self.progress_var.set(100 if success else 0)
if success:
output_path = payload
fmt = self.format_var.get()
log_path = output_path.replace(f".{fmt}", "_log.txt")
self.logger.save_log(log_path)
self.status_var.set(f"Готово: {output_path}")
self._append_progress_log("Готово. Файл: " + output_path)
messagebox.showinfo("Успех", f"Обработка завершена.\nФайл: {output_path}")
if self.open_folder_var.get() or self.open_log_var.get():
self._open_folder(output_path)
if self.close_progress_win_var.get():
self._close_progress_window()
else:
err = payload
self.status_var.set(err)
if err == "Отменено пользователем":
self.logger.log("INFO", err)
self._append_progress_log(err)
messagebox.showinfo("Остановлено", "Выполнение остановлено пользователем.")
else:
self.logger.log("ERROR", err)
self._append_progress_log("Ошибка: " + err)
messagebox.showerror("Ошибка", err)
if self._progress_bar_var is not None:
self._progress_bar_var.set(100 if success else 0)
if self._progress_win is not None and self._progress_win.winfo_exists():
self._progress_win.protocol("WM_DELETE_WINDOW", self._close_progress_window)
self._progress_win.title("Выполнение завершено")
if self._progress_close_btn is not None:
self._progress_close_btn.config(state="normal")
if self._progress_stop_btn is not None:
self._progress_stop_btn.config(state="disabled", text="⏹ Остановить")
self.root.after(0, lambda: self.progress_var.set(0))
def process_videos(self): def process_videos(self):
if self._processing:
return
if not self.video_items: if not self.video_items:
messagebox.showerror("Ошибка", "Добавьте хотя бы один видео файл") messagebox.showerror("Ошибка", "Добавьте хотя бы один видео файл")
return return
# Collect video data
video_data = [] video_data = []
for item in self.video_items: for item in self.video_items:
time_ranges = item.get_time_ranges() video_data.append({"path": item.file_path, "time_ranges": item.get_time_ranges()})
if not time_ranges: # Use entire video if no ranges specified
time_ranges = [(0, item.get_duration())]
video_data.append({
'path': item.file_path,
'time_ranges': time_ranges
})
output_format = self.format_var.get() fps_val = self.fps_var.get()
output_quality = self.quality_var.get() fps = None if fps_val == "Исходный" else int(fps_val)
res_val = self.resolution_var.get()
target_height = 720 if res_val == "720p" else (1080 if res_val == "1080p" else None)
output_dir = self.output_dir_var.get() output_dir = self.output_dir_var.get()
output_format = self.format_var.get()
quality = self.quality_var.get()
preset = self.preset_var.get()
audio_bitrate = self.audio_bitrate_var.get()
filename_prefix = (self.filename_prefix_var.get() or "edited_video").strip()
progress_queue = self._progress_queue
logger = self.logger
cancel_event = threading.Event()
self._cancel_event = cancel_event
try: def worker():
processor = VideoProcessor() try:
processor = VideoProcessor(logger=logger)
def progress_callback(progress, message): def on_progress(stage, progress, message):
self.progress_var.set(progress * 100) progress_queue.put((stage, progress, message))
self.update_status(message)
output_path = processor.process_videos( output_path = processor.process_videos(
video_data=video_data, video_data=video_data,
output_dir=output_dir, output_dir=output_dir,
output_format=output_format, output_format=output_format,
quality=output_quality, quality=quality,
progress_callback=progress_callback progress_callback=on_progress,
) fps=fps,
preset=preset,
audio_bitrate=audio_bitrate,
target_height=target_height,
filename_prefix=filename_prefix,
cancel_check=cancel_event.is_set,
)
progress_queue.put(("result", True, output_path))
except CancelledError:
progress_queue.put(("result", False, "Отменено пользователем"))
except Exception as e:
progress_queue.put(("result", False, str(e)))
finally:
progress_queue.put("done")
# Save log self._processing = True
log_path = output_path.replace(f".{output_format}", "_log.txt") self.run_button.config(state="disabled", text="Работает...")
self.logger.save_log(log_path) self.status_var.set("Обработка...")
self.progress_var.set(0)
self.update_status(f"Готово! Файл сохранен: {output_path}") self._open_progress_window()
messagebox.showinfo("Успех", f"Обработка завершена!\nФайл: {output_path}") t = threading.Thread(target=worker, daemon=True)
t.start()
except Exception as e:
error_msg = f"Ошибка обработки: {str(e)}"
self.update_status(error_msg)
self.logger.log("ERROR", error_msg)
messagebox.showerror("Ошибка", error_msg)
finally:
self.progress_var.set(0)

142
gui/theme.py Normal file
View File

@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
"""Стили и тема приложения — тёмная тема в стиле Catppuccin Mocha."""
import tkinter as tk
from tkinter import ttk
# Палитра Catppuccin Mocha
COLORS = {
"bg": "#1e1e2e", # Base
"surface": "#313244", # Surface0
"overlay": "#45475a", # Surface1
"text": "#cdd6f4", # Text
"subtext": "#a6adc8", # Subtext0
"accent": "#89b4fa", # Blue
"accent_hover": "#b4befe", # Lavender
"green": "#a6e3a1", # Green
"red": "#f38ba8", # Red
"yellow": "#f9e2af", # Yellow
"border": "#585b70", # Surface2
}
def setup_theme(root: tk.Tk):
"""Применяет тёмную тему к окну и настраивает ttk.Style."""
root.configure(bg=COLORS["bg"])
root.option_add("*Font", "Tahoma 10")
style = ttk.Style(root)
style.theme_use("clam")
# Общие
style.configure(
".",
background=COLORS["bg"],
foreground=COLORS["text"],
fieldbackground=COLORS["surface"],
troughcolor=COLORS["overlay"],
borderwidth=0,
)
style.map(".", background=[("active", COLORS["overlay"])])
# Frame / карточки
style.configure(
"Card.TFrame",
background=COLORS["surface"],
relief="flat",
)
style.configure(
"Card.TLabelframe",
background=COLORS["surface"],
foreground=COLORS["text"],
)
style.configure(
"Card.TLabelframe.Label",
background=COLORS["surface"],
foreground=COLORS["accent"],
font="Tahoma 10 bold",
)
# Кнопки
style.configure(
"TButton",
background=COLORS["overlay"],
foreground=COLORS["text"],
padding=(12, 6),
font="Tahoma 9",
)
style.map(
"TButton",
background=[("active", COLORS["border"]), ("pressed", COLORS["accent"])],
foreground=[("active", COLORS["text"])],
)
# Акцентная кнопка (Выполнить)
style.configure(
"Accent.TButton",
background=COLORS["accent"],
foreground=COLORS["bg"],
padding=(20, 10),
font="Tahoma 10 bold",
)
style.map(
"Accent.TButton",
background=[("active", COLORS["accent_hover"]), ("pressed", COLORS["overlay"])],
foreground=[("active", COLORS["bg"])],
)
# Поля ввода
style.configure(
"TEntry",
fieldbackground=COLORS["surface"],
foreground=COLORS["text"],
insertcolor=COLORS["text"],
padding=6,
)
# Combobox
style.configure(
"TCombobox",
fieldbackground=COLORS["surface"],
background=COLORS["overlay"],
foreground=COLORS["text"],
arrowcolor=COLORS["text"],
padding=6,
)
style.map("TCombobox", fieldbackground=[("readonly", COLORS["surface"])])
# Progressbar
style.configure(
"TProgressbar",
background=COLORS["accent"],
troughcolor=COLORS["overlay"],
borderwidth=0,
thickness=8,
)
# Scrollbar
style.configure(
"TScrollbar",
background=COLORS["overlay"],
troughcolor=COLORS["surface"],
borderwidth=0,
arrowcolor=COLORS["text"],
)
style.map("TScrollbar", background=[("active", COLORS["border"])])
# Label — шрифт одной строкой, иначе Tcl воспринимает "UI" как размер
style.configure("TLabel", background=COLORS["bg"], foreground=COLORS["text"])
style.configure(
"Header.TLabel",
background=COLORS["bg"],
foreground=COLORS["text"],
font="Tahoma 16 bold",
)
style.configure(
"Subtext.TLabel",
background=COLORS["surface"],
foreground=COLORS["subtext"],
font="Tahoma 9",
)
return style

View File

@ -1,108 +1,146 @@
# -*- coding: utf-8 -*-
import tkinter as tk import tkinter as tk
from tkinter import ttk from tkinter import ttk
import os import os
from moviepy.editor import VideoFileClip from moviepy import VideoFileClip
from utils.file_utils import get_file_size
from gui.theme import COLORS
class VideoItem(ttk.Frame): class VideoItem(ttk.Frame):
def __init__(self, parent, file_path): """Карточка одного видео в списке с диапазонами и стильным оформлением."""
def __init__(self, parent, file_path, on_remove=None):
super().__init__(parent) super().__init__(parent)
self.file_path = file_path self.file_path = file_path
self.time_ranges = [] self.time_ranges = []
self._duration = 0
self.on_remove = on_remove
self.setup_ui() self.setup_ui()
def setup_ui(self): def setup_ui(self):
# File info # Контейнер-карточка с фоном
file_name = os.path.basename(self.file_path) self.configure(style="Card.TFrame")
ttk.Label(self, text=file_name, font=('Arial', 9, 'bold')).grid(row=0, column=0, sticky=tk.W) card = tk.Frame(self, bg=COLORS["surface"], highlightbackground=COLORS["border"], highlightthickness=1)
card.pack(fill=tk.BOTH, expand=True, padx=0, pady=0)
inner = tk.Frame(card, bg=COLORS["surface"], padx=12, pady=10)
inner.pack(fill=tk.BOTH, expand=True)
# Верхняя строка: имя файла + размер, длительность, кнопка удалить
top = tk.Frame(inner, bg=COLORS["surface"])
top.pack(fill=tk.X, pady=(0, 6))
file_name = os.path.basename(self.file_path)
try:
size_str = get_file_size(self.file_path)
file_info = f"{file_name} · {size_str}"
except Exception:
file_info = file_name
lbl_name = tk.Label(top, text=file_info, font=("Tahoma", 10, "bold"),
fg=COLORS["text"], bg=COLORS["surface"])
lbl_name.pack(side=tk.LEFT)
# Duration info
try: try:
with VideoFileClip(self.file_path) as clip: with VideoFileClip(self.file_path) as clip:
duration = clip.duration self._duration = clip.duration
duration_str = self.format_time(duration) duration_str = self.format_time(self._duration)
ttk.Label(self, text=f"Длительность: {duration_str}").grid(row=1, column=0, sticky=tk.W) lbl_dur = tk.Label(top, text=f" · {duration_str}", font=("Tahoma", 9),
except: fg=COLORS["subtext"], bg=COLORS["surface"])
duration_str = "Неизвестно" lbl_dur.pack(side=tk.LEFT)
ttk.Label(self, text="Ошибка чтения файла").grid(row=1, column=0, sticky=tk.W) except Exception:
lbl_err = tk.Label(top, text=" · Ошибка чтения", font=("Tahoma", 9),
fg=COLORS["red"], bg=COLORS["surface"])
lbl_err.pack(side=tk.LEFT)
# Time ranges frame btn_remove = tk.Button(top, text="", font=("Tahoma", 9), fg=COLORS["text"],
ranges_frame = ttk.LabelFrame(self, text="Временные диапазоны") bg=COLORS["overlay"], activebackground=COLORS["red"],
ranges_frame.grid(row=2, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) activeforeground=COLORS["text"], relief=tk.FLAT, cursor="hand2",
command=self._on_remove_click)
btn_remove.pack(side=tk.RIGHT)
self.ranges_container = ttk.Frame(ranges_frame) # Блок диапазонов на удаление (остальное попадёт в итоговое видео)
self.ranges_container.pack(fill=tk.X, padx=5, pady=5) ranges_lbl = tk.Label(inner, text="Удалить из видео (исключить эти фрагменты)", font=("Tahoma", 9),
fg=COLORS["subtext"], bg=COLORS["surface"])
ranges_lbl.pack(anchor=tk.W, pady=(4, 4))
# Add range button self.ranges_container = tk.Frame(inner, bg=COLORS["surface"])
ttk.Button(ranges_frame, text="+ Добавить диапазон", self.ranges_container.pack(fill=tk.X, pady=(0, 6))
command=self.add_time_range).pack(pady=5)
# Buttons ttk.Button(inner, text="+ Добавить диапазон на удаление", command=self.add_time_range).pack(anchor=tk.W)
ttk.Button(self, text="Удалить",
command=self.destroy).grid(row=0, column=2, rowspan=2, padx=5)
self.columnconfigure(0, weight=1) self.columnconfigure(0, weight=1)
def format_time(self, seconds): def format_time(self, seconds):
hours = int(seconds // 3600) h = int(seconds // 3600)
minutes = int((seconds % 3600) // 60) m = int((seconds % 3600) // 60)
seconds = int(seconds % 60) s = int(seconds % 60)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}" return f"{h:02d}:{m:02d}:{s:02d}"
def add_time_range(self, start_time=0, end_time=0): def add_time_range(self, start_time=0, end_time=0):
range_frame = ttk.Frame(self.ranges_container) row = tk.Frame(self.ranges_container, bg=COLORS["surface"])
range_frame.pack(fill=tk.X, pady=2) row.pack(fill=tk.X, pady=2)
ttk.Label(range_frame, text="От:").pack(side=tk.LEFT) tk.Label(row, text="Удалить с", font=("Tahoma", 8), fg=COLORS["subtext"], bg=COLORS["surface"]).pack(side=tk.LEFT, padx=(0, 4))
start_var = tk.StringVar(value=self.format_time(start_time)) start_var = tk.StringVar(value=self.format_time(start_time))
start_entry = ttk.Entry(range_frame, textvariable=start_var, width=8) ent_start = tk.Entry(row, textvariable=start_var, width=10, font=("Consolas", 9),
start_entry.pack(side=tk.LEFT, padx=(0, 10)) bg=COLORS["overlay"], fg=COLORS["text"], insertbackground=COLORS["text"],
relief=tk.FLAT, highlightthickness=1, highlightbackground=COLORS["border"])
ent_start.pack(side=tk.LEFT, padx=(0, 12), ipady=4, ipadx=6)
ttk.Label(range_frame, text="До:").pack(side=tk.LEFT) tk.Label(row, text="по", font=("Tahoma", 8), fg=COLORS["subtext"], bg=COLORS["surface"]).pack(side=tk.LEFT, padx=(0, 4))
end_var = tk.StringVar(value=self.format_time(end_time)) end_var = tk.StringVar(value=self.format_time(end_time))
end_entry = ttk.Entry(range_frame, textvariable=end_var, width=8) ent_end = tk.Entry(row, textvariable=end_var, width=10, font=("Consolas", 9),
end_entry.pack(side=tk.LEFT, padx=(0, 10)) bg=COLORS["overlay"], fg=COLORS["text"], insertbackground=COLORS["text"],
relief=tk.FLAT, highlightthickness=1, highlightbackground=COLORS["border"])
ent_end.pack(side=tk.LEFT, padx=(0, 8), ipady=4, ipadx=6)
def remove_range(): def remove_range():
range_frame.destroy() row.destroy()
self.time_ranges = [r for r in self.time_ranges if r['frame'] != range_frame] self.time_ranges[:] = [r for r in self.time_ranges if r["frame"] != row]
ttk.Button(range_frame, text="×", width=3, btn_del = tk.Button(row, text="×", font=("Tahoma", 9), fg=COLORS["subtext"],
command=remove_range).pack(side=tk.LEFT) bg=COLORS["surface"], activebackground=COLORS["red"],
activeforeground=COLORS["text"], relief=tk.FLAT, cursor="hand2",
command=remove_range)
btn_del.pack(side=tk.LEFT)
self.time_ranges.append({ self.time_ranges.append({"frame": row, "start_var": start_var, "end_var": end_var})
'frame': range_frame,
'start_var': start_var,
'end_var': end_var
})
def get_time_ranges(self): def get_time_ranges(self):
ranges = [] out = []
for range_data in self.time_ranges: for r in self.time_ranges:
try: try:
start_time = self.parse_time(range_data['start_var'].get()) start = self.parse_time(r["start_var"].get())
end_time = self.parse_time(range_data['end_var'].get()) end = self.parse_time(r["end_var"].get())
if start < end:
if start_time < end_time: out.append((start, end))
ranges.append((start_time, end_time)) except (ValueError, TypeError):
except:
continue continue
return ranges return out
def parse_time(self, time_str): def parse_time(self, time_str):
parts = time_str.split(':') time_str = (time_str or "").strip()
if len(parts) == 3: # HH:MM:SS if not time_str:
hours, minutes, seconds = map(int, parts) return 0
return hours * 3600 + minutes * 60 + seconds parts = time_str.split(":")
elif len(parts) == 2: # MM:SS if len(parts) == 3:
minutes, seconds = map(int, parts) return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])
return minutes * 60 + seconds if len(parts) == 2:
else: # SS return int(parts[0]) * 60 + float(parts[1])
return int(time_str) return float(parts[0])
def _on_remove_click(self):
if self.on_remove:
self.on_remove(self)
self.destroy()
def get_duration(self): def get_duration(self):
if self._duration > 0:
return self._duration
try: try:
with VideoFileClip(self.file_path) as clip: with VideoFileClip(self.file_path) as clip:
return clip.duration return clip.duration
except: except Exception:
return 0 return 0

View File

@ -1,2 +1,4 @@
moviepy==1.0.3 # Видеоредактор — зависимости (Windows и Linux)
Pillow==10.0.0 # Используются версии с готовыми wheel-пакетами для обеих платформ.
moviepy>=1.0.3
Pillow>=10.1.0