The problem: iPhone videos silently fail as Telegram avatars
Try setting a video as your Telegram profile picture from an iPhone. It just... doesn't work. No error, no explanation. The upload dialog closes, your avatar stays the same.
I hit this myself last month. Recorded a short clip on my iPhone, tried to set it as my TG avatar, nothing happened. Turns out iOS records in HEVC (H.265) by default, and Telegram's video avatar spec only accepts H.264. There's no warning. The client just refuses silently.
So I built a bot that fixes this. Send it any video or GIF, get back a file that Telegram will actually accept.
What Telegram wants for a video avatar
The spec is undocumented in the public Bot API docs. From reverse-engineering the client behavior:
- Codec: H.264 (yuv420p pixel format)
- Container: MP4 with faststart flag
- Resolution: 800x800, square, center-cropped
- Duration: max 10 seconds
- File size: max 2 MB
- Audio: must be stripped
Miss any of these and the client rejects the upload without telling you why. The 2 MB cap is the annoyingly tight one, especially at 10 seconds and 800x800 resolution.
The ffmpeg pipeline
Two passes. First a cropdetect pass to find the safe square region. Then the actual encode.
ffmpeg -ss 0 -t 10 -i input.mov \
-vf "cropdetect=24:16:0" -f null - 2>&1 \
| grep -oE "crop=[0-9:]+" | tail -1
That gives you something like crop=1080:1080:420:0. Feed it into the encode:
ffmpeg -ss 0 -t 10 -i input.mov \
-vf "crop=1080:1080:420:0,scale=800:800:flags=lanczos,format=yuv420p" \
-c:v libx264 -preset slow -crf 26 \
-pix_fmt yuv420p \
-movflags +faststart \
-an \
-y output.mp4
Notes:
-
-anstrips audio. Telegram wants silent video for avatars. -
-movflags +faststartmoves the moov atom to the front so TG can start reading before download completes. -
-crf 26is my sweet spot at 800x800 to stay under 2 MB. Adjust down for higher quality if the clip is short enough. -
format=yuv420pinside the filter chain is belt-and-braces. Some players still choke on yuv444.
If the encode comes out over 2 MB, I retry with -crf 30. If still too big, I trim duration. That's the whole size-target logic. No fancy VBR two-pass.
Wiring it into aiogram 3
The bot is Python with aiogram 3. Handler for incoming video, document, or animation:
from aiogram import Router, F
from aiogram.types import Message, FSInputFile
from pathlib import Path
import asyncio, tempfile
router = Router()
@router.message(F.video | F.document | F.animation)
async def handle_video(msg: Message):
file_obj = msg.video or msg.animation or msg.document
if not file_obj:
return
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "in.bin"
dst = Path(tmp) / "out.mp4"
await msg.bot.download(file_obj, destination=src)
await msg.reply("Converting, hang on...")
rc = await convert(src, dst)
if rc != 0 or not dst.exists():
await msg.reply("ffmpeg choked on that file, sorry")
return
await msg.reply_video(
FSInputFile(dst),
caption="Ready. Long-press your avatar in Settings to set it."
)
async def convert(src: Path, dst: Path) -> int:
crop = await detect_crop(src)
cmd = [
"ffmpeg", "-y", "-ss", "0", "-t", "10", "-i", str(src),
"-vf", f"{crop},scale=800:800:flags=lanczos,format=yuv420p",
"-c:v", "libx264", "-preset", "slow", "-crf", "26",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", "-an",
str(dst),
]
proc = await asyncio.create_subprocess_exec(*cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL)
return await proc.wait()
detect_crop runs the cropdetect pass and greps the last crop= line. I skipped it above for brevity. It's a 15-line asyncio subprocess plus regex.
Edge cases that bit me
HEVC with rotation metadata
iPhone portrait videos have a 90-degree rotation flag in the container. If you crop before ffmpeg applies the rotation, you crop the wrong region. Fix: don't set -noautorotate. Let ffmpeg apply rotation automatically before the filter chain runs.
Animated GIFs with transparent frames
Converting straight to yuv420p gives random flashes on transparent pixels. Add a palette pass for GIF sources, or pre-composite over black with pad=color=black.
Very short clips
Telegram accepts clips under 1 second but the avatar loop looks janky. I now pad short clips with tpad=stop_mode=clone:stop_duration=1 to hit at least 2 seconds.
Files bigger than 20 MB
Telegram Bot API can't download files bigger than 20 MB directly. For those the bot replies asking to trim the clip client-side. Not solving that one right now.
Packaging as a public bot
It's live at t.me/LiveAvaBot. Send any video or GIF, get back a converted MP4 ready to set as your TG video avatar. Free for the first few conversions per user, then Telegram Stars after that (mostly to cover the VPS ffmpeg CPU).
The whole bot is about 400 lines of Python. ffmpeg does the heavy lifting, I just wrote the pipeline and the aiogram wiring.
What I'd do differently
Two-pass encoding for size targeting would be nicer than my "try crf 26, retry crf 30" heuristic. ffmpeg's -fs flag exists but often truncates output, so I never trusted it. Better path is probably -b:v with a proper two-pass log file. For 10-second clips the current approach is fine.
Also, no 4K HEVC support yet. Decoding 4K HEVC on a shared vCPU is slow enough that users time out before it finishes. If demand grows I'll move ffmpeg to a beefier worker.
Built by me. Bot: @liveavabot.
Top comments (0)