I built @liveavabot last year to fix a stupid problem: iPhone videos silently fail as Telegram video avatars. The bot has processed conversions for 290 users so far, and a few of them asked the same question: "can I hit this from my own code?"
So I shipped a REST API. This post walks through the Telegram spec that makes video avatars weird, the ffmpeg pipeline that solves it, and how to call the new endpoint from your own app.
Why video avatars are harder than they look
Telegram's video avatar format is picky. The spec, if you dig into the Bot API docs and the desktop client source, boils down to:
- Codec: H.264 (yuv420p pixel format), audio track removed
- Resolution: 800x800, square, center-cropped
- Duration: 3 to 10 seconds
- File size: under 2 MB
- Container: MP4 with faststart flag
Miss any of these and Telegram either rejects the upload with a generic error or, worse, accepts it and displays a black square. iPhone videos are HEVC (H.265) by default since iOS 11, and Telegram's avatar upload does not transcode. It just refuses.
Most users never figure out why their video "doesn't work." They try three times, give up, use a still photo.
The ffmpeg pipeline
Here is the actual command the bot runs. It does cropdetect to find the tightest square crop, then re-encodes to spec:
ffmpeg -i input.mov \
-vf "crop='min(iw,ih)':'min(iw,ih)',scale=800:800,format=yuv420p" \
-c:v libx264 -profile:v high -level 4.0 \
-preset medium -crf 23 \
-movflags +faststart \
-an \
-t 10 \
-y output.mp4
A few things worth noting:
-
crop='min(iw,ih)':'min(iw,ih)'grabs a centered square from portrait or landscape source. No math, ffmpeg figures it out. -
format=yuv420pis the pixel format Telegram actually renders. Skip it and iOS devices show nothing. -
-anstrips audio. Telegram ignores it anyway, but including it counts toward the 2 MB budget. -
-movflags +faststartmoves the moov atom to the front. Without it, Telegram sometimes chokes on large files even when they fit the size limit. -
-t 10hard-caps duration at 10 seconds. If the source is shorter, ffmpeg just uses what's there.
CRF 23 usually keeps a 10-second 800x800 clip under 2 MB. If it goes over, the bot re-runs with CRF 28 as a fallback.
The aiogram 3 handler
The Telegram-side code is boring on purpose. Grab the file, hand it to the converter, send the result back:
from aiogram import Router, F
from aiogram.types import Message, FSInputFile
from pathlib import Path
import asyncio
router = Router()
@router.message(F.video | F.animation | F.document)
async def handle_video(msg: Message):
file = msg.video or msg.animation or msg.document
src = Path(f"/tmp/{file.file_id}.mov")
dst = Path(f"/tmp/{file.file_id}_avatar.mp4")
await msg.bot.download(file, destination=src)
await msg.answer("converting...")
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-i", str(src),
"-vf", "crop='min(iw,ih)':'min(iw,ih)',scale=800:800,format=yuv420p",
"-c:v", "libx264", "-crf", "23",
"-movflags", "+faststart", "-an", "-t", "10",
"-y", str(dst),
)
await proc.wait()
await msg.answer_video(FSInputFile(dst))
src.unlink(missing_ok=True)
dst.unlink(missing_ok=True)
In production the converter runs in a worker with a queue, size checks, HEVC detection, and a fallback CRF pass. But this snippet is what the core actually does.
The REST API
A few devs asked to embed this in their own apps: profile-picture flows in a client, a wedding-video service, a Discord-to-Telegram bridge. So I exposed the same pipeline over HTTP.
Endpoint
POST /api/v1/convert
Authorization: Bearer <token>
Content-Type: multipart/form-data
file: <video>
preset: lofi|bw|vignette|blur|none (optional, default: none)
trim_start: 0.0 (optional)
Response
{
"status": "ok",
"output_url": "https://...",
"duration_s": 8.2,
"size_bytes": 1847291,
"codec": "h264"
}
The preset parameter applies an ffmpeg filter chain on top of the base conversion. lofi adds grain and a slight desaturation, bw is a straight greyscale, vignette is the ffmpeg vignette filter with defaults, blur is a soft gaussian on the edges. trim_start shifts the source window if the good bit is not at second zero.
Output URLs are signed and expire after 1 hour. Downloading the file is your job; the bot does not keep it.
Getting a token
Send /api to @liveavabot and it DMs you a token. Rate limits are generous during beta (60 requests per hour per token), let me know if you hit them.
Quick curl example
curl -X POST https://api.liveava.bot/api/v1/convert \
-H "Authorization: Bearer $TOKEN" \
-F "file=@my_video.mov" \
-F "preset=lofi"
Lessons and edge cases
A few things I learned shipping this:
- HEVC detection has to happen before you trust anything.
ffprobe -show_streamsis your friend. Some "MP4" files from Android are actually HEVC in an MP4 container. - The 2 MB limit is not always exactly 2 MB. Telegram seems to accept up to ~2.1 MB in practice, but I keep the target under 1.9 MB to be safe.
- 4K source video is still rough. The crop+scale pass on a 4K 60fps clip takes 15-20 seconds on the current worker. I have not optimised this yet.
- The API's biggest surprise: about half the traffic is from people testing whether their video will work before they even open Telegram. That was not the use case I designed for.
Built by me, @liveavabot is at https://t.me/LiveAvaBot?start=devto_article_20260807. API docs and token via /api in the bot. Feedback welcome, especially from anyone building on top of it.
Top comments (0)