DEV Community

liveavabot
liveavabot

Posted on

Why iPhone Videos Fail as Telegram Avatars (and How to Fix It)

The Video That Won't Upload

You recorded a clip on your iPhone, tried to set it as your Telegram video avatar, and nothing happened. No error. Just a spinner. You trimmed it. Still nothing. You exported it again. Nothing.

The problem is HEVC. iPhones default to HEVC (H.265) since iOS 11, and Telegram's video avatar endpoint rejects H.265 silently. No feedback to the user. The video just doesn't apply.

I hit this myself when building a small tool. Then I read the actual spec.

What Telegram Actually Requires

Telegram's video avatar has strict requirements. Most aren't documented in one place:

  • Codec: H.264 (libx264), not H.265/HEVC
  • Container: MP4
  • Resolution: exactly 800×800 pixels, square
  • Duration: 10 seconds maximum
  • File size: 2 MB maximum
  • Audio: none (strip with -an)
  • Pixel format: yuv420p
  • Streaming: moov atom at front (-movflags +faststart)

The audio requirement trips people up. Even a video with no audible sound will fail if the container has an empty audio stream. You have to explicitly drop it.

Converting With ffmpeg

ffmpeg handles all of this. The tricky part is the crop. Vertical phone videos need centering before squaring, and some have black borders from export.

A two-pass approach: first detect the actual content bounds, then encode to spec.

Step 1: detect crop bounds

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

This outputs something like crop=1080:1080:0:120. The cropdetect filter finds where actual image content starts and ends.

Step 2: encode to Telegram spec

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

Breaking that down:

  • crop=1080:1080:0:120 uses the bounds from step 1 (adjust per video)
  • scale=800:800:flags=lanczos resizes to exactly 800×800
  • format=yuv420p forces the pixel format
  • -c:v libx264 sets the H.264 encoder
  • -crf 28 trades quality for size (23 is ffmpeg default, 28 gets most clips under 2 MB)
  • -an drops audio, no exceptions
  • -t 10 caps at 10 seconds
  • -movflags +faststart moves the moov atom to front for streaming

For most iPhone clips (1080×1920), you can skip cropdetect entirely and hardcode crop=1080:1080:0:420 for a center-square crop.

The Aiogram 3 Handler

The bot receives a video or GIF, runs ffmpeg, sends the result back. Here's the core handler:

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

router = Router()

FFMPEG_CMD = [
    "ffmpeg", "-y", "-i", "{input}",
    "-vf", "crop=ih:ih,scale=800:800:flags=lanczos,format=yuv420p",
    "-c:v", "libx264", "-crf", "28", "-preset", "fast",
    "-an", "-t", "10", "-movflags", "+faststart",
    "{output}"
]

@router.message(F.video | F.animation | F.document)
async def handle_video(message: Message):
    file_id = (
        message.video or message.animation or message.document
    ).file_id

    await message.answer("Converting...")

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

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

        cmd = [
            part.replace("{input}", input_path).replace("{output}", output_path)
            for part in FFMPEG_CMD
        ]

        proc = await asyncio.create_subprocess_exec(
            *cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        _, stderr = await proc.communicate()

        if proc.returncode != 0 or not os.path.exists(output_path):
            await message.answer("Conversion failed. Is this a valid video?")
            return

        size = os.path.getsize(output_path)
        if size > 2 * 1024 * 1024:
            await message.answer(f"File still too large ({size // 1024} KB). Try a shorter clip.")
            return

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

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

The crop=ih:ih filter takes a center square without a cropdetect pre-pass. Works for most phone videos. For wide landscape clips with black bars, add a cropdetect step first.

Packaging It as @liveavabot

I wrapped this into a production bot, currently at 398 users. The core logic is exactly the code above, plus:

  • A queue so multiple simultaneous uploads don't fork a hundred ffmpeg processes
  • Size checks before downloading (Telegram caps bot file downloads at 20 MB)
  • GIF support (Telegram delivers GIFs as animation type, same handler path)
  • A /start message with explicit instructions

The ffmpeg piece is straightforward. Most of the production work was handling people who send 200 MB screen recordings and videos that are already H.264 but still fail because of an empty audio stream.

Built by me: @LiveAvaBot.

Edge Cases and Lessons

HEVC with Dolby Vision: some recent iPhones tag clips with Dolby Vision HDR metadata. libx264 doesn't support it and ffmpeg fails on the HDR pass. Adding -vf "zscale=transfer=709:primaries=709:matrix=709,format=yuv420p" before the scale step converts colorspace first. Rare, but worth knowing.

GIFs use more temp disk than you expect: a 3-second GIF can decode to 150 MB of raw frames before re-encoding. ffmpeg handles it, but disk usage spikes. Keep temp dirs on SSD if running this at any scale.

4K input is slow: the scale step on 4K source takes 10-15 seconds. Most avatar clips are phone-format anyway, so I haven't optimized for it. The bot accepts them but warns about conversion time.

CRF 28 isn't always enough: for dense 10-second 1080p clips, CRF 28 occasionally produces a file just over 2 MB. The bot checks size after conversion and replies with an error. Dropping to -t 8 or bumping to CRF 32 usually fixes it.

The main lesson: read the spec before debugging. Telegram's silent HEVC rejection is intentional on their end. ffmpeg is doing the heavy lifting here. I just wrote the wrapper.

Top comments (0)