Why iPhone Videos Fail as Telegram Avatars

Telegram added animated profile pictures a while back. You can set a short video as your avatar. In the app it looks great. But if you try to upload a video recorded on an iPhone, Telegram silently rejects it. No error message, no feedback. The upload just sits there, then fails.

I ran into this while building a side project. The issue is HEVC (H.265). iPhone has been recording in HEVC by default since iOS 11. Telegram's video avatar spec requires H.264. The app doesn't tell you this. It just fails.

So I built a Telegram bot that handles the conversion.

What Telegram Actually Requires

The spec, pieced together from the API docs and a lot of trial and error:

  • Codec: H.264 (libx264), yuv420p pixel format
  • Resolution: exactly 800x800 pixels, square
  • Duration: 10 seconds maximum
  • File size: 2 MB maximum
  • Audio: none (must be stripped)

All five conditions need to hold. Miss one and the upload silently fails. The size limit is the sneaky one: a 10-second 800x800 H.264 video at default ffmpeg settings can easily hit 4-5 MB. You need to tune the bitrate.

The ffmpeg Pipeline

Here's the core command. It does everything in one pass:

ffmpeg -i input.mov \ -vf "cropdetect,crop='iw:ih',scale=800:800:force_original_aspect_ratio=disable" \ -c:v libx264 -profile:v baseline -level 3.1 \ -pix_fmt yuv420p \ -b:v 800k -maxrate 800k -bufsize 1600k \ -t 10 \ -an \ -movflags +faststart \ output.mp4 

Breaking it down:

  • cropdetect + crop='iw:ih' handles letterboxed or portrait videos. It detects black bars and crops them before scaling.
  • scale=800:800:force_original_aspect_ratio=disable scales the result to exactly 800x800. Telegram center-crops on display so this works fine.
  • -profile:v baseline -level 3.1 ensures the widest device compatibility.
  • -pix_fmt yuv420p is mandatory. Without it, some encoders pick yuv444p and Telegram silently rejects the file.
  • -b:v 800k keeps the output under 2 MB for a 10-second clip with buffer to spare.
  • -t 10 hard-caps at 10 seconds.
  • -an strips audio entirely.
  • -movflags +faststart moves the moov atom to the front of the file. Telegram requires this to start streaming before the full file lands.

I tested this on iPhone 14 Pro videos (HEVC, HDR), Android H.264 videos, and desktop screen recordings. All convert cleanly.

The aiogram 3 Handler

Here's a stripped-down version of the bot handler:

import asyncio import os import tempfile from aiogram import Bot, Router, F from aiogram.types import Message router = Router() VF_FILTER = "cropdetect,crop='iw:ih',scale=800:800:force_original_aspect_ratio=disable" @router.message(F.video | F.document | F.video_note) async def handle_video(message: Message, bot: Bot): file_id = None if message.video: file_id = message.video.file_id elif message.document and message.document.mime_type.startswith("video/"): file_id = message.document.file_id elif message.video_note: file_id = message.video_note.file_id if not file_id: return await message.reply("Converting...") with tempfile.TemporaryDirectory() as tmpdir: input_path = os.path.join(tmpdir, "input.mp4") output_path = os.path.join(tmpdir, "output.mp4") file = await bot.get_file(file_id) await bot.download_file(file.file_path, input_path) cmd = [ "ffmpeg", "-i", input_path, "-vf", VF_FILTER, "-c:v", "libx264", "-profile:v", "baseline", "-level", "3.1", "-pix_fmt", "yuv420p", "-b:v", "800k", "-maxrate", "800k", "-bufsize", "1600k", "-t", "10", "-an", "-movflags", "+faststart", output_path, "-y" ] proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, ) _, stderr = await proc.communicate() if proc.returncode != 0: await message.reply("Conversion failed. Is this a valid video file?") return size = os.path.getsize(output_path) if size > 2 * 1024 * 1024: await message.reply(f"Output is {size // 1024} KB, over the 2 MB limit.") return with open(output_path, "rb") as f: await message.reply_document(f, filename="avatar.mp4") 

The handler accepts video, video_note, and document types. GIFs come in as document with mime_type=video/mp4 because Telegram converts them server-side before delivery. The TemporaryDirectory context manager cleans up automatically.

One thing worth noting: aiogram 3 uses magic filters (F.video) rather than the string key syntax from v2. If you're migrating from v2, expect to rewrite most of your filter expressions.

I'm using create_subprocess_exec with a list rather than create_subprocess_shell with a string. This avoids shell quoting issues entirely, which matters because the -vf value contains both single and double quotes.

@liveavabot

I packaged this into @LiveAvaBot. Send it any video or GIF and it returns an 800x800 H.264 mp4 ready to upload directly as a Telegram profile video.

488 users so far. The bot handles around 7 conversions per day. It runs on a small VPS with ffmpeg installed.

I added a free tier (3 conversions per day) and a paid tier via Telegram Stars for heavier users. The Stars integration in aiogram 3 is straightforward: a PreCheckoutQuery handler, an invoice with LabeledPrice, and that's basically it.

Edge Cases and What's Next

HDR input from iPhone 14+ uses HDR10 or Dolby Vision. The conversion strips the color profile and tone-maps to SDR implicitly. The output looks slightly desaturated on some clips. I haven't added explicit -vf zscale tone-mapping yet, but it's on the list.

4K input (3840x2160) converts fine, but ffmpeg takes a few extra seconds per clip. I don't currently check file size before accepting the upload, so a 500 MB 4K video would sit in the queue for a while. A file size guard on the Document handler is the next thing to add.

Very short clips (under 1 second) pass through cleanly but look odd as a looping avatar. I don't filter these out.

The bot runs on CPU-only hardware. libx264 at 800x800 takes about 1-2 seconds per clip, which is fine at current load. I'd need hardware acceleration if volume picks up significantly.

Built by me. Try it: @LiveAvaBot