DEV Community

liveavabot
liveavabot

Posted on

Why Telegram Silently Rejects iPhone Videos as Avatars

The Problem: Your iPhone Video Gets Silently Rejected

You send a 4-second clip to set as your Telegram video avatar. Telegram accepts it, shows a spinner, then does nothing. No error message. The avatar never updates.

This happens to almost everyone shooting on iPhone. iOS 11+ records in HEVC (H.265) by default to save storage. Telegram's video avatar API requires H.264. When you upload HEVC, the API silently fails or returns a generic error with zero information about what went wrong.

I ran into this while building a bot that converts videos to Telegram avatars. The first version worked fine on test videos I encoded myself, then broke the moment any real user uploaded an iPhone recording.

What Telegram Actually Requires

The animated profile photo endpoint has hard constraints:

  • Codec: H.264 (libx264). No HEVC, no VP9, no AV1.
  • Pixel format: yuv420p (8-bit). No HDR, no 10-bit color.
  • Resolution: exactly 800x800. Square.
  • Duration: 5-10 seconds. Clips under 5 seconds need to be looped up to 5s.
  • File size: under 2 MB.
  • Audio: must be absent. A video with an audio stream is rejected.
  • Moov atom: must be at the start of the file (faststart).

Every item on that list can individually cause a silent failure. A typical iPhone video hits four of them at once: HEVC codec, possible 10-bit HDR color, arbitrary aspect ratio, and audio.

Solving It With ffmpeg

The pipeline runs in two steps: detect any letterboxing or pillarboxing, then encode.

First, get the crop parameters:

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

That outputs a crop=w:h:x:y string. Then the actual encode, substituting the crop result:

ffmpeg -i input.mov \
  -vf "crop=720:720:0:180,scale=800:800,format=yuv420p" \
  -c:v libx264 \
  -profile:v baseline \
  -level 3.1 \
  -crf 28 \
  -preset fast \
  -movflags +faststart \
  -an \
  -t 10 \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

What each flag does:

  • crop=720:720:0:180 applies the cropdetect result.
  • scale=800:800 forces the square output. No-op if already square.
  • format=yuv420p converts 10-bit HDR to 8-bit. This is the critical flag for iPhone footage.
  • -c:v libx264 -profile:v baseline -level 3.1 forces H.264 compatible with Telegram.
  • -crf 28 controls quality. Lower = better quality, larger file. 28 stays comfortably under 2 MB for a 10-second 800x800 clip.
  • -movflags +faststart moves the moov atom to the file start.
  • -an strips all audio.
  • -t 10 hard caps at 10 seconds.

For clips under 5 seconds, add -stream_loop -1 before -i and set -t 5 to loop the input up to the minimum duration.

The aiogram 3 Handler

I use aiogram 3. The handler downloads the file, runs ffmpeg as a subprocess, and uploads the result.

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

router = Router()

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

    file_id = (message.video or message.animation or message.document).file_id
    file = await message.bot.get_file(file_id)

    with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp_in:
        await message.bot.download_file(file.file_path, destination=tmp_in)
        input_path = tmp_in.name

    output_path = input_path.replace(".mp4", "_out.mp4")

    probe = subprocess.run(
        ["ffmpeg", "-i", input_path, "-vf", "cropdetect=24:16:0", "-f", "null", "-"],
        capture_output=True, text=True
    )
    crop = None
    for line in probe.stderr.splitlines():
        if "crop=" in line:
            crop = line.split("crop=")[1].split()[0]

    vf = f"crop={crop},scale=800:800,format=yuv420p" if crop else "scale=800:800,format=yuv420p"

    result = subprocess.run([
        "ffmpeg", "-y", "-i", input_path,
        "-vf", vf,
        "-c:v", "libx264", "-profile:v", "baseline", "-level", "3.1",
        "-crf", "28", "-preset", "fast",
        "-movflags", "+faststart",
        "-an", "-t", "10",
        output_path
    ], capture_output=True)

    if result.returncode != 0:
        await message.answer("Conversion failed. Try a shorter clip.")
        return

    with open(output_path, "rb") as f:
        await message.answer_video(
            BufferedInputFile(f.read(), filename="avatar.mp4"),
            caption="Ready. Set this as your Telegram video avatar."
        )

    os.unlink(input_path)
    os.unlink(output_path)
Enter fullscreen mode Exit fullscreen mode

This is close to the production handler, minus retry logic and file size validation. In practice I also check the output file size and retry with -crf 32 if it's over 2 MB.

How I Packaged This as @liveavabot

The bot has been running since early 2025. The flow is one step: send a video or GIF, get back an 800x800 H.264 mp4 under 2 MB. No account, no signup, no configuration.

At 495 users with a steady trickle of new ones each week, it's not a massive project. But every person who uses it ran into the same thing: Telegram rejecting their iPhone video with zero feedback. The bot exists because fixing that one specific problem is genuinely useful.

Try it: LiveAvaBot

Edge Cases Worth Knowing

Vertical video. Portrait iPhone clips are 1080x1920. cropdetect doesn't help here since there's no letterboxing to detect. I use scale=800:800:force_original_aspect_ratio=decrease,pad=800:800:(ow-iw)/2:(oh-ih)/2 to add black bars instead of stretching the frame.

GIFs. Telegram delivers GIFs as animation type, not video. Same pipeline handles them, but small GIFs (320x240) look blocky upscaled to 800. I added unsharp=5:5:0.8 after the scale filter to recover some detail.

Large files. A 4K iPhone clip at 10 seconds is around 200 MB. Downloading 200 MB per request isn't practical. I check file size before downloading and reject files over 50 MB with an explanation.

The moov atom. I forgot -movflags +faststart in the first version. Telegram's API silently failed on about 30% of videos. Adding that one flag fixed all of them.

Short clips. Telegram requires at least 5 seconds. I loop short clips with -stream_loop -1 -i input -t 5. Works for most GIFs. Complex ones sometimes show a visible jump at the loop point.

What I Would Do Differently

The ffmpeg call is synchronous inside an async handler. At current load that's fine. For thousands of concurrent users I'd move encoding to a task queue (arq or Celery). A 2-5 second blocking call is acceptable at low volume and painful at high volume.

I'd also use -ss seek before input for long files. It skips decoding the trimmed portion entirely and is noticeably faster than post-decode -t for large inputs.

For now it works. iPhone videos convert cleanly, GIFs loop correctly, and the moov atom is always in the right place.

Built by me. @liveavabot on Telegram.

Top comments (0)