1
0
Fork 0
mirror of https://github.com/EDeev/circlechek.git synced 2026-10-08 04:59:46 +03:00

Compare commits

..

No commits in common. "44010bba0b092974d763418e87c85f8c8f3267f3" and "3ac2dfe5e1ab17c12a37a5d3abcd4f3116cd56d6" have entirely different histories.

17 changed files with 309 additions and 497 deletions

View file

@ -1,2 +0,0 @@
# Токен бота от @BotFather
BOT_TOKEN=123456:your-token

2
.gitattributes vendored
View file

@ -1,2 +0,0 @@
* text=auto eol=lf
*.mp4 binary

View file

@ -1,48 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install ruff==0.14.0 -r requirements.txt
- name: Ruff (синтаксис и ошибки)
run: ruff check --select E9,F code
- name: Обработка тестового кружочка (градиент и блюр, со звуком и без)
working-directory: code
env:
BOT_TOKEN: "123456:TEST"
run: |
python - <<'PY'
import os, shutil, subprocess
import imageio_ffmpeg
from scripts import Movie, process_frames
import handlers # noqa: F401 — бот импортируется без ошибок
ff = imageio_ffmpeg.get_ffmpeg_exe()
src = {
"audio": ["-f", "lavfi", "-i", "testsrc=size=240x240:rate=30:duration=1",
"-f", "lavfi", "-i", "sine=duration=1", "-shortest"],
"silent": ["-f", "lavfi", "-i", "color=c=black:size=240x240:rate=30:duration=1"],
}
for name, args in src.items():
for mode in ("grad", "blur"):
path = f"/tmp/{name}-{mode}"
shutil.rmtree(path, ignore_errors=True)
os.makedirs(path + "/frames"); os.makedirs(path + "/background")
subprocess.run([ff, "-loglevel", "error", "-y", *args, "-c:v", "libx264",
"-pix_fmt", "yuv420p", path + "/v.mp4"], check=True)
movie = Movie(path + "/v.mp4", "v", path)
assert movie.split_into_frames()
result = movie.unity_into_video(process_frames(path + "/frames", path + "/background", mode))
assert os.path.getsize(result) > 0
print(name, mode, "ok")
PY

View file

@ -1,42 +0,0 @@
name: Docker
on:
push:
tags: ["v*"]
workflow_dispatch:
jobs:
image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/login-action@v3
with:
registry: dcr.deev.su
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
- id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/edeev/circlechek
dcr.deev.su/edeev/circlechek
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

6
.gitignore vendored
View file

