DEV Community

liveavabot
liveavabot

Posted on

How I Built a Telegram Video Avatar Converter with FFmpeg

The problem: iPhone videos silently fail as Telegram avatars

Telegram lets you set a short video as your profile picture. It plays in a loop on your profile page. Neat feature. But if you record a clip on iPhone and try to upload it, Telegram either rejects the upload with no error or shows a spinner forever. No message. Just nothing.

The reason: modern iPhones record in HEVC (H.265), and Telegram's video avatar endpoint only accepts H.264 in an MP4 container. The client does not warn you. The server just drops the upload on the floor.

I hit this exact wall trying to set my own avatar with a clip from my phone. So I built a bot that takes any video or GIF, re-encodes it to Telegram's spec, and sends it back. I named it @liveavabot.

What Telegram actually wants for a video avatar

The Telegram spec for video profile pictures (also called animated avatars) is narrow and mostly undocumented in the Bot API reference. Here is what I pieced together from trial and mobile client source:

  • Container: MP4 with the faststart flag (moov atom at the front)
  • Video codec: H.264 (avc1), yuv420p pixel format
  • Resolution: 800 by 800 pixels, square, no letterbox
  • Duration: 3 to 10 seconds
  • Frame rate: 30 fps or lower
  • Audio: must be removed entirely, not just muted
  • File size: under 2 MB, and this is the mean one

Miss any of these and the upload fails silently. The 2 MB cap is the hard part, because 10 seconds of decent-quality 800x800 H.264 usually blows past that if you use default settings.

Solving it with ffmpeg cropdetect and scale

The cropping is the interesting bit. Most user videos are portrait or landscape, not square. If you naive-scale a 1080x1920 clip to 800x800, it looks stretched and awful. You need to crop to a centered square first, then scale down.

ffmpeg has a cropdetect filter that scans a video and tells you the largest non-black rectangle. In practice, for user avatars, I found a simpler expression works better: crop to the shorter side, centered. Here is the actual command the bot runs:

ffmpeg -i input.mov \
  -t 10 \
  -vf "crop='min(iw,ih)':'min(iw,ih)',scale=800:800,fps=30,format=yuv420p" \
  -c:v libx264 -profile:v baseline -level 3.1 \
  -preset veryfast -crf 28 \
  -maxrate 1500k -bufsize 3000k \
  -movflags +faststart \
  -an \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

Breakdown:

  • -t 10 clamps duration to 10 seconds
  • crop='min(iw,ih)':'min(iw,ih)' picks the shorter side and cuts a centered square
  • scale=800:800 resizes to Telegram's target
  • fps=30 caps frame rate at 30
  • format=yuv420p forces the pixel format Telegram needs
  • -profile:v baseline -level 3.1 maximizes decoder compatibility on older devices
  • -crf 28 gives a decent quality-to-size tradeoff
  • -maxrate 1500k keeps peak bitrate under control so the file stays small
  • -movflags +faststart moves the moov atom to the front so it is streamable
  • -an drops audio completely

The -crf 28 -maxrate 1500k combo is what I settled on after testing a couple hundred clips. Lower CRF means better quality but bigger files. 28 with a 1500k ceiling keeps most 10-second clips under 2 MB without looking terrible. If a clip still comes out too big (happens with high-motion content), the bot re-encodes with CRF 32 as a fallback pass.

The aiogram 3 handler

Wrapping this in a Telegram bot is straightforward with aiogram 3. Here is the core handler that receives a video or animation and returns the converted file:

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

router = Router()

async def run_ffmpeg(src: pathlib.Path, dst: pathlib.Path) -> None:
    cmd = [
        "ffmpeg", "-y", "-i", str(src),
        "-t", "10",
        "-vf",
        "crop='min(iw,ih)':'min(iw,ih)',scale=800:800,fps=30,format=yuv420p",
        "-c:v", "libx264", "-profile:v", "baseline", "-level", "3.1",
        "-preset", "veryfast", "-crf", "28",
        "-maxrate", "1500k", "-bufsize", "3000k",
        "-movflags", "+faststart",
        "-an",
        str(dst),
    ]
    proc = await asyncio.create_subprocess_exec(
        *cmd,
        stdout=asyncio.subprocess.DEVNULL,
        stderr=asyncio.subprocess.PIPE,
    )
    _, err = await proc.communicate()
    if proc.returncode != 0:
        raise RuntimeError(err.decode(errors="ignore")[:500])

@router.message(F.video | F.animation | F.document)
async def convert(msg: Message) -> None:
    file_obj = msg.video or msg.animation or msg.document
    if not file_obj:
        return
    with tempfile.TemporaryDirectory() as tmp:
        tmp = pathlib.Path(tmp)
        src, dst = tmp / "in.bin", tmp / "out.mp4"
        await msg.bot.download(file_obj, destination=src)
        await run_ffmpeg(src, dst)
        await msg.answer_video(
            FSInputFile(dst),
            caption="Open Settings, Edit profile, tap avatar to set.",
        )
Enter fullscreen mode Exit fullscreen mode

A few things worth noting:

  • I accept F.document too, because iPhone users often send clips as documents when sharing from Files
  • The temp directory auto-cleans on exit, so no leaked files build up on disk
  • The ffmpeg stderr gets truncated to 500 chars if things blow up, keeps error logs sane

Packaging it as @liveavabot

The full bot adds a few things around this core:

  • File size check up front (Telegram caps user uploads at 20 MB for bots without the local API server)
  • HEVC detection via ffprobe before conversion, so I can log how often iPhone clips show up (spoiler: most of them)
  • A rate limit per user, because someone will always try to hammer it
  • Storage of user IDs in SQLite for a rough monthly-active count, no other tracking

Deployed on a small VPS. ffmpeg is doing all the heavy lifting, the Python wrapper is maybe 400 lines. Cost per conversion is negligible, most clips encode in under 3 seconds on 2 vCPUs.

Try it here: https://t.me/LiveAvaBot?start=devto_article_20260815

Edge cases and what is next

Things that still trip the bot:

  • 4K HDR videos take forever to encode and sometimes hit the 2 MB ceiling even at CRF 32. I need to add a resolution pre-check and downscale before the main pass.
  • Very short clips (under 3 seconds) get rejected by Telegram because they violate the minimum duration. I should loop them automatically instead of erroring out.
  • Some Android phones send WebM animations that trigger a weird cropdetect result on the very first frame. Worked around it with -ss 0.5 to skip the first half-second, but it is a hack.

The Telegram spec for animated profile pictures is not fully documented anywhere I could find. I dug most of it out of failed uploads and the mobile client. If you want to build something similar, budget a weekend just for figuring out what the server actually accepts.

Built by me, @liveavabot. Feedback and PRs welcome.

Top comments (0)