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

Исправлены ошибки обработки кружочков

- папка обработки была одна на чат: после первой же ошибки она оставалась, и все следующие
  кружочки в этом чате падали на 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 — версии закреплены
  по работающему окружению.
This commit is contained in:
Деев Егор Викторович 2026-10-05 19:16:13 +00:00
parent 7f85340c86
commit e74873588a
6 changed files with 142 additions and 154 deletions

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 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

View file

@ -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")

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.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)

View file

@ -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

View file

@ -1,8 +1,9 @@
from PIL import Image, ImageDraw, ImageFilter import logging
from moviepy import * import os
import numpy, os
from init import bug_report import numpy
from moviepy import AudioFileClip, ImageSequenceClip, VideoFileClip
from PIL import Image, ImageDraw, ImageFilter
class Movie: class Movie:
@ -11,48 +12,52 @@ class Movie:
self.video_name = video_name self.video_name = video_name
self.path = path 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): def split_into_frames(self):
video_clip = VideoFileClip(self.video_file) try:
video_clip.audio.write_audiofile(self.path + f"/{self.video_name}-audio.mp3", logger=None) video_clip = VideoFileClip(self.video_file)
except Exception:
logging.exception("Не удалось открыть кружочек")
return False
self.path_frames = self.path + "/frames" try:
step = 1 / 30.0 if video_clip.fps > 60.0 else 1 / video_clip.fps # у кружочка может не быть звуковой дорожки
if video_clip.audio is not None:
video_clip.audio.write_audiofile(self.path_audio, logger=None)
self.has_audio = True
count = 0 step = 1 / 30.0 if video_clip.fps > 60.0 else 1 / video_clip.fps
for current_duration in numpy.arange(0, video_clip.duration, step):
count += 1
str_c = str(count) for count, current_duration in enumerate(numpy.arange(0, video_clip.duration, step), start=1):
num = "00000"[:(len(str_c) * -1)] + str_c 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 return True
@bug_report
def unity_into_video(self, frames): def unity_into_video(self, frames):
video_clip = VideoFileClip(self.video_file) video_clip = VideoFileClip(self.video_file)
fps = 30.0 if video_clip.fps > 60.0 else video_clip.fps fps = 30.0 if video_clip.fps > 60.0 else video_clip.fps
video_clip.close() video_clip.close()
os.remove(self.video_file)
clip = ImageSequenceClip(list(map(lambda x: self.path_frames + "/" + x, frames)), fps=fps) clip = ImageSequenceClip([self.path_frames + "/" + x for x in frames], fps=fps)
clip.write_videofile(self.video_file, logger=None) 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() clip.close()
video_clip = VideoFileClip(self.video_file) return result
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: class Frame:
@ -61,31 +66,14 @@ class Frame:
self.background = background self.background = background
def size(self): def size(self):
image = Image.open(self.filename) with Image.open(self.filename) as image:
width, height = image.size return image.size
image.close()
return width, height
def medium_color(self): def medium_color(self):
img = Image.open(self.filename) with Image.open(self.filename) as img:
width, height = img.size pixels = numpy.asarray(img.convert("RGB"), dtype=numpy.float64)
pixels = img.load() r, g, b = (int(c) for c in pixels.reshape(-1, 3).mean(axis=0))
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 return r, g, b
def gradient(self, width, height, start_list, stop_list, is_horizontal_list): 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)): 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) 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): def blur(self, width, height):
image = Image.open(self.filename) with Image.open(self.filename) as image:
image = image.filter(ImageFilter.GaussianBlur(6)) image = image.filter(ImageFilter.GaussianBlur(6))
part_width = width // 64 part_width = width // 64
part_height = height // 64 part_height = height // 64
@ -129,3 +118,22 @@ class Frame:
im1.close() im1.close()
im2.close() im2.close()
mask.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

View file

@ -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