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 

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 

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) 

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.