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:180applies the cropdetect result. -
scale=800:800forces the square output. No-op if already square. -
format=yuv420pconverts 10-bit HDR to 8-bit. This is the critical flag for iPhone footage. -
-c:v libx264 -profile:v baseline -level 3.1forces H.264 compatible with Telegram. -
-crf 28controls quality. Lower = better quality, larger file. 28 stays comfortably under 2 MB for a 10-second 800x800 clip. -
-movflags +faststartmoves the moov atom to the file start. -
-anstrips all audio. -
-t 10hard 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.
