mirror of
https://github.com/EDeev/circlechek.git
synced 2026-10-08 04:59:46 +03:00
Compare commits
4 commits
3ac2dfe5e1
...
44010bba0b
| Author | SHA1 | Date | |
|---|---|---|---|
| 44010bba0b | |||
| a15bfba330 | |||
| e74873588a | |||
| 7f85340c86 |
17 changed files with 495 additions and 307 deletions
2
.env.example
Normal file
2
.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Токен бота от @BotFather
|
||||||
|
BOT_TOKEN=123456:your-token
|
||||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
* text=auto eol=lf
|
||||||
|
*.mp4 binary
|
||||||
48
.github/workflows/ci.yml
vendored
Normal file
48
.github/workflows/ci.yml
vendored
Normal 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
42
.github/workflows/docker.yml
vendored
Normal 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
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
data/*/*
|
||||||
|
!data/*/.gitkeep
|
||||||
|
data/*.log
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal 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
21
LICENSE
Normal 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
78
README.en.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Circlechek
|
||||||
|
|
||||||
|
[Русский](README.md) · **English**
|
||||||
|
|
||||||
|
[](https://github.com/EDeev/circlechek/actions/workflows/ci.yml)
|
||||||
|
[](https://github.com/EDeev/circlechek/actions/workflows/docker.yml)
|
||||||
|
[](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)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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
127
README.md
|
|
@ -1,106 +1,77 @@
|
||||||
# 🎥 Circlechek
|
# Circlechek
|
||||||
|
|
||||||
**Telegram-бот для преобразования квадратных видео в кружочки и обработки видеосообщений с настраиваемыми фонами.**
|
**Русский** · [English](README.en.md)
|
||||||
|
|
||||||
## 📋 Описание
|
[](https://github.com/EDeev/circlechek/actions/workflows/ci.yml)
|
||||||
|
[](https://github.com/EDeev/circlechek/actions/workflows/docker.yml)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
Circlechek — это простой и функциональный Telegram-бот, предназначенный для работы с видеоконтентом. Бот предоставляет две основные возможности:
|
Telegram-бот для кружочков: делает из квадратного видео видеосообщение-кружок, а из присланного
|
||||||
|
кружочка — обычное видео, где углы заполнены размытым кадром или градиентом под цвет картинки.
|
||||||
|
|
||||||
1. **Преобразование видео в кружочки** — превращает квадратные видео (до 1 минуты) в формат видеосообщений Telegram
|
**Статус:** личный проект, завершён · бот [@circlechek_bot](https://t.me/circlechek_bot)
|
||||||
2. **Обработка кружочков** — конвертирует видеосообщения в обычные видео с настраиваемым фоном по краям
|
|
||||||
|
|
||||||
## ✨ Функциональность
|

|
||||||
|
|
||||||
### 🔄 Видео → Кружочек
|
**Стек:** Python 3.12 · aiogram 3 · MoviePy 2 · Pillow · NumPy · Docker
|
||||||
- Принимает квадратные видеофайлы длительностью до 60 секунд
|
|
||||||
- Автоматически преобразует их в формат кружочков Telegram
|
|
||||||
- Сохраняет качество и плавность воспроизведения
|
|
||||||
|
|
||||||
### 🎨 Кружочек → Видео
|
## Возможности
|
||||||
- Обрабатывает видеосообщения с добавлением фона по краям
|
|
||||||
- **Градиентный фон** — создает плавный цветовой переход на основе доминирующих цветов кадра
|
|
||||||
- **Размытый фон** — использует размытую версию центральной части видео
|
|
||||||
- Сохраняет исходное аудио и синхронизацию
|
|
||||||
|
|
||||||
## 🛠 Технологический стек
|
- **Видео → кружочек.** Квадратное видео до минуты возвращается кружком.
|
||||||
|
- **Кружочек → видео.** Бот разбирает кружок на кадры, заполняет углы и собирает видео обратно со звуком.
|
||||||
|
Фон на выбор:
|
||||||
|
- **блюр** — размытая центральная часть кадра;
|
||||||
|
- **градиент** — по среднему цвету кадра.
|
||||||
|
- Тяжёлая обработка идёт в отдельном потоке, поэтому пока один кружочек обрабатывается, бот отвечает
|
||||||
|
остальным. Каждая обработка — в своей временной папке, которая убирается и при ошибке.
|
||||||
|
|
||||||
- **Python 3.x** — основной язык разработки
|
> [!NOTE]
|
||||||
- **aiogram** — асинхронная библиотека для работы с Telegram Bot API
|
> У части кружочков Telegram бывают некорректные метаданные — тогда обработка не удастся или в видео
|
||||||
- **PIL (Pillow)** — обработка изображений и создание эффектов
|
> будет брак. Бот предупреждает об этом, результат стоит проверять.
|
||||||
- **MoviePy** — работа с видеофайлами, извлечение кадров и аудио
|
|
||||||
- **NumPy** — математические операции с массивами для обработки изображений
|
|
||||||
|
|
||||||
## 🚀 Установка и запуск
|
## Запуск
|
||||||
|
|
||||||
### Требования
|
|
||||||
- Python 3.8+
|
|
||||||
- Токен Telegram-бота от [@BotFather](https://t.me/BotFather)
|
|
||||||
|
|
||||||
### Установка зависимостей
|
|
||||||
```bash
|
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Настройка
|
Готовый образ: `docker pull ghcr.io/edeev/circlechek` или `docker pull dcr.deev.su/edeev/circlechek`.
|
||||||
1. Получите токен бота в [@BotFather](https://t.me/BotFather)
|
|
||||||
2. Отредактируйте файл `code/config.py`:
|
Без Docker: Python 3.12, `pip install -r requirements.txt`, затем `cd code && BOT_TOKEN=… python bot.py`
|
||||||
```python
|
(FFmpeg ставится вместе с MoviePy).
|
||||||
botToken = "ВАШ_ТОКЕН_БОТА"
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
code/bot.py запуск
|
||||||
|
code/handlers.py команды, приём видео и кружочков, кнопки выбора фона
|
||||||
|
code/scripts.py Movie — кадры и звук через MoviePy; Frame — фон и маска-круг через Pillow и NumPy
|
||||||
|
data/ временные файлы обработки
|
||||||
```
|
```
|
||||||
|
|
||||||
### Запуск
|
## Разработка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd code
|
pip install ruff -r requirements.txt
|
||||||
python bot.py
|
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. **Обработка кружочка**:
|
|
||||||
- Отправьте видеосообщение боту
|
|
||||||
- Выберите тип фона: "Градиент" или "Блюр"
|
|
||||||
- Получите обработанное видео
|
|
||||||
|
|
||||||
## ⚠️ Важные особенности
|
**Деев Егор Викторович** — [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">
|
<div align="center">
|
||||||
<sub>⭐ Если проект оказался полезным, поставьте звездочку на GitHub!</sub>
|
<sub>⭐ Если проект оказался полезным, поставьте звёздочку на GitHub!</sub>
|
||||||
<p><sub>Создано с ❤️ от вашего дорогого - deev.space ©</sub></p>
|
<p><sub>Сделано с ❤️ — <a href="https://deev.space">deev.space</a></sub></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import asyncio, logging, warnings
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
from init import *
|
from init import bot, dp
|
||||||
from handlers import router
|
from handlers import router
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
dp.include_router(router)
|
dp.include_router(router)
|
||||||
await bot.delete_webhook(drop_pending_updates=True)
|
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__":
|
if __name__ == "__main__":
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
# warnings.filterwarnings("ignore")
|
|
||||||
|
|
||||||
try: asyncio.run(main())
|
try: asyncio.run(main())
|
||||||
except KeyboardInterrupt: pass
|
except KeyboardInterrupt: pass
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
botToken = "XXXXXXXXXXXXXXXXXXXXXXXX" # @circlechek_bot
|
import os
|
||||||
|
|
||||||
|
botToken = os.getenv("BOT_TOKEN", "XXXXXXXXXXXXXXXXXXXXXXXX") # @circlechek_bot
|
||||||
|
|
||||||
WARN = ("<b>Предупреждение!</b> Кружочек может быть с некорректными метаданными, в этом случае программа выдаст ошибку "
|
WARN = ("<b>Предупреждение!</b> Кружочек может быть с некорректными метаданными, в этом случае программа выдаст ошибку "
|
||||||
"либо произведёт видео с браком! <b>Обязательно перепроверяйте получившиеся видео!</b>\n\n")
|
"либо произведёт видео с браком! <b>Обязательно перепроверяйте получившиеся видео!</b>\n\n")
|
||||||
|
|
|
||||||
123
code/handlers.py
123
code/handlers.py
|
|
@ -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.types import Message, CallbackQuery, ContentType, InlineKeyboardButton, InlineKeyboardMarkup, FSInputFile
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
|
|
||||||
import shutil
|
from scripts import Movie, process_frames
|
||||||
|
|
||||||
from scripts import *
|
|
||||||
from config import WARN
|
from config import WARN
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
@ -15,7 +20,7 @@ router = Router()
|
||||||
async def helps(msg: Message) -> None:
|
async def helps(msg: Message) -> None:
|
||||||
buttons = [[InlineKeyboardButton(text="ФУНКЦИИ", callback_data="fun"),
|
buttons = [[InlineKeyboardButton(text="ФУНКЦИИ", callback_data="fun"),
|
||||||
InlineKeyboardButton(text="АВТОР", callback_data="auth")]]
|
InlineKeyboardButton(text="АВТОР", callback_data="auth")]]
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons, row_width=2)
|
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
await msg.answer(text="<b>Кружочичек</b> — бот для обработки видео и кружочков в Телеграме. Для начала работы "
|
await msg.answer(text="<b>Кружочичек</b> — бот для обработки видео и кружочков в Телеграме. Для начала работы "
|
||||||
"вам достаточно скинуть квадратное видео не дольше минуты в чат, чтобы получить кружок. "
|
"вам достаточно скинуть квадратное видео не дольше минуты в чат, чтобы получить кружок. "
|
||||||
|
|
@ -33,7 +38,7 @@ async def author(call: CallbackQuery) -> None:
|
||||||
'двух предложенных вариантах.'
|
'двух предложенных вариантах.'
|
||||||
|
|
||||||
'\n\nЯ же пишу подобные небольшие проекты, о которых вы можете узнать '
|
'\n\nЯ же пишу подобные небольшие проекты, о которых вы можете узнать '
|
||||||
'больше на моём <a href="https://github.com/IGlek">GitHub</a>.')
|
'больше на моём <a href="https://github.com/EDeev">GitHub</a>.')
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "fun")
|
@router.callback_query(F.data == "fun")
|
||||||
|
|
@ -45,78 +50,84 @@ async def function(call: CallbackQuery) -> None:
|
||||||
# ОБРАБОТЧИК ВИДЕО
|
# ОБРАБОТЧИК ВИДЕО
|
||||||
@router.message(F.content_type == ContentType.VIDEO)
|
@router.message(F.content_type == ContentType.VIDEO)
|
||||||
async def video_to_circle(msg: Message) -> None:
|
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.reply("<b>Началась обработка видео!</b> Оно должно быть квадратным и не дольше одной минуты, в ином "
|
||||||
"случае бот в ответ вернёт вам изначальное видео, а не кружочек!")
|
"случае бот в ответ вернёт вам изначальное видео, а не кружочек!")
|
||||||
await msg.bot.download(file=msg.video.file_id, destination=video)
|
try:
|
||||||
await msg.answer_video_note(video_note=FSInputFile(video))
|
await msg.bot.download(file=msg.video.file_id, destination=video)
|
||||||
os.remove(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)
|
@router.message(F.content_type == ContentType.VIDEO_NOTE)
|
||||||
async def video_note(msg: Message) -> None:
|
async def video_note(msg: Message) -> None:
|
||||||
buttons = [[InlineKeyboardButton(text="Градиент", callback_data="grad"),
|
# номер сообщения в кнопках: если прислать несколько кружочков подряд, каждый обработается свой
|
||||||
InlineKeyboardButton(text="Блюр", callback_data="blur")]]
|
buttons = [[InlineKeyboardButton(text="Градиент", callback_data=f"grad:{msg.message_id}"),
|
||||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=buttons, row_width=2)
|
InlineKeyboardButton(text="Блюр", callback_data=f"blur:{msg.message_id}")]]
|
||||||
|
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
msg_answer = await msg.reply("Кружочек загружается...")
|
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)
|
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:
|
async def work_part(call: CallbackQuery) -> None:
|
||||||
msg = await call.message.edit_text(WARN + "<b>Начало обработки!</b>")
|
mode, note_id = call.data.split(":")
|
||||||
|
video_name = f"{call.message.chat.id}_{note_id}"
|
||||||
video_name = str(msg.chat.id)
|
|
||||||
video_file = video_name + ".mp4"
|
video_file = video_name + ".mp4"
|
||||||
|
source = "../data/video_notes/" + video_file
|
||||||
|
|
||||||
path = f"../data/videos/{video_name}"
|
if not os.path.exists(source):
|
||||||
os.mkdir(path)
|
# кнопку нажали повторно или кружочек уже обработан
|
||||||
|
await call.answer("Этот кружочек уже обработан. Пришлите его ещё раз.", show_alert=True)
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
msg = await msg.edit_text(WARN + "<b>Этап:</b> 2 - Обработка кадров.")
|
msg = await call.message.edit_text(WARN + "<b>Начало обработки!</b>")
|
||||||
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()
|
path = tempfile.mkdtemp(prefix=video_name + "-", dir="../data/videos")
|
||||||
|
try:
|
||||||
|
path_video = path + "/" + video_file
|
||||||
|
os.replace(source, path_video)
|
||||||
|
|
||||||
if call.data == "blur":
|
path_frames = path + "/frames"
|
||||||
img.blur(width, height)
|
os.mkdir(path_frames)
|
||||||
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()
|
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,
|
msg = await msg.edit_text(WARN + "<b>Этап:</b> 3 - Объединение кадров.")
|
||||||
reply_to_message_id=call.message.reply_to_message.message_id)
|
result = await asyncio.to_thread(video.unity_into_video, frames)
|
||||||
await msg_final.delete()
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
||||||
23
code/init.py
23
code/init.py
|
|
@ -7,26 +7,3 @@ from config import botToken
|
||||||
|
|
||||||
bot = Bot(token=botToken, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
bot = Bot(token=botToken, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||||
dp = Dispatcher(storage=MemoryStorage())
|
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
|
|
||||||
|
|
||||||
|
|
|
||||||
270
code/scripts.py
270
code/scripts.py
|
|
@ -1,131 +1,139 @@
|
||||||
from PIL import Image, ImageDraw, ImageFilter
|
import logging
|
||||||
from moviepy import *
|
import os
|
||||||
import numpy, os
|
|
||||||
|
import numpy
|
||||||
from init import bug_report
|
from moviepy import AudioFileClip, ImageSequenceClip, VideoFileClip
|
||||||
|
from PIL import Image, ImageDraw, ImageFilter
|
||||||
|
|
||||||
class Movie:
|
|
||||||
def __init__(self, video_file, video_name, path):
|
class Movie:
|
||||||
self.video_file = video_file
|
def __init__(self, video_file, video_name, path):
|
||||||
self.video_name = video_name
|
self.video_file = video_file
|
||||||
self.path = path
|
self.video_name = video_name
|
||||||
|
self.path = path
|
||||||
self.path_frames = ""
|
|
||||||
|
self.path_frames = self.path + "/frames"
|
||||||
def split_into_frames(self):
|
self.path_audio = self.path + f"/{self.video_name}-audio.mp3"
|
||||||
video_clip = VideoFileClip(self.video_file)
|
self.has_audio = False
|
||||||
video_clip.audio.write_audiofile(self.path + f"/{self.video_name}-audio.mp3", logger=None)
|
|
||||||
|
def split_into_frames(self):
|
||||||
self.path_frames = self.path + "/frames"
|
try:
|
||||||
step = 1 / 30.0 if video_clip.fps > 60.0 else 1 / video_clip.fps
|
video_clip = VideoFileClip(self.video_file)
|
||||||
|
except Exception:
|
||||||
count = 0
|
logging.exception("Не удалось открыть кружочек")
|
||||||
for current_duration in numpy.arange(0, video_clip.duration, step):
|
return False
|
||||||
count += 1
|
|
||||||
|
try:
|
||||||
str_c = str(count)
|
# у кружочка может не быть звуковой дорожки
|
||||||
num = "00000"[:(len(str_c) * -1)] + str_c
|
if video_clip.audio is not None:
|
||||||
|
video_clip.audio.write_audiofile(self.path_audio, logger=None)
|
||||||
frame_filename = os.path.join(self.path_frames, f"frame-{num}.jpeg")
|
self.has_audio = True
|
||||||
try: video_clip.save_frame(frame_filename, current_duration)
|
|
||||||
except Exception: return False
|
step = 1 / 30.0 if video_clip.fps > 60.0 else 1 / video_clip.fps
|
||||||
|
|
||||||
video_clip.close()
|
for count, current_duration in enumerate(numpy.arange(0, video_clip.duration, step), start=1):
|
||||||
return True
|
frame_filename = os.path.join(self.path_frames, f"frame-{count:05}.jpeg")
|
||||||
|
video_clip.save_frame(frame_filename, current_duration)
|
||||||
@bug_report
|
except Exception:
|
||||||
def unity_into_video(self, frames):
|
logging.exception("Не удалось разобрать кружочек на кадры")
|
||||||
video_clip = VideoFileClip(self.video_file)
|
return False
|
||||||
fps = 30.0 if video_clip.fps > 60.0 else video_clip.fps
|
finally:
|
||||||
video_clip.close()
|
video_clip.close()
|
||||||
os.remove(self.video_file)
|
|
||||||
|
return True
|
||||||
clip = ImageSequenceClip(list(map(lambda x: self.path_frames + "/" + x, frames)), fps=fps)
|
|
||||||
clip.write_videofile(self.video_file, logger=None)
|
def unity_into_video(self, frames):
|
||||||
clip.close()
|
video_clip = VideoFileClip(self.video_file)
|
||||||
|
fps = 30.0 if video_clip.fps > 60.0 else video_clip.fps
|
||||||
video_clip = VideoFileClip(self.video_file)
|
video_clip.close()
|
||||||
audio_clip = AudioFileClip(self.path + f"/{self.video_name}-audio.mp3")
|
|
||||||
|
clip = ImageSequenceClip([self.path_frames + "/" + x for x in frames], fps=fps)
|
||||||
video_clip_with_audio = video_clip.with_audio(audio_clip)
|
if self.has_audio:
|
||||||
video_clip_with_audio.write_videofile(self.path + "/acs-" + self.video_name + ".mp4", logger=None)
|
audio_clip = AudioFileClip(self.path_audio)
|
||||||
|
# звук не длиннее видео, иначе последний кадр «замирает»
|
||||||
video_clip.close()
|
clip = clip.with_audio(audio_clip.subclipped(0, min(audio_clip.duration, clip.duration)))
|
||||||
audio_clip.close()
|
|
||||||
|
result = self.path + "/acs-" + self.video_name + ".mp4"
|
||||||
|
clip.write_videofile(result, codec="libx264", audio_codec="aac", logger=None)
|
||||||
class Frame:
|
clip.close()
|
||||||
def __init__(self, filename, background):
|
|
||||||
self.filename = filename
|
return result
|
||||||
self.background = background
|
|
||||||
|
|
||||||
def size(self):
|
class Frame:
|
||||||
image = Image.open(self.filename)
|
def __init__(self, filename, background):
|
||||||
width, height = image.size
|
self.filename = filename
|
||||||
image.close()
|
self.background = background
|
||||||
|
|
||||||
return width, height
|
def size(self):
|
||||||
|
with Image.open(self.filename) as image:
|
||||||
def medium_color(self):
|
return image.size
|
||||||
img = Image.open(self.filename)
|
|
||||||
width, height = img.size
|
def medium_color(self):
|
||||||
|
with Image.open(self.filename) as img:
|
||||||
pixels = img.load()
|
pixels = numpy.asarray(img.convert("RGB"), dtype=numpy.float64)
|
||||||
r, g, b = [], [], []
|
|
||||||
|
r, g, b = (int(c) for c in pixels.reshape(-1, 3).mean(axis=0))
|
||||||
for y in range(width):
|
return r, g, b
|
||||||
for x in range(height):
|
|
||||||
p = pixels[x, y]
|
def gradient(self, width, height, start_list, stop_list, is_horizontal_list):
|
||||||
r.append(p[0])
|
def get_gradient(start, stop, width, height, is_horizontal):
|
||||||
g.append(p[1])
|
if is_horizontal:
|
||||||
b.append(p[2])
|
return numpy.tile(numpy.linspace(start, stop, width), (height, 1))
|
||||||
|
else:
|
||||||
r = sum(r) // len(r)
|
return numpy.tile(numpy.linspace(start, stop, height), (width, 1)).T
|
||||||
g = sum(g) // len(g)
|
|
||||||
b = sum(b) // len(b)
|
result = numpy.zeros((height, width, len(start_list)))
|
||||||
|
for i, (start, stop, is_horizontal) in enumerate(zip(start_list, stop_list, is_horizontal_list)):
|
||||||
img.close()
|
result[:, :, i] = get_gradient(start, stop, width, height, is_horizontal)
|
||||||
return r, g, b
|
|
||||||
|
# без обрезки тёмные и светлые цвета «переворачиваются» при переводе в uint8
|
||||||
def gradient(self, width, height, start_list, stop_list, is_horizontal_list):
|
Image.fromarray(numpy.uint8(numpy.clip(result, 0, 255))).save(self.background, quality=95)
|
||||||
def get_gradient(start, stop, width, height, is_horizontal):
|
|
||||||
if is_horizontal:
|
def blur(self, width, height):
|
||||||
return numpy.tile(numpy.linspace(start, stop, width), (height, 1))
|
with Image.open(self.filename) as image:
|
||||||
else:
|
image = image.filter(ImageFilter.GaussianBlur(6))
|
||||||
return numpy.tile(numpy.linspace(start, stop, height), (width, 1)).T
|
|
||||||
|
part_width = width // 64
|
||||||
result = numpy.zeros((height, width, len(start_list)))
|
part_height = height // 64
|
||||||
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 = image.crop((part_width * 10, part_height * 10, part_width * 54, part_height * 54))
|
||||||
|
image = image.resize((width, height))
|
||||||
Image.fromarray(numpy.uint8(result)).save(self.background, quality=95)
|
image.save(self.background, quality=95)
|
||||||
|
|
||||||
def blur(self, width, height):
|
image.close()
|
||||||
image = Image.open(self.filename)
|
|
||||||
image = image.filter(ImageFilter.GaussianBlur(6))
|
def unity_image(self):
|
||||||
|
im1 = Image.open(self.background)
|
||||||
part_width = width // 64
|
im2 = Image.open(self.filename)
|
||||||
part_height = height // 64
|
|
||||||
|
mask = Image.new("L", im2.size, 0)
|
||||||
image = image.crop((part_width * 10, part_height * 10, part_width * 54, part_height * 54))
|
draw = ImageDraw.Draw(mask)
|
||||||
image = image.resize((width, height))
|
draw.ellipse(((1, 1), (im2.size[0] - 1, im2.size[1] - 1)), fill=255)
|
||||||
image.save(self.background, quality=95)
|
mask_blur = mask.filter(ImageFilter.GaussianBlur(1))
|
||||||
|
|
||||||
image.close()
|
im1.paste(im2, (0, 0), mask_blur)
|
||||||
|
im1.save(self.filename)
|
||||||
def unity_image(self):
|
|
||||||
im1 = Image.open(self.background)
|
im1.close()
|
||||||
im2 = Image.open(self.filename)
|
im2.close()
|
||||||
|
mask.close()
|
||||||
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)
|
def process_frames(path_frames, path_background, mode):
|
||||||
mask_blur = mask.filter(ImageFilter.GaussianBlur(1))
|
frames = sorted(os.listdir(path_frames))
|
||||||
|
|
||||||
im1.paste(im2, (0, 0), mask_blur)
|
for frame in frames:
|
||||||
im1.save(self.filename)
|
img = Frame(path_frames + "/" + frame, path_background + "/" + f"{frame[:-5]}-g.jpeg")
|
||||||
|
width, height = img.size()
|
||||||
im1.close()
|
|
||||||
im2.close()
|
if mode == "blur":
|
||||||
mask.close()
|
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
11
compose.yaml
Normal 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
BIN
docs/demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 328 KiB |
|
|
@ -1,14 +1,4 @@
|
||||||
# Core bot framework
|
aiogram==3.23.0
|
||||||
aiogram>=3.0.0
|
moviepy==2.2.1
|
||||||
|
Pillow==10.3.0
|
||||||
# Image processing
|
numpy==2.2.6
|
||||||
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
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue