DEV Community

liveavabot
liveavabot

Posted on

Building a Telegram Video Avatar Bot with ffmpeg and Aiogram 3

The problem: iPhone videos silently fail as Telegram avatars

Try setting a video as your Telegram profile picture from an iPhone. It just... doesn't work. No error, no explanation. The upload dialog closes, your avatar stays the same.

I hit this myself last month. Recorded a short clip on my iPhone, tried to set it as my TG avatar, nothing happened. Turns out iOS records in HEVC (H.265) by default, and Telegram's video avatar spec only accepts H.264. There's no warning. The client just refuses silently.

So I built a bot that fixes this. Send it any video or GIF, get back a file that Telegram will actually accept.

What Telegram wants for a video avatar

The spec is undocumented in the public Bot API docs. From reverse-engineering the client behavior:

  • Codec: H.264 (yuv420p pixel format)
  • Container: MP4 with faststart flag
  • Resolution: 800x800, square, center-cropped
  • Duration: max 10 seconds
  • File size: max 2 MB
  • Audio: must be stripped

Miss any of these and the client rejects the upload without telling you why. The 2 MB cap is the annoyingly tight one, especially at 10 seconds and 800x800 resolution.

The ffmpeg pipeline

Two passes. First a cropdetect pass to find the safe square region. Then the actual encode.

ffmpeg -ss 0 -t 10 -i input.mov \
  -vf "cropdetect=24:16:0" -f null - 2>&1 \
  | grep -oE "crop=[0-9:]+" | tail -1
Enter fullscreen mode Exit fullscreen mode

That gives you something like crop=1080:1080:420:0. Feed it into the encode:

ffmpeg -ss 0 -t 10 -i input.mov \
  -vf "crop=1080:1080:420:0,scale=800:800:flags=lanczos,format=yuv420p" \
  -c:v libx264 -preset slow -crf 26 \
  -pix_fmt yuv420p \
  -movflags +faststart \
  -an \
  -y output.mp4
Enter fullscreen mode Exit fullscreen mode

Notes:

  • -an strips audio. Telegram wants silent video for avatars.
  • -movflags +faststart moves the moov atom to the front so TG can start reading before download completes.
  • -crf 26 is my sweet spot at 800x800 to stay under 2 MB. Adjust down for higher quality if the clip is short enough.
  • format=yuv420p inside the filter chain is belt-and-braces. Some players still choke on yuv444.

If the encode comes out over 2 MB, I retry with -crf 30. If still too big, I trim duration. That's the whole size-target logic. No fancy VBR two-pass.

Wiring it into aiogram 3

The bot is Python with aiogram 3. Handler for incoming video, document, or animation:

from aiogram import Router, F
from aiogram.types import Message, FSInputFile
from pathlib import Path
import asyncio, tempfile

router = Router()

@router.message(F.video | F.document | F.animation)
async def handle_video(msg: Message):
    file_obj = msg.video or msg.animation or msg.document
    if not file_obj:
        return

    with tempfile.TemporaryDirectory() as tmp:
        src = Path(tmp) / "in.bin"
        dst = Path(tmp) / "out.mp4"

        await msg.bot.download(file_obj, destination=src)
        await msg.reply("Converting, hang on...")

        rc = await convert(src, dst)
        if rc != 0 or not dst.exists():
            await msg.reply("ffmpeg choked on that file, sorry")
            return

        await msg.reply_video(
            FSInputFile(dst),
            caption="Ready. Long-press your avatar in Settings to set it."
        )

async def convert(src: Path, dst: Path) -> int:
    crop = await detect_crop(src)
    cmd = [
        "ffmpeg", "-y", "-ss", "0", "-t", "10", "-i", str(src),
        "-vf", f"{crop},scale=800:800:flags=lanczos,format=yuv420p",
        "-c:v", "libx264", "-preset", "slow", "-crf", "26",
        "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-an",
        str(dst),
    ]
    proc = await asyncio.create_subprocess_exec(*cmd,
        stdout=asyncio.subprocess.DEVNULL,
        stderr=asyncio.subprocess.DEVNULL)
    return await proc.wait()
Enter fullscreen mode Exit fullscreen mode

detect_crop runs the cropdetect pass and greps the last crop= line. I skipped it above for brevity. It's a 15-line asyncio subprocess plus regex.

Edge cases that bit me

HEVC with rotation metadata

iPhone portrait videos have a 90-degree rotation flag in the container. If you crop before ffmpeg applies the rotation, you crop the wrong region. Fix: don't set -noautorotate. Let ffmpeg apply rotation automatically before the filter chain runs.

Animated GIFs with transparent frames

Converting straight to yuv420p gives random flashes on transparent pixels. Add a palette pass for GIF sources, or pre-composite over black with pad=color=black.

Very short clips

Telegram accepts clips under 1 second but the avatar loop looks janky. I now pad short clips with tpad=stop_mode=clone:stop_duration=1 to hit at least 2 seconds.

Files bigger than 20 MB

Telegram Bot API can't download files bigger than 20 MB directly. For those the bot replies asking to trim the clip client-side. Not solving that one right now.

Packaging as a public bot

It's live at t.me/LiveAvaBot. Send any video or GIF, get back a converted MP4 ready to set as your TG video avatar. Free for the first few conversions per user, then Telegram Stars after that (mostly to cover the VPS ffmpeg CPU).

The whole bot is about 400 lines of Python. ffmpeg does the heavy lifting, I just wrote the pipeline and the aiogram wiring.

What I'd do differently

Two-pass encoding for size targeting would be nicer than my "try crf 26, retry crf 30" heuristic. ffmpeg's -fs flag exists but often truncates output, so I never trusted it. Better path is probably -b:v with a proper two-pass log file. For 10-second clips the current approach is fine.

Also, no 4K HEVC support yet. Decoding 4K HEVC on a shared vCPU is slow enough that users time out before it finishes. If demand grows I'll move ffmpeg to a beefier worker.

Built by me. Bot: @liveavabot.

Top comments (0)