@ -1,6 +0,0 @@
.env
__pycache__/
*.pyc
data/*/*
!data/*/.gitkeep
data/*.log

View file

@ -1,19 +0,0 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY code/ code/
RUN useradd --create-home --uid 1000 app \
&& mkdir -p data/circles data/video_notes data/videos \
&& chown -R app:app /app
USER app
VOLUME ["/app/data"]
# пути к данным в коде — относительно папки code/ (../data)
WORKDIR /app/code
CMD ["python", "bot.py"]

21
LICENSE
View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 Egor Deev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,78 +0,0 @@
# Circlechek
[Русский](README.md) · **English**
[![CI](https://github.com/EDeev/circlechek/actions/workflows/ci.yml/badge.svg)](https://github.com/EDeev/circlechek/actions/workflows/ci.yml)
[![Docker](https://github.com/EDeev/circlechek/actions/workflows/docker.yml/badge.svg)](https://github.com/EDeev/circlechek/actions/workflows/docker.yml)
[![License](https://img.shields.io/github/license/EDeev/circlechek)](LICENSE)
A Telegram bot for video notes ("circles"): turns a square video into a round video note, and turns a
video note back into a regular video with the corners filled by a blurred frame or a gradient matching
the picture. The bot speaks Russian.
**Status:** personal project, completed · bot [@circlechek_bot](https://t.me/circlechek_bot)
![A video note and two background options](docs/demo.png)
**Stack:** Python 3.12 · aiogram 3 · MoviePy 2 · Pillow · NumPy · Docker
## Features
- **Video → video note.** A square video up to one minute comes back as a circle.
- **Video note → video.** The bot splits the circle into frames, fills the corners and reassembles the
video with sound. Background options:
- **blur** — a blurred center of the frame;
- **gradient** — based on the frame's average color.
- Heavy processing runs in a separate thread, so the bot keeps answering others while one circle is
processed. Each job gets its own temporary folder, cleaned up even on errors.
> [!NOTE]
> Some Telegram video notes have broken metadata; processing then fails or the video has artifacts. The
> bot warns about this, so check the result.
## Running
```bash
git clone https://github.com/EDeev/circlechek.git && cd circlechek
cp .env.example .env # BOT_TOKEN from @BotFather
docker compose up -d
```
Prebuilt image: `docker pull ghcr.io/edeev/circlechek` or `docker pull dcr.deev.su/edeev/circlechek`.
Without Docker: Python 3.12, `pip install -r requirements.txt`, then `cd code && BOT_TOKEN=… python bot.py`
(FFmpeg comes with MoviePy).
## Structure
```
code/bot.py entry point
code/handlers.py commands, receiving videos and video notes, background buttons
code/scripts.py Movie — frames and audio via MoviePy; Frame — background and circle mask via Pillow and NumPy
data/ temporary processing files
```
## Development
```bash
pip install ruff -r requirements.txt
ruff check --select E9,F code
```
CI checks the code on every push and processes test video notes (with and without sound, both
backgrounds). The Docker image is built on `v*` tags and published to GitHub Packages and `dcr.deev.su`.
## License
MIT — see [LICENSE](LICENSE).
## Author
**Egor Deev** — [GitHub](https://github.com/EDeev) · [Telegram](https://t.me/DeevEgor) · [egor@deev.space](mailto:egor@deev.space)
---
<div align="center">
<sub>⭐ If you find this project useful, give it a star on GitHub!</sub>
<p><sub>Made with ❤️ — <a href="https://deev.space">deev.space</a></sub></p>
</div>

127
README.md
View file

@ -1,77 +1,106 @@
# Circlechek
# 🎥 Circlechek
**Русский** · [English](README.en.md)
**Telegram-бот для преобразования квадратных видео в кружочки и обработки видеосообщений с настраиваемыми фонами.**
[![CI](https://github.com/EDeev/circlechek/actions/workflows/ci.yml/badge.svg)](https://github.com/EDeev/circlechek/actions/workflows/ci.yml)
[![Docker](https://github.com/EDeev/circlechek/actions/workflows/docker.yml/badge.svg)](https://github.com/EDeev/circlechek/actions/workflows/docker.yml)
[![License](https://img.shields.io/github/license/EDeev/circlechek)](LICENSE)
## 📋 Описание
Telegram-бот для кружочков: делает из квадратного видео видеосообщение-кружок, а из присланного
кружочка — обычное видео, где углы заполнены размытым кадром или градиентом под цвет картинки.
Circlechek — это простой и функциональный Telegram-бот, предназначенный для работы с видеоконтентом. Бот предоставляет две основные возможности:
**Статус:** личный проект, завершён · бот [@circlechek_bot](https://t.me/circlechek_bot)
1. **Преобразование видео в кружочки** — превращает квадратные видео (до 1 минуты) в формат видеосообщений Telegram
2. **Обработка кружочков** — конвертирует видеосообщения в обычные видео с настраиваемым фоном по краям
![Кружочек и два варианта фона](docs/demo.png)
## ✨ Функциональность
**Стек:** Python 3.12 · aiogram 3 · MoviePy 2 · Pillow · NumPy · Docker
### 🔄 Видео → Кружочек
- Принимает квадратные видеофайлы длительностью до 60 секунд
- Автоматически преобразует их в формат кружочков Telegram
- Сохраняет качество и плавность воспроизведения
## Возможности
### 🎨 Кружочек → Видео
- Обрабатывает видеосообщения с добавлением фона по краям
- **Градиентный фон** — создает плавный цветовой переход на основе доминирующих цветов кадра
- **Размытый фон** — использует размытую версию центральной части видео
- Сохраняет исходное аудио и синхронизацию
- **Видео → кружочек.** Квадратное видео до минуты возвращается кружком.
- **Кружочек → видео.** Бот разбирает кружок на кадры, заполняет углы и собирает видео обратно со звуком.
Фон на выбор:
- **блюр** — размытая центральная часть кадра;
- **градиент** — по среднему цвету кадра.
- Тяжёлая обработка идёт в отдельном потоке, поэтому пока один кружочек обрабатывается, бот отвечает
остальным. Каждая обработка — в своей временной папке, которая убирается и при ошибке.
## 🛠 Технологический стек
> [!NOTE]
> У части кружочков Telegram бывают некорректные метаданные — тогда обработка не удастся или в видео
> будет брак. Бот предупреждает об этом, результат стоит проверять.
- **Python 3.x** — основной язык разработки
- **aiogram** — асинхронная библиотека для работы с Telegram Bot API
- **PIL (Pillow)** — обработка изображений и создание эффектов
- **MoviePy** — работа с видеофайлами, извлечение кадров и аудио
- **NumPy** — математические операции с массивами для обработки изображений
## Запуск
## 🚀 Установка и запуск
### Требования
- Python 3.8+
- Токен Telegram-бота от [@BotFather](https://t.me/BotFather)
### Установка зависимостей
```bash
git clone https://github.com/EDeev/circlechek.git && cd circlechek
cp .env.example .env # BOT_TOKEN от @BotFather
docker compose up -d
pip install -r requirements.txt
```
Готовый образ: `docker pull ghcr.io/edeev/circlechek` или `docker pull dcr.deev.su/edeev/circlechek`.
Без Docker: Python 3.12, `pip install -r requirements.txt`, затем `cd code && BOT_TOKEN=… python bot.py`
(FFmpeg ставится вместе с MoviePy).
## Структура
```
code/bot.py запуск
code/handlers.py команды, приём видео и кружочков, кнопки выбора фона
code/scripts.py Movie — кадры и звук через MoviePy; Frame — фон и маска-круг через Pillow и NumPy
data/ временные файлы обработки
### Настройка
1. Получите токен бота в [@BotFather](https://t.me/BotFather)
2. Отредактируйте файл `code/config.py`:
```python
botToken = "ВАШ_ТОКЕН_БОТА"
```
## Разработка
### Запуск
```bash
pip install ruff -r requirements.txt
ruff check --select E9,F code
cd code
python bot.py
```
CI на каждый push проверяет код и прогоняет обработку тестовых кружочков (со звуком и без, оба вида
фона). Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и `dcr.deev.su`.
## 📁 Структура проекта
## Лицензия
```
circlechek/
├── code/
│ ├── bot.py # Основной файл запуска бота
│ ├── handlers.py # Обработчики команд и сообщений
│ ├── scripts.py # Классы для работы с видео и изображениями
│ ├── config.py # Конфигурация бота
│ └── init.py # Инициализация бота и утилиты
├── data/
│ ├── circles/ # Временные файлы кружочков
│ ├── video_notes/ # Временные видеосообщения
│ └── videos/ # Обработанные видео
└── README.md
```
MIT — см. [LICENSE](LICENSE).
## 🎯 Использование
## Автор
1. **Начало работы**: Отправьте `/start` боту
2. **Создание кружочка**: Отправьте квадратное видео (до 1 минуты)
3. **Обработка кружочка**:
- Отправьте видеосообщение боту
- Выберите тип фона: "Градиент" или "Блюр"
- Получите обработанное видео
**Деев Егор Викторович** — [GitHub](https://github.com/EDeev) · [Telegram](https://t.me/DeevEgor) · [egor@deev.space](mailto:egor@deev.space)
## ⚠️ Важные особенности
- Видео для преобразования в кружочки должны быть **квадратными** и **не длиннее 1 минуты**
- Некоторые кружочки могут иметь некорректные метаданные, что может привести к ошибкам обработки
- Рекомендуется всегда проверять качество полученного результата
- Все временные файлы автоматически удаляются после обработки
## 📄 Лицензия
Проект распространяется под лицензией MIT.
## 👨‍💻 Автор
**Деев Егор Викторович** - Backend Developer
- GitHub: [@EDeev](https://github.com/EDeev)
- Email: egor@deev.space
- Telegram: [@Egor_Deev](https://t.me/Egor_Deev)
---
<div align="center">
<sub>⭐ Если проект оказался полезным, поставьте звёздочку на GitHub!</sub>
<p><sub>Сделано с ❤️ — <a href="https://deev.space">deev.space</a></sub></p>
<sub>⭐ Если проект оказался полезным, поставьте звездочку на GitHub!</sub>
<p><sub>Создано с ❤️ от вашего дорогого - deev.space ©</sub></p>
</div>

View file

@ -1,18 +1,18 @@
import asyncio
import logging
import asyncio, logging, warnings
from init import bot, dp
from init import *
from handlers import router
async def main() -> None:
dp.include_router(router)
await bot.delete_webhook(drop_pending_updates=True)
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types(), skipUpdates=True)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
# warnings.filterwarnings("ignore")
try: asyncio.run(main())
except KeyboardInterrupt: pass

View file

@ -1,6 +1,4 @@
import os
botToken = os.getenv("BOT_TOKEN", "XXXXXXXXXXXXXXXXXXXXXXXX") # @circlechek_bot
botToken = "XXXXXXXXXXXXXXXXXXXXXXXX" # @circlechek_bot
WARN = ("<b>Предупреждение!</b> Кружочек может быть с некорректными метаданными, в этом случае программа выдаст ошибку "
"либо произведёт видео с браком! <b>Обязательно перепроверяйте получившиеся видео!</b>\n\n")

View file

@ -1,15 +1,10 @@
import asyncio
import logging
import os
import shutil
import tempfile
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram import types, F, Router
from aiogram.types import Message, CallbackQuery, ContentType, InlineKeyboardButton, InlineKeyboardMarkup, FSInputFile
from aiogram.filters import Command
from scripts import Movie, process_frames
import shutil
from scripts import *
from config import WARN
router = Router()
@ -20,7 +15,7 @@ router = Router()
async def helps(msg: Message) -> None:
buttons = [[InlineKeyboardButton(text="ФУНКЦИИ", callback_data="fun"),
InlineKeyboardButton(text="АВТОР", callback_data="auth")]]
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons, row_width=2)
await msg.answer(text="<b>Кружочичек</b> — бот для обработки видео и кружочков в Телеграме. Для начала работы "
"вам достаточно скинуть квадратное видео не дольше минуты в чат, чтобы получить кружок. "
@ -38,7 +33,7 @@ async def author(call: CallbackQuery) -> None:
'двух предложенных вариантах.'
'\n\nЯ же пишу подобные небольшие проекты, о которых вы можете узнать '
'больше на моём <a href="https://github.com/EDeev">GitHub</a>.')
'больше на моём <a href="https://github.com/IGlek">GitHub</a>.')
@router.callback_query(F.data == "fun")
@ -50,84 +45,78 @@ async def function(call: CallbackQuery) -> None:
# ОБРАБОТЧИК ВИДЕО
@router.message(F.content_type == ContentType.VIDEO)
async def video_to_circle(msg: Message) -> None:
video = f"../data/circles/{msg.chat.id}_{msg.message_id}.mp4"
video = "../data/circles/" + str(msg.chat.id) + ".mp4"
await msg.reply("<b>Началась обработка видео!</b> Оно должно быть квадратным и не дольше одной минуты, в ином "
"случае бот в ответ вернёт вам изначальное видео, а не кружочек!")
try:
await msg.bot.download(file=msg.video.file_id, destination=video)
await msg.answer_video_note(video_note=FSInputFile(video))
except TelegramBadRequest as err:
logging.warning("Не удалось сделать кружочек: %s", err)
await msg.answer("<b>Не получилось сделать кружочек.</b> Видео должно быть квадратным, не дольше минуты "
"и не больше 20 МБ.")
finally:
if os.path.exists(video):
os.remove(video)
await msg.bot.download(file=msg.video.file_id, destination=video)
await msg.answer_video_note(video_note=FSInputFile(video))
os.remove(video)
# ОБРАБОТЧИК КРУЖОЧКОВ
@router.message(F.content_type == ContentType.VIDEO_NOTE)
async def video_note(msg: Message) -> None:
# номер сообщения в кнопках: если прислать несколько кружочков подряд, каждый обработается свой
buttons = [[InlineKeyboardButton(text="Градиент", callback_data=f"grad:{msg.message_id}"),
InlineKeyboardButton(text="Блюр", callback_data=f"blur:{msg.message_id}")]]
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
buttons = [[InlineKeyboardButton(text="Градиент", callback_data="grad"),
InlineKeyboardButton(text="Блюр", callback_data="blur")]]
keyboard = types.InlineKeyboardMarkup(inline_keyboard=buttons, row_width=2)
msg_answer = await msg.reply("Кружочек загружается...")
await msg.bot.download(file=msg.video_note.file_id,
destination=f"../data/video_notes/{msg.chat.id}_{msg.message_id}.mp4")
await msg.bot.download(file=msg.video_note.file_id, destination="../data/video_notes/" + str(msg.chat.id) + ".mp4")
await msg_answer.edit_text(text=WARN + "Какой тип фона в углах вы выберите?", reply_markup=keyboard)
@router.callback_query(F.data.regexp(r"^(grad|blur):\d+$"))
@router.callback_query(lambda call: call.data == "grad" or call.data == "blur")
async def work_part(call: CallbackQuery) -> None:
mode, note_id = call.data.split(":")
video_name = f"{call.message.chat.id}_{note_id}"
video_file = video_name + ".mp4"
source = "../data/video_notes/" + video_file
if not os.path.exists(source):
# кнопку нажали повторно или кружочек уже обработан
await call.answer("Этот кружочек уже обработан. Пришлите его ещё раз.", show_alert=True)
return
msg = await call.message.edit_text(WARN + "<b>Начало обработки!</b>")
# отдельная папка на каждую обработку и уборка в любом случае — иначе после первой же ошибки
# повторная обработка в этом чате падала на уже существующей папке
path = tempfile.mkdtemp(prefix=video_name + "-", dir="../data/videos")
try:
path_video = path + "/" + video_file
os.replace(source, path_video)
video_name = str(msg.chat.id)
video_file = video_name + ".mp4"
path_frames = path + "/frames"
os.mkdir(path_frames)
path = f"../data/videos/{video_name}"
os.mkdir(path)
path_background = path + "/background"
os.mkdir(path_background)
path_video = path + "/" + video_file
os.replace("../data/video_notes/" + video_file, path_video)
# тяжёлая обработка — в отдельном потоке, чтобы бот не замирал для остальных пользователей
msg = await msg.edit_text(WARN + "<b>Этап:</b> 1 - Обработка видео.")
video = Movie(path_video, video_name, path)
if not await asyncio.to_thread(video.split_into_frames):
await msg.edit_text("<b>Возникла ошибка!</b> Кружочек невозможно обработать!")
return
path_frames = path + f"/frames"
os.mkdir(path_frames)
msg = await msg.edit_text(WARN + "<b>Этап:</b> 2 - Обработка кадров.")
frames = await asyncio.to_thread(process_frames, path_frames, path_background, mode)
path_background = path + f"/background"
os.mkdir(path_background)
msg = await msg.edit_text(WARN + "<b>Этап:</b> 3 - Объединение кадров.")
result = await asyncio.to_thread(video.unity_into_video, frames)
msg = await msg.edit_text(WARN + "<b>Этап:</b> 1 - Обработка видео.")
video = Movie(path_video, video_name, path)
procces = video.split_into_frames()
msg_final = await msg.edit_text(WARN + "<b>Готово!</b> Видео отправляется...")
reply_to = call.message.reply_to_message
await msg.answer_video(video=FSInputFile(result), caption=WARN,
reply_to_message_id=reply_to.message_id if reply_to else None)
await msg_final.delete()
except Exception:
logging.exception("Ошибка обработки кружочка")
if not procces:
await msg.edit_text("<b>Возникла ошибка!</b> Кружочек невозможно обработать!")
finally:
shutil.rmtree(path, ignore_errors=True)
shutil.rmtree(path)
return
msg = await msg.edit_text(WARN + "<b>Этап:</b> 2 - Обработка кадров.")
frames = list(sorted(os.listdir(path_frames)))
for frame in frames:
img = Frame(path_frames + "/" + frame, path_background + "/" + f'{frame[:-5]}-g.jpeg')
width, height = img.size()
if call.data == "blur":
img.blur(width, height)
else:
r, g, b = img.medium_color(); nearly = 10
img.gradient(width, height, (r - nearly, g - nearly, b - nearly),
(r + nearly, g + nearly, b + nearly), (True, False, False))
img.unity_image()
msg = await msg.edit_text(WARN + "<b>Этап:</b> 3 - Объединение кадров.")
video.unity_into_video(frames)
msg_final = await msg.edit_text(WARN + "<b>Готово!</b> Видео отправляется...")
await msg.answer_video(video=FSInputFile(path + "/acs-" + video_file), caption=WARN,
reply_to_message_id=call.message.reply_to_message.message_id)
await msg_final.delete()
shutil.rmtree(path)

View file

@ -7,3 +7,26 @@ from config import botToken
bot = Bot(token=botToken, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher(storage=MemoryStorage())
import logging, functools, sys
# logging.basicConfig(level=logging.INFO, filename='../data/info.log',filemode="w", format="%(asctime)s %(levelname)s %(message)s")
def bug_report(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
result = func(*args, **kwargs)
logging.info(f"Successful result")
return result
except Exception as err:
logging.error("Ошибка", exc_info=True)
"""with open('../data/log.txt', "a") as f:
f.write(repr(e))"""
sys.exit()
return wrapper

View file

@ -1,139 +1,131 @@
import logging
import os
import numpy
from moviepy import AudioFileClip, ImageSequenceClip, VideoFileClip
from PIL import Image, ImageDraw, ImageFilter
class Movie:
def __init__(self, video_file, video_name, path):
self.video_file = video_file
self.video_name = video_name
self.path = path
self.path_frames = self.path + "/frames"
self.path_audio = self.path + f"/{self.video_name}-audio.mp3"
self.has_audio = False
def split_into_frames(self):
try:
video_clip = VideoFileClip(self.video_file)
except Exception:
logging.exception("Не удалось открыть кружочек")
return False
try:
# у кружочка может не быть звуковой дорожки
if video_clip.audio is not None:
video_clip.audio.write_audiofile(self.path_audio, logger=None)
self.has_audio = True
step = 1 / 30.0 if video_clip.fps > 60.0 else 1 / video_clip.fps
for count, current_duration in enumerate(numpy.arange(0, video_clip.duration, step), start=1):
frame_filename = os.path.join(self.path_frames, f"frame-{count:05}.jpeg")
video_clip.save_frame(frame_filename, current_duration)
except Exception:
logging.exception("Не удалось разобрать кружочек на кадры")
return False
finally:
video_clip.close()
return True
def unity_into_video(self, frames):
video_clip = VideoFileClip(self.video_file)
fps = 30.0 if video_clip.fps > 60.0 else video_clip.fps
video_clip.close()
clip = ImageSequenceClip([self.path_frames + "/" + x for x in frames], fps=fps)
if self.has_audio:
audio_clip = AudioFileClip(self.path_audio)
# звук не длиннее видео, иначе последний кадр «замирает»
clip = clip.with_audio(audio_clip.subclipped(0, min(audio_clip.duration, clip.duration)))
result = self.path + "/acs-" + self.video_name + ".mp4"
clip.write_videofile(result, codec="libx264", audio_codec="aac", logger=None)
clip.close()
return result
class Frame:
def __init__(self, filename, background):
self.filename = filename
self.background = background
def size(self):
with Image.open(self.filename) as image:
return image.size
def medium_color(self):
with Image.open(self.filename) as img:
pixels = numpy.asarray(img.convert("RGB"), dtype=numpy.float64)
r, g, b = (int(c) for c in pixels.reshape(-1, 3).mean(axis=0))
return r, g, b
def gradient(self, width, height, start_list, stop_list, is_horizontal_list):
def get_gradient(start, stop, width, height, is_horizontal):
if is_horizontal:
return numpy.tile(numpy.linspace(start, stop, width), (height, 1))
else:
return numpy.tile(numpy.linspace(start, stop, height), (width, 1)).T
result = numpy.zeros((height, width, len(start_list)))
for i, (start, stop, is_horizontal) in enumerate(zip(start_list, stop_list, is_horizontal_list)):
result[:, :, i] = get_gradient(start, stop, width, height, is_horizontal)
# без обрезки тёмные и светлые цвета «переворачиваются» при переводе в uint8
Image.fromarray(numpy.uint8(numpy.clip(result, 0, 255))).save(self.background, quality=95)
def blur(self, width, height):
with Image.open(self.filename) as image:
image = image.filter(ImageFilter.GaussianBlur(6))
part_width = width // 64
part_height = height // 64
image = image.crop((part_width * 10, part_height * 10, part_width * 54, part_height * 54))
image = image.resize((width, height))
image.save(self.background, quality=95)
image.close()
def unity_image(self):
im1 = Image.open(self.background)
im2 = Image.open(self.filename)
mask = Image.new("L", im2.size, 0)
draw = ImageDraw.Draw(mask)
draw.ellipse(((1, 1), (im2.size[0] - 1, im2.size[1] - 1)), fill=255)
mask_blur = mask.filter(ImageFilter.GaussianBlur(1))
im1.paste(im2, (0, 0), mask_blur)
im1.save(self.filename)
im1.close()
im2.close()
mask.close()
def process_frames(path_frames, path_background, mode):
frames = sorted(os.listdir(path_frames))
for frame in frames:
img = Frame(path_frames + "/" + frame, path_background + "/" + f"{frame[:-5]}-g.jpeg")
width, height = img.size()
if mode == "blur":
img.blur(width, height)
else:
r, g, b = img.medium_color(); nearly = 10
img.gradient(width, height, (r - nearly, g - nearly, b - nearly),
(r + nearly, g + nearly, b + nearly), (True, False, False))
img.unity_image()
return frames
from PIL import Image, ImageDraw, ImageFilter
from moviepy import *
import numpy, os
from init import bug_report
class Movie:
def __init__(self, video_file, video_name, path):
self.video_file = video_file
self.video_name = video_name
self.path = path
self.path_frames = ""
def split_into_frames(self):
video_clip = VideoFileClip(self.video_file)
video_clip.audio.write_audiofile(self.path + f"/{self.video_name}-audio.mp3", logger=None)
self.path_frames = self.path + "/frames"
step = 1 / 30.0 if video_clip.fps > 60.0 else 1 / video_clip.fps
count = 0
for current_duration in numpy.arange(0, video_clip.duration, step):
count += 1
str_c = str(count)
num = "00000"[:(len(str_c) * -1)] + str_c
frame_filename = os.path.join(self.path_frames, f"frame-{num}.jpeg")
try: video_clip.save_frame(frame_filename, current_duration)
except Exception: return False
video_clip.close()
return True
@bug_report
def unity_into_video(self, frames):
video_clip = VideoFileClip(self.video_file)
fps = 30.0 if video_clip.fps > 60.0 else video_clip.fps
video_clip.close()
os.remove(self.video_file)
clip = ImageSequenceClip(list(map(lambda x: self.path_frames + "/" + x, frames)), fps=fps)
clip.write_videofile(self.video_file, logger=None)
clip.close()
video_clip = VideoFileClip(self.video_file)
audio_clip = AudioFileClip(self.path + f"/{self.video_name}-audio.mp3")
video_clip_with_audio = video_clip.with_audio(audio_clip)
video_clip_with_audio.write_videofile(self.path + "/acs-" + self.video_name + ".mp4", logger=None)
video_clip.close()
audio_clip.close()
class Frame:
def __init__(self, filename, background):
self.filename = filename
self.background = background
def size(self):
image = Image.open(self.filename)
width, height = image.size
image.close()
return width, height
def medium_color(self):
img = Image.open(self.filename)
width, height = img.size
pixels = img.load()
r, g, b = [], [], []
for y in range(width):
for x in range(height):
p = pixels[x, y]
r.append(p[0])
g.append(p[1])
b.append(p[2])
r = sum(r) // len(r)
g = sum(g) // len(g)
b = sum(b) // len(b)
img.close()
return r, g, b
def gradient(self, width, height, start_list, stop_list, is_horizontal_list):
def get_gradient(start, stop, width, height, is_horizontal):
if is_horizontal:
return numpy.tile(numpy.linspace(start, stop, width), (height, 1))
else:
return numpy.tile(numpy.linspace(start, stop, height), (width, 1)).T
result = numpy.zeros((height, width, len(start_list)))
for i, (start, stop, is_horizontal) in enumerate(zip(start_list, stop_list, is_horizontal_list)):
result[:, :, i] = get_gradient(start, stop, width, height, is_horizontal)
Image.fromarray(numpy.uint8(result)).save(self.background, quality=95)
def blur(self, width, height):
image = Image.open(self.filename)
image = image.filter(ImageFilter.GaussianBlur(6))
part_width = width // 64
part_height = height // 64
image = image.crop((part_width * 10, part_height * 10, part_width * 54, part_height * 54))
image = image.resize((width, height))
image.save(self.background, quality=95)
image.close()
def unity_image(self):
im1 = Image.open(self.background)
im2 = Image.open(self.filename)
mask = Image.new("L", im2.size, 0)
draw = ImageDraw.Draw(mask)
draw.ellipse(((1, 1), (im2.size[0] - 1, im2.size[1] - 1)), fill=255)
mask_blur = mask.filter(ImageFilter.GaussianBlur(1))
im1.paste(im2, (0, 0), mask_blur)
im1.save(self.filename)
im1.close()
im2.close()
mask.close()

View file

@ -1,11 +0,0 @@
services:
bot:
build: .
image: ghcr.io/edeev/circlechek:latest
env_file: .env
volumes:
- data:/app/data
restart: unless-stopped
volumes:
data:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

View file

@ -1,4 +1,14 @@
aiogram==3.23.0
moviepy==2.2.1
Pillow==10.3.0
numpy==2.2.6
# Core bot framework
aiogram>=3.0.0
# Image processing
Pillow>=9.0.0
# Video processing
moviepy>=1.0.3
# Mathematical operations
numpy>=1.21.0
# Optional: For better video codec support
imageio-ffmpeg>=0.4.0