The problem: iPhone videos silently die as Telegram avatars
Telegram lets you set a short video as your profile picture. It's a nice feature. But if you try to upload a video shot on an iPhone, it often just fails. No error, no explanation, the upload spinner runs and then nothing happens.
I hit this myself last month. Shot a 4-second clip, tried to set it as my avatar, got nothing. Turned out the file was HEVC (H.265), which iPhones use by default since iOS 11. Telegram's video avatar endpoint only accepts H.264. It doesn't tell you that. It just silently rejects.
So I built @LiveAvaBot. Send it any video or GIF, get back a file that Telegram will actually accept as your profile picture. Under the hood it's a thin wrapper around ffmpeg with some aiogram glue. This post is the technical write-up.
What Telegram's video avatar spec actually requires
I dug through the Bot API docs and did a bunch of testing. The undocumented reality:
- Codec: H.264 (yuv420p pixel format). HEVC gets rejected.
- Container: MP4 with faststart flag (moov atom at the front).
- Resolution: exactly 800x800 square. Non-square videos need cropping, not letterboxing.
- Duration: max 10 seconds. Anything longer gets truncated or rejected.
- File size: under 2MB. Bigger files fail silently.
- Audio: must be stripped. Even a silent audio track sometimes causes issues.
- Framerate: 30fps works, higher rates occasionally break.
The 2MB cap is the interesting one. It means you can't just crank the bitrate. You have to actually think about the encoding.
FFmpeg: cropdetect, scale, encode
The pipeline has two ffmpeg passes. First pass runs cropdetect to figure out where the actual content sits (some videos have black bars). Second pass does the real conversion.
ffmpeg -i input.mov -vf cropdetect=24:16:0 -f null - 2>&1 | \
grep -oE 'crop=[0-9:]+' | tail -1
That gives you something like crop=1080:1080:0:420. Feed it back into the encode:
ffmpeg -y -i input.mov \
-t 10 \
-vf "crop=1080:1080:0:420,scale=800:800:flags=lanczos,fps=30,format=yuv420p" \
-c:v libx264 -profile:v high -level 4.0 \
-pix_fmt yuv420p \
-b:v 1400k -maxrate 1600k -bufsize 3200k \
-movflags +faststart \
-an \
output.mp4
Breaking down the flags that matter:
-
-t 10caps duration at 10 seconds. -
crop=...uses the values from the detect pass. -
scale=800:800:flags=lanczosgets us to the required square. Lanczos is sharper than default bicubic. -
format=yuv420pinside the filter graph, plus-pix_fmt yuv420pon the output. Belt and suspenders. HEVC often uses yuv420p10le which Telegram hates. -
-b:v 1400ktargets the 2MB budget for 10 seconds. Adjust down for longer clips. -
-movflags +faststartputs the moov atom at the start of the file. Telegram needs this for streaming. -
-androps audio.
Bitrate math: 2MB is roughly 16 Mbit. Over 10 seconds that's a 1.6 Mbit/s ceiling. I aim for 1.4 Mbit/s target with a 1.6 Mbit/s cap, and the file lands around 1.7 to 1.8MB in practice. Enough headroom for the container overhead.
The aiogram 3 handler
Nothing fancy on the bot side. Download the incoming file, run the pipeline, send back the result.
from aiogram import F, Router
from aiogram.types import Message, FSInputFile
from pathlib import Path
import asyncio
import tempfile
router = Router()
async def run_ffmpeg(input_path: Path, output_path: Path) -> None:
detect = await asyncio.create_subprocess_exec(
"ffmpeg", "-i", str(input_path),
"-vf", "cropdetect=24:16:0", "-f", "null", "-",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await detect.communicate()
crop = parse_crop(stderr.decode()) or "in_w:in_h:0:0"
encode = await asyncio.create_subprocess_exec(
"ffmpeg", "-y", "-i", str(input_path),
"-t", "10",
"-vf", f"crop={crop},scale=800:800:flags=lanczos,fps=30,format=yuv420p",
"-c:v", "libx264", "-profile:v", "high", "-level", "4.0",
"-pix_fmt", "yuv420p",
"-b:v", "1400k", "-maxrate", "1600k", "-bufsize", "3200k",
"-movflags", "+faststart",
"-an",
str(output_path),
)
await encode.wait()
@router.message(F.video | F.animation | F.document)
async def handle_video(message: Message) -> None:
file = message.video or message.animation or message.document
if not file:
return
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
src = tmp_path / "src"
dst = tmp_path / "avatar.mp4"
await message.bot.download(file, destination=src)
await run_ffmpeg(src, dst)
await message.answer_video(
FSInputFile(dst),
caption="Ready. Long-press your profile picture in Telegram to set it."
)
The parse_crop helper is five lines of regex on the ffmpeg stderr, I won't bore you with it.
Points worth calling out:
-
F.video | F.animation | F.documentcatches all three ways Telegram delivers uploaded video content. GIFs come in asanimation. Compressed video isvideo. Uncompressed (send-as-file) isdocument. - Using
asyncio.create_subprocess_execinstead ofsubprocess.runkeeps the bot responsive under concurrent load. On my single-CPU VPS I can process 3 or 4 clips in parallel before ffmpeg starts thrashing. - The temporary directory is nuked on exit. No leftover files.
Packaging it as a bot
The actual bot runs on a small Hetzner box under systemd. aiogram 3 with long polling (no webhook, no reverse proxy, keeps ops simple). SQLite for the tiny amount of state I keep (user id, conversion count for basic analytics).
The whole thing is maybe 300 lines of Python plus the ffmpeg invocation. If you want to try it, send a video to the bot.
Edge cases and things that broke
A few things that cost me hours of debugging:
Portrait videos with bars. The cropdetect pass helps a lot, but sometimes it catches actual content as "black" if the video opens on a dark scene. Adding cropdetect=24:16:0,select='gt(t\,1)' skips the first second before detecting.
Live Photos. These are a HEIC still plus a short MOV. iPhone sends the MOV part when you share. Usually fine, but the MOV is often only 1.5 seconds and shows up jittery. I don't do anything special, just document it.
Slow-mo footage. iPhone slow-mo is 240fps internally but plays back at variable rate via metadata. ffmpeg respects the playback rate, so the output looks smooth. Was a pleasant surprise, not something I had to fix.
Audio track presence. Even with -an, some source files with weird audio containers cause the mux to complain. Adding -map 0:v:0 explicitly before -c:v forces video-only mapping and shuts that up.
What's next
Currently handling around 300 users with a couple of conversions per day. Things on my list:
- Batch mode: send multiple videos, get a zip back.
- 4K source handling. Right now anything over 1080p gets awkward memory usage during the crop pass.
- Optional audio on avatars for Telegram Premium users (spec allows it, I just haven't wired it up).
If you want to build something similar, ffmpeg is doing 95% of the work. I just wrote the wrapper and the retry logic for when an encode fails.
Built by me. Live at @LiveAvaBot.
Top comments (0)