DEV Community

liveavabot
liveavabot

Posted on

Fixing iPhone HEVC for Telegram Video Avatars With ffmpeg

Why iPhone Videos Silently Fail as Telegram Avatars

Set an iPhone video as your Telegram video profile picture and you get... nothing. No error. Telegram accepts the upload, spins, then the avatar doesn't change. The video plays fine on your phone. It looks sharp. Telegram just quietly rejects it.

The culprit is HEVC (H.265). iPhones have recorded in HEVC by default since iOS 11. Telegram's video avatar spec requires H.264. Telegram doesn't surface a codec error, so users have no idea what went wrong. They assume the feature is broken.

I ran into this while building a small side-project. The fix is about 80 lines of Python wrapping ffmpeg.

What Telegram's Video Avatar Spec Requires

The Bot API documents this, but it's scattered across a few pages:

  • Codec: H.264 (libx264). Not H.265, VP9, or AV1.
  • Resolution: exactly 800x800 pixels.
  • Duration: 10 seconds maximum.
  • File size: 2 MB maximum.
  • Audio: must be absent. Telegram strips it server-side, but removing it saves bytes.
  • Container: MP4 with moov atom at the front (faststart flag).
  • Pixel format: yuv420p for broad decoder compatibility.

The 800x800 square crop is the hardest constraint. Phone videos are 16:9 or 9:16. You can't just scale, you need to crop to a square first.

The ffmpeg Pipeline

ffmpeg handles everything: crop to square, scale to 800x800, convert pixel format, encode H.264, strip audio, enforce duration.

For video files (including HEVC from iPhone):

ffmpeg -y -i input.mov \
  -vf "crop=min(iw\,ih):min(iw\,ih):0:(ih-iw)/2,scale=800:800,format=yuv420p" \
  -c:v libx264 -crf 28 -preset fast \
  -movflags +faststart \
  -an -t 10 \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

crop=min(iw,ih):min(iw,ih) takes the largest centered square. For a 1080x1920 portrait video, that's a 1080x1080 crop from center. -an removes the audio track entirely. -t 10 hard-stops at 10 seconds.

For GIFs, which lack a proper frame rate, you need one extra filter:

ffmpeg -y -i input.gif \
  -vf "fps=15,crop=min(iw\,ih):min(iw\,ih):0:0,scale=800:800,format=yuv420p" \
  -c:v libx264 -crf 28 -preset fast \
  -movflags +faststart \
  -an \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

fps=15 normalizes GIF frame timing before cropping. Without it, variable-framerate GIFs encode at wrong speeds.

CRF 28 gives acceptable quality and usually lands under 2 MB for a 10-second 800x800 clip. If it doesn't, retry logic tries CRF 32, then CRF 36, before giving up and telling the user to send a shorter clip.

The aiogram 3 Handler

The bot uses aiogram 3.x in webhook mode. The handler accepts video, animation (GIF), and document messages, then runs ffmpeg asynchronously via asyncio.create_subprocess_exec so the event loop stays unblocked.

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

router = Router()

@router.message(F.video | F.document | F.animation)
async def handle_video(message: Message):
    await message.answer("Converting, give me a second...")

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

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

        await message.bot.download(file_id, destination=src)

        proc = await asyncio.create_subprocess_exec(
            "ffmpeg", "-y", "-i", src,
            "-vf", "crop=min(iw\\,ih):min(iw\\,ih),scale=800:800,format=yuv420p",
            "-c:v", "libx264", "-crf", "28", "-preset", "fast",
            "-movflags", "+faststart", "-an", "-t", "10",
            dst,
            stderr=asyncio.subprocess.PIPE,
        )
        _, stderr = await proc.communicate()

        if proc.returncode != 0:
            await message.answer("ffmpeg failed. Is this a valid video file?")
            return

        if os.path.getsize(dst) > 2 * 1024 * 1024:
            await message.answer("Over 2 MB after conversion. Try a shorter clip.")
            return

        with open(dst, "rb") as f:
            await message.answer_video(
                BufferedInputFile(f.read(), filename="avatar.mp4"),
                caption="Done. Set this as your Telegram video avatar in profile settings.",
            )
Enter fullscreen mode Exit fullscreen mode

For 104 users, running ffmpeg jobs directly in the event loop is fine. At higher concurrency you'd want a worker pool with a job cap.

Shipping It and the Edge Cases I Didn't Expect

I packaged this as @liveavabot, running on a Hetzner VPS with systemd and nginx. Stateless per-request, no database needed for the core conversion flow.

A few things surprised me along the way.

Videos sent as video vs. document. When you send a video normally in Telegram, Telegram's servers transcode it before your bot ever sees it. HEVC becomes H.264 automatically. Problem solved? Not quite. Telegram's transcoding scales to around 640px wide, not 800x800. The bot then has to upscale, which hurts quality. If you send the file as a document, it arrives untouched. I added a note in /start: send as a file for best quality.

Portrait vs. landscape. The center square crop works for both orientations, but portrait videos (9:16) lose the sides and landscape (16:9) loses the top and bottom. For videos with important content near the edges, users get surprised. There's no great solution without a trim UI, which I haven't built yet.

GIFs with solid borders. Some meme GIFs have white or black letterbox borders baked in. The simple center crop handles this poorly. I experimented with ffmpeg's cropdetect filter to find the content bounds automatically, but it added around 500ms latency per file. I kept the simple crop and called it a known limit in the docs.

The 2 MB wall. A dense 10-second animated GIF at 800x800 can exceed 2 MB even at CRF 28. The retry logic at CRF 32 and CRF 36 catches most of these. If it still doesn't fit, I tell the user to send a clip under 6 seconds. That covers the remaining cases.

The bot is live at https://t.me/LiveAvaBot?start=devto_article_20260603. Currently 104 users, all organic.

Built by me: @liveavabot

Top comments (0)