I've been using HeyGen to create talking-head clips for product demos. The results look good. But when I tried to set one as my Telegram video avatar, Telegram showed a spinning indicator and quietly dropped the file. No error message.
Took me a while to figure out why.
What Telegram Actually Requires
Telegram's video avatar spec is stricter than most people expect. All four constraints have to be met simultaneously:
- Codec: H.264 (libx264). HEVC/H.265 is silently rejected.
- Resolution: exactly 800x800 pixels, square.
- Duration: 10 seconds maximum.
- Audio: must be absent. A muted track still fails.
HeyGen exports 16:9 or 9:16 video in H.264 or HEVC, with stereo audio, anywhere from 10 to 120 seconds long. That's a four-way mismatch.
The Mismatch, Spelled Out
| Property | HeyGen output | Telegram avatar spec |
|---|---|---|
| Aspect | 16:9 or 9:16 | 1:1 exactly |
| Duration | 10-120s | max 10s |
| Codec | H.264 or HEVC | H.264 only |
| Audio | stereo | none |
The aspect ratio is the tricky one. Cropping a talking head from 16:9 to 1:1 cuts off part of the frame. You want the largest possible square, centered, before scaling to 800x800.
The ffmpeg Pipeline
ffmpeg handles all four constraints in one pass:
ffmpeg -y -i heygen_output.mp4 -t 10 \
-vf "crop=min(iw\,ih):min(iw\,ih),scale=800:800,fps=30,format=yuv420p" \
-c:v libx264 -preset medium -b:v 900k \
-an -movflags +faststart \
telegram_avatar.mp4
What each part does:
-
-t 10: hard-cuts at 10 seconds before encoding. -
crop=min(iw\,ih):min(iw\,ih): takes the largest centered square from whatever aspect ratio you feed in. -
scale=800:800: resizes to the exact spec. -
fps=30: normalizes frame rate. Some HeyGen exports come in at 24fps. -
format=yuv420p: broad device compatibility. Some encoders default to yuv444p, which older iOS players reject. -
-c:v libx264 -preset medium -b:v 900k: H.264 at 900kbps. A 10-second 800x800 clip comes out around 1.1MB, under the 2MB cap. -
-an: strips all audio tracks. -
-movflags +faststart: moves the moov atom to the front so Telegram can stream before the file fully downloads.
If your source has letterboxing baked in (black bars from HeyGen rendering), run a cropdetect pre-pass first:
# detect content bounds
ffmpeg -i heygen_output.mp4 -vf cropdetect=24:16:0 -t 5 -f null - 2>&1 | grep -oP "crop=\S+"
That outputs a crop string like crop=720:720:0:180. Feed that into the encode pass instead of the min(iw,ih) expression. Most HeyGen exports don't have letterboxing, but it matters when processing a batch of mixed sources.
An aiogram 3 Handler
If you want to accept HeyGen exports directly in Telegram and return an avatar-ready file, here's a minimal aiogram 3 handler:
import asyncio
import tempfile
import os
from aiogram import Router, F
from aiogram.types import Message, BufferedInputFile
router = Router()
async def run_ffmpeg(input_path: str, output_path: str) -> bool:
cmd = [
'ffmpeg', '-y', '-i', input_path, '-t', '10',
'-vf', 'crop=min(iw\\,ih):min(iw\\,ih),scale=800:800,fps=30,format=yuv420p',
'-c:v', 'libx264', '-preset', 'medium', '-b:v', '900k',
'-an', '-movflags', '+faststart',
output_path
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
return proc.returncode == 0
@router.message(F.video | F.document)
async def handle_video(message: Message) -> None:
file = message.video or message.document
if file is None:
return
bot = message.bot
tg_file = await bot.get_file(file.file_id)
with tempfile.TemporaryDirectory() as tmpdir:
input_path = os.path.join(tmpdir, 'input.mp4')
output_path = os.path.join(tmpdir, 'avatar.mp4')
await bot.download_file(tg_file.file_path, destination=input_path)
ok = await run_ffmpeg(input_path, output_path)
if not ok:
await message.reply('ffmpeg failed on this file.')
return
with open(output_path, 'rb') as f:
data = f.read()
await message.reply_video(
BufferedInputFile(data, filename='avatar.mp4'),
caption='Set this as your Telegram video avatar in Profile settings.'
)
asyncio.create_subprocess_exec keeps ffmpeg off the event loop. The handler accepts both F.video and F.document because Telegram sometimes stores video as document depending on how the user shares it. In production, add a file size check before download and a timeout around the ffmpeg call.
How I Packaged This
I wrapped this pipeline in a Telegram bot at https://t.me/LiveAvaBot?start=devto_article_20260910. Send it any video or GIF, it runs the ffmpeg pipeline server-side and returns an avatar-ready file. No app, no account.
The HeyGen use case came up because a few users mentioned they were trying to use AI avatar clips as Telegram profile videos. The same pipeline handles iPhone HEVC exports too, for the same codec reason: libx264 transcodes anything ffmpeg can decode.
The server runs on a Hetzner VPS. ffmpeg does the actual work. I just wrote the glue, the Telegram handler, and the queue.
Edge Cases Worth Knowing
HEVC source: Telegram rejects it silently. The pipeline transcodes to H.264 regardless of input codec, so HEVC HeyGen exports and iPhone videos both work.
File over 2MB after encoding: Usually happens with very high-bitrate source files. The 900kbps target keeps 800x800 10-second clips around 1.1MB. If you hit the cap, drop to 600kbps and retry.
Duration at exactly 10 seconds: Telegram's limit is inclusive. -t 10 in ffmpeg is fine. A 10.1-second clip fails.
Portrait 9:16 output: crop=min(iw,ih):min(iw,ih) handles both orientations. For portrait it crops top and bottom; for landscape it crops both sides.
Black bars in HeyGen export: Some HeyGen templates render with letterboxing. Use cropdetect as described above. Without it you end up with black bars inside the 800x800 frame.
Built by me: @liveavabot is a side project. Try it at https://t.me/LiveAvaBot?start=devto_article_20260910.
Top comments (0)