Обновление
This commit is contained in:
parent
41beee6283
commit
324fa7440a
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal 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
14
.idea/AnimeVideoEditot.iml
generated
Normal 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>
|
||||||
137
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
137
.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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>
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal 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
10
.idea/material_theme_project_new.xml
generated
Normal 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
7
.idea/misc.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@ -1,3 +1,3 @@
|
|||||||
# AnimeVideoEditot
|
# Anime Video Editor
|
||||||
|
|
||||||
Приложение для создания единого видео файла из разных.
|
Приложение для объединения нескольких видео в один файл. Поддерживает выбор временных диапазонов для каждого ролика, настройку формата (MP4, AVI, MOV, MKV) и качества экспорта.
|
||||||
@ -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()
|
||||||
|
video = source_videos[i]
|
||||||
|
keep_ranges = all_keep_ranges[i]
|
||||||
|
for start, end in keep_ranges:
|
||||||
|
if cancel_check():
|
||||||
|
raise CancelledError()
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress = current_step / total_steps
|
p = current_step / total_clip_steps if total_clip_steps else 1.0
|
||||||
progress_callback(progress,
|
progress_callback(STAGE_PREPARE, p, f"Обработка фрагмента {i + 1}/{len(video_data)}")
|
||||||
f"Вырезание фрагмента {i + 1}/{len(video_data)}")
|
clip = video.subclipped(start, end)
|
||||||
|
|
||||||
# Обрезаем видео по таймкодам
|
|
||||||
clip = video.subclip(start, end)
|
|
||||||
clips.append(clip)
|
clips.append(clip)
|
||||||
current_step += 1
|
current_step += 1
|
||||||
|
|
||||||
self.logger.log("INFO",
|
|
||||||
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
|
||||||
@ -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:00–0:10, 0:20–0:40, 0:50–1:00, склеенные подряд.
|
||||||
|
|
||||||
|
══════════════════════════════════════════════════════════
|
||||||
|
3. НАСТРОЙКИ ВЫВОДА
|
||||||
|
══════════════════════════════════════════════════════════
|
||||||
|
• Профиль: «Свои настройки», «YouTube» или «VK Video» — подставляет рекомендуемые параметры для загрузки на эти платформы (MP4, 1080p, битрейт и т.д.). После выбора профиля параметры можно менять вручную.
|
||||||
|
• Формат: MP4, AVI, MOV, MKV.
|
||||||
|
• Качество: low / medium / high (влияет на битрейт).
|
||||||
|
• FPS: Исходный или 24, 25, 30, 60.
|
||||||
|
• Разрешение: Исходное, 720p или 1080p (масштабирование по высоте).
|
||||||
|
• Кодек (preset): от ultrafast (быстро) до veryslow (медленнее, меньше размер).
|
||||||
|
• Аудио битрейт: 128k–320k.
|
||||||
|
• Имя файла: префикс для итогового файла (к нему добавится дата и время).
|
||||||
|
• «📁 Папка для сохранения» — куда сохранить результат.
|
||||||
|
|
||||||
|
══════════════════════════════════════════════════════════
|
||||||
|
4. ЗАПУСК ОБРАБОТКИ
|
||||||
|
══════════════════════════════════════════════════════════
|
||||||
|
• Нажмите «▶ Выполнить».
|
||||||
|
• Обработка идёт в фоне — окно остаётся отзывчивым, можно смотреть прогресс.
|
||||||
|
• После завершения появится сообщение с путём к файлу. Рядом сохраняется лог (_log.txt).
|
||||||
|
• При большом количестве файлов (больше 3) включается режим с меньшим потреблением памяти.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def show_instruction(self):
|
||||||
|
win = tk.Toplevel(self.root)
|
||||||
|
win.title("Инструкция — Anime Video Editor")
|
||||||
|
win.geometry("560x520")
|
||||||
|
win.minsize(400, 300)
|
||||||
|
win.configure(bg=COLORS["bg"])
|
||||||
|
win.transient(self.root)
|
||||||
|
win.grab_set()
|
||||||
|
fr = tk.Frame(win, bg=COLORS["bg"], padx=12, pady=12)
|
||||||
|
fr.pack(fill=tk.BOTH, expand=True)
|
||||||
|
text = tk.Text(fr, wrap=tk.WORD, font=("Tahoma", 10), bg=COLORS["surface"], fg=COLORS["text"],
|
||||||
|
insertbackground=COLORS["text"], relief=tk.FLAT, padx=10, pady=10)
|
||||||
|
scroll = ttk.Scrollbar(fr)
|
||||||
|
text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||||
|
scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||||
|
text.configure(yscrollcommand=scroll.set)
|
||||||
|
scroll.configure(command=text.yview)
|
||||||
|
text.insert(tk.END, self.INSTRUCTION_TEXT.strip())
|
||||||
|
text.config(state=tk.DISABLED)
|
||||||
|
ttk.Button(win, text="Закрыть", command=win.destroy).pack(pady=(0, 10))
|
||||||
|
|
||||||
|
def show_about(self):
|
||||||
|
messagebox.showinfo(
|
||||||
|
"О программе",
|
||||||
|
"Anime Video Editor ver. 0.0.8\n\n"
|
||||||
|
"Объединение и нарезка видео: несколько файлов в один с вырезкой указанных фрагментов.\n\n"
|
||||||
|
"Обработка в фоне, поддержка больших и множества файлов."
|
||||||
|
"Автор - stirelshka8"
|
||||||
|
"Страница проекта - https://git.tuxops.ru/stirelshka8/AnimeVideoEditot"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _poll_progress(self):
|
||||||
|
"""Обработка очереди прогресса из фонового потока (только в main thread)."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = self._progress_queue.get_nowait()
|
||||||
|
if msg == "done":
|
||||||
|
continue
|
||||||
|
if isinstance(msg, tuple):
|
||||||
|
if len(msg) == 3 and msg[0] == "result":
|
||||||
|
_, success, payload = msg
|
||||||
|
self._on_processing_done(success, payload)
|
||||||
|
elif len(msg) == 3 and isinstance(msg[0], int):
|
||||||
|
stage, p, text = msg
|
||||||
|
self.progress_var.set(p * 100)
|
||||||
|
self.status_var.set(text)
|
||||||
|
if self._progress_bar_var is not None:
|
||||||
|
self._progress_bar_var.set(p * 100)
|
||||||
|
if self._progress_stage_var is not None:
|
||||||
|
self._progress_stage_var.set(f"Этап {stage}/3: {text}")
|
||||||
|
self._append_progress_log(text)
|
||||||
|
except Empty:
|
||||||
|
pass
|
||||||
|
if self.root.winfo_exists():
|
||||||
|
self.root.after(200, self._poll_progress)
|
||||||
|
|
||||||
|
def _on_processing_done(self, success, payload):
|
||||||
|
self._processing = False
|
||||||
|
self._cancel_event = None
|
||||||
|
self.run_button.config(state="normal", text="▶ Выполнить")
|
||||||
|
self.progress_var.set(100 if success else 0)
|
||||||
|
if success:
|
||||||
|
output_path = payload
|
||||||
|
fmt = self.format_var.get()
|
||||||
|
log_path = output_path.replace(f".{fmt}", "_log.txt")
|
||||||
|
self.logger.save_log(log_path)
|
||||||
|
self.status_var.set(f"Готово: {output_path}")
|
||||||
|
self._append_progress_log("Готово. Файл: " + output_path)
|
||||||
|
messagebox.showinfo("Успех", f"Обработка завершена.\nФайл: {output_path}")
|
||||||
|
if self.open_folder_var.get() or self.open_log_var.get():
|
||||||
|
self._open_folder(output_path)
|
||||||
|
if self.close_progress_win_var.get():
|
||||||
|
self._close_progress_window()
|
||||||
|
else:
|
||||||
|
err = payload
|
||||||
|
self.status_var.set(err)
|
||||||
|
if err == "Отменено пользователем":
|
||||||
|
self.logger.log("INFO", err)
|
||||||
|
self._append_progress_log(err)
|
||||||
|
messagebox.showinfo("Остановлено", "Выполнение остановлено пользователем.")
|
||||||
|
else:
|
||||||
|
self.logger.log("ERROR", err)
|
||||||
|
self._append_progress_log("Ошибка: " + err)
|
||||||
|
messagebox.showerror("Ошибка", err)
|
||||||
|
if self._progress_bar_var is not None:
|
||||||
|
self._progress_bar_var.set(100 if success else 0)
|
||||||
|
if self._progress_win is not None and self._progress_win.winfo_exists():
|
||||||
|
self._progress_win.protocol("WM_DELETE_WINDOW", self._close_progress_window)
|
||||||
|
self._progress_win.title("Выполнение завершено")
|
||||||
|
if self._progress_close_btn is not None:
|
||||||
|
self._progress_close_btn.config(state="normal")
|
||||||
|
if self._progress_stop_btn is not None:
|
||||||
|
self._progress_stop_btn.config(state="disabled", text="⏹ Остановить")
|
||||||
|
self.root.after(0, lambda: self.progress_var.set(0))
|
||||||
|
|
||||||
def process_videos(self):
|
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
|
||||||
|
|
||||||
|
def worker():
|
||||||
try:
|
try:
|
||||||
processor = VideoProcessor()
|
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))
|
||||||
# Save log
|
except CancelledError:
|
||||||
log_path = output_path.replace(f".{output_format}", "_log.txt")
|
progress_queue.put(("result", False, "Отменено пользователем"))
|
||||||
self.logger.save_log(log_path)
|
|
||||||
|
|
||||||
self.update_status(f"Готово! Файл сохранен: {output_path}")
|
|
||||||
messagebox.showinfo("Успех", f"Обработка завершена!\nФайл: {output_path}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Ошибка обработки: {str(e)}"
|
progress_queue.put(("result", False, str(e)))
|
||||||
self.update_status(error_msg)
|
|
||||||
self.logger.log("ERROR", error_msg)
|
|
||||||
messagebox.showerror("Ошибка", error_msg)
|
|
||||||
finally:
|
finally:
|
||||||
|
progress_queue.put("done")
|
||||||
|
|
||||||
|
self._processing = True
|
||||||
|
self.run_button.config(state="disabled", text="Работает...")
|
||||||
|
self.status_var.set("Обработка...")
|
||||||
self.progress_var.set(0)
|
self.progress_var.set(0)
|
||||||
|
self._open_progress_window()
|
||||||
|
t = threading.Thread(target=worker, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|||||||
142
gui/theme.py
Normal file
142
gui/theme.py
Normal 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
|
||||||
@ -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
|
||||||
@ -1,2 +1,4 @@
|
|||||||
moviepy==1.0.3
|
# Видеоредактор — зависимости (Windows и Linux)
|
||||||
Pillow==10.0.0
|
# Используются версии с готовыми wheel-пакетами для обеих платформ.
|
||||||
|
moviepy>=1.0.3
|
||||||
|
Pillow>=10.1.0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user