The problem: your iPhone video will not upload as a Telegram avatar
I recorded a 4-second clip on my iPhone last month, tried to set it as my Telegram profile video, and got nothing. No error, no toast, just silence. The upload dialog closed and my avatar stayed the same static image.
Turns out Telegram's video avatar endpoint quietly rejects anything it cannot decode. iPhones record in HEVC (H.265) by default since iOS 11, and Telegram's avatar pipeline only accepts H.264. There is no error message, no fallback, no "please convert this first". It just fails.
I built a small bot to fix this exact problem. Send it any video or GIF, get back a file that Telegram will actually accept as your profile animation. This post is the build log.
What Telegram's video avatar spec actually wants
The requirements are scattered across the Bot API reference and a few forum threads. Here is the full list I had to satisfy, discovered through trial and error:
- Codec: H.264 (libx264). HEVC, VP9, AV1 all rejected.
- Container: MP4 with the faststart flag.
- Pixel format: yuv420p. yuv420p10le or yuv444p will not work.
- Resolution: exactly 800x800, square crop.
- Duration: 10 seconds or less.
- Size: under 2 MB. This is the killer constraint.
- Audio: must be stripped. Even a silent audio track can cause issues.
- Frame rate: I clamp to 30 fps to save bytes.
The 2 MB cap is the hardest part. A 10-second 800x800 H.264 clip at reasonable quality is closer to 3 or 4 MB with default settings. You need aggressive bitrate control.
The ffmpeg pipeline
Here is the actual command the bot runs. Breakdown below:
ffmpeg -y -i input.mov \
-vf "scale=800:800:force_original_aspect_ratio=increase,crop=800:800" \
-c:v libx264 -profile:v baseline -level 3.1 \
-pix_fmt yuv420p \
-r 30 \
-t 10 \
-b:v 1200k -maxrate 1400k -bufsize 2000k \
-an \
-movflags +faststart \
output.mp4
Walking through it:
scale=800:800:force_original_aspect_ratio=increase,crop=800:800 scales the shorter dimension to 800 then center-crops to a square. This preserves aspect ratio without stretching faces. For sources with black letterbox bars, I run cropdetect=24:16:0 as a separate probe pass first to get the real crop values, then feed those into the encode.
-c:v libx264 -profile:v baseline -level 3.1 picks H.264 with the most compatible profile. Baseline profile means no B-frames, which decodes faster on old Android clients.
-pix_fmt yuv420p is non-negotiable. iPhone HEVC often uses yuv420p10le (10-bit), and Telegram silently rejects the output even after codec conversion if you leave the pixel format alone. Force 8-bit.
-b:v 1200k -maxrate 1400k -bufsize 2000k is the bitrate ceiling that keeps a 10-second clip under 2 MB. I landed on these values after about 40 test uploads.
-an strips audio. -movflags +faststart moves the moov atom to the front so Telegram can start decoding before the file finishes downloading.
The aiogram 3 handler
I use aiogram 3 (async, type-hinted) for the Telegram side. The handler receives a video or animation, downloads it, runs ffmpeg, and sends the result back. Here is the core:
from aiogram import Router, F
from aiogram.types import Message, FSInputFile
from pathlib import Path
import asyncio, tempfile, uuid
router = Router()
@router.message(F.video | F.animation | F.video_note | F.document)
async def convert_to_avatar(message: Message):
if message.video:
file_id = message.video.file_id
elif message.animation:
file_id = message.animation.file_id
elif message.video_note:
file_id = message.video_note.file_id
else:
file_id = message.document.file_id
tmp = Path(tempfile.mkdtemp())
src = tmp / f"in_{uuid.uuid4().hex}"
dst = tmp / f"out_{uuid.uuid4().hex}.mp4"
file = await message.bot.get_file(file_id)
await message.bot.download_file(file.file_path, destination=str(src))
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-y", "-i", str(src),
"-vf", "scale=800:800:force_original_aspect_ratio=increase,crop=800:800",
"-c:v", "libx264", "-profile:v", "baseline", "-level", "3.1",
"-pix_fmt", "yuv420p", "-r", "30", "-t", "10",
"-b:v", "1200k", "-maxrate", "1400k", "-bufsize", "2000k",
"-an", "-movflags", "+faststart",
str(dst),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
await proc.communicate()
if proc.returncode != 0:
await message.answer("ffmpeg failed. Try a shorter or lower-res clip.")
return
size_mb = dst.stat().st_size / 1024 / 1024
if size_mb > 2:
await message.answer(f"Output is {size_mb:.1f} MB, over the 2 MB limit. Trim first.")
return
await message.answer_video(
FSInputFile(dst),
caption="Set this as your profile video: open Settings, tap the avatar, pick Video.",
)
Notes on this:
F.video | F.animation | F.video_note | F.document catches every file type users actually send. Documents matter because Telegram Desktop uploads MP4s as documents by default, not videos.
I use asyncio.create_subprocess_exec instead of subprocess.run because the bot handles concurrent requests. Blocking on ffmpeg would freeze every other user's conversion.
The 2 MB post-check is a safety net. My bitrate settings usually keep the file comfortably under, but a very high-motion 10-second clip can spike past. Rather than silently produce a file Telegram will reject, I return an explicit error and let the user trim.
Packaging it as @liveavabot
The bot is live at @LiveAvaBot with 301 users so far. Behind the scenes it runs on a small VPS: one Python process, ffmpeg from apt, a SQLite database for user state and rate limits. Nothing fancy. The whole thing is under 2000 lines of Python.
I added a few conveniences on top of the ffmpeg core:
-
HEVC pre-detection:
ffprobereads the codec first. If the input is already H.264 under 2 MB and 800x800, I skip re-encoding and just send it back. - Rate limit: 5 conversions per user per hour. Prevents someone from hammering the queue.
-
GIF support:
.gifinputs get treated as animations, output as MP4. Telegram avatars must be MP4, animated GIFs will not work as avatars directly.
Edge cases and what is next
Things that still trip the bot up:
- 4K vertical iPhone clips: cropping to 800x800 loses too much face. I want to add a face-detection pass to center on the subject instead of the geometric center.
-
Weird pixel formats: some Android phones export in exotic YUV variants that ffmpeg auto-converts wrong. Forcing
-pix_fmt yuv420pearly in the filter chain catches most of these. -
Silent audio tracks:
-anstrips them, but I have seen odd cases where the video still fails until I re-mux with an explicit-map 0:v:0. Not fully debugged.
Next up: a /crop_square command that lets the user pick which part of a 16:9 video to keep, since automatic center-crop is not always right for portrait clips.
Built by me, @liveavabot on Telegram.
Top comments (0)