The bug that made me write this
I tried setting a short clip from my iPhone as my Telegram profile video. Telegram accepted the upload, showed a spinner, then quietly reverted to my old static picture. No error message. No hint. Just the old avatar again.
The clip was H.265 (HEVC), which iPhones record by default. Telegram's video avatar endpoint accepts H.264 only. The upload technically worked, the server just refused to use the file, and there was no user-visible reason.
I wrote a bot that takes any video or GIF and produces a file Telegram will actually accept as a video avatar. This post is the ffmpeg pipeline and the aiogram 3 handler that runs it.
What Telegram's video avatar format actually is
Telegram doesn't document this well, but reverse engineering it gives you:
- Container: MP4
- Video codec: H.264, yuv420p, high profile is fine
- Audio: must be removed entirely
- Resolution: 800x800 square, exact
- Duration: 10 seconds max (I clamp to 9.9 to be safe)
- File size: 2 MB max
- Frame rate: 30 fps works, higher gets rejected sometimes
- moov atom at the front (faststart)
If any of those are wrong, the upload either silently fails or the avatar shows as a still frame from second zero.
The ffmpeg pipeline
The tricky part is turning a rectangular phone video into an 800x800 square without ugly letterboxing. I run cropdetect on a probe pass, then crop and scale in the encode pass.
ffmpeg -ss 00:00:02 -i input.mov -vframes 10 \
-vf cropdetect=24:16:0 -f null - 2>&1 | \
grep -o 'crop=[0-9:]*' | tail -1
# example output: crop=1080:1080:0:420
Then the actual encode with the detected crop values:
ffmpeg -y -i input.mov \
-t 9.9 \
-an \
-vf "crop=1080:1080:0:420,scale=800:800:flags=lanczos,format=yuv420p,fps=30" \
-c:v libx264 -preset veryfast -crf 23 \
-profile:v high -level 4.0 \
-movflags +faststart \
-pix_fmt yuv420p \
output.mp4
Key flags:
-
-androps audio. Telegram rejects avatars with any audio track. -
-vf format=yuv420pplus the explicit-pix_fmt yuv420pare belt and suspenders. Some sources are yuv444p and Telegram won't touch that. -
-movflags +faststartmoves the moov atom to the start of the file so the server can begin streaming without downloading the whole thing. -
-t 9.9clamps duration. I found 10.0 flat sometimes gets rejected by rounding. -
-crf 23keeps files under 2 MB for most 9 second clips. If the output goes over, I re-encode with-crf 28and warn the user about quality.
For GIFs I skip cropdetect and center-crop to a square, because GIFs are usually already framed for the subject.
Aiogram 3 handler
I run aiogram 3 with the Router pattern. The handler grabs the file, hands it to an async ffmpeg subprocess, then sends back the result.
import asyncio
import subprocess
import tempfile
from pathlib import Path
from aiogram import Router, F
from aiogram.types import Message, FSInputFile
router = Router()
async def convert(src: Path, dst: Path) -> None:
cmd = [
"ffmpeg", "-y", "-i", str(src),
"-t", "9.9",
"-an",
"-vf", "cropdetect=24:16:0,scale=800:800:flags=lanczos,format=yuv420p,fps=30",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
"-profile:v", "high", "-level", "4.0",
"-movflags", "+faststart",
"-pix_fmt", "yuv420p",
str(dst),
]
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(stderr.decode()[-500:])
@router.message(F.video | F.animation | F.document)
async def handle_video(message: Message) -> None:
file = message.video or message.animation or message.document
if not file:
return
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "in"
dst = Path(tmp) / "out.mp4"
tg_file = await message.bot.get_file(file.file_id)
await message.bot.download_file(tg_file.file_path, destination=src)
try:
await convert(src, dst)
except RuntimeError as e:
await message.answer(f"ffmpeg failed: {e}")
return
size_mb = dst.stat().st_size / 1024 / 1024
if size_mb > 2.0:
await message.answer(
f"Output is {size_mb:.1f} MB, over the 2 MB Telegram limit. "
"Try a shorter clip."
)
return
await message.answer_video(FSInputFile(dst))
That's the core. The production version has more error handling, rate limiting, and metrics, but the pipeline is 40 lines.
Packaging this as @liveavabot
I put the pipeline behind a bot. Send it any video or GIF, get back an MP4 you can set as your Telegram video avatar. It handles iPhone HEVC, WhatsApp exports, Instagram reels saves, TikTok downloads, and other common sources.
Stack:
- aiogram 3 on Python 3.11
- ffmpeg 6.x from apt
- systemd unit, no docker
- SQLite for user counts and rate limiting
- Hetzner CX22, 2 GB RAM is enough for concurrent ffmpeg jobs
Cost so far: 4 EUR per month for the VPS. ffmpeg does the actual work, I just wrote the wrapper and the Telegram plumbing.
Try it: https://t.me/LiveAvaBot?start=devto_article_20260730
Edge cases I hit
- iPhone Live Photos: the .mov wrapper contains a still HEIC plus a 3 second video. ffmpeg picks the right stream automatically, but the aspect is often 4:3 not 16:9, so cropdetect matters.
- Animated WebPs sent as stickers: Telegram sends these as documents with mime
image/webp. ffmpeg handles them, but you need-r 30on input or the output fps is broken. - Videos with rotation metadata: shoot vertical on iPhone, ffmpeg auto-rotates on read. Works fine.
- Very short GIFs (under 1 second): I loop them to 3 seconds so the avatar isn't a blink. Use
-stream_loop 3 -t 3before-i. - Files over 20 MB: Telegram's bot API caps downloads at 20 MB. I catch that and tell the user to trim before sending.
What's next
Small backlog:
- Auto face-detect crop instead of center-crop (dlib or mediapipe)
- Batch mode for multiple videos in one message
- Preset selection (portrait, landscape, action)
The core problem, HEVC to H.264 with the right specs, is solved. The rest is polish.
Built by me, @liveavabot on Telegram.
Top comments (0)