DEV Community

liveavabot
liveavabot

Posted on

Why iPhone Videos Silently Fail as Telegram Avatars (FFmpeg Fix)

Last month I tried to set a video avatar on Telegram using a clip from my iPhone. The upload ran, the progress bar filled, and then nothing. No error message. The old avatar just stayed there.

It took me an evening to figure out why. iPhones record HEVC (H.265) by default since iOS 11, and Telegram's video avatar pipeline only accepts H.264. Instead of telling you that, the client silently drops the file. This post is the build log of how I fixed it with ffmpeg and then wrapped the fix into a Telegram bot.

What Telegram Actually Requires

The video avatar spec is not written down in one place. I pieced it together from the Bot API docs, the Telegram Desktop source, and trial and error:

  • Video codec: H.264, pixel format yuv420p (8-bit, not 10-bit)
  • Container: MP4
  • Resolution: square, 800x800 works everywhere
  • Duration: 10 seconds max
  • File size: roughly 2 MB before clients start refusing
  • Audio: none. A file with an audio track gets rejected
  • moov atom at the front (faststart), or mobile clients stall

An iPhone clip breaks almost every line of that list. It's HEVC, it's 1080x1920 portrait, it's often 10-bit HDR (yuv420p10le), it has an audio track, and a 10 second clip weighs 20+ MB.

The FFmpeg Pipeline

There are two problems to solve: geometry (make it square) and encoding (make it H.264 under 2 MB).

For screen recordings and letterboxed clips I first run cropdetect to strip black bars, then center-crop to a square:

# pass 1: detect black bars on 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: crop to square, scale, encode, strip audio
ffmpeg -i input.mov -t 10 \
  -vf "crop='min(iw,ih)':'min(iw,ih)',scale=800:800,format=yuv420p" \
  -c:v libx264 -profile:v high -preset medium -crf 26 \
  -an -movflags +faststart -y output.mp4
Enter fullscreen mode Exit fullscreen mode

Notes on the flags, because each one earns its place:

  • crop='min(iw,ih)':'min(iw,ih)' center-crops to a square. When you omit the x and y arguments, ffmpeg centers the crop for you.
  • format=yuv420p is the fix for iPhone HDR clips. They come in as yuv420p10le, and 10-bit H.264 breaks Telegram playback.
  • -an drops the audio track. Without it the avatar upload fails.
  • -crf 26 lands a 10 second 800x800 clip at 1.2 to 1.8 MB most of the time. If the output is still over 2 MB, I retry with crf+4.
  • -movflags +faststart moves the moov atom to the front so the avatar starts playing before the whole file is fetched.

The Bot Handler in aiogram 3

The manual pipeline worked, but I didn't want to ssh into a box every time I change an avatar. So: Telegram bot. Here's a minimal aiogram 3 handler:

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

router = Router()

@router.message(F.video | F.animation)
async def convert(message: Message, bot: Bot):
    file = message.video or message.animation
    if file.file_size and file.file_size > 50 * 1024 * 1024:
        await message.answer("Over 50 MB, the Bot API won't let me download that.")
        return

    with tempfile.TemporaryDirectory() as tmp:
        src = pathlib.Path(tmp, "src.bin")
        dst = pathlib.Path(tmp, "avatar.mp4")
        await bot.download(file, destination=src)

        proc = await asyncio.create_subprocess_exec(
            "ffmpeg", "-i", str(src), "-t", "10",
            "-vf", "crop='min(iw,ih)':'min(iw,ih)',scale=800:800,format=yuv420p",
            "-c:v", "libx264", "-preset", "medium", "-crf", "26",
            "-an", "-movflags", "+faststart", "-y", str(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), width=800, height=800)
Enter fullscreen mode Exit fullscreen mode

ffmpeg is doing the heavy lifting here, I just wrote the wrapper. Two details worth copying: run ffmpeg through create_subprocess_exec so the event loop doesn't block during encoding, and use a TemporaryDirectory so failed conversions don't pile up on disk.

Shipping It as @liveavabot

I cleaned this up and published it as @LiveAvaBot. You send it a video or a GIF, it replies with the converted file, and you set that as your avatar from Telegram settings. It also handles GIFs (Telegram stores them as silent mp4 already, so those mostly need a crop and a rescale) and documents with video mime types, since forwarded videos sometimes arrive that way.

It sits at 230 users now. Not a big number, and the usage pattern is funny: most people convert one file and disappear for a month. It's a tool, not an app, and I've stopped pretending otherwise.

Edge Cases That Bit Me

  • Rotation metadata. iPhones record portrait as landscape plus a rotate flag. Modern ffmpeg autorotates, but if you compute crop geometry from the raw stream width and height, you'll crop the wrong axis. Let the filter chain handle it.
  • 10-bit HDR. Covered above, but it deserves repeating: if your output "looks fine in VLC but Telegram rejects it", check pix_fmt first.
  • The 2 MB budget. Grainy low-light clips blow past it even at crf 26. My retry loop bumps crf by 4 each attempt and caps at 34 before giving up.
  • The Bot API download cap. Files over 50 MB can't be downloaded through the standard Bot API at all. A self-hosted Bot API server lifts this. I haven't bothered yet.

Next on the list is a job queue: 4K 60fps input encodes slowly on my small VPS, and two simultaneous users can make each other wait.

Disclosure: I built @liveavabot myself, and it's free to use.

If you've fought the Telegram avatar spec and hit traps I missed, tell me in the comments.

Top comments (0)