1
0
Fork 0
mirror of https://github.com/EDeev/circlechek.git synced 2026-10-07 20:49:51 +03:00

Compare commits

...

4 commits

Author SHA1 Message Date
44010bba0b README на русском и английском с иллюстрацией фонов 2026-10-05 19:16:14 +00:00
a15bfba330 Docker, CI и лицензия MIT
CI: ruff и обработка тестовых кружочков (со звуком и без, градиент и блюр). Образ по тегу
v* публикуется в GHCR и dcr.deev.su.
2026-10-05 19:16:13 +00:00
e74873588a Исправлены ошибки обработки кружочков
- папка обработки была одна на чат: после первой же ошибки она оставалась, и все следующие
  кружочки в этом чате падали на os.mkdir — теперь своя временная папка на каждую обработку
  и уборка в любом случае;
- кнопки фона знают свой кружочек: если прислать несколько подряд, обработается нужный;
  повторное нажатие отвечает подсказкой вместо ошибки;
- покадровая обработка шла прямо в обработчике и блокировала бота для всех — вынесена в
  отдельный поток;
- кружочек без звука падал на .audio.write_audiofile;
- градиент по тёмным и светлым кадрам «переворачивался» (−10 в uint8 = 246): значения
  ограничены 0…255; средний цвет кадра считается NumPy, а не циклом по пикселям;
- декоратор bug_report вызывал sys.exit() внутри обработчика — убран, ошибки логируются,
  пользователь получает сообщение;
- видео, которое не удалось превратить в кружок, больше не остаётся на диске;
- токен — из переменной BOT_TOKEN; ссылка «АВТОР» вела на чужой теперь профиль IGlek —
  заменена на EDeev;
- requirements.txt: moviepy>=1.0.3 не подходил к коду под moviepy 2 — версии закреплены
  по работающему окружению.
2026-10-05 19:16:13 +00:00
7f85340c86 Переводы строк приведены к LF 2026-10-05 19:11:25 +00:00
17 changed files with 495 additions and 307 deletions

2
.env.example Normal file
View file

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

2
.gitattributes vendored Normal file
View file

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

48
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,48 @@
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

42
.github/workflows/docker.yml vendored Normal file
View file

@ -0,0 +1,42 @@
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 Normal file
View file

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

19
Dockerfile Normal file
View file

@ -0,0 +1,19 @@
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 Normal file
View file

@ -0,0 +1,21 @@
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.

78
README.en.md Normal file
View file

