The Silent Failure
You record a short clip on your iPhone. You open Telegram Desktop, go to settings, click "Set video". The upload starts, spins for a second, and nothing happens. No error. No toast. The old picture avatar stays exactly where it was.
I hit this maybe twenty times before I sat down with tcpdump and figured out what was happening. Telegram's client silently drops the upload if the file does not match a very specific set of constraints. HEVC (the default codec on modern iPhones) is one of them. No warning, no fallback, just a dead upload.
What Telegram Actually Wants
After digging through the Bot API docs, MTProto source, and a few third-party clients, the requirements for a video avatar are:
- Container: MP4 with faststart (moov atom at the front).
- Video codec: H.264 baseline or main profile, yuv420p pixel format.
- Resolution: exactly 800x800, square.
- Duration: up to 10 seconds, ideally under 9.
- File size: under 2 MB.
- Audio: must be removed entirely. Not muted, removed.
- Frame rate: 30 fps caps out cleanly.
Any of those wrong and the client refuses the upload without telling you why. HEVC is by far the most common cause because Apple made it the default in iOS 11 and never looked back.
The FFmpeg Recipe
The tricky part is not the codec conversion, that is one flag. The tricky part is going from a landscape or portrait clip to a centered 800x800 square without ugly black bars. I use cropdetect on the first pass to find the actual content bounds, then crop and scale on the second.
# Pass 1: find the crop box
ffmpeg -ss 1 -t 3 -i input.mov -vf cropdetect=24:16:0 -f null - 2>&1 \
| grep -oP 'crop=[^ ]+' | tail -1
# outputs something like: crop=1080:1080:420:0
# Pass 2: crop, scale, re-encode, strip audio
ffmpeg -i input.mov \
-vf "crop=1080:1080:420:0,scale=800:800:flags=lanczos,format=yuv420p" \
-c:v libx264 -profile:v main -preset veryfast -crf 26 \
-pix_fmt yuv420p \
-t 9.5 -r 30 \
-movflags +faststart \
-an \
output.mp4
Key flags worth calling out:
-
cropuses the values from cropdetect. I default to a centered square crop when detection fails. -
format=yuv420ptogether with-pix_fmt yuv420pkills the "unsupported pixel format" issue you get with HEVC 10-bit source. -
-androps audio. Telegram rejects the upload if any audio track exists. -
-movflags +faststartmoves the moov atom so the client can start validating before the whole file arrives. -
-crf 26is a good starting quality. If the output pushes over 2 MB, bump to 28 and re-encode.
That single ffmpeg invocation handles about 95 percent of source files I have thrown at it. The remaining 5 percent are edge cases (extreme aspect ratios, corrupt containers, HDR metadata) that need per-case handling.
The Aiogram Handler
I wanted the whole thing behind a Telegram bot so you never have to touch ffmpeg yourself. Aiogram 3 with async subprocess kept this compact:
import asyncio
import tempfile
from pathlib import Path
from aiogram import Router, F
from aiogram.types import Message, FSInputFile
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 > 20 * 1024 * 1024:
await msg.answer("File is over 20 MB, too big to fetch.")
return
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "in.bin"
dst = Path(tmp) / "out.mp4"
await msg.bot.download(file, destination=src)
rc = await run_ffmpeg(src, dst)
if rc != 0 or not dst.exists():
await msg.answer("Conversion failed. Try a shorter clip.")
return
if dst.stat().st_size > 2 * 1024 * 1024:
await msg.answer("Result is over 2 MB. Try a shorter clip.")
return
await msg.answer_video(
FSInputFile(dst),
caption="Set this as your video avatar in Telegram settings.",
)
async def run_ffmpeg(src: Path, dst: Path) -> int:
cmd = [
"ffmpeg", "-y", "-i", str(src),
"-vf", "crop=in_h:in_h,scale=800:800:flags=lanczos,format=yuv420p",
"-c:v", "libx264", "-profile:v", "main", "-preset", "veryfast",
"-crf", "26", "-pix_fmt", "yuv420p",
"-t", "9.5", "-r", "30",
"-movflags", "+faststart", "-an",
str(dst),
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
return await proc.wait()
Two things I got wrong on my first pass:
- I forgot the 20 MB Bot API download cap. If you want bigger files you need a local Bot API server. For a "convert my selfie clip" use case, 20 MB is fine.
- I originally sent the result as a document. Telegram treats documents differently in the video avatar picker. Sending as
answer_videoputs it in the video gallery where the avatar picker actually looks.
Packaged as a Bot
I bundled this into a bot called @liveavabot. Send it any video or GIF, get back a valid Telegram video avatar. It runs the same ffmpeg recipe above, plus a few extras: cropdetect fallback for weird aspect ratios, auto-CRF adjustment if the file lands over 2 MB, and animated GIF support (which needs -fflags +genpts to fix timestamps).
If you want to try it: https://t.me/LiveAvaBot?start=devto_article_20260726
Lessons and Edge Cases
Stuff I learned along the way that is not in any doc:
- HEIC still images uploaded as "video" by some Android clients arrive as MP4 with a single frame. Detect and reject early with a duration check.
-
iPhone slow-motion clips have variable frame rates that some ffmpeg builds mishandle. Force
-vsync cfr -r 30to normalize. -
HDR HEVC from newer iPhones has BT.2020 color primaries. Add
-vf "zscale=t=linear:npl=100,tonemap=hable,zscale=p=bt709:t=bt709:m=bt709:r=tv,format=yuv420p"before your crop chain, or accept washed-out output. - The 2 MB cap is enforced client-side. A 2.1 MB file uploads successfully via the Bot API and then vanishes when the client tries to set it as an avatar. Always re-encode with a lower CRF if you land close to the limit.
- faststart matters more than you think. Without it, the client waits for the whole file before it can validate dimensions, and the upload times out on slower connections.
Next on my list: batch mode (send five clips, get five avatars back), and a browser extension that intercepts drag-drop uploads on Telegram Web and runs the same conversion locally with ffmpeg.wasm. If you have opinions on either, ping the bot.
Built by me, @liveavabot.
Top comments (0)