The Problem Nobody Warns You About
You open Telegram, try to set a video avatar, upload a clip from your iPhone, and nothing happens. No error. The upload completes, Telegram just silently ignores it. If you're lucky, you get a generic "wrong format" toast. Usually you get nothing.
The issue is HEVC (H.265). Every iPhone since 2017 records in HEVC by default. Telegram's video avatar feature requires H.264, yuv420p pixel format, 800x800 resolution, under 10 seconds, under 2MB, and no audio track. HEVC satisfies zero of those constraints. Telegram doesn't tell you any of this.
I ran into this personally, got annoyed, and built a bot to fix it.
What Telegram Actually Requires
The spec (pulled from the Bot API docs and empirical testing) is specific:
- Codec: H.264 baseline or main profile
- Pixel format: yuv420p (4:2:0 chroma subsampling)
- Resolution: exactly 800x800
- Duration: 10 seconds max (Telegram crops longer clips client-side, but better to cut at encode)
- File size: 2MB max
- Audio: must be absent (not muted, actually absent from the container)
- Container: MP4 with moov atom at front (faststart)
The 800x800 requirement is strict. 799x800 fails. The audio requirement catches a lot of people who convert the video but forget -an.
The ffmpeg Pipeline
Naive scaling to 800x800 squishes the video. Most source clips are 9:16 (portrait) or 16:9 (landscape), neither of which is square. The right approach is to scale up so the shorter dimension reaches 800, then center-crop to exactly 800x800.
ffmpeg -i input.mov \
-vf "scale=800:800:force_original_aspect_ratio=increase,crop=800:800" \
-c:v libx264 \
-profile:v baseline \
-level:v 3.0 \
-pix_fmt yuv420p \
-t 9 \
-an \
-crf 26 \
-movflags +faststart \
-y output.mp4
force_original_aspect_ratio=increase scales the video up so the shorter dimension reaches 800. Then crop=800:800 takes the center 800x800 square. No padding, no letterboxing.
-profile:v baseline -level:v 3.0 ensures compatibility. Some older Android devices choke on High profile H.264 even when the codec is technically correct.
-pix_fmt yuv420p is non-negotiable. ffmpeg defaults to yuv420p for most inputs, but HEVC files sometimes carry yuv422p or yuv444p. The default passthrough will preserve that and break the Telegram requirement silently.
-t 9 rather than 10 gives a safety margin for container overhead. Testing showed that a 10.0s source sometimes produces a 10.03s output due to frame rounding at certain framerates.
-crf 26 is a reasonable quality/size tradeoff for 800x800 at under 2MB. For longer clips near the 9s limit, you may need -crf 30 to stay under the size cap. I added an automatic retry at lower quality if the first pass exceeds 2MB.
-movflags +faststart moves the moov atom to the front of the file. Without this, Telegram's server-side validation sometimes rejects the upload even when the codec is correct. This one bit me for two hours.
The aiogram 3 Handler
I used aiogram 3. The handler accepts videos, documents (some clients send MP4 as documents rather than videos), and animations (Telegram GIFs, which are actually MP4 under the hood):
from aiogram import Router, F, Bot
from aiogram.types import Message, FSInputFile
import asyncio, tempfile, os
router = Router()
@router.message(F.video | F.document | F.animation)
async def handle_video(message: Message, bot: Bot):
obj = message.video or message.document or message.animation
file = await bot.get_file(obj.file_id)
if file.file_size > 20 * 1024 * 1024:
await message.reply("File too large (20MB Telegram API limit).")
return
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "input")
dst = os.path.join(tmp, "output.mp4")
await bot.download_file(file.file_path, destination=src)
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-i", src,
"-vf", "scale=800:800:force_original_aspect_ratio=increase,crop=800:800",
"-c:v", "libx264", "-profile:v", "baseline", "-level:v", "3.0",
"-pix_fmt", "yuv420p", "-t", "9", "-an", "-crf", "26",
"-movflags", "+faststart", "-y", dst,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
await message.reply("Conversion failed.")
return
if os.path.getsize(dst) > 2 * 1024 * 1024:
await message.reply("Output over 2MB. Try a shorter clip.")
return
await message.reply_video_note(
video_note=FSInputFile(dst),
duration=9,
length=800,
)
reply_video_note is the correct method for video avatars (what Telegram calls "video notes" or round videos). reply_video sends a regular video, not the round format.
One gotcha: the length parameter in reply_video_note expects the pixel dimension of the square, not a duration. It's confusingly named.
Packaging It as a Bot
The bot is live at https://t.me/LiveAvaBot?start=devto_article_20260429. You send it any video, GIF, or short clip, it converts and replies with a round video note ready to set as your avatar.
The stack is minimal: Python 3.12, aiogram 3.x, ffmpeg installed via apt, systemd for process supervision. No database for the core conversion flow (stateless request/response). I added SQLite later for usage tracking and per-user rate limiting, but the happy path has zero persistence requirements.
Deployment is a single VPS at about $5/month. The bot runs under systemd with Restart=on-failure. Total setup took an afternoon once the ffmpeg flags were sorted.
Edge Cases and What I Learned
GIFs are already videos. Telegram re-encodes GIFs as MP4 on upload. When a user sends a GIF, you receive an Animation object with a video file, not a GIF89a. The conversion pipeline handles them fine, but you need to check message.animation explicitly.
HEVC from iPhones comes in .mov containers. ffmpeg handles this, but some inputs have metadata tracks, data tracks, and other non-video streams. Adding -map 0:v:0 selects only the first video stream and avoids muxing junk into the output.
2MB is tight for 9 seconds at 800x800. Average bitrate works out to about 1.8 Mbps. Fine for talking-head footage or simple animations. Screen recordings with lots of fine text detail hit the limit reliably. The automatic CRF retry helps, but adds a couple seconds of latency.
The Bot API download limit is 20MB. Files over 20MB require running the local Bot API server. I haven't set that up yet. Large files get a clear error message rather than a silent failure, which I consider the minimum acceptable behavior.
Currently 22 users have tried the bot, with 5 active in the last 24 hours. The conversion success rate is high. Every rejection now has an explanation attached.
Built by me, @liveavabot.
Top comments (0)