- PVSM.RU - https://www.pvsm.ru -
В Telegram есть премиум-эмодзи, паки стикеров и NFT-подарки. Всё это красиво выглядит, но достать оттуда что-то для своей работы неудобно. Скачать пак целиком нельзя. Чтобы узнать custom_emoji_id, нужен отдельный бот или отладчик. А анимацию подарка вне Telegram можно разве что записать с экрана.
Я написал бота Emoji Studio, который всё это делает. В статье — не столько про сами функции, сколько про несколько интересных задач, которые пришлось решить по дороге:
как получить картинку подарка, когда у Telegram нет для этого API;
почему при наложении кадров Lottie появляется тёмная обводка и как это исправить;
откуда вокруг подарка взялся непрозрачный квадрат;
как перекрасить пак, чтобы не пропали тени и блики.
Стек: Python 3.12, aiogram 3, rlottie-python, numpy, Pillow, ffmpeg, SQLite. Код открыт: github.com/alvaro-lox/emoji-studio-bot [1].
Меню разбито на два раздела.
🧩 Эмодзи и стикеры. Пользователь выбирает режим, и бот обрабатывает всё, что ему присылают:
ID эмодзи — custom_emoji_id каждого эмодзи, кнопка «Скопировать» и готовый код для python-telegram-bot, aiogram, Telethon и Pyrogram. Для целого пака — список ID и JSON-файл.
Скачать — всё, что прислано подряд, приходит одним zip (.tgs, .webm, .webp).
Перекрасить — пак в любой цвет, с кнопкой «Создать набор в Telegram».
В любом режиме ссылка на пак даёт превью всего пака одной картинкой, а фото, GIF или видео превращается в эмодзи 100×100.
🎁 NFT-подарки. Присылаешь https://t.me/nft/plushpepe-1 [2] — получаешь MP4 в 60 fps, как карточка в Telegram. Можно выбрать формат (3:2, 1:1 для аватарки, 9:16 для сторис), свой фон, свою подпись, скачать GIF или исходную модель .tgs. Можно включить слежку: бот напишет, когда у подарка сменится владелец.
В Bot API нет метода «отдай мне картинку NFT-подарка». Зато у каждого подарка есть публичная страница t.me/nft/<slug> [3], и Telegram сам рисует на ней карточку. Если открыть исходный код, там есть всё нужное:
радиальный градиент фона — обычный SVG с <stop stop-color="…">;
узор — картинка-плитка #giftPattern и список её копий с позицией, масштабом и прозрачностью;
модель — ссылка на .tgs в <source type="application/x-tgsticker">;
таблица с моделью, фоном, символом, их редкостью и владельцем.
Разбор занимает пару десятков строк регулярок:
for opacity, x, y, scale in re.findall( r'<g opacity="([d.]+)" transform="translate(([-d.]+),s*([-d.]+))">s*' r'<use xlink:href="#giftPattern" transform="scale(([d.]+))"', html,): info.pattern.append(PatternItem(float(x), float(y), float(scale), float(opacity)))
r'<g opacity="([d.]+)" transform="translate(([-d.]+),s*([-d.]+))">s*'
r'<use xlink:href="#giftPattern" transform="scale(([d.]+))"',
html,
):
info.pattern.append(PatternItem(float(x), float(y), float(scale), float(opacity)))
Координаты узора заданы для холста 420×280, поэтому дальше я просто масштабирую их под нужный размер. Для формата 9:16 холст растягивается по высоте, а модель и узор остаются по центру.
Регулярки вместо HTML-парсера — осознанный выбор. Страница маленькая, нужные куски стабильны, а если Telegram поменяет вёрстку, сломается всё равно любой способ разбора. Зато на такой случай есть тесты на сохранённых копиях настоящих страниц.
Модель подарка — анимация Lottie. Кадры я получаю через rlottie (тот же движок, что и в самом Telegram) и накладываю на фон в numpy. Первая версия делала это по учебнику:
out = src * a + dst * (1 - a)
И по краям модели появилась грязная тёмная кайма. Причина в том, что rlottie отдаёт пиксели с premultiplied alpha: цвет уже умножен на прозрачность. Если умножить его ещё раз, полупрозрачные пиксели на краях становятся темнее, чем должны. Правильная формула короче:
# rlottie отдаёт premultiplied alpha: out = src + dst * (1 - a)alpha = model[..., 3:4] / 255.0composed = np.clip(model[..., :3] + region * (1 - alpha), 0, 255).astype(np.uint8)
alpha = model[..., 3:4] / 255.0
composed = np.clip(model[..., :3] + region * (1 - alpha), 0, 255).astype(np.uint8)
Та же ловушка есть в Pillow: если сделать из кадра rlottie картинку как RGBA, края будут тёмными. Нужно открыть его как RGBa (с маленькой «a» — это и есть premultiplied), а потом сконвертировать.
У Lottie своя частота кадров, обычно 30 или 60. Чтобы получить ровно 60 fps, для каждого выходного кадра я беру ближайший кадр анимации. Если он не изменился, повторно его не рендерю:
for k in range(frames_out): index = min(total - 1, int(k * rate / FPS)) if index != last_index: ... # рендер и наложение bg[vy0:vy1, vx0:vx1] = composed stdin.write(bg.tobytes())
index = min(total - 1, int(k * rate / FPS))
if index != last_index:
... # рендер и наложение
bg[vy0:vy1, vx0:vx1] = composed
stdin.write(bg.tobytes())
Кадры не сохраняются на диск: сырые RGB-байты идут сразу в ffmpeg через pipe (-f rawvideo -pix_fmt rgb24 -i -), на выходе libx264 с crf 18 и +faststart, чтобы видео начинало играть до полной загрузки. Фон с узором рисуется один раз, а на каждом кадре перерисовывается только прямоугольник под моделью.
Отдельная мелочь — GIF. В нём задержка кадра хранится в сотых долях секунды, поэтому 60 fps там просто невозможно: 1/60 с округлится до 0,02 с, и анимация замедлится. Поэтому GIF делается в 50 fps, с палитрой через palettegen/paletteuse, чтобы градиент не шёл полосами.
Самый интересный баг. Пользователь прислал скриншот: вокруг модели подарка виден непрозрачный прямоугольник со своим фоном.
Выяснилось следующее. Если делать много запросов подряд, t.me/nft/… [4] начинает отдавать пустую страницу без данных подарка. Ссылка на модель на странице тоже живёт недолго. На такой случай у бота был запасной путь: nft.fragment.com/gift/<slug>.tgs [5].
Проблема в том, что Fragment по этой ссылке отдаёт не модель, а всю карточку: фон, узор и модель в одном Lottie-файле. Бот рисовал эту карточку поверх своего фона, и получался квадрат. Я проверил это напрямую: в углах кадра из Fragment альфа около 251, а у модели со страницы — 0.
Если посмотреть внутрь, карточка Fragment устроена аккуратно: слои называются Background, Pattern, Color Icon, а сама модель лежит отдельным ассетом с id G. Значит, модель можно просто вырезать:
def extract_fragment_model(tgs: bytes) -> bytes: """Fragment отдаёт всю карточку. Оставляем только модель — ассет G.""" data = json.loads(gzip.decompress(tgs)) assets = {a.get("id"): a for a in data.get("assets", [])} model = assets.get("G") if not model or not model.get("layers"): raise ValueError("no model asset in fragment tgs") # лучше ошибка, чем квадрат data["layers"] = model["layers"] data["assets"] = [a for a in data["assets"] if a.get("id") != "G"] return gzip.compress(json.dumps(data, separators=(",", ":")).encode())
"""Fragment отдаёт всю карточку. Оставляем только модель — ассет G."""
data = json.loads(gzip.decompress(tgs))
assets = {a.get("id"): a for a in data.get("assets", [])}
model = assets.get("G")
if not model or not model.get("layers"):
raise ValueError("no model asset in fragment tgs") # лучше ошибка, чем квадрат
data["layers"] = model["layers"]
data["assets"] = [a for a in data["assets"] if a.get("id") != "G"]
return gzip.compress(json.dumps(data, separators=(",", ":")).encode())
Из той же карточки берутся цвета градиента (слой Background) и узор (слой Pattern). Так что, если t.me [6] не отвечает, бот всё равно рисует подарок с правильными цветами.
Итоговая цепочка такая: страница t.me [6], при пустой странице два повтора с паузой, потом Fragment. «Подарок не найден» бот отвечает, только если подарка нет и там. У запасного пути есть ограничения: нет владельца и редкости. Поэтому такие данные кэшируются всего на 10 минут, а слежка за владельцем их игнорирует, иначе бот прислал бы ложное «владелец сменился».
Тест, который теперь стережёт этот баг, проверяет простую вещь: у того, что мы рисуем, углы кадра прозрачные.
Хочется перекрасить пак эмодзи в свой цвет. Если просто заменить все цвета на выбранный, получится плоская заливка: теней и бликов больше нет.
Поэтому перекраска идёт в пространстве HLS. От выбранного цвета берутся тон и насыщенность, а светлота каждого исходного цвета сохраняется. Тени остаются тёмными, блики светлыми:
class Tint: def __init__(self, hex_color: str): r, g, b = hex_to_rgb(hex_color) self.hue, self.light, self.sat = colorsys.rgb_to_hls(r, g, b)
def __call__(self, r: float, g: float, b: float) -> tuple[float, float, float]: _, light, sat = colorsys.rgb_to_hls(r, g, b) # серые участки тоже окрашиваем, но чуть слабее насыщенных new_sat = self.sat * (0.55 + 0.45 * sat) return colorsys.hls_to_rgb(self.hue, light, new_sat)
def init(self, hex_color: str):
r, g, b = hex_to_rgb(hex_color)
self.hue, self.light, self.sat = colorsys.rgb_to_hls(r, g, b)
def call(self, r: float, g: float, b: float) -> tuple[float, float, float]:
, light, sat = colorsys.rgbto_hls(r, g, b)
# серые участки тоже окрашиваем, но чуть слабее насыщенных
new_sat = self.sat (0.55 + 0.45 sat)
return colorsys.hls_to_rgb(self.hue, light, new_sat)
Для .tgs эта функция применяется к каждому цвету в JSON Lottie: заливкам, обводкам, градиентам (у них цвета лежат плоским списком вперемешку с позициями) и анимированным ключевым кадрам. Для .webp — попиксельно. Перекрашенный пак можно сразу опубликовать как новый набор: бот создаёт его через createNewStickerSet и докидывает остальное через addStickerToSet, потому что при создании можно передать не больше 50 стикеров.
|
До |
После (серый) |
|---|---|
|
|
|
|
|
|
|
|
Тени на швах, блики на пуговицах и объём сердца остались на месте — поменялся только цвет.
Код для Telethon. В HTML-библиотеках эмодзи — это просто тег. В Telethon нужны MessageEntityCustomEmoji со смещениями, и считаются они в UTF-16, а не в символах Python. Эмодзи вроде 👨👩👧 занимает там 8 единиц.
Защита от нагрузки. Рендер подарка — тяжёлая операция, поэтому на тяжёлые действия есть лимит со скользящим окном на каждого пользователя, плюс антифлуд. На админов лимиты не действуют.
Код разложен по тем же разделам, что и меню:
app/├── menu.py главное меню и разделы├── nft/ подарки: parser, render, service, watcher, handlers├── stickers/ эмодзи и стикеры: core, downloader, recolor, emoji_maker, packs, handlers/├── admin/ статистика, рассылка, баны└── common/ хранилище SQLite, лимиты, тексты RU/EN, иконки
├── menu.py главное меню и разделы
├── nft/ подарки: parser, render, service, watcher, handlers
├── stickers/ эмодзи и стикеры: core, downloader, recolor, emoji_maker, packs, handlers/
├── admin/ статистика, рассылка, баны
└── common/ хранилище SQLite, лимиты, тексты RU/EN, иконки
Есть тесты (pytest, 65 штук), ruff и mypy проходят без замечаний. Запуск — docker compose up -d. Для Windows есть deploy.bat: спрашивает адрес сервера, пароль и токен, заходит по SSH, ставит Docker и запускает бота. Пароль нигде не сохраняется.
Нормальное удаление фона нейросетью (сейчас бот срезает только однотонный фон).
Генерация эмодзи по текстовому описанию.
Попробовать бота: @EmojiTGSFileBot [7]. Код: github.com/alvaro-lox/emoji-studio-bot [1]. Буду рад вопросам и замечаниям в комментариях, особенно если знаете, как Telegram рисует подарки у себя и где я что-то упростил.
Автор: Alvaro123
Источник [8]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/python/461189
Ссылки в тексте:
[1] github.com/alvaro-lox/emoji-studio-bot: http://github.com/alvaro-lox/emoji-studio-bot
[2] https://t.me/nft/plushpepe-1: https://t.me/nft/plushpepe-1
[3] t.me/nft/<slug>: http://t.me/nft/<slug>
[4] t.me/nft/…: http://t.me/nft/%E2%80%A6
[5] nft.fragment.com/gift/<slug>.tgs: http://nft.fragment.com/gift/<slug>.tgs
[6] t.me: http://t.me
[7] @EmojiTGSFileBot: https://www.pvsm.ru/users/EmojiTGSFileBot
[8] Источник: https://habr.com/ru/articles/1101044/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1101044
Нажмите здесь для печати.