The Problem No One Warns You About
When Telegram introduced video avatars, the spec seems simple: a square video, under 10 seconds, under 2MB. Send any short clip from your phone and done.
Except it's not. iPhone users send HEVC .mov clips, Telegram silently rejects them, and the bot returns a generic error. No useful message, no codec name, nothing. The user assumes the bot is broken.
I ran into this building @liveavabot. About a third of early test files were HEVC from iPhones shooting in the default "High Efficiency" mode. None of them worked until I understood what Telegram actually requires.
What Telegram Actually Requires
The video avatar spec is documented only in fragments across Telegram's API docs and forum posts. Here's what I confirmed through testing:
- Codec: H.264 (AVC), not HEVC (H.265)
-
Container: MP4 with
faststartflag (moov atom at the front) -
Pixel format:
yuv420p - Resolution: exactly 800x800
- Duration: 10 seconds max
- File size: 2MB max
- Audio: must be absent (Telegram strips it anyway, but presence sometimes triggers a rejection)
HEVC fails at the first checkpoint. iPhone 12 and later shoot HEVC by default. The .mov container adds more friction. And if your source video has odd dimensions (most phone footage is 16:9 or 4:3), naive scaling to 800x800 squishes it.
The FFmpeg Pipeline
The approach I settled on: cropdetect to find the largest square crop, then scale and encode in one pass.
First, a quick pass to get crop parameters:
ffmpeg -i input.mov \
-vf "cropdetect=24:2:0" \
-f null - 2>&1 | grep cropdetect | tail -1
That outputs a line like crop=1080:1080:0:60. Feed those values into the encode pass:
ffmpeg -i input.mov \
-vf "crop=1080:1080:0:60,scale=800:800:flags=lanczos,format=yuv420p" \
-c:v libx264 -crf 26 -preset fast \
-movflags +faststart \
-an \
-t 10 \
output.mp4
Key flags worth understanding:
-
-c:v libx264forces H.264, overriding whatever the input codec was -
-crf 26balances quality vs file size (lower = better quality, bigger file) -
-movflags +faststartmoves the moov atom before the mdat, which Telegram requires for streaming -
-anstrips the audio track entirely -
-t 10hard-caps duration at 10 seconds
In practice I run cropdetect in a subprocess, parse the last crop= line with a regex, then inject those values into the encode command. For videos that are already roughly square, cropdetect returns the full frame dimensions, so no quality is lost.
The 2MB constraint is the hardest part. -crf 26 gets most 10-second clips under 2MB, but dense motion (confetti, fast panning) can push it over. My fallback bumps -crf to 30, then 34. If it's still over 2MB I truncate to 8 seconds. Hasn't failed yet across a few hundred test files.
The Aiogram 3 Handler
The bot itself is a thin wrapper around the ffmpeg pipeline. Here's the core video handler:
@router.message(F.video | F.animation | F.document)
async def handle_video(message: Message, bot: Bot) -> None:
file_id = None
if message.video:
file_id = message.video.file_id
elif message.animation:
file_id = message.animation.file_id
elif message.document and message.document.mime_type.startswith("video/"):
file_id = message.document.file_id
if not file_id:
await message.reply("Send a video file or GIF.")
return
status = await message.reply("Converting...")
with tempfile.TemporaryDirectory() as tmpdir:
input_path = Path(tmpdir) / "input"
output_path = Path(tmpdir) / "output.mp4"
tg_file = await bot.get_file(file_id)
await bot.download_file(tg_file.file_path, input_path)
success = await asyncio.to_thread(convert_to_avatar, input_path, output_path)
if not success:
await status.edit_text("Conversion failed. Is this a valid video file?")
return
await bot.send_video_note(
chat_id=message.chat.id,
video_note=FSInputFile(output_path),
reply_to_message_id=message.message_id,
)
await status.delete()
convert_to_avatar is the synchronous ffmpeg wrapper. Running it in asyncio.to_thread keeps the event loop unblocked during encoding.
One thing that tripped me up: Telegram's bot API doesn't let you directly set another user's avatar programmatically. You can set your own bot's profile photo, or you use the client API (TDLib or Telethon) for user accounts. For @liveavabot the use case is simpler: the user downloads the converted clip and sets it themselves. So I send back the video note and a download button.
How I Packaged This as @liveavabot
The bot went live after about a weekend of work. It has 453 users so far. Most sessions are under a minute: send video, get back 800x800 H.264 clip, done.
A few things that surprised me during testing:
- GIFs from Telegram's own GIF keyboard come in as
.mp4animations, not.gif. Aiogram'sF.animationfilter catches them correctly without any extra handling. - Some Android phones send VP9-encoded
.webmfiles. FFmpeg handles these without complaint, but I had to addwebmto the accepted MIME type list. - The biggest user drop-off point was people sending photos instead of videos. A one-line handler that replies "that's a photo, not a video" cut the confusion noticeably.
The bot runs on a small VPS, processes files in a temp directory that gets wiped after each request, and logs nothing but request counts. No user data stored.
Try it: https://t.me/LiveAvaBot?start=devto_article_20261010
Built by me (@liveavabot).
What's Next
4K source handling. For true 4K sources, pre-scaling before cropdetect would be faster. Currently ffmpeg downsamples during the encode pass, which works but takes longer on a low-end VPS.
Better error messages. When ffmpeg fails, I return a generic message. Parsing ffmpeg's stderr would let me return the actual reason: "input is 47s, please trim to under 10s" is a lot more useful than "conversion failed".
Direct avatar setting. Right now the user sets the avatar manually after downloading. A proper implementation using the Telegram client API could set it in one tap. That's a bigger change but it's on the list.
The whole thing is straightforward Python: aiogram 3 for the bot layer, subprocess calls to ffmpeg for the conversion, nothing exotic. If you're building something similar, the cropdetect pipeline is worth borrowing.
Top comments (0)