DEV Community

liveavabot
liveavabot

Posted on

HeyGen + Telegram Avatar: Fixing the Format Gap with ffmpeg

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
Enter fullscreen mode Exit fullscreen mode

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+"
Enter fullscreen mode Exit fullscreen mode

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.'
        )
Enter fullscreen mode Exit fullscreen mode

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)