DEV Community

liveavabot
liveavabot

Posted on

How I Built a Telegram Video Avatar Bot That Handles iPhone HEVC

Why Your iPhone Video Breaks as a Telegram Avatar

I wanted to set a video avatar on Telegram. Dropped my iPhone clip in. Nothing. Telegram just ignored it with no error, no feedback, just silent rejection.

Turns out Telegram has a strict spec for video avatars, and iPhone recordings don't meet it by default. This is what I built to fix that, and here's exactly how it works.

What Telegram Actually Requires

The Telegram API spec for video profile photos is specific:

  • Codec: H.264 (not HEVC/H.265)
  • Resolution: 800x800 pixels, square crop
  • Duration: 10 seconds max
  • File size: 2 MB max
  • Audio: none (must be stripped)

iPhone 12 and newer shoot in HEVC (H.265) by default. It's smaller, looks better at the same bitrate, but Telegram's video avatar parser doesn't accept it. No warning, just silent failure. Same issue with GIFs and some Android clips shot in non-square resolutions.

The fix is transcoding through ffmpeg.

The ffmpeg Pipeline

I spent a few hours iterating on the ffmpeg command. The tricky part is the crop: videos aren't always square, so you can't just scale blindly. You need to detect the relevant content area first, then crop and scale.

First pass gets the crop parameters:

ffmpeg -i input.mov \
  -vf "cropdetect=24:2:0" \
  -frames:v 30 -f null - 2>&1 | grep crop
Enter fullscreen mode Exit fullscreen mode

Then encode with the detected crop values:

ffmpeg -i input.mov \
  -vf "crop=<w>:<h>:<x>:<y>,scale=800:800:force_original_aspect_ratio=decrease,pad=800:800:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p" \
  -c:v libx264 -profile:v baseline -level 3.0 \
  -crf 28 -preset fast \
  -t 9.9 \
  -an \
  -movflags +faststart \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

Breaking that down:

  • crop=... uses cropdetect values to cut letterboxing or pillarboxing
  • scale=800:800 with force_original_aspect_ratio=decrease avoids stretching
  • pad=800:800 centers it on a black square canvas
  • setsar=1 fixes the sample aspect ratio so players don't misinterpret it
  • format=yuv420p ensures H.264 baseline compatibility (some encoders default to yuv444p)
  • -profile:v baseline -level 3.0 keeps it within Telegram's decoder constraints
  • -an strips audio
  • -movflags +faststart moves the moov atom to the front
  • -t 9.9 not 10.0: Telegram counts frames, and a 10.0s clip at 30fps can have 301 frames which gets rejected

In practice I run both passes via Python's asyncio.create_subprocess_exec, parse the crop= line from stderr, and feed those values into the encode command.

The Aiogram 3 Handler

The bot is built with aiogram 3. The handler accepts video and GIF messages, downloads the file, runs the ffmpeg pipeline, and sends back the result.

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

router = Router()

async def transcode_for_tg_avatar(input_path: str, output_path: str) -> bool:
    # Pass 1: cropdetect
    proc = await asyncio.create_subprocess_exec(
        "ffmpeg", "-i", input_path,
        "-vf", "cropdetect=24:2:0",
        "-frames:v", "30", "-f", "null", "-",
        stderr=asyncio.subprocess.PIPE,
    )
    _, stderr = await proc.communicate()
    crops = re.findall(r"crop=(\d+:\d+:\d+:\d+)", stderr.decode())
    crop = crops[-1] if crops else None

    vf_parts = []
    if crop:
        vf_parts.append(f"crop={crop}")
    vf_parts += [
        "scale=800:800:force_original_aspect_ratio=decrease",
        "pad=800:800:(ow-iw)/2:(oh-ih)/2",
        "setsar=1",
        "format=yuv420p",
    ]

    # Pass 2: encode
    proc2 = await asyncio.create_subprocess_exec(
        "ffmpeg", "-i", input_path,
        "-vf", ",".join(vf_parts),
        "-c:v", "libx264", "-profile:v", "baseline", "-level", "3.0",
        "-crf", "28", "-preset", "fast",
        "-t", "9.9", "-an", "-movflags", "+faststart",
        "-y", output_path,
        stderr=asyncio.subprocess.PIPE,
    )
    await proc2.communicate()
    return os.path.exists(output_path) and os.path.getsize(output_path) < 2_000_000

@router.message(F.video | F.animation | F.document)
async def handle_video(message: Message):
    file = message.video or message.animation or message.document
    with tempfile.TemporaryDirectory() as tmpdir:
        input_path = f"{tmpdir}/input"
        output_path = f"{tmpdir}/output.mp4"
        await message.bot.download(file, destination=input_path)
        ok = await transcode_for_tg_avatar(input_path, output_path)
        if ok:
            await message.answer_video(
                FSInputFile(output_path),
                caption="Set this as your video avatar in Profile settings.",
            )
        else:
            await message.answer("Couldn't process that file. Try a shorter clip under 30s.")
Enter fullscreen mode Exit fullscreen mode

A few things worth noting: the animation type in Telegram covers GIFs that Telegram auto-converts to mp4. You get those the same way as video. Documents can also be video files (when sent uncompressed), so I handle all three types.

If the output is still over 2 MB after the first encode pass (long clips at high resolution), I bump CRF to 32 and re-encode. That covers most cases without needing to trim the user's clip.

Packaging It as @liveavabot

Once the core ffmpeg logic worked locally, I wrapped it in a proper bot and deployed to a VPS. It's live at https://t.me/LiveAvaBot?start=devto_article_20261012.

The flow is simple: send a video or GIF, get back a ready-to-use mp4. No settings, no signup. After receiving the file, you go to Telegram Profile > Set Video Avatar and upload it.

I added HEVC detection via ffprobe to give a specific success message ("Your iPhone HEVC video was re-encoded to H.264") rather than generic output. People appreciate knowing why their file needed processing.

Currently 456 users have tried it. Most common input is iPhone 14/15 videos in HEVC and square GIFs that just need re-encoding to meet the spec.

Edge Cases I Hit

Rotation metadata. Some iPhone videos have rotate=90 in the container metadata but pixel data stored landscape. ffmpeg's -vf pipeline doesn't auto-rotate by default. I added -autorotate 1 as an input flag, which handles it on ffmpeg 4.3+.

Variable framerate. Some screen recordings come in VFR. H.264 baseline prefers CFR. Adding -vsync cfr before the output fixed duration mismatches I was seeing on certain clips.

The 9.9s cut. I mentioned this above but it bit me in testing. Cutting at exactly 10.0s at 30fps gives 300 or 301 frames depending on rounding, and Telegram's avatar upload rejects the latter. 9.9s is a cheap fix.

Next: I want to test sending the output back as a document (uncompressed) instead of as a video message, to prevent Telegram's client from re-compressing it during the avatar upload step. Still validating whether that compression step actually degrades quality enough to matter.

Built by me: LiveAvaBot on Telegram.

Top comments (0)