The Problem Nobody Warns You About
You record a short clip on your iPhone, try to set it as your Telegram video avatar, and Telegram just... silently fails. No error message. The avatar doesn't update. You try again. Same result.
The reason is HEVC (H.265). iPhones have been shooting HEVC by default since iOS 11. Telegram's video avatar uploader accepts HEVC files without complaint, processes them, and then quietly discards them. You get no feedback.
I ran into this building @liveavabot. Users kept sending me iPhone videos asking why they didn't work. Once I understood the spec, I wrote the conversion pipeline. Here's what I learned.
What Telegram Actually Requires
Telegram's video avatar format has five hard constraints:
- Codec: H.264 (libx264), yuv420p pixel format
- Resolution: exactly 800x800 pixels, square
- Duration: 10 seconds maximum
- File size: 2 MB maximum
- Audio: not allowed (stream must be stripped)
HEVC fails the first constraint. Telegram accepts the upload, but the resulting avatar is blank or rejected at the API level. The client shows nothing.
Beyond codec, the square crop is the other common failure point. Most videos are 16:9 or 9:16. If you naively scale to 800x800, you get distortion. You need to detect the actual content area, crop it square, then scale.
The ffmpeg Pipeline
I use two ffmpeg passes. The first detects the crop rectangle, the second does the actual encode.
Pass 1: Crop Detection
ffmpeg -i input.mov \
-vf cropdetect=24:16:0 \
-f null -t 10 - 2>&1 | grep cropdetect | tail -1
This samples the first 10 seconds and outputs a crop=W:H:X:Y value. cropdetect=24:16:0 means: black threshold 24, round to 16px increments, skip 0 frames. For most iPhone videos the crop is the full frame, but for letterboxed content it cuts the black bars.
Pass 2: Encode to Telegram Spec
ffmpeg -i input.mov \
-vf "crop=W:H:X:Y,scale=800:800:force_original_aspect_ratio=disable" \
-c:v libx264 -pix_fmt yuv420p \
-crf 28 -preset fast \
-t 10 \
-an \
-movflags +faststart \
-y output.mp4
Key flags:
-
scale=800:800:force_original_aspect_ratio=disableforces the square without padding or black bars -
-anstrips the audio stream entirely -
-t 10hard-caps at 10 seconds -
-movflags +faststartmoves the moov atom to the front, required for streaming -
-crf 28 -preset fastis a starting point; I retry at-crf 32if the output exceeds 2 MB
For GIFs I skip the cropdetect pass and use scale=800:800:flags=lanczos directly.
The aiogram 3 Handler
The bot accepts video, document, and animation messages, runs the conversion in a subprocess, and sends back the result.
import asyncio
from pathlib import Path
from aiogram import Router, F
from aiogram.types import Message, FSInputFile
router = Router()
async def convert_to_avatar(src: Path, dst: Path) -> bool:
crop_cmd = [
"ffmpeg", "-i", str(src),
"-vf", "cropdetect=24:16:0",
"-f", "null", "-t", "10", "-",
]
proc = await asyncio.create_subprocess_exec(
*crop_cmd, stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
crop = parse_cropdetect(stderr.decode()) # extract last crop= line
encode_cmd = [
"ffmpeg", "-i", str(src),
"-vf", f"{crop},scale=800:800:force_original_aspect_ratio=disable",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-crf", "28", "-preset", "fast",
"-t", "10", "-an", "-movflags", "+faststart",
"-y", str(dst),
]
proc = await asyncio.create_subprocess_exec(*encode_cmd)
await proc.communicate()
return dst.exists() and dst.stat().st_size <= 2 * 1024 * 1024
@router.message(F.video | F.document | F.animation)
async def handle_video(message: Message):
status = await message.answer("Converting...")
file = await message.bot.get_file(...)
src = Path(f"/tmp/{file.file_id}.mov")
dst = src.with_suffix(".mp4")
await message.bot.download_file(file.file_path, src)
ok = await convert_to_avatar(src, dst)
if ok:
await message.reply_video(FSInputFile(dst))
else:
await message.reply("Couldn't fit under 2 MB. Try a shorter clip.")
await status.delete()
parse_cropdetect is a small helper that greps the stderr output for the last crop= line and returns it formatted for the -vf chain. About 10 lines.
I run the conversion via asyncio.create_subprocess_exec rather than run_in_executor, since the subprocess approach doesn't block the event loop. For a busy bot you'd want a semaphore to cap concurrent ffmpeg processes. For the current load of 91 users, a single async subprocess per request is fine.
Packaging It as @liveavabot
I wrapped the pipeline in a Telegram bot so people don't need ffmpeg installed locally. You send a video or GIF, the bot converts it and sends back an MP4 ready to set as your avatar.
The bot is at https://t.me/LiveAvaBot?start=devto_article_20260530, it's free to use.
Hosting is a single Hetzner VPS. ffmpeg does the heavy lifting. The bot code is around 400 lines of Python including the aiogram handlers, file cleanup, and basic rate limiting.
One thing I added early on: HEVC detection at intake. If the incoming file is H.265, the bot logs it separately so I can track how many iPhone users hit this path. About 30% of video submissions are HEVC.
Edge Cases and What's Next
Portrait 9:16 videos. Most common case. The cropdetect pass handles them. If a screen recording added letterbox bars, cropdetect catches those too and the result is a tighter square.
GIFs over 50 MB. Telegram sends large GIFs as Animation type with an MP4 preview already attached. I decode the original GIF with ffmpeg -ignore_loop 0 and encode it through the same pipeline as a regular video.
2 MB overflow. -crf 28 doesn't always land under 2 MB. I retry at -crf 32. Still over? The bot asks the user to trim to under 8 seconds. Automatic trimming isn't implemented yet, mostly because it changes the content in a way users don't always expect.
Audio removal. Some users are surprised that audio gets stripped. Telegram's spec doesn't allow audio in video avatars, so there's no way around it. Worth stating upfront in the bot's reply message.
Next on the list: a small web UI for people who don't want to use Telegram for the conversion. The ffmpeg command is simple enough to run server-side behind a file upload form.
Built by me, @LiveAvaBot.
Top comments (0)