The Bug With No Error Message
I tried to set a short clip as my Telegram profile video. Recorded it on my iPhone, opened Telegram, picked the file, hit save. Telegram took it, showed a spinner for a second, then kept my old photo. No error. No toast. Nothing happened.
I tried three more times. Same silent nothing. The file played fine in every other app, so it wasn't corrupt.
The problem is the codec. Since iOS 11, iPhones record video in HEVC (H.265) by default. It's great for storage. It is also not what Telegram wants for a profile video avatar. Telegram's avatar slot expects H.264, and when it gets something it can't decode for that slot, it doesn't tell you. It just drops the upload.
So I wrote a bot that re-encodes the file before it ever reaches that slot. This post is how it works.
What Telegram Actually Wants From a Video Avatar
There is no public error message, so I pieced the spec together by testing files until they stuck. A video avatar that Telegram accepts looks like this:
- Container: MP4.
- Video codec: H.264.
libx264is fine. - Pixel format:
yuv420p. HEVC files are oftenyuv420p10le(10-bit), which the H.264 avatar path rejects. - Shape: square. I use 800x800. Non-square files get center-cropped by Telegram in ways you don't control.
- Duration: 10 seconds or less.
- Size: 2 MB or less.
- Audio: none. The avatar has no sound, and an audio track sometimes pushes you over the size cap for nothing.
-
faststart: themoovatom belongs at the front of the file so playback can begin before the whole thing downloads.
Miss any one of these and you get the silent drop. The 10-bit pixel format caught me out the longest, because the file looked completely valid.
Fixing It With ffmpeg
ffmpeg does all the real work here. I just call it correctly.
The first step is finding a square crop. iPhone video is 16:9 or 4:3, never square, so I need a 1:1 region. cropdetect scans a few seconds and reports a crop rectangle:
ffmpeg -i input.mov -t 3 -vf cropdetect=24:16:0 -f null - 2>&1 \
| awk '/crop=/ { c=$NF } END { print c }'
That prints something like crop=1080:1080:420:0. It centers on the actual content instead of blindly cropping from a corner.
Then the real encode:
ffmpeg -i input.mov \
-t 10 \
-vf "crop=1080:1080:420:0,scale=800:800:flags=lanczos,format=yuv420p" \
-c:v libx264 -profile:v high -preset slow -crf 28 \
-an \
-movflags +faststart \
-y output.mp4
Going through the flags that matter:
-
-t 10hard-caps the clip at 10 seconds. - The
-vfchain crops to square, scales to 800x800 with the Lanczos filter (sharper than the default bilinear), and forcesformat=yuv420pso the 10-bit problem disappears. -
-c:v libx264is the codec swap. HEVC goes in, H.264 comes out. -
-crf 28trades a little quality for size. At 800x800 it looks fine and helps stay under 2 MB. -
-androps the audio track completely. -
-movflags +faststartmoves themoovatom to the front.
One encode, every requirement satisfied.
Wiring It Into an aiogram 3 Bot
I wrapped this in a Telegram bot with aiogram 3 so I never have to think about it again. The handler accepts video, animation (GIFs arrive as animation), and raw document uploads:
import asyncio
from pathlib import Path
from aiogram import Bot, Dispatcher, F
from aiogram.types import Message, FSInputFile
dp = Dispatcher()
@dp.message(F.video | F.animation | F.document)
async def handle_video(message: Message, bot: Bot) -> None:
media = message.video or message.animation or message.document
if media is None:
return
src = Path(f"/tmp/{media.file_unique_id}.src")
dst = Path(f"/tmp/{media.file_unique_id}.mp4")
await bot.download(media, destination=src)
await message.answer("Re-encoding to Telegram avatar format...")
ok = await convert_to_avatar(src, dst)
if not ok:
await message.answer("Couldn't convert that one. 4K and HDR aren't handled yet.")
else:
await message.answer_video(FSInputFile(dst))
src.unlink(missing_ok=True)
dst.unlink(missing_ok=True)
The conversion runs ffmpeg as a subprocess and checks the output before trusting it:
async def convert_to_avatar(src: Path, dst: Path) -> bool:
vf = "crop=in_h:in_h,scale=800:800:flags=lanczos,format=yuv420p"
cmd = [
"ffmpeg", "-i", str(src), "-t", "10",
"-vf", vf,
"-c:v", "libx264", "-preset", "slow", "-crf", "28",
"-an", "-movflags", "+faststart", "-y", str(dst),
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
if proc.returncode != 0 or not dst.exists():
return False
return dst.stat().st_size <= 2 * 1024 * 1024
For the bot I use crop=in_h:in_h, a center square based on input height. It skips the separate cropdetect pass and keeps latency low. cropdetect is the better choice when framing matters more than speed.
Packaging It As a Bot Anyone Can Use
Once it worked for me, sharing it was almost free. Send a video to the bot, get back a file that drops straight into the Telegram avatar slot. No app, no settings, no codec knowledge required. It lives here: https://t.me/LiveAvaBot?start=devto_article_20260520
The whole thing is one ffmpeg call behind a message handler. ffmpeg is doing the heavy lifting. I just wrote the wrapper that knows the exact spec Telegram never documents.
Edge Cases And What's Next
A few things I learned shipping it:
- Size overshoot. A busy 10-second clip can still land above 2 MB at crf 28. The fix is a second pass with a higher crf, bumping it 4 or 5 at a time until the file fits.
- Rotation metadata. iPhone videos carry a rotation flag instead of rotating pixels. ffmpeg honors it by default now, but older builds can hand you a sideways avatar.
- GIFs. They arrive as
animation, notvideo, which is why the handler filter includes both. After encoding they behave like any other clip. - 4K and HDR. Not handled yet. HDR needs a tone-mapping step before the H.264 conversion or the colors wash out. That is next on the list.
Telegram silently rejecting valid-looking files is annoying. The fix turned out small once you know what the avatar slot wants: one ffmpeg call behind a message handler.
Built by me, @liveavabot.
Top comments (0)