DEV Community

liveavabot
liveavabot

Posted on

Fixing Telegram Video Avatars: HEVC, ffmpeg, and aiogram 3

The Problem: iPhone Videos Silently Fail

Setting a video avatar in Telegram sounds trivial. Open the app, tap your profile, upload a short clip. But if you recorded that clip on an iPhone, there's a good chance Telegram will silently refuse it with no useful error message.

The reason is HEVC (H.265). iPhones have recorded in HEVC by default since iOS 11, and Telegram's video avatar pipeline doesn't support it. You don't get "wrong codec." You get nothing. The upload just fails.

I hit this myself while testing a channel. After about 20 minutes of confusion, I started digging into what Telegram actually requires.

What Telegram Actually Requires

Here are the hard constraints for a Telegram video avatar:

  • Codec: H.264 (not HEVC, not VP9, not AV1)
  • Resolution: 800x800 pixels, square
  • Duration: 10 seconds max
  • File size: 2 MB max
  • Audio: none (must be stripped or the upload fails)
  • Container: MP4 with the faststart flag for streaming

That last one trips people up. faststart moves the moov atom to the front of the MP4 file so Telegram can start processing before the full download completes. Without it, some uploads fail silently.

The codec constraint is the main issue. HEVC footage from iPhone needs to be re-encoded to H.264, and the resolution needs to be cropped to a square before scaling. A naive scale to 800x800 on portrait video produces letterboxing, which looks bad as an avatar.

How ffmpeg Handles It

ffmpeg's cropdetect filter finds the natural crop bounds of a video. You can chain it with scale and format conversion in a single pass.

Here's the command I settled on after testing with a batch of iPhone clips:

ffmpeg -i input.mov \
  -vf "scale=800:800:force_original_aspect_ratio=increase,crop=800:800" \
  -c:v libx264 \
  -preset fast \
  -crf 28 \
  -pix_fmt yuv420p \
  -t 10 \
  -an \
  -movflags +faststart \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

Walk-through of the key flags:

  • scale=800:800:force_original_aspect_ratio=increase scales the video so the shorter dimension reaches 800px, then crop=800:800 cuts it square.
  • libx264 re-encodes from HEVC to H.264.
  • yuv420p forces 4:2:0 chroma subsampling. Without this, H.264 output from some sources uses 4:4:4, which Telegram rejects silently.
  • -t 10 trims to 10 seconds max.
  • -an strips all audio tracks.
  • +faststart relocates the moov atom to the file header.

CRF 28 gives decent quality at small file size. For a 10-second 800x800 clip that's usually well under 2 MB. If the output goes over, I retry with CRF 30 or add a bitrate cap (-b:v 800k).

One edge case I didn't expect: vertical iPhone videos shot in 60fps HEVC. The first few frames have a partial black region from the auto-exposure ramp, which throws off cropdetect. I now skip the first 15 frames: cropdetect=24:2:15.

aiogram 3 Handler

Here's a minimal handler that accepts a video or GIF, converts it, and replies with the processed file:

from aiogram import Router, F
from aiogram.types import Message, BufferedInputFile
import subprocess, tempfile, os

router = Router()

@router.message(F.video | F.animation | F.document)
async def handle_video(message: Message):
    await message.answer("Processing...")

    file = message.video or message.animation or message.document
    tg_file = await message.bot.get_file(file.file_id)

    with tempfile.TemporaryDirectory() as tmpdir:
        input_path = os.path.join(tmpdir, "input")
        output_path = os.path.join(tmpdir, "output.mp4")

        await message.bot.download_file(tg_file.file_path, input_path)

        cmd = [
            "ffmpeg", "-i", input_path,
            "-vf", "scale=800:800:force_original_aspect_ratio=increase,crop=800:800",
            "-c:v", "libx264", "-preset", "fast", "-crf", "28",
            "-pix_fmt", "yuv420p",
            "-t", "10", "-an", "-movflags", "+faststart",
            "-y", output_path
        ]
        result = subprocess.run(cmd, capture_output=True, timeout=60)

        if result.returncode != 0:
            await message.answer("Conversion failed. Try a shorter clip.")
            return

        with open(output_path, "rb") as f:
            data = f.read()

        await message.answer_video(
            BufferedInputFile(data, filename="avatar.mp4"),
            caption="Set this as your Telegram video avatar."
        )
Enter fullscreen mode Exit fullscreen mode

This is simplified. The real bot handles the 20 MB download cap (Telegram's limit for bots without the local API server), retries, and a two-pass cropdetect. But the structure is the same.

One migration note: aiogram 3 uses F.video | F.animation | F.document for content type filters. The old content_types= argument from aiogram 2 is gone. That's a common breakage point if you're upgrading.

How I Packaged This as @liveavabot

The bot has been running for a few months and now has around 450 users. It handles iPhone MOV files, Android MP4s, GIFs, and the occasional WebM someone drags in from a browser.

The pipeline per request:

  1. Receive file via aiogram 3
  2. Download to a temp directory
  3. Run ffmpeg: cropdetect pass, then encode pass
  4. Check output size, retry with lower CRF if over 2 MB
  5. Send the MP4 back
  6. Clean up temp files

You can try it at https://t.me/LiveAvaBot?start=devto_article_20261008.

Lessons and What's Next

A few things I'd do differently starting over:

Temp file handling. Initially I used tempfile.NamedTemporaryFile directly in handlers. Concurrent requests stepped on each other. tempfile.TemporaryDirectory() as a context manager is cleaner and cleans up even on exceptions.

ffmpeg timeout. Some clips have corrupt headers that make ffmpeg hang indefinitely. subprocess.run(..., timeout=60) is a must. Without it, the bot eventually wedges itself on a bad file.

The yuv420p flag. I missed this in the first version. Telegram rejected about 15% of outputs with no error message. Adding -pix_fmt yuv420p fixed all of them. It's worth adding even if you don't think you need it.

What's next: a preview step where the bot sends a 3-second thumbnail before converting the full clip. That would help users pick the right moment in longer videos without running the full encode.

ffmpeg is doing most of the heavy lifting here. The bot is just a thin wrapper that moves files around and calls the right flags in the right order. Built by me, @liveavabot is a personal side project and free to use.

Top comments (0)