DEV Community

liveavabot
liveavabot

Posted on

Why iPhone Videos Fail as Telegram Avatars and How I Fixed It

Last month a friend tried to set a video from her iPhone as her Telegram profile video. Telegram accepted the upload, spun for a second, then quietly did nothing. No error message. The video just never appeared.

The cause is boring: iPhones have recorded HEVC (H.265) by default since iOS 11, and Telegram's video avatar pipeline only accepts H.264. Telegram never tells you this. It fails silently, and you're left assuming your video is somehow broken.

I spent an evening figuring out the exact requirements, wrote an ffmpeg wrapper around them, and ended up shipping the whole thing as a Telegram bot. This is the build log.

What Telegram Actually Requires

The docs describe video avatars only vaguely, so most of this came from testing uploads until they stuck:

  • Container: MP4
  • Video codec: H.264, pixel format yuv420p (8-bit, this matters)
  • Aspect: square, 800x800 works well since avatars render inside a circle
  • Duration: 10 seconds max
  • File size: around 2 MB, stay under it to be safe
  • Audio: none, a file with an audio track gets rejected

The killer combination for iPhone footage is codec plus pixel format. Modern iPhones shoot HEVC in yuv420p10le (10-bit) when HDR is on. Even after transcoding to H.264, keeping 10-bit color makes some clients render green garbage instead of video. You need both libx264 and an explicit format=yuv420p.

The ffmpeg Pipeline

The conversion is a center crop to square, a scale to 800x800, a pixel format fix, and an H.264 encode with the audio stripped. I also run cropdetect first, because screen recordings and downloaded clips often arrive letterboxed, and black bars look terrible inside a circular avatar.

# pass 1: detect black bars (sample the first 3 seconds)
ffmpeg -i input.mov -t 3 -vf "cropdetect=24:16:0" -f null - 2>&1 \
  | grep -o "crop=[0-9:]*" | tail -1

# pass 2: center-crop to square, scale, force 8-bit H.264, drop audio
ffmpeg -i input.mov -t 10 \
  -vf "crop='min(iw,ih)':'min(iw,ih)',scale=800:800,fps=30,format=yuv420p" \
  -c:v libx264 -profile:v main -preset slow -crf 26 \
  -movflags +faststart -an -y output.mp4
Enter fullscreen mode Exit fullscreen mode

The crop expression takes the largest centered square regardless of orientation. format=yuv420p downgrades 10-bit HDR to 8-bit, which every Telegram client can decode. -an removes the audio track, which is mandatory for avatars. -movflags +faststart moves the moov atom to the front of the file so playback starts without a full download.

-crf 26 with -preset slow usually lands a 10 second clip around 1.2 to 1.6 MB. For the 2 MB cap I don't do bitrate math. I check the output size and re-encode with CRF bumped by 3 if it's over. Two retries have covered everything users have thrown at the bot so far.

The aiogram 3 Handler

The bot side is small. aiogram 3 magic filters let one handler catch videos, GIFs (Telegram converts those to silent MP4 "animations" on upload), and video files sent as documents:

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

router = Router()

VF = "crop='min(iw,ih)':'min(iw,ih)',scale=800:800,fps=30,format=yuv420p"

@router.message(F.video | F.animation | F.document.mime_type.startswith("video/"))
async def convert(message: Message, bot: Bot):
    src_file = message.video or message.animation or message.document
    if src_file.file_size > 20 * 1024 * 1024:
        await message.answer("Over 20 MB, Bot API won't let me download that.")
        return

    with tempfile.TemporaryDirectory() as tmp:
        src = os.path.join(tmp, "in.bin")
        dst = os.path.join(tmp, "avatar.mp4")
        await bot.download(src_file, destination=src)

        proc = await asyncio.create_subprocess_exec(
            "ffmpeg", "-i", src, "-t", "10", "-vf", VF,
            "-c:v", "libx264", "-profile:v", "main",
            "-preset", "slow", "-crf", "26",
            "-movflags", "+faststart", "-an", "-y", dst,
        )
        await proc.wait()
        if proc.returncode != 0:
            await message.answer("ffmpeg choked on that file, try another one.")
            return

        await message.answer_video(
            FSInputFile(dst),
            caption="Done. Settings > Edit profile > Set new photo > pick this video.",
        )
Enter fullscreen mode Exit fullscreen mode

Two boring details that matter. Standard Bot API servers cap downloads at 20 MB, so I reject bigger files up front instead of failing halfway through. And the subprocess uses create_subprocess_exec, not shell=True, so filenames never touch a shell. In production I also wrap proc.wait() in asyncio.wait_for with a 120 second timeout, because a corrupt file can make ffmpeg hang forever.

Shipping It as a Bot

I packaged this as @LiveAvaBot. You send a video or GIF, it replies with a ready avatar file in 10 to 20 seconds, and Telegram accepts the result as a profile video. It runs on a small VPS under systemd, nothing exotic.

Numbers so far: 215 users, about 7 conversions a day this week. Tiny, but people come back with a second video after the first one works, which tells me the pain is real. The most common input is exactly the case that started this whole thing: HEVC clips straight off an iPhone.

Edge Cases That Bit Me

Rotation metadata. Phones store rotation as a display matrix instead of rotating pixels. Modern ffmpeg autorotates on decode, but if you write filter logic that compares iw and ih yourself, do it after autorotation or portrait videos come out sideways.

10-bit HDR. Worth repeating because the failure mode is nasty: the file uploads fine, then renders green or black frames on some clients. format=yuv420p, always.

GIFs are not GIFs. Telegram converts uploaded GIFs to silent MP4s, so those are already halfway to spec. Real .gif files sent as documents still work, ffmpeg decodes them fine.

Very short clips. A 0.8 second video makes a jarring avatar loop. I don't pad these yet, I just warn the user.

Next up: 4K 60fps input is slow with -preset slow on a 2 vCPU box, sometimes 90 seconds per file. I'll probably drop long inputs to -preset medium and add a job queue instead of converting inline.

Disclosure: built by me, @liveavabot is my own project and free to use. If you've hit the silent avatar rejection, send the bot your weirdest iPhone clip and tell me if it survives.

Top comments (0)