Telegram lets you set a short looping video as your profile avatar. It's a neat feature. But if you try uploading a video from a recent iPhone, nothing happens. No error, no feedback, just silence.
I ran into this while building a helper bot. Spent two hours debugging before realizing the problem: iPhone records in HEVC (H.265), and Telegram's video avatar pipeline quietly rejects anything that isn't H.264. No warning, no rejection message in the UI.
What Telegram Actually Enforces
After digging through the API docs and doing manual tests, the constraints are:
- Codec: H.264, yuv420p pixel format
- Shape: square, 800x800 pixels
- Duration: 10 seconds maximum
- File size: 2MB maximum
- Audio track: must be absent
None of this is documented clearly in one place. The Telegram mobile clients handle it automatically when you record in-app. Everything else is on you.
The HEVC problem hits iPhone users hardest because iOS 11+ defaults to HEVC for main camera recordings. Most Telegram users on iPhone have never successfully set a video avatar from their camera roll, because the app silently drops the clip.
The ffmpeg Pipeline
The good news: ffmpeg handles all of it. The tricky parts are cropping to square without knowing the source aspect ratio upfront, re-encoding HEVC to H.264 with the correct pixel format, and hitting the 2MB cap without a slow two-pass encode.
Here's the command I use:
ffmpeg -i input.mov \
-vf "cropdetect=24:2:0,crop=ih:ih,scale=800:800:flags=lanczos,format=yuv420p" \
-c:v libx264 -preset fast -crf 26 \
-t 10 -an -movflags +faststart \
output.mp4
Breaking it down:
cropdetect=24:2:0 scans the first frames for black borders and prints crop parameters to stderr. You parse that output and feed it back as a crop filter. For a quick shortcut, crop=ih:ih does a center square crop using the shorter dimension (height for portrait video, width for landscape).
scale=800:800:flags=lanczos resizes to the required 800x800. Lanczos produces cleaner edges than bilinear when downscaling.
format=yuv420p must be explicit. libx264 sometimes outputs yuv444p for certain source files, and Telegram rejects that without any explanation.
-crf 26 hits under 2MB for most clips at 800x800 and up to 10s. Dense motion content like screen recordings or gaming clips can still bust the cap. For those I retry with -b:v 1200k.
-an strips the audio track. Required.
-movflags +faststart relocates the moov atom to the front of the file. Without it, some videos upload successfully but never actually set as the avatar.
Real Numbers
Raw input from iPhone 14: ProRes.mov, 1080x1920, HEVC, 4.1s, 47MB. Output after this pipeline: output.mp4, 800x800, H.264 yuv420p, 4.1s, 0.9MB. Telegram accepts it immediately.
The aiogram 3 Handler
I wrapped the pipeline in a Telegram bot using aiogram 3. The handler receives a video, document, or animation, downloads it, runs ffmpeg in a subprocess, and sends the result back.
from aiogram import Router, F
from aiogram.types import Message, BufferedInputFile
import asyncio, tempfile, os
router = Router()
async def run_ffmpeg(src: str, dst: str) -> bool:
proc = await asyncio.create_subprocess_exec(
'ffmpeg', '-y', '-i', src,
'-vf', 'crop=ih:ih,scale=800:800:flags=lanczos,format=yuv420p',
'-c:v', 'libx264', '-preset', 'fast', '-crf', '26',
'-t', '10', '-an', '-movflags', '+faststart', dst,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
await proc.communicate()
return proc.returncode == 0 and os.path.getsize(dst) <= 2 * 1024 * 1024
@router.message(F.video | F.document | F.animation)
async def handle_video(message: Message):
file = message.video or message.document or message.animation
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, 'input')
dst = os.path.join(tmp, 'output.mp4')
await message.bot.download(file.file_id, destination=src)
if not await run_ffmpeg(src, dst):
await message.reply('Conversion failed. Try a shorter clip.')
return
with open(dst, 'rb') as f:
await message.reply_video(
BufferedInputFile(f.read(), filename='avatar.mp4'),
caption='Set this as your Telegram video avatar.',
)
The handler registers F.video | F.document | F.animation because Telegram delivers GIFs as animation messages (MP4 internally, not a real animation type). Skip that check and GIFs never get processed.
run_ffmpeg returns False on both non-zero exit code and output files over 2MB. The caller gets a clean boolean and sends the user a plain error message.
What I Shipped as @liveavabot
The bot is live at https://t.me/LiveAvaBot?start=devto_article_20261020. Send it a video, a GIF, or a document with a video file inside, and it returns a ready-to-set 800x800 MP4.
Current numbers: 467 total users. The typical path is someone who tried to set an iPhone clip as their avatar, got no response from Telegram, searched for a solution, and landed on the bot.
A few edge cases that showed up after launch:
Rotation metadata. Some Android phones write rotation as container metadata instead of physically rotating frames. ffmpeg applies auto-rotation by default, which is usually correct, but it can invert the portrait crop logic. -noautorotate disables it when needed.
Very short clips. Sub-second videos encode fine but occasionally produce a black preview thumbnail in Telegram. Not a hard failure, just a display quirk that disappears after the avatar loops once.
20MB Bot API download limit. The Bot API won't let bots fetch files over 20MB. I reject those upfront with a plain message. The local Bot API server removes this cap, but that adds infrastructure I haven't prioritized yet.
Lessons and What's Next
The pipeline is solid for typical phone videos. Two gaps remain.
Dense motion footage still hits the 2MB cap at CRF 26. A proper two-pass VBR encode would be more reliable but doubles processing time. I'm currently testing single-pass with -maxrate 1500k -bufsize 3000k as a middle ground.
4K source files are blocked by the 20MB API limit before ffmpeg even runs. The local Bot API server would fix it.
The bot doesn't handle 4K yet, and ffmpeg is doing the actual heavy lifting here. I wrote the wrapper around it. If you're building something similar, the cropdetect plus -movflags +faststart combination is the part that took the most trial and error to get right.
Built by me. @LiveAvaBot.
Top comments (0)