The problem nobody explains
I recorded a short clip on my iPhone, opened Telegram, tapped my avatar, picked "Set video". Nothing happened. No error. No toast. The old avatar stayed. I tried again with a screen recording. Same silence.
Turns out Telegram has a very narrow spec for video avatars, and the iPhone camera app violates almost every part of it by default. iPhone shoots HEVC (H.265) in .mov. Telegram wants H.264 in .mp4. iPhone shoots portrait 1080x1920. Telegram wants 800x800 square. iPhone records with audio. Telegram wants no audio track at all. And there's a 2 MB hard cap on the file.
If any of these fail, Telegram just refuses without telling you why. I lost an hour before I read the raw Bot API docs and found the requirements buried in the setUserProfilePhoto notes.
I built a bot to fix this once and never think about it again. This post walks through what the spec actually is, how ffmpeg solves it, and the minimal aiogram 3 handler that ties it together.
The Telegram video avatar spec
Straight from testing and the Bot API source:
- Container: mp4 with faststart (moov atom at the front)
- Video codec: H.264, profile Main or Baseline, pixel format yuv420p
- Resolution: exactly 800x800, square
- Duration: 3 to 10 seconds
- File size: 2 MB or less
- Audio: no audio stream. Not muted. Removed.
- Frame rate: 30 fps works, 60 is risky at this size cap
The two silent killers are HEVC and audio. If your file has an audio track, even a silent one, Telegram rejects the upload without any status message.
The ffmpeg pipeline
Two passes. First a cropdetect to figure out what's actually inside the frame (portrait iPhone videos leave huge black bars if you center-crop naively). Then the real encode.
ffmpeg -hide_banner -i input.mov \
-vf cropdetect=24:16:0 -f null - 2>&1 | \
grep -oP 'crop=\S+' | tail -n 1
That prints something like crop=1080:1080:0:420. Feed it back into the real command:
ffmpeg -y -i input.mov \
-t 10 \
-vf "crop=1080:1080:0:420,scale=800:800:flags=lanczos,format=yuv420p,setsar=1" \
-c:v libx264 -profile:v main -level 4.0 \
-preset slow -crf 26 \
-movflags +faststart \
-an \
output.mp4
Key flags:
-
-t 10clips duration to 10s. -
crop,scalein the same filter chain: crop to square first, scale to 800. -
format=yuv420pforces the pixel format Telegram wants. -
-anstrips audio. -
-movflags +faststartputs the moov atom up front so Telegram can start reading before the download completes. -
-crf 26is the sweet spot for staying under 2 MB on a 10s clip. If the input is a busy scene, drop to 28.
I also run ffprobe after encoding to double-check the file is actually under 2 MB. If it's over, I re-encode with -crf 30 and warn the user that quality took a hit.
Minimal aiogram 3 handler
The bot accepts video, document (with video mime), or animation (mp4 GIF). Here's the routing:
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.animation | F.document)
async def handle_media(msg: Message):
file = msg.video or msg.animation or msg.document
if file.file_size and file.file_size > 20 * 1024 * 1024:
await msg.reply("File over 20 MB, please trim first.")
return
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "in.bin"
dst = Path(tmp) / "out.mp4"
await msg.bot.download(file, destination=src)
ok = await convert_to_avatar(src, dst)
if not ok:
await msg.reply("Conversion failed. Try a shorter or simpler clip.")
return
await msg.reply_video(
video=FSInputFile(dst),
caption="Ready. In Telegram, tap your avatar then Set Video.",
)
async def convert_to_avatar(src: Path, dst: Path) -> bool:
crop = await detect_crop(src)
vf = f"{crop},scale=800:800:flags=lanczos,format=yuv420p,setsar=1"
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-y", "-i", str(src),
"-t", "10",
"-vf", vf,
"-c:v", "libx264", "-profile:v", "main", "-level", "4.0",
"-preset", "slow", "-crf", "26",
"-movflags", "+faststart",
"-an",
str(dst),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
return (
proc.returncode == 0
and dst.exists()
and dst.stat().st_size < 2 * 1024 * 1024
)
detect_crop runs the cropdetect pass and returns the last crop= value. If none is found (already square input), it falls back to crop=in_h:in_h:(in_w-in_h)/2:0.
How I packaged this as @liveavabot
I put the whole pipeline behind a Telegram bot so people don't have to install ffmpeg or figure out flags. Send a video or GIF, get back a file ready to set as your avatar. Handles iPhone HEVC, portrait clips, over-long clips, oversized files. Link: https://t.me/LiveAvaBot?start=devto_article_20260819.
The bot runs in Python with aiogram 3 on a small VPS, using ffmpeg 6 from the system package. No queue for now, just async subprocess with a semaphore capping parallel encodes at 3 so a burst doesn't OOM the box.
Edge cases and lessons
A few things I got wrong the first time:
- I forgot
format=yuv420pat first. Some inputs (especially from cameras) come as yuv422p or yuv444p. H.264 Main profile at those pixel formats encodes fine but Telegram won't accept it. -
-preset slowmatters.-preset veryfastat the same CRF produces files roughly 30% larger, which pushes 10s clips over 2 MB. - Animation stickers exported as mp4 sometimes have zero-duration streams that break
-t 10. I now clamp with a duration computed from ffprobe first. - If the input has a weird SAR (sample aspect ratio), the scale to 800x800 stretches.
setsar=1before scale fixes it.
Still on the todo list: 4K HDR clips need tone-mapping before H.264 encode or the colors look washed. I skip those for now and reply asking for a lower-res source.
That's the whole thing. ffmpeg is doing the heavy lifting, the bot is a thin wrapper that hides the flags. If you're building anything similar, the cropdetect + explicit pixel format + -an combo is the part I'd focus on first.
Built by me, @liveavabot.
Top comments (0)