DEV Community

liveavabot
liveavabot

Posted on

How I Built a Telegram Bot to Fix iPhone HEVC Video Avatars

The Problem Nobody Explains

You record a video on your iPhone. You try to set it as your Telegram video avatar. The upload spinner runs, then nothing happens. No error message. Telegram just silently ignores the file.

The culprit is HEVC. iPhones default to H.265/HEVC recording since iOS 11, and Telegram's video avatar pipeline doesn't transcode on ingest. It drops the file without telling you why.

I hit this while setting up an avatar for a Telegram channel. Took me an embarrassing amount of time to figure out what was happening. Once I understood the actual spec, I built a bot so nobody else has to go through the same debugging session.

What Telegram Actually Requires

Telegram video avatars have a strict technical spec. I assembled this partly from docs, partly from trial and error:

  • Codec: H.264 (not HEVC, not VP9, not AV1)
  • Resolution: 800x800 pixels, square
  • Duration: 10 seconds maximum
  • File size: 2MB maximum
  • Audio: must be removed (audio tracks cause rejection)
  • Pixel format: yuv420p (some encoders default to yuv444p, which breaks playback on older clients)
  • Container: MP4 with faststart flag (moov atom at the front, required for streaming)

The square crop is the tricky part. Source footage is almost never 1:1, and naive scaling produces letterboxing instead of a proper crop to fill the avatar circle.

ffmpeg Pipeline: Cropdetect, Scale, Encode

I use a two-pass approach. First, cropdetect finds the actual content region and removes any letterboxing already in the source. Then scale-and-crop to 800x800.

Detect crop parameters first:

ffmpeg -i input.mp4 \
  -vf "cropdetect=24:2:0" \
  -f null - 2>&1 | grep cropdetect | tail -1
Enter fullscreen mode Exit fullscreen mode

Then encode with the detected values (or skip cropdetect if the source is already full-frame):

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

A few decisions worth explaining:

scale=800:800:force_original_aspect_ratio=increase scales so the smaller dimension reaches 800. Then crop=800:800 center-crops the excess. No black bars, no distortion.

crf 26 keeps file size reasonable. Most 10s clips land well under 2MB. For high-motion content I add -maxrate 1.5M -bufsize 3M as a ceiling.

-an removes audio unconditionally. Telegram rejects files with audio tracks.

-movflags +faststart rewrites the container so the moov atom sits at the front, which Telegram's servers require for video avatars.

In the bot, I run cropdetect first, parse the last crop= line from stderr, then feed those values into the main encode. That handles already-letterboxed iPhone footage without double-cropping.

The aiogram 3 Handler

The bot runs on aiogram 3. Here's a simplified version of the video handler:

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

router = Router()

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

    file_id = (
        message.video.file_id if message.video
        else message.document.file_id if message.document
        else message.animation.file_id
    )

    with tempfile.TemporaryDirectory() as tmp:
        src = os.path.join(tmp, "input")
        dst = os.path.join(tmp, "avatar.mp4")

        bot_file = await message.bot.get_file(file_id)
        await message.bot.download_file(bot_file.file_path, src)

        result = encode_for_avatar(src, dst)

        if result["ok"]:
            await message.answer_video(
                FSInputFile(dst),
                caption="Ready. Set this as your video avatar in Telegram Settings."
            )
        else:
            await message.answer(f"Encoding failed: {result['error']}")

    await status.delete()


def encode_for_avatar(src: str, dst: str) -> dict:
    cmd = [
        "ffmpeg", "-y", "-i", src,
        "-vf", (
            "scale=800:800:force_original_aspect_ratio=increase,"
            "crop=800:800,"
            "format=yuv420p"
        ),
        "-c:v", "libx264",
        "-preset", "fast",
        "-crf", "26",
        "-t", "10",
        "-an",
        "-movflags", "+faststart",
        dst
    ]
    proc = subprocess.run(cmd, capture_output=True, timeout=60)
    if proc.returncode != 0:
        return {"ok": False, "error": proc.stderr.decode()[-200:]}

    size = os.path.getsize(dst)
    if size > 2 * 1024 * 1024:
        return {"ok": False, "error": f"Output too large: {size // 1024}KB"}

    return {"ok": True}
Enter fullscreen mode Exit fullscreen mode

The explicit size check at the end matters. CRF 26 is not a bitrate ceiling. High-motion clips can still produce files over 2MB. In production I retry with CRF 32 plus -maxrate 1.4M if the first pass exceeds the limit. That covers 99% of inputs.

Packaging It as @liveavabot

The bot is live at https://t.me/LiveAvaBot?start=devto_article_20260925. Send it a video, HEVC recording, or GIF, and it replies with an 800x800 H.264 MP4 ready to set as a video avatar.

The stack is thin: aiogram 3, ffmpeg, Python 3.11, running on a 2-core VPS. No GPU. Encoding a 10s clip takes 2-3 seconds on this hardware, which is fast enough that users rarely notice the wait.

One thing I didn't anticipate: GIFs are a significant chunk of traffic. People want to convert animated stickers and reaction GIFs into video avatars. ffmpeg handles these without any special casing, since it reads GIF as a video input.

The bot has 428 users now. Growth has been entirely organic, mostly from Telegram groups where someone posts their new video avatar and others ask how they made it.

Built by me: @liveavabot

Edge Cases Worth Knowing

HEVC from non-iPhone sources. Android devices in "high efficiency" mode, screen recorders, some mirrorless cameras. The pipeline doesn't care about the source. If ffmpeg decodes it, the bot handles it.

Oversized output. My two-attempt retry (CRF 26, then CRF 32 with maxrate) handles most cases. The only real failures are extreme slow-motion clips with very high frame counts, which I haven't seen in practice yet.

GIF transparency. Some GIFs use palette entry 0 as transparent. When converted to yuv420p, those areas render black. The correct fix is compositing over a solid background before encoding. I know about this bug but haven't shipped the fix yet.

Source longer than 10 seconds. ffmpeg's -t 10 trims to the first 10 seconds. Users sometimes expect a clip selector. I added a note in the reply when the source exceeds 10s so people know only the first 10s were used.

What's next: considering a small web UI for people who want to convert videos for Telegram without using Telegram. The irony is not lost on me, but the demand shows up in feedback messages regularly.

Top comments (0)