You record a 5-second clip on your iPhone, open Telegram, try to set it as your video avatar, and nothing happens. No error, no feedback. Telegram quietly refuses HEVC (H.265) video without telling you why.
I ran into this while building @liveavabot, a bot that converts any video or GIF into the exact format Telegram requires for video avatars. The HEVC problem caused more confusion than anything else in the first week.
What Telegram Actually Requires
The video avatar spec is strict and mostly undocumented. Through testing and reading API error responses, here's what works:
- Codec: H.264 (not HEVC/H.265, not VP9, not AV1)
-
Container: MP4 with the
faststartflag set - Resolution: 800x800 pixels, square
- Duration: 10 seconds maximum
- File size: 2 MB maximum
- Audio: Must be absent (video avatars are silent)
- Pixel format: yuv420p
iPhones default to HEVC since iOS 11 to save storage. Most Telegram clients silently discard HEVC input when setting a video avatar. The desktop client occasionally shows a generic "file format not supported" message. Mobile just does nothing.
The ffmpeg Pipeline
The conversion runs in two steps. First, cropdetect to find actual content boundaries (many clips have letterboxing or pillarboxing). Second, encode with the detected crop applied.
# Pass 1: detect crop (look for "crop=" lines in stderr output)
ffmpeg -i input.mov -vf "cropdetect=24:2:0" -f null - 2>&1
# The last crop= line gives the rectangle: crop=W:H:X:Y
# Example: crop=1080:1080:0:0
# Pass 2: apply crop, scale to 800x800, encode as H.264
ffmpeg -i input.mov \
-vf "crop=1080:1080:0:0,scale=800:800:flags=lanczos,fps=30" \
-c:v libx264 -preset fast -crf 23 \
-pix_fmt yuv420p -an \
-movflags +faststart \
-t 10 \
output.mp4
Key flags:
-
-pix_fmt yuv420pforces the pixel format Telegram expects -
-anstrips audio completely -
-movflags +faststartmoves the moov atom to the file front, required for streaming -
-t 10hard-caps at 10 seconds -
fps=30normalizes frame rate (Cinematic mode clips are often 24fps or variable)
If cropdetect returns nothing useful (uniform borders or no borders at all), I fall back to center-crop: scale=800:800:force_original_aspect_ratio=increase,crop=800:800.
aiogram 3 Handler
import asyncio
import os
import tempfile
from aiogram import Bot, Router, F
from aiogram.types import Message, FSInputFile
router = Router()
async def convert_for_tg_avatar(src: str, dst: str) -> bool:
# Pass 1: cropdetect
p1 = await asyncio.create_subprocess_exec(
'ffmpeg', '-i', src, '-vf', 'cropdetect=24:2:0', '-f', 'null', '-',
stderr=asyncio.subprocess.PIPE,
)
_, err = await p1.communicate()
lines = [l for l in err.decode().splitlines() if 'crop=' in l]
crop = lines[-1].rsplit('crop=', 1)[1].split()[0] if lines else 'iw:ih:0:0'
crop_filter = f'crop={crop}'
# Pass 2: encode
vf = f'{crop_filter},scale=800:800:flags=lanczos,fps=30'
p2 = await asyncio.create_subprocess_exec(
'ffmpeg', '-y', '-i', src,
'-vf', vf, '-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
'-pix_fmt', 'yuv420p', '-an', '-movflags', '+faststart', '-t', '10',
dst, stderr=asyncio.subprocess.PIPE,
)
await p2.communicate()
return os.path.exists(dst) and os.path.getsize(dst) > 0
@router.message(F.video | F.animation | F.document)
async def handle_video(message: Message, bot: Bot):
status = await message.reply('Converting...')
file_id = (message.video or message.animation or message.document).file_id
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, 'src')
dst = os.path.join(tmp, 'out.mp4')
f = await bot.get_file(file_id)
await bot.download_file(f.file_path, src)
if not await convert_for_tg_avatar(src, dst):
await status.edit_text('Conversion failed. Try a shorter clip.')
return
size = os.path.getsize(dst)
if size > 2 * 1024 * 1024:
await status.edit_text(
f'Output is {size // 1024} KB, over the 2 MB limit. Try a shorter clip.'
)
return
await status.delete()
await message.reply_video(
FSInputFile(dst),
caption='Ready. Set it in Telegram: Profile > Edit > Set Video.'
)
The handler doesn't retry with a lower CRF when the output is too large. I tried auto-lowering CRF early on and the result looked noticeably bad. Better to reject and ask for a shorter clip.
Packaging This as @liveavabot
The bot runs on a small VPS with aiogram 3.x, a systemd service, and ffmpeg 6.x from apt. No GPU, no cloud functions. Conversion takes 2-5 seconds for a 10-second 1080p clip.
A few things I added after the first 100 users:
- Check
file.file_sizebefore downloading. The bot API includes file size in the message object. If it's over 20 MB, reject before downloading anything. - Accept
.movdocuments. When someone drags a.movinto Telegram as a file, it arrives as adocumentwith mime typevideo/quicktime. The handler accepts it. - Per-user rate limit. One conversion per 10 seconds, tracked in a simple in-memory dict. Prevents accidental spam.
The bot is at https://t.me/LiveAvaBot?start=devto_article_20260927. Basic conversion is free. Currently at 430 users.
Edge Cases and Loose Ends
HEVC with Dolby Vision metadata. Some iPhone clips embed Dolby Vision HDR metadata. ffmpeg re-encodes fine, but output occasionally looks washed out. Adding -colorspace bt709 -color_trc bt709 -color_primaries bt709 helps but doesn't fully fix it. Still debugging.
Variable frame rate video. Cinematic mode and Slo-Mo produce VFR clips. The fps=30 filter normalizes them, but cropdetect can misfire on the first pass if there are blank leading frames. Adding -vsync vfr to the cropdetect pass helps.
GIF inputs. Animated GIFs need -ignore_loop 0 or ffmpeg encodes only the first frame.
2 MB limit on busy clips. CRF 23 at 800x800 for 10 seconds of high-motion content can exceed 2 MB at this quality level. I'm planning a two-pass VBR fallback targeting 1.8 MB for clips that fail the size check, instead of hard rejecting.
ffmpeg handles all the real work here. I wrote the async wrapper and Telegram handler around it.
Built by me. The bot: https://t.me/LiveAvaBot?start=devto_article_20260927.
Top comments (0)