@ -0,0 +1,78 @@
# 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,106 +1,77 @@
# 🎥 Circlechek
# Circlechek
**Telegram-бот для преобразования квадратных видео в кружочки и обработки видеосообщений с настраиваемыми фонами.**
**Русский** · [English](README.en.md)
## 📋 Описание
[![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)
Circlechek — это простой и функциональный Telegram-бот, предназначенный для работы с видеоконтентом. Бот предоставляет две основные возможности:
Telegram-бот для кружочков: делает из квадратного видео видеосообщение-кружок, а из присланного
кружочка — обычное видео, где углы заполнены размытым кадром или градиентом под цвет картинки.
1. **Преобразование видео в кружочки** — превращает квадратные видео (до 1 минуты) в формат видеосообщений Telegram
2. **Обработка кружочков** — конвертирует видеосообщения в обычные видео с настраиваемым фоном по краям
**Статус:** личный проект, завершён · бот [@circlechek_bot](https://t.me/circlechek_bot)
## ✨ Функциональность
![Кружочек и два варианта фона](docs/demo.png)
### 🔄 Видео → Кружочек
- Принимает квадратные видеофайлы длительностью до 60 секунд
- Автоматически преобразует их в формат кружочков Telegram
- Сохраняет качество и плавность воспроизведения
**Стек:** Python 3.12 · aiogram 3 · MoviePy 2 · Pillow · NumPy · Docker
### 🎨 Кружочек → Видео
- Обрабатывает видеосообщения с добавлением фона по краям
- **Градиентный фон** — создает плавный цветовой переход на основе доминирующих цветов кадра
- **Размытый фон** — использует размытую версию центральной части видео
- Сохраняет исходное аудио и синхронизацию
## Возможности
## 🛠 Технологический стек
- **Видео → кружочек.** Квадратное видео до минуты возвращается кружком.
- **Кружочек → видео.** Бот разбирает кружок на кадры, заполняет углы и собирает видео обратно со звуком.
Фон на выбор:
- **блюр** — размытая центральная часть кадра;
- **градиент** — по среднему цвету кадра.
- Тяжёлая обработка идёт в отдельном потоке, поэтому пока один кружочек обрабатывается, бот отвечает
остальным. Каждая обработка — в своей временной папке, которая убирается и при ошибке.
- **Python 3.x** — основной язык разработки
- **aiogram** — асинхронная библиотека для работы с Telegram Bot API
- **PIL (Pillow)** — обработка изображений и создание эффектов
- **MoviePy** — работа с видеофайлами, извлечение кадров и аудио
- **NumPy** — математические операции с массивами для обработки изображений
> [!NOTE]
> У части кружочков Telegram бывают некорректные метаданные — тогда обработка не удастся или в видео
> будет брак. Бот предупреждает об этом, результат стоит проверять.
## 🚀 Установка и запуск
## Запуск
### Требования
- Python 3.8+
- Токен Telegram-бота от [@BotFather](https://t.me/BotFather)
### Установка зависимостей
```bash
pip install -r requirements.txt
git clone https://github.com/EDeev/circlechek.git && cd circlechek
cp .env.example .env # BOT_TOKEN от @BotFather
docker compose up -d
```
### Настройка
1. Получите токен бота в [@BotFather](https://t.me/BotFather)
2. Отредактируйте файл `code/config.py`:
```python
botToken = "ВАШ_ТОКЕН_БОТА"
Готовый образ: `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/ временные файлы обработки
```
### Запуск
## Разработка
```bash
cd code
python bot.py
pip install ruff -r requirements.txt
ruff check --select E9,F code
```
## 📁 Структура проекта
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. **Обработка кружочка**:
- Отправьте видеосообщение боту
- Выберите тип фона: "Градиент" или "Блюр"
- Получите обработанное видео
## Автор
## ⚠️ Важные особенности
- Видео для преобразования в кружочки должны быть **квадратными** и **не длиннее 1 минуты**
- Некоторые кружочки могут иметь некорректные метаданные, что может привести к ошибкам обработки
- Рекомендуется всегда проверять качество полученного результата
- Все временные файлы автоматически удаляются после обработки
## 📄 Лицензия
Проект распространяется под лицензией MIT.
## 👨‍💻 Автор
**Деев Егор Викторович** - Backend Developer
- GitHub: [@EDeev](https://github.com/EDeev)
- Email: egor@deev.space
- Telegram: [@Egor_Deev](https://t.me/Egor_Deev)
**Деев Егор Викторович** — [GitHub](https://github.com/EDeev) · [Telegram](https://t.me/DeevEgor) · [egor@deev.space](mailto:egor@deev.space)
---
<div align="center">
<sub>⭐ Если проект оказался полезным, поставьте звездочку на GitHub!</sub>
<p><sub>Создано с ❤️ от вашего дорогого - deev.space ©</sub></p>
<sub>⭐ Если проект оказался полезным, поставьте звёздочку на GitHub!</sub>
<p><sub>Сделано с ❤️ — <a href="https://deev.space">deev.space</a></sub></p>
</div>

View file

@ -1,18 +1,18 @@
import asyncio, logging, warnings
import asyncio
import logging
from init import *
from init import bot, dp
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(), skipUpdates=True)
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
# warnings.filterwarnings("ignore")
try: asyncio.run(main())
except KeyboardInterrupt: pass

View file

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

View file

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

View file

@ -7,26 +7,3 @@ 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,8 +1,9 @@
from PIL import Image, ImageDraw, ImageFilter
from moviepy import *
import numpy, os
import logging
import os
from init import bug_report
import numpy
from moviepy import AudioFileClip, ImageSequenceClip, VideoFileClip
from PIL import Image, ImageDraw, ImageFilter
class Movie:
@ -11,48 +12,52 @@ class Movie:
self.video_name = video_name
self.path = path
self.path_frames = ""
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):
video_clip = VideoFileClip(self.video_file)
video_clip.audio.write_audiofile(self.path + f"/{self.video_name}-audio.mp3", logger=None)
try:
video_clip = VideoFileClip(self.video_file)
except Exception:
logging.exception("Не удалось открыть кружочек")
return False
self.path_frames = self.path + "/frames"
step = 1 / 30.0 if video_clip.fps > 60.0 else 1 / video_clip.fps
try:
# у кружочка может не быть звуковой дорожки
if video_clip.audio is not None:
video_clip.audio.write_audiofile(self.path_audio, logger=None)
self.has_audio = True
count = 0
for current_duration in numpy.arange(0, video_clip.duration, step):
count += 1
step = 1 / 30.0 if video_clip.fps > 60.0 else 1 / video_clip.fps
str_c = str(count)
num = "00000"[:(len(str_c) * -1)] + str_c
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()
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 = 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()
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()
return result
class Frame:
@ -61,31 +66,14 @@ class Frame:
self.background = background
def size(self):
image = Image.open(self.filename)
width, height = image.size
image.close()
return width, height
with Image.open(self.filename) as image:
return image.size
def medium_color(self):
img = Image.open(self.filename)
width, height = img.size
with Image.open(self.filename) as img:
pixels = numpy.asarray(img.convert("RGB"), dtype=numpy.float64)
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()
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):
@ -99,11 +87,12 @@ class Frame:
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)
# без обрезки тёмные и светлые цвета «переворачиваются» при переводе в uint8
Image.fromarray(numpy.uint8(numpy.clip(result, 0, 255))).save(self.background, quality=95)
def blur(self, width, height):
image = Image.open(self.filename)
image = image.filter(ImageFilter.GaussianBlur(6))
with Image.open(self.filename) as image:
image = image.filter(ImageFilter.GaussianBlur(6))
part_width = width // 64
part_height = height // 64
@ -129,3 +118,22 @@ class Frame:
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

11
compose.yaml Normal file
View file

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

BIN
docs/demo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

View file

@ -1,14 +1,4 @@
# 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
aiogram==3.23.0
moviepy==2.2.1
Pillow==10.3.0
numpy==2.2.6