The Bug That Isn't a Bug
A friend sent me a screen recording of his problem. He picked a clip for his Telegram profile (the round animated avatar), the app showed a spinner, and then the avatar stayed a static photo. No error message, nothing in the UI at all. He tried three times and gave up.
I pulled the file apart. Recorded on an iPhone 13: .mov container, HEVC video, 10-bit color. That has been the default capture format since iOS 11. Telegram's video avatar pipeline wants H.264, and when the codec doesn't match, the client silently drops the upload instead of telling you why.
That silent failure annoyed me enough to build a fix. This post walks through the ffmpeg pipeline and the aiogram 3 handler behind it.
What Telegram Actually Wants
The docs are thin on video avatars, so most of these numbers came from testing against real clients:
- Container: MP4
- Codec: H.264 with yuv420p pixel format
- Resolution: square, 800x800 works everywhere
- Duration: 10 seconds max
- Audio: none, strip the track entirely
- Size: around 2 MB or under
The two killers are codec and pixel format. iPhone HEVC is usually yuv420p10le, meaning 10-bit. Even if you transcode to H.264 but keep 10-bit, some clients still refuse to animate it. format=yuv420p is not optional.
Audio surprises people too. A video avatar has no sound by design, and Telegram is stricter about a leftover audio track than you'd expect. -an is the reliable answer.
The FFmpeg Pipeline
The pipeline has three jobs: find the actual picture area, cut a square from it, and land on the codec spec.
For the picture area I run cropdetect on the first two seconds. It catches letterboxed videos and screen recordings with black bars:
ffmpeg -i input.mov -t 2 -vf "cropdetect=24:16:0" -f null - 2>&1 \
| grep -o "crop=[0-9:]*" | tail -1
# crop=1080:1350:0:285
Then I compute the largest centered square inside that region and do a single encode pass:
ffmpeg -y -i input.mov -t 10 \
-vf "crop=1080:1080:0:420,scale=800:800:flags=lanczos,format=yuv420p" \
-c:v libx264 -profile:v main -preset veryfast -crf 26 \
-an -movflags +faststart \
output.mp4
Flag by flag: -t 10 hard-caps duration. The crop values come straight from the cropdetect output. scale=800:800:flags=lanczos downscales sharper than the default bilinear. format=yuv420p forces 8-bit 4:2:0 and fixes the 10-bit HEVC case. -an drops audio. -movflags +faststart moves the moov atom to the front so playback starts before the file finishes downloading.
For the 2 MB cap I skip the bitrate math. I encode at CRF 26 and check the output size. Over the limit? Retry at 28, then 31. Two retries have covered every real file users have thrown at it. A 10-second 800x800 clip at CRF 26 usually lands between 1.2 and 1.8 MB.
Wiring It Into aiogram 3
The bot side is short. Download the incoming file, run the encode, reply with the result:
import asyncio
import tempfile
from pathlib import Path
from aiogram import Bot, F, Router
from aiogram.types import FSInputFile, Message
router = Router()
VF = "crop='min(iw,ih)':'min(iw,ih)',scale=800:800:flags=lanczos,format=yuv420p"
@router.message(F.video | F.animation | F.document)
async def convert(message: Message, bot: Bot):
media = message.video or message.animation or message.document
if media.file_size and media.file_size > 50 * 1024 * 1024:
await message.reply("Over 50 MB, the Bot API refuses the download.")
return
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "input.bin"
dst = Path(tmp) / "avatar.mp4"
await bot.download(media, destination=src)
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-y", "-i", str(src), "-t", "10",
"-vf", VF,
"-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
"-an", "-movflags", "+faststart", str(dst),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
if proc.returncode != 0 or not dst.exists():
await message.reply("Conversion failed on this one. Mind sending the original as a file?")
return
await message.reply_video(FSInputFile(dst), width=800, height=800)
Three notes on the snippet. The inline min(iw,ih) crop is the lazy version, production runs cropdetect first as shown above. create_subprocess_exec means user filenames never touch a shell. And F.document matters more than it looks: sending a video "as file" is exactly how iPhone HEVC .mov files arrive, so skipping documents means missing the users who need this most. You'd want a mime type check inside the handler, I cut it here for length.
The 50 MB guard is a Bot API limit, getFile refuses anything larger, so failing early with a clear message beats a mysterious timeout.
Shipping It as @liveavabot
I wrapped the whole thing into @LiveAvaBot. You send it a video or GIF, it replies with a converted clip, and you set that clip from Telegram's profile settings. 223 people have used it so far, and most of them arrive after hitting the exact silent failure from the intro.
It runs on a small VPS. ffmpeg is doing the heavy lifting, my code is mostly plumbing, rate limits, and cleanup. With the veryfast preset a typical iPhone clip converts in under four seconds, so I haven't needed a job queue yet.
Edge Cases That Bit Me
Rotation metadata was the worst one. iPhones store portrait video as landscape plus a rotation flag in a display matrix. Modern ffmpeg autorotates on decode, but if you compute your crop square from raw stream dimensions you'll cut along the wrong axis. Probe the rotation, or measure dimensions after decode, not from container metadata.
Real .gif uploads (not Telegram's MP4 animations) can carry bizarre frame timing. Adding fps=30 to the filter chain normalizes them.
Some screen recorders write files where ffprobe reports duration as N/A. I fall back to decoding and counting frames before deciding whether the 10-second cap even applies.
And 10-bit HEVC deserves a second mention. It accounts for most of the "why doesn't my avatar animate" reports I get, which is why format=yuv420p sits permanently in the filter chain.
Next on the list: 4K sources are slow on the current box, so a queue with a progress message is probably coming. I also want a loop point picker so avatars don't visibly jump when they repeat.
Disclosure: built by me, @liveavabot.
If you've reverse engineered other undocumented Telegram media specs (voice message waveforms, anyone?), tell me how you did it, I collect these.
Top comments (0)