DEV Community

liveavabot
liveavabot

Posted on

Why iPhone HEVC Videos Fail as Telegram Video Avatars

The Problem No One Documents

I was building a Telegram bot and hit a specific wall: users sending iPhone videos couldn't set them as video avatars. Telegram accepted the upload fine but silently rejected the avatar. No error message. No indication of what went wrong.

The culprit: iPhone records in HEVC (H.265) by default since the iPhone 12. Telegram's video avatar feature requires H.264. There's no error because Telegram doesn't surface one. It just doesn't apply the avatar.

This happens silently on every iPhone that hasn't manually set the camera to "Most Compatible" in Settings. That's a lot of users.

What Telegram Actually Requires

The video avatar spec isn't documented in one place. I pieced it together through trial, error, and reading client source code:

  • Codec: H.264 (libx264), yuv420p color space
  • Resolution: exactly 800x800 pixels
  • Duration: 10 seconds maximum
  • File size: 2MB maximum
  • Audio: must be stripped completely
  • Container: MP4 with faststart flag (MOOV atom at file start)

Every constraint applies simultaneously. A 1920x1080 H.264 video fails on resolution. A square 4K HEVC clip fails on both codec and resolution. The spec is strict and Telegram enforces all of it without telling the user which part failed.

The yuv420p requirement catches people too. Some encoders default to yuv444p or yuv422p. Telegram rejects those silently as well.

The ffmpeg Pipeline

The conversion needs to: detect and crop black bars, scale to 800x800, transcode to H.264, strip audio, enforce the 10-second cap, and stay under 2MB. I split this into two passes to get clean crops on non-square source material.

Pass 1: Crop Detection

Many videos have black bars from aspect ratio mismatches. Scale without cropping and the 800x800 avatar has bars on two sides. ffmpeg's cropdetect filter analyzes the first few seconds and returns the actual content bounds:

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

Output looks like: crop=1080:1080:0:0. Parse that string and pass it to the encode step. The three parameters to cropdetect are: luminance threshold (24), rounding (16), and skip frames at start (0). Default values work well for most content.

Pass 2: Encode

ffmpeg -i input.mp4 \
  -vf "crop=1080:1080:0:0,scale=800:800:flags=lanczos,fps=30" \
  -t 10 \
  -c:v libx264 -preset fast -crf 28 \
  -pix_fmt yuv420p \
  -an \
  -movflags +faststart \
  -y output.mp4
Enter fullscreen mode Exit fullscreen mode

crop=1080:1080:0:0 uses the detected content bounds from pass 1. scale=800:800:flags=lanczos resizes to the exact target using Lanczos resampling. fps=30 normalizes inconsistent frame rates. -t 10 hard caps duration at 10 seconds.

-c:v libx264 -preset fast -crf 28 encodes H.264 at quality level 28. Lower CRF (23-25) gives higher quality and is safe for short clips. Higher CRF (32) compresses more aggressively when you need to hit the 2MB ceiling. -pix_fmt yuv420p is required for cross-device compatibility. -an strips all audio tracks. -movflags +faststart moves the MOOV atom to the file start so Telegram can stream it correctly.

CRF 28 keeps most 10-second clips under 2MB. Dense motion content sometimes pushes over. In that case I retry at CRF 32.

The aiogram 3 Handler

I used aiogram 3 for the bot. The handler accepts video files, GIFs (sent as animations), and raw documents in one route.

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

router = Router()

async def convert_to_avatar(input_path: str, output_path: str) -> bool:
    probe = await asyncio.create_subprocess_exec(
        'ffmpeg', '-ss', '0', '-t', '3', '-i', input_path,
        '-vf', 'cropdetect=24:16:0', '-f', 'null', '-',
        stderr=asyncio.subprocess.PIPE
    )
    _, stderr = await probe.communicate()

    crop = 'iw:ih:0:0'
    matches = re.findall(r'crop=(\d+:\d+:\d+:\d+)', stderr.decode())
    if matches:
        crop = matches[-1]

    proc = await asyncio.create_subprocess_exec(
        'ffmpeg', '-i', input_path,
        '-vf', f'crop={crop},scale=800:800:flags=lanczos,fps=30',
        '-t', '10',
        '-c:v', 'libx264', '-preset', 'fast', '-crf', '28',
        '-pix_fmt', 'yuv420p', '-an',
        '-movflags', '+faststart',
        '-y', output_path,
        stderr=asyncio.subprocess.PIPE
    )
    await proc.communicate()

    if not os.path.exists(output_path):
        return False
    if os.path.getsize(output_path) > 2 * 1024 * 1024:
        return False
    return True


@router.message(F.video | F.animation | F.document)
async def handle_video(message: Message):
    file = message.video or message.animation or message.document
    if not file:
        return

    with tempfile.TemporaryDirectory() as tmp:
        input_path = os.path.join(tmp, 'input.mp4')
        output_path = os.path.join(tmp, 'avatar.mp4')

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

        ok = await convert_to_avatar(input_path, output_path)
        if not ok:
            await message.reply('Conversion failed. Try a shorter or smaller file.')
            return

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

        await message.reply_video(
            BufferedInputFile(data, filename='avatar.mp4'),
            caption='Ready. Set this as your video avatar in Telegram Profile settings.'
        )
Enter fullscreen mode Exit fullscreen mode

The two-pass approach adds 1-2 seconds to processing but avoids black bars on non-square sources. asyncio handles concurrent requests without blocking on I/O.

@liveavabot: The Hosted Version

I packaged this pipeline as https://t.me/LiveAvaBot?start=devto_article_20261018. Send it any video or GIF and it returns an 800x800 H.264 MP4 ready to set as a Telegram video avatar.

It handles iPhone HEVC recordings, landscape and portrait sources, animated GIFs, and anything else ffmpeg can decode. Most conversions finish in 3-8 seconds. The bot runs on a VPS with ffmpeg installed system-wide and asyncio handles concurrent requests.

Built by me: @LiveAvaBot.

Edge Cases Worth Knowing

File size over 2MB. Short clips at CRF 28 are usually fine. Dense motion content at 10 seconds sometimes pushes over. I retry at CRF 32 if the first pass is oversized. If CRF 32 still fails, the source is probably very long or very high resolution and the user should trim it first.

Animated GIFs. ffmpeg treats them as video, so the pipeline works unchanged. GIFs often have inconsistent frame rates (5-15 fps). The fps=30 filter normalizes this without artifacts.

Portrait vs landscape. The cropdetect pass handles both orientations correctly. A 9:16 portrait phone recording crops to a square and scales to 800x800 without distortion.

Corrupt or truncated files. ffmpeg exits non-zero and the bot replies with an error. No retry since there's nothing to fix server-side.

4K source videos. Processing takes 10-15 seconds, noticeably longer than 1080p. I'm considering a fast scale-down pre-pass for anything over 1080p before running cropdetect.

The ffmpeg pipeline is the hard part. Once that's working and tested, the aiogram handler is straightforward plumbing around it. The two-pass crop approach is the thing I'd add earlier if I was doing this again.

Top comments (0)