Как я рисовал NFT-подарки Telegram в 60 fps, не имея API: бот для эмодзи, стикеров и подарков

В 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.
Что умеет бот
Меню разбито на два раздела.
🧩 Эмодзи и стикеры. Пользователь выбирает режим, и бот обрабатывает всё, что ему присылают:
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 — получаешь MP4 в 60 fps, как карточка в Telegram. Можно выбрать формат (3:2, 1:1 для аватарки, 9:16 для сторис), свой фон, свою подпись, скачать GIF или исходную модель .tgs. Можно включить слежку: бот напишет, когда у подарка сменится владелец.


Задача 1. Как получить подарок без API
В Bot API нет метода «отдай мне картинку NFT-подарка». Зато у каждого подарка есть публичная страница t.me/nft/<slug>, и 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 поменяет вёрстку, сломается всё равно любой способ разбора. Зато на такой случай есть тесты на сохранённых копиях настоящих страниц.
Задача 2. Тёмная обводка, или premultiplied alpha
Модель подарка — анимация 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), а потом сконвертировать.
Задача 3. 60 fps без промежуточных файлов
У 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, чтобы градиент не шёл полосами.
Задача 4. Квадрат вокруг подарка
Самый интересный баг. Пользователь прислал скриншот: вокруг модели подарка виден непрозрачный прямоугольник со своим фоном.

Выяснилось следующее. Если делать много запросов подряд, t.me/nft/… начинает отдавать пустую страницу без данных подарка. Ссылка на модель на странице тоже живёт недолго. На такой случай у бота был запасной путь: nft.fragment.com/gift/<slug>.tgs.
Проблема в том, что 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 не отвечает, бот всё равно рисует подарок с правильными цветами.
Итоговая цепочка такая: страница t.me, при пустой странице два повтора с паузой, потом Fragment. «Подарок не найден» бот отвечает, только если подарка нет и там. У запасного пути есть ограничения: нет владельца и редкости. Поэтому такие данные кэшируются всего на 10 минут, а слежка за владельцем их игнорирует, иначе бот прислал бы ложное «владелец сменился».
Тест, который теперь стережёт этот баг, проверяет простую вещь: у того, что мы рисуем, углы кадра прозрачные.
Задача 5. Перекраска без потери объёма
Хочется перекрасить пак эмодзи в свой цвет. Если просто заменить все цвета на выбранный, получится плоская заливка: теней и бликов больше нет.
Поэтому перекраска идёт в пространстве 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. Код: github.com/alvaro-lox/emoji-studio-bot. Буду рад вопросам и замечаниям в комментариях, особенно если знаете, как Telegram рисует подарки у себя и где я что-то упростил.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.